From a50d279cba5647e0e5db1f58539c97f2d8a5c618 Mon Sep 17 00:00:00 2001 From: kubuntugamer <52578831+kubuntugamer@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:35:34 -0500 Subject: [PATCH 001/238] test(datamerge): pin facing-page record placement (#198 no longer reproduces) (#258) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(datamerge): pin facing-page record placement (#198) #198 reported that with `facingPages: true` the merged records after the first landed at identical coordinates on one spread, leaving a later page empty. That no longer reproduces: `data.merge` now builds a new document rather than duplicating pages inside the template, and the three records land on absolute pages 0, 1 and 2 with no overlap. Nothing in the engine changed here. These two tests pin the behaviour the issue asked for, both at the one-record-per-page plan and at the short block that tiles, so a future refactor of `tile_records` can't quietly bring the stacking back. Refs #198 🤖 Generated with Codebuff Co-Authored-By: Codebuff * Merge origin/main; the facing-pages merge tests find each frame's page with page_of_item Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Codebuff Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/datamerge/tests.rs | 63 ++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/crates/engine/src/cmd/datamerge/tests.rs b/crates/engine/src/cmd/datamerge/tests.rs index 64f2f5d08..e4636d109 100644 --- a/crates/engine/src/cmd/datamerge/tests.rs +++ b/crates/engine/src/cmd/datamerge/tests.rs @@ -809,3 +809,66 @@ fn oversized_data_file_is_refused_before_reading() { assert!(msg.contains("larger than"), "{msg}"); assert_eq!(read_path_capped(&path, 4096).unwrap().len(), 2048); } + +/// #198: with facing pages on, one record per page must not stack the copies at identical +/// coordinates on one spread (leaving a later page empty). +#[test] +fn facing_pages_merge_puts_each_record_on_its_own_page() { + let mut s = Session::new(); + exec(&mut s, "file.new", json!({"facingPages": true, "pages": 1, "width": 612, "height": 792})); + select_csv(&mut s, "Title,Clue\nCase 1,Cellar\nCase 2,Attic\nCase 3,Garden\n"); + // A block taller than the page's usable height, so only one record fits per page. + text_frame(&mut s, [72.0, 72.0, 540.0, 700.0], "<> - <<Clue>>", None); + let r = exec(&mut s, "data.merge", json!({})); + assert_eq!(r["records"], 3); + + let doc = &s.documents()[s.active_index().unwrap()].doc; + assert_eq!(doc.page_count(), 3, "one page per record"); + + // Which absolute page each merged frame sits on. + let mut pages_seen = std::collections::BTreeSet::new(); + let mut origins = Vec::new(); + for sp in &doc.spreads { + for it in &sp.items { + if !matches!(it.content, Content::Text(_)) { + continue; + } + let abs = doc.page_of_item(it.id); + let b = it.bounds(); + origins.push((abs, b.x0.round() as i32, b.y0.round() as i32)); + if let Some(a) = abs { + pages_seen.insert(a); + } + } + } + assert_eq!(origins.len(), 3, "one text frame per record"); + assert_eq!(pages_seen, [0usize, 1, 2].into_iter().collect(), "each record on its own page, none stacked and none left empty: {origins:?}"); +} + +/// #198's actual repro shape: a short block that tiles several records down one page. With +/// facing pages on they must still land at distinct positions, not on top of each other. +#[test] +fn facing_pages_merge_tiles_short_blocks_without_stacking() { + let mut s = Session::new(); + exec(&mut s, "file.new", json!({"facingPages": true, "pages": 1, "width": 612, "height": 792})); + select_csv(&mut s, "Title,Clue\nCase 1,Cellar\nCase 2,Attic\nCase 3,Garden\n"); + text_frame(&mut s, [72.0, 72.0, 540.0, 200.0], "<<Title>> - <<Clue>>", None); + let r = exec(&mut s, "data.merge", json!({})); + assert_eq!(r["records"], 3); + + let doc = &s.documents()[s.active_index().unwrap()].doc; + let mut origins = Vec::new(); + for sp in &doc.spreads { + for it in &sp.items { + if matches!(it.content, Content::Text(_)) { + let abs = doc.page_of_item(it.id); + let b = it.bounds(); + origins.push((abs, b.x0.round() as i32, b.y0.round() as i32)); + } + } + } + origins.sort(); + assert_eq!(origins.len(), 3, "one text frame per record"); + let unique: std::collections::BTreeSet<_> = origins.iter().collect(); + assert_eq!(unique.len(), 3, "no two records may share a page and coordinates: {origins:?}"); +} From 8146737e977fe0dce4d53eab471c61cd21b52758 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 10 Oct 2026 03:36:47 -0400 Subject: [PATCH 002/238] Text: a column, frame or page break ends its paragraph (#285) A break character is stored as the last character of the paragraph it ends, followed by the usual '\n' separator. The story model, paragraph ranges, styles, editing and every exporter keep splitting paragraphs on '\n' only, so the change stays in the places that create, lay out or write break characters: - IDML import writes the break and ends the paragraph, so the range that follows keeps its own paragraph style. NextOddPage and NextEvenPage map to new odd and even page break characters; IDML export writes a paragraph that ends in a break as that one Br with its ParagraphBreakType, so export and re-import give the same story. - Word and RTF import, Type > Insert Break Character (with odd and even page breaks added) and typing end the paragraph after a break. Typing between a break and its return goes to the next paragraph; deleting either deletes both. - Layout: a break that ends a paragraph leaves no empty line; the next paragraph starts in the next column, frame or page, as a paragraph start option would. Page breaks and Next Page start options move to the next frame on a later page; odd and even ones to the next page with that page number parity (no such frame: overset). A break inside a paragraph from an older document still moves the rest of that paragraph on. Storing the break as a paragraph separator of its own would have changed every place that counts or splits paragraphs on '\n'. Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --- Cargo.lock | 1 + crates/compose/src/breaker.rs | 12 ++-- crates/compose/src/lib.rs | 102 ++++++++++++++++++++++------------ crates/compose/src/shape.rs | 2 + crates/compose/src/tests.rs | 17 ++++++ crates/doc/src/story.rs | 45 ++++++++++++++- crates/engine/src/cmd/find.rs | 2 + crates/engine/src/cmd/text.rs | 71 ++++++++++++++++++++++- crates/idml/Cargo.toml | 3 + crates/idml/src/export.rs | 6 +- crates/idml/src/import.rs | 17 +++--- crates/idml/src/tests.rs | 49 +++++++++++++++- crates/textimport/src/docx.rs | 20 +++++++ crates/textimport/src/rtf.rs | 2 + crates/ui-egui/src/menus.rs | 2 + 15 files changed, 298 insertions(+), 53 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3fc1e3274..513b34313 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1013,6 +1013,7 @@ name = "designcraft-idml" version = "0.5.0" dependencies = [ "designcraft-color", + "designcraft-compose", "designcraft-doc", "designcraft-geom", "quick-xml 0.38.4", diff --git a/crates/compose/src/breaker.rs b/crates/compose/src/breaker.rs index 7646aaba2..dbc8819bf 100644 --- a/crates/compose/src/breaker.rs +++ b/crates/compose/src/breaker.rs @@ -208,7 +208,7 @@ fn cjk_break(a: &Glyph, b: &Glyph, sp: &Spacing) -> bool { pub fn is_forced(c: char) -> bool { use designcraft_doc::story::*; - matches!(c, FORCED_LINE_BREAK | COLUMN_BREAK | FRAME_BREAK | PAGE_BREAK) + c == FORCED_LINE_BREAK || is_break_char(c) } /// Optical margin protrusion of `c` as fractions of its advance: (left edge, right edge). @@ -613,8 +613,9 @@ fn breaks_from_positions(items: &[Item], ig: &[usize], glyphs: &[Glyph], chain: if out.is_empty() { out.push(Break { start: 0, end: n, next: n, hyphen: false, forced: true }); } - // Lines produced past the paragraph end (e.g. a trailing forced break) are dropped. - let trailing_forced = glyphs.last().is_some_and(|g| is_forced(g.ch)); + // Lines produced past the paragraph end are dropped, except the empty line after a trailing + // forced line break (a trailing column, frame or page break ends the paragraph). + let trailing_forced = glyphs.last().is_some_and(|g| g.ch == designcraft_doc::story::FORCED_LINE_BREAK); out.retain(|b| b.start < n || (b.start == n && trailing_forced)); out } @@ -725,9 +726,10 @@ pub fn greedy( start = next; line += 1; } - // A forced break as the very last glyph leaves an empty final line (InDesign shows it). + // A forced line break as the very last glyph leaves an empty final line (InDesign shows it); + // a column, frame or page break there ends the paragraph. if let Some(last) = glyphs.last() - && is_forced(last.ch) + && last.ch == designcraft_doc::story::FORCED_LINE_BREAK { out.push(Break { start: n, end: n, next: n, hyphen: false, forced: true }); } diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 2174708a7..6d5f3f769 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -544,15 +544,16 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C } let pf = &story.paras[pi]; let (pp, base_chars) = doc.styles.resolve_para(pf); + // A break character that ends the previous paragraph sends this one on, like a start option. + let after_break = pi + .checked_sub(1) + .and_then(|p| para_ranges.get(p)) + .and_then(|r| story.text.get(r.clone())) + .and_then(|t| t.chars().next_back()) + .and_then(break_start); if let Some(t) = story.para_table(pi) { if cur.last_baseline.is_some() { - match pp.start_paragraph { - StartParagraph::NextColumn => cur.next_column(&cols), - StartParagraph::NextFrame | StartParagraph::NextPage | StartParagraph::NextOddPage | StartParagraph::NextEvenPage => { - cur.next_frame() - } - StartParagraph::Anywhere => {} - } + cur.start(after_break.unwrap_or(pp.start_paragraph), &cols, frames, doc); } let at_top = cur.last_baseline.is_none(); let start_at = (cur.fi, cur.col); @@ -747,16 +748,23 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let spanning = matches!(pp.span_columns, SpanColumns::Span(n) if n != 1); span_paras[pi] = spanning; let next_column = |cur: &mut Cursor| if spanning { cur.next_frame() } else { cur.next_column(&cols) }; + // A paragraph start or a break character; Next Column while a paragraph spans columns is + // the next frame, as above. + let begin_at = |cur: &mut Cursor, start: StartParagraph| match start { + StartParagraph::NextColumn => next_column(cur), + other => cur.start(other, &cols, frames, doc), + }; // Paragraph start options. + if let Some(start) = after_break + && cur.last_baseline.is_some() + { + begin_at(&mut cur, start); + } if force_col[pi] { next_column(&mut cur); } if cur.last_baseline.is_some() { - match pp.start_paragraph { - StartParagraph::NextColumn => next_column(&mut cur), - StartParagraph::NextFrame | StartParagraph::NextPage | StartParagraph::NextOddPage | StartParagraph::NextEvenPage => cur.next_frame(), - StartParagraph::Anywhere => {} - } + begin_at(&mut cur, pp.start_paragraph); } if spanning && cur.fi < frames.len() { // Close the band: the paragraph goes below the deepest of its columns, which are @@ -1001,28 +1009,18 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C cur.last_descent = desc; cur.pending = 0.0; line_no += 1; - // Column / frame / page break characters. - if b.forced && e < glyphs.len() + 1 { - let jumped = match brk { - Some(story::COLUMN_BREAK) => { - next_column(&mut cur); - true - } - Some(story::FRAME_BREAK) | Some(story::PAGE_BREAK) => { - cur.next_frame(); - true - } - _ => false, - }; - if jumped { - // The rest of the paragraph continues in the new column/frame: re-break it there. - col_first_line = line_no; - if k + 1 < breaks.len() { - g0 += b.next; - moved = true; - break; - } - } + // A column / frame / page break inside the paragraph: the rest of it continues in the + // new column/frame/page, re-broken there. (A break that ends the paragraph moves the + // next paragraph instead.) + if b.forced + && k + 1 < breaks.len() + && let Some(start) = brk.and_then(break_start) + { + begin_at(&mut cur, start); + col_first_line = line_no; + g0 += b.next; + moved = true; + break; } } if !moved { @@ -1575,6 +1573,18 @@ fn keep_violation(ctx: &KeepCtx, pp: &ParaProps, info: &[ParaInfo], force_col: & None } +/// The start option a column, frame or page break character stands for. +fn break_start(c: char) -> Option<StartParagraph> { + match c { + story::COLUMN_BREAK => Some(StartParagraph::NextColumn), + story::FRAME_BREAK => Some(StartParagraph::NextFrame), + story::PAGE_BREAK => Some(StartParagraph::NextPage), + story::ODD_PAGE_BREAK => Some(StartParagraph::NextOddPage), + story::EVEN_PAGE_BREAK => Some(StartParagraph::NextEvenPage), + _ => None, + } +} + fn ft_prev_end(overset: &Option<usize>, len: usize) -> usize { overset.unwrap_or(len) } @@ -1719,6 +1729,30 @@ impl Cursor { let y1 = limit_of(&self.limits, self.band_key()).map_or(col.y1, |b| b.min(col.y1).max(y0)); Rect::new(col.x0, y0, col.x1, y1) } + /// Move on as a paragraph start option (or a break character) asks. + fn start(&mut self, start: StartParagraph, cols: &[Vec<Rect>], frames: &[FrameSpec], doc: &Document) { + match start { + StartParagraph::Anywhere => {} + StartParagraph::NextColumn => self.next_column(cols), + StartParagraph::NextFrame => self.next_frame(), + StartParagraph::NextPage => self.next_page(frames, doc, None), + StartParagraph::NextOddPage => self.next_page(frames, doc, Some(true)), + StartParagraph::NextEvenPage => self.next_page(frames, doc, Some(false)), + } + } + /// Move to the next frame on a later page, one with an odd page number (`odd` true) or an + /// even one (false) when asked. Frames without a page (parent pages) qualify; with no frame + /// left, the rest of the story is overset. + fn next_page(&mut self, frames: &[FrameSpec], doc: &Document, odd: Option<bool>) { + let from = frames.get(self.fi).and_then(|f| f.page); + let skip = |f: &FrameSpec| f.page.is_some_and(|p| Some(p) == from || odd.is_some_and(|o| (doc.page_number(p) % 2 == 1) != o)); + let mut fi = self.fi.saturating_add(1); + while frames.get(fi).is_some_and(skip) { + fi += 1; + } + self.next_frame(); + self.fi = fi; + } /// Baseline for the next line with leading `lead` and ascent `asc`. fn next_baseline(&self, f: &FrameSpec, col: Rect, lead: f64, asc: f64, _pp: &ParaProps) -> f64 { match self.last_baseline { diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index 41583b0a9..6e3854f3a 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -545,6 +545,8 @@ fn shape_run_raw( | story::COLUMN_BREAK | story::FRAME_BREAK | story::PAGE_BREAK + | story::ODD_PAGE_BREAK + | story::EVEN_PAGE_BREAK | story::INDENT_HERE | story::RIGHT_INDENT_TAB | story::TABLE_ANCHOR diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 6c7da80da..c853f3127 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -573,6 +573,23 @@ fn column_break_moves_following_text() { assert!(lines[1].baseline < 20.0, "second column starts at the top"); } +/// InDesign: a break character ends its paragraph; the next paragraph starts on the next page (odd +/// or even for those breaks), with no empty line where the break was. +#[test] +fn break_characters_end_their_paragraph_and_keep_page_parity() { + use designcraft_doc::story::{EVEN_PAGE_BREAK, ODD_PAGE_BREAK, PAGE_BREAK}; + let mut d = Document::new(&NewDocument { pages: 4, facing_pages: false, primary_text_frame: true, ..Default::default() }); + let sid = d.settings.primary_story.unwrap(); + let text = format!("one{ODD_PAGE_BREAK}\ntwo{EVEN_PAGE_BREAK}\nthree{PAGE_BREAK}\nfour"); + d.story_mut(sid).unwrap().insert(0, &text); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + // Frame i is on page i + 1: "one" on 1, "two" on the next odd page (3), "three" on the next + // even page (4), and "four" has no page left. + let lines: Vec<(usize, usize)> = cs.frames.iter().enumerate().flat_map(|(fi, f)| f.lines.iter().map(move |l| (fi, l.para))).collect(); + assert_eq!(lines, vec![(0, 0), (2, 1), (3, 2)]); + assert_eq!(cs.overset_at, Some(text.find("four").unwrap())); +} + #[test] fn line_before_a_break_character_is_a_last_line() { // As in InDesign: column, frame and page breaks end the paragraph's last line (set with the diff --git a/crates/doc/src/story.rs b/crates/doc/src/story.rs index 21ac81bb7..6dbfa13c6 100644 --- a/crates/doc/src/story.rs +++ b/crates/doc/src/story.rs @@ -8,7 +8,9 @@ //! the "typing" format). //! //! Special characters: `\t` tab, `\u{2028}` forced line break, [`PAGE_NUMBER`], [`SECTION_MARKER`], -//! [`COLUMN_BREAK`], [`FRAME_BREAK`], [`PAGE_BREAK`], `\u{AD}` discretionary hyphen, +//! break characters ([`COLUMN_BREAK`], [`FRAME_BREAK`], [`PAGE_BREAK`], [`ODD_PAGE_BREAK`], +//! [`EVEN_PAGE_BREAK`]; each ends its paragraph, so it is followed by `\n`, see +//! [`is_break_char`]), `\u{AD}` discretionary hyphen, //! `\u{2011}` non-breaking hyphen, `\u{A0}` non-breaking space, [`INDENT_HERE`], [`RIGHT_INDENT_TAB`], //! [`TABLE_ANCHOR`] (a table, see [`crate::table`]), [`crate::notes::FOOTNOTE_REF`] (a footnote), //! [`crate::xref::ANCHOR_MARK`] / [`crate::xref::XREF_MARK`] (text anchor / cross-reference). @@ -34,6 +36,30 @@ pub const PREV_PAGE_NUMBER: char = '\u{E008}'; /// Anchor of a table: alone in its paragraph, whose [`ParaFormat::table`] names the table. pub const TABLE_ANCHOR: char = '\u{E009}'; pub const FORCED_LINE_BREAK: char = '\u{2028}'; +pub const ODD_PAGE_BREAK: char = '\u{E011}'; +pub const EVEN_PAGE_BREAK: char = '\u{E012}'; + +/// A column, frame or page break character. It ends its paragraph: the story stores it as the +/// paragraph's last character, followed by the `\n` separator (also at the end of the story), and +/// the next paragraph starts in the next column, frame or page. A break inside a paragraph (from +/// an older document or a raw edit) still moves the rest of that paragraph on. +pub fn is_break_char(c: char) -> bool { + matches!(c, COLUMN_BREAK | FRAME_BREAK | PAGE_BREAK | ODD_PAGE_BREAK | EVEN_PAGE_BREAK) +} + +/// `text` with a `\n` after every break character that isn't followed by one. `next` is the +/// character after the text (None at the end of the story). +pub fn end_paragraphs_at_breaks(text: &str, next: Option<char>) -> String { + let mut out = String::with_capacity(text.len() + 1); + let mut chars = text.chars().peekable(); + while let Some(c) = chars.next() { + out.push(c); + if is_break_char(c) && chars.peek().copied().or(next) != Some('\n') { + out.push('\n'); + } + } + out +} pub const BASIC_PARAGRAPH: &str = "[Basic Paragraph]"; pub const NO_CHAR_STYLE: &str = "[None]"; @@ -269,6 +295,23 @@ impl Story { self.rev += 1; } + /// End the paragraph after every break character that isn't followed by a `\n`. + pub fn end_paragraphs_at_breaks(&mut self) { + let at: Vec<usize> = self + .text + .char_indices() + .filter(|&(i, c)| is_break_char(c) && !self.text[i + c.len_utf8()..].starts_with('\n')) + .map(|(i, c)| i + c.len_utf8()) + .collect(); + for &pos in at.iter().rev() { + let fmt = self.char_format_at(pos).clone(); + self.insert_with_raw(pos, "\n", fmt); + } + if !at.is_empty() { + self.fix_tables(); + } + } + /// Delete the byte range (clamped to char boundaries). Merged paragraphs keep the first one's format. pub fn delete(&mut self, range: std::ops::Range<usize>) { let a = floor_char_boundary(&self.text, range.start.min(self.text.len())); diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs index 12c09dffc..878160400 100644 --- a/crates/engine/src/cmd/find.rs +++ b/crates/engine/src/cmd/find.rs @@ -80,6 +80,8 @@ fn metachars(s: &str, grep: bool) -> String { ("~M", "\u{E002}"), ("~R", "\u{E003}"), ("~P", "\u{E004}"), + ("~L", "\u{E011}"), + ("~E", "\u{E012}"), ("~=", "\u{2014}"), ("~_", "\u{2014}"), ("~-", "\u{AD}"), diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 92ddaf606..576fa45db 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -370,6 +370,14 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> { let st = d.text_story_mut(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?; let r = t.range(); let mut r = r.start.min(st.len())..r.end.min(st.len()); + // Between a break character and its return is the end of that paragraph: typing there goes + // to the start of the next one. + if r.is_empty() + && st.text.get(r.start..).is_some_and(|t| t.starts_with('\n')) + && st.text.get(..r.start).and_then(|t| t.chars().next_back()).is_some_and(designcraft_doc::is_break_char) + { + r = r.start + 1..r.start + 1; + } // Autocorrect: a word ended by a space or punctuation is looked up. if let Some(list) = &autocorrect && r.is_empty() @@ -392,8 +400,13 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> { } } } + // A column, frame or page break ends its paragraph. + let mut text = if text.contains(designcraft_doc::is_break_char) { + designcraft_doc::end_paragraphs_at_breaks(&text, st.text.get(r.end..).and_then(|t| t.chars().next())) + } else { + text.clone() + }; // Typing next to a table anchor starts a paragraph of its own. - let mut text = text.clone(); let mut trail = 0; if t.cell.is_none() && !text.is_empty() && st.table_at(r.start).is_some() { let pi = st.para_at(r.start); @@ -538,6 +551,7 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> { let start = if word { prev_word(&st.text, r.start) } else { prev_char(&st.text, r.start) }; start..r.start }; + let r = with_break_pair(&st.text, r); if tracking && t.cell.is_none() { // Marked, not removed: the caret steps over the deleted text. let at = super::changes::mark_deleted(st, r.clone()); @@ -551,6 +565,20 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> { }) } +/// A break character and the return after it end one paragraph: deleting either deletes both. +fn with_break_pair(text: &str, r: std::ops::Range<usize>) -> std::ops::Range<usize> { + let Some(del) = text.get(r.clone()) else { return r }; + let mut chars = del.chars(); + match (chars.next(), chars.next()) { + (Some('\n'), None) => match text.get(..r.start).and_then(|t| t.chars().next_back()) { + Some(b) if designcraft_doc::is_break_char(b) => r.start.saturating_sub(b.len_utf8())..r.end, + _ => r, + }, + (Some(b), None) if designcraft_doc::is_break_char(b) && text.get(r.end..).is_some_and(|t| t.starts_with('\n')) => r.start..r.end + 1, + _ => r, + } +} + pub fn next_char(s: &str, i: usize) -> usize { s[i.min(s.len())..].chars().next().map(|c| i + c.len_utf8()).unwrap_or(s.len()) } @@ -1349,6 +1377,47 @@ mod autocorrect_tests { } } +#[cfg(test)] +mod break_tests { + use serde_json::json; + + use crate::Session; + + /// Type › Insert Break Character: the break ends its paragraph, the caret starts the next one. + #[test] + fn inserted_break_ends_its_paragraph() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "OneTwo\nThree"})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + let story = |s: &Session| s.doc().unwrap().doc.stories[&sid].clone(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 3, "focus": 3})).unwrap(); + s.execute("text.insert", &json!({"text": "\u{e011}", "raw": true})).unwrap(); + assert_eq!(story(&s).text, "One\u{e011}\nTwo\nThree"); + assert_eq!(s.doc().unwrap().selection.text.unwrap().focus, "One\u{e011}\n".len()); + // At the end of a paragraph the break takes the place of its return. + let at = story(&s).text.find("\nThree").unwrap(); + s.execute("text.select", &json!({"story": sid.0, "anchor": at, "focus": at})).unwrap(); + s.execute("text.insert", &json!({"text": "\u{e002}", "raw": true})).unwrap(); + let st = story(&s); + assert_eq!(st.text, "One\u{e011}\nTwo\u{e002}\nThree"); + assert_eq!(st.paras.len(), 3); + st.check().unwrap(); + // Typing after the break goes to the next paragraph; deleting the break or its return + // removes both. + let at = "One\u{e011}".len(); + s.execute("text.select", &json!({"story": sid.0, "anchor": at, "focus": at})).unwrap(); + s.execute("text.insert", &json!({"text": "x"})).unwrap(); + assert_eq!(story(&s).text, "One\u{e011}\nxTwo\u{e002}\nThree"); + s.execute("text.select", &json!({"story": sid.0, "anchor": at + 1, "focus": at + 1})).unwrap(); + s.execute("text.delete", &json!({})).unwrap(); + assert_eq!(story(&s).text, "OnexTwo\u{e002}\nThree"); + s.execute("text.select", &json!({"story": sid.0, "anchor": "OnexTwo".len(), "focus": "OnexTwo".len()})).unwrap(); + s.execute("text.delete", &json!({"forward": true})).unwrap(); + assert_eq!(story(&s).text, "OnexTwoThree"); + } +} + #[cfg(test)] mod language_tests { use serde_json::json; diff --git a/crates/idml/Cargo.toml b/crates/idml/Cargo.toml index 9ee690531..7e11a33f5 100644 --- a/crates/idml/Cargo.toml +++ b/crates/idml/Cargo.toml @@ -15,3 +15,6 @@ designcraft-doc = { workspace = true } thiserror = { workspace = true } quick-xml = { workspace = true } zip = { workspace = true } + +[dev-dependencies] +designcraft-compose = { workspace = true } diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index c63815b21..663678493 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1865,6 +1865,7 @@ impl<'a> Ex<'a> { let pf = s.paras.get(pi).cloned().unwrap_or_default(); let mut psr = self.psr_el(&pf); let last = pi + 1 == n; + let ends_with_break = s.text.get(r.clone()).and_then(|t| t.chars().next_back()).is_some_and(st::is_break_char); // Character runs intersecting the paragraph. let mut segs: Vec<(std::ops::Range<usize>, CharFormat)> = Vec::new(); for (rr, f) in s.runs() { @@ -1911,6 +1912,8 @@ impl<'a> Ex<'a> { st::COLUMN_BREAK => Some("NextColumn"), st::FRAME_BREAK => Some("NextFrame"), st::PAGE_BREAK => Some("NextPage"), + st::ODD_PAGE_BREAK => Some("NextOddPage"), + st::EVEN_PAGE_BREAK => Some("NextEvenPage"), _ => None, }; if let Some(code) = ace { @@ -2088,7 +2091,8 @@ impl<'a> Ex<'a> { flush_text(&mut cur, &mut pending); flush_content(&mut pending, &mut out); let is_last_seg = k + 1 == nseg; - if is_last_seg && !last { + // A break character that ends the paragraph was written as its `Br`. + if is_last_seg && !last && !ends_with_break { out.push(Node::El(El::new("Br"))); } if !out.is_empty() || csrs.is_empty() { diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index a8f7e7ae9..2b5f3fef7 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1626,21 +1626,22 @@ impl<'r> Importer<'r> { } } } - "Br" => match brk { - Some(t) => { + "Br" => { + // A column, frame or page break ends its paragraph like a return does. + if let Some(t) = brk { let ch = match t { "NextColumn" => st::COLUMN_BREAK, "NextFrame" => st::FRAME_BREAK, + "NextOddPage" => st::ODD_PAGE_BREAK, + "NextEvenPage" => st::EVEN_PAGE_BREAK, _ => st::PAGE_BREAK, }; b.push(&ch.to_string(), cf); } - None => { - b.push("\n", cf); - b.paras.push(pf.clone()); - b.fresh = true; - } - }, + b.push("\n", cf); + b.paras.push(pf.clone()); + b.fresh = true; + } "Table" => { let t = self.table(c); b.push_table(t, pf, cf); diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 850726ba3..86f287314 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -196,6 +196,49 @@ fn fixture_with_story(story: &str) -> Vec<u8> { ]) } +/// InDesign: a break character ends its paragraph (`<Br/>` with a ParagraphBreakType closes the +/// range); the text after it is a paragraph with its own style, set on the next page of the +/// break's parity. +const ODD_PAGE_BREAK_STORY: &str = r#"<?xml version="1.0" encoding="UTF-8"?> +<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0"> + <Story Self="s1"> + <ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/Text%3aBody"> + <CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content>Body</Content></CharacterStyleRange> + <CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]" ParagraphBreakType="NextOddPage"><Br/></CharacterStyleRange> + </ParagraphStyleRange> + <ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/$ID/NormalParagraphStyle" Justification="CenterAlign"> + <CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content>1</Content></CharacterStyleRange> + </ParagraphStyleRange> + </Story> +</idPkg:Story>"#; + +#[test] +fn odd_page_break_ends_its_paragraph_and_keeps_its_parity() { + let mut d = import_idml_with(&fixture_with_story(ODD_PAGE_BREAK_STORY), &|_| None).unwrap(); + let sid = *d.stories.keys().next().unwrap(); + let story = &d.stories[&sid]; + assert_eq!(story.text, format!("Body{}\n1", st::ODD_PAGE_BREAK)); + assert_eq!(story.paras.len(), 2); + assert_eq!(story.paras[0].style, "Text/Body"); + assert_eq!(story.paras[1].style, st::BASIC_PARAGRAPH); + assert_eq!(story.paras[1].para.align, Some(designcraft_doc::Align::Center)); + // Export writes the break as the paragraph's one `Br`; importing that gives the same story. + let back = import_idml(&export_idml(&d)).unwrap(); + let again = back.stories.values().next().unwrap(); + assert_eq!(again.text, story.text); + assert_eq!(again.paras, story.paras); + // The fixture's frames are on pages v and vi: the next odd page after v has no frame. + let cs = designcraft_compose::compose_story(&d, sid, &Default::default()); + assert_eq!(cs.frames[0].lines.len(), 1, "no empty line after the break"); + assert!(cs.frames[1].lines.is_empty()); + assert_eq!(cs.overset_at, Some(story.text.len() - 1)); + // Numbered from 4, the second frame is on page 5: "1" starts there. + d.sections[0].start_number = Some(4); + let cs = designcraft_compose::compose_story(&d, sid, &Default::default()); + assert_eq!(cs.overset_at, None); + assert_eq!(cs.frames[1].lines.iter().map(|l| l.para).collect::<Vec<_>>(), vec![1]); +} + #[test] fn imports_hand_written_fixture() { let d = import_idml_with(&fixture(), &|_| None).unwrap(); @@ -240,8 +283,8 @@ fn imports_hand_written_fixture() { assert_eq!(d.styles.char_style("Strong").unwrap().chars.font_style.as_deref(), Some("Bold")); // Frames, threads, text. let story = d.stories.values().next().unwrap(); - assert_eq!(story.text, format!("Hello & bold\nPage {}\tend{}after", st::PAGE_NUMBER, st::FRAME_BREAK)); - assert_eq!(story.paras.len(), 2); + assert_eq!(story.text, format!("Hello & bold\nPage {}\tend{}\nafter", st::PAGE_NUMBER, st::FRAME_BREAK)); + assert_eq!(story.paras.len(), 3); assert_eq!(story.paras[0].style, "Text/Body"); assert_eq!(story.paras[1].style, st::BASIC_PARAGRAPH); assert_eq!(story.paras[1].para.align, Some(designcraft_doc::Align::Center)); @@ -408,7 +451,7 @@ fn small_doc() -> Document { if let Some(s) = d.story_mut(sid) { s.format_chars(0..3, |f| f.over = CharAttrs { size: Some(20.0), fill: Some("Brand".into()), ..Default::default() }); let end = s.len(); - s.insert(end, &format!(" {}", st::COLUMN_BREAK)); + s.insert(end, &format!(" {}\n", st::COLUMN_BREAK)); } let id = designcraft_doc::ItemId(d.alloc()); let mut it = designcraft_doc::Item::new(id, lid, Shape::Oval, shapes::ellipse(Rect::new(650.0, 300.0, 750.0, 380.0))); diff --git a/crates/textimport/src/docx.rs b/crates/textimport/src/docx.rs index 670f8a0fa..67a2b71a1 100644 --- a/crates/textimport/src/docx.rs +++ b/crates/textimport/src/docx.rs @@ -378,6 +378,8 @@ pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> { let body = doc.child("body").ok_or_else(|| ImportError::Corrupt("no document body".into()))?; let mut st = Story::new(StoryId(0)); ctx.blocks(body, &mut st, true); + // A page or column break ends its paragraph. + st.end_paragraphs_at_breaks(); // Only styles that are used, plus what they are based on. let used_p: std::collections::HashSet<String> = st.paras.iter().map(|p| p.style.clone()).collect(); let used_c: std::collections::HashSet<String> = st.runs().map(|(_, f)| f.style.clone()).collect(); @@ -472,6 +474,24 @@ mod tests { assert_eq!(i.char_styles[0].chars.font_style.as_deref(), Some("Italic")); } + /// A page break ends its paragraph: the text after it is a paragraph of its own (same style). + #[test] + fn page_break_ends_its_paragraph() { + let styles = format!(r#"<w:styles {W}><w:style w:type="paragraph" w:styleId="H"><w:name w:val="Chapter"/></w:style></w:styles>"#); + let document = format!( + r#"<w:document {W}><w:body> + <w:p><w:r><w:t>End of one</w:t></w:r><w:r><w:br w:type="page"/></w:r></w:p> + <w:p><w:pPr><w:pStyle w:val="H"/></w:pPr><w:r><w:br w:type="page"/><w:t>Two</w:t><w:br w:type="column"/></w:r></w:p> + </w:body></w:document>"# + ); + let st = import(&docx(&document, &styles, "")).unwrap().story; + st.check().unwrap(); + let (pb, cb) = (designcraft_doc::PAGE_BREAK, designcraft_doc::COLUMN_BREAK); + assert_eq!(st.text, format!("End of one{pb}\n{pb}\nTwo{cb}\n")); + let styles: Vec<&str> = st.paras.iter().map(|p| p.style.as_str()).collect(); + assert_eq!(styles, [BASIC_PARAGRAPH, "Chapter", "Chapter", "Chapter"]); + } + #[test] fn tracked_changes_import_as_accepted_including_moves() { // Regression (#256): text in a tracked move destination (`w:moveTo`) vanished on import. diff --git a/crates/textimport/src/rtf.rs b/crates/textimport/src/rtf.rs index 9189b52d2..91a5ae5a3 100644 --- a/crates/textimport/src/rtf.rs +++ b/crates/textimport/src/rtf.rs @@ -257,6 +257,8 @@ pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> { let n = st.len(); st.delete(n - 1..n); } + // A page or column break ends its paragraph. + st.end_paragraphs_at_breaks(); Ok(Imported { story: st, para_styles: vec![], char_styles: vec![], warnings }) } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index c8d54bc73..74ebaa6c0 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -358,6 +358,8 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:text.insert|Column Break|{\"text\": \"\u{e002}\", \"raw\": true}", "cmd:text.insert|Frame Break|{\"text\": \"\u{e003}\", \"raw\": true}", "cmd:text.insert|Page Break|{\"text\": \"\u{e004}\", \"raw\": true}", + "cmd:text.insert|Odd Page Break|{\"text\": \"\u{e011}\", \"raw\": true}", + "cmd:text.insert|Even Page Break|{\"text\": \"\u{e012}\", \"raw\": true}", "cmd:text.insert|Paragraph Return|{\"text\": \"\\n\", \"raw\": true}", "cmd:text.insert|Forced Line Break|{\"text\": \"\u{2028}\", \"raw\": true}", "<", From 74a81f9fb02c68fe0c54639435c92dff47c91965 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick <hi@skylar.cc> Date: Sat, 10 Oct 2026 01:46:19 -0600 Subject: [PATCH 003/238] Harden temporary files and release inputs (#153) Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --- .github/workflows/freebsd.yml | 7 +- .github/workflows/packaging-lint.yml | 8 +- .github/workflows/release.yml | 31 +++- Cargo.lock | 1 + Cargo.toml | 1 + crates/engine/Cargo.toml | 3 + crates/engine/src/cmd/print.rs | 54 ++++++- crates/engine/src/recovery.rs | 223 ++++++++++++++++++++++++++- packaging/linux/package.sh | 16 +- packaging/windows/sign.ps1 | 13 +- 10 files changed, 336 insertions(+), 21 deletions(-) diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 77c030386..9b7503bc1 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -28,6 +28,7 @@ jobs: timeout-minutes: 300 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # The Japanese font tests run against craft-fonts (an optional build input; they skip without it). - name: Check out craft-fonts uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -45,7 +46,11 @@ jobs: copyback: false prepare: | pkg install -y git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib - curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal + curl -fsSL -o /tmp/rustup-init \ + https://static.rust-lang.org/rustup/archive/1.28.2/x86_64-unknown-freebsd/rustup-init + test "$(sha256 -q /tmp/rustup-init)" = "4d9fef2e40731489f3186c61a0f178d54d79864fe3d34791edf5b17f70074956" + chmod 700 /tmp/rustup-init + /tmp/rustup-init -y --profile minimal run: | . "$HOME/.cargo/env" # The checkout is synced in under another owner; let tests that call git read it. diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 59c0a1ceb..03fddb96f 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -22,14 +22,18 @@ jobs: runs-on: ubuntu-latest env: ACTIONLINT_VERSION: 1.7.12 + ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install tools run: | sudo apt-get update sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml - curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \ - | tar -xz -C "$RUNNER_TEMP" actionlint + archive="$RUNNER_TEMP/actionlint.tar.gz" + curl -fsSL -o "$archive" \ + "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" + printf '%s %s\n' "$ACTIONLINT_SHA256" "$archive" | sha256sum -c - + tar -xzf "$archive" -C "$RUNNER_TEMP" actionlint - name: actionlint run: | "$RUNNER_TEMP/actionlint" -color diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 634552c49..bbd8acf3a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -73,6 +73,7 @@ jobs: DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it. - name: Check out craft-fonts uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -131,6 +132,7 @@ jobs: DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it. - name: Check out craft-fonts uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -181,14 +183,22 @@ jobs: fail-fast: false matrix: include: - - { arch: x86_64, runner: ubuntu-22.04, deb: amd64 } - - { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 } + - arch: x86_64 + runner: ubuntu-22.04 + deb: amd64 + nfpm_sha256: 3f1cf344bd0b57373ca55636a78c08b0491f7293d609a456a9ac3b0b150fda97 + - arch: aarch64 + runner: ubuntu-22.04-arm + deb: arm64 + nfpm_sha256: 27419eb382695a7942be8ad52259f3ec1854fad001b3ae4baed34ce39a223b97 env: DESIGNCRAFT_VERSION: ${{ needs.version.outputs.version }} DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} NFPM_VERSION: 2.47.0 + NFPM_SHA256: ${{ matrix.nfpm_sha256 }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it. - name: Check out craft-fonts uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -203,6 +213,7 @@ jobs: desktop-file-utils appstream python3-yaml zsync curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \ "https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb" + printf '%s %s\n' "$NFPM_SHA256" "$RUNNER_TEMP/nfpm.deb" | sha256sum -c - sudo dpkg -i "$RUNNER_TEMP/nfpm.deb" - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 @@ -286,6 +297,7 @@ jobs: DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it. - name: Check out craft-fonts uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -305,7 +317,11 @@ jobs: # The FreeBSD CI packages, plus bash for the packaging script. prepare: | pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable + curl -fsSL -o /tmp/rustup-init \ + https://static.rust-lang.org/rustup/archive/1.28.2/x86_64-unknown-freebsd/rustup-init + test "$(sha256 -q /tmp/rustup-init)" = "4d9fef2e40731489f3186c61a0f178d54d79864fe3d34791edf5b17f70074956" + chmod 700 /tmp/rustup-init + /tmp/rustup-init -y --profile minimal --default-toolchain stable run: | set -e . "$HOME/.cargo/env" @@ -334,8 +350,10 @@ jobs: DESIGNCRAFT_VERSION: ${{ needs.version.outputs.version }} DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} TRUNK_VERSION: 0.21.14 + TRUNK_SHA256: f2b4680cd239693a646a2795e4633c625328d7b2a044fbe749fa3a2fe9e7036b steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it. - name: Check out craft-fonts uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -350,8 +368,11 @@ jobs: key: release-web - name: Install trunk run: | - curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \ - | tar -xz -C "$HOME/.cargo/bin" trunk + archive="$RUNNER_TEMP/trunk.tar.gz" + curl -fsSL -o "$archive" \ + "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" + printf '%s %s\n' "$TRUNK_SHA256" "$archive" | sha256sum -c - + tar -xzf "$archive" -C "$HOME/.cargo/bin" trunk trunk --version - name: Apply version override if: inputs.version != '' diff --git a/Cargo.lock b/Cargo.lock index 513b34313..2f57f017f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -958,6 +958,7 @@ dependencies = [ "regex", "serde", "serde_json", + "tempfile", "thiserror 2.0.21", "wasm-bindgen-test", "zip", diff --git a/Cargo.toml b/Cargo.toml index af62549eb..34417be80 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,7 @@ unicode-script = "0.5" unicode-bidi-mirroring = "0.4" unicode-segmentation = "1" regex = { version = "1", default-features = false, features = ["std", "unicode", "perf"] } +tempfile = "3" # text / rendering / formats skrifa = "0.47" diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 241fd281a..23350e7ad 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -29,6 +29,9 @@ log = { workspace = true } regex = { workspace = true } quick-xml = { workspace = true } +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +tempfile = { workspace = true } + [dev-dependencies] designcraft-fonts = { workspace = true, features = ["testing"] } zip = { workspace = true } diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index b944b6235..724feb2be 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -1,5 +1,8 @@ //! File › Print: the pages go to a printer through the system print spooler (`lpr`), as PDF. +#[cfg(not(target_arch = "wasm32"))] +use std::io::Write; + use serde_json::{Value, json}; #[cfg(not(target_arch = "wasm32"))] @@ -55,7 +58,8 @@ fn print(s: &mut Session, p: &Value) -> Result<Value> { let printer = str_param(p, "printer").map(str::to_string); let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false); let pdf = designcraft_pdf::export_pdf(d, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?; - let file = std::env::temp_dir().join(format!("designcraft-print-{}-{}.pdf", std::process::id(), d.title.replace(['/', '\\'], "_"))); + let mut file = secure_print_file()?; + let file_path = file.path().to_path_buf(); let mut cmd: Vec<String> = vec!["lpr".into()]; if let Some(pr) = &printer { cmd.extend(["-P".into(), pr.clone()]); @@ -63,13 +67,14 @@ fn print(s: &mut Session, p: &Value) -> Result<Value> { if copies > 1 { cmd.extend(["-#".into(), copies.to_string()]); } - cmd.extend(["-T".into(), d.title.clone(), file.to_string_lossy().to_string()]); + cmd.extend(["-T".into(), d.title.clone(), file_path.to_string_lossy().to_string()]); let pages = opts.pages.as_ref().map_or(d.page_count(), |v| v.len()); let out = json!({"printer": printer, "copies": copies, "pages": pages, "command": cmd, "bytes": pdf.len()}); if dry { return Ok(out); } - std::fs::write(&file, &pdf).map_err(|e| EngineError::Other(format!("{}: {e}", file.display())))?; + file.as_file_mut().write_all(&pdf).map_err(|e| EngineError::Other(format!("{}: {e}", file.path().display())))?; + file.as_file_mut().flush().map_err(|e| EngineError::Other(format!("{}: {e}", file.path().display())))?; let st = std::process::Command::new(&cmd[0]).args(&cmd[1..]).output().map_err(|e| bad("file.print", format!("lpr: {e}")))?; if !st.status.success() { return Err(bad("file.print", String::from_utf8_lossy(&st.stderr).trim().to_string())); @@ -78,6 +83,23 @@ fn print(s: &mut Session, p: &Value) -> Result<Value> { } } +#[cfg(not(target_arch = "wasm32"))] +fn secure_print_file() -> Result<tempfile::NamedTempFile> { + let file = tempfile::Builder::new() + .prefix("designcraft-print-") + .suffix(".pdf") + .tempfile() + .map_err(|e| EngineError::Other(format!("temporary print file: {e}")))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + file.as_file() + .set_permissions(std::fs::Permissions::from_mode(0o600)) + .map_err(|e| EngineError::Other(format!("{}: {e}", file.path().display())))?; + } + Ok(file) +} + #[cfg(all(test, not(target_arch = "wasm32")))] mod tests { use serde_json::json; @@ -93,8 +115,34 @@ mod tests { let cmd: Vec<&str> = r["command"].as_array().unwrap().iter().filter_map(|v| v.as_str()).collect(); assert_eq!(&cmd[..5], ["lpr", "-P", "Office", "-#", "2"]); assert!(cmd.last().unwrap().ends_with(".pdf")); + assert!(!std::path::Path::new(cmd.last().unwrap()).exists()); assert!(r["bytes"].as_u64().unwrap() > 500); assert!(s.execute("file.print", &json!({"pages": "9", "dryRun": true})).is_err()); assert!(s.execute("file.printers", &json!({})).unwrap()["printers"].is_array()); } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn print_uses_distinct_ephemeral_files() { + let first = super::secure_print_file().unwrap(); + let second = super::secure_print_file().unwrap(); + let first_path = first.path().to_path_buf(); + let second_path = second.path().to_path_buf(); + assert_ne!(first_path, second_path); + drop((first, second)); + assert!(!first_path.exists()); + assert!(!second_path.exists()); + } + + #[cfg(all(not(target_arch = "wasm32"), unix))] + #[test] + fn print_file_is_private_and_removed_on_drop() { + use std::os::unix::fs::PermissionsExt; + + let file = super::secure_print_file().unwrap(); + let path = file.path().to_path_buf(); + assert_eq!(file.as_file().metadata().unwrap().permissions().mode() & 0o777, 0o600); + drop(file); + assert!(!path.exists()); + } } diff --git a/crates/engine/src/recovery.rs b/crates/engine/src/recovery.rs index e442f0ad0..a038c2d27 100644 --- a/crates/engine/src/recovery.rs +++ b/crates/engine/src/recovery.rs @@ -5,7 +5,14 @@ //! //! A recovery entry is `<uid>.designcraft` (the document) plus `<uid>.json` (`{"path", "title", //! "saved"}`: the original file, the title, and when it was written). +//! +//! Each file is replaced atomically, but the pair is not a transaction. The document is published +//! and made durable before its metadata so a crash can leave metadata missing or stale, never a +//! listed metadata record without a complete primary document. Listing and opening recovery data +//! therefore treat metadata as optional. +#[cfg(not(target_arch = "wasm32"))] +use std::io::Write; use std::path::{Path, PathBuf}; use serde_json::{Value, json}; @@ -30,6 +37,10 @@ fn files(dir: &Path, uid: u64) -> (PathBuf, PathBuf) { /// Remove a document's recovery entry (it was saved or closed). pub fn discard(dir: &Path, uid: u64) { + #[cfg(not(target_arch = "wasm32"))] + if validate_recovery_dir(dir).is_err() { + return; + } let (d, m) = files(dir, uid); let _ = std::fs::remove_file(d); let _ = std::fs::remove_file(m); @@ -38,7 +49,7 @@ pub fn discard(dir: &Path, uid: u64) { /// Write recovery entries for the dirty documents (and drop those of clean ones). Returns how /// many were written. pub fn save(s: &Session, dir: &Path) -> Result<usize> { - std::fs::create_dir_all(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; + secure_recovery_dir(dir)?; let mut n = 0; for d in &s.docs { if !d.is_dirty() { @@ -48,18 +59,135 @@ pub fn save(s: &Session, dir: &Path) -> Result<usize> { let (doc, meta) = files(dir, d.uid); let bytes = crate::cmd::to_bytes(d.preview_stash.as_deref().unwrap_or(d.doc.as_ref())); // Write then rename, so a crash mid-write never leaves a torn file. - let tmp = doc.with_extension("tmp"); - std::fs::write(&tmp, &bytes).and_then(|_| std::fs::rename(&tmp, &doc)).map_err(|e| EngineError::Other(format!("{}: {e}", doc.display())))?; + write_recovery_file(&doc, &bytes)?; let m = json!({"path": d.path, "title": d.title(), "saved": designcraft_doc::vars::now()}); - std::fs::write(&meta, m.to_string()).map_err(|e| EngineError::Other(format!("{}: {e}", meta.display())))?; + write_recovery_file(&meta, m.to_string().as_bytes())?; n += 1; } Ok(n) } +#[cfg(not(target_arch = "wasm32"))] +fn secure_recovery_dir(dir: &Path) -> Result<()> { + match std::fs::symlink_metadata(dir) { + Ok(_) => return validate_recovery_dir(dir), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(EngineError::Other(format!("{}: {e}", dir.display()))), + } + + // Set the mode while creating directories so existing caller-owned directories are never + // chmodded. DirBuilder applies this mode only to components it creates; the process umask may + // make it more restrictive. + #[cfg(unix)] + let create_result = { + use std::os::unix::fs::DirBuilderExt; + + let mut builder = std::fs::DirBuilder::new(); + builder.recursive(true).mode(0o700).create(dir) + }; + #[cfg(not(unix))] + let create_result = std::fs::create_dir_all(dir); + + create_result.map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; + + // Re-check after creation. This also fails closed if another process won a creation race with + // a symlink or, on Windows, any reparse-point leaf. + validate_recovery_dir(dir) +} + +#[cfg(target_arch = "wasm32")] +fn secure_recovery_dir(_dir: &Path) -> Result<()> { + Err(EngineError::Other("recovery files are unavailable on the web".into())) +} + +#[cfg(not(target_arch = "wasm32"))] +fn write_recovery_file(path: &Path, bytes: &[u8]) -> Result<()> { + let Some(dir) = path.parent() else { + return Err(EngineError::Other(format!("{} has no parent folder", path.display()))); + }; + validate_recovery_dir(dir)?; + let mut tmp = tempfile::Builder::new() + .prefix(".designcraft-recovery-") + .suffix(".tmp") + .tempfile_in(dir) + .map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + tmp.as_file().set_permissions(std::fs::Permissions::from_mode(0o600)).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?; + } + tmp.as_file_mut().write_all(bytes).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?; + tmp.as_file_mut().sync_all().map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?; + // Validate the non-followed leaf again immediately before renaming the randomized temporary + // file to its fixed sibling destination. + validate_recovery_dir(dir)?; + tmp.persist(path).map_err(|e| EngineError::Other(format!("{}: {}", path.display(), e.error)))?; + sync_recovery_dir(dir)?; + Ok(()) +} + +#[cfg(not(target_arch = "wasm32"))] +fn validate_recovery_dir(dir: &Path) -> Result<()> { + let metadata = std::fs::symlink_metadata(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; + if is_link_like(&metadata) { + return Err(EngineError::Other(format!("{} must not be a symbolic link or reparse point", dir.display()))); + } + if !metadata.file_type().is_dir() { + return Err(EngineError::Other(format!("{} is not a directory", dir.display()))); + } + Ok(()) +} + +#[cfg(all(not(target_arch = "wasm32"), windows))] +fn is_link_like(metadata: &std::fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + + // This catches all reparse-point leaves, including junctions, rather than relying solely on + // FileType::is_symlink(). + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 +} + +#[cfg(all(not(target_arch = "wasm32"), not(windows)))] +fn is_link_like(metadata: &std::fs::Metadata) -> bool { + metadata.file_type().is_symlink() +} + +#[cfg(not(target_arch = "wasm32"))] +fn sync_recovery_dir(dir: &Path) -> Result<()> { + validate_recovery_dir(dir)?; + + #[cfg(unix)] + { + let directory = std::fs::File::open(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; + directory.sync_all().map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; + } + + #[cfg(not(unix))] + { + // Rust does not expose a portable durable-directory handle. Try where the platform + // supports opening directories, but do not turn a fully persisted file into a reported + // failure where it does not. + if let Ok(directory) = std::fs::File::open(dir) { + let _ = directory.sync_all(); + } + } + + Ok(()) +} + +#[cfg(target_arch = "wasm32")] +fn write_recovery_file(_path: &Path, _bytes: &[u8]) -> Result<()> { + Err(EngineError::Other("recovery files are unavailable on the web".into())) +} + /// Recovery entries in `dir`: (uid, metadata). pub fn list(dir: &Path) -> Vec<(u64, Value)> { let mut out = Vec::new(); + #[cfg(not(target_arch = "wasm32"))] + if validate_recovery_dir(dir).is_err() { + return out; + } let Ok(rd) = std::fs::read_dir(dir) else { return out }; for e in rd.flatten() { let p = e.path(); @@ -82,8 +210,12 @@ pub fn list(dir: &Path) -> Vec<(u64, Value)> { /// Reopen every recovery entry as an unsaved document (and remove the entries: the new session /// writes its own). Returns the opened documents' indices. pub fn open(s: &mut Session, dir: &Path) -> Result<Vec<usize>> { + #[cfg(not(target_arch = "wasm32"))] + validate_recovery_dir(dir)?; let mut opened = Vec::new(); for (uid, meta) in list(dir) { + #[cfg(not(target_arch = "wasm32"))] + validate_recovery_dir(dir)?; let (doc, _) = files(dir, uid); let Ok(bytes) = std::fs::read(&doc) else { continue }; let mut d = match crate::cmd::from_bytes(&bytes) { @@ -108,6 +240,13 @@ pub fn open(s: &mut Session, dir: &Path) -> Result<Vec<usize>> { mod tests { use super::*; + fn dirty_session() -> Session { + let mut session = Session::new(); + session.execute("file.new", &json!({})).unwrap(); + session.execute("frame.create", &json!({"rect": [10, 10, 100, 100]})).unwrap(); + session + } + #[test] fn recovers_unsaved_documents() { let dir = std::env::temp_dir().join(format!("dc-recovery-{}", std::process::id())); @@ -135,6 +274,82 @@ mod tests { assert!(list(&dir).is_empty()); let _ = std::fs::remove_dir_all(&dir); } + + #[cfg(unix)] + #[test] + fn recovery_data_is_private_to_the_user() { + use std::os::unix::fs::PermissionsExt; + + let root = tempfile::tempdir().unwrap(); + let dir = root.path().join("recovery"); + let s = dirty_session(); + assert_eq!(save(&s, &dir).unwrap(), 1); + + assert_eq!(std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777, 0o700); + let entries: Vec<_> = std::fs::read_dir(&dir).unwrap().collect(); + assert_eq!(entries.len(), 2); + for entry in entries { + let entry = entry.unwrap(); + assert_eq!(entry.metadata().unwrap().permissions().mode() & 0o777, 0o600); + } + } + + #[test] + fn existing_recovery_directory_is_used() { + let root = tempfile::tempdir().unwrap(); + let dir = root.path().join("recovery"); + std::fs::create_dir(&dir).unwrap(); + + assert_eq!(save(&dirty_session(), &dir).unwrap(), 1); + assert_eq!(list(&dir).len(), 1); + assert!(std::fs::symlink_metadata(&dir).unwrap().file_type().is_dir()); + } + + #[test] + fn non_directory_recovery_path_is_rejected() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("recovery"); + std::fs::write(&path, b"not a directory").unwrap(); + + assert!(save(&dirty_session(), &path).is_err()); + assert_eq!(std::fs::read(&path).unwrap(), b"not a directory"); + } + + #[cfg(unix)] + #[test] + fn existing_recovery_directory_permissions_are_preserved() { + use std::os::unix::fs::PermissionsExt; + + let root = tempfile::tempdir().unwrap(); + let dir = root.path().join("recovery"); + std::fs::create_dir(&dir).unwrap(); + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o750)).unwrap(); + + assert_eq!(save(&dirty_session(), &dir).unwrap(), 1); + assert_eq!(std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777, 0o750); + } + + #[cfg(unix)] + #[test] + fn symlink_recovery_directory_is_rejected() { + use std::os::unix::fs::symlink; + + let root = tempfile::tempdir().unwrap(); + let destination = root.path().join("destination"); + let link = root.path().join("recovery"); + std::fs::create_dir(&destination).unwrap(); + symlink(&destination, &link).unwrap(); + + assert!(save(&dirty_session(), &link).is_err()); + assert!(std::fs::read_dir(&destination).unwrap().next().is_none()); + std::fs::write(destination.join("7.designcraft"), b"unchanged").unwrap(); + std::fs::write(destination.join("7.json"), b"{}").unwrap(); + assert!(list(&link).is_empty()); + discard(&link, 7); + assert_eq!(std::fs::read(destination.join("7.designcraft")).unwrap(), b"unchanged"); + assert!(open(&mut Session::new(), &link).is_err()); + assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink()); + } } #[cfg(test)] diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index 13411e729..c54750906 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -94,6 +94,11 @@ fi # ---- AppImage ----------------------------------------------------------------------------------- if has appimage; then + APPIMAGETOOL_VERSION=1.9.1 + case "$ARCH" in + x86_64) APPIMAGETOOL_SHA256=ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0 ;; + aarch64) APPIMAGETOOL_SHA256=f0837e7448a0c1e4e650a93bb3e85802546e60654ef287576f46c71c126a9158 ;; + esac APPDIR="$WORK/DesignCraft.AppDir" cp -R "$STAGE" "$APPDIR" mv "$APPDIR/usr/share/doc" "$WORK/doc-unused" @@ -104,11 +109,16 @@ if has appimage; then TOOL="${APPIMAGETOOL:-$(command -v appimagetool || true)}" if [ -z "$TOOL" ]; then - TOOL="$CARGO_TARGET_DIR/appimagetool-$ARCH.AppImage" + TOOL="$CARGO_TARGET_DIR/appimagetool-$APPIMAGETOOL_VERSION-$ARCH.AppImage" if [ ! -x "$TOOL" ]; then - curl -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage" - chmod +x "$TOOL" + download="$TOOL.download" + curl -fsSL -o "$download" \ + "https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-$ARCH.AppImage" + printf '%s %s\n' "$APPIMAGETOOL_SHA256" "$download" | sha256sum -c - + chmod 755 "$download" + mv "$download" "$TOOL" fi + printf '%s %s\n' "$APPIMAGETOOL_SHA256" "$TOOL" | sha256sum -c - fi # Absolute, because appimagetool runs in $DIST below (CARGO_TARGET_DIR or APPIMAGETOOL may be # relative, e.g. target/agent-<name>). diff --git a/packaging/windows/sign.ps1 b/packaging/windows/sign.ps1 index dbfadbf2a..81efe520a 100644 --- a/packaging/windows/sign.ps1 +++ b/packaging/windows/sign.ps1 @@ -56,8 +56,8 @@ if (-not $haveCert -and -not $haveAzure) { $script:SignTool = Find-SignTool $common = @('sign', '/v', '/fd', 'SHA256', '/td', 'SHA256', '/d', 'DesignCraft', '/du', 'https://github.com/storytold/designcraft') -$tmp = Join-Path ([IO.Path]::GetTempPath()) "designcraft-sign-$PID" -New-Item -ItemType Directory -Force -Path $tmp | Out-Null +$tmp = Join-Path ([IO.Path]::GetTempPath()) "designcraft-sign-$([IO.Path]::GetRandomFileName())" +New-Item -ItemType Directory -Path $tmp | Out-Null try { if ($haveCert) { @@ -73,8 +73,15 @@ try { Write-Output "Signing with Azure Trusted Signing ($env:AZURE_SIGNING_ACCOUNT / $env:AZURE_CERT_PROFILE): $($Files -join ', ')" # The dlib authenticates with DefaultAzureCredential, which reads AZURE_TENANT_ID, # AZURE_CLIENT_ID and AZURE_CLIENT_SECRET from the environment. + $trustedSigningVersion = '1.0.95' + $trustedSigningSha256 = '3bfcf1e0a3cb42af1692f0a8ed45c15de070c2de86f28a59b2795d904d8a920f' $pkg = Join-Path $tmp 'trusted-signing.zip' - Invoke-WebRequest -Uri 'https://www.nuget.org/api/v2/package/Microsoft.Trusted.Signing.Client' -OutFile $pkg + $uri = "https://api.nuget.org/v3-flatcontainer/microsoft.trusted.signing.client/$trustedSigningVersion/microsoft.trusted.signing.client.$trustedSigningVersion.nupkg" + Invoke-WebRequest -Uri $uri -OutFile $pkg + $actualHash = (Get-FileHash -Path $pkg -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actualHash -ne $trustedSigningSha256) { + throw "Microsoft.Trusted.Signing.Client $trustedSigningVersion checksum mismatch" + } Expand-Archive -Path $pkg -DestinationPath (Join-Path $tmp 'client') -Force $dlib = Get-ChildItem -Path (Join-Path $tmp 'client') -Recurse -Filter Azure.CodeSigning.Dlib.dll | Where-Object { $_.FullName -match '\\x64\\' } | Select-Object -First 1 From d0d4812ada5b2a4f69a2a87decba04f2d1dc50ec Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran <vikram.bhaskaran@outlook.com> Date: Sat, 10 Oct 2026 18:47:14 +1100 Subject: [PATCH 004/238] Set a text selection as warichu inside the parent line (#280) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Set warichu as smaller lines inside the parent line. Type › Warichu stacks the selected run after the line is broken, closes up the rest of the line, and round-trips the IDML warichu properties. The line break itself still sees the full-size run. * Keep clicks, the caret, and Up/Down on each warichu row. The glyphs were already stacked inside the parent line, but hit testing, the caret, and vertical movement still used that parent line. Break minimums, row widths, spacing, and tab leaders now follow the small lines, and underlines sit on the small baseline. --------- Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 4 +- crates/compose/src/lib.rs | 244 ++++++++++++++++++++++++++++++- crates/compose/src/shape.rs | 7 + crates/compose/src/tests.rs | 186 ++++++++++++++++++++++++ crates/compose/src/warichu.rs | 266 ++++++++++++++++++++++++++++++++++ crates/doc/src/attrs.rs | 12 ++ crates/doc/src/cjk.rs | 13 ++ crates/engine/src/cmd/text.rs | 106 +++++++++++++- crates/idml/src/cjk.rs | 23 +++ crates/idml/src/export.rs | 21 +++ crates/idml/src/import.rs | 7 + crates/idml/src/tests.rs | 26 ++++ crates/pdf/src/text.rs | 2 +- crates/render/src/text.rs | 2 +- crates/ui-egui/src/canvas.rs | 42 +----- crates/ui-egui/src/i18n.rs | 2 + crates/ui-egui/src/i18n/ar.rs | 1 + crates/ui-egui/src/menus.rs | 1 + docs/cjk.md | 13 +- 19 files changed, 935 insertions(+), 43 deletions(-) create mode 100644 crates/compose/src/warichu.rs diff --git a/ROADMAP.md b/ROADMAP.md index 821728bf7..a73d64ea8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -5,7 +5,7 @@ - AR1.1: Kashida justification tests verify space fallback without Tatweel fonts using an isolated font database, while retaining elongation assertions when fonts support it. - AR1: Arabic IDML controls now retain character direction, kashida switches, diacritic offsets/forms, paragraph policy values and independent story/table directions. Shaping preserves joining context and script/language selection; paragraph-level bidi, contextual digit conversion, mark-safe elongation, RTL columns/tables and physical column insertion have regression coverage. Vendor-specific justification and diacritic presets remain explicit preflight warnings; see docs/arabic-typography.md. -- CJK1: IDML composite fonts now retain character mappings and metrics; explicit aki, tsume, jidori, em alignment/leading, named/custom kinsoku boundaries, hanging punctuation, tate-chu-yoko offsets and kenten symbols flow through composition and IDML export. Automatic kerning no longer imports inactive numeric values as enormous manual spacing. Composite definitions are editable through commands with undo/cache invalidation. Mojikumi definitions are retained and unsupported spacing-table / push-in-push-out policies are reported by preflight. Full InDesign CJK composition parity, frame-grid layout and detailed ruby/warichu remain unfinished. +- CJK1: IDML composite fonts now retain character mappings and metrics; explicit aki, tsume, jidori, em alignment/leading, named/custom kinsoku boundaries, hanging punctuation, tate-chu-yoko offsets and kenten symbols flow through composition and IDML export. Automatic kerning no longer imports inactive numeric values as enormous manual spacing. Composite definitions are editable through commands with undo/cache invalidation. Mojikumi definitions are retained and unsupported spacing-table / push-in-push-out policies are reported by preflight. Full InDesign CJK composition parity, frame-grid layout and detailed ruby remain unfinished. Warichu sets the selected run in smaller lines inside the line after the line is broken. DesignCraft aims at full Adobe InDesign parity — and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. @@ -192,7 +192,7 @@ four agents in parallel on separate crates: |---|---| | Open P0 (2: PDF/X-4 validation against a certified checker, PostScript interpretation for EPS) | 8 h | | Open P1 (none partial) | 0 h | -| Open P2 (4 partial: localization breadth, INDD pages (not readable clean-room), in-app media playback, warichu) | 14 h | +| Open P2 (3 partial: localization breadth, INDD pages (not readable clean-room), in-app media playback) | 14 h | | Depth and pixel fidelity of every dialog, panel and menu against InDesign 2026 | 90 h | | Performance (incremental composition, GPU raster) and interchange hardening (IDML/PDF corpus) | 55 h | diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 6d5f3f769..e8af2553b 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -20,6 +20,7 @@ mod ruby; pub mod shape; pub mod table; pub mod vars; +mod warichu; pub mod xref; use std::collections::HashMap; @@ -70,6 +71,17 @@ pub struct RunStyle { pub ruby: Option<String>, pub kenten: bool, pub kenten_character: String, + /// Warichu: stack this run in smaller lines inside the parent em. + pub warichu: bool, + pub warichu_lines: u32, + /// Percentage of the parent size. + pub warichu_size: f64, + /// Extra points between warichu baselines. 0 keeps baselines one small em apart. A negative + /// value tightens that em, down to a shared baseline. + pub warichu_line_spacing: f64, + pub warichu_align: designcraft_doc::cjk::WarichuAlignment, + pub warichu_chars_before: u32, + pub warichu_chars_after: u32, } /// An underline or strikethrough bar: its centre `offset` below the baseline (negative = @@ -963,6 +975,8 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let forced_mid = brk == Some(story::FORCED_LINE_BREAK) && !last; let (mut placed, end_x, ratio) = layout_line(&glyphs, s, e, b.hyphen, lx0, lx1, col.x0, &pp, &spacing, ends_para, forced_mid, f.left_page, &bidi_info); + // Warichu runs before ruby so a reading is placed over the stacked note. + let end_x = end_x + warichu::place(&styles_tab, &mut placed); ruby::annotate(db, &styles_tab, &mut placed, doc.settings.glyph_fallback); let range_end = if last { prange.end } else { glyphs.get(g0 + b.next).map(|g| g.byte).unwrap_or(prange.end) }; let range_start = glyphs.get(s).map(|g| g.byte).unwrap_or(prange.start).min(range_end); @@ -2568,10 +2582,132 @@ fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool) { // ---------- caret and hit testing (Type tool) ---------- +/// Two stacked glyphs this far apart in `y` sit on different rows (warichu). +const ROW_Y: f64 = 0.25; + +/// (ascent, descent) of `g` in points, both positive. +fn glyph_extents(g: &PlacedGlyph) -> (f64, f64) { + let (asc, desc) = g.face.vertical_metrics(); + let sy = g.sy.abs(); + (asc * sy, desc * sy) +} + +fn x_span(g: &PlacedGlyph) -> (f64, f64) { + (g.x.min(g.x + g.adv), g.x.max(g.x + g.adv)) +} + +fn x_ranges_overlap(a: &PlacedGlyph, b: &PlacedGlyph) -> bool { + let (a0, a1) = x_span(a); + let (b0, b1) = x_span(b); + a0 < b1 - 0.2 && b0 < a1 - 0.2 +} + +fn line_glyphs(l: &Line) -> Vec<&PlacedGlyph> { + l.glyphs.iter().filter(|g| g.len > 0).collect() +} + +/// `g` shares its x with another glyph on a different baseline (a stacked warichu row). +fn on_stacked_row(glyphs: &[&PlacedGlyph], g: &PlacedGlyph) -> bool { + glyphs.iter().any(|o| (o.y - g.y).abs() > ROW_Y && x_ranges_overlap(o, g)) +} + +fn line_has_stacked_rows(l: &Line) -> bool { + let glyphs = line_glyphs(l); + glyphs.iter().any(|g| on_stacked_row(&glyphs, g)) +} + +/// The glyph `caret_x` anchors `pos` to, if the caret is not the line end. +fn caret_anchor(l: &Line, pos: usize) -> Option<&PlacedGlyph> { + for g in l.glyphs.iter().filter(|g| g.len > 0) { + if pos >= g.byte && pos < g.byte + g.len { + return Some(g); + } + if g.byte >= pos { + return Some(g); + } + } + None +} + +/// Baseline an underline or strikethrough uses. Warichu follows the small line; everything else +/// stays on the parent baseline (a baseline shift does not move the rule). +pub fn rule_baseline(style: &RunStyle, line: &Line, g: &PlacedGlyph) -> f64 { + if style.warichu { line.baseline + g.y } else { line.baseline } +} + +fn band_quad(x0: f64, x1: f64, top: f64, bot: f64) -> [Point; 4] { + [Point::new(x0, top), Point::new(x1, top), Point::new(x1, bot), Point::new(x0, bot)] +} + +/// Highlight rectangles for bytes `s..e` on `l`. `past_end` extends the line-end caret when the +/// selection continues onto the next line. Stacked rows (warichu) each get their own band. +pub fn highlight_quads(l: &Line, s: usize, e: usize, past_end: bool) -> Vec<[Point; 4]> { + let selected: Vec<&PlacedGlyph> = l.glyphs.iter().filter(|g| g.len > 0 && g.byte >= s && g.byte < e).collect(); + let stacked = selected.iter().any(|g| on_stacked_row(&selected, g)); + if stacked { + let mut items: Vec<(f64, f64, f64, f64, f64)> = selected + .iter() + .map(|g| { + let (asc, desc) = glyph_extents(g); + let (x0, x1) = x_span(g); + let y = l.baseline + g.y; + (g.y, x0, x1, y - asc, y + desc) + }) + .collect(); + items.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.total_cmp(&b.1))); + let mut quads = Vec::new(); + let mut cur: Option<(f64, f64, f64, f64, f64)> = None; + for (y, x0, x1, top, bot) in items { + match cur { + Some(c) if (c.0 - y).abs() <= ROW_Y && x0 <= c.2 + 0.5 => { + cur = Some((c.0, c.1, c.2.max(x1), c.3.min(top), c.4.max(bot))); + } + Some(c) => { + quads.push(band_quad(c.1, c.2, c.3, c.4)); + cur = Some((y, x0, x1, top, bot)); + } + None => cur = Some((y, x0, x1, top, bot)), + } + } + if let Some(c) = cur { + quads.push(band_quad(c.1, c.2, c.3, c.4)); + } + if past_end { + quads.push(band_quad(l.end_x, l.end_x + 3.0, l.baseline - l.ascent, l.baseline + l.descent)); + } + return quads; + } + let top = l.baseline - l.ascent; + let bot = l.baseline + l.descent; + if l.glyphs.iter().any(|g| g.rtl) { + let mut spans: Vec<(f64, f64)> = selected.iter().map(|g| (g.x, g.x + g.adv)).collect(); + spans.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut merged: Vec<(f64, f64)> = Vec::new(); + for (a, b) in spans { + match merged.last_mut() { + Some(m) if a <= m.1 + 0.5 => m.1 = m.1.max(b), + _ => merged.push((a, b)), + } + } + return merged.into_iter().map(|(a, b)| band_quad(a, b, top, bot)).collect(); + } + let x0 = caret_x(l, s); + let x1 = if past_end { l.end_x.max(x0 + 3.0) } else { caret_x(l, e) }; + vec![band_quad(x0, x1, top, bot)] +} + /// Caret geometry for story byte `pos`: (frame index, x, baseline, ascent, descent). +/// A stacked warichu row reports that row's baseline and em, not the parent line's. pub fn caret(cs: &ComposedStory, pos: usize) -> Option<(usize, f64, f64, f64, f64)> { let (fi, l) = caret_line(cs, pos)?; - Some((fi, caret_x(l, pos), l.baseline, l.ascent, l.descent)) + let x = caret_x(l, pos); + let glyphs = line_glyphs(l); + let metrics = caret_anchor(l, pos).filter(|g| on_stacked_row(&glyphs, g)).map(|g| { + let (asc, desc) = glyph_extents(g); + (l.baseline + g.y, asc, desc) + }); + let (bl, asc, desc) = metrics.unwrap_or((l.baseline, l.ascent, l.descent)); + Some((fi, x, bl, asc, desc)) } /// The line the caret at `pos` is drawn on (and its frame index). @@ -2661,6 +2797,9 @@ pub fn hit(cs: &ComposedStory, fi: usize, p: Point) -> Option<usize> { // Bidi: the caret position drawn nearest the point. return caret_stops(l).into_iter().min_by(|a, b| (a.1 - p.x).abs().total_cmp(&(b.1 - p.x).abs())).map(|s| s.0); } + if line_has_stacked_rows(l) { + return Some(hit_stacked(l, p)); + } let mut best = l.range.start; let mut prev_mid = f64::NEG_INFINITY; for g in l.glyphs.iter().filter(|g| g.len > 0) { @@ -2687,6 +2826,109 @@ fn line_dist(l: &Line, y: f64) -> f64 { } } +fn glyph_contains(l: &Line, g: &PlacedGlyph, p: Point) -> bool { + let (x0, x1) = x_span(g); + if p.x < x0 - 0.01 || p.x > x1 + 0.01 { + return false; + } + let (asc, desc) = glyph_extents(g); + let y = l.baseline + g.y; + p.y >= y - asc && p.y <= y + desc +} + +/// Midpoint walk over `glyphs` (visual order). Past the last glyph yields the byte after it, or +/// the line end when that glyph closes the line. +fn hit_sorted(l: &Line, mut glyphs: Vec<&PlacedGlyph>, p_x: f64) -> usize { + glyphs.sort_by(|a, b| a.x.total_cmp(&b.x)); + let last_byte = l.glyphs.iter().rev().find(|g| g.len > 0).map(|g| g.byte); + let mut best = l.range.start; + let mut prev_mid = f64::NEG_INFINITY; + let mut saw_last = false; + for g in &glyphs { + if last_byte == Some(g.byte) { + saw_last = true; + } + let mid = g.x + g.adv / 2.0; + if p_x < mid && p_x >= prev_mid { + return g.byte; + } + if mid > prev_mid { + prev_mid = mid; + } + best = g.byte + g.len; + } + if glyphs.is_empty() { + return if l.last_in_para { l.range.end } else { l.range.start }; + } + if saw_last && l.last_in_para { l.range.end } else { best.min(l.range.end) } +} + +fn hit_stacked(l: &Line, p: Point) -> usize { + let glyphs = line_glyphs(l); + let stacked = |g: &PlacedGlyph| on_stacked_row(&glyphs, g); + let mut containing: Vec<&PlacedGlyph> = glyphs.iter().copied().filter(|g| glyph_contains(l, g, p)).collect(); + containing.sort_by(|a, b| { + let da = (l.baseline + a.y - p.y).abs(); + let db = (l.baseline + b.y - p.y).abs(); + da.total_cmp(&db).then(b.x.total_cmp(&a.x)) + }); + if let Some(g) = containing.first() { + let y = g.y; + let row: Vec<&PlacedGlyph> = if stacked(g) { + glyphs.iter().copied().filter(|o| (o.y - y).abs() <= ROW_Y).collect() + } else { + glyphs.iter().copied().filter(|o| !stacked(o)).collect() + }; + return hit_sorted(l, row, p.x); + } + // Between the rows, or in the line box beside them: the nearest row under this x, else the + // glyphs that are not part of the stack (the text after the note). + let mut best: Option<(f64, f64)> = None; + for g in glyphs.iter().copied().filter(|g| stacked(g)) { + let (x0, x1) = glyphs.iter().copied().filter(|o| (o.y - g.y).abs() <= ROW_Y).fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), o| { + let (u, v) = x_span(o); + (a.min(u), b.max(v)) + }); + if p.x < x0 - 1.0 || p.x > x1 + 1.0 { + continue; + } + let dist = (l.baseline + g.y - p.y).abs(); + if best.is_none_or(|(d, _)| dist < d) { + best = Some((dist, g.y)); + } + } + if let Some((_, y)) = best { + let row = glyphs.iter().copied().filter(|o| (o.y - y).abs() <= ROW_Y).collect(); + return hit_sorted(l, row, p.x); + } + let rest = glyphs.iter().copied().filter(|g| !on_stacked_row(&glyphs, g)).collect(); + hit_sorted(l, rest, p.x) +} + +/// Baseline of the stacked row above (`up`) or below the caret at `(x, caret_y)` on `l`. +/// `None` when the caret is not over a stack, or that side has no further row. +pub fn adjacent_row(l: &Line, x: f64, caret_y: f64, up: bool) -> Option<f64> { + let glyphs = line_glyphs(l); + let mut rows: Vec<(f64, f64, f64)> = Vec::new(); + for g in glyphs.iter().copied().filter(|g| on_stacked_row(&glyphs, g)) { + let y = l.baseline + g.y; + if rows.iter().any(|r| (r.0 - y).abs() <= ROW_Y) { + continue; + } + let (x0, x1) = + glyphs.iter().copied().filter(|o| (l.baseline + o.y - y).abs() <= ROW_Y).fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), o| { + let (u, v) = x_span(o); + (a.min(u), b.max(v)) + }); + rows.push((y, x0, x1)); + } + // The right edge belongs to the following character, so a caret parked there does not + // count as still inside the note. + let over = |r: &(f64, f64, f64)| x >= r.1 - 0.01 && x < r.2 - 0.01; + let candidates = rows.iter().filter(|r| over(r)).filter(|r| if up { r.0 < caret_y - ROW_Y } else { r.0 > caret_y + ROW_Y }); + if up { candidates.map(|r| r.0).max_by(|a, b| a.total_cmp(b)) } else { candidates.map(|r| r.0).min_by(|a, b| a.total_cmp(b)) } +} + #[cfg(test)] mod tests; diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index 6e3854f3a..1e1af40a7 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -162,6 +162,13 @@ impl StyleTable<'_> { ruby: (!p.ruby.is_empty()).then(|| p.ruby.clone()), kenten: p.kenten, kenten_character: p.kenten_character.clone(), + warichu: p.warichu, + warichu_lines: p.warichu_lines, + warichu_size: p.warichu_size, + warichu_line_spacing: p.warichu_line_spacing, + warichu_align: p.warichu_alignment, + warichu_chars_before: p.warichu_chars_before_break, + warichu_chars_after: p.warichu_chars_after_break, }; if let Some(i) = self.styles.iter().rposition(|s| *s == rs) { return i as u32; diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index c853f3127..dea9008d5 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1065,6 +1065,192 @@ fn ruby_and_kenten_sit_over_their_text() { assert!((dot.x + dot.adv / 2.0 - (de.x + de.adv / 2.0)).abs() < 1.0 && dot.y < 0.0); } +#[test] +fn warichu_stacks_the_run_inside_the_line_and_closes_up() { + let text = "ABCDEFGHZ"; + let mut d = Document::new(&designcraft_doc::build::NewDocument::default()); + let lid = d.default_layer(); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 500.0, 200.0), lid, text, ParaFormat::default()).unwrap(); + let plain = compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0].glyphs.clone(); + let z0 = plain.iter().find(|g| g.byte == 8 && g.len > 0).unwrap().x; + let sx0 = plain.iter().find(|g| g.byte == 0 && g.len > 0).unwrap().sx; + d.story_mut(sid).unwrap().format_chars(0..8, |f| { + f.over.warichu = Some(true); + f.over.warichu_alignment = Some(designcraft_doc::cjk::WarichuAlignment::Left); + }); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let l = &cs.frames[0].lines[0]; + let run: Vec<_> = l.glyphs.iter().filter(|g| g.byte < 8 && g.len > 0).collect(); + assert_eq!(run.len(), 8, "the note keeps one glyph per letter"); + let size = cs.styles[run[0].style as usize].size; + assert!((run[0].sx - sx0 * 0.5).abs() < 1e-6, "half the parent size"); + let y_of = |slice: &[&PlacedGlyph]| slice.iter().map(|g| g.y).sum::<f64>() / slice.len() as f64; + let (y_top, y_bot) = (y_of(&run[..4]), y_of(&run[4..])); + assert!((y_bot - y_top - size * 0.5).abs() < 0.05 * size, "two lines one small em apart: {y_top} {y_bot} {size}"); + assert!((run[0].x - run[4].x).abs() < 1e-6, "left alignment shares the start"); + let z = l.glyphs.iter().find(|g| g.byte == 8 && g.len > 0).unwrap(); + let right = run.iter().map(|g| g.x + g.adv).fold(f64::MIN, f64::max); + assert!((z.x - right).abs() < 1e-3, "the next character follows the note: {} {right}", z.x); + assert!(z.x < z0 - 1.0, "the note is narrower than the full-size run: {} {z0}", z.x); + assert!((l.end_x - (z.x + z.adv)).abs() < 1e-3, "the line-end caret follows the close-up"); +} + +#[test] +fn warichu_break_minimum_keeps_a_short_run_on_one_line() { + let mut d = Document::new(&designcraft_doc::build::NewDocument::default()); + let lid = d.default_layer(); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 500.0, 200.0), lid, "ABCD", ParaFormat::default()).unwrap(); + d.story_mut(sid).unwrap().format_chars(0..4, |f| { + f.over.warichu = Some(true); + f.over.warichu_lines = Some(2); + f.over.warichu_chars_before_break = Some(3); + f.over.warichu_chars_after_break = Some(3); + }); + let l = &compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0]; + let run: Vec<_> = l.glyphs.iter().filter(|g| g.len > 0 && g.byte < 4).collect(); + assert_eq!(run.len(), 4); + let y0 = run[0].y; + assert!(run.iter().all(|g| (g.y - y0).abs() < 1e-6), "not enough characters to break"); +} + +#[test] +fn warichu_break_minimums_apply_to_the_first_and_last_line() { + let mut d = Document::new(&designcraft_doc::build::NewDocument::default()); + let lid = d.default_layer(); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 500.0, 200.0), lid, "ABCDEFGH", ParaFormat::default()).unwrap(); + d.story_mut(sid).unwrap().format_chars(0..8, |f| { + f.over.warichu = Some(true); + f.over.warichu_lines = Some(2); + f.over.warichu_alignment = Some(designcraft_doc::cjk::WarichuAlignment::Left); + f.over.warichu_chars_before_break = Some(1); + f.over.warichu_chars_after_break = Some(5); + }); + let l = &compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0]; + let run: Vec<_> = l.glyphs.iter().filter(|g| g.len > 0 && g.byte < 8).collect(); + let y0 = run[0].y; + let first = run.iter().filter(|g| (g.y - y0).abs() < 1e-6).count(); + let second = run.len() - first; + assert_eq!(first + second, run.len()); + assert!(first >= 1 && second >= 5, "before 1 and after 5 on eight letters: {first} {second}"); +} + +#[test] +fn warichu_rows_balance_by_width() { + let mut d = Document::new(&designcraft_doc::build::NewDocument::default()); + let lid = d.default_layer(); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 500.0, 200.0), lid, "IIIIWWWW", ParaFormat::default()).unwrap(); + d.story_mut(sid).unwrap().format_chars(0..8, |f| { + f.over.warichu = Some(true); + f.over.warichu_lines = Some(2); + f.over.warichu_alignment = Some(designcraft_doc::cjk::WarichuAlignment::Left); + }); + let l = &compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0]; + let run: Vec<_> = l.glyphs.iter().filter(|g| g.len > 0 && g.byte < 8).collect(); + let y0 = run[0].y; + let width = |pred: bool| run.iter().filter(|g| ((g.y - y0).abs() < 1e-6) == pred).map(|g| g.adv).sum::<f64>(); + let (top, bot) = (width(true), width(false)); + let widest = run.iter().map(|g| g.adv).fold(0.0_f64, f64::max); + assert!((top - bot).abs() <= widest + 0.05, "rows differ by at most one glyph: {top} {bot}"); +} + +#[test] +fn warichu_negative_spacing_tightens_the_rows() { + let mut d = Document::new(&designcraft_doc::build::NewDocument::default()); + let lid = d.default_layer(); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 500.0, 200.0), lid, "ABCDEFGH", ParaFormat::default()).unwrap(); + d.story_mut(sid).unwrap().format_chars(0..8, |f| { + f.over.warichu = Some(true); + f.over.warichu_alignment = Some(designcraft_doc::cjk::WarichuAlignment::Left); + f.over.warichu_line_spacing = Some(-1.0); + }); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let l = &cs.frames[0].lines[0]; + let run: Vec<_> = l.glyphs.iter().filter(|g| g.len > 0 && g.byte < 8).collect(); + let size = cs.styles[run[0].style as usize].size; + let gap = run[4].y - run[0].y; + assert!((gap - (size * 0.5 - 1.0)).abs() < 0.05, "one point tighter than a small em: {gap} {size}"); +} + +#[test] +fn warichu_tab_leaders_stay_in_the_gap() { + let frame = |warichu: bool| { + let mut doc = Document::new(&designcraft_doc::build::NewDocument::default()); + let lid = doc.default_layer(); + let pf = ParaFormat { + para: ParaAttrs { + tabs: Some(vec![designcraft_doc::TabStop { + position: 200.0, + align: designcraft_doc::TabAlign::Right, + leader: ".".into(), + align_on: String::new(), + }]), + ..Default::default() + }, + ..Default::default() + }; + let (_, sid) = doc.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 300.0, 100.0), lid, "Intro\tABCDEFGH", pf).unwrap(); + if warichu { + let at = "Intro\t".len(); + doc.story_mut(sid).unwrap().format_chars(at..at + 8, |f| { + f.over.warichu = Some(true); + f.over.warichu_alignment = Some(designcraft_doc::cjk::WarichuAlignment::Left); + }); + } + let cs = compose_story(&doc, sid, &ComposeOptions::default()); + let line = cs.frames[0].lines[0].clone(); + let dots: Vec<f64> = line.glyphs.iter().filter(|g| g.len == 0 && g.visible).map(|g| g.x).collect(); + (dots, line) + }; + let (plain, _) = frame(false); + let (noted, line) = frame(true); + assert!(plain.len() > 5 && plain.len() == noted.len(), "the leader is not rebuilt or dropped: {} {}", plain.len(), noted.len()); + for (a, b) in plain.iter().zip(¬ed) { + assert!((a - b).abs() < 1e-6, "a leader before the note stays put: {a} {b}"); + } + let note_x = line.glyphs.iter().filter(|g| g.len > 0 && g.byte >= "Intro\t".len()).map(|g| g.x).fold(f64::INFINITY, f64::min); + assert!(noted.iter().all(|x| *x < note_x), "leaders stay ahead of the note"); +} + +#[test] +fn warichu_hit_caret_and_selection_follow_each_row() { + let text = "ABCDEFGHZ"; + let mut d = Document::new(&designcraft_doc::build::NewDocument::default()); + let lid = d.default_layer(); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 500.0, 200.0), lid, text, ParaFormat::default()).unwrap(); + d.story_mut(sid).unwrap().format_chars(0..8, |f| { + f.over.warichu = Some(true); + f.over.warichu_alignment = Some(designcraft_doc::cjk::WarichuAlignment::Left); + }); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let l = &cs.frames[0].lines[0]; + let at = |byte: usize| l.glyphs.iter().find(|g| g.byte == byte && g.len > 0).unwrap(); + let top = at(0); + let bot = at(4); + let z = at(8); + let (_, _, y0, asc, _) = caret(&cs, 0).unwrap(); + let (_, _, y4, _, _) = caret(&cs, 4).unwrap(); + let (_, x_end, y_end, _, _) = caret(&cs, text.len()).unwrap(); + assert!((y0 - (l.baseline + top.y)).abs() < 1e-6 && (y4 - (l.baseline + bot.y)).abs() < 1e-6, "caret sits on the row"); + assert!(asc < l.ascent * 0.8, "the row caret is shorter than the parent line"); + assert!((y_end - l.baseline).abs() < 1e-6 && (x_end - l.end_x).abs() < 1e-3, "the line end stays on the parent baseline"); + let click = |g: &PlacedGlyph| hit(&cs, 0, designcraft_geom::Point::new(g.x + g.adv * 0.25, l.baseline + g.y)).unwrap(); + assert_eq!(click(top), 0); + assert_eq!(click(bot), 4); + assert_eq!(hit(&cs, 0, designcraft_geom::Point::new(z.x + z.adv * 0.25, l.baseline)).unwrap(), 8); + let up = adjacent_row(l, bot.x + bot.adv * 0.25, l.baseline + bot.y, true).unwrap(); + assert!(hit(&cs, 0, designcraft_geom::Point::new(bot.x + bot.adv * 0.25, up)).unwrap() < 4); + assert!(adjacent_row(l, top.x + 0.1, l.baseline + top.y, true).is_none(), "the top row does not move up inside the note"); + assert!(adjacent_row(l, z.x + 0.1, l.baseline, false).is_none(), "the character after the note is not inside a row"); + let quads = highlight_quads(l, 0, 8, false); + assert_eq!(quads.len(), 2, "one band per row"); + let mid_y = |q: &[designcraft_geom::Point; 4]| (q[0].y + q[2].y) / 2.0; + assert!(mid_y(&quads[0]) < l.baseline && mid_y(&quads[1]) > l.baseline, "the bands sit on either side of the parent baseline"); + let style = &cs.styles[top.style as usize]; + assert!((rule_baseline(style, l, top) - (l.baseline + top.y)).abs() < 1e-9); + let z_style = &cs.styles[z.style as usize]; + assert!(!z_style.warichu && (rule_baseline(z_style, l, z) - l.baseline).abs() < 1e-9); +} + #[test] fn tate_chu_yoko_sets_digits_across_one_em() { let mut d = Document::new(&designcraft_doc::build::NewDocument::default()); diff --git a/crates/compose/src/warichu.rs b/crates/compose/src/warichu.rs new file mode 100644 index 000000000..aaabb2e5f --- /dev/null +++ b/crates/compose/src/warichu.rs @@ -0,0 +1,266 @@ +//! Warichu (割り注): an inline note set in two or more smaller lines inside one parent em. +//! +//! Placement happens after the line is broken, in the composer's along/across space (`x` runs +//! along the line). The run gets shorter and the rest of the line closes up. Characters that did +//! not fit the line stay on the next line. Right-to-left and tate-chu-yoko runs are left alone. + +use designcraft_doc::cjk::WarichuAlignment; + +use crate::{PlacedGlyph, RunStyle}; + +pub(crate) fn place(styles: &[RunStyle], line: &mut [PlacedGlyph]) -> f64 { + if !line.iter().any(|g| is_warichu(styles, g)) { + return 0.0; + } + let mut delta = 0.0; + let mut i = 0; + while i < line.len() { + if !is_warichu(styles, &line[i]) { + i += 1; + continue; + } + let Some(key) = key(styles, &line[i]) else { + i += 1; + continue; + }; + let mut j = i + 1; + while j < line.len() { + let g = &line[j]; + if g.len == 0 { + j += 1; + continue; + } + if is_warichu(styles, g) && key_of(styles, g) == Some(key) { + j += 1; + continue; + } + break; + } + let members: Vec<usize> = (i..j).filter(|&k| is_warichu(styles, &line[k])).collect(); + if members.is_empty() || members.iter().any(|&k| line[k].rtl || line[k].tcy.is_some()) { + i = j; + continue; + } + delta += layout(styles, line, &members); + i = j; + } + delta +} + +fn style<'a>(styles: &'a [RunStyle], g: &PlacedGlyph) -> Option<&'a RunStyle> { + styles.get(g.style as usize) +} + +fn is_warichu(styles: &[RunStyle], g: &PlacedGlyph) -> bool { + g.len > 0 && g.visible && style(styles, g).is_some_and(|s| s.warichu) +} + +type Key = (u32, u64, u64, WarichuAlignment, u32, u32); + +fn key(styles: &[RunStyle], g: &PlacedGlyph) -> Option<Key> { + let s = style(styles, g)?; + Some(( + s.warichu_lines, + s.warichu_size.to_bits(), + s.warichu_line_spacing.to_bits(), + s.warichu_align, + s.warichu_chars_before, + s.warichu_chars_after, + )) +} + +fn key_of(styles: &[RunStyle], g: &PlacedGlyph) -> Option<Key> { + is_warichu(styles, g).then(|| key(styles, g)).flatten() +} + +/// How many small lines `len` glyphs can fill. The first line keeps at least `before` glyphs and +/// the last at least `after`; every other line keeps one. Fewer lines than `want` when the run is +/// too short for that break. +fn line_count(len: usize, want: usize, before: usize, after: usize) -> usize { + if len == 0 { + return 1; + } + let mut n = want.clamp(1, 16).min(len); + let before = before.max(1); + let after = after.max(1); + while n >= 2 { + if before + after + n - 2 <= len { + return n; + } + n -= 1; + } + 1 +} + +/// Glyph counts for `n` lines. Minimises the widest line, and the first and last lines keep their +/// break minimums. One line when no split satisfies them. +fn split_even(advs: &[f64], n: usize, before: usize, after: usize) -> Vec<usize> { + let m = advs.len(); + if n <= 1 || m == 0 { + return vec![m]; + } + let before = before.clamp(1, m); + let after = after.clamp(1, m); + let mut pref = vec![0.0_f64; m + 1]; + for (i, a) in advs.iter().enumerate() { + pref[i + 1] = pref[i] + *a; + } + let mut best = vec![vec![f64::INFINITY; m + 1]; n + 1]; + let mut take = vec![vec![0_usize; m + 1]; n + 1]; + for s in 0..m { + if m - s >= after { + best[1][s] = pref[m] - pref[s]; + take[1][s] = m - s; + } + } + for lines in 2..=n { + for s in 0..m { + let min_t = if lines == n { before } else { 1 }; + let min_leave = after + lines - 2; + if s + min_t + min_leave > m { + continue; + } + let max_t = m - s - min_leave; + let mut local = f64::INFINITY; + let mut best_t = min_t; + for t in min_t..=max_t { + let rest = best[lines - 1][s + t]; + if !rest.is_finite() { + continue; + } + let mx = (pref[s + t] - pref[s]).max(rest); + if mx < local { + local = mx; + best_t = t; + } + } + best[lines][s] = local; + take[lines][s] = best_t; + } + } + if !best[n][0].is_finite() { + return vec![m]; + } + let mut counts = Vec::with_capacity(n); + let mut s = 0_usize; + for lines in (1..=n).rev() { + let t = take[lines][s]; + if t == 0 || s + t > m { + return vec![m]; + } + counts.push(t); + s += t; + } + if s != m { vec![m] } else { counts } +} + +fn restore(line: &mut [PlacedGlyph], members: &[usize], saved: &[(f64, f64, f64, f64)]) { + for (i, &k) in members.iter().enumerate() { + let Some((y, adv, sx, sy)) = saved.get(i).copied() else { continue }; + let Some(g) = line.get_mut(k) else { continue }; + g.y = y; + g.adv = adv; + g.sx = sx; + g.sy = sy; + } +} + +/// Scale `members` and stack them in `lines`. Returns how far the rest of the line moves. +fn layout(styles: &[RunStyle], line: &mut [PlacedGlyph], members: &[usize]) -> f64 { + let Some(st) = members.first().and_then(|&k| style(styles, &line[k])) else { + return 0.0; + }; + let before = st.warichu_chars_before as usize; + let after = st.warichu_chars_after as usize; + let n = line_count(members.len(), st.warichu_lines as usize, before, after); + let scale = (st.warichu_size / 100.0).clamp(0.05, 4.0); + let old_x0 = members.iter().map(|&k| line[k].x).fold(f64::INFINITY, f64::min); + let old_right = members.iter().map(|&k| line[k].x + line[k].adv).fold(f64::NEG_INFINITY, f64::max); + let old_span = old_right - old_x0; + if !old_span.is_finite() || old_span <= 0.0 { + return 0.0; + } + let saved: Vec<(f64, f64, f64, f64)> = members + .iter() + .map(|&k| { + let g = &line[k]; + (g.y, g.adv, g.sx, g.sy) + }) + .collect(); + let old_xs: Vec<f64> = members.iter().map(|&k| line[k].x).collect(); + for &k in members { + line[k].sx *= scale; + line[k].sy *= scale; + line[k].adv *= scale; + line[k].y *= scale; + } + let scaled: Vec<f64> = members.iter().map(|&k| line[k].adv).collect(); + if scaled.iter().any(|a| !a.is_finite()) { + restore(line, members, &saved); + return 0.0; + } + let counts = if n <= 1 { vec![members.len()] } else { split_even(&scaled, n, before.max(1), after.max(1)) }; + let mut widths = Vec::with_capacity(counts.len()); + let mut off = 0_usize; + for &c in &counts { + if off + c > members.len() { + restore(line, members, &saved); + return 0.0; + } + let w: f64 = members[off..off + c].iter().map(|&k| line[k].adv).sum(); + widths.push(w); + off += c; + } + let block = widths.iter().copied().fold(0.0_f64, f64::max); + if !block.is_finite() || off != members.len() { + restore(line, members, &saved); + return 0.0; + } + let rows = counts.len().max(1); + let small = st.size * scale; + // 0 is the default (one small em). Any other value, including a negative one, is added to + // that em and then clamped so the rows cannot swap order. + let gap = if st.warichu_line_spacing == 0.0 { small } else { (small + st.warichu_line_spacing).max(0.0) }; + let mut off = 0_usize; + for (li, &c) in counts.iter().enumerate() { + let w = widths[li]; + let (start_x, extra) = match st.warichu_align { + WarichuAlignment::Left => (0.0, 0.0), + WarichuAlignment::Right => ((block - w).max(0.0), 0.0), + WarichuAlignment::Justify if c > 1 && block > w => (0.0, (block - w) / (c - 1) as f64), + WarichuAlignment::Auto | WarichuAlignment::Center | WarichuAlignment::Justify => ((block - w) / 2.0, 0.0), + }; + let y_off = (li as f64 - (rows as f64 - 1.0) / 2.0) * gap; + let mut x = old_x0 + start_x; + for &k in &members[off..off + c] { + line[k].x = x; + line[k].y += y_off; + x += line[k].adv + extra; + } + off += c; + } + let delta = block - old_span; + let mut placed: Vec<(f64, f64)> = old_xs.iter().enumerate().map(|(i, ox)| (*ox, line[members[i]].x)).collect(); + placed.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut member_at = vec![false; line.len()]; + for &k in members { + if let Some(slot) = member_at.get_mut(k) { + *slot = true; + } + } + for (k, g) in line.iter_mut().enumerate() { + if member_at.get(k).copied().unwrap_or(false) { + continue; + } + if g.x >= old_right - 1e-6 { + g.x += delta; + } else if g.x >= old_x0 - 1e-6 + && let Some(anchor) = placed.iter().rev().find(|p| p.0 <= g.x + 1e-6) + { + // A mark drawn with the run (a tab leader is not: it sits before old_x0) keeps its + // offset from the glyph it was attached to, in the scaled line. + g.x = anchor.1 + (g.x - anchor.0) * scale; + } + } + delta +} diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index ce658e907..3a9a7e9e6 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -692,6 +692,18 @@ attr_set! { ruby: String = String::new(), /// Kenten: an emphasis dot above each character. kenten: bool = false, + /// Warichu: the run is set in smaller lines stacked inside one line height. + warichu: bool = false, + /// How many warichu lines (InDesign's minimum is 2). + warichu_lines: u32 = 2, + /// Warichu size as a percentage of the parent size. + warichu_size: f64 = 50.0, + /// Extra points between warichu baselines. 0 is automatic (one small em). + warichu_line_spacing: f64 = 0.0, + warichu_alignment: crate::cjk::WarichuAlignment = crate::cjk::WarichuAlignment::Auto, + /// Minimum characters on a warichu line before a break, and on the line after it. + warichu_chars_before_break: u32 = 1, + warichu_chars_after_break: u32 = 1, /// Digits (World-Ready): how 0–9 are drawn. digits: Digits = Digits::Default, character_direction: crate::arabic::CharacterDirection = crate::arabic::CharacterDirection::Default, diff --git a/crates/doc/src/cjk.rs b/crates/doc/src/cjk.rs index 1ad2221e4..5696531d7 100644 --- a/crates/doc/src/cjk.rs +++ b/crates/doc/src/cjk.rs @@ -87,6 +87,19 @@ pub enum CharacterAlignment { IcfBottom, } +/// How the short lines of a warichu run sit inside the run's block. +/// `Auto` centres them. `Justify` spreads a short line out to the block width. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum WarichuAlignment { + #[default] + Auto, + Left, + Center, + Right, + Justify, +} + impl Kinsoku { /// Clean-room Unicode rules for the named CJK sets. Explicit document tables /// are imported separately and always take precedence over these defaults. diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 576fa45db..4950d82b9 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -206,6 +206,15 @@ pub fn specs() -> Vec<CommandSpec> { format_chars(s, &json!({"kenten": on})) } ), + cmd!( + "type.warichu", + "Warichu", + [], + None, + "{on?: bool, lines?: int, size?: number, lineSpacing?: number, align?: auto|left|center|right|justify, charsBeforeBreak?: int, charsAfterBreak?: int} — set the selection as an inline note in smaller lines inside one line height (toggles by default)", + has_text_or_frames, + warichu_cmd + ), cmd!( "type.storyDirection", "Story Direction", @@ -714,9 +723,18 @@ fn line_of(cs: &compose::ComposedStory, pos: usize) -> Option<(usize, usize)> { } fn vertical(cs: &compose::ComposedStory, pos: usize, up: bool) -> Option<usize> { - let (fi, x, baseline, _, _) = compose::caret(cs, pos)?; + let (fi, x, caret_y, _, _) = compose::caret(cs, pos)?; let lines: Vec<(usize, &compose::Line)> = cs.frames.iter().enumerate().flat_map(|(i, f)| f.lines.iter().map(move |l| (i, l))).collect(); - let cur = lines.iter().position(|(i, l)| *i == fi && (l.baseline - baseline).abs() < 0.01 && pos >= l.range.start && pos <= l.range.end)?; + // Match the line by the bytes it owns. A warichu caret sits on a small-line baseline, so it + // does not equal the parent line's baseline. + let cur = lines.iter().position(|(i, l)| *i == fi && pos >= l.range.start && pos <= l.range.end && (pos < l.range.end || l.last_in_para))?; + let (_, l) = lines.get(cur)?; + if let Some(y) = compose::adjacent_row(l, x, caret_y, up) + && let Some(p) = compose::hit(cs, fi, Point::new(x, y)) + && p != pos + { + return Some(p); + } let target = if up { cur.checked_sub(1)? } else { cur + 1 }; let (tf, tl) = lines.get(target)?; compose::hit(cs, *tf, Point::new(x, tl.baseline - 1.0)) @@ -764,6 +782,43 @@ pub(crate) fn format_targets(s: &Session) -> Vec<Target> { v } +fn warichu_cmd(s: &mut Session, p: &Value) -> Result<Value> { + let has_opts = ["lines", "size", "lineSpacing", "align", "charsBeforeBreak", "charsAfterBreak"].iter().any(|k| p.get(k).is_some()); + let on = match p.get("on").and_then(Value::as_bool) { + Some(v) => v, + None if has_opts => true, + None => !format_targets(s).first().is_some_and(|t| { + s.doc() + .ok() + .and_then(|d| d.doc.text_story(t.story, t.cell)) + .is_some_and(|st| st.char_format_at(if t.range.is_empty() { t.range.start } else { t.range.start + 1 }).over.warichu == Some(true)) + }), + }; + let mut body = serde_json::Map::new(); + body.insert("warichu".into(), json!(on)); + if on { + if let Some(v) = p.get("lines").and_then(Value::as_u64) { + body.insert("warichuLines".into(), json!(v.clamp(2, 16))); + } + if let Some(v) = p.get("size").and_then(Value::as_f64).filter(|v| v.is_finite()) { + body.insert("warichuSize".into(), json!(v.clamp(1.0, 1000.0))); + } + if let Some(v) = p.get("lineSpacing").and_then(Value::as_f64).filter(|v| v.is_finite()) { + body.insert("warichuLineSpacing".into(), json!(v.clamp(-10_000.0, 10_000.0))); + } + if let Some(v) = p.get("align").and_then(Value::as_str) { + body.insert("warichuAlignment".into(), json!(v)); + } + if let Some(v) = p.get("charsBeforeBreak").and_then(Value::as_u64) { + body.insert("warichuCharsBeforeBreak".into(), json!(v.min(100))); + } + if let Some(v) = p.get("charsAfterBreak").and_then(Value::as_u64) { + body.insert("warichuCharsAfterBreak".into(), json!(v.min(100))); + } + } + format_chars(s, &Value::Object(body)) +} + pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result<Value> { let mut a = CharAttrs::default(); if let Some(o) = attrs.as_object() { @@ -1564,6 +1619,53 @@ mod tcy_tests { assert_eq!(f(&s).kenten, Some(false)); assert_eq!(f(&s).ruby.as_deref(), Some("")); } + + #[test] + fn warichu_toggles_on_the_selection_and_undoes() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "注釈です"})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + let n = "注釈".len(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": n})).unwrap(); + s.execute("type.warichu", &json!({"lines": 2, "size": 40, "align": "left", "lineSpacing": 1, "charsBeforeBreak": 2, "charsAfterBreak": 2})) + .unwrap(); + let f = |s: &Session| s.doc().unwrap().doc.stories[&designcraft_doc::StoryId(sid)].char_format_at(1).over.clone(); + let a = f(&s); + assert_eq!(a.warichu, Some(true)); + assert_eq!(a.warichu_lines, Some(2)); + assert_eq!(a.warichu_size, Some(40.0)); + assert_eq!(a.warichu_line_spacing, Some(1.0)); + assert_eq!(a.warichu_alignment, Some(designcraft_doc::cjk::WarichuAlignment::Left)); + assert_eq!(a.warichu_chars_before_break, Some(2)); + assert_eq!(a.warichu_chars_after_break, Some(2)); + // The characters after the selection are not part of the note. + assert_eq!(s.doc().unwrap().doc.stories[&designcraft_doc::StoryId(sid)].char_format_at(n + 1).over.warichu, None); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(f(&s).warichu, None); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(f(&s).warichu, Some(true)); + s.execute("type.warichu", &json!({})).unwrap(); + assert_eq!(f(&s).warichu, Some(false)); + } + + #[test] + fn warichu_up_and_down_move_between_the_rows() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": "ABCDEFGHZ"})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 8})).unwrap(); + s.execute("type.warichu", &json!({"lines": 2, "align": "left"})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); + let down = s.execute("text.move", &json!({"dir": "down"})).unwrap()["pos"].as_u64().unwrap() as usize; + assert!((4..8).contains(&down), "down enters the second row: {down}"); + let up = s.execute("text.move", &json!({"dir": "up"})).unwrap()["pos"].as_u64().unwrap() as usize; + assert!(up < 4, "up returns to the first row: {up}"); + s.execute("text.select", &json!({"story": sid, "anchor": 8, "focus": 8})).unwrap(); + let stayed = s.execute("text.move", &json!({"dir": "down"})).unwrap()["pos"].as_u64().unwrap() as usize; + assert_eq!(stayed, 8, "down from the character after the note does not fall back into a row"); + } } #[cfg(test)] diff --git a/crates/idml/src/cjk.rs b/crates/idml/src/cjk.rs index 667ff8d43..ae9f25892 100644 --- a/crates/idml/src/cjk.rs +++ b/crates/idml/src/cjk.rs @@ -40,6 +40,29 @@ pub(crate) fn alignment_out(v: A) -> &'static str { A::IcfBottom => "AlignIcfBottom", } } +pub(crate) fn warichu_align_in(v: &str) -> Option<designcraft_doc::cjk::WarichuAlignment> { + use designcraft_doc::cjk::WarichuAlignment as A; + Some(match v { + "Auto" => A::Auto, + "Left" | "LeftAlign" => A::Left, + "Center" | "CenterAlign" => A::Center, + "Right" | "RightAlign" => A::Right, + "FullJustify" | "Justify" | "LeftJustify" | "CenterJustify" | "RightJustify" => A::Justify, + _ => return None, + }) +} + +pub(crate) fn warichu_align_out(v: designcraft_doc::cjk::WarichuAlignment) -> &'static str { + use designcraft_doc::cjk::WarichuAlignment as A; + match v { + A::Auto => "Auto", + A::Left => "Left", + A::Center => "Center", + A::Right => "Right", + A::Justify => "FullJustify", + } +} + pub(crate) fn kenten_character(v: &str) -> Option<&'static str> { Some(match v { "None" => "", diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 663678493..ab54177f4 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1165,6 +1165,27 @@ impl<'a> Ex<'a> { if let Some(v) = a.tate_chu_yoko { el.set("Tatechuyoko", bool_s(v)); } + if let Some(v) = a.warichu { + el.set("Warichu", bool_s(v)); + } + if let Some(v) = a.warichu_lines { + el.set("WarichuLines", v); + } + if let Some(v) = a.warichu_size { + el.set("WarichuSize", num(v)); + } + if let Some(v) = a.warichu_line_spacing { + el.set("WarichuLineSpacing", num(v)); + } + if let Some(v) = a.warichu_alignment { + el.set("WarichuAlignment", crate::cjk::warichu_align_out(v)); + } + if let Some(v) = a.warichu_chars_before_break { + el.set("WarichuCharsBeforeBreak", v); + } + if let Some(v) = a.warichu_chars_after_break { + el.set("WarichuCharsAfterBreak", v); + } if let Some(list) = &a.conditions { el.set("AppliedConditions", list.iter().map(|c| format!("Condition/{}", escape_id(c))).collect::<Vec<_>>().join(" ")); } diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 2b5f3fef7..f815fd278 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1152,6 +1152,13 @@ impl<'r> Importer<'r> { a.tate_chu_yoko = e.boolean("Tatechuyoko"); a.tate_chu_yoko_x_offset = e.num("TatechuyokoXOffset"); a.tate_chu_yoko_y_offset = e.num("TatechuyokoYOffset"); + a.warichu = e.boolean("Warichu"); + a.warichu_lines = e.num("WarichuLines").map(|v| v.max(2.0) as u32); + a.warichu_size = e.num("WarichuSize"); + a.warichu_line_spacing = e.num("WarichuLineSpacing"); + a.warichu_alignment = e.prop("WarichuAlignment").as_deref().and_then(crate::cjk::warichu_align_in); + a.warichu_chars_before_break = e.num("WarichuCharsBeforeBreak").map(|v| v.max(0.0) as u32); + a.warichu_chars_after_break = e.num("WarichuCharsAfterBreak").map(|v| v.max(0.0) as u32); if let Some(on) = e.boolean("RubyFlag") { a.ruby = Some(if on { e.prop("RubyString").unwrap_or_default() } else { String::new() }); } diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 86f287314..6aa3d7487 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -787,6 +787,32 @@ fn cjk_character_attributes_import_from_independent_xml_and_round_trip() { assert_eq!(a.leading_model, b.leading_model); } +#[test] +fn warichu_imports_from_idml_and_round_trips() { + let story = r#"<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging"><Story Self="s1"> + <ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/$ID/[No paragraph style]"> + <CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]" Warichu="true" WarichuLines="3" WarichuSize="40" WarichuLineSpacing="1" WarichuAlignment="Left" WarichuCharsBeforeBreak="2" WarichuCharsAfterBreak="2"><Content>注釈</Content></CharacterStyleRange> + </ParagraphStyleRange></Story></idPkg:Story>"#; + let doc = import_idml(&fixture_with_story(story)).unwrap(); + let st = doc.stories.values().find(|s| s.text.contains("注釈")).unwrap(); + let a = &st.runs().next().unwrap().1.over; + assert_eq!(a.warichu, Some(true)); + assert_eq!(a.warichu_lines, Some(3)); + assert_eq!(a.warichu_size, Some(40.0)); + assert_eq!(a.warichu_line_spacing, Some(1.0)); + assert_eq!(a.warichu_alignment, Some(designcraft_doc::cjk::WarichuAlignment::Left)); + assert_eq!(a.warichu_chars_before_break, Some(2)); + assert_eq!(a.warichu_chars_after_break, Some(2)); + let back = import_idml(&export_idml(&doc)).unwrap(); + let b = &back.stories.values().find(|s| s.text.contains("注釈")).unwrap().runs().next().unwrap().1.over; + assert_eq!(a.warichu, b.warichu); + assert_eq!(a.warichu_lines, b.warichu_lines); + assert_eq!(a.warichu_size, b.warichu_size); + assert_eq!(a.warichu_alignment, b.warichu_alignment); + assert_eq!(a.warichu_chars_before_break, b.warichu_chars_before_break); + assert_eq!(a.warichu_chars_after_break, b.warichu_chars_after_break); +} + #[test] fn automatic_kerning_does_not_import_inactive_numeric_values() { for (attributes, expected) in [ diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs index fb2370cb5..bf59efded 100644 --- a/crates/pdf/src/text.rs +++ b/crates/pdf/src/text.rs @@ -49,7 +49,7 @@ fn text_rules(cs: &ComposedStory, ft: &FrameText, underline: bool) -> Vec<(desig if !on { continue; } - let rect = rule.rect(g.x, g.x + g.adv, line.baseline); + let rect = rule.rect(g.x, g.x + g.adv, designcraft_compose::rule_baseline(style, line, g)); match bars.last_mut() { Some((last_rule, last)) if last_rule == rule && (last.y0 - rect.y0).abs() < 1e-6 && (rect.x0 - last.x1).abs() < 0.5 => { last.x1 = rect.x1; diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 854681642..734ec275e 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -127,7 +127,7 @@ fn build_line(db: &FontDb, cs: &ComposedStory, l: &Line, vertical: bool) -> Line let style = &cs.styles[g.style as usize]; for (on, rule, bars) in [(style.underline, &style.underline_rule, &mut underlines), (style.strikethrough, &style.strike_rule, &mut strikes)] { if on { - bars.push((rule.color.clone(), rule.tint, rule.rect(g.x, g.x + g.adv, l.baseline))); + bars.push((rule.color.clone(), rule.tint, rule.rect(g.x, g.x + g.adv, designcraft_compose::rule_baseline(style, l, g)))); } } let outline = db.outline(&g.face, g.gid); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index b4b17d3ab..34dd61442 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -986,32 +986,8 @@ fn draw_text_selection( if s > e || (s == e && !(l.range.end < range.end && e == l.range.end)) { continue; } - let quad = |x0: f64, x1: f64| { - [ - Point::new(x0, l.baseline - l.ascent), - Point::new(x1, l.baseline - l.ascent), - Point::new(x1, l.baseline + l.descent), - Point::new(x0, l.baseline + l.descent), - ] - }; - if l.glyphs.iter().any(|g| g.rtl) { - // Mixed directions: the selected glyphs' boxes, joined where they touch. - let mut spans: Vec<(f64, f64)> = - l.glyphs.iter().filter(|g| g.len > 0 && g.byte >= s && g.byte < e).map(|g| (g.x, g.x + g.adv)).collect(); - spans.sort_by(|a, b| a.0.total_cmp(&b.0)); - let mut merged: Vec<(f64, f64)> = Vec::new(); - for (a, b) in spans { - match merged.last_mut() { - Some(m) if a <= m.1 + 0.5 => m.1 = m.1.max(b), - _ => merged.push((a, b)), - } - } - quads.extend(merged.into_iter().map(|(a, b)| quad(a, b))); - } else { - let x0 = compose::caret_x(l, s); - let x1 = if e == l.range.end && range.end > l.range.end { l.end_x.max(x0 + 3.0) } else { compose::caret_x(l, e) }; - quads.push(quad(x0, x1)); - } + let past = e == l.range.end && range.end > l.range.end; + quads.extend(compose::highlight_quads(l, s, e, past)); for (gi, g) in l.glyphs.iter().enumerate() { if g.visible && g.len > 0 && g.byte >= s && g.byte < e { glyphs.push((li, gi)); @@ -1072,15 +1048,11 @@ fn draw_cell_selection( if s > e || (s == e && !(l.range.end < range.end && e == l.range.end)) { continue; } - let x0 = compose::caret_x(l, s); - let x1 = if e == l.range.end && range.end > l.range.end { l.end_x.max(x0 + 3.0) } else { compose::caret_x(l, e) }; - let q = [ - xf.to_screen(m * Point::new(x0, l.baseline - l.ascent)), - xf.to_screen(m * Point::new(x1, l.baseline - l.ascent)), - xf.to_screen(m * Point::new(x1, l.baseline + l.descent)), - xf.to_screen(m * Point::new(x0, l.baseline + l.descent)), - ]; - painter.add(egui::Shape::convex_polygon(q.to_vec(), Color32::from_rgba_unmultiplied(80, 140, 255, 110), Stroke::NONE)); + let past = e == l.range.end && range.end > l.range.end; + for q in compose::highlight_quads(l, s, e, past) { + let poly = q.map(|p| xf.to_screen(m * p)); + painter.add(egui::Shape::convex_polygon(poly.to_vec(), Color32::from_rgba_unmultiplied(80, 140, 255, 110), Stroke::NONE)); + } } } if ts.is_caret() diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index e1f386fa8..0ffb9e735 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -88,6 +88,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Tate-Chu-Yoko", ["Tate-Chu-Yoko", "Tate-Chu-Yoko", "Tate-Chu-Yoko", "縦中横", "纵中横"]), ("Ruby…", ["Ruby …", "Ruby…", "Ruby…", "ルビ…", "旁注…"]), ("Kenten", ["Kenten", "Kenten", "Kenten", "圏点", "圈点"]), + ("Warichu", ["Warichu", "Warichu", "Warichu", "割り注", "割注"]), ("Font", ["Schriftart", "Police", "Fuente", "フォント", "字体"]), ("Size", ["Schriftgrad", "Corps", "Tamaño", "サイズ", "大小"]), ("Character", ["Zeichen", "Caractère", "Carácter", "文字", "字符"]), @@ -2996,6 +2997,7 @@ mod tests { ("Tate-Chu-Yoko", "縦中横"), ("Ruby…", "ルビ…"), ("Kenten", "圏点"), + ("Warichu", "割り注"), ] { assert_eq!(tr("ja", en), ja); } diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 024e34d36..1b0c72975 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -1051,6 +1051,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Tate-Chu-Yoko", "نص أفقي داخل النص العمودي"), ("Ruby…", "روبي…"), ("Kenten", "علامات توكيد"), + ("Warichu", "واريشو"), ("Tabs", "علامات الجدولة"), ("Story", "القصة"), ("Create Outlines", "إنشاء حدود خارجية"), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 74ebaa6c0..6fc783f3a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -437,6 +437,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:type.tateChuYoko", "ui:app.rubyDialog", "cmd:type.kenten", + "cmd:type.warichu", ">Track Changes", "cmd:changes.track", "cmd:changes.acceptAll", diff --git a/docs/cjk.md b/docs/cjk.md index be1b0f349..5da0a74a8 100644 --- a/docs/cjk.md +++ b/docs/cjk.md @@ -122,7 +122,18 @@ All set after line layout like today's ruby, but able to affect line height: - **Kenten**: position, size, alignment, colour, font (the kinds and a custom character are there). - **Warichu**: an inline run set in 2+ smaller lines inside one line height, with size, line - spacing, alignment and breaking rules. + spacing, alignment and breaking rules. Type › Warichu (`type.warichu`) does this after the + line is broken: the run is scaled and stacked, and the rest of that line closes up. It does + not pull more characters onto the line. The first small line keeps at least + `WarichuCharsBeforeBreak` glyphs and the last at least `WarichuCharsAfterBreak`; the glyphs + are split so those lines are as close in width as the minimums allow. Line spacing of 0 keeps + the baselines one small em apart, a positive value adds to that em, and a negative value + tightens it down to a shared baseline. The caret, the selection, and hit testing follow the + small line the character sits on, and Up and Down move between those lines before leaving the + parent line. Underlines follow the small baseline. Right-to-left and tate-chu-yoko runs are + left as body text. IDML reads and writes `Warichu`, `WarichuLines`, `WarichuSize`, + `WarichuLineSpacing`, `WarichuAlignment`, `WarichuCharsBeforeBreak` and + `WarichuCharsAfterBreak`. - **Tate-chu-yoko**: scale-to-fit-em, automatic TCY for runs of N digits (with or without Latin), rensuji (offsets are there). - **Shatai** (oblique in the CJK sense: angle and magnification, keeping the em box), From b581031d916cfd85153fcfa2681beebfd83dfa3f Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick <hi@skylar.cc> Date: Sat, 10 Oct 2026 01:50:09 -0600 Subject: [PATCH 005/238] IDML: resolve table styles and correct cell height fitting (#288) * ci: simplify testkit dependency match for Clippy * IDML: resolve table styles and shared cell edges * Compose: size table cells by baseline and physical bounds --------- Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --- crates/compose/src/table.rs | 941 +++++++++++++++++++++++++++++-- crates/doc/src/styles.rs | 532 ++++++++++++++++- crates/doc/src/table.rs | 114 +++- crates/engine/src/cmd/table.rs | 164 +++++- crates/engine/src/tests_table.rs | 223 ++++++++ crates/idml/src/export.rs | 165 +++++- crates/idml/src/import.rs | 293 ++++++---- crates/idml/src/names.rs | 2 +- crates/idml/src/tests.rs | 330 +++++++++++ xtask/src/layers.rs | 7 +- 10 files changed, 2559 insertions(+), 212 deletions(-) diff --git a/crates/compose/src/table.rs b/crates/compose/src/table.rs index f1632992e..c70f477be 100644 --- a/crates/compose/src/table.rs +++ b/crates/compose/src/table.rs @@ -7,7 +7,7 @@ use std::sync::Arc; -use designcraft_doc::{Align, CellStroke, Document, ItemId, ParaProps, RowHeightMode, Story, Table, TextFrameOptions, VerticalJustification}; +use designcraft_doc::{Align, Cell, CellStroke, Document, ItemId, ParaProps, RowHeightMode, Table, TextFrameOptions, VerticalJustification}; use designcraft_geom::{Point, Rect}; use crate::{ComposeOptions, ComposedStory, Cursor, FrameSpec, Line}; @@ -25,7 +25,8 @@ pub struct PlacedCell { pub text: Arc<ComposedStory>, /// The cell's story text (exporters map glyphs back to text). pub source: Arc<str>, - /// Content height available in the cell (for clipping). + /// Inset content box. Text baselines fit here; descenders may enter the bottom inset. + /// Graphic content is clipped to this box. pub clip: Rect, pub overset: bool, /// A header/footer row repeated in a continuation fragment. @@ -85,10 +86,25 @@ impl TableFrag { struct CellLayout { text: Arc<ComposedStory>, content_h: f64, + line_heights: Vec<f64>, +} + +/// Cell text fits by its baseline, while its typographic/inline-object bounds must still +/// remain inside the physical cell. The bottom inset can contain ordinary descenders; +/// adding both the inset and the full descent unnecessarily grows every row. +struct CellLineExtent { + baseline: f64, + bottom: f64, +} + +impl CellLineExtent { + fn fit_height(&self, bottom_inset: f64) -> f64 { + self.baseline.max(self.bottom - bottom_inset) + } } /// Compose a cell story at width `w` (content space, top-left at the origin). -fn compose_cell(doc: &Document, story: &Story, w: f64, opts: &ComposeOptions, left_page: bool) -> CellLayout { +fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, left_page: bool) -> CellLayout { let spec = FrameSpec { id: ItemId(0), area: Rect::new(0.0, 0.0, w.max(1.0), 1.0e6), @@ -101,9 +117,28 @@ fn compose_cell(doc: &Document, story: &Story, w: f64, opts: &ComposeOptions, le left_page, page_rect: None, }; - let cs = crate::compose(doc, story, std::slice::from_ref(&spec), opts); - let content_h = cs.frames.first().map(|f| if f.lines.is_empty() { 0.0 } else { f.content_height }).unwrap_or(0.0); - CellLayout { text: Arc::new(cs), content_h } + let cs = crate::compose(doc, &cell.text, std::slice::from_ref(&spec), opts); + let line_heights: Vec<f64> = cs + .frames + .first() + .map(|f| { + let mut extents: Vec<_> = f.lines.iter().map(|l| CellLineExtent { baseline: l.baseline, bottom: l.baseline + l.descent }).collect(); + // Line metrics include baseline shifts and super/subscript. Include the final + // geometry of in-flow objects too, but leave custom anchors out of text flow. + for object in &f.objects { + if cell.text.objects.get(object.index).is_some_and(|o| !matches!(o.position, designcraft_doc::AnchorPosition::Custom { .. })) + && let Some(extent) = extents.get_mut(object.line) + { + extent.bottom = extent.bottom.max(object.origin.y + object.size.1); + } + } + extents.iter().map(|e| e.fit_height(cell.insets[2])).collect() + }) + .unwrap_or_default(); + // Check all lines: a large descender or lowered inline object on an earlier line can + // extend below the final baseline when leading is fixed. + let content_h = line_heights.iter().copied().fold(0.0, f64::max); + CellLayout { text: Arc::new(cs), content_h, line_heights } } /// Lay out `table` (anchored at byte `anchor` of paragraph `pi`) from the cursor. Returns false @@ -158,7 +193,7 @@ pub(crate) fn place_table( let cell = &table.cells[r * nc + c]; let cs = (cell.col_span.max(1) as usize).min(nc - c); let w = colx[c + cs] - colx[c] - cell.insets[1] - cell.insets[3]; - layouts.push(Some(compose_cell(doc, &cell.text, w, opts, left_page))); + layouts.push(Some(compose_cell(doc, cell, w, opts, left_page))); } } // Row heights. @@ -314,7 +349,6 @@ fn emit( let body = table.body_rows(); let nbody = body.len(); let mut cells = Vec::new(); - let mut strokes = Vec::new(); let o = &table.options; let right = x0 + colx[nc]; for &(r, y, repeated) in rows { @@ -352,13 +386,23 @@ fn emit( VerticalJustification::Bottom if space > 0.0 => space, _ => 0.0, }; - let overset = space < -0.01; + let overset = space < -0.01 || lay.text.is_overset(); let mut text = lay.text.clone(); if overset && let Some(f) = text.frames.first() { - // Drop the lines that don't fit the cell. - let keep: Vec<Line> = f.lines.iter().filter(|l| l.baseline + l.descent <= clip.height() + 0.01).cloned().collect(); + // Use the same baseline/bounds test as auto-growth, and retain a prefix: + // overset text cannot reappear after a line that did not fit. + let keep = lay.line_heights.iter().take_while(|h| **h <= clip.height() + 0.01).count(); + let overset_at = f.lines.get(keep).map(|l| l.range.start); let mut t = (*text).clone(); - t.frames[0].lines = keep; + if let Some(frame) = t.frames.first_mut() { + frame.lines.truncate(keep); + frame.objects.retain(|o| o.line < keep); + frame.tables.retain(|table| table.line < keep); + frame.range.end = frame.lines.last().map_or(frame.range.start, |l| l.range.end); + } + if let Some(at) = overset_at { + t.overset_at = Some(at); + } text = Arc::new(t); } cells.push(PlacedCell { @@ -374,28 +418,9 @@ fn emit( repeated, graphic: cell.graphic.clone(), }); - // Edges: every cell draws its top and left; the fragment's bottom/right cells their bottom/right. - // The table border replaces outer edges unless the cell explicitly overrides it. - let at_top = (y - top).abs() < 1e-6; - let at_bottom = (y1 - bottom).abs() < 1e-6; - let edge = |i: usize, outer: bool| { - if outer && !cell.border_overrides[i] { o.border.clone() } else { cell.strokes[i].clone() } - }; - let mut push = |a: Point, b: Point, s: CellStroke| { - if s.is_visible() { - strokes.push(StrokeSeg { a, b, stroke: s }); - } - }; - push(Point::new(rect.x0, rect.y0), Point::new(rect.x1, rect.y0), edge(0, at_top)); - push(Point::new(rect.x0, rect.y0), Point::new(rect.x0, rect.y1), edge(1, (rect.x0 - x0).abs() < 1e-6)); - if at_bottom { - push(Point::new(rect.x0, rect.y1), Point::new(rect.x1, rect.y1), edge(2, true)); - } - if (rect.x1 - right).abs() < 1e-6 { - push(Point::new(rect.x1, rect.y0), Point::new(rect.x1, rect.y1), edge(3, true)); - } } } + let strokes = fragment_strokes(table, rows, heights, colx, x0, bottom, owners); let ft = &mut out.frames[cur.fi]; let line = ft.lines.len(); let h = bottom - top; @@ -431,6 +456,148 @@ fn emit( }); } +#[derive(Clone, Copy, PartialEq, Eq)] +enum EdgeSource { + Cell((usize, usize), usize), + TablePattern, +} + +/// Resolve a shared edge before dropping invisible strokes: a higher-priority None is +/// meaningful and must be allowed to suppress the other cell's visible edge. +fn boundary_stroke<'a>( + table: &'a Table, + before: Option<((usize, usize), &'a Cell)>, + after: Option<((usize, usize), &'a Cell)>, + before_side: usize, + after_side: usize, + pattern: Option<&'a CellStroke>, +) -> Option<(&'a CellStroke, i32, EdgeSource)> { + let supplied = |cell: &Cell, side: usize| cell.stroke_defined[side] || cell.border_overrides[side] || cell.stroke_priorities[side] != 0; + let rank = + |cell: &Cell, side: usize| (cell.stroke_priorities[side], supplied(cell, side), supplied(cell, side) && cell.strokes[side].is_visible()); + let (owner, cell, side, outer) = match (before, after) { + (Some((ao, a)), Some((bo, b))) => { + // Table patterns supply missing interior edges; cell styles and local edits + // retain precedence, including explicit zero/None. Perimeter rules never enter + // this branch. + if !supplied(a, before_side) + && !supplied(b, after_side) + && let Some(stroke) = pattern + { + return Some((stroke, 0, EdgeSource::TablePattern)); + } + // Adobe IDML specification, Cell attributes (pp. 237-240): higher numeric + // edge priority means higher drawing priority. Equal-priority conflicts are + // unspecified: prefer supplied formatting, then a visible supplied rule. + // Metadata-free native / legacy cells retain lower/right top/left ownership, + // including explicit None already stored there by older native editing code. + if rank(a, before_side) > rank(b, after_side) { (ao, a, before_side, false) } else { (bo, b, after_side, false) } + } + (Some((owner, a)), None) => (owner, a, before_side, true), + (None, Some((owner, b))) => (owner, b, after_side, true), + (None, None) => return None, + }; + if outer && !cell.border_overrides[side] { + Some((table.options.border_for(side), 0, EdgeSource::Cell(owner, side))) + } else { + Some((&cell.strokes[side], cell.stroke_priorities[side], EdgeSource::Cell(owner, side))) + } +} + +/// Keep an unchanged winning cell edge continuous across different losing neighbors. In +/// particular, splitting a merged dashed edge would restart its dash phase at every cell. +fn push_boundary_stroke( + strokes: &mut Vec<(StrokeSeg, i32)>, + previous: &mut Option<EdgeSource>, + a: Point, + b: Point, + edge: Option<(&CellStroke, i32, EdgeSource)>, +) { + let Some((stroke, priority, source)) = edge.filter(|(s, _, _)| s.is_visible()) else { + *previous = None; + return; + }; + if *previous == Some(source) + && let Some((last, last_priority)) = strokes.last_mut() + && last.b == a + && last.stroke == *stroke + && *last_priority == priority + { + last.b = b; + } else { + strokes.push((StrokeSeg { a, b, stroke: stroke.clone() }, priority)); + } + *previous = Some(source); +} + +/// Visit each displayed grid boundary once, including boundaries between repeated headers / +/// footers and nonadjacent source body rows. Interior lines within a merged owner are omitted. +fn fragment_strokes( + table: &Table, + rows: &[(usize, f64, bool)], + heights: &[f64], + colx: &[f64], + x0: f64, + bottom: f64, + owners: &[(usize, usize)], +) -> Vec<StrokeSeg> { + let nc = table.ncols(); + let right = x0 + colx.last().copied().unwrap_or(0.0); + let rtl = table.options.direction == designcraft_doc::TextDirection::RightToLeft; + let owner = |r: usize, c: usize| { + let &(r, c) = owners.get(r.checked_mul(nc)?.checked_add(c)?)?; + Some(((r, c), table.cell(r, c)?)) + }; + let x = |c: usize| colx.get(c).map(|&offset| if rtl { right - offset } else { x0 + offset }); + // Uniform active groups do not depend on which shared edge starts the pattern. Keep + // nonuniform and skipped patterns intact in the model until their phase is verified. + let row_stroke = table.options.row_strokes.as_ref().filter(|p| p.skip_first == 0 && p.skip_last == 0).and_then(|p| p.uniform_stroke()); + let column_stroke = table.options.column_strokes.as_ref().filter(|p| p.skip_first == 0 && p.skip_last == 0).and_then(|p| p.uniform_stroke()); + let body = table.body_rows(); + let mut strokes = Vec::new(); + for boundary in 0..=rows.len() { + let before = boundary.checked_sub(1).and_then(|r| rows.get(r)); + let after = rows.get(boundary); + let y = after.map_or(bottom, |row| row.1); + let pattern = + if before.is_some_and(|row| body.contains(&row.0)) && after.is_some_and(|row| body.contains(&row.0)) { row_stroke } else { None }; + let mut previous = None; + for physical_col in 0..nc { + let c = if rtl { nc - physical_col - 1 } else { physical_col }; + let above = before.and_then(|row| owner(row.0, c)); + let below = after.and_then(|row| owner(row.0, c)); + let pair = (above.map(|v| v.0), below.map(|v| v.0)); + if pair.0 == pair.1 { + previous = None; + continue; + } + let (Some(a), Some(b)) = (x(c), x(c + 1)) else { continue }; + let edge = boundary_stroke(table, above, below, 2, 0, pattern); + push_boundary_stroke(&mut strokes, &mut previous, Point::new(a.min(b), y), Point::new(a.max(b), y), edge); + } + } + for boundary in 0..=nc { + let Some(x) = x(boundary) else { continue }; + let mut previous = None; + for &(r, y, _) in rows { + let before = boundary.checked_sub(1).and_then(|c| owner(r, c)); + let after = (boundary < nc).then(|| owner(r, boundary)).flatten(); + let (left, right) = if rtl { (after, before) } else { (before, after) }; + let pair = (left.map(|v| v.0), right.map(|v| v.0)); + if pair.0 == pair.1 { + previous = None; + continue; + } + let Some(&height) = heights.get(r) else { continue }; + let edge = boundary_stroke(table, left, right, 3, 1, if body.contains(&r) { column_stroke } else { None }); + push_boundary_stroke(&mut strokes, &mut previous, Point::new(x, y), Point::new(x, y + height), edge); + } + } + // Shared edges have a single winner; retain the documented priority at crossings too. + strokes.sort_by_key(|(_, priority)| *priority); + strokes.into_iter().map(|(stroke, _)| stroke).collect() +} + // ---------- hit testing and carets in cells ---------- /// The cell under `p` (frame inner space) in frame `fi`: (table, row, col, story byte in the cell). @@ -477,6 +644,283 @@ pub fn cell_caret(cs: &ComposedStory, table: u64, row: usize, col: usize, pos: u } } +#[cfg(test)] +mod height_tests { + use super::*; + use designcraft_doc::{AnchorPosition, AnchoredObject, CellRange, LayerId, ParaFormat, Position, SpreadRef, build::NewDocument}; + + fn text_cell(text: &str, bottom: f64) -> Cell { + let mut cell = Cell::with_text(text, ParaFormat::default()); + cell.insets = [8.5, 4.0, bottom, 4.0]; + cell.text.format_chars(0..cell.text.len(), |f| { + f.over.font_family = Some("Source Sans 3".into()); + f.over.font_style = Some("Regular".into()); + f.over.size = Some(11.0); + }); + cell + } + + fn measure(cell: &Cell, width: f64) -> CellLayout { + compose_cell(&Document::new(&NewDocument::default()), cell, width, &ComposeOptions::default(), false) + } + + fn compose_table(table: Table, height: f64) -> ComposedStory { + let mut doc = Document::new(&NewDocument::default()); + let layer = doc.default_layer(); + let (_, sid) = doc.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 200.0, height), layer, "", ParaFormat::default()).unwrap(); + doc.story_mut(sid).unwrap().insert_table(0, table); + crate::compose_story(&doc, sid, &ComposeOptions::default()) + } + + fn single_table(cell: Cell) -> Table { + let mut table = Table::new(77, 1, 1, 0, 0, 100.0); + table.cells[0] = cell; + table + } + + fn object(position: AnchorPosition) -> AnchoredObject { + let path = designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 12.0, 30.0)); + AnchoredObject::new(designcraft_doc::Item::new(ItemId(0), LayerId(0), designcraft_doc::Shape::Rectangle, path), position) + } + + #[test] + fn cell_bottom_inset_contains_descenders_without_growing_every_row() { + let cell = text_cell("gyp", 8.5); + let layout = measure(&cell, 92.0); + let line = &layout.text.frames[0].lines[0]; + assert!(line.descent > 0.0 && line.descent < cell.insets[2]); + let expected = cell.insets[0] + line.baseline + cell.insets[2]; + let composed = compose_table(single_table(cell), 200.0); + let placed = composed.frames[0].tables[0].cell(0, 0).unwrap(); + assert!((placed.rect.height() - expected).abs() < 1e-6); + assert!(!placed.overset); + assert_eq!(placed.text.frames[0].lines.len(), 1); + assert!(placed.origin.y + line.baseline + line.descent <= placed.rect.y1); + // The shared frame composer keeps its full typographic height for non-cell users. + assert!((layout.text.frames[0].content_height - line.baseline - line.descent).abs() < 1e-6); + } + + #[test] + fn baseline_sized_rows_fit_the_frame_without_losing_the_final_rows() { + let cell = text_cell("gyp", 8.5); + let layout = measure(&cell, 92.0); + let row_height = 17.0 + layout.text.frames[0].lines[0].baseline; + let mut table = Table::new(77, 16, 1, 1, 0, 100.0); + table.cells.fill(cell); + let composed = compose_table(table, 17.0 * row_height + 25.0); + assert!(!composed.is_overset()); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.cells.len(), 17); + assert!(fragment.cell(16, 0).is_some()); + assert!((fragment.rect.height() - 17.0 * row_height).abs() < 1e-6); + } + + #[test] + fn multiline_cells_keep_leading_and_paragraph_spacing() { + let mut cell = text_cell("gyp\njqp", 8.5); + cell.text.format_paras(0..3, |p| p.para.space_after = Some(6.0)); + let layout = measure(&cell, 92.0); + let lines = &layout.text.frames[0].lines; + assert_eq!(lines.len(), 2); + assert!((lines[1].baseline - lines[0].baseline - lines[1].leading - 6.0).abs() < 1e-6); + let expected = 17.0 + lines[1].baseline; + let composed = compose_table(single_table(cell), 200.0); + let placed = composed.frames[0].tables[0].cell(0, 0).unwrap(); + assert_eq!(placed.text.frames[0].lines.len(), 2); + assert!((placed.rect.height() - expected).abs() < 1e-6); + let wrapped = measure(&text_cell("gyp gyp gyp gyp gyp gyp gyp gyp", 8.5), 30.0); + assert!(wrapped.text.frames[0].lines.len() > 2); + assert!((wrapped.content_h - wrapped.text.frames[0].lines.last().unwrap().baseline).abs() < 1e-6); + } + + #[test] + fn small_insets_still_contain_descenders_and_shifted_text() { + for bottom in [0.0, 1.0] { + for (shift, position) in [(0.0, Position::Normal), (-15.0, Position::Normal), (0.0, Position::Superscript), (0.0, Position::Subscript)] { + let mut cell = text_cell("gyp", bottom); + cell.text.format_chars(0..3, |f| { + f.over.baseline_shift = Some(shift); + f.over.position = Some(position); + }); + let layout = measure(&cell, 92.0); + let line = &layout.text.frames[0].lines[0]; + let expected = cell.insets[0] + line.baseline + bottom.max(line.descent); + let composed = compose_table(single_table(cell), 200.0); + let placed = composed.frames[0].tables[0].cell(0, 0).unwrap(); + assert!((placed.rect.height() - expected).abs() < 1e-6); + assert!(placed.origin.y + line.baseline + line.descent <= placed.rect.y1 + 1e-6); + assert!(!placed.overset); + } + } + } + + #[test] + fn exact_height_uses_the_same_fit_rule_and_keeps_only_a_prefix() { + let cell = text_cell("gyp\njqp", 8.5); + let layout = measure(&cell, 92.0); + let first_height = 17.0 + layout.text.frames[0].lines[0].baseline; + let full_height = 17.0 + layout.text.frames[0].lines[1].baseline; + for (height, count, overset) in [(full_height, 2, false), (first_height, 1, true), (first_height - 1.0, 0, true)] { + let mut table = single_table(cell.clone()); + table.rows[0].mode = RowHeightMode::Exactly; + table.rows[0].height = height; + let composed = compose_table(table, 200.0); + let placed = composed.frames[0].tables[0].cell(0, 0).unwrap(); + assert!((placed.rect.height() - height).abs() < 1e-6); + assert_eq!(placed.overset, overset); + assert_eq!(placed.text.frames[0].lines.len(), count); + if overset { + assert_eq!(placed.text.overset_at, Some(layout.text.frames[0].lines[count].range.start)); + } + } + } + + #[test] + fn exact_height_cannot_fit_a_descender_outside_the_physical_cell() { + let cell = text_cell("gyp", 0.0); + let layout = measure(&cell, 92.0); + let line = &layout.text.frames[0].lines[0]; + let mut table = single_table(cell); + table.rows[0].mode = RowHeightMode::Exactly; + table.rows[0].height = 8.5 + line.baseline + line.descent / 2.0; + let composed = compose_table(table, 200.0); + let placed = composed.frames[0].tables[0].cell(0, 0).unwrap(); + assert!(placed.overset); + assert!(placed.text.frames[0].lines.is_empty()); + } + + #[test] + fn earlier_lowered_line_controls_height_and_cannot_be_skipped_on_overset() { + let mut cell = text_cell("gyp\njqp", 8.5); + cell.text.format_chars(0..cell.text.len(), |f| f.over.leading = Some(designcraft_doc::Leading::Points(6.0))); + cell.text.format_chars(0..3, |f| f.over.baseline_shift = Some(-30.0)); + let layout = measure(&cell, 92.0); + let lines = &layout.text.frames[0].lines; + assert_eq!(lines.len(), 2); + let deep_bottom = lines[0].baseline + lines[0].descent; + assert!(deep_bottom > lines[1].baseline + lines[1].descent + 8.5); + let composed = compose_table(single_table(cell.clone()), 200.0); + let placed = composed.frames[0].tables[0].cell(0, 0).unwrap(); + assert!((placed.rect.height() - 8.5 - deep_bottom).abs() < 1e-6); + assert!(!placed.overset); + + let mut table = single_table(cell); + table.rows[0].mode = RowHeightMode::Exactly; + table.rows[0].height = 17.0 + lines[1].baseline; + let composed = compose_table(table, 200.0); + let placed = composed.frames[0].tables[0].cell(0, 0).unwrap(); + assert!(placed.overset); + assert!(placed.text.frames[0].lines.is_empty(), "a later fitting line cannot reappear after the first line oversets"); + } + + #[test] + fn empty_and_trailing_empty_paragraphs_keep_their_baseline_height() { + for text in ["", "gyp\n", "gyp\n\n"] { + let cell = text_cell(text, 8.5); + let layout = measure(&cell, 92.0); + let lines = &layout.text.frames[0].lines; + assert_eq!(lines.len(), text.matches('\n').count() + 1); + assert!(lines.last().unwrap().glyphs.is_empty()); + let expected = 17.0 + lines.last().unwrap().baseline; + let composed = compose_table(single_table(cell), 200.0); + let placed = composed.frames[0].tables[0].cell(0, 0).unwrap(); + assert!((placed.rect.height() - expected).abs() < 1e-6); + assert_eq!(placed.text.frames[0].lines.len(), lines.len()); + assert!(!placed.overset); + } + } + + #[test] + fn vertical_alignment_uses_baseline_fit_without_losing_descenders() { + for bottom in [0.0, 8.5] { + for (vj, expected_shift) in + [(VerticalJustification::Top, 0.0), (VerticalJustification::Center, 10.0), (VerticalJustification::Bottom, 20.0)] + { + let mut cell = text_cell("gyp", bottom); + cell.vj = vj; + let layout = measure(&cell, 92.0); + let required = cell.insets[0] + layout.content_h + bottom; + let mut table = single_table(cell); + table.rows[0].mode = RowHeightMode::Exactly; + table.rows[0].height = required + 20.0; + let composed = compose_table(table, 200.0); + let placed = composed.frames[0].tables[0].cell(0, 0).unwrap(); + let line = &placed.text.frames[0].lines[0]; + assert!((placed.origin.y - placed.clip.y0 - expected_shift).abs() < 1e-6); + assert!(placed.origin.y + line.baseline + line.descent <= placed.rect.y1 + 1e-6); + assert!(!placed.overset); + } + } + } + + #[test] + fn row_spans_grow_to_the_same_baseline_and_physical_bounds() { + let cell = text_cell("gyp\njqp\ngyp", 8.5); + let layout = measure(&cell, 92.0); + let expected = 17.0 + layout.text.frames[0].lines.last().unwrap().baseline; + let mut table = Table::new(77, 2, 1, 0, 0, 100.0); + table.merge(CellRange::new(0, 0, 1, 0)).unwrap(); + table.rows[0].mode = RowHeightMode::Exactly; + table.rows[0].height = 3.0; + table.cells[0] = Cell { row_span: 2, ..cell }; + let composed = compose_table(table, 200.0); + let placed = composed.frames[0].tables[0].cell(0, 0).unwrap(); + assert!((placed.rect.height() - expected).abs() < 1e-6); + assert_eq!(placed.text.frames[0].lines.len(), 3); + assert!(!placed.overset); + } + + #[test] + fn lowered_inline_objects_are_contained_but_custom_anchors_stay_out_of_flow() { + let mut cell = text_cell("gyp", 8.5); + cell.text.insert_object(0, object(AnchorPosition::Inline { y_offset: -40.0 })); + let composed = compose_table(single_table(cell), 200.0); + let placed = composed.frames[0].tables[0].cell(0, 0).unwrap(); + let object = &placed.text.frames[0].objects[0]; + assert!((placed.origin.y + object.origin.y + object.size.1 - placed.rect.y1).abs() < 1e-6); + assert!(!placed.overset); + + let mut cell = text_cell("gyp", 8.5); + let original = measure(&cell, 92.0).content_h; + cell.text.insert_object( + 0, + self::object(AnchorPosition::Custom { + x_relative: Default::default(), + y_relative: Default::default(), + x_offset: 0.0, + y_offset: 100.0, + object_point: 0, + ref_point: 0, + keep_within_column: false, + }), + ); + assert!((measure(&cell, 92.0).content_h - original).abs() < 1e-6); + } + + #[test] + fn overset_lines_cannot_leave_visible_objects_or_nested_tables_behind() { + let plain = text_cell("gyp", 8.5); + let first_height = 17.0 + measure(&plain, 92.0).text.frames[0].lines[0].baseline; + for nested in [false, true] { + let mut cell = text_cell("gyp\nHidden", 8.5); + if nested { + cell.text.insert_table(4, single_table(text_cell("nested", 8.5))); + } else { + cell.text.insert_object(4, object(AnchorPosition::Inline { y_offset: 0.0 })); + } + let mut table = single_table(cell); + table.rows[0].mode = RowHeightMode::Exactly; + table.rows[0].height = first_height; + let composed = compose_table(table, 200.0); + let placed = composed.frames[0].tables[0].cell(0, 0).unwrap(); + assert!(placed.overset); + assert_eq!(placed.text.frames[0].lines.len(), 1); + assert!(placed.text.frames[0].objects.is_empty()); + assert!(placed.text.frames[0].tables.is_empty()); + } + } +} + #[cfg(test)] mod border_tests { use super::*; @@ -524,4 +968,437 @@ mod border_tests { assert_eq!(fragment.cells.len(), 1); assert!(fragment.strokes.is_empty()); } + + fn rule_table(body: usize, cols: usize, header: usize, footer: usize) -> Table { + let mut table = Table::new(77, body, cols, header, footer, 100.0); + table.options.border = CellStroke::none(); + for row in &mut table.rows { + row.height = 20.0; + row.mode = RowHeightMode::Exactly; + } + for cell in &mut table.cells { + cell.insets = [0.0; 4]; + cell.strokes = std::array::from_fn(|_| CellStroke::none()); + } + table + } + + fn set_rule(table: &mut Table, row: usize, col: usize, side: usize, weight: f64, priority: i32) { + let cell = table.cell_mut(row, col).unwrap(); + cell.strokes[side] = CellStroke { weight, ..Default::default() }; + cell.stroke_defined[side] = true; + cell.stroke_priorities[side] = priority; + } + + fn assert_no_overlapping_rules(fragment: &TableFrag) { + for (i, a) in fragment.strokes.iter().enumerate() { + for b in &fragment.strokes[i + 1..] { + let horizontal_overlap = a.a.y == a.b.y && a.a.y == b.a.y && b.a.y == b.b.y && a.a.x.max(b.a.x) < a.b.x.min(b.b.x); + let vertical_overlap = a.a.x == a.b.x && a.a.x == b.a.x && b.a.x == b.b.x && a.a.y.max(b.a.y) < a.b.y.min(b.b.y); + assert!(!horizontal_overlap && !vertical_overlap, "duplicate shared edge: {a:?}, {b:?}"); + } + } + } + + #[test] + fn header_bottom_only_style_survives_zero_body_edges() { + // Synthetic equivalent of a header with a one-point bottom rule and zero on all + // remaining header/body edges. Both sides supply style formatting at priority zero. + let mut table = rule_table(2, 3, 1, 0); + for cell in &mut table.cells { + cell.stroke_defined = [true; 4]; + cell.border_overrides = [true; 4]; + } + for col in 0..3 { + set_rule(&mut table, 0, col, 2, 1.0, 0); + } + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + let y = fragment.cell(0, 0).unwrap().rect.y1; + assert_eq!(fragment.strokes.len(), 3); + assert!(fragment.strokes.iter().all(|s| s.a.y == y && s.b.y == y && s.stroke.weight == 1.0)); + assert_eq!(fragment.strokes.iter().map(|s| s.b.x - s.a.x).sum::<f64>(), 100.0); + assert_no_overlapping_rules(fragment); + } + + #[test] + fn shared_rules_choose_numeric_priority_once_and_preserve_drawing_order() { + let mut table = rule_table(2, 2, 0, 0); + set_rule(&mut table, 0, 0, 2, 3.0, 7); + set_rule(&mut table, 1, 0, 0, 1.0, 4); + set_rule(&mut table, 0, 0, 3, 4.0, 3); + set_rule(&mut table, 0, 1, 1, 2.0, 9); + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 2); + let horizontal = &fragment.strokes[0]; + assert_eq!(horizontal.a.y, horizontal.b.y); + assert_eq!(horizontal.stroke.weight, 3.0); + let vertical = &fragment.strokes[1]; + assert_eq!(vertical.a.x, vertical.b.x); + assert_eq!(vertical.stroke.weight, 2.0, "higher priority draws last at a crossing"); + assert_no_overlapping_rules(fragment); + } + + #[test] + fn signed_negative_priorities_resolve_and_draw_in_numeric_order() { + let mut table = rule_table(2, 2, 0, 0); + set_rule(&mut table, 0, 0, 2, 1.0, -2); + set_rule(&mut table, 1, 0, 0, 3.0, -1); + set_rule(&mut table, 0, 0, 3, 2.0, -3); + set_rule(&mut table, 0, 1, 1, 4.0, -2); + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 2); + assert_eq!(fragment.strokes[0].stroke.weight, 4.0, "priority -2 draws before -1"); + assert_eq!(fragment.strokes[0].a.x, fragment.strokes[0].b.x); + assert_eq!(fragment.strokes[1].stroke.weight, 3.0, "priority -1 wins over -2"); + assert_eq!(fragment.strokes[1].a.y, fragment.strokes[1].b.y); + assert_no_overlapping_rules(fragment); + } + + #[test] + fn default_zero_priority_beats_a_negative_priority_rule() { + let mut table = rule_table(2, 1, 0, 0); + set_rule(&mut table, 0, 0, 2, 3.0, -1); + assert_eq!(table.cell(1, 0).unwrap().stroke_priorities, [0; 4]); + let composed = compose_table(table.clone()); + assert!(composed.frames[0].tables[0].strokes.is_empty(), "priority zero None suppresses priority -1"); + table.cell_mut(1, 0).unwrap().strokes[0] = CellStroke::default(); + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 1); + assert_eq!(fragment.strokes[0].stroke.weight, 1.0, "the priority-zero native rule wins over priority -1"); + } + + #[test] + fn higher_priority_zero_or_none_suppresses_a_visible_shared_rule() { + for invisible in [CellStroke::none(), CellStroke { color: designcraft_color::swatch::NONE.into(), ..Default::default() }] { + let mut table = rule_table(2, 2, 0, 0); + set_rule(&mut table, 0, 0, 2, 3.0, 7); + set_rule(&mut table, 1, 0, 0, 0.0, 8); + table.cell_mut(1, 0).unwrap().strokes[0] = invisible.clone(); + set_rule(&mut table, 0, 0, 3, 3.0, 7); + set_rule(&mut table, 0, 1, 1, 0.0, 8); + table.cell_mut(0, 1).unwrap().strokes[1] = invisible; + let composed = compose_table(table); + assert!(composed.frames[0].tables[0].strokes.is_empty()); + } + } + + #[test] + fn supplied_zero_shared_edge_beats_missing_native_default() { + let mut table = Table::new(77, 2, 1, 0, 0, 100.0); + table.options.border = CellStroke::none(); + let upper = table.cell_mut(0, 0).unwrap(); + upper.strokes[2] = CellStroke::none(); + upper.stroke_defined[2] = true; + let composed = compose_table(table.clone()); + assert!(composed.frames[0].tables[0].strokes.is_empty(), "explicit zero beats a missing edge's one-point default"); + table.cell_mut(0, 0).unwrap().stroke_defined[2] = false; + let composed = compose_table(table); + assert_eq!(composed.frames[0].tables[0].strokes.len(), 1, "a missing zero cannot suppress the opposite visible default"); + } + + #[test] + fn specified_zero_priority_outer_edges_keep_per_side_table_borders() { + for direction in [designcraft_doc::TextDirection::LeftToRight, designcraft_doc::TextDirection::RightToLeft] { + let mut table = rule_table(1, 1, 0, 0); + table.options.direction = direction; + table.options.borders = std::array::from_fn(|i| Some(CellStroke { weight: (i + 1) as f64, ..Default::default() })); + table.cell_mut(0, 0).unwrap().stroke_defined = [true; 4]; + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 4); + for rule in &fragment.strokes { + let expected = if rule.a.y == rule.b.y { + if rule.a.y == fragment.rect.y0 { 1.0 } else { 3.0 } + } else if rule.a.x == fragment.rect.x0 { + 2.0 + } else { + 4.0 + }; + assert_eq!(rule.stroke.weight, expected, "priority-zero cell values do not override perimeter formatting"); + } + assert_no_overlapping_rules(fragment); + } + } + + fn uniform_pattern(weight: f64) -> designcraft_doc::AltStrokes { + let stroke = CellStroke { weight, ..Default::default() }; + designcraft_doc::AltStrokes { first: 1, next: 1, first_stroke: stroke.clone(), next_stroke: stroke, skip_first: 0, skip_last: 0 } + } + + #[test] + fn uniform_zero_table_patterns_suppress_missing_grid_edges_but_not_perimeter() { + let mut table = Table::new(77, 2, 2, 0, 0, 100.0); + table.options.row_strokes = Some(uniform_pattern(0.0)); + table.options.column_strokes = Some(uniform_pattern(0.0)); + table.options.border.weight = 2.0; + let composed = compose_table(table.clone()); + let fragment = &composed.frames[0].tables[0]; + assert!(!fragment.strokes.is_empty(), "table perimeter remains visible"); + assert!(fragment.strokes.iter().all(|s| { + s.stroke.weight == 2.0 + && ((s.a.x == s.b.x && (s.a.x == fragment.rect.x0 || s.a.x == fragment.rect.x1)) + || (s.a.y == s.b.y && (s.a.y == fragment.rect.y0 || s.a.y == fragment.rect.y1))) + })); + assert_no_overlapping_rules(fragment); + table.options.border = CellStroke::none(); + set_rule(&mut table, 0, 0, 2, 3.0, 0); + set_rule(&mut table, 0, 1, 1, 4.0, 0); + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 2, "supplied cell strokes override the table patterns"); + assert!(fragment.strokes.iter().any(|s| s.stroke.weight == 3.0)); + assert!(fragment.strokes.iter().any(|s| s.stroke.weight == 4.0)); + assert_no_overlapping_rules(fragment); + } + + #[test] + fn uniform_visible_table_patterns_supply_continuous_rules_below_explicit_none() { + let mut table = Table::new(77, 2, 2, 0, 0, 100.0); + table.options.border = CellStroke::none(); + table.options.row_strokes = Some(uniform_pattern(2.0)); + table.options.column_strokes = Some(uniform_pattern(3.0)); + let composed = compose_table(table.clone()); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 2, "a table pattern stays continuous across cell boundaries"); + assert!(fragment.strokes.iter().any(|s| s.stroke.weight == 2.0 && s.b.x - s.a.x == 100.0)); + assert!(fragment.strokes.iter().any(|s| s.stroke.weight == 3.0 && s.b.y - s.a.y == fragment.rect.height())); + for cell in &mut table.cells { + cell.strokes = std::array::from_fn(|_| CellStroke::none()); + cell.stroke_defined = [true; 4]; + } + let composed = compose_table(table); + assert!(composed.frames[0].tables[0].strokes.is_empty(), "explicit cell None is stronger than table patterns"); + } + + #[test] + fn uniform_table_patterns_leave_header_and_footer_edges_unchanged() { + let mut table = Table::new(77, 2, 2, 1, 1, 100.0); + table.options.border = CellStroke::none(); + table.options.row_strokes = Some(uniform_pattern(0.0)); + table.options.column_strokes = Some(uniform_pattern(0.0)); + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + let header = fragment.cell(0, 0).unwrap().rect; + let body = fragment.cell(1, 0).unwrap().rect; + let footer = fragment.cell(3, 0).unwrap().rect; + assert!(fragment.strokes.iter().any(|s| s.a == Point::new(header.x1, header.y0) && s.b == Point::new(header.x1, header.y1))); + assert!(fragment.strokes.iter().any(|s| s.a == Point::new(footer.x1, footer.y0) && s.b == Point::new(footer.x1, footer.y1))); + assert!(!fragment.strokes.iter().any(|s| s.a.y == body.y1 && s.b.y == body.y1)); + assert!(!fragment.strokes.iter().any(|s| s.a == Point::new(body.x1, body.y0) && s.b == Point::new(body.x1, body.y1))); + assert_no_overlapping_rules(fragment); + } + + #[test] + fn unverified_nonuniform_or_skipped_pattern_phase_keeps_native_edges() { + for skipped in [false, true] { + let mut table = Table::new(77, 2, 2, 0, 0, 100.0); + table.options.border = CellStroke::none(); + let mut pattern = uniform_pattern(2.0); + if skipped { + pattern.skip_first = 1; + } else { + pattern.next_stroke.weight = 3.0; + } + table.options.row_strokes = Some(pattern.clone()); + table.options.column_strokes = Some(pattern); + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 4); + assert!(fragment.strokes.iter().all(|s| s.stroke.weight == 1.0)); + } + } + + #[test] + fn metadata_free_native_and_legacy_edges_keep_top_and_left_suppression() { + let mut table = Table::new(77, 2, 2, 0, 0, 100.0); + table.options.border = CellStroke::none(); + let composed = compose_table(table.clone()); + assert_eq!(composed.frames[0].tables[0].strokes.len(), 4, "unmodified native grid control"); + // The metadata arrays also default to zero/false when reading legacy JSON. Older + // native table.setCell code changed these strokes without setting any metadata. + table.cell_mut(1, 0).unwrap().strokes[0] = CellStroke::none(); + table.cell_mut(0, 1).unwrap().strokes[1].color = designcraft_color::swatch::NONE.into(); + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 2); + let hidden_top = fragment.cell(1, 0).unwrap().rect; + let hidden_left = fragment.cell(0, 1).unwrap().rect; + assert!(!fragment.strokes.iter().any(|s| s.a == Point::new(hidden_top.x0, hidden_top.y0) && s.b == Point::new(hidden_top.x1, hidden_top.y0))); + assert!( + !fragment.strokes.iter().any(|s| s.a == Point::new(hidden_left.x0, hidden_left.y0) && s.b == Point::new(hidden_left.x0, hidden_left.y1)) + ); + assert_no_overlapping_rules(fragment); + } + + #[test] + fn equal_priority_visible_rules_keep_top_and_left_ownership() { + let mut table = rule_table(2, 2, 0, 0); + set_rule(&mut table, 0, 0, 2, 5.0, 0); + set_rule(&mut table, 1, 0, 0, 2.0, 0); + set_rule(&mut table, 0, 0, 3, 6.0, 0); + set_rule(&mut table, 0, 1, 1, 3.0, 0); + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 2); + assert_eq!(fragment.strokes[0].stroke.weight, 2.0); + assert_eq!(fragment.strokes[1].stroke.weight, 3.0); + assert_no_overlapping_rules(fragment); + } + + #[test] + fn row_spanning_edge_resolves_each_neighbor_without_internal_rules() { + let mut table = rule_table(2, 2, 0, 0); + table.merge(CellRange::new(0, 0, 1, 0)).unwrap(); + set_rule(&mut table, 0, 0, 3, 1.0, 5); + set_rule(&mut table, 0, 1, 1, 2.0, 6); + set_rule(&mut table, 1, 1, 1, 3.0, 4); + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 2); + let upper = fragment.strokes.iter().find(|s| s.stroke.weight == 2.0).unwrap(); + let lower = fragment.strokes.iter().find(|s| s.stroke.weight == 1.0).unwrap(); + assert_eq!(upper.a, Point::new(50.0, fragment.rect.y0)); + assert_eq!(upper.b, lower.a); + assert_eq!(lower.b, Point::new(50.0, fragment.rect.y1)); + assert_no_overlapping_rules(fragment); + } + + #[test] + fn column_spanning_edge_resolves_each_neighbor_without_internal_rules() { + let mut table = rule_table(2, 2, 0, 0); + table.merge(CellRange::new(0, 0, 0, 1)).unwrap(); + set_rule(&mut table, 0, 0, 2, 1.0, 5); + set_rule(&mut table, 1, 0, 0, 2.0, 6); + set_rule(&mut table, 1, 1, 0, 3.0, 4); + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 2); + let left = fragment.strokes.iter().find(|s| s.stroke.weight == 2.0).unwrap(); + let right = fragment.strokes.iter().find(|s| s.stroke.weight == 1.0).unwrap(); + let y = fragment.cell(0, 0).unwrap().rect.y1; + assert_eq!(left.a, Point::new(fragment.rect.x0, y)); + assert_eq!(left.b, right.a); + assert_eq!(right.b, Point::new(fragment.rect.x1, y)); + assert_no_overlapping_rules(fragment); + } + + #[test] + fn merged_winning_dashed_edge_stays_continuous_across_losing_neighbors() { + let mut table = rule_table(2, 2, 0, 0); + table.columns.iter_mut().for_each(|column| column.width = 45.0); + table.merge(CellRange::new(1, 0, 1, 1)).unwrap(); + set_rule(&mut table, 1, 0, 0, 1.0, 1); + table.cell_mut(1, 0).unwrap().strokes[0].kind = designcraft_doc::StrokeType::Dashed { pattern: vec![6.0, 4.0] }; + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 1, "one winning source edge must retain one dash phase"); + let rule = &fragment.strokes[0]; + let merged = fragment.cell(1, 0).unwrap().rect; + assert_eq!(rule.a, Point::new(merged.x0, merged.y0)); + assert_eq!(rule.b, Point::new(merged.x1, merged.y0)); + assert_eq!(rule.b.x - rule.a.x, 90.0); + assert_no_overlapping_rules(fragment); + } + + #[test] + fn merged_perimeter_keeps_four_continuous_strokes() { + let mut table = rule_table(2, 2, 0, 0); + table.merge(CellRange::new(0, 0, 1, 1)).unwrap(); + table.options.border.weight = 2.0; + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 4); + assert_eq!(fragment.strokes.iter().filter(|s| s.a.y == s.b.y).count(), 2); + assert_eq!(fragment.strokes.iter().filter(|s| s.a.x == s.b.x).count(), 2); + assert_no_overlapping_rules(fragment); + } + + #[test] + fn rtl_shared_rules_use_physical_edges_and_outer_borders() { + let mut table = rule_table(1, 2, 0, 0); + table.options.direction = designcraft_doc::TextDirection::RightToLeft; + table.columns[0].width = 30.0; + table.columns[1].width = 70.0; + set_rule(&mut table, 0, 0, 1, 2.0, 9); + set_rule(&mut table, 0, 1, 3, 4.0, 2); + set_rule(&mut table, 0, 0, 3, 5.0, 3); + table.cell_mut(0, 0).unwrap().border_overrides[3] = true; + set_rule(&mut table, 0, 1, 1, 6.0, 4); + table.cell_mut(0, 1).unwrap().border_overrides[1] = true; + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 3); + for (x, weight) in [(0.0, 6.0), (70.0, 2.0), (100.0, 5.0)] { + let rule = fragment.strokes.iter().find(|s| s.a.x == x && s.b.x == x).unwrap(); + assert_eq!(rule.stroke.weight, weight); + } + assert_no_overlapping_rules(fragment); + } + + fn compose_two_fragments(table: Table, height: f64) -> ComposedStory { + let mut doc = Document::new(&NewDocument::default()); + let layer = doc.default_layer(); + let (first, sid) = doc.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 120.0, height), layer, "", ParaFormat::default()).unwrap(); + let (second, _) = + doc.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 200.0, 120.0, 200.0 + height), layer, "", ParaFormat::default()).unwrap(); + doc.thread(first, second).unwrap(); + doc.story_mut(sid).unwrap().insert_table(0, table); + let composed = crate::compose_story(&doc, sid, &ComposeOptions::default()); + assert!(!composed.is_overset(), "overset at {:?}", composed.overset_at); + assert_eq!(composed.frames[0].tables.len(), 1); + assert_eq!(composed.frames[1].tables.len(), 1); + composed + } + + #[test] + fn repeated_header_and_footer_rules_resolve_displayed_fragment_neighbors() { + let mut table = rule_table(4, 2, 1, 1); + table.merge(CellRange::new(0, 0, 0, 1)).unwrap(); + table.merge(CellRange::new(5, 0, 5, 1)).unwrap(); + set_rule(&mut table, 0, 0, 2, 1.0, 2); + set_rule(&mut table, 5, 0, 0, 2.0, 3); + // Keep 19 points for the anchor's trailing empty paragraph, still below the + // 100 points needed to fit a third body row plus the header and footer. + let composed = compose_two_fragments(table, 99.0); + for (i, frame) in composed.frames.iter().enumerate() { + let fragment = &frame.tables[0]; + let header = fragment.cell(0, 0).unwrap(); + let footer = fragment.cell(5, 0).unwrap(); + assert_eq!(header.repeated, i == 1); + assert_eq!(footer.repeated, i == 0); + assert_eq!(fragment.strokes.len(), 2); + let header_rules: Vec<_> = fragment.strokes.iter().filter(|s| s.a.y == header.rect.y1 && s.b.y == header.rect.y1).collect(); + let footer_rules: Vec<_> = fragment.strokes.iter().filter(|s| s.a.y == footer.rect.y0 && s.b.y == footer.rect.y0).collect(); + assert_eq!(header_rules.len(), 1); + assert!(header_rules.iter().all(|s| s.stroke.weight == 1.0)); + assert_eq!(footer_rules.len(), 1); + assert!(footer_rules.iter().all(|s| s.stroke.weight == 2.0)); + assert_no_overlapping_rules(fragment); + } + assert!(composed.frames[0].tables[0].cell(1, 0).is_some()); + assert!(composed.frames[1].tables[0].cell(3, 0).is_some()); + } + + #[test] + fn fragment_boundary_does_not_resolve_against_an_undisplayed_neighbor() { + let mut table = rule_table(4, 1, 0, 0); + set_rule(&mut table, 1, 0, 2, 3.0, 5); + table.cell_mut(1, 0).unwrap().border_overrides[2] = true; + set_rule(&mut table, 2, 0, 0, 4.0, 6); + table.cell_mut(2, 0).unwrap().border_overrides[0] = true; + // Two rows fit in each frame; leave room for the trailing empty paragraph. + let composed = compose_two_fragments(table, 59.0); + let first = &composed.frames[0].tables[0]; + let second = &composed.frames[1].tables[0]; + assert_eq!(first.strokes.len(), 1); + assert_eq!(first.strokes[0].stroke.weight, 3.0); + assert_eq!(first.strokes[0].a.y, first.rect.y1); + assert_eq!(second.strokes.len(), 1); + assert_eq!(second.strokes[0].stroke.weight, 4.0); + assert_eq!(second.strokes[0].a.y, second.rect.y0); + } } diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index 7a700fbce..e348ff295 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -148,6 +148,236 @@ impl Default for Styles { pub const NO_CELL_STYLE: &str = "[None]"; pub const BASIC_TABLE: &str = "[Basic Table]"; +pub const NO_TABLE_STYLE: &str = "[No table style]"; + +/// Partial edge formatting. Missing attributes inherit; zero weight and the None swatch +/// are explicit values, rather than a request to use the parent/default edge. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct CellStrokeAttrs { + #[serde(skip_serializing_if = "Option::is_none")] + pub weight: Option<f64>, + #[serde(skip_serializing_if = "Option::is_none")] + pub color: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub tint: Option<f32>, + #[serde(skip_serializing_if = "Option::is_none")] + pub kind: Option<crate::item::StrokeType>, +} + +impl CellStrokeAttrs { + pub fn is_empty(&self) -> bool { + self.weight.is_none() && self.color.is_none() && self.tint.is_none() && self.kind.is_none() + } + + pub fn apply_to(&self, stroke: &mut crate::table::CellStroke) { + if let Some(v) = self.weight { + stroke.weight = v; + } + if let Some(v) = &self.color { + stroke.color = v.clone(); + } + if let Some(v) = self.tint { + stroke.tint = v; + } + if let Some(v) = &self.kind { + stroke.kind = v.clone(); + } + } + + fn merge(&mut self, other: &Self) { + if other.weight.is_some() { + self.weight = other.weight; + } + if other.color.is_some() { + self.color = other.color.clone(); + } + if other.tint.is_some() { + self.tint = other.tint; + } + if other.kind.is_some() { + self.kind = other.kind.clone(); + } + } + + fn from_stroke(stroke: &crate::table::CellStroke) -> Self { + Self { weight: Some(stroke.weight), color: Some(stroke.color.clone()), tint: Some(stroke.tint), kind: Some(stroke.kind.clone()) } + } +} + +fn no_edge_attrs(edges: &[CellStrokeAttrs; 4]) -> bool { + edges.iter().all(CellStrokeAttrs::is_empty) +} + +fn no_inset_attrs(insets: &[Option<f64>; 4]) -> bool { + insets.iter().all(Option::is_none) +} + +/// Partial alternating-fill settings, retained separately from the legacy complete value. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct AltFillsAttrs { + #[serde(skip_serializing_if = "Option::is_none")] + pub first: Option<u32>, + #[serde(skip_serializing_if = "Option::is_none")] + pub first_color: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub first_tint: Option<f32>, + #[serde(skip_serializing_if = "Option::is_none")] + pub next: Option<u32>, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_color: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_tint: Option<f32>, + #[serde(skip_serializing_if = "Option::is_none")] + pub skip_first: Option<u32>, + #[serde(skip_serializing_if = "Option::is_none")] + pub skip_last: Option<u32>, +} + +impl AltFillsAttrs { + pub fn is_empty(&self) -> bool { + self == &Self::default() + } + + /// Overlay supplied attributes, retaining explicit zero counts and None swatches. + pub fn merge(&mut self, other: &Self) { + if other.first.is_some() { + self.first = other.first; + } + if other.first_color.is_some() { + self.first_color = other.first_color.clone(); + } + if other.first_tint.is_some() { + self.first_tint = other.first_tint; + } + if other.next.is_some() { + self.next = other.next; + } + if other.next_color.is_some() { + self.next_color = other.next_color.clone(); + } + if other.next_tint.is_some() { + self.next_tint = other.next_tint; + } + if other.skip_first.is_some() { + self.skip_first = other.skip_first; + } + if other.skip_last.is_some() { + self.skip_last = other.skip_last; + } + } + + fn from_fills(fills: &crate::table::AltFills) -> Self { + Self { + first: Some(fills.first), + first_color: Some(fills.first_color.clone()), + first_tint: Some(fills.first_tint), + next: Some(fills.next), + next_color: Some(fills.next_color.clone()), + next_tint: Some(fills.next_tint), + skip_first: Some(fills.skip_first), + skip_last: Some(fills.skip_last), + } + } + + pub fn apply_to(&self, fills: &mut Option<crate::table::AltFills>) { + if self.is_empty() { + return; + } + let mut value = fills.clone().unwrap_or_default(); + if let Some(v) = self.first { + value.first = v; + } + if let Some(v) = &self.first_color { + value.first_color = v.clone(); + } + if let Some(v) = self.first_tint { + value.first_tint = v; + } + if let Some(v) = self.next { + value.next = v; + } + if let Some(v) = &self.next_color { + value.next_color = v.clone(); + } + if let Some(v) = self.next_tint { + value.next_tint = v; + } + if let Some(v) = self.skip_first { + value.skip_first = v; + } + if let Some(v) = self.skip_last { + value.skip_last = v; + } + *fills = (value.first > 0).then_some(value); + } +} + +/// Partial table alternating strokes; missing attributes inherit independently. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct AltStrokesAttrs { + #[serde(skip_serializing_if = "Option::is_none")] + pub first: Option<u32>, + #[serde(skip_serializing_if = "Option::is_none")] + pub next: Option<u32>, + #[serde(skip_serializing_if = "CellStrokeAttrs::is_empty")] + pub first_stroke: CellStrokeAttrs, + #[serde(skip_serializing_if = "CellStrokeAttrs::is_empty")] + pub next_stroke: CellStrokeAttrs, + #[serde(skip_serializing_if = "Option::is_none")] + pub skip_first: Option<u32>, + #[serde(skip_serializing_if = "Option::is_none")] + pub skip_last: Option<u32>, +} + +impl AltStrokesAttrs { + pub fn is_empty(&self) -> bool { + self == &Self::default() + } + + pub fn merge(&mut self, other: &Self) { + if other.first.is_some() { + self.first = other.first; + } + if other.next.is_some() { + self.next = other.next; + } + self.first_stroke.merge(&other.first_stroke); + self.next_stroke.merge(&other.next_stroke); + if other.skip_first.is_some() { + self.skip_first = other.skip_first; + } + if other.skip_last.is_some() { + self.skip_last = other.skip_last; + } + } + + pub fn apply_to(&self, strokes: &mut Option<crate::table::AltStrokes>) { + if self.is_empty() { + return; + } + let mut value = strokes.clone().unwrap_or_default(); + if let Some(v) = self.first { + value.first = v; + } + if let Some(v) = self.next { + value.next = v; + } + self.first_stroke.apply_to(&mut value.first_stroke); + self.next_stroke.apply_to(&mut value.next_stroke); + if let Some(v) = self.skip_first { + value.skip_first = v; + } + if let Some(v) = self.skip_last { + value.skip_last = v; + } + // Zero counts disable rendering, but the remaining settings still belong to + // the document (and are reused if the pattern is enabled later). + *strokes = Some(value); + } +} /// A cell style: the cell attributes it sets (unset = left as is) and a paragraph style for the /// cell's text. @@ -156,16 +386,24 @@ pub const BASIC_TABLE: &str = "[Basic Table]"; pub struct CellStyle { pub name: String, #[serde(skip_serializing_if = "Option::is_none")] + pub based_on: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] pub fill: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub fill_tint: Option<f32>, #[serde(skip_serializing_if = "Option::is_none")] pub insets: Option<[f64; 4]>, + /// Per-side values override `insets`; top, left, bottom, right. + #[serde(skip_serializing_if = "no_inset_attrs")] + pub inset_overrides: [Option<f64>; 4], #[serde(skip_serializing_if = "Option::is_none")] pub vj: Option<crate::item::VerticalJustification>, /// All four edges. #[serde(skip_serializing_if = "Option::is_none")] pub stroke: Option<crate::table::CellStroke>, + /// Per-edge attributes override the legacy uniform `stroke`. + #[serde(skip_serializing_if = "no_edge_attrs")] + pub strokes: [CellStrokeAttrs; 4], #[serde(skip_serializing_if = "Option::is_none")] pub paragraph_style: Option<String>, } @@ -176,6 +414,16 @@ pub struct CellStyle { pub struct TableStyle { pub name: String, #[serde(skip_serializing_if = "Option::is_none")] + pub based_on: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub header_same_as_body: Option<bool>, + #[serde(skip_serializing_if = "Option::is_none")] + pub footer_same_as_body: Option<bool>, + #[serde(skip_serializing_if = "Option::is_none")] + pub left_column_same_as_body: Option<bool>, + #[serde(skip_serializing_if = "Option::is_none")] + pub right_column_same_as_body: Option<bool>, + #[serde(skip_serializing_if = "Option::is_none")] pub header: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub body: Option<String>, @@ -187,8 +435,18 @@ pub struct TableStyle { pub right_column: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub border: Option<crate::table::CellStroke>, + #[serde(skip_serializing_if = "no_edge_attrs")] + pub borders: [CellStrokeAttrs; 4], #[serde(skip_serializing_if = "Option::is_none")] pub alt_rows: Option<crate::table::AltFills>, + #[serde(skip_serializing_if = "AltFillsAttrs::is_empty")] + pub row_fills: AltFillsAttrs, + #[serde(skip_serializing_if = "AltFillsAttrs::is_empty")] + pub column_fills: AltFillsAttrs, + #[serde(skip_serializing_if = "AltStrokesAttrs::is_empty")] + pub row_strokes: AltStrokesAttrs, + #[serde(skip_serializing_if = "AltStrokesAttrs::is_empty")] + pub column_strokes: AltStrokesAttrs, #[serde(skip_serializing_if = "Option::is_none")] pub space_before: Option<f64>, #[serde(skip_serializing_if = "Option::is_none")] @@ -207,11 +465,25 @@ impl CellStyle { if let Some(v) = self.insets { cell.insets = v; } + for (slot, value) in cell.insets.iter_mut().zip(self.inset_overrides) { + if let Some(value) = value { + *slot = value; + } + } if let Some(v) = self.vj { cell.vj = v; } if let Some(v) = &self.stroke { cell.strokes = [v.clone(), v.clone(), v.clone(), v.clone()]; + cell.border_overrides = [true; 4]; + cell.stroke_defined = [true; 4]; + } + for (i, edge) in self.strokes.iter().enumerate() { + edge.apply_to(&mut cell.strokes[i]); + if !edge.is_empty() { + cell.border_overrides[i] = true; + cell.stroke_defined[i] = true; + } } if let Some(ps) = &self.paragraph_style { for p in &mut cell.text.paras { @@ -224,14 +496,33 @@ impl CellStyle { } impl TableStyle { - /// Write this style into `t`: options, then each cell's region style. + /// Write the resolved table options, then region cell styles in precedence order: + /// body, left/right columns, header/footer. Explicit cell formatting is applied later. pub fn apply_to(&self, t: &mut crate::table::Table, cells: &[CellStyle]) { if let Some(b) = &self.border { t.options.border = b.clone(); + t.options.borders = Default::default(); + } + for (i, attrs) in self.borders.iter().enumerate() { + if !attrs.is_empty() { + let mut edge = t.options.border_for(i).clone(); + attrs.apply_to(&mut edge); + t.options.borders[i] = Some(edge); + } + } + if self.borders.iter().any(|edge| !edge.is_empty()) { + // Keep the legacy uniform accessor representative without changing absent sides. + let borders = std::array::from_fn::<_, 4, _>(|i| t.options.border_for(i).clone()); + t.options.border = borders[0].clone(); + t.options.borders = borders.map(Some); } if self.alt_rows.is_some() { t.options.alt_rows = self.alt_rows.clone(); } + self.row_fills.apply_to(&mut t.options.alt_rows); + self.column_fills.apply_to(&mut t.options.alt_cols); + self.row_strokes.apply_to(&mut t.options.row_strokes); + self.column_strokes.apply_to(&mut t.options.column_strokes); if let Some(v) = self.space_before { t.options.space_before = v; } @@ -243,27 +534,148 @@ impl TableStyle { let kind = t.rows[r].kind; for c in 0..nc { let visual_column = if t.options.direction == crate::TextDirection::RightToLeft { nc - 1 - c } else { c }; - let region = match kind { - crate::table::RowKind::Header => self.header.as_ref(), - crate::table::RowKind::Footer => self.footer.as_ref(), - _ if visual_column == 0 && self.left_column.is_some() => self.left_column.as_ref(), - _ if visual_column + 1 == nc && self.right_column.is_some() => self.right_column.as_ref(), - _ => self.body.as_ref(), + let same_or_region = |same, region: &Option<String>| { + if same == Some(true) { self.body.clone() } else { region.clone() } }; - if let (Some(name), Some(cell)) = (region, t.cells.get_mut(r * nc + c)) - && let Some(cs) = cells.iter().find(|s| s.name == *name) - { - cs.apply_to(cell); + let left = same_or_region(self.left_column_same_as_body, &self.left_column); + let column = if visual_column == 0 && left.is_some() { + left + } else if visual_column + 1 == nc { + same_or_region(self.right_column_same_as_body, &self.right_column) + } else { + None + }; + let row = match kind { + crate::table::RowKind::Header => same_or_region(self.header_same_as_body, &self.header), + crate::table::RowKind::Footer => same_or_region(self.footer_same_as_body, &self.footer), + crate::table::RowKind::Body => None, + }; + if let Some(cell) = t.cells.get_mut(r * nc + c) { + for name in [self.body.as_ref(), column.as_ref(), row.as_ref()].into_iter().flatten() { + if name != NO_CELL_STYLE { + resolve_cell_style(cells, name).apply_to(cell); + } + } } } } t.style = if self.name == BASIC_TABLE { String::new() } else { self.name.clone() }; } + + fn merge(&mut self, other: &Self) { + if other.header.is_some() { + self.header = other.header.clone(); + } + if other.body.is_some() { + self.body = other.body.clone(); + } + if other.footer.is_some() { + self.footer = other.footer.clone(); + } + if other.left_column.is_some() { + self.left_column = other.left_column.clone(); + } + if other.right_column.is_some() { + self.right_column = other.right_column.clone(); + } + if other.header_same_as_body.is_some() { + self.header_same_as_body = other.header_same_as_body; + } + if other.footer_same_as_body.is_some() { + self.footer_same_as_body = other.footer_same_as_body; + } + if other.left_column_same_as_body.is_some() { + self.left_column_same_as_body = other.left_column_same_as_body; + } + if other.right_column_same_as_body.is_some() { + self.right_column_same_as_body = other.right_column_same_as_body; + } + if let Some(border) = &other.border { + self.borders = std::array::from_fn(|_| CellStrokeAttrs::from_stroke(border)); + } + for (edge, value) in self.borders.iter_mut().zip(&other.borders) { + edge.merge(value); + } + if let Some(fills) = &other.alt_rows { + self.row_fills = AltFillsAttrs::from_fills(fills); + } + self.row_fills.merge(&other.row_fills); + self.column_fills.merge(&other.column_fills); + self.row_strokes.merge(&other.row_strokes); + self.column_strokes.merge(&other.column_strokes); + if other.space_before.is_some() { + self.space_before = other.space_before; + } + if other.space_after.is_some() { + self.space_after = other.space_after; + } + } +} + +/// Resolve a cell style with the same 32-level/cycle limit as paragraph styles. +pub fn resolve_cell_style(cells: &[CellStyle], name: &str) -> CellStyle { + let mut chain = Vec::new(); + let mut cur = cells.iter().find(|s| s.name == name); + while let Some(style) = cur { + if chain.len() >= MAX_DEPTH || chain.iter().any(|s: &&CellStyle| s.name == style.name) { + break; + } + chain.push(style); + cur = style.based_on.as_deref().and_then(|base| cells.iter().find(|s| s.name == base)); + } + let mut out = CellStyle { name: name.into(), ..Default::default() }; + for style in chain.into_iter().rev() { + if style.fill.is_some() { + out.fill = style.fill.clone(); + } + if style.fill_tint.is_some() { + out.fill_tint = style.fill_tint; + } + if let Some(insets) = style.insets { + out.inset_overrides = insets.map(Some); + } + for (slot, value) in out.inset_overrides.iter_mut().zip(style.inset_overrides) { + if value.is_some() { + *slot = value; + } + } + if style.vj.is_some() { + out.vj = style.vj; + } + if let Some(stroke) = &style.stroke { + out.strokes = std::array::from_fn(|_| CellStrokeAttrs::from_stroke(stroke)); + } + for (slot, attrs) in out.strokes.iter_mut().zip(&style.strokes) { + slot.merge(attrs); + } + if style.paragraph_style.is_some() { + out.paragraph_style = style.paragraph_style.clone(); + } + } + out } const MAX_DEPTH: usize = 32; impl Styles { + /// Resolve table style inheritance without recursion; repeated/missing bases stop the chain. + pub fn resolve_table_style(&self, name: &str) -> TableStyle { + let mut chain = Vec::new(); + let mut cur = self.table.iter().find(|s| s.name == name); + while let Some(style) = cur { + if chain.len() >= MAX_DEPTH || chain.iter().any(|s: &&TableStyle| s.name == style.name) { + break; + } + chain.push(style); + cur = style.based_on.as_deref().and_then(|base| self.table.iter().find(|s| s.name == base)); + } + let mut out = TableStyle { name: name.into(), ..Default::default() }; + for style in chain.into_iter().rev() { + out.merge(style); + } + out + } + pub fn para(&self, name: &str) -> Option<&ParagraphStyle> { self.paragraph.iter().find(|s| s.name == name) } @@ -411,6 +823,104 @@ mod tests { assert!(!st.para_based_on_cycles(BASIC_PARAGRAPH, NO_PARA_STYLE)); } + #[test] + fn legacy_cell_and_table_style_json_keep_uniform_values() { + let cell: CellStyle = + serde_json::from_str(r#"{"name":"Legacy","insets":[1,2,3,4],"stroke":{"weight":2,"color":"Brand","tint":0.5,"kind":{"kind":"solid"}}}"#) + .unwrap(); + assert!(cell.based_on.is_none()); + assert!(no_edge_attrs(&cell.strokes)); + assert!(no_inset_attrs(&cell.inset_overrides)); + let resolved = resolve_cell_style(std::slice::from_ref(&cell), "Legacy"); + let mut target = crate::table::Cell::default(); + resolved.apply_to(&mut target); + assert_eq!(target.insets, [1.0, 2.0, 3.0, 4.0]); + assert!(target.strokes.iter().all(|s| s.weight == 2.0 && s.color == "Brand" && s.tint == 0.5)); + let json = serde_json::to_value(&cell).unwrap(); + assert!(json.get("strokes").is_none()); + assert_eq!(serde_json::from_value::<CellStyle>(json).unwrap(), cell); + + let table: TableStyle = + serde_json::from_str(r#"{"name":"Legacy","body":"Legacy","border":{"weight":3},"altRows":{"first":2,"firstColor":"Brand"}}"#).unwrap(); + let styles = Styles { cell: vec![cell], table: vec![table.clone()], ..Styles::default() }; + let mut target = crate::table::Table::new(1, 1, 2, 0, 0, 100.0); + styles.resolve_table_style("Legacy").apply_to(&mut target, &styles.cell); + assert_eq!(target.options.border_for(3).weight, 3.0); + assert_eq!(target.options.alt_rows.as_ref().unwrap().first, 2); + assert_eq!(target.cell(0, 0).unwrap().strokes[0].weight, 2.0); + assert_eq!(serde_json::from_str::<TableStyle>(&serde_json::to_string(&table).unwrap()).unwrap(), table); + } + + #[test] + fn table_region_flags_cover_footer_right_column_and_rtl() { + let cells = [ + CellStyle { name: "Body".into(), fill: Some("Body".into()), ..Default::default() }, + CellStyle { name: "Footer".into(), fill: Some("Footer".into()), ..Default::default() }, + CellStyle { name: "Right".into(), fill: Some("Right".into()), ..Default::default() }, + ]; + let mut style = TableStyle { + body: Some("Body".into()), + footer: Some("Footer".into()), + right_column: Some("Right".into()), + footer_same_as_body: Some(false), + right_column_same_as_body: Some(false), + ..Default::default() + }; + for direction in [crate::TextDirection::LeftToRight, crate::TextDirection::RightToLeft] { + let mut table = crate::table::Table::new(1, 1, 3, 0, 1, 100.0); + table.options.direction = direction; + let right = if direction == crate::TextDirection::LeftToRight { 2 } else { 0 }; + style.apply_to(&mut table, &cells); + assert_eq!(table.cell(0, right).unwrap().fill, "Right"); + assert_eq!(table.cell(1, right).unwrap().fill, "Footer"); + } + style.footer_same_as_body = Some(true); + style.right_column_same_as_body = Some(true); + let mut table = crate::table::Table::new(1, 1, 3, 0, 1, 100.0); + style.apply_to(&mut table, &cells); + assert!(table.cells.iter().all(|c| c.fill == "Body")); + } + + #[test] + fn single_column_tables_use_a_configured_right_region_when_left_is_absent() { + let cells = [CellStyle { name: "Right".into(), fill: Some("Right".into()), ..Default::default() }]; + let style = TableStyle { right_column: Some("Right".into()), ..Default::default() }; + let mut table = crate::table::Table::new(1, 1, 1, 0, 0, 100.0); + style.apply_to(&mut table, &cells); + assert_eq!(table.cell(0, 0).unwrap().fill, "Right"); + } + + #[test] + fn cell_and_table_style_resolution_stop_at_the_depth_limit() { + let cells: Vec<_> = (0..40) + .map(|i| CellStyle { + name: i.to_string(), + based_on: Some((i + 1).to_string()), + fill: (i == 32).then(|| "Too deep".into()), + fill_tint: (i == 31).then_some(0.5), + ..Default::default() + }) + .collect(); + let resolved = resolve_cell_style(&cells, "0"); + assert!(resolved.fill.is_none()); + assert_eq!(resolved.fill_tint, Some(0.5)); + let styles = Styles { + table: (0..40) + .map(|i| TableStyle { + name: i.to_string(), + based_on: Some((i + 1).to_string()), + space_before: (i == 32).then_some(123.0), + space_after: (i == 31).then_some(15.0), + ..Default::default() + }) + .collect(), + ..Styles::default() + }; + let resolved = styles.resolve_table_style("0"); + assert!(resolved.space_before.is_none()); + assert_eq!(resolved.space_after, Some(15.0)); + } + #[test] fn character_style_attrs_include_its_based_on_chain_only() { let mut st = Styles::default(); diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index ff7844b1a..7fe385e98 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -79,6 +79,58 @@ impl CellStroke { } } +/// Table-level alternating row/column strokes. Retained independently of cell formatting +/// so an explicit cell style or local edge continues to take precedence. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct AltStrokes { + pub first: u32, + pub next: u32, + pub first_stroke: CellStroke, + pub next_stroke: CellStroke, + pub skip_first: u32, + pub skip_last: u32, +} + +impl Default for AltStrokes { + fn default() -> Self { + Self { + first: 0, + next: 0, + first_stroke: CellStroke::default(), + next_stroke: CellStroke { weight: 0.25, ..Default::default() }, + skip_first: 0, + skip_last: 0, + } + } +} + +impl AltStrokes { + /// A phase-independent stroke for unskipped, visually uniform patterns. Unequal + /// patterns and skip settings are preserved for round trips until their boundary + /// phase is supported; they must not be rendered with a guessed phase. + pub fn uniform_stroke(&self) -> Option<&CellStroke> { + if self.skip_first > 0 || self.skip_last > 0 { + return None; + } + match (self.first > 0, self.next > 0) { + (true, false) => Some(&self.first_stroke), + (false, true) => Some(&self.next_stroke), + (true, true) if self.first_stroke == self.next_stroke || (!self.first_stroke.is_visible() && !self.next_stroke.is_visible()) => { + Some(&self.first_stroke) + } + _ => None, + } + } +} + +fn no_flags(flags: &[bool; 4]) -> bool { + !flags.iter().any(|v| *v) +} +fn no_priorities(priorities: &[i32; 4]) -> bool { + priorities.iter().all(|v| *v == 0) +} + fn one() -> u32 { 1 } @@ -120,6 +172,12 @@ pub struct Cell { /// top, left, bottom, right order as `strokes`. Older documents keep their table border. #[serde(default)] pub border_overrides: [bool; 4], + /// Whether an edge has supplied cell/style attributes, independently of perimeter priority. + #[serde(default, skip_serializing_if = "no_flags")] + pub stroke_defined: [bool; 4], + /// IDML edge-conflict priorities, in top, left, bottom, right order. + #[serde(default, skip_serializing_if = "no_priorities")] + pub stroke_priorities: [i32; 4], /// Applied cell style ("" = [None]). #[serde(default, skip_serializing_if = "String::is_empty")] pub style: String, @@ -142,6 +200,8 @@ impl Default for Cell { rotation: 0.0, strokes: Default::default(), border_overrides: [false; 4], + stroke_priorities: [0; 4], + stroke_defined: [false; 4], style: String::new(), graphic: None, } @@ -200,12 +260,12 @@ impl AltFills { /// Fill for the `i`-th of `n` body rows/columns, if the pattern paints it. pub fn fill_for(&self, i: usize, n: usize) -> Option<(&str, f32)> { let (sf, sl) = (self.skip_first as usize, self.skip_last as usize); - if i < sf || i + sl >= n { + if i < sf || i >= n.saturating_sub(sl) { return None; } - let period = (self.first + self.next).max(1) as usize; - let k = (i - sf) % period; - let (c, t) = if k < self.first as usize { (&self.first_color, self.first_tint) } else { (&self.next_color, self.next_tint) }; + let period = (u64::from(self.first) + u64::from(self.next)).max(1); + let k = (i - sf) as u64 % period; + let (c, t) = if k < u64::from(self.first) { (&self.first_color, self.first_tint) } else { (&self.next_color, self.next_tint) }; (c != designcraft_color::swatch::NONE).then_some((c.as_str(), t)) } } @@ -217,6 +277,9 @@ pub struct TableOptions { pub direction: crate::TextDirection, /// Outer border (overrides the outer cell edges). pub border: CellStroke, + /// Per-side borders; absent sides use the legacy uniform `border`. + #[serde(skip_serializing_if = "no_borders")] + pub borders: [Option<CellStroke>; 4], pub space_before: f64, pub space_after: f64, /// Header rows repeat at the top of every text column / frame the table continues into. @@ -226,6 +289,20 @@ pub struct TableOptions { pub alt_rows: Option<AltFills>, #[serde(skip_serializing_if = "Option::is_none")] pub alt_cols: Option<AltFills>, + #[serde(skip_serializing_if = "Option::is_none")] + pub row_strokes: Option<AltStrokes>, + #[serde(skip_serializing_if = "Option::is_none")] + pub column_strokes: Option<AltStrokes>, +} + +fn no_borders(borders: &[Option<CellStroke>; 4]) -> bool { + borders.iter().all(Option::is_none) +} + +impl TableOptions { + pub fn border_for(&self, side: usize) -> &CellStroke { + self.borders.get(side).and_then(Option::as_ref).unwrap_or(&self.border) + } } impl Default for TableOptions { @@ -233,12 +310,15 @@ impl Default for TableOptions { TableOptions { direction: crate::TextDirection::LeftToRight, border: CellStroke::default(), + borders: Default::default(), space_before: 4.0, space_after: -4.0, repeat_header: true, repeat_footer: true, alt_rows: None, alt_cols: None, + row_strokes: None, + column_strokes: None, } } } @@ -1097,6 +1177,16 @@ mod tests { assert!(b.fill_for(3, 9).is_none()); } + #[test] + fn alternating_fill_counts_cannot_overflow() { + let fills = AltFills { first: u32::MAX, next: 1, ..Default::default() }; + assert!(fills.fill_for(0, 2).is_some()); + assert!(fills.fill_for(1, 2).is_some()); + let skipped = AltFills { skip_last: u32::MAX, ..fills }; + assert!(skipped.fill_for(1, 2).is_none()); + assert!(skipped.fill_for(usize::MAX, usize::MAX).is_none()); + } + #[test] fn serde_roundtrip() { let mut s = story("x"); @@ -1118,8 +1208,22 @@ mod tests { old.as_object_mut().unwrap().remove("borderOverrides"); let back: Cell = serde_json::from_value(old).unwrap(); assert_eq!(back.border_overrides, [false; 4]); + assert_eq!(back.stroke_defined, [false; 4]); + assert_eq!(back.stroke_priorities, [0; 4]); + let legacy_options: TableOptions = serde_json::from_str(r#"{"border":{"weight":3}}"#).unwrap(); + assert!(legacy_options.borders.iter().all(Option::is_none)); + assert!(legacy_options.row_strokes.is_none()); + assert!(legacy_options.column_strokes.is_none()); + for side in 0..4 { + assert_eq!(legacy_options.border_for(side).weight, 3.0); + } - let explicit = Cell { border_overrides: [true, false, true, false], ..cell }; + let explicit = Cell { + border_overrides: [true, false, true, false], + stroke_defined: [true, true, false, false], + stroke_priorities: [3, 0, 7, 0], + ..cell + }; let back: Cell = serde_json::from_str(&serde_json::to_string(&explicit).unwrap()).unwrap(); assert_eq!(back, explicit); assert_eq!(back.blank_like().border_overrides, explicit.border_overrides); diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 6836d1bf8..028971e97 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -650,6 +650,11 @@ fn set_cell(s: &mut Session, p: &Value) -> Result<Value> { edit_table(s, &g, "table.setCell", |t| { let owners = t.owners(); let nc = t.ncols(); + let nr = t.nrows(); + let rtl = t.options.direction == designcraft_doc::TextDirection::RightToLeft; + let borders: [CellStroke; 4] = std::array::from_fn(|side| t.options.border_for(side).clone()); + // Local edits outrank previously imported edges, including explicit zero/None. + let priority = if stroke.is_some() { next_cell_stroke_priority(t) } else { 0 }; let rg = g.range; for r in rg.r0..=rg.r1 { for c in rg.c0..=rg.c1 { @@ -675,40 +680,57 @@ fn set_cell(s: &mut Session, p: &Value) -> Result<Value> { } if let Some(sv) = &stroke { let edges = str_param(sv, "edges").unwrap_or("all"); + let at_bottom = r.saturating_add(cell.row_span.max(1) as usize); + let at_right = c.saturating_add(cell.col_span.max(1) as usize); + let perimeter = [r == 0, if rtl { at_right == nc } else { c == 0 }, at_bottom == nr, if rtl { c == 0 } else { at_right == nc }]; for (i, on) in [ ("top", r == rg.r0), - ("left", c == rg.c0), - ("bottom", r + cell.row_span as usize - 1 == rg.r1), - ("right", c + cell.col_span as usize - 1 == rg.c1), + ("left", if rtl { at_right.saturating_sub(1) == rg.c1 } else { c == rg.c0 }), + ("bottom", at_bottom.saturating_sub(1) == rg.r1), + ("right", if rtl { c == rg.c0 } else { at_right.saturating_sub(1) == rg.c1 }), ] .iter() .enumerate() .map(|(i, (name, outer))| (i, edges == "all" || edges == *name || (edges == "outer" && *outer) || (edges == "inner" && !*outer))) { if on { - cell.strokes[i] = parse_stroke(sv, cell.strokes[i].clone()); + let base = if perimeter[i] && !cell.border_overrides[i] { &borders[i] } else { &cell.strokes[i] }; + cell.strokes[i] = parse_stroke(sv, base.clone()); + cell.stroke_defined[i] = true; + cell.stroke_priorities[i] = priority; + cell.border_overrides[i] = true; } } } } } - // Neighbouring cells share edges: mirror the edges along the range boundary. + // Keep both copies of unmerged shared boundaries consistent. Left/right name + // physical sides, also in RTL tables. if let Some(sv) = &stroke { let edges = str_param(sv, "edges").unwrap_or("all"); - if matches!(edges, "all" | "outer" | "bottom") && rg.r1 + 1 < t.nrows() { + let selected = |side: &str| edges == "all" || edges == "outer" || edges == side; + if selected("top") + && let Some(row) = rg.r0.checked_sub(1) + { for c in rg.c0..=rg.c1 { - let s0 = t.cell(rg.r1, c).map(|x| x.strokes[2].clone()); - if let (Some(s0), Some(n)) = (s0, t.cell_mut(rg.r1 + 1, c)) { - n.strokes[0] = s0; - } + mirror_cell_edge(t, &owners, (rg.r0, c), 0, (row, c), 2); } } - if matches!(edges, "all" | "outer" | "right") && rg.c1 + 1 < t.ncols() { + if selected("bottom") && rg.r1.saturating_add(1) < nr { + for c in rg.c0..=rg.c1 { + mirror_cell_edge(t, &owners, (rg.r1, c), 2, (rg.r1 + 1, c), 0); + } + } + if selected(if rtl { "right" } else { "left" }) + && let Some(col) = rg.c0.checked_sub(1) + { for r in rg.r0..=rg.r1 { - let s0 = t.cell(r, rg.c1).map(|x| x.strokes[3].clone()); - if let (Some(s0), Some(n)) = (s0, t.cell_mut(r, rg.c1 + 1)) { - n.strokes[1] = s0; - } + mirror_cell_edge(t, &owners, (r, rg.c0), if rtl { 3 } else { 1 }, (r, col), if rtl { 1 } else { 3 }); + } + } + if selected(if rtl { "left" } else { "right" }) && rg.c1.saturating_add(1) < nc { + for r in rg.r0..=rg.r1 { + mirror_cell_edge(t, &owners, (r, rg.c1), if rtl { 1 } else { 3 }, (r, rg.c1 + 1), if rtl { 3 } else { 1 }); } } } @@ -716,6 +738,53 @@ fn set_cell(s: &mut Session, p: &Value) -> Result<Value> { }) } +/// Only saturation needs rebasing. Preserve every distinct priority's order and keep +/// zero/negative priorities untouched; the work is bounded by the table's edges. +fn next_cell_stroke_priority(t: &mut Table) -> i32 { + let highest = t.cells.iter().flat_map(|cell| cell.stroke_priorities).max().unwrap_or(0).max(0); + if highest < i32::MAX { + return highest + 1; + } + let values: std::collections::BTreeSet<_> = t.cells.iter().flat_map(|cell| cell.stroke_priorities).filter(|p| *p > 0).collect(); + let mut ranks = std::collections::BTreeMap::new(); + let mut highest = 0i32; + for value in values { + highest = highest.saturating_add(1); + ranks.insert(value, highest); + } + for cell in &mut t.cells { + for priority in &mut cell.stroke_priorities { + if let Some(rank) = ranks.get(priority) { + *priority = *rank; + } + } + } + highest.saturating_add(1) +} + +fn mirror_cell_edge(t: &mut Table, owners: &[(usize, usize)], from: (usize, usize), edge: usize, to: (usize, usize), opposite: usize) { + let owner = |pos: (usize, usize)| owners.get(pos.0.checked_mul(t.ncols())?.checked_add(pos.1)?).copied(); + if owner(from) != Some(from) || owner(to) != Some(to) { + return; + } + // A merged cell's edge may border several separately editable cells. Mirroring + // a segment onto that whole edge would change unselected neighbors; priorities + // instead resolve each segment without altering the merged cell's formatting. + let merged = |cell: &designcraft_doc::Cell| cell.row_span > 1 || cell.col_span > 1; + if t.cell(from.0, from.1).is_some_and(merged) || t.cell(to.0, to.1).is_some_and(merged) { + return; + } + let source = t + .cell(from.0, from.1) + .map(|cell| (cell.strokes[edge].clone(), cell.stroke_defined[edge], cell.stroke_priorities[edge], cell.border_overrides[edge])); + if let (Some((stroke, defined, priority, border_override)), Some(neighbor)) = (source, t.cell_mut(to.0, to.1)) { + neighbor.strokes[opposite] = stroke; + neighbor.stroke_defined[opposite] = defined; + neighbor.stroke_priorities[opposite] = priority; + neighbor.border_overrides[opposite] = border_override; + } +} + fn set_row_height(s: &mut Session, p: &Value) -> Result<Value> { let g = target(s, p, "table.setRowHeight")?; let h = p.get("height").and_then(Value::as_f64).map(|h| h.clamp(0.0, 10000.0)); @@ -846,6 +915,7 @@ fn options(s: &mut Session, p: &Value) -> Result<Value> { } if let Some(b) = p.get("border") { o.border = parse_stroke(b, o.border.clone()); + o.borders = Default::default(); } o.space_before = f64_or(&p, "spaceBefore", o.space_before); o.space_after = f64_or(&p, "spaceAfter", o.space_after); @@ -982,6 +1052,9 @@ fn stroke_param(v: Option<&Value>) -> Option<designcraft_doc::CellStroke> { /// Fill in the cell style fields given in `p`. fn cell_style_fields(cs: &mut designcraft_doc::CellStyle, p: &Value, cmd: &str) -> Result<()> { + if let Some(v) = p.get("basedOn") { + cs.based_on = v.as_str().map(str::to_string); + } if let Some(f) = color_param(p, "fill") { cs.fill = Some(f); } @@ -989,10 +1062,14 @@ fn cell_style_fields(cs: &mut designcraft_doc::CellStyle, p: &Value, cmd: &str) cs.fill_tint = Some(t.clamp(0.0, 1.0) as f32); } match p.get("insets") { - Some(Value::Number(n)) => cs.insets = n.as_f64().map(|v| [v.max(0.0); 4]), + Some(Value::Number(n)) => { + cs.insets = n.as_f64().map(|v| [v.max(0.0); 4]); + cs.inset_overrides = Default::default(); + } Some(Value::Array(a)) if a.len() == 4 => { let v: Vec<f64> = a.iter().map(|x| x.as_f64().unwrap_or(0.0).max(0.0)).collect(); cs.insets = Some([v[0], v[1], v[2], v[3]]); + cs.inset_overrides = Default::default(); } _ => {} } @@ -1001,6 +1078,7 @@ fn cell_style_fields(cs: &mut designcraft_doc::CellStyle, p: &Value, cmd: &str) } if let Some(st) = stroke_param(p.get("stroke")) { cs.stroke = Some(st); + cs.strokes = Default::default(); } if let Some(ps) = str_param(p, "paragraphStyle") { cs.paragraph_style = Some(ps.to_string()); @@ -1037,15 +1115,11 @@ fn cell_style_create(s: &mut Session, p: &Value) -> Result<Value> { fn cell_style_apply(s: &mut Session, p: &Value) -> Result<Value> { let name = str_param(p, "name").ok_or_else(|| bad("style.cell.apply", "missing name"))?.to_string(); - let cs = s - .doc()? - .doc - .styles - .cell - .iter() - .find(|c| c.name == name) - .cloned() - .ok_or_else(|| bad("style.cell.apply", format!("no cell style `{name}`")))?; + let styles = &s.doc()?.doc.styles.cell; + if !styles.iter().any(|c| c.name == name) { + return Err(bad("style.cell.apply", format!("no cell style `{name}`"))); + } + let cs = designcraft_doc::resolve_cell_style(styles, &name); let g = target(s, p, "style.cell.apply")?; edit_table(s, &g, "style.cell.apply", |t| { let owners = t.owners(); @@ -1067,7 +1141,7 @@ fn cell_style_apply(s: &mut Session, p: &Value) -> Result<Value> { /// Re-apply cell styles named in `names` to every cell using them (after a style edit). fn reapply_cell_styles(d: &mut Document, names: &[String]) { - let styles = d.styles.cell.clone(); + let styles: Vec<_> = names.iter().map(|name| designcraft_doc::resolve_cell_style(&d.styles.cell, name)).collect(); for sid in d.stories.keys().copied().collect::<Vec<_>>() { let Some(st) = d.story_mut(sid) else { continue }; for t in st.tables.values_mut() { @@ -1086,18 +1160,42 @@ fn reapply_cell_styles(d: &mut Document, names: &[String]) { } } +/// Include descendants when editing an inherited style, with the resolver's depth limit. +fn dependent_styles<'a>(name: &str, styles: impl Iterator<Item = (&'a str, Option<&'a str>)>) -> Vec<String> { + let styles: Vec<_> = styles.collect(); + styles + .iter() + .filter(|(candidate, _)| { + let mut current = Some(*candidate); + for _ in 0..32 { + let Some(current_name) = current else { return false }; + if current_name == name { + return true; + } + current = styles.iter().find(|(n, _)| *n == current_name).and_then(|(_, base)| *base); + } + false + }) + .map(|(name, _)| (*name).to_string()) + .collect() +} + fn cell_style_edit(s: &mut Session, p: &Value) -> Result<Value> { let name = str_param(p, "name").ok_or_else(|| bad("style.cell.edit", "missing name"))?.to_string(); let p = p.clone(); s.edit(|d, _| { let cs = d.styles_mut().cell.iter_mut().find(|c| c.name == name).ok_or_else(|| bad("style.cell.edit", format!("no cell style `{name}`")))?; cell_style_fields(cs, &p, "style.cell.edit")?; - reapply_cell_styles(d, std::slice::from_ref(&name)); + let names = dependent_styles(&name, d.styles.cell.iter().map(|cs| (cs.name.as_str(), cs.based_on.as_deref()))); + reapply_cell_styles(d, &names); ok() }) } fn table_style_fields(ts: &mut designcraft_doc::TableStyle, p: &Value) { + if let Some(v) = p.get("basedOn") { + ts.based_on = v.as_str().map(str::to_string); + } for (k, f) in [ ("header", &mut ts.header), ("body", &mut ts.body), @@ -1111,6 +1209,7 @@ fn table_style_fields(ts: &mut designcraft_doc::TableStyle, p: &Value) { } if let Some(b) = stroke_param(p.get("border")) { ts.border = Some(b); + ts.borders = Default::default(); } if let Some(a) = p.get("altRows").and_then(Value::as_object) { let n = |k: &str, d: u32| a.get(k).and_then(Value::as_u64).map_or(d, |v| v as u32); @@ -1150,7 +1249,10 @@ fn table_style_create(s: &mut Session, p: &Value) -> Result<Value> { fn table_style_apply(s: &mut Session, p: &Value) -> Result<Value> { let name = str_param(p, "name").ok_or_else(|| bad("style.table.apply", "missing name"))?.to_string(); let doc = &s.doc()?.doc; - let ts = doc.styles.table.iter().find(|c| c.name == name).cloned().ok_or_else(|| bad("style.table.apply", format!("no table style `{name}`")))?; + if !doc.styles.table.iter().any(|c| c.name == name) { + return Err(bad("style.table.apply", format!("no table style `{name}`"))); + } + let ts = doc.styles.resolve_table_style(&name); let cells = doc.styles.cell.clone(); let g = target(s, p, "style.table.apply")?; edit_table(s, &g, "style.table.apply", |t| { @@ -1166,11 +1268,13 @@ fn table_style_edit(s: &mut Session, p: &Value) -> Result<Value> { let ts = d.styles_mut().table.iter_mut().find(|c| c.name == name).ok_or_else(|| bad("style.table.edit", format!("no table style `{name}`")))?; table_style_fields(ts, &p); - let (ts, cells) = (ts.clone(), d.styles.cell.clone()); + let names = dependent_styles(&name, d.styles.table.iter().map(|ts| (ts.name.as_str(), ts.based_on.as_deref()))); + let styles: Vec<_> = names.iter().map(|name| d.styles.resolve_table_style(name)).collect(); + let cells = d.styles.cell.clone(); for sid in d.stories.keys().copied().collect::<Vec<_>>() { let Some(st) = d.story_mut(sid) else { continue }; for t in st.tables.values_mut() { - if t.style == name { + if let Some(ts) = styles.iter().find(|ts| ts.name == t.style) { ts.apply_to(std::sync::Arc::make_mut(t), &cells); } } diff --git a/crates/engine/src/tests_table.rs b/crates/engine/src/tests_table.rs index 8f128ad58..45861347d 100644 --- a/crates/engine/src/tests_table.rs +++ b/crates/engine/src/tests_table.rs @@ -176,3 +176,226 @@ fn file_roundtrip_keeps_tables() { let st = back.story(designcraft_doc::StoryId(sid)).unwrap(); assert_eq!(st.tables.values().next().unwrap().cells[0].text.text, "A1"); } + +fn table_for(s: &Session, sid: u64, tid: u64) -> &doc::Table { + s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap().tables.get(&tid).unwrap() +} + +#[test] +fn explicit_cell_edges_win_imported_priorities_and_keep_undo_and_file_roundtrip() { + for (direction, neighbors) in [ + (doc::TextDirection::LeftToRight, [(0, 1, 2), (1, 0, 3), (1, 2, 1), (2, 1, 0)]), + (doc::TextDirection::RightToLeft, [(0, 1, 2), (1, 0, 1), (1, 2, 3), (2, 1, 0)]), + ] { + for imported_priority in [-7, 41, i32::MAX] { + let (mut s, sid, _) = session_with_frame(); + let r = s.execute("table.insert", &json!({"rows": 3, "cols": 3})).unwrap(); + let tid = r["table"].as_u64().unwrap(); + s.edit(|d, _| { + let t = d.story_mut(doc::StoryId(sid)).unwrap().table_mut(tid).unwrap(); + t.options.border = doc::CellStroke::none(); + t.options.direction = direction; + for cell in &mut t.cells { + cell.strokes = std::array::from_fn(|_| doc::CellStroke::none()); + } + t.cell_mut(0, 0).unwrap().stroke_priorities[0] = -9; + for (row, col, edge) in neighbors { + let cell = t.cell_mut(row, col).unwrap(); + cell.strokes[edge] = doc::CellStroke { weight: 7.0, ..Default::default() }; + cell.stroke_defined[edge] = true; + cell.stroke_priorities[edge] = imported_priority; + } + Ok(()) + }) + .unwrap(); + let before = table_for(&s, sid, tid).clone(); + s.execute("table.setCell", &json!({"row": 1, "col": 1, "stroke": {"weight": 0, "edges": "all"}})).unwrap(); + let after = table_for(&s, sid, tid).clone(); + assert_eq!(after.cell(0, 0).unwrap().stroke_priorities[0], -9, "unrelated signed priorities are preserved"); + let edited = after.cell(1, 1).unwrap(); + let priority = if imported_priority == i32::MAX { 2 } else { imported_priority.max(0) + 1 }; + assert_eq!(edited.stroke_defined, [true; 4]); + assert_eq!(edited.border_overrides, [true; 4]); + assert_eq!(edited.stroke_priorities, [priority; 4]); + for (row, col, edge) in neighbors { + let neighbor = after.cell(row, col).unwrap(); + assert_eq!(neighbor.strokes[edge].weight, 0.0); + assert!(neighbor.stroke_defined[edge]); + assert!(neighbor.border_overrides[edge]); + assert_eq!(neighbor.stroke_priorities[edge], priority); + } + let composed = s.cache.get(&s.doc().unwrap().doc, doc::StoryId(sid), None); + assert!(composed.frames.iter().flat_map(|f| &f.tables).all(|t| t.strokes.is_empty())); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(table_for(&s, sid, tid), &before); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(table_for(&s, sid, tid), &after); + let bytes = crate::cmd::file_bytes(&s.doc().unwrap().doc); + let back = crate::cmd::file_from(&bytes).unwrap(); + assert_eq!(back.story(doc::StoryId(sid)).unwrap().tables.get(&tid).unwrap().as_ref(), &after); + check(&s); + } + } +} + +#[test] +fn perimeter_edits_override_imported_sides_and_uniform_border_clears_side_options() { + let (mut s, sid, _) = session_with_frame(); + let r = s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap(); + let tid = r["table"].as_u64().unwrap(); + s.edit(|d, _| { + let t = d.story_mut(doc::StoryId(sid)).unwrap().table_mut(tid).unwrap(); + t.options.border.weight = 4.0; + t.options.borders = std::array::from_fn(|_| Some(doc::CellStroke { weight: 9.0, ..Default::default() })); + Ok(()) + }) + .unwrap(); + s.execute("table.setCell", &json!({"stroke": {"tint": 0.25, "edges": "left"}})).unwrap(); + let t = table_for(&s, sid, tid); + let cell = t.cell(0, 0).unwrap(); + assert_eq!(cell.stroke_defined, [false, true, false, false]); + assert_eq!(cell.border_overrides, [false, true, false, false]); + assert_eq!(cell.strokes[1].weight, 9.0, "partial edits preserve the visible perimeter base"); + assert_eq!(cell.strokes[1].tint, 0.25); + let before = t.clone(); + s.execute("table.options", &json!({"border": {"weight": 3}})).unwrap(); + let t = table_for(&s, sid, tid); + assert!(t.options.borders.iter().all(Option::is_none)); + assert_eq!(t.options.border.weight, 3.0); + let composed = s.cache.get(&s.doc().unwrap().doc, doc::StoryId(sid), None); + let frag = composed.frames.iter().flat_map(|f| &f.tables).next().unwrap(); + assert_eq!(frag.strokes.iter().filter(|s| s.stroke.weight == 3.0).count(), 3); + assert_eq!(frag.strokes.iter().filter(|s| s.stroke.weight == 9.0 && s.stroke.tint == 0.25).count(), 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(table_for(&s, sid, tid), &before); +} + +#[test] +fn inherited_cell_and_table_styles_apply_and_follow_base_edits() { + let (mut s, sid, _) = session_with_frame(); + let r = s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap(); + let tid = r["table"].as_u64().unwrap(); + s.execute("style.cell.create", &json!({"name": "Cell Base", "insets": 7, "stroke": {"weight": 2}})).unwrap(); + s.execute("style.cell.create", &json!({"name": "Cell Child", "basedOn": "Cell Base", "fill": "[Paper]"})).unwrap(); + s.execute("style.cell.apply", &json!({"name": "Cell Child", "table": tid})).unwrap(); + let t = table_for(&s, sid, tid); + assert!(t.cells.iter().all(|cell| cell.style == "Cell Child" && cell.insets == [7.0; 4] && cell.strokes[0].weight == 2.0)); + s.execute("style.cell.edit", &json!({"name": "Cell Base", "insets": 5})).unwrap(); + assert!(table_for(&s, sid, tid).cells.iter().all(|cell| cell.insets == [5.0; 4])); + s.execute("style.table.create", &json!({"name": "Table Base", "body": "Cell Child", "border": {"weight": 6}, "spaceBefore": 8})).unwrap(); + s.execute("style.table.create", &json!({"name": "Table Child", "basedOn": "Table Base", "spaceAfter": 3})).unwrap(); + s.execute("style.table.apply", &json!({"name": "Table Child"})).unwrap(); + let t = table_for(&s, sid, tid); + assert_eq!(t.style, "Table Child"); + assert_eq!(t.options.border_for(0).weight, 6.0); + assert_eq!(t.options.space_before, 8.0); + assert_eq!(t.options.space_after, 3.0); + assert!(t.cells.iter().all(|cell| cell.style == "Cell Child" && cell.insets == [5.0; 4])); + s.execute("style.table.edit", &json!({"name": "Table Base", "border": {"weight": 4}, "spaceBefore": 10})).unwrap(); + let t = table_for(&s, sid, tid); + assert_eq!(t.options.border_for(0).weight, 4.0); + assert_eq!(t.options.space_before, 10.0); + assert_eq!(t.options.space_after, 3.0); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(table_for(&s, sid, tid).options.border_for(0).weight, 6.0); +} + +#[test] +fn uniform_style_edits_replace_imported_per_side_attributes() { + let (mut s, sid, _) = session_with_frame(); + let r = s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap(); + let tid = r["table"].as_u64().unwrap(); + s.edit(|d, _| { + d.styles_mut().cell.push(doc::CellStyle { + name: "Imported Cell".into(), + strokes: std::array::from_fn(|_| doc::CellStrokeAttrs { weight: Some(9.0), ..Default::default() }), + inset_overrides: [Some(12.0); 4], + ..Default::default() + }); + d.styles_mut().table.push(doc::TableStyle { + name: "Imported Table".into(), + borders: std::array::from_fn(|_| doc::CellStrokeAttrs { weight: Some(10.0), ..Default::default() }), + ..Default::default() + }); + Ok(()) + }) + .unwrap(); + s.execute("style.cell.apply", &json!({"name": "Imported Cell"})).unwrap(); + s.execute("style.cell.edit", &json!({"name": "Imported Cell", "stroke": {"weight": 2}, "insets": 3})).unwrap(); + let cell = table_for(&s, sid, tid).cell(0, 0).unwrap(); + assert!(cell.strokes.iter().all(|edge| edge.weight == 2.0)); + assert_eq!(cell.insets, [3.0; 4]); + s.execute("style.table.apply", &json!({"name": "Imported Table"})).unwrap(); + s.execute("style.table.edit", &json!({"name": "Imported Table", "border": {"weight": 4}})).unwrap(); + let t = table_for(&s, sid, tid); + assert!((0..4).all(|edge| t.options.border_for(edge).weight == 4.0)); +} + +#[test] +fn rtl_cell_edge_selection_uses_physical_sides() { + let (mut s, sid, _) = session_with_frame(); + let r = s.execute("table.insert", &json!({"rows": 1, "cols": 3})).unwrap(); + let tid = r["table"].as_u64().unwrap(); + s.execute("table.options", &json!({"direction": "rightToLeft", "border": {"weight": 0}})).unwrap(); + s.execute("table.setCell", &json!({"cols": [0, 2], "stroke": {"weight": 0}})).unwrap(); + s.execute("table.setCell", &json!({"row": 0, "col": 0, "stroke": {"weight": 2, "edges": "left"}})).unwrap(); + let t = table_for(&s, sid, tid); + assert_eq!(t.cell(0, 0).unwrap().strokes[1].weight, 2.0); + assert_eq!(t.cell(0, 1).unwrap().strokes[3].weight, 2.0); + assert_eq!(t.cell(0, 1).unwrap().strokes[1].weight, 0.0); + s.execute("table.setCell", &json!({"cols": [0, 2], "stroke": {"weight": 5, "edges": "outer"}})).unwrap(); + let t = table_for(&s, sid, tid); + assert_eq!(t.cell(0, 0).unwrap().strokes[3].weight, 5.0); + assert_eq!(t.cell(0, 2).unwrap().strokes[1].weight, 5.0); + assert_eq!(t.cell(0, 0).unwrap().strokes[1].weight, 2.0); + assert_eq!(t.cell(0, 2).unwrap().strokes[3].weight, 0.0); + s.execute("table.setCell", &json!({"cols": [0, 2], "stroke": {"weight": 0, "edges": "inner"}})).unwrap(); + let t = table_for(&s, sid, tid); + assert_eq!(t.cell(0, 0).unwrap().strokes[3].weight, 5.0); + assert_eq!(t.cell(0, 2).unwrap().strokes[1].weight, 5.0); + assert_eq!(t.cell(0, 0).unwrap().strokes[1].weight, 0.0); + assert_eq!(t.cell(0, 1).unwrap().strokes[3].weight, 0.0); +} + +#[test] +fn editing_beside_a_merged_cell_only_changes_the_selected_edge_segment() { + for direction in [doc::TextDirection::LeftToRight, doc::TextDirection::RightToLeft] { + for imported_priority in [41, i32::MAX] { + let (mut s, sid, _) = session_with_frame(); + let r = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap(); + let tid = r["table"].as_u64().unwrap(); + s.execute("table.merge", &json!({"row": 0, "cols": [0, 1]})).unwrap(); + s.edit(|d, _| { + let t = d.story_mut(doc::StoryId(sid)).unwrap().table_mut(tid).unwrap(); + t.options.direction = direction; + t.options.border = doc::CellStroke::none(); + for cell in &mut t.cells { + cell.strokes = std::array::from_fn(|_| doc::CellStroke::none()); + } + let merged = t.cell_mut(0, 0).unwrap(); + merged.strokes[2] = doc::CellStroke { weight: 7.0, ..Default::default() }; + merged.stroke_defined[2] = true; + merged.stroke_priorities[2] = imported_priority; + t.cell_mut(1, 1).unwrap().stroke_priorities[2] = 7; + Ok(()) + }) + .unwrap(); + let before = table_for(&s, sid, tid).clone(); + s.execute("table.setCell", &json!({"row": 1, "col": 0, "stroke": {"weight": 0, "edges": "top"}})).unwrap(); + let t = table_for(&s, sid, tid); + assert_eq!(t.cell(0, 0).unwrap().strokes[2].weight, 7.0, "the merged edge itself must not be erased"); + let priorities = + [t.cell(1, 1).unwrap().stroke_priorities[2], t.cell(0, 0).unwrap().stroke_priorities[2], t.cell(1, 0).unwrap().stroke_priorities[0]]; + assert_eq!(priorities, if imported_priority == i32::MAX { [1, 2, 3] } else { [7, 41, 42] }); + let composed = s.cache.get(&s.doc().unwrap().doc, doc::StoryId(sid), None); + let frag = composed.frames.iter().flat_map(|f| &f.tables).next().unwrap(); + let unselected = frag.cell(1, 1).unwrap().rect; + let visible: Vec<_> = frag.strokes.iter().filter(|s| s.stroke.weight == 7.0).collect(); + assert_eq!(visible.len(), 1); + assert_eq!(visible[0].a, designcraft_geom::Point::new(unselected.x0, unselected.y0)); + assert_eq!(visible[0].b, designcraft_geom::Point::new(unselected.x1, unselected.y0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(table_for(&s, sid, tid), &before); + } + } +} diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index ab54177f4..2758a6e62 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -886,6 +886,11 @@ impl<'a> Ex<'a> { el.set(k, num(v)); } } + for (key, value) in ["TopInset", "LeftInset", "BottomInset", "RightInset"].iter().zip(cs.inset_overrides) { + if let Some(value) = value { + el.set(key, num(value)); + } + } if let Some(vj) = cs.vj { el.set("VerticalJustification", names::vj_out(vj)); } @@ -894,20 +899,32 @@ impl<'a> Ex<'a> { self.cell_stroke_attrs(&mut el, side, st); } } + for (side, attrs) in ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].iter().zip(&cs.strokes) { + self.partial_cell_stroke_attrs(&mut el, side, attrs); + } if let Some(ps) = &cs.paragraph_style { el.set("AppliedParagraphStyle", names::style_self("ParagraphStyle", PARA_BUILTINS, ps)); } - cells = cells.child(with_props(el, vec![p("BasedOn", "string", "$ID/[None]")])); + let base = cs.based_on.as_deref().unwrap_or(designcraft_doc::NO_CELL_STYLE); + let based_on = if base == designcraft_doc::NO_CELL_STYLE { + p("BasedOn", "string", "$ID/[None]") + } else { + p("BasedOn", "object", names::style_self("CellStyle", names::CELL_BUILTINS, base)) + }; + cells = cells.child(with_props(el, vec![based_on])); } root.push(cells); - let mut tables = El::new("RootTableStyleGroup") - .attr("Self", self.fresh()) - .child(El::new("TableStyle").attr("Self", "TableStyle/$ID/[No table style]").attr("Name", "$ID/[No table style]")) - .child(with_props( + let mut tables = El::new("RootTableStyleGroup").attr("Self", self.fresh()); + if !styles.table.iter().any(|s| s.name == designcraft_doc::NO_TABLE_STYLE) { + tables.push(El::new("TableStyle").attr("Self", "TableStyle/$ID/[No table style]").attr("Name", "$ID/[No table style]")); + } + if !styles.table.iter().any(|s| s.name == designcraft_doc::BASIC_TABLE) { + tables.push(with_props( El::new("TableStyle").attr("Self", "TableStyle/$ID/[Basic Table]").attr("Name", "$ID/[Basic Table]"), vec![p("BasedOn", "string", "$ID/[No table style]")], )); - for ts in styles.table.iter().filter(|t| t.name != designcraft_doc::BASIC_TABLE) { + } + for ts in &styles.table { let mut el = El::new("TableStyle") .attr("Self", names::style_self("TableStyle", names::TABLE_BUILTINS, &ts.name)) .attr("Name", names::style_name_out(names::TABLE_BUILTINS, &ts.name)); @@ -922,11 +939,24 @@ impl<'a> Ex<'a> { el.set(k, names::style_self("CellStyle", names::CELL_BUILTINS, n)); } } + for (key, value) in [ + ("HeaderRegionSameAsBodyRegion", ts.header_same_as_body), + ("FooterRegionSameAsBodyRegion", ts.footer_same_as_body), + ("LeftColumnRegionSameAsBodyRegion", ts.left_column_same_as_body), + ("RightColumnRegionSameAsBodyRegion", ts.right_column_same_as_body), + ] { + if let Some(value) = value { + el.set(key, value); + } + } if let Some(b) = &ts.border { for side in ["Top", "Left", "Bottom", "Right"] { self.cell_stroke_attrs(&mut el, &format!("{side}Border"), b); } } + for (side, attrs) in ["TopBorder", "LeftBorder", "BottomBorder", "RightBorder"].iter().zip(&ts.borders) { + self.partial_cell_stroke_attrs(&mut el, side, attrs); + } if let Some(a) = &ts.alt_rows { el.set("StartRowFillColor", self.sw(&a.first_color)); el.set("StartRowFillCount", a.first); @@ -935,13 +965,26 @@ impl<'a> Ex<'a> { el.set("EndRowFillCount", a.next); el.set("EndRowFillTint", pct(a.next_tint as f64)); } + self.partial_alt_fill_attrs(&mut el, "Row", &ts.row_fills); + self.partial_alt_fill_attrs(&mut el, "Column", &ts.column_fills); + self.partial_alt_stroke_attrs(&mut el, "Row", &ts.row_strokes); + self.partial_alt_stroke_attrs(&mut el, "Column", &ts.column_strokes); if let Some(v) = ts.space_before { el.set("SpaceBefore", num(v)); } if let Some(v) = ts.space_after { el.set("SpaceAfter", num(v)); } - tables = tables.child(with_props(el, vec![p("BasedOn", "string", "$ID/[Basic Table]")])); + let mut props = Vec::new(); + if ts.name != designcraft_doc::NO_TABLE_STYLE { + let base = ts.based_on.as_deref().unwrap_or(designcraft_doc::NO_TABLE_STYLE); + props.push(if base == designcraft_doc::NO_TABLE_STYLE { + p("BasedOn", "string", "$ID/[No table style]") + } else { + p("BasedOn", "object", names::style_self("TableStyle", names::TABLE_BUILTINS, base)) + }); + } + tables = tables.child(with_props(el, props)); } root.push(tables); // Named numbered lists. @@ -2137,6 +2180,71 @@ impl<'a> Ex<'a> { el.set(&format!("{prefix}StrokeType"), names::stroke_type_out(&s.kind)); } + fn partial_cell_stroke_attrs(&self, el: &mut El, prefix: &str, attrs: &designcraft_doc::CellStrokeAttrs) { + if let Some(value) = attrs.weight { + el.set(&format!("{prefix}StrokeWeight"), num(value)); + } + if let Some(value) = &attrs.color { + el.set(&format!("{prefix}StrokeColor"), self.sw(value)); + } + if let Some(value) = attrs.tint { + el.set(&format!("{prefix}StrokeTint"), pct(value as f64)); + } + if let Some(value) = &attrs.kind { + el.set(&format!("{prefix}StrokeType"), names::stroke_type_out(value)); + } + } + + fn partial_alt_fill_attrs(&self, el: &mut El, kind: &str, attrs: &designcraft_doc::AltFillsAttrs) { + if let Some(value) = attrs.first { + el.set(&format!("Start{kind}FillCount"), value); + } + if let Some(value) = &attrs.first_color { + el.set(&format!("Start{kind}FillColor"), self.sw(value)); + } + if let Some(value) = attrs.first_tint { + el.set(&format!("Start{kind}FillTint"), pct(value as f64)); + } + if let Some(value) = attrs.next { + el.set(&format!("End{kind}FillCount"), value); + } + if let Some(value) = &attrs.next_color { + el.set(&format!("End{kind}FillColor"), self.sw(value)); + } + if let Some(value) = attrs.next_tint { + el.set(&format!("End{kind}FillTint"), pct(value as f64)); + } + if let Some(value) = attrs.skip_first { + el.set(&format!("SkipFirstAlternatingFill{kind}s"), value); + } + if let Some(value) = attrs.skip_last { + el.set(&format!("SkipLastAlternatingFill{kind}s"), value); + } + } + + fn partial_alt_stroke_attrs(&self, el: &mut El, kind: &str, attrs: &designcraft_doc::AltStrokesAttrs) { + if let Some(value) = attrs.first { + el.set(&format!("Start{kind}StrokeCount"), value); + } + if let Some(value) = attrs.next { + el.set(&format!("End{kind}StrokeCount"), value); + } + self.partial_cell_stroke_attrs(el, &format!("Start{kind}"), &attrs.first_stroke); + self.partial_cell_stroke_attrs(el, &format!("End{kind}"), &attrs.next_stroke); + if kind == "Column" + && let Some(value) = &attrs.next_stroke.kind + { + el.attrs.retain(|(key, _)| key != "EndColumnStrokeType"); + el.set("EndColumnLineStyle", names::stroke_type_out(value)); + } + if let Some(value) = attrs.skip_first { + el.set(&format!("SkipFirstAlternatingStroke{kind}s"), value); + } + if let Some(value) = attrs.skip_last { + el.set(&format!("SkipLastAlternatingStroke{kind}s"), value); + } + } + /// `<Table>` with rows, columns and cells (cell names are `column:row`). fn table_el(&mut self, t: &designcraft_doc::Table) -> El { let id = uid(t.id); @@ -2159,8 +2267,8 @@ impl<'a> Ex<'a> { .attr("SpaceAfter", num(t.options.space_after)) .attr("HeaderBehavior", if t.options.repeat_header { "RepeatOnEachTextColumn" } else { "RepeatOnce" }) .attr("FooterBehavior", if t.options.repeat_footer { "RepeatOnEachTextColumn" } else { "RepeatOnce" }); - for side in ["Top", "Left", "Bottom", "Right"] { - self.cell_stroke_attrs(&mut el, &format!("{side}Border"), &t.options.border); + for (i, side) in ["Top", "Left", "Bottom", "Right"].iter().enumerate() { + self.cell_stroke_attrs(&mut el, &format!("{side}Border"), t.options.border_for(i)); } for (kind, alt) in [("Row", &t.options.alt_rows), ("Column", &t.options.alt_cols)] { if let Some(a) = alt { @@ -2172,6 +2280,27 @@ impl<'a> Ex<'a> { el.set(&format!("End{kind}FillTint"), pct(a.next_tint as f64)); el.set(&format!("SkipFirstAlternatingFill{kind}s"), a.skip_first); el.set(&format!("SkipLastAlternatingFill{kind}s"), a.skip_last); + } else { + // A local off value must override any enabled pattern in the named style. + el.set(&format!("Start{kind}FillCount"), 0); + el.set(&format!("End{kind}FillCount"), 0); + } + } + for (kind, strokes) in [("Row", &t.options.row_strokes), ("Column", &t.options.column_strokes)] { + if let Some(strokes) = strokes { + el.set(&format!("Start{kind}StrokeCount"), strokes.first); + el.set(&format!("End{kind}StrokeCount"), strokes.next); + self.cell_stroke_attrs(&mut el, &format!("Start{kind}"), &strokes.first_stroke); + self.cell_stroke_attrs(&mut el, &format!("End{kind}"), &strokes.next_stroke); + if kind == "Column" { + el.attrs.retain(|(key, _)| key != "EndColumnStrokeType"); + el.set("EndColumnLineStyle", names::stroke_type_out(&strokes.next_stroke.kind)); + } + el.set(&format!("SkipFirstAlternatingStroke{kind}s"), strokes.skip_first); + el.set(&format!("SkipLastAlternatingStroke{kind}s"), strokes.skip_last); + } else { + el.set(&format!("Start{kind}StrokeCount"), 0); + el.set(&format!("End{kind}StrokeCount"), 0); } } for (r, row) in t.rows.iter().enumerate() { @@ -2223,9 +2352,21 @@ impl<'a> Ex<'a> { .attr("VerticalJustification", vj) .attr("RotationAngle", num(cell.rotation)); for (i, side) in ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].iter().enumerate() { - self.cell_stroke_attrs(&mut ce, side, &cell.strokes[i]); - if cell.border_overrides[i] { - ce.set(&format!("{side}StrokePriority"), 1); + // Do not turn absent native defaults into explicit shared-edge candidates. + if cell.stroke_defined[i] + || cell.border_overrides[i] + || cell.stroke_priorities[i] != 0 + || cell.strokes[i] != designcraft_doc::CellStroke::default() + { + self.cell_stroke_attrs(&mut ce, side, &cell.strokes[i]); + } + let priority = if cell.border_overrides[i] && !cell.stroke_defined[i] { + cell.stroke_priorities[i].max(1) + } else { + cell.stroke_priorities[i] + }; + if priority != 0 { + ce.set(&format!("{side}StrokePriority"), priority); } } for psr in self.story_paras(&cell.text) { diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index f815fd278..8f96be67c 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -7,11 +7,10 @@ use std::sync::Arc; use designcraft_color::cms::lab; use designcraft_color::{Color, ColorType, Gradient, GradientKind, GradientStop, Swatch, SwatchValue, swatch}; use designcraft_doc::{ - AltFills, Asset, AssetId, BaselineGrid, Cell, CellRange, CellStroke, CharAttrs, CharFormat, CharRun, CharacterStyle, Columns, ColumnsKind, - Content, DocSettings, Document, DropShadow, Effects, Fill, GridAlign, GridRelative, Guide, Item, ItemId, Kerning, LAYER_COLORS, Layer, LayerId, - ListType, Margins, ObjectStyle, Page, PageId, PageSide, ParaAttrs, ParaFormat, ParagraphStyle, ParentInfo, RowHeightMode, Rule, Section, Shape, - SpanColumns, Spread, SpreadId, Story, StoryId, Stroke, Styles, TabStop, Table, TextFrame, TextFrameOptions, TextWrap, VerticalJustification, - story as st, + Asset, AssetId, BaselineGrid, CellRange, CharAttrs, CharFormat, CharRun, CharacterStyle, Columns, ColumnsKind, Content, DocSettings, Document, + DropShadow, Effects, Fill, GridAlign, GridRelative, Guide, Item, ItemId, Kerning, LAYER_COLORS, Layer, LayerId, ListType, Margins, ObjectStyle, + Page, PageId, PageSide, ParaAttrs, ParaFormat, ParagraphStyle, ParentInfo, RowHeightMode, Rule, Section, Shape, SpanColumns, Spread, SpreadId, + Story, StoryId, Stroke, Styles, TabStop, Table, TextFrame, TextFrameOptions, TextWrap, story as st, }; use designcraft_geom::corners::{Corner, CornerOptions}; use designcraft_geom::{Affine, Anchor, PathData, Point, Rect, SubPath}; @@ -20,6 +19,16 @@ use crate::names::{self, CHAR_BUILTINS, OBJECT_BUILTINS, PARA_BUILTINS, unescape use crate::xml::{El, Node, parse}; use crate::{IdmlError, MIMETYPE, Result, base64_decode, sniff_image}; +/// Preserve the legacy uniform field only when all four sides explicitly specify every +/// property. Partial records must stay sparse so they can inherit from a parent style. +fn uniform_cell_stroke(edges: &[designcraft_doc::CellStrokeAttrs; 4]) -> Option<designcraft_doc::CellStroke> { + let first = edges.first()?; + if edges.iter().any(|edge| edge != first) { + return None; + } + Some(designcraft_doc::CellStroke { weight: first.weight?, color: first.color.clone()?, tint: first.tint?, kind: first.kind.clone()? }) +} + /// Import an IDML package. Linked images are read from disk when available (not on wasm). pub fn import_idml(bytes: &[u8]) -> Result<Document> { import_idml_with(bytes, &default_reader) @@ -109,6 +118,8 @@ struct Importer<'r> { char_names: HashMap<String, String>, lists: Vec<designcraft_doc::NumberedList>, object_names: HashMap<String, String>, + cell_names: HashMap<String, String>, + table_names: HashMap<String, String>, /// Object style Self → element (attribute fallback for page items). object_els: HashMap<String, El>, layers: Vec<Layer>, @@ -193,6 +204,8 @@ impl<'r> Importer<'r> { char_names: HashMap::new(), lists: Vec::new(), object_names: HashMap::new(), + cell_names: HashMap::new(), + table_names: HashMap::new(), object_els: HashMap::new(), layers: Vec::new(), layer_ids: HashMap::new(), @@ -815,30 +828,31 @@ impl<'r> Importer<'r> { None => self.styles.character.push(s), } } - // Cell and table styles (the built-ins stay as they are). - let cell_ref = |r: &str| names::style_name_in(names::CELL_BUILTINS, &unescape_id(r.trim_start_matches("CellStyle/"))); + // Keep built-ins as well: [Basic Table] is editable, and [No table style] + // supplies the defaults used by named styles in ordinary IDML files. + for (id, e) in &cell_styles { + self.cell_names.insert(id.clone(), names::style_name_in(names::CELL_BUILTINS, e.get("Name").unwrap_or(""))); + } + for (id, e) in &table_styles { + self.table_names.insert(id.clone(), names::style_name_in(names::TABLE_BUILTINS, e.get("Name").unwrap_or(""))); + } for (_, e) in &cell_styles { let name = names::style_name_in(names::CELL_BUILTINS, e.get("Name").unwrap_or("")); - if name == designcraft_doc::NO_CELL_STYLE { - continue; - } let mut cs = designcraft_doc::CellStyle { name: name.clone(), ..Default::default() }; - cs.fill = e.get("FillColor").map(|c| self.swatch_ref(c)); - cs.fill_tint = e.num("FillTint").and_then(|v| tint(Some(v))); - let ins: Vec<Option<f64>> = ["TopInset", "LeftInset", "BottomInset", "RightInset"].iter().map(|k| e.num(k)).collect(); - if ins.iter().all(Option::is_some) { - cs.insets = Some([ins[0].unwrap_or(0.0), ins[1].unwrap_or(0.0), ins[2].unwrap_or(0.0), ins[3].unwrap_or(0.0)]); + cs.based_on = + e.prop("BasedOn").map(|r| self.cell_style_ref(&r)).or_else(|| Some(designcraft_doc::NO_CELL_STYLE.into())).filter(|n| *n != name); + cs.fill = e.prop("FillColor").map(|c| self.swatch_ref(&c)); + cs.fill_tint = tint(e.num("FillTint")); + cs.inset_overrides = ["TopInset", "LeftInset", "BottomInset", "RightInset"].map(|k| e.num(k).map(|v| v.max(0.0))); + if let [Some(top), Some(left), Some(bottom), Some(right)] = cs.inset_overrides { + cs.insets = Some([top, left, bottom, right]); + cs.inset_overrides = [None; 4]; } cs.vj = e.get("VerticalJustification").map(names::vj_in); - if e.get("TopEdgeStrokeWeight").is_some() || e.get("TopEdgeStrokeColor").is_some() { - let mut st = CellStroke::default(); - if let Some(w) = e.num("TopEdgeStrokeWeight") { - st.weight = w.max(0.0); - } - if let Some(c) = e.get("TopEdgeStrokeColor") { - st.color = self.swatch_ref(c); - } - cs.stroke = Some(st); + 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); + cs.strokes = Default::default(); } cs.paragraph_style = e.get("AppliedParagraphStyle").map(|r| self.para_style_ref(r)).filter(|n| n != designcraft_doc::NO_PARA_STYLE); match self.styles.cell.iter_mut().find(|c| c.name == name) { @@ -848,37 +862,29 @@ impl<'r> Importer<'r> { } for (_, e) in &table_styles { let name = names::style_name_in(names::TABLE_BUILTINS, e.get("Name").unwrap_or("")); - if name == designcraft_doc::BASIC_TABLE || name == "[No table style]" { - continue; - } let mut ts = designcraft_doc::TableStyle { name: name.clone(), ..Default::default() }; - ts.header = e.get("HeaderRegionCellStyle").map(cell_ref).filter(|n| n != designcraft_doc::NO_CELL_STYLE); - ts.body = e.get("BodyRegionCellStyle").map(cell_ref).filter(|n| n != designcraft_doc::NO_CELL_STYLE); - ts.footer = e.get("FooterRegionCellStyle").map(cell_ref).filter(|n| n != designcraft_doc::NO_CELL_STYLE); - ts.left_column = e.get("LeftColumnRegionCellStyle").map(cell_ref).filter(|n| n != designcraft_doc::NO_CELL_STYLE); - ts.right_column = e.get("RightColumnRegionCellStyle").map(cell_ref).filter(|n| n != designcraft_doc::NO_CELL_STYLE); - if e.get("TopBorderStrokeWeight").is_some() { - let mut b = CellStroke::default(); - if let Some(w) = e.num("TopBorderStrokeWeight") { - b.weight = w.max(0.0); - } - if let Some(c) = e.get("TopBorderStrokeColor") { - b.color = self.swatch_ref(c); - } - ts.border = Some(b); - } - if let Some(first) = e.num("StartRowFillCount").filter(|n| *n > 0.0) { - ts.alt_rows = Some(AltFills { - first: first as u32, - first_color: e.get("StartRowFillColor").map(|c| self.swatch_ref(c)).unwrap_or_else(|| swatch::NONE.into()), - first_tint: tint(e.num("StartRowFillTint")).unwrap_or(1.0), - next: e.num("EndRowFillCount").unwrap_or(1.0) as u32, - next_color: e.get("EndRowFillColor").map(|c| self.swatch_ref(c)).unwrap_or_else(|| swatch::NONE.into()), - next_tint: tint(e.num("EndRowFillTint")).unwrap_or(1.0), - skip_first: 0, - skip_last: 0, - }); + ts.based_on = + e.prop("BasedOn").map(|r| self.table_style_ref(&r)).or_else(|| Some(designcraft_doc::NO_TABLE_STYLE.into())).filter(|n| *n != name); + ts.header = e.get("HeaderRegionCellStyle").map(|r| if r == "n" { designcraft_doc::NO_CELL_STYLE.into() } else { self.cell_style_ref(r) }); + ts.body = e.get("BodyRegionCellStyle").map(|r| if r == "n" { designcraft_doc::NO_CELL_STYLE.into() } else { self.cell_style_ref(r) }); + ts.footer = e.get("FooterRegionCellStyle").map(|r| if r == "n" { designcraft_doc::NO_CELL_STYLE.into() } else { self.cell_style_ref(r) }); + ts.left_column = + e.get("LeftColumnRegionCellStyle").map(|r| if r == "n" { designcraft_doc::NO_CELL_STYLE.into() } else { self.cell_style_ref(r) }); + ts.right_column = + e.get("RightColumnRegionCellStyle").map(|r| if r == "n" { designcraft_doc::NO_CELL_STYLE.into() } else { self.cell_style_ref(r) }); + ts.header_same_as_body = e.boolean("HeaderRegionSameAsBodyRegion"); + ts.footer_same_as_body = e.boolean("FooterRegionSameAsBodyRegion"); + ts.left_column_same_as_body = e.boolean("LeftColumnRegionSameAsBodyRegion"); + ts.right_column_same_as_body = e.boolean("RightColumnRegionSameAsBodyRegion"); + ts.borders = ["TopBorder", "LeftBorder", "BottomBorder", "RightBorder"].map(|side| self.cell_stroke_attrs(e, side)); + if let Some(border) = uniform_cell_stroke(&ts.borders) { + ts.border = Some(border); + ts.borders = Default::default(); } + ts.row_fills = self.alt_fill_attrs(e, "Row"); + ts.column_fills = self.alt_fill_attrs(e, "Column"); + ts.row_strokes = self.alt_stroke_attrs(e, "Row"); + ts.column_strokes = self.alt_stroke_attrs(e, "Column"); ts.space_before = e.num("SpaceBefore"); ts.space_after = e.num("SpaceAfter"); match self.styles.table.iter_mut().find(|c| c.name == name) { @@ -1386,12 +1392,73 @@ impl<'r> Importer<'r> { Some(r) } + fn cell_style_ref(&self, reference: &str) -> String { + let reference = reference.trim(); + self.cell_names + .get(reference) + .cloned() + .unwrap_or_else(|| names::style_name_in(names::CELL_BUILTINS, &unescape_id(reference.trim_start_matches("CellStyle/")))) + } + + fn table_style_ref(&self, reference: &str) -> String { + let reference = reference.trim(); + self.table_names + .get(reference) + .cloned() + .unwrap_or_else(|| names::style_name_in(names::TABLE_BUILTINS, &unescape_id(reference.trim_start_matches("TableStyle/")))) + } + + fn cell_stroke_attrs(&mut self, el: &El, prefix: &str) -> designcraft_doc::CellStrokeAttrs { + designcraft_doc::CellStrokeAttrs { + weight: el.num(&format!("{prefix}StrokeWeight")).map(|v| v.max(0.0)), + color: el.prop(&format!("{prefix}StrokeColor")).map(|v| self.swatch_ref(&v)), + tint: tint(el.num(&format!("{prefix}StrokeTint"))), + kind: el.prop(&format!("{prefix}StrokeType")).map(|v| names::stroke_type_in(&v)), + } + } + + fn alt_fill_attrs(&mut self, el: &El, kind: &str) -> designcraft_doc::AltFillsAttrs { + designcraft_doc::AltFillsAttrs { + first: el.num(&format!("Start{kind}FillCount")).map(|v| v.max(0.0) as u32), + first_color: el.prop(&format!("Start{kind}FillColor")).map(|v| self.swatch_ref(&v)), + first_tint: tint(el.num(&format!("Start{kind}FillTint"))), + next: el.num(&format!("End{kind}FillCount")).map(|v| v.max(0.0) as u32), + next_color: el.prop(&format!("End{kind}FillColor")).map(|v| self.swatch_ref(&v)), + next_tint: tint(el.num(&format!("End{kind}FillTint"))), + skip_first: el.num(&format!("SkipFirstAlternatingFill{kind}s")).map(|v| v.max(0.0) as u32), + skip_last: el.num(&format!("SkipLastAlternatingFill{kind}s")).map(|v| v.max(0.0) as u32), + } + } + + fn alt_stroke_attrs(&mut self, el: &El, kind: &str) -> designcraft_doc::AltStrokesAttrs { + let first_stroke = self.cell_stroke_attrs(el, &format!("Start{kind}")); + let mut next_stroke = self.cell_stroke_attrs(el, &format!("End{kind}")); + // IDML uses LineStyle for this one attribute (rather than StrokeType). + if kind == "Column" + && let Some(value) = el.prop("EndColumnLineStyle") + { + next_stroke.kind = Some(names::stroke_type_in(&value)); + } + designcraft_doc::AltStrokesAttrs { + first: el.num(&format!("Start{kind}StrokeCount")).map(|v| v.max(0.0) as u32), + next: el.num(&format!("End{kind}StrokeCount")).map(|v| v.max(0.0) as u32), + first_stroke, + next_stroke, + skip_first: el.num(&format!("SkipFirstAlternatingStroke{kind}s")).map(|v| v.max(0.0) as u32), + skip_last: el.num(&format!("SkipLastAlternatingStroke{kind}s")).map(|v| v.max(0.0) as u32), + } + } + // ---------- stories ---------- fn story(&mut self, id: StoryId, e: &El) -> Story { + self.story_with_para(id, e, ParaFormat::default()) + } + + fn story_with_para(&mut self, id: StoryId, e: &El, default_para: ParaFormat) -> Story { let mut b = StoryBuilder { text: String::new(), - paras: vec![ParaFormat::default()], + paras: vec![default_para.clone()], runs: Vec::new(), fresh: true, last: CharFormat::default(), @@ -1403,7 +1470,7 @@ impl<'r> Importer<'r> { index_refs: Vec::new(), objects: Vec::new(), }; - self.walk_story(e, &mut b, &ParaFormat::default(), &CharAttrs::default(), &CharFormat::default(), None); + self.walk_story(e, &mut b, &default_para, &CharAttrs::default(), &CharFormat::default(), None); let StoryBuilder { text, mut paras, runs, last, tables: tbls, notes, anchors, xrefs, index_refs, objects, .. } = b; let mut chars: Vec<CharRun> = runs.into_iter().filter(|r| r.len > 0).collect(); if chars.is_empty() { @@ -1490,50 +1557,39 @@ impl<'r> Importer<'r> { if h.saturating_add(f) < nr { t.set_header_footer(h, f); } - let stroke = |me: &mut Self, el: &El, prefix: &str, base: &CellStroke| -> CellStroke { - let mut s = base.clone(); - if let Some(w) = el.num(&format!("{prefix}StrokeWeight")) { - s.weight = w.max(0.0); - } - if let Some(c) = el.get(&format!("{prefix}StrokeColor")) { - s.color = me.swatch_ref(c); - } - if let Some(v) = el.num(&format!("{prefix}StrokeTint")) { - s.tint = tint(Some(v)).unwrap_or(1.0); - } - if let Some(v) = el.get(&format!("{prefix}StrokeType")) { - s.kind = names::stroke_type_in(v); - } - s - }; - t.options.border = stroke(self, e, "TopBorder", &t.options.border); + let style_name = e.get("AppliedTableStyle").map(|r| self.table_style_ref(r)).unwrap_or_else(|| designcraft_doc::BASIC_TABLE.into()); + let style = self.styles.resolve_table_style(&style_name); + style.apply_to(&mut t, &self.styles.cell); + // IDML distinguishes an explicit AppliedCellStyle from the effective region style. + for cell in &mut t.cells { + cell.style.clear(); + } + // Table-local options override the named table style. Cell styles are already + // materialized independently, so their edges retain their higher precedence. + let mut borders = std::array::from_fn::<_, 4, _>(|i| t.options.border_for(i).clone()); + for (side, border) in ["TopBorder", "LeftBorder", "BottomBorder", "RightBorder"].iter().zip(&mut borders) { + self.cell_stroke_attrs(e, side).apply_to(border); + } + t.options.border = borders[0].clone(); + t.options.borders = borders.map(Some); t.options.space_before = e.num("SpaceBefore").unwrap_or(t.options.space_before); t.options.space_after = e.num("SpaceAfter").unwrap_or(t.options.space_after); t.options.repeat_header = e.get("HeaderBehavior") != Some("RepeatOnce"); t.options.repeat_footer = e.get("FooterBehavior") != Some("RepeatOnce"); - for kind in ["Row", "Column"] { - let Some(first) = e.num(&format!("Start{kind}FillCount")).filter(|n| *n > 0.0) else { continue }; - let alt = AltFills { - first: first as u32, - first_color: e.get(&format!("Start{kind}FillColor")).map(|c| self.swatch_ref(c)).unwrap_or_else(|| swatch::NONE.into()), - first_tint: tint(e.num(&format!("Start{kind}FillTint"))).unwrap_or(1.0), - next: e.num(&format!("End{kind}FillCount")).unwrap_or(1.0) as u32, - next_color: e.get(&format!("End{kind}FillColor")).map(|c| self.swatch_ref(c)).unwrap_or_else(|| swatch::NONE.into()), - next_tint: tint(e.num(&format!("End{kind}FillTint"))).unwrap_or(1.0), - skip_first: e.num(&format!("SkipFirstAlternatingFill{kind}s")).unwrap_or(0.0) as u32, - skip_last: e.num(&format!("SkipLastAlternatingFill{kind}s")).unwrap_or(0.0) as u32, - }; - if kind == "Row" { - t.options.alt_rows = Some(alt); - } else { - t.options.alt_cols = Some(alt); - } - } - t.style = e - .get("AppliedTableStyle") - .map(|r| names::style_name_in(names::TABLE_BUILTINS, &unescape_id(r.trim_start_matches("TableStyle/")))) - .filter(|n| n != designcraft_doc::BASIC_TABLE && n != "[No table style]") - .unwrap_or_default(); + // A local count may enable a pattern disabled by its parent style. Merge before + // materialization so the parent's colors/tints are still available in that case. + let mut row_fills = style.row_fills.clone(); + row_fills.merge(&self.alt_fill_attrs(e, "Row")); + row_fills.apply_to(&mut t.options.alt_rows); + let mut column_fills = style.column_fills.clone(); + column_fills.merge(&self.alt_fill_attrs(e, "Column")); + column_fills.apply_to(&mut t.options.alt_cols); + let mut row_strokes = style.row_strokes.clone(); + row_strokes.merge(&self.alt_stroke_attrs(e, "Row")); + row_strokes.apply_to(&mut t.options.row_strokes); + let mut column_strokes = style.column_strokes.clone(); + column_strokes.merge(&self.alt_stroke_attrs(e, "Column")); + column_strokes.apply_to(&mut t.options.column_strokes); let mut regions = Vec::new(); for ce in e.find_all("Cell") { let Some((c, r)) = @@ -1544,36 +1600,39 @@ impl<'r> Importer<'r> { if r >= nr || c >= nc { continue; } - let rs = ce.num("RowSpan").unwrap_or(1.0).max(1.0) as usize; - let cs = ce.num("ColumnSpan").unwrap_or(1.0).max(1.0) as usize; - let text = self.story(StoryId(0), ce); - let mut cell = Cell { text, ..Default::default() }; - cell.style = ce - .get("AppliedCellStyle") - .map(|r| names::style_name_in(names::CELL_BUILTINS, &unescape_id(r.trim_start_matches("CellStyle/")))) - .filter(|n| n != designcraft_doc::NO_CELL_STYLE) - .unwrap_or_default(); + 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 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 { + designcraft_doc::resolve_cell_style(&self.styles.cell, &cell_style).apply_to(&mut cell); + } + cell.style = if cell_style == designcraft_doc::NO_CELL_STYLE { String::new() } else { cell_style }; + let default_para = cell.text.paras.first().cloned().unwrap_or_default(); + cell.text = self.story_with_para(StoryId(0), ce, default_para); if let Some(fc) = ce.get("FillColor") { cell.fill = self.swatch_ref(fc); } - cell.fill_tint = tint(ce.num("FillTint")).unwrap_or(1.0); + if let Some(tint) = tint(ce.num("FillTint")) { + cell.fill_tint = tint; + } for (i, k) in ["TopInset", "LeftInset", "BottomInset", "RightInset"].iter().enumerate() { if let Some(v) = ce.num(k) { cell.insets[i] = v.max(0.0); } } - cell.vj = match ce.get("VerticalJustification") { - Some("CenterAlign") => VerticalJustification::Center, - Some("BottomAlign") => VerticalJustification::Bottom, - Some("JustifyAlign") => VerticalJustification::Justify, - _ => VerticalJustification::Top, - }; + if let Some(vj) = ce.get("VerticalJustification") { + cell.vj = names::vj_in(vj); + } + cell.rotation = ce.num("RotationAngle").unwrap_or(cell.rotation); for (i, side) in ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].iter().enumerate() { - let base = cell.strokes[i].clone(); - cell.strokes[i] = stroke(self, ce, side, &base); - // A positive edge priority records a local cell override, including an - // explicit None stroke. Keep it when this edge meets the table perimeter. - cell.border_overrides[i] = ce.num(&format!("{side}StrokePriority")).is_some_and(|priority| priority > 0.0); + let attrs = self.cell_stroke_attrs(ce, side); + attrs.apply_to(&mut cell.strokes[i]); + cell.stroke_defined[i] |= !attrs.is_empty(); + let priority = ce.prop(&format!("{side}StrokePriority")).and_then(|v| v.trim().parse::<i32>().ok()).unwrap_or(0); + cell.stroke_priorities[i] = priority; + // Preserve style-defined edges and #102's positive-priority local overrides. + cell.border_overrides[i] |= priority > 0; } if let Some(slot) = t.cell_mut(r, c) { *slot = cell; @@ -1593,7 +1652,7 @@ impl<'r> Importer<'r> { match n { Node::El(c) => match c.local() { "ParagraphStyleRange" => { - let style = c.get("AppliedParagraphStyle").map(|r| self.para_style_ref(r)).unwrap_or_else(|| st::BASIC_PARAGRAPH.into()); + let style = c.get("AppliedParagraphStyle").map(|r| self.para_style_ref(r)).unwrap_or_else(|| pf.style.clone()); let para = self.para_attrs(c); let chars = self.char_attrs(c); let npf = ParaFormat { style, para, chars: CharAttrs::default(), table: None }; diff --git a/crates/idml/src/names.rs b/crates/idml/src/names.rs index ae2805bda..682dabee4 100644 --- a/crates/idml/src/names.rs +++ b/crates/idml/src/names.rs @@ -85,7 +85,7 @@ pub fn unescape_id(s: &str) -> String { pub const PARA_BUILTINS: &[(&str, &str)] = &[("[No Paragraph Style]", "$ID/[No paragraph style]"), ("[Basic Paragraph]", "$ID/NormalParagraphStyle")]; pub const CHAR_BUILTINS: &[(&str, &str)] = &[("[None]", "$ID/[No character style]")]; pub const CELL_BUILTINS: &[(&str, &str)] = &[("[None]", "$ID/[None]")]; -pub const TABLE_BUILTINS: &[(&str, &str)] = &[("[Basic Table]", "$ID/[Basic Table]")]; +pub const TABLE_BUILTINS: &[(&str, &str)] = &[("[Basic Table]", "$ID/[Basic Table]"), ("[No table style]", "$ID/[No table style]")]; pub const OBJECT_BUILTINS: &[(&str, &str)] = &[("[None]", "$ID/[None]"), ("[Basic Graphics Frame]", "$ID/[Normal Graphics Frame]"), ("[Basic Text Frame]", "$ID/[Normal Text Frame]")]; diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 6aa3d7487..d7308cd28 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -928,6 +928,336 @@ fn arabic_story_and_table_directions_survive_idml_export() { assert_eq!(st.direction, designcraft_doc::TextDirection::RightToLeft); assert_eq!(st.tables.values().next().unwrap().options.direction, designcraft_doc::TextDirection::RightToLeft); } +/// Fully synthetic style-only table, including sparse based-on attributes and local overrides. +const CASCADE_STYLES: &str = r#"<idPkg:Styles xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging"> +<RootCellStyleGroup> + <CellStyle Self="CellStyle/$ID/[None]" Name="$ID/[None]"/> + <CellStyle Self="cs-base" Name="Base" FillColor="Color/Brand" FillTint="25" + TopInset="2" LeftInset="3" BottomInset="4" RightInset="5" VerticalJustification="CenterAlign" + TopEdgeStrokeWeight="2" TopEdgeStrokeColor="Color/Brand" TopEdgeStrokeTint="40" TopEdgeStrokeType="StrokeStyle/$ID/Dashed" + LeftEdgeStrokeWeight="3" BottomEdgeStrokeWeight="4" RightEdgeStrokeWeight="5" + AppliedParagraphStyle="ParagraphStyle/CellParagraph"/> + <CellStyle Self="cs-body" Name="Body" FillTint="50"><Properties><BasedOn type="object">cs-base</BasedOn></Properties></CellStyle> + <CellStyle Self="cs-left" Name="Left" FillColor="Color/Black" LeftEdgeStrokeWeight="0"><Properties><BasedOn type="object">cs-body</BasedOn></Properties></CellStyle> + <CellStyle Self="cs-header" Name="Header" FillColor="Color/Paper" BottomEdgeStrokeColor="Color/Brand" BottomEdgeStrokeWeight="6"> + <Properties><BasedOn type="object">cs-body</BasedOn></Properties> + </CellStyle> + <CellStyle Self="cs-explicit" Name="Explicit" FillColor="Color/Brand" FillTint="75" TopInset="0" + TopEdgeStrokeColor="Swatch/None" RightEdgeStrokeTint="30"/> +</RootCellStyleGroup> +<RootParagraphStyleGroup><ParagraphStyle Self="ParagraphStyle/CellParagraph" Name="CellParagraph" PointSize="9"/></RootParagraphStyleGroup> +<RootTableStyleGroup> + <TableStyle Self="TableStyle/$ID/[No table style]" Name="$ID/[No table style]" + TopBorderStrokeWeight="7" LeftBorderStrokeWeight="8" BottomBorderStrokeWeight="9" RightBorderStrokeWeight="10" + StartRowFillCount="0" StartRowFillColor="Color/Brand" StartRowFillTint="20" EndRowFillCount="0" EndRowFillColor="Swatch/None" + HeaderRegionSameAsBodyRegion="true" FooterRegionSameAsBodyRegion="true" + LeftColumnRegionSameAsBodyRegion="true" RightColumnRegionSameAsBodyRegion="true"/> + <TableStyle Self="ts-base" Name="BaseTable" BodyRegionCellStyle="cs-body" HeaderRegionCellStyle="cs-header" + LeftColumnRegionCellStyle="cs-left" HeaderRegionSameAsBodyRegion="false" LeftColumnRegionSameAsBodyRegion="false"> + <Properties><BasedOn type="string">$ID/[No table style]</BasedOn></Properties> + </TableStyle> + <TableStyle Self="ts-child" Name="Table" SpaceBefore="12" StartRowFillCount="1" EndRowFillCount="1"> + <Properties><BasedOn type="object">ts-base</BasedOn></Properties> + </TableStyle> + <TableStyle Self="TableStyle/$ID/[Basic Table]" Name="$ID/[Basic Table]" SpaceAfter="17"> + <Properties><BasedOn type="object">ts-child</BasedOn></Properties> + </TableStyle> +</RootTableStyleGroup></idPkg:Styles>"#; + +const CASCADE_STORY: &str = r#"<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging"> +<Story Self="s1"><ParagraphStyleRange><CharacterStyleRange><Table AppliedTableStyle="ts-child" HeaderRowCount="1" SpaceBefore="19" RightBorderStrokeColor="Color/Brand"> +<Row Name="0" MinimumHeight="24"/><Row Name="1" MinimumHeight="24"/> +<Column Name="0" SingleColumnWidth="60"/><Column Name="1" SingleColumnWidth="60"/><Column Name="2" SingleColumnWidth="60"/> +<Cell Name="0:0" AppliedCellStyle="CellStyle/$ID/[None]"><ParagraphStyleRange><CharacterStyleRange><Content>Header left</Content></CharacterStyleRange></ParagraphStyleRange></Cell> +<Cell Name="1:0" AppliedCellStyle="cs-explicit"><ParagraphStyleRange><CharacterStyleRange><Content>Explicit</Content></CharacterStyleRange></ParagraphStyleRange></Cell> +<Cell Name="2:0" AppliedCellStyle="cs-explicit" FillColor="Swatch/None" FillTint="0" LeftInset="0" VerticalJustification="BottomAlign" + TopEdgeStrokeWeight="0" RightEdgeStrokeColor="Swatch/None" RightEdgeStrokePriority="7"> + <ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/$ID/NormalParagraphStyle"><CharacterStyleRange><Content>Local</Content></CharacterStyleRange></ParagraphStyleRange> +</Cell> +<Cell Name="0:1" AppliedCellStyle="CellStyle/$ID/[None]"><ParagraphStyleRange><CharacterStyleRange><Content>Left</Content></CharacterStyleRange></ParagraphStyleRange></Cell> +<Cell Name="1:1"><ParagraphStyleRange><CharacterStyleRange><Content>Body</Content></CharacterStyleRange></ParagraphStyleRange></Cell> +<Cell Name="2:1"><ParagraphStyleRange><CharacterStyleRange><Content>Body right</Content></CharacterStyleRange></ParagraphStyleRange></Cell> +</Table></CharacterStyleRange></ParagraphStyleRange></Story></idPkg:Story>"#; + +fn fixture_with_table_styles(styles: &str, story: &str) -> Vec<u8> { + zip_files(&[ + ("designmap.xml", DESIGNMAP), + ("Resources/Graphic.xml", GRAPHIC), + ("Resources/Styles.xml", styles), + ("Resources/Preferences.xml", PREFS), + ("MasterSpreads/MasterSpread_m1.xml", MASTER), + ("Spreads/Spread_sp1.xml", SPREAD), + ("Stories/Story_s1.xml", story), + ]) +} + +#[test] +fn table_style_cascade_preserves_sparse_edges_and_local_overrides() { + let d = import_idml(&fixture_with_table_styles(CASCADE_STYLES, CASCADE_STORY)).unwrap(); + d.check().unwrap(); + let table = d.stories.values().flat_map(|s| s.tables.values()).next().unwrap(); + assert_eq!((table.nrows(), table.ncols()), (2, 3)); + assert_eq!(table.style, "Table"); + assert_eq!(table.options.space_before, 19.0); + assert_eq!(std::array::from_fn::<_, 4, _>(|i| table.options.border_for(i).weight), [7.0, 8.0, 9.0, 10.0]); + assert_eq!(table.options.border_for(3).color, "Brand"); + let fills = table.options.alt_rows.as_ref().unwrap(); + assert_eq!((fills.first, fills.next, fills.first_tint), (1, 1, 0.2)); + assert_eq!(fills.first_color, "Brand"); + let header = table.cell(0, 0).unwrap(); + assert_eq!(header.fill, designcraft_color::swatch::PAPER, "header takes precedence over left region"); + assert!(header.style.is_empty(), "a table region does not become AppliedCellStyle"); + assert_eq!(header.insets, [2.0, 3.0, 4.0, 5.0]); + assert_eq!(header.vj, designcraft_doc::VerticalJustification::Center); + assert_eq!(header.text.paras[0].style, "CellParagraph"); + assert_eq!(header.strokes[0].weight, 2.0); + assert_eq!(header.strokes[0].tint, 0.4); + assert_ne!(header.strokes[0].kind, designcraft_doc::StrokeType::Solid); + assert_eq!(header.strokes[2].weight, 6.0); + assert_eq!(header.border_overrides, [true; 4]); + assert_eq!(header.stroke_defined, [true; 4]); + assert_eq!(header.stroke_priorities, [0; 4]); + let explicit = table.cell(0, 1).unwrap(); + assert_eq!(explicit.fill, "Brand"); + assert_eq!(explicit.fill_tint, 0.75); + assert_eq!(explicit.insets, [0.0, 3.0, 4.0, 5.0]); + assert_eq!(explicit.strokes[0].weight, 2.0, "color-only override inherits weight"); + assert_eq!(explicit.strokes[0].color, designcraft_color::swatch::NONE); + assert_eq!(explicit.strokes[3].weight, 5.0); + assert_eq!(explicit.strokes[3].tint, 0.3); + let local = table.cell(0, 2).unwrap(); + assert_eq!(local.fill, designcraft_color::swatch::NONE); + assert_eq!(local.fill_tint, 0.0); + assert_eq!(local.insets[1], 0.0); + assert_eq!(local.vj, designcraft_doc::VerticalJustification::Bottom); + assert_eq!(local.strokes[0].weight, 0.0); + assert_eq!(local.strokes[3].color, designcraft_color::swatch::NONE); + assert_eq!(local.stroke_priorities, [0, 0, 0, 7]); + assert_eq!(local.text.paras[0].style, st::BASIC_PARAGRAPH, "explicit paragraph style wins"); + assert_eq!(table.cell(1, 0).unwrap().fill, designcraft_color::swatch::BLACK); + assert_eq!(table.cell(1, 0).unwrap().strokes[1].weight, 0.0); + assert_eq!(table.cell(1, 1).unwrap().fill_tint, 0.5); + assert_eq!(table.cell(1, 2).unwrap().fill, "Brand"); + + let back = import_idml(&export_idml(&d)).unwrap(); + let roundtrip = back.stories.values().flat_map(|s| s.tables.values()).next().unwrap(); + for (before, after) in table.cells.iter().zip(&roundtrip.cells) { + assert_eq!(after.strokes, before.strokes); + assert_eq!(after.border_overrides, before.border_overrides); + assert_eq!(after.stroke_priorities, before.stroke_priorities); + assert_eq!(after.style, before.style); + assert_eq!(after.fill, before.fill); + assert_eq!(after.insets, before.insets); + } + for i in 0..4 { + assert_eq!(roundtrip.options.border_for(i), table.options.border_for(i)); + } + let style = back.styles.cell.iter().find(|s| s.name == "Explicit").unwrap(); + assert_eq!(style.strokes[0].weight, None, "style export keeps absence distinct from zero"); + assert_eq!(style.strokes[0].color.as_deref(), Some(designcraft_color::swatch::NONE)); + assert_eq!(back.styles.table.iter().find(|s| s.name == "Table").unwrap().based_on.as_deref(), Some("BaseTable")); +} + +#[test] +fn table_style_same_as_body_flags_and_editable_basic_table_are_inherited() { + let styles = CASCADE_STYLES.replace( + "Name=\"Table\" SpaceBefore", + "Name=\"Table\" HeaderRegionSameAsBodyRegion=\"true\" LeftColumnRegionSameAsBodyRegion=\"true\" SpaceBefore", + ); + let story = CASCADE_STORY.replace("AppliedTableStyle=\"ts-child\"", "AppliedTableStyle=\"TableStyle/$ID/[Basic Table]\""); + let d = import_idml(&fixture_with_table_styles(&styles, &story)).unwrap(); + let table = d.stories.values().flat_map(|s| s.tables.values()).next().unwrap(); + assert!(table.style.is_empty()); + assert_eq!(table.options.space_after, 17.0); + assert_eq!(table.cell(0, 0).unwrap().fill, "Brand"); + assert_eq!(table.cell(0, 0).unwrap().strokes[2].weight, 4.0); + assert_eq!(table.cell(1, 0).unwrap().fill, "Brand"); + assert_eq!(table.cell(1, 0).unwrap().strokes[1].weight, 3.0); +} + +#[test] +fn table_style_cycles_and_missing_bases_terminate_without_losing_nearest_values() { + let styles = r#"<idPkg:Styles xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging"> +<RootCellStyleGroup> +<CellStyle Self="CellStyle/A" Name="A" TopEdgeStrokeWeight="0"><Properties><BasedOn type="object">CellStyle/B</BasedOn></Properties></CellStyle> +<CellStyle Self="CellStyle/B" Name="B" TopEdgeStrokeColor="Color/Brand"><Properties><BasedOn type="object">CellStyle/A</BasedOn></Properties></CellStyle> +<CellStyle Self="CellStyle/Missing" Name="Missing" LeftInset="0"><Properties><BasedOn type="object">CellStyle/Unknown</BasedOn></Properties></CellStyle> +</RootCellStyleGroup><RootTableStyleGroup> +<TableStyle Self="ts-child" Name="A" BodyRegionCellStyle="CellStyle/A"><Properties><BasedOn type="object">TableStyle/B</BasedOn></Properties></TableStyle> +<TableStyle Self="TableStyle/B" Name="B" SpaceAfter="20"><Properties><BasedOn type="object">ts-child</BasedOn></Properties></TableStyle> +</RootTableStyleGroup></idPkg:Styles>"#; + let d = import_idml(&fixture_with_table_styles(styles, CASCADE_STORY)).unwrap(); + let table = d.stories.values().flat_map(|s| s.tables.values()).next().unwrap(); + assert_eq!(table.options.space_after, 20.0); + assert_eq!(table.cell(1, 1).unwrap().strokes[0].weight, 0.0); + assert_eq!(table.cell(1, 1).unwrap().strokes[0].color, "Brand"); + let missing = designcraft_doc::resolve_cell_style(&d.styles.cell, "Missing"); + assert_eq!(missing.inset_overrides[1], Some(0.0)); +} + +#[test] +fn table_local_alternating_fill_off_survives_named_style_roundtrip() { + let styles = CASCADE_STYLES.replace( + "Name=\"Table\" SpaceBefore", + "Name=\"Table\" StartColumnFillCount=\"1\" EndColumnFillCount=\"1\" StartColumnFillColor=\"Color/Brand\" SpaceBefore", + ); + let story = CASCADE_STORY.replace("HeaderRowCount=\"1\"", "HeaderRowCount=\"1\" StartRowFillCount=\"0\" StartColumnFillCount=\"0\""); + let document = import_idml(&fixture_with_table_styles(&styles, &story)).unwrap(); + for document in [&document, &import_idml(&export_idml(&document)).unwrap()] { + let table = document.stories.values().flat_map(|s| s.tables.values()).next().unwrap(); + assert!(table.options.alt_rows.is_none()); + assert!(table.options.alt_cols.is_none()); + } +} + +#[test] +fn table_local_fill_counts_enable_inherited_disabled_pattern_colors() { + let story = + CASCADE_STORY.replace("AppliedTableStyle=\"ts-child\"", "AppliedTableStyle=\"ts-base\" StartRowFillCount=\"1\" EndRowFillCount=\"1\""); + let document = import_idml(&fixture_with_table_styles(CASCADE_STYLES, &story)).unwrap(); + let table = document.stories.values().flat_map(|s| s.tables.values()).next().unwrap(); + let fills = table.options.alt_rows.as_ref().unwrap(); + assert_eq!((fills.first, fills.next), (1, 1)); + assert_eq!(fills.first_color, "Brand"); + assert_eq!(fills.first_tint, 0.2); +} + +#[test] +fn table_absent_and_explicit_default_edges_remain_distinct_on_idml_roundtrip() { + let story = CASCADE_STORY + .replace("AppliedTableStyle=\"ts-child\"", "AppliedTableStyle=\"Missing\"") + .replace("Name=\"1:1\"", "Name=\"1:1\" TopEdgeStrokeWeight=\"1\" TopEdgeStrokePriority=\"0\""); + let document = import_idml(&fixture_with_table_styles(STYLES, &story)).unwrap(); + for document in [&document, &import_idml(&export_idml(&document)).unwrap()] { + let table = document.stories.values().flat_map(|s| s.tables.values()).next().unwrap(); + assert_eq!(table.cell(1, 0).unwrap().stroke_defined, [false; 4]); + assert_eq!(table.cell(1, 1).unwrap().stroke_defined, [true, false, false, false]); + assert_eq!(table.cell(1, 1).unwrap().border_overrides, [false; 4]); + assert_eq!(table.cell(1, 1).unwrap().stroke_priorities, [0; 4]); + } +} + +#[test] +fn multiline_table_and_cell_style_bases_resolve() { + let styles = CASCADE_STYLES.replace(">cs-base</BasedOn>", ">\n cs-base \n</BasedOn>").replace(">ts-base</BasedOn>", ">\n ts-base \n</BasedOn>"); + let document = import_idml(&fixture_with_table_styles(&styles, CASCADE_STORY)).unwrap(); + let table = document.stories.values().flat_map(|s| s.tables.values()).next().unwrap(); + assert_eq!(table.cell(1, 1).unwrap().strokes[0].weight, 2.0); + assert_eq!(table.options.border_for(0).weight, 7.0); +} + +#[test] +fn legacy_native_documents_export_both_builtin_table_styles_when_missing() { + let story = CASCADE_STORY.replace("AppliedTableStyle=\"ts-child\"", "AppliedTableStyle=\"TableStyle/$ID/[Basic Table]\""); + let mut document = import_idml(&fixture_with_story(&story)).unwrap(); + document.styles_mut().table.clear(); + let bytes = export_idml(&document); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap(); + let mut styles = String::new(); + std::io::Read::read_to_string(&mut zip.by_name("Resources/Styles.xml").unwrap(), &mut styles).unwrap(); + assert_eq!(styles.matches("Self=\"TableStyle/$ID/[Basic Table]\"").count(), 1); + assert_eq!(styles.matches("Self=\"TableStyle/$ID/[No table style]\"").count(), 1); + let back = import_idml(&bytes).unwrap(); + assert!(back.styles.table.iter().any(|s| s.name == designcraft_doc::BASIC_TABLE)); +} + +#[test] +fn hostile_table_spans_are_clamped_before_arithmetic() { + let story = CASCADE_STORY.replace("Name=\"1:1\"", "Name=\"1:1\" RowSpan=\"inf\" ColumnSpan=\"inf\""); + let document = import_idml(&fixture_with_table_styles(CASCADE_STYLES, &story)).unwrap(); + document.check().unwrap(); + let table = document.stories.values().flat_map(|s| s.tables.values()).next().unwrap(); + assert_eq!(table.cell(1, 1).unwrap().row_span, 1); + assert_eq!(table.cell(1, 1).unwrap().col_span, 2); +} + +#[test] +fn signed_idml_edge_priorities_roundtrip_without_clamping() { + let story = CASCADE_STORY.replace( + "Name=\"1:1\"", + "Name=\"1:1\" TopEdgeStrokePriority=\"-5\" LeftEdgeStrokePriority=\"-2147483648\" RightEdgeStrokePriority=\"2147483647\"", + ); + let document = import_idml(&fixture_with_table_styles(CASCADE_STYLES, &story)).unwrap(); + for document in [&document, &import_idml(&export_idml(&document)).unwrap()] { + let table = document.stories.values().flat_map(|s| s.tables.values()).next().unwrap(); + assert_eq!(table.cell(1, 1).unwrap().stroke_priorities, [-5, i32::MIN, 0, i32::MAX]); + } +} + +#[test] +fn alternating_table_strokes_inherit_and_roundtrip_without_flattening_cell_edges() { + let styles = CASCADE_STYLES.replace( + "Name=\"BaseTable\"", + "Name=\"BaseTable\" StartRowStrokeCount=\"1\" EndRowStrokeCount=\"1\" StartRowStrokeWeight=\"0\" EndRowStrokeWeight=\"0\" StartRowStrokeColor=\"Color/Paper\" EndRowStrokeColor=\"Color/Brand\" StartColumnStrokeCount=\"2\" EndColumnStrokeCount=\"1\" StartColumnStrokeWeight=\"1\" EndColumnStrokeWeight=\"2\" EndColumnLineStyle=\"StrokeStyle/$ID/Dotted\" SkipFirstAlternatingStrokeColumns=\"1\"", + ).replace("Name=\"Table\"", "Name=\"Table\" StartRowStrokeTint=\"25\""); + let story = CASCADE_STORY.replace("HeaderRowCount=\"1\"", "HeaderRowCount=\"1\" EndRowStrokeColor=\"Swatch/None\""); + let document = import_idml(&fixture_with_table_styles(&styles, &story)).unwrap(); + let before = document.stories.values().flat_map(|s| s.tables.values()).next().unwrap(); + let row = before.options.row_strokes.as_ref().unwrap(); + assert_eq!((row.first, row.next), (1, 1)); + assert_eq!(row.first_stroke.tint, 0.25); + assert_eq!(row.first_stroke.color, designcraft_color::swatch::PAPER); + assert_eq!(row.next_stroke.color, designcraft_color::swatch::NONE); + assert!(!row.uniform_stroke().unwrap().is_visible(), "zero patterns are uniform even when their colors differ"); + let column = before.options.column_strokes.as_ref().unwrap(); + assert_eq!((column.first, column.next, column.skip_first), (2, 1, 1)); + assert_eq!(column.next_stroke.kind, designcraft_doc::StrokeType::Dotted); + assert!(column.uniform_stroke().is_none(), "nonuniform/skipped patterns are preserved without guessing their phase"); + assert_eq!(before.cell(0, 0).unwrap().strokes[2].weight, 6.0, "cell styles remain higher-precedence edges"); + let back = import_idml(&export_idml(&document)).unwrap(); + let after = back.stories.values().flat_map(|s| s.tables.values()).next().unwrap(); + assert_eq!(after.options.row_strokes, before.options.row_strokes); + assert_eq!(after.options.column_strokes, before.options.column_strokes); + let style = back.styles.table.iter().find(|s| s.name == "Table").unwrap(); + assert_eq!(style.row_strokes.first_stroke.weight, None); + assert_eq!(style.row_strokes.first_stroke.tint, Some(0.25)); +} + +#[test] +fn alternating_table_strokes_local_off_overrides_named_style_on_roundtrip() { + let styles = CASCADE_STYLES.replace( + "Name=\"Table\"", + "Name=\"Table\" StartRowStrokeCount=\"1\" EndRowStrokeCount=\"1\" StartColumnStrokeCount=\"1\" EndColumnStrokeCount=\"1\"", + ); + let story = CASCADE_STORY.replace( + "HeaderRowCount=\"1\"", + "HeaderRowCount=\"1\" StartRowStrokeCount=\"0\" EndRowStrokeCount=\"0\" StartRowStrokeWeight=\"4\" EndRowStrokeColor=\"Color/Brand\" StartColumnStrokeCount=\"0\" EndColumnStrokeCount=\"0\" SkipFirstAlternatingStrokeColumns=\"3\" EndColumnLineStyle=\"StrokeStyle/$ID/Dotted\"", + ); + let document = import_idml(&fixture_with_table_styles(&styles, &story)).unwrap(); + for document in [&document, &import_idml(&export_idml(&document)).unwrap()] { + let table = document.stories.values().flat_map(|s| s.tables.values()).next().unwrap(); + let row = table.options.row_strokes.as_ref().unwrap(); + let column = table.options.column_strokes.as_ref().unwrap(); + assert_eq!((row.first, row.next, column.first, column.next), (0, 0, 0, 0)); + assert!(row.uniform_stroke().is_none()); + assert!(column.uniform_stroke().is_none()); + assert_eq!(row.first_stroke.weight, 4.0); + assert_eq!(row.next_stroke.color, "Brand"); + assert_eq!(column.skip_first, 3); + assert_eq!(column.next_stroke.kind, designcraft_doc::StrokeType::Dotted); + } +} + +#[test] +fn complete_uniform_native_style_fields_keep_legacy_identity_on_idml_roundtrip() { + let mut document = import_idml(&fixture()).unwrap(); + let stroke = designcraft_doc::CellStroke { weight: 3.0, tint: 0.5, ..Default::default() }; + document.styles_mut().cell.push(designcraft_doc::CellStyle { + name: "Uniform".into(), + insets: Some([5.0; 4]), + stroke: Some(stroke.clone()), + ..Default::default() + }); + document.styles_mut().table.push(designcraft_doc::TableStyle { name: "Uniform".into(), border: Some(stroke.clone()), ..Default::default() }); + let back = import_idml(&export_idml(&document)).unwrap(); + let cell = back.styles.cell.iter().find(|s| s.name == "Uniform").unwrap(); + assert_eq!(cell.insets, Some([5.0; 4])); + assert_eq!(cell.stroke.as_ref(), Some(&stroke)); + assert!(cell.strokes.iter().all(designcraft_doc::CellStrokeAttrs::is_empty)); + let table = back.styles.table.iter().find(|s| s.name == "Uniform").unwrap(); + assert_eq!(table.border.as_ref(), Some(&stroke)); + assert!(table.borders.iter().all(designcraft_doc::CellStrokeAttrs::is_empty)); +} #[test] fn imports_column_rules_from_frames_and_object_styles() { diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 3e9e28c0a..cebd6201e 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -171,11 +171,10 @@ pub fn check(crates: &[Crate]) -> Vec<Violation> { match classify(&d.name) { // Unregistered deps are reported on their own entry. None => {} - Some(Class::Testkit) => { - if d.kind != DepKind::Dev { - out.push(Violation::TestkitAsNormalDep { krate: c.name.clone() }); - } + Some(Class::Testkit) if d.kind != DepKind::Dev => { + out.push(Violation::TestkitAsNormalDep { krate: c.name.clone() }); } + Some(Class::Testkit) => {} Some(dc) => { let to = dc.layer().unwrap_or(u8::MAX); if to >= layer && !(to == layer && intra_layer_allowed(&c.name, &d.name)) { From 1bccd0c9e41a2f1977738ecb87c5d850be7d3e88 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick <hi@skylar.cc> Date: Sat, 10 Oct 2026 01:53:23 -0600 Subject: [PATCH 006/238] Keep menus and dialogs usable at larger UI scales (#281) * ci: simplify testkit dependency match for Clippy * UI: keep menus and dialogs reachable at larger scales * UI: share viewport-aware scrolling across menus Incorporate @aiongg's shared menu-button, anchor-aware sizing, and reopen-reset work from #279 (717ddb6), retaining #281's clipped-submenu and scrollbar interaction fixes. Use the wrapper for workspace, zoom, and panel menus as well as the menu bar. Preserve checkbox dismissal and cover anchor placement, scroll reset, and very short viewports with regression tests. --------- Co-authored-by: skylartaylor <26588250+skylartaylor@users.noreply.github.com> Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/chrome.rs | 99 ++++--- crates/ui-egui/src/dialogs.rs | 299 ++++++++++++++++++- crates/ui-egui/src/menus.rs | 376 +++++++++++++++++++++++- crates/ui-egui/src/panels/pages.rs | 2 +- crates/ui-egui/src/panels/properties.rs | 7 +- crates/ui-egui/src/panels/styles.rs | 2 +- crates/ui-egui/src/panels/swatches.rs | 8 +- 7 files changed, 727 insertions(+), 66 deletions(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 1abcf419e..2896fcdc4 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -58,52 +58,67 @@ pub fn app_bar(app: &mut DesignApp, ui: &mut egui::Ui) { ui.add_space(8.0); let current = app.ui.workspace.clone(); let shown = crate::i18n::workspace_name(&app.ui.language, ¤t); - ui.menu_button(crate::rtl::widget(ui, egui::RichText::new(format!("{shown} ▾")).font(semibold(11.5)).color(t.text)), |ui| { - let customs: Vec<String> = app.ui.custom_workspaces.iter().map(|w| w.name.clone()).collect(); - for w in - ["Essentials", "Advanced", "Book", "Digital Publishing", "Interactive for PDF", "Printing and Proofing", "Typography"] - .into_iter() - .map(str::to_string) - .chain(customs.iter().cloned()) - { + crate::menus::menu_button( + ui, + crate::rtl::widget(ui, egui::RichText::new(format!("{shown} ▾")).font(semibold(11.5)).color(t.text)), + |ui| { + let customs: Vec<String> = app.ui.custom_workspaces.iter().map(|w| w.name.clone()).collect(); + for w in [ + "Essentials", + "Advanced", + "Book", + "Digital Publishing", + "Interactive for PDF", + "Printing and Proofing", + "Typography", + ] + .into_iter() + .map(str::to_string) + .chain(customs.iter().cloned()) + { + if ui + .selectable_label(w == current, crate::rtl::widget(ui, crate::i18n::workspace_name(&app.ui.language, &w))) + .clicked() + { + let _ = app.run("window.workspace", json!({"name": w})); + ui.close(); + } + } + ui.separator(); if ui - .selectable_label(w == current, crate::rtl::widget(ui, crate::i18n::workspace_name(&app.ui.language, &w))) + .button(crate::rtl::widget( + ui, + format!( + "{} {}", + crate::i18n::tr(&app.ui.language, "Reset"), + crate::i18n::workspace_name(&app.ui.language, ¤t) + ), + )) .clicked() { - let _ = app.run("window.workspace", json!({"name": w})); + let _ = app.run("window.resetWorkspace", json!({})); ui.close(); } - } - ui.separator(); - if ui - .button(crate::rtl::widget( - ui, - format!( - "{} {}", - crate::i18n::tr(&app.ui.language, "Reset"), - crate::i18n::workspace_name(&app.ui.language, ¤t) - ), - )) - .clicked() - { - let _ = app.run("window.resetWorkspace", json!({})); - ui.close(); - } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New Workspace…"))).clicked() { - let _ = app.run("window.newWorkspace", json!({})); - ui.close(); - } - if !customs.is_empty() { - ui.menu_button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Delete Workspace")), |ui| { - for w in &customs { - if ui.button(w).clicked() { - let _ = app.run("window.deleteWorkspace", json!({"name": w})); - ui.close(); - } - } - }); - } - }); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New Workspace…"))).clicked() { + let _ = app.run("window.newWorkspace", json!({})); + ui.close(); + } + if !customs.is_empty() { + crate::menus::menu_button( + ui, + crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Delete Workspace")), + |ui| { + for w in &customs { + if ui.button(w).clicked() { + let _ = app.run("window.deleteWorkspace", json!({"name": w})); + ui.close(); + } + } + }, + ); + } + }, + ); ui.add_space(6.0); if icons::button(ui, "share", 22.0, false, crate::i18n::tr(&app.ui.language, "Share")).clicked() { app.status("Export a PDF, IDML or package to share — no cloud account needed."); @@ -534,7 +549,7 @@ pub fn status_bar(app: &mut DesignApp, ui: &mut egui::Ui) { return; } let z = app.view().map(|v| v.zoom).unwrap_or(1.0); - ui.menu_button(egui::RichText::new(format!("{:.2}% ▾", z * 100.0)).font(small.clone()), |ui| { + crate::menus::menu_button(ui, egui::RichText::new(format!("{:.2}% ▾", z * 100.0)).font(small.clone()), |ui| { for p in [0.05, 0.125, 0.25, 0.5, 0.75, 1.0, 2.0, 4.0, 8.0, 16.0] { if ui.button(format!("{}%", p * 100.0)).clicked() { let _ = app.run("view.zoom", json!({"zoom": p})); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 05f72bc63..1947190f5 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -576,7 +576,7 @@ fn document_setup(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { /// Preferences: a section list and the section's options. Application options always; units and /// increments when a document is open (InDesign keeps those with the document). -fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { +fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog, max_height: f32) { let has_doc = d.fields.contains_key("horizontalUnits"); let sections: &[(&str, &str)] = if has_doc { &[ @@ -615,21 +615,26 @@ fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { }; let cur = d.s("section"); ui.horizontal_top(|ui| { - // A fixed height: the separator would otherwise take the whole window. - ui.set_min_height(240.0); - ui.set_max_height(240.0); + // Bound both panes independently so selecting a lower section keeps its options visible. + let height = max_height.min(440.0); + ui.set_min_height(240.0_f32.min(height)); + ui.set_max_height(height); ui.vertical(|ui| { ui.set_width(150.0); - for (id, label) in sections { - if ui.selectable_label(cur == *id, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() { - d.fields.insert("section".into(), json!(id)); + egui::ScrollArea::vertical().id_salt("preferences_sections").min_scrolled_height(1.0).max_height(height).show(ui, |ui| { + for (id, label) in sections { + if ui.selectable_label(cur == *id, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() { + d.fields.insert("section".into(), json!(id)); + } } - } + }); }); ui.separator(); ui.vertical(|ui| { ui.set_min_width(300.0); - match cur.as_str() { + egui::ScrollArea::vertical().id_salt(("preferences_options", &cur)).min_scrolled_height(1.0).max_height(height).show(ui, |ui| match cur + .as_str() + { "dictionary" => { ui.label(crate::rtl::widget( ui, @@ -963,7 +968,7 @@ fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Page Numbering")).font(semibold(12.0))); check(ui, d, "absolutePageNumbers", crate::i18n::tr(&app.ui.language, "Absolute Numbering (instead of Section Numbering)")); } - } + }); }); }); } @@ -1025,13 +1030,17 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { }, }; egui::Modal::new(egui::Id::new("dialog")).show(ctx, |ui| { - ui.set_min_width(380.0); - ui.set_max_width(match d.id.as_str() { + let frame_margin = egui::Frame::popup(ui.style()).total_margin().sum(); + let available = (ctx.content_rect().size() - frame_margin - egui::vec2(16.0, 16.0)).max(egui::Vec2::splat(1.0)); + let preferred_width: f32 = match d.id.as_str() { "alert" => 440.0, "closeDocument" => 380.0, "newDocument" if crate::i18n::is_rtl(&app.ui.language) => 380.0, _ => 640.0, - }); + }; + let width = preferred_width.min(available.x); + ui.set_min_width(380.0_f32.min(width)); + ui.set_max_width(width); if crate::i18n::is_rtl(&app.ui.language) { ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -1042,6 +1051,11 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { crate::rtl::label(ui, egui::RichText::new(title).font(semibold(16.0))); } ui.add_space(10.0); + // Keep the title and action buttons outside the scrollable body. Oversized + // forms remain reachable at large UI scales, without shrinking their text. + let footer_height = ui.spacing().interact_size.y.max(ui.text_style_height(&egui::TextStyle::Button) + 2.0 * ui.spacing().button_padding.y); + let body_height = (available.y - ui.min_size().y - footer_height - 12.0 - 2.0 * ui.spacing().item_spacing.y).max(1.0); + egui::ScrollArea::both().id_salt(("dialog_body", &d.id)).min_scrolled_width(1.0).min_scrolled_height(1.0).max_width(width).max_height(body_height).show(ui, |ui| { match d.id.as_str() { "newDocument" => { ui.horizontal(|ui| { @@ -1256,7 +1270,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "insertXref" => insert_xref(app, ui, &mut d), "findFont" => find_font(app, ui, &mut d), "colorPicker" => color_picker(ui, &mut d), - "preferences" => preferences(app, ui, &mut d), + "preferences" => preferences(app, ui, &mut d, body_height), "print" => print_dialog(app, ui, &mut d), "pdfImport" => { ui.label(egui::RichText::new(if d.b("async") { d.s("name") } else { d.s("path") }).size(11.0)); @@ -1560,6 +1574,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { } _ => {} } + }); ui.add_space(12.0); let ok_label = if d.id == "closeDocument" { " Save " } else { " OK " }; ui.horizontal(|ui| { @@ -3548,6 +3563,262 @@ fn find_font(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod tests { use super::*; + fn dialog_context(language: &str) -> egui::Context { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx, language); + crate::theme::apply(&ctx, &crate::theme::Tokens::for_brightness(crate::theme::Brightness::default())); + ctx + } + + fn dialog_frame(app: &mut DesignApp, ctx: &egui::Context, screen: egui::Rect, events: Vec<egui::Event>) -> Vec<(String, egui::Rect, egui::Rect)> { + let time = ctx.input(|i| i.time) + 1.0 / 60.0; + let mut output = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(time), events, ..Default::default() }, |ui| { + show(app, ui.ctx()); + }); + output.textures_delta.clear(); + fn collect(shape: &egui::Shape, clip: egui::Rect, labels: &mut Vec<(String, egui::Rect, egui::Rect)>) { + match shape { + egui::Shape::Text(t) => labels.push((t.galley.job.text.clone(), t.visual_bounding_rect(), clip)), + egui::Shape::Vec(shapes) => { + for s in shapes { + collect(s, clip, labels); + } + } + _ => {} + } + } + let mut labels = Vec::new(); + for shape in output.shapes { + collect(&shape.shape, shape.clip_rect, &mut labels); + } + labels + } + + fn visible_label(labels: &[(String, egui::Rect, egui::Rect)], screen: egui::Rect, expected: &str) -> egui::Rect { + let (_, rect, clip) = labels.iter().find(|(text, _, _)| text.trim() == expected).unwrap_or_else(|| panic!("{expected} is painted")); + assert!(screen.contains_rect(*rect), "{expected} must stay on screen: {rect:?}"); + assert!(clip.contains_rect(*rect), "{expected} must not be clipped: {rect:?}, {clip:?}"); + *rect + } + + fn click_dialog(app: &mut DesignApp, ctx: &egui::Context, screen: egui::Rect, pos: egui::Pos2) { + for pressed in [true, false] { + dialog_frame( + app, + ctx, + screen, + vec![ + egui::Event::PointerMoved(pos), + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }, + ], + ); + } + } + + fn scroll_dialog(app: &mut DesignApp, ctx: &egui::Context, screen: egui::Rect, pos: egui::Pos2, delta: egui::Vec2) { + dialog_frame( + app, + ctx, + screen, + vec![ + egui::Event::PointerMoved(pos), + egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta, phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE }, + ], + ); + // Let smooth wheel scrolling and scrollbar visibility settle before clicking. + for _ in 0..30 { + dialog_frame(app, ctx, screen, vec![]); + } + } + + #[test] + fn dialog_title_and_actions_fit_scaled_viewports() { + for language in ["en", "ar"] { + for size in [egui::vec2(1178.0, 814.0), egui::vec2(589.0, 407.0), egui::vec2(392.0, 271.0), egui::vec2(450.0, 280.0)] { + for (command, title) in [("app.preferences", "Preferences"), ("app.newDocumentDialog", "New Document")] { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.ui.language = language.into(); + app.run("file.new", json!({})).unwrap(); + app.run(command, json!({})).unwrap(); + let ctx = dialog_context(language); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, size); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + for expected in [title, "OK", "Cancel"] { + visible_label(&labels, screen, crate::i18n::tr(language, expected)); + } + } + } + } + } + + #[test] + fn preferences_sidebar_scroll_keeps_selected_options_visible() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("app.preferences", json!({})).unwrap(); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(589.0, 407.0)); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + let sidebar = visible_label(&labels, screen, "General").center(); + assert!( + !labels.iter().any(|(text, rect, clip)| text == "File Handling" && clip.contains_rect(*rect)), + "last section starts below the sidebar" + ); + scroll_dialog(&mut app, &ctx, screen, sidebar, egui::vec2(0.0, -600.0)); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + let files = visible_label(&labels, screen, "File Handling").center(); + click_dialog(&mut app, &ctx, screen, files); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("section"), "files"); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + for expected in ["Document Recovery Data", "Save recovery data every:", "OK", "Cancel"] { + visible_label(&labels, screen, expected); + } + } + + #[test] + fn preferences_horizontal_scroll_reaches_clipped_options() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("app.preferences", json!({})).unwrap(); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(450.0, 280.0)); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + let body = visible_label(&labels, screen, "When Scaling").center(); + let option = "Absolute Numbering (instead of Section Numbering)"; + let (_, rect, clip) = labels.iter().find(|(text, _, _)| text == option).unwrap(); + assert!(rect.right() > clip.right(), "rightmost option starts horizontally clipped: {rect:?}, {clip:?}"); + scroll_dialog(&mut app, &ctx, screen, body, egui::vec2(-600.0, 0.0)); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + for expected in [option, "Preferences", "OK", "Cancel"] { + visible_label(&labels, screen, expected); + } + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, option).center()); + assert!(app.ui.dialog.as_ref().unwrap().b("absolutePageNumbers")); + } + + #[test] + fn new_document_vertical_scroll_reaches_last_field() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("app.newDocumentDialog", json!({})).unwrap(); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(392.0, 271.0)); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + let body = visible_label(&labels, screen, "Width").center(); + let (_, rect, clip) = labels.iter().find(|(text, _, _)| text == "Primary Text Frame").unwrap(); + assert!(rect.bottom() > clip.bottom(), "last field starts vertically clipped"); + scroll_dialog(&mut app, &ctx, screen, body, egui::vec2(0.0, -600.0)); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + for expected in ["Primary Text Frame", "New Document", "OK", "Cancel"] { + visible_label(&labels, screen, expected); + } + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "Primary Text Frame").center()); + assert!(app.ui.dialog.as_ref().unwrap().b("primaryTextFrame")); + } + + #[test] + fn arabic_new_document_leftmost_fields_remain_reachable() { + for size in [egui::vec2(392.0, 271.0), egui::vec2(450.0, 280.0)] { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.ui.language = "ar".into(); + app.run("app.newDocumentDialog", json!({})).unwrap(); + let fields = [("height", "66p1"), ("gutter", "1p2"), ("marginBottom", "3p3"), ("marginOutside", "3p4")]; + for (field, value) in fields { + app.ui.dialog.as_mut().unwrap().fields.insert(field.into(), json!(value)); + } + let ctx = dialog_context("ar"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, size); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + for (_, value) in fields { + visible_label(&labels, screen, value); + } + let body = visible_label(&labels, screen, "66p1").center(); + for x in [-600.0, 600.0] { + scroll_dialog(&mut app, &ctx, screen, body, egui::vec2(x, 0.0)); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + for (_, value) in fields { + visible_label(&labels, screen, value); + } + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "66p1").center()); + dialog_frame( + &mut app, + &ctx, + screen, + vec![ + egui::Event::Key { + key: egui::Key::A, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers { ctrl: true, command: true, ..Default::default() }, + }, + egui::Event::Text("88p8".into()), + ], + ); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("height"), "88p8", "leftmost numeric field accepts edits"); + scroll_dialog(&mut app, &ctx, screen, body, egui::vec2(0.0, -600.0)); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + let label = crate::i18n::tr("ar", "Primary Text Frame"); + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, label).center()); + assert!(app.ui.dialog.as_ref().unwrap().b("primaryTextFrame"), "leftmost checkbox remains clickable after scrolling"); + } + } + + #[test] + fn resized_dialogs_cancel_and_reopen_without_applying_edits() { + for (command, title, field) in [("app.preferences", "Preferences", "recoveryMinutes"), ("app.newDocumentDialog", "New Document", "pages")] { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run(command, json!({})).unwrap(); + let original = app.ui.dialog.as_ref().unwrap().fields[field].clone(); + app.ui.dialog.as_mut().unwrap().fields.insert(field.into(), json!(42)); + let ctx = dialog_context("en"); + for size in [egui::vec2(1178.0, 814.0), egui::vec2(392.0, 271.0), egui::vec2(589.0, 407.0)] { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, size); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + for expected in [title, "OK", "Cancel"] { + visible_label(&labels, screen, expected); + } + } + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(589.0, 407.0)); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "Cancel").center()); + assert!(app.ui.dialog.is_none()); + assert!(app.session.active().is_none(), "cancelling must not create a document"); + app.run(command, json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields[field], original, "cancelled edits must not survive reopening"); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + visible_label(&labels, screen, title); + dialog_frame( + &mut app, + &ctx, + screen, + vec![egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }], + ); + assert!(app.ui.dialog.is_none(), "Escape must also close the reopened dialog"); + } + } + fn draw(app: &mut DesignApp, ctx: &egui::Context) { let input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), ..Default::default() }; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 6fc783f3a..13e42e2fe 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1837,6 +1837,86 @@ pub fn activate(app: &mut DesignApp, id: &str, params: &Value) { let _ = app.run(id, p); } +/// A menu button whose popup stays within the viewport and scrolls when needed. +/// All menu buttons share this wrapper, including workspace, zoom and panel menus. +pub fn menu_button<'a, R>( + ui: &mut egui::Ui, + atoms: impl egui::IntoAtoms<'a>, + add_contents: impl FnOnce(&mut egui::Ui) -> R, +) -> egui::InnerResponse<Option<R>> { + use egui::containers::menu::{MenuButton, MenuConfig}; + + // A menu opens from a button already shown in the previous frame. Base its height on + // that anchor, not the popup's eventual position, so placement cannot oscillate. + let button = ui.ctx().read_response(ui.next_auto_id()).map(|response| response.rect); + let max_height = menu_max_height(ui, button); + let contents = |ui: &mut egui::Ui| { + // Override the popup's initial available height, including when it opens upwards. + ui.set_max_height(max_height); + let mut scroll = egui::ScrollArea::vertical().max_height(max_height).min_scrolled_height(1.0); + if ui.is_sizing_pass() { + // Closing and reopening a menu starts at its first command again. + scroll = scroll.vertical_scroll_offset(0.0); + } + scroll.show(ui, add_contents).inner + }; + // Scrollbar clicks must not dismiss the popup; command rows close it explicitly. + let config = MenuConfig::find(ui).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside); + let (response, inner) = if egui::containers::menu::is_in_menu(ui) { + clipped_submenu(ui, atoms, config, contents) + } else { + MenuButton::new(atoms).config(config).ui(ui, contents) + }; + egui::InnerResponse { response, inner: inner.map(|response| response.inner) } +} + +/// The tallest menu that fits on either side of its anchor, including the popup frame. +fn menu_max_height(ui: &egui::Ui, button: Option<egui::Rect>) -> f32 { + let screen = ui.ctx().content_rect(); + let space = match button { + // Submenus open beside their row, aligned with its top or bottom. + Some(rect) if egui::containers::menu::is_in_menu(ui) => (screen.bottom() - rect.top()).max(rect.bottom() - screen.top()), + Some(rect) => (screen.bottom() - rect.bottom()).max(rect.top() - screen.top()), + None => screen.height(), + }; + let chrome = egui::Frame::menu(ui.style()).total_margin().sum().y + 8.0; + (space.min(screen.height()) - chrome).max(1.0) +} + +/// egui's submenu hover test uses the full row rect, even outside a scroll area's clip. +fn clipped_submenu<'a, R>( + ui: &mut egui::Ui, + atoms: impl egui::IntoAtoms<'a>, + config: egui::containers::menu::MenuConfig, + contents: impl FnOnce(&mut egui::Ui) -> R, +) -> (egui::Response, Option<egui::InnerResponse<R>>) { + use egui::containers::menu::{MenuState, SubMenu, SubMenuButton}; + + let id = SubMenu::id_from_widget_id(ui.next_auto_id()); + let open = MenuState::from_ui(ui, |state, _| state.open_item == Some(id)); + let inactive = ui.style().visuals.widgets.inactive; + if open { + ui.style_mut().visuals.widgets.inactive = ui.style().visuals.widgets.open; + } + let button = SubMenuButton::new(atoms).config(config); + let response = ui.add(button.button); + ui.style_mut().visuals.widgets.inactive = inactive; + let clip = ui.clip_rect(); + if !response.rect.intersect(clip).is_positive() { + if open { + MenuState::from_ui(ui, |state, _| state.open_item = None); + } + return (response, None); + } + // Undo the half-spacing expansion used by SubMenu::show after clamping to the clip. + let hover_margin = ui.spacing().item_spacing / 2.0; + let mut clipped = response.clone(); + clipped.rect = response.rect.expand2(hover_margin).intersect(clip).shrink2(hover_margin); + clipped.interact_rect = response.interact_rect.intersect(clip); + let inner = button.sub_menu.show(ui, &clipped, contents); + (response, inner) +} + /// A native menu bar item was chosen (macOS): like [`activate`], except that the menu bar takes /// ⌘A, ⌘C, ⌘X, ⌘V, ⌘Z and ⇧⌘Z before the window sees them, so while a text field has keyboard /// focus, Select All, Copy, Cut, Paste, Undo and Redo act on that field, as in any Mac app. @@ -1883,7 +1963,7 @@ pub fn menu_bar(app: &mut DesignApp, ui: &mut egui::Ui) { menus.reverse(); } for (menu, entries) in menus { - ui.menu_button(crate::rtl::widget(ui, crate::i18n::tr(&lang, menu)), |ui| { + menu_button(ui, crate::rtl::widget(ui, crate::i18n::tr(&lang, menu)), |ui| { if crate::i18n::is_rtl(&lang) { ui.set_max_width(320.0); } @@ -1917,7 +1997,7 @@ fn menu_items(app: &mut DesignApp, ui: &mut egui::Ui, items: &[Item], path: &str Item::Sub(name, children) => { let sub = format!("{path}/{name}"); let shown = crate::i18n::tr(&app.ui.language, name).to_owned(); - ui.menu_button(crate::rtl::widget(ui, shown), |ui| { + menu_button(ui, crate::rtl::widget(ui, shown), |ui| { hidden += menu_items(app, ui, children, &sub); }); } @@ -2166,6 +2246,97 @@ mod tests { } } + /// Exercise the real dropdown, including clipping and wheel input, on a short viewport. + #[test] + fn oversized_edit_menu_scrolls_to_preferences() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(700.0, 260.0)); + let mut tick = 0; + let mut frame = |app: &mut DesignApp, events: Vec<egui::Event>| { + tick += 1; + let mut output = + ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(tick as f64 / 60.0), events, ..Default::default() }, |ui| { + egui::MenuBar::new().ui(ui, |ui| menu_bar(app, ui)); + }); + output.textures_delta.clear(); + let mut labels = Vec::new(); + fn collect(shape: &egui::Shape, clip: egui::Rect, labels: &mut Vec<(String, egui::Rect, egui::Rect)>) { + match shape { + egui::Shape::Text(t) => labels.push((t.galley.job.text.clone(), t.visual_bounding_rect(), clip)), + egui::Shape::Vec(shapes) => { + for shape in shapes { + collect(shape, clip, labels); + } + } + _ => {} + } + } + for shape in output.shapes { + collect(&shape.shape, shape.clip_rect, &mut labels); + } + labels + }; + frame(&mut app, vec![]); + let labels = frame(&mut app, vec![]); + let edit = labels.iter().find(|(text, _, _)| text == "Edit").unwrap().1.center(); + for pressed in [true, false] { + frame( + &mut app, + vec![ + egui::Event::PointerMoved(edit), + egui::Event::PointerButton { pos: edit, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }, + ], + ); + } + for _ in 0..3 { + frame(&mut app, vec![]); + } + let layer = ctx.layer_id_at(egui::pos2(edit.x + 80.0, 100.0)).expect("menu layer"); + let bounds = ctx.memory(|m| m.area_rect(layer.id)).expect("menu bounds"); + let track = egui::pos2(bounds.right() - 9.0, bounds.bottom() - 25.0); + for pressed in [true, false] { + frame( + &mut app, + vec![ + egui::Event::PointerMoved(track), + egui::Event::PointerButton { pos: track, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }, + ], + ); + } + assert!(egui::Popup::is_any_open(&ctx), "clicking the scroll track must not dismiss the menu"); + frame( + &mut app, + vec![ + egui::Event::PointerMoved(egui::pos2(edit.x + 80.0, 100.0)), + egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -3000.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }, + ], + ); + let mut labels = Vec::new(); + for _ in 0..60 { + labels = frame(&mut app, vec![]); + } + let (_, rect, clip) = labels.iter().find(|(text, _, _)| text == "Preferences…").expect("Preferences is rendered"); + assert!(screen.contains_rect(*rect) && clip.contains_rect(*rect), "Preferences must be visible after scrolling: {rect:?}, {clip:?}"); + let pos = rect.center(); + for pressed in [true, false] { + frame( + &mut app, + vec![ + egui::Event::PointerMoved(pos), + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }, + ], + ); + } + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("preferences")); + assert!(!egui::Popup::is_any_open(&ctx), "choosing a command dismisses the menu"); + } + #[test] fn transform_menu_items_open_their_dialogs() { for id in ["transform.move", "transform.scale", "transform.rotate", "transform.shear"] { @@ -2911,4 +3082,205 @@ mod tests { assert!(app.ui.hidden_menu_items.is_empty()); assert!(items.len() > 100, "every menu's items are listed: {}", items.len()); } + + #[test] + fn menu_button_fits_its_anchor_and_resets_scroll_on_reopen() { + // Workspace/panel menus may be in the middle; the status-bar zoom menu opens upwards. + for anchor_y in [0.0, 130.0, 260.0] { + let ctx = egui::Context::default(); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(400.0, 300.0)); + let mut tick = 0; + let mut frame = |events: Vec<egui::Event>| { + tick += 1; + let mut button = None; + let mut first = None; + let mut last = None; + let mut output = + ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(tick as f64 / 60.0), events, ..Default::default() }, |ui| { + ui.add_space(anchor_y); + let response = menu_button(ui, "Long menu", |ui| { + for i in 0..60 { + let response = ui.button(format!("Item {i}")); + if i == 0 { + first = Some((response.rect, ui.clip_rect())); + } + if i == 59 { + last = Some((response.rect, ui.clip_rect())); + } + } + }); + button = Some(response.response); + }); + output.textures_delta.clear(); + (button.expect("menu button"), first, last) + }; + let click = + |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + frame(vec![]); + let (button, _, _) = frame(vec![]); + let anchor = button.rect; + for pressed in [true, false] { + frame(vec![egui::Event::PointerMoved(anchor.center()), click(anchor.center(), pressed)]); + } + for _ in 0..3 { + frame(vec![]); + } + let popup = ctx.memory(|memory| memory.area_rect(egui::Popup::default_response_id(&button))).expect("open menu"); + assert!(screen.contains_rect(popup), "menu at {anchor_y} fits in the viewport: {popup:?}"); + assert!( + popup.top() >= anchor.bottom() - 1.0 || popup.bottom() <= anchor.top() + 1.0, + "menu at {anchor_y} stays on one side of its button: {popup:?}, {anchor:?}" + ); + let (_, first, last) = frame(vec![]); + let (first, clip) = first.expect("first row"); + assert!(clip.contains_rect(first), "newly opened menu starts at the first row"); + assert!(!clip.intersects(last.expect("last row").0), "last row starts below the scroll area"); + + frame(vec![ + egui::Event::PointerMoved(popup.center()), + egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -5000.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }, + ]); + for _ in 0..60 { + frame(vec![]); + } + let (_, first, last) = frame(vec![]); + let (last, clip) = last.expect("scrolled last row"); + assert!(popup.contains_rect(last) && clip.contains_rect(last), "last row is reachable: {last:?}, {popup:?}, {clip:?}"); + assert!(!clip.intersects(first.expect("scrolled first row").0), "menu really scrolled away from its start"); + + let outside = egui::pos2(screen.right() - 10.0, screen.center().y); + for pressed in [true, false] { + frame(vec![egui::Event::PointerMoved(outside), click(outside, pressed)]); + } + assert!(!egui::Popup::is_any_open(&ctx), "outside click closes the menu"); + assert!(frame(vec![]).1.is_none(), "closed menu is no longer drawn"); + for pressed in [true, false] { + frame(vec![egui::Event::PointerMoved(anchor.center()), click(anchor.center(), pressed)]); + } + for _ in 0..3 { + frame(vec![]); + } + let (_, first, last) = frame(vec![]); + let (first, clip) = first.expect("reopened first row"); + assert!(popup.contains_rect(first) && clip.contains_rect(first), "reopening resets to the first row: {first:?}, {clip:?}"); + assert!(!clip.intersects(last.expect("reopened last row").0), "reopening discards the old scroll offset"); + } + } + + #[test] + fn tiny_menu_viewport_does_not_force_the_default_scroll_height() { + let ctx = egui::Context::default(); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(400.0, 100.0)); + let mut tick = 0; + let mut frame = |events: Vec<egui::Event>| { + tick += 1; + let mut button = None; + let mut output = + ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(tick as f64 / 60.0), events, ..Default::default() }, |ui| { + ui.add_space(35.0); + let response = menu_button(ui, "Long menu", |ui| { + for i in 0..20 { + let _ = ui.button(format!("Item {i}")); + } + }); + button = Some(response.response); + }); + output.textures_delta.clear(); + button.expect("menu button") + }; + frame(vec![]); + let button = frame(vec![]); + let pos = button.rect.center(); + for pressed in [true, false] { + frame(vec![ + egui::Event::PointerMoved(pos), + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }, + ]); + } + for _ in 0..3 { + frame(vec![]); + } + let popup = ctx.memory(|memory| memory.area_rect(egui::Popup::default_response_id(&button))).expect("open menu"); + let space = (screen.bottom() - button.rect.bottom()).max(button.rect.top() - screen.top()); + assert!(space < 64.0, "fixture has less space than egui's default minimum scroll height"); + // A complete command row may not fit, but the scroll area's minimum must not make + // the popup exceed its height budget or cover the button that dismisses it. + assert!(popup.height() <= space, "tiny menu respects its available height: {popup:?}, {space}"); + assert!(screen.contains_rect(popup), "tiny menu remains inside the viewport: {popup:?}"); + assert!( + popup.top() >= button.rect.bottom() - 1.0 || popup.bottom() <= button.rect.top() + 1.0, + "tiny menu stays beside its anchor: {popup:?}, {:?}", + button.rect + ); + } + + #[test] + fn clipped_submenu_does_not_open_when_hovering_outside_menu() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 900.0)); + let entries = vec![Item::Sub("Hidden submenu".into(), vec![cmd_item("app.preferences", Value::Null)])]; + let mut tick = 0; + let mut frame = |spacing: f32, events: Vec<egui::Event>| { + tick += 1; + let mut button_rect = egui::Rect::NOTHING; + let mut hidden_submenu = None; + let mut output = + ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(tick as f64 / 60.0), events, ..Default::default() }, |ui| { + egui::MenuBar::new().ui(ui, |ui| { + let response = ui.menu_button("Menu", |ui| { + egui::ScrollArea::vertical().max_height(400.0).show(ui, |ui| { + ui.add_space(spacing); + let button_id = ui.next_auto_id(); + menu_items(&mut app, ui, &entries, "Test"); + let response = ui.ctx().read_response(button_id).expect("submenu button response"); + let open = egui::containers::menu::MenuState::from_ui(ui, |state, _| state.open_item.is_some()); + hidden_submenu = Some((response.rect, ui.clip_rect(), open)); + }); + }); + button_rect = response.response.rect; + }); + }); + output.textures_delta.clear(); + (button_rect, hidden_submenu) + }; + frame(500.0, vec![]); + let button = frame(500.0, vec![]).0.center(); + for pressed in [true, false] { + frame( + 500.0, + vec![ + egui::Event::PointerMoved(button), + egui::Event::PointerButton { pos: button, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }, + ], + ); + } + for _ in 0..3 { + frame(500.0, vec![]); + } + let (_, Some((hidden, clip, open))) = frame(500.0, vec![]) else { panic!("test menu is open") }; + assert!(!open, "submenu starts closed"); + assert!(screen.contains_rect(hidden), "hidden row remains inside the screen: {hidden:?}"); + assert!(!clip.intersects(hidden), "submenu must be fully clipped: {hidden:?}, {clip:?}"); + let (_, Some((_, _, open))) = frame(500.0, vec![egui::Event::PointerMoved(hidden.center())]) else { panic!("test menu remains open") }; + assert!(!open, "hovering the clipped row's position outside the menu must not open its submenu"); + + let partial_spacing = 500.0 - (hidden.top() - clip.bottom()) - 6.0; + let (_, Some((partial, clip, open))) = frame(partial_spacing, vec![]) else { panic!("test menu remains open") }; + assert!(!open); + assert!(partial.top() < clip.bottom() && partial.bottom() > clip.bottom(), "row must be partially clipped: {partial:?}, {clip:?}"); + let outside = egui::pos2(partial.center().x, clip.bottom() + 1.0); + let (_, Some((_, _, open))) = frame(partial_spacing, vec![egui::Event::PointerMoved(outside)]) else { panic!("test menu remains open") }; + assert!(!open, "hovering the clipped portion of a partially visible row must not open its submenu"); + let visible = partial.intersect(clip).center(); + let (_, Some((_, _, open))) = frame(partial_spacing, vec![egui::Event::PointerMoved(visible)]) else { panic!("test menu remains open") }; + assert!(open, "hovering the visible portion still opens the submenu"); + let (_, Some((_, _, open))) = frame(500.0, vec![]) else { panic!("test menu remains open") }; + assert!(!open, "an open submenu closes when its row becomes fully clipped"); + } } diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index 6019e7883..6bbad9066 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -256,7 +256,7 @@ fn page_menu(app: &mut DesignApp, ui: &mut egui::Ui, doc: &designcraft_doc::Docu ui.close(); } } - ui.menu_button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Apply Parent")), |ui| { + crate::menus::menu_button(ui, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Apply Parent")), |ui| { if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "[None]"))).clicked() { let _ = app.run("layout.pages.applyParent", json!({"pages": [abs], "parent": null})); ui.close(); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 5b8a710a1..49cb920f2 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1333,7 +1333,7 @@ pub fn character_panel(app: &mut DesignApp, ui: &mut egui::Ui) { } let features: Vec<String> = c["otfFeatures"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default(); - ui.menu_button("OpenType", |ui| open_type_menu(app, ui, &features)); + crate::menus::menu_button(ui, "OpenType", |ui| open_type_menu(app, ui, &features)); }); // Underline / Strikethrough Options. for (key, title) in [("underline", "Underline Options"), ("strikethrough", "Strikethrough Options")] { @@ -1388,13 +1388,14 @@ pub fn open_type_menu(app: &mut DesignApp, ui: &mut egui::Ui, features: &[String } } ui.separator(); - ui.menu_button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Stylistic Sets")), |ui| { + crate::menus::menu_button(ui, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Stylistic Sets")), |ui| { let mask = otf::stylistic_sets(features); for n in 1..=20u32 { let mut on = mask & (1 << (n - 1)) != 0; if ui.checkbox(&mut on, crate::rtl::widget(ui, format!("{} {n}", crate::i18n::tr(&app.ui.language, "Set")))).clicked() { let sets: Vec<u32> = (1..=20).filter(|k| if *k == n { on } else { mask & (1 << (k - 1)) != 0 }).collect(); let _ = app.run("type.openType", json!({"stylisticSets": sets})); + ui.close(); } } }); @@ -1410,7 +1411,7 @@ pub fn paragraph_panel(app: &mut DesignApp, ui: &mut egui::Ui) { ui.horizontal(|ui| { crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Paragraph")).strong()); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.menu_button("☰", |ui| { + crate::menus::menu_button(ui, "☰", |ui| { if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Paragraph Rules…"))).clicked() { let _ = app.run("app.paragraphRulesDialog", json!({})); ui.close(); diff --git a/crates/ui-egui/src/panels/styles.rs b/crates/ui-egui/src/panels/styles.rs index 82dc1282a..25a596644 100644 --- a/crates/ui-egui/src/panels/styles.rs +++ b/crates/ui-egui/src/panels/styles.rs @@ -121,7 +121,7 @@ fn style_row(app: &mut DesignApp, ui: &mut egui::Ui, para: bool, n: &str, curren ui.close(); } if !n.starts_with('[') { - ui.menu_button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Move to Group")), |ui| { + crate::menus::menu_button(ui, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Move to Group")), |ui| { if n.contains('/') && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "[No Group]"))).clicked() { let _ = app.run("style.group", json!({"kind": kind, "names": [n], "group": ""})); ui.close(); diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index d80b72883..1aa11ddb3 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -73,12 +73,13 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { if icons::button(ui, "plus", 20.0, false, crate::i18n::tr(&app.ui.language, "New Swatch")).clicked() { let _ = app.run("swatch.create", json!({"color": {"c": 0, "m": 50, "y": 100, "k": 0}})); } - ui.menu_button("☰", |ui| { - ui.menu_button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Ink Manager")), |ui| { + crate::menus::menu_button(ui, "☰", |ui| { + crate::menus::menu_button(ui, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Ink Manager")), |ui| { let Ok(l) = app.session.execute("ink.list", &json!({})) else { return }; let mut all = l["allToProcess"].as_bool().unwrap_or(false); if ui.checkbox(&mut all, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "All Spots to Process"))).changed() { let _ = app.run("ink.options", json!({"allToProcess": all})); + ui.close(); } ui.separator(); let inks = l["inks"].as_array().cloned().unwrap_or_default(); @@ -91,6 +92,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { ui.add_enabled_ui(!all, |ui| { if ui.checkbox(&mut process, format!("{name} → process")).changed() { let _ = app.run("ink.options", json!({"ink": name, "toProcess": process})); + ui.close(); } }); } @@ -162,7 +164,7 @@ fn swatch_row( ui.close(); } if !sw.locked { - ui.menu_button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Move to Color Group")), |ui| { + crate::menus::menu_button(ui, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Move to Color Group")), |ui| { if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "(Top Level)"))).clicked() { let _ = app.run("swatch.moveToGroup", json!({"swatches": [sw.name], "group": null})); ui.close(); From 37803c8176f4a762b47e2ffe17f01b6c24fcca0a Mon Sep 17 00:00:00 2001 From: Brandon Thomas <echelon@gmail.com> Date: Sat, 10 Oct 2026 04:00:40 -0400 Subject: [PATCH 007/238] PDF: reuse image conversions and index page objects (#176) Co-authored-by: skylar taylor-barrick <hi@skylar.cc> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/images/src/lib.rs | 39 ++- crates/images/src/luma8_tests.rs | 116 +++++++ .../images/tests/rgba_conversion_contract.rs | 112 ++++++ crates/pdf/src/export.rs | 275 ++++++++++++++- crates/pdf/src/lib.rs | 11 + crates/pdf/src/transitions.rs | 76 +++- crates/pdf/tests/image_content_cache.rs | 5 + .../pdf/tests/page_entry_search_contract.rs | 147 ++++++++ .../support/image_content_cache_contract.rs | 325 ++++++++++++++++++ crates/pdf/tests/support/image_fixtures.rs | 23 ++ 10 files changed, 1123 insertions(+), 6 deletions(-) create mode 100644 crates/images/src/luma8_tests.rs create mode 100644 crates/images/tests/rgba_conversion_contract.rs create mode 100644 crates/pdf/tests/image_content_cache.rs create mode 100644 crates/pdf/tests/page_entry_search_contract.rs create mode 100644 crates/pdf/tests/support/image_content_cache_contract.rs create mode 100644 crates/pdf/tests/support/image_fixtures.rs diff --git a/crates/images/src/lib.rs b/crates/images/src/lib.rs index 1e81115c4..4751263a4 100644 --- a/crates/images/src/lib.rs +++ b/crates/images/src/lib.rs @@ -159,9 +159,46 @@ pub fn decode_rgba(bytes: &[u8]) -> Option<image::RgbaImage> { if is_psd(bytes) { return psd::decode(bytes); } - Some(image::load_from_memory(bytes).ok()?.to_rgba8()) + Some(decode_to_rgba8(&image::load_from_memory(bytes).ok()?)) } +/// Fresh raster decodes are sRGB in the pinned image decoder. Expanding an L8 value to +/// [gray, gray, gray, 255] is exact and avoids its generic floating-point conversion. +fn decode_to_rgba8(img: &image::DynamicImage) -> image::RgbaImage { + let prepared = match img { + image::DynamicImage::ImageLuma8(gray) => prepare_luma8_rgba(gray), + _ => None, + }; + rgba_or_original(img, prepared) +} + +// Keep preparation failure on the existing conversion/error path. This small boundary also +// lets tests force failure deterministically without exhausting memory or changing an allocator. +fn rgba_or_original(img: &image::DynamicImage, prepared: Option<image::RgbaImage>) -> image::RgbaImage { + prepared.unwrap_or_else(|| img.to_rgba8()) +} + +fn prepare_luma8_rgba(gray: &image::GrayImage) -> Option<image::RgbaImage> { + let (width, height) = gray.dimensions(); + let pixels = usize::try_from(width).ok()?.checked_mul(usize::try_from(height).ok()?)?; + let length = pixels.checked_mul(4)?; + let mut bytes = Vec::new(); + // Unlike the generic ConvertBuffer API's infallible allocation, preserve a route back to + // the original to_rgba8 behavior when the requested buffer cannot be reserved. + bytes.try_reserve_exact(length).ok()?; + bytes.resize(length, 0); + for (output, input) in bytes.as_chunks_mut::<4>().0.iter_mut().zip(gray.pixels()) { + let value = input.0[0]; + output.copy_from_slice(&[value, value, value, 255]); + } + let mut rgba = image::RgbaImage::from_raw(width, height, bytes)?; + rgba.set_color_space(gray.color_space()).ok()?; + Some(rgba) +} + +#[cfg(test)] +mod luma8_tests; + /// A CMYK raster's ink values, 8 bits per ink (0 = no ink, 255 = solid), as PDF's DeviceCMYK /// reads them. #[derive(Clone, Debug, PartialEq, Eq, Hash)] diff --git a/crates/images/src/luma8_tests.rs b/crates/images/src/luma8_tests.rs new file mode 100644 index 000000000..69e9b40dd --- /dev/null +++ b/crates/images/src/luma8_tests.rs @@ -0,0 +1,116 @@ +use super::{decode_to_rgba8, prepare_luma8_rgba, rgba_or_original}; +use image::metadata::{CicpColorPrimaries as Primaries, CicpTransferCharacteristics as Transfer}; +use image::{DynamicImage, GrayImage, RgbaImage}; + +const PRIMARIES: [Primaries; 12] = [ + Primaries::SRgb, + Primaries::Unspecified, + Primaries::RgbM, + Primaries::RgbB, + Primaries::Bt601, + Primaries::Rgb240m, + Primaries::GenericFilm, + Primaries::Rgb2020, + Primaries::Xyz, + Primaries::SmpteRp431, + Primaries::SmpteRp432, + Primaries::Industry22, +]; +const TRANSFERS: [Transfer; 17] = [ + Transfer::Bt709, + Transfer::Unspecified, + Transfer::Bt470M, + Transfer::Bt470BG, + Transfer::Bt601, + Transfer::Smpte240m, + Transfer::Linear, + Transfer::Log100, + Transfer::LogSqrt, + Transfer::Iec61966_2_4, + Transfer::Bt1361, + Transfer::SRgb, + Transfer::Bt2020_10bit, + Transfer::Bt2020_12bit, + Transfer::Smpte2084, + Transfer::Smpte428, + Transfer::Bt2100Hlg, +]; + +#[test] +fn every_luma8_value_and_representable_cicp_match_old_and_independent_oracles() { + for primaries in PRIMARIES { + for transfer in TRANSFERS { + let mut gray = GrayImage::from_raw(256, 1, (0u8..=255).collect()).unwrap(); + gray.set_rgb_primaries(primaries); + gray.set_transfer_function(transfer); + let original_color = gray.color_space(); + let source = DynamicImage::ImageLuma8(gray); + let old = source.to_rgba8(); + let new = decode_to_rgba8(&source); + assert_eq!(new.dimensions(), (256, 1)); + assert_eq!(new.as_raw(), old.as_raw(), "{primaries:?} {transfer:?}"); + assert_eq!(new.color_space(), original_color); + assert_eq!(new.color_space(), old.color_space()); + for (value, pixel) in (0u8..=255).zip(new.pixels()) { + assert_eq!(pixel.0, [value, value, value, 255]); + } + } + } +} + +#[test] +fn zero_non_square_and_chunk_boundary_dimensions_match() { + for (width, height) in [(0, 0), (0, 7), (7, 0), (3, 7), (255, 1), (256, 1), (257, 1)] { + let length = width as usize * height as usize; + let bytes = (0..length).map(|n| (n % 256) as u8).collect(); + let source = DynamicImage::ImageLuma8(GrayImage::from_raw(width, height, bytes).unwrap()); + let old = source.to_rgba8(); + let new = decode_to_rgba8(&source); + assert_eq!(new.dimensions(), old.dimensions()); + assert_eq!(new.color_space(), old.color_space()); + assert_eq!(new.as_raw(), old.as_raw()); + } +} + +#[test] +fn unused_backing_elements_do_not_enter_the_output() { + let gray = GrayImage::from_raw(2, 1, vec![13, 27, 222, 223, 224]).unwrap(); + let prepared = prepare_luma8_rgba(&gray).unwrap(); + assert_eq!(prepared.as_raw(), &[13, 13, 13, 255, 27, 27, 27, 255]); + assert_eq!(prepared, DynamicImage::ImageLuma8(gray).to_rgba8()); +} + +#[test] +fn forced_preparation_failure_uses_original_conversion_and_metadata() { + let mut gray = GrayImage::from_raw(3, 1, vec![0, 129, 255]).unwrap(); + gray.set_rgb_primaries(Primaries::Rgb2020); + gray.set_transfer_function(Transfer::Smpte2084); + let source = DynamicImage::ImageLuma8(gray); + let old = source.to_rgba8(); + let fallback = rgba_or_original(&source, None); + assert_eq!(fallback.as_raw(), old.as_raw()); + assert_eq!(fallback.dimensions(), old.dimensions()); + assert_eq!(fallback.color_space(), old.color_space()); +} + +#[test] +fn non_luma8_variants_keep_original_conversion() { + let variants = [ + DynamicImage::ImageRgba8(RgbaImage::from_pixel(3, 2, image::Rgba([200, 30, 90, 0]))), + DynamicImage::ImageRgb8(image::RgbImage::from_pixel(3, 2, image::Rgb([17, 29, 43]))), + DynamicImage::ImageLumaA8(image::GrayAlphaImage::from_pixel(3, 2, image::LumaA([129, 67]))), + DynamicImage::ImageLuma16(image::ImageBuffer::from_pixel(3, 2, image::Luma([129u16]))), + DynamicImage::ImageLumaA16(image::ImageBuffer::from_pixel(3, 2, image::LumaA([65406u16, 32896]))), + DynamicImage::ImageRgb16(image::ImageBuffer::from_pixel(3, 2, image::Rgb([128u16, 129, 65535]))), + DynamicImage::ImageRgba16(image::ImageBuffer::from_pixel(3, 2, image::Rgba([128u16, 129, 65535, 32896]))), + DynamicImage::ImageRgb32F(image::ImageBuffer::from_pixel(3, 2, image::Rgb([0.1f32, 0.5, 0.9]))), + DynamicImage::ImageRgba32F(image::ImageBuffer::from_pixel(3, 2, image::Rgba([0.1f32, 0.5, 0.9, 0.25]))), + ]; + for source in variants { + let old = source.to_rgba8(); + let new = decode_to_rgba8(&source); + assert_eq!(new.as_raw(), old.as_raw()); + assert_eq!(new.dimensions(), old.dimensions()); + assert_eq!(new.color_space(), old.color_space()); + } +} diff --git a/crates/images/tests/rgba_conversion_contract.rs b/crates/images/tests/rgba_conversion_contract.rs new file mode 100644 index 000000000..9f514cf73 --- /dev/null +++ b/crates/images/tests/rgba_conversion_contract.rs @@ -0,0 +1,112 @@ +//! Original small fixtures pin the straight RGBA8 conversion contract. +use image::{DynamicImage, ImageFormat, RgbaImage}; + +fn encoded(image: &DynamicImage, format: ImageFormat) -> Vec<u8> { + let mut bytes = Vec::new(); + image.write_to(&mut std::io::Cursor::new(&mut bytes), format).unwrap(); + bytes +} + +fn assert_pixels(source: DynamicImage, expected: &[u8]) { + for format in [ImageFormat::Png, ImageFormat::Tiff] { + let bytes = encoded(&source, format); + let original = bytes.clone(); + let result = designcraft_images::decode_rgba(&bytes).unwrap(); + assert_eq!(result.dimensions(), (2, 2)); + assert_eq!(result.as_raw(), expected, "{format:?}"); + assert_eq!(bytes, original); + // The reference is the existing borrowed-conversion API, not the proposed owned one. + assert_eq!(result, image::load_from_memory(&bytes).unwrap().to_rgba8()); + } +} + +#[test] +fn rgba8_keeps_hidden_rgb_and_straight_partial_alpha() { + let pixels = [200, 30, 90, 0, 20, 170, 240, 1, 180, 40, 70, 128, 5, 250, 90, 255]; + let source = DynamicImage::ImageRgba8(RgbaImage::from_raw(2, 2, pixels.to_vec()).unwrap()); + assert_pixels(source, &pixels); +} + +#[test] +fn rgb8_only_adds_opaque_alpha() { + let source = DynamicImage::ImageRgb8(image::RgbImage::from_raw(2, 2, vec![200, 30, 90, 20, 170, 240, 180, 40, 70, 5, 250, 90]).unwrap()); + assert_pixels(source, &[200, 30, 90, 255, 20, 170, 240, 255, 180, 40, 70, 255, 5, 250, 90, 255]); +} + +#[test] +fn grayscale8_matches_fixed_rgb_values() { + let source = DynamicImage::ImageLuma8(image::GrayImage::from_raw(2, 2, vec![0, 1, 128, 255]).unwrap()); + assert_pixels(source, &[0, 0, 0, 255, 1, 1, 1, 255, 128, 128, 128, 255, 255, 255, 255, 255]); +} + +#[test] +fn grayscale16_uses_existing_eight_bit_scaling() { + let source = DynamicImage::ImageLuma16(image::ImageBuffer::from_raw(2, 2, vec![0, 257, 32896, 65535]).unwrap()); + assert_pixels(source, &[0, 0, 0, 255, 1, 1, 1, 255, 128, 128, 128, 255, 255, 255, 255, 255]); +} + +#[test] +fn grayscale16_rounding_boundaries_match_fixed_values() { + let source = DynamicImage::ImageLuma16(image::ImageBuffer::from_raw(2, 2, vec![128, 129, 65406, 65407]).unwrap()); + assert_pixels(source, &[0, 0, 0, 255, 1, 1, 1, 255, 254, 254, 254, 255, 255, 255, 255, 255]); +} + +#[test] +fn rgba16_preserves_straight_alpha_and_existing_scaling() { + let source = DynamicImage::ImageRgba16( + image::ImageBuffer::from_raw(2, 2, vec![65535, 0, 32896, 0, 0, 65535, 257, 257, 32896, 257, 65535, 32896, 257, 32896, 0, 65535]).unwrap(), + ); + assert_pixels(source, &[255, 0, 128, 0, 0, 255, 1, 1, 128, 1, 255, 128, 1, 128, 0, 255]); +} + +#[test] +fn malformed_rasters_remain_unsupported_without_mutating_input() { + let png = encoded(&DynamicImage::ImageRgba8(RgbaImage::from_pixel(2, 2, image::Rgba([13, 27, 42, 128]))), ImageFormat::Png); + let tiff = encoded(&DynamicImage::ImageRgba8(RgbaImage::from_pixel(2, 2, image::Rgba([13, 27, 42, 128]))), ImageFormat::Tiff); + for input in [b"".as_slice(), b"not a raster", &png[..16], &tiff[..6]] { + let original = input.to_vec(); + assert!(designcraft_images::decode_rgba(input).is_none()); + assert_eq!(input, original); + } +} + +#[test] +fn all_gray_bytes_survive_png_and_tiff_decoding() { + let bytes: Vec<u8> = (0u8..=255).collect(); + let source = DynamicImage::ImageLuma8(image::GrayImage::from_raw(256, 1, bytes).unwrap()); + for format in [ImageFormat::Png, ImageFormat::Tiff] { + let encoded = encoded(&source, format); + let old = image::load_from_memory(&encoded).unwrap().to_rgba8(); + let actual = designcraft_images::decode_rgba(&encoded).unwrap(); + assert_eq!(actual.dimensions(), (256, 1)); + assert_eq!(actual.as_raw(), old.as_raw()); + assert_eq!(actual.color_space(), old.color_space()); + for (value, pixel) in (0u8..=255).zip(actual.pixels()) { + assert_eq!(pixel.0, [value, value, value, 255]); + } + } +} + +#[test] +fn grayscale_eps_proxy_keeps_all_values_and_source_bytes() { + let source = DynamicImage::ImageLuma8(image::GrayImage::from_raw(256, 1, (0u8..=255).collect()).unwrap()); + let tiff = encoded(&source, ImageFormat::Tiff); + // Original DOS EPS wrapper with this test's generated grayscale TIFF preview. + let ps = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 256 1\n%%EOF\n"; + let mut eps = vec![0xC5, 0xD0, 0xD3, 0xC6]; + for value in [30, ps.len(), 0, 0, 30 + ps.len(), tiff.len()] { + eps.extend_from_slice(&u32::try_from(value).unwrap().to_le_bytes()); + } + eps.extend_from_slice(&[0xFF, 0xFF]); + eps.extend_from_slice(ps); + eps.extend_from_slice(&tiff); + let original = eps.clone(); + let old = image::load_from_memory(&tiff).unwrap().to_rgba8(); + // Current main exposes EPS proxies separately; automatic EPS raster routing is PR150. + let (proxy, _) = designcraft_images::eps_proxy(&eps).unwrap(); + let actual = designcraft_images::decode_rgba(&proxy).unwrap(); + assert_eq!(actual.dimensions(), (256, 1)); + assert_eq!(actual.as_raw(), old.as_raw()); + assert_eq!(actual.color_space(), old.color_space()); + assert_eq!(eps, original); +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index e0b98726f..dbcbe2962 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -147,6 +147,9 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions) clip: Rect::ZERO, warnings, images: HashMap::new(), + images_by_content: ImageContentCache::default(), + #[cfg(test)] + image_conversions: 0, pdfs: HashMap::new(), version, too_new: Vec::new(), @@ -320,6 +323,9 @@ pub fn export_booklet(doc: &Document, cache: &Cache, opts: &BookletOptions) -> R clip: Rect::ZERO, warnings: Vec::new(), images: HashMap::new(), + images_by_content: ImageContentCache::default(), + #[cfg(test)] + image_conversions: 0, pdfs: HashMap::new(), // krilla's default. version: PdfVersion::Pdf17, @@ -403,6 +409,32 @@ pub(crate) fn civil(t: i64) -> (i64, u8, u8, u8, u8, u8) { (year, month as u8, day as u8, (secs / 3600) as u8, (secs / 60 % 60) as u8, (secs % 60) as u8) } +/// A successful conversion and its name-independent diagnostic. Replayed once for each asset. +#[derive(Clone)] +struct ConvertedImage { + image: Image, + warning: Option<&'static str>, +} + +const CMYK_FALLBACK_WARNING: &str = "converted to RGB (a CMYK TIFF that is planar, has premultiplied alpha, uses other inks or is over 256 MiB)"; + +#[cfg(not(test))] +type ImageContentCache = HashMap<Arc<Vec<u8>>, ConvertedImage>; + +// Unit tests force collisions through the real cache path; production keeps RandomState. +#[cfg(test)] +type ImageContentCache = HashMap<Arc<Vec<u8>>, ConvertedImage, std::hash::BuildHasherDefault<ConstantImageHasher>>; +#[cfg(test)] +#[derive(Default)] +struct ConstantImageHasher; +#[cfg(test)] +impl std::hash::Hasher for ConstantImageHasher { + fn finish(&self) -> u64 { + 0 + } + fn write(&mut self, _: &[u8]) {} +} + pub(crate) struct Exporter<'a> { pub doc: &'a Document, pub cache: &'a Cache, @@ -411,6 +443,11 @@ pub(crate) struct Exporter<'a> { pub clip: Rect, pub warnings: Vec<String>, images: HashMap<AssetId, Option<Image>>, + // Immutable export options (including rgb_only) make bytes the complete conversion key. + // PDF-page/SVG/media routing precedes this path. Failures remain per AssetId. + images_by_content: ImageContentCache, + #[cfg(test)] + image_conversions: usize, /// Placed PDFs (embedded as vector pages). pdfs: HashMap<AssetId, Option<krilla::pdf::PdfDocument>>, /// The PDF version written; placed PDFs must not be newer. @@ -1191,7 +1228,19 @@ impl Exporter<'_> { } let asset = self.doc.assets.get(&id)?.clone(); let data = asset.data.clone(); + if let Some(converted) = self.images_by_content.get(&data).cloned() { + if let Some(warning) = converted.warning { + self.warn(format!("{}: {warning}", asset.name)); + } + self.images.insert(id, Some(converted.image.clone())); + return Some(converted.image); + } let fmt = image::guess_format(&data).ok(); + #[cfg(test)] + { + self.image_conversions += 1; + } + let mut warning = None; let mut mode = RasterMode::for_standard(self.opts.standard); mode.interpolate &= self.interpolate; let img = match fmt { @@ -1201,10 +1250,8 @@ impl Exporter<'_> { Some(image::ImageFormat::Tiff) if !self.rgb_only && designcraft_images::is_cmyk_tiff(&data) => match cmyk_tiff(&data, mode) { Some(img) => Some(img), None => { - self.warn(format!( - "{}: converted to RGB (a CMYK TIFF that is planar, has premultiplied alpha, uses other inks or is over 256 MiB)", - asset.name - )); + warning = Some(CMYK_FALLBACK_WARNING); + self.warn(format!("{}: {CMYK_FALLBACK_WARNING}", asset.name)); lossless(&data, fmt, mode) } }, @@ -1214,6 +1261,11 @@ impl Exporter<'_> { if img.is_none() { self.warn(format!("image `{}` could not be decoded and was skipped", asset.name)); } + if let Some(image) = &img { + // Share the original encoded payload. HashMap checks exact bytes after hashing, + // so collisions cannot merge distinct images or their diagnostics. + self.images_by_content.insert(data, ConvertedImage { image: image.clone(), warning }); + } self.images.insert(id, img.clone()); img } @@ -1479,3 +1531,218 @@ fn recompress(data: &[u8], mode: RasterMode) -> Option<Image> { image::ImageEncoder::write_image(enc, rgb.as_raw(), rgb.width(), rgb.height(), image::ExtendedColorType::Rgb8).ok()?; jpeg(buf.into(), || embedded_icc(data).filter(|p| is_rgb_icc(p)), mode) } + +#[cfg(test)] +mod image_content_cache_tests { + use super::*; + use designcraft_doc::Asset; + + fn png(w: u32, h: u32, color: [u8; 4]) -> Vec<u8> { + let image = image::RgbaImage::from_pixel(w, h, image::Rgba(color)); + let mut bytes = Vec::new(); + image.write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png).unwrap(); + bytes + } + fn asset(doc: &mut Document, bytes: Vec<u8>, name: &str) -> AssetId { + let id = AssetId(doc.alloc()); + doc.assets.insert( + id, + Arc::new(Asset { id, name: name.into(), mime: "image/png".into(), link: None, data: Arc::new(bytes), pixels: None, page: 0 }), + ); + id + } + fn exporter<'a>(doc: &'a Document, cache: &'a Cache, opts: &'a PdfOptions) -> Exporter<'a> { + Exporter { + doc, + cache, + opts, + clip: Rect::ZERO, + warnings: Vec::new(), + images: HashMap::new(), + images_by_content: ImageContentCache::default(), + image_conversions: 0, + pdfs: HashMap::new(), + version: PdfVersion::Pdf17, + too_new: Vec::new(), + svgs: HashMap::new(), + fonts: HashMap::new(), + reverse_cmaps: HashMap::new(), + rgb_only: false, + interpolate: true, + tags: Vec::new(), + story_tags: HashMap::new(), + tag_story: None, + para_tags: HashMap::new(), + } + } + #[test] + fn exact_bytes_reuse_conversion_under_real_hash_collisions() { + use std::hash::{BuildHasher, Hasher}; + let mut d = Document::new(&Default::default()); + let red = png(2, 1, [255, 0, 0, 255]); + let blue = png(1, 2, [0, 0, 255, 128]); + let a1 = asset(&mut d, red.clone(), "a1"); + let b1 = asset(&mut d, blue.clone(), "b1"); + let a2 = asset(&mut d, red, "a2"); + let b2 = asset(&mut d, blue, "b2"); + assert!(!Arc::ptr_eq(&d.assets[&a1].data, &d.assets[&a2].data)); + let cache = Cache::new(); + let opts = PdfOptions::default(); + let mut ex = exporter(&d, &cache, &opts); + let mut h1 = ex.images_by_content.hasher().build_hasher(); + h1.write(&d.assets[&a1].data); + let mut h2 = ex.images_by_content.hasher().build_hasher(); + h2.write(&d.assets[&b1].data); + assert_eq!(h1.finish(), h2.finish()); + assert_ne!(d.assets[&a1].data, d.assets[&b1].data); + for (id, count, size) in [(a1, 1, (2, 1)), (b1, 2, (1, 2)), (a2, 2, (2, 1)), (a1, 2, (2, 1)), (b2, 2, (1, 2))] { + assert_eq!(ex.load_image(id).unwrap().size(), size); + assert_eq!(ex.image_conversions, count, "count actual converter executions, not krilla resource equality"); + } + assert_eq!(ex.images.len(), 4); + assert_eq!(ex.images_by_content.len(), 2); + let first_key = ex.images_by_content.keys().find(|key| key.as_slice() == d.assets[&a1].data.as_slice()).unwrap(); + assert!(Arc::ptr_eq(first_key, &d.assets[&a1].data), "cache keys share the original encoded payload"); + ex.images_by_content.clear(); + assert_eq!(ex.load_image(a1).unwrap().size(), (2, 1)); + assert_eq!(ex.image_conversions, 2, "the AssetId cache remains the first lookup"); + assert!(ex.images_by_content.is_empty()); + } + #[test] + fn successful_content_cache_does_not_suppress_failure_names() { + let mut d = Document::new(&Default::default()); + let a = asset(&mut d, vec![0, 1, 2, 3], "broken-a.png"); + let b = asset(&mut d, vec![0, 1, 2, 3], "broken-b.png"); + let cache = Cache::new(); + let opts = PdfOptions::default(); + let mut ex = exporter(&d, &cache, &opts); + assert!(ex.load_image(a).is_none()); + assert!(ex.load_image(b).is_none()); + assert!(ex.load_image(a).is_none()); + assert_eq!(ex.image_conversions, 2); + assert!(ex.images_by_content.is_empty()); + assert_eq!(ex.warnings.len(), 2); + assert!(ex.warnings[0].contains("broken-a.png")); + assert!(ex.warnings[1].contains("broken-b.png")); + } + #[test] + fn content_cache_is_scoped_to_each_immutable_options_context() { + let mut d = Document::new(&Default::default()); + let data = png(2, 2, [240, 20, 30, 255]); + let a = asset(&mut d, data.clone(), "a"); + let b = asset(&mut d, data, "b"); + let cache = Cache::new(); + for compress_images in [false, true, false] { + let opts = PdfOptions { compress_images, ..Default::default() }; + let mut ex = exporter(&d, &cache, &opts); + assert!(ex.load_image(a).is_some()); + assert!(ex.load_image(b).is_some()); + assert_eq!(ex.image_conversions, 1); + assert_eq!(ex.images_by_content.len(), 1); + } + } + + #[test] + fn successful_cmyk_fallback_replays_each_asset_name_once_and_converts_once() { + let bytes = crate::image_fixtures::cmyk_tiff(None, true); + assert!(designcraft_images::is_cmyk_tiff(&bytes)); + assert!(designcraft_images::decode_cmyk_tiff(&bytes).is_none()); + assert!(designcraft_images::decode_rgba(&bytes).is_some()); + let mut d = Document::new(&Default::default()); + let a = asset(&mut d, bytes.clone(), "ink-a.tif"); + let b = asset(&mut d, bytes, "ink-b.tif"); + let cache = Cache::new(); + for compress_images in [false, true] { + let opts = PdfOptions { compress_images, ..Default::default() }; + let mut ex = exporter(&d, &cache, &opts); + for id in [a, b, a, b] { + assert_eq!(ex.load_image(id).unwrap().size(), (2, 1)); + } + assert_eq!(ex.image_conversions, 1); + assert_eq!(ex.images_by_content.len(), 1); + assert_eq!(ex.warnings, [format!("ink-a.tif: {CMYK_FALLBACK_WARNING}"), format!("ink-b.tif: {CMYK_FALLBACK_WARNING}")]); + } + } + + #[test] + fn cmyk_alpha_and_rgb_only_context_keep_conversion_counts_and_failures() { + let cache = Cache::new(); + for alpha in [None, Some(2), Some(1)] { + let mut d = Document::new(&Default::default()); + let bytes = crate::image_fixtures::cmyk_tiff(alpha, false); + let a = asset(&mut d, bytes.clone(), "a.tif"); + let b = asset(&mut d, bytes, "b.tif"); + for standard in [Standard::None, Standard::PdfA2b, Standard::PdfX4, Standard::PdfA2b, Standard::None] { + let opts = PdfOptions { standard, ..Default::default() }; + let mut ex = exporter(&d, &cache, &opts); + ex.rgb_only = standard == Standard::PdfA2b; + // The pinned image decoder does not support CMYKA as RGB. Associated alpha + // is also rejected by the ink-preserving path: retain both failure names. + let succeeds = alpha.is_none() || (alpha == Some(2) && !ex.rgb_only); + let first = ex.load_image(a); + assert_eq!(first.is_some(), succeeds, "{alpha:?} {standard:?}"); + if alpha.is_none() { + let (space, samples) = image_color_samples(first.unwrap()); + assert_eq!(space, if ex.rgb_only { "DeviceRGB" } else { "DeviceCMYK" }); + assert_eq!(samples, if ex.rgb_only { vec![0, 0, 0, 255, 0, 0] } else { crate::image_fixtures::INKS.to_vec() }); + } + assert_eq!(ex.load_image(b).is_some(), succeeds, "{alpha:?} {standard:?}"); + assert_eq!(ex.load_image(a).is_some(), succeeds); + assert_eq!(ex.image_conversions, if succeeds { 1 } else { 2 }); + assert_eq!(ex.images_by_content.len(), usize::from(succeeds)); + if succeeds { + assert!(ex.warnings.is_empty()); + } else { + for name in ["a.tif", "b.tif"] { + assert!(ex.warnings.contains(&format!("image `{name}` could not be decoded and was skipped"))); + assert_eq!(ex.warnings.contains(&format!("{name}: {CMYK_FALLBACK_WARNING}")), !ex.rgb_only); + } + } + } + } + } + + // Serialize the converted raster without an archival validator so the RGB-only pixel + // contract stays independent of main's existing PDF/A interpolation validation failure. + fn image_color_samples(image: Image) -> (String, Vec<u8>) { + use hayro_syntax::object::Name; + let mut pdf = krilla::Document::new(); + let size = Size::from_wh(20.0, 20.0).unwrap(); + let mut page = pdf.start_page_with(PageSettings::new(size)); + let mut surface = page.surface(); + surface.draw_image(image, size); + surface.finish(); + page.finish(); + let bytes = pdf.finish().unwrap(); + let pdf = hayro_syntax::Pdf::new(bytes).unwrap(); + let stream = pdf + .objects() + .into_iter() + .filter_map(|o| o.into_stream()) + .find(|s| s.dict().get::<Name>("Subtype").is_some_and(|n| n.as_str() == "Image")) + .unwrap(); + (stream.dict().get::<Name>("ColorSpace").unwrap().as_str().to_owned(), stream.decoded().unwrap().into_owned()) + } + + #[test] + fn content_cache_owns_only_existing_payloads_and_drops_with_exporter() { + let mut d = Document::new(&Default::default()); + let bytes = png(2, 1, [20, 30, 40, 255]); + let a = asset(&mut d, bytes.clone(), "a.png"); + let b = asset(&mut d, bytes, "b.png"); + let payload = Arc::downgrade(&d.assets[&a].data); + let duplicate = Arc::downgrade(&d.assets[&b].data); + let cache = Cache::new(); + let opts = PdfOptions::default(); + let mut ex = exporter(&d, &cache, &opts); + assert!(ex.load_image(a).is_some()); + assert!(ex.load_image(b).is_some()); + assert_eq!(duplicate.strong_count(), 1, "a content hit must not retain the second encoded allocation"); + let key = ex.images_by_content.keys().next().unwrap(); + assert!(Arc::ptr_eq(key, &d.assets[&a].data)); + drop(ex); + drop(d); + assert!(payload.upgrade().is_none(), "no converted image or encoded key outlives this exporter"); + assert!(duplicate.upgrade().is_none()); + } +} diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 2881ede67..c4aad577c 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -202,3 +202,14 @@ pub fn parse_page_range(s: &str, count: usize) -> Result<Vec<usize>> { #[cfg(test)] mod tests; + +// Run the same public API/output contracts with the unit-only colliding cache hasher as well +// as the production RandomState used by the integration-test executable. +#[cfg(test)] +extern crate self as designcraft_pdf; +#[cfg(test)] +#[path = "../tests/support/image_content_cache_contract.rs"] +mod image_content_cache_collision_exports; +#[cfg(test)] +#[path = "../tests/support/image_fixtures.rs"] +mod image_fixtures; diff --git a/crates/pdf/src/transitions.rs b/crates/pdf/src/transitions.rs index 8f6a864f2..cbfbe27cc 100644 --- a/crates/pdf/src/transitions.rs +++ b/crates/pdf/src/transitions.rs @@ -3,6 +3,7 @@ //! a new cross-reference section pointing back at the original. use designcraft_doc::{PageTransition, TransitionKind}; +use std::collections::HashMap; /// The `/Trans` dictionary for a transition. fn trans_dict(t: &PageTransition) -> String { @@ -51,6 +52,56 @@ pub(crate) fn object(pdf: &[u8], id: usize) -> Option<std::ops::Range<usize>> { Some(start..end) } +// Bound optional indexing metadata. Larger requests retain the original lookup path. +const MAX_INDEXED_PAGE_OBJECTS: usize = 4096; + +/// Latest literal object headers for the requested IDs, found in a single reverse pass. +/// Match `object` exactly, including embedded headers and the lack of a boundary after `obj`. +/// Only requested IDs are retained; neither PDF size nor the largest ID sizes this map. +/// If the metadata limit or an allocation failure prevents indexing, use `object` instead. +fn object_starts(pdf: &[u8], ids: impl Iterator<Item = usize>) -> Option<HashMap<usize, Option<usize>>> { + let mut starts = HashMap::new(); + for id in ids { + if starts.contains_key(&id) { + continue; + } + if starts.len() == MAX_INDEXED_PAGE_OBJECTS || starts.try_reserve(1).is_err() { + return None; + } + starts.insert(id, None); + } + let mut missing = starts.len(); + if missing == 0 { + return Some(starts); + } + let mut end = pdf.len(); + while let Some(at) = pdf.get(..end).and_then(|bytes| bytes.iter().rposition(|b| *b == b'\n')) { + let Some(start) = at.checked_add(1) else { break }; + let Some(line) = pdf.get(start..end) else { break }; + end = at; + let digits = line.iter().position(|b| !b.is_ascii_digit()).unwrap_or(line.len()); + let Some(number) = line.get(..digits) else { continue }; + if number.is_empty() || (number.len() > 1 && number.first() == Some(&b'0')) { + continue; + } + if !line.get(digits..).is_some_and(|tail| tail.starts_with(b" 0 obj")) { + continue; + } + let Some(id) = number.iter().try_fold(0usize, |n, b| n.checked_mul(10)?.checked_add(usize::from(*b - b'0'))) else { continue }; + let Some(slot) = starts.get_mut(&id) else { continue }; + if slot.is_some() { + continue; + } + let Some(body) = start.checked_add(digits).and_then(|n| n.checked_add(6)) else { continue }; + *slot = Some(body); + missing -= 1; + if missing == 0 { + break; + } + } + Some(starts) +} + /// Add transitions to the pages of `pdf` (index = PDF page). `None` when the file's structure /// isn't the simple kind this understands (classic xref table, a flat page tree). pub fn add_transitions(pdf: &[u8], trans: &[Option<PageTransition>]) -> Option<Vec<u8>> { @@ -84,6 +135,7 @@ pub fn add_page_entries(pdf: &[u8], extra: &[Option<String>]) -> Option<Vec<u8>> let kids_str = &kids_str[kids_str.find('[')? + 1..kids_str.find(']')?]; let nums: Vec<usize> = kids_str.split_whitespace().filter_map(|t| t.parse().ok()).collect(); let kids: Vec<usize> = nums.chunks(2).map(|c| c[0]).collect(); + let starts = object_starts(pdf, extra.iter().zip(&kids).filter_map(|(t, &kid)| t.as_ref().map(|_| kid))); let mut out = pdf.to_vec(); if !out.ends_with(b"\n") { out.push(b'\n'); @@ -91,7 +143,13 @@ pub fn add_page_entries(pdf: &[u8], extra: &[Option<String>]) -> Option<Vec<u8>> let mut entries: Vec<(usize, usize)> = Vec::new(); for (i, t) in extra.iter().enumerate() { let (Some(t), Some(&kid)) = (t, kids.get(i)) else { continue }; - let body = String::from_utf8_lossy(&pdf[object(pdf, kid)?]).to_string(); + let range = if let Some(starts) = &starts { + let start = starts.get(&kid).copied().flatten()?; + start..find(pdf, b"endobj", start)? + } else { + object(pdf, kid)? + }; + let body = String::from_utf8_lossy(&pdf[range]).to_string(); if !body.contains("/Type/Page") && !body.contains("/Type /Page") { return None; } @@ -140,3 +198,19 @@ pub fn add_catalog_entries(pdf: &[u8], entries: &str) -> Option<Vec<u8>> { ); Some(out) } + +#[cfg(test)] +mod object_index_tests { + use super::{MAX_INDEXED_PAGE_OBJECTS, object_starts}; + + #[test] + fn metadata_limit_disables_only_the_optional_index() { + let at_limit = object_starts(b"", 0..MAX_INDEXED_PAGE_OBJECTS).unwrap(); + assert_eq!(at_limit.len(), MAX_INDEXED_PAGE_OBJECTS); + assert!(object_starts(b"", 0..=MAX_INDEXED_PAGE_OBJECTS).is_none()); + // Duplicate requests do not consume the unique-ID budget. + let starts = object_starts(b"\n0 0 objendobj", std::iter::repeat_n(0, MAX_INDEXED_PAGE_OBJECTS + 1)).unwrap(); + assert_eq!(starts.len(), 1); + assert_eq!(starts.get(&0), Some(&Some(8))); + } +} diff --git a/crates/pdf/tests/image_content_cache.rs b/crates/pdf/tests/image_content_cache.rs new file mode 100644 index 000000000..dd3d413d0 --- /dev/null +++ b/crates/pdf/tests/image_content_cache.rs @@ -0,0 +1,5 @@ +//! Run public contracts with the production content-cache hasher. +#[path = "support/image_content_cache_contract.rs"] +mod contracts; +#[path = "support/image_fixtures.rs"] +mod image_fixtures; diff --git a/crates/pdf/tests/page_entry_search_contract.rs b/crates/pdf/tests/page_entry_search_contract.rs new file mode 100644 index 000000000..981215551 --- /dev/null +++ b/crates/pdf/tests/page_entry_search_contract.rs @@ -0,0 +1,147 @@ +//! Independent literal-search contract tests. These pass on the old implementation too: +//! this optimization must preserve the current deliberately narrow PDF handling. +use designcraft_pdf::add_page_entries; + +fn fixture(kids: &str, objects: &[u8]) -> Vec<u8> { + let mut pdf = + format!("%PDF-1.7\n1 0 obj\n<</Type/Catalog/Pages 2 0 R>>\nendobj\n2 0 obj\n<</Type/Pages/Kids[{kids}]/Count 2>>\nendobj\n").into_bytes(); + pdf.extend_from_slice(objects); + pdf.extend_from_slice(b"\ntrailer\n<</Size 10/Root 1 0 R>>\nstartxref\n0\n%%EOF\n"); + pdf +} +fn old_page() -> &'static [u8] { + b"3 0 obj\n<</Type/Page/Marker/old>>\nendobj\n" +} +fn update(pdf: &[u8], extras: &[Option<&str>]) -> Option<Vec<u8>> { + add_page_entries(pdf, &extras.iter().map(|s| s.map(str::to_owned)).collect::<Vec<_>>()) +} +fn appended<'a>(pdf: &[u8], out: &'a [u8]) -> &'a [u8] { + assert!(out.starts_with(pdf)); + &out[pdf.len() + usize::from(!pdf.ends_with(b"\n"))..] +} + +#[test] +fn newest_copy_is_selected_and_duplicates_keep_request_order() { + let mut objects = old_page().to_vec(); + objects.extend_from_slice(b"3 0 obj\n<</Type/Page/Marker/new>>\nendobj\n"); + let pdf = fixture("3 0 R 3 0 R", &objects); + let out = update(&pdf, &[Some("/A 1"), Some("/B 2")]).unwrap(); + assert!( + appended(&pdf, &out).starts_with(b"3 0 obj\n<</Type/Page/Marker/new/A 1>>\nendobj\n3 0 obj\n<</Type/Page/Marker/new/B 2>>\nendobj\nxref\n") + ); +} + +#[test] +fn embedded_header_and_object_suffix_are_literal_matches() { + let mut objects = old_page().to_vec(); + objects.extend_from_slice(b"8 0 obj\nstream\n3 0 object<</Type/Page/Marker/embedded>>endobj\nendstream\nendobj\n"); + let pdf = fixture("3 0 R", &objects); + let out = update(&pdf, &[Some("/A 1")]).unwrap(); + assert!(appended(&pdf, &out).starts_with(b"3 0 object<</Type/Page/Marker/embedded/A 1>>endobj\nxref\n")); +} + +#[test] +fn latest_unterminated_match_does_not_fall_back() { + let mut pdf = fixture("3 0 R", old_page()); + pdf.extend_from_slice(b"3 0 obj<</Type/Page/Marker/unterminated>>"); + assert_eq!(update(&pdf, &[Some("/A 1")]), None); +} + +#[test] +fn latest_nonpage_and_missing_requested_page_are_rejected() { + let mut objects = old_page().to_vec(); + objects.extend_from_slice(b"3 0 obj<</Type/Other>>endobj\n"); + assert_eq!(update(&fixture("3 0 R", &objects), &[Some("/A 1")]), None); + assert_eq!(update(&fixture("7 0 R", old_page()), &[Some("/A 1")]), None); +} + +#[test] +fn only_canonical_decimal_and_exact_generation_spacing_match() { + for header in ["03 0 obj", "+3 0 obj", "3 1 obj", "3 0 obj", "3\t0 obj", "3 0\tobj", "184467440737095516160 0 obj"] { + let mut objects = old_page().to_vec(); + objects.extend_from_slice(format!("{header}<</Type/Page/Marker/wrong>>endobj\n").as_bytes()); + let pdf = fixture("3 0 R", &objects); + let out = update(&pdf, &[Some("/A 1")]).unwrap(); + assert!(appended(&pdf, &out).starts_with(b"3 0 obj\n<</Type/Page/Marker/old/A 1>>\nendobj\nxref\n"), "{header}"); + } +} + +#[test] +fn crlf_matches_but_bare_cr_and_byte_zero_do_not() { + let mut objects = old_page().to_vec(); + objects.extend_from_slice(b"\r\n3 0 obj<</Type/Page/Marker/crlf>>endobj\r3 0 obj<</Type/Page/Marker/barecr>>endobj\n"); + let pdf = fixture("3 0 R", &objects); + let out = update(&pdf, &[Some("/A 1")]).unwrap(); + assert!(appended(&pdf, &out).starts_with(b"3 0 obj<</Type/Page/Marker/crlf/A 1>>endobj\nxref\n")); + let mut pdf = old_page().to_vec(); + pdf.extend_from_slice(&fixture("3 0 R", b"")); + assert_eq!(update(&pdf, &[Some("/A 1")]), None); +} + +#[test] +fn first_subsequent_endobj_and_lossy_body_are_preserved() { + let pdf = fixture("3 0 R", b"3 0 obj<</Type/Page/Marker/\xff>>endobj IGNORE endobj\n"); + let out = update(&pdf, &[Some("/A 1")]).unwrap(); + assert!(appended(&pdf, &out).starts_with("3 0 obj<</Type/Page/Marker/\u{fffd}/A 1>>endobj\nxref\n".as_bytes())); +} + +#[test] +fn absent_extras_do_not_require_objects_and_out_of_range_requests_are_ignored() { + let malformed = b"not a PDF"; + assert_eq!(update(malformed, &[]).unwrap(), malformed); + assert_eq!(update(malformed, &[None, None]).unwrap(), malformed); + let pdf = fixture("7 0 R 3 0 R", old_page()); + let out = update(&pdf, &[None, Some("/A 1"), Some("/Ignored 1")]).unwrap(); + assert!(appended(&pdf, &out).starts_with(b"3 0 obj\n<</Type/Page/Marker/old/A 1>>\nendobj\nxref\n")); + assert!(!out.windows(b"/Ignored".len()).any(|w| w == b"/Ignored")); +} + +#[test] +fn zero_and_maximum_usize_ids_are_supported_without_index_sizing_by_id() { + for id in [0, usize::MAX] { + let pdf = fixture(&format!("{id} 0 R"), format!("{id} 0 obj<</Type/Page/Marker/extreme>>endobj\n").as_bytes()); + let out = update(&pdf, &[Some("/A 1")]).unwrap(); + assert!(appended(&pdf, &out).starts_with(format!("{id} 0 obj<</Type/Page/Marker/extreme/A 1>>endobj\nxref\n").as_bytes())); + } +} + +/// A complete, blank PDF with correct classic xref offsets and 4,097 distinct pages. +/// This deliberately exceeds the optional index's documented 4,096-entry budget. +pub(crate) fn over_index_budget_fixture() -> (Vec<u8>, Vec<Option<String>>) { + let count = 4097; + let size = count + 3; + let kids = (3..size).map(|id| format!("{id} 0 R ")).collect::<String>(); + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = vec![0usize]; + for (id, body) in [(1, "<</Type/Catalog/Pages 2 0 R>>".to_owned()), (2, format!("<</Type/Pages/Kids[{kids}]/Count {count}>>"))] { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{id} 0 obj\n{body}\nendobj\n").as_bytes()); + } + for id in 3..size { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{id} 0 obj\n<</Type/Page/Parent 2 0 R/MediaBox[0 0 10 10]/Marker {id}>>\nendobj\n").as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {size}\n0000000000 65535 f\r\n").as_bytes()); + for offset in offsets.iter().skip(1) { + pdf.extend_from_slice(format!("{offset:010} 00000 n\r\n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<</Size {size}/Root 1 0 R>>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + let extras = (3..size).map(|id| Some(format!("/Tag {id}"))).collect(); + (pdf, extras) +} + +#[test] +fn index_budget_fallback_preserves_every_requested_page_update() { + let (pdf, extras) = over_index_budget_fixture(); + assert_eq!(extras.len(), 4097); + let out = add_page_entries(&pdf, &extras).unwrap(); + let mut remaining = appended(&pdf, &out); + for id in 3..4100 { + let expected = format!("{id} 0 obj\n<</Type/Page/Parent 2 0 R/MediaBox[0 0 10 10]/Marker {id}/Tag {id}>>\nendobj\n"); + assert!(remaining.starts_with(expected.as_bytes()), "missing or changed page {id}"); + remaining = &remaining[expected.len()..]; + } + assert!(remaining.starts_with(b"xref\n")); + assert!(remaining.windows(b"4099 1\n".len()).any(|w| w == b"4099 1\n")); +} diff --git a/crates/pdf/tests/support/image_content_cache_contract.rs b/crates/pdf/tests/support/image_content_cache_contract.rs new file mode 100644 index 000000000..6a4ac91f1 --- /dev/null +++ b/crates/pdf/tests/support/image_content_cache_contract.rs @@ -0,0 +1,325 @@ +//! Public-export controls run against the production RandomState cache, independent of the +//! unit-only forced-collision hasher. Also included by the library unit-test target. +//! All images and placed PDFs are tiny procedural fixtures. +use std::sync::Arc; + +use designcraft_compose::Cache; +use designcraft_doc::{Asset, AssetId, Content, Document, Graphic, Item, ItemId, Shape, SpreadRef}; +use designcraft_geom::{Affine, Rect, shapes}; +use designcraft_pdf::{BookletOptions, PdfOptions, Standard, export_booklet, export_pdf, export_pdf_with_report}; + +use crate::image_fixtures as fixtures; + +fn png(w: u32, h: u32, color: [u8; 4]) -> Vec<u8> { + let img = image::RgbaImage::from_pixel(w, h, image::Rgba(color)); + let mut bytes = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png).unwrap(); + bytes +} + +fn place(d: &mut Document, data: Vec<u8>, x: f64) { + place_named(d, data, x, &format!("original-{x}.png"), "image/png", 0); +} + +fn place_named(d: &mut Document, data: Vec<u8>, x: f64, name: &str, mime: &str, page: u32) { + let aid = AssetId(d.alloc()); + d.assets.insert(aid, Arc::new(Asset { id: aid, name: name.into(), mime: mime.into(), link: None, data: Arc::new(data), pixels: None, page })); + let id = ItemId(d.alloc()); + let mut item = Item::new(id, d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(x, 36.0, x + 20.0, 56.0))); + item.stroke.weight = 0.0; + item.content = Content::Graphic(Graphic { + asset: aid, + size: (20.0, 20.0), + xf: Affine::translate((x, 36.0)), + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + }); + d.insert_item(SpreadRef::Doc(0), item, None).unwrap(); +} + +#[derive(Debug)] +struct DrawnImage { + size: (u32, u32), + rgb: [u8; 3], + alpha: u8, + x: f64, +} + +fn drawn_images(bytes: Vec<u8>) -> Vec<DrawnImage> { + drawn_content(bytes).0 +} + +fn drawn_content(bytes: Vec<u8>) -> (Vec<DrawnImage>, Vec<([u8; 4], kurbo::Rect)>) { + use hayro_interpret::font::Glyph; + use hayro_interpret::{ + BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, ImageData, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask, + interpret_page, + }; + use kurbo::Shape as _; + struct Sink(Vec<DrawnImage>, Vec<([u8; 4], kurbo::Rect)>); + impl Device<'_> for Sink { + fn set_soft_mask(&mut self, _: Option<SoftMask<'_>>) {} + fn set_blend_mode(&mut self, _: BlendMode) {} + fn draw_path(&mut self, path: &kurbo::BezPath, transform: kurbo::Affine, paint: &Paint<'_>, mode: &PathDrawMode) { + if let (Paint::Color(color), PathDrawMode::Fill(_)) = (paint, mode) { + self.1.push((color.to_rgba().to_rgba8(), (transform * path.clone()).bounding_box())); + } + } + fn push_clip_path(&mut self, _: &ClipPath) {} + fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'_>>, _: BlendMode) {} + fn draw_glyph(&mut self, _: &Glyph<'_>, _: kurbo::Affine, _: kurbo::Affine, _: &Paint<'_>, _: &GlyphDrawMode) {} + fn draw_image(&mut self, image: Image<'_, '_>, transform: kurbo::Affine) { + if let Image::Raster(image) = image { + image.with_rgba( + |pixels, alpha| { + let size = (pixels.width(), pixels.height()); + let rgb = match pixels { + ImageData::Rgb(p) => [p.data[0], p.data[1], p.data[2]], + ImageData::Luma(p) => [p.data[0]; 3], + }; + self.0.push(DrawnImage { size, rgb, alpha: alpha.map_or(255, |p| p.data[0]), x: transform.as_coeffs()[4] }); + }, + None, + ); + } + } + fn pop_clip_path(&mut self) {} + fn pop_transparency_group(&mut self) {} + } + let pdf = hayro_syntax::Pdf::new(bytes).unwrap(); + let cache = InterpreterCache::new(); + let mut sink = Sink(Vec::new(), Vec::new()); + for page in pdf.pages().iter() { + let mut context = + Context::new(kurbo::Affine::IDENTITY, kurbo::Rect::new(0.0, 0.0, 1000.0, 1000.0), &cache, pdf.xref(), InterpreterSettings::default()); + interpret_page(page, &mut context, &mut sink); + } + (sink.0, sink.1) +} + +#[test] +fn repeated_svg_assets_keep_vector_paths_and_placement() { + let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><rect x="2" y="3" width="5" height="7" fill="red"/></svg>"#; + let mut d = Document::new(&Default::default()); + for (name, x) in [("vector-a.svg", 36.0), ("vector-b.svg", 66.0)] { + place_named(&mut d, svg.to_vec(), x, name, "image/svg+xml", 0); + } + let report = export_pdf_with_report(&d, &Cache::new(), &PdfOptions::default()).unwrap(); + assert!(report.warnings.is_empty(), "{:?}", report.warnings); + assert!(image_objects(&report.bytes).is_empty(), "placed SVGs must stay vector artwork"); + let (images, paths) = drawn_content(report.bytes); + assert!(images.is_empty()); + let red: Vec<_> = paths.into_iter().filter(|(color, _)| *color == [255, 0, 0, 255]).collect(); + assert_eq!(red.len(), 2, "both SVG assets must paint the original rectangle"); + for ((_, bounds), x) in red.iter().zip([38.0, 68.0]) { + assert!((bounds.x0 - x).abs() < 1e-6, "{bounds:?}"); + assert!((bounds.width() - 5.0).abs() < 1e-6, "{bounds:?}"); + assert!((bounds.height() - 7.0).abs() < 1e-6, "{bounds:?}"); + } +} + +#[test] +fn repeated_encoded_assets_preserve_colors_alpha_dimensions_and_placement() { + let mut d = Document::new(&Default::default()); + let red = png(2, 1, [255, 0, 0, 255]); + let blue = png(1, 2, [0, 0, 255, 128]); + for (data, x) in [(red.clone(), 36.0), (blue.clone(), 66.0), (red, 96.0), (blue, 126.0)] { + place(&mut d, data, x); + } + d.check().unwrap(); + let got = drawn_images(export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap()); + assert_eq!(got.len(), 4); + for (index, item) in got.iter().enumerate() { + assert_eq!(item.size, if index % 2 == 0 { (2, 1) } else { (1, 2) }); + assert_eq!(item.rgb, if index % 2 == 0 { [255, 0, 0] } else { [0, 0, 255] }); + assert_eq!(item.alpha, if index % 2 == 0 { 255 } else { 128 }); + assert!((item.x - (36.0 + index as f64 * 30.0)).abs() < 1e-6, "{got:?}"); + } +} + +#[test] +fn compress_images_context_does_not_leak_between_exports() { + let mut d = Document::new(&Default::default()); + let data = png(4, 4, [220, 30, 40, 255]); + place(&mut d, data.clone(), 36.0); + place(&mut d, data, 66.0); + for compress_images in [false, true, false] { + let bytes = export_pdf(&d, &Cache::new(), &PdfOptions { compress_images, ..Default::default() }).unwrap(); + let has_dct = bytes.windows(b"/DCTDecode".len()).any(|w| w == b"/DCTDecode"); + assert_eq!(has_dct, compress_images, "export options must remain independent"); + } +} + +#[test] +fn repeated_png_with_deferred_decode_error_is_skipped() { + let mut bad = png(2, 2, [255, 0, 0, 255]); + let idat = bad.windows(4).position(|w| w == b"IDAT").unwrap(); + bad[idat + 4] ^= 255; + // Metadata succeeds, while corrupted IDAT decoding would fail later in the backend: the + // image is decoded first, so both copies are skipped with a warning, not a failed export. + assert!(krilla::image::Image::from_png(Arc::new(bad.clone()).into(), true).is_ok()); + let mut d = Document::new(&Default::default()); + place(&mut d, bad.clone(), 36.0); + place(&mut d, bad, 66.0); + let report = export_pdf_with_report(&d, &Cache::new(), &PdfOptions::default()).unwrap(); + assert!(report.warnings.iter().any(|w| w.contains("could not be decoded")), "{:?}", report.warnings); + assert!(image_objects(&report.bytes).is_empty()); +} + +#[derive(Debug, PartialEq)] +struct ImageObject { + space: String, + size: (i32, i32), + samples: Vec<u8>, + alpha: Option<Vec<u8>>, +} + +fn image_objects(bytes: &[u8]) -> Vec<ImageObject> { + use hayro_syntax::object::{Name, Stream}; + let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).unwrap(); + pdf.objects() + .into_iter() + .filter_map(|o| o.into_stream()) + .filter(|s| s.dict().get::<Name>("Subtype").is_some_and(|n| n.as_str() == "Image")) + .map(|s| ImageObject { + space: s.dict().get::<Name>("ColorSpace").map_or("ICC".into(), |n| n.as_str().to_owned()), + size: (s.dict().get::<i32>("Width").unwrap(), s.dict().get::<i32>("Height").unwrap()), + samples: s.decoded().unwrap().into_owned(), + alpha: s.dict().get::<Stream<'_>>("SMask").map(|a| a.decoded().unwrap().into_owned()), + }) + .collect() +} + +#[test] +fn repeated_cmyk_tiffs_keep_exact_inks_and_unassociated_alpha() { + for alpha in [None, Some(2)] { + let bytes = fixtures::cmyk_tiff(alpha, false); + let mut d = Document::new(&Default::default()); + for (name, x) in [("a.tif", 36.0), ("b.tif", 66.0)] { + place_named(&mut d, bytes.clone(), x, name, "image/tiff", 0); + } + for standard in [Standard::None, Standard::PdfX4] { + for compress_images in [false, true] { + let report = export_pdf_with_report(&d, &Cache::new(), &PdfOptions { standard, compress_images, ..Default::default() }).unwrap(); + assert!(report.warnings.is_empty(), "{:?}", report.warnings); + let images = image_objects(&report.bytes); + let cmyk: Vec<_> = images.iter().filter(|i| i.space == "DeviceCMYK").collect(); + assert_eq!(cmyk.len(), 1, "{images:?}"); + assert_eq!(cmyk[0].size, (2, 1)); + assert_eq!(cmyk[0].samples, fixtures::INKS); + assert_eq!(cmyk[0].alpha, alpha.map(|_| fixtures::ALPHA.to_vec())); + let drawn = drawn_images(report.bytes); + assert_eq!(drawn.len(), 2); + assert_eq!(drawn.iter().map(|i| i.x).collect::<Vec<_>>(), [36.0, 66.0]); + } + } + } +} + +#[test] +fn successful_fallback_warns_for_both_names_and_preserves_rgb_samples() { + let bytes = fixtures::cmyk_tiff(None, true); + let mut d = Document::new(&Default::default()); + for (name, x) in [("other-inks-a.tif", 36.0), ("other-inks-b.tif", 66.0)] { + place_named(&mut d, bytes.clone(), x, name, "image/tiff", 0); + } + let reason = "converted to RGB (a CMYK TIFF that is planar, has premultiplied alpha, uses other inks or is over 256 MiB)"; + let expected_warnings = [format!("other-inks-a.tif: {reason}"), format!("other-inks-b.tif: {reason}")]; + for compress_images in [false, true] { + let report = export_pdf_with_report(&d, &Cache::new(), &PdfOptions { compress_images, ..Default::default() }).unwrap(); + assert_eq!(report.warnings, expected_warnings); + let images = image_objects(&report.bytes); + assert_eq!(images.len(), 1); + assert_eq!(images[0].space, "DeviceRGB"); + assert_eq!(images[0].samples, [0, 0, 0, 255, 0, 0]); + assert_eq!(images[0].size, (2, 1)); + } + let booklet = export_booklet(&d, &Cache::new(), &BookletOptions::default()).unwrap(); + assert_eq!(booklet.warnings, expected_warnings); + assert_eq!(image_objects(&booklet.bytes)[0].samples, [0, 0, 0, 255, 0, 0]); +} + +#[test] +fn pdfa_rgb_conversion_does_not_poison_later_cmyk_exports() { + let bytes = fixtures::cmyk_tiff(None, false); + let mut d = Document::new(&Default::default()); + place_named(&mut d, bytes.clone(), 36.0, "a.tif", "image/tiff", 0); + place_named(&mut d, bytes, 66.0, "b.tif", "image/tiff", 0); + for standard in [Standard::PdfA2b, Standard::None, Standard::PdfX4, Standard::PdfA2b, Standard::None] { + let result = export_pdf_with_report(&d, &Cache::new(), &PdfOptions { standard, created: Some(1_700_000_000), ..Default::default() }); + let report = result.unwrap(); + let images = image_objects(&report.bytes); + assert_eq!(images.len(), 1); + if standard == Standard::PdfA2b { + assert_eq!(report.warnings, ["PDF/A: CMYK colours were converted to RGB (no CMYK output intent profile is available yet)"]); + assert_eq!(images[0].space, "ICC"); + assert_eq!(images[0].samples, [0, 0, 0, 255, 0, 0]); + } else { + assert!(report.warnings.is_empty()); + assert_eq!(images[0].space, "DeviceCMYK"); + assert_eq!(images[0].samples, fixtures::INKS); + } + } +} + +#[test] +fn unsupported_associated_alpha_keeps_each_asset_failure_warning() { + let bytes = fixtures::cmyk_tiff(Some(1), false); + let mut d = Document::new(&Default::default()); + for (name, x) in [("alpha-a.tif", 36.0), ("alpha-b.tif", 66.0)] { + place_named(&mut d, bytes.clone(), x, name, "image/tiff", 0); + } + let report = export_pdf_with_report(&d, &Cache::new(), &PdfOptions::default()).unwrap(); + assert!(image_objects(&report.bytes).is_empty()); + assert_eq!(report.warnings.len(), 4); + for name in ["alpha-a.tif", "alpha-b.tif"] { + assert!(report.warnings.iter().any(|w| w.starts_with(&format!("{name}: converted to RGB")))); + assert!(report.warnings.contains(&format!("image `{name}` could not be decoded and was skipped"))); + } +} + +fn two_page_pdf() -> Vec<u8> { + let mut pdf = krilla::Document::new(); + for color in [[255, 0, 0, 255], [0, 0, 255, 255]] { + let mut page = pdf.start_page_with(krilla::page::PageSettings::new(krilla::geom::Size::from_wh(20.0, 20.0).unwrap())); + let mut surface = page.surface(); + let image = krilla::image::Image::from_png(Arc::new(png(1, 1, color)).into(), true).unwrap(); + surface.draw_image(image, krilla::geom::Size::from_wh(20.0, 20.0).unwrap()); + surface.finish(); + page.finish(); + } + pdf.finish().unwrap() +} + +#[test] +fn placed_pdf_selection_and_media_routing_precede_content_cache() { + let mut d = Document::new(&Default::default()); + let pdf = two_page_pdf(); + place_named(&mut d, pdf.clone(), 36.0, "page-two.pdf", "application/pdf", 1); + place_named(&mut d, pdf, 66.0, "page-one.pdf", "application/pdf", 0); + let red = png(2, 1, [255, 0, 0, 255]); + place_named(&mut d, red.clone(), 96.0, "red.png", "image/png", 0); + place_named(&mut d, red, 126.0, "movie.mp4", "video/mp4", 0); + let report = export_pdf_with_report(&d, &Cache::new(), &PdfOptions::default()).unwrap(); + assert!(report.warnings.is_empty(), "{:?}", report.warnings); + let drawn = drawn_images(report.bytes); + assert_eq!(drawn.len(), 3, "media bytes must draw the existing placeholder, not the matching raster: {drawn:?}"); + assert_eq!(drawn.iter().map(|i| (i.rgb, i.size)).collect::<Vec<_>>(), [([0, 0, 255], (1, 1)), ([255, 0, 0], (1, 1)), ([255, 0, 0], (2, 1))]); + assert_eq!(drawn.iter().map(|i| i.x).collect::<Vec<_>>(), [36.0, 66.0, 96.0]); +} + +#[test] +fn repeated_direct_pngs_export_as_pdfa_without_interpolation() { + let data = png(2, 1, [220, 30, 40, 255]); + let mut d = Document::new(&Default::default()); + place(&mut d, data.clone(), 36.0); + place(&mut d, data, 66.0); + // PDF/A turns image interpolation off (#128), and the cached conversion keeps that. + let report = + export_pdf_with_report(&d, &Cache::new(), &PdfOptions { standard: Standard::PdfA2b, created: Some(1_700_000_000), ..Default::default() }) + .unwrap(); + assert_eq!(image_objects(&report.bytes).len(), 1, "both placements share one image"); + let needle = b"/Interpolate true"; + assert!(!report.bytes.windows(needle.len()).any(|w| w == needle)); +} diff --git a/crates/pdf/tests/support/image_fixtures.rs b/crates/pdf/tests/support/image_fixtures.rs new file mode 100644 index 000000000..02dafae20 --- /dev/null +++ b/crates/pdf/tests/support/image_fixtures.rs @@ -0,0 +1,23 @@ +//! Original procedural CMYK TIFFs shared by conversion and public-export contract tests. + +pub const INKS: [u8; 8] = [0, 0, 0, 255, 0, 255, 255, 0]; +pub const ALPHA: [u8; 2] = [255, 128]; + +pub fn cmyk_tiff(alpha: Option<u16>, other_inks: bool) -> Vec<u8> { + use tiff::encoder::colortype::{CMYK8, CMYKA8}; + use tiff::tags::Tag; + let mut bytes = Vec::new(); + let mut encoder = tiff::encoder::TiffEncoder::new(std::io::Cursor::new(&mut bytes)).unwrap(); + if let Some(kind) = alpha { + let mut image = encoder.new_image::<CMYKA8>(2, 1).unwrap(); + image.encoder().write_tag(Tag::ExtraSamples, &[kind][..]).unwrap(); + image.write_data(&[0, 0, 0, 255, ALPHA[0], 0, 255, 255, 0, ALPHA[1]]).unwrap(); + } else { + let mut image = encoder.new_image::<CMYK8>(2, 1).unwrap(); + if other_inks { + image.encoder().write_tag(Tag::Unknown(332), 2u16).unwrap(); + } + image.write_data(&INKS).unwrap(); + } + bytes +} From 0b69d6856ca0bca10cfe712d9f8ef724af74c8f4 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 10 Oct 2026 04:01:49 -0400 Subject: [PATCH 008/238] PDF: a placed image whose file is missing is reported as missing (#299) A linked image that wasn't found on open has no data; the export warning said it could not be decoded. It now says it is missing and names the link. Co-authored-by: Brandon Thomas <echelon@gmail.com> --- crates/pdf/src/export.rs | 8 +++++++- crates/pdf/src/tests.rs | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index dbcbe2962..d92fc242b 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -1259,7 +1259,13 @@ impl Exporter<'_> { _ => lossless(&data, fmt, mode), }; if img.is_none() { - self.warn(format!("image `{}` could not be decoded and was skipped", asset.name)); + if data.is_empty() { + // A linked file that wasn't found when the document was opened: nothing to decode. + let at = asset.link.as_deref().map(|l| format!(" ({l})")).unwrap_or_default(); + self.warn(format!("image `{}` is missing{at} and was skipped", asset.name)); + } else { + self.warn(format!("image `{}` could not be decoded and was skipped", asset.name)); + } } if let Some(image) = &img { // Share the original encoded payload. HashMap checks exact bytes after hashing, diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 521e4444e..9ac53d057 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -240,6 +240,20 @@ fn place(d: &mut Document, data: Vec<u8>, px: (u32, u32)) { d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); } +/// A placed image whose linked file wasn't found has no data: the warning says it is missing, +/// not that it could not be decoded. +#[test] +fn a_missing_linked_image_is_reported_as_missing() { + let mut d = doc_with_text("still here"); + place(&mut d, Vec::new(), (64, 64)); + if let Some(a) = d.assets.values_mut().next() { + std::sync::Arc::make_mut(a).link = Some("/gone/curtains.jpeg".into()); + } + let r = export_pdf_with_report(&d, &Cache::new(), &PdfOptions::default()).unwrap(); + assert!(r.warnings.iter().any(|w| w.contains("is missing (/gone/curtains.jpeg) and was skipped")), "{:?}", r.warnings); + assert!(!r.warnings.iter().any(|w| w.contains("could not be decoded")), "{:?}", r.warnings); +} + /// krilla reads PNG, GIF and WebP lazily, so a damaged one passed the exporter's "does it decode" /// check and failed the whole export when the PDF was written ("PDF writer error: … unexpected /// end of file"). A cut-off download must be skipped with a warning, as a damaged TIFF is. From 70974afc6621004865088383dd35638a60ca3e5e Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 10 Oct 2026 04:08:56 -0400 Subject: [PATCH 009/238] Lists: numbering formats and expressions from IDML; an empty paragraph gets no bullet or number (#298) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Lists: numbering formats from IDML (A, B, C; i, ii, iii; 01, 02 …) A paragraph's numbering format, number expression, bullet character and text after the bullet import from IDML and export back. Numbered labels come from the expression ("^#.^t", "Tabel ^#^t") with its metacharacters expanded, in place of a fixed "n." label. Adds 001 and 0001 number styles, keeps letter and roman labels short for huge numbers, and counts saturate instead of overflowing. * Lists: an empty paragraph gets no bullet or number A list paragraph with no characters before its return draws no label, and numbering carries on past it: in a story's own list, in named lists (also across stories) and in cross-reference paragraph numbers. --------- Co-authored-by: Brandon Thomas <echelon@gmail.com> --- crates/compose/src/lib.rs | 17 +++--- crates/compose/src/tests.rs | 71 ++++++++++++++++++++++++ crates/doc/src/attrs.rs | 106 +++++++++++++++++++++++++++++++++++- crates/doc/src/notes.rs | 6 +- crates/doc/src/xref.rs | 15 +++-- crates/idml/src/export.rs | 12 ++++ crates/idml/src/import.rs | 11 ++++ crates/idml/src/names.rs | 42 +++++++++++++- crates/idml/src/tests.rs | 43 +++++++++++++++ 9 files changed, 305 insertions(+), 18 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index e8af2553b..09e295250 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -504,13 +504,14 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C story .paras .iter() - .map(|p| { + .zip(¶_ranges) + .map(|(p, r)| { let (pp, _) = doc.styles.resolve_para(p); - if pp.list_type != designcraft_doc::ListType::Numbers || pp.list_name.is_empty() { + if pp.list_type != designcraft_doc::ListType::Numbers || pp.list_name.is_empty() || r.is_empty() { return None; } let c = counters.entry(pp.list_name.clone()).or_insert_with(|| doc.list_start(story.id, &pp.list_name)); - *c = pp.start_at.map_or(*c + 1, |s| s.max(1)); + *c = pp.start_at.map_or(c.saturating_add(1), |s| s.max(1)); Some(*c) }) .collect() @@ -718,19 +719,21 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C // List labels take the default super/subscript settings. let label_env = shape::TypeEnv { adv: Default::default(), ..env }; match pp.list_type { + // An empty paragraph has no bullet or number, and the numbering carries on past it. + designcraft_doc::ListType::Numbers | designcraft_doc::ListType::Bullets if prange.is_empty() => {} designcraft_doc::ListType::Numbers if !pp.list_name.is_empty() => { // A named list: carries on past other paragraphs (and from earlier stories). let n = named_numbers.get(pi).copied().flatten().unwrap_or(1); - let label = format!("{}.{}", pp.number_style.format(n), pp.list_separator); + let label = pp.number_label(n); prepend_label(db, &mut sp.glyphs, &label, prange.start, &base_chars, label_env, &mut table); } designcraft_doc::ListType::Numbers => { - list_counter = pp.start_at.map_or(list_counter + 1, |s| s.max(1)); - let label = format!("{}.{}", pp.number_style.format(list_counter), pp.list_separator); + list_counter = pp.start_at.map_or(list_counter.saturating_add(1), |s| s.max(1)); + let label = pp.number_label(list_counter); prepend_label(db, &mut sp.glyphs, &label, prange.start, &base_chars, label_env, &mut table); } designcraft_doc::ListType::Bullets => { - let label = format!("{}{}", pp.bullet_char, pp.list_separator); + let label = pp.bullet_label(); prepend_label(db, &mut sp.glyphs, &label, prange.start, &base_chars, label_env, &mut table); } designcraft_doc::ListType::None => list_counter = 0, diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index dea9008d5..7d75d7337 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -2548,3 +2548,74 @@ fn spanning_paragraph_in_rtl_and_vertical_frames() { assert_no_overlap(&cs.frames[0]); assert!(cs.frames[0].lines.iter().any(|l| l.para == h)); } + +/// Each paragraph's list label as text: its label glyphs (laid before the paragraph's own text) +/// matched back to characters of the label font; tabs and other invisible glyphs are skipped. +fn list_labels(cs: &ComposedStory, alphabet: &str) -> Vec<String> { + all_lines(cs) + .iter() + .filter(|l| l.first_in_para) + .map(|l| { + l.glyphs + .iter() + .take_while(|g| g.len == 0) + .filter(|g| g.visible) + .map(|g| alphabet.chars().find(|c| g.face.glyph_for(*c) == g.gid).unwrap_or('?')) + .collect() + }) + .collect() +} + +const LABEL_CHARS: &str = "0123456789ABCDIVXabcdivx.\u{2022} Tabel"; + +fn numbered(style: designcraft_doc::NumberStyle, expression: &str) -> ParaAttrs { + ParaAttrs { + list_type: Some(designcraft_doc::ListType::Numbers), + number_style: Some(style), + number_expression: Some(expression.into()), + ..Default::default() + } +} + +#[test] +fn numbered_lists_follow_their_format_and_expression() { + use designcraft_doc::NumberStyle as N; + let cases: [(N, &str, [&str; 3]); 4] = [ + (N::UpperLetters, "^#.^t", ["A.", "B.", "C."]), + (N::LowerRoman, "^#.^t", ["i.", "ii.", "iii."]), + (N::ArabicThreeDigits, "^#.^t", ["001.", "002.", "003."]), + (N::Arabic, "Tabel ^#^t", ["Tabel 1", "Tabel 2", "Tabel 3"]), + ]; + for (style, expression, want) in cases { + let (d, sid, _) = doc_with("One\nTwo\nThree", Rect::new(0.0, 0.0, 300.0, 300.0), numbered(style, expression)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert_eq!(list_labels(&cs, LABEL_CHARS), want, "{style:?} {expression}"); + } +} + +#[test] +fn numbering_from_the_largest_start_number_does_not_overflow() { + let (mut d, sid, _) = doc_with("One\nTwo", Rect::new(0.0, 0.0, 300.0, 300.0), numbered(designcraft_doc::NumberStyle::Arabic, "^#.")); + d.story_mut(sid).unwrap().paras[0].para.start_at = Some(Some(u32::MAX)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let max = format!("{}.", u32::MAX); + assert_eq!(list_labels(&cs, LABEL_CHARS), [max.clone(), max]); +} + +#[test] +fn empty_paragraphs_get_no_bullet_or_number() { + let bullets = ParaAttrs { list_type: Some(designcraft_doc::ListType::Bullets), ..Default::default() }; + let (d, sid, _) = doc_with("One\n\nTwo\n", Rect::new(0.0, 0.0, 300.0, 300.0), bullets); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert_eq!(list_labels(&cs, LABEL_CHARS), ["\u{2022}", "", "\u{2022}", ""]); + // An empty paragraph doesn't use up a number, in a story's own list or a named one. + for name in ["", "Steps"] { + let attrs = ParaAttrs { list_name: Some(name.into()), ..numbered(designcraft_doc::NumberStyle::Arabic, "^#.^t") }; + let (mut d, sid, _) = doc_with("One\n\nTwo\n", Rect::new(0.0, 0.0, 300.0, 300.0), attrs); + if !name.is_empty() { + d.settings.lists.push(designcraft_doc::NumberedList { name: name.into(), continue_across_stories: false }); + } + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert_eq!(list_labels(&cs, LABEL_CHARS), ["1.", "", "2.", ""], "list `{name}`"); + } +} diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index 3a9a7e9e6..a1f723d1f 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -571,15 +571,33 @@ pub enum NumberStyle { LowerLetters, /// 01, 02, … ArabicLeadingZero, + /// 001, 002, … + ArabicThreeDigits, + /// 0001, 0002, … + ArabicFourDigits, /// *, †, ‡, §, ¶, ‖, then doubled (**, ††, …): footnotes. Symbols, } +/// Numbers past this are written in Arabic digits by the styles that repeat characters (roman +/// numerals, letters, symbols), so a hostile start number can't build a huge label. +const MAX_REPEATED_FORM: u32 = 3999; + impl NumberStyle { pub fn format(self, n: u32) -> String { + if n > MAX_REPEATED_FORM + && matches!( + self, + NumberStyle::UpperRoman | NumberStyle::LowerRoman | NumberStyle::UpperLetters | NumberStyle::LowerLetters | NumberStyle::Symbols + ) + { + return n.to_string(); + } match self { NumberStyle::Arabic => n.to_string(), NumberStyle::ArabicLeadingZero => format!("{n:02}"), + NumberStyle::ArabicThreeDigits => format!("{n:03}"), + NumberStyle::ArabicFourDigits => format!("{n:04}"), NumberStyle::UpperRoman => roman(n), NumberStyle::LowerRoman => roman(n).to_lowercase(), NumberStyle::UpperLetters => letters(n), @@ -593,6 +611,58 @@ impl NumberStyle { } } +/// Expand the metacharacters of a list's label text (`^t` tab, `^>` en space, `^^` caret, …), +/// putting `number` in for `^#`. Level placeholders (`^1`…`^9`) and metacharacters with no +/// meaning in a label are dropped; a label never holds a paragraph break. +pub fn expand_list_text(s: &str, number: &str) -> String { + let mut out = String::with_capacity(s.len() + number.len()); + let mut it = s.chars(); + while let Some(c) = it.next() { + if c != '^' { + if c != '\n' && c != '\r' { + out.push(c); + } + continue; + } + let Some(m) = it.next() else { + out.push('^'); + break; + }; + let r = match m { + '#' => { + out.push_str(number); + continue; + } + '^' => '^', + 't' => '\t', + 'y' => crate::story::RIGHT_INDENT_TAB, + 'i' => crate::story::INDENT_HERE, + 'n' => crate::story::FORCED_LINE_BREAK, + 'm' => '\u{2003}', + '>' => '\u{2002}', + 'f' => '\u{2001}', + '|' => '\u{200A}', + '<' => '\u{2009}', + '/' => '\u{2007}', + '.' => '\u{2008}', + 's' => '\u{A0}', + 'S' => '\u{202F}', + '_' => '\u{2014}', + '=' => '\u{2013}', + '~' => '\u{2011}', + '{' => '\u{201C}', + '}' => '\u{201D}', + '[' => '\u{2018}', + ']' => '\u{2019}', + '"' => '"', + '\'' => '\'', + _ => continue, + }; + out.push(r); + } + out +} + fn roman(mut n: u32) -> String { if n == 0 { return "0".into(); @@ -800,7 +870,10 @@ attr_set! { start_at: Option<u32> = None, bullet_char: String = "\u{2022}".into(), number_style: NumberStyle = NumberStyle::Arabic, - /// Text after the number/bullet (`^t` = tab). + /// A numbered paragraph's label: `^#` is the number, other metacharacters expand as in + /// [`expand_list_text`]. + number_expression: String = "^#.^t".into(), + /// Text after the bullet (metacharacters expand as in [`expand_list_text`]). list_separator: String = "\t".into(), balance_ragged: bool = false, optical_margin: bool = false, @@ -852,6 +925,20 @@ impl ParaAttrs { } } +impl ParaProps { + /// The label of a numbered paragraph with number `n`. + pub fn number_label(&self, n: u32) -> String { + expand_list_text(&self.number_expression, &self.number_style.format(n)) + } + + /// The label of a bulleted paragraph. + pub fn bullet_label(&self) -> String { + let mut s: String = self.bullet_char.chars().filter(|c| *c != '\n' && *c != '\r').collect(); + s.push_str(&expand_list_text(&self.list_separator, "")); + s + } +} + #[cfg(test)] mod tests { use super::*; @@ -1035,5 +1122,22 @@ mod tests { assert_eq!(NumberStyle::UpperLetters.format(27), "AA"); assert_eq!(NumberStyle::LowerLetters.format(28), "bb"); assert_eq!(NumberStyle::ArabicLeadingZero.format(3), "03"); + assert_eq!(NumberStyle::ArabicThreeDigits.format(7), "007"); + assert_eq!(NumberStyle::ArabicFourDigits.format(12), "0012"); + // Repeating forms stay small for huge numbers. + assert_eq!(NumberStyle::UpperRoman.format(u32::MAX), u32::MAX.to_string()); + assert_eq!(NumberStyle::LowerLetters.format(u32::MAX), u32::MAX.to_string()); + assert_eq!(NumberStyle::Symbols.format(u32::MAX), u32::MAX.to_string()); + } + + #[test] + fn list_labels_expand_metacharacters() { + let p = ParaProps { number_style: NumberStyle::UpperLetters, ..Default::default() }; + assert_eq!(p.number_label(2), "B.\t"); + let p = ParaProps { number_expression: "Tabel ^#^t".into(), ..Default::default() }; + assert_eq!(p.number_label(3), "Tabel 3\t"); + assert_eq!(expand_list_text("^2.^#.^>x^^^", "4"), ".4.\u{2002}x^^"); + assert_eq!(expand_list_text("a\nb^p", "1"), "ab"); + assert_eq!(ParaProps::default().bullet_label(), "\u{2022}\t"); } } diff --git a/crates/doc/src/notes.rs b/crates/doc/src/notes.rs index 0b0162d2c..b681542cb 100644 --- a/crates/doc/src/notes.rs +++ b/crates/doc/src/notes.rs @@ -235,10 +235,10 @@ impl crate::Document { before.sort_by_key(|st| key(st)); let mut n = 0u32; for st in before { - for p in &st.paras { + for (p, r) in st.paras.iter().zip(st.para_ranges()) { let (pp, _) = self.styles.resolve_para(p); - if pp.list_type == crate::ListType::Numbers && pp.list_name == name { - n = pp.start_at.map_or(n + 1, |s| s.max(1)); + if pp.list_type == crate::ListType::Numbers && pp.list_name == name && !r.is_empty() { + n = pp.start_at.map_or(n.saturating_add(1), |s| s.max(1)); } } } diff --git a/crates/doc/src/xref.rs b/crates/doc/src/xref.rs index a76a6473a..a2301b1c8 100644 --- a/crates/doc/src/xref.rs +++ b/crates/doc/src/xref.rs @@ -347,13 +347,18 @@ impl Document { let (sid, pos) = self.find_anchor(id)?; let st = self.story(sid)?; let pi = st.para_at(pos); - let r = st.para_ranges()[pi].clone(); + let ranges = st.para_ranges(); + let r = ranges[pi].clone(); let (pp, _) = self.styles.resolve_para(&st.paras[pi]); let para_num = match pp.list_type { - crate::ListType::Numbers => { - // Count the run of numbered paragraphs before this one. - let n = (0..=pi).rev().take_while(|&k| self.styles.resolve_para(&st.paras[k]).0.list_type == crate::ListType::Numbers).count(); - format!("{}.", pp.number_style.format(n as u32)) + crate::ListType::Numbers if !r.is_empty() => { + // Count the run of numbered paragraphs before this one (empty ones have no number). + let n = (0..=pi) + .rev() + .take_while(|&k| self.styles.resolve_para(&st.paras[k]).0.list_type == crate::ListType::Numbers) + .filter(|&k| ranges.get(k).is_some_and(|r| !r.is_empty())) + .count(); + pp.number_label(u32::try_from(n).unwrap_or(u32::MAX)).trim_end().to_string() } _ => String::new(), }; diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 2758a6e62..25567e443 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1468,6 +1468,18 @@ impl<'a> Ex<'a> { }, ); } + if let Some(n) = a.number_style { + props.push(p("NumberingFormat", "string", names::numbering_format_out(n))); + } + if let Some(x) = &a.number_expression { + el.set("NumberingExpression", names::list_text_out(x)); + } + if let Some(x) = &a.list_separator { + el.set("BulletsTextAfter", names::list_text_out(x)); + } + if let Some(c) = a.bullet_char.as_deref().and_then(|s| s.chars().next()) { + props.push(El::new("BulletChar").attr("BulletCharacterType", "UnicodeOnly").attr("BulletCharacterValue", u32::from(c).to_string())); + } if let Some(b) = a.balance_ragged { props.push(p("BalanceRaggedLines", "enumeration", if b { "FullyBalanced" } else { "NoBalancing" })); } diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 8f96be67c..fb2378446 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1347,6 +1347,17 @@ impl<'r> Importer<'r> { "NumberedList" => ListType::Numbers, _ => ListType::None, }); + // A format with no counterpart (CJK, Arabic, Hebrew numbering) is left unset: decimal. + a.number_style = e.prop("NumberingFormat").and_then(|v| names::numbering_format_in(&v)); + a.number_expression = e.prop("NumberingExpression"); + a.list_separator = e.prop("BulletsTextAfter"); + if let Some(b) = e.prop_el("BulletChar") { + // A glyph of a named font (`GlyphWithFont`) has no character to map to. + let unicode = matches!(b.get("BulletCharacterType"), Some("UnicodeOnly" | "UnicodeWithFont")); + if let Some(c) = b.get("BulletCharacterValue").and_then(|v| v.trim().parse::<u32>().ok()).and_then(char::from_u32).filter(|_| unicode) { + a.bullet_char = Some(c.to_string()); + } + } a.balance_ragged = e.prop("BalanceRaggedLines").map(|v| v.trim() != "NoBalancing" && v.trim() != "false"); a.shading_on = e.boolean("ParagraphShadingOn"); if let Some(c) = e.prop("ParagraphShadingColor") { diff --git a/crates/idml/src/names.rs b/crates/idml/src/names.rs index 682dabee4..1df0f56d5 100644 --- a/crates/idml/src/names.rs +++ b/crates/idml/src/names.rs @@ -462,7 +462,7 @@ pub const REF_POINTS: [&str; 9] = [ pub fn number_style_out(n: NumberStyle) -> &'static str { match n { - NumberStyle::Arabic | NumberStyle::ArabicLeadingZero => "Arabic", + NumberStyle::Arabic | NumberStyle::ArabicLeadingZero | NumberStyle::ArabicThreeDigits | NumberStyle::ArabicFourDigits => "Arabic", NumberStyle::UpperRoman => "UpperRoman", NumberStyle::LowerRoman => "LowerRoman", NumberStyle::UpperLetters => "UpperLetters", @@ -541,17 +541,55 @@ pub fn pt(x: f64, y: f64) -> String { format!("{} {}", num(x), num(y)) } +// ---------- list numbering ---------- + +/// A paragraph's `NumberingFormat` ("A, B, C, D...", "001, 002, 003...", …), told apart by its +/// first item. None for formats with no counterpart (CJK, Arabic, Hebrew, …). +pub fn numbering_format_in(s: &str) -> Option<NumberStyle> { + Some(match s.split(',').next().unwrap_or("").trim() { + "1" => NumberStyle::Arabic, + "01" => NumberStyle::ArabicLeadingZero, + "001" => NumberStyle::ArabicThreeDigits, + "0001" => NumberStyle::ArabicFourDigits, + "I" => NumberStyle::UpperRoman, + "i" => NumberStyle::LowerRoman, + "A" => NumberStyle::UpperLetters, + "a" => NumberStyle::LowerLetters, + _ => return None, + }) +} +pub fn numbering_format_out(n: NumberStyle) -> &'static str { + match n { + NumberStyle::Arabic | NumberStyle::Symbols => "1, 2, 3, 4...", + NumberStyle::ArabicLeadingZero => "01, 02, 03...", + NumberStyle::ArabicThreeDigits => "001, 002, 003...", + NumberStyle::ArabicFourDigits => "0001, 0002, 0003...", + NumberStyle::UpperRoman => "I, II, III, IV...", + NumberStyle::LowerRoman => "i, ii, iii, iv...", + NumberStyle::UpperLetters => "A, B, C, D...", + NumberStyle::LowerLetters => "a, b, c, d...", + } +} +/// Label text in IDML metacharacters (a tab is `^t`). +pub fn list_text_out(s: &str) -> String { + s.replace('\t', "^t") +} + // ---------- footnote options ---------- pub fn note_style_out(n: NumberStyle) -> &'static str { match n { NumberStyle::ArabicLeadingZero => "SingleLeadingZeros", + NumberStyle::ArabicThreeDigits => "DoubleLeadingZeros", + NumberStyle::ArabicFourDigits => "TripleLeadingZeros", n => number_style_out(n), } } pub fn note_style_in(s: &str) -> NumberStyle { match s { - "SingleLeadingZeros" | "DoubleLeadingZeros" => NumberStyle::ArabicLeadingZero, + "SingleLeadingZeros" => NumberStyle::ArabicLeadingZero, + "DoubleLeadingZeros" => NumberStyle::ArabicThreeDigits, + "TripleLeadingZeros" => NumberStyle::ArabicFourDigits, "Asterisks" => NumberStyle::Symbols, s => number_style_in(s), } diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index d7308cd28..163fd0b26 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -1339,3 +1339,46 @@ fn layers_import_and_export_in_stacking_order() { assert_eq!(names(&back), ["Content", "Background"]); assert_eq!(back.spreads[0].items[0].layer, back.layers[1].id); } + +#[test] +fn list_numbering_format_expression_and_bullet_import_and_round_trip() { + let styles = STYLES.replace( + " </ParagraphStyleGroup>", + r#" <ParagraphStyle Self="ParagraphStyle/Text%3aHead" Name="Text:Head" BulletsAndNumberingListType="NumberedList" NumberingExpression="^#.^t"> + <Properties><NumberingFormat type="string">A, B, C, D...</NumberingFormat></Properties> + </ParagraphStyle> + <ParagraphStyle Self="ParagraphStyle/Text%3aTable" Name="Text:Table" BulletsAndNumberingListType="NumberedList" NumberingExpression="Tabel ^#^t"> + <Properties><NumberingFormat type="string">001, 002, 003...</NumberingFormat></Properties> + </ParagraphStyle> + <ParagraphStyle Self="ParagraphStyle/Text%3aKanji" Name="Text:Kanji" BulletsAndNumberingListType="NumberedList"> + <Properties><NumberingFormat type="string">一, 二, 三, 四...</NumberingFormat></Properties> + </ParagraphStyle> + <ParagraphStyle Self="ParagraphStyle/Text%3aPoint" Name="Text:Point" BulletsAndNumberingListType="BulletList" BulletsTextAfter="^>"> + <Properties><BulletChar BulletCharacterType="UnicodeOnly" BulletCharacterValue="9632"/></Properties> + </ParagraphStyle> + </ParagraphStyleGroup>"#, + ); + let bytes = zip_files(&[ + ("designmap.xml", DESIGNMAP), + ("Resources/Graphic.xml", GRAPHIC), + ("Resources/Styles.xml", &styles), + ("Resources/Preferences.xml", PREFS), + ("MasterSpreads/MasterSpread_m1.xml", MASTER), + ("Spreads/Spread_sp1.xml", SPREAD), + ("Stories/Story_s1.xml", STORY), + ]); + let check = |d: &Document| { + let (head, _) = d.styles.resolve_para_style("Text/Head"); + assert_eq!(head.number_style, designcraft_doc::NumberStyle::UpperLetters); + assert_eq!(head.number_label(2), "B.\t"); + let (table, _) = d.styles.resolve_para_style("Text/Table"); + assert_eq!(table.number_label(7), "Tabel 007\t"); + let (kanji, _) = d.styles.resolve_para_style("Text/Kanji"); + assert_eq!(kanji.number_style, designcraft_doc::NumberStyle::Arabic); + let (point, _) = d.styles.resolve_para_style("Text/Point"); + assert_eq!(point.bullet_label(), "\u{25A0}\u{2002}"); + }; + let d = import_idml(&bytes).unwrap(); + check(&d); + check(&import_idml(&export_idml(&d)).unwrap()); +} From 736acffe5fa87d8666ad68bb48ef28b9000da312 Mon Sep 17 00:00:00 2001 From: Jeremy Yao <jeremy.yao.info@gmail.com> Date: Sat, 10 Oct 2026 04:11:47 -0400 Subject: [PATCH 010/238] Desktop: pick the graphics backend before the window exists; DirectX 12 on Windows (#297) * Desktop: pick the graphics backend before the window exists; DirectX 12 on Windows On an AMD hybrid-graphics laptop (Radeon RX 6800M beside an integrated Radeon, with two versions of amdvlk64.dll loaded into one process) the app died a few seconds after launch with an access violation inside AMD's Vulkan driver at the first swapchain present. Nothing reached the log: a fault inside a driver is a native crash the engine's panic guard never sees. wgpu's default backend set (PRIMARY | GL) enumerates Vulkan first, so every start on that machine went down the same path, while WGPU_BACKEND=dx12 and WGPU_BACKEND=gl ran fine and the headless CPU renderer was never affected. The desktop app now chooses the backend before creating the window (apps/designcraft/src/gpu.rs): DirectX 12 first on Windows, then Vulkan, then OpenGL; Vulkan then OpenGL on Linux; Metal on macOS. OpenGL stays in the set as the in-process fallback when the chosen backend has no adapter. WGPU_BACKEND still overrides everything and turns the fallback off. Start-up fallback: gpu.json in the settings directory records the backend being tried until the third frame begins (the first has been presented by then) or the app exits normally. A record left behind means that backend never showed a frame, so the next start skips it, tries the next candidate and says so in the status bar. The list starts over when every candidate has failed or the app version changes; DESIGNCRAFT_NO_PREFS records nothing. The adapter, backend and driver version are logged at start-up for bug reports. Verified on the affected machine: a default start runs on DirectX 12; a simulated crashed DX12 start moved to Vulkan, which faulted for real, and the start after that came up on OpenGL with the status line. Nine unit tests cover the chooser and the record; cargo xtask ci passes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Merge origin/main; on Windows OpenGL isn't added to the DX12 or Vulkan instance (#167) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com> --- Cargo.lock | 1 + README.md | 15 ++ ROADMAP.md | 1 + apps/designcraft/Cargo.toml | 1 + apps/designcraft/src/gpu.rs | 396 +++++++++++++++++++++++++++++++++++ apps/designcraft/src/main.rs | 83 ++++++-- 6 files changed, 482 insertions(+), 15 deletions(-) create mode 100644 apps/designcraft/src/gpu.rs diff --git a/Cargo.lock b/Cargo.lock index 2f57f017f..d18de53d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -870,6 +870,7 @@ dependencies = [ "log", "muda", "rfd", + "serde", "serde_json", "winit", "winresource", diff --git a/README.md b/README.md index 6884a15e5..5c7d4c1ad 100644 --- a/README.md +++ b/README.md @@ -137,9 +137,24 @@ The file stops growing at 16 MiB. `--version` writes no file. | Variable | Effect | |---|---| | `RUST_LOG` | Log levels for standard error and the log file. Default: `info` for DesignCraft's own crates, `warn` for everything else. env_logger-style directives replace that, e.g. `RUST_LOG=debug`, `RUST_LOG=warn,designcraft_render=trace` or `RUST_LOG=info,wgpu_core=warn`; a directive ending in `*` covers every target starting with it (`designcraft*=debug`). | +| `WGPU_BACKEND` | The graphics backend that composites the window: `dx12`, `vulkan`, `gl` or `metal` (a comma-separated list lets wgpu choose among them). Default: DirectX 12 on Windows, Vulkan on Linux, Metal on macOS, with OpenGL as the fallback. Setting it also turns off the start-up fallback described below. | The logger is `apps/designcraft/src/logging.rs`; the web build logs to the browser console instead. +### Graphics backend + +The canvas is rendered on the CPU; the GPU only composites the interface, through wgpu. A +graphics driver that faults takes the process down before any Rust code can catch it, so the +backend is chosen before the window exists, and a start that never showed a frame is remembered: +`gpu.json` in the settings directory records the backend being tried until the first frame has +been presented. A record left behind means that backend crashed (or the app was killed) at +start-up, so the next start tries the next one (DirectX 12, Vulkan, then OpenGL on Windows; +Vulkan, then OpenGL on Linux) and says so in the status bar. The list starts over once every +backend has failed, or with a new DesignCraft version; delete the file or set `WGPU_BACKEND` to +start afresh. Seen in the wild: on an AMD hybrid-graphics laptop (a Radeon RX 6800M beside an +integrated Radeon) the AMD Vulkan driver faulted at the first present, which is why DirectX 12 is +the Windows default. The code is `apps/designcraft/src/gpu.rs`. + ### Web ```sh diff --git a/ROADMAP.md b/ROADMAP.md index a73d64ea8..eb05508d1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -37,6 +37,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - 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). - Crash recovery: unsaved documents are written to the recovery folder every 30 s and reopened (unsaved, remembering their files) after a crash; File › Revert and Save a Copy…; `file.recovery.*`, `file.revert`, `file.saveACopy`. +- Graphics backend chosen before the window exists (DirectX 12 on Windows, Vulkan on Linux, Metal on macOS, OpenGL as the fallback; `WGPU_BACKEND` overrides) with a start-up fallback: a backend whose start never showed a frame is skipped at the next start and the status bar says so (`gpu.json`). Found on an AMD hybrid-graphics laptop whose Vulkan driver faulted at the first present. - Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool). - Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`. - Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow adds pages and threaded frames until the text fits; Remove Styles option (`designcraft-textimport`). diff --git a/apps/designcraft/Cargo.toml b/apps/designcraft/Cargo.toml index eaacdd328..416b07406 100644 --- a/apps/designcraft/Cargo.toml +++ b/apps/designcraft/Cargo.toml @@ -14,6 +14,7 @@ designcraft-engine = { workspace = true } designcraft-ui-egui = { workspace = true } eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links"] } egui = { workspace = true } +serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } diff --git a/apps/designcraft/src/gpu.rs b/apps/designcraft/src/gpu.rs new file mode 100644 index 000000000..e80f566b9 --- /dev/null +++ b/apps/designcraft/src/gpu.rs @@ -0,0 +1,396 @@ +//! Which wgpu backend draws the window, and the fallback when one of them crashes at start-up. +//! +//! The canvas is rendered on the CPU (`designcraft-render`); the GPU only composites the +//! interface, so every backend shows the same pixels and the one that never crashes is the right +//! one. wgpu's default backend set (`PRIMARY | GL`) enumerates Vulkan first, and on an AMD +//! hybrid-graphics laptop (a Radeon RX 6800M beside an integrated Radeon, two versions of +//! `amdvlk64.dll` loaded into the process) AMD's Vulkan driver faulted with an access violation +//! at the first swapchain present. A fault inside a driver can't be caught (the crash guard never +//! sees it), so the backend is chosen before the window exists: on Windows DirectX 12 first (the +//! backend browsers' WebGPU uses), then Vulkan, then OpenGL; on Linux Vulkan, then OpenGL; on +//! macOS Metal. `WGPU_BACKEND` — wgpu's own variable: `dx12`, `vulkan`, `gl`, `metal`, or a +//! comma-separated list — overrides the choice and turns the fallback off. +//! +//! The fallback: before the window is created, `gpu.json` in the settings directory records the +//! backend being tried; once a frame has been presented, that entry is cleared. An entry still +//! there at the next start means the previous start never got that far — the driver crashed, or +//! the app was killed meanwhile — so that backend joins `failed`, the next candidate is tried and +//! the status bar says so. When every candidate has failed the list starts over, and so does a +//! new DesignCraft version (drivers and wgpu move on). `DESIGNCRAFT_NO_PREFS` records nothing. + +use std::path::{Path, PathBuf}; + +use eframe::wgpu; +use serde::{Deserialize, Serialize}; + +/// The record's file name inside the settings directory. +pub const FILE: &str = "gpu.json"; + +/// A graphics backend wgpu can draw the window with. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Backend { + Dx12, + Vulkan, + Metal, + Gl, +} + +impl Backend { + /// The name the status bar and the log use. + pub fn name(self) -> &'static str { + match self { + Backend::Dx12 => "DirectX 12", + Backend::Vulkan => "Vulkan", + Backend::Metal => "Metal", + Backend::Gl => "OpenGL", + } + } + + /// wgpu's bit for this backend. + pub fn bit(self) -> wgpu::Backends { + match self { + Backend::Dx12 => wgpu::Backends::DX12, + Backend::Vulkan => wgpu::Backends::VULKAN, + Backend::Metal => wgpu::Backends::METAL, + Backend::Gl => wgpu::Backends::GL, + } + } + + /// The backend of the adapter wgpu picked; `None` for the web and no-op backends. + pub fn of(backend: wgpu::Backend) -> Option<Backend> { + match backend { + wgpu::Backend::Dx12 => Some(Backend::Dx12), + wgpu::Backend::Vulkan => Some(Backend::Vulkan), + wgpu::Backend::Metal => Some(Backend::Metal), + wgpu::Backend::Gl => Some(Backend::Gl), + _ => None, + } + } +} + +/// The backends to try on this platform, best first. +pub fn candidates() -> &'static [Backend] { + if cfg!(windows) { + &[Backend::Dx12, Backend::Vulkan, Backend::Gl] + } else if cfg!(target_os = "macos") { + &[Backend::Metal] + } else { + &[Backend::Vulkan, Backend::Gl] + } +} + +/// What `gpu.json` holds. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Record { + /// The DesignCraft version that wrote the record; another version starts afresh. + #[serde(default)] + pub version: String, + /// The backend of a start that has not presented a frame yet. + #[serde(default)] + pub trying: Option<Backend>, + /// Backends whose start never presented a frame. + #[serde(default)] + pub failed: Vec<Backend>, +} + +/// The backend for a start, from [`choose`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Choice { + pub backend: Backend, + /// The record to write before the window is created. + pub record: Record, + /// The backend the previous start never finished with, when this start moves away from it. + pub switched_from: Option<Backend>, +} + +/// Pick the backend for this start from the candidates (best first) and the previous start's +/// record; `None` without candidates. +pub fn choose(candidates: &[Backend], previous: &Record, version: &str) -> Option<Choice> { + let first = *candidates.first()?; + let same_version = previous.version == version; + let mut failed: Vec<Backend> = if same_version { previous.failed.clone() } else { Vec::new() }; + failed.retain(|b| candidates.contains(b)); + let unfinished = previous.trying.filter(|_| same_version); + if let Some(b) = unfinished + && !failed.contains(&b) + { + failed.push(b); + } + let backend = match candidates.iter().copied().find(|b| !failed.contains(b)) { + Some(b) => b, + // Every candidate has failed: start over rather than never starting. + None => { + failed.clear(); + first + } + }; + let switched_from = unfinished.filter(|b| *b != backend); + Some(Choice { backend, record: Record { version: version.to_owned(), trying: Some(backend), failed }, switched_from }) +} + +/// The record in `path`; a missing or unreadable file is an empty record. +pub fn read(path: &Path) -> Record { + std::fs::read(path).ok().and_then(|bytes| serde_json::from_slice(&bytes).ok()).unwrap_or_default() +} + +/// Write `record` to `path` (creating the directory). +pub fn write(path: &Path, record: &Record) -> std::io::Result<()> { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + let bytes = serde_json::to_vec_pretty(record).map_err(std::io::Error::other)?; + std::fs::write(path, bytes) +} + +/// A start in progress: the backend chosen for it and the record that says so until a frame has +/// been presented. +#[derive(Debug)] +pub struct Startup { + pub backend: Backend, + pub switched_from: Option<Backend>, + /// `gpu.json`, when the record is persisted. + path: Option<PathBuf>, + record: Record, +} + +impl Startup { + /// Choose the backend for this start and record it in `path` (nothing is recorded without + /// one). `None` when `WGPU_BACKEND` is set — wgpu follows it and the fallback is off — or the + /// platform has no candidates. + pub fn begin(path: Option<PathBuf>, version: &str) -> Option<Startup> { + if wgpu::Backends::from_env().is_some_and(|b| !b.is_empty()) { + log::info!("graphics: WGPU_BACKEND is set, wgpu picks the backend and the start-up fallback is off"); + return None; + } + Self::begin_unforced(path, version) + } + + fn begin_unforced(path: Option<PathBuf>, version: &str) -> Option<Startup> { + let previous = path.as_deref().map(read).unwrap_or_default(); + let choice = choose(candidates(), &previous, version)?; + if let Some(p) = &path + && let Err(e) = write(p, &choice.record) + { + log::warn!("graphics: {}: {e}", p.display()); + } + match choice.switched_from { + Some(from) => log::warn!( + "graphics: the last start with {} never showed a frame; trying {} (failed so far: {:?})", + from.name(), + choice.backend.name(), + choice.record.failed + ), + None => log::info!("graphics: {} (WGPU_BACKEND overrides)", choice.backend.name()), + } + Some(Startup { backend: choice.backend, switched_from: choice.switched_from, path, record: choice.record }) + } + + /// The backends wgpu may use: the chosen one, with OpenGL as the in-process fallback when it + /// has no adapter (unless OpenGL itself has failed). Not on Windows: some OpenGL drivers + /// (AMD's) crash while the instance is created (#167), before any fallback could help, so + /// there OpenGL is only ever tried as its own candidate. + pub fn backends(&self) -> wgpu::Backends { + let mut backends = self.backend.bit(); + if !cfg!(windows) && !self.record.failed.contains(&Backend::Gl) { + backends |= wgpu::Backends::GL; + } + backends + } + + /// wgpu picked an adapter of `backend`: record that one, so a crash is blamed on the backend + /// that is actually drawing. + pub fn started_with(&mut self, backend: Backend) { + if self.record.trying == Some(backend) { + return; + } + self.backend = backend; + self.record.trying = Some(backend); + self.save(); + } + + /// A frame has been presented (or the app exited normally): this start did not crash. + pub fn presented(&mut self) { + if self.record.trying.take().is_some() { + self.save(); + } + } + + /// The status-bar line for a start that moved away from a backend that crashed. + pub fn status_line(&self) -> Option<String> { + let from = self.switched_from?; + Some(format!( + "Graphics: switched from {} to {} — the last start with {} never showed a frame (set WGPU_BACKEND to choose).", + from.name(), + self.backend.name(), + from.name() + )) + } + + fn save(&self) { + if let Some(p) = &self.path + && let Err(e) = write(p, &self.record) + { + log::warn!("graphics: {}: {e}", p.display()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + const V: &str = "0.5.0"; + const WIN: &[Backend] = &[Backend::Dx12, Backend::Vulkan, Backend::Gl]; + + fn temp_path(tag: &str) -> PathBuf { + static N: AtomicUsize = AtomicUsize::new(0); + let d = std::env::temp_dir().join(format!("designcraft-gpu-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed))); + let _ = std::fs::remove_dir_all(&d); + d.join(FILE) + } + + fn record(trying: Option<Backend>, failed: &[Backend]) -> Record { + Record { version: V.into(), trying, failed: failed.to_vec() } + } + + #[test] + fn every_platform_has_candidates_without_duplicates() { + let c = candidates(); + assert!(!c.is_empty()); + for (i, b) in c.iter().enumerate() { + assert!(!c[..i].contains(b), "{b:?} twice in {c:?}"); + } + if cfg!(windows) { + assert_eq!(c[0], Backend::Dx12, "DirectX 12 is the Windows default (AMD's Vulkan driver faulted at the first present)"); + assert!(!c.contains(&Backend::Metal)); + } + if cfg!(target_os = "macos") { + assert_eq!(c, &[Backend::Metal]); + } + } + + #[test] + fn a_fresh_start_takes_the_first_candidate() { + let c = choose(WIN, &Record::default(), V).unwrap(); + assert_eq!(c.backend, Backend::Dx12); + assert_eq!(c.record, record(Some(Backend::Dx12), &[])); + assert_eq!(c.switched_from, None); + assert!(choose(&[], &Record::default(), V).is_none()); + } + + #[test] + fn a_start_that_never_presented_moves_to_the_next_candidate() { + let c = choose(WIN, &record(Some(Backend::Dx12), &[]), V).unwrap(); + assert_eq!(c.backend, Backend::Vulkan); + assert_eq!(c.record, record(Some(Backend::Vulkan), &[Backend::Dx12])); + assert_eq!(c.switched_from, Some(Backend::Dx12)); + + let c = choose(WIN, &record(Some(Backend::Vulkan), &[Backend::Dx12]), V).unwrap(); + assert_eq!(c.backend, Backend::Gl); + assert_eq!(c.record.failed, &[Backend::Dx12, Backend::Vulkan]); + assert_eq!(c.switched_from, Some(Backend::Vulkan)); + } + + #[test] + fn a_finished_start_keeps_the_failed_list_and_switches_silently() { + // `trying` was cleared by a presented frame; the next start still avoids what failed. + let c = choose(WIN, &record(None, &[Backend::Dx12]), V).unwrap(); + assert_eq!(c.backend, Backend::Vulkan); + assert_eq!(c.record.failed, &[Backend::Dx12]); + assert_eq!(c.switched_from, None); + // The same backend failing again is listed once. + let c = choose(WIN, &record(Some(Backend::Dx12), &[Backend::Dx12]), V).unwrap(); + assert_eq!(c.record.failed, &[Backend::Dx12]); + } + + #[test] + fn when_every_candidate_failed_the_list_starts_over() { + let c = choose(WIN, &record(Some(Backend::Gl), &[Backend::Dx12, Backend::Vulkan]), V).unwrap(); + assert_eq!(c.backend, Backend::Dx12); + assert_eq!(c.record.failed, &[]); + assert_eq!(c.switched_from, Some(Backend::Gl)); + } + + #[test] + fn another_version_or_platform_forgets_what_failed() { + let old = Record { version: "0.4.0".into(), trying: Some(Backend::Dx12), failed: vec![Backend::Vulkan] }; + let c = choose(WIN, &old, V).unwrap(); + assert_eq!(c.backend, Backend::Dx12); + assert_eq!(c.record, record(Some(Backend::Dx12), &[])); + assert_eq!(c.switched_from, None); + // A backend that is no candidate here (a record copied from another machine) is dropped. + let c = choose(&[Backend::Vulkan, Backend::Gl], &record(None, &[Backend::Metal, Backend::Vulkan]), V).unwrap(); + assert_eq!(c.backend, Backend::Gl); + assert_eq!(c.record.failed, &[Backend::Vulkan]); + } + + #[test] + fn the_record_round_trips_and_garbage_reads_as_empty() { + let path = temp_path("roundtrip"); + assert_eq!(read(&path), Record::default()); + let r = record(Some(Backend::Vulkan), &[Backend::Dx12]); + write(&path, &r).expect("write"); + assert_eq!(read(&path), r); + let text = std::fs::read_to_string(&path).expect("read"); + assert!(text.contains("\"trying\": \"vulkan\"") && text.contains("\"dx12\""), "{text}"); + std::fs::write(&path, b"{not json").expect("garbage"); + assert_eq!(read(&path), Record::default()); + // Unknown fields and missing ones are tolerated. + std::fs::write(&path, br#"{"failed":["gl"],"later":1}"#).expect("partial"); + assert_eq!(read(&path), Record { version: String::new(), trying: None, failed: vec![Backend::Gl] }); + let _ = std::fs::remove_dir_all(path.parent().expect("dir")); + } + + #[test] + fn a_start_records_its_backend_until_a_frame_is_presented() { + let path = temp_path("startup"); + let mut s = Startup::begin_unforced(Some(path.clone()), V).expect("candidates"); + let first = candidates()[0]; + assert_eq!(s.backend, first); + assert_eq!(read(&path).trying, Some(first)); + assert!(s.backends().contains(first.bit())); + // OpenGL is the in-process fallback, except on Windows where it is only its own candidate. + assert_eq!(s.backends().contains(wgpu::Backends::GL), !cfg!(windows) || first == Backend::Gl); + assert_eq!(s.status_line(), None); + + // wgpu actually picked OpenGL (the chosen backend had no adapter): blame that one. + s.started_with(Backend::Gl); + assert_eq!(read(&path).trying, Some(Backend::Gl)); + s.presented(); + assert_eq!(read(&path), record(None, &[])); + s.presented(); + assert_eq!(read(&path), record(None, &[])); + + // The next start after one that never presented: switches, says so, and leaves OpenGL + // out of the set once it failed. + write(&path, &record(Some(Backend::Gl), &[])).expect("write"); + let s = Startup::begin_unforced(Some(path.clone()), V).expect("candidates"); + if candidates().len() > 1 { + assert_eq!(s.switched_from, Some(Backend::Gl)); + let line = s.status_line().expect("status"); + assert!(line.contains("OpenGL") && line.contains(s.backend.name()) && line.contains("WGPU_BACKEND"), "{line}"); + assert!(!s.backends().contains(wgpu::Backends::GL)); + } else { + assert_eq!(s.switched_from, None); + } + let _ = std::fs::remove_dir_all(path.parent().expect("dir")); + } + + #[test] + fn without_a_path_nothing_is_written_and_a_bad_path_is_not_fatal() { + let mut s = Startup::begin_unforced(None, V).expect("candidates"); + s.started_with(Backend::Gl); + s.presented(); + // A file where the directory should be: every write fails, nothing panics. + let blocked = temp_path("blocked"); + let dir = blocked.parent().expect("dir"); + std::fs::create_dir_all(dir.parent().expect("tmp")).expect("tmp"); + std::fs::write(dir, "a file where the directory should be").expect("block"); + let mut s = Startup::begin_unforced(Some(blocked.clone()), V).expect("candidates"); + s.started_with(Backend::Gl); + s.presented(); + let _ = std::fs::remove_file(dir); + } +} diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index fa413e3ac..c30606232 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -5,10 +5,14 @@ //! `--control <port>` (or `DESIGNCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server: //! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`. //! See `designcraft_ui_egui::control` for the methods. +//! +//! The graphics backend (DirectX 12, Vulkan, Metal or OpenGL) is chosen in `gpu` before the window +//! exists, with a fallback for a driver that crashes at start-up; `WGPU_BACKEND` overrides it. #![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod control_server; +mod gpu; mod logging; #[cfg(target_os = "macos")] mod native_menu; @@ -16,33 +20,54 @@ mod native_menu; use designcraft_engine::Session; use designcraft_ui_egui::{DesignApp, Services}; -struct App(DesignApp, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>); +struct App { + app: DesignApp, + #[cfg(target_os = "macos")] + menu: Option<native_menu::NativeMenu>, + /// The graphics start in progress (`gpu.json`) until a frame has been presented; see `gpu`. + startup: Option<gpu::Startup>, + /// Frames begun so far. + frames: u32, +} impl eframe::App for App { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + // The third call: two frames went through `ui`, so the first was presented and the driver + // survived it (a fault at the first present is what the fallback in `gpu` is for). + if self.frames >= 2 + && let Some(s) = self.startup.as_mut() + { + s.presented(); + self.startup = None; + } + self.frames = self.frames.saturating_add(1); #[cfg(target_os = "macos")] { - if self.1.is_none() && std::env::var_os("DESIGNCRAFT_NO_NATIVE_MENU").is_none() { - self.1 = Some(native_menu::NativeMenu::install(&mut self.0)); + if self.menu.is_none() && std::env::var_os("DESIGNCRAFT_NO_NATIVE_MENU").is_none() { + self.menu = Some(native_menu::NativeMenu::install(&mut self.app)); } - if let Some(m) = &mut self.1 { - m.poll(&mut self.0, ctx); + if let Some(m) = &mut self.menu { + m.poll(&mut self.app, ctx); } } - self.0.logic(ctx); + self.app.logic(ctx); } fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { - self.0.raw_input_hook(raw); + self.app.raw_input_hook(raw); } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { - self.0.ui(ui); + self.app.ui(ui); } fn on_exit(&mut self) { - save_prefs(&self.0); + // A normal exit before the third frame is not a crash. + if let Some(s) = self.startup.as_mut() { + s.presented(); + } + save_prefs(&self.app); } } -/// The per-user settings directory: `ui.json`, `prefs.json` and `logs/` live here. +/// The per-user settings directory: `ui.json`, `prefs.json`, `gpu.json` and `logs/` live here. fn config_dir() -> Option<std::path::PathBuf> { if cfg!(target_os = "macos") { std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support/DesignCraft")) @@ -56,12 +81,17 @@ fn config_dir() -> Option<std::path::PathBuf> { } } +/// `DESIGNCRAFT_NO_PREFS`: read and write nothing in the settings directory (tests, demos). +fn no_prefs() -> bool { + std::env::var_os("DESIGNCRAFT_NO_PREFS").is_some() +} + fn prefs_path() -> Option<std::path::PathBuf> { config_dir().map(|b| b.join("ui.json")) } fn load_prefs(app: &mut DesignApp) { - if std::env::var_os("DESIGNCRAFT_NO_PREFS").is_some() { + if no_prefs() { return; } if let Some(p) = prefs_path() @@ -80,7 +110,7 @@ fn load_prefs(app: &mut DesignApp) { } fn save_prefs(app: &DesignApp) { - if std::env::var_os("DESIGNCRAFT_NO_PREFS").is_some() { + if no_prefs() { return; } if let Some(p) = prefs_path() { @@ -241,6 +271,14 @@ fn main() -> eframe::Result { if let Some(icon) = app_icon() { options.viewport = options.viewport.with_icon(icon); } + // The graphics backend, decided before the window exists: a driver that faults takes the + // process down before any Rust code can catch it (see `gpu`). + let gpu = gpu::Startup::begin(if no_prefs() { None } else { config_dir().map(|d| d.join(gpu::FILE)) }, env!("CARGO_PKG_VERSION")); + if let Some(s) = &gpu + && let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup + { + create.instance_descriptor.backends = s.backends(); + } // winit has no file drag-and-drop on Wayland (only on X11), so dropping images from the file // manager showed a "no" cursor. Run through XWayland when it's there; DESIGNCRAFT_WAYLAND=1 // keeps the native Wayland backend. @@ -255,11 +293,24 @@ fn main() -> eframe::Result { "DesignCraft", options, Box::new(move |cc| { + let mut gpu = gpu; + if let Some(rs) = &cc.wgpu_render_state { + // What a bug report needs to know about the graphics driver. + let info = rs.adapter.get_info(); + log::info!("graphics: {} on {:?} ({:?}; driver {} {})", info.name, info.backend, info.device_type, info.driver, info.driver_info); + if let (Some(s), Some(backend)) = (gpu.as_mut(), gpu::Backend::of(info.backend)) { + s.started_with(backend); + } + } let mut session = Session::new(); // Crash recovery: reopen what a previous run left unsaved, then keep it current. session.recovery_dir = designcraft_engine::recovery::default_dir(); let recovered = session.execute("file.recovery.open", &serde_json::json!({})).ok(); let mut app = DesignApp::new(session, services()); + if let Some(line) = gpu.as_ref().and_then(gpu::Startup::status_line) { + app.status(line); + } + // Recovered documents matter more than the graphics note; both are in the log. if let Some(n) = recovered.as_ref().and_then(|r| r["opened"].as_array()).map(Vec::len).filter(|n| *n > 0) { app.status(format!("Recovered {n} unsaved document{} from the last session.", if n == 1 { "" } else { "s" })); } @@ -277,11 +328,13 @@ fn main() -> eframe::Result { eprintln!("designcraft: {f}: {e}"); } } - Ok(Box::new(App( + Ok(Box::new(App { app, #[cfg(target_os = "macos")] - None, - ))) + menu: None, + startup: gpu, + frames: 0, + })) }), ) } From 0b54cc3d664e7e0303fe3646cfa8a154d19b52fb Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 10 Oct 2026 04:18:49 -0400 Subject: [PATCH 011/238] Text: a word longer than the line breaks at the column edge; a line that exactly fills the measure fits (#293) * Text: a line that exactly fills the measure fits A line whose natural width overruns its measure by less than 0.01 pt (rounded frame widths and advances) fits, in the paragraph and the single-line composer. A one-word line that exactly filled its frame was hyphenated and lost its last letters. * Text: a word longer than the line breaks at the column edge The paragraph composer gets emergency break points inside any word (or part between hyphenation points) that is wider than the narrowest measure even fully shrunk. They cost more than a hyphen, so a legal hyphenation point wins where one fits; justified pieces are rated by the space they leave, so each line takes as many letters as fit. Tracking is part of the width. Justified lines shrink no further than the paragraph's minimum word and letter spacing and glyph scaling: an overfull line stays overfull instead of overlapping its glyphs, and an overfull single word shrinks within the letter and glyph limits. --------- Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --- crates/compose/src/breaker.rs | 95 ++++++++++++++++++++++++++++++++--- crates/compose/src/lib.rs | 11 +++- crates/compose/src/tests.rs | 53 +++++++++++++++++++ 3 files changed, 149 insertions(+), 10 deletions(-) diff --git a/crates/compose/src/breaker.rs b/crates/compose/src/breaker.rs index dbc8819bf..754cfc3d0 100644 --- a/crates/compose/src/breaker.rs +++ b/crates/compose/src/breaker.rs @@ -148,24 +148,46 @@ struct Item { hang: f64, /// Auto hyphenation points: index of the break opportunity before the word (or `NONE`). zone_from: usize, + /// An emergency break inside a word wider than the measure. + emergency: bool, } const NONE: usize = usize::MAX; impl Item { fn boxed(w: f64, st: [f64; 3], sh: [f64; 3]) -> Item { - Item { kind: Kind::Box, w, st, sh, p: 0.0, flagged: false, auto: false, hang: 0.0, zone_from: NONE } + Item { kind: Kind::Box, w, st, sh, p: 0.0, flagged: false, auto: false, hang: 0.0, zone_from: NONE, emergency: false } } fn glue(w: f64, st: f64, sh: f64) -> Item { - Item { kind: Kind::Glue, w, st: [st, 0.0, 0.0], sh: [sh, 0.0, 0.0], p: 0.0, flagged: false, auto: false, hang: 0.0, zone_from: NONE } + Item { + kind: Kind::Glue, + w, + st: [st, 0.0, 0.0], + sh: [sh, 0.0, 0.0], + p: 0.0, + flagged: false, + auto: false, + hang: 0.0, + zone_from: NONE, + emergency: false, + } } fn penalty(w: f64, p: f64, flagged: bool) -> Item { - Item { kind: Kind::Penalty, w, st: [0.0; 3], sh: [0.0; 3], p, flagged, auto: false, hang: 0.0, zone_from: NONE } + Item { kind: Kind::Penalty, w, st: [0.0; 3], sh: [0.0; 3], p, flagged, auto: false, hang: 0.0, zone_from: NONE, emergency: false } } } const INF: f64 = 10000.0; +/// A line whose natural width exceeds its measure by no more than this (points) still fits: frame +/// widths and glyph advances are rounded, so a line that exactly fills the measure can come out a +/// hair too wide. +const FIT_EPS: f64 = 0.01; + +/// Penalty for an emergency break inside a word wider than the measure: high, so a paragraph +/// takes no more of them than it must, but below [`INF`] so they are legal. +const EMERGENCY_PENALTY: f64 = 5000.0; + /// Does the glyph force a line end after it? /// A line may break between `a` and `b` in CJK text (any break next to an ideograph, kana or /// hangul), except where kinsoku forbids it: no line starts with closing punctuation, small @@ -274,7 +296,8 @@ fn push_inword(it: &mut Vec<Item>, ig: &mut Vec<usize>, p: Item, g: usize, sp: & } /// Build Knuth items. `item_glyph[k]` = glyph index of item k (penalties: the glyph *before* which the break happens). -fn items(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing) -> (Vec<Item>, Vec<usize>) { +/// `emergency_after[i]` allows an emergency break after glyph `i` (see [`overlong_words`]). +fn items(glyphs: &[Glyph], hyph_after: &[bool], emergency_after: &[bool], sp: &Spacing) -> (Vec<Item>, Vec<usize>) { let mut it: Vec<Item> = Vec::with_capacity(glyphs.len() * 2 + 2); let mut ig = Vec::with_capacity(glyphs.len() * 2 + 2); let n = glyphs.len(); @@ -352,6 +375,10 @@ fn items(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing) -> (Vec<Item>, Vec p.hang = hw * hang('-').1; } push_inword(&mut it, &mut ig, p, i + 1, sp); + } else if emergency_after.get(i) == Some(&true) { + let mut p = Item::penalty(0.0, EMERGENCY_PENALTY, false); + p.emergency = true; + push_inword(&mut it, &mut ig, p, i + 1, sp); } } } @@ -384,7 +411,11 @@ pub fn knuth_plass(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing, width: & if glyphs.is_empty() { return vec![Break { start: 0, end: 0, next: 0, hyphen: false, forced: true }]; } - let (items, ig) = items(glyphs, hyph_after, sp); + let (mut items, mut ig) = items(glyphs, hyph_after, &[], sp); + let narrowest = (0..=glyphs.len().min(64)).map(width).fold(f64::INFINITY, f64::min).max(1.0); + if let Some(emergency_after) = overlong_words(&items, &ig, glyphs, narrowest) { + (items, ig) = self::items(glyphs, hyph_after, &emergency_after, sp); + } for tolerance in [2.5, 12.0, f64::INFINITY] { if let Some(b) = kp_pass(&items, &ig, glyphs, sp, width, tolerance, tolerance.is_infinite()) { return b; @@ -393,6 +424,46 @@ pub fn knuth_plass(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing, width: & greedy(glyphs, hyph_after, sp, width, &|_, _, _| 0.0) } +/// Glyphs after which a word wider than `measure` may take an emergency break, or `None` when every +/// word fits. A word is a run of boxes between two break opportunities (so a word with hyphenation +/// points only gets emergency breaks where a part between them is still too wide), measured at +/// its narrowest (fully shrunk) width. Emergency breaks respect no-break text and kinsoku. +fn overlong_words(items: &[Item], ig: &[usize], glyphs: &[Glyph], measure: f64) -> Option<Vec<bool>> { + let mut out: Option<Vec<bool>> = None; + let mut seg: Option<(usize, usize)> = None; + let mut w = 0.0; + for (k, it) in items.iter().enumerate() { + match it.kind { + Kind::Box => { + let g = ig.get(k).copied().unwrap_or(0); + seg = Some(seg.map_or((g, g), |(a, _)| (a, g))); + w += it.w - it.sh.iter().sum::<f64>(); + } + Kind::Penalty if it.p >= INF => {} + Kind::Glue | Kind::Penalty => { + if let Some((a, b)) = seg.take() + && w > measure + FIT_EPS + { + 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 }; + let ok = !g.no_break + && g.break_after != Some(false) + && next.ch != SOFT_HYPHEN + && !is_forced(next.ch) + && kinsoku_allows(g.ch, next.ch); + if let Some(m) = marks.get_mut(i) { + *m = ok; + } + } + } + w = 0.0; + } + } + } + out +} + /// Upper bound on the hyphen-count states tracked per breakpoint. const MAX_HYPHEN_STATES: usize = 8; @@ -454,7 +525,15 @@ fn kp_pass( y[t] = sy[b][t] - n.ty[t]; z[t] = sz[b][t] - n.tz[t]; } - let (r, feasible) = tiered_ratio(target - l, y, z); + // A line that overruns its measure by a rounding error fits exactly. + let d = target - l; + let d = if d < 0.0 && d > -FIT_EPS { 0.0 } else { d }; + let (mut r, feasible) = tiered_ratio(d, y, z); + if it.emergency && sp.justify && r > 0.0 { + // A piece of an overlong word usually has no elastic material: rate it by the + // space it leaves, so the fullest piece wins (the break at the last glyph that fits). + r = d / target; + } if feasible && !forced { keep.push(a); } else { @@ -675,7 +754,7 @@ pub fn greedy( } let hang_r = right_hang(g, sp); let adv = if g.ch == '\t' { tab(line, x, i) } else { g.adv }; - if x + adv - hang_r > w + shrink + if x + adv - hang_r > w + shrink + FIT_EPS && i > start && (last_ok.is_some() || glyphs.get(i - 1).is_none_or(|p| p.break_after != Some(false) && !p.no_break)) { @@ -701,7 +780,7 @@ pub fn greedy( last_ok = Some((i + 1, false)); } else if hyph_after[i] && may_hyphenate(hyphens) { let hy = hyphen_width(g, &mut hy_cache) * if sp.optical { 1.0 - hang('-').1 } else { 1.0 }; - if x + hy <= w + shrink { + if x + hy <= w + shrink + FIT_EPS { last_ok = Some((i + 1, true)); } } diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 09e295250..ebb4bbd62 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -2081,6 +2081,11 @@ fn layout_line( let (seg_line, seg_add, seg_scale) = (line.get(seg..).unwrap_or(&[]), add.get_mut(seg..).unwrap_or(&mut []), scale.get_mut(seg..).unwrap_or(&mut [])); distribute(seg_line, &rebased, extra, sp, seg_add, seg_scale); + } else if justify_this && spaces.is_empty() && line.len() > seg + 1 && !last && extra < 0.0 { + // An overfull single word shrinks only as far as the minimum letter spacing and glyph scaling allow. + let (seg_line, seg_add, seg_scale) = + (line.get(seg..).unwrap_or(&[]), add.get_mut(seg..).unwrap_or(&mut []), scale.get_mut(seg..).unwrap_or(&mut [])); + distribute(seg_line, &[], extra, sp, seg_add, seg_scale); } else if justify_this && spaces.is_empty() && line.len() > seg + 1 && !last { // Single word: Single Word Justification. match pp.single_word_justify { @@ -2364,10 +2369,12 @@ fn distribute(line: &[Glyph], spaces: &[usize], extra: f64, sp: &Spacing, add: & let tg = rem.min(yg); rem -= tg; let sign = if stretch { 1.0 } else { -1.0 }; + // Stretch left over beyond every limit is shared equally by the word spaces; shrink never goes + // past the minimums (that would overlap glyphs), so an overfull line stays overfull. + let over = if stretch && !spaces.is_empty() { rem / spaces.len() as f64 } else { 0.0 }; for (k, &i) in spaces.iter().enumerate() { let share = if yw > 1e-9 { tw * word[k] / yw } else { 0.0 }; - // Left over beyond every limit: shared equally by the word spaces. - add[i] += sign * (share + rem / spaces.len() as f64); + add[i] += sign * share + over; } if yl > 1e-9 && tl > 0.0 { for (i, l) in letter.iter().enumerate() { diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 7d75d7337..0013d9865 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -2180,6 +2180,59 @@ fn vertical_lines_fit_the_em_box() { } } +/// Lines of `text` set in a frame `width` wide, with `tracking` on every character. +fn tracked_lines(text: &str, width: f64, para: ParaAttrs, tracking: f64) -> Vec<Line> { + let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, width, 4000.0), para); + d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| f.over.tracking = Some(tracking)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(!cs.is_overset(), "overset at width {width}"); + all_lines(&cs).into_iter().cloned().collect() +} + +#[test] +fn a_word_longer_than_the_line_breaks_at_the_column_edge() { + use designcraft_doc::Composer; + let word = "n".repeat(60); + let long = format!("Then {word} and on"); + for tracking in [0.0, 740.0] { + // Natural advances, from one unbroken line. + let natural: HashMap<usize, f64> = + tracked_lines(&long, 1e5, ParaAttrs::default(), tracking)[0].glyphs.iter().map(|g| (g.byte, g.adv)).collect(); + for composer in [Composer::Paragraph, Composer::SingleLine] { + for align in [Align::Left, Align::LeftJustified] { + let para = ParaAttrs { composer: Some(composer), align: Some(align), hyphenate: Some(false), ..Default::default() }; + let lines = tracked_lines(&long, 72.0, para, tracking); + let what = format!("{composer:?} {align:?} tracking {tracking}"); + assert!(lines.len() >= 4, "{what}: {} lines", lines.len()); + for l in &lines { + let ink: Vec<_> = l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).collect(); + let right = ink.iter().map(|g| g.x + g.adv.min(natural[&g.byte])).fold(l.x0, f64::max); + assert!(l.end_x <= l.x1 + 0.02 && right <= l.x1 + 0.02, "{what}: line {:?} ends at {} past {}", l.range, l.end_x, l.x1); + for w in ink.windows(2) { + assert!(w[1].x >= w[0].x + natural[&w[0].byte] - 1e-6, "{what}: glyphs overlap at {}", w[1].byte); + } + } + } + } + } +} + +#[test] +fn a_line_that_exactly_fills_the_measure_fits() { + use designcraft_doc::Composer; + let text = "#knowyourplastic"; + let one = &tracked_lines(text, 1e5, ParaAttrs::default(), 0.0)[0]; + // The measure a rounding error short of the natural width. + let width = one.end_x - one.x0 - 0.005; + for composer in [Composer::Paragraph, Composer::SingleLine] { + for align in [Align::Left, Align::LeftJustified] { + let para = ParaAttrs { composer: Some(composer), align: Some(align), ..Default::default() }; + let lines = tracked_lines(text, width, para, 0.0); + assert_eq!(lines.len(), 1, "{composer:?} {align:?}: {:?}", lines.iter().map(|l| l.range.clone()).collect::<Vec<_>>()); + } + } +} + #[test] fn justified_line_with_a_tab_justifies_the_text_after_its_last_tab() { // InDesign justifies only the text after a line's last (left) tab; tab stops stay aligned. From c657d8545eaabb7f84ac377e9c06667ca07c264a Mon Sep 17 00:00:00 2001 From: vpkoskinen <46873698+vpkoskinen@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:23:32 +0300 Subject: [PATCH 012/238] PDF export: options dialog with remembered settings (#196) * PDF export: Export Adobe PDF options dialog with remembered settings File > Export PDF... now opens an options dialog (General / Compression / Marks and Bleeds / Advanced) that exposes only the parameters the engine's file.exportPdf already supports: PDF standard, image compression, flattener preset, spreads, document bleed, crop/bleed/page-info marks with weight and offset, tagged PDF, page range, and the title. Choosing a preset fills the dependent fields; editing any field switches the preset to Custom. The last-used settings persist in ui.json (UiState.pdfExport) and are reused across exports and sessions. Programmatic callers that pass explicit params (path, options) still export directly without the dialog; cancelling the save picker writes nothing. Adds English and ar/pt-br strings, unit tests for the settings serde, the dialog defaults and preset mapping, the open/confirm flow (stub writer), and the menu dispatch, plus a ROADMAP note. * PDF export: rename dialog title to "Export PDF" --------- Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 2 +- crates/engine/src/cmd/export.rs | 2 +- crates/ui-egui/src/dialogs.rs | 379 +++++++++++++++++++++++++++++++ crates/ui-egui/src/i18n.rs | 47 ++++ crates/ui-egui/src/i18n/ar.rs | 20 ++ crates/ui-egui/src/i18n/pt_br.rs | 20 ++ crates/ui-egui/src/lib.rs | 78 +++++++ crates/ui-egui/src/menus.rs | 23 +- 8 files changed, 567 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index eb05508d1..86a435456 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -19,7 +19,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts, document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches, Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document and Preferences dialogs, Quick Apply (⌘Return), 4 brightness themes. - Pen tool, Direct Selection anchor/handle editing, rotate from corners, Rotate/Scale/Shear tools, Eyedropper, snapping & smart guides, Align/Distribute, Find/Change (text + GREP), Story Editor, Paragraph Style Options, Effects panel, hidden characters, native macOS menu bar, measured InDesign 2026 look (Medium Dark): Properties sections per selection state, 21 pt spinner fields with arithmetic, Contextual Task Bar, inverse (black) text selection, Pages panel with drag-and-drop reorder / parent apply, Layers panel with per-object rows. - Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection, Type (draw frame, click caret, select, type), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom. -- PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`. +- PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`. File › Export PDF… now opens an "Export PDF" options dialog (General / Compression / Marks and Bleeds / Advanced) and remembers the last-used settings across exports and sessions. - IDML interchange (`designcraft-idml`): export and import of swatches, styles, fonts, preferences, parent spreads, spreads/pages, frames, groups, images (embedded or linked), formatted threaded stories — opens in InDesign 2026 and round-trips InDesign-exported files (`file.exportIdml`, `file.openIdml`, File → Export IDML…, CLI `--in x.idml` / `--export x.idml`). - Tables (M8): tables anchored in stories (header/footer/body rows, merged cells, per-edge strokes, fills + alternating fills, insets, vertical justification, at-least/exact row heights); composed into the text column (columns scale to fit, rows break across columns/frames with repeating headers/footers, overset); rendered, exported to PDF (real text) and IDML (export + import); Table menu, Table panel, Create Table dialog, caret/typing/Tab navigation and cell selection in cells; `table.*` commands. - Hyperlinks (text or frames → URL / e-mail / page) and bookmarks, exported as PDF link annotations and outline. diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index bf986ced7..f47863851 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -1,4 +1,4 @@ -//! File › Export: Adobe PDF (Print). +//! File › Export PDF (Print). use designcraft_pdf::{Marks, PdfOptions, Standard}; use serde_json::{Value, json}; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 1947190f5..2ab7a3bad 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -31,6 +31,22 @@ impl Dialog { "insertXref" => json!({"linkTo": "paragraph", "style": "", "target": "", "format": ""}), "findChange" => json!({"find": "", "change": "", "grep": false, "caseSensitive": false, "wholeWord": false, "scope": "document"}), "documentSetup" => json!({}), + "pdfExport" => { + json!({ + "preset": "Desktop Printing", + "standard": "none", + "compressImages": false, + "flatten": "", + "spreads": false, + "bleed": true, + "marksCrop": false, + "marksBleed": false, + "marksPageInfo": false, + "marksWeight": "0.25", + "marksOffset": "6", + "tagged": true + }) + } _ => json!({}), }; if let Value::Object(d) = defaults { @@ -235,6 +251,22 @@ pub fn open_print(app: &mut DesignApp) { )); } +/// File › Export PDF…: the "Export PDF" options dialog, seeded from the persisted settings +/// (`UiState.pdf_export`) and the active document. `pages` is per-job (not persisted); `flatten` +/// falls back to the global flattener preset when the persisted value is empty. +pub fn open_pdf_export(app: &mut DesignApp) { + let mut fields = serde_json::to_value(&app.ui.pdf_export).unwrap_or_else(|_| json!({})); + if let Value::Object(m) = &mut fields { + let title = app.session.active().map(|st| st.doc.title.clone()).unwrap_or_default(); + m.insert("title".into(), json!(title)); + m.insert("pages".into(), json!("all")); + if app.ui.pdf_export.flatten.is_empty() { + m.insert("flatten".into(), json!(app.ui.flattener)); + } + } + app.ui.dialog = Some(Dialog::new("pdfExport", fields)); +} + fn print_dialog(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { let printers: Vec<String> = d .fields @@ -284,6 +316,99 @@ fn print_dialog(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { }); } +/// The presets offered by the Export PDF dialog, in display order. The stored value is the +/// English name; the caption is translated. +const PDF_EXPORT_PRESETS: &[&str] = &["Desktop Printing", "Commercial Printing", "Screen and Email", "PDF/X-4", "PDF/A-2b", "Custom"]; + +/// Apply a preset's dependent fields. Flatten is only touched by "Screen and Email"; "Custom" +/// (and any unknown value) changes nothing. +fn apply_pdf_preset(d: &mut Dialog, preset: &str) { + let (standard, compress, crop, bleed, page_info, flatten) = match preset { + "Desktop Printing" => ("none", false, false, false, false, None), + "Commercial Printing" => ("none", true, true, true, false, None), + "Screen and Email" => ("none", true, false, false, false, Some("low")), + "PDF/X-4" => ("x4", false, true, true, false, None), + "PDF/A-2b" => ("a2b", false, false, false, false, None), + _ => return, + }; + d.fields.insert("standard".into(), json!(standard)); + d.fields.insert("compressImages".into(), json!(compress)); + d.fields.insert("marksCrop".into(), json!(crop)); + d.fields.insert("marksBleed".into(), json!(bleed)); + d.fields.insert("marksPageInfo".into(), json!(page_info)); + if let Some(f) = flatten { + d.fields.insert("flatten".into(), json!(f)); + } +} + +/// File › Export PDF: the "Export PDF" options (General / Compression / Marks and Bleeds / +/// Advanced). Selecting a preset fills the dependent fields; editing any other control flips the +/// preset back to "Custom". +fn pdf_export(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + let lang = app.ui.language.as_str(); + let head = |ui: &mut egui::Ui, t: &str| { + ui.add_space(6.0); + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(lang, t)).font(semibold(12.0))); + }; + // Snapshot the non-preset fields so a manual edit can be detected after drawing. + let before = d.fields.clone(); + let mut preset_clicked = false; + + head(ui, "General"); + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(lang, "Preset:")); + let cur = d.s("preset"); + egui::ComboBox::from_id_salt("pdf_preset").selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, &cur))).width(200.0).show_ui( + ui, + |ui| { + for p in PDF_EXPORT_PRESETS { + if ui.selectable_label(cur == *p, crate::rtl::widget(ui, crate::i18n::tr(lang, p))).clicked() { + d.fields.insert("preset".into(), json!(*p)); + preset_clicked = true; + } + } + }, + ); + }); + if preset_clicked { + let p = d.s("preset"); + apply_pdf_preset(d, &p); + } + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(lang, "Page Range:")); + text_field(ui, d, "pages", 160.0); + }); + check(ui, d, "spreads", crate::i18n::tr(lang, "Spreads")); + + head(ui, "Compression"); + check(ui, d, "compressImages", crate::i18n::tr(lang, "Compress images")); + + head(ui, "Marks and Bleeds"); + check(ui, d, "bleed", crate::i18n::tr(lang, "Include document bleed")); + check(ui, d, "marksCrop", crate::i18n::tr(lang, "Crop Marks")); + check(ui, d, "marksBleed", crate::i18n::tr(lang, "Bleed Marks")); + check(ui, d, "marksPageInfo", crate::i18n::tr(lang, "Page Information")); + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(lang, "Weight:")); + text_field(ui, d, "marksWeight", 60.0); + ui.add_space(8.0); + crate::rtl::label(ui, crate::i18n::tr(lang, "Offset:")); + text_field(ui, d, "marksOffset", 60.0); + }); + + head(ui, "Advanced"); + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(lang, "Standard:")); + translated_combo(lang, ui, d, "standard", &[("none", "None"), ("x4", "PDF/X-4"), ("a2b", "PDF/A-2b")]); + }); + check(ui, d, "tagged", crate::i18n::tr(lang, "Tagged PDF")); + + // Any edit other than picking a preset means the settings are no longer that preset. + if !preset_clicked && d.fields != before { + d.fields.insert("preset".into(), json!("Custom")); + } +} + /// The options of the selected text frame (the first selected item, or the frame of the caret). fn selected_frame_options(app: &DesignApp) -> Option<designcraft_doc::TextFrameOptions> { let st = app.session.active()?; @@ -1020,6 +1145,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "preferences" => crate::i18n::tr(&app.ui.language, "Preferences"), "print" => crate::i18n::tr(&app.ui.language, "Print"), "pdfImport" => crate::i18n::tr(&app.ui.language, "Place PDF"), + "pdfExport" => crate::i18n::tr(&app.ui.language, "Export PDF"), "colorSettings" => crate::i18n::tr(&app.ui.language, "Color Settings"), "layerOptions" => crate::i18n::tr(&app.ui.language, "Object Layer Options"), "keyboardShortcuts" => crate::i18n::tr(&app.ui.language, "Keyboard Shortcuts"), @@ -1564,6 +1690,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { text_frame_options_dialog(app, ui, &mut d); } "documentSetup" => document_setup(app, ui, &mut d), + "pdfExport" => pdf_export(app, ui, &mut d), "alert" => { let file = d.s("file"); if !file.is_empty() { @@ -1807,6 +1934,57 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> { app.status(format!("Sent {} page(s) to {}", r["pages"], r["printer"].as_str().unwrap_or("the default printer"))); Ok(r) } + "pdfExport" => { + // Remember the last-used options; `pages` and `title` are per-job and not persisted. + app.ui.pdf_export.preset = d.s("preset"); + app.ui.pdf_export.standard = d.s("standard"); + app.ui.pdf_export.compress_images = d.b("compressImages"); + app.ui.pdf_export.flatten = d.s("flatten"); + app.ui.pdf_export.spreads = d.b("spreads"); + app.ui.pdf_export.bleed = d.b("bleed"); + app.ui.pdf_export.marks_crop = d.b("marksCrop"); + app.ui.pdf_export.marks_bleed = d.b("marksBleed"); + app.ui.pdf_export.marks_page_info = d.b("marksPageInfo"); + app.ui.pdf_export.marks_weight = d.s("marksWeight"); + app.ui.pdf_export.marks_offset = d.s("marksOffset"); + app.ui.pdf_export.tagged = d.b("tagged"); + + let mut params = json!({ + "standard": d.s("standard"), + "compressImages": d.b("compressImages"), + "spreads": d.b("spreads"), + "bleed": d.b("bleed"), + "tagged": d.b("tagged"), + "marks": { + "crop": d.b("marksCrop"), + "bleed": d.b("marksBleed"), + "pageInfo": d.b("marksPageInfo"), + "weight": d.pt("marksWeight").unwrap_or(0.25), + "offset": d.pt("marksOffset").unwrap_or(6.0), + }, + }); + let flatten = d.s("flatten"); + if !flatten.is_empty() { + params["flatten"] = json!(flatten); + } + let pages = d.s("pages"); + if !pages.trim().is_empty() && pages != "all" { + params["pages"] = json!(pages); + } + let title = d.s("title"); + if !title.is_empty() { + params["title"] = json!(title); + } + let name = if title.is_empty() { "Export.pdf".to_string() } else { format!("{title}.pdf") }; + match app.services.pick_save.as_mut().and_then(|f| f(&name)) { + Some(path) => { + params["path"] = json!(path); + crate::menus::export_pdf(app, ¶ms) + } + // The user cancelled the save dialog: export nothing. + None => Ok(Value::Null), + } + } "preferences" => { app.run( "prefs.set", @@ -4033,6 +4211,207 @@ mod tests { } } + #[test] + fn pdf_export_defaults() { + let d = Dialog::new("pdfExport", json!({})); + let keys: Vec<&str> = d.fields.iter().map(|(k, _)| k.as_str()).collect(); + // serde_json::Map keeps keys sorted. + let expected = [ + "bleed", + "compressImages", + "flatten", + "marksBleed", + "marksCrop", + "marksOffset", + "marksPageInfo", + "marksWeight", + "preset", + "spreads", + "standard", + "tagged", + ]; + assert_eq!(keys, expected); + assert_eq!(d.fields["preset"].as_str(), Some("Desktop Printing")); + assert_eq!(d.fields["standard"].as_str(), Some("none")); + assert_eq!(d.fields["compressImages"].as_bool(), Some(false)); + assert_eq!(d.fields["flatten"].as_str(), Some("")); + assert_eq!(d.fields["spreads"].as_bool(), Some(false)); + assert_eq!(d.fields["bleed"].as_bool(), Some(true)); + assert_eq!(d.fields["marksCrop"].as_bool(), Some(false)); + assert_eq!(d.fields["marksBleed"].as_bool(), Some(false)); + assert_eq!(d.fields["marksPageInfo"].as_bool(), Some(false)); + assert_eq!(d.fields["marksWeight"].as_str(), Some("0.25")); + assert_eq!(d.fields["marksOffset"].as_str(), Some("6")); + assert_eq!(d.fields["tagged"].as_bool(), Some(true)); + } + + #[test] + fn pdf_export_override_keeps_override() { + let d = Dialog::new("pdfExport", json!({"bleed": false})); + assert_eq!(d.fields["bleed"].as_bool(), Some(false)); + assert_eq!(d.fields["preset"].as_str(), Some("Desktop Printing")); + assert_eq!(d.fields["tagged"].as_bool(), Some(true)); + } + + #[test] + fn pdf_export_preset_high_quality_print_keeps_flatten() { + let mut d = Dialog::new("pdfExport", json!({})); + d.fields.insert("flatten".into(), json!("medium")); + apply_pdf_preset(&mut d, "Desktop Printing"); + assert_eq!(d.s("standard"), "none"); + assert!(!d.b("compressImages")); + assert!(!d.b("marksCrop")); + assert!(!d.b("marksBleed")); + assert!(!d.b("marksPageInfo")); + assert_eq!(d.s("flatten"), "medium", "a leave-unchanged preset must not touch flatten"); + } + + #[test] + fn pdf_export_preset_press_quality_keeps_flatten() { + let mut d = Dialog::new("pdfExport", json!({})); + d.fields.insert("flatten".into(), json!("high")); + apply_pdf_preset(&mut d, "Commercial Printing"); + assert_eq!(d.s("standard"), "none"); + assert!(d.b("compressImages")); + assert!(d.b("marksCrop")); + assert!(d.b("marksBleed")); + assert!(!d.b("marksPageInfo")); + assert_eq!(d.s("flatten"), "high"); + } + + #[test] + fn pdf_export_preset_smallest_file_size_sets_flatten_low() { + let mut d = Dialog::new("pdfExport", json!({})); + apply_pdf_preset(&mut d, "Screen and Email"); + assert_eq!(d.s("standard"), "none"); + assert!(d.b("compressImages")); + assert!(!d.b("marksCrop")); + assert!(!d.b("marksBleed")); + assert!(!d.b("marksPageInfo")); + assert_eq!(d.s("flatten"), "low"); + } + + #[test] + fn pdf_export_preset_pdf_x4_keeps_flatten() { + let mut d = Dialog::new("pdfExport", json!({})); + d.fields.insert("flatten".into(), json!("medium")); + apply_pdf_preset(&mut d, "PDF/X-4"); + assert_eq!(d.s("standard"), "x4"); + assert!(!d.b("compressImages")); + assert!(d.b("marksCrop")); + assert!(d.b("marksBleed")); + assert!(!d.b("marksPageInfo")); + assert_eq!(d.s("flatten"), "medium"); + } + + #[test] + fn pdf_export_preset_pdf_a2b_keeps_flatten() { + let mut d = Dialog::new("pdfExport", json!({})); + d.fields.insert("flatten".into(), json!("low")); + apply_pdf_preset(&mut d, "PDF/A-2b"); + assert_eq!(d.s("standard"), "a2b"); + assert!(!d.b("compressImages")); + assert!(!d.b("marksCrop")); + assert!(!d.b("marksBleed")); + assert!(!d.b("marksPageInfo")); + assert_eq!(d.s("flatten"), "low"); + } + + #[test] + fn open_pdf_export_seeds_fields_from_settings() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.ui.pdf_export.standard = "x4".into(); + app.ui.pdf_export.tagged = false; + app.ui.pdf_export.marks_crop = true; + app.ui.pdf_export.flatten = String::new(); + app.ui.flattener = "medium".into(); + open_pdf_export(&mut app); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!(d.id, "pdfExport"); + assert_eq!(d.s("standard"), "x4"); + assert!(!d.b("tagged")); + assert!(d.b("marksCrop")); + assert_eq!(d.s("pages"), "all"); + assert_eq!(d.s("flatten"), "medium", "empty flatten falls back to the global flattener"); + assert_eq!(d.s("title"), "", "no document → empty title"); + + // A non-empty persisted flatten is used verbatim, not the global fallback. + app.ui.pdf_export.flatten = "low".into(); + app.ui.flattener = "medium".into(); + open_pdf_export(&mut app); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!(d.s("flatten"), "low"); + } + + #[test] + fn pdf_export_preset_custom_changes_nothing() { + let mut d = Dialog::new("pdfExport", json!({})); + d.fields.insert("standard".into(), json!("x4")); + d.fields.insert("compressImages".into(), json!(true)); + d.fields.insert("marksCrop".into(), json!(true)); + d.fields.insert("marksBleed".into(), json!(true)); + d.fields.insert("marksPageInfo".into(), json!(true)); + d.fields.insert("flatten".into(), json!("high")); + let before = d.fields.clone(); + apply_pdf_preset(&mut d, "Custom"); + assert_eq!(d.fields, before); + } + + #[test] + fn confirm_pdf_export_writes_file_and_persists_settings() { + use std::sync::{Arc, Mutex}; + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({})).unwrap(); + let writes: Arc<Mutex<Vec<(String, Vec<u8>)>>> = Arc::new(Mutex::new(Vec::new())); + let sink = writes.clone(); + app.services.write = Some(Box::new(move |path: &str, bytes: &[u8]| { + sink.lock().unwrap().push((path.to_string(), bytes.to_vec())); + Ok(()) + })); + app.services.pick_save = Some(Box::new(|_name: &str| Some("/tmp/designcraft-test.pdf".to_string()))); + + app.ui.dialog = Some(Dialog::new("pdfExport", json!({}))); + { + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("standard".into(), json!("x4")); + d.fields.insert("bleed".into(), json!(false)); + d.fields.insert("marksCrop".into(), json!(true)); + d.fields.insert("tagged".into(), json!(false)); + d.fields.insert("title".into(), json!("Report")); + } + confirm(&mut app).unwrap(); + + let got = writes.lock().unwrap(); + assert_eq!(got.len(), 1, "confirming the dialog writes exactly one file"); + assert_eq!(got[0].0, "/tmp/designcraft-test.pdf"); + assert!(got[0].1.starts_with(b"%PDF"), "the written bytes are a PDF"); + drop(got); + + assert_eq!(app.ui.pdf_export.standard, "x4"); + assert!(!app.ui.pdf_export.bleed); + assert!(app.ui.pdf_export.marks_crop); + assert!(!app.ui.pdf_export.tagged); + } + + #[test] + fn confirm_pdf_export_cancelled_save_writes_nothing() { + use std::sync::{Arc, Mutex}; + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({})).unwrap(); + let writes: Arc<Mutex<Vec<(String, Vec<u8>)>>> = Arc::new(Mutex::new(Vec::new())); + let sink = writes.clone(); + app.services.write = Some(Box::new(move |path: &str, bytes: &[u8]| { + sink.lock().unwrap().push((path.to_string(), bytes.to_vec())); + Ok(()) + })); + app.services.pick_save = Some(Box::new(|_name: &str| None)); + + app.ui.dialog = Some(Dialog::new("pdfExport", json!({}))); + let r = confirm(&mut app).unwrap(); + assert_eq!(r, Value::Null); + assert!(writes.lock().unwrap().is_empty(), "cancelling the save picker must not write"); + } + use designcraft_doc::{CharAttrs, CharacterStyle}; /// A document with character styles Base (bold) and Emphasis (italic) on the first two letters. diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 0ffb9e735..e5e8a8d08 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -229,6 +229,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Art", ["Art", "Art", "Art", "Art", "画板"]), ("Based On:", ["Based On:", "Based On:", "Based On:", "Based On:", "基于:"]), ("Bleed", ["Bleed", "Bleed", "Bleed", "Bleed", "出血"]), + ("Bleed Marks", ["Beschnittmarken", "Traits de fond perdu", "Marcas de sangrado", "塗り足しマーク", "出血标记"]), ("Body Rows", ["Body Rows", "Body Rows", "Body Rows", "Body Rows", "表体行"]), ("Bottom", ["Bottom", "Bottom", "Bottom", "Bottom", "底部"]), ("Break Link to Style", ["Break Link to Style", "Break Link to Style", "Break Link to Style", "Break Link to Style", "断开与样式的链接"]), @@ -275,6 +276,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Create Table", ["Create Table", "Create Table", "Create Table", "Create Table", "创建表"]), ("Create Table…", ["Create Table…", "Create Table…", "Create Table…", "Create Table…", "创建表…"]), ("Crop", ["Crop", "Crop", "Crop", "Crop", "裁剪"]), + ("Crop Marks", ["Schnittmarken", "Traits de coupe", "Marcas de recorte", "トンボ", "裁切标记"]), ("Crop to:", ["Crop to:", "Crop to:", "Crop to:", "Crop to:", "裁剪到:"]), ("Cursor Key:", ["Cursor Key:", "Cursor Key:", "Cursor Key:", "Cursor Key:", "光标键:"]), ("Default", ["Default", "Default", "Default", "Default", "默认"]), @@ -294,6 +296,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Duplicate Spread", ["Duplicate Spread", "Duplicate Spread", "Duplicate Spread", "Duplicate Spread", "复制跨页"]), ("Email", ["Email", "Email", "Email", "Email", "电子邮件"]), ("Embed", ["Embed", "Embed", "Embed", "Embed", "嵌入"]), + ("Export PDF", ["PDF exportieren", "Exporter en PDF", "Exportar PDF", "PDF を書き出し", "导出PDF"]), ("Export XML…", ["Export XML…", "Export XML…", "Export XML…", "Export XML…", "导出XML…"]), ("Fast", ["Fast", "Fast", "Fast", "Fast", "快速"]), ( @@ -311,6 +314,10 @@ const TABLE: &[(&str, [&str; 5])] = &[ ), ("Fit Content to Frame", ["Fit Content to Frame", "Fit Content to Frame", "Fit Content to Frame", "Fit Content to Frame", "内容适合框架"]), ("Fitting:", ["Fitting:", "Fitting:", "Fitting:", "Fitting:", "适合:"]), + ( + "Flattener preset", + ["Voreinstellung für Transparenzreduzierung", "Préréglage d'aplatissement", "Preajuste de acoplado", "透明分割プリセット", "拼合器预设"], + ), ("Font Family:", ["Font Family:", "Font Family:", "Font Family:", "Font Family:", "字体系列:"]), ("Font Size:", ["Font Size:", "Font Size:", "Font Size:", "Font Size:", "字体大小:"]), ("Font Style:", ["Font Style:", "Font Style:", "Font Style:", "Font Style:", "字体样式:"]), @@ -328,6 +335,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Height", ["Height", "Height", "Height", "Height", "高度"]), ("Height:", ["Height:", "Height:", "Height:", "Height:", "高度:"]), ("High Quality", ["High Quality", "High Quality", "High Quality", "High Quality", "高品质"]), + ("Desktop Printing", ["Bürodruck", "Impression bureautique", "Impresión de oficina", "オフィス印刷", "办公打印"]), ( "Horizontal Gridline Every:", ["Horizontal Gridline Every:", "Horizontal Gridline Every:", "Horizontal Gridline Every:", "Horizontal Gridline Every:", "水平网格线间隔:"], @@ -336,6 +344,16 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Hyphenate", ["Hyphenate", "Hyphenate", "Hyphenate", "Hyphenate", "连字符"]), ("Import Options", ["Import Options", "Import Options", "Import Options", "Import Options", "导入选项"]), ("Import XML…", ["Import XML…", "Import XML…", "Import XML…", "Import XML…", "导入XML…"]), + ( + "Include document bleed", + [ + "Dokumentbeschnitt einschließen", + "Inclure le fond perdu du document", + "Incluir sangrado del documento", + "ドキュメントの塗り足しを含める", + "包含文档出血", + ], + ), ("Increment Every:", ["Increment Every:", "Increment Every:", "Increment Every:", "Increment Every:", "递增步长:"]), ("Ink Manager", ["Ink Manager", "Ink Manager", "Ink Manager", "Ink Manager", "油墨管理器"]), ("Insert Column", ["Insert Column", "Insert Column", "Insert Column", "Insert Column", "插入列"]), @@ -355,6 +373,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Load…", ["Load…", "Load…", "Load…", "Load…", "加载…"]), ("Make Compound", ["Make Compound", "Make Compound", "Make Compound", "Make Compound", "建立复合路径"]), ("Margins", ["Margins", "Margins", "Margins", "Margins", "边距"]), + ("Marks and Bleeds", ["Marken und Beschnitt", "Traits et fond perdu", "Marcas y sangrado", "トンボと塗り足し", "标记和出血"]), ("Maximum", ["Maximum", "Maximum", "Maximum", "Maximum", "最大"]), ("Menu Customization", ["Menu Customization", "Menu Customization", "Menu Customization", "Menu Customization", "菜单自定"]), ( @@ -425,6 +444,9 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("OpenType", ["OpenType", "OpenType", "OpenType", "OpenType", "OpenType"]), ("Orientation:", ["Orientation:", "Orientation:", "Orientation:", "Orientation:", "方向:"]), ("Outside", ["Outside", "Outside", "Outside", "Outside", "外侧"]), + ("Page Information", ["Seiteninformationen", "Informations sur la page", "Información de página", "ページ情報", "页面信息"]), + ("Page Range", ["Seitenbereich", "Plage de pages", "Intervalo de páginas", "ページ範囲", "页面范围"]), + ("Page Range:", ["Seitenbereich:", "Plage de pages :", "Intervalo de páginas:", "ページ範囲:", "页面范围:"]), ("Pages:", ["Pages:", "Pages:", "Pages:", "Pages:", "页面:"]), ("Paragraph Composer", ["Paragraph Composer", "Paragraph Composer", "Paragraph Composer", "Paragraph Composer", "段落排版器"]), ( @@ -432,6 +454,8 @@ const TABLE: &[(&str, [&str; 5])] = &[ ["Paragraph Style Options", "Paragraph Style Options", "Paragraph Style Options", "Paragraph Style Options", "段落样式选项"], ), ("Paragraph Style:", ["Paragraph Style:", "Paragraph Style:", "Paragraph Style:", "Paragraph Style:", "段落样式:"]), + ("PDF/A-2b", ["PDF/A-2b", "PDF/A-2b", "PDF/A-2b", "PDF/A-2b", "PDF/A-2b"]), + ("PDF/X-4", ["PDF/X-4", "PDF/X-4", "PDF/X-4", "PDF/X-4", "PDF/X-4"]), ("Place PDF", ["Place PDF", "Place PDF", "Place PDF", "Place PDF", "置入PDF"]), ("Plain Text", ["Plain Text", "Plain Text", "Plain Text", "Plain Text", "纯文本"]), ("Polygon Settings", ["Polygon Settings", "Polygon Settings", "Polygon Settings", "Polygon Settings", "多边形设置"]), @@ -441,6 +465,8 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Preferences", ["Preferences", "Preferences", "Preferences", "Preferences", "首选项"]), ("Prefix:", ["Prefix:", "Prefix:", "Prefix:", "Prefix:", "前缀:"]), ("Preset", ["Preset", "Preset", "Preset", "Preset", "预设"]), + ("Preset:", ["Voreinstellung:", "Préréglage :", "Preajuste:", "プリセット:", "预设:"]), + ("Commercial Printing", ["Druckerei", "Impression commerciale", "Impresión comercial", "商業印刷", "商业印刷"]), ("Print", ["Print", "Print", "Print", "Print", "打印"]), ("Printer:", ["Printer:", "Printer:", "Printer:", "Printer:", "打印机:"]), ("Printing / Exporting:", ["Printing / Exporting:", "Printing / Exporting:", "Printing / Exporting:", "Printing / Exporting:", "打印/导出:"]), @@ -508,11 +534,14 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Single-line", ["Single-line", "Single-line", "Single-line", "Single-line", "单行"]), ("Single-line Composer", ["Single-line Composer", "Single-line Composer", "Single-line Composer", "Single-line Composer", "单行排版器"]), ("Size:", ["Size:", "Size:", "Size:", "Size:", "大小:"]), + ("Screen and Email", ["Bildschirm und E-Mail", "Écran et e-mail", "Pantalla y correo", "画面とメール", "屏幕和电子邮件"]), ( "Space Between Footnotes:", ["Space Between Footnotes:", "Space Between Footnotes:", "Space Between Footnotes:", "Space Between Footnotes:", "脚注间距:"], ), ("Speed", ["Speed", "Speed", "Speed", "Speed", "速度"]), + ("Standard", ["Standard", "Norme", "Estándar", "標準", "标准"]), + ("Standard:", ["Standard:", "Norme :", "Estándar:", "標準:", "标准:"]), ("Star Inset:", ["Star Inset:", "Star Inset:", "Star Inset:", "Star Inset:", "星形内边距:"]), ("Start Page #:", ["Start Page #:", "Start Page #:", "Start Page #:", "Start Page #:", "起始页码:"]), ("Start at:", ["Start at:", "Start at:", "Start at:", "Start at:", "起始编号:"]), @@ -524,6 +553,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Suffix:", ["Suffix:", "Suffix:", "Suffix:", "Suffix:", "后缀:"]), ("Synchronize", ["Synchronize", "Synchronize", "Synchronize", "Synchronize", "同步"]), ("Tag:", ["Tag:", "Tag:", "Tag:", "Tag:", "标签:"]), + ("Tagged PDF", ["Markiertes PDF", "PDF balisé", "PDF etiquetado", "タグ付き PDF", "带标签的 PDF"]), ("Text Frame", ["Text Frame", "Text Frame", "Text Frame", "Text Frame", "文本框架"]), ("Text Frame Options", ["Text Frame Options", "Text Frame Options", "Text Frame Options", "Text Frame Options", "文本框架选项"]), ("Text Message", ["Text Message", "Text Message", "Text Message", "Text Message", "短信"]), @@ -735,6 +765,8 @@ const TABLE: &[(&str, [&str; 5])] = &[ "Community & Project Links", ["Community & Project Links", "Community & Project Links", "Community & Project Links", "Community & Project Links", "社区与项目链接"], ), + ("Compress images", ["Bilder komprimieren", "Compresser les images", "Comprimir imágenes", "画像を圧縮", "压缩图像"]), + ("Compression", ["Komprimierung", "Compression", "Compresión", "圧縮", "压缩"]), ("Conditions", ["Conditions", "Conditions", "Conditions", "Conditions", "条件"]), ("Container", ["Container", "Container", "Container", "Container", "容器"]), ("Content", ["Content", "Content", "Content", "Content", "内容"]), @@ -3002,4 +3034,19 @@ mod tests { assert_eq!(tr("ja", en), ja); } } + + #[test] + fn pdf_export_dialog_strings_are_translated() { + // Translated labels in every supported language. + assert_ne!(tr("zh", "Crop Marks"), "Crop Marks"); + assert_ne!(tr("de", "Tagged PDF"), "Tagged PDF"); + assert_ne!(tr("ar", "Include document bleed"), "Include document bleed"); + assert_ne!(tr("pt-br", "Compress images"), "Compress images"); + // English returns the key unchanged. + assert_eq!(tr("", "Export PDF"), "Export PDF"); + // An unknown string still falls through unchanged. + assert_eq!(tr("de", "Low Quality Print"), "Low Quality Print"); + // Fixed standard names stay identical in every language. + assert_eq!(tr("ja", "PDF/X-4"), "PDF/X-4"); + } } diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 1b0c72975..10e393cd2 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -776,6 +776,26 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Ruby", "روبي"), ("Show Note Anchors in Layout View", "إظهار مراسي الملاحظات في عرض التخطيط"), ("Spreads", "صفحات متقابلة"), + ("Export PDF", "تصدير PDF"), + ("Standard", "معيار"), + ("Standard:", "المعيار:"), + ("Compress images", "ضغط الصور"), + ("Flattener preset", "إعداد تسوية الشفافية المسبق"), + ("Include document bleed", "تضمين نزف المستند"), + ("Crop Marks", "علامات القص"), + ("Bleed Marks", "علامات النزف"), + ("Page Information", "معلومات الصفحة"), + ("Page Range", "نطاق الصفحات"), + ("Page Range:", "نطاق الصفحات:"), + ("Tagged PDF", "PDF موسوم"), + ("Desktop Printing", "طباعة مكتبية"), + ("Commercial Printing", "طباعة تجارية"), + ("Screen and Email", "الشاشة والبريد الإلكتروني"), + ("PDF/X-4", "PDF/X-4"), + ("PDF/A-2b", "PDF/A-2b"), + ("Compression", "ضغط"), + ("Marks and Bleeds", "العلامات والنزف"), + ("Preset:", "الإعداد المسبق:"), ("Substituted Fonts", "الخطوط المستبدلة"), ("Transformations are Totals", "عرض قيم التحويل الإجمالية"), ("Use Black Point Compensation", "استخدام تعويض النقطة السوداء"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 8325b52c8..d25717dc6 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -778,6 +778,26 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Ruby", "Ruby"), ("Show Note Anchors in Layout View", "Mostrar âncoras de notas na exibição de layout"), ("Spreads", "Páginas duplas"), + ("Export PDF", "Exportar PDF"), + ("Standard", "Padrão"), + ("Standard:", "Padrão:"), + ("Compress images", "Compactar imagens"), + ("Flattener preset", "Predefinição de achatamento"), + ("Include document bleed", "Incluir sangria do documento"), + ("Crop Marks", "Marcas de corte"), + ("Bleed Marks", "Marcas de sangria"), + ("Page Information", "Informações da página"), + ("Page Range", "Intervalo de páginas"), + ("Page Range:", "Intervalo de páginas:"), + ("Tagged PDF", "PDF marcado"), + ("Desktop Printing", "Impressão de escritório"), + ("Commercial Printing", "Impressão comercial"), + ("Screen and Email", "Tela e e-mail"), + ("PDF/X-4", "PDF/X-4"), + ("PDF/A-2b", "PDF/A-2b"), + ("Compression", "Compactação"), + ("Marks and Bleeds", "Marcas e sangria"), + ("Preset:", "Predefinição:"), ("Substituted Fonts", "Fontes substituídas"), ("Transformations are Totals", "Transformações são totais"), ("Use Black Point Compensation", "Usar compensação de ponto preto"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 69c2443fe..fb81e9c34 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -109,6 +109,49 @@ fn default_zone() -> f64 { 4.0 } +/// File › Export PDF… options: the values persisted in `UiState.pdf_export` and used as the +/// dialog's defaults. Serialised as `pdfExport` with camelCase field names. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct PdfExportSettings { + /// Preset (one of the five presets, or "Custom" after a manual edit). + pub preset: String, + /// PDF standard (`none`, `x4`, `a2b`). + pub standard: String, + /// Compression › Compress images (JPEG). + pub compress_images: bool, + /// Transparency flattener preset (`""` = none, `high`, `medium`, `low`). + pub flatten: String, + pub spreads: bool, + pub bleed: bool, + pub marks_crop: bool, + pub marks_bleed: bool, + pub marks_page_info: bool, + pub marks_weight: String, + pub marks_offset: String, + /// File › Export PDF › Advanced › Tagged PDF. + pub tagged: bool, +} + +impl Default for PdfExportSettings { + fn default() -> Self { + PdfExportSettings { + preset: "Desktop Printing".into(), + standard: "none".into(), + compress_images: false, + flatten: "".into(), + spreads: false, + bleed: true, + marks_crop: false, + marks_bleed: false, + marks_page_info: false, + marks_weight: "0.25".into(), + marks_offset: "6".into(), + tagged: true, + } + } +} + /// Persisted UI state. #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -149,6 +192,8 @@ pub struct UiState { pub language: String, /// Edit › Transparency Flattener Presets: "" (none), "high", "medium" or "low" for PDF export. pub flattener: String, + /// File › Export PDF… options (persisted; see `PdfExportSettings`). + pub pdf_export: PdfExportSettings, /// View › Separations Preview: a process plate (0–3) and/or an ink limit (total, 0–4). #[serde(skip)] pub separation: Option<u8>, @@ -252,6 +297,7 @@ impl Default for UiState { dynamic_spelling: false, language: String::new(), flattener: String::new(), + pdf_export: PdfExportSettings::default(), separation: None, ink_limit: None, display_quality: designcraft_render::DisplayQuality::High, @@ -1124,6 +1170,38 @@ mod tests { assert_eq!(ui.snap_view().zone_px, 0.0); } + #[test] + fn pdf_export_settings_round_trip() { + let ui = UiState::default(); + let json = serde_json::to_string(&ui).unwrap(); + let parsed: UiState = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.pdf_export, PdfExportSettings::default()); + } + + #[test] + fn pdf_export_settings_defaults() { + let settings = PdfExportSettings::default(); + assert_eq!(settings.preset, "Desktop Printing"); + assert_eq!(settings.standard, "none"); + assert!(!settings.compress_images); + assert_eq!(settings.flatten, ""); + assert!(!settings.spreads); + assert!(settings.bleed); + assert!(!settings.marks_crop); + assert!(!settings.marks_bleed); + assert!(!settings.marks_page_info); + assert_eq!(settings.marks_weight, "0.25"); + assert_eq!(settings.marks_offset, "6"); + assert!(settings.tagged); + } + + #[test] + fn pdf_export_settings_survive_missing_key() { + let json = r#"{ "language": "en" }"#; + let ui: UiState = serde_json::from_str(json).unwrap(); + assert_eq!(ui.pdf_export, PdfExportSettings::default()); + } + /// A temporary file named `name` whose bytes aren't a document or a graphic. fn unreadable_file(name: &str) -> std::path::PathBuf { let path = std::env::temp_dir().join(format!("designcraft-ui-{}-{name}", std::process::id())); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 13e42e2fe..94e5c0971 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -973,7 +973,14 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value, app.ui.open_panel = if app.ui.open_panel.as_deref() == Some("table") { None } else { Some("table".into()) }; Ok(Value::Null) } - "app.exportPdf" => export_pdf(app, p), + "app.exportPdf" => { + if p.is_null() || p.as_object().is_none_or(|m| m.is_empty()) { + crate::dialogs::open_pdf_export(app); + Ok(Value::Null) + } else { + export_pdf(app, p) + } + } "app.palette" => { app.ui.palette = Some(String::new()); Ok(Value::Null) @@ -1531,7 +1538,7 @@ fn export_bytes(app: &mut DesignApp, p: &Value, ext: &str, cmd: &str) -> Result< /// File › Export PDF…: ask for a path, export through `file.exportPdf` and write the bytes with the /// platform writer (a download on the web). -fn export_pdf(app: &mut DesignApp, p: &Value) -> Result<Value, String> { +pub(crate) fn export_pdf(app: &mut DesignApp, p: &Value) -> Result<Value, String> { let st = app.session.active().ok_or("no document")?; let path = match p.get("path").and_then(Value::as_str) { Some(s) => Some(s.to_string()), @@ -2347,6 +2354,18 @@ mod tests { } } + #[test] + fn pdf_export_menu_item_opens_the_dialog_or_exports() { + // No params: the menu item opens the options dialog. + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + activate(&mut app, "app.exportPdf", &Value::Null); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("pdfExport")); + // Explicit options: programmatic callers still export directly, opening no dialog. + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + activate(&mut app, "app.exportPdf", &json!({"path": "/tmp/x.pdf"})); + assert!(app.ui.dialog.is_none()); + } + #[test] fn snap_preferences_are_a_command() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From fc2ce5556f0e5fa93d1678cadc57792acd434591 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 10 Oct 2026 04:27:59 -0400 Subject: [PATCH 013/238] Text: Split Columns sets a paragraph in sub-columns within its column (#295) Consecutive paragraphs with the same Split Columns settings form a split block: their text fills the sub-columns of the column one after the other from the block's top, inset by the outside gutter and separated by the inside gutter, then moves on to the next column. When text follows the block, its last part is balanced across the sub-columns and the text continues below the deepest one at the full column width; a block that ends the story fills its sub-columns in turn. Keep options don't apply between a block's lines. IDML: SplitColumnInsideGutter and SplitColumnOutsideGutter import and export (paragraph attributes split_inside_gutter, split_outside_gutter). Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --- crates/compose/src/lib.rs | 259 +++++++++++++++++++++++++++++++++--- crates/compose/src/tests.rs | 99 ++++++++++++++ crates/doc/src/attrs.rs | 3 + crates/idml/src/export.rs | 2 + crates/idml/src/import.rs | 2 + crates/idml/src/tests.rs | 17 +++ 6 files changed, 364 insertions(+), 18 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index ebb4bbd62..3dd7c25b3 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -493,6 +493,8 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C pi: 0, band: Band::default(), limits: Rc::default(), + split: None, + split_limits: Rc::default(), }; let para_ranges = story.para_ranges(); let np = para_ranges.len(); @@ -534,12 +536,20 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let mut trial: Option<Trial> = None; let mut balance_runs = 0usize; let balance_budget = 20 * story.paras.iter().filter(|p| matches!(doc.styles.resolve_para(p).0.span_columns, SpanColumns::Span(_))).count(); - // Paragraphs set across columns (vertical justification moves their frames' lines together). + // Balancing split-column blocks that text follows. + let mut split_limits: Rc<Vec<(SplitKey, f64)>> = Rc::default(); + let mut split_trial: Option<Trial<SplitKey>> = None; + let mut split_runs = 0usize; + let split_budget = 20 * story.paras.iter().filter(|p| matches!(doc.styles.resolve_para(p).0.span_columns, SpanColumns::Split(_))).count(); + // Paragraphs set across columns (vertical justification moves their frames' lines together), + // and in split columns. let mut span_paras = vec![false; np]; + let mut split_paras = vec![false; np]; 'paras: while pi < np { let prange = para_ranges[pi].clone(); cur.pi = pi; cur.limits = Rc::clone(&limits); + cur.split_limits = Rc::clone(&split_limits); let snap = Snapshot::take(&out, &cur, list_counter); snaps.truncate(pi); snaps.push(snap); @@ -662,6 +672,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C // look again (the style changes the widths) until the lines settle. let cols_here = &cols[cur.fi]; let col = span_rect(cols_here, cols_here[cur.col.min(cols_here.len() - 1)], pp.span_columns); + let col = SplitCfg::of(&pp).map_or(col, |c| c.sub_rect(col, 0)); let spacing = spacing_for(&pp, base_chars.size); let mut lines: Vec<(std::ops::Range<usize>, String)> = Vec::new(); for _ in 0..3 { @@ -762,7 +773,22 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C // the next column. let spanning = matches!(pp.span_columns, SpanColumns::Span(n) if n != 1); span_paras[pi] = spanning; - let next_column = |cur: &mut Cursor| if spanning { cur.next_frame() } else { cur.next_column(&cols) }; + // Consecutive paragraphs with the same Split Columns settings share a split block; within + // it, a paragraph moves on to the next sub-column. + let split_cfg = SplitCfg::of(&pp); + split_paras[pi] = split_cfg.is_some(); + if cur.split.is_some_and(|s| Some(s.cfg) != split_cfg) { + cur.split = None; + } + let next_column = |cur: &mut Cursor| { + if spanning { + cur.next_frame() + } else if cur.can_next_sub() { + cur.next_sub() + } else { + cur.next_column(&cols) + } + }; // A paragraph start or a break character; Next Column while a paragraph spans columns is // the next frame, as above. let begin_at = |cur: &mut Cursor, start: StartParagraph| match start { @@ -825,6 +851,13 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C if cur.last_baseline.is_some() { cur.pending += pp.space_before; } + if let Some(cfg) = split_cfg + && cur.split.is_none() + { + let start = cur.state(); + cur.split = Some(SplitBlock { cfg, sub: 0, sub_lines: 0, part_lines: 0, line0: None, para: pi, start, top: None }); + cur.split_part(); + } let at_top = cur.last_baseline.is_none(); let start_at = (cur.fi, cur.col); // Rule above / shading track the paragraph's first line. @@ -852,6 +885,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let col = cur.clip(base); // A spanning paragraph isn't held to the balanced bottom of the columns above it. let col = if spanning { span_rect(&cols[cur.fi], Rect::new(col.x0, col.y0, col.x1, base.y1), pp.span_columns) } else { col }; + let col = cur.split_rect(col); // Estimate slots for the breaker with the paragraph's base leading. let est_first = cur.next_baseline(f, col, base_leading, base_chars.size * 0.75, &pp); let slots = estimate_slots(f, col, est_first, base_leading, base_chars.size, &glyphs[g0..], &pp, line_no); @@ -914,7 +948,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C } } } - let capped = line_cap[pi] == Some(line_no) && line_no > col_first_line; + let capped = cur.split.is_none() && line_cap[pi] == Some(line_no) && line_no > col_first_line; // Footnotes referenced on this line need room at the bottom of the column too // (a line at the top of a column is set anyway). let line_notes = if notes.active() { notes.refs_in(line_glyphs) } else { Vec::new() }; @@ -923,7 +957,32 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let reserve = if cur.last_baseline.is_none() { notes.reserve(doc, cur.fi, cur.col, &[], col_w, f, opts) } else { reserve }; let fits = baseline + desc <= col.y1 - reserve + 0.01 && !capped; if !fits { - if !capped { + if cur.can_next_sub() { + g0 = s; + cur.next_sub(); + col_first_line = line_no; + moved = true; + break; + } + // A split block that doesn't fit above its trial bottom: re-lay it lower. + if let Some(key) = cur.split_key() + && limit_of(&split_limits, key).is_some_and(f64::is_finite) + { + let j = if split_trial.as_ref().is_some_and(|t| t.key == key) { + trial_failed(&mut split_trial, &mut split_limits, split_runs < split_budget) + } else { + set_limit(&mut split_limits, key, Some(f64::INFINITY)); + Some(key.2) + }; + if let Some(j) = j { + split_runs += 1; + rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap); + pi = j; + continue 'paras; + } + } + // Keep options don't apply between a split block's lines. + if !capped && cur.split.is_none_or(|s| s.part_lines == 0) { let ctx = KeepCtx { pi, line_no, @@ -988,6 +1047,11 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C if line_no == 0 { first_line_rect = Some((cur.fi, baseline, asc, x0)); } + if let Some(sb) = cur.split.as_mut() { + sb.line0.get_or_insert(ft.lines.len()); + sb.sub_lines += 1; + sb.part_lines += 1; + } ft.lines.push(Line { column: cur.col as u32, baseline, @@ -1099,6 +1163,47 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let (x0, x1) = if r.column_width { (l.x0, l.x1) } else { (l.x0, l.end_x) }; ft.decos.push(Deco { rect: Rect::new(x0 + r.left_indent, y, x1 - r.right_indent, y + r.weight), color: r.color.clone(), tint: r.tint }); } + // The end of a split block: text after it continues below its deepest sub-column, which + // are balanced first. A block that ends the story fills its sub-columns in turn. + if let Some(sb) = cur.split { + let next = + story.paras.get(pi + 1).filter(|_| story.para_table(pi + 1).is_none()).and_then(|p| SplitCfg::of(&doc.styles.resolve_para(p).0)); + if next != Some(sb.cfg) { + cur.split = None; + let part = out.frames.get(cur.fi).and_then(|ft| { + let lines = ft.lines.get(sb.line0?..)?; + let top = lines.first().map(|l| l.baseline - l.ascent)?; + let deepest = lines.iter().map(|l| (l.baseline, l.descent)).max_by(|a, b| (a.0 + a.1).total_cmp(&(b.0 + b.1)))?; + Some((sb.top.unwrap_or(top), deepest)) + }); + if pi + 1 < np + && let Some((top, (baseline, descent))) = part + { + let key = (cur.fi, cur.col, sb.para); + let active = split_trial.as_ref().is_some_and(|t| t.key == key); + if !active && let Some(t) = split_trial.take() { + set_limit(&mut split_limits, t.key, None); + } + if sb.part_lines > 1 && (active || limit_of(&split_limits, key).is_none() && split_runs < split_budget) { + let bottom = baseline + descent; + let mut t = split_trial.take().unwrap_or(Trial { key, span: pi, lo: top, hi: bottom, tries: 0, done: false }); + // This layout fits: its bottom is the best so far. + t.hi = t.hi.min(bottom); + let next = if t.done { t.hi } else { t.next() }; + set_limit(&mut split_limits, key, Some(next)); + if !t.done { + split_trial = Some(t); + split_runs += 1; + rewind(key.2, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap); + pi = key.2; + continue 'paras; + } + } + cur.last_baseline = Some(baseline); + cur.last_descent = descent; + } + } + } cur.pending += pp.space_after; pi += 1; } @@ -1108,13 +1213,16 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let f = &frames[fi]; if let (Some(a), Some(z)) = (ft.lines.first(), ft.lines.last()) { ft.range = a.range.start..z.range.end; - ft.content_height = z.baseline + z.descent - f.area.y0; + // The deepest line of the last column (split columns can end higher up). + let bottom = ft.lines.iter().filter(|l| l.column == z.column).map(|l| l.baseline + l.descent).fold(z.baseline + z.descent, f64::max); + ft.content_height = bottom - f.area.y0; } else { let p = ft_prev_end(&out.overset_at, story.text.len()); ft.range = p..p; } let before: Vec<f64> = ft.tables.iter().map(|t| ft.lines.get(t.line).map_or(0.0, |l| l.baseline)).collect(); - let spanned = ft.columns.len() > 1 && ft.lines.iter().any(|l| span_paras.get(l.para) == Some(&true)); + let spanned = ft.columns.len() > 1 && ft.lines.iter().any(|l| span_paras.get(l.para) == Some(&true)) + || ft.lines.iter().any(|l| split_paras.get(l.para) == Some(&true)); vertical_justify(ft, f, spanned); for (t, b) in ft.tables.iter_mut().zip(before) { let now = ft.lines.get(t.line).map_or(b, |l| l.baseline); @@ -1253,11 +1361,10 @@ fn para_box_decos(out: &mut ComposedStory, pi: usize, pp: &ParaProps) { } let mut parts: Vec<(usize, Rect)> = Vec::new(); for (fi, ft) in out.frames.iter().enumerate() { - let mut cols_seen: Vec<u32> = ft.lines.iter().filter(|l| l.para == pi).map(|l| l.column).collect(); - cols_seen.dedup(); - for c in cols_seen { - let lines: Vec<&Line> = ft.lines.iter().filter(|l| l.para == pi && l.column == c).collect(); - if let (Some(a), Some(z)) = (lines.first(), lines.last()) { + // A part ends where the next line moves to another column or sub-column. + let lines: Vec<&Line> = ft.lines.iter().filter(|l| l.para == pi).collect(); + for part in lines.chunk_by(|a, b| a.column == b.column && b.baseline > a.baseline) { + if let (Some(a), Some(z)) = (part.first(), part.last()) { parts.push((fi, Rect::new(a.x0, a.baseline - a.ascent, a.x1, z.baseline + z.descent))); } } @@ -1621,11 +1728,88 @@ struct Cursor { band: Band, /// Column bottoms that balance bands, by band key (kept by the compose loop). limits: Rc<Vec<(BandKey, f64)>>, + /// The split-column block the text is in. + split: Option<SplitBlock>, + /// Bottoms that balance split blocks, by block part (kept by the compose loop). + split_limits: Rc<Vec<(SplitKey, f64)>>, } /// A band's frame and first paragraph. type BandKey = (usize, usize); +/// A split block's part in one column: frame, column and the paragraph it starts in. +type SplitKey = (usize, usize, usize); + +/// What a balancing trial re-lays from. +trait TrialKey: Copy + PartialEq { + fn para(&self) -> usize; +} + +impl TrialKey for BandKey { + fn para(&self) -> usize { + self.1 + } +} + +impl TrialKey for SplitKey { + fn para(&self) -> usize { + self.2 + } +} + +/// Split Columns settings: sub-columns, inside and outside gutters. +#[derive(Clone, Copy, Debug, PartialEq)] +struct SplitCfg { + n: usize, + inside: f64, + outside: f64, +} + +impl SplitCfg { + fn of(pp: &ParaProps) -> Option<SplitCfg> { + let SpanColumns::Split(n) = pp.span_columns else { return None }; + let gutter = |g: f64| if g.is_finite() { g.max(0.0) } else { 0.0 }; + Some(SplitCfg { n: (n as usize).clamp(2, 64), inside: gutter(pp.split_inside_gutter), outside: gutter(pp.split_outside_gutter) }) + } + /// Sub-column `sub` of `col`. + fn sub_rect(&self, col: Rect, sub: usize) -> Rect { + let (x0, x1) = (col.x0 + self.outside, col.x1 - self.outside); + let w = ((x1 - x0 - self.inside * (self.n - 1) as f64) / self.n as f64).max(1.0); + let x = x0 + sub.min(self.n - 1) as f64 * (w + self.inside); + Rect::new(x, col.y0, x + w, col.y1) + } +} + +/// Where the cursor resumes: the line above (if any) and the space waiting below it. +#[derive(Clone, Copy, Debug)] +struct Resume { + last_baseline: Option<f64>, + last_descent: f64, + last_reference: f64, + pending: f64, +} + +/// Consecutive paragraphs with the same Split Columns settings, set in sub-columns of their +/// column: text fills the sub-columns one after the other from the block's top, then moves on to +/// the next column. Text after the block continues below its deepest sub-column. +#[derive(Clone, Copy, Debug)] +struct SplitBlock { + cfg: SplitCfg, + /// The sub-column the text is in. + sub: usize, + /// Lines set in the current sub-column, and in the block's part in this column. + sub_lines: usize, + part_lines: usize, + /// The part's first line in its frame. + line0: Option<usize>, + /// The paragraph the part starts in (balancing re-lays from there). + para: usize, + /// Every sub-column starts from here. + start: Resume, + /// The part's top. + top: Option<f64>, +} + /// A run of the frame's columns: the whole frame, or the part between spanning paragraphs. /// Text fills its columns one after the other; a spanning paragraph closes it. #[derive(Clone, Copy, Debug, Default)] @@ -1652,8 +1836,8 @@ struct Above { /// Balancing the columns above a spanning paragraph: a search for the lowest column bottom at /// which the band's text still fits its frame, re-laying the band at each trial bottom. #[derive(Debug)] -struct Trial { - key: BandKey, +struct Trial<K = BandKey> { + key: K, /// The spanning paragraph that closes the band. span: usize, /// Highest bottom known not to fit / lowest known to fit. @@ -1664,7 +1848,7 @@ struct Trial { done: bool, } -impl Trial { +impl<K> Trial<K> { /// The next bottom to try: half way, or `hi` once settled. fn next(&mut self) -> f64 { self.tries += 1; @@ -1680,7 +1864,7 @@ impl Trial { /// A balancing trial whose band didn't fit its frame: records the next bottom to try in /// `limits` (or, when even the settled bottom fails or the budget is spent, leaves the band /// unbalanced) and returns the paragraph to re-lay from. -fn trial_failed(trial: &mut Option<Trial>, limits: &mut Rc<Vec<(BandKey, f64)>>, budget_left: bool) -> Option<usize> { +fn trial_failed<K: TrialKey>(trial: &mut Option<Trial<K>>, limits: &mut Rc<Vec<(K, f64)>>, budget_left: bool) -> Option<usize> { let t = trial.as_mut()?; let key = t.key; let bottom = if t.done || !budget_left { @@ -1691,10 +1875,10 @@ fn trial_failed(trial: &mut Option<Trial>, limits: &mut Rc<Vec<(BandKey, f64)>>, t.next() }; set_limit(limits, key, Some(bottom)); - Some(key.1) + Some(key.para()) } -fn set_limit(limits: &mut Rc<Vec<(BandKey, f64)>>, key: BandKey, bottom: Option<f64>) { +fn set_limit<K: TrialKey>(limits: &mut Rc<Vec<(K, f64)>>, key: K, bottom: Option<f64>) { let v = Rc::make_mut(limits); v.retain(|(k, _)| *k != key); if let Some(b) = bottom { @@ -1702,7 +1886,7 @@ fn set_limit(limits: &mut Rc<Vec<(BandKey, f64)>>, key: BandKey, bottom: Option< } } -fn limit_of(limits: &[(BandKey, f64)], key: BandKey) -> Option<f64> { +fn limit_of<K: TrialKey>(limits: &[(K, f64)], key: K) -> Option<f64> { limits.iter().find(|(k, _)| *k == key).map(|&(_, b)| b) } @@ -1713,6 +1897,7 @@ impl Cursor { self.next_frame(); } else { self.resume(); + self.split_part(); } } fn next_frame(&mut self) { @@ -1720,6 +1905,44 @@ impl Cursor { self.col = 0; self.band = Band { para: self.pi, ..Band::default() }; self.resume(); + self.split_part(); + } + fn state(&self) -> Resume { + Resume { last_baseline: self.last_baseline, last_descent: self.last_descent, last_reference: self.last_reference, pending: self.pending } + } + /// Start a split block (or its part in a new column) here. + fn split_part(&mut self) { + let start = self.state(); + let top = start.last_baseline.map(|b| b + start.last_descent); + let para = self.pi; + if let Some(s) = self.split.as_mut() { + *s = SplitBlock { sub: 0, sub_lines: 0, part_lines: 0, line0: None, para, start, top, ..*s }; + } + } + /// Move on to the next sub-column of the split block, back at the block's top. + fn next_sub(&mut self) { + let Some(s) = self.split.as_mut() else { return }; + s.sub += 1; + s.sub_lines = 0; + let r = s.start; + self.last_baseline = r.last_baseline; + self.last_descent = r.last_descent; + self.last_reference = r.last_reference; + self.pending = r.pending; + } + /// Within a split block, the next sub-column when there's one to go to. + fn can_next_sub(&self) -> bool { + self.split.is_some_and(|s| s.sub_lines > 0 && s.sub + 1 < s.cfg.n) + } + fn split_key(&self) -> Option<SplitKey> { + self.split.map(|s| (self.fi, self.col, s.para)) + } + /// The sub-column the split block's text is in, within `col` and above its balanced bottom. + fn split_rect(&self, col: Rect) -> Rect { + let Some(s) = self.split else { return col }; + let r = s.cfg.sub_rect(col, s.sub); + let y1 = self.split_key().and_then(|k| limit_of(&self.split_limits, k)).map_or(col.y1, |b| b.min(col.y1).max(col.y0)); + Rect::new(r.x0, r.y0, r.x1, y1) } /// Start the current column at the top of the band. fn resume(&mut self) { diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 0013d9865..0d1fe2d27 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -2602,6 +2602,105 @@ fn spanning_paragraph_in_rtl_and_vertical_frames() { assert!(cs.frames[0].lines.iter().any(|l| l.para == h)); } +// ---------- split columns ---------- + +/// A story of a body paragraph, `split` paragraphs in split columns and `after` body paragraphs. +/// Returns the index of the first split paragraph. +fn split_doc(split: usize, after: usize, rect: Rect, cfg: (u32, f64, f64)) -> (Document, StoryId, ItemId, usize) { + let mut paras: Vec<&str> = vec![LOREM; 1 + split + after]; + paras[0] = "An introduction set at the full measure of the column, before the split block."; + let (mut d, sid, fid) = doc_with(¶s.join("\n"), rect, ParaAttrs::default()); + let st = d.story_mut(sid).unwrap(); + for p in &mut st.paras[1..=split] { + p.para.span_columns = Some(SpanColumns::Split(cfg.0)); + p.para.split_inside_gutter = Some(cfg.1); + p.para.split_outside_gutter = Some(cfg.2); + } + (d, sid, fid, 1) +} + +/// The sub-columns of `col`: (x0, x1) of each. +fn sub_columns(col: Rect, (n, inside, outside): (u32, f64, f64)) -> Vec<(f64, f64)> { + let w = (col.width() - 2.0 * outside - inside * (n - 1) as f64) / n as f64; + (0..n).map(|k| col.x0 + outside + k as f64 * (w + inside)).map(|x| (x, x + w)).collect() +} + +/// The split paragraphs' lines, by sub-column. +fn split_lines<'a>(ft: &'a FrameText, paras: std::ops::Range<usize>, subs: &[(f64, f64)]) -> Vec<Vec<&'a Line>> { + let mut by_sub = vec![Vec::new(); subs.len()]; + for l in ft.lines.iter().filter(|l| paras.contains(&l.para)) { + let k = subs.iter().position(|&(x0, x1)| (l.x0 - x0).abs() < 1e-6 && (l.x1 - x1).abs() < 1e-6); + let k = k.unwrap_or_else(|| panic!("line of para {} at {}..{} is in no sub-column of {subs:?}", l.para, l.x0, l.x1)); + by_sub[k].push(l); + } + by_sub +} + +#[test] +fn split_paragraphs_are_set_in_balanced_sub_columns() { + for cfg in [(2, 12.0, 10.0), (3, 6.0, 0.0)] { + let (d, sid, _, s) = split_doc(2, 1, Rect::new(0.0, 0.0, 400.0, 700.0), cfg); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(!cs.is_overset()); + let ft = &cs.frames[0]; + assert_no_overlap(ft); + let by_sub = split_lines(ft, s..s + 2, &sub_columns(ft.columns[0], cfg)); + let counts: Vec<usize> = by_sub.iter().map(Vec::len).collect(); + assert!(counts.iter().all(|&n| n > 0), "{cfg:?}: {counts:?}"); + assert!(counts.iter().max().unwrap() - counts.iter().min().unwrap() <= 1, "{cfg:?}: balanced {counts:?}"); + // Every sub-column starts on the same baseline, below the paragraph before the block. + let firsts: Vec<f64> = by_sub.iter().map(|v| v[0].baseline).collect(); + assert!(firsts.iter().all(|b| (b - firsts[0]).abs() < 1e-6), "{cfg:?}: {firsts:?}"); + let intro = ft.lines.iter().filter(|l| l.para == 0).map(|l| line_box(l).1).fold(f64::NEG_INFINITY, f64::max); + assert!(firsts[0] > intro && firsts[0] < intro + 20.0, "{cfg:?}: block at {} after {intro}", firsts[0]); + // The text after the block continues at the full measure below its deepest sub-column. + let bottom = by_sub.iter().flatten().map(|l| line_box(l).1).fold(f64::NEG_INFINITY, f64::max); + let next = ft.lines.iter().find(|l| l.para == s + 2).unwrap(); + assert!(line_box(next).0 >= bottom - 0.5 && line_box(next).0 < bottom + 20.0, "{cfg:?}: {} after {bottom}", line_box(next).0); + assert!((next.x0 - ft.columns[0].x0).abs() < 1e-6 && (next.x1 - ft.columns[0].x1).abs() < 1e-6); + } +} + +#[test] +fn split_block_in_the_second_column_and_at_the_story_end() { + let cfg = (2, 12.0, 0.0); + let (mut d, sid, fid, s) = split_doc(3, 0, Rect::new(0.0, 0.0, 540.0, 400.0), cfg); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2; + d.story_mut(sid).unwrap().paras[s].para.start_paragraph = Some(StartParagraph::NextColumn); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(!cs.is_overset()); + let ft = &cs.frames[0]; + assert_no_overlap(ft); + assert!(ft.lines.iter().filter(|l| l.para >= s).all(|l| l.column == 1)); + let by_sub = split_lines(ft, s..s + 3, &sub_columns(ft.columns[1], cfg)); + // Nothing follows the block: it fills its first sub-column to the bottom of the frame, then + // the next. + let (a, b) = (&by_sub[0], &by_sub[1]); + assert!(!a.is_empty() && !b.is_empty() && a.len() > b.len() + 1, "{} and {} lines", a.len(), b.len()); + let low = a.last().unwrap(); + assert!(low.baseline + low.descent > 400.0 - low.leading, "the first sub-column ends at {}", low.baseline); + assert!((a[0].baseline - b[0].baseline).abs() < 1e-6); +} + +#[test] +fn split_block_flows_into_the_next_column() { + let cfg = (2, 12.0, 0.0); + let (mut d, sid, fid, s) = split_doc(6, 1, Rect::new(0.0, 0.0, 540.0, 200.0), cfg); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2; + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let ft = &cs.frames[0]; + assert_no_overlap(ft); + for c in 0..2 { + let in_col: Vec<&Line> = ft.lines.iter().filter(|l| l.column == c as u32).collect(); + let lines: Vec<&Line> = in_col.iter().copied().filter(|l| (s..s + 6).contains(&l.para)).collect(); + let subs = sub_columns(ft.columns[c], cfg); + assert!(subs.iter().all(|&(x0, x1)| lines.iter().any(|l| (l.x0 - x0).abs() < 1e-6 && (l.x1 - x1).abs() < 1e-6)), "column {c}"); + } + // The block continues at the top of the second column. + let top = ft.lines.iter().find(|l| l.column == 1).unwrap(); + assert!((s..s + 6).contains(&top.para) && top.baseline < 20.0); +} + /// Each paragraph's list label as text: its label glyphs (laid before the paragraph's own text) /// matched back to characters of the label font; tabs and other invisible glyphs are skipped. fn list_labels(cs: &ComposedStory, alphabet: &str) -> Vec<String> { diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index a1f723d1f..b99740e2c 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -860,6 +860,9 @@ attr_set! { keep_last: u32 = 2, start_paragraph: StartParagraph = StartParagraph::Anywhere, span_columns: SpanColumns = SpanColumns::Single, + /// Split Columns: the space between sub-columns and the inset from the column's edges. + split_inside_gutter: f64 = 6.0, + split_outside_gutter: f64 = 0.0, rule_above: Rule = Rule::default(), rule_below: Rule = Rule::default(), tabs: Vec<TabStop> = Vec::new(), diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 25567e443..9d73562b9 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1438,6 +1438,8 @@ impl<'a> Ex<'a> { } } } + n!(split_inside_gutter, "SplitColumnInsideGutter"); + n!(split_outside_gutter, "SplitColumnOutsideGutter"); if let Some(r) = &a.rule_above { self.rule(el, props, "RuleAbove", r); } diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index fb2378446..d85d9ee02 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1327,6 +1327,8 @@ impl<'r> Importer<'r> { _ => SpanColumns::Single, }); } + a.split_inside_gutter = e.num("SplitColumnInsideGutter").filter(|v| v.is_finite()); + a.split_outside_gutter = e.num("SplitColumnOutsideGutter").filter(|v| v.is_finite()); a.rule_above = self.rule(e, "RuleAbove"); a.rule_below = self.rule(e, "RuleBelow"); if let Some(tl) = e.prop_el("TabList") { diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 163fd0b26..7b747587a 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -1340,6 +1340,23 @@ fn layers_import_and_export_in_stacking_order() { assert_eq!(back.spreads[0].items[0].layer, back.layers[1].id); } +#[test] +fn split_columns_import_and_round_trip() { + let story = r#"<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging"><Story Self="s1"> + <ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/$ID/[No paragraph style]" SpanColumnType="SplitColumns" SplitColumnInsideGutter="27" SplitColumnOutsideGutter="4.5"> + <Properties><SpanSplitColumnCount type="short">3</SpanSplitColumnCount></Properties> + <CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content>Split list</Content></CharacterStyleRange> + </ParagraphStyleRange></Story></idPkg:Story>"#; + let doc = import_idml(&fixture_with_story(story)).unwrap(); + let para = |d: &Document| d.stories.values().find(|s| s.text.contains("Split list")).unwrap().paras[0].para.clone(); + let a = para(&doc); + assert_eq!(a.span_columns, Some(designcraft_doc::SpanColumns::Split(3))); + assert_eq!(a.split_inside_gutter, Some(27.0)); + assert_eq!(a.split_outside_gutter, Some(4.5)); + let b = para(&import_idml(&export_idml(&doc)).unwrap()); + assert_eq!((b.span_columns, b.split_inside_gutter, b.split_outside_gutter), (a.span_columns, a.split_inside_gutter, a.split_outside_gutter)); +} + #[test] fn list_numbering_format_expression_and_bullet_import_and_round_trip() { let styles = STYLES.replace( From 92649ffdb0e36d04a08e7698da7841c8ea9a6d0e Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick <hi@skylar.cc> Date: Sat, 10 Oct 2026 02:50:26 -0600 Subject: [PATCH 014/238] Security: validate engine inputs and package paths (#155) Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --- Cargo.lock | 19 ++ Cargo.toml | 1 + crates/engine/Cargo.toml | 1 + crates/engine/src/cmd/guides.rs | 179 ++++++++++--- crates/engine/src/cmd/object.rs | 155 ++++++++++- crates/engine/src/cmd/package.rs | 447 ++++++++++++++++++++++++++++--- crates/engine/src/tests.rs | 8 +- 7 files changed, 722 insertions(+), 88 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d18de53d8..9473c974a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -635,6 +635,15 @@ dependencies = [ "wayland-client", ] +[[package]] +name = "caseless" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6fd507454086c8edfd769ca6ada439193cdb209c7681712ef6275cccbfe5d8" +dependencies = [ + "unicode-normalization", +] + [[package]] name = "cast" version = "0.3.0" @@ -937,6 +946,7 @@ dependencies = [ name = "designcraft-engine" version = "0.5.0" dependencies = [ + "caseless", "designcraft-color", "designcraft-compose", "designcraft-doc", @@ -4507,6 +4517,15 @@ version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-properties" version = "0.1.4" diff --git a/Cargo.toml b/Cargo.toml index 34417be80..d69017d63 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ unicode-bidi = "0.3" unicode-script = "0.5" unicode-bidi-mirroring = "0.4" unicode-segmentation = "1" +caseless = "0.2" regex = { version = "1", default-features = false, features = ["std", "unicode", "perf"] } tempfile = "3" diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 23350e7ad..8b4604f11 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -28,6 +28,7 @@ thiserror = { workspace = true } log = { workspace = true } regex = { workspace = true } quick-xml = { workspace = true } +caseless = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] tempfile = { workspace = true } diff --git a/crates/engine/src/cmd/guides.rs b/crates/engine/src/cmd/guides.rs index 8a0fee5e3..47aacdc86 100644 --- a/crates/engine/src/cmd/guides.rs +++ b/crates/engine/src/cmd/guides.rs @@ -7,9 +7,13 @@ use designcraft_doc::{Document, Guide, Orientation, SpreadRef}; use designcraft_geom::Rect; use serde_json::{Value, json}; -use super::{CommandSpec, bad, cmd, has_doc, spread_param, str_param}; +use super::{CommandSpec, bad, cmd, has_doc, str_param}; use crate::{Result, Session}; +const MAX_GUIDE_DIVISIONS: u64 = 1000; +const MAX_GUIDES_CREATED: usize = 10_000; +const MAX_GUIDE_GUTTER: f64 = 1_000_000.0; + pub fn specs() -> Vec<CommandSpec> { vec![ cmd!( @@ -24,7 +28,7 @@ pub fn specs() -> Vec<CommandSpec> { cmd!("guide.move", "Move Guide", [], None, "{spread?, page, index, position}", has_doc, move_guide), cmd!("guide.delete", "Delete Guide", [], None, "{spread?, page, index}", has_doc, delete), cmd!("guide.deleteAll", "Delete All Guides on Spread", ["View", "Grids & Guides"], None, "{spread?}", has_doc, |s, p| { - let r = spread_param(p, "spread"); + let r = guide_spread(p, "guide.deleteAll")?; s.edit(|d, _| { let sp = d.spread_mut(r).ok_or_else(|| bad("guide.deleteAll", "no such spread"))?; let mut n = 0; @@ -37,7 +41,7 @@ pub fn specs() -> Vec<CommandSpec> { }), cmd!(query "guide.list", "Guides", [], None, "{spread?} → [{page, index, orientation, position, spread}]", has_doc, |s, p| { let d = &s.doc()?.doc; - let sp = d.spread(spread_param(p, "spread")).ok_or_else(|| bad("guide.list", "no such spread"))?; + let sp = d.spread(guide_spread(p, "guide.list")?).ok_or_else(|| bad("guide.list", "no such spread"))?; let mut out = Vec::new(); for (pi, pg) in sp.pages.iter().enumerate() { for (i, g) in pg.guides.iter().enumerate() { @@ -51,13 +55,48 @@ pub fn specs() -> Vec<CommandSpec> { "Create Guides…", ["Layout"], None, - "{rows?: 0, columns?: 0 (at most 100 each), rowGutter?: 12, columnGutter?: 12, fitTo?: margins|page, removeExisting?: false, spread?, page? (index in the spread; default all pages)}", + "{rows?: 0..1000, columns?: 0..1000, rowGutter?: 12, columnGutter?: 12, fitTo?: margins|page, removeExisting?: false, spread?, page? (index in the spread; default all pages)} — at most 10000 guides per command", has_doc, create_guides ), ] } +fn checked_usize(value: &Value, key: &str, command: &str) -> Result<usize> { + let n = value.as_u64().ok_or_else(|| bad(command, format!("`{key}` must be a non-negative integer")))?; + usize::try_from(n).map_err(|_| bad(command, format!("`{key}` is too large for this platform"))) +} + +fn optional_usize(p: &Value, key: &str, command: &str) -> Result<Option<usize>> { + p.get(key).map(|value| checked_usize(value, key, command)).transpose() +} + +/// Numeric spread shorthand or the serialized `SpreadRef` form, without lossy/defaulting casts. +fn guide_spread(p: &Value, command: &str) -> Result<SpreadRef> { + let Some(value) = p.get("spread") else { return Ok(SpreadRef::Doc(0)) }; + if value.is_number() { + return Ok(SpreadRef::Doc(checked_usize(value, "spread", command)?)); + } + let object = value.as_object().ok_or_else(|| bad(command, "`spread` must be a non-negative integer or a spread reference"))?; + let kind = object.get("kind").and_then(Value::as_str).ok_or_else(|| bad(command, "`spread.kind` must be `doc` or `parent`"))?; + let index = object.get("index").ok_or_else(|| bad(command, "missing `spread.index`"))?; + let index = checked_usize(index, "spread.index", command)?; + match kind { + "doc" => Ok(SpreadRef::Doc(index)), + "parent" => Ok(SpreadRef::Parent(index)), + _ => Err(bad(command, "`spread.kind` must be `doc` or `parent`")), + } +} + +fn finite_number(p: &Value, key: &str, command: &str) -> Result<Option<f64>> { + let Some(value) = p.get(key) else { return Ok(None) }; + let number = value.as_f64().ok_or_else(|| bad(command, format!("`{key}` must be a finite number")))?; + if !number.is_finite() { + return Err(bad(command, format!("`{key}` must be a finite number"))); + } + Ok(Some(number)) +} + fn orientation(p: &Value) -> Result<Orientation> { match str_param(p, "orientation") { Some("horizontal") => Ok(Orientation::Horizontal), @@ -73,17 +112,17 @@ fn page_at(d: &Document, r: SpreadRef, x: f64) -> usize { fn add(s: &mut Session, p: &Value) -> Result<Value> { let o = orientation(p)?; - let pos = p.get("position").and_then(Value::as_f64).ok_or_else(|| bad("guide.add", "missing position"))?; - let r = spread_param(p, "spread"); + let pos = finite_number(p, "position", "guide.add")?.ok_or_else(|| bad("guide.add", "missing position"))?; + let r = guide_spread(p, "guide.add")?; let spread_guide = p.get("spreadGuide").and_then(Value::as_bool).unwrap_or(false); let d = &s.doc()?.doc; - let pi = match p.get("page").and_then(Value::as_u64) { - Some(pi) => pi as usize, + let pi = match optional_usize(p, "page", "guide.add")? { + Some(pi) => pi, None if spread_guide => 0, None => { let x = match o { Orientation::Vertical => pos, - Orientation::Horizontal => p.get("at").and_then(Value::as_f64).unwrap_or(0.0), + Orientation::Horizontal => finite_number(p, "at", "guide.add")?.unwrap_or(0.0), }; page_at(d, r, x) } @@ -98,9 +137,9 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> { } fn guide_ref(p: &Value, cmd: &str) -> Result<(SpreadRef, usize, usize)> { - let pi = p.get("page").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing page"))? as usize; - let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing index"))? as usize; - Ok((spread_param(p, "spread"), pi, i)) + let pi = optional_usize(p, "page", cmd)?.ok_or_else(|| bad(cmd, "missing page"))?; + let i = optional_usize(p, "index", cmd)?.ok_or_else(|| bad(cmd, "missing index"))?; + Ok((guide_spread(p, cmd)?, pi, i)) } /// Commands must use the same guide/layer protection as pointer editing. @@ -114,7 +153,7 @@ fn check_editable(d: &Document, r: SpreadRef, pi: usize, i: usize, command: &str fn move_guide(s: &mut Session, p: &Value) -> Result<Value> { let (r, pi, i) = guide_ref(p, "guide.move")?; - let pos = p.get("position").and_then(Value::as_f64).ok_or_else(|| bad("guide.move", "missing position"))?; + let pos = finite_number(p, "position", "guide.move")?.ok_or_else(|| bad("guide.move", "missing position"))?; s.edit(|d, _| { check_editable(d, r, pi, i, "guide.move")?; let g = d @@ -140,40 +179,89 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> { }) } +fn guide_divisions(p: &Value, key: &str) -> Result<u64> { + let Some(value) = p.get(key) else { return Ok(0) }; + let count = value.as_u64().ok_or_else(|| bad("layout.createGuides", format!("`{key}` must be an integer")))?; + if count > MAX_GUIDE_DIVISIONS { + return Err(bad("layout.createGuides", format!("`{key}` must not exceed {MAX_GUIDE_DIVISIONS}"))); + } + Ok(count) +} + +fn guide_gutter(p: &Value, key: &str) -> Result<f64> { + let Some(value) = p.get(key) else { return Ok(12.0) }; + let gutter = value.as_f64().ok_or_else(|| bad("layout.createGuides", format!("`{key}` must be a finite number")))?; + if !gutter.is_finite() || !(0.0..=MAX_GUIDE_GUTTER).contains(&gutter) { + return Err(bad("layout.createGuides", format!("`{key}` must be between 0 and {MAX_GUIDE_GUTTER}"))); + } + Ok(gutter) +} + +fn guide_count(divisions: u64, gutter: f64) -> Result<usize> { + let edges = divisions.saturating_sub(1); + let count = if gutter > 0.0 { edges.checked_mul(2) } else { Some(edges) } + .ok_or_else(|| bad("layout.createGuides", "the requested guide count is too large"))?; + usize::try_from(count).map_err(|_| bad("layout.createGuides", "the requested guide count is too large for this platform")) +} + /// Evenly spaced rows/columns with gutters inside `area`: the guide positions. -pub(crate) fn grid_positions(a: f64, b: f64, n: u32, gutter: f64) -> Vec<f64> { +fn grid_positions(a: f64, b: f64, n: u64, gutter: f64) -> Result<Vec<f64>> { if n < 2 { - return vec![]; + return Ok(vec![]); + } + if !a.is_finite() || !b.is_finite() || !gutter.is_finite() || n > MAX_GUIDE_DIVISIONS { + return Err(bad("layout.createGuides", "guide geometry must be finite and within the supported range")); } let cell = ((b - a) - gutter * (n - 1) as f64) / n as f64; - let mut v = Vec::new(); + if !cell.is_finite() { + return Err(bad("layout.createGuides", "guide geometry is outside the supported range")); + } + let mut v = Vec::with_capacity(guide_count(n, gutter)?); for k in 1..n { let edge = a + k as f64 * cell + (k - 1) as f64 * gutter; + if !edge.is_finite() || (gutter > 0.0 && !(edge + gutter).is_finite()) { + return Err(bad("layout.createGuides", "guide geometry is outside the supported range")); + } v.push(edge); if gutter > 0.0 { v.push(edge + gutter); } } - v + Ok(v) } -/// The most rows or columns Create Guides lays out. The count comes from the caller, and every -/// guide is stored in the document. -const MAX_GUIDE_GRID: u32 = 100; - fn create_guides(s: &mut Session, p: &Value) -> Result<Value> { - let count = |key: &str| p.get(key).and_then(Value::as_u64).unwrap_or(0).min(u64::from(MAX_GUIDE_GRID)) as u32; - let (rows, cols) = (count("rows"), count("columns")); - let rg = p.get("rowGutter").and_then(Value::as_f64).unwrap_or(12.0); - let cg = p.get("columnGutter").and_then(Value::as_f64).unwrap_or(12.0); + let rows = guide_divisions(p, "rows")?; + let cols = guide_divisions(p, "columns")?; + let rg = guide_gutter(p, "rowGutter")?; + let cg = guide_gutter(p, "columnGutter")?; let to_page = str_param(p, "fitTo") == Some("page"); let remove = p.get("removeExisting").and_then(Value::as_bool).unwrap_or(false); - let r = spread_param(p, "spread"); - let only = p.get("page").and_then(Value::as_u64).map(|v| v as usize); + let r = guide_spread(p, "layout.createGuides")?; + let only = optional_usize(p, "page", "layout.createGuides")?; + let page_count = { + let sp = s.doc()?.doc.spread(r).ok_or_else(|| bad("layout.createGuides", "no such spread"))?; + match only { + Some(page) => { + if sp.pages.get(page).is_none() { + return Err(bad("layout.createGuides", "no such page")); + } + 1 + } + None => sp.pages.len(), + } + }; + let per_page = guide_count(rows, rg)? + .checked_add(guide_count(cols, cg)?) + .ok_or_else(|| bad("layout.createGuides", "the requested guide count is too large"))?; + per_page + .checked_mul(page_count) + .filter(|count| *count <= MAX_GUIDES_CREATED) + .ok_or_else(|| bad("layout.createGuides", format!("a single command may create at most {MAX_GUIDES_CREATED} guides")))?; let layer = s.doc()?.active_layer; s.edit(|d, _| { let sp = d.spread_mut(r).ok_or_else(|| bad("layout.createGuides", "no such spread"))?; - let mut n = 0; + let mut n = 0usize; for (pi, pg) in sp.pages.iter_mut().enumerate() { if only.is_some_and(|o| o != pi) { continue; @@ -182,7 +270,7 @@ fn create_guides(s: &mut Session, p: &Value) -> Result<Value> { pg.guides.clear(); } let area: Rect = if to_page { pg.bounds() } else { pg.margin_rect() }; - for y in grid_positions(area.y0, area.y1, rows, rg) { + for y in grid_positions(area.y0, area.y1, rows, rg)? { pg.guides.push(Guide { orientation: Orientation::Horizontal, position: y, @@ -193,7 +281,7 @@ fn create_guides(s: &mut Session, p: &Value) -> Result<Value> { }); n += 1; } - for x in grid_positions(area.x0, area.x1, cols, cg) { + for x in grid_positions(area.x0, area.x1, cols, cg)? { pg.guides.push(Guide { orientation: Orientation::Vertical, position: x, @@ -243,16 +331,33 @@ mod tests { assert_eq!(s.doc().unwrap().doc.spreads[0].pages[0].guides.len(), 5); } - /// A huge row or column count once made that many guides (2³² − 1 of them exhaust memory, which - /// no guard catches), and a count past 2³² wrapped to a small one. #[test] - fn create_guides_caps_the_row_and_column_counts() { + fn create_guides_validates_generation_limits() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); - let args = json!({"spread": 0, "rows": 4_294_967_298_u64, "columns": 3_000_000, "rowGutter": 0.0, "columnGutter": 0.0}); - let r = s.execute("layout.createGuides", &args).unwrap(); - let per_page = 2 * (u64::from(MAX_GUIDE_GRID) - 1); - assert_eq!(r["guides"], per_page * s.doc().unwrap().doc.spreads[0].pages.len() as u64); + assert!(s.execute("layout.createGuides", &json!({"columns": MAX_GUIDE_DIVISIONS + 1})).is_err()); + assert!(s.execute("layout.createGuides", &json!({"columns": 2, "columnGutter": -1})).is_err()); + assert!(s.execute("layout.createGuides", &json!({"columns": "many"})).is_err()); + let r = s.execute("layout.createGuides", &json!({"columns": MAX_GUIDE_DIVISIONS, "columnGutter": 0})).unwrap(); + assert_eq!(r["guides"], MAX_GUIDE_DIVISIONS - 1); + } + + #[test] + fn mutating_guide_commands_reject_invalid_indices() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("guide.add", &json!({"orientation": "vertical", "position": 100})).unwrap(); + + assert!(s.execute("guide.add", &json!({"orientation": "vertical", "position": 100, "page": -1})).is_err()); + assert!(s.execute("guide.move", &json!({"page": 0.5, "index": 0, "position": 200})).is_err()); + assert!(s.execute("guide.delete", &json!({"page": 0, "index": -1})).is_err()); + assert!(s.execute("guide.deleteAll", &json!({"spread": "0"})).is_err()); + assert!(s.execute("layout.createGuides", &json!({"spread": -1, "columns": 2})).is_err()); + assert!(s.execute("layout.createGuides", &json!({"page": u64::MAX, "columns": 2})).is_err()); + assert_eq!(s.execute("guide.list", &json!({})).unwrap().as_array().unwrap().len(), 1); + + s.execute("guide.add", &json!({"spread": {"kind": "doc", "index": 0}, "page": 0, "orientation": "horizontal", "position": 150})).unwrap(); + assert_eq!(s.execute("guide.list", &json!({})).unwrap().as_array().unwrap().len(), 2); } } diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index c03d9ffa9..17c44c27a 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -1,7 +1,7 @@ //! Object creation and the Object menu: frames, lines, transforms, arrange, group, lock/hide, //! fill/stroke, content type, Text Frame Options, corners, fitting, text wrap. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use designcraft_color::BlendMode; @@ -565,7 +565,7 @@ pub fn specs() -> Vec<CommandSpec> { "Step and Repeat…", ["Edit"], Some("Cmd+Alt+U"), - "{count?: 1, dx?, dy?, rows?, columns?, ids?} — copies of the selection (or ids) offset by (dx, dy); with rows/columns, a grid", + "{count?: 1..1000, dx?, dy?, rows?, columns?, ids?} — copies of the selection (or ids) offset by (dx, dy); with rows/columns, a grid of at most 1000 cells, and at most 1000 item copies including group contents", has_selection, step_and_repeat ), @@ -1962,20 +1962,113 @@ fn path_move_anchors(s: &mut Session, p: &Value) -> Result<Value> { }) } +const MAX_STEP_REPEAT_COPIES: u64 = 1000; +const MAX_STEP_REPEAT_WORK: usize = 1000; +const MAX_STEP_REPEAT_OFFSET: f64 = 1_000_000_000.0; + +fn step_repeat_count(p: &Value, key: &str, default: u64) -> Result<u64> { + let Some(value) = p.get(key) else { return Ok(default) }; + let count = value.as_u64().ok_or_else(|| bad("edit.stepAndRepeat", format!("`{key}` must be an integer")))?; + if !(1..=MAX_STEP_REPEAT_COPIES).contains(&count) { + return Err(bad("edit.stepAndRepeat", format!("`{key}` must be between 1 and {MAX_STEP_REPEAT_COPIES}"))); + } + Ok(count) +} + +fn step_repeat_delta(p: &Value, key: &str, default: f64) -> Result<f64> { + let Some(value) = p.get(key) else { return Ok(default) }; + let delta = value.as_f64().ok_or_else(|| bad("edit.stepAndRepeat", format!("`{key}` must be a finite number")))?; + if !delta.is_finite() { + return Err(bad("edit.stepAndRepeat", format!("`{key}` must be a finite number"))); + } + Ok(delta) +} + +fn checked_step_offset(dx: f64, dy: f64, x: u64, y: u64) -> Result<Vec2> { + let (x, y) = (x as f64 * dx, y as f64 * dy); + if !x.is_finite() || !y.is_finite() || x.abs() > MAX_STEP_REPEAT_OFFSET || y.abs() > MAX_STEP_REPEAT_OFFSET { + return Err(bad("edit.stepAndRepeat", format!("generated offsets must stay between -{MAX_STEP_REPEAT_OFFSET} and {MAX_STEP_REPEAT_OFFSET}"))); + } + Ok(Vec2::new(x, y)) +} + +fn step_repeat_offsets(p: &Value) -> Result<Vec<Vec2>> { + let (dx, dy) = (step_repeat_delta(p, "dx", 12.0)?, step_repeat_delta(p, "dy", 12.0)?); + match (p.get("rows"), p.get("columns")) { + (None, None) => { + let count = step_repeat_count(p, "count", 1)?; + (1..=count).map(|k| checked_step_offset(dx, dy, k, k)).collect() + } + (Some(_), Some(_)) => { + let rows = step_repeat_count(p, "rows", 1)?; + let columns = step_repeat_count(p, "columns", 1)?; + let cells = rows + .checked_mul(columns) + .filter(|count| *count <= MAX_STEP_REPEAT_COPIES) + .ok_or_else(|| bad("edit.stepAndRepeat", format!("`rows` × `columns` must not exceed {MAX_STEP_REPEAT_COPIES}")))?; + let capacity = usize::try_from(cells.saturating_sub(1)) + .map_err(|_| bad("edit.stepAndRepeat", "the requested grid is too large for this platform"))?; + let mut offsets = Vec::with_capacity(capacity); + for row in 0..rows { + for column in 0..columns { + if row != 0 || column != 0 { + offsets.push(checked_step_offset(dx, dy, column, row)?); + } + } + } + Ok(offsets) + } + _ => Err(bad("edit.stepAndRepeat", "`rows` and `columns` must be provided together")), + } +} + +/// Existing roots to duplicate, with repeated/ancestor-overlapping targets collapsed. Returns an +/// error before cloning the document when the copies (including group descendants) exceed the +/// command's work budget. +fn checked_step_repeat_targets(d: &Document, ids: &[ItemId], copies: usize, max_work: usize) -> Result<Vec<ItemId>> { + let mut seen = HashSet::new(); + let mut located = Vec::new(); + for id in ids { + if seen.insert(*id) + && let Some(loc) = d.find(*id) + { + located.push((*id, loc)); + } + } + if located.is_empty() { + return Err(bad("edit.stepAndRepeat", "nothing to repeat")); + } + + // A selected ancestor already deep-copies its descendants. Keeping a selected child as a + // second root would duplicate it twice and make the checked work disagree with the real work. + let roots: Vec<ItemId> = located + .iter() + .filter(|(_, loc)| { + !located.iter().any(|(_, parent)| parent.spread == loc.spread && parent.path.len() < loc.path.len() && loc.path.starts_with(&parent.path)) + }) + .map(|(id, _)| *id) + .collect(); + + let mut expanded = HashSet::new(); + for id in &roots { + if let Some(item) = d.item(*id) { + item.walk(&mut |child| { + expanded.insert(child.id); + }); + } + } + expanded + .len() + .checked_mul(copies) + .filter(|work| *work <= max_work) + .ok_or_else(|| bad("edit.stepAndRepeat", format!("a single command may create at most {max_work} items, including group contents")))?; + Ok(roots) +} + fn step_and_repeat(s: &mut Session, p: &Value) -> Result<Value> { let ids = targets(s, p)?; - let (dx, dy) = (f64_or(p, "dx", 12.0), f64_or(p, "dy", 12.0)); - let rows = p.get("rows").and_then(Value::as_u64).map(|v| v as usize); - let cols = p.get("columns").and_then(Value::as_u64).map(|v| v as usize); - let count = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1000) as usize; - let offsets: Vec<Vec2> = match (rows, cols) { - (Some(r), Some(c)) if r.checked_mul(c).is_some_and(|n| n <= 1000) => (0..r) - .flat_map(|i| (0..c).map(move |j| (i, j))) - .filter(|&(i, j)| i + j > 0) - .map(|(i, j)| Vec2::new(j as f64 * dx, i as f64 * dy)) - .collect(), - _ => (1..=count).map(|k| Vec2::new(k as f64 * dx, k as f64 * dy)).collect(), - }; + let offsets = step_repeat_offsets(p)?; + let ids = checked_step_repeat_targets(&s.doc()?.doc, &ids, offsets.len(), MAX_STEP_REPEAT_WORK)?; s.edit(|d, sel| { let src = d.clone(); let mut all = ids.clone(); @@ -1990,6 +2083,40 @@ fn step_and_repeat(s: &mut Session, p: &Value) -> Result<Value> { }) } +#[cfg(test)] +mod step_repeat_tests { + use super::*; + + #[test] + fn step_repeat_validates_counts_and_offsets() { + assert_eq!(step_repeat_offsets(&json!({"count": MAX_STEP_REPEAT_COPIES})).unwrap().len(), MAX_STEP_REPEAT_COPIES as usize); + assert_eq!(step_repeat_offsets(&json!({"rows": 25, "columns": 40})).unwrap().len(), 999); + assert!(step_repeat_offsets(&json!({"count": MAX_STEP_REPEAT_COPIES + 1})).is_err()); + assert!(step_repeat_offsets(&json!({"rows": 500, "columns": 3})).is_err()); + assert!(step_repeat_offsets(&json!({"rows": 3})).is_err()); + assert!(step_repeat_offsets(&json!({"count": 2, "dx": f64::MAX})).is_err()); + } + + #[test] + fn step_repeat_counts_unique_items_and_group_contents() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = ItemId(s.execute("frame.create", &json!({"rect": [0, 0, 10, 10]})).unwrap()["id"].as_u64().unwrap()); + let b = ItemId(s.execute("frame.create", &json!({"rect": [20, 0, 30, 10]})).unwrap()["id"].as_u64().unwrap()); + let c = ItemId(s.execute("frame.create", &json!({"rect": [40, 0, 50, 10]})).unwrap()["id"].as_u64().unwrap()); + let d = &s.doc().unwrap().doc; + assert_eq!(checked_step_repeat_targets(d, &[a, a], 2, 2).unwrap(), vec![a]); + assert!(checked_step_repeat_targets(d, &[a, b, c], 1, 2).is_err()); + assert_eq!(s.execute("edit.stepAndRepeat", &json!({"ids": [a.0, a.0], "count": 1})).unwrap()["created"], 1); + + let group = ItemId(s.execute("object.group", &json!({"ids": [a.0, b.0]})).unwrap()["id"].as_u64().unwrap()); + let d = &s.doc().unwrap().doc; + assert_eq!(checked_step_repeat_targets(d, &[group, group, a], 1, 3).unwrap(), vec![group]); + assert!(checked_step_repeat_targets(d, &[group, a], 1, 2).is_err()); + assert_eq!(s.execute("edit.stepAndRepeat", &json!({"ids": [group.0, a.0], "count": 1})).unwrap()["created"], 1); + } +} + #[cfg(test)] mod polygon_tests { use super::*; diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index 50556d97f..468667510 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -1,15 +1,194 @@ //! File › Package and Links › Copy Links To: gather a document with the files it uses. +use std::fs::Metadata; use std::path::{Path, PathBuf}; use std::sync::Arc; +#[cfg(not(target_arch = "wasm32"))] +use std::fs::File; +#[cfg(not(target_arch = "wasm32"))] +use std::io::Write; + +use super::{CommandSpec, bad, cmd, has_doc, str_param}; +use crate::{EngineError, Result, Session}; use designcraft_compose::ComposedStory; use designcraft_doc::{AssetId, Document}; use designcraft_fonts::{FaceRef, FontFace, FontSource}; use serde_json::{Value, json}; -use super::{CommandSpec, bad, cmd, has_doc, str_param}; -use crate::{EngineError, Result, Session}; +const MAX_OUTPUT_COMPONENT_BYTES: usize = 200; + +fn path_error(path: &Path, message: impl std::fmt::Display) -> EngineError { + EngineError::Other(format!("{}: {message}", path.display())) +} + +fn is_redirect(metadata: &Metadata) -> bool { + // Rust reports Windows name-surrogate reparse points (including directory junctions) as + // symlinks, while leaving non-redirecting reparse metadata such as cloud placeholders alone. + metadata.file_type().is_symlink() +} + +fn ensure_real_directory(path: &Path, ancestor: bool) -> Result<()> { + match std::fs::symlink_metadata(path) { + Ok(metadata) => { + if is_redirect(&metadata) { + // An existing folder above the package may be reached through a link the user + // set up (`/tmp` on macOS is one); the package folder itself may not. + if ancestor && std::fs::metadata(path).is_ok_and(|m| m.is_dir()) { + return Ok(()); + } + return Err(path_error(path, "refusing a redirected output directory")); + } + if !metadata.is_dir() { + return Err(path_error(path, "output path is not a directory")); + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + if let Some(parent) = path.parent().filter(|parent| !parent.as_os_str().is_empty()) + && parent != path + { + ensure_real_directory(parent, true)?; + } + if let Err(e) = std::fs::create_dir(path) + && e.kind() != std::io::ErrorKind::AlreadyExists + { + return Err(path_error(path, e)); + } + let metadata = std::fs::symlink_metadata(path).map_err(|e| path_error(path, e))?; + if is_redirect(&metadata) || !metadata.is_dir() { + return Err(path_error(path, "output directory was redirected while it was being created")); + } + } + Err(e) => return Err(path_error(path, e)), + } + Ok(()) +} + +/// A selected output directory and its resolved identity. Every package path is checked against +/// it before use. Outputs are staged in the same directory and atomically persisted over an +/// existing regular file, preserving overwrite behavior without following file links. +struct OutputRoot { + path: PathBuf, + canonical: PathBuf, +} + +impl OutputRoot { + fn new(path: PathBuf) -> Result<Self> { + ensure_real_directory(&path, false)?; + let canonical = std::fs::canonicalize(&path).map_err(|e| path_error(&path, e))?; + Ok(Self { path, canonical }) + } + + fn validate_directory(&self, path: &Path) -> Result<()> { + if !path.starts_with(&self.path) { + return Err(path_error(path, "output path is outside the selected directory")); + } + ensure_real_directory(path, false)?; + let canonical = std::fs::canonicalize(path).map_err(|e| path_error(path, e))?; + if !canonical.starts_with(&self.canonical) { + return Err(path_error(path, "output directory resolves outside the selected directory")); + } + Ok(()) + } + + fn child(&self, name: &str) -> Result<PathBuf> { + let path = self.path.join(name); + if path.parent() != Some(self.path.as_path()) { + return Err(path_error(&path, "invalid output directory name")); + } + Ok(path) + } + + fn validate_file_entry(&self, path: &Path) -> Result<bool> { + let parent = path.parent().ok_or_else(|| path_error(path, "output file has no parent directory"))?; + self.validate_directory(parent)?; + if !path.starts_with(&self.path) { + return Err(path_error(path, "output file is outside the selected directory")); + } + match std::fs::symlink_metadata(path) { + Ok(metadata) => { + if is_redirect(&metadata) { + return Err(path_error(path, "refusing to replace a redirected output file")); + } + if !metadata.is_file() { + return Err(path_error(path, "output path is not a regular file")); + } + Ok(true) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(e) => Err(path_error(path, e)), + } + } + + #[cfg(not(target_arch = "wasm32"))] + fn temporary_file(&self, path: &Path, permissions: Option<std::fs::Permissions>) -> Result<tempfile::NamedTempFile> { + let parent = path.parent().ok_or_else(|| path_error(path, "output file has no parent directory"))?; + self.validate_directory(parent)?; + let mut builder = tempfile::Builder::new(); + builder.prefix(".designcraft-package-").suffix(".tmp"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + // Match fs::write for a new output: 0666 restricted by the caller's umask. + builder.permissions(std::fs::Permissions::from_mode(0o666)); + } + let file = builder.tempfile_in(parent).map_err(|e| path_error(path, e))?; + #[cfg(unix)] + if let Some(permissions) = permissions { + // Overwrites retain their prior mode; copied fonts retain their source mode. + file.as_file().set_permissions(permissions).map_err(|e| path_error(path, e))?; + } + #[cfg(not(unix))] + // The replacement inherits its directory's ACL. Rust's portable permission API cannot + // reproduce a destination file's Windows DACL without platform-specific code. + let _ = permissions; + Ok(file) + } + + fn write(&self, path: &Path, bytes: &[u8]) -> Result<()> { + #[cfg(target_arch = "wasm32")] + { + let _ = (path, bytes); + return Err(EngineError::Other("package files are unavailable on the web".into())); + } + #[cfg(not(target_arch = "wasm32"))] + { + let existed = self.validate_file_entry(path)?; + let permissions = if existed { Some(std::fs::symlink_metadata(path).map_err(|e| path_error(path, e))?.permissions()) } else { None }; + let mut file = self.temporary_file(path, permissions)?; + file.write_all(bytes).map_err(|e| path_error(path, e))?; + // Recheck immediately before replacement. If a name is raced after this check, + // persist replaces that directory entry rather than following it. + self.validate_file_entry(path)?; + file.persist(path).map_err(|e| path_error(path, e.error))?; + Ok(()) + } + } + + fn copy(&self, from: &Path, to: &Path) -> Result<()> { + if self.validate_file_entry(to)? + && std::fs::canonicalize(to).is_ok_and(|target| std::fs::canonicalize(from).is_ok_and(|source| source == target)) + { + return Ok(()); + } + #[cfg(target_arch = "wasm32")] + { + let _ = from; + return Err(EngineError::Other("package files are unavailable on the web".into())); + } + #[cfg(not(target_arch = "wasm32"))] + { + let mut source = File::open(from).map_err(|e| path_error(from, e))?; + let permissions = Some(source.metadata().map_err(|e| path_error(from, e))?.permissions()); + let mut target = self.temporary_file(to, permissions)?; + std::io::copy(&mut source, &mut target).map_err(|e| path_error(to, e))?; + self.validate_file_entry(to)?; + target.persist(to).map_err(|e| path_error(to, e.error))?; + Ok(()) + } + } +} pub fn specs() -> Vec<CommandSpec> { vec![ @@ -34,14 +213,57 @@ pub fn specs() -> Vec<CommandSpec> { ] } -/// A file name for asset `a` in a Links folder, unique among `taken`. -fn link_name(name: &str, id: AssetId, taken: &mut Vec<String>) -> String { - let base = - Path::new(name).file_name().map(|n| n.to_string_lossy().to_string()).filter(|n| !n.is_empty()).unwrap_or_else(|| format!("asset-{}", id.0)); - unique_name(base, taken) +fn is_reserved_windows_name(name: &str) -> bool { + let stem = name.split('.').next().unwrap_or(name).trim_end_matches([' ', '.']); + ["con", "prn", "aux", "nul"].into_iter().any(|reserved| caseless::compatibility_caseless_match_str(stem, reserved)) + || (1..=9).any(|n| { + caseless::compatibility_caseless_match_str(stem, &format!("com{n}")) + || caseless::compatibility_caseless_match_str(stem, &format!("lpt{n}")) + }) } -/// `base`, or `base` numbered ("logo 2.png"), unique among `taken` (case-insensitively). +fn portable_names_equal(a: &str, b: &str) -> bool { + caseless::canonical_caseless_match_str(a, b) +} + +/// A portable single path component made from document metadata. +fn safe_component(name: &str, fallback: &str) -> String { + let leaf = name.rsplit(['/', '\\']).find(|part| !part.is_empty()).unwrap_or(""); + let mut safe = String::new(); + for ch in leaf.trim().chars() { + let replacement = ch.is_control() || matches!(ch, '<' | '>' | ':' | '"' | '|' | '?' | '*' | '/' | '\\'); + let ch = if replacement { '_' } else { ch }; + if safe.len() + ch.len_utf8() > MAX_OUTPUT_COMPONENT_BYTES { + break; + } + safe.push(ch); + } + safe.truncate(safe.trim_end_matches([' ', '.']).len()); + if safe.is_empty() || safe == "." || safe == ".." { + safe = fallback.to_string(); + } + if is_reserved_windows_name(&safe) { + safe.insert(0, '_'); + } + safe +} + +fn package_output_path(dir: &Path, title: &str, extension: &str) -> Result<PathBuf> { + let path = dir.join(format!("{title}.{extension}")); + if path.parent() != Some(dir) { + return Err(bad("file.package", "an output filename would be outside the selected directory")); + } + Ok(path) +} + +/// A file name for asset `a` in a Links folder, unique among `taken`. +fn link_name(name: &str, id: AssetId, taken: &mut Vec<String>) -> String { + let fallback = format!("asset-{}", id.0); + unique_name(safe_component(name, &fallback), taken) +} + +/// `base`, or `base` numbered ("logo 2.png"), unique under portable Unicode normalization and +/// case-insensitive comparison. fn unique_name(base: String, taken: &mut Vec<String>) -> String { let (stem, ext) = match base.rsplit_once('.') { Some((s, e)) => (s.to_string(), format!(".{e}")), @@ -49,7 +271,7 @@ fn unique_name(base: String, taken: &mut Vec<String>) -> String { }; let mut n = base; let mut k = 2; - while taken.iter().any(|t| t.eq_ignore_ascii_case(&n)) { + while taken.iter().any(|t| portable_names_equal(t, &n)) { n = format!("{stem} {k}{ext}"); k += 1; } @@ -58,8 +280,8 @@ fn unique_name(base: String, taken: &mut Vec<String>) -> String { } /// Write each asset's bytes into `dir`; returns the relinked document and the paths written. -fn write_links(d: &Document, dir: &Path, only: Option<&[AssetId]>) -> Result<(Document, Vec<PathBuf>)> { - std::fs::create_dir_all(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; +fn write_links(d: &Document, root: &OutputRoot, dir: &Path, only: Option<&[AssetId]>) -> Result<(Document, Vec<PathBuf>)> { + root.validate_directory(dir)?; let mut out = d.clone(); let mut taken = Vec::new(); let mut written = Vec::new(); @@ -72,7 +294,7 @@ fn write_links(d: &Document, dir: &Path, only: Option<&[AssetId]>) -> Result<(Do let a = &d.assets[&id]; let name = link_name(a.link.as_deref().unwrap_or(&a.name), id, &mut taken); let path = dir.join(&name); - std::fs::write(&path, a.data.as_slice()).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?; + root.write(&path, a.data.as_slice())?; let mut na = (**a).clone(); na.link = Some(path.to_string_lossy().to_string()); out.assets.insert(id, Arc::new(na)); @@ -84,7 +306,7 @@ fn write_links(d: &Document, dir: &Path, only: Option<&[AssetId]>) -> Result<(Do /// Copy the font file of `face` into `dir` (created when first needed) unless its licence /// restricts it or it is already there (`copied`: the source files copied) → what the package /// report adds after the font's name (nothing when copied). -fn copy_font(face: &FontFace, dir: &Path, copied: &mut Vec<PathBuf>, taken: &mut Vec<String>, files: &mut Vec<PathBuf>) -> String { +fn copy_font(root: &OutputRoot, face: &FontFace, dir: &Path, copied: &mut Vec<PathBuf>, taken: &mut Vec<String>, files: &mut Vec<PathBuf>) -> String { let from = match &face.source { FontSource::Bundled => return " — included with DesignCraft".into(), FontSource::Memory => return " — not copied: no font file".into(), @@ -96,13 +318,10 @@ fn copy_font(face: &FontFace, dir: &Path, copied: &mut Vec<PathBuf>, taken: &mut if copied.contains(from) { return String::new(); } - let name = from.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "font".into()); + let name = safe_component(&from.file_name().map(|n| n.to_string_lossy()).unwrap_or_default(), "font"); let to = dir.join(unique_name(name, taken)); - // Packaging into the folder the font is in: it is there already (and copying a file onto - // itself empties it on some systems). - let same = std::fs::canonicalize(&to).is_ok_and(|t| std::fs::canonicalize(from).is_ok_and(|f| f == t)); - match std::fs::create_dir_all(dir).and_then(|_| if same { Ok(0) } else { std::fs::copy(from, &to) }) { - Ok(_) => { + match root.validate_directory(dir).and_then(|_| root.copy(from, &to)) { + Ok(()) => { copied.push(from.clone()); files.push(to); String::new() @@ -136,18 +355,22 @@ fn drawn_faces(cs: &ComposedStory, depth: usize, out: &mut Vec<FaceRef>) { fn package(s: &mut Session, p: &Value) -> Result<Value> { let dir = PathBuf::from(str_param(p, "dir").ok_or_else(|| bad("file.package", "missing `dir`"))?); let st = s.doc()?; - let title = st.doc.title.clone(); + let root = OutputRoot::new(dir.clone())?; + let display_title = st.doc.title.clone(); + let title = safe_component(&display_title, "Untitled"); let fonts = s.execute("font.list", &json!({}))?; 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 (packed, mut files) = write_links(&d, &dir.join("Links"), None)?; - let doc_path = dir.join(format!("{title}.designcraft")); - std::fs::write(&doc_path, super::to_bytes(&packed)).map_err(|e| EngineError::Other(format!("{}: {e}", doc_path.display())))?; + let links_dir = root.child("Links")?; + let (packed, mut files) = write_links(&d, &root, &links_dir, None)?; + let doc_path = package_output_path(&dir, &title, "designcraft")?; + root.write(&doc_path, &super::to_bytes(&packed))?; files.insert(0, doc_path); // The font files, and what the report says about each font. let db = designcraft_fonts::FontDb::global().scoped(d.font_scope); - let (fonts_dir, mut copied, mut taken) = (dir.join(designcraft_fonts::DOCUMENT_FONTS_FOLDER), Vec::new(), Vec::new()); + let fonts_dir = root.child(designcraft_fonts::DOCUMENT_FONTS_FOLDER)?; + let (mut copied, mut taken) = (Vec::new(), Vec::new()); let (mut font_lines, mut named) = (Vec::new(), Vec::new()); for f in fonts.as_array().into_iter().flatten() { let (family, style) = (f["family"].as_str().unwrap_or(""), f["style"].as_str().unwrap_or("")); @@ -155,7 +378,7 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> { // A missing font's text is drawn in its substitute, which isn't a fallback font. let face = db.face(family, style); named.push(face.id()); - let note = if missing { " — MISSING".to_string() } else { copy_font(&face, &fonts_dir, &mut copied, &mut taken, &mut files) }; + let note = if missing { " — MISSING".to_string() } else { copy_font(&root, &face, &fonts_dir, &mut copied, &mut taken, &mut files) }; font_lines.push(format!(" {family} {style}{note}\n")); } // Fallback fonts: the faces that draw characters the named fonts lack. @@ -165,21 +388,23 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> { } drawn.retain(|f| !named.contains(&f.id())); drawn.sort_by(|a, b| (&a.family, &a.style).cmp(&(&b.family, &b.style))); - let fallback_lines: Vec<String> = - drawn.iter().map(|f| format!(" {} {}{}\n", f.family, f.style, copy_font(f, &fonts_dir, &mut copied, &mut taken, &mut files))).collect(); + let fallback_lines: Vec<String> = drawn + .iter() + .map(|f| format!(" {} {}{}\n", f.family, f.style, copy_font(&root, f, &fonts_dir, &mut copied, &mut taken, &mut files))) + .collect(); if p.get("idml").and_then(Value::as_bool).unwrap_or(true) { - let path = dir.join(format!("{title}.idml")); - std::fs::write(&path, designcraft_idml::export_idml(&packed)).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?; + let path = package_output_path(&dir, &title, "idml")?; + root.write(&path, &designcraft_idml::export_idml(&packed))?; files.push(path); } if p.get("pdf").and_then(Value::as_bool).unwrap_or(false) { - let path = dir.join(format!("{title}.pdf")); + let path = package_output_path(&dir, &title, "pdf")?; let bytes = designcraft_pdf::export_pdf(&packed, &s.cache, &Default::default()).map_err(|e| EngineError::Other(e.to_string()))?; - std::fs::write(&path, bytes).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?; + root.write(&path, &bytes)?; files.push(path); } // The report: fonts (missing first), links, preflight, the user's instructions. - let mut r = format!("Package report: {title}\n\n"); + let mut r = format!("Package report: {display_title}\n\n"); if let Some(t) = str_param(p, "instructions").filter(|t| !t.trim().is_empty()) { r += &format!("Instructions\n{t}\n\n"); } @@ -200,7 +425,7 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> { } } let report = dir.join("Instructions.txt"); - std::fs::write(&report, &r).map_err(|e| EngineError::Other(format!("{}: {e}", report.display())))?; + root.write(&report, r.as_bytes())?; files.push(report); Ok(json!({"dir": dir.to_string_lossy(), "files": files.iter().map(|f| f.to_string_lossy().to_string()).collect::<Vec<_>>(), "report": r})) } @@ -209,7 +434,8 @@ fn copy_links(s: &mut Session, p: &Value) -> Result<Value> { let dir = PathBuf::from(str_param(p, "dir").ok_or_else(|| bad("links.copyTo", "missing `dir`"))?); let only: Option<Vec<AssetId>> = 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 (relinked, written) = write_links(&d, &dir, only.as_deref())?; + let root = OutputRoot::new(dir.clone())?; + let (relinked, written) = write_links(&d, &root, &dir, only.as_deref())?; s.edit(|doc, _| { doc.assets = relinked.assets; Ok(()) @@ -291,6 +517,12 @@ mod tests { std::fs::create_dir_all(&fonts).unwrap(); std::fs::write(fonts.join("free.ttf"), font_with(FREE, &['F']).unwrap()).unwrap(); std::fs::write(fonts.join("restricted.otf"), with_fs_type(font_with(RESTRICTED, &['R']).unwrap(), 0x0002).unwrap()).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + std::fs::set_permissions(fonts.join("free.ttf"), std::fs::Permissions::from_mode(0o640)).unwrap(); + } // Text in both fonts, and in the bundled default font. let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); @@ -308,6 +540,15 @@ mod tests { let r = s.execute("file.package", &json!({"dir": out.to_string_lossy(), "idml": false})).unwrap(); let packed_fonts = out.join(DOCUMENT_FONTS_FOLDER); assert!(packed_fonts.join("free.ttf").exists()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + assert_eq!( + std::fs::metadata(packed_fonts.join("free.ttf")).unwrap().permissions().mode() & 0o777, + std::fs::metadata(fonts.join("free.ttf")).unwrap().permissions().mode() & 0o777 + ); + } assert!(!packed_fonts.join("restricted.otf").exists(), "a restricted licence keeps the font out"); assert!(r["files"].to_string().contains("free.ttf"), "{}", r["files"]); let report = r["report"].as_str().unwrap(); @@ -369,4 +610,142 @@ mod tests { assert_eq!(Session::new().execute("file.open", &json!({"path": doc})).unwrap()["documentFonts"], 2); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn package_uses_portable_names_inside_the_selected_directory() { + assert_eq!(safe_component("Archive/Annual: Review", "Untitled"), "Annual_ Review"); + assert_eq!(safe_component(".", "Untitled"), "Untitled"); + assert_eq!(safe_component("CON", "Untitled"), "_CON"); + assert_eq!(safe_component("COM¹.txt", "Untitled"), "_COM¹.txt"); + assert_eq!(safe_component("LPT³", "Untitled"), "_LPT³"); + for name in ["COM¹", "COM²", "COM³", "LPT¹", "LPT²", "LPT³"] { + assert!(is_reserved_windows_name(name), "{name}"); + } + let mut taken = Vec::new(); + assert_eq!(unique_name("Logo.svg".into(), &mut taken), "Logo.svg"); + assert_eq!(unique_name("logo.svg".into(), &mut taken), "logo 2.svg"); + assert_eq!(unique_name("Résumé.pdf".into(), &mut taken), "Résumé.pdf"); + assert_eq!(unique_name("Re\u{301}sume\u{301}.pdf".into(), &mut taken), "Re\u{301}sume\u{301} 2.pdf"); + assert_eq!(unique_name("ΟΣ.txt".into(), &mut taken), "ΟΣ.txt"); + assert_eq!(unique_name("ος.txt".into(), &mut taken), "ος 2.txt"); + + let dir = std::env::temp_dir().join(format!("dc-package-safe-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut s = Session::new(); + s.execute("file.new", &json!({"title": "Archive/Annual: Review"})).unwrap(); + let r = s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": false})).unwrap(); + let files = r["files"].as_array().unwrap(); + assert!(files.iter().all(|path| Path::new(path.as_str().unwrap()).starts_with(&dir))); + assert!(dir.join("Annual_ Review.designcraft").is_file()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn package_overwrites_existing_regular_outputs() { + let dir = std::env::temp_dir().join(format!("dc-package-overwrite-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let document = dir.join("Annual.designcraft"); + std::fs::write(&document, b"previous package").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + std::fs::set_permissions(&document, std::fs::Permissions::from_mode(0o640)).unwrap(); + } + + let mut s = Session::new(); + s.execute("file.new", &json!({"title": "Annual"})).unwrap(); + s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": false})).unwrap(); + s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": false})).unwrap(); + assert!(super::super::from_bytes(&std::fs::read(&document).unwrap()).is_ok()); + assert!(!dir.join(designcraft_fonts::DOCUMENT_FONTS_FOLDER).exists()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + assert_eq!(std::fs::metadata(&document).unwrap().permissions().mode() & 0o777, 0o640); + } + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn package_accepts_a_relative_destination() { + let dir = PathBuf::from(format!(".dc-package-relative-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut s = Session::new(); + s.execute("file.new", &json!({"title": "Relative"})).unwrap(); + s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": false})).unwrap(); + assert!(dir.join("Relative.designcraft").is_file()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[cfg(unix)] + #[test] + fn package_refuses_redirected_destinations() { + use std::os::unix::fs::symlink; + + let base = std::env::temp_dir().join(format!("dc-package-redirects-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + let outside = base.join("separate"); + std::fs::create_dir_all(&outside).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({"title": "Annual"})).unwrap(); + + let redirected_root = base.join("redirected-package"); + symlink(&outside, &redirected_root).unwrap(); + assert!(s.execute("file.package", &json!({"dir": redirected_root.to_string_lossy(), "idml": false})).is_err()); + assert!(!outside.join("Annual.designcraft").exists()); + std::fs::remove_file(&redirected_root).unwrap(); + + // A linked folder above the package is followed, as `/tmp` is on macOS. + let linked_parent = base.join("linked-parent"); + symlink(&outside, &linked_parent).unwrap(); + let nested_package = linked_parent.join("nested").join("Package"); + s.execute("file.package", &json!({"dir": nested_package.to_string_lossy(), "idml": false})).unwrap(); + assert!(outside.join("nested").join("Package").join("Annual.designcraft").is_file()); + std::fs::remove_file(&linked_parent).unwrap(); + + let package = base.join("Package"); + std::fs::create_dir_all(&package).unwrap(); + symlink(&outside, package.join("Links")).unwrap(); + assert!(s.execute("file.package", &json!({"dir": package.to_string_lossy(), "idml": false})).is_err()); + assert!(!outside.join("Annual.designcraft").exists()); + std::fs::remove_file(package.join("Links")).unwrap(); + std::fs::create_dir(package.join("Links")).unwrap(); + + let kept = outside.join("kept.designcraft"); + std::fs::write(&kept, b"kept").unwrap(); + symlink(&kept, package.join("Annual.designcraft")).unwrap(); + assert!(s.execute("file.package", &json!({"dir": package.to_string_lossy(), "idml": false})).is_err()); + assert_eq!(std::fs::read(&kept).unwrap(), b"kept"); + let _ = std::fs::remove_dir_all(&base); + } + + #[cfg(windows)] + #[test] + fn package_refuses_windows_directory_redirects() { + use std::os::windows::fs::symlink_dir; + + let base = std::env::temp_dir().join(format!("dc-package-windows-redirect-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let outside = base.join("separate"); + std::fs::create_dir_all(&outside).unwrap(); + let redirected = base.join("Package"); + match symlink_dir(&outside, &redirected) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => { + let _ = std::fs::remove_dir_all(&base); + return; + } + Err(e) => panic!("could not create directory redirect: {e}"), + } + let mut s = Session::new(); + s.execute("file.new", &json!({"title": "Annual"})).unwrap(); + assert!(s.execute("file.package", &json!({"dir": redirected.to_string_lossy(), "idml": false})).is_err()); + assert!(!outside.join("Annual.designcraft").exists()); + std::fs::remove_dir(&redirected).unwrap(); + let _ = std::fs::remove_dir_all(&base); + } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 1f5f7189e..c1032bccf 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -378,9 +378,11 @@ fn step_and_repeat_grid() { fn step_and_repeat_with_a_huge_grid_makes_no_grid() { let mut s = session(); s.execute("frame.create", &json!({"rect": [36, 36, 66, 66], "content": "unassigned"})).unwrap(); - let r = s.execute("edit.stepAndRepeat", &json!({"rows": 4_294_967_296_u64, "columns": 4_294_967_296_u64})).unwrap(); - // As for any grid of more than 1000 copies: `count` (default 1) copies instead. - assert_eq!(r["created"], 1); + let items = |s: &Session| s.doc().unwrap().doc.spreads[0].items.len(); + let before = items(&s); + // Counts past the limit are an error, and nothing is copied. + assert!(s.execute("edit.stepAndRepeat", &json!({"rows": 4_294_967_296_u64, "columns": 4_294_967_296_u64})).is_err()); + assert_eq!(items(&s), before); } /// The column count is the caller's and sizes a list every time the page's columns are laid out. From a127c6d5c3576972bbd60445f26d893db071eef3 Mon Sep 17 00:00:00 2001 From: Brandon Thomas <echelon@gmail.com> Date: Sat, 10 Oct 2026 14:09:17 -0400 Subject: [PATCH 015/238] Progress docs: target-app parity, gaps, alpha gate, readiness by audience (#355) Progress docs per craftrules standards/progress-docs.md: target-app parity, gaps, roadmap with alpha gate, readiness by audience (full / mainstream / essentials) with Opus 5.5 hours, and feature, format, hardware, UI and localization parity docs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- AGENTS.md | 2 +- CLAUDE.md | 2 +- README.md | 2 +- ROADMAP.md | 385 +++++++++----------- docs/arabic-typography.md | 9 + docs/architecture.md | 98 ++++++ docs/cjk-typography.md | 9 + docs/cjk.md | 10 + docs/file-format-parity.md | 110 ++++++ docs/gaps.md | 226 ++++++++++++ docs/hardware-parity.md | 56 +++ docs/localization-parity.md | 80 +++++ docs/{parity.md => parity-checklist.md} | 17 +- docs/roadmap.md | 115 ++++++ docs/target-app-parity.md | 448 ++++++++++++++++++++++++ docs/typography-parity.md | 90 +++++ docs/ui-parity.md | 77 ++++ xtask/src/main.rs | 2 +- xtask/src/parity.rs | 6 +- 19 files changed, 1513 insertions(+), 231 deletions(-) create mode 100644 docs/architecture.md create mode 100644 docs/file-format-parity.md create mode 100644 docs/gaps.md create mode 100644 docs/hardware-parity.md create mode 100644 docs/localization-parity.md rename docs/{parity.md => parity-checklist.md} (92%) create mode 100644 docs/roadmap.md create mode 100644 docs/target-app-parity.md create mode 100644 docs/typography-parity.md create mode 100644 docs/ui-parity.md diff --git a/AGENTS.md b/AGENTS.md index bedbfb6e6..65693656c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,7 +38,7 @@ People trust DesignCraft with their layouts; a crash loses their work. **This ou - Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk). ## Roadmap -`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a milestone task lands. +`ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. It follows craftrules' [`standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md). Details: `docs/target-app-parity.md` (assessment), `docs/gaps.md` (ranked work list: pick from the top), `docs/roadmap.md` (milestones, current focus), `docs/parity-checklist.md` (presence checklist, `cargo xtask parity`), and the typography, file-format, UI, hardware and localization parity docs in `docs/`. When work lands, update the affected docs and their status lines, delete or shrink the closed gap, and add a progress-log line to ROADMAP.md. ## Contributor credits (About window) diff --git a/CLAUDE.md b/CLAUDE.md index dfe78adb6..e8c455b0a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,4 +38,4 @@ People trust DesignCraft with their layouts; a crash loses their work. **This ou - Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk). ## Roadmap -`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a milestone task lands. +`ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. It follows craftrules' [`standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md). Details: `docs/target-app-parity.md` (assessment), `docs/gaps.md` (ranked work list: pick from the top), `docs/roadmap.md` (milestones, current focus), `docs/parity-checklist.md` (presence checklist, `cargo xtask parity`), and the typography, file-format, UI, hardware and localization parity docs in `docs/`. When work lands, update the affected docs and their status lines, delete or shrink the closed gap, and add a progress-log line to ROADMAP.md. diff --git a/README.md b/README.md index 5c7d4c1ad..7bff6310b 100644 --- a/README.md +++ b/README.md @@ -183,7 +183,7 @@ egui frontend is a separate crate, so the UI can be swapped without touching the | L6 | `ui-egui` (InDesign-style UI, control channel) | | L7 | `apps/designcraft`, `apps/designcraft-cli`, `apps/designcraft-web` | -- **Status and milestones:** [ROADMAP.md](ROADMAP.md) +- **Status and milestones:** [ROADMAP.md](ROADMAP.md) (stage: pre-alpha); how close to InDesign: [`docs/target-app-parity.md`](docs/target-app-parity.md); known gaps: [`docs/gaps.md`](docs/gaps.md) - **Contributor and agent rules** (clean-room, asset policy, quality gates): [`AGENTS.md`](AGENTS.md) - **Bundled assets:** every one is listed with its licence in [`ASSETS.md`](ASSETS.md) - **App icon and colour:** a calico cat in a polka-dot scarf on DesignCraft green `#7bb51c`; see [`assets/app-icon/`](assets/app-icon/README.md) diff --git a/ROADMAP.md b/ROADMAP.md index 86a435456..277e982ff 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,228 +1,171 @@ # DesignCraft roadmap -- 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. +**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%) -- AR1.1: Kashida justification tests verify space fallback without Tatweel fonts using an isolated font database, while retaining elongation assertions when fonts support it. +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (stage re-normalized to pre-alpha by the core-workflow gate; earlier today: major, full re-measure against InDesign 2026 21.6; restructured to the craftrules progress-docs standard) · **Target:** Adobe InDesign 2026 -- AR1: Arabic IDML controls now retain character direction, kashida switches, diacritic offsets/forms, paragraph policy values and independent story/table directions. Shaping preserves joining context and script/language selection; paragraph-level bidi, contextual digit conversion, mark-safe elongation, RTL columns/tables and physical column insertion have regression coverage. Vendor-specific justification and diacritic presets remain explicit preflight warnings; see docs/arabic-typography.md. -- CJK1: IDML composite fonts now retain character mappings and metrics; explicit aki, tsume, jidori, em alignment/leading, named/custom kinsoku boundaries, hanging punctuation, tate-chu-yoko offsets and kenten symbols flow through composition and IDML export. Automatic kerning no longer imports inactive numeric values as enormous manual spacing. Composite definitions are editable through commands with undo/cache invalidation. Mojikumi definitions are retained and unsupported spacing-table / push-in-push-out policies are reported by preflight. Full InDesign CJK composition parity, frame-grid layout and detailed ruby remain unfinished. Warichu sets the selected run in smaller lines inside the line after the line is broken. +DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON +format + IDML), scriptable by agents (MCP), and available on the web. -DesignCraft aims at full Adobe InDesign parity — and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. +## Headline numbers -## Status (2026-10-01) - -**Working today** -- Document model: facing/non-facing spreads, parent pages with page-number markers, sections, layers, frames (text / graphic / unassigned), threaded stories, paragraph/character/object styles with based-on, swatches (process/spot/tints/gradients), corner options, text wrap, drop shadow, opacity/blend. -- Text engine: Knuth–Plass paragraph composer and single-line composer, dictionary hyphenation (public-domain Moby list + our own trained Liang patterns), justification with word/letter/glyph-scaling ranges, keep options (keep with next, keep lines together, widows/orphans), balance ragged lines, optical margin alignment, hyphenation zone/limit, columns, threading across frames and spreads, tabs, bullets/numbering, rules, shading, baseline grid, first-baseline options, vertical justification, wrap exclusions, overset detection, caret/hit testing. -- FID2: IDML underline/strikethrough Text Color resets survive style inheritance and roundtrip. Raster and PDF paint underlines behind glyphs, retain strikethrough above, and center rules on their offsets; synthetic overlap tests cover both backends. -- Rendering: vello_cpu (SIMD, multithreaded) — pages, items, images with mip levels, gradients, composed text. -- UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts, document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches, Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document and Preferences dialogs, Quick Apply (⌘Return), 4 brightness themes. -- Pen tool, Direct Selection anchor/handle editing, rotate from corners, Rotate/Scale/Shear tools, Eyedropper, snapping & smart guides, Align/Distribute, Find/Change (text + GREP), Story Editor, Paragraph Style Options, Effects panel, hidden characters, native macOS menu bar, measured InDesign 2026 look (Medium Dark): Properties sections per selection state, 21 pt spinner fields with arithmetic, Contextual Task Bar, inverse (black) text selection, Pages panel with drag-and-drop reorder / parent apply, Layers panel with per-object rows. -- Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection, Type (draw frame, click caret, select, type), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom. -- PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`. File › Export PDF… now opens an "Export PDF" options dialog (General / Compression / Marks and Bleeds / Advanced) and remembers the last-used settings across exports and sessions. -- IDML interchange (`designcraft-idml`): export and import of swatches, styles, fonts, preferences, parent spreads, spreads/pages, frames, groups, images (embedded or linked), formatted threaded stories — opens in InDesign 2026 and round-trips InDesign-exported files (`file.exportIdml`, `file.openIdml`, File → Export IDML…, CLI `--in x.idml` / `--export x.idml`). -- Tables (M8): tables anchored in stories (header/footer/body rows, merged cells, per-edge strokes, fills + alternating fills, insets, vertical justification, at-least/exact row heights); composed into the text column (columns scale to fit, rows break across columns/frames with repeating headers/footers, overset); rendered, exported to PDF (real text) and IDML (export + import); Table menu, Table panel, Create Table dialog, caret/typing/Tab navigation and cell selection in cells; `table.*` commands. -- Hyperlinks (text or frames → URL / e-mail / page) and bookmarks, exported as PDF link annotations and outline. -- EPUB 3 (reflowable) export: stories in reading order, CSS from paragraph/character styles, images, navigation (`file.exportEpub`, CLI `--export x.epub`). -- Data Merge (one linked CSV, TSV, text, or Excel file; text, image, QR, and hyperlink placeholders; preview; a new document with one record per page or records tiled on a single page), spell checking (public-domain Moby list + document dictionary, suggestions), Step and Repeat (count or grid), snippets, object styles, Numbering & Section Options. -- Footnotes: Type › Insert Footnote (caret moves into the note), Document Footnote Options (numbering style incl. symbols, start/restart per page/spread/section, prefix/suffix, reference position/character style, paragraph style, separator, spacing, first baseline, rule above); notes composed at the bottom of the referencing column with the body text making room; edited in place on the canvas; rendered, exported to PDF (real text) and IDML (InDesign's structure, verified opening in InDesign 2026) and imported from IDML; `footnote.*` commands. -- Paragraph Rules: Rule Above / Rule Below (on, weight, colour, tint, column or text width, offset, left/right indent) set in Type › Paragraph Rules…, the Paragraph panel menu and Paragraph Style Options › Paragraph Rules; inherited style values shown, and `type.para` / `style.paragraph.edit` change only the rule fields named. -- Cross-references and text anchors: Insert Cross-Reference (paragraph by style, or text anchor), 11 formats with InDesign's building blocks (full/partial paragraph, paragraph text/number, page number, anchor name, chapter, file name) plus user formats; resolved live at composition — never out of date, no Update step; unresolved destinations show `??`; PDF link annotations to the destination page; IDML export/import as InDesign cross-reference sources, hyperlinks, text destinations and formats (verified in InDesign 2026); `xref.*` / `anchor.*` commands. -- Index: page references (topic from the selection or up to 4 levels, sort keys; current page, to end of story, next n paragraphs, suppressed, See / See also), Generate / Update Index (section headings, nested or run-in, page ranges merged, Index Title / Section Head / Level 1–4 styles); IDML export/import as InDesign topics, page references and topic cross-references (verified in InDesign 2026); `index.*` commands. -- Text clipboard keeps formatting (character/paragraph formats, footnotes, cross-references, index markers, tables) and falls back to plain text when the system clipboard changed; Paste without Formatting (⇧⌘V); Change Case (UPPERCASE, lowercase, Title Case, Sentence case — formatting kept); Type › Insert Special Character / White Space / Break Character submenus. -- Anchored objects: items flow in the text inline (on the baseline, with Y offset; lines grow to fit) or above the line (left/center/right, space before/after); paste copied items into text to anchor them; Anchored Object Options and Release (back onto the page where shown); rendered, exported to PDF and to/from IDML (InDesign anchored object settings; verified in InDesign 2026); copy/paste and threading carry them; `anchored.*` commands. -- 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. -- 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 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). -- Crash recovery: unsaved documents are written to the recovery folder every 30 s and reopened (unsaved, remembering their files) after a crash; File › Revert and Save a Copy…; `file.recovery.*`, `file.revert`, `file.saveACopy`. -- Graphics backend chosen before the window exists (DirectX 12 on Windows, Vulkan on Linux, Metal on macOS, OpenGL as the fallback; `WGPU_BACKEND` overrides) with a start-up fallback: a backend whose start never showed a frame is skipped at the next start and the status bar says so (`gpu.json`). Found on an AMD hybrid-graphics laptop whose Vulkan driver faulted at the first present. -- Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool). -- Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`. -- Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow adds pages and threaded frames until the text fits; Remove Styles option (`designcraft-textimport`). -- Gradient Feather (⇧G tool, `object.gradientFeather`): objects fade along a linear or radial opacity gradient, on screen and as a soft mask in PDF. -- Script Label (`object.label`, `object.findByLabel`), alt text (`object.altText`), Isolate Blending / Knockout Group (`object.transparencyGroup`; on screen, PDF isolates), Table ▸ Sort (`table.sortRows`, numeric-aware, header/footer fixed). -- File ▸ Export Text (`file.exportText`): a story as Text Only or RTF (fonts, styles, alignment, indents, tables as tab-separated rows); Export EPUB now asks for a path in the UI. -- Tagged PDF (`file.exportPdf {tagged}`, on by default from the UI): structure tree with stories as paragraphs in reading order, figures carrying their alt text, parent-page items and printer's marks as artifacts. -- Global Light (`object.globalLight`, `globalLight` on drop/inner shadows; Use Global Light in the Effects panel) and Generate Static Caption (`object.caption`: template over name, path, alt text, label, effective ppi, dimensions, format; any side, offset, style). -- Control panel scale X/Y, rotation and shear fields (`transform.info`, absolute `transform.set {scaleX, scaleY, rotation, shear}` about the reference point) and Preferences › Transformations are Totals for nested objects and placed graphics. -- Variable fonts: each named instance is a style (Font menu, shaping with feature variations, metrics, outlines) and embeds at its axis settings in PDF; free axis sliders still to come. -- Object ▸ Select (first/next above, next/last below, container, content, previous/next in group), full Object ▸ Convert Shape (`object.convertShape`), Table ▸ Split Cell Horizontally/Vertically. -- Attributes panel (`object.attributes`: Overprint Fill/Stroke/Gap, Nonprinting), with Overprint Gap in Overprint Preview and IDML. -- Page Numbering View (Preferences: section or absolute): page labels everywhere follow it; Go to Page takes section names, numbers or `+n`. -- Preferences › Advanced Type (document `advancedType`: superscript/subscript size and position; IDML TextPreference); subscripts now drop 33.3% like InDesign. -- Preferences › Composition › Highlight: keep violations, H&J violations (three shades), custom tracking/kerning, substituted fonts. -- File ▸ Export HTML (`file.exportHtml`): one self-contained page in reading order, styles as CSS, images inline, alt text from Object Export Options (also in EPUB). -- Swatches panel colour groups (`swatch.newColorGroup`, `moveToGroup`, `ungroupColorGroup`, `renameColorGroup`; folders and a row context menu; IDML ColorGroup). -- Object ▸ Generate QR Code (`object.qrCode`: web link, text, SMS, email, business card; vector modules as a compound path, into a frame or re-encoded in place). -- Conditional text (`condition.new/options/delete/apply/list`, Conditional Text panel): hidden conditions drop their text from layout and exports, indicators underline on screen, IDML Condition/AppliedConditions. -- Endnotes (`endnote.insert/edit/delete/list/options`, Type ▸ Insert Endnote): references number through the document, the endnote frame on a new last page lists them under a heading; IDML keeps the listed text only. -- Editorial notes (`note.new/edit/delete/convertToText/list`, Notes panel): anchored in text, flagged on screen, never printed or exported. -- File ▸ Print Booklet (`file.printBooklet`): saddle-stitch or 2-up consecutive printer spreads as PDF, padded with blanks, with a gap between pages. -- Edit ▸ Transparency Blend Space (`edit.transparencyBlendSpace`; RGB for web/mobile documents; IDML TransparencyPreference) — exported PDFs give pages with transparency a DeviceCMYK or DeviceRGB page group, so viewers and RIPs blend in that space. On screen, spreads with transparency in a CMYK blend space are shown through the working CMYK profile (their colours keep to its gamut, as when composited in CMYK). -- High Contrast interface theme (fifth Interface Color Theme: black chrome, white text, yellow selection). -- Place Excel workbooks (.xlsx): the first worksheet's used range becomes a table (shared/inline strings, numbers, booleans). -- Object Library (`library.new/open/add/place/remove/list/json/close`, Library panel): `.dclib` files of snippets that keep stories, styles, swatches and images. -- Ink Manager (`ink.list`, `ink.options`; Swatches panel menu): All Spots to Process, per-ink conversion and ink aliases in PDF output; IDML ConvertToProcess/AliasInkName. -- Appearance of Black complete: Printing/Exporting rich black for RGB output (PNG), Overprint [Black] Swatch at 100% (document, IDML OverprintBlack). -- Hyperlinks and Bookmarks panels (Window ▸ Interactive): new from the selection, edit (`hyperlink.edit`), go to source (`hyperlink.goToSource`), rename bookmarks (`bookmark.rename`), go to page, delete. -- Nested line styles (`nestedLineStyles` paragraph attribute, Paragraph Style Options › Drop Caps and Nested Styles, IDML AllNestedLineStyles): the first lines restyle until the line breaks settle. -- User dictionary hyphenation exceptions (`hyphenation.addException/removeException/list`, Edit ▸ Spelling ▸ User Dictionary): `ex~am~ple` breaks only at `~`, a plain word never hyphenates. -- Frame Fitting Options (`object.fittingOptions`, Object ▸ Fitting ▸ Frame Fitting Options…): Auto-Fit refits on resize, fitting, Align From reference point, crop amounts; IDML FrameFittingOption. -- Paragraph borders (per-side weights, colour, tint, offsets) and shading offsets, drawn per column part of split paragraphs (also when overset); Paragraph panel controls; IDML ParagraphBorder*/ParagraphShading*Offset. -- Font menu: search, favourites (stars, Show Favorites Only; `favoriteFonts` preference) and each family previewed in its own face. Western (and other-script) families come first, then Japanese, Simplified Chinese, Traditional Chinese and Korean ones, each group alphabetical and set off by a separator; the group comes from the font's own data (`meta` languages, family names in a CJK language, OS/2 code pages, else what it covers), read once (installed fonts by the font scan, without loading them). CJK families show their native names, the English name on hover; Preferences › Type › Show Font Names in English (`showFontNamesInEnglish`) shows English names throughout. Search matches either name; documents store the English name. Every family list (Character panel, Control bar, Properties, style dialogs, Find Font, Glyphs) follows this order. -- Application preferences (engine `Prefs`) now persist across launches (desktop: prefs.json beside ui.json). -- View ▸ Fit Selection in Window (⌥⌘=) and Find/Change ▸ Object (`find.objects`, `find.changeObjects`: by fill, stroke, weight, opacity, kind, layer, label). -- Custom workspaces (`window.newWorkspace/deleteWorkspace/resetWorkspace`; workspace switcher shows the current one): bars and panel arrangement saved by name. -- Word/RTF Import Options (File ▸ Place with Import Options…, `place.styles`, `file.place {styleMap, styleConflicts}`): map imported styles onto the document's, use existing / redefine / auto-rename on conflicts, or remove formatting. -- Custom anchored objects (`anchored.insert/options {position: custom}`): placed relative to the anchor, frame, column, page margins or page edge with reference points and offsets, no space in the text, keep within column; IDML Anchored position. -- Variable font axes: Character panel sliders; any axis values (`fontStyle: "Bold {wght:650}"`) make that instance for layout, screen and PDF. -- Image Import Options › Crop to for placed PDFs (`file.place {pdfCrop: crop|trim|bleed|art|media}`, Place PDF dialog): the frame shows that page box. -- Stroke styles: striped (thick-thin… and custom bands), wavy and straight-hash strokes now draw on screen and in PDF; named custom stripe/dash/dot styles (`strokeStyle.new/delete/list`, Stroke panel Type list; IDML Striped/Dashed/DottedStrokeStyle). -- Object ▸ Primary Text Frame (`object.primaryTextFrame`): any text frame's story can become (or stop being) the primary story Smart Text Reflow follows. -- Column rules (Text Frame Options › Column Rules, `object.textFrameOptions {columnRule, columnRuleWeight, columnRuleColor, columnRuleTint, columnRuleOffset, columnRuleTopInset, columnRuleBottomInset}`, reported by `document.inspect`): a bar centred in each gutter of a multi-column frame, inside the insets and between the columns of right-to-left and vertical frames too, broken around paragraphs that span columns, with weight, colour, tint, horizontal offset and top/bottom insets; on screen, in PDF (tagged as an artifact) and in IDML (`ColumnRule…` attributes of `TextFramePreference`, also in object styles). The dialog shows the frame's options and changes only the fields edited. Rule stroke types and overprint aren't there yet. -- Place a page of an IDML or DesignCraft document (`file.place {layoutPage}`): its objects, stories, styles and swatches as one group (INDD files are not readable). -- Articles panel (`article.new/add/remove/options/delete/list`): reading order and content of EPUB and HTML exports. -- Object ▸ Clipping Path (`object.clippingPath {type: alpha|edges, threshold, tolerance}`): traces the placed image and makes the outline the frame. -- Gridify: arrow keys while dragging a frame or shape tool add columns/rows (`frame.grid`), one undo step. -- Gap tool (U, `gap.move`): drag the space between objects (or an object and the page edge) and both sides resize. -- XML basics (`xml.newTag/deleteTag/tag/mapStyle/structure`, `file.exportXml`, `file.importXml`; Tags panel with structure; View ▸ Show Tagged Frames): tagged frames export in reading order, mapped paragraph styles become elements, imported elements flow into frames with matching tags. Inline text tagging (`xml.tagText`, an `xmlTag` character attribute, exported as nested elements) and tag markers (View ▸ Structure ▸ Show Tag Markers) too. DTDs (`xml.loadDtd`, `xml.validate`, `xml.deleteDtd`; Tags panel): loading one adds its elements as tags and names the root; validation checks content models (sequences, choices, `?*+`, mixed content, EMPTY/ANY, parameter entities) and required attributes, and lists each problem with its element path. -- Adjust Layout / Layout Adjustment (`adjustLayout` on Document Setup and Margins and Columns): objects follow each page's margin box to its new size and position. -- Content Collector and Placer tools (B; `conveyor.collect/place/list/clear`): copies of objects (with stories, styles, images) ride a conveyor between pages and documents; Alt-click keeps them on it. -- Linked stories (Edit ▸ Place and Link, `story.placeAndLink/links/updateLink/unlink`): child stories copy a parent, show out of date in the Links panel and update. -- Edit ▸ Menus (`window.hideMenuItem`): hide menu items; menus with hidden items end in Show All Menu Items. -- View ▸ Separations Preview (`view.separations`): one process plate as ink density, or areas over an ink limit in red (a GCR preview of the rendered view; spot plates and the flattener preview aren't there). -- Separations Preview plates now come from each object's own colour (CMYK as authored; RGB and images through the colour settings, images via a cached 17³ table), so pure black shows only on the black plate. -- Language (Character panel ▸ Language, Properties ▸ Character, `type.char {language}`): text in other languages isn't hyphenated or spell-checked with the English dictionaries, typographer's quotes follow the language (German „…“, French and Russian «…», Swiss, Spanish, Dutch, Nordic, Japanese and Traditional Chinese 「…」, Simplified Chinese “…”), the font's localized forms (OpenType `locl`: Turkish i, Bulgarian Cyrillic, Japanese / Chinese / Korean glyph variants) follow it too, and so does the fallback for CJK characters the font lacks (with Draw Missing Glyphs from Fallback Fonts on): Japanese, Simplified Chinese, Traditional Chinese and Korean each try their own installed system fonts (Japanese starts with the craft-fonts Japanese faces, Mincho first, when built with them; Hangul, kana and Bopomofo pick their language's fonts whatever the language). Language names from IDML keep their spelling; locale codes as InDesign writes them for some languages (`de_DE_2006`, `nl_NL_2005`; any `ll`, `ll_CC`, `ll-CC`) are their language and region for all of the above, and Chinese is also recognised in its common spellings ("Simplified Chinese", "Chinese (Traditional)", `zh_CN`, `zh-Hant`). Spanish is hyphenated with Javier Bezos's Liang patterns (`hyph-es.tex` 5.0, MIT) through the `hypher` crate; hyphenation for other languages isn't bundled yet. -- Books (`book.new/open/add/remove/list/styleSource/paginate/syncStyles/exportPdf`, Book panel): `.dcbook` files, continuous page numbering, style sync from a style source, one merged PDF. -- Track Changes (`changes.track/list/acceptAll/rejectAll`, Type ▸ Track Changes): typing is marked added (highlighted on screen), deletions are kept but hidden from layout and exports until accepted or rejected. Per-change accept/reject and authors aren't there yet. -- Track Changes: per-change Accept / Reject (`changes.accept`, `changes.reject`) and a Track Changes panel listing every change. -- Move table rows and columns (`table.moveRow`, `table.moveColumn`, Table panel buttons; with whole rows or columns selected, drag them with the Type tool — `table.dropCells`). -- Distribute Rows Evenly (`table.distributeRows`, Table menu and panel): selected rows receive equal fixed heights preserving their composed total height, with undo/redo. Row-spanning merges and rows outside the composed frames are rejected; shrinking tall cells can overset their text. -- Object States (`states.create/show/rename/release/list`, Object States panel): multi-state objects show one state on screen and in PDF. -- Buttons (`button.set/clear/list`, Buttons and Forms panel): go to page / next / previous / first / last / URL as PDF link annotations. Form fields too (`form.set/clear/list`: text fields, check boxes, combo and list boxes, signature fields; required, multiline, defaults) exported as AcroForm widgets through an incremental update (not in PDF/X). -- Power Zoom (Hand tool: press and hold still for half a second, or Alt-press): zoom out to the spread, aim the red view rectangle, release to zoom back in there. -- Graphic cells (`table.placeGraphic`, `table.textCell`, Table › Convert Cell Type, Table panel): images in table cells, fitted or filled, clipped to the cell on screen and in PDF. -- Live Distribute (Space while dragging a selection handle; `transform.resize {distribute}`): objects keep their size and spread with the bounds. -- Liquid Layout (`liquid.pageRule/object`, `guide.liquid`, `layout.createAlternate`, Liquid Layout panel): scale / re-center / guide-based / object-based page rules applied on page resize and Document Setup; dashed liquid guides; alternate layouts as resized page copies in a named section. Text in an alternate layout stays linked to the original stories (Links panel shows them out of date; `story.updateLink`). -- Split Window / New Window (`window.split`, `window.newWindow`, Window ▸ Arrange): two side-by-side views of the document with their own zoom, scroll and render cache. New Window puts the second view in its own OS window (an egui viewport; a floating window on the web). -- Transparency flattener (`file.exportPdf {flatten: high|medium|low|ppi}`, Edit ▸ Transparency Flattener Presets): each object involving transparency becomes an opaque image of its area over what's beneath it, so everything else stays vector (spreads with transparent parent items are rasterised whole). -- Video and sound (place .mp4/.mov/.webm/.mp3/.wav…, `media.options/get`, Media panel): media frames show a poster or placeholder on screen, in print and in PDF, and export as HTML5 `<video>`/`<audio>` in EPUB. Interactive PDF export (`file.exportPdf {media: true}`, File ▸ Export PDF (Interactive)) embeds each file and plays it in a Screen annotation through a rendition action (controls, loop, play on page load). No in-app playback. -- 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 for right-to-left text; IDML DigitsType) draw 0–9 in another script. Kashidas (`kashidas` paragraph attribute, on by default): 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 (Edit ▸ Interface Language, `app.language`): 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. -- 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 our own Generic CMYK ICC 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. -- 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: Edit ▸ Color Settings (`color.settings`: RGB/CMYK working spaces, 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. -- Keyboard: Tab / Shift+Tab hide and show all panels / all but Tools (`window.hidePanels`, `window.hidePanelsExceptTools`); Cmd+F6 / Cmd+Shift+F6 cycle documents; panel fields are reached with Tab and Escape returns to the layout. -- Smart guides and snapping (View ▸ Snap to Guides / Snap to Document Grid / Smart Guides, `view.snapPreferences` and Preferences ▸ Guides & Pasteboard): moving, drawing, resizing, rotating and pen/anchor drags snap in order to the document and baseline grids, ruler guides, margins and columns, other objects' edges and centres (parent-page items too, by visible stroked bounds), equal spacing, matching dimensions and matching rotation, with guides, gap marks and measurements drawn on the canvas. Shift and Cmd constrain the gesture. -- View ▸ Rotate Spread (`view.rotateSpread`, `layout.rotateSpreadView`): the spread in view turns in quarter turns on screen (stored with the document; output is unaffected). The canvas maps each spread through its own transform, so tools, snapping, resize handles, guides, overlays and rendering all follow the turned spread. -- Vertical type (Vertical Type Tool, Type ▸ Story Direction `type.storyDirection`, IDML StoryOrientation): the direction belongs to the story, so every frame of a thread is vertical or horizontal together and a frame threaded onto a story takes its direction (`object.textFrameOptions {vertical}` sets the frames' stories); lines run top to bottom and follow each other right to left; the arrow keys follow them (Down and Up along the line, Left and Right to the next and previous line, keeping the position along it); CJK characters stay upright and other text turns, on screen and in PDF; CJK line breaking with basic kinsoku (also in horizontal text); Korean breaks at spaces, word by word (Hangul in any language, hanja in Korean text), or between syllables when the paragraph asks for it (`type.para {koreanCharBreaks}`, Paragraph Style Options ▸ Justification; not written to IDML). Upright text is shaped top to bottom: punctuation and brackets take their vertical forms (OpenType `vert`; `vkrn` and `vpal` through OpenType features), glyphs advance by their vertical metrics (`vmtx`) and hang from their vertical origins (`VORG`) on the centre of the ideographic em box (`BASE`, else OS/2), which the first line's ascent follows; turned text keeps its horizontal forms. Tate-chu-yoko (Type ▸ Tate-Chu-Yoko, `type.tateChuYoko`, IDML `Tatechuyoko`) sets a run such as "12" side by side within one em of the line. Ruby (Type ▸ Ruby…, `type.ruby`, IDML RubyString; group ruby spread 1-2-1 or centred) and kenten emphasis dots (`type.kenten`, IDML KentenKind) are set over their text — to its right in vertical frames — without changing line breaks. Warichu and ruby options (per-character ruby, positions, overhang) aren't there yet. -- Math expressions (Type ▸ Insert Math Expression…, `math.insert`, `math.svg`): a LaTeX subset (fractions, scripts, roots, big operators with limits, Greek, relations and operators with math spacing, `\left…\right`, `\text`) typeset by our own engine into SVG, placed inline at the cursor or as a frame; the LaTeX is kept as alt text and can be edited. -- Object Layer Options for placed PDFs (`object.pdfLayers`, `object.layerOptions`, Object ▸ Object Layer Options…): the file's optional-content layers listed and shown or hidden per frame on screen; in exported PDFs such graphics go out as 300 ppi images of their visible layers. -- Effects: Inner Glow (edge or centre), Bevel and Emboss (inner bevel lit from the light angle), Satin (`object.innerGlow`, `object.bevel`, `object.satin`); PDF export now keeps soft effects: objects with shadows, glows, feathers, bevels or satin go out as 300 ppi transparent images of their appearance (threaded text frames excepted). -- Directional Feather (`object.directionalFeather {widths: [top, left, bottom, right]}`): each side fades over its own width. -- View ▸ Flattener Preview (`view.flattenerPreview`): objects involving transparency highlighted in red; knockout groups export as images (PDF output has no knockout groups). -- Live captions (`object.caption {live: true}`, Object ▸ Captions ▸ Generate Live Caption): the caption text is re-filled from the source object's metadata after every edit (same undo step). -- Style Export Tagging (`style.exportTag`, Paragraph Style Options ▸ Export Tagging): paragraph and character styles choose their HTML element and class in EPUB/HTML. -- Tagged Text: export (`file.exportText {format: "tagged"}`) and place (detected by its header; ASCII, UTF-8 or UTF-16) with paragraph and character styles, font, typeface and size, and `<0x…>` characters. -- Tagged PDF: one structure element per paragraph (H1–H6 and BlockQuote from Export Tagging, else P) in reading order, frame fills, strokes, rules and tables as artifacts, figures with alt text. -- EPUB: cover image from the first page (`file.exportEpub {cover}`), and Fixed Layout export (`file.exportFixedEpub`, File ▸ Export EPUB (Fixed Layout)…): pre-paginated pages as 144 ppi images with their text kept for search and read-aloud. -- Interactive PDF options (`file.exportPdf {fullScreen, bookmarksPanel, pageLayout, view, advanceSeconds}`, File ▸ Export PDF (Interactive)…): page mode, layout, opening view and page timing in the catalog and pages. -- Object Export Options (`object.exportOptions`): tagged-PDF artifact, EPUB/HTML rasterize (the object as an image), alignment and page break before, alt text. -- Scripts panel (Window ▸ Utilities ▸ Scripts, `script.run`): saved command scripts (with `$N.path` references to earlier results) run as one undo step; edit, add, load and delete them in the panel. Scripts use DesignCraft's command language, not ExtendScript. -- Drag and drop text editing: drag selected text to move it (Alt copies) with its formatting (`text.release`). -- Image Import Options: place any page of a multi-page PDF (`file.place {pdfPage}`; the app asks which page), shown on screen and embedded as that page in PDF export. -- Define Lists: named numbered lists that continue across stories in page order (`list.define`, paragraph `listName` / `startAt`), round-tripped through IDML; `null` in `type.para` / `type.char` now removes an override. -- View › Overprint Preview (⌥⇧⌘Y): overprinting fills, strokes and 100% [Black] text mix with what's beneath on screen. -- Type on a Path (⇧T tool, `type.onPath`): a story along any line, curve or shape on screen and in PDF; Type on a Path Options (start, flip, baseline/ascender/descender/centre) and Delete Type from Path. Round-trips through IDML as TextPath; on-path caret drawing is still to come. -- Preferences › Appearance of Black: 100% K on screen accurately (dark grey) or as rich black (`window.richBlack`); the printing/export side is still to come. -- Spreads of up to 10 pages: Add to Previous Spread and Allow Spread to Shuffle (Pages panel; `layout.pages.toSpread`, `layout.spreadShuffle`); inserted pages go around kept spreads. -- Page tool (⇧P) and `layout.pageSize`: pages of their own size (or a preset); the spine stays put and objects keep their place on their page. -- File › Print (⌘P): printer, copies, page range, spreads, marks, bleed; printed as PDF through the system spooler (`file.print`, `file.printers`, `dryRun` for agents). Shortcuts now act like choosing the menu item (dialogs open), and UI commands win shortcut ties (⌘N opens New Document). -- Nested styles (through/up to N sentences, words, characters, letters, digits, tabs, breaks, spaces or given characters) and GREP styles (regex → character style), edited in Paragraph Style Options and composed under local formatting; `ui.dialog.open` for agents. Nested line styles are still to come; nested and GREP styles round-trip through IDML. -- Table and cell styles (round-trip through IDML): cell styles (fill, insets, vertical justification, strokes, paragraph style) and table styles (region cell styles, border, alternating rows, spacing); apply from the Table panel, edits re-apply to every user; `style.cell.*`, `style.table.*`. -- Style groups: paragraph and character styles shown in group folders, Move to Group (new, existing, none); `style.group` renames every use (styles, stories, table cells, footnotes, object styles). -- Scrubby zoom: drag the Zoom tool left or right to zoom continuously around the press point. -- Guides belong to the active layer: hidden with it (or with its Show Guides off) and locked with it. -- Pencil tool (N): freehand strokes simplified to smooth paths (Alt closes); Smooth and Erase tools re-fit or remove the stretch you drag along (`path.smooth`, `path.erase`). -- Edit › Keyboard Shortcuts: every command, click and press the new keys, conflicts shown, per-command and global reset; `window.setShortcut`. -- Primary Text Frame (New Document) and Smart Text Reflow: pages are added while the primary story oversets and empty ones at the end removed, in the same undo step (Preferences › Type). -- Glyphs panel: every character of any font (rendered by our renderer), search by character or U+code, click to insert, recently used. -- UI scaling (Preferences › Interface, 50–200%; `window.uiScale`); the window title steps aside when the bar is crowded. -- Underline / Strikethrough Options: weight, offset, colour and tint (Character panel; screen, PDF and IDML). -- Relink to Folder and Relink File Extension (`links.relinkFolder`; missing links found by name). -- File › Package (⌥⇧⌘P): the document relinked to a Links folder, the placed files, the font files that draw its text in a Document Fonts folder (the fonts it names and any fallback fonts that draw characters those lack; except fonts whose licence restricts it, OS/2 `fsType`), an IDML copy, an optional PDF and a report (fonts, fallback fonts, links, preflight, instructions); Copy Links To (`links.copyTo`). -- Document fonts: opening a `.designcraft` or `.idml` file loads the fonts in the `Document Fonts` folder beside it (as Package writes it), ahead of installed fonts with the same name, so a packaged document opens with its fonts on another machine. Damaged, oversized (over 256 MB per file, 512 MB in all) and surplus (over 200) font files are skipped and listed in `file.open`'s `warnings`; `font.list` gives each font's `source`. Document fonts belong to their document, as in InDesign: other open documents don't see them (each document with a same-named font draws with its own), and closing the document (or reverting it, or a book chapter's export ending) makes them unavailable. Their files stay in memory until DesignCraft quits, so reopening a document doesn't read or load them again; on the web there is no folder to read. -- Load Swatches / Save Swatches for Exchange (.ase: CMYK, RGB, Lab, Gray; spot or process) from the Swatches panel menu; `swatch.load` / `swatch.save`. -- Pathfinder (Object › Pathfinder and a Pathfinder panel): Add, Subtract, Intersect, Exclude Overlap, Minus Back; curves stay curves (flo_curves); `object.pathfinder`. -- Make / Release Compound Path (⌘8; nested paths become holes), Create Outlines (⇧⌘O; one path per text colour), Scissors tool (C) with `path.split`. -- Transform Again / Individually / Sequence Again (⌥⌘3; a moved copy repeats like Step and Repeat), Clear Transformations; Layers: Merge, Delete Unused, Hide/Lock Others, Show/Unlock All; Break Link to Style (paragraph and character). -- Floating panels: tear a panel off the dock (its float button, or drag its header away) into a movable, resizable window; Dock puts it back; positions persist; `window.floatPanel` / `window.dockPanel`. -- Place SVG (vector in PDF export via krilla-svg, resvg on screen, text in the bundled fonts), Photoshop (.psd/.psb composite in its colour mode — Bitmap, Grayscale, Duotone, Indexed, RGB, CMYK, Lab, 8/16-bit — with merged transparency; extra alpha and spot channels don't change the image), BMP, and Illustrator files saved with PDF compatibility (.ai). New `designcraft-images` crate (L2) for sniffing, sizes and decoding. EPS is still missing. -- View › Display Performance: Fast (grey boxes, no effects, ⌥⇧⌘Z), Typical (72 ppi proxies, ⌥⌘Z), High Quality (⌥⌘H, our default — the renderer is fast enough). -- OpenType menu (Character panel): discretionary ligatures, fractions, ordinal, swash, titling, contextual alternates, slashed zero, the four figure styles and stylistic sets 1–20; `type.openType`; mapped to IDML's OTF attributes. -- Add Anchor Point (=), Delete Anchor Point (-) and Convert Direction Point (⇧C) tools (click toggles smooth/corner, drag pulls out handles), with `path.addAnchor`, `path.deleteAnchor`, `path.convertAnchor`. -- Gradients: the Gradient Swatch tool (G) drags a gradient's start and end across objects (Shift: 45°); the Gradient panel sets type, angle, reverse and edits stops on a ramp (drag, add, drag off to remove, location, colour). Edited gradients are unnamed swatches; `object.gradient`; the vector round-trips through IDML. -- Document Setup (⌥⌘P): intent, number of pages, start page # (an even start begins with a left page), facing pages, size presets and orientation, bleed and slug per edge; `layout.documentSetup {}` reports the setup. -- 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. -- 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. -- Place PDF: placed PDF pages are sized by their crop box, drawn on screen through hayro (with mip levels) and embedded in exported PDFs as vector form XObjects (krilla), `<PDF>` in IDML. -- Menus expose the engine's features (TOC, numbering & sections, text variables, hyperlinks, cross-references, footnotes, step and repeat, spelling, fitting/content, effects, Data Merge, Preflight, EPUB); any menu command with parameters gets a dialog generated from its parameter documentation. -- Table of contents (generate/update, dot leaders) and text variables (running headers, last page number, chapter number, file name, dates, custom) resolved per page. -- Soft effects: blurred drop shadow, inner shadow, outer glow, basic feather. -- ~200 commands, all reachable through the JSON control channel; headless CLI rendering to PNG. -- MCP server (`designcraft-cli mcp [--connect PORT]`, docs/mcp.md): headless engine or the running app; commands, batch, document/story inspection, page renders as images, window screenshots, pointer/keyboard/dialog input. -- Web build (`apps/designcraft-web`, trunk): the same UI on WebGPU with a WebGL2 fallback; open/place via the browser file picker or drag-and-drop, save/export as downloads. - -**Next (in order):** books · New Window / split view · Ink Manager and Separations Preview · XML structure, tags and export · buttons and forms · PDF/X-4 output intent (needs krilla support) · EPS place. - -## 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 -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 -four agents in parallel on separate crates: - -| Work | Estimate | -|---|---| -| Open P0 (2: PDF/X-4 validation against a certified checker, PostScript interpretation for EPS) | 8 h | -| Open P1 (none partial) | 0 h | -| Open P2 (3 partial: localization breadth, INDD pages (not readable clean-room), in-app media playback) | 14 h | -| Depth and pixel fidelity of every dialog, panel and menu against InDesign 2026 | 90 h | -| Performance (incremental composition, GPU raster) and interchange hardening (IDML/PDF corpus) | 55 h | - -Basis: since the first estimate this effort landed about 45 arcs (from parent overrides, Preferences, Stroke panel, -gradients and Pathfinder to table/cell styles, nested/GREP styles and Print) in roughly 14 hours on a heavily loaded -machine — about 1–2 hours per catalogue row. - -## Agents: CLI and MCP - -Every command is reachable from `designcraft-cli` (`run`, `script` with `$N.path` result references, `app` for the -running window, `describe`, `commands`), from MCP (`designcraft-cli mcp`, tools including `execute` and `batch` with the -same references), and from the app's JSON control channel. See [docs/agents.md](docs/agents.md). - -## Milestones - -| # | Milestone | State | +| | Value | Kind | |---|---|---| -| M0 | Skeleton + vertical slice | ✅ | -| M1 | Selection, transform, layers, pages, MCP | in progress | -| M2 | Type I (Type tool, threading, Character/Paragraph, composer) | in progress | -| M3 | Styles (nested/GREP, bullets, keeps, span columns) | started | -| M4 | Color & effects | started | -| M5 | Graphics & links | started | -| M6 | Files & export (native, IDML, PDF, PNG/JPEG) | in progress (IDML ✅, PDF export) | -| M7 | Long documents (sections, TOC, index, footnotes, books) | in progress (sections, TOC, text variables/running heads, hyperlinks, bookmarks, footnotes, cross-references, index) | -| M8 | Tables | ✅ core (table/cell styles, rotation, diagonal lines pending) | -| M9 | Performance (MT composition, tiles) | in progress (glyph path cache, bounded RTL label layout cache, culling, parallel compose 6.7× faster, perf harness) | -| M10 | Find/Change, spelling, Preflight | in progress (Find/Change ✅, spelling ✅, Preflight) | -| M11 | Layout power features (liquid/alternate layouts, data merge) | started (data merge phase 1, step & repeat, snippets) | -| M12 | Interactive & digital (EPUB, HTML, interactive PDF) | started (EPUB, PDF links/bookmarks) | -| M13 | Automation (scripts, batch) | | -| M14 | 1.0 polish & packaging | | +| **Feature breadth** | **~80%** (69% of InDesign 21.6's in-scope menu items matched; 99% of our 275-row checklist) | measured bounds, estimate between | +| **Ready for real work** | **~47%** (range 42–52%) | estimated: weighted sum over the dimensions below | +| Mainstream practitioner | **~42%** (weekly core 60.7% × 0.88 interaction × 0.92 stability × 0.85 file exchange) | estimated | +| Essentials user | **~54%** (core features 72% × 0.90 launch × 0.88 discoverability × 0.95 files received) | estimated | +| Remaining to **beta** | **420–750 Opus 5.5 agent-hours** (~120–200 wall-clock hours with 4–5 agents) | estimated | +| Remaining to **full parity** | **1,150–1,950 Opus 5.5 agent-hours** | estimated | + +| Audience | Ready | Opus 5.5 agent-hours to ~95% | Work that dominates | +|---|---|---|---| +| Full target (ready for real work) | ~47% | 1,150–1,950 (~70% parallel) | depth across all areas, scripting compatibility, localization, typography fidelity, INDD/IDML | +| Mainstream practitioner | ~42% | 500–850 (~65% parallel) | INDD import and IDML corpus, composition fidelity, weekly-area depth, PDF/X-1a and printing | +| Essentials user | ~54% | 135–250 (~50% parallel) | start-up robustness, context menus and copy/paste, threading, fonts, Windows printing | + +**Why pre-alpha:** it fails one of InDesign's core workflows, the alpha gate in +[docs/roadmap.md](docs/roadmap.md#alpha-gate). A user can't thread a story from one frame to +another: the in and out ports are drawn, but clicking the out port does nothing and there is no +thread or unthread command. Long text flows only through autoflow on Place and Primary Text +Frame, which users report adding and keeping pages wrongly (#165, #140, #194, #352). Everything +else in the gate works end to end on macOS (document setup, styles, images, object editing, PDF +export, save and reopen), and ready for real work (~47%) is above the alpha bar, so manual +threading is the whole distance to alpha. Beyond that, beta needs INDD opening, IDML proven on +real files, composition compared with InDesign's, and basic editing fixed on Windows and Linux. +Details: [docs/target-app-parity.md](docs/target-app-parity.md). + +## By dimension + +| Dimension | Ready | Hours | Doc | +|---|---|---|---| +| Features (depth) | 55% | 250–400 | [target-app-parity.md](docs/target-app-parity.md) | +| Typography and composition fidelity | 45% | 120–200 | [typography-parity.md](docs/typography-parity.md) | +| File formats | 40% | 150–250 + 20–60 (INDD) | [file-format-parity.md](docs/file-format-parity.md) | +| UI/UX fidelity | 45% | 120–180 | [ui-parity.md](docs/ui-parity.md) | +| Stability | 45% | 60–100 | [gaps.md](docs/gaps.md) | +| Performance | 65% | 40–80 | [gaps.md](docs/gaps.md) | +| Localization | 45% | 120–180 | [localization-parity.md](docs/localization-parity.md) | +| Platforms | 55% | 40–80 | [hardware-parity.md](docs/hardware-parity.md) | +| Hardware | 45% | 20–40 | [hardware-parity.md](docs/hardware-parity.md) | +| Ecosystem (scripting, plug-ins) | 15% | 150–250 | [gaps.md](docs/gaps.md) | +| AI features | 15% | 60–120 | [gaps.md](docs/gaps.md) | + +All "Ready" figures are estimates; the weights and evidence are in target-app-parity.md. + +## Features + +Breadth from [docs/parity-checklist.md](docs/parity-checklist.md) (`cargo xtask parity`, +measured); Ready estimated. + +| Area | Breadth | Ready | Hours | +|---|---|---|---| +| Application shell & workspace | 99% | 55% | 40–60 | +| Documents, pages, spreads | 100% | 65% | 20–35 | +| Layers | 100% | 75% | 5–10 | +| Frames, shapes & paths | 100% | 60% | 25–40 | +| Transform | 100% | 70% | 10–20 | +| Fill, stroke, colour | 100% | 60% | 20–35 | +| Effects & transparency | 100% | 50% | 20–30 | +| Placing & links | 93% | 55% | 20–35 | +| Type & text frames | 99% | 45% | 40–60 | +| Typography | 100% | 45% | see typography-parity.md | +| Styles | 100% | 60% | 15–25 | +| Tables | 100% | 55% | 25–40 | +| Long documents | 100% | 45% | 30–50 | +| Interactivity & digital | 100% | 40% | 30–50 | +| Output & production | 92% | 55% | 40–70 | +| XML & automation | 100% | 35% | see Ecosystem | +| View & navigation | 100% | 65% | 10–20 | +| Undo, history, saving | 100% | 70% | 5–10 | +| Accessibility | 100% | 50% | 15–25 | + +## Languages + +Interface strings translated, of 1,724 measured. Detail: [docs/localization-parity.md](docs/localization-parity.md). + +| Language | Code | Status | Translated | +|---|---|---|---| +| English | en | full | 100% | +| Simplified Chinese | zh | partial (no canvas IME) | 83% | +| Spanish | es | partial | 83% | +| Hindi | hi | none | 0% | +| Arabic | ar | partial (RTL UI and composition) | 85% | +| French | fr | partial | 83% | +| Portuguese (Brazil) | pt-br | partial | 61% | +| Indonesian | id | none | 0% | +| Japanese | ja | partial (no canvas IME) | 95% | +| German | de | partial | 83% | +| Korean | ko | none | 0% | +| Vietnamese | vi | none | 0% | + +Also shipped: Italian (85%), Ukrainian (83%). + +## Upcoming + +Ranked; detail and estimates in [docs/roadmap.md](docs/roadmap.md), the full list in +[docs/gaps.md](docs/gaps.md). + +1. **Alpha gate:** manual text threading (out-port click, thread/unthread commands) and Primary Text Frame fixes (#165) (15–25 h) +2. Basic editing on every platform: copy/paste, context menus, caret formatting (30–50 h) +3. CI running `cargo xtask ci` on pull requests (4–8 h) +4. Open INDD through a clean-room converter, owner decision on #114 (20–60 h) +5. IDML real-file corpus and fixes (60–100 h) +6. Composition oracle against InDesign; optical kerning; overset rule (80–140 h) +7. Start on any GPU; find every installed font (25–50 h) + +## Documents + +[target-app-parity.md](docs/target-app-parity.md) (assessment) · [gaps.md](docs/gaps.md) (work +list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.md](docs/parity-checklist.md) +(presence checklist) · [typography-parity.md](docs/typography-parity.md) · +[file-format-parity.md](docs/file-format-parity.md) · [ui-parity.md](docs/ui-parity.md) · +[hardware-parity.md](docs/hardware-parity.md) · [localization-parity.md](docs/localization-parity.md) · +[architecture.md](docs/architecture.md) · agents: [agents.md](docs/agents.md), [mcp.md](docs/mcp.md), +[control-protocol.md](docs/control-protocol.md) · CJK: [cjk.md](docs/cjk.md), +[cjk-typography.md](docs/cjk-typography.md) · Arabic: [arabic-typography.md](docs/arabic-typography.md) + +## Progress log + +Newest first. + +- **2026-10-10** · Readiness per audience: full ~47% (method aligned with the standard, was + 42%), mainstream practitioner ~42%, essentials user ~54%, each with hours to ~95%. +- **2026-10-10** · Stage re-normalized to **pre-alpha** under craftrules' core-workflow gate: text + can't be threaded by hand (no out-port click, no thread command). +- **2026-10-10** · Full re-measure against InDesign 2026 21.6 (menu dump, bundle formats and + localizations, 124 issues): breadth ~80%, ready ~42% (down from the ~83% "including depth" of + 2026-10-04, on user evidence and the INDD gap); progress docs restructured to the craftrules + standard (`docs/parity.md` → `docs/parity-checklist.md`; new target-app-parity, gaps, roadmap, + architecture, typography, file-format, UI, hardware and localization docs). Landed today: + split columns (#295), PDF export options dialog (#196), column rules (#274), Paragraph Rules + dialog (#263), Character Style Options (#144), PDF/X-4 RGB handling (#223), Spanish + hyphenation (#222), Japanese (#272), Italian (#218) and Ukrainian (#236) interfaces, relocated packages relink (#244), graphics backend chosen + before the window (#297), IDML table styles (#288), warichu (#280), list numbering from IDML + (#298), security hardening (#153, #155), many text-composition fixes (#282–#293). +- **2026-10-09** · Version 0.5.0; many user reports triaged into issues (#160–#265). +- **2026-10-08** · Version 0.4.0. +- **2026-10-07** · Community PRs: data merge, Brazilian Portuguese, PDF/X-4 flags, Office fonts + on macOS; CJK 1.x (Korean line breaking, fallback fonts in Package); PrintCraft renamed PdfCraft. +- **2026-10-06** · Snapping to parent items, Windows packaging, ARM64 workflow, control-channel + hardening. +- **2026-10-05** · FreeBSD build; caret-boundary fixes. +- **2026-10-04** · Never-crash pass across importers and the engine; rotate spread view; kashida + in styles; previous estimate: breadth 99%, "~83% including depth", ~170 h remaining. +- **2026-10-03** · Release 0.1.1; separations, XML tagging, track changes, PDF/X-4 output + intent, split window, buttons, menu customization. +- **2026-10-02** · Hyperlinks/Bookmarks panels, conditional text, tagged PDF, lists across + stories, style groups, Glyphs panel, High Contrast theme. +- **2026-10-01** · First commit; skeleton, IDML, PDF, tables, footnotes, cross-references, index, + spelling and parallel composition on the first day. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | Full number 42% → 47% (method aligned with the standard: plain weighted sum, no new evidence); readiness table with hours per audience | +| 2026-10-10 | minor | Headline adds mainstream practitioner (~42%) and essentials user (~54%); method in docs/target-app-parity.md | +| 2026-10-10 | minor | Stage alpha → pre-alpha under the new core-workflow gate: manual text threading is missing; alpha gate table added to docs/roadmap.md | +| 2026-10-10 | major | Restructured to the progress-docs standard; full re-measure; the "Working today" inventory moved to docs/target-app-parity.md, milestones to docs/roadmap.md, the CLI/MCP section left to docs/agents.md (which already covered it) | +| 2026-10-04 | major | Parity estimate: breadth 99%, depth ~83%, ~170 h | +| 2026-10-01 | major | First roadmap: status, milestones | diff --git a/docs/arabic-typography.md b/docs/arabic-typography.md index bff397d26..7bc6a98aa 100644 --- a/docs/arabic-typography.md +++ b/docs/arabic-typography.md @@ -1,5 +1,7 @@ # Arabic import and composition +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-07 · **Change:** trivial (status line and revision history added; content unchanged in the 2026-10-10 review) · **Target:** Adobe InDesign 2026 + Task: AR1. These changes repair verified gaps in the existing typesetting pipeline. They do not claim full InDesign or vendor-specific Naskh composition parity. @@ -74,3 +76,10 @@ compatibility warnings remain, along with separate font/link/overset findings. - [Diacritic position enumeration](https://developer.adobe.com/indesign/uxp/dom/api/d/diacritic-position-options/) and [positional forms](https://developer.adobe.com/indesign/uxp/dom/api/p/positional-forms/) identify serialized options; their presence alone does not establish visual parity. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | trivial | Reviewed in the full re-measure; status line and this table added; summarized in [typography-parity.md](typography-parity.md) and [localization-parity.md](localization-parity.md) | +| 2026-10-07 | major | Created: AR1: Arabic import and composition | diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 000000000..4f06fbcee --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,98 @@ +# DesignCraft architecture + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first committed version, from the code on main and the local plan) · **Target:** Adobe InDesign 2026 + +How DesignCraft is built today. The design notes behind it are in `plan/architecture.md` +(local, gitignored); this file describes what the code on `main` does. Agents: AGENTS.md has the +rules (never crash, everything is a command, layering, clean-room). + +## Workspace + +A Cargo workspace: 16 library crates in `crates/`, 3 apps in `apps/`, and `xtask`. 126,411 +lines of Rust, 1,122 tests (2026-10-10). Pure Rust, no C dependencies by default, +`unsafe_code = "forbid"` workspace-wide. + +| Layer | Crate | What it does | Lines | +|---|---|---|---| +| L0 | `geom` | kurbo re-export, units and measurement parsing (`1p6`, `12mm`, `3in+2p`), corner shapes, polygons, transforms, hit testing | 2.5k | +| L0 | `color` | CMYK/RGB/Gray (Lab in CMS), swatches, tints, gradients, blend modes, ASE, ICC colour management (`cms/`), colour harmony | 2.9k | +| L1 | `doc` | the document model: spreads, pages, parents, layers, items, stories (text + run lists), tables, notes, styles of five kinds, swatches, sections, variables, CJK and Arabic attributes | 10.2k | +| L1 | `fonts` | font database (bundled OFL fonts, system fonts, document fonts, optional craft-fonts), metrics, outlines (skrifa), shaping (harfrust), variable instances, font classification for menus | 3.5k | +| L2 | `compose` | the text engine: style resolution, shaping, Knuth–Plass and single-line breaking, Liang hyphenation, justification, tabs, keeps, columns, span/split, baseline grid, vertical justification, wrap, threading, overset, anchored objects, footnotes, tables, bidi, CJK (ruby, warichu, kinsoku), caret and hit testing | 11.7k | +| L2 | `images` | decoding placed graphics (PNG, JPEG, GIF, WebP, TIFF incl. CMYK, BMP, PSD composite, EPS preview) | 1.6k | +| L3 | `render` | document to pixels with vello_cpu (SIMD, multithreaded): items, composed text, images with mips, gradients, effects, damage regions, placed PDF via hayro | 3.5k | +| L3 | `pdf` | PDF export with krilla: real text with subset fonts, CMYK and spot, bleed and marks, tagged PDF, links, forms, transitions, PDF/X-4 and PDF/A-2b | 5.0k | +| L3 | `idml` | IDML import and export (designmap, spreads, parents, stories, styles, graphics, preferences; CJK and Arabic attributes) | 8.0k | +| L3 | `epub` | EPUB 3 reflowable and fixed-layout export | 0.9k | +| L3 | `textimport` | Word (.docx), RTF, Excel (.xlsx), Tagged Text import; text export | 1.8k | +| L3 | `format` | the native `.designcraft` zip (`document.json`, `assets/`, `meta.json`), versioned | 0.3k | +| L4 | `tools` | tool state machines: pointer events and modifiers to commands and overlays; snapping and smart guides | 5.9k | +| L5 | `engine` | `Session`: documents, history, selection, the command registry (446 commands in `cmd/`), compose cache, preflight, find/change, scripts, recovery, links | 29.3k | +| L6 | `ui-egui` | the InDesign-style egui frontend: menus, Control panel, Tools, dock and panels, dialogs, canvas, theme tokens, i18n, the control channel | 33.4k | +| L6 | `mcp` | MCP server over stdio, headless or proxying to the running app | 1.7k | +| L7 | `apps/designcraft` | desktop binary (eframe/wgpu), file dialogs, control server | 1.6k | +| L7 | `apps/designcraft-cli` | headless CLI: `run`, `script`, `app`, `describe`, `commands`, `mcp` | 1.2k | +| L7 | `apps/designcraft-web` | the same UI in WASM (WebGPU, WebGL2 fallback) | 0.3k | + +`cargo xtask layers` enforces the layering: a crate depends only on lower layers (with the +intra-layer orders geom → color and doc → fonts); nothing below L6 depends on egui, eframe, winit +or rfd; L0–L6 compile for `wasm32-unknown-unknown` (`cargo xtask wasm`). + +## Data model + +Coordinates are points (1/72 in), y down; each spread has its own coordinate system with its +origin at the top left of its first page. A `Document` holds settings, spreads and parent +spreads (items under `Arc`, so edits are copy-on-write and undo is cheap), layers, stories, +styles (paragraph, character, object, table, cell; based-on chains and groups), swatches, +sections and text variables. A story is one `String` plus paragraph and character run lists of +`{len, style, overrides}`; effective attributes are default ← based-on chain ← style ← overrides. +Special characters (page number, footnote reference, anchored object, table) are placeholder +code points in the text. Threading is the story's ordered frame list. + +## Commands, the only way to change a document + +Every user-visible action is a `CommandSpec` (`id`, `label`, menu path, shortcut, parameter +doc, `enabled`, `run`, journaled, undoable) in `crates/engine/src/cmd/*`. The UI, the CLI, MCP +and the control channel all call `Session::execute(id, params)`. UI-only commands (windows, +panels, zoom, dialogs) are `UI_COMMANDS` in `crates/ui-egui/src/menus.rs`, reachable the same way. +Tool gestures emit Begin/Preview/Commit commands so a drag is one undo step. Every journaled +command is replayable as a script. Escaped panics during a command or an import/export become an +error (`engine/src/guard.rs`), never a lost document; unsaved documents are written to a recovery +folder every 30 s. + +## Composition and rendering + +The compose cache is keyed by story revision, frame geometry, style revision and font database +revision; stories compose in parallel (rayon) on native targets. Composition produces per-frame +lines with positioned glyph runs, decorations, anchors and overset. The canvas renders on a +background worker with vello_cpu into tiles; after an edit only frames whose composition changed +are re-rendered and patched (damage regions, pixel-identical to a full render). The GPU (wgpu: +Metal, DirectX 12, Vulkan, OpenGL; WebGPU/WebGL2 on the web) only presents; the backend is chosen +before the window exists, with a start-up fallback list in `gpu.json`. + +## File I/O + +Native `.designcraft` (zip + JSON); IDML both ways; PDF, EPUB, HTML, PNG/JPEG, text, XML export; +placed graphics through `images` (PDF/AI pages through hayro); Word/RTF/Excel/Tagged Text through +`textimport`. INDD is not readable. Details: [file-format-parity.md](file-format-parity.md). + +## Agent control + +The desktop app opens a loopback JSON-lines control channel (`--control PORT`) with +`engine.execute`, inspection, real input injection (`ui.pointer`, `ui.key`, `ui.click`, `ui.drag`), +dialog control, screenshots and offscreen renders. `designcraft-cli mcp` exposes the same as MCP +tools, headless or connected to the app. See [agents.md](agents.md), [mcp.md](mcp.md), +[control-protocol.md](control-protocol.md). + +## Quality gates + +`cargo xtask ci`: rustfmt, clippy `-D warnings`, tests, `assets` (every asset attributed in +ASSETS.md), `layers`, `wasm`. Clean crates deny `unwrap`/`expect`/`panic!`/`todo!`/ +`unimplemented!`/`unreachable!`. CI on GitHub currently runs only the FreeBSD and Windows ARM64 +builds (on manifest changes), packaging lint and the release workflow; `xtask ci` runs locally. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | Created from the code on main (92649ff) and `plan/architecture.md` | diff --git a/docs/cjk-typography.md b/docs/cjk-typography.md index f85b14ede..49d350b88 100644 --- a/docs/cjk-typography.md +++ b/docs/cjk-typography.md @@ -1,5 +1,7 @@ # CJK import and composition +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-07 · **Change:** trivial (status line and revision history added; content unchanged in the 2026-10-10 review) · **Target:** Adobe InDesign 2026 + Task: CJK1. This is incremental support, not a claim of InDesign composition parity. ## Data flow @@ -62,3 +64,10 @@ removing unsupported-rule warnings to produce a clean report is not acceptable. Tests use synthetic documents; user IDML files and proprietary application output are not committed as fixtures. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | trivial | Reviewed in the full re-measure; status line and this table added; summarized in [typography-parity.md](typography-parity.md) and [localization-parity.md](localization-parity.md) | +| 2026-10-07 | major | Created: CJK1: IDML CJK import and composition | diff --git a/docs/cjk.md b/docs/cjk.md index 5da0a74a8..179b6f250 100644 --- a/docs/cjk.md +++ b/docs/cjk.md @@ -1,5 +1,7 @@ # CJK typesetting plan +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** trivial (status line and revision history added; content unchanged in the 2026-10-10 review) · **Target:** Adobe InDesign 2026 + Goal: the Chinese, Japanese and Korean typesetting that InDesign offers with its East Asian feature set (the "Japanese/CJK" feature set, `Feature Set Locale Setting` 257), so that a DesignCraft document can be set to the standard of a Japanese, Chinese or Korean publisher, @@ -285,3 +287,11 @@ Each follows what an InDesign user expects. Chinese, since the exact names InDesign writes are unconfirmed without a sample file. 8. **Missing glyphs** are drawn as the font's box and reported, as in InDesign; drawing them from fallback fonts is a per-document choice, on only for documents saved before it existed. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | trivial | Reviewed in the full re-measure; status line and this table added; summarized in [typography-parity.md](typography-parity.md) and [localization-parity.md](localization-parity.md) | +| 2026-10-10 | minor | Updated (see git history) | +| 2026-10-06 | major | Created: CJK typesetting plan and status (the Japanese/CJK feature set) | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md new file mode 100644 index 000000000..90777577e --- /dev/null +++ b/docs/file-format-parity.md @@ -0,0 +1,110 @@ +# File format parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first version: every format InDesign 2026 declares, measured against the code) · **Target:** Adobe InDesign 2026 (21.6) + +Every format InDesign 2026 reads or writes, with DesignCraft's support, fidelity and tests. The +InDesign list comes from its bundle's `Info.plist` (`CFBundleDocumentTypes`, +`UTExportedTypeDeclarations`), its import/export filter folder names and its File menus; ours from +the open/place/export filters in `apps/designcraft/src/main.rs` and the `file.*` commands. +Overall numbers: [target-app-parity.md](target-app-parity.md); open problems: [gaps.md](gaps.md). + +**Dimension score: ~40% ready (estimated), 150–250 h + 20–60 h for INDD integration.** The +single largest gap is that `.indd` can't be opened. + +Read/Write: **✓** supported · **~** partial · **✗** missing · **—** InDesign doesn't either. +Weight: what share of an InDesign professional's file traffic the format carries (used for the +score below). + +## Documents and interchange + +| Format | Ext | InDesign | DesignCraft read | DesignCraft write | Fidelity / tests | Weight | +|---|---|---|---|---|---|---| +| InDesign document | `.indd` | read/write (native) | ✗ (#21; converters proposed in #98, #114) | ✗ | — | 30% | +| InDesign template | `.indt` | read/write | ✗ | ✗ | — | 2% | +| InDesign Markup | `.idml` | read/write | ✓ | ✓ | Synthetic fixtures in `crates/idml/src/tests.rs` (49 tests); exported files verified by opening in InDesign 2026 by hand; indd-utils' 98-document run imports 811 spreads / 2,866 stories / 5,812 items. Open: #193, #338, #339, #340, #211. No real-file corpus test | 20% | +| DesignCraft native | `.designcraft` | — | ✓ | ✓ | zip + JSON, versioned; round-trip tests | — | +| InDesign book | `.indb` | read/write | ✗ (own `.dcbook`) | ✗ | Books are shallow (see gaps.md) | 2% | +| InDesign library | `.indl`, `.inlx` | read/write | ✗ (own `.dclib`) | ✗ | | 1% | +| Snippet | `.idms` | read/write | ✗ (own snippets) | ✗ | | 1% | +| InCopy story / assignment | `.icml`, `.icma`, `.incx`, `.inca` | read/write | ✗ | ✗ | InCopy workflow out of scope; ICML story import would still help | 1% | +| InDesign Interchange (legacy) | `.inx` | read | ✗ | — | | <1% | +| PageMaker | `.pmd`, `.p65`, `.t65`, `.pmt` | read | ✗ | — | | <1% | +| QuarkXPress | `.qxd`, `.qxt` | read (old versions) | ✗ (#73) | — | | <1% | + +## Output + +| Format | Ext | InDesign | DesignCraft | Fidelity / tests | Weight | +|---|---|---|---|---|---| +| PDF (print) | `.pdf` | ✓ all presets, PDF/X-1a/3/4, PDF/A, PDF/UA | ✓ krilla: real text with subset fonts, DeviceCMYK/RGB, spot Separations, bleed/slug, marks, spreads, compression options, remembered settings (#196); **PDF/X-4** with output intent and a validator (#223, #200); **PDF/A-2b**; tagged PDF | 53 tests in `crates/pdf`; no PDF/X-1a or X-3 (needs flattening to PDF 1.3/1.4); no presets / `.joboptions`; no external validator (veraPDF / callas) in CI | 18% | +| PDF (interactive) | `.pdf` | ✓ | ✓ links, bookmarks, buttons, AcroForm fields, page transitions, object states | | 2% | +| JPEG / PNG | `.jpg`, `.png` | ✓ | PNG from the UI; JPEG from the CLI only | no resolution / colour space options dialog for JPEG | 3% | +| EPUB reflowable | `.epub` | ✓ | ✓ stories in article order, CSS from styles, images, nav | | 2% | +| EPUB fixed layout | `.epub` | ✓ (live HTML/CSS) | ~ page images with text kept for search | not live text | 1% | +| HTML | `.html` | ✓ | ~ one self-contained page (#314) | | 1% | +| HTML5 ZIP / Publish Online | | ✓ | ✗ (Publish Online out of scope) | | <1% | +| SVG export | `.svg` | ✓ (via plug-in) | ✗ | | <1% | +| EPS export | `.eps` | ✓ | ✗ | | <1% | +| Text Only / RTF / Tagged Text | `.txt`, `.rtf` | ✓ | ✓ (`file.exportText` txt, rtf, tagged) | | 1% | +| XML | `.xml` | ✓ | ✓ export and import, DTD validate | depth unmeasured | 1% | +| Print / PostScript | | ✓ printers, PPDs, separations | ~ PDF to `lpr` (no Windows, no PostScript) | | 3% | +| Package | folder | ✓ fonts, links, IDML, PDF, report | ✓ (#340: IDML inside links to absolute paths) | | 2% | + +## Placed and imported content + +| Format | Ext | InDesign | DesignCraft | Notes | Weight | +|---|---|---|---|---|---| +| PDF / AI (PDF-compatible) | `.pdf`, `.ai` | ✓ page boxes, layers, pages | ✓ rendered with hayro, embedded as vector in PDF export, crop to page boxes | layer visibility depth; editable vectors not offered (#227) | 3% | +| JPEG, PNG, GIF, BMP, WebP | | ✓ | ✓ | CMYK JPEG/TIFF keep inks in PDF (#35) | 2% | +| TIFF | `.tif` | ✓ incl. CMYK, alpha, paths | ✓ incl. CMYK | clipping paths from 8BIM resources unverified | 1% | +| PSD | `.psd` | ✓ layers, comps, alpha, paths | ~ composite + Object Layer Options | layer comps partial | 1% | +| EPS | `.eps` | ✓ (PostScript) | ~ preview only (no interpreter) | | <1% | +| SVG | `.svg` | ✓ | ✓ (usvg) | | <1% | +| HEIC | `.heic` | ✓ | ✗ | | <1% | +| Word | `.docx` | ✓ styles, footnotes, tables, track changes, options | ✓ styles by name, footnotes, tables, tabs, breaks, moved text (#257), style mapping and conflicts | `.doc` not supported (InDesign reads it) | 2% | +| RTF | `.rtf` | ✓ | ✓ | | <1% | +| Excel | `.xlsx`, `.xls` | ✓ | ~ `.xlsx` first sheet's used range | no sheet/range options; `.xls` ✗ | <1% | +| Plain text, Markdown | `.txt`, `.md` | ✓ (txt) | ✓ | | <1% | +| InDesign Tagged Text | `.txt` | ✓ | ✓ import and export | | <1% | +| CSV / TSV (Data Merge) | | ✓ | ✓ (+ Excel) | | <1% | +| Audio / video | `.mp4`, `.mp3`, … | ✓ placeholders for EPUB/PDF | ~ placed as media objects | no in-app playback | <1% | + +## Resources and settings + +| Format | Ext | InDesign | DesignCraft | +|---|---|---|---| +| Adobe Swatch Exchange | `.ase` | read/write | ✓ load and save | +| Colour books | `.acb` | read (17 libraries ship) | ✗ (licensed libraries; Pantone etc. out of scope, own libraries possible) | +| ICC profiles | `.icc`, `.icm` | read | ✓ | +| Colour settings | `.csf` | read/write | ✗ | +| PDF presets | `.joboptions`, `.pdfs` | read/write | ✗ | +| Print / flattener / document presets | `.prst`, `.flst`, `.dcst` | read/write | ✗ | +| Preflight profiles | `.idpp` | read/write | ✗ (own profiles in the document) | +| Keyboard shortcut sets | `.indk` | read/write | ✗ (own JSON) | +| User dictionary | `.udc` | read/write | ✗ (own, in the document and prefs) | +| Fonts | OTF, TTF, TTC, variable | ✓ | ✓ (Type 1 retired by both) | + +## Score + +Weighted by the Weight column (documents, output and placed tables; resources unweighted), +counting ✓ = 1, ~ = 0.5, and IDML at 0.6 until a real-file corpus proves it: **~45% present**. +Taking fidelity into account (no INDD, IDML unproven, PDF/X-1a and presets missing), **~40% ready +for real work** (estimated). INDD alone moves this dimension by up to 30 points. + +## Remaining effort + +| Work | Opus 5.5 h | +|---|---| +| INDD import via a clean-room converter (owner decision, #114) | 20–60 | +| IDML real-file corpus, metric, fixes (#193, #338, #339, #340) | 60–100 | +| PDF/X-1a and X-3 (transparency flattener), PDF presets and `.joboptions`, external validator | 40–70 | +| Print path (Windows, PostScript level 3 output or system print APIs) | 25–45 | +| ICML story import, IDMS snippets, INDT templates (once INDD reads) | 15–25 | +| HTML / fixed-layout EPUB with live text | 20–35 | +| JPEG export dialog, SVG export, EPS export | 10–20 | +| **Total** | **190–355** (150–250 excluding INDD and PostScript output) | + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | Created from InDesign 2026's declared document types and filters, `plan/indesign/08-file-formats.md` and the code | diff --git a/docs/gaps.md b/docs/gaps.md new file mode 100644 index 000000000..b8be72673 --- /dev/null +++ b/docs/gaps.md @@ -0,0 +1,226 @@ +# Where DesignCraft falls short of InDesign + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (stage re-normalized to pre-alpha by the core-workflow gate; earlier today: major, first version: gaps from the full re-measure and the issue tracker) · **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. +Numbers and weights are in [target-app-parity.md](target-app-parity.md); the plan is in +[roadmap.md](roadmap.md). Estimates are Opus 5.5 agent-hours, calibrated in +target-app-parity.md. When you close a gap, delete its entry (or shrink it), add a line to the +ROADMAP progress log and update the timestamps. + +Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · **H** hardware · +**L** localization · **P** performance · **S** stability · **E** ecosystem · **A** AI. + +## Alpha blocker (the core-workflow gate in [roadmap.md](roadmap.md#alpha-gate)) + +### 0. Text can't be threaded by hand (F, U) · **ALPHA BLOCKER** +- **Missing:** threading a story from one frame to another. The canvas draws in and out ports + (`crates/ui-egui/src/canvas.rs`), but clicking the out port does nothing: no loaded text + cursor, no click-a-frame or draw-a-frame to continue the story, no unthread by double-clicking + a port, and no `frame.thread` / `frame.unthread` command for the control channel or MCP + (`Document::thread` exists in `crates/doc/src/edit.rs` but only autoflow, IDML import and the + sample call it). Long text flows only through autoflow on Place and Primary Text Frame, which + users report adding pages they can't delete (#165). +- **Evidence:** #352, #194, #165, #140 (Windows and unspecified platforms; the code shows it's + missing everywhere). +- **Impact:** a story can't run from page 2 to page 7, or from one column frame to the next + that the designer drew. That is InDesign's core text workflow. +- **Estimate:** 15–25 h (commands with undo and tests 4–6, out-port loaded cursor and + click/draw/unthread 6–12, Primary Text Frame page add/delete fixes 5–7). +- **Doc:** [ui-parity.md](ui-parity.md) + +## Blocking (beta can't be declared while these are open) + +### 1. Basic editing fails for many users (U, S) +- **Missing:** object copy/paste on Windows and Linux (#351, #348, #312, #185, #164, #201, text RTF + paste #212); Paste in Place onto the selected page (#348); right-click context menus that don't + open (#238, #187, #160, #312); threading (gap 0); double-click word selection in fields + (#189); character formatting set at a caret is discarded (#289). +- **Evidence:** 16 issues from 2026-10-05 to 2026-10-10, mostly against the 0.4/0.5 release builds. +- **Impact:** a layout artist can't finish the simplest job. This dominates the + ready-for-real-work number. +- **Estimate:** 30–50 h (reproduce per platform through the control channel, regression tests). +- **Doc:** [ui-parity.md](ui-parity.md) + +### 2. InDesign documents (.indd) can't be opened (FF) +- **Missing:** INDD (and INDT, INDB, INDL) reading. Everything InDesign users receive is INDD. +- **Evidence:** #21. Two clean-room converters are proposed: PR #98 and `indd-utils` (#114), + which reports 96.9% of InDesign's IDML values reproduced on ~650 INDD/IDML pairs, 0 failures on + ~4,300 files, and output that DesignCraft's IDML importer already reads. +- **Impact:** without it, every job starts with "ask the client for an IDML". +- **Estimate:** 20–60 h to integrate indd-utils (INDD → IDML → existing importer), warnings UI, + corpus test; **owner decision** on provenance and where the crate lives. +- **Doc:** [file-format-parity.md](file-format-parity.md) + +### 3. IDML round-trip isn't proven on real files (FF) +- **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). +- **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 + back to InDesign. +- **Estimate:** 60–100 h (corpus, metric, fixes). +- **Doc:** [file-format-parity.md](file-format-parity.md) + +### 4. Composition isn't compared with InDesign (T) +- **Missing:** an oracle harness: the same synthetic stories set by InDesign 2026 (observed + black-box, results kept under `plan/`) and by DesignCraft, comparing line breaks, overset and + frame fit. Known differences: optical kerning falls back to metrics, so imported text sets + wider (#181); a frame goes overset when descenders don't fit, InDesign needs only the baseline + (#180); setting the text language was missing (#130; Language is now in Properties, #145, the issue is still open); "hyphenation doesn't work" + (#87). +- **Impact:** an opened IDML reflows: different line breaks, different page count, overset text. + Professionals notice on the first page. +- **Estimate:** 80–140 h (harness 20–30, optical kerning 15–25, then fixes by cluster). +- **Doc:** [typography-parity.md](typography-parity.md) + +### 5. Startup crashes on some graphics hardware (S, H) +- **Missing:** a start that survives every GPU/driver: Metal device lost on Intel Macs (#334), + AMD OpenGL driver crash on Windows (#167), launch failures (#273, #220, #192, #166), blank or + frozen canvas after graphics loss (#90). +- **Evidence:** a backend fallback landed 2026-10-10 (#297: DirectX 12 on Windows, `gpu.json` + skip list); not yet confirmed by the reporters. +- **Estimate:** 15–30 h. +- **Doc:** [hardware-parity.md](hardware-parity.md) + +### 6. Installed fonts not always found (F) +- **Missing:** fonts in user font folders used for text (#161, #110, #107), fonts activated by + font managers and Adobe Fonts (#327, #261), reload when fonts change on disk (#328). +- **Impact:** documents set in the client's fonts render in Source Sans 3: wrong everywhere. +- **Estimate:** 10–20 h (VectorCraft has a port, #261). +- **Doc:** [typography-parity.md](typography-parity.md) + +### 7. No CI runs the tests on pull requests (S) +- **Missing:** a PR workflow running `cargo xtask ci` (fmt, clippy, tests, layers, wasm). Today + only FreeBSD and Windows ARM build checks run, and only when manifests change; `main` has no + branch protection. 65 PRs are open, some integrated in bulk (#259). +- **Impact:** regressions land unseen; the 1,122 tests protect nothing they aren't run on. +- **Estimate:** 4–8 h (plus runner minutes; owner decision on required checks). + +## High + +### 8. Canvas text input has no IME (U, L) +- **Missing:** composition input for Chinese, Japanese and Korean on the canvas (#322). + VectorCraft has an implementation to port. +- **Estimate:** 10–20 h. **Doc:** [localization-parity.md](localization-parity.md) + +### 9. PDF output presets and standards (FF) +- **Missing:** PDF/X-1a and PDF/X-3 (no flattener to PDF 1.3/1.4), Adobe PDF Presets and + `.joboptions` load/save, output-intent choice beyond the built-in profile, PDF/UA validation; + #159, #32. +- **Have:** PDF/X-4 with output intent and a validator (`#200`), PDF/A-2b, tagged PDF, CMYK and + spot, marks and bleed, the options dialog with remembered settings (#196). +- **Estimate:** 40–70 h. **Doc:** [file-format-parity.md](file-format-parity.md) + +### 10. Printing (H, F) +- **Missing:** a real print path: `file.print` sends PDF to `lpr`, so Windows can't print; no + PostScript, printer PPDs, separations to device, print presets, or trapping. +- **Estimate:** 25–45 h. **Doc:** [hardware-parity.md](hardware-parity.md) + +### 11. Paragraph and character style editing reliability (F) +- **Missing:** saving edits and deleting a paragraph style reported failing (#169); Tabs panel + for setting tab stops (#326). +- **Estimate:** 10–20 h. **Doc:** [ui-parity.md](ui-parity.md) + +### 12. Tables in depth (F) +- **Missing:** anchored objects in cells not drawn (#163); Table ▸ Table Options and Cell Options + submenus as InDesign lays them out, Diagonal Lines, Convert Rows to Header/Body/Footer, Edit + Header/Footer, Go to Row; general reliability (#20). +- **Estimate:** 25–40 h. + +### 13. Hyphenation and spelling languages (T, L) +- **Missing:** hyphenation patterns for every language but English and Spanish; spelling for + every language but English. InDesign ships dictionaries for ~40 languages. +- **Estimate:** 30–60 h (permissively licensed patterns exist for most; spelling word lists need + licence review). **Doc:** [typography-parity.md](typography-parity.md) + +### 14. Windows and Linux are thinly tested (S) +- **Missing:** runtime testing on Windows and Linux: installer confusion (#64), pull-down panels + can't scroll on Linux Mint (#267), tab bar overlaps menus in some workspaces (#182), CJK UI + font missing on Linux (#332). +- **Estimate:** 30–60 h (smoke tests in CI, fixes). + +## Medium + +### 15. Long documents in depth (F) +- **Missing:** Books beyond a list of documents: style/swatch synchronization, book-wide + pagination, book PDF export, book TOC and index; Table of Contents Styles. +- **Estimate:** 30–50 h. + +### 16. Menu items still missing (U) +- **Missing:** 151 of 482 in-scope items of InDesign 21.6's menu dump have no match (measured 2026-10-10; some exist under other labels): Type ▸ Notes + and Track Changes submenus (the panels and commands exist), hyperlink and cross-reference + update commands, Convert Bullets/Numbering to Text, Convert Variable to Text, Object ▸ Clear + Effects / Clear All Transparency / Rotate 180° / Reverse Path, Layout ▸ Go Back/Forward, + Window ▸ Arrange (2-up … 6-up), Trap Presets, Overlays, Edit ▸ Assign Profiles / Convert to + Profile / Edit Original, File ▸ File Info / Open PDF / Document and Print Presets / User + Settings, Type ▸ Size presets, Table ▸ Table Options / Cell Options submenus, Paste Before/After, + Convert Rows/Columns, Object ▸ Interactive ▸ Convert to (form fields), Flex Layout (new in 2026), + View ▸ Extras badges and Story Editor options. +- **Estimate:** 40–70 h (most are thin wrappers over existing engine features). +- **Doc:** [ui-parity.md](ui-parity.md) + +### 17. HTML and fixed-layout EPUB (FF) +- **Missing:** HTML export is one basic page (#314); fixed-layout EPUB writes page images, not + live text and CSS; no animation or timing for EPUB/HTML. +- **Estimate:** 30–50 h. + +### 18. Colour management depth (F) +- **Missing:** Assign Profiles / Convert to Profile, per-image rendering intents in output, + hex entry and colour picker issues (#323, #190), keep kerning on colour change (#29). +- **Estimate:** 20–35 h. + +### 19. Placed graphics depth (FF) +- **Missing:** EPS is shown through its preview only (no PostScript interpreter); PSD layer + comps beyond visibility; Edit Original / Edit With; PDF placed as editable vectors (#227, #24); + QuarkXPress import (#73). +- **Estimate:** 30–60 h (PostScript interpreter is a large separate project: +80–150 h). + +### 20. Performance on large documents (P) +- **Missing:** incremental composition (recompose from the edited paragraph; 8 ms keystroke + budget), benchmarks against InDesign on long books and image-heavy catalogues. +- **Have:** compose 300k characters ~38 ms warm, damage-region repaint (< 1 ms while typing), + parallel story composition. +- **Estimate:** 40–80 h. + +## Lower + +### 21. Localization breadth (L) +- **Missing:** Hindi, Indonesian, Korean, Vietnamese interfaces; ~17% of strings in the 83–85% + languages; 39% of pt-BR; native-speaker review for all. **Estimate:** 120–180 h. + **Doc:** [localization-parity.md](localization-parity.md) + +### 22. InDesign scripting compatibility (E) +- **Missing:** an ExtendScript / UXP-compatible DOM so existing InDesign scripts run. Our own + command API, scripts panel, CLI and MCP are strong and agent-friendly, but not compatible. +- **Estimate:** 150–250 h for a useful DOM subset; **owner decision**. + +### 23. Plug-ins (E) +- **Missing:** any plug-in model. InDesign's third-party ecosystem (imposition, preflight, data + publishing) doesn't carry over and can't. **Owner decision**; not estimated. + +### 24. AI features (A) +- **Missing:** InDesign 2026's Generative Expand, Text to Image and Auto Style equivalents; local + generative vector requested (#232). **Owner decision** on openly licensed models. +- **Estimate:** 60–120 h. + +### 25. Accessibility of the app itself (U) +- **Missing:** screen-reader exposure of panels and dialogs (egui AccessKit coverage unverified); + PDF/UA validation of exports. **Estimate:** 15–25 h. + +## Out of scope (by design) + +Adobe cloud services (CC Libraries, Stock, Adobe Fonts activation, Publish Online, Share for +Review, Creative Cloud sign-in), InCopy assignment workflows, Bridge, Exchange extensions, Version +Cue. These are excluded from every percentage. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | Gap 0 added and marked alpha blocker: manual text threading is missing (stage re-normalized to pre-alpha) | +| 2026-10-10 | major | Created: 25 ranked gaps from the full re-measure against InDesign 2026 21.6 and issues #1–#352 | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md new file mode 100644 index 000000000..6dc727edd --- /dev/null +++ b/docs/hardware-parity.md @@ -0,0 +1,56 @@ +# Hardware and platform parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first version) · **Target:** Adobe InDesign 2026 (21.6) + +What InDesign does with hardware, per platform, against DesignCraft. Layout is not a +GPU-hungry workload, so this dimension carries little weight, but a start that fails on a +graphics driver loses the user entirely. Overall numbers: [target-app-parity.md](target-app-parity.md). + +**Hardware: ~45% ready, 20–40 h. Platforms: ~55% ready, 40–80 h.** (estimated) + +## Hardware features + +| Feature | InDesign 2026 | DesignCraft macOS | Windows | Linux | Web | +|---|---|---|---|---|---| +| GPU display (canvas) | GPU Performance: GPU preview, animated zoom (Metal on macOS; Windows with supported GPUs) | canvas rasterized on the CPU (vello_cpu, SIMD, multithreaded), presented by wgpu (Metal) | wgpu DirectX 12 (since #297), Vulkan/OpenGL fallback | Vulkan, OpenGL fallback | WebGPU, WebGL2 fallback | +| Start-up on any GPU | robust | Metal device lost on Intel Macs (#334) | AMD OpenGL crash (#167), launch failures (#273) | some reports (#192, #220) | | +| Backend fallback | n/a | `gpu.json` skips a backend whose start never showed a frame (#297) | same | same | | +| Animated / scrubby zoom | ✓ (GPU) | ✓ (CPU tiles, damage regions) | ✓ | ✓ | ✓ | +| HiDPI / Retina | ✓ | ✓ | ✓ | ✓ | ✓ | +| UI scaling | ✓ | ✓ (menus and dialogs usable at larger scales, #281) | ✓ | ✓ | ✓ | +| Multi-monitor, floating document windows | ✓ | ~ one window; New Window for the same document | ~ | ~ | ✗ | +| Printing | system print with PPDs, PostScript, separations | PDF to `lpr` | ✗ (no `lpr`) | PDF to `lpr` (CUPS) | browser download | +| Colour-managed display (monitor profile) | ✓ | ✗ (working CMYK profile simulated; monitor profile not applied) | ✗ | ✗ | ✗ | +| Touch / pen | Touch workspace (Windows), gestures | trackpad pinch/scroll | ~ | ~ | ~ | +| Multi-core composition | single-threaded composer per story (documented by Adobe as mostly single-threaded) | parallel story composition (rayon), 6.7× on the sample | ✓ | ✓ | single-threaded | + +## Platforms + +| Platform | InDesign 2026 | DesignCraft | Evidence | +|---|---|---|---| +| macOS (Apple silicon, Intel) | ✓ | ✓ primary development platform | Intel GPU start failure (#334); window controls misaligned (#333); font managers (#327) | +| Windows x64 | ✓ | ~ builds and installers ship | copy/paste and context menus (#351, #312), installer confusion (#64), launch failures (#273, #167) | +| Windows ARM64 | ✓ | ~ build (CI workflow) | not exercised at runtime | +| Linux (x86_64, aarch64) | ✗ | ~ deb, AppImage, Flatpak | clipboard (#164), panels don't scroll (#267), CJK UI font (#332) | +| FreeBSD | ✗ | ~ build | | +| Web (WASM) | ✗ (InDesign on the web is not shipped) | ~ same UI, file picker, downloads; no control channel | | + +DesignCraft runs on more platforms than InDesign; what's missing is runtime testing on the ones +the developers don't use daily. + +## Remaining effort + +| Work | Opus 5.5 h | +|---|---| +| Start-up robustness on every GPU: confirm #297 with reporters, Intel Metal fix, CPU-only present fallback | 15–30 | +| Printing on Windows (system print API) and print presets | 15–30 (counted in file-format-parity.md) | +| Monitor profile in display colour management | 5–10 | +| Windows and Linux runtime smoke tests in CI | 20–40 | +| Multi-window document views | 15–30 | +| **Hardware + platforms** | **60–120** | + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | Created from the code, InDesign 2026's GPU Performance features and the issue tracker | diff --git a/docs/localization-parity.md b/docs/localization-parity.md new file mode 100644 index 000000000..d36053202 --- /dev/null +++ b/docs/localization-parity.md @@ -0,0 +1,80 @@ +# Localization parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first version; catalogs measured) · **Target:** Adobe InDesign 2026 (21.6) + +Per-language status of the DesignCraft interface and of document typesetting in each script. +Script-specific typesetting detail: [cjk.md](cjk.md), [cjk-typography.md](cjk-typography.md), +[arabic-typography.md](arabic-typography.md). Overall numbers: [target-app-parity.md](target-app-parity.md). + +**Dimension score: ~45% ready (estimated), 120–180 h** plus native-speaker review (human). + +## How it was measured + +Interface strings live in `crates/ui-egui/src/i18n.rs` (a shared table with German, French, +Spanish, Japanese and Simplified Chinese columns) and `crates/ui-egui/src/i18n/{ar,it,ja,pt_br,uk}.rs`. +Untranslated strings fall back to English. A script (2026-10-10) built the set of English +interface strings as the union of every catalog's keys, every engine command label and every +literal passed to `tr`/`t` in the UI crate: **1,724 strings**. A further ~145 capitalized literals +in the UI crate appear in no catalog (some are test data or proper names), so the true total is +somewhat higher and every percentage below is a slight overestimate. Help: DesignCraft has no +help content in any language (Help ▸ opens the project's pages). + +InDesign 2026 ships 25 interface localizations (`.lproj` folders: cs, da, de, el, en, en_GB, es, +fi, fr, hu, it, ja, ko, nb, nl, pl, pt, ro, ru, sq, sv, tr, uk, zh_CN, zh_TW); Arabic and Hebrew +come as Middle East editions with an English or French interface and right-to-left features. + +## Key languages + +| Language | Code | InDesign 2026 UI | UI strings translated | Dialogs / tooltips / help | Script support (documents and UI) | Native review | Status | To `full` (h) | +|---|---|---|---|---|---|---|---|---| +| English | en | ✓ | 1,724 / 1,724 (100%) | all / all / none | Latin ✓ | yes | full | 0 | +| Simplified Chinese (Mandarin) | zh | ✓ (zh_CN) | 1,438 (83%) | menus, panels, most dialogs; tooltips partly; no help | CJK typesetting ✓ (cjk.md); **no canvas IME** (#322); CJK UI font not embedded in release builds, so boxes on Linux (#332) | no | partial | 25–40 | +| Spanish | es | ✓ | 1,438 (83%) | menus, panels, most dialogs; no help | Latin ✓; Spanish hyphenation ✓ (#222) | no | partial | 5–8 | +| Hindi | hi | ✗ | 0 (0%) | none | Devanagari shaped by harfrust in documents but untested; no Hindi hyphenation or spelling | no | none | 20–30 | +| Arabic | ar | Middle East edition (English/French UI) | 1,459 (85%) | menus, panels, most dialogs; no help | RTL UI text (`rtl.rs`: bidi + shaping); Arabic composition, kashida, digits, RTL stories and tables ✓ (arabic-typography.md); list bullets and multi-column RTL open (#100) | no | partial | 15–25 | +| French | fr | ✓ | 1,438 (83%) | menus, panels, most dialogs; no help | Latin ✓; no French hyphenation | no | partial | 6–10 | +| Portuguese (Brazil) | pt-br | ✓ (pt) | 1,057 (61%) | menus, panels, some dialogs; no help | Latin ✓; no Portuguese hyphenation | no | partial | 8–12 | +| Indonesian | id | ✗ | 0 (0%) | none | Latin ✓ | no | none | 12–18 | +| Japanese | ja | ✓ | 1,644 (95%) | whole interface (#272), vertical-type commands; no help | vertical text, kinsoku, aki, ruby, warichu ✓ (partial depth, cjk.md); **no canvas IME** (#322); Japanese UI and Mincho fonts from craft-fonts | no | partial | 6–10 (+ IME, shared with zh) | +| German | de | ✓ | 1,438 (83%) | menus, panels, most dialogs; no help | Latin ✓; no German hyphenation (InDesign ships 2006 reform patterns) | no | partial | 6–10 | +| Korean | ko | ✓ | 0 (0%) | none | Korean line breaking at spaces ✓; **no IME** (#322) | no | none | 15–25 | +| Vietnamese | vi | ✗ | 0 (0%) | none | Latin with stacked diacritics via harfrust, untested | no | none | 12–18 | + +Percentages are of the 1,724 measured strings. A language is `full` only when every user-visible +string is translated and its script renders and edits correctly; none but English is. + +## Other shipped languages + +| Language | Code | UI strings translated | Status | +|---|---|---|---| +| Italian | it | 1,464 (85%) | partial | +| Ukrainian | uk | 1,432 (83%), plural categories handled | partial | + +DesignCraft ships **2** interface languages beyond the twelve; InDesign ships 17 beyond its +eight of the twelve (cs, da, el, en_GB, fi, hu, it, nb, nl, pl, ro, ru, sq, sv, tr, uk, zh_TW). + +## Document languages + +InDesign 2026 hyphenates and spell-checks ~40 languages (Hunspell and Duden dictionaries). +DesignCraft hyphenates **English and Spanish** and spell-checks **English** only. Text language +can be set per paragraph and character (Properties, Character panel) and drives quotes and +locale shaping. See [typography-parity.md](typography-parity.md). + +## Remaining effort + +| Work | Opus 5.5 h | +|---|---| +| Fill the 83–85% catalogs (de, fr, es, zh, ar, it, uk; ~290 strings each) | 25–40 | +| pt-BR to full | 8–12 | +| New: Hindi, Indonesian, Korean, Vietnamese (1,724 strings, plural rules, tests) | 50–75 | +| Canvas IME (zh, ja, ko) | 10–20 | +| CJK UI font in every release build; Devanagari and Vietnamese rendering tests | 10–15 | +| Catalog completeness test: every `tr` key and command label present in every catalog | 4–8 | +| Help content (at least English, then the twelve) | 15–25 | +| **Total** | **120–180** + native-speaker review for 11 languages (human) | + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | Created: catalogs measured (1,724 strings), the twelve key languages plus Italian and Ukrainian | diff --git a/docs/parity.md b/docs/parity-checklist.md similarity index 92% rename from docs/parity.md rename to docs/parity-checklist.md index 94b364735..d2d1ac11c 100644 --- a/docs/parity.md +++ b/docs/parity-checklist.md @@ -1,8 +1,19 @@ -# Feature parity with InDesign +# InDesign feature checklist -The summary is generated by `cargo xtask parity` from the table at the bottom of this file (edit a row, re-run). +> **Generated:** summary by `cargo xtask parity` from the rows below; rows last re-scored 2026-10-04, summary unchanged since (checked 2026-10-10) · **Target:** Adobe InDesign 2026 -Each row of the InDesign feature catalogue is scored **D** (done), **P** (partial: works but lacks options or UI) or **M** (missing), weighted by priority (**P0** core = 3, **P1** = 2, **P2** = 1). Out-of-scope rows (Adobe cloud services, plug-ins, Typekit, Flash) are excluded. +This is the **presence** checklist: does each InDesign feature exist in DesignCraft? It answers +the feature-breadth question only. Depth, fidelity and readiness for real work are assessed in +[target-app-parity.md](target-app-parity.md); individual shortfalls are in [gaps.md](gaps.md). +Formerly `docs/parity.md`. + +Edit a row's status, then run `cargo xtask parity` to regenerate the summary; don't edit the +summary by hand. Each row of the InDesign feature catalogue is scored **D** (done), **P** (partial: works but lacks options or UI) or **M** (missing), weighted by priority (**P0** core = 3, **P1** = 2, **P2** = 1). Out-of-scope rows (Adobe cloud services, plug-ins, Typekit, Flash) are excluded. + +Caveat (2026-10-10 review): rows were graded by the agents that built the features, at a coarse +grain. A measured match of InDesign 21.6's own menu dump against DesignCraft's labels finds 69% +of in-scope menu items (75% against the documented menu tree) (see [target-app-parity.md](target-app-parity.md)), so read 99% as an upper +bound. <!-- SUMMARY --> **Weighted breadth parity: 99%** over 275 features. diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 000000000..e738da950 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,115 @@ +# DesignCraft roadmap (detail) + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (stage re-normalized to pre-alpha by the core-workflow gate; earlier today: major, milestones re-assessed; beta plan from the full re-measure) · **Target:** Adobe InDesign 2026 (21.6) + +Forward-looking plan. The one-page summary is [ROADMAP.md](../ROADMAP.md); numbers are in +[target-app-parity.md](target-app-parity.md); the work list is [gaps.md](gaps.md). Hours are +Opus 5.5 agent-hours (calibration in target-app-parity.md). Driving the app from agents: +[agents.md](agents.md). + +## Alpha gate + +InDesign's core workflows: what a layout professional does with it every day. DesignCraft is +alpha only when every one works end to end on macOS (its main platform) and the work saves and +reopens. **Gate result (2026-10-10): fails, so the stage is pre-alpha.** Rows marked "no" are the +alpha checklist; the partial rows don't block the workflow on macOS (there is a working path) +but are next in line. + +| Core workflow | Works end to end? | Evidence | Hours to pass | +|---|---|---|---| +| Set up a document (size, margins, columns, parent pages, page numbers, sections); save and reopen | yes | New Document, Document Setup, parent pages and overrides, Numbering & Section Options; `.designcraft` round-trip tests; recovery | 0 | +| Flow a long story across frames and pages: thread, unthread, autoflow | **no** | out/in ports drawn but no out-port click, loaded text cursor or `thread`/`unthread` command; only autoflow on Place and Primary Text Frame, which adds pages users can't delete (#165, #140, #194, #352) | 15–25 | +| Format text with paragraph and character styles | partial (not blocking) | Paragraph/Character Style Options, Quick Apply, based-on, nested/GREP styles work; style edit/delete from the panel reported (#169); formatting set at a caret is discarded (#289) | 6–12 | +| Place, crop and fit images; wrap text around them | yes | place gun, frame fitting and Fitting Options, content grabber, text wrap, Links panel | 0 | +| Arrange and edit objects: select, move, transform, duplicate, copy/paste, align, layers | partial (not blocking on macOS) | all present and working on macOS; Paste in Place lands on page 1 (#348); right-click menus fail for some macOS users (#160, #238); copy/paste and context menus fail on Windows/Linux (#351, #185, #164) | 10–20 | +| Export a print-ready PDF (bleed, marks, CMYK and spot, PDF/X-4) | yes | krilla export with subset fonts, Separations, marks and bleed, PDF/X-4 with validator; 53 PDF tests | 0 | + +Exchanging files with InDesign users (open INDD, round-trip IDML) is a beta requirement, not part +of the alpha gate: IDML works both ways today and INDD is gap 2. + +**Distance to alpha:** ~15–25 h (the threading row). Ready for real work (~47%) is already above +the ~40% alpha bar. + +## Current focus + +In order. Each closes a beta blocker from [gaps.md](gaps.md). + +0. **Alpha gate: manual text threading** (gap 0): `frame.thread` / `frame.unthread` commands, + out-port click with a loaded text cursor (click a frame or draw one), unthread by + double-clicking a port, Primary Text Frame page fixes (#165). 15–25 h. +1. **Basic editing works on every platform** (gap 1): object copy/paste, Paste in Place, context menus, caret formatting, reproduced and pinned with control-channel tests on + macOS, Windows and Linux. 30–50 h. +2. **CI on pull requests** (gap 7): `cargo xtask ci` on every PR, at least macOS and Linux; + Windows smoke start. 4–8 h. Needs the owner to make it a required check. +3. **Open INDD** (gap 2): decide with the owner how to bring in the clean-room converter + (#114 indd-utils, #98), then INDD → IDML → existing importer, warnings shown. 20–60 h. +4. **IDML real-file corpus** (gap 3): a pinned corpus, a survival metric, and fixes for #193, + #338, #339, #340. 60–100 h. +5. **Composition oracle** (gap 4): synthetic stories set in InDesign 2026 vs DesignCraft; + optical kerning; the overset rule. 80–140 h. +6. **Start on any GPU; find every installed font** (gaps 5, 6). 25–50 h. + +## Milestones + +States re-assessed 2026-10-10 against the code and the issue tracker. "Done" means the feature +set exists; depth gaps are in gaps.md. + +| # | Milestone | State | +|---|---|---| +| M0 | Skeleton + vertical slice | ✅ | +| M1 | Selection, transform, layers, pages, MCP | ✅ core; handle and snapping issues (#186, #188, #337) | +| M2 | Type I (Type tool, threading, Character/Paragraph, composer) | partial: Type tool and composer ✅; **manual threading missing** (alpha blocker); caret formatting (#289); no IME (#322) | +| M3 | Styles (nested/GREP, bullets, keeps, span columns) | ✅; style editing reliability (#169) | +| M4 | Color & effects | ✅ core; colour management depth | +| M5 | Graphics & links | ✅ core; EPS preview only | +| M6 | Files & export (native, IDML, PDF, PNG/JPEG) | IDML ✅, PDF ✅ (X-4, A-2b, tagged); INDD ✗; PDF/X-1a/X-3 and presets ✗ | +| M7 | Long documents (sections, TOC, index, footnotes, books) | ✅ except Books (shallow) | +| M8 | Tables | ✅ core (table/cell styles, rotation); diagonal lines, anchored objects in cells (#163) | +| M9 | Performance (MT composition, tiles) | in progress: parallel compose 6.7×, damage regions; incremental composition next | +| M10 | Find/Change, spelling, Preflight | ✅ (spelling English only) | +| M11 | Layout power features (liquid/alternate layouts, data merge) | present, shallow; Flex Layout (new in InDesign 2026) ✗ | +| M12 | Interactive & digital (EPUB, HTML, interactive PDF) | present; HTML basic (#314), fixed-layout EPUB as images | +| M13 | Automation (scripts, batch) | ✅ own command scripts, CLI, MCP; ExtendScript/UXP compatibility ✗ | +| M14 | 1.0 polish & packaging | packaging ✅ (macOS, Windows x64/ARM64, Linux deb/AppImage/Flatpak, FreeBSD, web); polish not started | +| **A1** | **Alpha** (core-workflow gate) | blocked by manual text threading; see Alpha gate | +| **B1** | **Beta** (ready ≥ 75%, INDD/IDML reliable) | not started as a milestone; see below | + +## To beta + +Beta needs ~75% ready for real work and no blocking gap in the main file format. From ~47% today +that is ~28 points and **420–750 h** (~120–200 wall-clock hours with four to five agents): + +| Work | Gap | Hours | +|---|---|---| +| Basic editing on every platform | 1 | 30–50 | +| INDD import | 2 | 20–60 | +| IDML real-file corpus and fixes | 3 | 60–100 | +| Composition oracle and fixes | 4, 13 | 80–140 | +| Start-up robustness, fonts | 5, 6 | 25–50 | +| CI, Windows/Linux runtime tests | 7, 14 | 35–70 | +| IME | 8 | 10–20 | +| PDF/X-1a/X-3, presets, print on Windows | 9, 10 | 65–110 | +| Styles, tables, missing menu items (the most-used half) | 11, 12, 16 | 60–100 | +| Long documents (Books) | 15 | 30–50 | +| **Total** | | **420–750** | + +## After beta: to full parity + +Remaining gaps 16–25: the rest of the menu long tail, HTML/EPUB depth, colour management, placed +graphics depth (PostScript), performance on large documents, localization to `full` in the +twelve languages, InDesign scripting compatibility (owner decision), AI features (owner decision), +accessibility. **730–1,200 h** more; **1,150–1,950 h** in total from today. + +## Superseded plan + +The order the project followed until 2026-10-04 ("Next: books · New Window / split view · Ink +Manager and Separations Preview · XML structure, tags and export · buttons and forms · PDF/X-4 +output intent · EPS place") is done except Books depth and EPS (preview only). + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | trivial | Ready figure 42% → 47% (method aligned with the standard); beta distance 28 points | +| 2026-10-10 | minor | Alpha gate table added (6 core workflows); gate fails on manual threading, stage pre-alpha; A1 milestone and focus item 0 | +| 2026-10-10 | major | Created: milestones moved from ROADMAP.md and re-assessed; Current focus and the beta plan from the full re-measure | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md new file mode 100644 index 000000000..8044ed51b --- /dev/null +++ b/docs/target-app-parity.md @@ -0,0 +1,448 @@ +# DesignCraft parity with Adobe InDesign + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (stage re-normalized to pre-alpha by the core-workflow gate; earlier today: major, full re-measure against InDesign 2026 21.6; replaces the 2026-10-04 estimate in ROADMAP.md) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) + +The authoritative assessment of how close DesignCraft is to Adobe InDesign. [ROADMAP.md](../ROADMAP.md) +summarizes it; [gaps.md](gaps.md) lists every known shortfall one at a time; the feature-by-feature +presence checklist is [parity-checklist.md](parity-checklist.md) (`cargo xtask parity`). + +## Headline + +| Number | Value | Kind | +|---|---|---| +| **Feature breadth** (does each InDesign feature exist?) | **~80%** (range 69–99%) | measured lower and upper bounds, estimate between them (below) | +| **Ready for real work** (could an InDesign professional switch for real jobs?) | **~47%** (range 42–52%) | estimated, weighted sum (below); 1,150–1,950 h to ~95% | +| Mainstream practitioner (weekly core of a typical InDesign pro) | **~42%** | 500–850 h to ~95%; estimated, [method below](#mainstream-practitioner-42) | +| Essentials user (flyers, newsletters: the core only) | **~54%** | 135–250 h to ~95%; estimated, [method below](#essentials-user-54) | +| Remaining effort to **beta** (~75% ready, main format reliable; 28 points away) | **420–750 Opus 5.5 agent-hours** | estimated | +| Remaining effort to **full parity** | **1,150–1,950 Opus 5.5 agent-hours** | estimated | +| Stage | **pre-alpha** (fails the core-workflow gate: no manual text threading); ~15–25 h to alpha | see [roadmap.md](roadmap.md#alpha-gate) | + +## How breadth was measured + +Two measurements bracket it, and neither is the answer by itself. + +- **Checklist: 99% (upper bound, measured, self-graded rows).** `cargo xtask parity` scores the 275 + rows of [parity-checklist.md](parity-checklist.md), weighted P0 = 3, P1 = 2, P2 = 1: 269 done, + 6 partial, 0 missing. The rows are a catalogue we wrote and were graded by the agents that built + the features; "done" means the feature exists, not that it has all of InDesign's options. +- **Menu dump: 69% (lower bound, measured 2026-10-10).** The main menu tree of the running + InDesign 2026 21.6, dumped through its scripting DOM during black-box observation + (`plan/indesign/screenshots/menus.txt`, local, never committed: 1,121 entries, 798 leaf items), + was matched by a script against every label in the DesignCraft source (engine command labels, + `UI_COMMANDS`, the `MENUS` table, dialog and panel strings; case, punctuation and "Show/Hide" + variants normalized). Excluded as out of scope or dynamic: the installed-font list (490 items), + Plug-Ins, Adobe cloud and AI services (Firefly, Generate, Rewrite, Auto Style, Stock, CC + Libraries, Publish Online, Share for Review), InCopy, Help links, window tiling, recent files + and workspace names. **331 of 482 in-scope items match (68.7%)**: File 19/26, Edit 28/31, + Layout 21/27, Type 49/92, Object 81/110, Table 26/45, View 40/57, Window 50/73, app menu 17/20. + It undercounts: some items exist under another label or only in a panel menu (Notes and Track + Changes have panels and `note.*` / `changes.*` commands but not InDesign's Type ▸ Notes / + Track Changes submenus). +- **Menu tree from documentation: 75% (cross-check).** The same match against the clean-room menu + tree compiled from public documentation (`plan/indesign/04-menu-tree.md`, 689 items): 440 of 590 + in-scope items (74.6%). +- **Command surface (measured):** 446 engine commands (188 placed in menus) + 99 UI commands; the + menu bar has 9 menus, 49 submenus and 340 items. The original plan targeted ~1,000 commands. + +The breadth figure is taken between them, at **~80%**, because the checklist overstates (rows were +written at a coarse grain and graded by their builders) and the label match understates (label +differences, panel-only commands). + +## How "ready for real work" was estimated + +Each dimension below was judged from the code, the tests, the issue tracker (124 issues, 79 open on +2026-10-10, most from people trying the 0.4/0.5 releases on real work) and the InDesign 2026 +reference material. Weights reflect what an InDesign professional hits first: opening the files +they receive, setting type exactly, and basic editing that never fails. + +| Dimension | Weight | Ready | Remaining (Opus 5.5 h) | Evidence | Doc | +|---|---|---|---|---|---| +| Features (depth of each area) | 25% | 55% | 250–400 | 275 catalogue rows exist; many lack options (e.g. Books is 258 lines of engine code, Liquid Layout 329); see the area table | this file | +| Typography and composition fidelity | 15% | 45% | 120–200 | Knuth–Plass composer, keeps, grids, CJK, Arabic all exist; no comparison with InDesign's line breaks; optical kerning falls back to metrics (#181); overset rule differs (#180); hyphenation patterns for English and Spanish only | [typography-parity.md](typography-parity.md) | +| File formats | 20% | 40% | 150–250 (+20–60 for INDD integration) | INDD cannot be opened (#21); IDML both ways and verified in InDesign 2026, but real-file bugs open (#193, #338, #339, #340); PDF export strong; PDF/X-4 only | [file-format-parity.md](file-format-parity.md) | +| UI/UX fidelity | 15% | 45% | 120–180 | Measured InDesign 2026 look; but users report object copy/paste (#351, #348, #185, #164), right-click menus (#238, #187, #160), out-port threading (#352, #194, #165), canvas IME (#322) failing | [ui-parity.md](ui-parity.md) | +| Stability | 10% | 45% | 60–100 | Never-crash rules and last-resort guards; startup failures on some GPUs and machines (#334, #273, #220, #192, #167, #166); no CI runs the tests on pull requests | [gaps.md](gaps.md) | +| Performance | 5% | 65% | 40–80 | 300k characters compose in ~38 ms warm; typing repaints in < 1 ms (damage regions); no incremental composition; nothing benchmarked against InDesign; no large real documents | [gaps.md](gaps.md) | +| Localization | 3% | 45% | 120–180 | 9 translations beyond English; Japanese 95%, most others 83–85%, pt-BR 61%; Hindi, Indonesian, Korean, Vietnamese none | [localization-parity.md](localization-parity.md) | +| Platforms | 3% | 55% | 40–80 | macOS is where it's tested; Windows and Linux users hit basic failures; web and FreeBSD builds exist | [hardware-parity.md](hardware-parity.md) | +| Hardware | 2% | 45% | 20–40 | Canvas rasterizes on the CPU (vello_cpu, multithreaded); GPU only presents; printing is `lpr` (no Windows printing, no PostScript) | [hardware-parity.md](hardware-parity.md) | +| Ecosystem (scripting, plug-ins) | 2% | 15% | 150–250 | Own command/script/MCP API is strong; no ExtendScript/UXP DOM, so InDesign scripts don't run; no plug-ins | [gaps.md](gaps.md) | +| AI features | 0% (not weighted) | 15% | 60–120 | Agent control through MCP goes beyond InDesign; no generative features (InDesign 2026: Generative Expand, Text to Image, Auto Style) | [gaps.md](gaps.md) | +| **Weighted sum** | 100% | **47.2% → ~47%** (range 42–52%) | **1,150–1,950** | | | + +**How the number is built:** the plain weighted sum of the table above, per the craftrules +standard: 25×55 + 15×45 + 20×40 + 15×45 + 10×45 + 5×65 + 3×45 + 3×55 + 2×45 + 2×15 = 47.2% → +**~47%** (range 42–52%, ±5 points of judgement in the dimension values). An earlier version +today applied a further ×0.9 for cross-cutting core-path failures and reported 42%. That +multiplicative step was dropped to match the standard (discounts belong only to the mainstream +and essentials numbers). Those failures are already in the UI/UX (45%) and Stability (45%) rows, +and the missing manual threading is what holds the stage at pre-alpha through the gate. + +## Mainstream practitioner and essentials user + +Two narrower questions, computed with the craftrules method so every app's numbers compare. The +stage still follows the full number (~47%) and the core-workflow gate. All three are estimates. + +| Audience | Ready | Opus 5.5 agent-hours to ~95% | Parallelizes | Work that dominates | +|---|---|---|---|---| +| Full target (ready for real work) | **~47%** | **1,150–1,950** | ~70% (per crate) | depth across all 19 areas, InDesign scripting compatibility, localization to full in 12 languages, typography fidelity, INDD/IDML exchange | +| Mainstream practitioner | **~42%** | **500–850** | ~65% | INDD import and IDML real-file corpus (80–160), composition fidelity against InDesign (120–200), depth of the weekly areas (150–240), PDF/X-1a, presets and printing (65–110), editing and threading (45–75), stability and CI (25–45), fonts (10–20) | +| Essentials user | **~54%** | **135–250** | ~50% | start-up robustness on every GPU (25–45), editing and discoverability bugs: context menus, copy/paste, installer (30–50), threading (15–25), text editing and fonts (20–40), printing on Windows (15–30), depth of the core features (30–60) | + +Hours are calibrated as in [Remaining effort and calibration](#remaining-effort-and-calibration) +(1–2 h per feature arc, 2–4 h per behaviour cluster matched against an oracle). Each audience's +work is a subset of the one above it: essentials ⊂ mainstream ⊂ full. Human needs: the owner's +decision on INDD (#114), InDesign oracle captures on the owner's Mac, native-speaker review (full +only). + +### Mainstream practitioner: ~42% + +The typical InDesign professional (magazine, book, brochure, packaging-adjacent layout artist) +uses these weekly: the alpha-gate workflows plus their everyday tools. Excluded: plug-ins and +scripting compatibility, generative/cloud AI, InCopy and team workflows, specialist hardware, +interface languages other than the user's own. Depth here is what the feature does when it +works; interaction, stability and exchange problems are the discounts, so they aren't counted +twice. + +| Area | Weight | Depth | Evidence | +|---|---|---|---| +| Document setup: pages, margins, columns, parents, sections | 10% | 65% | features area table | +| Text frames: type, select, edit, thread, autoflow | 15% | 45% | manual threading missing; autoflow and Primary Text Frame only | +| Typography: composer, H&J, OpenType, tabs, lists, keeps | 15% | 65% | typography-parity.md: 18 ✓ / 16 ~; Tabs panel missing; InDesign-identical breaks counted under exchange | +| Paragraph, character and object styles | 10% | 65% | dialogs, based-on, nested/GREP, Quick Apply | +| Frames, shapes, transform, arrange, align | 10% | 70% | full tool set; copy/paste problems counted under interaction | +| Colour, swatches, stroke | 8% | 60% | hex entry, CMS depth | +| Images: place, fit, links, wrap | 10% | 60% | EPS preview only, Edit Original missing | +| Tables | 5% | 55% | anchored objects in cells, Table Options submenus | +| Layers | 3% | 75% | | +| Effects and transparency | 3% | 50% | fidelity unmeasured | +| Output: PDF export, print, package | 8% | 60% | PDF/X-4 strong; no X-1a, presets, Windows printing | +| View, navigation, undo, save | 3% | 70% | | +| **Weighted depth** | 100% | **60.7%** | | + +Discounts (multiplicative): + +| Discount | Factor | Evidence | +|---|---|---| +| Interaction fidelity | ×0.88 | right-click menus fail for some macOS users (#160, #238) and on Linux (#354); Paste in Place lands on page 1 (#348); formatting at a caret discarded (#289); font search focus (#335, #86) | +| Stability on real machines | ×0.92 | start-up failures on Intel Mac Metal (#334) and Windows drivers (#167, #273, #220, #192, #166); no CI on pull requests; macOS on Apple silicon is solid | +| File exchange with InDesign users | ×0.85 | `.indd` can't be opened (#21); IDML import/export bugs (#193, #338, #339, #340, #211); opened text reflows: optical kerning (#181), overset rule (#180). Harsher than VectorCraft's ×0.92 because InDesign pros receive and send native files daily | + +60.7% × 0.88 × 0.92 × 0.85 = 41.8% → **~42%**. It is lower than the full number (47%) +because the full number spreads weight over dimensions DesignCraft does relatively well +(performance, platforms, breadth of the long tail: 99% checklist presence), while the weekly core +carries the gaps at full weight: threading, editing bugs and INDD exchange. + +### Essentials user: ~54% + +Someone making a flyer, poster, newsletter or simple booklet: they touch only the core. +Excluded: advanced options, pro workflows (preflight, separations, books, long-document +features), exchange edge cases, and everything excluded above. + +| Core feature | Weight | Depth | Evidence | +|---|---|---|---| +| Launch, welcome screen, new document from a preset | 10% | 80% | presets incl. A6 (#248) | +| Type text in a frame, select and edit it | 15% | 65% | caret formatting (#289), double-click selection in fields (#189) | +| Font, size, colour, alignment | 15% | 65% | installed fonts not always found (#161, #110, #107) | +| Place an image and fit it | 10% | 75% | place gun, fitting | +| Draw shapes and lines, fill and stroke | 10% | 80% | | +| Move, resize, rotate, align, duplicate | 10% | 75% | | +| Copy and paste | 5% | 60% | works on macOS; Paste in Place to the wrong page (#348) | +| Undo / redo | 5% | 85% | | +| Save and reopen | 5% | 85% | `.designcraft` round trip, recovery | +| Export PDF / PNG | 10% | 80% | | +| Print | 5% | 40% | PDF to `lpr` only, nothing on Windows | +| **Weighted depth** | 100% | **72.0%** | | + +| Discount | Factor | Evidence | +|---|---|---| +| Launch and stability | ×0.90 | the start-up failures above hit casual users first and they don't know `WGPU_BACKEND` | +| Discoverability and UI clarity | ×0.88 | a faithful, dense InDesign UI; right-click menus don't open for some users; the out port looks clickable but does nothing; Windows installer confusing (#64) | +| Opening files people send them | ×0.95 | casual users get Word, images and PDFs, which place well (docx/rtf/xlsx, PDF pages); INDD is rare for them | + +72.0% × 0.90 × 0.88 × 0.95 = 54.2% → **~54%**. + +### User evidence (GitHub issues and comments, 2026-10-10) + +126 issues and 94 comments; no issues were filed by the maintainer (echelon). + +- **Praise:** 7 people in 6 threads (#32 "so damn impressive… InDesign open source for Linux", + #77, #107, #113, #197, #328). +- **Switched-from-InDesign reports:** 0 completed switches. 2 former InDesign users trying real + work: #348, a retired designer who lost an Adobe licence, laying out 18 pages; #140. Both were + blocked by core-path bugs (paste to page, threading). #320 is an art director with 10 years of + daily InDesign asking for a feature. +- **Open issues (81):** + - 44 are core-path bugs (54%), clustering into a few root causes: copy/paste (8), + context menus (5), threading (4), launch (6), fonts (5). + - 9 are InDesign file-exchange bugs. + - 26 are niche requests or polish (HTML export, QuarkXPress import, MCP gateway, theme options). + - 2 are meta. + +So the user evidence matches the numbers: interest and goodwill are high, and real work is +stopped by a short list of core-path bugs. + +### Why the number went down + +The 2026-10-04 estimate said "~83% including depth" and 170 h remaining. It was written before +anyone outside the project had used the app. New evidence since then: + +- 79 open issues, many about core editing on Windows and Linux (copy/paste, context menus, + threading, fonts not found, startup crashes), filed against the 0.4 and 0.5 releases. +- The first measured menu coverage: 69% of the in-scope items of InDesign 21.6's own menu dump. +- The main file format: InDesign users receive `.indd` files, which DesignCraft can't open. The + previous estimate treated INDD as out of scope; a professional can't. +- No comparison of composition against InDesign exists, so typographic fidelity is unproven. + +## Features by area + +Breadth is the checklist's measured score; Ready is this review's estimate of depth and reliability. +Hours overlap with the dimension table (UI and format work sits in both views); the dimension +table is the one summed. + +| Area | Breadth (checklist) | Ready | Hours | Main gaps | +|---|---|---|---|---| +| Application shell & workspace | 99% | 55% | 40–60 | context menus not opening on some systems; window controls (#333); System theme (#237); New Window/Arrange; Help menu | +| Documents, pages, spreads | 100% | 65% | 20–35 | Layout ▸ Pages submenu items, Page Attributes / Color Label, Go Back/Forward, layout grid (CJK) | +| Layers | 100% | 75% | 5–10 | IDML layer order fixed 2026-10-09; Layer panel options depth | +| Frames, shapes & paths | 100% | 60% | 25–40 | object copy/paste failures, live-corner handle (#186), reference point relocation (#337), Join/Reverse Path/Convert Point menu | +| Transform | 100% | 70% | 10–20 | Rotate 180°; transform panel depth | +| Fill, stroke, colour | 100% | 60% | 20–35 | hex entry (#323); Assign Profiles / Convert to Profile; color-managed proofing depth; Adobe Color Themes OOS | +| Effects & transparency | 100% | 50% | 20–30 | effects fidelity vs InDesign unmeasured; Clear Effects / Clear All Transparency; flattener depth | +| Placing & links | 93% | 55% | 20–35 | EPS is preview-only (no PostScript interpreter); Edit Original; placed PDF layer visibility; INDD page place | +| Type & text frames | 99% | 45% | 40–60 | **no manual threading** (ports drawn, no out-port click, no thread/unthread command: alpha blocker; #352, #194, #165, #140); caret formatting discarded (#289); canvas IME (#322); Notes/Track Changes menus; frame grids | +| Typography | 100% | 45% | (typography dimension) | see [typography-parity.md](typography-parity.md) | +| Styles | 100% | 60% | 15–25 | Paragraph Styles edit/delete reported failing (#169); style options depth | +| Tables | 100% | 55% | 25–40 | anchored objects in cells not drawn (#163); Table ▸ Table Options submenus, Diagonal Lines, Convert Rows, Edit Header/Footer | +| Long documents | 100% | 45% | 30–50 | Books are shallow (sync, book pagination, book PDF/TOC/index); Table of Contents Styles | +| Interactivity & digital | 100% | 40% | 30–50 | HTML export basic (#314); fixed-layout EPUB is page images; no animation, timing, media playback | +| Output & production | 92% | 55% | 40–70 | PDF/X-4 only (no X-1a/X-3), no PDF/print presets or `.joboptions`, print via `lpr` (no Windows), no trapping; Output Preview panel | +| XML & automation | 100% | 35% | (ecosystem dimension) | no ExtendScript/UXP; XML structure depth | +| View & navigation | 100% | 65% | 10–20 | Navigator, display badges, Show/Hide extras (content grabber, live corners) | +| Undo, history, saving | 100% | 70% | 5–10 | Undo/Redo name the action; File ▸ Open Recent | +| Accessibility | 100% | 50% | 15–25 | tagged PDF exists; PDF/UA validation, reading order depth, screen-reader support in the UI | + +## Remaining effort and calibration + +**Calibrated against this repo's history.** The first commit was 2026-10-01 09:00; by 2026-10-10 +`origin/main` had 470 commits (77 merged pull requests), 126,411 lines of Rust in 276 files and +1,122 `#[test]` functions. Commit timestamps show ~43 wall-clock hours of activity (gaps under +45 minutes), with one to five agents working in parallel, so roughly **100–150 Opus 5.5 +agent-hours** built the current app: ~0.25 h per commit, **1–2 h per feature arc** (a command, +dialog, IDML and PDF support, tests). The community fixes of 2026-10-09/10 (text composition edge +cases #282–#298, 40–400 lines each) took about **1–2 h each**. Depth work against an oracle +(matching InDesign's behaviour exactly) is slower than adding features: **2–4 h per behaviour +cluster**, more when the oracle has to be built first. + +| Work | Opus 5.5 h | Parallelizes? | Needs a human | +|---|---|---|---| +| Beta blockers (see [roadmap.md](roadmap.md)) | 420–750 | ~70% (per crate) | owner decision on INDD import (#114); InDesign oracle captures on the owner's Mac | +| Rest of full parity | 730–1,200 | ~70% | native-speaker review for 12 languages; owner decision on ExtendScript compatibility and AI models | +| **Total to full parity** | **1,150–1,950** | | | + +With four to five agents in parallel and an integrator, the beta work is ~120–200 wall-clock +hours. + +## Methodology and sources + +- **Target:** Adobe InDesign 2026, version 21.6.0.57, installed at `/Applications/Adobe InDesign + 2026/`. Inspected without launching and without reading anything inside the bundle beyond names + and listings (clean-room rule, AGENTS.md): `Info.plist` `CFBundleDocumentTypes` and + `UTExportedTypeDeclarations` (formats), `.lproj` folder names (25 UI localizations), plug-in + folder names (filters), `Presets/` folder names (swatch libraries, workspaces, shortcut sets). +- **Reference material:** `plan/indesign/01–11` (local, gitignored): overview, UI/UX, feature + catalogue, menu tree, tools, panels, dialogs, file formats, shortcuts, typography, and + observations of the running app. +- **DesignCraft measured from source on `origin/main` (92649ff, 2026-10-10)**: command registry, + `MENUS`, i18n catalogs, crate line and test counts, format modules. No local build (disk). +- **User evidence:** GitHub issues #1–#352 and open PRs (65 open, including the INDD converter + proposals #98 and #114). + +## Appendix: what works today + +The inventory below was the "Working today" list of ROADMAP.md (moved here 2026-10-10, unchanged). +It is evidence of breadth: each item exists. It is not a claim of InDesign-level depth. + +- Document model: facing/non-facing spreads, parent pages with page-number markers, sections, layers, frames (text / graphic / unassigned), threaded stories, paragraph/character/object styles with based-on, swatches (process/spot/tints/gradients), corner options, text wrap, drop shadow, opacity/blend. +- Text engine: Knuth–Plass paragraph composer and single-line composer, dictionary hyphenation (public-domain Moby list + our own trained Liang patterns), justification with word/letter/glyph-scaling ranges, keep options (keep with next, keep lines together, widows/orphans), balance ragged lines, optical margin alignment, hyphenation zone/limit, columns, threading across frames and spreads, tabs, bullets/numbering, rules, shading, baseline grid, first-baseline options, vertical justification, wrap exclusions, overset detection, caret/hit testing. +- FID2: IDML underline/strikethrough Text Color resets survive style inheritance and roundtrip. Raster and PDF paint underlines behind glyphs, retain strikethrough above, and center rules on their offsets; synthetic overlap tests cover both backends. +- Rendering: vello_cpu (SIMD, multithreaded) — pages, items, images with mip levels, gradients, composed text. +- UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts, document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches, Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document and Preferences dialogs, Quick Apply (⌘Return), 4 brightness themes. +- Pen tool, Direct Selection anchor/handle editing, rotate from corners, Rotate/Scale/Shear tools, Eyedropper, snapping & smart guides, Align/Distribute, Find/Change (text + GREP), Story Editor, Paragraph Style Options, Effects panel, hidden characters, native macOS menu bar, measured InDesign 2026 look (Medium Dark): Properties sections per selection state, 21 pt spinner fields with arithmetic, Contextual Task Bar, inverse (black) text selection, Pages panel with drag-and-drop reorder / parent apply, Layers panel with per-object rows. +- Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection, Type (draw frame, click caret, select, type), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom. +- PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`. File › Export PDF… now opens an "Export PDF" options dialog (General / Compression / Marks and Bleeds / Advanced) and remembers the last-used settings across exports and sessions. +- IDML interchange (`designcraft-idml`): export and import of swatches, styles, fonts, preferences, parent spreads, spreads/pages, frames, groups, images (embedded or linked), formatted threaded stories — opens in InDesign 2026 and round-trips InDesign-exported files (`file.exportIdml`, `file.openIdml`, File → Export IDML…, CLI `--in x.idml` / `--export x.idml`). +- Tables (M8): tables anchored in stories (header/footer/body rows, merged cells, per-edge strokes, fills + alternating fills, insets, vertical justification, at-least/exact row heights); composed into the text column (columns scale to fit, rows break across columns/frames with repeating headers/footers, overset); rendered, exported to PDF (real text) and IDML (export + import); Table menu, Table panel, Create Table dialog, caret/typing/Tab navigation and cell selection in cells; `table.*` commands. +- Hyperlinks (text or frames → URL / e-mail / page) and bookmarks, exported as PDF link annotations and outline. +- EPUB 3 (reflowable) export: stories in reading order, CSS from paragraph/character styles, images, navigation (`file.exportEpub`, CLI `--export x.epub`). +- Data Merge (one linked CSV, TSV, text, or Excel file; text, image, QR, and hyperlink placeholders; preview; a new document with one record per page or records tiled on a single page), spell checking (public-domain Moby list + document dictionary, suggestions), Step and Repeat (count or grid), snippets, object styles, Numbering & Section Options. +- Footnotes: Type › Insert Footnote (caret moves into the note), Document Footnote Options (numbering style incl. symbols, start/restart per page/spread/section, prefix/suffix, reference position/character style, paragraph style, separator, spacing, first baseline, rule above); notes composed at the bottom of the referencing column with the body text making room; edited in place on the canvas; rendered, exported to PDF (real text) and IDML (InDesign's structure, verified opening in InDesign 2026) and imported from IDML; `footnote.*` commands. +- Paragraph Rules: Rule Above / Rule Below (on, weight, colour, tint, column or text width, offset, left/right indent) set in Type › Paragraph Rules…, the Paragraph panel menu and Paragraph Style Options › Paragraph Rules; inherited style values shown, and `type.para` / `style.paragraph.edit` change only the rule fields named. +- Cross-references and text anchors: Insert Cross-Reference (paragraph by style, or text anchor), 11 formats with InDesign's building blocks (full/partial paragraph, paragraph text/number, page number, anchor name, chapter, file name) plus user formats; resolved live at composition — never out of date, no Update step; unresolved destinations show `??`; PDF link annotations to the destination page; IDML export/import as InDesign cross-reference sources, hyperlinks, text destinations and formats (verified in InDesign 2026); `xref.*` / `anchor.*` commands. +- Index: page references (topic from the selection or up to 4 levels, sort keys; current page, to end of story, next n paragraphs, suppressed, See / See also), Generate / Update Index (section headings, nested or run-in, page ranges merged, Index Title / Section Head / Level 1–4 styles); IDML export/import as InDesign topics, page references and topic cross-references (verified in InDesign 2026); `index.*` commands. +- Text clipboard keeps formatting (character/paragraph formats, footnotes, cross-references, index markers, tables) and falls back to plain text when the system clipboard changed; Paste without Formatting (⇧⌘V); Change Case (UPPERCASE, lowercase, Title Case, Sentence case — formatting kept); Type › Insert Special Character / White Space / Break Character submenus. +- Anchored objects: items flow in the text inline (on the baseline, with Y offset; lines grow to fit) or above the line (left/center/right, space before/after); paste copied items into text to anchor them; Anchored Object Options and Release (back onto the page where shown); rendered, exported to PDF and to/from IDML (InDesign anchored object settings; verified in InDesign 2026); copy/paste and threading carry them; `anchored.*` commands. +- 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. +- 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 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). +- Crash recovery: unsaved documents are written to the recovery folder every 30 s and reopened (unsaved, remembering their files) after a crash; File › Revert and Save a Copy…; `file.recovery.*`, `file.revert`, `file.saveACopy`. +- Graphics backend chosen before the window exists (DirectX 12 on Windows, Vulkan on Linux, Metal on macOS, OpenGL as the fallback; `WGPU_BACKEND` overrides) with a start-up fallback: a backend whose start never showed a frame is skipped at the next start and the status bar says so (`gpu.json`). Found on an AMD hybrid-graphics laptop whose Vulkan driver faulted at the first present. +- Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool). +- Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`. +- Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow adds pages and threaded frames until the text fits; Remove Styles option (`designcraft-textimport`). +- Gradient Feather (⇧G tool, `object.gradientFeather`): objects fade along a linear or radial opacity gradient, on screen and as a soft mask in PDF. +- Script Label (`object.label`, `object.findByLabel`), alt text (`object.altText`), Isolate Blending / Knockout Group (`object.transparencyGroup`; on screen, PDF isolates), Table ▸ Sort (`table.sortRows`, numeric-aware, header/footer fixed). +- File ▸ Export Text (`file.exportText`): a story as Text Only or RTF (fonts, styles, alignment, indents, tables as tab-separated rows); Export EPUB now asks for a path in the UI. +- Tagged PDF (`file.exportPdf {tagged}`, on by default from the UI): structure tree with stories as paragraphs in reading order, figures carrying their alt text, parent-page items and printer's marks as artifacts. +- Global Light (`object.globalLight`, `globalLight` on drop/inner shadows; Use Global Light in the Effects panel) and Generate Static Caption (`object.caption`: template over name, path, alt text, label, effective ppi, dimensions, format; any side, offset, style). +- Control panel scale X/Y, rotation and shear fields (`transform.info`, absolute `transform.set {scaleX, scaleY, rotation, shear}` about the reference point) and Preferences › Transformations are Totals for nested objects and placed graphics. +- Variable fonts: each named instance is a style (Font menu, shaping with feature variations, metrics, outlines) and embeds at its axis settings in PDF; free axis sliders still to come. +- Object ▸ Select (first/next above, next/last below, container, content, previous/next in group), full Object ▸ Convert Shape (`object.convertShape`), Table ▸ Split Cell Horizontally/Vertically. +- Attributes panel (`object.attributes`: Overprint Fill/Stroke/Gap, Nonprinting), with Overprint Gap in Overprint Preview and IDML. +- Page Numbering View (Preferences: section or absolute): page labels everywhere follow it; Go to Page takes section names, numbers or `+n`. +- Preferences › Advanced Type (document `advancedType`: superscript/subscript size and position; IDML TextPreference); subscripts now drop 33.3% like InDesign. +- Preferences › Composition › Highlight: keep violations, H&J violations (three shades), custom tracking/kerning, substituted fonts. +- File ▸ Export HTML (`file.exportHtml`): one self-contained page in reading order, styles as CSS, images inline, alt text from Object Export Options (also in EPUB). +- Swatches panel colour groups (`swatch.newColorGroup`, `moveToGroup`, `ungroupColorGroup`, `renameColorGroup`; folders and a row context menu; IDML ColorGroup). +- Object ▸ Generate QR Code (`object.qrCode`: web link, text, SMS, email, business card; vector modules as a compound path, into a frame or re-encoded in place). +- Conditional text (`condition.new/options/delete/apply/list`, Conditional Text panel): hidden conditions drop their text from layout and exports, indicators underline on screen, IDML Condition/AppliedConditions. +- Endnotes (`endnote.insert/edit/delete/list/options`, Type ▸ Insert Endnote): references number through the document, the endnote frame on a new last page lists them under a heading; IDML keeps the listed text only. +- Editorial notes (`note.new/edit/delete/convertToText/list`, Notes panel): anchored in text, flagged on screen, never printed or exported. +- File ▸ Print Booklet (`file.printBooklet`): saddle-stitch or 2-up consecutive printer spreads as PDF, padded with blanks, with a gap between pages. +- Edit ▸ Transparency Blend Space (`edit.transparencyBlendSpace`; RGB for web/mobile documents; IDML TransparencyPreference) — exported PDFs give pages with transparency a DeviceCMYK or DeviceRGB page group, so viewers and RIPs blend in that space. On screen, spreads with transparency in a CMYK blend space are shown through the working CMYK profile (their colours keep to its gamut, as when composited in CMYK). +- High Contrast interface theme (fifth Interface Color Theme: black chrome, white text, yellow selection). +- Place Excel workbooks (.xlsx): the first worksheet's used range becomes a table (shared/inline strings, numbers, booleans). +- Object Library (`library.new/open/add/place/remove/list/json/close`, Library panel): `.dclib` files of snippets that keep stories, styles, swatches and images. +- Ink Manager (`ink.list`, `ink.options`; Swatches panel menu): All Spots to Process, per-ink conversion and ink aliases in PDF output; IDML ConvertToProcess/AliasInkName. +- Appearance of Black complete: Printing/Exporting rich black for RGB output (PNG), Overprint [Black] Swatch at 100% (document, IDML OverprintBlack). +- Hyperlinks and Bookmarks panels (Window ▸ Interactive): new from the selection, edit (`hyperlink.edit`), go to source (`hyperlink.goToSource`), rename bookmarks (`bookmark.rename`), go to page, delete. +- Nested line styles (`nestedLineStyles` paragraph attribute, Paragraph Style Options › Drop Caps and Nested Styles, IDML AllNestedLineStyles): the first lines restyle until the line breaks settle. +- User dictionary hyphenation exceptions (`hyphenation.addException/removeException/list`, Edit ▸ Spelling ▸ User Dictionary): `ex~am~ple` breaks only at `~`, a plain word never hyphenates. +- Frame Fitting Options (`object.fittingOptions`, Object ▸ Fitting ▸ Frame Fitting Options…): Auto-Fit refits on resize, fitting, Align From reference point, crop amounts; IDML FrameFittingOption. +- Paragraph borders (per-side weights, colour, tint, offsets) and shading offsets, drawn per column part of split paragraphs (also when overset); Paragraph panel controls; IDML ParagraphBorder*/ParagraphShading*Offset. +- Font menu: search, favourites (stars, Show Favorites Only; `favoriteFonts` preference) and each family previewed in its own face. Western (and other-script) families come first, then Japanese, Simplified Chinese, Traditional Chinese and Korean ones, each group alphabetical and set off by a separator; the group comes from the font's own data (`meta` languages, family names in a CJK language, OS/2 code pages, else what it covers), read once (installed fonts by the font scan, without loading them). CJK families show their native names, the English name on hover; Preferences › Type › Show Font Names in English (`showFontNamesInEnglish`) shows English names throughout. Search matches either name; documents store the English name. Every family list (Character panel, Control bar, Properties, style dialogs, Find Font, Glyphs) follows this order. +- Application preferences (engine `Prefs`) now persist across launches (desktop: prefs.json beside ui.json). +- View ▸ Fit Selection in Window (⌥⌘=) and Find/Change ▸ Object (`find.objects`, `find.changeObjects`: by fill, stroke, weight, opacity, kind, layer, label). +- Custom workspaces (`window.newWorkspace/deleteWorkspace/resetWorkspace`; workspace switcher shows the current one): bars and panel arrangement saved by name. +- Word/RTF Import Options (File ▸ Place with Import Options…, `place.styles`, `file.place {styleMap, styleConflicts}`): map imported styles onto the document's, use existing / redefine / auto-rename on conflicts, or remove formatting. +- Custom anchored objects (`anchored.insert/options {position: custom}`): placed relative to the anchor, frame, column, page margins or page edge with reference points and offsets, no space in the text, keep within column; IDML Anchored position. +- Variable font axes: Character panel sliders; any axis values (`fontStyle: "Bold {wght:650}"`) make that instance for layout, screen and PDF. +- Image Import Options › Crop to for placed PDFs (`file.place {pdfCrop: crop|trim|bleed|art|media}`, Place PDF dialog): the frame shows that page box. +- Stroke styles: striped (thick-thin… and custom bands), wavy and straight-hash strokes now draw on screen and in PDF; named custom stripe/dash/dot styles (`strokeStyle.new/delete/list`, Stroke panel Type list; IDML Striped/Dashed/DottedStrokeStyle). +- Object ▸ Primary Text Frame (`object.primaryTextFrame`): any text frame's story can become (or stop being) the primary story Smart Text Reflow follows. +- Column rules (Text Frame Options › Column Rules, `object.textFrameOptions {columnRule, columnRuleWeight, columnRuleColor, columnRuleTint, columnRuleOffset, columnRuleTopInset, columnRuleBottomInset}`, reported by `document.inspect`): a bar centred in each gutter of a multi-column frame, inside the insets and between the columns of right-to-left and vertical frames too, broken around paragraphs that span columns, with weight, colour, tint, horizontal offset and top/bottom insets; on screen, in PDF (tagged as an artifact) and in IDML (`ColumnRule…` attributes of `TextFramePreference`, also in object styles). The dialog shows the frame's options and changes only the fields edited. Rule stroke types and overprint aren't there yet. +- Place a page of an IDML or DesignCraft document (`file.place {layoutPage}`): its objects, stories, styles and swatches as one group (INDD files are not readable). +- Articles panel (`article.new/add/remove/options/delete/list`): reading order and content of EPUB and HTML exports. +- Object ▸ Clipping Path (`object.clippingPath {type: alpha|edges, threshold, tolerance}`): traces the placed image and makes the outline the frame. +- Gridify: arrow keys while dragging a frame or shape tool add columns/rows (`frame.grid`), one undo step. +- Gap tool (U, `gap.move`): drag the space between objects (or an object and the page edge) and both sides resize. +- XML basics (`xml.newTag/deleteTag/tag/mapStyle/structure`, `file.exportXml`, `file.importXml`; Tags panel with structure; View ▸ Show Tagged Frames): tagged frames export in reading order, mapped paragraph styles become elements, imported elements flow into frames with matching tags. Inline text tagging (`xml.tagText`, an `xmlTag` character attribute, exported as nested elements) and tag markers (View ▸ Structure ▸ Show Tag Markers) too. DTDs (`xml.loadDtd`, `xml.validate`, `xml.deleteDtd`; Tags panel): loading one adds its elements as tags and names the root; validation checks content models (sequences, choices, `?*+`, mixed content, EMPTY/ANY, parameter entities) and required attributes, and lists each problem with its element path. +- Adjust Layout / Layout Adjustment (`adjustLayout` on Document Setup and Margins and Columns): objects follow each page's margin box to its new size and position. +- Content Collector and Placer tools (B; `conveyor.collect/place/list/clear`): copies of objects (with stories, styles, images) ride a conveyor between pages and documents; Alt-click keeps them on it. +- Linked stories (Edit ▸ Place and Link, `story.placeAndLink/links/updateLink/unlink`): child stories copy a parent, show out of date in the Links panel and update. +- Edit ▸ Menus (`window.hideMenuItem`): hide menu items; menus with hidden items end in Show All Menu Items. +- View ▸ Separations Preview (`view.separations`): one process plate as ink density, or areas over an ink limit in red (a GCR preview of the rendered view; spot plates and the flattener preview aren't there). +- Separations Preview plates now come from each object's own colour (CMYK as authored; RGB and images through the colour settings, images via a cached 17³ table), so pure black shows only on the black plate. +- Language (Character panel ▸ Language, Properties ▸ Character, `type.char {language}`): text in other languages isn't hyphenated or spell-checked with the English dictionaries, typographer's quotes follow the language (German „…“, French and Russian «…», Swiss, Spanish, Dutch, Nordic, Japanese and Traditional Chinese 「…」, Simplified Chinese “…”), the font's localized forms (OpenType `locl`: Turkish i, Bulgarian Cyrillic, Japanese / Chinese / Korean glyph variants) follow it too, and so does the fallback for CJK characters the font lacks (with Draw Missing Glyphs from Fallback Fonts on): Japanese, Simplified Chinese, Traditional Chinese and Korean each try their own installed system fonts (Japanese starts with the craft-fonts Japanese faces, Mincho first, when built with them; Hangul, kana and Bopomofo pick their language's fonts whatever the language). Language names from IDML keep their spelling; locale codes as InDesign writes them for some languages (`de_DE_2006`, `nl_NL_2005`; any `ll`, `ll_CC`, `ll-CC`) are their language and region for all of the above, and Chinese is also recognised in its common spellings ("Simplified Chinese", "Chinese (Traditional)", `zh_CN`, `zh-Hant`). Spanish is hyphenated with Javier Bezos's Liang patterns (`hyph-es.tex` 5.0, MIT) through the `hypher` crate; hyphenation for other languages isn't bundled yet. +- Books (`book.new/open/add/remove/list/styleSource/paginate/syncStyles/exportPdf`, Book panel): `.dcbook` files, continuous page numbering, style sync from a style source, one merged PDF. +- Track Changes (`changes.track/list/acceptAll/rejectAll`, Type ▸ Track Changes): typing is marked added (highlighted on screen), deletions are kept but hidden from layout and exports until accepted or rejected. Per-change accept/reject and authors aren't there yet. +- Track Changes: per-change Accept / Reject (`changes.accept`, `changes.reject`) and a Track Changes panel listing every change. +- Move table rows and columns (`table.moveRow`, `table.moveColumn`, Table panel buttons; with whole rows or columns selected, drag them with the Type tool — `table.dropCells`). +- Distribute Rows Evenly (`table.distributeRows`, Table menu and panel): selected rows receive equal fixed heights preserving their composed total height, with undo/redo. Row-spanning merges and rows outside the composed frames are rejected; shrinking tall cells can overset their text. +- Object States (`states.create/show/rename/release/list`, Object States panel): multi-state objects show one state on screen and in PDF. +- Buttons (`button.set/clear/list`, Buttons and Forms panel): go to page / next / previous / first / last / URL as PDF link annotations. Form fields too (`form.set/clear/list`: text fields, check boxes, combo and list boxes, signature fields; required, multiline, defaults) exported as AcroForm widgets through an incremental update (not in PDF/X). +- Power Zoom (Hand tool: press and hold still for half a second, or Alt-press): zoom out to the spread, aim the red view rectangle, release to zoom back in there. +- Graphic cells (`table.placeGraphic`, `table.textCell`, Table › Convert Cell Type, Table panel): images in table cells, fitted or filled, clipped to the cell on screen and in PDF. +- Live Distribute (Space while dragging a selection handle; `transform.resize {distribute}`): objects keep their size and spread with the bounds. +- Liquid Layout (`liquid.pageRule/object`, `guide.liquid`, `layout.createAlternate`, Liquid Layout panel): scale / re-center / guide-based / object-based page rules applied on page resize and Document Setup; dashed liquid guides; alternate layouts as resized page copies in a named section. Text in an alternate layout stays linked to the original stories (Links panel shows them out of date; `story.updateLink`). +- Split Window / New Window (`window.split`, `window.newWindow`, Window ▸ Arrange): two side-by-side views of the document with their own zoom, scroll and render cache. New Window puts the second view in its own OS window (an egui viewport; a floating window on the web). +- Transparency flattener (`file.exportPdf {flatten: high|medium|low|ppi}`, Edit ▸ Transparency Flattener Presets): each object involving transparency becomes an opaque image of its area over what's beneath it, so everything else stays vector (spreads with transparent parent items are rasterised whole). +- Video and sound (place .mp4/.mov/.webm/.mp3/.wav…, `media.options/get`, Media panel): media frames show a poster or placeholder on screen, in print and in PDF, and export as HTML5 `<video>`/`<audio>` in EPUB. Interactive PDF export (`file.exportPdf {media: true}`, File ▸ Export PDF (Interactive)) embeds each file and plays it in a Screen annotation through a rendition action (controls, loop, play on page load). No in-app playback. +- 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 for right-to-left text; IDML DigitsType) draw 0–9 in another script. Kashidas (`kashidas` paragraph attribute, on by default): 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 (Edit ▸ Interface Language, `app.language`): 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. +- 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 our own Generic CMYK ICC 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. +- 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: Edit ▸ Color Settings (`color.settings`: RGB/CMYK working spaces, 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. +- Keyboard: Tab / Shift+Tab hide and show all panels / all but Tools (`window.hidePanels`, `window.hidePanelsExceptTools`); Cmd+F6 / Cmd+Shift+F6 cycle documents; panel fields are reached with Tab and Escape returns to the layout. +- Smart guides and snapping (View ▸ Snap to Guides / Snap to Document Grid / Smart Guides, `view.snapPreferences` and Preferences ▸ Guides & Pasteboard): moving, drawing, resizing, rotating and pen/anchor drags snap in order to the document and baseline grids, ruler guides, margins and columns, other objects' edges and centres (parent-page items too, by visible stroked bounds), equal spacing, matching dimensions and matching rotation, with guides, gap marks and measurements drawn on the canvas. Shift and Cmd constrain the gesture. +- View ▸ Rotate Spread (`view.rotateSpread`, `layout.rotateSpreadView`): the spread in view turns in quarter turns on screen (stored with the document; output is unaffected). The canvas maps each spread through its own transform, so tools, snapping, resize handles, guides, overlays and rendering all follow the turned spread. +- Vertical type (Vertical Type Tool, Type ▸ Story Direction `type.storyDirection`, IDML StoryOrientation): the direction belongs to the story, so every frame of a thread is vertical or horizontal together and a frame threaded onto a story takes its direction (`object.textFrameOptions {vertical}` sets the frames' stories); lines run top to bottom and follow each other right to left; the arrow keys follow them (Down and Up along the line, Left and Right to the next and previous line, keeping the position along it); CJK characters stay upright and other text turns, on screen and in PDF; CJK line breaking with basic kinsoku (also in horizontal text); Korean breaks at spaces, word by word (Hangul in any language, hanja in Korean text), or between syllables when the paragraph asks for it (`type.para {koreanCharBreaks}`, Paragraph Style Options ▸ Justification; not written to IDML). Upright text is shaped top to bottom: punctuation and brackets take their vertical forms (OpenType `vert`; `vkrn` and `vpal` through OpenType features), glyphs advance by their vertical metrics (`vmtx`) and hang from their vertical origins (`VORG`) on the centre of the ideographic em box (`BASE`, else OS/2), which the first line's ascent follows; turned text keeps its horizontal forms. Tate-chu-yoko (Type ▸ Tate-Chu-Yoko, `type.tateChuYoko`, IDML `Tatechuyoko`) sets a run such as "12" side by side within one em of the line. Ruby (Type ▸ Ruby…, `type.ruby`, IDML RubyString; group ruby spread 1-2-1 or centred) and kenten emphasis dots (`type.kenten`, IDML KentenKind) are set over their text — to its right in vertical frames — without changing line breaks. Warichu and ruby options (per-character ruby, positions, overhang) aren't there yet. +- Math expressions (Type ▸ Insert Math Expression…, `math.insert`, `math.svg`): a LaTeX subset (fractions, scripts, roots, big operators with limits, Greek, relations and operators with math spacing, `\left…\right`, `\text`) typeset by our own engine into SVG, placed inline at the cursor or as a frame; the LaTeX is kept as alt text and can be edited. +- Object Layer Options for placed PDFs (`object.pdfLayers`, `object.layerOptions`, Object ▸ Object Layer Options…): the file's optional-content layers listed and shown or hidden per frame on screen; in exported PDFs such graphics go out as 300 ppi images of their visible layers. +- Effects: Inner Glow (edge or centre), Bevel and Emboss (inner bevel lit from the light angle), Satin (`object.innerGlow`, `object.bevel`, `object.satin`); PDF export now keeps soft effects: objects with shadows, glows, feathers, bevels or satin go out as 300 ppi transparent images of their appearance (threaded text frames excepted). +- Directional Feather (`object.directionalFeather {widths: [top, left, bottom, right]}`): each side fades over its own width. +- View ▸ Flattener Preview (`view.flattenerPreview`): objects involving transparency highlighted in red; knockout groups export as images (PDF output has no knockout groups). +- Live captions (`object.caption {live: true}`, Object ▸ Captions ▸ Generate Live Caption): the caption text is re-filled from the source object's metadata after every edit (same undo step). +- Style Export Tagging (`style.exportTag`, Paragraph Style Options ▸ Export Tagging): paragraph and character styles choose their HTML element and class in EPUB/HTML. +- Tagged Text: export (`file.exportText {format: "tagged"}`) and place (detected by its header; ASCII, UTF-8 or UTF-16) with paragraph and character styles, font, typeface and size, and `<0x…>` characters. +- Tagged PDF: one structure element per paragraph (H1–H6 and BlockQuote from Export Tagging, else P) in reading order, frame fills, strokes, rules and tables as artifacts, figures with alt text. +- EPUB: cover image from the first page (`file.exportEpub {cover}`), and Fixed Layout export (`file.exportFixedEpub`, File ▸ Export EPUB (Fixed Layout)…): pre-paginated pages as 144 ppi images with their text kept for search and read-aloud. +- Interactive PDF options (`file.exportPdf {fullScreen, bookmarksPanel, pageLayout, view, advanceSeconds}`, File ▸ Export PDF (Interactive)…): page mode, layout, opening view and page timing in the catalog and pages. +- Object Export Options (`object.exportOptions`): tagged-PDF artifact, EPUB/HTML rasterize (the object as an image), alignment and page break before, alt text. +- Scripts panel (Window ▸ Utilities ▸ Scripts, `script.run`): saved command scripts (with `$N.path` references to earlier results) run as one undo step; edit, add, load and delete them in the panel. Scripts use DesignCraft's command language, not ExtendScript. +- Drag and drop text editing: drag selected text to move it (Alt copies) with its formatting (`text.release`). +- Image Import Options: place any page of a multi-page PDF (`file.place {pdfPage}`; the app asks which page), shown on screen and embedded as that page in PDF export. +- Define Lists: named numbered lists that continue across stories in page order (`list.define`, paragraph `listName` / `startAt`), round-tripped through IDML; `null` in `type.para` / `type.char` now removes an override. +- View › Overprint Preview (⌥⇧⌘Y): overprinting fills, strokes and 100% [Black] text mix with what's beneath on screen. +- Type on a Path (⇧T tool, `type.onPath`): a story along any line, curve or shape on screen and in PDF; Type on a Path Options (start, flip, baseline/ascender/descender/centre) and Delete Type from Path. Round-trips through IDML as TextPath; on-path caret drawing is still to come. +- Preferences › Appearance of Black: 100% K on screen accurately (dark grey) or as rich black (`window.richBlack`); the printing/export side is still to come. +- Spreads of up to 10 pages: Add to Previous Spread and Allow Spread to Shuffle (Pages panel; `layout.pages.toSpread`, `layout.spreadShuffle`); inserted pages go around kept spreads. +- Page tool (⇧P) and `layout.pageSize`: pages of their own size (or a preset); the spine stays put and objects keep their place on their page. +- File › Print (⌘P): printer, copies, page range, spreads, marks, bleed; printed as PDF through the system spooler (`file.print`, `file.printers`, `dryRun` for agents). Shortcuts now act like choosing the menu item (dialogs open), and UI commands win shortcut ties (⌘N opens New Document). +- Nested styles (through/up to N sentences, words, characters, letters, digits, tabs, breaks, spaces or given characters) and GREP styles (regex → character style), edited in Paragraph Style Options and composed under local formatting; `ui.dialog.open` for agents. Nested line styles are still to come; nested and GREP styles round-trip through IDML. +- Table and cell styles (round-trip through IDML): cell styles (fill, insets, vertical justification, strokes, paragraph style) and table styles (region cell styles, border, alternating rows, spacing); apply from the Table panel, edits re-apply to every user; `style.cell.*`, `style.table.*`. +- Style groups: paragraph and character styles shown in group folders, Move to Group (new, existing, none); `style.group` renames every use (styles, stories, table cells, footnotes, object styles). +- Scrubby zoom: drag the Zoom tool left or right to zoom continuously around the press point. +- Guides belong to the active layer: hidden with it (or with its Show Guides off) and locked with it. +- Pencil tool (N): freehand strokes simplified to smooth paths (Alt closes); Smooth and Erase tools re-fit or remove the stretch you drag along (`path.smooth`, `path.erase`). +- Edit › Keyboard Shortcuts: every command, click and press the new keys, conflicts shown, per-command and global reset; `window.setShortcut`. +- Primary Text Frame (New Document) and Smart Text Reflow: pages are added while the primary story oversets and empty ones at the end removed, in the same undo step (Preferences › Type). +- Glyphs panel: every character of any font (rendered by our renderer), search by character or U+code, click to insert, recently used. +- UI scaling (Preferences › Interface, 50–200%; `window.uiScale`); the window title steps aside when the bar is crowded. +- Underline / Strikethrough Options: weight, offset, colour and tint (Character panel; screen, PDF and IDML). +- Relink to Folder and Relink File Extension (`links.relinkFolder`; missing links found by name). +- File › Package (⌥⇧⌘P): the document relinked to a Links folder, the placed files, the font files that draw its text in a Document Fonts folder (the fonts it names and any fallback fonts that draw characters those lack; except fonts whose licence restricts it, OS/2 `fsType`), an IDML copy, an optional PDF and a report (fonts, fallback fonts, links, preflight, instructions); Copy Links To (`links.copyTo`). +- Document fonts: opening a `.designcraft` or `.idml` file loads the fonts in the `Document Fonts` folder beside it (as Package writes it), ahead of installed fonts with the same name, so a packaged document opens with its fonts on another machine. Damaged, oversized (over 256 MB per file, 512 MB in all) and surplus (over 200) font files are skipped and listed in `file.open`'s `warnings`; `font.list` gives each font's `source`. Document fonts belong to their document, as in InDesign: other open documents don't see them (each document with a same-named font draws with its own), and closing the document (or reverting it, or a book chapter's export ending) makes them unavailable. Their files stay in memory until DesignCraft quits, so reopening a document doesn't read or load them again; on the web there is no folder to read. +- Load Swatches / Save Swatches for Exchange (.ase: CMYK, RGB, Lab, Gray; spot or process) from the Swatches panel menu; `swatch.load` / `swatch.save`. +- Pathfinder (Object › Pathfinder and a Pathfinder panel): Add, Subtract, Intersect, Exclude Overlap, Minus Back; curves stay curves (flo_curves); `object.pathfinder`. +- Make / Release Compound Path (⌘8; nested paths become holes), Create Outlines (⇧⌘O; one path per text colour), Scissors tool (C) with `path.split`. +- Transform Again / Individually / Sequence Again (⌥⌘3; a moved copy repeats like Step and Repeat), Clear Transformations; Layers: Merge, Delete Unused, Hide/Lock Others, Show/Unlock All; Break Link to Style (paragraph and character). +- Floating panels: tear a panel off the dock (its float button, or drag its header away) into a movable, resizable window; Dock puts it back; positions persist; `window.floatPanel` / `window.dockPanel`. +- Place SVG (vector in PDF export via krilla-svg, resvg on screen, text in the bundled fonts), Photoshop (.psd/.psb composite in its colour mode — Bitmap, Grayscale, Duotone, Indexed, RGB, CMYK, Lab, 8/16-bit — with merged transparency; extra alpha and spot channels don't change the image), BMP, and Illustrator files saved with PDF compatibility (.ai). New `designcraft-images` crate (L2) for sniffing, sizes and decoding. EPS is still missing. +- View › Display Performance: Fast (grey boxes, no effects, ⌥⇧⌘Z), Typical (72 ppi proxies, ⌥⌘Z), High Quality (⌥⌘H, our default — the renderer is fast enough). +- OpenType menu (Character panel): discretionary ligatures, fractions, ordinal, swash, titling, contextual alternates, slashed zero, the four figure styles and stylistic sets 1–20; `type.openType`; mapped to IDML's OTF attributes. +- Add Anchor Point (=), Delete Anchor Point (-) and Convert Direction Point (⇧C) tools (click toggles smooth/corner, drag pulls out handles), with `path.addAnchor`, `path.deleteAnchor`, `path.convertAnchor`. +- Gradients: the Gradient Swatch tool (G) drags a gradient's start and end across objects (Shift: 45°); the Gradient panel sets type, angle, reverse and edits stops on a ramp (drag, add, drag off to remove, location, colour). Edited gradients are unnamed swatches; `object.gradient`; the vector round-trips through IDML. +- Document Setup (⌥⌘P): intent, number of pages, start page # (an even start begins with a left page), facing pages, size presets and orientation, bleed and slug per edge; `layout.documentSetup {}` reports the setup. +- 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. +- 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. +- Place PDF: placed PDF pages are sized by their crop box, drawn on screen through hayro (with mip levels) and embedded in exported PDFs as vector form XObjects (krilla), `<PDF>` in IDML. +- Menus expose the engine's features (TOC, numbering & sections, text variables, hyperlinks, cross-references, footnotes, step and repeat, spelling, fitting/content, effects, Data Merge, Preflight, EPUB); any menu command with parameters gets a dialog generated from its parameter documentation. +- Table of contents (generate/update, dot leaders) and text variables (running headers, last page number, chapter number, file name, dates, custom) resolved per page. +- Soft effects: blurred drop shadow, inner shadow, outer glow, basic feather. +- ~200 commands, all reachable through the JSON control channel; headless CLI rendering to PNG. +- MCP server (`designcraft-cli mcp [--connect PORT]`, docs/mcp.md): headless engine or the running app; commands, batch, document/story inspection, page renders as images, window screenshots, pointer/keyboard/dialog input. +- Web build (`apps/designcraft-web`, trunk): the same UI on WebGPU with a WebGL2 fallback; open/place via the browser file picker or drag-and-drop, save/export as downloads. + +Task notes that headed ROADMAP.md: + +- 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. +- AR1.1: Kashida justification tests verify space fallback without Tatweel fonts using an isolated font database, while retaining elongation assertions when fonts support it. +- AR1: Arabic IDML controls now retain character direction, kashida switches, diacritic offsets/forms, paragraph policy values and independent story/table directions. Shaping preserves joining context and script/language selection; paragraph-level bidi, contextual digit conversion, mark-safe elongation, RTL columns/tables and physical column insertion have regression coverage. Vendor-specific justification and diacritic presets remain explicit preflight warnings; see docs/arabic-typography.md. +- CJK1: IDML composite fonts now retain character mappings and metrics; explicit aki, tsume, jidori, em alignment/leading, named/custom kinsoku boundaries, hanging punctuation, tate-chu-yoko offsets and kenten symbols flow through composition and IDML export. Automatic kerning no longer imports inactive numeric values as enormous manual spacing. Composite definitions are editable through commands with undo/cache invalidation. Mojikumi definitions are retained and unsupported spacing-table / push-in-push-out policies are reported by preflight. Full InDesign CJK composition parity, frame-grid layout and detailed ruby remain unfinished. Warichu sets the selected run in smaller lines inside the line after the line is broken. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | Full number 42% → 47%: method aligned with the standard (plain weighted sum, the ×0.9 discount dropped), no new evidence; readiness table with hours for full, mainstream and essentials; beta distance 33 → 28 points | +| 2026-10-10 | minor | Added mainstream practitioner (~42%) and essentials user (~54%) numbers with written weights and discounts and a user-evidence count; full number (42%) now written as 47.2% × 0.9, value unchanged | +| 2026-10-10 | minor | Stage alpha → pre-alpha under the core-workflow gate (manual threading missing); Type & text frames row updated | +| 2026-10-10 | major | Created from ROADMAP.md's "How far from full parity" (2026-10-04) and "Working today"; full re-measure against InDesign 2026 21.6: menu coverage measured against InDesign 21.6's menu dump (69%) and the documented tree (75%), breadth set at ~80%, ready-for-real-work re-estimated 83% → 42% on user-reported evidence and the INDD gap; hours recalibrated from git history | +| 2026-10-04 | major | (in ROADMAP.md) breadth 99% weighted over 275 rows, ~83% including depth, ~170 h remaining | diff --git a/docs/typography-parity.md b/docs/typography-parity.md new file mode 100644 index 000000000..0339faf1e --- /dev/null +++ b/docs/typography-parity.md @@ -0,0 +1,90 @@ +# Typography and composition parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first version, from the full re-measure) · **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 +document tracks both: does the attribute exist, and does it compose like InDesign? Script-specific +detail lives in [cjk-typography.md](cjk-typography.md), [cjk.md](cjk.md) and +[arabic-typography.md](arabic-typography.md). Overall numbers: [target-app-parity.md](target-app-parity.md). + +**Dimension score: ~45% ready (estimated), 120–200 h to parity.** Presence is high (76% scored below, nothing absent); fidelity +against InDesign is **unmeasured**, which is the main gap. + +## Fidelity: how we'd know + +There is no oracle yet. What exists: unit tests of break points and invariants in +`crates/compose/src/tests.rs` (165 tests in the crate), IDML files built by DesignCraft and opened +in InDesign 2026 by hand, and user reports comparing the two (#179, #180, #181, #282–#295). + +**Needed (gap 4 in [gaps.md](gaps.md)):** a set of synthetic stories (our own text and fonts: +Source Serif 4, Inter, Shippori Mincho from craft-fonts) covering each row below, set in InDesign +2026 by observation on the owner's Mac (results under `plan/`, never committed), exported as IDML; +DesignCraft opens the IDML and a test compares per paragraph: line count, break positions (by +character offset), last-line width, overset and frame fit. Report the share of paragraphs whose +breaks match exactly. Target for beta: ≥ 90% of Latin body paragraphs identical, 100% fit in the +same frames. + +## Checklist + +Status: **✓** present and believed close · **~** present, known differences or partial · **✗** missing. +Fidelity: **unmeasured** unless stated. + +| Area | InDesign 2026 | DesignCraft | Status | Notes / evidence | +|---|---|---|---|---| +| Paragraph Composer | Adobe's multi-line composer | Knuth–Plass total fit, fitness classes, hyphen penalties | ~ | same family of algorithm, Adobe's weights unknown; breaks will differ on some paragraphs | +| Single-line Composer | greedy | greedy with the same ranges | ✓ | | +| World-Ready composers | Paragraph / Single-line World-Ready | RTL, Arabic and bidi in the main composer | ~ | Indic scripts (Devanagari etc.) shape through harfrust but are untested | +| Justification | word/letter spacing, glyph scaling min/desired/max, single-word, last line | all of these | ✓ | a justified line with a tab now justifies after the tab (#284) | +| Hyphenation | per-language dictionaries (~40), zone, limits, capitalized, last word, across columns | Liang patterns: English (trained from the public-domain Moby list) and Spanish (Bezos); zone, limits, exceptions in the user dictionary | ~ | other languages don't hyphenate (#87 reported "doesn't work") | +| Spelling | per-language dictionaries | English (Moby) + document/user dictionary | ~ | | +| Kerning | Metrics, Optical, manual, none | Metrics, manual, none | ~ | **Optical treated as Metrics** (#181): imported display text sets wider and can overset | +| Tracking, scale, skew, baseline shift | ✓ | ✓ | ✓ | | +| OpenType features | ligatures, discretionary, swash, fractions, ordinals, figures, small caps, stylistic sets, positional forms | through harfrust | ✓ | | +| Variable fonts | axes in Character panel | named instances and free axis values; PDF embeds the instance | ✓ | | +| Leading | auto (120%), absolute, leading model | ✓ | ✓ | CJK leading models in cjk-typography.md | +| First baseline | ascent, cap height, x-height, leading, fixed, min | ✓ | ✓ | | +| Frame fit / overset | baseline of the last line must fit | descenders must fit | ~ | **differs** (#180): frames go overset where InDesign fits | +| Baseline grid | document and frame grids | ✓ | ✓ | | +| Vertical justification | top, centre, bottom, justify, paragraph spacing limit | ✓ | ✓ | | +| Columns, span / split columns | ✓ | ✓ | ✓ | split columns within a column (#295); text after a span flows below it (#276) | +| Column rules | ✓ | ✓ (screen, PDF, IDML) | ~ | no stroke types or overprint (#274) | +| Keep options and breaks | keep with next, lines together, start in next column/frame/page/odd/even | ✓ | ✓ | break characters end paragraphs (#285); the line before a break is a last line (#283) | +| Drop caps, nested, line and GREP styles | ✓ | ✓ | ✓ | | +| Bullets and numbering | lists, levels, formats, restart, across stories | ✓ | ~ | numbering expressions from IDML (#298); Convert Bullets/Numbering to Text missing | +| Tabs | left/center/right/decimal/align-on, leaders, right-indent tab | ✓ | ~ | **Tabs panel missing** (#326); wrapping of lines with tabs fixed (#282) | +| Paragraph rules, borders, shading | ✓ | ✓ | ✓ | | +| Balance ragged lines | ✓ | ✓ | ✓ | | +| Optical margin alignment | ✓ | ✓ (hang table of our own) | ~ | hang amounts are ours, InDesign's unknown | +| Text wrap | bounding box, shape, jump object/column, contour options | ✓ | ✓ | | +| Anchored objects | inline, above line, custom | ✓ | ~ | anchored objects in table cells not drawn (#163) | +| Footnotes, endnotes | ✓ | ✓ | ~ | footnotes don't split across columns yet | +| Type on a path | ✓ | ✓ | ✓ | | +| Vertical text (CJK) | ✓ | ✓ | ~ | see cjk.md | +| 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; user folders, font managers and Adobe Fonts not always found | ~ | #161, #110, #107, #327, #261 | +| 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: +**76%**. Every row exists in some form; the ~ rows are where InDesign users will see a difference. + +## Remaining effort + +| Work | Opus 5.5 h | +|---|---| +| Oracle harness (synthetic stories, IDML exchange, comparison metric) | 20–30 | +| Optical kerning approximation (outline-based side-bearing model) + preflight warning | 15–25 | +| Overset rule (baseline fits) and frame-fit parity | 5–10 | +| Fixing composer differences the harness finds (weights, penalties, glyph-scaling order) | 40–70 | +| Hyphenation patterns for the top 15 InDesign languages (permissive sources) | 20–35 | +| Spelling dictionaries beyond English (licence review per language) | 10–20 (+ owner review) | +| Tabs panel, list conversion commands | 8–15 | +| **Total** | **120–200** | + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | Created: typography checklist (34 rows), fidelity gap and oracle plan | diff --git a/docs/ui-parity.md b/docs/ui-parity.md new file mode 100644 index 000000000..ee6db4232 --- /dev/null +++ b/docs/ui-parity.md @@ -0,0 +1,77 @@ +# UI and interaction parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (stage re-normalized to pre-alpha by the core-workflow gate; earlier today: major, first version) · **Target:** Adobe InDesign 2026 (21.6, Medium Dark, macOS) + +Tools, handles, snapping, nudging, modifiers, shortcuts, panels, menus, context menus and feel, +against InDesign 2026. The look was measured from the running app +(`plan/indesign/11-observed-ui.md`, local); behaviour from `plan/indesign/02-ui-ux.md`, +`05-tools.md`, `06-panels.md`, `07-dialogs.md` and `09-shortcuts.md`, and from user reports. +Overall numbers: [target-app-parity.md](target-app-parity.md). + +**Dimension score: ~45% ready (estimated), 120–180 h.** The chrome looks like InDesign; the +basics of editing fail for too many users on Windows and Linux. + +## Measured + +| What | Value | How | +|---|---|---| +| InDesign 21.6 menu items matched (in scope) | 331 / 482 (69%) | script over the running app's menu dump; see target-app-parity.md | +| Menu bar | 9 menus, 49 submenus, 340 items | `MENUS` in `crates/ui-egui/src/menus.rs` | +| Commands | 446 engine (188 in menus) + 99 UI | `crates/engine/src/cmd/*`, `UI_COMMANDS` | +| Theme colours | measured hex values of Medium Dark, 4 brightness themes + High Contrast | `theme::Tokens` vs `11-observed-ui.md` §1 | + +## Checklist + +**✓** like InDesign · **~** present, differs or unreliable · **✗** missing. + +| Area | InDesign 2026 | DesignCraft | Status | Evidence | +|---|---|---|---|---| +| Chrome: app bar, Control panel, Tools panel, dock, doc tabs, status bar | Medium Dark, measured sizes | measured look | ✓ | 11-observed-ui.md | +| Properties panel and Contextual Task Bar | per selection | ✓, task bar movable (#143) | ✓ | | +| Selection tool: click, marquee, move, Alt-duplicate, resize handles, rotate from corners | ✓ | ✓ | ✓ | | +| Direct Selection, Pen family, Pencil, Scissors | ✓ | ✓ | ~ | moving reference point "0" fails (#337) | +| Live corners (yellow handle) | ✓ | ✓ | ~ | not working for some users (#186) | +| Content grabber, frame fitting by double-click | ✓ | ✓ | ✓ | | +| Smart guides, snapping to guides/grid/baseline | ✓ | ✓ (2,049-line `snap.rs`) | ~ | vertical smart spacing between two rectangles (#188) | +| Nudging with arrows, Shift ×10 | ✓ | ✓ | ✓ | | +| Gridify while drawing, Gap tool, Live Distribute | ✓ | ✓ | ✓ | | +| Type tool: caret, selection, double/triple click, drag-and-drop text | ✓ | ✓ | ~ | formatting at a caret discarded (#289); text editing complaints (#23, #174) | +| Threading: click out port, load text cursor, click frame | ✓ | ✗ ports drawn only; no thread/unthread command; Primary Text Frame and autoflow on Place | ✗ | **alpha blocker** (#352, #194, #165, #140) | +| Copy / cut / paste objects and text, Paste Into / in Place | ✓ | ✓ on macOS | ~ | **failing on Windows/Linux** (#351, #348, #312, #185, #164, #201, #212) | +| Context menus (canvas, panels, rulers) | ✓ everywhere | 9 context-menu sites, ruler units by right-click | ~ | **not opening for several users** (#238, #187, #160) | +| IME text input on the canvas | ✓ | ✗ | ✗ | #322 | +| Font menu: search, preview, favourites, type-ahead | ✓ | ✓ | ~ | search field focus on macOS (#335, #86) | +| Keyboard shortcuts: InDesign defaults, editor, sets | ✓ | ✓ editor; defaults from 09-shortcuts.md | ✓ | | +| Quick Apply | ✓ | ✓ | ✓ | | +| Numeric fields: units, arithmetic, scrubbing | ✓ | 21 pt spinner fields with arithmetic | ✓ | | +| Colour fields: hex, picker from any swatch field | ✓ | ✓ | ~ | hex entry and picker issues (#323, #190) | +| Panels: Pages, Layers, Swatches, Color, Stroke, Character, Paragraph, Styles, Links, Effects, Text Wrap, Align, Pathfinder, Table, Index, TOC, Hyperlinks, Bookmarks, Articles, Tags, Notes, Track Changes, Object States, Buttons and Forms, Preflight, Data Merge, Library, Scripts, Glyphs, Info | ✓ | all present | ~ | **Tabs panel missing** (#326); Navigator, Trap Presets, Animation, Timing missing; pull-down panels can't scroll on Linux (#267) | +| Dialogs: New Document, Document Setup, Preferences (all pages), Paragraph/Character/Object/Table/Cell Style Options, Export PDF, Print, Package, Find/Change | ✓ | present | ~ | generated command dialogs fill the long tail; depth varies | +| Workspaces | Essentials, Advanced, Book, Digital Publishing, Interactive for PDF, Printing and Proofing, Typography, Touch | ✓ named workspaces + custom | ~ | tab bar overlaps menus in Book/Advanced (#182) | +| Story Editor | ✓ | ✓ | ~ | style-name column, depth ruler missing | +| Undo granularity (a drag = one step) | ✓ | ✓ (Begin/Preview/Commit) | ✓ | | +| Closing with unsaved changes asks | ✓ | ✓ (#146) | ✓ | | +| UI scaling, System appearance | ✓ | scaling ✓ (#281); follows macOS light/dark ✗ (#237) | ~ | | +| Window controls, full screen | ✓ | ~ | ~ | misaligned on macOS (#333) | + +Counted 2026-10-10: 27 rows, 11 ✓, 14 ~, 2 ✗ → **67% presence**, scoring ✓ = 1, ~ = 0.5. Ready +for real work is lower, ~45%, because threading is missing and three of the ~ rows (copy/paste, +context menus, typing at a caret) are things a layout artist does every minute, and their failures stop work. + +## Remaining effort + +| Work | Opus 5.5 h | +|---|---| +| Copy/paste, context menus, threading clicks, caret formatting on every platform, with control-channel regression tests | 30–50 | +| Canvas IME (port from VectorCraft) | 10–20 | +| Missing menu items (~150) and panels (Tabs, Navigator, Trap Presets) | 40–70 | +| Handle/tool feel fixes (#186, #188, #337), font search focus | 10–20 | +| Dialog depth pass against 07-dialogs.md | 30–40 | +| **Total** | **120–180** (overlaps gaps 1, 8, 16) | + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | Threading row corrected to ✗ after checking the code (no out-port interaction, no thread command) | +| 2026-10-10 | major | Created: measured menu coverage, 27-row interaction checklist from the code and issues | diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 6715e9153..4850efeae 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -24,7 +24,7 @@ commands: corpus [--download] show where test corpora live; --download fetches PngSuite into corpus/pngsuite stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`) - parity recompute the InDesign feature-parity summary in docs/parity.md + parity recompute the InDesign feature-parity summary in docs/parity-checklist.md ico <out.ico> <in.png>... pack square PNGs (<= 256 px) into a Windows .ico (see packaging/icons.sh) "; diff --git a/xtask/src/parity.rs b/xtask/src/parity.rs index d0dfcf61a..f4eb84eba 100644 --- a/xtask/src/parity.rs +++ b/xtask/src/parity.rs @@ -1,4 +1,4 @@ -//! `cargo xtask parity`: recompute the feature-parity summary in docs/parity.md from its row +//! `cargo xtask parity`: recompute the feature-parity summary in docs/parity-checklist.md from its row //! table (each InDesign feature scored D / P / M, weighted by priority). use std::path::Path; @@ -69,10 +69,10 @@ pub fn summary(rows: &[(String, String, u8, f64)]) -> String { } pub fn run(root: &Path) -> Result<(), String> { - let path = root.join("docs/parity.md"); + let path = root.join("docs/parity-checklist.md"); let text = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?; let (Some(a), Some(b)) = (text.find(START), text.find(ROWS)) else { - return Err("docs/parity.md needs a `<!-- SUMMARY -->` marker before `## Rows`".into()); + return Err("docs/parity-checklist.md needs a `<!-- SUMMARY -->` marker before `## Rows`".into()); }; let rows = rows(&text[b..]); let s = summary(&rows); From c62866443c4cb2698e22483d840204e45f1738ed Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick <hi@skylar.cc> Date: Sat, 10 Oct 2026 14:42:40 -0600 Subject: [PATCH 016/238] UI.9: integrate shared panel docking and preserve workspace behavior --- Cargo.lock | 10 + Cargo.toml | 2 + README.md | 2 + ROADMAP.md | 3 + apps/designcraft/src/gpu.rs | 4 +- apps/designcraft/src/main.rs | 23 +- apps/designcraft/src/profile.rs | 71 +++ crates/engine/src/cmd/table.rs | 2 +- crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/canvas.rs | 4 +- crates/ui-egui/src/control.rs | 2 +- crates/ui-egui/src/dock.rs | 132 ++++- crates/ui-egui/src/lib.rs | 62 ++- crates/ui-egui/src/menus.rs | 195 +++++++ crates/ui-egui/src/panel_docking.rs | 515 +++++++++++++++++++ crates/ui-egui/src/panel_docking_tests.rs | 599 ++++++++++++++++++++++ crates/ui-egui/src/taskbar.rs | 73 ++- crates/ui-egui/src/theme.rs | 17 +- docs/control-protocol.md | 48 ++ xtask/src/layers.rs | 4 +- 20 files changed, 1706 insertions(+), 63 deletions(-) create mode 100644 apps/designcraft/src/profile.rs create mode 100644 crates/ui-egui/src/panel_docking.rs create mode 100644 crates/ui-egui/src/panel_docking_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 9473c974a..7d4d8b31b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -796,6 +796,15 @@ dependencies = [ "libm", ] +[[package]] +name = "craft-ui" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675" +dependencies = [ + "egui", + "serde", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -1122,6 +1131,7 @@ dependencies = [ name = "designcraft-ui-egui" version = "0.5.0" dependencies = [ + "craft-ui", "designcraft-color", "designcraft-compose", "designcraft-doc", diff --git a/Cargo.toml b/Cargo.toml index d69017d63..b4531a9f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,6 +67,8 @@ quick-xml = "0.38" zip = { version = "3", default-features = false, features = ["deflate"] } # ui +# Shared frontend components; keep this dependency above the engine boundary. +craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } egui = "0.36" egui_extras = { version = "0.36", default-features = false, features = ["svg"] } eframe = { version = "0.36", default-features = false } diff --git a/README.md b/README.md index 5c7d4c1ad..2ef989977 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,8 @@ by its own renderer. Try it yourself with `File → New → Sample Document`, or ## Quick start +The egui frontend requires Rust 1.95 or newer (including its shared `craft-ui` components). + ```sh cargo run --release -p designcraft # desktop app (start screen) cargo run --release -p designcraft -- --sample # open the sample magazine diff --git a/ROADMAP.md b/ROADMAP.md index 86a435456..a8ba2db09 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,9 @@ # DesignCraft roadmap - 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. +- UI.5: Panel tabs can move into other groups, split the dock, float, resize and return to their saved locations through shared docking transactions. Layouts persist in custom workspaces; UI and control commands share the same validated action path. + +- UI.4: Shared pane geometry and widget-state theme application through `craft-ui`, retaining all five palettes and local fonts. Split panes send keyboard input only to the focused canvas and scope contextual toolbar IDs per pane. - AR1.1: Kashida justification tests verify space fallback without Tatweel fonts using an isolated font database, while retaining elongation assertions when fonts support it. diff --git a/apps/designcraft/src/gpu.rs b/apps/designcraft/src/gpu.rs index e80f566b9..6dfb0236e 100644 --- a/apps/designcraft/src/gpu.rs +++ b/apps/designcraft/src/gpu.rs @@ -367,7 +367,9 @@ mod tests { // out of the set once it failed. write(&path, &record(Some(Backend::Gl), &[])).expect("write"); let s = Startup::begin_unforced(Some(path.clone()), V).expect("candidates"); - if candidates().len() > 1 { + // This synthetic prior start records OpenGL even on Metal-only macOS. + // Switching depends on the chosen backend, not the candidate count. + if s.backend != Backend::Gl { assert_eq!(s.switched_from, Some(Backend::Gl)); let line = s.status_line().expect("status"); assert!(line.contains("OpenGL") && line.contains(s.backend.name()) && line.contains("WGPU_BACKEND"), "{line}"); diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index c30606232..d0241fc50 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -16,6 +16,7 @@ mod gpu; mod logging; #[cfg(target_os = "macos")] mod native_menu; +mod profile; use designcraft_engine::Session; use designcraft_ui_egui::{DesignApp, Services}; @@ -67,27 +68,13 @@ impl eframe::App for App { } } -/// The per-user settings directory: `ui.json`, `prefs.json`, `gpu.json` and `logs/` live here. -fn config_dir() -> Option<std::path::PathBuf> { - if cfg!(target_os = "macos") { - std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support/DesignCraft")) - } else if cfg!(windows) { - std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join("DesignCraft")) - } else { - std::env::var_os("XDG_CONFIG_HOME") - .map(std::path::PathBuf::from) - .or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config"))) - .map(|c| c.join("designcraft")) - } -} - /// `DESIGNCRAFT_NO_PREFS`: read and write nothing in the settings directory (tests, demos). fn no_prefs() -> bool { std::env::var_os("DESIGNCRAFT_NO_PREFS").is_some() } fn prefs_path() -> Option<std::path::PathBuf> { - config_dir().map(|b| b.join("ui.json")) + profile::config_dir().map(|b| b.join("ui.json")) } fn load_prefs(app: &mut DesignApp) { @@ -249,7 +236,7 @@ fn main() -> eframe::Result { } // The log file lives under the settings directory; opened after the arguments, so `--version` // leaves no file behind. Records logged until now are written to it first. - if let (Some(logger), Some(dir)) = (logger, config_dir()) { + if let (Some(logger), Some(dir)) = (logger, profile::config_dir()) { match logger.attach_dir(&dir.join("logs")) { Ok(path) => log::info!("DesignCraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()), // Standard error only by now (`attach_dir` gave up on the file); unlike `eprintln!`, never panics. @@ -273,7 +260,7 @@ fn main() -> eframe::Result { } // The graphics backend, decided before the window exists: a driver that faults takes the // process down before any Rust code can catch it (see `gpu`). - let gpu = gpu::Startup::begin(if no_prefs() { None } else { config_dir().map(|d| d.join(gpu::FILE)) }, env!("CARGO_PKG_VERSION")); + let gpu = gpu::Startup::begin(if no_prefs() { None } else { profile::config_dir().map(|d| d.join(gpu::FILE)) }, env!("CARGO_PKG_VERSION")); if let Some(s) = &gpu && let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup { @@ -304,7 +291,7 @@ fn main() -> eframe::Result { } let mut session = Session::new(); // Crash recovery: reopen what a previous run left unsaved, then keep it current. - session.recovery_dir = designcraft_engine::recovery::default_dir(); + session.recovery_dir = profile::recovery_dir(); let recovered = session.execute("file.recovery.open", &serde_json::json!({})).ok(); let mut app = DesignApp::new(session, services()); if let Some(line) = gpu.as_ref().and_then(gpu::Startup::status_line) { diff --git a/apps/designcraft/src/profile.rs b/apps/designcraft/src/profile.rs new file mode 100644 index 000000000..c3da12b08 --- /dev/null +++ b/apps/designcraft/src/profile.rs @@ -0,0 +1,71 @@ +//! Native settings and recovery paths, including isolated automation profiles. + +use std::ffi::OsString; +use std::path::PathBuf; + +pub fn config_dir() -> Option<PathBuf> { + config_dir_with(std::env::consts::OS, |key| std::env::var_os(key)) +} + +fn config_dir_with(os: &str, env: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> { + if let Some(root) = env("DESIGNCRAFT_CONFIG_DIR") { + // An explicitly empty override must not fall back to the user's profile. + return (!root.is_empty()).then(|| PathBuf::from(root)); + } + match os { + "macos" => env("HOME").map(|p| PathBuf::from(p).join("Library/Application Support/DesignCraft")), + "windows" => env("APPDATA").map(|p| PathBuf::from(p).join("DesignCraft")), + _ => { + env("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| env("HOME").map(|p| PathBuf::from(p).join(".config"))).map(|p| p.join("designcraft")) + } + } +} + +pub fn recovery_dir() -> Option<PathBuf> { + recovery_dir_with(std::env::var_os("DESIGNCRAFT_CONFIG_DIR"), designcraft_engine::recovery::default_dir) +} + +fn recovery_dir_with(root: Option<OsString>, default: impl FnOnce() -> Option<PathBuf>) -> Option<PathBuf> { + match root { + Some(root) => (!root.is_empty()).then(|| PathBuf::from(root).join("Recovery")), + None => default(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn environment(key: &str) -> Option<OsString> { + match key { + "HOME" => Some("/user".into()), + "APPDATA" => Some("/roaming".into()), + "XDG_CONFIG_HOME" => Some("/xdg-config".into()), + _ => None, + } + } + + #[test] + fn isolated_profile_overrides_every_platform_and_recovery() { + for os in ["macos", "windows", "linux"] { + let dir = config_dir_with(os, |key| if key == "DESIGNCRAFT_CONFIG_DIR" { Some("/isolated".into()) } else { environment(key) }); + assert_eq!(dir, Some(PathBuf::from("/isolated"))); + } + assert_eq!(recovery_dir_with(Some("/isolated".into()), || Some("/user/recovery".into())), Some(PathBuf::from("/isolated/Recovery"))); + } + + #[test] + fn empty_override_never_reads_or_writes_the_default_profile() { + assert_eq!(config_dir_with("macos", |key| { if key == "DESIGNCRAFT_CONFIG_DIR" { Some(OsString::new()) } else { environment(key) } }), None); + assert_eq!(recovery_dir_with(Some(OsString::new()), || Some("/user/recovery".into())), None); + } + + #[test] + fn unset_override_preserves_existing_platform_paths() { + assert_eq!(config_dir_with("macos", environment), Some("/user/Library/Application Support/DesignCraft".into())); + assert_eq!(config_dir_with("windows", environment), Some("/roaming/DesignCraft".into())); + assert_eq!(config_dir_with("linux", environment), Some("/xdg-config/designcraft".into())); + assert_eq!(config_dir_with("linux", |key| if key == "HOME" { environment(key) } else { None }), Some("/user/.config/designcraft".into())); + assert_eq!(recovery_dir_with(None, || Some("/existing/recovery".into())), Some("/existing/recovery".into())); + } +} diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 028971e97..9929c1c71 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -834,7 +834,7 @@ fn distribute_rows(s: &mut Session, p: &Value) -> Result<Value> { } for key in ["row", "story", "table"] { if let Some(v) = p.get(key) - && !v.as_u64().is_some_and(|n| usize::try_from(n).is_ok()) + && v.as_u64().is_none_or(|n| usize::try_from(n).is_err()) { return Err(bad(ID, format!("{key} must be a non-negative integer"))); } diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 52cd054b8..35a3ef0e8 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -9,6 +9,7 @@ description = "DesignCraft egui frontend (InDesign-style UI); swappable — noth workspace = true [dependencies] +craft-ui = { workspace = true } designcraft-geom = { workspace = true } designcraft-color = { workspace = true } designcraft-doc = { workspace = true } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 34dd61442..4c7313e61 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1443,8 +1443,8 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r if resp.clicked() || resp.drag_started() { resp.request_focus(); } - // Keyboard for the active tool. - if ui.ctx().text_edit_focused() && !resp.has_focus() { + // Both split panes show the same document, but only the focused pane dispatches its keys. + if app.pane != app.focus_pane || (ui.ctx().text_edit_focused() && !resp.has_focus()) { return; } let evs = ui.input(|i| i.events.clone()); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 33e7c1b97..f0d34a7ba 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -278,7 +278,7 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> app.ui.control_bar = v; } if p.get("closePanel").is_some() { - app.ui.open_panel = None; + r = crate::panel_docking::close_visible(app).map(|_| Value::Null); } if let Some(v) = p.get("dockExpanded").and_then(Value::as_bool) { app.ui.dock_expanded = v; diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index fdf20e6c4..72810e151 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -49,6 +49,9 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ]; pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { + if crate::panel_docking::show(app, ui) { + return; + } let t = Tokens::get(ui.ctx()); // Expanded panel stack (outermost, 253 pt) — declared first so it sits at the far right. if app.ui.dock_expanded { @@ -66,7 +69,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { ui.ctx(), crate::i18n::tr(&app.ui.language, label), semibold(11.5), - if active { Color32::from_rgb(0xf3, 0xf3, 0xf3) } else { t.text_dim }, + if active { t.text_strong } else { t.text_dim }, ); let w = g.size().x + 26.0; let r = egui::Rect::from_min_size(egui::pos2(x, strip.min.y), vec2(w, 27.0)); @@ -75,9 +78,15 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } ui.painter().galley(egui::pos2(r.min.x + 13.0, r.center().y - g.size().y / 2.0), g, Color32::WHITE); ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.0, t.border)); - if ui.interact(r, ui.id().with(("docktab", *id)), Sense::click()).clicked() { + let response = craft_ui::tabs::Tab::new(ui.id().with(("docktab", *id)), label, active).sense(Sense::click_and_drag()).show_at( + ui, + r, + |_, _| {}, + ); + if response.clicked() { app.ui.dock_tab = id.to_string(); } + crate::panel_docking::legacy_tab(app, ui, id, &response); x += w; } let menu_r = egui::Rect::from_min_size(egui::pos2(strip.max.x - 24.0, strip.min.y + 4.0), vec2(18.0, 18.0)); @@ -100,13 +109,16 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { for (id, label, icon) in ICON_PANELS { let open = app.ui.open_panel.as_deref() == Some(*id); let floats = app.ui.floating.iter().any(|(p, _)| p == id); - if icons::button(ui, icon, 28.0, open || floats, crate::i18n::tr(&app.ui.language, label)).clicked() { + let response = + icons::button(ui, icon, 28.0, open || floats, crate::i18n::tr(&app.ui.language, label)).interact(Sense::click_and_drag()); + if response.clicked() { if floats { ui.ctx().move_to_top(egui::LayerId::new(egui::Order::Middle, egui::Id::new(("floating_panel", *id)))); } else { app.ui.open_panel = if open { None } else { Some(id.to_string()) }; } } + crate::panel_docking::legacy_tab(app, ui, id, &response); ui.add_space(1.0); } }); @@ -123,6 +135,9 @@ fn dock_header(ui: &mut egui::Ui, t: &Tokens, chevron: &str) { /// A panel opened from the icon column, shown as a floating flyout next to the dock. pub fn flyout(app: &mut DesignApp, ctx: &egui::Context) { + if app.ui.docking.is_some() { + return; + } let Some(id) = app.ui.open_panel.clone() else { return }; let t = Tokens::get(ctx); let label = crate::i18n::tr(&app.ui.language, ICON_PANELS.iter().find(|p| p.0 == id).map(|p| p.1).unwrap_or("Panel")).to_owned(); @@ -170,6 +185,9 @@ pub fn flyout(app: &mut DesignApp, ctx: &egui::Context) { /// The contents of an icon-column panel. pub fn panel_body(app: &mut DesignApp, ui: &mut egui::Ui, id: &str) { match id { + "properties" => panels::properties::show(app, ui), + "pages" => panels::pages::show(app, ui), + "layers" => panels::layers::show(app, ui), "swatches" => panels::swatches::show(app, ui), "paragraphStyles" => panels::styles::paragraph(app, ui), "characterStyles" => panels::styles::character(app, ui), @@ -209,20 +227,27 @@ pub fn panel_body(app: &mut DesignApp, ui: &mut egui::Ui, id: &str) { /// Tear panel `id` off the dock into a floating window at `at`. pub fn float_panel(app: &mut DesignApp, id: &str, at: egui::Pos2) { - if app.ui.open_panel.as_deref() == Some(id) { - app.ui.open_panel = None; + if let Some(Err(error)) = crate::panel_docking::command(app, "window.panel.float", &serde_json::json!({"panel":id,"x":at.x,"y":at.y})) { + app.status(error); } - app.ui.floating.retain(|(p, _)| p != id); - app.ui.floating.push((id.to_string(), [at.x, at.y])); } /// Put a floating panel back in the dock's icon column. pub fn dock_panel(app: &mut DesignApp, id: &str) { + if app.ui.docking.is_some() { + if let Some(Err(error)) = crate::panel_docking::command(app, "window.panel.dock", &serde_json::json!({"panel":id})) { + app.status(error); + } + return; + } app.ui.floating.retain(|(p, _)| p != id); } /// Floating panels: movable, resizable windows; "Dock" returns one to the icon column. pub fn floating(app: &mut DesignApp, ctx: &egui::Context) { + if app.ui.docking.is_some() { + return; + } let t = Tokens::get(ctx); for (id, at) in app.ui.floating.clone() { let label = crate::i18n::tr(&app.ui.language, ICON_PANELS.iter().find(|p| p.0 == id).map(|p| p.1).unwrap_or("Panel")).to_owned(); @@ -291,3 +316,96 @@ fn dock_glyph(p: &egui::Painter, r: egui::Rect, c: Color32) { p.rect_stroke(b, 0.0, Stroke::new(1.0, c), egui::StrokeKind::Middle); p.rect_filled(egui::Rect::from_min_max(egui::pos2(b.max.x - 3.5, b.min.y), b.max), 0.0, c); } + +#[cfg(test)] +mod visual_regressions { + use super::*; + + fn scaled_input(size: egui::Vec2, scale: f32) -> egui::RawInput { + let mut input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), ..Default::default() }; + input.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(scale); + input + } + + /// Opt-in evidence for native screenshot review; ordinary test runs stay headless and fast. + #[test] + fn capture_dock_visual_fixtures() { + let Some(directory) = std::env::var_os("CRAFT_UI_VISUAL_FIXTURES").map(std::path::PathBuf::from) else { return }; + std::fs::create_dir_all(&directory).unwrap(); + for brightness in crate::theme::Brightness::ALL { + for width in [800.0, 1280.0] { + for scale in [1.0, 1.5, 2.0] { + let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default()); + app.ui.dock_expanded = true; + app.ui.dock_tab = "properties".into(); + let mut ready = false; + let mut harness = + egui_kittest::Harness::builder().with_size(vec2(width, 640.0)).with_pixels_per_point(scale).wgpu().build_ui_state( + move |ui, app: &mut DesignApp| { + if !ready { + crate::theme::install_fonts(ui.ctx(), "en"); + crate::theme::apply(ui.ctx(), &Tokens::for_brightness(brightness)); + ready = true; + return; + } + show(app, ui); + }, + app, + ); + harness.input_mut().max_texture_side = Some(8192); + harness.run_steps(4); + let stem = format!("designcraft-dock-{}-{width}-{scale}x", brightness.id()); + harness.render().unwrap().save(directory.join(format!("{stem}.png"))).unwrap(); + let panel = egui::containers::panel::PanelState::load(&harness.ctx, egui::Id::new("dock")).unwrap().outer_rect; + let region = serde_json::json!({ + "screen": [width, 640.0], "scale": scale, + "regions": {"dock-tabs": [panel.left(), panel.top(), panel.width(), 45.0]} + }); + std::fs::write(directory.join(format!("{stem}.json")), serde_json::to_vec_pretty(®ion).unwrap()).unwrap(); + } + } + } + } + + fn luminance(c: Color32) -> f32 { + let linear = |v: u8| { + let v = f32::from(v) / 255.0; + if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } + }; + 0.2126 * linear(c.r()) + 0.7152 * linear(c.g()) + 0.0722 * linear(c.b()) + } + + #[test] + fn selected_dock_tab_has_readable_rendered_contrast_in_every_theme() { + for brightness in crate::theme::Brightness::ALL { + for width in [800.0, 1280.0] { + for scale in [1.0, 1.5, 2.0] { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx, "en"); + let tokens = Tokens::for_brightness(brightness); + crate::theme::apply(&ctx, &tokens); + let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default()); + app.ui.dock_expanded = true; + app.ui.dock_tab = "properties".into(); + let mut output = ctx.run_ui(scaled_input(vec2(width, 640.0), scale), |ui| show(&mut app, ui)); + output.textures_delta.clear(); + assert_eq!(ctx.content_rect().size(), vec2(width, 640.0)); + assert_eq!(ctx.pixels_per_point(), scale); + let text = output + .shapes + .iter() + .find_map(|shape| match &shape.shape { + egui::Shape::Text(text) if text.galley.job.text == "Properties" => Some(text), + _ => None, + }) + .expect("the active Properties tab is painted"); + let color = text.galley.job.sections.first().expect("tab text has a format").format.color; + let a = luminance(color); + let b = luminance(tokens.panel); + let contrast = (a.max(b) + 0.05) / (a.min(b) + 0.05); + assert!(contrast >= 4.5, "{brightness:?} at {width}: selected tab contrast is {contrast:.2}"); + } + } + } + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fb81e9c34..5e1046d81 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -16,6 +16,7 @@ pub mod dock; pub mod i18n; pub mod icons; pub mod menus; +mod panel_docking; pub mod panels; pub mod render_worker; mod rtl; @@ -97,6 +98,11 @@ pub struct SavedWorkspace { pub dock_expanded: bool, pub open_panel: Option<String>, pub floating: Vec<(String, [f32; 2])>, + /// Custom panel membership and placement; absent in older workspace files. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub docking: Option<craft_ui::docking::Layout<String>>, + #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub docking_hidden: std::collections::BTreeMap<String, craft_ui::docking::Location<String>>, } /// A missing on-by-default bool stays on. `bool::default` is false. @@ -217,6 +223,20 @@ pub struct UiState { pub open_panel: Option<String>, /// Panels torn off the dock into their own floating windows: (panel id, top-left position). pub floating: Vec<(String, [f32; 2])>, + /// Shared panel layout after the user customizes the legacy dock. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub docking: Option<craft_ui::docking::Layout<String>>, + #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub docking_hidden: std::collections::BTreeMap<String, craft_ui::docking::Location<String>>, + /// Invalidates in-flight gestures when a workspace is replaced. + #[serde(skip)] + pub docking_generation: u64, + /// One-frame reveal request; z-order is never persisted. + #[serde(skip)] + pub docking_raise: Option<String>, + /// Last explicitly revealed customized panel, for the legacy closePanel control. + #[serde(skip)] + pub docking_opened: Option<String>, pub dock_expanded: bool, pub units: Unit, pub workspace: String, @@ -310,6 +330,11 @@ impl Default for UiState { dock_tab: "properties".into(), open_panel: None, floating: Vec::new(), + docking: None, + docking_hidden: Default::default(), + docking_generation: 0, + docking_raise: None, + docking_opened: None, dock_expanded: true, units: Unit::Picas, workspace: "Essentials".into(), @@ -824,6 +849,9 @@ impl DesignApp { let t0 = now_ms(); let t = theme::Tokens::get(&ctx); let presenting = self.ui.screen_mode == ScreenMode::Presentation; + if presenting || self.ui.hidden_panels != 0 { + panel_docking::cancel_gesture(self, &ctx); + } let dbg = std::env::var_os("DESIGNCRAFT_DEBUG_LAYOUT").is_some(); if dbg { eprintln!("root start {:?}", ui.available_rect_before_wrap()); @@ -855,16 +883,29 @@ impl DesignApp { if !presenting { chrome::doc_tabs(self, ui); } - if self.split && !presenting { + let split_layout = (self.split && !presenting) + .then(|| { + craft_ui::layout::split_rect( + ui.available_rect_before_wrap(), + craft_ui::layout::SplitAxis::Horizontal, + craft_ui::layout::SplitSize::Ratio(0.5), + 2.0, + 0.0, + ) + }) + .flatten(); + if let Some(layout) = split_layout { // Two panes with a divider; each has its own view (zoom, scroll) and render cache. - let r = ui.available_rect_before_wrap(); - let mid = r.center().x; - let halves = [ - egui::Rect::from_min_max(r.min, egui::pos2(mid - 1.0, r.max.y)), - egui::Rect::from_min_max(egui::pos2(mid + 1.0, r.min.y), r.max), - ]; - ui.painter().rect_filled(egui::Rect::from_min_max(egui::pos2(mid - 1.0, r.min.y), egui::pos2(mid + 1.0, r.max.y)), 0.0, t.border); - for (k, half) in halves.into_iter().enumerate() { + ui.painter().rect_filled(layout.divider, 0.0, t.border); + // Resolve the clicked pane before either canvas handles this frame's keys. + if let Some(p) = ui.input(|i| i.pointer.any_pressed().then(|| i.pointer.interact_pos()).flatten()) { + if layout.first.contains(p) { + self.focus_pane = 0; + } else if layout.second.contains(p) { + self.focus_pane = 1; + } + } + for (k, half) in [layout.first, layout.second].into_iter().enumerate() { self.switch_pane(k as u8); // A new pane starts at the other pane's view. if k == 1 && self.view().is_none() { @@ -877,9 +918,6 @@ impl DesignApp { } } ui.scope_builder(egui::UiBuilder::new().max_rect(half), |ui| canvas::show(self, ui)); - if ui.input(|i| i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| half.contains(p))) { - self.focus_pane = k as u8; - } } self.switch_pane(self.focus_pane); } else { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 94e5c0971..d123ec4eb 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -130,6 +130,22 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ "{panel: properties|pages|layers|swatches|paragraphStyles|characterStyles|stroke|character|paragraph|textWrap|links|table}", ), ("window.floatPanel", "Float Panel", None, "{panel, x?, y?} — the panel in its own movable window"), + ( + "window.panel.layout", + "Change Panel Layout", + None, + "{action} — serialized docking action, including floating geometry, divider sizes and accordion state", + ), + ( + "window.panel.move", + "Move Panel", + None, + "{panel, anchor, zone?: tab|center|left|right|top|bottom, before?: panel} — group, split or reorder a panel", + ), + ("window.panel.float", "Float Panel", None, "{panel, x?, y?, width?, height?} — detach a panel inside the application window"), + ("window.panel.dock", "Dock Panel", None, "{panel, anchor?, zone?, before?} — return a floating panel to a dock group"), + ("window.panel.activate", "Activate Panel", None, "{panel} — show and select a panel in its current group"), + ("window.panel.close", "Close Panel", None, "{panel} — hide a panel"), ("window.dockPanel", "Dock Panel", None, "{panel} — back into the dock's icon column"), ("window.controlBar", "Control", Some("Cmd+Alt+6"), "{}"), ("window.split", "Split Window", None, "{on?: bool} — two views of the document side by side, each with its own zoom and scroll"), @@ -707,6 +723,9 @@ pub fn ui_label(id: &str) -> Option<(&'static str, Option<&'static str>)> { /// Run a UI command; `None` if `id` isn't one. pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value, String>> { + if let Some(result) = crate::panel_docking::command(app, id, p) { + return Some(result); + } let rect = app.canvas_rect; let flag = |b: &mut bool| { *b = !*b; @@ -1379,6 +1398,8 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value, dock_expanded: u.dock_expanded, open_panel: u.open_panel.clone(), floating: u.floating.clone(), + docking: u.docking.clone(), + docking_hidden: u.docking_hidden.clone(), }; app.ui.custom_workspaces.retain(|x| x.name != name); app.ui.custom_workspaces.push(w); @@ -1402,6 +1423,9 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value, return run_ui(app, "window.workspace", &json!({"name": name})); } "window.workspace" => { + app.ui.docking_generation = app.ui.docking_generation.wrapping_add(1); + app.ui.docking_raise = None; + app.ui.docking_opened = None; let name = p.get("name").and_then(Value::as_str).unwrap_or("Essentials"); if let Some(w) = app.ui.custom_workspaces.iter().find(|w| w.name == name).cloned() { app.ui.control_bar = w.control_bar; @@ -1412,10 +1436,17 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value, app.ui.dock_expanded = w.dock_expanded; app.ui.open_panel = w.open_panel; app.ui.floating = w.floating; + app.ui.docking = w.docking; + app.ui.docking_hidden = w.docking_hidden; app.ui.workspace = w.name; return Some(Ok(Value::Null)); } // Workspaces choose which bars and panels are visible. + if app.ui.docking.is_some() { + app.ui.floating.clear(); + } + app.ui.docking = None; + app.ui.docking_hidden.clear(); app.ui.control_bar = matches!(name, "Advanced" | "Typography" | "Printing and Proofing" | "Book"); let dock_tab = match name { "Typography" => Some("properties"), @@ -2440,6 +2471,170 @@ mod tests { frame(&mut app); } + #[test] + fn split_window_dispatches_text_once_to_the_shared_document() { + for focus_pane in [0, 1] { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let made = app.run("frame.create", json!({"rect": [36, 36, 300, 200], "content": "text"})).unwrap(); + let story = made["story"].as_u64().unwrap(); + app.session.set_tool("type"); + app.run("text.select", json!({"story": story, "anchor": 0, "focus": 0})).unwrap(); + assert!(app.session.wants_text()); + app.run("window.split", json!({"on": true})).unwrap(); + app.focus_pane = focus_pane; + + let ctx = egui::Context::default(); + let frame = |app: &mut crate::DesignApp, events| { + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }); + out.textures_delta.clear(); + }; + frame(&mut app, vec![]); + frame(&mut app, vec![]); + frame(&mut app, vec![]); + let undo_before = app.session.active().unwrap().history.undo.len(); + frame(&mut app, vec![egui::Event::Text("A".into())]); + let text = app.run("story.get", json!({"story": story})).unwrap(); + assert_eq!(text["text"], "A", "one key press with pane {focus_pane} focused"); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo_before + 1); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.run("story.get", json!({"story": story})).unwrap()["text"], ""); + + // A press and key arriving together must use the newly focused pane, whichever + // canvas is drawn first. Middle-click changes pane focus without moving the caret. + let other = app.other_pane.as_ref().and_then(|p| p.1).unwrap().center(); + frame(&mut app, vec![egui::Event::PointerMoved(other)]); + frame( + &mut app, + vec![ + egui::Event::PointerButton { pos: other, button: egui::PointerButton::Middle, pressed: true, modifiers: egui::Modifiers::NONE }, + egui::Event::Text("B".into()), + ], + ); + assert_eq!(app.focus_pane, 1 - focus_pane); + assert_eq!(app.run("story.get", json!({"story": story})).unwrap()["text"], "B", "a key in the pane-switch frame is dispatched once"); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo_before + 1); + } + } + + #[test] + fn split_window_click_focus_routes_menu_zoom_but_hover_scroll_stays_local() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("window.split", json!({"on": true})).unwrap(); + let ctx = egui::Context::default(); + let mut time = 0.0; + let mut frame = |app: &mut crate::DesignApp, events| { + time += 0.1; + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), + time: Some(time), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }); + out.textures_delta.clear(); + }; + for _ in 0..3 { + frame(&mut app, vec![]); + } + let left = app.canvas_rect.unwrap().center(); + let right = app.other_pane.as_ref().and_then(|p| p.1).unwrap().center(); + let cameras = |app: &mut crate::DesignApp| { + let current = app.pane; + app.switch_pane(0); + let left = *app.view().unwrap(); + app.switch_pane(1); + let right = *app.view().unwrap(); + app.switch_pane(current); + [left, right] + }; + let button = + |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + frame(&mut app, vec![egui::Event::PointerMoved(right)]); + frame(&mut app, vec![button(right, true)]); + frame(&mut app, vec![button(right, false)]); + assert_eq!((app.pane, app.focus_pane), (1, 1)); + let before = cameras(&mut app); + app.run("view.zoom", json!({"zoom": 2.0})).unwrap(); + let zoomed = cameras(&mut app); + assert_eq!(zoomed[0], before[0], "menu zoom leaves the other camera alone"); + assert!((zoomed[1].zoom - 2.0).abs() < 1e-9); + + frame(&mut app, vec![egui::Event::PointerMoved(left)]); + frame( + &mut app, + vec![egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -40.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }], + ); + let scrolled = cameras(&mut app); + assert_eq!(scrolled[1], zoomed[1], "hover scroll leaves the focused camera alone"); + assert_ne!(scrolled[0].origin, zoomed[0].origin); + assert_eq!((app.pane, app.focus_pane), (1, 1), "hovering does not move menu focus"); + + frame(&mut app, vec![button(left, true)]); + frame(&mut app, vec![button(left, false)]); + assert_eq!((app.pane, app.focus_pane), (0, 0)); + app.run("view.zoom", json!({"zoom": 1.0})).unwrap(); + let final_views = cameras(&mut app); + assert!((final_views[0].zoom - 1.0).abs() < 1e-9); + assert_eq!(final_views[1], scrolled[1]); + } + + #[test] + fn split_window_contextual_bars_have_independent_widget_ids() { + fn id_warnings(shape: &egui::Shape, found: &mut Vec<String>) { + match shape { + egui::Shape::Vec(shapes) => shapes.iter().for_each(|shape| id_warnings(shape, found)), + egui::Shape::Text(text) if text.galley.text().contains("use of widget ID") => found.push(text.galley.text().to_string()), + _ => {} + } + } + + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + for rect in [[36, 36, 280, 180], [300, 36, 550, 180]] { + app.run("frame.create", json!({"rect": rect, "content": "unassigned"})).unwrap(); + } + app.run("edit.selectAll", json!({})).unwrap(); + app.run("window.split", json!({"on": true})).unwrap(); + let ctx = egui::Context::default(); + ctx.options_mut(|options| options.warn_on_id_clash = true); + let mut warnings = Vec::new(); + for _ in 0..4 { + let input = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), ..Default::default() }; + let mut output = ctx.run_ui(input, |ui| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }); + warnings.clear(); + for shape in &output.shapes { + id_warnings(&shape.shape, &mut warnings); + } + output.textures_delta.clear(); + } + assert!(warnings.is_empty(), "both bars reused the same interactive widget IDs: {warnings:?}"); + let bars: Vec<_> = + (0_u8..2).map(|pane| ctx.memory(|memory| memory.area_rect(egui::Id::new(("task_bar", egui::ViewportId::ROOT, pane)))).unwrap()).collect(); + assert!(!bars[0].intersects(bars[1]), "each pane keeps its own bar: {bars:?}"); + } + #[test] fn interface_language_switches_and_validates() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs new file mode 100644 index 000000000..e53986c5c --- /dev/null +++ b/crates/ui-egui/src/panel_docking.rs @@ -0,0 +1,515 @@ +//! Application-owned panel IDs and commands around the shared docking layout. + +use craft_ui::docking::{Action, Floating, Layout, Node, Placement, Zone}; +use serde_json::{Value, json}; + +use crate::DesignApp; + +#[derive(Clone, Default)] +struct LegacyDrag { + layout: Option<Layout<String>>, + open_panel: Option<String>, + floating: Vec<(String, [f32; 2])>, + generation: u64, +} + +pub(crate) fn cancel_gesture(app: &mut DesignApp, ctx: &egui::Context) { + cancel_legacy_drag(app, ctx); + craft_ui::docking::cancel_drag::<String>(ctx, egui::Id::new("designcraft-panel-docking")); +} + +fn cancel_legacy_drag(app: &mut DesignApp, ctx: &egui::Context) -> bool { + let key = egui::Id::new("designcraft-panel-docking").with("legacy-origin"); + let previous = ctx.data_mut(|data| data.remove_temp::<LegacyDrag>(key)); + if let Some(previous) = previous { + craft_ui::docking::cancel_drag::<String>(ctx, egui::Id::new("designcraft-panel-docking")); + if previous.generation != app.ui.docking_generation { + return false; + } + app.ui.open_panel = previous.open_panel; + app.ui.floating = previous.floating; + app.ui.docking = previous.layout; + true + } else { + false + } +} + +fn normalize(raw: &str) -> Option<&'static str> { + crate::dock::DOCK_TABS + .iter() + .chain(crate::dock::ICON_PANELS) + .find(|(id, label, _)| id.eq_ignore_ascii_case(raw.trim()) || label.eq_ignore_ascii_case(raw.trim())) + .map(|(id, _, _)| *id) +} + +pub(crate) fn valid(layout: &Layout<String>) -> bool { + layout.validate().is_ok() && layout.panels().iter().all(|id| normalize(id) == Some(id.as_str())) +} + +fn legacy(app: &DesignApp) -> Layout<String> { + let panels: Vec<String> = crate::dock::DOCK_TABS.iter().map(|p| p.0.to_string()).collect(); + let active = panels.iter().position(|p| p == &app.ui.dock_tab).unwrap_or(0); + let mut layout = Layout { root: Some(Node::Tabs { panels, active }), floating: Vec::new() }; + for (id, at) in &app.ui.floating { + if normalize(id).is_some() && !layout.contains(id) && at.iter().all(|v| v.is_finite()) { + layout.floating.push(Floating { panels: vec![id.clone()], active: 0, rect: [at[0], at[1], 280.0, 360.0] }); + } + } + if let Some(id) = &app.ui.open_panel + && normalize(id).is_some() + && !layout.contains(id) + { + // Preserve the visible flyout when the first unrelated docking gesture succeeds. + if let Some(Node::Tabs { panels, active }) = &mut layout.root { + panels.push(id.clone()); + *active = panels.len().saturating_sub(1); + } + } + layout +} + +fn first_root(layout: &Layout<String>, except: &str) -> Option<String> { + let root = Layout { root: layout.root.clone(), floating: Vec::new() }; + root.panels().into_iter().find(|p| p.as_str() != except).cloned() +} + +fn ensure(layout: &mut Layout<String>, panel: &str) -> Result<(), String> { + let anchor = first_root(layout, panel); + layout.apply(Action::Open { panel: panel.into(), anchor }).map_err(|e| e.to_string()) +} + +fn number(params: &Value, key: &str, default: f32) -> Result<f32, String> { + match params.get(key) { + None => Ok(default), + Some(v) => v + .as_f64() + .filter(|v| v.is_finite() && v.abs() <= 1_000_000.0) + .map(|v| v as f32) + .ok_or_else(|| format!("{key} must be a finite coordinate")), + } +} + +fn placement(params: &Value) -> Result<Placement<String>, String> { + if let Some(before) = params.get("before") { + let id = before.as_str().and_then(normalize).ok_or("before must name a panel")?; + return Ok(Placement::Tab { before: Some(id.into()) }); + } + let zone = match params.get("zone") { + None => "tab", + Some(value) => value.as_str().ok_or("zone must be a string")?, + }; + Ok(match zone { + "tab" => Placement::Tab { before: None }, + "center" => Placement::Split(Zone::Center), + "left" => Placement::Split(Zone::Left), + "right" => Placement::Split(Zone::Right), + "top" => Placement::Split(Zone::Top), + "bottom" => Placement::Split(Zone::Bottom), + _ => return Err("zone must be tab, center, left, right, top or bottom".into()), + }) +} + +pub(crate) fn command(app: &mut DesignApp, id: &str, params: &Value) -> Option<Result<Value, String>> { + if id == "app.tablePanel" && app.ui.docking.is_some() { + let panel = "table".to_string(); + let visible = app.ui.docking.as_ref().is_some_and(|layout| layout.contains(&panel)); + return Some(change(app, if visible { "close" } else { "activate" }, &json!({"panel": panel}))); + } + if id == "window.panel.layout" { + return Some( + params + .get("action") + .ok_or_else(|| "action is required".to_string()) + .and_then(|value| serde_json::from_value::<Action<String>>(value.clone()).map_err(|error| error.to_string())) + .and_then(|action| apply_action(app, action)), + ); + } + let operation = match id { + "window.panel.move" => "move", + "window.panel.float" | "window.floatPanel" => "float", + "window.panel.dock" | "window.dockPanel" => "dock", + "window.panel.activate" => "activate", + "window.panel.close" => "close", + "window.panel" if app.ui.docking.is_some() => "activate", + _ => return None, + }; + Some(change(app, operation, params)) +} + +// UI output and automation share this atomic dispatcher. The application owns the registry +// and saved return locations; craft-ui owns tree validation and structural changes. +fn apply_action(app: &mut DesignApp, action: Action<String>) -> Result<Value, String> { + let registered = |id: &String| normalize(id) == Some(id.as_str()); + let known = match &action { + Action::Move { panel, anchor, placement } | Action::OpenAt { panel, anchor, placement } => { + registered(panel) + && registered(anchor) + && match placement { + Placement::Tab { before: Some(id) } => registered(id), + _ => true, + } + } + Action::Open { panel, anchor } => registered(panel) && anchor.as_ref().is_none_or(registered), + Action::Float { panel, .. } + | Action::Close { panel } + | Action::Activate { panel } + | Action::MoveFloating { panel, .. } + | Action::SetStackOpen { panel, .. } + | Action::ResizeStack { panel, .. } => registered(panel), + Action::ResizeSplit { path, .. } => path.len() <= craft_ui::docking::MAX_DEPTH, + }; + if !known { + return Err("docking action contains an unknown panel or an excessive split path".into()); + } + if app.ui.docking.as_ref().is_some_and(|layout| !valid(layout)) { + return Err("saved panel layout is invalid; reset the workspace first".into()); + } + let mut layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app)); + let returning = match &action { + Action::Close { panel } => layout.location(panel).ok().map(|location| (panel.clone(), location)), + Action::Float { panel, .. } => { + layout.location(panel).ok().filter(|location| location.floating.is_none()).map(|location| (panel.clone(), location)) + } + _ => None, + }; + let reveal = match &action { + Action::Activate { panel } | Action::Open { panel, .. } | Action::OpenAt { panel, .. } => Some(panel.clone()), + _ => None, + }; + layout.apply(action).map_err(|error| error.to_string())?; + if let Some((panel, location)) = returning { + app.ui.docking_hidden.insert(panel, location); + } + app.ui.docking = Some(layout); + sync_legacy(app); + if let Some(panel) = reveal { + app.ui.docking_opened = Some(panel.clone()); + app.ui.docking_raise = Some(panel); + app.ui.dock_expanded = true; + } else if app.ui.docking_opened.as_ref().is_some_and(|panel| !app.ui.docking.as_ref().is_some_and(|layout| layout.contains(panel))) { + app.ui.docking_opened = None; + } + app.ui.open_panel = None; + Ok(json!({"layout": app.ui.docking})) +} + +fn change(app: &mut DesignApp, operation: &str, params: &Value) -> Result<Value, String> { + let panel = params.get("panel").and_then(Value::as_str).and_then(normalize).ok_or("panel must name a registered panel")?; + if app.ui.docking.as_ref().is_some_and(|layout| !valid(layout)) { + return Err("saved panel layout is invalid; reset the workspace first".into()); + } + let mut layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app)); + let mut hidden: std::collections::BTreeMap<_, _> = + app.ui.docking_hidden.iter().filter(|(id, _)| normalize(id).is_some()).take(64).map(|(id, at)| (id.clone(), at.clone())).collect(); + let mut restored = false; + if operation == "dock" + && params.get("anchor").or_else(|| params.get("onto")).is_none() + && let Some(location) = hidden.get(panel).filter(|location| { + location.floating.is_none() + && location.anchor.as_ref().is_none_or(|anchor| Layout { root: layout.root.clone(), floating: Vec::new() }.contains(anchor)) + }) + { + let mut candidate = layout.clone(); + if candidate.contains(&panel.to_string()) { + candidate.apply(Action::Close { panel: panel.into() }).map_err(|e| e.to_string())?; + } + if candidate.restore(panel.into(), location).is_ok() { + layout = candidate; + restored = true; + hidden.remove(panel); + } + } + match operation { + "dock" if restored => {} + "float" => { + let rect = [number(params, "x", 400.0)?, number(params, "y", 140.0)?, number(params, "width", 300.0)?, number(params, "height", 400.0)?]; + ensure(&mut layout, panel)?; + if let Ok(location) = layout.location(&panel.to_string()) + && location.floating.is_none() + { + hidden.insert(panel.into(), location); + } + layout.apply(Action::Float { panel: panel.into(), rect }).map_err(|e| e.to_string())?; + } + "move" | "dock" => { + if operation == "move" && params.get("anchor").or_else(|| params.get("onto")).is_none() { + return Err("move requires a destination panel".into()); + } + let anchor = match params.get("anchor").or_else(|| params.get("onto")) { + Some(v) => Some(v.as_str().and_then(normalize).ok_or("anchor must name a registered panel")?.to_string()), + None => first_root(&layout, panel), + }; + let placement = placement(params)?; + if let Some(anchor) = anchor { + ensure(&mut layout, panel)?; + layout.apply(Action::Move { panel: panel.into(), anchor, placement }).map_err(|e| e.to_string())?; + } else if operation == "dock" { + if layout.contains(&panel.to_string()) { + layout.apply(Action::Close { panel: panel.into() }).map_err(|e| e.to_string())?; + } + layout.apply(Action::Open { panel: panel.into(), anchor: None }).map_err(|e| e.to_string())?; + } else { + return Err("move requires an existing destination panel".into()); + } + } + "close" => { + hidden.insert(panel.into(), layout.location(&panel.to_string()).map_err(|e| e.to_string())?); + layout.apply(Action::Close { panel: panel.into() }).map_err(|e| e.to_string())?; + } + _ => { + if !layout.contains(&panel.to_string()) + && let Some(location) = hidden.get(panel) + { + // A missing neighbor is allowed: the normal visible dock becomes the fallback. + let _ = layout.restore(panel.into(), location); + } + ensure(&mut layout, panel)?; + hidden.remove(panel); + } + } + app.ui.docking = Some(layout); + app.ui.docking_hidden = hidden; + sync_legacy(app); + app.ui.open_panel = None; + app.ui.docking_opened = if operation == "close" { None } else { Some(panel.to_owned()) }; + app.ui.docking_raise = if operation == "close" { None } else { Some(panel.to_owned()) }; + if operation != "close" { + app.ui.dock_expanded = true; + } + Ok(json!({"panel": panel, "floating": app.ui.floating.iter().map(|(id, _)| id).collect::<Vec<_>>(), "layout": app.ui.docking})) +} + +pub(crate) fn show(app: &mut DesignApp, ui: &mut egui::Ui) -> bool { + let area = egui::Id::new("designcraft-panel-docking"); + let changed_workspace = ui.ctx().data_mut(|data| { + let previous = data.get_temp::<u64>(area.with("generation")); + data.insert_temp(area.with("generation"), app.ui.docking_generation); + previous.is_some_and(|generation| generation != app.ui.docking_generation) + }); + if changed_workspace { + craft_ui::docking::cancel_drag::<String>(ui.ctx(), area); + ui.ctx().data_mut(|data| data.remove::<LegacyDrag>(area.with("legacy-origin"))); + } + + let cancelled = + ui.input(|input| !input.focused || input.key_pressed(egui::Key::Escape) || (!input.pointer.primary_down() && !input.pointer.any_released())); + if cancelled && cancel_legacy_drag(app, ui.ctx()) && app.ui.docking.is_none() { + return false; + } + let Some(layout) = app.ui.docking.as_ref() else { return false }; + if !valid(layout) { + app.ui.docking = None; + app.status("Invalid panel layout; restored the default dock"); + return false; + } + let mut layout = layout.clone(); + if !app.ui.dock_expanded { + layout.root = None; + } + let raise = app.ui.docking_raise.take(); + let mut blocked = Vec::new(); + if app.ui.dialog.is_some() || app.ui.about || app.ui.palette.is_some() || egui::Popup::is_any_open(ui.ctx()) { + blocked.push(ui.ctx().content_rect()); + } else if app.story_editor.is_some() { + // The editor is rendered after the dock; block its unmeasured first frame conservatively. + let rect = ui.ctx().memory(|m| m.area_rect(egui::Id::new("story_editor"))).unwrap_or_else(|| ui.ctx().content_rect()); + blocked.push(rect); + } + let t = crate::theme::Tokens::get(ui.ctx()); + let language = app.ui.language.clone(); + let mut draw = |ui: &mut egui::Ui| { + let mut style = craft_ui::docking::DockStyle::from_ui(ui); + style.tab_height = 27.0; + style.background = t.panel; + style.tab_background = t.tab_strip; + style.active_background = t.panel; + style.text = t.text_strong; + style.inactive_text = t.text_dim; + style.border = egui::Stroke::new(1.0, t.border); + style.accent = t.accent; + style.float_label = crate::i18n::tr(&language, "Float Panel").to_owned(); + style.close_label = crate::i18n::tr(&language, "Close Panel").to_owned(); + style.move_label = crate::i18n::tr(&language, "Group with").to_owned(); + style.panels_label = crate::i18n::tr(&language, "Panels").to_owned(); + style.resize_label = crate::i18n::tr(&language, "Resize panels").to_owned(); + style.resize_window_label = crate::i18n::tr(&language, "Resize panel window").to_owned(); + let mut area = craft_ui::docking::DockArea::new(egui::Id::new("designcraft-panel-docking")).blocked_rects(&blocked); + if let Some(panel) = &raise { + area = area.raise_panel(panel.clone()); + } + area.show_customized( + ui, + &layout, + &style, + |id| { + crate::i18n::tr( + &language, + crate::dock::DOCK_TABS.iter().chain(crate::dock::ICON_PANELS).find(|p| p.0 == id).map(|p| p.1).unwrap_or(id), + ) + .to_owned() + }, + |_| craft_ui::docking::Permissions::default(), + |_| craft_ui::docking::PanelLimits { min: egui::vec2(220.0, 80.0), ..Default::default() }, + &mut PanelContent(app), + ) + }; + let output = if layout.root.is_some() { + egui::Panel::right("shared-panel-dock") + .default_size(310.0) + .size_range(220.0..=720.0) + .resizable(true) + .frame(egui::Frame::NONE.fill(t.panel)) + .show(ui, &mut draw) + .inner + } else { + draw(ui) + }; + if let Some(panel) = output.focused { + app.ui.docking_opened = Some(panel); + } + if let Some(error) = output.error { + app.ui.status = error.to_string(); + } + if ui.input(|input| input.pointer.any_released()) { + if !output.actions.iter().any(|action| matches!(action, Action::Move { .. } | Action::Float { .. })) && cancel_legacy_drag(app, ui.ctx()) { + return app.ui.docking.is_some(); + } + ui.ctx().data_mut(|data| data.remove::<LegacyDrag>(egui::Id::new("designcraft-panel-docking").with("legacy-origin"))); + } + for action in output.actions { + if let Err(error) = app.run("window.panel.layout", json!({"action": action})) { + app.ui.status = error; + } + } + icon_rail(app, ui); + true +} + +fn icon_rail(app: &mut DesignApp, ui: &mut egui::Ui) { + let t = crate::theme::Tokens::get(ui.ctx()); + let column = egui::Panel::right("shared-panel-icons") + .exact_size(38.0) + .resizable(false) + .frame(egui::Frame::NONE.fill(t.panel)) + .show(ui, |ui| { + egui::ScrollArea::vertical().id_salt("shared-panel-icons-scroll").show(ui, |ui| { + ui.spacing_mut().item_spacing.y = 2.0; + for &(id, label, icon) in crate::dock::DOCK_TABS.iter().chain(crate::dock::ICON_PANELS) { + if app.ui.docking.as_ref().is_some_and(|layout| { + layout.contains(&id.to_string()) && (app.ui.dock_expanded || layout.floating.iter().any(|g| g.panels.iter().any(|p| p == id))) + }) { + continue; + } + + let response = + crate::icons::button(ui, icon, 28.0, false, crate::i18n::tr(&app.ui.language, label)).interact(egui::Sense::click_and_drag()); + if response.clicked() { + let _ = app.run("window.panel.activate", json!({"panel":id})); + } + legacy_tab(app, ui, id, &response); + } + }); + }) + .response + .rect; + let _ = column; +} + +pub(crate) fn legacy_tab(app: &mut DesignApp, ui: &mut egui::Ui, panel: &str, response: &egui::Response) { + let Some(panel) = normalize(panel) else { return }; + let language = app.ui.language.clone(); + response.context_menu(|ui| { + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&language, "Float Panel"))).clicked() { + if let Err(error) = app.run("window.panel.float", json!({"panel": panel})) { + app.status(error); + } + ui.close(); + } + ui.menu_button(crate::rtl::widget(ui, crate::i18n::tr(&language, "Group with")), |ui| { + egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { + for (anchor, label, _) in crate::dock::DOCK_TABS.iter().chain(crate::dock::ICON_PANELS) { + if *anchor != panel && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&language, label))).clicked() { + // A closed icon panel is made visible before becoming the destination. + let result = app + .run("window.panel.activate", json!({"panel": anchor})) + .and_then(|_| app.run("window.panel.move", json!({"panel":panel,"anchor":anchor}))); + if let Err(error) = result { + app.status(error); + } + ui.close(); + } + } + }); + }); + }); + if response.drag_started() + && let Some(id) = normalize(panel) + { + let mut layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app)); + let bounds = ui.max_rect(); + let source = egui::Rect::from_min_size( + egui::pos2(bounds.left(), response.rect.top()), + egui::vec2(bounds.width().clamp(240.0, 600.0), bounds.height().clamp(200.0, 600.0)), + ); + if ensure(&mut layout, id).is_ok() + && craft_ui::docking::begin_drag(ui.ctx(), egui::Id::new("designcraft-panel-docking"), id.to_string(), source) + { + ui.ctx().data_mut(|data| { + data.insert_temp( + egui::Id::new("designcraft-panel-docking").with("legacy-origin"), + LegacyDrag { + layout: app.ui.docking.clone(), + open_panel: app.ui.open_panel.clone(), + floating: app.ui.floating.clone(), + generation: app.ui.docking_generation, + }, + ) + }); + app.ui.docking = Some(layout); + app.ui.open_panel = None; + } + } +} + +struct PanelContent<'a>(&'a mut DesignApp); +impl craft_ui::docking::DockContent<String> for PanelContent<'_> { + fn body(&mut self, ui: &mut egui::Ui, id: &String) { + egui::Frame::NONE.inner_margin(egui::Margin::same(8)).show(ui, |ui| { + egui::ScrollArea::vertical() + .id_salt(("shared-panel-body", id)) + .auto_shrink([false, false]) + .show(ui, |ui| crate::dock::panel_body(self.0, ui, id)); + }); + } + fn label(&mut self, ui: &egui::Ui, _: &String, logical: &str, font: &egui::FontId, color: egui::Color32) -> egui::WidgetText { + egui::WidgetText::Galley(crate::rtl::plain(ui.ctx(), logical, font.clone(), color)) + } + fn action_label(&mut self, ui: &egui::Ui, logical: &str, font: &egui::FontId, color: egui::Color32) -> egui::WidgetText { + egui::WidgetText::Galley(crate::rtl::plain(ui.ctx(), logical, font.clone(), color)) + } +} + +/// Close the last explicitly opened panel through the same validated layout transaction. +pub(crate) fn close_visible(app: &mut DesignApp) -> Result<(), String> { + if app.ui.docking.is_some() { + if let Some(panel) = app.ui.docking_opened.clone() { + change(app, "close", &json!({"panel": panel}))?; + } + } else { + app.ui.open_panel = None; + } + Ok(()) +} + +// Legacy inspection fields are derived from the shared layout after customization. +fn sync_legacy(app: &mut DesignApp) { + if let Some(layout) = &app.ui.docking { + app.ui.floating = + layout.floating.iter().flat_map(|group| group.panels.iter().map(|id| (id.clone(), [group.rect[0], group.rect[1]]))).collect(); + } +} + +#[cfg(test)] +#[path = "panel_docking_tests.rs"] +mod tests; diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs new file mode 100644 index 000000000..210ae0ce0 --- /dev/null +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -0,0 +1,599 @@ +use super::*; +use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; +use egui_kittest::{Harness, kittest::Queryable}; + +fn app() -> DesignApp { + DesignApp::new(designcraft_engine::Session::new(), Default::default()) +} + +#[test] +fn panel_commands_group_split_reorder_float_and_restore_without_duplicate_ownership() { + let mut app = app(); + app.run("window.panel.move", json!({"panel":"swatches", "anchor":"layers", "zone":"top"})).unwrap(); + app.run("window.panel.move", json!({"panel":"properties", "anchor":"swatches", "before":"swatches"})).unwrap(); + let before_float = app.ui.docking.clone().unwrap(); + app.run("window.panel.float", json!({"panel":"properties", "x":50,"y":70,"width":330,"height":410})).unwrap(); + assert!(app.ui.docking.as_ref().unwrap().floating.iter().any(|g| g.panels == ["properties"])); + app.run("window.panel.dock", json!({"panel":"properties"})).unwrap(); + assert_eq!(app.ui.docking.as_ref().unwrap(), &before_float); + app.run("window.panel.close", json!({"panel":"properties"})).unwrap(); + assert!(!app.ui.docking.as_ref().unwrap().contains(&"properties".into())); + app.run("window.panel.activate", json!({"panel":"properties"})).unwrap(); + assert_eq!(app.ui.docking.as_ref().unwrap(), &before_float); + let layout = app.ui.docking.as_ref().unwrap(); + assert!(valid(layout)); + let unique: std::collections::HashSet<_> = layout.panels().into_iter().collect(); + assert_eq!(unique.len(), layout.panels().len()); + let saved = serde_json::to_value(&app.ui).unwrap(); + let restored: crate::UiState = serde_json::from_value(saved).unwrap(); + assert_eq!(restored.docking, app.ui.docking); + assert_eq!(restored.docking_hidden, app.ui.docking_hidden); +} + +#[test] +fn invalid_panel_requests_are_atomic_and_do_not_enter_a_custom_workspace() { + let mut app = app(); + for (id, params) in [ + ("window.panel.move", json!({"panel":"layers","anchor":"unknown"})), + ("window.panel.move", json!({"panel":"layers","anchor":"properties","before":"unknown"})), + ("window.panel.move", json!({"panel":"layers","anchor":"properties","zone":42})), + ("window.panel.float", json!({"panel":"layers","width":-10})), + ("window.panel.float", json!({"panel":"unknown"})), + ("window.panel.float", json!({"panel":"layers","x":"invalid"})), + ] { + let old = app.ui.docking.clone(); + let hidden = app.ui.docking_hidden.clone(); + assert!(app.run(id, params).is_err(), "{id}"); + assert_eq!(app.ui.docking, old); + assert_eq!(app.ui.docking_hidden, hidden); + } +} + +fn harness(app: DesignApp, size: egui::Vec2) -> Harness<'static, DesignApp> { + let mut ready = false; + let mut harness = Harness::builder().with_size(size).with_step_dt(1.0 / 60.0).build_ui_state( + move |ui, app: &mut DesignApp| { + if !ready { + crate::theme::install_fonts(ui.ctx(), &app.ui.language); + crate::theme::apply(ui.ctx(), &crate::theme::Tokens::for_brightness(crate::theme::Brightness::MediumDark)); + ready = true; + return; + } + crate::dock::show(app, ui); + egui::CentralPanel::default().show(ui, |_| {}); + }, + app, + ); + harness.run_steps(4); + harness +} + +fn drag(h: &mut Harness<'static, DesignApp>, from: Pos2, to: Pos2) { + h.event(Event::PointerMoved(from)); + h.run_steps(1); + h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + for step in 1..=8 { + h.event(Event::PointerMoved(from + (to - from) * (step as f32 / 8.0))); + h.run_steps(1); + } + h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +#[test] +fn ordinary_panel_tab_drag_enters_shared_docking_and_can_redock_into_another_tab_group() { + let mut h = harness(app(), vec2(1280.0, 800.0)); + let source = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center(); + drag(&mut h, source, egui::pos2(160.0, 140.0)); + let layout = h.state().ui.docking.as_ref().expect("ordinary tab drag starts shared docking"); + assert!(layout.floating.iter().any(|g| g.panels.contains(&"layers".into())), "{layout:?}"); + let source = h.ctx.read_response(egui::Id::new("designcraft-panel-docking").with(("tab", &"layers".to_string()))).unwrap().rect.center(); + let destination = h.ctx.read_response(egui::Id::new("designcraft-panel-docking").with(("tab", &"properties".to_string()))).unwrap().rect.center(); + drag(&mut h, source, destination); + let layout = h.state().ui.docking.as_ref().unwrap(); + assert!(!layout.floating.iter().any(|g| g.panels.contains(&"layers".into()))); + assert!(valid(layout)); +} + +#[test] +fn saved_workspace_restores_the_custom_tree_and_hidden_panel_placement() { + let mut app = app(); + + app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap(); + app.run("window.panel.float", json!({"panel":"properties","x":50,"y":80})).unwrap(); + app.run("window.panel.close", json!({"panel":"swatches"})).unwrap(); + let layout = app.ui.docking.clone(); + let hidden = app.ui.docking_hidden.clone(); + app.run("window.newWorkspace", json!({"name":"Custom docking"})).unwrap(); + app.run("window.workspace", json!({"name":"Essentials"})).unwrap(); + assert!(app.ui.docking.is_none()); + app.run("window.workspace", json!({"name":"Custom docking"})).unwrap(); + assert_eq!(app.ui.docking, layout); + assert_eq!(app.ui.docking_hidden, hidden); +} + +/// Explicit offscreen evidence; this creates no native windows. +#[test] +fn capture_custom_panel_docking_visual_fixtures() { + let Some(directory) = std::env::var_os("CRAFT_UI_DOCKING_FIXTURES").map(std::path::PathBuf::from) else { return }; + std::fs::create_dir_all(&directory).unwrap(); + for theme in crate::theme::Brightness::ALL { + for width in [800.0, 1280.0] { + for scale in [1.0, 1.5, 2.0] { + let mut app = app(); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect":[40,40,280,180],"content":"text","text":"Synthetic layout — مرحبا"})).unwrap(); + let language = if width == 800.0 && scale == 1.0 { "ar" } else { "en" }; + app.run("app.language", json!({"lang":if language == "en" { "" } else { language }})).unwrap(); + app.run("window.panel.move", json!({"panel":"swatches", "anchor":"layers", "zone":"top"})).unwrap(); + app.run("window.panel.float", json!({"panel":"properties","x":30,"y":90,"width":300,"height":440})).unwrap(); + let mut ready = false; + let mut harness = Harness::builder().with_size(vec2(width, 800.0)).with_pixels_per_point(scale).wgpu().build_ui_state( + move |ui, app: &mut DesignApp| { + if !ready { + crate::theme::install_fonts(ui.ctx(), &app.ui.language); + crate::theme::apply(ui.ctx(), &crate::theme::Tokens::for_brightness(theme)); + ready = true; + return; + } + crate::dock::show(app, ui); + egui::CentralPanel::default().show(ui, |ui| { + ui.label("Custom panel workspace"); + if app.ui.language == "ar" { + ui.label("Shaped action references:"); + for action in ["Float Panel", "Group with", "Close Panel"] { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, action)); + } + } + }); + }, + app, + ); + harness.input_mut().max_texture_side = Some(8192); + harness.run_steps(5); + assert!(valid(harness.state().ui.docking.as_ref().unwrap())); + harness + .render() + .unwrap() + .save(directory.join(format!("designcraft-shared-docking-{}-{language}-{width}-{scale}x.png", theme.id()))) + .unwrap(); + if language == "ar" { + let tab = egui::Id::new("designcraft-panel-docking").with(("tab", &"swatches".to_string())); + let at = harness.ctx.read_response(tab).unwrap().rect.center(); + harness.input_mut().events.extend([ + Event::PointerMoved(at), + Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE }, + ]); + harness.run_steps(1); + harness.input_mut().events.push(Event::PointerButton { + pos: at, + button: PointerButton::Secondary, + pressed: false, + modifiers: Modifiers::NONE, + }); + harness.run_steps(2); + assert!(egui::Popup::is_any_open(&harness.ctx), "actual tab context menu must be open"); + for action in ["Float Panel", "Group with", "Close Panel"] { + assert!( + harness.query_all_by_label(crate::i18n::tr("ar", action)).count() >= 2, + "both logical reference and actual menu label must exist" + ); + } + harness.render().unwrap().save(directory.join(format!("designcraft-dock-context-{}-ar.png", theme.id()))).unwrap(); + } + } + } + } +} + +#[test] +fn escape_cancels_the_first_drag_without_replacing_the_legacy_workspace() { + let mut h = harness(app(), vec2(1280.0, 800.0)); + let source = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center(); + h.event(Event::PointerMoved(source)); + h.run_steps(1); + h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.event(Event::PointerMoved(source - vec2(90.0, 0.0))); + h.run_steps(2); + assert!(h.state().ui.docking.is_some()); + h.event(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(1); + assert!(h.state().ui.docking.is_none()); + h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert!(h.state().ui.docking.is_none()); +} + +#[test] +fn serialized_layout_actions_cover_geometry_reorder_accordion_and_atomic_rejection() { + let mut app = app(); + app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap(); + app.run("window.panel.layout", json!({"action":{"ResizeSplit":{"path":[],"size":{"Ratio":0.35}}}})).unwrap(); + assert!( + matches!(app.ui.docking.as_ref().unwrap().root, Some(Node::Split { size: craft_ui::layout::SplitSize::Ratio(value), .. }) if (value - 0.35).abs() < 0.001) + ); + app.run("window.panel.float", json!({"panel":"properties"})).unwrap(); + app.run("window.panel.layout", json!({"action":{"MoveFloating":{"panel":"properties","rect":[44,66,360,450]}}})).unwrap(); + assert_eq!( + app.ui.docking.as_ref().unwrap().floating.iter().find(|group| group.panels.iter().any(|p| p == "properties")).unwrap().rect, + [44.0, 66.0, 360.0, 450.0] + ); + app.run("window.panel.layout", json!({"action":{"Move":{"panel":"properties","anchor":"swatches","placement":{"Tab":{"before":"swatches"}}}}})) + .unwrap(); + assert_eq!(group_members_for_test(app.ui.docking.as_ref().unwrap(), "swatches"), ["properties", "swatches"]); + let before = app.ui.docking.clone(); + let hidden = app.ui.docking_hidden.clone(); + for action in [ + json!({"MoveFloating":{"panel":"properties","rect":[0,0,-5,30]}}), + json!({"ResizeSplit":{"path":[true,true,true],"size":{"Ratio":0.2}}}), + json!({"Open":{"panel":"unknown","anchor":null}}), + json!({"Move":{"panel":"properties","anchor":"swatches","placement":{"Tab":{"before":"unknown"}}}}), + ] { + assert!(app.run("window.panel.layout", json!({"action":action})).is_err()); + assert_eq!(app.ui.docking, before); + assert_eq!(app.ui.docking_hidden, hidden); + } + app.ui.docking = Some(Layout { + root: Some(Node::Stack { entries: vec![craft_ui::docking::StackEntry { panel: "layers".into(), open: true, height: Some(100.0) }] }), + floating: Vec::new(), + }); + app.run("window.panel.layout", json!({"action":{"SetStackOpen":{"panel":"layers","open":false}}})).unwrap(); + app.run("window.panel.layout", json!({"action":{"ResizeStack":{"panel":"layers","height":150}}})).unwrap(); + let Some(Node::Stack { entries }) = &app.ui.docking.as_ref().unwrap().root else { panic!("expected stack") }; + assert!(!entries[0].open); + assert_eq!(entries[0].height, Some(150.0)); +} + +fn group_members_for_test(layout: &Layout<String>, panel: &str) -> Vec<String> { + let mut pending: Vec<_> = layout.root.iter().collect(); + while let Some(node) = pending.pop() { + match node { + Node::Tabs { panels, .. } if panels.iter().any(|p| p == panel) => return panels.clone(), + Node::Split { first, second, .. } => pending.extend([first.as_ref(), second.as_ref()]), + _ => {} + } + } + Vec::new() +} + +#[test] +fn populated_canvas_keeps_a_real_stroke_edit_after_floating_and_redocking() { + let mut app = app(); + app.run("file.new", json!({"width":480,"height":360})).unwrap(); + let id = app.run("frame.create", json!({"rect":[80,70,330,230],"content":"none"})).unwrap()["id"].as_u64().unwrap(); + app.run("object.fill", json!({"swatch":"C=100 M=0 Y=0 K=0"})).unwrap(); + app.run("object.stroke", json!({"weight":8})).unwrap(); + app.run("window.panel.float", json!({"panel":"stroke","x":40,"y":140,"width":310,"height":490})).unwrap(); + let directory = std::env::var_os("CRAFT_UI_DOCKING_FIXTURES").map(std::path::PathBuf::from); + let builder = Harness::builder().with_size(vec2(1280.0, 800.0)); + let builder = if directory.is_some() { builder.wgpu() } else { builder }; + let mut h = builder.build_ui_state( + |ui, app: &mut DesignApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.input_mut().max_texture_side = Some(8192); + h.run_steps(5); + let at = h.get_by_label("Bevel").rect().center(); + h.hover_at(at); + h.run_steps(1); + for pressed in [true, false] { + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(1); + } + h.run_steps(2); + assert_eq!(h.state().session.active().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().stroke.join, designcraft_doc::Join::Bevel); + if let Some(directory) = &directory { + std::fs::create_dir_all(directory).unwrap(); + h.render().unwrap().save(directory.join("designcraft-populated-stroke-floating.png")).unwrap(); + } + let from = h.ctx.read_response(egui::Id::new("designcraft-panel-docking").with(("tab", &"stroke".to_string()))).unwrap().rect.center(); + let to = h.ctx.read_response(egui::Id::new("designcraft-panel-docking").with(("tab", &"properties".to_string()))).unwrap().rect.center(); + drag(&mut h, from, to); + assert!(h.state().ui.docking.as_ref().unwrap().floating.is_empty()); + assert_eq!(h.state().session.active().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().stroke.join, designcraft_doc::Join::Bevel); + if let Some(directory) = &directory { + h.render().unwrap().save(directory.join("designcraft-populated-stroke-redocked.png")).unwrap(); + } +} + +#[test] +fn changing_workspace_during_a_drag_cancels_the_stale_gesture() { + let mut app = app(); + app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap(); + let destination = app.ui.docking.clone(); + app.run("window.newWorkspace", json!({"name":"Destination"})).unwrap(); + app.run("window.workspace", json!({"name":"Essentials"})).unwrap(); + let mut h = harness(app, vec2(1280.0, 800.0)); + let source = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center(); + h.event(Event::PointerMoved(source)); + h.run_steps(1); + h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.event(Event::PointerMoved(source - vec2(90.0, 0.0))); + h.run_steps(2); + h.state_mut().run("window.workspace", json!({"name":"Destination"})).unwrap(); + h.event(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(1); + assert_eq!(h.state().ui.docking, destination); + h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert_eq!(h.state().ui.docking, destination); +} + +#[test] +fn first_customization_preserves_the_visible_legacy_flyout() { + let mut app = app(); + app.ui.open_panel = Some("paragraphStyles".into()); + app.ui.dock_expanded = false; + app.run("window.panel.float", json!({"panel":"layers"})).unwrap(); + let layout = app.ui.docking.as_ref().unwrap(); + assert!(layout.contains(&"paragraphStyles".into())); + assert!(valid(layout)); + assert_eq!(layout.panels().iter().filter(|p| p.as_str() == "paragraphStyles").count(), 1); +} + +#[test] +fn hiding_before_first_destination_frame_does_not_restore_an_old_drag_snapshot() { + let mut app = app(); + app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap(); + app.run("window.newWorkspace", json!({"name":"Destination"})).unwrap(); + app.run("window.workspace", json!({"name":"Essentials"})).unwrap(); + let mut h = harness(app, vec2(1280.0, 800.0)); + let source = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center(); + h.event(Event::PointerMoved(source)); + h.run_steps(1); + h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.event(Event::PointerMoved(source - vec2(90.0, 0.0))); + h.run_steps(2); + h.state_mut().run("window.workspace", json!({"name":"Destination"})).unwrap(); + let destination = h.state().ui.docking.clone(); + let open_panel = h.state().ui.open_panel.clone(); + let floating = h.state().ui.floating.clone(); + // The app calls this before dock rendering when panels are hidden or Presentation starts. + let ctx = h.ctx.clone(); + cancel_gesture(h.state_mut(), &ctx); + assert_eq!(h.state().ui.docking, destination); + assert_eq!(h.state().ui.open_panel, open_panel); + assert_eq!(h.state().ui.floating, floating); + h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert_eq!(h.state().ui.docking, destination); +} + +#[test] +fn legacy_table_and_control_visibility_work_after_customization_without_document_edits() { + let mut app = app(); + app.run("file.new", json!({})).unwrap(); + let doc = serde_json::to_value(&app.session.active().unwrap().doc).unwrap(); + let undo = app.session.active().unwrap().history.undo.len(); + app.run("window.panel.float", json!({"panel":"layers"})).unwrap(); + app.run("app.tablePanel", json!({})).unwrap(); + assert!(app.ui.docking.as_ref().unwrap().contains(&"table".into())); + app.run("app.tablePanel", json!({})).unwrap(); + assert!(!app.ui.docking.as_ref().unwrap().contains(&"table".into())); + app.run("app.tablePanel", json!({})).unwrap(); + let ctx = egui::Context::default(); + let (req, _) = crate::control::ControlRequest::new("ui.set", json!({"closePanel":true,"dockExpanded":false})); + let crate::control::Outcome::Done(result) = crate::control::handle(&mut app, &ctx, &req) else { panic!("expected synchronous control result") }; + assert_eq!(result["ok"], true); + assert!(!app.ui.dock_expanded); + assert!(!app.ui.docking.as_ref().unwrap().contains(&"table".into())); + app.run("window.panel", json!({"panel":"properties"})).unwrap(); + assert!(app.ui.dock_expanded); + assert_eq!(app.ui.docking_raise.as_deref(), Some("properties")); + assert_eq!(serde_json::to_value(&app.session.active().unwrap().doc).unwrap(), doc); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo); +} + +#[test] +fn floating_activation_queues_one_transient_raise_and_workspace_switch_clears_it() { + let mut app = app(); + app.run("window.panel.float", json!({"panel":"layers"})).unwrap(); + app.run("window.panel.activate", json!({"panel":"layers"})).unwrap(); + assert_eq!(app.ui.docking_raise.as_deref(), Some("layers")); + assert!(serde_json::to_value(&app.ui).unwrap().get("docking_raise").is_none()); + app.run("window.workspace", json!({"name":"Typography"})).unwrap(); + assert!(app.ui.docking_raise.is_none()); +} + +#[test] +fn shaped_panel_label_keeps_the_logical_accessibility_text() { + let ctx = egui::Context::default(); + let logical = "خصائص Pages"; + crate::theme::install_fonts(&ctx, "ar"); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + let galley = crate::rtl::plain(ui.ctx(), logical, egui::FontId::proportional(12.0), egui::Color32::WHITE); + assert_eq!(galley.job.text, logical); + }); + output.textures_delta.clear(); +} + +#[test] +fn modal_overlay_blocks_a_real_tab_drag_release() { + let mut app = app(); + app.run("window.panel.float", json!({"panel":"layers"})).unwrap(); + let mut h = harness(app, vec2(1280.0, 800.0)); + let area = egui::Id::new("designcraft-panel-docking"); + let source = h.ctx.read_response(area.with(("tab", &"properties".to_string()))).unwrap().rect.center(); + h.event(Event::PointerMoved(source)); + h.run_steps(1); + h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.event(Event::PointerMoved(source - vec2(90.0, 0.0))); + h.run_steps(2); + assert_eq!(h.ctx.dragged_id(), Some(area.with(("tab", &"properties".to_string()))), "test must start the owned tab gesture"); + let before = h.state().ui.docking.clone(); + h.state_mut().ui.dialog = Some(crate::dialogs::Dialog::new("newDocument", json!({}))); + let destination = egui::pos2(150.0, 150.0); + h.event(Event::PointerMoved(destination)); + h.run_steps(1); + h.event(Event::PointerButton { pos: destination, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert_eq!(h.state().ui.docking, before, "release under a modal must not float or dock the panel"); + h.state_mut().ui.dialog = None; + h.run_steps(2); + assert_eq!(h.state().ui.docking, before, "closing the overlay must not revive its blocked drag"); +} + +#[test] +fn shift_f9_toggles_table_after_customization_through_the_real_shortcut_path() { + let mut app = app(); + app.run("file.new", json!({})).unwrap(); + app.run("window.panel.float", json!({"panel":"layers"})).unwrap(); + let before = serde_json::to_value(&app.session.active().unwrap().doc).unwrap(); + let mut h = crate::test_window::open(app, vec2(1440.0, 900.0)); + for visible in [true, false] { + let modifiers = Modifiers { shift: true, ..Modifiers::NONE }; + h.event(Event::Key { key: egui::Key::F9, physical_key: None, pressed: true, repeat: false, modifiers }); + h.run_steps(1); + h.event(Event::Key { key: egui::Key::F9, physical_key: None, pressed: false, repeat: false, modifiers }); + h.run_steps(2); + assert_eq!(h.state().app.ui.docking.as_ref().unwrap().contains(&"table".into()), visible); + } + assert_eq!(serde_json::to_value(&h.state().app.session.active().unwrap().doc).unwrap(), before); +} + +#[test] +fn first_story_editor_frame_conservatively_blocks_owned_tab_release() { + let mut app = app(); + app.run("file.new", json!({})).unwrap(); + let story = app.run("frame.create", json!({"rect":[72,72,300,200],"content":"text","text":"Story editor overlay fixture"})).unwrap()["story"] + .as_u64() + .unwrap(); + app.run("window.panel.float", json!({"panel":"layers"})).unwrap(); + let doc = serde_json::to_value(&app.session.active().unwrap().doc).unwrap(); + let mut h = crate::test_window::open(app, vec2(1440.0, 900.0)); + let tab = egui::Id::new("designcraft-panel-docking").with(("tab", &"properties".to_string())); + let from = h.ctx.read_response(tab).unwrap().rect.center(); + h.event(Event::PointerMoved(from)); + h.run_steps(1); + h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.event(Event::PointerMoved(from - vec2(90.0, 0.0))); + h.run_steps(2); + assert_eq!(h.ctx.dragged_id(), Some(tab), "must own a real tab drag before opening editor"); + assert!(h.ctx.memory(|m| m.area_rect(egui::Id::new("story_editor"))).is_none()); + let layout = h.state().app.ui.docking.clone(); + let frame_before = h.ctx.cumulative_frame_nr(); + h.state_mut().app.run("app.storyEditor", json!({"story":story})).unwrap(); + // The unmeasured first frame blocks the full content, even outside the eventual editor. + let release = egui::pos2(150.0, 150.0); + h.input_mut().events.extend([ + Event::PointerMoved(release), + Event::PointerButton { pos: release, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }, + ]); + h.run_steps(1); + assert_eq!(h.ctx.cumulative_frame_nr() - frame_before, 1, "release must occur in the first editor frame"); + let measured = h.ctx.memory(|m| m.area_rect(egui::Id::new("story_editor"))).unwrap(); + assert!(!measured.contains(release), "fixture exercises the conservative first-frame guard outside eventual editor bounds"); + assert_eq!(h.state().app.ui.docking, layout, "first editor frame must block release before it has a measured rectangle"); + assert_eq!(serde_json::to_value(&h.state().app.session.active().unwrap().doc).unwrap(), doc); + h.run_steps(3); + let editor = h.ctx.memory(|m| m.area_rect(egui::Id::new("story_editor"))).unwrap(); + let screen = h.ctx.content_rect(); + let outside = [screen.left_bottom() + vec2(80.0, -80.0), screen.right_bottom() + vec2(-450.0, -80.0)] + .into_iter() + .find(|p| !editor.contains(*p)) + .expect("fixture has space outside editor"); + let before_outside = h.state().app.ui.docking.clone(); + let tab = egui::Id::new("designcraft-panel-docking").with(("tab", &"layers".to_string())); + let from = h.ctx.read_response(tab).unwrap().rect.center(); + h.event(Event::PointerMoved(from)); + h.run_steps(1); + h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.event(Event::PointerMoved(from - vec2(90.0, 0.0))); + h.run_steps(2); + assert_eq!(h.ctx.dragged_id(), Some(tab)); + h.event(Event::PointerMoved(outside)); + h.event(Event::PointerButton { pos: outside, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert_ne!(h.state().app.ui.docking, before_outside, "measured editor must permit ordinary releases outside its bounds"); + let after = h.state().app.ui.docking.clone(); + h.state_mut().app.run("app.storyEditor", json!({"story":story})).unwrap(); + h.run_steps(3); + assert_eq!(h.state().app.ui.docking, after, "closing editor must not revive a blocked drag"); + assert_eq!(serde_json::to_value(&h.state().app.session.active().unwrap().doc).unwrap(), doc); +} + +#[test] +fn capture_legacy_panel_menu_visual_fixture() { + let Some(directory) = std::env::var_os("CRAFT_UI_DOCKING_FIXTURES").map(std::path::PathBuf::from) else { return }; + std::fs::create_dir_all(&directory).unwrap(); + let mut app = app(); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect":[40,40,280,180],"content":"text","text":"Legacy Arabic menu fixture"})).unwrap(); + app.run("app.language", json!({"lang":"ar"})).unwrap(); + let doc = serde_json::to_value(&app.session.active().unwrap().doc).unwrap(); + let mut ready = false; + let mut h = Harness::builder().with_size(vec2(800.0, 800.0)).wgpu().build_ui_state( + move |ui, app: &mut DesignApp| { + if !ready { + crate::theme::install_fonts(ui.ctx(), &app.ui.language); + crate::theme::apply(ui.ctx(), &crate::theme::Tokens::for_brightness(crate::theme::Brightness::Dark)); + ready = true; + return; + } + crate::dock::show(app, ui); + egui::CentralPanel::default().show(ui, |ui| { + ui.label("Shaped legacy menu references:"); + for label in ["Float Panel", "Layers"] { + crate::rtl::label(ui, crate::i18n::tr("ar", label)); + } + }); + }, + app, + ); + h.input_mut().max_texture_side = Some(8192); + h.run_steps(5); + assert!(h.state().ui.docking.is_none(), "fixture must use the actual legacy menu before customization"); + let at = h.get_by_label("Properties").rect().center(); + h.input_mut().events.extend([ + Event::PointerMoved(at), + Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE }, + ]); + h.run_steps(1); + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert!(egui::Popup::is_any_open(&h.ctx)); + assert_eq!(h.query_all_by_label(crate::i18n::tr("ar", "Float Panel")).count(), 2, "reference and actual logical action must be accessible"); + h.render().unwrap().save(directory.join("designcraft-legacy-context-dark-ar.png")).unwrap(); + let group = h.get_by_label_contains(crate::i18n::tr("ar", "Group with")).rect().center(); + h.hover_at(group); + h.run_steps(4); + assert_eq!(h.query_all_by_label(crate::i18n::tr("ar", "Layers")).count(), 2, "reference and actual target must be accessible"); + h.render().unwrap().save(directory.join("designcraft-legacy-targets-dark-ar.png")).unwrap(); + assert!(h.state().ui.docking.is_none()); + let (last, last_label, _) = crate::dock::ICON_PANELS.last().unwrap(); + let logical = crate::i18n::tr("ar", last_label); + let scroll_at = h.query_all_by_label(crate::i18n::tr("ar", "Layers")).find(|node| node.rect().left() > 500.0).unwrap().rect().center(); + let submenu_layer = h.ctx.layer_id_at(scroll_at).unwrap(); + let submenu = h.ctx.memory(|m| m.area_rect(submenu_layer.id)).unwrap(); + let before = h.query_all_by_label(logical).find(|node| submenu.x_range().contains(node.rect().center().x)).map(|node| node.rect()); + assert!(before.is_none_or(|rect| !submenu.contains_rect(rect)), "actual submenu must require scrolling to reach its last target"); + h.input_mut().events.extend([ + Event::PointerMoved(scroll_at), + Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(0.0, -1600.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE }, + ]); + h.run_steps(12); + let target = h.query_all_by_label(logical).find(|node| submenu.x_range().contains(node.rect().center().x)).unwrap().rect(); + assert!(submenu.contains_rect(target), "last target must be inside the actual submenu after scrolling"); + assert!(h.ctx.content_rect().contains_rect(target), "last target must be physically visible after wheel scrolling"); + h.render().unwrap().save(directory.join("designcraft-legacy-targets-scrolled-dark-ar.png")).unwrap(); + h.query_all_by_label(logical).find(|node| submenu.contains_rect(node.rect())).unwrap().click(); + h.run_steps(4); + let layout = h.state().ui.docking.as_ref().unwrap(); + assert_eq!(layout.panels().iter().filter(|panel| panel.as_str() == "properties").count(), 1); + assert_eq!(layout.panels().iter().filter(|panel| panel.as_str() == *last).count(), 1); + let group = group_members_for_test(layout, last); + assert!(group.iter().any(|panel| panel == "properties")); + assert_eq!(group.last().map(String::as_str), Some("properties"), "clicked destination must receive the original panel after it"); + assert_eq!(serde_json::to_value(&h.state().session.active().unwrap().doc).unwrap(), doc); + h.render().unwrap().save(directory.join("designcraft-legacy-last-target-selected-dark-ar.png")).unwrap(); +} diff --git a/crates/ui-egui/src/taskbar.rs b/crates/ui-egui/src/taskbar.rs index 9b25afd43..cf66e9d5f 100644 --- a/crates/ui-egui/src/taskbar.rs +++ b/crates/ui-egui/src/taskbar.rs @@ -16,7 +16,7 @@ const GRIP: &str = "task_bar_grip"; /// Show the bar under `sel` (screen rect of the selection), or where it is pinned, kept inside /// `canvas`. pub fn show(app: &mut DesignApp, ctx: &egui::Context, sel: Rect, canvas: Rect) { - let grip_id = egui::Id::new(GRIP); + let grip_id = egui::Id::new((GRIP, ctx.viewport_id(), app.pane)); // (Two separate context calls: `is_pointer_over_egui` inside an `input` closure deadlocks.) let dragging = ctx.dragged_id() == Some(grip_id); if !app.ui.task_bar || (ctx.input(|i| i.pointer.any_down()) && !ctx.is_pointer_over_egui() && !dragging) { @@ -28,7 +28,8 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context, sel: Rect, canvas: Rect) { return; } let t = Tokens::get(ctx); - let id = egui::Id::new("task_bar"); + // The same selection can be shown in two canvases; each bar needs its own area and widgets. + let id = egui::Id::new(("task_bar", ctx.viewport_id(), app.pane)); // Width from the previous frame (content-sized). let w = ctx.memory(|m| m.area_rect(id).map(|r| r.width())).unwrap_or(420.0); let at = match app.ui.task_bar_pin { @@ -46,7 +47,11 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context, sel: Rect, canvas: Rect) { pos2(x, y) } }; - app.ui.task_bar_at = Some([at.x - canvas.min.x, at.y - canvas.min.y]); + // A shared persisted pin is relative to each canvas. The transient pin target belongs + // to the focused pane, so rendering the sibling cannot overwrite it. + if app.pane == app.focus_pane { + app.ui.task_bar_at = Some([at.x - canvas.min.x, at.y - canvas.min.y]); + } egui::Area::new(id).order(egui::Order::Middle).fixed_pos(at).show(ctx, |ui| { egui::Frame::NONE .fill(t.panel) @@ -75,7 +80,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context, sel: Rect, canvas: Rect) { } else { object_controls(app, ui); } - more(app, ui); + more(app, ui, [at.x - canvas.min.x, at.y - canvas.min.y]); }); }); }); @@ -266,7 +271,7 @@ fn object_controls(app: &mut DesignApp, ui: &mut Ui) { } } -fn more(app: &mut DesignApp, ui: &mut Ui) { +fn more(app: &mut DesignApp, ui: &mut Ui, at: [f32; 2]) { let t = Tokens::get(ui.ctx()); let (r, resp) = ui.allocate_exact_size(vec2(22.0, FIELD_H), Sense::click()); if resp.hovered() { @@ -286,7 +291,8 @@ fn more(app: &mut DesignApp, ui: &mut Ui) { let pinned = app.ui.task_bar_pin.is_some(); let pin = crate::i18n::tr(&app.ui.language, "Pin Bar Position"); if ui.button(crate::rtl::widget(ui, if pinned { format!("✓ {pin}") } else { format!(" {pin}") })).clicked() { - let _ = app.run("window.taskBarPin", json!({"on": !pinned})); + let params = if pinned { json!({"on": false}) } else { json!({"on": true, "at": at}) }; + let _ = app.run("window.taskBarPin", params); ui.close(); } if ui.add_enabled(pinned, egui::Button::new(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Reset Bar Position")))).clicked() { @@ -323,7 +329,7 @@ mod tests { } fn bar(h: &Harness<'static, Window>) -> egui::Rect { - h.ctx.memory(|m| m.area_rect(egui::Id::new("task_bar"))).unwrap() + h.ctx.memory(|m| m.area_rect(egui::Id::new(("task_bar", h.ctx.viewport_id(), h.state().app.pane)))).unwrap() } fn grip(h: &Harness<'static, Window>) -> egui::Pos2 { @@ -372,6 +378,59 @@ mod tests { assert_eq!(app.ui.task_bar_pin, Some([5.0, 6.0])); } + #[test] + fn split_pane_grips_handle_only_their_own_drag() { + let (mut h, _) = window(); + h.set_size(vec2(2200.0, 900.0)); + // A compact object HUD fits in each pane, leaving room to exercise both axes + // without the intentional canvas-edge clamp masking horizontal movement. + let id = h.state_mut().app.run("frame.create", json!({"rect":[72,72,300,200],"content":"none"})).unwrap()["id"].as_u64().unwrap(); + h.state_mut().app.run("selection.set", json!({"ids":[id]})).unwrap(); + h.state_mut().app.run("window.split", json!({"on":true})).unwrap(); + h.run_steps(6); + let area = + |h: &Harness<'static, Window>, pane| h.ctx.memory(|m| m.area_rect(egui::Id::new(("task_bar", h.ctx.viewport_id(), pane)))).unwrap(); + for pane in [0_u8, 1] { + h.state_mut().app.run("window.taskBarPin", json!({"at":[100,120]})).unwrap(); + h.run_steps(4); + let before = area(&h, pane); + let from = pos2(before.min.x + 10.0, before.center().y); + let delta = vec2(24.0, 28.0); + drag(&mut h, from, delta); + let after = area(&h, pane); + assert!((after.min - before.min - delta).length() < 2.0, "pane {pane} drag was handled by a sibling grip: {before:?} -> {after:?}"); + assert!(h.state().app.ui.task_bar_pin.is_some()); + } + } + + #[test] + fn popup_pin_uses_the_invoking_hud_position_after_focus_moves_to_sibling() { + let (mut h, _) = window(); + let id = h.state_mut().app.run("frame.create", json!({"rect":[72,72,300,200],"content":"none"})).unwrap()["id"].as_u64().unwrap(); + h.state_mut().app.run("selection.set", json!({"ids":[id]})).unwrap(); + h.state_mut().app.run("window.split", json!({"on":true})).unwrap(); + h.run_steps(6); + h.state_mut().app.switch_pane(1); + h.state_mut().app.run("view.zoom", json!({"zoom":0.5})).unwrap(); + h.state_mut().app.switch_pane(0); + h.run_steps(4); + let expected = h.state().app.ui.task_bar_at.unwrap(); + let left = h.ctx.memory(|m| m.area_rect(egui::Id::new(("task_bar", h.ctx.viewport_id(), 0_u8)))).unwrap(); + click_at(&mut h, pos2(left.max.x - 19.0, left.center().y)); + let pin = h.get_by_label_contains("Pin Bar Position").rect(); + let at = pos2(pin.max.x - 2.0, pin.center().y); + let sibling = h.state().app.other_pane.as_ref().and_then(|(_, rect)| *rect).unwrap(); + assert!(sibling.contains(at), "fixture popup must extend into sibling canvas: {at:?} outside {sibling:?}"); + h.event(egui::Event::PointerMoved(at)); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + assert_eq!(h.state().app.focus_pane, 1); + assert_ne!(h.state().app.ui.task_bar_at, Some(expected), "fixture HUD positions must differ"); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(4); + assert_eq!(h.state().app.ui.task_bar_pin, Some(expected), "Pin must use the invoking HUD position"); + } + #[test] fn the_grip_moves_the_bar_and_it_stays_there() { let (mut h, id) = window(); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 3dd50180f..193abf344 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -332,18 +332,11 @@ pub fn apply(ctx: &egui::Context, t: &Tokens) { v.selection.stroke = Stroke::new(1.0, t.text_strong); v.hyperlink_color = t.accent; v.override_text_color = Some(t.text); - for (w, fill) in [ - (&mut v.widgets.noninteractive, t.panel), - (&mut v.widgets.inactive, t.button), - (&mut v.widgets.hovered, t.hover), - (&mut v.widgets.active, t.tool_active), - (&mut v.widgets.open, t.hover), - ] { - w.bg_fill = fill; - w.weak_bg_fill = fill; - w.corner_radius = r; - w.fg_stroke = Stroke::new(1.0, t.text); - } + craft_ui::theme::WidgetPalette { noninteractive: t.panel, inactive: t.button, hovered: t.hover, active: t.tool_active, open: t.hover }.apply_to( + &mut v.widgets, + r, + Stroke::new(1.0, t.text), + ); v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, t.divider); v.widgets.inactive.bg_stroke = Stroke::new(1.0, t.field_border); v.widgets.hovered.bg_stroke = Stroke::new(1.0, t.field_border); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index d3b6f62a1..d97c2bad8 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -3,6 +3,17 @@ Start the app with `--control <port>` (or `DESIGNCRAFT_CONTROL_PORT`). The server listens on `127.0.0.1` only and speaks JSON lines: one request object per line, one reply per line. +For an isolated native test profile, set `DESIGNCRAFT_CONFIG_DIR` to a dedicated directory before +launch. UI/engine preferences and the `Recovery` directory then live under that root. An unset +override preserves the usual platform paths; an explicitly empty override disables those paths. +`DESIGNCRAFT_NO_PREFS` still disables preference reads/writes independently of recovery. + +Control pointer and key input is queued across native frames. After a drag, wait for both the +expected document state and `document.inspect.canUndo` before testing undo; an object can appear +in the preview before its undo entry is committed. On macOS, default shortcuts owned by the native +menu (including Command-Z) are skipped by the egui shortcut handler. `ui.key` does not send a Cocoa +menu event; use `ui.menu.invoke` for a menu-command test and test physical accelerators separately. + **Only requests are read.** Every line must be a JSON object with a string `method` (`id` and `params` are optional; blank lines are skipped). Anything else gets one error reply (`{"ok": false, "error": "… closing the connection"}`) and the server **closes the connection**, so @@ -44,3 +55,40 @@ merged document; the template stays as it was ([agents.md](agents.md#data-merge) Headless window screenshots (locked screen, hidden window): `cargo run -p designcraft-ui-egui --example ui_shot -- script.jsonl`, where each line is one of the requests above, `{"shot": "/abs/out.png"}` or `{"steps": n}` (renders the whole UI offscreen with wgpu). The MCP server (`designcraft-cli mcp`) wraps the same methods for Claude and other agents. + +## Shared panel docking + +Panel arrangements support tab grouping and reordering, horizontal and vertical splits, +and movable, resizable floating groups inside the application window. Drag a panel tab, +or use its context menu. Floating groups remain within the application viewport. + +The shared layout begins with the existing workspace on the first docking operation. +Workspace saves retain its groups, active tabs, split sizes, floating geometry, and the +positions of hidden panels. Resetting a built-in workspace restores its default dock. + +- `window.panel.move {panel, anchor, zone?, before?}` moves a panel into the target group. + `zone` is `tab` (default), `center`, `left`, `right`, `top`, or `bottom`; `before` reorders + tabs by panel ID. The destination must already be visible. +- `window.panel.float {panel, x?, y?, width?, height?}` detaches a panel. +- `window.panel.dock {panel, anchor?, zone?, before?}` returns a panel to its previous + dock placement when possible, or to the supplied destination. +- `window.panel.activate {panel}` reveals and selects a panel, restoring its saved + position when possible. `window.panel.close {panel}` hides it. + +Panel IDs are stable across themes. Invalid IDs, non-finite or invalid geometry, and +invalid destinations return an error without changing the arrangement. + +`window.panel.layout {action}` also accepts the shared, externally tagged action schema. +For example, `{"action":{"MoveFloating":{"panel":"properties","rect":[44,66,360,450]}}}` +updates a floating group's position and size; `{"action":{"ResizeSplit":{"path":[],"size":{"Ratio":0.35}}}}` +resizes the root divider (`false`/`true` path entries select first/second children). +`Move` with `placement: {"Tab":{"before":"swatches"}}` reorders tabs, and +`SetStackOpen {panel,open}` / `ResizeStack {panel,height}` control accordion entries. +The UI dispatches these same commands. Unknown panel IDs, invalid geometry, and stale +split paths return an error without changing the workspace. + +After panel customization, `app.tablePanel` (including Shift+F9) still toggles the +Table panel. `ui.set` with `closePanel` closes the last explicitly opened panel; +`dockExpanded:false` hides the root dock while floating groups remain visible. +The icon rail can reopen a collapsed dock through any root panel. Activating a +floating panel also raises its group. These operations change only UI layout. diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index cebd6201e..949351557 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -73,7 +73,7 @@ fn intra_layer_allowed(from: &str, to: &str) -> bool { /// External crates that constitute a UI toolkit / windowing dependency. /// Entries ending in `*` are prefixes. -pub const UI_CRATES: &[&str] = &["egui", "eframe", "winit", "egui_kittest", "rfd", "bevy*"]; +pub const UI_CRATES: &[&str] = &["craft-ui", "egui", "eframe", "winit", "egui_kittest", "rfd", "bevy*"]; /// First layer allowed to use UI crates. pub const UI_MIN_LAYER: u8 = 6; @@ -285,7 +285,7 @@ mod tests { #[test] fn ui_crates_below_l6_flagged() { - for dep in ["egui", "eframe", "winit", "egui_kittest", "rfd", "bevy_ecs", "bevy"] { + for dep in ["craft-ui", "egui", "eframe", "winit", "egui_kittest", "rfd", "bevy_ecs", "bevy"] { let v = check(&[c("designcraft-engine", &[(dep, Normal, false)])]); assert!(matches!(v[..], [Violation::UiBelowL6 { layer: 5, .. }]), "{dep}"); } From a62188aa9b659f00d0b4e9b31db58882e9417ab6 Mon Sep 17 00:00:00 2001 From: con3code <con3office@gmail.com> Date: Sun, 11 Oct 2026 07:47:05 +0900 Subject: [PATCH 017/238] UI: CJK interface text from installed fonts, on the Latin baseline Without the craft-fonts build input the interface had no CJK glyphs: egui can't find system fonts, so Japanese and Chinese menus and labels drew as boxes, and Korean and Traditional Chinese font names in the font menus did in every build. - The first installed UI face of each script (Japanese, Simplified Chinese, Traditional Chinese, Korean; macOS, Windows and Linux families) is added as a fallback for the scripts craft-fonts doesn't cover. - A fallback face sits on the UI face's baseline: egui centres a fallback's line height in the primary's, so a CJK face with a taller ascent or a line gap sat above or below the Latin text beside it. The tweak is computed from the two faces' line metrics. - The interface language's script comes first, before the built-in CJK faces, so Han characters take that language's forms. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013phQ3ekq6uGBEWD6FjFYG2 --- crates/fonts/src/lib.rs | 10 ++ crates/ui-egui/src/rtl.rs | 4 +- crates/ui-egui/src/theme.rs | 227 +++++++++++++++++++++++++++++++++++- 3 files changed, 235 insertions(+), 6 deletions(-) diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index c1d744780..43d5837ff 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -81,6 +81,16 @@ pub struct ShapedGlyph { } /// An OpenType feature setting: `"liga"`, `"-kern"`, `"ss01"`. +/// Line metrics of the font at `index` of `data`, in font units: (units per em, ascent, descent +/// (negative below the baseline), line gap) — what text layouts such as egui's build lines from. +/// `None` if the font can't be read. +pub fn line_metrics(data: &[u8], index: u32) -> Option<(f32, f32, f32, f32)> { + use skrifa::MetadataProvider; + let f = skrifa::FontRef::from_index(data, index).ok()?; + let m = f.metrics(skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default()); + Some((f32::from(m.units_per_em.max(1)), m.ascent, m.descent, m.leading)) +} + pub fn feature(tag: &str) -> Option<Feature> { let (on, t) = match tag.strip_prefix('-') { Some(r) => (false, r), diff --git a/crates/ui-egui/src/rtl.rs b/crates/ui-egui/src/rtl.rs index 2521aae20..79f130043 100644 --- a/crates/ui-egui/src/rtl.rs +++ b/crates/ui-egui/src/rtl.rs @@ -300,7 +300,9 @@ mod tests { fn with_fonts(test: impl FnOnce(&egui::Context)) { let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx, "ar"); + // The built-in faces only: installed fonts (the CJK fallbacks) would make the glyphs + // measured here depend on the machine. + ctx.set_fonts(crate::theme::font_definitions(designcraft_fonts::CRAFT_FONTS, "ar")); ctx.begin_pass(egui::RawInput { max_texture_side: Some(8192), ..Default::default() }); test(&ctx); let mut output = ctx.end_pass(); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 3dd50180f..eb294d575 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -255,7 +255,125 @@ impl Tokens { /// Install the UI fonts for the interface language `lang` (it orders the CJK fallbacks). pub fn install_fonts(ctx: &egui::Context, lang: &str) { - ctx.set_fonts(font_definitions(designcraft_fonts::CRAFT_FONTS, lang)); + let craft = designcraft_fonts::CRAFT_FONTS; + let mut defs = font_definitions(craft, lang); + add_system_fallbacks(&mut defs, &system_ui_fonts(craft), lang); + ctx.set_fonts(defs); +} + +/// An installed font the UI falls back to for a script the built-in faces lack. +pub(crate) struct SystemFont { + /// "Jpan", "Hans", "Hant" or "Kore". + script: &'static str, + family: String, + bytes: Vec<u8>, + index: u32, +} + +/// Installed UI faces (sans serif) per script, the first one found of each list: macOS, then +/// Windows, then Linux families. +const SYSTEM_UI_FONTS: &[(&str, &[&str])] = &[ + ( + "Jpan", + &[ + "Hiragino Sans", + "Hiragino Kaku Gothic ProN", + "Yu Gothic UI", + "Yu Gothic", + "Meiryo", + "MS Gothic", + "Noto Sans CJK JP", + "Noto Sans JP", + "Source Han Sans JP", + "IPAexGothic", + "Droid Sans Fallback", + ], + ), + ("Hans", &["PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", "Source Han Sans SC", "WenQuanYi Micro Hei"]), + // Traditional Chinese and Korean: font menus list those families under their native names. + ("Hant", &["PingFang TC", "Microsoft JhengHei", "Noto Sans CJK TC", "Source Han Sans TC"]), + ("Kore", &["Apple SD Gothic Neo", "Malgun Gothic", "Noto Sans CJK KR", "Noto Sans KR", "Source Han Sans K", "NanumGothic", "UnDotum"]), +]; + +/// The installed faces for the CJK scripts `craft` (the built-in craft-fonts) doesn't cover, so +/// the interface shows Japanese and Chinese without the craft-fonts build input, and Korean and +/// Traditional Chinese (font names in the font menus) in any build. egui can't +/// find system fonts itself; the document font scan (not on the web) does. +fn system_ui_fonts(craft: &[designcraft_fonts::CraftFont]) -> Vec<SystemFont> { + let db = designcraft_fonts::FontDb::global(); + SYSTEM_UI_FONTS + .iter() + .filter(|(script, _)| !craft.iter().any(|f| f.scripts.contains(script))) + .filter_map(|(script, families)| { + let family = families.iter().find(|f| db.has_family(f))?; + let face = db.face(family, "Regular"); + // `face` falls back to another family when this one can't be read: keep only the real one. + (face.family.eq_ignore_ascii_case(family) && !face.data().is_empty()).then(|| SystemFont { + script, + family: (*family).to_string(), + bytes: face.data().to_vec(), + index: face.index(), + }) + }) + .collect() +} + +/// The UI face every family starts with (Source Sans 3; the semibold shares its line metrics). +const UI_FONT: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"); + +/// The tweak that puts a fallback face's baseline on the UI face's. egui centres a fallback's +/// line height in the primary's instead (`pos.y = ascent + (line height − its line height) / 2`), +/// so a CJK face with a taller ascent or a larger line gap would sit above or below the Latin +/// text beside it. Shifts are in ems of the font size. +pub(crate) fn baseline_tweak(face: &[u8], index: u32) -> egui::FontTweak { + let metrics = |data: &[u8], i: u32| { + designcraft_fonts::line_metrics(data, i).map(|(upem, ascent, descent, gap)| (ascent / upem, (ascent - descent + gap) / upem)) + }; + let shift = match (metrics(UI_FONT, 0), metrics(face, index)) { + (Some((ap, hp)), Some((af, hf))) => ap - (af + 0.5 * (hp - hf)), + _ => 0.0, + }; + egui::FontTweak { y_offset_factor: if shift.is_finite() { shift.clamp(-0.5, 0.5) } else { 0.0 }, ..Default::default() } +} + +/// The script whose letterforms the interface language `lang` prefers for Han characters, Kana +/// and Hangul: "Jpan", "Hans", "Hant" or "Kore". Languages without a CJK preference use Japanese +/// forms, the app's default CJK face. +fn cjk_script(lang: &str) -> &'static str { + match lang { + "zh" | "zh-hans" | "zh-cn" => "Hans", + "zh-hant" | "zh-tw" | "zh-hk" => "Hant", + "ko" => "Kore", + _ => "Jpan", + } +} + +/// Add installed faces `system` to every family in the interface language's order: the faces of +/// its script (`cjk_script`) go before the built-in CJK faces, so Han characters take that +/// language's forms (Traditional Chinese, Korean); the others go at the end. +pub(crate) fn add_system_fallbacks(fonts: &mut FontDefinitions, system: &[SystemFont], lang: &str) { + let name = |f: &SystemFont| format!("system-{}", f.family); + for f in system { + fonts.font_data.insert( + name(f), + Arc::new(FontData { index: f.index, tweak: baseline_tweak(&f.bytes, f.index), ..FontData::from_owned(f.bytes.clone()) }), + ); + } + let first = cjk_script(lang); + let preferred: Vec<String> = system.iter().filter(|f| f.script == first).map(name).collect(); + let rest: Vec<String> = ["Jpan", "Hans", "Hant", "Kore"] + .iter() + .filter(|s| **s != first) + .flat_map(|s| system.iter().filter(move |f| f.script == *s)) + .map(name) + .collect(); + for (family, stack) in fonts.families.iter_mut() { + // The Arabic families keep their own order (see `font_definitions`). + let arabic = matches!(family, FontFamily::Name(n) if n.starts_with("arabic")); + let at = if arabic { stack.len() } else { stack.iter().position(|n| n.starts_with("craft-")).unwrap_or(stack.len()) }; + stack.splice(at..at, preferred.iter().cloned()); + stack.extend(rest.iter().cloned()); + } } /// The UI fonts: the app's own, then the craft-fonts faces from `craft` (empty without @@ -263,7 +381,7 @@ pub fn install_fonts(ctx: &egui::Context, lang: &str) { /// Simplified Chinese, in the interface language's order, then Arabic. The `arabic` families /// put the Arabic face first for right-to-left runs. egui has no system-font discovery, so /// without craft-fonts CJK and Arabic UI text has no glyphs. -fn font_definitions(craft: &'static [designcraft_fonts::CraftFont], lang: &str) -> FontDefinitions { +pub(crate) fn font_definitions(craft: &'static [designcraft_fonts::CraftFont], lang: &str) -> FontDefinitions { let mut fonts = FontDefinitions::default(); let add = |fonts: &mut FontDefinitions, name: &str, data: &'static [u8]| { fonts.font_data.insert(name.into(), Arc::new(FontData::from_static(data))); @@ -281,11 +399,15 @@ fn font_definitions(craft: &'static [designcraft_fonts::CraftFont], lang: &str) let chinese = pick("Hans"); let arabic = pick("Arab"); let name = |f: &designcraft_fonts::CraftFont| format!("craft-{}-{}", f.family, f.style); - for f in japanese.iter().chain(&chinese).chain(&arabic) { + for f in japanese.iter().chain(&chinese) { + // CJK faces sit on the Latin baseline (see `baseline_tweak`). + fonts.font_data.insert(name(f), Arc::new(FontData { tweak: baseline_tweak(f.bytes, 0), ..FontData::from_static(f.bytes) })); + } + for f in &arabic { add(&mut fonts, &name(f), f.bytes); } - // Han characters take the forms of the interface language: Chinese faces first for zh. - let zh = lang.starts_with("zh"); + // Han characters take the forms of the interface language: Chinese faces first for Chinese. + let zh = matches!(cjk_script(lang), "Hans" | "Hant"); for (family, stack) in fonts.families.iter_mut() { let bold = *family == FontFamily::Name("semibold".into()); let mut ja = japanese.clone(); @@ -406,6 +528,101 @@ mod japanese_font_tests { assert_eq!(stack.iter().find(|n| n.starts_with("craft-")).map(String::as_str), Some("craft-BIZ UDPGothic-Regular")); } + /// Without craft-fonts, an installed Japanese face gives the interface its glyphs (here a + /// synthetic stand-in, so the test doesn't depend on the machine's fonts). + #[test] + fn installed_fonts_give_japanese_glyphs_without_craft_fonts() { + let bytes = designcraft_fonts::testing::font_with("DC UI Japanese", &['日', '本', '語']).unwrap(); + let system = [super::SystemFont { script: "Jpan", family: "DC UI Japanese".into(), bytes, index: 0 }]; + let mut defs = super::font_definitions(&[], "ja"); + super::add_system_fallbacks(&mut defs, &system, "ja"); + for family in families() { + assert_eq!(defs.families[&family].last().map(String::as_str), Some("system-DC UI Japanese"), "{family:?}"); + } + let ctx = egui::Context::default(); + ctx.set_fonts(defs); + let mut output = ctx.run_ui(egui::RawInput::default(), |_| {}); + output.textures_delta.clear(); + ctx.fonts_mut(|fonts| { + let font = egui::FontId::new(13.0, egui::FontFamily::Proportional); + for ch in "日本語".chars() { + assert!(fonts.has_glyph(&font, ch), "missing {ch}"); + } + }); + } + + /// A fallback face with other line metrics lands on the UI face's baseline: egui puts a glyph + /// at `ascent + (primary line height − its line height) / 2`, plus the tweak. + #[test] + fn fallback_faces_share_the_ui_baseline() { + let ui = designcraft_fonts::line_metrics(super::UI_FONT, 0).unwrap(); + // A synthetic face with a taller ascent and a line gap (Hiragino-like proportions). + let base = designcraft_fonts::testing::font_with("DC Tall", &['日']).unwrap(); + let tall = designcraft_fonts::testing::with_table_u16(base, b"hhea", 4, 1200u16).unwrap(); + let tall = designcraft_fonts::testing::with_table_u16(tall, b"hhea", 8, 500u16).unwrap(); + let face = designcraft_fonts::line_metrics(&tall, 0).unwrap(); + let tweak = super::baseline_tweak(&tall, 0); + let em = |m: (f32, f32, f32, f32)| (m.1 / m.0, (m.1 - m.2 + m.3) / m.0); + let ((ap, hp), (af, hf)) = (em(ui), em(face)); + let baseline = af + 0.5 * (hp - hf) + tweak.y_offset_factor; + assert!((baseline - ap).abs() < 1e-4, "{baseline} vs {ap}"); + assert!(tweak.y_offset_factor.abs() > 0.01, "the synthetic face needs a shift: {}", tweak.y_offset_factor); + // The UI face itself needs no shift. + assert!(super::baseline_tweak(super::UI_FONT, 0).y_offset_factor.abs() < 1e-6); + } + + /// Korean font names (the font menus show Korean families by their Hangul names) get glyphs + /// from an installed Korean face, after the Japanese and Chinese ones. + #[test] + fn installed_korean_faces_give_hangul_glyphs() { + let face = |family: &str, chars: &[char], script| super::SystemFont { + script, + family: family.into(), + bytes: designcraft_fonts::testing::font_with(family, chars).unwrap(), + index: 0, + }; + let system = [face("DC UI Korean", &['한', '글'], "Kore"), face("DC UI Japanese", &['日'], "Jpan")]; + let mut defs = super::font_definitions(&[], "ja"); + super::add_system_fallbacks(&mut defs, &system, "ja"); + let stack = &defs.families[&egui::FontFamily::Proportional]; + let at = |n: &str| stack.iter().position(|x| x == n).unwrap(); + assert!(at("system-DC UI Japanese") < at("system-DC UI Korean")); + let ctx = egui::Context::default(); + ctx.set_fonts(defs); + let mut output = ctx.run_ui(egui::RawInput::default(), |_| {}); + output.textures_delta.clear(); + ctx.fonts_mut(|fonts| { + let font = egui::FontId::new(13.0, egui::FontFamily::Proportional); + assert!(fonts.has_glyph(&font, '한') && fonts.has_glyph(&font, '글')); + }); + } + + /// Han characters take the interface language's forms: its script's installed face comes + /// before the built-in CJK faces and the other scripts' faces. + #[test] + fn interface_language_orders_the_cjk_faces() { + let face = |family: &str, script| super::SystemFont { + script, + family: family.into(), + bytes: designcraft_fonts::testing::font_with(family, &['日']).unwrap(), + index: 0, + }; + let system = [face("DC Ja", "Jpan"), face("DC Hans", "Hans"), face("DC Hant", "Hant"), face("DC Ko", "Kore")]; + for (lang, first) in [("ja", "DC Ja"), ("zh", "DC Hans"), ("zh-hant", "DC Hant"), ("ko", "DC Ko"), ("", "DC Ja")] { + let mut defs = super::font_definitions(&[], lang); + // Stand-in for a built-in CJK face (craft-fonts may be absent in this build). + defs.families.entry(egui::FontFamily::Proportional).or_default().push("craft-Stand-in".into()); + super::add_system_fallbacks(&mut defs, &system, lang); + let stack = &defs.families[&egui::FontFamily::Proportional]; + let at = |n: &str| stack.iter().position(|x| x == n).unwrap(); + let first_at = at(&format!("system-{first}")); + assert!(first_at < at("craft-Stand-in"), "{lang}: {stack:?}"); + for f in &system { + assert!(first_at <= at(&format!("system-{}", f.family)), "{lang}: {stack:?}"); + } + } + } + #[test] fn ui_works_without_craft_fonts() { let ctx = ctx_with(&[]); From 70cfd2d4fa84ef44fd7552cc31ac42bf22a12819 Mon Sep 17 00:00:00 2001 From: con3code <con3office@gmail.com> Date: Sun, 11 Oct 2026 08:30:39 +0900 Subject: [PATCH 018/238] UI: installed CJK faces are read once, without waiting for the font scan - install_fonts no longer waits for the system font catalog on the first frame: the built-in faces are installed at once, the scan runs in the background, and the installed CJK faces are added when it is done. - The installed faces are read once and shared: changing the interface language reorders them without copying the font files into egui again. - fonts: line_metrics no longer sits between feature's doc comment and feature. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013phQ3ekq6uGBEWD6FjFYG2 --- crates/fonts/src/fontdb.rs | 6 +++ crates/fonts/src/lib.rs | 2 +- crates/ui-egui/src/lib.rs | 14 ++++-- crates/ui-egui/src/theme.rs | 91 ++++++++++++++++++++++--------------- 4 files changed, 73 insertions(+), 40 deletions(-) diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 14441e144..c631f3020 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -881,6 +881,12 @@ impl FontDb { }); } + /// Whether the installed fonts are cataloged: a lookup by family name won't wait for the + /// scan (always true on wasm, which has none). See [`FontDb::scan_in_background`]. + pub fn system_fonts_cataloged(&self) -> bool { + cfg!(target_arch = "wasm32") || self.cataloged.get().is_some() + } + /// Catalog the installed fonts on a background thread, so the first lookup by family name /// (opening a file, the font menus) doesn't wait for the scan. A no-op once they are /// cataloged, and on wasm. diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 43d5837ff..630888315 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -80,7 +80,6 @@ pub struct ShapedGlyph { pub rtl: bool, } -/// An OpenType feature setting: `"liga"`, `"-kern"`, `"ss01"`. /// Line metrics of the font at `index` of `data`, in font units: (units per em, ascent, descent /// (negative below the baseline), line gap) — what text layouts such as egui's build lines from. /// `None` if the font can't be read. @@ -91,6 +90,7 @@ pub fn line_metrics(data: &[u8], index: u32) -> Option<(f32, f32, f32, f32)> { Some((f32::from(m.units_per_em.max(1)), m.ascent, m.descent, m.leading)) } +/// An OpenType feature setting: `"liga"`, `"-kern"`, `"ss01"`. pub fn feature(tag: &str) -> Option<Feature> { let (on, t) = match tag.strip_prefix('-') { Some(r) => (false, r), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fb81e9c34..29b1ec0b4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -474,6 +474,9 @@ pub struct DesignApp { styled: bool, /// The interface language the UI fonts were installed for (it orders the CJK fallbacks). fonts_lang: String, + /// The installed CJK faces are among the UI fonts (false while the system fonts are still + /// being cataloged: the fonts are installed again when they are). + fonts_system: bool, pub restyle: bool, fonts_ready: bool, pub integrated_titlebar: bool, @@ -514,6 +517,7 @@ impl DesignApp { shot_token: 0, styled: false, fonts_lang: String::new(), + fonts_system: false, restyle: false, fonts_ready: false, integrated_titlebar: false, @@ -726,17 +730,21 @@ impl DesignApp { ctx.set_zoom_factor(scale); } if !self.styled { - theme::install_fonts(ctx, &self.ui.language); + self.fonts_system = theme::install_fonts(ctx, &self.ui.language); self.fonts_lang = self.ui.language.clone(); self.styled = true; self.restyle = true; } else { - if self.fonts_lang != self.ui.language { - theme::install_fonts(ctx, &self.ui.language); + if self.fonts_lang != self.ui.language || (!self.fonts_system && theme::system_fonts_ready()) { + self.fonts_system = theme::install_fonts(ctx, &self.ui.language); self.fonts_lang = self.ui.language.clone(); } self.fonts_ready = true; } + if !self.fonts_system { + // The font scan runs in the background: look again shortly. + ctx.request_repaint_after(std::time::Duration::from_millis(250)); + } if self.restyle { theme::apply(ctx, &theme::Tokens::for_brightness(self.ui.brightness)); self.restyle = false; diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index eb294d575..14918008f 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -254,11 +254,23 @@ impl Tokens { } /// Install the UI fonts for the interface language `lang` (it orders the CJK fallbacks). -pub fn install_fonts(ctx: &egui::Context, lang: &str) { +/// Returns false while the installed fonts are still being cataloged: the built-in faces are +/// installed without waiting, and the caller installs again once they are (see +/// [`system_fonts_ready`]) to add the installed CJK faces. +pub fn install_fonts(ctx: &egui::Context, lang: &str) -> bool { let craft = designcraft_fonts::CRAFT_FONTS; let mut defs = font_definitions(craft, lang); - add_system_fallbacks(&mut defs, &system_ui_fonts(craft), lang); + let system = system_ui_fonts(craft); + if let Some(system) = system { + add_system_fallbacks(&mut defs, system, lang); + } ctx.set_fonts(defs); + system.is_some() +} + +/// Whether [`install_fonts`] would find the installed fonts cataloged. +pub fn system_fonts_ready() -> bool { + designcraft_fonts::FontDb::global().system_fonts_cataloged() } /// An installed font the UI falls back to for a script the built-in faces lack. @@ -266,8 +278,16 @@ pub(crate) struct SystemFont { /// "Jpan", "Hans", "Hant" or "Kore". script: &'static str, family: String, - bytes: Vec<u8>, - index: u32, + /// The face as egui takes it, on the UI face's baseline. Shared: installing the fonts again + /// (another interface language) doesn't copy the file. + data: Arc<FontData>, +} + +impl SystemFont { + pub(crate) fn new(script: &'static str, family: String, bytes: Vec<u8>, index: u32) -> Self { + let tweak = baseline_tweak(&bytes, index); + SystemFont { script, family, data: Arc::new(FontData { index, tweak, ..FontData::from_owned(bytes) }) } + } } /// Installed UI faces (sans serif) per script, the first one found of each list: macOS, then @@ -299,23 +319,29 @@ const SYSTEM_UI_FONTS: &[(&str, &[&str])] = &[ /// the interface shows Japanese and Chinese without the craft-fonts build input, and Korean and /// Traditional Chinese (font names in the font menus) in any build. egui can't /// find system fonts itself; the document font scan (not on the web) does. -fn system_ui_fonts(craft: &[designcraft_fonts::CraftFont]) -> Vec<SystemFont> { +fn system_ui_fonts(craft: &[designcraft_fonts::CraftFont]) -> Option<&'static [SystemFont]> { + static FACES: std::sync::OnceLock<Vec<SystemFont>> = std::sync::OnceLock::new(); let db = designcraft_fonts::FontDb::global(); - SYSTEM_UI_FONTS - .iter() - .filter(|(script, _)| !craft.iter().any(|f| f.scripts.contains(script))) - .filter_map(|(script, families)| { - let family = families.iter().find(|f| db.has_family(f))?; - let face = db.face(family, "Regular"); - // `face` falls back to another family when this one can't be read: keep only the real one. - (face.family.eq_ignore_ascii_case(family) && !face.data().is_empty()).then(|| SystemFont { - script, - family: (*family).to_string(), - bytes: face.data().to_vec(), - index: face.index(), + // Never wait for the font scan here (the first frame): it runs in the background, and the + // faces are read once, when it is done. + // (Tests wait instead, so that the fonts don't change in the middle of one.) + if !db.system_fonts_cataloged() && !cfg!(test) { + db.scan_in_background(); + return None; + } + Some(FACES.get_or_init(|| { + SYSTEM_UI_FONTS + .iter() + .filter(|(script, _)| !craft.iter().any(|f| f.scripts.contains(script))) + .filter_map(|(script, families)| { + let family = families.iter().find(|f| db.has_family(f))?; + let face = db.face(family, "Regular"); + // `face` falls back to another family when this one can't be read: keep only the real one. + (face.family.eq_ignore_ascii_case(family) && !face.data().is_empty()) + .then(|| SystemFont::new(script, (*family).to_string(), face.data().to_vec(), face.index())) }) - }) - .collect() + .collect() + })) } /// The UI face every family starts with (Source Sans 3; the semibold shares its line metrics). @@ -354,10 +380,7 @@ fn cjk_script(lang: &str) -> &'static str { pub(crate) fn add_system_fallbacks(fonts: &mut FontDefinitions, system: &[SystemFont], lang: &str) { let name = |f: &SystemFont| format!("system-{}", f.family); for f in system { - fonts.font_data.insert( - name(f), - Arc::new(FontData { index: f.index, tweak: baseline_tweak(&f.bytes, f.index), ..FontData::from_owned(f.bytes.clone()) }), - ); + fonts.font_data.insert(name(f), f.data.clone()); } let first = cjk_script(lang); let preferred: Vec<String> = system.iter().filter(|f| f.script == first).map(name).collect(); @@ -533,7 +556,7 @@ mod japanese_font_tests { #[test] fn installed_fonts_give_japanese_glyphs_without_craft_fonts() { let bytes = designcraft_fonts::testing::font_with("DC UI Japanese", &['日', '本', '語']).unwrap(); - let system = [super::SystemFont { script: "Jpan", family: "DC UI Japanese".into(), bytes, index: 0 }]; + let system = [super::SystemFont::new("Jpan", "DC UI Japanese".into(), bytes, 0)]; let mut defs = super::font_definitions(&[], "ja"); super::add_system_fallbacks(&mut defs, &system, "ja"); for family in families() { @@ -575,11 +598,8 @@ mod japanese_font_tests { /// from an installed Korean face, after the Japanese and Chinese ones. #[test] fn installed_korean_faces_give_hangul_glyphs() { - let face = |family: &str, chars: &[char], script| super::SystemFont { - script, - family: family.into(), - bytes: designcraft_fonts::testing::font_with(family, chars).unwrap(), - index: 0, + let face = |family: &str, chars: &[char], script| { + super::SystemFont::new(script, family.into(), designcraft_fonts::testing::font_with(family, chars).unwrap(), 0) }; let system = [face("DC UI Korean", &['한', '글'], "Kore"), face("DC UI Japanese", &['日'], "Jpan")]; let mut defs = super::font_definitions(&[], "ja"); @@ -601,12 +621,8 @@ mod japanese_font_tests { /// before the built-in CJK faces and the other scripts' faces. #[test] fn interface_language_orders_the_cjk_faces() { - let face = |family: &str, script| super::SystemFont { - script, - family: family.into(), - bytes: designcraft_fonts::testing::font_with(family, &['日']).unwrap(), - index: 0, - }; + let face = + |family: &str, script| super::SystemFont::new(script, family.into(), designcraft_fonts::testing::font_with(family, &['日']).unwrap(), 0); let system = [face("DC Ja", "Jpan"), face("DC Hans", "Hans"), face("DC Hant", "Hant"), face("DC Ko", "Kore")]; for (lang, first) in [("ja", "DC Ja"), ("zh", "DC Hans"), ("zh-hant", "DC Hant"), ("ko", "DC Ko"), ("", "DC Ja")] { let mut defs = super::font_definitions(&[], lang); @@ -618,7 +634,10 @@ mod japanese_font_tests { let first_at = at(&format!("system-{first}")); assert!(first_at < at("craft-Stand-in"), "{lang}: {stack:?}"); for f in &system { - assert!(first_at <= at(&format!("system-{}", f.family)), "{lang}: {stack:?}"); + let name = format!("system-{}", f.family); + assert!(first_at <= at(&name), "{lang}: {stack:?}"); + // Another language shares the face's data instead of copying the font file. + assert!(std::sync::Arc::ptr_eq(&defs.font_data[&name], &f.data), "{lang}: {name}"); } } } From a9124a0517594ce9872f73637e7b841a4967bcf9 Mon Sep 17 00:00:00 2001 From: Brandon Thomas <echelon@gmail.com> Date: Sat, 10 Oct 2026 19:33:30 -0400 Subject: [PATCH 019/238] Release prep: version 0.6.0; Linux riscv64 tar.gz in releases (#398) - release.yml: new linux-riscv64 job (cross-compiled on ubuntu-24.04 against ports.ubuntu.com riscv64 libs, tar.gz only, CLI smoke-tested under QEMU), ported from GridCraft; the draft release job needs it, so SHA256SUMS covers it. - packaging/linux/package.sh: CROSS_ARCH/CROSS_TARGET/CROSS_COMPILE/EMULATOR support, as in GridCraft. - cargo xtask version set 0.6.0; README, hardware parity and ROADMAP log note the new platform. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --- .github/workflows/release.yml | 77 ++++++++++++++++++++++++++++++++++- Cargo.lock | 40 +++++++++--------- Cargo.toml | 2 +- README.md | 2 + ROADMAP.md | 2 + docs/hardware-parity.md | 2 +- packaging/linux/package.sh | 21 +++++++--- 7 files changed, 117 insertions(+), 29 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bbd8acf3a..856bdbe6e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -388,9 +388,84 @@ jobs: path: dist/release/* if-no-files-found: error + linux-riscv64: + name: Linux riscv64 + needs: version + # Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release + # from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's + # riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the + # CLI is smoke-tested under QEMU. + runs-on: ubuntu-24.04 + # No `environment: release`: this job signs nothing, so it gets no release secrets. + env: + DESIGNCRAFT_VERSION: ${{ needs.version.outputs.version }} + DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it. + - name: Check out craft-fonts + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: storytold/craft-fonts + ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + path: craft-fonts + - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable + with: + targets: riscv64gc-unknown-linux-gnu + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + key: release-linux-riscv64 + - name: Apply version override + if: inputs.version != '' + run: cargo xtask version set "$DESIGNCRAFT_VERSION" + - name: Install cross-compilation dependencies + run: | + . /etc/os-release + sudo dpkg --add-architecture riscv64 + # Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports. + if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then + sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources + fi + if [ -f /etc/apt/sources.list ]; then + sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list + sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list + fi + for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do + echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse" + done | sudo tee /etc/apt/sources.list.d/riscv64.list + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \ + libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \ + libxi-dev:riscv64 libgl1-mesa-dev:riscv64 desktop-file-utils appstream + - name: Build and package (tar.gz) + env: + CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts + CRAFT_FONTS_REQUIRED: "1" + CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc + PKG_CONFIG_SYSROOT_DIR: / + PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig + PKG_CONFIG_ALLOW_CROSS: "1" + CROSS_ARCH: riscv64 + CROSS_TARGET: riscv64gc-unknown-linux-gnu + CROSS_COMPILE: riscv64-linux-gnu- + EMULATOR: qemu-riscv64 + QEMU_LD_PREFIX: /usr/riscv64-linux-gnu + run: packaging/linux/package.sh --formats tar + - name: Check the package + run: | + tar -tzf dist/release/designcraft-*-linux-riscv64.tar.gz \ + | grep -E '/bin/designcraft$|/bin/designcraft-cli$|/share/applications/ai\.storyteller\.designcraft\.desktop$' + file target/riscv64gc-unknown-linux-gnu/release/designcraft | grep -F 'RISC-V' + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: release-linux-riscv64 + path: dist/release/*.tar.gz + if-no-files-found: error + release: name: Draft GitHub Release - needs: [version, macos, windows, linux, flatpak, freebsd, web] + needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web] runs-on: ubuntu-latest environment: release permissions: diff --git a/Cargo.lock b/Cargo.lock index 9473c974a..484bfa1b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -870,7 +870,7 @@ dependencies = [ [[package]] name = "designcraft" -version = "0.5.0" +version = "0.6.0" dependencies = [ "designcraft-engine", "designcraft-ui-egui", @@ -887,7 +887,7 @@ dependencies = [ [[package]] name = "designcraft-cli" -version = "0.5.0" +version = "0.6.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -903,7 +903,7 @@ dependencies = [ [[package]] name = "designcraft-color" -version = "0.5.0" +version = "0.6.0" dependencies = [ "kurbo", "moxcms", @@ -915,7 +915,7 @@ dependencies = [ [[package]] name = "designcraft-compose" -version = "0.5.0" +version = "0.6.0" dependencies = [ "designcraft-color", "designcraft-doc", @@ -932,7 +932,7 @@ dependencies = [ [[package]] name = "designcraft-doc" -version = "0.5.0" +version = "0.6.0" dependencies = [ "designcraft-color", "designcraft-geom", @@ -944,7 +944,7 @@ dependencies = [ [[package]] name = "designcraft-engine" -version = "0.5.0" +version = "0.6.0" dependencies = [ "caseless", "designcraft-color", @@ -977,7 +977,7 @@ dependencies = [ [[package]] name = "designcraft-epub" -version = "0.5.0" +version = "0.6.0" dependencies = [ "designcraft-color", "designcraft-doc", @@ -986,7 +986,7 @@ dependencies = [ [[package]] name = "designcraft-fonts" -version = "0.5.0" +version = "0.6.0" dependencies = [ "harfrust 0.13.3", "kurbo", @@ -998,7 +998,7 @@ dependencies = [ [[package]] name = "designcraft-format" -version = "0.5.0" +version = "0.6.0" dependencies = [ "designcraft-doc", "serde", @@ -1009,7 +1009,7 @@ dependencies = [ [[package]] name = "designcraft-geom" -version = "0.5.0" +version = "0.6.0" dependencies = [ "flo_curves", "kurbo", @@ -1022,7 +1022,7 @@ dependencies = [ [[package]] name = "designcraft-idml" -version = "0.5.0" +version = "0.6.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1035,7 +1035,7 @@ dependencies = [ [[package]] name = "designcraft-images" -version = "0.5.0" +version = "0.6.0" dependencies = [ "designcraft-color", "designcraft-fonts", @@ -1046,7 +1046,7 @@ dependencies = [ [[package]] name = "designcraft-mcp" -version = "0.5.0" +version = "0.6.0" dependencies = [ "designcraft-engine", "designcraft-geom", @@ -1059,7 +1059,7 @@ dependencies = [ [[package]] name = "designcraft-pdf" -version = "0.5.0" +version = "0.6.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1081,7 +1081,7 @@ dependencies = [ [[package]] name = "designcraft-render" -version = "0.5.0" +version = "0.6.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1099,7 +1099,7 @@ dependencies = [ [[package]] name = "designcraft-textimport" -version = "0.5.0" +version = "0.6.0" dependencies = [ "designcraft-doc", "quick-xml 0.38.4", @@ -1108,7 +1108,7 @@ dependencies = [ [[package]] name = "designcraft-tools" -version = "0.5.0" +version = "0.6.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1120,7 +1120,7 @@ dependencies = [ [[package]] name = "designcraft-ui-egui" -version = "0.5.0" +version = "0.6.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1145,7 +1145,7 @@ dependencies = [ [[package]] name = "designcraft-web" -version = "0.5.0" +version = "0.6.0" dependencies = [ "designcraft-engine", "designcraft-format", @@ -5665,7 +5665,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.5.0" +version = "0.6.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index d69017d63..e08d33a1b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"] [workspace.package] -version = "0.5.0" +version = "0.6.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" diff --git a/README.md b/README.md index 7bff6310b..688e44dd5 100644 --- a/README.md +++ b/README.md @@ -221,6 +221,8 @@ Installers and executables are code-signed. | Fedora/RHEL/openSUSE | `designcraft-<ver>-linux-x86_64.rpm` | `designcraft-<ver>-linux-aarch64.rpm` | | | Tarball | `designcraft-<ver>-linux-x86_64.tar.gz` | `designcraft-<ver>-linux-aarch64.tar.gz` | Unpack anywhere | +RISC-V (riscv64): `designcraft-<ver>-linux-riscv64.tar.gz` only, cross-compiled; needs glibc 2.39+ (Ubuntu 24.04 or newer). + ### FreeBSD | Build | File | diff --git a/ROADMAP.md b/ROADMAP.md index 277e982ff..0d1c1ef5e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,8 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-10** · Version 0.6.0; releases add a Linux riscv64 tar.gz (cross-compiled, CLI + smoke-tested under QEMU). - **2026-10-10** · Readiness per audience: full ~47% (method aligned with the standard, was 42%), mainstream practitioner ~42%, essentials user ~54%, each with hours to ~95%. - **2026-10-10** · Stage re-normalized to **pre-alpha** under craftrules' core-workflow gate: text diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 6dc727edd..1b75d1d34 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -31,7 +31,7 @@ graphics driver loses the user entirely. Overall numbers: [target-app-parity.md] | macOS (Apple silicon, Intel) | ✓ | ✓ primary development platform | Intel GPU start failure (#334); window controls misaligned (#333); font managers (#327) | | Windows x64 | ✓ | ~ builds and installers ship | copy/paste and context menus (#351, #312), installer confusion (#64), launch failures (#273, #167) | | Windows ARM64 | ✓ | ~ build (CI workflow) | not exercised at runtime | -| Linux (x86_64, aarch64) | ✗ | ~ deb, AppImage, Flatpak | clipboard (#164), panels don't scroll (#267), CJK UI font (#332) | +| Linux (x86_64, aarch64; riscv64 tar.gz) | ✗ | ~ deb, AppImage, Flatpak | clipboard (#164), panels don't scroll (#267), CJK UI font (#332) | | FreeBSD | ✗ | ~ build | | | Web (WASM) | ✗ (InDesign on the web is not shipped) | ~ same UI, file picker, downloads; no control channel | | diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index c54750906..fd7fc4ef8 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Build and package DesignCraft for Linux (<arch> is x86_64 or aarch64): +# Build and package DesignCraft for Linux (<arch> is x86_64 or aarch64, or riscv64 when cross-compiling): # # $DIST/designcraft-<version>-linux-<arch>.AppImage any distro with glibc >= the build host's # $DIST/designcraft-<version>-linux-<arch>.AppImage.zsync delta updates (needs zsyncmake) @@ -30,8 +30,9 @@ while [ $# -gt 0 ]; do esac done -ARCH="$(uname -m)" +ARCH="${CROSS_ARCH:-$(uname -m)}" case "$ARCH" in + riscv64) DEB_ARCH=riscv64 ;; x86_64) DEB_ARCH=amd64 ;; aarch64 | arm64) ARCH=aarch64; DEB_ARCH=arm64 ;; *) echo "unsupported architecture $ARCH" >&2; exit 2 ;; @@ -42,9 +43,13 @@ BASENAME="designcraft-$VERSION-linux-$ARCH" echo "==> DesignCraft $VERSION for Linux $ARCH ($FORMATS)" if [ "$SKIP_BUILD" = 0 ]; then - (cd "$ROOT" && cargo build --release --locked -p designcraft -p designcraft-cli) + if [ -n "${CROSS_TARGET:-}" ]; then + (cd "$ROOT" && cargo build --release --locked -p designcraft -p designcraft-cli --target "$CROSS_TARGET") + else + (cd "$ROOT" && cargo build --release --locked -p designcraft -p designcraft-cli) + fi fi -BIN="$CARGO_TARGET_DIR/release" +BIN="$CARGO_TARGET_DIR/${CROSS_TARGET:+$CROSS_TARGET/}release" WORK="$CARGO_TARGET_DIR/linux-package" STAGE="$WORK/root" rm -rf "$WORK" @@ -52,7 +57,7 @@ rm -rf "$WORK" # ---- stage an FHS tree (shared by every format) ------------------------------------------------- install -Dm755 "$BIN/designcraft" "$STAGE/usr/bin/designcraft" install -Dm755 "$BIN/designcraft-cli" "$STAGE/usr/bin/designcraft-cli" -strip "$STAGE/usr/bin/designcraft" "$STAGE/usr/bin/designcraft-cli" 2>/dev/null || true +"${CROSS_COMPILE:-}strip" "$STAGE/usr/bin/designcraft" "$STAGE/usr/bin/designcraft-cli" 2>/dev/null || true install -Dm644 "$HERE/$APP_ID.desktop" "$STAGE/usr/share/applications/$APP_ID.desktop" install -Dm644 "$HERE/$APP_ID.mime.xml" "$STAGE/usr/share/mime/packages/$APP_ID.xml" mkdir -p "$STAGE/usr/share/metainfo" @@ -145,6 +150,10 @@ if has appimage; then fi fi -"$STAGE/usr/bin/designcraft-cli" --version +if [ -z "${CROSS_TARGET:-}" ]; then + "$STAGE/usr/bin/designcraft-cli" --version +elif [ -n "${EMULATOR:-}" ]; then + "$EMULATOR" "$STAGE/usr/bin/designcraft-cli" --version +fi echo "==> done" ls -lh "$DIST" From 2bdb0ea01d86cacfa3875968a36c2d4357727305 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick <hi@skylar.cc> Date: Sat, 10 Oct 2026 18:52:20 -0600 Subject: [PATCH 020/238] UI.9: recover saved docking metadata and declare frontend MSRV --- Cargo.toml | 2 +- apps/designcraft-web/Cargo.toml | 1 + apps/designcraft/Cargo.toml | 1 + apps/designcraft/src/main.rs | 2 +- crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/prefs.rs | 116 ++++++++++++++++++++++++++++++++ 7 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/src/prefs.rs diff --git a/Cargo.toml b/Cargo.toml index 05d701e56..35a1ed711 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtas version = "0.6.0" edition = "2024" license = "MIT OR Apache-2.0" -rust-version = "1.90" +rust-version = "1.95" repository = "https://github.com/storytold/designcraft" [workspace.dependencies] diff --git a/apps/designcraft-web/Cargo.toml b/apps/designcraft-web/Cargo.toml index 75775c31e..b1131c79c 100644 --- a/apps/designcraft-web/Cargo.toml +++ b/apps/designcraft-web/Cargo.toml @@ -3,6 +3,7 @@ name = "designcraft-web" description = "DesignCraft in the browser (eframe web runner, WebGPU with WebGL2 fallback)" version.workspace = true edition.workspace = true +rust-version.workspace = true license.workspace = true publish = false diff --git a/apps/designcraft/Cargo.toml b/apps/designcraft/Cargo.toml index 416b07406..13254e11e 100644 --- a/apps/designcraft/Cargo.toml +++ b/apps/designcraft/Cargo.toml @@ -2,6 +2,7 @@ name = "designcraft" version.workspace = true edition.workspace = true +rust-version.workspace = true license.workspace = true publish = false default-run = "designcraft" diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index d0241fc50..35a5686f4 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -83,7 +83,7 @@ fn load_prefs(app: &mut DesignApp) { } if let Some(p) = prefs_path() && let Ok(bytes) = std::fs::read(&p) - && let Ok(ui) = serde_json::from_slice::<designcraft_ui_egui::UiState>(&bytes) + && let Ok(ui) = designcraft_ui_egui::UiState::from_preferences_json(&bytes) { app.ui = ui; } diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 35a3ef0e8..92bff320c 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -2,6 +2,7 @@ name = "designcraft-ui-egui" version.workspace = true edition.workspace = true +rust-version.workspace = true license.workspace = true description = "DesignCraft egui frontend (InDesign-style UI); swappable — nothing below depends on it" diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5e1046d81..c003154ad 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -18,6 +18,7 @@ pub mod icons; pub mod menus; mod panel_docking; pub mod panels; +mod prefs; pub mod render_worker; mod rtl; pub mod story_editor; diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs new file mode 100644 index 000000000..cb5a0b54c --- /dev/null +++ b/crates/ui-egui/src/prefs.rs @@ -0,0 +1,116 @@ +//! Compatibility recovery at the saved-preferences boundary. Command deserialization stays strict. + +use craft_ui::docking::{Layout, Location}; +use serde_json::Value; + +use crate::UiState; + +impl UiState { + /// Read saved preferences, discarding only unreadable optional docking metadata. + /// A layout from a newer craft-ui version must not reset unrelated settings or named workspaces. + pub fn from_preferences_json(bytes: &[u8]) -> serde_json::Result<Self> { + let mut saved: Value = serde_json::from_slice(bytes)?; + recover_docking(&mut saved); + if let Some(workspaces) = saved.get_mut("customWorkspaces").and_then(Value::as_array_mut) { + for workspace in workspaces { + recover_docking(workspace); + } + } + serde_json::from_value(saved) + } +} + +fn recover_docking(saved: &mut Value) { + let Some(fields) = saved.as_object_mut() else { return }; + if fields.get("docking").is_some_and(|value| serde_json::from_value::<Option<Layout<String>>>(value.clone()).is_err()) { + fields.remove("docking"); + } + if let Some(hidden) = fields.get_mut("dockingHidden") { + if let Some(entries) = hidden.as_object_mut() { + entries.retain(|_, value| serde_json::from_value::<Location<String>>(value.clone()).is_ok()); + } else { + fields.remove("dockingHidden"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{DesignApp, SavedWorkspace}; + use serde_json::json; + + fn populated() -> UiState { + let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default()); + app.run("window.panel.move", json!({"panel":"swatches", "anchor":"layers", "zone":"top"})).unwrap(); + app.run("window.panel.close", json!({"panel":"properties"})).unwrap(); + app.ui.ui_scale = 1.5; + app.ui.language = "ja".into(); + app.ui.custom_workspaces = vec![ + SavedWorkspace { + name: "Print".into(), + docking: app.ui.docking.clone(), + docking_hidden: app.ui.docking_hidden.clone(), + ..Default::default() + }, + SavedWorkspace { name: "Proof".into(), docking: app.ui.docking.clone(), ..Default::default() }, + ]; + app.ui + } + + #[test] + fn saved_preferences_preserve_valid_layouts_and_hidden_origins() { + let original = serde_json::to_value(populated()).unwrap(); + let loaded = UiState::from_preferences_json(&serde_json::to_vec(&original).unwrap()).unwrap(); + assert_eq!(serde_json::to_value(loaded).unwrap(), original); + } + + #[test] + fn future_layouts_recover_independently_without_resetting_preferences_or_workspaces() { + let original = populated(); + for field in ["docking", "dockingHidden"] { + for invalid in [json!({"root":{"FutureLayout":{}}}), json!(false)] { + let mut saved = serde_json::to_value(&original).unwrap(); + saved[field] = invalid.clone(); + saved["customWorkspaces"][0][field] = invalid; + assert!(serde_json::from_value::<UiState>(saved.clone()).is_err(), "command parsing remains strict"); + let loaded = UiState::from_preferences_json(&serde_json::to_vec(&saved).unwrap()).unwrap(); + assert_eq!(loaded.ui_scale, 1.5); + assert_eq!(loaded.language, "ja"); + assert_eq!(loaded.custom_workspaces.len(), 2); + assert_eq!(loaded.custom_workspaces[0].name, "Print"); + assert_eq!(loaded.custom_workspaces[1].name, "Proof"); + assert_eq!(loaded.custom_workspaces[1].docking, original.custom_workspaces[1].docking); + if field == "docking" { + assert!(loaded.docking.is_none()); + assert!(loaded.custom_workspaces[0].docking.is_none()); + assert_eq!(loaded.docking_hidden, original.docking_hidden); + assert_eq!(loaded.custom_workspaces[0].docking_hidden, original.custom_workspaces[0].docking_hidden); + } else { + assert!(loaded.docking_hidden.is_empty()); + assert!(loaded.custom_workspaces[0].docking_hidden.is_empty()); + assert_eq!(loaded.docking, original.docking); + assert_eq!(loaded.custom_workspaces[0].docking, original.custom_workspaces[0].docking); + } + } + } + } + + #[test] + fn unreadable_hidden_origin_does_not_discard_other_origins() { + let original = populated(); + let mut saved = serde_json::to_value(&original).unwrap(); + saved["dockingHidden"]["futurePanel"] = json!({"FutureLocation":{}}); + saved["customWorkspaces"][0]["dockingHidden"]["futurePanel"] = json!(false); + let loaded = UiState::from_preferences_json(&serde_json::to_vec(&saved).unwrap()).unwrap(); + assert!(!original.docking_hidden.is_empty()); + assert_eq!(loaded.docking_hidden, original.docking_hidden); + assert_eq!(loaded.custom_workspaces[0].docking_hidden, original.custom_workspaces[0].docking_hidden); + } + + #[test] + fn unrelated_malformed_preferences_are_still_rejected() { + assert!(UiState::from_preferences_json(br#"{"uiScale":"invalid","docking":false}"#).is_err()); + assert!(UiState::from_preferences_json(br#"{"customWorkspaces":false}"#).is_err()); + } +} From 65f0b37920f75d8b443b00388a8b23846634e2c9 Mon Sep 17 00:00:00 2001 From: con3code <con3office@gmail.com> Date: Sun, 11 Oct 2026 10:00:17 +0900 Subject: [PATCH 021/238] Fonts: system_fonts_cataloged builds on wasm `cfg!` doesn't remove the expression, and `cataloged` only exists off wasm, so `cargo xtask wasm` failed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013phQ3ekq6uGBEWD6FjFYG2 --- crates/fonts/src/fontdb.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index c631f3020..60c5835c7 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -884,7 +884,10 @@ impl FontDb { /// Whether the installed fonts are cataloged: a lookup by family name won't wait for the /// scan (always true on wasm, which has none). See [`FontDb::scan_in_background`]. pub fn system_fonts_cataloged(&self) -> bool { - cfg!(target_arch = "wasm32") || self.cataloged.get().is_some() + #[cfg(target_arch = "wasm32")] + return true; + #[cfg(not(target_arch = "wasm32"))] + self.cataloged.get().is_some() } /// Catalog the installed fonts on a background thread, so the first lookup by family name From b522a0a3e8803de4bd91e1af6b5d1b651a1cdaaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wladmir=20J=C3=BAnior?= <wlad.w.junior@gmail.com> Date: Sun, 11 Oct 2026 00:44:04 -0300 Subject: [PATCH 022/238] pt-BR: complete the interface translation --- crates/ui-egui/src/i18n/pt_br.rs | 424 +++++++++++++++++++++++++++++++ 1 file changed, 424 insertions(+) diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index d25717dc6..1e1c6e612 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -1073,4 +1073,428 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Paragraph Rules…", "Linhas de parágrafo…"), ("Paragraph Rules", "Linhas de parágrafo"), ("Rule Below", "Linha abaixo"), + ("Can't Open the File", "Não foi possível abrir o arquivo"), + ("Can't Place the File", "Não foi possível inserir o arquivo"), + ("Story Direction", "Direção do texto"), + ("Tate-Chu-Yoko", "Tate-chu-yoko"), + ("Ruby…", "Ruby…"), + ("Kenten", "Kenten"), + ("Tabs", "Tabulações"), + ("Story", "Matéria"), + ("Create Outlines", "Criar contornos"), + ("Show Hidden Characters", "Mostrar caracteres ocultos"), + ("Unmerge Cells", "Desfazer mesclagem de células"), + ("Snap to Guides", "Alinhar às guias"), + ("Snap to Document Grid", "Alinhar à grade do documento"), + ("Align to Object Edges", "Alinhar às bordas dos objetos"), + ("Align to Object Centers", "Alinhar aos centros dos objetos"), + ("Smart Dimensions", "Dimensões inteligentes"), + ("Smart Spacing", "Espaçamento inteligente"), + ("Snap to Zone", "Alinhar à zona"), + ("Info", "Informações"), + ("Index", "Índice remissivo"), + ("Articles", "Artigos"), + ("Tags", "Tags"), + ("Object States", "Estados do objeto"), + ("Liquid Layout", "Layout líquido"), + ("Split Window", "Dividir janela"), + ("New Window", "Nova janela"), + ("CMYK", "CMYK"), + ("Lab", "Lab"), + ("OpenType", "OpenType"), + ("RGB", "RGB"), + ("Accept All Changes", "Aceitar todas as alterações"), + ("Activate Document", "Ativar documento"), + ("Add Anchor Point", "Adicionar ponto de ancoragem"), + ("Add Document", "Adicionar documento"), + ("Add Hyphenation Exception", "Adicionar exceção de hifenização"), + ("Add Item", "Adicionar item"), + ("Add Page to Spread", "Adicionar página ao spread"), + ("Allow Selected Spread to Shuffle", "Permitir que o spread selecionado seja reorganizado"), + ("Anchored Object Options…", "Opções de objeto ancorado…"), + ("Anchored Objects", "Objetos ancorados"), + ("Apply Attributes (Eyedropper)", "Aplicar atributos (conta-gotas)"), + ("Apply Cell Style", "Aplicar estilo de célula"), + ("Apply Condition", "Aplicar condição"), + ("Apply Parent to Pages…", "Aplicar página-mestre às páginas…"), + ("ArtCraft App Page…", "Página do aplicativo ArtCraft…"), + ("ArtCraft Website…", "Site do ArtCraft…"), + ("Attributes", "Atributos"), + ("Bevel and Emboss", "Chanfro e relevo"), + ("Buttons", "Botões"), + ("Cell Style Options…", "Opções de estilo de célula…"), + ("Change All (Object)", "Alterar tudo (objeto)"), + ("Changes", "Alterações"), + ("Character Formatting", "Formatação de caracteres"), + ("Clear Conveyor", "Limpar transportador"), + ("Clear Transformations", "Limpar transformações"), + ("Clipping Path…", "Traçado de recorte…"), + ("Close Book", "Fechar livro"), + ("Close Library", "Fechar biblioteca"), + ("Collect", "Coletar"), + ("Color Settings…", "Configurações de cor…"), + ("Community & Project Links", "Links da comunidade e do projeto"), + ("Container", "Contêiner"), + ("Content", "Conteúdo"), + ("Contextual Task Bar", "Barra de tarefas contextual"), + ("Control", "Controle"), + ("Convert Cell to Graphic Cell", "Converter célula em célula gráfica"), + ("Convert Cell to Graphic Cell…", "Converter célula em célula gráfica…"), + ("Convert Cell to Text Cell", "Converter célula em célula de texto"), + ("Convert Color", "Converter cor"), + ("Convert Direction Point", "Converter ponto de direção"), + ("Convert Note to Text", "Converter nota em texto"), + ("Convert Table to Text", "Converter tabela em texto"), + ("Convert Text to Table", "Converter texto em tabela"), + ("Convert to Button", "Converter em botão"), + ("Convert to Form Field", "Converter em campo de formulário"), + ("Copy Link(s) To…", "Copiar link(s) para…"), + ("Corner Options…", "Opções de canto…"), + ("Create Alternate Layout…", "Criar layout alternativo…"), + ("Create Frame", "Criar quadro"), + ("Create Merged Document…", "Criar documento mesclado…"), + ("Cross-Reference Formats", "Formatos de referência cruzada"), + ("Cross-References", "Referências cruzadas"), + ("Data Merge Fields", "Campos de mesclagem de dados"), + ("Define Cross-Reference Format…", "Definir formato de referência cruzada…"), + ("Define Lists…", "Definir listas…"), + ("Define Text Variable…", "Definir variável de texto…"), + ("Delete All Guides on Spread", "Excluir todas as guias do spread"), + ("Delete Anchor Point", "Excluir ponto de ancoragem"), + ("Delete Item", "Excluir item"), + ("Delete Text Variable", "Excluir variável de texto"), + ("Delete Workspace…", "Excluir espaço de trabalho…"), + ("DesignCraft Website…", "Site do DesignCraft…"), + ("DesignCraft on GitHub…", "DesignCraft no GitHub…"), + ("Detach All Objects from Parent", "Desvincular todos os objetos da página-mestre"), + ("Directional Feather", "Esmaecimento direcional"), + ("Distribute", "Distribuir"), + ("Dock Panel", "Encaixar painel"), + ("Document Endnote Options…", "Opções de notas de fim do documento…"), + ("Document Preferences", "Preferências do documento"), + ("Document Presets", "Predefinições de documento"), + ("Document…", "Documento…"), + ("Edit Parents", "Editar páginas-mestre"), + ("Edit Story", "Editar matéria"), + ("Endnotes", "Notas de fim"), + ("Entire Pasteboard", "Toda a mesa de trabalho"), + ("Erase", "Apagar"), + ("Export Book to PDF…", "Exportar livro para PDF…"), + ("Export EPUB (Reflowable)…", "Exportar EPUB (reflowable)…"), + ("Export Selection as Snippet", "Exportar seleção como snippet"), + ("Extend Text Selection", "Estender seleção de texto"), + ("Fast Display", "Exibição rápida"), + ("Find Object", "Localizar objeto"), + ("Find by Script Label", "Localizar por rótulo de script"), + ("First Object Above", "Primeiro objeto acima"), + ("Fit Selection in Window", "Ajustar seleção à janela"), + ("Flattener Preview", "Visualização do achatamento"), + ("Float Panel", "Flutuar painel"), + ("Fonts in Document", "Fontes no documento"), + ("Form Fields", "Campos de formulário"), + ("Frame Fitting Options…", "Opções de ajuste de quadro…"), + ("Generate Index…", "Gerar índice remissivo…"), + ("Generate QR Code…", "Gerar código QR…"), + ("Generate Static Caption", "Gerar legenda estática"), + ("Get Story", "Obter matéria"), + ("Get Table", "Obter tabela"), + ("Global Light…", "Luz global…"), + ("Go To Link", "Ir para o link"), + ("Go To Source", "Ir para a origem"), + ("Gradient Feather", "Esmaecimento em gradiente"), + ("Gridify", "Converter em grade"), + ("Group Transparency", "Transparência do grupo"), + ("Hide Menu Item", "Ocultar item de menu"), + ("Hide/Lock Others", "Ocultar/bloquear outros"), + ("High Quality Display", "Exibição em alta qualidade"), + ("History", "Histórico"), + ("Import Options: Styles", "Opções de importação: estilos"), + ("Index Page References", "Referências de página do índice remissivo"), + ("Inner Glow", "Brilho interno"), + ("Inner Shadow", "Sombra interna"), + ("Insert Anchored Object…", "Inserir objeto ancorado…"), + ("Insert Cross-Reference…", "Inserir referência cruzada…"), + ("Insert Endnote", "Inserir nota de fim"), + ("Insert Math Expression…", "Inserir expressão matemática…"), + ("Insert Pages…", "Inserir páginas…"), + ("Insert Variable", "Inserir variável"), + ("Inspect Document", "Inspecionar documento"), + ("Interface Color Theme", "Tema de cores da interface"), + ("Join the ArtCraft Discord…", "Entrar no Discord do ArtCraft…"), + ("Last Object Below", "Último objeto abaixo"), + ("Library Contents", "Conteúdo da biblioteca"), + ("Library Items", "Itens da biblioteca"), + ("Liquid Page Rule", "Regra de página líquida"), + ("List Documents", "Listar documentos"), + ("List Styles", "Listar estilos"), + ("List Tools", "Listar ferramentas"), + ("Load Place Cursor", "Carregar cursor de inserção"), + ("Make Compound Path", "Criar traçado composto"), + ("Map Styles to Tags", "Mapear estilos para tags"), + ("Math Expression SVG", "SVG de expressão matemática"), + ("Media Options", "Opções de mídia"), + ("Menus…", "Menus…"), + ("Merge Layers", "Mesclar camadas"), + ("Move Caret", "Mover cursor de texto"), + ("Move Gap", "Mover espaço"), + ("Move Row", "Mover linha"), + ("New Book", "Novo livro"), + ("New Cell Style…", "Novo estilo de célula…"), + ("New Color Swatch…", "Nova amostra de cor…"), + ("New Condition…", "Nova condição…"), + ("New Guide", "Nova guia"), + ("New Hyperlink Destination…", "Novo destino de hiperlink…"), + ("New Hyperlink…", "Novo hiperlink…"), + ("New Library", "Nova biblioteca"), + ("New Page Reference…", "Nova referência de página…"), + ("New Stroke Style…", "Novo estilo de traço…"), + ("New Table Style…", "Novo estilo de tabela…"), + ("Next Cell", "Próxima célula"), + ("Next Document", "Próximo documento"), + ("Next Object Above", "Próximo objeto acima"), + ("Next Object Below", "Próximo objeto abaixo"), + ("Next Object in Group", "Próximo objeto no grupo"), + ("Object Export Options", "Opções de exportação de objeto"), + ("Object Export Options…", "Opções de exportação de objeto…"), + ("Object Layer Options…", "Opções de camada de objeto…"), + ("Object Layers", "Camadas de objeto"), + ("Object Liquid Settings", "Configurações líquidas do objeto"), + ("Open Book", "Abrir livro"), + ("Open Bytes", "Abrir bytes"), + ("Open IDML", "Abrir IDML"), + ("Open Library", "Abrir biblioteca"), + ("Outer Glow", "Brilho externo"), + ("Overprint Preview", "Visualização de sobreimpressão"), + ("Override All Parent Page Items", "Substituir todos os itens da página-mestre"), + ("Page Transition", "Transição de página"), + ("Page Transitions", "Transições de página"), + ("Paragraph Formatting", "Formatação de parágrafo"), + ("Paste Into", "Colar dentro"), + ("Paste without Formatting", "Colar sem formatação"), + ("Place Caret", "Posicionar cursor de texto"), + ("Place Item", "Inserir item"), + ("Place Snippet", "Inserir snippet"), + ("Place and Link", "Inserir e vincular"), + ("Polygon Settings…", "Configurações de polígono…"), + ("Preflight Document", "Verificar documento (preflight)"), + ("Previous Cell", "Célula anterior"), + ("Previous Document", "Documento anterior"), + ("Previous Object in Group", "Objeto anterior no grupo"), + ("Printers", "Impressoras"), + ("Proof Colors", "Cores de prova"), + ("Proof Setup", "Configuração de prova"), + ("Quick Apply…", "Aplicação rápida…"), + ("Recover Documents", "Recuperar documentos"), + ("Recovery Data", "Dados de recuperação"), + ("Reject All Changes", "Rejeitar todas as alterações"), + ("Release Compound Path", "Liberar traçado composto"), + ("Remove All Local Overrides", "Remover todas as substituições locais"), + ("Remove Document", "Remover documento"), + ("Report an Issue…", "Relatar um problema…"), + ("Reset Shortcuts", "Redefinir atalhos"), + ("Reset Workspace", "Redefinir espaço de trabalho"), + ("Resize", "Redimensionar"), + ("Rotate Spread", "Girar spread"), + ("Run Script", "Executar script"), + ("Sample Document", "Documento de exemplo"), + ("Satin", "Cetim"), + ("Save Recovery Data", "Salvar dados de recuperação"), + ("Script Label", "Rótulo de script"), + ("Select All on Layer", "Selecionar tudo na camada"), + ("Select Cells", "Selecionar células"), + ("Select Column", "Selecionar coluna"), + ("Select Frame", "Selecionar quadro"), + ("Select Row", "Selecionar linha"), + ("Select Table", "Selecionar tabela"), + ("Select Text", "Selecionar texto"), + ("Select Tool", "Selecionar ferramenta"), + ("Select Word", "Selecionar palavra"), + ("Selection Attributes", "Atributos da seleção"), + ("Separations Preview", "Visualização de separações"), + ("Serialize", "Serializar"), + ("Set Active Layer", "Definir camada ativa"), + ("Set Shortcut", "Definir atalho"), + ("Set Visibility / Lock", "Definir visibilidade / bloqueio"), + ("Show Panel", "Mostrar painel"), + ("Show/Hide Baseline Grid", "Mostrar/ocultar grade de linha de base"), + ("Show/Hide Frame Edges", "Mostrar/ocultar bordas dos quadros"), + ("Show/Hide Guides", "Mostrar/ocultar guias"), + ("Show/Hide Hidden Characters", "Mostrar/ocultar caracteres ocultos"), + ("Show/Hide Panels", "Mostrar/ocultar painéis"), + ("Show/Hide Panels Except Tools", "Mostrar/ocultar painéis, exceto ferramentas"), + ("Show/Hide Rulers", "Mostrar/ocultar réguas"), + ("Show/Hide Tagged Frames", "Mostrar/ocultar quadros marcados"), + ("Show/Hide Text Threads", "Mostrar/ocultar encadeamentos de texto"), + ("Smooth", "Suavizar"), + ("Sort", "Ordenar"), + ("Split Path", "Dividir traçado"), + ("Step and Repeat…", "Passo e repetição…"), + ("Stroke Styles", "Estilos de traço"), + ("Style Source", "Origem do estilo"), + ("Synchronize Book", "Sincronizar livro"), + ("Table Panel", "Painel Tabela"), + ("Table Style Options…", "Opções de estilo de tabela…"), + ("Table of Contents Entries", "Entradas do sumário"), + ("Table of Contents…", "Sumário…"), + ("Text Anchors", "Âncoras de texto"), + ("Text Variables", "Variáveis de texto"), + ("Toggle Normal/Preview", "Alternar entre normal e visualização"), + ("Toggle Selection", "Alternar seleção"), + ("Tools: Double Column", "Ferramentas: coluna dupla"), + ("Transform Panel", "Painel Transformar"), + ("Transform Values", "Valores de transformação"), + ("Transparency Blend Space", "Espaço de mesclagem de transparência"), + ("Transparency Flattener Presets", "Predefinições de achatamento de transparência"), + ("Type on a Path", "Texto em um traçado"), + ("Type on a Path Options…", "Opções de texto em um traçado…"), + ("Typical Display", "Exibição típica"), + ("Update Link", "Atualizar link"), + ("User Dictionary Words", "Palavras do dicionário do usuário"), + ("User Dictionary…", "Dicionário do usuário…"), + ("Zoom To", "Zoom para"), + ("⇧", "⇧"), + ("⌃", "⌃"), + ("⌘", "⌘"), + ("⌥", "⌥"), + ("Accept Change", "Aceitar alteração"), + ("Add Anchor", "Adicionar âncora"), + ("Add Selection to Article", "Adicionar seleção ao artigo"), + ("Add Unnamed Colors", "Adicionar cores sem nome"), + ("Add to Dictionary", "Adicionar ao dicionário"), + ("Align Center", "Alinhar ao centro"), + ("Align Left", "Alinhar à esquerda"), + ("Align Right", "Alinhar à direita"), + ("Apply Character Style", "Aplicar estilo de caractere"), + ("Apply Object Style", "Aplicar estilo de objeto"), + ("Apply Paragraph Style", "Aplicar estilo de parágrafo"), + ("Apply Table Style", "Aplicar estilo de tabela"), + ("Article Options", "Opções de artigo"), + ("Basic Feather", "Esmaecimento básico"), + ("Bold", "Negrito"), + ("Character Style Options…", "Opções de estilo de caractere…"), + ("Close Path", "Fechar traçado"), + ("Color Group Options", "Opções de grupo de cores"), + ("Condition Options…", "Opções de condição…"), + ("Convert to Liquid Guide", "Converter em guia líquida"), + ("Convert to Object", "Converter em objeto"), + ("Create Line", "Criar linha"), + ("Create Path", "Criar traçado"), + ("Decrease Point Size", "Diminuir tamanho em pontos"), + ("Delete Article", "Excluir artigo"), + ("Delete Bookmark", "Excluir marcador"), + ("Delete Endnote", "Excluir nota de fim"), + ("Delete Footnote", "Excluir nota de rodapé"), + ("Delete Guide", "Excluir guia"), + ("Delete Hyperlink", "Excluir hiperlink"), + ("Delete List", "Excluir lista"), + ("Delete Note", "Excluir nota"), + ("Delete Pages", "Excluir páginas"), + ("Delete Paragraph Style", "Excluir estilo de parágrafo"), + ("Delete Stroke Style", "Excluir estilo de traço"), + ("Delete Table", "Excluir tabela"), + ("Delete Tag", "Excluir tag"), + ("Delete Text", "Excluir texto"), + ("Distribute Columns Evenly", "Distribuir colunas uniformemente"), + ("Edit Endnote", "Editar nota de fim"), + ("Edit Note", "Editar nota"), + ("Embed Link", "Incorporar link"), + ("Flip", "Inverter"), + ("Hyperlink Options…", "Opções de hiperlink…"), + ("Increase Point Size", "Aumentar tamanho em pontos"), + ("Insert Column Left", "Inserir coluna à esquerda"), + ("Insert Column Right", "Inserir coluna à direita"), + ("Insert Row Above", "Inserir linha acima"), + ("Insert Row Below", "Inserir linha abaixo"), + ("Italic", "Itálico"), + ("Justify Left", "Justificar à esquerda"), + ("Layer Options…", "Opções de camada…"), + ("Move Anchors", "Mover âncoras"), + ("Move Column", "Mover coluna"), + ("Move Guide", "Mover guia"), + ("Move Layer", "Mover camada"), + ("Move Pages…", "Mover páginas…"), + ("Move to Layer", "Mover para a camada"), + ("New Article", "Novo artigo"), + ("New Bookmark", "Novo marcador"), + ("New Layer…", "Nova camada…"), + ("New Note", "Nova nota"), + ("New Object Style…", "Novo estilo de objeto…"), + ("New Parent…", "Nova página-mestre…"), + ("New Tag", "Nova tag"), + ("Paste in Place", "Colar no mesmo lugar"), + ("Place Loaded Graphic", "Inserir gráfico carregado"), + ("Reject Change", "Rejeitar alteração"), + ("Release State to Objects", "Liberar estado para objetos"), + ("Remove Hyphenation Exception", "Remover exceção de hifenização"), + ("Remove from Article", "Remover do artigo"), + ("Rename", "Renomear"), + ("Rename Bookmark", "Renomear marcador"), + ("Rotate", "Girar"), + ("Scale", "Escalar"), + ("Set Cross-Reference Format", "Definir formato de referência cruzada"), + ("Set Footnote Text", "Definir texto da nota de rodapé"), + ("Set Story Text", "Definir texto da matéria"), + ("Shear", "Distorcer"), + ("Show All on Spread", "Mostrar tudo no spread"), + ("Show State", "Mostrar estado"), + ("State Options", "Opções de estado"), + ("Tag Frame", "Marcar quadro"), + ("Tag Text", "Marcar texto"), + ("Unlink", "Desvincular"), + ("Update Index", "Atualizar índice remissivo"), + ("Update Table of Contents", "Atualizar sumário"), + ("Bulleted and Numbered Lists", "Listas com marcadores e numeradas"), + ("Hyperlinks Cross-References", "Hiperlinks e referências cruzadas"), + ("Document Footnote Options...", "Opções de notas de rodapé do documento..."), + ("Anchored Object", "Objeto ancorado"), + ("Apply Attributes (Eyedropper", "Aplicar atributos (conta-gotas"), + ("Captions", "Legendas"), + ("Change All (Object", "Alterar tudo (objeto"), + ("Clipping Path", "Traçado de recorte"), + ("Convert Cell Type", "Converter tipo de célula"), + ("Copy Link(s", "Copiar link(s"), + ("DesignCraft", "DesignCraft"), + ("Export EPUB (Fixed Layout", "Exportar EPUB (layout fixo"), + ("Export EPUB (Reflowable", "Exportar EPUB (reflowable"), + ("Hyperlinks & Cross-References", "Hiperlinks e referências cruzadas"), + ("Hyphens and Dashes", "Hifens e travessões"), + ("Markers", "Marcadores"), + ("Other", "Outros"), + ("Output", "Saída"), + ("Quotation Marks", "Aspas"), + ("Symbols", "Símbolos"), + ("Type & Tables", "Texto e tabelas"), + ("Utilities", "Utilitários"), + ("Korean Line Breaks:", "Quebras de linha em coreano:"), + ("Between syllables (not only at spaces)", "Entre sílabas (não apenas nos espaços)"), + ("Show Font Names in English", "Mostrar nomes de fontes em inglês"), + ("Missing Glyphs", "Glifos ausentes"), + ("Draw Missing Glyphs from Fallback Fonts", "Desenhar glifos ausentes com fontes substitutas"), + ("No data source", "Nenhuma fonte de dados"), + ("Select Data Source…", "Selecionar fonte de dados…"), + ("Data Merge", "Mesclagem de dados"), + ("Previous", "Anterior"), + ("Next", "Próximo"), + ("Record", "Registro"), + ("Merge options", "Opções de mesclagem"), + ("All records", "Todos os registros"), + ("One record", "Um registro"), + ("Range", "Intervalo"), + ("Records per page", "Registros por página"), + ("Single record", "Registro único"), + ("Multiple records", "Vários registros"), + ("Rows first", "Linhas primeiro"), + ("Columns first", "Colunas primeiro"), + ("Column spacing", "Espaçamento entre colunas"), + ("Row spacing", "Espaçamento entre linhas"), + ("Fit proportionally", "Ajustar proporcionalmente"), + ("Fill proportionally", "Preencher proporcionalmente"), + ("Fit content to frame", "Ajustar conteúdo ao quadro"), + ("Center in frame", "Centralizar no quadro"), + ("Link images", "Vincular imagens"), + ("Limit", "Limite"), + ("Bind to frame", "Vincular ao quadro"), + ("Insert field", "Inserir campo"), + ("Pass csv, rows, or bytes from the control channel.", "Passe csv, linhas ou bytes pelo canal de controle."), + ("Panels", "Painéis"), ]; From 9f7da3f6abcce29186af9a8660158e60419d069d Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick <hi@skylar.cc> Date: Sun, 11 Oct 2026 07:29:54 +0200 Subject: [PATCH 023/238] M6: Adopt craft-print for CUPS and Windows PDF printing --- Cargo.lock | 9 ++ Cargo.toml | 1 + ROADMAP.md | 4 +- crates/engine/Cargo.toml | 1 + crates/engine/src/cmd/print.rs | 148 +++++++++++++-------------------- docs/file-format-parity.md | 6 +- docs/gaps.md | 8 +- docs/hardware-parity.md | 9 +- docs/roadmap.md | 4 +- docs/target-app-parity.md | 10 +-- 10 files changed, 92 insertions(+), 108 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7ec5bff86..2c71f6b6c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -796,6 +796,14 @@ dependencies = [ "libm", ] +[[package]] +name = "craft-print" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=c22bb1b0ce6316f84973cb69675fe7331c5886cc#c22bb1b0ce6316f84973cb69675fe7331c5886cc" +dependencies = [ + "tempfile", +] + [[package]] name = "craft-ui" version = "0.1.0" @@ -956,6 +964,7 @@ name = "designcraft-engine" version = "0.6.0" dependencies = [ "caseless", + "craft-print", "designcraft-color", "designcraft-compose", "designcraft-doc", diff --git a/Cargo.toml b/Cargo.toml index 35a1ed711..87d27cadc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ rust-version = "1.95" repository = "https://github.com/storytold/designcraft" [workspace.dependencies] +craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "c22bb1b0ce6316f84973cb69675fe7331c5886cc" } # internal designcraft-geom = { path = "crates/geom" } designcraft-color = { path = "crates/color" } diff --git a/ROADMAP.md b/ROADMAP.md index 9a0f71e70..8aa7c03ae 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **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-10 · **Change:** minor (stage re-normalized to pre-alpha by the core-workflow gate; earlier today: major, full re-measure against InDesign 2026 21.6; restructured to the craftrules progress-docs standard) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared print spooler; native Windows validation pending) · **Target:** Adobe InDesign 2026 DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. @@ -129,6 +129,8 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-11 (printing):** `file.printers` and `file.print` use craft-libs/craft-print for CUPS and Windows. DesignCraft retains PDF export, page selection, spreads, marks and bleed; dry runs create no temporary PDF or print job. Windows keeps sheet size/orientation and corrected duplex binding edges. Headless tests exercise exported bytes, job settings, dry runs and refusals; shared native Windows regression tests are present, with execution pending. PostScript, production print controls and physical output validation remain open. No readiness percentage change. + - **2026-10-10 (UI.4–UI.5):** Panel tabs can move into other groups, split the dock, float, resize and return to their saved locations through shared docking transactions. Layouts persist in custom workspaces; UI and control commands share the same validated action path. Shared pane geometry and widget-state theme application retain all five palettes and local fonts. Split panes send keyboard input only to the focused canvas and scope contextual toolbar IDs per pane. No readiness percentage change. - **2026-10-10** · Version 0.6.0; releases add a Linux riscv64 tar.gz (cross-compiled, CLI smoke-tested under QEMU). diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 8b4604f11..62578ba2f 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -9,6 +9,7 @@ description = "DesignCraft engine: session, documents, history, selection, comma workspace = true [dependencies] +craft-print = { workspace = true } designcraft-images = { workspace = true } designcraft-geom = { workspace = true } designcraft-color = { workspace = true } diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 724feb2be..20dc26f23 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -1,7 +1,4 @@ -//! File › Print: the pages go to a printer through the system print spooler (`lpr`), as PDF. - -#[cfg(not(target_arch = "wasm32"))] -use std::io::Write; +//! File › Print: the pages go to a printer through the system print spooler (craft-print), as PDF. use serde_json::{Value, json}; @@ -20,91 +17,55 @@ pub fn specs() -> Vec<CommandSpec> { "Print…", ["File"], Some("Cmd+P"), - "{printer?, copies?: 1, pages?: \"1-3,5\", spreads?, marks?, bleed?, dryRun?: bool (only return the spool command)} → {printer, copies, pages, command}", + "{printer?, copies?: 1, pages?: \"1-3,5\", spreads?, marks?, bleed?, dryRun?: bool (export and preview submission without printing)} → {printer, copies, pages, command, bytes}", has_doc, print ), ] } -#[cfg(not(target_arch = "wasm32"))] fn printers() -> Value { - let run = |args: &[&str]| { - std::process::Command::new("lpstat").args(args).output().ok().map(|o| String::from_utf8_lossy(&o.stdout).to_string()).unwrap_or_default() - }; - let names: Vec<String> = run(&["-e"]).lines().map(str::trim).filter(|l| !l.is_empty()).map(str::to_string).collect(); - // "system default destination: NAME" - let default = run(&["-d"]).rsplit(':').next().map(str::trim).filter(|s| !s.is_empty() && !s.contains("no system default")).map(str::to_string); + let printers = craft_print::printers(); + let default = printers.iter().find(|p| p.default).map(|p| p.name.clone()); + let names: Vec<_> = printers.into_iter().map(|p| p.name).collect(); json!({"printers": names, "default": default}) } -#[cfg(target_arch = "wasm32")] -fn printers() -> Value { - json!({"printers": [], "default": null}) -} - fn print(s: &mut Session, p: &Value) -> Result<Value> { - let d = &s.doc()?.doc; - let opts = super::export::options(p, d.page_count())?; #[cfg(target_arch = "wasm32")] { - // Even a dry run cannot construct a native spool command in the browser. - let _ = opts; + super::export::options(p, s.doc()?.doc.page_count())?; Err(bad("file.print", "printing isn't available on the web: export a PDF and print it")) } #[cfg(not(target_arch = "wasm32"))] - { - let copies = p.get("copies").and_then(Value::as_u64).unwrap_or(1).clamp(1, 999); - let printer = str_param(p, "printer").map(str::to_string); - let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false); - let pdf = designcraft_pdf::export_pdf(d, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?; - let mut file = secure_print_file()?; - let file_path = file.path().to_path_buf(); - let mut cmd: Vec<String> = vec!["lpr".into()]; - if let Some(pr) = &printer { - cmd.extend(["-P".into(), pr.clone()]); - } - if copies > 1 { - cmd.extend(["-#".into(), copies.to_string()]); - } - cmd.extend(["-T".into(), d.title.clone(), file_path.to_string_lossy().to_string()]); - let pages = opts.pages.as_ref().map_or(d.page_count(), |v| v.len()); - let out = json!({"printer": printer, "copies": copies, "pages": pages, "command": cmd, "bytes": pdf.len()}); - if dry { - return Ok(out); - } - file.as_file_mut().write_all(&pdf).map_err(|e| EngineError::Other(format!("{}: {e}", file.path().display())))?; - file.as_file_mut().flush().map_err(|e| EngineError::Other(format!("{}: {e}", file.path().display())))?; - let st = std::process::Command::new(&cmd[0]).args(&cmd[1..]).output().map_err(|e| bad("file.print", format!("lpr: {e}")))?; - if !st.status.success() { - return Err(bad("file.print", String::from_utf8_lossy(&st.stderr).trim().to_string())); - } - Ok(out) - } + print_with(s, p, craft_print::submit) } #[cfg(not(target_arch = "wasm32"))] -fn secure_print_file() -> Result<tempfile::NamedTempFile> { - let file = tempfile::Builder::new() - .prefix("designcraft-print-") - .suffix(".pdf") - .tempfile() - .map_err(|e| EngineError::Other(format!("temporary print file: {e}")))?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - file.as_file() - .set_permissions(std::fs::Permissions::from_mode(0o600)) - .map_err(|e| EngineError::Other(format!("{}: {e}", file.path().display())))?; +fn print_with( + s: &mut Session, + p: &Value, + submit: impl FnOnce(&[u8], &craft_print::Job) -> std::result::Result<String, craft_print::Error>, +) -> Result<Value> { + let d = &s.doc()?.doc; + let opts = super::export::options(p, d.page_count())?; + let copies = p.get("copies").and_then(Value::as_u64).unwrap_or(1).clamp(1, 999) as u32; + let job = + craft_print::Job { printer: str_param(p, "printer").map(str::to_string), copies, title: d.title.clone(), ..craft_print::Job::default() }; + let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false); + let pdf = designcraft_pdf::export_pdf(d, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?; + let command = craft_print::command_preview(&job).map_err(|e| bad("file.print", e.to_string()))?; + let pages = opts.pages.as_ref().map_or(d.page_count(), |v| v.len()); + let out = json!({"printer": job.printer, "copies": copies, "pages": pages, "command": command, "bytes": pdf.len()}); + if !dry { + submit(&pdf, &job).map_err(|e| bad("file.print", e.to_string()))?; } - Ok(file) + Ok(out) } #[cfg(all(test, not(target_arch = "wasm32")))] mod tests { - use serde_json::json; - - use crate::Session; + use super::*; #[test] fn print_builds_the_spool_command() { @@ -112,37 +73,42 @@ mod tests { s.execute("file.new", &json!({"pages": 4})).unwrap(); let r = s.execute("file.print", &json!({"printer": "Office", "copies": 2, "pages": "2-3", "dryRun": true})).unwrap(); assert_eq!(r["pages"], 2); - let cmd: Vec<&str> = r["command"].as_array().unwrap().iter().filter_map(|v| v.as_str()).collect(); - assert_eq!(&cmd[..5], ["lpr", "-P", "Office", "-#", "2"]); - assert!(cmd.last().unwrap().ends_with(".pdf")); - assert!(!std::path::Path::new(cmd.last().unwrap()).exists()); + let command: Vec<&str> = r["command"].as_array().unwrap().iter().filter_map(Value::as_str).collect(); + if craft_print::backend() == Some(craft_print::Backend::Cups) { + assert_eq!(&command[..5], ["lp", "-d", "Office", "-n", "2"]); + assert!(command.contains(&"fit-to-page=false")); + } else { + assert_eq!(command[0], "powershell"); + } assert!(r["bytes"].as_u64().unwrap() > 500); assert!(s.execute("file.print", &json!({"pages": "9", "dryRun": true})).is_err()); assert!(s.execute("file.printers", &json!({})).unwrap()["printers"].is_array()); } - #[cfg(not(target_arch = "wasm32"))] #[test] - fn print_uses_distinct_ephemeral_files() { - let first = super::secure_print_file().unwrap(); - let second = super::secure_print_file().unwrap(); - let first_path = first.path().to_path_buf(); - let second_path = second.path().to_path_buf(); - assert_ne!(first_path, second_path); - drop((first, second)); - assert!(!first_path.exists()); - assert!(!second_path.exists()); - } - - #[cfg(all(not(target_arch = "wasm32"), unix))] - #[test] - fn print_file_is_private_and_removed_on_drop() { - use std::os::unix::fs::PermissionsExt; - - let file = super::secure_print_file().unwrap(); - let path = file.path().to_path_buf(); - assert_eq!(file.as_file().metadata().unwrap().permissions().mode() & 0o777, 0o600); - drop(file); - assert!(!path.exists()); + fn print_submits_the_selected_export_and_reports_refusal() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 4})).unwrap(); + let params = json!({"printer": "Office (east)", "copies": 2, "pages": "2-3", "marks": true, "bleed": true}); + let expected = { + let d = &s.doc().unwrap().doc; + let options = super::super::export::options(¶ms, d.page_count()).unwrap(); + designcraft_pdf::export_pdf(d, &s.cache, &options).unwrap() + }; + let r = print_with(&mut s, ¶ms, |pdf, job| { + // The production export options and full bytes reach the shared spooler boundary. + assert_eq!(pdf, expected); + assert!(pdf.starts_with(b"%PDF-")); + assert_eq!(job.printer.as_deref(), Some("Office (east)")); + assert_eq!(job.copies, 2); + assert!(!job.title.is_empty()); + Ok("request id is Office-1".into()) + }) + .unwrap(); + assert_eq!(r["pages"], 2); + let refusal = print_with(&mut s, ¶ms, |_, _| Err(craft_print::Error::Spool("printer offline".into()))).unwrap_err(); + assert!(refusal.to_string().contains("printer offline")); + print_with(&mut s, &json!({"dryRun": true}), |_, _| panic!("dry runs must not spool")).unwrap(); + assert!(print_with(&mut s, &json!({"pages": "9"}), |_, _| panic!("invalid ranges must not spool")).is_err()); } } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 90777577e..4bc9a402d 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first version: every format InDesign 2026 declares, measured against the code) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared print spooler; native Windows validation pending) · **Target:** Adobe InDesign 2026 (21.6) Every format InDesign 2026 reads or writes, with DesignCraft's support, fidelity and tests. The InDesign list comes from its bundle's `Info.plist` (`CFBundleDocumentTypes`, @@ -46,7 +46,7 @@ score below). | EPS export | `.eps` | ✓ | ✗ | | <1% | | Text Only / RTF / Tagged Text | `.txt`, `.rtf` | ✓ | ✓ (`file.exportText` txt, rtf, tagged) | | 1% | | XML | `.xml` | ✓ | ✓ export and import, DTD validate | depth unmeasured | 1% | -| Print / PostScript | | ✓ printers, PPDs, separations | ~ PDF to `lpr` (no Windows, no PostScript) | | 3% | +| Print / PostScript | | ✓ printers, PPDs, separations | ~ PDF to CUPS/Windows (craft-print; no PostScript) | | 3% | | Package | folder | ✓ fonts, links, IDML, PDF, report | ✓ (#340: IDML inside links to absolute paths) | | 2% | ## Placed and imported content @@ -97,7 +97,7 @@ for real work** (estimated). INDD alone moves this dimension by up to 30 points. | INDD import via a clean-room converter (owner decision, #114) | 20–60 | | IDML real-file corpus, metric, fixes (#193, #338, #339, #340) | 60–100 | | PDF/X-1a and X-3 (transparency flattener), PDF presets and `.joboptions`, external validator | 40–70 | -| Print path (Windows, PostScript level 3 output or system print APIs) | 25–45 | +| Print fidelity, PostScript level 3 output and print presets | 25–45 | | ICML story import, IDMS snippets, INDT templates (once INDD reads) | 15–25 | | HTML / fixed-layout EPUB with live text | 20–35 | | JPEG export dialog, SVG export, EPS export | 10–20 | diff --git a/docs/gaps.md b/docs/gaps.md index b8be72673..f9a40d4c3 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-10 · **Change:** minor (stage re-normalized to pre-alpha by the core-workflow gate; earlier today: major, first version: gaps from the full re-measure and the issue tracker) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared print spooler; native Windows validation pending) · **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. @@ -117,8 +117,10 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · - **Estimate:** 40–70 h. **Doc:** [file-format-parity.md](file-format-parity.md) ### 10. Printing (H, F) -- **Missing:** a real print path: `file.print` sends PDF to `lpr`, so Windows can't print; no - PostScript, printer PPDs, separations to device, print presets, or trapping. +- **Missing:** PostScript, a printer-driver options UI, separations to device, print presets, or trapping. +- **Have:** `file.print` exports selected pages/spreads with marks and bleed, then submits through + craft-print: CUPS on macOS/Linux and the Windows PDF raster spooler. Physical printer fidelity + remains a hardware check. - **Estimate:** 25–45 h. **Doc:** [hardware-parity.md](hardware-parity.md) ### 11. Paragraph and character style editing reliability (F) diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 1b75d1d34..98fd44d0e 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware and platform parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first version) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared print spooler; native Windows validation pending) · **Target:** Adobe InDesign 2026 (21.6) What InDesign does with hardware, per platform, against DesignCraft. Layout is not a GPU-hungry workload, so this dimension carries little weight, but a start that fails on a @@ -19,7 +19,7 @@ graphics driver loses the user entirely. Overall numbers: [target-app-parity.md] | HiDPI / Retina | ✓ | ✓ | ✓ | ✓ | ✓ | | UI scaling | ✓ | ✓ (menus and dialogs usable at larger scales, #281) | ✓ | ✓ | ✓ | | Multi-monitor, floating document windows | ✓ | ~ one window; New Window for the same document | ~ | ~ | ✗ | -| Printing | system print with PPDs, PostScript, separations | PDF to `lpr` | ✗ (no `lpr`) | PDF to `lpr` (CUPS) | browser download | +| Printing | system print with PPDs, PostScript, separations | PDF to CUPS (`craft-print`) | Windows PDF raster spooler (`craft-print`) | PDF to CUPS (`craft-print`) | browser download | | Colour-managed display (monitor profile) | ✓ | ✗ (working CMYK profile simulated; monitor profile not applied) | ✗ | ✗ | ✗ | | Touch / pen | Touch workspace (Windows), gestures | trackpad pinch/scroll | ~ | ~ | ~ | | Multi-core composition | single-threaded composer per story (documented by Adobe as mostly single-threaded) | parallel story composition (rayon), 6.7× on the sample | ✓ | ✓ | single-threaded | @@ -43,7 +43,7 @@ the developers don't use daily. | Work | Opus 5.5 h | |---|---| | Start-up robustness on every GPU: confirm #297 with reporters, Intel Metal fix, CPU-only present fallback | 15–30 | -| Printing on Windows (system print API) and print presets | 15–30 (counted in file-format-parity.md) | +| Printing fidelity, PostScript and print presets | 15–30 (counted in file-format-parity.md) | | Monitor profile in display colour management | 5–10 | | Windows and Linux runtime smoke tests in CI | 20–40 | | Multi-window document views | 15–30 | @@ -54,3 +54,6 @@ the developers don't use daily. | Date | Change | Summary | |---|---|---| | 2026-10-10 | major | Created from the code, InDesign 2026's GPU Performance features and the issue tracker | + +Windows submission now uses shared craft-print. It preserves PDF sheet size/orientation and +duplex binding edges; printer-driver options UI and physical output validation remain open. diff --git a/docs/roadmap.md b/docs/roadmap.md index e738da950..d80fe7aaa 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # DesignCraft roadmap (detail) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (stage re-normalized to pre-alpha by the core-workflow gate; earlier today: major, milestones re-assessed; beta plan from the full re-measure) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (stage re-normalized to pre-alpha by the core-workflow gate; earlier today: major, milestones re-assessed; beta plan from the full re-measure) · **Target:** Adobe InDesign 2026 (21.6) Forward-looking plan. The one-page summary is [ROADMAP.md](../ROADMAP.md); numbers are in [target-app-parity.md](target-app-parity.md); the work list is [gaps.md](gaps.md). Hours are @@ -88,7 +88,7 @@ that is ~28 points and **420–750 h** (~120–200 wall-clock hours with four to | Start-up robustness, fonts | 5, 6 | 25–50 | | CI, Windows/Linux runtime tests | 7, 14 | 35–70 | | IME | 8 | 10–20 | -| PDF/X-1a/X-3, presets, print on Windows | 9, 10 | 65–110 | +| PDF/X-1a/X-3, presets, physical print validation | 9, 10 | 65–110 | | Styles, tables, missing menu items (the most-used half) | 11, 12, 16 | 60–100 | | Long documents (Books) | 15 | 30–50 | | **Total** | | **420–750** | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8044ed51b..ba66f898a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # DesignCraft parity with Adobe InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (stage re-normalized to pre-alpha by the core-workflow gate; earlier today: major, full re-measure against InDesign 2026 21.6; replaces the 2026-10-04 estimate in ROADMAP.md) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared print spooler; native Windows validation pending) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) The authoritative assessment of how close DesignCraft is to Adobe InDesign. [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) lists every known shortfall one at a time; the feature-by-feature @@ -66,7 +66,7 @@ they receive, setting type exactly, and basic editing that never fails. | Performance | 5% | 65% | 40–80 | 300k characters compose in ~38 ms warm; typing repaints in < 1 ms (damage regions); no incremental composition; nothing benchmarked against InDesign; no large real documents | [gaps.md](gaps.md) | | Localization | 3% | 45% | 120–180 | 9 translations beyond English; Japanese 95%, most others 83–85%, pt-BR 61%; Hindi, Indonesian, Korean, Vietnamese none | [localization-parity.md](localization-parity.md) | | Platforms | 3% | 55% | 40–80 | macOS is where it's tested; Windows and Linux users hit basic failures; web and FreeBSD builds exist | [hardware-parity.md](hardware-parity.md) | -| Hardware | 2% | 45% | 20–40 | Canvas rasterizes on the CPU (vello_cpu, multithreaded); GPU only presents; printing is `lpr` (no Windows printing, no PostScript) | [hardware-parity.md](hardware-parity.md) | +| Hardware | 2% | 45% | 20–40 | Canvas rasterizes on the CPU (vello_cpu, multithreaded); GPU only presents; printing uses shared CUPS/Windows PDF spoolers (no PostScript; physical output needs validation) | [hardware-parity.md](hardware-parity.md) | | Ecosystem (scripting, plug-ins) | 2% | 15% | 150–250 | Own command/script/MCP API is strong; no ExtendScript/UXP DOM, so InDesign scripts don't run; no plug-ins | [gaps.md](gaps.md) | | AI features | 0% (not weighted) | 15% | 60–120 | Agent control through MCP goes beyond InDesign; no generative features (InDesign 2026: Generative Expand, Text to Image, Auto Style) | [gaps.md](gaps.md) | | **Weighted sum** | 100% | **47.2% → ~47%** (range 42–52%) | **1,150–1,950** | | | @@ -117,7 +117,7 @@ twice. | Tables | 5% | 55% | anchored objects in cells, Table Options submenus | | Layers | 3% | 75% | | | Effects and transparency | 3% | 50% | fidelity unmeasured | -| Output: PDF export, print, package | 8% | 60% | PDF/X-4 strong; no X-1a, presets, Windows printing | +| Output: PDF export, print, package | 8% | 60% | PDF/X-4 strong; no X-1a or presets; physical print validation pending | | View, navigation, undo, save | 3% | 70% | | | **Weighted depth** | 100% | **60.7%** | | @@ -152,7 +152,7 @@ features), exchange edge cases, and everything excluded above. | Undo / redo | 5% | 85% | | | Save and reopen | 5% | 85% | `.designcraft` round trip, recovery | | Export PDF / PNG | 10% | 80% | | -| Print | 5% | 40% | PDF to `lpr` only, nothing on Windows | +| Print | 5% | 40% | PDF to CUPS/Windows via craft-print; physical output validation pending | | **Weighted depth** | 100% | **72.0%** | | | Discount | Factor | Evidence | @@ -217,7 +217,7 @@ table is the one summed. | Tables | 100% | 55% | 25–40 | anchored objects in cells not drawn (#163); Table ▸ Table Options submenus, Diagonal Lines, Convert Rows, Edit Header/Footer | | Long documents | 100% | 45% | 30–50 | Books are shallow (sync, book pagination, book PDF/TOC/index); Table of Contents Styles | | Interactivity & digital | 100% | 40% | 30–50 | HTML export basic (#314); fixed-layout EPUB is page images; no animation, timing, media playback | -| Output & production | 92% | 55% | 40–70 | PDF/X-4 only (no X-1a/X-3), no PDF/print presets or `.joboptions`, print via `lpr` (no Windows), no trapping; Output Preview panel | +| Output & production | 92% | 55% | 40–70 | PDF/X-4 only (no X-1a/X-3), no PDF/print presets or `.joboptions`, print via craft-print (CUPS/Windows), no trapping; Output Preview panel | | XML & automation | 100% | 35% | (ecosystem dimension) | no ExtendScript/UXP; XML structure depth | | View & navigation | 100% | 65% | 10–20 | Navigator, display badges, Show/Hide extras (content grabber, live corners) | | Undo, history, saving | 100% | 70% | 5–10 | Undo/Redo name the action; File ▸ Open Recent | From 0a9bfa57352e4872562487415ab1e3e4bf688f50 Mon Sep 17 00:00:00 2001 From: noah1400 <48360809+noah1400@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:09:26 +0200 Subject: [PATCH 024/238] UI: Alt + wheel zooms the view at the pointer (#470) The wheel turned over a document view with Alt (Option) held scrolled the view like the plain wheel. It now zooms at the pointer, exactly as Ctrl/Cmd + wheel does. As with that zoom, a wheel motion keeps its key to its end: letting Alt go while a trackpad swipe coasts on does not turn the rest into scrolling. --- crates/ui-egui/src/canvas.rs | 107 +++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4c7313e61..f4a4bf438 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -181,6 +181,28 @@ pub fn set_zoom(app: &mut DesignApp, z: f64) { zoom_at(app, rect.center(), z / cur); } +/// Whether the wheel motion now arriving was made with Alt down. As egui does for Ctrl/Cmd, a +/// motion keeps its key to its end: the wheel is smoothed over a few frames, and a trackpad swipe +/// coasts on after the key is let go. +fn wheel_alt(ui: &egui::Ui) -> bool { + let id = egui::Id::new("canvas_wheel_alt"); + // Alt, and whether a trackpad swipe is under way. + let (mut alt, mut swipe): (bool, bool) = ui.data(|d| d.get_temp(id)).unwrap_or_default(); + ui.input(|i| { + for e in &i.events { + if let egui::Event::MouseWheel { phase, modifiers, .. } = e { + (alt, swipe) = match phase { + egui::TouchPhase::Start => (modifiers.alt, true), + egui::TouchPhase::Move => (modifiers.alt || (swipe && alt), swipe), + egui::TouchPhase::End | egui::TouchPhase::Cancel => (false, false), + }; + } + } + }); + ui.data_mut(|d| d.insert_temp(id, (alt, swipe))); + alt +} + pub fn apply_view_request(app: &mut DesignApp, p: &Value) { // Power Zoom (Hand tool, Alt-press): zoom out to the spread, aim a view-sized rectangle, zoom // back in where it was released. @@ -1347,6 +1369,7 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r let wants_text = app.session.wants_text(); let space = ui.input(|i| i.key_down(egui::Key::Space)) && !wants_text && !ui.ctx().text_edit_focused(); // Scroll / zoom. + let alt = wheel_alt(ui); if resp.hovered() { let (scroll, zoom_delta, m, hover) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.modifiers, i.pointer.hover_pos())); if let Some(hp) = hover { @@ -1354,6 +1377,10 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r zoom_at(app, hp, zoom_delta as f64); } else if m.command && scroll.y != 0.0 { zoom_at(app, hp, (1.0 + scroll.y as f64 * 0.003).clamp(0.5, 2.0)); + } else if alt && scroll != egui::Vec2::ZERO { + // Alt + wheel zooms by as much as Ctrl/Cmd + wheel, which egui turns into `zoom_delta`. + let speed = ui.ctx().options(|o| o.input_options.scroll_zoom_speed); + zoom_at(app, hp, (speed * (scroll.x + scroll.y)).exp() as f64); } else if scroll != egui::Vec2::ZERO && let Some(v) = app.view_mut() { @@ -1767,6 +1794,86 @@ mod tests { h.run_steps(3); } + fn new_document() -> Harness<'static, DesignApp> { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + harness(app) + } + + fn held(modifiers: egui::Modifiers) -> egui::Event { + egui::Event::ModifiersChanged(modifiers) + } + + fn wheel(phase: egui::TouchPhase, up: f32, modifiers: egui::Modifiers) -> egui::Event { + egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(0.0, up), phase, modifiers } + } + + // Plays `events` with the pointer at `at`, a frame each, and lets the motion settle. + fn play(h: &mut Harness<'_, DesignApp>, at: Pos2, events: &[egui::Event]) -> View { + h.hover_at(at); + for event in events { + h.event(event.clone()); + h.step(); + } + h.run_steps(30); + *h.state().view().unwrap() + } + + /// Alt + wheel scrolled the view like the plain wheel; it zooms at the pointer (#470). + #[test] + fn alt_wheel_zooms_at_the_pointer() { + use egui::{Modifiers, TouchPhase::Move}; + let mut h = new_document(); + let rect = h.state().canvas_rect.unwrap(); + let at = rect.center() + vec2(150.0, 90.0); + let before = *h.state().view().unwrap(); + let under = Xf::new(rect, &before).to_canvas(at); + let zoomed = play(&mut h, at, &[held(Modifiers::ALT), wheel(Move, 100.0, Modifiers::ALT)]); + assert!(zoomed.zoom > before.zoom * 1.2, "wheel up zooms in: {} -> {}", before.zoom, zoomed.zoom); + let still = Xf::new(rect, &zoomed).to_canvas(at); + assert!((still.x - under.x).abs() < 0.01 && (still.y - under.y).abs() < 0.01, "the point under the pointer stays: {under:?} -> {still:?}"); + let back = play(&mut h, at, &[held(Modifiers::ALT), wheel(Move, -100.0, Modifiers::ALT)]); + assert!((back.zoom - before.zoom).abs() < 1e-6, "wheel down zooms out: {} -> {}", zoomed.zoom, back.zoom); + // The plain wheel still scrolls. + let scrolled = play(&mut h, at, &[held(Modifiers::NONE), wheel(Move, 100.0, Modifiers::NONE)]); + assert!(scrolled.zoom == back.zoom && scrolled.origin.y < back.origin.y, "{back:?} -> {scrolled:?}"); + } + + /// Alt zooms exactly as Ctrl/Cmd does, also when the key is let go while the wheel's motion is + /// still arriving: the rest of it zooms too and does not scroll the page. + #[test] + fn alt_wheel_zooms_like_ctrl_wheel_to_the_end_of_the_motion() { + use egui::{ + Modifiers, + TouchPhase::{End, Move, Start}, + }; + const NONE: Modifiers = Modifiers::NONE; + // Each motion in steps; the views are compared after every step. + let motions: [fn(Modifiers) -> Vec<Vec<egui::Event>>; 4] = [ + |key| vec![vec![held(key), wheel(Move, 100.0, key)]], + // A wheel turned fast, the key let go a frame later. + |key| vec![vec![held(key), wheel(Move, 400.0, key), held(NONE)]], + // A trackpad swipe that coasts on after the key is let go; once it has ended, the wheel scrolls. + |key| { + vec![ + vec![held(key), wheel(Start, 0.0, key), wheel(Move, 50.0, key), held(NONE), wheel(Move, 200.0, NONE)], + vec![wheel(End, 0.0, NONE), wheel(Move, 100.0, NONE)], + ] + }, + |key| vec![vec![held(key | Modifiers::SHIFT), wheel(Move, 100.0, key | Modifiers::SHIFT)]], + ]; + for (i, motion) in motions.iter().enumerate() { + let (mut with_alt, mut with_command) = (new_document(), new_document()); + let at = with_alt.state().canvas_rect.unwrap().center() + vec2(150.0, 90.0); + let start = *with_alt.state().view().unwrap(); + for (alt, command) in motion(Modifiers::ALT).iter().zip(motion(Modifiers::COMMAND)) { + let (alt, command) = (play(&mut with_alt, at, alt), play(&mut with_command, at, &command)); + assert!(command.zoom > start.zoom, "motion {i} zooms"); + assert_eq!(alt, command, "motion {i}"); + } + } + } + fn units(h: &Harness<'_, DesignApp>) -> (Unit, Unit) { let s = &h.state().session.doc().unwrap().doc.settings; (s.horizontal_units, s.vertical_units) From cde814c0dff8aaa5321b5fe4cccf9fd9f53d9d3d Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Sun, 11 Oct 2026 12:24:00 +0000 Subject: [PATCH 025/238] Selection tool: Alt and Shift resize around the centre and keep side-handle drags centred --- crates/tools/src/select.rs | 71 +++++++++++++++++--------------------- crates/tools/src/tests.rs | 25 ++++++++++++++ 2 files changed, 57 insertions(+), 39 deletions(-) diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 8f59c3503..babaa4712 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -178,12 +178,18 @@ fn resize_edges_rect(from: Rect, handle: usize, p: Point) -> Rect { } fn finish_resize(from: Rect, handle: usize, rect: Rect, m: Mods) -> Rect { - let rect = if m.shift && from.width() > 1e-9 && from.height() > 1e-9 { - apply_aspect(from, handle, rect, aspect_scale(from, handle, rect)) - } else { - rect - }; - if m.alt { apply_alt(from, handle, rect) } else { rect } + // Alt first: Shift then measures the drag from the centre and keeps it there. + let rect = if m.alt { apply_alt(from, handle, rect) } else { rect }; + if !(m.shift && from.width() > 1e-9 && from.height() > 1e-9) { + return rect; + } + let s = aspect_scale(from, handle, rect); + if m.alt { centered_rect(from, from.width() * s, from.height() * s) } else { apply_aspect(from, handle, rect, s) } +} + +fn centered_rect(from: Rect, w: f64, h: f64) -> Rect { + let c = from.center(); + Rect::new(c.x - w / 2.0, c.y - h / 2.0, c.x + w / 2.0, c.y + h / 2.0) } fn aspect_scale(from: Rect, handle: usize, rect: Rect) -> f64 { @@ -214,31 +220,17 @@ fn drives_x(handle: usize, from: Rect, rect: Rect) -> bool { sx > sy } +/// Scales `from` by `s` with the edges opposite the handle fixed. An axis the handle doesn't move stays centred. fn apply_aspect(from: Rect, handle: usize, rect: Rect, s: f64) -> Rect { - let (mut x0, mut y0, mut x1, mut y1) = (rect.x0, rect.y0, rect.x1, rect.y1); - let (w, h) = (from.width() * s, from.height() * s); - match handle { - 0 => { - x0 = x1 - w; - y0 = y1 - h; - } - 2 => { - x1 = x0 + w; - y0 = y1 - h; - } - 3..=5 => { - x1 = x0 + w; - y1 = y0 + h; - } - 6 | 7 => { - x0 = x1 - w; - y1 = y0 + h; - } - _ => { - x0 = x1 - w; - y0 = y1 - h; - } - } + let (x_edges, y_edges) = resize_edge_flags(handle); + let span = |edges: [bool; 3], lo: f64, hi: f64, mid: f64, len: f64| match edges { + [true, _, _] => (hi - len, hi), + [_, _, true] => (lo, lo + len), + _ => (mid - len / 2.0, mid + len / 2.0), + }; + let c = from.center(); + let (x0, x1) = span(x_edges, rect.x0, rect.x1, c.x, from.width() * s); + let (y0, y1) = span(y_edges, rect.y0, rect.y1, c.y, from.height() * s); Rect::new(x0, y0, x1, y1) } @@ -285,8 +277,7 @@ fn finish_snapped_resize( if !(w.is_finite() && h.is_finite()) { return proposed; } - let c = from.center(); - Rect::new(c.x - w / 2.0, c.y - h / 2.0, c.x + w / 2.0, c.y + h / 2.0) + centered_rect(from, w, h) } /// Length match: add the difference once. Position match: the flagged edge moves and the other side mirrors. @@ -313,16 +304,18 @@ fn axis_size_delta(edges: [bool; 3], delta: f64) -> f64 { } } +/// Mirrors the dragged sides around the centre of `from`. Shrinking and crossing the centre follow the pointer. fn apply_alt(from: Rect, handle: usize, rect: Rect) -> Rect { - let (x0, y0, x1, y1) = (rect.x0, rect.y0, rect.x1, rect.y1); + let (x_edges, y_edges) = resize_edge_flags(handle); let c = from.center(); - let (hw, hh) = ((x1 - x0) / 2.0, (y1 - y0) / 2.0); - let (hw, hh) = match handle { - 1 | 5 => (from.width() / 2.0, (if handle == 1 { c.y - y0 } else { y1 - c.y })), - 3 | 7 => ((if handle == 7 { c.x - x0 } else { x1 - c.x }), from.height() / 2.0), - _ => (hw.abs().max((c.x - x0).abs()).max((x1 - c.x).abs()), hh.abs().max((c.y - y0).abs()).max((y1 - c.y).abs())), + let size = |edges: [bool; 3], lo: f64, hi: f64, mid: f64, len: f64| match edges { + [true, _, _] => 2.0 * (mid - lo), + [_, _, true] => 2.0 * (hi - mid), + _ => len, }; - Rect::new(c.x - hw, c.y - hh, c.x + hw, c.y + hh) + let w = size(x_edges, rect.x0, rect.x1, c.x, from.width()); + let h = size(y_edges, rect.y0, rect.y1, c.y, from.height()); + centered_rect(from, w, h) } /// Left/center/right and top/center/bottom for a handle. Centers stay off. diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 6c75b0da6..f62a400aa 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -60,6 +60,31 @@ fn resize_rect_modifiers() { assert_eq!(c, Rect::new(-50.0, 0.0, 150.0, 50.0)); } +#[test] +fn resize_side_shift_keeps_the_other_axis_centred() { + let r = Rect::new(0.0, 0.0, 100.0, 50.0); + let shift = Mods { shift: true, ..Default::default() }; + assert_eq!(select::resize_rect(r, 3, Point::new(150.0, 25.0), shift), Rect::new(0.0, -12.5, 150.0, 62.5)); + assert_eq!(select::resize_rect(r, 5, Point::new(50.0, 100.0), shift), Rect::new(-50.0, 0.0, 150.0, 100.0)); +} + +#[test] +fn resize_corner_alt_shift_scales_around_the_centre() { + let r = Rect::new(0.0, 0.0, 100.0, 50.0); + let mods = Mods { shift: true, alt: true, ..Default::default() }; + // From the centre the pointer is at 0.2 by 0.6; the larger factor drives and the corner stays on y = 40. + assert_eq!(select::resize_rect(r, 4, Point::new(60.0, 40.0), mods), Rect::new(20.0, 10.0, 80.0, 40.0)); + assert_eq!(select::resize_rect(r, 0, Point::new(-50.0, 10.0), mods), Rect::new(-50.0, -25.0, 150.0, 75.0)); +} + +#[test] +fn resize_corner_alt_mirrors_the_opposite_side() { + let r = Rect::new(0.0, 0.0, 100.0, 50.0); + let alt = Mods { alt: true, ..Default::default() }; + assert_eq!(select::resize_rect(r, 0, Point::new(10.0, 5.0), alt), Rect::new(10.0, 5.0, 90.0, 45.0)); + assert_eq!(select::resize_rect(r, 4, Point::new(120.0, 60.0), alt), Rect::new(-20.0, -10.0, 120.0, 60.0)); +} + #[test] fn cmd_shift_click_overrides_parent_item() { let mut d = Document::new(&NewDocument::default()); From 2077d9f088dc41bde6b201b098a596ded61fafd7 Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Sun, 11 Oct 2026 14:09:14 +0000 Subject: [PATCH 026/238] UI: show Tamil, Indic, Southeast Asian and other scripts from installed fonts --- crates/ui-egui/src/theme.rs | 135 ++++++++++++++++++++++++++++-------- 1 file changed, 108 insertions(+), 27 deletions(-) diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 14918008f..c0a2376fb 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -256,7 +256,7 @@ impl Tokens { /// Install the UI fonts for the interface language `lang` (it orders the CJK fallbacks). /// Returns false while the installed fonts are still being cataloged: the built-in faces are /// installed without waiting, and the caller installs again once they are (see -/// [`system_fonts_ready`]) to add the installed CJK faces. +/// [`system_fonts_ready`]) to add the installed faces for the scripts they lack. pub fn install_fonts(ctx: &egui::Context, lang: &str) -> bool { let craft = designcraft_fonts::CRAFT_FONTS; let mut defs = font_definitions(craft, lang); @@ -275,7 +275,7 @@ pub fn system_fonts_ready() -> bool { /// An installed font the UI falls back to for a script the built-in faces lack. pub(crate) struct SystemFont { - /// "Jpan", "Hans", "Hant" or "Kore". + /// The ISO 15924 code of the script it is for ("Jpan", "Kore", "Taml"…). script: &'static str, family: String, /// The face as egui takes it, on the UI face's baseline. Shared: installing the fonts again @@ -290,11 +290,14 @@ impl SystemFont { } } -/// Installed UI faces (sans serif) per script, the first one found of each list: macOS, then -/// Windows, then Linux families. -const SYSTEM_UI_FONTS: &[(&str, &[&str])] = &[ +/// Installed UI faces (sans serif) per script, with a character every face of the script has: +/// the first listed family that is installed and covers the character, macOS families first, +/// then Windows, then Linux. One family can serve several scripts (Nirmala UI covers the Indic +/// scripts); it is added once. +const SYSTEM_UI_FONTS: &[(&str, char, &[&str])] = &[ ( "Jpan", + 'あ', &[ "Hiragino Sans", "Hiragino Kaku Gothic ProN", @@ -309,16 +312,36 @@ const SYSTEM_UI_FONTS: &[(&str, &[&str])] = &[ "Droid Sans Fallback", ], ), - ("Hans", &["PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", "Source Han Sans SC", "WenQuanYi Micro Hei"]), + ("Hans", '中', &["PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", "Source Han Sans SC", "WenQuanYi Micro Hei"]), // Traditional Chinese and Korean: font menus list those families under their native names. - ("Hant", &["PingFang TC", "Microsoft JhengHei", "Noto Sans CJK TC", "Source Han Sans TC"]), - ("Kore", &["Apple SD Gothic Neo", "Malgun Gothic", "Noto Sans CJK KR", "Noto Sans KR", "Source Han Sans K", "NanumGothic", "UnDotum"]), + ("Hant", '中', &["PingFang TC", "Microsoft JhengHei", "Noto Sans CJK TC", "Source Han Sans TC"]), + ("Kore", '한', &["Apple SD Gothic Neo", "Malgun Gothic", "Noto Sans CJK KR", "Noto Sans KR", "Source Han Sans K", "NanumGothic", "UnDotum"]), + // Scripts the built-in faces lack, after the CJK ones (text in the Story Editor, font names). + ("Taml", 'த', &["Tamil Sangam MN", "Tamil MN", "Nirmala UI", "Latha", "Noto Sans Tamil", "Noto Sans Tamil UI", "Lohit Tamil"]), + ("Deva", 'क', &["Kohinoor Devanagari", "Devanagari Sangam MN", "Nirmala UI", "Mangal", "Noto Sans Devanagari", "Lohit Devanagari"]), + ("Beng", 'ক', &["Kohinoor Bangla", "Bangla Sangam MN", "Nirmala UI", "Vrinda", "Noto Sans Bengali", "Lohit Bengali"]), + ("Gujr", 'ક', &["Kohinoor Gujarati", "Gujarati Sangam MN", "Nirmala UI", "Shruti", "Noto Sans Gujarati", "Lohit Gujarati"]), + ("Guru", 'ਕ', &["Gurmukhi Sangam MN", "Gurmukhi MN", "Nirmala UI", "Raavi", "Noto Sans Gurmukhi", "Lohit Gurmukhi"]), + ("Knda", 'ಕ', &["Kannada Sangam MN", "Nirmala UI", "Tunga", "Noto Sans Kannada", "Lohit Kannada"]), + ("Mlym", 'ക', &["Malayalam Sangam MN", "Nirmala UI", "Kartika", "Noto Sans Malayalam", "Lohit Malayalam"]), + ("Telu", 'క', &["Kohinoor Telugu", "Telugu Sangam MN", "Nirmala UI", "Gautami", "Noto Sans Telugu", "Lohit Telugu"]), + ("Orya", 'କ', &["Oriya Sangam MN", "Nirmala UI", "Kalinga", "Noto Sans Oriya", "Lohit Odia"]), + ("Sinh", 'ක', &["Sinhala Sangam MN", "Nirmala UI", "Iskoola Pota", "Noto Sans Sinhala", "LKLUG"]), + ("Thai", 'ก', &["Thonburi", "Leelawadee UI", "Tahoma", "Noto Sans Thai", "Loma"]), + ("Khmr", 'ក', &["Khmer Sangam MN", "Leelawadee UI", "Khmer UI", "Noto Sans Khmer", "Khmer OS"]), + ("Laoo", 'ກ', &["Lao Sangam MN", "Leelawadee UI", "Lao UI", "Noto Sans Lao", "Phetsarath OT"]), + ("Mymr", 'က', &["Myanmar Sangam MN", "Myanmar Text", "Noto Sans Myanmar", "Padauk"]), + ("Hebr", 'א', &["Arial Hebrew", "Segoe UI", "Noto Sans Hebrew", "DejaVu Sans"]), + ("Armn", 'Ա', &["Mshtakan", "Segoe UI", "Sylfaen", "Noto Sans Armenian", "DejaVu Sans"]), + ("Geor", 'ა', &["Segoe UI", "Sylfaen", "Noto Sans Georgian", "DejaVu Sans"]), + ("Ethi", 'አ', &["Kefa", "Ebrima", "Nyala", "Noto Sans Ethiopic", "Abyssinica SIL"]), ]; -/// The installed faces for the CJK scripts `craft` (the built-in craft-fonts) doesn't cover, so -/// the interface shows Japanese and Chinese without the craft-fonts build input, and Korean and -/// Traditional Chinese (font names in the font menus) in any build. egui can't -/// find system fonts itself; the document font scan (not on the web) does. +/// The installed faces for the scripts `craft` (the built-in craft-fonts) doesn't cover, so the +/// interface shows Japanese and Chinese without the craft-fonts build input, and Korean, +/// Traditional Chinese (font names in the font menus), Indic, Southeast Asian and other scripts +/// in any build. egui can't find system fonts itself; the document font scan (not on the web) +/// does. fn system_ui_fonts(craft: &[designcraft_fonts::CraftFont]) -> Option<&'static [SystemFont]> { static FACES: std::sync::OnceLock<Vec<SystemFont>> = std::sync::OnceLock::new(); let db = designcraft_fonts::FontDb::global(); @@ -329,19 +352,30 @@ fn system_ui_fonts(craft: &[designcraft_fonts::CraftFont]) -> Option<&'static [S db.scan_in_background(); return None; } - Some(FACES.get_or_init(|| { - SYSTEM_UI_FONTS - .iter() - .filter(|(script, _)| !craft.iter().any(|f| f.scripts.contains(script))) - .filter_map(|(script, families)| { - let family = families.iter().find(|f| db.has_family(f))?; - let face = db.face(family, "Regular"); - // `face` falls back to another family when this one can't be read: keep only the real one. - (face.family.eq_ignore_ascii_case(family) && !face.data().is_empty()) - .then(|| SystemFont::new(script, (*family).to_string(), face.data().to_vec(), face.index())) - }) - .collect() - })) + Some(FACES.get_or_init(|| installed_ui_fonts(db, craft))) +} + +/// The faces of [`SYSTEM_UI_FONTS`] that `db` has, for the scripts `craft` doesn't cover. +fn installed_ui_fonts(db: &designcraft_fonts::FontDb, craft: &[designcraft_fonts::CraftFont]) -> Vec<SystemFont> { + let mut faces: Vec<SystemFont> = Vec::new(); + for (script, sample, families) in SYSTEM_UI_FONTS { + if craft.iter().any(|f| f.scripts.contains(script)) { + continue; + } + let found = families.iter().find_map(|family| { + if !db.has_family(family) { + return None; + } + let face = db.face(family, "Regular"); + // `face` falls back to another family when this one can't be read: keep only the real one. + (face.family.eq_ignore_ascii_case(family) && !face.data().is_empty() && face.covers(*sample)).then_some((family, face)) + }); + let Some((family, face)) = found else { continue }; + if !faces.iter().any(|f| f.family.eq_ignore_ascii_case(family)) { + faces.push(SystemFont::new(script, (*family).to_string(), face.data().to_vec(), face.index())); + } + } + faces } /// The UI face every family starts with (Source Sans 3; the semibold shares its line metrics). @@ -376,7 +410,8 @@ fn cjk_script(lang: &str) -> &'static str { /// Add installed faces `system` to every family in the interface language's order: the faces of /// its script (`cjk_script`) go before the built-in CJK faces, so Han characters take that -/// language's forms (Traditional Chinese, Korean); the others go at the end. +/// language's forms (Traditional Chinese, Korean); the other CJK faces go at the end, then the +/// other scripts' faces. pub(crate) fn add_system_fallbacks(fonts: &mut FontDefinitions, system: &[SystemFont], lang: &str) { let name = |f: &SystemFont| format!("system-{}", f.family); for f in system { @@ -384,10 +419,12 @@ pub(crate) fn add_system_fallbacks(fonts: &mut FontDefinitions, system: &[System } let first = cjk_script(lang); let preferred: Vec<String> = system.iter().filter(|f| f.script == first).map(name).collect(); - let rest: Vec<String> = ["Jpan", "Hans", "Hant", "Kore"] + const CJK: [&str; 4] = ["Jpan", "Hans", "Hant", "Kore"]; + let rest: Vec<String> = CJK .iter() .filter(|s| **s != first) .flat_map(|s| system.iter().filter(move |f| f.script == *s)) + .chain(system.iter().filter(|f| !CJK.contains(&f.script))) .map(name) .collect(); for (family, stack) in fonts.families.iter_mut() { @@ -642,6 +679,50 @@ mod japanese_font_tests { } } + /// Tamil, Devanagari and Thai text get glyphs from installed faces, found by family and + /// checked for coverage, after the CJK faces. Synthetic fonts in a folder of their own stand + /// in for the machine's. + #[test] + fn installed_faces_give_other_scripts_glyphs_after_the_cjk_faces() { + let dir = std::env::temp_dir().join(format!("dc-ui-script-fonts-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let fonts = [ + ("Noto Sans CJK JP", &['あ', '日'][..]), + // Listed first for Tamil but without Tamil glyphs: passed over. + ("Tamil Sangam MN", &['a'][..]), + // One face for two scripts is added once. + ("Nirmala UI", &['த', 'க', 'क'][..]), + ("Noto Sans Thai", &['ก'][..]), + ]; + for (i, (family, chars)) in fonts.iter().enumerate() { + std::fs::write(dir.join(format!("font{i}.ttf")), designcraft_fonts::testing::font_with(family, chars).unwrap()).unwrap(); + } + let db = designcraft_fonts::FontDb::with_font_dirs(vec![dir.clone()]); + let system = super::installed_ui_fonts(&db, &[]); + let _ = std::fs::remove_dir_all(&dir); + let picked: Vec<(&str, &str)> = system.iter().map(|f| (f.script, f.family.as_str())).collect(); + assert_eq!(picked, [("Jpan", "Noto Sans CJK JP"), ("Taml", "Nirmala UI"), ("Thai", "Noto Sans Thai")]); + + let mut defs = super::font_definitions(&[], "ja"); + defs.families.entry(egui::FontFamily::Proportional).or_default().push("craft-Stand-in".into()); + super::add_system_fallbacks(&mut defs, &system, "ja"); + let stack = &defs.families[&egui::FontFamily::Proportional]; + let at = |n: &str| stack.iter().position(|x| x == n).unwrap(); + assert!(at("system-Noto Sans CJK JP") < at("craft-Stand-in"), "{stack:?}"); + assert!(at("craft-Stand-in") < at("system-Nirmala UI") && at("system-Nirmala UI") < at("system-Noto Sans Thai"), "{stack:?}"); + defs.families.entry(egui::FontFamily::Proportional).or_default().retain(|n| n != "craft-Stand-in"); + let ctx = egui::Context::default(); + ctx.set_fonts(defs); + let mut output = ctx.run_ui(egui::RawInput::default(), |_| {}); + output.textures_delta.clear(); + ctx.fonts_mut(|fonts| { + let font = egui::FontId::new(13.0, egui::FontFamily::Proportional); + for ch in ['த', 'क', 'ก'] { + assert!(fonts.has_glyph(&font, ch), "missing {ch}"); + } + }); + } + #[test] fn ui_works_without_craft_fonts() { let ctx = ctx_with(&[]); From ca343e8985dc4d92dd65cf77a8198723b5f8a9eb Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" <dylan@dylanmtaylor.com> Date: Sun, 11 Oct 2026 11:52:22 -0400 Subject: [PATCH 027/238] ci: refactor workflows to use storytold/craft-common-actions (#489) * ci: refactor workflows to use reusable artcraft-common-actions * ci: point reusable workflows to storytold/craft-common-actions * ci: pin storytold/craft-common-actions to commit 27a978f and pass secrets explicitly * ci: use @main (unpinned) for craft-common-actions with explanation comment --- .github/workflows/freebsd.yml | 39 +- .github/workflows/packaging-lint.yml | 53 +-- .github/workflows/release.yml | 511 +-------------------------- .github/workflows/windows-arm64.yml | 72 +--- 4 files changed, 29 insertions(+), 646 deletions(-) diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 9b7503bc1..e60c2bdf3 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -23,38 +23,7 @@ permissions: jobs: freebsd: - name: FreeBSD 14 (build + test) - runs-on: ubuntu-latest - timeout-minutes: 300 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - # The Japanese font tests run against craft-fonts (an optional build input; they skip without it). - - name: Check out craft-fonts - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: storytold/craft-fonts - ref: abb83316d96aa59c1cf64784289e378fe9fa5695 - path: craft-fonts - - name: Build and test in a FreeBSD VM - uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9 - with: - release: "14.3" - usesh: true - cpu: 4 - mem: 12288 - # Nothing to bring back; copying the target dir out of the VM fails on large builds. - copyback: false - prepare: | - pkg install -y git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib - curl -fsSL -o /tmp/rustup-init \ - https://static.rust-lang.org/rustup/archive/1.28.2/x86_64-unknown-freebsd/rustup-init - test "$(sha256 -q /tmp/rustup-init)" = "4d9fef2e40731489f3186c61a0f178d54d79864fe3d34791edf5b17f70074956" - chmod 700 /tmp/rustup-init - /tmp/rustup-init -y --profile minimal - run: | - . "$HOME/.cargo/env" - # The checkout is synced in under another owner; let tests that call git read it. - git config --global --add safe.directory "*" - rustc --version - CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked - CRAFT_FONTS_DIR="$PWD/craft-fonts" CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main + with: + craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695 diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 03fddb96f..f54b0538b 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -19,54 +19,5 @@ concurrency: jobs: lint: - runs-on: ubuntu-latest - env: - ACTIONLINT_VERSION: 1.7.12 - ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Install tools - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml - archive="$RUNNER_TEMP/actionlint.tar.gz" - curl -fsSL -o "$archive" \ - "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" - printf '%s %s\n' "$ACTIONLINT_SHA256" "$archive" | sha256sum -c - - tar -xzf "$archive" -C "$RUNNER_TEMP" actionlint - - name: actionlint - run: | - "$RUNNER_TEMP/actionlint" -color - - name: shellcheck - run: shellcheck packaging/*.sh packaging/*/*.sh - - name: PowerShell - shell: pwsh - run: | - foreach ($f in Get-ChildItem packaging/windows/*.ps1) { - $errors = $null - [System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null - if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 } - Write-Output "ok $($f.Name)" - } - - name: XML, desktop entry, AppStream, Flatpak manifest - run: | - xmllint --noout packaging/windows/designcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml* - # Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon. - if grep -oE '<Icon Id="[^"]*"' packaging/windows/designcraft.wxs | grep -vE '\.(ico|exe)"$'; then - echo "::error::Icon Id must end in .ico or .exe"; exit 1 - fi - desktop-file-validate packaging/linux/ai.storyteller.designcraft.desktop - sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.designcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.designcraft.metainfo.xml" - appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.designcraft.metainfo.xml" - python3 - <<'EOF' - import yaml - src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.designcraft.yml")) - bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.designcraft.bundle.yml")) - for m in (src, bundle): - assert m["id"] == "ai.storyteller.designcraft" and m["command"] == "designcraft", m["id"] - for key in ("runtime", "runtime-version", "sdk", "finish-args"): - assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}" - print("Flatpak manifests ok") - EOF - - name: FreeBSD package layout (dry run) - run: bash packaging/freebsd/package.sh --dry-run + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 856bdbe6e..37c71fc65 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,12 +1,3 @@ -# Release pipeline: every push to `release` builds signed installers for every platform and -# creates or updates the draft GitHub Release "DesignCraft v<version>". See docs/releasing.md. -# -# The signing jobs (macOS, Windows) and the draft-release job run in the `release` environment -# (restricted to the `release` branch), which holds the signing secrets. Every secret is -# optional: missing ones produce unsigned artifacts and a warning, never a failed build. The jobs -# that sign nothing (version, Linux, Flatpak, FreeBSD, web) get no environment and no secrets, -# so a `workflow_dispatch` on another branch dry-runs them; the signing jobs are refused there, -# which skips the draft release. name: Release on: @@ -20,495 +11,27 @@ on: default: "" permissions: - contents: read + contents: write concurrency: group: release-${{ github.ref }} - # Never cancel a run mid-notarization or mid-upload; a newer push waits its turn. cancel-in-progress: false -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: 0 - RUST_BACKTRACE: 1 - DESIGNCRAFT_BUILD_SHA: ${{ github.sha }} - INPUT_VERSION: ${{ inputs.version }} - jobs: - version: - name: version - runs-on: ubuntu-latest - # No `environment: release`: no secrets needed, and branch dry runs need this job. - outputs: - version: ${{ steps.v.outputs.version }} - date: ${{ steps.v.outputs.date }} - prerelease: ${{ steps.v.outputs.prerelease }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - id: v - name: Resolve version - run: | - . packaging/env.sh - v="${INPUT_VERSION#v}" - v="${v:-$VERSION}" - if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then - echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1" - exit 1 - fi - case "$v" in *-*) pre=true ;; *) pre=false ;; esac - { - echo "version=$v" - echo "date=$(date -u +%Y-%m-%d)" - echo "prerelease=$pre" - } >>"$GITHUB_OUTPUT" - echo "### DesignCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY" - - macos: - name: macOS universal - needs: version - runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend) - environment: release - env: - DESIGNCRAFT_VERSION: ${{ needs.version.outputs.version }} - DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - # Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it. - - name: Check out craft-fonts - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: storytold/craft-fonts - ref: abb83316d96aa59c1cf64784289e378fe9fa5695 - path: craft-fonts - - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable - with: - targets: aarch64-apple-darwin, x86_64-apple-darwin - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: release-macos-universal - - name: Apply version override - if: inputs.version != '' - run: cargo xtask version set "$DESIGNCRAFT_VERSION" - - name: Import signing certificate - env: - APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} - run: packaging/macos/import-cert.sh - - name: Build, sign, notarize - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - run: packaging/macos/package.sh --arch universal - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-macos - path: dist/release/* - if-no-files-found: error - - name: Remove temporary keychain - if: always() - run: | - if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi - - windows: - name: Windows ${{ matrix.arch }} - needs: version - runs-on: windows-latest - environment: release - strategy: - fail-fast: false - matrix: - include: - - { arch: x64, target: x86_64-pc-windows-msvc } - - { arch: x86, target: i686-pc-windows-msvc } - # Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above. - # .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware. - - { arch: arm64, target: aarch64-pc-windows-msvc } - env: - DESIGNCRAFT_VERSION: ${{ needs.version.outputs.version }} - DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - # Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it. - - name: Check out craft-fonts - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: storytold/craft-fonts - ref: abb83316d96aa59c1cf64784289e378fe9fa5695 - path: craft-fonts - - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable - with: - targets: ${{ matrix.target }} - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: release-windows-${{ matrix.arch }} - - name: Apply version override - if: inputs.version != '' - shell: bash - run: cargo xtask version set "$DESIGNCRAFT_VERSION" - - name: Install WiX - shell: pwsh - run: dotnet tool install --global wix --version 5.0.2 - - name: Build, sign, package - shell: pwsh - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} - WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} - AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} - AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} - AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} - run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }} - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-windows-${{ matrix.arch }} - path: dist/release/* - if-no-files-found: error - - linux: - name: Linux ${{ matrix.arch }} - needs: version - # The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+, - # Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md. - runs-on: ${{ matrix.runner }} - # No `environment: release`: this job signs nothing, so it gets no release secrets. - strategy: - fail-fast: false - matrix: - include: - - arch: x86_64 - runner: ubuntu-22.04 - deb: amd64 - nfpm_sha256: 3f1cf344bd0b57373ca55636a78c08b0491f7293d609a456a9ac3b0b150fda97 - - arch: aarch64 - runner: ubuntu-22.04-arm - deb: arm64 - nfpm_sha256: 27419eb382695a7942be8ad52259f3ec1854fad001b3ae4baed34ce39a223b97 - env: - DESIGNCRAFT_VERSION: ${{ needs.version.outputs.version }} - DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - NFPM_VERSION: 2.47.0 - NFPM_SHA256: ${{ matrix.nfpm_sha256 }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - # Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it. - - name: Check out craft-fonts - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: storytold/craft-fonts - ref: abb83316d96aa59c1cf64784289e378fe9fa5695 - path: craft-fonts - - name: Install build and packaging tools - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \ - desktop-file-utils appstream python3-yaml zsync - curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \ - "https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb" - printf '%s %s\n' "$NFPM_SHA256" "$RUNNER_TEMP/nfpm.deb" | sha256sum -c - - sudo dpkg -i "$RUNNER_TEMP/nfpm.deb" - - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: release-linux-${{ matrix.arch }} - - name: Apply version override - if: inputs.version != '' - run: cargo xtask version set "$DESIGNCRAFT_VERSION" - - name: Build and package (AppImage, deb, rpm, tar.gz) - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - run: packaging/linux/package.sh - - name: Check the packages - run: | - dpkg-deb --info dist/release/*.deb - dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/designcraft$|metainfo|applications' - ldd target/release/designcraft - python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.designcraft.yml')); assert m['id'] == 'ai.storyteller.designcraft'" - for appimage in dist/release/*.AppImage; do - chmod +x "$appimage" - APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version - # Update information is embedded, and the .zsync for delta updates sits beside it. - "$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|' - test -s "$appimage.zsync" - done - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-linux-${{ matrix.arch }} - path: dist/release/* - if-no-files-found: error - - flatpak: - name: Flatpak ${{ matrix.arch }} - needs: [version, linux] - # Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust - # build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships. - runs-on: ${{ matrix.runner }} - # No `environment: release`: this job signs nothing, so it gets no release secrets. - strategy: - fail-fast: false - matrix: - include: - - { arch: x86_64, runner: ubuntu-24.04 } - - { arch: aarch64, runner: ubuntu-24.04-arm } - env: - DESIGNCRAFT_VERSION: ${{ needs.version.outputs.version }} - DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - name: release-linux-${{ matrix.arch }} - path: dist/linux - - name: Install flatpak-builder - run: | - sudo apt-get update - # librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's - # `appstreamcli compose`, which renders the scalable icon and fails without it. - sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common - # Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs. - sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - - name: Build the Flatpak bundle - run: packaging/linux/flatpak-bundle.sh "dist/linux/designcraft-$DESIGNCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz" - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-flatpak-${{ matrix.arch }} - path: dist/release/*.flatpak - if-no-files-found: error - - freebsd: - name: FreeBSD x86_64 - needs: version - # GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM, the same image and packages as - # .github/workflows/freebsd.yml. - runs-on: ubuntu-latest - timeout-minutes: 180 - # No `environment: release`: this job signs nothing, so it gets no release secrets. - env: - DESIGNCRAFT_VERSION: ${{ needs.version.outputs.version }} - DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - # Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it. - - name: Check out craft-fonts - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: storytold/craft-fonts - ref: abb83316d96aa59c1cf64784289e378fe9fa5695 - path: craft-fonts - - name: Build and package in a FreeBSD VM (tar.gz) - # vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build). - uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9 - with: - release: "14.3" - usesh: true - cpu: 4 - mem: 12288 - # Passed into the VM by name; never templated into the script below. - envs: "DESIGNCRAFT_VERSION DESIGNCRAFT_BUILD_DATE DESIGNCRAFT_BUILD_SHA INPUT_VERSION" - # The FreeBSD CI packages, plus bash for the packaging script. - prepare: | - pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib - curl -fsSL -o /tmp/rustup-init \ - https://static.rust-lang.org/rustup/archive/1.28.2/x86_64-unknown-freebsd/rustup-init - test "$(sha256 -q /tmp/rustup-init)" = "4d9fef2e40731489f3186c61a0f178d54d79864fe3d34791edf5b17f70074956" - chmod 700 /tmp/rustup-init - /tmp/rustup-init -y --profile minimal --default-toolchain stable - run: | - set -e - . "$HOME/.cargo/env" - git config --global --add safe.directory "$PWD" - # Build outside the checkout so only dist/ is copied back out of the VM (copying the - # target dir back fails on large builds). 4 jobs: more runs the VM out of memory. - export CARGO_TARGET_DIR=/var/tmp/designcraft-target CARGO_BUILD_JOBS=4 - if [ -n "$INPUT_VERSION" ]; then cargo xtask version set "$DESIGNCRAFT_VERSION"; fi - # craft-fonts was checked out next to the sources and synced into the VM with them. - export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1 - bash packaging/freebsd/package.sh - tar -tzf dist/release/designcraft-*-freebsd-x86_64.tar.gz \ - | grep -E '/bin/designcraft$|/bin/designcraft-cli$|/share/applications/ai\.storyteller\.designcraft\.desktop$|/share/doc/designcraft/LICENSE-MIT$' - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-freebsd-x86_64 - path: dist/release/* - if-no-files-found: error - - web: - name: Web (wasm) - needs: version - runs-on: ubuntu-latest - # No `environment: release`: this job signs nothing, so it gets no release secrets. - env: - DESIGNCRAFT_VERSION: ${{ needs.version.outputs.version }} - DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - TRUNK_VERSION: 0.21.14 - TRUNK_SHA256: f2b4680cd239693a646a2795e4633c625328d7b2a044fbe749fa3a2fe9e7036b - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - # Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it. - - name: Check out craft-fonts - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: storytold/craft-fonts - ref: abb83316d96aa59c1cf64784289e378fe9fa5695 - path: craft-fonts - - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable - with: - targets: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: release-web - - name: Install trunk - run: | - archive="$RUNNER_TEMP/trunk.tar.gz" - curl -fsSL -o "$archive" \ - "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" - printf '%s %s\n' "$TRUNK_SHA256" "$archive" | sha256sum -c - - tar -xzf "$archive" -C "$HOME/.cargo/bin" trunk - trunk --version - - name: Apply version override - if: inputs.version != '' - run: cargo xtask version set "$DESIGNCRAFT_VERSION" - - name: Build and zip - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - run: packaging/web/package.sh - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-web - path: dist/release/* - if-no-files-found: error - - linux-riscv64: - name: Linux riscv64 - needs: version - # Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release - # from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's - # riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the - # CLI is smoke-tested under QEMU. - runs-on: ubuntu-24.04 - # No `environment: release`: this job signs nothing, so it gets no release secrets. - env: - DESIGNCRAFT_VERSION: ${{ needs.version.outputs.version }} - DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - # Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it. - - name: Check out craft-fonts - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: storytold/craft-fonts - ref: abb83316d96aa59c1cf64784289e378fe9fa5695 - path: craft-fonts - - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable - with: - targets: riscv64gc-unknown-linux-gnu - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: release-linux-riscv64 - - name: Apply version override - if: inputs.version != '' - run: cargo xtask version set "$DESIGNCRAFT_VERSION" - - name: Install cross-compilation dependencies - run: | - . /etc/os-release - sudo dpkg --add-architecture riscv64 - # Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports. - if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then - sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources - fi - if [ -f /etc/apt/sources.list ]; then - sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list - sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list - fi - for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do - echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse" - done | sudo tee /etc/apt/sources.list.d/riscv64.list - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \ - libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \ - libxi-dev:riscv64 libgl1-mesa-dev:riscv64 desktop-file-utils appstream - - name: Build and package (tar.gz) - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc - PKG_CONFIG_SYSROOT_DIR: / - PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig - PKG_CONFIG_ALLOW_CROSS: "1" - CROSS_ARCH: riscv64 - CROSS_TARGET: riscv64gc-unknown-linux-gnu - CROSS_COMPILE: riscv64-linux-gnu- - EMULATOR: qemu-riscv64 - QEMU_LD_PREFIX: /usr/riscv64-linux-gnu - run: packaging/linux/package.sh --formats tar - - name: Check the package - run: | - tar -tzf dist/release/designcraft-*-linux-riscv64.tar.gz \ - | grep -E '/bin/designcraft$|/bin/designcraft-cli$|/share/applications/ai\.storyteller\.designcraft\.desktop$' - file target/riscv64gc-unknown-linux-gnu/release/designcraft | grep -F 'RISC-V' - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-linux-riscv64 - path: dist/release/*.tar.gz - if-no-files-found: error - release: - name: Draft GitHub Release - needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web] - runs-on: ubuntu-latest - environment: release - permissions: - contents: write - env: - VERSION: ${{ needs.version.outputs.version }} - PRERELEASE: ${{ needs.version.outputs.prerelease }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GH_REPO: ${{ github.repository }} - steps: - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - pattern: release-* - path: artifacts - merge-multiple: true - - name: Checksums - working-directory: artifacts - run: | - sha256sum -- * | tee SHA256SUMS.txt - - name: Create or update the draft release - working-directory: artifacts - run: | - tag="v$VERSION" - title="DesignCraft v$VERSION" - pre=() - if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi - if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then - if [ "$draft" != "true" ]; then - echo "::error::Release $tag is already published. Bump the version (cargo xtask version set) before pushing to release." - exit 1 - fi - echo "Updating draft $tag: replacing its assets" - gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do - gh release delete-asset "$tag" "$asset" --yes - done - gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE" - gh release upload "$tag" --clobber -- * - else - echo "Creating draft $tag" - gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- * - fi - { - echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:" - echo - sed 's/^/ /' SHA256SUMS.txt - } >>"$GITHUB_STEP_SUMMARY" + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/release.yml@main + with: + app-name: designcraft + app-title: DesignCraft + craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + version: ${{ inputs.version }} + secrets: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index b568d4e78..ed7060b99 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -26,70 +26,10 @@ concurrency: permissions: contents: read -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: 0 - jobs: - package: - name: package (cross-compiled on x64) - runs-on: windows-latest - timeout-minutes: 90 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable - with: - targets: aarch64-pc-windows-msvc - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: windows-arm64-package - - name: Install WiX - shell: pwsh - run: dotnet tool install --global wix --version 5.0.2 - - name: Build and package (unsigned) - shell: pwsh - env: - DIST: ${{ github.workspace }}\dist\arm64 - run: ./packaging/windows/package.ps1 -Arch arm64 - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: designcraft-windows-arm64 - path: dist/arm64/* - if-no-files-found: error - retention-days: 7 - - install: - name: install and run on ARM64 - needs: package - runs-on: windows-11-arm - timeout-minutes: 30 - steps: - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - name: designcraft-windows-arm64 - path: dist - - name: Install the MSI, check it, run it, uninstall it - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - $msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName - $p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru - if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" } - $dir = Join-Path $env:ProgramFiles 'DesignCraft' - foreach ($exe in 'designcraft.exe', 'designcraft-cli.exe') { - $path = Join-Path $dir $exe - if (-not (Test-Path $path)) { throw "$exe not installed in $dir" } - $bytes = [System.IO.File]::ReadAllBytes($path) - $machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4) - if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" } - Write-Output "ok $path (ARM64)" - } - # The installed CLI runs natively on ARM64. - $cli = Join-Path $dir 'designcraft-cli.exe' - $out = & $cli --version 2>&1 | Out-String - if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "designcraft-cli --version exited $LASTEXITCODE" } - Write-Output "ok designcraft-cli --version: $($out.Trim())" - $p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru - if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" } - if (Test-Path (Join-Path $dir 'designcraft.exe')) { throw 'uninstall left designcraft.exe behind' } - Write-Output 'ok uninstalled' + windows-arm64: + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main + with: + app-name: designcraft + app-dir: DesignCraft From a02fc011bf5af1300a7e59ecb146487138fe38d1 Mon Sep 17 00:00:00 2001 From: Mara Johnson <mjohnson7cd@gmail.com> Date: Sun, 11 Oct 2026 12:41:33 -0400 Subject: [PATCH 028/238] Paragraph panel: Optical Margin Alignment checkbox (#494) The composer already hangs punctuation when a paragraph's opticalMargin is on, but nothing in the UI could turn it on. Reported by @mjohnson7cd --- crates/ui-egui/src/i18n.rs | 4 ++++ crates/ui-egui/src/panels/properties.rs | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index e5e8a8d08..6ab27ba56 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -2372,6 +2372,10 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Inward", ["Inward", "Inward", "Inward", "Inward", "向内"]), ("Metrics", ["Metrics", "Metrics", "Metrics", "Metrics", "度量"]), ("Optical", ["Optical", "Optical", "Optical", "Optical", "视觉"]), + ( + "Optical Margin Alignment", + ["Optischer Randausgleich", "Marges optiques", "Alineación de márgenes ópticos", "オプティカルマージン揃え", "视觉边距对齐"], + ), ("Applies to fill", ["Applies to fill", "Applies to fill", "Applies to fill", "Applies to fill", "应用于填色"]), ("Applies to text", ["Applies to text", "Applies to text", "Applies to text", "Applies to text", "应用于文本"]), ("Gray", ["Gray", "Gray", "Gray", "Gray", "灰度"]), diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 49cb920f2..3d0377558 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1478,6 +1478,10 @@ pub fn paragraph_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let _ = app.run("type.para", json!({"attrs": {"gridAlign": if grid { "allLines" } else { "none" }}})); } }); + let mut optical = p["opticalMargin"].as_bool().unwrap_or(false); + if ui.checkbox(&mut optical, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Optical Margin Alignment"))).changed() { + let _ = app.run("type.para", json!({"attrs": {"opticalMargin": optical}})); + } ui.horizontal(|ui| { caption(ui, crate::i18n::tr(&app.ui.language, "Composer")); let single = p["composer"].as_str() == Some("singleLine"); From 3ec5cef6a2f5c705820180443671cec9c4d4315f Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick <hi@skylar.cc> Date: Sun, 11 Oct 2026 22:14:24 +0200 Subject: [PATCH 029/238] M6: Adopt merged craft-print with scaling and queue duplex defaults --- Cargo.lock | 18 +++++++++--------- Cargo.toml | 2 +- ROADMAP.md | 4 ++-- crates/engine/src/cmd/print.rs | 14 +++++++++++--- docs/file-format-parity.md | 2 +- docs/gaps.md | 2 +- docs/hardware-parity.md | 7 ++++--- docs/target-app-parity.md | 2 +- 8 files changed, 30 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2c71f6b6c..cc457b2ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -799,7 +799,7 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=c22bb1b0ce6316f84973cb69675fe7331c5886cc#c22bb1b0ce6316f84973cb69675fe7331c5886cc" +source = "git+https://github.com/storytold/craft-libs?rev=32bcac12db98113d18d8f56bc47fb6ddabe4d54f#32bcac12db98113d18d8f56bc47fb6ddabe4d54f" dependencies = [ "tempfile", ] @@ -1481,7 +1481,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2655,7 +2655,7 @@ dependencies = [ "once_cell", "png", "thiserror 2.0.21", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2738,7 +2738,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3847,7 +3847,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4259,10 +4259,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.5", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4497,7 +4497,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5234,7 +5234,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 87d27cadc..950b0b43e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ rust-version = "1.95" repository = "https://github.com/storytold/designcraft" [workspace.dependencies] -craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "c22bb1b0ce6316f84973cb69675fe7331c5886cc" } +craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "32bcac12db98113d18d8f56bc47fb6ddabe4d54f" } # internal designcraft-geom = { path = "crates/geom" } designcraft-color = { path = "crates/color" } diff --git a/ROADMAP.md b/ROADMAP.md index 8aa7c03ae..82a42cf18 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **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-11 · **Change:** minor (shared print spooler; native Windows validation pending) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared print spooler; Windows CI passes) · **Target:** Adobe InDesign 2026 DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. @@ -129,7 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. -- **2026-10-11 (printing):** `file.printers` and `file.print` use craft-libs/craft-print for CUPS and Windows. DesignCraft retains PDF export, page selection, spreads, marks and bleed; dry runs create no temporary PDF or print job. Windows keeps sheet size/orientation and corrected duplex binding edges. Headless tests exercise exported bytes, job settings, dry runs and refusals; shared native Windows regression tests are present, with execution pending. PostScript, production print controls and physical output validation remain open. No readiness percentage change. +- **2026-10-11 (printing):** `file.printers` and `file.print` use craft-libs/craft-print for CUPS and Windows. DesignCraft retains PDF export, page selection, spreads, marks and bleed; dry runs create no temporary PDF or print job. Windows selects matching or default driver paper and shrinks oversized sheets without enlarging smaller ones; queue duplex defaults are preserved. Headless tests exercise exported bytes, job settings, dry runs and refusals; shared native Windows CI passes, including paused-queue submission and cleanup. PostScript, production print controls and physical output validation remain open. No readiness percentage change. - **2026-10-10 (UI.4–UI.5):** Panel tabs can move into other groups, split the dock, float, resize and return to their saved locations through shared docking transactions. Layouts persist in custom workspaces; UI and control commands share the same validated action path. Shared pane geometry and widget-state theme application retain all five palettes and local fonts. Split panes send keyboard input only to the focused canvas and scope contextual toolbar IDs per pane. No readiness percentage change. - **2026-10-10** · Version 0.6.0; releases add a Linux riscv64 tar.gz (cross-compiled, CLI diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 20dc26f23..659b520e8 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -50,8 +50,13 @@ fn print_with( let d = &s.doc()?.doc; let opts = super::export::options(p, d.page_count())?; let copies = p.get("copies").and_then(Value::as_u64).unwrap_or(1).clamp(1, 999) as u32; - let job = - craft_print::Job { printer: str_param(p, "printer").map(str::to_string), copies, title: d.title.clone(), ..craft_print::Job::default() }; + let job = craft_print::Job { + printer: str_param(p, "printer").map(str::to_string), + copies, + scaling: craft_print::Scaling::ShrinkToFit, + title: d.title.clone(), + ..craft_print::Job::default() + }; let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false); let pdf = designcraft_pdf::export_pdf(d, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?; let command = craft_print::command_preview(&job).map_err(|e| bad("file.print", e.to_string()))?; @@ -76,7 +81,8 @@ mod tests { let command: Vec<&str> = r["command"].as_array().unwrap().iter().filter_map(Value::as_str).collect(); if craft_print::backend() == Some(craft_print::Backend::Cups) { assert_eq!(&command[..5], ["lp", "-d", "Office", "-n", "2"]); - assert!(command.contains(&"fit-to-page=false")); + assert!(command.contains(&"print-scaling=auto-fit")); + assert!(!command.iter().any(|a| a.starts_with("sides="))); } else { assert_eq!(command[0], "powershell"); } @@ -101,6 +107,8 @@ mod tests { assert!(pdf.starts_with(b"%PDF-")); assert_eq!(job.printer.as_deref(), Some("Office (east)")); assert_eq!(job.copies, 2); + assert_eq!(job.duplex, None); + assert_eq!(job.scaling, craft_print::Scaling::ShrinkToFit); assert!(!job.title.is_empty()); Ok("request id is Office-1".into()) }) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 4bc9a402d..fc8fbc3a5 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared print spooler; native Windows validation pending) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared print spooler; Windows CI passes) · **Target:** Adobe InDesign 2026 (21.6) Every format InDesign 2026 reads or writes, with DesignCraft's support, fidelity and tests. The InDesign list comes from its bundle's `Info.plist` (`CFBundleDocumentTypes`, diff --git a/docs/gaps.md b/docs/gaps.md index f9a40d4c3..6f92c5500 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-11 · **Change:** minor (shared print spooler; native Windows validation pending) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared print spooler; Windows CI passes) · **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. diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 98fd44d0e..fb92c587a 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware and platform parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared print spooler; native Windows validation pending) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared print spooler; Windows CI passes) · **Target:** Adobe InDesign 2026 (21.6) What InDesign does with hardware, per platform, against DesignCraft. Layout is not a GPU-hungry workload, so this dimension carries little weight, but a start that fails on a @@ -55,5 +55,6 @@ the developers don't use daily. |---|---|---| | 2026-10-10 | major | Created from the code, InDesign 2026's GPU Performance features and the issue tracker | -Windows submission now uses shared craft-print. It preserves PDF sheet size/orientation and -duplex binding edges; printer-driver options UI and physical output validation remain open. +Windows submission now uses shared craft-print. It selects matching or default driver paper, +shrinks oversized sheets to the printable area without enlarging smaller ones, and preserves +the queue's duplex default; printer-driver options UI and physical output validation remain open. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ba66f898a..74d8e7895 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # DesignCraft parity with Adobe InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared print spooler; native Windows validation pending) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared print spooler; Windows CI passes) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) The authoritative assessment of how close DesignCraft is to Adobe InDesign. [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) lists every known shortfall one at a time; the feature-by-feature From 957890223a0c84070278557bac529d6da848ebe6 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick <hi@skylar.cc> Date: Mon, 12 Oct 2026 01:20:53 +0200 Subject: [PATCH 030/238] M6: Pin craft-print with native driver capability validation --- Cargo.lock | 12 ++++++------ Cargo.toml | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cc457b2ed..7e5ebb850 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -799,7 +799,7 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=32bcac12db98113d18d8f56bc47fb6ddabe4d54f#32bcac12db98113d18d8f56bc47fb6ddabe4d54f" +source = "git+https://github.com/storytold/craft-libs?rev=d991bf6792f8ade99c51358bf3c1d902c8cd96b5#d991bf6792f8ade99c51358bf3c1d902c8cd96b5" dependencies = [ "tempfile", ] @@ -1481,7 +1481,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3847,7 +3847,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -4259,10 +4259,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5234,7 +5234,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 950b0b43e..4fcbfd625 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ rust-version = "1.95" repository = "https://github.com/storytold/designcraft" [workspace.dependencies] -craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "32bcac12db98113d18d8f56bc47fb6ddabe4d54f" } +craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" } # internal designcraft-geom = { path = "crates/geom" } designcraft-color = { path = "crates/color" } From 3dc0a1563ad2a74618386124d2baf30770ca3b5b Mon Sep 17 00:00:00 2001 From: japbcoelho <319161831+japbcoelho@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:39:40 -0300 Subject: [PATCH 031/238] Place: PDF pages and SVGs keep their exact size (#511) file.place sized a placed PDF page (and SVG) from its size rounded to whole points, while the export draws the page into that size: a page of 91.774 x 613.276 pt became 92 x 613 and was drawn 0.25 % wider and 0.3 % shorter, so pieces laid edge to edge left gaps of up to 1.9 pt. A 100.4 x 50.4 pt page placed 100.4 pt wide came out 50.2 pt tall: `Rect { x0: 0.0, y0: 0.0, x1: 100.4, y1: 50.2 }`. The graphic's natural size is now the exact page (crop box) or SVG size; the rounded pixel size stays for display and preflight. place.load (the place cursor) gets the same exact size. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/cmd/file.rs | 26 +++++++++++++++++++++++--- crates/render/src/lib.rs | 9 +++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index f871a43e8..3a2885ce3 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -313,8 +313,9 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> { .map(|(w, h)| (w.round().max(1.0) as u32, h.round().max(1.0) as u32)) .ok_or_else(|| bad("file.place", "can't read that PDF page"))?, }; - // 72 ppi by default unless the file says otherwise; scale so it fits the page when huge. - let (nw, nh) = eps_size.unwrap_or((pw as f64, ph as f64)); + // 72 ppi by default unless the file says otherwise; scale so it fits the page when huge. PDF pages + // and SVGs keep their exact size: rounding it to whole points would stretch them unevenly. + let (nw, nh) = eps_size.or_else(|| designcraft_render::natural_size(&bytes, pdf_page as usize)).unwrap_or((pw as f64, ph as f64)); // Image Import Options › Crop to: the frame shows that box of the PDF page. let crop_box = match str_param(p, "pdfCrop") { Some(k) if designcraft_render::is_pdf(&bytes) => { @@ -440,6 +441,7 @@ pub(crate) fn read_source(p: &Value) -> Result<(Vec<u8>, String, Option<String>) fn place_load(s: &mut Session, p: &Value) -> Result<Value> { let (bytes, name, link) = read_source(p)?; let (pw, ph) = designcraft_render::image_size(&bytes).ok_or_else(|| bad("place.load", "unsupported or corrupt image"))?; + let natural = designcraft_render::natural_size(&bytes, 0).unwrap_or((pw as f64, ph as f64)); let aid = s.edit(|d, _| { let aid = AssetId(d.alloc()); let mime = designcraft_render::image_mime(&bytes).to_string(); @@ -449,7 +451,7 @@ fn place_load(s: &mut Session, p: &Value) -> Result<Value> { ); Ok(aid) })?; - s.loaded = Some((aid, (pw as f64, ph as f64))); + s.loaded = Some((aid, natural)); s.set_tool("placeGun"); Ok(json!({"asset": aid.0, "name": name, "width": pw, "height": ph})) } @@ -689,6 +691,24 @@ mod pdf_crop_tests { assert!((trim.width() - 612.0).abs() < 1e-3 && (trim.height() - 792.0).abs() < 1e-3, "{trim:?}"); assert!(t.execute("file.place", &json!({"base64": b64, "name": "a.pdf", "pdfCrop": "nope"})).is_err()); } + + #[test] + fn placed_pdf_keeps_its_exact_page_size() { + // A 100.4 x 50.4 pt page placed 100.4 pt wide is 50.4 pt tall, not stretched to whole points. + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100.4, "height": 50.4, "pages": 1})).unwrap(); + let b64 = s.execute("file.exportPdf", &json!({})).unwrap()["base64"].as_str().unwrap().to_string(); + let mut t = Session::new(); + t.execute("file.new", &json!({})).unwrap(); + let r = t.execute("file.place", &json!({"base64": b64, "name": "a.pdf", "width": 100.4, "x": 0, "y": 0})).unwrap(); + let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap()); + let d = &t.doc().unwrap().doc; + let it = d.item(id).unwrap(); + let b = it.bounds(); + assert!((b.width() - 100.4).abs() < 1e-3 && (b.height() - 50.4).abs() < 1e-3, "{b:?}"); + let designcraft_doc::Content::Graphic(g) = &it.content else { panic!("not a graphic") }; + assert!((g.size.0 - 100.4).abs() < 1e-3 && (g.size.1 - 50.4).abs() < 1e-3, "{:?}", g.size); + } } #[cfg(test)] diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index c1b288def..b0f8df133 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1022,6 +1022,15 @@ pub fn image_size(bytes: &[u8]) -> Option<(u32, u32)> { designcraft_images::pixel_size(bytes) } +/// Natural size of a placed graphic, unrounded: a PDF page (crop box) and an SVG in points, a raster +/// in pixels. Placed graphics are drawn at this size; `image_size` rounds it to whole pixels for display. +pub fn natural_size(bytes: &[u8], page: usize) -> Option<(f64, f64)> { + if is_pdf(bytes) { + return pdf_page_size(bytes, page).map(|(w, h)| (w.max(1e-3), h.max(1e-3))); + } + designcraft_images::natural_size(bytes) +} + /// MIME type guess for encoded image bytes. /// Is this a PDF file (placed PDFs are graphics)? pub fn is_pdf(bytes: &[u8]) -> bool { From 2b46ca664cab8c22ffa396f20f6c51948f3ecd8d Mon Sep 17 00:00:00 2001 From: japbcoelho <319161831+japbcoelho@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:39:53 -0300 Subject: [PATCH 032/238] Hyphenation: Portuguese text is hyphenated with hyph-utf8's Portuguese patterns (#512) Text set in Portuguese ("Portuguese: Brazilian", "Portuguese", pt_BR) got no hyphenation, so justified Portuguese columns could only stretch their word spaces (up to 335 % of the normal space in a 56 mm column of 10 pt Times). Turn on hypher's "portuguese" feature, beside Spanish: hyph-pt.tex 1.4 (2024-07-13) from hyph-utf8, BSD-3-Clause, credited in NOTICE. Lang::for_language already maps the Portuguese names and locale codes. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --- NOTICE | 20 ++++++++++++++++++++ crates/compose/Cargo.toml | 2 +- crates/compose/src/hyphen/mod.rs | 22 +++++++++++++++++++++- crates/compose/src/tests.rs | 17 +++++++++++++++++ docs/target-app-parity.md | 2 +- 5 files changed, 60 insertions(+), 3 deletions(-) diff --git a/NOTICE b/NOTICE index 8b67a99e1..087bb6196 100644 --- a/NOTICE +++ b/NOTICE @@ -32,6 +32,26 @@ list (author, source, license) is in ASSETS.md. AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +- Portuguese hyphenation patterns, embedded through the hypher crate (feature "portuguese"): + hyph-pt.tex 1.4 from hyph-utf8, https://github.com/hyphenation/tex-hyphen. + Copyright (C) 1987, 1994, 1996, 2015 Pedro J. de Rezende, 1996, 2015 J. Joao Dias Almeida, + 2024 Leonardo Araujo and Aline Benevides. BSD 3-clause licence: + Redistribution and use in source and binary forms, with or without modification, are permitted + provided that the following conditions are met: * Redistributions of source code must retain the + above copyright notice, this list of conditions and the following disclaimer. * Redistributions in + binary form must reproduce the above copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided with the distribution. * Neither + the name of the University of Campinas, of the University of Minho nor the names of its + contributors may be used to endorse or promote products derived from this software without + specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND + CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO + EVENT SHALL PEDRO J. DE REZENDE OR J.JOAO DIAS ALMEIDA BE LIABLE FOR ANY DIRECT, INDIRECT, + INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - Japanese fonts (craft-fonts): builds made with craft-fonts (CRAFT_FONTS_DIR; all official releases) embed the fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (BIZ UDPGothic, BIZ UDMincho, diff --git a/crates/compose/Cargo.toml b/crates/compose/Cargo.toml index 012868835..b8993bcfb 100644 --- a/crates/compose/Cargo.toml +++ b/crates/compose/Cargo.toml @@ -18,7 +18,7 @@ designcraft-fonts = { workspace = true } log = { workspace = true } regex = { workspace = true } miniz_oxide = "0.8" -hypher = { version = "0.1.8", default-features = false, features = ["alloc", "spanish"] } +hypher = { version = "0.1.8", default-features = false, features = ["alloc", "portuguese", "spanish"] } [dev-dependencies] designcraft-fonts = { workspace = true, features = ["testing"] } diff --git a/crates/compose/src/hyphen/mod.rs b/crates/compose/src/hyphen/mod.rs index 22644eb70..e21ade031 100644 --- a/crates/compose/src/hyphen/mod.rs +++ b/crates/compose/src/hyphen/mod.rs @@ -1,6 +1,7 @@ //! Hyphenation. English uses the bundled dictionary and patterns below. Other languages use the //! Liang patterns compiled into `hypher`, one cargo feature per language: Spanish is Javier Bezos's -//! `hyph-es.tex` 5.0 from hyph-utf8, MIT (see NOTICE). +//! `hyph-es.tex` 5.0 from hyph-utf8, MIT; Portuguese is `hyph-pt.tex` 1.4 from hyph-utf8 (Pedro J. +//! de Rezende, J. Joao Dias Almeida, Leonardo Araujo, Aline Benevides), BSD-3-Clause (see NOTICE). //! //! English lookup order for each word: //! 1. the **dictionary** ([`Dictionary::en_us`]) — the public-domain Moby Hyphenator II word list @@ -389,6 +390,25 @@ mod tests { assert!(Lang::for_language("French").is_none(), "only languages built in"); } + #[test] + fn portuguese_uses_the_hyph_pt_patterns() { + let pt = Lang::for_language("Portuguese: Brazilian").unwrap(); + assert_eq!(pt, Lang::for_language("Portuguese").unwrap()); + assert_eq!(pt, Lang::for_language("pt_BR").unwrap()); + assert_ne!(pt, Lang::for_language("Spanish").unwrap()); + // hyph-pt's own minimum: three letters after the last break. + let lim = Limits { before_last: 3, ..Limits::default() }; + let h = |w: &str| { + let pts = hyphen_points_in(w, &lim, pt); + w.chars().enumerate().map(|(i, c)| if pts.contains(&i) { format!("-{c}") } else { c.to_string() }).collect::<String>() + }; + assert_eq!(h("matemática"), "ma-te-má-tica"); + assert_eq!(h("orientação"), "ori-en-ta-ção"); + assert_eq!(h("Universidade"), "Uni-ver-si-dade"); + assert_eq!(h("experiência"), "ex-pe-ri-ên-cia"); + assert_eq!(h("pós-graduação"), "pós-gra-du-a-ção", "each part of a compound"); + } + #[test] fn heuristic_fallback() { let c: Vec<char> = "happen".chars().collect(); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 0d1fe2d27..f5d0113c1 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1371,6 +1371,23 @@ fn spanish_text_gets_spanish_hyphenation() { } } +#[test] +fn portuguese_text_gets_portuguese_hyphenation() { + let text = "matemática matemática matemática matemática matemática matemática"; + let (mut d, sid, _) = doc_with(text, Rect::new(36.0, 36.0, 76.0, 700.0), ParaAttrs::default()); + let st = d.story_mut(sid).unwrap(); + let n = st.len(); + st.format_chars(0..n, |f| f.over.language = Some("Portuguese: Brazilian".into())); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let breaks: Vec<&str> = all_lines(&cs).into_iter().filter(|l| l.hyphenated).map(|l| text[l.range.clone()].trim_end()).collect(); + assert!(!breaks.is_empty(), "Portuguese text is hyphenated"); + // Portuguese syllables: ma-te-má-tica. + for b in breaks { + let tail = b.rsplit(' ').next().unwrap_or(b); + assert!(["ma", "mate", "matemá"].contains(&tail), "{b:?}"); + } +} + #[test] fn cjk_aki_adds_space_without_scaling_outlines() { let (mut d, sid, _) = doc_with("AB", Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs::default()); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8044ed51b..ac553fe24 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -346,7 +346,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Edit ▸ Menus (`window.hideMenuItem`): hide menu items; menus with hidden items end in Show All Menu Items. - View ▸ Separations Preview (`view.separations`): one process plate as ink density, or areas over an ink limit in red (a GCR preview of the rendered view; spot plates and the flattener preview aren't there). - Separations Preview plates now come from each object's own colour (CMYK as authored; RGB and images through the colour settings, images via a cached 17³ table), so pure black shows only on the black plate. -- Language (Character panel ▸ Language, Properties ▸ Character, `type.char {language}`): text in other languages isn't hyphenated or spell-checked with the English dictionaries, typographer's quotes follow the language (German „…“, French and Russian «…», Swiss, Spanish, Dutch, Nordic, Japanese and Traditional Chinese 「…」, Simplified Chinese “…”), the font's localized forms (OpenType `locl`: Turkish i, Bulgarian Cyrillic, Japanese / Chinese / Korean glyph variants) follow it too, and so does the fallback for CJK characters the font lacks (with Draw Missing Glyphs from Fallback Fonts on): Japanese, Simplified Chinese, Traditional Chinese and Korean each try their own installed system fonts (Japanese starts with the craft-fonts Japanese faces, Mincho first, when built with them; Hangul, kana and Bopomofo pick their language's fonts whatever the language). Language names from IDML keep their spelling; locale codes as InDesign writes them for some languages (`de_DE_2006`, `nl_NL_2005`; any `ll`, `ll_CC`, `ll-CC`) are their language and region for all of the above, and Chinese is also recognised in its common spellings ("Simplified Chinese", "Chinese (Traditional)", `zh_CN`, `zh-Hant`). Spanish is hyphenated with Javier Bezos's Liang patterns (`hyph-es.tex` 5.0, MIT) through the `hypher` crate; hyphenation for other languages isn't bundled yet. +- Language (Character panel ▸ Language, Properties ▸ Character, `type.char {language}`): text in other languages isn't hyphenated or spell-checked with the English dictionaries, typographer's quotes follow the language (German „…“, French and Russian «…», Swiss, Spanish, Dutch, Nordic, Japanese and Traditional Chinese 「…」, Simplified Chinese “…”), the font's localized forms (OpenType `locl`: Turkish i, Bulgarian Cyrillic, Japanese / Chinese / Korean glyph variants) follow it too, and so does the fallback for CJK characters the font lacks (with Draw Missing Glyphs from Fallback Fonts on): Japanese, Simplified Chinese, Traditional Chinese and Korean each try their own installed system fonts (Japanese starts with the craft-fonts Japanese faces, Mincho first, when built with them; Hangul, kana and Bopomofo pick their language's fonts whatever the language). Language names from IDML keep their spelling; locale codes as InDesign writes them for some languages (`de_DE_2006`, `nl_NL_2005`; any `ll`, `ll_CC`, `ll-CC`) are their language and region for all of the above, and Chinese is also recognised in its common spellings ("Simplified Chinese", "Chinese (Traditional)", `zh_CN`, `zh-Hant`). Spanish is hyphenated with Javier Bezos's Liang patterns (`hyph-es.tex` 5.0, MIT) and Portuguese with hyph-utf8's `hyph-pt.tex` 1.4 (BSD-3-Clause), both through the `hypher` crate; hyphenation for other languages isn't bundled yet. - Books (`book.new/open/add/remove/list/styleSource/paginate/syncStyles/exportPdf`, Book panel): `.dcbook` files, continuous page numbering, style sync from a style source, one merged PDF. - Track Changes (`changes.track/list/acceptAll/rejectAll`, Type ▸ Track Changes): typing is marked added (highlighted on screen), deletions are kept but hidden from layout and exports until accepted or rejected. Per-change accept/reject and authors aren't there yet. - Track Changes: per-change Accept / Reject (`changes.accept`, `changes.reject`) and a Track Changes panel listing every change. From 66860bd1de915971b1da9a84530ddeabdae92e80 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:40:06 -0400 Subject: [PATCH 033/238] UI: Return and Escape in the font menu stay in the menu, never reach the dialog under it (#447) Every font field's menu (Control bar, Contextual Task Bar, Properties > Character, Character and Glyphs panels, Paragraph/Character Style Options, Find/Replace Font) keeps its search focused and open on a click and filters as you type. In a dialog, Return picked the highlighted family and also pressed OK, and Escape closed the menu and cancelled the dialog: the menu now consumes the keys it uses. The drawn font fields report themselves as combo boxes to screen readers. --- crates/ui-egui/src/panels/mod.rs | 129 +++++++++++++++++++++++++++++-- 1 file changed, 122 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 05a73395b..581f6747e 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -251,8 +251,9 @@ struct FontMenuState { /// The Font menu, inside any font field's popup or combo box: a search field (it has the keyboard /// while the menu is open, and starts empty each time it opens), favourites (★, Show Favorites /// Only), and each family's name with a sample in that family, the script groups apart. ↑/↓ move -/// through the matches and Return picks one. Returns the family picked and closes the menu; the -/// search, Favorites and the stars keep it open (the popup closes on a click outside only). +/// through the matches, Return picks one and Escape closes the menu; none of them reach a dialog +/// under the menu. Returns the family picked and closes the menu; the search, Favorites and the +/// stars keep it open (the popup closes on a click outside only). pub fn font_menu_body(app: &mut DesignApp, ui: &mut egui::Ui, menu: &[designcraft_fonts::FamilyInfo], current: &str, width: f32) -> Option<String> { let (fonts, scope) = (fonts(app), font_scope(app)); let english = app.session.prefs.show_font_names_in_english; @@ -293,7 +294,12 @@ pub fn font_menu_body(app: &mut DesignApp, ui: &mut egui::Ui, menu: &[designcraf if search.changed() { st.highlight = if q.is_empty() { None } else { Some(0) }; } - let (down, up, enter) = ui.input(|i| (i.key_pressed(egui::Key::ArrowDown), i.key_pressed(egui::Key::ArrowUp), i.key_pressed(egui::Key::Enter))); + // The menu owns these keys while it is open: a dialog under it must not take Return as OK or + // Escape as Cancel. + let (down, up, enter, escape) = ui.input_mut(|i| { + let mut key = |k| i.consume_key(egui::Modifiers::NONE, k); + (key(egui::Key::ArrowDown), key(egui::Key::ArrowUp), key(egui::Key::Enter), key(egui::Key::Escape)) + }); let last = shown.len().checked_sub(1); st.highlight = match (st.highlight, last) { (_, None) => None, @@ -374,7 +380,7 @@ pub fn font_menu_body(app: &mut DesignApp, ui: &mut egui::Ui, menu: &[designcraf if favs_changed { let _ = app.run("prefs.set", json!({ "favoriteFonts": favs })); } - if pick.is_some() { + if pick.is_some() || escape { ui.close(); } pick @@ -404,6 +410,12 @@ pub fn font_combo( /// [`font_menu_body`] in a popup under `field` (a font field the caller draws). Returns the family /// picked. pub fn font_popup(app: &mut DesignApp, field: &egui::Response, menu: &[designcraft_fonts::FamilyInfo], current: &str) -> Option<String> { + // Screen readers (and tests) see the field as the combo box it looks like. + field.widget_info(|| { + let mut info = egui::WidgetInfo::new(egui::WidgetType::ComboBox); + info.current_text_value = Some(if current.is_empty() { "—".to_string() } else { font_label(app, menu, current) }); + info + }); egui::Popup::menu(field) .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) .show(|ui| font_menu_body(app, ui, menu, current, field.rect.width())) @@ -506,9 +518,22 @@ mod font_menu_ui_tests { /// The Type tool's selection over a new text frame's text, with the Control bar on. Returns the /// story. fn typing() -> (Harness<'static, Window>, u64) { + typing_in("Advanced") + } + + /// [`typing`] in the workspace `name`. + fn typing_in(name: &str) -> (Harness<'static, Window>, u64) { + typing_on(name, vec2(1440.0, 900.0), false) + } + + /// [`typing_in`] a window of `size`, with the macOS window chrome (the native menu bar and the + /// title bar inside the window) when `mac`. + fn typing_on(name: &str, size: egui::Vec2, mac: bool) -> (Harness<'static, Window>, u64) { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.ui.control_bar = true; - let mut h = test_window::open(app, vec2(1440.0, 900.0)); + app.run("window.workspace", json!({ "name": name })).unwrap(); + app.native_menu = mac; + app.integrated_titlebar = mac; + let mut h = test_window::open(app, size); let app = &mut h.state_mut().app; let r = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Hello", "caret": true})).unwrap(); app.run("tool.select", json!({"tool": "type"})).unwrap(); @@ -523,7 +548,7 @@ mod font_menu_ui_tests { /// The Control bar's font field, showing `family`. fn field(h: &Harness<'static, Window>, family: &str) -> egui::Rect { - h.get_by(|n| n.role() == egui::accesskit::Role::ComboBox && n.value().as_deref() == Some(family)).rect() + fields(h, family)[0] } /// The text in the focused search field. @@ -582,4 +607,94 @@ mod font_menu_ui_tests { assert_eq!(story_text(&mut h, story), before); assert!(h.state().app.session.active().unwrap().selection.text.is_some(), "the text is still selected"); } + + /// The font fields showing `family`, left to right and top to bottom. + fn fields(h: &Harness<'static, Window>, family: &str) -> Vec<egui::Rect> { + let mut r: Vec<egui::Rect> = + h.query_all_by(|n| n.role() == egui::accesskit::Role::ComboBox && n.value().as_deref() == Some(family)).map(|n| n.rect()).collect(); + r.sort_by(|a, b| a.min.x.total_cmp(&b.min.x).then(a.min.y.total_cmp(&b.min.y))); + r + } + + /// Opens the font menu of the field at `at`, clicks its search field, types, checks the menu + /// stays open and lists only the matches, then picks the first with ↓ and Return. + fn search_in_menu(h: &mut Harness<'static, Window>, at: egui::Rect, what: &str) { + click_at(h, at.center()); + let field = h.query_by(|n| n.role() == egui::accesskit::Role::TextInput && n.is_focused()).map(|n| n.rect()); + let field = field.unwrap_or_else(|| panic!("{what}: the menu opens with the search field focused")); + click_at(h, field.center()); + assert!(h.ctx.text_edit_focused(), "{what}: a click on the search field keeps the menu open"); + h.event(egui::Event::Text("SOURCE SA".into())); + h.run_steps(4); + assert_eq!(search(h), "SOURCE SA", "{what}: the typing went to the search"); + assert!(h.query_by_label("Source Sans 3").is_some(), "{what}: a match is listed"); + assert!(h.query_by_label(designcraft_fonts::DEFAULT_FAMILY).is_none(), "{what}: families that don't match aren't"); + h.key_press(egui::Key::ArrowDown); + h.run_steps(2); + h.key_press(egui::Key::Enter); + h.run_steps(4); + assert!(!h.ctx.text_edit_focused(), "{what}: the menu closed"); + } + + /// Every font field over selected text (Control bar, Contextual Task Bar, Properties › + /// Character, Character panel) searches, in the workspaces that show them and in a small + /// window with the macOS window chrome. + #[test] + fn every_font_field_searches_in_every_workspace() { + for (name, size, mac, panel) in [ + ("Advanced", vec2(1440.0, 900.0), false, None), + ("Printing and Proofing", vec2(1440.0, 900.0), false, None), + ("Essentials", vec2(1440.0, 900.0), false, Some("character")), + ("Printing and Proofing", vec2(900.0, 560.0), true, None), + ] { + let (mut h, story) = typing_on(name, size, mac); + if panel.is_some() { + h.state_mut().app.ui.open_panel = panel.map(str::to_string); + h.run_steps(4); + } + let name = &format!("{name} {size:?} {panel:?}"); + let before = story_text(&mut h, story); + let count = fields(&h, designcraft_fonts::DEFAULT_FAMILY).len(); + assert_eq!(count, 3, "{name}: the font fields are shown"); + for k in 0..count { + h.state_mut().app.run("type.char", json!({"attrs": {"fontFamily": designcraft_fonts::DEFAULT_FAMILY}})).unwrap(); + h.run_steps(4); + let at = fields(&h, designcraft_fonts::DEFAULT_FAMILY)[k]; + search_in_menu(&mut h, at, &format!("{name}, field {k}")); + assert_eq!(family(&mut h), "Source Sans 3", "{name}, field {k}"); + assert_eq!(story_text(&mut h, story), before, "{name}, field {k}"); + } + } + } + + /// The font fields away from the text: the Glyphs panel, Paragraph Style Options › Basic + /// Character Formats and Find/Replace Font's Replace With. + #[test] + fn font_fields_in_panels_and_dialogs_search() { + let paragraph = json!({"name": designcraft_doc::BASIC_PARAGRAPH, "section": "chars"}); + for (what, panel, dialog, shown) in [ + ("Glyphs", Some("glyphs"), None, designcraft_fonts::DEFAULT_FAMILY), + ("Paragraph Style Options", None, Some(("paragraphStyleOptions", paragraph)), designcraft_fonts::DEFAULT_FAMILY), + ("Find/Replace Font", None, Some(("findFont", json!({}))), "—"), + ] { + let app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let mut h = test_window::open(app, vec2(1440.0, 900.0)); + let dialog_id = dialog.as_ref().map(|(id, _)| id.to_string()); + h.state_mut().app.ui.open_panel = panel.map(str::to_string); + h.state_mut().app.ui.dialog = dialog.map(|(id, fields)| crate::dialogs::Dialog::new(id, fields)); + h.run_steps(4); + let at = *fields(&h, shown).first().unwrap_or_else(|| panic!("{what}: the font field is shown")); + search_in_menu(&mut h, at, what); + assert_eq!(h.state().app.ui.dialog.as_ref().map(|d| d.id.clone()), dialog_id, "{what}: Return in the menu leaves the dialog open"); + assert!(!fields(&h, "Source Sans 3").is_empty(), "{what}: the field shows the family picked"); + // Escape closes the menu only. + let at = fields(&h, "Source Sans 3")[0]; + click_at(&mut h, at.center()); + assert!(h.ctx.text_edit_focused(), "{what}: the menu opened"); + h.key_press(egui::Key::Escape); + h.run_steps(4); + assert!(!h.ctx.text_edit_focused(), "{what}: Escape closed the menu"); + assert_eq!(h.state().app.ui.dialog.as_ref().map(|d| d.id.clone()), dialog_id, "{what}: Escape in the menu leaves the dialog open"); + } + } } From 597f0e649f240c306b91abc0d8968347117c0a41 Mon Sep 17 00:00:00 2001 From: japbcoelho <319161831+japbcoelho@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:49:46 -0300 Subject: [PATCH 034/238] Color parsing: one scale per colour, not per component (#510) A CMYK component of 1 or less was read as a fraction even when the other components were percentages, so {"c": 0.7, "m": 0, "y": 12.2, "k": 0} (a pale cream) became C 70 % Y 12.2 % and exported as `0.7019608 0 0.12156863 0 k`. The same applied to RGB arrays. Now a colour is read as percentages (0..100, or 0..255 for RGB) when any component is above 1, and as fractions only when every component is within 0..1. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/cmd/style.rs | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 94bd92c6d..296a8d07b 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -716,13 +716,18 @@ pub fn parse_color(v: &Value) -> Option<designcraft_color::Color> { use designcraft_color::Color; match v { Value::String(s) => Color::from_hex(s), + // One scale per colour, not per component: {c: 0.7, y: 12.2} is C 0.7 %, Y 12.2 %. Only a colour whose + // components are all within 0..1 is read as fractions (the older form). Value::Array(a) if a.len() >= 3 => { - let f = |i: usize| a[i].as_f64().map(|x| if x > 1.0 { x / 255.0 } else { x } as f32); - Some(Color::rgb(f(0)?, f(1)?, f(2)?)) + let v = [a[0].as_f64()?, a[1].as_f64()?, a[2].as_f64()?]; + let scale = if v.iter().any(|&x| x > 1.0) { 255.0 } else { 1.0 }; + Some(Color::rgb((v[0] / scale) as f32, (v[1] / scale) as f32, (v[2] / scale) as f32)) } Value::Object(o) => { - let g = |k: &str| o.get(k).and_then(Value::as_f64).map(|x| if x > 1.0 { x / 100.0 } else { x } as f32); - Some(Color::cmyk(g("c")?, g("m")?, g("y")?, g("k")?)) + let g = |k: &str| o.get(k).and_then(Value::as_f64); + let v = [g("c")?, g("m")?, g("y")?, g("k")?]; + let scale = if v.iter().any(|&x| x > 1.0) { 100.0 } else { 1.0 }; + Some(Color::cmyk((v[0] / scale) as f32, (v[1] / scale) as f32, (v[2] / scale) as f32, (v[3] / scale) as f32)) } _ => None, } @@ -1040,6 +1045,20 @@ fn rename_in(it: &mut designcraft_doc::Item, from: &str, to: &str) { mod color_tests { use super::*; + #[test] + fn percent_components_at_most_one_stay_percent() { + use designcraft_color::Color; + // {"c": 0.7, ..., "y": 12.2} is a pale cream (C 0.7 %, Y 12.2 %), not C 70 %. + let Some(Color::Cmyk { c, m, y, k }) = parse_color(&json!({"c": 0.7, "m": 0, "y": 12.2, "k": 0})) else { panic!("not CMYK") }; + assert!((c - 0.007).abs() < 1e-6 && m == 0.0 && (y - 0.122).abs() < 1e-6 && k == 0.0, "got {c} {m} {y} {k}"); + // Fractions (every component within 0..1) keep working. + let Some(Color::Cmyk { c, k, .. }) = parse_color(&json!({"c": 0.5, "m": 0, "y": 0, "k": 1})) else { panic!("not CMYK") }; + assert!((c - 0.5).abs() < 1e-6 && (k - 1.0).abs() < 1e-6); + // RGB arrays follow the same rule: [1, 128, 255] is 0..255. + let Some(Color::Rgb { r, g, b }) = parse_color(&json!([1, 128, 255])) else { panic!("not RGB") }; + assert!((r - 1.0 / 255.0).abs() < 1e-6 && (g - 128.0 / 255.0).abs() < 1e-6 && (b - 1.0).abs() < 1e-6); + } + #[test] fn style_groups_rename_every_use() { let mut s = Session::new(); From ca1a8a12bceda169f55f700ae3fbfe034c03c728 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:49:56 -0400 Subject: [PATCH 035/238] Place: an image placed into a frame leaves Auto-Fit off (#528) * Place: an image placed into a frame fills it once and leaves Auto-Fit off * Place: the fitting test names its fitting, since a placed image no longer auto-fits --- crates/engine/src/cmd/file.rs | 6 +++--- crates/engine/src/cmd/object.rs | 4 ++-- crates/engine/src/tests.rs | 10 +++++++++- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 3a2885ce3..a0c98f644 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -351,14 +351,14 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> { let id = if let Some(fid) = target_frame { let it = d.item_mut(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?; let r = it.inner_bounds(); - // Fill frame proportionally. + // Fill the frame proportionally once. Auto-Fit stays off, so resizing the frame crops. let k = (r.width() / nw).max(r.height() / nh); let (gw, gh) = (nw * k, nh * k); it.content = Content::Graphic(Graphic { asset: aid, size: (nw, nh), xf: Affine::translate((r.x0 + (r.width() - gw) / 2.0, r.y0 + (r.height() - gh) / 2.0)) * Affine::scale(k), - auto_fit: designcraft_doc::Fitting::FillProportionally, + auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4], }); @@ -473,7 +473,7 @@ fn place_drop(s: &mut Session, p: &Value) -> Result<Value> { asset: aid, size: (nw, nh), xf: Affine::translate((r.x0 + (r.width() - nw * k) / 2.0, r.y0 + (r.height() - nh * k) / 2.0)) * Affine::scale(k), - auto_fit: designcraft_doc::Fitting::FillProportionally, + auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4], }); diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 17c44c27a..c4f2562a1 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -2463,8 +2463,8 @@ mod fitting_tests { s.execute("object.fittingOptions", &json!({"ids": [f], "fitting": "fitProportionally", "align": 0})).unwrap(); let c = content(&s); assert!((c.x0 - 100.0).abs() < 1e-6 && (c.y0 - 100.0).abs() < 1e-6 && (c.width() - 200.0).abs() < 1e-6, "{c:?}"); - // Crop 10 pt on every side: the content spreads past the frame. - s.execute("object.fittingOptions", &json!({"ids": [f], "crop": 10, "align": 4})).unwrap(); + // Crop 10 pt on every side and fit again: the content spreads past the frame. + s.execute("object.fittingOptions", &json!({"ids": [f], "fitting": "fitProportionally", "crop": 10, "align": 4})).unwrap(); let c = content(&s); assert!((c.width() - 220.0).abs() < 1e-6, "{c:?}"); // Auto-fit: resizing the frame refits the content. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index c1032bccf..17d1ed098 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -284,7 +284,15 @@ fn place_gun_click_drag_and_into_frame() { let f = s.execute("frame.create", &json!({"rect": [300, 300, 400, 400], "content": "graphic"})).unwrap()["id"].as_u64().unwrap(); s.execute("place.load", &json!({"base64": b64})).unwrap(); s.execute("place.drop", &json!({"frame": f})).unwrap(); - assert!(matches!(s.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap().content, designcraft_doc::Content::Graphic(_))); + let graphic_xf = |s: &Session| match &s.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap().content { + designcraft_doc::Content::Graphic(g) => g.xf, + _ => panic!("not a graphic"), + }; + let placed = graphic_xf(&s); + // Dragging the top edge down crops the top only: the image stays where it is. + s.execute("selection.set", &json!({"ids": [f]})).unwrap(); + s.execute("transform.resize", &json!({"from": [300, 300, 400, 400], "to": [300, 340, 400, 400]})).unwrap(); + assert_eq!(graphic_xf(&s), placed); } #[test] From 7968afa39f88471796771a50bc0e770a1c7ed94e Mon Sep 17 00:00:00 2001 From: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Date: Mon, 12 Oct 2026 03:08:18 +0200 Subject: [PATCH 036/238] SVG text: find fonts by PostScript name A font-family that names no family but matches a face's PostScript name (as Illustrator's SVG export writes them, e.g. 'MyriadPro-Regular') now sets the text in that face, from the bundled, loaded or installed fonts. The name picks the face, so font-weight and font-style don't change it. Family names and the generic fallbacks resolve as before. Part of #430 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/fonts/src/fontdb.rs | 44 ++++++++++++++++- crates/fonts/src/tests_sysfonts.rs | 20 ++++++++ crates/images/src/lib.rs | 78 ++++++++++++++++++++++++++++-- 3 files changed, 135 insertions(+), 7 deletions(-) diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 14441e144..1a400e9fe 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -121,6 +121,8 @@ pub struct FontFace { em: std::sync::OnceLock<(f64, f64)>, /// The font menu group and native family name, read on first use. group: std::sync::OnceLock<(FontGroup, Option<String>)>, + /// The PostScript name, read on first use. + postscript: std::sync::OnceLock<Option<String>>, } /// A cheap `Copy` handle to a face. Faces are never unloaded, so the handle lives for the rest of @@ -271,6 +273,13 @@ impl FontFace { let (g, n) = self.group.get_or_init(|| self.skrifa().map(|f| crate::group::classify(&f, &self.family)).unwrap_or_default()); (*g, n.as_deref()) } + /// The PostScript name (`name` ID 6), read from the font once. None for a variable font's + /// named instance: the file's name belongs to its default instance. + pub fn postscript_name(&self) -> Option<&str> { + self.postscript + .get_or_init(|| if self.is_variable() { None } else { self.skrifa().and_then(|f| name(&f, &[StringId::POSTSCRIPT_NAME])) }) + .as_deref() + } /// Glyph id for `c` (0 = .notdef). pub fn glyph_for(&self, c: char) -> u32 { self.skrifa().and_then(|f| f.charmap().map(c)).map(|g| g.to_u32()).unwrap_or(0) @@ -304,6 +313,7 @@ struct CatalogEntry { /// The font menu group and native family name, read by the scan. group: FontGroup, native: Option<String>, + postscript: Option<String>, } /// What the scan reads of one face in a font file: its names and font menu group. @@ -314,6 +324,7 @@ struct ScannedFace { style: String, group: FontGroup, native: Option<String>, + postscript: Option<String>, } /// Process-wide font database: the fonts every document shares (bundled, installed, added), and @@ -567,7 +578,8 @@ fn file_face_names(path: &std::path::Path) -> Vec<ScannedFace> { crate::group::classify(&skrifa::FontRef::new(&font).ok()?, &family) } }; - Some(ScannedFace { family, style, group, native }) + let postscript = name(&f, &[StringId::POSTSCRIPT_NAME]); + Some(ScannedFace { family, style, group, native, postscript }) }) .collect() } @@ -709,6 +721,7 @@ fn make_face(bytes: FontBytes, source: FontSource, index: u32, family: String, s bmp: std::sync::OnceLock::new(), em: std::sync::OnceLock::new(), group: std::sync::OnceLock::new(), + postscript: std::sync::OnceLock::new(), }) } @@ -925,7 +938,14 @@ impl FontDb { continue; } for f in file_face_names(&p) { - found.push(CatalogEntry { family: f.family, style: f.style, path: p.clone(), group: f.group, native: f.native }); + found.push(CatalogEntry { + family: f.family, + style: f.style, + path: p.clone(), + group: f.group, + native: f.native, + postscript: f.postscript, + }); } } } @@ -1074,6 +1094,11 @@ impl FontDb { self.scoped(0).face(family, style) } + /// The shared face with PostScript name `name` (see [`ScopedFonts::postscript_face`]). + pub fn postscript_face(&self, name: &str) -> Option<(String, String)> { + self.scoped(0).postscript_face(name) + } + /// Is `family` among the shared fonts (loaded, or installed on the system)? pub fn has_family(&self, family: &str) -> bool { self.scoped(0).has_family(family) @@ -1283,6 +1308,21 @@ impl ScopedFonts<'_> { self.has_exact_family(family) || without_format_suffix(family).is_some_and(|base| self.has_exact_family(base)) } + /// The family and style of the face whose PostScript name is `name`, ignoring case (the + /// document's, loaded, or installed on the system, not loading it). SVGs exported from + /// Illustrator name fonts this way (`font-family="'MyriadPro-Regular'"`). + pub fn postscript_face(&self, name: &str) -> Option<(String, String)> { + let named = |ps: Option<&str>| ps.is_some_and(|ps| ps.eq_ignore_ascii_case(name)); + if let Some(f) = self.own().iter().chain(self.db.read_faces().iter()).find(|f| named(f.postscript_name())) { + return Some((f.family.clone(), f.style.clone())); + } + #[cfg(not(target_arch = "wasm32"))] + if let Some(c) = self.db.read_catalog().iter().find(|c| named(c.postscript.as_deref())) { + return Some((c.family.clone(), c.style.clone())); + } + None + } + fn has_exact_family(&self, family: &str) -> bool { if self.own().iter().any(|f| f.family.eq_ignore_ascii_case(family)) || self.db.is_loaded(family) { return true; diff --git a/crates/fonts/src/tests_sysfonts.rs b/crates/fonts/src/tests_sysfonts.rs index a9cda7cdc..f10088705 100644 --- a/crates/fonts/src/tests_sysfonts.rs +++ b/crates/fonts/src/tests_sysfonts.rs @@ -164,3 +164,23 @@ fn font_menus_group_installed_and_loaded_fonts_without_loading_them() { assert_eq!(infos.iter().filter(|i| i.family == "Groups Mincho").count(), 1); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn installed_and_loaded_fonts_are_found_by_postscript_name() { + let dir = std::env::temp_dir().join(format!("dc-sysfonts-postscript-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("ps.ttf"), crate::testing::font_with("DC Test PS Installed", &['a']).unwrap()).unwrap(); + let db = FontDb::with_font_dirs(vec![dir]); + let found = Some(("DC Test PS Installed".to_string(), "Regular".to_string())); + // From the catalog, without loading the font; case doesn't matter. + assert_eq!(db.postscript_face("dctestpsinstalled-regular"), found); + assert!(!db.is_loaded("DC Test PS Installed")); + // Bundled and added (loaded) faces. + assert_eq!(db.postscript_face("SourceSerif4-Semibold"), Some(("Source Serif 4".to_string(), "Semibold".to_string()))); + db.add_font(crate::testing::font_with("DC Test PS Added", &['a']).unwrap()); + assert_eq!(db.postscript_face("DCTestPSAdded-Regular"), Some(("DC Test PS Added".to_string(), "Regular".to_string()))); + // A family name isn't a PostScript name. + assert_eq!(db.postscript_face("Source Serif 4"), None); + assert_eq!(db.postscript_face("NoSuchFont-Regular"), None); +} diff --git a/crates/images/src/lib.rs b/crates/images/src/lib.rs index 4751263a4..4f507f486 100644 --- a/crates/images/src/lib.rs +++ b/crates/images/src/lib.rs @@ -73,25 +73,54 @@ fn svg_options() -> &'static usvg::Options<'static> { } /// A family the SVG names that isn't bundled comes from the shared fonts (installed ones too), -/// as it would in a text frame. +/// as it would in a text frame. A name that is no family's but a face's PostScript name (as +/// Illustrator's SVG export writes them: `font-family="'MyriadPro-Regular'"`) sets the text in +/// that face, whatever font-weight and font-style say: the name already picks the weight and +/// style, and a family's other faces would contradict it. Families listed before it come first. fn select_font() -> usvg::FontSelectionFn<'static> { let bundled = usvg::FontResolver::default_font_selector(); Box::new(move |font, db| { let fonts = designcraft_fonts::FontDb::global(); for family in font.families() { if let usvg::FontFamily::Named(name) = family - && !db.faces().any(|f| f.families.iter().any(|(n, _)| n.eq_ignore_ascii_case(name))) - && fonts.has_family(name) + && !has_family(db, name) { - for style in fonts.styles(name) { - add_face(db, fonts.face(name, &style)); + if fonts.has_family(name) { + for style in fonts.styles(name) { + add_face(db, fonts.face(name, &style)); + } + } else if postscript_face(db, name).is_none() + && let Some((family, style)) = fonts.postscript_face(name) + { + add_face(db, fonts.face(&family, &style)); } } } + for family in font.families() { + match family { + usvg::FontFamily::Named(name) if !has_family(db, name) => { + if let Some(id) = postscript_face(db, name) { + return Some(id); + } + } + // A family (or generic family) that matches: usvg's own selection. + _ => break, + } + } bundled(font, db) }) } +/// Does a face in the SVG's font database have family `name` (ignoring case)? +fn has_family(db: &usvg::fontdb::Database, name: &str) -> bool { + db.faces().any(|f| f.families.iter().any(|(n, _)| n.eq_ignore_ascii_case(name))) +} + +/// The face in the SVG's font database with PostScript name `name` (ignoring case). +fn postscript_face(db: &usvg::fontdb::Database, name: &str) -> Option<usvg::fontdb::ID> { + db.faces().find(|f| f.post_script_name.eq_ignore_ascii_case(name)).map(|f| f.id) +} + /// Characters the chosen fonts lack (Korean, Arabic…) fall back to a shared face that has them. fn select_fallback() -> usvg::FallbackSelectionFn<'static> { let loaded = usvg::FontResolver::default_fallback_selector(); @@ -342,6 +371,45 @@ mod tests { assert!(set.len() == 2 && set.iter().all(|(f, gid)| f != "Source Sans 3" && *gid != 0), "{set:?}"); } + /// The PostScript name of the face that sets the text of `svg` (one face for all of it). + fn text_face(svg_attrs: &str) -> String { + let svg = + format!(r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="40"><text y="30" font-size="20"{svg_attrs}>ab</text></svg>"#); + let tree = svg_tree(svg.as_bytes()).unwrap(); + let mut faces = Vec::new(); + for n in tree.root().children() { + if let usvg::Node::Text(t) = n { + for g in t.layouted().iter().flat_map(|s| &s.positioned_glyphs) { + faces.extend(tree.fontdb().face(g.font).map(|f| f.post_script_name.clone())); + } + } + } + faces.dedup(); + assert_eq!(faces.len(), 1, "{faces:?}"); + faces.remove(0) + } + + #[test] + fn svg_text_finds_fonts_by_postscript_name() { + // Bundled, as Illustrator's SVG export names it; any case. + assert_eq!(text_face(r#" font-family="'SourceSerif4-Semibold'""#), "SourceSerif4-Semibold"); + assert_eq!(text_face(r#" font-family="sourceserif4-semibold""#), "SourceSerif4-Semibold"); + // A shared font (as an installed one would be). + let font = designcraft_fonts::testing::font_with("DC Test SVG PostScript", &['a', 'b']).unwrap(); + designcraft_fonts::FontDb::global().add_font(font); + assert_eq!(text_face(r#" font-family="DCTestSVGPostScript-Regular""#), "DCTestSVGPostScript-Regular"); + // The name picks that face: font-weight and font-style don't change it. + assert_eq!(text_face(r#" font-family="SourceSerif4-Semibold" font-weight="bold" font-style="italic""#), "SourceSerif4-Semibold"); + // A family listed before it still comes first; one after it is a fallback. + assert_eq!(text_face(r#" font-family="'Source Sans 3', SourceSerif4-Bold""#), "SourceSans3-Regular"); + assert_eq!(text_face(r#" font-family="NoSuch-Font, SourceSerif4-Bold, 'Source Sans 3'""#), "SourceSerif4-Bold"); + // Family names work as before. + assert_eq!(text_face(r#" font-family="'Source Serif 4'" font-weight="bold""#), "SourceSerif4-Bold"); + // An unknown name falls back as before (the serif default). + assert_eq!(text_face(r#" font-family="NoSuch-Font""#), text_face(r#" font-family="No Such Font""#)); + assert_eq!(text_face(r#" font-family="NoSuch-Font""#), "SourceSerif4-Regular"); + } + /// A minimal Photoshop file: 2×1 RGB, no layers, raw merged image. fn tiny_psd() -> Vec<u8> { let mut b = Vec::new(); From 223dedfe5a56b35dfd4ba9f2643d96c0f7b5d005 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:23:58 -0400 Subject: [PATCH 037/238] feat(assets): deduplicate files and omit unused assets on save Share duplicate file data while keeping distinct link paths independent, and leave unused assets out of saved documents. Validation: all six cargo xtask ci gates passed on the merge candidate; 1,170 native tests passed and all 15 WASM packages checked. Independent regression checks cover updates, relinking, embedding, undo and save/reopen. Task: #420 --- crates/doc/src/assets.rs | 351 ++++++++++++++++++++++++ crates/doc/src/lib.rs | 2 + crates/engine/src/cmd/datamerge/fill.rs | 41 ++- crates/engine/src/cmd/file.rs | 12 +- crates/engine/src/cmd/links.rs | 117 ++++++++ crates/engine/src/cmd/mathexpr.rs | 15 +- crates/engine/src/cmd/media.rs | 9 +- crates/engine/src/cmd/object.rs | 20 +- crates/engine/src/cmd/package.rs | 6 +- crates/engine/src/cmd/table.rs | 16 +- crates/format/src/lib.rs | 153 +++++++++-- crates/idml/src/import.rs | 41 ++- crates/idml/src/tests.rs | 1 + crates/idml/src/tests/assets.rs | 110 ++++++++ 14 files changed, 812 insertions(+), 82 deletions(-) create mode 100644 crates/doc/src/assets.rs create mode 100644 crates/idml/src/tests/assets.rs diff --git a/crates/doc/src/assets.rs b/crates/doc/src/assets.rs new file mode 100644 index 000000000..24ba5c243 --- /dev/null +++ b/crates/doc/src/assets.rs @@ -0,0 +1,351 @@ +//! Placed files (assets): one asset per distinct file, the assets a document uses, and the +//! compaction a save applies (duplicates merged, unused assets left out). +//! +//! An asset holds what is shared by every placement of a file (bytes, link, type, the PDF page +//! shown); each placement's frame data (transform, crop, fitting) lives on its [`Graphic`]. + +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::sync::Arc; + +use crate::story::Story; +use crate::{Asset, AssetId, Content, Document, Item}; + +/// Nesting limit for groups, anchored objects, table cells and notes. A document nested deeper +/// is treated as using every asset, so nothing it references is merged away or pruned. +const MAX_DEPTH: usize = 64; + +impl Asset { + /// Whether `other` holds the same file: the same type, page, link and bytes. Files at + /// different paths stay apart even with identical bytes, since each can be updated or + /// relinked on its own. Assets without bytes (a link that couldn't be read) are the same only + /// when they link to the same path. + pub fn same_file(&self, other: &Asset) -> bool { + if self.mime != other.mime || self.page != other.page { + return false; + } + if self.link != other.link { + return false; + } + match (self.data.is_empty(), other.data.is_empty()) { + (false, false) => self.same_bytes(other), + (true, true) => self.link.is_some(), + _ => false, + } + } + + fn same_bytes(&self, other: &Asset) -> bool { + Arc::ptr_eq(&self.data, &other.data) || (self.data.len() == other.data.len() && *self.data == *other.data) + } +} + +/// The asset in `assets` holding the same file as `asset`. +pub fn find_same_asset(assets: &BTreeMap<AssetId, Arc<Asset>>, asset: &Asset) -> Option<AssetId> { + assets.values().find(|a| a.same_file(asset)).map(|a| a.id) +} + +/// Add `asset` to `assets` unless they already hold the same file; returns the id placements +/// should reference. A new asset with the bytes of an existing one shares its buffer. +pub fn insert_asset(assets: &mut BTreeMap<AssetId, Arc<Asset>>, mut asset: Asset) -> AssetId { + if let Some(id) = find_same_asset(assets, &asset) { + return id; + } + if !asset.data.is_empty() + && let Some(a) = assets.values().find(|a| a.same_bytes(&asset)) + { + asset.data = Arc::clone(&a.data); + } + let id = asset.id; + assets.insert(id, Arc::new(asset)); + id +} + +/// Calls `f` with every asset reference in `it` and its children; false when nesting exceeded +/// [`MAX_DEPTH`] (some references weren't visited). +fn item_refs(it: &Item, f: &mut impl FnMut(AssetId), depth: usize) -> bool { + if depth > MAX_DEPTH { + return false; + } + if let Content::Graphic(g) = &it.content { + f(g.asset); + } + if let Some(p) = it.media.as_ref().and_then(|m| m.poster) { + f(p); + } + it.children().iter().all(|c| item_refs(c, f, depth + 1)) +} + +/// Asset references in a story's anchored objects, graphic cells and notes (recursively). +fn story_refs(st: &Story, f: &mut impl FnMut(AssetId), depth: usize) -> bool { + if depth > MAX_DEPTH { + return false; + } + let mut complete = st.objects.iter().all(|o| item_refs(&o.item, f, depth + 1)); + for t in st.tables.values() { + for c in &t.cells { + if let Some(g) = &c.graphic { + f(g.asset); + } + complete &= story_refs(&c.text, f, depth + 1); + } + } + for n in st.notes.iter().chain(&st.endnotes) { + complete &= story_refs(&n.text, f, depth + 1); + } + complete +} + +/// Every asset reference in a story (its anchored objects, graphic cells and notes). +pub fn story_asset_refs(st: &Story) -> Vec<AssetId> { + let mut out = Vec::new(); + story_refs(st, &mut |a| out.push(a), 0); + out +} + +/// Point a story's references to a key of `map` at its value. +pub fn remap_story_assets(st: &mut Story, map: &HashMap<AssetId, AssetId>) { + if !map.is_empty() { + remap_story(st, map, 0); + } +} + +fn item_uses(it: &Item, map: &HashMap<AssetId, AssetId>) -> bool { + let mut hit = false; + item_refs(it, &mut |a| hit |= map.contains_key(&a), 0); + hit +} + +fn story_uses(st: &Story, map: &HashMap<AssetId, AssetId>) -> bool { + let mut hit = false; + story_refs(st, &mut |a| hit |= map.contains_key(&a), 0); + hit +} + +fn remap_id(id: &mut AssetId, map: &HashMap<AssetId, AssetId>) { + if let Some(n) = map.get(id) { + *id = *n; + } +} + +/// Rewrites the references in `it`, copying only the shared items that change. +fn remap_item(it: &mut Arc<Item>, map: &HashMap<AssetId, AssetId>, depth: usize) { + if depth > MAX_DEPTH || !item_uses(it, map) { + return; + } + remap_item_in(Arc::make_mut(it), map, depth); +} + +fn remap_item_in(it: &mut Item, map: &HashMap<AssetId, AssetId>, depth: usize) { + if let Content::Graphic(g) = &mut it.content { + remap_id(&mut g.asset, map); + } + if let Some(p) = it.media.as_mut().and_then(|m| m.poster.as_mut()) { + remap_id(p, map); + } + if let Some(kids) = it.children_mut() { + for k in kids { + remap_item(k, map, depth + 1); + } + } +} + +fn remap_story(st: &mut Story, map: &HashMap<AssetId, AssetId>, depth: usize) { + if depth > MAX_DEPTH { + return; + } + for o in &mut st.objects { + if item_uses(&o.item, map) { + remap_item_in(&mut Arc::make_mut(o).item, map, depth + 1); + } + } + for t in st.tables.values_mut() { + if !t.cells.iter().any(|c| c.graphic.as_ref().is_some_and(|g| map.contains_key(&g.asset)) || story_uses(&c.text, map)) { + continue; + } + for c in &mut Arc::make_mut(t).cells { + if let Some(g) = &mut c.graphic { + remap_id(&mut g.asset, map); + } + remap_story(&mut c.text, map, depth + 1); + } + } + for n in st.notes.iter_mut().chain(st.endnotes.iter_mut()) { + if story_uses(&n.text, map) { + remap_story(&mut Arc::make_mut(n).text, map, depth + 1); + } + } +} + +impl Document { + /// Add a placed file, reusing the asset of an identical file already in the document; + /// returns the id to reference. + pub fn add_asset(&mut self, asset: Asset) -> AssetId { + insert_asset(&mut self.assets, asset) + } + + /// Copy asset `id` of `src` into this document (or reuse the identical asset it already + /// has); returns the id to reference here, `None` when `src` has no such asset. + pub fn adopt_asset(&mut self, src: &Document, id: AssetId) -> Option<AssetId> { + let a = src.assets.get(&id)?; + if let Some(same) = find_same_asset(&self.assets, a) { + return Some(same); + } + let fresh = AssetId(self.alloc()); + Some(self.add_asset(Asset { id: fresh, ..(**a).clone() })) + } + + /// Calls `f` with every asset reference: graphics on document and parent spreads (in + /// groups and multi-state objects too), media posters, anchored and inline objects, graphic + /// table cells and notes. False when nesting exceeded the depth limit. + fn asset_refs(&self, f: &mut impl FnMut(AssetId)) -> bool { + let mut complete = true; + for sp in self.spreads.iter().chain(&self.parents) { + for it in &sp.items { + complete &= item_refs(it, f, 0); + } + } + for st in self.stories.values() { + complete &= story_refs(st, f, 0); + } + complete + } + + /// The assets something in the document references. When the document nests deeper than + /// the traversal limit, every asset counts as used. + pub fn used_assets(&self) -> BTreeSet<AssetId> { + let mut used = BTreeSet::new(); + if !self.asset_refs(&mut |a| { + used.insert(a); + }) { + return self.assets.keys().copied().collect(); + } + used + } + + /// Point every reference to a key of `map` at its value. + pub fn remap_assets(&mut self, map: &HashMap<AssetId, AssetId>) { + if map.is_empty() { + return; + } + for sp in self.spreads.iter_mut().chain(self.parents.iter_mut()) { + if sp.items.iter().any(|it| item_uses(it, map)) { + for it in &mut Arc::make_mut(sp).items { + remap_item(it, map, 0); + } + } + } + for st in self.stories.values_mut() { + if story_uses(st, map) { + remap_story(Arc::make_mut(st), map, 0); + } + } + } + + /// Merge assets holding the same file into the first of them (lowest id), rewriting the + /// references; returns how many assets were removed. + pub fn merge_duplicate_assets(&mut self) -> usize { + if !self.asset_refs(&mut |_| {}) { + return 0; + } + let mut reps: HashMap<(usize, &str, u32), Vec<&Arc<Asset>>> = HashMap::new(); + let mut map = HashMap::new(); + for a in self.assets.values() { + let bucket = reps.entry((a.data.len(), a.mime.as_str(), a.page)).or_default(); + match bucket.iter().find(|r| r.same_file(a)) { + Some(r) => { + map.insert(a.id, r.id); + } + None => bucket.push(a), + } + } + self.remap_assets(&map); + self.assets.retain(|id, _| !map.contains_key(id)); + map.len() + } + + /// Merge duplicate assets and drop the ones nothing references (what a saved file holds). + pub fn compact_assets(&mut self) { + self.merge_duplicate_assets(); + let used = self.used_assets(); + self.assets.retain(|id, _| used.contains(id)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Graphic, ItemId, LayerId, Shape, StoryId}; + use designcraft_geom::{Affine, Rect, shapes}; + + fn asset(id: u64, data: &[u8]) -> Asset { + Asset { id: AssetId(id), name: "p.png".into(), mime: "image/png".into(), link: None, data: Arc::new(data.to_vec()), pixels: None, page: 0 } + } + + fn graphic_item(id: u64, asset: u64, crop: f64) -> Item { + let mut it = Item::new(ItemId(id), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0))); + it.content = Content::Graphic(Graphic { + asset: AssetId(asset), + size: (10.0, 10.0), + xf: Affine::translate((crop, 0.0)), + auto_fit: Default::default(), + fit_align: 4, + crop: [crop; 4], + }); + it + } + + #[test] + fn identical_files_share_one_asset() { + let mut d = Document::new(&Default::default()); + let a = d.add_asset(asset(1, b"same")); + let b = d.add_asset(asset(2, b"same")); + let c = d.add_asset(asset(3, b"diff")); + let p = d.add_asset(Asset { page: 1, ..asset(4, b"same") }); + assert_eq!(a, b); + assert_ne!(a, c); + assert_ne!(a, p); + assert_eq!(d.assets.len(), 3); + // Unreadable links match by path only. + let m1 = d.add_asset(Asset { link: Some("/m/1.png".into()), ..asset(5, b"") }); + let m2 = d.add_asset(Asset { link: Some("/m/2.png".into()), ..asset(6, b"") }); + let m3 = d.add_asset(asset(7, b"")); + let m4 = d.add_asset(asset(8, b"")); + assert_ne!(m1, m2); + assert_ne!(m3, m4); + } + + #[test] + fn files_at_different_paths_stay_separate_assets() { + let mut d = Document::new(&Default::default()); + let linked = |id, path: &str| Asset { link: Some(path.into()), ..asset(id, b"same") }; + let embedded = d.add_asset(asset(1, b"same")); + let a = d.add_asset(linked(2, "/p/a.png")); + let b = d.add_asset(linked(3, "/q/b.png")); + assert_eq!(d.add_asset(linked(4, "/p/a.png")), a, "the same path placed again"); + assert_eq!(d.assets.len(), 3); + assert!(embedded != a && a != b && embedded != b); + assert_eq!(d.assets[&embedded].link, None); + assert_eq!(d.assets[&b].link.as_deref(), Some("/q/b.png")); + assert!(Arc::ptr_eq(&d.assets[&a].data, &d.assets[&b].data), "identical bytes share one buffer"); + } + + #[test] + fn compaction_merges_duplicates_and_drops_unused_assets() { + let mut d = Document::new(&Default::default()); + for (id, data) in [(1, b"one"), (2, b"one"), (3, b"two"), (4, b"old")] { + d.assets.insert(AssetId(id), Arc::new(asset(id, data))); + } + let sp = Arc::make_mut(d.spreads.first_mut().unwrap()); + sp.items.push(Arc::new(graphic_item(10, 1, 1.0))); + sp.items.push(Arc::new(graphic_item(11, 2, 2.0))); + // A graphic cell and an anchored object in a story. + let mut st = Story::new(StoryId(20)); + st.objects.push(Arc::new(crate::AnchoredObject::new(graphic_item(12, 3, 3.0), Default::default()))); + d.stories.insert(StoryId(20), Arc::new(st)); + assert_eq!(d.used_assets(), [1, 2, 3].map(AssetId).into_iter().collect()); + + d.compact_assets(); + assert_eq!(d.assets.keys().copied().collect::<Vec<_>>(), vec![AssetId(1), AssetId(3)]); + let crops: Vec<(AssetId, f64)> = d.spreads[0].items.iter().filter_map(|i| i.graphic().map(|g| (g.asset, g.crop[0]))).collect(); + assert_eq!(crops, vec![(AssetId(1), 1.0), (AssetId(1), 2.0)]); + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index c4b69e86d..2c90d8b95 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -12,6 +12,7 @@ pub mod anchored; pub mod arabic; pub mod arrow; +pub mod assets; pub mod attrs; pub mod build; pub mod cjk; @@ -36,6 +37,7 @@ use std::collections::BTreeMap; use std::sync::Arc; pub use anchored::{AnchorPosition, AnchoredObject, OBJECT_MARK}; +pub use assets::{find_same_asset, insert_asset, remap_story_assets, story_asset_refs}; pub use attrs::*; pub use datamerge::{ DataField, DataFieldKind, DataMerge, DataSource, Delimiter, Fingerprint, MergeOptions, Placeholder, PlaceholderAnchor, PlaceholderRole, diff --git a/crates/engine/src/cmd/datamerge/fill.rs b/crates/engine/src/cmd/datamerge/fill.rs index deb0948bc..89dd0c74e 100644 --- a/crates/engine/src/cmd/datamerge/fill.rs +++ b/crates/engine/src/cmd/datamerge/fill.rs @@ -75,6 +75,11 @@ fn renumber(dst: &mut designcraft_doc::Document, src: &designcraft_doc::Document let new_id = ItemId(dst.alloc()); maps.items.insert(old, new_id); it.id = new_id; + if let Some(m) = it.media.as_mut() + && let Some(p) = m.poster + { + m.poster = dst.adopt_asset(src, p).or(Some(p)); + } match &mut it.content { Content::Text(tf) => { let new_sid = if let Some(n) = maps.stories.get(&tf.story) { @@ -83,6 +88,15 @@ fn renumber(dst: &mut designcraft_doc::Document, src: &designcraft_doc::Document let n = StoryId(dst.alloc()); let mut st = src.story(tf.story).cloned().unwrap_or_else(|| Story::new(n)); st.id = n; + let mut assets = HashMap::new(); + for a in designcraft_doc::story_asset_refs(&st) { + if let Some(na) = dst.adopt_asset(src, a) + && na != a + { + assets.insert(a, na); + } + } + designcraft_doc::remap_story_assets(&mut st, &assets); st.frames.clear(); dst.stories.insert(n, Arc::new(st)); maps.stories.insert(tf.story, n); @@ -94,8 +108,8 @@ fn renumber(dst: &mut designcraft_doc::Document, src: &designcraft_doc::Document } } Content::Graphic(g) => { - if let Some(a) = src.assets.get(&g.asset) { - dst.assets.entry(g.asset).or_insert_with(|| a.clone()); + if let Some(a) = dst.adopt_asset(src, g.asset) { + g.asset = a; } } Content::Group { items } => { @@ -355,21 +369,18 @@ fn place_image( }; let (nw, nh) = (f64::from(pw), f64::from(ph)); let mode = fitting_of(&options.fitting); - let aid = AssetId(doc.alloc()); + let fresh = AssetId(doc.alloc()); let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| field.to_string()); let link = options.link_images.then(|| path.to_string_lossy().into_owned()); - doc.assets.insert( - aid, - Arc::new(Asset { - id: aid, - name, - mime: designcraft_render::image_mime(&bytes).to_string(), - link, - data: Arc::new(bytes), - pixels: Some((pw, ph)), - page: 0, - }), - ); + let aid = doc.add_asset(Asset { + id: fresh, + name, + mime: designcraft_render::image_mime(&bytes).to_string(), + link, + data: Arc::new(bytes), + pixels: Some((pw, ph)), + page: 0, + }); let mut g = Graphic { asset: aid, size: (nw, nh), xf: Affine::translate((inner.x0, inner.y0)), auto_fit: mode, fit_align: 0, crop: [0.0; 4] }; if let Some(xf) = g.fitted(inner, mode) { g.xf = xf; diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index a0c98f644..97ded38f5 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -337,9 +337,9 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> { let py = p.get("y").and_then(Value::as_f64); let want_w = p.get("width").and_then(Value::as_f64); s.edit(|d, sel| { - let aid = AssetId(d.alloc()); + let fresh = AssetId(d.alloc()); let mime = designcraft_render::image_mime(&bytes).to_string(); - d.assets.insert(aid, Arc::new(Asset { page: pdf_page, id: aid, name, mime, link, data: Arc::new(bytes), pixels: Some((pw, ph)) })); + let aid = d.add_asset(Asset { page: pdf_page, id: fresh, name, mime, link, data: Arc::new(bytes), pixels: Some((pw, ph)) }); // The width the whole page (or image) gets; the height follows the proportions. let w = match want_w { Some(w) => w, @@ -443,13 +443,9 @@ fn place_load(s: &mut Session, p: &Value) -> Result<Value> { let (pw, ph) = designcraft_render::image_size(&bytes).ok_or_else(|| bad("place.load", "unsupported or corrupt image"))?; let natural = designcraft_render::natural_size(&bytes, 0).unwrap_or((pw as f64, ph as f64)); let aid = s.edit(|d, _| { - let aid = AssetId(d.alloc()); + let fresh = AssetId(d.alloc()); let mime = designcraft_render::image_mime(&bytes).to_string(); - d.assets.insert( - aid, - Arc::new(Asset { page: 0, id: aid, name: name.clone(), mime, link: link.clone(), data: Arc::new(bytes), pixels: Some((pw, ph)) }), - ); - Ok(aid) + Ok(d.add_asset(Asset { page: 0, id: fresh, name: name.clone(), mime, link: link.clone(), data: Arc::new(bytes), pixels: Some((pw, ph)) })) })?; s.loaded = Some((aid, natural)); s.set_tool("placeGun"); diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index c05c6dad9..73de3ea7e 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -112,8 +112,10 @@ pub fn status(a: &designcraft_doc::Asset) -> &'static str { } fn list(d: &Document) -> Vec<Value> { + let used = d.used_assets(); d.assets .values() + .filter(|a| used.contains(&a.id)) .map(|a| { let uses: Vec<Value> = uses(d, a.id) .into_iter() @@ -311,3 +313,118 @@ mod relink_folder_tests { let _ = std::fs::remove_dir_all(&dir); } } + +#[cfg(test)] +mod shared_asset_tests { + use super::*; + + fn png(w: u32, h: u32) -> Vec<u8> { + designcraft_render::Rendered { width: w, height: h, pixels: vec![200u8; (w * h * 4) as usize] }.to_png() + } + + #[test] + fn placements_of_one_file_share_an_asset_and_saves_drop_replaced_ones() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = super::super::file::base64_encode(&png(10, 10)); + s.execute("file.place", &json!({"base64": a, "name": "a.png", "x": 72, "y": 72})).unwrap(); + s.execute("edit.deselectAll", &json!({})).unwrap(); + s.execute("file.place", &json!({"base64": a, "name": "a.png", "x": 200, "y": 72})).unwrap(); + let l = s.execute("links.list", &json!({})).unwrap(); + assert_eq!(l.as_array().unwrap().len(), 1); + assert_eq!(l[0]["uses"].as_array().unwrap().len(), 2); + assert_eq!(s.doc().unwrap().doc.assets.len(), 1); + + // A new image into a frame: the old asset stays in memory (undo) but not in the file. + let f = s.execute("frame.create", &json!({"rect": [100, 300, 200, 400]})).unwrap()["id"].as_u64().unwrap(); + let b = super::super::file::base64_encode(&png(20, 10)); + let c = super::super::file::base64_encode(&png(30, 10)); + s.execute("file.place", &json!({"base64": b, "name": "b.png", "frame": f})).unwrap(); + s.execute("file.place", &json!({"base64": c, "name": "c.png", "frame": f})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.assets.len(), 3); + assert_eq!(s.execute("links.list", &json!({})).unwrap().as_array().unwrap().len(), 2); + let back = designcraft_format::load(&designcraft_format::save(&s.doc().unwrap().doc).unwrap()).unwrap(); + let mut names: Vec<&str> = back.assets.values().map(|a| a.name.as_str()).collect(); + names.sort(); + assert_eq!(names, ["a.png", "c.png"]); + } + + #[test] + fn files_with_identical_bytes_at_different_paths_keep_their_own_links() { + let dir = std::env::temp_dir().join(format!("dc-same-bytes-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let (a, b) = (dir.join("a.png"), dir.join("b.png")); + std::fs::write(&a, png(10, 10)).unwrap(); + std::fs::write(&b, png(10, 10)).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + for (path, x) in [(&a, 72), (&b, 200)] { + s.execute("edit.deselectAll", &json!({})).unwrap(); + s.execute("file.place", &json!({"path": path.to_string_lossy(), "x": x, "y": 72})).unwrap(); + } + assert_eq!(s.doc().unwrap().doc.assets.len(), 2); + let rows = |s: &mut Session| -> Vec<(String, Value)> { + let l = s.execute("links.list", &json!({})).unwrap(); + l.as_array().unwrap().iter().map(|r| (r["path"].as_str().unwrap().to_string(), r["pixels"].clone())).collect() + }; + let paths = |r: &[(String, Value)]| r.iter().map(|(p, _)| p.clone()).collect::<Vec<_>>(); + let before = rows(&mut s); + assert_eq!(paths(&before), [a.to_string_lossy(), b.to_string_lossy()]); + + // b.png changes on disk: updating it leaves a.png's placement alone. + std::fs::write(&b, png(20, 10)).unwrap(); + assert_eq!(s.execute("links.update", &json!({})).unwrap()["updated"], 1); + let after = rows(&mut s); + assert_eq!(paths(&after), paths(&before)); + assert_eq!((after[0].1.clone(), after[1].1.clone()), (json!([10, 10]), json!([20, 10]))); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn pasted_graphics_reuse_the_identical_asset_of_the_target() { + use designcraft_doc::build::NewDocument; + use designcraft_doc::{Asset, Graphic, Item, ItemId, Shape, SpreadRef}; + let asset = |id: u64, data: Vec<u8>| Asset { + id: AssetId(id), + name: "p.png".into(), + mime: "image/png".into(), + link: None, + data: Arc::new(data), + pixels: None, + page: 0, + }; + let mut src = Document::new(&NewDocument::default()); + let mut dst = Document::new(&NewDocument::default()); + let (pa, pb) = (png(4, 4), png(8, 4)); + // The source's asset id is taken in the target by a different file. + let sid = AssetId(src.alloc()); + src.assets.insert(sid, Arc::new(asset(sid.0, pa.clone()))); + dst.assets.insert(sid, Arc::new(asset(sid.0, pb))); + dst.alloc(); + let same = AssetId(dst.alloc()); + assert_ne!(same, sid); + dst.assets.insert(same, Arc::new(asset(same.0, pa))); + let iid = ItemId(src.alloc()); + let mut it = Item::new( + iid, + src.default_layer(), + Shape::Rectangle, + designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(0.0, 0.0, 9.0, 9.0)), + ); + it.content = Content::Graphic(Graphic { + asset: sid, + size: (4.0, 4.0), + xf: designcraft_geom::Affine::IDENTITY, + auto_fit: Default::default(), + fit_align: 4, + crop: [1.0; 4], + }); + src.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + let ids = super::super::object::duplicate_from(&mut dst, &src, &[iid], SpreadRef::Doc(0), designcraft_geom::Vec2::ZERO).unwrap(); + let g = dst.item(ids[0]).and_then(|i| i.graphic()).unwrap(); + assert_eq!((g.asset, g.crop), (same, [1.0; 4])); + assert_eq!(dst.assets.len(), 2); + } +} diff --git a/crates/engine/src/cmd/mathexpr.rs b/crates/engine/src/cmd/mathexpr.rs index 791a0d704..ec90d52e3 100644 --- a/crates/engine/src/cmd/mathexpr.rs +++ b/crates/engine/src/cmd/mathexpr.rs @@ -13,11 +13,16 @@ use crate::{Result, Session}; fn math_item(d: &mut designcraft_doc::Document, latex: &str, size: f64, lid: designcraft_doc::LayerId, at: (f64, f64)) -> Item { let (svg, (w, h)) = crate::math::to_svg(latex, size); - let aid = AssetId(d.alloc()); - d.assets.insert( - aid, - Arc::new(Asset { page: 0, id: aid, name: "math.svg".into(), mime: "image/svg+xml".into(), link: None, data: Arc::new(svg), pixels: None }), - ); + let fresh = AssetId(d.alloc()); + let aid = d.add_asset(Asset { + page: 0, + id: fresh, + name: "math.svg".into(), + mime: "image/svg+xml".into(), + link: None, + data: Arc::new(svg), + pixels: None, + }); let id = ItemId(d.alloc()); let r = Rect::new(at.0, at.1, at.0 + w, at.1 + h); let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(r)); diff --git a/crates/engine/src/cmd/media.rs b/crates/engine/src/cmd/media.rs index b80089585..b6e4cd4b8 100644 --- a/crates/engine/src/cmd/media.rs +++ b/crates/engine/src/cmd/media.rs @@ -25,8 +25,8 @@ pub(crate) fn place_media(s: &mut Session, p: &Value, name: String, mime: &str, let (px, py) = (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)); let mime = mime.to_string(); s.edit(|d, sel| { - let aid = AssetId(d.alloc()); - d.assets.insert(aid, Arc::new(Asset { page: 0, id: aid, name, mime, link, data: Arc::new(bytes), pixels: None })); + let fresh = AssetId(d.alloc()); + let aid = d.add_asset(Asset { page: 0, id: fresh, name, mime, link, data: Arc::new(bytes), pixels: None }); let id = match target { Some(fid) => { let it = d.item_mut(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?; @@ -90,10 +90,9 @@ pub fn specs() -> Vec<CommandSpec> { s.edit(|d, _| { let poster_id = match poster { Some(Some((bytes, name, link, px))) => { - let aid = AssetId(d.alloc()); + let fresh = AssetId(d.alloc()); let mime = designcraft_render::image_mime(&bytes).to_string(); - d.assets.insert(aid, Arc::new(Asset { page: 0, id: aid, name, mime, link, data: Arc::new(bytes), pixels: Some(px) })); - Some(Some(aid)) + Some(Some(d.add_asset(Asset { page: 0, id: fresh, name, mime, link, data: Arc::new(bytes), pixels: Some(px) }))) } Some(None) => Some(None), None => None, diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index c4f2562a1..ca405a181 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -900,6 +900,11 @@ pub fn duplicate_from(dst: &mut Document, src: &Document, ids: &[ItemId], to: Sp fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut HashMap<StoryId, StoryId>) { it.id = ItemId(dst.alloc()); let my_id = it.id; + if let Some(m) = it.media.as_mut() + && let Some(p) = m.poster + { + m.poster = dst.adopt_asset(src, p).or(Some(p)); + } match &mut it.content { Content::Text(tf) => { let new_sid = match stories.get(&tf.story) { @@ -908,6 +913,15 @@ fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut Has let n = StoryId(dst.alloc()); let mut st = src.story(tf.story).cloned().unwrap_or_else(|| Story::new(n)); st.id = n; + let mut assets = HashMap::new(); + for a in designcraft_doc::story_asset_refs(&st) { + if let Some(na) = dst.adopt_asset(src, a) + && na != a + { + assets.insert(a, na); + } + } + designcraft_doc::remap_story_assets(&mut st, &assets); st.frames.clear(); dst.stories.insert(n, Arc::new(st)); stories.insert(tf.story, n); @@ -920,10 +934,8 @@ fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut Has } } Content::Graphic(g) => { - if let Some(a) = src.assets.get(&g.asset) - && !dst.assets.contains_key(&g.asset) - { - dst.assets.insert(g.asset, a.clone()); + if let Some(a) = dst.adopt_asset(src, g.asset) { + g.asset = a; } } Content::Group { items } => { diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index 468667510..25f09537c 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -285,13 +285,13 @@ fn write_links(d: &Document, root: &OutputRoot, dir: &Path, only: Option<&[Asset let mut out = d.clone(); let mut taken = Vec::new(); let mut written = Vec::new(); - let mut ids: Vec<AssetId> = d.assets.keys().copied().collect(); - ids.sort_by_key(|i| i.0); + // Only files something places; the saved document leaves the others out. + let ids = d.used_assets(); for id in ids { if only.is_some_and(|o| !o.contains(&id)) { continue; } - let a = &d.assets[&id]; + let Some(a) = d.assets.get(&id) else { continue }; let name = link_name(a.link.as_deref().unwrap_or(&a.name), id, &mut taken); let path = dir.join(&name); root.write(&path, a.data.as_slice())?; diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 9929c1c71..c2313170a 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -1402,20 +1402,10 @@ fn place_graphic(s: &mut Session, p: &Value) -> Result<Value> { let (pw, ph) = designcraft_render::image_size(&bytes).ok_or_else(|| bad(ID, "unsupported or corrupt image"))?; let fill = p.get("fit").and_then(Value::as_str) == Some("fill"); s.edit(|d, sel| { - let aid = designcraft_doc::AssetId(d.alloc()); + let fresh = designcraft_doc::AssetId(d.alloc()); let mime = designcraft_render::image_mime(&bytes).to_string(); - d.assets.insert( - aid, - std::sync::Arc::new(designcraft_doc::Asset { - page: 0, - id: aid, - name, - mime, - link, - data: std::sync::Arc::new(bytes), - pixels: Some((pw, ph)), - }), - ); + let aid = + d.add_asset(designcraft_doc::Asset { page: 0, id: fresh, name, mime, link, data: std::sync::Arc::new(bytes), pixels: Some((pw, ph)) }); let st = d.story_mut(g.story).ok_or_else(|| bad(ID, "no story"))?; let t = st.table_mut(g.table).ok_or_else(|| bad(ID, "no table"))?; let (r, c) = t.owner(g.range.r0, g.range.c0); diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index f80ad0a65..408bb3ccc 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -2,14 +2,15 @@ //! //! - `mimetype` (stored first, uncompressed): `application/vnd.designcraft+zip` //! - `document.json`: the [`Document`] (pretty JSON, documented by the serde model in `designcraft-doc`) -//! - `assets/<id>.<ext>`: embedded placed files, byte-for-byte +//! - `assets/<id>.<ext>`: embedded placed files, byte-for-byte: one per distinct file, and only +//! the ones the document references (see [`Document::compact_assets`]) //! - `meta.json`: format version and generator //! //! Older single-file JSON documents (assets as base64 in `assetData`) still open. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::io::{Cursor, Read, Write}; use std::sync::Arc; @@ -42,8 +43,12 @@ fn ext_for(mime: &str) -> &'static str { } } -/// Serialize a document to `.designcraft` bytes. +/// Serialize a document to `.designcraft` bytes. The file holds each distinct placed file once +/// and leaves out assets nothing references; `doc` itself keeps them (undo can bring them back). pub fn save(doc: &Document) -> Result<Vec<u8>, FormatError> { + let mut compact = doc.clone(); + compact.compact_assets(); + let doc = &compact; let io = |e: zip::result::ZipError| FormatError::Io(e.to_string()); let mut buf = Cursor::new(Vec::new()); { @@ -100,13 +105,26 @@ fn load_zip(bytes: &[u8]) -> Result<Document, FormatError> { upgrade(&mut v); let mut doc: Document = serde_json::from_value(v).map_err(|e| FormatError::NotOurs(e.to_string()))?; let names: Vec<String> = z.file_names().map(str::to_string).collect(); + // Files saved with one copy per placement: identical copies share one buffer as they are + // read, so memory holds each distinct file once. + let mut loaded: HashMap<usize, Vec<Arc<Vec<u8>>>> = HashMap::new(); for name in names { let Some(stem) = name.strip_prefix("assets/") else { continue }; let Some(id) = stem.split('.').next().and_then(|s| s.parse::<u64>().ok()) else { continue }; if let (Some(data), Some(a)) = (read(&mut z, &name), doc.assets.get_mut(&AssetId(id))) { - Arc::make_mut(a).data = Arc::new(data); + let same = loaded.entry(data.len()).or_default(); + let data = match same.iter().find(|d| ***d == data) { + Some(d) => d.clone(), + None => { + let d = Arc::new(data); + same.push(d.clone()); + d + } + }; + Arc::make_mut(a).data = data; } } + doc.merge_duplicate_assets(); doc.check().map_err(|e| FormatError::NotOurs(e.to_string()))?; Ok(doc) } @@ -124,6 +142,7 @@ fn load_legacy_json(bytes: &[u8]) -> Result<Document, FormatError> { } } } + doc.merge_duplicate_assets(); doc.check().map_err(|e| FormatError::NotOurs(e.to_string()))?; Ok(doc) } @@ -205,24 +224,67 @@ mod tests { use designcraft_doc::geom::Rect; use designcraft_doc::{Asset, ParaFormat, SpreadRef}; + fn png_asset(d: &mut Document, data: &[u8]) -> AssetId { + let aid = AssetId(d.alloc()); + let a = Asset { + page: 0, + id: aid, + name: "x.png".into(), + mime: "image/png".into(), + link: None, + data: Arc::new(data.to_vec()), + pixels: Some((1, 1)), + }; + d.assets.insert(aid, Arc::new(a)); + aid + } + + fn place(d: &mut Document, asset: AssetId, crop: f64) -> designcraft_doc::ItemId { + use designcraft_doc::geom::{Affine, shapes}; + use designcraft_doc::{Content, Graphic, Item, ItemId, Shape}; + let id = ItemId(d.alloc()); + let mut it = Item::new(id, d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0))); + it.content = Content::Graphic(Graphic { + asset, + size: (1.0, 1.0), + xf: Affine::translate((crop, crop)), + auto_fit: Default::default(), + fit_align: 4, + crop: [crop; 4], + }); + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + id + } + + fn asset_entries(bytes: &[u8]) -> usize { + let z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); + z.file_names().filter(|n| n.starts_with("assets/")).count() + } + + /// Zip `d` the way files were saved with one asset per placement (nothing merged or pruned). + fn save_uncompacted(d: &Document) -> Vec<u8> { + let mut buf = Cursor::new(Vec::new()); + let mut z = zip::ZipWriter::new(&mut buf); + let opts = zip::write::SimpleFileOptions::default(); + z.start_file("mimetype", opts).unwrap(); + z.write_all(MIME.as_bytes()).unwrap(); + z.start_file("document.json", opts).unwrap(); + z.write_all(&serde_json::to_vec(d).unwrap()).unwrap(); + for (id, a) in &d.assets { + z.start_file(format!("assets/{}.png", id.0), opts).unwrap(); + z.write_all(&a.data).unwrap(); + } + z.finish().unwrap(); + buf.into_inner() + } + #[test] fn roundtrip_with_assets() { let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() }); let lid = d.default_layer(); d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 200.0, 200.0), lid, "héllo\nworld", ParaFormat::default()).unwrap(); - let aid = AssetId(d.alloc()); - d.assets.insert( - aid, - Arc::new(Asset { - page: 0, - id: aid, - name: "x.png".into(), - mime: "image/png".into(), - link: None, - data: Arc::new(vec![1, 2, 3, 4]), - pixels: Some((1, 1)), - }), - ); + let aid = png_asset(&mut d, &[1, 2, 3, 4]); + place(&mut d, aid, 0.0); let bytes = save(&d).unwrap(); assert!(bytes.starts_with(b"PK")); let back = load(&bytes).unwrap(); @@ -231,6 +293,63 @@ mod tests { assert_eq!(back.stories, d.stories); } + #[test] + fn saves_hold_each_used_file_once() { + let mut d = Document::new(&NewDocument::default()); + let first = png_asset(&mut d, &[7; 64]); + let copy = png_asset(&mut d, &[7; 64]); + let replaced = png_asset(&mut d, &[9; 64]); + let a = place(&mut d, first, 1.0); + let b = place(&mut d, copy, 2.0); + assert_eq!(d.assets.len(), 3); + + // Duplicates merge into the first asset, the unreferenced one is left out, and every + // placement keeps its own crop and transform. + let bytes = save(&d).unwrap(); + assert_eq!(asset_entries(&bytes), 1); + assert_eq!(d.assets.len(), 3, "the open document keeps its assets"); + let back = load(&bytes).unwrap(); + assert_eq!(back.assets.keys().copied().collect::<Vec<_>>(), vec![first]); + assert!(!back.assets.contains_key(&replaced)); + for (id, crop) in [(a, 1.0), (b, 2.0)] { + let g = back.item(id).and_then(|i| i.graphic()).unwrap(); + assert_eq!((g.asset, g.crop, g.xf), (first, [crop; 4], d.item(id).unwrap().graphic().unwrap().xf)); + } + } + + #[test] + fn files_with_identical_bytes_at_different_paths_keep_their_paths() { + let mut d = Document::new(&NewDocument::default()); + let ids: Vec<AssetId> = (0..3).map(|_| png_asset(&mut d, &[3; 32])).collect(); + for (id, link) in ids.iter().zip(["/p/a.png", "/q/b.png"]) { + if let Some(a) = d.assets.get_mut(id) { + Arc::make_mut(a).link = Some(link.into()); + } + } + let items: Vec<_> = ids.iter().enumerate().map(|(k, id)| place(&mut d, *id, k as f64)).collect(); + for bytes in [save(&d).unwrap(), save_uncompacted(&d)] { + let back = load(&bytes).unwrap(); + assert_eq!(back.assets.len(), 3); + let links: Vec<Option<&str>> = items + .iter() + .map(|i| back.item(*i).and_then(|i| i.graphic()).and_then(|g| back.assets.get(&g.asset)).unwrap().link.as_deref()) + .collect(); + assert_eq!(links, [Some("/p/a.png"), Some("/q/b.png"), None]); + } + } + + #[test] + fn older_files_with_one_asset_per_placement_open_with_one() { + let mut d = Document::new(&NewDocument::default()); + let ids: Vec<AssetId> = (0..3).map(|_| png_asset(&mut d, &[5; 32])).collect(); + for (k, id) in ids.iter().enumerate() { + place(&mut d, *id, k as f64); + } + let back = load(&save_uncompacted(&d)).unwrap(); + assert_eq!(back.assets.len(), 1); + assert_eq!(asset_entries(&save(&back).unwrap()), 1); + } + /// The object of the item `id` in a document's JSON. fn item_json(v: &mut Value, id: u64) -> Option<&mut Value> { if v.get("id").and_then(Value::as_u64) == Some(id) && v.get("content").is_some() { diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index d85d9ee02..138775268 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -132,6 +132,8 @@ struct Importer<'r> { page_index: HashMap<String, usize>, item_ids: HashMap<String, ItemId>, assets: BTreeMap<AssetId, Arc<Asset>>, + /// Assets read from a link path, so later placements of that path reuse them unread. + linked_assets: HashMap<String, AssetId>, ctx: ItemCtx, sections: Vec<Section>, footnote_options: designcraft_doc::FootnoteOptions, @@ -217,6 +219,7 @@ impl<'r> Importer<'r> { page_index: HashMap::new(), item_ids: HashMap::new(), assets: BTreeMap::new(), + linked_assets: HashMap::new(), ctx: ItemCtx { threads: Vec::new() }, sections: Vec::new(), footnote_options: Default::default(), @@ -2205,12 +2208,29 @@ impl<'r> Importer<'r> { let size = ((r - l).abs(), (b - t).abs()); let link = g.find("Link"); let uri = link.and_then(|k| k.get("LinkResourceURI")).map(uri_to_path); + let graphic = |asset| { + Content::Graphic(designcraft_doc::Graphic { + asset, + size, + xf: gxf * Affine::translate((l, t)), + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + }) + }; let mut data = g.prop_el("Contents").map(|c| base64_decode(&c.text_content())).unwrap_or_default(); + let mut read_from = None; if data.is_empty() && let Some(p) = &uri - && let Some(d) = (self.read_link)(p) { - data = d; + // Every placement of a linked file shares one asset; read the file once. + if let Some(id) = self.linked_assets.get(p) { + return graphic(*id); + } + if let Some(d) = (self.read_link)(p) { + data = d; + read_from = Some(p.clone()); + } } let (mime, px) = sniff_image(&data); let mime = mime.map(str::to_string).unwrap_or_else(|| match link.and_then(|k| k.get("LinkResourceFormat")).unwrap_or("") { @@ -2226,17 +2246,14 @@ impl<'r> Importer<'r> { }); let name = uri.as_deref().and_then(|p| p.rsplit(['/', '\\']).next()).filter(|n| !n.is_empty()).map(str::to_string).unwrap_or_else(|| "image".into()); - let id = AssetId(self.alloc()); + let fresh = AssetId(self.alloc()); let link_path = uri.filter(|p| p.contains('/') || p.contains('\\')); - self.assets.insert(id, Arc::new(Asset { page: 0, id, name, mime, link: link_path, data: Arc::new(data), pixels })); - Content::Graphic(designcraft_doc::Graphic { - asset: id, - size, - xf: gxf * Affine::translate((l, t)), - auto_fit: Default::default(), - fit_align: 4, - crop: [0.0; 4], - }) + let id = + designcraft_doc::insert_asset(&mut self.assets, Asset { page: 0, id: fresh, name, mime, link: link_path, data: Arc::new(data), pixels }); + if let Some(p) = read_from { + self.linked_assets.insert(p, id); + } + graphic(id) } fn link_threads(&mut self) { diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 7b747587a..4c7817009 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -7,6 +7,7 @@ use designcraft_geom::{Rect, shapes}; use super::*; +mod assets; mod decorations; fn zip_files(files: &[(&str, &str)]) -> Vec<u8> { diff --git a/crates/idml/src/tests/assets.rs b/crates/idml/src/tests/assets.rs new file mode 100644 index 000000000..dd2761442 --- /dev/null +++ b/crates/idml/src/tests/assets.rs @@ -0,0 +1,110 @@ +//! Placed files: every placement of one file shares an asset. + +use std::cell::Cell; + +use designcraft_doc::{AssetId, Content}; + +use super::*; + +const PHOTO: &str = "file:/photos/wedding%20day/couple.jpg"; + +/// A graphic frame at `x` showing `image` (an `<Image>` element) with `crop` on every side. +fn frame(n: usize, x: f64, crop: f64, image: &str) -> String { + format!( + r#"<Rectangle Self="r{n}" ItemLayer="L1" ItemTransform="1 0 0 1 {x} 0" ContentType="GraphicType"> + <Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray> + <PathPointType Anchor="0 0"/><PathPointType Anchor="0 30"/><PathPointType Anchor="40 30"/><PathPointType Anchor="40 0"/> + </PathPointArray></GeometryPathType></PathGeometry></Properties> + <FrameFittingOption TopCrop="{crop}" LeftCrop="{crop}" BottomCrop="{crop}" RightCrop="{crop}"/> + {image} + </Rectangle>"# + ) +} + +fn image(n: usize, uri: &str, contents: Option<&[u8]>) -> String { + let contents = contents.map(|c| format!("<Contents><![CDATA[{}]]></Contents>", base64_encode(c))).unwrap_or_default(); + format!( + r#"<Image Self="im{n}" ItemTransform="1 0 0 1 {n} 0"> + <Properties>{contents}<GraphicBounds Left="0" Top="0" Right="40" Bottom="30"/></Properties> + <Link Self="lk{n}" LinkResourceURI="{uri}" LinkResourceFormat="$ID/JPEG" StoredState="Normal"/> + </Image>"# + ) +} + +fn package(frames: &[String]) -> Vec<u8> { + let spread = format!( + r#"<?xml version="1.0" encoding="UTF-8"?> +<idPkg:Spread xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0"> + <Spread Self="sp1" PageCount="1" ItemTransform="1 0 0 1 0 0"> + <Page Self="p1" AppliedMaster="m1" GeometricBounds="0 0 700 500" ItemTransform="1 0 0 1 0 0"/> + {} + </Spread> +</idPkg:Spread>"#, + frames.join("\n") + ); + zip_files(&[ + ("designmap.xml", DESIGNMAP), + ("Resources/Graphic.xml", GRAPHIC), + ("Resources/Styles.xml", STYLES), + ("Resources/Preferences.xml", PREFS), + ("MasterSpreads/MasterSpread_m1.xml", MASTER), + ("Spreads/Spread_sp1.xml", &spread), + ("Stories/Story_s1.xml", STORY), + ]) +} + +/// (asset, crop, transform) of each graphic, left to right. +fn placements(d: &Document) -> Vec<(AssetId, [f64; 4], designcraft_geom::Affine)> { + let mut out: Vec<(f64, AssetId, [f64; 4], designcraft_geom::Affine)> = d.spreads[0] + .items + .iter() + .filter_map(|i| match &i.content { + Content::Graphic(g) => Some((i.xf.translation().x, g.asset, g.crop, g.xf)), + _ => None, + }) + .collect(); + out.sort_by(|a, b| a.0.total_cmp(&b.0)); + out.into_iter().map(|(_, a, c, x)| (a, c, x)).collect() +} + +#[test] +fn one_file_placed_several_times_imports_as_one_asset() { + let bytes = vec![42u8; 300]; + let reads = Cell::new(0); + let reader = |p: &str| { + reads.set(reads.get() + 1); + p.ends_with("couple.jpg").then(|| bytes.clone()) + }; + // Three placements linking the file, and one embedding the same bytes under another path: + // that one keeps its own asset and path. + let frames = vec![ + frame(1, 0.0, 1.0, &image(1, PHOTO, None)), + frame(2, 100.0, 2.0, &image(2, PHOTO, None)), + frame(3, 200.0, 3.0, &image(3, PHOTO, None)), + frame(4, 300.0, 4.0, &image(4, "file:/elsewhere/copy.jpg", Some(&bytes))), + ]; + let d = import_idml_with(&package(&frames), &reader).unwrap(); + assert_eq!(reads.get(), 1, "the linked file is read once"); + assert_eq!(d.assets.len(), 2); + let p = placements(&d); + assert_eq!(p.iter().map(|(_, c, _)| c[0]).collect::<Vec<_>>(), vec![1.0, 2.0, 3.0, 4.0]); + let link = |id| d.assets.get(id).and_then(|a| a.link.as_deref()); + let links: Vec<_> = p.iter().map(|(id, _, _)| link(id)).collect(); + assert_eq!(links[..3], [Some("/photos/wedding day/couple.jpg"); 3]); + assert_eq!(links[3], Some("/elsewhere/copy.jpg")); + assert!(p[..3].iter().all(|(id, _, _)| *id == p[0].0)); + + // Exported linked or embedded, the round trip keeps the two assets and each placement's crop + // and transform. + for embed_images in [false, true] { + let out = export_idml_with(&d, &ExportOptions { embed_images }); + let back = import_idml_with(&out, &reader).unwrap(); + assert_eq!(back.assets.len(), 2, "embed_images: {embed_images}"); + let q = placements(&back); + assert_eq!(q.len(), 4); + for ((_, c0, x0), (_, c1, x1)) in p.iter().zip(&q) { + assert_eq!(c0, c1); + assert!(x0.as_coeffs().iter().zip(x1.as_coeffs()).all(|(a, b)| (a - b).abs() < 1e-6), "{x0:?} {x1:?}"); + } + } +} From 95c8c9cc5bc28605530e67e9d34b8cdd6331d8cc Mon Sep 17 00:00:00 2001 From: moppeeer <dupalled@gmail.com> Date: Mon, 12 Oct 2026 03:32:49 +0200 Subject: [PATCH 038/238] fix(hyphenation): honor capitalized-word settings for uppercase words Honor the capitalized-word hyphenation setting for uppercase words and retain the current roadmap entries. Validation: all six cargo xtask ci gates passed on the current main merge candidate; 1,170 native tests passed and all 15 WASM packages checked. Task: #538 Co-authored-by: skylartaylor <26588250+skylartaylor@users.noreply.github.com> --- ROADMAP.md | 1 + crates/compose/src/hyphen/mod.rs | 10 ++++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 82a42cf18..ec8eb0920 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11 (printing):** `file.printers` and `file.print` use craft-libs/craft-print for CUPS and Windows. DesignCraft retains PDF export, page selection, spreads, marks and bleed; dry runs create no temporary PDF or print job. Windows selects matching or default driver paper and shrinks oversized sheets without enlarging smaller ones; queue duplex defaults are preserved. Headless tests exercise exported bytes, job settings, dry runs and refusals; shared native Windows CI passes, including paused-queue submission and cleanup. PostScript, production print controls and physical output validation remain open. No readiness percentage change. - **2026-10-10 (UI.4–UI.5):** Panel tabs can move into other groups, split the dock, float, resize and return to their saved locations through shared docking transactions. Layouts persist in custom workspaces; UI and control commands share the same validated action path. Shared pane geometry and widget-state theme application retain all five palettes and local fonts. Split panes send keyboard input only to the focused canvas and scope contextual toolbar IDs per pane. No readiness percentage change. diff --git a/crates/compose/src/hyphen/mod.rs b/crates/compose/src/hyphen/mod.rs index e21ade031..e3f4a43b3 100644 --- a/crates/compose/src/hyphen/mod.rs +++ b/crates/compose/src/hyphen/mod.rs @@ -233,9 +233,8 @@ pub fn hyphen_points_in(word: &str, lim: &Limits, lang: Lang) -> Vec<usize> { if letters < lim.min_word.max(2) { return false; } - if letters > 1 && run.iter().filter(|c| c.is_alphabetic()).all(|c| c.is_uppercase()) { - return false; - } + // Words in capitals (FERRUM COMMODI) hyphenate like capitalized words, as InDesign's: Hyphenate + // Capitalized Words off keeps both, acronyms included, whole. if !lim.capitalized && run[0].is_uppercase() { return false; } @@ -333,7 +332,10 @@ mod tests { #[test] fn case_apostrophes_and_punctuation() { assert_eq!(h("Typography"), "Ty-pog-ra-phy"); - assert_eq!(h("TYPOGRAPHY"), "TYPOGRAPHY"); + assert_eq!(h("TYPOGRAPHY"), "TY-POG-RA-PHY"); + assert_eq!(h("COMMODI"), "COM-MO-DI"); + let whole = Limits { capitalized: false, ..Limits::default() }; + assert_eq!(hyphenate_word("TYPOGRAPHY", &whole), "TYPOGRAPHY", "Hyphenate Capitalized Words off"); assert_eq!(h("typography,"), "ty-pog-ra-phy,"); assert_eq!(h("“typography”"), "“ty-pog-ra-phy”"); assert_eq!(h("typography's"), "ty-pog-ra-phy's"); From f70f93de441df0879ca56adf4ec89f2eda77c323 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:39:34 -0400 Subject: [PATCH 039/238] fix(idml): report missing parts and show import warnings Match IDML part names leniently, report missing parts, and show import warnings when opening a file. Cover the malformed URI crash with a regression test. Validation: all six cargo xtask ci gates passed on the current main merge candidate; 1,176 native tests passed and all 15 WASM packages checked. Hosted FreeBSD tests and Windows ARM64 packaging and install/run checks passed. Task: #356 Co-authored-by: skylartaylor <26588250+skylartaylor@users.noreply.github.com> --- Cargo.lock | 1 + crates/engine/src/cmd/file.rs | 2 +- crates/engine/src/cmd/interchange.rs | 17 +++-- crates/idml/src/import.rs | 100 ++++++++++++++++++------- crates/idml/src/lib.rs | 2 +- crates/idml/src/tests.rs | 54 ++++++++++++++ crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/lib.rs | 108 +++++++++++++++++++++++++++ 8 files changed, 253 insertions(+), 32 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7e5ebb850..05729755a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1160,6 +1160,7 @@ dependencies = [ "unicode-bidi", "unicode-bidi-mirroring", "unicode-segmentation", + "zip", ] [[package]] diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 97ded38f5..ed126eee5 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> { cmd!(noundo "file.newSample", "Sample Document", ["Help"], None, "{} — a multi-page magazine sample", always, file_sample), cmd!(query "file.presets", "Document Presets", [], None, "{}", always, |_, _| Ok(serde_json::to_value(PRESETS).unwrap_or_default())), cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), - "{path} — .designcraft or .idml; the fonts in a `Document Fonts` folder beside it load first → {index, documentFonts: faces loaded, warnings: font files skipped}", + "{path} — .designcraft or .idml; the fonts in a `Document Fonts` folder beside it load first → {index, documentFonts: faces loaded, warnings: font files skipped (and, for .idml, package parts missing)}", always, file_open), cmd!(noundo "file.openBytes", "Open Bytes", [], None, "{name, base64} — DesignCraft JSON or an IDML package", always, file_open_bytes), cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?}", has_doc, file_save), diff --git a/crates/engine/src/cmd/interchange.rs b/crates/engine/src/cmd/interchange.rs index 8a65ec913..a0af09f5f 100644 --- a/crates/engine/src/cmd/interchange.rs +++ b/crates/engine/src/cmd/interchange.rs @@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> { "{path?, embedImages?: true} — writes an IDML package to `path`, or returns {base64} without a path", has_doc, export_idml), cmd!(noundo "file.openIdml", "Open IDML", [], None, - "{path | base64, name?} — opens an IDML package as a new document (linked images are read next to the file or from its Links/ folder; the fonts in a `Document Fonts` folder beside it load first) → {index, documentFonts, warnings}", + "{path | base64, name?} — opens an IDML package as a new document (linked images are read next to the file or from its Links/ folder; the fonts in a `Document Fonts` folder beside it load first) → {index, documentFonts, warnings: package parts missing, font files skipped}", always, open_idml), ] } @@ -34,6 +34,11 @@ fn export_idml(s: &mut Session, p: &Value) -> Result<Value> { /// Import IDML bytes; `dir` is the folder the package came from (for relative link lookup). pub fn import(bytes: &[u8], dir: Option<&std::path::Path>) -> Result<Document> { + import_report(bytes, dir).map(|(document, _)| document) +} + +/// [`import`], with the problems that did not stop it (parts missing from the package, …). +pub fn import_report(bytes: &[u8], dir: Option<&std::path::Path>) -> Result<(Document, Vec<String>)> { #[cfg(not(target_arch = "wasm32"))] let resolved = std::cell::RefCell::new(std::collections::HashMap::new()); let read = |link: &str| -> Option<Vec<u8>> { @@ -49,7 +54,8 @@ pub fn import(bytes: &[u8], dir: Option<&std::path::Path>) -> Result<Document> { None } }; - let document = designcraft_idml::import_idml_with(bytes, &read).map_err(|e| EngineError::Other(e.to_string()))?; + let designcraft_idml::Imported { document, warnings } = + designcraft_idml::import_idml_report(bytes, &read).map_err(|e| EngineError::Other(e.to_string()))?; #[cfg(not(target_arch = "wasm32"))] let document = { let mut document = document; @@ -62,7 +68,7 @@ pub fn import(bytes: &[u8], dir: Option<&std::path::Path>) -> Result<Document> { resolve_packaged_links(&mut document, dir); document }; - Ok(document) + Ok((document, warnings)) } #[cfg(not(target_arch = "wasm32"))] @@ -142,14 +148,15 @@ pub(crate) fn open_idml(s: &mut Session, p: &Value) -> Result<Value> { } else { return Err(bad("file.openIdml", "missing `path` or `base64`")); }; - let mut d = import(&bytes, dir.as_deref())?; + let (mut d, mut warnings) = import_report(&bytes, dir.as_deref())?; if let Some(n) = name { d.title = n; } - let (fonts, faces, warnings) = match str_param(p, "path").filter(|_| str_param(p, "base64").is_none()) { + let (fonts, faces, font_warnings) = match str_param(p, "path").filter(|_| str_param(p, "base64").is_none()) { Some(path) => super::file::load_document_fonts(&mut d, path), None => (None, 0, Vec::new()), }; + warnings.extend(font_warnings); // Never save over the .idml with the native format: the document starts unsaved. let mut st = DocState::new(d, None); st.fonts = fonts; diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 138775268..46c3253b6 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -48,8 +48,24 @@ fn default_reader(path: &str) -> Option<Vec<u8>> { /// Import with a custom reader for linked files (`path` → bytes). pub fn import_idml_with(bytes: &[u8], read_link: &dyn Fn(&str) -> Option<Vec<u8>>) -> Result<Document> { + import_idml_report(bytes, read_link).map(|r| r.document) +} + +/// An imported IDML document and what the import could not bring in. +#[derive(Debug)] +pub struct Imported { + pub document: Document, + /// One line per problem that did not stop the import (a part listed in `designmap.xml` but + /// absent from the package, …). + pub warnings: Vec<String>, +} + +/// Import with a custom reader for linked files, reporting the problems that did not stop it. +pub fn import_idml_report(bytes: &[u8], read_link: &dyn Fn(&str) -> Option<Vec<u8>>) -> Result<Imported> { let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| IdmlError::NotIdml(e.to_string()))?; let mut files: HashMap<String, Vec<u8>> = HashMap::new(); + // Normalised part name → entry name, first entry wins (zip order, so the choice is stable). + let mut lenient: HashMap<String, String> = HashMap::new(); for i in 0..zip.len() { let mut f = zip.by_index(i).map_err(|e| IdmlError::NotIdml(e.to_string()))?; if f.is_dir() { @@ -59,35 +75,81 @@ pub fn import_idml_with(bytes: &[u8], read_link: &dyn Fn(&str) -> Option<Vec<u8> // The size is what the archive claims: don't reserve more than a sane part up front. let mut buf = Vec::with_capacity((f.size() as usize).min(1 << 24)); f.read_to_end(&mut buf).map_err(|e| IdmlError::Part { part: name.clone(), msg: e.to_string() })?; + lenient.entry(normalize_part_name(&name)).or_insert_with(|| name.clone()); files.insert(name, buf); } - if let Some(m) = files.get("mimetype") + let part = |name: &str| files.get(name).or_else(|| lenient.get(&normalize_part_name(name)).and_then(|n| files.get(n))); + if let Some(m) = part("mimetype") && String::from_utf8_lossy(m).trim() != MIMETYPE { return Err(IdmlError::NotIdml(format!("unexpected mimetype `{}`", String::from_utf8_lossy(m).trim()))); } - let dm = files.get("designmap.xml").ok_or_else(|| IdmlError::NotIdml("missing designmap.xml".into()))?; + let dm = part("designmap.xml").ok_or_else(|| IdmlError::NotIdml("missing designmap.xml".into()))?; let root = parse(dm).map_err(|msg| IdmlError::Part { part: "designmap.xml".into(), msg })?; if root.local() != "Document" { return Err(IdmlError::NotIdml(format!("designmap root is <{}>", root.name))); } // Flatten includes: every `idPkg:*` child is replaced by the children of its part's root. let mut top: Vec<El> = Vec::new(); + let mut warnings = Vec::new(); for c in root.elements() { if c.name.starts_with("idPkg:") { let Some(src) = c.get("src") else { continue }; - let Some(data) = files.get(src) else { continue }; - let part = parse(data).map_err(|msg| IdmlError::Part { part: src.into(), msg })?; - top.extend(part.elements().cloned()); + let Some(data) = part(src) else { + warnings.push(format!("{src}: listed in designmap.xml but missing from the package; its contents were not imported")); + continue; + }; + let el = parse(data).map_err(|msg| IdmlError::Part { part: src.into(), msg })?; + top.extend(el.elements().cloned()); } else { top.push(c.clone()); } } let mut im = Importer::new(read_link); im.run(&root, &top)?; - let d = im.finish(&root)?; - d.check().map_err(|e| IdmlError::Invalid(e.to_string()))?; - Ok(d) + let document = im.finish(&root).map_err(|e| match e { + // Without its spread parts there is nothing to open: say which parts were missing. + IdmlError::Invalid(msg) if !warnings.is_empty() => IdmlError::Invalid(format!("{msg} ({})", warnings.join("; "))), + e => e, + })?; + document.check().map_err(|e| IdmlError::Invalid(e.to_string()))?; + Ok(Imported { document, warnings }) +} + +/// A package part name in the form the lookup compares: InDesign and other IDML writers differ +/// in percent-encoding, case, path separators and a leading `./` or `/`. +fn normalize_part_name(name: &str) -> String { + let decoded = percent_decode(&name.replace('\\', "/")); + let mut rest = decoded.as_str(); + loop { + if let Some(r) = rest.strip_prefix("./") { + rest = r; + } else if let Some(r) = rest.strip_prefix('/') { + rest = r; + } else { + break; + } + } + rest.to_lowercase() +} + +/// Decode `%XX` escapes; anything else (including a malformed escape) is kept as written. +pub(crate) fn percent_decode(s: &str) -> String { + let b = s.as_bytes(); + let mut out = Vec::with_capacity(b.len()); + let mut i = 0; + while let Some(&c) = b.get(i) { + if c == b'%' + && let (Some(h), Some(l)) = (b.get(i + 1).and_then(|h| (*h as char).to_digit(16)), b.get(i + 2).and_then(|l| (*l as char).to_digit(16))) + { + out.push((h * 16 + l) as u8); + i += 3; + continue; + } + out.push(c); + i += 1; + } + String::from_utf8_lossy(&out).into_owned() } struct ItemCtx { @@ -2560,21 +2622,9 @@ fn path_of(pg: &El) -> PathData { pub(crate) fn uri_to_path(uri: &str) -> String { let rest = uri.strip_prefix("file://").or_else(|| uri.strip_prefix("file:")).unwrap_or(uri); // `file:///C:/…` → `/C:/…` → `C:/…` - let rest = if rest.len() > 3 && rest.starts_with('/') && rest.as_bytes()[2] == b':' { &rest[1..] } else { rest }; - let mut out = Vec::with_capacity(rest.len()); - let b = rest.as_bytes(); - let mut i = 0; - while i < b.len() { - if b[i] == b'%' - && i + 2 < b.len() - && let Ok(v) = u8::from_str_radix(&rest[i + 1..i + 3], 16) - { - out.push(v); - i += 3; - continue; - } - out.push(b[i]); - i += 1; - } - String::from_utf8_lossy(&out).to_string() + let rest = match rest.as_bytes() { + [b'/', _, b':', _, ..] => rest.get(1..).unwrap_or(rest), + _ => rest, + }; + percent_decode(rest) } diff --git a/crates/idml/src/lib.rs b/crates/idml/src/lib.rs index 71d688255..0526070d0 100644 --- a/crates/idml/src/lib.rs +++ b/crates/idml/src/lib.rs @@ -49,7 +49,7 @@ mod xml; mod tests; pub use export::{ExportOptions, export_idml, export_idml_with}; -pub use import::{import_idml, import_idml_with}; +pub use import::{Imported, import_idml, import_idml_report, import_idml_with}; /// The IDML package mimetype (content of the first, stored `mimetype` entry). pub const MIMETYPE: &str = "application/vnd.adobe.indesign-idml-package"; diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 4c7817009..23ec99bdf 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -428,6 +428,60 @@ fn imports_scalar_insets_in_object_style_without_replacing_frame_override() { assert_eq!(frame_inset(&d), [3.0; 4]); } +/// A one-page spread part `Self` with one rectangle. +fn one_frame_spread(id: &str) -> String { + format!( + r#"<idPkg:Spread xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging"> + <Spread Self="{id}"> + <Page Self="{id}p" GeometricBounds="0 0 100 100" ItemTransform="1 0 0 1 0 0"/> + <Rectangle Self="{id}r" ItemTransform="1 0 0 1 0 0"> + <Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray> + <PathPointType Anchor="10 10"/><PathPointType Anchor="10 50"/> + <PathPointType Anchor="50 50"/><PathPointType Anchor="50 10"/> + </PathPointArray></GeometryPathType></PathGeometry></Properties> + </Rectangle> + </Spread> + </idPkg:Spread>"# + ) +} + +/// Writers differ in how a designmap `src` spells its part: percent-encoded, another case, +/// backslashes, a leading `./`. Those resolve; a part absent from the package is a warning that +/// names it, not silently dropped objects. +#[test] +fn designmap_parts_match_leniently_and_missing_ones_are_reported() { + let designmap = r#"<Document xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" Self="d"> + <idPkg:Spread src="Spreads/Spread_u1a%20b.xml"/> + <idPkg:Spread src="./spreads\SPREAD_u2.xml"/> + <idPkg:Spread src="Spreads/Spread_gone.xml"/> + </Document>"#; + let zip = zip_files(&[ + ("designmap.xml", designmap), + ("Spreads/Spread_u1a b.xml", &one_frame_spread("u1")), + ("Spreads/Spread_u2.xml", &one_frame_spread("u2")), + ]); + let r = import_idml_report(&zip, &|_| None).unwrap(); + assert_eq!(r.document.spreads.len(), 2, "both lenient spellings resolve"); + assert!(r.document.spreads.iter().all(|sp| sp.items.len() == 1), "the spreads keep their frames"); + assert_eq!(r.warnings.len(), 1, "{:?}", r.warnings); + assert!(r.warnings[0].contains("Spreads/Spread_gone.xml"), "{:?}", r.warnings); + + // With every spread part missing the import fails, and the error names the part. + let only_missing = r#"<Document xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" Self="d"> + <idPkg:Spread src="Spreads/Spread_gone.xml"/> + </Document>"#; + let err = import_idml(&zip_files(&[("designmap.xml", only_missing)])).unwrap_err(); + assert!(err.to_string().contains("Spreads/Spread_gone.xml"), "{err}"); +} + +#[test] +fn percent_decode_keeps_malformed_escapes() { + assert_eq!(import::percent_decode("a%20b%2"), "a b%2"); + assert_eq!(import::percent_decode("%é%zz"), "%é%zz"); + assert_eq!(import::uri_to_path("file:///C:/a%20b"), "C:/a b"); + assert_eq!(import::uri_to_path("file:/a%bé"), "/a%bé"); +} + #[test] fn rejects_non_idml() { assert!(import_idml(b"not a zip").is_err()); diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 92bff320c..850902196 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -30,6 +30,7 @@ log = { workspace = true } [dev-dependencies] designcraft-idml = { workspace = true } +zip = { workspace = true } # Headless (offscreen wgpu) rendering of the whole window for UI screenshots: `examples/ui_shot.rs`. egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } designcraft-fonts = { workspace = true, features = ["testing"] } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c003154ad..3d94bdb03 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -812,6 +812,7 @@ impl DesignApp { /// Open (`file.open`, `file.openBytes`) or place (`file.place`) a file the user chose. A file /// that can't be opened or placed gets an alert with the reason, as well as the status line. + /// A successful import with warnings keeps the document and shows the warnings in an alert. pub fn open_file(&mut self, cmd: &str, params: Value) -> Result<Value, String> { let file = match params.get("path").and_then(Value::as_str) { Some(path) => std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().to_string()), @@ -821,6 +822,20 @@ impl DesignApp { if let Err(e) = &r { let title = if cmd == "file.place" { "Can't Place the File" } else { "Can't Open the File" }; self.ui.dialog = Some(dialogs::Dialog::new("alert", json!({"title": title, "file": file, "message": e}))); + } else if let Ok(result) = &r { + let warnings: Vec<&str> = result + .get("warnings") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .filter(|warning| !warning.is_empty()) + .collect(); + if !warnings.is_empty() { + self.status(format!("{file}: {}", warnings.join("; "))); + self.ui.dialog = + Some(dialogs::Dialog::new("alert", json!({"title": "Import Warnings", "file": file, "message": warnings.join("\n")}))); + } } r } @@ -1319,6 +1334,99 @@ mod tests { } } +#[cfg(test)] +mod import_warning_tests { + use super::*; + use std::io::Write; + + fn idml(missing_parts: bool) -> Vec<u8> { + let missing = if missing_parts { r#"<idPkg:Spread src="Spreads/missing.xml"/><idPkg:Story src="Stories/missing.xml"/>"# } else { "" }; + let map = format!( + r#"<Document xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" Self="d"><idPkg:Spread src="Spreads/one.xml"/>{missing}</Document>"# + ); + let spread = r#"<idPkg:Spread xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging"><Spread Self="s"><Page Self="p" GeometricBounds="0 0 100 100" ItemTransform="1 0 0 1 0 0"/><Rectangle Self="r" ItemTransform="1 0 0 1 0 0"/></Spread></idPkg:Spread>"#; + let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let options = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + for (name, text) in [("mimetype", designcraft_idml::MIMETYPE), ("designmap.xml", &map), ("Spreads/one.xml", spread)] { + zip.start_file(name, options).unwrap(); + zip.write_all(text.as_bytes()).unwrap(); + } + zip.finish().unwrap().into_inner() + } + + fn app() -> DesignApp { + let mut app = DesignApp::new(Session::new(), Services { inbox: Some(Inbox::default()), ..Default::default() }); + app.run("file.new", json!({"title": "Existing work"})).unwrap(); + app.run("frame.create", json!({"rect": [10, 10, 50, 50]})).unwrap(); + app + } + + fn assert_partial_import_warning(app: &mut DesignApp, file: &str) { + let dialog = app.ui.dialog.as_ref().expect("partial import must show an alert"); + assert_eq!(dialog.id, "alert"); + assert_eq!(dialog.fields["title"], "Import Warnings"); + assert_eq!(dialog.fields["file"], file); + for part in ["Spreads/missing.xml", "Stories/missing.xml"] { + assert!(dialog.fields["message"].as_str().unwrap().contains(part), "every missing part is named"); + assert!(app.ui.status.contains(part), "the status line retains the warning"); + } + assert_eq!(app.session.documents().len(), 2); + assert_eq!(app.session.documents()[0].doc.title, "Existing work"); + assert!(app.session.documents()[0].is_dirty(), "existing unsaved work stays intact"); + assert_eq!(app.session.documents()[0].doc.spreads[0].items.len(), 1); + assert_eq!(app.session.doc().unwrap().doc.spreads.len(), 1); + assert_eq!(app.session.doc().unwrap().doc.spreads[0].items.len(), 1, "available document content is kept"); + dialogs::confirm(app).unwrap(); + assert!(app.ui.dialog.is_none(), "OK dismisses the warning"); + assert_eq!(app.session.documents().len(), 2, "dismissing the warning keeps the imported document"); + } + + #[test] + fn byte_open_reports_partial_import_warnings_without_losing_documents() { + let mut app = app(); + let result = + app.open_file("file.openBytes", json!({"name": "partial.idml", "base64": designcraft_engine::cmd::base64_encode(&idml(true))})).unwrap(); + assert_eq!(result["warnings"].as_array().unwrap().len(), 2); + assert_partial_import_warning(&mut app, "partial.idml"); + } + + #[test] + fn native_open_reports_partial_import_warnings_without_losing_documents() { + let path = std::env::temp_dir().join(format!("designcraft-partial-import-{}", std::process::id())).join("partial.idml"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, idml(true)).unwrap(); + let picked = path.to_string_lossy().to_string(); + let mut app = app(); + app.services.pick_open = Some(Box::new(move |_| Some(picked.clone()))); + let result = app.run("app.openDialog", json!({})); + std::fs::remove_file(&path).unwrap(); + std::fs::remove_dir(path.parent().unwrap()).unwrap(); + assert_eq!(result.unwrap()["warnings"].as_array().unwrap().len(), 2); + assert_partial_import_warning(&mut app, "partial.idml"); + } + + #[test] + fn inbox_open_reports_partial_import_warnings_without_losing_documents() { + let mut app = app(); + let request = app.import_request("open"); + app.services.inbox.as_ref().unwrap().lock().unwrap().push(ImportedFile { request, name: "partial.idml".into(), bytes: idml(true) }); + app.drain_inbox(); + assert_partial_import_warning(&mut app, "partial"); + } + + #[test] + fn complete_import_does_not_show_a_warning() { + let mut app = app(); + let result = app + .open_file("file.openBytes", json!({"name": "complete.idml", "base64": designcraft_engine::cmd::base64_encode(&idml(false))})) + .unwrap(); + assert_eq!(result["warnings"], json!([])); + assert!(app.ui.dialog.is_none()); + assert!(app.ui.status.is_empty()); + assert_eq!(app.session.documents().len(), 2); + } +} + /// The whole window in a headless test harness, at a chosen window size. #[cfg(test)] pub(crate) mod test_window { From 80c29046811f0ee0e5eb7447803106c29c194db2 Mon Sep 17 00:00:00 2001 From: KnottyDyes <47539659+KnottyDyes@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:10:01 -0500 Subject: [PATCH 040/238] fix(mcp): prevent request replay after lost replies Do not resend a request after a transport failure. Report the uncertain outcome and reconnect on the next explicit request. Validation: all six cargo xtask ci gates passed on the merge candidate; 1,177 native tests passed and all 15 WASM packages checked. Lost-reply, invalid UTF-8 and timeout probes reproduced duplicate edits before the fix and one edit afterward. Task: #514 --- crates/mcp/src/backend.rs | 60 +++++++++++++++++++++++++++++++++++---- docs/mcp.md | 3 +- 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index 0b940aa3a..c4930cc99 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -78,15 +78,11 @@ impl Backend for Remote { let id = self.next_id; self.next_id += 1; let line = json!({"id": id, "method": method, "params": params}).to_string(); - // One retry with a fresh connection (the app may have restarted). let reply = match self.roundtrip(&line) { Ok(r) => r, - Err(_) => { + Err(e) => { self.conn = None; - self.roundtrip(&line).map_err(|e| { - self.conn = None; - format!("DesignCraft app at {} is not reachable: {e}", self.addr) - })? + return Err(format!("DesignCraft app at {}: {e}; operation may have completed; inspect state before retrying", self.addr)); } }; let v: Value = serde_json::from_str(reply.trim()).map_err(|e| format!("bad reply from app: {e}"))?; @@ -105,3 +101,55 @@ impl Backend for Remote { format!("connected to the DesignCraft app at {}", self.addr) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::net::TcpListener; + + #[test] + fn remote_does_not_replay_when_reply_is_lost() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let server = std::thread::spawn(move || { + let first = { + let (stream, _) = listener.accept().unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + let mut reader = BufReader::new(stream); + let mut line = String::new(); + assert!(reader.read_line(&mut line).unwrap() > 0); + let request: Value = serde_json::from_str(&line).unwrap(); + assert_eq!(request["method"], "engine.execute"); + assert_eq!(request["params"]["command"], "frame.create"); + request + }; + let mut applied = 1; + let (mut stream, _) = listener.accept().unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + stream.set_write_timeout(Some(Duration::from_secs(5))).unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + loop { + let mut line = String::new(); + assert!(reader.read_line(&mut line).unwrap() > 0); + let request: Value = serde_json::from_str(&line).unwrap(); + if request == first { + applied += 1; + writeln!(stream, "{}", json!({"id": request["id"], "ok": true, "result": {"applied": applied}})).unwrap(); + } else { + assert_eq!(request, json!({"id": 2, "method": "document.inspect", "params": {}})); + writeln!(stream, "{}", json!({"id": request["id"], "ok": true, "result": {"applied": applied}})).unwrap(); + return applied; + } + } + }); + let mut remote = Remote::connect(&addr).unwrap(); + + let result = remote.call("engine.execute", json!({"command": "frame.create", "params": {"rect": [0, 0, 100, 100]}})); + let next = remote.call("document.inspect", json!({})); + let applied = server.join().unwrap(); + + assert_eq!(applied, 1, "lost reply must not replay the applied edit; client returned {result:?}"); + assert!(result.is_err(), "lost reply must report an uncertain outcome: {result:?}"); + assert_eq!(next, Ok(json!({"applied": 1})), "the next explicit request must reconnect successfully"); + } +} diff --git a/docs/mcp.md b/docs/mcp.md index 772f8a91f..17c03a3df 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -51,7 +51,8 @@ claude mcp add designcraft -- designcraft-cli mcp | UI-only commands via `execute` (`view.*`, `window.*`, `help.*`, `edit.dynamicSpelling`, and `app.*` except `app.links`) | Error | Work | In connected mode every tool maps to a control-channel method (`engine.execute`, `ui.render`, `ui.pointer`, …), so -what the agent does shows up live in the window. The connection is re-established once if the app restarts. +what the agent does shows up live in the window. A failed request is not resent: the operation may have completed, +so inspect the document before retrying. The next request re-establishes the connection. ## Coordinates From a21e8db14fa24df3fc6136ccb2ec93d8aebbdb1d Mon Sep 17 00:00:00 2001 From: seanth <seanth@gmail.com> Date: Sun, 11 Oct 2026 23:27:52 -0400 Subject: [PATCH 041/238] fix(compose): key parent-page compositions by absolute page index Parent-page text now uses the absolute page index as its cache key, so repeated page numbers and renumbering do not reuse another page's composition. Validation: targeted canvas and PDF regressions, plus all six cargo xtask ci gates on main after #514 (1,179 native tests passed, 3 ignored; 15 crates passed WASM compilation checks). Task: #523 --- crates/compose/src/cache.rs | 32 ++++++++++------ crates/engine/src/cmd/variables.rs | 59 +++++++++++++++++++++++++++++- crates/pdf/src/export.rs | 31 ++++++++-------- crates/render/src/fx.rs | 8 ++-- crates/render/src/lib.rs | 27 +++++++------- 5 files changed, 111 insertions(+), 46 deletions(-) diff --git a/crates/compose/src/cache.rs b/crates/compose/src/cache.rs index 6d6b1b980..8aabbf0f1 100644 --- a/crates/compose/src/cache.rs +++ b/crates/compose/src/cache.rs @@ -13,7 +13,8 @@ use designcraft_doc::{Document, Item, Story, StoryId, Styles, WrapMode}; use crate::vars::RunningIndex; use crate::{ComposeOptions, ComposedStory}; -type Key = (StoryId, Option<String>); +/// A story, composed for one absolute page (parent-page items) or for its own frames (`None`). +type Key = (StoryId, Option<usize>); struct Entry { sig: Vec<usize>, @@ -55,11 +56,23 @@ impl Cache { Self::default() } - /// The composed story, from cache or freshly composed. - pub fn get(&self, doc: &Document, sid: StoryId, page_name: Option<&str>) -> Arc<ComposedStory> { + /// The composed story, from cache or freshly composed. `page` is the absolute page a + /// parent-page item is shown on (its page number markers and variables are that page's), or + /// `None` for a story's own frames. Pages are identified by index, not name: names repeat when + /// sections restart their numbering. + pub fn get(&self, doc: &Document, sid: StoryId, page: Option<usize>) -> Arc<ComposedStory> { let Some(story) = doc.stories.get(&sid) else { return Arc::new(ComposedStory { story: sid, ..Default::default() }) }; let (mut sig, keep_items) = signature(doc, story); - let key = (sid, page_name.map(str::to_string)); + let key = (sid, page); + // The key holds the page's index, which a renumbering doesn't change: its name (section + // start number, style, prefix) is part of the signature. + let page_name = page.map(|p| doc.page_name(p)); + if let Some(name) = &page_name { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + name.hash(&mut h); + sig.push(h.finish() as usize); + } let with_vars = crate::vars::has_vars(story); let running = with_vars && crate::vars::has_running(doc, story); if with_vars { @@ -125,23 +138,20 @@ impl Cache { return e.out.clone(); } } - // Parent-page items are composed per page; variables need that page's index. - let page = match page_name { - Some(n) if with_vars => (0..doc.page_count()).find(|&i| doc.page_name(i) == n), - _ => None, - }; + // Parent-page items are composed per page: markers show its name, variables need its index. + let page = page.filter(|_| with_vars); let running = running.then(|| self.running_index(doc)); let out = Arc::new(crate::compose_story( doc, sid, - &ComposeOptions { page_name: page_name.map(str::to_string), page, running, label: None, xrefs: with_xrefs.then(|| self.xref_index(doc)) }, + &ComposeOptions { page_name, page, running, label: None, xrefs: with_xrefs.then(|| self.xref_index(doc)) }, )); let mut g = self.map.lock().unwrap_or_else(|e| e.into_inner()); let stamp = g.1; if g.0.len() > 4096 { g.0.retain(|_, e| stamp - e.stamp < 64); } - let replaced = g.0.insert(key.clone(), Entry { sig, _keep: (story.clone(), doc.styles.clone(), keep_items), out: out.clone(), stamp }); + let replaced = g.0.insert(key, Entry { sig, _keep: (story.clone(), doc.styles.clone(), keep_items), out: out.clone(), stamp }); drop(g); if key.1.is_none() && let Some(old) = replaced diff --git a/crates/engine/src/cmd/variables.rs b/crates/engine/src/cmd/variables.rs index bbcfaf0a1..e7772d4ea 100644 --- a/crates/engine/src/cmd/variables.rs +++ b/crates/engine/src/cmd/variables.rs @@ -129,6 +129,63 @@ fn define(s: &mut Session, p: &Value) -> Result<Value> { mod tests { use super::*; + /// A parent-page page-number marker follows Numbering & Section Options: changing a section's + /// start number renames its pages without changing their index or the number of sections, and + /// the marker composed for a page shows the new name. + #[test] + fn parent_page_number_follows_section_renumbering() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 3, "facingPages": false})).unwrap(); + let marker = designcraft_doc::story::PAGE_NUMBER.to_string(); + let r = s + .execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [72, 30, 500, 60], "content": "text", "text": marker})) + .unwrap(); + let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, marker); + let shown = |s: &Session, page: usize| -> usize { + let d = s.doc().unwrap().doc.clone(); + let cs = s.cache.get(&d, sid, Some(page)); + cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum() + }; + assert_eq!(shown(&s, 2), 1, "page 3"); + s.execute("layout.section", &json!({"page": 1, "startNumber": 100})).unwrap(); + assert_eq!(s.doc().unwrap().doc.page_name(2), "102"); + assert_eq!(shown(&s, 2), 3, "page 102 after renumbering"); + } + + /// Two sections that both number from 1 repeat page names ("1, 2, 1, 2"). A parent-page running + /// header is composed for the page it is shown on, not the first page with that name. + #[test] + fn running_header_on_parent_page_with_repeated_page_numbers() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 4, "facingPages": false})).unwrap(); + s.execute("layout.section", &json!({"page": 3, "startNumber": 1})).unwrap(); + let names: Vec<String> = (0..4).map(|i| s.doc().unwrap().doc.page_name(i)).collect(); + assert_eq!(names, ["1", "2", "1", "2"]); + s.execute("style.paragraph.create", &json!({"name": "Heading"})).ok(); + // The only heading is on the third page (the second section's page 1). + let sp = s.doc().unwrap().doc.page_loc(2).unwrap().0; + let r = s.execute("frame.create", &json!({"spread": sp, "rect": [72, 200, 400, 260], "content": "text", "text": "Epsilon"})).unwrap(); + let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 1})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Heading"})).unwrap(); + s.execute("variables.define", &json!({"name": "Chapter Head", "type": "runningHeader", "style": "Heading"})).unwrap(); + let r = s + .execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [72, 30, 500, 60], "content": "text", "text": ""})) + .unwrap(); + let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + s.execute("variables.insert", &json!({"name": "Chapter Head"})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let shown = |page: usize| -> usize { + let cs = s.cache.get(&d, sid, Some(page)); + cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum() + }; + assert_eq!(shown(0), 0, "no heading on or before the first page"); + assert_eq!(shown(2), "Epsilon".len(), "the third page (named 1) shows its own heading"); + assert_eq!(shown(3), "Epsilon".len(), "the fourth page (named 2) carries it"); + } + #[test] fn running_header_on_parent_page_follows_headings() { let mut s = Session::new(); @@ -158,7 +215,7 @@ mod tests { assert!(matches!(d.find(fid).unwrap().spread, designcraft_doc::SpreadRef::Parent(0))); // Visible glyphs of the header composed for each page: "Alpha · 3" vs "Epsilon · 3". let shown = |page: usize| -> usize { - let cs = s.cache.get(&d, sid, Some(&d.page_name(page))); + let cs = s.cache.get(&d, sid, Some(page)); cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum() }; let (p1, p2, p3) = (shown(0), shown(1), shown(2)); diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index d92fc242b..dcf8abb83 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -630,7 +630,6 @@ impl Exporter<'_> { let Some((ppi, ppage)) = doc.parent_page_for(first + pi) else { continue }; let parent = &doc.parents[ppi]; let dx = page.x - parent.pages[ppage].x; - let page_name = doc.page_name(first + pi); for it in &parent.items { if it.layer != layer.id || page.overridden.contains(&it.id) { continue; @@ -639,7 +638,7 @@ impl Exporter<'_> { if parent.page_at_x(it.bounds().center().x) != Some(ppage) && parent.pages.len() > 1 { continue; } - self.top_item(s, it, Affine::translate((dx, 0.0)), Some(&page_name), true); + self.top_item(s, it, Affine::translate((dx, 0.0)), Some(first + pi), true); } } for it in &sp.items { @@ -679,9 +678,9 @@ impl Exporter<'_> { } /// A spread or parent item, inside its structure tag when tagging. - fn top_item(&mut self, s: &mut Surface, it: &Item, parent: Affine, page_name: Option<&str>, on_parent: bool) { + fn top_item(&mut self, s: &mut Surface, it: &Item, parent: Affine, page: Option<usize>, on_parent: bool) { if !self.opts.tagged || it.hidden || it.nonprinting { - return self.item(s, it, parent, page_name); + return self.item(s, it, parent, page); } let story = it.text_frame().map(|t| t.story); let figure = !it.alt_text.is_empty() || matches!(it.content, Content::Graphic(_)) || it.has_nested_items(); @@ -689,7 +688,7 @@ impl Exporter<'_> { // Page furniture and decoration. let kind = if on_parent { ArtifactType::Page } else { ArtifactType::Other }; s.start_tagged(ContentTag::Artifact(Artifact::new(kind, None))); - self.item(s, it, parent, page_name); + self.item(s, it, parent, page); s.end_tagged(); return; } @@ -700,12 +699,12 @@ impl Exporter<'_> { self.tags.push(TagEntry::Story(sid)); } self.tag_story = Some(sid); - self.item(s, it, parent, page_name); + self.item(s, it, parent, page); self.tag_story = None; return; } let id = s.start_tagged(ContentTag::Other); - self.item(s, it, parent, page_name); + self.item(s, it, parent, page); s.end_tagged(); let alt = (!it.alt_text.is_empty()).then(|| it.alt_text.clone()); let mut g = TagGroup::new(Tag::Figure(alt)); @@ -751,10 +750,10 @@ impl Exporter<'_> { tree } - fn item(&mut self, s: &mut Surface, it: &Item, parent: Affine, page_name: Option<&str>) { + fn item(&mut self, s: &mut Surface, it: &Item, parent: Affine, page: Option<usize>) { let gf = &it.effects.gradient_feather; if !gf.on || it.hidden || it.nonprinting { - return self.item_inner(s, it, parent, page_name); + return self.item_inner(s, it, parent, page); } // Gradient feather: a luminosity mask (white = opaque) over the object. let xf = parent * it.xf; @@ -804,11 +803,11 @@ impl Exporter<'_> { ms.finish(); let stream = sb.finish(); s.push_mask(krilla::mask::Mask::new(stream, krilla::mask::MaskType::Luminosity)); - self.item_inner(s, it, parent, page_name); + self.item_inner(s, it, parent, page); s.pop(); } - fn item_inner(&mut self, s: &mut Surface, it: &Item, parent: Affine, page_name: Option<&str>) { + fn item_inner(&mut self, s: &mut Surface, it: &Item, parent: Affine, page: Option<usize>) { if it.hidden || it.nonprinting { return; } @@ -843,7 +842,7 @@ impl Exporter<'_> { s.push_clip_path(c, &FillRule::NonZero); } for c in it.children() { - self.item(s, c, xf, page_name); + self.item(s, c, xf, page); } if clip.is_some() { s.pop(); @@ -863,7 +862,7 @@ impl Exporter<'_> { if !it.children().is_empty() { let pushes = Self::push_group(s, it.opacity, it.blend) + Self::push_isolation(s, it); for c in it.shown_children() { - self.item(s, c, xf, page_name); + self.item(s, c, xf, page); } for _ in 0..pushes { s.pop(); @@ -921,7 +920,7 @@ impl Exporter<'_> { } } Content::Text(tfr) => { - let cs = self.cache.get(doc, tfr.story, page_name); + let cs = self.cache.get(doc, tfr.story, page); if let (Some(ft), Some(pt)) = (cs.frame(it.id), &tfr.options.path) { // Type on a path: the glyphs as outlines along the path. for (si, gp) in designcraft_compose::path_glyphs(&cs, ft, &bp, pt) { @@ -966,11 +965,11 @@ impl Exporter<'_> { if let Content::Text(tfr) = &it.content && let Some(st) = doc.story(tfr.story).filter(|st| !st.objects.is_empty()) { - let cs = self.cache.get(doc, tfr.story, page_name); + let cs = self.cache.get(doc, tfr.story, page); if let Some(ft) = cs.frame(it.id) { for o in &ft.objects { if let Some(obj) = st.objects.get(o.index) { - self.item(s, &obj.item, xf * Affine::translate(o.origin.to_vec2()), page_name); + self.item(s, &obj.item, xf * Affine::translate(o.origin.to_vec2()), page); } } } diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 77bb18780..6b29516f4 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -66,7 +66,7 @@ fn shadow(sigma: f64, color: peniko::Color) -> Filter { impl Renderer { /// Draw an item with soft effects: below-effects, the (feathered) body, inner shadow. - pub(crate) fn draw_item_fx(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine, page_name: Option<&str>) { + pub(crate) fn draw_item_fx(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine, page: Option<usize>) { let doc = f.doc; let e = &it.effects; let sw = if it.stroke.is_none() { 0.0 } else { it.stroke.extent() }; @@ -95,7 +95,7 @@ impl Renderer { let moved = Frame { view: fr.view * Affine::translate(off), ..*fr }; c.set_transform(moved.view); c.push_layer(None, None, None, None, Some(filter)); - me.draw_body(c, &moved, it, bp, xf, page_name); + me.draw_body(c, &moved, it, bp, xf, page); if grow > 0.0 && solid { c.set_transform(moved.view * xf); c.set_paint(peniko::Color::BLACK); @@ -119,7 +119,7 @@ impl Renderer { self.with_filters(ctx, f, reach, w / 4.0, |me, c, fr| { c.set_transform(fr.view * xf); c.push_clip_layer(bp); - me.draw_body(c, fr, it, bp, xf, page_name); + me.draw_body(c, fr, it, bp, xf, page); // Keep the body where the blurred inset silhouette is. c.set_transform(Affine::IDENTITY); c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestIn)), None, None, None); @@ -139,7 +139,7 @@ impl Renderer { c.pop_layer(); }); } else { - self.draw_body(ctx, f, it, bp, xf, page_name); + self.draw_body(ctx, f, it, bp, xf, page); } // Directional feather: one fading ramp per side, multiplied in. let df = &e.directional_feather; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index b0f8df133..2cc5c548f 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -216,7 +216,7 @@ pub struct Renderer { resources: Resources, glyphs: text::GlyphCache, /// Composed stories looked up during the current render. - stories: HashMap<(StoryId, Option<String>), Arc<ComposedStory>>, + stories: HashMap<(StoryId, Option<usize>), Arc<ComposedStory>>, pub threads: u16, pub stats: FrameStats, } @@ -408,7 +408,6 @@ impl Renderer { let Some((ppi, ppage)) = doc.parent_page_for(first + pi) else { continue }; let parent = &doc.parents[ppi]; let dx = page.x - parent.pages[ppage].x; - let page_name = doc.page_name(first + pi); let pr = parent.pages[ppage].bounds(); for it in &parent.items { if it.layer != layer.id || page.overridden.contains(&it.id) { @@ -419,7 +418,7 @@ impl Renderer { continue; } let _ = pr; - self.draw_item(ctx, f, it, Affine::translate((dx, 0.0)), Some(&page_name)); + self.draw_item(ctx, f, it, Affine::translate((dx, 0.0)), Some(first + pi)); } } } @@ -431,7 +430,7 @@ impl Renderer { } } - fn draw_item(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, parent: Affine, page_name: Option<&str>) { + fn draw_item(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, parent: Affine, page: Option<usize>) { if it.hidden || f.opts.hidden.contains(&it.id) || f.opts.printing_only && it.nonprinting { return; } @@ -458,7 +457,7 @@ impl Renderer { ctx.set_transform(f.view * xf); ctx.push_clip_layer(&bp); for c in it.children() { - self.draw_item(ctx, f, c, xf, page_name); + self.draw_item(ctx, f, c, xf, page); } ctx.pop_layer(); if !it.stroke.is_none() { @@ -489,11 +488,11 @@ impl Renderer { ctx.fill_path(&c.path.to_bezpath()); ctx.pop_layer(); } - self.draw_item(ctx, f, c, xf, page_name); + self.draw_item(ctx, f, c, xf, page); } } else { for c in it.shown_children() { - self.draw_item(ctx, f, c, xf, page_name); + self.draw_item(ctx, f, c, xf, page); } } if layered { @@ -520,9 +519,9 @@ impl Renderer { ctx.push_layer(None, Some(blend_mode(it.blend)), Some(it.opacity), None, None); } if effects { - self.draw_item_fx(ctx, f, it, &bp, xf, page_name); + self.draw_item_fx(ctx, f, it, &bp, xf, page); } else { - self.draw_body(ctx, f, it, &bp, xf, page_name); + self.draw_body(ctx, f, it, &bp, xf, page); } if layered { ctx.pop_layer(); @@ -531,18 +530,18 @@ impl Renderer { /// The composed story for a frame (memoised for the current render: the cache lookup /// validates the whole thread each time). - fn story(&mut self, f: &Frame, sid: StoryId, page_name: Option<&str>) -> Arc<ComposedStory> { - let key = (sid, page_name.map(str::to_string)); + fn story(&mut self, f: &Frame, sid: StoryId, page: Option<usize>) -> Arc<ComposedStory> { + let key = (sid, page); if let Some(cs) = self.stories.get(&key) { return cs.clone(); } - let cs = f.cache.get(f.doc, sid, page_name); + let cs = f.cache.get(f.doc, sid, page); self.stories.insert(key, cs.clone()); cs } /// Fill, content and stroke of a leaf item (no opacity, blend or effects). - pub(crate) fn draw_body(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine, page_name: Option<&str>) { + pub(crate) fn draw_body(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine, page: Option<usize>) { let doc = f.doc; // Fill. if !it.fill.is_none() { @@ -575,7 +574,7 @@ impl Renderer { ctx.pop_layer(); } Content::Text(tf) => { - let cs = self.story(f, tf.story, page_name); + let cs = self.story(f, tf.story, page); if let Some(ft) = cs.frame(it.id) { match &tf.options.path { Some(pt) => self.draw_path_text(ctx, f, &cs, ft, xf, bp, pt), From fae9bc84ce244a3dc50e169163507f4915756eab Mon Sep 17 00:00:00 2001 From: KnottyDyes <47539659+KnottyDyes@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:30:21 -0500 Subject: [PATCH 042/238] fix(text): reject unknown character fill swatches Reject unknown character fill swatches before modifying text or undo history. Registered swatch names remain valid, including names that resemble hex colours. Validation: invalid-name atomicity and save/reopen regressions, plus all six cargo xtask ci gates on main after #514 and #523 (1,186 native tests passed, 3 ignored; 15 crates passed WASM compilation checks). Task: #403 --- crates/engine/src/cmd/find.rs | 1 + crates/engine/src/cmd/style.rs | 152 +++++++++++++++++++++++++++++++++ crates/engine/src/cmd/text.rs | 13 +++ 3 files changed, 166 insertions(+) diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs index 878160400..5dc0bc8ad 100644 --- a/crates/engine/src/cmd/find.rs +++ b/crates/engine/src/cmd/find.rs @@ -181,6 +181,7 @@ fn change(s: &mut Session, p: &Value) -> Result<Value> { over.set_json(k, v).map_err(|e| bad("find.change", e))?; } } + super::text::validate_char_fill(&s.doc()?.doc, &over, "find.change")?; let targets = scope(s, p)?; s.edit(|d, sel| { let mut count = 0usize; diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 296a8d07b..8a520b685 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -444,6 +444,7 @@ fn create_para(s: &mut Session, p: &Value) -> Result<Value> { chars = full; } } + super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.create")?; let based_on = str_param(p, "basedOn").map(str::to_string); let next = str_param(p, "nextStyle").map(str::to_string); s.edit(|d, _| { @@ -470,6 +471,7 @@ fn CharProps_to_attrs(p: &designcraft_doc::CharProps) -> CharAttrs { fn edit_para(s: &mut Session, p: &Value) -> Result<Value> { let name = str_param(p, "name").ok_or_else(|| bad("style.paragraph.edit", "missing name"))?.to_string(); let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?; + super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.edit")?; let rename = str_param(p, "rename").map(str::to_string); let based = p.get("basedOn").cloned(); s.edit(|d, _| { @@ -621,6 +623,7 @@ fn delete_para(s: &mut Session, p: &Value) -> Result<Value> { fn create_char(s: &mut Session, p: &Value) -> Result<Value> { let base = str_param(p, "name").unwrap_or("Character Style 1").to_string(); let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?; + super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.character.create")?; // Based on [None] is based on nothing. let based_on = str_param(p, "basedOn").filter(|b| *b != designcraft_doc::NO_CHAR_STYLE).map(str::to_string); s.edit(|d, _| { @@ -641,6 +644,7 @@ fn edit_char(s: &mut Session, p: &Value) -> Result<Value> { return Err(bad("style.character.edit", "[None] can't be edited")); } let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?; + super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.character.edit")?; // `null` unsets an attribute (the style no longer sets it). let unset: Vec<String> = p .get("chars") @@ -1045,6 +1049,154 @@ fn rename_in(it: &mut designcraft_doc::Item, from: &str, to: &str) { mod color_tests { use super::*; + fn fill_session() -> Session { + let mut session = Session::new(); + session.execute("file.new", &json!({})).unwrap(); + let frame = session.execute("frame.create", &json!({"rect": [0, 0, 200, 100], "content": "text", "text": "Hi"})).unwrap(); + session.execute("text.select", &json!({"story": frame["story"], "anchor": 0, "focus": 2})).unwrap(); + session.execute("style.paragraph.create", &json!({"name": "Existing"})).unwrap(); + session.execute("style.character.create", &json!({"name": "Existing"})).unwrap(); + session.execute("type.char", &json!({"size": 18})).unwrap(); + session.execute("edit.undo", &json!({})).unwrap(); + session + } + + fn assert_fill_rejected(session: &mut Session, command: &str, params: Value) { + let before = session.doc().unwrap().clone(); + let journal = session.journal.clone(); + let fill = params + .pointer("/chars/fill") + .or_else(|| params.pointer("/attrs/fill")) + .or_else(|| params.get("fill")) + .and_then(Value::as_str) + .unwrap_or("Missing Ink"); + + let result = session.execute(command, ¶ms); + + match result { + Err(crate::EngineError::BadParams { cmd, msg }) => { + assert_eq!(cmd, command); + assert!(msg.contains(fill), "{msg}"); + assert!(msg.contains("swatch.create") && msg.contains("returned name"), "{msg}"); + } + other => panic!("expected BadParams for {command}, got {other:?}"), + } + let after = session.doc().unwrap(); + assert!(std::sync::Arc::ptr_eq(&before.doc, &after.doc)); + assert_eq!(before.selection, after.selection); + assert_eq!(before.revision, after.revision); + assert_eq!(format!("{:?}", before.history), format!("{:?}", after.history)); + assert_eq!(journal, session.journal); + } + + #[test] + fn char_fill_rejects_unknown_flat_and_nested_names_when_formatting() { + for fill in ["Missing Ink", "#ff0000", "[black]", " [Black]"] { + for params in [json!({"fill": fill, "size": 24}), json!({"attrs": {"fill": fill, "size": 24}})] { + let mut session = fill_session(); + assert_fill_rejected(&mut session, "type.char", params); + } + } + } + + #[test] + fn char_fill_rejects_unknown_names_when_creating_or_editing_styles() { + for command in ["style.paragraph.create", "style.paragraph.edit", "style.character.create", "style.character.edit"] { + for fill in ["Missing Ink", "#ff0000"] { + let mut session = fill_session(); + session.execute("style.paragraph.apply", &json!({"name": "Existing"})).unwrap(); + session.execute("style.character.apply", &json!({"name": "Existing"})).unwrap(); + assert_fill_rejected(&mut session, command, json!({"name": "Existing", "rename": "Renamed", "chars": {"fill": fill, "size": 24}})); + } + } + } + + #[test] + fn char_fill_rejects_inherited_unknown_name_when_creating_from_selection() { + let mut session = fill_session(); + session + .edit(|document, selection| { + let selected = selection.text.unwrap(); + document.story_mut(selected.story).unwrap().format_chars(selected.range(), |format| format.over.fill = Some("Missing Ink".into())); + Ok(()) + }) + .unwrap(); + + assert_fill_rejected(&mut session, "style.paragraph.create", json!({"name": "Inherited", "fromSelection": true})); + } + + #[test] + fn char_fill_accepts_valid_override_when_selection_has_unknown_name() { + let mut session = fill_session(); + session + .edit(|document, selection| { + let selected = selection.text.unwrap(); + document.story_mut(selected.story).unwrap().format_chars(selected.range(), |format| format.over.fill = Some("Missing Ink".into())); + Ok(()) + }) + .unwrap(); + + session.execute("style.paragraph.create", &json!({"name": "Override", "fromSelection": true, "chars": {"fill": "[Paper]"}})).unwrap(); + + assert_eq!(session.doc().unwrap().doc.styles.para("Override").unwrap().chars.fill.as_deref(), Some("[Paper]")); + } + + #[test] + fn char_fill_rejects_unknown_name_when_changing_text_even_without_output() { + for (find, change) in [("Hi", "Bye"), ("Hi", ""), ("Absent", "Bye")] { + for fill in ["Missing Ink", "#ff0000"] { + let mut session = fill_session(); + assert_fill_rejected(&mut session, "find.change", json!({"find": find, "change": change, "attrs": {"fill": fill}})); + } + } + } + + #[test] + fn char_fill_preserves_exact_registered_names_when_used_at_all_boundaries() { + for fill in ["Brand Ink", "#ff0000", "[None]", "[Black]"] { + let mut session = fill_session(); + if !fill.starts_with('[') { + let created = session.execute("swatch.create", &json!({"name": fill, "color": "#108080"})).unwrap(); + assert_eq!(created["name"], fill); + } + for params in [json!({"fill": fill}), json!({"attrs": {"fill": fill}})] { + session.execute("type.char", ¶ms).unwrap(); + assert_eq!(session.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], fill); + } + for kind in ["paragraph", "character"] { + session.execute(&format!("style.{kind}.create"), &json!({"name": "Colored", "chars": {"fill": fill}})).unwrap(); + session.execute(&format!("style.{kind}.edit"), &json!({"name": "Existing", "chars": {"fill": fill}})).unwrap(); + let document = &session.doc().unwrap().doc; + for name in ["Colored", "Existing"] { + let attrs = if kind == "paragraph" { + &document.styles.para(name).unwrap().chars + } else { + &document.styles.char_style(name).unwrap().chars + }; + assert_eq!(attrs.fill.as_deref(), Some(fill)); + } + } + assert_eq!(session.execute("find.change", &json!({"find": "Hi", "change": "Bye", "attrs": {"fill": fill}})).unwrap()["count"], 1); + let selected = session.doc().unwrap().selection.text.unwrap(); + let story = session.doc().unwrap().doc.story(selected.story).unwrap(); + assert_eq!(story.text, "Bye"); + assert_eq!(story.format_after(0).over.fill.as_deref(), Some(fill)); + } + } + + #[test] + fn char_fill_accepts_omitted_and_null_attributes_when_used_at_all_boundaries() { + for chars in [json!({"size": 18}), json!({"fill": null})] { + let mut session = fill_session(); + session.execute("type.char", &json!({"attrs": chars})).unwrap(); + for kind in ["paragraph", "character"] { + session.execute(&format!("style.{kind}.create"), &json!({"name": "Nullable", "chars": chars})).unwrap(); + session.execute(&format!("style.{kind}.edit"), &json!({"name": "Existing", "chars": chars})).unwrap(); + } + assert_eq!(session.execute("find.change", &json!({"find": "Hi", "change": "Bye", "attrs": chars})).unwrap()["count"], 1); + } + } + #[test] fn percent_components_at_most_one_stay_percent() { use designcraft_color::Color; diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 4950d82b9..71d390d02 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -819,6 +819,18 @@ fn warichu_cmd(s: &mut Session, p: &Value) -> Result<Value> { format_chars(s, &Value::Object(body)) } +pub(crate) fn validate_char_fill(document: &designcraft_doc::Document, attrs: &CharAttrs, command_id: &str) -> Result<()> { + if let Some(fill) = &attrs.fill + && document.swatch(fill).is_none() + { + return Err(bad( + command_id, + format!("unknown character fill `{fill}`; create a swatch with `swatch.create` and use its returned name as `fill`"), + )); + } + Ok(()) +} + pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result<Value> { let mut a = CharAttrs::default(); if let Some(o) = attrs.as_object() { @@ -826,6 +838,7 @@ pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result<Value> { a.set_json(k, v).map_err(|e| bad("type.char", e))?; } } + validate_char_fill(&s.doc()?.doc, &a, "type.char")?; let cleared: Vec<String> = attrs.as_object().map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect()).unwrap_or_default(); let targets = format_targets(s); s.edit(|d, _| { From 5789caaa6042b2e1b0743553fba0a84e974faf20 Mon Sep 17 00:00:00 2001 From: moppeeer <dupalled@gmail.com> Date: Mon, 12 Oct 2026 05:54:43 +0200 Subject: [PATCH 043/238] fix(objects): reject unknown fill and stroke swatches Reject unknown object fill, stroke and gap swatch names before editing the document, and suggest an existing name where possible. Validation: three independent atomicity and compatibility probes, plus all six cargo xtask ci gates against current main (1,187 native tests passed, 3 ignored; all 15 applicable crates passed WASM compilation checks). ROADMAP progress entries are retained. Task: #520 --- ROADMAP.md | 1 + crates/engine/src/cmd/object.rs | 53 +++++++++++++++++++++++++++++++-- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index ec8eb0920..f100d45be 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -130,6 +130,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. +- **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. - **2026-10-11 (printing):** `file.printers` and `file.print` use craft-libs/craft-print for CUPS and Windows. DesignCraft retains PDF export, page selection, spreads, marks and bleed; dry runs create no temporary PDF or print job. Windows selects matching or default driver paper and shrinks oversized sheets without enlarging smaller ones; queue duplex defaults are preserved. Headless tests exercise exported bytes, job settings, dry runs and refusals; shared native Windows CI passes, including paused-queue submission and cleanup. PostScript, production print controls and physical output validation remain open. No readiness percentage change. - **2026-10-10 (UI.4–UI.5):** Panel tabs can move into other groups, split the dock, float, resize and return to their saved locations through shared docking transactions. Layouts persist in custom workspaces; UI and control commands share the same validated action path. Shared pane geometry and widget-state theme application retain all five palettes and local fonts. Split panes send keyboard input only to the focused canvas and scope contextual toolbar IDs per pane. No readiness percentage change. diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index ca405a181..5570ee93a 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -14,6 +14,17 @@ use serde_json::{Value, json}; use super::{CommandSpec, bad, bool_or, cmd, f64_or, has_doc, has_selection, ok, point_param, rect_param, spread_param, str_param, targets}; use crate::{Result, Session}; +/// `name` must be a swatch of the document: an unknown name would paint nothing. The error names +/// the swatch it was probably meant to be (`Paper` → `[Paper]`, a different case). +fn known_swatch(doc: &designcraft_doc::Document, name: &str, cmd: &str, key: &str) -> Result<()> { + if doc.swatch(name).is_some() { + return Ok(()); + } + let bare = |n: &str| n.trim().trim_start_matches('[').trim_end_matches(']').to_lowercase(); + let hint = doc.swatches.iter().find(|s| bare(&s.name) == bare(name)).map(|s| format!(" (did you mean `{}`?)", s.name)).unwrap_or_default(); + Err(bad(cmd, format!("{key}: no swatch named `{name}`{hint}; create one with `swatch.create` and use its returned name"))) +} + pub fn specs() -> Vec<CommandSpec> { vec![ cmd!( @@ -160,8 +171,9 @@ pub fn specs() -> Vec<CommandSpec> { ) } ), - cmd!("object.fill", "Fill", [], None, "{swatch, tint?: 0..1, ids?}", has_selection, |s, p| { + cmd!("object.fill", "Fill", [], None, "{swatch (a swatch's name, e.g. [Paper]), tint?: 0..1, ids?}", has_selection, |s, p| { let sw = str_param(p, "swatch").ok_or_else(|| bad("object.fill", "missing swatch"))?.to_string(); + known_swatch(&s.doc()?.doc, &sw, "object.fill", "swatch")?; let tint = f64_or(p, "tint", 1.0) as f32; set_flag(s, p, move |i| i.fill = Fill { swatch: sw.clone(), tint, ..i.fill.clone() }, false) }), @@ -173,6 +185,11 @@ pub fn specs() -> Vec<CommandSpec> { "{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?}", has_selection, |s, p| { + 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)?; + } + } let p2 = p.clone(); set_flag( s, @@ -2163,7 +2180,7 @@ mod scale_tests { s.execute("file.new", &json!({})).unwrap(); let r = s.execute("frame.create", &json!({"rect": [100, 100, 200, 150]})).unwrap(); let id = ItemId(r["id"].as_u64().unwrap()); - s.execute("object.stroke", &json!({"weight": 4, "swatch": "Black"})).unwrap(); + s.execute("object.stroke", &json!({"weight": 4, "swatch": "[Black]"})).unwrap(); let weight = |s: &Session| s.doc().unwrap().doc.item(id).unwrap().stroke.weight; let bounds = |s: &Session| s.doc().unwrap().doc.item(id).unwrap().bounds(); s.execute("transform.scale", &json!({"sx": 2.0})).unwrap(); @@ -2726,3 +2743,35 @@ mod blend_mode_tests { assert_eq!(it.opacity, 1.0); } } + +#[cfg(test)] +mod swatch_name_tests { + use serde_json::json; + + #[test] + fn fills_and_strokes_must_name_a_swatch_and_change_nothing_otherwise() { + 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 (cmd, params, hint) in [ + ("object.fill", json!({"swatch": "Paper", "ids": [id]}), Some("[Paper]")), + ("object.fill", json!({"swatch": "[black]", "ids": [id]}), Some("[Black]")), + ("object.fill", json!({"swatch": "Missing Ink", "ids": [id]}), None), + ("object.stroke", json!({"swatch": "#ff0000", "weight": 4, "ids": [id]}), None), + ("object.stroke", json!({"gapSwatch": "paper", "ids": [id]}), Some("[Paper]")), + ] { + let before = s.doc().unwrap().doc.clone(); + let e = s.execute(cmd, ¶ms).unwrap_err().to_string(); + assert!(e.contains("no swatch named") && e.contains("swatch.create"), "{cmd}: {e}"); + if let Some(h) = hint { + assert!(e.contains(&format!("did you mean `{h}`")), "{cmd}: {e}"); + } + assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc), "{cmd}: nothing changed"); + } + // Real names, [None] included, still work. + for sw in ["[Paper]", "[None]", "C=100 M=0 Y=0 K=0"] { + s.execute("object.fill", &json!({"swatch": sw, "ids": [id]})).unwrap(); + s.execute("object.stroke", &json!({"swatch": sw, "ids": [id]})).unwrap(); + } + } +} From 835edae6594f10892142eda9c64141471a009d4c Mon Sep 17 00:00:00 2001 From: moppeeer <dupalled@gmail.com> Date: Mon, 12 Oct 2026 05:58:49 +0200 Subject: [PATCH 044/238] feat(commands): return IDs from move, duplicate and repeat Return ordered item IDs from Move, Duplicate and Step and Repeat while preserving the existing moved and created counts, selection and undo behavior. Scripts can use the IDs to operate on copies. Validation: eight independent probes covering both swatch validation and returned IDs, plus all six cargo xtask ci gates on main after #520 (1,188 native tests passed, 3 ignored; all 15 applicable crates passed WASM compilation checks). Both ROADMAP entries and command regression modules are retained. Task: #521 --- ROADMAP.md | 1 + crates/engine/src/cmd/object.rs | 46 ++++++++++++++++++++++++++++++--- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f100d45be..d33353caa 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -130,6 +130,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. +- **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. - **2026-10-11 (printing):** `file.printers` and `file.print` use craft-libs/craft-print for CUPS and Windows. DesignCraft retains PDF export, page selection, spreads, marks and bleed; dry runs create no temporary PDF or print job. Windows selects matching or default driver paper and shrinks oversized sheets without enlarging smaller ones; queue duplex defaults are preserved. Headless tests exercise exported bytes, job settings, dry runs and refusals; shared native Windows CI passes, including paused-queue submission and cleanup. PostScript, production print controls and physical output validation remain open. No readiness percentage change. diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 5570ee93a..cd13be117 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -37,7 +37,15 @@ pub fn specs() -> Vec<CommandSpec> { frame_create ), cmd!("line.create", "Create Line", [], None, "{spread?, a: [x,y], b: [x,y]}", has_doc, line_create), - cmd!("transform.move", "Move", ["Object", "Transform"], None, "{dx, dy, copy?: bool, ids?, toSpread?}", has_selection, transform_move), + cmd!( + "transform.move", + "Move", + ["Object", "Transform"], + None, + "{dx, dy, copy?: bool, ids?, toSpread?} → {moved, ids: the objects moved (with copy, the copies)}", + has_selection, + transform_move + ), cmd!( "transform.resize", "Resize", @@ -582,7 +590,7 @@ pub fn specs() -> Vec<CommandSpec> { "Step and Repeat…", ["Edit"], Some("Cmd+Alt+U"), - "{count?: 1..1000, dx?, dy?, rows?, columns?, ids?} — copies of the selection (or ids) offset by (dx, dy); with rows/columns, a grid of at most 1000 cells, and at most 1000 item copies including group contents", + "{count?: 1..1000, dx?, dy?, rows?, columns?, ids?} — copies of the selection (or ids) offset by (dx, dy); with rows/columns, a grid of at most 1000 cells, and at most 1000 item copies including group contents → {created, ids: the copies}", has_selection, step_and_repeat ), @@ -998,7 +1006,7 @@ fn transform_move(s: &mut Session, p: &Value) -> Result<Value> { } it.xf = Affine::translate((dx, dy)) * it.xf; } - Ok(json!({"moved": ids.len()})) + Ok(json!({"moved": ids.len(), "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})) }) } @@ -2108,7 +2116,8 @@ fn step_and_repeat(s: &mut Session, p: &Value) -> Result<Value> { } } *sel = Selection::items(all.clone()); - Ok(json!({"created": all.len() - ids.len()})) + let copies: Vec<u64> = all.iter().skip(ids.len()).map(|i| i.0).collect(); + Ok(json!({"created": copies.len(), "ids": copies})) }) } @@ -2744,6 +2753,35 @@ mod blend_mode_tests { } } +#[cfg(test)] +mod copy_ids_tests { + use serde_json::json; + + #[test] + fn copies_report_their_ids() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = s.execute("frame.create", &json!({"rect": [0, 0, 50, 50]})).unwrap()["id"].as_u64().unwrap(); + let ids = |r: &serde_json::Value| r["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect::<Vec<_>>(); + let selected = |s: &crate::Session| s.doc().unwrap().selection.items.iter().map(|i| i.0).collect::<Vec<_>>(); + // Moving reports what moved; copying reports the copies, which are selected. + assert_eq!(ids(&s.execute("transform.move", &json!({"dx": 5, "dy": 0, "ids": [a]})).unwrap()), [a]); + let r = s.execute("transform.move", &json!({"dx": 100, "dy": 0, "copy": true, "ids": [a]})).unwrap(); + let copy = ids(&r); + assert!(copy.len() == 1 && copy[0] != a && s.doc().unwrap().doc.item(designcraft_doc::ItemId(copy[0])).is_some(), "{r}"); + assert_eq!(selected(&s), copy); + // Edit › Duplicate. + let r = s.execute("edit.duplicate", &json!({})).unwrap(); + let dup = ids(&r); + assert!(dup.len() == 1 && dup[0] != copy[0], "{r}"); + assert_eq!(selected(&s), dup); + // Step and Repeat: every copy. + let r = s.execute("edit.stepAndRepeat", &json!({"count": 3, "dx": 0, "dy": 60, "ids": [a]})).unwrap(); + assert_eq!((r["created"].as_u64(), ids(&r).len()), (Some(3), 3), "{r}"); + assert!(ids(&r).iter().all(|i| s.doc().unwrap().doc.item(designcraft_doc::ItemId(*i)).is_some())); + } +} + #[cfg(test)] mod swatch_name_tests { use serde_json::json; From 406f1b3d267fa17617a96d75324d0d709a875361 Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Mon, 12 Oct 2026 05:09:40 +0000 Subject: [PATCH 045/238] Sections: the section marker shows the section's marker text (#556) --- ROADMAP.md | 1 + crates/compose/src/cache.rs | 12 +++++++++-- crates/compose/src/lib.rs | 13 +++++++++++- crates/compose/src/notes.rs | 2 ++ crates/compose/src/shape.rs | 10 +++++++++- crates/compose/src/table.rs | 1 + crates/engine/src/cmd/variables.rs | 32 ++++++++++++++++++++++++++++++ 7 files changed, 67 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d33353caa..e4cb88230 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-12** · The section marker character shows its section's marker text on document pages (nothing when the section has none) instead of the word "Section", which stays on parent pages; editing the marker recomposes (#556). No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. diff --git a/crates/compose/src/cache.rs b/crates/compose/src/cache.rs index 8aabbf0f1..4ede03594 100644 --- a/crates/compose/src/cache.rs +++ b/crates/compose/src/cache.rs @@ -67,10 +67,18 @@ impl Cache { // The key holds the page's index, which a renumbering doesn't change: its name (section // start number, style, prefix) is part of the signature. let page_name = page.map(|p| doc.page_name(p)); + let section_marker = page.map(|p| crate::section_marker(doc, p)); if let Some(name) = &page_name { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); - name.hash(&mut h); + (name, §ion_marker).hash(&mut h); + sig.push(h.finish() as usize); + } + if story.text.contains(designcraft_doc::story::SECTION_MARKER) { + // Frames on document pages show their section's marker text. + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + doc.sections.iter().for_each(|s| (s.start, &s.marker).hash(&mut h)); sig.push(h.finish() as usize); } let with_vars = crate::vars::has_vars(story); @@ -144,7 +152,7 @@ impl Cache { let out = Arc::new(crate::compose_story( doc, sid, - &ComposeOptions { page_name, page, running, label: None, xrefs: with_xrefs.then(|| self.xref_index(doc)) }, + &ComposeOptions { page_name, section_marker, page, running, label: None, xrefs: with_xrefs.then(|| self.xref_index(doc)) }, )); let mut g = self.map.lock().unwrap_or_else(|e| e.into_inner()); let stamp = g.1; diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 3dd7c25b3..7db6bd27a 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -303,6 +303,8 @@ pub struct FrameSpec { pub vertical: bool, pub exclusions: Vec<Exclusion>, pub page_name: Option<String>, + /// Marker text of the section the frame's page is in (None on parent pages). + pub section_marker: Option<String>, /// Absolute document page the frame is on (None on parent pages). pub page: Option<usize>, /// Baseline grid in inner space: (first grid line y, increment). @@ -329,11 +331,18 @@ impl FrameSpec { } } +/// The marker text of the section absolute page `abs` is in (empty when it has none). +pub fn section_marker(doc: &Document, abs: usize) -> String { + doc.section_of(abs).map(|s| s.marker.clone()).unwrap_or_default() +} + /// Composition inputs that are not in the story itself. #[derive(Clone, Debug, Default)] pub struct ComposeOptions { /// Page name for page-number markers when the story is composed for a specific page (parent items). pub page_name: Option<String>, + /// Section marker text when the story is composed for a specific page (parent items). + pub section_marker: Option<String>, /// Absolute page for text variables when composed for a specific page (parent items). pub page: Option<usize>, /// Running-header index (needed when the story contains running-header variables). @@ -387,6 +396,7 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec<FrameSpec> { } _ => (None, None, false), }; + let section_marker = page.map(|abs| section_marker(doc, abs)); // The page under the frame and its margins, in inner space (bounding boxes). let page_rect = match (loc.spread, spread) { (_, Some(sp)) => sp @@ -438,6 +448,7 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec<FrameSpec> { vertical, exclusions: if tf.options.path.is_some() { vec![] } else { exclusions }, page_name, + section_marker, page, grid, left_page, @@ -618,7 +629,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C }; let mut sub = SubstCtx { page_name: opts.page_name.clone().or_else(|| cur_frame.and_then(|f| f.page_name.clone())), - section_marker: None, + section_marker: opts.section_marker.clone().or_else(|| cur_frame.and_then(|f| f.section_marker.clone())), vars: var_values, hidden_conditions: doc.conditions.iter().filter(|c| !c.visible).map(|c| c.name.clone()).collect(), vertical: cur_frame.is_some_and(|f| f.vertical), diff --git a/crates/compose/src/notes.rs b/crates/compose/src/notes.rs index 32b2bbec2..d0055ee06 100644 --- a/crates/compose/src/notes.rs +++ b/crates/compose/src/notes.rs @@ -102,6 +102,7 @@ impl Notes { vertical: false, exclusions: vec![], page_name: f.page_name.clone(), + section_marker: f.section_marker.clone(), page: f.page, grid: None, left_page: f.left_page, @@ -109,6 +110,7 @@ impl Notes { }; let nopts = ComposeOptions { page_name: opts.page_name.clone(), + section_marker: opts.section_marker.clone(), page: opts.page, running: opts.running.clone(), label: Some(label), diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index 1e1af40a7..0d00d3275 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -88,6 +88,7 @@ pub struct ShapedPara { #[derive(Clone, Debug, Default)] pub struct SubstCtx { pub page_name: Option<String>, + /// The section marker text of the page being composed (`None` on parent pages: "Section"). pub section_marker: Option<String>, /// Text variable values by index (on the page being composed). pub vars: Arc<Vec<String>>, @@ -586,7 +587,14 @@ fn shape_run_raw( } else { sub.page_name.clone().unwrap_or_else(|| "#".into()) }; - shape_segment(db, text, i..i + c.len_utf8(), Some(&s), p, &primary, auto_leading, style, out, sub.vertical); + if s.is_empty() { + // A section without marker text: the character takes no space. + let mut g = control_glyph(&primary, p, auto_leading, style, i, c); + g.adv = 0.0; + out.push(g); + } else { + shape_segment(db, text, i..i + c.len_utf8(), Some(&s), p, &primary, auto_leading, style, out, sub.vertical); + } } designcraft_doc::OBJECT_MARK => { let mut g = control_glyph(&primary, p, auto_leading, style, i, c); diff --git a/crates/compose/src/table.rs b/crates/compose/src/table.rs index c70f477be..bc4f0d06f 100644 --- a/crates/compose/src/table.rs +++ b/crates/compose/src/table.rs @@ -112,6 +112,7 @@ fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, left vertical: false, exclusions: vec![], page_name: opts.page_name.clone(), + section_marker: opts.section_marker.clone(), page: opts.page, grid: None, left_page, diff --git a/crates/engine/src/cmd/variables.rs b/crates/engine/src/cmd/variables.rs index e7772d4ea..3288e8161 100644 --- a/crates/engine/src/cmd/variables.rs +++ b/crates/engine/src/cmd/variables.rs @@ -153,6 +153,38 @@ mod tests { assert_eq!(shown(&s, 2), 3, "page 102 after renumbering"); } + /// A section marker character shows the marker text of the section of the page it is composed + /// for: on each document page a parent-page frame is shown on, and in a frame on a document + /// page. A section without marker text shows nothing; the parent page itself shows "Section". + #[test] + fn section_marker_shows_the_pages_section_marker() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 4, "facingPages": false})).unwrap(); + s.execute("layout.section", &json!({"page": 3, "startNumber": 1, "marker": "Intro"})).unwrap(); + let marker = designcraft_doc::story::SECTION_MARKER.to_string(); + let r = s + .execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [72, 30, 500, 60], "content": "text", "text": marker})) + .unwrap(); + let parent = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + let sp = s.doc().unwrap().doc.page_loc(3).unwrap().0; + let r = s.execute("frame.create", &json!({"spread": sp, "rect": [72, 200, 400, 260], "content": "text", "text": marker})).unwrap(); + let own = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + let shown = |s: &Session, sid, page: Option<usize>| -> usize { + let d = s.doc().unwrap().doc.clone(); + let cs = s.cache.get(&d, sid, page); + cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum() + }; + assert_eq!(shown(&s, parent, Some(0)), 0, "the first section has no marker"); + assert_eq!(shown(&s, parent, Some(2)), "Intro".len()); + assert_eq!(shown(&s, parent, Some(3)), "Intro".len()); + assert_eq!(shown(&s, own, None), "Intro".len(), "a frame on a document page"); + assert_eq!(shown(&s, parent, None), "Section".len(), "the parent page itself"); + // Editing the marker recomposes. + s.execute("layout.section", &json!({"page": 3, "marker": "Appendix"})).unwrap(); + assert_eq!(shown(&s, parent, Some(2)), "Appendix".len()); + assert_eq!(shown(&s, own, None), "Appendix".len()); + } + /// Two sections that both number from 1 repeat page names ("1, 2, 1, 2"). A parent-page running /// header is composed for the page it is shown on, not the first page with that name. #[test] From 0635dea06548ceb975922b511db0215d5144eecd Mon Sep 17 00:00:00 2001 From: Sean Hammond <sean.hammond@und.edu> Date: Mon, 12 Oct 2026 06:16:04 +0000 Subject: [PATCH 046/238] Text wrap: small single-character frames (tombstones) are set beside text wrap An article's closing tombstone - a one-character text frame, here a 7.6 pt Noto Emoji symbol in a 10.5 pt box - was overset (red +, nothing drawn) whenever its spread had a text-wrap object anywhere, while InDesign sets it. Two causes in the wrap code: - Every text frame took every wrap object on its spread as an exclusion, whether or not the object reached the frame. Now only objects that overlap the frame wrap its text. - free_slot skips gaps narrower than 1.5 em of the paragraph's size (slivers beside a wrapped object), but applied that to the whole column too when nothing cut it: with a 14 pt paragraph style, no line could go in a frame narrower than 21 pt. The minimum now only applies to gaps a wrap object cut; a band no object reaches gets the whole column, however narrow. On the document that showed it (an InDesign 2026 IDML with 12 tombstones and two unrelated wrap objects on the spread), the 12 tombstone stories are set and no other story's layout changes (54 -> 42 overset stories, all of the difference the tombstones). Tests: small_single_character_frame_sets_beside_unrelated_text_wrap and narrow_frame_sets_lines_no_wrap_object_reaches fail before (overset) and pass after. Probably also #476 (Wingdings bullets overset in 20 x 21 pt frames). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CgGsJkVe5GYG9qK6VFdECa --- crates/compose/src/lib.rs | 17 ++++++++++++++++- crates/compose/src/tests.rs | 38 +++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 3dd7c25b3..5ab809649 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -370,6 +370,13 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec<FrameSpec> { let b = other.bounds(); let r = Rect::new(b.x0 - o[1], b.y0 - o[0], b.x1 + o[3], b.y1 + o[2]); let inner = inv.transform_rect_bbox(r); + // Only objects that reach the frame wrap its text: one elsewhere on the spread + // leaves it alone (a small frame beside it would otherwise lose its gaps to the + // minimum gap width, see `free_slot`). + let reach = inner.intersect(item.inner_bounds()); + if !(reach.width() > 0.0 && reach.height() > 0.0) { + continue; + } exclusions.push(Exclusion { rect: inner, mode: other.wrap.mode }); } } @@ -2071,9 +2078,13 @@ fn line_metrics( (asc, desc, lead) } -/// Widest free horizontal interval of `col` in the band, or None if blocked. +/// Widest free horizontal interval of `col` in the band, or None if blocked. A gap a wrap object +/// cuts narrower than 1.5 em of `size` is skipped (a sliver beside the object); when no object +/// reaches the band the whole column is free, however narrow (a one-character frame such as an +/// article's closing tombstone). fn free_slot(f: &FrameSpec, col: Rect, y0: f64, y1: f64, size: f64) -> Option<(f64, f64)> { let mut free = vec![(col.x0, col.x1)]; + let mut cut = false; for ex in &f.exclusions { let r = ex.rect; let overlaps_band = r.y0 < y1 && r.y1 > y0; @@ -2081,6 +2092,7 @@ fn free_slot(f: &FrameSpec, col: Rect, y0: f64, y1: f64, size: f64) -> Option<(f WrapMode::JumpToNextColumn if y1 > r.y0 && r.x0 < col.x1 && r.x1 > col.x0 => return None, WrapMode::JumpObject if overlaps_band && r.x0 < col.x1 && r.x1 > col.x0 => return None, WrapMode::BoundingBox | WrapMode::Contour if overlaps_band => { + cut = true; let mut next = Vec::new(); for (a, b) in free { if r.x1 <= a || r.x0 >= b { @@ -2099,6 +2111,9 @@ fn free_slot(f: &FrameSpec, col: Rect, y0: f64, y1: f64, size: f64) -> Option<(f _ => {} } } + if !cut { + return Some((col.x0, col.x1)); + } free.into_iter().filter(|(a, b)| b - a >= size * 1.5).max_by(|x, y| (x.1 - x.0).total_cmp(&(y.1 - y.0))) } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index f5d0113c1..b0cc474c7 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -146,6 +146,44 @@ fn wrap_pushes_text_aside() { assert!(below.x0 < 1.0); } +/// A small frame holding one character (a tombstone at the end of an article: a 7.6 pt symbol in +/// a 10.5 pt box), in a paragraph whose style is 14 pt, with a text-wrap object on the spread at +/// `wrap`. +fn tombstone(frame: Rect, wrap: Rect) -> (Document, StoryId) { + let (mut d, sid, _) = doc_with("X", frame, ParaAttrs::default()); + let st = d.story_mut(sid).unwrap(); + st.paras[0].chars = designcraft_doc::CharAttrs { size: Some(14.0), ..Default::default() }; + st.format_chars(0..1, |f| f.over.size = Some(7.6)); + let lid = d.default_layer(); + let mut it = designcraft_doc::Item::new(ItemId(d.alloc()), lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(wrap)); + it.wrap.mode = WrapMode::BoundingBox; + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + (d, sid) +} + +/// A text-wrap object elsewhere on the spread doesn't affect a frame it doesn't overlap: the small +/// frame's one character is set (it was overset: every frame took every wrap object on the +/// spread, and then no gap narrower than 1.5 em of the paragraph's size was used). +#[test] +fn small_single_character_frame_sets_beside_unrelated_text_wrap() { + let (d, sid) = tombstone(Rect::new(300.0, 300.0, 310.5, 310.5), Rect::new(0.0, 0.0, 120.0, 60.0)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(!cs.is_overset(), "the tombstone is set"); + assert_eq!(cs.line_count(), 1); +} + +/// A narrow frame that a text-wrap object overlaps further down sets the lines the object doesn't +/// reach: the minimum gap width only applies to gaps a wrap object cuts. +#[test] +fn narrow_frame_sets_lines_no_wrap_object_reaches() { + let (d, sid) = tombstone(Rect::new(300.0, 300.0, 310.5, 340.0), Rect::new(290.0, 330.0, 330.0, 360.0)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(!cs.is_overset(), "the first line is above the wrap object"); + assert_eq!(cs.line_count(), 1); + let (top, baseline) = (cs.frames[0].columns[0].y0, cs.frames[0].lines[0].baseline); + assert!(baseline - top < 20.0, "on the first line: {baseline} (text area from {top})"); +} + #[test] fn paragraph_composer_is_no_worse_than_greedy() { // Sum of squared slack over lines (excluding last) should not exceed the greedy result. From 591cb579b3bfcafd2e212ed9d7f3cce8581347c6 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sun, 11 Oct 2026 23:14:31 -0700 Subject: [PATCH 047/238] Swatches: Load Swatches refuses .ase files over 100,000 colors and merges in linear time ase::read accepted any number of colors in a swatch exchange file, and swatch.load looked up each incoming name with a linear search through the document's swatches. The time to load a file grew with the square of its number of colors: in a test build, 50,000 colors used 2.5 s of CPU and 100,000 used 9.6 s. No limit applied to larger files. ase::read now returns an error for a file with more than 100,000 colors (ase::MAX_COLORS). load_swatches looks up each name's first swatch in a HashMap, and each color added from the file is indexed too. A file of 100,000 colors now loads in under 0.1 s. The merge follows the same rules as before: names already in the document are kept unless replace is set, a name repeated in the file matches its first copy, and a locked swatch is left unchanged. --- ROADMAP.md | 1 + crates/color/src/ase.rs | 25 ++++++++++++++++++++- crates/engine/src/cmd/style.rs | 41 +++++++++++++++++++++++++++++++++- 3 files changed, 65 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d33353caa..f4deeef4d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -130,6 +130,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. +- **2026-10-12** · Load Swatches refuses an `.ase` file with more than 100,000 colors, and `swatch.load` merges a file of 100,000 colors in under 0.1 s (it took 9.6 s of CPU in a test build). No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. - **2026-10-11 (printing):** `file.printers` and `file.print` use craft-libs/craft-print for CUPS and Windows. DesignCraft retains PDF export, page selection, spreads, marks and bleed; dry runs create no temporary PDF or print job. Windows selects matching or default driver paper and shrinks oversized sheets without enlarging smaller ones; queue duplex defaults are preserved. Headless tests exercise exported bytes, job settings, dry runs and refusals; shared native Windows CI passes, including paused-queue submission and cleanup. PostScript, production print controls and physical output validation remain open. No readiness percentage change. diff --git a/crates/color/src/ase.rs b/crates/color/src/ase.rs index ed592bf58..ecae906fd 100644 --- a/crates/color/src/ase.rs +++ b/crates/color/src/ase.rs @@ -11,8 +11,12 @@ pub enum AseError { Signature, #[error("the file ends early")] Truncated, + #[error("the file holds more than {MAX_COLORS} colors")] + TooManyColors, } +/// The most colors [`read`] accepts in one file. +pub const MAX_COLORS: usize = 100_000; const COLOR: u16 = 0x0001; #[cfg(test)] const GROUP_START: u16 = 0xC001; @@ -56,6 +60,7 @@ impl R<'_> { } /// The colour swatches in an `.ase` file (groups are flattened). +/// A file with more than [`MAX_COLORS`] colors is an error. pub fn read(bytes: &[u8]) -> Result<Vec<Swatch>, AseError> { let mut r = R { b: bytes, i: 0 }; if r.take(4)? != b"ASEF" { @@ -63,7 +68,7 @@ pub fn read(bytes: &[u8]) -> Result<Vec<Swatch>, AseError> { } r.take(4)?; // version let blocks = r.u32()?; - let mut out = Vec::new(); + let (mut out, mut colors) = (Vec::new(), 0usize); for _ in 0..blocks { let kind = r.u16()?; let len = r.u32()? as usize; @@ -72,6 +77,10 @@ pub fn read(bytes: &[u8]) -> Result<Vec<Swatch>, AseError> { if kind != COLOR { continue; } + colors += 1; + if colors > MAX_COLORS { + return Err(AseError::TooManyColors); + } let mut b = R { b: body, i: 0 }; let name = b.name()?; let model = b.take(4)?.to_vec(); @@ -191,4 +200,18 @@ mod tests { let SwatchValue::Color { color, .. } = &w[0].value else { panic!() }; assert!(color.to_rgb().iter().all(|v| *v > 0.98), "Lab white: {color:?}"); } + + #[test] + fn more_than_max_colors_is_an_error() { + let gray = Swatch { + name: "G".into(), + value: SwatchValue::Color { color: Color::Gray { k: 0.5 }, color_type: ColorType::Process }, + locked: false, + named: true, + hidden: false, + }; + let sw = vec![gray; MAX_COLORS + 1]; + assert_eq!(read(&write(&sw)).err(), Some(AseError::TooManyColors)); + assert_eq!(read(&write(&sw[1..])).map(|w| w.len()), Ok(MAX_COLORS), "up to the limit"); + } } diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 8a520b685..69e58b24d 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -815,9 +815,14 @@ fn load_swatches(s: &mut Session, p: &Value) -> Result<Value> { let incoming = designcraft_color::ase::read(&bytes).map_err(|e| bad("swatch.load", e.to_string()))?; let replace = p.get("replace").and_then(Value::as_bool).unwrap_or(false); s.edit(|d, _| { + // The position of the first swatch with each name, kept up to date as the file's colors are added. + let mut at: std::collections::HashMap<String, usize> = std::collections::HashMap::new(); + for (i, x) in d.swatches.iter().enumerate() { + at.entry(x.name.clone()).or_insert(i); + } let (mut added, mut replaced) = (0, 0); for w in incoming { - match d.swatches.iter_mut().find(|x| x.name == w.name) { + match at.get(&w.name).and_then(|&i| d.swatches.get_mut(i)) { Some(x) if x.locked => {} Some(x) => { if replace { @@ -827,6 +832,7 @@ fn load_swatches(s: &mut Session, p: &Value) -> Result<Value> { } } None => { + at.insert(w.name.clone(), d.swatches.len()); d.swatches.push(w); added += 1; } @@ -1314,6 +1320,39 @@ mod color_tests { assert!(s.execute("swatch.load", &json!({"base64": "AAAA"})).is_err()); } + #[test] + fn loading_swatches_matches_the_first_swatch_of_each_name() { + use designcraft_color::{Color, ColorType}; + let ink = |k: f32| SwatchValue::Color { color: Color::cmyk(0.0, 0.0, 0.0, k), color_type: ColorType::Process }; + let sw = |name: &str, k: f32| Swatch { name: name.into(), value: ink(k), locked: false, named: true, hidden: false }; + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + // Two swatches share a name, and an unnamed color is hidden. + s.edit(|d, _| { + d.swatches.extend([sw("Twin", 0.1), sw("Twin", 0.2), Swatch { named: false, hidden: true, ..sw("Unnamed", 0.3) }]); + Ok(()) + }) + .unwrap(); + let file = designcraft_color::ase::write(&[sw("New", 0.4), sw("New", 0.5), sw("Twin", 0.6), sw("Unnamed", 0.7), sw("[Black]", 0.8)]); + let b64 = crate::cmd::base64_encode(&file); + let named = |s: &Session, name: &str| -> Vec<(SwatchValue, bool)> { + s.doc().unwrap().doc.swatches.iter().filter(|w| w.name == name).map(|w| (w.value.clone(), w.hidden)).collect() + }; + // With `replace`, the file's second "New" replaces its first, only the first "Twin" changes, the hidden + // swatch becomes visible, and the locked [Black] stays. + let r = s.execute("swatch.load", &json!({"base64": b64, "replace": true})).unwrap(); + assert_eq!((r["added"].as_u64(), r["replaced"].as_u64()), (Some(1), Some(3))); + assert_eq!(named(&s, "New"), [(ink(0.5), false)]); + assert_eq!(named(&s, "Twin"), [(ink(0.6), false), (ink(0.2), false)]); + assert_eq!(named(&s, "Unnamed"), [(ink(0.7), false)]); + assert_eq!(named(&s, "[Black]"), [(ink(1.0), false)]); + // Without it, every name is already there and nothing changes. + let before = s.doc().unwrap().doc.swatches.clone(); + let r = s.execute("swatch.load", &json!({"base64": b64})).unwrap(); + assert_eq!((r["added"].as_u64(), r["replaced"].as_u64()), (Some(0), Some(0))); + assert_eq!(s.doc().unwrap().doc.swatches, before); + } + #[test] fn unnamed_colors_then_add_to_swatches() { let mut s = Session::new(); From c310cf72ed534ffd31b7d3164fbd8039056bff7b Mon Sep 17 00:00:00 2001 From: Akash Bhat <akashbhat1402@gmail.com> Date: Mon, 12 Oct 2026 12:09:40 +0530 Subject: [PATCH 048/238] fix(compose): enable Norwegian hyphenation patterns Enable hypher/norwegian in designcraft-compose so no/nb/nn map to the bundled hyph-no.tex patterns. Add unit + composition regression tests, NOTICE attribution, and update parity docs. Fixes #535. --- NOTICE | 6 ++++++ crates/compose/Cargo.toml | 2 +- crates/compose/src/hyphen/mod.rs | 23 ++++++++++++++++++++++- crates/compose/src/tests.rs | 19 +++++++++++++++++++ docs/gaps.md | 2 +- docs/localization-parity.md | 4 ++-- docs/target-app-parity.md | 4 ++-- docs/typography-parity.md | 2 +- 8 files changed, 54 insertions(+), 8 deletions(-) diff --git a/NOTICE b/NOTICE index 087bb6196..972ab4567 100644 --- a/NOTICE +++ b/NOTICE @@ -52,6 +52,12 @@ list (author, source, license) is in ASSETS.md. INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +- Norwegian hyphenation patterns, embedded through the hypher crate (feature "norwegian"): + hyph-no.tex from hyph-utf8, https://github.com/hyphenation/tex-hyphen. + Copyright (C) Rune Kleveland and Ole Michael Selberg. Copying and distribution of this file, + with or without modification, is permitted in any medium without royalty provided the copyright + notice and this notice are preserved (additionally distributed under the LaTeX Project Public + License in CTAN distributions). - Japanese fonts (craft-fonts): builds made with craft-fonts (CRAFT_FONTS_DIR; all official releases) embed the fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (BIZ UDPGothic, BIZ UDMincho, diff --git a/crates/compose/Cargo.toml b/crates/compose/Cargo.toml index b8993bcfb..de678b37f 100644 --- a/crates/compose/Cargo.toml +++ b/crates/compose/Cargo.toml @@ -18,7 +18,7 @@ designcraft-fonts = { workspace = true } log = { workspace = true } regex = { workspace = true } miniz_oxide = "0.8" -hypher = { version = "0.1.8", default-features = false, features = ["alloc", "portuguese", "spanish"] } +hypher = { version = "0.1.8", default-features = false, features = ["alloc", "portuguese", "spanish", "norwegian"] } [dev-dependencies] designcraft-fonts = { workspace = true, features = ["testing"] } diff --git a/crates/compose/src/hyphen/mod.rs b/crates/compose/src/hyphen/mod.rs index e3f4a43b3..ae5997e52 100644 --- a/crates/compose/src/hyphen/mod.rs +++ b/crates/compose/src/hyphen/mod.rs @@ -1,7 +1,8 @@ //! Hyphenation. English uses the bundled dictionary and patterns below. Other languages use the //! Liang patterns compiled into `hypher`, one cargo feature per language: Spanish is Javier Bezos's //! `hyph-es.tex` 5.0 from hyph-utf8, MIT; Portuguese is `hyph-pt.tex` 1.4 from hyph-utf8 (Pedro J. -//! de Rezende, J. Joao Dias Almeida, Leonardo Araujo, Aline Benevides), BSD-3-Clause (see NOTICE). +//! de Rezende, J. Joao Dias Almeida, Leonardo Araujo, Aline Benevides), BSD-3-Clause; Norwegian is +//! `hyph-no.tex` from hyph-utf8 (Rune Kleveland, Ole Michael Selberg), permissive (see NOTICE). //! //! English lookup order for each word: //! 1. the **dictionary** ([`Dictionary::en_us`]) — the public-domain Moby Hyphenator II word list @@ -411,6 +412,26 @@ mod tests { assert_eq!(h("pós-graduação"), "pós-gra-du-a-ção", "each part of a compound"); } + #[test] + fn norwegian_uses_the_hyph_no_patterns() { + let nb = Lang::for_language("Norwegian: Bokmål").unwrap(); + assert_eq!(nb, Lang::for_language("Norwegian").unwrap()); + assert_eq!(nb, Lang::for_language("Norwegian: Nynorsk").unwrap()); + assert_eq!(nb, Lang::for_language("nb").unwrap()); + assert_eq!(nb, Lang::for_language("nn").unwrap()); + assert_eq!(nb, Lang::for_language("no").unwrap()); + assert_ne!(nb, Lang::for_language("Spanish").unwrap()); + let h = |w: &str| { + let pts = hyphen_points_in(w, &Limits::default(), nb); + w.chars().enumerate().map(|(i, c)| if pts.contains(&i) { format!("-{c}") } else { c.to_string() }).collect::<String>() + }; + assert_eq!(h("kaller"), "kal-ler"); + assert_eq!(h("bilde"), "bil-de"); + assert_eq!(h("sjøfugl"), "sjø-fugl"); + assert_eq!(h("sirkulasjonen"), "sir-ku-la-sjo-nen"); + assert_eq!(h("beveger"), "be-ve-ger"); + } + #[test] fn heuristic_fallback() { let c: Vec<char> = "happen".chars().collect(); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index f5d0113c1..aeecc5144 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1388,6 +1388,25 @@ fn portuguese_text_gets_portuguese_hyphenation() { } } +#[test] +fn norwegian_text_gets_norwegian_hyphenation() { + let text = "sirkulasjonen beveger nedsynkningen kaller overflaten bilde"; + let (mut d, sid, _) = doc_with(text, Rect::new(36.0, 36.0, 76.0, 700.0), ParaAttrs::default()); + let st = d.story_mut(sid).unwrap(); + let n = st.len(); + st.format_chars(0..n, |f| f.over.language = Some("Norwegian: Bokmål".into())); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let breaks: Vec<&str> = all_lines(&cs).into_iter().filter(|l| l.hyphenated).map(|l| text[l.range.clone()].trim_end()).collect(); + assert!(!breaks.is_empty(), "Norwegian text is hyphenated"); + for b in breaks { + let tail = b.rsplit(' ').next().unwrap_or(b); + let whole = text[text.find(b).unwrap() + b.len() - tail.len()..].split(|c: char| !c.is_alphabetic()).next().unwrap_or(""); + let nb = hyphen::Lang::for_language("Norwegian: Bokmål").unwrap(); + let pts = hyphen::hyphen_points_in(whole, &hyphen::Limits::default(), nb); + assert!(pts.contains(&tail.chars().count()), "break {tail}- in {whole} is a hyph-no point"); + } +} + #[test] fn cjk_aki_adds_space_without_scaling_outlines() { let (mut d, sid, _) = doc_with("AB", Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs::default()); diff --git a/docs/gaps.md b/docs/gaps.md index 6f92c5500..b04e6691a 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -135,7 +135,7 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · - **Estimate:** 25–40 h. ### 13. Hyphenation and spelling languages (T, L) -- **Missing:** hyphenation patterns for every language but English and Spanish; spelling for +- **Missing:** hyphenation patterns for every language but English, Spanish, Portuguese and Norwegian; spelling for every language but English. InDesign ships dictionaries for ~40 languages. - **Estimate:** 30–60 h (permissively licensed patterns exist for most; spelling word lists need licence review). **Doc:** [typography-parity.md](typography-parity.md) diff --git a/docs/localization-parity.md b/docs/localization-parity.md index d36053202..8b630a06c 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -33,7 +33,7 @@ come as Middle East editions with an English or French interface and right-to-le | Hindi | hi | ✗ | 0 (0%) | none | Devanagari shaped by harfrust in documents but untested; no Hindi hyphenation or spelling | no | none | 20–30 | | Arabic | ar | Middle East edition (English/French UI) | 1,459 (85%) | menus, panels, most dialogs; no help | RTL UI text (`rtl.rs`: bidi + shaping); Arabic composition, kashida, digits, RTL stories and tables ✓ (arabic-typography.md); list bullets and multi-column RTL open (#100) | no | partial | 15–25 | | French | fr | ✓ | 1,438 (83%) | menus, panels, most dialogs; no help | Latin ✓; no French hyphenation | no | partial | 6–10 | -| Portuguese (Brazil) | pt-br | ✓ (pt) | 1,057 (61%) | menus, panels, some dialogs; no help | Latin ✓; no Portuguese hyphenation | no | partial | 8–12 | +| Portuguese (Brazil) | pt-br | ✓ (pt) | 1,057 (61%) | menus, panels, some dialogs; no help | Latin ✓; Portuguese hyphenation ✓ | no | partial | 8–12 | | Indonesian | id | ✗ | 0 (0%) | none | Latin ✓ | no | none | 12–18 | | Japanese | ja | ✓ | 1,644 (95%) | whole interface (#272), vertical-type commands; no help | vertical text, kinsoku, aki, ruby, warichu ✓ (partial depth, cjk.md); **no canvas IME** (#322); Japanese UI and Mincho fonts from craft-fonts | no | partial | 6–10 (+ IME, shared with zh) | | German | de | ✓ | 1,438 (83%) | menus, panels, most dialogs; no help | Latin ✓; no German hyphenation (InDesign ships 2006 reform patterns) | no | partial | 6–10 | @@ -56,7 +56,7 @@ eight of the twelve (cs, da, el, en_GB, fi, hu, it, nb, nl, pl, ro, ru, sq, sv, ## Document languages InDesign 2026 hyphenates and spell-checks ~40 languages (Hunspell and Duden dictionaries). -DesignCraft hyphenates **English and Spanish** and spell-checks **English** only. Text language +DesignCraft hyphenates **English, Spanish, Portuguese and Norwegian** and spell-checks **English** only. Text language can be set per paragraph and character (Properties, Character panel) and drives quotes and locale shaping. See [typography-parity.md](typography-parity.md). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e9c27a9f8..4b0b4a6e3 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -59,7 +59,7 @@ they receive, setting type exactly, and basic editing that never fails. | Dimension | Weight | Ready | Remaining (Opus 5.5 h) | Evidence | Doc | |---|---|---|---|---|---| | Features (depth of each area) | 25% | 55% | 250–400 | 275 catalogue rows exist; many lack options (e.g. Books is 258 lines of engine code, Liquid Layout 329); see the area table | this file | -| Typography and composition fidelity | 15% | 45% | 120–200 | Knuth–Plass composer, keeps, grids, CJK, Arabic all exist; no comparison with InDesign's line breaks; optical kerning falls back to metrics (#181); overset rule differs (#180); hyphenation patterns for English and Spanish only | [typography-parity.md](typography-parity.md) | +| Typography and composition fidelity | 15% | 45% | 120–200 | Knuth–Plass composer, keeps, grids, CJK, Arabic all exist; no comparison with InDesign's line breaks; optical kerning falls back to metrics (#181); overset rule differs (#180); hyphenation patterns for English, Spanish, Portuguese and Norwegian only | [typography-parity.md](typography-parity.md) | | File formats | 20% | 40% | 150–250 (+20–60 for INDD integration) | INDD cannot be opened (#21); IDML both ways and verified in InDesign 2026, but real-file bugs open (#193, #338, #339, #340); PDF export strong; PDF/X-4 only | [file-format-parity.md](file-format-parity.md) | | UI/UX fidelity | 15% | 45% | 120–180 | Measured InDesign 2026 look; but users report object copy/paste (#351, #348, #185, #164), right-click menus (#238, #187, #160), out-port threading (#352, #194, #165), canvas IME (#322) failing | [ui-parity.md](ui-parity.md) | | Stability | 10% | 45% | 60–100 | Never-crash rules and last-resort guards; startup failures on some GPUs and machines (#334, #273, #220, #192, #167, #166); no CI runs the tests on pull requests | [gaps.md](gaps.md) | @@ -346,7 +346,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Edit ▸ Menus (`window.hideMenuItem`): hide menu items; menus with hidden items end in Show All Menu Items. - View ▸ Separations Preview (`view.separations`): one process plate as ink density, or areas over an ink limit in red (a GCR preview of the rendered view; spot plates and the flattener preview aren't there). - Separations Preview plates now come from each object's own colour (CMYK as authored; RGB and images through the colour settings, images via a cached 17³ table), so pure black shows only on the black plate. -- Language (Character panel ▸ Language, Properties ▸ Character, `type.char {language}`): text in other languages isn't hyphenated or spell-checked with the English dictionaries, typographer's quotes follow the language (German „…“, French and Russian «…», Swiss, Spanish, Dutch, Nordic, Japanese and Traditional Chinese 「…」, Simplified Chinese “…”), the font's localized forms (OpenType `locl`: Turkish i, Bulgarian Cyrillic, Japanese / Chinese / Korean glyph variants) follow it too, and so does the fallback for CJK characters the font lacks (with Draw Missing Glyphs from Fallback Fonts on): Japanese, Simplified Chinese, Traditional Chinese and Korean each try their own installed system fonts (Japanese starts with the craft-fonts Japanese faces, Mincho first, when built with them; Hangul, kana and Bopomofo pick their language's fonts whatever the language). Language names from IDML keep their spelling; locale codes as InDesign writes them for some languages (`de_DE_2006`, `nl_NL_2005`; any `ll`, `ll_CC`, `ll-CC`) are their language and region for all of the above, and Chinese is also recognised in its common spellings ("Simplified Chinese", "Chinese (Traditional)", `zh_CN`, `zh-Hant`). Spanish is hyphenated with Javier Bezos's Liang patterns (`hyph-es.tex` 5.0, MIT) and Portuguese with hyph-utf8's `hyph-pt.tex` 1.4 (BSD-3-Clause), both through the `hypher` crate; hyphenation for other languages isn't bundled yet. +- Language (Character panel ▸ Language, Properties ▸ Character, `type.char {language}`): text in other languages isn't hyphenated or spell-checked with the English dictionaries, typographer's quotes follow the language (German „…“, French and Russian «…», Swiss, Spanish, Dutch, Nordic, Japanese and Traditional Chinese 「…」, Simplified Chinese “…”), the font's localized forms (OpenType `locl`: Turkish i, Bulgarian Cyrillic, Japanese / Chinese / Korean glyph variants) follow it too, and so does the fallback for CJK characters the font lacks (with Draw Missing Glyphs from Fallback Fonts on): Japanese, Simplified Chinese, Traditional Chinese and Korean each try their own installed system fonts (Japanese starts with the craft-fonts Japanese faces, Mincho first, when built with them; Hangul, kana and Bopomofo pick their language's fonts whatever the language). Language names from IDML keep their spelling; locale codes as InDesign writes them for some languages (`de_DE_2006`, `nl_NL_2005`; any `ll`, `ll_CC`, `ll-CC`) are their language and region for all of the above, and Chinese is also recognised in its common spellings ("Simplified Chinese", "Chinese (Traditional)", `zh_CN`, `zh-Hant`). Spanish is hyphenated with Javier Bezos's Liang patterns (`hyph-es.tex` 5.0, MIT), Portuguese with hyph-utf8's `hyph-pt.tex` 1.4 (BSD-3-Clause) and Norwegian with hyph-utf8's `hyph-no.tex` (permissive), all through the `hypher` crate; hyphenation for other languages isn't bundled yet. - Books (`book.new/open/add/remove/list/styleSource/paginate/syncStyles/exportPdf`, Book panel): `.dcbook` files, continuous page numbering, style sync from a style source, one merged PDF. - Track Changes (`changes.track/list/acceptAll/rejectAll`, Type ▸ Track Changes): typing is marked added (highlighted on screen), deletions are kept but hidden from layout and exports until accepted or rejected. Per-change accept/reject and authors aren't there yet. - Track Changes: per-change Accept / Reject (`changes.accept`, `changes.reject`) and a Track Changes panel listing every change. diff --git a/docs/typography-parity.md b/docs/typography-parity.md index 0339faf1e..e86167592 100644 --- a/docs/typography-parity.md +++ b/docs/typography-parity.md @@ -36,7 +36,7 @@ Fidelity: **unmeasured** unless stated. | Single-line Composer | greedy | greedy with the same ranges | ✓ | | | World-Ready composers | Paragraph / Single-line World-Ready | RTL, Arabic and bidi in the main composer | ~ | Indic scripts (Devanagari etc.) shape through harfrust but are untested | | Justification | word/letter spacing, glyph scaling min/desired/max, single-word, last line | all of these | ✓ | a justified line with a tab now justifies after the tab (#284) | -| Hyphenation | per-language dictionaries (~40), zone, limits, capitalized, last word, across columns | Liang patterns: English (trained from the public-domain Moby list) and Spanish (Bezos); zone, limits, exceptions in the user dictionary | ~ | other languages don't hyphenate (#87 reported "doesn't work") | +| Hyphenation | per-language dictionaries (~40), zone, limits, capitalized, last word, across columns | Liang patterns: English (trained from the public-domain Moby list), Spanish (Bezos), Portuguese (hyph-pt) and Norwegian (hyph-no); zone, limits, exceptions in the user dictionary | ~ | other languages don't hyphenate (#87 reported "doesn't work") | | Spelling | per-language dictionaries | English (Moby) + document/user dictionary | ~ | | | Kerning | Metrics, Optical, manual, none | Metrics, manual, none | ~ | **Optical treated as Metrics** (#181): imported display text sets wider and can overset | | Tracking, scale, skew, baseline shift | ✓ | ✓ | ✓ | | From 45f2a2090ba37db7601452e9e2ce0b60b21b10e6 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Mon, 12 Oct 2026 00:53:01 -0700 Subject: [PATCH 049/238] Contributors: add dotCooCoo (Robert Lee) --- contributors/people.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 67a8fdf30..16cc84160 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -17,6 +17,10 @@ # To remove your names, delete your entry (or set fields empty) in a PR. The next # contributors.py run, and the next build, drop them. +[people.dotCooCoo] +real_name = "Robert Lee" +sync_github_name = true + [people.echelon] real_name = "Brandon Thomas" display_name = "Brandon Thomas" From 3384224419824d5284c68035eb30fad6e3d89c3f Mon Sep 17 00:00:00 2001 From: Brandon Thomas <echelon@gmail.com> Date: Mon, 12 Oct 2026 05:44:24 -0400 Subject: [PATCH 050/238] Release prep: 0.7.0 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- Cargo.lock | 40 ++++++++++++++++++++-------------------- Cargo.toml | 2 +- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 05729755a..2e01165c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -887,7 +887,7 @@ dependencies = [ [[package]] name = "designcraft" -version = "0.6.0" +version = "0.7.0" dependencies = [ "designcraft-engine", "designcraft-ui-egui", @@ -904,7 +904,7 @@ dependencies = [ [[package]] name = "designcraft-cli" -version = "0.6.0" +version = "0.7.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -920,7 +920,7 @@ dependencies = [ [[package]] name = "designcraft-color" -version = "0.6.0" +version = "0.7.0" dependencies = [ "kurbo", "moxcms", @@ -932,7 +932,7 @@ dependencies = [ [[package]] name = "designcraft-compose" -version = "0.6.0" +version = "0.7.0" dependencies = [ "designcraft-color", "designcraft-doc", @@ -949,7 +949,7 @@ dependencies = [ [[package]] name = "designcraft-doc" -version = "0.6.0" +version = "0.7.0" dependencies = [ "designcraft-color", "designcraft-geom", @@ -961,7 +961,7 @@ dependencies = [ [[package]] name = "designcraft-engine" -version = "0.6.0" +version = "0.7.0" dependencies = [ "caseless", "craft-print", @@ -995,7 +995,7 @@ dependencies = [ [[package]] name = "designcraft-epub" -version = "0.6.0" +version = "0.7.0" dependencies = [ "designcraft-color", "designcraft-doc", @@ -1004,7 +1004,7 @@ dependencies = [ [[package]] name = "designcraft-fonts" -version = "0.6.0" +version = "0.7.0" dependencies = [ "harfrust 0.13.3", "kurbo", @@ -1016,7 +1016,7 @@ dependencies = [ [[package]] name = "designcraft-format" -version = "0.6.0" +version = "0.7.0" dependencies = [ "designcraft-doc", "serde", @@ -1027,7 +1027,7 @@ dependencies = [ [[package]] name = "designcraft-geom" -version = "0.6.0" +version = "0.7.0" dependencies = [ "flo_curves", "kurbo", @@ -1040,7 +1040,7 @@ dependencies = [ [[package]] name = "designcraft-idml" -version = "0.6.0" +version = "0.7.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1053,7 +1053,7 @@ dependencies = [ [[package]] name = "designcraft-images" -version = "0.6.0" +version = "0.7.0" dependencies = [ "designcraft-color", "designcraft-fonts", @@ -1064,7 +1064,7 @@ dependencies = [ [[package]] name = "designcraft-mcp" -version = "0.6.0" +version = "0.7.0" dependencies = [ "designcraft-engine", "designcraft-geom", @@ -1077,7 +1077,7 @@ dependencies = [ [[package]] name = "designcraft-pdf" -version = "0.6.0" +version = "0.7.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1099,7 +1099,7 @@ dependencies = [ [[package]] name = "designcraft-render" -version = "0.6.0" +version = "0.7.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1117,7 +1117,7 @@ dependencies = [ [[package]] name = "designcraft-textimport" -version = "0.6.0" +version = "0.7.0" dependencies = [ "designcraft-doc", "quick-xml 0.38.4", @@ -1126,7 +1126,7 @@ dependencies = [ [[package]] name = "designcraft-tools" -version = "0.6.0" +version = "0.7.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1138,7 +1138,7 @@ dependencies = [ [[package]] name = "designcraft-ui-egui" -version = "0.6.0" +version = "0.7.0" dependencies = [ "craft-ui", "designcraft-color", @@ -1165,7 +1165,7 @@ dependencies = [ [[package]] name = "designcraft-web" -version = "0.6.0" +version = "0.7.0" dependencies = [ "designcraft-engine", "designcraft-format", @@ -5685,7 +5685,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.6.0" +version = "0.7.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 4fcbfd625..82d13fe8d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"] [workspace.package] -version = "0.6.0" +version = "0.7.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From 152086b3ea1e06efd716b5676a52ed52f7284524 Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Mon, 12 Oct 2026 12:28:06 +0000 Subject: [PATCH 051/238] Footnotes: notes stack baseline to baseline (#582) --- crates/compose/src/notes.rs | 83 +++++++++++++++++++++++++++---------- crates/compose/src/tests.rs | 46 ++++++++++++++++++++ 2 files changed, 108 insertions(+), 21 deletions(-) diff --git a/crates/compose/src/notes.rs b/crates/compose/src/notes.rs index 32b2bbec2..e2614adab 100644 --- a/crates/compose/src/notes.rs +++ b/crates/compose/src/notes.rs @@ -5,6 +5,11 @@ //! the first footnote and the space between footnotes). A line that would collide moves to the //! next column with its footnotes. After the story is laid out the footnotes of each column are //! stacked against the column bottom, with the separator rule above the first one. +//! +//! The footnotes of a column read as one text: the first footnote's first baseline sits at the +//! First Baseline offset below the top of the footnote area, and each later footnote's first +//! baseline sits its first line's leading (plus Space Between Footnotes) below the previous +//! footnote's last baseline. Only the last footnote's descent counts toward the area's height. use std::collections::HashMap; use std::ops::Range; @@ -22,10 +27,38 @@ pub(crate) struct Placed { k: usize, fi: usize, col: usize, - h: f64, + m: NoteMetrics, text: Arc<ComposedStory>, } +/// Vertical metrics of a composed footnote, relative to the top of its composed text. +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct NoteMetrics { + /// First and last baselines (the first one includes the First Baseline offset). + first: f64, + last: f64, + /// Leading of the first line: its baseline's distance below the previous footnote's last. + lead: f64, + /// Descent of the last line. + descent: f64, +} + +/// Height of footnotes stacked baseline to baseline, from the top of the footnote area to the +/// last footnote's descent. +fn stack_height<'a>(notes: impl IntoIterator<Item = &'a NoteMetrics>, space_between: f64) -> f64 { + let mut h: Option<f64> = None; + let mut descent = 0.0; + for m in notes { + let last = match h { + None => m.last, + Some(prev) => prev + space_between + m.lead + (m.last - m.first), + }; + h = Some(last); + descent = m.descent; + } + h.map_or(0.0, |h| h + descent) +} + pub(crate) struct Notes { /// Byte offsets of the reference characters (index = footnote index). refs: Vec<usize>, @@ -36,7 +69,7 @@ pub(crate) struct Notes { pub key: Option<usize>, pub placed: Vec<Placed>, /// Composed footnote text by (index, label, width). - cache: HashMap<(usize, String, u64), (Arc<ComposedStory>, f64)>, + cache: HashMap<(usize, String, u64), (Arc<ComposedStory>, NoteMetrics)>, notes: Vec<Arc<designcraft_doc::Footnote>>, } @@ -88,7 +121,7 @@ impl Notes { v } - fn compose(&mut self, doc: &Document, k: usize, w: f64, f: &FrameSpec, opts: &ComposeOptions) -> (Arc<ComposedStory>, f64) { + fn compose(&mut self, doc: &Document, k: usize, w: f64, f: &FrameSpec, opts: &ComposeOptions) -> (Arc<ComposedStory>, NoteMetrics) { let o = &doc.footnote_options; let label = format!("{}{}", o.label(self.numbers[k], true), o.separator); let key = (k, label.clone(), w.to_bits()); @@ -115,8 +148,12 @@ impl Notes { xrefs: opts.xrefs.clone(), }; let cs = crate::compose(doc, &self.notes[k].text, std::slice::from_ref(&spec), &nopts); - let h = cs.frames.first().and_then(|ft| ft.lines.last()).map_or(0.0, |l| l.baseline + l.descent); - let v = (Arc::new(cs), h); + let lines = cs.frames.first().map(|ft| ft.lines.as_slice()).unwrap_or_default(); + let m = match (lines.first(), lines.last()) { + (Some(a), Some(z)) => NoteMetrics { first: a.baseline, last: z.baseline, lead: a.leading, descent: z.descent }, + _ => NoteMetrics::default(), + }; + let v = (Arc::new(cs), m); self.cache.insert(key, v.clone()); v } @@ -125,25 +162,20 @@ impl Notes { #[allow(clippy::too_many_arguments)] pub fn reserve(&mut self, doc: &Document, fi: usize, col: usize, extra: &[usize], w: f64, f: &FrameSpec, opts: &ComposeOptions) -> f64 { let o = &doc.footnote_options; - let mut n = 0usize; - let mut h = 0.0; - for p in self.placed.iter().filter(|p| p.fi == fi && p.col == col) { - n += 1; - h += p.h; - } + let (space_between, space_before) = (o.space_between, o.space_before); + let mut ms: Vec<NoteMetrics> = self.placed.iter().filter(|p| p.fi == fi && p.col == col).map(|p| p.m).collect(); for &k in extra { - n += 1; - h += self.compose(doc, k, w, f, opts).1; + ms.push(self.compose(doc, k, w, f, opts).1); } - if n == 0 { 0.0 } else { h + o.space_between * (n - 1) as f64 + o.space_before } + if ms.is_empty() { 0.0 } else { stack_height(&ms, space_between) + space_before } } pub fn place(&mut self, doc: &Document, k: usize, fi: usize, col: usize, w: f64, f: &FrameSpec, opts: &ComposeOptions) { if self.placed.iter().any(|p| p.k == k) { return; } - let (text, h) = self.compose(doc, k, w, f, opts); - self.placed.push(Placed { k, fi, col, h, text }); + let (text, m) = self.compose(doc, k, w, f, opts); + self.placed.push(Placed { k, fi, col, m, text }); } /// Stack each column's footnotes against its bottom, rule above the first. @@ -159,7 +191,7 @@ impl Notes { for (fi, ci) in groups { let (Some(_), Some(col)) = (frames.get(fi), cols.get(fi).and_then(|c| c.get(ci))) else { continue }; let here: Vec<&Placed> = self.placed.iter().filter(|p| p.fi == fi && p.col == ci).collect(); - let total: f64 = here.iter().map(|p| p.h).sum::<f64>() + o.space_between * (here.len() - 1) as f64; + let total = stack_height(here.iter().map(|p| &p.m), o.space_between); let top = col.y1 - total; let ft = &mut out.frames[fi]; if o.rule.on && o.rule.weight > 0.0 { @@ -171,20 +203,29 @@ impl Notes { tint: o.rule.tint, }); } - let mut y = top; + // Previous footnote's last baseline and bottom. + let mut prev: Option<(f64, f64)> = None; for p in here { - let note = &story.notes[p.k]; + let Some(note) = story.notes.get(p.k) else { continue }; + let (y, y0) = match prev { + None => (top, top), + Some((last, bottom)) => { + let first = last + o.space_between + p.m.lead; + (first - p.m.first, (bottom + o.space_between).min(first)) + } + }; + let y1 = y + p.m.last + p.m.descent; ft.notes.push(PlacedNote { id: note.id, index: p.k, column: ci as u32, label: o.label(self.numbers[p.k], true), origin: Point::new(col.x0, y), - rect: Rect::new(col.x0, y, col.x1, y + p.h), + rect: Rect::new(col.x0, y0, col.x1, y1), text: p.text.clone(), source: Arc::from(note.text.text.as_str()), }); - y += p.h + o.space_between; + prev = Some((y + p.m.last, y1)); } } } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index f5d0113c1..67f3592d1 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -832,6 +832,52 @@ fn footnotes_sit_at_the_column_bottom_and_push_text() { assert!(note_caret(&cs, n.id, 0).is_some()); } +#[test] +fn footnotes_stack_baseline_to_baseline() { + // Single-line footnotes read as one text: consecutive baselines sit one leading (plus + // Space Between Footnotes) apart, and only the last footnote's descent adds to the area. + let text = format!("{LOREM} {LOREM} {LOREM} {LOREM}"); + for space_between in [0.0, 3.0] { + let (mut d, sid, fid) = doc_with(&text, Rect::new(36.0, 36.0, 300.0, 236.0), ParaAttrs::default()); + d.footnote_options.space_between = space_between; + d.footnote_options.space_before = 4.0; + { + let st = d.story_mut(sid).unwrap(); + for at in [60, 40, 20, 10] { + st.insert_note(at, "Note.", ParaFormat::default()); + } + st.check().unwrap(); + } + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let ft = cs.frame(fid).unwrap(); + assert_eq!(ft.notes.len(), 4); + let first_line = |n: &PlacedNote| { + let lines = &n.text.frames[0].lines; + assert_eq!(lines.len(), 1); + lines[0].clone() + }; + let baselines: Vec<f64> = ft.notes.iter().map(|n| n.origin.y + first_line(n).baseline).collect(); + let step = first_line(&ft.notes[1]).leading + space_between; + for w in baselines.windows(2) { + assert!((w[1] - w[0] - step).abs() < 1e-6, "baselines {baselines:?}, step {step}"); + } + for w in ft.notes.windows(2) { + assert!(w[0].rect.y1 <= w[1].rect.y0 + 1e-6, "rects stack in order"); + } + // Area: first-baseline offset + (n - 1) steps + the last descent, ending at the column bottom. + let col = ft.columns[0]; + let top = ft.notes[0].rect.y0; + let (l0, l3) = (first_line(&ft.notes[0]), first_line(&ft.notes[3])); + assert!((col.y1 - top - (l0.baseline + 3.0 * step + l3.descent)).abs() < 1e-6); + assert!((ft.notes[3].rect.y1 - col.y1).abs() < 1e-6); + // The body text fills the room left above the footnotes and their space before. + let room = top - d.footnote_options.space_before; + let last = ft.lines.last().unwrap(); + assert!(last.baseline + last.descent <= room + 0.01, "text clears the footnotes"); + assert!(last.baseline + last.leading + last.descent > room, "one more line wouldn't fit"); + } +} + #[test] fn footnote_first_line_wraps_at_the_column_edge_after_its_number() { // InDesign sets the footnote number and separator as part of the first line: that line From b21111f232223e333d372eab9c77a83895fe326d Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Mon, 12 Oct 2026 14:21:36 +0000 Subject: [PATCH 052/238] Toolbox: Shift+X, D, X and / shortcuts for the Fill/Stroke proxy The proxy's tooltips named keys that did nothing. color.defaultFillStroke (D), color.swapFillStroke (Shift+X), color.toggleFillStroke (X) and color.applyNone (/) are UI commands; the proxy's buttons run the same commands. Swap keeps the fill and stroke tints. Which of fill and stroke is in front lives in UiState, shared by the Tools panel proxy, the Color panel and the commands. The single-key shortcuts stay out of text being typed, like the tool shortcuts. --- ROADMAP.md | 1 + crates/ui-egui/src/i18n.rs | 9 ++++ crates/ui-egui/src/i18n/ar.rs | 3 ++ crates/ui-egui/src/i18n/it.rs | 3 ++ crates/ui-egui/src/i18n/ja.rs | 3 ++ crates/ui-egui/src/i18n/pt_br.rs | 3 ++ crates/ui-egui/src/i18n/uk.rs | 3 ++ crates/ui-egui/src/lib.rs | 5 ++ crates/ui-egui/src/menus.rs | 9 ++++ crates/ui-egui/src/panels/swatches.rs | 9 ++-- crates/ui-egui/src/toolbar.rs | 78 ++++++++++++++++++++++++--- docs/target-app-parity.md | 2 +- 12 files changed, 113 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d33353caa..29984b279 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-12** · The Tools panel's Fill/Stroke keys work: D (default fill and stroke), X (fill or stroke in front), Shift+X (swap, tints included) and / (Apply None), as `color.*` commands; the Color panel follows the proxy's fill/stroke choice. No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index e5e8a8d08..d9e9d07cb 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -646,6 +646,15 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Fill (X)", ["Fill (X)", "Fill (X)", "Fill (X)", "Fill (X)", "填色 (X)"]), ("Stroke (X)", ["Stroke (X)", "Stroke (X)", "Stroke (X)", "Stroke (X)", "描边 (X)"]), ("Apply None (/)", ["Apply None (/)", "Apply None (/)", "Apply None (/)", "Apply None (/)", "应用无 (/)"]), + ( + "Default Fill and Stroke", + ["Default Fill and Stroke", "Default Fill and Stroke", "Default Fill and Stroke", "Default Fill and Stroke", "默认填色和描边"], + ), + ("Swap Fill and Stroke", ["Swap Fill and Stroke", "Swap Fill and Stroke", "Swap Fill and Stroke", "Swap Fill and Stroke", "交换填色和描边"]), + ( + "Toggle Fill and Stroke", + ["Toggle Fill and Stroke", "Toggle Fill and Stroke", "Toggle Fill and Stroke", "Toggle Fill and Stroke", "切换填色和描边"], + ), ("Reverse the gradient", ["Reverse the gradient", "Reverse the gradient", "Reverse the gradient", "Reverse the gradient", "反转渐变"]), ("Style source", ["Style source", "Style source", "Style source", "Style source", "样式源"]), ("Open", ["Open", "Open", "Open", "Open", "打开"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 10e393cd2..e9f8981cb 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -520,6 +520,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Superscript", "النص المرتفع"), ("Tag the selection", "إضافة وسم للتحديد"), ("The structure is valid.", "البنية صالحة."), + ("Toggle Fill and Stroke", "التبديل بين التعبئة والحد"), ("Toggle double column", "تبديل التخطيط بعمودين"), ("Type:", "النوع:"), ("Underline", "تسطير"), @@ -844,6 +845,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Crop to:", "القص إلى:"), ("Cross-Reference Format", "تنسيق المرجع المتقاطع"), ("Cursor Key:", "مفتاح المؤشر:"), + ("Default Fill and Stroke", "التعبئة والحد الافتراضيان"), ("Default Fill and Stroke (D)", "التعبئة والحد الافتراضيان (D)"), ("Default View:", "العرض الافتراضي:"), ("Delete Column", "حذف العمود"), @@ -985,6 +987,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Style Name Conflicts", "تعارض أسماء الأنماط"), ("Stylistic Sets", "المجموعات الأسلوبية"), ("Subdivisions:", "التقسيمات الفرعية:"), + ("Swap Fill and Stroke", "تبديل التعبئة والحد"), ("Swap Fill and Stroke (Shift+X)", "تبديل التعبئة والحد (Shift+X)"), ("Synchronize", "مزامنة"), ("System default", "افتراضي النظام"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index c91afd6ba..9fe80ca10 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -376,6 +376,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Fill (X)", "Riempimento (X)"), ("Stroke (X)", "Traccia (X)"), ("Apply None (/)", "Applica nessuno (/)"), + ("Default Fill and Stroke", "Riempimento e traccia predefiniti"), + ("Swap Fill and Stroke", "Scambia riempimento e traccia"), + ("Toggle Fill and Stroke", "Alterna riempimento e traccia"), ("Reverse the gradient", "Inverti la sfumatura"), ("Style source", "Origine stile"), ("Open", "Apri"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index be6e2c4da..9db79b854 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -378,6 +378,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Fill (X)", "塗り (X)"), ("Stroke (X)", "線 (X)"), ("Apply None (/)", "カラーなしを適用 (/)"), + ("Default Fill and Stroke", "塗りと線をデフォルトに戻す"), + ("Swap Fill and Stroke", "塗りと線を入れ替え"), + ("Toggle Fill and Stroke", "塗りと線を切り替え"), ("Reverse the gradient", "グラデーションを反転"), ("Style source", "スタイルの同期元"), ("Open", "開く"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index d25717dc6..e1634665c 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -519,6 +519,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Superscript", "Sobrescrito"), ("Tag the selection", "Marcar a seleção"), ("The structure is valid.", "A estrutura é válida."), + ("Toggle Fill and Stroke", "Alternar entre preenchimento e traçado"), ("Toggle double column", "Alternar coluna dupla"), ("Type:", "Tipo:"), ("Underline", "Sublinhado"), @@ -846,6 +847,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Crop to:", "Cortar para:"), ("Cross-Reference Format", "Formato de referência cruzada"), ("Cursor Key:", "Tecla do cursor:"), + ("Default Fill and Stroke", "Preenchimento e traçado padrão"), ("Default Fill and Stroke (D)", "Preenchimento e traçado padrão (D)"), ("Default View:", "Exibição padrão:"), ("Delete Column", "Excluir coluna"), @@ -992,6 +994,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Style Name Conflicts", "Conflitos de nomes de estilos"), ("Stylistic Sets", "Conjuntos estilísticos"), ("Subdivisions:", "Subdivisões:"), + ("Swap Fill and Stroke", "Alternar preenchimento e traçado"), ("Swap Fill and Stroke (Shift+X)", "Alternar preenchimento e traçado (Shift+X)"), ("Synchronize", "Sincronizar"), ("System default", "Padrão do sistema"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 9a9fb89ba..b4434a2f5 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -379,6 +379,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Fill (X)", "Заливка (X)"), ("Stroke (X)", "Обведення (X)"), ("Apply None (/)", "Без кольору (/)"), + ("Default Fill and Stroke", "Заливка та обведення за замовчуванням"), + ("Swap Fill and Stroke", "Змінити заливку та обведення"), + ("Toggle Fill and Stroke", "Перемкнути заливку та обведення"), ("Reverse the gradient", "Змінити напрямок градієнта"), ("Style source", "Джерело стилів"), ("Open", "Відкрити"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3d94bdb03..ce8e439e3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -287,6 +287,10 @@ pub struct UiState { pub palette: Option<String>, #[serde(skip)] pub flyout: Option<usize>, + /// Fill/Stroke proxy (Tools and Color panels): the stroke is in front, so Apply None and the + /// Color panel act on it (X toggles). + #[serde(skip)] + pub stroke_front: bool, } impl Default for UiState { @@ -362,6 +366,7 @@ impl Default for UiState { dialog: None, palette: None, flyout: None, + stroke_front: false, } } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d123ec4eb..0d3f8c172 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -85,6 +85,10 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("view.zoom", "Zoom To", None, "{zoom: 1.0 = 100%}"), ("view.screenMode", "Screen Mode", None, "{mode: normal|preview|bleed|slug|presentation}"), ("view.togglePreview", "Toggle Normal/Preview", Some("W"), "{}"), + ("color.defaultFillStroke", "Default Fill and Stroke", Some("D"), "{} — the selection gets no fill and a black stroke"), + ("color.swapFillStroke", "Swap Fill and Stroke", Some("Shift+X"), "{} — the selection's fill and stroke colours (and tints) trade places"), + ("color.toggleFillStroke", "Toggle Fill and Stroke", Some("X"), "{} → {strokeFront} — which of fill and stroke the proxy has in front"), + ("color.applyNone", "Apply None", Some("/"), "{} — [None] for the fill or stroke in front of the proxy"), ("view.frameEdges", "Show/Hide Frame Edges", Some("Cmd+H"), "{}"), ("view.rulers", "Show/Hide Rulers", Some("Cmd+R"), "{}"), ("view.guides", "Show/Hide Guides", Some("Cmd+;"), "{}"), @@ -1109,6 +1113,10 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value, app.ui.screen_mode = if app.ui.screen_mode == ScreenMode::Normal { ScreenMode::Preview } else { ScreenMode::Normal }; Ok(Value::Null) } + "color.defaultFillStroke" => crate::toolbar::default_fill_stroke(app), + "color.swapFillStroke" => crate::toolbar::swap_fill_stroke(app), + "color.toggleFillStroke" => crate::toolbar::toggle_fill_stroke(app), + "color.applyNone" => crate::toolbar::apply_none(app), "view.frameEdges" => flag(&mut app.ui.frame_edges), "view.overprintPreview" => { app.canvas.shown = None; @@ -2097,6 +2105,7 @@ fn parse_shortcut(sc: &str) -> Option<(egui::Modifiers, egui::Key)> { "," => key = Some(egui::Key::Comma), "." => key = Some(egui::Key::Period), "\\" => key = Some(egui::Key::Backslash), + "/" => key = Some(egui::Key::Slash), "Delete" => key = Some(egui::Key::Delete), k => key = egui::Key::from_name(k), } diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 1aa11ddb3..7544347a9 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -209,9 +209,8 @@ pub fn color_panel(app: &mut DesignApp, ui: &mut egui::Ui) { return; }; let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return }; - let stroke_id = egui::Id::new("color_panel_stroke"); let mode_id = egui::Id::new("color_panel_mode"); - let mut stroke: bool = ui.data(|d| d.get_temp(stroke_id)).unwrap_or(false); + let mut stroke = app.ui.stroke_front; let default_mode = if doc.settings.intent == designcraft_doc::Intent::Print { ColorMode::Cmyk } else { ColorMode::Rgb }; let mut mode: ColorMode = ui.data(|d| d.get_temp(mode_id)).unwrap_or(default_mode); let target = if stroke { "stroke" } else { "fill" }; @@ -253,10 +252,8 @@ pub fn color_panel(app: &mut DesignApp, ui: &mut egui::Ui) { }); }); }); - ui.data_mut(|d| { - d.insert_temp(stroke_id, stroke); - d.insert_temp(mode_id, mode); - }); + app.ui.stroke_front = stroke; + ui.data_mut(|d| d.insert_temp(mode_id, mode)); let sw = doc.swatch(&name).cloned(); let Some(c) = doc.resolve_color(&name, 1.0) else { ui.label(crate::rtl::widget( diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 5a5e1f08a..ba825505e 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -196,9 +196,8 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) { let t = Tokens::get(ui.ctx()); let info = crate::panels::sel_info(app); let doc = app.session.active().map(|d| d.doc.clone()); - let stroke_front_id = egui::Id::new("proxy_stroke_front"); let text_id = egui::Id::new("proxy_text"); - let stroke_front: bool = ui.data(|d| d.get_temp(stroke_front_id)).unwrap_or(false); + let stroke_front = app.ui.stroke_front; let text_mode: bool = ui.data(|d| d.get_temp(text_id)).unwrap_or(false); let (fill_sw, stroke_sw) = match &info { Some(i) => (i.fill.clone(), i.stroke.clone()), @@ -216,8 +215,7 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) { }) .clicked() { - let _ = app.run("object.fill", json!({"swatch": "[None]"})); - let _ = app.run("object.stroke", json!({"swatch": "[Black]"})); + let _ = app.run("color.defaultFillStroke", json!({})); } let swap = egui::Rect::from_min_size(egui::pos2(x0 + 19.0, row.min.y), vec2(11.0, 11.0)); icons::paint(ui.painter(), swap, "swap", t.icon); @@ -228,8 +226,7 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) { }) .clicked() { - let _ = app.run("object.fill", json!({"swatch": stroke_sw})); - let _ = app.run("object.stroke", json!({"swatch": fill_sw})); + let _ = app.run("color.swapFillStroke", json!({})); } ui.add_space(2.0); // Proxy: 19.5 pt squares, fill top-left, stroke bottom-right. @@ -278,7 +275,7 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) { if fresp.clicked() || sresp.clicked() { let front = if stroke_front { !fresp.clicked() } else { sresp.clicked() && !fill_r.contains(sresp.interact_pointer_pos().unwrap_or_default()) }; - ui.data_mut(|d| d.insert_temp(stroke_front_id, front)); + app.ui.stroke_front = front; } if fresp.double_clicked() || sresp.double_clicked() { let _ = app.run("window.panel", json!({"panel": "swatches"})); @@ -318,7 +315,7 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) { flyout_triangle(ui.painter(), well, t.icon); let target = if stroke_front { "object.stroke" } else { "object.fill" }; if resp.clicked() { - let _ = app.run(target, json!({"swatch": "[None]"})); + let _ = app.run("color.applyNone", json!({})); } egui::Popup::context_menu(&resp).show(|ui| { for (label, sw) in [("Apply Color", "[Black]"), ("Apply None", "[None]")] { @@ -411,6 +408,33 @@ fn tool_button(ui: &mut egui::Ui, icon: &str, active: bool, tip: &str) -> egui:: }) } +/// Default Fill and Stroke (D): no fill, a black stroke. +pub(crate) fn default_fill_stroke(app: &mut DesignApp) -> Result<Value, String> { + app.run("object.fill", json!({"swatch": "[None]", "tint": 1.0}))?; + app.run("object.stroke", json!({"swatch": "[Black]", "tint": 1.0})) +} + +/// Swap Fill and Stroke (Shift+X): the selection's fill and stroke colours trade places, tints +/// included. +pub(crate) fn swap_fill_stroke(app: &mut DesignApp) -> Result<Value, String> { + let Some(info) = crate::panels::sel_info(app) else { return Err("nothing selected".into()) }; + let (fill_tint, stroke_tint) = (crate::panels::sel_tint(app, false), crate::panels::sel_tint(app, true)); + app.run("object.fill", json!({"swatch": info.stroke, "tint": stroke_tint}))?; + app.run("object.stroke", json!({"swatch": info.fill, "tint": fill_tint})) +} + +/// Fill/Stroke toggle (X): brings the other of fill and stroke to the front of the proxy. +pub(crate) fn toggle_fill_stroke(app: &mut DesignApp) -> Result<Value, String> { + app.ui.stroke_front = !app.ui.stroke_front; + Ok(json!({"strokeFront": app.ui.stroke_front})) +} + +/// Apply None (/): to the fill or the stroke, whichever is in front of the proxy. +pub(crate) fn apply_none(app: &mut DesignApp) -> Result<Value, String> { + let target = if app.ui.stroke_front { "object.stroke" } else { "object.fill" }; + app.run(target, json!({"swatch": "[None]"})) +} + #[cfg(test)] mod tests { use egui::{pos2, vec2}; @@ -482,4 +506,42 @@ mod tests { wheel(&mut h, panel.center(), vec2(0.0, -2000.0)); assert_eq!((rect(&h, LAST_TOOL), rect(&h, FILL)), before); } + /// The Fill/Stroke proxy's keys run on the selection and stay out of text being typed. + #[test] + fn fill_stroke_proxy_keys() { + use egui::{Event, Key, Modifiers}; + use serde_json::json; + fn press(app: &mut crate::DesignApp, ctx: &egui::Context, key: Key, modifiers: Modifiers) { + let events = [true, false].map(|pressed| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }).to_vec(); + ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| crate::menus::shortcuts(app, ui.ctx())).textures_delta.clear(); + } + fn colors(app: &crate::DesignApp) -> (String, f32, String, f32) { + let st = app.session.active().unwrap(); + let it = st.doc.item(st.selection.items[0]).unwrap(); + (it.fill.swatch.clone(), it.fill.tint, it.stroke.swatch.clone(), it.stroke.tint) + } + let mut app = app(false); + let ctx = egui::Context::default(); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Hello"})).unwrap(); + app.run("selection.set", json!({"ids": [f["id"]]})).unwrap(); + app.run("object.fill", json!({"swatch": "[Paper]", "tint": 0.5})).unwrap(); + app.run("object.stroke", json!({"swatch": "[Black]", "tint": 1.0})).unwrap(); + + press(&mut app, &ctx, Key::X, Modifiers::SHIFT); + assert_eq!(colors(&app), ("[Black]".into(), 1.0, "[Paper]".into(), 0.5), "Shift+X swaps, tints included"); + press(&mut app, &ctx, Key::D, Modifiers::NONE); + assert_eq!(colors(&app), ("[None]".into(), 1.0, "[Black]".into(), 1.0), "D: no fill, black stroke"); + press(&mut app, &ctx, Key::X, Modifiers::NONE); + assert!(app.ui.stroke_front, "X brings the stroke to the front"); + press(&mut app, &ctx, Key::Slash, Modifiers::NONE); + assert_eq!(colors(&app).2, "[None]", "/ applies None to the stroke in front"); + + // Typing in a story: the keys are text, not shortcuts. + app.session.execute("text.select", &json!({"story": f["story"], "anchor": 5})).unwrap(); + app.select_tool("type"); + assert!(app.session.wants_text()); + press(&mut app, &ctx, Key::X, Modifiers::NONE); + assert!(app.ui.stroke_front, "X while typing leaves the proxy alone"); + } } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e9c27a9f8..ee95a4905 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -268,7 +268,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Text engine: Knuth–Plass paragraph composer and single-line composer, dictionary hyphenation (public-domain Moby list + our own trained Liang patterns), justification with word/letter/glyph-scaling ranges, keep options (keep with next, keep lines together, widows/orphans), balance ragged lines, optical margin alignment, hyphenation zone/limit, columns, threading across frames and spreads, tabs, bullets/numbering, rules, shading, baseline grid, first-baseline options, vertical justification, wrap exclusions, overset detection, caret/hit testing. - FID2: IDML underline/strikethrough Text Color resets survive style inheritance and roundtrip. Raster and PDF paint underlines behind glyphs, retain strikethrough above, and center rules on their offsets; synthetic overlap tests cover both backends. - Rendering: vello_cpu (SIMD, multithreaded) — pages, items, images with mip levels, gradients, composed text. -- UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts, document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches, Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document and Preferences dialogs, Quick Apply (⌘Return), 4 brightness themes. +- UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts and the Fill/Stroke proxy (D, X, Shift+X, / as `color.*` commands), document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches, Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document and Preferences dialogs, Quick Apply (⌘Return), 4 brightness themes. - Pen tool, Direct Selection anchor/handle editing, rotate from corners, Rotate/Scale/Shear tools, Eyedropper, snapping & smart guides, Align/Distribute, Find/Change (text + GREP), Story Editor, Paragraph Style Options, Effects panel, hidden characters, native macOS menu bar, measured InDesign 2026 look (Medium Dark): Properties sections per selection state, 21 pt spinner fields with arithmetic, Contextual Task Bar, inverse (black) text selection, Pages panel with drag-and-drop reorder / parent apply, Layers panel with per-object rows. - Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection, Type (draw frame, click caret, select, type), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom. - PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`. File › Export PDF… now opens an "Export PDF" options dialog (General / Compression / Marks and Bleeds / Advanced) and remembers the last-used settings across exports and sessions. From 4ac6e9a54060e52c264ab1c6f21900a04479977b Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Mon, 12 Oct 2026 14:28:58 +0000 Subject: [PATCH 053/238] Engine: swap and default fill and stroke in one undo step object.swapFillStroke and object.defaultFillStroke change every unlocked target in one edit, so one undo restores them all. Swap works per object and keeps each object's tints; a stroke that gets a colour at zero weight gets the default 1 pt. Objects that are locked, or on a locked layer, stay as they are. color.swapFillStroke and color.defaultFillStroke run these commands. --- ROADMAP.md | 2 +- crates/engine/src/cmd/object.rs | 110 ++++++++++++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 14 +++- crates/ui-egui/src/toolbar.rs | 11 +--- 4 files changed, 126 insertions(+), 11 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 29984b279..4dd17e470 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,7 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. -- **2026-10-12** · The Tools panel's Fill/Stroke keys work: D (default fill and stroke), X (fill or stroke in front), Shift+X (swap, tints included) and / (Apply None), as `color.*` commands; the Color panel follows the proxy's fill/stroke choice. No readiness percentage change. +- **2026-10-12** · The Tools panel's Fill/Stroke keys work: D (default fill and stroke), X (fill or stroke in front), Shift+X (swap per object, tints included) and / (Apply None), as `color.*` commands; swap and default are one undo step (`object.swapFillStroke`, `object.defaultFillStroke`); the Color panel follows the proxy's fill/stroke choice. No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index cd13be117..699247291 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -185,6 +185,24 @@ pub fn specs() -> Vec<CommandSpec> { let tint = f64_or(p, "tint", 1.0) as f32; set_flag(s, p, move |i| i.fill = Fill { swatch: sw.clone(), tint, ..i.fill.clone() }, false) }), + cmd!( + "object.swapFillStroke", + "Swap Fill and Stroke", + [], + None, + "{ids?} — each object's fill and stroke colours (and tints) trade places; locked objects stay as they are", + has_editable_selection, + |s, p| edit_unlocked(s, p, swap_fill_stroke) + ), + cmd!( + "object.defaultFillStroke", + "Default Fill and Stroke", + [], + None, + "{ids?} — no fill and a black stroke; locked objects stay as they are", + has_editable_selection, + |s, p| edit_unlocked(s, p, default_fill_stroke) + ), cmd!( "object.stroke", "Stroke", @@ -1404,6 +1422,60 @@ fn set_flag(s: &mut Session, p: &Value, f: impl Fn(&mut Item), deselect: bool) - }) } +/// An object a colour command may change: neither it nor its layer is locked. +fn unlocked(d: &Document, it: &Item) -> bool { + !it.locked && d.layer(it.layer).is_none_or(|l| !l.locked) +} + +fn has_editable_selection(s: &Session) -> std::result::Result<(), String> { + has_selection(s)?; + let st = s.active().ok_or_else(|| super::NOTHING_SELECTED.to_string())?; + if st.selection.items.iter().any(|id| st.doc.item(*id).is_some_and(|it| unlocked(&st.doc, it))) { + Ok(()) + } else { + Err("the selected objects are locked".into()) + } +} + +/// Runs `f` on each unlocked target in one edit (one undo step). +fn edit_unlocked(s: &mut Session, p: &Value, f: impl Fn(&mut Item)) -> Result<Value> { + let ids = targets(s, p)?; + s.edit(|d, _| { + let mut changed = 0; + for id in &ids { + if d.item(*id).is_some_and(|it| unlocked(d, it)) + && let Some(it) = d.item_mut(*id) + { + f(it); + changed += 1; + } + } + Ok(json!({"changed": changed})) + }) +} + +/// A stroke that gets a colour needs a weight to show it. +fn visible_weight(st: &mut Stroke) { + if st.swatch != designcraft_color::swatch::NONE && st.weight <= 0.0 { + st.weight = Stroke::default().weight; + } +} + +fn swap_fill_stroke(it: &mut Item) { + let fill = std::mem::take(&mut it.fill); + it.fill = Fill { swatch: std::mem::take(&mut it.stroke.swatch), tint: it.stroke.tint, ..fill.clone() }; + it.stroke.swatch = fill.swatch; + it.stroke.tint = fill.tint; + visible_weight(&mut it.stroke); +} + +fn default_fill_stroke(it: &mut Item) { + it.fill = Fill { swatch: designcraft_color::swatch::NONE.into(), tint: 1.0, ..it.fill.clone() }; + it.stroke.swatch = designcraft_color::swatch::BLACK.into(); + it.stroke.tint = 1.0; + visible_weight(&mut it.stroke); +} + fn all_flag(s: &mut Session, f: impl Fn(&mut Item)) -> Result<Value> { s.edit(|d, _| { for i in 0..d.spreads.len() { @@ -2125,6 +2197,44 @@ fn step_and_repeat(s: &mut Session, p: &Value) -> Result<Value> { mod step_repeat_tests { use super::*; + #[test] + fn swap_and_default_fill_stroke_are_one_undo_step_per_object() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = |v: Value| ItemId(v["id"].as_u64().unwrap()); + let a = id(s.execute("frame.create", &json!({"rect": [0, 0, 10, 10]})).unwrap()); + let b = id(s.execute("frame.create", &json!({"rect": [20, 0, 30, 10]})).unwrap()); + let locked = id(s.execute("frame.create", &json!({"rect": [40, 0, 50, 10]})).unwrap()); + s.execute("object.fill", &json!({"ids": [a.0], "swatch": "[Paper]", "tint": 0.5})).unwrap(); + s.execute("object.stroke", &json!({"ids": [a.0], "swatch": "[Black]", "weight": 2})).unwrap(); + s.execute("object.fill", &json!({"ids": [b.0, locked.0], "swatch": "[Black]"})).unwrap(); + s.execute("object.stroke", &json!({"ids": [b.0, locked.0], "swatch": "[None]"})).unwrap(); + s.execute("object.lock", &json!({"ids": [locked.0]})).unwrap(); + s.execute("selection.set", &json!({"ids": [a.0, b.0, locked.0]})).unwrap(); + let colors = |s: &Session, i: ItemId| { + let it = s.doc().unwrap().doc.item(i).unwrap().clone(); + (it.fill.swatch, it.fill.tint, it.stroke.swatch, it.stroke.tint) + }; + let before = [colors(&s, a), colors(&s, b), colors(&s, locked)]; + + assert_eq!(s.execute("object.swapFillStroke", &json!({})).unwrap()["changed"], 2); + assert_eq!(colors(&s, a), ("[Black]".into(), 1.0, "[Paper]".into(), 0.5)); + assert_eq!(colors(&s, b), ("[None]".into(), 1.0, "[Black]".into(), 1.0)); + assert_eq!(colors(&s, locked), before[2], "a locked object keeps its colours"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!([colors(&s, a), colors(&s, b), colors(&s, locked)], before, "one undo restores both"); + + s.execute("object.defaultFillStroke", &json!({})).unwrap(); + for i in [a, b] { + assert_eq!(colors(&s, i), ("[None]".into(), 1.0, "[Black]".into(), 1.0)); + } + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!([colors(&s, a), colors(&s, b), colors(&s, locked)], before); + + s.execute("selection.set", &json!({"ids": [locked.0]})).unwrap(); + assert!(s.execute("object.swapFillStroke", &json!({})).is_err(), "nothing unlocked to change"); + } + #[test] fn step_repeat_validates_counts_and_offsets() { assert_eq!(step_repeat_offsets(&json!({"count": MAX_STEP_REPEAT_COPIES})).unwrap().len(), MAX_STEP_REPEAT_COPIES as usize); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 0d3f8c172..b614467e4 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -85,8 +85,18 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("view.zoom", "Zoom To", None, "{zoom: 1.0 = 100%}"), ("view.screenMode", "Screen Mode", None, "{mode: normal|preview|bleed|slug|presentation}"), ("view.togglePreview", "Toggle Normal/Preview", Some("W"), "{}"), - ("color.defaultFillStroke", "Default Fill and Stroke", Some("D"), "{} — the selection gets no fill and a black stroke"), - ("color.swapFillStroke", "Swap Fill and Stroke", Some("Shift+X"), "{} — the selection's fill and stroke colours (and tints) trade places"), + ( + "color.defaultFillStroke", + "Default Fill and Stroke", + Some("D"), + "{} — the selection gets no fill and a black stroke (object.defaultFillStroke)", + ), + ( + "color.swapFillStroke", + "Swap Fill and Stroke", + Some("Shift+X"), + "{} — each selected object's fill and stroke colours (and tints) trade places (object.swapFillStroke)", + ), ("color.toggleFillStroke", "Toggle Fill and Stroke", Some("X"), "{} → {strokeFront} — which of fill and stroke the proxy has in front"), ("color.applyNone", "Apply None", Some("/"), "{} — [None] for the fill or stroke in front of the proxy"), ("view.frameEdges", "Show/Hide Frame Edges", Some("Cmd+H"), "{}"), diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index ba825505e..2a6744d25 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -410,17 +410,12 @@ fn tool_button(ui: &mut egui::Ui, icon: &str, active: bool, tip: &str) -> egui:: /// Default Fill and Stroke (D): no fill, a black stroke. pub(crate) fn default_fill_stroke(app: &mut DesignApp) -> Result<Value, String> { - app.run("object.fill", json!({"swatch": "[None]", "tint": 1.0}))?; - app.run("object.stroke", json!({"swatch": "[Black]", "tint": 1.0})) + app.run("object.defaultFillStroke", json!({})) } -/// Swap Fill and Stroke (Shift+X): the selection's fill and stroke colours trade places, tints -/// included. +/// Swap Fill and Stroke (Shift+X): each selected object's fill and stroke trade places. pub(crate) fn swap_fill_stroke(app: &mut DesignApp) -> Result<Value, String> { - let Some(info) = crate::panels::sel_info(app) else { return Err("nothing selected".into()) }; - let (fill_tint, stroke_tint) = (crate::panels::sel_tint(app, false), crate::panels::sel_tint(app, true)); - app.run("object.fill", json!({"swatch": info.stroke, "tint": stroke_tint}))?; - app.run("object.stroke", json!({"swatch": info.fill, "tint": fill_tint})) + app.run("object.swapFillStroke", json!({})) } /// Fill/Stroke toggle (X): brings the other of fill and stroke to the front of the proxy. From bb45f12b05dd05aec3c556592cc70ddf3bf10ba5 Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Mon, 12 Oct 2026 14:39:14 +0000 Subject: [PATCH 054/238] Place: with no frame selected, the graphic loads into the place cursor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File › Place (native picker, browser import, Place with Import Options and the PDF page dialog) passes loadCursor to file.place. With no empty or graphic frame selected and no x/y given, an image is added to the document and loaded into the place cursor instead of landing in a new frame on the first spread; a click places it at its natural size, a drag draws its frame. The loaded graphic keeps the chosen PDF page and crop box. Escape or choosing another tool unloads the cursor. Dropped files and file.place without loadCursor behave as before. --- crates/engine/src/cmd/file.rs | 43 ++++++++++++++++++++------- crates/engine/src/lib.rs | 14 +++++++-- crates/engine/src/tooling.rs | 4 +++ crates/tools/src/nav.rs | 7 +++++ crates/ui-egui/src/dialogs.rs | 2 +- crates/ui-egui/src/lib.rs | 56 ++++++++++++++++++++++++++++++----- crates/ui-egui/src/menus.rs | 2 +- 7 files changed, 106 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index ed126eee5..aa2356493 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -100,7 +100,7 @@ pub fn specs() -> Vec<CommandSpec> { "Place…", ["File"], Some("Cmd+D"), - "{path?|base64?, name?, frame?: id (place into), spread?, x?, y?, width?, pdfPage?: n (1-based, Image Import Options), pdfCrop?: crop|trim|bleed|art|media, layoutPage?: n (IDML / .designcraft: that page's objects as a group)} — places an image (into the selected empty frame if any); text files (.txt, .docx, .rtf, .md) and Excel workbooks (.xlsx, as a table) go into the insertion point, the selected frame or a new frame on `page`/`rect` — {autoflow?: adds pages with threaded frames until the text fits, removeStyles?, styleMap?: {imported name: document style}, styleConflicts?: useExisting|redefine|autoRename}", + "{path?|base64?, name?, frame?: id (place into), spread?, x?, y?, width?, pdfPage?: n (1-based, Image Import Options), pdfCrop?: crop|trim|bleed|art|media, loadCursor?: with no frame to fill and no x/y, an image loads into the place cursor (File › Place), layoutPage?: n (IDML / .designcraft: that page's objects as a group)} — places an image (into the selected empty frame if any); text files (.txt, .docx, .rtf, .md) and Excel workbooks (.xlsx, as a table) go into the insertion point, the selected frame or a new frame on `page`/`rect` — {autoflow?: adds pages with threaded frames until the text fits, removeStyles?, styleMap?: {imported name: document style}, styleConflicts?: useExisting|redefine|autoRename}", has_doc, file_place ), @@ -336,6 +336,11 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> { let px = p.get("x").and_then(Value::as_f64); let py = p.get("y").and_then(Value::as_f64); let want_w = p.get("width").and_then(Value::as_f64); + if target_frame.is_none() && px.is_none() && py.is_none() && p.get("loadCursor").and_then(Value::as_bool) == Some(true) { + let mime = designcraft_render::image_mime(&bytes).to_string(); + let asset = Asset { page: pdf_page, id: AssetId(0), name, mime, link, data: Arc::new(bytes), pixels: Some((pw, ph)) }; + return load_cursor(s, asset, (nw, nh), crop_box); + } s.edit(|d, sel| { let fresh = AssetId(d.alloc()); let mime = designcraft_render::image_mime(&bytes).to_string(); @@ -442,18 +447,32 @@ fn place_load(s: &mut Session, p: &Value) -> Result<Value> { let (bytes, name, link) = read_source(p)?; let (pw, ph) = designcraft_render::image_size(&bytes).ok_or_else(|| bad("place.load", "unsupported or corrupt image"))?; let natural = designcraft_render::natural_size(&bytes, 0).unwrap_or((pw as f64, ph as f64)); + let mime = designcraft_render::image_mime(&bytes).to_string(); + let asset = Asset { page: 0, id: AssetId(0), name, mime, link, data: Arc::new(bytes), pixels: Some((pw, ph)) }; + load_cursor(s, asset, natural, None) +} + +/// Adds `asset` to the document (with a fresh id) and loads it into the place cursor. +fn load_cursor(s: &mut Session, mut asset: Asset, size: (f64, f64), crop: Option<(f64, f64, f64, f64)>) -> Result<Value> { + let name = asset.name.clone(); + let (pw, ph) = asset.pixels.unwrap_or_default(); let aid = s.edit(|d, _| { - let fresh = AssetId(d.alloc()); - let mime = designcraft_render::image_mime(&bytes).to_string(); - Ok(d.add_asset(Asset { page: 0, id: fresh, name: name.clone(), mime, link: link.clone(), data: Arc::new(bytes), pixels: Some((pw, ph)) })) + asset.id = AssetId(d.alloc()); + Ok(d.add_asset(asset)) })?; - s.loaded = Some((aid, natural)); + s.loaded = Some(crate::LoadedGraphic { asset: aid, size, crop }); s.set_tool("placeGun"); - Ok(json!({"asset": aid.0, "name": name, "width": pw, "height": ph})) + Ok(json!({"asset": aid.0, "name": name, "width": pw, "height": ph, "loaded": true})) } fn place_drop(s: &mut Session, p: &Value) -> Result<Value> { - let (aid, (nw, nh)) = s.loaded.ok_or_else(|| bad("place.drop", "nothing loaded in the place cursor"))?; + let crate::LoadedGraphic { asset: aid, size: (nw, nh), crop } = + s.loaded.ok_or_else(|| bad("place.drop", "nothing loaded in the place cursor"))?; + // Undo can remove the loaded graphic's asset. + if !s.doc()?.doc.assets.contains_key(&aid) { + s.loaded = None; + return Err(bad("place.drop", "the loaded graphic is no longer in the document")); + } let sr = super::spread_param(p, "spread"); let lid = s.doc()?.active_layer; let frame = super::id_param(p, "frame"); @@ -477,12 +496,14 @@ fn place_drop(s: &mut Session, p: &Value) -> Result<Value> { } None => { // Drag: fit proportionally into the dragged rect; click: actual size at the point. + // The frame shows the crop box when one was chosen. + let (bx, by, bw, bh) = crop.unwrap_or((0.0, 0.0, nw, nh)); let (frame_r, k) = match rect { Some(r) if r.width() > 2.0 && r.height() > 2.0 => { - let k = (r.width() / nw).min(r.height() / nh); - (Rect::new(r.x0, r.y0, r.x0 + nw * k, r.y0 + nh * k), k) + let k = (r.width() / bw).min(r.height() / bh); + (Rect::new(r.x0, r.y0, r.x0 + bw * k, r.y0 + bh * k), k) } - _ => (Rect::new(x, y, x + nw, y + nh), 1.0), + _ => (Rect::new(x, y, x + bw, y + bh), 1.0), }; let id = ItemId(d.alloc()); let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(frame_r)); @@ -490,7 +511,7 @@ fn place_drop(s: &mut Session, p: &Value) -> Result<Value> { it.content = Content::Graphic(Graphic { asset: aid, size: (nw, nh), - xf: Affine::translate((frame_r.x0, frame_r.y0)) * Affine::scale(k), + xf: Affine::translate((frame_r.x0 - bx * k, frame_r.y0 - by * k)) * Affine::scale(k), auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4], diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 771c89b13..29a396e39 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -105,6 +105,16 @@ pub struct DocState { #[derive(Debug)] pub struct FontScope(pub u32); +/// A graphic loaded in the place cursor (`place.load`, File › Place with no frame to fill). +#[derive(Clone, Copy, Debug)] +pub struct LoadedGraphic { + pub asset: designcraft_doc::AssetId, + /// Natural size in points. + pub size: (f64, f64), + /// Image Import Options › Crop to: the box of the graphic a new frame shows (x, y, w, h). + pub crop: Option<(f64, f64, f64, f64)>, +} + impl Drop for FontScope { fn drop(&mut self) { designcraft_fonts::FontDb::global().close_scope(self.0); @@ -258,8 +268,8 @@ pub struct Session { pub text_clipboard: Option<(Arc<designcraft_doc::Story>, String)>, /// Requests for the UI (dialogs, view changes) produced by tools/commands. pub ui_requests: Vec<UiRequest>, - /// Graphic loaded in the place cursor: (asset, natural size in points). - pub loaded: Option<(designcraft_doc::AssetId, (f64, f64))>, + /// Graphic loaded in the place cursor. + pub loaded: Option<LoadedGraphic>, pub(crate) untitled: u32, /// Transforms applied to the current selection, oldest first (Transform Again / Sequence /// Again), with the selection they were applied to and whether the last one came from a diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 1bf8db930..e562ec144 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -59,6 +59,10 @@ impl Session { return; } self.tool = designcraft_tools::create(id); + // Any other tool unloads the place cursor. + if id != "placeGun" { + self.loaded = None; + } // Leaving the Type tool keeps the text frame selected. if id != "type" && let Some(st) = self.active_mut() diff --git a/crates/tools/src/nav.rs b/crates/tools/src/nav.rs index d892b090a..fa9621650 100644 --- a/crates/tools/src/nav.rs +++ b/crates/tools/src/nav.rs @@ -157,6 +157,13 @@ impl Tool for PlaceGun { None => vec![], } } + fn key(&mut self, _cx: &ToolContext, key: crate::ToolKey, _mods: Mods) -> Vec<Action> { + match key { + // Escape unloads the place cursor. + crate::ToolKey::Escape => vec![Action::SwitchTool("selection".into())], + _ => vec![], + } + } fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { Cursor::LoadedGraphic } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2ab7a3bad..da0a2ad06 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1921,7 +1921,7 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> { if let Some(target) = d.fields.get("target").and_then(Value::as_u64) { app.activate_import_target(&crate::ImportRequest { purpose: "place".into(), target: Some(target) })?; } - app.open_file("file.place", json!({"path": d.s("path"), "pdfPage": d.n("page").unwrap_or(1.0).max(1.0) as u64, "pdfCrop": if crop.is_empty() { "crop".to_string() } else { crop }})) + app.place_file(json!({"path": d.s("path"), "pdfPage": d.n("page").unwrap_or(1.0).max(1.0) as u64, "pdfCrop": if crop.is_empty() { "crop".to_string() } else { crop }})) } "print" => { let pages = if d.s("range") == "all" { Value::Null } else { json!(d.s("pages")) }; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3d94bdb03..ac25e6c23 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -622,7 +622,6 @@ impl DesignApp { /// command right away; asynchronous ones (web) deliver the file through the inbox. pub fn pick_and_open(&mut self, purpose: &str) -> Result<Value, String> { let target = self.session.active().map(|d| d.uid); - let cmd = if purpose == "place" { "file.place" } else { "file.open" }; if let Some(pick) = self.services.pick_open.as_mut() { return match pick(purpose) { // Image Import Options: a multi-page PDF asks which page. @@ -634,10 +633,11 @@ impl DesignApp { Some(dialogs::Dialog::new("pdfImport", json!({"path": path, "page": "1", "pages": pages, "target": target}))); Ok(Value::Null) } else { - self.open_file(cmd, json!({"path": path})) + self.place_file(json!({"path": path})) } } - Some(path) => self.open_file(cmd, json!({"path": path})), + Some(path) if purpose == "place" => self.place_file(json!({"path": path})), + Some(path) => self.open_file("file.open", json!({"path": path})), None => Ok(Value::Null), }; } @@ -669,10 +669,8 @@ impl DesignApp { pub(crate) fn confirm_pdf_import(&mut self, page: u64, crop: String) -> Result<Value, String> { let file = self.pending_pdf.take().ok_or("PDF import cancelled: no pending file")?; self.activate_import_target(&file.request)?; - self.open_file( - "file.place", - json!({"name": file.name, "base64": designcraft_engine::cmd::base64_encode(&file.bytes), "pdfPage": page, "pdfCrop": crop}), - ) + let params = json!({"name": file.name, "base64": designcraft_engine::cmd::base64_encode(&file.bytes), "pdfPage": page, "pdfCrop": crop}); + if file.request.purpose == "place" { self.place_file(params) } else { self.open_file("file.place", params) } } /// A single unresolved PDF owns its bytes. Later deliveries cancel explicitly rather than @@ -726,6 +724,8 @@ impl DesignApp { let b64 = designcraft_engine::cmd::base64_encode(&file.bytes); if lower.ends_with(".ase") || file.request.purpose == "swatches" { self.run("swatch.load", json!({"base64": b64})) + } else if file.request.purpose == "place" { + self.place_file(json!({"name": file.name, "base64": b64})) } else if place { self.open_file("file.place", json!({"name": file.name, "base64": b64})) } else { @@ -810,6 +810,15 @@ impl DesignApp { self.open_file(cmd, json!({"path": path})) } + /// File › Place: the file fills the selected empty or graphic frame; with none, an image loads + /// into the place cursor (click to place at its natural size, drag to draw the frame). + pub(crate) fn place_file(&mut self, mut params: Value) -> Result<Value, String> { + if let Some(o) = params.as_object_mut() { + o.insert("loadCursor".into(), json!(true)); + } + self.open_file("file.place", params) + } + /// Open (`file.open`, `file.openBytes`) or place (`file.place`) a file the user chose. A file /// that can't be opened or placed gets an alert with the reason, as well as the status line. /// A successful import with warnings keeps the document and shows the warnings in an alert. @@ -1625,6 +1634,7 @@ mod browser_import_tests { assert_eq!(*asset.data, bytes); assert!(app.pending_pdf.is_none()); assert!(app.session.documents()[1].doc.assets.is_empty()); + app.run("place.drop", json!({"x": 36, "y": 36})).unwrap(); let exported = app.run("file.exportPdf", json!({})).unwrap(); let exported = designcraft_engine::cmd::base64_decode(exported["base64"].as_str().unwrap()); let pixels = designcraft_render::render_pdf_page(&exported, 0, 400).unwrap(); @@ -1632,6 +1642,38 @@ mod browser_import_tests { assert_eq!(pixels.data().iter().filter(|p| p.r > 240 && p.g < 10 && p.b < 10).count(), 0); } + #[test] + fn place_loads_the_cursor_unless_a_frame_can_take_the_graphic() { + let mut app = app(); + let items = |app: &DesignApp| app.session.doc().unwrap().doc.spreads[0].items.len(); + // Nothing selected: the graphic waits in the place cursor; Escape unloads it. + let request = app.import_request("place"); + deliver(&mut app, request, "red.svg", image()); + assert_eq!(app.session.tool_id(), "placeGun"); + assert!(app.session.loaded.is_some()); + assert_eq!(items(&app), 0); + let mods = designcraft_tools::Mods::default(); + app.session.tool_key(designcraft_tools::ToolKey::Escape, mods, designcraft_engine::ViewInfo::at_zoom(1.0)).unwrap(); + assert_eq!(app.session.tool_id(), "selection"); + assert!(app.session.loaded.is_none()); + // A click with the loaded cursor places the graphic at its natural size. + let request = app.import_request("place"); + deliver(&mut app, request, "red.svg", image()); + app.run("place.drop", json!({"x": 100, "y": 100})).unwrap(); + let it = app.session.doc().unwrap().doc.spreads[0].items.last().unwrap(); + assert_eq!(it.bounds(), designcraft_geom::Rect::new(100.0, 100.0, 107.5, 107.5), "10 CSS px = 7.5 pt"); + // An empty graphic frame selected: the graphic fills it. + let f = app.run("frame.create", json!({"rect": [200, 200, 300, 300], "content": "graphic"})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [f]})).unwrap(); + let request = app.import_request("place"); + deliver(&mut app, request, "red.svg", image()); + assert_eq!(app.session.tool_id(), "selection"); + assert!(app.session.loaded.is_none()); + assert_eq!(items(&app), 2); + let frame = app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap(); + assert!(matches!(frame.content, designcraft_doc::Content::Graphic(_))); + } + #[test] fn pdf_options_reject_oversized_page_without_editing() { let mut app = app(); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d123ec4eb..fee4653d2 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -743,7 +743,7 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value, let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("place")) else { return Some(Ok(Value::Null)) }; let l = path.to_lowercase(); if !(l.ends_with(".docx") || l.ends_with(".rtf")) { - return Some(app.open_file("file.place", json!({"path": path}))); + return Some(app.place_file(json!({"path": path}))); } let info = match app.run("place.styles", json!({"path": path})) { Ok(v) => v, From ebd1d78194082efba477983ebb24569a41d7dd41 Mon Sep 17 00:00:00 2001 From: Brandon Thomas <echelon@gmail.com> Date: Mon, 12 Oct 2026 11:08:17 -0400 Subject: [PATCH 055/238] Release workflow: pass the Azure signing secrets so Windows builds are signed (#534) Since the release workflow moved to storytold/craft-common-actions, it has passed only the Apple and WINDOWS_CERTIFICATE secrets. The signing job reads values from the release environment, but only for names the caller lists, so the AZURE_* names that Trusted Signing uses came through empty and Windows builds would ship unsigned. Same fix as storytold/craft-launcher#174. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --- .github/workflows/release.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 37c71fc65..57eb9bb53 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,3 +35,9 @@ jobs: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} + AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} + AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} From 3624f488192a8eeade11bd27609c9e5056214e75 Mon Sep 17 00:00:00 2001 From: Brandon Thomas <echelon@gmail.com> Date: Mon, 12 Oct 2026 11:11:06 -0400 Subject: [PATCH 056/238] Release 0.7.1: rebuild with the fixed shared release workflow The previous release build failed in craft-common-actions, not in this repo: riscv64 packaging got --formats deb,rpm,tar (fixed in craft-common-actions#3), Linux jobs lacked the ALSA headers (craft-common-actions#5), and Windows signing needed the AZURE_* secrets listed in the caller. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- Cargo.lock | 40 ++++++++++++++++++++-------------------- Cargo.toml | 2 +- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2e01165c9..5e4dc8975 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -887,7 +887,7 @@ dependencies = [ [[package]] name = "designcraft" -version = "0.7.0" +version = "0.7.1" dependencies = [ "designcraft-engine", "designcraft-ui-egui", @@ -904,7 +904,7 @@ dependencies = [ [[package]] name = "designcraft-cli" -version = "0.7.0" +version = "0.7.1" dependencies = [ "designcraft-color", "designcraft-compose", @@ -920,7 +920,7 @@ dependencies = [ [[package]] name = "designcraft-color" -version = "0.7.0" +version = "0.7.1" dependencies = [ "kurbo", "moxcms", @@ -932,7 +932,7 @@ dependencies = [ [[package]] name = "designcraft-compose" -version = "0.7.0" +version = "0.7.1" dependencies = [ "designcraft-color", "designcraft-doc", @@ -949,7 +949,7 @@ dependencies = [ [[package]] name = "designcraft-doc" -version = "0.7.0" +version = "0.7.1" dependencies = [ "designcraft-color", "designcraft-geom", @@ -961,7 +961,7 @@ dependencies = [ [[package]] name = "designcraft-engine" -version = "0.7.0" +version = "0.7.1" dependencies = [ "caseless", "craft-print", @@ -995,7 +995,7 @@ dependencies = [ [[package]] name = "designcraft-epub" -version = "0.7.0" +version = "0.7.1" dependencies = [ "designcraft-color", "designcraft-doc", @@ -1004,7 +1004,7 @@ dependencies = [ [[package]] name = "designcraft-fonts" -version = "0.7.0" +version = "0.7.1" dependencies = [ "harfrust 0.13.3", "kurbo", @@ -1016,7 +1016,7 @@ dependencies = [ [[package]] name = "designcraft-format" -version = "0.7.0" +version = "0.7.1" dependencies = [ "designcraft-doc", "serde", @@ -1027,7 +1027,7 @@ dependencies = [ [[package]] name = "designcraft-geom" -version = "0.7.0" +version = "0.7.1" dependencies = [ "flo_curves", "kurbo", @@ -1040,7 +1040,7 @@ dependencies = [ [[package]] name = "designcraft-idml" -version = "0.7.0" +version = "0.7.1" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1053,7 +1053,7 @@ dependencies = [ [[package]] name = "designcraft-images" -version = "0.7.0" +version = "0.7.1" dependencies = [ "designcraft-color", "designcraft-fonts", @@ -1064,7 +1064,7 @@ dependencies = [ [[package]] name = "designcraft-mcp" -version = "0.7.0" +version = "0.7.1" dependencies = [ "designcraft-engine", "designcraft-geom", @@ -1077,7 +1077,7 @@ dependencies = [ [[package]] name = "designcraft-pdf" -version = "0.7.0" +version = "0.7.1" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1099,7 +1099,7 @@ dependencies = [ [[package]] name = "designcraft-render" -version = "0.7.0" +version = "0.7.1" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1117,7 +1117,7 @@ dependencies = [ [[package]] name = "designcraft-textimport" -version = "0.7.0" +version = "0.7.1" dependencies = [ "designcraft-doc", "quick-xml 0.38.4", @@ -1126,7 +1126,7 @@ dependencies = [ [[package]] name = "designcraft-tools" -version = "0.7.0" +version = "0.7.1" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1138,7 +1138,7 @@ dependencies = [ [[package]] name = "designcraft-ui-egui" -version = "0.7.0" +version = "0.7.1" dependencies = [ "craft-ui", "designcraft-color", @@ -1165,7 +1165,7 @@ dependencies = [ [[package]] name = "designcraft-web" -version = "0.7.0" +version = "0.7.1" dependencies = [ "designcraft-engine", "designcraft-format", @@ -5685,7 +5685,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.7.0" +version = "0.7.1" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 82d13fe8d..c2126ade8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"] [workspace.package] -version = "0.7.0" +version = "0.7.1" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From 1278957455becb2ddb155a62871a4960df842e63 Mon Sep 17 00:00:00 2001 From: Sean Hammond <sean.hammond@und.edu> Date: Mon, 12 Oct 2026 15:14:08 +0000 Subject: [PATCH 057/238] Text wrap: the minimum gap applies only where a wrap object reaches the column free_slot held a column to the minimum gap width (1.5 em) whenever a wrap object overlapped the line's band vertically, even if the object sat beside another column of the frame: in a frame of narrow columns, an object beside column 2 dropped column 1's lines. The minimum now applies only when the object reaches the column itself (aiongg's review on #589). Test: wrap_object_beside_one_narrow_column_leaves_the_other_alone (two 18 pt columns in a 14 pt paragraph, an object beside column 2) fails before (overset) and passes after. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CgGsJkVe5GYG9qK6VFdECa --- crates/compose/src/lib.rs | 8 +++++--- crates/compose/src/tests.rs | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 5ab809649..cc29bee0e 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -2080,8 +2080,8 @@ fn line_metrics( /// Widest free horizontal interval of `col` in the band, or None if blocked. A gap a wrap object /// cuts narrower than 1.5 em of `size` is skipped (a sliver beside the object); when no object -/// reaches the band the whole column is free, however narrow (a one-character frame such as an -/// article's closing tombstone). +/// reaches the column in the band the whole column is free, however narrow (a one-character frame +/// such as an article's closing tombstone, or a narrow column beside another that wraps). fn free_slot(f: &FrameSpec, col: Rect, y0: f64, y1: f64, size: f64) -> Option<(f64, f64)> { let mut free = vec![(col.x0, col.x1)]; let mut cut = false; @@ -2092,7 +2092,9 @@ fn free_slot(f: &FrameSpec, col: Rect, y0: f64, y1: f64, size: f64) -> Option<(f WrapMode::JumpToNextColumn if y1 > r.y0 && r.x0 < col.x1 && r.x1 > col.x0 => return None, WrapMode::JumpObject if overlaps_band && r.x0 < col.x1 && r.x1 > col.x0 => return None, WrapMode::BoundingBox | WrapMode::Contour if overlaps_band => { - cut = true; + // Held to the minimum width only when the object reaches this column: one beside + // another column of the frame leaves this one whole, however narrow. + cut |= r.x0 < col.x1 && r.x1 > col.x0; let mut next = Vec::new(); for (a, b) in free { if r.x1 <= a || r.x0 >= b { diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index b0cc474c7..9e5ad3f25 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -184,6 +184,25 @@ fn narrow_frame_sets_lines_no_wrap_object_reaches() { assert!(baseline - top < 20.0, "on the first line: {baseline} (text area from {top})"); } +/// In a frame of narrow columns, a wrap object beside column 2 leaves column 1 alone: only a gap +/// the object cuts in its own column is held to the minimum width (it was dropped, as the object +/// overlapped the line's band, and the text went past column 1). +#[test] +fn wrap_object_beside_one_narrow_column_leaves_the_other_alone() { + // Two 18 pt columns (12 pt gutter): narrower than 1.5 em of the 14 pt paragraph. + let (mut d, sid) = tombstone(Rect::new(300.0, 300.0, 348.0, 340.0), Rect::new(336.0, 300.0, 380.0, 340.0)); + let fid = d.spreads[0].items.iter().find(|i| i.text_frame().is_some_and(|t| t.story == sid)).unwrap().id; + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2; + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let f = &cs.frames[0]; + assert_eq!(f.columns.len(), 2); + assert!(f.columns[0].width() < 21.0, "column 1 is narrower than 1.5 em: {}", f.columns[0].width()); + assert!(!cs.is_overset()); + let l = &f.lines[0]; + assert_eq!(l.column, 0, "set in column 1"); + assert!(l.baseline - f.columns[0].y0 < 20.0, "on its first line: {}", l.baseline); +} + #[test] fn paragraph_composer_is_no_worse_than_greedy() { // Sum of squared slack over lines (excluding last) should not exceed the greedy result. From 5cdcac4aceb87ebe0a1f2e0e3cdffd2b4e9b3fb3 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:19:36 -0400 Subject: [PATCH 058/238] fix(save): replace files atomically Replace native engine saves and engine/MCP exports atomically, preserving private permissions during staging and cleaning up failed writes. Add regressions for long filenames and caught writer unwinding. Fixes #436. Co-authored-by: aiongg <aiongtaigi@gmail.com> Co-authored-by: skylar taylor-barrick <hi@skylar.cc> --- ROADMAP.md | 3 +- crates/engine/src/atomic.rs | 359 +++++++++++++++++++++++++++ crates/engine/src/cmd/book.rs | 6 +- crates/engine/src/cmd/export.rs | 10 +- crates/engine/src/cmd/file.rs | 37 ++- crates/engine/src/cmd/interchange.rs | 2 +- crates/engine/src/cmd/library.rs | 2 +- crates/engine/src/cmd/style.rs | 6 +- crates/engine/src/cmd/xml.rs | 2 +- crates/engine/src/lib.rs | 2 + crates/mcp/src/headless.rs | 4 +- crates/mcp/src/tools.rs | 2 +- docs/target-app-parity.md | 1 + 13 files changed, 417 insertions(+), 19 deletions(-) create mode 100644 crates/engine/src/atomic.rs diff --git a/ROADMAP.md b/ROADMAP.md index d33353caa..6045a0007 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **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-11 · **Change:** minor (shared print spooler; Windows CI passes) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (atomic engine saves and exports) · **Target:** Adobe InDesign 2026 DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. diff --git a/crates/engine/src/atomic.rs b/crates/engine/src/atomic.rs new file mode 100644 index 000000000..7eed2f028 --- /dev/null +++ b/crates/engine/src/atomic.rs @@ -0,0 +1,359 @@ +//! Crash-safe file replacement: how documents, exports and other user files reach the disk. +//! +//! [`write_atomic`] never writes the destination in place. It writes a uniquely named temporary +//! file in the same folder, flushes it (`sync_all`), renames it over the destination and then +//! flushes the folder (Unix) so the rename survives a power loss. When any step before the rename +//! fails, the temporary file is removed and the destination keeps its old bytes. +//! +//! - A replaced file keeps its permissions (Unix mode). +//! - A symbolic link keeps pointing at its file: the file it names is replaced. +//! - Windows: antivirus scanners and the search indexer briefly hold files open, which makes the +//! replacing rename fail with `PermissionDenied`; the rename is retried with a short backoff. +//! - A failed rename (cross-device, unwritable folder) is returned as an error; there is no +//! fallback to writing in place. +//! - Flushing the parent folder after replacement is best effort. A successful save confirms +//! that the new file is in place; it cannot guarantee survival of a subsequent power loss if +//! the file system rejects that folder flush. +//! +//! The web has no file system, so this module is not built for `wasm32`. + +use std::fs::File; +#[cfg(unix)] +use std::fs::OpenOptions; +use std::io::{self, Write as _}; +use std::path::{Path, PathBuf}; + +/// How many times a replacing rename is tried before its error is returned. +const RENAME_TRIES: u32 = if cfg!(windows) { 5 } else { 1 }; +/// The pause before the first rename retry; it doubles up to [`RENAME_MAX_DELAY_MS`]. +const RENAME_FIRST_DELAY_MS: u64 = 10; +const RENAME_MAX_DELAY_MS: u64 = 200; +/// Symbolic links followed before giving up (a loop, or an absurd chain). +const MAX_LINKS: usize = 40; + +/// Atomically replace (or create) `path` with `bytes`. See the module docs. +pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> { + write_atomic_with(path, |f| f.write_all(bytes)) +} + +/// [`write_atomic`] for a command's `path` parameter: the error names the path. +pub(crate) fn write_file(path: &str, bytes: &[u8]) -> crate::Result<()> { + write_atomic(Path::new(path), bytes).map_err(|e| crate::EngineError::Other(format!("{path}: {e}"))) +} + +/// Atomically replace (or create) `path` with what `fill` writes into the temporary file. When +/// `fill`, the flush or the rename fails, the temporary file is removed, `path` is untouched and +/// the error is returned. +pub fn write_atomic_with(path: &Path, fill: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> { + let target = resolve_links(path)?; + target.file_name().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, format!("{}: not a file path", path.display())))?; + let dir = match target.parent() { + Some(d) if !d.as_os_str().is_empty() => d.to_path_buf(), + _ => PathBuf::from("."), + }; + let existing = std::fs::metadata(&target).ok(); + if existing.is_some() { + check_writable(&target)?; + } + let mut tmp = create_temp(&dir, existing.as_ref())?; + if let Some(existing) = existing { + // Restore the exact mode before any document bytes are written, and flush it along with + // the content. The caller's umask may have restricted the initial temporary-file mode. + keep_permissions(tmp.as_file(), &existing)?; + } + fill(tmp.as_file_mut())?; + tmp.as_file().sync_all()?; + // Closing the file before renaming also allows replacement on Windows. TempPath removes the + // staging file on every error; NamedTempFile does the same if the writer unwinds. + let tmp = tmp.into_temp_path(); + retry_rename(RENAME_TRIES, || std::fs::rename(&tmp, &target))?; + // The new bytes are in place. A failed folder flush can't be undone and must not report the + // save as failed. + let _ = sync_dir(&dir); + Ok(()) +} + +/// Create a fresh temporary file beside the target (the same folder, so the rename stays on one +/// volume). A short randomized name supports long destination names and skips existing entries. +fn create_temp(dir: &Path, existing: Option<&std::fs::Metadata>) -> io::Result<tempfile::NamedTempFile> { + let mut builder = tempfile::Builder::new(); + builder.prefix(".designcraft-save-").suffix(".tmp"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + // Private replacements are private from creation. New files retain fs::write's normal + // 0666 mode restricted by the caller's umask. + let mode = existing.map_or(0o666, |metadata| metadata.permissions().mode()); + builder.permissions(std::fs::Permissions::from_mode(mode)); + } + #[cfg(not(unix))] + let _ = existing; + builder.tempfile_in(dir) +} + +/// The file a chain of symbolic links ends at (`path` itself when it is not a link). A dangling +/// link resolves to the missing file it names, which is then created. +fn resolve_links(path: &Path) -> io::Result<PathBuf> { + let mut p = path.to_path_buf(); + for _ in 0..MAX_LINKS { + match std::fs::symlink_metadata(&p) { + Ok(m) if m.file_type().is_symlink() => { + let to = std::fs::read_link(&p)?; + p = match p.parent() { + Some(dir) if to.is_relative() => dir.join(to), + _ => to, + }; + } + _ => return Ok(p), + } + } + Err(io::Error::new(io::ErrorKind::InvalidInput, format!("{}: too many levels of symbolic links", path.display()))) +} + +/// A rename replaces a file the user can't write (Unix only needs the folder to be writable); +/// refuse, as writing it in place would. Opening for writing without truncating changes nothing. +#[cfg(unix)] +fn check_writable(target: &Path) -> io::Result<()> { + OpenOptions::new().write(true).open(target).map(drop) +} + +/// Windows refuses to rename over a read-only file by itself. +#[cfg(not(unix))] +fn check_writable(_: &Path) -> io::Result<()> { + Ok(()) +} + +/// The replacement keeps the replaced file's mode (Unix; Windows files keep only a read-only flag, +/// and a read-only file can't be replaced). +#[cfg(unix)] +fn keep_permissions(tmp: &File, replaced: &std::fs::Metadata) -> io::Result<()> { + tmp.set_permissions(replaced.permissions()) +} + +#[cfg(not(unix))] +fn keep_permissions(_: &File, _: &std::fs::Metadata) -> io::Result<()> { + Ok(()) +} + +/// Run `rename` up to `tries` times, pausing with a doubling delay while it fails with +/// `PermissionDenied` (a scanner holding the file on Windows). Other errors return at once. +fn retry_rename(tries: u32, mut rename: impl FnMut() -> io::Result<()>) -> io::Result<()> { + let mut delay = RENAME_FIRST_DELAY_MS; + let mut left = tries.max(1); + loop { + left = left.saturating_sub(1); + match rename() { + Err(e) if e.kind() == io::ErrorKind::PermissionDenied && left > 0 => { + std::thread::sleep(std::time::Duration::from_millis(delay)); + delay = delay.saturating_mul(2).min(RENAME_MAX_DELAY_MS); + } + r => return r, + } + } +} + +/// Flush a folder's entries (Unix). Rust's portable file API cannot open a directory on Windows; +/// this does not establish power-loss durability for its directory entries. +#[cfg(unix)] +fn sync_dir(dir: &Path) -> io::Result<()> { + File::open(dir)?.sync_all() +} + +#[cfg(not(unix))] +fn sync_dir(_: &Path) -> io::Result<()> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn dir(name: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("dc-atomic-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d + } + + /// The names in `d`, sorted. + fn names(d: &Path) -> Vec<String> { + let mut v: Vec<String> = std::fs::read_dir(d).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); + v.sort(); + v + } + + #[test] + fn replaces_and_creates_without_leftovers() { + let d = dir("replace"); + let path = d.join("doc.designcraft"); + write_atomic(&path, b"first").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"first"); + write_atomic(&path, b"second, longer").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"second, longer"); + write_atomic(&path, b"3").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"3"); + assert_eq!(names(&d), ["doc.designcraft"], "no temporary file is left"); + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn a_failed_write_leaves_the_original_and_no_temporary_file() { + let d = dir("fail"); + let path = d.join("doc.designcraft"); + std::fs::write(&path, b"original").unwrap(); + let r = write_atomic_with(&path, |f| { + f.write_all(b"half a docu")?; + Err(io::Error::other("disk full")) + }); + assert_eq!(r.unwrap_err().to_string(), "disk full"); + assert_eq!(std::fs::read(&path).unwrap(), b"original"); + assert_eq!(names(&d), ["doc.designcraft"], "the temporary file is removed"); + // A folder that doesn't exist: an error, nothing written. + assert!(write_atomic(&d.join("missing").join("x.designcraft"), b"x").is_err()); + assert_eq!(names(&d), ["doc.designcraft"]); + // A folder in the way: an error, the folder stays and nothing is left beside it. + std::fs::create_dir(d.join("folder")).unwrap(); + assert!(write_atomic(&d.join("folder"), b"x").is_err()); + assert_eq!(names(&d), ["doc.designcraft", "folder"]); + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn a_long_destination_name_does_not_lengthen_the_temporary_name() { + let d = dir("long-name"); + let name = format!("{}.designcraft", "a".repeat(235)); + let path = d.join(&name); + std::fs::write(&path, b"old").unwrap(); + write_atomic(&path, b"new").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"new"); + assert_eq!(names(&d), [name]); + let _ = std::fs::remove_dir_all(&d); + } + + #[cfg(unix)] + #[test] + fn private_permissions_are_preserved_while_the_file_is_written() { + use std::os::unix::fs::PermissionsExt as _; + let d = dir("private-staging"); + let path = d.join("private.designcraft"); + std::fs::write(&path, b"old").unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + write_atomic_with(&path, |file| { + assert_eq!(file.metadata()?.permissions().mode() & 0o777, 0o600); + file.write_all(b"private document")?; + assert_eq!(file.metadata()?.permissions().mode() & 0o777, 0o600); + Ok(()) + }) + .unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"private document"); + assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600); + assert_eq!(names(&d), ["private.designcraft"]); + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn an_unwinding_writer_removes_its_partial_temporary_file() { + let d = dir("unwinding-writer"); + let path = d.join("private.designcraft"); + std::fs::write(&path, b"old").unwrap(); + let result = std::panic::catch_unwind(|| { + write_atomic_with(&path, |file| { + file.write_all(b"partial private document")?; + panic!("synthetic writer panic"); + }) + }); + assert!(result.is_err()); + assert_eq!(std::fs::read(&path).unwrap(), b"old"); + assert_eq!(names(&d), ["private.designcraft"]); + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn the_rename_is_retried_only_while_access_is_denied() { + let mut calls = 0; + let r = retry_rename(3, || { + calls += 1; + if calls < 3 { Err(io::Error::from(io::ErrorKind::PermissionDenied)) } else { Ok(()) } + }); + assert!(r.is_ok() && calls == 3); + calls = 0; + let r = retry_rename(3, || { + calls += 1; + Err(io::Error::from(io::ErrorKind::PermissionDenied)) + }); + assert_eq!(r.map_err(|e| e.kind()), Err(io::ErrorKind::PermissionDenied)); + assert_eq!(calls, 3, "gives up after the last try"); + calls = 0; + let r = retry_rename(3, || { + calls += 1; + Err(io::Error::from(io::ErrorKind::CrossesDevices)) + }); + assert!(r.is_err() && calls == 1, "other errors are not retried"); + } + + #[cfg(unix)] + #[test] + fn permissions_are_kept_and_unwritable_files_are_not_replaced() { + use std::os::unix::fs::PermissionsExt as _; + let d = dir("perms"); + let path = d.join("private.designcraft"); + std::fs::write(&path, b"old").unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + write_atomic(&path, b"new").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"new"); + assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600); + // A read-only file stays as it is (root may write it anyway). + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o400)).unwrap(); + if OpenOptions::new().write(true).open(&path).is_err() { + assert!(write_atomic(&path, b"newer").is_err()); + assert_eq!(std::fs::read(&path).unwrap(), b"new"); + assert_eq!(names(&d), ["private.designcraft"]); + } + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + let _ = std::fs::remove_dir_all(&d); + } + + /// A folder the user can't write: the save fails and the original stays. + #[cfg(unix)] + #[test] + fn an_unwritable_folder_fails_without_touching_the_file() { + use std::os::unix::fs::PermissionsExt as _; + let d = dir("ro-dir"); + let path = d.join("doc.designcraft"); + std::fs::write(&path, b"original").unwrap(); + std::fs::set_permissions(&d, std::fs::Permissions::from_mode(0o555)).unwrap(); + // Root writes anyway; the check only means something for an ordinary user. + let probe = d.join("probe"); + let unwritable = std::fs::write(&probe, b"").is_err(); + if unwritable { + assert!(write_atomic(&path, b"new").is_err()); + assert_eq!(std::fs::read(&path).unwrap(), b"original"); + } + std::fs::set_permissions(&d, std::fs::Permissions::from_mode(0o755)).unwrap(); + let _ = std::fs::remove_file(&probe); + assert_eq!(names(&d), ["doc.designcraft"]); + let _ = std::fs::remove_dir_all(&d); + } + + #[cfg(unix)] + #[test] + fn a_symbolic_link_keeps_pointing_at_the_replaced_file() { + let d = dir("link"); + std::fs::create_dir(d.join("real")).unwrap(); + let target = d.join("real").join("doc.designcraft"); + std::fs::write(&target, b"old").unwrap(); + let link = d.join("link.designcraft"); + std::os::unix::fs::symlink("real/doc.designcraft", &link).unwrap(); + write_atomic(&link, b"new").unwrap(); + assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink()); + assert_eq!(std::fs::read(&target).unwrap(), b"new"); + assert_eq!(names(&d), ["link.designcraft", "real"]); + assert_eq!(names(&d.join("real")), ["doc.designcraft"]); + // A loop of links is an error, not a hang. + std::os::unix::fs::symlink("b", d.join("a")).unwrap(); + std::os::unix::fs::symlink("a", d.join("b")).unwrap(); + assert!(write_atomic(&d.join("a"), b"x").is_err()); + let _ = std::fs::remove_dir_all(&d); + } +} diff --git a/crates/engine/src/cmd/book.rs b/crates/engine/src/cmd/book.rs index 9631dac2b..1204029c0 100644 --- a/crates/engine/src/cmd/book.rs +++ b/crates/engine/src/cmd/book.rs @@ -28,7 +28,7 @@ fn no_book() -> EngineError { #[cfg(not(target_arch = "wasm32"))] fn save(b: &Book) -> Result<()> { let bytes = serde_json::to_vec_pretty(b).map_err(|e| EngineError::Other(e.to_string()))?; - std::fs::write(&b.path, bytes).map_err(|e| EngineError::Other(format!("{}: {e}", b.path))) + crate::atomic::write_file(&b.path, &bytes) } #[cfg(target_arch = "wasm32")] @@ -52,7 +52,7 @@ fn load(path: &str) -> Result<designcraft_doc::Document> { fn store(path: &str, d: &designcraft_doc::Document) -> Result<()> { #[cfg(not(target_arch = "wasm32"))] - return std::fs::write(path, super::file::to_bytes(d)).map_err(|e| EngineError::Other(format!("{path}: {e}"))); + return crate::atomic::write_file(path, &super::file::to_bytes(d)); #[cfg(target_arch = "wasm32")] { let _ = (path, d); @@ -210,7 +210,7 @@ pub fn specs() -> Vec<CommandSpec> { match str_param(p, "path") { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + crate::atomic::write_file(path, &bytes)?; Ok(json!({"path": path, "bytes": bytes.len(), "pages": pages})) } None => Ok(json!({"base64": super::file::base64_encode(&bytes), "bytes": bytes.len(), "pages": pages})), diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index f47863851..8e0f04d69 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -44,7 +44,7 @@ fn print_booklet(s: &mut Session, p: &Value) -> Result<Value> { match str_param(p, "path") { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - std::fs::write(path, &r.bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + crate::atomic::write_file(path, &r.bytes)?; Ok(json!({"path": path, "bytes": r.bytes.len(), "sheets": r.pages, "warnings": r.warnings})) } None => Ok(json!({"base64": super::file::base64_encode(&r.bytes), "bytes": r.bytes.len(), "sheets": r.pages, "warnings": r.warnings})), @@ -59,7 +59,7 @@ fn export_html(s: &mut Session, p: &Value) -> Result<Value> { match str_param(p, "path") { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - std::fs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + crate::atomic::write_file(path, text.as_bytes())?; Ok(json!({"path": path, "bytes": text.len()})) } None => Ok(json!({"bytes": text.len(), "text": text})), @@ -91,7 +91,7 @@ fn export_text(s: &mut Session, p: &Value) -> Result<Value> { match path { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - std::fs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + crate::atomic::write_file(path, text.as_bytes())?; Ok(json!({"path": path, "bytes": text.len()})) } None => Ok(json!({"bytes": text.len(), "text": text})), @@ -160,7 +160,7 @@ fn export_epub(s: &mut Session, p: &Value) -> Result<Value> { match p.get("path").and_then(Value::as_str) { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - std::fs::write(path, &bytes).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?; + crate::atomic::write_file(path, &bytes)?; Ok(serde_json::json!({"path": path, "bytes": bytes.len()})) } None => Ok(serde_json::json!({"base64": super::file::base64_encode(&bytes), "bytes": bytes.len()})), @@ -598,7 +598,7 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> { #[cfg(not(target_arch = "wasm32"))] fn write_file(path: &str, bytes: &[u8]) -> Result<()> { - std::fs::write(path, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}"))) + crate::atomic::write_file(path, bytes) } #[cfg(target_arch = "wasm32")] fn write_file(path: &str, _bytes: &[u8]) -> Result<()> { diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index ed126eee5..b5887bb50 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> { let path = str_param(p, "path").ok_or_else(|| bad("file.saveACopy", "missing `path`"))?.to_string(); let bytes = to_bytes(&s.doc()?.doc); #[cfg(not(target_arch = "wasm32"))] - std::fs::write(&path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + crate::atomic::write_file(&path, &bytes)?; Ok(json!({"path": path, "bytes": bytes.len()})) }), cmd!(noundo "file.revert", "Revert", ["File"], None, "{} — back to the last saved version (not undoable)", can_revert, file_revert), @@ -247,7 +247,7 @@ fn file_save(s: &mut Session, p: &Value) -> Result<Value> { } let bytes = to_bytes(&st.doc); #[cfg(not(target_arch = "wasm32"))] - std::fs::write(&path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + crate::atomic::write_file(&path, &bytes)?; st.path = Some(path.clone()); st.saved_revision = st.revision; st.saved_doc = st.doc.clone(); @@ -518,7 +518,7 @@ fn snippet_export(s: &mut Session, p: &Value) -> Result<Value> { match str_param(p, "path") { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + crate::atomic::write_file(path, &bytes)?; Ok(json!({"path": path, "bytes": bytes.len(), "items": d.spreads.first().map(|s| s.items.len()).unwrap_or(0)})) } None => Ok(json!({"base64": base64_encode(&bytes), "bytes": bytes.len()})), @@ -735,6 +735,37 @@ mod layout_place_tests { } } +#[cfg(test)] +mod save_tests { + use serde_json::json; + + use crate::Session; + + /// Saving over an existing document replaces it whole and leaves nothing beside it. + #[test] + fn save_replaces_the_existing_file_without_leftovers() { + let dir = std::env::temp_dir().join(format!("dc-save-atomic-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("doc.designcraft"); + std::fs::write(&path, vec![b'x'; 1 << 20]).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": "Saved whole", "caret": false})).unwrap(); + s.execute("file.save", &json!({"path": path.to_string_lossy()})).unwrap(); + s.execute("file.saveACopy", &json!({"path": path.to_string_lossy()})).unwrap(); + let saved = super::from_bytes(&std::fs::read(&path).unwrap()).unwrap(); + assert!(saved.stories.values().any(|st| st.text == "Saved whole")); + let names: Vec<_> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name()).collect(); + assert_eq!(names, ["doc.designcraft"], "no temporary file is left"); + // A folder that doesn't exist: an error naming the path. + let missing = dir.join("missing").join("doc.designcraft"); + let e = s.execute("file.save", &json!({"path": missing.to_string_lossy()})).unwrap_err(); + assert!(e.to_string().contains("missing"), "{e}"); + let _ = std::fs::remove_dir_all(&dir); + } +} + #[cfg(test)] mod document_fonts_tests { use std::path::{Path, PathBuf}; diff --git a/crates/engine/src/cmd/interchange.rs b/crates/engine/src/cmd/interchange.rs index a0af09f5f..96498e863 100644 --- a/crates/engine/src/cmd/interchange.rs +++ b/crates/engine/src/cmd/interchange.rs @@ -25,7 +25,7 @@ fn export_idml(s: &mut Session, p: &Value) -> Result<Value> { match str_param(p, "path") { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + crate::atomic::write_file(path, &bytes)?; Ok(json!({"path": path, "bytes": bytes.len()})) } None => Ok(json!({"base64": base64_encode(&bytes), "bytes": bytes.len()})), diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index f0e77af56..00855dd09 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -29,7 +29,7 @@ impl Library { #[cfg(not(target_arch = "wasm32"))] if let Some(p) = &self.path { let bytes = serde_json::to_vec_pretty(self).map_err(|e| crate::EngineError::Other(e.to_string()))?; - std::fs::write(p, bytes).map_err(|e| crate::EngineError::Other(format!("{p}: {e}")))?; + crate::atomic::write_file(p, &bytes)?; } Ok(()) } diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 8a520b685..4bf69ec41 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -844,7 +844,11 @@ fn save_swatches(s: &mut Session, p: &Value) -> Result<Value> { let bytes = designcraft_color::ase::write(&chosen); match str_param(p, "path") { Some(path) => { - std::fs::write(path, &bytes).map_err(|e| bad("swatch.save", format!("{path}: {e}")))?; + #[cfg(not(target_arch = "wasm32"))] + let written = crate::atomic::write_atomic(std::path::Path::new(path), &bytes); + #[cfg(target_arch = "wasm32")] + let written = std::fs::write(path, &bytes); + written.map_err(|e| bad("swatch.save", format!("{path}: {e}")))?; Ok(json!({"path": path, "bytes": bytes.len()})) } None => Ok(json!({"base64": super::file::base64_encode(&bytes)})), diff --git a/crates/engine/src/cmd/xml.rs b/crates/engine/src/cmd/xml.rs index 32252a9df..6dab205fa 100644 --- a/crates/engine/src/cmd/xml.rs +++ b/crates/engine/src/cmd/xml.rs @@ -305,7 +305,7 @@ pub fn specs() -> Vec<CommandSpec> { match str_param(p, "path") { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - std::fs::write(path, text.as_bytes()).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?; + crate::atomic::write_file(path, text.as_bytes())?; Ok(json!({"path": path, "bytes": text.len()})) } None => Ok(json!({"text": text, "bytes": text.len()})), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 771c89b13..164719305 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -7,6 +7,8 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +#[cfg(not(target_arch = "wasm32"))] +pub mod atomic; pub mod cmd; pub mod dtd; pub mod guard; diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index aea3e8763..eea81ddd3 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -201,7 +201,7 @@ impl Headless { let png = img.to_png(); match s(p, "path") { Some(path) => { - std::fs::write(path, &png).map_err(|e| format!("write {path}: {e}"))?; + designcraft_engine::atomic::write_atomic(std::path::Path::new(path), &png).map_err(|e| format!("write {path}: {e}"))?; Ok(json!({"path": path, "width": img.width, "height": img.height})) } None => Ok(json!({"width": img.width, "height": img.height, "pngBase64": designcraft_engine::cmd::base64_encode(&png)})), @@ -221,7 +221,7 @@ impl Headless { .ok_or_else(|| format!("no page {page}"))?; let lower = path.to_ascii_lowercase(); let bytes = if lower.ends_with(".jpg") || lower.ends_with(".jpeg") { img.to_jpeg(90) } else { img.to_png() }; - std::fs::write(path, &bytes).map_err(|e| format!("write {path}: {e}"))?; + designcraft_engine::atomic::write_atomic(std::path::Path::new(path), &bytes).map_err(|e| format!("write {path}: {e}"))?; Ok(json!({"path": path, "width": img.width, "height": img.height})) } } diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 802f615dc..c51bac8ed 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -479,7 +479,7 @@ fn render_page(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> { let path = a.get("path").and_then(Value::as_str); if let Some(path) = path { let png = designcraft_engine::cmd::base64_decode(&b64); - std::fs::write(path, png).map_err(|e| format!("write {path}: {e}"))?; + designcraft_engine::atomic::write_atomic(std::path::Path::new(path), &png).map_err(|e| format!("write {path}: {e}"))?; } Ok(ToolResult::image(b64, &json!({"width": r.get("width"), "height": r.get("height"), "path": path}))) } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e9c27a9f8..6c033d700 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -288,6 +288,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - 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 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. Failed writes before replacement retain the original; existing Unix permissions apply before staging document bytes. Desktop Services writes and CLI raster exports still need this routing. Parent-folder flushing is best effort, so this is not a power-loss durability guarantee. - Crash recovery: unsaved documents are written to the recovery folder every 30 s and reopened (unsaved, remembering their files) after a crash; File › Revert and Save a Copy…; `file.recovery.*`, `file.revert`, `file.saveACopy`. - Graphics backend chosen before the window exists (DirectX 12 on Windows, Vulkan on Linux, Metal on macOS, OpenGL as the fallback; `WGPU_BACKEND` overrides) with a start-up fallback: a backend whose start never showed a frame is skipped at the next start and the status bar says so (`gpu.json`). Found on an AMD hybrid-graphics laptop whose Vulkan driver faulted at the first present. - Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool). From 328b4d5a52f095a49b946a1e9189a65ba08dde79 Mon Sep 17 00:00:00 2001 From: Noah Scholz <48360809+noah1400@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:26:34 +0200 Subject: [PATCH 059/238] fix(recovery): isolate recovery data between app instances Keep each app's recovery data in an owned run folder. Validate current folder and lock ownership and disable recovery when isolation fails instead of using the shared root. Fixes #496. Co-authored-by: noah1400 <48360809+noah1400@users.noreply.github.com> Co-authored-by: skylar taylor-barrick <hi@skylar.cc> --- ROADMAP.md | 3 +- apps/designcraft/src/profile.rs | 39 +- crates/engine/src/recovery.rs | 641 +++++++++++++++++++++++++++++++- docs/target-app-parity.md | 2 +- 4 files changed, 662 insertions(+), 23 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 6045a0007..15c9af8de 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **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 (atomic engine saves and exports) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (isolated recovery for each running app) · **Target:** Adobe InDesign 2026 DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change. - **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. diff --git a/apps/designcraft/src/profile.rs b/apps/designcraft/src/profile.rs index c3da12b08..3e90879e3 100644 --- a/apps/designcraft/src/profile.rs +++ b/apps/designcraft/src/profile.rs @@ -22,7 +22,19 @@ fn config_dir_with(os: &str, env: impl Fn(&str) -> Option<OsString>) -> Option<P } pub fn recovery_dir() -> Option<PathBuf> { - recovery_dir_with(std::env::var_os("DESIGNCRAFT_CONFIG_DIR"), designcraft_engine::recovery::default_dir) + recovery_dir_with(std::env::var_os("DESIGNCRAFT_CONFIG_DIR"), designcraft_engine::recovery::default_dir).and_then(own_run) +} + +/// This run's folder in the recovery folder `root`. A second running DesignCraft has its own, so +/// neither writes over the other's documents nor takes them for a crash's. +fn own_run(root: PathBuf) -> Option<PathBuf> { + match designcraft_engine::recovery::run_dir(&root) { + Ok(dir) => Some(dir), + Err(e) => { + log::warn!("recovery is unavailable because this run cannot be isolated: {e}"); + None + } + } } fn recovery_dir_with(root: Option<OsString>, default: impl FnOnce() -> Option<PathBuf>) -> Option<PathBuf> { @@ -68,4 +80,29 @@ mod tests { assert_eq!(config_dir_with("linux", |key| if key == "HOME" { environment(key) } else { None }), Some("/user/.config/designcraft".into())); assert_eq!(recovery_dir_with(None, || Some("/existing/recovery".into())), Some("/existing/recovery".into())); } + + #[test] + fn a_run_has_its_own_recovery_folder_when_one_can_be_made() { + let dir = std::env::temp_dir().join(format!("designcraft-profile-run-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let root = dir.join("Recovery"); + assert_eq!(own_run(root.clone()).as_deref().and_then(|dir| dir.parent()), Some(root.as_path())); + // Failure must not fall back to the shared root. + let blocked = dir.join("blocked"); + std::fs::write(&blocked, "a file where the folder should be").unwrap(); + assert_eq!(own_run(blocked.join("Recovery")), None, "isolation failure must not select the shared root"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn an_exhausted_run_namespace_never_falls_back_to_shared_recovery() { + let root = std::env::temp_dir().join(format!("designcraft-profile-exhausted-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + for n in 0..64 { + std::fs::create_dir(root.join(format!("run-{}-{n}", std::process::id()))).unwrap(); + } + assert_eq!(own_run(root.clone()), None, "a shared root would restore inter-process UID collisions"); + let _ = std::fs::remove_dir_all(&root); + } } diff --git a/crates/engine/src/recovery.rs b/crates/engine/src/recovery.rs index a038c2d27..2ab67ce44 100644 --- a/crates/engine/src/recovery.rs +++ b/crates/engine/src/recovery.rs @@ -6,6 +6,11 @@ //! A recovery entry is `<uid>.designcraft` (the document) plus `<uid>.json` (`{"path", "title", //! "saved"}`: the original file, the title, and when it was written). //! +//! Document uids start at 1 in every process, so each running app keeps its entries in a folder +//! of its own in the recovery folder (`run_dir`) and holds the lock in it for as long as it runs. +//! An app that starts moves into its own folder what apps that have ended left in theirs, and +//! leaves the folder of one still running alone. +//! //! Each file is replaced atomically, but the pair is not a transaction. The document is published //! and made durable before its metadata so a crash can leave metadata missing or stale, never a //! listed metadata record without a complete primary document. Listing and opening recovery data @@ -31,6 +36,295 @@ pub fn default_dir() -> Option<PathBuf> { std::env::var_os("XDG_DATA_HOME").map(PathBuf::from).or_else(|| home.map(|h| h.join(".local/share"))).map(|d| d.join("designcraft/recovery")) } +/// This process's folder in a recovery folder, with the lock that says it is still running. +#[cfg(not(target_arch = "wasm32"))] +struct Run { + root: PathBuf, + dir: PathBuf, + lock: std::fs::File, + #[cfg(unix)] + directory: std::fs::File, +} + +#[cfg(not(target_arch = "wasm32"))] +static RUNS: std::sync::Mutex<Vec<Run>> = std::sync::Mutex::new(Vec::new()); + +/// The lock in a run's folder. +#[cfg(not(target_arch = "wasm32"))] +const LOCK: &str = "lock"; + +/// This run's own folder in the recovery folder `root`, made on the first call. Document uids +/// start at 1 in every process, so two running apps writing to one folder wrote over each +/// other's entries. The folder's lock is held until the process ends: an app that starts leaves +/// the folder of one that is still running alone. +#[cfg(not(target_arch = "wasm32"))] +pub fn run_dir(root: &Path) -> Result<PathBuf> { + let mut runs = RUNS.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(run) = runs.iter().find(|r| r.root == root) { + return Ok(run.dir.clone()); + } + secure_recovery_dir(root)?; + for n in 0..64 { + let dir = root.join(format!("run-{}-{n}", std::process::id())); + // Only a folder this run makes is its own. One that is there is another app's, or was + // left by a run that ended under this process id. + #[cfg(unix)] + let made = { + use std::os::unix::fs::DirBuilderExt; + + let mut builder = std::fs::DirBuilder::new(); + builder.mode(0o700).create(&dir) + }; + #[cfg(not(unix))] + let made = std::fs::create_dir(&dir); + match made { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(EngineError::Other(format!("{}: {e}", dir.display()))), + } + let locked = validate_recovery_dir(&dir).and_then(|()| { + let lock = new_lock(&dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; + match lock.try_lock() { + Ok(()) => { + validate_lock(&dir, &lock)?; + Ok(Some(lock)) + } + Err(std::fs::TryLockError::WouldBlock) => Ok(None), + Err(std::fs::TryLockError::Error(e)) => Err(EngineError::Other(format!("{}: {e}", dir.display()))), + } + }); + match locked { + // An app that is starting can take a folder this new for one a run left, and remove + // it before the lock is held: then the lock is gone, or that app holds it. + Ok(Some(lock)) => { + #[cfg(unix)] + let directory = std::fs::File::open(&dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; + runs.push(Run { + root: root.to_path_buf(), + dir: dir.clone(), + lock, + #[cfg(unix)] + directory, + }); + return Ok(dir); + } + Ok(_) => {} + Err(e) => { + // Never unlink a lock that another starting run may now own. + let _ = std::fs::remove_dir(&dir); + return Err(e); + } + } + } + Err(EngineError::Other(format!("{}: no free folder for this run", root.display()))) +} + +#[cfg(not(target_arch = "wasm32"))] +fn new_lock(dir: &Path) -> std::io::Result<std::fs::File> { + let mut new = std::fs::OpenOptions::new(); + new.read(true).write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + new.mode(0o600); + } + let mut file = new.open(dir.join(LOCK))?; + // A marker distinguishes the held handle from a replacement at the same pathname on + // platforms without portable file-identity metadata. Empty older-version locks remain valid. + static NEXT_LOCK: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_err(std::io::Error::other)?.as_nanos(); + let marker = format!("{}:{}:{now}", std::process::id(), NEXT_LOCK.fetch_add(1, std::sync::atomic::Ordering::Relaxed)); + file.write_all(marker.as_bytes())?; + file.sync_all()?; + Ok(file) +} + +/// Check the current lock name without accepting a symlink or a lock held through an unlinked +/// handle. An advisory lock on an old handle does not protect a replacement at the same name. +#[cfg(not(target_arch = "wasm32"))] +fn validate_lock(dir: &Path, lock: &std::fs::File) -> Result<()> { + let path = dir.join(LOCK); + let fail = || EngineError::Other(format!("{}: this run no longer owns its recovery lock", path.display())); + let before = std::fs::symlink_metadata(&path).map_err(|_| fail())?; + if is_link_like(&before) || !before.file_type().is_file() { + return Err(fail()); + } + let named = std::fs::File::open(&path).map_err(|_| fail())?; + let held = lock.metadata().map_err(|_| fail())?; + let current = named.metadata().map_err(|_| fail())?; + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if held.dev() != before.dev() || held.ino() != before.ino() || held.dev() != current.dev() || held.ino() != current.ino() { + return Err(fail()); + } + } + #[cfg(not(unix))] + let _ = (held, current); + if lock_marker(lock).map_err(|_| fail())? != lock_marker(&named).map_err(|_| fail())? { + return Err(fail()); + } + let after = std::fs::symlink_metadata(&path).map_err(|_| fail())?; + if is_link_like(&after) || !after.file_type().is_file() { + return Err(fail()); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if held.dev() != after.dev() || held.ino() != after.ino() { + return Err(fail()); + } + } + Ok(()) +} + +#[cfg(not(target_arch = "wasm32"))] +fn lock_marker(file: &std::fs::File) -> std::io::Result<Vec<u8>> { + use std::io::{Read, Seek}; + let mut file = file.try_clone()?; + file.seek(std::io::SeekFrom::Start(0))?; + let mut bytes = Vec::new(); + file.take(129).read_to_end(&mut bytes)?; + if bytes.len() > 128 { + return Err(std::io::Error::other("invalid recovery lock marker")); + } + Ok(bytes) +} + +/// A run's folder that was removed while it runs is made again by the next save: so is its lock. +#[cfg(not(target_arch = "wasm32"))] +fn relock(dir: &Path, created: bool) -> Result<()> { + let mut runs = RUNS.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let Some(run) = runs.iter_mut().find(|r| r.dir == dir) else { return Ok(()) }; + validate_recovery_dir(&run.root)?; + #[cfg(unix)] + let directory = std::fs::symlink_metadata(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; + // Reclaim a newly recreated folder only when this operation actually created it. Existing + // replacements, including ones without a lock, may contain somebody else's documents. + let same_directory = { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let held = run.directory.metadata().map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; + held.dev() == directory.dev() && held.ino() == directory.ino() + } + #[cfg(not(unix))] + { + // Without stable directory identity, a valid held lock is the proof of ownership. + // A missing lock cannot authorize an existing folder, even on coarse-timestamp disks. + validate_lock(dir, &run.lock).is_ok() + } + }; + if !created && !same_directory { + return Err(EngineError::Other(format!("{}: this run no longer owns its recovery folder", dir.display()))); + } + match std::fs::symlink_metadata(dir.join(LOCK)) { + Ok(_) => return validate_lock(dir, &run.lock), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(EngineError::Other(format!("{}: {e}", dir.display()))), + } + let lock = new_lock(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; + lock.try_lock().map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; + validate_lock(dir, &lock)?; + #[cfg(unix)] + let replacement_directory = std::fs::File::open(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let held = replacement_directory.metadata().map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; + if held.dev() != directory.dev() || held.ino() != directory.ino() { + return Err(EngineError::Other(format!("{}: recovery folder changed while restoring its lock", dir.display()))); + } + } + validate_lock(dir, &lock)?; + run.lock = lock; + #[cfg(unix)] + { + run.directory = replacement_directory; + } + Ok(()) +} + +/// Move into `own`, this run's folder, what is left to recover beside it: the entries of runs +/// that have ended, and those written straight into the recovery folder before runs had folders. +#[cfg(not(target_arch = "wasm32"))] +fn take_over(own: &Path) { + let root = { + let runs = RUNS.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let Some(run) = runs.iter().find(|r| r.dir == own) else { return }; + run.root.clone() + }; + move_entries(&root, own); + let Ok(rd) = std::fs::read_dir(&root) else { return }; + for e in rd.flatten() { + let dir = e.path(); + if dir == own || !e.file_name().to_string_lossy().starts_with("run-") || validate_recovery_dir(&dir).is_err() { + continue; + } + // Opened for writing: over NFS an exclusive lock on a file opened for reading is refused. + let Ok(metadata) = std::fs::symlink_metadata(dir.join(LOCK)) else { continue }; + if is_link_like(&metadata) || !metadata.file_type().is_file() { + continue; + } + let Ok(lock) = std::fs::OpenOptions::new().read(true).write(true).open(dir.join(LOCK)) else { continue }; + match lock.try_lock() { + // Free: the run has ended. Holding it keeps another starting app out. + Ok(()) => {} + Err(std::fs::TryLockError::WouldBlock) => continue, + Err(std::fs::TryLockError::Error(e)) => { + log::warn!("recovery data in {} is left there: {e}", dir.display()); + continue; + } + } + if let Err(e) = validate_lock(&dir, &lock) { + log::warn!("recovery data in {} is left there: {e}", dir.display()); + continue; + } + move_entries(&dir, own); + remove_emptied(&dir); + } +} + +/// Remove the folder of a run that ended, with what the app left in it beside documents. A +/// document still in it, or a file the app did not write, keeps the folder as it is. +#[cfg(not(target_arch = "wasm32"))] +fn remove_emptied(dir: &Path) { + let Ok(rd) = std::fs::read_dir(dir) else { return }; + let mut left = Vec::new(); + for e in rd { + let Ok(e) = e else { return }; + let path = e.path(); + let name = e.file_name(); + let name = name.to_string_lossy(); + let numbered = path.file_stem().and_then(|s| s.to_str()).is_some_and(|s| s.parse::<u64>().is_ok()); + // Metadata whose document has gone, and a file a crash left half written. + if (numbered && name.ends_with(".json")) || (name.starts_with(".designcraft-recovery-") && name.ends_with(".tmp")) { + left.push(path); + } else if name != LOCK { + return; + } + } + for path in left { + let _ = std::fs::remove_file(path); + } + // The lock last: until then the folder is still that of a run that ended. + let _ = std::fs::remove_file(dir.join(LOCK)); + let _ = std::fs::remove_dir(dir); +} + +#[cfg(not(target_arch = "wasm32"))] +fn move_entries(from: &Path, own: &Path) { + for (uid, _) in list(from) { + let (doc, meta) = files(from, uid); + // Under a uid no document of this run has or gets. + let (own_doc, own_meta) = files(own, crate::NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed)); + // The document first: its metadata is optional. + if std::fs::rename(&doc, &own_doc).is_ok() { + let _ = std::fs::rename(&meta, &own_meta); + } + } +} + fn files(dir: &Path, uid: u64) -> (PathBuf, PathBuf) { (dir.join(format!("{uid}.designcraft")), dir.join(format!("{uid}.json"))) } @@ -38,7 +332,7 @@ fn files(dir: &Path, uid: u64) -> (PathBuf, PathBuf) { /// Remove a document's recovery entry (it was saved or closed). pub fn discard(dir: &Path, uid: u64) { #[cfg(not(target_arch = "wasm32"))] - if validate_recovery_dir(dir).is_err() { + if validate_recovery_dir(dir).is_err() || relock(dir, false).is_err() { return; } let (d, m) = files(dir, uid); @@ -49,7 +343,11 @@ pub fn discard(dir: &Path, uid: u64) { /// Write recovery entries for the dirty documents (and drop those of clean ones). Returns how /// many were written. pub fn save(s: &Session, dir: &Path) -> Result<usize> { - secure_recovery_dir(dir)?; + let created = secure_recovery_dir(dir)?; + #[cfg(not(target_arch = "wasm32"))] + relock(dir, created)?; + #[cfg(target_arch = "wasm32")] + let _ = created; let mut n = 0; for d in &s.docs { if !d.is_dirty() { @@ -68,35 +366,45 @@ pub fn save(s: &Session, dir: &Path) -> Result<usize> { } #[cfg(not(target_arch = "wasm32"))] -fn secure_recovery_dir(dir: &Path) -> Result<()> { +fn secure_recovery_dir(dir: &Path) -> Result<bool> { match std::fs::symlink_metadata(dir) { - Ok(_) => return validate_recovery_dir(dir), + Ok(_) => return validate_recovery_dir(dir).map(|()| false), Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => return Err(EngineError::Other(format!("{}: {e}", dir.display()))), } - // Set the mode while creating directories so existing caller-owned directories are never - // chmodded. DirBuilder applies this mode only to components it creates; the process umask may - // make it more restrictive. + // Create parent directories privately without changing existing caller-owned permissions. + // Create the leaf separately so a creation race can never be mistaken for our own folder. #[cfg(unix)] let create_result = { use std::os::unix::fs::DirBuilderExt; - - let mut builder = std::fs::DirBuilder::new(); - builder.recursive(true).mode(0o700).create(dir) + if let Some(parent) = dir.parent().filter(|p| !p.as_os_str().is_empty()) { + std::fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(parent) + .map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; + } + std::fs::DirBuilder::new().mode(0o700).create(dir) }; #[cfg(not(unix))] - let create_result = std::fs::create_dir_all(dir); - - create_result.map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; - - // Re-check after creation. This also fails closed if another process won a creation race with - // a symlink or, on Windows, any reparse-point leaf. - validate_recovery_dir(dir) + let create_result = { + if let Some(parent) = dir.parent().filter(|p| !p.as_os_str().is_empty()) { + std::fs::create_dir_all(parent).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; + } + std::fs::create_dir(dir) + }; + let created = match create_result { + Ok(()) => true, + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => false, + Err(e) => return Err(EngineError::Other(format!("{}: {e}", dir.display()))), + }; + validate_recovery_dir(dir)?; + Ok(created) } #[cfg(target_arch = "wasm32")] -fn secure_recovery_dir(_dir: &Path) -> Result<()> { +fn secure_recovery_dir(_dir: &Path) -> Result<bool> { Err(EngineError::Other("recovery files are unavailable on the web".into())) } @@ -185,7 +493,7 @@ fn write_recovery_file(_path: &Path, _bytes: &[u8]) -> Result<()> { pub fn list(dir: &Path) -> Vec<(u64, Value)> { let mut out = Vec::new(); #[cfg(not(target_arch = "wasm32"))] - if validate_recovery_dir(dir).is_err() { + if validate_recovery_dir(dir).is_err() || relock(dir, false).is_err() { return out; } let Ok(rd) = std::fs::read_dir(dir) else { return out }; @@ -211,8 +519,14 @@ pub fn list(dir: &Path) -> Vec<(u64, Value)> { /// writes its own). Returns the opened documents' indices. pub fn open(s: &mut Session, dir: &Path) -> Result<Vec<usize>> { #[cfg(not(target_arch = "wasm32"))] - validate_recovery_dir(dir)?; + { + validate_recovery_dir(dir)?; + relock(dir, false)?; + } let mut opened = Vec::new(); + // For this run's own folder, first bring in what runs that ended left in theirs. + #[cfg(not(target_arch = "wasm32"))] + take_over(dir); for (uid, meta) in list(dir) { #[cfg(not(target_arch = "wasm32"))] validate_recovery_dir(dir)?; @@ -350,6 +664,293 @@ mod tests { assert!(open(&mut Session::new(), &link).is_err()); assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink()); } + + #[test] + fn a_run_has_a_locked_folder_of_its_own() { + let root = tempfile::tempdir().unwrap(); + let dir = root.path().join("recovery"); + let own = run_dir(&dir).unwrap(); + assert_eq!(own.parent(), Some(dir.as_path())); + assert_eq!(run_dir(&dir).unwrap(), own, "one for the whole run"); + let lock = std::fs::File::open(own.join(LOCK)).unwrap(); + assert!(matches!(lock.try_lock(), Err(std::fs::TryLockError::WouldBlock)), "held while the run lives"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!(std::fs::metadata(&own).unwrap().permissions().mode() & 0o777, 0o700); + assert_eq!(std::fs::metadata(own.join(LOCK)).unwrap().permissions().mode() & 0o777, 0o600); + } + } + + /// The recovery folder was cleared while the app runs: the next save makes the run's folder + /// again, and its lock with it. + #[cfg(unix)] + #[test] + fn a_removed_run_folder_is_made_again_with_its_lock() { + let root = tempfile::tempdir().unwrap(); + let own = run_dir(root.path()).unwrap(); + std::fs::remove_dir_all(&own).unwrap(); + assert_eq!(save(&dirty_session(), &own).unwrap(), 1); + let lock = std::fs::File::open(own.join(LOCK)).unwrap(); + assert!(matches!(lock.try_lock(), Err(std::fs::TryLockError::WouldBlock))); + } + + /// Replacing the name of a live run's lock must not authorize writes under the old handle. + #[cfg(unix)] + #[test] + fn save_rejects_a_run_whose_lock_was_replaced() { + let root = tempfile::tempdir().unwrap(); + let own = run_dir(root.path()).unwrap(); + std::fs::remove_file(own.join(LOCK)).unwrap(); + let replacement = new_lock(&own).unwrap(); + replacement.try_lock().unwrap(); + assert!(save(&dirty_session(), &own).is_err()); + assert!(list(&own).is_empty(), "another owner's folder must not receive our recovery data"); + } + + /// A blocked attempt to recreate a run's lock must stop the save before it writes documents. + #[cfg(unix)] + #[test] + fn save_rejects_a_run_whose_lock_cannot_be_recreated() { + let root = tempfile::tempdir().unwrap(); + let own = run_dir(root.path()).unwrap(); + std::fs::remove_file(own.join(LOCK)).unwrap(); + std::fs::create_dir(own.join(LOCK)).unwrap(); + assert!(save(&dirty_session(), &own).is_err()); + assert!(list(&own).is_empty()); + } + + #[cfg(unix)] + #[test] + fn discarding_a_document_does_not_touch_a_replaced_runs_entries() { + let root = tempfile::tempdir().unwrap(); + let own = run_dir(root.path()).unwrap(); + std::fs::remove_file(own.join(LOCK)).unwrap(); + let replacement = new_lock(&own).unwrap(); + replacement.try_lock().unwrap(); + let (doc, meta) = files(&own, 1); + std::fs::write(&doc, b"the replacement owner's document").unwrap(); + std::fs::write(&meta, b"{}").unwrap(); + discard(&own, 1); + assert_eq!(std::fs::read(doc).unwrap(), b"the replacement owner's document"); + assert!(meta.exists()); + } + + #[cfg(unix)] + #[test] + fn saving_a_run_rejects_a_symlinked_lock() { + let root = tempfile::tempdir().unwrap(); + let own = run_dir(root.path()).unwrap(); + std::fs::remove_file(own.join(LOCK)).unwrap(); + let unrelated = root.path().join("unrelated"); + std::fs::write(&unrelated, b"unrelated file").unwrap(); + std::os::unix::fs::symlink(&unrelated, own.join(LOCK)).unwrap(); + assert!(save(&dirty_session(), &own).is_err()); + assert_eq!(std::fs::read(unrelated).unwrap(), b"unrelated file"); + } + + #[cfg(unix)] + #[test] + fn a_replaced_run_directory_is_not_claimed_or_overwritten() { + let root = tempfile::tempdir().unwrap(); + let own = run_dir(root.path()).unwrap(); + let session = dirty_session(); + let uid = session.docs[0].uid; + std::fs::remove_dir_all(&own).unwrap(); + std::fs::create_dir(&own).unwrap(); + let (doc, _) = files(&own, uid); + std::fs::write(&doc, b"another owner's document").unwrap(); + assert!(save(&session, &own).is_err()); + assert_eq!(std::fs::read(doc).unwrap(), b"another owner's document"); + assert!(!own.join(LOCK).exists()); + } + + #[cfg(unix)] + #[test] + fn a_run_gets_no_folder_in_a_symlinked_recovery_folder() { + use std::os::unix::fs::symlink; + + let root = tempfile::tempdir().unwrap(); + let destination = root.path().join("destination"); + let link = root.path().join("recovery"); + std::fs::create_dir(&destination).unwrap(); + symlink(&destination, &link).unwrap(); + + assert!(run_dir(&link).is_err()); + assert!(std::fs::read_dir(&destination).unwrap().next().is_none()); + } + + fn unsaved(text: &str) -> Session { + let mut session = Session::new(); + session.execute("file.new", &json!({})).unwrap(); + session.execute("frame.create", &json!({"rect": [10, 10, 100, 100], "content": "text", "text": text})).unwrap(); + session + } + + fn texts(s: &Session) -> Vec<String> { + let mut texts: Vec<String> = s.documents().iter().flat_map(|d| d.doc.stories.values().map(|st| st.text.clone())).collect(); + texts.sort(); + texts + } + + /// What a run that has ended leaves: its folder with its lock, free now, and the entry of + /// one unsaved document, numbered `uid` as that process numbered it. + fn run_left(root: &Path, name: &str, text: &str, uid: u64) -> PathBuf { + let dir = root.join(name); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(LOCK), b"").unwrap(); + let mut s = unsaved(text); + s.docs[0].uid = uid; + assert_eq!(save(&s, &dir).unwrap(), 1); + dir + } + + /// Every process numbers its first document 1: two running apps both wrote `1.designcraft` + /// into the one recovery folder, and only the document written last was recovered. + #[test] + fn documents_of_two_apps_that_ended_are_both_recovered() { + let root = tempfile::tempdir().unwrap(); + let first = run_left(root.path(), "run-7-0", "first app", 1); + let second = run_left(root.path(), "run-8-0", "second app", 1); + let own = run_dir(root.path()).unwrap(); + let mut s = Session::new(); + assert_eq!(open(&mut s, &own).unwrap().len(), 2); + assert_eq!(texts(&s), ["first app", "second app"]); + assert!(s.documents().iter().all(DocState::is_dirty)); + assert!(!first.exists() && !second.exists(), "nothing is left of the runs that ended"); + } + + /// An app that started reopened the unsaved documents of one still running, and removed + /// its entries. + #[test] + fn a_running_apps_recovery_data_is_left_alone() { + let root = tempfile::tempdir().unwrap(); + let running = run_left(root.path(), "run-7-0", "still open", 1); + let lock = std::fs::File::open(running.join(LOCK)).unwrap(); + lock.try_lock().unwrap(); + let own = run_dir(root.path()).unwrap(); + let mut s = Session::new(); + assert!(open(&mut s, &own).unwrap().is_empty()); + assert_eq!(list(&running).len(), 1); + // Once it has ended, its document is recovered. + drop(lock); + assert_eq!(open(&mut s, &own).unwrap().len(), 1); + assert_eq!(texts(&s), ["still open"]); + assert!(!running.exists()); + } + + /// A process id comes round again: the folder a run left under it is not this run's. + #[test] + fn a_folder_left_under_this_process_id_is_not_reused() { + let root = tempfile::tempdir().unwrap(); + let left = run_left(root.path(), &format!("run-{}-0", std::process::id()), "left behind", 1); + let own = run_dir(root.path()).unwrap(); + assert_ne!(own, left); + let mut s = Session::new(); + assert_eq!(open(&mut s, &own).unwrap().len(), 1); + assert_eq!(texts(&s), ["left behind"]); + } + + /// Entries written straight into the recovery folder, as before runs had folders. + #[test] + fn recovery_data_of_an_earlier_version_is_recovered() { + let root = tempfile::tempdir().unwrap(); + assert_eq!(save(&unsaved("earlier version"), root.path()).unwrap(), 1); + let own = run_dir(root.path()).unwrap(); + let mut s = Session::new(); + assert_eq!(open(&mut s, &own).unwrap().len(), 1); + assert_eq!(texts(&s), ["earlier version"]); + assert!(list(root.path()).is_empty()); + } + + /// An entry that can't be opened stays on disk, in this run's folder now. + #[test] + fn an_unreadable_entry_of_a_run_that_ended_is_kept() { + let root = tempfile::tempdir().unwrap(); + let left = run_left(root.path(), "run-7-0", "readable", 1); + std::fs::write(left.join("2.designcraft"), b"not a document").unwrap(); + let own = run_dir(root.path()).unwrap(); + let mut s = Session::new(); + assert_eq!(open(&mut s, &own).unwrap().len(), 1); + assert!(list(&own).iter().any(|e| std::fs::read(files(&own, e.0).0).unwrap() == b"not a document")); + assert!(!left.exists()); + } + + /// Only a folder named as a run's, with a lock that is free, is a run that ended. + #[test] + fn what_is_no_run_that_ended_is_passed_over() { + let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("run-1-0"), b"a file").unwrap(); + // An app that is starting has made its folder and not yet its lock. + let starting = root.path().join("run-2-0"); + std::fs::create_dir(&starting).unwrap(); + let other = run_left(root.path(), "backup", "not a run", 1); + #[cfg(unix)] + let (_elsewhere, linked) = { + let elsewhere = tempfile::tempdir().unwrap(); + let linked = run_left(elsewhere.path(), "run-3-0", "behind a link", 1); + std::os::unix::fs::symlink(&linked, root.path().join("run-3-0")).unwrap(); + (elsewhere, linked) + }; + let own = run_dir(root.path()).unwrap(); + let mut s = Session::new(); + assert!(open(&mut s, &own).unwrap().is_empty()); + assert!(starting.exists()); + assert_eq!(list(&other).len(), 1); + #[cfg(unix)] + assert!(list(&linked).len() == 1 && root.path().join("run-3-0").is_symlink()); + } + + /// The metadata goes with its document: a document taken over remembers its file. + #[test] + fn a_document_taken_over_remembers_its_file() { + let root = tempfile::tempdir().unwrap(); + let file = root.path().join("layout.designcraft"); + let recovery = root.path().join("recovery"); + let left = recovery.join("run-7-0"); + std::fs::create_dir_all(&left).unwrap(); + std::fs::write(left.join(LOCK), b"").unwrap(); + let mut a = unsaved("saved once"); + a.execute("file.saveAs", &json!({"path": file.to_string_lossy()})).unwrap(); + a.execute("frame.create", &json!({"rect": [10, 10, 100, 100]})).unwrap(); + assert_eq!(save(&a, &left).unwrap(), 1); + let own = run_dir(&recovery).unwrap(); + let mut s = Session::new(); + assert_eq!(open(&mut s, &own).unwrap().len(), 1); + assert_eq!(s.doc().unwrap().path.as_deref(), Some(file.to_string_lossy().as_ref())); + } + + /// The folder of a run that ended goes with what the app left in it. A file the app did not + /// write keeps the folder, and is not deleted. + #[test] + fn only_what_the_app_left_goes_with_the_folder_of_a_run_that_ended() { + let root = tempfile::tempdir().unwrap(); + let emptied = run_left(root.path(), "run-7-0", "moved", 1); + std::fs::write(emptied.join("9.json"), b"{}").unwrap(); + std::fs::write(emptied.join(".designcraft-recovery-abc.tmp"), b"half written").unwrap(); + let kept = run_left(root.path(), "run-8-0", "moved too", 1); + std::fs::write(kept.join("copy.designcraft"), b"a copy someone put here").unwrap(); + let own = run_dir(root.path()).unwrap(); + let mut s = Session::new(); + assert_eq!(open(&mut s, &own).unwrap().len(), 2); + assert!(!emptied.exists()); + assert_eq!(std::fs::read(kept.join("copy.designcraft")).unwrap(), b"a copy someone put here"); + assert!(kept.join(LOCK).exists(), "still the folder of a run that ended"); + } + + /// A folder that is there is another app's or was left behind, lock or no lock: a run's own + /// is one it made itself. + #[test] + fn a_folder_that_is_there_is_not_made_this_runs() { + let root = tempfile::tempdir().unwrap(); + let left = root.path().join(format!("run-{}-0", std::process::id())); + std::fs::create_dir(&left).unwrap(); + std::fs::write(left.join("1.designcraft"), b"left with no lock").unwrap(); + let own = run_dir(root.path()).unwrap(); + assert_ne!(own, left); + assert!(!left.join(LOCK).exists()); + assert_eq!(std::fs::read(left.join("1.designcraft")).unwrap(), b"left with no lock"); + } } #[cfg(test)] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6c033d700..97fb5d015 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -289,7 +289,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - 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. Failed writes before replacement retain the original; existing Unix permissions apply before staging document bytes. Desktop Services writes and CLI raster exports still need this routing. Parent-folder flushing is best effort, so this is not a power-loss durability guarantee. -- Crash recovery: unsaved documents are written to the recovery folder every 30 s and reopened (unsaved, remembering their files) after a crash; File › Revert and Save a Copy…; `file.recovery.*`, `file.revert`, `file.saveACopy`. +- Crash recovery: unsaved documents are written every 30 s into a locked folder owned by that running app and reopened (unsaved, remembering their files) after a crash. Startup takes entries from ended runs and leaves live runs alone; writes and cleanup validate current folder and lock ownership. Failed isolation disables recovery with a warning. Concurrent older versions still use unlocked root entries; mixed-version isolation remains open. File › Revert and Save a Copy…; `file.recovery.*`, `file.revert`, `file.saveACopy`. - Graphics backend chosen before the window exists (DirectX 12 on Windows, Vulkan on Linux, Metal on macOS, OpenGL as the fallback; `WGPU_BACKEND` overrides) with a start-up fallback: a backend whose start never showed a frame is skipped at the next start and the status bar says so (`gpu.json`). Found on an AMD hybrid-graphics laptop whose Vulkan driver faulted at the first present. - Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool). - Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`. From d020b4e85d89ddf120933107a24b23b33036c8a0 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:31:24 -0400 Subject: [PATCH 060/238] fix(ui): preserve documents when quitting is cancelled Prompt for unsaved documents on window close and application/menu quit. Defer discarding documents until all approvals are current, retaining every tab when a later decision cancels quitting. Refs #544. Cancelable macOS Dock Quit remains separate. Co-authored-by: aiongg <aiongtaigi@gmail.com> Co-authored-by: skylar taylor-barrick <hi@skylar.cc> --- ROADMAP.md | 3 +- apps/designcraft/src/native_menu.rs | 5 +- crates/ui-egui/src/control.rs | 2 + crates/ui-egui/src/dialogs.rs | 3 + crates/ui-egui/src/i18n.rs | 1 + crates/ui-egui/src/i18n/ar.rs | 1 + crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/i18n/ja.rs | 1 + crates/ui-egui/src/i18n/pt_br.rs | 1 + crates/ui-egui/src/i18n/uk.rs | 1 + crates/ui-egui/src/lib.rs | 475 ++++++++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 5 + docs/control-protocol.md | 3 +- docs/target-app-parity.md | 1 + docs/ui-parity.md | 2 +- 15 files changed, 501 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 15c9af8de..4b7163c76 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **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 (isolated recovery for each running app) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (window and menu quit confirmation) · **Target:** Adobe InDesign 2026 DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change. - **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. diff --git a/apps/designcraft/src/native_menu.rs b/apps/designcraft/src/native_menu.rs index 8bf75b159..f51db197a 100644 --- a/apps/designcraft/src/native_menu.rs +++ b/apps/designcraft/src/native_menu.rs @@ -40,6 +40,8 @@ impl NativeMenu { // Our own About splash (with the community links) instead of the stock panel. let about = MenuItem::with_id("dc-about", "About DesignCraft", true, None); let discord = MenuItem::with_id("dc-discord", "Join the ArtCraft Discord…", true, None); + // Not the predefined Quit: it terminates the app at once, past the unsaved-changes prompt. + let quit = MenuItem::with_id("dc-quit", designcraft_ui_egui::i18n::tr(&app.ui.language, "Quit DesignCraft"), true, accel("Cmd+Q")); let _ = app_menu.append_items(&[ &about, &discord, @@ -50,10 +52,11 @@ impl NativeMenu { &PredefinedMenuItem::hide_others(None), &PredefinedMenuItem::show_all(None), &PredefinedMenuItem::separator(), - &PredefinedMenuItem::quit(None), + &quit, ]); items.insert("dc-about".into(), ("help.about".into(), serde_json::Value::Null, Handle::Plain(about))); items.insert("dc-discord".into(), ("help.discord".into(), serde_json::Value::Null, Handle::Plain(discord))); + items.insert("dc-quit".into(), ("app.quit".into(), serde_json::Value::Null, Handle::Plain(quit))); let _ = menu.append(&app_menu); for (title, entries) in menus::menu_tree() { let sub = Submenu::new(designcraft_ui_egui::i18n::tr(&app.ui.language, title), true); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index f0d34a7ba..12df640da 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -353,6 +353,8 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> "app.save" => wrap(app.run("file.save", json!({"path": s("path")}))), "app.export" => wrap(app.run("app.exportPng", p.clone())), "app.quit" => { + // Automation must not wait on a prompt: quit without asking about unsaved changes. + app.quit_without_asking = true; ctx.send_viewport_cmd(egui::ViewportCommand::Close); ok(Value::Null) } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2ab7a3bad..14d2552e8 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2078,6 +2078,9 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> { "closeDocument" => { // The document may have moved in the tab row (or gone) while the dialog was open. let uid = d.fields.get("uid").and_then(Value::as_u64); + if d.b("quitting") { + return app.confirm_quit_document(uid.ok_or("Missing document in quit confirmation")?, d.b("discard")); + } let index = |app: &DesignApp| app.session.documents().iter().position(|doc| Some(doc.uid) == uid); let Some(i) = index(app) else { return Ok(Value::Null) }; if !d.b("discard") { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index e5e8a8d08..5976d7d07 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -699,6 +699,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ), ("for", ["for", "pour", "por", "に対して", "行"]), ("About DesignCraft", ["About DesignCraft", "About DesignCraft", "About DesignCraft", "About DesignCraft", "关于 DesignCraft"]), + ("Quit DesignCraft", ["DesignCraft beenden", "Quitter DesignCraft", "Salir de DesignCraft", "DesignCraft を終了", "退出 DesignCraft"]), ("Accept All Changes", ["Accept All Changes", "Accept All Changes", "Accept All Changes", "Accept All Changes", "接受所有更改"]), ("Activate Document", ["Activate Document", "Activate Document", "Activate Document", "Activate Document", "激活文档"]), ("Add Anchor Point", ["Add Anchor Point", "Add Anchor Point", "Add Anchor Point", "Add Anchor Point", "添加锚点"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 10e393cd2..c3373486e 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -149,6 +149,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Bookmarks", "إشارات مرجعية"), ("Show All Menu Items", "إظهار كل عناصر القائمة"), ("About DesignCraft", "حول DesignCraft"), + ("Quit DesignCraft", "إنهاء DesignCraft"), ("Welcome to DesignCraft", "مرحباً بك في DesignCraft"), ("Page layout for print and screen — fast, open and scriptable.", "تخطيط الصفحات للطباعة والشاشة — سريع ومفتوح وقابل للبرمجة."), (" New file ", " ملف جديد "), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index c91afd6ba..34766cf22 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -396,6 +396,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Spreads of up to 10 pages", "Doppie pagine con al massimo 10 pagine"), ("for", "per"), ("About DesignCraft", "Informazioni su DesignCraft"), + ("Quit DesignCraft", "Esci da DesignCraft"), ("Accept All Changes", "Accetta tutte le modifiche"), ("Activate Document", "Attiva documento"), ("Add Anchor Point", "Aggiungi punto di ancoraggio"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index be6e2c4da..de57db5e9 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -398,6 +398,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Spreads of up to 10 pages", "最大10ページのスプレッド"), ("for", "に対して"), ("About DesignCraft", "DesignCraft について"), + ("Quit DesignCraft", "DesignCraft を終了"), ("Accept All Changes", "すべての変更を採用"), ("Activate Document", "ドキュメントをアクティブ化"), ("Add Anchor Point", "アンカーポイントを追加"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index d25717dc6..a4d06bbe9 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -149,6 +149,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Bookmarks", "Indicadores"), ("Show All Menu Items", "Mostrar todos os itens de menu"), ("About DesignCraft", "Sobre o DesignCraft"), + ("Quit DesignCraft", "Encerrar o DesignCraft"), ("Welcome to DesignCraft", "Bem-vindo ao DesignCraft"), ("Page layout for print and screen — fast, open and scriptable.", "Layout de página para impressão e tela — rápido, aberto e scriptável."), (" New file ", " Novo arquivo "), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 9a9fb89ba..9b70204a4 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -399,6 +399,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Spreads of up to 10 pages", "Розвороти до 10 сторінок"), ("for", "для"), ("About DesignCraft", "Про DesignCraft"), + ("Quit DesignCraft", "Завершити DesignCraft"), ("Accept All Changes", "Прийняти всі зміни"), ("Activate Document", "Активувати документ"), ("Add Anchor Point", "Додати опорну точку"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3d94bdb03..e0fa1dff0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -510,6 +510,18 @@ pub struct DesignApp { last_time: f64, /// When recovery data was last written (seconds, egui time). pub last_recovery: f64, + /// Quit was chosen: close the window on the next frame, as its close button does. + quit_requested: bool, + /// Close the window without asking about unsaved changes (the control channel's `app.quit`). + pub(crate) quit_without_asking: bool, + /// No document is discarded until every unsaved document has an up-to-date approval. + quitting: Option<QuitState>, +} + +struct QuitState { + original_active: Option<u64>, + accepted: HashMap<u64, u64>, + pending: Option<u64>, } impl DesignApp { @@ -547,6 +559,9 @@ impl DesignApp { native_shortcuts: Default::default(), last_time: 0.0, last_recovery: 0.0, + quit_requested: false, + quit_without_asking: false, + quitting: None, } } @@ -802,6 +817,146 @@ impl DesignApp { let _ = self.open_dropped(&p); } } + self.handle_quit(ctx); + } + + /// Quit: close the window the way its close button does, so unsaved documents ask first. + pub fn request_quit(&mut self) { + self.quit_requested = true; + } + + /// Ask about unsaved documents without closing any of them yet. A later Cancel must also + /// keep documents whose earlier answer was Don't Save, including their history and recovery. + fn handle_quit(&mut self, ctx: &egui::Context) { + if std::mem::take(&mut self.quit_requested) { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + if ctx.input(|i| i.viewport().close_requested()) { + if self.quit_without_asking { + self.quitting = None; + return; + } + if self.quitting.is_none() && !self.session.documents().iter().any(|d| d.is_dirty()) { + return; + } + let approved = + self.quitting.as_ref().is_some_and(|q| q.pending.is_none()) && self.ui.dialog.is_none() && self.next_unapproved_document().is_none(); + if approved { + // Commit only when the backend receives the final close, after checking for + // edits that arrived since the last answer. Closing also discards recovery data. + if let Err(e) = self.finish_confirmed_quit() { + self.status(e); + self.cancel_quit(); + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + } + return; + } + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + if self.quitting.is_none() { + self.quitting = Some(QuitState { original_active: self.session.active().map(|d| d.uid), accepted: HashMap::new(), pending: None }); + } + } + let Some(q) = self.quitting.as_ref() else { return }; + if let Some(uid) = q.pending { + let asking = self.ui.dialog.as_ref().is_some_and(|d| { + d.id == "closeDocument" + && d.fields.get("quitting").and_then(Value::as_bool) == Some(true) + && d.fields.get("uid").and_then(Value::as_u64) == Some(uid) + }); + if !asking { + self.cancel_quit(); + } + return; + } + if self.ui.dialog.is_some() { + self.cancel_quit(); + return; + } + self.ask_next_unsaved(ctx); + } + + /// Finalize a complete, still-current quit plan at an orderly host exit. The host can + /// terminate before egui receives its queued Close; an unfinished/stale plan leaves every + /// document live so the host can preserve its recovery data instead. + pub fn finish_confirmed_quit(&mut self) -> Result<(), String> { + let approved = + self.quitting.as_ref().is_some_and(|q| q.pending.is_none()) && self.ui.dialog.is_none() && self.next_unapproved_document().is_none(); + if !approved { + return Ok(()); + } + while !self.session.documents().is_empty() { + self.run("file.close", json!({"index": 0}))?; + } + self.quitting = None; + Ok(()) + } + + fn next_unapproved_document(&self) -> Option<(usize, u64)> { + let q = self.quitting.as_ref()?; + self.session + .documents() + .iter() + .enumerate() + .find(|(_, d)| d.is_dirty() && q.accepted.get(&d.uid) != Some(&d.revision)) + .map(|(i, d)| (i, d.uid)) + } + + fn cancel_quit(&mut self) { + let Some(q) = self.quitting.take() else { return }; + if let Some(i) = q.original_active.and_then(|uid| self.session.documents().iter().position(|d| d.uid == uid)) { + let _ = self.run("file.activate", json!({"index": i})); + } + } + + /// Save/Don't Save during Quit approves this UID's current revision, without destroying it. + pub(crate) fn confirm_quit_document(&mut self, uid: u64, discard: bool) -> Result<Value, String> { + if self.quitting.as_ref().and_then(|q| q.pending) != Some(uid) { + return Err("Quit confirmation is no longer active".into()); + } + let Some(i) = self.session.documents().iter().position(|d| d.uid == uid) else { + if let Some(q) = self.quitting.as_mut() { + q.pending = None; + } + return Ok(Value::Null); + }; + if !discard { + let saved = self.run("file.activate", json!({"index": i})).and_then(|_| self.run("app.save", json!({}))); + if let Err(e) = saved { + self.cancel_quit(); + return Err(e); + } + if self.session.documents().iter().find(|d| d.uid == uid).is_none_or(|d| d.is_dirty()) { + // No file chosen (or an asynchronous host save has not completed): stop quitting. + self.cancel_quit(); + return Ok(Value::Null); + } + } + if let Some(q) = self.quitting.as_mut() { + if let Some(d) = self.session.documents().iter().find(|d| d.uid == uid) { + q.accepted.insert(uid, d.revision); + } + q.pending = None; + } + Ok(Value::Null) + } + + /// Show the next unapproved document, or request a final close that revalidates all approvals. + fn ask_next_unsaved(&mut self, ctx: &egui::Context) { + let Some((i, uid)) = self.next_unapproved_document() else { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + return; + }; + if self.run("file.activate", json!({"index": i})).is_err() { + self.cancel_quit(); + return; + } + menus::close_document(self, Some(i)); + if let Some(d) = self.ui.dialog.as_mut() { + d.fields.insert("quitting".into(), json!(true)); + } + if let Some(q) = self.quitting.as_mut() { + q.pending = Some(uid); + } } /// A file dropped on the window: documents open, anything else is placed. @@ -1173,6 +1328,326 @@ mod tests { assert!(app.ui.dialog.is_none()); } + /// One frame of the quit handling, with the window asked to close when `close`; returns the + /// commands sent to the window. + fn quit_frame(app: &mut DesignApp, ctx: &egui::Context, close: bool) -> Vec<egui::ViewportCommand> { + let mut raw = egui::RawInput::default(); + if close { + raw.viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close); + } + let mut out = ctx.run_ui(raw, |ui| app.handle_quit(ui.ctx())); + out.textures_delta.clear(); + out.viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.commands.clone()).unwrap_or_default() + } + + fn dialog_uid(app: &DesignApp) -> Option<u64> { + app.ui.dialog.as_ref().filter(|d| d.id == "closeDocument").and_then(|d| d.fields.get("uid")?.as_u64()) + } + + /// Quitting closed the window at once, losing unsaved changes (#544). + #[test] + fn quitting_with_unsaved_changes_asks_and_cancel_keeps_everything() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + let cmds = quit_frame(&mut app, &ctx, true); + assert!(cmds.contains(&egui::ViewportCommand::CancelClose), "{cmds:?}"); + assert_eq!(dialog_uid(&app), Some(app.session.documents()[0].uid)); + // Cancel: the window and the document stay as they were. + app.ui.dialog = None; + for _ in 0..2 { + assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + } + assert_eq!(app.session.documents().len(), 1); + assert!(app.session.documents()[0].is_dirty()); + // A Save whose file picker is cancelled stops quitting too. + quit_frame(&mut app, &ctx, true); + dialogs::confirm(&mut app).unwrap(); + assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + assert!(app.session.documents()[0].is_dirty()); + } + + #[test] + fn quitting_asks_about_each_unsaved_document_then_closes() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [36, 36, 136, 136]})).unwrap(); + app.run("file.new", json!({})).unwrap(); + let uids: Vec<u64> = app.session.documents().iter().map(|d| d.uid).collect(); + assert!(quit_frame(&mut app, &ctx, true).contains(&egui::ViewportCommand::CancelClose)); + for (n, uid) in uids.iter().take(2).enumerate() { + assert_eq!(dialog_uid(&app), Some(*uid), "question {n}"); + assert_eq!(app.session.active().map(|d| d.uid), Some(*uid), "the document asked about is shown"); + app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true)); + dialogs::confirm(&mut app).unwrap(); + let cmds = quit_frame(&mut app, &ctx, false); + assert_eq!(cmds.contains(&egui::ViewportCommand::Close), n == 1, "{cmds:?}"); + } + // All documents stay open until the backend's final close commits the decision. + assert_eq!(app.session.documents().len(), 3); + assert!(quit_frame(&mut app, &ctx, true).is_empty()); + assert!(app.session.documents().is_empty()); + } + + /// A later Cancel must not destroy documents already approved for discard. + #[test] + fn quitting_later_cancel_retains_every_document_and_active_tab() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [20, 20, 120, 120]})).unwrap(); + let uids: Vec<u64> = app.session.documents().iter().map(|d| d.uid).collect(); + let active = app.session.active().unwrap().uid; + let revisions: Vec<u64> = app.session.documents().iter().map(|d| d.revision).collect(); + let docs: Vec<_> = app.session.documents().iter().map(|d| d.doc.clone()).collect(); + quit_frame(&mut app, &ctx, true); + app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true)); + dialogs::confirm(&mut app).unwrap(); + quit_frame(&mut app, &ctx, false); + assert_eq!(dialog_uid(&app), Some(uids[1])); + app.ui.dialog = None; + assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + assert_eq!(app.session.documents().iter().map(|d| d.uid).collect::<Vec<_>>(), uids); + assert_eq!(app.session.active().unwrap().uid, active); + assert_eq!(app.session.documents().iter().map(|d| d.revision).collect::<Vec<_>>(), revisions); + for (d, original) in app.session.documents().iter().zip(docs) { + assert!(std::sync::Arc::ptr_eq(&d.doc, &original)); + assert!(d.is_dirty()); + assert!(!d.history.undo.is_empty()); + } + } + + fn second_unsaved_document(app: &mut DesignApp) { + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [20, 20, 120, 120]})).unwrap(); + } + + fn discard_quit_dialog(app: &mut DesignApp) { + app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true)); + dialogs::confirm(app).unwrap(); + } + + #[test] + fn quitting_first_cancel_restores_original_clean_active_document() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + app.run("file.new", json!({})).unwrap(); + let original_active = app.session.active().unwrap().uid; + quit_frame(&mut app, &ctx, true); + assert_ne!(app.session.active().unwrap().uid, original_active); + app.ui.dialog = None; + assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + assert_eq!(app.session.active().unwrap().uid, original_active); + assert_eq!(app.session.documents().len(), 2); + } + + #[test] + fn quitting_save_as_cancel_after_discard_keeps_all_documents() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + second_unsaved_document(&mut app); + let active = app.session.active().unwrap().uid; + quit_frame(&mut app, &ctx, true); + discard_quit_dialog(&mut app); + quit_frame(&mut app, &ctx, false); + dialogs::confirm(&mut app).unwrap(); // No picker: Save As was cancelled. + assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + assert_eq!(app.session.documents().len(), 2); + assert!(app.session.documents().iter().all(|d| d.is_dirty())); + assert_eq!(app.session.active().unwrap().uid, active); + } + + #[test] + fn quitting_save_failure_after_discard_keeps_documents_and_can_retry() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + second_unsaved_document(&mut app); + let path = std::env::temp_dir() + .join(format!("designcraft-quit-missing-{}-{}", std::process::id(), app.session.active().unwrap().uid)) + .join("missing") + .join("document.designcraft"); + assert!(!path.parent().unwrap().exists()); + let picked = path.to_string_lossy().to_string(); + app.services.pick_save = Some(Box::new(move |_| Some(picked.clone()))); + quit_frame(&mut app, &ctx, true); + discard_quit_dialog(&mut app); + quit_frame(&mut app, &ctx, false); + assert!(dialogs::confirm(&mut app).is_err()); + assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + assert_eq!(app.session.documents().len(), 2); + assert!(app.session.documents().iter().all(|d| d.is_dirty())); + // Starting over must ask about the previously discarded first document again. + quit_frame(&mut app, &ctx, true); + assert_eq!(dialog_uid(&app), Some(app.session.documents()[0].uid)); + } + + #[test] + fn quitting_save_then_later_cancel_keeps_saved_document_open() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + second_unsaved_document(&mut app); + let first = app.session.documents()[0].uid; + let path = std::env::temp_dir().join(format!("designcraft-quit-save-{}-{first}.designcraft", std::process::id())); + let picked = path.to_string_lossy().to_string(); + app.services.pick_save = Some(Box::new(move |_| Some(picked.clone()))); + quit_frame(&mut app, &ctx, true); + dialogs::confirm(&mut app).unwrap(); + quit_frame(&mut app, &ctx, false); + assert_eq!(app.session.documents().len(), 2); + assert!(!app.session.documents()[0].is_dirty()); + assert!(path.exists()); + app.ui.dialog = None; + assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + assert_eq!(app.session.documents()[0].uid, first); + assert!(app.session.documents()[1].is_dirty()); + std::fs::remove_file(path).unwrap(); + } + + #[test] + fn quitting_rechecks_discard_approval_after_a_later_edit() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + second_unsaved_document(&mut app); + let first = app.session.documents()[0].uid; + quit_frame(&mut app, &ctx, true); + discard_quit_dialog(&mut app); + quit_frame(&mut app, &ctx, false); + app.run("file.activate", json!({"index": 0})).unwrap(); + app.run("frame.create", json!({"rect": [200, 200, 300, 300]})).unwrap(); + discard_quit_dialog(&mut app); // Approval is bound to the second UID, despite the switch. + assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + assert_eq!(dialog_uid(&app), Some(first), "new edit needs a fresh decision"); + assert_eq!(app.session.documents().len(), 2); + } + + #[test] + fn quitting_final_close_rechecks_new_unsaved_document() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + quit_frame(&mut app, &ctx, true); + discard_quit_dialog(&mut app); + assert!(quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + second_unsaved_document(&mut app); // Arrives after Close was queued, before its event. + let new_uid = app.session.active().unwrap().uid; + assert!(quit_frame(&mut app, &ctx, true).contains(&egui::ViewportCommand::CancelClose)); + assert_eq!(dialog_uid(&app), Some(new_uid)); + assert_eq!(app.session.documents().len(), 2); + } + + #[test] + fn quitting_pending_save_uses_dialog_uid_after_tab_switch() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + second_unsaved_document(&mut app); + let first = app.session.documents()[0].uid; + let path = std::env::temp_dir().join(format!("designcraft-quit-uid-{}-{first}.designcraft", std::process::id())); + let picked = path.to_string_lossy().to_string(); + app.services.pick_save = Some(Box::new(move |_| Some(picked.clone()))); + quit_frame(&mut app, &ctx, true); + app.run("file.activate", json!({"index": 1})).unwrap(); + dialogs::confirm(&mut app).unwrap(); + assert_eq!(app.session.documents()[0].path.as_deref(), path.to_str()); + assert!(!app.session.documents()[0].is_dirty()); + assert!(app.session.documents()[1].is_dirty()); + quit_frame(&mut app, &ctx, false); + assert_eq!(dialog_uid(&app), Some(app.session.documents()[1].uid)); + std::fs::remove_file(path).unwrap(); + } + + #[test] + fn quitting_discard_preserves_recovery_until_final_commit() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + let uid = app.session.active().unwrap().uid; + let dir = std::env::temp_dir().join(format!("designcraft-quit-recovery-{}-{uid}", std::process::id())); + app.session.recovery_dir = Some(dir.clone()); + app.run("file.recovery.save", json!({})).unwrap(); + let entry = dir.join(format!("{uid}.designcraft")); + quit_frame(&mut app, &ctx, true); + discard_quit_dialog(&mut app); + assert!(quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + assert!(entry.exists(), "a queued close is not a committed close"); + assert!(quit_frame(&mut app, &ctx, true).is_empty()); + assert!(!entry.exists(), "accepted Don't Save must not be recovered on restart"); + assert!(app.session.documents().is_empty()); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn quitting_native_exit_commits_completed_approvals_before_close_event() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + quit_frame(&mut app, &ctx, true); + discard_quit_dialog(&mut app); + assert!(quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + assert_eq!(app.session.documents().len(), 1); + app.finish_confirmed_quit().unwrap(); + assert!(app.session.documents().is_empty()); + } + + #[test] + fn quitting_native_exit_retains_incomplete_stale_and_new_document_plans() { + for case in ["incomplete", "stale", "new document"] { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + if case == "incomplete" { + second_unsaved_document(&mut app); + } + quit_frame(&mut app, &ctx, true); + discard_quit_dialog(&mut app); + quit_frame(&mut app, &ctx, false); + if case == "stale" { + app.run("frame.create", json!({"rect": [200, 200, 300, 300]})).unwrap(); + } else if case == "new document" { + second_unsaved_document(&mut app); + } + let active = app.session.active().unwrap().uid; + let before: Vec<_> = app + .session + .documents() + .iter() + .map(|d| (d.uid, d.revision, d.doc.clone(), d.selection.items.clone(), d.history.undo.len(), d.history.redo.len())) + .collect(); + app.finish_confirmed_quit().unwrap(); + assert_eq!(app.session.documents().len(), before.len(), "{case}"); + assert_eq!(app.session.active().unwrap().uid, active, "{case}"); + for (d, (uid, revision, doc, selection, undo, redo)) in app.session.documents().iter().zip(before) { + assert_eq!( + (d.uid, d.revision, &d.selection.items, d.history.undo.len(), d.history.redo.len()), + (uid, revision, &selection, undo, redo), + "{case}" + ); + assert!(std::sync::Arc::ptr_eq(&d.doc, &doc), "{case}"); + } + } + } + + #[test] + fn quitting_without_unsaved_changes_closes_at_once() { + let ctx = egui::Context::default(); + let mut app = DesignApp::new(designcraft_engine::Session::new(), Services::default()); + app.run("file.new", json!({})).unwrap(); + // Quit (⌘Q) closes the window like its close button. + app.run("app.quit", json!({})).unwrap(); + assert!(quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + assert!(quit_frame(&mut app, &ctx, true).is_empty()); + assert!(app.ui.dialog.is_none()); + } + + /// Scripts must not hang on a question: the control channel's quit never asks. + #[test] + fn control_channel_quit_does_not_ask() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + let (req, _rx) = control::ControlRequest::new("app.quit", json!({})); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + control::handle(&mut app, ui.ctx(), &req); + }); + out.textures_delta.clear(); + assert!(quit_frame(&mut app, &ctx, true).is_empty()); + assert!(app.ui.dialog.is_none()); + } + /// The Control panel's two-row groups were centred as if they were one row tall, so the second /// row hung below the panel and was cut off. #[test] diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d123ec4eb..cb1422394 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -11,6 +11,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("app.placeDialog", "Place…", Some("Cmd+D"), "{}"), ("app.saveDialog", "Save As…", Some("Cmd+Shift+S"), "{}"), ("app.save", "Save", Some("Cmd+S"), "{}"), + ("app.quit", "Quit DesignCraft", Some("Cmd+Q"), "{} — close the window; each document with unsaved changes asks first"), ("app.saveCopyDialog", "Save a Copy…", Some("Cmd+Alt+S"), "{} — choose where to write a copy (the document stays as it is)"), ("app.loadSwatches", "Load Swatches…", None, "{} — pick a swatch exchange (.ase) file"), ("app.packageDialog", "Package…", Some("Cmd+Alt+Shift+P"), "{} — choose the package folder to create (file.package)"), @@ -769,6 +770,10 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value, } Ok(Value::Null) } + "app.quit" => { + app.request_quit(); + Ok(Value::Null) + } "app.saveCopyDialog" => { let name = app.session.active().map(|d| format!("{} copy.designcraft", d.doc.title)).unwrap_or_default(); if let Some(path) = app.services.pick_save.as_mut().and_then(|f| f(&name)) { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index d97c2bad8..9e5145616 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -46,7 +46,8 @@ so only enable it while you use it. Transport: `apps/designcraft/src/control_ser | `ui.resize`, `ui.focus` | | Window control | | `ui.screenshot` | `{path?}` | PNG of the whole window | | `ui.render` | `{path?, page?, scale?, bleed?}` | Render a page headlessly (PNG; base64 if no path) | -| `app.open` / `app.save` / `app.export` / `app.quit` | | Files | +| `app.open` / `app.save` / `app.export` | | Files | +| `app.quit` | | Quit without asking about unsaved changes (the `app.quit` command, like ⌘Q, asks) | Data merge runs through `engine.execute` like any command: `data.source.select`, `data.fields`, `data.placeholder.add` / `.remove`, `data.options`, `data.preview` / `data.preview.stop`, `data.merge`. `data.merge` creates and activates a new diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 97fb5d015..5705ee738 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -290,6 +290,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - 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. Failed writes before replacement retain the original; existing Unix permissions apply before staging document bytes. Desktop Services writes and CLI raster exports still need this routing. Parent-folder flushing is best effort, so this is not a power-loss durability guarantee. - Crash recovery: unsaved documents are written every 30 s into a locked folder owned by that running app and reopened (unsaved, remembering their files) after a crash. Startup takes entries from ended runs and leaves live runs alone; writes and cleanup validate current folder and lock ownership. Failed isolation disables recovery with a warning. Concurrent older versions still use unlocked root entries; mixed-version isolation remains open. File › Revert and Save a Copy…; `file.recovery.*`, `file.revert`, `file.saveACopy`. +- Window close and application/menu Quit ask about every unsaved document. Cancel or a cancelled/failed save keeps all tabs open; Don't Save approvals discard nothing until the complete plan is current. Later edits and new documents are checked before closing. Control-channel quit remains immediate; a cancelable macOS Dock Quit prompt is still missing (#544). - Graphics backend chosen before the window exists (DirectX 12 on Windows, Vulkan on Linux, Metal on macOS, OpenGL as the fallback; `WGPU_BACKEND` overrides) with a start-up fallback: a backend whose start never showed a frame is skipped at the next start and the status bar says so (`gpu.json`). Found on an AMD hybrid-graphics laptop whose Vulkan driver faulted at the first present. - Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool). - Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index ee6db4232..8805defce 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -50,7 +50,7 @@ basics of editing fail for too many users on Windows and Linux. | Workspaces | Essentials, Advanced, Book, Digital Publishing, Interactive for PDF, Printing and Proofing, Typography, Touch | ✓ named workspaces + custom | ~ | tab bar overlaps menus in Book/Advanced (#182) | | Story Editor | ✓ | ✓ | ~ | style-name column, depth ruler missing | | Undo granularity (a drag = one step) | ✓ | ✓ (Begin/Preview/Commit) | ✓ | | -| Closing with unsaved changes asks | ✓ | ✓ (#146) | ✓ | | +| Closing with unsaved changes asks | ✓ | ✓ File › Close and window/menu quit (#146, #545) | ✓ | macOS Dock Quit still bypasses the prompt (#544) | | UI scaling, System appearance | ✓ | scaling ✓ (#281); follows macOS light/dark ✗ (#237) | ~ | | | Window controls, full screen | ✓ | ~ | ~ | misaligned on macOS (#333) | From d7d6f27069fe62a347fb67baab0dfe92c3933d7c Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Mon, 12 Oct 2026 10:48:39 +0200 Subject: [PATCH 061/238] feat(eyedropper): Sample a placed image's colour into the selection The Eyedropper on a placed image now does what InDesign does: with text selected, clicking the photo colours the text with the image's colour under the pointer; with objects selected, it fills them. The colour is an unnamed colour (reused when the same colour is sampled again). Images on locked layers can be sampled, and unfilled frames over the image let it show through. - color.sample {at, spread?, target?: fill|stroke, ids?} samples the frontmost visible raster image at a spread point; one undo step; a caret or a point with no image is refused without an edit. - object.color shares its swatch lookup with it. - The Eyedropper tool sends color.sample over an image, matchAttributes elsewhere as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 1 + crates/engine/src/cmd/style.rs | 261 +++++++++++++++++++++++++++---- crates/tools/src/tests.rs | 41 +++++ crates/tools/src/xform.rs | 41 ++++- crates/ui-egui/src/i18n.rs | 4 + crates/ui-egui/src/i18n/ar.rs | 1 + crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/i18n/ja.rs | 1 + crates/ui-egui/src/i18n/pt_br.rs | 1 + crates/ui-egui/src/i18n/uk.rs | 1 + 10 files changed, 320 insertions(+), 33 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4b7163c76..02437c82d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Newest first. - **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change. - **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change. +- **2026-10-12** · The Eyedropper samples a placed image: clicking the photo with text (or objects) selected fills them with the image's colour under the pointer, as an unnamed colour (`color.sample`), as in InDesign. Images on locked layers can be sampled. No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 4bf69ec41..65052d56a 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -184,6 +184,15 @@ pub fn specs() -> Vec<CommandSpec> { super::has_selection, apply_color ), + cmd!( + "color.sample", + "Sample Color (Eyedropper)", + [], + None, + "{at: [x,y] (spread coords), spread?, target?: fill|stroke, ids?} → {color: \"#rrggbb\", swatch, from: image id} — the Eyedropper on a placed image: the image's colour at the point (an unnamed colour) fills the selected text, else the selected objects", + has_text_or_selection, + sample_color + ), cmd!( "swatch.addToSwatches", "Add to Swatches", @@ -957,41 +966,149 @@ fn apply_color(s: &mut Session, p: &Value) -> Result<Value> { let stroke = str_param(p, "target") == Some("stroke"); let ids = super::targets(s, p)?; s.edit(|d, _| { - // Reuse a swatch holding exactly this colour, else add an unnamed one. - let existing = d - .swatches - .iter() - .find(|w| matches!(&w.value, SwatchValue::Color { color: c, color_type: designcraft_color::ColorType::Process } if *c == color)) - .map(|w| w.name.clone()); - let name = match existing { - Some(n) => n, - None => { - let name = Styles::unique_name(|n| d.swatch(n).is_some(), &value_name(color)); - d.swatches.push(Swatch { - name: name.clone(), - value: SwatchValue::Color { color, color_type: designcraft_color::ColorType::Process }, - locked: false, - named: false, - hidden: true, - }); - name - } - }; - for id in &ids { - if let Some(it) = d.item_mut(*id) { - if stroke { - it.stroke.swatch = name.clone(); - it.stroke.tint = 1.0; - } else { - it.fill.swatch = name.clone(); - it.fill.tint = 1.0; - } - } - } + let name = color_swatch(d, color); + color_items(d, &ids, &name, stroke); Ok(json!({"swatch": name})) }) } +/// A process swatch holding exactly `color`, else a new unnamed one (hidden from the Swatches +/// panel until Add to Swatches). +fn color_swatch(d: &mut designcraft_doc::Document, color: designcraft_color::Color) -> String { + let existing = d + .swatches + .iter() + .find(|w| matches!(&w.value, SwatchValue::Color { color: c, color_type: designcraft_color::ColorType::Process } if *c == color)) + .map(|w| w.name.clone()); + if let Some(n) = existing { + return n; + } + let name = Styles::unique_name(|n| d.swatch(n).is_some(), &value_name(color)); + d.swatches.push(Swatch { + name: name.clone(), + value: SwatchValue::Color { color, color_type: designcraft_color::ColorType::Process }, + locked: false, + named: false, + hidden: true, + }); + name +} + +fn color_items(d: &mut designcraft_doc::Document, ids: &[designcraft_doc::ItemId], name: &str, stroke: bool) { + for id in ids { + if let Some(it) = d.item_mut(*id) { + if stroke { + it.stroke.swatch = name.to_string(); + it.stroke.tint = 1.0; + } else { + it.fill.swatch = name.to_string(); + it.fill.tint = 1.0; + } + } + } +} + +fn has_text_or_selection(s: &Session) -> std::result::Result<(), String> { + has_doc(s)?; + if s.active().is_some_and(|d| d.selection.text.is_some() || d.selection.cells.is_some()) { Ok(()) } else { super::has_selection(s) } +} + +/// The Eyedropper on a placed image (InDesign samples the pixel under the pointer): that colour, +/// as an unnamed colour, fills the selected text (characters), or the selected objects. +fn sample_color(s: &mut Session, p: &Value) -> Result<Value> { + const ID: &str = "color.sample"; + let at = super::point_param(p, "at").ok_or_else(|| bad(ID, "missing at"))?; + let sr = super::spread_param(p, "spread"); + let stroke = str_param(p, "target") == Some("stroke"); + let named = p.get("ids").and_then(Value::as_array).is_some_and(|a| !a.is_empty()) || p.get("id").is_some(); + let st = s.doc()?; + let (from, color) = image_color_at(&st.doc, sr, at).ok_or_else(|| bad(ID, "no placed image under the point"))?; + let hex = color.to_hex(); + if named || (st.selection.text.is_none() && st.selection.cells.is_none()) { + let ids = super::targets(s, p)?; + return s.edit(|d, _| { + let name = color_swatch(d, color); + color_items(d, &ids, &name, stroke); + Ok(json!({"color": hex, "swatch": name, "from": from.0})) + }); + } + // A caret colours nothing (as with a swatch click): say so instead of an empty undo step. + let targets: Vec<_> = super::text::format_targets(s).into_iter().filter(|t| !t.range.is_empty()).collect(); + if targets.is_empty() { + return Err(bad(ID, "select the text to colour")); + } + s.edit(|d, _| { + let name = color_swatch(d, color); + for t in &targets { + let Some(story) = d.text_story_mut(t.story, t.cell) else { continue }; + story.format_chars(t.range.clone(), |f| { + if stroke { + f.over.stroke = Some(name.clone()); + f.over.stroke_tint = Some(1.0); + } else { + f.over.fill = Some(name.clone()); + f.over.fill_tint = Some(1.0); + } + }); + } + Ok(json!({"color": hex, "swatch": name, "from": from.0})) + }) +} + +/// The colour of the placed raster image at `at` on spread `sr`: the frontmost visible image whose +/// frame shows that point (locked ones too, as sampling changes nothing). (image frame, colour); +/// None over no image, a transparent pixel, or a graphic that is not a raster (PDF, EPS). +pub(crate) fn image_color_at( + d: &designcraft_doc::Document, + sr: designcraft_doc::SpreadRef, + at: designcraft_geom::Point, +) -> Option<(designcraft_doc::ItemId, designcraft_color::Color)> { + let sp = d.spread(sr)?; + let mut items: Vec<&designcraft_doc::Item> = sp.items.iter().map(|i| &**i).collect(); + items.sort_by_key(|i| d.layer_rank(i.layer)); + items + .into_iter() + .rev() + .filter(|it| d.layer(it.layer).is_none_or(|l| l.visible)) + .find_map(|it| sample_item(d, it, designcraft_geom::Affine::IDENTITY, at, 0)) +} + +fn sample_item( + d: &designcraft_doc::Document, + it: &designcraft_doc::Item, + outer: designcraft_geom::Affine, + at: designcraft_geom::Point, + depth: usize, +) -> Option<(designcraft_doc::ItemId, designcraft_color::Color)> { + use designcraft_doc::Content; + if it.hidden || depth > 64 { + return None; + } + let xf = outer * it.xf; + match &it.content { + Content::Group { items } => items.iter().rev().find_map(|c| sample_item(d, c, xf, at, depth + 1)), + Content::Graphic(g) => { + let inner = xf.inverse() * at; + if !designcraft_geom::hit::fill_contains(&it.path.to_bezpath(), designcraft_geom::FillRule::NonZero, inner) { + return None; + } + let gp = g.xf.inverse() * inner; + let (w, h) = g.size; + // Written so that NaN (a degenerate transform) is outside too. + if !(w > 0.0 && h > 0.0 && gp.x >= 0.0 && gp.y >= 0.0 && gp.x < w && gp.y < h) { + return None; + } + let img = designcraft_images::decode_rgba(&d.assets.get(&g.asset)?.data)?; + let (pw, ph) = img.dimensions(); + let px = ((gp.x / w * f64::from(pw)) as u32).min(pw.checked_sub(1)?); + let py = ((gp.y / h * f64::from(ph)) as u32).min(ph.checked_sub(1)?); + let [r, gr, b, a] = img.get_pixel_checked(px, py)?.0; + (a > 0).then(|| (it.id, designcraft_color::Color::rgb8(r, gr, b))) + } + _ => None, + } +} + fn add_to_swatches(s: &mut Session, p: &Value) -> Result<Value> { let st = s.doc()?; let name = match str_param(p, "swatch") { @@ -1493,3 +1610,85 @@ mod cjk_tests { assert!(s.execute("style.compositeFont.set", &definition(-1.0)).is_err()); } } + +#[cfg(test)] +mod sample_color_tests { + use designcraft_color::{Color, SwatchValue}; + use designcraft_doc::ItemId; + use serde_json::json; + + use crate::Session; + + /// A 2×1 image, red then blue, placed 144 pt wide at (72, 72): each pixel is 72 pt. + fn session_with_image() -> (Session, u64) { + let png = designcraft_render::Rendered { width: 2, height: 1, pixels: vec![255, 0, 0, 255, 0, 0, 255, 255] }.to_png(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let b64 = super::super::file::base64_encode(&png); + let r = s.execute("file.place", &json!({"base64": b64, "name": "rb.png", "x": 72, "y": 72, "width": 144})).unwrap(); + (s, r["id"].as_u64().unwrap()) + } + + fn swatch_color(s: &Session, name: &str) -> Color { + match &s.doc().unwrap().doc.swatch(name).unwrap().value { + SwatchValue::Color { color, .. } => *color, + other => panic!("{other:?}"), + } + } + + #[test] + fn the_eyedropper_colours_selected_text_with_the_image_under_it() { + let (mut s, image) = session_with_image(); + let f = s.execute("frame.create", &json!({"rect": [300, 300, 500, 360], "content": "text", "text": "Elevate."})).unwrap(); + let fid = ItemId(f["id"].as_u64().unwrap()); + let story = s.doc().unwrap().doc.item(fid).unwrap().text_frame().unwrap().story; + s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 7})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute("color.sample", &json!({"at": [180, 100]})).unwrap(); + assert_eq!((r["color"].as_str(), r["from"].as_u64()), (Some("#0000ff"), Some(image)), "{r}"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + let name = r["swatch"].as_str().unwrap().to_string(); + assert_eq!(swatch_color(&s, &name), Color::rgb8(0, 0, 255)); + assert!(s.doc().unwrap().doc.swatch(&name).unwrap().hidden, "an unnamed colour"); + let st = s.doc().unwrap().doc.story(story).unwrap().clone(); + assert_eq!(st.format_after(0).over.fill.as_deref(), Some(name.as_str())); + assert_eq!(st.format_after(7).over.fill, None, "the full stop keeps its colour"); + // The frame itself is not coloured. + assert!(s.doc().unwrap().doc.item(fid).unwrap().fill.is_none()); + } + + #[test] + fn the_eyedropper_fills_selected_objects_and_samples_locked_images() { + let (mut s, _) = session_with_image(); + let lid = s.doc().unwrap().doc.layers[0].id.0; + s.execute("layer.set", &json!({"id": lid, "locked": true})).unwrap(); + s.execute("layer.new", &json!({"name": "TEXT"})).unwrap(); + let rect = s.execute("frame.create", &json!({"rect": [300, 300, 400, 400]})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": [rect]})).unwrap(); + let r = s.execute("color.sample", &json!({"at": [100, 100], "target": "stroke"})).unwrap(); + assert_eq!(r["color"], "#ff0000", "{r}"); + let it = s.doc().unwrap().doc.item(ItemId(rect)).unwrap().clone(); + assert_eq!(swatch_color(&s, &it.stroke.swatch), Color::rgb8(255, 0, 0)); + // Sampling the same colour again reuses its swatch. + let again = s.execute("color.sample", &json!({"at": [90, 90]})).unwrap(); + assert_eq!(again["swatch"], r["swatch"]); + } + + #[test] + fn nothing_to_sample_or_nothing_to_colour_is_refused_without_an_edit() { + let (mut s, _) = session_with_image(); + let f = s.execute("frame.create", &json!({"rect": [300, 300, 500, 360], "content": "text", "text": "Hi"})).unwrap(); + let fid = ItemId(f["id"].as_u64().unwrap()); + let story = s.doc().unwrap().doc.item(fid).unwrap().text_frame().unwrap().story; + s.execute("text.select", &json!({"story": story.0, "anchor": 1, "focus": 1})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let caret = s.execute("color.sample", &json!({"at": [100, 100]})).unwrap_err().to_string(); + assert!(caret.contains("select the text"), "{caret}"); + s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 2})).unwrap(); + let none = s.execute("color.sample", &json!({"at": [600, 600]})).unwrap_err().to_string(); + assert!(none.contains("no placed image"), "{none}"); + // Just past the image's right edge. + assert!(s.execute("color.sample", &json!({"at": [216.5, 100]})).is_err()); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + } +} diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 6c75b0da6..00806302c 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -705,3 +705,44 @@ fn pen_click_snaps_to_a_guide() { let x = p["anchors"][0]["p"][0].as_f64().unwrap(); assert!((x - 100.0).abs() < 1e-6, "anchor x {x}"); } + +#[test] +fn eyedropper_samples_a_placed_image_and_matches_other_objects() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let photo = ItemId(d.alloc()); + let mut it = Item::new(photo, lid, Shape::Rectangle, shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0))); + it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { + asset: designcraft_doc::AssetId(1), + size: (100.0, 100.0), + xf: designcraft_geom::Affine::translate((0.0, 0.0)), + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + }); + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + // An unfilled text frame over part of the photo lets it show through. + d.add_text_frame(SpreadRef::Doc(0), Rect::new(150.0, 150.0, 250.0, 250.0), lid, "x", ParaFormat::default()).unwrap(); + let swatch = add_rect(&mut d, Rect::new(300.0, 100.0, 350.0, 150.0)); + d.item_mut(swatch).unwrap().fill.swatch = "[Black]".into(); + let target = add_rect(&mut d, Rect::new(400.0, 100.0, 450.0, 150.0)); + // The photo's layer is locked: sampling it still works. + d.layers.iter_mut().for_each(|l| l.locked = true); + let s = Selection { items: vec![target], ..Default::default() }; + let (c, l) = (Cache::new(), CanvasLayout::new(&d, false)); + let off = l.xf(SpreadRef::Doc(0)).translation(); + let cx = ctx(&d, &s, &c, &l); + let mut t = create("eyedropper"); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 120.0 + off.x, 130.0 + off.y)); + let sample = |x: f64, y: f64| Action::Exec("color.sample".into(), serde_json::json!({"at": [x, y], "spread": {"kind": "doc", "index": 0}})); + assert_eq!(a, vec![sample(120.0, 130.0)]); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 180.0 + off.x, 180.0 + off.y)); + assert_eq!(a, vec![sample(180.0, 180.0)], "through the text frame"); + // A filled object is matched as before (here on a locked layer: not reachable, so nothing). + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 320.0 + off.x, 120.0 + off.y)).is_empty()); + let mut d2 = d.clone(); + d2.layers.iter_mut().for_each(|l| l.locked = false); + let cx = ctx(&d2, &s, &c, &l); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 320.0 + off.x, 120.0 + off.y)); + assert_eq!(a, vec![Action::Exec("object.matchAttributes".into(), serde_json::json!({"from": swatch.0}))]); +} diff --git a/crates/tools/src/xform.rs b/crates/tools/src/xform.rs index fbb1df9b8..4ca55ffa2 100644 --- a/crates/tools/src/xform.rs +++ b/crates/tools/src/xform.rs @@ -1,6 +1,7 @@ //! Rotate (R), Scale (S) and Shear (O) tools: drag around the selection centre (Alt-click sets //! the reference point elsewhere in a later iteration); Eyedropper (I): click an object to apply -//! its attributes to the selection. +//! its attributes to the selection, or a placed image to colour the selection (text or objects) +//! with the image's colour under the pointer. use designcraft_doc::SpreadRef; use designcraft_geom::{Point, Rect}; @@ -247,6 +248,37 @@ fn positive_pad(v: f64) -> f64 { if v.is_finite() && v > 0.0 { v } else { 0.0 } } +/// The spread and spread point under `p` when a placed graphic shows there: the frontmost visible +/// filled or graphic item at the point (locked included) is a graphic frame. +fn image_under(cx: &ToolContext, p: Point) -> Option<(SpreadRef, Point)> { + use designcraft_doc::{Content, Item}; + fn shows(it: &Item, outer: designcraft_geom::Affine, at: Point, depth: usize) -> Option<bool> { + if it.hidden || depth > 64 { + return None; + } + let xf = outer * it.xf; + if let Content::Group { items } = &it.content { + return items.iter().rev().find_map(|c| shows(c, xf, at, depth + 1)); + } + let graphic = matches!(it.content, Content::Graphic(_)); + // Unfilled frames (a text frame over the photo) let the image show through. + if !graphic && it.fill.is_none() { + return None; + } + designcraft_doc::edit_hit(it, outer.inverse() * at, 0.0).then_some(graphic) + } + let (sr, at) = cx.layout.spread_at(p)?; + let sp = cx.doc.spread(sr)?; + let mut items: Vec<&Item> = sp.items.iter().map(|i| &**i).collect(); + items.sort_by_key(|i| cx.doc.layer_rank(i.layer)); + let image = items + .into_iter() + .rev() + .filter(|it| cx.doc.layer(it.layer).is_none_or(|l| l.visible)) + .find_map(|it| shows(it, designcraft_geom::Affine::IDENTITY, at, 0))?; + image.then_some((sr, at)) +} + #[derive(Default)] pub struct EyedropperTool; @@ -258,8 +290,13 @@ impl Tool for EyedropperTool { if ev.kind != PointerKind::Down { return vec![]; } + let selected = !cx.selection.items.is_empty() || cx.selection.text.is_some() || cx.selection.cells.is_some(); + // A placed image under the pointer is sampled (its layer may be locked: sampling changes nothing). + if selected && let Some((sr, at)) = image_under(cx, ev.pos) { + return vec![Action::Exec("color.sample".into(), json!({"at": [at.x, at.y], "spread": sr}))]; + } let Some((_, id)) = cx.hit(ev.pos) else { return vec![] }; - if cx.selection.items.is_empty() && cx.selection.text.is_none() { + if !selected { return vec![Action::Exec("selection.set".into(), json!({"ids": [id.0]}))]; } vec![Action::Exec("object.matchAttributes".into(), json!({"from": id.0}))] diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 5976d7d07..29e5eca8e 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -739,6 +739,10 @@ const TABLE: &[(&str, [&str; 5])] = &[ ), ("Apply Cell Style", ["Apply Cell Style", "Apply Cell Style", "Apply Cell Style", "Apply Cell Style", "应用单元格样式"]), ("Apply Color", ["Apply Color", "Apply Color", "Apply Color", "Apply Color", "应用颜色"]), + ( + "Sample Color (Eyedropper)", + ["Farbe aufnehmen (Pipette)", "Prélever la couleur (Pipette)", "Tomar color (Cuentagotas)", "カラーを抽出 (スポイト)", "取样颜色 (吸管)"], + ), ("Apply Condition", ["Apply Condition", "Apply Condition", "Apply Condition", "Apply Condition", "应用条件"]), ( "Apply Parent to Pages…", diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index c3373486e..fb3b7731d 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -1041,6 +1041,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Formatting affects container (J)", "تطبيق التنسيق على الحاوية (J)"), ("Formatting affects text (J)", "تطبيق التنسيق على النص (J)"), ("Apply Color", "تطبيق اللون"), + ("Sample Color (Eyedropper)", "أخذ عينة من اللون (القطارة)"), ("Apply None", "إزالة اللون"), ("Height Only", "الارتفاع فقط"), ("Width Only", "العرض فقط"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 34766cf22..95fbcb44d 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -412,6 +412,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Apply Attributes (Eyedropper)", "Applica attributi (Contagocce)"), ("Apply Cell Style", "Applica stile di cella"), ("Apply Color", "Applica colore"), + ("Sample Color (Eyedropper)", "Campiona colore (Contagocce)"), ("Apply Condition", "Applica condizione"), ("Apply Parent to Pages…", "Applica pagina mastro alle pagine…"), ("ArtCraft App Page…", "Pagina dell'app ArtCraft…"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index de57db5e9..935174345 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -414,6 +414,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Apply Attributes (Eyedropper)", "属性を適用 (スポイト)"), ("Apply Cell Style", "セルスタイルを適用"), ("Apply Color", "カラーを適用"), + ("Sample Color (Eyedropper)", "カラーを抽出 (スポイト)"), ("Apply Condition", "条件を適用"), ("Apply Parent to Pages…", "ページに親ページを適用…"), ("ArtCraft App Page…", "ArtCraft アプリページ…"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index a4d06bbe9..6c383cc8c 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -1045,6 +1045,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Formatting affects container (J)", "Formatação afeta o contêiner (J)"), ("Formatting affects text (J)", "Formatação afeta o texto (J)"), ("Apply Color", "Aplicar cor"), + ("Sample Color (Eyedropper)", "Amostrar cor (Conta-gotas)"), ("Apply None", "Aplicar nenhum"), ("Height Only", "Somente altura"), ("Width Only", "Somente largura"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 9b70204a4..6ea43f398 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -415,6 +415,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Apply Attributes (Eyedropper)", "Застосувати атрибути (піпетка)"), ("Apply Cell Style", "Застосувати стиль клітинки"), ("Apply Color", "Застосувати колір"), + ("Sample Color (Eyedropper)", "Узяти колір (піпетка)"), ("Apply Condition", "Застосувати умову"), ("Apply Parent to Pages…", "Застосувати шаблон до сторінок…"), ("ArtCraft App Page…", "Сторінка програми ArtCraft…"), From 9ab226d741b90b9bf2d1143bc871100a9d37070c Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Mon, 12 Oct 2026 17:56:05 +0200 Subject: [PATCH 062/238] fix(eyedropper): Sample rasters only; colour text through object.color A placed PDF, EPS or SVG under the Eyedropper matches attributes again, as before, instead of failing with "no placed image under the point" (Asset::is_raster, shared by the tool and color.sample). color.sample hands its colour to object.color, which now colours selected text, the text of selected frames ({text: true}), or the typing colour of an empty story; a caret in existing text is refused before a swatch or undo step. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/doc/src/lib.rs | 10 +++ crates/engine/src/cmd/style.rs | 108 ++++++++++++++++++++++----------- crates/tools/src/tests.rs | 23 +++++++ crates/tools/src/xform.rs | 18 ++++-- 4 files changed, 119 insertions(+), 40 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 2c90d8b95..a643cc08d 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -452,6 +452,16 @@ pub struct Asset { pub page: u32, } +impl Asset { + /// A raster image (PNG, JPEG, GIF, WebP, TIFF, BMP, PSD), as opposed to a PDF, EPS or SVG. + pub fn is_raster(&self) -> bool { + matches!( + self.mime.as_str(), + "image/png" | "image/jpeg" | "image/gif" | "image/webp" | "image/tiff" | "image/bmp" | "image/vnd.adobe.photoshop" + ) + } +} + fn is_zero(v: &u32) -> bool { *v == 0 } diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 65052d56a..b09e78503 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -180,8 +180,8 @@ pub fn specs() -> Vec<CommandSpec> { "Apply Color", [], None, - "{color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b](0..255), target?: fill|stroke, ids?} — an unnamed colour (not in the Swatches panel until Add to Swatches) on the selection", - super::has_selection, + "{color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b](0..255), target?: fill|stroke, text?: bool, ids?} — an unnamed colour on the selected text or objects; `text` also targets the text of selected frames", + has_text_or_selection, apply_color ), cmd!( @@ -964,6 +964,44 @@ fn apply_gradient(s: &mut Session, p: &Value) -> Result<Value> { fn apply_color(s: &mut Session, p: &Value) -> Result<Value> { let color = p.get("color").and_then(parse_color).ok_or_else(|| bad("object.color", "missing or bad color"))?; let stroke = str_param(p, "target") == Some("stroke"); + let named = p.get("ids").is_some() || p.get("id").is_some(); + let text = !named + && (p.get("text").and_then(Value::as_bool).unwrap_or(false) || s.doc()?.selection.text.is_some() || s.doc()?.selection.cells.is_some()); + if text { + let targets = super::text::format_targets(s); + // A caret in existing text has no editable range. Refuse it before creating a swatch + // or history; an empty story has a format run that the next insertion inherits. + if !targets + .iter() + .any(|t| !t.range.is_empty() || s.active().is_some_and(|d| d.doc.text_story(t.story, t.cell).is_some_and(|st| st.is_empty()))) + { + return Err(bad("object.color", "select the text to colour")); + } + return s.edit(|d, _| { + let name = color_swatch(d, color); + for t in &targets { + let Some(story) = d.text_story_mut(t.story, t.cell) else { continue }; + let set = |f: &mut designcraft_doc::CharFormat| { + if stroke { + (f.over.stroke, f.over.stroke_tint) = (Some(name.clone()), Some(1.0)); + } else { + (f.over.fill, f.over.fill_tint) = (Some(name.clone()), Some(1.0)); + } + }; + if t.range.is_empty() { + if story.is_empty() + && let Some(run) = story.chars.first_mut() + { + set(&mut run.format); + story.rev = story.rev.wrapping_add(1); + } + } else { + story.format_chars(t.range.clone(), set); + } + } + Ok(json!({"swatch": name})) + }); + } let ids = super::targets(s, p)?; s.edit(|d, _| { let name = color_swatch(d, color); @@ -1019,40 +1057,15 @@ fn sample_color(s: &mut Session, p: &Value) -> Result<Value> { const ID: &str = "color.sample"; let at = super::point_param(p, "at").ok_or_else(|| bad(ID, "missing at"))?; let sr = super::spread_param(p, "spread"); - let stroke = str_param(p, "target") == Some("stroke"); - let named = p.get("ids").and_then(Value::as_array).is_some_and(|a| !a.is_empty()) || p.get("id").is_some(); let st = s.doc()?; let (from, color) = image_color_at(&st.doc, sr, at).ok_or_else(|| bad(ID, "no placed image under the point"))?; let hex = color.to_hex(); - if named || (st.selection.text.is_none() && st.selection.cells.is_none()) { - let ids = super::targets(s, p)?; - return s.edit(|d, _| { - let name = color_swatch(d, color); - color_items(d, &ids, &name, stroke); - Ok(json!({"color": hex, "swatch": name, "from": from.0})) - }); - } - // A caret colours nothing (as with a swatch click): say so instead of an empty undo step. - let targets: Vec<_> = super::text::format_targets(s).into_iter().filter(|t| !t.range.is_empty()).collect(); - if targets.is_empty() { - return Err(bad(ID, "select the text to colour")); - } - s.edit(|d, _| { - let name = color_swatch(d, color); - for t in &targets { - let Some(story) = d.text_story_mut(t.story, t.cell) else { continue }; - story.format_chars(t.range.clone(), |f| { - if stroke { - f.over.stroke = Some(name.clone()); - f.over.stroke_tint = Some(1.0); - } else { - f.over.fill = Some(name.clone()); - f.over.fill_tint = Some(1.0); - } - }); - } - Ok(json!({"color": hex, "swatch": name, "from": from.0})) - }) + let mut params = p.clone(); + params["color"] = json!(hex); + let mut result = apply_color(s, ¶ms)?; + result["color"] = json!(hex); + result["from"] = json!(from.0); + Ok(result) } /// The colour of the placed raster image at `at` on spread `sr`: the frontmost visible image whose @@ -1098,7 +1111,8 @@ fn sample_item( if !(w > 0.0 && h > 0.0 && gp.x >= 0.0 && gp.y >= 0.0 && gp.x < w && gp.y < h) { return None; } - let img = designcraft_images::decode_rgba(&d.assets.get(&g.asset)?.data)?; + let asset = d.assets.get(&g.asset).filter(|a| a.is_raster())?; + let img = designcraft_images::decode_rgba(&asset.data)?; let (pw, ph) = img.dimensions(); let px = ((gp.x / w * f64::from(pw)) as u32).min(pw.checked_sub(1)?); let py = ((gp.y / h * f64::from(ph)) as u32).min(ph.checked_sub(1)?); @@ -1691,4 +1705,30 @@ mod sample_color_tests { assert!(s.execute("color.sample", &json!({"at": [216.5, 100]})).is_err()); assert_eq!(s.doc().unwrap().history.undo.len(), undo); } + + #[test] + fn sampling_and_explicit_colour_use_the_same_text_application_path() { + for command in ["color.sample", "object.color"] { + let (mut s, _) = session_with_image(); + let f = s.execute("frame.create", &json!({"rect": [300, 300, 500, 360], "content": "text"})).unwrap(); + let story = designcraft_doc::StoryId(f["story"].as_u64().unwrap()); + s.execute("text.select", &json!({"story": story.0, "anchor": 0})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute(command, &json!({"at": [100, 100], "color": "#ff0000"})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{command}"); + s.execute("text.insert", &json!({"text": "Hi"})).unwrap(); + let text = s.doc().unwrap().doc.story(story).unwrap(); + assert_eq!(text.format_after(0).over.fill.as_deref(), r["swatch"].as_str(), "empty-story typing: {command}"); + // Explicit ids override text targeting, including with a text selection active. + let fid = ItemId(f["id"].as_u64().unwrap()); + s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 2})).unwrap(); + let r = s.execute(command, &json!({"at": [180, 100], "color": "#0000ff", "id": fid.0})).unwrap(); + assert_eq!(s.doc().unwrap().doc.item(fid).unwrap().fill.swatch, r["swatch"].as_str().unwrap()); + let text = s.doc().unwrap().doc.story(story).unwrap(); + assert_ne!(text.format_after(0).over.fill.as_deref(), r["swatch"].as_str(), "text unchanged: {command}"); + let r = s.execute(command, &json!({"at": [180, 100], "color": "#0000ff", "target": "stroke"})).unwrap(); + let text = s.doc().unwrap().doc.story(story).unwrap(); + assert_eq!(text.format_after(0).over.stroke.as_deref(), r["swatch"].as_str(), "selected text stroke: {command}"); + } + } } diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 00806302c..0d3981904 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -720,6 +720,18 @@ fn eyedropper_samples_a_placed_image_and_matches_other_objects() { fit_align: 4, crop: [0.0; 4], }); + d.assets.insert( + designcraft_doc::AssetId(1), + std::sync::Arc::new(designcraft_doc::Asset { + id: designcraft_doc::AssetId(1), + name: "photo.png".into(), + mime: "image/png".into(), + link: None, + data: std::sync::Arc::new(vec![]), + pixels: Some((100, 100)), + page: 0, + }), + ); d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); // An unfilled text frame over part of the photo lets it show through. d.add_text_frame(SpreadRef::Doc(0), Rect::new(150.0, 150.0, 250.0, 250.0), lid, "x", ParaFormat::default()).unwrap(); @@ -745,4 +757,15 @@ fn eyedropper_samples_a_placed_image_and_matches_other_objects() { let cx = ctx(&d2, &s, &c, &l); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 320.0 + off.x, 120.0 + off.y)); assert_eq!(a, vec![Action::Exec("object.matchAttributes".into(), serde_json::json!({"from": swatch.0}))]); + // Vector graphics and missing assets must retain the ordinary attribute-matching path. + for mime in ["application/pdf", "application/postscript", "image/svg+xml", "application/octet-stream"] { + std::sync::Arc::make_mut(d2.assets.get_mut(&designcraft_doc::AssetId(1)).unwrap()).mime = mime.into(); + let cx = ctx(&d2, &s, &c, &l); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 120.0 + off.x, 130.0 + off.y)); + assert_eq!(a, vec![Action::Exec("object.matchAttributes".into(), serde_json::json!({"from": photo.0}))], "{mime}"); + } + d2.assets.clear(); + let cx = ctx(&d2, &s, &c, &l); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 120.0 + off.x, 130.0 + off.y)); + assert_eq!(a, vec![Action::Exec("object.matchAttributes".into(), serde_json::json!({"from": photo.0}))]); } diff --git a/crates/tools/src/xform.rs b/crates/tools/src/xform.rs index 4ca55ffa2..e0bcd24f3 100644 --- a/crates/tools/src/xform.rs +++ b/crates/tools/src/xform.rs @@ -248,24 +248,30 @@ fn positive_pad(v: f64) -> f64 { if v.is_finite() && v > 0.0 { v } else { 0.0 } } -/// The spread and spread point under `p` when a placed graphic shows there: the frontmost visible -/// filled or graphic item at the point (locked included) is a graphic frame. +/// The spread and spread point under `p` when a placed raster shows there. Other graphic formats +/// still block an image behind them, but use attribute matching instead of pixel sampling. fn image_under(cx: &ToolContext, p: Point) -> Option<(SpreadRef, Point)> { use designcraft_doc::{Content, Item}; - fn shows(it: &Item, outer: designcraft_geom::Affine, at: Point, depth: usize) -> Option<bool> { + fn shows(d: &designcraft_doc::Document, it: &Item, outer: designcraft_geom::Affine, at: Point, depth: usize) -> Option<bool> { if it.hidden || depth > 64 { return None; } let xf = outer * it.xf; if let Content::Group { items } = &it.content { - return items.iter().rev().find_map(|c| shows(c, xf, at, depth + 1)); + return items.iter().rev().find_map(|c| shows(d, c, xf, at, depth + 1)); } let graphic = matches!(it.content, Content::Graphic(_)); // Unfilled frames (a text frame over the photo) let the image show through. if !graphic && it.fill.is_none() { return None; } - designcraft_doc::edit_hit(it, outer.inverse() * at, 0.0).then_some(graphic) + if !designcraft_doc::edit_hit(it, outer.inverse() * at, 0.0) { + return None; + } + Some(match &it.content { + Content::Graphic(g) => d.assets.get(&g.asset).is_some_and(|a| a.is_raster()), + _ => false, + }) } let (sr, at) = cx.layout.spread_at(p)?; let sp = cx.doc.spread(sr)?; @@ -275,7 +281,7 @@ fn image_under(cx: &ToolContext, p: Point) -> Option<(SpreadRef, Point)> { .into_iter() .rev() .filter(|it| cx.doc.layer(it.layer).is_none_or(|l| l.visible)) - .find_map(|it| shows(it, designcraft_geom::Affine::IDENTITY, at, 0))?; + .find_map(|it| shows(cx.doc, it, designcraft_geom::Affine::IDENTITY, at, 0))?; image.then_some((sr, at)) } From 04319fce0f3a484caaac3add4f5e2eace212f0bb Mon Sep 17 00:00:00 2001 From: KnottyDyes <47539659+KnottyDyes@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:37:29 -0600 Subject: [PATCH 063/238] Styles: selection capture accepts deleted-swatch fills Validate caller-supplied character fills before merging selection attributes. Deleted swatch names already retained in text no longer block New Style from Selection; explicit unknown fills still fail without changing state. Cover swatch creation, text formatting, deletion and style capture, including non-fill overrides and explicit-fill rejection with fromSelection. --- crates/engine/src/cmd/style.rs | 44 +++++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 4bf69ec41..e60d2a1d7 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -429,6 +429,8 @@ fn create_para(s: &mut Session, p: &Value) -> Result<Value> { let base = str_param(p, "name").unwrap_or("Paragraph Style 1").to_string(); let mut para: ParaAttrs = attrs(p.get("para"), |a: &mut ParaAttrs, k, v| a.set_json(k, v))?; let mut chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?; + // Validate caller-supplied fills, not selection fills retained after a swatch is deleted. + super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.create")?; if p.get("fromSelection").and_then(Value::as_bool).unwrap_or(false) && let Ok(cur) = s.execute("type.selectionAttrs", &json!({})) && !cur.is_null() @@ -444,7 +446,6 @@ fn create_para(s: &mut Session, p: &Value) -> Result<Value> { chars = full; } } - super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.create")?; let based_on = str_param(p, "basedOn").map(str::to_string); let next = str_param(p, "nextStyle").map(str::to_string); s.edit(|d, _| { @@ -1116,17 +1117,38 @@ mod color_tests { } #[test] - fn char_fill_rejects_inherited_unknown_name_when_creating_from_selection() { - let mut session = fill_session(); - session - .edit(|document, selection| { - let selected = selection.text.unwrap(); - document.story_mut(selected.story).unwrap().format_chars(selected.range(), |format| format.over.fill = Some("Missing Ink".into())); - Ok(()) - }) - .unwrap(); + fn char_fill_preserves_deleted_swatch_when_creating_from_selection() { + for params in + [json!({"name": "Inherited", "fromSelection": true}), json!({"name": "Inherited", "fromSelection": true, "chars": {"size": 24}})] + { + let mut session = fill_session(); + let swatch = session.execute("swatch.create", &json!({"name": "Deleted Ink", "color": "#108080"})).unwrap(); + let fill = swatch["name"].as_str().unwrap(); + session.execute("type.char", &json!({"fill": fill})).unwrap(); + session.execute("swatch.delete", &json!({"name": fill})).unwrap(); + assert_eq!(session.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], fill); - assert_fill_rejected(&mut session, "style.paragraph.create", json!({"name": "Inherited", "fromSelection": true})); + session.execute("style.paragraph.create", ¶ms).unwrap(); + + let chars = &session.doc().unwrap().doc.styles.para("Inherited").unwrap().chars; + assert_eq!(chars.fill.as_deref(), Some(fill)); + if params.get("chars").is_some() { + assert_eq!(chars.size, Some(24.0)); + } + } + } + + #[test] + fn char_fill_rejects_explicit_unknown_name_when_creating_from_selection() { + for fill in ["Missing Ink", "#ff0000"] { + let mut session = fill_session(); + + assert_fill_rejected( + &mut session, + "style.paragraph.create", + json!({"name": "Explicit", "fromSelection": true, "chars": {"fill": fill, "size": 24}}), + ); + } } #[test] From 32597369315c1ff274b7135ef583a2827ef60c1f Mon Sep 17 00:00:00 2001 From: KnottyDyes <47539659+KnottyDyes@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:02:07 -0600 Subject: [PATCH 064/238] MCP: reconnect closed sockets before sending the next request Probe reused connections before writing so an app restart does not lose the next call. Report connection failures as not reachable with nothing sent, while retaining uncertain outcomes and no replay after sending begins. Add same-port restart and unavailable-app loopback regressions, and retain the lost-reply guard. --- crates/mcp/src/backend.rs | 115 +++++++++++++++++++++++++++++++++++++- 1 file changed, 113 insertions(+), 2 deletions(-) diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index c4930cc99..debdbb124 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -55,10 +55,27 @@ impl Remote { Err(last) } - fn roundtrip(&mut self, line: &str) -> std::io::Result<String> { + fn prepare_connection(&mut self) -> std::io::Result<()> { + if let Some((reader, writer)) = self.conn.take() { + writer.set_nonblocking(true)?; + let probe = writer.peek(&mut [0]); + writer.set_nonblocking(false)?; + let live = match probe { + Ok(0) => false, + Ok(_) => true, + Err(e) => e.kind() == std::io::ErrorKind::WouldBlock, + }; + if live { + self.conn = Some((reader, writer)); + } + } if self.conn.is_none() { self.reconnect()?; } + Ok(()) + } + + fn roundtrip(&mut self, line: &str) -> std::io::Result<String> { let Some((reader, writer)) = self.conn.as_mut() else { return Err(std::io::Error::new(std::io::ErrorKind::NotConnected, "not connected")); }; @@ -78,6 +95,10 @@ impl Backend for Remote { let id = self.next_id; self.next_id += 1; let line = json!({"id": id, "method": method, "params": params}).to_string(); + if let Err(e) = self.prepare_connection() { + self.conn = None; + return Err(format!("DesignCraft app at {} is not reachable: {e}; nothing was sent", self.addr)); + } let reply = match self.roundtrip(&line) { Ok(r) => r, Err(e) => { @@ -107,6 +128,96 @@ mod tests { use super::*; use std::net::TcpListener; + fn closed_remote() -> (Remote, String) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let mut remote = Remote::connect(&addr).unwrap(); + let (mut stream, _) = listener.accept().unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); + stream.set_write_timeout(Some(Duration::from_secs(2))).unwrap(); + let server = std::thread::spawn(move || { + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + assert!(reader.read_line(&mut line).unwrap() > 0); + let request: Value = serde_json::from_str(&line).unwrap(); + assert_eq!(request["method"], "document.inspect"); + writeln!(stream, "{}", json!({"id": request["id"], "ok": true, "result": null})).unwrap(); + stream.shutdown(std::net::Shutdown::Both).unwrap(); + drop(listener); + }); + assert_eq!(remote.call("document.inspect", json!({})), Ok(Value::Null)); + server.join().unwrap(); + let stream = &remote.conn.as_ref().unwrap().1; + stream.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); + assert_eq!(stream.peek(&mut [0]).unwrap(), 0, "FIN must be observable before the next call"); + (remote, addr) + } + + #[test] + fn remote_executes_once_when_app_restarts_between_calls() { + let (mut remote, addr) = closed_remote(); + let listener = TcpListener::bind(&addr).unwrap(); + listener.set_nonblocking(true).unwrap(); + let server = std::thread::spawn(move || { + let deadline = std::time::Instant::now() + Duration::from_secs(2); + let mut stream = loop { + match listener.accept() { + Ok((stream, _)) => break stream, + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + if std::time::Instant::now() >= deadline { + return 0; + } + std::thread::yield_now(); + } + Err(e) => panic!("accept failed: {e}"), + } + }; + stream.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); + stream.set_write_timeout(Some(Duration::from_secs(2))).unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut applied = 0; + loop { + let mut line = String::new(); + assert!(reader.read_line(&mut line).unwrap() > 0); + let request: Value = serde_json::from_str(&line).unwrap(); + match request["method"].as_str().unwrap() { + "engine.execute" => { + assert_eq!(request["params"]["command"], "frame.create"); + applied += 1; + } + "document.inspect" => {} + method => panic!("unexpected method: {method}"), + } + writeln!(stream, "{}", json!({"id": request["id"], "ok": true, "result": {"applied": applied}})).unwrap(); + if request["method"] == "document.inspect" { + return applied; + } + } + }); + + let result = remote.call("engine.execute", json!({"command": "frame.create", "params": {"rect": [0, 0, 100, 100]}})); + let inspected = match &result { + Ok(_) => remote.call("document.inspect", json!({})), + Err(_) => Err("edit failed".to_string()), + }; + let applied = server.join().unwrap(); + + assert_eq!(result, Ok(json!({"applied": 1})), "the first edit after restart must succeed"); + assert_eq!(inspected, Ok(json!({"applied": 1}))); + assert_eq!(applied, 1); + } + + #[test] + fn remote_reports_nothing_sent_when_reconnect_has_no_listener() { + let (mut remote, _addr) = closed_remote(); + + let error = remote.call("engine.execute", json!({"command": "frame.create", "params": {}})).unwrap_err(); + + assert!(error.contains("not reachable"), "{error}"); + assert!(error.contains("nothing was sent"), "{error}"); + assert!(!error.contains("operation may have completed"), "{error}"); + } + #[test] fn remote_does_not_replay_when_reply_is_lost() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); @@ -149,7 +260,7 @@ mod tests { let applied = server.join().unwrap(); assert_eq!(applied, 1, "lost reply must not replay the applied edit; client returned {result:?}"); - assert!(result.is_err(), "lost reply must report an uncertain outcome: {result:?}"); + assert!(result.unwrap_err().contains("operation may have completed"), "lost reply must report an uncertain outcome"); assert_eq!(next, Ok(json!({"applied": 1})), "the next explicit request must reconnect successfully"); } } From 45a228426a9191322cb875e62ff2fa986f689060 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Mon, 12 Oct 2026 11:03:34 +0200 Subject: [PATCH 065/238] feat(ui): Effects panel shows the level, Isolate Blending and Knockout Group The Effects panel had the blend mode, opacity and Drop Shadow only. Like InDesign's it now names the level the settings apply to with its blend mode and opacity (Group: Color Dodge 100%, Object: Normal 100%), and has Isolate Blending and Knockout Group, enabled for a selected group, through object.transparencyGroup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 1 + crates/ui-egui/src/i18n.rs | 4 ++ crates/ui-egui/src/i18n/ar.rs | 4 ++ crates/ui-egui/src/i18n/it.rs | 4 ++ crates/ui-egui/src/i18n/ja.rs | 4 ++ crates/ui-egui/src/i18n/pt_br.rs | 4 ++ crates/ui-egui/src/i18n/uk.rs | 4 ++ crates/ui-egui/src/panels/properties.rs | 86 +++++++++++++++++++++++++ 8 files changed, 111 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 4b7163c76..2dbfe0ef0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Newest first. - **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change. - **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change. +- **2026-10-12** · The Effects panel shows the level its settings apply to ("Group: Color Dodge 100%") and, as in InDesign, Isolate Blending and Knockout Group for a selected group. No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 5976d7d07..62c92553a 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -739,6 +739,10 @@ const TABLE: &[(&str, [&str; 5])] = &[ ), ("Apply Cell Style", ["Apply Cell Style", "Apply Cell Style", "Apply Cell Style", "Apply Cell Style", "应用单元格样式"]), ("Apply Color", ["Apply Color", "Apply Color", "Apply Color", "Apply Color", "应用颜色"]), + ("Isolate Blending", ["Füllmethode isolieren", "Isoler le mode de fusion", "Aislar fusión", "描画モードを分離", "分离混合"]), + ("Knockout Group", ["Aussparungsgruppe", "Groupe de masquage", "Grupo de exclusión", "グループの抜き", "挖空组"]), + ("Object:", ["Objekt:", "Objet :", "Objeto:", "オブジェクト:", "对象:"]), + ("Group:", ["Gruppe:", "Groupe :", "Grupo:", "グループ:", "编组:"]), ("Apply Condition", ["Apply Condition", "Apply Condition", "Apply Condition", "Apply Condition", "应用条件"]), ( "Apply Parent to Pages…", diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index c3373486e..9e37dfdb1 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -1041,6 +1041,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Formatting affects container (J)", "تطبيق التنسيق على الحاوية (J)"), ("Formatting affects text (J)", "تطبيق التنسيق على النص (J)"), ("Apply Color", "تطبيق اللون"), + ("Isolate Blending", "عزل المزج"), + ("Knockout Group", "مجموعة مفرغة"), + ("Object:", "الكائن:"), + ("Group:", "المجموعة:"), ("Apply None", "إزالة اللون"), ("Height Only", "الارتفاع فقط"), ("Width Only", "العرض فقط"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 34766cf22..b14398d16 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -412,6 +412,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Apply Attributes (Eyedropper)", "Applica attributi (Contagocce)"), ("Apply Cell Style", "Applica stile di cella"), ("Apply Color", "Applica colore"), + ("Isolate Blending", "Isola fusione"), + ("Knockout Group", "Gruppo di foratura"), + ("Object:", "Oggetto:"), + ("Group:", "Gruppo:"), ("Apply Condition", "Applica condizione"), ("Apply Parent to Pages…", "Applica pagina mastro alle pagine…"), ("ArtCraft App Page…", "Pagina dell'app ArtCraft…"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index de57db5e9..a90da290f 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -414,6 +414,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Apply Attributes (Eyedropper)", "属性を適用 (スポイト)"), ("Apply Cell Style", "セルスタイルを適用"), ("Apply Color", "カラーを適用"), + ("Isolate Blending", "描画モードを分離"), + ("Knockout Group", "グループの抜き"), + ("Object:", "オブジェクト:"), + ("Group:", "グループ:"), ("Apply Condition", "条件を適用"), ("Apply Parent to Pages…", "ページに親ページを適用…"), ("ArtCraft App Page…", "ArtCraft アプリページ…"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index a4d06bbe9..ee51a3916 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -1045,6 +1045,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Formatting affects container (J)", "Formatação afeta o contêiner (J)"), ("Formatting affects text (J)", "Formatação afeta o texto (J)"), ("Apply Color", "Aplicar cor"), + ("Isolate Blending", "Isolar mesclagem"), + ("Knockout Group", "Grupo de vazamento"), + ("Object:", "Objeto:"), + ("Group:", "Grupo:"), ("Apply None", "Aplicar nenhum"), ("Height Only", "Somente altura"), ("Width Only", "Somente largura"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 9b70204a4..0314ee403 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -415,6 +415,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Apply Attributes (Eyedropper)", "Застосувати атрибути (піпетка)"), ("Apply Cell Style", "Застосувати стиль клітинки"), ("Apply Color", "Застосувати колір"), + ("Isolate Blending", "Ізолювати змішування"), + ("Knockout Group", "Група вибивання"), + ("Object:", "Об’єкт:"), + ("Group:", "Група:"), ("Apply Condition", "Застосувати умову"), ("Apply Parent to Pages…", "Застосувати шаблон до сторінок…"), ("ArtCraft App Page…", "Сторінка програми ArtCraft…"), diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 49cb920f2..695bb149a 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1988,6 +1988,13 @@ pub fn effects_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let _ = app.run("object.opacity", json!({"opacity": (v / 100.0).clamp(0.0, 1.0)})); } }); + // The level the settings apply to, with its blend mode and opacity ("Group: Color Dodge 100%"). + let group = matches!(it.content, designcraft_doc::Content::Group { .. }); + ui.horizontal(|ui| { + let level = crate::i18n::tr(&app.ui.language, if group { "Group:" } else { "Object:" }); + crate::rtl::label(ui, egui::RichText::new(level).font(crate::theme::semibold(12.0))); + crate::rtl::label(ui, format!("{} {}%", crate::i18n::tr(&app.ui.language, it.blend.label()), (it.opacity * 100.0).round())); + }); divider(ui); let ds = it.effects.drop_shadow.clone(); let mut on = ds.on; @@ -2030,6 +2037,20 @@ pub fn effects_panel(app: &mut DesignApp, ui: &mut egui::Ui) { ui.end_row(); }); } + divider(ui); + // Groups only, as in InDesign (greyed for other objects). + ui.horizontal(|ui| { + let mut iso = it.isolate; + let label = crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Isolate Blending")); + if ui.add_enabled(group, egui::Checkbox::new(&mut iso, label)).changed() { + let _ = app.run("object.transparencyGroup", json!({"isolate": iso})); + } + let mut ko = it.knockout; + let label = crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Knockout Group")); + if ui.add_enabled(group, egui::Checkbox::new(&mut ko, label)).changed() { + let _ = app.run("object.transparencyGroup", json!({"knockout": ko})); + } + }); } pub fn preflight_panel(app: &mut DesignApp, ui: &mut egui::Ui) { @@ -2208,3 +2229,68 @@ mod tests { assert_eq!(after, name, "clicking the swatch chip applies it"); } } + +#[cfg(test)] +mod effects_panel_tests { + use super::*; + + /// One frame of the Effects panel: the text painted and where. + fn frame(app: &mut DesignApp, ctx: &egui::Context, events: Vec<egui::Event>) -> Vec<(String, egui::Rect)> { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(320.0, 400.0)); + let time = ctx.input(|i| i.time) + 1.0 / 60.0; + let mut out = + ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(time), events, ..Default::default() }, |ui| effects_panel(app, ui)); + out.textures_delta.clear(); + let mut v = Vec::new(); + for s in out.shapes { + if let egui::Shape::Text(t) = s.shape { + v.push((t.galley.job.text.clone(), t.visual_bounding_rect())); + } + } + v + } + + fn click(app: &mut DesignApp, ctx: &egui::Context, label: &str) { + let labels = frame(app, ctx, vec![]); + let pos = labels.iter().find(|(t, _)| t == label).unwrap_or_else(|| panic!("{label} is painted")).1.center(); + for pressed in [true, false] { + frame( + app, + ctx, + vec![ + egui::Event::PointerMoved(pos), + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }, + ], + ); + } + } + + #[test] + fn the_effects_panel_shows_the_level_and_isolate_blending_and_knockout_group_for_groups() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({})).unwrap(); + let a = app.run("frame.create", json!({"rect": [72, 72, 144, 144]})).unwrap()["id"].as_u64().unwrap(); + let b = app.run("frame.create", json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap(); + let g = app.run("object.group", json!({"ids": [a, b]})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [g]})).unwrap(); + app.run("object.opacity", json!({"opacity": 1.0, "blend": "colorDodge"})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx, "en"); + crate::theme::apply(&ctx, &Tokens::for_brightness(crate::theme::Brightness::default())); + let texts: Vec<String> = frame(&mut app, &ctx, vec![]).into_iter().map(|(t, _)| t).collect(); + for expected in ["Group:", "Color Dodge 100%", "Isolate Blending", "Knockout Group"] { + assert!(texts.iter().any(|t| t == expected), "{expected} in {texts:?}"); + } + let item = |app: &DesignApp, id: u64| app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).cloned().unwrap(); + click(&mut app, &ctx, "Isolate Blending"); + assert!(item(&app, g).isolate); + click(&mut app, &ctx, "Knockout Group"); + assert!(item(&app, g).knockout); + // Another object: the level is Object, and the group options are greyed. + app.run("selection.set", json!({"ids": [a]})).unwrap(); + let texts: Vec<String> = frame(&mut app, &ctx, vec![]).into_iter().map(|(t, _)| t).collect(); + assert!(texts.iter().any(|t| t == "Object:"), "{texts:?}"); + click(&mut app, &ctx, "Knockout Group"); + assert!(!item(&app, a).knockout); + } +} From b71d469b79d2e6c89ea5cf38ea3a9163164171a2 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Mon, 12 Oct 2026 18:17:34 +0200 Subject: [PATCH 066/238] fix(ui): A frame holding pasted-in content stays at the Object level Paste Into gives a frame nested content, but it isn't a group: the Effects panel showed "Group:" and enabled Isolate Blending and Knockout Group for it. It now asks Item::is_group, so such a frame shows "Object:" with the group options disabled. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/panels/properties.rs | 28 ++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 695bb149a..6ef2de8a7 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1989,7 +1989,7 @@ pub fn effects_panel(app: &mut DesignApp, ui: &mut egui::Ui) { } }); // The level the settings apply to, with its blend mode and opacity ("Group: Color Dodge 100%"). - let group = matches!(it.content, designcraft_doc::Content::Group { .. }); + let group = it.is_group(); ui.horizontal(|ui| { let level = crate::i18n::tr(&app.ui.language, if group { "Group:" } else { "Object:" }); crate::rtl::label(ui, egui::RichText::new(level).font(crate::theme::semibold(12.0))); @@ -2293,4 +2293,30 @@ mod effects_panel_tests { click(&mut app, &ctx, "Knockout Group"); assert!(!item(&app, a).knockout); } + + #[test] + fn pasted_into_frames_keep_object_level_and_disable_group_blending() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({})).unwrap(); + let child = app.run("frame.create", json!({"rect": [80, 80, 120, 120]})).unwrap()["id"].clone(); + app.run("selection.set", json!({"ids": [child]})).unwrap(); + app.run("edit.copy", json!({})).unwrap(); + let container = app.run("frame.create", json!({"rect": [72, 72, 200, 200]})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [container]})).unwrap(); + app.run("edit.pasteInto", json!({})).unwrap(); + app.run("selection.set", json!({"ids": [container]})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx, "en"); + crate::theme::apply(&ctx, &Tokens::for_brightness(crate::theme::Brightness::default())); + let texts: Vec<String> = frame(&mut app, &ctx, vec![]).into_iter().map(|(t, _)| t).collect(); + assert!(texts.iter().any(|t| t == "Object:"), "a frame remains an object after Paste Into: {texts:?}"); + let undo = app.session.doc().unwrap().history.undo.len(); + click(&mut app, &ctx, "Isolate Blending"); + click(&mut app, &ctx, "Knockout Group"); + let st = app.session.doc().unwrap(); + let it = st.doc.item(designcraft_doc::ItemId(container)).unwrap(); + assert!(it.has_nested_items()); + assert!(!it.isolate && !it.knockout, "group-only options stay disabled for a frame holding content"); + assert_eq!(st.history.undo.len(), undo, "disabled controls do not create undo steps"); + } } From 03a8d4b95525ab14a3ea69f47fd44af65f3cc90f Mon Sep 17 00:00:00 2001 From: moppeeer <339764117+moppeeer@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:41:01 +0200 Subject: [PATCH 067/238] feat(fonts): Font format, classification and PANOSE for the font menus Each face reports what InDesign's font menu shows and filters by, read once from its tables: its format (TrueType, OpenType/CFF, variable, colour), its class (serif, slab serif, sans serif, script, monospaced, decorative, symbol) from PANOSE, else the OS/2 IBM family class, else the family name, else the shape of its "l" (serifs widen the foot), its PANOSE numbers (with a distance for similar fonts) and when its file was added. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/fonts/src/fontdb.rs | 76 +++++++++++++++ crates/fonts/src/kind.rs | 191 +++++++++++++++++++++++++++++++++++++ crates/fonts/src/lib.rs | 2 + 3 files changed, 269 insertions(+) create mode 100644 crates/fonts/src/kind.rs diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 14441e144..079c0f1b1 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -121,6 +121,8 @@ pub struct FontFace { em: std::sync::OnceLock<(f64, f64)>, /// The font menu group and native family name, read on first use. group: std::sync::OnceLock<(FontGroup, Option<String>)>, + /// Format, class and PANOSE numbers (the font menus), read on first use. + kind: std::sync::OnceLock<crate::kind::FaceKind>, } /// A cheap `Copy` handle to a face. Faces are never unloaded, so the handle lives for the rest of @@ -188,6 +190,32 @@ impl FontFace { pub fn skrifa(&self) -> Option<skrifa::FontRef<'_>> { skrifa::FontRef::from_index(self.data(), self.index).ok() } + /// The face's format, class and PANOSE numbers, as the font menus show and filter them. + pub fn kind(&self) -> crate::kind::FaceKind { + *self.kind.get_or_init(|| { + let mut kind = self.skrifa().map(|f| crate::kind::read_kind(&f, &self.family)).unwrap_or_default(); + // Nothing in the tables or the name: look at the letters. + if kind.class == crate::kind::FontClass::Other + && let Some(serifs) = has_serifs(self) + { + kind.class = if serifs { crate::kind::FontClass::Serif } else { crate::kind::FontClass::SansSerif }; + } + kind + }) + } + /// When the font file was added (created, else last modified): the font menus' Show Recently + /// Added. `None` for fonts without a file. + pub fn added(&self) -> Option<std::time::SystemTime> { + #[cfg(not(target_arch = "wasm32"))] + { + let meta = std::fs::metadata(self.source.path()?).ok()?; + meta.created().or_else(|_| meta.modified()).ok() + } + #[cfg(target_arch = "wasm32")] + { + None + } + } pub fn hb(&self) -> Option<harfrust::FontRef<'_>> { harfrust::FontRef::from_index(self.data(), self.index).ok() } @@ -709,6 +737,7 @@ fn make_face(bytes: FontBytes, source: FontSource, index: u32, family: String, s bmp: std::sync::OnceLock::new(), em: std::sync::OnceLock::new(), group: std::sync::OnceLock::new(), + kind: std::sync::OnceLock::new(), }) } @@ -1432,6 +1461,53 @@ fn over_total() -> String { format!("over the {} MB the folder's fonts may take in all", MAX_DOCUMENT_FONTS_TOTAL >> 20) } +/// Does the face's "l" (else "I") have serifs: much wider at its foot than mid-stem? `None` when +/// it has neither letter. +fn has_serifs(face: &FontFace) -> Option<bool> { + let gid = ['l', 'I'].into_iter().map(|c| face.glyph_for(c)).find(|g| *g != 0)?; + let path = font_outline(face, gid); + let bbox = kurbo::Shape::bounding_box(&path); + // An empty or degenerate outline (NaN included) says nothing. + if bbox.height().is_nan() || bbox.height() <= 1.0 { + return None; + } + let mut edges = Vec::new(); + let mut last = None; + let mut start = None; + kurbo::flatten(&path, 1.0, |el| match el { + kurbo::PathEl::MoveTo(p) => { + last = Some(p); + start = Some(p); + } + kurbo::PathEl::LineTo(p) => { + if let Some(a) = last { + edges.push((a, p)); + } + last = Some(p); + } + kurbo::PathEl::ClosePath => { + if let (Some(a), Some(b)) = (last, start) { + edges.push((a, b)); + } + last = start; + } + _ => {} + }); + // The outline is y-down: the foot is near bbox.y1 (the baseline). + let width_at = |y: f64| { + let xs: Vec<f64> = edges + .iter() + .filter(|(a, b)| (a.y <= y && y < b.y) || (b.y <= y && y < a.y)) + .map(|(a, b)| a.x + (y - a.y) * (b.x - a.x) / (b.y - a.y)) + .collect(); + let (lo, hi) = xs.iter().fold((f64::MAX, f64::MIN), |(lo, hi), x| (lo.min(*x), hi.max(*x))); + (hi > lo).then_some(hi - lo) + }; + let foot = width_at(bbox.y1 - bbox.height() * 0.02)?; + let stem = width_at(bbox.y0 + bbox.height() * 0.5)?; + Some(foot > stem * 1.6) +} + /// Glyph `gid`'s outline as the font draws it, in font units, y-down (flipped). fn font_outline(face: &FontFace, gid: u32) -> BezPath { let mut pen = FlipPen(BezPath::new()); diff --git a/crates/fonts/src/kind.rs b/crates/fonts/src/kind.rs new file mode 100644 index 000000000..6a224bc00 --- /dev/null +++ b/crates/fonts/src/kind.rs @@ -0,0 +1,191 @@ +//! What the font menus say about a face: its file format (the icon after the sample), its +//! classification (the Filters menu) and its PANOSE numbers (Show Similar Fonts). + +use skrifa::raw::TableProvider; +use skrifa::raw::types::Tag; + +/// A face's font format, as the font menus show it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum FontFormat { + /// TrueType outlines (`glyf`). + TrueType, + /// PostScript (CFF) outlines. + OpenType, + /// A variable font (`fvar`). + Variable, + /// Colour glyphs (COLR, SVG, bitmaps): emoji and colour fonts. + Color, +} + +/// The classes the font menus filter by. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum FontClass { + Serif, + SlabSerif, + SansSerif, + Script, + Monospaced, + Decorative, + Symbol, + /// Nothing in the font says. + Other, +} + +impl FontClass { + /// The classes, in the Filters menu's order. + pub const ALL: [FontClass; 8] = [ + FontClass::Serif, + FontClass::SlabSerif, + FontClass::SansSerif, + FontClass::Script, + FontClass::Monospaced, + FontClass::Decorative, + FontClass::Symbol, + FontClass::Other, + ]; + + /// The name the Filters menu shows (English; the UI translates it). + pub fn label(self) -> &'static str { + match self { + FontClass::Serif => "Serif", + FontClass::SlabSerif => "Slab Serif", + FontClass::SansSerif => "Sans Serif", + FontClass::Script => "Script", + FontClass::Monospaced => "Monospaced", + FontClass::Decorative => "Decorative", + FontClass::Symbol => "Symbol", + FontClass::Other => "Other", + } + } +} + +/// A face's format, class and PANOSE numbers. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FaceKind { + pub format: FontFormat, + pub class: FontClass, + /// The OS/2 table's PANOSE classification (all zero when the font has none). + pub panose: [u8; 10], +} + +impl Default for FaceKind { + fn default() -> Self { + FaceKind { format: FontFormat::TrueType, class: FontClass::Other, panose: [0; 10] } + } +} + +/// Read a face's kind from its tables; `family` helps classify fonts whose tables don't say. +pub fn read_kind(f: &skrifa::FontRef<'_>, family: &str) -> FaceKind { + let has = |tag: &[u8; 4]| f.table_data(Tag::new(tag)).is_some(); + let format = if [b"COLR", b"SVG ", b"CBDT", b"sbix"].iter().any(|t| has(t)) { + FontFormat::Color + } else if has(b"fvar") { + FontFormat::Variable + } else if has(b"CFF ") || has(b"CFF2") { + FontFormat::OpenType + } else { + FontFormat::TrueType + }; + let os2 = f.os2().ok(); + let panose: [u8; 10] = os2.as_ref().and_then(|t| t.panose_10().try_into().ok()).unwrap_or([0; 10]); + let ibm = os2.as_ref().map_or(0, |t| (t.s_family_class() >> 8) as u8); + let fixed = f.post().ok().is_some_and(|p| p.is_fixed_pitch() != 0); + FaceKind { format, class: classify(&panose, ibm, fixed, family), panose } +} + +/// The class from PANOSE (family kind, serif style, proportion), else the IBM family class in OS/2, +/// else the family name. +pub fn classify(panose: &[u8; 10], ibm_class: u8, fixed_pitch: bool, family: &str) -> FontClass { + let name = family.to_lowercase(); + let named = |words: &[&str]| words.iter().any(|w| name.contains(w)); + // PANOSE proportion 9 is monospaced (Latin text). + if fixed_pitch || (panose[0] == 2 && panose[3] == 9) || named(&["mono", "courier", " code"]) { + return FontClass::Monospaced; + } + match panose[0] { + 3 => return FontClass::Script, + 4 => return FontClass::Decorative, + 5 => return FontClass::Symbol, + 2 => match panose[1] { + // Square serifs are slabs; cove, bone, triangle… serifs are serifs. + 6 => return FontClass::SlabSerif, + 2..=10 => return FontClass::Serif, + 11..=15 => return FontClass::SansSerif, + _ => {} + }, + _ => {} + } + match ibm_class { + 1 | 2 | 3 | 7 => FontClass::Serif, + 4 | 5 => FontClass::SlabSerif, + 8 => FontClass::SansSerif, + 9 => FontClass::Decorative, + 10 => FontClass::Script, + 12 => FontClass::Symbol, + _ if named(&["slab"]) => FontClass::SlabSerif, + _ if named(&["sans", "grotesk", "grotesque", "gothic", "helvetica", "arial"]) => FontClass::SansSerif, + _ if named(&["script", "hand", "brush"]) => FontClass::Script, + _ if named(&["serif"]) => FontClass::Serif, + _ => FontClass::Other, + } +} + +/// How unlike two faces look by their PANOSE numbers (0 = alike), counting only the digits both +/// fonts set; `None` when they share none. +pub fn panose_distance(a: &[u8; 10], b: &[u8; 10]) -> Option<u32> { + let pairs: Vec<(u8, u8)> = a.iter().zip(b).skip(1).filter(|(x, y)| **x > 1 && **y > 1).map(|(x, y)| (*x, *y)).collect(); + if pairs.is_empty() { + return None; + } + Some(pairs.iter().map(|(x, y)| u32::from(x.abs_diff(*y))).sum::<u32>() * 9 / pairs.len() as u32) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classes_come_from_panose_then_the_ibm_class_then_the_name() { + let latin = |serif: u8, proportion: u8| [2, serif, 5, proportion, 5, 0, 0, 0, 0, 0]; + assert_eq!(classify(&latin(2, 3), 0, false, "Garamond"), FontClass::Serif); + assert_eq!(classify(&latin(6, 3), 0, false, "Rockwell"), FontClass::SlabSerif); + assert_eq!(classify(&latin(11, 3), 0, false, "Inter"), FontClass::SansSerif); + assert_eq!(classify(&latin(11, 9), 0, false, "Inter"), FontClass::Monospaced, "proportion 9"); + assert_eq!(classify(&[3, 0, 0, 0, 0, 0, 0, 0, 0, 0], 0, false, "Brush"), FontClass::Script); + assert_eq!(classify(&[0; 10], 8, false, "Untitled"), FontClass::SansSerif, "IBM class 8"); + assert_eq!(classify(&[0; 10], 0, true, "Untitled"), FontClass::Monospaced, "post.isFixedPitch"); + assert_eq!(classify(&[0; 10], 0, false, "JetBrains Mono"), FontClass::Monospaced); + assert_eq!(classify(&[0; 10], 0, false, "Roboto Slab"), FontClass::SlabSerif); + assert_eq!(classify(&[0; 10], 0, false, "Plain"), FontClass::Other); + } + + #[test] + fn bundled_fonts_are_classified() { + let db = crate::FontDb::global(); + for (family, class) in [ + ("Source Serif 4", FontClass::Serif), + ("Source Sans 3", FontClass::SansSerif), + ("Inter", FontClass::SansSerif), + ("JetBrains Mono", FontClass::Monospaced), + ] { + let kind = db.face(family, "Regular").kind(); + assert_eq!(kind.class, class, "{family}"); + assert_eq!(kind.format, FontFormat::TrueType, "{family}"); + } + let serif = db.face("Source Serif 4", "Regular").kind().panose; + let sans = db.face("Source Sans 3", "Regular").kind().panose; + let serif_bold = db.face("Source Serif 4", "Bold").kind().panose; + if let (Some(near), Some(far)) = (panose_distance(&serif, &serif_bold), panose_distance(&serif, &sans)) { + assert!(near < far, "the bold serif is nearer the serif than the sans is ({near} vs {far})"); + } + } + + #[test] + fn panose_distance_counts_shared_digits() { + let a = [2, 2, 5, 3, 5, 4, 5, 2, 2, 4]; + assert_eq!(panose_distance(&a, &a), Some(0)); + let b = [2, 11, 5, 3, 5, 4, 5, 2, 2, 4]; + assert!(panose_distance(&a, &b).is_some_and(|d| d > 0)); + assert_eq!(panose_distance(&a, &[0; 10]), None, "no PANOSE: nothing to compare"); + } +} diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index c1d744780..dd4583701 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -9,6 +9,7 @@ mod fontdb; mod group; +mod kind; #[cfg(any(test, feature = "testing"))] pub mod testing; mod vertical; @@ -20,6 +21,7 @@ pub use fontdb::{ pub use group::{FamilyInfo, FontGroup, sort_for_menu}; pub use harfrust::Feature; use harfrust::{Direction, Language, ShapeOptions, Tag, UnicodeBuffer}; +pub use kind::{FaceKind, FontClass, FontFormat, classify, panose_distance}; pub use kurbo::BezPath; use skrifa::MetadataProvider; use skrifa::instance::Size; From 6baa57b503009b10927c563f5d454a1438dfbe06 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Mon, 12 Oct 2026 00:13:57 +0200 Subject: [PATCH 068/238] feat(fonts): A family's kind without keeping it loaded ScopedFonts::family_kind reads a family's format, class, PANOSE and file date from its Regular face. A loaded face is asked; an installed family that isn't loaded is read from its file, which is let go afterwards, instead of loading every family the font menu's filters look at for the rest of the session. Files over MAX_DOCUMENT_FONT_BYTES are skipped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/fonts/src/fontdb.rs | 37 ++++++++++++++++++++++++++++++ crates/fonts/src/tests_sysfonts.rs | 17 ++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 079c0f1b1..1ccc8d076 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -1306,6 +1306,43 @@ impl ScopedFonts<'_> { None } + /// `family`'s kind (format, class, PANOSE) and when its file was added, read from its Regular + /// face (else its first), for the font menus' filters, which look at every family. A face + /// already loaded is asked; an installed family that isn't loaded is read from its file, which + /// is then let go rather than kept loaded. None for an unknown family or an unreadable file. + pub fn family_kind(&self, family: &str) -> Option<(crate::kind::FaceKind, Option<std::time::SystemTime>)> { + let styles = self.styles(family); + let style = styles.iter().find(|s| *s == "Regular").or(styles.first())?.clone(); + if self.own().iter().chain(self.db.read_faces().iter()).any(|f| f.family.eq_ignore_ascii_case(family)) { + let f = self.find(family, &style)?; + return Some((f.kind(), f.added())); + } + #[cfg(not(target_arch = "wasm32"))] + { + let path = { + let cat = self.db.read_catalog(); + let of_family = |c: &&CatalogEntry| c.family.eq_ignore_ascii_case(family); + let entry = cat.iter().filter(of_family).find(|c| norm(&c.style) == norm(&style)).or_else(|| cat.iter().find(of_family)); + entry.map(|c| c.path.clone())? + }; + if std::fs::metadata(&path).ok()?.len() > MAX_DOCUMENT_FONT_BYTES { + return None; + } + let data = Arc::new(std::fs::read(&path).ok()?); + let found = enumerate_faces(&data); + let pick = found + .iter() + .find(|(_, f, st, _)| f.eq_ignore_ascii_case(family) && norm(st) == norm(&style)) + .or_else(|| found.iter().find(|(_, f, _, _)| f.eq_ignore_ascii_case(family)))? + .clone(); + let (index, fam, st, coords) = pick; + let face = make_face(FontBytes::Owned(data.clone()), FontSource::Installed(path), index, fam, st, coords)?; + Some((face.kind(), face.added())) + } + #[cfg(target_arch = "wasm32")] + None + } + /// Is `family` available (the document's, loaded, or installed on the system), as named or /// without a format suffix (see [`ScopedFonts::face`])? pub fn has_family(&self, family: &str) -> bool { diff --git a/crates/fonts/src/tests_sysfonts.rs b/crates/fonts/src/tests_sysfonts.rs index a9cda7cdc..ca82ad4d7 100644 --- a/crates/fonts/src/tests_sysfonts.rs +++ b/crates/fonts/src/tests_sysfonts.rs @@ -126,6 +126,23 @@ fn a_link_back_to_a_parent_folder_ends_the_scan() { assert_eq!(db.load_system_fonts(), 2); } +#[test] +fn a_familys_kind_is_read_without_keeping_it_loaded() { + let dir = font_dir("kind"); + let db = FontDb::with_font_dirs(vec![dir]); + db.load_system_fonts(); + let fonts = db.scoped(0); + let (kind, added) = fonts.family_kind(FAMILY).expect("an installed family"); + assert_eq!(kind.class, crate::kind::FontClass::SansSerif); + assert!(added.is_some(), "the file's date"); + assert!(!db.is_loaded(FAMILY), "the file was read and let go"); + // A loaded family is asked directly, with the same answer. + let _ = fonts.face(FAMILY, "Regular"); + assert!(db.is_loaded(FAMILY)); + assert_eq!(fonts.family_kind(FAMILY).map(|(k, _)| k.class), Some(crate::kind::FontClass::SansSerif)); + assert!(fonts.family_kind("No Such Family").is_none()); +} + #[test] fn the_scan_reads_names_without_loading_the_font() { let dir = font_dir("names"); From ef874284815cbebecce851319b3a8b7a28fc93ad Mon Sep 17 00:00:00 2001 From: moppeeer <339764117+moppeeer@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:41:01 +0200 Subject: [PATCH 069/238] feat(engine): Report the selected text; remember recently used fonts type.selectionAttrs reports the start of the selected text on one line (`selectedText`, up to 60 characters), for previews such as the font menu's Selected Text sample. The preferences keep `recentFonts`, the fonts last picked in the font menus. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/cmd/text.rs | 36 +++++++++++++++++++++++++++++++++-- crates/engine/src/lib.rs | 3 +++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 71d390d02..1e3272fe3 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -253,7 +253,7 @@ pub fn specs() -> Vec<CommandSpec> { has_text_or_frames, break_link ), - cmd!(query "type.selectionAttrs", "Selection Attributes", [], None, "{} → resolved character/paragraph attributes at the text selection", has_doc, selection_attrs), + cmd!(query "type.selectionAttrs", "Selection Attributes", [], None, "{} → resolved character/paragraph attributes at the text selection, and its start as `selectedText` (one line, up to 60 characters)", has_doc, selection_attrs), ] } @@ -970,6 +970,9 @@ fn open_type(s: &mut Session, p: &Value) -> Result<Value> { Ok(json!({"features": list, "figures": otf::figures(&list)})) } +/// How much of the selected text `type.selectionAttrs` reports. +const SELECTED_TEXT_CHARS: usize = 60; + fn selection_attrs(s: &mut Session, _p: &Value) -> Result<Value> { let st = s.doc()?; let targets = format_targets(s); @@ -980,8 +983,17 @@ fn selection_attrs(s: &mut Session, _p: &Value) -> Result<Value> { let (pp, base) = st.doc.styles.resolve_para(pf); let cf = if r.is_empty() { story.char_format_at(r.start) } else { story.format_after(r.start) }; let cp = st.doc.styles.resolve_char(&base, cf); + // The selected text (its start, on one line), for previews such as the font menu's. + let line: String = story + .text + .get(r.start..r.end) + .unwrap_or_default() + .chars() + .map(|c| if c.is_control() || c == designcraft_doc::OBJECT_MARK { ' ' } else { c }) + .collect(); + let selected: String = line.trim_start().chars().take(SELECTED_TEXT_CHARS).collect(); Ok(json!({"story": sid.0, "paragraphStyle": pf.style, "characterStyle": cf.style, "para": pp, "chars": cp, - "paraOverrides": pf.para.count(), "charOverrides": cf.over.count()})) + "paraOverrides": pf.para.count(), "charOverrides": cf.over.count(), "selectedText": selected.trim()})) } /// Our own filler text (not Adobe's). @@ -1154,6 +1166,26 @@ mod open_type_tests { s.execute("text.move", &json!({"dir": "right"})).unwrap(); } + #[test] + fn selection_attrs_report_the_selected_text() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s + .execute( + "frame.create", + &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Spring issue +What's new in typography this season, and why it matters to every designer"}), + ) + .unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 13})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["selectedText"], "Spring issue", "on one line, trimmed"); + s.execute("text.select", &json!({"story": r["story"], "anchor": 13, "focus": 200})).unwrap(); + let long = s.execute("type.selectionAttrs", &json!({})).unwrap()["selectedText"].as_str().unwrap().to_string(); + assert!(long.starts_with("What's new") && (55..=60).contains(&long.chars().count()), "the line break skipped, capped: {long}"); + s.execute("text.select", &json!({"story": r["story"], "anchor": 3, "focus": 3})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["selectedText"], "", "a caret selects nothing"); + } + #[test] fn open_type_toggles_reach_the_text() { let mut s = Session::new(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 164719305..061165cb4 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -179,6 +179,8 @@ pub struct Prefs { pub rich_black_output: bool, /// Font menu favourites (family names). pub favorite_fonts: Vec<String>, + /// Fonts picked in the font menus, most recent first (shown at the menus' top). + pub recent_fonts: Vec<String>, /// Preferences › Type › Show Font Names in English: the font menus show CJK families by their /// English names instead of their native ones. Documents store the English name either way. pub show_font_names_in_english: bool, @@ -235,6 +237,7 @@ impl Default for Prefs { highlight_substituted_fonts: true, rich_black_output: false, favorite_fonts: Vec::new(), + recent_fonts: Vec::new(), show_font_names_in_english: false, smart_text_reflow: true, autocorrect: false, From d6635f180a9e8e879514d4b7d9ed21b0256619f2 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Mon, 12 Oct 2026 18:22:03 +0200 Subject: [PATCH 070/238] fix(engine): The selected-text preview reads only the 60 characters it reports type.selectionAttrs copied the whole selection to a string before taking the first 60 characters, so selecting a long story copied all of it on every query. It now skips leading blanks and stops after 60 characters. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/cmd/text.rs | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 1e3272fe3..334dfad03 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -973,6 +973,14 @@ fn open_type(s: &mut Session, p: &Value) -> Result<Value> { /// How much of the selected text `type.selectionAttrs` reports. const SELECTED_TEXT_CHARS: usize = 60; +fn selected_text_preview(chars: impl Iterator<Item = char>) -> String { + chars + .map(|c| if c.is_control() || c == designcraft_doc::OBJECT_MARK { ' ' } else { c }) + .skip_while(|c| c.is_whitespace()) + .take(SELECTED_TEXT_CHARS) + .collect() +} + fn selection_attrs(s: &mut Session, _p: &Value) -> Result<Value> { let st = s.doc()?; let targets = format_targets(s); @@ -984,14 +992,7 @@ fn selection_attrs(s: &mut Session, _p: &Value) -> Result<Value> { let cf = if r.is_empty() { story.char_format_at(r.start) } else { story.format_after(r.start) }; let cp = st.doc.styles.resolve_char(&base, cf); // The selected text (its start, on one line), for previews such as the font menu's. - let line: String = story - .text - .get(r.start..r.end) - .unwrap_or_default() - .chars() - .map(|c| if c.is_control() || c == designcraft_doc::OBJECT_MARK { ' ' } else { c }) - .collect(); - let selected: String = line.trim_start().chars().take(SELECTED_TEXT_CHARS).collect(); + let selected = selected_text_preview(story.text.get(r.start..r.end).unwrap_or_default().chars()); Ok(json!({"story": sid.0, "paragraphStyle": pf.style, "characterStyle": cf.style, "para": pp, "chars": cp, "paraOverrides": pf.para.count(), "charOverrides": cf.over.count(), "selectedText": selected.trim()})) } @@ -1166,6 +1167,17 @@ mod open_type_tests { s.execute("text.move", &json!({"dir": "right"})).unwrap(); } + #[test] + fn selected_text_preview_stops_at_its_character_budget() { + let read = std::cell::Cell::new(0usize); + let prefix = ['\t', '\u{2003}', designcraft_doc::OBJECT_MARK]; + let chars = prefix.into_iter().chain(std::iter::repeat_n('字', 100_000)).inspect(|_| read.set(read.get() + 1)); + let preview = super::selected_text_preview(chars); + assert_eq!(preview, "字".repeat(super::SELECTED_TEXT_CHARS)); + assert_eq!(read.get(), prefix.len() + super::SELECTED_TEXT_CHARS, "the rest of a large selection is never copied or scanned"); + assert_eq!(super::selected_text_preview("A\nB".chars()), "A B"); + } + #[test] fn selection_attrs_report_the_selected_text() { let mut s = Session::new(); From 0ac04151c9a0ab63fa02bbe5b58a7fece07de20b Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Sun, 11 Oct 2026 20:12:02 +0200 Subject: [PATCH 071/238] fix(engine): Every command that writes story text reads CR as a paragraph return frame.create and text.insert turned \r and \r\n into paragraph returns, but story.replaceRange, story.setText, note.new, footnote and endnote text, type on a path and table cells kept the CR in the text, where it drew as a missing glyph and joined the paragraphs. They all use text_param now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 3 +++ crates/engine/src/cmd/editorial.rs | 2 +- crates/engine/src/cmd/endnotes.rs | 6 +++--- crates/engine/src/cmd/notes.rs | 6 +++--- crates/engine/src/cmd/object.rs | 5 +++++ crates/engine/src/cmd/path_type.rs | 2 +- crates/engine/src/cmd/table.rs | 2 +- crates/engine/src/cmd/text.rs | 4 ++-- 8 files changed, 19 insertions(+), 11 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4b7163c76..242413657 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,9 @@ Newest first. - **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change. - **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change. +- **2026-10-11** · `story.replaceRange`, `story.setText`, notes, footnotes, endnotes, type on a path and table cells read ` +` and ` +` as paragraph returns, as `frame.create` and `text.insert` already did. No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. diff --git a/crates/engine/src/cmd/editorial.rs b/crates/engine/src/cmd/editorial.rs index 6cee420ed..451c96110 100644 --- a/crates/engine/src/cmd/editorial.rs +++ b/crates/engine/src/cmd/editorial.rs @@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> { if ts.cell.is_some() { return Err(bad("note.new", "notes go in story text")); } - let text = str_param(p, "text").unwrap_or("").to_string(); + let text = super::text_param(p, "text"); let author = str_param(p, "author").unwrap_or("").to_string(); let at = ts.anchor.max(ts.focus); s.edit(|d, sel| { diff --git a/crates/engine/src/cmd/endnotes.rs b/crates/engine/src/cmd/endnotes.rs index bdf9ca695..664b5c10f 100644 --- a/crates/engine/src/cmd/endnotes.rs +++ b/crates/engine/src/cmd/endnotes.rs @@ -4,7 +4,7 @@ use designcraft_doc::{ParaFormat, SpreadRef, StoryId}; use serde_json::{Value, json}; -use super::{CommandSpec, bad, cmd, has_doc, has_text, ok, str_param}; +use super::{CommandSpec, bad, cmd, has_doc, has_text, ok}; use crate::{Result, Session}; pub fn specs() -> Vec<CommandSpec> { @@ -20,7 +20,7 @@ pub fn specs() -> Vec<CommandSpec> { ), cmd!("endnote.edit", "Edit Endnote", [], None, "{story, id, text}", has_doc, |s, p| { let (sid, id) = target(p)?; - let text = str_param(p, "text").unwrap_or("").to_string(); + let text = super::text_param(p, "text"); s.edit(|d, _| { let style = d.endnote_options.para_style.clone(); let st = d.story_mut(sid).ok_or_else(|| bad("endnote.edit", "no such story"))?; @@ -103,7 +103,7 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> { if st.doc.endnote_story == Some(ts.story) { return Err(bad(ID, "the endnote frame lists the endnotes")); } - let text = str_param(p, "text").unwrap_or("").to_string(); + let text = super::text_param(p, "text"); let at = ts.anchor.max(ts.focus); s.edit(|d, sel| { // The endnote frame: a text frame in the margins of a new last page. diff --git a/crates/engine/src/cmd/notes.rs b/crates/engine/src/cmd/notes.rs index 4d7b0f13f..c17dc6cdb 100644 --- a/crates/engine/src/cmd/notes.rs +++ b/crates/engine/src/cmd/notes.rs @@ -3,7 +3,7 @@ use designcraft_doc::{CellAddr, FootnoteOptions, ParaFormat, StoryId, TextSel}; use serde_json::{Value, json}; -use super::{CommandSpec, bad, cmd, has_doc, str_param}; +use super::{CommandSpec, bad, cmd, has_doc}; use crate::{Result, Session}; pub fn specs() -> Vec<CommandSpec> { @@ -58,7 +58,7 @@ fn in_note(s: &Session) -> std::result::Result<(), String> { } fn insert(s: &mut Session, p: &Value) -> Result<Value> { - let text = str_param(p, "text").unwrap_or("").to_string(); + let text = super::text_param(p, "text"); s.edit(|d, sel| { let t = sel.text.ok_or_else(|| bad("footnote.insert", "no insertion point"))?; let para = ParaFormat { style: d.footnote_options.para_style.clone(), ..Default::default() }; @@ -130,7 +130,7 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> { fn set_text(s: &mut Session, p: &Value) -> Result<Value> { let sid = story_id(p, "footnote.setText")?; let id = note_id(p, "footnote.setText")?; - let text = str_param(p, "text").unwrap_or("").to_string(); + let text = super::text_param(p, "text"); s.edit(|d, _| { let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; let n = st.note_mut(id).ok_or_else(|| bad("footnote.setText", format!("no footnote {id}")))?; diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index cd13be117..62f52acaa 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -2604,6 +2604,11 @@ mod line_end_tests { s.execute("text.insert", &json!({"text": "\rFour", "raw": true})).unwrap(); assert_eq!(story(&s).text, "One\nTwo\nThree\nFour"); assert_eq!(story(&s).paras.len(), 4); + // Editing a range or the whole story (scripts written for InDesign end paragraphs with \r). + s.execute("story.replaceRange", &json!({"story": sid.0, "start": 0, "end": 3, "text": "Zero\rOne"})).unwrap(); + assert_eq!((story(&s).text.as_str(), story(&s).paras.len()), ("Zero\nOne\nTwo\nThree\nFour", 5)); + s.execute("story.setText", &json!({"story": sid.0, "text": "A\r\nB\rC"})).unwrap(); + assert_eq!((story(&s).text.as_str(), story(&s).paras.len()), ("A\nB\nC", 3)); } } diff --git a/crates/engine/src/cmd/path_type.rs b/crates/engine/src/cmd/path_type.rs index 77bbf87ab..6d0da227d 100644 --- a/crates/engine/src/cmd/path_type.rs +++ b/crates/engine/src/cmd/path_type.rs @@ -36,7 +36,7 @@ fn align_param(p: &Value) -> Result<Option<PathAlign>> { fn on_path(s: &mut Session, p: &Value) -> Result<Value> { let id = super::id_param(p, "id").ok_or_else(|| bad("type.onPath", "missing id"))?; - let text = super::str_param(p, "text").unwrap_or("").to_string(); + let text = super::text_param(p, "text"); let mut pt = PathType { start: p.get("start").and_then(Value::as_f64).unwrap_or(0.0).max(0.0), flip: p.get("flip").and_then(Value::as_bool).unwrap_or(false), diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index c2313170a..9a1cffc10 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -645,7 +645,7 @@ fn set_cell(s: &mut Session, p: &Value) -> Result<Value> { Some(o) => return Err(bad("table.setCell", format!("unknown vj `{o}`"))), None => None, }; - let text = str_param(p, "text").map(str::to_string); + let text = str_param(p, "text").map(|_| super::text_param(p, "text")); let stroke = p.get("stroke").cloned(); edit_table(s, &g, "table.setCell", |t| { let owners = t.owners(); diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 71d390d02..6eafa2333 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -76,7 +76,7 @@ pub fn specs() -> Vec<CommandSpec> { ), cmd!("story.setText", "Set Story Text", [], None, "{story, text} — replace a story's whole text", has_doc, |s, p| { let sid = StoryId(p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("story.setText", "missing story"))?); - let text = str_param(p, "text").unwrap_or("").to_string(); + let text = super::text_param(p, "text"); s.edit(|d, sel| { d.set_story_text(sid, &text)?; if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid) { @@ -97,7 +97,7 @@ pub fn specs() -> Vec<CommandSpec> { 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 text = str_param(p, "text").unwrap_or("").to_string(); + 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()))); From e0cf54ec2b06414c88b30f6d29ea7d2498347ec9 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Mon, 12 Oct 2026 00:00:30 +0200 Subject: [PATCH 072/238] fix(engine): note.edit reads CR as a paragraph return too note.edit was the one story-text command still reading `text` without text_param. The progress-log line held real CR and CRLF characters where it meant to show the escapes; it reads `\r` and `\r\n` now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 1 + crates/engine/src/cmd/editorial.rs | 2 +- crates/engine/src/cmd/object.rs | 7 +++++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 242413657..6e41e1727 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Newest first. - **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change. - **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change. +- **2026-10-11** · `story.replaceRange`, `story.setText`, notes, footnotes, endnotes, type on a path and table cells read `\r` and `\r\n` as paragraph returns, as `frame.create` and `text.insert` already did. No readiness percentage change. - **2026-10-11** · `story.replaceRange`, `story.setText`, notes, footnotes, endnotes, type on a path and table cells read ` ` and ` ` as paragraph returns, as `frame.create` and `text.insert` already did. No readiness percentage change. diff --git a/crates/engine/src/cmd/editorial.rs b/crates/engine/src/cmd/editorial.rs index 451c96110..6bb73176b 100644 --- a/crates/engine/src/cmd/editorial.rs +++ b/crates/engine/src/cmd/editorial.rs @@ -36,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> { }), cmd!("note.edit", "Edit Note", [], None, "{story, id, text?, author?}", has_doc, |s, p| { let (sid, id) = target(p)?; - let (text, author) = (str_param(p, "text").map(str::to_string), str_param(p, "author").map(str::to_string)); + let (text, author) = (str_param(p, "text").map(|_| super::text_param(p, "text")), str_param(p, "author").map(str::to_string)); s.edit(|d, _| { let st = d.story_mut(sid).ok_or_else(|| bad("note.edit", "no such story"))?; let n = st.editorial.iter_mut().find(|n| n.id == id).ok_or_else(|| bad("note.edit", format!("no note {id}")))?; diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 62f52acaa..51699514a 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -2609,6 +2609,13 @@ mod line_end_tests { assert_eq!((story(&s).text.as_str(), story(&s).paras.len()), ("Zero\nOne\nTwo\nThree\nFour", 5)); s.execute("story.setText", &json!({"story": sid.0, "text": "A\r\nB\rC"})).unwrap(); assert_eq!((story(&s).text.as_str(), story(&s).paras.len()), ("A\nB\nC", 3)); + // Notes, made and edited. + s.execute("text.select", &json!({"story": sid.0, "anchor": 1})).unwrap(); + let n = s.execute("note.new", &json!({"text": "One\rTwo"})).unwrap(); + let note = |s: &mut Session| s.execute("note.list", &json!({})).unwrap()[0]["text"].clone(); + assert_eq!(note(&mut s), "One\nTwo"); + s.execute("note.edit", &json!({"story": n["story"], "id": n["id"], "text": "Three\r\nFour"})).unwrap(); + assert_eq!(note(&mut s), "Three\nFour"); } } From 3651bcb10b54900c6b022d4c1b8ec85d854993f8 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Mon, 12 Oct 2026 18:26:47 +0200 Subject: [PATCH 073/238] docs: Drop a duplicate progress-log line with raw line breaks Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 6e41e1727..8aee91dc9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -133,9 +133,6 @@ Newest first. - **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change. - **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change. - **2026-10-11** · `story.replaceRange`, `story.setText`, notes, footnotes, endnotes, type on a path and table cells read `\r` and `\r\n` as paragraph returns, as `frame.create` and `text.insert` already did. No readiness percentage change. -- **2026-10-11** · `story.replaceRange`, `story.setText`, notes, footnotes, endnotes, type on a path and table cells read ` -` and ` -` as paragraph returns, as `frame.create` and `text.insert` already did. No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. From 6880042c6a32dde23c3d9c560f1b36c204184ded Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Mon, 12 Oct 2026 14:57:09 +0200 Subject: [PATCH 074/238] fix(compose): Hyphenation Zone applies to the Single-line Composer only InDesign's Hyphenation Zone ("the white space allowed at the end of a line of unjustified text before hyphenation begins") applies only with the Single-line Composer. The Paragraph Composer, the default, weighs every break of the paragraph instead. DesignCraft applied the default 3p0 zone to both, so ragged paragraphs under the Paragraph Composer refused hyphens InDesign makes. In LYH Studio's poster tutorial the placeholder text breaks "aper-natem" and "mosticsia-tur"; ours ran a line longer. The breaker now gets the zone only for unjustified Single-line Composer paragraphs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 1 + crates/compose/src/lib.rs | 4 +++- crates/compose/src/tests.rs | 15 ++++++++++----- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4b7163c76..fa779dd7d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Newest first. - **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change. - **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change. +- **2026-10-12** · Hyphenation Zone applies to the Single-line Composer only, as in InDesign. With the default Paragraph Composer, ragged text hyphenates where InDesign does (aper-natem), giving the same line breaks. No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 3dd7c25b3..24004d2a9 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1477,7 +1477,9 @@ fn spacing_for(pp: &ParaProps, base_size: f64) -> Spacing { hyphen_penalty: 50.0 + 450.0 * pp.hyph_weight, hyphen_limit: pp.hyph_limit, ragged_stretch: base_size * 2.0, - hyph_zone: if pp.align.is_justified() { 0.0 } else { pp.hyph_zone }, + // The zone is the Single-line Composer's (unjustified text only), as in InDesign: the + // Paragraph Composer weighs every break of the paragraph instead. + hyph_zone: if pp.align.is_justified() || pp.composer != Composer::SingleLine { 0.0 } else { pp.hyph_zone }, optical: pp.optical_margin, korean_char_breaks: pp.korean_char_breaks, } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index f5d0113c1..a7a981447 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -288,11 +288,16 @@ fn hyphenation_zone_limits_ragged_hyphens() { let cs = compose_story(&d, sid, &ComposeOptions::default()); all_lines(&cs).iter().filter(|l| l.hyphenated).count() }; - for c in [designcraft_doc::Composer::Paragraph, designcraft_doc::Composer::SingleLine] { - let (free, zoned, huge) = (count(0.0, c), count(36.0, c), count(1000.0, c)); - // A huge zone leaves only words that start a line (no space to break at) hyphenable. - assert!(free > 0 && zoned <= free && huge * 4 <= free, "{c:?}: {free} {zoned} {huge}"); - } + // Single-line Composer: a huge zone leaves only words that start a line (no space to break + // at) hyphenable. + let c = designcraft_doc::Composer::SingleLine; + let (free, zoned, huge) = (count(0.0, c), count(36.0, c), count(1000.0, c)); + assert!(free > 0 && zoned <= free && huge * 4 <= free, "{c:?}: {free} {zoned} {huge}"); + // The zone is the Single-line Composer's only (InDesign): the Paragraph Composer hyphenates + // the same whatever it is. + let c = designcraft_doc::Composer::Paragraph; + let (free, zoned, huge) = (count(0.0, c), count(36.0, c), count(1000.0, c)); + assert!(free > 0 && zoned == free && huge == free, "{c:?}: {free} {zoned} {huge}"); } #[test] From 7ee66df165588af2db643128498a62be58852c0f Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Mon, 12 Oct 2026 10:58:17 +0200 Subject: [PATCH 075/238] feat(ui): Step and Repeat dialog with Create as a grid and Preview Edit > Step and Repeat opened the generic command form (Count 1..1000, Dx, Dy, Rows, Columns, Ids). It now has InDesign's dialog: Repeat (Count, or Rows and Columns when Create as a grid is on), Offset (Vertical, Horizontal, in the document's ruler units or any unit typed) and Preview, which shows the copies on the page while the dialog is open. Cancel, Esc and ui.dialog.cancel take the preview away; OK makes the copies once, as one undo step. A value that isn't a number keeps the dialog open and says so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 1 + crates/ui-egui/src/control.rs | 3 +- crates/ui-egui/src/dialogs.rs | 218 ++++++++++++++++++++++++++++++- crates/ui-egui/src/i18n.rs | 8 ++ crates/ui-egui/src/i18n/ar.rs | 8 ++ crates/ui-egui/src/i18n/it.rs | 8 ++ crates/ui-egui/src/i18n/ja.rs | 5 + crates/ui-egui/src/i18n/pt_br.rs | 8 ++ crates/ui-egui/src/i18n/uk.rs | 8 ++ docs/target-app-parity.md | 2 +- 10 files changed, 262 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4b7163c76..9622e24cc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Newest first. - **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change. - **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change. +- **2026-10-12** · Edit › Step and Repeat has its InDesign dialog: Repeat (Count, or Rows and Columns with Create as a grid), Offset (Vertical, Horizontal, in ruler units) and Preview, which shows the copies while the dialog is open; Cancel takes them away and OK makes them as one undo step. No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 12df640da..378bb5cba 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -302,8 +302,7 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> } "ui.dialog.confirm" => wrap(crate::dialogs::confirm(app)), "ui.dialog.cancel" => { - app.cancel_pdf_import(); - app.ui.dialog = None; + crate::dialogs::cancel(app); ok(Value::Null) } "ui.resize" => { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 14d2552e8..695a66af1 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1656,6 +1656,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { ui.end_row(); }); } + STEP_AND_REPEAT => step_and_repeat_dialog(app, ui, &mut d), id if id.starts_with("cmd:") => command_form(app, ui, &mut d), "goToPage" => { ui.horizontal(|ui| { @@ -1733,14 +1734,20 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { Some(true) => { let _ = confirm(app); } - Some(false) => { - app.ui.dialog = None; - app.cancel_pdf_import(); - } + Some(false) => cancel(app), None => {} } } +/// Close the open dialog without applying it (Cancel, Esc, the control channel): a previewing +/// dialog puts the document back as it was. +pub fn cancel(app: &mut DesignApp) { + if app.ui.dialog.take().is_some_and(|d| d.fields.contains_key(PREVIEWED)) { + app.session.cancel_interaction(); + } + app.cancel_pdf_import(); +} + /// Apply the open dialog. pub fn confirm(app: &mut DesignApp) -> Result<Value, String> { let Some(d) = app.ui.dialog.take() else { return Err("no dialog open".into()) }; @@ -2167,6 +2174,20 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> { "alert" => Ok(Value::Null), "findChange" if d.b("objectMode") => Ok(Value::Null), "findChange" => app.run("find.change", json!({"find": d.s("find"), "change": d.s("change"), "grep": d.b("grep"), "caseSensitive": d.b("caseSensitive"), "wholeWord": d.b("wholeWord"), "scope": d.s("scope")})), + STEP_AND_REPEAT => { + // The preview goes; the copies are made once, as one undo step. + if d.fields.contains_key(PREVIEWED) { + app.session.cancel_interaction(); + } + let r = step_and_repeat_params(app, &d).and_then(|p| app.run("edit.stepAndRepeat", p)); + if let Err(e) = &r { + let mut d = d.clone(); + d.fields.remove(PREVIEWED); + d.fields.insert("status".into(), json!(e)); + app.ui.dialog = Some(d); + } + r + } id if id.starts_with("cmd:") => { let cid = &id[4..]; let doc = designcraft_engine::find_command(cid).map_or("", |c| c.params); @@ -3558,6 +3579,120 @@ fn command_form(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { } } +const STEP_AND_REPEAT: &str = "cmd:edit.stepAndRepeat"; +/// The params a previewing dialog shows on the page (its interaction is open while it is set). +const PREVIEWED: &str = "_previewed"; + +/// Edit › Step and Repeat, as in InDesign: Repeat (Count, or Rows and Columns with Create as a +/// grid), Offset (Vertical, Horizontal, in the document's ruler units) and Preview, which shows +/// the copies on the page while the dialog is open. +fn step_and_repeat_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + let (hu, vu) = app.session.active().map_or((Unit::Picas, Unit::Picas), |s| (s.doc.settings.horizontal_units, s.doc.settings.vertical_units)); + for (k, v) in [ + ("count", json!("1")), + ("rows", json!("1")), + ("columns", json!("1")), + ("vertical", json!(format_measure(12.0, vu))), + ("horizontal", json!(format_measure(12.0, hu))), + ] { + d.fields.entry(k).or_insert(v); + } + let lang = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang, s).to_string(); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + crate::rtl::label(ui, egui::RichText::new(tr("Repeat")).font(semibold(12.0))); + egui::Grid::new("sr_repeat").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + let grid = d.b("grid"); + let fields: &[(&str, &str)] = if grid { &[("rows", "Rows:"), ("columns", "Columns:")] } else { &[("count", "Count:")] }; + for (k, l) in fields { + crate::rtl::label(ui, tr(l)); + text_field(ui, d, k, 70.0); + ui.end_row(); + } + crate::rtl::label(ui, ""); + check(ui, d, "grid", &tr("Create as a grid")); + ui.end_row(); + }); + ui.add_space(10.0); + crate::rtl::label(ui, egui::RichText::new(tr("Offset")).font(semibold(12.0))); + ui.horizontal(|ui| { + for (k, l) in [("vertical", "Vertical:"), ("horizontal", "Horizontal:")] { + crate::rtl::label(ui, tr(l)); + text_field(ui, d, k, 80.0); + ui.add_space(12.0); + } + }); + }); + ui.add_space(32.0); + check(ui, d, "preview", &tr("Preview")); + }); + step_and_repeat_preview(app, d); + if let Some(st) = d.fields.get("status").and_then(Value::as_str) { + ui.add_space(6.0); + crate::rtl::label(ui, egui::RichText::new(st).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); + } +} + +/// `edit.stepAndRepeat`'s params from the dialog: whole counts, offsets in the ruler units +/// (or any unit typed: "-0.75 in", "3p"). Empty fields are 1 copy and 1p0. +fn step_and_repeat_params(app: &DesignApp, d: &Dialog) -> Result<Value, String> { + let (hu, vu) = app.session.active().map_or((Unit::Picas, Unit::Picas), |s| (s.doc.settings.horizontal_units, s.doc.settings.vertical_units)); + let invalid = |s: &str| format!("{}: {}", crate::i18n::tr(&app.ui.language, "Invalid value"), s.trim()); + // Fields not shown yet (a dialog confirmed over the control channel) take the defaults. + let whole = |k: &str| { + let s = d.s(k); + if s.trim().is_empty() { Ok(1) } else { s.trim().parse::<u64>().map_err(|_| invalid(&s)) } + }; + let measure = |k: &str, u: Unit| { + let s = d.s(k); + if s.trim().is_empty() { + return Ok(12.0); + } + parse_measure(&s, u).ok().filter(|v| v.is_finite()).ok_or_else(|| invalid(&s)) + }; + let (dx, dy) = (measure("horizontal", hu)?, measure("vertical", vu)?); + Ok(if d.b("grid") { + json!({"rows": whole("rows")?, "columns": whole("columns")?, "dx": dx, "dy": dy}) + } else { + json!({"count": whole("count")?, "dx": dx, "dy": dy}) + }) +} + +/// Preview on: the copies show on the page (redone when a field changes); off: they go. +fn step_and_repeat_preview(app: &mut DesignApp, d: &mut Dialog) { + let shown = d.fields.get(PREVIEWED).cloned(); + if !d.b("preview") { + if shown.is_some() { + app.session.cancel_interaction(); + d.fields.remove(PREVIEWED); + d.fields.remove("status"); + } + return; + } + let params = match step_and_repeat_params(app, d) { + Ok(p) => p, + Err(e) => { + d.fields.insert("status".into(), json!(e)); + return; + } + }; + if shown.as_ref() == Some(¶ms) { + return; + } + if shown.is_none() + && let Err(e) = app.session.begin_interaction("Step and Repeat") + { + d.fields.insert("status".into(), json!(e.to_string())); + return; + } + match app.session.preview("edit.stepAndRepeat", params.clone()) { + Ok(()) => d.fields.remove("status"), + Err(e) => d.fields.insert("status".into(), json!(e.to_string())), + }; + d.fields.insert(PREVIEWED.into(), params); +} + /// New Cross-Reference: link to a paragraph (paragraph styles on the left, their paragraphs on /// the right) or a text anchor, with a cross-reference format. fn insert_xref(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { @@ -3744,6 +3879,81 @@ fn find_font(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod tests { use super::*; + #[test] + fn step_and_repeat_dialog_previews_and_makes_the_copies_as_one_undo_step() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 600, 540, 640]})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + let open = |app: &mut DesignApp| { + crate::menus::activate(app, "edit.stepAndRepeat", &Value::Null); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some(STEP_AND_REPEAT)); + }; + let set = |app: &mut DesignApp, k: &str, v: Value| { + app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v); + }; + let items = |app: &DesignApp| app.session.doc().unwrap().doc.spreads[0].items.len(); + let undo = |app: &DesignApp| app.session.doc().unwrap().history.undo.len(); + let (n0, u0) = (items(&app), undo(&app)); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0)); + open(&mut app); + dialog_frame(&mut app, &ctx, screen, vec![]); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + for expected in ["Step and Repeat", "Repeat", "Count:", "Create as a grid", "Offset", "Vertical:", "Horizontal:", "Preview", "OK", "Cancel"] { + visible_label(&labels, screen, expected); + } + // Preview: the copies show, and change with the fields, without an undo step. + set(&mut app, "count", json!("5")); + set(&mut app, "vertical", json!("-0.75 in")); + set(&mut app, "horizontal", json!("0")); + set(&mut app, "preview", json!(true)); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert_eq!((items(&app), undo(&app)), (n0 + 5, u0)); + set(&mut app, "count", json!("3")); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert_eq!(items(&app), n0 + 3); + set(&mut app, "preview", json!(false)); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert_eq!(items(&app), n0, "Preview off takes them away"); + set(&mut app, "preview", json!(true)); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert_eq!(items(&app), n0 + 3); + // Cancel puts the document back. + cancel(&mut app); + assert!(app.ui.dialog.is_none()); + assert_eq!((items(&app), undo(&app)), (n0, u0)); + // OK after a preview makes the copies once, as one undo step, 0.75 in apart. + app.run("selection.set", json!({"ids": [id]})).unwrap(); + open(&mut app); + set(&mut app, "count", json!("2")); + set(&mut app, "vertical", json!("-0.75 in")); + set(&mut app, "horizontal", json!("0")); + set(&mut app, "preview", json!(true)); + dialog_frame(&mut app, &ctx, screen, vec![]); + let r = confirm(&mut app).unwrap(); + assert_eq!((items(&app), undo(&app)), (n0 + 2, u0 + 1)); + let last = r["ids"][1].as_u64().unwrap(); + let top = app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(last)).unwrap().bounds().min_y(); + assert!((top - (600.0 - 108.0)).abs() < 1e-6, "{top}"); + // Create as a grid: Rows × Columns cells, the original being one. + app.run("selection.set", json!({"ids": [id]})).unwrap(); + open(&mut app); + set(&mut app, "grid", json!(true)); + set(&mut app, "rows", json!("2")); + set(&mut app, "columns", json!("3")); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + visible_label(&labels, screen, "Rows:"); + visible_label(&labels, screen, "Columns:"); + assert_eq!(confirm(&mut app).unwrap()["created"], 5); + // A value that isn't a measure keeps the dialog open and says so. + open(&mut app); + set(&mut app, "vertical", json!("up")); + assert!(confirm(&mut app).is_err()); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!(d.s("status"), "Invalid value: up"); + } + fn dialog_context(language: &str) -> egui::Context { let ctx = egui::Context::default(); crate::theme::install_fonts(&ctx, language); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 5976d7d07..473f02205 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -739,6 +739,14 @@ const TABLE: &[(&str, [&str; 5])] = &[ ), ("Apply Cell Style", ["Apply Cell Style", "Apply Cell Style", "Apply Cell Style", "Apply Cell Style", "应用单元格样式"]), ("Apply Color", ["Apply Color", "Apply Color", "Apply Color", "Apply Color", "应用颜色"]), + ("Repeat", ["Wiederholen", "Répéter", "Repetir", "繰り返し", "重复"]), + ("Count:", ["Anzahl:", "Nombre :", "Recuento:", "繰り返し数:", "计数:"]), + ("Create as a grid", ["Als Raster erstellen", "Créer en tant que grille", "Crear como cuadrícula", "グリッドとして作成", "创建为网格"]), + ("Rows:", ["Zeilen:", "Rangées :", "Filas:", "行:", "行:"]), + ("Columns:", ["Spalten:", "Colonnes :", "Columnas:", "列:", "列:"]), + ("Vertical:", ["Vertikal:", "Vertical :", "Vertical:", "垂直方向:", "垂直:"]), + ("Horizontal:", ["Horizontal:", "Horizontal :", "Horizontal:", "水平方向:", "水平:"]), + ("Invalid value", ["Ungültiger Wert", "Valeur non valide", "Valor no válido", "無効な値", "无效值"]), ("Apply Condition", ["Apply Condition", "Apply Condition", "Apply Condition", "Apply Condition", "应用条件"]), ( "Apply Parent to Pages…", diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index c3373486e..0382b3302 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -1041,6 +1041,14 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Formatting affects container (J)", "تطبيق التنسيق على الحاوية (J)"), ("Formatting affects text (J)", "تطبيق التنسيق على النص (J)"), ("Apply Color", "تطبيق اللون"), + ("Repeat", "تكرار"), + ("Count:", "العدد:"), + ("Create as a grid", "إنشاء كشبكة"), + ("Rows:", "الصفوف:"), + ("Columns:", "الأعمدة:"), + ("Vertical:", "رأسي:"), + ("Horizontal:", "أفقي:"), + ("Invalid value", "قيمة غير صالحة"), ("Apply None", "إزالة اللون"), ("Height Only", "الارتفاع فقط"), ("Width Only", "العرض فقط"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 34766cf22..562afe5d2 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -412,6 +412,14 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Apply Attributes (Eyedropper)", "Applica attributi (Contagocce)"), ("Apply Cell Style", "Applica stile di cella"), ("Apply Color", "Applica colore"), + ("Repeat", "Ripeti"), + ("Count:", "Conteggio:"), + ("Create as a grid", "Crea come griglia"), + ("Rows:", "Righe:"), + ("Columns:", "Colonne:"), + ("Vertical:", "Verticale:"), + ("Horizontal:", "Orizzontale:"), + ("Invalid value", "Valore non valido"), ("Apply Condition", "Applica condizione"), ("Apply Parent to Pages…", "Applica pagina mastro alle pagine…"), ("ArtCraft App Page…", "Pagina dell'app ArtCraft…"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index de57db5e9..9a1aff485 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -414,6 +414,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Apply Attributes (Eyedropper)", "属性を適用 (スポイト)"), ("Apply Cell Style", "セルスタイルを適用"), ("Apply Color", "カラーを適用"), + ("Repeat", "繰り返し"), + ("Count:", "繰り返し数:"), + ("Create as a grid", "グリッドとして作成"), + ("Rows:", "行:"), + ("Invalid value", "無効な値"), ("Apply Condition", "条件を適用"), ("Apply Parent to Pages…", "ページに親ページを適用…"), ("ArtCraft App Page…", "ArtCraft アプリページ…"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index a4d06bbe9..465bbd627 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -1045,6 +1045,14 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Formatting affects container (J)", "Formatação afeta o contêiner (J)"), ("Formatting affects text (J)", "Formatação afeta o texto (J)"), ("Apply Color", "Aplicar cor"), + ("Repeat", "Repetir"), + ("Count:", "Contagem:"), + ("Create as a grid", "Criar como grade"), + ("Rows:", "Linhas:"), + ("Columns:", "Colunas:"), + ("Vertical:", "Vertical:"), + ("Horizontal:", "Horizontal:"), + ("Invalid value", "Valor inválido"), ("Apply None", "Aplicar nenhum"), ("Height Only", "Somente altura"), ("Width Only", "Somente largura"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 9b70204a4..68c438edb 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -415,6 +415,14 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Apply Attributes (Eyedropper)", "Застосувати атрибути (піпетка)"), ("Apply Cell Style", "Застосувати стиль клітинки"), ("Apply Color", "Застосувати колір"), + ("Repeat", "Повторення"), + ("Count:", "Кількість:"), + ("Create as a grid", "Створити як сітку"), + ("Rows:", "Рядки:"), + ("Columns:", "Стовпці:"), + ("Vertical:", "Вертикально:"), + ("Horizontal:", "Горизонтально:"), + ("Invalid value", "Неприпустиме значення"), ("Apply Condition", "Застосувати умову"), ("Apply Parent to Pages…", "Застосувати шаблон до сторінок…"), ("ArtCraft App Page…", "Сторінка програми ArtCraft…"), diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5705ee738..83b277507 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -276,7 +276,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Tables (M8): tables anchored in stories (header/footer/body rows, merged cells, per-edge strokes, fills + alternating fills, insets, vertical justification, at-least/exact row heights); composed into the text column (columns scale to fit, rows break across columns/frames with repeating headers/footers, overset); rendered, exported to PDF (real text) and IDML (export + import); Table menu, Table panel, Create Table dialog, caret/typing/Tab navigation and cell selection in cells; `table.*` commands. - Hyperlinks (text or frames → URL / e-mail / page) and bookmarks, exported as PDF link annotations and outline. - EPUB 3 (reflowable) export: stories in reading order, CSS from paragraph/character styles, images, navigation (`file.exportEpub`, CLI `--export x.epub`). -- Data Merge (one linked CSV, TSV, text, or Excel file; text, image, QR, and hyperlink placeholders; preview; a new document with one record per page or records tiled on a single page), spell checking (public-domain Moby list + document dictionary, suggestions), Step and Repeat (count or grid), snippets, object styles, Numbering & Section Options. +- Data Merge (one linked CSV, TSV, text, or Excel file; text, image, QR, and hyperlink placeholders; preview; a new document with one record per page or records tiled on a single page), spell checking (public-domain Moby list + document dictionary, suggestions), Step and Repeat (count or grid, with a previewing dialog), snippets, object styles, Numbering & Section Options. - Footnotes: Type › Insert Footnote (caret moves into the note), Document Footnote Options (numbering style incl. symbols, start/restart per page/spread/section, prefix/suffix, reference position/character style, paragraph style, separator, spacing, first baseline, rule above); notes composed at the bottom of the referencing column with the body text making room; edited in place on the canvas; rendered, exported to PDF (real text) and IDML (InDesign's structure, verified opening in InDesign 2026) and imported from IDML; `footnote.*` commands. - Paragraph Rules: Rule Above / Rule Below (on, weight, colour, tint, column or text width, offset, left/right indent) set in Type › Paragraph Rules…, the Paragraph panel menu and Paragraph Style Options › Paragraph Rules; inherited style values shown, and `type.para` / `style.paragraph.edit` change only the rule fields named. - Cross-references and text anchors: Insert Cross-Reference (paragraph by style, or text anchor), 11 formats with InDesign's building blocks (full/partial paragraph, paragraph text/number, page number, anchor name, chapter, file name) plus user formats; resolved live at composition — never out of date, no Update step; unresolved destinations show `??`; PDF link annotations to the destination page; IDML export/import as InDesign cross-reference sources, hyperlinks, text destinations and formats (verified in InDesign 2026); `xref.*` / `anchor.*` commands. From 03d8b1e61b23b455c04aa2b51adcb28ad22bb557 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Mon, 12 Oct 2026 05:08:30 +0200 Subject: [PATCH 076/238] fix(ui): Double-clicking the Fill or Stroke box opens the Color Picker In InDesign a double-click on the Tools panel's Fill or Stroke box opens the Color Picker; DesignCraft opened the Swatches panel. It now opens the Color Picker for that box, with the selection's colour (the selected text's when text is selected). The Stroke box also reports its name to screen readers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 1 + crates/ui-egui/src/toolbar.rs | 49 +++++++++++++++++++++++++++++++++-- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4b7163c76..b72b1f25c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Newest first. - **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change. - **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change. +- **2026-10-12** · Double-clicking the Fill or Stroke box in the Tools panel opens the Color Picker with the selection's colour, as in InDesign (it opened the Swatches panel). No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 5a5e1f08a..ac2729695 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -272,16 +272,31 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) { crate::rtl::label(ui, fill_tip); }); fresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, fill_tip)); + let stroke_tip = crate::i18n::tr(&app.ui.language, "Stroke (X)"); let sresp = ui.interact(stroke_r.translate(vec2(0.0, 0.0)), ui.id().with("proxy_stroke"), Sense::click()).on_hover_ui(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Stroke (X)")); + crate::rtl::label(ui, stroke_tip); }); + sresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, stroke_tip)); if fresp.clicked() || sresp.clicked() { let front = if stroke_front { !fresp.clicked() } else { sresp.clicked() && !fill_r.contains(sresp.interact_pointer_pos().unwrap_or_default()) }; ui.data_mut(|d| d.insert_temp(stroke_front_id, front)); } + // A double-click opens the Color Picker for that box (as InDesign's), with the selection's + // colour: the selected text's when text is selected. if fresp.double_clicked() || sresp.double_clicked() { - let _ = app.run("window.panel", json!({"panel": "swatches"})); + let on_fill = fresp.double_clicked() && (!stroke_front || !sresp.double_clicked()); + let stroke = !on_fill; + let text = crate::panels::text_attrs(app).filter(|_| app.session.active().is_some_and(|d| d.selection.text.is_some())); + let current = match text { + Some(a) => a["chars"][if stroke { "stroke" } else { "fill" }].as_str().unwrap_or("[None]").to_string(), + None if stroke => stroke_sw.clone(), + None => fill_sw.clone(), + }; + let rgb = doc.as_ref().and_then(|d| d.resolve_color(¤t, 1.0)).map_or([0.0; 3], |c| c.to_rgb()); + let byte = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + let hex = format!("#{:02x}{:02x}{:02x}", byte(rgb[0]), byte(rgb[1]), byte(rgb[2])); + app.ui.dialog = Some(crate::dialogs::Dialog::new("colorPicker", json!({"target": if stroke { "stroke" } else { "fill" }, "hex": hex}))); } ui.add_space(3.0); // Formatting affects container / text. @@ -472,6 +487,36 @@ mod tests { assert!(!h.state().app.ui.tools_double_column); } + #[test] + fn double_clicking_the_fill_box_opens_the_color_picker_with_the_selections_colour() { + let mut app = app(false); + app.run("file.new", serde_json::json!({})).unwrap(); + let id = app.run("frame.create", serde_json::json!({"rect": [72, 72, 200, 200]})).unwrap()["id"].clone(); + app.run("object.color", serde_json::json!({"color": "#456daf", "ids": [id]})).unwrap(); + app.run("selection.set", serde_json::json!({"ids": [id]})).unwrap(); + // Frames 1 ms apart, so two clicks make a double-click. + let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 900.0)).with_step_dt(0.001).build_ui_state( + |ui, app: &mut crate::DesignApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(6); + let at = h.get_by_label(FILL).rect().center(); + h.hover_at(at); + h.step(); + for pressed in [true, false, true, false] { + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + h.step(); + } + h.run_steps(3); + let d = h.state().ui.dialog.clone().expect("the Color Picker opened"); + assert_eq!(d.id, "colorPicker"); + assert_eq!((d.fields["target"].as_str(), d.fields["hex"].as_str()), (Some("fill"), Some("#456daf")), "{:?}", d.fields); + } + #[test] fn a_tall_window_keeps_one_column_and_does_not_scroll() { let mut h = test_window::open(app(false), vec2(1400.0, 900.0)); From 8fd67c8674cb17acdef2eb5f6b4b2b915ee578b5 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Mon, 12 Oct 2026 00:34:51 +0200 Subject: [PATCH 077/238] =?UTF-8?q?feat(ui):=20Rotation,=20shear,=20scale,?= =?UTF-8?q?=20rotate=20and=20flip=20in=20Properties=20=E2=80=BA=20Transfor?= =?UTF-8?q?m?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InDesign's Properties › Transform shows, under X/Y/W/H, the rotation and shear angles beside the X and Y scale, then buttons to rotate a quarter turn either way and to flip horizontally or vertically. DesignCraft's showed only X/Y/W/H; the rest was in the Control bar and Object › Transform. The fields set totals through transform.set around the reference point; the buttons run transform.rotate and transform.flip. Rotate and flip icons are original drawings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 1 + crates/ui-egui/src/icons.rs | 23 +++++++ crates/ui-egui/src/panels/properties.rs | 91 +++++++++++++++++++++++++ 3 files changed, 115 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 4b7163c76..d5db7e85a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Newest first. - **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change. - **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change. +- **2026-10-12** · Properties › Transform shows rotation, shear and X/Y scale, and Rotate 90° and Flip Horizontal/Vertical buttons, as InDesign's does. No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 69415e9b3..dba5a7a7a 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -178,6 +178,29 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) { pen.line(&pts); pen.line(&[(13.0, 4.0), (15.5, 6.0), (16.5, 2.8)]); } + // Properties › Transform: rotate a quarter turn (an arc over a square), flip (a shape and its + // mirror image across a dashed axis). + "rotate-cw" | "rotate-ccw" => { + let m = |x: f32| if name == "rotate-ccw" { 20.0 - x } else { x }; + pen.rect(m(3.0).min(m(11.0)), 9.0, m(3.0).max(m(11.0)), 17.0); + let pts: Vec<(f32, f32)> = (0..12) + .map(|i| { + let a = std::f32::consts::PI * (1.0 - i as f32 / 11.0); + (m(11.0 + 5.0 * a.cos()), 9.0 - 5.0 * a.sin()) + }) + .collect(); + pen.line(&pts); + pen.line(&[(m(14.0), 7.0), (m(16.0), 9.5), (m(18.0), 7.0)]); + } + "flip-h" | "flip-v" => { + let t = |x: f32, y: f32| if name == "flip-v" { (y, x) } else { (x, y) }; + pen.fill(&[t(3.0, 16.0), t(8.5, 4.0), t(8.5, 16.0)]); + pen.closed(&[t(11.5, 16.0), t(11.5, 4.0), t(17.0, 16.0)]); + for k in 0..4 { + let y0 = 2.5 + k as f32 * 4.0; + pen.line(&[t(10.0, y0), t(10.0, y0 + 2.0)]); + } + } "tool-gradient" | "tool-gradient-feather" => { for i in 0..6 { let a = 30 + i * 35; diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 49cb920f2..17473fba0 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -460,11 +460,62 @@ fn transform_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { set.insert("ref".into(), json!(rp)); let _ = app.run("transform.set", Value::Object(set)); } + transform_angles(app, ui, rp); if more_options(ui, &app.ui.language).clicked() { app.ui.open_panel = Some("info".into()); } } +/// Properties › Transform, below X/Y/W/H as in InDesign: rotation and shear angles beside the +/// X and Y scales (totals, as Transformations are Totals), then rotate a quarter turn and flip. +fn transform_angles(app: &mut DesignApp, ui: &mut Ui, rp: u8) { + let t = Tokens::get(ui.ctx()); + let lang = app.ui.language.clone(); + let tv = app.session.execute("transform.info", &json!({})).ok(); + let tv_get = |k: &str| tv.as_ref().and_then(|v| v[k].as_f64()); + ui.add_space(4.0); + let b = block(ui, 2.0 * FIELD_H + 4.0); + let mut set = serde_json::Map::new(); + let rows = [ + [("tool-rotate", "Rotation Angle", "rotation", "°", "p_rot"), ("tool-scale", "Scale X Percentage", "scaleX", "%", "p_sx")], + [("tool-shear", "Shear X Angle", "shear", "°", "p_shear"), ("tool-scale", "Scale Y Percentage", "scaleY", "%", "p_sy")], + ]; + for (row, cells) in rows.into_iter().enumerate() { + let yy = row as f32 * 25.0; + for (col, (icon, tip, key, suffix, id)) in cells.into_iter().enumerate() { + let x = if col == 0 { 40.0 } else { 120.0 }; + let ir = sub(b, x, yy + 2.0, 17.0, 17.0); + icons::paint(ui.painter(), ir, icon, t.icon); + ui.interact(ir, ui.id().with(id), Sense::hover()).on_hover_text(crate::i18n::tr(&lang, tip)); + if let Some(v) = place(ui, sub(b, x + 19.0, yy, 54.0, FIELD_H), |ui| widgets::number(ui, id, tv_get(key), suffix, 54.0, 1)) { + set.insert(key.into(), json!(v)); + } + } + } + if !set.is_empty() { + set.insert("ref".into(), json!(rp)); + let _ = app.run("transform.set", Value::Object(set)); + } + ui.add_space(2.0); + ui.horizontal(|ui| { + ui.add_space(36.0); + ui.spacing_mut().item_spacing.x = 10.0; + for (icon, tip, cmd, params) in [ + ("rotate-cw", "Rotate 90° Clockwise", "transform.rotate", json!({"angle": -90})), + ("rotate-ccw", "Rotate 90° Counterclockwise", "transform.rotate", json!({"angle": 90})), + ("flip-h", "Flip Horizontal", "transform.flip", json!({"axis": "horizontal"})), + ("flip-v", "Flip Vertical", "transform.flip", json!({"axis": "vertical"})), + ] { + let label = crate::i18n::tr(&lang, tip); + let resp = icon_toggle(ui, icon, false, label); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label)); + if resp.clicked() { + let _ = app.run(cmd, params); + } + } + }); +} + /// A stroke-type preview dropdown (light box with the line style). fn stroke_type_dropdown(app: &mut DesignApp, ui: &mut Ui, cur: &str, w: f32) { const TYPES: &[(&str, &str)] = &[ @@ -2207,4 +2258,44 @@ mod tests { let after = h.state().session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().fill.swatch.clone(); assert_eq!(after, name, "clicking the swatch chip applies it"); } + + #[test] + fn transform_flips_and_rotates_from_properties() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 300, 200]})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + let mut h = Harness::builder().with_size(vec2(320.0, 1400.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let id = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::<bool>(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + show(app, ui); + }, + app, + ); + h.run_steps(3); + let xf = |h: &Harness<'_, DesignApp>| h.state().session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().xf; + // Flip Horizontal mirrors the frame (a negative determinant); Flip Vertical as well brings it + // back to a half turn, as in the tutorial's petal. + h.get_by_label("Flip Horizontal").click(); + h.run_steps(3); + assert!(xf(&h).determinant() < 0.0, "{:?}", xf(&h)); + h.get_by_label("Flip Vertical").click(); + h.run_steps(3); + let c = xf(&h).as_coeffs(); + assert!((c[0] + 1.0).abs() < 1e-9 && (c[3] + 1.0).abs() < 1e-9, "a half turn: {c:?}"); + let info = h.state_mut().session.execute("transform.info", &json!({})).unwrap(); + assert!((info["rotation"].as_f64().unwrap().abs() - 180.0).abs() < 1e-6, "{info}"); + // A quarter turn clockwise from there. + h.get_by_label("Rotate 90° Clockwise").click(); + h.run_steps(3); + let info = h.state_mut().session.execute("transform.info", &json!({})).unwrap(); + assert!((info["rotation"].as_f64().unwrap() - 90.0).abs() < 1e-6, "{info}"); + // The angle and scale fields are there. + assert!(h.query_by(|n| n.role() == egui::accesskit::Role::TextInput && n.value().as_deref() == Some("90°")).is_some()); + } } From 1dc33dd280d057f694b1d3661886069ddddead64 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Mon, 12 Oct 2026 00:29:17 +0200 Subject: [PATCH 078/238] fix(render): One underline bar per stretch of text, no seams The canvas drew an underline (and a strikethrough) as one rectangle per glyph; where two anti-aliased edges met at a fractional pixel they left a lighter seam between the letters. Contiguous bars of the same colour and position are merged into one, as the PDF export already does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 1 + crates/engine/tests/text_decorations.rs | 39 +++++++++++++++++++++++++ crates/render/src/text.rs | 20 +++++++++++-- 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4b7163c76..66083a13e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Newest first. - **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change. - **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change. +- **2026-10-12** · Underlines and strikethroughs are drawn as one bar per stretch of text on screen, as in PDF export: a bar per letter left faint seams between the letters. No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. diff --git a/crates/engine/tests/text_decorations.rs b/crates/engine/tests/text_decorations.rs index cd3388f76..c49c1d2cb 100644 --- a/crates/engine/tests/text_decorations.rs +++ b/crates/engine/tests/text_decorations.rs @@ -163,3 +163,42 @@ fn decoration_offsets_are_centers_for_explicit_and_automatic_rules() { } } } + +/// An underline is one bar per stretch of text: a bar per glyph left lighter seams between the +/// letters where the anti-aliased edges met (at fractional positions). +#[test] +fn an_underline_has_no_seams_between_letters() { + let mut d = Document::new(&NewDocument { width: 400.0, height: 120.0, facing_pages: false, ..Default::default() }); + let chars = CharAttrs { + font_family: Some("Source Sans 3".into()), + size: Some(48.0), + fill: Some("[Black]".into()), + underline: Some(true), + underline_weight: Some(Some(6.0)), + ..Default::default() + }; + d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 390.0, 110.0), d.default_layer(), "FERRUM", ParaFormat { chars, ..Default::default() }) + .unwrap(); + let mut renderer = Renderer::new(); + renderer.threads = 0; + let placed = [Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }]; + let options = RenderOptions { paper: false, background: Some([255; 4]), greek_below_px: 0.0, ..Default::default() }; + // A fractional scale puts the letters' edges between pixels. + let (w, h) = (543u32, 163u32); + let img = renderer.render(&d, &Cache::new(), &placed, w, h, Affine::scale(1.3579), &options); + let px = |x: u32, y: u32| { + let i = ((y * w + x) * 4) as usize; + [img.pixels[i], img.pixels[i + 1], img.pixels[i + 2], img.pixels[i + 3]] + }; + // The underline's middle row: the row with the longest run of dark pixels ([Black] shows as + // about 52 grey), then nothing lighter than its darkest pixel along it. + let row = (0..h).max_by_key(|&y| (0..w).filter(|&x| px(x, y)[0] < 110).count()).unwrap(); + let dark: Vec<u32> = (0..w).filter(|&x| px(x, row)[0] < 110).collect(); + let (x0, x1) = (dark[0], *dark.last().unwrap()); + assert!(x1 - x0 > 150, "the underline spans the word: {x0}..{x1}"); + // The bar's own anti-aliased ends aside. + let inside = x0 + 2..=x1 - 2; + let ink = inside.clone().map(|x| px(x, row)[0]).min().unwrap(); + let light: Vec<(u32, u8)> = inside.map(|x| (x, px(x, row)[0])).filter(|&(_, v)| v > ink + 12).collect(); + assert!(light.is_empty(), "seams in the underline at {light:?}"); +} diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 734ec275e..cc46ad729 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -117,8 +117,8 @@ fn union(r: Option<Rect>, b: Rect) -> Option<Rect> { fn build_line(db: &FontDb, cs: &ComposedStory, l: &Line, vertical: bool) -> LineGlyphs { let mut runs: Vec<(u32, BezPath)> = Vec::new(); - let mut underlines = Vec::new(); - let mut strikes = Vec::new(); + let mut underlines: Vec<(String, f32, Rect)> = Vec::new(); + let mut strikes: Vec<(String, f32, Rect)> = Vec::new(); let mut glyphs = 0; for g in &l.glyphs { if !g.visible { @@ -127,7 +127,21 @@ fn build_line(db: &FontDb, cs: &ComposedStory, l: &Line, vertical: bool) -> Line let style = &cs.styles[g.style as usize]; for (on, rule, bars) in [(style.underline, &style.underline_rule, &mut underlines), (style.strikethrough, &style.strike_rule, &mut strikes)] { if on { - bars.push((rule.color.clone(), rule.tint, rule.rect(g.x, g.x + g.adv, designcraft_compose::rule_baseline(style, l, g)))); + let r = rule.rect(g.x, g.x + g.adv, designcraft_compose::rule_baseline(style, l, g)); + // One bar per stretch, as the PDF export draws them: a bar per glyph left + // anti-aliased seams between the letters. + match bars.last_mut() { + Some((color, tint, last)) + if *color == rule.color + && *tint == rule.tint + && (last.y0 - r.y0).abs() < 1e-6 + && (last.y1 - r.y1).abs() < 1e-6 + && (r.x0 - last.x1).abs() < 0.5 => + { + last.x1 = last.x1.max(r.x1); + } + _ => bars.push((rule.color.clone(), rule.tint, r)), + } } } let outline = db.outline(&g.face, g.gid); From fb296eacac425d643e0fc655f9a4e32b73266568 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Sun, 11 Oct 2026 20:25:49 +0200 Subject: [PATCH 079/238] fix(ui): View items in the View menu, laid out like InDesign's Rotate Spread, Proof Setup, Proof Colors, Flattener Preview, Show Tag Markers, Separations Preview and Show/Hide Tagged Frames were at the end of the Type menu. They are in View now, which follows InDesign's order: Overprint Preview and proofing, zoom, Rotate Spread, Screen Mode, Display Performance, rulers, Extras (frame edges, text threads), Grids & Guides and Structure (tagged frames, tag markers). Show Hidden Characters stays in Type, as in InDesign; W still toggles Preview. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 1 + crates/ui-egui/src/i18n.rs | 3 + crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/menus.rs | 104 +++++++++++++++++++++++----------- 4 files changed, 77 insertions(+), 32 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4b7163c76..6dcb644fb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Newest first. - **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change. - **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change. +- **2026-10-11** · The View menu follows InDesign's: proofing first, then zoom, Rotate Spread, Screen Mode, Display Performance, rulers, Extras, Grids & Guides and Structure; the view items that sat at the end of the Type menu moved there. No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 5976d7d07..b6898c7f4 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -2811,6 +2811,9 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Paragraph Rules", ["Absatzlinien", "Filets de paragraphe", "Filetes de párrafo", "段落境界線", "段落线"]), ("Rule Below", ["Linie unterhalb", "Filet après", "Filete después", "後境界線", "下方横线"]), ("Tint:", ["Farbton:", "Teinte:", "Matiz:", "濃淡:", "色调:"]), + ("Slug", ["Infobereich", "Ligne-bloc", "Indicaciones", "印刷可能領域", "辅助信息区"]), + ("Presentation", ["Präsentation", "Présentation", "Presentación", "プレゼンテーション", "演示文稿"]), + ("Extras", ["Extras", "Options supplémentaires", "Extras", "エクストラ", "附加"]), ("Column", ["Spalte", "Colonne", "Columna", "段", "栏"]), ]; diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 34766cf22..7747e87ec 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -1493,4 +1493,5 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Paragraph Rules", "Filetti paragrafo"), ("Rule Below", "Filetto sotto"), ("Tint:", "Tinta:"), + ("Presentation", "Presentazione"), ]; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index cb1422394..b0049865d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -403,34 +403,6 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:type.sizeDown", "-", "ui:view.hiddenCharacters", - "ui:view.taggedFrames", - ">Rotate Spread", - "ui:view.rotateSpread|90° CW|{\"angle\": 90}", - "ui:view.rotateSpread|90° CCW|{\"angle\": -90}", - "ui:view.rotateSpread|180°|{\"angle\": 180}", - "ui:view.rotateSpread|Clear Rotation|{\"angle\": 0}", - "<", - ">Proof Setup", - "ui:view.proofSetup|Working CMYK|{\"target\": \"workingCmyk\"}", - "ui:view.proofSetup|Internet Standard RGB (sRGB)|{\"target\": \"srgb\"}", - "ui:view.proofSetup|Legacy Macintosh RGB|{\"target\": \"legacyMacRgb\"}", - "ui:view.proofSetup|Monitor RGB|{\"target\": \"monitorRgb\"}", - "ui:view.proofSetup|Color Blindness – Protanopia|{\"target\": \"protanopia\"}", - "ui:view.proofSetup|Color Blindness – Deuteranopia|{\"target\": \"deuteranopia\"}", - "-", - "ui:view.proofSetup|Simulate Paper Color|{\"simulatePaper\": \"toggle\"}", - "<", - "ui:view.proofColors", - "ui:view.flattenerPreview", - "ui:view.tagMarkers", - ">Separations Preview", - "ui:view.separations|Off|{\"plate\": null, \"inkLimit\": null}", - "ui:view.separations|Cyan|{\"plate\": \"cyan\"}", - "ui:view.separations|Magenta|{\"plate\": \"magenta\"}", - "ui:view.separations|Yellow|{\"plate\": \"yellow\"}", - "ui:view.separations|Black|{\"plate\": \"black\"}", - "ui:view.separations|Ink Limit 300%|{\"inkLimit\": 300}", - "<", "-", ">Hyperlinks & Cross-References", "cmd:hyperlink.create", @@ -620,6 +592,28 @@ pub const MENUS: &[(&str, &[&str])] = &[ ( "View", &[ + "ui:view.overprintPreview", + ">Proof Setup", + "ui:view.proofSetup|Working CMYK|{\"target\": \"workingCmyk\"}", + "ui:view.proofSetup|Internet Standard RGB (sRGB)|{\"target\": \"srgb\"}", + "ui:view.proofSetup|Legacy Macintosh RGB|{\"target\": \"legacyMacRgb\"}", + "ui:view.proofSetup|Monitor RGB|{\"target\": \"monitorRgb\"}", + "ui:view.proofSetup|Color Blindness – Protanopia|{\"target\": \"protanopia\"}", + "ui:view.proofSetup|Color Blindness – Deuteranopia|{\"target\": \"deuteranopia\"}", + "-", + "ui:view.proofSetup|Simulate Paper Color|{\"simulatePaper\": \"toggle\"}", + "<", + "ui:view.proofColors", + ">Separations Preview", + "ui:view.separations|Off|{\"plate\": null, \"inkLimit\": null}", + "ui:view.separations|Cyan|{\"plate\": \"cyan\"}", + "ui:view.separations|Magenta|{\"plate\": \"magenta\"}", + "ui:view.separations|Yellow|{\"plate\": \"yellow\"}", + "ui:view.separations|Black|{\"plate\": \"black\"}", + "ui:view.separations|Ink Limit 300%|{\"inkLimit\": 300}", + "<", + "ui:view.flattenerPreview", + "-", "ui:view.zoomIn", "ui:view.zoomOut", "ui:view.fitPage", @@ -628,8 +622,20 @@ pub const MENUS: &[(&str, &[&str])] = &[ "ui:view.actualSize", "ui:view.entirePasteboard", "-", - "ui:view.overprintPreview", - "ui:view.togglePreview", + ">Rotate Spread", + "ui:view.rotateSpread|90° CW|{\"angle\": 90}", + "ui:view.rotateSpread|90° CCW|{\"angle\": -90}", + "ui:view.rotateSpread|180°|{\"angle\": 180}", + "ui:view.rotateSpread|Clear Rotation|{\"angle\": 0}", + "<", + "-", + ">Screen Mode", + "ui:view.screenMode|Normal|{\"mode\": \"normal\"}", + "ui:view.screenMode|Preview|{\"mode\": \"preview\"}", + "ui:view.screenMode|Bleed|{\"mode\": \"bleed\"}", + "ui:view.screenMode|Slug|{\"mode\": \"slug\"}", + "ui:view.screenMode|Presentation|{\"mode\": \"presentation\"}", + "<", ">Display Performance", "ui:view.fastDisplay", "ui:view.typicalDisplay", @@ -637,10 +643,11 @@ pub const MENUS: &[(&str, &[&str])] = &[ "<", "-", "ui:view.rulers", + "-", + ">Extras", "ui:view.frameEdges", "ui:view.textThreads", - "ui:view.hiddenCharacters", - "-", + "<", ">Grids & Guides", "ui:view.guides", "ui:view.snapToGuides", @@ -650,6 +657,11 @@ pub const MENUS: &[(&str, &[&str])] = &[ "-", "ui:app.deleteAllGuides", "<", + "-", + ">Structure", + "ui:view.taggedFrames", + "ui:view.tagMarkers", + "<", ], ), ( @@ -2279,6 +2291,34 @@ pub fn quick_apply_items(session: &designcraft_engine::Session, query: &str) -> mod tests { use super::*; + /// The entries (command ids) of a menu bar menu, submenus included. + fn menu_ids(title: &str) -> Vec<&'static str> { + let items = MENUS.iter().find(|(t, _)| *t == title).map(|(_, items)| *items).unwrap_or_default(); + items.iter().filter_map(|e| e.split_once(':').map(|(_, rest)| rest.split('|').next().unwrap_or(rest))).collect() + } + + #[test] + fn view_items_are_in_the_view_menu_as_in_indesign() { + let (view, typ) = (menu_ids("View"), menu_ids("Type")); + for id in [ + "view.rotateSpread", + "view.proofSetup", + "view.proofColors", + "view.flattenerPreview", + "view.separations", + "view.taggedFrames", + "view.tagMarkers", + ] { + assert!(view.contains(&id), "{id} is in View"); + assert!(!typ.contains(&id), "{id} isn't in Type"); + } + // Screen Mode and Extras are View submenus; Show Hidden Characters stays in Type. + assert!(view.contains(&"view.screenMode") && view.contains(&"view.frameEdges")); + assert!(typ.contains(&"view.hiddenCharacters") && !view.contains(&"view.hiddenCharacters")); + // W still toggles Normal / Preview without a menu entry. + assert_eq!(ui_label("view.togglePreview").and_then(|c| c.1), Some("W")); + } + fn walk(items: &[Item], out: &mut Vec<(String, String, Value)>) { for it in items { match it { From 9ac6f522bb31d4a139af56f4f97f32b981bdfd19 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Mon, 12 Oct 2026 00:09:46 +0200 Subject: [PATCH 080/238] fix(ui): Screen Mode checks the current mode; Japanese View submenu paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit View › Screen Mode showed no check mark (checked() had no view.screenMode arm), so with the Normal/Preview row gone nothing showed the current mode. ja.rs still translated the old Type/Proof Setup, Type/Rotate Spread and Type/Separations Preview paths; it now has the View/… paths, Screen Mode, Extras and Structure included. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/i18n/ja.rs | 9 +++++--- crates/ui-egui/src/menus.rs | 41 +++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index de57db5e9..1c220bdd6 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -1550,15 +1550,18 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Type/Insert Special Character/Quotation Marks", "書式/特殊文字を挿入/引用符"), ("Type/Insert Special Character/Symbols", "書式/特殊文字を挿入/記号"), ("Type/Insert White Space", "書式/空白文字を挿入"), - ("Type/Proof Setup", "書式/カラー校正設定"), - ("Type/Rotate Spread", "書式/スプレッドを回転"), - ("Type/Separations Preview", "書式/分版プレビュー"), ("Type/Story Direction", "書式/組み方向"), ("Type/Text Variables", "書式/テキスト変数"), ("Type/Track Changes", "書式/変更を記録"), ("Type/Type on a Path", "書式/パス上のテキスト"), ("View/Display Performance", "表示/表示品質"), + ("View/Extras", "表示/表示補助"), ("View/Grids & Guides", "表示/グリッドとガイド"), + ("View/Proof Setup", "表示/カラー校正設定"), + ("View/Rotate Spread", "表示/スプレッドを回転"), + ("View/Screen Mode", "表示/スクリーンモード"), + ("View/Separations Preview", "表示/分版プレビュー"), + ("View/Structure", "表示/構造"), ("Window/Arrange", "ウィンドウ/配置"), ("Window/Interactive", "ウィンドウ/インタラクティブ"), ("Window/Interface Color Theme", "ウィンドウ/インターフェイスのカラーテーマ"), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b0049865d..4de5d0d0d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1793,6 +1793,17 @@ pub fn checked(app: &DesignApp, id: &str, params: &Value) -> Option<bool> { "view.proofColors" => app.ui.proof_colors, "view.flattenerPreview" => app.ui.flattener_preview, "view.tagMarkers" => app.ui.tag_markers, + "view.screenMode" => { + let mode = match params.get("mode").and_then(Value::as_str) { + Some("normal") => crate::ScreenMode::Normal, + Some("preview") => crate::ScreenMode::Preview, + Some("bleed") => crate::ScreenMode::Bleed, + Some("slug") => crate::ScreenMode::Slug, + Some("presentation") => crate::ScreenMode::Presentation, + _ => return None, + }; + app.ui.screen_mode == mode + } "view.rotateSpread" => match params.get("angle").and_then(Value::as_i64) { Some(0) => app.session.active().is_some_and(|st| st.doc.spreads.iter().all(|sp| sp.pages.first().is_none_or(|p| p.view_rotation == 0))), _ => return None, @@ -2319,6 +2330,36 @@ mod tests { assert_eq!(ui_label("view.togglePreview").and_then(|c| c.1), Some("W")); } + /// The submenu paths of a menu bar menu ("View/Screen Mode"), as Edit › Menus keys them. + fn submenu_paths(title: &str) -> Vec<String> { + let items = MENUS.iter().find(|(t, _)| *t == title).map(|(_, items)| *items).unwrap_or_default(); + let mut stack = vec![title.to_string()]; + let mut out = Vec::new(); + for e in items { + if let Some(name) = e.strip_prefix('>') { + let path = format!("{}/{name}", stack.last().map_or("", String::as_str)); + out.push(path.clone()); + stack.push(path); + } else if *e == "<" { + stack.pop(); + } + } + out + } + + #[test] + fn screen_mode_shows_the_current_mode_and_view_submenus_are_translated() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let mode = |app: &DesignApp, m: &str| checked(app, "view.screenMode", &json!({ "mode": m })); + assert_eq!((mode(&app, "normal"), mode(&app, "preview")), (Some(true), Some(false))); + app.ui.screen_mode = crate::ScreenMode::Preview; + assert_eq!((mode(&app, "normal"), mode(&app, "preview")), (Some(false), Some(true))); + // Every View submenu has its Japanese path (Edit › Menus shows them). + for path in submenu_paths("View") { + assert_ne!(crate::i18n::tr("ja", &path), path, "{path} has no Japanese row"); + } + } + fn walk(items: &[Item], out: &mut Vec<(String, String, Value)>) { for it in items { match it { From 0022b3a89bdff6ad99a050d73152225281a46df3 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Sun, 11 Oct 2026 20:16:44 +0200 Subject: [PATCH 081/238] feat(ui): Pathfinder section in Properties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With several objects selected, InDesign's Properties panel shows Pathfinder: Add, Subtract, Intersect, Exclude Overlap and Minus Back. DesignCraft had them only in Object › Pathfinder and the Pathfinder panel. The section sits after Align, with code-drawn icons. Section titles and icon buttons in Properties now have accessible names (the title; the button's tooltip), so screen readers can tell them apart. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 1 + crates/ui-egui/src/icons.rs | 32 ++++++++++++ crates/ui-egui/src/panels/properties.rs | 69 +++++++++++++++++++++++++ crates/ui-egui/src/widgets.rs | 6 ++- 4 files changed, 107 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4b7163c76..add862906 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Newest first. - **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change. - **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change. +- **2026-10-11** · Properties gains InDesign's Pathfinder section (Add, Subtract, Intersect, Exclude Overlap, Minus Back) with several objects selected; section titles and icon buttons have accessible names. No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 69415e9b3..3021e7d3b 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -495,6 +495,38 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) { _ => {} } } + // Pathfinder: a back square (top left) and a front one (bottom right), the result filled. + "pf-add" | "pf-subtract" | "pf-intersect" | "pf-exclude" | "pf-minus-back" => { + let (back, front) = ((3.0, 3.0, 13.0, 13.0), (7.0, 7.0, 17.0, 17.0)); + match name { + "pf-add" => { + pen.frect(back.0, back.1, back.2, back.3); + pen.frect(front.0, front.1, front.2, front.3); + } + "pf-subtract" => { + pen.frect(3.0, 3.0, 13.0, 7.0); + pen.frect(3.0, 7.0, 7.0, 13.0); + pen.rect(front.0, front.1, front.2, front.3); + } + "pf-intersect" => { + pen.rect(back.0, back.1, back.2, back.3); + pen.rect(front.0, front.1, front.2, front.3); + pen.frect(7.0, 7.0, 13.0, 13.0); + } + "pf-exclude" => { + pen.frect(3.0, 3.0, 13.0, 7.0); + pen.frect(3.0, 7.0, 7.0, 13.0); + pen.frect(13.0, 7.0, 17.0, 17.0); + pen.frect(7.0, 13.0, 13.0, 17.0); + pen.rect(7.0, 7.0, 13.0, 13.0); + } + _ => { + pen.rect(back.0, back.1, back.2, back.3); + pen.frect(13.0, 7.0, 17.0, 17.0); + pen.frect(7.0, 13.0, 13.0, 17.0); + } + } + } // ---- Properties panel glyphs (original drawings) "orient-portrait" => { pen.rect(5.5, 3.0, 14.5, 17.0); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 49cb920f2..baf167447 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -121,6 +121,11 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { section(ui, crate::i18n::tr(&app.ui.language, "Align"), true); align_row(app, ui); divider(ui); + if i.count > 1 { + section(ui, crate::i18n::tr(&app.ui.language, "Pathfinder"), true); + pathfinder_row(app, ui); + divider(ui); + } if i.is_text && i.count == 1 { section(ui, crate::i18n::tr(&app.ui.language, "Text Frame"), true); text_frame_section(app, ui); @@ -1724,6 +1729,27 @@ pub fn wrap_panel(app: &mut DesignApp, ui: &mut egui::Ui) { }); } +/// The Pathfinder operations, in the order of InDesign's Pathfinder buttons. +const PATHFINDER: [(&str, &str, &str); 5] = [ + ("pf-add", "add", "Add"), + ("pf-subtract", "subtract", "Subtract"), + ("pf-intersect", "intersect", "Intersect"), + ("pf-exclude", "exclude", "Exclude Overlap"), + ("pf-minus-back", "minusBack", "Minus Back"), +]; + +/// Properties › Pathfinder (several objects selected): combine them into one shape. +fn pathfinder_row(app: &mut DesignApp, ui: &mut Ui) { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 8.0; + for (icon, op, tip) in PATHFINDER { + if icon_toggle(ui, icon, false, crate::i18n::tr(&app.ui.language, tip)).clicked() { + let _ = app.run("object.pathfinder", json!({"op": op})); + } + } + }); +} + /// Pathfinder panel: combine the selected shapes; make or release compound paths. pub fn pathfinder_panel(app: &mut DesignApp, ui: &mut egui::Ui) { crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Pathfinder")).strong()); @@ -2207,4 +2233,47 @@ mod tests { let after = h.state().session.active().unwrap().doc.item(designcraft_doc::ids::ItemId(id)).unwrap().fill.swatch.clone(); assert_eq!(after, name, "clicking the swatch chip applies it"); } + + fn panel(app: DesignApp) -> Harness<'static, DesignApp> { + let mut h = Harness::builder().with_size(vec2(320.0, 1400.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let id = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::<bool>(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + show(app, ui); + }, + app, + ); + h.run_steps(3); + h + } + + #[test] + fn several_shapes_show_pathfinder_and_intersect_combines_them() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let a = app.run("frame.create", json!({"rect": [100, 100, 300, 300], "shape": "ellipse"})).unwrap()["id"].clone(); + let b = app.run("frame.create", json!({"rect": [200, 100, 300, 200]})).unwrap()["id"].clone(); + // One shape: no Pathfinder section. + app.run("selection.set", json!({"ids": [a]})).unwrap(); + let mut h = panel(app); + assert!(h.query_by_label("Pathfinder").is_none()); + // Two: the section and its five buttons; Intersect leaves one shape, the ellipse's quarter. + h.state_mut().run("selection.set", json!({"ids": [a, b]})).unwrap(); + h.run_steps(3); + assert!(h.query_by_label("Pathfinder").is_some()); + for op in ["Add", "Subtract", "Intersect", "Exclude Overlap", "Minus Back"] { + assert!(h.query_by_label(op).is_some(), "{op}"); + } + h.get_by_label("Intersect").click(); + h.run_steps(3); + let doc = &h.state().session.active().unwrap().doc; + let items = &doc.spreads[0].items; + assert_eq!(items.len(), 1, "one shape left"); + let r = items[0].bounds(); + assert!((r.x0 - 200.0).abs() < 0.5 && (r.y1 - 200.0).abs() < 0.5 && (r.x1 - 300.0).abs() < 0.5, "{r:?}"); + } } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index b13627c9c..bcb27ddf9 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -408,8 +408,10 @@ pub fn selection_band(ui: &mut Ui, title: &str) { pub fn section(ui: &mut Ui, title: &str, _default_open: bool) -> bool { let t = Tokens::get(ui.ctx()); ui.add_space(7.0); - let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 16.0), Sense::hover()); + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 16.0), Sense::hover()); crate::rtl::paint(ui.painter(), r.left_center(), egui::Align2::LEFT_CENTER, title, egui::FontId::proportional(11.5), t.text_strong); + // Screen readers hear the section's title. + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, title)); ui.add_space(3.0); true } @@ -525,6 +527,8 @@ pub fn icon_toggle_sized(ui: &mut Ui, icon: &str, on: bool, tip: &str, size: egu } let s = (size.y - 4.0).min(17.0); crate::icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(s, s)), icon, if on { t.text_strong } else { t.icon }); + // Named by its tooltip, for screen readers. + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, tip)); if tip.is_empty() { resp } else { From a175245f95fcb946d793d967d3f1a96b096e053f Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Sun, 11 Oct 2026 20:00:52 +0200 Subject: [PATCH 082/238] fix(geom): Corner Options keep curves and round every corner point Corners were applied only where two straight segments meet, and every other segment was redrawn as a straight line: a Pathfinder quarter circle with rounded corners rendered as a triangle with one round corner. Corner points are now where the path turns; the sides next to a corner are trimmed along their length, curves included, and stay curves. Smooth points are left alone; rectangles are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 1 + crates/geom/src/corners.rs | 152 +++++++++++++++++++++++++++---------- 2 files changed, 112 insertions(+), 41 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4b7163c76..ecadb88fa 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Newest first. - **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change. - **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change. +- **2026-10-11** · Corner Options keep curved sides curved and round every corner point, including where an arc meets a line (a Pathfinder quarter circle with rounded corners stayed a triangle). No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. diff --git a/crates/geom/src/corners.rs b/crates/geom/src/corners.rs index df1fd0fa9..6e8ea32e8 100644 --- a/crates/geom/src/corners.rs +++ b/crates/geom/src/corners.rs @@ -1,9 +1,10 @@ //! Live corner options (Object → Corner Options): rounded, inverse rounded, inset, bevel, fancy. //! -//! Corners are applied at render time to the straight-segment corners of a closed path, so the -//! frame keeps its simple editable geometry (as in InDesign). +//! Corners are applied at render time to the corner points of a closed path (where its direction +//! turns), so the frame keeps its simple editable geometry (as in InDesign). Curved sides stay +//! curved: a corner where an arc meets a line trims the arc along its length. -use kurbo::{BezPath, Point, Vec2}; +use kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveArclen, Point, Vec2}; use serde::{Deserialize, Serialize}; use crate::path::{PathData, SubPath}; @@ -63,8 +64,9 @@ impl CornerOptions { } } -/// Apply corner options to `path`. Corners are applied to anchors joining two straight segments of -/// closed subpaths; other anchors are kept. Anchor `i` of every subpath uses `opts.corners[i % 4]`. +/// Apply corner options to `path`. Corners are applied to the corner points of closed subpaths +/// (where the incoming and outgoing directions differ, curved sides included); smooth points are +/// kept. Anchor `i` of every subpath uses `opts.corners[i % 4]`. pub fn apply(path: &PathData, opts: &CornerOptions) -> BezPath { if opts.is_none() { return path.to_bezpath(); @@ -76,50 +78,76 @@ pub fn apply(path: &PathData, opts: &CornerOptions) -> BezPath { out } +/// Arc length accuracy (points) when trimming curved sides. +const ARC_ACCURACY: f64 = 1e-3; +/// Directions closer than this (cosine, about 1°) are a smooth point, not a corner. +const SMOOTH: f64 = 0.9998; + +/// The unit direction a segment leaves its start in (None for a point). +fn start_dir(c: &CubicBez) -> Option<Vec2> { + [c.p1, c.p2, c.p3].into_iter().map(|q| q - c.p0).find(|d| d.hypot() > 1e-9).map(|d| d / d.hypot()) +} + +/// The unit direction a segment arrives at its end in (None for a point). +fn end_dir(c: &CubicBez) -> Option<Vec2> { + [c.p2, c.p1, c.p0].into_iter().map(|q| c.p3 - q).find(|d| d.hypot() > 1e-9).map(|d| d / d.hypot()) +} + +/// Does the path turn where `prev` ends and `next` starts? +fn is_corner(prev: &CubicBez, next: &CubicBez) -> bool { + matches!((end_dir(prev), start_dir(next)), (Some(a), Some(b)) if a.dot(b) < SMOOTH) +} + fn apply_subpath(sp: &SubPath, opts: &CornerOptions, out: &mut BezPath) { let n = sp.anchors.len(); - let straight_corner = |i: usize| { - let a = &sp.anchors[i]; - sp.closed && !a.has_in() && !a.has_out() - }; - if !sp.closed || n < 3 || !(0..n).any(straight_corner) { + if !sp.closed || n < 2 { sp.to_bezpath_into(out); return; } - let pts: Vec<Point> = sp.anchors.iter().map(|a| a.p).collect(); - let mut first = true; - for i in 0..n { - let c = opts.corners[i % 4]; - let p = pts[i]; - let prev = pts[(i + n - 1) % n]; - let next = pts[(i + 1) % n]; - if c.is_none() || !straight_corner(i) || !straight_corner((i + n - 1) % n) && sp.anchors[(i + n - 1) % n].has_out() { - if first { - out.move_to(p); - first = false; - } else { - out.line_to(p); + // Segment `i` runs from anchor `i` to anchor `i + 1`; the corner at anchor `i` joins segments + // `i - 1` and `i`, and takes at most half of each. + let segs: Vec<CubicBez> = (0..n).map(|i| sp.segment(i)).collect(); + let lens: Vec<f64> = segs.iter().map(|c| c.arclen(ARC_ACCURACY)).collect(); + let size: Vec<f64> = (0..n) + .map(|i| { + let c = opts.corners[i % 4]; + let prev = (i + n - 1) % n; + if c.is_none() || !is_corner(&segs[prev], &segs[i]) { + return 0.0; } - continue; - } - let din = prev - p; - let dout = next - p; - let (lin, lout) = (din.hypot(), dout.hypot()); - if lin < 1e-9 || lout < 1e-9 { - continue; - } - let s = c.size.min(lin / 2.0).min(lout / 2.0); - let ui = din / lin; - let uo = dout / lout; - let a = p + ui * s; // entry point on incoming edge - let b = p + uo * s; // exit point on outgoing edge - if first { - out.move_to(a); - first = false; + c.size.min(lens[prev] / 2.0).min(lens[i] / 2.0).max(0.0) + }) + .collect(); + if size.iter().all(|s| *s <= 0.0) { + sp.to_bezpath_into(out); + return; + } + // Each segment without the parts its corners take. + let trimmed: Vec<CubicBez> = (0..n) + .map(|i| { + let (s0, s1) = (size[i], size[(i + 1) % n]); + let t0 = if s0 > 0.0 { segs[i].inv_arclen(s0, ARC_ACCURACY) } else { 0.0 }; + let t1 = if s1 > 0.0 { segs[i].inv_arclen(lens[i] - s1, ARC_ACCURACY) } else { 1.0 }; + segs[i].subsegment(t0..t1.max(t0)) + }) + .collect(); + out.move_to(trimmed[0].p0); + for i in 0..n { + let seg = &trimmed[i]; + if sp.segment_is_line(i) { + out.line_to(seg.p3); } else { - out.line_to(a); + out.curve_to(seg.p1, seg.p2, seg.p3); + } + // The corner at the segment's end, from where this segment stops to where the next starts. + let j = (i + 1) % n; + let next = &trimmed[j]; + if size[j] > 0.0 { + match (end_dir(seg).or_else(|| end_dir(&segs[i])), start_dir(next).or_else(|| start_dir(&segs[j]))) { + (Some(ta), Some(tb)) => corner_shape(out, opts.corners[j % 4].shape, sp.anchors[j].p, seg.p3, next.p0, -ta, tb, size[j]), + _ => out.line_to(next.p0), + } } - corner_shape(out, c.shape, p, a, b, ui, uo, s); } out.close_path(); } @@ -185,6 +213,48 @@ mod tests { assert!((a - std::f64::consts::PI * 100.0).abs() < 1.0, "{a}"); } + fn nearest(bp: &BezPath, p: Point) -> f64 { + use kurbo::ParamCurveNearest; + bp.segments().map(|s| s.nearest(p, 1e-6).distance_sq.sqrt()).fold(f64::INFINITY, f64::min) + } + + /// A quarter disc of radius 100 centred on (0, 100): its arc from (0, 0) to (100, 100). + fn quarter_disc() -> PathData { + let k = 100.0 * crate::shapes::KAPPA; + let mut a0 = crate::path::Anchor::corner(Point::new(0.0, 0.0)); + a0.h_out = Point::new(k, 0.0); + let mut a1 = crate::path::Anchor::corner(Point::new(100.0, 100.0)); + a1.h_in = Point::new(100.0, 100.0 - k); + let a2 = crate::path::Anchor::corner(Point::new(0.0, 100.0)); + PathData::new(vec![SubPath { anchors: vec![a0, a1, a2], closed: true }]) + } + + #[test] + fn rounded_corners_keep_curved_sides_and_round_where_an_arc_meets_a_line() { + let p = quarter_disc(); + let bp = apply(&p, &CornerOptions::uniform(CornerShape::Rounded, 20.0)); + // Still a quarter disc (area pi/4 * 100^2 = 7854), less three small rounded corners; a + // triangle would be 5000. + let area = bp.area().abs(); + assert!(area > 7300.0 && area < 7854.0, "area {area}"); + // The middle of the arc is kept. + let mid = Point::new(100.0 * std::f64::consts::FRAC_1_SQRT_2, 100.0 - 100.0 * std::f64::consts::FRAC_1_SQRT_2); + assert!(nearest(&bp, mid) < 0.05, "{}", nearest(&bp, mid)); + // All three corners are rounded off: the line-line one and both where the arc meets a line. + for corner in [Point::new(0.0, 0.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0)] { + assert!(nearest(&bp, corner) > 3.0, "{corner:?} is still sharp"); + } + } + + #[test] + fn smooth_points_are_not_corners() { + let e = crate::shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 60.0)); + for shape in CornerShape::ALL.into_iter().skip(1) { + let bp = apply(&e, &CornerOptions::uniform(shape, 20.0)); + assert!((bp.area().abs() - e.to_bezpath().area().abs()).abs() < 1e-6, "{shape:?}"); + } + } + #[test] fn size_is_clamped_to_half_edge() { let r = rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)); From 5eda7cebbcb8479d58bf26e95517bad43d7a89db Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Sun, 11 Oct 2026 23:57:34 +0200 Subject: [PATCH 083/238] fix(geom): Trim straight sides exactly for Corner Options Straight sides were trimmed through inv_arclen at 1e-3 accuracy, so a rectangle's corner points landed up to about 1e-4 pt off. Lines are now measured and trimmed along the chord, exactly; curves still use arc length. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/geom/src/corners.rs | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/crates/geom/src/corners.rs b/crates/geom/src/corners.rs index 6e8ea32e8..6b0c9cb0a 100644 --- a/crates/geom/src/corners.rs +++ b/crates/geom/src/corners.rs @@ -107,7 +107,9 @@ fn apply_subpath(sp: &SubPath, opts: &CornerOptions, out: &mut BezPath) { // Segment `i` runs from anchor `i` to anchor `i + 1`; the corner at anchor `i` joins segments // `i - 1` and `i`, and takes at most half of each. let segs: Vec<CubicBez> = (0..n).map(|i| sp.segment(i)).collect(); - let lens: Vec<f64> = segs.iter().map(|c| c.arclen(ARC_ACCURACY)).collect(); + let lines: Vec<bool> = (0..n).map(|i| sp.segment_is_line(i)).collect(); + // Straight sides are measured (and trimmed below) exactly; curves by arc length. + let lens: Vec<f64> = segs.iter().zip(&lines).map(|(c, line)| if *line { (c.p3 - c.p0).hypot() } else { c.arclen(ARC_ACCURACY) }).collect(); let size: Vec<f64> = (0..n) .map(|i| { let c = opts.corners[i % 4]; @@ -126,6 +128,12 @@ fn apply_subpath(sp: &SubPath, opts: &CornerOptions, out: &mut BezPath) { let trimmed: Vec<CubicBez> = (0..n) .map(|i| { let (s0, s1) = (size[i], size[(i + 1) % n]); + if lines[i] { + let c = &segs[i]; + let u = if lens[i] > 1e-12 { (c.p3 - c.p0) / lens[i] } else { Vec2::ZERO }; + let (a, b) = (c.p0 + u * s0, c.p3 - u * s1); + return CubicBez::new(a, a, b, b); + } let t0 = if s0 > 0.0 { segs[i].inv_arclen(s0, ARC_ACCURACY) } else { 0.0 }; let t1 = if s1 > 0.0 { segs[i].inv_arclen(lens[i] - s1, ARC_ACCURACY) } else { 1.0 }; segs[i].subsegment(t0..t1.max(t0)) @@ -134,7 +142,7 @@ fn apply_subpath(sp: &SubPath, opts: &CornerOptions, out: &mut BezPath) { out.move_to(trimmed[0].p0); for i in 0..n { let seg = &trimmed[i]; - if sp.segment_is_line(i) { + if lines[i] { out.line_to(seg.p3); } else { out.curve_to(seg.p1, seg.p2, seg.p3); @@ -246,6 +254,25 @@ mod tests { } } + #[test] + fn straight_sides_are_trimmed_exactly() { + let r = rectangle(Rect::new(0.0, 0.0, 100.0, 60.0)); + let bp = apply(&r, &CornerOptions::uniform(CornerShape::Bevel, 10.0)); + // Every point a bevelled rectangle goes through sits exactly 10 pt from a corner. + let pts: Vec<Point> = bp + .elements() + .iter() + .filter_map(|e| match e { + kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) => Some(*p), + _ => None, + }) + .collect(); + assert_eq!(pts.len(), 9, "{pts:?}"); + for p in pts { + assert!([0.0, 10.0, 90.0, 100.0].contains(&p.x) && [0.0, 10.0, 50.0, 60.0].contains(&p.y), "{p:?}"); + } + } + #[test] fn smooth_points_are_not_corners() { let e = crate::shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 60.0)); From b9048a5b083176399ebb7d6bc9c9461e749bcc5a Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick <hi@skylar.cc> Date: Mon, 12 Oct 2026 11:53:46 -0600 Subject: [PATCH 084/238] fix(save): preserve live state when persistence fails Preserve document paths, revisions and collection contents when persistence fails. Rebase Save a Copy paths without changing the open document. Follow-up to #485. PR #613. --- ROADMAP.md | 3 +- crates/engine/src/cmd/book.rs | 15 ++-- crates/engine/src/cmd/file.rs | 23 ++--- crates/engine/src/cmd/library.rs | 9 +- .../tests/collection_state_atomicity.rs | 87 +++++++++++++++++++ crates/engine/tests/save_state_atomicity.rs | 68 +++++++++++++++ docs/target-app-parity.md | 1 + 7 files changed, 186 insertions(+), 20 deletions(-) create mode 100644 crates/engine/tests/collection_state_atomicity.rs create mode 100644 crates/engine/tests/save_state_atomicity.rs diff --git a/ROADMAP.md b/ROADMAP.md index 4b7163c76..8260a780a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **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 (window and menu quit confirmation) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (failed writes preserve live save and collection state) · **Target:** Adobe InDesign 2026 DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-12** · Save As, library edits and book collection edits persist prospective values before changing live state; failed writes preserve the document and existing collections. Save a Copy rebases data-source paths for its destination without changing the open document. No readiness percentage change. - **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change. - **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change. diff --git a/crates/engine/src/cmd/book.rs b/crates/engine/src/cmd/book.rs index 1204029c0..ad0d44bdc 100644 --- a/crates/engine/src/cmd/book.rs +++ b/crates/engine/src/cmd/book.rs @@ -108,15 +108,16 @@ pub fn specs() -> Vec<CommandSpec> { cmd!(noundo "book.add", "Add Document", [], None, "{path, at?: index}", has_book, |s, p| { let path = str_param(p, "path").ok_or_else(|| bad("book.add", "`path` required"))?.to_string(); load(&path)?; - let b = s.book.as_mut().ok_or_else(no_book)?; + let mut b = s.book.as_ref().ok_or_else(no_book)?.clone(); let at = p.get("at").and_then(Value::as_u64).map_or(b.documents.len(), |i| (i as usize).min(b.documents.len())); b.documents.insert(at, path); - save(b)?; + save(&b)?; + s.book = Some(b); ok() }), cmd!(noundo "book.remove", "Remove Document", [], None, "{index}", has_book, |s, p| { let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("book.remove", "`index` required"))? as usize; - let b = s.book.as_mut().ok_or_else(no_book)?; + let mut b = s.book.as_ref().ok_or_else(no_book)?.clone(); if i >= b.documents.len() { return Err(bad("book.remove", format!("no document {i}"))); } @@ -124,17 +125,19 @@ pub fn specs() -> Vec<CommandSpec> { if b.style_source >= b.documents.len() { b.style_source = 0; } - save(b)?; + save(&b)?; + s.book = Some(b); ok() }), cmd!(noundo "book.styleSource", "Style Source", [], None, "{index}", has_book, |s, p| { let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize; - let b = s.book.as_mut().ok_or_else(no_book)?; + let mut b = s.book.as_ref().ok_or_else(no_book)?.clone(); if i >= b.documents.len() { return Err(bad("book.styleSource", format!("no document {i}"))); } b.style_source = i; - save(b)?; + save(&b)?; + s.book = Some(b); ok() }), cmd!(query "book.list", "Book", [], None, "{} → {path, styleSource, documents: [{path, pages, firstPage}]}", has_book, |s, _| { diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index b5887bb50..2537d1e4c 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -25,7 +25,9 @@ pub fn specs() -> Vec<CommandSpec> { cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path}", has_doc, file_save), cmd!(noundo "file.saveACopy", "Save a Copy…", ["File"], None, "{path} — writes the document without changing which file it is or its unsaved state", has_doc, |s, p| { let path = str_param(p, "path").ok_or_else(|| bad("file.saveACopy", "missing `path`"))?.to_string(); - let bytes = to_bytes(&s.doc()?.doc); + let mut copy = (*s.doc()?.doc).clone(); + super::datamerge::refresh_relative_paths(&mut copy, std::path::Path::new(&path)); + let bytes = to_bytes(©); #[cfg(not(target_arch = "wasm32"))] crate::atomic::write_file(&path, &bytes)?; Ok(json!({"path": path, "bytes": bytes.len()})) @@ -237,17 +239,18 @@ fn file_save(s: &mut Session, p: &Value) -> Result<Value> { .map(str::to_string) .or_else(|| st.path.clone()) .ok_or_else(|| bad("file.save", "missing `path` (document has never been saved)"))?; - { - let mut d = (*st.doc).clone(); - super::datamerge::refresh_relative_paths(&mut d, std::path::Path::new(&path)); - if d.data_merge != st.doc.data_merge { - st.doc = Arc::new(d); - st.revision = st.revision.saturating_add(1); - } - } - let bytes = to_bytes(&st.doc); + // Prepare paths for the destination without changing the live document until persistence succeeds. + let mut saved = (*st.doc).clone(); + super::datamerge::refresh_relative_paths(&mut saved, std::path::Path::new(&path)); + let paths_changed = saved.data_merge != st.doc.data_merge; + let saved = if paths_changed { Arc::new(saved) } else { st.doc.clone() }; + let bytes = to_bytes(&saved); #[cfg(not(target_arch = "wasm32"))] crate::atomic::write_file(&path, &bytes)?; + if paths_changed { + st.doc = saved; + st.revision = st.revision.saturating_add(1); + } st.path = Some(path.clone()); st.saved_revision = st.revision; st.saved_doc = st.doc.clone(); diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 00855dd09..384b4f827 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -86,10 +86,12 @@ pub fn specs() -> Vec<CommandSpec> { let n = st.selection.items.len(); (str_param(p, "name").map(str::to_string).unwrap_or_else(|| if n > 1 { format!("{n} objects") } else { kind.clone() }), str_param(p, "description").unwrap_or("").to_string()) }; - let lib = s.library.as_mut().ok_or_else(no_library)?; + let mut lib = s.library.as_ref().ok_or_else(no_library)?.clone(); + let index = lib.items.len(); lib.items.push(LibraryItem { name, description, snippet: super::file::base64_encode(&snip) }); lib.save()?; - Ok(json!({"index": lib.items.len() - 1})) + s.library = Some(lib); + Ok(json!({"index": index})) }), cmd!( "library.place", @@ -107,13 +109,14 @@ pub fn specs() -> Vec<CommandSpec> { } ), cmd!(noundo "library.remove", "Delete Item", [], None, "{index}", has_library, |s, p| { - let lib = s.library.as_mut().ok_or_else(no_library)?; + let mut lib = s.library.as_ref().ok_or_else(no_library)?.clone(); let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("library.remove", "`index` required"))? as usize; if i >= lib.items.len() { return Err(bad("library.remove", format!("no item {i}"))); } lib.items.remove(i); lib.save()?; + s.library = Some(lib); ok() }), cmd!(noundo "conveyor.collect", "Collect", [], None, "{ids? (default: the selection)} — each object onto the Content Collector conveyor → {count}", has_selection, |s, p| { diff --git a/crates/engine/tests/collection_state_atomicity.rs b/crates/engine/tests/collection_state_atomicity.rs new file mode 100644 index 000000000..b73eefa7e --- /dev/null +++ b/crates/engine/tests/collection_state_atomicity.rs @@ -0,0 +1,87 @@ +#![cfg(not(target_arch = "wasm32"))] + +use designcraft_engine::Session; +use serde_json::json; + +fn library_failure(command: &str) { + let temp = tempfile::tempdir().unwrap(); + let folder = temp.path().join("library"); + std::fs::create_dir(&folder).unwrap(); + let file = folder.join("items.dclib"); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("frame.create", &json!({"rect": [0, 0, 50, 50]})).unwrap(); + s.execute("library.new", &json!({"path": file})).unwrap(); + s.execute("library.add", &json!({"name": "Keep"})).unwrap(); + let before = serde_json::to_value(&s.library).unwrap(); + let path = s.library.as_ref().unwrap().path.clone(); + let journal = s.journal.clone(); + let original_bytes = std::fs::read(&file).unwrap(); + let kept = temp.path().join("kept-library"); + std::fs::rename(&folder, &kept).unwrap(); + + assert!(s.execute(command, &json!({"name": "New", "index": 0})).is_err()); + + assert_eq!(before, serde_json::to_value(&s.library).unwrap(), "a failed library write must retain its original items"); + assert_eq!(path, s.library.as_ref().unwrap().path); + assert_eq!(journal, s.journal); + assert_eq!(original_bytes, std::fs::read(kept.join("items.dclib")).unwrap()); +} + +#[test] +fn failed_library_add_keeps_existing_items() { + library_failure("library.add"); +} + +#[test] +fn failed_library_remove_keeps_existing_items() { + library_failure("library.remove"); +} + +fn book_failure(command: &str) { + let temp = tempfile::tempdir().unwrap(); + let mut s = Session::new(); + let mut chapters = Vec::new(); + for i in 0..3 { + s.execute("file.new", &json!({})).unwrap(); + let path = temp.path().join(format!("chapter-{i}.designcraft")); + s.execute("file.saveAs", &json!({"path": path})).unwrap(); + chapters.push(path); + } + let folder = temp.path().join("book"); + std::fs::create_dir(&folder).unwrap(); + let file = folder.join("chapters.dcbook"); + s.execute("book.new", &json!({"path": file})).unwrap(); + s.execute("book.add", &json!({"path": chapters[0]})).unwrap(); + s.execute("book.add", &json!({"path": chapters[1]})).unwrap(); + s.execute("book.styleSource", &json!({"index": 1})).unwrap(); + let before = serde_json::to_value(&s.book).unwrap(); + let path = s.book.as_ref().unwrap().path.clone(); + let journal = s.journal.clone(); + let original_bytes = std::fs::read(&file).unwrap(); + let kept = temp.path().join("kept-book"); + std::fs::rename(&folder, &kept).unwrap(); + let params = if command == "book.add" { json!({"path": chapters[2]}) } else { json!({"index": 0}) }; + + assert!(s.execute(command, ¶ms).is_err()); + + assert_eq!(before, serde_json::to_value(&s.book).unwrap(), "a failed book write must retain its original chapter list and style source"); + assert_eq!(path, s.book.as_ref().unwrap().path); + assert_eq!(journal, s.journal); + assert_eq!(original_bytes, std::fs::read(kept.join("chapters.dcbook")).unwrap()); +} + +#[test] +fn failed_book_add_keeps_existing_chapters() { + book_failure("book.add"); +} + +#[test] +fn failed_book_remove_keeps_existing_chapters() { + book_failure("book.remove"); +} + +#[test] +fn failed_book_style_source_keeps_existing_source() { + book_failure("book.styleSource"); +} diff --git a/crates/engine/tests/save_state_atomicity.rs b/crates/engine/tests/save_state_atomicity.rs new file mode 100644 index 000000000..326d83be8 --- /dev/null +++ b/crates/engine/tests/save_state_atomicity.rs @@ -0,0 +1,68 @@ +#![cfg(not(target_arch = "wasm32"))] + +use std::path::Path; +use std::sync::Arc; + +use designcraft_engine::Session; +use serde_json::json; + +fn linked_document(root: &Path) -> (Session, std::path::PathBuf) { + let source = root.join("source/contacts.csv"); + std::fs::create_dir_all(source.parent().unwrap()).unwrap(); + std::fs::write(&source, "Name\nAda\n").unwrap(); + let original = root.join("original/template.designcraft"); + std::fs::create_dir_all(original.parent().unwrap()).unwrap(); + let mut session = Session::new(); + session.execute("file.new", &json!({})).unwrap(); + session.execute("data.source.select", &json!({"path": source})).unwrap(); + session.execute("file.saveAs", &json!({"path": original})).unwrap(); + (session, original) +} + +#[test] +fn failed_save_as_preserves_document_saved_state_history_and_original_file() { + let temp = tempfile::tempdir().unwrap(); + let (mut session, original) = linked_document(temp.path()); + session.execute("frame.create", &json!({"rect": [0, 0, 50, 50]})).unwrap(); + session.execute("edit.undo", &json!({})).unwrap(); + let before = session.doc().unwrap().clone(); + let journal = session.journal.clone(); + let original_bytes = std::fs::read(&original).unwrap(); + let destination = temp.path().join("missing/elsewhere/template.designcraft"); + + assert!(session.execute("file.saveAs", &json!({"path": destination})).is_err()); + + let after = session.doc().unwrap(); + assert!(Arc::ptr_eq(&before.doc, &after.doc), "failed Save As must not change data-source paths"); + assert!(Arc::ptr_eq(&before.saved_doc, &after.saved_doc)); + assert_eq!(before.path, after.path); + assert_eq!((before.revision, before.saved_revision, before.is_dirty()), (after.revision, after.saved_revision, after.is_dirty())); + assert_eq!(before.doc.data_merge, after.doc.data_merge); + assert_eq!(before.history.undo.len(), after.history.undo.len()); + assert_eq!(before.history.redo.len(), after.history.redo.len()); + assert_eq!(serde_json::to_value(&before.selection).unwrap(), serde_json::to_value(&after.selection).unwrap()); + assert_eq!(journal, session.journal); + assert_eq!(original_bytes, std::fs::read(original).unwrap()); + assert!(!destination.exists()); +} + +#[test] +fn save_a_copy_rebases_its_data_source_without_changing_the_open_document() { + let temp = tempfile::tempdir().unwrap(); + let (mut session, original) = linked_document(temp.path()); + let before = session.doc().unwrap().clone(); + let original_bytes = std::fs::read(&original).unwrap(); + let destination = temp.path().join("copies/nested/template.designcraft"); + std::fs::create_dir_all(destination.parent().unwrap()).unwrap(); + + session.execute("file.saveACopy", &json!({"path": destination})).unwrap(); + + let copy = designcraft_format::load(&std::fs::read(destination).unwrap()).unwrap(); + assert_eq!(copy.data_merge.sources[0].relative_path.as_deref(), Some("../../source/contacts.csv")); + let after = session.doc().unwrap(); + assert!(Arc::ptr_eq(&before.doc, &after.doc)); + assert!(Arc::ptr_eq(&before.saved_doc, &after.saved_doc)); + assert_eq!(before.path, after.path); + assert_eq!((before.revision, before.saved_revision, before.is_dirty()), (after.revision, after.saved_revision, after.is_dirty())); + assert_eq!(original_bytes, std::fs::read(original).unwrap()); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5705ee738..bbf179622 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -289,6 +289,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - 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. Failed writes before replacement retain the original; existing Unix permissions apply before staging document bytes. Desktop Services writes and CLI raster exports still need this routing. Parent-folder flushing is best effort, so this is not a power-loss durability guarantee. +- Save As prepares destination-relative data-source paths before writing and installs them only after a successful save. Failed Save As retains the document, saved state and history; Save a Copy rebases only the saved copy. Library and book collection edits likewise update live values only after persistence succeeds. - Crash recovery: unsaved documents are written every 30 s into a locked folder owned by that running app and reopened (unsaved, remembering their files) after a crash. Startup takes entries from ended runs and leaves live runs alone; writes and cleanup validate current folder and lock ownership. Failed isolation disables recovery with a warning. Concurrent older versions still use unlocked root entries; mixed-version isolation remains open. File › Revert and Save a Copy…; `file.recovery.*`, `file.revert`, `file.saveACopy`. - Window close and application/menu Quit ask about every unsaved document. Cancel or a cancelled/failed save keeps all tabs open; Don't Save approvals discard nothing until the complete plan is current. Later edits and new documents are checked before closing. Control-channel quit remains immediate; a cancelable macOS Dock Quit prompt is still missing (#544). - Graphics backend chosen before the window exists (DirectX 12 on Windows, Vulkan on Linux, Metal on macOS, OpenGL as the fallback; `WGPU_BACKEND` overrides) with a start-up fallback: a backend whose start never showed a frame is skipped at the next start and the status bar says so (`gpu.json`). Found on an AMD hybrid-graphics laptop whose Vulkan driver faulted at the first present. From e198eb5d1dfb7fef562808820e20473b6c00f081 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick <hi@skylar.cc> Date: Mon, 12 Oct 2026 12:04:39 -0600 Subject: [PATCH 085/238] fix(export): replace desktop and CLI outputs atomically Route desktop writes and CLI raster exports through the engine atomic writer. Add actual-caller regressions for reader visibility, private modes, symlinks and valid long filenames. Follow-up to #485. PR #614. --- ROADMAP.md | 3 ++- apps/designcraft-cli/src/main.rs | 2 +- apps/designcraft-cli/tests/cli.rs | 32 +++++++++++++++++++++++++++ apps/designcraft/src/main.rs | 36 ++++++++++++++++++++++++++++++- docs/target-app-parity.md | 2 +- 5 files changed, 71 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8260a780a..31985c019 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **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 (failed writes preserve live save and collection state) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (atomic desktop and CLI exports) · **Target:** Adobe InDesign 2026 DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-12** · Desktop write callbacks and CLI raster exports use the atomic writer too; open readers retain the original bytes, while private Unix modes and symlink targets survive replacement. Permanent tests cover both actual callers. No readiness percentage change. - **2026-10-12** · Save As, library edits and book collection edits persist prospective values before changing live state; failed writes preserve the document and existing collections. Save a Copy rebases data-source paths for its destination without changing the open document. No readiness percentage change. - **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change. diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs index b1819c357..c3808f24b 100644 --- a/apps/designcraft-cli/src/main.rs +++ b/apps/designcraft-cli/src/main.rs @@ -281,7 +281,7 @@ fn export(s: &mut Session, out: &str, page: usize, scale: f64) -> Result<(), Str .render_page(&st.doc, &s.cache, page, scale, true, &designcraft_render::RenderOptions { printing_only: true, ..Default::default() }) .ok_or("no such page")?; let bytes = if out.ends_with(".jpg") || out.ends_with(".jpeg") { img.to_jpeg(90) } else { img.to_png() }; - std::fs::write(out, bytes).map_err(|e| format!("{out}: {e}"))?; + designcraft_engine::atomic::write_atomic(std::path::Path::new(out), &bytes).map_err(|e| format!("{out}: {e}"))?; eprintln!("wrote {out} ({}×{}, {:.1} ms, {} glyphs)", img.width, img.height, t.elapsed().as_secs_f64() * 1000.0, r.stats.glyphs); Ok(()) } diff --git a/apps/designcraft-cli/tests/cli.rs b/apps/designcraft-cli/tests/cli.rs index 1a06d13b0..c2804be94 100644 --- a/apps/designcraft-cli/tests/cli.rs +++ b/apps/designcraft-cli/tests/cli.rs @@ -122,3 +122,35 @@ fn run_export_options_come_before_their_export() { assert!(err.contains("wrote") && std::path::Path::new(a).exists() && std::path::Path::new(b).exists(), "{err}"); let _ = std::fs::remove_dir_all(&dir); } + +#[cfg(unix)] +#[test] +fn raster_export_preserves_an_existing_reader_and_private_symlink_target() { + use std::io::Read; + use std::os::unix::fs::{PermissionsExt, symlink}; + struct Fixture(std::path::PathBuf); + impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("designcraft-cli-atomic-{}-{stamp}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + let fixture = Fixture(dir); + let target = fixture.0.join(format!("{}.png", "a".repeat(243))); + let old = b"previous private export"; + std::fs::write(&target, old).unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o600)).unwrap(); + let alias = fixture.0.join("linked.png"); + symlink(target.file_name().unwrap(), &alias).unwrap(); + let mut reader = std::fs::File::open(&target).unwrap(); + let out = cli().args(["run", "--sample", "--scale", "0.1", "--export", alias.to_str().unwrap()]).output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let mut observed = Vec::new(); + reader.read_to_end(&mut observed).unwrap(); + assert!(observed == old, "CLI export changed bytes under an existing reader"); + assert!(std::fs::read(&target).unwrap().starts_with(b"\x89PNG\r\n\x1a\n")); + assert_eq!(std::fs::metadata(&target).unwrap().permissions().mode() & 0o777, 0o600); + assert!(std::fs::symlink_metadata(alias).unwrap().file_type().is_symlink()); +} diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index 35a5686f4..e3ff86cf0 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -186,7 +186,7 @@ fn services() -> Services { })), pick_save: Some(Box::new(|name: &str| rfd::FileDialog::new().set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()))), read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), - write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), + write: Some(Box::new(|p: &str, b: &[u8]| designcraft_engine::atomic::write_atomic(std::path::Path::new(p), b).map_err(|e| e.to_string()))), ..Default::default() } } @@ -353,3 +353,37 @@ mod tests { assert!(documents.iter().any(|filter| filter.extensions.contains(&"designcraft"))); } } + +#[cfg(all(test, unix))] +mod atomic_writer_tests { + #[test] + fn desktop_write_preserves_an_existing_reader_and_private_symlink_target() { + use std::io::Read; + use std::os::unix::fs::{PermissionsExt, symlink}; + struct Fixture(std::path::PathBuf); + impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("designcraft-desktop-atomic-{}-{stamp}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + let fixture = Fixture(dir); + let target = fixture.0.join(format!("{}.png", "a".repeat(243))); + let old = b"previous private export"; + std::fs::write(&target, old).unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o600)).unwrap(); + let alias = fixture.0.join("linked.png"); + symlink(target.file_name().unwrap(), &alias).unwrap(); + let mut reader = std::fs::File::open(&target).unwrap(); + let mut write = super::services().write.unwrap(); + write(alias.to_str().unwrap(), b"complete new export").unwrap(); + let mut observed = Vec::new(); + reader.read_to_end(&mut observed).unwrap(); + assert!(observed == old, "desktop export changed bytes under an existing reader"); + assert_eq!(std::fs::read(&target).unwrap(), b"complete new export"); + assert_eq!(std::fs::metadata(&target).unwrap().permissions().mode() & 0o777, 0o600); + assert!(std::fs::symlink_metadata(alias).unwrap().file_type().is_symlink()); + } +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index bbf179622..18188e642 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -288,7 +288,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - 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 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. Failed writes before replacement retain the original; existing Unix permissions apply before staging document bytes. Desktop Services writes and CLI raster exports still need this routing. Parent-folder flushing is best effort, so this is not a power-loss durability guarantee. +- Native engine saves, Save a Copy, snippets, books, libraries, swatches and engine/MCP exports use same-folder atomic replacement, as do desktop Services writes and CLI raster exports. Failed writes before replacement retain the original; existing Unix permissions apply before staging document bytes, and already-open readers retain the old bytes. Parent-folder flushing is best effort, so this is not a power-loss durability guarantee. - Save As prepares destination-relative data-source paths before writing and installs them only after a successful save. Failed Save As retains the document, saved state and history; Save a Copy rebases only the saved copy. Library and book collection edits likewise update live values only after persistence succeeds. - Crash recovery: unsaved documents are written every 30 s into a locked folder owned by that running app and reopened (unsaved, remembering their files) after a crash. Startup takes entries from ended runs and leaves live runs alone; writes and cleanup validate current folder and lock ownership. Failed isolation disables recovery with a warning. Concurrent older versions still use unlocked root entries; mixed-version isolation remains open. File › Revert and Save a Copy…; `file.recovery.*`, `file.revert`, `file.saveACopy`. - Window close and application/menu Quit ask about every unsaved document. Cancel or a cancelled/failed save keeps all tabs open; Don't Save approvals discard nothing until the complete plan is current. Later edits and new documents are checked before closing. Control-channel quit remains immediate; a cancelable macOS Dock Quit prompt is still missing (#544). From 9509b2bceabddbec0cda1c42da6761895456108f Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick <hi@skylar.cc> Date: Mon, 12 Oct 2026 12:12:01 -0600 Subject: [PATCH 086/238] fix(book): stage chapter updates before saving Prepare and stage every chapter before a book update writes files. Preserve imported IDML sources, reject open native chapters and report completed paths after a later write failure. Follow-up to #485. PR #615. --- ROADMAP.md | 3 +- crates/engine/src/cmd/book.rs | 215 ++++++++++++++++----- crates/engine/src/cmd/book/safety_tests.rs | 52 +++++ crates/engine/tests/book_safety.rs | 188 ++++++++++++++++++ docs/gaps.md | 9 +- docs/target-app-parity.md | 3 +- 6 files changed, 417 insertions(+), 53 deletions(-) create mode 100644 crates/engine/src/cmd/book/safety_tests.rs create mode 100644 crates/engine/tests/book_safety.rs diff --git a/ROADMAP.md b/ROADMAP.md index 31985c019..ab0749c46 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **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 (atomic desktop and CLI exports) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (book chapter update safety) · **Target:** Adobe InDesign 2026 DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-12** · Book numbering and style updates prepare every chapter before saving. Only closed native chapters are changed; imported IDML remains a read-only style/PDF source. If a later disk write fails, the error names the completed chapters; the batch is not a whole-book transaction. No readiness percentage change. - **2026-10-12** · Desktop write callbacks and CLI raster exports use the atomic writer too; open readers retain the original bytes, while private Unix modes and symlink targets survive replacement. Permanent tests cover both actual callers. No readiness percentage change. - **2026-10-12** · Save As, library edits and book collection edits persist prospective values before changing live state; failed writes preserve the document and existing collections. Save a Copy rebases data-source paths for its destination without changing the open document. No readiness percentage change. - **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. diff --git a/crates/engine/src/cmd/book.rs b/crates/engine/src/cmd/book.rs index ad0d44bdc..fbf80ed9e 100644 --- a/crates/engine/src/cmd/book.rs +++ b/crates/engine/src/cmd/book.rs @@ -47,20 +47,176 @@ fn load(path: &str) -> Result<designcraft_doc::Document> { if path.to_lowercase().ends_with(".idml") { return designcraft_idml::import_idml(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}"))); } - super::file::from_bytes(&bytes) + super::file::from_bytes(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}"))) } -fn store(path: &str, d: &designcraft_doc::Document) -> Result<()> { - #[cfg(not(target_arch = "wasm32"))] - return crate::atomic::write_file(path, &super::file::to_bytes(d)); - #[cfg(target_arch = "wasm32")] - { - let _ = (path, d); - Err(EngineError::Other("books need a file system".into())) +#[cfg(not(target_arch = "wasm32"))] +const MAX_BATCH_CHAPTERS: usize = 4096; + +/// A batch keeps only bounded path records in memory. Chapter snapshots live in a private +/// temporary directory; only one document and serialized blob are live while staging. +#[cfg(not(target_arch = "wasm32"))] +struct ChapterBatch { + directory: tempfile::TempDir, + chapters: Vec<(String, tempfile::TempPath)>, + command: &'static str, +} + +#[cfg(not(target_arch = "wasm32"))] +impl ChapterBatch { + fn new(chapters: usize, command: &'static str) -> Result<Self> { + if chapters > MAX_BATCH_CHAPTERS { + return Err(bad(command, format!("a book update supports at most {MAX_BATCH_CHAPTERS} chapters"))); + } + let directory = tempfile::Builder::new() + .prefix(".designcraft-book-") + .tempdir() + .map_err(|e| EngineError::Other(format!("{command}: cannot stage chapter changes: {e}")))?; + Ok(Self { directory, chapters: Vec::with_capacity(chapters), command }) + } + + fn stage(&mut self, path: &str, doc: &designcraft_doc::Document) -> Result<()> { + use std::io::Write; + + // Propagate serialization errors; an empty fallback must never replace a chapter. + let bytes = designcraft_format::save(doc).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let mut file = tempfile::Builder::new() + .prefix("chapter-") + .tempfile_in(self.directory.path()) + .map_err(|e| EngineError::Other(format!("{path}: cannot stage changes: {e}")))?; + file.write_all(&bytes).map_err(|e| EngineError::Other(format!("{path}: cannot stage changes: {e}")))?; + self.chapters.push((path.to_string(), file.into_temp_path())); + Ok(()) + } + + fn commit(self) -> Result<()> { + self.commit_with(write_staged_chapter) + } + + fn commit_with(self, mut write: impl FnMut(&std::path::Path, &std::path::Path) -> std::io::Result<()>) -> Result<()> { + let mut completed = Vec::with_capacity(self.chapters.len()); + for (path, staged) in self.chapters { + if let Err(e) = write(std::path::Path::new(&path), &staged) { + // Each file is atomic; the whole book is not. Keep the successful prefix visible + // rather than pretending that an error undid earlier chapter replacements. + return Err(EngineError::Other(format!( + "{}: failed to save chapter {path}: {e}; completed chapter paths: {completed:?}", + self.command + ))); + } + completed.push(path); + } + Ok(()) } } +#[cfg(not(target_arch = "wasm32"))] +fn write_staged_chapter(path: &std::path::Path, staged: &std::path::Path) -> std::io::Result<()> { + let mut input = std::fs::File::open(staged)?; + crate::atomic::write_atomic_with(path, |file| std::io::copy(&mut input, file).map(|_| ())) +} + +/// Batch commands only write closed native chapters. IDML is an imported source, and an open +/// chapter's model (including unsaved edits) cannot be reconciled by a disk-only book update. +#[cfg(not(target_arch = "wasm32"))] +fn writable_chapter(s: &Session, path: &str, command: &'static str) -> Result<()> { + use std::path::Path; + + if Path::new(path).extension().and_then(|ext| ext.to_str()).is_some_and(|ext| ext.eq_ignore_ascii_case("idml")) { + return Err(bad(command, format!("cannot change imported IDML chapter {path}; convert it to a native DesignCraft document first"))); + } + let target = std::fs::canonicalize(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + if s.docs.iter().any(|doc| doc.path.as_ref().is_some_and(|open| std::fs::canonicalize(open).is_ok_and(|open| open == target))) { + return Err(bad(command, format!("chapter {path} is open; close it before updating the book"))); + } + if !std::fs::metadata(&target).is_ok_and(|metadata| metadata.is_file()) { + return Err(bad(command, format!("chapter {path} is not a regular file"))); + } + // Check both the file and its resolved parent without truncating it. Replacement needs a + // writable directory even when an in-place write would be permitted. + std::fs::OpenOptions::new().write(true).open(&target).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let parent = target.parent().ok_or_else(|| bad(command, format!("chapter {path} has no parent folder")))?; + tempfile::Builder::new().prefix(".designcraft-book-check-").tempfile_in(parent).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + Ok(()) +} + +#[cfg(not(target_arch = "wasm32"))] +fn paginate(s: &Session) -> Result<Value> { + let b = s.book.as_ref().ok_or_else(no_book)?; + if b.documents.is_empty() { + return Ok(json!({"pages": 0})); + } + let mut batch = ChapterBatch::new(b.documents.len(), "book.paginate")?; + let mut next = 1u32; + for path in &b.documents { + writable_chapter(s, path, "book.paginate")?; + let mut d = load(path)?; + start_at(&mut d, next); + let pages = u32::try_from(d.page_count()).map_err(|_| bad("book.paginate", "too many pages for continuous numbering"))?; + next = next.checked_add(pages).ok_or_else(|| bad("book.paginate", "too many pages for continuous numbering"))?; + batch.stage(path, &d)?; + } + batch.commit()?; + Ok(json!({"pages": next - 1})) +} + +#[cfg(not(target_arch = "wasm32"))] +fn sync_styles(s: &Session) -> Result<Value> { + let b = s.book.as_ref().ok_or_else(no_book)?; + let mut batch = ChapterBatch::new(b.documents.len(), "book.syncStyles")?; + let src_path = b.documents.get(b.style_source).ok_or_else(|| bad("book.syncStyles", "the book is empty"))?; + let src = load(src_path)?; + let styles = src.styles.clone(); + let swatches = src.swatches.clone(); + drop(src); + let mut n = 0; + for (i, path) in b.documents.iter().enumerate() { + if i == b.style_source { + continue; + } + writable_chapter(s, path, "book.syncStyles")?; + let mut d = load(path)?; + { + let st = d.styles_mut(); + for ps in &styles.paragraph { + st.paragraph.retain(|x| x.name != ps.name); + st.paragraph.push(ps.clone()); + } + for cs in &styles.character { + st.character.retain(|x| x.name != cs.name); + st.character.push(cs.clone()); + } + } + for sw in &swatches { + match d.swatches.iter_mut().find(|w| w.name == sw.name) { + Some(w) if !w.locked => *w = sw.clone(), + Some(_) => {} + None => d.swatches.push(sw.clone()), + } + } + batch.stage(path, &d)?; + n += 1; + } + batch.commit()?; + Ok(json!({"synced": n})) +} + +#[cfg(target_arch = "wasm32")] +fn paginate(_: &Session) -> Result<Value> { + Err(EngineError::Other("books need a file system".into())) +} + +#[cfg(target_arch = "wasm32")] +fn sync_styles(_: &Session) -> Result<Value> { + Err(EngineError::Other("books need a file system".into())) +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +#[path = "book/safety_tests.rs"] +mod safety_tests; + /// Make page numbering of `d` start at `n` (the first section). +#[cfg(not(target_arch = "wasm32"))] fn start_at(d: &mut designcraft_doc::Document, n: u32) { if !d.sections.iter().any(|x| x.start == 0) { d.sections.insert( @@ -152,48 +308,10 @@ pub fn specs() -> Vec<CommandSpec> { Ok(json!({"path": b.path, "styleSource": b.style_source, "documents": docs})) }), cmd!(noundo "book.paginate", "Update Numbering", [], None, "{} — each document starts numbering where the previous one ended (files are saved)", has_book, |s, _| { - let b = s.book.clone().ok_or_else(no_book)?; - let mut next = 1u32; - for p in &b.documents { - let mut d = load(p)?; - start_at(&mut d, next); - next += d.page_count() as u32; - store(p, &d)?; - } - Ok(json!({"pages": next - 1})) + paginate(s) }), cmd!(noundo "book.syncStyles", "Synchronize Book", [], None, "{} — paragraph and character styles and swatches of the style source go into every document (by name)", has_book, |s, _| { - let b = s.book.clone().ok_or_else(no_book)?; - let src_path = b.documents.get(b.style_source).ok_or_else(|| bad("book.syncStyles", "the book is empty"))?; - let src = load(src_path)?; - let mut n = 0; - for (i, p) in b.documents.iter().enumerate() { - if i == b.style_source { - continue; - } - let mut d = load(p)?; - { - let st = d.styles_mut(); - for ps in &src.styles.paragraph { - st.paragraph.retain(|x| x.name != ps.name); - st.paragraph.push(ps.clone()); - } - for cs in &src.styles.character { - st.character.retain(|x| x.name != cs.name); - st.character.push(cs.clone()); - } - } - for sw in &src.swatches { - match d.swatches.iter_mut().find(|w| w.name == sw.name) { - Some(w) if !w.locked => *w = sw.clone(), - Some(_) => {} - None => d.swatches.push(sw.clone()), - } - } - store(p, &d)?; - n += 1; - } - Ok(json!({"synced": n})) + sync_styles(s) }), cmd!(noundo "book.exportPdf", "Export Book to PDF…", [], None, "{path?} — every document in order as one PDF → {path, bytes, pages} (no path: {base64, …})", has_book, |s, p| { let b = s.book.clone().ok_or_else(no_book)?; @@ -238,6 +356,7 @@ mod tests { s.execute("file.new", &json!({"pages": pages})).unwrap(); s.execute("style.paragraph.create", &json!({"name": style, "chars": {"size": 20}})).unwrap(); s.execute("file.saveAs", &json!({"path": p(name)})).unwrap(); + s.execute("file.close", &json!({})).unwrap(); } s.execute("book.new", &json!({"path": p("book.dcbook")})).unwrap(); s.execute("book.add", &json!({"path": p("a.designcraft")})).unwrap(); diff --git a/crates/engine/src/cmd/book/safety_tests.rs b/crates/engine/src/cmd/book/safety_tests.rs new file mode 100644 index 000000000..e4ec20efd --- /dev/null +++ b/crates/engine/src/cmd/book/safety_tests.rs @@ -0,0 +1,52 @@ +use super::{ChapterBatch, MAX_BATCH_CHAPTERS, write_staged_chapter}; +use crate::Session; +use serde_json::json; + +#[test] +fn a_commit_failure_reports_the_completed_prefix_and_cleans_staging() { + let dir = tempfile::tempdir().unwrap(); + let first = dir.path().join("first.designcraft"); + let second = dir.path().join("second.designcraft"); + let third = dir.path().join("third.designcraft"); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let old = (*s.doc().unwrap().doc).clone(); + let mut changed = old.clone(); + changed.sections.first_mut().unwrap().start_number = Some(9); + let old_bytes = designcraft_format::save(&old).unwrap(); + for path in [&first, &second, &third] { + std::fs::write(path, &old_bytes).unwrap(); + } + let mut batch = ChapterBatch::new(3, "book.paginate").unwrap(); + for path in [&first, &second, &third] { + batch.stage(path.to_str().unwrap(), &changed).unwrap(); + } + let staging = batch.directory.path().to_path_buf(); + let mut calls = 0; + let error = batch + .commit_with(|path, staged| { + calls += 1; + if path == second { Err(std::io::Error::other("synthetic disk full")) } else { write_staged_chapter(path, staged) } + }) + .unwrap_err() + .to_string(); + assert_eq!(calls, 2); + assert!(error.contains("synthetic disk full"), "{error}"); + assert!(error.contains(second.to_str().unwrap()), "{error}"); + let completed = error.split("completed chapter paths:").nth(1).unwrap(); + assert!(completed.contains(first.to_str().unwrap()), "{error}"); + assert!(!completed.contains(second.to_str().unwrap()) && !completed.contains(third.to_str().unwrap()), "{error}"); + assert_eq!(designcraft_format::load(&std::fs::read(first).unwrap()).unwrap().sections.first().unwrap().start_number, Some(9)); + assert_eq!(std::fs::read(second).unwrap(), old_bytes); + assert_eq!(std::fs::read(third).unwrap(), old_bytes); + assert!(!staging.exists(), "temporary chapter snapshots must be removed on a partial failure"); +} + +#[test] +fn chapter_record_limit_is_checked_before_staging_allocation() { + let error = match ChapterBatch::new(MAX_BATCH_CHAPTERS + 1, "book.paginate") { + Err(e) => e, + Ok(_) => panic!("oversized book batch accepted"), + }; + assert!(error.to_string().contains("4096")); +} diff --git a/crates/engine/tests/book_safety.rs b/crates/engine/tests/book_safety.rs new file mode 100644 index 000000000..74c96815a --- /dev/null +++ b/crates/engine/tests/book_safety.rs @@ -0,0 +1,188 @@ +#![cfg(not(target_arch = "wasm32"))] + +use designcraft_engine::Session; +use serde_json::json; +use std::path::Path; +fn dir(name: &str) -> tempfile::TempDir { + tempfile::Builder::new().prefix(&format!("dc-book-safety-{name}-")).tempdir().unwrap() +} +fn document(start: u32, custom: bool) -> designcraft_doc::Document { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages":2})).unwrap(); + let mut d = (*s.doc().unwrap().doc).clone(); + d.sections.first_mut().unwrap().start_number = Some(start); + if custom { + let mut style = d.styles.paragraph.get(1).unwrap().clone(); + style.name = "Synthetic Book Style".into(); + d.styles_mut().paragraph.push(style); + } + d +} +fn native(p: &Path, start: u32, custom: bool) { + std::fs::write(p, designcraft_format::save(&document(start, custom)).unwrap()).unwrap(); +} +fn idml(p: &Path, start: u32) { + std::fs::write(p, designcraft_idml::export_idml(&document(start, false))).unwrap(); + assert!(designcraft_idml::import_idml(&std::fs::read(p).unwrap()).is_ok()); +} +fn book(d: &Path, chapters: &[&Path]) -> Session { + let mut s = Session::new(); + s.execute("book.new", &json!({"path":d.join("book.dcbook").to_string_lossy()})).unwrap(); + for p in chapters { + s.execute("book.add", &json!({"path":p.to_string_lossy()})).unwrap(); + } + s +} +#[test] +fn late_missing_chapter_does_not_partially_paginate_earlier_chapters() { + let d = dir("late-missing"); + let first = d.path().join("first.designcraft"); + let later = d.path().join("later.designcraft"); + native(&first, 9, false); + native(&later, 1, false); + let mut s = book(d.path(), &[&first, &later]); + let before = std::fs::read(&first).unwrap(); + std::fs::remove_file(later).unwrap(); + let result = s.execute("book.paginate", &json!({})); + assert!(result.is_err()); + let after = std::fs::read(&first).unwrap(); + assert!( + before == after, + "book.paginate changed the first chapter before error: {result:?}; new first number {:?}", + designcraft_format::load(&after).unwrap().sections.first().unwrap().start_number + ); +} +#[test] +fn late_corrupt_chapter_does_not_partially_sync_earlier_chapters() { + let d = dir("late-corrupt"); + let source = d.path().join("source.designcraft"); + let first = d.path().join("first.designcraft"); + let later = d.path().join("later.designcraft"); + native(&source, 1, true); + native(&first, 1, false); + native(&later, 1, false); + let mut s = book(d.path(), &[&source, &first, &later]); + let before = std::fs::read(&first).unwrap(); + std::fs::write(later, b"not a document").unwrap(); + let result = s.execute("book.syncStyles", &json!({})); + assert!(result.is_err()); + let after = std::fs::read(&first).unwrap(); + assert!( + before == after, + "book.syncStyles changed the first target before error: {result:?}; copied custom style {}", + designcraft_format::load(&after).unwrap().styles.paragraph.iter().any(|s| s.name == "Synthetic Book Style") + ); +} +#[test] +fn paginate_does_not_replace_imported_idml_with_native_format() { + let d = dir("idml-paginate"); + let chapter = d.path().join("chapter.idml"); + idml(&chapter, 9); + let mut s = book(d.path(), &[&chapter]); + let before = std::fs::read(&chapter).unwrap(); + let result = s.execute("book.paginate", &json!({})); + let after = std::fs::read(&chapter).unwrap(); + assert!( + before == after, + "book.paginate rewrote imported IDML: {result:?}; IDML readable after {}, native readable after {}", + designcraft_idml::import_idml(&after).is_ok(), + designcraft_format::load(&after).is_ok() + ); +} +#[test] +fn style_sync_does_not_replace_imported_idml_with_native_format() { + let d = dir("idml-sync"); + let source = d.path().join("source.designcraft"); + let chapter = d.path().join("chapter.idml"); + native(&source, 1, true); + idml(&chapter, 1); + let mut s = book(d.path(), &[&source, &chapter]); + let before = std::fs::read(&chapter).unwrap(); + let result = s.execute("book.syncStyles", &json!({})); + let after = std::fs::read(&chapter).unwrap(); + assert!( + before == after, + "book.syncStyles rewrote imported IDML: {result:?}; IDML readable after {}, native readable after {}", + designcraft_idml::import_idml(&after).is_ok(), + designcraft_format::load(&after).is_ok() + ); +} + +#[test] +fn an_open_unsaved_chapter_blocks_batch_writes_before_any_files_change() { + let d = dir("open-unsaved"); + let first = d.path().join("first.designcraft"); + let later = d.path().join("later.designcraft"); + native(&first, 9, false); + native(&later, 5, false); + let mut s = book(d.path(), &[&first, &later]); + s.execute("file.open", &json!({"path":later.to_string_lossy()})).unwrap(); + s.execute("frame.create", &json!({"rect":[36,36,200,100],"content":"text","text":"Unsaved chapter edit"})).unwrap(); + let first_before = std::fs::read(&first).unwrap(); + let later_before = std::fs::read(&later).unwrap(); + let revision = s.doc().unwrap().revision; + let result = s.execute("book.paginate", &json!({})); + assert!( + result.is_err(), + "book.paginate accepted an open dirty chapter: {result:?}; first changed {}, open backing file changed {}", + std::fs::read(&first).unwrap() != first_before, + std::fs::read(&later).unwrap() != later_before + ); + assert_eq!(std::fs::read(&first).unwrap(), first_before); + assert_eq!(std::fs::read(&later).unwrap(), later_before); + assert_eq!(s.doc().unwrap().revision, revision); + assert!(s.doc().unwrap().is_dirty()); + let e = result.unwrap_err().to_string(); + assert!(e.contains("later.designcraft") && e.contains("close"), "{e}"); +} + +#[cfg(unix)] +#[test] +fn an_alias_of_an_open_chapter_blocks_all_batch_writes() { + let d = dir("open-alias"); + let first = d.path().join("first.designcraft"); + let real = d.path().join("real.designcraft"); + let alias = d.path().join("alias.designcraft"); + native(&first, 9, false); + native(&real, 5, false); + std::os::unix::fs::symlink("real.designcraft", &alias).unwrap(); + let mut s = book(d.path(), &[&first, &alias]); + s.execute("file.open", &json!({"path":real.to_string_lossy()})).unwrap(); + let first_before = std::fs::read(&first).unwrap(); + let real_before = std::fs::read(&real).unwrap(); + let result = s.execute("book.paginate", &json!({})); + assert!(result.is_err(), "book.paginate accepted alias of open chapter: {result:?}"); + assert_eq!(std::fs::read(&first).unwrap(), first_before); + assert_eq!(std::fs::read(&real).unwrap(), real_before); + let e = result.unwrap_err().to_string(); + assert!(e.contains("alias.designcraft") && e.contains("close"), "{e}"); + assert!(std::fs::symlink_metadata(alias).unwrap().file_type().is_symlink()); +} +#[test] +fn imported_idml_remains_a_read_only_style_source() { + let d = dir("idml-style-source"); + let source = d.path().join("source.idml"); + let target = d.path().join("target.designcraft"); + std::fs::write(&source, designcraft_idml::export_idml(&document(1, true))).unwrap(); + native(&target, 1, false); + let mut s = book(d.path(), &[&source, &target]); + let before = std::fs::read(&source).unwrap(); + let result = s.execute("book.syncStyles", &json!({})).unwrap(); + assert_eq!(result["synced"], 1); + assert_eq!(std::fs::read(&source).unwrap(), before); + assert!(designcraft_idml::import_idml(&before).is_ok()); + let target = designcraft_format::load(&std::fs::read(target).unwrap()).unwrap(); + assert!(target.styles.paragraph.iter().any(|s| s.name == "Synthetic Book Style")); +} +#[test] +fn imported_idml_still_exports_to_book_pdf_without_source_changes() { + let d = dir("idml-pdf"); + let chapter = d.path().join("chapter.idml"); + idml(&chapter, 1); + let mut s = book(d.path(), &[&chapter]); + let before = std::fs::read(&chapter).unwrap(); + let result = s.execute("book.exportPdf", &json!({})).unwrap(); + assert_eq!(result["pages"], 2); + assert!(result["base64"].as_str().is_some_and(|s| !s.is_empty())); + assert_eq!(std::fs::read(chapter).unwrap(), before); +} diff --git a/docs/gaps.md b/docs/gaps.md index 6f92c5500..ebd2d7da5 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-11 · **Change:** minor (shared print spooler; Windows CI passes) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (book chapter update safety) · **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. @@ -149,8 +149,11 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · ## Medium ### 15. Long documents in depth (F) -- **Missing:** Books beyond a list of documents: style/swatch synchronization, book-wide - pagination, book PDF export, book TOC and index; Table of Contents Styles. +- **Missing:** book-wide TOC and index; Table of Contents Styles and deeper production workflows. +- **Present:** basic style synchronization, continuous book numbering and merged PDF export. + Chapter updates prepare the full batch and write only closed native chapters; IDML is a + read-only source. Each file is atomic, but a later I/O failure can leave completed chapters; + the error lists them. Cross-file transactional saving remains unavailable. - **Estimate:** 30–50 h. ### 16. Menu items still missing (U) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 18188e642..2ef2588d7 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # DesignCraft parity with Adobe InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared print spooler; Windows CI passes) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (book chapter update safety) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) The authoritative assessment of how close DesignCraft is to Adobe InDesign. [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) lists every known shortfall one at a time; the feature-by-feature @@ -351,6 +351,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Separations Preview plates now come from each object's own colour (CMYK as authored; RGB and images through the colour settings, images via a cached 17³ table), so pure black shows only on the black plate. - Language (Character panel ▸ Language, Properties ▸ Character, `type.char {language}`): text in other languages isn't hyphenated or spell-checked with the English dictionaries, typographer's quotes follow the language (German „…“, French and Russian «…», Swiss, Spanish, Dutch, Nordic, Japanese and Traditional Chinese 「…」, Simplified Chinese “…”), the font's localized forms (OpenType `locl`: Turkish i, Bulgarian Cyrillic, Japanese / Chinese / Korean glyph variants) follow it too, and so does the fallback for CJK characters the font lacks (with Draw Missing Glyphs from Fallback Fonts on): Japanese, Simplified Chinese, Traditional Chinese and Korean each try their own installed system fonts (Japanese starts with the craft-fonts Japanese faces, Mincho first, when built with them; Hangul, kana and Bopomofo pick their language's fonts whatever the language). Language names from IDML keep their spelling; locale codes as InDesign writes them for some languages (`de_DE_2006`, `nl_NL_2005`; any `ll`, `ll_CC`, `ll-CC`) are their language and region for all of the above, and Chinese is also recognised in its common spellings ("Simplified Chinese", "Chinese (Traditional)", `zh_CN`, `zh-Hant`). Spanish is hyphenated with Javier Bezos's Liang patterns (`hyph-es.tex` 5.0, MIT) and Portuguese with hyph-utf8's `hyph-pt.tex` 1.4 (BSD-3-Clause), both through the `hypher` crate; hyphenation for other languages isn't bundled yet. - Books (`book.new/open/add/remove/list/styleSource/paginate/syncStyles/exportPdf`, Book panel): `.dcbook` files, continuous page numbering, style sync from a style source, one merged PDF. +- Book numbering/style updates validate and serialize all targets before changing the first chapter. They write only closed native chapters, checking canonical paths for open aliases; IDML remains a read-only style source and PDF input. Snapshots are staged on disk with at most 4,096 chapter records in memory. Each replacement is atomic; a later write failure explicitly lists completed chapters rather than claiming whole-book rollback. Import/serialization memory limits and advanced book TOC/index work remain separate. - Track Changes (`changes.track/list/acceptAll/rejectAll`, Type ▸ Track Changes): typing is marked added (highlighted on screen), deletions are kept but hidden from layout and exports until accepted or rejected. Per-change accept/reject and authors aren't there yet. - Track Changes: per-change Accept / Reject (`changes.accept`, `changes.reject`) and a Track Changes panel listing every change. - Move table rows and columns (`table.moveRow`, `table.moveColumn`, Table panel buttons; with whole rows or columns selected, drag them with the Type tool — `table.dropCells`). From 8e151a2fd48ce2b1d0cebed6281af17ca7bd2006 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick <hi@skylar.cc> Date: Mon, 12 Oct 2026 12:15:46 -0600 Subject: [PATCH 087/238] fix(recovery): retain recovered checkpoints until save or close Keep recovered snapshots available through a second crash before autosave. Track checkpoint ownership across folders and restarted IDs; Save, Close and Revert clean only checkpoints owned by that document. Follow-up to #506 and the retention concern in #380. PR #616. --- ROADMAP.md | 3 +- crates/engine/src/cmd/file.rs | 20 +- crates/engine/src/lib.rs | 9 +- crates/engine/src/recovery.rs | 552 +++++++++++++++++++++++++++++++++- docs/target-app-parity.md | 4 +- 5 files changed, 557 insertions(+), 31 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index ab0749c46..171c12942 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **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 (book chapter update safety) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (recovery checkpoint retention) · **Target:** Adobe InDesign 2026 DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. - **2026-10-12** · Book numbering and style updates prepare every chapter before saving. Only closed native chapters are changed; imported IDML remains a read-only style/PDF source. If a later disk write fails, the error names the completed chapters; the batch is not a whole-book transaction. No readiness percentage change. - **2026-10-12** · Desktop write callbacks and CLI raster exports use the atomic writer too; open readers retain the original bytes, while private Unix modes and symlink targets survive replacement. Permanent tests cover both actual callers. No readiness percentage change. - **2026-10-12** · Save As, library edits and book collection edits persist prospective values before changing live state; failed writes preserve the document and existing collections. Save a Copy rebases data-source paths for its destination without changing the open document. No readiness percentage change. diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 2537d1e4c..692c363e8 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> { }), cmd!(noundo "file.recovery.save", "Save Recovery Data", [], None, "{dir?} — write every unsaved document to the recovery folder (the app does this on a timer)", always, |s, p| { let dir = recovery_dir(s, p)?; - Ok(json!({"saved": crate::recovery::save(s, &dir)?})) + Ok(json!({"saved": crate::recovery::save_tracked(s, &dir)?})) }), cmd!(noundo "file.recovery.open", "Recover Documents", [], None, "{dir?} — reopen documents left in the recovery folder (unsaved)", always, |s, p| { let dir = recovery_dir(s, p)?; @@ -255,9 +255,10 @@ fn file_save(s: &mut Session, p: &Value) -> Result<Value> { st.saved_revision = st.revision; st.saved_doc = st.doc.clone(); #[cfg(not(target_arch = "wasm32"))] - if let Some(dir) = &s.recovery_dir { - let uid = s.doc()?.uid; - crate::recovery::discard(dir, uid); + { + let recovery_dir = s.recovery_dir.clone(); + let index = s.active_index().ok_or(EngineError::NoDocument)?; + crate::recovery::discard_document(s, index, recovery_dir.as_deref()); } Ok(json!({"path": path, "bytes": bytes.len()})) } @@ -646,15 +647,14 @@ fn file_revert(s: &mut Session, _: &Value) -> Result<Value> { // As opened again: the fonts beside it now (unchanged files are shared, not read again). let (fonts, _, _) = load_document_fonts(&mut d, &path); let uid = s.doc()?.uid; - let st = s.doc_mut()?; + let recovery_dir = s.recovery_dir.clone(); + let index = s.active_index().ok_or(EngineError::NoDocument)?; let mut fresh = DocState::new(d, Some(path.clone())); fresh.fonts = fonts; fresh.uid = uid; - fresh.revision = st.revision + 1; - *st = fresh; - if let Some(dir) = &s.recovery_dir { - crate::recovery::discard(dir, uid); - } + fresh.revision = s.doc()?.revision + 1; + crate::recovery::discard_document(s, index, recovery_dir.as_deref()); + *s.doc_mut()? = fresh; Ok(json!({"path": path})) } #[cfg(target_arch = "wasm32")] diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 164719305..2165eebc4 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -93,6 +93,9 @@ pub struct DocState { /// Editing parent spreads (Pages panel double-click on a parent). pub editing_parents: bool, pub uid: u64, + /// Recovery entries this state actually opened or published, for repeated-open and cleanup. + /// Bounded, deduplicated checkpoint addresses; never inferred from uid alone. + pub(crate) recovery_entries: Vec<(std::path::PathBuf, u64)>, /// Unfilled document while data-merge preview is showing a record. Not saved. pub preview_stash: Option<Arc<Document>>, /// 1-based record currently previewed. @@ -131,6 +134,7 @@ impl DocState { interaction: None, editing_parents: false, uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), + recovery_entries: Vec::new(), preview_stash: None, preview_record: None, fonts: None, @@ -376,8 +380,9 @@ impl Session { self.stop_data_preview(); } #[cfg(not(target_arch = "wasm32"))] - if let (Some(dir), Some(d)) = (&self.recovery_dir, self.docs.get(i)) { - recovery::discard(dir, d.uid); + { + let recovery_dir = self.recovery_dir.clone(); + recovery::discard_document(self, i, recovery_dir.as_deref()); } if i < self.docs.len() { self.docs.remove(i); diff --git a/crates/engine/src/recovery.rs b/crates/engine/src/recovery.rs index 2ab67ce44..85308be3b 100644 --- a/crates/engine/src/recovery.rs +++ b/crates/engine/src/recovery.rs @@ -340,31 +340,122 @@ pub fn discard(dir: &Path, uid: u64) { let _ = std::fs::remove_file(m); } -/// Write recovery entries for the dirty documents (and drop those of clean ones). Returns how -/// many were written. -pub fn save(s: &Session, dir: &Path) -> Result<usize> { +/// Consume this document's checkpoint provenance after Save/Close/Revert. A default-directory +/// uid may also be another live recovered document's retained source after a failed republish. +pub(crate) fn discard_document(s: &mut Session, index: usize, default_dir: Option<&Path>) { + let Some(doc) = s.docs.get_mut(index) else { return }; + let uid = doc.uid; + // A completed Save/Close/Revert consumes the provenance, even when removal fails. A stale + // source uid must never become a future claim on another document's checkpoint. + let mut entries = std::mem::take(&mut doc.recovery_entries); + if let Some(dir) = default_dir + && !entries.iter().any(|(entry_dir, entry_uid)| *entry_uid == uid && same_directory(entry_dir, dir)) + { + entries.push((dir.to_path_buf(), uid)); + } + for (dir, uid) in entries { + // Session ownership governs deletion as well as writes: never consume another live + // document's only source merely because its old uid matches this unrelated document. + if !s.docs.iter().enumerate().any(|(other_index, owner)| { + other_index != index && owner.recovery_entries.iter().any(|(entry_dir, entry_uid)| *entry_uid == uid && same_directory(entry_dir, &dir)) + }) { + discard(&dir, uid); + } + } +} + +const MAX_RECOVERY_ENTRIES: usize = 16; + +fn same_directory(a: &Path, b: &Path) -> bool { + a == b || std::fs::canonicalize(a).ok().zip(std::fs::canonicalize(b).ok()).is_some_and(|(a, b)| a == b) +} + +fn has_checkpoint(doc: &DocState, dir: &Path) -> bool { + doc.recovery_entries.iter().any(|(entry_dir, uid)| *uid == doc.uid && same_directory(entry_dir, dir)) +} + +fn protect_retained_sources(s: &Session, dir: &Path) -> Result<()> { + for document in &s.docs { + if let Some(owner) = s.docs.iter().find(|owner| { + owner.uid != document.uid && owner.recovery_entries.iter().any(|(entry_dir, uid)| *uid == document.uid && same_directory(entry_dir, dir)) + }) { + return Err(EngineError::Other(format!("save or close recovered document {} before reusing its recovery folder", owner.title()))); + } + } + Ok(()) +} + +fn prepare_save(s: &Session, dir: &Path) -> Result<()> { + // Check before any write or deletion: a failed republish can retain a source whose uid + // coincides with an unrelated live document in a manually configured recovery directory. + protect_retained_sources(s, dir)?; let created = secure_recovery_dir(dir)?; #[cfg(not(target_arch = "wasm32"))] relock(dir, created)?; #[cfg(target_arch = "wasm32")] let _ = created; + Ok(()) +} + +/// Write recovery entries for dirty documents and drop clean checkpoints. Returns how many +/// were written. Command callers use the tracked variant without configuring automatic recovery. +pub fn save(s: &Session, dir: &Path) -> Result<usize> { + prepare_save(s, dir)?; let mut n = 0; for d in &s.docs { if !d.is_dirty() { discard(dir, d.uid); continue; } - let (doc, meta) = files(dir, d.uid); - let bytes = crate::cmd::to_bytes(d.preview_stash.as_deref().unwrap_or(d.doc.as_ref())); - // Write then rename, so a crash mid-write never leaves a torn file. - write_recovery_file(&doc, &bytes)?; - let m = json!({"path": d.path, "title": d.title(), "saved": designcraft_doc::vars::now()}); - write_recovery_file(&meta, m.to_string().as_bytes())?; + write_entry(dir, d)?; n += 1; } Ok(n) } +/// Command-path checkpointing also records explicitly selected directories, without configuring +/// automatic recovery. The immutable public helper remains available for fixtures and callers. +pub(crate) fn save_tracked(s: &mut Session, dir: &Path) -> Result<usize> { + // Reject the complete batch before writing rather than silently losing old ownership records. + for document in &s.docs { + if document.is_dirty() && !has_checkpoint(document, dir) && document.recovery_entries.len() >= MAX_RECOVERY_ENTRIES { + return Err(EngineError::Other( + "too many recovery checkpoints; save or close the document before choosing another recovery folder".into(), + )); + } + } + prepare_save(s, dir)?; + let mut n = 0; + for index in 0..s.docs.len() { + if s.docs.get(index).is_some_and(|document| !document.is_dirty()) { + discard_document(s, index, Some(dir)); + continue; + } + let Some(document) = s.docs.get_mut(index) else { continue }; + let (doc, meta) = files(dir, document.uid); + let bytes = designcraft_format::save(document.preview_stash.as_deref().unwrap_or(document.doc.as_ref())) + .map_err(|e| EngineError::Other(e.to_string()))?; + write_recovery_file(&doc, &bytes)?; + // The primary is useful even if optional metadata fails next. Track it immediately. + if !has_checkpoint(document, dir) { + document.recovery_entries.push((dir.to_path_buf(), document.uid)); + } + let metadata = json!({"path": document.path, "title": document.title(), "saved": designcraft_doc::vars::now()}); + write_recovery_file(&meta, metadata.to_string().as_bytes())?; + n += 1; + } + Ok(n) +} + +/// Publish a document's recovery entry before its optional metadata. +fn write_entry(dir: &Path, d: &DocState) -> Result<()> { + let (doc, meta) = files(dir, d.uid); + let bytes = designcraft_format::save(d.preview_stash.as_deref().unwrap_or(d.doc.as_ref())).map_err(|e| EngineError::Other(e.to_string()))?; + write_recovery_file(&doc, &bytes)?; + let metadata = json!({"path": d.path, "title": d.title(), "saved": designcraft_doc::vars::now()}); + write_recovery_file(&meta, metadata.to_string().as_bytes()) +} + #[cfg(not(target_arch = "wasm32"))] fn secure_recovery_dir(dir: &Path) -> Result<bool> { match std::fs::symlink_metadata(dir) { @@ -410,6 +501,11 @@ fn secure_recovery_dir(_dir: &Path) -> Result<bool> { #[cfg(not(target_arch = "wasm32"))] fn write_recovery_file(path: &Path, bytes: &[u8]) -> Result<()> { + publish_recovery_file(path, bytes, true) +} + +#[cfg(not(target_arch = "wasm32"))] +fn publish_recovery_file(path: &Path, bytes: &[u8], replace: bool) -> Result<()> { let Some(dir) = path.parent() else { return Err(EngineError::Other(format!("{} has no parent folder", path.display()))); }; @@ -429,7 +525,8 @@ fn write_recovery_file(path: &Path, bytes: &[u8]) -> Result<()> { // Validate the non-followed leaf again immediately before renaming the randomized temporary // file to its fixed sibling destination. validate_recovery_dir(dir)?; - tmp.persist(path).map_err(|e| EngineError::Other(format!("{}: {}", path.display(), e.error)))?; + let published = if replace { tmp.persist(path) } else { tmp.persist_noclobber(path) }; + published.map_err(|e| EngineError::Other(format!("{}: {}", path.display(), e.error)))?; sync_recovery_dir(dir)?; Ok(()) } @@ -489,6 +586,45 @@ fn write_recovery_file(_path: &Path, _bytes: &[u8]) -> Result<()> { Err(EngineError::Other("recovery files are unavailable on the web".into())) } +#[cfg(target_arch = "wasm32")] +fn publish_recovery_file(_path: &Path, _bytes: &[u8], _replace: bool) -> Result<()> { + Err(EngineError::Other("recovery files are unavailable on the web".into())) +} + +/// Canonical keys are only for alias comparison; operational paths stay unchanged so run +/// ownership checks still find the directory registered by the native profile. +fn directory_key(dir: &Path) -> Result<PathBuf> { + std::fs::canonicalize(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display()))) +} + +/// Include metadata-only entries as occupied, so publication cannot attach a new document to +/// somebody else's orphan metadata. No filename-derived maximum changes the uid allocator. +fn occupied_uids(dir: &Path) -> Result<std::collections::HashSet<u64>> { + let entries = std::fs::read_dir(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; + let mut ids = std::collections::HashSet::new(); + for entry in entries { + let entry = entry.map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; + let path = entry.path(); + if matches!(path.extension().and_then(|s| s.to_str()), Some("json" | "designcraft")) + && let Some(uid) = path.file_stem().and_then(|s| s.to_str()).and_then(|s| s.parse().ok()) + { + ids.insert(uid); + } + } + Ok(ids) +} + +fn reserve_uid(state: &mut DocState, occupied: &mut std::collections::HashSet<u64>) -> Result<()> { + // At most occupied.len() consecutive candidates can collide. The extra attempt must be free. + for _ in 0..=occupied.len() { + if occupied.insert(state.uid) { + return Ok(()); + } + state.uid = crate::NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } + Err(EngineError::Other("no free recovery document uid".into())) +} + /// Recovery entries in `dir`: (uid, metadata). pub fn list(dir: &Path) -> Vec<(u64, Value)> { let mut out = Vec::new(); @@ -515,19 +651,42 @@ pub fn list(dir: &Path) -> Vec<(u64, Value)> { out } -/// Reopen every recovery entry as an unsaved document (and remove the entries: the new session -/// writes its own). Returns the opened documents' indices. +/// Reopen recovery entries as unsaved documents. Publish each recovered document under its new +/// uid before removing the original, so another crash before the next timer preserves the edits. +/// Entries belonging to documents already open in this session's recovery folder are left alone. +/// Returns the opened documents' indices. pub fn open(s: &mut Session, dir: &Path) -> Result<Vec<usize>> { #[cfg(not(target_arch = "wasm32"))] { validate_recovery_dir(dir)?; relock(dir, false)?; } + let destination = s.recovery_dir.clone().unwrap_or_else(|| dir.to_path_buf()); + let created = secure_recovery_dir(&destination)?; + #[cfg(not(target_arch = "wasm32"))] + relock(&destination, created)?; + #[cfg(target_arch = "wasm32")] + let _ = created; + let source_key = directory_key(dir)?; + let destination_key = directory_key(&destination)?; let mut opened = Vec::new(); // For this run's own folder, first bring in what runs that ended left in theirs. #[cfg(not(target_arch = "wasm32"))] take_over(dir); - for (uid, meta) in list(dir) { + let entries = list(dir); + // Reserve every source entry before opening the first one: publication must never overwrite + // a source still waiting in this list, even after a fresh process restarts its allocator at 1. + let mut occupied = occupied_uids(dir)?; + if source_key != destination_key { + occupied.extend(occupied_uids(&destination)?); + } + occupied.extend(s.docs.iter().map(|d| d.uid)); + for (uid, meta) in entries { + if s.docs.iter().any(|d| { + d.recovery_entries.iter().any(|(entry_dir, entry_uid)| *entry_uid == uid && directory_key(entry_dir).is_ok_and(|key| key == source_key)) + }) { + continue; + } #[cfg(not(target_arch = "wasm32"))] validate_recovery_dir(dir)?; let (doc, _) = files(dir, uid); @@ -540,12 +699,37 @@ pub fn open(s: &mut Session, dir: &Path) -> Result<Vec<usize>> { // Skipped font files are logged; the document opens without them. let fonts = path.as_deref().and_then(|p| crate::cmd::load_document_fonts(&mut d, p).0); let mut st = DocState::new(d, path); + reserve_uid(&mut st, &mut occupied)?; + st.recovery_entries.push((dir.to_path_buf(), uid)); st.fonts = fonts; // Unsaved: the copy on disk (if any) is older than what was recovered. st.saved_doc = std::sync::Arc::new((*st.doc).clone()); st.revision += 1; - opened.push(s.add_document(st)); - discard(dir, uid); + let index = s.add_document(st); + opened.push(index); + // New uids are published without replacement, and the original is consumed only after + // both files succeed. Track a published primary even if metadata fails, so Save/Close can + // clean up every snapshot this document actually owns rather than an unrelated same uid. + if let Some(state) = s.docs.get_mut(index) { + let (document, metadata) = files(&destination, state.uid); + let publish = designcraft_format::save(state.doc.as_ref()) + .map_err(|e| EngineError::Other(e.to_string())) + .and_then(|bytes| publish_recovery_file(&document, &bytes, false)); + match publish { + Ok(()) => { + state.recovery_entries.push((destination.clone(), state.uid)); + let meta = json!({"path": state.path, "title": state.title(), "saved": designcraft_doc::vars::now()}); + match publish_recovery_file(&metadata, meta.to_string().as_bytes(), false) { + Ok(()) => { + discard(dir, uid); + state.recovery_entries = vec![(destination.clone(), state.uid)]; + } + Err(e) => log::warn!("recovery entry {uid} kept: {e}"), + } + } + Err(e) => log::warn!("recovery entry {uid} kept: {e}"), + } + } } Ok(opened) } @@ -579,7 +763,8 @@ mod tests { let st = s2.doc().unwrap(); assert!(st.is_dirty()); assert_eq!(st.doc.spreads[0].items.len(), 1); - assert!(list(&dir).is_empty(), "entries are consumed"); + assert_eq!(list(&dir).iter().map(|e| e.0).collect::<Vec<_>>(), [st.uid], "the entry follows its reopened document"); + discard(&dir, st.uid); // Saving a document drops its entry. s.recovery_dir = Some(dir.clone()); assert_eq!(save(&s, &dir).unwrap(), 1); @@ -589,6 +774,57 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn a_recovered_document_survives_a_second_crash_before_autosave() { + let dir = tempfile::tempdir().unwrap(); + let original = unsaved("edits preserved after a second crash"); + assert_eq!(save(&original, dir.path()).unwrap(), 1); + let mut reopened = Session::new(); + assert_eq!(open(&mut reopened, dir.path()).unwrap().len(), 1); + drop(reopened); + let mut recovered_again = Session::new(); + assert_eq!(open(&mut recovered_again, dir.path()).unwrap().len(), 1); + assert_eq!(texts(&recovered_again), ["edits preserved after a second crash"]); + assert!(recovered_again.documents().iter().all(DocState::is_dirty)); + assert_eq!(list(dir.path()).len(), 1); + } + + #[test] + fn opening_recovery_again_does_not_duplicate_live_documents() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(save(&unsaved("one recovered document"), dir.path()).unwrap(), 1); + let mut reopened = Session::new(); + reopened.recovery_dir = Some(dir.path().to_path_buf()); + assert_eq!(open(&mut reopened, dir.path()).unwrap().len(), 1); + assert!(open(&mut reopened, dir.path()).unwrap().is_empty()); + assert_eq!(texts(&reopened), ["one recovered document"]); + assert_eq!(list(dir.path()).len(), 1); + } + + #[cfg(unix)] + #[test] + fn a_failed_recovery_rewrite_preserves_the_original() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + assert_eq!(save(&unsaved("keep the original"), dir.path()).unwrap(), 1); + let entries = list(dir.path()); + let original = files(dir.path(), entries[0].0).0; + let bytes = std::fs::read(&original).unwrap(); + std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o500)).unwrap(); + let mut reopened = Session::new(); + let result = open(&mut reopened, dir.path()); + // Restore before assertions so a failing test does not leave an unremovable fixture. + std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + assert_eq!(result.unwrap().len(), 1); + assert_eq!(texts(&reopened), ["keep the original"]); + assert_eq!(std::fs::read(original).unwrap(), bytes); + assert_eq!(list(dir.path()).len(), 1); + // Its source uid differs from the reopened uid, but explicit Close still discards it. + assert!(open(&mut reopened, dir.path()).unwrap().is_empty()); + reopened.close_document(0); + assert!(list(dir.path()).is_empty()); + } + #[cfg(unix)] #[test] fn recovery_data_is_private_to_the_user() { @@ -682,6 +918,290 @@ mod tests { } } + #[cfg(unix)] + fn failed_republication_session() -> (Session, tempfile::TempDir, u64) { + use std::os::unix::fs::PermissionsExt; + let root = tempfile::tempdir().unwrap(); + let mut session = Session::new(); + session.execute("file.new", &json!({})).unwrap(); + let live_uid = session.docs[0].uid; + let mut original = unsaved("retained old work"); + original.docs[0].uid = live_uid; + save(&original, root.path()).unwrap(); + session.recovery_dir = Some(root.path().to_path_buf()); + std::fs::set_permissions(root.path(), std::fs::Permissions::from_mode(0o500)).unwrap(); + let recovered = open(&mut session, root.path()); + std::fs::set_permissions(root.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + assert_eq!(recovered.unwrap().len(), 1); + assert_ne!(session.docs[1].uid, live_uid); + (session, root, live_uid) + } + + #[cfg(unix)] + #[test] + fn retained_source_survives_a_coincident_live_uid_checkpoint_failure() { + let (mut session, root, live_uid) = failed_republication_session(); + let original = std::fs::read(files(root.path(), live_uid).0).unwrap(); + let recovered_uid = session.docs[1].uid; + std::fs::create_dir(files(root.path(), recovered_uid).0).unwrap(); + session.execute("file.activate", &json!({"index": 0})).unwrap(); + session.execute("frame.create", &json!({"rect": [0,0,100,100], "content": "text", "text": "other live work"})).unwrap(); + assert!(session.execute("file.recovery.save", &json!({"dir": root.path().to_string_lossy()})).is_err()); + assert_eq!(std::fs::read(files(root.path(), live_uid).0).unwrap(), original); + } + + #[cfg(unix)] + #[test] + fn clean_autosave_preserves_another_documents_retained_source() { + let (mut session, root, live_uid) = failed_republication_session(); + let checkpoint = files(root.path(), live_uid).0; + let original = std::fs::read(&checkpoint).unwrap(); + assert!(!session.docs[0].is_dirty()); + assert!(session.docs[1].is_dirty()); + assert!(session.execute("file.recovery.save", &json!({})).is_err()); + assert_eq!(std::fs::read(checkpoint).unwrap(), original); + } + + #[cfg(unix)] + #[test] + fn closing_a_coincident_document_preserves_another_documents_retained_source() { + let (mut session, root, live_uid) = failed_republication_session(); + let checkpoint = files(root.path(), live_uid).0; + let original = std::fs::read(&checkpoint).unwrap(); + session.execute("file.close", &json!({"index": 0})).unwrap(); + assert_eq!(std::fs::read(checkpoint).unwrap(), original); + } + + #[cfg(unix)] + #[test] + fn saving_a_coincident_document_preserves_another_documents_retained_source() { + let (mut session, root, live_uid) = failed_republication_session(); + let checkpoint = files(root.path(), live_uid).0; + let original = std::fs::read(&checkpoint).unwrap(); + session.execute("file.activate", &json!({"index": 0})).unwrap(); + session.execute("file.saveAs", &json!({"path": root.path().join("saved-other.designcraft").to_string_lossy()})).unwrap(); + assert_eq!(std::fs::read(checkpoint).unwrap(), original); + } + + #[cfg(unix)] + #[test] + fn reverting_a_coincident_document_preserves_another_documents_retained_source() { + let (mut session, root, live_uid) = failed_republication_session(); + let checkpoint = files(root.path(), live_uid).0; + let original = std::fs::read(&checkpoint).unwrap(); + let saved = root.path().join("saved-other.designcraft"); + std::fs::write(&saved, designcraft_format::save(&session.docs[0].doc).unwrap()).unwrap(); + session.docs[0].path = Some(saved.to_string_lossy().into_owned()); + session.execute("file.activate", &json!({"index": 0})).unwrap(); + session.execute("frame.create", &json!({"rect": [0,0,100,100]})).unwrap(); + session.execute("file.revert", &json!({})).unwrap(); + assert_eq!(std::fs::read(checkpoint).unwrap(), original); + } + + #[cfg(unix)] + #[test] + fn retained_source_provenance_is_consumed_before_its_uid_is_reused() { + let (mut session, root, live_uid) = failed_republication_session(); + session.execute("file.saveAs", &json!({"path": root.path().join("saved.designcraft").to_string_lossy()})).unwrap(); + session.execute("file.activate", &json!({"index": 0})).unwrap(); + session.execute("frame.create", &json!({"rect": [0,0,100,100], "content": "text", "text": "other live work"})).unwrap(); + session.execute("file.recovery.save", &json!({"dir": root.path().to_string_lossy()})).unwrap(); + let checkpoint = files(root.path(), live_uid).0; + let expected = std::fs::read(&checkpoint).unwrap(); + session.execute("file.close", &json!({"index": 1})).unwrap(); + assert_eq!( + std::fs::read(checkpoint).unwrap(), + expected, + "closing the saved recovered document must not delete another document's checkpoint" + ); + } + + #[test] + fn explicit_checkpoint_tracking_is_bounded_and_deduplicated_before_writes() { + let root = tempfile::tempdir().unwrap(); + let mut session = unsaved("bounded checkpoints"); + for n in 0..MAX_RECOVERY_ENTRIES { + session.execute("file.recovery.save", &json!({"dir": root.path().join(n.to_string()).to_string_lossy()})).unwrap(); + } + let excess = root.path().join("excess"); + assert!(session.execute("file.recovery.save", &json!({"dir": excess.to_string_lossy()})).is_err()); + assert!(!excess.exists(), "reject before creating or writing the next directory"); + let first_alias = root.path().join("0").join("."); + session.execute("file.recovery.save", &json!({"dir": first_alias.to_string_lossy()})).unwrap(); + for n in 0..MAX_RECOVERY_ENTRIES { + assert_eq!(persisted_texts(&root.path().join(n.to_string())), ["bounded checkpoints"]); + } + assert!(session.recovery_dir.is_none()); + session.execute("file.close", &json!({})).unwrap(); + for n in 0..MAX_RECOVERY_ENTRIES { + assert!(list(&root.path().join(n.to_string())).is_empty()); + } + } + + fn restart_fixture() -> tempfile::TempDir { + let root = tempfile::tempdir().unwrap(); + let mut first = unsaved("restart first"); + first.docs[0].uid = 1; + save(&first, root.path()).unwrap(); + let mut second = unsaved("restart second"); + second.docs[0].uid = 2; + save(&second, root.path()).unwrap(); + root + } + + fn persisted_texts(dir: &Path) -> Vec<String> { + let mut result = Vec::new(); + for (uid, _) in list(dir) { + let doc = crate::cmd::from_bytes(&std::fs::read(files(dir, uid).0).unwrap()).unwrap(); + result.extend(doc.stories.values().map(|story| story.text.clone())); + } + result.sort(); + result + } + + #[test] + #[ignore = "subprocess helper, executed by the restart recovery regression tests"] + fn restart_recovery_child() { + let source = PathBuf::from(std::env::var("DESIGNCRAFT_TEST_RECOVERY_SOURCE").unwrap()); + let mode = std::env::var("DESIGNCRAFT_TEST_RECOVERY_MODE").unwrap(); + if mode == "occupied_destination" { + let owned_root = tempfile::tempdir().unwrap(); + let destination = run_dir(owned_root.path()).unwrap(); + let mut existing = unsaved("already in destination"); + existing.docs[0].uid = 3; + save(&existing, &destination).unwrap(); + let (existing_doc, existing_meta) = files(&destination, 3); + let doc_bytes = std::fs::read(&existing_doc).unwrap(); + let meta_bytes = std::fs::read(&existing_meta).unwrap(); + let mut session = Session::new(); + session.recovery_dir = Some(destination.clone()); + let opened = open(&mut session, &source).unwrap(); + assert_eq!(opened.len(), 2); + assert_eq!(texts(&session), ["restart first", "restart second"]); + assert!(session.docs.iter().all(|document| document.uid != 3)); + assert_eq!(persisted_texts(&destination), ["already in destination", "restart first", "restart second"]); + assert!(list(&source).is_empty()); + session.execute("file.saveAs", &json!({"path": source.join("saved.designcraft").to_string_lossy()})).unwrap(); + session.execute("file.close", &json!({"index": opened[0]})).unwrap(); + assert_eq!(list(&destination).len(), 1); + assert_eq!(std::fs::read(existing_doc).unwrap(), doc_bytes); + assert_eq!(std::fs::read(existing_meta).unwrap(), meta_bytes); + return; + } + let mut session = Session::new(); + if mode == "advance" || mode == "coincident_live" { + session.execute("file.new", &json!({})).unwrap(); + assert_eq!(session.docs[0].uid, 1); + } + let owned_root; + let destination = match mode.as_str() { + "cross_dir" => { + owned_root = tempfile::tempdir().unwrap(); + let own = run_dir(owned_root.path()).unwrap(); + session.recovery_dir = Some(own.clone()); + own + } + "alias" => { + let alias = source.join("."); + session.recovery_dir = Some(alias.clone()); + alias + } + "coincident_live" => { + session.recovery_dir = Some(source.clone()); + source.clone() + } + _ => source.clone(), + }; + let opened = open(&mut session, &source).unwrap(); + assert_eq!(opened.len(), 2); + assert_eq!(texts(&session), ["restart first", "restart second"]); + assert_eq!(persisted_texts(&destination), ["restart first", "restart second"]); + assert!(session.docs.iter().all(|d| d.uid > 0 && d.uid < u64::MAX)); + if matches!(mode.as_str(), "cross_dir" | "manual" | "alias" | "revert") { + let was_disabled = session.recovery_dir.is_none(); + if mode == "cross_dir" { + assert!(list(&source).is_empty()); + } + // A copy does not consume recovery. A successful Save and a Close do. + session.execute("file.saveACopy", &json!({"path": source.join("copy.designcraft").to_string_lossy()})).unwrap(); + assert_eq!(list(&destination).len(), 2); + session.execute("file.saveAs", &json!({"path": source.join("saved.designcraft").to_string_lossy()})).unwrap(); + assert_eq!(list(&destination).len(), 1); + if mode == "revert" { + session.execute("frame.create", &json!({"rect": [0, 0, 100, 100], "content": "text", "text": "after saving"})).unwrap(); + session.execute("file.recovery.save", &json!({"dir": destination.to_string_lossy()})).unwrap(); + assert_eq!(list(&destination).len(), 2); + session.execute("file.revert", &json!({})).unwrap(); + assert_eq!(list(&destination).len(), 1); + } + session.execute("file.close", &json!({"index": opened[0]})).unwrap(); + assert!(list(&destination).is_empty()); + assert!(list(&source).is_empty()); + assert_eq!(session.recovery_dir.is_none(), was_disabled, "manual recovery must not configure autosave"); + } + } + + fn run_restart_case(mode: &str) { + let source = restart_fixture(); + if mode == "max_uid" { + let (doc, meta) = files(source.path(), 2); + let (max_doc, max_meta) = files(source.path(), u64::MAX); + std::fs::rename(doc, max_doc).unwrap(); + std::fs::rename(meta, max_meta).unwrap(); + } + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "recovery::tests::restart_recovery_child", "--ignored", "--nocapture"]) + .env("DESIGNCRAFT_TEST_RECOVERY_SOURCE", source.path()) + .env("DESIGNCRAFT_TEST_RECOVERY_MODE", mode) + .output() + .unwrap(); + assert!( + output.status.success(), + "fresh child failed: {}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let child_stdout = String::from_utf8_lossy(&output.stdout); + assert!(child_stdout.contains("test result: ok. 1 passed; 0 failed;"), "the fresh-process helper did not run exactly once: {child_stdout}"); + } + + #[test] + fn restart_uid_collision_cannot_delete_the_new_snapshot() { + run_restart_case("fresh"); + } + #[test] + fn restart_uid_collision_cannot_overwrite_an_unopened_entry() { + run_restart_case("advance"); + } + #[test] + fn cross_directory_recovery_uses_the_configured_destination() { + run_restart_case("cross_dir"); + } + #[test] + fn cross_directory_recovery_preserves_occupied_destination_entries() { + run_restart_case("occupied_destination"); + } + #[test] + fn manual_recovery_save_and_close_clean_up_without_enabling_autosave() { + run_restart_case("manual"); + } + #[test] + fn recovery_directory_aliases_do_not_delete_new_snapshots() { + run_restart_case("alias"); + } + #[test] + fn a_maximum_source_uid_does_not_advance_or_overflow_the_allocator() { + run_restart_case("max_uid"); + } + #[test] + fn a_coincident_live_uid_is_not_recovered_entry_provenance() { + run_restart_case("coincident_live"); + } + #[test] + fn manual_recovery_revert_removes_its_previous_checkpoint() { + run_restart_case("revert"); + } + /// The recovery folder was cleared while the app runs: the next save makes the run's folder /// again, and its lock with it. #[cfg(unix)] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2ef2588d7..9095faf50 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # DesignCraft parity with Adobe InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (book chapter update safety) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (recovery checkpoint retention) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) The authoritative assessment of how close DesignCraft is to Adobe InDesign. [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) lists every known shortfall one at a time; the feature-by-feature @@ -290,7 +290,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - 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, as do desktop Services writes and CLI raster exports. Failed writes before replacement retain the original; existing Unix permissions apply before staging document bytes, and already-open readers retain the old bytes. Parent-folder flushing is best effort, so this is not a power-loss durability guarantee. - Save As prepares destination-relative data-source paths before writing and installs them only after a successful save. Failed Save As retains the document, saved state and history; Save a Copy rebases only the saved copy. Library and book collection edits likewise update live values only after persistence succeeds. -- Crash recovery: unsaved documents are written every 30 s into a locked folder owned by that running app and reopened (unsaved, remembering their files) after a crash. Startup takes entries from ended runs and leaves live runs alone; writes and cleanup validate current folder and lock ownership. Failed isolation disables recovery with a warning. Concurrent older versions still use unlocked root entries; mixed-version isolation remains open. File › Revert and Save a Copy…; `file.recovery.*`, `file.revert`, `file.saveACopy`. +- Crash recovery: unsaved documents are written every 30 s into a locked folder owned by that running app and reopened (unsaved, remembering their files) after a crash. Startup takes entries from ended runs and leaves live runs alone; writes and cleanup validate current folder and lock ownership. Failed isolation disables recovery with a warning. Recovered checkpoints remain available through another crash before autosave; restarting IDs and explicit recovery from another folder preserve unopened snapshots. Successful Save, Close or Revert clears the document's checkpoints, including manual recovery while autosave is disabled; failed republishing keeps the original. Concurrent older versions still use unlocked root entries; mixed-version isolation remains open. File › Revert and Save a Copy…; `file.recovery.*`, `file.revert`, `file.saveACopy`. - Window close and application/menu Quit ask about every unsaved document. Cancel or a cancelled/failed save keeps all tabs open; Don't Save approvals discard nothing until the complete plan is current. Later edits and new documents are checked before closing. Control-channel quit remains immediate; a cancelable macOS Dock Quit prompt is still missing (#544). - Graphics backend chosen before the window exists (DirectX 12 on Windows, Vulkan on Linux, Metal on macOS, OpenGL as the fallback; `WGPU_BACKEND` overrides) with a start-up fallback: a backend whose start never showed a frame is skipped at the next start and the status bar says so (`gpu.json`). Found on an AMD hybrid-graphics laptop whose Vulkan driver faulted at the first present. - Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool). From c3859c828b4424dbef46f77010e15be3aa0ffcf8 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick <hi@skylar.cc> Date: Mon, 12 Oct 2026 12:18:32 -0600 Subject: [PATCH 088/238] fix(recovery): preserve documents on orderly app exit Checkpoint the latest document state during App::on_exit after honoring current quit approvals. Keep incomplete or stale plans, clear recovery for clean documents and report unavailable or failed storage. Follow-up to #545; cancelable macOS Dock Quit remains open in #544. PR #617. --- ROADMAP.md | 3 +- apps/designcraft/src/main.rs | 154 +++++++++++++++++++++++++++++++++++ docs/architecture.md | 10 ++- 3 files changed, 163 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 171c12942..f826a3767 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **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 (recovery checkpoint retention) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (orderly native exit recovery) · **Target:** Adobe InDesign 2026 DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. - **2026-10-12** · Book numbering and style updates prepare every chapter before saving. Only closed native chapters are changed; imported IDML remains a read-only style/PDF source. If a later disk write fails, the error names the completed chapters; the batch is not a whole-book transaction. No readiness percentage change. - **2026-10-12** · Desktop write callbacks and CLI raster exports use the atomic writer too; open readers retain the original bytes, while private Unix modes and symlink targets survive replacement. Permanent tests cover both actual callers. No readiness percentage change. diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index e3ff86cf0..f46812579 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -64,10 +64,33 @@ impl eframe::App for App { if let Some(s) = self.startup.as_mut() { s.presented(); } + if let Err(e) = preserve_documents_on_exit(&mut self.app) { + log::error!("Couldn't preserve documents on exit: {e}"); + eprintln!("designcraft: couldn't preserve documents on exit: {e}"); + } save_prefs(&self.app); } } +/// Orderly OS termination can bypass egui's cancelable close event. Finish only a complete, +/// current quit plan; otherwise preserve every live document, including edits after the timer. +fn preserve_documents_on_exit(app: &mut DesignApp) -> Result<(), String> { + app.finish_confirmed_quit()?; + if app.session.documents().is_empty() { + return Ok(()); + } + if app.session.recovery_dir.is_none() { + return if app.session.documents().iter().any(|d| d.is_dirty()) { + Err("Recovery is unavailable; unsaved documents could not be preserved on exit".into()) + } else { + Ok(()) + }; + } + // Also discard stale recovery for clean documents (for example, Undo back to the saved + // state after the last timer tick). Never choose a fallback recovery path here. + app.run("file.recovery.save", serde_json::json!({})).map(|_| ()) +} + /// `DESIGNCRAFT_NO_PREFS`: read and write nothing in the settings directory (tests, demos). fn no_prefs() -> bool { std::env::var_os("DESIGNCRAFT_NO_PREFS").is_some() @@ -387,3 +410,134 @@ mod atomic_writer_tests { assert!(std::fs::symlink_metadata(alias).unwrap().file_type().is_symlink()); } } + +#[cfg(test)] +mod exit_recovery_tests { + use super::*; + use serde_json::json; + + fn dirty_app(label: &str) -> (DesignApp, std::path::PathBuf) { + let mut app = DesignApp::new(Session::new(), Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [20, 20, 120, 120]})).unwrap(); + let uid = app.session.active().unwrap().uid; + let dir = std::env::temp_dir().join(format!("designcraft-exit-{label}-{}-{uid}", std::process::id())); + assert!(!dir.exists()); + app.session.recovery_dir = Some(dir.clone()); + (app, dir) + } + + fn close_frame(app: &mut DesignApp, ctx: &egui::Context, close: bool) { + let mut raw = egui::RawInput::default(); + if close { + raw.viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close); + } + let mut out = ctx.run_ui(raw, |ui| app.logic(ui.ctx())); + out.textures_delta.clear(); + } + + #[test] + fn exit_recovery_preserves_latest_dirty_document_without_mutating_it() { + let (mut app, dir) = dirty_app("latest"); + app.run("file.recovery.save", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [200, 200, 300, 300]})).unwrap(); + let d = app.session.active().unwrap(); + let (uid, revision, saved_revision, doc, selection, undo, redo) = + (d.uid, d.revision, d.saved_revision, d.doc.clone(), d.selection.items.clone(), d.history.undo.len(), d.history.redo.len()); + preserve_documents_on_exit(&mut app).unwrap(); + let current = app.session.active().unwrap(); + assert_eq!( + (current.uid, current.revision, current.saved_revision, ¤t.selection.items, current.history.undo.len(), current.history.redo.len()), + (uid, revision, saved_revision, &selection, undo, redo) + ); + assert!(std::sync::Arc::ptr_eq(¤t.doc, &doc)); + assert!(current.is_dirty()); + let recovered = designcraft_engine::cmd::from_bytes(&std::fs::read(dir.join(format!("{uid}.designcraft"))).unwrap()).unwrap(); + assert_eq!(serde_json::to_value(&recovered).unwrap(), serde_json::to_value(doc.as_ref()).unwrap()); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn exit_recovery_removes_stale_data_after_undo_to_saved_state() { + let (mut app, dir) = dirty_app("clean"); + let uid = app.session.active().unwrap().uid; + app.run("file.recovery.save", json!({})).unwrap(); + let entry = dir.join(format!("{uid}.designcraft")); + assert!(entry.exists()); + app.run("edit.undo", json!({})).unwrap(); + assert!(!app.session.active().unwrap().is_dirty()); + let doc = app.session.active().unwrap().doc.clone(); + preserve_documents_on_exit(&mut app).unwrap(); + assert!(!entry.exists(), "clean exit must not resurrect the pre-Undo edits"); + assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, &doc)); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn exit_recovery_reports_unavailable_or_failed_storage_without_changing_documents() { + let (mut app, dir) = dirty_app("failure"); + let doc = app.session.active().unwrap().doc.clone(); + app.session.recovery_dir = None; + assert!(preserve_documents_on_exit(&mut app).unwrap_err().contains("unavailable")); + std::fs::create_dir(&dir).unwrap(); + let blocked = dir.join("not-a-directory"); + std::fs::write(&blocked, b"preserve this file").unwrap(); + app.session.recovery_dir = Some(blocked.clone()); + assert!(preserve_documents_on_exit(&mut app).is_err()); + assert_eq!(std::fs::read(&blocked).unwrap(), b"preserve this file"); + assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, &doc)); + assert!(app.session.active().unwrap().is_dirty()); + assert_eq!(app.session.documents().len(), 1); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn exit_recovery_respects_complete_incomplete_stale_and_new_document_quit_plans() { + for case in ["complete", "incomplete", "stale", "new"] { + let (mut app, dir) = dirty_app(case); + if case == "incomplete" { + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [20, 20, 120, 120]})).unwrap(); + } + app.run("file.recovery.save", json!({})).unwrap(); + let first_uid = app.session.documents()[0].uid; + let ctx = egui::Context::default(); + close_frame(&mut app, &ctx, true); + app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true)); + designcraft_ui_egui::dialogs::confirm(&mut app).unwrap(); + close_frame(&mut app, &ctx, false); + if case == "stale" { + app.run("frame.create", json!({"rect": [200, 200, 300, 300]})).unwrap(); + } else if case == "new" { + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [20, 20, 120, 120]})).unwrap(); + } + let active = app.session.active().unwrap().uid; + let before: Vec<_> = app + .session + .documents() + .iter() + .map(|d| (d.uid, d.revision, d.doc.clone(), d.selection.items.clone(), d.history.undo.len(), d.history.redo.len())) + .collect(); + preserve_documents_on_exit(&mut app).unwrap(); + if case == "complete" { + assert!(app.session.documents().is_empty()); + assert!(!dir.join(format!("{first_uid}.designcraft")).exists(), "accepted Don't Save must not be recovered"); + } else { + assert_eq!(app.session.documents().len(), before.len(), "{case}"); + assert_eq!(app.session.active().unwrap().uid, active, "{case}"); + for (d, (uid, revision, doc, selection, undo, redo)) in app.session.documents().iter().zip(before) { + assert_eq!( + (d.uid, d.revision, &d.selection.items, d.history.undo.len(), d.history.redo.len()), + (uid, revision, &selection, undo, redo), + "{case}" + ); + assert!(std::sync::Arc::ptr_eq(&d.doc, &doc), "{case}"); + let recovered = designcraft_engine::cmd::from_bytes(&std::fs::read(dir.join(format!("{uid}.designcraft"))).unwrap()).unwrap(); + assert_eq!(serde_json::to_value(&recovered).unwrap(), serde_json::to_value(doc.as_ref()).unwrap(), "{case}"); + } + } + std::fs::remove_dir_all(dir).unwrap(); + } + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 4f06fbcee..be3274b24 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # DesignCraft architecture -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first committed version, from the code on main and the local plan) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (orderly native exit recovery) · **Target:** Adobe InDesign 2026 How DesignCraft is built today. The design notes behind it are in `plan/architecture.md` (local, gitignored); this file describes what the code on `main` does. Agents: AGENTS.md has the @@ -57,8 +57,11 @@ and the control channel all call `Session::execute(id, params)`. UI-only command panels, zoom, dialogs) are `UI_COMMANDS` in `crates/ui-egui/src/menus.rs`, reachable the same way. Tool gestures emit Begin/Preview/Commit commands so a drag is one undo step. Every journaled command is replayable as a script. Escaped panics during a command or an import/export become an -error (`engine/src/guard.rs`), never a lost document; unsaved documents are written to a recovery -folder every 30 s. +error (`engine/src/guard.rs`), never a lost document; native recovery is written periodically +into each running app's locked folder. Orderly native shutdown finalizes complete, current quit +approvals and writes the remaining documents' latest recovery state, including clean-document +cleanup after Undo. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit +remains open (#544). ## Composition and rendering @@ -95,4 +98,5 @@ builds (on manifest changes), packaging lint and the release workflow; `xtask ci | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Documented orderly native exit recovery and the remaining cancelable Dock Quit gap | | 2026-10-10 | major | Created from the code on main (92649ff) and `plan/architecture.md` | From 84158e36d34003a84206eeba695289e2342f1186 Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Mon, 12 Oct 2026 19:32:28 +0000 Subject: [PATCH 089/238] Document tabs: long names are shortened so the close button stays visible A tab is at most 260 px wide. A longer document name ends in an ellipsis (cut at a grapheme boundary, RTL names included), the dirty marker and zoom stay readable, and the full name shows in the tab's tooltip. Tabs and their close buttons carry accessible labels. Fixes #603 --- crates/ui-egui/src/chrome.rs | 55 +++++++++++++++++++++++++++++++++--- crates/ui-egui/src/rtl.rs | 8 ++++++ 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 2896fcdc4..c2f1cb48e 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -489,9 +489,17 @@ fn control_text(app: &mut DesignApp, ui: &mut egui::Ui) { }); } +/// The widest a document tab grows: a longer name is shortened so its close button and the +/// tabs after it stay in view. +const DOC_TAB_MAX_W: f32 = 260.0; +/// Close button and padding around a tab's title. +const DOC_TAB_PAD: f32 = 44.0; + pub fn doc_tabs(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let (bar, _) = ui.allocate_exact_size(vec2(ui.available_width(), 28.0), Sense::hover()); + let max_w = DOC_TAB_MAX_W.min(bar.width()); + let close_label = crate::i18n::tr(&app.ui.language, "Close"); ui.painter().rect_filled(bar, 0.0, t.tab_strip); ui.painter().line_segment([bar.left_bottom(), bar.right_bottom()], Stroke::new(1.0, t.border)); let mut x = bar.min.x; @@ -504,14 +512,28 @@ pub fn doc_tabs(app: &mut DesignApp, ui: &mut egui::Ui) { let is_active = Some(i) == active; // `*` = unsaved; the view-mode suffix only in Preview (InDesign shows its GPU mode there). let suffix = if is_active && app.ui.screen_mode == crate::ScreenMode::Preview { " [Preview]" } else { "" }; - let label = format!("{}{} @ {:.0}%{suffix}", if *dirty { "*" } else { "" }, crate::rtl::isolate(title), zoom * 100.0); - let galley = crate::rtl::plain(ui.ctx(), &label, semibold(11.5), if is_active { t.text_strong } else { t.text_dim }); - let w = (galley.size().x + 44.0).max(if is_active { 210.0 } else { 150.0 }); + let color = if is_active { t.text_strong } else { t.text_dim }; + // The name gives way first; the dirty marker and zoom stay readable. + let zoom_label = crate::rtl::plain(ui.ctx(), &format!(" @ {:.0}%{suffix}", zoom * 100.0), semibold(11.5), color); + let name = format!("{}{}", if *dirty { "*" } else { "" }, crate::rtl::isolate(title)); + let name_max = max_w - DOC_TAB_PAD - zoom_label.size().x; + let galley = crate::rtl::truncated(ui.ctx(), &name, semibold(11.5), color, name_max); + let elided = galley.elided; + let w = (galley.size().x + zoom_label.size().x + DOC_TAB_PAD).max(if is_active { 210.0 } else { 150.0 }).min(max_w); let r = egui::Rect::from_min_size(egui::pos2(x, bar.min.y), vec2(w, 28.0)); let resp = ui.interact(r, ui.id().with(("doctab", i)), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, title)); + let resp = if elided { + resp.on_hover_ui(|ui| { + let _ = crate::rtl::label(ui, title); + }) + } else { + resp + }; ui.painter().rect_filled(r, 0.0, if is_active { t.panel } else { t.tab_strip }); let cr = egui::Rect::from_center_size(egui::pos2(r.min.x + 18.0, r.center().y), vec2(14.0, 14.0)); let cresp = ui.interact(cr, ui.id().with(("docclose", i)), Sense::click()); + cresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("{close_label} {title}"))); ui.painter().text( cr.center(), egui::Align2::CENTER_CENTER, @@ -519,7 +541,11 @@ pub fn doc_tabs(app: &mut DesignApp, ui: &mut egui::Ui) { egui::FontId::proportional(14.0), if cresp.hovered() { t.text_strong } else { t.text_dim }, ); - ui.painter().galley(egui::pos2(r.min.x + 32.0, r.center().y - galley.size().y / 2.0), galley, Color32::WHITE); + let name_w = galley.size().x; + // Clipped too, for a window narrower than the zoom label. + let text = ui.painter().with_clip_rect(r); + text.galley(egui::pos2(r.min.x + 32.0, r.center().y - galley.size().y / 2.0), galley, Color32::WHITE); + text.galley(egui::pos2(r.min.x + 32.0 + name_w, r.center().y - zoom_label.size().y / 2.0), zoom_label, Color32::WHITE); ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.0, t.border)); if cresp.clicked() { close = Some(i); @@ -783,6 +809,27 @@ mod tests { h.get_by_label(label).rect() } + /// A very long document name made its tab grow past the window, hiding the close button + /// and the tabs after it (#603). + #[test] + fn long_document_names_keep_tabs_short_and_close_buttons_visible() { + let latin = "Quarterly report with a very long file name ".repeat(6); + let hebrew = "דוח רבעוני עם שם קובץ ארוך מאוד ".repeat(6); + let mut app = app(); + for title in [latin.as_str(), hebrew.as_str(), "Short"] { + app.run("file.new", serde_json::json!({ "title": title })).unwrap(); + } + let h = test_window::open(app, vec2(1200.0, 600.0)); + for title in [latin.as_str(), hebrew.as_str()] { + let tab = rect(&h, title); + assert!(tab.width() <= super::DOC_TAB_MAX_W, "{title:?} tab is {} wide", tab.width()); + let close = rect(&h, &format!("Close {title}")); + assert!(tab.contains_rect(close), "close button {close:?} outside its tab {tab:?}"); + } + let (first, short) = (rect(&h, &latin), rect(&h, "Short")); + assert!(short.min.x - first.min.x <= 2.0 * super::DOC_TAB_MAX_W, "the last tab {short:?} starts too far right"); + } + #[test] fn a_narrow_window_scrolls_the_control_bar() { let w = 700.0; diff --git a/crates/ui-egui/src/rtl.rs b/crates/ui-egui/src/rtl.rs index 2521aae20..a554bc64a 100644 --- a/crates/ui-egui/src/rtl.rs +++ b/crates/ui-egui/src/rtl.rs @@ -76,6 +76,14 @@ pub fn plain(ctx: &egui::Context, text: &str, font: FontId, color: Color32) -> A layout(ctx, LayoutJob::simple_singleline(text.to_owned(), font, color)) } +/// One line no wider than `max_width`: longer text ends in an ellipsis, cut at a grapheme +/// boundary in logical order (`Galley::elided` tells whether it was cut). +pub fn truncated(ctx: &egui::Context, text: &str, font: FontId, color: Color32, max_width: f32) -> Arc<Galley> { + let mut job = LayoutJob::simple_singleline(text.to_owned(), font, color); + job.wrap = TextWrapping::truncate_at_width(max_width.max(0.0)); + layout(ctx, job) +} + pub fn paint(p: &egui::Painter, pos: Pos2, anchor: Align2, text: &str, font: FontId, color: Color32) { let galley = plain(p.ctx(), text, font, color); let rect = anchor.anchor_rect(Rect::from_min_size(pos, galley.size())); From d8d66a27577a51f4a01574fcf3911df795df9c3e Mon Sep 17 00:00:00 2001 From: hasan-sdaif <306196098+hasan-sdaif@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:47:15 +0000 Subject: [PATCH 090/238] =?UTF-8?q?Complete=20the=20Arabic=20(=D8=A7=D9=84?= =?UTF-8?q?=D8=B9=D8=B1=D8=A8=D9=8A=D8=A9)=20catalog:=20every=20missing=20?= =?UTF-8?q?key=20translated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ar.rs covered 1,456 of the interface strings; the newer catalogs (it, ja, pt-br, uk) had since grown to a 1,690-key union. This adds the 238 keys Arabic was missing, in the same clean-room Modern Standard Arabic and InDesign-style vocabulary the file already uses (كائن مرسّى، مسار مغلق، خطوة وتكرار، الفحص المسبق، دمج البيانات، الكائن الأصل…): - the whole menu-path family (نص/…، كائن/…، جدول/…، نافذة/…) — Type on a Path, Track Changes, Story Direction, Transform Again, Pathfinder… - the special-character and white-space inventory (شرطة كاملة، مسافة شعرية، محرف نهاية الفقرة، علامات الاقتباس المستقيمة…) and their symbols (رمز حقوق النشر، رمز العلامة التجارية…) - data merge (سجل، سجلات في الصفحة، تحديد مصدر بيانات…), EPUB/HTML/XML/ IDML exports, proof setups and colour-blindness previews, anchored objects, parent pages, slug and bleed fields, quick apply, preflight. Same table, same lookups — Arabic now covers the union of every language's keys, matching Japanese's completeness (1,694 keys). --- crates/ui-egui/src/i18n/ar.rs | 240 ++++++++++++++++++++++++++++++++++ 1 file changed, 240 insertions(+) diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 10e393cd2..84e061bf9 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -1469,4 +1469,244 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Paragraph Rules…", "خطوط الفقرة…"), ("Paragraph Rules", "خطوط الفقرة"), ("Rule Below", "خط سفلي"), + // Completion: the keys the newer catalogs grew (menu paths, special characters, + // data merge, EPUB exports, proof setups) — same clean-room Arabic as above. + ("90° CCW", "90° عكس عقارب الساعة"), + ("90° CW", "90° مع عقارب الساعة"), + ("A, B, C, D...", "أ، ب، ج، د..."), + ("All records", "كل السجلات"), + ("Alpha Channel", "قناة ألفا"), + ("Anchored Object Options", "خيارات الكائن المرسّى"), + ("Apply Parent to Pages", "تطبيق الأصل على الصفحات"), + ("ArtCraft App Page", "صفحة تطبيق ArtCraft"), + ("ArtCraft Website", "موقع ArtCraft"), + ("Bind to frame", "ربط بالإطار"), + ("Black", "أسود"), + ("Bleed:", "الامتداد:"), + ("Bottom:", "أسفل:"), + ("Bullet Character", "محرف التعداد"), + ("CMYK:", "CMYK:"), + ("Center in frame", "توسيط في الإطار"), + ("Check Spelling", "تدقيق الإملاء"), + ("Clear Rotation", "مسح التدوير"), + ("Closed Path", "مسار مغلق"), + ("Color Blindness – Deuteranopia", "عمى الألوان – الديوتيرانوبيا"), + ("Color Blindness – Protanopia", "عمى الألوان – البروتانوبيا"), + ("Column Break", "فاصل عمود"), + ("Column spacing", "تباعد الأعمدة"), + ("Columns first", "الأعمدة أولاً"), + ("Columns:", "الأعمدة:"), + ("Conditional Text", "نص شرطي"), + ("Copyright Symbol", "رمز حقوق النشر"), + ("Corner Options", "خيارات الزوايا"), + ("Create Guides", "إنشاء أدلة"), + ("Current Page Number", "رقم الصفحة الحالية"), + ("Cyan", "سماوي"), + ("Dark", "داكن"), + ("Data Merge", "دمج البيانات"), + ("Define Cross-Reference Format", "تعريف تنسيق المرجع المتصالب"), + ("Define Lists", "تعريف القوائم"), + ("Define Text Variable", "تعريف متغير نص"), + ("Delete Type from Path", "حذف النص من المسار"), + ("DesignCraft Website", "موقع DesignCraft"), + ("DesignCraft on GitHub", "DesignCraft على GitHub"), + ("Detect Edges", "كشف الحواف"), + ("Discretionary Hyphen", "واصلة تقديرية"), + ("Document CMYK", "CMYK المستند"), + ("Document Endnote Options", "خيارات الحاشية الختامية للمستند"), + ("Document Footnote Options", "خيارات الحاشية السفلية للمستند"), + ("Document RGB", "RGB المستند"), + ("Double Left Quotation Marks", "علامتا اقتباس يسرى مزدوجتان"), + ("Double Right Quotation Marks", "علامتا اقتباس يمنى مزدوجتان"), + ("Edit/Interface Language", "تحرير/لغة الواجهة"), + ("Edit/Spelling", "تحرير/الإملاء"), + ("Edit/Transparency Blend Space", "تحرير/فراغ مزج الشفافية"), + ("Edit/Transparency Flattener Presets", "تحرير/إعدادات تسطيح الشفافية المسبقة"), + ("Ellipsis", "علامة حذف"), + ("Em Dash", "شرطة كاملة"), + ("Em Space", "مسافة كاملة"), + ("En Dash", "شرطة نصف كاملة"), + ("En Space", "مسافة نصف كاملة"), + ("Export EPUB", "تصدير EPUB"), + ("Export EPUB (Fixed Layout)", "تصدير EPUB (تخطيط ثابت)"), + ("Export EPUB (Reflowable)", "تصدير EPUB (قابل لإعادة التدفق)"), + ("Export HTML", "تصدير HTML"), + ("Export IDML", "تصدير IDML"), + ("Export PDF (Interactive)", "تصدير PDF (تفاعلي)"), + ("Export Page as PNG", "تصدير الصفحة كـ PNG"), + ("Export Text", "تصدير النص"), + ("Export XML", "تصدير XML"), + ("Figure Space", "مسافة رقمية"), + ("File/Export", "ملف/تصدير"), + ("Fill proportionally", "تعبئة متناسبة"), + ("Fit content to frame", "ملاءمة المحتوى للإطار"), + ("Fit proportionally", "ملاءمة متناسبة"), + ("Flush Space", "مسافة محاذاة"), + ("Forced Line Break", "فاصل سطر قسري"), + ("Frame Break", "فاصل إطار"), + ("GREP", "GREP"), + ("Generate Index", "إنشاء الفهرس"), + ("Generate Live Caption", "إنشاء تسمية توضيحية حية"), + ("Global Light", "الإضاءة الشاملة"), + ("Graphic", "رسم"), + ("Hair Space", "مسافة شعرية"), + ("High Contrast", "تباين عالٍ"), + ("High Resolution", "دقة عالية"), + ("Horizontal:", "أفقي:"), + ("I, II, III, IV...", "أ، ب، ج، د..."), + ("Import XML", "استيراد XML"), + ("Indent to Here", "إزاحة بادئة إلى هنا"), + ("Ink Limit 300%", "حد الحبر 300%"), + ("Insert Anchored Object", "إدراج كائن مرسّى"), + ("Insert Cross-Reference", "إدراج مرجع متصالب"), + ("Insert Math Expression", "إدراج تعبير رياضي"), + ("Insert Pages", "إدراج صفحات"), + ("Insert field", "إدراج حقل"), + ("Internet Standard RGB (sRGB)", "RGB القياسي للإنترنت (sRGB)"), + ("Join the ArtCraft Discord", "انضم إلى ArtCraft على Discord"), + ("Layout/Index", "تخطيط/الفهرس"), + ("Layout/Pages", "تخطيط/الصفحات"), + ("Left:", "يسار:"), + ("Legacy Macintosh RGB", "RGB ماكينتوش القديم"), + ("Light", "فاتح"), + ("Limit", "حد"), + ("Link images", "ربط الصور"), + ("Load DTD", "تحميل DTD"), + ("Load Swatches", "تحميل العينات"), + ("Low Resolution", "دقة منخفضة"), + ("Magenta", "أرجواني"), + ("Margins and Columns", "الهوامش والأعمدة"), + ("Margins:", "الهوامش:"), + ("Medium Dark", "داكن متوسط"), + ("Medium Light", "فاتح متوسط"), + ("Medium Resolution", "دقة متوسطة"), + ("Menus", "القوائم"), + ("Merge options", "خيارات الدمج"), + ("Monitor RGB", "RGB الشاشة"), + ("Move Pages", "نقل الصفحات"), + ("Multiple records", "سجلات متعددة"), + ("New Condition", "شرط جديد"), + ("New Hyperlink", "ارتباط تشعبي جديد"), + ("New Hyperlink Destination", "وجهة ارتباط تشعبي جديدة"), + ("New Page Reference", "مرجع صفحة جديد"), + ("New Parent", "أصل جديد"), + ("New Stroke Style", "نمط حد جديد"), + ("Next", "التالي"), + ("Next Page Number", "رقم الصفحة التالية"), + ("No data source", "لا مصدر بيانات"), + ("Nonbreaking Hyphen", "واصلة غير فاصلة"), + ("Nonbreaking Space", "مسافة غير فاصلة"), + ("None (keep transparency)", "بلا (الإبقاء على الشفافية)"), + ("Numbering & Section Options", "خيارات الترقيم والمقاطع"), + ("Object/Anchored Object", "كائن/كائن مرسّى"), + ("Object/Arrange", "كائن/ترتيب"), + ("Object/Captions", "كائن/التسميات التوضيحية"), + ("Object/Clipping Path", "كائن/مسار الاقتصاص"), + ("Object/Content", "كائن/محتوى"), + ("Object/Convert Shape", "كائن/تحويل الشكل"), + ("Object/Effects", "كائن/تأثيرات"), + ("Object/Fitting", "كائن/ملاءمة"), + ("Object/Pathfinder", "كائن/باحث المسارات"), + ("Object/Paths", "كائن/مسارات"), + ("Object/Select", "كائن/تحديد"), + ("Object/Transform", "كائن/تحويل"), + ("Object/Transform Again", "كائن/تحويل مجدداً"), + ("One record", "سجل واحد"), + ("Open Path", "مسار مفتوح"), + ("Package", "تجميع"), + ("Page Break", "فاصل صفحات"), + ("Paragraph Return", "محرف نهاية الفقرة"), + ("Paragraph Symbol", "رمز الفقرة"), + ("Pass csv, rows, or bytes from the control channel.", "مرّر csv أو rows أو bytes عبر قناة التحكم."), + ("Picas", "بيكا"), + ("Place with Import Options", "وضع مع خيارات الاستيراد"), + ("Preflight", "الفحص المسبق"), + ("Previous", "السابق"), + ("Previous Page Number", "رقم الصفحة السابقة"), + ("Print Booklet", "طباعة كتيّب"), + ("Punctuation Space", "مسافة ترقيم"), + ("Quarter Space", "مسافة ربعية"), + ("Quick Apply", "تطبيق سريع"), + ("RGB:", "RGB:"), + ("Range", "نطاق"), + ("Record", "سجل"), + ("Records per page", "سجلات في الصفحة"), + ("Registered Trademark Symbol", "رمز العلامة التجارية المسجلة"), + ("Relink", "إعادة ربط"), + ("Relink to Folder", "إعادة ربط إلى مجلد"), + ("Report an Issue", "الإبلاغ عن مشكلة"), + ("Right Indent Tab", "جدولة مسافة بادئة يمنى"), + ("Right:", "يمين:"), + ("Row spacing", "تباعد الصفوف"), + ("Rows first", "الصفوف أولاً"), + ("Save As", "حفظ باسم"), + ("Save Swatches for Exchange", "حفظ العينات للتبادل"), + ("Save a Copy", "حفظ نسخة"), + ("Scripts", "البرمجيات النصية"), + ("Section Marker", "علامة المقطع"), + ("Section Symbol", "رمز المقطع"), + ("Select Data Source…", "تحديد مصدر بيانات…"), + ("Sentence case", "حالة الجملة"), + ("Simulate Paper Color", "محاكاة لون الورق"), + ("Single Left Quotation Mark", "علامة اقتباس يسرى مفردة"), + ("Single Right Quotation Mark", "علامة اقتباس يمنى مفردة"), + ("Single record", "سجل مفرد"), + ("Sixth Space", "مسافة سدسية"), + ("Slug:", "الشريط:"), + ("Smart Guide Options", "خيارات الأدلة الذكية"), + ("Step and Repeat", "خطوة وتكرار"), + ("Story Column Direction", "اتجاه أعمدة التنسيق"), + ("Straight Double Quotation Marks", "علامتا اقتباس مستقيمتان مزدوجتان"), + ("Straight Single Quotation Mark (Apostrophe)", "علامة اقتباس مستقيمة مفردة (فاصلة عليا)"), + ("Subscript:", "رمز سفلي:"), + ("Superscript:", "رمز علوي:"), + ("Tab", "جدولة"), + ("Table of Contents", "جدول المحتويات"), + ("Table/Convert Cell Type", "جدول/تحويل نوع الخلية"), + ("Table/Delete", "جدول/حذف"), + ("Table/Insert", "جدول/إدراج"), + ("Table/Select", "جدول/تحديد"), + ("Thin Space", "مسافة رفيعة"), + ("Third Space", "مسافة ثلثية"), + ("Title Case", "حالة العنوان"), + ("Top:", "أعلى:"), + ("Trademark Symbol", "رمز العلامة التجارية"), + ("Transform Again Individually", "تحويل مجدداً بشكل فردي"), + ("Transform Sequence Again", "تحويل التسلسل مجدداً"), + ("Transform Sequence Again Individually", "تحويل التسلسل مجدداً بشكل فردي"), + ("Type on a Path Options", "خيارات النص على مسار"), + ("Type/Bulleted and Numbered Lists", "نص/القوائم النقطية والمرقمة"), + ("Type/Change Case", "نص/تغيير الحالة"), + ("Type/Hyperlinks & Cross-References", "نص/الارتباطات التشعبية والمراجع المتصالبة"), + ("Type/Insert Break Character", "نص/إدراج محرف فاصل"), + ("Type/Insert Special Character/Hyphens and Dashes", "نص/إدراج محرف خاص/الواصلات والشرطات"), + ("Type/Insert Special Character/Markers", "نص/إدراج محرف خاص/العلامات"), + ("Type/Insert Special Character/Other", "نص/إدراج محرف خاص/أخرى"), + ("Type/Insert Special Character/Quotation Marks", "نص/إدراج محرف خاص/علامات الاقتباس"), + ("Type/Insert Special Character/Symbols", "نص/إدراج محرف خاص/الرموز"), + ("Type/Insert White Space", "نص/إدراج مسافة بيضاء"), + ("Type/Proof Setup", "نص/إعداد التدقيق"), + ("Type/Rotate Spread", "نص/تدوير النطاق"), + ("Type/Separations Preview", "نص/معاينة الفصل"), + ("Type/Story Direction", "نص/اتجاه التنسيق"), + ("Type/Text Variables", "نص/متغيرات النص"), + ("Type/Track Changes", "نص/تعقب التغييرات"), + ("Type/Type on a Path", "نص/النص على مسار"), + ("URL:", "URL:"), + ("Unassigned", "غير معيّن"), + ("Vertical:", "رأسي:"), + ("View/Display Performance", "عرض/أداء العرض"), + ("View/Grids & Guides", "عرض/الشبكات والأدلة"), + ("Window/Arrange", "نافذة/ترتيب"), + ("Window/Interactive", "نافذة/تفاعلي"), + ("Window/Interface Color Theme", "نافذة/سمة ألوان الواجهة"), + ("Window/Output", "نافذة/الإخراج"), + ("Window/Panels", "نافذة/اللوحات"), + ("Window/Type & Tables", "نافذة/النص والجداول"), + ("Window/Utilities", "نافذة/الأدوات المساعدة"), + ("Working CMYK", "CMYK العامل"), + ("Yellow", "أصفر"), + ("a, b, c, d...", "أ، ب، ج، د..."), + ("i, ii, iii, iv...", "أ، ب، ج، د..."), + ("lowercase", "صغير"), ]; From 17cf5b4e91ebf0db0314e90fc4c4d8b4d7826c3d Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick <hi@skylar.cc> Date: Mon, 12 Oct 2026 13:57:37 -0600 Subject: [PATCH 091/238] test(recovery): make failed republishing fixtures deterministic Force actual failed recovery publication at the reserved destination so privileged runners exercise the intended retention path. Keep the normal writer, publication order and existing assertions; enable these fixtures across platforms. Validation: all six local gates and the FreeBSD root VM workspace build/tests. PR #627. --- crates/engine/src/recovery.rs | 48 ++++++++++++++++++++--------------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/crates/engine/src/recovery.rs b/crates/engine/src/recovery.rs index 85308be3b..33b9a2a33 100644 --- a/crates/engine/src/recovery.rs +++ b/crates/engine/src/recovery.rs @@ -656,6 +656,10 @@ pub fn list(dir: &Path) -> Vec<(u64, Value)> { /// Entries belonging to documents already open in this session's recovery folder are left alone. /// Returns the opened documents' indices. pub fn open(s: &mut Session, dir: &Path) -> Result<Vec<usize>> { + open_with_publisher(s, dir, |path, bytes| publish_recovery_file(path, bytes, false)) +} + +fn open_with_publisher(s: &mut Session, dir: &Path, mut publish: impl FnMut(&Path, &[u8]) -> Result<()>) -> Result<Vec<usize>> { #[cfg(not(target_arch = "wasm32"))] { validate_recovery_dir(dir)?; @@ -712,14 +716,14 @@ pub fn open(s: &mut Session, dir: &Path) -> Result<Vec<usize>> { // clean up every snapshot this document actually owns rather than an unrelated same uid. if let Some(state) = s.docs.get_mut(index) { let (document, metadata) = files(&destination, state.uid); - let publish = designcraft_format::save(state.doc.as_ref()) + let published = designcraft_format::save(state.doc.as_ref()) .map_err(|e| EngineError::Other(e.to_string())) - .and_then(|bytes| publish_recovery_file(&document, &bytes, false)); - match publish { + .and_then(|bytes| publish(&document, &bytes)); + match published { Ok(()) => { state.recovery_entries.push((destination.clone(), state.uid)); let meta = json!({"path": state.path, "title": state.title(), "saved": designcraft_doc::vars::now()}); - match publish_recovery_file(&metadata, meta.to_string().as_bytes(), false) { + match publish(&metadata, meta.to_string().as_bytes()) { Ok(()) => { discard(dir, uid); state.recovery_entries = vec![(destination.clone(), state.uid)]; @@ -801,20 +805,32 @@ mod tests { assert_eq!(list(dir.path()).len(), 1); } - #[cfg(unix)] + /// Directory collisions fail even for privileged runners. Creating the blocker in the + /// publisher occurs after UID reservation, which would otherwise skip an occupied path. + fn open_with_failed_republication(s: &mut Session, dir: &Path) -> Result<Vec<usize>> { + let mut attempts = 0; + let opened = open_with_publisher(s, dir, |path, bytes| { + assert_eq!(path.extension().and_then(|extension| extension.to_str()), Some("designcraft")); + std::fs::create_dir(path).unwrap(); + let result = publish_recovery_file(path, bytes, false); + std::fs::remove_dir(path).unwrap(); + assert!(result.is_err(), "a directory must obstruct actual recovery publication"); + attempts += 1; + result + }); + assert_eq!(attempts, 1, "the fixture must exercise the actual failed publication"); + opened + } + #[test] fn a_failed_recovery_rewrite_preserves_the_original() { - use std::os::unix::fs::PermissionsExt; let dir = tempfile::tempdir().unwrap(); assert_eq!(save(&unsaved("keep the original"), dir.path()).unwrap(), 1); let entries = list(dir.path()); let original = files(dir.path(), entries[0].0).0; let bytes = std::fs::read(&original).unwrap(); - std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o500)).unwrap(); let mut reopened = Session::new(); - let result = open(&mut reopened, dir.path()); - // Restore before assertions so a failing test does not leave an unremovable fixture. - std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + let result = open_with_failed_republication(&mut reopened, dir.path()); assert_eq!(result.unwrap().len(), 1); assert_eq!(texts(&reopened), ["keep the original"]); assert_eq!(std::fs::read(original).unwrap(), bytes); @@ -918,9 +934,7 @@ mod tests { } } - #[cfg(unix)] fn failed_republication_session() -> (Session, tempfile::TempDir, u64) { - use std::os::unix::fs::PermissionsExt; let root = tempfile::tempdir().unwrap(); let mut session = Session::new(); session.execute("file.new", &json!({})).unwrap(); @@ -929,15 +943,12 @@ mod tests { original.docs[0].uid = live_uid; save(&original, root.path()).unwrap(); session.recovery_dir = Some(root.path().to_path_buf()); - std::fs::set_permissions(root.path(), std::fs::Permissions::from_mode(0o500)).unwrap(); - let recovered = open(&mut session, root.path()); - std::fs::set_permissions(root.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + let recovered = open_with_failed_republication(&mut session, root.path()); assert_eq!(recovered.unwrap().len(), 1); assert_ne!(session.docs[1].uid, live_uid); (session, root, live_uid) } - #[cfg(unix)] #[test] fn retained_source_survives_a_coincident_live_uid_checkpoint_failure() { let (mut session, root, live_uid) = failed_republication_session(); @@ -950,7 +961,6 @@ mod tests { assert_eq!(std::fs::read(files(root.path(), live_uid).0).unwrap(), original); } - #[cfg(unix)] #[test] fn clean_autosave_preserves_another_documents_retained_source() { let (mut session, root, live_uid) = failed_republication_session(); @@ -962,7 +972,6 @@ mod tests { assert_eq!(std::fs::read(checkpoint).unwrap(), original); } - #[cfg(unix)] #[test] fn closing_a_coincident_document_preserves_another_documents_retained_source() { let (mut session, root, live_uid) = failed_republication_session(); @@ -972,7 +981,6 @@ mod tests { assert_eq!(std::fs::read(checkpoint).unwrap(), original); } - #[cfg(unix)] #[test] fn saving_a_coincident_document_preserves_another_documents_retained_source() { let (mut session, root, live_uid) = failed_republication_session(); @@ -983,7 +991,6 @@ mod tests { assert_eq!(std::fs::read(checkpoint).unwrap(), original); } - #[cfg(unix)] #[test] fn reverting_a_coincident_document_preserves_another_documents_retained_source() { let (mut session, root, live_uid) = failed_republication_session(); @@ -998,7 +1005,6 @@ mod tests { assert_eq!(std::fs::read(checkpoint).unwrap(), original); } - #[cfg(unix)] #[test] fn retained_source_provenance_is_consumed_before_its_uid_is_reused() { let (mut session, root, live_uid) = failed_republication_session(); From c35515c54daf282005faa41352a1688db8752874 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Mon, 12 Oct 2026 22:18:14 +0200 Subject: [PATCH 092/238] feat(tools): The Pen continues an open path from either end point As in InDesign, the Pen over an end point of an open path (selected, or under the pointer) shows a continue cursor; clicking it carries on drawing that path, new anchors going on its subpath, and clicking its other end closes it. Dragging from the end point first reshapes its direction lines. From the first anchor the subpath is reversed first. - engine: path.reverse (Object > Paths > Reverse Path), path.setAnchor; path.appendAnchor and path.close take a subpath, and appendAnchor refuses a closed subpath - tools: Cursor::PenContinue Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 1 + crates/engine/src/cmd/object.rs | 194 ++++++++++++++++++++++++++++++- crates/tools/src/lib.rs | 2 + crates/tools/src/pen.rs | 108 ++++++++++++++++- crates/tools/src/tests.rs | 59 ++++++++++ crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/i18n.rs | 2 + crates/ui-egui/src/i18n/ar.rs | 2 + crates/ui-egui/src/i18n/it.rs | 2 + crates/ui-egui/src/i18n/ja.rs | 2 + crates/ui-egui/src/i18n/pt_br.rs | 2 + crates/ui-egui/src/i18n/uk.rs | 2 + crates/ui-egui/src/menus.rs | 2 + 13 files changed, 368 insertions(+), 12 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4b7163c76..4f42aa6ba 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-12** · The Pen tool continues an open path from either end point, as in InDesign: the cursor shows it over an end point, a click there carries on drawing that path, a drag first reshapes the end point's direction lines, and the other end closes it. Object › Paths › Reverse Path (`path.reverse`); `path.setAnchor`; `path.appendAnchor` and `path.close` take a subpath. No readiness percentage change. - **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change. - **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change. diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index cd13be117..e79009e7e 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -595,8 +595,34 @@ pub fn specs() -> Vec<CommandSpec> { step_and_repeat ), cmd!("path.create", "Create Path", [], None, "{spread?, anchors: [{p:[x,y], in?:[x,y], out?:[x,y]}], closed?: bool}", has_doc, path_create), - cmd!("path.appendAnchor", "Add Anchor", [], None, "{id, anchor: {p, in?, out?}} (spread coords)", has_doc, path_append), - cmd!("path.close", "Close Path", ["Object", "Paths"], None, "{id}", has_doc, path_close), + cmd!( + "path.appendAnchor", + "Add Anchor", + [], + None, + "{id, anchor: {p, in?, out?} (spread coords), subpath?: index (default the last)} — after the subpath's last anchor", + has_doc, + path_append + ), + cmd!( + "path.setAnchor", + "Set Anchor", + [], + None, + "{id, subpath?: index (default the last), anchor: index|\"first\"|\"last\", p?, in?, out? (spread coords)} — moves an anchor or its direction lines; with both lines given and collinear it becomes smooth", + has_doc, + path_set_anchor + ), + cmd!( + "path.reverse", + "Reverse Path", + ["Object", "Paths"], + None, + "{ids?, subpath?: index (only that subpath of the first target)} — the anchors run the other way (the end point becomes the start)", + has_selection, + path_reverse + ), + cmd!("path.close", "Close Path", ["Object", "Paths"], None, "{id, subpath?: index (default every subpath)}", has_doc, path_close), cmd!("path.moveAnchors", "Move Anchors", [], None, "{id, anchors: [[subpath, index]], dx, dy, handle?: in|out}", has_doc, path_move_anchors), cmd!( "object.align", @@ -1935,11 +1961,20 @@ fn path_create(s: &mut Session, p: &Value) -> Result<Value> { fn path_append(s: &mut Session, p: &Value) -> Result<Value> { let id = super::id_param(p, "id").ok_or_else(|| bad("path.appendAnchor", "missing id"))?; let a = p.get("anchor").and_then(anchor_from).ok_or_else(|| bad("path.appendAnchor", "missing anchor"))?; + let subpath = p.get("subpath").and_then(Value::as_u64); s.edit(|d, _| { let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?; let inv = (d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default()).inverse(); let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; - let sp = it.path.subpaths.last_mut().ok_or_else(|| bad("path.appendAnchor", "empty path"))?; + let sp = match subpath { + Some(i) => { + usize::try_from(i).ok().and_then(|i| it.path.subpaths.get_mut(i)).ok_or_else(|| bad("path.appendAnchor", "no such subpath"))? + } + None => it.path.subpaths.last_mut().ok_or_else(|| bad("path.appendAnchor", "empty path"))?, + }; + if sp.closed { + return Err(bad("path.appendAnchor", "the subpath is closed")); + } sp.anchors.push(a.transform(inv)); if it.shape == Shape::GraphicLine { it.shape = Shape::Path; @@ -1948,12 +1983,103 @@ fn path_append(s: &mut Session, p: &Value) -> Result<Value> { }) } +fn path_set_anchor(s: &mut Session, p: &Value) -> Result<Value> { + const ID: &str = "path.setAnchor"; + let id = super::id_param(p, "id").ok_or_else(|| bad(ID, "missing id"))?; + let pt = |k: &str| -> Option<Point> { + let a = p.get(k)?.as_array()?; + let q = Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?); + (q.x.is_finite() && q.y.is_finite()).then_some(q) + }; + let (new_p, new_in, new_out) = (pt("p"), pt("in"), pt("out")); + if new_p.is_none() && new_in.is_none() && new_out.is_none() { + return Err(bad(ID, "give p, in or out")); + } + let subpath = p.get("subpath").and_then(Value::as_u64); + let which = p.get("anchor").cloned().unwrap_or(Value::Null); + s.edit(|d, _| { + let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?; + let inv = (d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default()).inverse(); + let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; + if it.locked { + return Err(bad(ID, "the path is locked")); + } + let sp = match subpath { + Some(i) => usize::try_from(i).ok().and_then(|i| it.path.subpaths.get_mut(i)), + None => it.path.subpaths.last_mut(), + } + .ok_or_else(|| bad(ID, "no such subpath"))?; + let n = sp.anchors.len(); + let ai = match &which { + Value::String(w) if w == "first" => 0, + Value::String(w) if w == "last" => n.saturating_sub(1), + v => v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(ID, "anchor: an index, \"first\" or \"last\""))?, + }; + let a = sp.anchors.get_mut(ai).ok_or_else(|| bad(ID, "no such anchor"))?; + if let Some(q) = new_p { + let v = inv * q - a.p; + a.p += v; + a.h_in += v; + a.h_out += v; + } + if let Some(q) = new_in { + a.h_in = inv * q; + } + if let Some(q) = new_out { + a.h_out = inv * q; + } + if new_in.is_some() && new_out.is_some() { + let (i, o) = (a.h_in - a.p, a.h_out - a.p); + let collinear = (i.x * o.y - i.y * o.x).abs() <= 1e-6 * i.hypot().max(1.0) * o.hypot().max(1.0) && i.dot(o) <= 0.0; + a.kind = if collinear { designcraft_geom::AnchorKind::Smooth } else { designcraft_geom::AnchorKind::Corner }; + } + ok() + }) +} + +fn path_reverse(s: &mut Session, p: &Value) -> Result<Value> { + let ids = targets(s, p)?; + let only = p.get("subpath").and_then(Value::as_u64).map(|i| usize::try_from(i).unwrap_or(usize::MAX)); + if let (Some(i), Some(id)) = (only, ids.first()) + && s.doc()?.doc.item(*id).is_none_or(|it| it.path.subpaths.get(i).is_none()) + { + return Err(bad("path.reverse", "no such subpath")); + } + let ids = if only.is_some() { ids.into_iter().take(1).collect() } else { ids }; + s.edit(|d, _| { + let mut reversed = 0; + for id in &ids { + let Some(it) = d.item_mut(*id) else { continue }; + if it.locked || it.is_group() { + continue; + } + for (i, sp) in it.path.subpaths.iter_mut().enumerate() { + if only.is_some_and(|o| o != i) { + continue; + } + sp.anchors.reverse(); + for a in &mut sp.anchors { + std::mem::swap(&mut a.h_in, &mut a.h_out); + } + } + reversed += 1; + } + Ok(json!({"reversed": reversed})) + }) +} + fn path_close(s: &mut Session, p: &Value) -> Result<Value> { let id = super::id_param(p, "id").ok_or_else(|| bad("path.close", "missing id"))?; + let only = p.get("subpath").and_then(Value::as_u64).map(|i| usize::try_from(i).unwrap_or(usize::MAX)); s.edit(|d, _| { let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; - for sp in &mut it.path.subpaths { - sp.closed = true; + if only.is_some_and(|i| i >= it.path.subpaths.len()) { + return Err(bad("path.close", "no such subpath")); + } + for (i, sp) in it.path.subpaths.iter_mut().enumerate() { + if only.is_none_or(|o| o == i) { + sp.closed = true; + } } if it.shape == Shape::GraphicLine { it.shape = Shape::Path; @@ -2813,3 +2939,61 @@ mod swatch_name_tests { } } } + +#[cfg(test)] +mod path_edit_tests { + use designcraft_doc::ItemId; + use designcraft_geom::Point; + use serde_json::json; + + fn session_with_path() -> (crate::Session, u64) { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s + .execute( + "path.create", + &json!({"anchors": [{"p": [100, 100]}, {"p": [200, 100], "in": [170, 80], "out": [230, 120]}, {"p": [300, 200]}]}), + ) + .unwrap()["id"] + .as_u64() + .unwrap(); + (s, id) + } + + fn anchors(s: &crate::Session, id: u64) -> Vec<designcraft_geom::Anchor> { + let d = &s.doc().unwrap().doc; + let it = d.item(ItemId(id)).unwrap(); + let xf = d.parent_xf(&d.find(ItemId(id)).unwrap()) * it.xf; + it.path.subpaths[0].anchors.iter().map(|a| a.transform(xf)).collect() + } + + #[test] + fn reverse_path_swaps_ends_and_direction_lines() { + let (mut s, id) = session_with_path(); + s.execute("path.reverse", &json!({"ids": [id]})).unwrap(); + let a = anchors(&s, id); + assert_eq!(a.iter().map(|a| a.p).collect::<Vec<_>>(), [Point::new(300.0, 200.0), Point::new(200.0, 100.0), Point::new(100.0, 100.0)]); + assert_eq!((a[1].h_in, a[1].h_out), (Point::new(230.0, 120.0), Point::new(170.0, 80.0))); + assert!(s.execute("path.reverse", &json!({"ids": [id], "subpath": 3})).is_err()); + } + + #[test] + fn anchors_go_on_the_named_open_subpath_and_set_anchor_reshapes_one() { + let (mut s, id) = session_with_path(); + s.execute("path.appendAnchor", &json!({"id": id, "subpath": 0, "anchor": {"p": [400, 250]}})).unwrap(); + assert_eq!(anchors(&s, id).last().unwrap().p, Point::new(400.0, 250.0)); + assert!(s.execute("path.appendAnchor", &json!({"id": id, "subpath": 1, "anchor": {"p": [0, 0]}})).is_err()); + // Direction lines on the last anchor: collinear and opposite make it smooth. + s.execute("path.setAnchor", &json!({"id": id, "anchor": "last", "in": [380, 230], "out": [420, 270]})).unwrap(); + let last = *anchors(&s, id).last().unwrap(); + assert_eq!((last.h_in, last.h_out, last.kind), (Point::new(380.0, 230.0), Point::new(420.0, 270.0), designcraft_geom::AnchorKind::Smooth)); + s.execute("path.setAnchor", &json!({"id": id, "anchor": 0, "p": [90, 110]})).unwrap(); + assert_eq!(anchors(&s, id)[0].p, Point::new(90.0, 110.0)); + assert!(s.execute("path.setAnchor", &json!({"id": id, "anchor": 9, "p": [0, 0]})).is_err()); + assert!(s.execute("path.setAnchor", &json!({"id": id, "anchor": "last"})).is_err(), "nothing to set"); + // Closing one subpath; then it takes no more anchors. + s.execute("path.close", &json!({"id": id, "subpath": 0})).unwrap(); + assert!(s.execute("path.appendAnchor", &json!({"id": id, "anchor": {"p": [0, 0]}})).is_err()); + assert!(s.execute("path.close", &json!({"id": id, "subpath": 4})).is_err()); + } +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 247494f84..4dad3be41 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -335,6 +335,8 @@ pub enum Cursor { ResizeNeSw, Rotate, Pen, + /// The Pen over an open path's end point: a click continues the path. + PenContinue, Text, Hand, HandGrab, diff --git a/crates/tools/src/pen.rs b/crates/tools/src/pen.rs index 3c75e51cd..21e90f734 100644 --- a/crates/tools/src/pen.rs +++ b/crates/tools/src/pen.rs @@ -1,8 +1,9 @@ //! Pen tool (P): click for corner points, drag for smooth points, click the first point to close, //! Enter/Esc (or switching tools) to finish an open path. Each anchor is its own undo step, as in -//! InDesign. +//! InDesign. Clicking an end point of an open path (the cursor shows it) continues that path from +//! there; dragging from it first reshapes its direction lines. -use designcraft_doc::SpreadRef; +use designcraft_doc::{ItemId, SpreadRef}; use designcraft_geom::{BezPath, Point}; use serde_json::{Value, json}; @@ -19,6 +20,45 @@ pub struct PenTool { hover: Point, /// Smart guides in canvas coordinates, as `snap_point` returned them. guides: Vec<Overlay>, + /// Continuing an existing path: the subpath the new anchors go on. + subpath: Option<usize>, + /// The press was on the continued path's end point: a drag reshapes its direction lines + /// instead of adding an anchor. + editing_end: bool, + /// The press on the end point has been dragged. + end_moved: bool, +} + +/// An end point of an open path under canvas point `p`: (item, subpath, whether it is the last +/// anchor). The selection is checked first, then the frontmost item under the pointer. +fn endpoint_at(cx: &ToolContext, p: Point) -> Option<(ItemId, usize, bool)> { + let tol = cx.tol(6.0); + let mut ids = cx.selection.items.clone(); + if let Some((_, id)) = cx.hit(p) + && !ids.contains(&id) + { + ids.push(id); + } + for id in ids { + let Some(it) = cx.doc.item(id) else { continue }; + if it.locked || it.is_group() || !matches!(it.content, designcraft_doc::Content::Unassigned) { + continue; + } + let Some(xf) = cx.item_canvas_xf(id).map(|m| m * it.xf) else { continue }; + for (si, sp) in it.path.subpaths.iter().enumerate() { + if sp.closed { + continue; + } + let (Some(first), Some(last)) = (sp.anchors.first(), sp.anchors.last()) else { continue }; + if (xf * last.p - p).hypot() <= tol { + return Some((id, si, true)); + } + if (xf * first.p - p).hypot() <= tol { + return Some((id, si, false)); + } + } + } + None } fn anchor_json(a: &(Point, Point, Point)) -> Value { @@ -32,9 +72,35 @@ impl PenTool { self.anchors.clear(); self.dragging = false; self.guides.clear(); + self.subpath = None; + self.editing_end = false; + self.end_moved = false; vec![] } + /// Start continuing subpath `si` of `id` from its last (`at_end`) or first anchor: the anchors + /// so far are its other end and the end point, in spread coordinates. + fn continue_from(&mut self, cx: &ToolContext, id: ItemId, si: usize, at_end: bool) -> Vec<Action> { + let (Some(it), Some(loc)) = (cx.doc.item(id), cx.doc.find(id)) else { return vec![] }; + let Some(sp) = it.path.subpaths.get(si) else { return vec![] }; + let (Some(first), Some(last)) = (sp.anchors.first(), sp.anchors.last()) else { return vec![] }; + let xf = cx.doc.parent_xf(&loc) * it.xf; + let at = |a: &designcraft_geom::Anchor, flip: bool| { + let (i, o) = if flip { (a.h_out, a.h_in) } else { (a.h_in, a.h_out) }; + (xf * a.p, xf * i, xf * o) + }; + // From the first anchor the path is reversed first, so the new anchors follow on. + let (other, end) = if at_end { (at(first, false), at(last, false)) } else { (at(last, true), at(first, true)) }; + self.path = Some(id.0); + self.subpath = Some(si); + self.spread = Some(loc.spread); + self.anchors = vec![other, end]; + self.editing_end = true; + self.end_moved = false; + self.dragging = true; + if at_end { vec![] } else { vec![Action::Exec("path.reverse".into(), json!({"ids": [id.0], "subpath": si}))] } + } + /// Shift constrains first. The stored point is the snapped spread point. Guides are already canvas. fn snap_click(&mut self, cx: &ToolContext, spread: SpreadRef, mut p: Point, shift: bool) -> Point { if shift && let Some(last) = self.anchors.last() { @@ -53,7 +119,13 @@ impl PenTool { let anchors: Vec<Value> = self.anchors.iter().map(anchor_json).collect(); vec![Action::Exec("path.create".into(), json!({"spread": spread_json(sr), "anchors": anchors, "closed": false, "notifyPen": true}))] } - Some(id) => vec![Action::Exec("path.appendAnchor".into(), json!({"id": id, "anchor": anchor_json(last)}))], + Some(id) => { + let mut p = json!({"id": id, "anchor": anchor_json(last)}); + if let Some(si) = self.subpath { + p["subpath"] = json!(si); + } + vec![Action::Exec("path.appendAnchor".into(), p)] + } None => vec![], } } @@ -93,12 +165,24 @@ impl Tool for PenTool { && (first.0 - sp).hypot() <= cx.tol(6.0) { let out = match self.path { - Some(id) => vec![Action::Exec("path.close".into(), json!({"id": id}))], + Some(id) => { + let mut p = json!({"id": id}); + if let Some(si) = self.subpath { + p["subpath"] = json!(si); + } + vec![Action::Exec("path.close".into(), p)] + } None => vec![], }; self.finish(); return out; } + // On an open path's end point: continue that path. + if self.anchors.is_empty() + && let Some((id, si, at_end)) = endpoint_at(cx, ev.pos) + { + return self.continue_from(cx, id, si, at_end); + } let p = self.snap_click(cx, snap_spread, sp, ev.mods.shift); if self.anchors.is_empty() { self.spread = Some(sr); @@ -108,6 +192,7 @@ impl Tool for PenTool { vec![] } PointerKind::Drag => { + self.end_moved |= self.editing_end; if let Some(a) = self.anchors.last_mut() { // Smooth point: out handle follows the pointer, in handle mirrors it. a.2 = sp; @@ -120,6 +205,17 @@ impl Tool for PenTool { return vec![]; } self.dragging = false; + // The continued path's end point: a drag set its direction lines. + if std::mem::take(&mut self.editing_end) { + let (Some(id), Some(si), Some(&(p, h_in, h_out))) = (self.path, self.subpath, self.anchors.last()) else { return vec![] }; + if !std::mem::take(&mut self.end_moved) || (h_out - p).hypot() < 1e-9 { + return vec![]; + } + return vec![Action::Exec( + "path.setAnchor".into(), + json!({"id": id, "subpath": si, "anchor": "last", "in": [h_in.x, h_in.y], "out": [h_out.x, h_out.y]}), + )]; + } self.commit_anchor() } _ => vec![], @@ -165,8 +261,8 @@ impl Tool for PenTool { out } - fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { - Cursor::Pen + fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor { + if self.anchors.is_empty() && endpoint_at(cx, p).is_some() { Cursor::PenContinue } else { Cursor::Pen } } fn busy(&self) -> bool { diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 6c75b0da6..f8f58c7d3 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -705,3 +705,62 @@ fn pen_click_snaps_to_a_guide() { let x = p["anchors"][0]["p"][0].as_f64().unwrap(); assert!((x - 100.0).abs() < 1e-6, "anchor x {x}"); } + +#[test] +fn pen_continues_an_open_path_from_either_end_point() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let id = ItemId(d.alloc()); + let anchors = vec![ + designcraft_geom::Anchor::corner(Point::new(100.0, 100.0)), + designcraft_geom::Anchor { + p: Point::new(200.0, 100.0), + h_in: Point::new(170.0, 80.0), + h_out: Point::new(230.0, 120.0), + kind: designcraft_geom::AnchorKind::Smooth, + }, + ]; + let mut it = Item::new(id, lid, Shape::Path, designcraft_geom::PathData::single(designcraft_geom::SubPath::new(anchors, false))); + it.stroke = Stroke::default(); + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); + let cx = ctx(&d, &s, &c, &l); + let off = l.xf(SpreadRef::Doc(0)).translation(); + let at = |x: f64, y: f64| (x + off.x, y + off.y); + let ev = |k: PointerKind, (x, y): (f64, f64)| PointerEvent::new(k, x, y); + let mut t = create("pen"); + // The cursor says the end point continues the path; elsewhere it's the plain pen. + assert_eq!(t.cursor(&cx, Point::new(at(200.0, 100.0).0, at(200.0, 100.0).1), Mods::default()), Cursor::PenContinue); + assert_eq!(t.cursor(&cx, Point::new(at(150.0, 300.0).0, at(150.0, 300.0).1), Mods::default()), Cursor::Pen); + // Clicking the last anchor adds nothing yet; the next click appends to that path. + assert!(t.pointer(&cx, &ev(PointerKind::Down, at(200.0, 100.0))).is_empty()); + assert!(t.pointer(&cx, &ev(PointerKind::Up, at(200.0, 100.0))).is_empty(), "a click on the end point changes nothing"); + t.pointer(&cx, &ev(PointerKind::Down, at(300.0, 200.0))); + let a = t.pointer(&cx, &ev(PointerKind::Up, at(300.0, 200.0))); + let [Action::Exec(cmd, p)] = a.as_slice() else { panic!("{a:?}") }; + assert_eq!((cmd.as_str(), p["id"].as_u64(), p["subpath"].as_u64()), ("path.appendAnchor", Some(id.0), Some(0))); + assert_eq!((p["anchor"]["p"][0].as_f64(), p["anchor"]["p"][1].as_f64()), (Some(300.0), Some(200.0))); + // Clicking the first anchor of the other end closes it. + let a = t.pointer(&cx, &ev(PointerKind::Down, at(100.0, 100.0))); + assert_eq!(a, vec![Action::Exec("path.close".into(), serde_json::json!({"id": id.0, "subpath": 0}))]); + assert!(t.pointer(&cx, &ev(PointerKind::Up, at(100.0, 100.0))).is_empty()); + // From the first anchor, the path is reversed first; a drag there sets its direction lines. + let mut t = create("pen"); + let a = t.pointer(&cx, &ev(PointerKind::Down, at(100.0, 100.0))); + assert_eq!(a, vec![Action::Exec("path.reverse".into(), serde_json::json!({"ids": [id.0], "subpath": 0}))]); + t.pointer(&cx, &ev(PointerKind::Drag, at(80.0, 140.0))); + let a = t.pointer(&cx, &ev(PointerKind::Up, at(80.0, 140.0))); + assert_eq!( + a, + vec![Action::Exec( + "path.setAnchor".into(), + serde_json::json!({"id": id.0, "subpath": 0, "anchor": "last", "in": [120.0, 60.0], "out": [80.0, 140.0]}) + )] + ); + // Closed paths and locked paths aren't continued. + let mut d2 = d.clone(); + d2.item_mut(id).unwrap().locked = true; + let cx = ctx(&d2, &s, &c, &l); + let t = create("pen"); + assert_eq!(t.cursor(&cx, Point::new(at(200.0, 100.0).0, at(200.0, 100.0).1), Mods::default()), Cursor::Pen); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4c7313e61..176b8a229 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -352,7 +352,7 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon { Cursor::ResizeNwSe => C::ResizeNwSe, Cursor::ResizeNeSw => C::ResizeNeSw, Cursor::Rotate => C::Alias, - Cursor::Pen => C::Crosshair, + Cursor::Pen | Cursor::PenContinue => C::Crosshair, Cursor::Text => C::Text, Cursor::Hand => C::Grab, Cursor::HandGrab => C::Grabbing, diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 5976d7d07..39b31ca0c 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -55,6 +55,8 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Cut", ["Ausschneiden", "Couper", "Cortar", "カット", "剪切"]), ("Copy", ["Kopieren", "Copier", "Copiar", "コピー", "复制"]), ("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]), + ("Reverse Path", ["Pfadrichtung umkehren", "Inverser le tracé", "Invertir trazado", "パスの方向反転", "反转路径"]), + ("Set Anchor", ["Ankerpunkt festlegen", "Définir le point d’ancrage", "Definir punto de ancla", "アンカーポイントを設定", "设置锚点"]), ("Clear", ["Löschen", "Effacer", "Borrar", "消去", "清除"]), ("Duplicate", ["Duplizieren", "Dupliquer", "Duplicar", "複製", "重复"]), ("Select All", ["Alles auswählen", "Tout sélectionner", "Seleccionar todo", "すべてを選択", "全选"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index c3373486e..d02370d52 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Reverse Path", "عكس المسار"), + ("Set Anchor", "تعيين نقطة الارتساء"), ("Clear", "مسح"), ("Delete", "حذف"), ("Duplicate", "تكرار"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 34766cf22..e61d461a0 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Reverse Path", "Inverti tracciato"), + ("Set Anchor", "Imposta ancoraggio"), ("Clear", "Cancella"), ("Duplicate", "Duplica"), ("Select All", "Seleziona tutto"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index de57db5e9..7dd06af9c 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -30,6 +30,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "カット"), ("Copy", "コピー"), ("Paste", "ペースト"), + ("Reverse Path", "パスの方向反転"), + ("Set Anchor", "アンカーポイントを設定"), ("Clear", "消去"), ("Duplicate", "複製"), ("Select All", "すべてを選択"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index a4d06bbe9..ea7f5291c 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Reverse Path", "Inverter demarcador"), + ("Set Anchor", "Definir ponto de ancoragem"), ("Clear", "Limpar"), ("Delete", "Excluir"), ("Duplicate", "Duplicar"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 9b70204a4..96bd9bb3e 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Reverse Path", "Розвернути контур"), + ("Set Anchor", "Задати прив’язку"), ("Clear", "Очистити"), ("Duplicate", "Дублювати"), ("Select All", "Виділити все"), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index cb1422394..f2b3d892f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -486,6 +486,8 @@ pub const MENUS: &[(&str, &[&str])] = &[ ">Paths", "cmd:object.makeCompoundPath", "cmd:object.releaseCompoundPath", + "-", + "cmd:path.reverse", "<", ">Pathfinder", "cmd:object.pathfinder|Add|{\"op\": \"add\"}", From 401fe3bb67dcb4a7c1bdac257b4de2b855d7fa69 Mon Sep 17 00:00:00 2001 From: Isaac Levine <isaacmlevine4@gmail.com> Date: Mon, 12 Oct 2026 10:57:59 -0400 Subject: [PATCH 093/238] Engine: check automation read and write roots on every path a command reads or writes --- crates/engine/src/cmd/book.rs | 10 +- crates/engine/src/cmd/colorsettings.rs | 3 +- crates/engine/src/cmd/datamerge/fill.rs | 4 +- crates/engine/src/cmd/datamerge/mod.rs | 10 +- crates/engine/src/cmd/datamerge/parse.rs | 2 +- crates/engine/src/cmd/export.rs | 10 +- crates/engine/src/cmd/file.rs | 73 +++- crates/engine/src/cmd/interchange.rs | 16 +- crates/engine/src/cmd/library.rs | 4 +- crates/engine/src/cmd/links.rs | 12 +- crates/engine/src/cmd/package.rs | 2 + crates/engine/src/cmd/style.rs | 8 +- crates/engine/src/cmd/xml.rs | 4 +- crates/engine/src/file_access.rs | 515 +++++++++++++++++++++++ crates/engine/src/io.rs | 71 ++++ crates/engine/src/lib.rs | 4 + crates/engine/src/tests_roots.rs | 212 ++++++++++ 17 files changed, 900 insertions(+), 60 deletions(-) create mode 100644 crates/engine/src/file_access.rs create mode 100644 crates/engine/src/io.rs create mode 100644 crates/engine/src/tests_roots.rs diff --git a/crates/engine/src/cmd/book.rs b/crates/engine/src/cmd/book.rs index fbf80ed9e..58edc6aa4 100644 --- a/crates/engine/src/cmd/book.rs +++ b/crates/engine/src/cmd/book.rs @@ -28,7 +28,7 @@ fn no_book() -> EngineError { #[cfg(not(target_arch = "wasm32"))] fn save(b: &Book) -> Result<()> { let bytes = serde_json::to_vec_pretty(b).map_err(|e| EngineError::Other(e.to_string()))?; - crate::atomic::write_file(&b.path, &bytes) + crate::io::write_file(&b.path, &bytes).map_err(EngineError::Other) } #[cfg(target_arch = "wasm32")] @@ -38,14 +38,14 @@ fn save(_: &Book) -> Result<()> { fn load(path: &str) -> Result<designcraft_doc::Document> { #[cfg(not(target_arch = "wasm32"))] - let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let bytes = crate::io::read_file(path).map_err(EngineError::Other)?; #[cfg(target_arch = "wasm32")] let bytes: Vec<u8> = { let _ = path; vec![] }; if path.to_lowercase().ends_with(".idml") { - return designcraft_idml::import_idml(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}"))); + return super::interchange::import_idml_confined(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}"))); } super::file::from_bytes(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}"))) } @@ -248,7 +248,7 @@ pub fn specs() -> Vec<CommandSpec> { cmd!(noundo "book.open", "Open Book", [], None, "{path} → {documents}", always, |s, p| { let path = str_param(p, "path").ok_or_else(|| bad("book.open", "`path` required"))?.to_string(); #[cfg(not(target_arch = "wasm32"))] - let text = std::fs::read_to_string(&path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let text = crate::io::read_to_string(&path).map_err(EngineError::Other)?; #[cfg(target_arch = "wasm32")] let text = String::new(); let mut b: Book = serde_json::from_str(&text).map_err(|e| bad("book.open", format!("not a book: {e}")))?; @@ -331,7 +331,7 @@ pub fn specs() -> Vec<CommandSpec> { match str_param(p, "path") { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - crate::atomic::write_file(path, &bytes)?; + crate::io::write_file(path, &bytes).map_err(EngineError::Other)?; Ok(json!({"path": path, "bytes": bytes.len(), "pages": pages})) } None => Ok(json!({"base64": super::file::base64_encode(&bytes), "bytes": bytes.len(), "pages": pages})), diff --git a/crates/engine/src/cmd/colorsettings.rs b/crates/engine/src/cmd/colorsettings.rs index c62b393e9..fc1d062a5 100644 --- a/crates/engine/src/cmd/colorsettings.rs +++ b/crates/engine/src/cmd/colorsettings.rs @@ -45,7 +45,8 @@ pub fn specs() -> Vec<CommandSpec> { let path = str_param(p, "path").ok_or_else(|| bad("color.loadProfile", "`path` required"))?; #[cfg(not(target_arch = "wasm32"))] { - let info = cms::load_icc_file(std::path::Path::new(path)).map_err(|e| bad("color.loadProfile", e.to_string()))?; + let bytes = crate::io::read_file(path).map_err(|e| bad("color.loadProfile", e))?; + let info = cms::register_icc(&bytes, None).map_err(|e| bad("color.loadProfile", e.to_string()))?; Ok(json!(info)) } #[cfg(target_arch = "wasm32")] diff --git a/crates/engine/src/cmd/datamerge/fill.rs b/crates/engine/src/cmd/datamerge/fill.rs index 89dd0c74e..6ecb1470d 100644 --- a/crates/engine/src/cmd/datamerge/fill.rs +++ b/crates/engine/src/cmd/datamerge/fill.rs @@ -340,11 +340,11 @@ fn place_image( let cands = image_candidates(cell, data_dir, doc_dir); let found = cands.iter().find_map(|p| { // Refuse huge files before reading them; a too-large image counts as missing. - let len = std::fs::metadata(p).ok()?.len(); + let len = crate::io::metadata(p).ok()?.len(); if len == 0 || len > super::MAX_IMAGE_FILE_BYTES { return None; } - std::fs::read(p).ok().filter(|b| !b.is_empty()).map(|b| (p.clone(), b)) + crate::io::read_file(p).ok().filter(|b| !b.is_empty()).map(|b| (p.clone(), b)) }); let Some((path, bytes)) = found else { if let Some(it) = doc.item_mut(item) { diff --git a/crates/engine/src/cmd/datamerge/mod.rs b/crates/engine/src/cmd/datamerge/mod.rs index 3b3e1dd3b..eda169b12 100644 --- a/crates/engine/src/cmd/datamerge/mod.rs +++ b/crates/engine/src/cmd/datamerge/mod.rs @@ -619,7 +619,7 @@ pub(crate) fn refresh_relative_paths(doc: &mut Document, doc_path: &Path) { /// again as `relative_path` beside the document. pub(crate) fn resolve_sources_on_open(doc: &mut Document, doc_path: Option<&Path>) { for src in &mut doc.data_merge.sources { - let abs_exists = src.path.as_deref().is_some_and(|p| Path::new(p).is_file()); + let abs_exists = src.path.as_deref().is_some_and(crate::io::is_file); if abs_exists { if let Some(path) = src.path.clone() { src.status = status_of(Path::new(&path), src.fingerprint); @@ -630,7 +630,7 @@ pub(crate) fn resolve_sources_on_open(doc: &mut Document, doc_path: Option<&Path && let Some(dir) = doc_path.parent() { let candidate = dir.join(&rel); - if candidate.is_file() { + if crate::io::is_file(&candidate) { let saved = candidate.to_string_lossy().into_owned(); src.fingerprint = fingerprint_of(Path::new(&saved)); src.status = SourceStatus::Ok; @@ -768,12 +768,14 @@ fn read_path(path: &str) -> std::result::Result<Vec<u8>, String> { } fn read_path_capped(path: &str, cap: u64) -> std::result::Result<Vec<u8>, String> { - match std::fs::metadata(path) { + match crate::io::metadata(path) { Ok(m) if m.len() > cap => return Err(format!("{path}: the data file is larger than {} MiB", cap / (1024 * 1024))), Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(format!("{path}: the data file is missing")), _ => {} } - match std::fs::read(path) { + // The roots' own message when they refuse the path. + crate::file_access::check_read(path)?; + match crate::io::read(path) { Ok(b) => Ok(b), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(format!("{path}: the data file is missing")), Err(e) => Err(format!("{path}: {e}")), diff --git a/crates/engine/src/cmd/datamerge/parse.rs b/crates/engine/src/cmd/datamerge/parse.rs index e65493dfc..689f45ca6 100644 --- a/crates/engine/src/cmd/datamerge/parse.rs +++ b/crates/engine/src/cmd/datamerge/parse.rs @@ -330,7 +330,7 @@ pub fn relative_between(from_dir: &Path, target: &Path) -> String { } pub fn fingerprint_of(path: &Path) -> Option<Fingerprint> { - let meta = std::fs::metadata(path).ok()?; + let meta = crate::io::metadata(path).ok()?; let mtime = meta .modified() .ok() diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 8e0f04d69..967349a4a 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -44,7 +44,7 @@ fn print_booklet(s: &mut Session, p: &Value) -> Result<Value> { match str_param(p, "path") { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - crate::atomic::write_file(path, &r.bytes)?; + crate::io::write_file(path, &r.bytes).map_err(EngineError::Other)?; Ok(json!({"path": path, "bytes": r.bytes.len(), "sheets": r.pages, "warnings": r.warnings})) } None => Ok(json!({"base64": super::file::base64_encode(&r.bytes), "bytes": r.bytes.len(), "sheets": r.pages, "warnings": r.warnings})), @@ -59,7 +59,7 @@ fn export_html(s: &mut Session, p: &Value) -> Result<Value> { match str_param(p, "path") { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - crate::atomic::write_file(path, text.as_bytes())?; + crate::io::write_file(path, text.as_bytes()).map_err(EngineError::Other)?; Ok(json!({"path": path, "bytes": text.len()})) } None => Ok(json!({"bytes": text.len(), "text": text})), @@ -91,7 +91,7 @@ fn export_text(s: &mut Session, p: &Value) -> Result<Value> { match path { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - crate::atomic::write_file(path, text.as_bytes())?; + crate::io::write_file(path, text.as_bytes()).map_err(EngineError::Other)?; Ok(json!({"path": path, "bytes": text.len()})) } None => Ok(json!({"bytes": text.len(), "text": text})), @@ -160,7 +160,7 @@ fn export_epub(s: &mut Session, p: &Value) -> Result<Value> { match p.get("path").and_then(Value::as_str) { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - crate::atomic::write_file(path, &bytes)?; + crate::io::write_file(path, &bytes).map_err(crate::EngineError::Other)?; Ok(serde_json::json!({"path": path, "bytes": bytes.len()})) } None => Ok(serde_json::json!({"base64": super::file::base64_encode(&bytes), "bytes": bytes.len()})), @@ -598,7 +598,7 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> { #[cfg(not(target_arch = "wasm32"))] fn write_file(path: &str, bytes: &[u8]) -> Result<()> { - crate::atomic::write_file(path, bytes) + crate::io::write_file(path, bytes).map_err(EngineError::Other) } #[cfg(target_arch = "wasm32")] fn write_file(path: &str, _bytes: &[u8]) -> Result<()> { diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 692c363e8..587855415 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -29,20 +29,21 @@ pub fn specs() -> Vec<CommandSpec> { super::datamerge::refresh_relative_paths(&mut copy, std::path::Path::new(&path)); let bytes = to_bytes(©); #[cfg(not(target_arch = "wasm32"))] - crate::atomic::write_file(&path, &bytes)?; + crate::io::write_file(&path, &bytes).map_err(EngineError::Other)?; Ok(json!({"path": path, "bytes": bytes.len()})) }), cmd!(noundo "file.revert", "Revert", ["File"], None, "{} — back to the last saved version (not undoable)", can_revert, file_revert), cmd!(query "file.recovery.list", "Recovery Data", [], None, "{dir?} → [{uid, path, title, saved}] unsaved documents left by a crash (default: the session's recovery folder)", always, |s, p| { - let dir = recovery_dir(s, p)?; + let dir = recovery_dir(s, p, FolderAccess::Read)?; Ok(Value::Array(crate::recovery::list(&dir).into_iter().map(|(uid, mut m)| { m["uid"] = json!(uid); m }).collect())) }), cmd!(noundo "file.recovery.save", "Save Recovery Data", [], None, "{dir?} — write every unsaved document to the recovery folder (the app does this on a timer)", always, |s, p| { - let dir = recovery_dir(s, p)?; - Ok(json!({"saved": crate::recovery::save_tracked(s, &dir)?})) + let dir = recovery_dir(s, p, FolderAccess::Write)?; + Ok(json!({"saved": crate::recovery::save(s, &dir)?})) }), cmd!(noundo "file.recovery.open", "Recover Documents", [], None, "{dir?} — reopen documents left in the recovery folder (unsaved)", always, |s, p| { - let dir = recovery_dir(s, p)?; + // Reopening removes the entries it read. + let dir = recovery_dir(s, p, FolderAccess::ReadWrite)?; Ok(json!({"opened": crate::recovery::open(s, &dir)?})) }), cmd!(query "file.serialize", "Serialize", [], None, "{} → {base64, bytes} the .designcraft file", has_doc, |s, _| { @@ -164,7 +165,7 @@ fn file_open(s: &mut Session, p: &Value) -> Result<Value> { if path.to_ascii_lowercase().ends_with(".idml") { return super::interchange::open_idml(s, p); } - let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let bytes = crate::io::read_file(path).map_err(EngineError::Other)?; let mut d = from_bytes(&bytes)?; super::interchange::resolve_packaged_links(&mut d, std::path::Path::new(path).parent()); super::datamerge::resolve_sources_on_open(&mut d, Some(std::path::Path::new(path))); @@ -189,6 +190,11 @@ pub(crate) fn load_document_fonts(d: &mut Document, path: &str) -> (Option<Arc<c { use designcraft_fonts::DOCUMENT_FONTS_FOLDER; let Some(folder) = std::path::Path::new(path).parent().map(document_fonts_folder) else { return (None, 0, Vec::new()) }; + // A document's folder is the agent's to name (a recovery entry or a book lists paths too): + // fonts outside the automation read root stay unloaded. + if let Err(e) = crate::file_access::check_read(&folder) { + return (None, 0, vec![format!("{DOCUMENT_FONTS_FOLDER}: {e}")]); + } let r = designcraft_fonts::FontDb::global().load_document_fonts(&folder); d.font_scope = r.scope; let scope = (r.scope != 0).then(|| Arc::new(crate::FontScope(r.scope))); @@ -207,14 +213,14 @@ pub(crate) fn load_document_fonts(d: &mut Document, path: &str) -> (Option<Arc<c fn document_fonts_folder(dir: &std::path::Path) -> std::path::PathBuf { use designcraft_fonts::DOCUMENT_FONTS_FOLDER; let exact = dir.join(DOCUMENT_FONTS_FOLDER); - if exact.is_dir() { + if crate::io::is_dir(&exact) { return exact; } - std::fs::read_dir(dir) + crate::io::read_dir(dir) .into_iter() .flatten() .flatten() - .find(|e| e.file_name().to_str().is_some_and(|n| n.eq_ignore_ascii_case(DOCUMENT_FONTS_FOLDER)) && e.path().is_dir()) + .find(|e| e.file_name().to_str().is_some_and(|n| n.eq_ignore_ascii_case(DOCUMENT_FONTS_FOLDER)) && crate::io::is_dir(e.path())) .map_or(exact, |e| e.path()) } @@ -239,6 +245,8 @@ fn file_save(s: &mut Session, p: &Value) -> Result<Value> { .map(str::to_string) .or_else(|| st.path.clone()) .ok_or_else(|| bad("file.save", "missing `path` (document has never been saved)"))?; + // Before anything about the document changes: a refused save leaves it as it was. + crate::file_access::check_write(&path).map_err(EngineError::Other)?; // Prepare paths for the destination without changing the live document until persistence succeeds. let mut saved = (*st.doc).clone(); super::datamerge::refresh_relative_paths(&mut saved, std::path::Path::new(&path)); @@ -246,7 +254,7 @@ fn file_save(s: &mut Session, p: &Value) -> Result<Value> { let saved = if paths_changed { Arc::new(saved) } else { st.doc.clone() }; let bytes = to_bytes(&saved); #[cfg(not(target_arch = "wasm32"))] - crate::atomic::write_file(&path, &bytes)?; + crate::io::write_file(&path, &bytes).map_err(EngineError::Other)?; if paths_changed { st.doc = saved; st.revision = st.revision.saturating_add(1); @@ -268,7 +276,7 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> { (base64_decode(b), str_param(p, "name").unwrap_or("image").to_string(), None) } else if let Some(path) = str_param(p, "path") { #[cfg(not(target_arch = "wasm32"))] - let b = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let b = crate::io::read_file(path).map_err(EngineError::Other)?; #[cfg(target_arch = "wasm32")] let b: Vec<u8> = vec![]; let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.into()); @@ -435,7 +443,7 @@ pub(crate) fn read_source(p: &Value) -> Result<(Vec<u8>, String, Option<String>) } let path = str_param(p, "path").ok_or_else(|| bad("place", "missing `path` or `base64`"))?; #[cfg(not(target_arch = "wasm32"))] - let b = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let b = crate::io::read_file(path).map_err(EngineError::Other)?; #[cfg(target_arch = "wasm32")] let b: Vec<u8> = vec![]; let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.into()); @@ -522,7 +530,7 @@ fn snippet_export(s: &mut Session, p: &Value) -> Result<Value> { match str_param(p, "path") { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - crate::atomic::write_file(path, &bytes)?; + crate::io::write_file(path, &bytes).map_err(EngineError::Other)?; Ok(json!({"path": path, "bytes": bytes.len(), "items": d.spreads.first().map(|s| s.items.len()).unwrap_or(0)})) } None => Ok(json!({"base64": base64_encode(&bytes), "bytes": bytes.len()})), @@ -534,7 +542,7 @@ fn snippet_export(s: &mut Session, p: &Value) -> Result<Value> { fn place_layout_page(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) -> Result<Value> { const ID: &str = "file.place"; let src = if name.to_lowercase().ends_with(".idml") { - designcraft_idml::import_idml(bytes).map_err(|e| bad(ID, e.to_string()))? + super::interchange::import_idml_confined(bytes).map_err(|e| bad(ID, e.to_string()))? } else { from_bytes(bytes)? }; @@ -612,17 +620,36 @@ pub(crate) fn snippet_place(s: &mut Session, p: &Value) -> Result<Value> { }) } -fn recovery_dir(s: &Session, p: &Value) -> Result<std::path::PathBuf> { +/// Which access a recovery command needs to the folder it is given. +#[derive(Clone, Copy)] +enum FolderAccess { + Read, + Write, + ReadWrite, +} + +/// The recovery folder: `dir` when the command names one, else the session's own (set by the +/// host application, so not the automation roots' business). A named folder is checked against +/// the roots for `access`. +fn recovery_dir(s: &Session, p: &Value, access: FolderAccess) -> Result<std::path::PathBuf> { #[cfg(target_arch = "wasm32")] { - let _ = (s, p); + let _ = (s, p, access); Err(EngineError::Other("no recovery folder on the web".into())) } #[cfg(not(target_arch = "wasm32"))] - str_param(p, "dir") - .map(std::path::PathBuf::from) - .or_else(|| s.recovery_dir.clone()) - .ok_or_else(|| bad("file.recovery", "no recovery folder (give `dir`)")) + { + let Some(named) = str_param(p, "dir") else { + return s.recovery_dir.clone().ok_or_else(|| bad("file.recovery", "no recovery folder (give `dir`)")); + }; + if matches!(access, FolderAccess::Read | FolderAccess::ReadWrite) { + crate::file_access::check_read(named).map_err(EngineError::Other)?; + } + if matches!(access, FolderAccess::Write | FolderAccess::ReadWrite) { + crate::file_access::check_write(named).map_err(EngineError::Other)?; + } + Ok(std::path::PathBuf::from(named)) + } } fn can_revert(s: &Session) -> std::result::Result<(), String> { @@ -639,10 +666,10 @@ fn file_revert(s: &mut Session, _: &Value) -> Result<Value> { #[cfg(not(target_arch = "wasm32"))] { let mut d = if path.to_ascii_lowercase().ends_with(".idml") { - let bytes = std::fs::read(&path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; - designcraft_idml::import_idml(&bytes).map_err(|e| EngineError::Other(e.to_string()))? + let bytes = crate::io::read_file(&path).map_err(EngineError::Other)?; + super::interchange::import_idml_confined(&bytes).map_err(|e| EngineError::Other(e.to_string()))? } else { - from_bytes(&std::fs::read(&path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?)? + from_bytes(&crate::io::read_file(&path).map_err(EngineError::Other)?)? }; // As opened again: the fonts beside it now (unchanged files are shared, not read again). let (fonts, _, _) = load_document_fonts(&mut d, &path); diff --git a/crates/engine/src/cmd/interchange.rs b/crates/engine/src/cmd/interchange.rs index 96498e863..9b9520662 100644 --- a/crates/engine/src/cmd/interchange.rs +++ b/crates/engine/src/cmd/interchange.rs @@ -25,13 +25,19 @@ fn export_idml(s: &mut Session, p: &Value) -> Result<Value> { match str_param(p, "path") { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - crate::atomic::write_file(path, &bytes)?; + crate::io::write_file(path, &bytes).map_err(EngineError::Other)?; Ok(json!({"path": path, "bytes": bytes.len()})) } None => Ok(json!({"base64": base64_encode(&bytes), "bytes": bytes.len()})), } } +/// [`designcraft_idml::import_idml`] with the linked images read through [`crate::io`], so the +/// automation read root applies to the paths the package names. +pub(crate) fn import_idml_confined(bytes: &[u8]) -> designcraft_idml::Result<Document> { + designcraft_idml::import_idml_with(bytes, &|path| crate::io::read_file(path).ok()) +} + /// Import IDML bytes; `dir` is the folder the package came from (for relative link lookup). pub fn import(bytes: &[u8], dir: Option<&std::path::Path>) -> Result<Document> { import_report(bytes, dir).map(|(document, _)| document) @@ -112,10 +118,10 @@ pub(crate) fn resolve_packaged_links(document: &mut Document, dir: Option<&std:: let Some(dir) = dir 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 || std::path::Path::new(link).is_file() { + if link.len() > MAX_LINK_PATH_BYTES || crate::io::is_file(link) { continue; } - let Some(path) = packaged_link_candidates(link, Some(dir)).into_iter().find(|p| p.is_file()) else { continue }; + let Some(path) = packaged_link_candidates(link, Some(dir)).into_iter().find(|p| crate::io::is_file(p)) else { continue }; // Preserve ordinary Windows paths rather than canonicalizing to a verbatim URI. let path = std::path::absolute(&path).unwrap_or(path); std::sync::Arc::make_mut(asset).link = Some(path.to_string_lossy().into_owned()); @@ -125,7 +131,7 @@ pub(crate) fn resolve_packaged_links(document: &mut Document, dir: Option<&std:: #[cfg(not(target_arch = "wasm32"))] fn read_packaged_link(link: &str, dir: Option<&std::path::Path>) -> Option<(String, Vec<u8>)> { for path in packaged_link_candidates(link, dir) { - if let Ok(bytes) = std::fs::read(&path) { + if let Ok(bytes) = crate::io::read_file(&path) { // Keep ordinary Windows drive/UNC syntax for IDML URI export; canonicalize // would introduce a verbatim prefix that is not an IDML file URI. let path = std::path::absolute(&path).unwrap_or(path); @@ -140,7 +146,7 @@ pub(crate) fn open_idml(s: &mut Session, p: &Value) -> Result<Value> { (base64_decode(b), None, str_param(p, "name").map(|n| n.trim_end_matches(".idml").to_string())) } else if let Some(path) = str_param(p, "path") { #[cfg(not(target_arch = "wasm32"))] - let b = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let b = crate::io::read_file(path).map_err(EngineError::Other)?; #[cfg(target_arch = "wasm32")] let b: Vec<u8> = Vec::new(); let pp = std::path::Path::new(path); diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 384b4f827..9897885d2 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -29,7 +29,7 @@ impl Library { #[cfg(not(target_arch = "wasm32"))] if let Some(p) = &self.path { let bytes = serde_json::to_vec_pretty(self).map_err(|e| crate::EngineError::Other(e.to_string()))?; - crate::atomic::write_file(p, &bytes)?; + crate::io::write_file(p, &bytes).map_err(crate::EngineError::Other)?; } Ok(()) } @@ -56,7 +56,7 @@ pub fn specs() -> Vec<CommandSpec> { (Some(j), _) => (j.to_string(), None), (None, Some(path)) => { #[cfg(not(target_arch = "wasm32"))] - let t = std::fs::read_to_string(path).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?; + let t = crate::io::read_to_string(path).map_err(crate::EngineError::Other)?; #[cfg(target_arch = "wasm32")] let t = String::new(); (t, Some(path.to_string())) diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 73de3ea7e..0effead6b 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -93,11 +93,11 @@ pub fn status(a: &designcraft_doc::Asset) -> &'static str { let Some(path) = &a.link else { return "embedded" }; #[cfg(not(target_arch = "wasm32"))] { - match std::fs::metadata(path) { + match crate::io::metadata(path) { Err(_) => "missing", Ok(m) if m.len() as usize != a.data.len() => "modified", // Same size: compare the bytes (cheap for the sizes involved, and exact). - Ok(_) => match std::fs::read(path) { + Ok(_) => match crate::io::read_file(path) { Ok(b) if b == *a.data => "ok", Ok(_) => "modified", Err(_) => "missing", @@ -145,7 +145,7 @@ fn list(d: &Document) -> Vec<Value> { #[cfg(not(target_arch = "wasm32"))] fn reload(path: &str) -> Result<(Vec<u8>, (u32, u32))> { - let b = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let b = crate::io::read_file(path).map_err(EngineError::Other)?; let px = designcraft_render::image_size(&b).ok_or_else(|| bad("links", format!("{path}: unsupported or corrupt image")))?; Ok((b, px)) } @@ -174,6 +174,10 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> { fn relink_folder(s: &mut Session, p: &Value) -> Result<Value> { let dir = str_param(p, "dir").map(std::path::PathBuf::from); + if let Some(dir) = &dir { + // Refused outright: candidates outside the automation read root would only look missing. + crate::file_access::check_read(dir).map_err(EngineError::Other)?; + } let ext = str_param(p, "extension").map(|e| e.trim_start_matches('.').to_string()); if dir.is_none() && ext.is_none() { return Err(bad("links.relinkFolder", "give `dir` and/or `extension`")); @@ -194,7 +198,7 @@ fn relink_folder(s: &mut Session, p: &Value) -> Result<Value> { } let folder = dir.clone().or_else(|| old.parent().map(|p| p.to_path_buf())).unwrap_or_default(); let cand = folder.join(&name); - if cand.exists() { + if crate::io::exists(&cand) { todo.push((a.id, cand)); } else { not_found.push(name.to_string_lossy().to_string()); diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index 25f09537c..300e7619d 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -74,6 +74,8 @@ struct OutputRoot { impl OutputRoot { fn new(path: PathBuf) -> Result<Self> { + // Before anything is created: the folder must be inside the automation write root. + crate::file_access::check_write(&path).map_err(EngineError::Other)?; ensure_real_directory(&path, false)?; let canonical = std::fs::canonicalize(&path).map_err(|e| path_error(&path, e))?; Ok(Self { path, canonical }) diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 4bf69ec41..c6ca1156c 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -809,7 +809,7 @@ fn value_name(color: designcraft_color::Color) -> String { fn load_swatches(s: &mut Session, p: &Value) -> Result<Value> { let bytes = match (str_param(p, "path"), str_param(p, "base64")) { (_, Some(b)) => super::file::base64_decode(b), - (Some(path), None) => std::fs::read(path).map_err(|e| bad("swatch.load", format!("{path}: {e}")))?, + (Some(path), None) => crate::io::read_file(path).map_err(|e| bad("swatch.load", e))?, _ => return Err(bad("swatch.load", "give `path` or `base64`")), }; let incoming = designcraft_color::ase::read(&bytes).map_err(|e| bad("swatch.load", e.to_string()))?; @@ -844,11 +844,7 @@ fn save_swatches(s: &mut Session, p: &Value) -> Result<Value> { let bytes = designcraft_color::ase::write(&chosen); match str_param(p, "path") { Some(path) => { - #[cfg(not(target_arch = "wasm32"))] - let written = crate::atomic::write_atomic(std::path::Path::new(path), &bytes); - #[cfg(target_arch = "wasm32")] - let written = std::fs::write(path, &bytes); - written.map_err(|e| bad("swatch.save", format!("{path}: {e}")))?; + crate::io::write_file(path, &bytes).map_err(|e| bad("swatch.save", e))?; Ok(json!({"path": path, "bytes": bytes.len()})) } None => Ok(json!({"base64": super::file::base64_encode(&bytes)})), diff --git a/crates/engine/src/cmd/xml.rs b/crates/engine/src/cmd/xml.rs index 6dab205fa..0b3f631e9 100644 --- a/crates/engine/src/cmd/xml.rs +++ b/crates/engine/src/cmd/xml.rs @@ -305,7 +305,7 @@ pub fn specs() -> Vec<CommandSpec> { match str_param(p, "path") { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - crate::atomic::write_file(path, text.as_bytes())?; + crate::io::write_file(path, text.as_bytes()).map_err(crate::EngineError::Other)?; Ok(json!({"path": path, "bytes": text.len()})) } None => Ok(json!({"text": text, "bytes": text.len()})), @@ -385,7 +385,7 @@ fn text_or_path(p: &Value, id: &str) -> Result<String> { (Some(t), _) => t.to_string(), (None, Some(path)) => { #[cfg(not(target_arch = "wasm32"))] - let t = std::fs::read_to_string(path).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?; + let t = crate::io::read_to_string(path).map_err(crate::EngineError::Other)?; #[cfg(target_arch = "wasm32")] let t = { let _ = path; diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs new file mode 100644 index 000000000..03ffd7c94 --- /dev/null +++ b/crates/engine/src/file_access.rs @@ -0,0 +1,515 @@ +//! The folders automation may read and write. +//! +//! `designcraft-cli mcp --automation-read-root <dir> --automation-write-root <dir>` confines the +//! files an agent's commands reach. [`AutomationRoots`] holds the two folders. While they are in force, every path a command +//! reads goes through [`check_read`] and every path it writes through [`check_write`]: +//! +//! - The path is made absolute against the working directory, as the file system reads it. Its +//! links are followed: for a file that doesn't exist yet, the links of its nearest existing +//! folder, the rest of the path being plain names (`..` below a missing folder is refused, and so +//! is a link that leads nowhere). +//! - The result must lie inside the root, compared by whole components (`/work2` isn't inside +//! `/work`), without regard to case on Windows. +//! - Read and write access are separate: a root given alone grants only its own kind of access. +//! +//! Roots are in force on a thread inside [`confine`], and in the whole process after +//! [`confine_process`] (`designcraft-cli mcp`), except inside [`unconfined`]. Without roots +//! nothing is checked. +//! +//! A path is checked, then opened: a process that can change the folders in between (swapping in +//! a link) can still win that race. The roots keep an agent to the files it was given; they don't +//! guard against other programs on the computer. + +use std::cell::RefCell; +use std::ffi::OsString; +use std::path::{Component, Path, PathBuf}; +use std::sync::{Arc, OnceLock}; + +/// Read access or write access. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Access { + Read, + Write, +} + +/// The folders automation may read and write; a root left out grants no access of its kind. +#[derive(Debug)] +pub struct AutomationRoots { + read: Option<Root>, + write: Option<Root>, +} + +#[derive(Debug)] +struct Root { + /// The folder made absolute, links followed: what paths are compared with. + resolved: PathBuf, + /// The same without Windows' `\\?\` prefix, for people. + shown: PathBuf, +} + +impl Root { + /// The folder at `path`, which must exist. + fn open(path: &Path, access: Access) -> Result<Self, String> { + let kind = match access { + Access::Read => "read", + Access::Write => "write", + }; + let resolved = std::fs::canonicalize(path) + .and_then(|p| if p.is_dir() { Ok(p) } else { Err(std::io::ErrorKind::NotADirectory.into()) }) + .map_err(|e| format!("cannot open the automation {kind} root `{}`: {e}", path.display()))?; + Ok(Self { shown: plain(resolved.clone()), resolved }) + } +} + +impl AutomationRoots { + /// The roots `read` and `write`, which must be existing folders; `None` when neither is given + /// (nothing is confined then). + pub fn new(read: Option<&Path>, write: Option<&Path>) -> Result<Option<Arc<Self>>, String> { + if read.is_none() && write.is_none() { + return Ok(None); + } + let read = read.map(|p| Root::open(p, Access::Read)).transpose()?; + let write = write.map(|p| Root::open(p, Access::Write)).transpose()?; + Ok(Some(Arc::new(Self { read, write }))) + } + + /// The read root, absolute with its links followed. + pub fn read_root(&self) -> Option<&Path> { + self.read.as_ref().map(|r| r.shown.as_path()) + } + + /// The write root, absolute with its links followed. + pub fn write_root(&self) -> Option<&Path> { + self.write.as_ref().map(|r| r.shown.as_path()) + } + + /// May the file or folder at `path` be read (or written)? `Err` says why not. + pub fn check(&self, path: impl AsRef<Path>, access: Access) -> Result<(), String> { + let path = path.as_ref(); + let root = match access { + Access::Read => self.read.as_ref(), + Access::Write => self.write.as_ref(), + }; + let Some(Root { resolved: folder, shown }) = root else { + return match access { + Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {}", path.display())), + Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {}", path.display())), + }; + }; + if inside(&resolve(path)?, folder) { + return Ok(()); + } + let root = shown.display(); + match access { + Access::Read => Err(format!("automation path rejected: outside the read root {root}: {}", path.display())), + Access::Write => Err(format!("automation path rejected: outside the write root {root}: {}", path.display())), + } + } +} + +/// Where the roots are in force on a thread ([`confine`], [`unconfined`]). +#[derive(Clone, Debug)] +enum Scope { + Confined(Arc<AutomationRoots>), + Unconfined, +} + +thread_local! { + /// This thread's scope; `None`: the process's roots ([`confine_process`]). + static SCOPE: RefCell<Option<Scope>> = const { RefCell::new(None) }; +} + +/// The roots in force in every thread outside [`confine`] and [`unconfined`]. +static PROCESS: OnceLock<Arc<AutomationRoots>> = OnceLock::new(); + +/// Put `roots` in force in the whole process, for good. `false` when roots were in force already +/// (they stay as they were). +pub fn confine_process(roots: Arc<AutomationRoots>) -> bool { + PROCESS.set(roots).is_ok() +} + +/// Puts the scope it replaced back when dropped (also when `f` panics). +struct Restore(Option<Scope>); + +impl Drop for Restore { + fn drop(&mut self) { + let previous = self.0.take(); + SCOPE.with(|s| *s.borrow_mut() = previous); + } +} + +fn with_scope<T>(scope: Option<Scope>, f: impl FnOnce() -> T) -> T { + let _restore = Restore(SCOPE.with(|s| s.replace(scope))); + f() +} + +/// Run `f` with `roots` in force on this thread (`None`: as things stand). +pub fn confine<T>(roots: Option<&Arc<AutomationRoots>>, f: impl FnOnce() -> T) -> T { + match roots { + Some(r) => with_scope(Some(Scope::Confined(r.clone())), f), + None => f(), + } +} + +/// Run `f` with no roots in force on this thread: what the person at the keyboard does, and the +/// app's own folders. +pub fn unconfined<T>(f: impl FnOnce() -> T) -> T { + with_scope(Some(Scope::Unconfined), f) +} + +/// The roots in force on this thread, if any. +pub fn current() -> Option<Arc<AutomationRoots>> { + match SCOPE.with(|s| s.borrow().clone()) { + Some(Scope::Confined(r)) => Some(r), + Some(Scope::Unconfined) => None, + None => PROCESS.get().cloned(), + } +} + +/// `path` without Windows' `\\?\` prefix (which `canonicalize` adds), for people to read. +fn plain(path: PathBuf) -> PathBuf { + if !cfg!(windows) { + return path; + } + let text = path.to_string_lossy(); + if let Some(rest) = text.strip_prefix(r"\\?\UNC\") { + return PathBuf::from(format!(r"\\{rest}")); + } + match text.strip_prefix(r"\\?\") { + Some(rest) => PathBuf::from(rest), + None => path, + } +} + +/// May `path` be read here? Always, without roots in force. +pub fn check_read(path: impl AsRef<Path>) -> Result<(), String> { + current().map_or(Ok(()), |r| r.check(path, Access::Read)) +} + +/// May `path` be written here? Always, without roots in force. +pub fn check_write(path: impl AsRef<Path>) -> Result<(), String> { + current().map_or(Ok(()), |r| r.check(path, Access::Write)) +} + +/// `path` made absolute with its links followed; for a path that doesn't exist (yet), its nearest +/// existing folder's links followed and the rest appended. +fn resolve(path: &Path) -> Result<PathBuf, String> { + if path.as_os_str().is_empty() { + return Err("automation path rejected: path is empty".into()); + } + // Checked as given: making `…\NUL` absolute gives the device path `\\.\NUL` on Windows. + if cfg!(windows) && path.components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) { + return Err(format!("automation path rejected: reserved device names are not allowed: {}", path.display())); + } + let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {}: {e}", path.display()))?; + // The names below the nearest existing folder, last first. + let mut missing: Vec<OsString> = vec![]; + let mut at = absolute.as_path(); + loop { + match std::fs::canonicalize(at) { + Ok(mut found) => { + found.extend(missing.iter().rev()); + return Ok(found); + } + Err(e) => match std::fs::symlink_metadata(at) { + Ok(m) if m.file_type().is_symlink() => { + return Err(format!("automation path rejected: a link that leads nowhere: {}", path.display())); + } + // There, yet its real path can't be had (no permission): nothing to compare. + Ok(_) => return Err(format!("automation path rejected: cannot resolve {}: {e}", path.display())), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(format!("automation path rejected: cannot resolve {}: {e}", path.display())), + }, + } + let parent = at.parent(); + match (at.components().next_back(), parent) { + (Some(Component::Normal(name)), Some(parent)) => { + missing.push(name.to_os_string()); + at = parent; + } + (Some(Component::ParentDir), _) => { + return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {}", path.display())); + } + // Down to the root (or drive) and it doesn't exist either. + _ => { + let e = std::io::Error::from(std::io::ErrorKind::NotFound); + return Err(format!("automation path rejected: cannot resolve {}: {e}", path.display())); + } + } + } +} + +/// Is `path` (resolved) `root` or inside it, component by component (without regard to case on +/// Windows, whose file systems ignore it)? +fn inside(path: &Path, root: &Path) -> bool { + let mut path = path.components(); + root.components().all(|r| path.next().is_some_and(|p| same_component(p, r))) +} + +fn same_component(a: Component, b: Component) -> bool { + if a == b { + return true; + } + if !cfg!(windows) { + return false; + } + match (a.as_os_str().to_str(), b.as_os_str().to_str()) { + (Some(a), Some(b)) => a.to_lowercase() == b.to_lowercase(), + _ => false, + } +} + +/// A Windows device name (`CON`, `NUL`, `COM1`… also with an extension), which names no file. +fn is_device_name(component: &str) -> bool { + let stem = component.split('.').next().unwrap_or(component).trim_end_matches(' ').to_ascii_uppercase(); + let numbered = |prefix: &str| stem.strip_prefix(prefix).is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9")); + matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CLOCK$" | "CONIN$" | "CONOUT$") || numbered("COM") || numbered("LPT") +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A fresh folder holding `in` (read root), `out` (write root) and `outside`. + fn folders(name: &str) -> (PathBuf, Arc<AutomationRoots>) { + let base = std::env::temp_dir().join(format!("dc-file-access-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + for d in ["in/sub", "out", "outside", "in2"] { + std::fs::create_dir_all(base.join(d)).unwrap(); + } + std::fs::write(base.join("in/a.designcraft"), "x").unwrap(); + std::fs::write(base.join("outside/secret.designcraft"), "x").unwrap(); + let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap().unwrap(); + (base, roots) + } + + fn s(p: PathBuf) -> String { + p.to_string_lossy().into_owned() + } + + #[test] + fn paths_inside_the_roots_pass_and_others_dont() { + let (base, roots) = folders("inside"); + assert_eq!(roots.check(s(base.join("in/a.designcraft")), Access::Read), Ok(())); + assert_eq!(roots.check(s(base.join("in")), Access::Read), Ok(()), "the root itself"); + assert_eq!(roots.check(s(base.join("in/sub/new.designcraft")), Access::Read), Ok(()), "missing files are checked by their folder"); + assert_eq!(roots.check(s(base.join("out/new/deeper/x.png")), Access::Write), Ok(()), "and by their nearest folder"); + let e = roots.check(s(base.join("outside/secret.designcraft")), Access::Read).unwrap_err(); + assert!(e.starts_with("automation path rejected: outside the read root"), "{e}"); + // Read and write access are separate. + let e = roots.check(s(base.join("in/a.designcraft")), Access::Write).unwrap_err(); + assert!(e.contains("outside the write root"), "{e}"); + assert!(roots.check(s(base.join("out/x.png")), Access::Read).unwrap_err().contains("outside the read root")); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn a_sibling_with_the_same_prefix_is_outside() { + let (base, roots) = folders("prefix"); + std::fs::write(base.join("in2/b.designcraft"), "x").unwrap(); + assert!(roots.check(s(base.join("in2/b.designcraft")), Access::Read).is_err(), "in2 is not inside in"); + assert!(roots.check(format!("{}2/c.designcraft", s(base.join("in"))), Access::Read).is_err()); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn parent_traversal_is_resolved_or_refused() { + let (base, roots) = folders("dotdot"); + let sep = std::path::MAIN_SEPARATOR; + let up = format!("{}{sep}..{sep}outside{sep}secret.designcraft", s(base.join("in"))); + assert!(roots.check(&up, Access::Read).unwrap_err().contains("outside the read root"), "`..` out of the root"); + let back = format!("{}{sep}..{sep}a.designcraft", s(base.join("in/sub"))); + assert_eq!(roots.check(&back, Access::Read), Ok(()), "`..` that stays inside"); + let missing = format!("{}{sep}nope{sep}..{sep}..{sep}outside{sep}x.designcraft", s(base.join("out"))); + let e = roots.check(&missing, Access::Write).unwrap_err(); + // Windows resolves `..` by name first (so does opening the file there): then it's outside. + assert!(e.contains("parent traversal below a folder that doesn't exist") || e.contains("outside the write root"), "{e}"); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn relative_paths_resolve_against_the_working_directory() { + let (base, roots) = folders("relative"); + // The tests' working directory is the crate's folder: not inside the roots. + assert!(roots.check("a.designcraft", Access::Read).unwrap_err().contains("outside the read root")); + assert!(roots.check("", Access::Read).unwrap_err().contains("path is empty")); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn a_missing_root_is_an_error_and_none_is_no_confinement() { + let (base, _) = folders("missing"); + let e = AutomationRoots::new(Some(&base.join("nope")), None).unwrap_err(); + assert!(e.starts_with("cannot open the automation read root"), "{e}"); + let e = AutomationRoots::new(None, Some(&base.join("in/a.designcraft"))).unwrap_err(); + assert!( + e.starts_with("cannot open the automation write root") + && e.ends_with(&std::io::Error::from(std::io::ErrorKind::NotADirectory).to_string()), + "{e}" + ); + assert!(AutomationRoots::new(Some(Path::new("")), None).unwrap_err().starts_with("cannot open the automation read root ``")); + assert!(AutomationRoots::new(None, None).unwrap().is_none()); + // One root alone grants only its own access. + let read_only = AutomationRoots::new(Some(&base.join("in")), None).unwrap().unwrap(); + let e = read_only.check(s(base.join("in/x.designcraft")), Access::Write).unwrap_err(); + assert!(e.contains("write authority is absent"), "{e}"); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn links_are_followed_before_comparing() { + let (base, roots) = folders("links"); + #[cfg(unix)] + let made = |target: &Path, link: &Path| std::os::unix::fs::symlink(target, link).is_ok(); + #[cfg(windows)] + let made = |target: &Path, link: &Path| { + let made = + if target.is_dir() { std::os::windows::fs::symlink_dir(target, link) } else { std::os::windows::fs::symlink_file(target, link) }; + made.is_ok() + }; + #[cfg(not(any(unix, windows)))] + let made = |_: &Path, _: &Path| false; + // Windows needs Developer Mode or elevation to make links: nothing to test without them. + if !made(&base.join("outside/secret.designcraft"), &base.join("in/leak.designcraft")) { + eprintln!("skipped: symbolic links can't be made here"); + let _ = std::fs::remove_dir_all(base); + return; + } + assert!(roots.check(s(base.join("in/leak.designcraft")), Access::Read).unwrap_err().contains("outside the read root"), "a file link out"); + assert!(made(&base.join("outside"), &base.join("out/away"))); + let e = roots.check(s(base.join("out/away/new.png")), Access::Write).unwrap_err(); + assert!(e.contains("outside the write root"), "a folder link out: {e}"); + assert!(made(&base.join("outside/gone.designcraft"), &base.join("out/dangling.png"))); + let e = roots.check(s(base.join("out/dangling.png")), Access::Write).unwrap_err(); + assert!(e.contains("a link that leads nowhere"), "writing would create the file outside: {e}"); + // A link to a folder inside the root stays inside. + assert!(made(&base.join("in/sub"), &base.join("in/alias"))); + assert_eq!(roots.check(s(base.join("in/alias/x.designcraft")), Access::Read), Ok(())); + // A root given through a link is the folder it leads to. + assert!(made(&base.join("in"), &base.join("in-link"))); + let linked = AutomationRoots::new(Some(&base.join("in-link")), None).unwrap().unwrap(); + assert_eq!(linked.check(s(base.join("in/a.designcraft")), Access::Read), Ok(())); + let _ = std::fs::remove_dir_all(base); + } + + #[cfg(windows)] + #[test] + fn windows_ignores_case_and_refuses_devices() { + let (base, roots) = folders("case"); + let upper = s(base.join("IN/a.designcraft")); + assert_eq!(roots.check(&upper, Access::Read), Ok(()), "{upper}"); + assert_eq!(roots.check(s(base.join("In/Sub/New.designcraft")), Access::Read), Ok(())); + let e = roots.check(s(base.join("out/NUL")), Access::Write).unwrap_err(); + assert!(e.contains("reserved device names"), "{e}"); + assert!(roots.check(s(base.join("out/com1.txt")), Access::Write).is_err()); + assert_eq!(roots.check(s(base.join("out/console.txt")), Access::Write), Ok(())); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn scopes_nest_and_stay_on_their_thread() { + let (base, roots) = folders("scopes"); + let outside = s(base.join("outside/secret.designcraft")); + assert_eq!(check_read(&outside), Ok(()), "nothing is confined by default"); + confine(Some(&roots), || { + assert!(check_read(&outside).is_err()); + assert_eq!(unconfined(|| check_read(&outside)), Ok(())); + assert!(check_read(&outside).is_err(), "restored after unconfined"); + let o = outside.clone(); + let there = std::thread::spawn(move || check_read(&o).is_ok()).join().unwrap(); + assert!(there, "another thread is not confined by this thread's scope"); + }); + assert_eq!(check_read(&outside), Ok(()), "restored after confine"); + let r = std::panic::catch_unwind(|| confine(Some(&roots), || panic!("boom"))); + assert!(r.is_err()); + assert_eq!(check_read(&outside), Ok(()), "restored after a panic"); + assert_eq!(confine(None, || check_read(&outside)), Ok(())); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn device_names() { + for d in ["CON", "nul", "Com1", "LPT9.txt", "aux.designcraft", "NUL "] { + assert!(is_device_name(d), "{d}"); + } + for f in ["console", "com10", "COM0", "lpt", "a.designcraft", "nul-x"] { + assert!(!is_device_name(f), "{f}"); + } + } + + /// Every file a command reads or writes by path goes through `io` (`read_file`, + /// `write_file`, `metadata`, `read_dir`...), which check the roots. Engine code that reaches + /// for `std::fs` itself would bypass them, so none may: every file under `src/` (commands, + /// `lib.rs`, `guard.rs`...) except + /// + /// - `io.rs` and this module, the checked gateway, + /// - `recovery.rs`, which keeps the unsaved documents in a folder the host application + /// chooses (a command's own `dir` is checked in `cmd/file.rs` before it gets there), + /// - `cmd/package.rs`, whose `OutputRoot` checks the folder against the write root before it + /// creates anything and then only writes inside it. Its reads are of the font files that + /// draw the document's text (installed fonts and the `Document Fonts` folder), never of a + /// path a command names, + /// - the test files. + #[test] + fn commands_do_not_touch_the_file_system_themselves() { + fn walk(dir: &Path, files: &mut Vec<PathBuf>) { + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + walk(&path, files); + } else if path.extension().is_some_and(|e| e == "rs") { + files.push(path); + } + } + } + /// The file's lines but its test modules (which build their own fixtures): a module is + /// skipped from its `#[cfg(test)]` to the closing brace at the same indent. + fn code_outside_tests(text: &str) -> Vec<(usize, &str)> { + let lines: Vec<&str> = text.lines().collect(); + let mut code = vec![]; + let mut i = 0; + while i < lines.len() { + let attr = lines[i].trim(); + let module = lines.get(i + 1).map(|l| l.trim_start().trim_start_matches("pub ")); + if attr.starts_with("#[cfg(") && attr.contains("test") && module.is_some_and(|m| m.starts_with("mod ")) { + let header = lines[i + 1]; + i += 2; + if header.trim_end().ends_with('{') { + let indent = &header[..header.len() - header.trim_start().len()]; + let close = format!("{indent}}}"); + while i < lines.len() && lines[i].trim_end() != close { + i += 1; + } + i += 1; + } + continue; + } + code.push((i + 1, lines[i])); + i += 1; + } + code + } + let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut files = vec![]; + walk(&src, &mut files); + assert!(files.iter().any(|f| f.ends_with("lib.rs")) && files.iter().any(|f| f.ends_with("cmd/file.rs")), "{files:?}"); + let mut offenders = vec![]; + for path in files { + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + let rel = path.strip_prefix(&src).unwrap_or(&path).to_string_lossy().replace('\\', "/"); + if matches!(rel.as_str(), "io.rs" | "file_access.rs" | "recovery.rs" | "cmd/package.rs") || name.starts_with("tests") { + continue; + } + let text = std::fs::read_to_string(&path).unwrap(); + for (n, line) in code_outside_tests(&text) { + let line = line.trim_start(); + let touches = + ["std::fs", "File::open", "File::create", "OpenOptions", ".is_file()", ".is_dir()", ".exists()"].iter().any(|w| line.contains(w)); + if !line.starts_with("//") && touches { + offenders.push(format!("{}:{}: {line}", path.display(), n + 1)); + } + } + } + assert!(offenders.is_empty(), "use crate::io (read_file, write_file, metadata, read_dir...): {offenders:#?}"); + } +} diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs new file mode 100644 index 000000000..4fa214750 --- /dev/null +++ b/crates/engine/src/io.rs @@ -0,0 +1,71 @@ +//! Reading and writing the files a command names by path. +//! +//! Every such read and write goes through this module, so the automation roots +//! ([`crate::file_access`]) are checked in one place. A command module that called `std::fs` +//! itself would bypass them: a test in `file_access` keeps that from happening. +//! +//! A path the roots refuse reads as a file that isn't there where the caller only asks whether +//! it is ([`is_file`], [`is_dir`], [`exists`], [`metadata`]), and as the roots' own message where +//! it asks for the bytes. + +use std::fs::Metadata; +use std::path::Path; + +/// The bytes of the file at `path`, inside the automation read root when there is one. +pub fn read_file(path: impl AsRef<Path>) -> Result<Vec<u8>, String> { + let path = path.as_ref(); + crate::file_access::check_read(path)?; + std::fs::read(path).map_err(|e| format!("{}: {e}", path.display())) +} + +/// [`read_file`] with the file system's own error, for callers that tell "missing" from other +/// failures. A refused path is a `PermissionDenied` error carrying the roots' message. +pub fn read(path: impl AsRef<Path>) -> std::io::Result<Vec<u8>> { + let path = path.as_ref(); + crate::file_access::check_read(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?; + std::fs::read(path) +} + +/// The text of the file at `path`, inside the automation read root when there is one. +pub fn read_to_string(path: impl AsRef<Path>) -> Result<String, String> { + let path = path.as_ref(); + crate::file_access::check_read(path)?; + std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display())) +} + +/// Write `bytes` to the file at `path`, inside the automation write root when there is one. +pub fn write_file(path: impl AsRef<Path>, bytes: &[u8]) -> Result<(), String> { + let path = path.as_ref(); + crate::file_access::check_write(path)?; + std::fs::write(path, bytes).map_err(|e| format!("{}: {e}", path.display())) +} + +/// What the file system says about `path` (links followed), inside the automation read root when +/// there is one. A refused path is a `PermissionDenied` error carrying the roots' message. +pub fn metadata(path: impl AsRef<Path>) -> std::io::Result<Metadata> { + let path = path.as_ref(); + crate::file_access::check_read(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?; + std::fs::metadata(path) +} + +/// Is there a file at `path` that automation may read? +pub fn is_file(path: impl AsRef<Path>) -> bool { + metadata(path).is_ok_and(|m| m.is_file()) +} + +/// Is there a folder at `path` that automation may read? +pub fn is_dir(path: impl AsRef<Path>) -> bool { + metadata(path).is_ok_and(|m| m.is_dir()) +} + +/// Is there anything at `path` that automation may read? +pub fn exists(path: impl AsRef<Path>) -> bool { + metadata(path).is_ok() +} + +/// The entries of the folder at `path`, inside the automation read root when there is one. +pub fn read_dir(path: impl AsRef<Path>) -> std::io::Result<std::fs::ReadDir> { + let path = path.as_ref(); + crate::file_access::check_read(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?; + std::fs::read_dir(path) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2165eebc4..1f06cbade 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -11,7 +11,9 @@ pub mod atomic; pub mod cmd; pub mod dtd; +pub mod file_access; pub mod guard; +pub mod io; pub mod links; pub mod math; pub mod recovery; @@ -558,4 +560,6 @@ mod tests; #[cfg(test)] mod tests_idml; #[cfg(test)] +mod tests_roots; +#[cfg(test)] mod tests_table; diff --git a/crates/engine/src/tests_roots.rs b/crates/engine/src/tests_roots.rs new file mode 100644 index 000000000..443403b0a --- /dev/null +++ b/crates/engine/src/tests_roots.rs @@ -0,0 +1,212 @@ +//! The automation roots at the engine: the files a document names (links, data sources, book +//! chapters, packaged fonts) are read and written through the same checks as the paths a command +//! is given. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use serde_json::{Value, json}; + +use crate::file_access::{AutomationRoots, confine}; +use crate::{Session, cmd}; + +const PNG: &[u8] = &[ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8, 0xcf, 0xc0, 0xf0, + 0x1f, 0x00, 0x05, 0x00, 0x01, 0xff, 0x89, 0x99, 0x3d, 0x1d, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, +]; + +/// A fresh folder with `in` (the read root), `out` (the write root) and `outside`, and the roots. +fn folders(name: &str) -> (PathBuf, Arc<AutomationRoots>) { + let base = std::env::temp_dir().join(format!("dc-engine-roots-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + for d in ["in", "out", "outside"] { + std::fs::create_dir_all(base.join(d)).unwrap(); + } + let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap().unwrap(); + (base, roots) +} + +fn p(path: &Path) -> String { + path.to_string_lossy().into_owned() +} + +/// A document with `pic.png` placed from `dir`, so the asset is embedded and linked to the file. +fn linked_picture(dir: &Path) -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("file.place", &json!({"path": p(&dir.join("pic.png")), "x": 36, "y": 36, "width": 72})).unwrap(); + s +} + +fn link_status(s: &mut Session) -> Value { + s.execute("links.list", &json!({})).unwrap()[0]["status"].clone() +} + +#[test] +fn a_link_outside_the_read_root_reads_as_missing() { + let (base, roots) = folders("links"); + std::fs::write(base.join("outside/pic.png"), PNG).unwrap(); + std::fs::write(base.join("in/pic.png"), PNG).unwrap(); + let mut outside = linked_picture(&base.join("outside")); + let mut inside = linked_picture(&base.join("in")); + assert_eq!(link_status(&mut outside), "ok"); + confine(Some(&roots), || { + assert_eq!(link_status(&mut outside), "missing", "the file is not the agent's to look at, not even to compare"); + assert_eq!(link_status(&mut inside), "ok"); + assert!(outside.execute("links.relinkFolder", &json!({"dir": p(&base.join("outside"))})).is_err()); + // A relink to a link that leads out is refused too, and the asset keeps what it had. + let asset = outside.execute("links.list", &json!({})).unwrap()[0]["asset"].clone(); + let e = outside.execute("links.relink", &json!({"asset": asset, "path": p(&base.join("outside/pic.png"))})).unwrap_err().to_string(); + assert!(e.contains("outside the read root"), "{e}"); + }); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn idml_links_are_read_through_the_roots() { + let (base, roots) = folders("idml"); + std::fs::write(base.join("outside/pic.png"), PNG).unwrap(); + let mut s = linked_picture(&base.join("outside")); + // Linked, not embedded: opening the package has to read the file its link names. + let idml = base64_idml(&mut s); + let linked = |doc: &designcraft_doc::Document| doc.assets.values().filter(|a| !a.data.is_empty()).count(); + let unconfined = cmd::interchange::import_idml_confined(&idml).unwrap(); + assert_eq!(linked(&unconfined), 1, "without roots the link is followed"); + let confined = confine(Some(&roots), || cmd::interchange::import_idml_confined(&idml)).unwrap(); + assert_eq!(linked(&confined), 0, "with roots a link outside the read root is not read"); + // The same through the commands that open or place a package. + let b64 = cmd::base64_encode(&idml); + let mut t = Session::new(); + t.execute("file.new", &json!({})).unwrap(); + confine(Some(&roots), || { + t.execute("file.openBytes", &json!({"name": "x.idml", "base64": b64})).unwrap(); + let doc = &t.doc().unwrap().doc; + assert_eq!(linked(doc), 0); + t.execute("file.place", &json!({"base64": b64, "name": "x.idml", "layoutPage": 1, "x": 0, "y": 0})).unwrap(); + assert_eq!(linked(&t.doc().unwrap().doc), 0); + }); + let _ = std::fs::remove_dir_all(base); +} + +fn base64_idml(s: &mut Session) -> Vec<u8> { + let r = s.execute("file.exportIdml", &json!({"embedImages": false})).unwrap(); + cmd::base64_decode(r["base64"].as_str().unwrap()) +} + +#[test] +fn a_merge_does_not_read_image_files_outside_the_read_root() { + let (base, roots) = folders("merge"); + std::fs::write(base.join("outside/pic.png"), PNG).unwrap(); + std::fs::write(base.join("in/data.csv"), format!("@Photo\n{}\n", base.join("outside/pic.png").display())).unwrap(); + let merge = |s: &mut Session| { + s.execute("file.new", &json!({})).unwrap(); + s.execute("data.source.select", &json!({"path": p(&base.join("in/data.csv"))})).unwrap(); + let frame = s.execute("frame.create", &json!({"rect": [36.0, 36.0, 80.0, 80.0], "content": "graphic"})).unwrap(); + s.execute("data.placeholder.add", &json!({"field": "Photo", "item": frame["id"]})).unwrap(); + s.execute("data.merge", &json!({})).unwrap()["missingImages"].as_array().unwrap().len() + }; + assert_eq!(merge(&mut Session::new()), 0, "without roots the picture is found"); + assert_eq!(confine(Some(&roots), || merge(&mut Session::new())), 1, "with roots it reads as missing"); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn a_data_source_path_stored_in_a_document_is_read_through_the_roots() { + let (base, roots) = folders("source"); + std::fs::write(base.join("outside/data.csv"), "name\nTOP SECRET\n").unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("data.source.select", &json!({"path": p(&base.join("outside/data.csv"))})).unwrap(); + confine(Some(&roots), || { + let e = s.execute("data.source.update", &json!({})).unwrap_err().to_string(); + assert!(e.contains("outside the read root") && !e.contains("TOP SECRET"), "{e}"); + }); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn a_book_writes_its_chapters_only_inside_the_write_root() { + let (base, roots) = folders("book"); + let chapter = base.join("in/a.designcraft"); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("file.save", &json!({"path": p(&chapter)})).unwrap(); + let second = base.join("in/b.designcraft"); + s.execute("file.saveAs", &json!({"path": p(&second)})).unwrap(); + let before = (std::fs::read(&chapter).unwrap(), std::fs::read(&second).unwrap()); + confine(Some(&roots), || { + s.execute("book.new", &json!({"path": p(&base.join("out/b.dcbook"))})).unwrap(); + s.execute("book.add", &json!({"path": p(&chapter)})).unwrap(); + s.execute("book.add", &json!({"path": p(&second)})).unwrap(); + for command in ["book.paginate", "book.syncStyles", "book.exportPdf"] { + let r = s.execute(command, &json!({"path": p(&base.join("outside/book.pdf"))})); + let e = r.unwrap_err().to_string(); + assert!(e.contains("outside the write root"), "{command}: {e}"); + } + let e = s.execute("book.new", &json!({"path": p(&base.join("outside/b.dcbook"))})).unwrap_err().to_string(); + assert!(e.contains("outside the write root"), "{e}"); + }); + assert_eq!((std::fs::read(&chapter).unwrap(), std::fs::read(&second).unwrap()), before, "no chapter was rewritten"); + assert!(!base.join("outside/b.dcbook").exists()); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn package_and_copy_links_create_nothing_outside_the_write_root() { + let (base, roots) = folders("package"); + std::fs::write(base.join("in/pic.png"), PNG).unwrap(); + let mut s = linked_picture(&base.join("in")); + confine(Some(&roots), || { + for command in ["file.package", "links.copyTo"] { + let e = s.execute(command, &json!({"dir": p(&base.join("outside/pkg"))})).unwrap_err().to_string(); + assert!(e.contains("outside the write root"), "{command}: {e}"); + // The read root is not writable either. + let e = s.execute(command, &json!({"dir": p(&base.join("in/pkg"))})).unwrap_err().to_string(); + assert!(e.contains("outside the write root"), "{command}: {e}"); + } + s.execute("file.package", &json!({"dir": p(&base.join("out/pkg"))})).unwrap(); + s.execute("links.copyTo", &json!({"dir": p(&base.join("out/copies"))})).unwrap(); + }); + assert!(!base.join("outside/pkg").exists() && !base.join("in/pkg").exists()); + assert!(base.join("out/pkg/Instructions.txt").exists() && base.join("out/copies/pic.png").exists()); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn recovery_folders_named_by_a_command_are_checked() { + let (base, roots) = folders("recovery"); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("frame.create", &json!({"rect": [10, 10, 100, 100]})).unwrap(); + confine(Some(&roots), || { + let out = p(&base.join("outside")); + let inside = p(&base.join("in")); + let to_write = p(&base.join("out")); + let e = s.execute("file.recovery.save", &json!({"dir": out})).unwrap_err().to_string(); + assert!(e.contains("outside the write root"), "{e}"); + let e = s.execute("file.recovery.save", &json!({"dir": inside})).unwrap_err().to_string(); + assert!(e.contains("outside the write root"), "{e}"); + assert!(s.execute("file.recovery.list", &json!({"dir": out})).unwrap_err().to_string().contains("outside the read root")); + // Reopening removes what it read, so it needs both. + assert!(s.execute("file.recovery.open", &json!({"dir": inside})).unwrap_err().to_string().contains("outside the write root")); + assert!(s.execute("file.recovery.open", &json!({"dir": to_write})).unwrap_err().to_string().contains("outside the read root")); + assert_eq!(s.execute("file.recovery.save", &json!({"dir": to_write})).unwrap()["saved"], 1); + }); + assert!(std::fs::read_dir(base.join("outside")).unwrap().next().is_none()); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn fonts_beside_a_document_outside_the_read_root_are_not_loaded() { + let (base, roots) = folders("fonts"); + std::fs::create_dir_all(base.join("outside/Document Fonts")).unwrap(); + std::fs::write(base.join("outside/Document Fonts/x.ttf"), b"not a font").unwrap(); + let mut d = designcraft_doc::Document::new(&designcraft_doc::build::NewDocument::default()); + let (_, faces, warnings) = confine(Some(&roots), || cmd::load_document_fonts(&mut d, &p(&base.join("outside/doc.designcraft")))); + assert_eq!(faces, 0); + assert!(warnings.iter().any(|w| w.contains("outside the read root")), "{warnings:?}"); + let (_, _, warnings) = cmd::load_document_fonts(&mut d, &p(&base.join("outside/doc.designcraft"))); + assert!(warnings.iter().any(|w| w.contains("x.ttf")), "without roots the folder is read and the bad font reported: {warnings:?}"); + let _ = std::fs::remove_dir_all(base); +} From 54330aa63278fb2cca0ea1ce6e2ab889f2bf6f62 Mon Sep 17 00:00:00 2001 From: Isaac Levine <isaacmlevine4@gmail.com> Date: Mon, 12 Oct 2026 10:57:59 -0400 Subject: [PATCH 094/238] MCP: confine the headless session to the automation roots --- crates/mcp/src/backend.rs | 6 + crates/mcp/src/headless.rs | 44 +++- crates/mcp/src/tools.rs | 2 +- crates/mcp/tests/automation_roots.rs | 349 +++++++++++++++++++++++++++ 4 files changed, 389 insertions(+), 12 deletions(-) create mode 100644 crates/mcp/tests/automation_roots.rs diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index c4930cc99..dc705e98f 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -13,6 +13,12 @@ pub trait Backend { fn call(&mut self, method: &str, params: Value) -> Result<Value, String>; /// True when a real UI is attached (`ui.screenshot`, `ui.click`, dialogs… work). fn has_ui(&self) -> bool; + /// Write `bytes` to the file at `path` for a tool that was asked to keep a file (the PNG of + /// `render_page`). A backend that confines file access (see [`crate::Headless`]) applies it + /// here too. + fn write_file(&mut self, path: &str, bytes: &[u8]) -> Result<(), String> { + designcraft_engine::io::write_file(path, bytes) + } /// Short human description ("headless", "connected to 127.0.0.1:7979"). fn describe(&self) -> String; } diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index eea81ddd3..c6277fbf2 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -1,5 +1,8 @@ //! An in-process engine session that answers the engine-level control-channel methods itself. +use std::sync::Arc; + +use designcraft_engine::file_access::{self, AutomationRoots}; use designcraft_engine::{Session, ViewInfo}; use designcraft_geom::Point; use designcraft_render::{RenderOptions, Renderer}; @@ -12,6 +15,7 @@ use crate::backend::Backend; pub struct Headless { pub session: Session, pub renderer: Renderer, + roots: Option<Arc<AutomationRoots>>, } impl Default for Headless { @@ -82,7 +86,14 @@ fn mods_from(p: &Value) -> Mods { impl Headless { /// A session with no document (the first `file.new` / `file.open` creates one). pub fn new() -> Self { - Self { session: Session::new(), renderer: Renderer::new() } + Self { session: Session::new(), renderer: Renderer::new(), roots: None } + } + + /// Confine every file the session's commands read and write to `roots` + /// (`designcraft-cli mcp --automation-read-root … --automation-write-root …`). + pub fn with_automation_roots(mut self, roots: Option<Arc<AutomationRoots>>) -> Self { + self.roots = roots; + self } /// A session with a fresh default (Letter) document, ready to lay out. @@ -201,7 +212,7 @@ impl Headless { let png = img.to_png(); match s(p, "path") { Some(path) => { - designcraft_engine::atomic::write_atomic(std::path::Path::new(path), &png).map_err(|e| format!("write {path}: {e}"))?; + designcraft_engine::io::write_file(path, &png)?; Ok(json!({"path": path, "width": img.width, "height": img.height})) } None => Ok(json!({"width": img.width, "height": img.height, "pngBase64": designcraft_engine::cmd::base64_encode(&png)})), @@ -221,13 +232,32 @@ impl Headless { .ok_or_else(|| format!("no page {page}"))?; let lower = path.to_ascii_lowercase(); let bytes = if lower.ends_with(".jpg") || lower.ends_with(".jpeg") { img.to_jpeg(90) } else { img.to_png() }; - designcraft_engine::atomic::write_atomic(std::path::Path::new(path), &bytes).map_err(|e| format!("write {path}: {e}"))?; + designcraft_engine::io::write_file(path, &bytes)?; Ok(json!({"path": path, "width": img.width, "height": img.height})) } } impl Backend for Headless { fn call(&mut self, method: &str, params: Value) -> Result<Value, String> { + let roots = self.roots.clone(); + file_access::confine(roots.as_ref(), || self.answer(method, params)) + } + + fn write_file(&mut self, path: &str, bytes: &[u8]) -> Result<(), String> { + file_access::confine(self.roots.as_ref(), || designcraft_engine::io::write_file(path, bytes)) + } + + fn has_ui(&self) -> bool { + false + } + + fn describe(&self) -> String { + "headless (in-process engine, no window)".into() + } +} + +impl Headless { + fn answer(&mut self, method: &str, params: Value) -> Result<Value, String> { let p = ¶ms; match method { "engine.execute" | "ui.menu.invoke" | "command" => { @@ -254,14 +284,6 @@ impl Backend for Headless { other => Err(format!("unknown method `{other}`")), } } - - fn has_ui(&self) -> bool { - false - } - - fn describe(&self) -> String { - "headless (in-process engine, no window)".into() - } } /// A supplied page must be a representable unsigned index; only omission selects page zero. diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index c51bac8ed..553ce400a 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -479,7 +479,7 @@ fn render_page(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> { let path = a.get("path").and_then(Value::as_str); if let Some(path) = path { let png = designcraft_engine::cmd::base64_decode(&b64); - designcraft_engine::atomic::write_atomic(std::path::Path::new(path), &png).map_err(|e| format!("write {path}: {e}"))?; + b.write_file(path, &png)?; } Ok(ToolResult::image(b64, &json!({"width": r.get("width"), "height": r.get("height"), "path": path}))) } diff --git a/crates/mcp/tests/automation_roots.rs b/crates/mcp/tests/automation_roots.rs new file mode 100644 index 000000000..87ce411e8 --- /dev/null +++ b/crates/mcp/tests/automation_roots.rs @@ -0,0 +1,349 @@ +//! `designcraft-cli mcp --automation-read-root <dir> --automation-write-root <dir>`: through the MCP +//! protocol, every file a command reads comes from inside the read root and every file it writes +//! goes inside the write root. Paths that leave a root (`..`, another folder, a sibling with the +//! same prefix, a link) are refused, nothing from a refused file reaches the document, and nothing +//! is written outside. +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::path::{Path, PathBuf}; + +use designcraft_engine::Session; +use designcraft_engine::file_access::AutomationRoots; +use designcraft_mcp::{Headless, Server}; +use serde_json::{Value, json}; + +/// What the secret files hold, so a test can look for it in everything the agent is told. +const SECRET: &str = "TOP SECRET"; + +/// A 1x1 PNG, so `place_image` has something real to read. +const PNG: &[u8] = &[ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8, 0xcf, 0xc0, 0xf0, + 0x1f, 0x00, 0x05, 0x00, 0x01, 0xff, 0x89, 0x99, 0x3d, 0x1d, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, +]; + +/// A `.designcraft` file of `pages` pages with `text` in a frame on the first, made outside any roots. +fn designcraft_file(path: &Path, pages: u32, text: &str) { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": pages})).unwrap(); + s.execute("frame.create", &json!({"rect": [36, 36, 300, 100], "content": "text", "text": text})).unwrap(); + s.execute("file.save", &json!({"path": path.to_str().unwrap()})).unwrap(); +} + +/// A fresh folder holding `in` (the read root), `out` (the write root), `outside` and `in2` (a +/// sibling whose name starts with `in`), and a server confined to `in` and `out` (either may be +/// left out). `in` and `outside` each hold the same kinds of files, those outside carrying +/// [`SECRET`]. The server starts with a new one-page document. +fn confined(tag: &str, read: bool, write: bool) -> (Server, PathBuf) { + let base = std::env::temp_dir().join(format!("dc-mcp-roots-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + for d in ["in", "out", "outside", "in2"] { + std::fs::create_dir_all(base.join(d)).unwrap(); + } + for (dir, text) in [("in", "Inside text"), ("outside", SECRET), ("in2", "Sibling")] { + let dir = base.join(dir); + designcraft_file(&dir.join("doc.designcraft"), if dir.ends_with("in") { 1 } else { 3 }, text); + std::fs::write(dir.join("pic.png"), PNG).unwrap(); + std::fs::write(dir.join("data.csv"), format!("name,city\n{text},Oslo\n")).unwrap(); + std::fs::write(dir.join("swatches.ase"), b"not a swatch file").unwrap(); + std::fs::write(dir.join("profile.icc"), b"not a profile").unwrap(); + std::fs::write(dir.join("lib.json"), format!("{{\"items\": [], \"note\": \"{text}\"}}")).unwrap(); + std::fs::write(dir.join("shape.dtd"), format!("<!ELEMENT {} (#PCDATA)>", text.replace(' ', "_"))).unwrap(); + std::fs::write(dir.join("tags.xml"), format!("<root><p>{text}</p></root>")).unwrap(); + } + let (in_dir, out_dir) = (base.join("in"), base.join("out")); + let roots = AutomationRoots::new(read.then_some(in_dir.as_path()), write.then_some(out_dir.as_path())).unwrap(); + let mut server = Server::new(Box::new(Headless::with_document().with_automation_roots(roots))); + ok(&mut server, "new_document", json!({})); + (server, base) +} + +fn s(p: &Path) -> String { + p.to_string_lossy().into_owned() +} + +/// `tools/call` -> (isError, every text block). +fn call(server: &mut Server, name: &str, args: Value) -> (bool, String) { + let line = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": name, "arguments": args}}).to_string(); + let reply: Value = serde_json::from_str(&server.handle_line(&line).unwrap()).unwrap(); + let result = &reply["result"]; + let text = result["content"].as_array().unwrap().iter().filter_map(|c| c["text"].as_str()).collect::<Vec<_>>().join("\n"); + (result["isError"] == true, text) +} + +fn ok(server: &mut Server, name: &str, args: Value) -> String { + let (error, text) = call(server, name, args.clone()); + assert!(!error, "{name} {args}: {text}"); + text +} + +/// A command through the `execute` tool. +fn run(server: &mut Server, command: &str, params: Value) -> (bool, String) { + call(server, "execute", json!({"command": command, "params": params})) +} + +fn run_ok(server: &mut Server, command: &str, params: Value) -> Value { + let (error, text) = run(server, command, params.clone()); + assert!(!error, "{command} {params}: {text}"); + serde_json::from_str(&text).unwrap_or(Value::Null) +} + +/// `command` is refused with `why` in its message, and the message says nothing of the file. +fn refused(server: &mut Server, command: &str, params: Value, why: &str) { + let (error, text) = run(server, command, params.clone()); + assert!(error && text.contains(why), "{command} {params} should be refused ({why}): {text}"); + assert!(!text.contains(SECRET), "{command}: {text}"); +} + +/// The open document's page count, which tells the three-page files outside from the one-page one. +fn pages(server: &mut Server) -> usize { + let doc: Value = serde_json::from_str(&ok(server, "inspect_document", json!({}))).unwrap(); + doc["pageCount"].as_u64().unwrap() as usize +} + +fn outside_names(base: &Path) -> Vec<String> { + let mut names: Vec<String> = + std::fs::read_dir(base.join("outside")).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); + names.sort(); + names +} + +#[test] +fn documents_open_only_from_inside_the_read_root() { + let (mut server, base) = confined("open", true, true); + let sep = std::path::MAIN_SEPARATOR; + ok(&mut server, "open_document", json!({"path": s(&base.join("in/doc.designcraft"))})); + assert_eq!(pages(&mut server), 1, "the file inside the root is the open document"); + + let escape = format!("{}{sep}..{sep}outside{sep}doc.designcraft", s(&base.join("in"))); + let paths = [s(&base.join("outside/doc.designcraft")), escape, s(&base.join("in2/doc.designcraft")), s(&base.join("out/new.designcraft"))]; + for path in paths { + let (error, text) = call(&mut server, "open_document", json!({"path": path})); + assert!(error && text.contains("outside the read root"), "{path}: {text}"); + assert!(!text.contains(SECRET), "{text}"); + refused(&mut server, "file.open", json!({"path": path}), "outside the read root"); + refused(&mut server, "file.openIdml", json!({"path": path}), "outside the read root"); + } + // Nothing from the refused files reached the session: the open document is still the one it was. + assert_eq!(pages(&mut server), 1); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn every_command_that_reads_a_path_is_refused_outside_the_read_root() { + let (mut server, base) = confined("reads", true, true); + let outside = |name: &str| s(&base.join("outside").join(name)); + let inside = |name: &str| s(&base.join("in").join(name)); + + // A placed image to relink and a data source to update, both from inside the root. + ok(&mut server, "place_image", json!({"path": inside("pic.png"), "x": 36, "y": 36, "width": 72})); + let links = run_ok(&mut server, "links.list", json!({})); + let asset = links[0]["asset"].clone(); + let fields = run_ok(&mut server, "data.source.select", json!({"path": inside("data.csv")})); + assert!(fields["fields"].to_string().contains("city"), "{fields}"); + let before = ok(&mut server, "inspect_document", json!({})); + + let reads: Vec<(&str, Value)> = vec![ + ("file.place", json!({"path": outside("pic.png")})), + ("place.load", json!({"path": outside("pic.png")})), + ("place.styles", json!({"path": outside("tags.xml")})), + ("links.relink", json!({"asset": asset, "path": outside("pic.png")})), + ("links.relinkFolder", json!({"dir": s(&base.join("outside"))})), + ("media.options", json!({"poster": {"path": outside("pic.png")}})), + ("color.loadProfile", json!({"path": outside("profile.icc")})), + ("swatch.load", json!({"path": outside("swatches.ase")})), + ("library.open", json!({"path": outside("lib.json")})), + ("book.open", json!({"path": outside("lib.json")})), + ("data.source.select", json!({"path": outside("data.csv")})), + ("data.merge", json!({"path": outside("data.csv")})), + ("xml.loadDtd", json!({"path": outside("shape.dtd")})), + ("file.importXml", json!({"path": outside("tags.xml")})), + ("file.recovery.list", json!({"dir": s(&base.join("outside"))})), + ("file.recovery.open", json!({"dir": s(&base.join("outside"))})), + ]; + for (command, params) in reads { + refused(&mut server, command, params, "outside the read root"); + } + let (error, text) = call(&mut server, "place_image", json!({"path": outside("pic.png")})); + assert!(error && text.contains("outside the read root") && !text.contains(SECRET), "{text}"); + // The control-channel method behind `open_document`. + let (error, text) = call(&mut server, "execute", json!({"command": "app.open", "params": {"path": outside("doc.designcraft")}})); + assert!(error && !text.contains(SECRET), "app.open: {text}"); + assert_eq!(ok(&mut server, "inspect_document", json!({})), before, "the refused reads changed nothing"); + assert_eq!(run_ok(&mut server, "data.source.select", json!({"path": inside("data.csv")}))["records"], 1); + + // Inside the root the same commands get as far as reading the file, so the table above is not + // refusing for some other reason: a file that isn't a profile or swatch file is reported as such. + for (command, params) in [("color.loadProfile", json!({"path": inside("profile.icc")})), ("swatch.load", json!({"path": inside("swatches.ase")}))] + { + let (error, text) = run(&mut server, command, params); + assert!(error && !text.contains("automation"), "{command}: {text}"); + } + run_ok(&mut server, "xml.loadDtd", json!({"path": inside("shape.dtd")})); + run_ok(&mut server, "file.importXml", json!({"path": inside("tags.xml")})); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn files_named_inside_a_document_are_read_through_the_roots_too() { + let (mut server, base) = confined("stored", true, true); + // A document in the read root whose picture is linked to a file outside it, and whose data + // source is a file outside it, made by someone without roots. + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + std::fs::write(base.join("outside/linked.png"), PNG).unwrap(); + s.execute("file.place", &json!({"path": self::s(&base.join("outside/linked.png")), "x": 36, "y": 36, "width": 72})).unwrap(); + s.execute("data.source.select", &json!({"path": self::s(&base.join("outside/data.csv"))})).unwrap(); + s.execute("file.save", &json!({"path": self::s(&base.join("in/linked.designcraft"))})).unwrap(); + assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "ok"); + + ok(&mut server, "open_document", json!({"path": self::s(&base.join("in/linked.designcraft"))})); + let links = run_ok(&mut server, "links.list", json!({})); + assert_eq!(links[0]["status"], "missing", "a link outside the read root reads as a file that isn't there: {links}"); + refused(&mut server, "data.source.update", json!({}), "outside the read root"); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn every_writing_command_is_refused_outside_the_write_root() { + let (mut server, base) = confined("write", true, true); + let outside = |name: &str| s(&base.join("outside").join(name)); + let inside = |name: &str| s(&base.join("out").join(name)); + let fixtures = outside_names(&base); + // Something to export: a picture to package and a text frame, selected so `file.exportText` + // and `snippet.export` have their input. + ok(&mut server, "place_image", json!({"path": s(&base.join("in/pic.png")), "x": 36, "y": 200, "width": 72})); + run_ok(&mut server, "frame.create", json!({"rect": [36, 300, 300, 100], "content": "text", "text": "Selected"})); + run_ok(&mut server, "select.firstAbove", json!({})); + + // Every command that writes a file, with the path parameter it takes. + let writes: Vec<(&str, &str)> = vec![ + ("file.save", "path"), + ("file.saveAs", "path"), + ("file.saveACopy", "path"), + ("file.exportPdf", "path"), + ("file.exportEpub", "path"), + ("file.exportFixedEpub", "path"), + ("file.exportHtml", "path"), + ("file.exportText", "path"), + ("file.exportXml", "path"), + ("file.exportIdml", "path"), + ("file.printBooklet", "path"), + ("snippet.export", "path"), + ("swatch.save", "path"), + ("library.new", "path"), + ("book.exportPdf", "path"), + ("book.new", "path"), + ("file.package", "dir"), + ("links.copyTo", "dir"), + ("file.recovery.save", "dir"), + ]; + // A book with a chapter to export, so `book.exportPdf` gets as far as its path. + run_ok(&mut server, "book.new", json!({"path": inside("first.dcbook")})); + run_ok(&mut server, "book.add", json!({"path": s(&base.join("in/doc.designcraft"))})); + for (command, key) in &writes { + let target = if *key == "dir" { outside("folder") } else { outside(&format!("out-{command}")) }; + refused(&mut server, command, json!({*key: target}), "outside the write root"); + } + for tool in ["save_document", "export_png", "render_page"] { + let (error, text) = call(&mut server, tool, json!({"path": outside("tool.out")})); + assert!(error && text.contains("outside the write root"), "{tool}: {text}"); + } + // The control-channel methods behind `save_document` and `export_png`. + for method in ["app.save", "app.export"] { + let (error, text) = call(&mut server, "execute", json!({"command": method, "params": {"path": outside("app.out")}})); + assert!(error, "{method}: {text}"); + } + // A sibling folder with the same prefix, `..` out of the root, and the read root. + let sep = std::path::MAIN_SEPARATOR; + for path in [ + s(&base.join("out2/x.designcraft")), + format!("{}{sep}..{sep}outside{sep}x.designcraft", s(&base.join("out"))), + s(&base.join("in/x.designcraft")), + ] { + let (error, text) = call(&mut server, "save_document", json!({"path": path})); + assert!(error && text.contains("outside the write root"), "{path}: {text}"); + } + assert_eq!(outside_names(&base), fixtures, "nothing was written outside beside the files made at the start"); + assert!(!base.join("out2").exists() && !base.join("in/x.designcraft").exists()); + + // Inside the write root every one of them works, and the files appear. + for (command, key) in &writes { + let target = if *key == "dir" { inside(&format!("dir-{command}")) } else { inside(&format!("out-{command}")) }; + run_ok(&mut server, command, json!({*key: target})); + } + for tool in ["export_png", "render_page"] { + ok(&mut server, tool, json!({"path": inside(&format!("{tool}.png"))})); + assert!(base.join("out").join(format!("{tool}.png")).exists(), "{tool}"); + } + for file in ["out-file.save", "out-file.exportPdf", "out-file.exportIdml", "out-book.exportPdf", "out-swatch.save", "out-snippet.export"] { + assert!(base.join("out").join(file).exists(), "{file}"); + } + assert!(base.join("out/dir-file.package/Instructions.txt").exists() && base.join("out/dir-links.copyTo/pic.png").exists()); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn one_root_grants_only_its_own_access() { + let (mut read_only, base) = confined("read-only", true, false); + ok(&mut read_only, "open_document", json!({"path": s(&base.join("in/doc.designcraft"))})); + run_ok(&mut read_only, "select.firstAbove", json!({})); + for (command, key) in [("file.save", "path"), ("file.exportPdf", "path"), ("file.package", "dir"), ("file.exportText", "path")] { + refused(&mut read_only, command, json!({key: s(&base.join("out/x"))}), "write authority is absent"); + } + for tool in ["save_document", "export_png", "render_page"] { + let (error, text) = call(&mut read_only, tool, json!({"path": s(&base.join("out/x.png"))})); + assert!(error && text.contains("write authority is absent"), "{tool}: {text}"); + } + // Calls that return bytes instead of writing a path keep working. + ok(&mut read_only, "render_page", json!({"scale": 0.25})); + run_ok(&mut read_only, "file.exportPdf", json!({})); + assert!(std::fs::read_dir(base.join("out")).unwrap().next().is_none()); + let _ = std::fs::remove_dir_all(base); + + let (mut write_only, base) = confined("write-only", false, true); + for (command, key) in + [("file.open", "path"), ("file.place", "path"), ("swatch.load", "path"), ("data.source.select", "path"), ("file.recovery.list", "dir")] + { + refused(&mut write_only, command, json!({key: s(&base.join("in/doc.designcraft"))}), "read authority is absent"); + } + let (error, text) = call(&mut write_only, "place_image", json!({"path": s(&base.join("in/pic.png"))})); + assert!(error && text.contains("read authority is absent"), "{text}"); + ok(&mut write_only, "save_document", json!({"path": s(&base.join("out/x.designcraft"))})); + assert!(base.join("out/x.designcraft").exists()); + let _ = std::fs::remove_dir_all(base); +} + +#[cfg(unix)] +#[test] +fn links_out_of_a_root_are_refused() { + let (mut server, base) = confined("links", true, true); + std::os::unix::fs::symlink(base.join("outside/doc.designcraft"), base.join("in/leak.designcraft")).unwrap(); + std::os::unix::fs::symlink(base.join("outside/data.csv"), base.join("in/leak.csv")).unwrap(); + std::os::unix::fs::symlink(base.join("outside"), base.join("out/away")).unwrap(); + std::os::unix::fs::symlink(base.join("outside/gone.designcraft"), base.join("out/dangling.designcraft")).unwrap(); + refused(&mut server, "file.open", json!({"path": s(&base.join("in/leak.designcraft"))}), "outside the read root"); + refused(&mut server, "data.source.select", json!({"path": s(&base.join("in/leak.csv"))}), "outside the read root"); + refused(&mut server, "file.saveAs", json!({"path": s(&base.join("out/away/x.designcraft"))}), "outside the write root"); + refused(&mut server, "file.exportPdf", json!({"path": s(&base.join("out/away/x.pdf"))}), "outside the write root"); + refused(&mut server, "file.saveAs", json!({"path": s(&base.join("out/dangling.designcraft"))}), "a link that leads nowhere"); + assert!(!base.join("outside/x.designcraft").exists() && !base.join("outside/x.pdf").exists() && !base.join("outside/gone.designcraft").exists()); + assert_eq!(pages(&mut server), 1, "nothing from the file the link led to reached the session"); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn without_roots_nothing_is_confined() { + let base = std::env::temp_dir().join(format!("dc-mcp-roots-none-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + designcraft_file(&base.join("a.designcraft"), 3, "Anywhere"); + let mut server = Server::new(Box::new(Headless::with_document().with_automation_roots(AutomationRoots::new(None, None).unwrap()))); + ok(&mut server, "open_document", json!({"path": s(&base.join("a.designcraft"))})); + assert_eq!(pages(&mut server), 3); + ok(&mut server, "save_document", json!({"path": s(&base.join("b.designcraft"))})); + ok(&mut server, "export_png", json!({"path": s(&base.join("b.png"))})); + assert!(base.join("b.designcraft").exists() && base.join("b.png").exists()); + let _ = std::fs::remove_dir_all(base); +} From 89399250750c0af1bb2023b6a770883496400a84 Mon Sep 17 00:00:00 2001 From: Isaac Levine <isaacmlevine4@gmail.com> Date: Mon, 12 Oct 2026 10:57:59 -0400 Subject: [PATCH 095/238] CLI: add --automation-read-root and --automation-write-root to mcp --- apps/designcraft-cli/src/automation_args.rs | 90 +++++++++++++++ apps/designcraft-cli/src/main.rs | 39 +++++-- apps/designcraft-cli/tests/cli.rs | 121 ++++++++++++++++++++ 3 files changed, 243 insertions(+), 7 deletions(-) create mode 100644 apps/designcraft-cli/src/automation_args.rs diff --git a/apps/designcraft-cli/src/automation_args.rs b/apps/designcraft-cli/src/automation_args.rs new file mode 100644 index 000000000..52ef34f20 --- /dev/null +++ b/apps/designcraft-cli/src/automation_args.rs @@ -0,0 +1,90 @@ +//! Parse `--automation-read-root` / `--automation-write-root` before the server starts. A malformed +//! flag must not silently remove a root and leave the server unconfined. + +use std::path::PathBuf; + +pub struct AutomationArgs { + pub read_root: Option<PathBuf>, + pub write_root: Option<PathBuf>, + read_given: bool, + write_given: bool, +} + +impl AutomationArgs { + pub fn new(read_root: Option<PathBuf>, write_root: Option<PathBuf>) -> Self { + Self { read_root, write_root, read_given: false, write_given: false } + } + + /// Returns whether this argument is a root flag. A second occurrence of a flag is an error, + /// including when the two spellings (`--flag DIR`, `--flag=DIR`) are mixed. + pub fn consume(&mut self, arg: &str, args: &mut impl Iterator<Item = String>) -> Result<bool, String> { + let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value))); + let (root, given) = match flag { + "--automation-read-root" => (&mut self.read_root, &mut self.read_given), + "--automation-write-root" => (&mut self.write_root, &mut self.write_given), + _ => return Ok(false), + }; + if *given { + return Err(format!("{flag} given twice")); + } + let value = match inline { + Some(value) => value.to_owned(), + None => { + let value = args.next().ok_or_else(|| format!("{flag} needs a folder"))?; + if value.starts_with('-') { + return Err(format!("{flag} needs a folder; use {flag}=<dir> for a folder starting with '-'")); + } + value + } + }; + if value.is_empty() { + return Err(format!("{flag} needs a non-empty folder")); + } + *root = Some(value.into()); + *given = true; + Ok(true) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roots_are_taken_once_and_other_arguments_are_left() { + let mut roots = AutomationArgs::new(None, None); + let mut args = ["a folder".to_owned(), "doc.designcraft".to_owned()].into_iter(); + assert!(roots.consume("--automation-read-root", &mut args).unwrap()); + assert!(roots.consume("--automation-write-root=out", &mut args).unwrap()); + assert!(!roots.consume("--sample", &mut args).unwrap()); + assert_eq!(roots.read_root, Some("a folder".into())); + assert_eq!(roots.write_root, Some("out".into())); + assert_eq!(args.next().as_deref(), Some("doc.designcraft")); + assert!(roots.consume("--automation-read-root=other", &mut args).is_err()); + assert_eq!(roots.read_root, Some("a folder".into())); + } + + #[test] + fn invalid_operands_leave_roots_unchanged() { + for flag in ["--automation-read-root", "--automation-write-root"] { + for operand in [None, Some(""), Some("--version"), Some("--connect"), Some("-folder")] { + let mut roots = AutomationArgs::new(Some("read".into()), Some("write".into())); + let mut args = operand.map(str::to_owned).into_iter(); + assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}"); + assert_eq!(roots.read_root, Some("read".into())); + assert_eq!(roots.write_root, Some("write".into())); + } + let mut roots = AutomationArgs::new(None, None); + assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err()); + } + } + + #[test] + fn explicit_dash_folder_and_relative_dash_folder_are_valid() { + let mut roots = AutomationArgs::new(None, None); + assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap()); + assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap()); + assert_eq!(roots.read_root, Some("-folder".into())); + assert_eq!(roots.write_root, Some("./-folder".into())); + } +} diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs index c3808f24b..80ee7915f 100644 --- a/apps/designcraft-cli/src/main.rs +++ b/apps/designcraft-cli/src/main.rs @@ -9,7 +9,8 @@ //! # run a command script (crates/engine/src/script.rs): `$N.path` references //! designcraft-cli app [--port PORT] COMMAND [JSON] # run a command in the running app (designcraft --control PORT) //! designcraft-cli app [--port PORT] --method METHOD [JSON] # any control-channel method (ui.screenshot, ui.render, …) -//! designcraft-cli mcp [--connect PORT] [--sample] # MCP server over stdio (docs/mcp.md) +//! designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR] +//! # MCP server over stdio (docs/mcp.md); the roots confine the files it reads and writes //! designcraft-cli perf [--pages N] [--frames N] [--chars N] [--images N] [--runs N] [--strict] # budgets on a synthetic stress document //! designcraft-cli bench FILE [--runs N] # the same measurements on one document //! designcraft-cli validate-pdf FILE.pdf # run the built-in PDF/X-4 conformance checks @@ -30,13 +31,14 @@ macro_rules! outln { }}; } +mod automation_args; mod perf; use designcraft_engine::Session; use serde_json::{Value, json}; /// The usage block. Shared by `--help` (stdout, success) and an unknown command (stderr, failure). -const USAGE: &str = "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample]\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli links\n designcraft-cli --version"; +const USAGE: &str = "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR]\n the two roots confine every file the agent's commands read (open, placed images, data sources, libraries) and\n write (save, export, package) to those folders, links followed. A root left out grants none of its access.\n They run the in-process server, so they conflict with --connect (a running app is not confined by them).\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli links\n designcraft-cli --version"; /// The links line under the usage block. fn usage_footer() -> String { @@ -129,16 +131,31 @@ fn validate_pdf(path: Option<&str>) -> Result<(), String> { /// `mcp` (headless, in-process engine) or `mcp --connect PORT|HOST:PORT` (drive a running app /// started with `designcraft --control PORT`). JSON-RPC on stdin/stdout; logs on stderr. fn mcp(args: &[String]) -> Result<(), String> { + use designcraft_engine::file_access::{self, AutomationRoots}; use designcraft_mcp::{Backend, Headless, Remote, Server, control_addr}; let mut connect: Option<String> = None; let mut sample = false; - let mut it = args.iter(); + let mut roots_args = automation_args::AutomationArgs::new(None, None); + let mut it = args.iter().cloned(); while let Some(a) = it.next() { - match a.as_str() { - "--connect" => connect = Some(it.next().cloned().ok_or("--connect needs a port or host:port")?), - "--sample" => sample = true, - other => return Err(format!("unknown mcp option `{other}` (usage: designcraft-cli mcp [--connect PORT] [--sample])")), + if roots_args.consume(&a, &mut it)? { + continue; } + match a.as_str() { + "--connect" => connect = Some(it.next().ok_or("--connect needs a port or host:port")?), + "--sample" => sample = true, + other => { + return Err(format!( + "unknown mcp option `{other}` (usage: designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR])" + )); + } + } + } + let roots = AutomationRoots::new(roots_args.read_root.as_deref(), roots_args.write_root.as_deref())?; + if roots.is_some() && connect.is_some() { + return Err("--automation-read-root and --automation-write-root confine the in-process server; a running app is not confined by them, \ +so drop --connect (or use --connect without the roots)" + .into()); } let backend: Box<dyn Backend> = match connect { Some(c) => { @@ -150,9 +167,17 @@ fn mcp(args: &[String]) -> Result<(), String> { } None => { let mut h = Headless::with_document(); + // `--sample` is the operator's own choice, made before the roots come into force. if sample { h.session.execute("file.newSample", &json!({})).map_err(|e| e.to_string())?; } + if let Some(roots) = roots { + // Every thread of this process, not only the server's: nothing reaches past the roots. + file_access::confine_process(roots.clone()); + let show = |root: Option<&std::path::Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string()); + eprintln!("designcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root())); + h = h.with_automation_roots(Some(roots)); + } Box::new(h) } }; diff --git a/apps/designcraft-cli/tests/cli.rs b/apps/designcraft-cli/tests/cli.rs index c2804be94..fd96b89bc 100644 --- a/apps/designcraft-cli/tests/cli.rs +++ b/apps/designcraft-cli/tests/cli.rs @@ -154,3 +154,124 @@ fn raster_export_preserves_an_existing_reader_and_private_symlink_target() { assert_eq!(std::fs::metadata(&target).unwrap().permissions().mode() & 0o777, 0o600); assert!(std::fs::symlink_metadata(alias).unwrap().file_type().is_symlink()); } + +/// Send `calls` (tool name, arguments) to `designcraft-cli mcp <args>`; returns whether it exited +/// cleanly, its stderr, and the stdout lines after `initialize`, one per call. +fn mcp_session(args: &[&str], calls: &[(&str, serde_json::Value)]) -> (bool, String, Vec<serde_json::Value>) { + let mut child = cli().arg("mcp").args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap(); + { + let stdin = child.stdin.as_mut().unwrap(); + writeln!(stdin, r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-06-18"}}}}"#).unwrap(); + for (i, (name, arguments)) in calls.iter().enumerate() { + let call = serde_json::json!({"jsonrpc": "2.0", "id": i + 2, "method": "tools/call", "params": {"name": name, "arguments": arguments}}); + writeln!(stdin, "{call}").unwrap(); + } + } + let out = child.wait_with_output().unwrap(); + let stdout = String::from_utf8(out.stdout).unwrap(); + let replies = stdout.lines().skip(1).map(|l| serde_json::from_str(l).unwrap()).collect(); + (out.status.success(), String::from_utf8_lossy(&out.stderr).into_owned(), replies) +} + +fn text(reply: &serde_json::Value) -> &str { + reply["result"]["content"][0]["text"].as_str().unwrap() +} + +/// A `.designcraft` file with one page, made with the CLI itself. +fn make_document(path: &std::path::Path) { + let out = cli().args(["run", "--cmd", "file.new={}", "--cmd"]).arg(format!("file.save={}", serde_json::json!({"path": path}))).output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + assert!(path.exists()); +} + +#[test] +fn mcp_automation_roots_confine_the_server() { + let base = std::env::temp_dir().join(format!("dc-cli-roots-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let (work, elsewhere) = (base.join("work"), base.join("elsewhere")); + std::fs::create_dir_all(&work).unwrap(); + std::fs::create_dir_all(&elsewhere).unwrap(); + make_document(&work.join("in.designcraft")); + make_document(&elsewhere.join("secret.designcraft")); + let work_s = work.to_str().unwrap(); + let secret = elsewhere.join("secret.designcraft").to_str().unwrap().to_string(); + + // Both spellings of the flags; inside works, outside is refused with a readable message. + let read_flag = format!("--automation-read-root={work_s}"); + let calls = [ + ("open_document", serde_json::json!({"path": work.join("in.designcraft").to_str().unwrap()})), + ("open_document", serde_json::json!({"path": secret})), + ("save_document", serde_json::json!({"path": work.join("saved.designcraft").to_str().unwrap()})), + ("save_document", serde_json::json!({"path": elsewhere.join("saved.designcraft").to_str().unwrap()})), + ("export_png", serde_json::json!({"path": elsewhere.join("page.png").to_str().unwrap()})), + ("render_page", serde_json::json!({"path": elsewhere.join("render.png").to_str().unwrap()})), + ]; + let (success, stderr, replies) = mcp_session(&[&read_flag, "--automation-write-root", work_s], &calls); + assert!(success, "{stderr}"); + assert!(stderr.contains("files confined"), "{stderr}"); + assert_eq!(replies[0]["result"]["isError"], false, "{}", replies[0]); + assert_eq!(replies[1]["result"]["isError"], true); + assert!(text(&replies[1]).contains("outside the read root"), "{}", text(&replies[1])); + assert_eq!(replies[2]["result"]["isError"], false, "{}", replies[2]); + assert!(work.join("saved.designcraft").exists()); + for reply in &replies[3..] { + assert_eq!(reply["result"]["isError"], true, "{reply}"); + assert!(text(reply).contains("outside the write root"), "{}", text(reply)); + } + for name in ["saved.designcraft", "page.png", "render.png"] { + assert!(!elsewhere.join(name).exists(), "{name} was written outside the write root"); + } + + // A root left out grants none of its access. + let save = [("new_document", serde_json::json!({})), calls[2].clone()]; + let (_, _, replies) = mcp_session(&[&read_flag], &save); + assert_eq!(replies[1]["result"]["isError"], true); + assert!(text(&replies[1]).contains("write authority is absent"), "{}", text(&replies[1])); + let (_, _, replies) = mcp_session(&["--automation-write-root", work_s], &calls[..1]); + assert_eq!(replies[0]["result"]["isError"], true); + assert!(text(&replies[0]).contains("read authority is absent"), "{}", text(&replies[0])); + + // No flags: nothing is confined. + let (success, _, replies) = mcp_session(&[], &calls[1..2]); + assert!(success); + assert_eq!(replies[0]["result"]["isError"], false, "{}", replies[0]); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn mcp_automation_root_flags_are_checked_up_front() { + let base = std::env::temp_dir().join(format!("dc-cli-roots-args-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + let dir = base.to_str().unwrap(); + let missing = base.join("missing"); + let fails = |args: &[&str], needle: &str| { + let mut full = vec!["mcp"]; + full.extend_from_slice(args); + let o = cli().args(&full).output().unwrap(); + assert!(!o.status.success(), "{args:?}"); + let err = String::from_utf8_lossy(&o.stderr).into_owned(); + assert!(err.contains(needle), "{args:?}: {err}"); + }; + // The flags confine the in-process server, so they cannot be combined with --connect. + fails(&["--connect", "7979", "--automation-read-root", dir], "--connect"); + fails(&["--automation-write-root", dir, "--connect", "7979"], "--connect"); + fails(&["--automation-read-root", dir, "--automation-read-root", dir], "given twice"); + fails(&["--automation-write-root", dir, "--automation-write-root", dir], "given twice"); + fails(&["--automation-read-root", dir, "--automation-read-root=x"], "given twice"); + fails(&["--automation-read-root"], "needs a folder"); + fails(&["--automation-write-root", "--sample"], "needs a folder"); + fails(&["--automation-read-root", missing.to_str().unwrap()], "cannot open the automation read root"); + fails(&["--automation-write-root="], "needs a non-empty folder"); + // A file is not a folder. + std::fs::write(base.join("f.txt"), "x").unwrap(); + fails(&["--automation-write-root", base.join("f.txt").to_str().unwrap()], "cannot open the automation write root"); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn usage_lists_the_automation_root_flags() { + let out = cli().arg("--help").output().unwrap(); + let usage = String::from_utf8_lossy(&out.stdout); + assert!(usage.contains("--automation-read-root DIR") && usage.contains("--automation-write-root DIR"), "{usage}"); +} From 3650f36927981fffde385d60a8c813f876354e6d Mon Sep 17 00:00:00 2001 From: Isaac Levine <isaacmlevine4@gmail.com> Date: Mon, 12 Oct 2026 10:57:59 -0400 Subject: [PATCH 096/238] Docs: document --automation-read-root and --automation-write-root for mcp --- docs/mcp.md | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/docs/mcp.md b/docs/mcp.md index 17c03a3df..20447fd02 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -41,6 +41,71 @@ claude mcp add designcraft -- "C:\Program Files\DesignCraft\designcraft-cli.exe" claude mcp add designcraft -- designcraft-cli mcp ``` +## Confining file access + +```sh +designcraft-cli mcp --automation-read-root /work/project --automation-write-root /work/project/out +``` + +```json +{"mcpServers": {"designcraft": {"command": "designcraft-cli", + "args": ["mcp", "--automation-read-root", "/work/project", "--automation-write-root", "/work/project/out"]}}} +``` + +The two flags (VectorCraft, PhotoCraft and the other Craft apps take the same ones, so one client +config works across the suite) keep the agent to the folders you give it: + +- Every file a command reads must lie inside the **read root**: `open_document`, `file.open`, + `file.openIdml`, `place_image` and `file.place` / `place.load` / `media.options` with a `path`, + `links.relink`, `links.relinkFolder`, `color.loadProfile`, `swatch.load`, `library.open`, + `book.open`, `data.source.select`, `data.merge` and `file.importXml` / `xml.loadDtd` with a `path`, + and `file.recovery.list` / `file.recovery.open` with a `dir`. A DesignCraft document names more + files than the agent does: linked images, data sources, book chapters, the `Document Fonts` folder + beside a document, an IDML package's linked images and the files an image cell of a merge names. + Those are read through the same checks, so a path in a document that leads outside the read root + reads as a file that isn't there. +- Every file a command writes must lie inside the **write root**: `save_document`, `export_png`, + `render_page` with a `path`, `file.save`, `file.saveAs`, `file.saveACopy`, `file.exportPdf`, + `file.exportEpub`, `file.exportFixedEpub`, `file.exportHtml`, `file.exportText`, `file.exportXml`, + `file.exportIdml`, `file.printBooklet`, `snippet.export`, `swatch.save`, `library.new` and the + library's saves, `book.new`, `book.add`, `book.paginate`, `book.syncStyles`, `book.exportPdf`, + `file.package`, `links.copyTo` and `file.recovery.save` / `file.recovery.open` with a `dir` + (reopening removes the entries it read). `file.package` and `links.copyTo` check their folder + before they create anything. +- Read and write access are separate. Give the same folder twice for both. A root left out grants + none of its access: with only `--automation-read-root` nothing can be written to disk. Without + either flag nothing changes. +- A path is made absolute against the server's working directory, then its links are followed: a + file (or folder) link that leads out of a root is outside it. For a file that doesn't exist yet, + its nearest existing folder's links are followed and the rest must be plain names, so `..` below a + folder that doesn't exist is refused, as is a link that leads nowhere (writing through it would + create its target). The result is compared with the root by whole folder names: `/work2/x` is not + inside `/work`. On Windows case is ignored, UNC paths (`\\server\share\...`) are resolved like + drive-letter ones, and device names (`CON`, `NUL`, `COM1`...) are refused. +- A refused path is an ordinary tool error the agent can read and correct, for example + `automation path rejected: outside the write root /work/project/out: /etc/x.designcraft` or + `automation filesystem access is not granted: write authority is absent: /work/project/out/x.designcraft`. +- The roots must be existing folders; a missing one stops the server with an error. Each flag is + given once (`--flag <dir>` or `--flag=<dir>`). + +The flags confine the in-process server, so `--connect` together with them is an error: the server +would otherwise talk to an app that isn't confined. DesignCraft's desktop app has no equivalent flags +yet, so a running app (`designcraft --control`) is not confined. `--sample` is the operator's own +choice and is opened before the roots apply. + +What the roots do not cover: commands that take no path still read and change the open document, +and `inspect_document` returns it. The system font scan, the installed fonts that `file.package` +copies, and the app's own recovery folder are fixed paths, not agent input, and are not checked. A +file inside a `Document Fonts` folder is read as a font even when it is a link that leads out of +the read root (it is not returned to the agent, only parsed as a font and skipped when it isn't +one). `file.print` hands a PDF to the system's print spooler, which is not a path the agent names. +Anything that reads or writes a path goes through one place in the engine (the `io` module); a test +keeps the rest of the engine from touching the file system on its own. + +The checks hold for every thread of the server process. A path is checked, then opened: another +program that swaps a link in between can still win that race, so the roots keep an agent to the +files it was given rather than guarding against other software on the computer. + ## Modes | | Headless (`mcp`) | Connected (`mcp --connect PORT` or `HOST:PORT`) | From cded7d997e961e47f2920703059753f43c57c5ac Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Mon, 12 Oct 2026 22:32:56 +0200 Subject: [PATCH 097/238] feat(tools): Drag type-on-a-path start and end brackets Selected type on a path shows its start and end brackets across the path, as in InDesign, and the Selection and Direct Selection tools drag them along it (Move Bracket, one undo step): the text starts and ends where they are, and text past the end bracket is overset. - doc: PathType.end (None: the path's end) - geom: PathWarp::nearest, the arc length of the nearest point - engine: type.pathOptions {end}; brackets stay on the path and can't cross - compose: the line runs from the start to the end bracket - idml: EndBracket read and written - tools: Cursor::PathBracket Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 1 + crates/compose/src/lib.rs | 3 +- crates/doc/src/item.rs | 6 +- crates/engine/src/cmd/path_type.rs | 62 +++++++++++++++++-- crates/geom/src/warp.rs | 21 +++++++ crates/idml/src/export.rs | 2 +- crates/idml/src/import.rs | 5 ++ crates/idml/src/tests.rs | 24 ++++++++ crates/tools/src/lib.rs | 2 + crates/tools/src/select.rs | 95 +++++++++++++++++++++++++++++- crates/tools/src/tests.rs | 48 +++++++++++++++ crates/ui-egui/src/canvas.rs | 1 + 12 files changed, 261 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4b7163c76..a213adda0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-12** · Type on a path has start and end brackets, as in InDesign: selected path type shows them across the path, and dragging either with the Selection or Direct Selection tool moves where the text starts or ends (text past the end bracket is overset). `type.pathOptions {end}`; IDML reads and writes EndBracket. No readiness percentage change. - **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change. - **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 3dd7c25b3..4aa16fd73 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -417,7 +417,8 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec<FrameSpec> { path: None, ..tf.options.clone() }; - (Rect::new(0.0, 0.0, (len - pt.start).max(1.0), size * 1.6), opts) + let end = pt.end.map_or(len, |e| e.clamp(0.0, len)); + (Rect::new(0.0, 0.0, (end - pt.start).max(1.0), size * 1.6), opts) } None => (item.text_area(), tf.options.clone()), }; diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 5cfe05fe2..0a701998e 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -345,8 +345,12 @@ pub struct TextFrameOptions { #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] pub struct PathType { - /// Where the text starts, as a distance along the path. + /// Where the text starts, as a distance along the path (the start bracket). pub start: f64, + /// Where the text ends, as a distance along the path (the end bracket); `None` is the path's + /// end. Text past it is overset. + #[serde(skip_serializing_if = "Option::is_none")] + pub end: Option<f64>, /// Run the other way (the text sits on the other side). pub flip: bool, pub align: PathAlign, diff --git a/crates/engine/src/cmd/path_type.rs b/crates/engine/src/cmd/path_type.rs index 77bbf87ab..c136cb8d1 100644 --- a/crates/engine/src/cmd/path_type.rs +++ b/crates/engine/src/cmd/path_type.rs @@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> { "Type on a Path Options…", ["Type", "Type on a Path"], None, - "{id?, start?, flip?, align?: baseline|ascender|descender|center, delete?: true (Delete Type from Path)}", + "{id?, start?, end?: distance along the path or null (the path's end), flip?, align?: baseline|ascender|descender|center, delete?: true (Delete Type from Path)} — start and end are the brackets: start ≤ end, both on the path", has_doc, path_options ), @@ -86,6 +86,7 @@ fn path_options(s: &mut Session, p: &Value) -> Result<Value> { let p = p.clone(); s.edit(|d, sel| { let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; + let len = designcraft_geom::warp::PathWarp::new(&it.path.to_bezpath(), false).length(); let Content::Text(tf) = &mut it.content else { return Err(bad("type.pathOptions", "not type on a path")) }; let Some(pt) = tf.options.path.as_mut() else { return Err(bad("type.pathOptions", "not type on a path")) }; if delete { @@ -95,8 +96,26 @@ fn path_options(s: &mut Session, p: &Value) -> Result<Value> { sel.text = None; return Ok(json!({"deleted": true})); } - if let Some(v) = p.get("start").and_then(Value::as_f64) { - pt.start = v.max(0.0); + if let Some(v) = p.get("start").and_then(Value::as_f64).filter(|v| v.is_finite()) { + pt.start = v.clamp(0.0, len); + } + match p.get("end") { + Some(Value::Null) => pt.end = None, + Some(v) => { + let e = v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad("type.pathOptions", "end: a distance or null"))?; + pt.end = (e < len - 0.01).then_some(e.max(0.0)); + } + None => {} + } + // The brackets can't cross: the one being moved stops at the other. + if let Some(e) = pt.end + && pt.start > e + { + if p.get("end").is_some() { + pt.end = Some(pt.start); + } else { + pt.start = e; + } } if let Some(v) = p.get("flip").and_then(Value::as_bool) { pt.flip = v; @@ -104,7 +123,7 @@ fn path_options(s: &mut Session, p: &Value) -> Result<Value> { if let Some(a) = align { pt.align = a; } - Ok(json!({"start": pt.start, "flip": pt.flip, "align": pt.align})) + Ok(json!({"start": pt.start, "end": pt.end, "flip": pt.flip, "align": pt.align})) }) } @@ -160,4 +179,39 @@ mod tests { let pdf = designcraft_pdf::export_pdf(&d, &s2.cache, &Default::default()).unwrap(); assert!(pdf.len() > 1000); } + + #[test] + fn the_brackets_set_where_the_text_starts_and_ends() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s.execute("line.create", &json!({"a": [100, 300], "b": [500, 300]})).unwrap()["id"].as_u64().unwrap(); + let r = s.execute("type.onPath", &json!({"id": id, "text": "Sixteen letters!"})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + let overset = |s: &Session| designcraft_compose::compose_story(&s.doc().unwrap().doc, sid, &Default::default()).overset_at.is_some(); + let undo = s.doc().unwrap().history.undo.len(); + assert!(!overset(&s)); + // The end bracket close to the start: the text no longer fits, and is overset. + let r = s.execute("type.pathOptions", &json!({"id": id, "start": 50, "end": 70})).unwrap(); + assert_eq!((r["start"].as_f64(), r["end"].as_f64()), (Some(50.0), Some(70.0))); + assert!(overset(&s)); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + // Brackets can't cross: the start stops at the end, the end at the start. + let r = s.execute("type.pathOptions", &json!({"id": id, "start": 90})).unwrap(); + assert_eq!(r["start"].as_f64(), Some(70.0)); + let r = s.execute("type.pathOptions", &json!({"id": id, "start": 10, "end": 5})).unwrap(); + assert_eq!((r["start"].as_f64(), r["end"].as_f64()), (Some(10.0), Some(10.0))); + // Past the path, or null: the path's end again; the start stays on the path. + let r = s.execute("type.pathOptions", &json!({"id": id, "end": 1000})).unwrap(); + assert!(r["end"].is_null()); + let r = s.execute("type.pathOptions", &json!({"id": id, "end": 200})).unwrap(); + assert_eq!(r["end"].as_f64(), Some(200.0)); + let r = s.execute("type.pathOptions", &json!({"id": id, "end": null, "start": 5000})).unwrap(); + assert_eq!((r["start"].as_f64(), r["end"].is_null()), (Some(400.0), true)); + assert!(s.execute("type.pathOptions", &json!({"id": id, "end": "far"})).is_err()); + // Saved with the document. + let d = s.doc().unwrap().doc.clone(); + let back = crate::cmd::file_from(&crate::cmd::file_bytes(&d)).unwrap(); + let path = |d: &designcraft_doc::Document| d.item(designcraft_doc::ItemId(id)).unwrap().text_frame().unwrap().options.path.clone(); + assert_eq!(path(&back), path(&d)); + } } diff --git a/crates/geom/src/warp.rs b/crates/geom/src/warp.rs index fe6b89078..34041d280 100644 --- a/crates/geom/src/warp.rs +++ b/crates/geom/src/warp.rs @@ -47,6 +47,22 @@ impl PathWarp { None } + /// The arc length (as `at` measures it, so flipped paths count from their end) of the point + /// of the path nearest `p`. + pub fn nearest(&self, p: Point) -> f64 { + use kurbo::ParamCurveNearest; + let mut best = (f64::INFINITY, 0.0); + let mut before = 0.0; + for (seg, len) in &self.segs { + let n = seg.nearest(p, 1e-6); + if n.distance_sq < best.0 { + best = (n.distance_sq, before + seg.subsegment(0.0..n.t).arclen(1e-4)); + } + before += len; + } + if self.flip { self.total - best.1 } else { best.1 } + } + /// Transform for something laid out on a straight line (x along the line, y down from the /// line) whose horizontal centre is `mid`: it lands centred at arc length `mid`, turned with /// the path, its line at `baseline` on the path. @@ -76,5 +92,10 @@ mod tests { assert!((g * Point::new(50.0, 10.0) - Point::new(50.0, 0.0)).hypot() < 1e-9); let flipped = PathWarp::new(&bp, true); assert!((flipped.at(0.0).unwrap().0 - Point::new(100.0, 100.0)).hypot() < 1e-9); + // The nearest point by arc length, off the path and on it; flipped counts from the end. + assert!((w.nearest(Point::new(120.0, 50.0)) - 150.0).abs() < 1e-6); + assert!((w.nearest(Point::new(120.0, 150.0)) - 200.0).abs() < 1e-6, "past the end: the end"); + assert!((w.nearest(Point::new(30.0, -8.0)) - 30.0).abs() < 1e-6); + assert!((flipped.nearest(Point::new(30.0, -8.0)) - 170.0).abs() < 1e-6); } } diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 9d73562b9..b9fa7aaae 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1629,7 +1629,7 @@ impl<'a> Ex<'a> { .attr("TextAlignment", names::path_align_out(pt.align)) .attr("FlipPathEffect", if pt.flip { "Flipped" } else { "NotFlipped" }) .attr("StartBracket", num(pt.start)) - .attr("EndBracket", num(len)), + .attr("EndBracket", num(pt.end.map_or(len, |e| e.min(len)))), ); } else if let Content::Text(t) = &it.content { let (prev, next) = self.threads.get(&it.id.0).copied().unwrap_or((None, None)); diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 46c3253b6..31b321519 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -2181,6 +2181,11 @@ impl<'r> Importer<'r> { let options = TextFrameOptions { path: Some(designcraft_doc::PathType { start: tp.num("StartBracket").unwrap_or(0.0).max(0.0), + // At (or past) the path's length it is the path's end: no end bracket. + end: tp + .num("EndBracket") + .filter(|e| e.is_finite() && *e >= 0.0) + .filter(|e| *e < designcraft_geom::warp::PathWarp::new(&it.path.to_bezpath(), false).length() - 0.01), flip: tp.get("FlipPathEffect") == Some("Flipped"), align: names::path_align_in(tp.get("TextAlignment").unwrap_or("")), }), diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 23ec99bdf..8d27074e9 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -1454,3 +1454,27 @@ fn list_numbering_format_expression_and_bullet_import_and_round_trip() { check(&d); check(&import_idml(&export_idml(&d)).unwrap()); } + +#[test] +fn type_on_a_path_keeps_its_start_and_end_brackets() { + use designcraft_geom::{Anchor, PathData, Point, SubPath}; + for end in [Some(250.0), None] { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let id = designcraft_doc::ItemId(d.alloc()); + let line = PathData::single(SubPath::new(vec![Anchor::corner(Point::new(100.0, 300.0)), Anchor::corner(Point::new(500.0, 300.0))], false)); + let mut it = designcraft_doc::Item::new(id, lid, Shape::GraphicLine, line); + let sid = designcraft_doc::StoryId(d.alloc()); + let mut tf = designcraft_doc::TextFrame { story: sid, options: Default::default() }; + tf.options.path = Some(designcraft_doc::PathType { start: 30.0, end, ..Default::default() }); + it.content = designcraft_doc::Content::Text(tf); + let mut story = designcraft_doc::Story::with_text(sid, "Along", Default::default()); + story.frames = vec![id]; + d.stories.insert(sid, std::sync::Arc::new(story)); + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + let back = import_idml(&export_idml(&d)).unwrap(); + let pt = back.spreads[0].items[0].text_frame().unwrap().options.path.clone().unwrap(); + assert!((pt.start - 30.0).abs() < 1e-6, "{pt:?}"); + assert_eq!(pt.end.map(|e| e.round()), end, "an end bracket at the path's end is no end bracket"); + } +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 247494f84..02b790d55 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -335,6 +335,8 @@ pub enum Cursor { ResizeNeSw, Rotate, Pen, + /// Over a type-on-a-path bracket: dragging it moves where the text starts or ends. + PathBracket, Text, Hand, HandGrab, diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 8f59c3503..fc9a8c316 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -38,6 +38,11 @@ enum Drag { start: Point, cur: Point, }, + /// Dragging a type-on-a-path bracket along its path (`end`: the end bracket). + Bracket { + id: ItemId, + end: bool, + }, Anchor { id: u64, si: usize, @@ -51,6 +56,50 @@ enum Drag { }, } +/// Half the length of a path-type bracket, in screen pixels. +const BRACKET_PX: f64 = 9.0; + +/// A selected type-on-a-path item's path (inner space), its inner → canvas transform and its +/// brackets' arc lengths (start, end). +fn path_type_of(cx: &ToolContext, id: ItemId) -> Option<(designcraft_geom::warp::PathWarp, designcraft_geom::Affine, f64, f64)> { + let it = cx.doc.item(id)?; + let pt = it.text_frame()?.options.path.as_ref()?; + let warp = designcraft_geom::warp::PathWarp::new(&it.path.to_bezpath(), pt.flip); + let end = pt.end.unwrap_or(warp.length()).min(warp.length()); + Some((warp, cx.item_canvas_xf(id)? * it.xf, pt.start, end)) +} + +/// The bracket line at arc length `s`: across the path, `BRACKET_PX` either side (canvas). +fn bracket_line(cx: &ToolContext, warp: &designcraft_geom::warp::PathWarp, xf: designcraft_geom::Affine, s: f64) -> Option<(Point, Point)> { + let (p, a) = warp.at(s)?; + let c = xf * p; + let t = xf * (p + designcraft_geom::Vec2::new(a.cos(), a.sin())) - c; + let n = designcraft_geom::Vec2::new(-t.y, t.x); + let n = n / n.hypot().max(1e-9) * cx.tol(BRACKET_PX); + Some((c - n, c + n)) +} + +/// The bracket of a selected type-on-a-path item under `p` (canvas): (item, whether it is the end +/// bracket). +fn bracket_at(cx: &ToolContext, p: Point) -> Option<(ItemId, bool)> { + let tol = cx.tol(BRACKET_PX); + cx.selection.items.iter().find_map(|id| { + let (warp, xf, start, end) = path_type_of(cx, *id)?; + if cx.doc.item(*id).is_some_and(|it| it.locked) { + return None; + } + let near = |s: f64| warp.at(s).is_some_and(|(q, _)| (xf * q - p).hypot() <= tol); + // The end bracket first: with both brackets at one point, it can still be pulled back. + if near(end) { + Some((*id, true)) + } else if near(start) { + Some((*id, false)) + } else { + None + } + }) +} + /// Anchor or handle of a selected item under `p` (canvas): (item, subpath, anchor, handle). pub fn anchor_at(cx: &ToolContext, p: Point) -> Option<(u64, usize, usize, Option<&'static str>)> { anchor_at_in(cx, &cx.selection.items, p) @@ -378,6 +427,13 @@ impl Tool for SelectionTool { } PointerKind::Down => { self.shift_release = None; + // Type on a path: a bracket moves where the text starts or ends. + if !ev.mods.shift + && let Some((id, end)) = bracket_at(cx, p) + { + self.drag = Drag::Bracket { id, end }; + return vec![Action::Begin("Move Bracket".into())]; + } if self.direct && let Some((id, si, ai, handle)) = anchor_at(cx, p) { @@ -437,6 +493,14 @@ impl Tool for SelectionTool { } } PointerKind::Drag => match self.drag.clone() { + Drag::Bracket { id, end } => { + let Some((warp, xf, _, _)) = path_type_of(cx, id) else { return vec![] }; + if xf.determinant().abs() < 1e-12 { + return vec![]; + } + let s = warp.nearest(xf.inverse() * p); + vec![Action::Preview("type.pathOptions".into(), json!({"id": id.0, if end { "end" } else { "start" }: s}))] + } Drag::Pending { start, hit } => { if (p - start).hypot() < cx.tol(3.0) { return vec![]; @@ -682,7 +746,9 @@ impl Tool for SelectionTool { let release = self.shift_release.take(); let d = std::mem::replace(&mut self.drag, Drag::None); match d { - Drag::Move { .. } | Drag::Resize { .. } | Drag::Anchor { .. } | Drag::Rotate { .. } => vec![Action::Commit], + Drag::Move { .. } | Drag::Resize { .. } | Drag::Anchor { .. } | Drag::Rotate { .. } | Drag::Bracket { .. } => { + vec![Action::Commit] + } Drag::Pending { .. } => match release { Some(id) => vec![Action::Exec("selection.toggle".into(), json!({"id": id}))], None => vec![], @@ -739,15 +805,19 @@ impl Tool for SelectionTool { } } - fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> { + fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> { match &self.drag { Drag::Marquee { start, cur } => vec![Overlay::Marquee(Rect::from_points(*start, *cur))], Drag::Move { .. } | Drag::Resize { .. } | Drag::Rotate { .. } | Drag::Anchor { .. } => self.guides.clone(), + Drag::None | Drag::Bracket { .. } => brackets(cx), _ => vec![], } } fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor { + if matches!(self.drag, Drag::Bracket { .. }) || (matches!(self.drag, Drag::None) && bracket_at(cx, p).is_some()) { + return Cursor::PathBracket; + } if self.direct { return Cursor::ArrowHollow; } @@ -769,6 +839,27 @@ impl Tool for SelectionTool { } } +/// The start and end brackets of the selected type-on-a-path items, in the selection colour: a +/// line across the path with a short tick pointing at the text between them. +fn brackets(cx: &ToolContext) -> Vec<Overlay> { + let mut out = Vec::new(); + for id in &cx.selection.items { + let Some((warp, xf, start, end)) = path_type_of(cx, *id) else { continue }; + let color = cx.doc.item(*id).and_then(|it| cx.doc.layer(it.layer)).map_or([43, 155, 255], |l| l.color); + for (s, towards) in [(start, end.max(start + 1.0)), (end, start.min(end - 1.0))] { + let Some((a, b)) = bracket_line(cx, &warp, xf, s) else { continue }; + out.push(Overlay::Line { a, b, color, dashed: false }); + // The tick at the outer end points along the path towards the text. + if let (Some((p0, _)), Some((p1, _))) = (warp.at(s), warp.at(towards)) { + let dir = xf * p1 - xf * p0; + let dir = dir / dir.hypot().max(1e-9) * cx.tol(BRACKET_PX * 0.6); + out.push(Overlay::Line { a: b, b: b + dir, color, dashed: false }); + } + } + } + out +} + fn handle_cursor(h: usize) -> Cursor { match h { 0 | 4 => Cursor::ResizeNwSe, diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 6c75b0da6..e39f8d4ee 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -705,3 +705,51 @@ fn pen_click_snaps_to_a_guide() { let x = p["anchors"][0]["p"][0].as_f64().unwrap(); assert!((x - 100.0).abs() < 1e-6, "anchor x {x}"); } + +#[test] +fn the_selection_tool_drags_a_path_type_bracket_along_its_path() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let id = ItemId(d.alloc()); + let line = designcraft_geom::PathData::single(designcraft_geom::SubPath::new( + vec![designcraft_geom::Anchor::corner(Point::new(100.0, 300.0)), designcraft_geom::Anchor::corner(Point::new(500.0, 300.0))], + false, + )); + let mut it = Item::new(id, lid, Shape::GraphicLine, line); + let sid = designcraft_doc::StoryId(d.alloc()); + let mut tf = designcraft_doc::TextFrame { story: sid, options: Default::default() }; + tf.options.path = Some(designcraft_doc::PathType { start: 40.0, end: Some(300.0), ..Default::default() }); + it.content = designcraft_doc::Content::Text(tf); + let mut st = designcraft_doc::Story::with_text(sid, "On the line", Default::default()); + st.frames = vec![id]; + d.stories.insert(sid, Arc::new(st)); + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + let (c, l) = (Cache::new(), CanvasLayout::new(&d, false)); + let off = l.xf(SpreadRef::Doc(0)).translation(); + let at = |x: f64, y: f64| Point::new(x + off.x, y + off.y); + let ev = |k: PointerKind, p: Point| PointerEvent::new(k, p.x, p.y); + // Not selected: no brackets. + let none = Selection::default(); + assert!(create("selection").overlays(&ctx(&d, &none, &c, &l)).is_empty()); + let s = Selection { items: vec![id], ..Default::default() }; + let cx = ctx(&d, &s, &c, &l); + let mut t = create("selection"); + // Both brackets drawn across the path at 140 and 400 (a line and a tick each). + let o = t.overlays(&cx); + let xs: Vec<f64> = o.iter().filter_map(|o| if let Overlay::Line { a, b, .. } = o { (a.x == b.x).then_some(a.x - off.x) } else { None }).collect(); + assert_eq!(xs, [140.0, 400.0], "{o:?}"); + assert_eq!(t.cursor(&cx, at(140.0, 300.0), Mods::default()), Cursor::PathBracket); + assert_ne!(t.cursor(&cx, at(250.0, 300.0), Mods::default()), Cursor::PathBracket); + // Drag the start bracket: its distance along the path follows the pointer, off the path too. + assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, at(140.0, 301.0))), vec![Action::Begin("Move Bracket".into())]); + let a = t.pointer(&cx, &ev(PointerKind::Drag, at(220.0, 330.0))); + let [Action::Preview(cmd, p)] = a.as_slice() else { panic!("{a:?}") }; + assert_eq!((cmd.as_str(), p["id"].as_u64()), ("type.pathOptions", Some(id.0))); + assert!((p["start"].as_f64().unwrap() - 120.0).abs() < 1e-6, "{p}"); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, at(220.0, 330.0))), vec![Action::Commit]); + // The end bracket. + t.pointer(&cx, &ev(PointerKind::Down, at(400.0, 300.0))); + let a = t.pointer(&cx, &ev(PointerKind::Drag, at(450.0, 290.0))); + let [Action::Preview(_, p)] = a.as_slice() else { panic!("{a:?}") }; + assert!((p["end"].as_f64().unwrap() - 350.0).abs() < 1e-6, "{p}"); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4c7313e61..2f30386c8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -353,6 +353,7 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon { Cursor::ResizeNeSw => C::ResizeNeSw, Cursor::Rotate => C::Alias, Cursor::Pen => C::Crosshair, + Cursor::PathBracket => C::ResizeColumn, Cursor::Text => C::Text, Cursor::Hand => C::Grab, Cursor::HandGrab => C::Grabbing, From d66f2c47ae1f2227879677be764127c8399e1708 Mon Sep 17 00:00:00 2001 From: Isaac Levine <isaacmlevine4@gmail.com> Date: Mon, 12 Oct 2026 16:50:04 -0400 Subject: [PATCH 098/238] Engine: route book chapters and atomic saves through the checked writers, and take file access out of fonts, IDML and ICC loading Rebased onto the atomic save work: book chapter replacement, canonicalize and the writability probe, and every command write now check the roots before they reach crate::atomic. IDML import no longer reads linked images itself (the engine passes its checked reader), load_icc_file is gone (profiles arrive as bytes), the script loader goes through the host reader, and FontDb::load_document_fonts asks a caller-supplied check about the folder and each font file, so a link that leads out of the read root is skipped. --- crates/color/src/cms/mod.rs | 7 ---- crates/color/src/cms/tests.rs | 6 +-- crates/compose/src/tests.rs | 4 +- crates/engine/src/atomic.rs | 5 --- crates/engine/src/cmd/book.rs | 16 +++----- crates/engine/src/cmd/book/safety_tests.rs | 4 +- crates/engine/src/cmd/file.rs | 10 ++--- crates/engine/src/io.rs | 34 ++++++++++++++++- crates/engine/src/tests_roots.rs | 29 ++++++++++++-- crates/fonts/src/fontdb.rs | 33 +++++++++++----- crates/fonts/src/tests_docfonts.rs | 44 +++++++++++++++++----- crates/idml/src/import.rs | 22 ++++------- crates/ui-egui/src/panels/library.rs | 6 ++- 13 files changed, 142 insertions(+), 78 deletions(-) diff --git a/crates/color/src/cms/mod.rs b/crates/color/src/cms/mod.rs index 2294d764e..ac7c45874 100644 --- a/crates/color/src/cms/mod.rs +++ b/crates/color/src/cms/mod.rs @@ -230,13 +230,6 @@ pub fn register_icc(bytes: &[u8], name: Option<String>) -> Result<ProfileInfo, C Ok(info) } -/// Load a `.icc`/`.icm` file from disk (native only). -#[cfg(not(target_arch = "wasm32"))] -pub fn load_icc_file(path: &std::path::Path) -> Result<ProfileInfo, CmsError> { - let bytes = std::fs::read(path).map_err(|e| CmsError::Io(format!("{}: {e}", path.display())))?; - register_icc(&bytes, None) -} - /// A colour-management context: resolved working spaces plus conversion settings. #[derive(Clone)] pub struct Cms { diff --git a/crates/color/src/cms/tests.rs b/crates/color/src/cms/tests.rs index 4e4f1d8b7..be6676d7b 100644 --- a/crates/color/src/cms/tests.rs +++ b/crates/color/src/cms/tests.rs @@ -188,10 +188,8 @@ fn bad_icc_rejected() { #[test] fn loads_system_cmyk_icc() { let path = std::path::Path::new("/System/Library/ColorSync/Profiles/Generic CMYK Profile.icc"); - if !path.exists() { - return; - } - let info = load_icc_file(path).unwrap(); + let Ok(bytes) = std::fs::read(path) else { return }; + let info = register_icc(&bytes, None).unwrap(); assert_eq!(info.kind, ProfileKind::Cmyk); assert!(profiles().iter().any(|p| p.name == info.name && !p.builtin)); let c = Cms::new(&ColorSettings { cmyk: info.name.clone(), ..Default::default() }).unwrap(); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index f5d0113c1..c275fef91 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1889,7 +1889,7 @@ fn each_document_shapes_words_in_its_own_fonts() { let folder = root.join(name); std::fs::create_dir_all(&folder).unwrap(); std::fs::write(folder.join("word.ttf"), font_with_glyph(FAMILY, &['a', 'b', ' '], glyph).unwrap()).unwrap(); - scopes.push((designcraft_fonts::FontDb::global().load_document_fonts(&folder).scope, glyph)); + scopes.push((designcraft_fonts::FontDb::global().load_document_fonts(&folder, &|_| Ok(())).scope, glyph)); } let text = "ab ab ab ab"; let mut faces = Vec::new(); @@ -1919,7 +1919,7 @@ fn composite_fonts_draw_from_the_documents_fonts() { let folder = std::env::temp_dir().join(format!("dc-compose-composite-docfont-{}", std::process::id())); std::fs::create_dir_all(&folder).unwrap(); std::fs::write(folder.join("digits.ttf"), font_with_glyph(FAMILY, &['1'], 'X').unwrap()).unwrap(); - let scope = FontDb::global().load_document_fonts(&folder).scope; + let scope = FontDb::global().load_document_fonts(&folder, &|_| Ok(())).scope; let (mut d, sid, _) = doc_with("A1B", Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs::default()); d.font_scope = scope; std::sync::Arc::make_mut(&mut d.styles).composite_fonts.push(CompositeFont { diff --git a/crates/engine/src/atomic.rs b/crates/engine/src/atomic.rs index 7eed2f028..96eb2f1f5 100644 --- a/crates/engine/src/atomic.rs +++ b/crates/engine/src/atomic.rs @@ -36,11 +36,6 @@ pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> { write_atomic_with(path, |f| f.write_all(bytes)) } -/// [`write_atomic`] for a command's `path` parameter: the error names the path. -pub(crate) fn write_file(path: &str, bytes: &[u8]) -> crate::Result<()> { - write_atomic(Path::new(path), bytes).map_err(|e| crate::EngineError::Other(format!("{path}: {e}"))) -} - /// Atomically replace (or create) `path` with what `fill` writes into the temporary file. When /// `fill`, the flush or the rename fails, the temporary file is removed, `path` is untouched and /// the error is returned. diff --git a/crates/engine/src/cmd/book.rs b/crates/engine/src/cmd/book.rs index 58edc6aa4..e278440e3 100644 --- a/crates/engine/src/cmd/book.rs +++ b/crates/engine/src/cmd/book.rs @@ -90,7 +90,7 @@ impl ChapterBatch { } fn commit(self) -> Result<()> { - self.commit_with(write_staged_chapter) + self.commit_with(|path, staged| crate::io::replace_with(path, staged)) } fn commit_with(self, mut write: impl FnMut(&std::path::Path, &std::path::Path) -> std::io::Result<()>) -> Result<()> { @@ -110,12 +110,6 @@ impl ChapterBatch { } } -#[cfg(not(target_arch = "wasm32"))] -fn write_staged_chapter(path: &std::path::Path, staged: &std::path::Path) -> std::io::Result<()> { - let mut input = std::fs::File::open(staged)?; - crate::atomic::write_atomic_with(path, |file| std::io::copy(&mut input, file).map(|_| ())) -} - /// Batch commands only write closed native chapters. IDML is an imported source, and an open /// chapter's model (including unsaved edits) cannot be reconciled by a disk-only book update. #[cfg(not(target_arch = "wasm32"))] @@ -125,16 +119,16 @@ fn writable_chapter(s: &Session, path: &str, command: &'static str) -> Result<() if Path::new(path).extension().and_then(|ext| ext.to_str()).is_some_and(|ext| ext.eq_ignore_ascii_case("idml")) { return Err(bad(command, format!("cannot change imported IDML chapter {path}; convert it to a native DesignCraft document first"))); } - let target = std::fs::canonicalize(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; - if s.docs.iter().any(|doc| doc.path.as_ref().is_some_and(|open| std::fs::canonicalize(open).is_ok_and(|open| open == target))) { + let target = crate::io::canonicalize(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + if s.docs.iter().any(|doc| doc.path.as_ref().is_some_and(|open| crate::io::canonicalize(open).is_ok_and(|open| open == target))) { return Err(bad(command, format!("chapter {path} is open; close it before updating the book"))); } - if !std::fs::metadata(&target).is_ok_and(|metadata| metadata.is_file()) { + if !crate::io::is_file(&target) { return Err(bad(command, format!("chapter {path} is not a regular file"))); } // Check both the file and its resolved parent without truncating it. Replacement needs a // writable directory even when an in-place write would be permitted. - std::fs::OpenOptions::new().write(true).open(&target).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + crate::io::writable(&target).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; let parent = target.parent().ok_or_else(|| bad(command, format!("chapter {path} has no parent folder")))?; tempfile::Builder::new().prefix(".designcraft-book-check-").tempfile_in(parent).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; Ok(()) diff --git a/crates/engine/src/cmd/book/safety_tests.rs b/crates/engine/src/cmd/book/safety_tests.rs index e4ec20efd..e8fe37cc2 100644 --- a/crates/engine/src/cmd/book/safety_tests.rs +++ b/crates/engine/src/cmd/book/safety_tests.rs @@ -1,4 +1,4 @@ -use super::{ChapterBatch, MAX_BATCH_CHAPTERS, write_staged_chapter}; +use super::{ChapterBatch, MAX_BATCH_CHAPTERS}; use crate::Session; use serde_json::json; @@ -26,7 +26,7 @@ fn a_commit_failure_reports_the_completed_prefix_and_cleans_staging() { let error = batch .commit_with(|path, staged| { calls += 1; - if path == second { Err(std::io::Error::other("synthetic disk full")) } else { write_staged_chapter(path, staged) } + if path == second { Err(std::io::Error::other("synthetic disk full")) } else { crate::io::replace_with(path, staged) } }) .unwrap_err() .to_string(); diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 587855415..64a73ab30 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> { }), cmd!(noundo "file.recovery.save", "Save Recovery Data", [], None, "{dir?} — write every unsaved document to the recovery folder (the app does this on a timer)", always, |s, p| { let dir = recovery_dir(s, p, FolderAccess::Write)?; - Ok(json!({"saved": crate::recovery::save(s, &dir)?})) + Ok(json!({"saved": crate::recovery::save_tracked(s, &dir)?})) }), cmd!(noundo "file.recovery.open", "Recover Documents", [], None, "{dir?} — reopen documents left in the recovery folder (unsaved)", always, |s, p| { // Reopening removes the entries it read. @@ -191,11 +191,9 @@ pub(crate) fn load_document_fonts(d: &mut Document, path: &str) -> (Option<Arc<c use designcraft_fonts::DOCUMENT_FONTS_FOLDER; let Some(folder) = std::path::Path::new(path).parent().map(document_fonts_folder) else { return (None, 0, Vec::new()) }; // A document's folder is the agent's to name (a recovery entry or a book lists paths too): - // fonts outside the automation read root stay unloaded. - if let Err(e) = crate::file_access::check_read(&folder) { - return (None, 0, vec![format!("{DOCUMENT_FONTS_FOLDER}: {e}")]); - } - let r = designcraft_fonts::FontDb::global().load_document_fonts(&folder); + // the fonts crate asks the automation roots about the folder and each font file, so fonts + // outside the read root stay unloaded. + let r = designcraft_fonts::FontDb::global().load_document_fonts(&folder, &|p| crate::file_access::check_read(p)); d.font_scope = r.scope; let scope = (r.scope != 0).then(|| Arc::new(crate::FontScope(r.scope))); (scope, r.faces, r.skipped.into_iter().map(|s| format!("{DOCUMENT_FONTS_FOLDER}: {s}")).collect()) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 4fa214750..7bc7d533c 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -37,7 +37,39 @@ pub fn read_to_string(path: impl AsRef<Path>) -> Result<String, String> { pub fn write_file(path: impl AsRef<Path>, bytes: &[u8]) -> Result<(), String> { let path = path.as_ref(); crate::file_access::check_write(path)?; - std::fs::write(path, bytes).map_err(|e| format!("{}: {e}", path.display())) + // Replaced atomically where there is a real file system (see `atomic`). + #[cfg(not(target_arch = "wasm32"))] + let written = crate::atomic::write_atomic(path, bytes); + #[cfg(target_arch = "wasm32")] + let written = std::fs::write(path, bytes); + written.map_err(|e| format!("{}: {e}", path.display())) +} + +/// Atomically replace the file at `path` with the contents of `staged`, a private temporary file +/// the engine made itself (so it is not checked), inside the automation write root when there is +/// one. +#[cfg(not(target_arch = "wasm32"))] +pub fn replace_with(path: impl AsRef<Path>, staged: impl AsRef<Path>) -> std::io::Result<()> { + let path = path.as_ref(); + crate::file_access::check_write(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?; + let mut input = std::fs::File::open(staged)?; + crate::atomic::write_atomic_with(path, |file| std::io::copy(&mut input, file).map(|_| ())) +} + +/// Could the file at `path` be opened for writing (without truncating it)? Inside the automation +/// write root when there is one. +pub fn writable(path: impl AsRef<Path>) -> std::io::Result<()> { + let path = path.as_ref(); + crate::file_access::check_write(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?; + std::fs::OpenOptions::new().write(true).open(path).map(drop) +} + +/// The canonical form of `path` (links resolved), inside the automation read root when there is +/// one. +pub fn canonicalize(path: impl AsRef<Path>) -> std::io::Result<std::path::PathBuf> { + let path = path.as_ref(); + crate::file_access::check_read(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?; + std::fs::canonicalize(path) } /// What the file system says about `path` (links followed), inside the automation read root when diff --git a/crates/engine/src/tests_roots.rs b/crates/engine/src/tests_roots.rs index 443403b0a..04711bba9 100644 --- a/crates/engine/src/tests_roots.rs +++ b/crates/engine/src/tests_roots.rs @@ -129,11 +129,15 @@ fn a_data_source_path_stored_in_a_document_is_read_through_the_roots() { fn a_book_writes_its_chapters_only_inside_the_write_root() { let (base, roots) = folders("book"); let chapter = base.join("in/a.designcraft"); - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("file.save", &json!({"path": p(&chapter)})).unwrap(); let second = base.join("in/b.designcraft"); - s.execute("file.saveAs", &json!({"path": p(&second)})).unwrap(); + { + // Made by another session: a book only rewrites chapters that are not open. + let mut maker = Session::new(); + maker.execute("file.new", &json!({})).unwrap(); + maker.execute("file.save", &json!({"path": p(&chapter)})).unwrap(); + maker.execute("file.saveAs", &json!({"path": p(&second)})).unwrap(); + } + let mut s = Session::new(); let before = (std::fs::read(&chapter).unwrap(), std::fs::read(&second).unwrap()); confine(Some(&roots), || { s.execute("book.new", &json!({"path": p(&base.join("out/b.dcbook"))})).unwrap(); @@ -210,3 +214,20 @@ fn fonts_beside_a_document_outside_the_read_root_are_not_loaded() { assert!(warnings.iter().any(|w| w.contains("x.ttf")), "without roots the folder is read and the bad font reported: {warnings:?}"); let _ = std::fs::remove_dir_all(base); } + +#[cfg(unix)] +#[test] +fn a_font_file_that_links_out_of_the_read_root_is_skipped() { + let (base, roots) = folders("fontlink"); + std::fs::create_dir_all(base.join("in/Document Fonts")).unwrap(); + std::fs::write(base.join("outside/secret.ttf"), b"not a font").unwrap(); + std::os::unix::fs::symlink(base.join("outside/secret.ttf"), base.join("in/Document Fonts/link.ttf")).unwrap(); + let mut d = designcraft_doc::Document::new(&designcraft_doc::build::NewDocument::default()); + let doc = p(&base.join("in/doc.designcraft")); + let (_, _, warnings) = confine(Some(&roots), || cmd::load_document_fonts(&mut d, &doc)); + assert!(warnings.iter().any(|w| w.contains("outside the read root")), "{warnings:?}"); + assert!(!warnings.iter().any(|w| w.contains("not a font")), "the file was opened: {warnings:?}"); + let (_, _, warnings) = cmd::load_document_fonts(&mut d, &doc); + assert!(warnings.iter().any(|w| w.contains("link.ttf")), "without roots the link is followed: {warnings:?}"); + let _ = std::fs::remove_dir_all(base); +} diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 14441e144..44a64bddc 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -963,18 +963,33 @@ impl FontDb { /// [`MAX_DOCUMENT_FONT_FILES`] (in name order), files over [`MAX_DOCUMENT_FONT_BYTES`] or past /// [`MAX_DOCUMENT_FONTS_TOTAL`] in all, and files that aren't fonts are skipped with a note. A /// missing folder loads nothing (scope 0). + /// + /// The folder is named by a document, so this crate does not trust it: `allow` (`Ok`, or why a + /// path may not be read) is asked about + /// the folder and about every font file in it before either is opened, and a path it refuses + /// is skipped with its message (the engine passes its automation-root check; a host with no + /// roots passes `&|_| Ok(())`). #[cfg(not(target_arch = "wasm32"))] - pub fn load_document_fonts(&self, folder: &std::path::Path) -> DocumentFonts { + pub fn load_document_fonts(&self, folder: &std::path::Path, allow: &dyn Fn(&std::path::Path) -> Result<(), String>) -> DocumentFonts { let mut out = DocumentFonts::default(); + if let Err(why) = allow(folder) { + out.skipped.push(why); + return out; + } let Ok(entries) = std::fs::read_dir(folder) else { return out }; - let mut files: Vec<std::path::PathBuf> = entries - .flatten() - .map(|e| e.path()) - .filter(|p| { - let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()); - matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) && p.is_file() - }) - .collect(); + let mut files: Vec<std::path::PathBuf> = Vec::new(); + for path in entries.flatten().map(|e| e.path()) { + let ext = path.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()); + if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) { + continue; + } + // Each file is vetted before it is touched at all, a link out of the folder included. + match allow(&path) { + Ok(()) if path.is_file() => files.push(path), + Ok(()) => {} + Err(why) => out.skipped.push(why), + } + } files.sort(); let mut total = 0u64; let mut own: Vec<Arc<FontFace>> = Vec::new(); diff --git a/crates/fonts/src/tests_docfonts.rs b/crates/fonts/src/tests_docfonts.rs index 525bc50b8..f2f73bca7 100644 --- a/crates/fonts/src/tests_docfonts.rs +++ b/crates/fonts/src/tests_docfonts.rs @@ -36,7 +36,7 @@ fn document_fonts_load_ahead_of_installed_ones() { let installed = db.face("DocFont Precedence", "Regular"); assert!(installed.covers('a') && matches!(installed.source, FontSource::Installed(_)), "{:?}", installed.source); - let r = db.load_document_fonts(&folder); + let r = db.load_document_fonts(&folder, &|_| Ok(())); assert_eq!((r.faces, r.skipped.len()), (1, 0), "{:?}", r.skipped); let face = db.scoped(r.scope).face("DocFont Precedence", "Regular"); assert!(face.covers('b'), "the document's face wins"); @@ -45,7 +45,7 @@ fn document_fonts_load_ahead_of_installed_ones() { assert!(db.face("DocFont Precedence", "Regular").covers('a'), "outside the document the installed face stays"); // Loading the folder again (another document beside it) shares the face, in a scope of its own. - let again = db.load_document_fonts(&folder); + let again = db.load_document_fonts(&folder, &|_| Ok(())); assert_eq!((again.faces, again.skipped.len()), (1, 0)); assert_ne!(again.scope, r.scope); assert!(Arc::ptr_eq(&db.scoped(again.scope).face("DocFont Precedence", "Regular"), &face)); @@ -64,7 +64,7 @@ fn damaged_oversized_and_other_files_are_skipped() { write_font(&folder.join("nested.ttf"), "inner.ttf", "DocFont Nested", &['n']); let db = FontDb::with_font_dirs(vec![]); - let r = db.load_document_fonts(&folder); + let r = db.load_document_fonts(&folder, &|_| Ok(())); assert_eq!(r.faces, 1); assert!(db.scoped(r.scope).has_family("DocFont Good")); assert!(!db.has_family("DocFont Good"), "the document's alone"); @@ -85,7 +85,7 @@ fn the_number_of_files_read_is_capped() { write_font(&folder, "zz-real.ttf", "DocFont Past Cap", &['z']); let db = FontDb::with_font_dirs(vec![]); - let r = db.load_document_fonts(&folder); + let r = db.load_document_fonts(&folder, &|_| Ok(())); assert_eq!((r.faces, r.scope), (0, 0)); assert!(!db.scoped(r.scope).has_family("DocFont Past Cap")); assert_eq!(r.skipped.len(), MAX_DOCUMENT_FONT_FILES + 1, "one note for each file read, one for the rest"); @@ -98,7 +98,7 @@ fn large_cjk_collections_are_within_the_limit() { // macOS's Songti collection is 67 MB; full Noto/Source Han CJK collections are larger. let folder = temp_dir("cjk-size"); std::fs::File::create(folder.join("cjk.ttc")).unwrap().set_len(200 << 20).unwrap(); - let r = FontDb::with_font_dirs(vec![]).load_document_fonts(&folder); + let r = FontDb::with_font_dirs(vec![]).load_document_fonts(&folder, &|_| Ok(())); assert_eq!(r.skipped.len(), 1, "{:?}", r.skipped); assert!(!r.skipped[0].contains("larger than"), "skipped for not being a font, not for its size: {:?}", r.skipped); let _ = std::fs::remove_dir_all(&folder); @@ -107,7 +107,7 @@ fn large_cjk_collections_are_within_the_limit() { #[test] fn a_missing_folder_loads_nothing() { let db = FontDb::with_font_dirs(vec![]); - let r = db.load_document_fonts(&std::env::temp_dir().join("dc-docfonts-no-such-folder").join(DOCUMENT_FONTS_FOLDER)); + let r = db.load_document_fonts(&std::env::temp_dir().join("dc-docfonts-no-such-folder").join(DOCUMENT_FONTS_FOLDER), &|_| Ok(())); assert_eq!((r.faces, r.scope, r.skipped.len()), (0, 0, 0)); } @@ -118,7 +118,7 @@ fn each_scope_sees_its_own_fonts_only() { write_font(&a, "a.ttf", FAMILY, &['a']); write_font(&b, "b.ttf", FAMILY, &['b', '\u{E123}']); let db = FontDb::with_font_dirs(vec![]); - let (ra, rb) = (db.load_document_fonts(&a), db.load_document_fonts(&b)); + let (ra, rb) = (db.load_document_fonts(&a, &|_| Ok(())), db.load_document_fonts(&b, &|_| Ok(()))); let (in_a, in_b) = (db.scoped(ra.scope), db.scoped(rb.scope)); assert!(in_a.face(FAMILY, "Regular").covers('a') && in_b.face(FAMILY, "Regular").covers('b')); @@ -139,16 +139,16 @@ fn a_closed_scope_finds_nothing_and_reopening_reuses_the_faces() { let folder = temp_dir("reopen"); let file = write_font(&folder, "r.ttf", FAMILY, &['r']); let db = FontDb::with_font_dirs(vec![]); - let r = db.load_document_fonts(&folder); + let r = db.load_document_fonts(&folder, &|_| Ok(())); let face = db.scoped(r.scope).face(FAMILY, "Regular"); db.close_scope(r.scope); assert!(!db.scoped(r.scope).has_family(FAMILY), "closed"); - let again = db.load_document_fonts(&folder); + let again = db.load_document_fonts(&folder, &|_| Ok(())); assert!(Arc::ptr_eq(&db.scoped(again.scope).face(FAMILY, "Regular"), &face), "an unchanged file isn't read again"); // A file changed since is read again. std::fs::write(&file, font_with(FAMILY, &['r', 's']).unwrap()).unwrap(); - let changed = db.load_document_fonts(&folder); + let changed = db.load_document_fonts(&folder, &|_| Ok(())); let new_face = db.scoped(changed.scope).face(FAMILY, "Regular"); assert!(new_face.covers('s') && new_face.id() != face.id()); assert!(!db.scoped(again.scope).face(FAMILY, "Regular").covers('s'), "the document open before keeps what it opened with"); @@ -166,3 +166,27 @@ fn restricted_licence_reads_the_embedding_bits() { assert!(!db.face(FALLBACK_FAMILY, "Regular").restricted_licence(), "the bundled fonts are OFL"); assert_eq!(db.face(FALLBACK_FAMILY, "Regular").source, FontSource::Bundled); } + +#[test] +fn the_access_check_vets_the_folder_and_every_file_before_it_is_opened() { + let folder = temp_dir("access"); + write_font(&folder, "kept.ttf", "DocFont Kept", &['k']); + write_font(&folder, "refused.ttf", "DocFont Refused", &['r']); + let db = FontDb::with_font_dirs(vec![]); + let asked = std::sync::Mutex::new(Vec::new()); + let allow = |p: &Path| { + asked.lock().unwrap().push(p.to_path_buf()); + if p.ends_with("refused.ttf") { Err("refused.ttf: outside the read root".to_string()) } else { Ok(()) } + }; + let r = db.load_document_fonts(&folder, &allow); + assert_eq!(r.faces, 1, "{:?}", r.skipped); + assert_eq!(r.skipped, vec!["refused.ttf: outside the read root".to_string()]); + let asked = asked.into_inner().unwrap(); + assert_eq!(asked.first().map(PathBuf::as_path), Some(folder.as_path()), "the folder is asked about first"); + assert!(asked.iter().any(|p| p.ends_with("kept.ttf")) && asked.iter().any(|p| p.ends_with("refused.ttf"))); + + // A refused folder is not even listed. + let r = db.load_document_fonts(&folder, &|_| Err("no".to_string())); + assert_eq!((r.faces, r.scope, r.skipped), (0, 0, vec!["no".to_string()])); + let _ = std::fs::remove_dir_all(&folder); +} diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 46c3253b6..41c79f0f7 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -29,21 +29,11 @@ fn uniform_cell_stroke(edges: &[designcraft_doc::CellStrokeAttrs; 4]) -> Option< Some(designcraft_doc::CellStroke { weight: first.weight?, color: first.color.clone()?, tint: first.tint?, kind: first.kind.clone()? }) } -/// Import an IDML package. Linked images are read from disk when available (not on wasm). +/// Import an IDML package without reading its linked images from disk: this crate never touches +/// the file system, so a caller that wants the links brings its own reader +/// ([`import_idml_with`]). The engine passes one that checks the automation roots. pub fn import_idml(bytes: &[u8]) -> Result<Document> { - import_idml_with(bytes, &default_reader) -} - -fn default_reader(path: &str) -> Option<Vec<u8>> { - #[cfg(not(target_arch = "wasm32"))] - { - std::fs::read(path).ok() - } - #[cfg(target_arch = "wasm32")] - { - let _ = path; - None - } + import_idml_with(bytes, &|_| None) } /// Import with a custom reader for linked files (`path` → bytes). @@ -68,7 +58,9 @@ pub fn import_idml_report(bytes: &[u8], read_link: &dyn Fn(&str) -> Option<Vec<u let mut lenient: HashMap<String, String> = HashMap::new(); for i in 0..zip.len() { let mut f = zip.by_index(i).map_err(|e| IdmlError::NotIdml(e.to_string()))?; - if f.is_dir() { + // A folder entry of the archive (the zip crate's own `is_dir`; spelled out so no file-system + // probe appears in this crate). + if f.name().ends_with('/') { continue; } let name = f.name().to_string(); diff --git a/crates/ui-egui/src/panels/library.rs b/crates/ui-egui/src/panels/library.rs index ccaa595dd..c64a61b26 100644 --- a/crates/ui-egui/src/panels/library.rs +++ b/crates/ui-egui/src/panels/library.rs @@ -195,8 +195,10 @@ pub fn scripts(app: &mut DesignApp, ui: &mut egui::Ui) { if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Load…"))).clicked() && let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("script")) { - #[cfg(not(target_arch = "wasm32"))] - match std::fs::read_to_string(&path) { + // Through the host's reader, like every other file the UI opens: this crate has no + // file system of its own. + let read = app.services.read.as_mut().map_or_else(|| Err("no file access".to_string()), |r| r(&path)); + match read.and_then(|bytes| String::from_utf8(bytes).map_err(|e| e.to_string())) { Ok(text) => { let name = std::path::Path::new(&path).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Script".into()); app.ui.scripts.push((name, text)); From 5473f42de4c0d017f069b8d4d44ae1007e4a11d9 Mon Sep 17 00:00:00 2001 From: Isaac Levine <isaacmlevine4@gmail.com> Date: Mon, 12 Oct 2026 16:50:04 -0400 Subject: [PATCH 099/238] Engine: guard test flags bare fs calls and Path probes, and scans every crate that reads paths The scan now reads crates/*/src instead of only the engine, and flags fs:: after use std::fs or use std::{fs, ...}, std::fs, File::open/create, OpenOptions, direct atomic writes and the Path probes (metadata, symlink_metadata, canonicalize, read_dir, try_exists, exists, is_file, is_dir, read_link). The files it leaves out are listed with the reason for each, and a second test fails when an entry no longer needs its exception. --- crates/engine/src/file_access.rs | 76 -------- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_file_guard.rs | 250 ++++++++++++++++++++++++++ docs/mcp.md | 13 +- 4 files changed, 260 insertions(+), 81 deletions(-) create mode 100644 crates/engine/src/tests_file_guard.rs diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index 03ffd7c94..4f9a17c6f 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -436,80 +436,4 @@ mod tests { assert!(!is_device_name(f), "{f}"); } } - - /// Every file a command reads or writes by path goes through `io` (`read_file`, - /// `write_file`, `metadata`, `read_dir`...), which check the roots. Engine code that reaches - /// for `std::fs` itself would bypass them, so none may: every file under `src/` (commands, - /// `lib.rs`, `guard.rs`...) except - /// - /// - `io.rs` and this module, the checked gateway, - /// - `recovery.rs`, which keeps the unsaved documents in a folder the host application - /// chooses (a command's own `dir` is checked in `cmd/file.rs` before it gets there), - /// - `cmd/package.rs`, whose `OutputRoot` checks the folder against the write root before it - /// creates anything and then only writes inside it. Its reads are of the font files that - /// draw the document's text (installed fonts and the `Document Fonts` folder), never of a - /// path a command names, - /// - the test files. - #[test] - fn commands_do_not_touch_the_file_system_themselves() { - fn walk(dir: &Path, files: &mut Vec<PathBuf>) { - for entry in std::fs::read_dir(dir).unwrap() { - let path = entry.unwrap().path(); - if path.is_dir() { - walk(&path, files); - } else if path.extension().is_some_and(|e| e == "rs") { - files.push(path); - } - } - } - /// The file's lines but its test modules (which build their own fixtures): a module is - /// skipped from its `#[cfg(test)]` to the closing brace at the same indent. - fn code_outside_tests(text: &str) -> Vec<(usize, &str)> { - let lines: Vec<&str> = text.lines().collect(); - let mut code = vec![]; - let mut i = 0; - while i < lines.len() { - let attr = lines[i].trim(); - let module = lines.get(i + 1).map(|l| l.trim_start().trim_start_matches("pub ")); - if attr.starts_with("#[cfg(") && attr.contains("test") && module.is_some_and(|m| m.starts_with("mod ")) { - let header = lines[i + 1]; - i += 2; - if header.trim_end().ends_with('{') { - let indent = &header[..header.len() - header.trim_start().len()]; - let close = format!("{indent}}}"); - while i < lines.len() && lines[i].trim_end() != close { - i += 1; - } - i += 1; - } - continue; - } - code.push((i + 1, lines[i])); - i += 1; - } - code - } - let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); - let mut files = vec![]; - walk(&src, &mut files); - assert!(files.iter().any(|f| f.ends_with("lib.rs")) && files.iter().any(|f| f.ends_with("cmd/file.rs")), "{files:?}"); - let mut offenders = vec![]; - for path in files { - let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); - let rel = path.strip_prefix(&src).unwrap_or(&path).to_string_lossy().replace('\\', "/"); - if matches!(rel.as_str(), "io.rs" | "file_access.rs" | "recovery.rs" | "cmd/package.rs") || name.starts_with("tests") { - continue; - } - let text = std::fs::read_to_string(&path).unwrap(); - for (n, line) in code_outside_tests(&text) { - let line = line.trim_start(); - let touches = - ["std::fs", "File::open", "File::create", "OpenOptions", ".is_file()", ".is_dir()", ".exists()"].iter().any(|w| line.contains(w)); - if !line.starts_with("//") && touches { - offenders.push(format!("{}:{}: {line}", path.display(), n + 1)); - } - } - } - assert!(offenders.is_empty(), "use crate::io (read_file, write_file, metadata, read_dir...): {offenders:#?}"); - } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 1f06cbade..e42ebced3 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -558,6 +558,8 @@ pub(crate) use push_undo as record_undo; #[cfg(test)] mod tests; #[cfg(test)] +mod tests_file_guard; +#[cfg(test)] mod tests_idml; #[cfg(test)] mod tests_roots; diff --git a/crates/engine/src/tests_file_guard.rs b/crates/engine/src/tests_file_guard.rs new file mode 100644 index 000000000..21c8f301d --- /dev/null +++ b/crates/engine/src/tests_file_guard.rs @@ -0,0 +1,250 @@ +//! The guarantee behind `--automation-read-root` and `--automation-write-root`: every file a +//! command reads or writes by path goes through a checked reader, in every crate a command can +//! reach, not only this one. +//! +//! In the engine that reader is the [`crate::io`] module. The lower crates cannot depend on the +//! engine (`cargo xtask layers`), so they either have no file access at all or take a reader or +//! an access check from the caller (`designcraft_idml::import_idml_with`, +//! `designcraft_fonts::FontDb::load_document_fonts`). +//! +//! The scan reads the source of every crate under `crates/` and flags what touches the file +//! system, however it is imported or spelled: `std::fs`, a bare `fs::` after `use std::fs` or +//! `use std::{fs, ...}`, `File::open`/`File::create`, `OpenOptions`, and the `Path` probes +//! (`.metadata(`, `.exists(`, `.canonicalize(`, `.read_dir(`...), as methods or as +//! `Path::exists`. The host applications under `apps/` are out of scope: they run with the +//! operator's own authority, and the roots are applied inside the process they start. + +use std::path::{Path, PathBuf}; + +/// Files that may touch the file system, with why. Paths are relative to `crates/`. A file not +/// listed here, and not a test file or test module, must not. +const ALLOWED: &[(&str, &str)] = &[ + ("engine/src/io.rs", "the checked gateway: every read and write a command makes"), + ("engine/src/file_access.rs", "the root check itself (resolves links with the file system)"), + ("engine/src/atomic.rs", "how a write reaches the disk; commands call it only through io::write_file and io::replace_with"), + ( + "engine/src/recovery.rs", + "the unsaved documents live in a folder the host application chooses (a command's own `dir` is checked in cmd/file.rs before it gets here)", + ), + ( + "engine/src/cmd/package.rs", + "`OutputRoot` checks the folder against the write root before it creates anything and then only writes inside it. Its reads are of the font files that draw the document's text (installed fonts and the `Document Fonts` folder), never of a path a command names", + ), + ( + "fonts/src/fontdb.rs", + "the installed-font scan reads the platform's fixed font folders (`system_font_dirs`: no command parameter or document field names one), and `load_document_fonts` asks the caller's `allow` check about its folder and each font file before opening either", + ), + ( + "mcp/src/tools.rs", + "`screenshot` reads back the PNG the connected app just wrote. Connected mode cannot be combined with the roots (the CLI refuses the pair), and the app's own engine checks the path it writes", + ), +]; + +fn rust_files(dir: &Path, files: &mut Vec<PathBuf>) { + let Ok(entries) = std::fs::read_dir(dir) else { return }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + rust_files(&path, files); + } else if path.extension().is_some_and(|e| e == "rs") { + files.push(path); + } + } +} + +/// Test files (`tests.rs`, `tests_*.rs`, `*_tests.rs`, anything under a `tests/` folder) build +/// their own fixtures on disk. +fn is_test_file(rel: &str) -> bool { + let name = rel.rsplit('/').next().unwrap_or(rel); + name.starts_with("tests") || name.ends_with("_tests.rs") || name == "testing.rs" || rel.split('/').any(|part| part == "tests") +} + +/// The file's lines but its test modules: a module is skipped from its `#[cfg(test)]` (or +/// `#[cfg(all(test, ...))]`) to the closing brace at the same indent. A `mod x;` declaration +/// has no body to skip. +fn code_outside_tests(text: &str) -> Vec<(usize, &str)> { + let lines: Vec<&str> = text.lines().collect(); + let mut code = vec![]; + let mut i = 0; + while i < lines.len() { + let attr = lines[i].trim(); + let module = lines.get(i + 1).map(|l| l.trim_start().trim_start_matches("pub ")); + if attr.starts_with("#[cfg(") && attr.contains("test") && module.is_some_and(|m| m.starts_with("mod ")) { + let header = lines[i + 1]; + i += 2; + if header.trim_end().ends_with('{') { + let indent = &header[..header.len() - header.trim_start().len()]; + let close = format!("{indent}}}"); + while i < lines.len() && lines[i].trim_end() != close { + i += 1; + } + i += 1; + } + continue; + } + code.push((i + 1, lines[i])); + i += 1; + } + code +} + +/// `line` without its `//` comment (a `//` inside a string or char is rare enough in this code +/// to leave alone: it only hides the rest of that line from the scan). +fn without_comment(line: &str) -> &str { + line.find("//").map_or(line, |at| &line[..at]) +} + +fn is_ident(c: char) -> bool { + c.is_alphanumeric() || c == '_' +} + +/// Does `line` contain `word` not glued to a longer identifier on its left (`std::fs::` and +/// ` fs::` match `fs::`; `ofs::` does not)? +fn has_word(line: &str, word: &str) -> bool { + line.match_indices(word).any(|(at, _)| !line[..at].chars().next_back().is_some_and(is_ident)) +} + +/// What in `line` reaches the file system, or `None`. +fn file_system_use(line: &str) -> Option<String> { + let line = without_comment(line); + // `std::fs::read`, `fs::read` after `use std::fs;` or `use std::{fs, io};`, `std::os::unix::fs::...`. + for word in ["fs::", "File::open", "File::create", "File::options", "OpenOptions", "atomic::write_atomic"] { + if has_word(line, word) { + return Some(word.into()); + } + } + // The probes on a path, as `p.exists()` or `Path::exists`. + for method in ["metadata", "symlink_metadata", "canonicalize", "read_dir", "try_exists", "exists", "is_file", "is_dir", "read_link"] { + if line.contains(&format!(".{method}(")) || line.contains(&format!("Path::{method}")) { + return Some(method.to_string()); + } + } + None +} + +/// `use` statements (which can span lines) that bring the file system module in under any name: +/// `use std::{fs as files, io};` leaves no `fs::` to find at the call. +fn imports_fs(text: &str) -> Vec<usize> { + let mut hits = vec![]; + let mut statement = String::new(); + let mut start = 0; + for (n, line) in code_outside_tests(text) { + let line = without_comment(line); + if statement.is_empty() { + if !line.trim_start().starts_with("use ") && !line.trim_start().starts_with("pub use ") { + continue; + } + start = n; + } + statement.push_str(line); + statement.push(' '); + if line.contains(';') { + let words: Vec<&str> = statement.split(|c: char| !is_ident(c)).collect(); + if words.contains(&"fs") && (words.contains(&"std") || words.contains(&"tokio") || words.contains(&"async_std")) { + hits.push(start); + } + statement.clear(); + } + } + hits +} + +/// Every offending line of the crate sources under `crates_dir`, minus [`ALLOWED`] and test code. +fn offenders(crates_dir: &Path) -> (Vec<String>, usize) { + let mut files = vec![]; + rust_files(crates_dir, &mut files); + let mut scanned = 0; + let mut found = vec![]; + for path in files { + let rel = path.strip_prefix(crates_dir).unwrap_or(&path).to_string_lossy().replace('\\', "/"); + // `crates/<name>/src/...` only: build scripts, benches and `tests/` are not shipped code. + if !rel.split('/').nth(1).is_some_and(|d| d == "src") || is_test_file(&rel) || ALLOWED.iter().any(|(allowed, _)| *allowed == rel) { + continue; + } + let Ok(text) = std::fs::read_to_string(&path) else { continue }; + scanned += 1; + for (n, line) in code_outside_tests(&text) { + if let Some(what) = file_system_use(line) { + found.push(format!("crates/{rel}:{n}: `{what}`: {}", line.trim())); + } + } + for n in imports_fs(&text) { + found.push(format!("crates/{rel}:{n}: imports the file system module")); + } + } + (found, scanned) +} + +fn crates_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("..") +} + +/// Every command reads and writes files through [`crate::io`], in the engine and in every crate +/// below it: none may reach the file system itself, so the automation roots cannot be walked +/// around by a path that one of them opens. The exceptions are [`ALLOWED`], each with its reason. +#[test] +fn no_crate_touches_the_file_system_outside_the_checked_readers() { + let (found, scanned) = offenders(&crates_dir()); + assert!(scanned > 100, "the scan found only {scanned} source files: has the layout moved?"); + assert!( + found.is_empty(), + "route these through crate::io (engine) or a reader the caller injects (lower crates), or add the file to ALLOWED with the reason it is safe: {found:#?}" + ); +} + +/// The allow list names files that exist, so a rename cannot leave a stale exception behind (or +/// quietly exempt a new file of the same name). +#[test] +fn every_allowed_file_exists_and_still_needs_its_exception() { + for (rel, reason) in ALLOWED { + let path = crates_dir().join(rel); + let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{rel}: {e}")); + assert!(!reason.is_empty()); + let touches = code_outside_tests(&text).iter().any(|(_, line)| file_system_use(line).is_some()) || !imports_fs(&text).is_empty(); + assert!(touches, "{rel} no longer touches the file system: drop it from ALLOWED"); + } +} + +/// The scan must not be foolable by how a call is spelled. +#[test] +fn the_scan_sees_every_spelling() { + let flagged = [ + "let b = std::fs::read(p)?;", + "let b = fs::read(p)?;", + " fs::write(p, b)", + "let f = File::open(p)?;", + "let f = std::fs::File::create(p)?;", + "let f = OpenOptions::new().write(true).open(p)?;", + "let m = path.metadata()?;", + "let m = path.symlink_metadata()?;", + "let c = path.canonicalize()?;", + "for e in path.read_dir()? {}", + "if path.try_exists()? {}", + "if p.exists() {}", + "if p.is_file() {}", + "if p.is_dir() {}", + "let l = p.read_link()?;", + "files.retain(Path::exists);", + "crate::atomic::write_atomic(p, b)?;", + "(a, fs::read(x))", + ]; + for line in flagged { + assert!(file_system_use(line).is_some(), "not flagged: {line}"); + } + let fine = [ + "let b = crate::io::read_file(p)?;", + "if crate::io::is_dir(p) {}", + "let m = crate::io::metadata(p)?;", + "// std::fs::read(p) is what io wraps", + "let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);", + "let ofs::x = 1;", + ]; + for line in fine { + assert!(file_system_use(line).is_none(), "wrongly flagged: {line}"); + } + for import in ["use std::fs;", "use std::{fs, io};", "use std::{\n io,\n fs as files,\n};", "use std::fs::File;"] { + assert!(!imports_fs(import).is_empty(), "import not flagged: {import}"); + } + assert!(imports_fs("use std::io::Read;\nuse std::path::Path;").is_empty()); + assert!(imports_fs("#[cfg(test)]\nmod tests {\n use std::fs;\n}\n").is_empty(), "test modules are skipped"); +} diff --git a/docs/mcp.md b/docs/mcp.md index 20447fd02..f69905390 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -96,11 +96,14 @@ choice and is opened before the roots apply. What the roots do not cover: commands that take no path still read and change the open document, and `inspect_document` returns it. The system font scan, the installed fonts that `file.package` copies, and the app's own recovery folder are fixed paths, not agent input, and are not checked. A -file inside a `Document Fonts` folder is read as a font even when it is a link that leads out of -the read root (it is not returned to the agent, only parsed as a font and skipped when it isn't -one). `file.print` hands a PDF to the system's print spooler, which is not a path the agent names. -Anything that reads or writes a path goes through one place in the engine (the `io` module); a test -keeps the rest of the engine from touching the file system on its own. +`Document Fonts` folder and each font file in it are checked before they are opened, so a link that +leads out of the read root is skipped with a note. `file.print` hands a PDF to the system's print +spooler, which is not a path the agent names. +Anything that reads or writes a path goes through one place in the engine (the `io` module). The +crates below the engine have no file access of their own: the IDML import and the font folders take +a reader or an access check from the engine, and ICC profiles arrive as bytes. A test scans the +source of every crate for the file system, however it is imported or spelled, and lists the few +files it excludes and why. The checks hold for every thread of the server process. A path is checked, then opened: another program that swaps a link in between can still win that race, so the roots keep an agent to the From f605ca3157085d0cf2de06be0af0ae13a50f85be Mon Sep 17 00:00:00 2001 From: webdevtodayjason <115739170+webdevtodayjason@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:16:17 +0000 Subject: [PATCH 100/238] chore(contributors): add Jason Brashear --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 67a8fdf30..9f9644959 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -30,3 +30,6 @@ sync_github_name = true [people.thaynej] real_name = "Jeffrey Thayne" + +[people.webdevtodayjason] +display_name = "Jason Brashear" From 98f1340f61a44f3681d8f2d3cac6298b10be5912 Mon Sep 17 00:00:00 2001 From: webdevtodayjason <115739170+webdevtodayjason@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:16:17 +0000 Subject: [PATCH 101/238] fix(index): keep level 3 and 4 topics in a run-in index --- crates/doc/src/index.rs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/doc/src/index.rs b/crates/doc/src/index.rs index 0c2e58f4d..84d8caf6a 100644 --- a/crates/doc/src/index.rs +++ b/crates/doc/src/index.rs @@ -184,10 +184,15 @@ fn entry_text(spec: &IndexSpec, n: &Node, page_name: &dyn Fn(usize) -> String) - t } +// `build` limits topic paths to four levels, bounding this traversal. +fn run_in_subtopics(spec: &IndexSpec, n: &Node, page_name: &dyn Fn(usize) -> String) -> Vec<String> { + n.children.values().flat_map(|c| [entry_text(spec, c, page_name)].into_iter().chain(run_in_subtopics(spec, c, page_name))).collect() +} + fn emit(spec: &IndexSpec, n: &Node, level: usize, page_name: &dyn Fn(usize) -> String, out: &mut Vec<(String, String)>) { if spec.run_in && level == 1 { let mut t = entry_text(spec, n, page_name); - let subs: Vec<String> = n.children.values().map(|c| entry_text(spec, c, page_name)).collect(); + let subs = run_in_subtopics(spec, n, page_name); if !subs.is_empty() { t.push_str(": "); t.push_str(&subs.join(&spec.between_entries)); @@ -209,6 +214,19 @@ mod tests { Resolved { r: IndexRef { topics: topics.iter().map(|s| s.to_string()).collect(), sort: vec![], range }, pages } } + #[test] + fn run_in_keeps_all_topic_levels_in_sort_order() { + let refs = vec![ + r(&["Typography", "kerning", "pairs", "optical"], IndexRange::CurrentPage, Some((4, 4))), + r(&["Typography", "kerning", "pairs"], IndexRange::CurrentPage, Some((2, 2))), + r(&["Typography", "leading"], IndexRange::CurrentPage, Some((6, 6))), + r(&["Typography", "kerning", "adjustment"], IndexRange::See("Spacing".into()), None), + ]; + let spec = IndexSpec { run_in: true, title: String::new(), section_headings: false, between_entries: " / ".into(), ..IndexSpec::default() }; + let out = build(&spec, &refs, &|p| (p + 1).to_string()); + assert_eq!(out, [("Index Level 1".into(), "Typography: kerning / adjustment See Spacing / pairs 3 / optical 5 / leading 7".into())]); + } + #[test] fn builds_nested_index() { let refs = vec![ From 64f0ca8fa6a38c484504871f9af9e142ad6dcf3d Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick <hi@skylar.cc> Date: Mon, 12 Oct 2026 14:52:12 -0600 Subject: [PATCH 102/238] fix(macos): make Dock Quit cancelable Route AppKit termination through the existing unsaved-document close transaction using the immutable winit patch. Restore pending prompts from hidden or minimized windows and preserve answers across repeated requests. Validation: all six local gates, native cancellation/retry/save smoke, and hosted platform checks. Fixes #544. PR #623. --- Cargo.lock | 4 +- Cargo.toml | 4 ++ ROADMAP.md | 3 +- apps/designcraft/Cargo.toml | 2 + apps/designcraft/src/main.rs | 9 +++ crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/lib.rs | 104 +++++++++++++++++++++++++++++++++++ docs/architecture.md | 11 +++- docs/target-app-parity.md | 4 +- 9 files changed, 134 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5e4dc8975..ef525c84e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1156,6 +1156,7 @@ dependencies = [ "regex", "serde", "serde_json", + "tempfile", "toml", "unicode-bidi", "unicode-bidi-mirroring", @@ -5516,8 +5517,7 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winit" version = "0.30.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +source = "git+https://github.com/skylartaylor/winit.git?rev=3d51077fd1a966f84df9ef287839dc84fd5b4ca7#3d51077fd1a966f84df9ef287839dc84fd5b4ca7" dependencies = [ "ahash", "android-activity", diff --git a/Cargo.toml b/Cargo.toml index c2126ade8..b08a82fc2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -97,3 +97,7 @@ opt-level = 3 lto = "thin" codegen-units = 16 debug = 0 + +# Opt-in cancelable macOS termination on the pinned winit 0.30 line. +[patch.crates-io] +winit = { git = "https://github.com/skylartaylor/winit.git", rev = "3d51077fd1a966f84df9ef287839dc84fd5b4ca7" } diff --git a/ROADMAP.md b/ROADMAP.md index f826a3767..91a951616 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **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 (orderly native exit recovery) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (cancelable macOS Dock Quit) · **Target:** Adobe InDesign 2026 DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. - **2026-10-12** · Book numbering and style updates prepare every chapter before saving. Only closed native chapters are changed; imported IDML remains a read-only style/PDF source. If a later disk write fails, the error names the completed chapters; the batch is not a whole-book transaction. No readiness percentage change. diff --git a/apps/designcraft/Cargo.toml b/apps/designcraft/Cargo.toml index 13254e11e..b65efbe48 100644 --- a/apps/designcraft/Cargo.toml +++ b/apps/designcraft/Cargo.toml @@ -31,4 +31,6 @@ rfd = { workspace = true } winit = { version = "0.30", default-features = false, features = ["x11"] } [target.'cfg(target_os = "macos")'.dependencies] +# The same winit as eframe; only the native termination opt-in is used. +winit = { version = "0.30", default-features = false } muda = { version = "0.21", default-features = false } diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index f46812579..26ce2ee4f 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -299,6 +299,15 @@ fn main() -> eframe::Result { b.with_x11(); })); } + // AppKit Dock Quit otherwise terminates before egui can cancel the close. Route it through + // ordinary CloseRequested events, preserving the same transactional Save/Don't Save flow. + #[cfg(target_os = "macos")] + { + options.event_loop_builder = Some(Box::new(|builder| { + use winit::platform::macos::EventLoopBuilderExtMacOS; + builder.with_close_requested_on_terminate(true); + })); + } eframe::run_native( "DesignCraft", options, diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 850902196..76b5f89b2 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -29,6 +29,7 @@ serde_json = { workspace = true } log = { workspace = true } [dev-dependencies] +tempfile = { workspace = true } designcraft-idml = { workspace = true } zip = { workspace = true } # Headless (offscreen wgpu) rendering of the whole window for UI screenshots: `examples/ui_shot.rs`. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e0fa1dff0..8c6189935 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -852,6 +852,11 @@ impl DesignApp { return; } ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + // Dock Quit can arrive while the app is hidden or minimized. Make its question + // visible through the normal safe window commands before asking about documents. + ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); + ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false)); + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); if self.quitting.is_none() { self.quitting = Some(QuitState { original_active: self.session.active().map(|d| d.uid), accepted: HashMap::new(), pending: None }); } @@ -1427,6 +1432,105 @@ mod tests { dialogs::confirm(app).unwrap(); } + #[test] + fn native_quit_prompts_restore_window_visibility_without_focusing_completed_quits() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + let commands = quit_frame(&mut app, &ctx, true); + assert!(commands.contains(&egui::ViewportCommand::CancelClose)); + for command in [egui::ViewportCommand::Visible(true), egui::ViewportCommand::Minimized(false), egui::ViewportCommand::Focus] { + assert!(commands.contains(&command), "unsaved prompt must restore the window: {commands:?}"); + } + discard_quit_dialog(&mut app); + let approved = quit_frame(&mut app, &ctx, false); + assert!(approved.contains(&egui::ViewportCommand::Close)); + let completed = quit_frame(&mut app, &ctx, true); + assert!(app.session.documents().is_empty()); + + let mut clean = DesignApp::new(designcraft_engine::Session::new(), Services::default()); + clean.run("file.new", json!({})).unwrap(); + let clean_close = quit_frame(&mut clean, &ctx, true); + let mut control = app_with_unsaved_document(); + control.quit_without_asking = true; + let control_close = quit_frame(&mut control, &ctx, true); + for commands in [&approved, &completed, &clean_close, &control_close] { + assert!( + !commands.iter().any(|command| matches!( + command, + egui::ViewportCommand::Visible(true) | egui::ViewportCommand::Minimized(false) | egui::ViewportCommand::Focus + )), + "completed and immediate quits must not activate the window: {commands:?}" + ); + } + } + + /// Dock Quit may arrive again while the first request's dialogs are still open. + #[test] + fn repeated_native_quit_requests_preserve_partial_approvals_and_cancel() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + second_unsaved_document(&mut app); + let original_active = app.session.active().unwrap().uid; + let uids: Vec<_> = app.session.documents().iter().map(|d| d.uid).collect(); + let docs: Vec<_> = app.session.documents().iter().map(|d| d.doc.clone()).collect(); + let directory = tempfile::tempdir().unwrap(); + app.session.recovery_dir = Some(directory.path().to_path_buf()); + app.run("file.recovery.save", json!({})).unwrap(); + assert!(quit_frame(&mut app, &ctx, true).contains(&egui::ViewportCommand::CancelClose)); + assert_eq!(dialog_uid(&app), Some(uids[0])); + discard_quit_dialog(&mut app); + quit_frame(&mut app, &ctx, false); + assert_eq!(dialog_uid(&app), Some(uids[1])); + for _ in 0..3 { + assert!(quit_frame(&mut app, &ctx, true).contains(&egui::ViewportCommand::CancelClose)); + assert_eq!(dialog_uid(&app), Some(uids[1])); + } + app.ui.dialog = None; // Cancel the second document, retaining the first Don't Save answer. + assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + assert_eq!(app.session.active().unwrap().uid, original_active); + assert_eq!(app.session.documents().iter().map(|d| d.uid).collect::<Vec<_>>(), uids); + for (document, original) in app.session.documents().iter().zip(docs) { + assert!(std::sync::Arc::ptr_eq(&document.doc, &original)); + assert!(document.is_dirty()); + assert!(!document.history.undo.is_empty()); + assert!(directory.path().join(format!("{}.designcraft", document.uid)).exists()); + } + // A fresh Dock Quit must ask about the first document again after cancellation. + assert!(quit_frame(&mut app, &ctx, true).contains(&egui::ViewportCommand::CancelClose)); + assert_eq!(dialog_uid(&app), Some(uids[0])); + } + + #[test] + fn repeated_native_quit_requests_save_and_discard_only_after_all_answers() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + second_unsaved_document(&mut app); + let uids: Vec<_> = app.session.documents().iter().map(|d| d.uid).collect(); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("saved.designcraft"); + let chosen = path.to_string_lossy().to_string(); + app.services.pick_save = Some(Box::new(move |_| Some(chosen.clone()))); + app.session.recovery_dir = Some(directory.path().join("recovery")); + app.run("file.recovery.save", json!({})).unwrap(); + quit_frame(&mut app, &ctx, true); + assert_eq!(dialog_uid(&app), Some(uids[0])); + dialogs::confirm(&mut app).unwrap(); // Save the first document. + assert!(!app.session.documents()[0].is_dirty()); + quit_frame(&mut app, &ctx, false); + assert_eq!(dialog_uid(&app), Some(uids[1])); + assert!(quit_frame(&mut app, &ctx, true).contains(&egui::ViewportCommand::CancelClose)); + assert_eq!(dialog_uid(&app), Some(uids[1])); + discard_quit_dialog(&mut app); + assert!(quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + assert_eq!(app.session.documents().len(), 2); + assert!(app.session.recovery_dir.as_ref().unwrap().join(format!("{}.designcraft", uids[1])).exists()); + assert!(quit_frame(&mut app, &ctx, true).is_empty()); + assert!(app.session.documents().is_empty()); + assert!(path.exists()); + assert_eq!(designcraft_engine::Session::new().execute("file.open", &json!({"path": path})).unwrap()["index"], 0); + assert_eq!(std::fs::read_dir(app.session.recovery_dir.as_ref().unwrap()).unwrap().count(), 0); + } + #[test] fn quitting_first_cancel_restores_original_clean_active_document() { let ctx = egui::Context::default(); diff --git a/docs/architecture.md b/docs/architecture.md index be3274b24..39a9039dc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # DesignCraft architecture -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (orderly native exit recovery) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (cancelable macOS Dock Quit) · **Target:** Adobe InDesign 2026 How DesignCraft is built today. The design notes behind it are in `plan/architecture.md` (local, gitignored); this file describes what the code on `main` does. Agents: AGENTS.md has the @@ -60,8 +60,12 @@ command is replayable as a script. Escaped panics during a command or an import/ error (`engine/src/guard.rs`), never a lost document; native recovery is written periodically into each running app's locked folder. Orderly native shutdown finalizes complete, current quit approvals and writes the remaining documents' latest recovery state, including clean-document -cleanup after Undo. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit -remains open (#544). +cleanup after Undo. Unavailable or failed recovery is reported. The desktop opts into a pinned +winit 0.30 patch that cancels AppKit termination and queues ordinary window close requests, +including Quit from the macOS Dock. The existing unsaved-document transaction can cancel +those requests or exit after current approvals are complete. A pending prompt restores and +focuses a hidden or minimized window; completed and immediate quits do not activate it. The +AppKit callback never runs the confirmation dialogs inside a modal termination loop. ## Composition and rendering @@ -98,5 +102,6 @@ builds (on manifest changes), packaging lint and the release workflow; `xtask ci | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Documented cancelable Dock Quit through the native event-loop boundary | | 2026-10-12 | minor | Documented orderly native exit recovery and the remaining cancelable Dock Quit gap | | 2026-10-10 | major | Created from the code on main (92649ff) and `plan/architecture.md` | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9095faf50..78e968dcf 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # DesignCraft parity with Adobe InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (recovery checkpoint retention) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (cancelable macOS Dock Quit) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) The authoritative assessment of how close DesignCraft is to Adobe InDesign. [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) lists every known shortfall one at a time; the feature-by-feature @@ -291,7 +291,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Native engine saves, Save a Copy, snippets, books, libraries, swatches and engine/MCP exports use same-folder atomic replacement, as do desktop Services writes and CLI raster exports. Failed writes before replacement retain the original; existing Unix permissions apply before staging document bytes, and already-open readers retain the old bytes. Parent-folder flushing is best effort, so this is not a power-loss durability guarantee. - Save As prepares destination-relative data-source paths before writing and installs them only after a successful save. Failed Save As retains the document, saved state and history; Save a Copy rebases only the saved copy. Library and book collection edits likewise update live values only after persistence succeeds. - Crash recovery: unsaved documents are written every 30 s into a locked folder owned by that running app and reopened (unsaved, remembering their files) after a crash. Startup takes entries from ended runs and leaves live runs alone; writes and cleanup validate current folder and lock ownership. Failed isolation disables recovery with a warning. Recovered checkpoints remain available through another crash before autosave; restarting IDs and explicit recovery from another folder preserve unopened snapshots. Successful Save, Close or Revert clears the document's checkpoints, including manual recovery while autosave is disabled; failed republishing keeps the original. Concurrent older versions still use unlocked root entries; mixed-version isolation remains open. File › Revert and Save a Copy…; `file.recovery.*`, `file.revert`, `file.saveACopy`. -- Window close and application/menu Quit ask about every unsaved document. Cancel or a cancelled/failed save keeps all tabs open; Don't Save approvals discard nothing until the complete plan is current. Later edits and new documents are checked before closing. Control-channel quit remains immediate; a cancelable macOS Dock Quit prompt is still missing (#544). +- Window close, application/menu Quit and macOS Dock Quit ask about every unsaved document. Cancel or a cancelled/failed save keeps all tabs open; Don't Save approvals discard nothing until the complete plan is current. Later edits and new documents are checked before closing. Repeated native requests preserve an active dialog and partial approvals. Control-channel quit remains immediate. - Graphics backend chosen before the window exists (DirectX 12 on Windows, Vulkan on Linux, Metal on macOS, OpenGL as the fallback; `WGPU_BACKEND` overrides) with a start-up fallback: a backend whose start never showed a frame is skipped at the next start and the status bar says so (`gpu.json`). Found on an AMD hybrid-graphics laptop whose Vulkan driver faulted at the first present. - Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool). - Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`. From 7515dfeaf50135f133fbe91cf5284206e06b1069 Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Mon, 12 Oct 2026 20:59:17 +0000 Subject: [PATCH 103/238] Word import: bullets and numbering from numbering.xml (#626) Paragraphs with w:numPr, directly or through their paragraph style, become bullet or numbered paragraphs: Word's number formats, label text, start numbers and overrides, level restarts, the text after the label and the level's indents. Parent levels in a label are written as text. Unreadable or hostile list definitions leave plain paragraphs. --- ROADMAP.md | 1 + crates/textimport/src/docx.rs | 294 ++++++++++++++++++++- crates/textimport/src/docx_numbering.rs | 325 ++++++++++++++++++++++++ crates/textimport/src/lib.rs | 3 +- docs/file-format-parity.md | 2 +- 5 files changed, 617 insertions(+), 8 deletions(-) create mode 100644 crates/textimport/src/docx_numbering.rs diff --git a/ROADMAP.md b/ROADMAP.md index f826a3767..9caf72aae 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-12** · Placed Word documents keep their bullets and numbering (#626): `numbering.xml` levels become bullet or numbered paragraphs with Word's number formats, label text, start numbers, restarts and list indents, also when a paragraph style carries the list. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. - **2026-10-12** · Book numbering and style updates prepare every chapter before saving. Only closed native chapters are changed; imported IDML remains a read-only style/PDF source. If a later disk write fails, the error names the completed chapters; the batch is not a whole-book transaction. No readiness percentage change. diff --git a/crates/textimport/src/docx.rs b/crates/textimport/src/docx.rs index 67a2b71a1..a51e9fe02 100644 --- a/crates/textimport/src/docx.rs +++ b/crates/textimport/src/docx.rs @@ -4,9 +4,10 @@ use std::collections::HashMap; use std::io::Read; use designcraft_doc::story::{BASIC_PARAGRAPH, NO_CHAR_STYLE}; -use designcraft_doc::{CharAttrs, CharFormat, ParaAttrs, ParaFormat, Story, StoryId, Table}; +use designcraft_doc::{CharAttrs, CharFormat, ListType, ParaAttrs, ParaFormat, Story, StoryId, Table}; use quick_xml::events::Event; +use crate::docx_numbering::{ListPara, Numbering}; use crate::{ImportError, Imported, ImportedStyle}; /// A minimal element tree (local names, attributes by local name). @@ -43,10 +44,10 @@ impl El { .collect() } /// `<w:x w:val="…"/>` → the value; a bare `<w:b/>` counts as "on". - fn val(&self, name: &str) -> Option<String> { + pub(crate) fn val(&self, name: &str) -> Option<String> { self.child(name).map(|e| e.attr("val").unwrap_or("true").to_string()) } - fn on(&self, name: &str) -> Option<bool> { + pub(crate) fn on(&self, name: &str) -> Option<bool> { self.val(name).map(|v| !matches!(v.as_str(), "0" | "false" | "none")) } } @@ -216,6 +217,12 @@ struct Ctx { footnotes: HashMap<String, El>, warnings: Vec<String>, next_table: u64, + numbering: Numbering, + /// A paragraph style's list (`w:numPr` numId and ilvl, through `w:basedOn`), by style id. + style_lists: HashMap<String, (Option<String>, Option<String>)>, + /// The number the model's own list counter is at (it restarts after a paragraph that isn't + /// in a list), to tell where a numbered paragraph needs an explicit Start At. + model_counter: u32, } impl Ctx { @@ -236,10 +243,20 @@ impl Ctx { first = false; let ppr = e.child("pPr"); let style = self.para_style(ppr); - let attrs = para_attrs(ppr); + let mut attrs = para_attrs(ppr); + let list = self.list(ppr); + if let Some(l) = &list { + // The level's indents, unless the paragraph sets its own. + let direct = attrs; + attrs = l.attrs.clone(); + attrs.merge(&direct); + } let at = st.len(); st.format_paras(at..at, |f| *f = ParaFormat { style: style.clone(), para: attrs.clone(), ..Default::default() }); self.inline(e, st, allow_notes); + if let Some(n) = self.start_at(list.as_ref(), st.len() == at) { + st.format_paras(at..at, |f| f.para.start_at = Some(Some(n))); + } } "tbl" => { let rows: Vec<Vec<String>> = e @@ -271,6 +288,35 @@ impl Ctx { } } + /// The list a paragraph is in (its own `w:numPr`, else its style's), counting it. + fn list(&mut self, ppr: Option<&El>) -> Option<ListPara> { + let direct = ppr.and_then(|p| p.child("numPr")); + let style = ppr.and_then(|p| p.val("pStyle")).and_then(|id| self.style_lists.get(&id)); + let num_id = direct.and_then(|n| n.val("numId")).or_else(|| style.and_then(|s| s.0.clone()))?; + let ilvl = direct.and_then(|n| n.val("ilvl")).or_else(|| style.and_then(|s| s.1.clone())); + // A level that isn't a small number is no list. + let ilvl = ilvl.map_or(Some(0), |l| l.trim().parse::<u32>().ok())?; + self.numbering.next(num_id.trim(), ilvl) + } + + /// Start At for a paragraph, so that the model numbers it as Word does: only where the + /// model's counter (which follows the previous numbered paragraph, and restarts after one + /// that isn't in a list) would give a different number. + fn start_at(&mut self, list: Option<&ListPara>, empty: bool) -> Option<u32> { + let Some(l) = list else { + self.model_counter = 0; + return None; + }; + // Bullets and empty paragraphs leave the counter alone (they show no number). + if l.attrs.list_type != Some(ListType::Numbers) || empty { + return None; + } + let want = l.number.max(1); + let next = self.model_counter.saturating_add(1); + self.model_counter = want; + (want != next).then_some(want) + } + /// Runs (and hyperlinks, fields' results) of a paragraph. fn inline(&mut self, p: &El, st: &mut Story, allow_notes: bool) { for e in p.els() { @@ -302,7 +348,9 @@ impl Ctx { "footnoteReference" if allow_notes => { if let Some(note) = e.attr("id").and_then(|id| self.footnotes.get(id).cloned()) { let mut ns = Story::new(StoryId(0)); + let counter = std::mem::take(&mut self.model_counter); self.blocks(¬e, &mut ns, false); + self.model_counter = counter; // Word puts the reference mark in the note text: we generate the number. let len = st.len(); let id = st.insert_note(len, "", ParaFormat::default()); @@ -328,17 +376,63 @@ impl Ctx { } } +/// Largest `word/numbering.xml` read. +const NUMBERING_LIMIT: u64 = 16 * 1024 * 1024; +/// Deepest `w:basedOn` chain followed (a cycle stops here too). +const MAX_STYLE_DEPTH: usize = 32; + +/// Each paragraph style's list: the first `w:numId` and `w:ilvl` on its `w:basedOn` chain. +fn style_lists(styles: &El) -> HashMap<String, (Option<String>, Option<String>)> { + let by_id: HashMap<&str, &El> = styles.els().filter(|s| s.name == "style").filter_map(|s| Some((s.attr("styleId")?, s))).collect(); + let mut out = HashMap::new(); + for (id, s) in &by_id { + let (mut num_id, mut ilvl) = (None, None); + let mut cur = Some(*s); + for _ in 0..MAX_STYLE_DEPTH { + let Some(c) = cur else { break }; + if let Some(n) = c.child("pPr").and_then(|p| p.child("numPr")) { + num_id = num_id.or_else(|| n.val("numId")); + ilvl = ilvl.or_else(|| n.val("ilvl")); + } + if num_id.is_some() && ilvl.is_some() { + break; + } + cur = c.val("basedOn").and_then(|b| by_id.get(b.as_str()).copied()); + } + if num_id.is_some() { + out.insert(id.to_string(), (num_id, ilvl)); + } + } + out +} + pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> { let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| ImportError::Corrupt(e.to_string()))?; let doc = part(&mut zip, "word/document.xml")?.ok_or_else(|| ImportError::Corrupt("no word/document.xml (not a Word document)".into()))?; let doc = parse(&doc)?; // Styles. - let mut ctx = Ctx { para_names: HashMap::new(), char_names: HashMap::new(), footnotes: HashMap::new(), warnings: vec![], next_table: 0 }; + let mut ctx = Ctx { + para_names: HashMap::new(), + char_names: HashMap::new(), + footnotes: HashMap::new(), + warnings: vec![], + next_table: 0, + numbering: Numbering::default(), + style_lists: HashMap::new(), + model_counter: 0, + }; + // Lists that can't be read leave plain paragraphs, not a failed import. + match part_capped(&mut zip, "word/numbering.xml", NUMBERING_LIMIT).and_then(|s| s.map(|s| parse(&s)).transpose()) { + Ok(Some(n)) => ctx.numbering = Numbering::parse(&n), + Ok(None) => {} + Err(_) => ctx.warnings.push("The Word file's list definitions could not be read: lists were imported as plain paragraphs.".into()), + } let mut para_styles = Vec::new(); let mut char_styles = Vec::new(); if let Some(styles) = part(&mut zip, "word/styles.xml")?.map(|s| parse(&s)).transpose()? { let ids: HashMap<String, String> = styles.els().filter(|s| s.name == "style").filter_map(|s| Some((s.attr("styleId")?.to_string(), s.val("name")?))).collect(); + ctx.style_lists = style_lists(&styles); for s in styles.els().filter(|s| s.name == "style") { let (Some(id), Some(name)) = (s.attr("styleId"), s.val("name")) else { continue }; let kind = s.attr("type").unwrap_or("paragraph"); @@ -404,9 +498,14 @@ mod tests { use std::io::Write; pub(crate) fn docx(document: &str, styles: &str, footnotes: &str) -> Vec<u8> { + docx_parts(&[("word/document.xml", document), ("word/styles.xml", styles), ("word/footnotes.xml", footnotes)]) + } + + /// A zip of `(part name, XML)`; empty parts are left out. + fn docx_parts(parts: &[(&str, &str)]) -> Vec<u8> { let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); let o = zip::write::SimpleFileOptions::default(); - for (n, c) in [("word/document.xml", document), ("word/styles.xml", styles), ("word/footnotes.xml", footnotes)] { + for &(n, c) in parts { if c.is_empty() { continue; } @@ -510,4 +609,187 @@ mod tests { i.story.check().unwrap(); assert_eq!(i.story.text, "Kept text. MOVED HERE. Plain INSERTED\nEnd."); } + + /// A paragraph in a Word list: `w:numPr` with `numId` and, unless `None`, `ilvl`. + fn list_p(num_id: &str, ilvl: Option<&str>, text: &str) -> String { + let ilvl = ilvl.map(|l| format!(r#"<w:ilvl w:val="{l}"/>"#)).unwrap_or_default(); + format!(r#"<w:p><w:pPr><w:numPr>{ilvl}<w:numId w:val="{num_id}"/></w:numPr></w:pPr><w:r><w:t>{text}</w:t></w:r></w:p>"#) + } + + fn plain_p(text: &str) -> String { + format!(r#"<w:p><w:r><w:t>{text}</w:t></w:r></w:p>"#) + } + + fn lists_docx(numbering: &str, styles: &str, body: &[String]) -> Imported { + let document = format!(r#"<w:document {W}><w:body>{}</w:body></w:document>"#, body.concat()); + let i = import(&docx_parts(&[("word/document.xml", &document), ("word/styles.xml", styles), ("word/numbering.xml", numbering)])).unwrap(); + i.story.check().unwrap(); + i + } + + /// The label the model gives paragraph `i` when its number is `n`. + fn label(st: &Story, i: usize, n: u32) -> String { + let mut p = designcraft_doc::ParaProps::default(); + p.apply(&st.paras[i].para); + match p.list_type { + ListType::Numbers => p.number_label(n), + ListType::Bullets => p.bullet_label(), + ListType::None => String::new(), + } + } + + /// Regression (#626): pandoc's Markdown list example lost its bullets and numbers. + #[test] + fn bullets_and_numbers_from_numbering_xml() { + let numbering = format!( + r#"<w:numbering {W}> + <w:abstractNum w:abstractNumId="990"><w:lvl w:ilvl="0"><w:start w:val="1"/><w:numFmt w:val="bullet"/><w:lvlText w:val="•"/> + <w:pPr><w:ind w:left="480" w:hanging="480"/></w:pPr></w:lvl></w:abstractNum> + <w:abstractNum w:abstractNumId="991"><w:lvl w:ilvl="0"><w:start w:val="1"/><w:numFmt w:val="decimal"/><w:lvlText w:val="%1."/> + <w:pPr><w:ind w:left="480" w:hanging="480"/></w:pPr></w:lvl></w:abstractNum> + <w:num w:numId="1000"><w:abstractNumId w:val="990"/></w:num> + <w:num w:numId="1001"><w:abstractNumId w:val="991"/><w:lvlOverride w:ilvl="0"><w:startOverride w:val="1"/></w:lvlOverride></w:num> + </w:numbering>"# + ); + let body = [ + plain_p("Intro paragraph."), + list_p("1000", Some("0"), "first item"), + list_p("1000", Some("0"), "second item"), + list_p("1001", Some("0"), "step one"), + list_p("1001", Some("0"), "step two"), + ]; + let st = lists_docx(&numbering, "", &body).story; + let types: Vec<Option<ListType>> = st.paras.iter().map(|p| p.para.list_type).collect(); + assert_eq!(types, [None, Some(ListType::Bullets), Some(ListType::Bullets), Some(ListType::Numbers), Some(ListType::Numbers)]); + assert_eq!(label(&st, 1, 1), "•\t"); + assert_eq!(st.paras[3].para.number_style, Some(designcraft_doc::NumberStyle::Arabic)); + assert_eq!((label(&st, 3, 1), label(&st, 4, 2)), ("1.\t".to_string(), "2.\t".to_string())); + // The model's own counter already starts at 1 and carries on: no Start At needed. + assert_eq!((st.paras[3].para.start_at, st.paras[4].para.start_at), (None, None)); + assert_eq!((st.paras[1].para.left_indent, st.paras[1].para.first_line_indent), (Some(24.0), Some(-24.0))); + } + + /// Level 1 inside level 0, parent numbers in a label, numbering that carries on past a plain + /// paragraph, and a `num` with a start override restarting. + #[test] + fn nested_levels_and_restarts() { + let numbering = format!( + r#"<w:numbering {W}> + <w:abstractNum w:abstractNumId="1"> + <w:lvl w:ilvl="0"><w:start w:val="1"/><w:numFmt w:val="decimal"/><w:lvlText w:val="%1."/></w:lvl> + <w:lvl w:ilvl="1"><w:start w:val="1"/><w:numFmt w:val="lowerLetter"/><w:lvlText w:val="%2)"/><w:suff w:val="space"/> + <w:pPr><w:ind w:left="1440" w:hanging="360"/></w:pPr></w:lvl> + <w:lvl w:ilvl="2"><w:start w:val="1"/><w:numFmt w:val="upperRoman"/><w:lvlText w:val="%1.%2.%3"/></w:lvl> + </w:abstractNum> + <w:num w:numId="1"><w:abstractNumId w:val="1"/></w:num> + <w:num w:numId="2"><w:abstractNumId w:val="1"/><w:lvlOverride w:ilvl="0"><w:startOverride w:val="1"/></w:lvlOverride></w:num> + <w:num w:numId="3"><w:abstractNumId w:val="1"/></w:num> + </w:numbering>"# + ); + let body = [ + list_p("1", None, "A"), + list_p("1", Some("1"), "B"), + list_p("1", Some("1"), "C"), + list_p("1", Some("2"), "D"), + list_p("1", Some("0"), "E"), + plain_p("Between"), + list_p("1", Some("0"), "F"), + list_p("2", Some("0"), "G"), + list_p("3", Some("0"), "H"), + ]; + let st = lists_docx(&numbering, "", &body).story; + let start: Vec<Option<Option<u32>>> = st.paras.iter().map(|p| p.para.start_at).collect(); + // Word's numbers: 1, a, b, 1.b.I, 2, –, 3, 1 (own counter), 4 (shares numId 1's). E's 2 + // follows the model's counter (1 after D). + assert_eq!(start, [None, Some(Some(1)), None, Some(Some(1)), None, None, Some(Some(3)), Some(Some(1)), Some(Some(4))]); + assert_eq!(label(&st, 1, 1), "a) "); + assert_eq!(label(&st, 3, 1), "1.b.I\t"); + assert_eq!(st.paras[1].para.left_indent, Some(72.0)); + assert_eq!(st.paras[1].para.first_line_indent, Some(-18.0)); + } + + /// A paragraph style's `w:numPr` (inherited through `w:basedOn`) makes its paragraphs a list; + /// numId 0 on a paragraph takes it out of the list. + #[test] + fn style_lists_and_num_id_zero() { + let numbering = format!( + r#"<w:numbering {W}> + <w:abstractNum w:abstractNumId="7"> + <w:lvl w:ilvl="0"><w:numFmt w:val="bullet"/><w:lvlText w:val=""/><w:rPr><w:rFonts w:ascii="Symbol" w:hAnsi="Symbol"/></w:rPr></w:lvl> + <w:lvl w:ilvl="1"><w:numFmt w:val="bullet"/><w:lvlText w:val="o"/><w:rPr><w:rFonts w:ascii="Courier New"/></w:rPr></w:lvl> + </w:abstractNum> + <w:num w:numId="5"><w:abstractNumId w:val="7"/></w:num> + </w:numbering>"# + ); + let styles = format!( + r#"<w:styles {W}> + <w:style w:type="paragraph" w:styleId="ListBullet"><w:name w:val="List Bullet"/><w:pPr><w:numPr><w:numId w:val="5"/></w:numPr></w:pPr></w:style> + <w:style w:type="paragraph" w:styleId="ListBullet2"><w:name w:val="List Bullet 2"/><w:basedOn w:val="ListBullet"/> + <w:pPr><w:numPr><w:ilvl w:val="1"/></w:numPr></w:pPr></w:style> + </w:styles>"# + ); + let styled = + |id: &str, extra: &str, text: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{id}"/>{extra}</w:pPr><w:r><w:t>{text}</w:t></w:r></w:p>"#); + let body = [ + styled("ListBullet", "", "one"), + styled("ListBullet2", "", "two"), + styled("ListBullet", r#"<w:numPr><w:ilvl w:val="0"/><w:numId w:val="0"/></w:numPr>"#, "not a list"), + ]; + let st = lists_docx(&numbering, &styles, &body).story; + assert_eq!(label(&st, 0, 1), "•\t"); + assert_eq!(label(&st, 1, 1), "◦\t"); + assert_eq!(st.paras[2].para.list_type, None); + assert_eq!(st.paras[2].style, "List Bullet"); + } + + /// Hostile list definitions: missing abstractNums and levels, huge or negative levels and + /// starts, a long label, a `w:basedOn` cycle. No panic; what can't be a list is plain. + #[test] + fn hostile_numbering_falls_back_to_plain_paragraphs() { + let long = "x".repeat(10_000); + let numbering = format!( + r#"<w:numbering {W}> + <w:abstractNum w:abstractNumId="1"> + <w:lvl w:ilvl="0"><w:start w:val="99999999999999"/><w:numFmt w:val="decimal"/><w:lvlText w:val="%1{long}%9%"/></w:lvl> + <w:lvl w:ilvl="99999999999999"><w:numFmt w:val="decimal"/></w:lvl> + <w:lvl w:ilvl="-1"><w:numFmt w:val="decimal"/></w:lvl> + <w:lvl w:ilvl="3"><w:start w:val="-5"/><w:numFmt w:val="ordinalText"/><w:lvlText w:val="%4^"/><w:lvlRestart w:val="99"/></w:lvl> + </w:abstractNum> + <w:num w:numId="1"><w:abstractNumId w:val="1"/></w:num> + <w:num w:numId="2"><w:abstractNumId w:val="404"/></w:num> + <w:num w:numId="3"/> + <w:num><w:abstractNumId w:val="1"/></w:num> + </w:numbering>"# + ); + let styles = format!( + r#"<w:styles {W}> + <w:style w:type="paragraph" w:styleId="A"><w:name w:val="A"/><w:basedOn w:val="B"/></w:style> + <w:style w:type="paragraph" w:styleId="B"><w:name w:val="B"/><w:basedOn w:val="A"/></w:style> + </w:styles>"# + ); + let body = [ + list_p("2", None, "missing abstractNum"), + list_p("3", None, "num without abstractNum"), + list_p("404", None, "unknown numId"), + list_p("1", Some("99999999999999"), "huge level"), + list_p("1", Some("9"), "level 9"), + list_p("1", Some("-1"), "negative level"), + list_p("1", Some("1"), "undefined level"), + r#"<w:p><w:pPr><w:pStyle w:val="A"/><w:numPr><w:ilvl w:val="0"/></w:numPr></w:pPr><w:r><w:t>cycle</w:t></w:r></w:p>"#.to_string(), + list_p("1", None, "huge start"), + list_p("1", Some("3"), "negative start"), + ]; + let st = lists_docx(&numbering, &styles, &body).story; + assert!(st.paras[..8].iter().all(|p| p.para.list_type.is_none()), "{:?}", st.paras); + assert_eq!(st.paras[8].para.start_at, Some(Some(u32::MAX))); + let expr = st.paras[8].para.number_expression.clone().unwrap_or_default(); + assert!(expr.starts_with("^#x") && expr.chars().count() < 80, "{expr}"); + // A start below 0 counts from 0, which the model shows as 1; `^` in a label is literal. + assert_eq!(label(&st, 9, 1), "1^\t"); + // Unreadable list definitions leave plain paragraphs and a warning, not a failed import. + let document = format!(r#"<w:document {W}><w:body>{}</w:body></w:document>"#, list_p("1", None, "x")); + let i = import(&docx_parts(&[("word/document.xml", &document), ("word/numbering.xml", "<w:numbering></w:num>")])).unwrap(); + assert_eq!(i.story.paras[0].para.list_type, None); + assert_eq!(i.warnings.len(), 1); + } } diff --git a/crates/textimport/src/docx_numbering.rs b/crates/textimport/src/docx_numbering.rs new file mode 100644 index 000000000..31b855b09 --- /dev/null +++ b/crates/textimport/src/docx_numbering.rs @@ -0,0 +1,325 @@ +//! Word lists: `word/numbering.xml` and the `w:numPr` of paragraphs and paragraph styles. +//! +//! A `w:num` (numId) points at a `w:abstractNum`, whose `w:lvl` elements (levels 0–8) give +//! each level's format, label text, start number, indents and the text after the label. +//! +//! Numbering follows Word's main rules, simplified: +//! - Numbers count per level. A paragraph at level L restarts the levels below it (unless a +//! level's `w:lvlRestart` says otherwise). +//! - `num` instances of one abstractNum share their counters, so a list carries on across +//! them and across paragraphs that aren't in a list. A `num` with a `w:startOverride` counts +//! on its own, starting at the override. +//! - Parent levels in a label (`%1.%2.`) are written as text: the model's labels number only +//! the paragraph's own level. + +use std::collections::HashMap; + +use designcraft_doc::{ListType, NumberStyle, ParaAttrs}; + +use crate::docx::El; + +/// Most `w:abstractNum` or `w:num` elements read; the rest are ignored. +pub(crate) const MAX_DEFS: usize = 10_000; +/// Word's list levels are 0–8. +const LEVELS: usize = 9; +/// Longest label text (`w:lvlText`) kept, in characters. +const MAX_LVL_TEXT: usize = 64; + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +enum Format { + #[default] + Bullet, + /// No number: the label is the level text alone. + Nothing, + Number(NumberStyle), +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +enum Suffix { + #[default] + Tab, + Space, + Nothing, +} + +#[derive(Clone, Debug, Default)] +struct Level { + format: Format, + text: String, + start: u32, + suffix: Suffix, + left: Option<f64>, + /// First-line indent in points (negative for a hanging indent). + first_line: Option<f64>, + /// `w:lvlRestart`: 0 = never restart, n = restart after level n (1-based) or a higher one. + restart: Option<u32>, + /// `w:isLgl`: parent levels in the label are written as decimal numbers. + legal: bool, + /// The font of the label (`w:rPr/w:rFonts`), which decides what a Symbol/Wingdings bullet is. + font: Option<String>, + picture: bool, +} + +#[derive(Debug, Default)] +struct Num { + abstract_id: String, + /// `w:lvlOverride` per level: a start override and/or a replacement level. + start_override: [Option<u32>; LEVELS], + level_override: [Option<Level>; LEVELS], +} + +impl Num { + fn has_start_override(&self) -> bool { + self.start_override.iter().any(Option::is_some) + } +} + +/// A list paragraph: the paragraph attributes that make it one, and its number in Word. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct ListPara { + pub(crate) attrs: ParaAttrs, + /// The paragraph's number at its level (also counted for bullets). + pub(crate) number: u32, +} + +/// The lists of a Word document and the numbering state while its paragraphs are read in order. +#[derive(Debug, Default)] +pub(crate) struct Numbering { + abstracts: HashMap<String, [Option<Level>; LEVELS]>, + nums: HashMap<String, Num>, + /// Current number per level, per counter key (an abstractNum, or a `num` with overrides). + counters: HashMap<String, [Option<u32>; LEVELS]>, +} + +fn level_index(e: &El) -> Option<usize> { + e.attr("ilvl").and_then(|v| v.trim().parse::<usize>().ok()).filter(|l| *l < LEVELS) +} + +fn parse_u32(v: Option<String>) -> Option<u32> { + let v = v?; + let v = v.trim(); + // Negative starts count from 0; numbers too large for a u32 are clamped. + match v.parse::<i64>() { + Ok(n) => Some(u32::try_from(n.max(0)).unwrap_or(u32::MAX)), + Err(_) => None, + } +} + +fn number_style(fmt: &str) -> Format { + match fmt { + "bullet" => Format::Bullet, + "none" => Format::Nothing, + "decimalZero" => Format::Number(NumberStyle::ArabicLeadingZero), + "upperRoman" => Format::Number(NumberStyle::UpperRoman), + "lowerRoman" => Format::Number(NumberStyle::LowerRoman), + "upperLetter" => Format::Number(NumberStyle::UpperLetters), + "lowerLetter" => Format::Number(NumberStyle::LowerLetters), + "chicago" => Format::Number(NumberStyle::Symbols), + // decimal and every format the model has no style for (ordinal, cardinalText, + // decimalEnclosedCircle, the East Asian counting systems, …). + _ => Format::Number(NumberStyle::Arabic), + } +} + +fn parse_level(e: &El) -> Level { + let tw = |v: Option<&str>| v.and_then(|v| v.trim().parse::<f64>().ok()).filter(|v| v.is_finite()).map(|v| v / 20.0); + let ind = e.child("pPr").and_then(|p| p.child("ind")); + Level { + format: e.val("numFmt").map_or(Format::Number(NumberStyle::Arabic), |f| number_style(&f)), + text: e.child("lvlText").and_then(|t| t.attr("val")).unwrap_or_default().chars().take(MAX_LVL_TEXT).collect(), + start: parse_u32(e.val("start")).unwrap_or(1), + suffix: match e.val("suff").as_deref() { + Some("space") => Suffix::Space, + Some("nothing") => Suffix::Nothing, + _ => Suffix::Tab, + }, + left: ind.and_then(|i| tw(i.attr("left").or(i.attr("start")))), + first_line: ind.and_then(|i| tw(i.attr("firstLine")).or(tw(i.attr("hanging")).map(|h| -h))), + restart: parse_u32(e.val("lvlRestart")), + legal: e.on("isLgl") == Some(true), + font: e.child("rPr").and_then(|r| r.child("rFonts")).and_then(|f| f.attr("ascii").or(f.attr("hAnsi"))).map(str::to_string), + picture: e.child("lvlPicBulletId").is_some(), + } +} + +/// The bullet Word shows for `text` in `font`. Symbol and Wingdings bullets are stored as +/// private-use characters (U+F0xx) or as the font's own code points; the common ones become +/// their Unicode look-alikes, unknown ones a plain bullet. +fn bullet_char(text: &str, font: Option<&str>) -> String { + let font = font.unwrap_or_default().to_ascii_lowercase(); + let symbol = font.contains("symbol"); + let wingdings = font.contains("wingdings"); + text.chars() + .map(|c| { + let code = u32::from(c); + let low = if (0xF000..=0xF0FF).contains(&code) { code - 0xF000 } else { code }; + if wingdings { + return match low { + 0x6C => '●', + 0x6E => '■', + 0x71 => '❑', + 0x76 => '❖', + 0xA7 => '▪', + 0xD8 => '➢', + 0xFC => '✓', + _ => '•', + }; + } + if symbol { + return match low { + 0x2D => '–', + 0x6F => '◦', + _ => '•', + }; + } + match c { + // Word's second-level bullet is a Courier New "o". + 'o' => '◦', + '\u{E000}'..='\u{F8FF}' => '•', + c => c, + } + }) + .filter(|c| *c != '\n' && *c != '\r') + .collect() +} + +impl Numbering { + /// Read `numbering.xml`'s root (`w:numbering`). + pub(crate) fn parse(root: &El) -> Self { + let mut n = Numbering::default(); + for e in root.els() { + match e.name.as_str() { + "abstractNum" if n.abstracts.len() < MAX_DEFS => { + let Some(id) = e.attr("abstractNumId") else { continue }; + let mut levels: [Option<Level>; LEVELS] = Default::default(); + for l in e.els().filter(|l| l.name == "lvl") { + if let Some(slot) = level_index(l).and_then(|i| levels.get_mut(i)) { + *slot = Some(parse_level(l)); + } + } + n.abstracts.insert(id.to_string(), levels); + } + "num" if n.nums.len() < MAX_DEFS => { + let (Some(id), Some(abstract_id)) = (e.attr("numId"), e.val("abstractNumId")) else { continue }; + let mut num = Num { abstract_id, ..Default::default() }; + for o in e.els().filter(|o| o.name == "lvlOverride") { + let Some(i) = level_index(o) else { continue }; + if let Some(slot) = num.start_override.get_mut(i) { + *slot = parse_u32(o.val("startOverride")); + } + if let (Some(slot), Some(l)) = (num.level_override.get_mut(i), o.child("lvl")) { + *slot = Some(parse_level(l)); + } + } + n.nums.insert(id.to_string(), num); + } + _ => {} + } + } + n + } + + /// The list formatting of the next paragraph with `w:numId` `num_id` at level `ilvl`, counting + /// it. `None` (a plain paragraph) for numId 0, an unknown list or level, or a level past 8. + pub(crate) fn next(&mut self, num_id: &str, ilvl: u32) -> Option<ListPara> { + let lv = usize::try_from(ilvl).ok().filter(|l| *l < LEVELS)?; + let num = self.nums.get(num_id)?; + let abs = self.abstracts.get(&num.abstract_id)?; + let level = |k: usize| num.level_override.get(k).and_then(Option::as_ref).or_else(|| abs.get(k).and_then(Option::as_ref)); + let def = level(lv)?; + let own = num.has_start_override(); + let key = if own { format!("n:{num_id}") } else { format!("a:{}", num.abstract_id) }; + let start = |k: usize| { + let o = if own { num.start_override.get(k).copied().flatten() } else { None }; + o.or_else(|| level(k).map(|l| l.start)).unwrap_or(1) + }; + let counters = self.counters.entry(key).or_default(); + let n = counters.get(lv).copied().flatten().map_or_else(|| start(lv), |v| v.saturating_add(1)); + if let Some(c) = counters.get_mut(lv) { + *c = Some(n); + } + for (m, c) in counters.iter_mut().enumerate().skip(lv + 1) { + let restart = match level(m).and_then(|l| l.restart) { + None => true, + Some(0) => false, + Some(r) => ilvl < r, + }; + if restart { + *c = None; + } + } + // Parent levels' current numbers, as text, for the label. + let parent = |k: usize| -> String { + let v = counters.get(k).copied().flatten().unwrap_or_else(|| start(k)); + match level(k).map(|l| l.format) { + Some(Format::Number(s)) if !def.legal => s.format(v), + Some(Format::Number(_)) => v.to_string(), + _ => String::new(), + } + }; + let label = |own_level: &str, escape: bool| -> String { + let mut out = String::new(); + let mut it = def.text.chars().peekable(); + while let Some(c) = it.next() { + if c == '%' + && let Some(k) = it.peek().and_then(|d| d.to_digit(10)).filter(|d| (1..=9).contains(d)) + { + it.next(); + let k = k as usize - 1; + if k == lv { + out.push_str(own_level); + } else { + let p = parent(k); + out.push_str(&if escape { p.replace('^', "^^") } else { p }); + } + } else if escape && c == '^' { + out.push_str("^^"); + } else if c != '\n' && c != '\r' { + out.push(c); + } + } + out + }; + // The model's defaults: `^t` after a number, a literal tab after a bullet. + let (number_sep, bullet_sep) = match def.suffix { + Suffix::Tab => ("^t", "\t"), + Suffix::Space => (" ", " "), + Suffix::Nothing => ("", ""), + }; + let mut a = ParaAttrs::default(); + match def.format { + Format::Number(style) => { + a.list_type = Some(ListType::Numbers); + a.number_style = Some(style); + a.number_expression = Some(format!("{}{number_sep}", label("^#", true))); + } + Format::Bullet => { + a.list_type = Some(ListType::Bullets); + a.bullet_char = Some(if def.picture { "•".into() } else { bullet_char(&def.text, def.font.as_deref()) }); + a.list_separator = Some(bullet_sep.into()); + } + Format::Nothing => { + a.list_type = Some(ListType::Bullets); + a.bullet_char = Some(label("", false)); + a.list_separator = Some(bullet_sep.into()); + } + } + a.left_indent = def.left; + a.first_line_indent = def.first_line; + Some(ListPara { attrs: a, number: n }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn symbol_and_wingdings_bullets_become_unicode() { + assert_eq!(bullet_char("\u{F0B7}", Some("Symbol")), "•"); + assert_eq!(bullet_char("\u{F0A7}", Some("Wingdings")), "▪"); + assert_eq!(bullet_char("o", Some("Courier New")), "◦"); + assert_eq!(bullet_char("–", None), "–"); + assert_eq!(bullet_char("\u{F076}", None), "•"); + } +} diff --git a/crates/textimport/src/lib.rs b/crates/textimport/src/lib.rs index 22f79c513..ba9cb92e3 100644 --- a/crates/textimport/src/lib.rs +++ b/crates/textimport/src/lib.rs @@ -1,6 +1,6 @@ //! Text import (File › Place of text files): plain text, Word documents (`.docx`) and RTF become //! a frameless [`Story`] with paragraph and character styles, local bold/italic/underline/size, -//! footnotes (Word) and tables (Word), ready to flow into text frames. +//! footnotes, tables and bullets/numbering (Word), ready to flow into text frames. //! //! Word styles are imported by name (their font, size, bold/italic and space before/after become //! the style definition, like InDesign's "Use Word styles" import option). RTF keeps bold, @@ -9,6 +9,7 @@ #![forbid(unsafe_code)] mod docx; +mod docx_numbering; pub mod export; mod rtf; pub mod tagged; diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index fc8fbc3a5..ae8f8c919 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -60,7 +60,7 @@ score below). | EPS | `.eps` | ✓ (PostScript) | ~ preview only (no interpreter) | | <1% | | SVG | `.svg` | ✓ | ✓ (usvg) | | <1% | | HEIC | `.heic` | ✓ | ✗ | | <1% | -| Word | `.docx` | ✓ styles, footnotes, tables, track changes, options | ✓ styles by name, footnotes, tables, tabs, breaks, moved text (#257), style mapping and conflicts | `.doc` not supported (InDesign reads it) | 2% | +| Word | `.docx` | ✓ styles, footnotes, tables, track changes, options | ✓ styles by name, footnotes, tables, tabs, breaks, moved text (#257), bullets and numbering from `numbering.xml` (#626; parent levels in a label are fixed text), style mapping and conflicts | `.doc` not supported (InDesign reads it) | 2% | | RTF | `.rtf` | ✓ | ✓ | | <1% | | Excel | `.xlsx`, `.xls` | ✓ | ~ `.xlsx` first sheet's used range | no sheet/range options; `.xls` ✗ | <1% | | Plain text, Markdown | `.txt`, `.md` | ✓ (txt) | ✓ | | <1% | From de31bd5647f86d0a2c7c45ec1d08bbc7a3eeef73 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <marcoarnulfo1@gmail.com> Date: Mon, 12 Oct 2026 23:03:15 +0200 Subject: [PATCH 104/238] fix(fonts): fall back to a font made for Malayalam (part of #628) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Malayalam took the first installed file mapping it: on macOS Arial Unicode MS, which has no Malayalam shaping, so conjuncts like ക്ഷ fell apart. Fonts made for Malayalam are tried first, loaded on demand through the CJK chain lookup. --- crates/compose/src/tests.rs | 17 +++++++++++++++ crates/fonts/src/fontdb.rs | 33 +++++++++++++++++++++++++----- crates/fonts/src/tests_fallback.rs | 30 +++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 5 deletions(-) diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index f5d0113c1..dd67c6855 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1835,6 +1835,23 @@ fn cjk_fallback_follows_the_language() { assert_eq!(Some(han.family.clone()), db.fallback_for('直', primary.id(), None).map(|f| f.family.clone())); } +#[test] +fn malayalam_falls_back_to_a_font_that_forms_its_conjuncts() { + // Run against the installed fonts: on macOS the first file mapping Malayalam is Arial + // Unicode MS, which leaves ക്ഷ as three glyphs. + let db = designcraft_fonts::FontDb::global(); + if !["Malayalam Sangam MN", "Malayalam MN", "Nirmala UI", "Noto Sans Malayalam"].iter().any(|f| db.has_family(f)) { + return; + } + let text = "ക്ഷ"; + let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs::default()); + d.settings.glyph_fallback = true; + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let glyphs: Vec<_> = all_lines(&cs).iter().flat_map(|l| l.glyphs.iter()).map(|g| (g.face.family.clone(), g.gid)).collect(); + assert_eq!(glyphs.len(), 1, "one conjunct: {glyphs:?}"); + assert_ne!(glyphs[0].1, 0); +} + #[test] fn missing_glyphs_are_the_fonts_box_unless_fallback_is_on() { use designcraft_fonts::testing::font_with; diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 14441e144..294cf34f3 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -435,6 +435,29 @@ const KOREAN_FALLBACKS: &[&str] = &[ "Source Han Sans K", ]; +/// Fonts made for Malayalam (macOS, Windows, Noto, Linux). A font that only maps the characters +/// (Arial Unicode MS, first by path on macOS) sets them without conjuncts or vowel signs. +const MALAYALAM_FALLBACKS: &[&str] = &[ + "Malayalam Sangam MN", + "Malayalam MN", + "Nirmala UI", + "Kartika", + "Noto Sans Malayalam", + "Noto Serif Malayalam", + "Manjari", + "Rachana", + "Meera", + "Lohit Malayalam", +]; + +/// The fallback chain of a script that only fonts made for it set well, whatever the language. +fn script_chain(c: char) -> Option<&'static [&'static str]> { + match c as u32 { + 0x0D00..=0x0D7F => Some(MALAYALAM_FALLBACKS), + _ => None, + } +} + /// The Japanese fallback chain: the craft-fonts Japanese faces' families in document order /// (Mincho first; none without craft-fonts), then [`JAPANESE_FALLBACKS`]. fn japanese_chain() -> &'static [&'static str] { @@ -1375,12 +1398,12 @@ impl ScopedFonts<'_> { } /// A face other than `exclude` that covers `c` in `language` (a BCP 47 tag, `None` when - /// unknown). CJK characters try their language's chain first (see [`cjk_chain`]); then the - /// first face (fallback family first, then load order, the document's fonts last) that covers - /// `c`. On native, system fonts are cataloged and loaded lazily the first time no loaded face - /// covers a character. + /// unknown). CJK characters try their language's chain first (see [`cjk_chain`]), Malayalam + /// the fonts made for it (see [`script_chain`]); then the first face (fallback family first, + /// then load order, the document's fonts last) that covers `c`. On native, system fonts are + /// cataloged and loaded lazily the first time no loaded face covers a character. pub fn fallback_for(&self, c: char, exclude: u32, language: Option<&str>) -> Option<Arc<FontFace>> { - if let Some(chain) = cjk_chain(c, language) + if let Some(chain) = cjk_chain(c, language).or_else(|| script_chain(c)) && let Some(f) = chain.iter().find_map(|family| self.chain_face(family, c, exclude)) { return Some(f); diff --git a/crates/fonts/src/tests_fallback.rs b/crates/fonts/src/tests_fallback.rs index 4d81f9a5b..d9e27ac62 100644 --- a/crates/fonts/src/tests_fallback.rs +++ b/crates/fonts/src/tests_fallback.rs @@ -127,3 +127,33 @@ fn chain_fonts_load_from_the_font_folders_when_first_needed() { assert_eq!(family(&db, HAN, Some("ko")).as_deref(), Some(first_loaded("Other CJK"))); assert!(!db.is_loaded("AppleMyungjo")); } + +const KA: char = 'ക'; +const VIRAMA: char = '\u{0D4D}'; +const AA: char = '\u{0D3E}'; + +#[test] +fn malayalam_takes_a_font_made_for_it() { + // Arial Unicode MS maps Malayalam but can't shape it (no conjuncts); loaded first, it won. + let db = db_with(&[("Arial Unicode MS", &[KA, VIRAMA, AA]), ("Malayalam Sangam MN", &[KA, VIRAMA, AA])]); + for c in [KA, VIRAMA, AA] { + for language in [None, Some("en-US"), Some("ml")] { + assert_eq!(family(&db, c, language).as_deref(), Some("Malayalam Sangam MN"), "{c:?} {language:?}"); + } + } + // Past the Malayalam fonts, the language-blind search as before. + let sangam = db.face("Malayalam Sangam MN", "Regular"); + assert_eq!(db.fallback_for(KA, sangam.id(), None).map(|f| f.family.clone()).as_deref(), Some("Arial Unicode MS")); +} + +#[test] +fn malayalam_fonts_load_from_the_font_folders_when_first_needed() { + let dir = std::env::temp_dir().join(format!("dc-malayalam-fallback-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("noto.ttf"), font_with("Noto Sans Malayalam", &[KA]).unwrap()).unwrap(); + let db = FontDb::with_font_dirs(vec![dir]); + db.add_font(font_with("Arial Unicode MS", &[KA]).unwrap()); + assert!(!db.is_loaded("Noto Sans Malayalam")); + assert_eq!(family(&db, KA, None).as_deref(), Some("Noto Sans Malayalam")); +} From 270dd46b5062c8295a9103fedbe934ac83f99f27 Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Mon, 12 Oct 2026 21:06:42 +0000 Subject: [PATCH 105/238] Text: stroke behind fill (#619) A character attribute strokeBehindFill paints a text stroke under the fill at double width, so its full weight lies outside the letters and they keep their shape. Screen and PNG rendering, PDF export (stroke as outlines first, then the filled text, so the text is not duplicated; clipped to the outside of the glyphs when there is no fill), Create Outlines (outside- aligned stroke), the Properties panel and Character Style Options. IDML StrokeAlignment on CharacterStyleRange, CharacterStyle and ParagraphStyle imports and exports as the attribute (OutsideAlignment = on, CenterAlignment = off). --- ROADMAP.md | 3 +- crates/compose/src/lib.rs | 2 + crates/compose/src/shape.rs | 1 + crates/doc/src/attrs.rs | 17 ++++ crates/engine/src/cmd/paths.rs | 10 ++- crates/idml/src/export.rs | 3 + crates/idml/src/import.rs | 6 ++ crates/idml/src/tests.rs | 19 +++++ crates/pdf/src/tests.rs | 61 ++++++++++++++ crates/pdf/src/text.rs | 99 +++++++++++++++++------ crates/render/src/text.rs | 96 +++++++++++++--------- crates/render/tests/text_stroke_behind.rs | 63 +++++++++++++++ crates/ui-egui/src/dialogs.rs | 3 + crates/ui-egui/src/panels/properties.rs | 4 + docs/typography-parity.md | 3 +- 15 files changed, 324 insertions(+), 66 deletions(-) create mode 100644 crates/render/tests/text_stroke_behind.rs diff --git a/ROADMAP.md b/ROADMAP.md index f826a3767..6106d978f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **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 (orderly native exit recovery) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text stroke behind fill) · **Target:** Adobe InDesign 2026 DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-12** · Text strokes can sit behind the fill: the stroke lies wholly outside the letters at its full weight on screen, in PNG and in PDF, set from the Properties panel or Character Style Options. IDML `StrokeAlignment` on text imports and exports as this setting, so imported text with outside-aligned strokes keeps its letter shapes (#619). No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. - **2026-10-12** · Book numbering and style updates prepare every chapter before saving. Only closed native chapters are changed; imported IDML remains a read-only style/PDF source. If a later disk write fails, the error names the completed chapters; the batch is not a whole-book transaction. No readiness percentage change. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 3dd7c25b3..2c3e7ccc0 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -50,6 +50,8 @@ pub struct RunStyle { pub stroke: String, pub stroke_tint: f32, pub stroke_weight: f64, + /// The stroke goes under the fill at double width, so its full weight shows outside the glyphs. + pub stroke_behind_fill: bool, pub underline: bool, pub strikethrough: bool, /// The underline and strikethrough bars (Underline / Strikethrough Options resolved). diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index 1e1af40a7..a781a467d 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -138,6 +138,7 @@ impl StyleTable<'_> { stroke: p.stroke.clone(), stroke_tint: p.stroke_tint, stroke_weight: p.stroke_weight, + stroke_behind_fill: p.stroke_behind_fill, underline: p.underline, strikethrough: p.strikethrough, underline_rule: crate::Rule { diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index b99740e2c..6a0fe4d1f 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -735,6 +735,9 @@ attr_set! { stroke: String = "[None]".into(), stroke_tint: f32 = 1.0, stroke_weight: f64 = 1.0, + /// Stroke behind fill: the stroke is painted under the fill and lies wholly outside the + /// letters at its full weight, so the letters keep their shape. Off, it straddles the outline. + stroke_behind_fill: bool = false, capitalization: Capitalization = Capitalization::Normal, position: Position = Position::Normal, underline: bool = false, @@ -961,6 +964,20 @@ mod tests { assert_eq!(a.rule_below, None, "null clears the override"); } + #[test] + fn stroke_behind_fill_is_saved_only_when_set() { + assert!(!CharProps::default().stroke_behind_fill); + assert_eq!(serde_json::to_value(CharAttrs::default()).unwrap(), serde_json::json!({})); + let mut a = CharAttrs::default(); + a.set_json("strokeBehindFill", &serde_json::json!(true)).unwrap(); + let v = serde_json::to_value(&a).unwrap(); + assert_eq!(v, serde_json::json!({"strokeBehindFill": true})); + assert_eq!(serde_json::from_value::<CharAttrs>(v).unwrap(), a); + let mut p = CharProps::default(); + p.apply(&a); + assert!(p.stroke_behind_fill); + } + #[test] fn digits_map_to_their_script() { assert_eq!(Digits::Default.map('7', "Arabic"), '7'); diff --git a/crates/engine/src/cmd/paths.rs b/crates/engine/src/cmd/paths.rs index b9d479067..53eb4aff0 100644 --- a/crates/engine/src/cmd/paths.rs +++ b/crates/engine/src/cmd/paths.rs @@ -246,7 +246,7 @@ fn create_outlines(s: &mut Session, p: &Value) -> Result<Value> { let cs = designcraft_compose::compose_story(d, tf.story, &Default::default()); let Some(ft) = cs.frame(*id) else { continue }; // One path per text look (fill, stroke). - let mut runs: Vec<((String, f32, String, f32, f64), BezPath)> = Vec::new(); + let mut runs: Vec<((String, f32, String, f32, f64, bool), BezPath)> = Vec::new(); for l in &ft.lines { for g in l.glyphs.iter().filter(|g| g.visible) { let st = &cs.styles[g.style as usize]; @@ -256,7 +256,7 @@ fn create_outlines(s: &mut Session, p: &Value) -> Result<Value> { } let skew = if st.skew != 0.0 { Affine::new([1.0, 0.0, -st.skew.to_radians().tan(), 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; let a = Affine::translate((g.x, l.baseline + g.y)) * skew * Affine::scale_non_uniform(g.sx, g.sy); - let key = (st.fill.clone(), st.fill_tint, st.stroke.clone(), st.stroke_tint, st.stroke_weight); + let key = (st.fill.clone(), st.fill_tint, st.stroke.clone(), st.stroke_tint, st.stroke_weight, st.stroke_behind_fill); let k = match runs.iter().position(|r| r.0 == key) { Some(k) => k, None => { @@ -274,14 +274,16 @@ fn create_outlines(s: &mut Session, p: &Value) -> Result<Value> { let (sr, _) = spread_xf(d, *id).ok_or(designcraft_doc::DocError::NoItem(*id))?; let at = d.spread(sr).and_then(|sp| sp.items.iter().position(|i| i.id == *id)); let text_xf = d.text_xf(&it); - let make = |d: &mut Document, ((fill, ft, stroke, stt, sw), bp): ((String, f32, String, f32, f64), BezPath)| { + let make = |d: &mut Document, ((fill, ft, stroke, stt, sw, behind), bp): ((String, f32, String, f32, f64, bool), BezPath)| { let mut o = Item::new(ItemId(d.alloc()), it.layer, Shape::Path, PathData::from_bezpath(&bp)); o.xf = text_xf; o.fill = Fill { swatch: fill, tint: ft, ..Fill::none() }; o.stroke = if stroke == designcraft_color::swatch::NONE { Stroke::none() } else { - Stroke { swatch: stroke, tint: stt, weight: sw, ..Stroke::default() } + // A stroke behind the fill lies wholly outside the letters, as an outside-aligned stroke. + let align = if behind { designcraft_doc::StrokeAlign::Outside } else { designcraft_doc::StrokeAlign::Center }; + Stroke { swatch: stroke, tint: stt, weight: sw, align, ..Stroke::default() } }; o }; diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 9d73562b9..0800fa2b2 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1117,6 +1117,9 @@ impl<'a> Ex<'a> { if let Some(v) = a.stroke_weight { el.set("StrokeWeight", num(v)); } + if let Some(v) = a.stroke_behind_fill { + el.set("StrokeAlignment", if v { "OutsideAlignment" } else { "CenterAlignment" }); + } if let Some(v) = a.capitalization { el.set("Capitalization", names::caps_out(v)); } diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 46c3253b6..4b8cbf45d 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1172,6 +1172,12 @@ impl<'r> Importer<'r> { } a.stroke_tint = tint(e.num("StrokeTint")); a.stroke_weight = e.num("StrokeWeight"); + // Text strokes are centred on the outline or lie outside it, behind the fill. + a.stroke_behind_fill = e.prop("StrokeAlignment").and_then(|v| match v.trim() { + "OutsideAlignment" => Some(true), + "CenterAlignment" => Some(false), + _ => None, + }); a.capitalization = e.prop("Capitalization").and_then(|v| names::caps_in(v.trim())); a.position = e.prop("Position").and_then(|v| names::position_in(v.trim())); a.underline = e.boolean("Underline"); diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 23ec99bdf..e4c6e9f92 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -842,6 +842,25 @@ fn cjk_character_attributes_import_from_independent_xml_and_round_trip() { assert_eq!(a.leading_model, b.leading_model); } +#[test] +fn text_stroke_alignment_imports_as_stroke_behind_fill_and_round_trips() { + let story = r#"<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging"><Story Self="s1"> + <ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/$ID/[No paragraph style]"> + <CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]" StrokeColor="Color/Black" StrokeWeight="2" StrokeAlignment="OutsideAlignment"><Content>Behind</Content></CharacterStyleRange> + <CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]" StrokeColor="Color/Black" StrokeAlignment="CenterAlignment"><Content>Over</Content></CharacterStyleRange> + <CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content>Plain</Content></CharacterStyleRange> + </ParagraphStyleRange></Story></idPkg:Story>"#; + let flags = |d: &Document| -> Vec<(String, Option<bool>)> { + let st = d.stories.values().find(|s| s.text.contains("Behind")).unwrap(); + st.runs().map(|(r, f)| (st.text[r].to_string(), f.over.stroke_behind_fill)).filter(|(t, _)| !t.trim().is_empty()).collect() + }; + let want = vec![("Behind".to_string(), Some(true)), ("Over".to_string(), Some(false)), ("Plain".to_string(), None)]; + let doc = import_idml(&fixture_with_story(story)).unwrap(); + assert_eq!(flags(&doc), want); + let back = import_idml(&export_idml(&doc)).unwrap(); + assert_eq!(flags(&back), want); +} + #[test] fn warichu_imports_from_idml_and_round_trips() { let story = r#"<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging"><Story Self="s1"> diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 9ac53d057..234e72a36 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -402,6 +402,67 @@ fn drawn(bytes: &[u8]) -> (Vec<(String, kurbo::Rect)>, Vec<kurbo::Rect>) { (rec.0, rec.1) } +/// The page's paint operations in order: `fill`/`stroke` + ` text`/` path` and the stroke width. +fn paint_order(bytes: &[u8]) -> Vec<(String, f32)> { + use hayro_interpret::font::Glyph; + use hayro_interpret::{ + BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask, + interpret_page, + }; + #[derive(Default)] + struct Rec(Vec<(String, f32)>); + impl Device<'_> for Rec { + fn set_soft_mask(&mut self, _: Option<SoftMask<'_>>) {} + fn set_blend_mode(&mut self, _: BlendMode) {} + fn draw_path(&mut self, _: &kurbo::BezPath, _: kurbo::Affine, _: &Paint<'_>, m: &PathDrawMode) { + self.0.push(match m { + PathDrawMode::Fill(_) => ("fill path".into(), 0.0), + PathDrawMode::Stroke(p) => ("stroke path".into(), p.line_width), + }); + } + fn push_clip_path(&mut self, _: &ClipPath) {} + fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'_>>, _: BlendMode) {} + fn draw_glyph(&mut self, _: &Glyph<'_>, _: kurbo::Affine, _: kurbo::Affine, _: &Paint<'_>, m: &GlyphDrawMode) { + self.0.push(match m { + GlyphDrawMode::Fill => ("fill text".into(), 0.0), + GlyphDrawMode::Stroke(p) => ("stroke text".into(), p.line_width), + GlyphDrawMode::Invisible => ("invisible text".into(), 0.0), + }); + } + fn draw_image(&mut self, _: Image<'_, '_>, _: kurbo::Affine) {} + fn pop_clip_path(&mut self) {} + fn pop_transparency_group(&mut self) {} + } + let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).expect("parse"); + let cache = InterpreterCache::new(); + let page = &pdf.pages()[0]; + let mut ctx = Context::new(kurbo::Affine::IDENTITY, kurbo::Rect::new(0.0, 0.0, 1.0, 1.0), &cache, pdf.xref(), InterpreterSettings::default()); + let mut rec = Rec::default(); + interpret_page(page, &mut ctx, &mut rec); + rec.0 +} + +#[test] +fn stroke_behind_fill_strokes_the_run_before_filling_it() { + let export = |behind: bool| { + let mut d = doc_with_text("I"); + let sid = *d.stories.keys().next().unwrap(); + d.story_mut(sid).unwrap().format_chars(0..1, |f| { + f.over.stroke = Some(designcraft_color::swatch::BLACK.into()); + f.over.stroke_weight = Some(4.0); + f.over.stroke_behind_fill = Some(behind); + }); + let bytes = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap(); + let ops: Vec<(String, f32)> = paint_order(&bytes).into_iter().filter(|(op, w)| op.ends_with("text") || *w > 0.0).collect(); + (ops, extract_text(&bytes)[0].clone()) + }; + let (over, _) = export(false); + assert_eq!(over, [("fill text".to_string(), 0.0), ("stroke text".to_string(), 4.0)]); + let (behind, text) = export(true); + assert_eq!(behind, [("stroke path".to_string(), 8.0), ("fill text".to_string(), 0.0)], "the double-width stroke goes first"); + assert_eq!(text.trim(), "I", "the text is not duplicated"); +} + #[test] fn missing_glyphs_print_as_boxes() { // Source Serif 4 lacks 語: its .notdef box is drawn as a path, not as a .notdef glyph diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs index bf59efded..0cd4770a9 100644 --- a/crates/pdf/src/text.rs +++ b/crates/pdf/src/text.rs @@ -300,16 +300,53 @@ impl Exporter<'_> { let hs = g0.sx / g0.sy; let skew = if st.skew != 0.0 { Affine::new([1.0, 0.0, -st.skew.to_radians().tan(), 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; let origin = Affine::translate((g0.x, baseline + g0.y)) * skew * Affine::scale_non_uniform(hs, 1.0); + // Stroke behind fill: a double-width stroke first, as outlines so the text is not + // duplicated, then the fill over its inner half. With no fill, the stroke is clipped + // to the outside of the glyphs instead. + let behind = st.stroke_behind_fill && stroke.is_some(); + let weight = if behind { st.stroke_weight * 2.0 } else { st.stroke_weight }; + let mut clipped = false; + let stroke = if behind { + let outlines = glyph_outlines(glyphs, size); + if fill.is_some() { + self.paint_outlines(s, &outlines, origin, None, stroke, weight); + None + } else { + clipped = self.push_outside_clip(s, &outlines, origin, weight); + stroke + } + } else { + stroke + }; // A missing glyph is drawn as its box: PDF/A and PDF/UA forbid showing .notdef as text. if g0.gid == 0 { - self.outline_run(s, glyphs, origin, size, fill, stroke, st.stroke_weight); - return; - } - let Some(font) = self.font(&face) else { + self.paint_outlines(s, &glyph_outlines(glyphs, size), origin, fill, stroke, weight); + } else if let Some(font) = self.font(&face) { + self.text_run(s, glyphs, story, font, origin, size, hs, fill, stroke, weight); + } else { self.warn(format!("font `{} {}` could not be embedded; its text was drawn as outlines", face.family, face.style)); - self.outline_run(s, glyphs, origin, size, fill, stroke, st.stroke_weight); - return; - }; + self.paint_outlines(s, &glyph_outlines(glyphs, size), origin, fill, stroke, weight); + } + if clipped { + s.pop(); + } + } + + /// A run as real text. + #[allow(clippy::too_many_arguments)] + fn text_run( + &mut self, + s: &mut Surface, + glyphs: &[PlacedGlyph], + story: &str, + font: Font, + origin: Affine, + size: f64, + hs: f64, + fill: Option<krilla::color::Color>, + stroke: Option<krilla::color::Color>, + stroke_weight: f64, + ) { let (text, ranges) = self.run_text(glyphs, story); let k = 1.0 / (hs * size); let kg: Vec<KrillaGlyph> = glyphs @@ -323,36 +360,24 @@ impl Exporter<'_> { .collect(); s.push_transform(&tf(origin)); s.set_fill(fill.map(|c| solid_fill(c, 1.0))); - s.set_stroke(stroke.map(|c| Stroke { paint: c.into(), width: st.stroke_weight as f32, ..Default::default() })); + s.set_stroke(stroke.map(|c| Stroke { paint: c.into(), width: stroke_weight as f32, ..Default::default() })); s.draw_glyphs(Point::from_xy(0.0, 0.0), &kg, font, &text, size as f32, false); s.set_fill(None); s.set_stroke(None); s.pop(); } - /// Fallback for faces krilla can't embed: glyph outlines as paths. - #[allow(clippy::too_many_arguments)] - fn outline_run( + /// Glyph outlines as paths (faces krilla can't embed, missing glyphs, strokes behind fill). + fn paint_outlines( &mut self, s: &mut Surface, - glyphs: &[PlacedGlyph], + outlines: &BezPath, origin: Affine, - size: f64, fill: Option<krilla::color::Color>, stroke: Option<krilla::color::Color>, stroke_weight: f64, ) { - let db = FontDb::global(); - let g0 = &glyphs[0]; - let k = size / g0.face.upem; - let mut bp = BezPath::new(); - for g in glyphs { - let a = Affine::translate(((g.x - g0.x) / (g0.sx / g0.sy), 0.0)) * Affine::scale(k); - let mut o = (*db.outline(&g.face, g.gid)).clone(); - o.apply_affine(a); - bp.extend(o.iter()); - } - let Some(p) = to_path(&bp) else { return }; + let Some(p) = to_path(outlines) else { return }; s.push_transform(&tf(origin)); s.set_fill(fill.map(|c| solid_fill(c, 1.0))); s.set_stroke(stroke.map(|c| Stroke { paint: c.into(), width: stroke_weight as f32, ..Default::default() })); @@ -362,6 +387,17 @@ impl Exporter<'_> { s.pop(); } + /// Clip to everything outside the glyphs (a frame around them plus their outlines, + /// even-odd). False when there was nothing to clip with. + fn push_outside_clip(&mut self, s: &mut Surface, outlines: &BezPath, origin: Affine, weight: f64) -> bool { + let m = weight * 2.0 + 10.0; + let mut outside = outlines.bounding_box().inflate(m, m).to_path(0.1); + outside.extend(outlines.iter()); + let Some(p) = to_path(&(origin * outside)) else { return false }; + s.push_clip_path(&p, &krilla::paint::FillRule::EvenOdd); + true + } + /// One line of plain text in the bundled UI face (page information mark). pub(crate) fn plain_text(&mut self, s: &mut Surface, text: &str, at: (f64, f64), size: f64, color: krilla::color::Color) { let face = FontDb::global().face(designcraft_fonts::FALLBACK_FAMILY, "Regular"); @@ -391,3 +427,18 @@ impl Exporter<'_> { s.set_fill(None); } } + +/// A run's glyph outlines at `size`, relative to its first glyph's origin. +fn glyph_outlines(glyphs: &[PlacedGlyph], size: f64) -> BezPath { + let db = FontDb::global(); + let mut bp = BezPath::new(); + let Some(g0) = glyphs.first() else { return bp }; + let k = size / g0.face.upem; + for g in glyphs { + let a = Affine::translate(((g.x - g0.x) / (g0.sx / g0.sy), 0.0)) * Affine::scale(k); + let mut o = (*db.outline(&g.face, g.gid)).clone(); + o.apply_affine(a); + bp.extend(o.iter()); + } + bp +} diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 734ec275e..57103a234 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -358,32 +358,9 @@ impl Renderer { let lg = &fg.lines[i]; self.stats.glyphs += lg.glyphs; for (si, bp) in &lg.runs { - if let Some(c) = fill_of(*si) { - let st = &cs.styles[*si as usize]; - let op = crate::overprints(f, &st.fill, st.fill_tint, false); - if op { - ctx.push_layer( - None, - Some(vello_cpu::peniko::BlendMode::new(vello_cpu::peniko::Mix::Multiply, vello_cpu::peniko::Compose::SrcOver)), - None, - None, - None, - ); - } - ctx.set_paint(c); - ctx.fill_path(bp); - if op { - ctx.pop_layer(); - } - } let st = &cs.styles[*si as usize]; - if st.stroke != designcraft_color::swatch::NONE - && let Some(c) = doc.resolve_color(&st.stroke, st.stroke_tint) - { - ctx.set_paint(color_of(&c, 1.0)); - ctx.set_stroke(kurbo::Stroke::new(st.stroke_weight)); - ctx.stroke_path(bp); - } + let fill = fill_of(*si).map(|c| (c, crate::overprints(f, &st.fill, st.fill_tint, false))); + paint_glyphs(ctx, bp, fill, stroke_of(doc, st), st.stroke_weight, st.stroke_behind_fill); } } for &i in &shown { @@ -456,6 +433,62 @@ impl Renderer { } /// Kurbo stroke for a table edge. +/// A run's stroke colour, if it has one. +fn stroke_of(doc: &designcraft_doc::Document, st: &designcraft_compose::RunStyle) -> Option<peniko::Color> { + if st.stroke == designcraft_color::swatch::NONE { + return None; + } + doc.resolve_color(&st.stroke, st.stroke_tint).map(|c| color_of(&c, 1.0)) +} + +/// Paint glyph outlines: the fill (multiplied when it overprints), then the stroke over it. With +/// stroke behind fill a double-width stroke goes first and the fill covers its inner half; with +/// no fill to cover it, the glyph shapes are cut out of the stroke instead. +fn paint_glyphs( + ctx: &mut RenderContext, + bp: &BezPath, + fill: Option<(peniko::Color, bool)>, + stroke: Option<peniko::Color>, + weight: f64, + behind: bool, +) { + let paint_fill = |ctx: &mut RenderContext| { + let Some((c, overprint)) = fill else { return }; + if overprint { + ctx.push_layer(None, Some(peniko::BlendMode::new(peniko::Mix::Multiply, peniko::Compose::SrcOver)), None, None, None); + } + ctx.set_paint(c); + ctx.fill_path(bp); + if overprint { + ctx.pop_layer(); + } + }; + let Some(sc) = stroke else { + paint_fill(ctx); + return; + }; + if !behind { + paint_fill(ctx); + ctx.set_paint(sc); + ctx.set_stroke(kurbo::Stroke::new(weight)); + ctx.stroke_path(bp); + return; + } + ctx.set_paint(sc); + ctx.set_stroke(kurbo::Stroke::new(weight * 2.0)); + if fill.is_some() { + ctx.stroke_path(bp); + paint_fill(ctx); + } else { + ctx.push_layer(None, None, None, None, None); + ctx.stroke_path(bp); + ctx.push_layer(None, Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestOut)), None, None, None); + ctx.fill_path(bp); + ctx.pop_layer(); + ctx.pop_layer(); + } +} + fn cell_stroke(s: &designcraft_doc::CellStroke) -> kurbo::Stroke { let st = kurbo::Stroke::new(s.weight).with_caps(kurbo::Cap::Square); match &s.kind { @@ -484,17 +517,8 @@ impl Renderer { ctx.set_transform(f.view * xf); for (si, bp) in designcraft_compose::path_glyphs(cs, ft, path, pt) { let st = &cs.styles[si as usize]; - if let Some(c) = doc.resolve_color(&st.fill, st.fill_tint) { - ctx.set_paint(color_of(&c, 1.0)); - ctx.fill_path(&bp); - } - if st.stroke != designcraft_color::swatch::NONE - && let Some(c) = doc.resolve_color(&st.stroke, st.stroke_tint) - { - ctx.set_paint(color_of(&c, 1.0)); - ctx.set_stroke(kurbo::Stroke::new(st.stroke_weight)); - ctx.stroke_path(&bp); - } + let fill = doc.resolve_color(&st.fill, st.fill_tint).map(|c| (color_of(&c, 1.0), false)); + paint_glyphs(ctx, &bp, fill, stroke_of(doc, st), st.stroke_weight, st.stroke_behind_fill); self.stats.glyphs += 1; } } diff --git a/crates/render/tests/text_stroke_behind.rs b/crates/render/tests/text_stroke_behind.rs new file mode 100644 index 000000000..103bee563 --- /dev/null +++ b/crates/render/tests/text_stroke_behind.rs @@ -0,0 +1,63 @@ +//! Stroke behind fill on text: the fill covers the inner half of a double-width stroke. +use designcraft_color::{Color, swatch::Swatch}; +use designcraft_compose::Cache; +use designcraft_doc::build::NewDocument; +use designcraft_doc::{Document, ParaFormat, SpreadRef}; +use designcraft_geom::Rect; +use designcraft_render::{RenderOptions, Rendered, Renderer}; + +const WHITE: [u8; 4] = [255, 255, 255, 255]; +const RED: [u8; 4] = [255, 0, 0, 255]; +const BLUE: [u8; 4] = [0, 0, 255, 255]; +const WEIGHT: f64 = 12.0; + +/// A 200 pt "I" filled with `fill`, stroked red at [`WEIGHT`] (none when `stroke` is false). +fn render(fill: &str, stroke: bool, behind: bool) -> Rendered { + let mut d = Document::new(&NewDocument { width: 400.0, height: 400.0, facing_pages: false, ..Default::default() }); + d.swatches.push(Swatch::color("Red", Color::rgb8(255, 0, 0))); + d.swatches.push(Swatch::color("Blue", Color::rgb8(0, 0, 255))); + let lid = d.default_layer(); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(40.0, 0.0, 400.0, 400.0), lid, "I", ParaFormat::default()).unwrap(); + d.story_mut(sid).unwrap().format_chars(0..1, |f| { + f.over.size = Some(200.0); + f.over.fill = Some(fill.into()); + f.over.stroke = Some(if stroke { "Red" } else { designcraft_color::swatch::NONE }.into()); + f.over.stroke_weight = Some(WEIGHT); + f.over.stroke_behind_fill = Some(behind); + }); + let mut r = Renderer::new(); + r.threads = 0; + let cache = Cache::new(); + let cs = cache.get(&d, sid, None); + assert!(!cs.frames[0].lines.is_empty(), "the glyph is set"); + r.render_page(&d, &cache, 0, 1.0, false, &RenderOptions { paper: false, background: Some(WHITE), ..Default::default() }).unwrap() +} + +/// The left edge of the stem on a row through the middle of the unstroked glyph. +fn stem_edge() -> (u32, u32) { + let img = render("Blue", false, false); + let blue: Vec<(u32, u32)> = (0..img.height).flat_map(|y| (0..img.width).map(move |x| (x, y))).filter(|&(x, y)| img.pixel(x, y) == BLUE).collect(); + assert!(!blue.is_empty(), "the glyph is drawn"); + let y0 = blue.iter().map(|p| p.1).min().unwrap(); + let y1 = blue.iter().map(|p| p.1).max().unwrap(); + let y = (y0 + y1) / 2; + let x = (0..img.width).find(|&x| img.pixel(x, y) == BLUE).unwrap(); + (x, y) +} + +#[test] +fn stroke_behind_fill_shows_only_outside_the_letter() { + let (x, y) = stem_edge(); + let inner = x + 3; // inside the outline, within the inner half of a centred stroke + let outer = x - 9; // outside the outline, beyond the outer half of a centred stroke + let over = render("Blue", true, false); + assert_eq!(over.pixel(inner, y), RED, "a centred stroke covers the fill's edge"); + assert_eq!(over.pixel(outer, y), WHITE, "a centred stroke reaches half its weight outside"); + let behind = render("Blue", true, true); + assert_eq!(behind.pixel(inner, y), BLUE, "the fill covers the stroke behind it"); + assert_eq!(behind.pixel(outer, y), RED, "the full stroke weight lies outside the letter"); + assert_eq!(behind.pixel(x - 15, y), WHITE, "and no further"); + let hollow = render(designcraft_color::swatch::NONE, true, true); + assert_eq!(hollow.pixel(inner, y), WHITE, "with no fill the letter stays empty"); + assert_eq!(hollow.pixel(outer, y), RED); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 14d2552e8..cee849a99 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3060,6 +3060,9 @@ fn character_color(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cf: &CharField crate::rtl::label(ui, crate::i18n::tr(lang, "Weight:")); char_number(ui, d, cf, "strokeWeight", " pt", 1.0, (0.0, 800.0)); ui.end_row(); + ui.label(""); + char_check(ui, d, cf, "strokeBehindFill", crate::i18n::tr(lang, "Stroke Behind Fill")); + ui.end_row(); }); } diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 49cb920f2..bc00d37b2 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -973,6 +973,10 @@ fn text_appearance_section(app: &mut DesignApp, ui: &mut Ui) { } ui.add_space(4.0); } + 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}})); + } } /// Character section: font family (with search), style, size/leading, kerning/tracking, •••. diff --git a/docs/typography-parity.md b/docs/typography-parity.md index 0339faf1e..093058395 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-10 · **Change:** major (first version, from the full re-measure) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (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 @@ -40,6 +40,7 @@ Fidelity: **unmeasured** unless stated. | Spelling | per-language dictionaries | English (Moby) + document/user dictionary | ~ | | | Kerning | Metrics, Optical, manual, none | Metrics, manual, none | ~ | **Optical treated as Metrics** (#181): imported display text sets wider and can overset | | Tracking, scale, skew, baseline shift | ✓ | ✓ | ✓ | | +| Character fill and stroke | fill, stroke, tint, weight, stroke aligned to centre or outside | fill, stroke, tints, weight, stroke behind fill (screen, PNG, PDF, IDML `StrokeAlignment`) | ~ | text strokes have no join or miter limit settings (#619 for stroke behind fill) | | OpenType features | ligatures, discretionary, swash, fractions, ordinals, figures, small caps, stylistic sets, positional forms | through harfrust | ✓ | | | Variable fonts | axes in Character panel | named instances and free axis values; PDF embeds the instance | ✓ | | | Leading | auto (120%), absolute, leading model | ✓ | ✓ | CJK leading models in cjk-typography.md | From f96895f400d54a984c2809a079417b44f9c3d77b Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Mon, 12 Oct 2026 23:35:58 +0200 Subject: [PATCH 106/238] feat(text): Edit type on a path along the curve The Type tool's clicks, drags and double-clicks on type on a path went to the character at the same spot of the straightened line, and the caret and highlight were drawn straight. A click now finds the character where its nearest point along the path is (compose::PathTextMap, from the line to the path and back, as path_glyphs places the glyphs); the caret stands across the path there and the highlight bends with the text. A click on the letters, above the path, hits the frame. Depends on #636 (PathWarp::nearest). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 1 + crates/compose/src/lib.rs | 45 +++++++++++++++++++++++++ crates/doc/src/edit.rs | 8 ++++- crates/engine/src/cmd/path_type.rs | 54 ++++++++++++++++++++++++++++++ crates/engine/src/cmd/text.rs | 2 ++ crates/ui-egui/src/canvas.rs | 28 ++++++++++++++-- 6 files changed, 135 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a213adda0..35bc92d16 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-12** · Editing type on a path: the Type tool clicks, drags and double-clicks along the curve (the character under the pointer, not one on the straightened line), a click on the letters themselves hits the frame, and the caret and highlight bend with the path. No readiness percentage change. - **2026-10-12** · Type on a path has start and end brackets, as in InDesign: selected path type shows them across the path, and dragging either with the Selection or Direct Selection tool moves where the text starts or ends (text past the end bracket is overset). `type.pathOptions {end}`; IDML reads and writes EndBracket. No readiness percentage change. - **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 4aa16fd73..4efd32bf5 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -3166,6 +3166,51 @@ pub fn adjacent_row(l: &Line, x: f64, caret_y: f64, up: bool) -> Option<f64> { #[cfg(test)] mod tests; +/// Type on a path: between the composed line (x along it, y as composed) and the frame's path +/// space, as `path_glyphs` places the glyphs. +pub struct PathTextMap { + warp: designcraft_geom::warp::PathWarp, + start: f64, + /// Line y that sits on the path (the alignment), and the line's baseline. + on: f64, + baseline: f64, +} + +impl PathTextMap { + /// The map of `item` (type on a path) for its composed frame `ft`; None for other frames. + pub fn new(item: &designcraft_doc::Item, ft: &FrameText) -> Option<Self> { + let pt = item.text_frame()?.options.path.as_ref()?; + let warp = designcraft_geom::warp::PathWarp::new(&item.path.to_bezpath(), pt.flip); + let (on, baseline) = match ft.lines.first() { + Some(l) => ( + match pt.align { + designcraft_doc::PathAlign::Baseline => l.baseline, + designcraft_doc::PathAlign::Ascender => l.baseline - l.ascent, + designcraft_doc::PathAlign::Descender => l.baseline + l.descent, + designcraft_doc::PathAlign::Center => l.baseline - (l.ascent - l.descent) / 2.0, + }, + l.baseline, + ), + None => (0.0, 0.0), + }; + Some(PathTextMap { warp, start: pt.start, on, baseline }) + } + + /// A point of the composed line, on the path: along it by x, across it by y. + pub fn to_path(&self, p: Point) -> Point { + match self.warp.at(self.start + p.x) { + Some((q, a)) => q + designcraft_geom::Vec2::new(-a.sin(), a.cos()) * (p.y - self.on), + None => p, + } + } + + /// A point of the path space, on the composed line: how far along the path its nearest point + /// is, at the baseline (for hit testing). + pub fn to_line(&self, p: Point) -> Point { + Point::new(self.warp.nearest(p) - self.start, self.baseline) + } +} + /// Each visible glyph of a type-on-a-path frame placed on the path: its outline in frame space /// (grouped by run style). pub fn path_glyphs( diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index 3bb827135..38de38ac7 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -416,5 +416,11 @@ pub fn item_hit(it: &Item, p: Point, tol: f64) -> bool { if area && it.path.is_closed() && designcraft_geom::hit::fill_contains(&bp, designcraft_geom::FillRule::NonZero, inner) { return true; } - designcraft_geom::hit::stroke_contains(&bp, it.stroke.weight.max(1.0), tol, inner) + // Type on a path: the letters stand over the path, so a click on them hits it too. + let reach = if it.text_frame().is_some_and(|t| t.options.path.is_some()) { PATH_TYPE_REACH } else { 0.0 }; + designcraft_geom::hit::stroke_contains(&bp, it.stroke.weight.max(1.0), tol + reach, inner) } + +/// How far from its path type on a path is hit (points): about a cap height of 12–18 pt type, +/// either side (the type may sit on or under the path). +const PATH_TYPE_REACH: f64 = 12.0; diff --git a/crates/engine/src/cmd/path_type.rs b/crates/engine/src/cmd/path_type.rs index c136cb8d1..9ce091a7e 100644 --- a/crates/engine/src/cmd/path_type.rs +++ b/crates/engine/src/cmd/path_type.rs @@ -214,4 +214,58 @@ mod tests { let path = |d: &designcraft_doc::Document| d.item(designcraft_doc::ItemId(id)).unwrap().text_frame().unwrap().options.path.clone(); assert_eq!(path(&back), path(&d)); } + + #[test] + fn the_type_tool_clicks_and_selects_along_a_curved_path() { + for flip in [false, true] { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + // An arch: up from (100, 400) over (300, 200) and down to (500, 400). + let id = s + .execute("path.create", &json!({"anchors": [{"p": [100, 400], "out": [100, 250]}, {"p": [300, 200], "in": [200, 200], "out": [400, 200]}, {"p": [500, 400], "in": [500, 250]}]})) + .unwrap()["id"] + .as_u64() + .unwrap(); + let r = s.execute("type.onPath", &json!({"id": id, "text": "AAAA BBBB CCCC DDDD EEEE FFFF GGGG", "flip": flip})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + // Where each glyph sits on the path, from the composed line. + let d = s.doc().unwrap().doc.clone(); + let it = d.item(designcraft_doc::ItemId(id)).unwrap(); + let cs = designcraft_compose::compose_story(&d, sid, &Default::default()); + let ft = &cs.frames[0]; + let map = designcraft_compose::PathTextMap::new(it, ft).unwrap(); + let l = &ft.lines[0]; + let glyph = |byte: usize| l.glyphs.iter().find(|g| g.byte == byte).unwrap(); + let on_path = |byte: usize| { + let g = glyph(byte); + // A little outside the path, as a click near the letter would be. + map.to_path(designcraft_geom::Point::new(g.x + g.adv * 0.3, l.baseline - 4.0)) + }; + // A click by the first C (byte 10), on the curve: the caret goes there. + let p = on_path(10); + let r = s.execute("text.placeCaret", &json!({"frame": id, "point": [p.x, p.y]})).unwrap(); + assert_eq!(r["pos"], 10, "flip={flip}: {r}"); + // Dragging to the first E (byte 20) selects C to D. + let q = on_path(20); + s.execute("text.extendTo", &json!({"frame": id, "point": [q.x, q.y]})).unwrap(); + let t = s.doc().unwrap().selection.text.unwrap(); + assert_eq!(t.range(), 10..20, "flip={flip}"); + // A double-click on a word (its first click places the caret) selects it. + let w = on_path(16); + s.execute("text.select", &json!({"story": sid.0, "anchor": 0})).unwrap(); + s.execute("text.selectWord", &json!({"frame": id, "point": [w.x, w.y]})).unwrap(); + assert_eq!(s.doc().unwrap().selection.text.unwrap().range(), 15..19, "flip={flip}"); + // A click on a letter, above the path, hits the frame (the type tool's hit test). + let above = map.to_path(designcraft_geom::Point::new(glyph(10).x, l.baseline - l.ascent * 0.7)); + let sp = s.doc().unwrap().doc.clone(); + assert_eq!( + sp.hit_item(0, sp.parent_xf(&sp.find(designcraft_doc::ItemId(id)).unwrap()) * above, 1.0), + Some(designcraft_doc::ItemId(id)), + "flip={flip}" + ); + // The map runs both ways along the path. + let back = map.to_line(map.to_path(designcraft_geom::Point::new(123.0, l.baseline))); + assert!((back.x - 123.0).abs() < 0.01, "{back:?}"); + } + } } diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 71d390d02..a1a17ccc8 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -279,6 +279,8 @@ fn hit_byte(s: &Session, frame: ItemId, pt: Point) -> Option<(StoryId, usize, Op let inner = xf.inverse() * pt; let cs = s.cache.get(&st.doc, sid, None); let fi = cs.frames.iter().position(|f| f.frame == frame)?; + // Type on a path: the click lands on the line where it is along the path. + let inner = cs.frames.get(fi).and_then(|ft| compose::PathTextMap::new(it, ft)).map_or(inner, |m| m.to_line(inner)); if let Some((table, row, col, b)) = compose::hit_cell(&cs, fi, inner) { return Some((sid, b, Some(CellAddr { table, row, col }))); } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 2f30386c8..5bc9146ed 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -979,6 +979,28 @@ fn draw_text_selection( if range.is_empty() { continue; } + // Type on a path: the highlight bends with the text. + if let Some(map) = compose::PathTextMap::new(it, ft) { + for l in &ft.lines { + let (s, e) = (range.start.max(l.range.start), range.end.min(l.range.end)); + if s >= e { + continue; + } + for q in compose::highlight_quads(l, s, e, false) { + let (x0, x1) = (q[0].x.min(q[1].x), q[0].x.max(q[1].x)); + let (y0, y1) = (q[0].y.min(q[2].y), q[0].y.max(q[2].y)); + // Short slices, each turned with the path. + let n = ((x1 - x0) / 2.0).ceil().clamp(1.0, 2000.0) as usize; + for k in 0..n { + let (a, b) = (x0 + (x1 - x0) * k as f64 / n as f64, x0 + (x1 - x0) * (k + 1) as f64 / n as f64); + let poly = + [Point::new(a, y0), Point::new(b, y0), Point::new(b, y1), Point::new(a, y1)].map(|p| xf.to_screen(m * map.to_path(p))); + painter.add(egui::Shape::convex_polygon(poly.to_vec(), Color32::from_rgba_unmultiplied(80, 140, 255, 110), Stroke::NONE)); + } + } + } + continue; + } let mut quads = Vec::new(); let mut glyphs = Vec::new(); for (li, l) in ft.lines.iter().enumerate() { @@ -1010,8 +1032,10 @@ fn draw_text_selection( let m = a * doc.text_xf(it); let blink = (painter.ctx().input(|i| i.time) * 1.6) as i64 % 2 == 0; if blink { - let p0 = xf.to_screen(m * Point::new(x, bl - asc)); - let p1 = xf.to_screen(m * Point::new(x, bl + desc)); + // Type on a path: the caret stands across the path where it is along it. + let on = |p: Point| compose::PathTextMap::new(it, ft).map_or(p, |map| map.to_path(p)); + let p0 = xf.to_screen(m * on(Point::new(x, bl - asc))); + let p1 = xf.to_screen(m * on(Point::new(x, bl + desc))); painter.line_segment([p0, p1], Stroke::new(1.0, Color32::BLACK)); } painter.ctx().request_repaint_after(std::time::Duration::from_millis(330)); From fc01666ac21856fc5b1263e237b6c72870d58e46 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <marcoarnulfo1@gmail.com> Date: Mon, 12 Oct 2026 23:58:11 +0200 Subject: [PATCH 107/238] feat(text): select sentence, paragraph and story on 3, 4 and 5 clicks (fixes #622) --- crates/engine/src/cmd/text.rs | 106 ++++++++++++++++++++++++++++++ crates/mcp/src/headless.rs | 2 +- crates/tools/src/lib.rs | 7 +- crates/tools/src/text.rs | 18 +++--- crates/ui-egui/src/canvas.rs | 118 +++++++++++++++++++++++++++++----- crates/ui-egui/src/control.rs | 2 +- 6 files changed, 227 insertions(+), 26 deletions(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 71d390d02..351fc37d6 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -31,6 +31,15 @@ pub fn specs() -> Vec<CommandSpec> { st.selection.text = Some(TextSel { anchor: a, focus: b, ..t }); ok() }), + cmd!(noundo "text.selectSentence", "Select Sentence", [], None, "{frame, point} — the sentence under the point (triple click)", has_doc, |s, p| { + select_around(s, p, "text.selectSentence", sentence_bounds) + }), + cmd!(noundo "text.selectParagraph", "Select Paragraph", [], None, "{frame, point} — the paragraph under the point (quadruple click)", has_doc, |s, p| { + select_around(s, p, "text.selectParagraph", para_bounds) + }), + cmd!(noundo "text.selectStory", "Select Story", [], None, "{frame, point} — all the text of the story under the point, threaded frames included (quintuple click)", has_doc, |s, p| { + select_around(s, p, "text.selectStory", |t, _| (0, t.len())) + }), cmd!(noundo "text.select", "Select Text", [], None, "{story, anchor, focus} (UTF-8 byte offsets into the story text, as find.find reports them: á or — counts 2 or 3)", has_doc, |s, p| { let sid = StoryId(p.get("story").and_then(Value::as_u64).unwrap_or(0)); let st = s.doc_mut()?; @@ -627,6 +636,62 @@ fn word_bounds(s: &str, i: usize) -> (usize, usize) { (a, b) } +/// Selects `bounds(text, byte)` around the byte under `point` in `frame`. Unlike a caret +/// placement, a press inside the current selection is no start of a text drag here: it is the +/// third or later click of a row, which lands in the word or sentence just selected. +fn select_around(s: &mut Session, p: &Value, id: &str, bounds: fn(&str, usize) -> (usize, usize)) -> Result<Value> { + let frame = id_param(p, "frame").ok_or_else(|| bad(id, "missing frame"))?; + let pt = point_param(p, "point").unwrap_or(Point::ZERO); + let (sid, b, cell) = hit_byte(s, frame, pt).ok_or_else(|| bad(id, "not a text frame"))?; + let st = s.doc_mut()?; + let text = st.doc.text_story(sid, cell).map(|x| x.text.clone()).unwrap_or_default(); + let (anchor, focus) = bounds(&text, floor_char_boundary(&text, b)); + st.selection = Selection::text(TextSel { story: sid, anchor, focus, frame: Some(frame), cell }); + st.revision += 1; + Ok(json!({"story": sid.0, "anchor": anchor, "focus": focus})) +} + +/// The paragraph around byte `i`, without its `\n`. +fn para_bounds(s: &str, i: usize) -> (usize, usize) { + let i = i.min(s.len()); + let a = s.get(..i).and_then(|t| t.rfind('\n')).map_or(0, |k| k + 1); + let b = s.get(i..).and_then(|t| t.find('\n')).map_or(s.len(), |k| i + k); + (a, b) +} + +/// The sentence around byte `i`, within its paragraph: it ends after `.`, `!`, `?` or `…` (and +/// any closing quotes or brackets) followed by a space or the paragraph end, or after a CJK full +/// stop, and takes the spaces after it, as deleting a sentence should leave none behind. +fn sentence_bounds(s: &str, i: usize) -> (usize, usize) { + let (pa, pb) = para_bounds(s, i); + let para = s.get(pa..pb).unwrap_or(""); + let mut start = 0; + let mut it = para.char_indices().peekable(); + while let Some((_, c)) = it.next() { + let cjk = matches!(c, '。' | '!' | '?'); + if !cjk && !matches!(c, '.' | '!' | '?' | '…') { + continue; + } + while it.peek().is_some_and(|&(_, c)| { + matches!(c, '.' | '!' | '?' | '…' | '。' | '!' | '?' | '"' | '\'' | '”' | '’' | '»' | '›' | ')' | ']' | '」' | '』' | ')') + }) { + it.next(); + } + if !cjk && it.peek().is_some_and(|&(_, c)| !c.is_whitespace()) { + continue; + } + while it.peek().is_some_and(|&(_, c)| c.is_whitespace()) { + it.next(); + } + let end = it.peek().map_or(para.len(), |&(k, _)| k); + if pa + end > i || end == para.len() { + return (pa + start, pa + end); + } + start = end; + } + (pa + start, pb) +} + /// Type ▸ Story Direction. fn story_direction(s: &mut Session, p: &Value) -> Result<Value> { let vertical = p.get("vertical").and_then(Value::as_bool).ok_or_else(|| bad("type.storyDirection", "missing vertical"))?; @@ -1896,3 +1961,44 @@ mod story_query_contract_tests { assert_eq!(s.execute("story.get", &json!({})).unwrap()["text"], ""); } } + +#[cfg(test)] +mod multi_click_tests { + use super::{para_bounds, sentence_bounds}; + + fn sentence(s: &str, at: &str) -> String { + let i = s.find(at).unwrap(); + let (a, b) = sentence_bounds(s, i); + s[a..b].to_string() + } + + #[test] + fn sentences_end_at_a_stop_before_a_space_or_the_paragraph_end() { + let s = "It costs 3.5 euros. \u{201C}Really?\u{201D} (Twice!) Yes\u{2026} fine.\nNext one. Last"; + assert_eq!(sentence(s, "costs"), "It costs 3.5 euros. "); + assert_eq!(sentence(s, "Really"), "\u{201C}Really?\u{201D} "); + assert_eq!(sentence(s, "Twice"), "(Twice!) "); + assert_eq!(sentence(s, "Yes"), "Yes\u{2026} "); + assert_eq!(sentence(s, "fine"), "fine."); + assert_eq!(sentence(s, "one"), "Next one. "); + assert_eq!(sentence(s, "Last"), "Last"); + // The caret at the very end of a paragraph is in its last sentence. + let end = s.find('\n').unwrap(); + assert_eq!(sentence_bounds(s, end), (s.find("fine").unwrap(), end)); + } + + #[test] + fn cjk_full_stops_end_sentences_without_a_space() { + let s = "これは文です。次の文!最後"; + assert_eq!(sentence(s, "次"), "次の文!"); + assert_eq!(sentence(s, "最後"), "最後"); + } + + #[test] + fn paragraphs_stop_at_returns() { + let s = "One.\nTwo two.\n"; + assert_eq!(para_bounds(s, 6), (5, 13)); + assert_eq!(para_bounds(s, 0), (0, 4)); + assert_eq!(para_bounds(s, s.len()), (s.len(), s.len())); + } +} diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index eea81ddd3..de0353575 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -123,7 +123,7 @@ impl Headless { let x = e.get("x").and_then(Value::as_f64).ok_or("pointer event needs numeric `x`")?; let y = e.get("y").and_then(Value::as_f64).ok_or("pointer event needs numeric `y`")?; let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base); - self.session.pointer(&PointerEvent { kind, pos: Point::new(x, y), mods }, VIEW).map_err(|e| e.to_string())?; + self.session.pointer(&PointerEvent { kind, pos: Point::new(x, y), mods, clicks: 0 }, VIEW).map_err(|e| e.to_string())?; } let requests: Vec<Value> = self.session.ui_requests.drain(..).map(|r| serde_json::to_value(r).unwrap_or_default()).collect(); Ok(json!({"selection": self.selection(), "tool": self.session.tool_id(), "requests": requests})) diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 247494f84..0bda9247d 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -63,11 +63,16 @@ pub struct PointerEvent { pub pos: Point, #[serde(default)] pub mods: Mods, + /// Presses in quick succession at about the same spot, this one included: 2 on a + /// `DoubleClick`, 3 or more on a `Down` (the Type tool selects a sentence, paragraph, story). + /// 0 or 1 is a single press. + #[serde(default)] + pub clicks: u8, } impl PointerEvent { pub fn new(kind: PointerKind, x: f64, y: f64) -> Self { - Self { kind, pos: Point::new(x, y), mods: Mods::default() } + Self { kind, pos: Point::new(x, y), mods: Mods::default(), clicks: 0 } } pub fn with_mods(mut self, m: Mods) -> Self { self.mods = m; diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index 13a93f23a..f166cf8d6 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -50,19 +50,21 @@ impl Tool for TypeTool { && !matches!(it.content, designcraft_doc::Content::Graphic(_) | designcraft_doc::Content::Group { .. }) { let sp = cx.layout.spread_at(ev.pos).map(|(_, p)| p).unwrap_or(ev.pos); + // Clicks in a row select a word, a sentence, a paragraph, then the whole story. + let unit = match (ev.kind, ev.clicks) { + (PointerKind::DoubleClick, _) => Some("text.selectWord"), + (_, 3) => Some("text.selectSentence"), + (_, 4) => Some("text.selectParagraph"), + (_, 5..) => Some("text.selectStory"), + _ => None, + }; // Whole rows/columns selected: wait to see whether this is a drag. - if ev.kind == PointerKind::Down && !ev.mods.shift && whole_rows_or_cols(cx) { + if unit.is_none() && !ev.mods.shift && whole_rows_or_cols(cx) { self.cell_drag = Some((id.0, sp)); return vec![]; } self.selecting = Some(id.0); - let cmd = if ev.kind == PointerKind::DoubleClick { - "text.selectWord" - } else if ev.mods.shift { - "text.extendTo" - } else { - "text.placeCaret" - }; + let cmd = unit.unwrap_or(if ev.mods.shift { "text.extendTo" } else { "text.placeCaret" }); return vec![Action::Exec(cmd.into(), json!({"frame": id.0, "point": [sp.x, sp.y]}))]; } vec![] diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4c7313e61..0ab1d6ebb 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1341,6 +1341,19 @@ fn fmt_tick(v: f64) -> String { if (v - r).abs() < 1e-6 { format!("{}", r as i64) } else { format!("{v:.1}") } } +/// Presses in a row, this one included: each within egui's double-click delay and distance of the +/// one before. Counted on the press, as text selection wants (egui counts releases, up to three). +fn count_click(ui: &egui::Ui, id: egui::Id, at: Pos2, now: f64) -> u8 { + let (delay, dist) = ui.ctx().options(|o| (o.input_options.max_double_click_delay, o.input_options.max_click_dist)); + let last: Option<(f64, Pos2, u8)> = ui.data(|d| d.get_temp(id)); + let n = match last { + Some((t, p, n)) if now - t < delay && p.distance(at) < dist => n.saturating_add(1), + _ => 1, + }; + ui.data_mut(|d| d.insert_temp(id, (now, at, n))); + n +} + fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rect: Rect) { let Some(v) = app.view().copied() else { return }; let xf = Xf::new(rect, &v); @@ -1383,20 +1396,15 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r let mut events: Vec<PointerEvent> = Vec::new(); let down_id = egui::Id::new(("canvas_pointer_down", app.pane)); let mut down: bool = ui.data(|d| d.get_temp(down_id)).unwrap_or(false); - let (pressed, released, origin, latest, dbl) = ui.input(|i| { - ( - i.pointer.primary_pressed(), - i.pointer.primary_released(), - i.pointer.press_origin(), - i.pointer.latest_pos(), - i.pointer.button_double_clicked(egui::PointerButton::Primary), - ) - }); + let (pressed, released, origin, latest, now) = + ui.input(|i| (i.pointer.primary_pressed(), i.pointer.primary_released(), i.pointer.press_origin(), i.pointer.latest_pos(), i.time)); if pressed && !space && let Some(o) = origin.filter(|o| (if xf.rot == 0 { rect.contains(*o) } else { resp.rect.contains(*o) }) && resp.hovered()) { - events.push(PointerEvent { kind: if dbl { PointerKind::DoubleClick } else { PointerKind::Down }, pos: pos(o), mods: m }); + let clicks = count_click(ui, egui::Id::new(("canvas_clicks", app.pane)), o, now); + let kind = if clicks == 2 { PointerKind::DoubleClick } else { PointerKind::Down }; + events.push(PointerEvent { kind, pos: pos(o), mods: m, clicks }); down = true; } if down @@ -1404,7 +1412,7 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r && resp.drag_delta() != egui::Vec2::ZERO && let Some(p) = latest { - events.push(PointerEvent { kind: PointerKind::Drag, pos: pos(p), mods: m }); + events.push(PointerEvent { kind: PointerKind::Drag, pos: pos(p), mods: m, clicks: 0 }); } // Power Zoom: holding the Hand tool still for half a second turns the press into one. let hold_id = egui::Id::new(("canvas_hold", app.pane)); @@ -1414,8 +1422,8 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r let still = origin_pos.zip(latest).is_some_and(|(o, l)| (o - l).length() < 3.0); if !fired && still && now - start >= 0.5 { let p = pos(latest.unwrap_or(rect.center())); - events.push(PointerEvent { kind: PointerKind::Up, pos: p, mods: m }); - events.push(PointerEvent { kind: PointerKind::Down, pos: p, mods: Mods { alt: true, ..m } }); + events.push(PointerEvent { kind: PointerKind::Up, pos: p, mods: m, clicks: 0 }); + events.push(PointerEvent { kind: PointerKind::Down, pos: p, mods: Mods { alt: true, ..m }, clicks: 1 }); ui.data_mut(|d| d.insert_temp(hold_id, (start, true))); } else { ui.data_mut(|d| d.insert_temp(hold_id, (start, fired))); @@ -1428,10 +1436,10 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r } if down && released { let p = latest.unwrap_or(rect.center()); - events.push(PointerEvent { kind: PointerKind::Up, pos: pos(p), mods: m }); + events.push(PointerEvent { kind: PointerKind::Up, pos: pos(p), mods: m, clicks: 0 }); down = false; } else if !down && let Some(p) = resp.hover_pos() { - events.push(PointerEvent { kind: PointerKind::Move, pos: pos(p), mods: m }); + events.push(PointerEvent { kind: PointerKind::Move, pos: pos(p), mods: m, clicks: 0 }); } ui.data_mut(|d| d.insert_temp(down_id, down)); for e in events { @@ -1798,6 +1806,86 @@ mod tests { } } + /// A text frame holding `text`, the Type tool, and a harness stepping at 60 fps (clicks in a + /// row must land within the double-click delay). + fn typing(text: &str) -> (Harness<'static, DesignApp>, u64) { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let r = app.run("frame.create", json!({"rect": [72, 72, 400, 400], "content": "text", "text": text})).unwrap(); + let id = r["id"].as_u64().unwrap(); + app.run("tool.select", json!({"tool": "type"})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut DesignApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + (h, id) + } + + /// The screen point over the first line of frame `id` where a click puts the caret at a byte + /// in `bytes`. + fn screen_at(h: &mut Harness<'_, DesignApp>, id: u64, bytes: std::ops::Range<usize>) -> Pos2 { + let mut x = 74.0; + let p = loop { + assert!(x < 400.0, "no point on the first line hits {bytes:?}"); + h.state_mut().run("text.placeCaret", json!({"frame": id, "point": [x, 80.0]})).unwrap(); + if h.state().session.doc().unwrap().selection.text.is_some_and(|t| bytes.contains(&t.focus)) { + break Point::new(x, 80.0); + } + x += 1.0; + }; + h.state_mut().run("edit.deselectAll", json!({})).unwrap(); + let app = h.state(); + let c = app.session.layout().to_canvas(SpreadRef::Doc(0), p); + Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(c) + } + + fn click(h: &mut Harness<'_, DesignApp>, p: Pos2) { + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + h.step(); + } + } + + fn selected(h: &Harness<'_, DesignApp>) -> std::ops::Range<usize> { + h.state().session.doc().unwrap().selection.text.map(|t| t.range()).unwrap_or_default() + } + + #[test] + fn clicks_in_a_row_select_word_sentence_paragraph_and_story() { + let text = "One two. Three four! Five six.\nSecond para here."; + let (mut h, id) = typing(text); + // Inside "Three", the second sentence: its start is not the paragraph's. + let p = screen_at(&mut h, id, 10..14); + h.hover_at(p); + h.step(); + let mut got = vec![]; + for _ in 0..5 { + click(&mut h, p); + let r = selected(&h); + got.push(text[r].to_string()); + } + assert_eq!(got[1..], ["Three", "Three four! ", "One two. Three four! Five six.", text], "{got:?}"); + assert!(got[0].is_empty(), "one click places the caret: {got:?}"); + } + + #[test] + fn slow_clicks_do_not_add_up() { + let (mut h, id) = typing("One two. Three four!"); + let p = screen_at(&mut h, id, 10..14); + h.hover_at(p); + h.step(); + for _ in 0..3 { + click(&mut h, p); + h.run_steps(30); + } + assert!(selected(&h).is_empty(), "three clicks half a second apart leave a caret: {:?}", selected(&h)); + } + #[test] fn rulers_open_no_units_menu_without_a_document() { let mut h = harness(DesignApp::new(designcraft_engine::Session::new(), crate::Services::default())); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 12df640da..68e92104e 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -147,7 +147,7 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> designcraft_geom::Point::new(x, y) }; let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base_mods); - if let Err(e) = app.session.pointer(&PointerEvent { kind, pos, mods }, view) { + if let Err(e) = app.session.pointer(&PointerEvent { kind, pos, mods, clicks: 0 }, view) { return err(e); } app.after_engine(); From 3d59dd5d9757d248b8e5a531b633e8df09c20e11 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <marcoarnulfo1@gmail.com> Date: Tue, 13 Oct 2026 00:38:39 +0200 Subject: [PATCH 108/238] fix(cmd): reject unknown spread values instead of falling back to page 1 (part of #651) --- crates/engine/src/cmd/file.rs | 8 +++---- crates/engine/src/cmd/guides.rs | 27 +++++------------------ crates/engine/src/cmd/linked.rs | 2 +- crates/engine/src/cmd/liquid.rs | 2 +- crates/engine/src/cmd/mathexpr.rs | 2 +- crates/engine/src/cmd/media.rs | 2 +- crates/engine/src/cmd/mod.rs | 23 ++++++++++++++------ crates/engine/src/cmd/object.rs | 36 ++++++++++++++++++++++++++----- crates/engine/src/cmd/qr.rs | 2 +- docs/mcp.md | 4 +++- 10 files changed, 65 insertions(+), 43 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 692c363e8..a1919b89e 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -335,7 +335,7 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> { let st = s.active()?; st.selection.items.iter().copied().find(|i| st.doc.item(*i).is_some_and(|it| matches!(it.content, Content::Unassigned | Content::Graphic(_)))) }); - let spread = super::spread_param(p, "spread"); + let spread = super::spread_param(p, "file.place")?; let lid = s.doc()?.active_layer; let px = p.get("x").and_then(Value::as_f64); let py = p.get("y").and_then(Value::as_f64); @@ -458,7 +458,7 @@ fn place_load(s: &mut Session, p: &Value) -> Result<Value> { fn place_drop(s: &mut Session, p: &Value) -> Result<Value> { let (aid, (nw, nh)) = s.loaded.ok_or_else(|| bad("place.drop", "nothing loaded in the place cursor"))?; - let sr = super::spread_param(p, "spread"); + let sr = super::spread_param(p, "place.drop")?; let lid = s.doc()?.active_layer; let frame = super::id_param(p, "frame"); let rect = super::rect_param(p, "rect"); @@ -550,7 +550,7 @@ fn place_layout_page(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) -> Re if ids.is_empty() { return Err(bad(ID, format!("page {page} has no objects"))); } - let to = super::spread_param(p, "spread"); + let to = super::spread_param(p, ID)?; let off = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) { (Some(x), Some(y)) => designcraft_geom::Vec2::new(x - pr.x0, y - pr.y0), _ => designcraft_geom::Vec2::new(-pr.x0, -pr.y0), @@ -586,7 +586,7 @@ pub(crate) fn snippet_place(s: &mut Session, p: &Value) -> Result<Value> { if ids.is_empty() { return Err(bad("snippet.place", "the snippet is empty")); } - let sr = super::spread_param(p, "spread"); + let sr = super::spread_param(p, "snippet.place")?; let bounds = ids.iter().filter_map(|i| src.item(*i).map(|it| it.bounds())).reduce(|a, b| a.union(b)).unwrap_or(Rect::ZERO); let off = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) { (Some(x), Some(y)) => designcraft_geom::Vec2::new(x - bounds.x0, y - bounds.y0), diff --git a/crates/engine/src/cmd/guides.rs b/crates/engine/src/cmd/guides.rs index 47aacdc86..1111cb3b0 100644 --- a/crates/engine/src/cmd/guides.rs +++ b/crates/engine/src/cmd/guides.rs @@ -28,7 +28,7 @@ pub fn specs() -> Vec<CommandSpec> { cmd!("guide.move", "Move Guide", [], None, "{spread?, page, index, position}", has_doc, move_guide), cmd!("guide.delete", "Delete Guide", [], None, "{spread?, page, index}", has_doc, delete), cmd!("guide.deleteAll", "Delete All Guides on Spread", ["View", "Grids & Guides"], None, "{spread?}", has_doc, |s, p| { - let r = guide_spread(p, "guide.deleteAll")?; + let r = super::spread_param(p, "guide.deleteAll")?; s.edit(|d, _| { let sp = d.spread_mut(r).ok_or_else(|| bad("guide.deleteAll", "no such spread"))?; let mut n = 0; @@ -41,7 +41,7 @@ pub fn specs() -> Vec<CommandSpec> { }), cmd!(query "guide.list", "Guides", [], None, "{spread?} → [{page, index, orientation, position, spread}]", has_doc, |s, p| { let d = &s.doc()?.doc; - let sp = d.spread(guide_spread(p, "guide.list")?).ok_or_else(|| bad("guide.list", "no such spread"))?; + let sp = d.spread(super::spread_param(p, "guide.list")?).ok_or_else(|| bad("guide.list", "no such spread"))?; let mut out = Vec::new(); for (pi, pg) in sp.pages.iter().enumerate() { for (i, g) in pg.guides.iter().enumerate() { @@ -71,23 +71,6 @@ fn optional_usize(p: &Value, key: &str, command: &str) -> Result<Option<usize>> p.get(key).map(|value| checked_usize(value, key, command)).transpose() } -/// Numeric spread shorthand or the serialized `SpreadRef` form, without lossy/defaulting casts. -fn guide_spread(p: &Value, command: &str) -> Result<SpreadRef> { - let Some(value) = p.get("spread") else { return Ok(SpreadRef::Doc(0)) }; - if value.is_number() { - return Ok(SpreadRef::Doc(checked_usize(value, "spread", command)?)); - } - let object = value.as_object().ok_or_else(|| bad(command, "`spread` must be a non-negative integer or a spread reference"))?; - let kind = object.get("kind").and_then(Value::as_str).ok_or_else(|| bad(command, "`spread.kind` must be `doc` or `parent`"))?; - let index = object.get("index").ok_or_else(|| bad(command, "missing `spread.index`"))?; - let index = checked_usize(index, "spread.index", command)?; - match kind { - "doc" => Ok(SpreadRef::Doc(index)), - "parent" => Ok(SpreadRef::Parent(index)), - _ => Err(bad(command, "`spread.kind` must be `doc` or `parent`")), - } -} - fn finite_number(p: &Value, key: &str, command: &str) -> Result<Option<f64>> { let Some(value) = p.get(key) else { return Ok(None) }; let number = value.as_f64().ok_or_else(|| bad(command, format!("`{key}` must be a finite number")))?; @@ -113,7 +96,7 @@ fn page_at(d: &Document, r: SpreadRef, x: f64) -> usize { fn add(s: &mut Session, p: &Value) -> Result<Value> { let o = orientation(p)?; let pos = finite_number(p, "position", "guide.add")?.ok_or_else(|| bad("guide.add", "missing position"))?; - let r = guide_spread(p, "guide.add")?; + let r = super::spread_param(p, "guide.add")?; let spread_guide = p.get("spreadGuide").and_then(Value::as_bool).unwrap_or(false); let d = &s.doc()?.doc; let pi = match optional_usize(p, "page", "guide.add")? { @@ -139,7 +122,7 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> { fn guide_ref(p: &Value, cmd: &str) -> Result<(SpreadRef, usize, usize)> { let pi = optional_usize(p, "page", cmd)?.ok_or_else(|| bad(cmd, "missing page"))?; let i = optional_usize(p, "index", cmd)?.ok_or_else(|| bad(cmd, "missing index"))?; - Ok((guide_spread(p, cmd)?, pi, i)) + Ok((super::spread_param(p, cmd)?, pi, i)) } /// Commands must use the same guide/layer protection as pointer editing. @@ -237,7 +220,7 @@ fn create_guides(s: &mut Session, p: &Value) -> Result<Value> { let cg = guide_gutter(p, "columnGutter")?; let to_page = str_param(p, "fitTo") == Some("page"); let remove = p.get("removeExisting").and_then(Value::as_bool).unwrap_or(false); - let r = guide_spread(p, "layout.createGuides")?; + let r = super::spread_param(p, "layout.createGuides")?; let only = optional_usize(p, "page", "layout.createGuides")?; let page_count = { let sp = s.doc()?.doc.spread(r).ok_or_else(|| bad("layout.createGuides", "no such spread"))?; diff --git a/crates/engine/src/cmd/linked.rs b/crates/engine/src/cmd/linked.rs index ec07a24b2..f504fe661 100644 --- a/crates/engine/src/cmd/linked.rs +++ b/crates/engine/src/cmd/linked.rs @@ -28,7 +28,7 @@ pub fn specs() -> Vec<CommandSpec> { |s, p| { let parent = StoryId(p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("story.placeAndLink", "missing story"))?); let rect = rect_param(p, "rect").ok_or_else(|| bad("story.placeAndLink", "missing rect"))?; - let sr = spread_param(p, "spread"); + let sr = spread_param(p, "story.placeAndLink")?; let layer = s.doc()?.active_layer; s.edit(|d, sel| { if d.story(parent).is_none() { diff --git a/crates/engine/src/cmd/liquid.rs b/crates/engine/src/cmd/liquid.rs index 604369df6..879fdb368 100644 --- a/crates/engine/src/cmd/liquid.rs +++ b/crates/engine/src/cmd/liquid.rs @@ -168,7 +168,7 @@ pub fn specs() -> Vec<CommandSpec> { } ), cmd!("guide.liquid", "Convert to Liquid Guide", [], None, "{spread?, page, index, on?: bool (default true)}", has_doc, |s, p| { - let sr = super::spread_param(p, "spread"); + let sr = super::spread_param(p, "guide.liquid")?; let pi = p.get("page").and_then(Value::as_u64).unwrap_or(0) as usize; let gi = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("guide.liquid", "`index` required"))? as usize; let on = p.get("on").and_then(Value::as_bool).unwrap_or(true); diff --git a/crates/engine/src/cmd/mathexpr.rs b/crates/engine/src/cmd/mathexpr.rs index ec90d52e3..d9ce51148 100644 --- a/crates/engine/src/cmd/mathexpr.rs +++ b/crates/engine/src/cmd/mathexpr.rs @@ -70,7 +70,7 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> { }); } let text = s.doc()?.selection.text.filter(|t| t.cell.is_none()); - let spread = super::spread_param(p, "spread"); + let spread = super::spread_param(p, "math.insert")?; let (px, py) = (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)); s.edit(|d, sel| { if let Some(t) = text { diff --git a/crates/engine/src/cmd/media.rs b/crates/engine/src/cmd/media.rs index b6e4cd4b8..f6d13ec0b 100644 --- a/crates/engine/src/cmd/media.rs +++ b/crates/engine/src/cmd/media.rs @@ -20,7 +20,7 @@ pub(crate) fn place_media(s: &mut Session, p: &Value, name: String, mime: &str, let st = s.active()?; st.selection.items.iter().copied().find(|i| st.doc.item(*i).is_some_and(|it| matches!(it.content, Content::Unassigned | Content::Graphic(_)))) }); - let spread = super::spread_param(p, "spread"); + let spread = super::spread_param(p, "file.place")?; let lid = s.doc()?.active_layer; let (px, py) = (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)); let mime = mime.to_string(); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 68061af0a..31e4544f4 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -254,12 +254,23 @@ pub(crate) fn rect_param(p: &Value, key: &str) -> Option<Rect> { let f = |i: usize| a.get(i).and_then(Value::as_f64); Some(Rect::new(f(0)?, f(1)?, f(2)?, f(3)?)) } -/// `{"kind":"doc","index":0}`, `0` or absent (= spread 0). -pub(crate) fn spread_param(p: &Value, key: &str) -> SpreadRef { - match p.get(key) { - Some(Value::Number(n)) => SpreadRef::Doc(n.as_u64().unwrap_or(0) as usize), - Some(v) => serde_json::from_value(v.clone()).unwrap_or(SpreadRef::Doc(0)), - None => SpreadRef::Doc(0), +/// The `spread` param: a body spread index (`0`), a spread reference (`{"kind":"doc"|"parent","index":0}`) +/// or absent (= spread 0). Anything else is an error: read as spread 0, a script aiming at a parent +/// spread got its items on the first page without a word. +pub(crate) fn spread_param(p: &Value, command: &str) -> Result<SpreadRef> { + let index = |v: &Value, key: &str| { + v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad(command, format!("`{key}` must be a non-negative integer"))) + }; + let Some(value) = p.get("spread") else { return Ok(SpreadRef::Doc(0)) }; + if value.is_number() { + return Ok(SpreadRef::Doc(index(value, "spread")?)); + } + let object = value.as_object().ok_or_else(|| bad(command, "`spread` must be a non-negative integer or {kind: doc|parent, index}"))?; + let i = index(object.get("index").ok_or_else(|| bad(command, "missing `spread.index`"))?, "spread.index")?; + match object.get("kind").and_then(Value::as_str) { + Some("doc") => Ok(SpreadRef::Doc(i)), + Some("parent") => Ok(SpreadRef::Parent(i)), + _ => Err(bad(command, "`spread.kind` must be `doc` or `parent`")), } } diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index cd13be117..21362ef9a 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -32,7 +32,7 @@ pub fn specs() -> Vec<CommandSpec> { "Create Frame", [], None, - "{spread?, rect: [x0,y0,x1,y1] (spread coords), shape?: rectangle|ellipse|polygon, content?: graphic|text|unassigned, sides?: 6, text?: string, caret?: bool, vertical?: bool (text: a new vertical story)}", + "{spread?: index | {kind: doc|parent, index} (a parent spread: index into `parents` of document.inspect), rect: [x0,y0,x1,y1] (spread coords), shape?: rectangle|ellipse|polygon, content?: graphic|text|unassigned, sides?: 6, text?: string, caret?: bool, vertical?: bool (text: a new vertical story)}", has_doc, frame_create ), @@ -828,7 +828,7 @@ fn frame_create(s: &mut Session, p: &Value) -> Result<Value> { let rect = rect_param(p, "rect").ok_or_else(|| bad("frame.create", "missing rect"))?; let shape = str_param(p, "shape").unwrap_or("rectangle"); let content = str_param(p, "content").unwrap_or("graphic"); - let sr = spread_param(p, "spread"); + let sr = spread_param(p, "frame.create")?; let lid = s.doc()?.active_layer; let text = super::text_param(p, "text"); let caret = bool_or(p, "caret", content == "text"); @@ -891,7 +891,7 @@ pub(crate) fn polygon_in(r: Rect, sides: u32, inset: f64) -> designcraft_geom::P fn line_create(s: &mut Session, p: &Value) -> Result<Value> { let a = point_param(p, "a").ok_or_else(|| bad("line.create", "missing a"))?; let b = point_param(p, "b").ok_or_else(|| bad("line.create", "missing b"))?; - let sr = spread_param(p, "spread"); + let sr = spread_param(p, "line.create")?; let lid = s.doc()?.active_layer; s.edit(|d, sel| { let id = ItemId(d.alloc()); @@ -1592,7 +1592,7 @@ fn gap_move(s: &mut Session, p: &Value) -> Result<Value> { .and_then(Value::as_array) .and_then(|a| Some(Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))) .ok_or_else(|| bad(ID, "missing at"))?; - let sr = spread_param(p, "spread"); + let sr = spread_param(p, ID)?; let (vertical, a, z, before, after) = find_gap(&s.doc()?.doc, sr, at).ok_or_else(|| bad(ID, "no gap there"))?; // `delta` along the gap's axis, or a drag (dx, dy) of which the axis part counts. let delta = @@ -1919,7 +1919,7 @@ fn path_create(s: &mut Session, p: &Value) -> Result<Value> { return Err(bad("path.create", "a path needs at least two anchors")); } let closed = bool_or(p, "closed", false); - let sr = spread_param(p, "spread"); + let sr = spread_param(p, "path.create")?; let lid = s.doc()?.active_layer; s.edit(|d, sel| { let id = ItemId(d.alloc()); @@ -2813,3 +2813,29 @@ mod swatch_name_tests { } } } + +#[cfg(test)] +mod spread_target_tests { + use super::*; + + #[test] + fn creating_on_a_parent_spread_and_rejecting_unknown_spreads() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap(); + let parent = json!({"kind": "parent", "index": 0}); + let r = s.execute("frame.create", &json!({"spread": parent, "rect": [0, 0, 100, 100]})).unwrap(); + let id = ItemId(r["id"].as_u64().unwrap()); + assert_eq!(s.doc().unwrap().doc.find(id).unwrap().spread, SpreadRef::Parent(0)); + s.execute("line.create", &json!({"spread": parent, "a": [0, 0], "b": [50, 50]})).unwrap(); + assert_eq!(s.doc().unwrap().doc.parents[0].items.len(), 2); + + // Each of these used to land on the first body page. + for bad in + [json!("A-Parent"), json!("0"), json!(-1), json!(1.5), json!({"kind": "master", "index": 0}), json!({"kind": "parent"}), json!([0])] + { + assert!(s.execute("frame.create", &json!({"spread": bad, "rect": [0, 0, 100, 100]})).is_err(), "frame.create spread {bad}"); + assert!(s.execute("line.create", &json!({"spread": bad, "a": [0, 0], "b": [50, 50]})).is_err(), "line.create spread {bad}"); + } + assert!(s.doc().unwrap().doc.spreads[0].items.is_empty()); + } +} diff --git a/crates/engine/src/cmd/qr.rs b/crates/engine/src/cmd/qr.rs index c201de4ee..d68f5e696 100644 --- a/crates/engine/src/cmd/qr.rs +++ b/crates/engine/src/cmd/qr.rs @@ -58,7 +58,7 @@ fn qr_code(s: &mut Session, p: &Value) -> Result<Value> { let text = encode(p).map_err(|e| bad(ID, e))?; let color = str_param(p, "color").unwrap_or(designcraft_color::swatch::BLACK).to_string(); let rect = rect_param(p, "rect"); - let sr = spread_param(p, "spread"); + let sr = spread_param(p, ID)?; let st = s.doc()?; let lid = st.active_layer; let sel = st.selection.items.clone(); diff --git a/docs/mcp.md b/docs/mcp.md index 17c03a3df..f35585746 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -58,7 +58,9 @@ so inspect the document before retrying. The next request re-establishes the con Points (1/72 in), y down, in **spread** space: on a single-page spread the page's top-left is (0, 0); on a facing spread the right page starts at x = page width. `inspect_document` lists page bounds per spread. Page indices in -`render_page` / `export_png` are 0-based. `pointer` events in connected mode may use `"space": "screen"` (egui points, +`render_page` / `export_png` are 0-based. Commands that create items take `spread`: a body spread index, or +`{"kind": "parent", "index": 0}` for a parent spread (its index in `parents` of `inspect_document`); any other +value is an error. `pointer` events in connected mode may use `"space": "screen"` (egui points, see `ui_inspect` → `canvasRect`). ## Text offsets From 9e15d5a17eb9ebaf153e17075d9a3e4f6c23bb98 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <marcoarnulfo1@gmail.com> Date: Tue, 13 Oct 2026 00:41:46 +0200 Subject: [PATCH 109/238] fix(cli): accept a UTF-8 BOM in scripts and document the syntax in script --help (fixes #654) --- README.md | 1 + apps/designcraft-cli/src/main.rs | 35 +++++++++++++++++++++++++++++++ apps/designcraft-cli/tests/cli.rs | 15 +++++++++++++ crates/engine/src/script.rs | 18 ++++++++++++++-- docs/agents.md | 8 +++++-- 5 files changed, 73 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 39fbe7100..0ec6540ba 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,7 @@ cargo run --release -p designcraft -- --sample # open the sample mag cargo run --release -p designcraft -- --sample --control 7979 # + JSON control channel cargo run --release -p designcraft-cli -- run --sample --all-pages out/ # headless: render every page to PNG cargo run --release -p designcraft-cli -- commands # list every command +cargo run --release -p designcraft-cli -- script --help # command script syntax (docs/agents.md) cargo xtask ci # fmt, clippy, tests, assets, layering, wasm ``` diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs index c3808f24b..dd62cd91d 100644 --- a/apps/designcraft-cli/src/main.rs +++ b/apps/designcraft-cli/src/main.rs @@ -38,6 +38,37 @@ use serde_json::{Value, json}; /// The usage block. Shared by `--help` (stdout, success) and an unknown command (stderr, failure). const USAGE: &str = "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample]\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli links\n designcraft-cli --version"; +/// `script --help`: the script syntax, otherwise only in `describe script.run` and docs/agents.md. +const SCRIPT_HELP: &str = r#"usage: designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going] + +Runs a command script from FILE (stdin when FILE is `-` or missing), headless or, with +--connect PORT, in the running app (designcraft --control PORT). + +Script text, one step per line: + command.id {"param": 1} also `command.id={json}`, or `command.id` alone + {"command": "command.id", "params": {...}} JSON lines, or a JSON array of them + # comment, // comment skipped, like blank lines + UTF-8 with or without a byte order mark; LF or CRLF line endings. + +References to earlier results (in string parameters): + "$N.path" the value at `path` in the result of step N. N is the 0-based step index: + comments and blank lines are not steps. `$last` is the previous step. + Paths go through result fields and array indices: "$1.story", "$3.ids.0". + "${N.path}" inside a longer string, replaced by the value's text: "Frame ${0.id}". + +Text parameters: "\n" starts a new paragraph, "\u2028" is a forced line break, "\t" a tab. +Most commands ignore parameters they do not know instead of rejecting them: check names +with `designcraft-cli describe COMMAND`. + +Options: + --in FILE / --sample open a document (.designcraft or .idml) or the sample first + --save OUT save as .designcraft after the script succeeds + --export OUT export after the script succeeds (.pdf, .idml, .epub, .png, .jpg); repeatable + --keep-going run every step, recording failures as {"error": ...} results + +Prints {"completed": n, "results": [...]}, plus failedIndex / failedCommand / error and a +non-zero exit when a step fails."#; + /// The links line under the usage block. fn usage_footer() -> String { format!( @@ -335,6 +366,10 @@ fn script(args: &[String]) -> Result<(), String> { "--save" => save = Some(val()?), "--export" => export.push(val()?), "--keep-going" => keep_going = true, + "--help" | "-h" => { + outln!("{SCRIPT_HELP}"); + return Ok(()); + } f if !f.starts_with("--") && file.is_none() => file = Some(f.to_string()), other => return Err(format!("unknown script option `{other}`")), } diff --git a/apps/designcraft-cli/tests/cli.rs b/apps/designcraft-cli/tests/cli.rs index c2804be94..92f68ab56 100644 --- a/apps/designcraft-cli/tests/cli.rs +++ b/apps/designcraft-cli/tests/cli.rs @@ -87,6 +87,21 @@ fn help_goes_to_stdout_and_unknown_args_to_stderr() { assert!(String::from_utf8_lossy(&out.stderr).starts_with("usage: designcraft-cli")); } +/// `script --help` was "unknown script option `--help`"; the line syntax was only in +/// `describe script.run` (#654). +#[test] +fn script_help_documents_the_syntax() { + for flag in ["--help", "-h"] { + let out = cli().args(["script", flag]).output().unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(out.status.success(), "{flag}: {:?} {}", out.status, String::from_utf8_lossy(&out.stderr)); + assert!(stdout.starts_with("usage: designcraft-cli script"), "{flag}: {stdout:?}"); + for needle in ["0-based", "$1.story", "$3.ids.0", "\\u2028", "ignore"] { + assert!(stdout.contains(needle), "{flag}: no {needle:?} in {stdout:?}"); + } + } +} + /// `designcraft-cli commands | head -1` panicked with "failed printing to /// stdout: Broken pipe (os error 32)" and exit status 101. #[test] diff --git a/crates/engine/src/script.rs b/crates/engine/src/script.rs index a447cb6bb..d2304e651 100644 --- a/crates/engine/src/script.rs +++ b/crates/engine/src/script.rs @@ -9,7 +9,8 @@ //! //! **Script text** ([`parse`]): a JSON array of steps, JSON lines (`{"command": …, "params": …}`), //! or compact lines `command.id {json}` / `command.id={json}` / `command.id`; blank lines and -//! lines starting with `#` or `//` are ignored. +//! lines starting with `#` or `//` are ignored. A leading byte order mark and CRLF line endings are +//! accepted. use serde_json::{Value, json}; @@ -22,7 +23,9 @@ pub struct Step { /// Parse script text (see the module docs). pub fn parse(text: &str) -> Result<Vec<Step>, String> { - let t = text.trim(); + // Windows tools save "UTF-8 with BOM"; `trim` keeps U+FEFF, which then became part of the + // first command id. + let t = text.trim_start_matches('\u{feff}').trim(); if t.starts_with('[') { let v: Value = serde_json::from_str(t).map_err(|e| format!("script: {e}"))?; return v @@ -174,6 +177,17 @@ mod tests { assert!(parse("frame.create {bad").unwrap_err().contains("line 1")); } + /// Windows editors and PowerShell 5.1 save "UTF-8 with BOM" with CRLF line endings; the BOM + /// became part of the first command id (#654). + #[test] + fn a_leading_bom_and_crlf_parse_like_plain_text() { + let plain = "file.new {\"width\": 100}\nframe.create {\"rect\": [0, 0, 10, 10]}\n"; + let windows = format!("\u{feff}{}", plain.replace('\n', "\r\n")); + assert_eq!(parse(&windows).unwrap(), parse(plain).unwrap()); + let array = r#"[{"command": "file.new"}]"#; + assert_eq!(parse(&format!("\u{feff}{array}")).unwrap(), parse(array).unwrap()); + } + #[test] fn resolves_references() { let results = vec![json!({"id": 8, "story": 9, "ids": [4, 5]}), json!({"name": "A"})]; diff --git a/docs/agents.md b/docs/agents.md index 0dd23f062..5115654a2 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -21,8 +21,12 @@ Parameters are documented on each command (`params`), e.g. `{anchor? | paragraph ## Scripts: several commands, later ones using earlier results A script is one command per line (`command.id {json params}`; `#` comments), JSON lines, or a JSON array of -`{command, params}`. A parameter string `"$N.path"` becomes that value of step N's result (0-based, `$last` for the -previous step); `"${N.path}"` interpolates inside longer strings. +`{command, params}`, in UTF-8 with or without a byte order mark. A parameter string `"$N.path"` becomes that value +of step N's result: N is the 0-based step index (comments and blank lines are not steps, `$last` is the previous +step) and the path goes through result fields and array indices (`"$1.story"`, `"$3.ids.0"`); `"${N.path}"` +interpolates inside longer strings. 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`. +`designcraft-cli script --help` prints this reference. ```sh cat > page.dcs <<'DCS' From 260af3b5dfdc41476a8e9ba137db56ea0be468f4 Mon Sep 17 00:00:00 2001 From: fabian-co <fabian-co@hotmail.com> Date: Mon, 12 Oct 2026 17:45:45 -0500 Subject: [PATCH 110/238] macOS: open the window on Intel Metal by skipping indirect-call validation wgpu validates indirect draw and dispatch arguments with a compute shader it compiles when the device is created. Metal on an Intel Iris Pro (MacBookPro11,3, macOS 12.7.6) fails to compile it ("Compiler encountered an internal error" in MTLCompilerService), the device is lost, and the window never opens. DesignCraft draws and dispatches nothing indirectly, so leave the check out of the instance flags. WGPU_VALIDATION_INDIRECT_CALL=1 turns it back on. VectorCraft does the same (storytold/vectorcraft#651). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- README.md | 1 + apps/designcraft/src/main.rs | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/README.md b/README.md index 39fbe7100..b78267247 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,7 @@ The file stops growing at 16 MiB. `--version` writes no file. |---|---| | `RUST_LOG` | Log levels for standard error and the log file. Default: `info` for DesignCraft's own crates, `warn` for everything else. env_logger-style directives replace that, e.g. `RUST_LOG=debug`, `RUST_LOG=warn,designcraft_render=trace` or `RUST_LOG=info,wgpu_core=warn`; a directive ending in `*` covers every target starting with it (`designcraft*=debug`). | | `WGPU_BACKEND` | The graphics backend that composites the window: `dx12`, `vulkan`, `gl` or `metal` (a comma-separated list lets wgpu choose among them). Default: DirectX 12 on Windows, Vulkan on Linux, Metal on macOS, with OpenGL as the fallback. Setting it also turns off the start-up fallback described below. | +| `WGPU_VALIDATION_INDIRECT_CALL` | `1` turns wgpu's check of indirect draw arguments back on. DesignCraft draws nothing indirectly and turns it off, since the compute shader the check needs fails to compile on some drivers (Metal on an Intel Iris Pro), which lost the device before the window opened. | The logger is `apps/designcraft/src/logging.rs`; the web build logs to the browser console instead. diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index 26ce2ee4f..d91ca40cb 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -289,6 +289,14 @@ fn main() -> eframe::Result { { create.instance_descriptor.backends = s.backends(); } + // Nothing draws or dispatches indirectly, so wgpu's check of indirect arguments only costs a + // compute shader when the device is created, one that Metal can't compile on an Intel Iris + // Pro: the device is lost and the window never opens. `WGPU_VALIDATION_INDIRECT_CALL=1` turns + // the check back on. + if let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup { + create.instance_descriptor.flags = + create.instance_descriptor.flags.difference(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env(); + } // winit has no file drag-and-drop on Wayland (only on X11), so dropping images from the file // manager showed a "no" cursor. Run through XWayland when it's there; DESIGNCRAFT_WAYLAND=1 // keeps the native Wayland backend. From cc5b4a1f0f9dac7fc509191746ec048d6c8ffb1a Mon Sep 17 00:00:00 2001 From: Jason Brashear <115739170+webdevtodayjason@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:20:54 -0500 Subject: [PATCH 111/238] fix(color): mirror gradient midpoints when reversing Move and mirror segment midpoints when reversing gradient stops so reversed gradients preserve color and opacity interpolation. Validated locally on macOS with all six cargo xtask ci gates (1,277 tests passed, four existing tests ignored, 15 WASM compilation checks), plus independent regressions, undo/redo, native and IDML round trips, and PNG/PDF rendering. Refs #638 --- crates/color/src/gradient.rs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index c5e30a98d..4da4f2d84 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -104,9 +104,12 @@ impl Gradient { out } pub fn reverse(&mut self) { + // A midpoint belongs to the segment after its stop, so it moves with that segment and mirrors. + let mids: Vec<_> = self.stops.iter().rev().skip(1).map(|s| 1.0 - s.midpoint).collect(); self.stops.reverse(); - for s in &mut self.stops { + for (s, m) in self.stops.iter_mut().zip(mids.into_iter().chain([0.5])) { s.offset = 1.0 - s.offset; + s.midpoint = m; } } pub fn sort(&mut self) { @@ -219,8 +222,24 @@ mod tests { #[test] fn reverse_stops() { let mut g = Gradient::default(); + g.stops[0].midpoint = 0.25; g.reverse(); assert_eq!(g.stops[0].color.to_hex(), "#000000"); assert_eq!(g.stops[0].offset, 0.0); + // The midpoint moves to its reversed segment and mirrors. + assert!((g.sample(0.75).0.to_rgb()[0] - 0.5).abs() < 1e-5); + assert_eq!(g.stops.iter().map(|s| s.midpoint).collect::<Vec<_>>(), [0.75, 0.5]); + + // With several stops, the reversed gradient is the original mirrored. + let mut g = Gradient::default(); + g.stops[0].midpoint = 0.3; + g.stops.insert(1, GradientStop { offset: 0.4, color: Color::rgb(1.0, 0.0, 0.0), opacity: 0.5, midpoint: 0.8 }); + let original = g.clone(); + g.reverse(); + for t in [0.1, 0.3, 0.5, 0.7, 0.9] { + let ((c, o), (want_c, want_o)) = (g.sample(t), original.sample(1.0 - t)); + assert!(c.to_rgb().iter().zip(want_c.to_rgb()).all(|(a, b)| (a - b).abs() < 1e-5), "colour at {t}"); + assert!((o - want_o).abs() < 1e-5, "opacity at {t}"); + } } } From 91263c912f1c1f2c61c087541d446e88edf43401 Mon Sep 17 00:00:00 2001 From: Jason Brashear <115739170+webdevtodayjason@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:21:52 -0500 Subject: [PATCH 112/238] fix(textimport): preserve leading empty Tagged Text paragraphs Preserve paragraph breaks after a ParaStyle tag starts the story so leading empty paragraphs and their styles survive Tagged Text import. Validated individually and after #638 with all six cargo xtask ci gates (1,278 tests passed, four existing tests ignored, 15 WASM compilation checks), six independent paragraph/header/Unicode probes, and an actual export-to-placement command smoke test. Refs #641 --- crates/textimport/src/tagged.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/textimport/src/tagged.rs b/crates/textimport/src/tagged.rs index cfd08eea7..b84577eae 100644 --- a/crates/textimport/src/tagged.rs +++ b/crates/textimport/src/tagged.rs @@ -99,6 +99,7 @@ pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> { let chars: Vec<char> = text.chars().collect(); // Paragraphs: (style, runs of (text, format)). let mut paras: Vec<(String, Vec<(String, CharFormat)>)> = vec![(String::new(), Vec::new())]; + let mut started = false; let mut fmt = CharFormat::default(); let mut para_styles: Vec<String> = Vec::new(); let mut char_styles: Vec<String> = Vec::new(); @@ -151,6 +152,7 @@ pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> { } } "ParaStyle" => { + started = true; let v = value.trim().to_string(); if !v.is_empty() && !para_styles.contains(&v) { para_styles.push(v.clone()); @@ -187,7 +189,7 @@ pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> { } let style = paras.last().map(|p| p.0.clone()).unwrap_or_default(); // Header lines before any text don't start paragraphs. - if paras.len() == 1 && paras[0].1.is_empty() { + if !started && paras.len() == 1 && paras.first().is_some_and(|p| p.1.is_empty()) { i += 1; continue; } @@ -243,6 +245,18 @@ mod tests { assert_eq!(import("<ASCII-WIN>\r\n<ParaStyle:Body>A<0xD83D>B<0xDE00>C".as_bytes()).unwrap().story.text, "ABC"); } + #[test] + fn tagged_text_keeps_a_leading_empty_paragraph() { + let doc = Document::new(&designcraft_doc::build::NewDocument::default()); + let mut story = Story::new(StoryId(0)); + story.insert(0, "\nText"); + story.paras[0].style = "Head".into(); + story.paras[1].style = "Body".into(); + let back = import(tagged_text(&doc, &story).as_bytes()).unwrap(); + assert_eq!(back.story.text, story.text); + assert_eq!(back.story.paras, story.paras); + } + #[test] fn tagged_text_round_trips() { let src = "<ASCII-WIN>\r\n<Version:7>\r\n<ParaStyle:Head>Caf<0x00E9> \\<1\\>\r\n<ParaStyle:Body>A <CharStyle:Em>word<CharStyle:> and <cSize:18.000000>big<cSize:> text"; From 7abcbc745f33daf175af8b09e52ae12400ce5ec8 Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:44:20 +1000 Subject: [PATCH 113/238] PARITY-DATA-SOURCE: honor quoted delimiters and record boundaries Co-Authored-By: Codex <codex@openai.com> --- crates/engine/src/cmd/datamerge/parse.rs | 106 +++++++-- crates/engine/src/cmd/datamerge/tests.rs | 276 +++++++++++++++++++++++ 2 files changed, 369 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/datamerge/parse.rs b/crates/engine/src/cmd/datamerge/parse.rs index e65493dfc..6ab10f3d9 100644 --- a/crates/engine/src/cmd/datamerge/parse.rs +++ b/crates/engine/src/cmd/datamerge/parse.rs @@ -367,10 +367,16 @@ fn sniff(name: &str, text: &str) -> Delimiter { if ext == "csv" { return Delimiter::Comma; } - let header = first_physical_line(text); - if header.contains('\t') { + let tab = header_delimiter_candidate(text, '\t'); + let semicolon = header_delimiter_candidate(text, ';'); + let comma = header_delimiter_candidate(text, ','); + // Prefer formats with coherent quoted-field boundaries. If none separates fields, + // retain the legacy permissive choice; this is detection, not record validation. + let prefer_coherent = [&tab, &semicolon, &comma].iter().any(|c| c.found && c.coherent); + let eligible = |c: &HeaderDelimiterCandidate| c.found && (!prefer_coherent || c.coherent); + if eligible(&tab) { Delimiter::Tab - } else if header.contains(';') && !header.contains(',') { + } else if eligible(&semicolon) && !eligible(&comma) { Delimiter::Semicolon } else { Delimiter::Comma @@ -381,8 +387,66 @@ pub fn extension(name: &str) -> String { Path::new(name).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase() } -fn first_physical_line(text: &str) -> &str { - text.split('\n').next().unwrap_or("").trim_end_matches('\r') +struct HeaderDelimiterCandidate { + found: bool, + coherent: bool, +} + +/// A candidate separator outside quotes in its first nonempty logical record. +/// Only that candidate starts a new field. Quotes in an unquoted field stay literal; +/// Non-whitespace text after a quoted field closes lowers coherence, not validity. +fn header_delimiter_candidate(text: &str, delimiter: char) -> HeaderDelimiterCandidate { + let mut found = false; + let mut coherent = true; + let mut quoted = false; + let mut after_quote = false; + let mut field_start = true; + let mut nonempty = false; + let mut chars = text.chars().peekable(); + while let Some(c) = chars.next() { + if quoted { + if c == '"' { + if chars.peek() == Some(&'"') { + chars.next(); + nonempty = true; + } else { + quoted = false; + after_quote = true; + } + } else if !c.is_whitespace() { + nonempty = true; + } + continue; + } + match c { + '"' if field_start => { + quoted = true; + field_start = false; + } + separator if separator == delimiter => { + found = true; + field_start = true; + after_quote = false; + } + '\r' | '\n' => { + if nonempty { + break; + } + found = false; + coherent = true; + field_start = true; + after_quote = false; + } + _ => { + // Whitespace may follow a closing quote, but does not start a new field + // or allow a later non-whitespace suffix to regain coherence. + coherent &= !after_quote || c.is_whitespace(); + nonempty |= !c.is_whitespace(); + field_start = false; + } + } + } + HeaderDelimiterCandidate { found, coherent: coherent && !quoted } } /// Header cell: trim, drop one leading apostrophe, then `@` is image and `#` is qr. @@ -452,6 +516,7 @@ fn parse_delimited(s: &str, delim: char) -> Result<Vec<RawRow>, String> { let mut row: Vec<String> = Vec::new(); let mut field = String::new(); let mut quoted = false; + let mut field_start = true; let mut line = 1usize; let mut row_line = 1usize; let mut data_rows = 0usize; @@ -501,21 +566,25 @@ fn parse_delimited(s: &str, delim: char) -> Result<Vec<RawRow>, String> { }; while let Some(c) = chars.next() { - if c == '\n' { + if c == '\n' || (c == '\r' && chars.peek() != Some(&'\n')) { line = line.saturating_add(1); } if quoted { match c { '"' if chars.peek() == Some(&'"') => { - field.push('"'); + if !discarding { + field.push('"'); + } chars.next(); } '"' => quoted = false, - _ => field.push(c), + _ if !discarding => field.push(c), + _ => {} } continue; } if c == delim { + field_start = true; if discarding { field.clear(); continue; @@ -531,14 +600,25 @@ fn parse_delimited(s: &str, delim: char) -> Result<Vec<RawRow>, String> { continue; } match c { - '"' if field.is_empty() && !discarding => quoted = true, - '\r' => {} - '\n' => { + '"' if field_start => { + quoted = true; + field_start = false; + } + '\r' | '\n' => { + if c == '\r' && chars.peek() == Some(&'\n') { + chars.next(); + line = line.saturating_add(1); + } finish(&mut row, &mut field, &mut rows, &mut data_rows, &mut header_width, row_line, &mut discarding)?; row_line = line; + field_start = true; + } + _ => { + field_start = false; + if !discarding { + field.push(c); + } } - _ if discarding => {} - _ => field.push(c), } } if !field.is_empty() || !row.is_empty() || discarding { diff --git a/crates/engine/src/cmd/datamerge/tests.rs b/crates/engine/src/cmd/datamerge/tests.rs index e4636d109..f4c2004e4 100644 --- a/crates/engine/src/cmd/datamerge/tests.rs +++ b/crates/engine/src/cmd/datamerge/tests.rs @@ -85,6 +85,282 @@ fn csv_parsing() { assert_eq!(quoted[1][0], "Ada\nGrace"); } +#[test] +fn data_source_sniffs_quoted_logical_headers() { + for (body, delimiter, names) in [ + ("\"Last, First\";City\nAda;London\n", Delimiter::Semicolon, ["Last, First", "City"]), + ("\"Name\tLabel\",City\nAda,London\n", Delimiter::Comma, ["Name\tLabel", "City"]), + ("\"Name\nLabel\";City\nAda;London\n", Delimiter::Semicolon, ["Name\nLabel", "City"]), + ("\r\n \r\nName;City\r\nAda;London\r\n", Delimiter::Semicolon, ["Name", "City"]), + ("\"Name \"\"label,\"\"\";City\nAda;London\n", Delimiter::Semicolon, ["Name \"label,\"", "City"]), + ] { + let mut s = Session::new(); + new_doc(&mut s, 1); + let result = exec(&mut s, "data.source.select", json!({"bytes": crate::cmd::base64_encode(body.as_bytes()), "name": "people.txt"})); + assert_eq!(result["records"], 1, "{body:?}"); + let source = &s.documents()[0].doc.data_merge.sources[0]; + assert_eq!(source.delimiter, delimiter, "{body:?}"); + assert_eq!(source.fields.iter().map(|f| f.name.as_str()).collect::<Vec<_>>(), names, "{body:?}"); + assert_eq!(source.rows, vec![vec!["Ada", "London"]], "{body:?}"); + } +} + +#[test] +fn data_source_sniff_quote_state_belongs_to_the_candidate_delimiter() { + for ending in ["\n", "\r\n", "\r"] { + for (body, delimiter, names) in [ + (format!("Name,\"preferred\tCity\"{ending}Ada\tLondon{ending}"), Delimiter::Tab, ["Name,\"preferred", "City\""]), + (format!("\"Last, First\";City{ending}Ada;London{ending}"), Delimiter::Semicolon, ["Last, First", "City"]), + (format!("\"Name\tLabel\",City{ending}Ada,London{ending}"), Delimiter::Comma, ["Name\tLabel", "City"]), + (format!("\"\";\"\"{ending} \t{ending}\"Last, First\";City{ending}Ada;London{ending}"), Delimiter::Semicolon, ["Last, First", "City"]), + ] { + let automatic = parse::table_from_bytes(body.as_bytes(), "people.txt", None).unwrap(); + let explicit = parse::table_from_bytes(body.as_bytes(), "people.txt", Some(delimiter)).unwrap(); + let fields: Vec<_> = names.into_iter().map(|name| DataField { name: name.into(), kind: DataFieldKind::Text }).collect(); + assert_eq!(automatic.delimiter, delimiter, "{body:?}"); + assert_eq!(automatic.fields, fields, "{body:?}"); + assert_eq!(automatic.rows, vec![vec!["Ada", "London"]], "{body:?}"); + assert!(automatic.warnings.is_empty(), "{body:?}: {:?}", automatic.warnings); + assert_eq!(automatic, explicit, "{body:?}"); + } + } +} + +#[test] +fn data_source_sniff_prefers_coherent_quoted_field_boundaries() { + for ending in ["\n", "\r\n", "\r"] { + for (header, first, second, delimiter, names, rows, warnings) in [ + ( + "\"Name\";\"Last, First\"", + "\"Ada\";\"Lovelace, Countess\"", + "\"Grace\";\"Hopper, Admiral\"", + Delimiter::Semicolon, + vec!["Name", "Last, First"], + vec![vec!["Ada", "Lovelace, Countess"], vec!["Grace", "Hopper, Admiral"]], + vec![], + ), + ( + "\"Name\",\"Region\tCity\"", + "\"Ada\",\"London\"", + "\"Grace\",\"Paris\"", + Delimiter::Comma, + vec!["Name", "Region\tCity"], + vec![vec!["Ada", "London"], vec!["Grace", "Paris"]], + vec![], + ), + ( + "\"Name\";\"City\";\"Last, \"\"First\"\"\";\"Title\"", + "\"Ada\";\"London\";\"Lovelace, \"\"Countess\"\"\";\"Analyst\"", + "\"Grace\";\"Paris\";\"Hopper, \"\"Admiral\"\"\";\"Engineer\";\"ignored\"", + Delimiter::Semicolon, + vec!["Name", "City", "Last, \"First\"", "Title"], + vec![vec!["Ada", "London", "Lovelace, \"Countess\"", "Analyst"], vec!["Grace", "Paris", "Hopper, \"Admiral\"", "Engineer"]], + vec!["Row 3 has cells past the header. Those cells were ignored."], + ), + ( + "\"Name\",\"Role\",\"Region\t\"\"City\"\"\",\"Title\"", + "\"Ada\",\"Analyst\",\"London\t\"\"West End\"\"\",\"Countess\"", + "\"Grace\",\"Engineer\",\"Paris\t\"\"Centre\"\"\",\"Admiral\",\"ignored\"", + Delimiter::Comma, + vec!["Name", "Role", "Region\t\"City\"", "Title"], + vec![vec!["Ada", "Analyst", "London\t\"West End\"", "Countess"], vec!["Grace", "Engineer", "Paris\t\"Centre\"", "Admiral"]], + vec!["Row 3 has cells past the header. Those cells were ignored."], + ), + ] { + let body = format!("{header}{ending}{first}{ending}{second}{ending}"); + let automatic = parse::table_from_bytes(body.as_bytes(), "people.txt", None).unwrap(); + let explicit = parse::table_from_bytes(body.as_bytes(), "people.txt", Some(delimiter)).unwrap(); + let fields: Vec<_> = names.into_iter().map(|name| DataField { name: name.into(), kind: DataFieldKind::Text }).collect(); + assert_eq!(automatic.delimiter, delimiter, "{body:?}"); + assert_eq!(automatic.fields, fields, "{body:?}"); + assert_eq!(automatic.rows, rows, "{body:?}"); + assert_eq!(automatic.warnings, warnings, "{body:?}"); + assert_eq!(automatic, explicit, "{body:?}"); + } + } +} + +#[test] +fn data_source_sniff_retains_priority_and_permissive_fallback() { + for ending in ["\n", "\r\n", "\r"] { + for (header, row, delimiter, names, cells) in [ + // Equally coherent candidates retain Tab, then Comma, then Semicolon priority. + ("Name,Title\tCity", "Ada,Analyst\tLondon\tignored", Delimiter::Tab, ["Name,Title", "City"], ["Ada,Analyst", "London"]), + ("Name;Title,City", "Ada;Analyst,London,ignored", Delimiter::Comma, ["Name;Title", "City"], ["Ada;Analyst", "London"]), + // No coherent candidate separates fields here. Keep parsing the trailing text + // after a quoted field exactly as before, using the same delimiter priority. + ( + "\"Name\"suffix,Title\tCity", + "\"Ada\"suffix,Analyst\tLondon\tignored", + Delimiter::Tab, + ["Namesuffix,Title", "City"], + ["Adasuffix,Analyst", "London"], + ), + ( + "\"Name\"suffix;Title,City", + "\"Ada\"suffix;Analyst,London,ignored", + Delimiter::Comma, + ["Namesuffix;Title", "City"], + ["Adasuffix;Analyst", "London"], + ), + ("\"Name\"suffix;City", "\"Ada\"suffix;London;ignored", Delimiter::Semicolon, ["Namesuffix", "City"], ["Adasuffix", "London"]), + ] { + let body = format!("{header}{ending}{row}{ending}"); + let automatic = parse::table_from_bytes(body.as_bytes(), "people.txt", None).unwrap(); + let explicit = parse::table_from_bytes(body.as_bytes(), "people.txt", Some(delimiter)).unwrap(); + let fields: Vec<_> = names.into_iter().map(|name| DataField { name: name.into(), kind: DataFieldKind::Text }).collect(); + assert_eq!(automatic.delimiter, delimiter, "{body:?}"); + assert_eq!(automatic.fields, fields, "{body:?}"); + assert_eq!(automatic.rows, vec![cells.to_vec()], "{body:?}"); + assert_eq!(automatic.warnings, vec!["Row 2 has cells past the header. Those cells were ignored."], "{body:?}"); + assert_eq!(automatic, explicit, "{body:?}"); + } + } +} + +#[test] +fn data_source_sniff_allows_post_quote_whitespace() { + for ending in ["\n", "\r\n", "\r"] { + for space in [" ", "\u{00A0}", "\u{2003}"] { + for (header, separator, delimiter, names) in [ + (format!("\"Name\"{space};\"Last, First\""), ';', Delimiter::Semicolon, ["Name", "Last, First"]), + (format!("\"Name\"{space},\"Region\tCity\"{space}"), ',', Delimiter::Comma, ["Name", "Region\tCity"]), + (format!("\"Name\"{space}\t\"Last, First; City\"{space}"), '\t', Delimiter::Tab, ["Name", "Last, First; City"]), + ] { + let body = format!( + "{header}{ending}\"Ada\"{space}{separator}\"London\"{space}{ending}\"Grace\"{space}{separator}\"Paris\"{space}{separator}ignored{ending}" + ); + let automatic = parse::table_from_bytes(body.as_bytes(), "people.txt", None).unwrap(); + let explicit = parse::table_from_bytes(body.as_bytes(), "people.txt", Some(delimiter)).unwrap(); + let fields: Vec<_> = names.into_iter().map(|name| DataField { name: name.into(), kind: DataFieldKind::Text }).collect(); + assert_eq!(automatic.delimiter, delimiter, "{body:?}"); + assert_eq!(automatic.fields, fields, "{body:?}"); + // Header classification trims; record values must keep their whitespace. + assert_eq!( + automatic.rows, + vec![vec![format!("Ada{space}"), format!("London{space}")], vec![format!("Grace{space}"), format!("Paris{space}")]], + "{body:?}" + ); + assert_eq!(automatic.warnings, vec!["Row 3 has cells past the header. Those cells were ignored."], "{body:?}"); + assert_eq!(automatic, explicit, "{body:?}"); + } + } + } +} + +#[test] +fn data_source_sniff_post_quote_whitespace_keeps_closed_field_state() { + for ending in ["\n", "\r\n", "\r"] { + for space in [" ", "\u{00A0}", "\u{2003}"] { + for suffix in ["suffix", "\"suffix\""] { + let body = format!("\"Name\"{space}{suffix};\"Last, First\"{ending}Ada,London,ignored{ending}"); + // Both separated formats have incompatible post-quote suffixes. Keep + // the permissive Comma fallback, including literal quotes in the name. + let automatic = parse::table_from_bytes(body.as_bytes(), "people.txt", None).unwrap(); + let explicit = parse::table_from_bytes(body.as_bytes(), "people.txt", Some(Delimiter::Comma)).unwrap(); + assert_eq!(automatic.delimiter, Delimiter::Comma, "{body:?}"); + assert_eq!( + automatic.fields, + vec![ + DataField { name: format!("Name{space}{suffix};\"Last"), kind: DataFieldKind::Text }, + DataField { name: "First\"".into(), kind: DataFieldKind::Text }, + ], + "{body:?}" + ); + assert_eq!(automatic.rows, vec![vec!["Ada", "London"]], "{body:?}"); + assert_eq!(automatic.warnings, vec!["Row 2 has cells past the header. Those cells were ignored."], "{body:?}"); + assert_eq!(automatic, explicit, "{body:?}"); + } + } + } +} + +#[test] +fn data_source_literal_quote_header_merges_the_detected_column() { + let mut s = Session::new(); + new_doc(&mut s, 1); + text_frame(&mut s, [36.0, 36.0, 240.0, 80.0], "", None); + let body = "Name,\"preferred\tCity\"\nAda\tLondon\nGrace\tParis\n"; + let selected = exec(&mut s, "data.source.select", json!({"bytes": crate::cmd::base64_encode(body.as_bytes()), "name": "people.txt"})); + assert_eq!(selected["records"], 2); + let source = &s.documents()[0].doc.data_merge.sources[0]; + assert_eq!(source.delimiter, Delimiter::Tab); + assert_eq!(source.rows, vec![vec!["Ada", "London"], vec!["Grace", "Paris"]]); + exec(&mut s, "data.placeholder.add", json!({"field": "City\""})); + let merged = exec(&mut s, "data.merge", json!({})); + assert_eq!(merged["records"], 2); + assert_eq!(merged["pages"], 2); + assert_eq!(texts(&s), vec!["London", "Paris"]); +} + +#[test] +fn data_source_preserves_quoted_newlines_and_counts_record_lines() { + for ending in ["\n", "\r\n", "\r"] { + let body = format!("Name,City{ending}\"Ada{ending}Lovelace\",London{ending}Grace,Paris,extra{ending}"); + let mut s = Session::new(); + new_doc(&mut s, 1); + let result = select_csv(&mut s, &body); + assert_eq!(result["records"], 2, "{ending:?}"); + let source = &s.documents()[0].doc.data_merge.sources[0]; + assert_eq!(source.rows, vec![vec![format!("Ada{ending}Lovelace"), "London".into()], vec!["Grace".into(), "Paris".into()]]); + assert_eq!(source.warnings.len(), 1); + assert!(source.warnings[0].contains("Row 4"), "{:?}", source.warnings); + } +} + +#[test] +fn data_source_quoted_header_and_cr_records_merge_independently() { + let mut s = Session::new(); + new_doc(&mut s, 1); + text_frame(&mut s, [36.0, 36.0, 240.0, 80.0], "", None); + let body = "\u{FEFF}\"Last, First\";City\rAda;London\rGrace;Paris\r"; + exec(&mut s, "data.source.select", json!({"bytes": crate::cmd::base64_encode(body.as_bytes()), "name": "people.txt"})); + exec(&mut s, "data.placeholder.add", json!({"field": "Last, First"})); + let template = s.documents()[0].doc.clone(); + let result = exec(&mut s, "data.merge", json!({})); + assert_eq!(result["records"], 2); + assert_eq!(result["pages"], 2); + assert_eq!(texts(&s), vec!["Ada", "Grace"]); + assert_eq!(serde_json::to_value(&s.documents()[0].doc).unwrap(), serde_json::to_value(&template).unwrap()); +} + +#[test] +fn data_source_discarded_quoted_fields_keep_records_and_warning_lines() { + for ending in ["\n", "\r\n", "\r"] { + for (discarded, following_line) in [(format!("\"Address \"\"line 1\"\"{ending}Address line 2\""), 4), ("literal\"quote".into(), 3)] { + let mut s = Session::new(); + new_doc(&mut s, 1); + text_frame(&mut s, [36.0, 36.0, 240.0, 80.0], "", None); + let body = format!("Name{ending}Ada,,,{discarded}{ending}Grace,extra{ending}"); + let selected = select_csv(&mut s, &body); + assert_eq!(selected["records"], 2, "{body:?}"); + let source = &s.documents()[0].doc.data_merge.sources[0]; + assert_eq!(source.rows, vec![vec!["Ada"], vec!["Grace"]], "{body:?}"); + assert_eq!(source.warnings.len(), 2, "{:?}", source.warnings); + assert!(source.warnings[0].contains("Row 2"), "{:?}", source.warnings); + assert!(source.warnings[1].contains(&format!("Row {following_line}")), "{:?}", source.warnings); + exec(&mut s, "data.placeholder.add", json!({"field": "Name"})); + let merged = exec(&mut s, "data.merge", json!({})); + assert_eq!(merged["records"], 2); + assert_eq!(merged["pages"], 2); + assert_eq!(texts(&s), vec!["Ada", "Grace"]); + } + } +} + +#[test] +fn data_source_delimiter_override_and_known_extensions_win() { + let body = "Name;City\nAda;London\n"; + let csv = parse::table_from_bytes(body.as_bytes(), "people.csv", None).unwrap(); + assert_eq!(csv.delimiter, Delimiter::Comma); + assert_eq!(csv.rows, vec![vec!["Ada;London"]]); + let explicit = parse::table_from_bytes(body.as_bytes(), "people.csv", Some(Delimiter::Semicolon)).unwrap(); + assert_eq!(explicit.rows, vec![vec!["Ada", "London"]]); + let tab = parse::table_from_bytes(b"Name,Title\tCity\nAda,Analyst\tLondon", "people.tsv", None).unwrap(); + assert_eq!(tab.delimiter, Delimiter::Tab); + assert_eq!(tab.rows, vec![vec!["Ada,Analyst", "London"]]); +} + #[test] fn text_sniff_padding_skips_and_warnings() { let dir = scratch("sniff"); From f7adb11cdc31b8997389c0fc7cb4fafab8cb4e7f Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:46:10 +1000 Subject: [PATCH 114/238] PARITY-CHARACTER-STYLE: capture selected character formatting Co-Authored-By: Codex <codex@openai.com> --- crates/engine/src/cmd/style.rs | 23 +- .../tests/character_style_from_selection.rs | 274 ++++++++++++++++++ crates/ui-egui/src/panels/styles.rs | 41 +++ 3 files changed, 336 insertions(+), 2 deletions(-) create mode 100644 crates/engine/tests/character_style_from_selection.rs diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 8a520b685..aa1cc4a5f 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -105,7 +105,7 @@ pub fn specs() -> Vec<CommandSpec> { "New Character Style…", [], None, - "{name, basedOn?: style | \"[None]\", chars?: {…}} — the style sets only `chars` → {name} (made unique)", + "{name, basedOn?: style | \"[None]\", chars?: {…}, fromSelection?: bool (default false)} — if true, capture the first selected character's style/local overrides (caret: typing format), then overlay chars (null unsets); only explicit fill names are validated; paragraph formatting stays inherited; no text target: chars only → {name} (made unique)", has_doc, create_char ), @@ -622,8 +622,27 @@ fn delete_para(s: &mut Session, p: &Value) -> Result<Value> { fn create_char(s: &mut Session, p: &Value) -> Result<Value> { let base = str_param(p, "name").unwrap_or("Character Style 1").to_string(); - let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?; + let mut chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?; + // Validate caller-supplied fills; captured formatting may retain a deleted swatch name. super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.character.create")?; + if p.get("fromSelection").and_then(Value::as_bool).unwrap_or(false) + && let Some(target) = super::text::format_targets_pub(s).first() + { + let st = s.doc()?; + let story = st.doc.text_story(target.story, target.cell).ok_or(designcraft_doc::DocError::NoStory(target.story))?; + // Match paragraph fromSelection's first-character/caret policy, but keep paragraph + // attributes inherited instead of freezing the fully resolved character properties. + let format = if target.range.is_empty() { story.char_format_at(target.range.start) } else { story.format_after(target.range.start) }; + let mut captured = st.doc.styles.char_style_attrs(&format.style); + captured.merge(&format.over); + // Replay the explicit fields so null clears a captured value rather than looking absent. + if let Some(overrides) = p.get("chars").and_then(Value::as_object) { + for (key, value) in overrides { + captured.set_json(key, value).map_err(|e| bad("style", e))?; + } + } + chars = captured; + } // Based on [None] is based on nothing. let based_on = str_param(p, "basedOn").filter(|b| *b != designcraft_doc::NO_CHAR_STYLE).map(str::to_string); s.edit(|d, _| { diff --git a/crates/engine/tests/character_style_from_selection.rs b/crates/engine/tests/character_style_from_selection.rs new file mode 100644 index 000000000..0c9d80831 --- /dev/null +++ b/crates/engine/tests/character_style_from_selection.rs @@ -0,0 +1,274 @@ +//! Character Styles panel's existing `fromSelection` command contract. +use designcraft_doc::{CharAttrs, StoryId}; +use designcraft_engine::Session; +use serde_json::{Value, json}; + +fn story(text: &str) -> (Session, u64) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let frame = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": text})).unwrap(); + (s, frame["story"].as_u64().unwrap()) +} + +fn select(s: &mut Session, sid: u64, anchor: usize, focus: usize) { + s.execute("text.select", &json!({"story": sid, "anchor": anchor, "focus": focus})).unwrap(); +} + +fn chars(s: &Session, name: &str) -> CharAttrs { + s.doc().unwrap().doc.styles.char_style(name).unwrap().chars.clone() +} + +fn document(s: &Session) -> Value { + serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap() +} + +#[test] +fn character_capture_overlays_style_chain_local_and_explicit_attrs_but_inherits_paragraph() { + let (mut s, sid) = story("alpha\nbeta"); + s.execute("style.paragraph.create", &json!({"name": "Source paragraph", "chars": {"size": 18}})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Destination", "chars": {"size": 30, "underline": true}})).unwrap(); + s.execute("style.character.create", &json!({"name": "Base", "chars": {"underline": true, "tracking": 10, "strikethrough": true}})).unwrap(); + s.execute("style.character.create", &json!({"name": "Emphasis", "basedOn": "Base", "chars": {"fontStyle": "Italic"}})).unwrap(); + select(&mut s, sid, 0, 5); + s.execute("style.paragraph.apply", &json!({"name": "Source paragraph"})).unwrap(); + s.execute("style.character.apply", &json!({"name": "Emphasis"})).unwrap(); + s.execute("type.char", &json!({"tracking": 20, "underline": false})).unwrap(); + s.execute("style.character.create", &json!({"name": "Captured", "fromSelection": true, "chars": {"tracking": 7}})).unwrap(); + assert_eq!( + chars(&s, "Captured"), + CharAttrs { font_style: Some("Italic".into()), underline: Some(false), tracking: Some(7.0), strikethrough: Some(true), ..Default::default() } + ); + select(&mut s, sid, 6, 10); + s.execute("style.paragraph.apply", &json!({"name": "Destination"})).unwrap(); + s.execute("style.character.apply", &json!({"name": "Captured"})).unwrap(); + let resolved = s.execute("type.selectionAttrs", &json!({})).unwrap(); + assert_eq!(resolved["chars"]["fontStyle"], "Italic"); + assert_eq!(resolved["chars"]["strikethrough"], true); + assert_eq!(resolved["chars"]["underline"], false); + assert_eq!(resolved["chars"]["tracking"], 7.0); + assert_eq!(resolved["chars"]["size"], 30.0); + s.execute("style.paragraph.edit", &json!({"name": "Destination", "chars": {"size": 34}})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"], 34.0); + // The captured attributes are a snapshot, independent of future source-style edits. + s.execute("style.character.edit", &json!({"name": "Emphasis", "chars": {"fontStyle": "Bold"}})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fontStyle"], "Italic"); +} + +#[test] +fn character_capture_reproducer_is_one_undo_step_and_survives_native_reopen() { + let (mut s, sid) = story("alpha beta"); + select(&mut s, sid, 0, 5); + s.execute("type.char", &json!({"underline": true})).unwrap(); + let before = document(&s); + let selection = s.doc().unwrap().selection.clone(); + let undo_len = s.doc().unwrap().history.undo.len(); + let source = serde_json::to_value(s.doc().unwrap().doc.story(StoryId(sid)).unwrap()).unwrap(); + s.execute("style.character.create", &json!({"name": "Captured", "fromSelection": true})).unwrap(); + assert_eq!(chars(&s, "Captured"), CharAttrs { underline: Some(true), ..Default::default() }); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_len + 1); + assert_eq!(s.doc().unwrap().selection, selection); + assert_eq!(serde_json::to_value(s.doc().unwrap().doc.story(StoryId(sid)).unwrap()).unwrap(), source); + let after = document(&s); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(document(&s), before); + assert_eq!(s.doc().unwrap().selection, selection); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(document(&s), after); + assert_eq!(s.doc().unwrap().selection, selection); + + // Both commands use the native ZIP format, without a filesystem fixture. + let saved = s.execute("file.serialize", &json!({})).unwrap(); + let mut reopened = Session::new(); + reopened.execute("file.openBytes", &json!({"name": "capture.designcraft", "base64": saved["base64"]})).unwrap(); + assert_eq!(chars(&reopened, "Captured"), chars(&s, "Captured")); + select(&mut reopened, sid, 6, 10); + assert_eq!(reopened.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["underline"], false); + let before_apply = document(&reopened); + reopened.execute("style.character.apply", &json!({"name": "Captured"})).unwrap(); + assert_eq!(reopened.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["underline"], true); + assert_eq!(reopened.doc().unwrap().doc.story(StoryId(sid)).unwrap().text, "alpha beta"); + let after_apply = document(&reopened); + reopened.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(document(&reopened), before_apply); + reopened.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(document(&reopened), after_apply); +} + +#[test] +fn character_capture_matches_paragraph_selection_and_caret_boundary_policy() { + let (mut s, sid) = story("éX"); + select(&mut s, sid, 0, 2); + s.execute("type.char", &json!({"underline": true})).unwrap(); + select(&mut s, sid, 2, 3); + s.execute("type.char", &json!({"underline": false})).unwrap(); + // A selected character takes the run on the right; a caret takes the typing run on the left. + // Reversed and mixed selections start at the lowest byte offset, just like paragraph capture. + for (anchor, focus, underlined) in [(3, 2, false), (2, 2, true), (3, 0, true)] { + select(&mut s, sid, anchor, focus); + let p = s.execute("style.paragraph.create", &json!({"name": "Paragraph", "fromSelection": true})).unwrap(); + let c = s.execute("style.character.create", &json!({"name": "Character", "fromSelection": true})).unwrap(); + let p = s.doc().unwrap().doc.styles.para(p["name"].as_str().unwrap()).unwrap(); + let c = chars(&s, c["name"].as_str().unwrap()); + assert_eq!(c.underline, Some(underlined)); + assert_eq!(c.underline, p.chars.underline); + assert_eq!(c.size, None, "unlike paragraph capture, character capture must not freeze paragraph defaults"); + } +} + +#[test] +fn character_capture_is_opt_in_and_no_text_uses_only_explicit_chars() { + let (mut s, sid) = story("alpha"); + select(&mut s, sid, 0, 5); + s.execute("type.char", &json!({"underline": true})).unwrap(); + for options in [json!({"name": "Plain"}), json!({"name": "Plain", "fromSelection": false})] { + let created = s.execute("style.character.create", &options).unwrap(); + assert!(chars(&s, created["name"].as_str().unwrap()).is_empty()); + } + assert!(s.doc().unwrap().doc.styles.char_style("Plain 2").is_some()); + s.execute("selection.set", &json!({"ids": []})).unwrap(); + s.execute("style.character.create", &json!({"name": "No target", "fromSelection": true, "basedOn": "Plain", "chars": {"underline": false}})) + .unwrap(); + assert_eq!(chars(&s, "No target"), CharAttrs { underline: Some(false), ..Default::default() }); + assert_eq!(s.doc().unwrap().doc.styles.char_style("No target").unwrap().based_on.as_deref(), Some("Plain")); + let (mut empty, empty_sid) = story(""); + select(&mut empty, empty_sid, 0, 0); + empty.execute("style.character.create", &json!({"name": "Empty", "fromSelection": true})).unwrap(); + assert!(chars(&empty, "Empty").is_empty()); +} + +#[test] +fn character_capture_retains_deleted_fill_and_reuses_it_after_native_reopen() { + for named_source in [false, true] { + let (mut s, sid) = story("alpha beta gamma"); + s.execute("swatch.create", &json!({"name": "Ink", "color": "#ff0000"})).unwrap(); + select(&mut s, sid, 0, 5); + if named_source { + s.execute("style.character.create", &json!({"name": "Base", "chars": {"fill": "Ink"}})).unwrap(); + s.execute("style.character.create", &json!({"name": "Source", "basedOn": "Base"})).unwrap(); + s.execute("style.character.apply", &json!({"name": "Source"})).unwrap(); + } else { + s.execute("type.char", &json!({"fill": "Ink"})).unwrap(); + } + s.execute("swatch.delete", &json!({"name": "Ink"})).unwrap(); + s.doc().unwrap().doc.check().unwrap(); + let before = document(&s); + let selection = s.doc().unwrap().selection.clone(); + let undo_len = s.doc().unwrap().history.undo.len(); + s.execute("style.character.create", &json!({"name": "Captured", "fromSelection": true})).unwrap(); + assert_eq!(chars(&s, "Captured"), CharAttrs { fill: Some("Ink".into()), ..Default::default() }); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_len + 1); + assert_eq!(s.doc().unwrap().selection, selection); + let after = document(&s); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(document(&s), before); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(document(&s), after); + // Non-fill overrides do not turn an existing captured reference into explicit input. + s.execute("style.character.create", &json!({"name": "Tweaked", "fromSelection": true, "chars": {"underline": false, "tracking": 0}})) + .unwrap(); + assert_eq!(chars(&s, "Tweaked"), CharAttrs { fill: Some("Ink".into()), underline: Some(false), tracking: Some(0.0), ..Default::default() }); + s.execute("style.character.create", &json!({"name": "Paper", "fromSelection": true, "chars": {"fill": "[Paper]"}})).unwrap(); + assert_eq!(chars(&s, "Paper"), CharAttrs { fill: Some("[Paper]".into()), ..Default::default() }); + s.execute("style.character.create", &json!({"name": "Cleared fill", "fromSelection": true, "chars": {"fill": null}})).unwrap(); + assert!(chars(&s, "Cleared fill").is_empty()); + select(&mut s, sid, 6, 10); + s.execute("style.character.apply", &json!({"name": "Captured"})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], "Ink"); + s.doc().unwrap().doc.check().unwrap(); + + let saved = s.execute("file.serialize", &json!({})).unwrap(); + let mut reopened = Session::new(); + reopened.execute("file.openBytes", &json!({"name": "deleted-fill.designcraft", "base64": saved["base64"]})).unwrap(); + reopened.doc().unwrap().doc.check().unwrap(); + for name in ["Captured", "Tweaked", "Paper", "Cleared fill"] { + assert_eq!(chars(&reopened, name), chars(&s, name)); + } + select(&mut reopened, sid, 6, 10); + assert_eq!(reopened.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], "Ink"); + select(&mut reopened, sid, 11, 16); + assert_eq!(reopened.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], "[Black]"); + reopened.execute("style.character.apply", &json!({"name": "Captured"})).unwrap(); + assert_eq!(reopened.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], "Ink"); + reopened.execute("style.character.apply", &json!({"name": "Cleared fill"})).unwrap(); + assert_eq!(reopened.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], "[Black]"); + assert_eq!(reopened.doc().unwrap().doc.story(StoryId(sid)).unwrap().text, "alpha beta gamma"); + } +} + +#[test] +fn character_capture_rejects_explicit_missing_fills_without_losing_redo() { + let (mut s, sid) = story("alpha"); + s.execute("swatch.create", &json!({"name": "Ink", "color": "#ff0000"})).unwrap(); + select(&mut s, sid, 0, 5); + s.execute("type.char", &json!({"fill": "Ink"})).unwrap(); + s.execute("swatch.delete", &json!({"name": "Ink"})).unwrap(); + s.execute("style.character.create", &json!({"name": "Redo sentinel"})).unwrap(); + let redo_doc = s.doc().unwrap().doc.clone(); + let redo_selection = s.doc().unwrap().selection.clone(); + s.execute("edit.undo", &json!({})).unwrap(); + let before = s.doc().unwrap().clone(); + let journal = s.journal.clone(); + for (capture, fill) in [(true, "Missing"), (true, "Ink"), (false, "Missing")] { + let error = s.execute("style.character.create", &json!({"name": "Invalid", "fromSelection": capture, "chars": {"fill": fill}})).unwrap_err(); + assert!(error.to_string().contains(fill)); + assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &before.doc)); + assert_eq!(s.doc().unwrap().selection, before.selection); + assert_eq!(s.doc().unwrap().revision, before.revision); + assert_eq!(format!("{:?}", s.doc().unwrap().history), format!("{:?}", before.history)); + assert_eq!(s.journal, journal); + } + s.execute("edit.redo", &json!({})).unwrap(); + assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &redo_doc)); + assert_eq!(s.doc().unwrap().selection, redo_selection); + assert!(s.doc().unwrap().doc.styles.char_style("Redo sentinel").is_some()); +} + +#[test] +fn character_capture_explicit_null_restores_live_paragraph_inheritance() { + for named_source in [false, true] { + let (mut s, sid) = story("alpha\nbeta"); + select(&mut s, sid, 0, 5); + let source = json!({"tracking": 20, "underline": true, "underlineWeight": 1.5, "fontStyle": "Italic"}); + if named_source { + s.execute("style.character.create", &json!({"name": "Base", "chars": source})).unwrap(); + s.execute("style.character.create", &json!({"name": "Source", "basedOn": "Base"})).unwrap(); + s.execute("style.character.apply", &json!({"name": "Source"})).unwrap(); + } else { + s.execute("type.char", &source).unwrap(); + } + // Omitting chars still captures values, including a nullable attribute's numeric value. + s.execute("style.character.create", &json!({"name": "Unchanged", "fromSelection": true})).unwrap(); + assert_eq!(chars(&s, "Unchanged").tracking, Some(20.0)); + assert_eq!(chars(&s, "Unchanged").underline_weight, Some(Some(1.5))); + let before = document(&s); + let undo_len = s.doc().unwrap().history.undo.len(); + s.execute( + "style.character.create", + &json!({"name": "Cleared", "fromSelection": true, + "chars": {"tracking": null, "underlineWeight": null, "underline": false, "baselineShift": 0}}), + ) + .unwrap(); + assert_eq!( + chars(&s, "Cleared"), + CharAttrs { font_style: Some("Italic".into()), underline: Some(false), baseline_shift: Some(0.0), ..Default::default() } + ); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_len + 1); + let after = document(&s); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(document(&s), before); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(document(&s), after); + s.execute("style.paragraph.create", &json!({"name": "Destination", "chars": {"tracking": 30, "underlineWeight": 2}})).unwrap(); + select(&mut s, sid, 6, 10); + s.execute("style.paragraph.apply", &json!({"name": "Destination"})).unwrap(); + s.execute("style.character.apply", &json!({"name": "Cleared"})).unwrap(); + let effective = s.execute("type.selectionAttrs", &json!({})).unwrap(); + assert_eq!(effective["chars"]["tracking"], 30.0); + assert_eq!(effective["chars"]["underlineWeight"], 2.0); + assert_eq!(effective["chars"]["underline"], false); + s.execute("style.paragraph.edit", &json!({"name": "Destination", "chars": {"tracking": 40, "underlineWeight": 3}})).unwrap(); + let effective = s.execute("type.selectionAttrs", &json!({})).unwrap(); + assert_eq!(effective["chars"]["tracking"], 40.0); + assert_eq!(effective["chars"]["underlineWeight"], 3.0); + } +} diff --git a/crates/ui-egui/src/panels/styles.rs b/crates/ui-egui/src/panels/styles.rs index 25a596644..d64cc1bd5 100644 --- a/crates/ui-egui/src/panels/styles.rs +++ b/crates/ui-egui/src/panels/styles.rs @@ -150,3 +150,44 @@ fn style_row(app: &mut DesignApp, ui: &mut egui::Ui, para: bool, n: &str, curren } }); } + +#[cfg(test)] +mod tests { + use egui_kittest::{Harness, kittest::Queryable}; + + use super::*; + + #[test] + fn character_style_plus_captures_selection_for_reuse() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let frame = app.run("frame.create", json!({"rect": [36, 36, 300, 200], "content": "text", "text": "alpha beta"})).unwrap(); + app.run("text.select", json!({"story": frame["story"], "anchor": 0, "focus": 5})).unwrap(); + app.run("type.char", json!({"underline": true})).unwrap(); + let selection = app.session.doc().unwrap().selection.clone(); + let undo_len = app.session.doc().unwrap().history.undo.len(); + let mut h = Harness::builder().with_size(vec2(360.0, 240.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let id = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::<bool>(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + character(app, ui); + }, + app, + ); + h.run_steps(3); + h.get_by_label("Create New Style from Selection").click(); + h.run_steps(3); + let app = h.state_mut(); + let style = app.session.doc().unwrap().doc.styles.char_style("Character Style 1").unwrap(); + assert_eq!(style.chars, designcraft_doc::CharAttrs { underline: Some(true), ..Default::default() }); + assert_eq!(app.session.doc().unwrap().selection, selection); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo_len + 1); + app.run("text.select", json!({"story": frame["story"], "anchor": 6, "focus": 10})).unwrap(); + app.run("style.character.apply", json!({"name": "Character Style 1"})).unwrap(); + assert_eq!(app.run("type.selectionAttrs", json!({})).unwrap()["chars"]["underline"], true); + } +} From b4db4286085db3abb2c98d0e97f54233852b1c55 Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:48:56 +1000 Subject: [PATCH 115/238] PARITY-ARRANGE: step past same-layer siblings in Arrange Co-Authored-By: Codex <codex@openai.com> --- crates/engine/src/cmd/object.rs | 180 +++++++++++++++++++++++++++++++- 1 file changed, 179 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index cd13be117..bc01aabb7 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -1318,6 +1318,43 @@ fn arrange(s: &mut Session, p: &Value) -> Result<Value> { let ids = targets(s, p)?; let to = str_param(p, "to").unwrap_or("front").to_string(); s.edit(|d, _| { + if matches!(to.as_str(), "forward" | "backward") { + let selected: HashSet<ItemId> = ids.iter().copied().collect(); + let mut positions: Vec<_> = selected + .iter() + .filter_map(|id| { + let loc = d.find(*id)?; + match loc.path.as_slice() { + [i] => Some((loc.spread, *i)), + _ => None, + } + }) + .collect(); + // Move the leading selected items first, independently of selection/click order. + // Swapping only with unselected siblings leaves the other selected indices valid. + positions.sort_by_key(|(_, i)| *i); + let forward = to == "forward"; + if forward { + positions.reverse(); + } + for (spread, i) in positions { + let Some(sp) = d.spread_mut(spread) else { continue }; + let Some(it) = sp.items.get(i) else { continue }; + let layer = it.layer; + let sibling = if forward { + sp.items.iter().enumerate().skip(i.saturating_add(1)).find(|(_, it)| it.layer == layer) + } else { + sp.items.iter().enumerate().take(i).rev().find(|(_, it)| it.layer == layer) + }; + if let Some((j, it)) = sibling + && !selected.contains(&it.id) + { + // Keep every other layer's slots and relative stacking order untouched. + sp.items.swap(i, j); + } + } + return ok(); + } for id in &ids { let Some(loc) = d.find(*id) else { continue }; if loc.path.len() != 1 { @@ -1330,7 +1367,6 @@ fn arrange(s: &mut Session, p: &Value) -> Result<Value> { let j = match to.as_str() { "front" => n - 1, "back" => 0, - "forward" => (i + 1).min(n - 1), _ => i.saturating_sub(1), }; sp.items.insert(j, it); @@ -2121,6 +2157,148 @@ fn step_and_repeat(s: &mut Session, p: &Value) -> Result<Value> { }) } +#[cfg(test)] +mod arrange_tests { + use super::*; + + fn fixture<const N: usize>(layers: [usize; N]) -> (Session, [ItemId; N]) { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap(); + let first = s.doc().unwrap().doc.default_layer().0; + let second = s.execute("layer.new", &json!({"name": "Other layer"})).unwrap()["id"].as_u64().unwrap(); + let layer_ids = [first, second]; + let mut ids = [ItemId(0); N]; + for (id, layer) in ids.iter_mut().zip(layers) { + // Overlap objects within each layer; separate layers spatially for hit testing. + let x = if layer == 0 { 0 } else { 200 }; + *id = ItemId(s.execute("frame.create", &json!({"rect": [x, 0, x + 100, 100]})).unwrap()["id"].as_u64().unwrap()); + s.execute("object.setLayer", &json!({"ids": [id.0], "layer": layer_ids[layer]})).unwrap(); + } + (s, ids) + } + + fn stack(s: &Session) -> Vec<ItemId> { + s.doc().unwrap().doc.spreads[0].items.iter().map(|it| it.id).collect() + } + + fn snapshot(s: &Session) -> Value { + let st = s.doc().unwrap(); + json!({"document": &*st.doc, "selection": &st.selection}) + } + + fn assert_items_unchanged(before: &Document, after: &Document) { + assert_eq!(serde_json::to_value(&before.layers).unwrap(), serde_json::to_value(&after.layers).unwrap()); + for it in &before.spreads[0].items { + assert_eq!(serde_json::to_value(it).unwrap(), serde_json::to_value(after.item(it.id).unwrap()).unwrap()); + } + } + + #[test] + fn arrange_steps_past_other_layers_change_hit_order_and_undo() { + for direction in ["forward", "backward"] { + let (mut s, [a, x, y, b]) = fixture([0, 1, 1, 0]); + let selected = if direction == "forward" { a } else { b }; + s.execute("selection.set", &json!({"ids": [selected.0]})).unwrap(); + let before = snapshot(&s); + let doc = s.doc().unwrap().doc.clone(); + let undo_len = s.doc().unwrap().history.undo.len(); + let hit = |s: &Session| s.doc().unwrap().doc.hit_item(0, Point::new(50.0, 50.0), 0.0); + assert_eq!(hit(&s), Some(b)); + + s.execute("object.arrange", &json!({"to": direction})).unwrap(); + assert_eq!(stack(&s), [b, x, y, a], "{direction}"); + assert_eq!(hit(&s), Some(a), "the visible topmost object must change"); + assert_items_unchanged(&doc, &s.doc().unwrap().doc); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_len + 1); + assert_eq!(s.doc().unwrap().selection.items, [selected]); + let after = snapshot(&s); + + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(snapshot(&s), before); + assert_eq!(hit(&s), Some(b)); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(snapshot(&s), after); + assert_eq!(hit(&s), Some(a)); + } + } + + #[test] + fn arrange_multiple_selection_preserves_order_independent_of_input_order() { + // Layer 0: a, b, c, d. Layer 1: x, y. Arrays name the original stack indices. + let cases = [ + ("forward", [0, 2], [4, 1, 0, 3, 2, 5]), // a,b cross c as a block + ("backward", [2, 4], [2, 1, 4, 3, 0, 5]), // b,c cross a as a block + ("forward", [0, 4], [2, 1, 0, 3, 5, 4]), // separate selected blocks + ("backward", [2, 5], [2, 1, 0, 3, 5, 4]), + ("forward", [4, 5], [0, 1, 2, 3, 4, 5]), // front block is already at its endpoint + ("backward", [0, 2], [0, 1, 2, 3, 4, 5]), // back block is already at its endpoint + ]; + for (direction, selected, expected) in cases { + for reverse in [false, true] { + let (mut s, ids) = fixture([0, 1, 0, 1, 0, 0]); + let doc = s.doc().unwrap().doc.clone(); + let mut selected = selected.map(|i| ids[i].0).to_vec(); + if reverse { + selected.reverse(); + } + // Explicit command targets may contain duplicates: an item still moves once. + selected.push(selected[0]); + s.execute("object.arrange", &json!({"to": direction, "ids": selected})).unwrap(); + assert_eq!(stack(&s), expected.map(|i| ids[i]), "{direction}, reverse={reverse}"); + assert_items_unchanged(&doc, &s.doc().unwrap().doc); + } + } + } + + #[test] + fn arrange_same_layer_steps_stop_at_layer_endpoints() { + let cases = [ + ("forward", vec![1], [0, 2, 1, 3, 4]), + ("backward", vec![3], [0, 1, 3, 2, 4]), + ("forward", vec![3], [0, 1, 2, 3, 4]), + ("backward", vec![1], [0, 1, 2, 3, 4]), + ("forward", vec![3, 1, 2], [0, 1, 2, 3, 4]), + ("backward", vec![3, 1, 2], [0, 1, 2, 3, 4]), + ]; + for (direction, selected, expected) in cases { + let (mut s, ids) = fixture([1, 0, 0, 0, 1]); + let selected: Vec<_> = selected.into_iter().map(|i| ids[i].0).collect(); + s.execute("object.arrange", &json!({"to": direction, "ids": selected})).unwrap(); + assert_eq!(stack(&s), expected.map(|i| ids[i])); + } + for direction in ["forward", "backward"] { + let (mut s, [x, a, y]) = fixture([1, 0, 1]); + s.execute("object.arrange", &json!({"to": direction, "ids": [a.0]})).unwrap(); + assert_eq!(stack(&s), [x, a, y], "only object on its layer"); + } + } + + #[test] + fn arrange_selection_on_two_layers_moves_each_layer_independently() { + for direction in ["forward", "backward"] { + let (mut s, [a, x, b, y]) = fixture([0, 1, 0, 1]); + let selected = if direction == "forward" { [x.0, a.0] } else { [b.0, y.0] }; + s.execute("object.arrange", &json!({"to": direction, "ids": selected})).unwrap(); + assert_eq!(stack(&s), [b, y, a, x]); + let doc = &s.doc().unwrap().doc; + assert_eq!(doc.hit_item(0, Point::new(50.0, 50.0), 0.0), Some(a)); + assert_eq!(doc.hit_item(0, Point::new(250.0, 50.0), 0.0), Some(x)); + } + } + + #[test] + fn arrange_menu_steps_use_same_layer_order() { + for command in ["object.bringForward", "object.sendBackward"] { + let (mut s, [a, x, b]) = fixture([0, 1, 0]); + let selected = if command == "object.bringForward" { a } else { b }; + s.execute("selection.set", &json!({"ids": [selected.0]})).unwrap(); + s.execute(command, &json!({})).unwrap(); + assert_eq!(stack(&s), [b, x, a]); + assert_eq!(s.doc().unwrap().doc.hit_item(0, Point::new(50.0, 50.0), 0.0), Some(a)); + } + } +} + #[cfg(test)] mod step_repeat_tests { use super::*; From e507b986014d5913ac841f22ee432d14b8736814 Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:51:50 +1000 Subject: [PATCH 116/238] PARITY-TAB-LEADERS: draw right-indent tab leaders Co-Authored-By: Codex <codex@openai.com> --- crates/compose/src/lib.rs | 12 ++++ crates/compose/src/tests.rs | 119 ++++++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 3dd7c25b3..ee1231503 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -2229,6 +2229,18 @@ fn layout_line( // A default tab stop is a left tab. last_tab = Some((i, stop.as_ref().is_none_or(|t| t.align == TabAlign::Left))); } else if line[i].ch == story::RIGHT_INDENT_TAB { + // Use the first stop past the right margin, or the last stop at/before it. + // Choose the stop before checking its leader: an empty leader takes precedence too. + let right = x1 - tab_origin; + let stop = pp + .tabs + .iter() + .filter(|t| t.position > right) + .min_by(|a, b| a.position.total_cmp(&b.position)) + .or_else(|| pp.tabs.iter().filter(|t| t.position <= right).max_by(|a, b| a.position.total_cmp(&b.position))); + if let Some(t) = stop.filter(|t| !t.leader.is_empty()) { + leaders.push((i, t.leader.clone())); + } let rest: f64 = line[i + 1..].iter().map(|g| g.adv).sum(); line[i].adv = (measure - x - rest).max(0.0); last_tab = Some((i, false)); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index f5d0113c1..55bf3c015 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -789,6 +789,125 @@ fn tab_leaders_fill_the_gap() { assert!(last_dot < num_x, "leaders stop before the page number"); } +#[test] +fn right_indent_tab_leaders_follow_stop_precedence_without_moving_text() { + let text = "Intro\u{e006}12"; + let cases = [ + ("last interior", 0.0, vec![(200.0, "."), (80.0, "-")], Some('.')), + ("first exterior", 0.0, vec![(380.0, "+"), (200.0, "-"), (320.0, ".")], Some('.')), + ("at the margin", 0.0, vec![(300.0, "."), (80.0, "-")], Some('.')), + ("no stops", 0.0, vec![], None), + ("empty exterior", 0.0, vec![(200.0, "."), (320.0, ""), (350.0, "+")], None), + ("empty interior", 0.0, vec![(80.0, "."), (200.0, "")], None), + ("indented margin", 36.0, vec![(260.0, "-"), (280.0, "."), (340.0, "+")], Some('.')), + ]; + for (name, right_indent, stops, expected) in cases { + let tabs = stops + .into_iter() + .map(|(position, leader)| designcraft_doc::TabStop { position, align: TabAlign::Right, leader: leader.into(), align_on: String::new() }) + .collect(); + let para = ParaAttrs { tabs: Some(tabs), left_indent: Some(12.0), right_indent: Some(right_indent), ..Default::default() }; + // Translation and left/right indents distinguish the tab origin from line/absolute x. + let (mut d, sid, _) = doc_with(text, Rect::new(72.0, 36.0, 372.0, 136.0), para); + let cs = compose_story(&d, sid, &Default::default()); + assert!(!cs.is_overset(), "{name}"); + assert_eq!(cs.frames[0].lines.len(), 1, "{name}"); + let line = &cs.frames[0].lines[0]; + let tab = line.glyphs.iter().find(|g| g.byte == 5 && g.len > 0).unwrap(); + let leaders: Vec<_> = line.glyphs.iter().filter(|g| g.visible && g.len == 0).collect(); + assert_eq!(!leaders.is_empty(), expected.is_some(), "{name}"); + for g in leaders { + assert_eq!(g.gid, g.face.get().glyph_for(expected.unwrap()), "{name}"); + let width = g.face.get().advance(g.gid) * g.sx; + assert!(g.x >= tab.x - 0.01 && g.x + width <= tab.x + tab.adv + 0.01, "{name}: leader outside tab gap"); + assert_eq!(g.byte, tab.byte, "{name}"); + } + assert!((line.end_x - (372.0 - right_indent)).abs() < 0.01, "{name}: number stays at the right margin"); + // Removing only the leaders must leave every source glyph and the line itself unchanged. + for t in d.story_mut(sid).unwrap().paras[0].para.tabs.as_mut().unwrap() { + t.leader.clear(); + } + let plain = compose_story(&d, sid, &Default::default()); + assert_eq!(plain.frames[0].lines.len(), 1, "{name}"); + let plain_line = &plain.frames[0].lines[0]; + assert_eq!((&line.range, line.baseline, line.end_x), (&plain_line.range, plain_line.baseline, plain_line.end_x), "{name}"); + let source_positions = |l: &Line| { + l.glyphs.iter().filter(|g| g.len > 0).map(|g| (g.byte, g.len, g.gid, g.x, g.y, g.adv, g.sx, g.sy, g.style)).collect::<Vec<_>>() + }; + assert_eq!(source_positions(line), source_positions(plain_line), "{name}"); + assert_eq!(d.stories[&sid].text, text, "{name}: source text is not expanded"); + } +} + +#[test] +fn right_indent_tab_leaders_use_the_tab_format_and_regular_tab_drawing() { + let mut rendered = Vec::new(); + for ch in [story::RIGHT_INDENT_TAB, '\t'] { + let text = format!("Intro{ch}12"); + let para = ParaAttrs { + tabs: Some(vec![designcraft_doc::TabStop { position: 300.0, align: TabAlign::Right, leader: ".-".into(), align_on: String::new() }]), + ..Default::default() + }; + let (mut d, sid, _) = doc_with(&text, Rect::new(72.0, 36.0, 372.0, 136.0), para); + d.story_mut(sid).unwrap().format_chars(5..5 + ch.len_utf8(), |f| { + f.over.font_family = Some("Inter".into()); + f.over.size = Some(18.0); + f.over.h_scale = Some(1.3); + f.over.v_scale = Some(0.8); + f.over.baseline_shift = Some(4.0); + f.over.fill_tint = Some(0.4); + }); + let cs = compose_story(&d, sid, &Default::default()); + assert!(!cs.is_overset()); + assert_eq!(cs.frames[0].lines.len(), 1); + let line = &cs.frames[0].lines[0]; + let tab = line.glyphs.iter().find(|g| g.byte == 5 && g.len > 0).unwrap(); + let prefix = &line.glyphs[0]; + assert_ne!(tab.face.id(), prefix.face.id(), "tab uses its own font"); + assert_ne!(tab.style, prefix.style, "tab uses its own character formatting"); + assert_eq!(cs.styles[tab.style as usize].size, 18.0); + assert_eq!(cs.styles[tab.style as usize].fill_tint, 0.4); + let leaders: Vec<_> = line.glyphs.iter().filter(|g| g.visible && g.len == 0).collect(); + assert!(leaders.len() > 10); + assert_eq!(leaders.len() % 2, 0, "only complete patterns are drawn"); + for (i, g) in leaders.iter().enumerate() { + assert_eq!(g.gid, tab.face.get().glyph_for(if i % 2 == 0 { '.' } else { '-' })); + assert_eq!(g.face.id(), tab.face.id()); + assert_eq!((g.sx, g.sy, g.y, g.style, g.byte), (tab.sx, tab.sy, -4.0, tab.style, tab.byte)); + let width = g.face.get().advance(g.gid) * g.sx; + assert!(g.x >= tab.x - 0.01 && g.x + width <= tab.x + tab.adv + 0.01); + } + rendered.push(leaders.iter().map(|g| (g.gid, g.x, g.y, g.sx, g.sy, g.style)).collect::<Vec<_>>()); + } + assert_eq!(rendered[0], rendered[1], "right-indent and equivalent regular right tabs use the same leader drawing"); +} + +#[test] +fn right_indent_tab_leaders_do_not_draw_when_the_gap_cannot_fit_a_pattern() { + let text = "A\u{e006}B"; + let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs::default()); + let cs = compose_story(&d, sid, &Default::default()); + let line = &cs.frames[0].lines[0]; + let natural: f64 = line.glyphs.iter().filter(|g| g.visible).map(|g| g.adv).sum(); + let first = &line.glyphs[0]; + let pattern_width = first.face.get().advance(first.face.get().glyph_for('-')) * first.sx; + assert!(pattern_width > 0.01); + for gap in [0.0, pattern_width / 2.0] { + let para = ParaAttrs { + tabs: Some(vec![designcraft_doc::TabStop { position: 1.0, align: TabAlign::Left, leader: "-".into(), align_on: String::new() }]), + ..Default::default() + }; + let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, natural + gap, 100.0), para); + let cs = compose_story(&d, sid, &Default::default()); + assert!(!cs.is_overset()); + assert_eq!(cs.frames[0].lines.len(), 1); + let line = &cs.frames[0].lines[0]; + let tab = line.glyphs.iter().find(|g| g.byte == 1 && g.len > 0).unwrap(); + assert!((tab.adv - gap).abs() < 1e-6); + assert!(line.glyphs.iter().all(|g| g.len > 0 || !g.visible), "a partial leader pattern must not overlap adjacent text"); + } +} + #[test] fn footnotes_sit_at_the_column_bottom_and_push_text() { // A short frame: without footnotes the text fills it; with two footnotes the body text From b3ec8dc908bebba44812cce36ccca80a8983bc42 Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:53:54 +1000 Subject: [PATCH 117/238] PARITY-EPUB-STYLE: match CSS to exported style mappings Co-Authored-By: Codex <codex@openai.com> --- crates/epub/src/lib.rs | 62 ++++++++--- crates/epub/tests/export_tag_styles.rs | 138 +++++++++++++++++++++++++ 2 files changed, 184 insertions(+), 16 deletions(-) create mode 100644 crates/epub/tests/export_tag_styles.rs diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index 643f59341..77b509d0b 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -322,7 +322,10 @@ pub fn stylesheet(doc: &Document) -> String { let mut rule = String::new(); para_css(&pp, base, lead, &mut rule); char_css(doc, &cp, base, &mut rule); - let _ = writeln!(css, "p.{} {{ {rule}}}", pc[ps.name.as_str()]); + let (tag, class) = export_mapping(st, &ps.name, false, &pc); + if let Some(selector) = class_selector(tag, &class) { + let _ = writeln!(css, "{selector} {{ {rule}}}"); + } } for cs in &st.character { if cs.name == story::NO_CHAR_STYLE { @@ -347,7 +350,10 @@ pub fn stylesheet(doc: &Document) -> String { if let Some(sz) = c.size { let _ = write!(rule, "font-size: {:.3}em; ", sz / base); } - let _ = writeln!(css, "span.{} {{ {rule}}}", cc[cs.name.as_str()]); + let (tag, class) = export_mapping(st, &cs.name, true, &cc); + if let Some(selector) = class_selector(tag, &class) { + let _ = writeln!(css, "{selector} {{ {rule}}}"); + } } css } @@ -386,6 +392,39 @@ fn is_tag(t: &str, character: bool) -> bool { } } +/// Resolve Export Tagging once for both markup and its stylesheet. Keep the generated, +/// collision-free class when no custom class was supplied, and the existing tag fallback. +fn export_mapping<'a>(st: &'a Styles, name: &str, character: bool, classes: &BTreeMap<&str, String>) -> (&'a str, String) { + let et = st.export_tag(name, character); + let tag = et.map(|e| e.tag.as_str()).filter(|t| is_tag(t, character)).unwrap_or(if character { "span" } else { "p" }); + let class = et.map(|e| e.class.clone()).filter(|c| !c.is_empty()).or_else(|| classes.get(name).cloned()).unwrap_or_else(|| slug(name)); + // Match the characters xml_attr actually emits, before escaping either output context. + let class = class.chars().filter(|c| valid_xml_char(*c) && !('\u{E000}'..='\u{E1FF}').contains(c)).collect(); + (tag, class) +} + +/// Each class token must match on the same element. Escape CSS identifier punctuation and +/// leading digits without changing the class written into XHTML. +fn class_selector(tag: &str, class: &str) -> Option<String> { + let mut tokens = class.split_ascii_whitespace().peekable(); + // A nonempty but whitespace-only custom class has no token to select. Never emit a bare + // element selector, which would apply this style to unrelated paragraphs or character runs. + tokens.peek()?; + let mut selector = tag.to_string(); + for token in tokens { + selector.push('.'); + for (i, c) in token.chars().enumerate() { + let leading_digit = c.is_ascii_digit() && (i == 0 || i == 1 && token.starts_with('-')); + if !leading_digit && token != "-" && (c.is_ascii_alphanumeric() || matches!(c, '-' | '_') || !c.is_ascii()) { + selector.push(c); + } else { + let _ = write!(selector, "\\{:x} ", c as u32); + } + } + } + Some(selector) +} + /// One story as XHTML paragraphs. pub fn story_html(doc: &Document, sid: StoryId) -> String { let Some(st) = doc.story(sid) else { return String::new() }; @@ -394,10 +433,7 @@ pub fn story_html(doc: &Document, sid: StoryId) -> String { for (pi, r) in st.para_ranges().iter().enumerate() { let pf = &st.paras[pi]; // Export Tagging: the style's element and class. - let et = doc.styles.export_tag(&pf.style, false); - let tag = et.map(|e| e.tag.as_str()).filter(|t| is_tag(t, false)).unwrap_or("p"); - let class = - et.map(|e| e.class.clone()).filter(|c| !c.is_empty()).or_else(|| pc.get(pf.style.as_str()).cloned()).unwrap_or_else(|| slug(&pf.style)); + let (tag, class) = export_mapping(&doc.styles, &pf.style, false, &pc); let text_empty = st.text[r.clone()].trim().is_empty(); let rtl = doc.styles.resolve_para(pf).0.direction == designcraft_doc::TextDirection::RightToLeft; let _ = write!(out, "<{tag} class=\"{}\"{}>", xml_attr(&class), if rtl { " dir=\"rtl\"" } else { "" }); @@ -427,17 +463,11 @@ pub fn story_html(doc: &Document, sid: StoryId) -> String { } else { story_markup(raw) }; - let et = (f.style != story::NO_CHAR_STYLE).then(|| doc.styles.export_tag(&f.style, true)).flatten(); - let ctag = et.map(|e| e.tag.as_str()).filter(|t| is_tag(t, true)).unwrap_or("span"); - let cls = if f.style != story::NO_CHAR_STYLE { - let class = et - .map(|e| e.class.clone()) - .filter(|c| !c.is_empty()) - .or_else(|| cc.get(f.style.as_str()).cloned()) - .unwrap_or_else(|| slug(&f.style)); - format!(" class=\"{}\"", xml_attr(&class)) + let (ctag, cls) = if f.style != story::NO_CHAR_STYLE { + let (tag, class) = export_mapping(&doc.styles, &f.style, true, &cc); + (tag, format!(" class=\"{}\"", xml_attr(&class))) } else { - String::new() + ("span", String::new()) }; let style = override_css(doc, &f.over); let style_attr = if style.is_empty() { String::new() } else { format!(" style=\"{}\"", xml_attr(&style)) }; diff --git a/crates/epub/tests/export_tag_styles.rs b/crates/epub/tests/export_tag_styles.rs new file mode 100644 index 000000000..47485b532 --- /dev/null +++ b/crates/epub/tests/export_tag_styles.rs @@ -0,0 +1,138 @@ +use std::io::{Cursor, Read}; + +use designcraft_doc::build::NewDocument; +use designcraft_doc::geom::Rect; +use designcraft_doc::{Align, CharAttrs, CharacterStyle, Document, ExportTag, ParaAttrs, ParaFormat, ParagraphStyle, SpreadRef, story}; +use designcraft_epub::{EpubOptions, HtmlOptions, export_epub, export_html}; + +fn fixture() -> Document { + let mut doc = Document::new(&NewDocument::default()); + let styles = doc.styles_mut(); + styles.paragraph.iter_mut().find(|p| p.name == story::BASIC_PARAGRAPH).unwrap().chars.size = Some(12.0); + styles.paragraph.push(ParagraphStyle { + name: "Chapter Title".into(), + based_on: None, + next_style: None, + para: ParaAttrs { align: Some(Align::Center), ..Default::default() }, + chars: CharAttrs { size: Some(24.0), ..Default::default() }, + shortcut: String::new(), + }); + styles.character.push(CharacterStyle { + name: "Key Term".into(), + based_on: None, + chars: CharAttrs { size: Some(18.0), ..Default::default() }, + shortcut: String::new(), + }); + let layer = doc.default_layer(); + doc.add_text_frame( + SpreadRef::Doc(0), + Rect::new(36.0, 36.0, 300.0, 100.0), + layer, + "Heading", + ParaFormat { style: "Chapter Title".into(), ..Default::default() }, + ) + .unwrap(); + let (_, sid) = doc.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 120.0, 300.0, 200.0), layer, "Accent plain", ParaFormat::default()).unwrap(); + doc.story_mut(sid).unwrap().format_chars(0..6, |f| f.style = "Key Term".into()); + doc +} + +fn map(doc: &mut Document, paragraph_tag: &str, paragraph_class: &str, character_tag: &str, character_class: &str) { + let tags = &mut doc.styles_mut().export_tags; + tags.insert("p:Chapter Title".into(), ExportTag { tag: paragraph_tag.into(), class: paragraph_class.into() }); + tags.insert("c:Key Term".into(), ExportTag { tag: character_tag.into(), class: character_class.into() }); +} + +fn package(doc: &Document) -> (String, String) { + let mut zip = zip::ZipArchive::new(Cursor::new(export_epub(doc, &EpubOptions::default()).unwrap())).unwrap(); + let mut read = |path: &str| { + let mut text = String::new(); + zip.by_name(path).unwrap().read_to_string(&mut text).unwrap(); + text + }; + let xhtml = read("OEBPS/content.xhtml"); + let css = read("OEBPS/style.css"); + let opf = read("OEBPS/content.opf"); + assert!(xhtml.contains("<link rel=\"stylesheet\" type=\"text/css\" href=\"style.css\"/>")); + assert!(opf.contains("href=\"style.css\" media-type=\"text/css\"")); + (xhtml, css) +} + +fn rule<'a>(css: &'a str, selector: &str) -> &'a str { + css.lines().find_map(|line| line.strip_prefix(&format!("{selector} {{ "))).unwrap_or_else(|| panic!("missing {selector} in {css}")) +} + +#[test] +fn mapped_tags_and_classes_keep_their_packaged_style_declarations() { + let mut doc = fixture(); + map(&mut doc, "h1", "chapter", "em", "term"); + let (xhtml, css) = package(&doc); + assert!(xhtml.contains("<h1 class=\"chapter\">Heading</h1>"), "{xhtml}"); + assert!(xhtml.contains("<em class=\"term\">Accent</em> plain"), "{xhtml}"); + assert!(rule(&css, "h1.chapter").contains("font-size: 2.000em;")); + assert!(rule(&css, "h1.chapter").contains("text-align: center;")); + assert!(rule(&css, "em.term").contains("font-size: 1.500em;")); + assert!(!css.contains("p.chapter-title {") && !css.contains("span.key-term {")); + assert!(rule(&css, "p.basic-paragraph").contains("font-size: 1.000em;")); + // HTML uses the same mapped stylesheet inline, while EPUB links its packaged CSS. + let html = export_html(&doc, &HtmlOptions::default()); + assert!(html.contains(&css)); + assert!(html.contains("<h1 class=\"chapter\">Heading</h1>")); +} + +#[test] +fn tag_only_class_only_and_unsupported_tags_keep_existing_fallbacks() { + for (pt, pc, ct, cc, paragraph, character) in [ + ("h2", "", "em", "", "h2.chapter-title", "em.key-term"), + ("", "chapter", "", "term", "p.chapter", "span.term"), + ("section", "", "h2", "", "p.chapter-title", "span.key-term"), + ("", "", "", "", "p.chapter-title", "span.key-term"), + ] { + let mut doc = fixture(); + map(&mut doc, pt, pc, ct, cc); + let (xhtml, css) = package(&doc); + for (selector, text, size) in [(paragraph, "Heading", "2.000em"), (character, "Accent", "1.500em")] { + let (tag, class) = selector.split_once('.').unwrap(); + assert!(xhtml.contains(&format!("<{tag} class=\"{class}\">{text}</{tag}>")), "{xhtml}"); + assert!(rule(&css, selector).contains(&format!("font-size: {size};"))); + } + } + let (xhtml, css) = package(&fixture()); + assert!(xhtml.contains("<p class=\"chapter-title\">Heading</p>")); + assert!(rule(&css, "p.chapter-title").contains("font-size: 2.000em;")); + assert!(rule(&css, "span.key-term").contains("font-size: 1.500em;")); +} + +#[test] +fn custom_class_tokens_match_without_renaming_them() { + for (class, attribute, selector) in [ + ("lead wide", "lead wide", ".lead.wide"), + ("lead\twide\n", "lead\twide\n", ".lead.wide"), + ("chapter:lead", "chapter:lead", r".chapter\3a lead"), + ("2nd-edition", "2nd-edition", r".\32 nd-edition"), + ("-2nd", "-2nd", r".-\32 nd"), + ("-", "-", r".\2d "), + ("見出し.café", "見出し.café", r".見出し\2e café"), + ("edition \"wide\"", "edition "wide"", r".edition.\22 wide\22 "), + ] { + let mut doc = fixture(); + map(&mut doc, "h2", class, "em", class); + let (xhtml, css) = package(&doc); + assert!(xhtml.contains(&format!("<h2 class=\"{attribute}\">Heading</h2>")), "{xhtml}"); + assert!(xhtml.contains(&format!("<em class=\"{attribute}\">Accent</em>")), "{xhtml}"); + assert!(rule(&css, &format!("h2{selector}")).contains("font-size: 2.000em;")); + assert!(rule(&css, &format!("em{selector}")).contains("font-size: 1.500em;")); + } +} + +#[test] +fn whitespace_only_custom_classes_do_not_apply_rules_to_every_element() { + let mut doc = fixture(); + map(&mut doc, "p", " \t", "span", " \n"); + let (xhtml, css) = package(&doc); + assert!(xhtml.contains("<p class=\" \t\">Heading</p>")); + assert!(xhtml.contains("<span class=\" \n\">Accent</span>")); + assert!(!css.lines().any(|line| line.starts_with("p {") || line.starts_with("span {"))); + assert!(!css.contains("font-size: 2.000em;") && !css.contains("font-size: 1.500em;")); + assert!(rule(&css, "p.basic-paragraph").contains("font-size: 1.000em;")); +} From 8f48869e589f1eb1adddd11d72bb374312437edb Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:56:09 +1000 Subject: [PATCH 118/238] PARITY-IDML-INDEX: retain index reference source sort keys Co-Authored-By: Codex <codex@openai.com> --- crates/idml/src/export.rs | 25 ++- crates/idml/src/import.rs | 55 ++++--- crates/idml/src/tests.rs | 1 + crates/idml/src/tests/index_sort.rs | 240 ++++++++++++++++++++++++++++ 4 files changed, 295 insertions(+), 26 deletions(-) create mode 100644 crates/idml/src/tests/index_sort.rs diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 9d73562b9..6c5b00a4f 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -33,10 +33,17 @@ impl Default for ExportOptions { } /// Export a document as an IDML package (ZIP bytes). +/// +/// IDML has one sort key per index topic path. If native references disagree, the first +/// reference serialized for that path supplies its key, including an empty/default key. +/// Later references cannot overwrite it; no synthetic topic names or sort keys are created. +/// Consistent references retain their keys. Conflicting per-reference keys cannot all survive +/// this topic-level representation. This policy also applies to [`export_idml_with`]. pub fn export_idml(doc: &Document) -> Vec<u8> { export_idml_with(doc, &ExportOptions::default()) } +/// Export with options, using the index sort-key conflict policy of [`export_idml`]. pub fn export_idml_with(doc: &Document, opts: &ExportOptions) -> Vec<u8> { let mut ex = Ex::new(doc, opts); let parts = ex.build(); @@ -133,8 +140,8 @@ struct Ex<'a> { threads: HashMap<u64, (Option<u64>, Option<u64>)>, /// Cross-reference sources written so far: (Self, target anchor). xref_sources: Vec<(String, u64)>, - /// Index topics (paths) and their See / See also cross-references. - index_topics: BTreeSet<Vec<String>>, + /// Index topic path → first serialized sort key; See / See also cross-references. + index_topics: BTreeMap<Vec<String>, String>, index_xrefs: Vec<(Vec<String>, &'static str, String)>, } @@ -148,7 +155,7 @@ impl<'a> Ex<'a> { fonts: BTreeMap::new(), threads: HashMap::new(), xref_sources: Vec::new(), - index_topics: BTreeSet::new(), + index_topics: BTreeMap::new(), index_xrefs: Vec::new(), }; ex.assign_swatch_ids(); @@ -1809,12 +1816,12 @@ impl<'a> Ex<'a> { fn index_el(&mut self) -> El { let mut topics = self.index_topics.clone(); for (_, _, target) in &self.index_xrefs { - topics.insert(vec![target.clone()]); + topics.entry(vec![target.clone()]).or_default(); } - fn build(ex: &mut Ex, topics: &BTreeSet<Vec<String>>, prefix: &[String]) -> Vec<El> { + fn build(ex: &mut Ex, topics: &BTreeMap<Vec<String>, String>, prefix: &[String]) -> Vec<El> { let mut out = Vec::new(); - for t in topics.iter().filter(|t| t.len() == prefix.len() + 1 && t.starts_with(prefix)) { - let mut el = El::new("Topic").attr("Self", topic_self(t)).attr("SortOrder", "").attr("Name", t.last().map_or("", String::as_str)); + for (t, sort) in topics.iter().filter(|(t, _)| t.len() == prefix.len() + 1 && t.starts_with(prefix)) { + let mut el = El::new("Topic").attr("Self", topic_self(t)).attr("SortOrder", sort).attr("Name", t.last().map_or("", String::as_str)); for (from, kind, target) in ex.index_xrefs.clone() { if from == *t { el.push( @@ -2069,7 +2076,9 @@ impl<'a> Ex<'a> { if let Some(r) = s.index_refs.get(k).filter(|r| !r.topics.is_empty()) { use designcraft_doc::index::IndexRange as R; for n in 1..=r.topics.len() { - self.index_topics.insert(r.topics[..n].to_vec()); + // A shared topic has one key: keep the first reference's + // value (even empty), rather than inventing a merged key. + self.index_topics.entry(r.topics[..n].to_vec()).or_insert_with(|| r.sort.get(n - 1).cloned().unwrap_or_default()); } let (kind, limit) = match &r.range { R::CurrentPage => (Some("CurrentPage"), None), diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 46c3253b6..1b1cb7d8e 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -206,11 +206,32 @@ struct Importer<'r> { /// Cross-reference format Self → name. xref_format_names: HashMap<String, String>, xref_formats: Vec<designcraft_doc::XrefFormat>, - /// Index topic Self → topic path; topic See / See also cross-references. - index_topics: HashMap<String, Vec<String>>, + /// Index topic Self → topic path and per-level sort keys; topic cross-references. + index_topics: HashMap<String, IndexTopic>, index_xrefs: Vec<designcraft_doc::IndexRef>, } +/// IDML stores a sort key on each topic, rather than on its page references. +#[derive(Clone, Default)] +struct IndexTopic { + path: Vec<String>, + sort: Vec<String>, +} + +impl IndexTopic { + fn child(&self, topic: &El) -> Self { + let mut path = self.path.clone(); + path.push(topic.get("Name").unwrap_or("").to_string()); + let mut sort = self.sort.clone(); + if let Some(key) = topic.get("SortOrder").filter(|key| !key.is_empty()) { + // Keep empty positions before a child's key, but omit trailing defaults. + sort.resize(self.path.len(), String::new()); + sort.push(key.to_string()); + } + Self { path, sort } + } +} + fn lab_to_color(l: f32, a: f32, b: f32) -> Color { let rgb = lab::xyz_to_srgb(lab::lab_to_xyz(lab::Lab::new(l, a, b))); Color::rgb(rgb[0], rgb[1], rgb[2]) @@ -453,7 +474,7 @@ impl<'r> Importer<'r> { self.xref_format(e); } if let Some(ix) = top.iter().find(|e| e.local() == "Index") { - self.index_topics_of(ix, &[]); + self.index_topics_of(ix, &IndexTopic::default()); } // Stories (ids first so frames can reference them). for e in top.iter().filter(|e| e.local() == "Story") { @@ -1010,33 +1031,31 @@ impl<'r> Importer<'r> { } /// Index topics (recursively); at the root, their See / See also cross-references too. - fn index_topics_of(&mut self, e: &El, prefix: &[String]) { + fn index_topics_of(&mut self, e: &El, prefix: &IndexTopic) { for t in e.find_all("Topic") { - let mut path = prefix.to_vec(); - path.push(t.get("Name").unwrap_or("").to_string()); + let topic = prefix.child(t); if let Some(me) = t.get("Self") { - self.index_topics.insert(me.to_string(), path.clone()); + self.index_topics.insert(me.to_string(), topic.clone()); } - self.index_topics_of(t, &path); + self.index_topics_of(t, &topic); } - if !prefix.is_empty() { + if !prefix.path.is_empty() { return; } - let mut stack = vec![(e, Vec::<String>::new())]; - while let Some((el, path)) = stack.pop() { + let mut stack = vec![(e, IndexTopic::default())]; + while let Some((el, prefix)) = stack.pop() { for t in el.find_all("Topic") { - let mut p = path.clone(); - p.push(t.get("Name").unwrap_or("").to_string()); + let topic = prefix.child(t); for x in t.find_all("CrossReference") { - let target = x.get("ReferencedTopic").and_then(|r| self.index_topics.get(r)).map(|p| p.join(": ")).unwrap_or_default(); + let target = x.get("ReferencedTopic").and_then(|r| self.index_topics.get(r)).map(|t| t.path.join(": ")).unwrap_or_default(); use designcraft_doc::index::IndexRange as R; let range = match x.get("CrossReferenceType") { Some("SeeAlso") | Some("SeeAlsoHerein") => R::SeeAlso(target), _ => R::See(target), }; - self.index_xrefs.push(designcraft_doc::IndexRef { topics: p.clone(), sort: vec![], range }); + self.index_xrefs.push(designcraft_doc::IndexRef { topics: topic.path.clone(), sort: topic.sort.clone(), range }); } - stack.push((t, p)); + stack.push((t, topic)); } } } @@ -1796,14 +1815,14 @@ impl<'r> Importer<'r> { } "PageReference" => { use designcraft_doc::index::IndexRange as R; - let topics = c.get("ReferencedTopic").and_then(|r| self.index_topics.get(r)).cloned().unwrap_or_default(); + let topic = c.get("ReferencedTopic").and_then(|r| self.index_topics.get(r)).cloned().unwrap_or_default(); let range = match c.get("PageReferenceType") { Some("ToEndOfStory") | Some("ToEndOfDocument") | Some("ToEndOfSection") => R::ToEndOfStory, Some("ForNextNParagraphs") => R::NextParagraphs(c.num("PageReferenceLimit").unwrap_or(1.0).max(1.0) as u32), Some("SuppressPageNumbers") => R::SuppressPageRange, _ => R::CurrentPage, }; - b.index_refs.push(designcraft_doc::IndexRef { topics, sort: vec![], range }); + b.index_refs.push(designcraft_doc::IndexRef { topics: topic.path, sort: topic.sort, range }); b.push(&designcraft_doc::INDEX_MARK.to_string(), cf); } "CrossReferenceSource" => { diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 23ec99bdf..087a2b5d7 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -9,6 +9,7 @@ use super::*; mod assets; mod decorations; +mod index_sort; fn zip_files(files: &[(&str, &str)]) -> Vec<u8> { use zip::write::SimpleFileOptions; diff --git a/crates/idml/src/tests/index_sort.rs b/crates/idml/src/tests/index_sort.rs new file mode 100644 index 000000000..e35e7fed9 --- /dev/null +++ b/crates/idml/src/tests/index_sort.rs @@ -0,0 +1,240 @@ +//! Original synthetic fixtures: no Adobe-generated files or implementation details. +//! Behavioral reference: https://developer.adobe.com/indesign/uxp/dom/api/t/topic/ + +use std::io::Read; + +use designcraft_doc::IndexRef; +use designcraft_doc::index::{self, IndexRange, IndexSpec, Resolved}; + +use super::*; +use crate::xml::El; + +fn strings(values: &[&str]) -> Vec<String> { + values.iter().map(|v| (*v).to_string()).collect() +} + +fn reference(topics: &[&str], sort: &[&str], range: IndexRange) -> IndexRef { + IndexRef { topics: strings(topics), sort: strings(sort), range } +} + +fn references(doc: &Document) -> Vec<IndexRef> { + doc.stories.values().flat_map(|s| s.index_refs.iter().map(|r| r.as_ref().clone())).collect() +} + +fn native(refs: Vec<IndexRef>) -> Document { + let mut doc = Document::new(&NewDocument::default()); + let layer = doc.default_layer(); + let (_, sid) = doc.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 400.0, 400.0), layer, "Source", ParaFormat::default()).unwrap(); + let story = doc.story_mut(sid).unwrap(); + for r in refs { + story.insert_index_ref(story.len(), r); + } + doc.check().unwrap(); + doc +} + +fn import_xml(index: &str, refs: &str) -> Document { + let map = format!( + r#"<Document xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging"> + {index} + <Layer Self="L" Name="Art"/> + <idPkg:Story src="Stories/s.xml"/> + <idPkg:Spread src="Spreads/s.xml"/> + </Document>"# + ); + let story = format!( + r#"<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging"> + <Story Self="s"><ParagraphStyleRange><CharacterStyleRange> + <Content>Source</Content>{refs} + </CharacterStyleRange></ParagraphStyleRange></Story> + </idPkg:Story>"# + ); + let spread = r#"<idPkg:Spread xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging"> + <Spread Self="sp"><Page Self="p" GeometricBounds="0 0 240 240"/> + <TextFrame Self="frame" ParentStory="s" ItemLayer="L"> + <Properties><PathGeometry><GeometryPathType><PathPointArray> + <PathPointType Anchor="0 0"/><PathPointType Anchor="200 0"/> + <PathPointType Anchor="200 200"/><PathPointType Anchor="0 200"/> + </PathPointArray></GeometryPathType></PathGeometry></Properties> + </TextFrame> + </Spread> + </idPkg:Spread>"#; + import_idml(&zip_files(&[("designmap.xml", &map), ("Stories/s.xml", &story), ("Spreads/s.xml", spread)])).unwrap() +} + +/// Inspect the exported XML directly, without calling the IDML importer. +fn exported_index(doc: &Document) -> El { + let bytes = export_idml(doc); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut map = String::new(); + zip.by_name("designmap.xml").unwrap().read_to_string(&mut map).unwrap(); + crate::xml::parse(map.as_bytes()).unwrap().find("Index").unwrap().clone() +} + +fn topic<'a>(index: &'a El, path: &[&str]) -> &'a El { + let mut el = index; + for name in path { + el = el.find_all("Topic").find(|t| t.get("Name") == Some(*name)).unwrap(); + } + el +} + +#[test] +fn imports_synthetic_sort_keys_for_nested_topics_and_cross_references() { + let doc = import_xml( + r#"<Index Self="ix"> + <Topic Self="z" Name="Zulu" SortOrder="Alpha"/> + <Topic Self="b" Name="Beta"/> + <Topic Self="empty" Name="Empty" SortOrder=""/> + <Topic Self="parent" Name="Parent" SortOrder=""> + <Topic Self="child" Name="É & <leaf>" SortOrder=" A&<é> "> + <CrossReference Self="see" ReferencedTopic="b" CrossReferenceType="See"/> + <CrossReference Self="also" ReferencedTopic="z" CrossReferenceType="SeeAlso"/> + </Topic> + </Topic> + <Topic Self="zoo" Name="Zoo" SortOrder="Aardvark"> + <Topic Self="leaf" Name="Leaf"/> + </Topic> + </Index>"#, + r#"<PageReference Self="r1" ReferencedTopic="z" PageReferenceType="CurrentPage"/> + <PageReference Self="r2" ReferencedTopic="z" PageReferenceType="ForNextNParagraphs" PageReferenceLimit="4"/> + <PageReference Self="r3" ReferencedTopic="b" PageReferenceType="CurrentPage"/> + <PageReference Self="r4" ReferencedTopic="empty" PageReferenceType="SuppressPageNumbers"/> + <PageReference Self="r5" ReferencedTopic="child" PageReferenceType="CurrentPage"/> + <PageReference Self="r6" ReferencedTopic="leaf" PageReferenceType="CurrentPage"/>"#, + ); + doc.check().unwrap(); + let refs = references(&doc); + assert_eq!(refs.len(), 8); + for r in [ + reference(&["Zulu"], &["Alpha"], IndexRange::CurrentPage), + reference(&["Zulu"], &["Alpha"], IndexRange::NextParagraphs(4)), + reference(&["Beta"], &[], IndexRange::CurrentPage), + reference(&["Empty"], &[], IndexRange::SuppressPageRange), + reference(&["Parent", "É & <leaf>"], &["", " A&<é> "], IndexRange::CurrentPage), + reference(&["Parent", "É & <leaf>"], &["", " A&<é> "], IndexRange::See("Beta".into())), + reference(&["Parent", "É & <leaf>"], &["", " A&<é> "], IndexRange::SeeAlso("Zulu".into())), + reference(&["Zoo", "Leaf"], &["Aardvark"], IndexRange::CurrentPage), + ] { + assert!(refs.contains(&r), "missing {r:?} in {refs:?}"); + } + let story = doc.stories.values().next().unwrap(); + assert_eq!(story.text.replace(designcraft_doc::INDEX_MARK, ""), "Source"); +} + +#[test] +fn exports_sort_keys_as_topic_attributes_without_changing_names() { + let doc = native(vec![ + reference(&["Zulu"], &["Alpha"], IndexRange::CurrentPage), + reference(&["Zulu"], &["Alpha"], IndexRange::NextParagraphs(2)), + reference(&["Beta"], &[], IndexRange::CurrentPage), + reference(&["Empty"], &[""], IndexRange::CurrentPage), + reference(&["Parent", "É & <leaf>"], &["", " A&<é> "], IndexRange::CurrentPage), + reference(&["Alias"], &["Able"], IndexRange::See("Zulu".into())), + reference(&["Related"], &["Azure"], IndexRange::SeeAlso("Zulu".into())), + reference(&["Parent", "Alias"], &["", "Able"], IndexRange::See("Zulu".into())), + reference(&["Parent", "Related"], &["", "Azure"], IndexRange::SeeAlso("Zulu".into())), + ]); + let index = exported_index(&doc); + assert_eq!(index.find_all("Topic").count(), 6, "repeated references and cross-reference targets share their topic"); + assert_eq!(topic(&index, &["Zulu"]).get("SortOrder"), Some("Alpha")); + assert_eq!(topic(&index, &["Beta"]).get("SortOrder"), Some("")); + assert_eq!(topic(&index, &["Empty"]).get("SortOrder"), Some("")); + assert_eq!(topic(&index, &["Parent"]).get("SortOrder"), Some("")); + assert_eq!(topic(&index, &["Parent", "É & <leaf>"]).get("SortOrder"), Some(" A&<é> ")); + for (name, key, kind) in [("Alias", "Able", "See"), ("Related", "Azure", "SeeAlso")] { + for path in [&[name][..], &["Parent", name][..]] { + let t = topic(&index, path); + assert_eq!(t.get("SortOrder"), Some(key)); + let cross_ref = t.find("CrossReference").unwrap(); + assert_eq!(cross_ref.get("CrossReferenceType"), Some(kind)); + assert_eq!(cross_ref.get("ReferencedTopic"), topic(&index, &["Zulu"]).get("Self")); + } + } +} + +/// These references all belong to the same page. Fix the page resolution to isolate index +/// ordering from fonts/composition, and use the real index generator for its text and headings. +fn generated_index(doc: &Document) -> Vec<(String, String)> { + let refs: Vec<_> = references(doc).into_iter().map(|r| Resolved { r, pages: Some((0, 0)) }).collect(); + index::build(&IndexSpec { title: String::new(), ..Default::default() }, &refs, &|p| (p + 1).to_string()) +} + +#[test] +fn round_trip_preserves_generated_index_order_and_headings() { + let doc = native(vec![ + reference(&["Zulu"], &["Alpha"], IndexRange::CurrentPage), + reference(&["Zulu"], &["Alpha"], IndexRange::CurrentPage), + reference(&["Beta"], &[], IndexRange::CurrentPage), + reference(&["Parent", "Zulu"], &["", "Alpha"], IndexRange::CurrentPage), + reference(&["Parent", "Beta"], &[], IndexRange::CurrentPage), + reference(&["Alias"], &["Able"], IndexRange::See("Zulu".into())), + reference(&["Related"], &["Delta"], IndexRange::SeeAlso("Beta".into())), + reference(&["Parent", "Alias"], &["", "Able"], IndexRange::See("Zulu".into())), + reference(&["Parent", "Related"], &["", "Azure"], IndexRange::SeeAlso("Beta".into())), + ]); + let before = generated_index(&doc); + let expected: Vec<_> = [ + ("Index Section Head", "A"), + ("Index Level 1", "Alias See Zulu"), + ("Index Level 1", "Zulu 1"), + ("Index Section Head", "B"), + ("Index Level 1", "Beta 1"), + ("Index Section Head", "D"), + ("Index Level 1", "Related 1. See also Beta"), + ("Index Section Head", "P"), + ("Index Level 1", "Parent"), + ("Index Level 2", "Alias See Zulu"), + ("Index Level 2", "Zulu 1"), + ("Index Level 2", "Related 1. See also Beta"), + ("Index Level 2", "Beta 1"), + ] + .into_iter() + .map(|(style, text)| (style.to_string(), text.to_string())) + .collect(); + assert_eq!(before, expected); + let back = import_idml(&export_idml(&doc)).unwrap(); + back.check().unwrap(); + assert_eq!(generated_index(&back), before); + assert_eq!(back.stories.values().next().unwrap().text.replace(designcraft_doc::INDEX_MARK, ""), "Source"); +} + +#[test] +fn conflicting_sort_keys_keep_the_first_serialized_value_including_defaults() { + fn check(first: &[&str], second: &[&str], expected: &str) { + let doc = native(vec![reference(&["Shared"], first, IndexRange::CurrentPage), reference(&["Shared"], second, IndexRange::CurrentPage)]); + let index = exported_index(&doc); + assert_eq!(index.find_all("Topic").count(), 1, "conflicts do not manufacture topics"); + assert_eq!(topic(&index, &["Shared"]).get("SortOrder"), Some(expected)); + let back = import_idml(&export_idml(&doc)).unwrap(); + let refs = references(&back); + assert_eq!(refs.len(), 2); + for r in refs { + assert_eq!(r.sort.first().map_or("", String::as_str), expected); + } + assert_eq!(references(&doc)[0].sort, strings(first), "export does not edit native references"); + assert_eq!(references(&doc)[1].sort, strings(second)); + } + check(&["Alpha"], &["Omega"], "Alpha"); + check(&["Omega"], &["Alpha"], "Omega"); + check(&[], &["Alpha"], ""); + check(&[""], &["Alpha"], ""); + check(&["Alpha"], &[], "Alpha"); + check(&["Alpha"], &[""], "Alpha"); +} + +#[test] +fn shared_ancestors_and_identical_leaf_names_keep_keys_by_full_path() { + let doc = native(vec![ + reference(&["One", "Zulu"], &["Alpha", "First"], IndexRange::CurrentPage), + reference(&["One", "Zulu"], &["Beta", "Second"], IndexRange::CurrentPage), + reference(&["One", "Beta"], &["Gamma", "Third"], IndexRange::CurrentPage), + reference(&["Two", "Zulu"], &["Delta", "Fourth"], IndexRange::CurrentPage), + ]); + let index = exported_index(&doc); + assert_eq!(topic(&index, &["One"]).get("SortOrder"), Some("Alpha")); + assert_eq!(topic(&index, &["One", "Zulu"]).get("SortOrder"), Some("First")); + assert_eq!(topic(&index, &["One", "Beta"]).get("SortOrder"), Some("Third")); + assert_eq!(topic(&index, &["Two"]).get("SortOrder"), Some("Delta")); + assert_eq!(topic(&index, &["Two", "Zulu"]).get("SortOrder"), Some("Fourth")); +} From 5ca6a474b93c35c1a42ba2e9755501144d782b1f Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:59:32 +1000 Subject: [PATCH 119/238] PARITY-TABLE-SPLIT: split each original selected cell once Co-Authored-By: Codex <codex@openai.com> --- crates/doc/src/table.rs | 165 ++++++++++++++++++++++++++++++- crates/engine/src/cmd/table.rs | 6 +- crates/engine/src/tests_table.rs | 122 +++++++++++++++++++++++ 3 files changed, 287 insertions(+), 6 deletions(-) diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index 7fe385e98..9449be93c 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -695,6 +695,79 @@ impl Table { self.set_regions(®ions); } + /// Split each original owner touched by `range` once. Remember merged-cell cuts in the + /// original grid: a neighboring split may refine that grid before we reach the owner. + pub fn split_cells(&mut self, range: CellRange, horizontal: bool) -> Result<(), String> { + if range.r0 > range.r1 || range.c0 > range.c1 || range.r1 >= self.nrows() || range.c1 >= self.ncols() { + return Err("cell range out of bounds".into()); + } + let owners = self.owners(); + let mut selected = std::collections::BTreeSet::new(); + for r in range.r0..=range.r1 { + for c in range.c0..=range.c1 { + let owner = owners.get(r * self.ncols() + c).ok_or("missing cell owner")?; + selected.insert(*owner); + } + } + let mut targets = Vec::new(); + for (r, c) in selected { + let cell = self.cell(r, c).ok_or("missing split cell")?; + let span = if horizontal { cell.row_span } else { cell.col_span } as usize; + let start = if horizontal { r } else { c }; + let cut = if span > 1 { Some(start.checked_add(span.div_ceil(2)).ok_or("invalid split boundary")?) } else { None }; + targets.push((r, c, cut)); + } + for i in 0..targets.len() { + let (r, c, cut) = *targets.get(i).ok_or("missing split target")?; + let cell = self.cell(r, c).ok_or("missing split cell")?; + let (rs, cs) = (cell.row_span as usize, cell.col_span as usize); + let mut blank = cell.blank_like(); + // Splitting creates an empty half, not a duplicate of graphic-cell content. + blank.graphic = None; + let before = if horizontal { self.nrows() } else { self.ncols() }; + if let Some(cut) = cut { + let (start, span) = if horizontal { (r, rs) } else { (c, cs) }; + let first = cut.checked_sub(start).filter(|n| *n > 0 && *n < span).ok_or("invalid split boundary")?; + let mut regions: Vec<_> = self.regions().into_iter().filter(|&(rr, cc, _, _)| (rr, cc) != (r, c)).collect(); + if horizontal { + regions.push((r, c, first, cs)); + regions.push((cut, c, rs - first, cs)); + } else { + regions.push((r, c, rs, first)); + regions.push((r, cut, rs, cs - first)); + } + self.set_regions(®ions); + } else { + // An originally single-track cell may now span two tracks inserted for its + // neighbor. split_cell divides that span instead of inserting another track. + self.split_cell(r, c, horizontal); + } + let first = self.cell(r, c).ok_or("missing split half")?; + let second = if horizontal { (r + first.row_span as usize, c) } else { (r, c + first.col_span as usize) }; + let second = self.cell_mut(second.0, second.1).ok_or("missing split half")?; + blank.row_span = second.row_span; + blank.col_span = second.col_span; + *second = blank; + let after = if horizontal { self.nrows() } else { self.ncols() }; + if after > before { + let at = if horizontal { r + 1 } else { c + 1 }; + let added = after - before; + for (row, col, cut) in targets.iter_mut().skip(i + 1) { + let axis = if horizontal { row } else { col }; + if *axis >= at { + *axis += added; + } + if let Some(cut) = cut + && *cut >= at + { + *cut += added; + } + } + } + } + Ok(()) + } + /// Split the cell covering (r, c) in two: `horizontal` stacks the halves (Split Cell /// Horizontally), otherwise side by side. A merged cell splits its span; a single cell gets a /// new row (column) that the cells beside it span, the row height (column width) shared. @@ -719,10 +792,11 @@ impl Table { return; } if horizontal { - let h = self.rows[or].height; + let row = self.rows[or].clone(); self.insert_rows(or + 1, 1); - self.rows[or].height = h / 2.0; - self.rows[or + 1].height = h / 2.0; + self.rows[or].height = row.height / 2.0; + // Both halves keep the split row's height mode and header/body/footer role. + self.rows[or + 1] = Row { height: row.height / 2.0, ..row }; // insert_rows doesn't grow regions that end at the split row; everything else on it // spans the new row now. let mut regions: Vec<_> = self.regions(); @@ -1255,4 +1329,89 @@ mod split_tests { assert_eq!(m.nrows(), 4); assert_eq!((m.cell(0, 0).unwrap().row_span, m.cell(2, 0).unwrap().row_span), (2, 2)); } + + #[test] + fn split_range_keeps_original_merged_cut_when_neighbor_tracks_are_refined() { + for horizontal in [true, false] { + let mut t = Table::new(1, 4, 4, 0, 0, 280.0); + for (i, size) in [40.0, 60.0, 80.0, 100.0].into_iter().enumerate() { + t.rows[i].mode = RowHeightMode::Exactly; + t.rows[i].height = if horizontal { size } else { 70.0 }; + t.columns[i].width = if horizontal { 70.0 } else { size }; + } + for r in 0..4 { + for c in 0..4 { + let cell = t.cell_mut(r, c).unwrap(); + cell.text.insert(0, &format!("{r},{c}")); + cell.style = format!("style-{r}-{c}"); + cell.insets = [r as f64 + c as f64 + 1.0; 4]; + } + } + let (merged, selected, owner, lower, neighbor, moved_neighbor) = if horizontal { + (CellRange::new(0, 1, 2, 1), CellRange::new(0, 0, 2, 1), (0, 1), (4, 1), (3, 3), (6, 3)) + } else { + (CellRange::new(1, 0, 1, 2), CellRange::new(0, 0, 1, 2), (1, 0), (1, 4), (3, 3), (3, 6)) + }; + t.merge(merged).unwrap(); + let original = t.clone(); + t.split_cells(selected, horizontal).unwrap(); + t.check().unwrap(); + assert_eq!((t.nrows(), t.ncols()), if horizontal { (7, 4) } else { (4, 7) }); + let sizes: Vec<_> = if horizontal { t.rows.iter().map(|r| r.height).collect() } else { t.columns.iter().map(|c| c.width).collect() }; + assert_eq!(sizes, [20.0, 20.0, 30.0, 30.0, 40.0, 40.0, 100.0]); + let first = t.cell(owner.0, owner.1).unwrap(); + let second = t.cell(lower.0, lower.1).unwrap(); + // The original three-track merge divides after its second original track, + // regardless of which of those tracks a neighboring selected cell subdivides. + assert_eq!(if horizontal { first.row_span } else { first.col_span }, 4); + assert_eq!(if horizontal { second.row_span } else { second.col_span }, 2); + assert_eq!(first.text, original.cell(owner.0, owner.1).unwrap().text); + assert!(second.text.is_empty()); + assert_eq!(second.style, first.style); + assert_eq!(second.insets, first.insets); + assert_eq!(t.cell(moved_neighbor.0, moved_neighbor.1), original.cell(neighbor.0, neighbor.1)); + let owners = t.owners(); + assert_eq!(owners.iter().copied().collect::<std::collections::BTreeSet<_>>().len(), 18, "four original selected owners split once each"); + let mut before_text: Vec<_> = original.cells.iter().filter(|c| !c.text.is_empty()).map(|c| c.text.text.clone()).collect(); + let mut after_text: Vec<_> = t.cells.iter().filter(|c| !c.text.is_empty()).map(|c| c.text.text.clone()).collect(); + before_text.sort(); + after_text.sort(); + assert_eq!(after_text, before_text, "no original text is copied or lost"); + } + } + + #[test] + fn split_range_resolves_covered_positions_without_expanding_selection() { + for horizontal in [true, false] { + let mut t = Table::new(1, 3, 3, 0, 0, 180.0); + t.merge(CellRange::new(0, 0, 1, 1)).unwrap(); + let selected = if horizontal { CellRange::new(1, 1, 1, 2) } else { CellRange::new(1, 1, 2, 1) }; + t.split_cells(selected, horizontal).unwrap(); + t.check().unwrap(); + let owners = t.owners(); + assert_eq!(owners.iter().copied().collect::<std::collections::BTreeSet<_>>().len(), 8, "only the two touched owners split"); + assert_eq!((t.nrows(), t.ncols()), if horizontal { (4, 3) } else { (3, 4) }); + assert_eq!(t.owner(0, 0), (0, 0)); + assert_eq!(if horizontal { t.owner(1, 0) } else { t.owner(0, 1) }, if horizontal { (1, 0) } else { (0, 1) }); + } + } + + #[test] + fn split_header_row_keeps_its_height_mode_and_role() { + let mut t = Table::new(1, 1, 2, 1, 1, 180.0); + t.rows[0].height = 60.0; + t.rows[0].mode = RowHeightMode::Exactly; + let body = t.rows[1].clone(); + let footer = t.rows[2].clone(); + t.split_cells(CellRange::new(0, 0, 0, 1), true).unwrap(); + t.check().unwrap(); + assert_eq!(t.header_rows(), 2); + for row in &t.rows[..2] { + assert_eq!(row.height, 30.0); + assert_eq!(row.mode, RowHeightMode::Exactly); + assert_eq!(row.kind, RowKind::Header); + } + assert_eq!(t.rows[2], body); + assert_eq!(t.rows[3], footer); + } } diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index c2313170a..6c6b1ff9d 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -178,7 +178,7 @@ fn table_specs() -> Vec<CommandSpec> { "Split Cell Horizontally", ["Table"], None, - "{} — the target cell becomes two, one above the other", + "{} — each target cell becomes two, one above the other", in_table, |s, p| split(s, p, true) ), @@ -187,7 +187,7 @@ fn table_specs() -> Vec<CommandSpec> { "Split Cell Vertically", ["Table"], None, - "{} — the target cell becomes two, side by side", + "{} — each target cell becomes two, side by side", in_table, |s, p| split(s, p, false) ), @@ -589,7 +589,7 @@ fn merge(s: &mut Session, p: &Value) -> Result<Value> { fn split(s: &mut Session, p: &Value, horizontal: bool) -> Result<Value> { let g = target(s, p, "table.split")?; edit_table(s, &g, "table.split", |t| { - t.split_cell(g.range.r0, g.range.c0, horizontal); + t.split_cells(g.range, horizontal).map_err(|e| bad("table.split", e))?; Ok(json!({"rows": t.nrows(), "cols": t.ncols()})) }) } diff --git a/crates/engine/src/tests_table.rs b/crates/engine/src/tests_table.rs index 45861347d..3934aabec 100644 --- a/crates/engine/src/tests_table.rs +++ b/crates/engine/src/tests_table.rs @@ -399,3 +399,125 @@ fn editing_beside_a_merged_cell_only_changes_the_selected_edge_segment() { } } } + +#[test] +fn split_selected_row_or_column_splits_both_cells() { + for horizontal in [true, false] { + let (mut s, sid, _) = session_with_frame(); + let tid = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap(); + let params = if horizontal { json!({"rows": [0, 0], "cols": [0, 1]}) } else { json!({"rows": [0, 1], "cols": [0, 0]}) }; + s.execute("table.select", ¶ms).unwrap(); + let command = if horizontal { "table.splitHorizontally" } else { "table.splitVertically" }; + s.execute(command, &json!({})).unwrap(); + let table = table_for(&s, sid, tid); + assert_eq!((table.nrows(), table.ncols()), if horizontal { (3, 2) } else { (2, 3) }); + for r in 0..2 { + for c in 0..2 { + assert_eq!(table.owner(r, c), (r, c), "both selected cells have independent halves"); + } + } + check(&s); + } +} + +#[test] +fn split_selected_block_and_merged_cells_preserves_geometry_and_history() { + for horizontal in [true, false] { + for merged in [false, true] { + let (mut s, sid, _) = session_with_frame(); + let tid = s.execute("table.insert", &json!({"rows": 4, "cols": 4, "width": 240})).unwrap()["table"].as_u64().unwrap(); + s.execute("table.setRowHeight", &json!({"table": tid, "height": 60, "mode": "exactly"})).unwrap(); + s.execute("style.cell.create", &json!({"name": "Neighbor", "fill": "[Paper]", "insets": 3})).unwrap(); + s.execute("style.cell.create", &json!({"name": "Chosen", "fill": "[Black]", "tint": 0.15, "insets": 2})).unwrap(); + s.execute("style.cell.apply", &json!({"table": tid, "name": "Neighbor"})).unwrap(); + for r in 0..4 { + for c in 0..4 { + s.execute("table.setCell", &json!({"row": r, "col": c, "text": format!("{r},{c}")})).unwrap(); + } + } + let range = if !merged { + doc::CellRange::new(1, 1, 2, 2) + } else if horizontal { + doc::CellRange::new(0, 0, 2, 1) + } else { + doc::CellRange::new(0, 0, 1, 2) + }; + let selection = json!({"rows": [range.r0, range.r1], "cols": [range.c0, range.c1]}); + s.execute("table.select", &selection).unwrap(); + s.execute("style.cell.apply", &json!({"name": "Chosen"})).unwrap(); + if merged { + let merge = if horizontal { json!({"rows": [0, 2], "cols": [1, 1]}) } else { json!({"rows": [1, 1], "cols": [0, 2]}) }; + s.execute("table.merge", &merge).unwrap(); + s.execute("table.select", &selection).unwrap(); + } + let before_doc = s.doc().unwrap().doc.clone(); + let before_selection = s.doc().unwrap().selection.clone(); + let before = table_for(&s, sid, tid).clone(); + let before_layout = s.cache.get(&before_doc, doc::StoryId(sid), None); + let before_fragment = before_layout.frames.iter().flat_map(|f| &f.tables).find(|t| t.table == tid).unwrap(); + let undo_len = s.doc().unwrap().history.undo.len(); + let command = if horizontal { "table.splitHorizontally" } else { "table.splitVertically" }; + s.execute(command, &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_len + 1, "one split command creates one undo entry"); + let after_doc = s.doc().unwrap().doc.clone(); + let after_selection = s.doc().unwrap().selection.clone(); + let after = table_for(&s, sid, tid).clone(); + let after_layout = s.cache.get(&after_doc, doc::StoryId(sid), None); + let after_fragment = after_layout.frames.iter().flat_map(|f| &f.tables).find(|t| t.table == tid).unwrap(); + assert_eq!(after_fragment.rect, before_fragment.rect, "splitting does not resize the table"); + assert_eq!(after_fragment.cells.len(), before_fragment.cells.len() + 4, "four original owners split once"); + for placed in &before_fragment.cells { + let original = before.cell(placed.row, placed.col).unwrap(); + let matches: Vec<_> = after_fragment.cells.iter().filter(|c| c.source == placed.source).collect(); + assert_eq!(matches.len(), 1, "original cell text survives once: {}", placed.source); + let surviving = matches[0]; + let cell = after.cell(surviving.row, surviving.col).unwrap(); + let mut normalized = cell.clone(); + normalized.row_span = original.row_span; + normalized.col_span = original.col_span; + assert_eq!(&normalized, original, "existing cell text and formatting are preserved"); + if !range.contains(placed.row, placed.col) { + assert_eq!(surviving.rect, placed.rect, "unselected neighbor geometry is unchanged"); + continue; + } + let second_addr = if horizontal { + (surviving.row + cell.row_span as usize, surviving.col) + } else { + (surviving.row, surviving.col + cell.col_span as usize) + }; + let second = after.cell(second_addr.0, second_addr.1).unwrap(); + assert_eq!(after.owner(second_addr.0, second_addr.1), second_addr); + assert!(second.text.is_empty()); + assert_eq!(second.style, original.style); + assert_eq!(second.insets, original.insets); + assert_eq!(second.fill, original.fill); + assert_eq!(second.fill_tint, original.fill_tint); + let second_rect = after_fragment.cell(second_addr.0, second_addr.1).unwrap().rect; + assert_eq!(surviving.rect.union(second_rect), placed.rect, "the halves exactly cover the original cell"); + if horizontal { + assert_eq!(surviving.rect.y1, second_rect.y0); + } else { + assert_eq!(surviving.rect.x1, second_rect.x0); + } + } + let text = s.doc().unwrap().selection.text.unwrap(); + let address = text.cell.unwrap(); + assert_eq!(after.owner(address.row, address.col), (address.row, address.col)); + assert!(text.focus <= after.cell(address.row, address.col).unwrap().text.len()); + let cells = s.doc().unwrap().selection.cells.unwrap(); + assert!(cells.range.r1 < after.nrows() && cells.range.c1 < after.ncols()); + check(&s); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &before_doc)); + assert_eq!(s.doc().unwrap().selection, before_selection); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_len); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &after_doc)); + assert_eq!(s.doc().unwrap().selection, after_selection); + let bytes = crate::cmd::file_bytes(&s.doc().unwrap().doc); + let reopened = crate::cmd::file_from(&bytes).unwrap(); + reopened.check().unwrap(); + assert_eq!(reopened.story(doc::StoryId(sid)).unwrap().tables.get(&tid).unwrap().as_ref(), &after); + } + } +} From 966b637796bc9a7cf175c066ded41521b7c72e0f Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <marcoarnulfo1@gmail.com> Date: Tue, 13 Oct 2026 04:27:41 +0200 Subject: [PATCH 120/238] fix(ui): open the dock's panel menu from its three-line button (part of #676) --- crates/ui-egui/src/dock.rs | 12 +++++ crates/ui-egui/src/panel_docking.rs | 54 ++++++++++++----------- crates/ui-egui/src/panel_docking_tests.rs | 25 +++++++++++ crates/ui-egui/src/panels/pages.rs | 9 ++++ 4 files changed, 75 insertions(+), 25 deletions(-) diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 72810e151..0d581e992 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -90,7 +90,19 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { x += w; } let menu_r = egui::Rect::from_min_size(egui::pos2(strip.max.x - 24.0, strip.min.y + 4.0), vec2(18.0, 18.0)); + let menu = ui.interact(menu_r, ui.id().with("dock-menu"), Sense::click()); + if menu.hovered() { + ui.painter().rect_filled(menu_r, 3.0, t.hover); + } icons::paint(ui.painter(), menu_r, "menu", t.icon); + let tab = app.ui.dock_tab.clone(); + egui::Popup::menu(&menu).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + if tab == "pages" { + panels::pages::panel_menu(app, ui); + ui.separator(); + } + crate::panel_docking::panel_menu(app, ui, &tab); + }); egui::Frame::NONE.inner_margin(egui::Margin { left: 10, right: 10, top: 4, bottom: 6 }).show(ui, |ui| { egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| match app.ui.dock_tab.as_str() { "pages" => panels::pages::show(app, ui), diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index e53986c5c..05fc1150c 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -418,31 +418,7 @@ fn icon_rail(app: &mut DesignApp, ui: &mut egui::Ui) { pub(crate) fn legacy_tab(app: &mut DesignApp, ui: &mut egui::Ui, panel: &str, response: &egui::Response) { let Some(panel) = normalize(panel) else { return }; - let language = app.ui.language.clone(); - response.context_menu(|ui| { - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&language, "Float Panel"))).clicked() { - if let Err(error) = app.run("window.panel.float", json!({"panel": panel})) { - app.status(error); - } - ui.close(); - } - ui.menu_button(crate::rtl::widget(ui, crate::i18n::tr(&language, "Group with")), |ui| { - egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { - for (anchor, label, _) in crate::dock::DOCK_TABS.iter().chain(crate::dock::ICON_PANELS) { - if *anchor != panel && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&language, label))).clicked() { - // A closed icon panel is made visible before becoming the destination. - let result = app - .run("window.panel.activate", json!({"panel": anchor})) - .and_then(|_| app.run("window.panel.move", json!({"panel":panel,"anchor":anchor}))); - if let Err(error) = result { - app.status(error); - } - ui.close(); - } - } - }); - }); - }); + response.context_menu(|ui| panel_menu(app, ui, panel)); if response.drag_started() && let Some(id) = normalize(panel) { @@ -472,6 +448,34 @@ pub(crate) fn legacy_tab(app: &mut DesignApp, ui: &mut egui::Ui, panel: &str, re } } +/// The docking commands of a panel: its tab's context menu and the end of its panel menu. +pub(crate) fn panel_menu(app: &mut DesignApp, ui: &mut egui::Ui, panel: &str) { + let Some(panel) = normalize(panel) else { return }; + let language = app.ui.language.clone(); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&language, "Float Panel"))).clicked() { + if let Err(error) = app.run("window.panel.float", json!({"panel": panel})) { + app.status(error); + } + ui.close(); + } + ui.menu_button(crate::rtl::widget(ui, crate::i18n::tr(&language, "Group with")), |ui| { + egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { + for (anchor, label, _) in crate::dock::DOCK_TABS.iter().chain(crate::dock::ICON_PANELS) { + if *anchor != panel && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&language, label))).clicked() { + // A closed icon panel is made visible before becoming the destination. + let result = app + .run("window.panel.activate", json!({"panel": anchor})) + .and_then(|_| app.run("window.panel.move", json!({"panel":panel,"anchor":anchor}))); + if let Err(error) = result { + app.status(error); + } + ui.close(); + } + } + }); + }); +} + struct PanelContent<'a>(&'a mut DesignApp); impl craft_ui::docking::DockContent<String> for PanelContent<'_> { fn body(&mut self, ui: &mut egui::Ui, id: &String) { diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index 210ae0ce0..de08e3cf5 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -597,3 +597,28 @@ fn capture_legacy_panel_menu_visual_fixture() { assert_eq!(serde_json::to_value(&h.state().session.active().unwrap().doc).unwrap(), doc); h.render().unwrap().save(directory.join("designcraft-legacy-last-target-selected-dark-ar.png")).unwrap(); } + +#[test] +fn legacy_dock_menu_button_opens_the_active_panel_menu() { + let mut app = app(); + app.run("file.new", json!({})).unwrap(); + app.ui.dock_tab = "pages".into(); + let pages = app.session.active().unwrap().doc.page_count(); + let mut h = harness(app, vec2(1280.0, 800.0)); + assert!(h.state().ui.docking.is_none()); + let tab = h.query_all_by_label("Pages").find(|node| node.rect().height() <= 45.0).unwrap().rect(); + let dock = egui::containers::panel::PanelState::load(&h.ctx, egui::Id::new("dock")).unwrap().outer_rect; + let at = egui::pos2(dock.right() - 15.0, tab.center().y); + h.event(Event::PointerMoved(at)); + h.run_steps(1); + for pressed in [true, false] { + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(1); + } + h.run_steps(2); + assert!(egui::Popup::is_any_open(&h.ctx), "the three-line button must open a menu"); + h.get_by_label("Insert Page After").click(); + h.run_steps(3); + assert_eq!(h.state().session.active().unwrap().doc.page_count(), pages + 1); + assert!(!egui::Popup::is_any_open(&h.ctx)); +} diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index 6bbad9066..ae323225b 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -216,6 +216,15 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } } +/// The panel menu's page commands, acting on the current page and its spread. +pub fn panel_menu(app: &mut DesignApp, ui: &mut egui::Ui) { + let Some(st) = app.session.active() else { return }; + let doc = st.doc.clone(); + let cur = crate::canvas::current_page(app).unwrap_or(0); + let Some((si, _)) = doc.page_loc(cur) else { return }; + page_menu(app, ui, &doc, cur, si); +} + fn page_menu(app: &mut DesignApp, ui: &mut egui::Ui, doc: &designcraft_doc::Document, abs: usize, si: usize) { if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Insert Page After"))).clicked() { let _ = app.run("layout.pages.insert", json!({"after": abs, "count": 1})); From fa9c7b08b255bef19577c4830da546f285e44387 Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:43:50 +1000 Subject: [PATCH 121/238] PARITY-DISTRIBUTE-LOCKS: preserve locked objects during distribution Co-Authored-By: Codex <codex@openai.com> --- crates/engine/src/cmd/object.rs | 4 + crates/engine/tests/distribute_locks.rs | 128 ++++++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 crates/engine/tests/distribute_locks.rs diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index cd13be117..729b955b1 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -1887,6 +1887,10 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> { for ((id, r), t) in items.iter().zip(targets) { let delta = t - lo(r); if let Some(it) = d.item_mut(*id) { + // Keep locked items in the spacing calculation, as align does for its reference. + if it.locked { + continue; + } let v = if horizontal { Vec2::new(delta, 0.0) } else { Vec2::new(0.0, delta) }; it.xf = Affine::translate(v) * it.xf; } diff --git a/crates/engine/tests/distribute_locks.rs b/crates/engine/tests/distribute_locks.rs new file mode 100644 index 000000000..4935e9946 --- /dev/null +++ b/crates/engine/tests/distribute_locks.rs @@ -0,0 +1,128 @@ +//! Distribution preserves explicit item locks without changing reference/spacing calculations. +use std::sync::Arc; + +use designcraft_doc::ItemId; +use designcraft_engine::Session; +use designcraft_geom::Affine; +use serde_json::{Value, json}; + +const STARTS: [f64; 4] = [10.0, 70.0, 150.0, 310.0]; +const SIZES: [f64; 4] = [20.0, 30.0, 40.0, 50.0]; +// An original, synthetic 1x1 RGBA PNG; used by both a locked and an unlocked frame. +const PIXEL: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGPYYqPxHwAE2AIYBVnQbwAAAABJRU5ErkJggg=="; + +fn fixture(horizontal: bool, locks: [bool; 4]) -> (Session, Vec<u64>) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let mut ids = Vec::new(); + for (i, (&start, &size)) in STARTS.iter().zip(&SIZES).enumerate() { + let cross = 40.0 + i as f64 * 35.0; + let rect = if horizontal { [start, cross, start + size, cross + 25.0] } else { [cross, start, cross + 25.0, start + size] }; + let graphic = i == 1 || i == 2; + let created = + s.execute("frame.create", &json!({"rect": rect, "content": if graphic {"unassigned"} else {"text"}, "text": "Keep this text"})).unwrap(); + let id = created["id"].as_u64().unwrap(); + ids.push(id); + if graphic { + s.execute("file.place", &json!({"frame": id, "base64": PIXEL, "name": "pixel.png"})).unwrap(); + } + } + // An unselected control must retain all geometry and content too. + s.execute("frame.create", &json!({"rect": [400, 400, 450, 450], "content": "text", "text": "Outside selection"})).unwrap(); + s.execute("object.fill", &json!({"ids": ids, "swatch": "[Black]", "tint": 0.5})).unwrap(); + let locked: Vec<u64> = ids.iter().zip(locks).filter_map(|(&id, lock)| lock.then_some(id)).collect(); + if !locked.is_empty() { + s.execute("object.lock", &json!({"ids": locked})).unwrap(); + } + // Layers selection may include locked items. Deliberately avoid geometric order. + s.execute("selection.set", &json!({"ids": [ids[3], ids[1], ids[0], ids[2]]})).unwrap(); + assert!(!s.doc().unwrap().doc.assets.is_empty()); + s.doc().unwrap().doc.check().unwrap(); + (s, ids) +} + +fn modes() -> [(Value, [f64; 4]); 3] { + [ + // Endpoint centers 20 and 335 give a center step of 105. + (json!({"by": "centers"}), [10.0, 110.0, 210.0, 310.0]), + // Outer span 350 minus total widths 140 gives three gaps of 70. + (json!({"by": "spacing"}), [10.0, 100.0, 200.0, 310.0]), + // An explicit spacing value selects fixed-gap mode without requiring `by`. + (json!({"spacing": 15}), [10.0, 45.0, 90.0, 145.0]), + ] +} + +fn check_distribution(horizontal: bool, mut params: Value, positions: [f64; 4], locks: [bool; 4], explicit_ids: bool) { + let (mut s, ids) = fixture(horizontal, locks); + params["axis"] = json!(if horizontal { "horizontal" } else { "vertical" }); + if explicit_ids { + params["ids"] = json!([ids[2], ids[0], ids[3], ids[1]]); + } + let before = s.doc().unwrap().doc.clone(); + let selection = s.doc().unwrap().selection.clone(); + let undo_len = s.doc().unwrap().history.undo.len(); + let mut expected = (*before).clone(); + for (i, &id) in ids.iter().enumerate() { + if !locks[i] { + let delta = positions[i] - STARTS[i]; + let translation = if horizontal { (delta, 0.0) } else { (0.0, delta) }; + let item = expected.item_mut(ItemId(id)).unwrap(); + item.xf = Affine::translate(translation) * item.xf; + } + } + s.execute("object.distribute", ¶ms).unwrap(); + let after = s.doc().unwrap().doc.clone(); + for (i, &id) in ids.iter().enumerate() { + let item = after.item(ItemId(id)).unwrap(); + let b = item.bounds(); + let start = if horizontal { b.x0 } else { b.y0 }; + assert_eq!(start, if locks[i] { STARTS[i] } else { positions[i] }, "item {i}: {params}, locks={locks:?}"); + if locks[i] { + assert_eq!(item, before.item(ItemId(id)).unwrap(), "locked item's transform, path, content and flags are unchanged"); + } + } + // Only the specified translations may change: all paths, sizes, other-axis coordinates, + // paint, content, stories, layer membership, item order and embedded asset bytes are exact. + assert_eq!(*after, expected, "{params}, locks={locks:?}"); + assert_eq!(s.doc().unwrap().selection, selection); + after.check().unwrap(); + if expected != *before { + assert_eq!(s.doc().unwrap().history.undo.len(), undo_len + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(Arc::ptr_eq(&s.doc().unwrap().doc, &before)); + assert_eq!(s.doc().unwrap().selection, selection); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(Arc::ptr_eq(&s.doc().unwrap().doc, &after)); + assert_eq!(s.doc().unwrap().selection, selection); + } +} + +#[test] +fn distribute_preserves_locked_items_and_moves_unlocked_controls() { + for horizontal in [true, false] { + for (params, positions) in modes() { + // Locked interior object still participates in the numeric reference calculation. + check_distribution(horizontal, params.clone(), positions, [false, true, false, false], false); + // Locked endpoints and an interior object; only the unlocked interior control moves. + check_distribution(horizontal, params, positions, [true, true, false, true], true); + } + } +} + +#[test] +fn distribute_unlocked_spacing_modes_are_unchanged() { + for horizontal in [true, false] { + for (params, positions) in modes() { + check_distribution(horizontal, params, positions, [false; 4], true); + } + } +} + +#[test] +fn distribute_all_locked_leaves_document_and_selection_unchanged() { + for horizontal in [true, false] { + for (params, positions) in modes() { + check_distribution(horizontal, params, positions, [true; 4], false); + } + } +} From 481b7d2f3b4caa756b11a4b893c656398b9a5d74 Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:17:58 +1000 Subject: [PATCH 122/238] PARITY-EFFECTIVE-PPI: check transformed image resolution Co-Authored-By: Codex <codex@openai.com> --- crates/engine/src/cmd/preflight.rs | 87 ++++---- crates/engine/tests/preflight_resolution.rs | 217 ++++++++++++++++++++ 2 files changed, 264 insertions(+), 40 deletions(-) create mode 100644 crates/engine/tests/preflight_resolution.rs diff --git a/crates/engine/src/cmd/preflight.rs b/crates/engine/src/cmd/preflight.rs index 3fc061d7d..f5ee8dfdf 100644 --- a/crates/engine/src/cmd/preflight.rs +++ b/crates/engine/src/cmd/preflight.rs @@ -5,6 +5,7 @@ use designcraft_compose::ComposedStory; use designcraft_doc::{Content, Document, Intent, Item, SpreadRef}; use designcraft_fonts::FaceRef; +use designcraft_geom::Affine; use serde::Serialize; use serde_json::{Value, json}; @@ -29,10 +30,12 @@ pub fn specs() -> Vec<CommandSpec> { "{minPpi?: 150} → issues [{severity, kind, message, item?, page?}]", has_doc, run)] } -fn page_of(d: &Document, sr: SpreadRef, it: &Item) -> Option<usize> { +fn page_of(d: &Document, sr: SpreadRef, it: &Item, parent: Affine) -> Option<usize> { let SpreadRef::Doc(si) = sr else { return None }; let sp = d.spreads.get(si)?; - Some(d.first_page_of_spread(si) + sp.page_at_x(it.bounds().center().x).unwrap_or(0)) + // Item bounds include its own transform; ancestors bring the frame into spread space. + let bounds = parent.transform_rect_bbox(it.bounds()); + Some(d.first_page_of_spread(si) + sp.page_at_x(bounds.center().x).unwrap_or(0)) } /// Characters a missing-glyph report names before "and N more". @@ -85,7 +88,7 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec<Issue> { missing_glyphs(&cs, &story.text, 0, &mut missing); if cs.is_overset() { let last = story.frames.last().copied(); - let page = last.and_then(|f| d.find(f).zip(d.item(f))).and_then(|(loc, it)| page_of(d, loc.spread, it)); + let page = last.and_then(|f| d.find(f).zip(d.item(f))).and_then(|(loc, it)| page_of(d, loc.spread, it, Affine::IDENTITY)); let n = story.text[cs.overset_at.unwrap_or(0).min(story.len())..].chars().count(); out.push(Issue { severity: "error", kind: "overset", message: format!("Overset text: {n} characters"), item: last.map(|i| i.0), page }); } @@ -161,48 +164,52 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec<Issue> { page: None, }); } - // Items. + // Carry the ancestor transform in the same order as rendering, retaining pre-order. for (si, sp) in d.spreads.iter().enumerate() { - for top in &sp.items { - top.walk(&mut |it| { - let page = page_of(d, SpreadRef::Doc(si), it); - match &it.content { - Content::Graphic(g) => match d.assets.get(&g.asset) { - None => { - out.push(Issue { severity: "error", kind: "missingLink", message: "Missing graphic".into(), item: Some(it.id.0), page }) - } - Some(a) if a.data.is_empty() => out.push(Issue { - severity: "error", - kind: "missingLink", - message: format!("Missing link: {}", a.link.as_deref().unwrap_or(&a.name)), - item: Some(it.id.0), - page, - }), - Some(a) => { - // Effective ppi: pixels per inch at the placed size. Placed PDF and - // SVG pages export as vectors; their `pixels` is only the preview's. - let vector = designcraft_images::is_pdf(&a.data) || designcraft_images::is_svg(&a.data); - if let Some((pw, _)) = a.pixels.filter(|_| !vector) { - let placed_w = (g.xf * it.xf).as_coeffs()[0].hypot((g.xf * it.xf).as_coeffs()[1]) * g.size.0; - let ppi = pw as f64 / (placed_w / 72.0).max(1e-6); - if d.settings.intent == Intent::Print && ppi < min_ppi { - out.push(Issue { - severity: "warning", - kind: "lowResolution", - message: format!("{}: effective {ppi:.0} ppi (< {min_ppi:.0})", a.name), - item: Some(it.id.0), - page, - }); - } + let mut pending: Vec<_> = sp.items.iter().rev().map(|it| (it.as_ref(), Affine::IDENTITY)).collect(); + while let Some((it, parent)) = pending.pop() { + let xf = parent * it.xf; + pending.extend(it.children().iter().rev().map(|child| (child.as_ref(), xf))); + let page = page_of(d, SpreadRef::Doc(si), it, parent); + match &it.content { + Content::Graphic(g) => match d.assets.get(&g.asset) { + None => out.push(Issue { severity: "error", kind: "missingLink", message: "Missing graphic".into(), item: Some(it.id.0), page }), + Some(a) if a.data.is_empty() => out.push(Issue { + severity: "error", + kind: "missingLink", + message: format!("Missing link: {}", a.link.as_deref().unwrap_or(&a.name)), + item: Some(it.id.0), + page, + }), + Some(a) => { + // Placed PDF and SVG pages export as vectors; `pixels` is only the preview's. + let vector = designcraft_images::is_pdf(&a.data) || designcraft_images::is_svg(&a.data); + if let Some((pw, ph)) = a.pixels.filter(|_| !vector) { + // Measure each image axis in points, not its rotated bounding box or + // cropped frame. Either axis can fall below the minimum resolution. + let [xx, yx, xy, yy, _, _] = (xf * g.xf).as_coeffs(); + let placed_w = xx.hypot(yx) * g.size.0; + let placed_h = xy.hypot(yy) * g.size.1; + let ppi_x = pw as f64 / (placed_w / 72.0).max(1e-6); + let ppi_y = ph as f64 / (placed_h / 72.0).max(1e-6); + let ppi = ppi_x.min(ppi_y); + if d.settings.intent == Intent::Print && ppi < min_ppi { + out.push(Issue { + severity: "warning", + kind: "lowResolution", + message: format!("{}: effective {ppi:.0} ppi (< {min_ppi:.0})", a.name), + item: Some(it.id.0), + page, + }); } } - }, - Content::Text(tf) if d.story(tf.story).is_some_and(|s| s.is_empty()) => { - out.push(Issue { severity: "warning", kind: "emptyText", message: "Empty text frame".into(), item: Some(it.id.0), page }); } - _ => {} + }, + Content::Text(tf) if d.story(tf.story).is_some_and(|s| s.is_empty()) => { + out.push(Issue { severity: "warning", kind: "emptyText", message: "Empty text frame".into(), item: Some(it.id.0), page }); } - }); + _ => {} + } } } // RGB swatches in use in a print document. diff --git a/crates/engine/tests/preflight_resolution.rs b/crates/engine/tests/preflight_resolution.rs new file mode 100644 index 000000000..f4562c569 --- /dev/null +++ b/crates/engine/tests/preflight_resolution.rs @@ -0,0 +1,217 @@ +//! Effective raster resolution follows image, frame and ancestor transforms in spread space. +use std::sync::Arc; + +use designcraft_doc::{Content, Intent, ItemId}; +use designcraft_engine::{Session, cmd::base64_encode}; +use designcraft_geom::Affine; +use serde_json::{Value, json}; + +fn place(s: &mut Session, name: &str, bytes: &[u8]) -> ItemId { + s.execute("selection.set", &json!({"ids": []})).unwrap(); + let placed = s.execute("file.place", &json!({"base64": base64_encode(bytes), "name": name, "x": 0, "y": 0, "width": 144})).unwrap(); + ItemId(placed["id"].as_u64().unwrap()) +} + +fn raster() -> (Session, ItemId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 1000, "height": 1000, "facingPages": false})).unwrap(); + // 600 × 300 pixels at 144 × 72 points: 300 ppi in both directions. + let png = designcraft_render::Rendered { width: 600, height: 300, pixels: vec![200; 600 * 300 * 4] }.to_png(); + let id = place(&mut s, "photo.png", &png); + assert_eq!(s.doc().unwrap().doc.item(id).unwrap().inner_bounds().size(), designcraft_geom::Size::new(144.0, 72.0)); + (s, id) +} + +fn facing_raster() -> (Session, ItemId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 612, "height": 792, "pages": 3, "facingPages": true})).unwrap(); + // Page 0 is the cover; spread 1 contains absolute pages 1 (left) and 2 (right). + let d = &s.doc().unwrap().doc; + assert_eq!(d.spreads.len(), 2); + assert_eq!(d.spreads[0].pages.len(), 1); + assert_eq!(d.spreads[1].pages.len(), 2); + assert_eq!(d.first_page_of_spread(1), 1); + assert_eq!(d.spreads[1].pages[0].x, 0.0); + assert_eq!(d.spreads[1].pages[1].x, 612.0); + let png = designcraft_render::Rendered { width: 600, height: 300, pixels: vec![200; 600 * 300 * 4] }.to_png(); + let placed = s + .execute( + "file.place", + &json!({ + "base64": base64_encode(&png), "name": "photo.png", "spread": 1, + "x": 0, "y": 0, "width": 144 + }), + ) + .unwrap(); + let id = ItemId(placed["id"].as_u64().unwrap()); + let d = &s.doc().unwrap().doc; + assert_eq!(d.find(id).unwrap().spread, designcraft_doc::SpreadRef::Doc(1)); + assert_eq!(d.item(id).unwrap().inner_bounds().size(), designcraft_geom::Size::new(144.0, 72.0)); + (s, id) +} + +fn transform(s: &mut Session, id: ItemId, frame: Affine, image: Affine) { + s.edit(|d, _| { + let it = d.item_mut(id).unwrap(); + it.xf = frame; + let Content::Graphic(g) = &mut it.content else { panic!("graphic fixture") }; + g.xf = image * g.xf; + Ok(()) + }) + .unwrap(); +} + +fn group(s: &mut Session, child: ItemId, xf: Affine) -> ItemId { + let spread = s.doc().unwrap().doc.find(child).unwrap().spread; + let marker = s.execute("frame.create", &json!({"spread": spread, "rect": [0, 0, 1, 1]})).unwrap()["id"].clone(); + let group = s.execute("object.group", &json!({"ids": [child.0, marker]})).unwrap(); + let id = ItemId(group["id"].as_u64().unwrap()); + s.edit(|d, _| { + d.item_mut(id).unwrap().xf = xf; + Ok(()) + }) + .unwrap(); + id +} + +fn resolutions(s: &mut Session, params: Value) -> Vec<Value> { + let before = s.doc().unwrap(); + let doc = before.doc.clone(); + let selection = before.selection.clone(); + let state = (before.revision, before.history.undo.len(), before.history.redo.len(), s.journal.len()); + let result = s.execute("preflight.run", ¶ms).unwrap(); + let after = s.doc().unwrap(); + assert!(Arc::ptr_eq(&doc, &after.doc), "preflight must leave the document and assets untouched"); + assert_eq!(selection, after.selection, "preflight must leave the selection untouched"); + assert_eq!(state, (after.revision, after.history.undo.len(), after.history.redo.len(), s.journal.len())); + result["issues"].as_array().unwrap().iter().filter(|i| i["kind"] == "lowResolution").cloned().collect() +} + +fn warning(s: &mut Session, id: ItemId, params: Value, ppi: u32, threshold: u32) { + warning_on_page(s, id, params, ppi, threshold, 0); +} + +fn warning_on_page(s: &mut Session, id: ItemId, params: Value, ppi: u32, threshold: u32, page: usize) { + assert_eq!( + resolutions(s, params), + vec![json!({ + "severity": "warning", "kind": "lowResolution", "item": id.0, "page": page, + "message": format!("photo.png: effective {ppi} ppi (< {threshold})") + })] + ); +} + +#[test] +fn either_raster_axis_can_fail_after_image_or_frame_scaling() { + for scale in [Affine::scale_non_uniform(4.0, 1.0), Affine::scale_non_uniform(1.0, 4.0)] { + for image_only in [false, true] { + let (mut s, id) = raster(); + let (frame, image) = if image_only { (Affine::IDENTITY, scale) } else { (scale, Affine::IDENTITY) }; + transform(&mut s, id, frame, image); + // One axis remains 300 ppi, the other is 75 ppi: report the worse axis once. + warning(&mut s, id, json!({}), 75, 150); + } + } +} + +#[test] +fn nested_groups_accumulate_scale_and_keep_image_frame_parent_order() { + let (mut s, id) = raster(); + let inner = group(&mut s, id, Affine::scale(2.0)); + group(&mut s, inner, Affine::scale(2.0)); + warning(&mut s, id, json!({}), 75, 150); + + let (mut s, id) = raster(); + // Double image X, then turn the frame 90 degrees, then triple spread X. + // The image axes become 288 and 216 pt, giving 150 and 100 ppi respectively. + transform(&mut s, id, Affine::new([0.0, 1.0, -1.0, 0.0, 400.0, 20.0]), Affine::scale_non_uniform(2.0, 1.0)); + group(&mut s, id, Affine::scale_non_uniform(3.0, 1.0)); + warning(&mut s, id, json!({}), 100, 150); +} + +#[test] +fn translated_scaled_nested_raster_reports_right_facing_page() { + let (mut s, id) = facing_raster(); + let inner = group(&mut s, id, Affine::scale(2.0)); + group(&mut s, inner, Affine::translate((640.0, 0.0)) * Affine::scale(2.0)); + // Local frame center x=72 is on the left page. Ancestors move its bounds to + // x=640..1216 on the right page, while scaling 300 ppi down to 75 ppi. + warning_on_page(&mut s, id, json!({}), 75, 150, 2); +} + +#[test] +fn translating_group_changes_warning_page_without_changing_resolution() { + let (mut s, id) = facing_raster(); + // A nonidentity frame transform also detects applying the frame transform twice. + transform(&mut s, id, Affine::translate((-240.0, 0.0)), Affine::IDENTITY); + warning_on_page(&mut s, id, json!({"minPpi": 301}), 300, 301, 1); + group(&mut s, id, Affine::translate((880.0, 0.0))); + // Translation moves the center to x=712, but does not change either image axis. + assert!(resolutions(&mut s, json!({})).is_empty()); + warning_on_page(&mut s, id, json!({"minPpi": 301}), 300, 301, 2); +} + +#[test] +fn rotation_translation_and_reflection_do_not_change_resolution() { + for xf in [ + Affine::translate((250.0, 300.0)) * Affine::rotate(std::f64::consts::FRAC_PI_4), + Affine::translate((250.0, 300.0)) * Affine::scale_non_uniform(-1.0, 1.0), + ] { + let (mut s, id) = raster(); + transform(&mut s, id, xf, Affine::IDENTITY); + // Measuring the rotated bounding box instead would incorrectly warn here. + assert!(resolutions(&mut s, json!({"minPpi": 250})).is_empty()); + warning(&mut s, id, json!({"minPpi": 301}), 300, 301); + } +} + +#[test] +fn cropping_keeps_resolution_and_fitting_changes_it_with_content_scale() { + let (mut s, id) = raster(); + s.execute("transform.resize", &json!({"ids": [id.0], "from": [0, 0, 144, 72], "to": [0, 0, 36, 18]})).unwrap(); + warning(&mut s, id, json!({"minPpi": 301}), 300, 301); + s.execute("transform.resize", &json!({"ids": [id.0], "from": [0, 0, 36, 18], "to": [0, 0, 144, 216]})).unwrap(); + warning(&mut s, id, json!({"minPpi": 301}), 300, 301); + + s.execute("object.fit", &json!({"ids": [id.0], "mode": "fitProportionally"})).unwrap(); + warning(&mut s, id, json!({"minPpi": 301}), 300, 301); + // Stretching just Y to 216 pt leaves X at 300 ppi but reduces Y to 100 ppi. + s.execute("object.fit", &json!({"ids": [id.0], "mode": "fitContentToFrame"})).unwrap(); + warning(&mut s, id, json!({}), 100, 150); + // Proportional fill is 432 × 216 pt: 100 ppi in both directions. + s.execute("object.fit", &json!({"ids": [id.0], "mode": "fillProportionally"})).unwrap(); + warning(&mut s, id, json!({}), 100, 150); + s.execute("object.fit", &json!({"ids": [id.0], "mode": "centerContent"})).unwrap(); + warning(&mut s, id, json!({}), 100, 150); +} + +#[test] +fn threshold_is_strict_and_resolution_remains_print_only() { + let (mut s, id) = raster(); + transform(&mut s, id, Affine::scale_non_uniform(1.0, 2.0), Affine::IDENTITY); + assert!(resolutions(&mut s, json!({})).is_empty(), "150 ppi meets the default threshold"); + assert!(resolutions(&mut s, json!({"minPpi": 149})).is_empty()); + warning(&mut s, id, json!({"minPpi": 151}), 150, 151); + for intent in [Intent::Web, Intent::Mobile] { + s.edit(|d, _| { + d.settings.intent = intent; + Ok(()) + }) + .unwrap(); + assert!(resolutions(&mut s, json!({"minPpi": 151})).is_empty()); + } +} + +#[test] +fn transformed_vector_previews_are_excluded_beside_rasters() { + let (mut s, id) = raster(); + let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" width="144" height="72"><rect width="144" height="72" fill="red"/></svg>"#; + let vector = place(&mut s, "vector.svg", svg); + let parent = s.execute("object.group", &json!({"ids": [id.0, vector.0]})).unwrap()["id"].as_u64().unwrap(); + s.edit(|d, _| { + d.item_mut(ItemId(parent)).unwrap().xf = Affine::scale_non_uniform(1.0, 4.0); + Ok(()) + }) + .unwrap(); + warning(&mut s, id, json!({}), 75, 150); +} From b70b586a16bc0759ca274db67e91ffca9e54179d Mon Sep 17 00:00:00 2001 From: Brandon Thomas <echelon@gmail.com> Date: Tue, 13 Oct 2026 02:22:34 -0400 Subject: [PATCH 123/238] fix(path-type): NaN-safe bracket clamping (f64::clamp panics on a NaN path length) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/compose/src/lib.rs | 2 +- crates/engine/src/cmd/path_type.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 4efd32bf5..14a97be2b 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -417,7 +417,7 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec<FrameSpec> { path: None, ..tf.options.clone() }; - let end = pt.end.map_or(len, |e| e.clamp(0.0, len)); + let end = pt.end.map_or(len, |e| e.max(0.0).min(len)); (Rect::new(0.0, 0.0, (end - pt.start).max(1.0), size * 1.6), opts) } None => (item.text_area(), tf.options.clone()), diff --git a/crates/engine/src/cmd/path_type.rs b/crates/engine/src/cmd/path_type.rs index 9ce091a7e..02e5a1901 100644 --- a/crates/engine/src/cmd/path_type.rs +++ b/crates/engine/src/cmd/path_type.rs @@ -97,7 +97,7 @@ fn path_options(s: &mut Session, p: &Value) -> Result<Value> { return Ok(json!({"deleted": true})); } if let Some(v) = p.get("start").and_then(Value::as_f64).filter(|v| v.is_finite()) { - pt.start = v.clamp(0.0, len); + pt.start = v.max(0.0).min(len); } match p.get("end") { Some(Value::Null) => pt.end = None, From e862e0a613c0793c6fea7cfd37ad4145ed6cf686 Mon Sep 17 00:00:00 2001 From: Brandon Thomas <echelon@gmail.com> Date: Tue, 13 Oct 2026 02:28:50 -0400 Subject: [PATCH 124/238] fix: integrate batched community PRs (#572 with #662's fallible spread_param; #524's test helper back in its module) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/cmd/style.rs | 2 +- crates/ui-egui/src/panels/properties.rs | 86 ++++++++++++------------- 2 files changed, 44 insertions(+), 44 deletions(-) diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 4fced4c67..c4d20f099 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -1075,7 +1075,7 @@ fn has_text_or_selection(s: &Session) -> std::result::Result<(), String> { fn sample_color(s: &mut Session, p: &Value) -> Result<Value> { const ID: &str = "color.sample"; let at = super::point_param(p, "at").ok_or_else(|| bad(ID, "missing at"))?; - let sr = super::spread_param(p, "spread"); + let sr = super::spread_param(p, "spread")?; let st = s.doc()?; let (from, color) = image_color_at(&st.doc, sr, at).ok_or_else(|| bad(ID, "no placed image under the point"))?; let hex = color.to_hex(); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 76ddf6b42..d9c74e40d 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -2345,6 +2345,49 @@ mod tests { // The angle and scale fields are there. assert!(h.query_by(|n| n.role() == egui::accesskit::Role::TextInput && n.value().as_deref() == Some("90°")).is_some()); } + + fn panel(app: DesignApp) -> Harness<'static, DesignApp> { + let mut h = Harness::builder().with_size(vec2(320.0, 1400.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let id = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::<bool>(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + show(app, ui); + }, + app, + ); + h.run_steps(3); + h + } + + #[test] + fn several_shapes_show_pathfinder_and_intersect_combines_them() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let a = app.run("frame.create", json!({"rect": [100, 100, 300, 300], "shape": "ellipse"})).unwrap()["id"].clone(); + let b = app.run("frame.create", json!({"rect": [200, 100, 300, 200]})).unwrap()["id"].clone(); + // One shape: no Pathfinder section. + app.run("selection.set", json!({"ids": [a]})).unwrap(); + let mut h = panel(app); + assert!(h.query_by_label("Pathfinder").is_none()); + // Two: the section and its five buttons; Intersect leaves one shape, the ellipse's quarter. + h.state_mut().run("selection.set", json!({"ids": [a, b]})).unwrap(); + h.run_steps(3); + assert!(h.query_by_label("Pathfinder").is_some()); + for op in ["Add", "Subtract", "Intersect", "Exclude Overlap", "Minus Back"] { + assert!(h.query_by_label(op).is_some(), "{op}"); + } + h.get_by_label("Intersect").click(); + h.run_steps(3); + let doc = &h.state().session.active().unwrap().doc; + let items = &doc.spreads[0].items; + assert_eq!(items.len(), 1, "one shape left"); + let r = items[0].bounds(); + assert!((r.x0 - 200.0).abs() < 0.5 && (r.y1 - 200.0).abs() < 0.5 && (r.x1 - 300.0).abs() < 0.5, "{r:?}"); + } } #[cfg(test)] @@ -2436,47 +2479,4 @@ mod effects_panel_tests { assert!(!it.isolate && !it.knockout, "group-only options stay disabled for a frame holding content"); assert_eq!(st.history.undo.len(), undo, "disabled controls do not create undo steps"); } - - fn panel(app: DesignApp) -> Harness<'static, DesignApp> { - let mut h = Harness::builder().with_size(vec2(320.0, 1400.0)).build_ui_state( - |ui, app: &mut DesignApp| { - let id = egui::Id::new("test_fonts"); - if ui.data(|d| d.get_temp::<bool>(id)).is_none() { - crate::theme::install_fonts(ui.ctx(), ""); - ui.data_mut(|d| d.insert_temp(id, true)); - return; - } - show(app, ui); - }, - app, - ); - h.run_steps(3); - h - } - - #[test] - fn several_shapes_show_pathfinder_and_intersect_combines_them() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let a = app.run("frame.create", json!({"rect": [100, 100, 300, 300], "shape": "ellipse"})).unwrap()["id"].clone(); - let b = app.run("frame.create", json!({"rect": [200, 100, 300, 200]})).unwrap()["id"].clone(); - // One shape: no Pathfinder section. - app.run("selection.set", json!({"ids": [a]})).unwrap(); - let mut h = panel(app); - assert!(h.query_by_label("Pathfinder").is_none()); - // Two: the section and its five buttons; Intersect leaves one shape, the ellipse's quarter. - h.state_mut().run("selection.set", json!({"ids": [a, b]})).unwrap(); - h.run_steps(3); - assert!(h.query_by_label("Pathfinder").is_some()); - for op in ["Add", "Subtract", "Intersect", "Exclude Overlap", "Minus Back"] { - assert!(h.query_by_label(op).is_some(), "{op}"); - } - h.get_by_label("Intersect").click(); - h.run_steps(3); - let doc = &h.state().session.active().unwrap().doc; - let items = &doc.spreads[0].items; - assert_eq!(items.len(), 1, "one shape left"); - let r = items[0].bounds(); - assert!((r.x0 - 200.0).abs() < 0.5 && (r.y1 - 200.0).abs() < 0.5 && (r.x1 - 300.0).abs() < 0.5, "{r:?}"); - } } From 6f4fa3e56da61b4fafacdf6e01887c87711217d6 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Mon, 12 Oct 2026 11:14:47 +0200 Subject: [PATCH 125/238] fix(render): A group's gradient feather fades the group, on screen and in PDF A gradient feather on a group (Gradient Feather tool over grouped text, as in LYH Studio's poster tutorial) did nothing on screen: groups never went through the effects path. In PDF the group's mask was the group's own path bounds, and a group has no path, so the mask covered a box of zero size and hid the group. - Item::feather_bounds: the box the feather spans, the objects' bounds for a group. - Render: a feathered group draws its objects in one layer, then fades it with the same mask as other objects (fx::gradient_feather_mask). - PDF: the group's mask spans its objects. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 1 + crates/doc/src/item.rs | 11 ++++++++++ crates/engine/src/cmd/paths.rs | 33 ++++++++++++++++++++++++++++++ crates/pdf/src/export.rs | 12 +++++++++-- crates/render/src/fx.rs | 37 ++++++++++++++++++++-------------- crates/render/src/lib.rs | 13 ++++++++++++ 6 files changed, 90 insertions(+), 17 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..0e1e59ea0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -153,6 +153,7 @@ Newest first. - **2026-10-12** · The Effects panel shows the level its settings apply to ("Group: Color Dodge 100%") and, as in InDesign, Isolate Blending and Knockout Group for a selected group. No readiness percentage change. - **2026-10-11** · Properties gains InDesign's Pathfinder section (Add, Subtract, Intersect, Exclude Overlap, Minus Back) with several objects selected; section titles and icon buttons have accessible names. No readiness percentage change. - **2026-10-12** · The Eyedropper samples a placed image: clicking the photo with text (or objects) selected fills them with the image's colour under the pointer, as an unnamed colour (`color.sample`), as in InDesign. Images on locked layers can be sampled. No readiness percentage change. +- **2026-10-12** · A gradient feather on a group fades the group as one, on screen and in PDF. Before, the screen ignored it, and PDF masked the group with a box of zero size because a group has no path of its own. No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 0a701998e..80b3f2f6d 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -1105,6 +1105,17 @@ impl Item { self.path.bounds().unwrap_or(Rect::ZERO) } + /// The box a gradient feather spans, in item space: the path's bounds, or a group's objects' + /// (a group has no path of its own). + pub fn feather_bounds(&self) -> Rect { + if self.is_group() + && let Content::Group { items } = &self.content + { + return items.iter().map(|c| c.bounds()).reduce(|a, b| a.union(b)).unwrap_or(Rect::ZERO); + } + self.inner_bounds() + } + /// Spread-space geometric bounds (axis-aligned box of the transformed path; groups: union). /// A group (its bounds are its children's). A frame holding pasted-into items is not. pub fn is_group(&self) -> bool { diff --git a/crates/engine/src/cmd/paths.rs b/crates/engine/src/cmd/paths.rs index b9d479067..976e892b9 100644 --- a/crates/engine/src/cmd/paths.rs +++ b/crates/engine/src/cmd/paths.rs @@ -435,6 +435,39 @@ mod tests { assert!(t.contains("/SMask") || t.contains("/Luminosity"), "a soft mask"); } + #[test] + fn a_groups_gradient_feather_fades_it_on_screen_and_in_pdf() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let mut ids = vec![]; + for rect in [[100, 100, 200, 200], [200, 100, 300, 200]] { + let id = s.execute("frame.create", &json!({"rect": rect})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.fill", &json!({"ids": [id], "swatch": "[Black]"})).unwrap(); + ids.push(id); + } + let g = s.execute("object.group", &json!({"ids": ids})).unwrap()["id"].as_u64().unwrap(); + // The Gradient Feather tool's drag, then by angle (a group has no path of its own: the + // gradient spans its objects). + for p in [json!({"ids": [g], "from": [100, 150], "to": [300, 150]}), json!({"ids": [g], "angle": 0})] { + s.execute("object.gradientFeather", &p).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let mut rr = designcraft_render::Renderer::new(); + rr.threads = 0; + let img = rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap(); + let (l, m, r) = (img.pixel(105, 150)[0], img.pixel(200, 150)[0], img.pixel(295, 150)[0]); + assert!(l < 60 && m > l + 40 && r > 220, "{p}: fades left to right on screen: {l} {m} {r}"); + let pdf = designcraft_pdf::export_pdf(&d, &s.cache, &Default::default()).unwrap(); + let pm = designcraft_render::render_pdf_page(&pdf, 0, 400).unwrap(); + // Over white paper. + let at = |x: usize, y: usize| { + let c = pm.data()[y * pm.width() as usize + x]; + c.r as u32 + 255 - c.a as u32 + }; + let (l, m, r) = (at(105, 150), at(200, 150), at(295, 150)); + assert!(l < 60 && m > l + 40 && r > 220, "{p}: fades left to right in PDF: {l} {m} {r}"); + } + } + #[test] fn create_outlines_turns_text_into_paths() { let mut s = Session::new(); diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index dcf8abb83..3627fd954 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -757,7 +757,7 @@ impl Exporter<'_> { } // Gradient feather: a luminosity mask (white = opaque) over the object. let xf = parent * it.xf; - let (p0, p1) = gf.points(it.inner_bounds()); + let (p0, p1) = gf.points(it.feather_bounds()); let gray = |a: f32| -> krilla::color::Color { luma::Color::new((a.clamp(0.0, 1.0) * 255.0).round() as u8).into() }; let stops = vec![ Stop { offset: NormalizedF32::ZERO, color: gray(gf.start), opacity: NormalizedF32::ONE }, @@ -791,7 +791,15 @@ impl Exporter<'_> { } .into() }; - let area = it.inner_bounds().inflate(it.stroke.extent() + 4.0, it.stroke.extent() + 4.0); + // A group's mask covers its objects (it has no path of its own), with room for text and + // strokes past their frames. + let area = if it.is_group() { + let b = it.feather_bounds(); + let pad = 36.0 + 0.25 * b.width().max(b.height()); + b.inflate(pad, pad) + } else { + it.inner_bounds().inflate(it.stroke.extent() + 4.0, it.stroke.extent() + 4.0) + }; let mut sb = s.stream_builder(); let mut ms = sb.surface(); ms.push_transform(&tf(xf)); diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 6b29516f4..0e1ffd565 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -64,6 +64,27 @@ fn shadow(sigma: f64, color: peniko::Color) -> Filter { }) } +/// Keep what was drawn in the current layer as much as `it`'s gradient feather says, over `area` +/// (item space; past the gradient's ends its end opacities carry on). +pub(crate) fn gradient_feather_mask(ctx: &mut RenderContext, f: &Frame, it: &Item, xf: Affine, area: Rect) { + let gf = &it.effects.gradient_feather; + let (p0, p1) = gf.points(it.feather_bounds()); + let stop = |o: f32, a: f32| peniko::ColorStop::from((o, peniko::Color::from_rgba8(0, 0, 0, (a.clamp(0.0, 1.0) * 255.0).round() as u8))); + let stops = [stop(0.0, gf.start), stop(1.0, gf.end)]; + let grad = if gf.radial { + peniko::Gradient::new_radial(p0, (p1 - p0).hypot().max(1e-3) as f32).with_stops(stops.as_slice()) + } else { + peniko::Gradient::new_linear(p0, p1).with_stops(stops.as_slice()) + }; + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestIn)), None, None, None); + ctx.set_transform(f.view * xf); + ctx.set_paint(grad); + ctx.fill_rect(&area); + ctx.reset_paint_transform(); + ctx.pop_layer(); +} + impl Renderer { /// Draw an item with soft effects: below-effects, the (feathered) body, inner shadow. pub(crate) fn draw_item_fx(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine, page: Option<usize>) { @@ -170,22 +191,8 @@ impl Renderer { } } if gf.on { - let (p0, p1) = gf.points(it.inner_bounds()); - let stop = |o: f32, a: f32| peniko::ColorStop::from((o, peniko::Color::from_rgba8(0, 0, 0, (a.clamp(0.0, 1.0) * 255.0).round() as u8))); - let stops = [stop(0.0, gf.start), stop(1.0, gf.end)]; - let grad = if gf.radial { - peniko::Gradient::new_radial(p0, (p1 - p0).hypot().max(1e-3) as f32).with_stops(stops.as_slice()) - } else { - peniko::Gradient::new_linear(p0, p1).with_stops(stops.as_slice()) - }; - ctx.set_transform(Affine::IDENTITY); - ctx.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestIn)), None, None, None); - ctx.set_transform(f.view * xf); - ctx.set_paint(grad); let area = if xf.determinant().abs() > 1e-12 { xf.inverse().transform_rect_bbox(reach) } else { it.inner_bounds() }; - ctx.fill_rect(&area.inflate(4.0, 4.0)); - ctx.reset_paint_transform(); - ctx.pop_layer(); + gradient_feather_mask(ctx, f, it, xf, area.inflate(4.0, 4.0)); } if gf.on || directional { ctx.pop_layer(); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 2cc5c548f..ea942c2c8 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -476,6 +476,12 @@ impl Renderer { ctx.set_transform(Affine::IDENTITY); ctx.push_layer(None, Some(blend_mode(it.blend)), Some(it.opacity), None, None); } + // Gradient feather: the group's objects drawn together, then faded as one. + let feather = it.effects.gradient_feather.on && f.opts.quality != DisplayQuality::Fast; + if feather { + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, None, None, None, None); + } if it.knockout { // Knockout: each object first clears its area of those behind it, so it composites // with the group's backdrop rather than with its siblings. @@ -495,6 +501,13 @@ impl Renderer { self.draw_item(ctx, f, c, xf, page); } } + if feather { + // Generous: text and strokes reach past their frames. + let b = it.feather_bounds(); + let pad = 36.0 + 0.25 * b.width().max(b.height()); + fx::gradient_feather_mask(ctx, f, it, xf, b.inflate(pad, pad)); + ctx.pop_layer(); + } if layered { ctx.pop_layer(); } From 5b74bd71e445ac5818d72b3634cb224cac198408 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Mon, 12 Oct 2026 18:01:17 +0200 Subject: [PATCH 126/238] fix(render): A group's gradient feather masks everything the group draws The DestIn mask now covers the whole render target (and the PDF's exported area), with the gradient kept in item space through the paint transform, since past the gradient's ends the end opacity carries on: a child's long drop shadow or thick stroke is faded, not cut off at a box. feather_bounds counts only shown children and active states, walking nested groups without recursion. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/doc/src/item.rs | 49 ++++++++++++++++++++++++---- crates/engine/src/cmd/paths.rs | 58 +++++++++++++++++++++++++++++++--- crates/pdf/src/export.rs | 19 +++-------- crates/render/src/fx.rs | 14 ++++---- crates/render/src/lib.rs | 5 +-- 5 files changed, 109 insertions(+), 36 deletions(-) diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 80b3f2f6d..5c08734f5 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -1105,15 +1105,27 @@ impl Item { self.path.bounds().unwrap_or(Rect::ZERO) } - /// The box a gradient feather spans, in item space: the path's bounds, or a group's objects' - /// (a group has no path of its own). + /// The box a gradient feather spans, in item space: the path's bounds, or a group's visible + /// objects' (a group has no path of its own). Only the active states contribute, including in + /// nested groups. Walk iteratively so deeply nested documents don't exhaust the call stack. pub fn feather_bounds(&self) -> Rect { - if self.is_group() - && let Content::Group { items } = &self.content - { - return items.iter().map(|c| c.bounds()).reduce(|a, b| a.union(b)).unwrap_or(Rect::ZERO); + if !self.is_group() { + return self.inner_bounds(); } - self.inner_bounds() + let mut stack: Vec<_> = self.shown_children().map(|c| (c.as_ref(), c.xf)).collect(); + let mut bounds: Option<Rect> = None; + while let Some((it, xf)) = stack.pop() { + if it.hidden { + continue; + } + if it.is_group() { + stack.extend(it.shown_children().map(|c| (c.as_ref(), xf * c.xf))); + } else { + let b = xf.transform_rect_bbox(it.inner_bounds()); + bounds = Some(bounds.map_or(b, |have| have.union(b))); + } + } + bounds.unwrap_or(Rect::ZERO) } /// Spread-space geometric bounds (axis-aligned box of the transformed path; groups: union). @@ -1230,6 +1242,29 @@ mod tests { assert_eq!(n, 3); } + #[test] + fn feather_bounds_follow_visible_states_in_nested_groups() { + let a = Item::new(ItemId(1), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(10.0, 20.0, 30.0, 40.0))); + let mut b = a.clone(); + b.id = ItemId(2); + b.xf = Affine::translate((500.0, 500.0)); + let mut state = Item::new(ItemId(3), LayerId(1), Shape::Group, PathData::default()); + state.content = Content::Group { items: vec![Arc::new(a), Arc::new(b)] }; + state.states = vec!["Visible".into(), "Inactive".into()]; + assert_eq!(state.feather_bounds(), Rect::new(10.0, 20.0, 30.0, 40.0)); + state.active_state = 1; + assert_eq!(state.feather_bounds(), Rect::new(510.0, 520.0, 530.0, 540.0)); + state.active_state = 0; + state.xf = Affine::translate((5.0, 10.0)) * Affine::scale(2.0); + let mut hidden = state.clone(); + hidden.id = ItemId(4); + hidden.hidden = true; + hidden.xf = Affine::translate((-1000.0, -1000.0)); + let mut outer = Item::new(ItemId(5), LayerId(1), Shape::Group, PathData::default()); + outer.content = Content::Group { items: vec![Arc::new(state), Arc::new(hidden)] }; + assert_eq!(outer.feather_bounds(), Rect::new(25.0, 50.0, 65.0, 90.0)); + } + #[test] fn serde_roundtrip() { let mut it = Item::new(ItemId(1), LayerId(1), Shape::Oval, shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 10.0))); diff --git a/crates/engine/src/cmd/paths.rs b/crates/engine/src/cmd/paths.rs index 976e892b9..d587526df 100644 --- a/crates/engine/src/cmd/paths.rs +++ b/crates/engine/src/cmd/paths.rs @@ -446,15 +446,17 @@ mod tests { ids.push(id); } let g = s.execute("object.group", &json!({"ids": ids})).unwrap()["id"].as_u64().unwrap(); + s.execute("transform.move", &json!({"ids": [g], "dx": 20, "dy": 30})).unwrap(); // The Gradient Feather tool's drag, then by angle (a group has no path of its own: the - // gradient spans its objects). - for p in [json!({"ids": [g], "from": [100, 150], "to": [300, 150]}), json!({"ids": [g], "angle": 0})] { + // gradient spans its objects). The gradient still follows the translated group even + // though the mask geometry covers the whole output. + for p in [json!({"ids": [g], "from": [120, 180], "to": [320, 180]}), json!({"ids": [g], "angle": 0})] { s.execute("object.gradientFeather", &p).unwrap(); let d = s.doc().unwrap().doc.clone(); let mut rr = designcraft_render::Renderer::new(); rr.threads = 0; let img = rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap(); - let (l, m, r) = (img.pixel(105, 150)[0], img.pixel(200, 150)[0], img.pixel(295, 150)[0]); + let (l, m, r) = (img.pixel(125, 180)[0], img.pixel(220, 180)[0], img.pixel(315, 180)[0]); assert!(l < 60 && m > l + 40 && r > 220, "{p}: fades left to right on screen: {l} {m} {r}"); let pdf = designcraft_pdf::export_pdf(&d, &s.cache, &Default::default()).unwrap(); let pm = designcraft_render::render_pdf_page(&pdf, 0, 400).unwrap(); @@ -463,11 +465,59 @@ mod tests { let c = pm.data()[y * pm.width() as usize + x]; c.r as u32 + 255 - c.a as u32 }; - let (l, m, r) = (at(105, 150), at(200, 150), at(295, 150)); + let (l, m, r) = (at(125, 180), at(220, 180), at(315, 180)); assert!(l < 60 && m > l + 40 && r > 220, "{p}: fades left to right in PDF: {l} {m} {r}"); } } + #[test] + fn group_gradient_feather_keeps_distant_shadows_and_thick_strokes() { + for shadow in [false, true] { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let mut ids = vec![]; + for rect in [[120, 120, 170, 170], [175, 120, 225, 170]] { + let id = s.execute("frame.create", &json!({"rect": rect})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.fill", &json!({"ids": [id], "swatch": "[Black]"})).unwrap(); + if shadow { + s.execute("object.dropShadow", &json!({"ids": [id], "on": true, "distance": 180, "angle": 90, "size": 0, "opacity": 1})).unwrap(); + } else { + s.execute("object.stroke", &json!({"ids": [id], "swatch": "[Black]", "weight": 180})).unwrap(); + } + ids.push(id); + } + let g = s.execute("object.group", &json!({"ids": ids})).unwrap()["id"].as_u64().unwrap(); + // Translate the group so mask geometry and gradient paint exercise distinct spaces. + s.execute("transform.move", &json!({"ids": [g], "dx": 30, "dy": 20})).unwrap(); + let point = if shadow { (175, 345) } else { (175, 60) }; + let sample = |s: &Session| { + let d = &s.doc().unwrap().doc; + let mut rr = designcraft_render::Renderer::new(); + rr.threads = 0; + // Zoomed viewport: the mask must extend over every device pixel. + let img = rr.render_page(d, &s.cache, 0, 2.0, false, &Default::default()).unwrap(); + let screen = img.pixel(point.0 * 2, point.1 * 2)[0]; + let pdf = designcraft_pdf::export_pdf(d, &s.cache, &Default::default()).unwrap(); + let pm = designcraft_render::render_pdf_page(&pdf, 0, 400).unwrap(); + let c = pm.data()[point.1 as usize * pm.width() as usize + point.0 as usize]; + [f32::from(screen), c.r as f32 + 255.0 - c.a as f32] + }; + let before = sample(&s); + // The sample lies past either end of the gradient, and outside the old fixed padding. + s.execute("object.gradientFeather", &json!({"ids": [g], "from": [130, 120], "to": [130, 220], "start": 40, "end": 80})).unwrap(); + let after = sample(&s); + let opacity = if shadow { 0.8 } else { 0.4 }; + for (kind, (before, after)) in ["screen", "PDF"].into_iter().zip(before.into_iter().zip(after)) { + assert!(before < 120.0, "{kind}, shadow={shadow}: sample contains the effect before feathering: {before}"); + let expected = 255.0 - (255.0 - before) * opacity; + assert!( + (after - expected).abs() < 16.0, + "{kind}, shadow={shadow}: end opacity continues past geometry: {after}, expected {expected}" + ); + } + } + } + #[test] fn create_outlines_turns_text_into_paths() { let mut s = Session::new(); diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 3627fd954..983be9433 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -772,7 +772,7 @@ impl Exporter<'_> { cx: p0.x as f32, cy: p0.y as f32, cr: r, - transform: Transform::identity(), + transform: tf(xf), spread_method: SpreadMethod::Pad, stops, anti_alias: false, @@ -784,30 +784,21 @@ impl Exporter<'_> { y1: p0.y as f32, x2: p1.x as f32, y2: p1.y as f32, - transform: Transform::identity(), + transform: tf(xf), spread_method: SpreadMethod::Pad, stops, anti_alias: false, } .into() }; - // A group's mask covers its objects (it has no path of its own), with room for text and - // strokes past their frames. - let area = if it.is_group() { - let b = it.feather_bounds(); - let pad = 36.0 + 0.25 * b.width().max(b.height()); - b.inflate(pad, pad) - } else { - it.inner_bounds().inflate(it.stroke.extent() + 4.0, it.stroke.extent() + 4.0) - }; + // The gradient keeps its item-space geometry, but its mask covers the complete exported + // area (including bleed), so distant child shadows and thick strokes are never cut off. let mut sb = s.stream_builder(); let mut ms = sb.surface(); - ms.push_transform(&tf(xf)); - if let Some(p) = to_path(&area.to_path(0.1)) { + if let Some(p) = to_path(&self.clip.to_path(0.1)) { ms.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: FillRule::NonZero })); ms.draw_path(&p); } - ms.pop(); ms.finish(); let stream = sb.finish(); s.push_mask(krilla::mask::Mask::new(stream, krilla::mask::MaskType::Luminosity)); diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 0e1ffd565..dab2e3ad0 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -64,9 +64,10 @@ fn shadow(sigma: f64, color: peniko::Color) -> Filter { }) } -/// Keep what was drawn in the current layer as much as `it`'s gradient feather says, over `area` -/// (item space; past the gradient's ends its end opacities carry on). -pub(crate) fn gradient_feather_mask(ctx: &mut RenderContext, f: &Frame, it: &Item, xf: Affine, area: Rect) { +/// Keep what was drawn in the current layer as much as `it`'s gradient feather says. Cover the +/// whole viewport: strokes, text and child effects can extend arbitrarily past the geometry, +/// and past the gradient's ends its end opacities carry on. +pub(crate) fn gradient_feather_mask(ctx: &mut RenderContext, f: &Frame, it: &Item, xf: Affine) { let gf = &it.effects.gradient_feather; let (p0, p1) = gf.points(it.feather_bounds()); let stop = |o: f32, a: f32| peniko::ColorStop::from((o, peniko::Color::from_rgba8(0, 0, 0, (a.clamp(0.0, 1.0) * 255.0).round() as u8))); @@ -78,9 +79,9 @@ pub(crate) fn gradient_feather_mask(ctx: &mut RenderContext, f: &Frame, it: &Ite }; ctx.set_transform(Affine::IDENTITY); ctx.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestIn)), None, None, None); - ctx.set_transform(f.view * xf); ctx.set_paint(grad); - ctx.fill_rect(&area); + ctx.set_paint_transform(f.view * xf); + ctx.fill_rect(&Rect::new(0.0, 0.0, f64::from(ctx.width()), f64::from(ctx.height()))); ctx.reset_paint_transform(); ctx.pop_layer(); } @@ -191,8 +192,7 @@ impl Renderer { } } if gf.on { - let area = if xf.determinant().abs() > 1e-12 { xf.inverse().transform_rect_bbox(reach) } else { it.inner_bounds() }; - gradient_feather_mask(ctx, f, it, xf, area.inflate(4.0, 4.0)); + gradient_feather_mask(ctx, f, it, xf); } if gf.on || directional { ctx.pop_layer(); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index ea942c2c8..cc937a1a2 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -502,10 +502,7 @@ impl Renderer { } } if feather { - // Generous: text and strokes reach past their frames. - let b = it.feather_bounds(); - let pad = 36.0 + 0.25 * b.width().max(b.height()); - fx::gradient_feather_mask(ctx, f, it, xf, b.inflate(pad, pad)); + fx::gradient_feather_mask(ctx, f, it, xf); ctx.pop_layer(); } if layered { From 6f47f10b4b24a83b5e29d32eb42c7179c2a6ac50 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Mon, 12 Oct 2026 10:33:52 +0200 Subject: [PATCH 127/238] fix(render): Blend modes don't blend with the paper The renderer painted the paper, then blended page items with it: black text in Color Dodge over blank paper dodged the white to white and vanished, and other modes showed the same way. In InDesign (and in PDF, where the page group starts transparent) the paper is not a backdrop: over blank paper an object in any blend mode shows as it is, and only the objects below it are blended with. On spreads that use a blend mode the items are now drawn in their own group, laid over the paper once; other spreads render as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 1 + crates/render/src/lib.rs | 21 +++++++++++ crates/render/tests/blend_over_paper.rs | 48 +++++++++++++++++++++++++ 3 files changed, 70 insertions(+) create mode 100644 crates/render/tests/blend_over_paper.rs diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..59b58a090 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -153,6 +153,7 @@ Newest first. - **2026-10-12** · The Effects panel shows the level its settings apply to ("Group: Color Dodge 100%") and, as in InDesign, Isolate Blending and Knockout Group for a selected group. No readiness percentage change. - **2026-10-11** · Properties gains InDesign's Pathfinder section (Add, Subtract, Intersect, Exclude Overlap, Minus Back) with several objects selected; section titles and icon buttons have accessible names. No readiness percentage change. - **2026-10-12** · The Eyedropper samples a placed image: clicking the photo with text (or objects) selected fills them with the image's colour under the pointer, as an unnamed colour (`color.sample`), as in InDesign. Images on locked layers can be sampled. No readiness percentage change. +- **2026-10-12** · On screen, blend modes blend with the objects below and not with the paper, as in InDesign and in PDF export: a Color Dodge or Screen object over blank paper shows as it is. No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. - **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. - **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 2cc5c548f..a381b1698 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -394,6 +394,13 @@ impl Renderer { } } } + // Blend modes blend with the objects below, not with the paper (as in InDesign and in PDF, + // where the page's group starts transparent): a Color Dodge or Screen object over blank paper + // shows as it is. So the items are drawn in their own group, laid over the paper once. + let isolate = f.opts.paper && spread_blends(doc, sp); + if isolate { + ctx.push_layer(None, None, None, None, None); + } // Layers back to front; within each layer parent items first (document spreads only). for layer in doc.layers.iter().rev() { if !layer.visible || f.opts.printing_only && !layer.printable { @@ -428,6 +435,9 @@ impl Renderer { } } } + if isolate { + ctx.pop_layer(); + } } fn draw_item(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, parent: Affine, page: Option<usize>) { @@ -920,6 +930,17 @@ pub(crate) fn overprints(f: &Frame, swatch: &str, tint: f32, flag: bool) -> bool f.opts.overprint_preview && (flag || (f.doc.settings.overprint_black && swatch == designcraft_color::swatch::BLACK && tint >= 0.999)) } +/// Does any item on the spread, or on the parent pages it shows, use a blend mode other than +/// Normal (groups and pasted-in items included)? +fn spread_blends(doc: &Document, sp: &designcraft_doc::Spread) -> bool { + let blends = |it: &Item| { + let mut any = false; + it.walk(&mut |i| any |= i.blend != Default::default()); + any + }; + sp.items.iter().any(|i| blends(i)) || doc.parents.iter().any(|p| p.items.iter().any(|i| blends(i))) +} + pub fn blend_mode(b: DcBlend) -> BlendMode { use DcBlend as B; let mix = match b { diff --git a/crates/render/tests/blend_over_paper.rs b/crates/render/tests/blend_over_paper.rs new file mode 100644 index 000000000..0fd257027 --- /dev/null +++ b/crates/render/tests/blend_over_paper.rs @@ -0,0 +1,48 @@ +//! Blend modes blend with the objects below, never with the paper: over blank paper an object in +//! Color Dodge (or any other mode) shows as it is, as in InDesign and in the exported PDF. + +use designcraft_color::{BlendMode, Color, Swatch}; +use designcraft_compose::Cache; +use designcraft_doc::{Document, Fill, Item, ItemId, Shape, SpreadRef, build::NewDocument}; +use designcraft_geom::{Affine, Rect, shapes}; +use designcraft_render::{Placed, RenderOptions, Renderer}; + +/// A 200 x 100 page: a mid-blue rectangle on the right half, and over both halves a black +/// rectangle in `blend`. +fn document(blend: BlendMode) -> Document { + let mut d = Document::new(&NewDocument { width: 200.0, height: 100.0, facing_pages: false, ..Default::default() }); + d.swatches.push(Swatch::color("Mid blue", Color::rgb(0.2, 0.4, 0.6))); + let mut rect = |r: Rect, swatch: &str, blend: BlendMode| { + let id = ItemId(d.alloc()); + let mut it = Item::new(id, d.default_layer(), Shape::Rectangle, shapes::rectangle(r)); + it.fill = Fill::swatch(swatch); + it.blend = blend; + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + }; + rect(Rect::new(100.0, 0.0, 200.0, 100.0), "Mid blue", BlendMode::Normal); + rect(Rect::new(20.0, 20.0, 180.0, 80.0), "[Black]", blend); + d +} + +fn pixel(d: &Document, x: u32, y: u32) -> [u8; 4] { + let mut r = Renderer::new(); + r.threads = 0; + let placed = [Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }]; + let img = r.render(d, &Cache::new(), &placed, 200, 100, Affine::IDENTITY, &RenderOptions::default()); + let i = ((y * 200 + x) * 4) as usize; + [img.pixels[i], img.pixels[i + 1], img.pixels[i + 2], img.pixels[i + 3]] +} + +#[test] +fn a_blend_mode_over_blank_paper_shows_the_object_as_it_is() { + let d = document(BlendMode::ColorDodge); + // Over the paper: black ([Black] shows as about 52 grey), as in Normal (it used to dodge the white paper to white, vanishing). + let over_paper = pixel(&d, 50, 50); + assert!(over_paper[0] < 80 && over_paper[1] < 80 && over_paper[2] < 80, "{over_paper:?}"); + // Over the blue rectangle it still blends: black in Color Dodge leaves the blue as it is. + let over_blue = pixel(&d, 150, 50); + assert!(over_blue[2] > 120 && over_blue[2] > over_blue[0], "{over_blue:?}"); + // Normal is unchanged. + let normal = pixel(&document(BlendMode::Normal), 150, 50); + assert!(normal[0] < 80 && normal[2] < 80, "{normal:?}"); +} From 7192c2e13684e985f8fb2787d3bffe8b4f859976 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Mon, 12 Oct 2026 23:48:11 +0200 Subject: [PATCH 128/238] feat(eyedropper): A sampled colour goes to the stroke when the Stroke box is in front As in InDesign, the Eyedropper applies a colour sampled from a placed image to the fill or the stroke, whichever is in front in the toolbox: with the Stroke box in front a selected line takes it on its stroke (it has no fill to show it). The toolbox's state reaches tools through ViewInfo/ToolContext::stroke_in_front. Depends on #572. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 1 + crates/engine/src/cmd/style.rs | 11 +++++++++++ crates/engine/src/tooling.rs | 5 ++++- crates/tools/src/lib.rs | 2 ++ crates/tools/src/snap.rs | 12 +++++++++++- crates/tools/src/tests.rs | 23 ++++++++++++++++++++++- crates/tools/src/xform.rs | 7 ++++++- crates/ui-egui/src/lib.rs | 7 ++++++- 8 files changed, 63 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..ad1576ab3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -138,6 +138,7 @@ Newest first. - **2026-10-12** · The Pen tool continues an open path from either end point, as in InDesign: the cursor shows it over an end point, a click there carries on drawing that path, a drag first reshapes the end point's direction lines, and the other end closes it. Object › Paths › Reverse Path (`path.reverse`); `path.setAnchor`; `path.appendAnchor` and `path.close` take a subpath. No readiness percentage change. - **2026-10-12** · Editing type on a path: the Type tool clicks, drags and double-clicks along the curve (the character under the pointer, not one on the straightened line), a click on the letters themselves hits the frame, and the caret and highlight bend with the path. No readiness percentage change. - **2026-10-12** · Type on a path has start and end brackets, as in InDesign: selected path type shows them across the path, and dragging either with the Selection or Direct Selection tool moves where the text starts or ends (text past the end bracket is overset). `type.pathOptions {end}`; IDML reads and writes EndBracket. No readiness percentage change. +- **2026-10-12** · The Eyedropper puts a colour sampled from an image on the stroke when the toolbox's Stroke box is in front, as in InDesign (a line takes it on its stroke). No readiness percentage change. - **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change. - **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change. diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index c4d20f099..9f0321560 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -1750,4 +1750,15 @@ mod sample_color_tests { assert_eq!(text.format_after(0).over.stroke.as_deref(), r["swatch"].as_str(), "selected text stroke: {command}"); } } + + #[test] + fn sampling_with_the_stroke_in_front_colours_a_lines_stroke() { + let (mut s, _) = session_with_image(); + let line = s.execute("line.create", &json!({"a": [300, 300], "b": [500, 300]})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": [line]})).unwrap(); + let r = s.execute("color.sample", &json!({"at": [100, 100], "target": "stroke"})).unwrap(); + let it = s.doc().unwrap().doc.item(ItemId(line)).unwrap().clone(); + assert_eq!(it.stroke.swatch, r["swatch"].as_str().unwrap(), "{r}"); + assert!(it.fill.is_none(), "the fill stays empty"); + } } diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 1bf8db930..28ed3dd33 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -35,11 +35,13 @@ pub struct ViewInfo { pub zoom: f64, pub snap: SnapView, pub unit: Unit, + /// The toolbox's Stroke box is in front (X): colour tools apply to strokes. + pub stroke_in_front: bool, } impl ViewInfo { pub const fn at_zoom(zoom: f64) -> Self { - Self { zoom, snap: SnapView::FACTORY, unit: Unit::Points } + Self { zoom, snap: SnapView::FACTORY, unit: Unit::Points, stroke_in_front: false } } } @@ -93,6 +95,7 @@ impl Session { layer, snap: view.snap, unit: view.unit, + stroke_in_front: view.stroke_in_front, }; Some(f(self.tool.as_mut(), &cx)) } diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index f3de390a4..d8a5c5446 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -205,6 +205,8 @@ pub struct ToolContext<'a> { pub layer: designcraft_doc::LayerId, pub snap: SnapView, pub unit: Unit, + /// The toolbox's Stroke box is in front: sampled colours go to strokes (the Eyedropper). + pub stroke_in_front: bool, } impl ToolContext<'_> { diff --git a/crates/tools/src/snap.rs b/crates/tools/src/snap.rs index 6d5c8f6a5..a7b4dad3b 100644 --- a/crates/tools/src/snap.rs +++ b/crates/tools/src/snap.rs @@ -1473,7 +1473,17 @@ mod tests { } fn ctx_on<'a>(doc: &'a Document, sel: &'a Selection, cache: &'a Cache, layout: &'a CanvasLayout) -> ToolContext<'a> { - ToolContext { doc, selection: sel, cache, layout, zoom: 1.0, layer: doc.default_layer(), snap: SnapView::FACTORY, unit: Unit::Points } + ToolContext { + doc, + selection: sel, + cache, + layout, + zoom: 1.0, + layer: doc.default_layer(), + snap: SnapView::FACTORY, + unit: Unit::Points, + stroke_in_front: false, + } } fn y_req(rect: Rect) -> SnapRequest<'static> { diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 6776902aa..f058e5e03 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -9,7 +9,17 @@ use designcraft_geom::{Point, Rect, Unit}; use super::*; fn ctx<'a>(d: &'a Document, s: &'a Selection, c: &'a Cache, l: &'a CanvasLayout) -> ToolContext<'a> { - ToolContext { doc: d, selection: s, cache: c, layout: l, zoom: 1.0, layer: d.default_layer(), snap: SnapView::OFF, unit: Unit::Points } + ToolContext { + doc: d, + selection: s, + cache: c, + layout: l, + zoom: 1.0, + layer: d.default_layer(), + snap: SnapView::OFF, + unit: Unit::Points, + stroke_in_front: false, + } } #[test] @@ -857,6 +867,17 @@ fn eyedropper_samples_a_placed_image_and_matches_other_objects() { assert_eq!(a, vec![sample(120.0, 130.0)]); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 180.0 + off.x, 180.0 + off.y)); assert_eq!(a, vec![sample(180.0, 180.0)], "through the text frame"); + // With the toolbox's Stroke box in front, the colour goes to the stroke (a line has no fill). + let mut cx_stroke = ctx(&d, &s, &c, &l); + cx_stroke.stroke_in_front = true; + let a = t.pointer(&cx_stroke, &PointerEvent::new(PointerKind::Down, 120.0 + off.x, 130.0 + off.y)); + assert_eq!( + a, + vec![Action::Exec( + "color.sample".into(), + serde_json::json!({"at": [120.0, 130.0], "spread": {"kind": "doc", "index": 0}, "target": "stroke"}) + )] + ); // A filled object is matched as before (here on a locked layer: not reachable, so nothing). assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 320.0 + off.x, 120.0 + off.y)).is_empty()); let mut d2 = d.clone(); diff --git a/crates/tools/src/xform.rs b/crates/tools/src/xform.rs index e0bcd24f3..194ceff05 100644 --- a/crates/tools/src/xform.rs +++ b/crates/tools/src/xform.rs @@ -299,7 +299,12 @@ impl Tool for EyedropperTool { let selected = !cx.selection.items.is_empty() || cx.selection.text.is_some() || cx.selection.cells.is_some(); // A placed image under the pointer is sampled (its layer may be locked: sampling changes nothing). if selected && let Some((sr, at)) = image_under(cx, ev.pos) { - return vec![Action::Exec("color.sample".into(), json!({"at": [at.x, at.y], "spread": sr}))]; + // To the fill or stroke in front in the toolbox, as in InDesign. + let mut p = json!({"at": [at.x, at.y], "spread": sr}); + if cx.stroke_in_front { + p["target"] = json!("stroke"); + } + return vec![Action::Exec("color.sample".into(), p)]; } let Some((_, id)) = cx.hit(ev.pos) else { return vec![] }; if !selected { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fc7a3e634..4dd58ebe4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -598,7 +598,12 @@ impl DesignApp { self.pane = pane; } pub fn view_info(&self) -> ViewInfo { - ViewInfo { zoom: self.view().map(|v| v.zoom).unwrap_or(1.0), snap: self.ui.snap_view(), unit: self.ui.units } + ViewInfo { + zoom: self.view().map(|v| v.zoom).unwrap_or(1.0), + snap: self.ui.snap_view(), + unit: self.ui.units, + stroke_in_front: self.ui.stroke_front, + } } pub fn status(&mut self, s: impl Into<String>) { From 39ec5013517d085caab5d1998eb154aa14bbb907 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Tue, 13 Oct 2026 10:12:43 +0200 Subject: [PATCH 129/238] fix(edit): copy a selected graphic on its own; delete leaves the frame With a placed image selected via Direct Selection (InDesign's content selection), Copy now copies the graphic itself: a frame of the whole image's bounds (uncropped) at its spread position, without the frame's fill, stroke or effects. Paste Into then puts the full picture in place inside the target shape, as InDesign does (LYH Studio episode 14, 5:44). Cut and Delete with the graphic selected remove only the graphic; the frame stays, empty and selected. Nested items copy in spread coordinates (parent transform applied). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 1 + crates/engine/src/cmd/edit.rs | 80 ++++++++++++++++++++++++++++++++--- 2 files changed, 75 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..e20a395cf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-13** · Copy with a placed graphic selected (Direct Selection) copies just the graphic, uncropped, where it sits on the page and without the frame's stroke, so Paste Into puts the whole image in place; Cut and Delete then remove only the graphic and leave its frame empty. Nested selections copy in their spread coordinates. No readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index e7190acb4..d6ef9d118 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -189,11 +189,20 @@ pub fn specs() -> Vec<CommandSpec> { return super::text::delete_selection(s); } let ids = super::targets(s, p)?; + // With a placed graphic itself selected (Direct Selection), only the graphic goes: its + // frame stays, empty and selected (as in InDesign). Other items are deleted. + let content = s.doc()?.selection.content && p.get("ids").is_none(); s.edit(|d, sel| { + let mut kept = Vec::new(); for id in &ids { - let _ = d.remove_item(*id); + if let Some(it) = d.item_mut(*id).filter(|it| content && matches!(it.content, designcraft_doc::Content::Graphic(_))) { + it.content = designcraft_doc::Content::Unassigned; + kept.push(*id); + } else { + let _ = d.remove_item(*id); + } } - *sel = Selection::default(); + *sel = Selection::items(kept); Ok(json!({"deleted": ids.len()})) }) }), @@ -266,12 +275,15 @@ pub(crate) fn clip_doc(s: &Session) -> Result<designcraft_doc::Document> { let st = s.doc()?; let mut d = (*st.doc).clone(); let keep: Vec<ItemId> = st.selection.items.clone(); - // Gather the selected items (keep their spread coordinates) into spread 0. + // Gather the selected items (in their spread coordinates, nested ones too) into spread 0. let mut items = Vec::new(); for id in &keep { - if let Some(it) = st.doc.item(*id) { - items.push(Arc::new(it.clone())); - } + let (Some(it), Some(loc)) = (st.doc.item(*id), st.doc.find(*id)) else { continue }; + let parent = st.doc.parent_xf(&loc); + items.push(Arc::new(match &it.content { + designcraft_doc::Content::Graphic(g) if st.selection.content => graphic_alone(it, g, parent), + _ => designcraft_doc::Item { xf: parent * it.xf, ..it.clone() }, + })); } for sp in d.spreads.iter_mut() { Arc::make_mut(sp).items.clear(); @@ -307,6 +319,22 @@ pub(crate) fn clip_doc(s: &Session) -> Result<designcraft_doc::Document> { Ok(d) } +/// A placed graphic on its own, as InDesign copies one selected with Direct Selection: a frame of +/// the whole graphic's bounds (uncropped), where the graphic sits on the spread (`parent` is the +/// frame's parent transform), without the frame's fill, stroke or effects. +fn graphic_alone(frame: &designcraft_doc::Item, g: &designcraft_doc::Graphic, parent: designcraft_geom::Affine) -> designcraft_doc::Item { + let r = designcraft_geom::Rect::new(0.0, 0.0, g.size.0, g.size.1); + let mut it = designcraft_doc::Item::new(frame.id, frame.layer, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(r)); + it.xf = parent * frame.xf * g.xf; + it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { + xf: designcraft_geom::Affine::IDENTITY, + auto_fit: designcraft_doc::Fitting::None, + crop: [0.0; 4], + ..g.clone() + }); + it +} + #[cfg(test)] mod text_clipboard_tests { use super::*; @@ -454,4 +482,44 @@ mod paste_into_tests { assert!(img.pixel(120, 120)[0] > 200, "{:?}", img.pixel(120, 120)); s.doc().unwrap().doc.check().unwrap(); } + + #[test] + fn a_selected_graphic_copies_and_clears_on_its_own() { + let dir = std::env::temp_dir().join(format!("dc-graphic-alone-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let png = dir.join("photo.png"); + std::fs::write(&png, designcraft_render::Rendered { width: 200, height: 100, pixels: vec![40u8; 200 * 100 * 4] }.to_png()).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + // A square frame showing the middle of a wide photo (cropped left and right). + let frame = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap(); + s.execute("file.place", &json!({"path": png.to_string_lossy(), "frame": frame})).unwrap(); + s.execute("object.fit", &json!({"mode": "fillProportionally", "ids": [frame]})).unwrap(); + s.execute("object.stroke", &json!({"ids": [frame], "swatch": "[Black]", "weight": 4})).unwrap(); + // Direct Selection on the photo, Copy: the whole photo, where it is, without the frame's stroke. + s.execute("selection.set", &json!({"ids": [frame], "content": true})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + let clip = s.clipboard.clone().unwrap(); + let it = &clip.spreads[0].items[0]; + assert!(matches!(&it.content, designcraft_doc::Content::Graphic(g) if g.crop == [0.0; 4])); + assert_eq!(it.bounds(), designcraft_geom::Rect::new(50.0, 100.0, 250.0, 200.0)); + assert!(it.stroke.weight == 0.0 || it.stroke.swatch == "[None]", "{:?}", it.stroke); + // Paste Into a shape over it: the photo lands in place, inside the shape. + let shape = s.execute("frame.create", &json!({"rect": [60, 90, 240, 210], "shape": "ellipse"})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": [shape]})).unwrap(); + s.execute("edit.pasteInto", &json!({})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let kid = d.item(ItemId(shape)).unwrap().children()[0].clone(); + let at = d.parent_xf(&d.find(kid.id).unwrap()) * kid.xf; + assert!((at * designcraft_geom::Point::new(0.0, 0.0) - designcraft_geom::Point::new(50.0, 100.0)).hypot() < 1e-6); + assert!((at * designcraft_geom::Point::new(200.0, 100.0) - designcraft_geom::Point::new(250.0, 200.0)).hypot() < 1e-6); + // Delete with the photo selected: the frame stays, empty. + s.execute("selection.set", &json!({"ids": [frame], "content": true})).unwrap(); + assert_eq!(s.execute("edit.clear", &json!({})).unwrap()["deleted"], 1); + let d = s.doc().unwrap().doc.clone(); + assert!(matches!(d.item(ItemId(frame)).unwrap().content, designcraft_doc::Content::Unassigned)); + assert_eq!(s.doc().unwrap().selection, Selection::items(vec![ItemId(frame)])); + d.check().unwrap(); + let _ = std::fs::remove_dir_all(&dir); + } } From 65bb4e3f661fc6541432fbd99d7d40ea33ba4e49 Mon Sep 17 00:00:00 2001 From: Noah Scholz <48360809+noah1400@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:17:34 +0000 Subject: [PATCH 130/238] fix(tables): keep each table anchor in its own paragraph Backspace at the start of the second of two consecutive tables merged their anchor paragraphs: Story::delete kept one table id, the other table was dropped and the paragraph held two anchors. Pasting a table next to an anchor replaced the existing table the same way, and pasting several paragraphs right before an anchor left its table unreferenced. Saving succeeded; reopening failed with "paragraph must hold exactly one anchor". fix_tables now splits a table paragraph that holds further anchors, so every anchor gets its own paragraph again. delete and insert_story tell it which table each surviving anchor belongs to (fix_tables_with), and insert_story fixes tables up once after the whole paste instead of after every run. Regression tests in doc (Backspace between tables, deleting the middle of three, paste before/after an anchor, paragraphs pasted before an anchor) and engine (text.delete and edit.paste through commands, then a file round-trip); all failed before the fix. Claude-Session: https://claude.ai/code/session_01La6SDgsmdZfgfDDmx3wiGU --- ROADMAP.md | 3 +- crates/doc/src/slice.rs | 55 +++++++++++++++++----- crates/doc/src/story.rs | 22 +++++---- crates/doc/src/table.rs | 78 +++++++++++++++++++++++++++++--- crates/engine/src/tests_table.rs | 37 +++++++++++++++ 5 files changed, 168 insertions(+), 27 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 91a951616..2788ac9cf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **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 (cancelable macOS Dock Quit) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (table anchors keep their own paragraphs) · **Target:** Adobe InDesign 2026 DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-13** · Backspace between two consecutive tables, pasting a table next to a table anchor, and pasting several paragraphs right before one keep every table on its own anchor paragraph. Previously a table was dropped or left without its anchor, and the saved file failed to reopen. No readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/crates/doc/src/slice.rs b/crates/doc/src/slice.rs index f85ec4adc..cc320bd09 100644 --- a/crates/doc/src/slice.rs +++ b/crates/doc/src/slice.rs @@ -57,18 +57,25 @@ impl Story { if src.text.is_empty() { return pos; } - // Text first (creates empty marker entries), then formats and marker payloads. + // The table anchored in the paragraph at `pos` keeps its anchor wherever the paste moves it. + let p0 = self.para_at(pos); + let own = self.paras.get(p0).and_then(|p| p.table).and_then(|id| { + let r = self.para_ranges().get(p0)?.clone(); + Some((r.start + self.text.get(r)?.find(TABLE_ANCHOR)?, id)) + }); + // Text first (creates empty marker entries), then formats and marker payloads; tables are + // fixed up once at the end. let mut at = pos; for (r, f) in src.runs() { if r.is_empty() { continue; } - self.insert_with(at, &src.text[r.clone()], f.clone()); + self.insert_with_raw(at, &src.text[r.clone()], f.clone()); at += r.len(); } let end = at; + let mut claims: Vec<(usize, u64)> = own.map(|(a, id)| (if a >= pos { a + (end - pos) } else { a }, id)).into_iter().collect(); // Paragraph formats of the pasted paragraphs after the first. - let p0 = self.para_at(pos); for (k, pf) in src.paras.iter().enumerate().skip(1) { if let Some(p) = self.paras.get_mut(p0 + k) { *p = crate::story::ParaFormat { table: None, ..pf.clone() }; @@ -106,25 +113,25 @@ impl Story { *slot = Arc::new(crate::xref::TextAnchor { id, name: a.name.clone() }); } } - // Tables: re-attach by paragraph (fresh ids so a pasted copy never shares an id). + // Tables: re-attach at their anchors (fresh ids so a pasted copy never shares an id). if !src.tables.is_empty() { let mut next = self.tables.keys().max().copied().unwrap_or(0) + 1; - for (k, pf) in src.paras.iter().enumerate() { + let ranges = src.para_ranges(); + for (pf, r) in src.paras.iter().zip(ranges) { let Some(tid) = pf.table else { continue }; let Some(t) = src.tables.get(&tid) else { continue }; - let pi = p0 + k; + let Some(k) = src.text.get(r.clone()).and_then(|t| t.find(TABLE_ANCHOR)) else { continue }; let mut t = (**t).clone(); t.id = next; next += 1; + claims.push((pos + r.start + k, t.id)); self.tables.insert(t.id, Arc::new(t)); - if let Some(p) = self.paras.get_mut(pi) { - p.table = Some(next - 1); - } } - self.fix_tables(); } + let splits = self.fix_tables_with(&claims); self.rev += 1; - end + // Past any return the fix-up put inside the pasted text. + end + splits.iter().filter(|&&s| s < end).count() } } @@ -133,6 +140,32 @@ mod tests { use super::*; use crate::story::ParaFormat; + #[test] + fn pasting_a_table_next_to_another_gives_each_its_own_paragraph() { + let mut s = Story::with_text(StoryId(1), "x", ParaFormat::default()); + let a = s.insert_table(1, crate::Table::new(5, 1, 1, 0, 0, 10.0)); + let n = TABLE_ANCHOR.len_utf8(); + let copy = s.extract(a..a + n); + // After the anchor: the copy follows the original. + let mut after = s.clone(); + let end = after.insert_story(a + n, ©); + after.check().unwrap(); + assert_eq!(after.text, format!("x\n{TABLE_ANCHOR}\n{TABLE_ANCHOR}\n")); + assert_eq!((after.table_anchor(5), after.table_anchor(6)), (Some(a), Some(a + n + 1))); + assert_eq!(end, a + 2 * n + 1); + // Before the anchor: the copy comes first and the original keeps its table. + let mut before = s.clone(); + let end = before.insert_story(a, ©); + before.check().unwrap(); + assert_eq!((before.table_anchor(6), before.table_anchor(5)), (Some(a), Some(a + n + 1))); + assert_eq!(end, a + n); + // Paragraphs pasted before the anchor keep the table too. + let mut text = s.clone(); + text.insert_story(a, &Story::with_text(StoryId(2), "Hello\nWorld", ParaFormat::default())); + text.check().unwrap(); + assert_eq!(text.table_anchor(5), Some(a + "Hello\nWorld".len())); + } + #[test] fn copy_paste_keeps_formatting_and_notes() { let mut a = Story::with_text(StoryId(1), "Alpha beta\nGamma delta", ParaFormat::default()); diff --git a/crates/doc/src/story.rs b/crates/doc/src/story.rs index 6dbfa13c6..98d63eb82 100644 --- a/crates/doc/src/story.rs +++ b/crates/doc/src/story.rs @@ -255,7 +255,8 @@ impl Story { self.insert_with_raw(pos, text, fmt); } - fn insert_with_raw(&mut self, pos: usize, text: &str, fmt: CharFormat) { + /// [`Story::insert_with`] without the table fix-up. + pub(crate) fn insert_with_raw(&mut self, pos: usize, text: &str, fmt: CharFormat) { if text.is_empty() { return; } @@ -332,20 +333,25 @@ impl Story { self.marks_deleted(a, b); let pi = self.para_at(a); let removed = self.text[a..b].bytes().filter(|c| *c == b'\n').count(); - // Merged paragraphs keep a table whose anchor survives the deletion. + // Merged paragraphs keep the tables whose anchors survive the deletion (the fix-up gives + // each its own paragraph again). + let mut claims = Vec::new(); if removed > 0 && !self.tables.is_empty() { let ranges = self.para_ranges(); - let first = self.paras[pi].table.filter(|_| self.text[ranges[pi].start..a].contains(TABLE_ANCHOR)); - let last = self.paras[pi + removed] - .table - .filter(|_| self.text[b.min(ranges[pi + removed].end)..ranges[pi + removed].end].contains(TABLE_ANCHOR)); - self.paras[pi].table = first.or(last); + let anchor = |pi: usize, r: std::ops::Range<usize>| { + self.paras[pi].table.and_then(|id| self.text[r.clone()].find(TABLE_ANCHOR).map(|k| (r.start + k, id))) + }; + let first = anchor(pi, ranges[pi].start..a); + let end = ranges[pi + removed].end; + let last = anchor(pi + removed, b.min(end)..end).map(|(at, id)| (at.saturating_sub(b - a), id)); + self.paras[pi].table = first.or(last).map(|(_, id)| id); + claims.extend(first.into_iter().chain(last)); } self.paras.drain(pi + 1..pi + 1 + removed); self.text.replace_range(a..b, ""); self.remove_run_bytes(a, b - a); self.normalize(); - self.fix_tables(); + self.fix_tables_with(&claims); self.rev += 1; } diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index 7fe385e98..e51a9cbe3 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -1012,21 +1012,61 @@ impl Story { /// Keep paragraphs and tables consistent after an edit: a paragraph keeps its table only if it /// still contains an anchor character, every table is referenced once, and tables nobody - /// references are dropped. + /// references are dropped. An anchor that shares a table's paragraph with another anchor gets + /// a paragraph of its own again, so a table paragraph always holds exactly one anchor. pub(crate) fn fix_tables(&mut self) { + self.fix_tables_with(&[]); + } + + /// [`Story::fix_tables`] for an edit that knows which table an anchor belongs to: `claims` are + /// `(anchor byte offset, table id)` pairs that win over the paragraphs' table ids (a paragraph + /// names one table, so merging two anchor paragraphs would otherwise lose one). Every other + /// table takes the first unclaimed anchor of its paragraph. Returns the byte offsets, before + /// the fix-up, where it inserted paragraph returns. + pub(crate) fn fix_tables_with(&mut self, claims: &[(usize, u64)]) -> Vec<usize> { if self.tables.is_empty() && self.paras.iter().all(|p| p.table.is_none()) { - return; + return Vec::new(); } - let ranges = self.para_ranges(); + let mut owner = std::collections::BTreeMap::new(); let mut used = std::collections::HashSet::new(); - for (p, r) in self.paras.iter_mut().zip(ranges) { - if let Some(id) = p.table - && (!self.text[r].contains(TABLE_ANCHOR) || !self.tables.contains_key(&id) || !used.insert(id)) + for &(at, id) in claims { + if self.text.get(at..).is_some_and(|t| t.starts_with(TABLE_ANCHOR)) + && self.tables.contains_key(&id) + && !owner.contains_key(&at) + && used.insert(id) { - p.table = None; + owner.insert(at, id); } } + let ranges = self.para_ranges(); + for (p, r) in self.paras.iter().zip(&ranges) { + let Some(id) = p.table.filter(|id| self.tables.contains_key(id) && !used.contains(id)) else { continue }; + let free = self.text[r.clone()].match_indices(TABLE_ANCHOR).map(|(k, _)| r.start + k).find(|at| !owner.contains_key(at)); + if let Some(at) = free { + used.insert(id); + owner.insert(at, id); + } + } + // A paragraph holding a table's anchor and other anchors is split before each later anchor. + let mut splits = Vec::new(); + for r in &ranges { + let anchors: Vec<usize> = self.text[r.clone()].match_indices(TABLE_ANCHOR).map(|(k, _)| r.start + k).collect(); + if anchors.len() > 1 && anchors.iter().any(|at| owner.contains_key(at)) { + splits.extend(anchors.into_iter().skip(1)); + } + } + for &at in splits.iter().rev() { + self.insert_raw(at, "\n"); + } + // Each owned anchor, moved past the returns inserted before it, names its paragraph's table. + let owner: std::collections::BTreeMap<usize, u64> = + owner.into_iter().map(|(at, id)| (at + splits.partition_point(|&s| s <= at), id)).collect(); + let ranges = self.para_ranges(); + for (p, r) in self.paras.iter_mut().zip(ranges) { + p.table = owner.range(r).next().map(|(_, &id)| id); + } self.tables.retain(|id, _| used.contains(id)); + splits } /// Check table invariants (part of [`Story::check`]). @@ -1097,6 +1137,30 @@ mod tests { assert!(s.tables.is_empty()); } + #[test] + fn deleting_the_return_between_two_tables_keeps_both() { + // Backspace at the start of the second table's paragraph. + let mut s = story("x"); + let a1 = s.insert_table(1, Table::new(1, 1, 1, 0, 0, 10.0)); + let a2 = s.insert_table(s.len(), Table::new(2, 1, 1, 0, 0, 10.0)); + let before = s.text.clone(); + s.delete(a2 - 1..a2); + s.check().unwrap(); + assert_eq!(s.text, before); + assert_eq!((s.table_anchor(1), s.table_anchor(2)), (Some(a1), Some(a2))); + // Deleting the table between two others keeps the outer ones on their own anchors. + let mut s = story("x"); + for id in 1..=3 { + s.insert_table(s.len(), Table::new(id, 1, 1, 0, 0, 10.0)); + } + let (a1, a3) = (s.table_anchor(1).unwrap(), s.table_anchor(3).unwrap()); + s.delete(a1 + TABLE_ANCHOR.len_utf8()..a3); + s.check().unwrap(); + assert_eq!(s.text, format!("x\n{TABLE_ANCHOR}\n{TABLE_ANCHOR}\n")); + assert_eq!(s.tables.keys().copied().collect::<Vec<_>>(), [1, 3]); + assert_eq!(s.table_anchor(3), Some(a1 + TABLE_ANCHOR.len_utf8() + 1)); + } + #[test] fn edits_around_the_anchor_keep_the_table() { let mut s = story("ab\ncd"); diff --git a/crates/engine/src/tests_table.rs b/crates/engine/src/tests_table.rs index 45861347d..4ca02f889 100644 --- a/crates/engine/src/tests_table.rs +++ b/crates/engine/src/tests_table.rs @@ -177,6 +177,43 @@ fn file_roundtrip_keeps_tables() { assert_eq!(st.tables.values().next().unwrap().cells[0].text.text, "A1"); } +#[test] +fn backspace_and_paste_between_tables_keep_both_and_the_file_reopens() { + let n = designcraft_doc::TABLE_ANCHOR.len_utf8(); + let anchor_of = |s: &Session, sid: u64, tid: u64| s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap().table_anchor(tid).unwrap(); + let reopens_with = |s: &Session, sid: u64, ids: &[u64]| { + let back = crate::cmd::file_from(&crate::cmd::file_bytes(&s.doc().unwrap().doc)).unwrap(); + let st = back.story(doc::StoryId(sid)).unwrap(); + assert!(ids.iter().all(|id| st.table_anchor(*id).is_some()), "{:?}", st.tables.keys()); + assert_eq!(st.tables.len(), ids.len()); + }; + // Backspace at the start of the second of two consecutive tables. + let (mut s, sid, _) = session_with_frame(); + let t1 = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap(); + let len = s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap().len(); + s.execute("text.select", &json!({"story": sid, "anchor": len})).unwrap(); + let t2 = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap(); + let a2 = anchor_of(&s, sid, t2); + s.execute("text.select", &json!({"story": sid, "anchor": a2})).unwrap(); + s.execute("text.delete", &json!({})).unwrap(); + check(&s); + reopens_with(&s, sid, &[t1, t2]); + // Pasting a copy of a table right after its anchor. + let (mut s, sid, _) = session_with_frame(); + let t1 = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap(); + let a1 = anchor_of(&s, sid, t1); + s.execute("text.select", &json!({"story": sid, "anchor": a1, "focus": a1 + n})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": a1 + n})).unwrap(); + let pasted = s.execute("edit.paste", &json!({})).unwrap(); + check(&s); + assert_eq!(pasted["pos"], a1 + 2 * n + 1); + let st = s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap().clone(); + let copy = st.tables.keys().copied().find(|id| *id != t1).unwrap(); + assert_eq!((st.table_anchor(t1), st.table_anchor(copy)), (Some(a1), Some(a1 + n + 1))); + reopens_with(&s, sid, &[t1, copy]); +} + fn table_for(s: &Session, sid: u64, tid: u64) -> &doc::Table { s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap().tables.get(&tid).unwrap() } From 3b36c286949bcb571ff5b5a818ab4f111589673c Mon Sep 17 00:00:00 2001 From: haranishi <223786623+haranishi@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:25:53 +0900 Subject: [PATCH 131/238] =?UTF-8?q?test:=20macOS=E3=81=AEMCP=E5=86=8D?= =?UTF-8?q?=E6=8E=A5=E7=B6=9A=E3=83=86=E3=82=B9=E3=83=88=E3=81=AE=E3=82=BD?= =?UTF-8?q?=E3=82=B1=E3=83=83=E3=83=88=E8=A8=AD=E5=AE=9A=E3=82=92=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/mcp/src/backend.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index debdbb124..ca4f588d9 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -172,6 +172,9 @@ mod tests { Err(e) => panic!("accept failed: {e}"), } }; + // Accepted sockets can inherit the listener's nonblocking mode. The server + // waits for both requests with timeouts, rather than reading before they arrive. + stream.set_nonblocking(false).unwrap(); stream.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); stream.set_write_timeout(Some(Duration::from_secs(2))).unwrap(); let mut reader = BufReader::new(stream.try_clone().unwrap()); From 96e9f371acdde103172e74122454e85d16db3fa0 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Tue, 13 Oct 2026 12:02:57 +0200 Subject: [PATCH 132/238] fix(render): effects apply to frames with pasted-in content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A frame holding items pasted into it (Edit › Paste Into) was drawn by its own branch in draw_item that skipped every effect: Basic Feather, Inner Glow, shadows and the rest were stored but never drawn. LYH Studio episode 14 (11:51) gives the outlined "GET MORE." title, with a photo pasted into it, an Inner Glow and a Basic Feather. Such a frame now goes the way of any other object: draw_body draws its fill, its items clipped to the frame and its stroke, so draw_item_fx wraps all of it. Its silhouette counts as solid for shadow spread. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 1 + crates/render/src/fx.rs | 2 +- crates/render/src/lib.rs | 45 +++++++++---------------------- crates/render/tests/effects.rs | 48 ++++++++++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 34 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..90520070e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-13** · Object effects (Basic Feather, Inner Glow, shadows, glows, bevel, satin, directional and gradient feathers) apply to frames with pasted-in content, on screen and in image exports: the frame, its clipped content and its stroke are one object to the effect. PDF export still has no soft effects for any object. No readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 6b29516f4..d90f9afea 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -74,7 +74,7 @@ impl Renderer { let reach = xf.transform_rect_bbox(bp.bounding_box()).inflate(sw + 1.0, sw + 1.0); // The silhouette is the shape when the object paints its area (fill / image); text frames // without a fill cast the shadow of their text only, which can't be grown. - let solid = !it.fill.is_none() || matches!(it.content, designcraft_doc::Content::Graphic(_)); + let solid = !it.fill.is_none() || matches!(it.content, designcraft_doc::Content::Graphic(_)) || it.has_nested_items(); let mut below = vec![]; if e.outer_glow.on { let g = &e.outer_glow; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 2cc5c548f..1dab91899 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -436,39 +436,9 @@ impl Renderer { } let xf = parent * it.xf; let vb = xf.transform_rect_bbox(it.inner_bounds()).inflate(it.stroke.extent() + 2.0, it.stroke.extent() + 2.0); - if it.has_nested_items() { - // A frame with items pasted into it: fill, the items clipped to the frame, stroke. - if !rect_overlaps(vb, f.visible) { - return; - } - let bp = if it.corners.is_none() { it.path.to_bezpath() } else { corners::apply(&it.path, &it.corners) }; - let layered = it.opacity < 0.999 || it.blend != DcBlend::Normal; - if layered { - ctx.set_transform(Affine::IDENTITY); - ctx.push_layer(None, Some(blend_mode(it.blend)), Some(it.opacity), None, None); - } - if !it.fill.is_none() { - ctx.set_transform(f.view * xf); - if set_fill_paint(ctx, f.doc, &it.fill, bp.bounding_box()) { - ctx.fill_path(&bp); - } - ctx.reset_paint_transform(); - } - ctx.set_transform(f.view * xf); - ctx.push_clip_layer(&bp); - for c in it.children() { - self.draw_item(ctx, f, c, xf, page); - } - ctx.pop_layer(); - if !it.stroke.is_none() { - self.draw_stroke(ctx, f, it, &bp, xf); - } - if layered { - ctx.pop_layer(); - } - return; - } - if !it.children().is_empty() { + // A frame with items pasted into it draws like any other object (draw_body: fill, the items + // clipped to the frame, stroke), so its effects apply to all of it. + if !it.children().is_empty() && !it.has_nested_items() { // Groups: children carry their own transforms relative to the group. // Isolate Blending / Knockout: the group is its own layer (blend modes stop at it). let layered = it.opacity < 0.999 || it.blend != DcBlend::Normal || it.isolate || it.knockout; @@ -573,6 +543,15 @@ impl Renderer { } ctx.pop_layer(); } + // Items pasted into the frame, clipped by it. + Content::Group { items } if it.has_nested_items() => { + ctx.set_transform(f.view * xf); + ctx.push_clip_layer(bp); + for c in items { + self.draw_item(ctx, f, c, xf, page); + } + ctx.pop_layer(); + } Content::Text(tf) => { let cs = self.story(f, tf.story, page); if let Some(ft) = cs.frame(it.id) { diff --git a/crates/render/tests/effects.rs b/crates/render/tests/effects.rs index a664c27a8..bd0160ee1 100644 --- a/crates/render/tests/effects.rs +++ b/crates/render/tests/effects.rs @@ -222,3 +222,51 @@ fn multithreaded_effects_match_single_threaded() { assert!(worst <= 4, "case {i}: max channel difference {worst}"); } } + +/// A frame (30..130) holding a pasted-in black square that overhangs it (Edit › Paste Into). +fn pasted_into(d: &mut Document) -> ItemId { + let id = rect_item(d, Rect::new(30.0, 30.0, 130.0, 130.0), "[None]"); + let kid = Item { + fill: Fill::swatch("[Black]"), + ..Item::new(ItemId(d.alloc()), d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 160.0, 160.0))) + }; + d.item_mut(id).unwrap().content = designcraft_doc::Content::Group { items: vec![std::sync::Arc::new(kid)] }; + assert!(d.item(id).unwrap().has_nested_items()); + id +} + +#[test] +fn effects_apply_to_a_frame_with_pasted_in_items() { + let mut d = doc(); + let id = pasted_into(&mut d); + let plain = render(&d); + let black = lum(plain.pixel(80, 80)); + assert!(black < 200 && lum(plain.pixel(31, 80)) <= black + 6, "the content fills the frame: {:?} {:?}", plain.pixel(31, 80), plain.pixel(80, 80)); + assert_eq!(lum(plain.pixel(27, 80)), WHITE, "clipped by the frame"); + // Basic Feather fades the frame's edge, content and all. + d.item_mut(id).unwrap().effects.feather = 16.0; + let img = render(&d); + assert!(lum(img.pixel(30, 80)) > 600, "nearly transparent at the path: {:?}", img.pixel(30, 80)); + assert!(lum(img.pixel(80, 80)) <= black + 6, "solid inside"); + assert_eq!(lum(img.pixel(27, 80)), WHITE, "still clipped"); + // Inner Glow: white screened in from the edges. + d.item_mut(id).unwrap().effects.feather = 0.0; + let g = &mut d.item_mut(id).unwrap().effects.inner_glow; + g.on = true; + g.size = 12.0; + g.opacity = 1.0; + let img = render(&d); + assert!(lum(img.pixel(32, 80)) > black + 150, "lit at the edge: {:?}", img.pixel(32, 80)); + assert!(lum(img.pixel(80, 80)) <= black + 6, "dark in the middle"); + // Drop shadow: cast by the content's silhouette, outside the frame. + d.item_mut(id).unwrap().effects.inner_glow.on = false; + let ds = &mut d.item_mut(id).unwrap().effects.drop_shadow; + ds.on = true; + ds.angle = 180.0; + ds.global_light = false; + ds.distance = 12.0; + ds.size = 0.0; + ds.opacity = 1.0; + let img = render(&d); + assert!(lum(img.pixel(136, 80)) < 300, "the shadow to the right: {:?}", img.pixel(136, 80)); +} From 27302d8ea33b44460bca8642584a2bb32da111a8 Mon Sep 17 00:00:00 2001 From: haranishi <223786623+haranishi@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:18:40 +0900 Subject: [PATCH 133/238] =?UTF-8?q?fix:=20Links=E3=83=91=E3=83=8D=E3=83=AB?= =?UTF-8?q?=E3=81=AB=E3=83=AA=E3=83=B3=E3=82=AF=E5=85=83=E3=83=91=E3=82=B9?= =?UTF-8?q?=E3=82=92=E8=A1=A8=E7=A4=BA=20#569?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ROADMAP.md | 3 +- crates/ui-egui/src/panels/properties.rs | 150 +++++++++++++++++++++++- 2 files changed, 150 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..aab4166ed 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **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 (cancelable macOS Dock Quit) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Links source paths) · **Target:** Adobe InDesign 2026 DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-13** · Links rows show their source paths; selecting a link shows its full path wrapped inside the panel, so same-named files from different folders can be distinguished. No readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index d9c74e40d..aa612313c 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1911,6 +1911,8 @@ pub fn links_panel(app: &mut DesignApp, ui: &mut egui::Ui) { } let sel_key = egui::Id::new("links_panel_sel"); let mut chosen: Option<u64> = ui.data(|d| d.get_temp(sel_key)); + // Leave room for Status and Page/PPI instead of widening the grid for long source paths. + let name_width = (ui.available_width() - 160.0).max(50.0); egui::Grid::new("links_grid").num_columns(3).striped(true).spacing([10.0, 4.0]).show(ui, |ui| { crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Name")).color(t.text_dim).size(11.0)); crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Status")).color(t.text_dim).size(11.0)); @@ -1919,7 +1921,25 @@ pub fn links_panel(app: &mut DesignApp, ui: &mut egui::Ui) { for r in &rows { let aid = r["asset"].as_u64().unwrap_or(0); let name = r["name"].as_str().unwrap_or(""); - if ui.selectable_label(chosen == Some(aid), name).clicked() { + let response = ui + .vertical(|ui| { + ui.set_width(name_width); + let name = ui.add(egui::Button::selectable(chosen == Some(aid), name).truncate()); + match r["path"].as_str().filter(|path| !path.is_empty()) { + Some(path) => name.union( + ui.add( + egui::Label::new(egui::RichText::new(path).color(t.text_dim).size(11.0)) + .truncate() + .sense(Sense::click()) + .show_tooltip_when_elided(false), + ) + .on_hover_text(path), + ), + None => name, + } + }) + .inner; + if response.clicked() { chosen = Some(aid); } let (txt, col) = match r["status"].as_str().unwrap_or("") { @@ -1952,7 +1972,12 @@ pub fn links_panel(app: &mut DesignApp, ui: &mut egui::Ui) { ui.data_mut(|d| d.insert_temp(sel_key, c)); } ui.add_space(6.0); - ui.horizontal(|ui| { + if let Some(path) = rows.iter().find(|r| r["asset"].as_u64() == chosen).and_then(|r| r["path"].as_str()).filter(|path| !path.is_empty()) { + // A selected link stays distinguishable even if two rows truncate to the same prefix. + ui.add(egui::Label::new(egui::RichText::new(path).color(t.text_dim).size(11.0)).wrap()); + ui.add_space(6.0); + } + ui.horizontal_wrapped(|ui| { let Some(aid) = chosen else { crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Choose a link.")).color(t.text_dim).size(11.0)); return; @@ -2239,6 +2264,127 @@ mod tests { text_attrs(app).unwrap()["chars"]["language"].clone() } + fn linked_graphics(paths: &[Option<&str>]) -> (DesignApp, Vec<designcraft_doc::ItemId>, Vec<designcraft_doc::AssetId>) { + use designcraft_doc::{Asset, AssetId, Content, Graphic, ItemId}; + use std::sync::Arc; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"title": "Linked graphics"})).unwrap(); + let mut items = Vec::new(); + let mut assets = Vec::new(); + for (index, path) in paths.iter().enumerate() { + let x = 72 + index * 100; + let id = ItemId(app.run("frame.create", json!({"rect": [x, 72, x + 72, 144]})).unwrap()["id"].as_u64().unwrap()); + let doc = Arc::make_mut(&mut app.session.doc_mut().unwrap().doc); + let aid = AssetId(doc.alloc()); + doc.assets.insert( + aid, + Arc::new(Asset { + id: aid, + name: "photo.svg".into(), + mime: "image/svg+xml".into(), + link: path.map(str::to_owned), + data: Arc::new( + br##"<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"><rect width="32" height="32" fill="#4488cc"/></svg>"## + .to_vec(), + ), + pixels: Some((32, 32)), + page: 0, + }), + ); + doc.item_mut(id).unwrap().content = Content::Graphic(Graphic { + asset: aid, + size: (72.0, 72.0), + xf: designcraft_geom::Affine::IDENTITY, + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + }); + items.push(id); + assets.push(aid); + } + (app, items, assets) + } + + fn links_harness(app: DesignApp, width: f32) -> Harness<'static, DesignApp> { + let builder = Harness::builder().with_size(vec2(width, 600.0)); + let builder = if std::env::var_os("DESIGNCRAFT_LINKS_FIXTURES").is_some() { builder.wgpu() } else { builder }; + let mut ready = false; + let mut h = builder.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; + } + links_panel(app, ui); + }, + app, + ); + h.input_mut().max_texture_side = Some(8192); + h.run_steps(3); + h + } + + fn capture_links(h: &mut Harness<'_, DesignApp>, name: &str) { + let Some(directory) = std::env::var_os("DESIGNCRAFT_LINKS_FIXTURES").map(std::path::PathBuf::from) else { return }; + std::fs::create_dir_all(&directory).unwrap(); + h.render().unwrap().save(directory.join(format!("round2-links569-{name}.png"))).unwrap(); + if name == "same-named-sources" { + let doc = &h.state().session.active().unwrap().doc; + let mut value = serde_json::to_value(doc).unwrap(); + value["assetData"] = + Value::Object(doc.assets.iter().map(|(id, a)| (id.0.to_string(), json!(designcraft_engine::cmd::base64_encode(&a.data)))).collect()); + std::fs::write(directory.join("round2-links569-synthetic.designcraft"), serde_json::to_vec(&value).unwrap()).unwrap(); + } + } + + #[test] + fn links_panel_distinguishes_same_named_sources_and_keeps_link_actions() { + let first = "/project/assets/studio-a/photo.svg"; + let second = "/project/assets/studio-b/photo.svg"; + let (app, items, assets) = linked_graphics(&[Some(first), Some(second), None]); + let mut h = links_harness(app, 320.0); + assert_eq!(h.get_all_by_label("photo.svg").count(), 3); + assert!(h.query_by_label(first).is_some(), "the first source path is visible in the row"); + capture_links(&mut h, "same-named-sources"); + h.get_by_label(second).click(); + h.run_steps(3); + capture_links(&mut h, "selected-studio-b"); + h.get_by_label("Go To").click(); + h.run_steps(3); + assert_eq!(h.state().session.active().unwrap().selection.items, vec![items[1]]); + h.get_by_label("Embed").click(); + h.run_steps(3); + assert!(h.state().session.active().unwrap().doc.assets[&assets[1]].link.is_none()); + assert_eq!(h.state().session.active().unwrap().doc.assets[&assets[0]].link.as_deref(), Some(first)); + assert!(h.query_by_label(second).is_none(), "an embedded graphic no longer shows a source path"); + assert_eq!(h.get_all_by_label("Embedded").count(), 2); + assert_eq!(h.get_all_by_label("photo.svg").count(), 3); + capture_links(&mut h, "embedded"); + h.get_by_label(first).click(); + h.run_steps(3); + capture_links(&mut h, "selected-studio-a"); + h.get_by_label("Go To").click(); + h.run_steps(3); + assert_eq!(h.state().session.active().unwrap().selection.items, vec![items[0]]); + } + + #[test] + fn links_panel_keeps_long_source_paths_inside_a_narrow_panel() { + let path = "/project/assets/studio-a/very-long-folder-name/another-long-folder-name/photo.svg"; + let (app, _, _) = linked_graphics(&[Some(path)]); + let mut h = links_harness(app, 240.0); + let source = h.get_by_label(path).rect(); + assert!(source.width() < 150.0, "the source is truncated to its column: {source:?}"); + assert!(source.right() <= 240.0, "the source stays inside the narrow panel: {source:?}"); + assert!(h.get_by_label("Page · PPI").rect().right() <= 240.0); + h.get_by_label(path).click(); + h.run_steps(3); + assert!(h.output().shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Text(text) if text.galley.job.text == path && !text.galley.elided && text.visual_bounding_rect().right() <= 240.0)), "the selected path is painted in full and wraps inside the panel"); + capture_links(&mut h, "long-path-narrow"); + } + #[test] fn properties_character_section_sets_the_language() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From e3ee342a4addab0c92e0715dee694a605987b120 Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Tue, 13 Oct 2026 12:12:16 +0000 Subject: [PATCH 134/238] feat(ui): drag a dialog by its title to move it --- ROADMAP.md | 1 + crates/ui-egui/src/about.rs | 12 +++++- crates/ui-egui/src/dialogs.rs | 71 +++++++++++++++++++++++++++++++- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/modal_drag.rs | 57 +++++++++++++++++++++++++ 5 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/src/modal_drag.rs diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..f626b2f28 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-13** · Dialogs and the About window move when you drag their title row, so the page behind them can be seen. A dragged dialog stays inside the window, and each dialog opens centred. No readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/crates/ui-egui/src/about.rs b/crates/ui-egui/src/about.rs index 4eae2e7c6..d0323a999 100644 --- a/crates/ui-egui/src/about.rs +++ b/crates/ui-egui/src/about.rs @@ -106,8 +106,18 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { } let t = Tokens::get(ctx); let mut open: Option<&'static str> = None; - let resp = egui::Modal::new(egui::Id::new("about")).show(ctx, |ui| { + let area = egui::Id::new("about"); + let instance = area.with("placement"); + let resp = crate::modal_drag::modal(ctx, area, instance).show(ctx, |ui| { ui.set_width(if app.ui.about_tab == 0 { 460.0 } else { 640.0 }); + // The tab row is the title row. The tabs register after the drag area, so they stay clickable. + let margin = egui::Frame::popup(ui.style()).inner_margin; + let top = ui.cursor().top(); + let title_row = Rect::from_min_max( + pos2(ui.max_rect().left() - margin.leftf(), top - margin.topf()), + pos2(ui.max_rect().right() + margin.rightf(), top + ui.spacing().interact_size.y), + ); + crate::modal_drag::title_drag(ui, instance, title_row); ui.horizontal(|ui| { for (i, l) in ABOUT_TABS.iter().enumerate() { let i = i as u8; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 695a66af1..c24c2ba0f 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1155,7 +1155,9 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { None => crate::i18n::tr(&app.ui.language, "Dialog"), }, }; - egui::Modal::new(egui::Id::new("dialog")).show(ctx, |ui| { + let host = egui::Id::new("dialog"); + let instance = host.with(&d.id); + crate::modal_drag::modal(ctx, host, instance).show(ctx, |ui| { let frame_margin = egui::Frame::popup(ui.style()).total_margin().sum(); let available = (ctx.content_rect().size() - frame_margin - egui::vec2(16.0, 16.0)).max(egui::Vec2::splat(1.0)); let preferred_width: f32 = match d.id.as_str() { @@ -1167,6 +1169,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { let width = preferred_width.min(available.x); ui.set_min_width(380.0_f32.min(width)); ui.set_max_width(width); + let title_top = ui.cursor().top(); if crate::i18n::is_rtl(&app.ui.language) { ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -1176,6 +1179,12 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { } else { crate::rtl::label(ui, egui::RichText::new(title).font(semibold(16.0))); } + let margin = egui::Frame::popup(ui.style()).inner_margin; + let title_row = egui::Rect::from_min_max( + egui::pos2(ui.max_rect().left() - margin.leftf(), title_top - margin.topf()), + egui::pos2(ui.max_rect().right() + margin.rightf(), ui.min_rect().bottom()), + ); + crate::modal_drag::title_drag(ui, instance, title_row); ui.add_space(10.0); // Keep the title and action buttons outside the scrollable body. Oversized // forms remain reachable at large UI scales, without shrinking their text. @@ -4776,4 +4785,64 @@ mod tests { assert_eq!(crate::menus::shortcut_of(&app, "app.palette").as_deref(), Some("Cmd+K")); assert_eq!(d.s("recording"), "", "recording ends with the recorded shortcut"); } + + #[test] + fn dragging_the_title_moves_the_dialog_and_dragging_the_body_does_not() { + use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; + use egui_kittest::{Harness, kittest::Queryable}; + fn drag(h: &mut Harness<'static, DesignApp>, from: Pos2, to: Pos2) { + h.event(Event::PointerMoved(from)); + h.run_steps(1); + h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + for step in 1..=8 { + h.event(Event::PointerMoved(from + (to - from) * (step as f32 / 8.0))); + h.run_steps(1); + } + h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + } + let rect = |h: &Harness<'static, DesignApp>| h.ctx.memory(|m| m.area_rect(egui::Id::new("dialog"))).unwrap(); + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.ui.dialog = Some(Dialog::new("insertTable", Value::Null)); + let mut ready = false; + let mut h = Harness::builder().with_size(vec2(1200.0, 800.0)).with_step_dt(1.0 / 60.0).build_ui_state( + move |ui, app: &mut DesignApp| { + if !ready { + crate::theme::install_fonts(ui.ctx(), &app.ui.language); + crate::theme::apply(ui.ctx(), &crate::theme::Tokens::for_brightness(crate::theme::Brightness::default())); + ready = true; + return; + } + show(app, ui.ctx()); + }, + app, + ); + h.run_steps(4); + let centred = rect(&h); + assert!((centred.center() - egui::pos2(600.0, 400.0)).length() < 1.0, "{centred:?}"); + assert!(centred.width() < 1000.0 && centred.height() < 700.0, "the area is the dialog, not the backdrop: {centred:?}"); + + let title = h.get_by_label("Create Table").rect().center(); + drag(&mut h, title, title + vec2(100.0, 60.0)); + let moved = rect(&h); + assert!((moved.min - centred.min - vec2(100.0, 60.0)).length() < 2.0, "{centred:?} -> {moved:?}"); + + let body = egui::pos2(moved.right() - 40.0, moved.center().y); + drag(&mut h, body, body + vec2(-150.0, 80.0)); + assert_eq!(rect(&h), moved, "a drag in the body leaves the dialog in place"); + + // The dialog stays inside the window. + let title = h.get_by_label("Create Table").rect().center(); + drag(&mut h, title, title - vec2(0.0, 1000.0)); + assert!(rect(&h).top() >= 0.0, "{:?}", rect(&h)); + + // Opened again, the dialog starts centred. + h.state_mut().ui.dialog = None; + h.run_steps(2); + h.state_mut().ui.dialog = Some(Dialog::new("insertTable", Value::Null)); + h.run_steps(4); + assert!((rect(&h).center() - centred.center()).length() < 1.0, "{:?}", rect(&h)); + } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fc7a3e634..d53d2c490 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -16,6 +16,7 @@ pub mod dock; pub mod i18n; pub mod icons; pub mod menus; +mod modal_drag; mod panel_docking; pub mod panels; mod prefs; diff --git a/crates/ui-egui/src/modal_drag.rs b/crates/ui-egui/src/modal_drag.rs new file mode 100644 index 000000000..2828a58a8 --- /dev/null +++ b/crates/ui-egui/src/modal_drag.rs @@ -0,0 +1,57 @@ +//! Modal windows (dialogs, About) that the user moves by dragging their title row. +//! +//! The modal stays anchored at the centre of the window and is shifted by an offset kept in +//! egui's temporary memory. A modal that was not shown in the previous pass starts centred. +//! egui keeps the whole modal inside the window, so its title row stays reachable. + +use egui::{Align2, Context, CursorIcon, Id, Modal, Rect, Sense, Ui, Vec2, vec2}; + +#[derive(Clone, Copy, Default)] +struct Placement { + offset: Vec2, + /// The pass the modal was last shown in: a gap means it was closed and opened again. + pass: u64, +} + +/// A modal centred in the window and shifted by the user's drag. +/// +/// `area` is the modal's area id. `instance` names what it shows (for example the dialog kind), +/// so a different dialog in the same host starts centred. +pub(crate) fn modal(ctx: &Context, area: Id, instance: Id) -> Modal { + let pass = ctx.cumulative_pass_nr(); + let size = ctx.memory(|m| m.area_rect(area)).map(|r| r.size()); + let content = ctx.content_rect(); + let offset = ctx.data_mut(|d| { + let p = d.get_temp_mut_or_default::<Placement>(instance); + if p.pass.saturating_add(1) < pass { + *p = Placement::default(); + } + p.pass = pass; + if let Some(size) = size { + p.offset = clamp(p.offset, size, content); + } + p.offset + }); + Modal::new(area).area(Modal::default_area(area).anchor(Align2::CENTER_CENTER, offset)) +} + +/// Makes `rect`, the modal's title row, drag the modal opened with [`modal`] for `instance`. +/// +/// Widgets registered after this call sit on top of the drag area and keep their own input. +pub(crate) fn title_drag(ui: &Ui, instance: Id, rect: Rect) { + let resp = ui.interact(rect, instance.with("title_drag"), Sense::drag()); + if resp.dragged() { + let delta = resp.drag_delta(); + ui.ctx().data_mut(|d| d.get_temp_mut_or_default::<Placement>(instance).offset += delta); + ui.ctx().set_cursor_icon(CursorIcon::Grabbing); + } else if resp.hovered() { + ui.ctx().set_cursor_icon(CursorIcon::Grab); + } +} + +/// Limits `offset` to the range in which a modal of `size` fits inside `content`, the range egui +/// constrains it to, so dragging back from beyond an edge moves it at once. +fn clamp(offset: Vec2, size: Vec2, content: Rect) -> Vec2 { + let room = ((content.size() - size) * 0.5).max(Vec2::ZERO); + vec2(offset.x.min(room.x).max(-room.x), offset.y.min(room.y).max(-room.y)) +} From c9a09989c8cccb8f23be576892bbeec6ec52428b Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Tue, 13 Oct 2026 12:28:23 +0000 Subject: [PATCH 135/238] feat(ui): a dragged dialog may go partly off-screen --- ROADMAP.md | 2 +- crates/ui-egui/src/dialogs.rs | 9 ++++-- crates/ui-egui/src/modal_drag.rs | 51 +++++++++++++++++++++++--------- 3 files changed, 45 insertions(+), 17 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f626b2f28..9f5bc414c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,7 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. -- **2026-10-13** · Dialogs and the About window move when you drag their title row, so the page behind them can be seen. A dragged dialog stays inside the window, and each dialog opens centred. No readiness percentage change. +- **2026-10-13** · Dialogs and the About window move when you drag their title row, so the page behind them can be seen. A dragged dialog may go partly off-screen, but its title row stays reachable; each dialog opens centred, and again after the window is resized. No readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index c24c2ba0f..364e5323c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4833,10 +4833,10 @@ mod tests { drag(&mut h, body, body + vec2(-150.0, 80.0)); assert_eq!(rect(&h), moved, "a drag in the body leaves the dialog in place"); - // The dialog stays inside the window. + // The title row stays inside the top edge. let title = h.get_by_label("Create Table").rect().center(); drag(&mut h, title, title - vec2(0.0, 1000.0)); - assert!(rect(&h).top() >= 0.0, "{:?}", rect(&h)); + assert!(rect(&h).top().abs() < 1.0, "{:?}", rect(&h)); // Opened again, the dialog starts centred. h.state_mut().ui.dialog = None; @@ -4844,5 +4844,10 @@ mod tests { h.state_mut().ui.dialog = Some(Dialog::new("insertTable", Value::Null)); h.run_steps(4); assert!((rect(&h).center() - centred.center()).length() < 1.0, "{:?}", rect(&h)); + + // Past the left edge, about 100 px of the title row stays inside. + let title = h.get_by_label("Create Table").rect().center(); + drag(&mut h, title, title - vec2(900.0, 0.0)); + assert!((rect(&h).right() - 100.0).abs() < 2.0, "{:?}", rect(&h)); } } diff --git a/crates/ui-egui/src/modal_drag.rs b/crates/ui-egui/src/modal_drag.rs index 2828a58a8..aabe103d2 100644 --- a/crates/ui-egui/src/modal_drag.rs +++ b/crates/ui-egui/src/modal_drag.rs @@ -1,16 +1,24 @@ //! Modal windows (dialogs, About) that the user moves by dragging their title row. //! //! The modal stays anchored at the centre of the window and is shifted by an offset kept in -//! egui's temporary memory. A modal that was not shown in the previous pass starts centred. -//! egui keeps the whole modal inside the window, so its title row stays reachable. +//! egui's temporary memory. A modal that was not shown in the previous pass, or whose window +//! changed size, starts centred. An undragged modal keeps egui's constraint to the window; a +//! dragged one may go partly off-screen, but its title row stays reachable. use egui::{Align2, Context, CursorIcon, Id, Modal, Rect, Sense, Ui, Vec2, vec2}; +/// How much of the title row's width stays inside the window. +const KEEP_VISIBLE: f32 = 100.0; + #[derive(Clone, Copy, Default)] struct Placement { offset: Vec2, /// The pass the modal was last shown in: a gap means it was closed and opened again. pass: u64, + /// The window content size the offset belongs to. + content: Vec2, + /// From the top of the modal to the bottom of its title row. + title_height: f32, } /// A modal centred in the window and shifted by the user's drag. @@ -23,16 +31,20 @@ pub(crate) fn modal(ctx: &Context, area: Id, instance: Id) -> Modal { let content = ctx.content_rect(); let offset = ctx.data_mut(|d| { let p = d.get_temp_mut_or_default::<Placement>(instance); - if p.pass.saturating_add(1) < pass { - *p = Placement::default(); + if p.pass.saturating_add(1) < pass || p.content != content.size() { + *p = Placement { content: content.size(), ..Placement::default() }; } p.pass = pass; - if let Some(size) = size { - p.offset = clamp(p.offset, size, content); + if let Some(size) = size + && p.offset != Vec2::ZERO + { + p.offset = clamp(p.offset, size, p.title_height, content); } p.offset }); - Modal::new(area).area(Modal::default_area(area).anchor(Align2::CENTER_CENTER, offset)) + // Dragged: `clamp` keeps the title row reachable instead of egui keeping the whole modal inside. + let constrained = offset == Vec2::ZERO; + Modal::new(area).area(Modal::default_area(area).anchor(Align2::CENTER_CENTER, offset).constrain(constrained)) } /// Makes `rect`, the modal's title row, drag the modal opened with [`modal`] for `instance`. @@ -40,18 +52,29 @@ pub(crate) fn modal(ctx: &Context, area: Id, instance: Id) -> Modal { /// Widgets registered after this call sit on top of the drag area and keep their own input. pub(crate) fn title_drag(ui: &Ui, instance: Id, rect: Rect) { let resp = ui.interact(rect, instance.with("title_drag"), Sense::drag()); + let delta = if resp.dragged() { resp.drag_delta() } else { Vec2::ZERO }; + let top = ui.ctx().memory(|m| m.area_rect(ui.layer_id().id)).map_or(rect.top(), |r| r.top()); + ui.ctx().data_mut(|d| { + let p = d.get_temp_mut_or_default::<Placement>(instance); + p.offset += delta; + p.title_height = (rect.bottom() - top).max(0.0); + }); if resp.dragged() { - let delta = resp.drag_delta(); - ui.ctx().data_mut(|d| d.get_temp_mut_or_default::<Placement>(instance).offset += delta); ui.ctx().set_cursor_icon(CursorIcon::Grabbing); } else if resp.hovered() { ui.ctx().set_cursor_icon(CursorIcon::Grab); } } -/// Limits `offset` to the range in which a modal of `size` fits inside `content`, the range egui -/// constrains it to, so dragging back from beyond an edge moves it at once. -fn clamp(offset: Vec2, size: Vec2, content: Rect) -> Vec2 { - let room = ((content.size() - size) * 0.5).max(Vec2::ZERO); - vec2(offset.x.min(room.x).max(-room.x), offset.y.min(room.y).max(-room.y)) +/// Limits `offset` so the title row of a modal of `size` stays inside `content` vertically and +/// [`KEEP_VISIBLE`] of its width stays inside horizontally. A modal taller than the window keeps +/// its top edge inside. +fn clamp(offset: Vec2, size: Vec2, title_height: f32, content: Rect) -> Vec2 { + let centred = Align2::CENTER_CENTER.align_size_within_rect(size, content); + let keep = KEEP_VISIBLE.min(size.x); + let x_lo = content.left() + keep - size.x - centred.left(); + let x_hi = content.right() - keep - centred.left(); + let y_lo = content.top() - centred.top(); + let y_hi = content.bottom() - title_height.min(size.y) - centred.top(); + vec2(offset.x.min(x_hi).max(x_lo), offset.y.min(y_hi).max(y_lo)) } From a59919bd2456ea638b9e552220bcad6829d1e63f Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Tue, 13 Oct 2026 14:26:00 +0200 Subject: [PATCH 136/238] feat(ui): Text Wrap panel with four offsets, link and Invert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InDesign's Text Wrap panel has the five wrap buttons with Invert, then Top, Left, Bottom and Right offsets around a link button that makes them all the same. DesignCraft's panel had a single Offset field that always showed 0 (the object's offset was never read), and Invert only in Properties. - SelInfo carries the wrap offsets; the panel shows each side's value in the document's units and sets one side, or all four while linked (linked by default when they're equal). - Invert sits next to the wrap buttons; fields are disabled without a wrap. LYH Studio episode 14 (11:13) opens Window › Text Wrap to give the quote a Jump Object wrap. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 1 + crates/ui-egui/src/panels/mod.rs | 3 + crates/ui-egui/src/panels/properties.rs | 109 ++++++++++++++++++++++-- 3 files changed, 105 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..0a1a141e9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-13** · The Text Wrap panel is laid out as InDesign's: the wrap buttons with Invert, and the four offsets (Top, Left, Bottom, Right) showing the object's values, with the link button that sets them all the same. It used to show a single offset, always 0. No readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 581f6747e..329161e71 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -43,6 +43,8 @@ pub struct SelInfo { /// Uniform corner (shape, size) — the first corner. pub corner: (designcraft_geom::corners::CornerShape, f64), pub wrap_invert: bool, + /// Text wrap offsets: top, left, bottom, right. + pub wrap_offsets: [f64; 4], pub locked: bool, } @@ -98,6 +100,7 @@ pub fn sel_info(app: &DesignApp) -> Option<SelInfo> { .unwrap_or_else(|| "solid".into()), corner: (first.corners.corners[0].shape, first.corners.corners[0].size), wrap_invert: first.wrap.invert, + wrap_offsets: first.wrap.offsets, locked: first.locked, }) } diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index d9c74e40d..0729bd9a3 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1757,27 +1757,76 @@ fn arrow_preview(ui: &mut egui::Ui, a: Arrowhead, start: bool, color: egui::Colo pub fn wrap_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let info = sel_info(app); + let mode = info.as_ref().map_or("none", |i| i.wrap); + // As InDesign's Text Wrap panel: the five wrap buttons and Invert, then the four offsets + // (top and bottom, left and right) with the link button that makes them all the same. ui.horizontal(|ui| { - for (icon, mode, tip) in [ + for (icon, m, tip) in [ ("wrap-none", "none", "No Text Wrap"), ("wrap-bbox", "boundingBox", "Wrap Around Bounding Box"), ("wrap-contour", "contour", "Wrap Around Object Shape"), ("wrap-jump", "jumpObject", "Jump Object"), ("wrap-next", "jumpToNextColumn", "Jump to Next Column"), ] { - let on = info.as_ref().is_some_and(|i| i.wrap == mode); - if icons::button(ui, icon, 26.0, on, crate::i18n::tr(&app.ui.language, tip)).clicked() { - let _ = app.run("object.textWrap", json!({"mode": mode})); + if icons::button(ui, icon, 26.0, mode == m, crate::i18n::tr(&app.ui.language, tip)).clicked() { + let _ = app.run("object.textWrap", json!({"mode": m})); } } + ui.add_space(6.0); + let mut inv = info.as_ref().is_some_and(|i| i.wrap_invert); + let r = ui.add_enabled_ui(mode != "none", |ui| widgets::checkbox(ui, &mut inv, crate::i18n::tr(&app.ui.language, "Invert"))).inner; + if r.changed() { + let _ = app.run("object.textWrap", json!({"mode": mode, "invert": inv})); + } }); let u = units(app); - ui.horizontal(|ui| { - caption(ui, crate::i18n::tr(&app.ui.language, "Offset")); - if let Some(v) = measure(ui, "wo", Some(0.0), u, 60.0) { - let _ = app.run("object.textWrap", json!({"mode": info.as_ref().map(|i| i.wrap).unwrap_or("boundingBox"), "offset": v})); + let offsets = info.as_ref().map(|i| i.wrap_offsets); + // Linked while the offsets are all the same, until the link is clicked. + let linked_id = egui::Id::new("wrap_offsets_linked"); + let linked = ui.data(|d| d.get_temp::<bool>(linked_id)).unwrap_or_else(|| offsets.is_none_or(|o| o.iter().all(|v| (v - o[0]).abs() < 1e-9))); + let mut set: Option<[f64; 4]> = None; + ui.add_enabled_ui(mode != "none" && info.is_some(), |ui| { + // Two rows of fixed-width cells: Top | Left, then Bottom | Right; the link between them. + let cell = |ui: &mut Ui, w: f32, label: &str| { + let x0 = ui.cursor().min.x; + caption(ui, crate::i18n::tr(&app.ui.language, label)); + ui.add_space((w - (ui.cursor().min.x - x0)).max(0.0)); + }; + // The second column starts where the first row's does (after the link button). + let mut col2_x: Option<f32> = None; + for (r, row) in [[(0, "Top"), (1, "Left")], [(2, "Bottom"), (3, "Right")]].into_iter().enumerate() { + ui.horizontal(|ui| { + for (k, (side, label)) in row.into_iter().enumerate() { + if k == 1 { + match col2_x { + Some(x) => ui.add_space((x - ui.cursor().min.x).max(0.0)), + None => col2_x = Some(ui.cursor().min.x), + } + } + cell(ui, if k == 0 { 56.0 } else { 40.0 }, label); + if let Some(v) = measure(ui, &format!("wo{side}"), offsets.map(|o| o[side]), u, 60.0) { + let mut o = offsets.unwrap_or([0.0; 4]); + if linked { + o = [v; 4]; + } else { + o[side] = v; + } + set = Some(o); + } + if k == 0 && r == 0 { + let tip = crate::i18n::tr(&app.ui.language, "Make all settings the same"); + let link = widgets::icon_toggle_sized(ui, if linked { "link" } else { "link-broken" }, linked, tip, vec2(22.0, 21.0)); + if link.clicked() { + ui.data_mut(|d| d.insert_temp(linked_id, !linked)); + } + } + } + }); } }); + if let Some(o) = set { + let _ = app.run("object.textWrap", json!({"mode": mode, "offset": o})); + } } /// The Pathfinder operations, in the order of InDesign's Pathfinder buttons. @@ -2480,3 +2529,47 @@ mod effects_panel_tests { assert_eq!(st.history.undo.len(), undo, "disabled controls do not create undo steps"); } } + +#[cfg(test)] +mod wrap_panel_tests { + use super::*; + + /// One frame of the Text Wrap panel: the text painted and where. + fn frame(app: &mut DesignApp, ctx: &egui::Context, events: Vec<egui::Event>) -> Vec<(String, egui::Rect)> { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(360.0, 300.0)); + let time = ctx.input(|i| i.time) + 1.0 / 60.0; + let mut out = + ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(time), events, ..Default::default() }, |ui| wrap_panel(app, ui)); + out.textures_delta.clear(); + out.shapes + .into_iter() + .filter_map(|s| if let egui::Shape::Text(t) = s.shape { Some((t.galley.job.text.clone(), t.visual_bounding_rect())) } else { None }) + .collect() + } + + #[test] + fn the_text_wrap_panel_shows_each_offset_and_invert() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 144, 144]})).unwrap()["id"].as_u64().unwrap(); + app.run("object.textWrap", json!({"mode": "boundingBox", "offset": [1, 2, 3, 4]})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx, "en"); + // Top and Left, then Bottom and Right, each with its own value (picas here: 0p1 = 1 pt). + let t: Vec<String> = frame(&mut app, &ctx, vec![]).into_iter().map(|(t, _)| t).filter(|t| !t.is_empty()).collect(); + assert_eq!(t, ["Invert", "Top", "0p1", "Left", "0p2", "Bottom", "0p3", "Right", "0p4"]); + // Invert, next to the wrap buttons. + let pos = frame(&mut app, &ctx, vec![]).into_iter().find(|(t, _)| t == "Invert").unwrap().1.center(); + for pressed in [true, false] { + frame( + &mut app, + &ctx, + vec![ + egui::Event::PointerMoved(pos), + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }, + ], + ); + } + assert!(app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().wrap.invert); + } +} From 027c986b65c85d73a45cbc4bd47fcc57bc25b2d7 Mon Sep 17 00:00:00 2001 From: haranishi <223786623+haranishi@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:54:37 +0900 Subject: [PATCH 137/238] =?UTF-8?q?test:=20Links=E3=83=91=E3=83=8D?= =?UTF-8?q?=E3=83=AB=E3=81=AE=E3=83=87=E3=83=90=E3=83=83=E3=82=B0=E7=94=A8?= =?UTF-8?q?=E6=9B=B8=E3=81=8D=E5=87=BA=E3=81=97=E3=82=92=E9=99=A4=E5=8E=BB?= =?UTF-8?q?=20(#703)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/ui-egui/src/panels/properties.rs | 22 +--------------------- 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index aa612313c..33bc24253 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -2306,10 +2306,8 @@ mod tests { } fn links_harness(app: DesignApp, width: f32) -> Harness<'static, DesignApp> { - let builder = Harness::builder().with_size(vec2(width, 600.0)); - let builder = if std::env::var_os("DESIGNCRAFT_LINKS_FIXTURES").is_some() { builder.wgpu() } else { builder }; let mut ready = false; - let mut h = builder.build_ui_state( + let mut h = Harness::builder().with_size(vec2(width, 600.0)).build_ui_state( move |ui, app: &mut DesignApp| { if !ready { crate::theme::install_fonts(ui.ctx(), ""); @@ -2326,19 +2324,6 @@ mod tests { h } - fn capture_links(h: &mut Harness<'_, DesignApp>, name: &str) { - let Some(directory) = std::env::var_os("DESIGNCRAFT_LINKS_FIXTURES").map(std::path::PathBuf::from) else { return }; - std::fs::create_dir_all(&directory).unwrap(); - h.render().unwrap().save(directory.join(format!("round2-links569-{name}.png"))).unwrap(); - if name == "same-named-sources" { - let doc = &h.state().session.active().unwrap().doc; - let mut value = serde_json::to_value(doc).unwrap(); - value["assetData"] = - Value::Object(doc.assets.iter().map(|(id, a)| (id.0.to_string(), json!(designcraft_engine::cmd::base64_encode(&a.data)))).collect()); - std::fs::write(directory.join("round2-links569-synthetic.designcraft"), serde_json::to_vec(&value).unwrap()).unwrap(); - } - } - #[test] fn links_panel_distinguishes_same_named_sources_and_keeps_link_actions() { let first = "/project/assets/studio-a/photo.svg"; @@ -2347,10 +2332,8 @@ mod tests { let mut h = links_harness(app, 320.0); assert_eq!(h.get_all_by_label("photo.svg").count(), 3); assert!(h.query_by_label(first).is_some(), "the first source path is visible in the row"); - capture_links(&mut h, "same-named-sources"); h.get_by_label(second).click(); h.run_steps(3); - capture_links(&mut h, "selected-studio-b"); h.get_by_label("Go To").click(); h.run_steps(3); assert_eq!(h.state().session.active().unwrap().selection.items, vec![items[1]]); @@ -2361,10 +2344,8 @@ mod tests { assert!(h.query_by_label(second).is_none(), "an embedded graphic no longer shows a source path"); assert_eq!(h.get_all_by_label("Embedded").count(), 2); assert_eq!(h.get_all_by_label("photo.svg").count(), 3); - capture_links(&mut h, "embedded"); h.get_by_label(first).click(); h.run_steps(3); - capture_links(&mut h, "selected-studio-a"); h.get_by_label("Go To").click(); h.run_steps(3); assert_eq!(h.state().session.active().unwrap().selection.items, vec![items[0]]); @@ -2382,7 +2363,6 @@ mod tests { h.get_by_label(path).click(); h.run_steps(3); assert!(h.output().shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Text(text) if text.galley.job.text == path && !text.galley.elided && text.visual_bounding_rect().right() <= 240.0)), "the selected path is painted in full and wraps inside the panel"); - capture_links(&mut h, "long-path-narrow"); } #[test] From d227ee84e9c4bd98edb4614330da92b2e635814c Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Tue, 13 Oct 2026 13:00:34 +0000 Subject: [PATCH 138/238] fix(script): only a well-formed `$N` reference is replaced; `$$` is a literal `$` (#749) --- ROADMAP.md | 1 + apps/designcraft-cli/src/main.rs | 2 + crates/engine/src/script.rs | 64 +++++++++++++++++++++++++++----- crates/mcp/src/tools.rs | 3 +- docs/agents.md | 3 +- 5 files changed, 61 insertions(+), 12 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..6badabc04 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-13** · Command scripts look up only a well-formed `$N.path` / `$last.path` reference; other strings starting with `$`, such as a GREP replacement of `$1` and a nonbreaking space, stay as written, and `$$` writes a literal `$` (#749). No readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs index dd62cd91d..4ae49cc00 100644 --- a/apps/designcraft-cli/src/main.rs +++ b/apps/designcraft-cli/src/main.rs @@ -55,6 +55,8 @@ References to earlier results (in string parameters): comments and blank lines are not steps. `$last` is the previous step. Paths go through result fields and array indices: "$1.story", "$3.ids.0". "${N.path}" inside a longer string, replaced by the value's text: "Frame ${0.id}". + Any other string starting with `$` stays as written ("$1-$2", "$5 off"). + "$$" 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. Most commands ignore parameters they do not know instead of rejecting them: check names diff --git a/crates/engine/src/script.rs b/crates/engine/src/script.rs index d2304e651..4a66ff87f 100644 --- a/crates/engine/src/script.rs +++ b/crates/engine/src/script.rs @@ -5,7 +5,8 @@ //! becomes the JSON value at `path` in the result of step `N` (0-based): after //! `frame.create → {"id": 8, "story": 9}`, `{"story": "$0.story"}` passes `9`. Paths are dotted //! keys and array indices (`$2.ids.0`). Inside longer strings `${N.path}` is replaced by the -//! value's text (`"Frame ${0.id}"`). +//! value's text (`"Frame ${0.id}"`). Any other string starting with `$` is plain text (`"$1-$2"`, +//! `"$5 off"`), and `$$` anywhere writes a literal `$` (`"$$1"` passes `$1`, a GREP group). //! //! **Script text** ([`parse`]): a JSON array of steps, JSON lines (`{"command": …, "params": …}`), //! or compact lines `command.id {json}` / `command.id={json}` / `command.id`; blank lines and @@ -92,27 +93,44 @@ fn short(v: &Value) -> String { if s.len() > 120 { format!("{}…", &s[..s.floor_char_boundary(120)]) } else { s } } +/// Whether `r` (the text after `$`) is a whole reference: digits or `last`, then zero or more +/// `.segment` of ASCII letters, digits, `_` or `-`. +fn is_reference(r: &str) -> bool { + let mut parts = r.split('.'); + let head = parts.next().unwrap_or(""); + let head_ok = head == "last" || (!head.is_empty() && head.bytes().all(|b| b.is_ascii_digit())); + head_ok && parts.all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')) +} + /// Replace references in `params` with values from earlier `results`. pub fn resolve(params: &Value, results: &[Value]) -> Result<Value, String> { Ok(match params { Value::String(s) => { - if let Some(r) = s.strip_prefix('$').filter(|r| !r.is_empty() && !r.contains(['{', ' '])) { + if let Some(r) = s.strip_prefix('$').filter(|r| is_reference(r)) { return lookup(r, results); } - if !s.contains("${") { + if !s.contains("${") && !s.contains("$$") { return Ok(params.clone()); } let mut out = String::new(); let mut rest = s.as_str(); - while let Some(i) = rest.find("${") { + while let Some(i) = rest.find('$') { out.push_str(&rest[..i]); - let end = rest[i..].find('}').ok_or_else(|| format!("unclosed `${{` in {s:?}"))? + i; - let v = lookup(&rest[i + 2..end], results)?; - match v { - Value::String(t) => out.push_str(&t), - other => out.push_str(&other.to_string()), + let after = &rest[i + 1..]; + if let Some(tail) = after.strip_prefix('$') { + out.push('$'); + rest = tail; + } else if let Some(inner) = after.strip_prefix('{') { + let end = inner.find('}').ok_or_else(|| format!("unclosed `${{` in {s:?}"))?; + match lookup(&inner[..end], results)? { + Value::String(t) => out.push_str(&t), + other => out.push_str(&other.to_string()), + } + rest = &inner[end + 1..]; + } else { + out.push('$'); + rest = after; } - rest = &rest[end + 1..]; } out.push_str(rest); Value::String(out) @@ -196,6 +214,32 @@ mod tests { assert_eq!(r, json!({"story": 9, "id": 5, "label": "Frame 8 of A", "keep": "$", "list": ["A"]})); assert!(resolve(&json!({"x": "$5.id"}), &results).unwrap_err().contains("hasn't run")); assert!(resolve(&json!({"x": "$0.nope"}), &results).unwrap_err().contains("no `nope`")); + assert!(resolve(&json!({"x": "Frame ${0.id"}), &results).unwrap_err().contains("unclosed")); + } + + /// GREP replacements start with `$` too; only a well-formed reference is looked up (#749). + #[test] + fn only_well_formed_references_resolve() { + let results = vec![json!({"id": 8})]; + let p = json!({"a": "$1\u{a0}", "b": "$1-$2", "c": "$5 off", "d": "$$1", "e": "$${0.id}", "f": "$0.id", "g": "${0.id}x", "h": "$1$$", "i": "$1."}); + let r = resolve(&p, &results).unwrap(); + assert_eq!(r, json!({"a": "$1\u{a0}", "b": "$1-$2", "c": "$5 off", "d": "$1", "e": "${0.id}", "f": 8, "g": "8x", "h": "$1$", "i": "$1."})); + } + + /// Czech typesetting: a nonbreaking space after one-letter prepositions. + #[test] + fn a_script_grep_change_keeps_its_group_reference() { + let mut s = crate::Session::new(); + let steps = vec![ + Step { command: "file.new".into(), params: json!({}) }, + Step { command: "frame.create".into(), params: json!({"rect": [36, 36, 300, 300], "content": "text", "text": "a v Praze"}) }, + Step { command: "find.change".into(), params: json!({"find": r"\b([av]) ", "change": "$1\u{a0}", "grep": true, "caseSensitive": true}) }, + Step { command: "story.get".into(), params: json!({"story": "$1.story"}) }, + ]; + let r = s.run_script(&steps); + assert!(r.failed.is_none(), "{r:?}"); + assert_eq!(r.results[2]["count"], 2); + assert_eq!(r.results[3]["text"], "a\u{a0}v\u{a0}Praze"); } #[test] diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index c51bac8ed..ccb6bd35e 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -118,7 +118,8 @@ pub fn tool_definitions() -> Vec<Value> { and message (earlier commands stay applied; use edit.undo to revert them). Later steps can use earlier results: a \ parameter string \"$N.path\" is replaced by that value of step N's result (0-based; \"$last.path\" for the previous \ step), e.g. after frame.create → {\"id\": 8, \"story\": 9}, {\"story\": \"$0.story\"} passes 9; \"${N.path}\" \ - interpolates into longer strings. Give `commands`, or `script` text (one `command.id {json}` per line).", + interpolates into longer strings. Other strings starting with $ stay as written; \"$$\" is a literal $ \ + (\"$$1\" passes the GREP group $1). Give `commands`, or `script` text (one `command.id {json}` per line).", obj( json!({ "commands": { diff --git a/docs/agents.md b/docs/agents.md index 5115654a2..b737ebb81 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -24,7 +24,8 @@ A script is one command per line (`command.id {json params}`; `#` comments), JSO `{command, params}`, in UTF-8 with or without a byte order mark. A parameter string `"$N.path"` becomes that value of step N's result: N is the 0-based step index (comments and blank lines are not steps, `$last` is the previous step) and the path goes through result fields and array indices (`"$1.story"`, `"$3.ids.0"`); `"${N.path}"` -interpolates inside longer strings. In text parameters `\n` starts a new paragraph and `\u2028` is a forced line +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`. `designcraft-cli script --help` prints this reference. From 7a30e5427e754e49e2f8a4f148db7ef96853c56f Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Tue, 13 Oct 2026 15:58:05 +0200 Subject: [PATCH 139/238] fix(render): radial gradients stretch with the object's box, as in InDesign InDesign draws a radial gradient in the object's bounding box: on a non-square frame it's an ellipse reaching the frame's sides, and a Gradient Swatch tool drag keeps those proportions. LYH Studio episode 6 (3:16) gives the left page's background a radial gradient: magenta in the middle, cyan out to every edge. DesignCraft drew a circle reaching only the longer side, so a portrait page stayed purple at its sides. - doc: `Fill::radial_ellipse(bounds)`: centre and radii (default: the box's centre and half its width and height; with a tool vector, its start, and its length measured with the box made square). - render: the radial paint is that circle, stretched by the paint transform; PDF: the shading's transform does the same. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 1 + crates/doc/src/item.rs | 16 ++++++++++ crates/engine/src/cmd/style.rs | 37 +++++++++++++++++++++ crates/pdf/src/export.rs | 13 ++++---- crates/render/src/lib.rs | 13 ++++---- crates/render/tests/gradients.rs | 55 ++++++++++++++++++++++++++++++++ docs/target-app-parity.md | 2 +- 7 files changed, 122 insertions(+), 15 deletions(-) create mode 100644 crates/render/tests/gradients.rs diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..6d2059781 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-13** · Radial gradients stretch with the object's box, as InDesign draws them: by default an ellipse reaching the frame's sides (it was a circle reaching only the longer side), and a Gradient Swatch tool drag keeps the box's proportions; on screen and in PDF. No readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 0a701998e..2962d374d 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -32,6 +32,22 @@ impl Default for Fill { } impl Fill { + /// A radial gradient's ellipse on an object whose bounds (in its own space) are `b`: centre and + /// horizontal and vertical radii. As in InDesign, a radial gradient stretches with the object's + /// box: by default it's centred and reaches the box's sides (an ellipse in a non-square box); + /// with a Gradient Swatch tool vector it's centred at the vector's start, and the vector's + /// length, measured with the box made square, sets its size. + pub fn radial_ellipse(&self, b: designcraft_geom::Rect) -> (designcraft_geom::Point, f64, f64) { + let k = if b.width() > 1e-9 && b.height() > 1e-9 { b.height() / b.width() } else { 1.0 }; + match self.gradient_vector { + Some([x0, y0, x1, y1]) => { + let r = designcraft_geom::Vec2::new(x1 - x0, (y1 - y0) / k).hypot(); + (designcraft_geom::Point::new(x0, y0), r, r * k) + } + None => (b.center(), b.width().max(1e-9) / 2.0, b.height().max(1e-9) / 2.0), + } + } + pub fn none() -> Self { Fill { swatch: designcraft_color::swatch::NONE.into(), tint: 1.0, gradient_angle: None, gradient_vector: None, overprint: false } } diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index c4d20f099..39025c2af 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -1751,3 +1751,40 @@ mod sample_color_tests { } } } + +#[cfg(test)] +mod radial_gradient_tests { + use serde_json::json; + + /// White at the side and the top of a 100×200 radial (black → white) rectangle at (50, 20) on + /// a 200×240 page, rendered at 1 px/pt. + fn sides(img: &designcraft_render::Rendered) -> (u8, u8, u8) { + (img.pixel(100, 120)[0], img.pixel(52, 120)[0], img.pixel(100, 22)[0]) + } + + #[test] + fn a_radial_gradient_is_the_objects_ellipse_on_screen_and_in_pdf() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 240, "facingPages": false})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [50, 20, 150, 220], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.stroke", &json!({"ids": [id], "swatch": "[None]"})).unwrap(); + s.execute( + "object.gradient", + &json!({"ids": [id], "kind": "radial", "stops": [{"location": 0, "color": "#000000"}, {"location": 100, "color": "#ffffff"}]}), + ) + .unwrap(); + let d = s.doc().unwrap().doc.clone(); + let mut r = designcraft_render::Renderer::new(); + let (c, side, top) = sides(&r.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap()); + assert!(c < 30 && side > 220 && top > 220, "screen: centre {c}, side {side}, top {top}"); + // The PDF, placed back and drawn: the same ellipse. + let pdf = designcraft_pdf::export_pdf(&d, &s.cache, &Default::default()).unwrap(); + let mut p = crate::Session::new(); + p.execute("file.new", &json!({"width": 200, "height": 240, "facingPages": false})).unwrap(); + p.execute("file.place", &json!({"base64": crate::cmd::file::base64_encode(&pdf), "name": "radial.pdf", "x": 0, "y": 0, "width": 200})) + .unwrap(); + let d = p.doc().unwrap().doc.clone(); + let (c, side, top) = sides(&r.render_page(&d, &p.cache, 0, 1.0, false, &Default::default()).unwrap()); + assert!(c < 30 && side > 220 && top > 220, "pdf: centre {c}, side {side}, top {top}"); + } +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index dcf8abb83..f75c652d0 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -1015,11 +1015,10 @@ impl Exporter<'_> { let v = fill.gradient_vector; return Some(match kind { GradientKind::Radial => { - let (c, r) = match v { - Some([x0, y0, x1, y1]) => (designcraft_geom::Point::new(x0, y0), Vec2::new(x1 - x0, y1 - y0).hypot()), - None => (c, bounds.width().max(bounds.height()) / 2.0), - }; - let r = r.max(1e-3) as f32; + // A circle stretched into the object's ellipse, as on screen. + let (c, rx, ry) = fill.radial_ellipse(bounds); + let rx = rx.max(1e-3); + let stretch = Affine::translate(c.to_vec2()) * Affine::scale_non_uniform(1.0, ry / rx) * Affine::translate(-c.to_vec2()); let (cx, cy) = (c.x as f32, c.y as f32); RadialGradient { fx: cx, @@ -1027,8 +1026,8 @@ impl Exporter<'_> { fr: 0.0, cx, cy, - cr: r, - transform: Transform::identity(), + cr: rx as f32, + transform: tf(stretch), spread_method: SpreadMethod::Pad, stops, anti_alias: false, diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 2cc5c548f..6746e70d8 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -886,15 +886,14 @@ fn set_fill_paint(ctx: &mut RenderContext, doc: &Document, fill: &designcraft_do } let c = bounds.center(); let grad = match (g.kind, fill.gradient_vector) { - (designcraft_color::GradientKind::Radial, Some([x0, y0, x1, y1])) => { - let r = Vec2::new(x1 - x0, y1 - y0).hypot().max(1e-3); - peniko::Gradient::new_radial((x0, y0), r as f32).with_stops(stops.as_slice()) + // Radial: a circle stretched into the object's ellipse (the paint transform). + (designcraft_color::GradientKind::Radial, _) => { + let (c, rx, ry) = fill.radial_ellipse(bounds); + let rx = rx.max(1e-3); + ctx.set_paint_transform(Affine::translate(c.to_vec2()) * Affine::scale_non_uniform(1.0, ry / rx) * Affine::translate(-c.to_vec2())); + peniko::Gradient::new_radial(c, rx as f32).with_stops(stops.as_slice()) } (_, Some([x0, y0, x1, y1])) => peniko::Gradient::new_linear((x0, y0), (x1, y1)).with_stops(stops.as_slice()), - (designcraft_color::GradientKind::Radial, None) => { - let r = bounds.width().max(bounds.height()) / 2.0; - peniko::Gradient::new_radial(c, r as f32).with_stops(stops.as_slice()) - } _ => { let a = angle.unwrap_or(0.0).to_radians(); let half = (bounds.width() * a.cos().abs() + bounds.height() * a.sin().abs()) / 2.0; diff --git a/crates/render/tests/gradients.rs b/crates/render/tests/gradients.rs new file mode 100644 index 000000000..619d3103b --- /dev/null +++ b/crates/render/tests/gradients.rs @@ -0,0 +1,55 @@ +//! Gradient fills: InDesign stretches a radial gradient with the object's box. + +use designcraft_color::swatch::{Swatch, SwatchValue}; +use designcraft_color::{Color, Gradient, GradientKind, GradientStop}; +use designcraft_compose::Cache; +use designcraft_doc::build::NewDocument; +use designcraft_doc::{Document, Fill, Item, ItemId, Shape, SpreadRef}; +use designcraft_geom::{Affine, Rect, Vec2, shapes}; +use designcraft_render::{Placed, RenderOptions, Rendered, Renderer}; + +/// A 100×200 rectangle at (50, 20) filled with a black-to-white radial gradient. +fn portrait(vector: Option<[f64; 4]>) -> Document { + let mut d = Document::new(&NewDocument { width: 200.0, height: 240.0, facing_pages: false, ..Default::default() }); + let stop = |offset: f32, color: Color| GradientStop { offset, color, opacity: 1.0, midpoint: 0.5 }; + d.swatches.push(Swatch { + name: "Radial".into(), + value: SwatchValue::Gradient { + gradient: Gradient { kind: GradientKind::Radial, stops: vec![stop(0.0, Color::BLACK), stop(1.0, Color::WHITE)] }, + }, + locked: false, + named: true, + hidden: false, + }); + let id = ItemId(d.alloc()); + let mut it = Item::new(id, d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(50.0, 20.0, 150.0, 220.0))); + it.fill = Fill { swatch: "Radial".into(), gradient_vector: vector, ..Fill::none() }; + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + d +} + +fn render(d: &Document) -> Rendered { + let mut r = Renderer::new(); + r.threads = 0; + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + r.render(d, &Cache::new(), &[Placed { spread: SpreadRef::Doc(0), xf: Affine::translate(Vec2::ZERO) }], 200, 240, Affine::IDENTITY, &opts) +} + +fn grey(img: &Rendered, x: u32, y: u32) -> u8 { + img.pixel(x, y)[0] +} + +#[test] +fn a_radial_gradient_stretches_with_its_object() { + // Centred, it reaches white at the sides and at the top and bottom alike (an ellipse). + let img = render(&portrait(None)); + assert!(grey(&img, 100, 120) < 20, "black at the centre: {}", grey(&img, 100, 120)); + let (side, top) = (grey(&img, 52, 120), grey(&img, 100, 22)); + assert!(side > 225 && top > 225, "white at the side ({side}) and the top ({top})"); + // Half-way to the side and half-way to the top: the same grey. + let (hs, ht) = (grey(&img, 75, 120), grey(&img, 100, 70)); + assert!(hs.abs_diff(ht) < 12, "half-way: side {hs}, top {ht}"); + // A Gradient Swatch tool vector to the right side: the same ellipse. + let dragged = render(&portrait(Some([100.0, 120.0, 150.0, 120.0]))); + assert!(grey(&dragged, 100, 70).abs_diff(ht) < 12 && grey(&dragged, 75, 120).abs_diff(hs) < 12); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index eb7d7e5c1..1a464e7d2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -421,7 +421,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - View › Display Performance: Fast (grey boxes, no effects, ⌥⇧⌘Z), Typical (72 ppi proxies, ⌥⌘Z), High Quality (⌥⌘H, our default — the renderer is fast enough). - OpenType menu (Character panel): discretionary ligatures, fractions, ordinal, swash, titling, contextual alternates, slashed zero, the four figure styles and stylistic sets 1–20; `type.openType`; mapped to IDML's OTF attributes. - Add Anchor Point (=), Delete Anchor Point (-) and Convert Direction Point (⇧C) tools (click toggles smooth/corner, drag pulls out handles), with `path.addAnchor`, `path.deleteAnchor`, `path.convertAnchor`. -- Gradients: the Gradient Swatch tool (G) drags a gradient's start and end across objects (Shift: 45°); the Gradient panel sets type, angle, reverse and edits stops on a ramp (drag, add, drag off to remove, location, colour). Edited gradients are unnamed swatches; `object.gradient`; the vector round-trips through IDML. +- Gradients: the Gradient Swatch tool (G) drags a gradient's start and end across objects (Shift: 45°); the Gradient panel sets type, angle, reverse and edits stops on a ramp (drag, add, drag off to remove, location, colour). Edited gradients are unnamed swatches; `object.gradient`; the vector round-trips through IDML. 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). - Document Setup (⌥⌘P): intent, number of pages, start page # (an even start begins with a left page), facing pages, size presets and orientation, bleed and slug per edge; `layout.documentSetup {}` reports the setup. - 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. - 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. From 029a911bf49b0162eb28137818ace58971f4fd77 Mon Sep 17 00:00:00 2001 From: Gert Pellin <gert@pellin.be> Date: Tue, 13 Oct 2026 16:11:39 +0200 Subject: [PATCH 140/238] EPUB/HTML: anchored objects are exported where they are anchored (#766) story_html left out each anchored object's mark and the object with it, and reading_order only sees page items, so an image anchored in a story was in neither the EPUB nor the HTML export. The story's text is now split at the object marks, and each anchored graphic is written in place: inline in the paragraph, an above-line object as a block span with its alignment. The graphic markup (and image or media file registration) is shared with free graphics (graphic_html). Fixes #766 --- crates/epub/src/lib.rs | 213 +++++++++++++++++++++++++++++++---------- 1 file changed, 160 insertions(+), 53 deletions(-) diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index 77b509d0b..ebb9d3de5 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -425,8 +425,14 @@ fn class_selector(tag: &str, class: &str) -> Option<String> { Some(selector) } -/// One story as XHTML paragraphs. +/// One story as XHTML paragraphs (anchored objects left out; see [`story_html_with`]). pub fn story_html(doc: &Document, sid: StoryId) -> String { + story_html_with(doc, sid, &mut |_| String::new()) +} + +/// One story as XHTML paragraphs, each anchored object written where it is anchored as the +/// markup `object` returns for it. +fn story_html_with(doc: &Document, sid: StoryId, object: &mut dyn FnMut(&designcraft_doc::AnchoredObject) -> String) -> String { let Some(st) = doc.story(sid) else { return String::new() }; let (pc, cc) = (para_classes(&doc.styles), char_classes(&doc.styles)); let mut out = String::new(); @@ -451,18 +457,32 @@ pub fn story_html(doc: &Document, sid: StoryId) -> String { continue; } let raw = &st.text[a..b]; - let t = if raw.chars().any(|c| designcraft_doc::vars::var_index(c).is_some()) { - let sub: String = raw - .chars() - .map(|c| match designcraft_doc::vars::var_index(c) { - Some(i) => doc.variable_value(i, None).unwrap_or_default(), - None => c.to_string(), - }) - .collect(); - story_markup(&sub) - } else { - story_markup(raw) + // Text with variables substituted; each anchored object's markup in place of its mark. + let text = |seg: &str| { + if seg.chars().any(|c| designcraft_doc::vars::var_index(c).is_some()) { + let sub: String = seg + .chars() + .map(|c| match designcraft_doc::vars::var_index(c) { + Some(i) => doc.variable_value(i, None).unwrap_or_default(), + None => c.to_string(), + }) + .collect(); + story_markup(&sub) + } else { + story_markup(seg) + } }; + let mut mark = st.text.get(..a).map_or(0, |t| t.matches(designcraft_doc::OBJECT_MARK).count()); + let mut t = String::new(); + for (i, seg) in raw.split(designcraft_doc::OBJECT_MARK).enumerate() { + if i > 0 { + if let Some(o) = st.objects.get(mark) { + t.push_str(&object(o)); + } + mark += 1; + } + t.push_str(&text(seg)); + } let (ctag, cls) = if f.style != story::NO_CHAR_STYLE { let (tag, class) = export_mapping(&doc.styles, &f.style, true, &cc); (tag, format!(" class=\"{}\"", xml_attr(&class))) @@ -563,6 +583,50 @@ fn ext(mime: &str) -> &'static str { } } +/// The markup for one graphic item: an `<img>`, or an HTML5 player for video and sound (with +/// `true`), its file registered in `images` under the name `img_src` is given. `None` for an +/// item that isn't a graphic or whose asset has no data. +fn graphic_html<'a>( + doc: &'a Document, + it: &Item, + images: &mut BTreeMap<String, (&'a str, &'a [u8])>, + img_src: &dyn Fn(&str, &str, &[u8]) -> String, +) -> Option<(String, bool)> { + let Content::Graphic(g) = &it.content else { return None }; + let a = doc.assets.get(&g.asset)?; + if a.data.is_empty() { + return None; + } + let mime = safe_mime(&a.mime); + // Video and sound: an HTML5 player (with the poster, when there is one). + if let Some(kind) = designcraft_doc::media_kind(mime) { + let name = format!("media/{}.{}", g.asset.0, ext(mime)); + images.insert(name.clone(), (mime, a.data.as_slice())); + let m = it.media.clone().unwrap_or_default(); + let mut attrs = String::new(); + for (on, attr) in [(m.controls, " controls=\"controls\""), (m.play_on_page_load, " autoplay=\"autoplay\""), (m.looping, " loop=\"loop\"")] { + if on { + attrs.push_str(attr); + } + } + if let Some(pa) = m.poster.and_then(|p| doc.assets.get(&p)) { + let poster_mime = safe_mime(&pa.mime); + let pn = format!("images/{}.{}", pa.id.0, ext(poster_mime)); + images.insert(pn.clone(), (poster_mime, pa.data.as_slice())); + if kind == "video" { + attrs.push_str(&format!(" poster=\"{}\"", xml_attr(&img_src(&pn, poster_mime, &pa.data)))); + } + } + let markup = format!("<{kind} src=\"{}\"{attrs}>{}</{kind}>", xml_attr(&img_src(&name, mime, &a.data)), xml_text(&a.name)); + return Some((markup, true)); + } + let name = format!("images/{}.{}", g.asset.0, ext(mime)); + images.insert(name.clone(), (mime, a.data.as_slice())); + // Alt text from Object Export Options, else the file name. + let alt = if it.alt_text.is_empty() { a.name.as_str() } else { it.alt_text.as_str() }; + Some((format!("<img src=\"{}\" alt=\"{}\"/>", xml_attr(&img_src(&name, mime, &a.data)), xml_attr(alt)), false)) +} + /// The content in reading order as HTML sections and figures; `img_src` names each image /// (a package path, or a data URI for single-file HTML). Returns (body, images by name, table of /// contents). @@ -587,50 +651,38 @@ fn body_html<'a>( toc.push((anchor.clone(), first)); } } - let _ = write!(body, "<section id=\"{anchor}\">\n{}</section>\n", story_html(doc, *sid)); + // Anchored objects go where they are anchored: their graphics in the text, an + // above-line object as a block of its own. + let mut object = |o: &designcraft_doc::AnchoredObject| { + let mut out = String::new(); + o.item.walk(&mut |it| { + if let Some((markup, _)) = graphic_html(doc, it, &mut images, img_src) { + out.push_str(&markup); + } + }); + match &o.position { + designcraft_doc::AnchorPosition::AboveLine { align, .. } if !out.is_empty() => { + let align = match align { + designcraft_doc::anchored::AnchorAlign::Left => "left", + designcraft_doc::anchored::AnchorAlign::Center => "center", + designcraft_doc::anchored::AnchorAlign::Right => "right", + }; + format!("<span class=\"anchored\" style=\"display:block;text-align:{align}\">{out}</span>") + } + _ => out, + } + }; + let _ = write!(body, "<section id=\"{anchor}\">\n{}</section>\n", story_html_with(doc, *sid, &mut object)); } Block::Image(iid) => { - let Some(Content::Graphic(g)) = doc.item(*iid).map(|i| &i.content) else { continue }; - let Some(a) = doc.assets.get(&g.asset) else { continue }; - if a.data.is_empty() { + let Some(it) = doc.item(*iid) else { continue }; + let Some((markup, media)) = graphic_html(doc, it, &mut images, img_src) else { continue }; + if media { + let _ = writeln!(body, "<figure>{markup}</figure>"); continue; } - let mime = safe_mime(&a.mime); - // Video and sound: an HTML5 player (with the poster, when there is one). - if let Some(kind) = designcraft_doc::media_kind(mime) { - let name = format!("media/{}.{}", g.asset.0, ext(mime)); - images.insert(name.clone(), (mime, a.data.as_slice())); - let m = doc.item(*iid).and_then(|i| i.media.clone()).unwrap_or_default(); - let mut attrs = String::new(); - for (on, attr) in - [(m.controls, " controls=\"controls\""), (m.play_on_page_load, " autoplay=\"autoplay\""), (m.looping, " loop=\"loop\"")] - { - if on { - attrs.push_str(attr); - } - } - if let Some(pa) = m.poster.and_then(|p| doc.assets.get(&p)) { - let poster_mime = safe_mime(&pa.mime); - let pn = format!("images/{}.{}", pa.id.0, ext(poster_mime)); - images.insert(pn.clone(), (poster_mime, pa.data.as_slice())); - if kind == "video" { - attrs.push_str(&format!(" poster=\"{}\"", xml_attr(&img_src(&pn, poster_mime, &pa.data)))); - } - } - let _ = writeln!( - body, - "<figure><{kind} src=\"{}\"{attrs}>{}</{kind}></figure>", - xml_attr(&img_src(&name, mime, &a.data)), - xml_text(&a.name) - ); - continue; - } - let name = format!("images/{}.{}", g.asset.0, ext(mime)); - images.insert(name.clone(), (mime, a.data.as_slice())); - // Alt text from Object Export Options, else the file name. - let alt = doc.item(*iid).map(|i| i.alt_text.as_str()).filter(|t| !t.is_empty()).unwrap_or(&a.name); // Object Export Options: alignment and a page break before. - let eo = doc.item(*iid).map(|i| i.export_options.clone()).unwrap_or_default(); + let eo = &it.export_options; let mut css = String::new(); if matches!(eo.align.as_str(), "left" | "center" | "right") { css.push_str(&format!("text-align:{};", eo.align)); @@ -639,8 +691,7 @@ fn body_html<'a>( css.push_str("page-break-before:always;break-before:page;"); } let style = if css.is_empty() { String::new() } else { format!(" style=\"{}\"", xml_attr(&css)) }; - let _ = - writeln!(body, "<figure{style}><img src=\"{}\" alt=\"{}\"/></figure>", xml_attr(&img_src(&name, mime, &a.data)), xml_attr(alt)); + let _ = writeln!(body, "<figure{style}>{markup}</figure>"); } } } @@ -788,6 +839,62 @@ mod tests { assert!(z.by_name("OEBPS/nav.xhtml").is_ok()); } + /// #766: anchored graphics are exported where they are anchored (inline in the text, an + /// above-line object as a block of its own), and their images go into the package. + #[test] + fn anchored_objects_are_exported_in_place() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 200.0), lid, "AB\nCD", ParaFormat::default()).unwrap(); + let aid = AssetId(d.alloc()); + d.assets.insert( + aid, + Arc::new(Asset { + page: 0, + id: aid, + name: "dot.png".into(), + mime: "image/png".into(), + link: None, + data: Arc::new(vec![7]), + pixels: Some((1, 1)), + }), + ); + let graphic = |d: &mut Document, alt: &str| { + let mut item = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0))); + item.alt_text = alt.into(); + item.content = Content::Graphic(Graphic { + asset: aid, + size: (1.0, 1.0), + xf: Affine::IDENTITY, + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + }); + item + }; + let inline = designcraft_doc::AnchoredObject::new(graphic(&mut d, "inline"), designcraft_doc::AnchorPosition::Inline { y_offset: 0.0 }); + let above = designcraft_doc::AnchoredObject::new( + graphic(&mut d, "above"), + designcraft_doc::AnchorPosition::AboveLine { align: designcraft_doc::anchored::AnchorAlign::Center, space_before: 0.0, space_after: 0.0 }, + ); + let st = d.story_mut(sid).unwrap(); + st.insert_object(1, inline); + // Byte offsets: "A" (1), the mark (3), "B\nC" (3): the second object goes between C and D. + st.insert_object(7, above); + let bytes = export_epub(&d, &EpubOptions::default()).unwrap(); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut html = String::new(); + z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut html).unwrap(); + let img = |alt: &str| format!("<img src=\"images/{}.png\" alt=\"{alt}\"/>", aid.0); + assert!(html.contains(&format!("A{}B</p>", img("inline"))), "{html}"); + assert!(html.contains(&format!("C<span class=\"anchored\" style=\"display:block;text-align:center\">{}</span>D", img("above"))), "{html}"); + assert!(z.by_name(&format!("OEBPS/images/{}.png", aid.0)).is_ok(), "the image is in the package"); + let page = export_html(&d, &HtmlOptions::default()); + assert!(page.contains("alt=\"inline\"") && page.contains("alt=\"above\""), "{page}"); + // story_html on its own still leaves objects out. + assert!(!story_html(&d, sid).contains("<img")); + } + #[test] fn styles_whose_names_slug_alike_keep_their_own_classes() { let mut d = Document::new(&NewDocument::default()); From 06762c3bfc80bf7523a9e7a3ff149801e0dd7ca6 Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Tue, 13 Oct 2026 14:17:13 +0000 Subject: [PATCH 141/238] fix(idml): export keeps overridden parent items and their local copies' links (#725) --- ROADMAP.md | 1 + crates/idml/src/export.rs | 6 +++++- crates/idml/src/import.rs | 1 + crates/idml/src/tests.rs | 30 ++++++++++++++++++++++++++++++ 4 files changed, 37 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..0499ea8fd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-13** · IDML export keeps parent item overrides: each page lists its overridden parent items and the local copy keeps its link to the parent item, so a reopened IDML file shows only the local copy (#725). No readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index d01abe9e7..056789eb6 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1584,11 +1584,12 @@ impl<'a> Ex<'a> { for (i, pg) in sp.pages.iter().enumerate() { let name = if master { sp.parent.as_ref().map(|i| i.prefix.clone()).unwrap_or_default() } else { d.page_name(first_page + i) }; let applied = if master { based_on } else { pg.parent }; + let overrides: Vec<String> = pg.overridden.iter().map(|i| uid(i.0)).collect(); let pe = El::new("Page") .attr("Self", uid(pg.id.0)) .attr("Name", name) .attr("AppliedMaster", applied.map(|s| uid(s.0)).unwrap_or_else(|| "n".into())) - .attr("OverrideList", "") + .attr("OverrideList", overrides.join(" ")) .attr("GeometricBounds", format!("0 0 {} {}", num(pg.height), num(pg.width))) .attr("ItemTransform", format!("1 0 0 1 {} {}", num(pg.x - ox), num(-oy))) .child(margin_el(pg)); @@ -1620,6 +1621,9 @@ impl<'a> Ex<'a> { _ => "Rectangle", }; let mut el = El::new(tag).attr("Self", uid(it.id.0)); + if let Some(o) = it.overrides { + el.set("OverriddenPageItem", uid(o.0)); + } if let Content::Text(t) = &it.content && let Some(pt) = &t.options.path { diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 59ca44be7..f9f559936 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -2093,6 +2093,7 @@ impl<'r> Importer<'r> { it.name = e.get("Name").filter(|n| *n != "$ID/").unwrap_or("").to_string(); it.locked = e.get("Locked") == Some("true"); it.hidden = e.get("Visible") == Some("false"); + it.overrides = e.get("OverriddenPageItem").and_then(|s| self.item_ids.get(s)).copied().filter(|i| self.is_parent_item(*i)); it.nonprinting = e.get("Nonprinting") == Some("true"); it.object_style = e .get("AppliedObjectStyle") diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index a2ee8f2d5..447baf838 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -553,6 +553,36 @@ fn round_trips_small_document() { assert!(back.swatch("Brand 30").is_some()); } +#[test] +fn round_trips_overridden_parent_items() { + let mut d = Document::new(&NewDocument { pages: 1, ..Default::default() }); + let lid = d.default_layer(); + let (ppi, ppage) = d.parent_page_for(0).unwrap(); + let px = d.parents[ppi].pages[ppage].x; + let pid = designcraft_doc::ItemId(d.alloc()); + let frame = designcraft_doc::Item::new(pid, lid, Shape::Rectangle, shapes::rectangle(Rect::new(px + 40.0, 40.0, px + 200.0, 120.0))); + d.insert_item(SpreadRef::Parent(ppi), frame.clone(), None).unwrap(); + // What layout.overrideParentItems leaves behind: a linked local copy, and the parent item listed on the page. + let mut copy = frame; + copy.id = designcraft_doc::ItemId(d.alloc()); + copy.xf = designcraft_geom::Affine::translate((d.spreads[0].pages[0].x - px, 0.0)) * copy.xf; + copy.overrides = Some(pid); + d.insert_item(SpreadRef::Doc(0), copy, None).unwrap(); + std::sync::Arc::make_mut(&mut d.spreads[0]).pages[0].overridden.push(pid); + + let back = import_idml(&export_idml(&d)).unwrap(); + let parent_ids: Vec<_> = back.parents[ppi].items.iter().map(|i| i.id).collect(); + assert_eq!(parent_ids.len(), 1); + let page = &back.spreads[0].pages[0]; + assert_eq!(page.overridden, parent_ids); + let local: Vec<_> = back.spreads[0].items.iter().collect(); + assert_eq!(local.len(), 1); + assert_eq!(local[0].overrides, Some(parent_ids[0])); + // Rendering skips overridden parent items, so page 1 shows only the local copy. + let shown_parent = back.parents[ppi].items.iter().filter(|i| !page.overridden.contains(&i.id)).count(); + assert_eq!(shown_parent + local.len(), 1); +} + #[test] fn uri_and_base64_helpers() { assert_eq!(import::uri_to_path("file:/a%20b/c.png"), "/a b/c.png"); From d6f4c0133a025b2a251baa6621995288af874b4e Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Tue, 13 Oct 2026 14:18:20 +0000 Subject: [PATCH 142/238] fix(compose): text recomposes when the baseline grid, a wrap layer's visibility or section numbering changes (#718) --- ROADMAP.md | 1 + crates/compose/src/cache.rs | 26 ++++++++++++++++++++-- crates/compose/src/tests.rs | 44 +++++++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..5fb535090 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-13** · Text recomposes when the document baseline grid changes, when a layer holding a text-wrap object is shown or hidden, or when a section's start number, numbering style, prefix or marker changes; the composition cache keyed only on the section count and item identities and kept the old layout. No readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/crates/compose/src/cache.rs b/crates/compose/src/cache.rs index 8aabbf0f1..a0d7e2c85 100644 --- a/crates/compose/src/cache.rs +++ b/crates/compose/src/cache.rs @@ -47,7 +47,18 @@ fn doc_signature(doc: &Document) -> u64 { } (Arc::as_ptr(&doc.styles) as usize).hash(&mut h); format!("{:?}", doc.text_variables).hash(&mut h); + sections_hash(doc).hash(&mut h); + h.finish() +} + +/// Everything page names and section markers are made from. +fn sections_hash(doc: &Document) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); doc.sections.len().hash(&mut h); + for s in &doc.sections { + (s.start, s.start_number, s.style as u8, &s.prefix, &s.marker, s.include_prefix).hash(&mut h); + } h.finish() } @@ -253,8 +264,17 @@ impl Cache { fn signature(doc: &Document, story: &Arc<Story>) -> (Vec<usize>, Vec<Arc<Item>>) { // The document's font scope: another document's fonts of the same name set it differently. - let mut sig = - vec![Arc::as_ptr(story) as usize, story.rev as usize, Arc::as_ptr(&doc.styles) as usize, doc.sections.len(), doc.font_scope as usize]; + // Sections name the pages under the frames (page-number and section markers). + let g = &doc.settings.baseline_grid; + let mut sig = vec![ + Arc::as_ptr(story) as usize, + story.rev as usize, + Arc::as_ptr(&doc.styles) as usize, + sections_hash(doc) as usize, + doc.font_scope as usize, + g.start.to_bits() as usize, + g.increment.to_bits() as usize, + ]; let mut keep = Vec::new(); let mut spreads = Vec::new(); for f in &story.frames { @@ -273,6 +293,8 @@ fn signature(doc: &Document, story: &Arc<Story>) -> (Vec<usize>, Vec<Arc<Item>>) for it in &sp.items { if it.wrap.mode != WrapMode::None { sig.push(Arc::as_ptr(it) as usize); + // Wrap objects on hidden layers don't wrap text. + sig.push(usize::from(doc.layer(it.layer).is_some_and(|l| l.visible))); keep.push(it.clone()); } } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 7a0e790d5..28682d689 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -2929,3 +2929,47 @@ fn empty_paragraphs_get_no_bullet_or_number() { assert_eq!(list_labels(&cs, LABEL_CHARS), ["1.", "", "2.", ""], "list `{name}`"); } } + +#[test] +fn cache_recomposes_when_the_baseline_grid_changes() { + let para = ParaAttrs { grid_align: Some(GridAlign::AllLines), ..Default::default() }; + let (mut d, sid, _) = doc_with(LOREM, Rect::new(0.0, 0.0, 200.0, 600.0), para); + let cache = Cache::new(); + let a = cache.get(&d, sid, None); + d.settings.baseline_grid.increment = 30.0; + let b = cache.get(&d, sid, None); + assert!(!Arc::ptr_eq(&a, &b)); + let (la, lb) = (&a.frames[0].lines[1], &b.frames[0].lines[1]); + assert!((la.baseline - lb.baseline).abs() > 1.0, "{} {}", la.baseline, lb.baseline); +} + +#[test] +fn cache_recomposes_when_a_wrap_layer_is_hidden() { + let (mut d, sid, _) = doc_with(&[LOREM; 2].join(" "), Rect::new(0.0, 0.0, 300.0, 800.0), ParaAttrs::default()); + let lid = d.default_layer(); + let id = ItemId(d.alloc()); + let mut it = + designcraft_doc::Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 120.0, 60.0))); + it.wrap.mode = WrapMode::BoundingBox; + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + let cache = Cache::new(); + let a = cache.get(&d, sid, None); + assert!(a.frames[0].lines[0].x0 >= 120.0 - 1e-6); + d.layer_mut(lid).unwrap().visible = false; + let b = cache.get(&d, sid, None); + assert!(!Arc::ptr_eq(&a, &b)); + assert!(b.frames[0].lines[0].x0 < 1.0, "{}", b.frames[0].lines[0].x0); +} + +#[test] +fn cache_recomposes_page_numbers_when_a_section_is_renumbered() { + let text = story::PAGE_NUMBER.to_string(); + let (mut d, sid, _) = doc_with(&text, Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs::default()); + let cache = Cache::new(); + let a = cache.get(&d, sid, None); + assert_eq!(a.frames[0].lines[0].glyphs.len(), 1); + d.sections[0].start_number = Some(1000); + let b = cache.get(&d, sid, None); + assert!(!Arc::ptr_eq(&a, &b)); + assert_eq!(b.frames[0].lines[0].glyphs.len(), 4); +} From 8817e0701364d4bf409dccfe9596df6b109dbe9b Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Tue, 13 Oct 2026 14:19:56 +0000 Subject: [PATCH 143/238] fix(paths): path edits refuse a path on a locked layer or in a locked group (#735) --- crates/engine/src/cmd/anchors.rs | 5 +++++ crates/engine/src/cmd/object.rs | 34 ++++++++++++++++++++++++++------ crates/engine/src/cmd/paths.rs | 1 + 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/anchors.rs b/crates/engine/src/cmd/anchors.rs index c5015c7c4..ad18b8225 100644 --- a/crates/engine/src/cmd/anchors.rs +++ b/crates/engine/src/cmd/anchors.rs @@ -5,6 +5,7 @@ use designcraft_doc::{Document, ItemId, Shape}; use designcraft_geom::{Affine, Anchor, AnchorKind, Point}; use serde_json::{Value, json}; +use super::object::path_editable; use super::{CommandSpec, bad, cmd, has_doc, point_param}; use crate::{Result, Session}; @@ -69,6 +70,7 @@ fn erase_or_smooth(s: &mut Session, p: &Value, smooth: bool) -> Result<Value> { .map(|a| a.iter().filter_map(|v| Some(Point::new(v.get(0)?.as_f64()?, v.get(1)?.as_f64()?))).collect()) .unwrap_or_default(); s.edit(|d, _| { + path_editable(d, id, cmd)?; let inv = to_inner(d, id).ok_or(designcraft_doc::DocError::NoItem(id))?; let scale = inv.determinant().abs().sqrt().max(1e-9); let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; @@ -135,6 +137,7 @@ fn add_anchor(s: &mut Session, p: &Value) -> Result<Value> { let at = point_param(p, "at").ok_or_else(|| bad("path.addAnchor", "missing at"))?; let tol = p.get("tolerance").and_then(Value::as_f64).unwrap_or(4.0); s.edit(|d, _| { + path_editable(d, id, "path.addAnchor")?; let inv = to_inner(d, id).ok_or(designcraft_doc::DocError::NoItem(id))?; let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; let q = inv * at; @@ -154,6 +157,7 @@ fn add_anchor(s: &mut Session, p: &Value) -> Result<Value> { fn delete_anchor(s: &mut Session, p: &Value) -> Result<Value> { let (id, si, ai) = anchor_param(p, "path.deleteAnchor")?; s.edit(|d, sel| { + path_editable(d, id, "path.deleteAnchor")?; let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; let sp = it.path.subpaths.get_mut(si).ok_or_else(|| bad("path.deleteAnchor", "no such subpath"))?; if ai >= sp.anchors.len() { @@ -181,6 +185,7 @@ fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> { let to = point_param(p, "to"); let corner = p.get("corner").and_then(Value::as_bool); s.edit(|d, _| { + path_editable(d, id, "path.convertAnchor")?; let inv = to_inner(d, id).ok_or(designcraft_doc::DocError::NoItem(id))?; let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; let sp = it.path.subpaths.get_mut(si).ok_or_else(|| bad("path.convertAnchor", "no such subpath"))?; diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index aa5a2cc01..a03e055ae 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -1489,6 +1489,20 @@ fn unlocked(d: &Document, it: &Item) -> bool { !it.locked && d.layer(it.layer).is_none_or(|l| !l.locked) } +/// Refuses a path edit on `id` when it, a group it sits in, or its layer is locked. +pub(super) fn path_editable(d: &Document, id: ItemId, cmd: &str) -> Result<()> { + let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?; + let mut items = d.spread(loc.spread).map(|sp| &sp.items[..]).unwrap_or_default(); + for &i in &loc.path { + let Some(it) = items.get(i) else { break }; + if !unlocked(d, it) { + return Err(bad(cmd, "the path is locked")); + } + items = it.children(); + } + Ok(()) +} + fn has_editable_selection(s: &Session) -> std::result::Result<(), String> { has_selection(s)?; let st = s.active().ok_or_else(|| super::NOTHING_SELECTED.to_string())?; @@ -2075,6 +2089,7 @@ fn path_append(s: &mut Session, p: &Value) -> Result<Value> { let a = p.get("anchor").and_then(anchor_from).ok_or_else(|| bad("path.appendAnchor", "missing anchor"))?; let subpath = p.get("subpath").and_then(Value::as_u64); s.edit(|d, _| { + path_editable(d, id, "path.appendAnchor")?; let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?; let inv = (d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default()).inverse(); let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; @@ -2110,12 +2125,10 @@ fn path_set_anchor(s: &mut Session, p: &Value) -> Result<Value> { let subpath = p.get("subpath").and_then(Value::as_u64); let which = p.get("anchor").cloned().unwrap_or(Value::Null); s.edit(|d, _| { + path_editable(d, id, ID)?; let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?; let inv = (d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default()).inverse(); let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; - if it.locked { - return Err(bad(ID, "the path is locked")); - } let sp = match subpath { Some(i) => usize::try_from(i).ok().and_then(|i| it.path.subpaths.get_mut(i)), None => it.path.subpaths.last_mut(), @@ -2157,12 +2170,19 @@ fn path_reverse(s: &mut Session, p: &Value) -> Result<Value> { { return Err(bad("path.reverse", "no such subpath")); } - let ids = if only.is_some() { ids.into_iter().take(1).collect() } else { ids }; + let ids: Vec<ItemId> = if only.is_some() { ids.into_iter().take(1).collect() } else { ids }; + let doc = &s.doc()?.doc; + let editable: Vec<ItemId> = ids.iter().copied().filter(|id| path_editable(doc, *id, "path.reverse").is_ok()).collect(); + if editable.is_empty() + && let Some(id) = ids.first() + { + path_editable(doc, *id, "path.reverse")?; + } s.edit(|d, _| { let mut reversed = 0; - for id in &ids { + for id in &editable { let Some(it) = d.item_mut(*id) else { continue }; - if it.locked || it.is_group() { + if it.is_group() { continue; } for (i, sp) in it.path.subpaths.iter_mut().enumerate() { @@ -2184,6 +2204,7 @@ fn path_close(s: &mut Session, p: &Value) -> Result<Value> { let id = super::id_param(p, "id").ok_or_else(|| bad("path.close", "missing id"))?; let only = p.get("subpath").and_then(Value::as_u64).map(|i| usize::try_from(i).unwrap_or(usize::MAX)); s.edit(|d, _| { + path_editable(d, id, "path.close")?; let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; if only.is_some_and(|i| i >= it.path.subpaths.len()) { return Err(bad("path.close", "no such subpath")); @@ -2206,6 +2227,7 @@ fn path_move_anchors(s: &mut Session, p: &Value) -> Result<Value> { let d0 = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0)); let handle = str_param(p, "handle").map(str::to_string); s.edit(|d, _| { + path_editable(d, id, "path.moveAnchors")?; let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?; let m = d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default(); // Spread-space delta → inner-space delta (linear part only). diff --git a/crates/engine/src/cmd/paths.rs b/crates/engine/src/cmd/paths.rs index b9d479067..14609d18f 100644 --- a/crates/engine/src/cmd/paths.rs +++ b/crates/engine/src/cmd/paths.rs @@ -312,6 +312,7 @@ fn split_path(s: &mut Session, p: &Value) -> Result<Value> { let id = super::id_param(p, "id").ok_or_else(|| bad("path.split", "missing id"))?; let at = point_param(p, "at").ok_or_else(|| bad("path.split", "missing at"))?; s.edit(|d, sel| { + super::object::path_editable(d, id, "path.split")?; let (sr, xf) = spread_xf(d, id).ok_or(designcraft_doc::DocError::NoItem(id))?; let it = d.item(id).cloned().ok_or(designcraft_doc::DocError::NoItem(id))?; if !matches!(it.content, Content::Unassigned) { From f60699278264d63ce3b9df918bb297373dd24cc8 Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Tue, 13 Oct 2026 14:19:57 +0000 Subject: [PATCH 144/238] fix(properties): links to other panels open them when panel docking is on (#736) --- ROADMAP.md | 1 + crates/ui-egui/src/panel_docking_tests.rs | 27 ++++++++++++++++++++++ crates/ui-egui/src/panels/properties.rs | 28 +++++++++++++++-------- crates/ui-egui/src/widgets.rs | 1 + 4 files changed, 47 insertions(+), 10 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..9f768db16 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-13** · Path edits (`path.setAnchor`, `path.moveAnchors`, `path.appendAnchor`, `path.reverse`, `path.close`, anchor add/delete/convert, Erase, Smooth and Scissors) refuse a path that is locked, sits in a locked group or on a locked layer (#735). Properties links to the Stroke, Character, Paragraph, Effects, Text Wrap and Info panels open them in a customized panel layout too (#736). No readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index de08e3cf5..65879b1a0 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -301,6 +301,33 @@ fn populated_canvas_keeps_a_real_stroke_edit_after_floating_and_redocking() { } } +#[test] +fn properties_stroke_link_opens_the_stroke_panel_in_a_custom_workspace() { + let mut app = app(); + app.run("file.new", json!({"width":480,"height":360})).unwrap(); + app.run("frame.create", json!({"rect":[80,70,330,230],"content":"none"})).unwrap(); + app.run("window.panel.float", json!({"panel":"layers"})).unwrap(); + assert!(!app.ui.docking.as_ref().unwrap().contains(&"stroke".into())); + let mut h = Harness::builder().with_size(vec2(1280.0, 800.0)).build_ui_state( + |ui, app: &mut DesignApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(5); + let at = h.get_by_role_and_label(egui::accesskit::Role::Link, "Stroke").rect().center(); + h.hover_at(at); + h.run_steps(1); + for pressed in [true, false] { + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(1); + } + h.run_steps(2); + assert!(h.state().ui.docking.as_ref().unwrap().contains(&"stroke".into())); + assert_eq!(h.state().ui.docking_opened.as_deref(), Some("stroke")); +} + #[test] fn changing_workspace_during_a_drag_cancels_the_stale_gesture() { let mut app = app(); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index d9c74e40d..f5acd8665 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -16,6 +16,14 @@ use crate::widgets::{ }; use crate::{DesignApp, icons}; +/// A link to another panel. `window.panel` shows it in the docked layout or as a flyout; it +/// toggles the flyout, so a flyout that is already open is left as it is. +fn show_panel(app: &mut DesignApp, panel: &str) { + if app.ui.open_panel.as_deref() != Some(panel) { + let _ = app.run("window.panel", json!({"panel": panel})); + } +} + fn units(app: &DesignApp) -> Unit { app.session.active().map(|d| d.doc.settings.horizontal_units).unwrap_or(Unit::Picas) } @@ -467,7 +475,7 @@ fn transform_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { } transform_angles(app, ui, rp); if more_options(ui, &app.ui.language).clicked() { - app.ui.open_panel = Some("info".into()); + show_panel(app, "info"); } } @@ -654,7 +662,7 @@ fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { let _ = app.run("object.stroke", json!({"swatch": n})); }); if place(ui, sub(b, 34.0, 0.0, 56.0, FIELD_H), |ui| link_label(ui, crate::i18n::tr(&app.ui.language, "Stroke"), 56.0)).clicked() { - app.ui.open_panel = Some("stroke".into()); + show_panel(app, "stroke"); } const WEIGHTS: &[f64] = &[0.0, 0.25, 0.5, 0.75, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 20.0, 30.0, 40.0, 50.0, 100.0]; if let Some(v) = place(ui, sub(b, 96.0, 0.0, 69.0, FIELD_H), |ui| { @@ -695,7 +703,7 @@ fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { let b = block(ui, FIELD_H); icons::paint(ui.painter(), sub(b, 3.0, 1.0, 19.0, 19.0), "opacity", t.icon); if place(ui, sub(b, 34.0, 0.0, 56.0, FIELD_H), |ui| link_label(ui, crate::i18n::tr(&app.ui.language, "Opacity"), 56.0)).clicked() { - app.ui.open_panel = Some("effects".into()); + show_panel(app, "effects"); } if let Some(v) = place(ui, sub(b, 96.0, 0.0, 51.0, FIELD_H), |ui| { NumField::number("opacity", Some((i.opacity * 100.0).round()), "%", 0).width(51.0).range(0.0, 100.0).show(ui) @@ -707,13 +715,13 @@ fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { ui.painter().rect(more, 0.0, if mresp.hovered() { t.hover } else { t.input }, Stroke::new(1.0, t.field_border), StrokeKind::Inside); icons::paint(ui.painter(), more.shrink(4.0), "chevron-right", t.icon); if mresp.clicked() { - app.ui.open_panel = Some("effects".into()); + show_panel(app, "effects"); } ui.add_space(6.0); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 10.0; if icon_toggle(ui, "frame-dashed", false, crate::i18n::tr(&app.ui.language, "Frame Fitting")).clicked() { - app.ui.open_panel = Some("textWrap".into()); + show_panel(app, "textWrap"); } if icon_toggle(ui, "fx", i.shadow, crate::i18n::tr(&app.ui.language, "Effects: Drop Shadow")).clicked() { let _ = app.run("object.dropShadow", json!({"on": !i.shadow})); @@ -801,7 +809,7 @@ fn wrap_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { } }); if more_options(ui, &app.ui.language).clicked() { - app.ui.open_panel = Some("textWrap".into()); + show_panel(app, "textWrap"); } } @@ -1018,7 +1026,7 @@ fn text_appearance_section(app: &mut DesignApp, ui: &mut Ui) { ); } else { if place(ui, sub(b, 34.0, 0.0, 56.0, FIELD_H), |ui| link_label(ui, crate::i18n::tr(&app.ui.language, label), 56.0)).clicked() { - app.ui.open_panel = Some("stroke".into()); + show_panel(app, "stroke"); } const WEIGHTS: &[f64] = &[0.0, 0.25, 0.5, 0.75, 1.0, 2.0, 3.0, 4.0, 5.0, 10.0]; if let Some(v) = place(ui, sub(b, 96.0, 0.0, 69.0, FIELD_H), |ui| { @@ -1125,7 +1133,7 @@ fn character_section(app: &mut DesignApp, ui: &mut Ui) { egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| language_menu(app, ui, &lang)); }); if more_options(ui, &app.ui.language).clicked() { - app.ui.open_panel = Some("character".into()); + show_panel(app, "character"); } } @@ -1157,7 +1165,7 @@ fn paragraph_section(app: &mut DesignApp, ui: &mut Ui) { } }); if more_options(ui, &app.ui.language).clicked() { - app.ui.open_panel = Some("paragraph".into()); + show_panel(app, "paragraph"); } } @@ -1175,7 +1183,7 @@ fn bullets_section(app: &mut DesignApp, ui: &mut Ui) { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.add_space(8.0); if outline_button(ui, crate::i18n::tr(&app.ui.language, "Options"), 66.0).clicked() { - app.ui.open_panel = Some("paragraph".into()); + show_panel(app, "paragraph"); } }); }); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index bcb27ddf9..af5a7b46c 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -364,6 +364,7 @@ pub fn link_label(ui: &mut Ui, text: &str, width: f32) -> Response { ui.painter().galley(pos, g, t.text); let y = r.center().y + 6.5; ui.painter().line_segment([pos2(r.min.x, y), pos2(r.min.x + w, y)], Stroke::new(1.0, if resp.hovered() { t.text_strong } else { t.text_dim })); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Link, true, text)); resp.on_hover_cursor(egui::CursorIcon::PointingHand) } From 4eb786e8803f75b75d091ed982feb41c096f4279 Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Tue, 13 Oct 2026 14:22:58 +0000 Subject: [PATCH 145/238] fix(recovery): an explicit recovery folder never overwrites or deletes another run's checkpoints (#715) --- ROADMAP.md | 1 + crates/engine/src/recovery.rs | 214 +++++++++++++++++++++++++++------- docs/target-app-parity.md | 2 +- 3 files changed, 176 insertions(+), 41 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..d42bd0c1a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-13** · An explicit recovery folder (`file.recovery.save` with `dir`) never overwrites or deletes another running app's checkpoints. Document uids start at 1 in every process, so two apps saving recovery to one chosen folder replaced or removed each other's `<uid>.designcraft`. A run replaces or removes only the entries it wrote or recovered there, refuses an occupied uid, and refuses a folder whose run lock another app holds. No readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/crates/engine/src/recovery.rs b/crates/engine/src/recovery.rs index 33b9a2a33..ac1d8d346 100644 --- a/crates/engine/src/recovery.rs +++ b/crates/engine/src/recovery.rs @@ -11,6 +11,10 @@ //! An app that starts moves into its own folder what apps that have ended left in theirs, and //! leaves the folder of one still running alone. //! +//! A folder given to `file.recovery.save` that is not this run's own may hold other runs' entries +//! under the same uids. This run writes there only entries it finds free, replaces and removes +//! only those it wrote or recovered, and refuses the folder of a run whose lock is still held. +//! //! Each file is replaced atomically, but the pair is not a transaction. The document is published //! and made durable before its metadata so a crash can leave metadata missing or stale, never a //! listed metadata record without a complete primary document. Listing and opening recovery data @@ -192,10 +196,11 @@ fn lock_marker(file: &std::fs::File) -> std::io::Result<Vec<u8>> { } /// A run's folder that was removed while it runs is made again by the next save: so is its lock. +/// Returns whether `dir` is this run's folder. #[cfg(not(target_arch = "wasm32"))] -fn relock(dir: &Path, created: bool) -> Result<()> { +fn relock(dir: &Path, created: bool) -> Result<bool> { let mut runs = RUNS.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - let Some(run) = runs.iter_mut().find(|r| r.dir == dir) else { return Ok(()) }; + let Some(run) = runs.iter_mut().find(|r| r.dir == dir) else { return Ok(false) }; validate_recovery_dir(&run.root)?; #[cfg(unix)] let directory = std::fs::symlink_metadata(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; @@ -219,7 +224,7 @@ fn relock(dir: &Path, created: bool) -> Result<()> { return Err(EngineError::Other(format!("{}: this run no longer owns its recovery folder", dir.display()))); } match std::fs::symlink_metadata(dir.join(LOCK)) { - Ok(_) => return validate_lock(dir, &run.lock), + Ok(_) => return validate_lock(dir, &run.lock).map(|()| true), Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => return Err(EngineError::Other(format!("{}: {e}", dir.display()))), } @@ -242,7 +247,93 @@ fn relock(dir: &Path, created: bool) -> Result<()> { { run.directory = replacement_directory; } - Ok(()) + Ok(true) +} + +/// Entries this process wrote or recovered in folders that are not its run's own: (canonical +/// folder, uid). Document uids start at 1 in every process, so a shared folder's entry under +/// one of this run's uids may be another run's. +static WRITTEN: std::sync::Mutex<std::collections::BTreeSet<(PathBuf, u64)>> = std::sync::Mutex::new(std::collections::BTreeSet::new()); + +/// What this run may do in a recovery folder. +struct Access { + /// This run's own folder (`run_dir`): every entry in it is this run's. + own: bool, + key: PathBuf, +} + +impl Access { + fn owns(&self, uid: u64) -> bool { + self.own || WRITTEN.lock().unwrap_or_else(std::sync::PoisonError::into_inner).contains(&(self.key.clone(), uid)) + } + + fn record(&self, uid: u64) { + if !self.own { + WRITTEN.lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert((self.key.clone(), uid)); + } + } + + fn forget(&self, uid: u64) { + WRITTEN.lock().unwrap_or_else(std::sync::PoisonError::into_inner).remove(&(self.key.clone(), uid)); + } + + /// Refuse an entry of another run before anything is written: this run never replaces it. + fn check_free(&self, dir: &Path, uid: u64) -> Result<()> { + if self.owns(uid) { + return Ok(()); + } + let (doc, meta) = files(dir, uid); + for path in [doc, meta] { + match std::fs::symlink_metadata(&path) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + _ => { + return Err(EngineError::Other(format!( + "{}: recovery data this run did not write is there; recover it or choose another recovery folder", + path.display() + ))); + } + } + } + Ok(()) + } +} + +/// Check what this run may do in `dir`. A folder that is not this run's own may be the folder +/// of another running app, whose lock that app holds: this run leaves it alone. +fn access(dir: &Path, created: bool) -> Result<Access> { + #[cfg(not(target_arch = "wasm32"))] + let own = relock(dir, created)?; + #[cfg(not(target_arch = "wasm32"))] + if !own { + check_unheld(dir)?; + } + #[cfg(target_arch = "wasm32")] + let own = { + let _ = created; + false + }; + Ok(Access { own, key: directory_key(dir)? }) +} + +#[cfg(not(target_arch = "wasm32"))] +fn check_unheld(dir: &Path) -> Result<()> { + let path = dir.join(LOCK); + let in_use = + || EngineError::Other(format!("{}: another running DesignCraft keeps its recovery data here; choose another recovery folder", dir.display())); + match std::fs::symlink_metadata(&path) { + Ok(metadata) if is_link_like(&metadata) || !metadata.file_type().is_file() => return Err(in_use()), + Ok(_) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(e) => return Err(EngineError::Other(format!("{}: {e}", path.display()))), + } + // Opened for writing: over NFS an exclusive lock on a file opened for reading is refused. + let lock = std::fs::OpenOptions::new().read(true).write(true).open(&path).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?; + match lock.try_lock() { + // Free: the run that held it has ended. Dropping the handle releases it again. + Ok(()) => Ok(()), + Err(std::fs::TryLockError::WouldBlock) => Err(in_use()), + Err(std::fs::TryLockError::Error(e)) => Err(EngineError::Other(format!("{}: {e}", path.display()))), + } } /// Move into `own`, this run's folder, what is left to recover beside it: the entries of runs @@ -329,15 +420,21 @@ fn files(dir: &Path, uid: u64) -> (PathBuf, PathBuf) { (dir.join(format!("{uid}.designcraft")), dir.join(format!("{uid}.json"))) } -/// Remove a document's recovery entry (it was saved or closed). +/// Remove a document's recovery entry (it was saved or closed). In a folder that is not this +/// run's own, only an entry this run wrote or recovered is removed. pub fn discard(dir: &Path, uid: u64) { #[cfg(not(target_arch = "wasm32"))] - if validate_recovery_dir(dir).is_err() || relock(dir, false).is_err() { + if validate_recovery_dir(dir).is_err() { + return; + } + let Ok(access) = access(dir, false) else { return }; + if !access.owns(uid) { return; } let (d, m) = files(dir, uid); let _ = std::fs::remove_file(d); let _ = std::fs::remove_file(m); + access.forget(uid); } /// Consume this document's checkpoint provenance after Save/Close/Revert. A default-directory @@ -385,29 +482,29 @@ fn protect_retained_sources(s: &Session, dir: &Path) -> Result<()> { Ok(()) } -fn prepare_save(s: &Session, dir: &Path) -> Result<()> { +fn prepare_save(s: &Session, dir: &Path) -> Result<Access> { // Check before any write or deletion: a failed republish can retain a source whose uid // coincides with an unrelated live document in a manually configured recovery directory. protect_retained_sources(s, dir)?; let created = secure_recovery_dir(dir)?; - #[cfg(not(target_arch = "wasm32"))] - relock(dir, created)?; - #[cfg(target_arch = "wasm32")] - let _ = created; - Ok(()) + let access = access(dir, created)?; + for document in s.docs.iter().filter(|d| d.is_dirty()) { + access.check_free(dir, document.uid)?; + } + Ok(access) } /// Write recovery entries for dirty documents and drop clean checkpoints. Returns how many /// were written. Command callers use the tracked variant without configuring automatic recovery. pub fn save(s: &Session, dir: &Path) -> Result<usize> { - prepare_save(s, dir)?; + let access = prepare_save(s, dir)?; let mut n = 0; for d in &s.docs { if !d.is_dirty() { discard(dir, d.uid); continue; } - write_entry(dir, d)?; + write_entry(&access, dir, d)?; n += 1; } Ok(n) @@ -424,7 +521,7 @@ pub(crate) fn save_tracked(s: &mut Session, dir: &Path) -> Result<usize> { )); } } - prepare_save(s, dir)?; + let access = prepare_save(s, dir)?; let mut n = 0; for index in 0..s.docs.len() { if s.docs.get(index).is_some_and(|document| !document.is_dirty()) { @@ -435,25 +532,30 @@ pub(crate) fn save_tracked(s: &mut Session, dir: &Path) -> Result<usize> { let (doc, meta) = files(dir, document.uid); let bytes = designcraft_format::save(document.preview_stash.as_deref().unwrap_or(document.doc.as_ref())) .map_err(|e| EngineError::Other(e.to_string()))?; - write_recovery_file(&doc, &bytes)?; + // An entry this run did not write is never replaced, even one made since it checked. + let replace = access.owns(document.uid); + publish_recovery_file(&doc, &bytes, replace)?; + access.record(document.uid); // The primary is useful even if optional metadata fails next. Track it immediately. if !has_checkpoint(document, dir) { document.recovery_entries.push((dir.to_path_buf(), document.uid)); } let metadata = json!({"path": document.path, "title": document.title(), "saved": designcraft_doc::vars::now()}); - write_recovery_file(&meta, metadata.to_string().as_bytes())?; + publish_recovery_file(&meta, metadata.to_string().as_bytes(), replace)?; n += 1; } Ok(n) } /// Publish a document's recovery entry before its optional metadata. -fn write_entry(dir: &Path, d: &DocState) -> Result<()> { +fn write_entry(access: &Access, dir: &Path, d: &DocState) -> Result<()> { let (doc, meta) = files(dir, d.uid); let bytes = designcraft_format::save(d.preview_stash.as_deref().unwrap_or(d.doc.as_ref())).map_err(|e| EngineError::Other(e.to_string()))?; - write_recovery_file(&doc, &bytes)?; + let replace = access.owns(d.uid); + publish_recovery_file(&doc, &bytes, replace)?; + access.record(d.uid); let metadata = json!({"path": d.path, "title": d.title(), "saved": designcraft_doc::vars::now()}); - write_recovery_file(&meta, metadata.to_string().as_bytes()) + publish_recovery_file(&meta, metadata.to_string().as_bytes(), replace) } #[cfg(not(target_arch = "wasm32"))] @@ -499,11 +601,6 @@ fn secure_recovery_dir(_dir: &Path) -> Result<bool> { Err(EngineError::Other("recovery files are unavailable on the web".into())) } -#[cfg(not(target_arch = "wasm32"))] -fn write_recovery_file(path: &Path, bytes: &[u8]) -> Result<()> { - publish_recovery_file(path, bytes, true) -} - #[cfg(not(target_arch = "wasm32"))] fn publish_recovery_file(path: &Path, bytes: &[u8], replace: bool) -> Result<()> { let Some(dir) = path.parent() else { @@ -581,11 +678,6 @@ fn sync_recovery_dir(dir: &Path) -> Result<()> { Ok(()) } -#[cfg(target_arch = "wasm32")] -fn write_recovery_file(_path: &Path, _bytes: &[u8]) -> Result<()> { - Err(EngineError::Other("recovery files are unavailable on the web".into())) -} - #[cfg(target_arch = "wasm32")] fn publish_recovery_file(_path: &Path, _bytes: &[u8], _replace: bool) -> Result<()> { Err(EngineError::Other("recovery files are unavailable on the web".into())) @@ -661,18 +753,13 @@ pub fn open(s: &mut Session, dir: &Path) -> Result<Vec<usize>> { fn open_with_publisher(s: &mut Session, dir: &Path, mut publish: impl FnMut(&Path, &[u8]) -> Result<()>) -> Result<Vec<usize>> { #[cfg(not(target_arch = "wasm32"))] - { - validate_recovery_dir(dir)?; - relock(dir, false)?; - } + validate_recovery_dir(dir)?; + let source = access(dir, false)?; let destination = s.recovery_dir.clone().unwrap_or_else(|| dir.to_path_buf()); let created = secure_recovery_dir(&destination)?; - #[cfg(not(target_arch = "wasm32"))] - relock(&destination, created)?; - #[cfg(target_arch = "wasm32")] - let _ = created; - let source_key = directory_key(dir)?; - let destination_key = directory_key(&destination)?; + let target = access(&destination, created)?; + let source_key = source.key.clone(); + let destination_key = target.key.clone(); let mut opened = Vec::new(); // For this run's own folder, first bring in what runs that ended left in theirs. #[cfg(not(target_arch = "wasm32"))] @@ -705,6 +792,8 @@ fn open_with_publisher(s: &mut Session, dir: &Path, mut publish: impl FnMut(&Pat let mut st = DocState::new(d, path); reserve_uid(&mut st, &mut occupied)?; st.recovery_entries.push((dir.to_path_buf(), uid)); + // Recovered, the entry is this run's: Save and Close of its document may remove it. + source.record(uid); st.fonts = fonts; // Unsaved: the copy on disk (if any) is older than what was recovered. st.saved_doc = std::sync::Arc::new((*st.doc).clone()); @@ -721,6 +810,7 @@ fn open_with_publisher(s: &mut Session, dir: &Path, mut publish: impl FnMut(&Pat .and_then(|bytes| publish(&document, &bytes)); match published { Ok(()) => { + target.record(state.uid); state.recovery_entries.push((destination.clone(), state.uid)); let meta = json!({"path": state.path, "title": state.title(), "saved": designcraft_doc::vars::now()}); match publish(&metadata, meta.to_string().as_bytes()) { @@ -1477,6 +1567,50 @@ mod tests { assert!(!left.join(LOCK).exists()); assert_eq!(std::fs::read(left.join("1.designcraft")).unwrap(), b"left with no lock"); } + + /// Another run's entry under uid 7 in a chosen folder, with a document of this run numbered + /// 7 too: document uids start at 1 in every process. + fn chosen_folder_with_another_runs_entry() -> (tempfile::TempDir, PathBuf, PathBuf) { + let root = tempfile::tempdir().unwrap(); + let (doc, meta) = files(root.path(), 7); + std::fs::write(&doc, b"another run's document").unwrap(); + std::fs::write(&meta, b"{\"title\": \"theirs\"}").unwrap(); + (root, doc, meta) + } + + #[test] + fn a_clean_document_leaves_another_runs_entry_in_a_chosen_folder() { + let (root, doc, meta) = chosen_folder_with_another_runs_entry(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.docs[0].uid = 7; + assert!(!s.docs[0].is_dirty()); + s.execute("file.recovery.save", &json!({"dir": root.path().to_string_lossy()})).unwrap(); + s.execute("file.close", &json!({})).unwrap(); + assert_eq!(std::fs::read(doc).unwrap(), b"another run's document"); + assert_eq!(std::fs::read(meta).unwrap(), b"{\"title\": \"theirs\"}"); + } + + #[test] + fn a_dirty_document_does_not_overwrite_another_runs_entry_in_a_chosen_folder() { + let (root, doc, meta) = chosen_folder_with_another_runs_entry(); + let mut s = unsaved("this run's work"); + s.docs[0].uid = 7; + assert!(s.execute("file.recovery.save", &json!({"dir": root.path().to_string_lossy()})).is_err()); + assert!(save(&s, root.path()).is_err()); + assert_eq!(std::fs::read(doc).unwrap(), b"another run's document"); + assert_eq!(std::fs::read(meta).unwrap(), b"{\"title\": \"theirs\"}"); + } + + /// A chosen folder that is a running app's run folder is that app's. + #[test] + fn a_folder_another_running_app_holds_is_refused() { + let root = tempfile::tempdir().unwrap(); + let held = new_lock(root.path()).unwrap(); + held.try_lock().unwrap(); + assert!(save(&dirty_session(), root.path()).is_err()); + assert!(list(root.path()).is_empty()); + } } #[cfg(test)] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index eb7d7e5c1..b76a821c4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -290,7 +290,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - 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, as do desktop Services writes and CLI raster exports. Failed writes before replacement retain the original; existing Unix permissions apply before staging document bytes, and already-open readers retain the old bytes. Parent-folder flushing is best effort, so this is not a power-loss durability guarantee. - Save As prepares destination-relative data-source paths before writing and installs them only after a successful save. Failed Save As retains the document, saved state and history; Save a Copy rebases only the saved copy. Library and book collection edits likewise update live values only after persistence succeeds. -- Crash recovery: unsaved documents are written every 30 s into a locked folder owned by that running app and reopened (unsaved, remembering their files) after a crash. Startup takes entries from ended runs and leaves live runs alone; writes and cleanup validate current folder and lock ownership. Failed isolation disables recovery with a warning. Recovered checkpoints remain available through another crash before autosave; restarting IDs and explicit recovery from another folder preserve unopened snapshots. Successful Save, Close or Revert clears the document's checkpoints, including manual recovery while autosave is disabled; failed republishing keeps the original. Concurrent older versions still use unlocked root entries; mixed-version isolation remains open. File › Revert and Save a Copy…; `file.recovery.*`, `file.revert`, `file.saveACopy`. +- Crash recovery: unsaved documents are written every 30 s into a locked folder owned by that running app and reopened (unsaved, remembering their files) after a crash. Startup takes entries from ended runs and leaves live runs alone; writes and cleanup validate current folder and lock ownership. Failed isolation disables recovery with a warning. Recovered checkpoints remain available through another crash before autosave; restarting IDs and explicit recovery from another folder preserve unopened snapshots. Successful Save, Close or Revert clears the document's checkpoints, including manual recovery while autosave is disabled; failed republishing keeps the original. A folder given to `file.recovery.save` keeps other runs' entries: a run replaces or removes only the entries it wrote or recovered there, and refuses a folder whose run lock another app holds. Concurrent older versions still use unlocked root entries; mixed-version isolation remains open. File › Revert and Save a Copy…; `file.recovery.*`, `file.revert`, `file.saveACopy`. - Window close, application/menu Quit and macOS Dock Quit ask about every unsaved document. Cancel or a cancelled/failed save keeps all tabs open; Don't Save approvals discard nothing until the complete plan is current. Later edits and new documents are checked before closing. Repeated native requests preserve an active dialog and partial approvals. Control-channel quit remains immediate. - Graphics backend chosen before the window exists (DirectX 12 on Windows, Vulkan on Linux, Metal on macOS, OpenGL as the fallback; `WGPU_BACKEND` overrides) with a start-up fallback: a backend whose start never showed a frame is skipped at the next start and the status bar says so (`gpu.json`). Found on an AMD hybrid-graphics laptop whose Vulkan driver faulted at the first present. - Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool). From 368958ee2f8509d031767ea05d2ebeccdcb056b2 Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Tue, 13 Oct 2026 14:51:52 +0000 Subject: [PATCH 146/238] fix(macos): a menu-bar command runs at once instead of on the next input (#784) --- ROADMAP.md | 1 + apps/designcraft/src/main.rs | 2 +- apps/designcraft/src/native_menu.rs | 16 +++++++++++++--- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..d49927e83 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-13** · macOS: a command chosen with the mouse in the menu bar runs at once. It used to wait for the next mouse move or key press over the window, so File › Open, Place and Save As seemed to take 10–20 seconds to open their dialogs. No readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index 26ce2ee4f..705839ae7 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -45,7 +45,7 @@ impl eframe::App for App { #[cfg(target_os = "macos")] { if self.menu.is_none() && std::env::var_os("DESIGNCRAFT_NO_NATIVE_MENU").is_none() { - self.menu = Some(native_menu::NativeMenu::install(&mut self.app)); + self.menu = Some(native_menu::NativeMenu::install(&mut self.app, ctx)); } if let Some(m) = &mut self.menu { m.poll(&mut self.app, ctx); diff --git a/apps/designcraft/src/native_menu.rs b/apps/designcraft/src/native_menu.rs index f51db197a..f6e475259 100644 --- a/apps/designcraft/src/native_menu.rs +++ b/apps/designcraft/src/native_menu.rs @@ -4,6 +4,7 @@ use std::collections::HashMap; use std::str::FromStr; +use std::sync::mpsc::{Receiver, channel}; use designcraft_ui_egui::DesignApp; use designcraft_ui_egui::menus::{self, Item}; @@ -19,6 +20,7 @@ enum Handle { pub struct NativeMenu { _menu: Menu, items: HashMap<String, (String, Value, Handle)>, + events: Receiver<MenuEvent>, last_refresh: f64, } @@ -31,7 +33,15 @@ fn accel(sc: &str) -> Option<Accelerator> { } impl NativeMenu { - pub fn install(app: &mut DesignApp) -> Self { + pub fn install(app: &mut DesignApp, ctx: &egui::Context) -> Self { + // A click in the menu bar reaches the app with no input over the window, so nothing would + // draw the frame that runs the command until the pointer or a key comes back: wake egui. + let (tx, events) = channel(); + let repaint = ctx.clone(); + MenuEvent::set_event_handler(Some(move |e: MenuEvent| { + let _ = tx.send(e); + repaint.request_repaint(); + })); let menu = Menu::new(); let mut items = HashMap::new(); let mut counter = 0usize; @@ -72,11 +82,11 @@ impl NativeMenu { (p.is_null() && accel(sc).is_some()).then(|| id.clone()) }) .collect(); - Self { _menu: menu, items, last_refresh: 0.0 } + Self { _menu: menu, items, events, last_refresh: 0.0 } } pub fn poll(&mut self, app: &mut DesignApp, ctx: &egui::Context) { - while let Ok(ev) = MenuEvent::receiver().try_recv() { + while let Ok(ev) = self.events.try_recv() { if let Some((cmd, params, _)) = self.items.get(ev.id.as_ref()) { menus::activate_native(app, ctx, &cmd.clone(), ¶ms.clone()); } From e08f3c5997fc284b493b650cf702548e580985f2 Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Tue, 13 Oct 2026 14:51:53 +0000 Subject: [PATCH 147/238] test(paths): path edits on a locked layer change nothing (#735) --- crates/engine/src/cmd/object.rs | 44 +++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index a03e055ae..ecc6a7586 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -3322,6 +3322,50 @@ mod path_edit_tests { assert!(s.execute("path.appendAnchor", &json!({"id": id, "anchor": {"p": [0, 0]}})).is_err()); assert!(s.execute("path.close", &json!({"id": id, "subpath": 4})).is_err()); } + + /// Every edit that `path_editable` guards, aimed at the path `id` of `session_with_path`. + fn path_edits(id: u64) -> Vec<(&'static str, serde_json::Value)> { + vec![ + ("path.appendAnchor", json!({"id": id, "anchor": {"p": [400, 250]}})), + ("path.setAnchor", json!({"id": id, "anchor": 0, "p": [90, 110]})), + ("path.close", json!({"id": id})), + ("path.moveAnchors", json!({"id": id, "anchors": [[0, 0]], "dx": 10, "dy": 10})), + ("path.reverse", json!({"ids": [id]})), + ("path.addAnchor", json!({"id": id, "at": [150, 100]})), + ("path.deleteAnchor", json!({"id": id, "subpath": 0, "anchor": 0})), + ("path.convertAnchor", json!({"id": id, "subpath": 0, "anchor": 0})), + ("path.erase", json!({"id": id, "points": [[120, 100], [180, 100]]})), + ("path.smooth", json!({"id": id, "points": [[120, 100], [180, 100]]})), + ("path.split", json!({"id": id, "at": [150, 100]})), + ] + } + + fn assert_no_path_edit(s: &mut crate::Session, id: u64, why: &str) { + let before = anchors(s, id); + for (cmd, p) in path_edits(id) { + assert!(s.execute(cmd, &p).is_err(), "{cmd} {why}"); + } + assert_eq!(anchors(s, id), before, "{why}"); + assert!(!s.doc().unwrap().doc.item(ItemId(id)).unwrap().path.subpaths[0].closed, "{why}"); + } + + #[test] + fn path_edits_leave_a_path_on_a_locked_layer_alone() { + let (mut s, id) = session_with_path(); + let layer = s.doc().unwrap().doc.item(ItemId(id)).unwrap().layer; + s.execute("layer.set", &json!({"id": layer.0, "locked": true})).unwrap(); + assert_no_path_edit(&mut s, id, "on a locked layer"); + } + + #[test] + fn path_edits_leave_a_path_in_a_locked_group_alone() { + let (mut s, id) = session_with_path(); + let other = s.execute("frame.create", &json!({"rect": [400, 400, 450, 450]})).unwrap()["id"].as_u64().unwrap(); + let group = s.execute("object.group", &json!({"ids": [id, other]})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.lock", &json!({"ids": [group]})).unwrap(); + assert!(!s.doc().unwrap().doc.item(ItemId(id)).unwrap().locked, "only the group is locked"); + assert_no_path_edit(&mut s, id, "in a locked group"); + } } #[cfg(test)] From 2be4588658b72df187ee88ba31fb033d362e1e8b Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Tue, 13 Oct 2026 14:51:54 +0000 Subject: [PATCH 148/238] test(properties): the Stroke link test starts with Properties active (#736) --- crates/ui-egui/src/panel_docking_tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index 65879b1a0..be36fa920 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -307,6 +307,7 @@ fn properties_stroke_link_opens_the_stroke_panel_in_a_custom_workspace() { app.run("file.new", json!({"width":480,"height":360})).unwrap(); app.run("frame.create", json!({"rect":[80,70,330,230],"content":"none"})).unwrap(); app.run("window.panel.float", json!({"panel":"layers"})).unwrap(); + app.run("window.panel", json!({"panel":"properties"})).unwrap(); assert!(!app.ui.docking.as_ref().unwrap().contains(&"stroke".into())); let mut h = Harness::builder().with_size(vec2(1280.0, 800.0)).build_ui_state( |ui, app: &mut DesignApp| { From 2d5abba78f23f735bc7c48ce5aea9622ddd3b418 Mon Sep 17 00:00:00 2001 From: Nick Marello <69686627+Nmarello@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:02:47 -0700 Subject: [PATCH 149/238] #21: opening or dropping an InDesign file says how to bring it over file.open on a .indd reported "not a DesignCraft document: expected value at line 1 column 1"; file.openBytes with a .indd name failed the same way, and a dropped .indd was placed as a graphic. All now say InDesign documents and templates can't be opened yet and point to File > Export > InDesign Markup (IDML), which DesignCraft already opens. The check is by extension, before anything is read. Also adds my contributors/people.toml entry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- contributors/people.toml | 5 +++++ crates/engine/src/cmd/file.rs | 36 +++++++++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 6 ++++-- 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 67a8fdf30..8145ee287 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -30,3 +30,8 @@ sync_github_name = true [people.thaynej] real_name = "Jeffrey Thayne" + +[people.Nmarello] +real_name = "Nick Marello" +display_name = "Nick Marello" +sync_github_name = false diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index a1919b89e..b9ad7f055 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -157,8 +157,24 @@ fn file_sample(s: &mut Session, _p: &Value) -> Result<Value> { Ok(json!({"index": i})) } +/// An InDesign file that DesignCraft can't open yet, with how to bring it over. Decoding one as a +/// DesignCraft document only reported "not a DesignCraft document: expected value at line 1 +/// column 1" (#21). +fn foreign_document(name: &str) -> Option<&'static str> { + let ext = std::path::Path::new(name).extension().and_then(|e| e.to_str())?.to_ascii_lowercase(); + match ext.as_str() { + "indd" | "indt" => Some( + "InDesign documents (.indd) and templates (.indt) can't be opened yet. In InDesign, choose File ▸ Export… ▸ InDesign Markup (IDML) and open the .idml in DesignCraft.", + ), + _ => None, + } +} + fn file_open(s: &mut Session, p: &Value) -> Result<Value> { let path = str_param(p, "path").ok_or_else(|| bad("file.open", "missing `path`"))?; + if let Some(hint) = foreign_document(path) { + return Err(EngineError::Other(format!("{path}: {hint}"))); + } #[cfg(not(target_arch = "wasm32"))] { if path.to_ascii_lowercase().ends_with(".idml") { @@ -219,6 +235,11 @@ fn document_fonts_folder(dir: &std::path::Path) -> std::path::PathBuf { } fn file_open_bytes(s: &mut Session, p: &Value) -> Result<Value> { + if let Some(name) = str_param(p, "name") + && let Some(hint) = foreign_document(name) + { + return Err(EngineError::Other(format!("{name}: {hint}"))); + } let b = base64_decode(str_param(p, "base64").unwrap_or("")); // IDML packages are recognised by their stored `mimetype` first entry. if designcraft_idml::is_idml(&b) { @@ -866,6 +887,21 @@ mod document_fonts_tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn an_indesign_file_says_how_to_bring_it_over() { + // The extension decides (any case), not the bytes: no InDesign-made file is needed. + let dir = temp_dir("indd"); + let path = dir.join("Brochure.INDD"); + std::fs::write(&path, b"not a DesignCraft document").unwrap(); + let mut s = Session::new(); + let e = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap_err().to_string(); + assert!(e.contains("InDesign Markup (IDML)") && !e.contains("expected value"), "{e}"); + let e = s.execute("file.openBytes", &json!({"name": "Brochure.indt", "base64": ""})).unwrap_err().to_string(); + assert!(e.contains("InDesign Markup (IDML)"), "{e}"); + assert!(s.doc().is_err(), "nothing was opened"); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn the_document_fonts_folder_is_found_whatever_its_case() { const FAMILY: &str = "DocFont Case Test"; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fc7a3e634..00712f052 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1230,7 +1230,9 @@ impl DesignApp { /// and IDML. pub fn opens_as_document(name: &str) -> bool { let n = name.to_ascii_lowercase(); - [".designcraft", ".idml"].iter().any(|ext| n.ends_with(ext)) + // InDesign files go to the open path too, which says how to bring them over (as IDML) + // instead of failing to place them as a graphic. + [".designcraft", ".idml", ".indd", ".indt"].iter().any(|ext| n.ends_with(ext)) } pub fn now_ms() -> f64 { @@ -1856,7 +1858,7 @@ mod tests { #[test] fn dropped_documents_open_and_the_rest_is_placed() { - for name in ["a.designcraft", "B.IDML"] { + for name in ["a.designcraft", "B.IDML", "c.indd", "D.INDT"] { assert!(opens_as_document(name), "{name}"); } for name in ["a.png", "b.pdf", "idml.txt", "c.idml.zip"] { From ac8f44d749fb7a2553e9c1e6e43a885da7a4c827 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:17:58 +0200 Subject: [PATCH 150/238] Images: reject EPS bounding boxes with nonfinite corners or size (#740) --- crates/images/src/eps.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/images/src/eps.rs b/crates/images/src/eps.rs index 01d959292..48b42d3a6 100644 --- a/crates/images/src/eps.rs +++ b/crates/images/src/eps.rs @@ -29,7 +29,10 @@ pub fn bounding_box(bytes: &[u8]) -> Option<[f64; 4]> { let read = |key: &str| { head.lines().find_map(|l| { let v: Vec<f64> = l.strip_prefix(key)?.split_whitespace().filter_map(|t| t.parse().ok()).collect(); - (v.len() == 4 && v[2] > v[0] && v[3] > v[1]).then(|| [v[0], v[1], v[2], v[3]]) + // Finite corners and a finite size (subtracting extreme corners can overflow to infinity). + let [x0, y0, x1, y1] = <[f64; 4]>::try_from(v).ok()?; + let finite = [x0, y0, x1, y1, x1 - x0, y1 - y0].iter().all(|c| c.is_finite()); + (finite && x1 > x0 && y1 > y0).then_some([x0, y0, x1, y1]) }) }; read("%%HiResBoundingBox:").or_else(|| read("%%BoundingBox:")) @@ -100,4 +103,16 @@ mod tests { assert_eq!(crate::pixel_size(&p), Some((10, 5))); assert!(!is_eps(b"%!PS-Adobe-3.0\nnot encapsulated")); } + + #[test] + fn eps_bounding_box_rejects_nonfinite_sizes() { + let bb = |line: &str| bounding_box(format!("%!PS-Adobe-3.0 EPSF-3.0\n{line}\n").as_bytes()); + assert_eq!(bb("%%BoundingBox: 0 0 100 50"), Some([0.0, 0.0, 100.0, 50.0])); + assert_eq!(bb("%%BoundingBox: 0 0 inf 50"), None); + assert_eq!(bb("%%BoundingBox: -inf 0 100 50"), None); + assert_eq!(bb("%%BoundingBox: -1e308 0 1e308 50"), None); + assert_eq!(bb("%%BoundingBox: 100 50 0 0"), None); + // A bad high-resolution box falls back to the plain one. + assert_eq!(bb("%%HiResBoundingBox: 0 0 inf inf\n%%BoundingBox: 0 0 10 20"), Some([0.0, 0.0, 10.0, 20.0])); + } } From 0820ef857c94e3259a4104d0d14fcaed2463167d Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:17:59 +0200 Subject: [PATCH 151/238] MCP: render_page and screenshot are not read-only because they can write to path (#733) --- crates/mcp/src/tests.rs | 10 ++++++++++ crates/mcp/src/tools.rs | 4 ++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 3051a5ad4..0f3cdd56c 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -167,6 +167,16 @@ fn tool_definitions_are_well_formed() { } } +#[test] +fn tools_that_write_files_are_not_read_only() { + // `render_page` and `screenshot` write a file when given `path`, so they must not advertise `readOnlyHint: true`. + for name in ["render_page", "screenshot", "export_png"] { + let definition = tool_definitions().into_iter().find(|v| v["name"] == name).unwrap(); + assert!(definition["inputSchema"]["properties"].get("path").is_some(), "{name} takes a path"); + assert_eq!(definition["annotations"]["readOnlyHint"], false, "{name} writes files"); + } +} + // ---------- layout through the tools ---------- #[test] diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index c51bac8ed..88c3a6097 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -232,7 +232,7 @@ pub fn tool_definitions() -> Vec<Value> { }), &[], ), - true, + false, ), tool( "export_png", @@ -249,7 +249,7 @@ pub fn tool_definitions() -> Vec<Value> { "Screenshot", &app_only("Capture the whole app window (menus, panels, canvas) and return it as a PNG image. Optionally also keep it at `path`."), obj(json!({"path": string("Also write the PNG here")}), &[]), - true, + false, ), // ----- input ----- tool( From e329cb80237b39b04d0c6da347418407502ab8df Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:18:08 +0200 Subject: [PATCH 152/238] Fix overset preflight page for frames inside translated groups (#738) --- crates/engine/src/cmd/preflight.rs | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/preflight.rs b/crates/engine/src/cmd/preflight.rs index f5ee8dfdf..e4ace2b6b 100644 --- a/crates/engine/src/cmd/preflight.rs +++ b/crates/engine/src/cmd/preflight.rs @@ -88,7 +88,7 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec<Issue> { missing_glyphs(&cs, &story.text, 0, &mut missing); if cs.is_overset() { let last = story.frames.last().copied(); - let page = last.and_then(|f| d.find(f).zip(d.item(f))).and_then(|(loc, it)| page_of(d, loc.spread, it, Affine::IDENTITY)); + let page = last.and_then(|f| d.find(f).zip(d.item(f))).and_then(|(loc, it)| page_of(d, loc.spread, it, d.parent_xf(&loc))); let n = story.text[cs.overset_at.unwrap_or(0).min(story.len())..].chars().count(); out.push(Issue { severity: "error", kind: "overset", message: format!("Overset text: {n} characters"), item: last.map(|i| i.0), page }); } @@ -329,3 +329,29 @@ mod missing_glyph_tests { assert!(found[0].1.ends_with(" and 10 more"), "{}", found[0].1); } } + +#[cfg(test)] +mod overset_page_tests { + use serde_json::json; + + use crate::Session; + + /// An overset frame inside a group moved onto the right-hand page was reported on the left one. + #[test] + fn overset_frame_in_a_moved_group_names_its_displayed_page() { + let mut s = Session::new(); + s.execute("file.new", &json!({"facingPages": true, "pages": 3, "width": 612, "height": 792})).unwrap(); + let frame = json!({"spread": 1, "rect": [72, 72, 140, 100], "content": "text", "text": "overflowing words ".repeat(100)}); + let a = s.execute("frame.create", &frame).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("frame.create", &json!({"spread": 1, "rect": [300, 300, 350, 350]})).unwrap()["id"].as_u64().unwrap(); + let g = s.execute("object.group", &json!({"ids": [a, b]})).unwrap()["id"].as_u64().unwrap(); + let overset_page = |s: &mut Session| { + let r = s.execute("preflight.run", &json!({})).unwrap(); + let issue = r["issues"].as_array().unwrap().iter().find(|i| i["kind"] == "overset").unwrap().clone(); + issue["page"].as_u64().unwrap() + }; + assert_eq!(overset_page(&mut s), 1); + s.execute("transform.move", &json!({"dx": 612, "dy": 0, "ids": [g]})).unwrap(); + assert_eq!(overset_page(&mut s), 2); + } +} From b8e7e8ede681db7abfd90d4f9ba7f93d42394744 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:19:22 +0200 Subject: [PATCH 153/238] fix(render): cap the failed-image cache at 64 entries (#728) --- crates/render/src/images.rs | 42 ++++++++++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs index 117a0fa11..465867e82 100644 --- a/crates/render/src/images.rs +++ b/crates/render/src/images.rs @@ -3,7 +3,7 @@ //! //! Entries are keyed by the address of the asset's encoded bytes (`Arc<Vec<u8>>`) and hold a //! clone of that `Arc`, so the address can't be reused while cached. Least recently used levels -//! are evicted beyond a memory budget. +//! are evicted beyond a memory budget; at most `MAX_BAD` failed assets are remembered. use std::collections::HashMap; use std::sync::{Arc, Mutex}; @@ -15,6 +15,9 @@ use crate::{decode_pixmap_page, halve}; /// Decoded pixels kept (bytes). const BUDGET: usize = 768 << 20; +/// Failed assets remembered; the oldest is forgotten (and retried if drawn again) beyond this. +const MAX_BAD: usize = 64; + struct Entry { _bytes: Arc<Vec<u8>>, pm: Arc<Pixmap>, @@ -28,7 +31,20 @@ struct Cache { bytes: usize, clock: u64, /// Assets that failed to decode (don't retry every frame). - bad: HashMap<(usize, u32), Arc<Vec<u8>>>, + /// Value: (insertion stamp, the encoded bytes keeping the address reserved). + bad: HashMap<(usize, u32), (u64, Arc<Vec<u8>>)>, +} + +impl Cache { + /// Remember a failed asset, evicting the oldest failures beyond [`MAX_BAD`]. + fn mark_bad(&mut self, key: (usize, u32), bytes: &Arc<Vec<u8>>) { + self.clock += 1; + self.bad.insert(key, (self.clock, bytes.clone())); + while self.bad.len() > MAX_BAD { + let Some(oldest) = self.bad.iter().min_by_key(|(_, (stamp, _))| *stamp).map(|(k, _)| *k) else { break }; + self.bad.remove(&oldest); + } + } } static CACHE: Mutex<Option<Cache>> = Mutex::new(None); @@ -97,7 +113,7 @@ pub fn decoded(bytes: &Arc<Vec<u8>>, page: u32) -> Option<Arc<Pixmap>> { Some(pm) } None => { - with(|c| c.bad.insert((id, page), bytes.clone())); + with(|c| c.mark_bad((id, page), bytes)); None } } @@ -133,3 +149,23 @@ pub fn clear() { c.bytes = 0; }); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn failed_assets_are_capped_and_oldest_is_dropped() { + let mut c = Cache::default(); + let buffers: Vec<Arc<Vec<u8>>> = (0..MAX_BAD + 10).map(|i| Arc::new(vec![i as u8; 4])).collect(); + for b in &buffers { + c.mark_bad((Arc::as_ptr(b) as usize, 0), b); + } + assert_eq!(c.bad.len(), MAX_BAD); + let first = &buffers[0]; + assert!(!c.bad.contains_key(&(Arc::as_ptr(first) as usize, 0))); + assert_eq!(Arc::strong_count(first), 1, "evicted buffer must be released"); + let last = &buffers[MAX_BAD + 9]; + assert!(c.bad.contains_key(&(Arc::as_ptr(last) as usize, 0))); + } +} From dfb41f5aea569e06aac0dfd09b2fe4ffe1fbbd3a Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:42:57 +0200 Subject: [PATCH 154/238] fix(cli): reject --pdf-options that is not a JSON object instead of panicking (#731) --- apps/designcraft-cli/src/main.rs | 6 +++++- apps/designcraft-cli/tests/cli.rs | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs index dd62cd91d..4bee26a79 100644 --- a/apps/designcraft-cli/src/main.rs +++ b/apps/designcraft-cli/src/main.rs @@ -261,7 +261,11 @@ fn run(args: &[String]) -> Result<(), String> { "--page" => page = val()?.parse().map_err(|_| "bad --page")?, "--scale" => scale = val()?.parse().map_err(|_| "bad --scale")?, "--pdf-options" => { - pdf_opts = serde_json::from_str(&val()?).map_err(|e| format!("--pdf-options: {e}"))?; + pdf_opts = match serde_json::from_str(&val()?).map_err(|e| format!("--pdf-options: {e}"))? { + Value::Null => json!({}), + o @ Value::Object(_) => o, + _ => return Err("--pdf-options: expected a JSON object".to_string()), + }; } "--export" => { let out = val()?; diff --git a/apps/designcraft-cli/tests/cli.rs b/apps/designcraft-cli/tests/cli.rs index 92f68ab56..b2eed1b75 100644 --- a/apps/designcraft-cli/tests/cli.rs +++ b/apps/designcraft-cli/tests/cli.rs @@ -138,6 +138,24 @@ fn run_export_options_come_before_their_export() { 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() { + let dir = std::env::temp_dir().join(format!("dc-cli-pdfopts-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let pdf = dir.join("out.pdf"); + let pdf = pdf.to_str().unwrap(); + for val in ["[]", "true", "1", r#""x""#] { + let out = cli().args(["run", "--sample", "--pdf-options", val, "--export", pdf]).output().unwrap(); + let err = String::from_utf8_lossy(&out.stderr); + assert_eq!(out.status.code(), Some(1), "{val}: {err}"); + assert!(err.contains("--pdf-options") && !err.contains("panicked"), "{val}: {err}"); + } + let out = cli().args(["run", "--sample", "--pdf-options", "null", "--export", pdf]).output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let _ = std::fs::remove_dir_all(&dir); +} + #[cfg(unix)] #[test] fn raster_export_preserves_an_existing_reader_and_private_symlink_target() { From cab9200975ef1b6edd5afe97ad10f8e8cf5266f9 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:43:09 +0200 Subject: [PATCH 155/238] fix: eyedropper ignores inactive states of a multi-state object (#737) --- crates/tools/src/tests.rs | 51 +++++++++++++++++++++++++++++++++++++++ crates/tools/src/xform.rs | 6 +++-- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 6776902aa..31fd04540 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -876,3 +876,54 @@ fn eyedropper_samples_a_placed_image_and_matches_other_objects() { let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 120.0 + off.x, 130.0 + off.y)); assert_eq!(a, vec![Action::Exec("object.matchAttributes".into(), serde_json::json!({"from": photo.0}))]); } + +#[test] +fn eyedropper_ignores_inactive_states_of_a_multi_state_object() { + use designcraft_doc::{Asset, AssetId, Content, Graphic}; + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + d.assets.insert( + AssetId(1), + std::sync::Arc::new(Asset { + id: AssetId(1), + name: "photo.png".into(), + mime: "image/png".into(), + link: None, + data: std::sync::Arc::new(vec![]), + pixels: Some((100, 100)), + page: 0, + }), + ); + let area = Rect::new(100.0, 100.0, 200.0, 200.0); + let raster = { + let mut it = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(area)); + it.content = Content::Graphic(Graphic { + asset: AssetId(1), + size: (100.0, 100.0), + xf: designcraft_geom::Affine::translate((0.0, 0.0)), + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + }); + it + }; + let mut vector = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(area)); + vector.fill.swatch = "[Black]".into(); + let target = add_rect(&mut d, Rect::new(400.0, 100.0, 450.0, 150.0)); + let s = Selection { items: vec![target], ..Default::default() }; + let sample = Action::Exec("color.sample".into(), serde_json::json!({"at": [120.0, 130.0], "spread": {"kind": "doc", "index": 0}})); + // Children [vector, raster] with the raster on top: it samples only while it is the active state. + for (active, samples) in [(1, true), (0, false)] { + let mut group = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(area)); + group.content = Content::Group { items: vec![std::sync::Arc::new(vector.clone()), std::sync::Arc::new(raster.clone())] }; + group.states = vec!["a".into(), "b".into()]; + group.active_state = active; + let mut d2 = d.clone(); + d2.insert_item(SpreadRef::Doc(0), group, None).unwrap(); + let (c, l) = (Cache::new(), CanvasLayout::new(&d2, false)); + let off = l.xf(SpreadRef::Doc(0)).translation(); + let cx = ctx(&d2, &s, &c, &l); + let a = create("eyedropper").pointer(&cx, &PointerEvent::new(PointerKind::Down, 120.0 + off.x, 130.0 + off.y)); + assert_eq!(a.contains(&sample), samples, "active state {active}: {a:?}"); + } +} diff --git a/crates/tools/src/xform.rs b/crates/tools/src/xform.rs index e0bcd24f3..37d2acf82 100644 --- a/crates/tools/src/xform.rs +++ b/crates/tools/src/xform.rs @@ -257,8 +257,10 @@ fn image_under(cx: &ToolContext, p: Point) -> Option<(SpreadRef, Point)> { return None; } let xf = outer * it.xf; - if let Content::Group { items } = &it.content { - return items.iter().rev().find_map(|c| shows(d, c, xf, at, depth + 1)); + if let Content::Group { .. } = &it.content { + // Only the shown children count: a multi-state object shows just its active state. + let shown: Vec<&Item> = it.shown_children().map(|c| &**c).collect(); + return shown.into_iter().rev().find_map(|c| shows(d, c, xf, at, depth + 1)); } let graphic = matches!(it.content, Content::Graphic(_)); // Unfilled frames (a text frame over the photo) let the image show through. From ec4ff485df8e87f21d766d1d010321e8bd5a4881 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:43:16 +0200 Subject: [PATCH 156/238] Keep a missing graphic missing when it is pasted into another document (#720) --- crates/engine/src/cmd/links.rs | 42 +++++++++++++++++++++++++++++++++ crates/engine/src/cmd/object.rs | 25 +++++++++++++------- 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 73de3ea7e..933fdfbd4 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -427,4 +427,46 @@ mod shared_asset_tests { assert_eq!((g.asset, g.crop), (same, [1.0; 4])); assert_eq!(dst.assets.len(), 2); } + + #[test] + fn pasted_missing_graphic_does_not_show_an_unrelated_target_asset() { + use designcraft_doc::build::NewDocument; + use designcraft_doc::{Asset, Graphic, Item, ItemId, Shape, SpreadRef}; + let mut src = Document::new(&NewDocument::default()); + let mut dst = Document::new(&NewDocument::default()); + // The source graphic points at an asset the source does not have; the target uses that id for another image. + let missing = AssetId(src.alloc()); + dst.assets.insert( + missing, + Arc::new(Asset { + id: missing, + name: "p.png".into(), + mime: "image/png".into(), + link: None, + data: Arc::new(png(8, 4)), + pixels: None, + page: 0, + }), + ); + let iid = ItemId(src.alloc()); + let mut it = Item::new( + iid, + src.default_layer(), + Shape::Rectangle, + designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(0.0, 0.0, 9.0, 9.0)), + ); + it.content = Content::Graphic(Graphic { + asset: missing, + size: (4.0, 4.0), + xf: designcraft_geom::Affine::IDENTITY, + auto_fit: Default::default(), + fit_align: 4, + crop: [1.0; 4], + }); + src.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + let ids = super::super::object::duplicate_from(&mut dst, &src, &[iid], SpreadRef::Doc(0), designcraft_geom::Vec2::ZERO).unwrap(); + let g = dst.item(ids[0]).and_then(|i| i.graphic()).unwrap(); + assert_ne!(g.asset, missing); + assert!(!dst.assets.contains_key(&g.asset)); + } } diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index aa5a2cc01..1d796cb07 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -6,7 +6,8 @@ use std::sync::Arc; use designcraft_color::BlendMode; use designcraft_doc::{ - Content, Document, Fill, Item, ItemId, ParaFormat, Selection, Shape, SpreadRef, Story, StoryId, Stroke, TextFrame, TextFrameOptions, TextSel, + AssetId, Content, Document, Fill, Item, ItemId, ParaFormat, Selection, Shape, SpreadRef, Story, StoryId, Stroke, TextFrame, TextFrameOptions, + TextSel, }; use designcraft_geom::{Affine, Point, Rect, Vec2, shapes}; use serde_json::{Value, json}; @@ -966,13 +967,24 @@ pub fn duplicate_from(dst: &mut Document, src: &Document, ids: &[ItemId], to: Sp Ok(out) } +/// The id to reference in `dst` for asset `id` of `src`. When `src` lacks the asset (a missing graphic), an id that `dst` +/// already uses for a different asset is swapped for a fresh unused one so the reference stays missing instead of showing +/// an unrelated image. +fn adopt_or_dangle(dst: &mut Document, src: &Document, id: AssetId) -> AssetId { + match dst.adopt_asset(src, id) { + Some(a) => a, + None if dst.assets.contains_key(&id) => AssetId(dst.alloc()), + None => id, + } +} + fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut HashMap<StoryId, StoryId>) { it.id = ItemId(dst.alloc()); let my_id = it.id; if let Some(m) = it.media.as_mut() && let Some(p) = m.poster { - m.poster = dst.adopt_asset(src, p).or(Some(p)); + m.poster = Some(adopt_or_dangle(dst, src, p)); } match &mut it.content { Content::Text(tf) => { @@ -984,9 +996,8 @@ fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut Has st.id = n; let mut assets = HashMap::new(); for a in designcraft_doc::story_asset_refs(&st) { - if let Some(na) = dst.adopt_asset(src, a) - && na != a - { + let na = adopt_or_dangle(dst, src, a); + if na != a { assets.insert(a, na); } } @@ -1003,9 +1014,7 @@ fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut Has } } Content::Graphic(g) => { - if let Some(a) = dst.adopt_asset(src, g.asset) { - g.asset = a; - } + g.asset = adopt_or_dangle(dst, src, g.asset); } Content::Group { items } => { for c in items.iter_mut() { From 79b9971939deae7ae03305053cc008ba49f53372 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:43:15 +0200 Subject: [PATCH 157/238] fix(pdf): leave form fields and media out of PDF/A-2b exports (#743) --- crates/pdf/src/export.rs | 6 +++++- crates/pdf/src/tests.rs | 26 ++++++++++++++++++++++++++ docs/target-app-parity.md | 2 +- 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index dcf8abb83..ceceefeff 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -215,12 +215,16 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions) // group that got past it, with its page. crate::pdfx::cmyk_group_spaces(&mut bytes); } - // Form fields (Buttons and Forms) and, for interactive PDF, video and sound; not in PDF/X. + // Form fields (Buttons and Forms) and, for interactive PDF, video and sound; not in PDF/X + // or PDF/A: this update comes after krilla's validator ran, and its NeedAppearances fields and + // Screen annotations aren't allowed in PDF/A. let fields = crate::forms::collect(doc, &sheets); let media = if opts.media { crate::forms::collect_media(doc, &sheets) } else { Vec::new() }; if !fields.is_empty() || !media.is_empty() { if opts.standard == Standard::PdfX4 { warnings.push("PDF/X-4: form fields and media were left out".into()); + } else if opts.standard == Standard::PdfA2b { + warnings.push("PDF/A-2b: form fields and media were left out".into()); } else { match crate::forms::add_fields(&bytes, &fields, &media) { Some(b) => bytes = b, diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 9ac53d057..c58c7533b 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -402,6 +402,32 @@ fn drawn(bytes: &[u8]) -> (Vec<(String, kurbo::Rect)>, Vec<kurbo::Rect>) { (rec.0, rec.1) } +#[test] +fn pdfa_leaves_out_form_fields_added_after_validation() { + let mut d = doc_with_text("Archive me"); + let lid = d.default_layer(); + let id = ItemId(d.alloc()); + let mut b = Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(36.0, 400.0, 200.0, 430.0))); + b.form_field = Some(designcraft_doc::FormField { + kind: designcraft_doc::FieldKind::TextField, + name: "name".into(), + value: String::new(), + options: Vec::new(), + multiline: false, + required: false, + font_size: 0.0, + }); + d.insert_item(SpreadRef::Doc(0), b, None).unwrap(); + let plain = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap(); + assert!(String::from_utf8_lossy(&plain).contains("/AcroForm"), "control: a plain export has the field"); + let opts = PdfOptions { standard: Standard::PdfA2b, created: Some(1_700_000_000), ..Default::default() }; + let a = export_pdf_with_report(&d, &Cache::new(), &opts).unwrap(); + let text = String::from_utf8_lossy(&a.bytes).to_string(); + assert!(text.contains("pdfaid")); + assert!(!text.contains("/AcroForm") && !text.contains("NeedAppearances"), "PDF/A must not get the form update"); + assert!(a.warnings.iter().any(|w| w.contains("PDF/A-2b") && w.contains("left out")), "{:?}", a.warnings); +} + #[test] fn missing_glyphs_print_as_boxes() { // Source Serif 4 lacks 語: its .notdef box is drawn as a path, not as a .notdef glyph diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index eb7d7e5c1..cbdc17974 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -357,7 +357,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Move table rows and columns (`table.moveRow`, `table.moveColumn`, Table panel buttons; with whole rows or columns selected, drag them with the Type tool — `table.dropCells`). - Distribute Rows Evenly (`table.distributeRows`, Table menu and panel): selected rows receive equal fixed heights preserving their composed total height, with undo/redo. Row-spanning merges and rows outside the composed frames are rejected; shrinking tall cells can overset their text. - Object States (`states.create/show/rename/release/list`, Object States panel): multi-state objects show one state on screen and in PDF. -- Buttons (`button.set/clear/list`, Buttons and Forms panel): go to page / next / previous / first / last / URL as PDF link annotations. Form fields too (`form.set/clear/list`: text fields, check boxes, combo and list boxes, signature fields; required, multiline, defaults) exported as AcroForm widgets through an incremental update (not in PDF/X). +- Buttons (`button.set/clear/list`, Buttons and Forms panel): go to page / next / previous / first / last / URL as PDF link annotations. Form fields too (`form.set/clear/list`: text fields, check boxes, combo and list boxes, signature fields; required, multiline, defaults) exported as AcroForm widgets through an incremental update (not in PDF/X or PDF/A). - Power Zoom (Hand tool: press and hold still for half a second, or Alt-press): zoom out to the spread, aim the red view rectangle, release to zoom back in there. - Graphic cells (`table.placeGraphic`, `table.textCell`, Table › Convert Cell Type, Table panel): images in table cells, fitted or filled, clipped to the cell on screen and in PDF. - Live Distribute (Space while dragging a selection handle; `transform.resize {distribute}`): objects keep their size and spread with the bounds. From 3c070a8cdd3be018cc19fb07423c9adb357aa6e1 Mon Sep 17 00:00:00 2001 From: Saad Abdullah <saadkhantareen@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:49:51 +0000 Subject: [PATCH 158/238] fix(geom): decompose of a flattened transform reports 0 shear instead of NaN --- crates/geom/src/decompose.rs | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/crates/geom/src/decompose.rs b/crates/geom/src/decompose.rs index 4c06a4261..0ea247366 100644 --- a/crates/geom/src/decompose.rs +++ b/crates/geom/src/decompose.rs @@ -24,7 +24,13 @@ pub fn decompose(m: Affine) -> Decomposed { let det = a * d - b * c; let rotation = (-b).atan2(a).to_degrees(); let rotation = if rotation <= -180.0 + 1e-9 { 180.0 } else { rotation }; - Decomposed { scale_x: sx, scale_y: det / sx, rotation, shear: ((a * c + b * d) / det).atan().to_degrees() } + // A flattened transform (e.g. scale_y = 0, so `det` and the shear numerator are both 0) has + // no recoverable shear: 0 / 0 would be NaN, and a NaN here poisons the object when the + // caller feeds the decomposed values back into `compose` (transform.set does exactly that). + // Report 0 instead; a non-zero numerator over a zero determinant still limits to ±90°. + let shear = ((a * c + b * d) / det).atan().to_degrees(); + let shear = if shear.is_nan() { 0.0 } else { shear }; + Decomposed { scale_x: sx, scale_y: det / sx, rotation, shear } } /// The linear transform with these values (no translation); `decompose` inverts it. @@ -50,4 +56,29 @@ mod tests { // transform.rotate's convention: rotate(-θ) is θ counter-clockwise. assert!((decompose(Affine::rotate(-0.5)).rotation - 0.5f64.to_degrees()).abs() < 1e-9); } + + #[test] + fn flattened_transform_has_no_nan_shear() { + // Regression: an object scaled to zero height (scale_y = 0) is singular — its + // determinant and shear numerator are both 0, and decompose used to report NaN shear, + // which compose then turned into a NaN transform. Shear is unrecoverable there, so + // it reports 0 and every other field stays finite and exact. + for v in [ + Decomposed { scale_x: 1.0, scale_y: 0.0, rotation: 0.0, shear: 20.0 }, + Decomposed { scale_x: 2.0, scale_y: 0.0, rotation: 30.0, shear: -45.0 }, + ] { + let back = decompose(compose(&v)); + assert!(back.scale_x.is_finite() && back.scale_y.is_finite() && back.rotation.is_finite() && back.shear.is_finite(), "{v:?} → {back:?}"); + assert_eq!(back.shear, 0.0, "{v:?} → {back:?}"); + assert!((back.scale_x - v.scale_x).abs() < 1e-9, "{v:?} → {back:?}"); + assert_eq!(back.scale_y, 0.0, "{v:?} → {back:?}"); + assert!((back.rotation - v.rotation).abs() < 1e-9, "{v:?} → {back:?}"); + // Feeding the decomposed values back into compose stays finite too. + assert!(compose(&back).as_coeffs().iter().all(|c| c.is_finite()), "{v:?} → {back:?}"); + } + // Directly singular matrices, including one whose shear numerator is non-zero + // (that case limits to ±90°, not NaN). + assert_eq!(decompose(Affine::new([1.0, 0.0, 0.0, 0.0, 0.0, 0.0])).shear, 0.0); + assert!((decompose(Affine::new([1.0, 0.0, 1.0, 0.0, 0.0, 0.0])).shear.abs() - 90.0).abs() < 1e-9); + } } From 7e344e85f17737c02f7f4e5e57180ebca80d6df8 Mon Sep 17 00:00:00 2001 From: Govinda Roy <govindaroy.ofc94@gmail.com> Date: Wed, 14 Oct 2026 00:16:42 +0600 Subject: [PATCH 159/238] Compose: spaces at the end of a paragraph take no room on the line The paragraph composer counted a trailing space (or several) before the paragraph end or a forced line break in the line's width, so "2 VOOR " in a frame exactly as wide as "2 VOOR" broke before "VOOR" and the frame went overset. InDesign lets those spaces hang past the edge. They now add no width; the line's visible range already left them out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/compose/src/breaker.rs | 9 +++++++++ crates/compose/src/tests.rs | 15 +++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/crates/compose/src/breaker.rs b/crates/compose/src/breaker.rs index 754cfc3d0..2aa9b3ae7 100644 --- a/crates/compose/src/breaker.rs +++ b/crates/compose/src/breaker.rs @@ -303,8 +303,17 @@ fn items(glyphs: &[Glyph], hyph_after: &[bool], emergency_after: &[bool], sp: &S let n = glyphs.len(); let mut hy_cache = Vec::new(); let mut last_space = NONE; + // Spaces before the paragraph end or a forced break hang: they take no room on the line (as + // in InDesign, where "2 VOOR " fits a frame as wide as "2 VOOR"). + let mut hangs = vec![false; n]; + for i in (0..n).rev() { + hangs[i] = glyphs[i].is_space() && glyphs.get(i + 1).is_none_or(|nx| hangs[i + 1] || is_forced(nx.ch)); + } for (i, g) in glyphs.iter().enumerate() { let prev_hang = if i > 0 { right_hang(&glyphs[i - 1], sp) } else { 0.0 }; + if hangs[i] { + continue; + } if is_forced(g.ch) { it.push(Item::glue(0.0, INF, 0.0)); ig.push(i); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 7a0e790d5..fd7484fce 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -2929,3 +2929,18 @@ fn empty_paragraphs_get_no_bullet_or_number() { assert_eq!(list_labels(&cs, LABEL_CHARS), ["1.", "", "2.", ""], "list `{name}`"); } } + +#[test] +fn spaces_at_the_end_of_a_paragraph_take_no_room() { + // A frame as wide as "2 VOOR" also holds "2 VOOR " on one line: the trailing space hangs. + let (d, sid, _) = doc_with("2 VOOR", Rect::new(0.0, 0.0, 500.0, 100.0), ParaAttrs::default()); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let l = all_lines(&cs)[0]; + let width = l.end_x - l.x0; + for text in ["2 VOOR ", "2 VOOR ", "2 VOOR \u{2028}more"] { + let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, width + 0.02, 100.0), ParaAttrs::default()); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let first = all_lines(&cs)[0]; + assert_eq!(&text[first.range.clone()].trim_end(), &"2 VOOR", "{text:?}"); + } +} From 0a34316b82ea3f66b624eaa8a11648ec3d2c70c7 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Tue, 13 Oct 2026 20:16:51 +0200 Subject: [PATCH 160/238] feat(ui): Window menu in InDesign's order; Window > Workspace with Essentials Classic LYH Studio episode 6 starts with "Window > Workspace > Essential Classic" and opens panels with "Window > Color > Gradient & Swatches". DesignCraft's Window menu had no Workspace or Color submenu (panels sat in one "Panels" list) and no Essentials Classic workspace. - Window menu: Arrange, Workspace, then the panels alphabetically with InDesign's groups (Color: Color, Gradient, Swatches; Editorial; Interactive; Object & Layout; Output; Styles; Type & Tables; Utilities). Every panel stays reachable; Next/Previous Document moved into Arrange. - Window > Workspace: the built-in workspaces as "[Essentials]", the current one checked, Reset <current>, New Workspace. The app bar's switcher uses the same list and order. - Essentials Classic: the Control panel, no Properties stack, and an icon column of named panel groups ([Pages, Layers, Links] [Stroke, Color] [Swatches] [Effects] [Paragraph] [Text Wrap] [Character Styles, Paragraph Styles] [Gradient]); Window > Pages opens Pages from its group. Saved workspaces keep the groups. - Flyouts and floating panels name Pages, Layers and Properties. - Translations for Editorial, Object & Layout, Styles and Essentials Classic. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 1 + crates/ui-egui/src/chrome.rs | 14 +- crates/ui-egui/src/dock.rs | 85 ++++++++++--- crates/ui-egui/src/i18n.rs | 13 +- crates/ui-egui/src/i18n/ar.rs | 4 + crates/ui-egui/src/i18n/it.rs | 4 + crates/ui-egui/src/i18n/ja.rs | 4 + crates/ui-egui/src/i18n/pt_br.rs | 4 + crates/ui-egui/src/i18n/uk.rs | 4 + crates/ui-egui/src/lib.rs | 8 ++ crates/ui-egui/src/menus.rs | 212 +++++++++++++++++++++++++------ docs/target-app-parity.md | 2 +- docs/ui-parity.md | 2 +- 13 files changed, 284 insertions(+), 73 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..495aadc61 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-13** · The Window menu follows InDesign's order (Arrange, Workspace, then the panels alphabetically with Color, Editorial, Object & Layout, Styles… submenus), and Window › Workspace lists the workspaces as InDesign does, now with Essentials Classic: the Control panel and an icon column of named panel groups. No readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index c2f1cb48e..5e02276a7 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -63,19 +63,7 @@ pub fn app_bar(app: &mut DesignApp, ui: &mut egui::Ui) { crate::rtl::widget(ui, egui::RichText::new(format!("{shown} ▾")).font(semibold(11.5)).color(t.text)), |ui| { let customs: Vec<String> = app.ui.custom_workspaces.iter().map(|w| w.name.clone()).collect(); - for w in [ - "Essentials", - "Advanced", - "Book", - "Digital Publishing", - "Interactive for PDF", - "Printing and Proofing", - "Typography", - ] - .into_iter() - .map(str::to_string) - .chain(customs.iter().cloned()) - { + for w in crate::menus::WORKSPACES.iter().map(|w| w.to_string()).chain(customs.iter().cloned()) { if ui .selectable_label(w == current, crate::rtl::widget(ui, crate::i18n::workspace_name(&app.ui.language, &w))) .clicked() diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 0d581e992..46cb606e4 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -113,30 +113,77 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { }, ); } - // Collapsed icon strip (37 pt) to the left of the panel stack. - egui::Panel::right("icon_column").exact_size(37.0).resizable(false).frame(egui::Frame::NONE.fill(t.panel)).show(ui, |ui| { + // Collapsed icon strip (37 pt) to the left of the panel stack; a workspace's iconic groups + // (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, "«"); ui.add_space(4.0); - ui.vertical_centered(|ui| { - for (id, label, icon) in ICON_PANELS { - let open = app.ui.open_panel.as_deref() == Some(*id); - let floats = app.ui.floating.iter().any(|(p, _)| p == id); - let response = - icons::button(ui, icon, 28.0, open || floats, crate::i18n::tr(&app.ui.language, label)).interact(Sense::click_and_drag()); - if response.clicked() { - if floats { - ui.ctx().move_to_top(egui::LayerId::new(egui::Order::Middle, egui::Id::new(("floating_panel", *id)))); - } else { - app.ui.open_panel = if open { None } else { Some(id.to_string()) }; + match &groups { + Some(groups) => { + for (i, group) in groups.iter().enumerate() { + if i > 0 { + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 7.0), Sense::hover()); + ui.painter().line_segment([r.left_center(), r.right_center()], Stroke::new(1.0, t.border)); + } + for (id, label, icon) in group.iter().filter_map(|id| panel_entry(id)) { + let on = app.ui.open_panel.as_deref() == Some(id) || app.ui.floating.iter().any(|(p, _)| p == id); + let (r, response) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click_and_drag()); + if on { + ui.painter().rect_filled(r.shrink2(vec2(3.0, 1.0)), 3.0, t.tool_active); + } else if response.hovered() { + ui.painter().rect_filled(r.shrink2(vec2(3.0, 1.0)), 3.0, t.hover); + } + icons::paint(ui.painter(), egui::Rect::from_min_size(r.min + vec2(8.0, 4.0), vec2(18.0, 18.0)), icon, t.icon); + crate::rtl::paint( + ui.painter(), + r.left_center() + vec2(32.0, 0.0), + egui::Align2::LEFT_CENTER, + crate::i18n::tr(&app.ui.language, label), + semibold(11.5), + t.text, + ); + icon_clicked(app, ui, id, &response); } } - crate::panel_docking::legacy_tab(app, ui, id, &response); - ui.add_space(1.0); } - }); + None => { + ui.vertical_centered(|ui| { + for (id, label, icon) in ICON_PANELS { + let on = app.ui.open_panel.as_deref() == Some(*id) || app.ui.floating.iter().any(|(p, _)| p == id); + let response = icons::button(ui, icon, 28.0, on, crate::i18n::tr(&app.ui.language, label)).interact(Sense::click_and_drag()); + icon_clicked(app, ui, id, &response); + ui.add_space(1.0); + } + }); + } + } }); } +/// Width of the icon column: icons only, or icons with names for a workspace's groups. +fn icon_column_width(app: &DesignApp) -> f32 { + if app.ui.icon_groups.is_some() { 150.0 } else { 37.0 } +} + +/// (id, label, icon) of any dock panel. +fn panel_entry(id: &str) -> Option<(&'static str, &'static str, &'static str)> { + DOCK_TABS.iter().chain(ICON_PANELS).find(|p| p.0 == id).copied() +} + +/// A click on a panel's icon: raise it when it floats, else open or close its flyout. +fn icon_clicked(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, response: &egui::Response) { + if response.clicked() { + if app.ui.floating.iter().any(|(p, _)| p == id) { + ui.ctx().move_to_top(egui::LayerId::new(egui::Order::Middle, egui::Id::new(("floating_panel", id)))); + } else { + let open = app.ui.open_panel.as_deref() == Some(id); + app.ui.open_panel = if open { None } else { Some(id.to_string()) }; + } + } + 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()); @@ -152,10 +199,10 @@ pub fn flyout(app: &mut DesignApp, ctx: &egui::Context) { } let Some(id) = app.ui.open_panel.clone() else { return }; let t = Tokens::get(ctx); - let label = crate::i18n::tr(&app.ui.language, ICON_PANELS.iter().find(|p| p.0 == id).map(|p| p.1).unwrap_or("Panel")).to_owned(); + let label = crate::i18n::tr(&app.ui.language, panel_entry(&id).map(|p| p.1).unwrap_or("Panel")).to_owned(); let screen = ctx.content_rect(); let dock_w = if app.ui.dock_expanded { ctx.memory(|m| m.area_rect(egui::Id::new("dock")).map(|r| r.width())).unwrap_or(280.0) } else { 0.0 }; - let pos = egui::pos2(screen.max.x - 37.0 - dock_w - 262.0, 120.0); + let pos = egui::pos2(screen.max.x - icon_column_width(app) - dock_w - 262.0, 120.0); let mut open = true; egui::Area::new(egui::Id::new("panel_flyout")).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| { egui::Frame::popup(ui.style()).fill(t.panel).inner_margin(egui::Margin::same(0)).show(ui, |ui| { @@ -262,7 +309,7 @@ pub fn floating(app: &mut DesignApp, ctx: &egui::Context) { } let t = Tokens::get(ctx); for (id, at) in app.ui.floating.clone() { - let label = crate::i18n::tr(&app.ui.language, ICON_PANELS.iter().find(|p| p.0 == id).map(|p| p.1).unwrap_or("Panel")).to_owned(); + let label = crate::i18n::tr(&app.ui.language, panel_entry(&id).map(|p| p.1).unwrap_or("Panel")).to_owned(); let mut open = true; let mut dock = false; let r = egui::Window::new(label.clone()) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 5252c3881..ffa50c108 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -55,6 +55,10 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Cut", ["Ausschneiden", "Couper", "Cortar", "カット", "剪切"]), ("Copy", ["Kopieren", "Copier", "Copiar", "コピー", "复制"]), ("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]), + ("Editorial", ["Redaktion", "Éditorial", "Editorial", "エディトリアル", "编辑"]), + ("Object & Layout", ["Objekt und Layout", "Objet et mise en page", "Objeto y diseño", "オブジェクト&レイアウト", "对象和版面"]), + ("Styles", ["Formate", "Styles", "Estilos", "スタイル", "样式"]), + ("Essentials Classic", ["Essentials Classic", "Essentials Classic", "Essentials Classic", "Essentials Classic", "基本功能(经典)"]), ("Reverse Path", ["Pfadrichtung umkehren", "Inverser le tracé", "Invertir trazado", "パスの方向反転", "反转路径"]), ("Set Anchor", ["Ankerpunkt festlegen", "Définir le point d’ancrage", "Definir punto de ancla", "アンカーポイントを設定", "设置锚点"]), ("Clear", ["Löschen", "Effacer", "Borrar", "消去", "清除"]), @@ -2921,7 +2925,14 @@ pub fn style_name<'a>(lang: &str, name: &'a str) -> &'a str { /// Built-in workspace captions; custom workspace names remain unchanged. pub fn workspace_name<'a>(lang: &str, name: &'a str) -> &'a str { match name { - "Essentials" | "Advanced" | "Book" | "Digital Publishing" | "Interactive for PDF" | "Printing and Proofing" | "Typography" => tr(lang, name), + "Essentials" + | "Essentials Classic" + | "Advanced" + | "Book" + | "Digital Publishing" + | "Interactive for PDF" + | "Printing and Proofing" + | "Typography" => tr(lang, name), _ => name, } } diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index a182d09ec..f17794248 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Editorial", "التحرير"), + ("Object & Layout", "الكائن والتخطيط"), + ("Styles", "الأنماط"), + ("Essentials Classic", "الأساسيات الكلاسيكية"), ("Reverse Path", "عكس المسار"), ("Set Anchor", "تعيين نقطة الارتساء"), ("Clear", "مسح"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 75ece03f6..e389a0390 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Editorial", "Editoriale"), + ("Object & Layout", "Oggetto e layout"), + ("Styles", "Stili"), + ("Essentials Classic", "Essenziali classico"), ("Reverse Path", "Inverti tracciato"), ("Set Anchor", "Imposta ancoraggio"), ("Clear", "Cancella"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 642b4a4c0..68a093c4d 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -30,6 +30,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "カット"), ("Copy", "コピー"), ("Paste", "ペースト"), + ("Editorial", "エディトリアル"), + ("Object & Layout", "オブジェクト&レイアウト"), + ("Styles", "スタイル"), + ("Essentials Classic", "基本(クラシック)"), ("Reverse Path", "パスの方向反転"), ("Set Anchor", "アンカーポイントを設定"), ("Clear", "消去"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 982dfb20d..5474ceb19 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Editorial", "Editorial"), + ("Object & Layout", "Objeto e layout"), + ("Styles", "Estilos"), + ("Essentials Classic", "Essencial clássico"), ("Reverse Path", "Inverter demarcador"), ("Set Anchor", "Definir ponto de ancoragem"), ("Clear", "Limpar"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index f3bc8fb35..91feec029 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -31,6 +31,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Editorial", "Редакція"), + ("Object & Layout", "Об’єкт і макет"), + ("Styles", "Стилі"), + ("Essentials Classic", "Основне (класичне)"), ("Reverse Path", "Розвернути контур"), ("Set Anchor", "Задати прив’язку"), ("Clear", "Очистити"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fc7a3e634..46863f007 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -104,6 +104,9 @@ pub struct SavedWorkspace { pub docking: Option<craft_ui::docking::Layout<String>>, #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] pub docking_hidden: std::collections::BTreeMap<String, craft_ui::docking::Location<String>>, + /// The workspace's own icon column (see [`UiState::icon_groups`]). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon_groups: Option<Vec<Vec<String>>>, } /// A missing on-by-default bool stays on. `bool::default` is false. @@ -239,6 +242,10 @@ pub struct UiState { #[serde(skip)] pub docking_opened: Option<String>, pub dock_expanded: bool, + /// A workspace's iconic panel groups (Essentials Classic), shown with their names in the icon + /// column instead of every panel; `None` is the full icon column. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon_groups: Option<Vec<Vec<String>>>, pub units: Unit, pub workspace: String, /// Window › Workspace › New Workspace: saved panel arrangements. @@ -341,6 +348,7 @@ impl Default for UiState { docking_raise: None, docking_opened: None, dock_expanded: true, + icon_groups: None, units: Unit::Picas, workspace: "Essentials".into(), custom_workspaces: Vec::new(), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 872cb81d0..b87848930 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -190,7 +190,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ "window.workspace", "Workspace", None, - "{name: Essentials|Advanced|Book|Digital Publishing|Interactive for PDF|Printing and Proofing|Typography}", + "{name: Advanced|Book|Digital Publishing|Essentials|Essentials Classic|Interactive for PDF|Printing and Proofing|Typography or a saved workspace}", ), ("window.newWorkspace", "New Workspace…", None, "{name} — saves the current bars and panel arrangement"), ("window.deleteWorkspace", "Delete Workspace…", None, "{name}"), @@ -682,50 +682,77 @@ pub const MENUS: &[(&str, &[&str])] = &[ ), ( "Window", + // InDesign's order: Arrange, Workspace, then the panels alphabetically with their groups. &[ ">Arrange", "ui:window.newWindow", "ui:window.split", + "-", + "ui:window.nextDocument", + "ui:window.previousDocument", + "<", + "ui:window.workspace", + "-", + "ui:window.panel|Articles|{\"panel\": \"articles\"}", + ">Color", + "ui:window.panel|Color|{\"panel\": \"color\"}", + "ui:window.panel|Gradient|{\"panel\": \"gradient\"}", + "ui:window.panel|Swatches|{\"panel\": \"swatches\"}", + "<", + "ui:window.taskBar", + "ui:window.controlBar", + ">Editorial", + "ui:window.panel|Notes|{\"panel\": \"notes\"}", + "ui:window.panel|Track Changes|{\"panel\": \"trackChanges\"}", + "<", + "ui:window.panel|Effects|{\"panel\": \"effects\"}", + "ui:window.panel|Info|{\"panel\": \"info\"}", + ">Interactive", + "ui:window.panel|Bookmarks|{\"panel\": \"bookmarks\"}", + "ui:window.panel|Buttons and Forms|{\"panel\": \"buttons\"}", + "ui:window.panel|Hyperlinks|{\"panel\": \"hyperlinks\"}", + "ui:window.panel|Liquid Layout|{\"panel\": \"liquid\"}", + "ui:window.panel|Media|{\"panel\": \"media\"}", + "ui:window.panel|Object States|{\"panel\": \"states\"}", + "ui:window.panel|Page Transitions|{\"panel\": \"transitions\"}", + "<", + "ui:window.panel|Layers|{\"panel\": \"layers\"}", + "ui:window.panel|Links|{\"panel\": \"links\"}", + ">Object & Layout", + "ui:window.panel|Align|{\"panel\": \"align\"}", + "ui:window.panel|Pathfinder|{\"panel\": \"pathfinder\"}", + "<", + ">Output", + "ui:window.panel|Attributes|{\"panel\": \"attributes\"}", + "ui:window.panel|Preflight|{\"panel\": \"preflight\"}", + "<", + "ui:window.panel|Pages|{\"panel\": \"pages\"}", + "ui:window.panel|Properties|{\"panel\": \"properties\"}", + "ui:window.panel|Stroke|{\"panel\": \"stroke\"}", + ">Styles", + "ui:window.panel|Character Styles|{\"panel\": \"characterStyles\"}", + "ui:window.panel|Paragraph Styles|{\"panel\": \"paragraphStyles\"}", + "<", + "ui:window.panel|Text Wrap|{\"panel\": \"textWrap\"}", + ">Type & Tables", + "ui:window.panel|Character|{\"panel\": \"character\"}", + "ui:window.panel|Conditional Text|{\"panel\": \"conditions\"}", + "ui:window.panel|Glyphs|{\"panel\": \"glyphs\"}", + "ui:window.panel|Paragraph|{\"panel\": \"paragraph\"}", + "ui:window.panel|Table|{\"panel\": \"table\"}", + "<", + ">Utilities", + "ui:window.panel|Book|{\"panel\": \"book\"}", + "ui:window.panel|Data Merge|{\"panel\": \"dataMerge\"}", + "ui:window.panel|Library|{\"panel\": \"library\"}", + "ui:window.panel|Scripts|{\"panel\": \"scripts\"}", + "ui:window.panel|Tags|{\"panel\": \"tags\"}", "<", "-", "ui:window.hidePanels", "ui:window.hidePanelsExceptTools", - "ui:window.nextDocument", - "ui:window.previousDocument", - "-", - "ui:window.controlBar", - "ui:window.taskBar", "ui:window.toolsDoubleColumn", "-", - "ui:window.panel", - "-", - ">Type & Tables", - "ui:window.panel|Conditional Text|{\"panel\": \"conditions\"}", - "ui:window.panel|Notes|{\"panel\": \"notes\"}", - "ui:window.panel|Glyphs|{\"panel\": \"glyphs\"}", - "<", - ">Interactive", - "ui:window.panel|Hyperlinks|{\"panel\": \"hyperlinks\"}", - "ui:window.panel|Bookmarks|{\"panel\": \"bookmarks\"}", - "ui:window.panel|Articles|{\"panel\": \"articles\"}", - "ui:window.panel|Object States|{\"panel\": \"states\"}", - "ui:window.panel|Buttons and Forms|{\"panel\": \"buttons\"}", - "ui:window.panel|Media|{\"panel\": \"media\"}", - "ui:window.panel|Page Transitions|{\"panel\": \"transitions\"}", - "ui:window.panel|Track Changes|{\"panel\": \"trackChanges\"}", - "ui:window.panel|Scripts|{\"panel\": \"scripts\"}", - "ui:window.panel|Liquid Layout|{\"panel\": \"liquid\"}", - "ui:window.panel|Tags|{\"panel\": \"tags\"}", - "ui:window.panel|Book|{\"panel\": \"book\"}", - "<", - ">Utilities", - "ui:window.panel|Data Merge|{\"panel\":\"dataMerge\"}", - "<", - ">Output", - "ui:window.panel|Attributes|{\"panel\": \"attributes\"}", - "cmd:preflight.run", - "<", - "-", "ui:window.brightness", ], ), @@ -746,6 +773,22 @@ pub const MENUS: &[(&str, &[&str])] = &[ ), ]; +/// The built-in workspaces, in InDesign's order. +pub const WORKSPACES: &[&str] = + &["Advanced", "Book", "Digital Publishing", "Essentials", "Essentials Classic", "Interactive for PDF", "Printing and Proofing", "Typography"]; + +/// Essentials Classic's icon column: InDesign's iconic panel groups, top to bottom. +pub const CLASSIC_GROUPS: &[&[&str]] = &[ + &["pages", "layers", "links"], + &["stroke", "color"], + &["swatches"], + &["effects"], + &["paragraph"], + &["textWrap"], + &["characterStyles", "paragraphStyles"], + &["gradient"], +]; + pub fn ui_label(id: &str) -> Option<(&'static str, Option<&'static str>)> { UI_COMMANDS.iter().find(|c| c.0 == id).map(|c| (c.1, c.2)) } @@ -1260,7 +1303,9 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value, } "window.panel" => { let panel = p.get("panel").and_then(Value::as_str).unwrap_or("properties").to_string(); - if crate::dock::DOCK_TABS.iter().any(|(id, _, _)| *id == panel) { + // A panel in the workspace's icon groups opens from there (Essentials Classic's Pages). + let iconic = app.ui.icon_groups.as_ref().is_some_and(|g| g.iter().flatten().any(|p| *p == panel)); + if !iconic && crate::dock::DOCK_TABS.iter().any(|(id, _, _)| *id == panel) { app.ui.dock_tab = panel; app.ui.dock_expanded = true; app.ui.open_panel = None; @@ -1437,6 +1482,7 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value, floating: u.floating.clone(), docking: u.docking.clone(), docking_hidden: u.docking_hidden.clone(), + icon_groups: u.icon_groups.clone(), }; app.ui.custom_workspaces.retain(|x| x.name != name); app.ui.custom_workspaces.push(w); @@ -1475,6 +1521,7 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value, app.ui.floating = w.floating; app.ui.docking = w.docking; app.ui.docking_hidden = w.docking_hidden; + app.ui.icon_groups = w.icon_groups; app.ui.workspace = w.name; return Some(Ok(Value::Null)); } @@ -1484,7 +1531,12 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value, } app.ui.docking = None; app.ui.docking_hidden.clear(); - app.ui.control_bar = matches!(name, "Advanced" | "Typography" | "Printing and Proofing" | "Book"); + app.ui.control_bar = matches!(name, "Advanced" | "Typography" | "Printing and Proofing" | "Book" | "Essentials Classic"); + // Essentials Classic: the Control panel and an icon column of grouped panels, no + // Properties stack. + let classic = name == "Essentials Classic"; + app.ui.icon_groups = classic.then(|| CLASSIC_GROUPS.iter().map(|g| g.iter().map(|p| p.to_string()).collect()).collect()); + app.ui.dock_expanded = !classic; let dock_tab = match name { "Typography" => Some("properties"), "Interactive for PDF" | "Digital Publishing" => Some("pages"), @@ -1753,6 +1805,16 @@ fn parse_entries(entries: &[&str]) -> Vec<Item> { .map(|(id, label, _)| Item::Cmd { label: label.to_string(), id: "window.panel".into(), params: json!({"panel": id}), shortcut: None }) .collect(); out.push(Item::Sub("Panels".into(), items)); + } else if e == "ui:window.workspace" { + // Shown as InDesign's "[Essentials]"; Reset names the current workspace (see `menu_items`). + let mut items: Vec<Item> = WORKSPACES + .iter() + .map(|w| Item::Cmd { label: w.to_string(), id: "window.workspace".into(), params: json!({"name": w}), shortcut: None }) + .collect(); + items.push(Item::Sep); + items.push(cmd_item("window.resetWorkspace", Value::Null)); + items.push(cmd_item("window.newWorkspace", Value::Null)); + out.push(Item::Sub("Workspace".into(), items)); } else if e == "ui:window.brightness" { let items = crate::theme::Brightness::ALL .iter() @@ -1792,6 +1854,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> { /// Check state of a toggle command (None = not a toggle). pub fn checked(app: &DesignApp, id: &str, params: &Value) -> Option<bool> { Some(match id { + "window.workspace" => params.get("name").and_then(Value::as_str) == Some(app.ui.workspace.as_str()), "view.rulers" => app.ui.rulers, "view.frameEdges" => app.ui.frame_edges, "view.overprintPreview" => app.ui.overprint_preview, @@ -2089,7 +2152,12 @@ fn menu_items(app: &mut DesignApp, ui: &mut egui::Ui, items: &[Item], path: &str } Item::Cmd { label, .. } if !app.ui.show_full_menus && app.ui.hidden_menu_items.contains(&menu_key(path, label)) => hidden += 1, Item::Cmd { label, id, params, shortcut: _ } => { - let label = crate::i18n::tr(&app.ui.language, label); + let lang = &app.ui.language; + let label = match id.as_str() { + "window.workspace" => format!("[{}]", crate::i18n::workspace_name(lang, label)), + "window.resetWorkspace" => format!("{} {}", crate::i18n::tr(lang, "Reset"), crate::i18n::workspace_name(lang, &app.ui.workspace)), + _ => crate::i18n::tr(lang, label).to_owned(), + }; let text = match checked(app, id, params) { Some(true) => format!("✓ {label}"), Some(false) => format!(" {label}"), @@ -3379,6 +3447,74 @@ mod tests { assert_eq!(app.ui.workspace, "Essentials"); } + #[test] + fn essentials_classic_is_the_control_panel_and_iconic_groups() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + run_ui(&mut app, "window.workspace", &json!({"name": "Essentials Classic"})).unwrap().unwrap(); + assert!(app.ui.control_bar && !app.ui.dock_expanded); + let groups = app.ui.icon_groups.clone().unwrap(); + assert_eq!(groups[0], ["pages", "layers", "links"]); + assert_eq!(groups.last().unwrap(), &["gradient"]); + assert_eq!(checked(&app, "window.workspace", &json!({"name": "Essentials Classic"})), Some(true)); + assert_eq!(checked(&app, "window.workspace", &json!({"name": "Essentials"})), Some(false)); + // Window › Pages opens Pages from its icon group, not the Properties stack. + run_ui(&mut app, "window.panel", &json!({"panel": "pages"})).unwrap().unwrap(); + assert_eq!((app.ui.open_panel.as_deref(), app.ui.dock_expanded), (Some("pages"), false)); + // A workspace saved from it keeps the groups. + run_ui(&mut app, "window.newWorkspace", &json!({"name": "Mine"})).unwrap().unwrap(); + run_ui(&mut app, "window.workspace", &json!({"name": "Essentials"})).unwrap().unwrap(); + assert_eq!((app.ui.icon_groups.as_ref(), app.ui.dock_expanded, app.ui.control_bar), (None, true, false)); + run_ui(&mut app, "window.workspace", &json!({"name": "Mine"})).unwrap().unwrap(); + assert_eq!(app.ui.icon_groups, Some(groups)); + } + + #[test] + fn the_window_menu_is_in_indesigns_order_and_reaches_every_panel() { + let window = menu_tree().into_iter().find(|(m, _)| *m == "Window").unwrap().1; + let names: Vec<&str> = window + .iter() + .filter_map(|i| match i { + Item::Sub(n, _) => Some(n.as_str()), + Item::Cmd { label, .. } => Some(label.as_str()), + Item::Sep => None, + }) + .collect(); + assert_eq!(&names[..4], ["Arrange", "Workspace", "Articles", "Color"]); + let sub = |name: &str| { + window + .iter() + .find_map(|i| match i { + Item::Sub(n, c) if n == name => Some(c.clone()), + _ => None, + }) + .unwrap() + }; + // Window › Workspace › Essentials Classic, Window › Color › Gradient & Swatches. + assert!( + sub("Workspace") + .iter() + .any(|i| matches!(i, Item::Cmd { id, params, .. } if id == "window.workspace" && params["name"] == "Essentials Classic")) + ); + let color: Vec<String> = + sub("Color").iter().filter_map(|i| if let Item::Cmd { label, .. } = i { Some(label.clone()) } else { None }).collect(); + assert_eq!(color, ["Color", "Gradient", "Swatches"]); + // Every panel is in the menu. + fn panels(items: &[Item], out: &mut Vec<String>) { + for i in items { + match i { + Item::Sub(_, c) => panels(c, out), + Item::Cmd { id, params, .. } if id == "window.panel" => out.extend(params["panel"].as_str().map(str::to_string)), + _ => {} + } + } + } + let mut found = Vec::new(); + panels(&window, &mut found); + for (id, _, _) in crate::dock::DOCK_TABS.iter().chain(crate::dock::ICON_PANELS) { + assert!(found.iter().any(|p| p == id), "Window menu misses the {id} panel"); + } + } + #[test] fn interactive_workspaces_show_their_panels() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index eb7d7e5c1..d2ab2922e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -329,7 +329,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Font menu: search, favourites (stars, Show Favorites Only; `favoriteFonts` preference) and each family previewed in its own face. Western (and other-script) families come first, then Japanese, Simplified Chinese, Traditional Chinese and Korean ones, each group alphabetical and set off by a separator; the group comes from the font's own data (`meta` languages, family names in a CJK language, OS/2 code pages, else what it covers), read once (installed fonts by the font scan, without loading them). CJK families show their native names, the English name on hover; Preferences › Type › Show Font Names in English (`showFontNamesInEnglish`) shows English names throughout. Search matches either name; documents store the English name. Every family list (Character panel, Control bar, Properties, style dialogs, Find Font, Glyphs) follows this order. - Application preferences (engine `Prefs`) now persist across launches (desktop: prefs.json beside ui.json). - View ▸ Fit Selection in Window (⌥⌘=) and Find/Change ▸ Object (`find.objects`, `find.changeObjects`: by fill, stroke, weight, opacity, kind, layer, label). -- Custom workspaces (`window.newWorkspace/deleteWorkspace/resetWorkspace`; workspace switcher shows the current one): bars and panel arrangement saved by name. +- Custom workspaces (`window.newWorkspace/deleteWorkspace/resetWorkspace`; workspace switcher shows the current one): bars and panel arrangement saved by name. Essentials Classic shows the Control panel and InDesign's iconic panel groups with names. The Window menu follows InDesign's: Arrange, Workspace, then the panels alphabetically with their Color, Editorial, Interactive, Object & Layout, Output, Styles, Type & Tables and Utilities groups. - Word/RTF Import Options (File ▸ Place with Import Options…, `place.styles`, `file.place {styleMap, styleConflicts}`): map imported styles onto the document's, use existing / redefine / auto-rename on conflicts, or remove formatting. - Custom anchored objects (`anchored.insert/options {position: custom}`): placed relative to the anchor, frame, column, page margins or page edge with reference points and offsets, no space in the text, keep within column; IDML Anchored position. - Variable font axes: Character panel sliders; any axis values (`fontStyle: "Bold {wght:650}"`) make that instance for layout, screen and PDF. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 8805defce..f9da7383b 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -47,7 +47,7 @@ basics of editing fail for too many users on Windows and Linux. | Colour fields: hex, picker from any swatch field | ✓ | ✓ | ~ | hex entry and picker issues (#323, #190) | | Panels: Pages, Layers, Swatches, Color, Stroke, Character, Paragraph, Styles, Links, Effects, Text Wrap, Align, Pathfinder, Table, Index, TOC, Hyperlinks, Bookmarks, Articles, Tags, Notes, Track Changes, Object States, Buttons and Forms, Preflight, Data Merge, Library, Scripts, Glyphs, Info | ✓ | all present | ~ | **Tabs panel missing** (#326); Navigator, Trap Presets, Animation, Timing missing; pull-down panels can't scroll on Linux (#267) | | Dialogs: New Document, Document Setup, Preferences (all pages), Paragraph/Character/Object/Table/Cell Style Options, Export PDF, Print, Package, Find/Change | ✓ | present | ~ | generated command dialogs fill the long tail; depth varies | -| Workspaces | Essentials, Advanced, Book, Digital Publishing, Interactive for PDF, Printing and Proofing, Typography, Touch | ✓ named workspaces + custom | ~ | tab bar overlaps menus in Book/Advanced (#182) | +| Workspaces | Essentials, Essentials Classic, Advanced, Book, Digital Publishing, Interactive for PDF, Printing and Proofing, Review, Typography, Touch | ✓ named workspaces (Essentials Classic: Control panel and iconic panel groups) + custom; Window › Workspace; no Review or Touch | ~ | tab bar overlaps menus in Book/Advanced (#182) | | Story Editor | ✓ | ✓ | ~ | style-name column, depth ruler missing | | Undo granularity (a drag = one step) | ✓ | ✓ (Begin/Preview/Commit) | ✓ | | | Closing with unsaved changes asks | ✓ | ✓ File › Close and window/menu quit (#146, #545) | ✓ | macOS Dock Quit still bypasses the prompt (#544) | From d49d7c4ea006eeaac2c98a6e0c4e4016a6f7df8e Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Tue, 13 Oct 2026 20:18:14 +0200 Subject: [PATCH 161/238] i18n: keep the new Window menu strings beside Workspace Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/i18n.rs | 8 ++++---- crates/ui-egui/src/i18n/ar.rs | 8 ++++---- crates/ui-egui/src/i18n/it.rs | 8 ++++---- crates/ui-egui/src/i18n/ja.rs | 8 ++++---- crates/ui-egui/src/i18n/pt_br.rs | 8 ++++---- crates/ui-egui/src/i18n/uk.rs | 8 ++++---- 6 files changed, 24 insertions(+), 24 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index ffa50c108..07d294c56 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -55,10 +55,6 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Cut", ["Ausschneiden", "Couper", "Cortar", "カット", "剪切"]), ("Copy", ["Kopieren", "Copier", "Copiar", "コピー", "复制"]), ("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]), - ("Editorial", ["Redaktion", "Éditorial", "Editorial", "エディトリアル", "编辑"]), - ("Object & Layout", ["Objekt und Layout", "Objet et mise en page", "Objeto y diseño", "オブジェクト&レイアウト", "对象和版面"]), - ("Styles", ["Formate", "Styles", "Estilos", "スタイル", "样式"]), - ("Essentials Classic", ["Essentials Classic", "Essentials Classic", "Essentials Classic", "Essentials Classic", "基本功能(经典)"]), ("Reverse Path", ["Pfadrichtung umkehren", "Inverser le tracé", "Invertir trazado", "パスの方向反転", "反转路径"]), ("Set Anchor", ["Ankerpunkt festlegen", "Définir le point d’ancrage", "Definir punto de ancla", "アンカーポイントを設定", "设置锚点"]), ("Clear", ["Löschen", "Effacer", "Borrar", "消去", "清除"]), @@ -195,6 +191,10 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Buttons and Forms", ["Schaltflächen und Formulare", "Boutons et formulaires", "Botones y formularios", "ボタンとフォーム", "按钮和表单"]), ("Liquid Layout", ["Flüssiges Layout", "Mise en page liquide", "Maquetación líquida", "リキッドレイアウト", "自适应版面"]), ("Workspace", ["Arbeitsbereich", "Espace de travail", "Espacio de trabajo", "ワークスペース", "工作区"]), + ("Editorial", ["Redaktion", "Éditorial", "Editorial", "エディトリアル", "编辑"]), + ("Object & Layout", ["Objekt und Layout", "Objet et mise en page", "Objeto y diseño", "オブジェクト&レイアウト", "对象和版面"]), + ("Styles", ["Formate", "Styles", "Estilos", "スタイル", "样式"]), + ("Essentials Classic", ["Essentials Classic", "Essentials Classic", "Essentials Classic", "Essentials Classic", "基本功能(经典)"]), ("Split Window", ["Fenster teilen", "Fractionner la fenêtre", "Dividir ventana", "ウィンドウを分割", "拆分窗口"]), ("New Window", ["Neues Fenster", "Nouvelle fenêtre", "Nueva ventana", "新規ウィンドウ", "新建窗口"]), ( diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index f17794248..6a282b764 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,10 +30,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), - ("Editorial", "التحرير"), - ("Object & Layout", "الكائن والتخطيط"), - ("Styles", "الأنماط"), - ("Essentials Classic", "الأساسيات الكلاسيكية"), ("Reverse Path", "عكس المسار"), ("Set Anchor", "تعيين نقطة الارتساء"), ("Clear", "مسح"), @@ -130,6 +126,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Hide Rulers", "إخفاء المساطر"), ("Extras", "إضافات"), ("Workspace", "مساحة العمل"), + ("Editorial", "التحرير"), + ("Object & Layout", "الكائن والتخطيط"), + ("Styles", "الأنماط"), + ("Essentials Classic", "الأساسيات الكلاسيكية"), ("New Workspace", "مساحة عمل جديدة"), ("Properties", "خصائص"), ("Layers", "الطبقات"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index e389a0390..44902773f 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,10 +28,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), - ("Editorial", "Editoriale"), - ("Object & Layout", "Oggetto e layout"), - ("Styles", "Stili"), - ("Essentials Classic", "Essenziali classico"), ("Reverse Path", "Inverti tracciato"), ("Set Anchor", "Imposta ancoraggio"), ("Clear", "Cancella"), @@ -116,6 +112,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Buttons and Forms", "Pulsanti e moduli"), ("Liquid Layout", "Layout liquido"), ("Workspace", "Area di lavoro"), + ("Editorial", "Editoriale"), + ("Object & Layout", "Oggetto e layout"), + ("Styles", "Stili"), + ("Essentials Classic", "Essenziali classico"), ("Split Window", "Dividi finestra"), ("New Window", "Nuova finestra"), ("Show All Menu Items", "Mostra tutte le voci di menu"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 68a093c4d..cb3dcdcda 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -30,10 +30,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "カット"), ("Copy", "コピー"), ("Paste", "ペースト"), - ("Editorial", "エディトリアル"), - ("Object & Layout", "オブジェクト&レイアウト"), - ("Styles", "スタイル"), - ("Essentials Classic", "基本(クラシック)"), ("Reverse Path", "パスの方向反転"), ("Set Anchor", "アンカーポイントを設定"), ("Clear", "消去"), @@ -118,6 +114,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Buttons and Forms", "ボタンとフォーム"), ("Liquid Layout", "リキッドレイアウト"), ("Workspace", "ワークスペース"), + ("Editorial", "エディトリアル"), + ("Object & Layout", "オブジェクト&レイアウト"), + ("Styles", "スタイル"), + ("Essentials Classic", "基本(クラシック)"), ("Split Window", "ウィンドウを分割"), ("New Window", "新規ウィンドウ"), ("Show All Menu Items", "すべてのメニュー項目を表示"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 5474ceb19..210ee8d45 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,10 +31,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), - ("Editorial", "Editorial"), - ("Object & Layout", "Objeto e layout"), - ("Styles", "Estilos"), - ("Essentials Classic", "Essencial clássico"), ("Reverse Path", "Inverter demarcador"), ("Set Anchor", "Definir ponto de ancoragem"), ("Clear", "Limpar"), @@ -130,6 +126,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Hide Rulers", "Ocultar réguas"), ("Extras", "Extras"), ("Workspace", "Área de trabalho"), + ("Editorial", "Editorial"), + ("Object & Layout", "Objeto e layout"), + ("Styles", "Estilos"), + ("Essentials Classic", "Essencial clássico"), ("New Workspace", "Nova área de trabalho"), ("Properties", "Propriedades"), ("Layers", "Camadas"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 91feec029..a90e9b607 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -31,10 +31,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), - ("Editorial", "Редакція"), - ("Object & Layout", "Об’єкт і макет"), - ("Styles", "Стилі"), - ("Essentials Classic", "Основне (класичне)"), ("Reverse Path", "Розвернути контур"), ("Set Anchor", "Задати прив’язку"), ("Clear", "Очистити"), @@ -119,6 +115,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Buttons and Forms", "Кнопки та форми"), ("Liquid Layout", "Гнучкий макет"), ("Workspace", "Робоче середовище"), + ("Editorial", "Редакція"), + ("Object & Layout", "Об’єкт і макет"), + ("Styles", "Стилі"), + ("Essentials Classic", "Основне (класичне)"), ("Split Window", "Розділити вікно"), ("New Window", "Нове вікно"), ("Show All Menu Items", "Показати всі пункти меню"), From 677cafbf9c3454e037ac6fd58fd498b9cc00f060 Mon Sep 17 00:00:00 2001 From: Govinda Roy <govindaroy.ofc94@gmail.com> Date: Wed, 14 Oct 2026 00:26:02 +0600 Subject: [PATCH 162/238] Images: EPS shows through its proxy at display time; simple PostScript (QR codes) is drawn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An EPS that came in with a document (InDesign embeds its QR codes as EPS) drew nothing: only File › Place converted EPS to a proxy. Now `decode_rgba` draws any EPS through `eps_proxy` and `natural_size` measures it by its bounding box in points, so screen, PNG and PDF show it while the document keeps the EPS itself. `eps_proxy` also draws PostScript that only fills rectangles in solid colours (setcmykcolor, setrgbcolor, setgray, rectfill and procedures that stand for them, as InDesign's QR codes are written), at 8 px per point so modules stay crisp. Anything else keeps the preview or placeholder. Input is bounded: tokens, rectangles, painted area and procedure definitions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/images/src/eps.rs | 147 ++++++++++++++++++++++++++++++++++++++- crates/images/src/lib.rs | 22 ++++++ 2 files changed, 166 insertions(+), 3 deletions(-) diff --git a/crates/images/src/eps.rs b/crates/images/src/eps.rs index 01d959292..f86c4eaef 100644 --- a/crates/images/src/eps.rs +++ b/crates/images/src/eps.rs @@ -1,6 +1,7 @@ -//! EPS: there's no PostScript interpreter here, so a placed EPS shows (and prints) through a -//! proxy, as InDesign does on screen: the file's TIFF preview when it has one, else a placeholder -//! the size of its bounding box. +//! EPS: there's no general PostScript interpreter here, so a placed EPS shows (and prints) +//! through a proxy, as InDesign does on screen: the file's TIFF preview when it has one, else the +//! rectangles of a simple one drawn here (InDesign's QR codes), else a placeholder the size of its +//! bounding box. /// An EPS file (plain, or DOS EPS with a binary header)? pub fn is_eps(bytes: &[u8]) -> bool { @@ -45,6 +46,10 @@ pub fn eps_proxy(bytes: &[u8]) -> Option<(Vec<u8>, (f64, f64))> { { return Some((tiff.to_vec(), (w, h))); } + // Simple PostScript drawn here: InDesign's QR codes are coloured rectangles only. + if let Some(png) = render_rectangles(sections(bytes).0, bb) { + return Some((png, (w, h))); + } let (pw, ph) = ((w * 2.0).round().clamp(2.0, 4000.0) as u32, (h * 2.0).round().clamp(2.0, 4000.0) as u32); let mut img = image::RgbaImage::from_pixel(pw, ph, image::Rgba([228, 228, 228, 255])); // Diagonals and a border mark it as a stand-in. @@ -68,10 +73,146 @@ pub fn eps_proxy(bytes: &[u8]) -> Option<(Vec<u8>, (f64, f64))> { Some((out, (w, h))) } +/// Pixels per point for rendered EPS (a QR code's modules stay crisp when scaled up on screen). +const RENDER_SCALE: f64 = 8.0; +/// The most rectangles and operators read from an EPS before giving up on drawing it. +const MAX_RECTS: usize = 200_000; +const MAX_TOKENS: usize = 2_000_000; +/// The most pixels painted, in multiples of the image's area (overlapping rectangles repaint). +const MAX_PAINT_AREAS: u64 = 64; + +/// PostScript that only fills rectangles in solid colours (`setcmykcolor`, `setrgbcolor`, +/// `setgray`, `rectfill`, and procedures that stand for one of them, such as InDesign's +/// `/rf { rectfill } bind def`), drawn as a PNG of the bounding box `bb`. None for anything else +/// (paths, text, images, transforms), which keeps its placeholder, or when nothing is drawn. +fn render_rectangles(ps: &[u8], bb: [f64; 4]) -> Option<Vec<u8>> { + let text = std::str::from_utf8(ps).ok()?; + let (w, h) = (bb[2] - bb[0], bb[3] - bb[1]); + if !(w > 0.0 && h > 0.0) { + return None; + } + let scale = RENDER_SCALE.min(4000.0 / w.max(h)); + let (pw, ph) = ((w * scale).round().max(1.0) as u32, (h * scale).round().max(1.0) as u32); + let mut img = image::RgbaImage::from_pixel(pw, ph, image::Rgba([0, 0, 0, 0])); + let mut aliases: std::collections::HashMap<String, String> = std::collections::HashMap::new(); + let mut stack: Vec<f64> = Vec::new(); + let mut color = [0u8, 0, 0, 255]; + let mut drawn = 0usize; + let mut painted = 0u64; + let paint_budget = u64::from(pw) * u64::from(ph) * MAX_PAINT_AREAS; + // Tokens without comments; `{ … }` procedures kept whole. + let mut tokens: Vec<String> = Vec::new(); + for line in text.lines() { + let line = line.split('%').next().unwrap_or(""); + let spaced = line.replace('{', " { ").replace('}', " } "); + tokens.extend(spaced.split_whitespace().map(str::to_string)); + if tokens.len() > MAX_TOKENS { + return None; + } + } + let unit = |v: f64| v.clamp(0.0, 1.0); + let mut i = 0; + while let Some(tok) = tokens.get(i) { + i += 1; + if let Ok(v) = tok.parse::<f64>() { + if !v.is_finite() { + return None; + } + stack.push(v); + continue; + } + // `/name { op } bind def`: name stands for op. + if let Some(name) = tok.strip_prefix('/') { + // At most `{ op } bind`: anything longer isn't an alias this reader knows. + let body: Vec<&String> = tokens.iter().skip(i).take(5).take_while(|t| *t != "def").collect(); + let op = match body.as_slice() { + [open, op, close] | [open, op, close, _] if *open == "{" && *close == "}" => (*op).clone(), + _ => return None, + }; + aliases.insert(name.to_string(), op); + i += body.len() + 1; + continue; + } + let op = aliases.get(tok).map_or(tok.as_str(), String::as_str); + let mut pop = |n: usize| -> Option<Vec<f64>> { (stack.len() >= n).then(|| stack.split_off(stack.len() - n)) }; + match op { + "setcmykcolor" => { + let v = pop(4)?; + let k = unit(v[3]); + let ch = |c: f64| ((1.0 - unit(c)) * (1.0 - k) * 255.0).round() as u8; + color = [ch(v[0]), ch(v[1]), ch(v[2]), 255]; + } + "setrgbcolor" => { + let v = pop(3)?; + let ch = |c: f64| (unit(c) * 255.0).round() as u8; + color = [ch(v[0]), ch(v[1]), ch(v[2]), 255]; + } + "setgray" => { + let v = pop(1)?; + let g = (unit(v[0]) * 255.0).round() as u8; + color = [g, g, g, 255]; + } + "rectfill" => { + let v = pop(4)?; + drawn += 1; + if drawn > MAX_RECTS { + return None; + } + // PostScript y runs up; the image's down. Edges round to whole pixels, so + // touching rectangles (QR code modules) meet without gaps. + let (x0, x1) = ((v[0] - bb[0]) * scale, (v[0] + v[2] - bb[0]) * scale); + let (y0, y1) = ((bb[3] - (v[1] + v[3])) * scale, (bb[3] - v[1]) * scale); + let px = |a: f64, b: f64, max: u32| { + let (lo, hi) = (a.min(b).round().max(0.0), a.max(b).round().min(max as f64)); + (lo as u32, hi.max(lo) as u32) + }; + let ((xa, xb), (ya, yb)) = (px(x0, x1, pw), px(y0, y1, ph)); + painted = painted.saturating_add(u64::from(xb - xa) * u64::from(yb - ya)); + if painted > paint_budget { + return None; + } + for y in ya..yb { + for x in xa..xb { + img.put_pixel(x, y, image::Rgba(color)); + } + } + } + "bind" | "gsave" | "grestore" | "newpath" | "showpage" | "save" | "restore" | "pop" | "end" | "begin" => {} + _ => return None, + } + } + if drawn == 0 { + return None; + } + let mut out = Vec::new(); + image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?; + Some(out) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn simple_rectangle_eps_is_drawn_like_a_qr_code() { + // As InDesign writes QR codes: a procedure for rectfill, a colour, then modules. + let eps = b"%!PS-Adobe-3.1 EPSF-3.0\n%%BoundingBox: 0 0 4 4\n%%EndComments\n/rf { rectfill } bind def\n0 0 0 1 setcmykcolor\n0 2 2 2 rf\n2 0 2 2 rf\n%%EOF\n"; + let (png, size) = eps_proxy(eps).unwrap(); + assert_eq!(size, (4.0, 4.0)); + let img = image::load_from_memory(&png).unwrap().to_rgba8(); + assert_eq!(img.dimensions(), (32, 32), "8 pixels per point"); + // PostScript y runs up: (0, 2)–(2, 4) is the image's top left. + assert_eq!(img.get_pixel(4, 4).0, [0, 0, 0, 255]); + assert_eq!(img.get_pixel(28, 28).0, [0, 0, 0, 255]); + assert_eq!(img.get_pixel(28, 4).0[3], 0, "undrawn stays transparent"); + assert_eq!(img.get_pixel(4, 28).0[3], 0); + // Anything else (here a path) keeps the placeholder. + let curve = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 4 4\n0 0 moveto 4 4 lineto stroke\n%%EOF\n"; + let (png, _) = eps_proxy(curve).unwrap(); + let img = image::load_from_memory(&png).unwrap().to_rgba8(); + assert_eq!(img.get_pixel(2, 4).0, [228, 228, 228, 255]); + } + #[test] fn eps_bounding_box_and_proxies() { let eps = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 50\n%%HiResBoundingBox: 0 0 100.5 50.25\nnewpath\n%%EOF\n"; diff --git a/crates/images/src/lib.rs b/crates/images/src/lib.rs index 4751263a4..7ce48c0a3 100644 --- a/crates/images/src/lib.rs +++ b/crates/images/src/lib.rs @@ -140,6 +140,10 @@ pub fn natural_size(bytes: &[u8]) -> Option<(f64, f64)> { let s = svg_tree(bytes)?.size(); return Some((s.width() as f64 * PT_PER_PX, s.height() as f64 * PT_PER_PX)); } + if is_eps(bytes) { + let bb = eps::bounding_box(bytes)?; + return Some((bb[2] - bb[0], bb[3] - bb[1])).filter(|(w, h)| w.is_finite() && h.is_finite() && *w > 0.0 && *h > 0.0); + } let (w, h) = pixel_size(bytes)?; Some((w as f64, h as f64)) } @@ -159,6 +163,11 @@ pub fn decode_rgba(bytes: &[u8]) -> Option<image::RgbaImage> { if is_psd(bytes) { return psd::decode(bytes); } + // An EPS is drawn as its proxy (preview, simple PostScript or placeholder); the document + // keeps the EPS itself. + if is_eps(bytes) { + return decode_rgba(&eps_proxy(bytes)?.0); + } Some(decode_to_rgba8(&image::load_from_memory(bytes).ok()?)) } @@ -386,6 +395,19 @@ mod tests { b } + /// A placed EPS is measured by its bounding box in points and drawn through its proxy; the + /// bytes stay the EPS. + #[test] + fn eps_draws_through_its_proxy_at_its_bounding_box_size() { + let eps = + b"%!PS-Adobe-3.1 EPSF-3.0\n%%BoundingBox: 0 0 4 2\n%%EndComments\n/rf { rectfill } bind def\n0 0 0 1 setcmykcolor\n0 0 2 2 rf\n%%EOF\n"; + assert_eq!(natural_size(eps), Some((4.0, 2.0))); + let img = decode_rgba(eps).expect("drawn"); + assert_eq!(img.dimensions(), (32, 16)); + assert_eq!(img.get_pixel(1, 1).0, [0, 0, 0, 255], "the rectangle"); + assert_eq!(img.get_pixel(30, 1).0[3], 0, "outside it, transparent"); + } + /// CMYK TIFFs read back as their ink values; RGB ones aren't CMYK. #[test] fn cmyk_tiff_keeps_inks() { From ba4519c3821a5dfb15d27ff5819fd7a2c8527e84 Mon Sep 17 00:00:00 2001 From: noah1400 <48360809+noah1400@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:31:54 +0200 Subject: [PATCH 163/238] Preferences: Story Editor size stays finite and in range (#821) Preferences OK stored whatever number the dialog held as the Story Editor font size. One that is not finite ("nan", "inf", or a number too large for an f32) was written to ui.json as null; the next launch could not read the file, started with the default UI settings and saved them over it on exit. The size is now kept between 8 and 36 pt, the slider's range, and a value that is not a finite number leaves the size as it was. --- crates/ui-egui/src/dialogs.rs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 695a66af1..e9776928e 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2009,7 +2009,10 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> { let words: Vec<String> = d.s("userWords").lines().map(str::to_string).collect(); app.run("spelling.setWords", json!({"words": words}))?; } - app.ui.story_editor_size = d.n("storyEditorSize").unwrap_or(14.0) as f32; + // The slider's range. A size that isn't finite is saved as `null`, and ui.json then fails to load. + if let Some(size) = d.n("storyEditorSize").filter(|v| v.is_finite()) { + app.ui.story_editor_size = size.clamp(8.0, 36.0) as f32; + } // Highlight options change the screen view only. app.canvas.shown = None; let view = match d.s("displayQuality").as_str() { @@ -4095,6 +4098,22 @@ mod tests { assert!(app.ui.dialog.as_ref().unwrap().b("absolutePageNumbers")); } + #[test] + fn preferences_ok_saves_a_story_editor_size_that_loads_back() { + // `ui.dialog.set` takes any value; a size that isn't finite is saved as `null`. + let sizes = [(json!("nan"), 20.0), (json!("inf"), 20.0), (json!(null), 20.0), (json!(1e39), 36.0), (json!(3), 8.0), (json!(11), 11.0)]; + for (size, want) in sizes { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.ui.story_editor_size = 20.0; + app.run("app.preferences", json!({})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("storyEditorSize".into(), size); + confirm(&mut app).unwrap(); + let saved = serde_json::to_vec(&app.ui).unwrap(); + assert_eq!(crate::UiState::from_preferences_json(&saved).unwrap().story_editor_size, want); + } + } + #[test] fn new_document_vertical_scroll_reaches_last_field() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From dc712baeca972c6732af9e0af33aac4f156afb61 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Tue, 13 Oct 2026 20:38:38 +0200 Subject: [PATCH 164/238] fix(color): gradients between process colours blend as inks, as InDesign shows them LYH Studio episode 6 builds every gradient from C=0 M=100 Y=0 K=0 to C=100 M=0 Y=0 K=0. InDesign blends a gradient's process stops as inks: halfway is C 50 M 50, a light lilac. DesignCraft converted both ends to RGB and blended there, so the middle came out a darker violet (117, 88, 189 instead of 135, 119, 181), and every Episode 6 gradient looked different. A midpoint between CMYK stops also became an RGB colour, so the PDF shading turned RGB with a conversion warning. - color: `Color::lerp` blends CMYK with CMYK (or grey), and grey with grey, in ink space; anything with RGB still blends in RGB. - `Gradient::display_stops`: the expanded stops plus samples through each span that isn't RGB at both ends, for renderers that interpolate in RGB; the screen renderer uses it. - pdf: a midpoint's expanded stop now keeps the stops' space; the colour-space test expects no conversion for CMYK or grey midpoints. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 1 + crates/color/src/gradient.rs | 39 +++++++++++++++++++++++ crates/color/src/lib.rs | 10 ++++++ crates/pdf/tests/gradient_color_spaces.rs | 5 ++- crates/render/src/lib.rs | 4 ++- crates/render/tests/gradient_inks.rs | 36 +++++++++++++++++++++ docs/target-app-parity.md | 2 +- 7 files changed, 92 insertions(+), 5 deletions(-) create mode 100644 crates/render/tests/gradient_inks.rs diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..1fa84857e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-13** · Gradients between process colours blend as inks, as InDesign shows them: magenta to cyan passes through C 50 M 50 (a light lilac) on screen instead of the darker RGB mix, and a midpoint between CMYK or grey stops no longer turns the PDF shading RGB. No readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index 4da4f2d84..6c19aabb1 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -103,6 +103,26 @@ impl Gradient { } out } + /// Stops for a renderer that blends between them in RGB: the expanded stops, plus samples + /// through each span that isn't RGB at both ends, so process colours keep blending as inks. + pub fn display_stops(&self) -> Vec<(f32, Color, f32)> { + const STEPS: usize = 16; + let stops = self.expanded_stops(); + let mut out: Vec<(f32, Color, f32)> = Vec::with_capacity(stops.len() * STEPS); + for (i, s) in stops.iter().enumerate() { + out.push(*s); + let Some(n) = stops.get(i + 1) else { continue }; + if matches!((s.1, n.1), (Color::Rgb { .. }, Color::Rgb { .. })) || n.0 - s.0 <= 1e-4 { + continue; + } + for j in 1..STEPS { + let t = s.0 + (n.0 - s.0) * j as f32 / STEPS as f32; + let (c, a) = self.sample(t); + out.push((t, c, a)); + } + } + out + } pub fn reverse(&mut self) { // A midpoint belongs to the segment after its stop, so it moves with that segment and mirrors. let mids: Vec<_> = self.stops.iter().rev().skip(1).map(|s| 1.0 - s.midpoint).collect(); @@ -199,6 +219,25 @@ mod tests { assert!((mid - 0.5).abs() < 1e-5); } + #[test] + fn process_stops_blend_as_inks() { + // LYH Studio episode 6: magenta to cyan is C 50 M 50 halfway (a light lilac), not the RGB mix. + let mut g = Gradient::default(); + g.stops[0].color = Color::cmyk(0.0, 1.0, 0.0, 0.0); + g.stops[1].color = Color::cmyk(1.0, 0.0, 0.0, 0.0); + assert_eq!(g.sample(0.5).0, Color::cmyk(0.5, 0.5, 0.0, 0.0)); + let d = g.display_stops(); + assert_eq!(d.len(), 17, "sampled through the span"); + assert!(d.iter().any(|(o, c, _)| (*o - 0.5).abs() < 1e-6 && *c == Color::cmyk(0.5, 0.5, 0.0, 0.0))); + // A midpoint stays an ink too; RGB stops aren't sampled. + g.stops[0].midpoint = 0.25; + assert!(matches!(g.expanded_stops()[1].1, Color::Cmyk { .. })); + assert_eq!(Gradient::default().display_stops().len(), 2); + // Grey and CMYK meet as inks; RGB with anything else blends in RGB. + assert_eq!(Color::gray(1.0).lerp(&Color::cmyk(1.0, 0.0, 0.0, 0.0), 0.5), Color::cmyk(0.5, 0.0, 0.0, 0.5)); + assert!(matches!(Color::rgb(1.0, 0.0, 0.0).lerp(&Color::cmyk(1.0, 0.0, 0.0, 0.0), 0.5), Color::Rgb { .. })); + } + #[test] fn midpoint_shifts() { let mut g = Gradient::default(); diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index bd5e915de..73a8cb269 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -170,7 +170,17 @@ impl Color { Color::rgb(1.0 - r, 1.0 - g, 1.0 - b) } /// Linear interpolation in display RGB. + /// The colour `t` of the way to `other`. Process inks blend as inks (CMYK with CMYK or grey, + /// grey with grey), as InDesign blends a gradient's stops; anything else blends in RGB. pub fn lerp(&self, other: &Color, t: f32) -> Color { + let l = |a: f32, b: f32| a + (b - a) * t; + match (*self, *other) { + (Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => return Color::cmyk(l(c, c2), l(m, m2), l(y, y2), l(k, k2)), + (Color::Gray { k }, Color::Gray { k: k2 }) => return Color::gray(l(k, k2)), + (Color::Gray { k }, Color::Cmyk { .. }) => return Color::cmyk(0.0, 0.0, 0.0, k).lerp(other, t), + (Color::Cmyk { .. }, Color::Gray { k }) => return self.lerp(&Color::cmyk(0.0, 0.0, 0.0, k), t), + _ => {} + } let a = self.to_rgb(); let b = other.to_rgb(); Color::rgb(a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t) diff --git a/crates/pdf/tests/gradient_color_spaces.rs b/crates/pdf/tests/gradient_color_spaces.rs index c523a4187..f9892965b 100644 --- a/crates/pdf/tests/gradient_color_spaces.rs +++ b/crates/pdf/tests/gradient_color_spaces.rs @@ -78,15 +78,14 @@ fn mixed_gradient_export_survives_in_subprocess() { } } } - // Expansion inserts an RGB midpoint even when the authoring stops share CMYK or Gray. + // A midpoint's expanded stop blends in the stops' own space: CMYK or Gray stays homogeneous. for colors in [[cmyk, Color::cmyk(0.0, 1.0, 0.0, 0.0)], [gray, Color::gray(0.8)]] { for kind in [GradientKind::Linear, GradientKind::Radial] { for standard in [Standard::None, Standard::PdfX4, Standard::PdfA2b] { let report = export_pdf_with_report(&document(&colors, kind, 0.25, 1.0), &Cache::new(), &PdfOptions { standard, ..Default::default() }) .unwrap(); - let archival_cmyk = standard == Standard::PdfA2b && matches!(colors[0], Color::Cmyk { .. }); - assert_eq!(conversion_warnings(&report.warnings), usize::from(!archival_cmyk), "{standard:?}: {:?}", report.warnings); + assert_eq!(conversion_warnings(&report.warnings), 0, "{standard:?}: {:?}", report.warnings); assert_eq!(Pdf::new(report.bytes).unwrap().pages().len(), 1); } } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 2cc5c548f..c49b3caf0 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -880,7 +880,9 @@ pub fn color_of(c: &designcraft_color::Color, alpha: f32) -> peniko::Color { fn set_fill_paint(ctx: &mut RenderContext, doc: &Document, fill: &designcraft_doc::Fill, bounds: Rect) -> bool { let (swatch, tint, angle) = (fill.swatch.as_str(), fill.tint, fill.gradient_angle); if let Some(g) = designcraft_color::swatch::resolve_gradient(&doc.swatches, swatch) { - let stops: Vec<peniko::ColorStop> = g.expanded_stops().iter().map(|(o, c, a)| peniko::ColorStop::from((*o, color_of(c, *a)))).collect(); + // Sampled so process colours blend as inks, as InDesign shows them (and as the PDF's CMYK + // shading prints). + let stops: Vec<peniko::ColorStop> = g.display_stops().iter().map(|(o, c, a)| peniko::ColorStop::from((*o, color_of(c, *a)))).collect(); if stops.is_empty() { return false; } diff --git a/crates/render/tests/gradient_inks.rs b/crates/render/tests/gradient_inks.rs new file mode 100644 index 000000000..8bb407a66 --- /dev/null +++ b/crates/render/tests/gradient_inks.rs @@ -0,0 +1,36 @@ +//! A gradient between process colours shows the inks' blend, as InDesign shows it. +use designcraft_color::swatch::Swatch; +use designcraft_color::{Color, Gradient, GradientKind, GradientStop, SwatchValue}; +use designcraft_compose::Cache; +use designcraft_doc::build::NewDocument; +use designcraft_doc::{Document, Fill, Item, ItemId, Shape, SpreadRef, Stroke}; +use designcraft_geom::{Affine, Rect, shapes}; +use designcraft_render::{Placed, RenderOptions, Renderer}; + +#[test] +fn a_cmyk_gradient_is_c50_m50_halfway() { + let mut d = Document::new(&NewDocument { width: 100.0, height: 20.0, facing_pages: false, ..Default::default() }); + let stop = |offset: f32, color: Color| GradientStop { offset, color, opacity: 1.0, midpoint: 0.5 }; + let gradient = + Gradient { kind: GradientKind::Linear, stops: vec![stop(0.0, Color::cmyk(0.0, 1.0, 0.0, 0.0)), stop(1.0, Color::cmyk(1.0, 0.0, 0.0, 0.0))] }; + d.swatches.push(Swatch { name: "Magenta to Cyan".into(), value: SwatchValue::Gradient { gradient }, locked: false, named: true, hidden: false }); + let id = ItemId(d.alloc()); + let mut item = Item::new(id, d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 20.0))); + item.fill = Fill::swatch("Magenta to Cyan"); + item.stroke = Stroke::default(); + d.insert_item(SpreadRef::Doc(0), item, None).unwrap(); + let img = Renderer::new().render( + &d, + &Cache::new(), + &[Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }], + 100, + 20, + Affine::IDENTITY, + &RenderOptions { paper: false, background: Some([255; 4]), ..Default::default() }, + ); + let want = Color::cmyk(0.5, 0.5, 0.0, 0.0).to_rgba8(1.0); + let got = img.pixel(50, 10); + for ch in 0..3 { + assert!(got[ch].abs_diff(want[ch]) <= 4, "halfway {got:?}, the inks' blend is {want:?}"); + } +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index eb7d7e5c1..0d2f8b0f2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -436,7 +436,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level Task notes that headed ROADMAP.md: -- 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. +- FID1: PDF export normalizes mixed emitted gradient color spaces to RGB with an explicit warning; homogeneous device-space gradients retain their components. Process stops (CMYK, grey) blend as inks on screen and at midpoints, so a midpoint keeps the shading CMYK. Linear/radial, midpoint, archival and print policies have synthetic regression coverage. - AR1.1: Kashida justification tests verify space fallback without Tatweel fonts using an isolated font database, while retaining elongation assertions when fonts support it. - AR1: Arabic IDML controls now retain character direction, kashida switches, diacritic offsets/forms, paragraph policy values and independent story/table directions. Shaping preserves joining context and script/language selection; paragraph-level bidi, contextual digit conversion, mark-safe elongation, RTL columns/tables and physical column insertion have regression coverage. Vendor-specific justification and diacritic presets remain explicit preflight warnings; see docs/arabic-typography.md. - CJK1: IDML composite fonts now retain character mappings and metrics; explicit aki, tsume, jidori, em alignment/leading, named/custom kinsoku boundaries, hanging punctuation, tate-chu-yoko offsets and kenten symbols flow through composition and IDML export. Automatic kerning no longer imports inactive numeric values as enormous manual spacing. Composite definitions are editable through commands with undo/cache invalidation. Mojikumi definitions are retained and unsupported spacing-table / push-in-push-out policies are reported by preflight. Full InDesign CJK composition parity, frame-grid layout and detailed ruby remain unfinished. Warichu sets the selected run in smaller lines inside the line after the line is broken. From 08386d03b115dc1bd20ebf4f3c99158b5334a186 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Tue, 13 Oct 2026 21:12:57 +0200 Subject: [PATCH 165/238] feat(ui): Object > Fitting with InDesign's shortcuts LYH Studio episode 6 (7:00) fills its photo frames with the caption "Fit Content to Frame (Ctrl + Shift + Alt + C)", InDesign's shortcut for Fill Frame Proportionally. DesignCraft's Fitting items were object.fit variants without shortcuts (a menu shortcut belongs to a command id, and the five modes share one). - Five UI commands, one per mode, with InDesign's keys: fit.fillFrameProportionally Cmd+Alt+Shift+C, fit.contentProportionally Cmd+Alt+Shift+E, fit.frameToContent Cmd+Alt+C, fit.contentToFrame Cmd+Alt+E, fit.centerContent Cmd+Shift+E (Ctrl on Windows and Linux); each runs object.fit. - Object > Fitting lists them (with their shortcuts), enabled as object.fit is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 1 + crates/ui-egui/src/menus.rs | 72 +++++++++++++++++++++++++++++++++---- 2 files changed, 67 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..ec341060e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-13** · Object › Fitting has InDesign's shortcuts: Fill Frame Proportionally ⌘⌥⇧C, Fit Content Proportionally ⌘⌥⇧E, Fit Frame to Content ⌘⌥C, Fit Content to Frame ⌘⌥E, Center Content ⌘⇧E. No readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 872cb81d0..657ccd3c5 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -163,6 +163,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("window.panel.close", "Close Panel", None, "{panel} — hide a panel"), ("window.dockPanel", "Dock Panel", None, "{panel} — back into the dock's icon column"), ("window.controlBar", "Control", Some("Cmd+Alt+6"), "{}"), + // Object › Fitting with InDesign's shortcuts (one `object.fit` mode each). + ("fit.fillFrameProportionally", "Fill Frame Proportionally", Some("Cmd+Alt+Shift+C"), "{} — object.fit {mode: fillProportionally}"), + ("fit.contentProportionally", "Fit Content Proportionally", Some("Cmd+Alt+Shift+E"), "{} — object.fit {mode: fitProportionally}"), + ("fit.frameToContent", "Fit Frame to Content", Some("Cmd+Alt+C"), "{} — object.fit {mode: fitFrameToContent}"), + ("fit.contentToFrame", "Fit Content to Frame", Some("Cmd+Alt+E"), "{} — object.fit {mode: fitContentToFrame}"), + ("fit.centerContent", "Center Content", Some("Cmd+Shift+E"), "{} — object.fit {mode: centerContent}"), ("window.split", "Split Window", None, "{on?: bool} — two views of the document side by side, each with its own zoom and scroll"), ("window.newWindow", "New Window", None, "{on?: bool} — another view of the active document in its own window"), ("window.taskBar", "Contextual Task Bar", None, "{}"), @@ -552,11 +558,11 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:anchored.release", "<", ">Fitting", - "cmd:object.fit|Fill Frame Proportionally|{\"mode\":\"fillProportionally\"}", - "cmd:object.fit|Fit Content Proportionally|{\"mode\":\"fitProportionally\"}", - "cmd:object.fit|Fit Frame to Content|{\"mode\":\"fitFrameToContent\"}", - "cmd:object.fit|Fit Content to Frame|{\"mode\":\"fitContentToFrame\"}", - "cmd:object.fit|Center Content|{\"mode\":\"centerContent\"}", + "ui:fit.fillFrameProportionally", + "ui:fit.contentProportionally", + "ui:fit.frameToContent", + "ui:fit.contentToFrame", + "ui:fit.centerContent", "-", "ui:app.fittingOptions", "<", @@ -1269,6 +1275,16 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value, } Ok(Value::Null) } + "fit.fillFrameProportionally" | "fit.contentProportionally" | "fit.frameToContent" | "fit.contentToFrame" | "fit.centerContent" => { + let mode = match id { + "fit.fillFrameProportionally" => "fillProportionally", + "fit.contentProportionally" => "fitProportionally", + "fit.frameToContent" => "fitFrameToContent", + "fit.contentToFrame" => "fitContentToFrame", + _ => "centerContent", + }; + app.run("object.fit", json!({"mode": mode})) + } "app.keyboardShortcuts" => { app.ui.dialog = Some(crate::dialogs::Dialog::new("keyboardShortcuts", json!({"query": "", "recording": ""}))); Ok(Value::Null) @@ -1856,6 +1872,9 @@ pub fn checked(app: &DesignApp, id: &str, params: &Value) -> Option<bool> { /// Enablement for menu display (UI commands need a document unless they're app/window-level). pub fn menu_enabled(app: &DesignApp, id: &str) -> bool { + if id.starts_with("fit.") { + return enabled(app, "object.fit"); + } if ui_label(id).is_some() { return app.session.active().is_some() || id.starts_with("app.") || id.starts_with("window."); } @@ -2872,6 +2891,47 @@ mod tests { assert!(run_ui(&mut app, "view.proofSetup", &json!({"target": "nope"})).unwrap().is_err()); } + #[test] + fn fitting_has_indesigns_shortcuts() { + // LYH Studio episode 6 (7:00): "Fit Content to Frame (Ctrl + Shift + Alt + C)" fills the + // photo frames proportionally. + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png(); + let f = app.run("frame.create", json!({"rect": [100, 100, 300, 300]})).unwrap()["id"].as_u64().unwrap(); + app.run("file.place", json!({"base64": designcraft_engine::cmd::base64_encode(&png), "name": "a.png", "frame": f})).unwrap(); + app.run("selection.set", json!({"ids": [f]})).unwrap(); + let width = |app: &crate::DesignApp| { + let it = app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap().clone(); + let designcraft_doc::Content::Graphic(g) = &it.content else { panic!("graphic") }; + it.xf.transform_rect_bbox(g.xf.transform_rect_bbox(designcraft_geom::Rect::new(0.0, 0.0, g.size.0, g.size.1))).width() + }; + for (id, keys) in [ + ("fit.fillFrameProportionally", "Cmd+Alt+Shift+C"), + ("fit.contentProportionally", "Cmd+Alt+Shift+E"), + ("fit.frameToContent", "Cmd+Alt+C"), + ("fit.contentToFrame", "Cmd+Alt+E"), + ("fit.centerContent", "Cmd+Shift+E"), + ] { + assert_eq!(shortcut_of(&app, id).as_deref(), Some(keys)); + } + let ctx = egui::Context::default(); + let press = |app: &mut crate::DesignApp, key: egui::Key| { + let modifiers = + egui::Modifiers { alt: true, ctrl: !cfg!(target_os = "macos"), shift: true, mac_cmd: cfg!(target_os = "macos"), command: true }; + let input = egui::RawInput { + events: vec![egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }], + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| shortcuts(app, &ui.ctx().clone())); + out.textures_delta.clear(); + }; + press(&mut app, egui::Key::C); + assert!((width(&app) - 400.0).abs() < 1e-6, "filled: {}", width(&app)); + press(&mut app, egui::Key::E); + assert!((width(&app) - 200.0).abs() < 1e-6, "fitted: {}", width(&app)); + } + #[test] fn tab_hides_panels_and_documents_cycle() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); @@ -3358,7 +3418,7 @@ mod tests { }); assert!(special.is_some_and(|c| c.iter().any(|i| matches!(i, Item::Sub(n, _) if n == "Symbols")))); // Fixed-parameter variants keep their own labels. - assert!(all.iter().any(|(l, id, p)| l == "Fill Frame Proportionally" && id == "object.fit" && p["mode"] == "fillProportionally")); + assert!(all.iter().any(|(l, id, _)| l == "Fill Frame Proportionally" && id == "fit.fillFrameProportionally")); } #[test] From efd2310c83c6936307153415e494c8704670cf09 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick <hi@skylar.cc> Date: Tue, 13 Oct 2026 13:20:47 -0600 Subject: [PATCH 166/238] refactor(pdf): share writer backend (PDFW) --- Cargo.lock | 14 +- Cargo.toml | 6 +- ROADMAP.md | 2 + crates/mcp/src/backend.rs | 2 + crates/pdf/Cargo.toml | 2 +- crates/pdf/src/export.rs | 153 ++++++++++++------ crates/pdf/src/lib.rs | 6 + crates/pdf/src/links.rs | 1 + crates/pdf/src/marks.rs | 1 + crates/pdf/src/pdfx.rs | 1 + crates/pdf/src/tests.rs | 23 +++ crates/pdf/src/text.rs | 13 +- .../support/image_content_cache_contract.rs | 2 + docs/architecture.md | 10 +- docs/pdf-backend.md | 34 ++++ xtask/src/main.rs | 4 + xtask/src/pdf_deps.rs | 87 ++++++++++ 17 files changed, 299 insertions(+), 62 deletions(-) create mode 100644 docs/pdf-backend.md create mode 100644 xtask/src/pdf_deps.rs diff --git a/Cargo.lock b/Cargo.lock index ef525c84e..21064be13 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -796,6 +796,15 @@ dependencies = [ "libm", ] +[[package]] +name = "craft-pdf" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=c3487320b2d1de657b860b3c8abb44e1f2b32bd8#c3487320b2d1de657b860b3c8abb44e1f2b32bd8" +dependencies = [ + "krilla", + "thiserror 2.0.21", +] + [[package]] name = "craft-print" version = "0.1.0" @@ -1079,6 +1088,7 @@ dependencies = [ name = "designcraft-pdf" version = "0.7.1" dependencies = [ + "craft-pdf", "designcraft-color", "designcraft-compose", "designcraft-doc", @@ -1089,7 +1099,6 @@ dependencies = [ "hayro-interpret", "hayro-syntax", "image", - "krilla", "krilla-svg", "kurbo", "log", @@ -2437,8 +2446,7 @@ dependencies = [ [[package]] name = "krilla" version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27da593198b20eeba65caeb73c2bbeec3e53ab08fa549898312ce81c4fce5e33" +source = "git+https://github.com/storytold/craft-libs?rev=c3487320b2d1de657b860b3c8abb44e1f2b32bd8#c3487320b2d1de657b860b3c8abb44e1f2b32bd8" dependencies = [ "base64", "bumpalo", diff --git a/Cargo.toml b/Cargo.toml index b08a82fc2..14655b205 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,8 @@ rust-version = "1.95" repository = "https://github.com/storytold/designcraft" [workspace.dependencies] +# Shared writer; the immutable revision must be published before this consumer. +craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.1.0", default-features = false } craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" } # internal designcraft-geom = { path = "crates/geom" } @@ -57,12 +59,11 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg", tiff = "0.11" # SVG (same usvg as krilla-svg). resvg = { version = "0.47", default-features = false, features = ["text"] } -krilla-svg = "0.8" +krilla-svg = "=0.8.1" # Boolean operations on Bézier paths (Pathfinder). flo_curves = "0.8" # QR codes (Object › Generate QR Code); MIT. qrcodegen = "1.8" -krilla = { version = "0.8", features = ["pdf"] } hayro = "0.7" quick-xml = "0.38" zip = { version = "3", default-features = false, features = ["deflate"] } @@ -100,4 +101,5 @@ debug = 0 # Opt-in cancelable macOS termination on the pinned winit 0.30 line. [patch.crates-io] +krilla = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.8.2" } winit = { git = "https://github.com/skylartaylor/winit.git", rev = "3d51077fd1a966f84df9ef287839dc84fd5b4ca7" } diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..2cd564f94 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,8 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-12 (PDFW)** · PDF, booklet and book composition share guarded writing, metadata and font preparation through craft-pdf. Layout, shaping, placed graphics, colour policy, tagging and PDF/X postprocessing remain in DesignCraft. Failed native drawing returns an export error; failed writer cleanup cannot serialize an incomplete document. No readiness percentage change. + - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index debdbb124..0258bd7e4 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -172,6 +172,8 @@ mod tests { Err(e) => panic!("accept failed: {e}"), } }; + // Accepted sockets inherit nonblocking mode on macOS; only accept should poll. + stream.set_nonblocking(false).unwrap(); stream.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); stream.set_write_timeout(Some(Duration::from_secs(2))).unwrap(); let mut reader = BufReader::new(stream.try_clone().unwrap()); diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index 47c384470..6f40462b7 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -14,7 +14,7 @@ designcraft-color = { workspace = true } designcraft-doc = { workspace = true } designcraft-fonts = { workspace = true } designcraft-compose = { workspace = true } -krilla = { workspace = true } +craft-pdf = { workspace = true, features = ["write-pdf"] } krilla-svg = { workspace = true } designcraft-images = { workspace = true } image = { workspace = true } diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index dcf8abb83..f9d09b616 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -3,6 +3,8 @@ use std::collections::HashMap; use std::sync::Arc; +use craft_pdf::write::backend as krilla; +use craft_pdf::write::{self, Metadata, Writer}; use designcraft_color::swatch::{ColorType, SwatchValue}; use designcraft_color::{BlendMode as DcBlend, Color, GradientKind}; use designcraft_compose::Cache; @@ -10,10 +12,9 @@ use designcraft_doc::{AssetId, Content, Document, Item, PageSide, Spread, Stroke use designcraft_geom::kurbo::{PathEl, Shape as _}; use designcraft_geom::{Affine, BezPath, Rect, Vec2, corners}; use krilla::color::{cmyk, luma, rgb}; -use krilla::configure::{Archival, ConfigurationBuilder, PdfVersion}; +use krilla::configure::{Archival, PdfVersion}; use krilla::geom::{Path, PathBuilder, Size, Transform}; use krilla::image::Image; -use krilla::metadata::Metadata; use krilla::num::NormalizedF32; use krilla::page::PageSettings; use krilla::paint::{Fill, FillRule, LinearGradient, RadialGradient, SpreadMethod, Stop, Stroke, StrokeDash}; @@ -59,13 +60,13 @@ fn sheets(doc: &Document, opts: &PdfOptions) -> Result<Vec<Sheet>> { return Err(PdfError::NoPages); } if let Some(bad) = pages.iter().find(|p| **p >= count) { - return Err(PdfError::BadPage(bad + 1)); + return Err(PdfError::BadPage(bad.saturating_add(1))); } let b = doc.settings.bleed.map(|v| if opts.bleed { v.max(0.0) } else { 0.0 }); let mut out: Vec<Sheet> = Vec::new(); let mut seen_spreads: Vec<usize> = Vec::new(); for abs in pages { - let (si, pi) = doc.page_loc(abs).ok_or(PdfError::BadPage(abs + 1))?; + let (si, pi) = doc.page_loc(abs).ok_or(PdfError::BadPage(abs.saturating_add(1)))?; let sp: &Spread = &doc.spreads[si]; let (trim, l, r, label) = if opts.spreads { if seen_spreads.contains(&si) { @@ -101,6 +102,10 @@ pub(crate) fn mark_start(opts: &PdfOptions, bleed: [f64; 4]) -> f64 { /// Like [`export_pdf`], also returning warnings about approximated or dropped features. pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions) -> Result<ExportReport> { + write::guard(|| export_pdf_with_report_inner(doc, cache, opts)) +} + +fn export_pdf_with_report_inner(doc: &Document, cache: &Cache, opts: &PdfOptions) -> Result<ExportReport> { let sheets = sheets(doc, opts)?; let warnings = Vec::new(); let (version, archival) = match opts.standard { @@ -108,37 +113,20 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions) Standard::PdfX4 => (PdfVersion::Pdf16, None), Standard::PdfA2b => (PdfVersion::Pdf17, Some(Archival::A2_B)), }; - let mut cb = ConfigurationBuilder::new().with_version(version); - if let Some(a) = archival { - cb = cb.with_archival_validator(a); - } - let configuration = cb.finish().map_err(|e| PdfError::Write(format!("{e:?}")))?; + let configuration = write::configuration(version, archival)?; let settings = krilla::SerializeSettings { compress_content_streams: opts.compress, configuration, ..Default::default() }; - let mut pdf = krilla::Document::new_with(settings); - let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); - let mut meta = Metadata::new().creator("DesignCraft".into()).producer("DesignCraft".into()); - if !title.is_empty() { - meta = meta.title(title.clone()); - } - if let Some(a) = &opts.author { - meta = meta.authors(vec![a.clone()]); - } let created = opts.created.or_else(now_unix); - if let Some(t) = created { - let c = civil(t); - meta = meta.creation_date( - krilla::metadata::DateTime::new(c.0.clamp(0, 9999) as u16) - .month(c.1) - .day(c.2) - .hour(c.3) - .minute(c.4) - .second(c.5) - .utc_offset_hour(0) - .utc_offset_minute(0), - ); - } - pdf.set_metadata(meta); + let metadata = Metadata { + creator: "DesignCraft".into(), + producer: "DesignCraft".into(), + title: Some(title.clone()), + authors: opts.author.iter().cloned().collect(), + created: created.map(date_time), + ..Default::default() + }; + let mut writer = Writer::new(settings, metadata); + let pdf = writer.document_mut(); let mut ex = Exporter { doc, @@ -182,6 +170,8 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions) let settings = PageSettings::new(size).with_trim_box(local(sh.trim)).with_bleed_box(local(sh.bleed)); let mut page = pdf.start_page_with(settings); let mut s = page.surface(); + #[cfg(all(test, not(target_arch = "wasm32")))] + unwind_tests::inject(&mut s); s.push_transform(&tf(Affine::translate(-sh.media.origin().to_vec2()))); ex.clip = sh.bleed; if let Some(clip) = to_path(&sh.bleed.to_path(0.1)) { @@ -207,7 +197,7 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions) pdf.set_tag_tree(ex.tag_tree()); } ex.check_placed_versions()?; - let mut bytes = pdf.finish().map_err(|e| PdfError::Write(format!("{e:?}")))?; + let mut bytes = writer.finish().map_err(PdfError::from)?; // Transparency blends in the document's blend space: CMYK for print (and always in PDF/X). let mut warnings = ex.warnings; if !ex.rgb_only && (doc.settings.blend_space == designcraft_doc::BlendSpace::Cmyk || opts.standard == Standard::PdfX4) { @@ -245,12 +235,13 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions) /// One PDF from several (a book's documents), every page carried over as vectors; each part /// comes with its pages' sizes (points). pub fn merge_pdfs(parts: &[(Vec<u8>, Vec<(f32, f32)>)], title: Option<&str>) -> Result<Vec<u8>> { - let mut pdf = krilla::Document::new_with(krilla::SerializeSettings::default()); - let mut meta = Metadata::new().creator("DesignCraft".into()).producer("DesignCraft".into()); - if let Some(t) = title { - meta = meta.title(t.to_string()); - } - pdf.set_metadata(meta); + write::guard(|| merge_pdfs_inner(parts, title)) +} + +fn merge_pdfs_inner(parts: &[(Vec<u8>, Vec<(f32, f32)>)], title: Option<&str>) -> Result<Vec<u8>> { + let metadata = Metadata { creator: "DesignCraft".into(), producer: "DesignCraft".into(), title: title.map(str::to_owned), ..Default::default() }; + let mut writer = Writer::new(krilla::SerializeSettings::default(), metadata); + let pdf = writer.document_mut(); let mut pages = 0; for (bytes, sizes) in parts { let src = krilla::pdf::Pdf::new(Arc::new(bytes.clone())).map_err(|e| PdfError::Write(format!("{e:?}")))?; @@ -259,6 +250,8 @@ pub fn merge_pdfs(parts: &[(Vec<u8>, Vec<(f32, f32)>)], title: Option<&str>) -> let size = Size::from_wh(w.max(1.0), h.max(1.0)).ok_or(PdfError::NoPages)?; let mut page = pdf.start_page_with(PageSettings::new(size)); let mut s = page.surface(); + #[cfg(all(test, not(target_arch = "wasm32")))] + unwind_tests::inject(&mut s); s.draw_pdf_page(&doc, size, i); s.finish(); page.finish(); @@ -268,7 +261,7 @@ pub fn merge_pdfs(parts: &[(Vec<u8>, Vec<(f32, f32)>)], title: Option<&str>) -> if pages == 0 { return Err(PdfError::NoPages); } - pdf.finish().map_err(|e| PdfError::Write(format!("{e:?}"))) + writer.finish().map_err(PdfError::from) } /// File › Print Booklet: how pages pair up on printer spreads. @@ -303,19 +296,20 @@ pub fn booklet_pairs(n: usize, kind: BookletKind) -> Vec<(Option<usize>, Option< /// An imposed PDF: two document pages per sheet, in booklet order. pub fn export_booklet(doc: &Document, cache: &Cache, opts: &BookletOptions) -> Result<ExportReport> { + write::guard(|| export_booklet_inner(doc, cache, opts)) +} + +fn export_booklet_inner(doc: &Document, cache: &Cache, opts: &BookletOptions) -> Result<ExportReport> { let n = doc.page_count(); if n == 0 { return Err(PdfError::NoPages); } let pdf_opts = PdfOptions { title: opts.title.clone(), ..PdfOptions::default() }; let settings = krilla::SerializeSettings { compress_content_streams: true, ..Default::default() }; - let mut pdf = krilla::Document::new_with(settings); let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); - let mut meta = Metadata::new().creator("DesignCraft".into()).producer("DesignCraft".into()); - if !title.is_empty() { - meta = meta.title(title); - } - pdf.set_metadata(meta); + let metadata = Metadata { creator: "DesignCraft".into(), producer: "DesignCraft".into(), title: Some(title), ..Default::default() }; + let mut writer = Writer::new(settings, metadata); + let pdf = writer.document_mut(); let mut ex = Exporter { doc, cache, @@ -355,6 +349,8 @@ pub fn export_booklet(doc: &Document, cache: &Cache, opts: &BookletOptions) -> R for (left, right) in &pairs { let mut page = pdf.start_page_with(PageSettings::new(size)); let mut s = page.surface(); + #[cfg(all(test, not(target_arch = "wasm32")))] + unwind_tests::inject(&mut s); for (slot, abs) in [(0.0, left), (pw + gap, right)] { let Some((si, pi)) = abs.and_then(|a| doc.page_loc(a)) else { continue }; let p = &doc.spreads[si].pages[pi]; @@ -374,7 +370,7 @@ pub fn export_booklet(doc: &Document, cache: &Cache, opts: &BookletOptions) -> R page.finish(); } ex.check_placed_versions()?; - let mut bytes = pdf.finish().map_err(|e| PdfError::Write(format!("{e:?}")))?; + let mut bytes = writer.finish().map_err(PdfError::from)?; if doc.settings.blend_space == designcraft_doc::BlendSpace::Cmyk { crate::pdfx::cmyk_group_spaces(&mut bytes); } @@ -393,6 +389,18 @@ fn now_unix() -> Option<i64> { None } +fn date_time(t: i64) -> krilla::metadata::DateTime { + let c = civil(t); + krilla::metadata::DateTime::new(c.0.clamp(0, 9999) as u16) + .month(c.1) + .day(c.2) + .hour(c.3) + .minute(c.4) + .second(c.5) + .utc_offset_hour(0) + .utc_offset_minute(0) +} + /// Unix seconds (UTC) → (year, month, day, hour, minute, second), proleptic Gregorian. pub(crate) fn civil(t: i64) -> (i64, u8, u8, u8, u8, u8) { let days = t.div_euclid(86_400); @@ -1751,3 +1759,54 @@ mod image_content_cache_tests { assert!(duplicate.upgrade().is_none()); } } + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod unwind_tests { + use super::*; + use std::cell::Cell; + + thread_local! { + static FAIL_NEXT_SURFACE: Cell<bool> = const { Cell::new(false) }; + } + + // Fail with live backend drawing state, so a missing guard or an unpatched destructor + // makes the child fail. Thread-local state keeps unrelated export tests independent. + #[allow(clippy::panic)] // Test-only fault injection exercises recovery with a live surface. + pub(super) fn inject(surface: &mut krilla::surface::Surface<'_>) { + if FAIL_NEXT_SURFACE.replace(false) { + surface.push_transform(&krilla::geom::Transform::from_translate(2.0, 3.0)); + panic!("synthetic export drawing failure"); + } + } + + #[test] + fn export_entry_points_recover_in_a_subprocess() { + const CHILD: &str = "DESIGNCRAFT_PDF_EXPORT_PANIC_PROBE"; + if std::env::var_os(CHILD).is_none() { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "export::unwind_tests::export_entry_points_recover_in_a_subprocess", "--nocapture"]) + .env(CHILD, "1") + .output() + .unwrap(); + assert!(output.status.success(), "export child failed: {:?}\n{}", output.status, String::from_utf8_lossy(&output.stderr)); + assert!(String::from_utf8_lossy(&output.stdout).contains("all three export entry points recovered")); + return; + } + let doc = Document::new(&designcraft_doc::build::NewDocument { pages: 1, ..Default::default() }); + let cache = Cache::new(); + let source = export_pdf(&doc, &cache, &Default::default()).unwrap(); + let parts = [(source, vec![(612.0, 792.0)])]; + for entry in ["document", "booklet", "merge"] { + let export = || match entry { + "document" => export_pdf(&doc, &cache, &Default::default()), + "booklet" => export_booklet(&doc, &cache, &Default::default()).map(|report| report.bytes), + _ => merge_pdfs(&parts, None), + }; + FAIL_NEXT_SURFACE.set(true); + assert!(matches!(export(), Err(PdfError::Write(message)) if message.contains("synthetic export drawing failure")), "{entry}"); + let fresh = export().unwrap(); + assert!(!hayro_syntax::Pdf::new(Arc::new(fresh)).unwrap().pages().is_empty(), "fresh {entry} export must still work"); + } + println!("all three export entry points recovered"); + } +} diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index c4aad577c..d22098cb8 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -200,6 +200,12 @@ pub fn parse_page_range(s: &str, count: usize) -> Result<Vec<usize>> { Ok(out) } +impl From<craft_pdf::write::WriteError> for PdfError { + fn from(error: craft_pdf::write::WriteError) -> Self { + Self::Write(error.to_string()) + } +} + #[cfg(test)] mod tests; diff --git a/crates/pdf/src/links.rs b/crates/pdf/src/links.rs index ce81287a1..07541ad1f 100644 --- a/crates/pdf/src/links.rs +++ b/crates/pdf/src/links.rs @@ -1,6 +1,7 @@ //! Interactive PDF: hyperlink annotations (frames and text ranges → URLs, e-mail, pages) and the //! bookmark outline. +use craft_pdf::write::backend as krilla; use designcraft_doc::{Bookmark, Document, HyperlinkDest, HyperlinkSource, SpreadRef}; use designcraft_geom::{Affine, Rect}; use krilla::action::{Action, LinkAction}; diff --git a/crates/pdf/src/marks.rs b/crates/pdf/src/marks.rs index a2dab3356..07894e06a 100644 --- a/crates/pdf/src/marks.rs +++ b/crates/pdf/src/marks.rs @@ -1,6 +1,7 @@ //! Printer's marks drawn in the slug area: crop marks at the trim corners, bleed marks at the bleed //! corners, and a page-information line. All marks use `[Registration]` (every plate). +use craft_pdf::write::backend as krilla; use designcraft_geom::{BezPath, Point}; use krilla::num::NormalizedF32; use krilla::paint::{LineCap, LineJoin, Stroke}; diff --git a/crates/pdf/src/pdfx.rs b/crates/pdf/src/pdfx.rs index 3218ffc76..ebb3d12da 100644 --- a/crates/pdf/src/pdfx.rs +++ b/crates/pdf/src/pdfx.rs @@ -452,6 +452,7 @@ impl RgbScan { #[cfg(test)] mod tests { + use craft_pdf::write::backend as krilla; use std::sync::Arc; use designcraft_color::{Color, Swatch}; diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 9ac53d057..fdda4b28d 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -446,3 +446,26 @@ fn column_rules_are_drawn() { assert!((xs[0] - 214.0).abs() < 0.01 && (xs[1] - 398.0).abs() < 0.01, "{xs:?}"); assert!(rules(false).is_empty()); } + +#[test] +fn extreme_page_selection_is_a_page_error_without_overflow() { + let doc = doc_with_text("Bounded page selection"); + let opts = PdfOptions { pages: Some(vec![usize::MAX]), ..Default::default() }; + assert_eq!(export_pdf(&doc, &Cache::new(), &opts), Err(PdfError::BadPage(usize::MAX))); +} + +#[test] +fn empty_and_populated_export_metadata_preserve_key_presence() { + let mut d = Document::new(&NewDocument::default()); + d.title.clear(); + let s = uncompressed(&d, PdfOptions::default()); + for key in ["/Title", "/Author", "<dc:title>", "<dc:creator>"] { + assert!(!s.contains(key), "unexpected empty metadata {key}"); + } + d.title = "Synthetic title".into(); + let s = uncompressed(&d, PdfOptions { author: Some("Test author".into()), created: Some(1_700_000_000), ..Default::default() }); + for key in ["/Title", "/Author", "/CreationDate", "<dc:title>", "<dc:creator>"] { + assert!(s.contains(key), "missing populated metadata {key}"); + } + assert!(s.contains("Synthetic title") && s.contains("Test author")); +} diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs index bf59efded..ba06d48ab 100644 --- a/crates/pdf/src/text.rs +++ b/crates/pdf/src/text.rs @@ -10,6 +10,7 @@ use std::collections::HashMap; use std::ops::Range; use std::sync::Arc; +use craft_pdf::write::backend as krilla; use designcraft_compose::{ComposedStory, FrameText, PlacedGlyph}; use designcraft_fonts::{FontDb, FontFace}; use designcraft_geom::kurbo::Shape as _; @@ -66,13 +67,11 @@ impl Exporter<'_> { self.fonts .entry(face.id()) .or_insert_with(|| { - if face.is_variable() { - // A named instance: krilla embeds the font at these axis settings. - let coords: Vec<(krilla::text::Tag, f32)> = face.coords.iter().map(|(t, v)| (krilla::text::Tag::new(t), *v)).collect(); - Font::new_variable(face.data().to_vec().into(), face.index(), &coords) - } else { - Font::new(face.data().to_vec().into(), face.index()) - } + // Keep the existing outline fallback and warning when embedding fails. + let variations = face.is_variable().then_some(face.coords.as_slice()); + craft_pdf::write::font(face.data(), face.index(), variations) + .inspect_err(|error| log::warn!("PDF font `{} {}` could not be embedded: {error}", face.family, face.style)) + .ok() }) .clone() } diff --git a/crates/pdf/tests/support/image_content_cache_contract.rs b/crates/pdf/tests/support/image_content_cache_contract.rs index 6a4ac91f1..c7812ce7b 100644 --- a/crates/pdf/tests/support/image_content_cache_contract.rs +++ b/crates/pdf/tests/support/image_content_cache_contract.rs @@ -3,6 +3,8 @@ //! All images and placed PDFs are tiny procedural fixtures. use std::sync::Arc; +use craft_pdf::write::backend as krilla; + use designcraft_compose::Cache; use designcraft_doc::{Asset, AssetId, Content, Document, Graphic, Item, ItemId, Shape, SpreadRef}; use designcraft_geom::{Affine, Rect, shapes}; diff --git a/docs/architecture.md b/docs/architecture.md index 39a9039dc..5073a4b86 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # DesignCraft architecture -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (cancelable macOS Dock Quit) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (shared PDF writing) · **Target:** Adobe InDesign 2026 How DesignCraft is built today. The design notes behind it are in `plan/architecture.md` (local, gitignored); this file describes what the code on `main` does. Agents: AGENTS.md has the @@ -21,7 +21,7 @@ lines of Rust, 1,122 tests (2026-10-10). Pure Rust, no C dependencies by default | L2 | `compose` | the text engine: style resolution, shaping, Knuth–Plass and single-line breaking, Liang hyphenation, justification, tabs, keeps, columns, span/split, baseline grid, vertical justification, wrap, threading, overset, anchored objects, footnotes, tables, bidi, CJK (ruby, warichu, kinsoku), caret and hit testing | 11.7k | | L2 | `images` | decoding placed graphics (PNG, JPEG, GIF, WebP, TIFF incl. CMYK, BMP, PSD composite, EPS preview) | 1.6k | | L3 | `render` | document to pixels with vello_cpu (SIMD, multithreaded): items, composed text, images with mips, gradients, effects, damage regions, placed PDF via hayro | 3.5k | -| L3 | `pdf` | PDF export with krilla: real text with subset fonts, CMYK and spot, bleed and marks, tagged PDF, links, forms, transitions, PDF/X-4 and PDF/A-2b | 5.0k | +| L3 | `pdf` | PDF export through craft-pdf with Krilla: real text with subset fonts, CMYK and spot, bleed and marks, tagged PDF, links, forms, transitions, PDF/X-4 and PDF/A-2b | 5.0k | | L3 | `idml` | IDML import and export (designmap, spreads, parents, stories, styles, graphics, preferences; CJK and Arabic attributes) | 8.0k | | L3 | `epub` | EPUB 3 reflowable and fixed-layout export | 0.9k | | L3 | `textimport` | Word (.docx), RTF, Excel (.xlsx), Tagged Text import; text export | 1.8k | @@ -83,6 +83,12 @@ Native `.designcraft` (zip + JSON); IDML both ways; PDF, EPUB, HTML, PNG/JPEG, t placed graphics through `images` (PDF/AI pages through hayro); Word/RTF/Excel/Tagged Text through `textimport`. INDD is not readable. Details: [file-format-parity.md](file-format-parity.md). +PDF authoring uses the independently pinned `craft-pdf` writer and patched Krilla. Document, +booklet and merge entry points guard the full export; a failed native drawing unwind returns +an error and incomplete backend state cannot serialize. Layout, shaping, PDF/X processing and +print preparation remain app-owned. Reader dependencies and the UI/print pins are unchanged. +See [pdf-backend.md](pdf-backend.md) for dependency and failure contracts. + ## Agent control The desktop app opens a loopback JSON-lines control channel (`--control PORT`) with diff --git a/docs/pdf-backend.md b/docs/pdf-backend.md new file mode 100644 index 000000000..fcc6afeaa --- /dev/null +++ b/docs/pdf-backend.md @@ -0,0 +1,34 @@ +# PDF writing boundary + +DesignCraft pins `craft-pdf` and its patched Krilla 0.8.2 backend to craft-libs revision +`c3487320b2d1de657b860b3c8abb44e1f2b32bd8`. The `write-pdf` capability is required for +placed PDF pages and book merging. The ordinary reader dependencies, `craft-ui` and +`craft-print` revisions remain unchanged by this writer migration. + +The shared library supplies fallible font copying/preparation, metadata setup, version and +archival configuration, final serialization and a native unwind guard. DesignCraft retains +layout, shaping, font choice/licensing, fallback warnings, image caches, links, tagging, +bleed/marks, PDF/X postprocessing and export presets. A shared writer does not improve the +format-conformance or product-parity claims by itself. + +`export_pdf_with_report`, `export_booklet` and `merge_pdfs` guard the whole export, including +painting and postprocessing. A native drawing panic becomes `PdfError::Write`. A failed +page or surface cleanup permanently poisons the backend writer, preventing successful +serialization of an incomplete document after a narrow catch. Discard other captured app +state after an internal failure. These guards do not catch WASM aborts, allocation aborts, +or errors in preceding application operations such as print preparation. + +Cargo applies patches only from the consuming workspace root. Keep the Krilla patch and +facade at the same full immutable commit, including Krilla reached through `krilla-svg`. +`cargo xtask pdf-deps` verifies their resolved versions, source and uniqueness, and runs in +`cargo xtask ci`. Publish the dependency revision before publishing this consumer branch. +Local path replacements are suitable only for development and should not be committed. + +The vendored Krilla patch ledger, per-file hashes, licence texts and four unchanged CC0 +Compact ICC profiles live in craft-libs (`vendor/krilla`, `licenses/pdf-writer`, and +`ATTRIBUTION.md`). No fonts or binary PDF fixtures are added to DesignCraft by this migration. + +Tests cover metadata key presence, hostile page selection, successful PDF export and native +subprocess recovery for all three export entry points. Native/WASM export comparisons and +independent PDF parsing/rendering supplement the repository gates. Physical printer output, +Windows/Linux runtime and PDF/A/PDF/X certification remain separate acceptance work. diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 4850efeae..c89b7e8d7 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -7,6 +7,7 @@ mod assets; mod ico; mod layers; mod parity; +mod pdf_deps; mod stats; mod version; @@ -24,6 +25,7 @@ commands: corpus [--download] show where test corpora live; --download fetches PngSuite into corpus/pngsuite stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`) + pdf-deps verify the pinned shared PDF writer and patched Krilla parity recompute the InDesign feature-parity summary in docs/parity-checklist.md ico <out.ico> <in.png>... pack square PNGs (<= 256 px) into a Windows .ico (see packaging/icons.sh) @@ -39,6 +41,7 @@ fn main() -> ExitCode { Some("ci") => cmd_ci(), Some("corpus") => cmd_corpus(rest.contains(&"--download")), Some("stats") => stats::run(&root(), rest.contains(&"--exact")), + Some("pdf-deps") => pdf_deps::run(&root()), Some("parity") => parity::run(&root()), Some("ico") => ico::run(&rest), Some("version") => version::run(&root(), &rest), @@ -175,6 +178,7 @@ fn cmd_ci() -> Result<(), String> { run(c, "cargo test --workspace") }), ), + ("pdf-deps", Box::new(|| pdf_deps::run(&root()))), ("assets", Box::new(|| assets::run(&root()))), ("layers", Box::new(cmd_layers)), ("wasm", Box::new(cmd_wasm)), diff --git a/xtask/src/pdf_deps.rs b/xtask/src/pdf_deps.rs new file mode 100644 index 000000000..d7dfb1cb7 --- /dev/null +++ b/xtask/src/pdf_deps.rs @@ -0,0 +1,87 @@ +//! Check the shared writer source, including transitive users such as krilla-svg. +use std::path::Path; +use std::process::Command; + +use serde_json::Value; + +const REV: &str = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8"; +const PACKAGES: &[(&str, &str)] = &[("craft-pdf", "0.1.0"), ("krilla", "0.8.2")]; + +pub fn run(root: &Path) -> Result<(), String> { + let output = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into())) + .current_dir(root) + .args(["metadata", "--locked", "--format-version", "1"]) + .output() + .map_err(|e| format!("cargo metadata: {e}"))?; + if !output.status.success() { + return Err(format!("cargo metadata: {}", String::from_utf8_lossy(&output.stderr))); + } + let metadata: Value = serde_json::from_slice(&output.stdout).map_err(|e| format!("cargo metadata: {e}"))?; + validate(&metadata)?; + println!("PDF writer: facade and patched Krilla resolve once to {REV}"); + Ok(()) +} + +fn validate(metadata: &Value) -> Result<(), String> { + let packages = metadata.get("packages").and_then(Value::as_array).ok_or("cargo metadata has no packages")?; + let source = format!("git+https://github.com/storytold/craft-libs?rev={REV}#{REV}"); + for (name, version) in PACKAGES { + let matching: Vec<_> = packages.iter().filter(|p| p.get("name").and_then(Value::as_str) == Some(name)).collect(); + let [package] = matching.as_slice() else { + return Err(format!("{name}: expected one resolved package, found {}", matching.len())); + }; + if package.get("version").and_then(Value::as_str) != Some(version) || package.get("source").and_then(Value::as_str) != Some(source.as_str()) { + return Err(format!("{name}: expected {version} from {source}; got {package}")); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn metadata() -> Value { + json!({"packages": PACKAGES.iter().map(|(name, version)| json!({"name":name,"version":version, + "source":format!("git+https://github.com/storytold/craft-libs?rev={REV}#{REV}")})).collect::<Vec<_>>()}) + } + + #[test] + fn accepts_one_pinned_facade_and_writer() { + assert!(validate(&metadata()).is_ok()); + } + + #[test] + fn rejects_unpatched_and_local_writer_sources() { + for source in [Value::Null, json!("registry+https://github.com/rust-lang/crates.io-index")] { + let mut m = metadata(); + m["packages"][1]["source"] = source; + assert!(validate(&m).is_err()); + } + } + + #[test] + fn rejects_missing_duplicate_and_wrong_version_packages() { + for index in [0, 1] { + let mut m = metadata(); + m["packages"][index]["version"] = json!("99.0.0"); + assert!(validate(&m).is_err()); + let mut m = metadata(); + let duplicate = m["packages"][index].clone(); + m["packages"].as_array_mut().unwrap().push(duplicate); + assert!(validate(&m).is_err()); + let mut m = metadata(); + m["packages"].as_array_mut().unwrap().remove(index); + assert!(validate(&m).is_err()); + } + } + + #[test] + fn rejects_facade_revision_drift_and_malformed_metadata() { + let mut m = metadata(); + m["packages"][0]["source"] = json!("git+https://github.com/storytold/craft-libs?branch=main#other"); + assert!(validate(&m).is_err()); + assert!(validate(&json!({})).is_err()); + } +} From 7a89954937937b371b29aa6d089de5cccc086105 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Tue, 13 Oct 2026 12:06:41 -0700 Subject: [PATCH 167/238] Placed PDFs: render with pdfcraft's patched hayro crates Placed PDFs rendered through hayro 0.7 from crates.io, whose hayro-interpret divides integers in calculator functions without a check, does not limit how deep functions nest and samples each mesh triangle over its whole bounding box. A placed PDF whose calculator function runs `1 0 idiv` or `-2147483648 -1 idiv` panicked the render. A chain of 100,000 functions, each stitching function naming the next, overflowed the stack and aborted the process. Mesh shadings with points far off the page, ten thousand overlapping triangles or forty thousand patches ran for over a minute or passed 2 GiB of memory. [patch.crates-io] now takes hayro, hayro-interpret, hayro-syntax and hayro-jbig2 from pdfcraft's vendor/ directory at b5467d93, with the patches its vendor/README.md lists. The crate versions do not change. The patched hayro-syntax sizes a page with a /UserUnit in points, and pdf_page_box scales the page's boxes by the same unit, so with Crop to the frame still shows the chosen box. NOTICE names the new source and the licenses. The function and mesh tests in crates/render/tests/placed_pdf.rs follow pdfcraft's tests in crates/render/src/raster.rs. --- Cargo.lock | 27 ++-- Cargo.toml | 5 + NOTICE | 5 + ROADMAP.md | 1 + crates/render/src/lib.rs | 12 +- crates/render/tests/placed_pdf.rs | 249 ++++++++++++++++++++++++++++++ 6 files changed, 285 insertions(+), 14 deletions(-) create mode 100644 crates/render/tests/placed_pdf.rs diff --git a/Cargo.lock b/Cargo.lock index ef525c84e..a87f01c74 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2004,13 +2004,13 @@ dependencies = [ [[package]] name = "hayro" version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4caa128ab87fd48ffb7490617cf93f77f606820dffbc9fd9ef6ab0ed077f56d" +source = "git+https://github.com/storytold/pdfcraft?rev=b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9#b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" dependencies = [ "bytemuck", "hayro-interpret", "image", "kurbo", + "log", "pic-scale", "vello_cpu 0.0.8", ] @@ -2034,8 +2034,7 @@ dependencies = [ [[package]] name = "hayro-interpret" version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2613d0406898995042d0794c4245b2f2fba1246490c8ac593769bba6551129d" +source = "git+https://github.com/storytold/pdfcraft?rev=b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9#b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" dependencies = [ "bitflags 2.13.2", "hayro-cmap", @@ -2045,7 +2044,7 @@ dependencies = [ "phf", "rustc-hash 2.1.3", "siphasher", - "skrifa 0.42.1", + "skrifa 0.47.0", "smallvec", "yoke", ] @@ -2053,8 +2052,7 @@ dependencies = [ [[package]] name = "hayro-jbig2" version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69374b3668dd45aeb3d3145cda68f2c7b4f223aaa2511e67d076f1c7d741388d" +source = "git+https://github.com/storytold/pdfcraft?rev=b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9#b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" dependencies = [ "fearless_simd", "hayro-ccitt", @@ -2078,8 +2076,7 @@ checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa" [[package]] name = "hayro-syntax" version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0edeafd70aa2db743de8ede8637d07ec87db05efe69cde371d03f1b185fcef27" +source = "git+https://github.com/storytold/pdfcraft?rev=b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9#b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" dependencies = [ "flate2", "hayro-ccitt", @@ -2088,6 +2085,7 @@ dependencies = [ "memchr", "rustc-hash 2.1.3", "smallvec", + "stringprep", "zune-jpeg", ] @@ -4199,6 +4197,17 @@ dependencies = [ "float-cmp", ] +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "subsetter" version = "0.2.6" diff --git a/Cargo.toml b/Cargo.toml index b08a82fc2..9ce011b57 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -101,3 +101,8 @@ debug = 0 # Opt-in cancelable macOS termination on the pinned winit 0.30 line. [patch.crates-io] winit = { git = "https://github.com/skylartaylor/winit.git", rev = "3d51077fd1a966f84df9ef287839dc84fd5b4ca7" } +# The hayro crates from pdfcraft's vendor/ directory, with the patches its README lists. +hayro = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" } +hayro-interpret = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" } +hayro-syntax = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" } +hayro-jbig2 = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" } diff --git a/NOTICE b/NOTICE index 087bb6196..21123eea5 100644 --- a/NOTICE +++ b/NOTICE @@ -58,6 +58,11 @@ list (author, source, license) is in ASSETS.md. Shippori Mincho), under the SIL Open Font License 1.1. Their licence texts ship with the packages as OFL-<family>.txt. Builds without CRAFT_FONTS_DIR do not include them. - All UI icons are drawn in code (crates/ui-egui/src/icons.rs) and are original work, MIT OR Apache-2.0. +- hayro PDF crates (placed PDFs): hayro, hayro-interpret, hayro-syntax and hayro-jbig2 + (Apache-2.0 OR MIT) are pdfcraft's patched copies, fetched by git from its vendor/ directory at + the commit that Cargo.toml pins (https://github.com/storytold/pdfcraft, MIT OR Apache-2.0). + hayro-interpret embeds PDFium's Foxit standard-14 fonts under PDFium's BSD-style license + (vendor/hayro-interpret/assets/LICENSE_FOXIT) and a CMYK profile under CC0-1.0. Rust dependencies (vello_cpu, kurbo, skrifa, harfrust, image, egui, eframe, …) are permissively licensed (MIT/Apache-2.0/BSD/ISC/Zlib) and fetched through crates.io. diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..2c8c2ec07 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -147,6 +147,7 @@ Newest first. - **2026-10-11** · `story.replaceRange`, `story.setText`, notes, footnotes, endnotes, type on a path and table cells read `\r` and `\r\n` as paragraph returns, as `frame.create` and `text.insert` already did. No readiness percentage change. - **2026-10-11** · The View menu follows InDesign's: proofing first, then zoom, Rotate Spread, Screen Mode, Display Performance, rulers, Extras, Grids & Guides and Structure; the view items that sat at the end of the Type menu moved there. No readiness percentage change. - **2026-10-12** · Underlines and strikethroughs are drawn as one bar per stretch of text on screen, as in PDF export: a bar per letter left faint seams between the letters. No readiness percentage change. +- **2026-10-13** · Placed PDFs are drawn through pdfcraft's patched hayro crates: calculator functions whose `idiv` divides by zero or overflows, chains of stitching functions a hundred thousand deep, and mesh shadings with points far off the page, ten thousand overlapping triangles or forty thousand patches no longer panic, overflow the stack, run for over a minute or use more than 2 GiB of memory. A page with a `/UserUnit`, and its trim, bleed and art boxes, are sized in points. No readiness percentage change. - **2026-10-12** · Properties › Transform shows rotation, shear and X/Y scale, and Rotate 90° and Flip Horizontal/Vertical buttons, as InDesign's does. No readiness percentage change. - **2026-10-12** · Edit › Step and Repeat has its InDesign dialog: Repeat (Count, or Rows and Columns with Create as a grid), Offset (Vertical, Horizontal, in ruler units) and Preview, which shows the copies while the dialog is open; Cancel takes them away and OK makes them as one undo step. No readiness percentage change. - **2026-10-12** · Double-clicking the Fill or Stroke box in the Tools panel opens the Color Picker with the selection's colour, as in InDesign (it opened the Swatches panel). No readiness percentage change. diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 2cc5c548f..b94281e73 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1081,18 +1081,20 @@ pub fn pdf_page_box(bytes: &[u8], page: usize, kind: &str) -> Option<(f64, f64, let pdf = hayro::hayro_syntax::Pdf::new(std::sync::Arc::new(bytes.to_vec())).ok()?; let p = pdf.pages().get(page)?; let visible = p.intersected_crop_box(); + // The boxes are in user space units; `/UserUnit` is the size of one in points. + let unit = p.user_unit() as f64; let key: &[u8] = match kind { "trim" => keys::TRIM_BOX, "bleed" => keys::BLEED_BOX, "art" => keys::ART_BOX, - "media" => return Some(rel(visible, p.media_box())), - _ => return Some(rel(visible, visible)), + "media" => return Some(rel(visible, p.media_box(), unit)), + _ => return Some(rel(visible, visible, unit)), }; let b = p.raw().get::<PRect>(key).map(|b| b.intersect(p.media_box())).unwrap_or(visible); - fn rel(v: PRect, b: PRect) -> (f64, f64, f64, f64) { - (b.x0 - v.x0, v.y1 - b.y1, b.width(), b.height()) + fn rel(v: PRect, b: PRect, unit: f64) -> (f64, f64, f64, f64) { + ((b.x0 - v.x0) * unit, (v.y1 - b.y1) * unit, b.width() * unit, b.height() * unit) } - Some(rel(visible, b)) + Some(rel(visible, b, unit)) } /// Separations Preview on a rendered image: `plate` 0–3 shows that process plate (C, M, Y, K) as diff --git a/crates/render/tests/placed_pdf.rs b/crates/render/tests/placed_pdf.rs new file mode 100644 index 000000000..d7487086a --- /dev/null +++ b/crates/render/tests/placed_pdf.rs @@ -0,0 +1,249 @@ +//! Placed PDFs are drawn through pdfcraft's patched hayro crates. Calculator functions whose `idiv` +//! divides by zero or overflows, stitching functions nested a hundred thousand deep, and mesh +//! shadings with points far off the page, ten thousand overlapping triangles or forty thousand +//! patches do not panic, overflow the stack or take more than a minute, and the rest of the page is +//! drawn. A page with a `/UserUnit` is sized in points, and so are its boxes. The function and mesh +//! cases follow pdfcraft's tests in `crates/render/src/raster.rs`. + +use std::sync::Arc; +use std::sync::mpsc::{self, RecvTimeoutError}; +use std::time::Duration; + +use designcraft_compose::Cache; +use designcraft_doc::build::NewDocument; +use designcraft_doc::{Asset, AssetId, Content, Document, Graphic, Item, ItemId, Shape, SpreadRef}; +use designcraft_geom::{Affine, Rect, shapes}; +use designcraft_render::{Rendered, Renderer, pdf_page_box, pdf_page_size}; +use hayro::hayro_interpret::Function; +use hayro::hayro_syntax::Pdf; +use hayro::hayro_syntax::object::{Object, ObjectIdentifier}; + +const WHITE: [u8; 4] = [255, 255, 255, 255]; +const RED: [u8; 4] = [255, 0, 0, 255]; +const BLUE: [u8; 4] = [0, 0, 255, 255]; + +/// An exponential function of one input with one output. +const LEAF: &str = "<</FunctionType 2/Domain[0 1]/C0[0]/C1[1]/N 1>>"; + +/// Mesh entries common to the shadings below: RGB colors, 8-bit coordinates and components. +const MESH: &str = "/ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8"; + +/// One triangle of a free-form triangle mesh: three vertices of a flag, x, y and a color. +const TRIANGLE: [u8; 18] = [0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 255, 0, 0, 0, 255, 0, 0, 255]; + +/// A stream object with the `dict` entries, its `/Length` and `data`. +fn stream(dict: &str, data: &[u8]) -> Vec<u8> { + let mut s = format!("<<{dict} /Length {}>>\nstream\n", data.len()).into_bytes(); + s.extend_from_slice(data); + s.extend_from_slice(b"\nendstream"); + s +} + +/// A one-page PDF whose page dictionary holds the `page` entries (boxes, resources) and whose page +/// draws `content`. The `objects` are numbered from 5. +fn pdf(page: &str, content: &str, objects: &[Vec<u8>]) -> Vec<u8> { + let mut bodies = vec![ + b"<</Type/Catalog/Pages 2 0 R>>".to_vec(), + b"<</Type/Pages/Kids[3 0 R]/Count 1>>".to_vec(), + format!("<</Type/Page/Parent 2 0 R{page}/Contents 4 0 R>>").into_bytes(), + stream("", content.as_bytes()), + ]; + bodies.extend_from_slice(objects); + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, body) in bodies.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(body); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f\r\n", bodies.len() + 1).as_bytes()); + for offset in offsets { + out.extend_from_slice(format!("{offset:010} 00000 n\r\n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<</Size {}/Root 1 0 R>>\nstartxref\n{xref}\n%%EOF\n", bodies.len() + 1).as_bytes()); + out +} + +/// The page of a document `size` points square with `pdf` placed to fill it, rendered by +/// `Renderer::render_page` at one pixel per point. Fails when the render panics or takes more +/// than a minute. +fn placed(size: f64, pdf: Vec<u8>) -> Rendered { + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let mut d = Document::new(&NewDocument { width: size, height: size, facing_pages: false, ..Default::default() }); + let id = AssetId(d.alloc()); + let asset = + d.add_asset(Asset { id, name: "art.pdf".into(), mime: "application/pdf".into(), link: None, data: Arc::new(pdf), pixels: None, page: 0 }); + let id = ItemId(d.alloc()); + let mut item = Item::new(id, d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, size, size))); + item.content = + Content::Graphic(Graphic { asset, size: (size, size), xf: Affine::IDENTITY, auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4] }); + d.insert_item(SpreadRef::Doc(0), item, None).unwrap(); + let mut renderer = Renderer::new(); + renderer.threads = 0; + let _ = tx.send(renderer.render_page(&d, &Cache::new(), 0, 1.0, false, &Default::default())); + }); + match rx.recv_timeout(Duration::from_secs(60)) { + Ok(page) => page.expect("the page renders"), + Err(RecvTimeoutError::Timeout) => panic!("the placed PDF did not render within a minute"), + Err(RecvTimeoutError::Disconnected) => panic!("the render of the placed PDF panicked"), + } +} + +/// A 40-point page that paints an axial shading through the first of `functions` (objects 6 +/// onwards), then a blue square over the whole page. +fn shaded(functions: &[Vec<u8>]) -> Vec<u8> { + let mut objects = vec![b"<</ShadingType 2/ColorSpace/DeviceRGB/Coords[0 0 40 0]/Function 6 0 R>>".to_vec()]; + objects.extend_from_slice(functions); + pdf("/MediaBox[0 0 40 40]/Resources<</Shading<</S 5 0 R>>>>", "/S sh 0 0 1 rg 0 0 40 40 re f", &objects) +} + +/// A calculator (Type 4) function of one input that runs `program`. +fn calculator(program: &str) -> Vec<u8> { + stream("/FunctionType 4/Domain[0 1]/Range[0 1 0 1 0 1]", program.as_bytes()) +} + +/// The function that is object 5 of a PDF holding `objects`. +fn function(objects: &[&str]) -> Option<Function> { + let objects: Vec<Vec<u8>> = objects.iter().map(|o| o.as_bytes().to_vec()).collect(); + let parsed = Pdf::new(pdf("/MediaBox[0 0 20 20]", "", &objects)).unwrap(); + let object = parsed.xref().get::<Object<'_>>(ObjectIdentifier::new(5, 0)).unwrap(); + Function::new(&object) +} + +#[test] +fn placed_pdf_function_dividing_by_zero_leaves_the_page_drawn() { + assert_eq!(placed(40.0, shaded(&[calculator("{ pop 1 0 idiv }")])).pixel(20, 20), BLUE); +} + +#[test] +fn placed_pdf_function_dividing_the_smallest_integer_by_minus_one_leaves_the_page_drawn() { + assert_eq!(placed(40.0, shaded(&[calculator("{ pop -2147483648 -1 idiv }")])).pixel(20, 20), BLUE); +} + +/// A chain of 100,000 functions: each stitching function names the next by reference, and the last +/// is an exponential function. +#[test] +fn placed_pdf_chain_of_stitching_functions_leaves_the_page_drawn() { + let mut functions: Vec<Vec<u8>> = + (7..100_006).map(|next| format!("<</FunctionType 3/Domain[0 1]/Functions[{next} 0 R]/Bounds[]/Encode[0 1]>>").into_bytes()).collect(); + functions.push(b"<</FunctionType 2/Domain[0 1]/C0[0 0 0]/C1[1 1 1]/N 1>>".to_vec()); + assert_eq!(placed(40.0, shaded(&functions)).pixel(20, 20), BLUE); +} + +/// Stitching functions that each name the next through `N 0 R`: 64 evaluate, 65 are refused. +#[test] +fn function_limits_depth_guard_refuses_a_chain_of_references() { + for functions in [64, 65] { + let objects: Vec<String> = (5..5 + functions) + .map(|number| { + if number < 4 + functions { + format!("<</FunctionType 3/Domain[0 1]/Functions[{} 0 R]/Bounds[]/Encode[0 1]>>", number + 1) + } else { + LEAF.to_owned() + } + }) + .collect(); + let objects: Vec<&str> = objects.iter().map(String::as_str).collect(); + let chain = function(&objects); + if functions == 64 { + assert_eq!(chain.unwrap().eval([0.5].into_iter().collect()).unwrap().as_slice(), &[0.5]); + } else { + assert!(chain.is_none(), "65 functions are one past the function-depth guard"); + } + } +} + +/// Stitching functions with a cycle, no children or a missing child are refused; one child named +/// twice still evaluates. +#[test] +fn function_limits_stitching_cycles_are_refused_and_shared_children_work() { + for objects in [ + &["<</FunctionType 3/Domain[0 1]/Functions[5 0 R]/Bounds[]/Encode[0 1]>>"][..], + &[ + "<</FunctionType 3/Domain[0 1]/Functions[6 0 R]/Bounds[]/Encode[0 1]>>", + "<</FunctionType 3/Domain[0 1]/Functions[5 0 R]/Bounds[]/Encode[0 1]>>", + ], + &["<</FunctionType 3/Domain[0 1]/Functions[]/Bounds[]/Encode[]>>"], + &["<</FunctionType 3/Domain[0 1]/Functions[99 0 R]/Bounds[]/Encode[0 1]>>"], + &["<</FunctionType 3/Domain[0 1]/Functions[6 0 R 99 0 R]/Bounds[0.5]/Encode[0 1 0 1]>>", LEAF], + ] { + assert!(function(objects).is_none(), "accepted invalid children: {objects:?}"); + } + let shared = function(&["<</FunctionType 3/Domain[0 1]/Functions[6 0 R 6 0 R]/Bounds[0.5]/Encode[0 1 0 1]>>", LEAF]).unwrap(); + let output = shared.eval([0.75].into_iter().collect()).unwrap(); + assert!((output[0] - 0.5).abs() < 0.001); +} + +/// Ten thousand triangles that each cover half of the placed page, which is drawn 1000 pixels +/// square. +#[test] +fn many_overlapping_mesh_triangles_finish() { + let shading = stream(&format!("/ShadingType 4 {MESH} /BitsPerFlag 8 /Decode [0 125 0 125 0 1 0 1 0 1]"), &TRIANGLE.repeat(10_000)); + let page = placed(125.0, pdf("/MediaBox[0 0 125 125]/Resources<</Shading<</Sh0 5 0 R>>>>", "/Sh0 sh", &[shading])); + let p = page.pixel(12, 112); + assert!(p != WHITE && p[3] == 255, "the triangles are drawn: {p:?}"); +} + +/// One Coons patch: its flag, twelve points around a square of 8-bit coordinates, four colors. +fn coons() -> Vec<u8> { + let mut patch = vec![0u8]; + for p in [(0, 0), (0, 85), (0, 170), (0, 255), (85, 255), (170, 255), (255, 255), (255, 170), (255, 85), (255, 0), (170, 0), (85, 0)] { + patch.extend_from_slice(&[p.0, p.1]); + } + patch.extend_from_slice(&[255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 0]); + patch +} + +/// A 40-point page that paints `shading` (object 5), then a red square at its bottom left. +fn meshed(shading: Vec<u8>) -> Vec<u8> { + pdf("/MediaBox[0 0 40 40]/Resources<</Shading<</Sh0 5 0 R>>>>", "/Sh0 sh 1 0 0 rg 0 0 4 4 re f", &[shading]) +} + +/// Meshes whose points lie far off the page are sampled only where the page shows them. +#[test] +fn mesh_shadings_far_off_the_page_render() { + let far = "/Decode [-40000 40000 -40000 40000 0 1 0 1 0 1]"; + for (what, shading) in [ + ("a Coons patch", stream(&format!("/ShadingType 6 {MESH} /BitsPerFlag 8 {far}"), &coons())), + ("a triangle mesh", stream(&format!("/ShadingType 4 {MESH} /BitsPerFlag 8 {far}"), &TRIANGLE)), + ] { + assert_eq!(placed(40.0, meshed(shading)).pixel(1, 38), RED, "{what} far off the page: the rest of the page draws"); + } + for (what, decode) in [("on the page", "[0 40 0 40 0 1 0 1 0 1]"), ("covering the page", "[-40000 40000 -40000 40000 0 1 0 1 0 1]")] { + let page = placed(40.0, meshed(stream(&format!("/ShadingType 6 {MESH} /BitsPerFlag 8 /Decode {decode}"), &coons()))); + let center = page.pixel(20, 20); + assert!(center != WHITE && center[3] == 255, "a patch {what} is shaded: {center:?}"); + } + // A patch filling a 4 × 4 pt rectangle as a pattern is still shaded. + let shading = stream(&format!("/ShadingType 6 {MESH} /BitsPerFlag 8 /Decode [10 14 10 14 0 1 0 1 0 1]"), &coons()); + let page = placed( + 40.0, + pdf("/MediaBox[0 0 40 40]/Resources<</Pattern<</P0<</PatternType 2/Shading 5 0 R>>>>>>", "/Pattern cs /P0 scn 10 10 4 4 re f", &[shading]), + ); + let middle = page.pixel(12, 28); + assert!(middle != WHITE && middle[3] == 255, "a small patch is shaded: {middle:?}"); +} + +/// Forty thousand Coons patches beside the page, and a lattice with no vertices per row. +#[test] +fn mesh_shadings_with_many_patches_or_no_rows_finish() { + for shading in [ + stream(&format!("/ShadingType 6 {MESH} /BitsPerFlag 8 /Decode [100 140 100 140 0 1 0 1 0 1]"), &coons().repeat(40_000)), + stream(&format!("/ShadingType 5 {MESH} /VerticesPerRow 0 /Decode [0 40 0 40 0 1 0 1 0 1]"), &[0; 64]), + ] { + assert_eq!(placed(40.0, meshed(shading)).pixel(1, 38), RED, "the rest of the page draws"); + } +} + +/// A page with `/UserUnit 2` is twice its media box in points, and Image Import Options › Crop +/// to measures the trim box in points as well. +#[test] +fn a_page_with_a_user_unit_and_its_boxes_are_sized_in_points() { + let page = pdf("/MediaBox[0 0 100 50]/TrimBox[10 10 90 40]/UserUnit 2", "", &[]); + assert_eq!(pdf_page_size(&page, 0), Some((200.0, 100.0))); + assert_eq!(pdf_page_box(&page, 0, "trim"), Some((20.0, 20.0, 160.0, 60.0))); + assert_eq!(pdf_page_box(&page, 0, "media"), Some((0.0, 0.0, 200.0, 100.0))); +} From 4760a000dbcf42515a915c6c1ef2ec542f111a87 Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Wed, 14 Oct 2026 06:16:28 +1000 Subject: [PATCH 168/238] PARITY-FIND-SCOPE: respect search scope and first-match limits Co-Authored-By: Codex <codex@openai.com> --- crates/engine/src/cmd/find.rs | 921 ++++++++++++++++- crates/engine/src/cmd/mod.rs | 3 +- crates/engine/src/cmd/text.rs | 2 + crates/engine/src/lib.rs | 20 + crates/ui-egui/src/control.rs | 8 +- crates/ui-egui/src/dialogs.rs | 1837 ++++++++++++++++++++++++--------- crates/ui-egui/src/lib.rs | 10 +- crates/ui-egui/src/menus.rs | 18 + 8 files changed, 2280 insertions(+), 539 deletions(-) diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs index 5dc0bc8ad..39f85b0a5 100644 --- a/crates/engine/src/cmd/find.rs +++ b/crates/engine/src/cmd/find.rs @@ -1,17 +1,22 @@ //! Edit → Find/Change: plain text and GREP (regular expressions) with InDesign's metacharacters, //! optional formatting in the change, scoped to the document, a story or the selection. -use designcraft_doc::{StoryId, TextSel, story}; +use std::ops::Range; +use std::sync::{Arc, Weak}; + +use designcraft_doc::story::floor_char_boundary; +use designcraft_doc::{Document, Selection, StoryId, TextSel}; use serde_json::{Value, json}; use super::{CommandSpec, bad, bool_or, cmd, has_doc, str_param}; -use crate::{Result, Session}; +use crate::{DocState, Result, Session}; pub fn specs() -> Vec<CommandSpec> { vec![ cmd!(noundo "find.objects", "Find Object", [], None, "{fill?, stroke?: swatch, strokeWeight?, opacity? (0–1), kind?: text|graphic|shape|line|group, layer?, label?, select?: bool (default true)} → {ids} — Find/Change › Object, through the document", has_doc, |s, p| { + s.reset_find_scope(); let ids = find_objects(&s.doc()?.doc, p); if p.get("select").and_then(Value::as_bool).unwrap_or(true) && !ids.is_empty() { let st = s.doc_mut()?; @@ -28,6 +33,7 @@ pub fn specs() -> Vec<CommandSpec> { "{…find.objects criteria, change: {fill?, stroke?, strokeWeight?, opacity?}} → {changed}", has_doc, |s, p| { + s.reset_find_scope(); let ids = find_objects(&s.doc()?.doc, p); let ch = p.get("change").cloned().unwrap_or(Value::Null); if !ch.is_object() { @@ -65,7 +71,7 @@ pub fn specs() -> Vec<CommandSpec> { has_doc, change ), - cmd!(noundo "find.next", "Find Next", ["Edit"], Some("Cmd+Alt+F"), "{find, grep?, …} — selects the next match after the caret", has_doc, find_next), + cmd!(noundo "find.next", "Find Next", ["Edit"], Some("Cmd+Alt+F"), "{find, grep?, caseSensitive?, wholeWord?, scope?: document|story|selection, story?} — Selection starts at its original beginning; later calls advance within the scope", has_doc, find_next), ] } @@ -125,34 +131,182 @@ fn build(p: &Value) -> Result<regex::Regex> { .map_err(|e| bad("find", format!("bad GREP: {e}"))) } -fn scope(s: &Session, p: &Value) -> Result<Vec<(StoryId, std::ops::Range<usize>)>> { +#[derive(Clone, Debug, PartialEq, Eq)] +enum Domain { + Document, + Story, + Selection, + Explicit(StoryId), +} + +impl Domain { + fn from_params(p: &Value) -> Self { + if let Some(id) = p.get("story").and_then(Value::as_u64) { + return Self::Explicit(StoryId(id)); + } + match str_param(p, "scope").unwrap_or("document") { + "story" => Self::Story, + "selection" => Self::Selection, + _ => Self::Document, + } + } +} + +/// Whether two requests use the same search domain, including explicit-story precedence. +pub fn same_find_domain(a: &Value, b: &Value) -> bool { + Domain::from_params(a) == Domain::from_params(b) +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct Matcher { + find: String, + grep: bool, + case_sensitive: bool, + whole_word: bool, +} + +impl Matcher { + fn from_params(p: &Value) -> Self { + Self { + find: str_param(p, "find").unwrap_or("").to_string(), + grep: bool_or(p, "grep", false), + case_sensitive: bool_or(p, "caseSensitive", false), + whole_word: bool_or(p, "wholeWord", false), + } + } +} + +/// A search owns its original domain, not the selection used to paint the latest hit. +/// Weak identity avoids retaining an old document merely for this transient cache. +#[derive(Clone)] +pub(crate) struct FindContext { + document: Weak<Document>, + uid: u64, + revision: u64, + selection: Selection, + domain: Domain, + matcher: Matcher, + targets: Vec<(StoryId, Range<usize>)>, + order: Vec<usize>, + target: usize, + offset: usize, + last_match_end: Option<usize>, +} + +impl FindContext { + pub(crate) fn is_current(&self, st: &DocState) -> bool { + self.uid == st.uid && Weak::ptr_eq(&self.document, &Arc::downgrade(&st.doc)) && self.revision == st.revision && self.selection == st.selection + } + + fn acknowledge(&mut self, st: &DocState) { + self.revision = st.revision; + self.selection = st.selection.clone(); + } + + fn restart(&mut self, st: &DocState) -> Result<()> { + self.target = 0; + self.last_match_end = None; + self.order = (0..self.targets.len()).collect(); + let caret = st.selection.text.filter(|t| t.cell.is_none() && st.selection.cells.is_none()); + if self.domain == Domain::Document + && let Some(t) = st.selection.text + && let Some(i) = self.targets.iter().position(|(sid, _)| *sid == t.story) + { + self.order.rotate_left(i); + } + let first = self.order.first().and_then(|i| self.targets.get(*i)); + self.offset = match first { + Some((sid, r)) if self.domain != Domain::Selection => caret.filter(|t| t.story == *sid).map_or(r.start, |t| t.range().end.min(r.end)), + Some((_, r)) => r.start, + None => 0, + }; + if let Some((sid, r)) = first { + let text = target_text(&st.doc, *sid, r)?; + if text.get(self.offset.saturating_sub(r.start)..).is_none() { + return Err(bad("find", "invalid UTF-8 search cursor")); + } + } + Ok(()) + } + + fn advance_target(&mut self) { + self.target = self.target.saturating_add(1); + self.last_match_end = None; + self.offset = self.order.get(self.target).and_then(|i| self.targets.get(*i)).map_or(0, |(_, r)| r.start); + } + + fn advance_empty(&mut self, text: &str, relative_end: usize, end: usize) { + // Move one Unicode scalar, not one byte; an endpoint is returned at most once. + if let Some(ch) = text.get(relative_end..).and_then(|rest| rest.chars().next()) { + self.offset = end + ch.len_utf8(); + } else { + self.advance_target(); + } + } +} + +fn target_text<'a>(d: &'a Document, sid: StoryId, r: &Range<usize>) -> Result<&'a str> { + d.story(sid) + .ok_or_else(|| bad("find", "no such story"))? + .text + .get(r.clone()) + .ok_or_else(|| bad("find", "invalid selection bounds or UTF-8 boundary")) +} + +fn scope(s: &Session, p: &Value) -> Result<FindContext> { let st = s.doc()?; + let domain = Domain::from_params(p); + let matcher = Matcher::from_params(p); + if let Some(previous) = s.find_context.as_ref().filter(|c| c.is_current(st) && c.domain == domain) { + let mut context = previous.clone(); + if context.matcher != matcher { + context.matcher = matcher; + context.restart(st)?; + } + return Ok(context); + } let d = &st.doc; - if let Some(sid) = p.get("story").and_then(Value::as_u64) { - let sid = StoryId(sid); - let len = d.story(sid).map(|x| x.len()).ok_or_else(|| bad("find", "no such story"))?; - return Ok(vec![(sid, 0..len)]); - } - match str_param(p, "scope").unwrap_or("document") { - "story" => { - let t = st.selection.text.ok_or_else(|| bad("find", "no story selected"))?; - Ok(vec![(t.story, 0..d.story(t.story).map(|x| x.len()).unwrap_or(0))]) + let targets = match domain { + Domain::Explicit(sid) => { + let len = d.story(sid).ok_or_else(|| bad("find", "no such story"))?.len(); + vec![(sid, 0..len)] } - "selection" => { + Domain::Story | Domain::Selection => { + if st.selection.cells.is_some() || st.selection.text.is_some_and(|t| t.cell.is_some()) { + return Err(bad("find", "Story/Selection search in table cells or notes is not supported")); + } let t = st.selection.text.ok_or_else(|| bad("find", "no text selected"))?; - Ok(vec![(t.story, t.range())]) + let story = d.story(t.story).ok_or_else(|| bad("find", "no such story"))?; + let r = if domain == Domain::Selection { t.range() } else { 0..story.len() }; + target_text(d, t.story, &r)?; + vec![(t.story, r)] } - _ => Ok(d.stories.values().map(|x| (x.id, 0..x.len())).collect()), - } + Domain::Document => d.stories.values().map(|x| (x.id, 0..x.len())).collect(), + }; + let mut context = FindContext { + document: Arc::downgrade(d), + uid: st.uid, + revision: st.revision, + selection: st.selection.clone(), + domain, + matcher, + targets, + order: Vec::new(), + target: 0, + offset: 0, + last_match_end: None, + }; + context.restart(st)?; + Ok(context) } fn find(s: &mut Session, p: &Value) -> Result<Value> { let re = build(p)?; + let context = scope(s, p)?; let st = s.doc()?; let mut out = Vec::new(); - for (sid, r) in scope(s, p)? { - let Some(story) = st.doc.story(sid) else { continue }; - let text = &story.text[r.clone()]; + for (sid, r) in &context.targets { + let text = target_text(&st.doc, *sid, r)?; for m in re.find_iter(text) { out.push(json!({"story": sid.0, "start": r.start + m.start(), "end": r.start + m.end(), "text": m.as_str()})); if out.len() >= 10_000 { @@ -160,6 +314,7 @@ fn find(s: &mut Session, p: &Value) -> Result<Value> { } } } + s.find_context = Some(context); Ok(Value::Array(out)) } @@ -182,13 +337,13 @@ fn change(s: &mut Session, p: &Value) -> Result<Value> { } } super::text::validate_char_fill(&s.doc()?.doc, &over, "find.change")?; - let targets = scope(s, p)?; + let targets = scope(s, p)?.targets; s.edit(|d, sel| { let mut count = 0usize; for (sid, r) in targets { let Some(story) = d.story(sid) else { continue }; // Collect matches first (positions refer to the original text), apply back to front. - let text = story.text[r.clone()].to_string(); + let text = story.text.get(r.clone()).ok_or_else(|| bad("find", "invalid selection bounds or UTF-8 boundary"))?.to_string(); let mut edits: Vec<(std::ops::Range<usize>, String)> = Vec::new(); for caps in re.captures_iter(&text) { let Some(m) = caps.get(0) else { continue }; @@ -211,10 +366,21 @@ fn change(s: &mut Session, p: &Value) -> Result<Value> { } count += 1; } - if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid) { - let len = st.len(); - t.anchor = t.anchor.min(len); - t.focus = t.focus.min(len); + if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid && t.cell.is_none()) { + // Repair byte validity once against the final story, without remapping valid offsets. + let range = t.range(); + let start = floor_char_boundary(&st.text, range.start); + let mut end = range.end.min(st.len()); + if t.is_caret() { + end = start; + } else if !st.text.is_char_boundary(end) { + let floor = floor_char_boundary(&st.text, end); + end = st.text.get(floor..).and_then(|rest| rest.chars().next()).map_or(floor, |ch| floor + ch.len_utf8()); + } + (t.anchor, t.focus) = if t.anchor <= t.focus { (start, end) } else { (end, start) }; + } + if first_only && count > 0 { + break; } } Ok(json!({"count": count})) @@ -223,29 +389,44 @@ fn change(s: &mut Session, p: &Value) -> Result<Value> { fn find_next(s: &mut Session, p: &Value) -> Result<Value> { let re = build(p)?; + let mut context = scope(s, p)?; let st = s.doc()?; - let (start_story, start_pos) = st.selection.text.map(|t| (Some(t.story), t.range().end)).unwrap_or((None, 0)); - let mut order: Vec<StoryId> = st.doc.stories.keys().copied().collect(); - if let Some(ss) = start_story - && let Some(i) = order.iter().position(|x| *x == ss) - { - order.rotate_left(i); - } let mut hit = None; - for (k, sid) in order.iter().enumerate() { - let Some(story) = st.doc.story(*sid) else { continue }; - let from = if k == 0 && start_story == Some(*sid) { start_pos.min(story.len()) } else { 0 }; - if let Some(m) = re.find_at(&story.text, from) { - hit = Some((*sid, m.start(), m.end())); + while let Some((sid, r)) = context.order.get(context.target).and_then(|i| context.targets.get(*i)).cloned() { + let text = target_text(&st.doc, sid, &r)?; + let from = context.offset.checked_sub(r.start).ok_or_else(|| bad("find", "invalid search cursor"))?; + if text.get(from..).is_none() { + return Err(bad("find", "invalid UTF-8 search cursor")); + } + if let Some(m) = re.find_at(text, from) { + let (a, b) = (r.start + m.start(), r.start + m.end()); + // Match find_iter/captures_iter: suppress an empty match adjacent to the + // previous match's end, but keep adjacent nonempty matches. + if m.is_empty() && context.last_match_end == Some(b) { + context.advance_empty(text, m.end(), b); + continue; + } + hit = Some((sid, a, b)); + context.last_match_end = Some(b); + if m.is_empty() { + context.advance_empty(text, m.end(), b); + } else { + context.offset = b; + } break; } + context.advance_target(); } - let Some((sid, a, b)) = hit else { return Ok(Value::Null) }; + let Some((sid, a, b)) = hit else { + s.find_context = Some(context); + return Ok(Value::Null); + }; let frame = st.doc.story(sid).and_then(|x| x.frames.first().copied()); let stm = s.doc_mut()?; - stm.selection = designcraft_doc::Selection::text(TextSel { story: sid, anchor: a, focus: b, frame, cell: None }); + stm.selection = Selection::text(TextSel { story: sid, anchor: a, focus: b, frame, cell: None }); stm.revision += 1; - let _ = story::PAGE_NUMBER; + context.acknowledge(stm); + s.find_context = Some(context); Ok(json!({"story": sid.0, "start": a, "end": b})) } @@ -278,6 +459,664 @@ mod tests { s.execute("find.next", &json!({"find": "or"})).unwrap(); assert!(s.doc().unwrap().selection.text.is_some()); } + fn scope_session(text: &str) -> (Session, designcraft_doc::StoryId) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let frame = s.execute("frame.create", &json!({"rect": [36, 36, 100, 100], "content": "text", "text": text})).unwrap(); + (s, designcraft_doc::StoryId(frame["story"].as_u64().unwrap())) + } + + fn scope_select(s: &mut Session, sid: designcraft_doc::StoryId, a: usize, b: usize) { + s.execute("text.select", &json!({"story": sid.0, "anchor": a, "focus": b})).unwrap(); + } + + fn scope_hit(s: &mut Session, p: &serde_json::Value, sid: designcraft_doc::StoryId, a: usize, b: usize) { + assert_eq!(s.execute("find.next", p).unwrap(), json!({"story": sid.0, "start": a, "end": b})); + let selected = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((selected.story, selected.range(), selected.cell), (sid, a..b, None)); + } + + #[test] + fn find_scope_first_change_stops_after_first_matching_story_and_repairs_selection() { + for (texts, first, explicit, grep, expected, count) in [ + (vec!["red red", "red"], Some(true), false, false, vec!["blue red", "red"], 1), + (vec!["keep", "red red", "red"], Some(true), false, false, vec!["keep", "blue red", "red"], 1), + (vec!["keep", "other"], Some(true), false, false, vec!["keep", "other"], 0), + (vec!["red red", "red"], Some(false), false, false, vec!["blue blue", "blue"], 3), + (vec!["red red", "red"], None, false, false, vec!["blue blue", "blue"], 3), + (vec!["red red", "red"], Some(true), true, false, vec!["blue red", "red"], 1), + (vec!["red red", "red"], Some(false), true, false, vec!["blue blue", "red"], 2), + (vec!["", "é🙂"], Some(true), false, true, vec!["X", "é🙂"], 1), + (vec!["é", "🙂"], Some(false), false, true, vec!["XéX", "X🙂X"], 4), + ] { + let (mut s, sid) = scope_session(texts[0]); + let mut sids = vec![sid]; + for text in texts.iter().skip(1) { + let frame = s.execute("frame.create", &json!({"rect": [120, 36, 200, 100], "content": "text", "text": text})).unwrap(); + sids.push(designcraft_doc::StoryId(frame["story"].as_u64().unwrap())); + } + assert_eq!(s.doc().unwrap().doc.stories.keys().copied().collect::<Vec<_>>(), sids); + // Document Change keeps canonical target order even with a later story current. + scope_select(&mut s, *sids.last().unwrap(), 0, 0); + let before = s.doc().unwrap().doc.clone(); + let mut p = json!({ + "find": if grep { "(?:)" } else { "red" }, + "change": if grep { "X" } else { "blue" }, + "scope": "document", "grep": grep + }); + if let Some(first) = first { + p["first"] = json!(first); + } + if explicit { + p["story"] = json!(sid.0); + p["scope"] = json!("selection"); + } + if first == Some(true) && !explicit && !grep && texts.last() == Some(&"red") { + let last = *sids.last().unwrap(); + scope_hit(&mut s, &p, last, 0, 3); + let first_hit = sids[if texts.len() == 2 { 0 } else { 1 }]; + assert_eq!( + s.execute("find.find", &p).unwrap(), + json!([ + {"story": first_hit.0, "start": 0, "end": 3, "text": "red"}, + {"story": first_hit.0, "start": 4, "end": 7, "text": "red"}, + {"story": last.0, "start": 0, "end": 3, "text": "red"} + ]), + "Count remains canonical after Next paints the later story" + ); + } + let selection = s.doc().unwrap().selection.clone(); + assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": count})); + let st = s.doc().unwrap(); + assert_eq!(st.selection, selection); + for (i, sid) in sids.iter().enumerate() { + assert_eq!(st.doc.story(*sid).unwrap().text, expected[i]); + if texts[i] == expected[i] { + assert_eq!(st.doc.story(*sid), before.story(*sid), "untouched story {i} stays entirely unchanged"); + } + } + st.doc.check().unwrap(); + } + + // Identical replacement is still one counted match, not a reason to visit another story. + let (mut s, sid) = scope_session("red red"); + let other = s.execute("frame.create", &json!({"rect": [120, 36, 200, 100], "content": "text", "text": "red"})).unwrap(); + let other_sid = designcraft_doc::StoryId(other["story"].as_u64().unwrap()); + scope_select(&mut s, other_sid, 0, 3); + let before = s.doc().unwrap().doc.clone(); + let selection = s.doc().unwrap().selection.clone(); + assert_eq!( + s.execute("find.change", &json!({"find": "red", "change": "red", "scope": "document", "first": true})).unwrap(), + json!({"count": 1}) + ); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "red red"); + assert_eq!(s.doc().unwrap().doc.story(other_sid), before.story(other_sid)); + assert_eq!(s.doc().unwrap().selection, selection); + s.doc().unwrap().doc.check().unwrap(); + + for reverse in [false, true] { + let (mut s, sid) = scope_session("éX X"); + let other = s.execute("frame.create", &json!({"rect": [120, 36, 200, 100], "content": "text", "text": "X"})).unwrap(); + let other_sid = designcraft_doc::StoryId(other["story"].as_u64().unwrap()); + scope_select(&mut s, sid, if reverse { 3 } else { 2 }, if reverse { 2 } else { 3 }); + let before = s.doc().unwrap().doc.clone(); + let mut selection = s.doc().unwrap().selection.clone(); + let t = selection.text.as_mut().unwrap(); + (t.anchor, t.focus) = if reverse { (6, 2) } else { (2, 6) }; + let p = json!({"find": "X", "change": "🙂", "scope": "document", "first": true}); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 3); + assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": 1})); + let st = s.doc().unwrap(); + assert_eq!(st.doc.story(sid).unwrap().text, "é🙂 X"); + assert_eq!(st.doc.story(other_sid), before.story(other_sid)); + assert_eq!(st.selection, selection, "first-match exit must follow final UTF-8 repair"); + st.doc.check().unwrap(); + assert_eq!( + s.execute("find.find", &json!({"find": "🙂", "scope": "selection"})).unwrap(), + json!([{"story": sid.0, "start": 2, "end": 6, "text": "🙂"}]) + ); + } + } + + #[test] + fn find_scope_exact_baseline_count_next_change() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let frame = s + .execute( + "frame.create", + &json!({ + "rect": [36, 36, 300, 200], "content": "text", "text": "red red" + }), + ) + .unwrap(); + let sid = frame["story"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 3})).unwrap(); + assert_eq!( + s.execute("find.find", &json!({"find": "red", "scope": "selection"})).unwrap(), + json!([{"story": sid, "start": 0, "end": 3, "text": "red"}]) + ); + assert_eq!(s.execute("find.next", &json!({"find": "red", "scope": "selection"})).unwrap(), json!({"story": sid, "start": 0, "end": 3})); + assert_eq!(s.execute("find.change", &json!({"find": "red", "change": "blue", "scope": "selection"})).unwrap(), json!({"count": 1})); + assert_eq!(s.execute("story.get", &json!({"story": sid})).unwrap()["text"], "blue red"); + } + + #[test] + fn find_scope_change_growth_preserves_direction_and_full_scalar_hits() { + for (text, find, change, changed, start, old_end, new_end) in [("a", "a", "é", "é", 0, 1, 2), ("éX", "X", "🙂", "é🙂", 2, 3, 6)] { + for reverse in [false, true] { + let (mut s, sid) = scope_session(text); + // Real caret movement keeps the frame identity, unlike text.select. + s.execute("text.move", &json!({"dir": "left", "extend": reverse})).unwrap(); + if !reverse { + s.execute("text.move", &json!({"dir": "right", "extend": true})).unwrap(); + } + let selected = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((selected.anchor, selected.focus), if reverse { (old_end, start) } else { (start, old_end) }); + assert!(selected.frame.is_some()); + let mut expected = s.doc().unwrap().selection.clone(); + let t = expected.text.as_mut().unwrap(); + (t.anchor, t.focus) = if reverse { (new_end, start) } else { (start, new_end) }; + let p = json!({"find": find, "change": change, "scope": "selection"}); + assert_eq!(s.execute("find.find", &p).unwrap(), json!([{"story": sid.0, "start": start, "end": old_end, "text": find}])); + assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": 1})); + let st = s.doc().unwrap(); + assert_eq!(st.doc.story(sid).unwrap().text, changed); + assert_eq!(st.selection, expected); + let t = st.selection.text.unwrap(); + assert!(changed.is_char_boundary(t.anchor) && changed.is_char_boundary(t.focus)); + assert!(s.find_context.is_none()); + let p = json!({"find": change, "scope": "selection"}); + assert_eq!(s.execute("find.find", &p).unwrap(), json!([{"story": sid.0, "start": start, "end": new_end, "text": change}])); + scope_hit(&mut s, &p, sid, start, new_end); + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null); + } + } + } + + #[test] + fn find_scope_change_repairs_final_boundaries_without_remapping_valid_offsets() { + for (text, (a, b), find, change, changed, (after_a, after_b), count) in [ + ("aX", (1, 1), "a", "é", "éX", (0, 0), 1), // A caret stays collapsed and floors. + ("aX", (2, 2), "a", "é", "éX", (2, 2), 1), // A valid caret stays at its byte offset. + ("abc", (1, 2), "abc", "🙂", "🙂", (0, 4), 1), // Both endpoints need repair. + ("abc", (2, 1), "abc", "🙂", "🙂", (4, 0), 1), + ("é🙂", (2, 6), "🙂", "X", "éX", (2, 3), 1), // Existing shortening behavior. + ("é🙂", (6, 2), "🙂", "X", "éX", (3, 2), 1), + ("red", (0, 3), "red", "blue", "blue", (0, 3), 1), // Do not extend a valid upper bound. + ("aX", (1, 2), "a", "zz", "zzX", (1, 2), 1), // Do not track the original X. + ("aaX", (1, 2), "a", "é", "ééX", (0, 2), 2), // Per-edit repair would wrongly yield 0..4. + ("aaX", (2, 3), "a", "é", "ééX", (2, 4), 2), + ("abc", (1, 3), "abc", "", "", (0, 0), 1), + ("abc", (2, 3), "abc", "é", "é", (2, 2), 1), // EOF clamp may collapse a nonempty range. + ] { + let (mut s, sid) = scope_session(text); + scope_select(&mut s, sid, a, b); + let mut expected = s.doc().unwrap().selection.clone(); + let t = expected.text.as_mut().unwrap(); + (t.anchor, t.focus) = (after_a, after_b); + assert_eq!(s.execute("find.change", &json!({"find": find, "change": change, "scope": "document"})).unwrap(), json!({"count": count})); + let st = s.doc().unwrap(); + assert_eq!(st.doc.story(sid).unwrap().text, changed, "{text} {a}->{b}"); + assert_eq!(st.selection, expected, "{text} {a}->{b}"); + assert!(changed.is_char_boundary(after_a) && changed.is_char_boundary(after_b)); + if after_a == after_b { + assert_eq!( + s.execute("find.find", &json!({"find": "(?:)", "grep": true, "scope": "selection"})).unwrap(), + json!([{"story": sid.0, "start": after_a, "end": after_a, "text": ""}]) + ); + } + } + } + + #[test] + fn find_scope_change_other_story_and_no_hits_preserve_selection() { + for explicit in [false, true] { + let (mut s, sid) = scope_session("éX"); + let frame = s.doc().unwrap().selection.text.unwrap().frame.unwrap(); + let other = s.execute("frame.create", &json!({"rect": [120, 36, 200, 100], "content": "text", "text": "a"})).unwrap(); + let other_sid = designcraft_doc::StoryId(other["story"].as_u64().unwrap()); + scope_select(&mut s, sid, 3, 2); + let before = s.doc().unwrap().selection.clone(); + let mut p = json!({"find": "a", "change": "🙂", "scope": "document"}); + if explicit { + p["story"] = json!(other_sid.0); + } + assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": 1})); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "éX"); + assert_eq!(s.doc().unwrap().doc.story(other_sid).unwrap().text, "🙂"); + assert_eq!(s.doc().unwrap().selection, before); + assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": 0})); + assert_eq!(s.doc().unwrap().selection, before); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "éX"); + assert_eq!(s.doc().unwrap().doc.story(other_sid).unwrap().text, "🙂"); + + s.execute("selection.set", &json!({"ids": [frame.0]})).unwrap(); + let before = s.doc().unwrap().selection.clone(); + assert!(before.text.is_none()); + p["find"] = json!("🙂"); + p["change"] = json!("é"); + assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": 1})); + assert_eq!(s.doc().unwrap().selection, before); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "éX"); + assert_eq!(s.doc().unwrap().doc.story(other_sid).unwrap().text, "é"); + } + } + + #[test] + fn find_scope_mixed_grep_matches_count_next_and_change() { + for (text, pattern, expected, changed) in [ + ("a", "a*", vec![(0, 1)], "X"), + ("aa", "a|(?:)", vec![(0, 1), (1, 2)], "XX"), + ("ab", "a*", vec![(0, 1), (2, 2)], "XbX"), + ("éa", "é|(?:)", vec![(0, 2), (3, 3)], "XaX"), + ] { + let (mut s, sid) = scope_session(text); + scope_select(&mut s, sid, 0, text.len()); + let p = json!({"find": pattern, "grep": true, "scope": "selection", "change": "X"}); + let counted = s.execute("find.find", &p).unwrap(); + let ranges: Vec<_> = + counted.as_array().unwrap().iter().map(|m| (m["start"].as_u64().unwrap() as usize, m["end"].as_u64().unwrap() as usize)).collect(); + assert_eq!(ranges, expected, "{pattern} on {text}"); + for (a, b) in &expected { + scope_hit(&mut s, &p, sid, *a, *b); + } + for _ in 0..2 { + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null, "{pattern} on {text}"); + } + assert_eq!(s.execute("find.find", &p).unwrap(), counted); + assert_eq!(s.execute("find.change", &p).unwrap()["count"], expected.len()); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, changed); + } + } + + #[test] + fn find_scope_capture_replacement_and_first_keep_original_domain() { + let (mut s, sid) = scope_session("a1 a2 | a3"); + scope_select(&mut s, sid, 0, 5); + let p = json!({"find": r"a(\d)", "grep": true, "scope": "selection", "change": "b$1", "first": true}); + scope_hit(&mut s, &p, sid, 0, 2); + scope_hit(&mut s, &p, sid, 3, 5); + assert_eq!(s.execute("find.change", &p).unwrap()["count"], 1); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "b1 a2 | a3"); + } + + #[test] + fn find_scope_selection_retains_nonempty_hits_count_change_and_native_text() { + for (a, b) in [(0, 7), (7, 0)] { + let (mut s, sid) = scope_session("red red | red"); + scope_select(&mut s, sid, a, b); + let p = json!({"find": "red", "change": "blue", "scope": "selection", "attrs": {"fontStyle": "Italic"}}); + let before = s.doc().unwrap().doc.clone(); + let selection = s.doc().unwrap().selection.clone(); + let revision = s.doc().unwrap().revision; + let history = s.doc().unwrap().history.undo.len(); + let strong = std::sync::Arc::strong_count(&before); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 2); + assert_eq!(s.doc().unwrap().selection, selection); + assert_eq!(s.doc().unwrap().revision, revision); + assert_eq!(std::sync::Arc::strong_count(&before), strong, "search stores weak document identity"); + scope_hit(&mut s, &p, sid, 0, 3); + scope_hit(&mut s, &p, sid, 4, 7); + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null); + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 2); + assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc)); + assert_eq!(s.doc().unwrap().history.undo.len(), history); + assert_eq!(s.execute("find.change", &p).unwrap()["count"], 2); + let changed = s.doc().unwrap().doc.story(sid).unwrap().clone(); + assert_eq!(changed.text, "blue blue | red"); + assert_eq!(changed.char_format_at(0).over.font_style.as_deref(), Some("Italic")); + assert_eq!(s.doc().unwrap().history.undo.len(), history + 1); + assert!(s.find_context.is_none()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "red red | red"); + assert!(s.find_context.is_none()); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap(), &changed); + let native = s.execute("file.serialize", &json!({})).unwrap(); + s.execute("file.openBytes", &json!({"name": "scope.designcraft", "base64": native["base64"]})).unwrap(); + assert_eq!(serde_json::to_value(s.doc().unwrap().doc.story(sid).unwrap()).unwrap(), serde_json::to_value(&changed).unwrap()); + assert!(s.find_context.is_none()); + } + } + + #[test] + fn find_scope_matcher_restart_replacement_continuation_and_external_reselection() { + let (mut s, sid) = scope_session("red blue red | blue red"); + scope_select(&mut s, sid, 0, 12); + let mut p = json!({"find": "red", "scope": "selection", "change": "one"}); + scope_hit(&mut s, &p, sid, 0, 3); + p["change"] = json!("two"); + p["attrs"] = json!({"fontStyle": "Italic"}); + scope_hit(&mut s, &p, sid, 9, 12); + p["find"] = json!("blue"); + scope_hit(&mut s, &p, sid, 4, 8); + p["find"] = json!("red"); + scope_hit(&mut s, &p, sid, 0, 3); + p["caseSensitive"] = json!(true); + scope_hit(&mut s, &p, sid, 0, 3); + p["grep"] = json!(true); + scope_hit(&mut s, &p, sid, 0, 3); // GREP alone changes matcher identity. + p["find"] = json!("r.d"); + scope_hit(&mut s, &p, sid, 0, 3); + p["wholeWord"] = json!(true); + scope_hit(&mut s, &p, sid, 0, 3); + let hit = s.doc().unwrap().selection.text.unwrap(); + scope_select(&mut s, sid, hit.anchor, hit.focus); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1, "same-range external reselection starts a new domain"); + scope_hit(&mut s, &p, sid, 0, 3); + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null); + scope_select(&mut s, sid, 20, 23); + p["find"] = json!("red"); + scope_hit(&mut s, &p, sid, 20, 23); + } + + #[test] + fn find_scope_word_reselection_at_same_hit_recaptures_bounds() { + let (mut s, sid) = scope_session("red red | red"); + scope_select(&mut s, sid, 0, 7); + let p = json!({"find": "red", "change": "blue", "scope": "selection"}); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 2); + scope_hit(&mut s, &p, sid, 0, 3); + let selected = s.doc().unwrap().selection.clone(); + let frame = selected.text.unwrap().frame.unwrap(); + let point = { + let st = s.doc().unwrap(); + let cs = s.cache.get(&st.doc, sid, None); + let (fi, x, baseline, ascent, _) = designcraft_compose::caret(&cs, 1).unwrap(); + let inner = designcraft_geom::Point::new(x, baseline - ascent * 0.5); + assert_eq!(cs.frames[fi].frame, frame); + assert_eq!(designcraft_compose::hit(&cs, fi, inner), Some(1), "real point lies inside the current hit"); + let loc = st.doc.find(frame).unwrap(); + st.doc.parent_xf(&loc) * st.doc.text_xf(st.doc.item(frame).unwrap()) * inner + }; + let before = s.doc().unwrap().doc.clone(); + let revision = s.doc().unwrap().revision; + let history = s.doc().unwrap().history.undo.len(); + s.execute("text.selectWord", &json!({"frame": frame.0, "point": [point.x, point.y]})).unwrap(); + assert!(s.text_drag, "exercise place's in-selection early return"); + assert_eq!(s.doc().unwrap().selection, selected, "same full selection, including frame"); + assert_eq!(s.doc().unwrap().revision, revision + 1); + assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc)); + assert_eq!(s.doc().unwrap().history.undo.len(), history); + assert!(s.find_context.is_none()); + assert_eq!(s.execute("find.find", &p).unwrap(), json!([{"story": sid.0, "start": 0, "end": 3, "text": "red"}])); + assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": 1})); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "blue red | red"); + assert_eq!(s.doc().unwrap().history.undo.len(), history + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "red red | red"); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "blue red | red"); + } + + #[test] + fn find_scope_story_explicit_story_document_order_and_scope_changes() { + let (mut s, a) = scope_session("black red"); + let other = s.execute("frame.create", &json!({"rect": [150, 36, 100, 100], "content": "text", "text": "red"})).unwrap(); + let b = designcraft_doc::StoryId(other["story"].as_u64().unwrap()); + scope_select(&mut s, a, 0, 9); + let story = json!({"find": "red", "scope": "story"}); + assert_eq!(s.execute("find.next", &story).unwrap(), serde_json::Value::Null, "Story does not enter B or wrap to its prefix"); + scope_select(&mut s, b, 0, 0); + let explicit = json!({"find": "black", "story": a.0, "scope": "selection"}); + scope_hit(&mut s, &explicit, a, 0, 5); + let selection = s.doc().unwrap().selection.clone(); + assert!(s.execute("find.next", &json!({"find": "red", "story": u64::MAX})).is_err()); + assert_eq!(s.doc().unwrap().selection, selection); + scope_select(&mut s, a, 0, 9); + scope_hit(&mut s, &json!({"find": "red"}), b, 0, 3); + let counted = s.execute("find.find", &json!({"find": "red"})).unwrap(); + assert_eq!(counted[0]["story"], a.0, "Count retains canonical story order"); + assert_eq!(counted[1]["story"], b.0); + scope_select(&mut s, a, 0, 9); + scope_hit(&mut s, &json!({"find": "red", "scope": "selection"}), a, 6, 9); + assert_eq!(s.execute("find.next", &story).unwrap(), serde_json::Value::Null, "scope change uses the current caret"); + s.execute("selection.set", &json!({"ids": []})).unwrap(); + for command in ["find.find", "find.next", "find.change"] { + assert!(s.execute(command, &json!({"find": "red", "change": "blue", "scope": "selection"})).is_err()); + } + } + + #[test] + fn find_scope_substring_context_utf8_zero_width_and_invalid_ranges() { + let (mut s, sid) = scope_session("scat é🙂 red"); + scope_select(&mut s, sid, 1, 4); + let p = json!({"find": "cat", "wholeWord": true, "scope": "selection"}); + scope_hit(&mut s, &p, sid, 1, 4); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1); + scope_select(&mut s, sid, 1, 4); + let p = json!({"find": "^c|^a", "grep": true, "scope": "selection"}); + scope_hit(&mut s, &p, sid, 1, 2); + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null, "cursor does not redefine ^"); + scope_select(&mut s, sid, 5, 11); + let empty = json!({"find": "(?:)", "grep": true, "scope": "selection"}); + for pos in [5, 7, 11] { + scope_hit(&mut s, &empty, sid, pos, pos); + } + for _ in 0..2 { + assert_eq!(s.execute("find.next", &empty).unwrap(), serde_json::Value::Null); + } + assert_eq!(s.execute("find.find", &empty).unwrap().as_array().unwrap().len(), 3); + scope_select(&mut s, sid, 7, 7); + scope_hit(&mut s, &empty, sid, 7, 7); + assert_eq!(s.execute("find.next", &empty).unwrap(), serde_json::Value::Null); + scope_select(&mut s, sid, 12, 15); + let nonempty = json!({"find": "red", "change": "blue", "scope": "selection"}); + scope_hit(&mut s, &nonempty, sid, 12, 15); + assert_eq!(s.execute("find.change", &nonempty).unwrap()["count"], 1); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "scat é🙂 blue"); + for range in [6..7, 0..usize::MAX] { + let st = s.doc_mut().unwrap(); + st.selection = designcraft_doc::Selection::text(designcraft_doc::TextSel { + story: sid, + anchor: range.start, + focus: range.end, + frame: None, + cell: None, + }); + st.revision += 1; + for command in ["find.find", "find.next", "find.change"] { + let before = s.doc().unwrap().doc.clone(); + let err = s.execute(command, &json!({"find": "x", "change": "y", "scope": "selection"})).unwrap_err(); + assert!(matches!(err, crate::EngineError::BadParams { .. }), "{err}"); + assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc)); + } + } + } + + #[test] + fn find_scope_invalid_query_keeps_continuation_and_cross_boundary_nonmatches() { + let (mut s, sid) = scope_session("red red | red"); + scope_select(&mut s, sid, 0, 7); + let p = json!({"find": "red", "scope": "selection"}); + scope_hit(&mut s, &p, sid, 0, 3); + let selected = s.doc().unwrap().selection.clone(); + assert!(s.execute("find.next", &json!({"find": "[", "grep": true, "scope": "selection"})).is_err()); + assert_eq!(s.doc().unwrap().selection, selected); + scope_hit(&mut s, &p, sid, 4, 7); + assert_eq!(s.execute("find.find", &json!({"find": "red |", "scope": "selection"})).unwrap(), json!([])); + } + + #[test] + fn find_scope_document_switch_edit_and_reset() { + let (mut s, sid) = scope_session("red red | red"); + scope_select(&mut s, sid, 0, 7); + let p = json!({"find": "red", "scope": "selection"}); + scope_hit(&mut s, &p, sid, 0, 3); + s.execute("file.new", &json!({})).unwrap(); + s.set_active(0); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1); + scope_select(&mut s, sid, 0, 7); + scope_hit(&mut s, &p, sid, 0, 3); + s.execute("story.setText", &json!({"story": sid.0, "text": "red"})).unwrap(); + assert!(s.find_context.is_none()); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1); + s.reset_find_scope(); + assert!(s.find_context.is_none()); + } + + #[test] + fn find_scope_real_preview_restores_before_resolving_bounds() { + let (mut s, sid) = scope_session(""); + s.execute( + "data.source.select", + &json!({ + "bytes": crate::cmd::base64_encode(b"x\nred red | red\n"), "name": "scope.csv" + }), + ) + .unwrap(); + s.execute("data.placeholder.add", &json!({"field": "x", "story": sid.0, "at": 0})).unwrap(); + let template = s.doc().unwrap().doc.clone(); + assert_eq!(template.story(sid).unwrap().text, "<<x>>"); + s.execute("data.preview", &json!({"record": 1})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "red red | red"); + // Install a viewport selection in the real preview. text.select is journaled + // and would itself restore the template before selecting. + let st = s.doc_mut().unwrap(); + st.selection = designcraft_doc::Selection::text(designcraft_doc::TextSel { story: sid, anchor: 0, focus: 7, frame: None, cell: None }); + st.revision += 1; + let p = json!({"find": "red", "change": "blue", "scope": "selection"}); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 2); + let history = s.doc().unwrap().history.undo.len(); + let err = s.execute("find.change", &p).unwrap_err(); + assert!(matches!(err, crate::EngineError::BadParams { .. }), "{err}"); + assert!(std::sync::Arc::ptr_eq(&template, &s.doc().unwrap().doc)); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "<<x>>"); + assert_eq!(s.doc().unwrap().history.undo.len(), history); + assert!(s.doc().unwrap().preview_stash.is_none()); + assert!(s.find_context.is_none()); + // Valid fresh bounds are allowed after restoration; no cached preview range + // survives. A document query can see the preview, but Next sees the template. + s.execute("data.preview", &json!({"record": 1})).unwrap(); + let document = json!({"find": "red"}); + assert_eq!(s.execute("find.find", &document).unwrap().as_array().unwrap().len(), 3); + assert_eq!(s.execute("find.next", &document).unwrap(), serde_json::Value::Null); + assert!(std::sync::Arc::ptr_eq(&template, &s.doc().unwrap().doc)); + assert_eq!(s.execute("find.find", &document).unwrap(), json!([])); + assert_eq!(s.doc().unwrap().history.undo.len(), history); + } + + fn child_scope_session(note: bool, child: &str) -> (Session, designcraft_doc::StoryId, designcraft_doc::TextSel) { + let (mut s, sid) = scope_session("red"); + scope_select(&mut s, sid, 3, 3); + if note { + s.execute("footnote.insert", &json!({"text": child})).unwrap(); + } else { + s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap(); + s.execute("text.insert", &json!({"text": child, "raw": true})).unwrap(); + } + let selected = s.doc().unwrap().selection.text.unwrap(); + (s, sid, selected) + } + + #[test] + fn find_scope_children_error_atomically_and_parent_requests_stay_top_level() { + for note in [false, true] { + for child in ["red", "red x", "é red red red red red"] { + let (mut s, sid, mut selected) = child_scope_session(note, child); + selected.anchor = 0; + selected.focus = child.len(); + s.doc_mut().unwrap().selection = designcraft_doc::Selection::text(selected); + let before = s.doc().unwrap().doc.clone(); + let selection = s.doc().unwrap().selection.clone(); + let history = s.doc().unwrap().history.undo.len(); + let revision = s.doc().unwrap().revision; + let journal = s.journal.clone(); + for scope in ["story", "selection"] { + for command in ["find.find", "find.next", "find.change"] { + let err = s.execute(command, &json!({"find": "red", "change": "blue", "scope": scope})).unwrap_err(); + assert!(matches!(err, crate::EngineError::BadParams { .. }), "{err}"); + assert!(err.to_string().contains("not supported")); + assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc)); + assert_eq!(s.doc().unwrap().selection, selection); + assert_eq!(s.doc().unwrap().history.undo.len(), history); + assert_eq!(s.doc().unwrap().revision, revision); + assert_eq!(s.journal, journal); + } + } + for explicit in [false, true] { + let mut p = json!({"find": "red", "change": "blue"}); + if explicit { + p["story"] = json!(sid.0); + p["scope"] = json!("selection"); + } + s.doc_mut().unwrap().selection = selection.clone(); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1); + scope_hit(&mut s, &p, sid, 0, 3); + s.doc_mut().unwrap().selection = selection.clone(); + assert_eq!(s.execute("find.change", &p).unwrap()["count"], 1); + assert_eq!(s.doc().unwrap().doc.text_story(sid, selected.cell).unwrap().text, child); + assert_eq!(s.doc().unwrap().selection, selection, "parent replacement does not clamp child offsets"); + p["find"] = json!("blue"); + p["change"] = json!("red"); + s.execute("find.change", &p).unwrap(); + } + } + } + let (mut s, _, _) = child_scope_session(false, "red"); + s.execute("table.selectTable", &json!({})).unwrap(); + for scope in ["story", "selection"] { + for command in ["find.find", "find.next", "find.change"] { + assert!( + s.execute(command, &json!({"find": "red", "change": "blue", "scope": scope})).unwrap_err().to_string().contains("not supported") + ); + } + } + } + + #[test] + fn find_scope_threaded_overset_story_uses_logical_offsets() { + let text = format!("{}red", "word ".repeat(100)); + let (mut s, sid) = scope_session(&text); + let a = s.doc().unwrap().doc.story(sid).unwrap().frames[0]; + let next = s.execute("frame.create", &json!({"rect": [150, 36, 20, 1], "content": "text"})).unwrap(); + let b = designcraft_doc::ItemId(next["id"].as_u64().unwrap()); + s.edit(|d, _| Ok(d.thread(a, b)?)).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().frames.len(), 2); + scope_select(&mut s, sid, 0, 0); + scope_hit(&mut s, &json!({"find": "red", "scope": "story"}), sid, 500, 503); + assert_eq!(s.execute("find.next", &json!({"find": "red", "scope": "story"})).unwrap(), serde_json::Value::Null); + } + + #[test] + fn find_scope_zero_width_story_explicit_and_document_endpoints_terminate() { + for scope in ["story", "explicit", "document"] { + let (mut s, sid) = scope_session("é🙂"); + scope_select(&mut s, sid, 0, 0); + let mut p = json!({"find": "(?:)", "grep": true, "scope": scope}); + if scope == "explicit" { + p["story"] = json!(sid.0); + } + for pos in [0, 2, 6] { + scope_hit(&mut s, &p, sid, pos, pos); + } + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null); + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null); + } + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn find_scope_revert_releases_context_with_same_document_uid() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("scope.designcraft"); + let (mut s, sid) = scope_session("red red | red"); + s.execute("file.save", &json!({"path": path.to_string_lossy()})).unwrap(); + scope_select(&mut s, sid, 0, 7); + let p = json!({"find": "red", "scope": "selection"}); + scope_hit(&mut s, &p, sid, 0, 3); + let uid = s.doc().unwrap().uid; + s.execute("story.setText", &json!({"story": sid.0, "text": "red red"})).unwrap(); + scope_select(&mut s, sid, 0, 7); + scope_hit(&mut s, &p, sid, 0, 3); + s.execute("file.revert", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().uid, uid); + assert!(s.find_context.is_none()); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "red red | red"); + assert!(s.execute("find.find", &p).is_err(), "revert's empty selection cannot inherit a previous range"); + } } /// Objects matching Find/Change › Object criteria, in document order (nested ones included). diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 31e4544f4..a0a6ac32c 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -15,7 +15,8 @@ mod editorial; mod endnotes; mod export; mod file; -mod find; +pub(crate) mod find; +pub use find::same_find_domain; mod fonts; mod guides; mod index; diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index b57e9e88a..9c05ecfae 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -29,6 +29,8 @@ pub fn specs() -> Vec<CommandSpec> { let text = &st.doc.text_story(t.story, t.cell).map(|x| x.text.clone()).unwrap_or_default(); let (a, b) = word_bounds(text, t.focus); st.selection.text = Some(TextSel { anchor: a, focus: b, ..t }); + // A word reselection is external even when place kept the same range for dragging. + st.revision = st.revision.wrapping_add(1); ok() }), cmd!(noundo "text.selectSentence", "Select Sentence", [], None, "{frame, point} — the sentence under the point (triple click)", has_doc, |s, p| { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index caeeb80ba..3064592f0 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -260,6 +260,7 @@ pub struct Session { pub cache: Arc<Cache>, pub(crate) tool: Box<dyn designcraft_tools::Tool>, pub journal: Vec<(String, Value)>, + pub(crate) find_context: Option<cmd::find::FindContext>, pub clipboard: Option<Arc<Document>>, /// Crash-recovery folder (the app sets it; saving or closing a document removes its entry). pub recovery_dir: Option<std::path::PathBuf>, @@ -300,6 +301,7 @@ impl Session { cache: Arc::new(Cache::new()), tool: designcraft_tools::create("selection"), journal: vec![], + find_context: None, clipboard: None, recovery_dir: None, text_clipboard: None, @@ -358,13 +360,26 @@ impl Session { pub fn doc_mut(&mut self) -> Result<&mut DocState> { self.active_mut().ok_or(EngineError::NoDocument) } + /// Release the transient Find/Change domain when its caller starts or closes a search. + pub fn reset_find_scope(&mut self) { + self.find_context = None; + } + + fn discard_stale_find_scope(&mut self) { + if self.find_context.as_ref().is_some_and(|c| self.active().is_none_or(|st| !c.is_current(st))) { + self.reset_find_scope(); + } + } + pub fn set_active(&mut self, i: usize) { if i < self.docs.len() && self.active != Some(i) { + self.reset_find_scope(); self.stop_data_preview(); self.active = Some(i); } } pub fn add_document(&mut self, d: DocState) -> usize { + self.reset_find_scope(); self.stop_data_preview(); self.docs.push(d); self.active = Some(self.docs.len() - 1); @@ -388,6 +403,7 @@ impl Session { recovery::discard_document(self, i, recovery_dir.as_deref()); } if i < self.docs.len() { + self.reset_find_scope(); self.docs.remove(i); self.active = if self.docs.is_empty() { None } else { Some(i.min(self.docs.len() - 1)) }; } @@ -418,6 +434,8 @@ impl Session { if id != "data.preview" && (spec.undoable || spec.journal) { self.stop_data_preview(); } + // Preview restoration can replace the document before a Find handler resolves bounds. + self.discard_stale_find_scope(); let before = self.active().map(|d| (d.uid, d.doc.clone())); let r = (spec.run)(self, params)?; // Undo, redo or deleting layers can take the active layer away: new objects would land on @@ -455,6 +473,7 @@ impl Session { if spec.journal { self.journal.push((id.to_string(), params.clone())); } + self.discard_stale_find_scope(); Ok(r) } @@ -542,6 +561,7 @@ impl Session { st.doc = Arc::new(doc); st.selection = sel; st.revision += 1; + self.reset_find_scope(); Ok(r) } } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 5a4de0058..c66bc9272 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -288,13 +288,19 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> "ui.dialog.set" => match app.ui.dialog.as_mut() { Some(d) => { let Some(f) = s("field") else { return err("missing `field`") }; - d.fields.insert(f.to_string(), p.get("value").cloned().unwrap_or(Value::Null)); + let value = p.get("value").cloned().unwrap_or(Value::Null); + if d.set_field(f, value) { + app.session.reset_find_scope(); + } ok(serde_json::to_value(&*d).unwrap_or_default()) } None => err("no dialog open"), }, "ui.dialog.open" => { let Some(id) = s("id") else { return err("missing `id`") }; + if id == "findChange" || app.ui.dialog.as_ref().is_some_and(|d| d.id == "findChange") { + app.session.reset_find_scope(); + } let d = crate::dialogs::Dialog::new(id, p.get("fields").cloned().unwrap_or(Value::Null)); let v = serde_json::to_value(&d).unwrap_or_default(); app.ui.dialog = Some(d); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 695a66af1..396cd2500 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -77,6 +77,16 @@ impl Dialog { } Dialog { id: id.into(), fields } } + /// Set a field and report a genuine Find domain/mode transition to the caller. + pub(crate) fn set_field(&mut self, key: &str, value: Value) -> bool { + let before = + (self.id == "findChange" && matches!(key, "scope" | "story" | "objectMode")).then(|| (self.b("objectMode"), find_change_params(self))); + self.fields.insert(key.into(), value); + before.is_some_and(|(object_mode, params)| { + object_mode != self.b("objectMode") || !designcraft_engine::cmd::same_find_domain(¶ms, &find_change_params(self)) + }) + } + fn s(&self, k: &str) -> String { match self.fields.get(k) { Some(Value::String(s)) => s.clone(), @@ -1181,536 +1191,640 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { // forms remain reachable at large UI scales, without shrinking their text. let footer_height = ui.spacing().interact_size.y.max(ui.text_style_height(&egui::TextStyle::Button) + 2.0 * ui.spacing().button_padding.y); let body_height = (available.y - ui.min_size().y - footer_height - 12.0 - 2.0 * ui.spacing().item_spacing.y).max(1.0); - egui::ScrollArea::both().id_salt(("dialog_body", &d.id)).min_scrolled_width(1.0).min_scrolled_height(1.0).max_width(width).max_height(body_height).show(ui, |ui| { - match d.id.as_str() { - "newDocument" => { - ui.horizontal(|ui| { - ui.with_layout(if crate::i18n::is_rtl(&app.ui.language) { egui::Layout::right_to_left(egui::Align::Center) } else { egui::Layout::left_to_right(egui::Align::Center) }, |ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Preset")); - let cur = d.s("preset"); - egui::ComboBox::from_id_salt("preset").selected_text(&cur).width(180.0).show_ui(ui, |ui| { - for p in designcraft_doc::build::PRESETS { - if ui.selectable_label(cur == p.name, p.name).clicked() { - d.fields.insert("preset".into(), json!(p.name)); - d.fields.insert("width".into(), json!(format_measure(p.width, p.units))); - d.fields.insert("height".into(), json!(format_measure(p.height, p.units))); - d.fields.insert("facingPages".into(), json!(p.intent == designcraft_doc::Intent::Print)); - } - } + egui::ScrollArea::both() + .id_salt(("dialog_body", &d.id)) + .min_scrolled_width(1.0) + .min_scrolled_height(1.0) + .max_width(width) + .max_height(body_height) + .show(ui, |ui| match d.id.as_str() { + "newDocument" => { + ui.horizontal(|ui| { + ui.with_layout( + if crate::i18n::is_rtl(&app.ui.language) { + egui::Layout::right_to_left(egui::Align::Center) + } else { + egui::Layout::left_to_right(egui::Align::Center) + }, + |ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Preset")); + let cur = d.s("preset"); + egui::ComboBox::from_id_salt("preset").selected_text(&cur).width(180.0).show_ui(ui, |ui| { + for p in designcraft_doc::build::PRESETS { + if ui.selectable_label(cur == p.name, p.name).clicked() { + d.fields.insert("preset".into(), json!(p.name)); + d.fields.insert("width".into(), json!(format_measure(p.width, p.units))); + d.fields.insert("height".into(), json!(format_measure(p.height, p.units))); + d.fields.insert("facingPages".into(), json!(p.intent == designcraft_doc::Intent::Print)); + } + } + }); + }, + ); }); - }); - }); - if crate::i18n::is_rtl(&app.ui.language) { - // egui Grid currently supports LTR only. Use explicit RTL rows; - // the numeric editors retain LTR alignment and logical values. - for (first, key, second, other, toggle) in [ - ("Width", "width", "Height", "height", false), - ("Pages", "pages", "Facing Pages", "facingPages", true), - ("Columns", "columns", "Gutter", "gutter", false), - ("Top", "marginTop", "Bottom", "marginBottom", false), - ("Inside", "marginInside", "Outside", "marginOutside", false), - ("Bleed", "bleed", "Primary Text Frame", "primaryTextFrame", true), - ] { - ui.horizontal(|ui| { - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, first)); - text_field(ui, &mut d, key, 80.0); - if toggle { - check(ui, &mut d, other, crate::i18n::tr(&app.ui.language, second)); - } else { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, second)); - text_field(ui, &mut d, other, 80.0); - } + if crate::i18n::is_rtl(&app.ui.language) { + // egui Grid currently supports LTR only. Use explicit RTL rows; + // the numeric editors retain LTR alignment and logical values. + for (first, key, second, other, toggle) in [ + ("Width", "width", "Height", "height", false), + ("Pages", "pages", "Facing Pages", "facingPages", true), + ("Columns", "columns", "Gutter", "gutter", false), + ("Top", "marginTop", "Bottom", "marginBottom", false), + ("Inside", "marginInside", "Outside", "marginOutside", false), + ("Bleed", "bleed", "Primary Text Frame", "primaryTextFrame", true), + ] { + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, first)); + text_field(ui, &mut d, key, 80.0); + if toggle { + check(ui, &mut d, other, crate::i18n::tr(&app.ui.language, second)); + } else { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, second)); + text_field(ui, &mut d, other, 80.0); + } + }); }); + } + } else { + egui::Grid::new("nd").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Width")); + text_field(ui, &mut d, "width", 80.0); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Height")); + text_field(ui, &mut d, "height", 80.0); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Pages")); + text_field(ui, &mut d, "pages", 80.0); + ui.label(""); + check(ui, &mut d, "facingPages", crate::i18n::tr(&app.ui.language, "Facing Pages")); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Columns")); + text_field(ui, &mut d, "columns", 80.0); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Gutter")); + text_field(ui, &mut d, "gutter", 80.0); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Top")); + text_field(ui, &mut d, "marginTop", 80.0); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Bottom")); + text_field(ui, &mut d, "marginBottom", 80.0); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Inside")); + text_field(ui, &mut d, "marginInside", 80.0); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Outside")); + text_field(ui, &mut d, "marginOutside", 80.0); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Bleed")); + text_field(ui, &mut d, "bleed", 80.0); + ui.label(""); + check(ui, &mut d, "primaryTextFrame", crate::i18n::tr(&app.ui.language, "Primary Text Frame")); + ui.end_row(); }); } - } else { - egui::Grid::new("nd").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Width")); - text_field(ui, &mut d, "width", 80.0); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Height")); - text_field(ui, &mut d, "height", 80.0); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Pages")); - text_field(ui, &mut d, "pages", 80.0); - ui.label(""); - check(ui, &mut d, "facingPages", crate::i18n::tr(&app.ui.language, "Facing Pages")); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Columns")); - text_field(ui, &mut d, "columns", 80.0); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Gutter")); - text_field(ui, &mut d, "gutter", 80.0); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Top")); - text_field(ui, &mut d, "marginTop", 80.0); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Bottom")); - text_field(ui, &mut d, "marginBottom", 80.0); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Inside")); - text_field(ui, &mut d, "marginInside", 80.0); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Outside")); - text_field(ui, &mut d, "marginOutside", 80.0); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Bleed")); - text_field(ui, &mut d, "bleed", 80.0); - ui.label(""); - check(ui, &mut d, "primaryTextFrame", crate::i18n::tr(&app.ui.language, "Primary Text Frame")); - ui.end_row(); - }); } - } - "frameSize" => { - egui::Grid::new("fs").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Width")); - text_field(ui, &mut d, "width", 90.0); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Height")); - text_field(ui, &mut d, "height", 90.0); - ui.end_row(); - }); - } - "findChange" => { - ui.horizontal(|ui| { - for (label, grep) in [("Text", false), ("GREP", true)] { - if ui.selectable_label(!d.b("objectMode") && d.b("grep") == grep, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() { - d.fields.insert("grep".into(), json!(grep)); - d.fields.insert("objectMode".into(), json!(false)); + "frameSize" => { + egui::Grid::new("fs").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Width")); + text_field(ui, &mut d, "width", 90.0); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Height")); + text_field(ui, &mut d, "height", 90.0); + ui.end_row(); + }); + } + "findChange" => { + ui.horizontal(|ui| { + for (label, grep) in [("Text", false), ("GREP", true)] { + if ui + .selectable_label( + !d.b("objectMode") && d.b("grep") == grep, + crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label)), + ) + .clicked() + { + if d.set_field("objectMode", json!(false)) { + app.session.reset_find_scope(); + } + d.fields.insert("grep".into(), json!(grep)); + } + } + if ui.selectable_label(d.b("objectMode"), crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Object"))).clicked() + && d.set_field("objectMode", json!(true)) + { + app.session.reset_find_scope(); + } + }); + if d.b("objectMode") { + // Find/Change › Object: objects by fill and kind; change their fill or opacity. + let swatches: Vec<String> = app + .session + .active() + .map(|st| st.doc.swatches.iter().filter(|w| !w.hidden).map(|w| w.name.clone()).collect()) + .unwrap_or_default(); + egui::Grid::new("fco").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + for (key, label, any) in [("objFill", "Find fill:", "(any)"), ("objChangeFill", "Change fill to:", "(unchanged)")] { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, label)); + let cur = d.s(key); + egui::ComboBox::from_id_salt(key) + .selected_text(if cur.is_empty() { any } else { cur.as_str() }) + .width(180.0) + .show_ui(ui, |ui| { + if ui + .selectable_label(cur.is_empty(), crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, any))) + .clicked() + { + d.fields.insert(key.into(), json!("")); + } + for w in &swatches { + if ui.selectable_label(*w == cur, w).clicked() { + d.fields.insert(key.into(), json!(w)); + } + } + }); + ui.end_row(); + } + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Kind:")); + let cur = d.s("objKind"); + egui::ComboBox::from_id_salt("objKind").selected_text(if cur.is_empty() { "(any)" } else { cur.as_str() }).show_ui( + ui, + |ui| { + for v in ["", "text", "graphic", "shape", "line", "group"] { + if ui + .selectable_label( + cur == v, + crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, if v.is_empty() { "(any)" } else { v })), + ) + .clicked() + { + d.fields.insert("objKind".into(), json!(v)); + } + } + }, + ); + ui.end_row(); + }); + let mut crit = json!({}); + if !d.s("objFill").is_empty() { + crit["fill"] = json!(d.s("objFill")); + } + if !d.s("objKind").is_empty() { + crit["kind"] = json!(d.s("objKind")); + } + ui.horizontal(|ui| { + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Find All"))).clicked() { + let n = app.run("find.objects", crit.clone()).ok().and_then(|r| r["ids"].as_array().map(Vec::len)).unwrap_or(0); + d.fields.insert("status".into(), json!(format!("{n} found"))); + } + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Change All"))).clicked() + && !d.s("objChangeFill").is_empty() + { + let mut p = crit.clone(); + p["change"] = json!({"fill": d.s("objChangeFill")}); + let n = app.run("find.changeObjects", p).ok().and_then(|r| r["changed"].as_u64()).unwrap_or(0); + d.fields.insert("status".into(), json!(format!("{n} changed"))); + } + }); + ui.label(d.s("status")); + } else { + egui::Grid::new("fc").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Find what:")); + text_field(ui, &mut d, "find", 260.0); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Change to:")); + text_field(ui, &mut d, "change", 260.0); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Search:")); + let cur = d.s("scope"); + egui::ComboBox::from_id_salt("fcscope") + .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, &cur))) + .show_ui(ui, |ui| { + for v in ["document", "story", "selection"] { + if ui.selectable_label(cur == v, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, v))).clicked() + && d.set_field("scope", json!(v)) + { + app.session.reset_find_scope(); + } + } + }); + ui.end_row(); + }); + ui.horizontal(|ui| { + check(ui, &mut d, "caseSensitive", crate::i18n::tr(&app.ui.language, "Case sensitive")); + check(ui, &mut d, "wholeWord", crate::i18n::tr(&app.ui.language, "Whole word")); + }); + ui.horizontal(|ui| { + let params0 = find_change_params(&d); + let params = || params0.clone(); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Find Next"))).clicked() { + let r = app.run("find.next", params()); + let msg = match r { + Ok(Value::Null) => "No matches".to_string(), + Ok(_) => "Found".to_string(), + Err(e) => e, + }; + d.fields.insert("status".into(), json!(msg)); + } + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Change All"))).clicked() { + let msg = match app.run("find.change", params()) { + Ok(v) => format!("{} replacement(s) made", v["count"]), + Err(e) => e, + }; + d.fields.insert("status".into(), json!(msg)); + } + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Count"))).clicked() { + let msg = match app.run("find.find", params()) { + Ok(v) => format!("{} match(es)", v.as_array().map(|a| a.len()).unwrap_or(0)), + Err(e) => e, + }; + d.fields.insert("status".into(), json!(msg)); + } + }); + if let Some(st) = d.fields.get("status").and_then(Value::as_str) { + ui.label(egui::RichText::new(st).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); } } - if ui.selectable_label(d.b("objectMode"),crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Object"))).clicked() { - d.fields.insert("objectMode".into(), json!(true)); + } + "paragraphStyleOptions" => paragraph_style_options(app, ui, &mut d), + "characterStyleOptions" => character_style_options(app, ui, &mut d), + "footnoteOptions" => footnote_options(app, ui, &mut d), + "paragraphRules" => { + let current = d.fields.get("current").cloned().unwrap_or_default(); + paragraph_rules(app, ui, &mut d, "", ¤t); + } + "insertXref" => insert_xref(app, ui, &mut d), + "findFont" => find_font(app, ui, &mut d), + "colorPicker" => color_picker(ui, &mut d), + "preferences" => preferences(app, ui, &mut d, body_height), + "print" => print_dialog(app, ui, &mut d), + "pdfImport" => { + ui.label(egui::RichText::new(if d.b("async") { d.s("name") } else { d.s("path") }).size(11.0)); + ui.horizontal(|ui| { + crate::rtl::label( + ui, + crate::i18n::tr(&app.ui.language, "Page (1–{count}):").replace("{count}", &d.n("pages").unwrap_or(1.0).to_string()), + ); + text_field(ui, &mut d, "page", 60.0); + }); + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Crop to:")); + let cur = d.s("crop"); + let label = |v: &str| match v { + "trim" => crate::i18n::tr(&app.ui.language, "Trim"), + "bleed" => crate::i18n::tr(&app.ui.language, "Bleed"), + "art" => crate::i18n::tr(&app.ui.language, "Art"), + "media" => crate::i18n::tr(&app.ui.language, "Media"), + _ => crate::i18n::tr(&app.ui.language, "Crop"), + }; + egui::ComboBox::from_id_salt("pdf_crop").selected_text(crate::rtl::widget(ui, label(&cur))).show_ui(ui, |ui| { + for v in ["crop", "trim", "bleed", "art", "media"] { + if ui.selectable_label(label(&cur) == label(v), label(v)).clicked() { + d.fields.insert("crop".into(), json!(v)); + } + } + }); + }); + } + "layerOptions" => { + let mut layers = d.fields.get("layers").and_then(Value::as_array).cloned().unwrap_or_default(); + if layers.is_empty() { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "This PDF has no layers.")); } - }); - if d.b("objectMode") { - // Find/Change › Object: objects by fill and kind; change their fill or opacity. - let swatches: Vec<String> = app.session.active().map(|st| st.doc.swatches.iter().filter(|w| !w.hidden).map(|w| w.name.clone()).collect()).unwrap_or_default(); - egui::Grid::new("fco").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - for (key, label, any) in [("objFill", "Find fill:", "(any)"), ("objChangeFill", "Change fill to:", "(unchanged)")] { + for l in &mut layers { + let mut on = l["visible"].as_bool().unwrap_or(true); + if ui.checkbox(&mut on, l["name"].as_str().unwrap_or("")).changed() { + l["visible"] = json!(on); + } + } + d.fields.insert("layers".into(), json!(layers)); + } + "colorSettings" => { + let profiles = d.fields.get("profiles").and_then(Value::as_array).cloned().unwrap_or_default(); + egui::Grid::new("color_settings").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + for (key, label, kind) in [("rgb", "RGB:", "rgb"), ("cmyk", "CMYK:", "cmyk")] { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, label)); let cur = d.s(key); - egui::ComboBox::from_id_salt(key).selected_text(if cur.is_empty() { any } else { cur.as_str() }).width(180.0).show_ui(ui, |ui| { - if ui.selectable_label(cur.is_empty(), crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, any))).clicked() { - d.fields.insert(key.into(), json!("")); - } - for w in &swatches { - if ui.selectable_label(*w == cur, w).clicked() { - d.fields.insert(key.into(), json!(w)); + egui::ComboBox::from_id_salt(("ws", key)).selected_text(&cur).width(260.0).show_ui(ui, |ui| { + for pr in profiles.iter().filter(|p| p["kind"] == kind) { + let name = pr["name"].as_str().unwrap_or(""); + if ui.selectable_label(name == cur, name).clicked() { + d.fields.insert(key.into(), json!(name)); } } }); ui.end_row(); } - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Kind:")); - let cur = d.s("objKind"); - egui::ComboBox::from_id_salt("objKind").selected_text(if cur.is_empty() { "(any)" } else { cur.as_str() }).show_ui(ui, |ui| { - for v in ["", "text", "graphic", "shape", "line", "group"] { - if ui.selectable_label(cur == v, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, if v.is_empty() { "(any)" } else { v }))).clicked() { - d.fields.insert("objKind".into(), json!(v)); - } - } - }); - ui.end_row(); - }); - let mut crit = json!({}); - if !d.s("objFill").is_empty() { - crit["fill"] = json!(d.s("objFill")); - } - if !d.s("objKind").is_empty() { - crit["kind"] = json!(d.s("objKind")); - } - ui.horizontal(|ui| { - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Find All"))).clicked() { - let n = app.run("find.objects", crit.clone()).ok().and_then(|r| r["ids"].as_array().map(Vec::len)).unwrap_or(0); - d.fields.insert("status".into(), json!(format!("{n} found"))); - } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Change All"))).clicked() && !d.s("objChangeFill").is_empty() { - let mut p = crit.clone(); - p["change"] = json!({"fill": d.s("objChangeFill")}); - let n = app.run("find.changeObjects", p).ok().and_then(|r| r["changed"].as_u64()).unwrap_or(0); - d.fields.insert("status".into(), json!(format!("{n} changed"))); - } - }); - ui.label(d.s("status")); - } else { - egui::Grid::new("fc").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Find what:")); - text_field(ui, &mut d, "find", 260.0); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Change to:")); - text_field(ui, &mut d, "change", 260.0); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Search:")); - let cur = d.s("scope"); - egui::ComboBox::from_id_salt("fcscope").selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, &cur))).show_ui(ui, |ui| { - for v in ["document", "story", "selection"] { - if ui.selectable_label(cur == v, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, v))).clicked() { - d.fields.insert("scope".into(), json!(v)); - } - } - }); - ui.end_row(); - }); - ui.horizontal(|ui| { - check(ui, &mut d, "caseSensitive", crate::i18n::tr(&app.ui.language, "Case sensitive")); - check(ui, &mut d, "wholeWord", crate::i18n::tr(&app.ui.language, "Whole word")); - }); - ui.horizontal(|ui| { - let params0 = json!({"find": d.s("find"), "change": d.s("change"), "grep": d.b("grep"), "caseSensitive": d.b("caseSensitive"), "wholeWord": d.b("wholeWord"), "scope": d.s("scope")}); - let params = || params0.clone(); - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Find Next"))).clicked() { - let r = app.run("find.next", params()); - let msg = match r { - Ok(Value::Null) => "No matches".to_string(), - Ok(_) => "Found".to_string(), - Err(e) => e, - }; - d.fields.insert("status".into(), json!(msg)); - } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Change All"))).clicked() { - let msg = match app.run("find.change", params()) { - Ok(v) => format!("{} replacement(s) made", v["count"]), - Err(e) => e, - }; - d.fields.insert("status".into(), json!(msg)); - } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Count"))).clicked() { - let msg = match app.run("find.find", params()) { - Ok(v) => format!("{} match(es)", v.as_array().map(|a| a.len()).unwrap_or(0)), - Err(e) => e, - }; - d.fields.insert("status".into(), json!(msg)); - } - }); - if let Some(st) = d.fields.get("status").and_then(Value::as_str) { - ui.label(egui::RichText::new(st).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); - } - } - } - "paragraphStyleOptions" => paragraph_style_options(app, ui, &mut d), - "characterStyleOptions" => character_style_options(app, ui, &mut d), - "footnoteOptions" => footnote_options(app, ui, &mut d), - "paragraphRules" => { - let current = d.fields.get("current").cloned().unwrap_or_default(); - paragraph_rules(app, ui, &mut d, "", ¤t); - } - "insertXref" => insert_xref(app, ui, &mut d), - "findFont" => find_font(app, ui, &mut d), - "colorPicker" => color_picker(ui, &mut d), - "preferences" => preferences(app, ui, &mut d, body_height), - "print" => print_dialog(app, ui, &mut d), - "pdfImport" => { - ui.label(egui::RichText::new(if d.b("async") { d.s("name") } else { d.s("path") }).size(11.0)); - ui.horizontal(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Page (1–{count}):").replace("{count}", &d.n("pages").unwrap_or(1.0).to_string())); - text_field(ui, &mut d, "page", 60.0); - }); - ui.horizontal(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Crop to:")); - let cur = d.s("crop"); - let label = |v: &str| match v { - "trim" => crate::i18n::tr(&app.ui.language, "Trim"), - "bleed" => crate::i18n::tr(&app.ui.language, "Bleed"), - "art" => crate::i18n::tr(&app.ui.language, "Art"), - "media" => crate::i18n::tr(&app.ui.language, "Media"), - _ => crate::i18n::tr(&app.ui.language, "Crop"), - }; - egui::ComboBox::from_id_salt("pdf_crop").selected_text(crate::rtl::widget(ui, label(&cur))).show_ui(ui, |ui| { - for v in ["crop", "trim", "bleed", "art", "media"] { - if ui.selectable_label(label(&cur) == label(v), label(v)).clicked() { - d.fields.insert("crop".into(), json!(v)); - } - } - }); - }); - } - "layerOptions" => { - let mut layers = d.fields.get("layers").and_then(Value::as_array).cloned().unwrap_or_default(); - if layers.is_empty() { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "This PDF has no layers.")); - } - for l in &mut layers { - let mut on = l["visible"].as_bool().unwrap_or(true); - if ui.checkbox(&mut on, l["name"].as_str().unwrap_or("")).changed() { - l["visible"] = json!(on); - } - } - d.fields.insert("layers".into(), json!(layers)); - } - "colorSettings" => { - let profiles = d.fields.get("profiles").and_then(Value::as_array).cloned().unwrap_or_default(); - egui::Grid::new("color_settings").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - for (key, label, kind) in [("rgb", "RGB:", "rgb"), ("cmyk", "CMYK:", "cmyk")] { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, label)); - let cur = d.s(key); - egui::ComboBox::from_id_salt(("ws", key)).selected_text(&cur).width(260.0).show_ui(ui, |ui| { - for pr in profiles.iter().filter(|p| p["kind"] == kind) { - let name = pr["name"].as_str().unwrap_or(""); - if ui.selectable_label(name == cur, name).clicked() { - d.fields.insert(key.into(), json!(name)); - } - } - }); - ui.end_row(); - } - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Intent:")); - let cur = d.s("intent"); - egui::ComboBox::from_id_salt("ws_intent") - .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, designcraft_color::cms::Intent::parse(&cur).map_or("", |i| i.label())))) - .show_ui(ui, |ui| { - for i in designcraft_color::cms::Intent::ALL { - if ui.selectable_label(i.id() == cur, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, i.label()))).clicked() { - d.fields.insert("intent".into(), json!(i.id())); - } - } - }); - ui.end_row(); - }); - check(ui, &mut d, "bpc", crate::i18n::tr(&app.ui.language, "Use Black Point Compensation")); - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Load Profile…"))).clicked() - && let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("icc")) - { - match app.session.execute("color.loadProfile", &json!({"path": path})) { - Ok(_) => { - let f = app.session.execute("color.settings", &json!({})).unwrap_or_default(); - d.fields.insert("profiles".into(), f["profiles"].clone()); - } - Err(e) => app.status(format!("Color Settings: {e}")), - } - } - } - "keyboardShortcuts" => keyboard_shortcuts(app, ui, &mut d), - "userDictionary" => user_dictionary(app, ui, &mut d), - "menus" => { - text_field(ui, &mut d, "query", 260.0); - let q = d.s("query").to_lowercase(); - egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { - for (menu, label) in crate::menus::all_menu_items().into_iter().filter(|(m, l)| q.is_empty() || m.to_lowercase().contains(&q) || l.to_lowercase().contains(&q)) { - let key = crate::menus::menu_key(&menu, &label); - let mut visible = !app.ui.hidden_menu_items.contains(&key); - if ui.checkbox(&mut visible, crate::rtl::widget(ui, format!("{} › {}", crate::i18n::tr(&app.ui.language, &menu), crate::i18n::tr(&app.ui.language, &label)))).changed() { - let _ = app.run("window.hideMenuItem", json!({"item": key, "hidden": !visible})); - } - } - }); - } - "importOptions" => { - ui.label(egui::RichText::new(d.s("path")).size(10.5)); - check(ui, &mut d, "removeStyles", crate::i18n::tr(&app.ui.language, "Remove Styles and Formatting from Text and Tables")); - if !d.b("removeStyles") { - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Style Name Conflicts")).font(semibold(12.0))); - let conflicts: Vec<String> = d.fields.get("conflicts").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default(); - ui.label(if conflicts.is_empty() { crate::i18n::tr(&app.ui.language, "No conflicts").to_string() } else { format!("{} conflict(s): {}", conflicts.len(), conflicts.join(", ")) }); - let cur = d.s("styleConflicts"); - ui.horizontal(|ui| { - for (v, l) in [("useExisting", "Use Document Style Definition"), ("redefine", "Redefine Document Style"), ("autoRename", "Auto Rename")] { - if ui.radio(cur == v, l).clicked() { - d.fields.insert("styleConflicts".into(), json!(v)); - } - } - }); - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Style Mapping")).font(semibold(12.0))); - let names: Vec<String> = app.session.active().map(|s| s.doc.styles.paragraph.iter().map(|p| p.name.clone()).collect()).unwrap_or_default(); - let imported: Vec<String> = d.fields.get("styles").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default(); - egui::ScrollArea::vertical().max_height(180.0).show(ui, |ui| { - egui::Grid::new("imp_map").num_columns(2).spacing([8.0, 4.0]).show(ui, |ui| { - for w in &imported { - ui.label(w); - let mapped = d.fields.get("map").and_then(|m| m.get(w)).and_then(Value::as_str).unwrap_or("").to_string(); - let shown = if mapped.is_empty() { "(import)".to_string() } else { mapped.clone() }; - egui::ComboBox::from_id_salt(("imp_map", w)).selected_text(crate::rtl::widget(ui, &shown)).width(170.0).show_ui(ui, |ui| { - if ui.selectable_label(mapped.is_empty(),crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "(import)"))).clicked() - && let Some(m) = d.fields.get_mut("map").and_then(Value::as_object_mut) + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Intent:")); + let cur = d.s("intent"); + egui::ComboBox::from_id_salt("ws_intent") + .selected_text(crate::rtl::widget( + ui, + crate::i18n::tr(&app.ui.language, designcraft_color::cms::Intent::parse(&cur).map_or("", |i| i.label())), + )) + .show_ui(ui, |ui| { + for i in designcraft_color::cms::Intent::ALL { + if ui + .selectable_label(i.id() == cur, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, i.label()))) + .clicked() { - m.remove(w); + d.fields.insert("intent".into(), json!(i.id())); } - for n in &names { - if ui.selectable_label(*n == mapped, n).clicked() - && let Some(m) = d.fields.get_mut("map").and_then(Value::as_object_mut) - { - m.insert(w.clone(), json!(n)); - } + } + }); + ui.end_row(); + }); + check(ui, &mut d, "bpc", crate::i18n::tr(&app.ui.language, "Use Black Point Compensation")); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Load Profile…"))).clicked() + && let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("icc")) + { + match app.session.execute("color.loadProfile", &json!({"path": path})) { + Ok(_) => { + let f = app.session.execute("color.settings", &json!({})).unwrap_or_default(); + d.fields.insert("profiles".into(), f["profiles"].clone()); + } + Err(e) => app.status(format!("Color Settings: {e}")), + } + } + } + "keyboardShortcuts" => keyboard_shortcuts(app, ui, &mut d), + "userDictionary" => user_dictionary(app, ui, &mut d), + "menus" => { + text_field(ui, &mut d, "query", 260.0); + let q = d.s("query").to_lowercase(); + egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { + for (menu, label) in crate::menus::all_menu_items() + .into_iter() + .filter(|(m, l)| q.is_empty() || m.to_lowercase().contains(&q) || l.to_lowercase().contains(&q)) + { + let key = crate::menus::menu_key(&menu, &label); + let mut visible = !app.ui.hidden_menu_items.contains(&key); + if ui + .checkbox( + &mut visible, + crate::rtl::widget( + ui, + format!("{} › {}", crate::i18n::tr(&app.ui.language, &menu), crate::i18n::tr(&app.ui.language, &label)), + ), + ) + .changed() + { + let _ = app.run("window.hideMenuItem", json!({"item": key, "hidden": !visible})); + } + } + }); + } + "importOptions" => { + ui.label(egui::RichText::new(d.s("path")).size(10.5)); + check(ui, &mut d, "removeStyles", crate::i18n::tr(&app.ui.language, "Remove Styles and Formatting from Text and Tables")); + if !d.b("removeStyles") { + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Style Name Conflicts")).font(semibold(12.0))); + let conflicts: Vec<String> = d + .fields + .get("conflicts") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()) + .unwrap_or_default(); + ui.label(if conflicts.is_empty() { + crate::i18n::tr(&app.ui.language, "No conflicts").to_string() + } else { + format!("{} conflict(s): {}", conflicts.len(), conflicts.join(", ")) + }); + let cur = d.s("styleConflicts"); + ui.horizontal(|ui| { + for (v, l) in [ + ("useExisting", "Use Document Style Definition"), + ("redefine", "Redefine Document Style"), + ("autoRename", "Auto Rename"), + ] { + if ui.radio(cur == v, l).clicked() { + d.fields.insert("styleConflicts".into(), json!(v)); + } + } + }); + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Style Mapping")).font(semibold(12.0))); + let names: Vec<String> = + app.session.active().map(|s| s.doc.styles.paragraph.iter().map(|p| p.name.clone()).collect()).unwrap_or_default(); + let imported: Vec<String> = d + .fields + .get("styles") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()) + .unwrap_or_default(); + egui::ScrollArea::vertical().max_height(180.0).show(ui, |ui| { + egui::Grid::new("imp_map").num_columns(2).spacing([8.0, 4.0]).show(ui, |ui| { + for w in &imported { + ui.label(w); + let mapped = d.fields.get("map").and_then(|m| m.get(w)).and_then(Value::as_str).unwrap_or("").to_string(); + let shown = if mapped.is_empty() { "(import)".to_string() } else { mapped.clone() }; + egui::ComboBox::from_id_salt(("imp_map", w)).selected_text(crate::rtl::widget(ui, &shown)).width(170.0).show_ui( + ui, + |ui| { + if ui + .selectable_label( + mapped.is_empty(), + crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "(import)")), + ) + .clicked() + && let Some(m) = d.fields.get_mut("map").and_then(Value::as_object_mut) + { + m.remove(w); + } + for n in &names { + if ui.selectable_label(*n == mapped, n).clicked() + && let Some(m) = d.fields.get_mut("map").and_then(Value::as_object_mut) + { + m.insert(w.clone(), json!(n)); + } + } + }, + ); + ui.end_row(); + } + }); + }); + } + } + "closeDocument" => { + let text = crate::i18n::tr(&app.ui.language, "“{}” has changes that are not saved. Save them before closing?") + .replace("{}", &d.s("title")); + crate::rtl::label(ui, text); + } + "newWorkspace" => { + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Name:")); + text_field(ui, &mut d, "name", 220.0); + }); + crate::rtl::label( + ui, + egui::RichText::new(crate::i18n::tr(&app.ui.language, "Saves which bars show and where the panels are.")).size(10.5), + ); + } + "fittingOptions" => { + check(ui, &mut d, "autoFit", crate::i18n::tr(&app.ui.language, "Auto-Fit")); + ui.add_space(4.0); + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Content Fitting")).font(semibold(12.0))); + let cur = d.s("fitting"); + let label = |v: &str| match v { + "fillProportionally" => crate::i18n::tr(&app.ui.language, "Fill Frame Proportionally"), + "fitProportionally" => crate::i18n::tr(&app.ui.language, "Fit Content Proportionally"), + "fitContentToFrame" => crate::i18n::tr(&app.ui.language, "Fit Content to Frame"), + "centerContent" => crate::i18n::tr(&app.ui.language, "Center Content"), + _ => crate::i18n::tr(&app.ui.language, "None"), + }; + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Fitting:")); + egui::ComboBox::from_id_salt("ff_fit").selected_text(crate::rtl::widget(ui, label(&cur))).width(200.0).show_ui(ui, |ui| { + for v in ["none", "fillProportionally", "fitProportionally", "fitContentToFrame", "centerContent"] { + if ui.selectable_label(cur == v, crate::rtl::widget(ui, label(v))).clicked() { + d.fields.insert("fitting".into(), json!(v)); + } + } + }); + }); + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Align From:")); + let a = d.n("align").unwrap_or(4.0) as u8; + egui::Grid::new("ff_align").spacing([2.0, 2.0]).show(ui, |ui| { + for row in 0..3u8 { + for col in 0..3u8 { + let i = row * 3 + col; + let (r, resp) = ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::click()); + let c = ui.visuals().text_color(); + if a == i { + ui.painter().rect_filled(r.shrink(2.0), 1.0, c); + } else { + ui.painter().rect_stroke(r.shrink(3.0), 1.0, egui::Stroke::new(1.0, c), egui::StrokeKind::Inside); } - }); + if resp.clicked() { + d.fields.insert("align".into(), json!(i)); + } + } ui.end_row(); } }); }); - } - } - "closeDocument" => { - let text = crate::i18n::tr(&app.ui.language, "“{}” has changes that are not saved. Save them before closing?").replace("{}", &d.s("title")); - crate::rtl::label(ui, text); - } - "newWorkspace" => { - ui.horizontal(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Name:")); - text_field(ui, &mut d, "name", 220.0); - }); - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Saves which bars show and where the panels are.")).size(10.5)); - } - "fittingOptions" => { - check(ui, &mut d, "autoFit", crate::i18n::tr(&app.ui.language, "Auto-Fit")); - ui.add_space(4.0); - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Content Fitting")).font(semibold(12.0))); - let cur = d.s("fitting"); - let label = |v: &str| match v { - "fillProportionally" => crate::i18n::tr(&app.ui.language, "Fill Frame Proportionally"), - "fitProportionally" => crate::i18n::tr(&app.ui.language, "Fit Content Proportionally"), - "fitContentToFrame" => crate::i18n::tr(&app.ui.language, "Fit Content to Frame"), - "centerContent" => crate::i18n::tr(&app.ui.language, "Center Content"), - _ => crate::i18n::tr(&app.ui.language, "None"), - }; - ui.horizontal(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Fitting:")); - egui::ComboBox::from_id_salt("ff_fit").selected_text(crate::rtl::widget(ui, label(&cur))).width(200.0).show_ui(ui, |ui| { - for v in ["none", "fillProportionally", "fitProportionally", "fitContentToFrame", "centerContent"] { - if ui.selectable_label(cur == v, crate::rtl::widget(ui, label(v))).clicked() { - d.fields.insert("fitting".into(), json!(v)); - } + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Crop Amount")).font(semibold(12.0))); + egui::Grid::new("ff_crop").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { + for (k, l) in [("cropTop", "Top:"), ("cropLeft", "Left:")] { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, l)); + text_field(ui, &mut d, k, 70.0); } + ui.end_row(); + for (k, l) in [("cropBottom", "Bottom:"), ("cropRight", "Right:")] { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, l)); + text_field(ui, &mut d, k, 70.0); + } + ui.end_row(); }); - }); - ui.horizontal(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Align From:")); - let a = d.n("align").unwrap_or(4.0) as u8; - egui::Grid::new("ff_align").spacing([2.0, 2.0]).show(ui, |ui| { - for row in 0..3u8 { - for col in 0..3u8 { - let i = row * 3 + col; - let (r, resp) = ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::click()); - let c = ui.visuals().text_color(); - if a == i { - ui.painter().rect_filled(r.shrink(2.0), 1.0, c); - } else { - ui.painter().rect_stroke(r.shrink(3.0), 1.0, egui::Stroke::new(1.0, c), egui::StrokeKind::Inside); - } - if resp.clicked() { - d.fields.insert("align".into(), json!(i)); + } + "qrCode" => { + let cur = d.s("type"); + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Type:")); + let label = |v: &str| match v { + "text" => crate::i18n::tr(&app.ui.language, "Plain Text"), + "sms" => crate::i18n::tr(&app.ui.language, "Text Message"), + "email" => crate::i18n::tr(&app.ui.language, "Email"), + "vcard" => crate::i18n::tr(&app.ui.language, "Business Card"), + _ => crate::i18n::tr(&app.ui.language, "Web Hyperlink"), + }; + egui::ComboBox::from_id_salt("qr_type").selected_text(crate::rtl::widget(ui, label(&cur))).width(160.0).show_ui(ui, |ui| { + for v in ["url", "text", "sms", "email", "vcard"] { + if ui.selectable_label(cur == v, crate::rtl::widget(ui, label(v))).clicked() { + d.fields.insert("type".into(), json!(v)); } } + }); + }); + let fields: &[(&str, &str)] = match cur.as_str() { + "sms" => &[("number", "Cell Number:"), ("message", "Message:")], + "email" => &[("to", "Email Address:"), ("subject", "Subject:"), ("body", "Message:")], + "vcard" => &[("name", "Name:"), ("org", "Organization:"), ("phone", "Phone:"), ("email", "Email:"), ("url", "URL:")], + "text" => &[("content", "Text:")], + _ => &[("content", "URL:")], + }; + egui::Grid::new("qr_fields").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + for (k, l) in fields { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, l)); + text_field(ui, &mut d, k, 260.0); ui.end_row(); } - }); - }); - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Crop Amount")).font(semibold(12.0))); - egui::Grid::new("ff_crop").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { - for (k, l) in [("cropTop", "Top:"), ("cropLeft", "Left:")] { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, l)); - text_field(ui, &mut d, k, 70.0); - } - ui.end_row(); - for (k, l) in [("cropBottom", "Bottom:"), ("cropRight", "Right:")] { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, l)); - text_field(ui, &mut d, k, 70.0); - } - ui.end_row(); - }); - } - "qrCode" => { - let cur = d.s("type"); - ui.horizontal(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Type:")); - let label = |v: &str| match v { - "text" => crate::i18n::tr(&app.ui.language, "Plain Text"), - "sms" => crate::i18n::tr(&app.ui.language, "Text Message"), - "email" => crate::i18n::tr(&app.ui.language, "Email"), - "vcard" => crate::i18n::tr(&app.ui.language, "Business Card"), - _ => crate::i18n::tr(&app.ui.language, "Web Hyperlink"), - }; - egui::ComboBox::from_id_salt("qr_type").selected_text(crate::rtl::widget(ui, label(&cur))).width(160.0).show_ui(ui, |ui| { - for v in ["url", "text", "sms", "email", "vcard"] { - if ui.selectable_label(cur == v, crate::rtl::widget(ui, label(v))).clicked() { - d.fields.insert("type".into(), json!(v)); - } - } - }); - }); - let fields: &[(&str, &str)] = match cur.as_str() { - "sms" => &[("number", "Cell Number:"), ("message", "Message:")], - "email" => &[("to", "Email Address:"), ("subject", "Subject:"), ("body", "Message:")], - "vcard" => &[("name", "Name:"), ("org", "Organization:"), ("phone", "Phone:"), ("email", "Email:"), ("url", "URL:")], - "text" => &[("content", "Text:")], - _ => &[("content", "URL:")], - }; - egui::Grid::new("qr_fields").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - for (k, l) in fields { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, l)); - text_field(ui, &mut d, k, 260.0); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Color:")); + text_field(ui, &mut d, "color", 160.0); ui.end_row(); - } - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Color:")); - text_field(ui, &mut d, "color", 160.0); - ui.end_row(); - }); - } - "polygonSettings" => { - egui::Grid::new("poly").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Number of Sides:")); - text_field(ui, &mut d, "sides", 60.0); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Star Inset:")); - ui.horizontal(|ui| { - text_field(ui, &mut d, "starInset", 60.0); - ui.label("%"); }); - ui.end_row(); - }); - } - STEP_AND_REPEAT => step_and_repeat_dialog(app, ui, &mut d), - id if id.starts_with("cmd:") => command_form(app, ui, &mut d), - "goToPage" => { - ui.horizontal(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Page")); - text_field(ui, &mut d, "page", 80.0); - }); - } - "ruby" => { - ui.horizontal(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Ruby:")); - text_field(ui, &mut d, "text", 200.0); - }); - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Set over the selected text; empty removes it.")).size(11.0)); - } - "insertTable" => { - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Table Dimensions")).font(semibold(12.0))); - egui::Grid::new("ins_table").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Body Rows")); - text_field(ui, &mut d, "bodyRows", 60.0); - crate::rtl::label(ui, crate::i18n::tr_context(&app.ui.language, "Columns", "table")); - text_field(ui, &mut d, "columns", 60.0); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Header Rows")); - text_field(ui, &mut d, "headerRows", 60.0); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Footer Rows")); - text_field(ui, &mut d, "footerRows", 60.0); - ui.end_row(); - }); - } - "textFrameOptions" => { - seed_text_frame_options(app, &mut d); - text_frame_options_dialog(app, ui, &mut d); - } - "documentSetup" => document_setup(app, ui, &mut d), - "pdfExport" => pdf_export(app, ui, &mut d), - "alert" => { - let file = d.s("file"); - if !file.is_empty() { - crate::rtl::label(ui, egui::RichText::new(crate::rtl::isolate(&file)).font(semibold(13.0))); - ui.add_space(4.0); } - crate::rtl::label(ui, d.s("message")); - } - _ => {} - } - }); + "polygonSettings" => { + egui::Grid::new("poly").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Number of Sides:")); + text_field(ui, &mut d, "sides", 60.0); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Star Inset:")); + ui.horizontal(|ui| { + text_field(ui, &mut d, "starInset", 60.0); + ui.label("%"); + }); + ui.end_row(); + }); + } + STEP_AND_REPEAT => step_and_repeat_dialog(app, ui, &mut d), + id if id.starts_with("cmd:") => command_form(app, ui, &mut d), + "goToPage" => { + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Page")); + text_field(ui, &mut d, "page", 80.0); + }); + } + "ruby" => { + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Ruby:")); + text_field(ui, &mut d, "text", 200.0); + }); + crate::rtl::label( + ui, + egui::RichText::new(crate::i18n::tr(&app.ui.language, "Set over the selected text; empty removes it.")).size(11.0), + ); + } + "insertTable" => { + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Table Dimensions")).font(semibold(12.0))); + egui::Grid::new("ins_table").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Body Rows")); + text_field(ui, &mut d, "bodyRows", 60.0); + crate::rtl::label(ui, crate::i18n::tr_context(&app.ui.language, "Columns", "table")); + text_field(ui, &mut d, "columns", 60.0); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Header Rows")); + text_field(ui, &mut d, "headerRows", 60.0); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Footer Rows")); + text_field(ui, &mut d, "footerRows", 60.0); + ui.end_row(); + }); + } + "textFrameOptions" => { + seed_text_frame_options(app, &mut d); + text_frame_options_dialog(app, ui, &mut d); + } + "documentSetup" => document_setup(app, ui, &mut d), + "pdfExport" => pdf_export(app, ui, &mut d), + "alert" => { + let file = d.s("file"); + if !file.is_empty() { + crate::rtl::label(ui, egui::RichText::new(crate::rtl::isolate(&file)).font(semibold(13.0))); + ui.add_space(4.0); + } + crate::rtl::label(ui, d.s("message")); + } + _ => {} + }); ui.add_space(12.0); let ok_label = if d.id == "closeDocument" { " Save " } else { " OK " }; ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui .add( - egui::Button::new(crate::rtl::widget(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, ok_label)).color(egui::Color32::WHITE))) - .fill(crate::theme::Tokens::get(ui.ctx()).accent_strong), + egui::Button::new(crate::rtl::widget( + ui, + egui::RichText::new(crate::i18n::tr(&app.ui.language, ok_label)).color(egui::Color32::WHITE), + )) + .fill(crate::theme::Tokens::get(ui.ctx()).accent_strong), ) .clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) @@ -1742,12 +1856,26 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { /// Close the open dialog without applying it (Cancel, Esc, the control channel): a previewing /// dialog puts the document back as it was. pub fn cancel(app: &mut DesignApp) { + if app.ui.dialog.as_ref().is_some_and(|d| d.id == "findChange") { + app.session.reset_find_scope(); + } if app.ui.dialog.take().is_some_and(|d| d.fields.contains_key(PREVIEWED)) { app.session.cancel_interaction(); } app.cancel_pdf_import(); } +fn find_change_params(d: &Dialog) -> Value { + let mut p = json!({ + "find": d.s("find"), "change": d.s("change"), "grep": d.b("grep"), + "caseSensitive": d.b("caseSensitive"), "wholeWord": d.b("wholeWord"), "scope": d.s("scope"), + }); + if let Some(story) = d.fields.get("story") { + p["story"] = story.clone(); + } + p +} + /// Apply the open dialog. pub fn confirm(app: &mut DesignApp) -> Result<Value, String> { let Some(d) = app.ui.dialog.take() else { return Err("no dialog open".into()) }; @@ -2172,8 +2300,12 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> { app.run("footnote.options", p) } "alert" => Ok(Value::Null), - "findChange" if d.b("objectMode") => Ok(Value::Null), - "findChange" => app.run("find.change", json!({"find": d.s("find"), "change": d.s("change"), "grep": d.b("grep"), "caseSensitive": d.b("caseSensitive"), "wholeWord": d.b("wholeWord"), "scope": d.s("scope")})), + "findChange" => { + let result = if d.b("objectMode") { Ok(Value::Null) } else { app.run("find.change", find_change_params(&d)) }; + // Confirmation consumes retained bounds before closing, even on an error. + app.session.reset_find_scope(); + result + } STEP_AND_REPEAT => { // The preview goes; the copies are made once, as one undo step. if d.fields.contains_key(PREVIEWED) { @@ -4022,6 +4154,721 @@ mod tests { } } + fn scope_control(app: &mut DesignApp, ctx: &egui::Context, method: &str, params: Value) -> Value { + let (request, _reply) = crate::control::ControlRequest::new(method, params); + let crate::control::Outcome::Done(value) = crate::control::handle(app, ctx, &request) else { panic!("ordinary control response") }; + assert_eq!(value["ok"], true, "{value}"); + value["result"].clone() + } + + fn scope_click(app: &mut DesignApp, ctx: &egui::Context, screen: egui::Rect, label: &str) { + let labels = dialog_frame(app, ctx, screen, vec![]); + let pos = visible_label(&labels, screen, label).center(); + click_dialog(app, ctx, screen, pos); + } + + fn scope_dialog() -> (DesignApp, egui::Context, egui::Rect, designcraft_engine::doc::StoryId) { + scope_dialog_with_selection("red red | red", 7) + } + + fn scope_dialog_with_selection(text: &str, end: usize) -> (DesignApp, egui::Context, egui::Rect, designcraft_engine::doc::StoryId) { + scope_dialog_with_tool(text, end, None) + } + + fn scope_dialog_with_tool( + text: &str, + end: usize, + tool: Option<&str>, + ) -> (DesignApp, egui::Context, egui::Rect, designcraft_engine::doc::StoryId) { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.ui.language = "en".into(); + app.run("file.new", json!({})).unwrap(); + let frame = app.run("frame.create", json!({"rect": [36, 36, 100, 100], "content": "text", "text": text})).unwrap(); + let sid = designcraft_engine::doc::StoryId(frame["story"].as_u64().unwrap()); + let ctx = dialog_context("en"); + if let Some(tool) = tool { + scope_control(&mut app, &ctx, "ui.tool.select", json!({"tool": tool})); + } + app.run("text.select", json!({"story": sid.0, "anchor": 0, "focus": end})).unwrap(); + app.run("app.findChange", json!({})).unwrap(); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1178.0, 814.0)); + for (field, value) in [("find", "red"), ("change", "unused")] { + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": field, "value": value})); + } + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + scope_click(&mut app, &ctx, screen, "document"); + scope_click(&mut app, &ctx, screen, "selection"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("scope"), "selection"); + (app, ctx, screen, sid) + } + + fn scope_selected(app: &DesignApp, sid: designcraft_engine::doc::StoryId, a: usize, b: usize) { + let t = app.session.doc().unwrap().selection.text.unwrap(); + assert_eq!((t.story, t.range(), t.cell), (sid, a..b, None)); + } + + fn scope_state_unchanged(app: &DesignApp, before: &designcraft_engine::DocState) { + let after = app.session.doc().unwrap(); + assert!(std::sync::Arc::ptr_eq(&before.doc, &after.doc)); + assert_eq!(after.uid, before.uid); + assert_eq!(after.revision, before.revision); + assert_eq!(after.selection, before.selection); + assert_eq!(after.history.limit, before.history.limit); + for (actual, expected) in [(&after.history.undo, &before.history.undo), (&after.history.redo, &before.history.redo)] { + assert_eq!(actual.len(), expected.len()); + for (a, b) in actual.iter().zip(expected) { + assert_eq!(a.label, b.label); + assert_eq!(a.selection, b.selection); + assert!(std::sync::Arc::ptr_eq(&a.doc, &b.doc)); + } + } + } + + #[test] + fn find_scope_dialog_control_effective_noops_retain_bounds() { + for (field, value) in [("objectMode", json!(false)), ("story", Value::Null)] { + let (mut app, ctx, screen, sid) = scope_dialog(); + assert!(!app.ui.dialog.as_ref().unwrap().fields.contains_key(field)); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "change", "value": "blue"})); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + let before = app.session.doc().unwrap().clone(); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": field, "value": value})); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields[field], value); + scope_state_unchanged(&app, &before); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 4, 7); + scope_click(&mut app, &ctx, screen, "Change All"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 replacement(s) made"); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "blue blue | red"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), before.history.undo.len() + 1); + } + } + + #[test] + fn find_scope_dialog_explicit_story_ignored_scope_preserves_grep_cursor() { + for physical in [false, true] { + for (text, pattern, hits, changed) in [ + ("é", "é|(?:)", vec![(0, 2)], "X"), + ("é🙂", "é|(?:)", vec![(0, 2), (6, 6)], "X🙂X"), + ("é🙂", "(?:)", vec![(0, 0), (2, 2), (6, 6)], "XéX🙂X"), + ] { + let (mut app, ctx, screen, sid) = scope_dialog_with_selection(text, 0); + for (field, value) in [("find", json!(pattern)), ("change", json!("X")), ("story", json!(sid.0))] { + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": field, "value": value})); + } + scope_click(&mut app, &ctx, screen, "GREP"); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), format!("{} match(es)", hits.len())); + for &(a, b) in &hits { + scope_click(&mut app, &ctx, screen, "Find Next"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "Found"); + scope_selected(&app, sid, a, b); + let before = app.session.doc().unwrap().clone(); + let cur = app.ui.dialog.as_ref().unwrap().s("scope"); + let next = if cur == "selection" { "document" } else { "selection" }; + if physical { + scope_click(&mut app, &ctx, screen, &cur); + scope_click(&mut app, &ctx, screen, next); + } else { + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "scope", "value": next})); + } + assert_eq!(app.ui.dialog.as_ref().unwrap().s("scope"), next); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["story"], json!(sid.0)); + scope_state_unchanged(&app, &before); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), format!("{} match(es)", hits.len())); + } + scope_click(&mut app, &ctx, screen, "Find Next"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "No matches"); + // An ignored scope change must not revive an exhausted endpoint either. + let before = app.session.doc().unwrap().clone(); + let cur = app.ui.dialog.as_ref().unwrap().s("scope"); + let next = if cur == "selection" { "document" } else { "selection" }; + if physical { + scope_click(&mut app, &ctx, screen, &cur); + scope_click(&mut app, &ctx, screen, next); + } else { + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "scope", "value": next})); + } + scope_click(&mut app, &ctx, screen, "Find Next"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "No matches"); + scope_state_unchanged(&app, &before); + scope_click(&mut app, &ctx, screen, "Change All"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), format!("{} replacement(s) made", hits.len())); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, changed); + } + } + } + + #[test] + fn find_scope_dialog_explicit_story_transitions_reset_cursor() { + for remove_override in [false, true] { + let (mut app, ctx, screen, sid) = scope_dialog_with_selection("é🙂", 0); + let other = app.run("frame.create", json!({"rect": [180, 36, 100, 100], "content": "text", "text": "other"})).unwrap(); + app.run("text.select", json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + for (field, value) in [("find", json!("(?:)")), ("story", json!(sid.0))] { + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": field, "value": value})); + } + scope_click(&mut app, &ctx, screen, "GREP"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 0); + let before = app.session.doc().unwrap().clone(); + let value = if remove_override { Value::Null } else { other["story"].clone() }; + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "story", "value": value})); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "story", "value": sid.0})); + scope_state_unchanged(&app, &before); + // Away and back still releases continuation even if no Find command intervenes. + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 0); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 2, 2); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "é🙂"); + let other_sid = designcraft_engine::doc::StoryId(other["story"].as_u64().unwrap()); + assert_eq!(app.session.doc().unwrap().doc.story(other_sid).unwrap().text, "other"); + } + } + + #[test] + fn find_scope_dialog_control_mode_changes_release_bounds() { + for object_mode in [false, true] { + let (mut app, ctx, _, sid) = scope_dialog(); + if object_mode { + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "objectMode", "value": true})); + } + // Direct Session consumers share the retained domain even while Object is shown. + let p = json!({"find": "red", "change": "blue", "scope": "selection"}); + let count = scope_control(&mut app, &ctx, "engine.execute", json!({"command": "find.find", "params": p})); + assert_eq!(count.as_array().unwrap().len(), 2); + scope_control(&mut app, &ctx, "engine.execute", json!({"command": "find.next", "params": p})); + scope_selected(&app, sid, 0, 3); + let before = app.session.doc().unwrap().clone(); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "objectMode", "value": !object_mode})); + assert_eq!(app.ui.dialog.as_ref().unwrap().b("objectMode"), !object_mode); + scope_state_unchanged(&app, &before); + let count = scope_control(&mut app, &ctx, "engine.execute", json!({"command": "find.find", "params": p})); + assert_eq!(count, json!([{"story": sid.0, "start": 0, "end": 3, "text": "red"}])); + let changed = scope_control(&mut app, &ctx, "engine.execute", json!({"command": "find.change", "params": p})); + assert_eq!(changed, json!({"count": 1})); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "blue red | red"); + } + } + + #[test] + fn find_scope_dialog_tool_replacement_releases_bounds() { + for (tool, replacement) in [("page", "cmd:layout.pageSize"), ("rectangleFrame", "frameSize")] { + let (mut app, ctx, screen, sid) = scope_dialog_with_tool("red red | red", 7, Some(tool)); + assert_eq!(app.session.tool_id(), tool); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + let before = app.session.doc().unwrap().clone(); + let active = (app.session.active_index(), app.session.documents().len()); + let off = app.session.layout().xf(designcraft_engine::doc::SpreadRef::Doc(0)).translation(); + let x = off.x + before.doc.spreads[0].pages[0].x + 200.0; + let y = off.y + 200.0; + let events = if tool == "page" { + json!([{"kind": "up", "x": x, "y": y}]) + } else { + json!([{"kind": "down", "x": x, "y": y}, {"kind": "up", "x": x, "y": y}]) + }; + // Real tool -> Action::Dialog -> after_engine; no injected UI request. + scope_control(&mut app, &ctx, "ui.pointer", json!({"events": events})); + assert_eq!(app.ui.dialog.as_ref().unwrap().id, replacement); + assert!(app.session.ui_requests.is_empty()); + assert_eq!((app.session.active_index(), app.session.documents().len()), active); + scope_state_unchanged(&app, &before); + scope_click(&mut app, &ctx, screen, "Cancel"); + assert!(app.ui.dialog.is_none()); + assert_eq!((app.session.active_index(), app.session.documents().len()), active); + scope_state_unchanged(&app, &before); + let p = json!({"find": "red", "change": "blue", "scope": "selection"}); + let count = scope_control(&mut app, &ctx, "engine.execute", json!({"command": "find.find", "params": p})); + assert_eq!(count, json!([{"story": sid.0, "start": 0, "end": 3, "text": "red"}])); + scope_state_unchanged(&app, &before); + let changed = scope_control(&mut app, &ctx, "engine.execute", json!({"command": "find.change", "params": p})); + assert_eq!(changed, json!({"count": 1})); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "blue red | red"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), before.history.undo.len() + 1); + } + } + + #[test] + fn find_scope_dialog_tool_without_replacement_retains_bounds() { + for tool in ["page", "rectangleFrame"] { + let (mut app, ctx, screen, sid) = scope_dialog_with_tool("red red | red", 7, Some(tool)); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "change", "value": "blue"})); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + let before = app.session.doc().unwrap().clone(); + let fields = app.ui.dialog.as_ref().unwrap().fields.clone(); + scope_control(&mut app, &ctx, "ui.pointer", json!({"events": [{"kind": "move", "x": 200, "y": 200}]})); + assert_eq!(app.ui.dialog.as_ref().unwrap().id, "findChange"); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields, fields); + scope_state_unchanged(&app, &before); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 4, 7); + scope_click(&mut app, &ctx, screen, "Change All"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 replacement(s) made"); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "blue blue | red"); + } + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn find_scope_dialog_native_pdf_replacement_releases_bounds() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("three.pdf"); + std::fs::write(&path, crate::browser_import_tests::pdf()).unwrap(); + for dismissal in ["Cancel", "Escape", "control"] { + let (mut app, ctx, screen, sid) = scope_dialog(); + let picked = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let read = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let seen_pick = picked.clone(); + let seen_read = read.clone(); + let pick_path = path.to_string_lossy().to_string(); + let read_path = pick_path.clone(); + app.services.pick_open = Some(Box::new(move |purpose| { + assert_eq!(purpose, "place"); + seen_pick.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Some(pick_path.clone()) + })); + app.services.read = Some(Box::new(move |path| { + assert_eq!(path, read_path); + seen_read.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + std::fs::read(path).map_err(|e| e.to_string()) + })); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + let before = app.session.doc().unwrap().clone(); + let active = (app.session.active_index(), app.session.documents().len()); + // Exercise engine.execute -> UiRequest::Pick -> native host PDF replacement. + scope_control(&mut app, &ctx, "engine.execute", json!({"command": "file.place", "params": {}})); + assert_eq!(picked.load(std::sync::atomic::Ordering::SeqCst), 1); + assert_eq!(read.load(std::sync::atomic::Ordering::SeqCst), 1); + let dialog = app.ui.dialog.as_ref().unwrap(); + assert_eq!(dialog.id, "pdfImport"); + assert_eq!(dialog.s("path"), path.to_string_lossy()); + assert_eq!(dialog.fields["pages"], 3); + assert_eq!(dialog.s("page"), "1"); + assert!(!dialog.b("async")); + assert_eq!(dialog.fields["target"], json!(before.uid)); + assert!(app.session.ui_requests.is_empty()); + assert!(app.pending_pdf.is_none()); + assert_eq!((app.session.active_index(), app.session.documents().len()), active); + scope_state_unchanged(&app, &before); + match dismissal { + "Cancel" => scope_click(&mut app, &ctx, screen, "Cancel"), + "Escape" => { + dialog_frame( + &mut app, + &ctx, + screen, + vec![egui::Event::Key { + key: egui::Key::Escape, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }], + ); + } + "control" => { + scope_control(&mut app, &ctx, "ui.dialog.cancel", json!({})); + } + _ => panic!("known dismissal"), + } + assert!(app.ui.dialog.is_none()); + assert!(app.pending_pdf.is_none()); + assert_eq!(picked.load(std::sync::atomic::Ordering::SeqCst), 1); + assert_eq!(read.load(std::sync::atomic::Ordering::SeqCst), 1); + assert_eq!((app.session.active_index(), app.session.documents().len()), active); + scope_state_unchanged(&app, &before); + let p = json!({"find": "red", "change": "blue", "scope": "selection"}); + let count = scope_control(&mut app, &ctx, "engine.execute", json!({"command": "find.find", "params": p})); + assert_eq!(count, json!([{"story": sid.0, "start": 0, "end": 3, "text": "red"}])); + scope_state_unchanged(&app, &before); + let changed = scope_control(&mut app, &ctx, "engine.execute", json!({"command": "find.change", "params": p})); + assert_eq!(changed, json!({"count": 1})); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "blue red | red"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), before.history.undo.len() + 1); + } + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn find_scope_dialog_native_picker_cancel_retains_bounds() { + let (mut app, ctx, screen, sid) = scope_dialog(); + let picked = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let seen = picked.clone(); + app.services.pick_open = Some(Box::new(move |purpose| { + assert_eq!(purpose, "place"); + seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + None + })); + let read = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let seen_read = read.clone(); + app.services.read = Some(Box::new(move |_| { + seen_read.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Err("canceled picker must not read".into()) + })); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "change", "value": "blue"})); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + let before = app.session.doc().unwrap().clone(); + let active = (app.session.active_index(), app.session.documents().len()); + let fields = app.ui.dialog.as_ref().unwrap().fields.clone(); + scope_control(&mut app, &ctx, "engine.execute", json!({"command": "file.place", "params": {}})); + assert_eq!(picked.load(std::sync::atomic::Ordering::SeqCst), 1); + assert_eq!(read.load(std::sync::atomic::Ordering::SeqCst), 0); + assert_eq!(app.ui.dialog.as_ref().unwrap().id, "findChange"); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields, fields); + assert_eq!((app.session.active_index(), app.session.documents().len()), active); + scope_state_unchanged(&app, &before); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 4, 7); + scope_click(&mut app, &ctx, screen, "Change All"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 replacement(s) made"); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "blue blue | red"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), before.history.undo.len() + 1); + } + + #[test] + fn find_scope_dialog_exact_baseline_buttons() { + let (mut app, ctx, screen, sid) = scope_dialog_with_selection("red red", 3); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "change", "value": "blue"})); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "1 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + scope_click(&mut app, &ctx, screen, "Change All"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "1 replacement(s) made"); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "blue red"); + } + + #[test] + fn find_scope_dialog_grep_tab_alone_restarts_original_selection() { + let (mut app, ctx, screen, sid) = scope_dialog(); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + scope_click(&mut app, &ctx, screen, "GREP"); + assert!(app.ui.dialog.as_ref().unwrap().b("grep")); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("find"), "red"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 4, 7); + scope_click(&mut app, &ctx, screen, "Text"); + assert!(!app.ui.dialog.as_ref().unwrap().b("grep")); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + } + + #[test] + fn find_scope_dialog_buttons_retain_nonempty_hits_and_change_all() { + let (mut app, ctx, screen, sid) = scope_dialog(); + let history = app.session.doc().unwrap().history.undo.len(); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "change", "value": "blue"})); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 4, 7); + scope_click(&mut app, &ctx, screen, "Find Next"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "No matches"); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), history); + scope_click(&mut app, &ctx, screen, "Change All"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 replacement(s) made"); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "blue blue | red"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), history + 1); + scope_click(&mut app, &ctx, screen, "Cancel"); + assert!(app.ui.dialog.is_none()); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "red red | red"); + app.run("edit.redo", json!({})).unwrap(); + let native = app.run("file.serialize", json!({})).unwrap(); + app.run("file.openBytes", json!({"name": "scope.designcraft", "base64": native["base64"]})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "blue blue | red"); + } + + fn scope_confirm_case(action: &str) { + let (mut app, ctx, screen, sid) = scope_dialog(); + scope_click(&mut app, &ctx, screen, "Count"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "change", "value": "blue"})); + match action { + "OK" => scope_click(&mut app, &ctx, screen, "OK"), + "Enter" => { + dialog_frame( + &mut app, + &ctx, + screen, + vec![egui::Event::Key { + key: egui::Key::Enter, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }], + ); + } + "control" => { + assert_eq!(scope_control(&mut app, &ctx, "ui.dialog.confirm", json!({}))["count"], 2); + } + _ => panic!("known confirmation route"), + } + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "blue blue | red"); + // The closed dialog cannot leave its old seven-byte range for a later direct query. + let matches = app.run("find.find", json!({"find": "blue", "scope": "selection"})).unwrap(); + assert_eq!(matches.as_array().unwrap().len(), 0); + } + + #[test] + fn find_scope_dialog_ok_consumes_retained_bounds() { + scope_confirm_case("OK"); + } + + #[test] + fn find_scope_dialog_enter_consumes_retained_bounds() { + scope_confirm_case("Enter"); + } + + #[test] + fn find_scope_dialog_control_confirm_consumes_retained_bounds() { + scope_confirm_case("control"); + } + + #[test] + fn find_scope_dialog_query_reselection_cancel_and_reopen() { + let (mut app, ctx, screen, sid) = scope_dialog(); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "grep", "value": true})); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "find", "value": "r.d"})); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_control(&mut app, &ctx, "ui.dialog.cancel", json!({})); + assert_eq!(app.run("find.find", json!({"find": "red", "scope": "selection"})).unwrap().as_array().unwrap().len(), 1); + app.run("text.select", json!({"story": sid.0, "anchor": 0, "focus": 7})).unwrap(); + app.run("find.next", json!({"find": "red", "scope": "selection"})).unwrap(); + scope_control(&mut app, &ctx, "ui.dialog.open", json!({"id": "findChange", "fields": {"find": "red", "scope": "selection"}})); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "1 match(es)"); + app.run("text.select", json!({"story": sid.0, "anchor": 4, "focus": 7})).unwrap(); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 4, 7); + app.run("app.findChange", json!({})).unwrap(); + for (field, value) in [("find", "red"), ("scope", "selection")] { + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": field, "value": value})); + } + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "1 match(es)"); + } + + #[test] + fn find_scope_dialog_word_reselection_limits_count_and_change_buttons() { + let (mut app, ctx, screen, sid) = scope_dialog(); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + let selected = app.session.doc().unwrap().selection.clone(); + let frame = selected.text.unwrap().frame.unwrap(); + let point = { + let st = app.session.doc().unwrap(); + let cs = app.session.cache.get(&st.doc, sid, None); + let (fi, x, baseline, ascent, _) = designcraft_compose::caret(&cs, 1).unwrap(); + let inner = designcraft_geom::Point::new(x, baseline - ascent * 0.5); + assert_eq!(cs.frames[fi].frame, frame); + assert_eq!(designcraft_compose::hit(&cs, fi, inner), Some(1)); + let loc = st.doc.find(frame).unwrap(); + st.doc.parent_xf(&loc) * st.doc.text_xf(st.doc.item(frame).unwrap()) * inner + }; + let before = app.session.doc().unwrap().doc.clone(); + let revision = app.session.doc().unwrap().revision; + app.run("text.selectWord", json!({"frame": frame.0, "point": [point.x, point.y]})).unwrap(); + assert_eq!(app.session.doc().unwrap().selection, selected); + assert_eq!(app.session.doc().unwrap().revision, revision + 1); + assert!(std::sync::Arc::ptr_eq(&before, &app.session.doc().unwrap().doc)); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "1 match(es)"); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "change", "value": "blue"})); + scope_click(&mut app, &ctx, screen, "Change All"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "1 replacement(s) made"); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "blue red | red"); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn find_scope_dialog_failed_native_drop_alert_releases_bounds() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("broken.designcraft"); + std::fs::write(&path, b"not a native document").unwrap(); + for dismissal in ["OK", "Escape", "control"] { + let (mut app, ctx, screen, sid) = scope_dialog(); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "2 match(es)"); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + let before = app.session.doc().unwrap().doc.clone(); + let selected = app.session.doc().unwrap().selection.clone(); + let revision = app.session.doc().unwrap().revision; + let history = app.session.doc().unwrap().history.undo.len(); + let uid = app.session.doc().unwrap().uid; + let error = app.open_dropped(&path.to_string_lossy()).unwrap_err(); + let alert = app.ui.dialog.as_ref().unwrap(); + assert_eq!(alert.id, "alert"); + assert_eq!(alert.s("title"), "Can't Open the File"); + assert_eq!(alert.s("message"), error); + assert!(std::sync::Arc::ptr_eq(&before, &app.session.doc().unwrap().doc)); + assert_eq!(app.session.doc().unwrap().selection, selected); + assert_eq!(app.session.doc().unwrap().revision, revision); + assert_eq!(app.session.doc().unwrap().uid, uid); + assert_eq!(app.session.doc().unwrap().history.undo.len(), history); + match dismissal { + "OK" => scope_click(&mut app, &ctx, screen, "OK"), + "Escape" => { + dialog_frame( + &mut app, + &ctx, + screen, + vec![egui::Event::Key { + key: egui::Key::Escape, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }], + ); + } + "control" => { + scope_control(&mut app, &ctx, "ui.dialog.cancel", json!({})); + } + _ => panic!("known dismissal"), + } + assert!(app.ui.dialog.is_none()); + let p = json!({"find": "red", "change": "blue", "scope": "selection"}); + assert_eq!(app.run("find.find", p.clone()).unwrap(), json!([{"story": sid.0, "start": 0, "end": 3, "text": "red"}])); + assert_eq!(app.run("find.change", p).unwrap(), json!({"count": 1})); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "blue red | red"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), history + 1); + } + } + + #[test] + fn find_scope_dialog_child_errors_and_explicit_parent() { + let (mut app, ctx, screen, sid) = scope_dialog(); + app.run("text.select", json!({"story": sid.0, "anchor": 13, "focus": 13})).unwrap(); + app.run("table.insert", json!({"rows": 1, "cols": 1})).unwrap(); + app.run("text.insert", json!({"text": "red child", "raw": true})).unwrap(); + let child = app.session.doc().unwrap().selection.text.unwrap(); + let before = app.session.doc().unwrap().doc.clone(); + let selection = app.session.doc().unwrap().selection.clone(); + let history = app.session.doc().unwrap().history.undo.len(); + for label in ["Count", "Find Next", "Change All"] { + scope_click(&mut app, &ctx, screen, label); + assert!(app.ui.dialog.as_ref().unwrap().s("status").contains("not supported")); + assert!(std::sync::Arc::ptr_eq(&before, &app.session.doc().unwrap().doc)); + assert_eq!(app.session.doc().unwrap().selection, selection); + assert_eq!(app.session.doc().unwrap().history.undo.len(), history); + } + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "change", "value": "blue"})); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "story", "value": sid.0})); + scope_click(&mut app, &ctx, screen, "Count"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "3 match(es)"); + scope_click(&mut app, &ctx, screen, "Change All"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "3 replacement(s) made"); + assert_eq!(app.session.doc().unwrap().doc.text_story(sid, child.cell).unwrap().text, "red child"); + assert!(app.session.doc().unwrap().doc.story(sid).unwrap().text.starts_with("blue blue | blue")); + } + + #[test] + fn find_scope_dialog_error_confirmation_and_escape_release_context() { + for escape in [false, true] { + let (mut app, ctx, screen, sid) = scope_dialog(); + scope_click(&mut app, &ctx, screen, "Find Next"); + scope_selected(&app, sid, 0, 3); + if escape { + dialog_frame( + &mut app, + &ctx, + screen, + vec![egui::Event::Key { + key: egui::Key::Escape, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }], + ); + } else { + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "find", "value": "["})); + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "grep", "value": true})); + scope_click(&mut app, &ctx, screen, "OK"); + } + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.doc().unwrap().doc.story(sid).unwrap().text, "red red | red"); + assert_eq!(app.run("find.find", json!({"find": "red", "scope": "selection"})).unwrap().as_array().unwrap().len(), 1); + } + } + + #[test] + fn find_scope_dialog_domain_and_object_changes_release_original_range() { + for route in ["scope", "object", "menu", "command"] { + let (mut app, ctx, screen, _) = scope_dialog(); + scope_click(&mut app, &ctx, screen, "Find Next"); + match route { + "scope" => { + for scope in ["story", "selection"] { + scope_control(&mut app, &ctx, "ui.dialog.set", json!({"field": "scope", "value": scope})); + } + } + "object" => { + scope_click(&mut app, &ctx, screen, "Object"); + scope_click(&mut app, &ctx, screen, "Text"); + } + "menu" => crate::menus::activate(&mut app, "app.newDocumentDialog", &Value::Null), + "command" => { + app.run("app.newDocumentDialog", json!({})).unwrap(); + } + _ => panic!("known reset route"), + } + assert_eq!(app.run("find.find", json!({"find": "red", "scope": "selection"})).unwrap().as_array().unwrap().len(), 1); + } + } + #[test] fn dialog_title_and_actions_fit_scaled_viewports() { for language in ["en", "ar"] { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fc7a3e634..1082dd160 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -622,6 +622,7 @@ impl DesignApp { pub fn after_engine(&mut self) { let reqs = std::mem::take(&mut self.session.ui_requests); for r in reqs { + let was_find = self.ui.dialog.as_ref().is_some_and(|d| d.id == "findChange"); match r { UiRequest::Dialog { id, params } => self.ui.dialog = Some(dialogs::Dialog::new(&id, params)), UiRequest::View { params } => canvas::apply_view_request(self, ¶ms), @@ -630,6 +631,10 @@ impl DesignApp { let _ = self.pick_and_open(&purpose); } } + // Release only when the request actually replaces Find, including a native PDF picker. + if was_find && !self.ui.dialog.as_ref().is_some_and(|d| d.id == "findChange") { + self.session.reset_find_scope(); + } } // New documents get a fitted view. if let Some(st) = self.session.active() { @@ -986,6 +991,9 @@ impl DesignApp { let r = self.run(cmd, params); if let Err(e) = &r { let title = if cmd == "file.place" { "Can't Place the File" } else { "Can't Open the File" }; + if self.ui.dialog.as_ref().is_some_and(|d| d.id == "findChange") { + self.session.reset_find_scope(); + } self.ui.dialog = Some(dialogs::Dialog::new("alert", json!({"title": title, "file": file, "message": e}))); } else if let Ok(result) = &r { let warnings: Vec<&str> = result @@ -2108,7 +2116,7 @@ mod browser_import_tests { result.push_str(&format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1)); result.into_bytes() } - fn pdf() -> Vec<u8> { + pub(super) fn pdf() -> Vec<u8> { pdf_pages(3) } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 872cb81d0..13d3a54d9 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -752,6 +752,15 @@ pub fn ui_label(id: &str) -> Option<(&'static str, Option<&'static str>)> { /// Run a UI command; `None` if `id` isn't one. pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value, String>> { + let was_find = app.ui.dialog.as_ref().is_some_and(|d| d.id == "findChange"); + let result = run_ui_inner(app, id, p); + if was_find && app.ui.dialog.as_ref().is_none_or(|d| d.id != "findChange") { + app.session.reset_find_scope(); + } + result +} + +fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value, String>> { if let Some(result) = crate::panel_docking::command(app, id, p) { return Some(result); } @@ -957,6 +966,7 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value, } } "app.findChange" => { + app.session.reset_find_scope(); app.ui.dialog = Some(crate::dialogs::Dialog::new("findChange", json!({}))); Ok(Value::Null) } @@ -1880,6 +1890,14 @@ pub fn close_document(app: &mut DesignApp, index: Option<usize>) { /// A menu item was chosen. An engine command whose label ends in "…" and that takes parameters /// opens a dialog built from its parameter documentation (see [`crate::dialogs::command_fields`]). pub fn activate(app: &mut DesignApp, id: &str, params: &Value) { + let was_find = app.ui.dialog.as_ref().is_some_and(|d| d.id == "findChange"); + activate_inner(app, id, params); + if was_find && app.ui.dialog.as_ref().is_none_or(|d| d.id != "findChange") { + app.session.reset_find_scope(); + } +} + +fn activate_inner(app: &mut DesignApp, id: &str, params: &Value) { if params.is_null() && id == "file.close" { close_document(app, None); return; From f4f2f0bb08bd3fa5426bc77f2e8af1f0124796a1 Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Wed, 14 Oct 2026 06:20:41 +1000 Subject: [PATCH 169/238] FIX-TABLE-SELECTION: resolve selected merged-cell owners Co-Authored-By: Codex <codex@openai.com> --- crates/doc/src/table.rs | 48 +++++ crates/engine/src/cmd/table.rs | 129 ++++++------- crates/engine/src/cmd/text.rs | 11 +- crates/engine/src/tests_table.rs | 315 +++++++++++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 76 +++++++- 5 files changed, 503 insertions(+), 76 deletions(-) diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index 9449be93c..087412bc2 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -444,6 +444,25 @@ impl Table { v } + /// Owning cells touched by the range, once each in row-major order. Bounds are clipped + /// to the grid; resolving covered positions does not expand the selection rectangle. + pub fn selected_owners(&self, range: CellRange) -> Vec<(usize, usize)> { + let (nr, nc) = (self.nrows(), self.ncols()); + if range.r0 >= nr || range.c0 >= nc || range.r0 > range.r1 || range.c0 > range.c1 { + return Vec::new(); + } + let owners = self.owners(); + let mut selected = std::collections::BTreeSet::new(); + for r in range.r0..=range.r1.min(nr - 1) { + for c in range.c0..=range.c1.min(nc - 1) { + if let Some(&owner) = owners.get(r * nc + c) { + selected.insert(owner); + } + } + } + selected.into_iter().collect() + } + /// The owning (top-left) cell address of grid position (r, c). pub fn owner(&self, r: usize, c: usize) -> (usize, usize) { self.owners().get(r * self.ncols() + c).copied().unwrap_or((r, c)) @@ -1136,6 +1155,35 @@ mod tests { Story::with_text(StoryId(1), t, ParaFormat::default()) } + #[test] + fn selected_owners_include_covered_cells_without_expanding_range() { + for column in [true, false] { + let mut t = Table::new(1, 2, 2, 0, 0, 120.0); + let range = if column { CellRange::new(0, 1, 1, 1) } else { CellRange::new(1, 0, 1, 1) }; + assert_eq!(t.selected_owners(range), if column { vec![(0, 1), (1, 1)] } else { vec![(1, 0), (1, 1)] }); + t.merge(if column { CellRange::new(0, 0, 0, 1) } else { CellRange::new(0, 0, 1, 0) }).unwrap(); + assert_eq!(t.selected_owners(range), vec![(0, 0), (1, 1)]); + } + } + + #[test] + fn selected_owners_are_bounded_sorted_and_unique() { + let mut t = Table::new(1, 3, 3, 0, 0, 180.0); + t.merge(CellRange::new(0, 0, 1, 1)).unwrap(); + assert_eq!(t.selected_owners(CellRange::new(1, 1, usize::MAX, usize::MAX)), vec![(0, 0), (1, 2), (2, 1), (2, 2)]); + assert_eq!(t.selected_owners(CellRange::new(0, 0, 1, 1)), vec![(0, 0)], "covered positions do not duplicate an owner"); + assert!(t.selected_owners(CellRange::new(3, 0, usize::MAX, usize::MAX)).is_empty()); + assert!(t.selected_owners(CellRange::new(0, 3, usize::MAX, usize::MAX)).is_empty()); + assert!(t.selected_owners(CellRange { r0: 1, c0: 0, r1: 0, c1: 1 }).is_empty()); + t.rows.clear(); + t.cells.clear(); + assert!(t.selected_owners(CellRange::cell(0, 0)).is_empty()); + + let mut right = Table::new(2, 2, 2, 0, 0, 120.0); + right.merge(CellRange::new(0, 1, 1, 1)).unwrap(); + assert_eq!(right.selected_owners(CellRange::new(1, 0, 1, 1)), vec![(0, 1), (1, 0)], "owner order, not encounter order"); + } + #[test] fn insert_table_gets_own_paragraph() { let mut s = story("before after"); diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index ad306be8e..c3df24420 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -7,6 +7,7 @@ //! whole table). use designcraft_compose as compose; +use designcraft_doc::story::floor_char_boundary; use designcraft_doc::{ AltFills, CellAddr, CellRange, CellStroke, Document, RowHeightMode, Selection, StoryId, StrokeType, Table, TableSel, TextSel, VerticalJustification, @@ -140,11 +141,9 @@ fn table_specs() -> Vec<CommandSpec> { |s, p| { let g = target(s, p, "table.textCell")?; edit_table(s, &g, "table.textCell", |t| { - for r in g.range.r0..=g.range.r1 { - for c in g.range.c0..=g.range.c1 { - if let Some(cell) = t.cell_mut(r, c) { - cell.graphic = None; - } + for (r, c) in t.selected_owners(g.range) { + if let Some(cell) = t.cell_mut(r, c) { + cell.graphic = None; } } ok() @@ -351,13 +350,17 @@ fn clamp_selection(d: &Document, sel: &mut Selection) { match story.and_then(|x| x.tables.get(&c.table)) { Some(tb) => { let (r, col) = tb.owner(c.row.min(tb.nrows() - 1), c.col.min(tb.ncols() - 1)); - let len = tb.cell(r, col).map_or(0, |x| x.text.len()); - sel.text = - Some(TextSel { anchor: t.anchor.min(len), focus: t.focus.min(len), cell: Some(CellAddr { table: c.table, row: r, col }), ..t }); + let text = tb.cell(r, col).map_or("", |x| x.text.text.as_str()); + sel.text = Some(TextSel { + anchor: floor_char_boundary(text, t.anchor), + focus: floor_char_boundary(text, t.focus), + cell: Some(CellAddr { table: c.table, row: r, col }), + ..t + }); } None => { - let len = story.map_or(0, |x| x.len()); - sel.text = Some(TextSel { anchor: t.anchor.min(len), focus: t.anchor.min(len), cell: None, ..t }); + let pos = story.map_or(0, |x| floor_char_boundary(&x.text, t.anchor)); + sel.text = Some(TextSel { anchor: pos, focus: pos, cell: None, ..t }); } } } @@ -648,7 +651,6 @@ fn set_cell(s: &mut Session, p: &Value) -> Result<Value> { let text = str_param(p, "text").map(|_| super::text_param(p, "text")); let stroke = p.get("stroke").cloned(); edit_table(s, &g, "table.setCell", |t| { - let owners = t.owners(); let nc = t.ncols(); let nr = t.nrows(); let rtl = t.options.direction == designcraft_doc::TextDirection::RightToLeft; @@ -656,50 +658,45 @@ fn set_cell(s: &mut Session, p: &Value) -> Result<Value> { // Local edits outrank previously imported edges, including explicit zero/None. let priority = if stroke.is_some() { next_cell_stroke_priority(t) } else { 0 }; let rg = g.range; - for r in rg.r0..=rg.r1 { - for c in rg.c0..=rg.c1 { - if owners[r * nc + c] != (r, c) { - continue; - } - let Some(cell) = t.cell_mut(r, c) else { continue }; - if let Some(f) = &fill { - cell.fill = f.clone(); - } - if let Some(ti) = tint { - cell.fill_tint = ti; - } - if let Some(i) = insets { - cell.insets = i; - } - if let Some(v) = vj { - cell.vj = v; - } - if let Some(tx) = &text { - let len = cell.text.len(); - cell.text.replace(0..len, tx); - } - if let Some(sv) = &stroke { - let edges = str_param(sv, "edges").unwrap_or("all"); - let at_bottom = r.saturating_add(cell.row_span.max(1) as usize); - let at_right = c.saturating_add(cell.col_span.max(1) as usize); - let perimeter = [r == 0, if rtl { at_right == nc } else { c == 0 }, at_bottom == nr, if rtl { c == 0 } else { at_right == nc }]; - for (i, on) in [ - ("top", r == rg.r0), - ("left", if rtl { at_right.saturating_sub(1) == rg.c1 } else { c == rg.c0 }), - ("bottom", at_bottom.saturating_sub(1) == rg.r1), - ("right", if rtl { c == rg.c0 } else { at_right.saturating_sub(1) == rg.c1 }), - ] - .iter() - .enumerate() - .map(|(i, (name, outer))| (i, edges == "all" || edges == *name || (edges == "outer" && *outer) || (edges == "inner" && !*outer))) - { - if on { - let base = if perimeter[i] && !cell.border_overrides[i] { &borders[i] } else { &cell.strokes[i] }; - cell.strokes[i] = parse_stroke(sv, base.clone()); - cell.stroke_defined[i] = true; - cell.stroke_priorities[i] = priority; - cell.border_overrides[i] = true; - } + for (r, c) in t.selected_owners(rg) { + let Some(cell) = t.cell_mut(r, c) else { continue }; + if let Some(f) = &fill { + cell.fill = f.clone(); + } + if let Some(ti) = tint { + cell.fill_tint = ti; + } + if let Some(i) = insets { + cell.insets = i; + } + if let Some(v) = vj { + cell.vj = v; + } + if let Some(tx) = &text { + let len = cell.text.len(); + cell.text.replace(0..len, tx); + } + if let Some(sv) = &stroke { + let edges = str_param(sv, "edges").unwrap_or("all"); + let at_bottom = r.saturating_add(cell.row_span.max(1) as usize); + let at_right = c.saturating_add(cell.col_span.max(1) as usize); + let perimeter = [r == 0, if rtl { at_right == nc } else { c == 0 }, at_bottom == nr, if rtl { c == 0 } else { at_right == nc }]; + for (i, on) in [ + ("top", r == rg.r0), + ("left", if rtl { at_right.saturating_sub(1) == rg.c1 } else { c == rg.c0 }), + ("bottom", at_bottom.saturating_sub(1) == rg.r1), + ("right", if rtl { c == rg.c0 } else { at_right.saturating_sub(1) == rg.c1 }), + ] + .iter() + .enumerate() + .map(|(i, (name, outer))| (i, edges == "all" || edges == *name || (edges == "outer" && *outer) || (edges == "inner" && !*outer))) + { + if on { + let base = if perimeter[i] && !cell.border_overrides[i] { &borders[i] } else { &cell.strokes[i] }; + cell.strokes[i] = parse_stroke(sv, base.clone()); + cell.stroke_defined[i] = true; + cell.stroke_priorities[i] = priority; + cell.border_overrides[i] = true; } } } @@ -707,6 +704,7 @@ fn set_cell(s: &mut Session, p: &Value) -> Result<Value> { // Keep both copies of unmerged shared boundaries consistent. Left/right name // physical sides, also in RTL tables. if let Some(sv) = &stroke { + let owners = t.owners(); let edges = str_param(sv, "edges").unwrap_or("all"); let selected = |side: &str| edges == "all" || edges == "outer" || edges == side; if selected("top") @@ -1095,7 +1093,10 @@ fn cell_style_create(s: &mut Session, p: &Value) -> Result<Value> { let cell = d .story(g.story) .and_then(|st| st.tables.get(&g.table)) - .and_then(|t| t.cell(g.range.r0, g.range.c0)) + .and_then(|t| { + let (r, c) = t.owner(g.range.r0, g.range.c0); + t.cell(r, c) + }) .ok_or_else(|| bad("style.cell.create", "no cell"))?; cs.fill = Some(cell.fill.clone()); cs.fill_tint = Some(cell.fill_tint); @@ -1122,17 +1123,11 @@ fn cell_style_apply(s: &mut Session, p: &Value) -> Result<Value> { let cs = designcraft_doc::resolve_cell_style(styles, &name); let g = target(s, p, "style.cell.apply")?; edit_table(s, &g, "style.cell.apply", |t| { - let owners = t.owners(); - let nc = t.ncols(); let mut n = 0; - for r in g.range.r0..=g.range.r1 { - for c in g.range.c0..=g.range.c1 { - if owners[r * nc + c] == (r, c) - && let Some(cell) = t.cell_mut(r, c) - { - cs.apply_to(cell); - n += 1; - } + for (r, c) in t.selected_owners(g.range) { + if let Some(cell) = t.cell_mut(r, c) { + cs.apply_to(cell); + n += 1; } } Ok(json!({"cells": n})) @@ -1353,7 +1348,7 @@ fn drop_cells(s: &mut Session, p: &Value) -> Result<Value> { && ts.story == sid && ts.table == tid && tid2 == tid - && ts.range.contains(fr, fc) + && s.doc()?.doc.story(sid).and_then(|st| st.tables.get(&tid)).is_some_and(|t| t.selected_owners(ts.range).binary_search(&(fr, fc)).is_ok()) { let (nr, nc) = { let t = s.doc()?.doc.story(sid).and_then(|st| st.tables.get(&tid)).ok_or_else(|| bad(ID, "no table"))?; diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index b57e9e88a..a53e6e8d2 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -823,14 +823,9 @@ pub(crate) fn format_targets(s: &Session) -> Vec<Target> { && let Some(t) = st.doc.story(ts.story).and_then(|x| x.tables.get(&ts.table)) { let mut v = Vec::new(); - let owners = t.owners(); - for r in ts.range.r0..=ts.range.r1.min(t.nrows().saturating_sub(1)) { - for c in ts.range.c0..=ts.range.c1.min(t.ncols().saturating_sub(1)) { - if owners[r * t.ncols() + c] == (r, c) { - let len = t.cell(r, c).map_or(0, |x| x.text.len()); - v.push(Target { story: ts.story, cell: Some(CellAddr { table: ts.table, row: r, col: c }), range: 0..len }); - } - } + for (r, c) in t.selected_owners(ts.range) { + let len = t.cell(r, c).map_or(0, |x| x.text.len()); + v.push(Target { story: ts.story, cell: Some(CellAddr { table: ts.table, row: r, col: c }), range: 0..len }); } return v; } diff --git a/crates/engine/src/tests_table.rs b/crates/engine/src/tests_table.rs index 3934aabec..c3a575d0d 100644 --- a/crates/engine/src/tests_table.rs +++ b/crates/engine/src/tests_table.rs @@ -16,6 +16,321 @@ fn check(s: &Session) { s.doc().unwrap().doc.check().unwrap(); } +#[test] +fn table_text_replacement_floors_utf8_selection_and_allows_quote_typing() { + for (original, (a, b), replacement, (after_a, after_b), quoted) in [ + ("a", (1, 1), "é", (0, 0), Some("“é")), // Exact caret/quote baseline. + ("abc", (3, 3), "🙂", (0, 0), Some("“🙂")), + ("ab", (1, 2), "éX", (0, 2), None), + ("ab", (2, 1), "éX", (2, 0), None), + ("abcdef", (3, 5), "🙂éX", (0, 4), None), + ("abcdef", (5, 3), "🙂éX", (4, 0), None), + ("abc", (1, 2), "🙂", (0, 0), None), // Both endpoints floor to the same boundary. + ("abcdef", (2, 6), "é🙂", (2, 6), None), // Valid Unicode endpoints stay fixed. + ("abcdef", (1, 4), "ABCDEF", (1, 4), None), + ("abcdef", (4, 1), "ABCDEF", (4, 1), None), + ("abcdef", (6, 6), "é", (2, 2), None), // Shortening clamps to EOF. + ("abc", (1, 3), "", (0, 0), None), + ] { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let frame = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": ""})).unwrap(); + let sid = frame["story"].as_u64().unwrap(); + let fid = frame["id"].as_u64().unwrap(); + let tid = s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap()["table"].as_u64().unwrap(); + s.execute("text.insert", &json!({"text": original, "raw": true})).unwrap(); + // Real commands position the child selection; text.select addresses parent stories. + for _ in a..original.len() { + s.execute("text.move", &json!({"dir": "left"})).unwrap(); + } + let (dir, steps) = if b >= a { ("right", b - a) } else { ("left", a - b) }; + for _ in 0..steps { + s.execute("text.move", &json!({"dir": dir, "extend": true})).unwrap(); + } + let mut expected = s.doc().unwrap().selection.clone(); + let selected = expected.text.as_mut().unwrap(); + assert_eq!((selected.anchor, selected.focus), (a, b)); + assert_eq!(selected.frame, Some(doc::ItemId(fid))); + assert_eq!(selected.cell, Some(doc::CellAddr { table: tid, row: 0, col: 0 })); + (selected.anchor, selected.focus) = (after_a, after_b); + let parent = s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap().text.clone(); + s.execute("table.setCell", &json!({"text": replacement})).unwrap(); + assert_eq!(table_for(&s, sid, tid).cell(0, 0).unwrap().text.text, replacement); + assert_eq!(s.doc().unwrap().selection, expected, "{original}: {a}->{b}, replacement {replacement}"); + assert!(replacement.is_char_boundary(after_a) && replacement.is_char_boundary(after_b)); + assert_eq!(s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap().text, parent); + if let Some(quoted) = quoted { + assert!(s.prefs.typographers_quotes); + s.execute("text.insert", &json!({"text": "\""})).unwrap(); + assert_eq!(table_for(&s, sid, tid).cell(0, 0).unwrap().text.text, quoted); + let selected = expected.text.as_mut().unwrap(); + (selected.anchor, selected.focus) = (3, 3); // Opening curly quote is three bytes. + assert_eq!(s.doc().unwrap().selection, expected); + } + check(&s); + } +} + +#[test] +fn table_edit_after_anchor_removal_floors_parent_caret() { + for (original, (a, b), replacement, pos, quoted, typed_pos) in [("ab", (1, 2), "é", 0, "“é\n", 3), ("abcde", (5, 1), "é🙂", 2, "é”🙂\n", 5)] + { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let other = s.execute("frame.create", &json!({"rect": [320, 36, 580, 200], "content": "text"})).unwrap(); + let other_sid = other["story"].as_u64().unwrap(); + let other_tid = s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap()["table"].as_u64().unwrap(); + s.execute("text.insert", &json!({"text": "keep", "raw": true})).unwrap(); + let mut expected_other = table_for(&s, other_sid, other_tid).clone(); + expected_other.cell_mut(0, 0).unwrap().insets = [7.0; 4]; + + let frame = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text"})).unwrap(); + let sid = frame["story"].as_u64().unwrap(); + let tid = s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap()["table"].as_u64().unwrap(); + s.execute("text.insert", &json!({"text": original, "raw": true})).unwrap(); + for _ in a..original.len() { + s.execute("text.move", &json!({"dir": "left"})).unwrap(); + } + let (dir, steps) = if b >= a { ("right", b - a) } else { ("left", a - b) }; + for _ in 0..steps { + s.execute("text.move", &json!({"dir": dir, "extend": true})).unwrap(); + } + let mut expected = s.doc().unwrap().selection.clone(); + let selected = expected.text.unwrap(); + assert_eq!((selected.anchor, selected.focus), (a, b)); + assert_eq!(selected.frame, Some(doc::ItemId(frame["id"].as_u64().unwrap()))); + assert_eq!(selected.cell, Some(doc::CellAddr { table: tid, row: 0, col: 0 })); + // Explicit parent replacement removes its anchor, leaving the child address stale. + assert_eq!( + s.execute("find.change", &json!({"story": sid, "find": doc::TABLE_ANCHOR.to_string(), "change": replacement})).unwrap()["count"], + 1 + ); + let parent = s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap(); + assert_eq!(parent.text, format!("{replacement}\n")); + assert!(!parent.tables.contains_key(&tid)); + assert_eq!(s.doc().unwrap().selection, expected); + assert!(!parent.text.is_char_boundary(selected.anchor)); + // An explicit edit to the surviving table reaches the disappeared-table fallback. + s.execute("table.setCell", &json!({"story": other_sid, "table": other_tid, "insets": 7})).unwrap(); + let selected = expected.text.as_mut().unwrap(); + (selected.anchor, selected.focus, selected.cell) = (pos, pos, None); // Both floor the OLD anchor. + assert_eq!(s.doc().unwrap().selection, expected); + assert_eq!(s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap().text, format!("{replacement}\n")); + assert_eq!(table_for(&s, other_sid, other_tid), &expected_other); + s.execute("text.insert", &json!({"text": "\""})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap().text, quoted); + let selected = expected.text.as_mut().unwrap(); + (selected.anchor, selected.focus) = (typed_pos, typed_pos); + assert_eq!(s.doc().unwrap().selection, expected); + assert_eq!(table_for(&s, other_sid, other_tid), &expected_other); + check(&s); + } +} + +fn selected_merged_table(column: bool) -> (Session, u64, u64, u64) { + let (mut s, sid, fid) = session_with_frame(); + let tid = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap(); + for (i, text) in ["A", "B", "C", "D"].iter().enumerate() { + s.execute("text.insert", &json!({"text": text})).unwrap(); + if i < 3 { + s.execute("table.nextCell", &json!({})).unwrap(); + } + } + let merge = if column { json!({"rows": [0, 0], "cols": [0, 1]}) } else { json!({"rows": [0, 1], "cols": [0, 0]}) }; + s.execute("table.select", &merge).unwrap(); + s.execute("table.merge", &json!({})).unwrap(); + s.execute("table.select", &json!({"rows": [1, 1], "cols": [1, 1]})).unwrap(); + s.execute(if column { "table.selectColumn" } else { "table.selectRow" }, &json!({})).unwrap(); + (s, sid, tid, fid) +} + +#[test] +fn selected_merged_owners_receive_text_formatting_and_character_styles() { + for column in [true, false] { + let (mut s, sid, tid, _) = selected_merged_table(column); + let selected = s.doc().unwrap().selection.clone(); + assert_eq!(selected.cells.unwrap().range, if column { doc::CellRange::new(0, 1, 1, 1) } else { doc::CellRange::new(1, 0, 1, 1) }); + let before = table_for(&s, sid, tid).clone(); + let neighbor = if column { (1, 0) } else { (0, 1) }; + s.execute("type.char", &json!({"attrs": {"size": 23}})).unwrap(); + for (r, c) in [(0, 0), (1, 1)] { + let cell = table_for(&s, sid, tid).cell(r, c).unwrap(); + assert!(cell.text.runs().all(|(_, f)| f.over.size == Some(23.0)), "all runs of owner {r},{c}"); + assert_eq!(cell.text.text, before.cell(r, c).unwrap().text.text); + } + assert_eq!(table_for(&s, sid, tid).cell(neighbor.0, neighbor.1), before.cell(neighbor.0, neighbor.1)); + assert_eq!(s.doc().unwrap().selection, selected); + s.execute("style.character.create", &json!({"name": "Selected owners", "chars": {"tracking": 37}})).unwrap(); + s.execute("style.character.apply", &json!({"name": "Selected owners"})).unwrap(); + for (r, c) in [(0, 0), (1, 1)] { + assert!(table_for(&s, sid, tid).cell(r, c).unwrap().text.runs().all(|(_, f)| f.style == "Selected owners")); + } + let after = table_for(&s, sid, tid); + assert_eq!(after.cell(neighbor.0, neighbor.1), before.cell(neighbor.0, neighbor.1)); + assert_eq!(after.rows, before.rows); + assert_eq!(after.columns, before.columns); + assert_eq!(s.doc().unwrap().selection, selected, "formatting preserves the original grid rectangle"); + check(&s); + } +} + +#[test] +fn selected_merged_owners_receive_cell_options_and_unique_style_counts() { + for column in [true, false] { + let (mut s, sid, tid, _) = selected_merged_table(column); + let range = s.doc().unwrap().selection.cells.unwrap().range; + let before = table_for(&s, sid, tid).clone(); + let neighbor = if column { (1, 0) } else { (0, 1) }; + let covered = if column { (0, 1) } else { (1, 0) }; + s.execute("table.setCell", &json!({"fill": "[Black]", "insets": 7, "text": "REPLACED"})).unwrap(); + for (r, c) in [(0, 0), (1, 1)] { + let cell = table_for(&s, sid, tid).cell(r, c).unwrap(); + assert_eq!(serde_json::to_value(&cell.fill).unwrap(), json!("[Black]")); + assert_eq!(cell.insets, [7.0; 4]); + assert_eq!(cell.text.text, "REPLACED"); + } + s.execute("style.cell.create", &json!({"name": "Selected cell owners", "insets": 9})).unwrap(); + assert_eq!(s.execute("style.cell.apply", &json!({"name": "Selected cell owners"})).unwrap()["cells"], 2); + let after = table_for(&s, sid, tid); + for (r, c) in [(0, 0), (1, 1)] { + assert_eq!(after.cell(r, c).unwrap().style, "Selected cell owners"); + assert_eq!(after.cell(r, c).unwrap().insets, [9.0; 4]); + } + assert_eq!(after.cell(neighbor.0, neighbor.1), before.cell(neighbor.0, neighbor.1)); + assert_eq!(after.cell(covered.0, covered.1), before.cell(covered.0, covered.1)); + assert_eq!(after.rows, before.rows); + assert_eq!(after.columns, before.columns); + assert_eq!(s.doc().unwrap().selection.cells.unwrap().range, range); + let merged = if column { json!({"rows": [0, 0], "cols": [0, 1]}) } else { json!({"rows": [0, 1], "cols": [0, 0]}) }; + s.execute("table.select", &merged).unwrap(); + assert_eq!(s.execute("style.cell.apply", &json!({"name": "Selected cell owners"})).unwrap()["cells"], 1); + check(&s); + } +} + +#[test] +fn covered_owner_strokes_keep_literal_edge_masks_and_neighbors() { + for rtl in [false, true] { + for outer in [false, true] { + let (mut s, sid, tid, _) = selected_merged_table(true); + if rtl { + s.execute("table.options", &json!({"direction": "rightToLeft"})).unwrap(); + } + s.execute("table.select", &json!({"rows": [0, 0], "cols": [1, 1]})).unwrap(); + let before = table_for(&s, sid, tid).clone(); + s.execute("table.setCell", &json!({"stroke": {"weight": 3, "edges": if outer { "outer" } else { "inner" }}})).unwrap(); + let after = table_for(&s, sid, tid); + let mask = if rtl { [true, true, true, false] } else { [true, false, true, true] }; + let expected = mask.map(|edge| if outer { edge } else { !edge }); + let owner = after.cell(0, 0).unwrap(); + assert_eq!(owner.border_overrides, expected); + assert_eq!(owner.stroke_defined, expected); + for (i, on) in expected.into_iter().enumerate() { + if on { + assert_eq!(owner.strokes[i].weight, 3.0); + assert_eq!(owner.stroke_priorities[i], 1); + } else { + assert_eq!(owner.strokes[i], before.cell(0, 0).unwrap().strokes[i]); + } + } + for (r, c) in [(0, 1), (1, 0), (1, 1)] { + assert_eq!(after.cell(r, c), before.cell(r, c), "strict merged/covered mirror guard preserves {r},{c}"); + } + assert_eq!(s.doc().unwrap().selection.cells.unwrap().range, doc::CellRange::cell(0, 1)); + check(&s); + } + } +} + +#[test] +fn cell_style_sampling_resolves_covered_point_but_retains_upper_left_policy() { + let (mut s, sid, tid, _) = selected_merged_table(true); + s.execute("table.setCell", &json!({"row": 0, "col": 0, "insets": 7})).unwrap(); + s.execute("table.select", &json!({"rows": [0, 0], "cols": [1, 1]})).unwrap(); + let selection = s.doc().unwrap().selection.clone(); + let before = table_for(&s, sid, tid).clone(); + s.execute("style.cell.create", &json!({"name": "Covered", "fromSelection": true})).unwrap(); + assert_eq!(s.doc().unwrap().doc.styles.cell.iter().find(|c| c.name == "Covered").unwrap().insets, Some([7.0; 4])); + assert_eq!(table_for(&s, sid, tid), &before); + assert_eq!(s.doc().unwrap().selection, selection); + + let (mut s, sid, _) = session_with_frame(); + let tid = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap(); + s.execute("table.select", &json!({"rows": [0, 1], "cols": [1, 1]})).unwrap(); + s.execute("table.merge", &json!({})).unwrap(); + for (row, col, insets, size) in [(0, 1, 7, 17), (1, 0, 11, 29)] { + s.execute("table.setCell", &json!({"row": row, "col": col, "text": "Text", "insets": insets})).unwrap(); + s.execute("table.select", &json!({"rows": [row, row], "cols": [col, col]})).unwrap(); + s.execute("type.char", &json!({"attrs": {"size": size}})).unwrap(); + } + s.execute("table.selectRow", &json!({})).unwrap(); + let before = table_for(&s, sid, tid).clone(); + let selection = s.doc().unwrap().selection.clone(); + // Row-major owners are [(0,1),(1,0)], while the rectangle starts at (1,0). + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"], 17.0); + s.execute("style.cell.create", &json!({"name": "Upper left", "fromSelection": true})).unwrap(); + assert_eq!(s.doc().unwrap().doc.styles.cell.iter().find(|c| c.name == "Upper left").unwrap().insets, Some([11.0; 4])); + assert_eq!(table_for(&s, sid, tid), &before); + assert_eq!(s.doc().unwrap().selection, selection); +} + +#[test] +fn selected_merged_owners_convert_graphics_back_to_text() { + let png = designcraft_render::Rendered { width: 1, height: 1, pixels: vec![255, 0, 0, 255] }.to_png(); + let image = crate::cmd::base64_encode(&png); + for column in [true, false] { + let (mut s, sid, tid, _) = selected_merged_table(column); + let neighbor = if column { (1, 0) } else { (0, 1) }; + let covered = if column { (0, 1) } else { (1, 0) }; + for (r, c) in [covered, (1, 1), neighbor] { + let placed = s.execute("table.placeGraphic", &json!({"row": r, "col": c, "base64": image, "name": "cell.png"})).unwrap(); + if (r, c) == covered { + assert_eq!((placed["row"].as_u64(), placed["col"].as_u64()), (Some(0), Some(0))); + } + } + let before = table_for(&s, sid, tid).clone(); + let selected = s.doc().unwrap().selection.cells.unwrap(); + s.execute("table.textCell", &json!({})).unwrap(); + let after = table_for(&s, sid, tid); + for (r, c) in [(0, 0), (1, 1)] { + assert!(before.cell(r, c).unwrap().graphic.is_some()); + assert!(after.cell(r, c).unwrap().graphic.is_none()); + assert_eq!(after.cell(r, c).unwrap().text, before.cell(r, c).unwrap().text); + } + assert_eq!(after.cell(neighbor.0, neighbor.1), before.cell(neighbor.0, neighbor.1)); + assert_eq!(after.cell(covered.0, covered.1), before.cell(covered.0, covered.1)); + assert_eq!(s.doc().unwrap().selection.cells, Some(selected)); + check(&s); + } +} + +#[test] +fn covered_selected_owner_drag_keeps_merged_move_restrictions_atomic() { + for column in [true, false] { + let (mut s, sid, _, fid) = selected_merged_table(column); + let before = s.doc().unwrap().doc.clone(); + let selection = s.doc().unwrap().selection.clone(); + let revision = s.doc().unwrap().revision; + let undo = s.doc().unwrap().history.undo.len(); + let redo = s.doc().unwrap().history.redo.len(); + let cs = s.cache.get(&before, doc::StoryId(sid), None); + let cells = &cs.frames[0].tables[0].cells; + let point = |row, col| { + let cell = cells.iter().find(|c| c.row == row && c.col == col).unwrap(); + before.text_xf(before.item(doc::ItemId(fid)).unwrap()) * cell.rect.center() + }; + let from = point(0, 0); + let to = if column { point(1, 0) } else { point(0, 1) }; + let err = s.execute("table.dropCells", &json!({"frame": fid, "from": [from.x, from.y], "to": [to.x, to.y]})).unwrap_err(); + assert!(err.to_string().contains(if column { "unmerge cells that span columns" } else { "unmerge cells that span rows" }), "{err}"); + let st = s.doc().unwrap(); + assert_eq!(st.doc, before); + assert_eq!(st.selection, selection); + assert_eq!(st.revision, revision); + assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo, redo)); + } +} + #[test] fn insert_type_in_cells_and_tab() { let (mut s, sid, _) = session_with_frame(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 077ae2944..cc7cee3d7 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -946,11 +946,12 @@ fn draw_text_selection( let range = ts.range(); // Selected table cells: tint every selected cell. if let Some(sel) = cells.filter(|c| c.story == ts.story) { + let selected = doc.story(sel.story).and_then(|s| s.tables.get(&sel.table)).map(|t| t.selected_owners(sel.range)).unwrap_or_default(); for ft in &cs.frames { let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame)) else { continue }; let m = a * doc.text_xf(it); for t in ft.tables.iter().filter(|t| t.table == sel.table) { - for c in t.cells.iter().filter(|c| sel.range.contains(c.row, c.col)) { + for c in t.cells.iter().filter(|c| selected.binary_search(&(c.row, c.col)).is_ok()) { let q = [ xf.to_screen(m * Point::new(c.rect.x0, c.rect.y0)), xf.to_screen(m * Point::new(c.rect.x1, c.rect.y0)), @@ -1805,6 +1806,79 @@ mod tests { (s.horizontal_units, s.vertical_units) } + #[test] + fn selected_merged_owners_paint_the_same_row_and_column_targets() { + for column in [true, false] { + for merged in [false, true] { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 1})).unwrap(); + app.run("frame.create", json!({"rect": [36, 36, 300, 300], "content": "text"})).unwrap(); + app.run("table.insert", json!({"rows": 2, "cols": 2})).unwrap(); + if merged { + let range = if column { json!({"rows": [0, 0], "cols": [0, 1]}) } else { json!({"rows": [0, 1], "cols": [0, 0]}) }; + app.run("table.select", range).unwrap(); + app.run("table.merge", json!({})).unwrap(); + } + app.run("table.select", json!({"rows": [1, 1], "cols": [1, 1]})).unwrap(); + app.run(if column { "table.selectColumn" } else { "table.selectRow" }, json!({})).unwrap(); + let st = app.session.doc().unwrap(); + let selection = st.selection.clone(); + let ts = selection.text.unwrap(); + let selected = selection.cells.unwrap(); + assert_eq!( + selected.range, + if column { designcraft_doc::CellRange::new(0, 1, 1, 1) } else { designcraft_doc::CellRange::new(1, 0, 1, 1) } + ); + let layout = CanvasLayout::new(&st.doc, st.editing_parents); + let cs = app.session.cache.get(&st.doc, ts.story, None); + let ft = &cs.frames[0]; + let table = ft.tables.iter().find(|t| t.table == selected.table).unwrap(); + let (a, _) = item_canvas_xf(&st.doc, &layout, ft.frame).unwrap(); + let m = a * st.doc.text_xf(st.doc.item(ft.frame).unwrap()); + let xf = Xf { min: pos2(0.0, 0.0), origin: Point::new(0.0, 0.0), zoom: 1.0, rot: 0, pivot: pos2(0.0, 0.0) }; + let owners = if merged { + [(0, 0), (1, 1)] + } else if column { + [(0, 1), (1, 1)] + } else { + [(1, 0), (1, 1)] + }; + let expected: Vec<_> = owners + .into_iter() + .map(|(r, c)| { + let rect = table.cells.iter().find(|cell| cell.row == r && cell.col == c).unwrap().rect; + vec![ + xf.to_screen(m * Point::new(rect.x0, rect.y0)), + xf.to_screen(m * Point::new(rect.x1, rect.y0)), + xf.to_screen(m * Point::new(rect.x1, rect.y1)), + xf.to_screen(m * Point::new(rect.x0, rect.y1)), + ] + }) + .collect(); + let ctx = egui::Context::default(); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + draw_text_selection(&app, ui.painter(), &xf, &st.doc, &layout, ts, Some(selected)); + }); + output.textures_delta.clear(); + let tint = Color32::from_rgba_unmultiplied(80, 140, 255, 90); + let painted: Vec<_> = output + .shapes + .into_iter() + .filter_map(|s| match s.shape { + egui::Shape::Path(path) if path.fill == tint => { + assert!(path.closed); + assert_eq!(path.stroke, egui::epaint::PathStroke::NONE); + Some(path.points) + } + _ => None, + }) + .collect(); + assert_eq!(painted, expected, "only the two touched owners are tinted; column={column}, merged={merged}"); + assert_eq!(app.session.doc().unwrap().selection, selection); + } + } + } + #[test] fn right_clicking_a_ruler_sets_that_rulers_units() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From 3539a72b2f30e7f42031a65452d4ad69473d6463 Mon Sep 17 00:00:00 2001 From: noah1400 <48360809+noah1400@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:23:48 +0200 Subject: [PATCH 170/238] Canvas: baseline grid draws only the lines on the page (#844) The grid was walked from its start, one line per step, with no bound. A start far above the page (from document.preferences, a saved file or an IDML package) made the next frame draw lines until the app ran out of memory. The lines are now the ones inside the page, found without walking those above it, and never more than one per point of the tallest page. --- crates/ui-egui/src/canvas.rs | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 077ae2944..45cfbbe68 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -584,6 +584,17 @@ fn dashed(painter: &egui::Painter, a: Pos2, b: Pos2, stroke: Stroke, dash: f32, painter.extend(egui::Shape::dashed_line(&[a, b], stroke, dash, gap)); } +/// The most baseline grid lines drawn on a page: one per point of the tallest page (216 in). +const MAX_BASELINES: usize = 216 * 72; + +/// Baseline grid lines inside a page, measured from its top: `start + n * increment` for n >= 0 (the bottom +/// edge is not a line). Lines above the page are skipped without walking them, however far up the start is. +fn baseline_grid_lines(start: f64, increment: f64, height: f64) -> impl Iterator<Item = f64> { + let step = increment.max(1.0); + let first = if start < 0.0 { start.rem_euclid(step) } else { start }; + (0..MAX_BASELINES).map(move |n| first + n as f64 * step).take_while(move |y| *y < height) +} + 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 { @@ -612,13 +623,11 @@ fn draw_guides(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document // Baseline grid. if app.ui.baseline_grid && xf.zoom >= s.baseline_grid.view_threshold { let g = &s.baseline_grid; - let mut y = g.start; - while y < p.height { + for y in baseline_grid_lines(g.start, g.increment, p.height) { painter.line_segment( [pt(Point::new(pr.x0, pr.y0 + y)), pt(Point::new(pr.x1, pr.y0 + y))], Stroke::new(hair(painter), c32(g.color).gamma_multiply(0.8)), ); - y += g.increment.max(1.0); } } // Margins (magenta) and columns (violet). @@ -1918,4 +1927,18 @@ mod tests { right_click(&mut h, pos2(rect.center().x, rect.min.y - RULER / 2.0)); assert!(h.query_by_label(" Points").is_none()); } + + #[test] + fn baseline_grid_lines_are_the_ones_on_the_page() { + // A start far above the page used to be walked line by line, without end. + for start in [-1e9, -1e17] { + let mut far = baseline_grid_lines(start, 12.0, 792.0); + assert_eq!((far.next(), far.count()), (Some(8.0), 65)); + } + let lines = |start, increment, height| baseline_grid_lines(start, increment, height).collect::<Vec<_>>(); + assert_eq!(lines(36.0, 12.0, 72.0), [36.0, 48.0, 60.0], "the bottom edge is not a line"); + assert_eq!(lines(-30.0, 12.0, 30.0), [6.0, 18.0], "a start above the page keeps its rhythm"); + assert_eq!(lines(f64::NAN, 12.0, 792.0), [0.0; 0]); + assert_eq!(baseline_grid_lines(0.0, 12.0, f64::INFINITY).count(), MAX_BASELINES); + } } From 129adfa42921d166b962e7b554e32aa2ba8bb90d Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Tue, 13 Oct 2026 20:41:56 +0000 Subject: [PATCH 171/238] Layout: Table of Contents dialog with entry styles, page number position and IDML TOC styles (#755) --- ROADMAP.md | 1 + crates/doc/src/lib.rs | 27 ++++ crates/engine/src/cmd/toc.rs | 220 +++++++++++++++++++++++--- crates/engine/src/tests_idml.rs | 30 ++++ crates/idml/src/export.rs | 46 +++++- crates/idml/src/import.rs | 43 ++++- crates/idml/src/names.rs | 26 ++++ crates/ui-egui/src/dialogs.rs | 267 +++++++++++++++++++++++++++++++- crates/ui-egui/src/menus.rs | 4 + docs/target-app-parity.md | 2 +- 10 files changed, 641 insertions(+), 25 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..b61dc9530 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-13** · Layout ▸ Table of Contents opens a real dialog: title and title style, Include Paragraph Styles and Other Styles lists with Add and Remove, and per style its entry style, page number position (after, before, none), separator and level. Replace Existing regenerates the current table of contents in place; otherwise OK makes a new frame on the page in view. IDML export writes the definition as a TOC style that import reads back, so Update works after a round trip. No readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index a643cc08d..337b024f4 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -584,6 +584,30 @@ pub struct TocEntry { /// 1-based nesting level. #[serde(default = "one")] pub level: u8, + /// Paragraph style of this style's entries; `None` is the generated "TOC Level n" style. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub entry_style: Option<String>, + #[serde(default)] + pub page_number: TocPageNumber, + /// Text between the entry and its page number; `None` is a tab. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub separator: Option<String>, +} + +impl TocEntry { + pub fn new(style: impl Into<String>, level: u8) -> Self { + Self { style: style.into(), level, entry_style: None, page_number: TocPageNumber::After, separator: None } + } +} + +/// Where an entry's page number goes. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum TocPageNumber { + #[default] + After, + Before, + None, } fn one() -> u8 { @@ -598,6 +622,9 @@ pub struct Toc { pub entries: Vec<TocEntry>, #[serde(default)] pub page_numbers: bool, + /// Paragraph style of the title; `None` is the generated "TOC Title" style. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title_style: Option<String>, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/engine/src/cmd/toc.rs b/crates/engine/src/cmd/toc.rs index d6ed53d6a..30ef0e875 100644 --- a/crates/engine/src/cmd/toc.rs +++ b/crates/engine/src/cmd/toc.rs @@ -1,9 +1,9 @@ //! Layout → Table of Contents: list the paragraphs of chosen styles with their page names, as a -//! story with "TOC Title" / "TOC Level n" styles (right tab with dot leader). Update Table of -//! Contents regenerates the same story after edits. +//! story with "TOC Title" / "TOC Level n" styles (right tab with dot leader) or the entry styles +//! chosen per style. Update Table of Contents regenerates the same story after edits. use designcraft_compose::{ComposeOptions, caret, compose_story}; -use designcraft_doc::{Align, Document, ParaAttrs, ParaFormat, ParagraphStyle, SpreadRef, StoryId, TabAlign, TabStop, Toc, TocEntry}; +use designcraft_doc::{Align, Document, ParaAttrs, ParaFormat, ParagraphStyle, SpreadRef, StoryId, TabAlign, TabStop, Toc, TocEntry, TocPageNumber}; use designcraft_geom::Rect; use serde_json::{Value, json}; @@ -17,7 +17,7 @@ pub fn specs() -> Vec<CommandSpec> { "Table of Contents…", ["Layout"], None, - "{entries: [{style, level?: 1}] | styles: [names], title?: \"Contents\", pageNumbers?: true, page?: 1-based page for a new frame (its margins), rect?: [x0,y0,x1,y1] (spread coords)} → {story, entries}", + "{entries: [{style, level?: 1 (1-9), entryStyle?: paragraph style of its entries (default \"TOC Level n\"), pageNumber?: \"after\"|\"before\"|\"none\", separator?: text between entry and number (default a tab)}] | styles: [names] (levels 1, 2, …), title?: \"Contents\", titleStyle?: paragraph style of the title (default \"TOC Title\"), pageNumbers?: true, replace?: true (regenerate the existing table of contents in place; false makes a new frame), page?: 1-based page for a new frame (its margins), rect?: [x0,y0,x1,y1] (spread coords)} → {story, entries}", has_doc, generate ), @@ -44,20 +44,66 @@ fn has_toc(s: &Session) -> std::result::Result<(), String> { } } +const MAX_ENTRIES: usize = 64; +const MAX_NAME: usize = 256; +const MAX_TITLE: usize = 1024; +const MAX_SEPARATOR: usize = 64; + fn entries_param(p: &Value) -> Result<Vec<TocEntry>> { - if let Some(v) = p.get("entries") { - return serde_json::from_value(v.clone()).map_err(|e| bad("toc.generate", e.to_string())); + let mut entries: Vec<TocEntry> = if let Some(v) = p.get("entries") { + if v.as_array().is_some_and(|a| a.len() > MAX_ENTRIES) { + return Err(bad("toc.generate", format!("at most {MAX_ENTRIES} entries"))); + } + serde_json::from_value(v.clone()).map_err(|e| bad("toc.generate", e.to_string()))? + } else if let Some(a) = p.get("styles").and_then(Value::as_array) { + if a.len() > MAX_ENTRIES { + return Err(bad("toc.generate", format!("at most {MAX_ENTRIES} styles"))); + } + a.iter().enumerate().filter_map(|(i, v)| v.as_str().map(|s| TocEntry::new(s, (i + 1).min(9) as u8))).collect() + } else { + return Err(bad("toc.generate", "give `entries` or `styles`")); + }; + for e in &mut entries { + if !(1..=9).contains(&e.level) { + return Err(bad("toc.generate", format!("level {} of `{}` is not 1–9", e.level, e.style))); + } + if e.style.chars().count() > MAX_NAME || e.entry_style.as_ref().is_some_and(|n| n.chars().count() > MAX_NAME) { + return Err(bad("toc.generate", format!("style names are at most {MAX_NAME} characters"))); + } + if let Some(sep) = &mut e.separator { + if sep.chars().count() > MAX_SEPARATOR { + return Err(bad("toc.generate", format!("the separator is at most {MAX_SEPARATOR} characters"))); + } + *sep = one_line(sep); + } + // The defaults are stored as `None`, so a definition reads the same however it was made. + if e.entry_style.as_deref() == Some("") { + e.entry_style = None; + } + if e.separator.as_deref() == Some("\t") { + e.separator = None; + } } - if let Some(a) = p.get("styles").and_then(Value::as_array) { - return Ok(a.iter().enumerate().filter_map(|(i, v)| v.as_str().map(|s| TocEntry { style: s.into(), level: (i + 1).min(9) as u8 })).collect()); + Ok(entries) +} + +/// Paragraph breaks in a title or separator would shift the paragraph styles after them. +fn one_line(s: &str) -> String { + s.chars().map(|c| if matches!(c, '\n' | '\r' | '\u{2029}') { ' ' } else { c }).collect() +} + +fn para_style_exists(d: &Document, name: &str) -> Result<()> { + match d.styles.para(name) { + Some(_) => Ok(()), + None => Err(bad("toc.generate", format!("no paragraph style `{name}`"))), } - Err(bad("toc.generate", "give `entries` or `styles`")) } pub struct Found { pub level: u8, pub text: String, pub page: String, + entry: usize, key: (usize, i64, i64), } @@ -70,7 +116,7 @@ pub fn collect(d: &Document, entries: &[TocEntry], skip: Option<StoryId>) -> Vec } let cs = compose_story(d, *sid, &ComposeOptions::default()); for (pi, r) in st.para_ranges().into_iter().enumerate() { - let Some(e) = entries.iter().find(|e| e.style == st.paras[pi].style) else { continue }; + let Some((ei, e)) = entries.iter().enumerate().find(|(_, e)| e.style == st.paras[pi].style) else { continue }; let text: String = st.text[r.clone()] .chars() .filter(|c| !('\u{E000}'..='\u{E1FF}').contains(c)) @@ -95,6 +141,7 @@ pub fn collect(d: &Document, entries: &[TocEntry], skip: Option<StoryId>) -> Vec out.push(Found { level: e.level, text, + entry: ei, page: d.page_name(abs), key: (abs, ((b.y0 + baseline) * 10.0) as i64, ((b.x0 + x) * 10.0) as i64), }); @@ -146,22 +193,35 @@ fn fill(d: &mut Document, toc: &Toc, width: Option<f64>) -> Result<usize> { d.story(toc.story).and_then(|st| st.frames.first()).and_then(|f| d.item(*f)).map(|i| i.text_area().width()).unwrap_or(300.0) }); ensure_styles(d, levels, width); + for name in toc.entries.iter().filter_map(|e| e.entry_style.as_deref()).chain(toc.title_style.as_deref()) { + para_style_exists(d, name)?; + } let mut text = String::new(); let mut styles = Vec::new(); if !toc.title.is_empty() { text.push_str(&toc.title); - styles.push("TOC Title".to_string()); + styles.push(toc.title_style.clone().unwrap_or_else(|| "TOC Title".into())); } for f in &found { + let Some(e) = toc.entries.get(f.entry) else { continue }; if !text.is_empty() { text.push('\n'); } - text.push_str(&f.text); - if toc.page_numbers { - text.push('\t'); - text.push_str(&f.page); + let sep = e.separator.as_deref().unwrap_or("\t"); + match e.page_number { + TocPageNumber::After if toc.page_numbers => { + text.push_str(&f.text); + text.push_str(sep); + text.push_str(&f.page); + } + TocPageNumber::Before if toc.page_numbers => { + text.push_str(&f.page); + text.push_str(sep); + text.push_str(&f.text); + } + _ => text.push_str(&f.text), } - styles.push(format!("TOC Level {}", f.level.max(1))); + styles.push(e.entry_style.clone().unwrap_or_else(|| format!("TOC Level {}", f.level.max(1)))); } let st = d.story_mut(toc.story).ok_or(designcraft_doc::DocError::NoStory(toc.story))?; let len = st.len(); @@ -179,13 +239,29 @@ fn generate(s: &mut Session, p: &Value) -> Result<Value> { if entries.is_empty() { return Err(bad("toc.generate", "no entries")); } - let title = str_param(p, "title").unwrap_or("Contents").to_string(); + let title = str_param(p, "title").unwrap_or("Contents"); + if title.chars().count() > MAX_TITLE { + return Err(bad("toc.generate", format!("the title is at most {MAX_TITLE} characters"))); + } + let title = one_line(title); + let title_style = str_param(p, "titleStyle").filter(|n| !n.is_empty()).map(str::to_string); + if title_style.as_ref().is_some_and(|n| n.chars().count() > MAX_NAME) { + return Err(bad("toc.generate", format!("style names are at most {MAX_NAME} characters"))); + } let page_numbers = p.get("pageNumbers").and_then(Value::as_bool).unwrap_or(true); + let replace = p.get("replace").and_then(Value::as_bool).unwrap_or(true); let rect = super::rect_param(p, "rect"); - let page = p.get("page").and_then(Value::as_u64).unwrap_or(1).max(1) as usize - 1; + if rect.is_some_and(|r| ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) || r.width() <= 0.0 || r.height() <= 0.0) { + return Err(bad("toc.generate", "`rect` must be finite with a positive size")); + } + let page = p.get("page").and_then(Value::as_u64).map_or(Some(0), |n| usize::try_from(n.max(1) - 1).ok()); + let page = page.ok_or_else(|| bad("toc.generate", "no such page"))?; s.edit(|d, sel| { - // Reuse the existing TOC story when there is one; otherwise make a frame. - let existing = d.toc.as_ref().map(|t| t.story).filter(|sid| d.story(*sid).is_some()); + for e in &entries { + para_style_exists(d, &e.style)?; + } + // Replace: regenerate the existing TOC story in place; otherwise make a frame. + let existing = d.toc.as_ref().map(|t| t.story).filter(|sid| replace && d.story(*sid).is_some()); let (story, width) = match existing { Some(sid) => (sid, None), None => { @@ -198,7 +274,7 @@ fn generate(s: &mut Session, p: &Value) -> Result<Value> { (sid, w) } }; - let toc = Toc { story, title: title.clone(), entries: entries.clone(), page_numbers }; + let toc = Toc { story, title: title.clone(), entries: entries.clone(), page_numbers, title_style: title_style.clone() }; let n = fill(d, &toc, width)?; d.toc = Some(toc); Ok(json!({"story": story.0, "entries": n})) @@ -237,4 +313,106 @@ mod tests { assert!(text.contains("Thoughts on the Grid"), "{text}"); s.doc().unwrap().doc.check().unwrap(); } + + fn sample_with_toc_page() -> (Session, usize) { + let mut s = Session::new(); + s.execute("file.newSample", &json!({})).unwrap(); + let pages = s.doc().unwrap().doc.page_count(); + s.execute("layout.pages.insert", &json!({"count": 1})).unwrap(); + (s, pages + 1) + } + + fn toc_lines(s: &Session) -> Vec<(String, String)> { + let d = &s.doc().unwrap().doc; + let st = d.story(d.toc.as_ref().unwrap().story).unwrap(); + st.para_ranges().into_iter().zip(&st.paras).map(|(r, f)| (st.text[r].trim_end_matches('\n').to_string(), f.style.clone())).collect() + } + + #[test] + fn toc_entry_style_page_number_position_and_separator_survive_update() { + let (mut s, page) = sample_with_toc_page(); + let basic = designcraft_doc::story::BASIC_PARAGRAPH; + s.execute( + "toc.generate", + &json!({"page": page, "title": "In This Issue", "titleStyle": basic, "entries": [ + {"style": "Cover Title", "level": 1, "pageNumber": "before", "separator": " — "}, + {"style": "Headline", "level": 2, "entryStyle": basic, "pageNumber": "none"}, + ]}), + ) + .unwrap(); + let check = |s: &Session, headline: &str| { + let lines = toc_lines(s); + assert_eq!(lines[0], ("In This Issue".to_string(), basic.to_string())); + let cover = lines.iter().find(|(t, _)| t.contains("The Quiet")).unwrap(); + assert!(cover.0.starts_with("1 — The Quiet"), "{cover:?}"); + assert_eq!(cover.1, "TOC Level 1"); + let head = lines.iter().find(|(t, _)| t.contains("on the Grid")).unwrap(); + assert!(head.0.starts_with(headline) && !head.0.contains('\t'), "{head:?}"); + assert_eq!(head.1, basic); + }; + check(&s, "Notes on the Grid"); + let d = &s.doc().unwrap().doc; + let hid = d.stories.iter().find(|(_, st)| st.text.starts_with("Notes on the Grid")).map(|(id, _)| *id).unwrap(); + s.execute("story.replaceRange", &json!({"story": hid.0, "start": 0, "end": 5, "text": "Thoughts"})).unwrap(); + s.execute("toc.update", &json!({})).unwrap(); + check(&s, "Thoughts on the Grid"); + } + + #[test] + fn replace_regenerates_the_toc_in_place_and_off_makes_a_new_frame() { + let (mut s, page) = sample_with_toc_page(); + let stories = |s: &Session| s.doc().unwrap().doc.stories.len(); + let first = s.execute("toc.generate", &json!({"styles": ["Headline"], "page": page})).unwrap()["story"].clone(); + let n = stories(&s); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute("toc.generate", &json!({"styles": ["Headline"], "title": "Inside", "page": page, "replace": true})).unwrap(); + assert_eq!(r["story"], first); + assert_eq!(stories(&s), n, "one table of contents"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + assert_eq!(toc_lines(&s)[0].0, "Inside"); + let r = s.execute("toc.generate", &json!({"styles": ["Headline"], "page": page, "replace": false})).unwrap(); + assert_ne!(r["story"], first); + assert_eq!(stories(&s), n + 1); + assert_eq!(s.doc().unwrap().doc.toc.as_ref().unwrap().story.0, r["story"].as_u64().unwrap()); + } + + #[test] + fn hostile_toc_params_are_errors_that_leave_the_document_alone() { + let (mut s, page) = sample_with_toc_page(); + let long = "x".repeat(5000); + let many: Vec<Value> = (0..65).map(|_| json!({"style": "Headline"})).collect(); + for p in [ + json!({"entries": [{"style": "Headline", "level": 0}]}), + json!({"entries": [{"style": "Headline", "level": 10}]}), + json!({"entries": [{"style": "Headline", "level": 300}]}), + json!({"entries": [{"style": "Headline", "level": -1}]}), + json!({"entries": [{"style": "Headline", "pageNumber": "sideways"}]}), + json!({"entries": [{"style": "No Such Style"}]}), + json!({"entries": [{"style": "Headline", "entryStyle": "No Such Style"}]}), + json!({"entries": [{"style": "Headline", "separator": long}]}), + json!({"entries": [{"style": long}]}), + json!({"entries": many}), + json!({"entries": "Headline"}), + json!({"entries": []}), + json!({"styles": ["Headline"], "titleStyle": "No Such Style"}), + json!({"styles": ["Headline"], "title": long}), + json!({"styles": ["Headline"], "rect": [100, 100, 50, 50]}), + json!({"styles": ["Headline"], "page": u64::MAX}), + json!([1, 2]), + ] { + let rev = s.doc().unwrap().revision; + assert!(s.execute("toc.generate", &merge(&p, page)).is_err(), "{p}"); + assert_eq!(s.doc().unwrap().revision, rev, "{p}"); + assert!(s.doc().unwrap().doc.toc.is_none()); + } + } + + /// `p` with the TOC page added when it is an object and has none. + fn merge(p: &Value, page: usize) -> Value { + let mut p = p.clone(); + if let Some(o) = p.as_object_mut() { + o.entry("page").or_insert(json!(page)); + } + p + } } diff --git a/crates/engine/src/tests_idml.rs b/crates/engine/src/tests_idml.rs index cc40768ea..143e46699 100644 --- a/crates/engine/src/tests_idml.rs +++ b/crates/engine/src/tests_idml.rs @@ -251,3 +251,33 @@ fn named_lists_round_trip_through_idml() { assert_eq!(st.paras[0].para.list_name.as_deref(), Some("Steps")); assert_eq!(st.paras[0].para.start_at, Some(Some(5))); } + +#[test] +fn table_of_contents_definition_round_trips_through_idml_and_updates() { + let mut s = Session::new(); + s.execute("file.newSample", &json!({})).unwrap(); + let page = s.doc().unwrap().doc.page_count() + 1; + s.execute("layout.pages.insert", &json!({"count": 1})).unwrap(); + let basic = designcraft_doc::story::BASIC_PARAGRAPH; + s.execute( + "toc.generate", + &json!({"page": page, "title": "Inside", "titleStyle": basic, "entries": [ + {"style": "Cover Title", "level": 1, "pageNumber": "before", "separator": " ^ "}, + {"style": "Headline", "level": 2, "entryStyle": basic, "pageNumber": "none"}, + ]}), + ) + .unwrap(); + let toc_text = |s: &Session| { + let d = &s.doc().unwrap().doc; + d.story(d.toc.as_ref().unwrap().story).unwrap().text.clone() + }; + let before = s.doc().unwrap().doc.toc.clone().unwrap(); + let text = toc_text(&s); + let b64 = s.execute("file.exportIdml", &json!({})).unwrap()["base64"].as_str().unwrap().to_string(); + s.execute("file.openIdml", &json!({"base64": b64, "name": "toc.idml"})).unwrap(); + let after = s.doc().unwrap().doc.toc.clone().expect("the TOC definition is imported"); + assert_eq!((&after.title, &after.title_style, &after.entries), (&before.title, &before.title_style, &before.entries)); + assert_eq!(toc_text(&s), text); + s.execute("toc.update", &json!({})).unwrap(); + assert_eq!(toc_text(&s), text); +} diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index d01abe9e7..2d1bf32d8 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -16,6 +16,8 @@ use crate::{DOM_VERSION, MIMETYPE, base64_encode}; const PKG_NS: &str = "http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging"; const NO_PARA_ID: &str = "ParagraphStyle/$ID/[No paragraph style]"; +const TOC_STYLE_ID: &str = "TOCStyle/Table of Contents"; +const TOC_STYLE_NAME: &str = "Table of Contents"; const NO_CHAR_ID: &str = "CharacterStyle/$ID/[No character style]"; /// Export options. @@ -1049,9 +1051,46 @@ impl<'a> Ex<'a> { let mut ro = El::new("RootObjectStyleGroup").attr("Self", self.fresh()); og.emit(&mut ro, "ObjectStyleGroup", ""); root.push(ro); + if let Some(toc) = d.toc.as_ref().filter(|t| d.story(t.story).is_some()) { + root.push(self.toc_style_el(toc)); + } document(&root) } + /// The document's table of contents as a TOC style; its story names it in `AppliedTOCStyle`. + fn toc_style_el(&mut self, toc: &designcraft_doc::Toc) -> El { + let para = |n: &str| names::style_self("ParagraphStyle", PARA_BUILTINS, n); + let mut el = El::new("TOCStyle") + .attr("Self", TOC_STYLE_ID) + .attr("Name", TOC_STYLE_NAME) + .attr("Title", &toc.title) + .attr("TitleStyle", para(toc.title_style.as_deref().unwrap_or("TOC Title"))) + .attr("RunIn", "false") + .attr("IncludeHidden", "false") + .attr("IncludeBookDocuments", "false") + .attr("CreatePdfBookmarks", "false"); + for e in &toc.entries { + let format = e.entry_style.clone().unwrap_or_else(|| format!("TOC Level {}", e.level)); + let position = match e.page_number { + _ if !toc.page_numbers => "NoPageNumber", + designcraft_doc::TocPageNumber::After => "AfterEntry", + designcraft_doc::TocPageNumber::Before => "BeforeEntry", + designcraft_doc::TocPageNumber::None => "NoPageNumber", + }; + el.push( + El::new("TOCStyleEntry") + .attr("Self", self.fresh()) + .attr("Name", names::style_name_out(PARA_BUILTINS, &e.style)) + .attr("FormatStyle", para(&format)) + .attr("Level", e.level) + .attr("PageNumberPosition", position) + .attr("Separator", names::toc_separator_out(e.separator.as_deref().unwrap_or("\t"))) + .attr("Sort", "false"), + ); + } + el + } + fn char_attrs(&mut self, el: &mut El, props: &mut Vec<El>, a: &CharAttrs) { if let Some(v) = a.leading_aki { el.set("LeadingAki", num(v.unwrap_or(-1.0))); @@ -1923,7 +1962,12 @@ impl<'a> Ex<'a> { // ---------- stories ---------- fn story_el(&mut self, s: &Story) -> El { - let mut el = El::new("Story").attr("Self", uid(s.id.0)).attr("AppliedTOCStyle", "n").attr("TrackChanges", "false").attr("StoryTitle", "$ID/"); + let toc = self.d.toc.as_ref().is_some_and(|t| t.story == s.id); + let mut el = El::new("Story") + .attr("Self", uid(s.id.0)) + .attr("AppliedTOCStyle", if toc { TOC_STYLE_ID } else { "n" }) + .attr("TrackChanges", "false") + .attr("StoryTitle", "$ID/"); el.push( El::new("StoryPreference") .attr("OpticalMarginAlignment", "false") diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 59ca44be7..c7fe2aec6 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -209,6 +209,7 @@ struct Importer<'r> { /// Index topic Self → topic path and per-level sort keys; topic cross-references. index_topics: HashMap<String, IndexTopic>, index_xrefs: Vec<designcraft_doc::IndexRef>, + toc: Option<designcraft_doc::Toc>, } /// IDML stores a sort key on each topic, rather than on its page references. @@ -312,6 +313,7 @@ impl<'r> Importer<'r> { xref_formats: designcraft_doc::xref::default_formats(), index_topics: HashMap::new(), index_xrefs: Vec::new(), + toc: None, } } @@ -485,6 +487,7 @@ impl<'r> Importer<'r> { let story = self.story(id, e); self.stories.insert(id, story); } + self.toc = self.toc_of(top); // Topic cross-references (See / See also) become markers at the start of the first story // with text. if !self.index_xrefs.is_empty() @@ -1140,6 +1143,44 @@ impl<'r> Importer<'r> { self.footnote_options = o; } + /// The table of contents: the TOC style a story is generated from (`AppliedTOCStyle`), with + /// its entries. Styles the document doesn't have fall back to the generated TOC styles. + fn toc_of(&self, top: &[El]) -> Option<designcraft_doc::Toc> { + use designcraft_doc::{TocEntry, TocPageNumber}; + let (ts, story) = top.iter().filter(|e| e.local() == "TOCStyle").find_map(|ts| { + let id = ts.get("Self")?; + let story = top.iter().find(|e| e.local() == "Story" && e.get("AppliedTOCStyle") == Some(id))?; + Some((ts, *self.story_ids.get(story.get("Self")?)?)) + })?; + let known = |r: Option<&str>| r.map(|r| self.para_style_ref(r)).filter(|n| self.styles.para(n).is_some()); + let entries: Vec<TocEntry> = ts + .elements() + .filter(|e| e.local() == "TOCStyleEntry") + .filter_map(|e| { + let style = names::style_name_in(PARA_BUILTINS, e.get("Name")?); + if style.is_empty() || style.chars().count() > 256 { + return None; + } + let level = e.get("Level").and_then(|l| l.trim().parse::<u8>().ok()).unwrap_or(1).clamp(1, 9); + let page_number = match e.get("PageNumberPosition") { + Some("BeforeEntry") => TocPageNumber::Before, + Some("NoPageNumber") => TocPageNumber::None, + _ => TocPageNumber::After, + }; + let separator = e.get("Separator").map(crate::names::toc_separator_in).filter(|s| s != "\t" && s.chars().count() <= 64); + let entry_style = known(e.get("FormatStyle")).filter(|n| *n != format!("TOC Level {level}")); + Some(TocEntry { style, level, entry_style, page_number, separator }) + }) + .take(64) + .collect(); + if entries.is_empty() { + return None; + } + let title: String = ts.get("Title").unwrap_or("").chars().filter(|c| !matches!(c, '\n' | '\r' | '\u{2029}')).take(1024).collect(); + let title_style = known(ts.get("TitleStyle")).filter(|n| n != "TOC Title"); + Some(designcraft_doc::Toc { story, title, entries, page_numbers: true, title_style }) + } + fn para_style_ref(&self, r: &str) -> String { self.para_names.get(r).cloned().unwrap_or_else(|| names::style_name_in(PARA_BUILTINS, &unescape_id(r.trim_start_matches("ParagraphStyle/")))) } @@ -2434,7 +2475,7 @@ impl<'r> Importer<'r> { bookmarks: vec![], user_words: vec![], hyphenation_exceptions: vec![], - toc: None, + toc: self.toc.take(), text_variables: designcraft_doc::vars::defaults(), footnote_options: std::mem::take(&mut self.footnote_options), xref_formats: std::mem::take(&mut self.xref_formats), diff --git a/crates/idml/src/names.rs b/crates/idml/src/names.rs index 1df0f56d5..3d863ef30 100644 --- a/crates/idml/src/names.rs +++ b/crates/idml/src/names.rs @@ -689,3 +689,29 @@ pub fn digits_in(s: &str) -> Option<designcraft_doc::Digits> { use designcraft_doc::Digits; [Digits::Default, Digits::Arabic, Digits::Hindi, Digits::Farsi, Digits::Native].into_iter().find(|d| digits_out(*d) == s) } + +/// A TOC separator in IDML's special-character notation: `^t` is a tab, `^^` a caret. +pub fn toc_separator_out(s: &str) -> String { + s.replace('^', "^^").replace('\t', "^t") +} +/// IDML TOC separator → text (`^t` and `^y`, the right indent tab, are tabs; other codes stay). +pub fn toc_separator_in(s: &str) -> String { + let mut out = String::new(); + let mut it = s.chars(); + while let Some(c) = it.next() { + if c != '^' { + out.push(c); + continue; + } + match it.next() { + Some('t' | 'y') => out.push('\t'), + Some('^') => out.push('^'), + Some(o) => { + out.push('^'); + out.push(o); + } + None => out.push('^'), + } + } + out +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 695a66af1..d8178f363 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2,7 +2,7 @@ //! (`ui.dialog.set {field, value}`) and confirm them (`ui.dialog.confirm`) like a user would. use designcraft_geom::{Unit, format_measure, units::parse_measure}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use serde_json::{Map, Value, json}; use crate::DesignApp; @@ -1657,6 +1657,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { }); } STEP_AND_REPEAT => step_and_repeat_dialog(app, ui, &mut d), + TOC => toc_dialog(app, ui, &mut d), id if id.starts_with("cmd:") => command_form(app, ui, &mut d), "goToPage" => { ui.horizontal(|ui| { @@ -2188,6 +2189,15 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> { } r } + TOC => { + let r = app.run("toc.generate", toc_params(app, &d)); + if let Err(e) = &r { + let mut d = d.clone(); + d.fields.insert("status".into(), json!(e)); + app.ui.dialog = Some(d); + } + r + } id if id.starts_with("cmd:") => { let cid = &id[4..]; let doc = designcraft_engine::find_command(cid).map_or("", |c| c.params); @@ -3580,6 +3590,223 @@ fn command_form(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { } const STEP_AND_REPEAT: &str = "cmd:edit.stepAndRepeat"; +const TOC: &str = "cmd:toc.generate"; + +/// One included style of the Table of Contents dialog, as `toc.generate` takes it, with the +/// separator in the dialog's notation (`^t` a tab). +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", default)] +struct TocRow { + style: String, + level: u8, + entry_style: String, + page_number: String, + separator: String, +} + +fn toc_separator_shown(s: &str) -> String { + s.replace('\t', "^t").replace('\u{2003}', "^m").replace('\u{2002}', "^>") +} + +fn toc_separator_typed(s: &str) -> String { + s.replace("^t", "\t").replace("^m", "\u{2003}").replace("^>", "\u{2002}") +} + +fn toc_rows(d: &Dialog) -> Vec<TocRow> { + d.fields.get("included").cloned().and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default() +} + +/// Layout › Table of Contents…: prefilled from the document's table of contents when it has one. +pub fn open_toc(app: &mut DesignApp) { + let Some(st) = app.session.active() else { return }; + let d = &st.doc; + let styles: Vec<&str> = d.styles.paragraph.iter().map(|p| p.name.as_str()).filter(|n| *n != designcraft_doc::NO_PARA_STYLE).collect(); + let toc = d.toc.as_ref().filter(|t| d.story(t.story).is_some()); + let rows: Vec<TocRow> = toc + .map(|t| { + t.entries + .iter() + .map(|e| TocRow { + style: e.style.clone(), + level: e.level, + entry_style: e.entry_style.clone().unwrap_or_default(), + page_number: match (t.page_numbers, e.page_number) { + (false, _) | (_, designcraft_doc::TocPageNumber::None) => "none", + (_, designcraft_doc::TocPageNumber::Before) => "before", + _ => "after", + } + .into(), + separator: toc_separator_shown(e.separator.as_deref().unwrap_or("\t")), + }) + .collect() + }) + .unwrap_or_default(); + let fields = json!({ + "title": toc.map_or("Contents", |t| t.title.as_str()), + "titleStyle": toc.and_then(|t| t.title_style.clone()).unwrap_or_default(), + "included": rows, + "selected": 0, + "styles": styles, + "hasToc": toc.is_some(), + "replace": toc.is_some(), + }); + app.ui.dialog = Some(Dialog::new(TOC, fields)); +} + +/// The dialog's fields as `toc.generate` params. A new frame goes on the page in view. +fn toc_params(app: &DesignApp, d: &Dialog) -> Value { + let entries: Vec<Value> = toc_rows(d) + .iter() + .map(|r| { + let mut e = json!({"style": r.style, "level": r.level, "separator": toc_separator_typed(&r.separator)}); + if !r.entry_style.is_empty() { + e["entryStyle"] = json!(r.entry_style); + } + if !r.page_number.is_empty() { + e["pageNumber"] = json!(r.page_number); + } + e + }) + .collect(); + let mut p = json!({"title": d.s("title"), "entries": entries, "replace": d.b("replace")}); + let title_style = d.s("titleStyle"); + if !title_style.is_empty() { + p["titleStyle"] = json!(title_style); + } + if let Some(page) = crate::canvas::current_page(app) { + p["page"] = json!(page + 1); + } + p +} + +/// A paragraph style menu whose first choice, stored as "", is the generated style `auto`. +fn toc_style_combo(ui: &mut egui::Ui, salt: &str, styles: &[String], auto: &str, cur: &mut String) -> bool { + let shown = if cur.is_empty() { auto.to_string() } else { cur.clone() }; + let mut changed = false; + egui::ComboBox::from_id_salt(("toc_style", salt)).selected_text(shown).width(200.0).show_ui(ui, |ui| { + if ui.selectable_label(cur.is_empty(), auto).clicked() { + cur.clear(); + changed = true; + } + for n in styles.iter().filter(|n| *n != auto) { + if ui.selectable_label(cur == n, n.as_str()).clicked() { + *cur = n.clone(); + changed = true; + } + } + }); + changed +} + +/// Layout › Table of Contents: the title and its style, the paragraph styles to include (Add / +/// Remove between the two lists) and, for the selected one, its entry style, page number +/// position, separator and level. +fn toc_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + let lang = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang, s).to_string(); + let styles: Vec<String> = d.fields.get("styles").cloned().and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default(); + let mut rows = toc_rows(d); + let mut sel = d.n("selected").map_or(0, |n| n.max(0.0) as usize); + let mut other = d.s("other"); + let mut title_style = d.s("titleStyle"); + egui::Grid::new("toc_title").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, tr("Title:")); + text_field(ui, d, "title", 200.0); + crate::rtl::label(ui, tr("Style:")); + toc_style_combo(ui, "title", &styles, "TOC Title", &mut title_style); + ui.end_row(); + }); + ui.add_space(8.0); + crate::rtl::label(ui, egui::RichText::new(tr("Styles in Table of Contents")).font(semibold(12.0))); + let others: Vec<&String> = styles.iter().filter(|n| !rows.iter().any(|r| &r.style == *n)).collect(); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + crate::rtl::label(ui, tr("Include Paragraph Styles:")); + egui::Frame::group(ui.style()).show(ui, |ui| { + ui.set_width(200.0); + egui::ScrollArea::vertical().id_salt("toc_included").max_height(150.0).min_scrolled_height(150.0).show(ui, |ui| { + for (i, r) in rows.iter().enumerate() { + let indent = " ".repeat(usize::from(r.level.saturating_sub(1))); + if ui.selectable_label(i == sel, format!("{indent}{}", r.style)).clicked() { + sel = i; + } + } + }); + }); + }); + ui.vertical(|ui| { + ui.add_space(40.0); + if ui.button(tr("« Add")).clicked() && others.iter().any(|n| **n == other) { + let level = u8::try_from(rows.len() + 1).unwrap_or(9).min(9); + rows.push(TocRow { style: other.clone(), level, page_number: "after".into(), separator: "^t".into(), ..Default::default() }); + sel = rows.len() - 1; + other.clear(); + } + if ui.button(tr("Remove »")).clicked() && sel < rows.len() { + other = rows.remove(sel).style; + sel = sel.min(rows.len().saturating_sub(1)); + } + }); + ui.vertical(|ui| { + crate::rtl::label(ui, tr("Other Styles:")); + egui::Frame::group(ui.style()).show(ui, |ui| { + ui.set_width(200.0); + egui::ScrollArea::vertical().id_salt("toc_other").max_height(150.0).min_scrolled_height(150.0).show(ui, |ui| { + for n in styles.iter().filter(|n| !rows.iter().any(|r| &r.style == *n)) { + if ui.selectable_label(*n == other, n.as_str()).clicked() { + other = n.clone(); + } + } + }); + }); + }); + }); + if let Some(r) = rows.get_mut(sel) { + ui.add_space(8.0); + crate::rtl::label(ui, egui::RichText::new(format!("{} {}", tr("Style:"), r.style)).font(semibold(12.0))); + egui::Grid::new("toc_entry").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, tr("Entry Style:")); + let auto = format!("TOC Level {}", r.level.clamp(1, 9)); + toc_style_combo(ui, "entry", &styles, &auto, &mut r.entry_style); + ui.end_row(); + crate::rtl::label(ui, tr("Page Number:")); + let choices = [("after", "After Entry"), ("before", "Before Entry"), ("none", "No Page Number")]; + let cur = choices.iter().find(|(v, _)| *v == r.page_number).map_or("After Entry", |(_, l)| l); + egui::ComboBox::from_id_salt("toc_page_number").selected_text(tr(cur)).width(200.0).show_ui(ui, |ui| { + for (v, l) in choices { + if ui.selectable_label(r.page_number == v, tr(l)).clicked() { + r.page_number = v.into(); + } + } + }); + ui.end_row(); + crate::rtl::label(ui, tr("Between Entry and Number:")); + ui.add(egui::TextEdit::singleline(&mut r.separator).desired_width(80.0).horizontal_align(egui::Align::Min)); + ui.end_row(); + crate::rtl::label(ui, tr("Level:")); + egui::ComboBox::from_id_salt("toc_level").selected_text(r.level.to_string()).width(60.0).show_ui(ui, |ui| { + for l in 1..=9u8 { + if ui.selectable_label(r.level == l, l.to_string()).clicked() { + r.level = l; + } + } + }); + ui.end_row(); + }); + } + ui.add_space(8.0); + crate::rtl::label(ui, egui::RichText::new(tr("Options")).font(semibold(12.0))); + let has_toc = d.b("hasToc"); + ui.add_enabled_ui(has_toc, |ui| check(ui, d, "replace", &tr("Replace Existing Table of Contents"))); + if let Some(st) = d.fields.get("status").and_then(Value::as_str) { + ui.add_space(6.0); + crate::rtl::label(ui, egui::RichText::new(st).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); + } + d.fields.insert("included".into(), json!(rows)); + d.fields.insert("selected".into(), json!(sel)); + d.fields.insert("other".into(), json!(other)); + d.fields.insert("titleStyle".into(), json!(title_style)); +} /// The params a previewing dialog shows on the page (its interaction is open while it is set). const PREVIEWED: &str = "_previewed"; @@ -3954,6 +4181,44 @@ mod tests { assert_eq!(d.s("status"), "Invalid value: up"); } + #[test] + fn toc_dialog_adds_a_style_and_ok_makes_the_table_of_contents() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.newSample", json!({})).unwrap(); + crate::menus::activate(&mut app, "toc.generate", &Value::Null); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some(TOC)); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0)); + dialog_frame(&mut app, &ctx, screen, vec![]); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + for expected in + ["Table of Contents", "Title:", "Include Paragraph Styles:", "Other Styles:", "« Add", "Remove »", "Replace Existing Table of Contents"] + { + visible_label(&labels, screen, expected); + } + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "Headline").center()); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "« Add").center()); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + for expected in ["Entry Style:", "TOC Level 1", "After Entry", "Between Entry and Number:", "Level:"] { + visible_label(&labels, screen, expected); + } + let undo = app.session.doc().unwrap().history.undo.len(); + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "OK").center()); + assert!(app.ui.dialog.is_none()); + let d = &app.session.doc().unwrap().doc; + let toc = d.toc.clone().unwrap(); + assert_eq!(toc.entries, vec![designcraft_doc::TocEntry::new("Headline", 1)]); + let text = &d.story(toc.story).unwrap().text; + assert!(text.starts_with("Contents\n") && text.contains("\nNotes on the Grid\t"), "{text}"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1); + // Reopened, the dialog shows the table of contents and replaces it. + crate::menus::activate(&mut app, "toc.generate", &Value::Null); + let d = app.ui.dialog.as_ref().unwrap(); + assert!(d.b("replace")); + assert_eq!(toc_rows(d).iter().map(|r| r.style.as_str()).collect::<Vec<_>>(), ["Headline"]); + } + fn dialog_context(language: &str) -> egui::Context { let ctx = egui::Context::default(); crate::theme::install_fonts(&ctx, language); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 872cb81d0..4f33f9bbb 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1888,6 +1888,10 @@ pub fn activate(app: &mut DesignApp, id: &str, params: &Value) { crate::dialogs::open_document_setup(app); return; } + if params.is_null() && id == "toc.generate" { + crate::dialogs::open_toc(app); + return; + } if params.is_null() && id == "file.print" { crate::dialogs::open_print(app); return; diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index eb7d7e5c1..625cfb4a5 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -428,7 +428,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - 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. - Place PDF: placed PDF pages are sized by their crop box, drawn on screen through hayro (with mip levels) and embedded in exported PDFs as vector form XObjects (krilla), `<PDF>` in IDML. - Menus expose the engine's features (TOC, numbering & sections, text variables, hyperlinks, cross-references, footnotes, step and repeat, spelling, fitting/content, effects, Data Merge, Preflight, EPUB); any menu command with parameters gets a dialog generated from its parameter documentation. -- Table of contents (generate/update, dot leaders) and text variables (running headers, last page number, chapter number, file name, dates, custom) resolved per page. +- Table of contents (Layout ▸ Table of Contents dialog: included styles, per-style entry style, page number position, separator and level, title style, Replace Existing; generate/update, dot leaders; the definition round-trips through IDML as a TOC style) and text variables (running headers, last page number, chapter number, file name, dates, custom) resolved per page. - Soft effects: blurred drop shadow, inner shadow, outer glow, basic feather. - ~200 commands, all reachable through the JSON control channel; headless CLI rendering to PNG. - MCP server (`designcraft-cli mcp [--connect PORT]`, docs/mcp.md): headless engine or the running app; commands, batch, document/story inspection, page renders as images, window screenshots, pointer/keyboard/dialog input. From 1bddba0e7578b79a8eba2db7dbaba2175dfdc450 Mon Sep 17 00:00:00 2001 From: noah1400 <48360809+noah1400@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:07:21 +0200 Subject: [PATCH 172/238] Compose: cross-reference index no longer rebuilds itself (#851) The cross-reference index composed its destination stories through the cache. One with a running header asked for the running-header index, which composed the story holding the cross-reference, which asked for the cross-reference index again: the stack overflowed and the app aborted. Destination stories with a running header are now composed without it, like those with cross-references already were. --- crates/compose/src/xref.rs | 8 +++++--- crates/engine/src/cmd/xref.rs | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/crates/compose/src/xref.rs b/crates/compose/src/xref.rs index 4bce48d27..b33714203 100644 --- a/crates/compose/src/xref.rs +++ b/crates/compose/src/xref.rs @@ -19,15 +19,17 @@ pub fn has_xrefs(st: &Story) -> bool { } impl XrefIndex { - /// `get` composes a story (normally through the cache). Stories that themselves hold - /// cross-references are composed without resolving them (their anchors' pages hardly move). + /// `get` composes a story (normally through the cache). Stories that hold cross-references or + /// running headers are composed without resolving them (their anchors' pages hardly move): + /// through the cache they would build this index again, directly or by way of the running-header index. pub fn build(doc: &Document, get: &dyn Fn(StoryId) -> Arc<ComposedStory>) -> XrefIndex { let mut pages = HashMap::new(); for (sid, st) in &doc.stories { if st.anchors.is_empty() { continue; } - let cs = if has_xrefs(st) { Arc::new(crate::compose_story(doc, *sid, &Default::default())) } else { get(*sid) }; + let indexed = has_xrefs(st) || crate::vars::has_running(doc, st); + let cs = if indexed { Arc::new(crate::compose_story(doc, *sid, &Default::default())) } else { get(*sid) }; for a in &st.anchors { let Some(pos) = st.anchor_pos(a.id) else { continue }; if let Some((page, ..)) = crate::vars::place(doc, &cs, pos) { diff --git a/crates/engine/src/cmd/xref.rs b/crates/engine/src/cmd/xref.rs index b0b97ebd2..54e5dd6b6 100644 --- a/crates/engine/src/cmd/xref.rs +++ b/crates/engine/src/cmd/xref.rs @@ -214,4 +214,23 @@ mod tests { s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.execute("xref.list", &json!({})).unwrap()[0]["text"], "(p. 3)"); } + + #[test] + fn cross_reference_into_a_story_with_a_running_header_composes() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap(); + let head = s.execute("frame.create", &json!({"rect": [72, 72, 400, 120], "content": "text", "text": "Chapter"})).unwrap()["story"].clone(); + let body = s.execute("frame.create", &json!({"rect": [72, 200, 400, 300], "content": "text", "text": "See"})).unwrap()["story"].clone(); + s.execute("text.select", &json!({"story": head, "anchor": 0, "focus": 0})).unwrap(); + s.execute("variables.insert", &json!({"name": "Running Header"})).unwrap(); + s.execute("text.select", &json!({"story": body, "anchor": 3, "focus": 3})).unwrap(); + s.execute("xref.insert", &json!({"story": head, "para": 0})).unwrap(); + // The cross-reference index and the running-header index used to build each other without end. + assert_eq!(s.execute("xref.list", &json!({})).unwrap()[0]["text"], "\u{201C}Chapter\u{201D} on page 1"); + // The header shows the body's paragraph in front of its own text. + let d = s.doc().unwrap().doc.clone(); + let cs = s.cache.get(&d, StoryId(head.as_u64().unwrap()), None); + let glyphs: usize = cs.frames[0].lines.iter().map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum(); + assert_eq!(glyphs, "SeeChapter".len()); + } } From 0fd627aa44b268d935f04e1b094d46ba84815865 Mon Sep 17 00:00:00 2001 From: LabDennis <341377186+LabDennis@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:12:09 +0200 Subject: [PATCH 173/238] perf(assets): index duplicate candidates by content (#727) --- crates/doc/src/assets.rs | 64 ++++++++++++++++++++++++++++++++++++-- crates/format/src/lib.rs | 33 ++++++++++++++++++-- docs/file-format-parity.md | 5 +-- 3 files changed, 96 insertions(+), 6 deletions(-) diff --git a/crates/doc/src/assets.rs b/crates/doc/src/assets.rs index 24ba5c243..cff196fc0 100644 --- a/crates/doc/src/assets.rs +++ b/crates/doc/src/assets.rs @@ -5,6 +5,7 @@ //! shown); each placement's frame data (transform, crop, fitting) lives on its [`Graphic`]. use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::hash::BuildHasher; use std::sync::Arc; use crate::story::Story; @@ -246,10 +247,19 @@ impl Document { if !self.asset_refs(&mut |_| {}) { return 0; } - let mut reps: HashMap<(usize, &str, u32), Vec<&Arc<Asset>>> = HashMap::new(); + let fingerprint = std::collections::hash_map::RandomState::new(); + let mut hashes = HashMap::new(); + let mut reps: HashMap<_, Vec<&Arc<Asset>>> = HashMap::new(); let mut map = HashMap::new(); for a in self.assets.values() { - let bucket = reps.entry((a.data.len(), a.mime.as_str(), a.page)).or_default(); + // Empty, unlinked assets never compare equal. Don't build a quadratic bucket + // of them, and hash a shared immutable buffer only once per compaction. + if a.data.is_empty() && a.link.is_none() { + continue; + } + let hash = *hashes.entry(Arc::as_ptr(&a.data)).or_insert_with(|| fingerprint.hash_one(a.data.as_slice())); + let bucket = reps.entry((hash, a.mime.as_str(), a.page, a.link.as_deref())).or_default(); + // A fingerprint only selects candidates; equality still checks the actual file. match bucket.iter().find(|r| r.same_file(a)) { Some(r) => { map.insert(a.id, r.id); @@ -280,6 +290,56 @@ mod tests { Asset { id: AssetId(id), name: "p.png".into(), mime: "image/png".into(), link: None, data: Arc::new(data.to_vec()), pixels: None, page: 0 } } + #[test] + fn equal_size_assets_keep_distinct_contents_and_merge_exact_copies() { + let mut d = Document::new(&Default::default()); + for id in 1..=256u64 { + let mut bytes = vec![42; 1024]; + bytes[1016..].copy_from_slice(&id.to_le_bytes()); + d.assets.insert(AssetId(id), Arc::new(asset(id, &bytes))); + d.assets.insert(AssetId(id + 256), Arc::new(asset(id + 256, &bytes))); + } + assert_eq!(d.merge_duplicate_assets(), 256); + assert_eq!(d.assets.len(), 256); + for id in 1..=256u64 { + assert_eq!(d.assets[&AssetId(id)].data[1016..], id.to_le_bytes()); + } + } + + #[test] + fn compaction_preserves_asset_identity_and_missing_data_rules() { + let mut d = Document::new(&Default::default()); + let mut cases = vec![asset(1, b"same"), asset(2, b"same"), asset(3, b"same"), asset(4, b"same")]; + cases[1].mime = "application/pdf".into(); + cases[2].page = 1; + cases[3].link = Some("/linked.png".into()); + cases.extend([asset(5, b""), asset(6, b""), asset(7, b""), asset(8, b"")]); + cases[6].link = Some("/missing.png".into()); + cases[7].link = Some("/missing.png".into()); + for a in cases { + d.assets.insert(a.id, Arc::new(a)); + } + assert_eq!(d.merge_duplicate_assets(), 1); + assert_eq!(d.assets.keys().copied().collect::<Vec<_>>(), (1..=7).map(AssetId).collect::<Vec<_>>()); + } + + #[test] + #[cfg(not(target_arch = "wasm32"))] + #[ignore = "manual bounded timing comparison; no timing assertion"] + fn equal_size_assets_timing() { + for count in [500, 1000, 2000] { + let mut d = Document::new(&Default::default()); + for id in 1..=count as u64 { + let mut bytes = vec![42; 8192]; + bytes[8184..].copy_from_slice(&id.to_le_bytes()); + d.assets.insert(AssetId(id), Arc::new(asset(id, &bytes))); + } + let start = std::time::Instant::now(); + assert_eq!(d.merge_duplicate_assets(), 0); + eprintln!("{count} distinct 8 KiB assets: {:?}", start.elapsed()); + } + } + fn graphic_item(id: u64, asset: u64, crop: f64) -> Item { let mut it = Item::new(ItemId(id), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0))); it.content = Content::Graphic(Graphic { diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 408bb3ccc..c1b6d805a 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -11,6 +11,7 @@ #![forbid(unsafe_code)] use std::collections::{HashMap, HashSet}; +use std::hash::BuildHasher; use std::io::{Cursor, Read, Write}; use std::sync::Arc; @@ -107,12 +108,13 @@ fn load_zip(bytes: &[u8]) -> Result<Document, FormatError> { let names: Vec<String> = z.file_names().map(str::to_string).collect(); // Files saved with one copy per placement: identical copies share one buffer as they are // read, so memory holds each distinct file once. - let mut loaded: HashMap<usize, Vec<Arc<Vec<u8>>>> = HashMap::new(); + let fingerprint = std::collections::hash_map::RandomState::new(); + let mut loaded: HashMap<u64, Vec<Arc<Vec<u8>>>> = HashMap::new(); for name in names { let Some(stem) = name.strip_prefix("assets/") else { continue }; let Some(id) = stem.split('.').next().and_then(|s| s.parse::<u64>().ok()) else { continue }; if let (Some(data), Some(a)) = (read(&mut z, &name), doc.assets.get_mut(&AssetId(id))) { - let same = loaded.entry(data.len()).or_default(); + let same = loaded.entry(fingerprint.hash_one(data.as_slice())).or_default(); let data = match same.iter().find(|d| ***d == data) { Some(d) => d.clone(), None => { @@ -338,6 +340,33 @@ mod tests { } } + #[test] + fn equal_size_archive_assets_preserve_content_and_share_only_identical_buffers() { + let mut d = Document::new(&NewDocument::default()); + let mut pairs = Vec::new(); + for n in 0..128u64 { + let mut bytes = vec![42; 1024]; + bytes[1016..].copy_from_slice(&n.to_le_bytes()); + let a = png_asset(&mut d, &bytes); + let b = png_asset(&mut d, &bytes); + // Distinct links must remain independently relinkable even when their bytes match. + Arc::make_mut(d.assets.get_mut(&a).unwrap()).link = Some(format!("/a/{n}.png")); + Arc::make_mut(d.assets.get_mut(&b).unwrap()).link = Some(format!("/b/{n}.png")); + place(&mut d, a, 1.0); + place(&mut d, b, 2.0); + pairs.push((a, b)); + } + let back = load(&save_uncompacted(&d)).unwrap(); + assert_eq!(back.assets.len(), 256); + for (n, (a, b)) in pairs.into_iter().enumerate() { + assert_eq!(back.assets[&a].data[1016..], (n as u64).to_le_bytes()); + assert!(Arc::ptr_eq(&back.assets[&a].data, &back.assets[&b].data)); + assert_eq!(back.assets[&a].link, d.assets[&a].link); + assert_eq!(back.assets[&b].link, d.assets[&b].link); + } + assert_eq!(back.spreads, d.spreads); + } + #[test] fn older_files_with_one_asset_per_placement_open_with_one() { let mut d = Document::new(&NewDocument::default()); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index fc8fbc3a5..06f59de94 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared print spooler; Windows CI passes) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (content-indexed asset deduplication, #727) · **Target:** Adobe InDesign 2026 (21.6) Every format InDesign 2026 reads or writes, with DesignCraft's support, fidelity and tests. The InDesign list comes from its bundle's `Info.plist` (`CFBundleDocumentTypes`, @@ -22,7 +22,7 @@ score below). | InDesign document | `.indd` | read/write (native) | ✗ (#21; converters proposed in #98, #114) | ✗ | — | 30% | | InDesign template | `.indt` | read/write | ✗ | ✗ | — | 2% | | InDesign Markup | `.idml` | read/write | ✓ | ✓ | Synthetic fixtures in `crates/idml/src/tests.rs` (49 tests); exported files verified by opening in InDesign 2026 by hand; indd-utils' 98-document run imports 811 spreads / 2,866 stories / 5,812 items. Open: #193, #338, #339, #340, #211. No real-file corpus test | 20% | -| DesignCraft native | `.designcraft` | — | ✓ | ✓ | zip + JSON, versioned; round-trip tests | — | +| DesignCraft native | `.designcraft` | — | ✓ | ✓ | zip + JSON, versioned; round-trip tests. Asset loading and compaction index by content hash, then confirm byte equality; equal-sized distinct files no longer all share one search bucket (#727). Separate links, MIME types and PDF pages retain their identity | — | | InDesign book | `.indb` | read/write | ✗ (own `.dcbook`) | ✗ | Books are shallow (see gaps.md) | 2% | | InDesign library | `.indl`, `.inlx` | read/write | ✗ (own `.dclib`) | ✗ | | 1% | | Snippet | `.idms` | read/write | ✗ (own snippets) | ✗ | | 1% | @@ -107,4 +107,5 @@ for real work** (estimated). INDD alone moves this dimension by up to 30 points. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Asset loading and compaction use content fingerprints with exact equality checks; tests cover equal-sized distinct buffers, shared copies, independent links and missing data (#727) | | 2026-10-10 | major | Created from InDesign 2026's declared document types and filters, `plan/indesign/08-file-formats.md` and the code | From 2d07bc5295ef71ec3dffbb6e52f67e0833291cc4 Mon Sep 17 00:00:00 2001 From: harrisonoconnorhover <130776285+harrisonoconnorhover@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:28:38 -0400 Subject: [PATCH 174/238] fix: refresh canvas when switching parent editing --- ROADMAP.md | 4 +- crates/ui-egui/examples/ui_shot.rs | 1 + crates/ui-egui/src/canvas.rs | 18 +++- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/parent_canvas_tests.rs | 104 ++++++++++++++++++++++ 5 files changed, 124 insertions(+), 5 deletions(-) create mode 100644 crates/ui-egui/src/parent_canvas_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..2499c222d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **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 (cancelable macOS Dock Quit) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (parent canvas refresh) · **Target:** Adobe InDesign 2026 DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. @@ -127,6 +127,8 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m ## Progress log +- **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. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. diff --git a/crates/ui-egui/examples/ui_shot.rs b/crates/ui-egui/examples/ui_shot.rs index c0bf38943..c241df135 100644 --- a/crates/ui-egui/examples/ui_shot.rs +++ b/crates/ui-egui/examples/ui_shot.rs @@ -31,6 +31,7 @@ fn main() { } let mut harness = egui_kittest::Harness::builder() .with_size(egui::vec2(1440.0, 900.0)) + .with_step_dt(1.0 / 60.0) .with_pixels_per_point(2.0) .with_max_steps(1_000_000) .wgpu() diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 077ae2944..0289ad9f0 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -372,12 +372,14 @@ fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rect, xf: &Xf, let Some(st) = app.session.active() else { return }; let ppp = ctx.pixels_per_point() as f64; let doc_key = (st.uid, std::sync::Arc::as_ptr(&st.doc) as u64, st.revision, preview); + let editing_parents = st.editing_parents; // Overscan: 25% of the viewport on every side, within the GPU's maximum texture size. let max_side = ctx.input(|i| i.max_texture_side) as f32 / ppp as f32; let (ow, oh) = ((rect.width() * 0.25).min((max_side - rect.width()) / 2.0).max(0.0), (rect.height() * 0.25).min((max_side - rect.height()) / 2.0).max(0.0)); let want = |origin: Point| crate::Shown { doc: doc_key, + editing_parents, origin: Point::new(origin.x - ow as f64 / xf.zoom, origin.y - oh as f64 / xf.zoom), zoom: xf.zoom, size: (rect.width() + 2.0 * ow, rect.height() + 2.0 * oh), @@ -385,7 +387,7 @@ fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rect, xf: &Xf, let target = want(xf.origin); // Does the current texture cover the viewport at this zoom and document state? let covers = |s: &crate::Shown| { - s.doc == doc_key && (s.zoom - xf.zoom).abs() < 1e-9 && { + s.doc == doc_key && s.editing_parents == editing_parents && (s.zoom - xf.zoom).abs() < 1e-9 && { let x0 = (xf.origin.x - s.origin.x) * s.zoom; let y0 = (xf.origin.y - s.origin.y) * s.zoom; x0 >= -0.5 && y0 >= -0.5 && x0 + rect.width() as f64 <= s.size.0 as f64 + 0.5 && y0 + rect.height() as f64 <= s.size.1 as f64 + 0.5 @@ -407,6 +409,7 @@ fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rect, xf: &Xf, return; } let doc_changed = app.canvas.shown.is_none_or(|s| s.doc != doc_key); + let mode_changed = app.canvas.shown.is_some_and(|s| s.editing_parents != editing_parents); let zoom_same = app.canvas.shown.is_some_and(|s| (s.zoom - xf.zoom).abs() < 1e-9); let job = |app: &DesignApp| { let st = app.session.active()?; @@ -449,7 +452,9 @@ fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rect, xf: &Xf, if doc_changed && zoom_same && patch_texture(app, layout, ppp, doc_key, preview) { return; } - if !has_worker || app.canvas.shown.is_none() || (doc_changed && zoom_same) { + // A mode switch changes every spread's meaning, even if the document has no damage. + // Show the new canvas immediately, including when fitting it changes the zoom. + if !has_worker || app.canvas.shown.is_none() || mode_changed || (doc_changed && zoom_same) { let t0 = crate::now_ms(); let Some((doc, placed, w, h, view, opts)) = job(app) else { return }; let img = app.canvas.renderer.render(&doc, &app.session.cache, &placed, w, h, view, &opts); @@ -457,6 +462,7 @@ fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rect, xf: &Xf, app.canvas.shown = Some(target); app.canvas.shown_doc = Some(doc); app.canvas.pending = None; + app.canvas.pending_doc = None; app.perf.render_ms = crate::now_ms() - t0; return; } @@ -464,7 +470,7 @@ fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rect, xf: &Xf, { // Already rendering this view? if app.canvas.pending.is_some_and(|(_, p)| { - p.doc == doc_key && (p.zoom - xf.zoom).abs() < 1e-9 && { + p.doc == doc_key && p.editing_parents == editing_parents && (p.zoom - xf.zoom).abs() < 1e-9 && { let x0 = (xf.origin.x - p.origin.x) * p.zoom; let y0 = (xf.origin.y - p.origin.y) * p.zoom; x0 >= 0.0 && y0 >= 0.0 && x0 + rect.width() as f64 <= p.size.0 as f64 && y0 + rect.height() as f64 <= p.size.1 as f64 @@ -487,7 +493,7 @@ fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rect, xf: &Xf, /// Returns false when a full render is needed (view moved, too much changed, or damage unknown). fn patch_texture(app: &mut DesignApp, layout: &CanvasLayout, ppp: f64, doc_key: (u64, u64, u64, bool), preview: bool) -> bool { let (Some(sh), Some(old), Some(st)) = (app.canvas.shown, app.canvas.shown_doc.clone(), app.session.active()) else { return false }; - if sh.doc.0 != doc_key.0 || sh.doc.3 != doc_key.3 || app.canvas.texture.is_none() { + if sh.doc.0 != doc_key.0 || sh.doc.3 != doc_key.3 || sh.editing_parents != st.editing_parents || app.canvas.texture.is_none() { return false; } let new = st.doc.clone(); @@ -1771,6 +1777,10 @@ fn guide_drag(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rec true } +#[cfg(test)] +#[path = "parent_canvas_tests.rs"] +mod parent_canvas_tests; + #[cfg(test)] mod tests { use designcraft_geom::Unit; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fc7a3e634..6e7650b92 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -410,6 +410,8 @@ impl Default for View { pub struct Shown { /// (doc uid, doc pointer, revision, preview). pub doc: (u64, u64, u64, bool), + /// Parent and document spreads have different canvas placement and content. + pub editing_parents: bool, /// Canvas point at the texture's top-left, zoom, and texture size in screen points. pub origin: Point, pub zoom: f64, diff --git a/crates/ui-egui/src/parent_canvas_tests.rs b/crates/ui-egui/src/parent_canvas_tests.rs new file mode 100644 index 000000000..b3197a7e0 --- /dev/null +++ b/crates/ui-egui/src/parent_canvas_tests.rs @@ -0,0 +1,104 @@ +use super::*; + +fn app_with_document_object() -> DesignApp { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 64, "pages": 1, "facingPages": true, "margins": 4})).unwrap(); + app.run("frame.create", json!({"rect": [24, 24, 40, 40], "content": "unassigned"})).unwrap(); + app.run("object.fill", json!({"swatch": "[Black]"})).unwrap(); + // Exercise the production texture cache without starting a background thread. + app.canvas.worker_started = true; + app +} + +fn render(app: &mut DesignApp, ctx: &egui::Context) -> egui::epaint::ImageDelta { + render_at_zoom(app, ctx, 1.0) +} + +fn render_at_zoom(app: &mut DesignApp, ctx: &egui::Context, zoom: f64) -> egui::epaint::ImageDelta { + let rect = Rect::from_min_size(Pos2::ZERO, vec2(128.0, 64.0)); + let view = View { zoom, origin: Point::new(-64.0, 0.0), fitted: true, rotation: 0 }; + let mut output = ctx.run_ui(Default::default(), |ui| { + let st = app.session.active().unwrap(); + let layout = CanvasLayout::new(&st.doc, st.editing_parents); + render_texture(app, ui.ctx(), rect, &Xf::new(rect, &view), &layout, false); + }); + let id = app.canvas.texture.as_ref().unwrap().id(); + let deltas = output.textures_delta.set.remove(&id); + output.textures_delta.clear(); + let mut deltas = deltas.expect("canvas pixels must update"); + assert_eq!(deltas.len(), 1, "one canvas render should produce one texture update"); + deltas.pop().unwrap() +} + +fn pixel(delta: &egui::epaint::ImageDelta, shown: crate::Shown, point: Point) -> Color32 { + let egui::ImageData::Color(image) = &delta.image; + let offset = delta.pos.unwrap_or([0, 0]); + let x = ((point.x - shown.origin.x) * shown.zoom).round() as usize - offset[0]; + let y = ((point.y - shown.origin.y) * shown.zoom).round() as usize - offset[1]; + image[(x, y)] +} + +#[test] +fn switching_parent_editing_repaints_pages_and_document_objects() { + let mut app = app_with_document_object(); + let ctx = egui::Context::default(); + let left_page = Point::new(-32.0, 32.0); + let document_object = Point::new(32.0, 32.0); + let before = render(&mut app, &ctx); + let shown = app.canvas.shown.unwrap(); + assert_eq!(pixel(&before, shown, left_page), Color32::TRANSPARENT); + let document_color = pixel(&before, shown, document_object); + assert_ne!(document_color, Color32::WHITE); + assert_eq!(document_color.a(), 255); + + let doc = app.session.active().unwrap().doc.clone(); + app.run("layout.parents.edit", json!({"on": true})).unwrap(); + assert!(std::sync::Arc::ptr_eq(&doc, &app.session.active().unwrap().doc), "switching canvas mode does not edit the document"); + let parent = render(&mut app, &ctx); + assert!(parent.pos.is_none(), "the parent canvas needs a complete repaint"); + let shown = app.canvas.shown.unwrap(); + assert_eq!(pixel(&parent, shown, left_page), Color32::WHITE, "the parent's left page must become visible"); + assert_eq!(pixel(&parent, shown, document_object), Color32::WHITE, "document-only objects must disappear in parent editing"); + + app.run("layout.parents.edit", json!({"on": false})).unwrap(); + let restored = render(&mut app, &ctx); + assert!(restored.pos.is_none()); + let shown = app.canvas.shown.unwrap(); + assert_eq!(pixel(&restored, shown, left_page), Color32::TRANSPARENT); + assert_eq!(pixel(&restored, shown, document_object), document_color); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn switching_parent_editing_with_a_new_zoom_repaints_before_the_worker() { + let mut app = app_with_document_object(); + let ctx = egui::Context::default(); + app.canvas.worker = Some(crate::render_worker::Worker::spawn(ctx.clone()).expect("render worker must start")); + let before = render(&mut app, &ctx); + assert_ne!(pixel(&before, app.canvas.shown.unwrap(), Point::new(32.0, 32.0)), Color32::WHITE); + + // Fitting the parent spread can change the zoom as well as the canvas mode. + app.run("layout.parents.edit", json!({"on": true})).unwrap(); + let parent = render_at_zoom(&mut app, &ctx, 0.75); + assert!(parent.pos.is_none(), "a mode switch must upload the new canvas in this frame"); + assert!(app.canvas.pending.is_none(), "parent pages must not wait behind an asynchronous zoom render"); + let shown = app.canvas.shown.unwrap(); + assert_eq!(shown.zoom, 0.75); + assert_eq!(pixel(&parent, shown, Point::new(-32.0, 32.0)), Color32::WHITE); + assert_eq!(pixel(&parent, shown, Point::new(32.0, 32.0)), Color32::WHITE); +} + +#[test] +fn small_edits_in_the_same_canvas_mode_still_repaint_partially() { + let mut app = app_with_document_object(); + let ctx = egui::Context::default(); + let before = render(&mut app, &ctx); + let point = Point::new(32.0, 32.0); + assert_ne!(pixel(&before, app.canvas.shown.unwrap(), point), Color32::WHITE); + + app.run("object.fill", json!({"swatch": "[Paper]"})).unwrap(); + let patch = render(&mut app, &ctx); + assert!(patch.pos.is_some(), "an ordinary small edit should keep the partial repaint optimization"); + assert_eq!(app.canvas.patches, 1); + assert_eq!(pixel(&patch, app.canvas.shown.unwrap(), point), Color32::WHITE); +} From 1e153c5c4533993cf89799f23ae47b19093c9617 Mon Sep 17 00:00:00 2001 From: Daniel van Lith <deeev@Daniels-MacBook-Pro.local> Date: Wed, 14 Oct 2026 10:36:52 +1300 Subject: [PATCH 175/238] macOS DMG: hdiutil create -srcfolder, so the app inside passes codesign --strict makehybrid -hfs gave every file in the image a com.apple.FinderInfo, so the app in the DMG, and a copy installed from it, failed codesign --verify --strict --deep ("resource fork, Finder information, or similar detritus not allowed"), though Gatekeeper accepts it. Build the image as VectorCraft (#1085) and PdfCraft (#823) do now: hdiutil create -srcfolder with a few tries for CI's "Resource busy", and verify the mounted app with --strict --deep so the regression can't ship again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- packaging/macos/package.sh | 39 +++++++++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index 38fa2afb9..c818ae6db 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -135,15 +135,40 @@ ln -s /Applications "$STAGE/Applications" mkdir -p "$STAGE/.background" cp "$HERE/dmg/background.tiff" "$STAGE/.background/background.tiff" cp "$HERE/dmg/dmg-layout.DS_Store" "$STAGE/.DS_Store" -rm -f "$DMG" "$WORK/raw.dmg" -# makehybrid + convert builds the image without attaching a device, unlike `create -srcfolder`, -# which is flaky on CI runners ("Resource busy") and hangs in sandboxed sessions. -# The volume name has no version: .DS_Store finds the background through an alias that includes it. -hdiutil makehybrid -hfs -hfs-volume-name "DesignCraft" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE" -hdiutil convert "$WORK/raw.dmg" -format UDZO -imagekey zlib-level=9 -o "$DMG" -rm -f "$WORK/raw.dmg" +rm -f "$DMG" +# `hdiutil create -srcfolder` writes the files as they are. `makehybrid -hfs` gave every file a +# com.apple.FinderInfo, which `codesign --verify --strict` rejects as "Finder information, or +# similar detritus" on the app in the image and on a copy installed from it (as VectorCraft's +# storytold/vectorcraft#1085 and PdfCraft's storytold/pdfcraft#823). `create` attaches a device +# while it copies, which fails now and then on CI runners ("Resource busy"), so it gets a few +# tries. The volume name has no version: .DS_Store finds the background through an alias that +# includes it. +# Usage: hdiutil_retry <what> <hdiutil args…>; tries five times with growing pauses. +hdiutil_retry() { + local what="$1" attempt + shift + for attempt in 1 2 3 4 5; do + if hdiutil "$@"; then + return 0 + fi + [ "$attempt" = 5 ] && { echo "hdiutil $what failed 5 times" >&2; return 1; } + echo "hdiutil $what failed (attempt $attempt of 5); retrying in $((attempt * 10))s" >&2 + sleep $((attempt * 10)) + done +} +hdiutil_retry create create -srcfolder "$STAGE" -volname "DesignCraft" -fs HFS+ -format UDZO -imagekey zlib-level=9 -ov "$DMG" sign "$DMG" codesign --verify --strict --verbose=2 "$DMG" +# The app inside the image must still pass --strict, so a makehybrid-style regression can't ship. +MOUNT="$WORK/dmg-check" +rm -rf "$MOUNT" +mkdir -p "$MOUNT" +hdiutil_retry attach attach -nobrowse -readonly -mountpoint "$MOUNT" "$DMG" >/dev/null +if ! codesign --verify --strict --deep --verbose=2 "$MOUNT/DesignCraft.app"; then + hdiutil_retry detach detach "$MOUNT" >/dev/null || true + exit 1 +fi +hdiutil_retry detach detach "$MOUNT" >/dev/null if [ "$NOTARIZE" = 1 ]; then notarize "$DMG" xcrun stapler staple "$DMG" From bfa536352dea4d0673d05fd8285caf9f82bf2d6e Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Wed, 14 Oct 2026 07:54:46 +1000 Subject: [PATCH 176/238] FIX-CHILD-NOTE-PASTE: filter unsupported markers in child stories Co-Authored-By: Codex <codex@openai.com> --- crates/engine/src/cmd/edit.rs | 128 +++++++++++++++++++++++++++++++++- 1 file changed, 126 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index e7190acb4..79d136a14 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -74,7 +74,7 @@ fn paste_text(s: &mut Session, text: Option<String>, formatted: bool) -> Result< } } let use_clip = formatted && clip.as_ref().is_some_and(|(_, p)| text.as_ref().is_none_or(|x| x.replace("\r\n", "\n") == *p)); - // Footnotes, cross-references and tables only paste into story text, not cells or footnotes. + // Footnotes, endnotes, editorial notes, cross-references and tables cannot be pasted into cells or footnotes. if use_clip && let Some((slice, _)) = clip { let slice = if t.cell.is_some() { strip_objects(&slice) } else { (*slice).clone() }; return s.edit(|d, sel| { @@ -93,7 +93,13 @@ fn paste_text(s: &mut Session, text: Option<String>, formatted: bool) -> Result< fn strip_objects(st: &designcraft_doc::Story) -> designcraft_doc::Story { let mut out = st.clone(); - let marks = [designcraft_doc::FOOTNOTE_REF, designcraft_doc::XREF_MARK, designcraft_doc::TABLE_ANCHOR]; + let marks = [ + designcraft_doc::FOOTNOTE_REF, + designcraft_doc::ENDNOTE_REF, + designcraft_doc::NOTE_MARK, + designcraft_doc::XREF_MARK, + designcraft_doc::TABLE_ANCHOR, + ]; while let Some(i) = out.text.find(marks) { let n = out.text[i..].chars().next().map_or(1, char::len_utf8); out.delete(i..i + n); @@ -355,6 +361,124 @@ mod text_clipboard_tests { s.doc().unwrap().doc.check().unwrap(); } + fn note_clipboard_session() -> (Session, designcraft_doc::StoryId) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let source = frame(&mut s, "aéz"); + s.execute("text.select", &json!({"story": source.0, "anchor": 1, "focus": 1})).unwrap(); + s.execute("endnote.insert", &json!({"text": "Citation A"})).unwrap(); + s.execute("note.new", &json!({"text": "Memo A", "author": "Ed"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(source).unwrap().text, "a\u{E00F}\u{E010}éz"); + s.execute("text.select", &json!({"story": source.0, "anchor": 0, "focus": 10})).unwrap(); + s.execute("type.char", &json!({"attrs": {"fontStyle": "Bold"}})).unwrap(); + assert_eq!(s.execute("edit.copy", &json!({})).unwrap(), json!({"text": "aéz"})); + (s, source) + } + + fn assert_child_note_paste( + s: &mut Session, + source: designcraft_doc::StoryId, + parent: designcraft_doc::StoryId, + cell: designcraft_doc::CellAddr, + endpoints: (usize, usize), + ) { + let before = s.doc().unwrap().doc.clone(); + let selected = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((selected.story, selected.cell, selected.anchor, selected.focus), (parent, Some(cell), endpoints.0, endpoints.1)); + assert_eq!(selected.frame, None); + let before_child = before.text_story(parent, Some(cell)).unwrap(); + assert_eq!(before_child.text, "L----R"); + let clipboard = s.text_clipboard.clone(); + let history = s.execute("document.history", &json!({})).unwrap(); + assert_eq!(history["redo"], json!([])); + + let result = s.execute("edit.paste", &json!({})).unwrap(); + let after = s.doc().unwrap().doc.clone(); + let child = after.text_story(parent, Some(cell)).unwrap(); + assert_eq!(child.text, "LaézR", "root-only note markers must not enter child text"); + assert_eq!(child.len(), 6); + assert_eq!(result, json!({"pos": 5, "formatted": true})); + assert_eq!(s.doc().unwrap().selection, Selection::text(designcraft_doc::TextSel { anchor: 5, focus: 5, ..selected })); + assert!(child.endnotes.is_empty() && child.editorial.is_empty()); + for pos in [1, 2, 4] { + let (_, format) = child.runs().find(|(range, _)| range.contains(&pos)).unwrap(); + assert_eq!(format.over.font_style.as_deref(), Some("Bold")); + } + // Inspect actual run coverage: char_format_at at a run boundary means the preceding run. + for pos in [0, 5] { + let (_, old_format) = before_child.runs().find(|(range, _)| range.contains(&pos)).unwrap(); + let (_, new_format) = child.runs().find(|(range, _)| range.contains(&pos)).unwrap(); + assert_eq!(new_format, old_format); + } + assert_eq!(after.story(parent).unwrap().text, before.story(parent).unwrap().text); + assert_eq!(after.story(parent).unwrap().chars, before.story(parent).unwrap().chars); + assert_eq!(after.story(source), before.story(source)); + assert_eq!(s.text_clipboard, clipboard); + + let mut pasted_history = history.clone(); + pasted_history["undo"].as_array_mut().unwrap().push(json!("Paste")); + assert_eq!(s.execute("document.history", &json!({})).unwrap(), pasted_history); + assert_eq!(s.execute("edit.undo", &json!({})).unwrap(), json!({"undone": "Paste"})); + assert_eq!(s.doc().unwrap().doc, before); + let mut undone_history = history; + undone_history["redo"] = json!(["Paste"]); + assert_eq!(s.execute("document.history", &json!({})).unwrap(), undone_history); + // This filter does not change which selection Undo restores. + assert_eq!(s.execute("edit.redo", &json!({})).unwrap(), json!({"redone": "Paste"})); + assert_eq!(s.doc().unwrap().doc, after); + assert_eq!(s.execute("document.history", &json!({})).unwrap(), pasted_history); + } + + #[test] + fn formatted_child_paste_filters_endnotes_and_editorial_in_cells() { + let (mut s, source) = note_clipboard_session(); + let parent = frame(&mut s, "PZ"); + s.execute("text.select", &json!({"story": parent.0, "anchor": 1, "focus": 1})).unwrap(); + let table_id = s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap()["table"].as_u64().unwrap(); + s.execute("text.insert", &json!({"text": "L----R"})).unwrap(); + s.execute("table.nextCell", &json!({})).unwrap(); + s.execute("text.insert", &json!({"text": "NEIGHBOR"})).unwrap(); + s.execute("table.prevCell", &json!({})).unwrap(); + s.execute("text.move", &json!({"dir": "storyStart"})).unwrap(); + s.execute("text.move", &json!({"dir": "right"})).unwrap(); + for _ in 0..4 { + s.execute("text.move", &json!({"dir": "right", "extend": true})).unwrap(); + } + let before_table = s.doc().unwrap().doc.story(parent).unwrap().tables[&table_id].clone(); + let cell = designcraft_doc::CellAddr { table: table_id, row: 0, col: 0 }; + assert_child_note_paste(&mut s, source, parent, cell, (1, 5)); + let table = &s.doc().unwrap().doc.story(parent).unwrap().tables[&table_id]; + assert_eq!(table.cell(0, 1).unwrap().text.text, "NEIGHBOR"); + let mut expected_table = (*before_table).clone(); + expected_table.cells[0].text = table.cells[0].text.clone(); + assert_eq!(table.as_ref(), &expected_table); + + // The same clipboard must still retain both markers when pasted into an ordinary root story. + let root = frame(&mut s, "L----R"); + s.execute("text.select", &json!({"story": root.0, "anchor": 1, "focus": 5})).unwrap(); + assert_eq!(s.execute("edit.paste", &json!({})).unwrap(), json!({"pos": 11, "formatted": true})); + assert_eq!(s.doc().unwrap().doc.story(root).unwrap().text, "La\u{E00F}\u{E010}ézR"); + } + + #[test] + fn formatted_child_paste_filters_endnotes_and_editorial_in_footnotes() { + let (mut s, source) = note_clipboard_session(); + let parent = frame(&mut s, "PZ"); + let neighbor = s.execute("footnote.insert", &json!({"text": "NEIGHBOR"})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": parent.0, "anchor": 1, "focus": 1})).unwrap(); + let target = s.execute("footnote.insert", &json!({"text": "L----R"})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.move", &json!({"dir": "left"})).unwrap(); + for _ in 0..4 { + s.execute("text.move", &json!({"dir": "left", "extend": true})).unwrap(); + } + let before = s.doc().unwrap().doc.story(parent).unwrap().clone(); + assert_child_note_paste(&mut s, source, parent, designcraft_doc::CellAddr::footnote(target), (5, 1)); + let after = s.doc().unwrap().doc.story(parent).unwrap(); + assert_eq!(after.note(neighbor).unwrap().text.text, "NEIGHBOR"); + assert_eq!(after.note(neighbor), before.note(neighbor)); + assert_eq!(after.notes.iter().map(|note| note.id).collect::<Vec<_>>(), before.notes.iter().map(|note| note.id).collect::<Vec<_>>()); + } + #[test] fn change_case_keeps_runs() { let mut s = Session::new(); From 5024ed8870a4c4cba5f1b6ca20a5914e55237944 Mon Sep 17 00:00:00 2001 From: Andreas Haraminac <andreas.haraminac@gmail.com> Date: Tue, 13 Oct 2026 21:55:44 +0000 Subject: [PATCH 177/238] #732: bound control-channel click and drag event queues --- ROADMAP.md | 3 +- crates/ui-egui/src/control.rs | 149 +++++++++++++++++++++++++++++++++- docs/control-protocol.md | 13 ++- docs/gaps.md | 4 +- 4 files changed, 163 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..6c6c09c76 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **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 (cancelable macOS Dock Quit) · **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 DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-13** · Control-channel clicks and drags validate positive counts and the complete pending-event budget before queuing input (#732). Oversized requests leave pending gestures intact, preventing unbounded click-count allocation. No readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 5a4de0058..232f2ca21 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -100,6 +100,27 @@ fn key_from(name: &str) -> Option<egui::Key> { }) } +// Retain room for the existing maximum drag (10,000 moves plus move/down/up), but +// bound the whole gesture and already queued input before generating any events. +const MAX_PENDING_POINTER_EVENTS: usize = 10_003; + +fn pointer_counts(p: &Value, drag: bool, pending: usize) -> Result<(u64, u64), String> { + let positive = |name: &str, default: u64| match p.get(name) { + None => Ok(default), + Some(v) => v.as_u64().filter(|n| *n > 0).ok_or_else(|| format!("`{name}` must be a positive integer")), + }; + let count = positive("count", 1)?; + let steps = if drag { positive("steps", 8)? } else { 0 }; + // One initial move, two button events per click, and each drag step. Check the + // arithmetic too: hostile counts must never overflow into a small budget. + let events = count.checked_mul(2).and_then(|n| n.checked_add(steps)).and_then(|n| n.checked_add(1)); + let available = MAX_PENDING_POINTER_EVENTS.saturating_sub(pending) as u64; + if events.is_none_or(|n| n > available) { + return Err(format!("pointer input would exceed the {MAX_PENDING_POINTER_EVENTS} pending-event limit")); + } + Ok((count, steps)) +} + pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { let p = &req.params; let s = |k: &str| p.get(k).and_then(Value::as_str); @@ -181,6 +202,10 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> } "ui.click" | "ui.drag" => { // Screen-space (egui points) pointer input through egui itself: reaches every widget. + let (count, steps) = match pointer_counts(p, req.method == "ui.drag", app.synthetic.len()) { + Ok(counts) => counts, + Err(e) => return err(e), + }; let f = |k: &str| p.get(k).and_then(Value::as_f64).unwrap_or(0.0) as f32; let button = match s("button") { Some("right") | Some("secondary") => egui::PointerButton::Secondary, @@ -200,15 +225,12 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> app.synthetic.push(egui::Event::PointerMoved(a)); app.synthetic.push(egui::Event::PointerButton { pos: a, button, pressed: true, modifiers }); if req.method == "ui.drag" { - // From the caller: a huge count would queue events until memory runs out. - let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).clamp(1, 10_000); for i in 1..=steps { let t = i as f32 / steps as f32; app.synthetic.push(egui::Event::PointerMoved(a + (end - a) * t)); } } app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: false, modifiers }); - let count = p.get("count").and_then(Value::as_u64).unwrap_or(1); for _ in 1..count { app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: true, modifiers }); app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: false, modifiers }); @@ -420,6 +442,127 @@ mod automation_contract_tests { output.textures_delta.clear(); } #[test] + fn oversized_pointer_requests_leave_pending_events_unchanged() { + let mut app = app(); + let ctx = egui::Context::default(); + let pending = vec![egui::Event::PointerMoved(egui::pos2(12.0, 34.0))]; + for (method, params) in [ + ("ui.click", json!({"count": 5002})), + ("ui.drag", json!({"steps": 10000, "count": 2})), + ("ui.drag", json!({"steps": 10001})), + ("ui.click", json!({"count": u64::MAX})), + ("ui.drag", json!({"steps": u64::MAX})), + ("ui.drag", json!({"count": u64::MAX, "steps": u64::MAX})), + ] { + app.synthetic = pending.clone(); + let reply = call(&mut app, &ctx, method, params); + assert_eq!(reply["ok"], false, "{method}: {reply}"); + assert_eq!(app.synthetic, pending, "a rejected gesture must not leave a press queued"); + } + } + #[test] + fn pointer_counts_require_positive_json_integers() { + let mut app = app(); + let ctx = egui::Context::default(); + let pending = vec![egui::Event::PointerMoved(egui::pos2(12.0, 34.0))]; + app.synthetic = pending.clone(); + for value in [json!(0), json!(-1), json!(1.5), Value::Null, json!("2"), json!(true), json!(18446744073709551616_f64)] { + for (method, field) in [("ui.click", "count"), ("ui.drag", "count"), ("ui.drag", "steps")] { + let mut params = json!({}); + params[field] = value.clone(); + let reply = call(&mut app, &ctx, method, params); + assert_eq!(reply["ok"], false, "{method}, {field}={value}: {reply}"); + assert!(reply["error"].as_str().unwrap().contains(field)); + assert_eq!(app.synthetic, pending); + } + } + } + #[test] + fn pointer_gestures_accept_the_exact_event_budget() { + let mut app = app(); + let ctx = egui::Context::default(); + for (method, params) in + [("ui.click", json!({"count": 5001})), ("ui.drag", json!({"steps": 10000})), ("ui.drag", json!({"steps": 9998, "count": 2}))] + { + app.synthetic.clear(); + assert_eq!(call(&mut app, &ctx, method, params)["ok"], true, "{method}"); + assert_eq!(app.synthetic.len(), 10003); + assert!(matches!(app.synthetic.last(), Some(egui::Event::PointerButton { pressed: false, .. }))); + let queued = app.synthetic.clone(); + assert_eq!(call(&mut app, &ctx, "ui.click", json!({}))["ok"], false); + assert_eq!(app.synthetic, queued, "a full queue retains its release events"); + } + } + #[test] + fn pointer_budget_counts_pending_input_and_recovers_when_it_drains() { + let mut app = app(); + let ctx = egui::Context::default(); + assert_eq!(call(&mut app, &ctx, "ui.click", json!({"count": 5000}))["ok"], true); + assert_eq!(app.synthetic.len(), 10001); + let queued = app.synthetic.clone(); + assert_eq!(call(&mut app, &ctx, "ui.click", json!({}))["ok"], false); + assert_eq!(app.synthetic, queued); + // The ordinary input hook consumes one pointer event per frame, freeing + // enough room for a complete click without dropping earlier input. + app.raw_input_hook(&mut egui::RawInput::default()); + assert_eq!(app.synthetic.len(), 10000); + assert_eq!(call(&mut app, &ctx, "ui.click", json!({}))["ok"], true); + assert_eq!(app.synthetic.len(), 10003); + + app.synthetic.clear(); + assert_eq!(call(&mut app, &ctx, "ui.move", json!({"x": 12, "y": 34}))["ok"], true); + let queued = app.synthetic.clone(); + assert_eq!(call(&mut app, &ctx, "ui.drag", json!({"steps": 10000}))["ok"], false); + assert_eq!(app.synthetic, queued); + assert_eq!(call(&mut app, &ctx, "ui.drag", json!({"steps": 9999}))["ok"], true); + assert_eq!(app.synthetic.len(), 10003); + } + #[test] + fn ordinary_pointer_gestures_keep_their_defaults_and_event_order() { + let mut app = app(); + let ctx = egui::Context::default(); + let a = egui::pos2(10.0, 20.0); + let end = egui::pos2(30.0, 40.0); + assert_eq!(call(&mut app, &ctx, "ui.click", json!({"x": 10, "y": 20}))["ok"], true); + assert_eq!( + app.synthetic, + vec![ + egui::Event::PointerMoved(a), + egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }, + egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }, + ] + ); + app.synthetic.clear(); + assert_eq!(call(&mut app, &ctx, "ui.drag", json!({"x": 10, "y": 20, "toX": 30, "toY": 40}))["ok"], true); + assert_eq!(app.synthetic.len(), 11, "default drag has eight steps"); + assert_eq!(app.synthetic.get(9), Some(&egui::Event::PointerMoved(end))); + + app.synthetic.clear(); + assert_eq!( + call( + &mut app, + &ctx, + "ui.drag", + json!({"x": 10, "y": 20, "toX": 30, "toY": 40, "steps": 2, "count": 2, "button": "right", "shift": true}) + )["ok"], + true + ); + let modifiers = egui::Modifiers { shift: true, ..Default::default() }; + let button = egui::PointerButton::Secondary; + assert_eq!( + app.synthetic, + vec![ + egui::Event::PointerMoved(a), + egui::Event::PointerButton { pos: a, button, pressed: true, modifiers }, + egui::Event::PointerMoved(egui::pos2(20.0, 30.0)), + egui::Event::PointerMoved(end), + egui::Event::PointerButton { pos: end, button, pressed: false, modifiers }, + egui::Event::PointerButton { pos: end, button, pressed: true, modifiers }, + egui::Event::PointerButton { pos: end, button, pressed: false, modifiers }, + ] + ); + } + #[test] fn control_typing_uses_explicit_caret_but_retains_widget_events() { let mut app = app(); let ctx = egui::Context::default(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 9e5145616..6615eb103 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -39,7 +39,7 @@ so only enable it while you use it. Transport: `apps/designcraft/src/control_ser | `ui.tool.select` | `{tool}` | Select a tool (`selection`, `type`, `rectangleFrame`, …) | | `ui.pointer` | `{events:[{kind: down\|drag\|up\|move\|doubleclick, x, y, space?: "screen"\|"canvas"}], mods?}` | Drive the active tool through the same code path as the mouse | | `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Synthetic keyboard input (typing into a text frame) | -| `ui.move` / `ui.click` / `ui.drag` | screen points, `button?: left\|right\|middle` | Real egui pointer input — reaches every widget, menu and panel | +| `ui.move` / `ui.click` / `ui.drag` | screen points, `button?: left\|right\|middle`, `count?: positive integer` (click/drag, default 1), `steps?: positive integer` (drag, default 8) | Real egui pointer input — reaches every widget, menu and panel | | `ui.set` | `{brightness?, panel?, rulers?, guides?, frameEdges?, baselineGrid?, textThreads?, screenMode?, zoom?, page?, fit?}` | UI state | | `ui.dialog.open` | `{id, fields?}` | Open a dialog by id (e.g. `paragraphStyleOptions` or `characterStyleOptions` with `{name, section}`, or `{new: true}` for a new style) | | `ui.dialog.set` / `ui.dialog.confirm` / `ui.dialog.cancel` | `{field, value}` | Fill and confirm the open dialog | @@ -57,6 +57,17 @@ Headless window screenshots (locked screen, hidden window): `cargo run -p design The MCP server (`designcraft-cli mcp`) wraps the same methods for Claude and other agents. +### Click and drag limits + +`ui.click` and `ui.drag` validate the whole gesture before queuing any input. They permit at +most 10,003 pending synthetic events, including input already waiting for native frames. A +click uses `1 + 2 * count` events; a drag uses `1 + steps + 2 * count`. The count includes the +initial click or drag; further clicks occur at its endpoint. An empty queue therefore accepts +up to 5,001 clicks, or a single drag with up to 10,000 steps. `count` and `steps` must be positive +JSON integers when supplied; invalid or over-budget requests return `ok: false` and leave pending +input unchanged. Oversized gestures are rejected rather than truncated. Wait for queued input +to finish before retrying a request that would exceed the pending-event limit. + ## Shared panel docking Panel arrangements support tab grouping and reordering, horizontal and vertical splits, diff --git a/docs/gaps.md b/docs/gaps.md index ebd2d7da5..a578036d6 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-12 · **Change:** minor (book chapter update safety) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (bounded control click/drag input) · **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. @@ -38,6 +38,8 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · open (#238, #187, #160, #312); threading (gap 0); double-click word selection in fields (#189); character formatting set at a caret is discarded (#289). - **Evidence:** 16 issues from 2026-10-05 to 2026-10-10, mostly against the 0.4/0.5 release builds. +- **Safety:** control-channel clicks and drags reject invalid or over-budget counts before + queuing input (#732), including combined drag steps, repeated clicks and already pending events. - **Impact:** a layout artist can't finish the simplest job. This dominates the ready-for-real-work number. - **Estimate:** 30–50 h (reproduce per platform through the control channel, regression tests). From 0aaaa9600d607cef7e480ad3373fa4f53e3c2d4e Mon Sep 17 00:00:00 2001 From: Seon-Jin Yoon <dr.why.dev@gmail.com> Date: Wed, 14 Oct 2026 07:12:08 +0900 Subject: [PATCH 178/238] fix(compose): odd/even page starts keep their parity at the top of a frame Start options were skipped for a paragraph at the top of a frame. That is right for Next Page, Next Frame and Next Column (there is nothing to break away from), but a Next Odd Page paragraph that lands at the top of an even page stayed there, as did Next Even Page at the top of an odd page, and the same went for odd and even page break characters. A part or chapter opener whose previous section ended exactly at the bottom of an odd page therefore opened on a left-hand page. Cursor::start_at_top now moves such a paragraph to the next page of the asked parity, for paragraphs, tables and break characters alike. Other start options at the top of a frame are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/compose/src/lib.rs | 34 ++++++++++++++++++++++++++++------ crates/compose/src/tests.rs | 23 +++++++++++++++++++++++ 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index cd0e7cbd9..6d4c4bb9f 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -576,8 +576,11 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C .and_then(|t| t.chars().next_back()) .and_then(break_start); if let Some(t) = story.para_table(pi) { + let start = after_break.unwrap_or(pp.start_paragraph); if cur.last_baseline.is_some() { - cur.start(after_break.unwrap_or(pp.start_paragraph), &cols, frames, doc); + cur.start(start, &cols, frames, doc); + } else { + cur.start_at_top(start, frames, doc); } let at_top = cur.last_baseline.is_none(); let start_at = (cur.fi, cur.col); @@ -796,17 +799,22 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C StartParagraph::NextColumn => next_column(cur), other => cur.start(other, &cols, frames, doc), }; - // Paragraph start options. - if let Some(start) = after_break - && cur.last_baseline.is_some() - { - begin_at(&mut cur, start); + // Paragraph start options. At the top of a frame there is nothing to break away from, but + // an odd or even page start still has to land on a page of that parity. + if let Some(start) = after_break { + if cur.last_baseline.is_some() { + begin_at(&mut cur, start); + } else { + cur.start_at_top(start, frames, doc); + } } if force_col[pi] { next_column(&mut cur); } if cur.last_baseline.is_some() { begin_at(&mut cur, pp.start_paragraph); + } else { + cur.start_at_top(pp.start_paragraph, frames, doc); } if spanning && cur.fi < frames.len() { // Close the band: the paragraph goes below the deepest of its columns, which are @@ -1983,6 +1991,20 @@ impl Cursor { StartParagraph::NextEvenPage => self.next_page(frames, doc, Some(false)), } } + /// A start option for a paragraph at the top of a frame: there is nothing to break away from, + /// but an odd or even page start still has to land on a page of that parity, so a paragraph + /// at the top of an even page that asks for the next odd page moves on (and vice versa). + fn start_at_top(&mut self, start: StartParagraph, frames: &[FrameSpec], doc: &Document) { + let want_odd = match start { + StartParagraph::NextOddPage => true, + StartParagraph::NextEvenPage => false, + _ => return, + }; + let Some(page) = frames.get(self.fi).and_then(|f| f.page) else { return }; + if (doc.page_number(page) % 2 == 1) != want_odd { + self.next_page(frames, doc, Some(want_odd)); + } + } /// Move to the next frame on a later page, one with an odd page number (`odd` true) or an /// even one (false) when asked. Frames without a page (parent pages) qualify; with no frame /// left, the rest of the story is overset. diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 7a0e790d5..f6f5c9ad8 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -595,6 +595,29 @@ fn break_characters_end_their_paragraph_and_keep_page_parity() { assert_eq!(cs.overset_at, Some(text.find("four").unwrap())); } +/// A paragraph that starts at the top of a frame isn't pushed on by Next Page, but Next Odd Page +/// (or Next Even Page) still moves it when that frame is on a page of the other parity. +#[test] +fn odd_and_even_page_starts_keep_parity_at_the_top_of_a_frame() { + use designcraft_doc::StartParagraph; + use designcraft_doc::story::PAGE_BREAK; + let pages_of = |start: StartParagraph| { + let mut d = Document::new(&NewDocument { pages: 4, facing_pages: false, primary_text_frame: true, ..Default::default() }); + let sid = d.settings.primary_story.unwrap(); + // "two" begins at the top of page 2, after the page break that ends "one". + d.story_mut(sid).unwrap().insert(0, &format!("one{PAGE_BREAK}\ntwo\nthree")); + d.story_mut(sid).unwrap().paras[1].para.start_paragraph = Some(start); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + // Frame i is on page i + 1. + cs.frames.iter().enumerate().flat_map(|(fi, f)| f.lines.iter().map(move |l| (l.para, fi + 1))).collect::<Vec<_>>() + }; + // Already at the top of an even page: Next Page and Next Even Page leave "two" where it is. + assert_eq!(pages_of(StartParagraph::NextPage), vec![(0, 1), (1, 2), (2, 2)]); + assert_eq!(pages_of(StartParagraph::NextEvenPage), vec![(0, 1), (1, 2), (2, 2)]); + // Next Odd Page takes it to page 3, leaving page 2 empty. + assert_eq!(pages_of(StartParagraph::NextOddPage), vec![(0, 1), (1, 3), (2, 3)]); +} + #[test] fn line_before_a_break_character_is_a_last_line() { // As in InDesign: column, frame and page breaks end the paragraph's last line (set with the From cc13e80c9d3ebfe3453b8fcb3aad0165aa6b027e Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Wed, 14 Oct 2026 00:29:55 +0200 Subject: [PATCH 179/238] feat(tools): Page tool selects pages; handles preview the liquid rule, Alt-drag resizes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chris Cicchino's "Adobe InDesign – Page Tool and Liquid Layout" (0:42– 2:06) selects a page with the Page tool, gets its bounding box with handles, drags a handle (the page springs back: a liquid-rule preview), holds Option to really resize it, Shift for proportions, and reads the size in a tooltip by the cursor. DesignCraft's Page tool only opened a generic Page Size dialog on click. - tools: the Page tool selects the page under a click (none on the pasteboard); the selected page shows its outline, eight handles and its liquid page rule ("Scale", "Object-Based"…) above it. Dragging a handle previews layout.pageSize (the liquid rule re-flows the objects) and springs back on release; Alt resizes, Shift keeps the proportions; a W/H readout follows the pointer; resize cursors on the handles. Double-click a page for the Page Size dialog. - doc: Selection.page (the Page tool's page); engine: layout.selectPage (no undo step). - layout.pageSize `anchor` (0-8): the page grows away from the reference point and its objects and guides keep their place relative to it (a handle drag anchors the opposite edge); liquid rules work from that. - canvas: PageHandles and Tag overlays; translations for the tags and the Resize Page undo label. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 1 + crates/doc/src/selection.rs | 3 + crates/engine/src/cmd/layout.rs | 61 +++++++++++- crates/engine/src/tests.rs | 73 ++++++++++++++ crates/tools/src/lib.rs | 10 +- crates/tools/src/nav.rs | 160 ++++++++++++++++++++++++++++--- crates/tools/src/tests.rs | 12 ++- crates/ui-egui/src/canvas.rs | 21 ++++ crates/ui-egui/src/i18n.rs | 3 + crates/ui-egui/src/i18n/ar.rs | 3 + crates/ui-egui/src/i18n/it.rs | 3 + crates/ui-egui/src/i18n/ja.rs | 3 + crates/ui-egui/src/i18n/pt_br.rs | 3 + crates/ui-egui/src/i18n/uk.rs | 3 + docs/target-app-parity.md | 2 +- 15 files changed, 340 insertions(+), 21 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..050c07d9a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-14** · The Page tool works as InDesign's: a click selects a page and shows its handles and liquid page rule; dragging a handle previews the rule and springs back, Alt-drag resizes the page from the opposite edge, Shift keeps proportions, with a W/H readout. No readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/crates/doc/src/selection.rs b/crates/doc/src/selection.rs index d9e9b7a3d..2d28bf2c2 100644 --- a/crates/doc/src/selection.rs +++ b/crates/doc/src/selection.rs @@ -80,6 +80,9 @@ pub struct Selection { /// Key object for Align. #[serde(default, skip_serializing_if = "Option::is_none")] pub key: Option<ItemId>, + /// The page selected with the Page tool (absolute index). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub page: Option<usize>, } impl Selection { diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index bd5320799..2c23640f1 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -376,10 +376,24 @@ pub fn specs() -> Vec<CommandSpec> { "Page Size", [], None, - "{pages: [1-based page numbers], width?, height?, preset?: \"A4\"|\"Letter\"|…} — pages of their own size (Page tool); objects on the pages keep their place on them", + "{pages: [1-based page numbers], width?, height?, preset?: \"A4\"|\"Letter\"|…, anchor?: 0–8 (reference point that stays put, row-major from the top-left; default 0)} — pages of their own size (Page tool); objects keep their place relative to the anchor, or follow the page's liquid rule", has_doc, page_size ), + cmd!(noundo "layout.selectPage", "Select Page", [], None, "{page: 1-based | null} — the page the Page tool selects (its handles, the Control panel)", has_doc, |s, p| { + let n = s.doc()?.doc.page_count(); + let page = match p.get("page") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_u64().filter(|v| *v >= 1 && *v as usize <= n).ok_or_else(|| bad("layout.selectPage", format!("`page`: 1–{n}")))? as usize - 1), + }; + let st = s.doc_mut()?; + if page.is_some() { + st.selection = Default::default(); + } + st.selection.page = page; + st.revision += 1; + Ok(json!({"page": page.map(|p| p + 1)})) + }), cmd!( "layout.section", "Numbering & Section Options…", @@ -640,8 +654,12 @@ fn page_size(s: &mut crate::Session, p: &Value) -> Result<Value> { w = Some(pr.width); h = Some(pr.height); } + let anchor = match p.get("anchor") { + None | Some(Value::Null) => 0, + Some(v) => v.as_u64().filter(|a| *a <= 8).ok_or_else(|| bad("layout.pageSize", "`anchor`: 0–8"))? as usize, + }; s.edit(|d, _| { - resize_pages(d, &pages, w, h); + resize_pages_from(d, &pages, w, h, anchor); Ok(json!({"pages": pages.len()})) }) } @@ -649,14 +667,35 @@ fn page_size(s: &mut crate::Session, p: &Value) -> Result<Value> { /// Give `pages` (1-based) their own size. Objects stay on their page; pages with a liquid rule /// re-flow their objects by it. pub(crate) fn resize_pages(d: &mut designcraft_doc::Document, pages: &[usize], w: Option<f64>, h: Option<f64>) { + resize_pages_from(d, pages, w, h, 0); +} + +/// [`resize_pages`] keeping reference point `anchor` (0–8, row-major from the top-left) in place: +/// the page grows away from it, so objects keep their place relative to it. +pub(crate) fn resize_pages_from(d: &mut designcraft_doc::Document, pages: &[usize], w: Option<f64>, h: Option<f64>, anchor: usize) { + let (fx, fy) = ((anchor % 3) as f64 / 2.0, (anchor / 3).min(2) as f64 / 2.0); { for page in pages { let Some((si, pi)) = d.page_loc(page - 1) else { continue }; let sp = std::sync::Arc::make_mut(&mut d.spreads[si]); let old: Vec<f64> = sp.pages.iter().map(|x| x.x).collect(); let old_bounds = sp.pages[pi].bounds(); + // The page's own objects (outer pages take the pasteboard beside them). + let last = sp.pages.len() - 1; + let (lo, hi) = (if pi == 0 { f64::NEG_INFINITY } else { old_bounds.x0 }, if pi == last { f64::INFINITY } else { old_bounds.x1 }); + let own: Vec<usize> = sp + .items + .iter() + .enumerate() + .filter(|(_, it)| { + let c = it.bounds().center().x; + c >= lo && c < hi + }) + .map(|(i, _)| i) + .collect(); let pg = &mut sp.pages[pi]; let dw = w.map_or(0.0, |w| w.max(1.0) - pg.width); + let dh = h.map_or(0.0, |h| h.max(1.0) - pg.height); if let Some(w) = w { pg.width = w.max(1.0); } @@ -693,8 +732,22 @@ pub(crate) fn resize_pages(d: &mut designcraft_doc::Document, pages: &[usize], w } } } - // Liquid rules: objects were carried along with the page's left edge. - let moved = old_bounds + designcraft_geom::Vec2::new(new[pi] - old[pi], 0.0); + // Growing away from the anchor: the page's objects and guides keep their place + // relative to it. + let shift = designcraft_geom::Vec2::new(fx * dw, fy * dh); + if shift != designcraft_geom::Vec2::ZERO { + for i in &own { + if let Some(it) = sp.items.get_mut(*i) { + let it = std::sync::Arc::make_mut(it); + it.xf = designcraft_geom::Affine::translate(shift) * it.xf; + } + } + for g in &mut sp.pages[pi].guides { + g.position += if g.orientation == designcraft_doc::Orientation::Vertical { shift.x } else { shift.y }; + } + } + // Liquid rules: objects were carried along with the page's left edge (and the anchor). + let moved = old_bounds + designcraft_geom::Vec2::new(new[pi] - old[pi], 0.0) + shift; super::liquid::apply(d, designcraft_doc::SpreadRef::Doc(si), pi, moved); } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 17d1ed098..deec4b9b1 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -569,3 +569,76 @@ fn commands_take_non_object_params_without_panicking() { assert!(r.is_ok(), "{p}: {r:?}"); } } + +#[test] +fn page_tool_selects_a_page_and_its_handles_preview_or_resize_it() { + use designcraft_tools::{CanvasLayout, Mods, PointerEvent, PointerKind}; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 612, "height": 792, "facingPages": false})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [400, 100, 500, 200]})).unwrap()["id"].as_u64().unwrap(); + let frame = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds(); + let width = |s: &Session| s.doc().unwrap().doc.spreads[0].pages[0].width; + // The page on the canvas (a single page is centred on the spine). + let page = |s: &Session| { + let d = &s.doc().unwrap().doc; + CanvasLayout::new(d, false).page_rect(d, 0).unwrap() + }; + s.set_tool("page"); + let v = ViewInfo::at_zoom(1.0); + let ev = |kind, x: f64, y: f64, alt: bool| PointerEvent { mods: Mods { alt, ..Default::default() }, ..PointerEvent::new(kind, x, y) }; + let drag = |s: &mut Session, from: (f64, f64), by: f64, alt: bool, check: &dyn Fn(&Session)| { + s.pointer(&ev(PointerKind::Down, from.0, from.1, alt), v).unwrap(); + s.pointer(&ev(PointerKind::Drag, from.0 + by, from.1, alt), v).unwrap(); + check(s); + s.pointer(&ev(PointerKind::Up, from.0 + by, from.1, alt), v).unwrap(); + }; + // A click selects the page. + let c = page(&s).center(); + s.pointer(&ev(PointerKind::Down, c.x, c.y, false), v).unwrap(); + s.pointer(&ev(PointerKind::Up, c.x, c.y, false), v).unwrap(); + assert_eq!(s.doc().unwrap().selection.page, Some(0)); + // A plain drag of the right handle previews the page's liquid rule and springs back. + s.execute("liquid.pageRule", &json!({"rule": "scale"})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let r = page(&s); + drag(&mut s, (r.x1, r.center().y), 612.0, false, &|s| { + assert_eq!(width(s), 1224.0, "previewed"); + // Scale keeps proportions: only wider, so the frame is centred on the wider page. + assert_eq!(frame(s).x0, 706.0, "the Scale rule in the preview: {:?}", frame(s)); + }); + assert_eq!((width(&s), frame(&s)), (612.0, designcraft_geom::Rect::new(400.0, 100.0, 500.0, 200.0)), "sprung back"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "nothing to undo"); + // Alt-drag resizes; with the rule off the frame stays where it is. + s.execute("liquid.pageRule", &json!({"rule": "off"})).unwrap(); + let r = page(&s); + drag(&mut s, (r.x1, r.center().y), 100.0, true, &|_| {}); + assert_eq!(width(&s), 712.0); + assert_eq!(frame(&s).x0, 400.0); + // The left handle grows the page to the left: the frame keeps its place by the right edge. + let r = page(&s); + drag(&mut s, (r.x0, r.center().y), -100.0, true, &|_| {}); + let pg = s.doc().unwrap().doc.spreads[0].pages[0].clone(); + assert_eq!(pg.width, 812.0); + assert_eq!(frame(&s).x0 - pg.x, 500.0, "100 pt further from the page's left edge"); + // A click on the pasteboard deselects the page. + let r = page(&s); + s.pointer(&ev(PointerKind::Down, r.x0 - 200.0, r.center().y, false), v).unwrap(); + assert_eq!(s.doc().unwrap().selection.page, None); +} + +#[test] +fn page_size_keeps_objects_by_its_anchor() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400, "facingPages": false})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap(); + let frame = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds(); + // Bottom-right stays put: 100 wider and 50 taller moves the frame 100 right and 50 down. + s.execute("layout.pageSize", &json!({"pages": [1], "width": 500, "height": 450, "anchor": 8})).unwrap(); + assert_eq!(frame(&s), designcraft_geom::Rect::new(200.0, 150.0, 300.0, 250.0)); + // The centre: half of it. + s.execute("layout.pageSize", &json!({"pages": [1], "width": 700, "height": 450, "anchor": 4})).unwrap(); + assert_eq!(frame(&s).x0, 300.0); + assert!(s.execute("layout.pageSize", &json!({"pages": [1], "width": 700, "anchor": 9})).is_err()); + assert!(s.execute("layout.selectPage", &json!({"page": 2})).is_err()); + assert_eq!(s.execute("layout.selectPage", &json!({"page": 1})).unwrap()["page"], 1); +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index f3de390a4..dd09e9c25 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -314,6 +314,14 @@ pub enum Overlay { b: Point, label: String, }, + /// A page selected with the Page tool (canvas rect): its outline and eight handles. + PageHandles(Rect), + /// A small tag whose bottom-left is at `p` (the Page tool's liquid page rule; English, the UI + /// translates it). + Tag { + p: Point, + text: String, + }, } /// Correction from a snap, in spread coordinates for `delta`. @@ -400,7 +408,7 @@ pub fn create(id: &str) -> Box<dyn Tool> { "hand" => Box::new(nav::HandTool::default()), "placeGun" => Box::new(nav::PlaceGun::default()), "zoom" => Box::new(nav::ZoomTool::default()), - "page" => Box::new(nav::PageTool), + "page" => Box::new(nav::PageTool::default()), "typeOnPath" => Box::new(nav::PathTypeTool), "gap" => Box::new(nav::GapTool::default()), "contentCollector" => Box::new(nav::ConveyorTool::new(false)), diff --git a/crates/tools/src/nav.rs b/crates/tools/src/nav.rs index d892b090a..a94a93881 100644 --- a/crates/tools/src/nav.rs +++ b/crates/tools/src/nav.rs @@ -3,7 +3,7 @@ use designcraft_geom::Point; use serde_json::json; -use crate::{Action, Cursor, Mods, PointerEvent, PointerKind, Tool, ToolContext}; +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext}; /// Hand tool: drag to pan. Alt-press starts Power Zoom: the view zooms out to the spread, the /// pointer aims a rectangle of the previous view, and releasing zooms back in there. @@ -162,27 +162,161 @@ impl Tool for PlaceGun { } } -/// Page tool (Shift+P): click a page to set its own size. +/// Page tool (Shift+P): click a page to select it; drag one of its handles to preview its liquid +/// page rule (the page springs back on release), Alt-drag to resize it, Shift to keep its +/// proportions; double-click a page for exact sizes. #[derive(Default)] -pub struct PageTool; +pub struct PageTool { + drag: Option<PageDrag>, +} + +/// A page handle being dragged. +#[derive(Clone, Copy, Debug)] +struct PageDrag { + /// Absolute page index. + page: usize, + /// Reference-point index of the handle (0–8, row-major; 4 is the centre and has none). + handle: usize, + start: Point, + cur: Point, + /// The page's size when the drag began. + size: (f64, f64), + alt: bool, +} + +/// The eight handles of a page rect: (reference-point index, canvas point). +fn page_handles(r: designcraft_geom::Rect) -> impl Iterator<Item = (usize, Point)> { + (0..9).filter(|i| *i != 4).map(move |i| (i, Point::new(r.x0 + r.width() * (i % 3) as f64 / 2.0, r.y0 + r.height() * (i / 3) as f64 / 2.0))) +} + +/// The liquid page rule as the Page tool tags a page. +pub fn rule_label(rule: designcraft_doc::LiquidRule) -> &'static str { + use designcraft_doc::LiquidRule as R; + match rule { + R::Off => "Off", + R::Scale => "Scale", + R::ReCenter => "Re-center", + R::ObjectBased => "Object-Based", + R::GuideBased => "Guide-Based", + } +} + +impl PageTool { + /// The selected page's handle under `p`. + fn handle_at(&self, cx: &ToolContext, p: Point) -> Option<(usize, usize)> { + let page = cx.selection.page?; + let r = cx.layout.page_rect(cx.doc, page)?; + page_handles(r).find(|(_, h)| (h.x - p.x).abs() <= cx.tol(5.0) && (h.y - p.y).abs() <= cx.tol(5.0)).map(|(i, _)| (page, i)) + } + + /// The new page size for the drag at `p`: the handle's edges follow the pointer (Shift: in + /// proportion). + fn size_at(cx: &ToolContext, d: &PageDrag, p: Point, shift: bool) -> (f64, f64) { + let si = cx.doc.page_loc(d.page).map_or(0, |l| l.0); + let v = cx.layout.delta_to_spread(designcraft_doc::SpreadRef::Doc(si), p - d.start); + let (col, row) = (d.handle % 3, d.handle / 3); + let dx = match col { + 0 => -v.x, + 2 => v.x, + _ => 0.0, + }; + let dy = match row { + 0 => -v.y, + 2 => v.y, + _ => 0.0, + }; + let (w0, h0) = d.size; + let (mut w, mut h) = ((w0 + dx).max(1.0), (h0 + dy).max(1.0)); + if shift { + // The axis being dragged (the larger change at a corner) sets the scale. + let k = match (col == 1, row == 1) { + (true, _) => h / h0, + (_, true) => w / w0, + _ if (w / w0 - 1.0).abs() >= (h / h0 - 1.0).abs() => w / w0, + _ => h / h0, + }; + (w, h) = ((w0 * k).max(1.0), (h0 * k).max(1.0)); + } + (w, h) + } +} impl Tool for PageTool { fn id(&self) -> &'static str { "page" } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> { - if ev.kind != PointerKind::Up { - return vec![]; + match ev.kind { + PointerKind::Down => { + if let Some((page, handle)) = self.handle_at(cx, ev.pos) { + let Some((si, pi)) = cx.doc.page_loc(page) else { return vec![] }; + let pg = &cx.doc.spreads[si].pages[pi]; + self.drag = Some(PageDrag { page, handle, start: ev.pos, cur: ev.pos, size: (pg.width, pg.height), alt: ev.mods.alt }); + return vec![Action::Begin("Resize Page".into())]; + } + // A click selects the page under the pointer (none on the pasteboard). + let page = (0..cx.doc.page_count()).find(|p| cx.layout.page_rect(cx.doc, *p).is_some_and(|r| r.contains(ev.pos))); + if page == cx.selection.page { + return vec![]; + } + vec![Action::Exec("layout.selectPage".into(), json!({"page": page.map(|p| p + 1)}))] + } + PointerKind::Drag => { + let Some(d) = self.drag.as_mut() else { return vec![] }; + d.cur = ev.pos; + d.alt |= ev.mods.alt; + let d = *d; + let (w, h) = Self::size_at(cx, &d, ev.pos, ev.mods.shift); + vec![Action::Preview("layout.pageSize".into(), json!({"pages": [d.page + 1], "width": w, "height": h, "anchor": 8 - d.handle}))] + } + PointerKind::Up => match self.drag.take() { + // Alt-drag resizes; a plain drag previews the liquid rule and springs back. + Some(d) if d.alt || ev.mods.alt => vec![Action::Commit], + Some(_) => vec![Action::Cancel], + None => vec![], + }, + PointerKind::DoubleClick => { + // Exact sizes: the Page Size dialog for the page. + let Some(page) = (0..cx.doc.page_count()).find(|p| cx.layout.page_rect(cx.doc, *p).is_some_and(|r| r.contains(ev.pos))) else { + return vec![]; + }; + let Some((si, pi)) = cx.doc.page_loc(page) else { return vec![] }; + let pg = &cx.doc.spreads[si].pages[pi]; + vec![Action::Dialog("cmd:layout.pageSize".into(), json!({"pages": [page + 1], "width": pg.width, "height": pg.height}))] + } + PointerKind::Move => vec![], } - let Some((designcraft_doc::SpreadRef::Doc(si), sp)) = cx.layout.spread_at(ev.pos) else { return vec![] }; - let Some(spread) = cx.doc.spreads.get(si) else { return vec![] }; - let Some(pi) = spread.pages.iter().position(|p| p.bounds().contains(sp)) else { return vec![] }; - let pg = &spread.pages[pi]; - let n = cx.doc.first_page_of_spread(si) + pi + 1; - vec![Action::Dialog("cmd:layout.pageSize".into(), json!({"pages": [n], "width": pg.width, "height": pg.height}))] } - fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { - Cursor::Arrow + fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> { + let Some(page) = cx.selection.page else { return vec![] }; + let Some(r) = cx.layout.page_rect(cx.doc, page) else { return vec![] }; + let Some((si, pi)) = cx.doc.page_loc(page) else { return vec![] }; + let pg = &cx.doc.spreads[si].pages[pi]; + let mut v = vec![Overlay::PageHandles(r), Overlay::Tag { p: Point::new(r.x0, r.y0 - cx.tol(6.0)), text: rule_label(pg.liquid).into() }]; + if let Some(d) = self.drag { + v.push(Overlay::Measure { + p: d.cur, + text: format!( + "W: {}\nH: {}", + designcraft_geom::format_measure(pg.width, cx.unit), + designcraft_geom::format_measure(pg.height, cx.unit) + ), + }); + } + v + } + fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor { + let handle = self.drag.map(|d| d.handle).or_else(|| self.handle_at(cx, p).map(|(_, h)| h)); + match handle { + Some(1 | 7) => Cursor::ResizeV, + Some(3 | 5) => Cursor::ResizeH, + Some(0 | 8) => Cursor::ResizeNwSe, + Some(_) => Cursor::ResizeNeSw, + None => Cursor::Arrow, + } + } + fn busy(&self) -> bool { + self.drag.is_some() } } diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 6776902aa..b2e597242 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -227,14 +227,22 @@ fn hand_tool_alt_press_power_zooms() { } #[test] -fn page_tool_opens_page_size() { +fn page_tool_selects_a_page_and_opens_page_size_on_double_click() { let d = Document::new(&NewDocument::default()); let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); let cx = ctx(&d, &s, &c, &l); let off = l.xf(SpreadRef::Doc(0)).translation(); + let (x, y) = (200.0 + off.x + d.spreads[0].pages[0].x, 200.0 + off.y); let mut t = create("page"); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 200.0 + off.x + d.spreads[0].pages[0].x, 200.0 + off.y)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x, y)); + assert_eq!(a, vec![Action::Exec("layout.selectPage".into(), serde_json::json!({"page": 1}))]); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::DoubleClick, x, y)); assert_eq!(a, vec![Action::Dialog("cmd:layout.pageSize".into(), serde_json::json!({"pages": [1], "width": 612.0, "height": 792.0}))]); + // The selected page shows its handles and its liquid page rule. + let s = Selection { page: Some(0), ..Default::default() }; + let cx = ctx(&d, &s, &c, &l); + let o = t.overlays(&cx); + assert!(matches!(o.as_slice(), [Overlay::PageHandles(_), Overlay::Tag { text, .. }] if text == "Off"), "{o:?}"); } #[test] diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 077ae2944..b3fa92336 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1237,6 +1237,27 @@ fn draw_tool_overlays(app: &mut DesignApp, painter: &egui::Painter, xf: &Xf) { painter.rect_filled(r, 3.0, tok.measure_bg); painter.galley(r.min + vec2(5.0, 3.0), g, tok.measure_text); } + Overlay::PageHandles(r) => { + // The Page tool's selected page: outline and eight handles, as InDesign draws them. + let r = xf.rect(r); + let col = Color32::from_rgb(79, 153, 255); + painter.rect_stroke(r, 0.0, Stroke::new(1.0, col), StrokeKind::Middle); + for i in (0..9).filter(|i| *i != 4) { + let c = pos2(r.min.x + r.width() * (i % 3) as f32 / 2.0, r.min.y + r.height() * (i / 3) as f32 / 2.0); + let h = Rect::from_center_size(c, vec2(7.0, 7.0)); + painter.rect_filled(h, 0.0, Color32::WHITE); + painter.rect_stroke(h, 0.0, Stroke::new(1.0, col), StrokeKind::Inside); + } + } + Overlay::Tag { p, text } => { + let text = crate::i18n::tr(&app.ui.language, &text).to_owned(); + let s = xf.to_screen(p); + let g = painter.layout_no_wrap(text, egui::FontId::proportional(10.0), tok.measure_text); + let r = Rect::from_min_size(s - vec2(0.0, g.size().y + 4.0), g.size() + vec2(8.0, 4.0)); + painter.rect_filled(r, 2.0, tok.measure_bg); + painter.rect_stroke(r, 2.0, Stroke::new(1.0, Color32::from_rgb(79, 153, 255)), StrokeKind::Inside); + painter.galley(r.min + vec2(4.0, 2.0), g, tok.measure_text); + } Overlay::Path { .. } => {} } } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 5252c3881..63c70d4a7 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -2498,6 +2498,9 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Added", ["Added", "Added", "Added", "Added", "已添加"]), ("Deleted", ["Deleted", "Deleted", "Deleted", "Deleted", "已删除"]), ("Guide-based", ["Guide-based", "Guide-based", "Guide-based", "Guide-based", "基于参考线"]), + ("Object-Based", ["Object-Based", "Object-Based", "Object-Based", "Object-Based", "基于对象"]), + ("Guide-Based", ["Guide-Based", "Guide-Based", "Guide-Based", "Guide-Based", "基于参考线"]), + ("Resize Page", ["Seitengröße ändern", "Redimensionner la page", "Cambiar tamaño de página", "ページのサイズを変更", "调整页面大小"]), ("Loop", ["Loop", "Loop", "Loop", "Loop", "循环播放"]), ("Object-based", ["Object-based", "Object-based", "Object-based", "Object-based", "基于对象"]), ("Off", ["Off", "Off", "Off", "Off", "关闭"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index a182d09ec..e0c17c88a 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -709,6 +709,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Added", "مضاف"), ("Deleted", "محذوف"), ("Guide-based", "استنادًا إلى الأدلة"), + ("Object-Based", "استنادًا إلى الكائنات"), + ("Guide-Based", "استنادًا إلى الأدلة"), + ("Resize Page", "تغيير حجم الصفحة"), ("Loop", "تكرار"), ("Object-based", "استنادًا إلى الكائنات"), ("Play on Page Load", "تشغيل عند تحميل الصفحة"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 75ece03f6..3f0915aa3 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -1295,6 +1295,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Added", "Aggiunto"), ("Deleted", "Eliminato"), ("Guide-based", "Basato su guide"), + ("Object-Based", "Basato su oggetti"), + ("Guide-Based", "Basato su guide"), + ("Resize Page", "Ridimensiona pagina"), ("Loop", "Ciclo continuo"), ("Object-based", "Basato su oggetti"), ("Off", "Disattivato"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 642b4a4c0..9e0f7a953 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -1291,6 +1291,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Added", "追加済み"), ("Deleted", "削除済み"), ("Guide-based", "ガイド基準"), + ("Object-Based", "オブジェクト基準"), + ("Guide-Based", "ガイド基準"), + ("Resize Page", "ページのサイズを変更"), ("Loop", "繰り返し再生"), ("Object-based", "オブジェクト基準"), ("Off", "オフ"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 982dfb20d..e1fbf308a 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -708,6 +708,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Added", "Adicionado"), ("Deleted", "Excluído"), ("Guide-based", "Baseado em guias"), + ("Object-Based", "Baseado em objetos"), + ("Guide-Based", "Baseado em guias"), + ("Resize Page", "Redimensionar página"), ("Loop", "Repetir"), ("Object-based", "Baseado em objetos"), ("Play on Page Load", "Reproduzir ao carregar a página"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index f3bc8fb35..5994dc135 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -1292,6 +1292,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Added", "Додано"), ("Deleted", "Видалено"), ("Guide-based", "На основі напрямних"), + ("Object-Based", "На основі об’єктів"), + ("Guide-Based", "На основі напрямних"), + ("Resize Page", "Змінити розмір сторінки"), ("Loop", "Зациклити"), ("Object-based", "На основі об’єктів"), ("Off", "Вимкнено"), diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index eb7d7e5c1..84b602d54 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -396,7 +396,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Type on a Path (⇧T tool, `type.onPath`): a story along any line, curve or shape on screen and in PDF; Type on a Path Options (start, flip, baseline/ascender/descender/centre) and Delete Type from Path. Round-trips through IDML as TextPath; on-path caret drawing is still to come. - Preferences › Appearance of Black: 100% K on screen accurately (dark grey) or as rich black (`window.richBlack`); the printing/export side is still to come. - Spreads of up to 10 pages: Add to Previous Spread and Allow Spread to Shuffle (Pages panel; `layout.pages.toSpread`, `layout.spreadShuffle`); inserted pages go around kept spreads. -- Page tool (⇧P) and `layout.pageSize`: pages of their own size (or a preset); the spine stays put and objects keep their place on their page. +- Page tool (⇧P) and `layout.pageSize`: pages of their own size (or a preset); the spine stays put and objects keep their place on their page. A click selects a page (handles, its liquid page rule tagged above it); dragging a handle previews the liquid rule and springs back, Alt-drag resizes, Shift keeps proportions, with a W/H readout; `anchor` keeps a reference point in place; double-click for exact sizes. - File › Print (⌘P): printer, copies, page range, spreads, marks, bleed; printed as PDF through the system spooler (`file.print`, `file.printers`, `dryRun` for agents). Shortcuts now act like choosing the menu item (dialogs open), and UI commands win shortcut ties (⌘N opens New Document). - Nested styles (through/up to N sentences, words, characters, letters, digits, tabs, breaks, spaces or given characters) and GREP styles (regex → character style), edited in Paragraph Style Options and composed under local formatting; `ui.dialog.open` for agents. Nested line styles are still to come; nested and GREP styles round-trip through IDML. - Table and cell styles (round-trip through IDML): cell styles (fill, insets, vertical justification, strokes, paragraph style) and table styles (region cell styles, border, alternating rows, spacing); apply from the Table panel, edits re-apply to every user; `style.cell.*`, `style.table.*`. From 554019cdd582207835a2f895344483389a016cb0 Mon Sep 17 00:00:00 2001 From: noah1400 <48360809+noah1400@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:32:16 +0200 Subject: [PATCH 180/238] Tables: table.options limits header and footer rows (#861) table.options added as many rows as the requested header and footer counts needed, without a limit. A large number typed into the Table panel's Header Rows field froze the app and exhausted memory. Both counts are now capped at 100, the limit table.insert already has. --- crates/engine/src/cmd/table.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index ad306be8e..7851ab858 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -931,8 +931,9 @@ fn options(s: &mut Session, p: &Value) -> Result<Value> { if let Some(v) = p.get("altCols") { o.alt_cols = parse_alt(v); } - let h = p.get("headerRows").and_then(Value::as_u64).map(|v| v as usize); - let f = p.get("footerRows").and_then(Value::as_u64).map(|v| v as usize); + // At most 100 of each, as in `table.insert`: the rows the table lacks are added below. + let h = p.get("headerRows").and_then(Value::as_u64).map(|v| v.min(100) as usize); + let f = p.get("footerRows").and_then(Value::as_u64).map(|v| v.min(100) as usize); if h.is_some() || f.is_some() { let (h, f) = (h.unwrap_or(t.header_rows()), f.unwrap_or(t.footer_rows())); // Add rows when there aren't enough for the requested header/footer plus one body row. @@ -1680,6 +1681,19 @@ mod sort_tests { s.execute("table.moveColumn", &json!({"from": 1, "to": 0})).unwrap(); assert_eq!((cell(&s, 2, 0), cell(&s, 2, 1)), ("x".to_string(), "a".to_string())); } + + #[test] + fn table_options_limits_header_and_footer_rows() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 700], "content": "text", "text": ""})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap(); + // The limit `table.insert` has: the table used to grow by as many rows as were asked for. + s.execute("table.options", &json!({"headerRows": 5000, "footerRows": 5000})).unwrap(); + let t = table(&s); + assert_eq!((t.header_rows(), t.footer_rows(), t.nrows()), (100, 100, 201)); + } } #[cfg(test)] From 59b6125af1c5402d6950ab8a92ff28125373a2f0 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Wed, 14 Oct 2026 00:51:47 +0200 Subject: [PATCH 181/238] feat(ui): Control panel for the Page tool; Controlled by Parent; Show Parent Page Overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chris Cicchino's "Adobe InDesign – Page Tool and Liquid Layout" (1:02– 2:06, 13:47) sizes pages from the Control panel with the Page tool selected: X/Y, W/H, the page-size preset ("US Business Card"), orientation, Liquid Page Rule ("Controlled by Parent"), Objects Move with Page and Show Parent Page Overlay. DesignCraft's Control panel showed the (empty) object controls. - ui: with the Page tool, the Control panel shows the selected page (or the page in view): the reference point (W/H, preset and orientation keep it in place via layout.pageSize's anchor), X/Y (read-only), W/H, the preset dropdown, portrait/landscape, Liquid Page Rule, Objects Move with Page (always on: pages don't move within spreads yet) and Show Parent Page Overlay. - doc: LiquidRule::ControlledByParent (the parent page's rule, Off without one; resolved by liquid::effective_rule); Page.show_parent_overlay. - engine: layout.pageOptions {pages, showParentOverlay}; liquid.pageRule takes controlledByParent. - canvas: the parent page's size over a page with the overlay on. - Liquid Layout panel and Control panel share InDesign's rule list (Off, Scale, Re-center, Object-based, Guide-based, Controlled by Parent); "US Business Card" preset; translations. Builds on the Page tool PR (#862) for the selected page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 1 + crates/doc/src/build.rs | 1 + crates/doc/src/page.rs | 6 ++ crates/engine/src/cmd/layout.rs | 29 ++++++ crates/engine/src/cmd/liquid.rs | 24 ++++- crates/engine/src/tests.rs | 25 +++++ crates/idml/src/import.rs | 1 + crates/tools/src/nav.rs | 1 + crates/ui-egui/src/canvas.rs | 12 ++- crates/ui-egui/src/chrome.rs | 111 ++++++++++++++++++++++- crates/ui-egui/src/i18n.rs | 29 ++++++ crates/ui-egui/src/i18n/ar.rs | 5 + crates/ui-egui/src/i18n/it.rs | 5 + crates/ui-egui/src/i18n/ja.rs | 5 + crates/ui-egui/src/i18n/pt_br.rs | 5 + crates/ui-egui/src/i18n/uk.rs | 5 + crates/ui-egui/src/panels/interactive.rs | 33 +++---- crates/ui-egui/src/panels/properties.rs | 7 +- 18 files changed, 280 insertions(+), 25 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 050c07d9a..d9957d5d1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-14** · With the Page tool, the Control panel shows the page as InDesign's does: W/H from a reference point, page-size preset (US Business Card added), orientation, Liquid Page Rule (now with Controlled by Parent) and Show Parent Page Overlay, which draws the parent page's size over a resized page. No readiness percentage change. - **2026-10-14** · The Page tool works as InDesign's: a click selects a page and shows its handles and liquid page rule; dragging a handle previews the rule and springs back, Alt-drag resizes the page from the opposite edge, Shift keeps proportions, with a W/H readout. No readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. diff --git a/crates/doc/src/build.rs b/crates/doc/src/build.rs index c274f2130..574049d76 100644 --- a/crates/doc/src/build.rs +++ b/crates/doc/src/build.rs @@ -226,6 +226,7 @@ impl Document { liquid: Default::default(), transition: None, view_rotation: 0, + show_parent_overlay: false, } } diff --git a/crates/doc/src/page.rs b/crates/doc/src/page.rs index f1eaa48ad..6aa325798 100644 --- a/crates/doc/src/page.rs +++ b/crates/doc/src/page.rs @@ -114,6 +114,9 @@ pub struct Page { /// Liquid page rule: how objects follow when the page changes size. #[serde(default, skip_serializing_if = "LiquidRule::is_off")] pub liquid: LiquidRule, + /// Page tool › Show Parent Page Overlay: the parent page's size drawn over this page. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub show_parent_overlay: bool, /// Window › Interactive › Page Transitions (the spread's first page holds the spread's). #[serde(default, skip_serializing_if = "Option::is_none")] pub transition: Option<PageTransition>, @@ -208,6 +211,8 @@ pub enum LiquidRule { GuideBased, /// Each object's pins and resize settings decide (`Item::liquid`). ObjectBased, + /// The rule of the page's parent page (InDesign's "Controlled by Parent"; Off without one). + ControlledByParent, } impl LiquidRule { @@ -367,6 +372,7 @@ mod tests { liquid: Default::default(), transition: None, view_rotation: 0, + show_parent_overlay: false, } } diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 2c23640f1..4055b0809 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -380,6 +380,15 @@ pub fn specs() -> Vec<CommandSpec> { has_doc, page_size ), + cmd!( + "layout.pageOptions", + "Page Options", + [], + None, + "{pages: [1-based], showParentOverlay?: bool} — Page tool options (Show Parent Page Overlay: the parent page's size drawn over the page)", + has_doc, + page_options + ), cmd!(noundo "layout.selectPage", "Select Page", [], None, "{page: 1-based | null} — the page the Page tool selects (its handles, the Control panel)", has_doc, |s, p| { let n = s.doc()?.doc.page_count(); let page = match p.get("page") { @@ -664,6 +673,26 @@ fn page_size(s: &mut crate::Session, p: &Value) -> Result<Value> { }) } +fn page_options(s: &mut crate::Session, p: &Value) -> Result<Value> { + let pages: Vec<usize> = + p.get("pages").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|n| n as usize).collect()).unwrap_or_default(); + let n = s.doc()?.doc.page_count(); + if pages.is_empty() || pages.iter().any(|x| *x == 0 || *x > n) { + return Err(bad("layout.pageOptions", "give `pages` (1-based)")); + } + let overlay = p.get("showParentOverlay").and_then(Value::as_bool); + s.edit(|d, _| { + for page in &pages { + let Some((si, pi)) = d.page_loc(page - 1) else { continue }; + let pg = &mut std::sync::Arc::make_mut(&mut d.spreads[si]).pages[pi]; + if let Some(v) = overlay { + pg.show_parent_overlay = v; + } + } + Ok(json!({"pages": pages.len()})) + }) +} + /// Give `pages` (1-based) their own size. Objects stay on their page; pages with a liquid rule /// re-flow their objects by it. pub(crate) fn resize_pages(d: &mut designcraft_doc::Document, pages: &[usize], w: Option<f64>, h: Option<f64>) { diff --git a/crates/engine/src/cmd/liquid.rs b/crates/engine/src/cmd/liquid.rs index 879fdb368..8c8d4be23 100644 --- a/crates/engine/src/cmd/liquid.rs +++ b/crates/engine/src/cmd/liquid.rs @@ -35,7 +35,7 @@ fn axis(lo: f64, hi: f64, o: (f64, f64), n: (f64, f64), pin_lo: bool, pin_hi: bo fn target(rule: LiquidRule, b: Rect, old: Rect, new: Rect, liquid: Option<ObjectLiquid>, vguides: &[f64], hguides: &[f64]) -> Rect { let (ox, oy, nx, ny) = ((old.x0, old.x1), (old.y0, old.y1), (new.x0, new.x1), (new.y0, new.y1)); match rule { - LiquidRule::Off => b, + LiquidRule::Off | LiquidRule::ControlledByParent => b, LiquidRule::ReCenter => b + (new.center() - old.center()), LiquidRule::Scale => { let k = (new.width() / old.width()).min(new.height() / old.height()); @@ -63,7 +63,7 @@ fn target(rule: LiquidRule, b: Rect, old: Rect, new: Rect, liquid: Option<Object pub(crate) fn apply(d: &mut Document, sr: SpreadRef, pi: usize, old: Rect) { let Some(sp) = d.spread(sr) else { return }; let Some(pg) = sp.pages.get(pi) else { return }; - let (rule, new) = (pg.liquid, pg.bounds()); + let (rule, new) = (effective_rule(d, sr, pi), pg.bounds()); if rule == LiquidRule::Off || old == new || old.width() < 1e-6 || old.height() < 1e-6 { return; } @@ -108,8 +108,24 @@ pub(crate) fn apply(d: &mut Document, sr: SpreadRef, pi: usize, old: Rect) { } } +/// The rule page `pi` of spread `sr` follows: its own, or its parent page's when it's controlled +/// by the parent (Off when that's unset too). +pub(crate) fn effective_rule(d: &Document, sr: SpreadRef, pi: usize) -> LiquidRule { + let Some(pg) = d.spread(sr).and_then(|sp| sp.pages.get(pi)) else { return LiquidRule::Off }; + if pg.liquid != LiquidRule::ControlledByParent { + return pg.liquid; + } + let SpreadRef::Doc(si) = sr else { return LiquidRule::Off }; + let abs = d.first_page_of_spread(si) + pi; + match d.parent_page_for(abs).and_then(|(ppi, ppg)| d.parents.get(ppi)?.pages.get(ppg).map(|p| p.liquid)) { + Some(LiquidRule::ControlledByParent) | None => LiquidRule::Off, + Some(r) => r, + } +} + fn rule_param(p: &Value, id: &str) -> Result<LiquidRule> { - serde_json::from_value(p.get("rule").cloned().unwrap_or(json!("off"))).map_err(|_| bad(id, "`rule`: off|scale|reCenter|guideBased|objectBased")) + serde_json::from_value(p.get("rule").cloned().unwrap_or(json!("off"))) + .map_err(|_| bad(id, "`rule`: off|scale|reCenter|guideBased|objectBased|controlledByParent")) } pub fn specs() -> Vec<CommandSpec> { @@ -119,7 +135,7 @@ pub fn specs() -> Vec<CommandSpec> { "Liquid Page Rule", ["Layout", "Liquid Layout"], None, - "{rule: off|scale|reCenter|guideBased|objectBased, pages?: [1-based] (default: all)}", + "{rule: off|scale|reCenter|guideBased|objectBased|controlledByParent (the parent page's rule), pages?: [1-based] (default: all)}", has_doc, |s, p| { let rule = rule_param(p, "liquid.pageRule")?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index deec4b9b1..9557e03cf 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -642,3 +642,28 @@ fn page_size_keeps_objects_by_its_anchor() { assert!(s.execute("layout.selectPage", &json!({"page": 2})).is_err()); assert_eq!(s.execute("layout.selectPage", &json!({"page": 1})).unwrap()["page"], 1); } + +#[test] +fn controlled_by_parent_takes_the_parent_pages_rule() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400, "facingPages": false})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap(); + let frame = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds(); + // The page follows its parent; the parent page re-centres. + s.execute("liquid.pageRule", &json!({"rule": "controlledByParent", "pages": [1]})).unwrap(); + s.edit(|d, _| { + std::sync::Arc::make_mut(&mut d.parents[0]).pages[0].liquid = designcraft_doc::LiquidRule::ReCenter; + Ok(()) + }) + .unwrap(); + s.execute("layout.pageSize", &json!({"pages": [1], "width": 600})).unwrap(); + assert_eq!(frame(&s).x0, 200.0, "re-centred: {:?}", frame(&s)); + // Without a parent (or a parent that's also controlled by its parent) nothing moves. + s.execute("layout.pages.applyParent", &json!({"pages": [0], "parent": null})).unwrap(); + s.execute("layout.pageSize", &json!({"pages": [1], "width": 800})).unwrap(); + assert_eq!(frame(&s).x0, 200.0); + // Show Parent Page Overlay is a page option. + s.execute("layout.pageOptions", &json!({"pages": [1], "showParentOverlay": true})).unwrap(); + assert!(s.doc().unwrap().doc.spreads[0].pages[0].show_parent_overlay); + assert!(s.execute("layout.pageOptions", &json!({"pages": [2], "showParentOverlay": true})).is_err()); +} diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 59ca44be7..5bb4387de 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1963,6 +1963,7 @@ impl<'r> Importer<'r> { liquid: Default::default(), transition: None, view_rotation: 0, + show_parent_overlay: false, }); } let overrides: Vec<Vec<String>> = diff --git a/crates/tools/src/nav.rs b/crates/tools/src/nav.rs index a94a93881..54db73d06 100644 --- a/crates/tools/src/nav.rs +++ b/crates/tools/src/nav.rs @@ -198,6 +198,7 @@ pub fn rule_label(rule: designcraft_doc::LiquidRule) -> &'static str { R::ReCenter => "Re-center", R::ObjectBased => "Object-Based", R::GuideBased => "Guide-Based", + R::ControlledByParent => "Controlled by Parent", } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index b3fa92336..003549a42 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -604,8 +604,18 @@ fn draw_guides(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document painter.rect_stroke(rect(br), 0.0, Stroke::new(hair(painter), c32(s.bleed_color)), StrokeKind::Middle); } let pasteboard = m.inverse().transform_rect_bbox(layout.pasteboard); - for p in &sp.pages { + for (pi, p) in sp.pages.iter().enumerate() { let pr = p.bounds(); + // Page tool › Show Parent Page Overlay: the parent page's size over the page. + if p.show_parent_overlay + && let SpreadRef::Doc(si) = slot.spread + && let Some((ppi, ppg)) = doc.parent_page_for(doc.first_page_of_spread(si) + pi) + && let Some(pp) = doc.parents.get(ppi).and_then(|ps| ps.pages.get(ppg)) + { + let r = rect(DRect::new(pr.x0, pr.y0, pr.x0 + pp.width, pr.y0 + pp.height)); + painter.rect_filled(r, 0.0, Color32::from_rgba_unmultiplied(110, 110, 230, 28)); + painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::from_rgb(110, 110, 230)), StrokeKind::Middle); + } if !app.ui.guides { continue; } diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index c2f1cb48e..f3a17ebf9 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -182,7 +182,9 @@ pub fn control_bar(app: &mut DesignApp, ui: &mut egui::Ui) { ui.horizontal_top(|ui| { let text_mode = matches!(app.session.tool_id(), "type" | "verticalType") || app.session.active().is_some_and(|d| d.selection.text.is_some()); - if text_mode { + if app.session.tool_id() == "page" { + control_page(app, ui); + } else if text_mode { control_text(app, ui); } else { control_object(app, ui); @@ -192,6 +194,96 @@ pub fn control_bar(app: &mut DesignApp, ui: &mut egui::Ui) { }); } +/// The Control panel with the Page tool, as InDesign's: reference point, X/Y, W/H, page-size +/// preset, orientation, Liquid Page Rule, Objects Move with Page and Show Parent Page Overlay, for +/// the page the Page tool selected (else the page in view). W/H keep the reference point in place. +fn control_page(app: &mut DesignApp, ui: &mut egui::Ui) { + let lang = app.ui.language.clone(); + let Some(st) = app.session.active() else { return }; + let units = st.doc.settings.horizontal_units; + let Some(page) = st.selection.page.or_else(|| crate::canvas::current_page(app)) else { return }; + let Some(st) = app.session.active() else { return }; + let d = st.doc.clone(); + let Some((si, pi)) = d.page_loc(page) else { return }; + let pg = d.spreads[si].pages[pi].clone(); + let n = page + 1; + // Reference point: W/H edits keep it in place. + let (r, _) = ui.allocate_exact_size(vec2(30.0, 44.0), Sense::hover()); + if let Some(p) = panels::properties::ref_point_proxy(ui, egui::Rect::from_center_size(r.center(), vec2(27.0, 27.0)), app.ui.ref_point) { + app.ui.ref_point = p; + } + let anchor = app.ui.ref_point.min(8); + let (fx, fy) = ((anchor % 3) as f64 / 2.0, (anchor / 3) as f64 / 2.0); + // The reference point's position: from the spine across, from the top down. + let (x, y) = (pg.x - d.spreads[si].spine_x() + fx * pg.width, fy * pg.height); + egui::Grid::new("ctl_page_xywh").num_columns(4).spacing(vec2(4.0, 4.0)).show(ui, |ui| { + caption(ui, crate::i18n::tr(&lang, "X:")); + // Pages don't move within their spread yet. + ui.add_enabled_ui(false, |ui| measure(ui, "cpx", Some(x), units, 64.0)); + caption(ui, crate::i18n::tr(&lang, "W:")); + if let Some(v) = measure(ui, "cpw", Some(pg.width), units, 64.0) { + let _ = app.run("layout.pageSize", json!({"pages": [n], "width": v, "anchor": anchor})); + } + ui.end_row(); + caption(ui, crate::i18n::tr(&lang, "Y:")); + ui.add_enabled_ui(false, |ui| measure(ui, "cpy", Some(y), units, 64.0)); + caption(ui, crate::i18n::tr(&lang, "H:")); + if let Some(v) = measure(ui, "cph", Some(pg.height), units, 64.0) { + let _ = app.run("layout.pageSize", json!({"pages": [n], "height": v, "anchor": anchor})); + } + ui.end_row(); + }); + vsep(ui); + // Page-size preset and orientation. + let landscape = pg.width > pg.height; + ui.vertical(|ui| { + let cur = panels::properties::preset_name(pg.width, pg.height); + egui::ComboBox::from_id_salt("ctl_page_preset").selected_text(crate::rtl::widget(ui, crate::i18n::tr(&lang, cur))).width(130.0).show_ui( + ui, + |ui| { + for (name, w, h) in panels::properties::PAGE_PRESETS { + if ui.selectable_label(cur == *name, crate::rtl::widget(ui, crate::i18n::tr(&lang, name))).clicked() { + let (w, h) = if landscape { (h, w) } else { (w, h) }; + let _ = app.run("layout.pageSize", json!({"pages": [n], "width": w, "height": h, "anchor": anchor})); + } + } + }, + ); + ui.horizontal(|ui| { + for (icon, tip, want) in [("orient-portrait", "Portrait", false), ("orient-landscape", "Landscape", true)] { + if crate::widgets::icon_toggle(ui, icon, landscape == want, crate::i18n::tr(&lang, tip)).clicked() && landscape != want { + let _ = app.run("layout.pageSize", json!({"pages": [n], "width": pg.height, "height": pg.width, "anchor": anchor})); + } + } + }); + }); + vsep(ui); + ui.vertical(|ui| { + caption(ui, crate::i18n::tr(&lang, "Liquid Page Rule:")); + let label = panels::interactive::liquid_rule_label(pg.liquid); + egui::ComboBox::from_id_salt("ctl_page_rule").selected_text(crate::rtl::widget(ui, crate::i18n::tr(&lang, label))).width(150.0).show_ui( + ui, + |ui| { + for (r, l) in panels::interactive::LIQUID_RULES { + if ui.selectable_label(pg.liquid == r, crate::rtl::widget(ui, crate::i18n::tr(&lang, l))).clicked() && pg.liquid != r { + let _ = app.run("liquid.pageRule", json!({"rule": r, "pages": [n]})); + } + } + }, + ); + }); + vsep(ui); + ui.vertical(|ui| { + // Objects follow a page that moves in its spread; pages don't move yet, so it's always on. + let mut follow = true; + ui.add_enabled(false, egui::Checkbox::new(&mut follow, crate::rtl::widget(ui, crate::i18n::tr(&lang, "Objects Move with Page")))); + let mut overlay = pg.show_parent_overlay; + if ui.checkbox(&mut overlay, crate::rtl::widget(ui, crate::i18n::tr(&lang, "Show Parent Page Overlay"))).changed() { + let _ = app.run("layout.pageOptions", json!({"pages": [n], "showParentOverlay": overlay})); + } + }); +} + /// A field label drawn as a tool icon (hover for its name). fn icon_caption(ui: &mut egui::Ui, icon: &str, tip: &str) { let (r, resp) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover()); @@ -830,6 +922,23 @@ mod tests { assert!(short.min.x - first.min.x <= 2.0 * super::DOC_TAB_MAX_W, "the last tab {short:?} starts too far right"); } + #[test] + fn the_page_tool_has_its_own_control_panel() { + // Chris Cicchino's Page Tool and Liquid Layout video (1:02): with the Page tool, the + // Control panel shows the page's size, preset, orientation and liquid page rule. + let mut app = app(); + app.run("file.new", serde_json::json!({"width": 252, "height": 144, "facingPages": false})).unwrap(); + app.session.set_tool("page"); + let mut h = test_window::open(app, vec2(1800.0, 600.0)); + for label in ["Liquid Page Rule:", "Objects Move with Page"] { + let _ = h.get_by_label(label); + } + assert_eq!(crate::panels::properties::preset_name(252.0, 144.0), "US Business Card"); + h.get_by_label("Show Parent Page Overlay").click(); + h.run_steps(4); + assert!(h.state().app.session.doc().unwrap().doc.spreads[0].pages[0].show_parent_overlay); + } + #[test] fn a_narrow_window_scrolls_the_control_bar() { let w = 700.0; diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 63c70d4a7..ab6b52fc2 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -2501,6 +2501,35 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Object-Based", ["Object-Based", "Object-Based", "Object-Based", "Object-Based", "基于对象"]), ("Guide-Based", ["Guide-Based", "Guide-Based", "Guide-Based", "Guide-Based", "基于参考线"]), ("Resize Page", ["Seitengröße ändern", "Redimensionner la page", "Cambiar tamaño de página", "ページのサイズを変更", "调整页面大小"]), + ( + "Liquid Page Rule:", + ["Regel für flüssige Seiten:", "Règle de page liquide :", "Regla de página líquida:", "リキッドページルール:", "自适应页面规则:"], + ), + ( + "Objects Move with Page", + [ + "Objekte mit Seite verschieben", + "Déplacer les objets avec la page", + "Los objetos se mueven con la página", + "オブジェクトをページと共に移動", + "对象随页面移动", + ], + ), + ( + "Show Parent Page Overlay", + [ + "Überlagerung der übergeordneten Seite anzeigen", + "Afficher la superposition de la page parent", + "Mostrar superposición de página principal", + "親ページオーバーレイを表示", + "显示父页面叠加", + ], + ), + ( + "Controlled by Parent", + ["Durch übergeordnete Seite gesteuert", "Contrôlé par le parent", "Controlado por principal", "親ページで制御", "由父页面控制"], + ), + ("US Business Card", ["US-Visitenkarte", "Carte de visite US", "Tarjeta de visita EE. UU.", "US 名刺", "美国名片"]), ("Loop", ["Loop", "Loop", "Loop", "Loop", "循环播放"]), ("Object-based", ["Object-based", "Object-based", "Object-based", "Object-based", "基于对象"]), ("Off", ["Off", "Off", "Off", "Off", "关闭"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index e0c17c88a..84b003e07 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -712,6 +712,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object-Based", "استنادًا إلى الكائنات"), ("Guide-Based", "استنادًا إلى الأدلة"), ("Resize Page", "تغيير حجم الصفحة"), + ("Liquid Page Rule:", "قاعدة الصفحة المرنة:"), + ("Objects Move with Page", "تحريك الكائنات مع الصفحة"), + ("Show Parent Page Overlay", "إظهار تراكب الصفحة الأصل"), + ("Controlled by Parent", "تتحكم فيه الصفحة الأصل"), + ("US Business Card", "بطاقة عمل أمريكية"), ("Loop", "تكرار"), ("Object-based", "استنادًا إلى الكائنات"), ("Play on Page Load", "تشغيل عند تحميل الصفحة"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 3f0915aa3..8195bac61 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -1298,6 +1298,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object-Based", "Basato su oggetti"), ("Guide-Based", "Basato su guide"), ("Resize Page", "Ridimensiona pagina"), + ("Liquid Page Rule:", "Regola pagina liquida:"), + ("Objects Move with Page", "Sposta oggetti con la pagina"), + ("Show Parent Page Overlay", "Mostra sovrapposizione pagina principale"), + ("Controlled by Parent", "Controllato dalla pagina principale"), + ("US Business Card", "Biglietto da visita USA"), ("Loop", "Ciclo continuo"), ("Object-based", "Basato su oggetti"), ("Off", "Disattivato"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 9e0f7a953..ead7f576d 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -1294,6 +1294,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object-Based", "オブジェクト基準"), ("Guide-Based", "ガイド基準"), ("Resize Page", "ページのサイズを変更"), + ("Liquid Page Rule:", "リキッドページルール:"), + ("Objects Move with Page", "オブジェクトをページと共に移動"), + ("Show Parent Page Overlay", "親ページオーバーレイを表示"), + ("Controlled by Parent", "親ページで制御"), + ("US Business Card", "US 名刺"), ("Loop", "繰り返し再生"), ("Object-based", "オブジェクト基準"), ("Off", "オフ"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index e1fbf308a..52874f549 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -711,6 +711,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object-Based", "Baseado em objetos"), ("Guide-Based", "Baseado em guias"), ("Resize Page", "Redimensionar página"), + ("Liquid Page Rule:", "Regra de página líquida:"), + ("Objects Move with Page", "Objetos se movem com a página"), + ("Show Parent Page Overlay", "Mostrar sobreposição da página principal"), + ("Controlled by Parent", "Controlado pela página principal"), + ("US Business Card", "Cartão de visita EUA"), ("Loop", "Repetir"), ("Object-based", "Baseado em objetos"), ("Play on Page Load", "Reproduzir ao carregar a página"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 5994dc135..68e15e629 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -1295,6 +1295,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object-Based", "На основі об’єктів"), ("Guide-Based", "На основі напрямних"), ("Resize Page", "Змінити розмір сторінки"), + ("Liquid Page Rule:", "Правило гнучкої сторінки:"), + ("Objects Move with Page", "Об’єкти рухаються зі сторінкою"), + ("Show Parent Page Overlay", "Показати накладення батьківської сторінки"), + ("Controlled by Parent", "Керується батьківською сторінкою"), + ("US Business Card", "Візитна картка (США)"), ("Loop", "Зациклити"), ("Object-based", "На основі об’єктів"), ("Off", "Вимкнено"), diff --git a/crates/ui-egui/src/panels/interactive.rs b/crates/ui-egui/src/panels/interactive.rs index 5b770aec8..cd36926fa 100644 --- a/crates/ui-egui/src/panels/interactive.rs +++ b/crates/ui-egui/src/panels/interactive.rs @@ -486,6 +486,21 @@ pub fn buttons(app: &mut DesignApp, ui: &mut egui::Ui) { } } +/// Liquid page rules in InDesign's order, with their menu labels. +pub const LIQUID_RULES: [(designcraft_doc::LiquidRule, &str); 6] = [ + (designcraft_doc::LiquidRule::Off, "Off"), + (designcraft_doc::LiquidRule::Scale, "Scale"), + (designcraft_doc::LiquidRule::ReCenter, "Re-center"), + (designcraft_doc::LiquidRule::ObjectBased, "Object-based"), + (designcraft_doc::LiquidRule::GuideBased, "Guide-based"), + (designcraft_doc::LiquidRule::ControlledByParent, "Controlled by Parent"), +]; + +/// A liquid page rule's menu label. +pub fn liquid_rule_label(rule: designcraft_doc::LiquidRule) -> &'static str { + LIQUID_RULES.iter().find(|(r, _)| *r == rule).map_or("Off", |(_, l)| l) +} + /// Liquid Layout panel: the current page's rule, the selection's pins (object-based), the page's /// liquid guides, and Create Alternate Layout. pub fn liquid(app: &mut DesignApp, ui: &mut egui::Ui) { @@ -501,23 +516,9 @@ pub fn liquid(app: &mut DesignApp, ui: &mut egui::Ui) { crate::rtl::label(ui, egui::RichText::new(format!("{} {}", crate::i18n::tr(&app.ui.language, "Page"), d.page_name(page))).strong()); let mut rule = pg.liquid; egui::ComboBox::from_id_salt("liquid_rule") - .selected_text(crate::rtl::widget( - ui, - crate::i18n::tr( - &app.ui.language, - match rule { - L::Off => "Off", - L::Scale => "Scale", - L::ReCenter => "Re-center", - L::GuideBased => "Guide-based", - L::ObjectBased => "Object-based", - }, - ), - )) + .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, liquid_rule_label(rule)))) .show_ui(ui, |ui| { - for (r, label) in - [(L::Off, "Off"), (L::Scale, "Scale"), (L::ReCenter, "Re-center"), (L::GuideBased, "Guide-based"), (L::ObjectBased, "Object-based")] - { + for (r, label) in LIQUID_RULES { ui.selectable_value(&mut rule, r, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))); } }); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index d9c74e40d..b67be2d8e 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -160,7 +160,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { // ---------------------------------------------------------------- No Selection /// Page-size presets for the Document dropdown (portrait, points). -const PAGE_PRESETS: &[(&str, f64, f64)] = &[ +pub(crate) const PAGE_PRESETS: &[(&str, f64, f64)] = &[ ("Letter", 612.0, 792.0), ("Legal", 612.0, 1008.0), ("Tabloid", 792.0, 1224.0), @@ -171,9 +171,10 @@ const PAGE_PRESETS: &[(&str, f64, f64)] = &[ ("A5", 419.528, 595.276), ("A6", 297.638, 419.528), ("B5", 498.898, 708.661), + ("US Business Card", 144.0, 252.0), ]; -fn preset_name(w: f64, h: f64) -> &'static str { +pub(crate) fn preset_name(w: f64, h: f64) -> &'static str { let (a, b) = (w.min(h), w.max(h)); PAGE_PRESETS.iter().find(|p| (p.1 - a).abs() < 0.6 && (p.2 - b).abs() < 0.6).map(|p| p.0).unwrap_or("Custom") } @@ -386,7 +387,7 @@ fn document_sections(app: &mut DesignApp, ui: &mut Ui) { // ---------------------------------------------------------------- Objects /// The 9-point reference-point proxy; returns a newly picked point (0..8, row-major). -fn ref_point_proxy(ui: &mut Ui, r: Rect, cur: u8) -> Option<u8> { +pub(crate) fn ref_point_proxy(ui: &mut Ui, r: Rect, cur: u8) -> Option<u8> { let t = Tokens::get(ui.ctx()); let mut out = None; let pitch = r.width() / 3.0; From 8864307d7c8567c80c8825aa4a13a13b9202ec92 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Wed, 14 Oct 2026 00:52:06 +0200 Subject: [PATCH 182/238] docs: the Page tool's Control panel in the parity notes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/target-app-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 84b602d54..cbcebc37b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -396,7 +396,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Type on a Path (⇧T tool, `type.onPath`): a story along any line, curve or shape on screen and in PDF; Type on a Path Options (start, flip, baseline/ascender/descender/centre) and Delete Type from Path. Round-trips through IDML as TextPath; on-path caret drawing is still to come. - Preferences › Appearance of Black: 100% K on screen accurately (dark grey) or as rich black (`window.richBlack`); the printing/export side is still to come. - Spreads of up to 10 pages: Add to Previous Spread and Allow Spread to Shuffle (Pages panel; `layout.pages.toSpread`, `layout.spreadShuffle`); inserted pages go around kept spreads. -- Page tool (⇧P) and `layout.pageSize`: pages of their own size (or a preset); the spine stays put and objects keep their place on their page. A click selects a page (handles, its liquid page rule tagged above it); dragging a handle previews the liquid rule and springs back, Alt-drag resizes, Shift keeps proportions, with a W/H readout; `anchor` keeps a reference point in place; double-click for exact sizes. +- Page tool (⇧P) and `layout.pageSize`: pages of their own size (or a preset); the spine stays put and objects keep their place on their page. A click selects a page (handles, its liquid page rule tagged above it); dragging a handle previews the liquid rule and springs back, Alt-drag resizes, Shift keeps proportions, with a W/H readout; `anchor` keeps a reference point in place; double-click for exact sizes. With the Page tool the Control panel shows the page (reference point, W/H, preset, orientation, Liquid Page Rule including Controlled by Parent, Show Parent Page Overlay); X/Y and Objects Move with Page are shown, but pages don't move within their spread yet. - File › Print (⌘P): printer, copies, page range, spreads, marks, bleed; printed as PDF through the system spooler (`file.print`, `file.printers`, `dryRun` for agents). Shortcuts now act like choosing the menu item (dialogs open), and UI commands win shortcut ties (⌘N opens New Document). - Nested styles (through/up to N sentences, words, characters, letters, digits, tabs, breaks, spaces or given characters) and GREP styles (regex → character style), edited in Paragraph Style Options and composed under local formatting; `ui.dialog.open` for agents. Nested line styles are still to come; nested and GREP styles round-trip through IDML. - Table and cell styles (round-trip through IDML): cell styles (fill, insets, vertical justification, strokes, paragraph style) and table styles (region cell styles, border, alternating rows, spacing); apply from the Table panel, edits re-apply to every user; `style.cell.*`, `style.table.*`. From 8f71219533f325779c678835dc8c4bca5c26f446 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Wed, 14 Oct 2026 01:13:23 +0200 Subject: [PATCH 183/238] feat(tools): Page tool pins and locks objects on object-based pages, on the canvas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chris Cicchino's "Adobe InDesign – Page Tool and Liquid Layout" (6:32– 9:58) clicks an object with the Page tool on an object-based page and gets InDesign's on-canvas liquid controls: a pin outside each edge ("Object is pinned relative to the page edge. Click to release.") and a padlock on the width and height lines ("The object's width is locked. Click to let the object change width as the page changes width."). The pins and resize settings existed (liquid.object, the Liquid Layout panel) but the canvas showed none of it. - tools: on an object-based page a Page tool click selects the object with its page (layout.selectPage takes `ids`); its pins (14 px outside each edge's middle) and width/height locks (beside the centre) toggle liquid.object on click, show InDesign's tooltips on hover and a hand cursor. - canvas: LiquidPins (orange dashed width/height lines with padlocks, open when the object follows the page; hollow pins, or a line to the page edge when pinned) and Tip overlays; tooltip translations. Builds on the Page tool PR (#862). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 1 + crates/engine/src/cmd/layout.rs | 21 ++++- crates/engine/src/tests.rs | 56 +++++++++++++ crates/tools/src/lib.rs | 15 ++++ crates/tools/src/nav.rs | 139 ++++++++++++++++++++++++++++++- crates/tools/src/tests.rs | 2 +- crates/ui-egui/src/canvas.rs | 52 ++++++++++++ crates/ui-egui/src/i18n.rs | 60 +++++++++++++ crates/ui-egui/src/i18n/ar.rs | 18 ++++ crates/ui-egui/src/i18n/it.rs | 18 ++++ crates/ui-egui/src/i18n/ja.rs | 18 ++++ crates/ui-egui/src/i18n/pt_br.rs | 18 ++++ crates/ui-egui/src/i18n/uk.rs | 18 ++++ docs/target-app-parity.md | 2 +- 14 files changed, 429 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d9957d5d1..5c16da0c0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -130,6 +130,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. - **2026-10-14** · With the Page tool, the Control panel shows the page as InDesign's does: W/H from a reference point, page-size preset (US Business Card added), orientation, Liquid Page Rule (now with Controlled by Parent) and Show Parent Page Overlay, which draws the parent page's size over a resized page. No readiness percentage change. +- **2026-10-14** · On an object-based page the Page tool shows a selected object's liquid pins and resize locks as InDesign does: a click pins a side to the page edge or locks the width or height, with InDesign's tooltips. No readiness percentage change. - **2026-10-14** · The Page tool works as InDesign's: a click selects a page and shows its handles and liquid page rule; dragging a handle previews the rule and springs back, Alt-drag resizes the page from the opposite edge, Shift keeps proportions, with a W/H readout. No readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 4055b0809..1650d389c 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -389,20 +389,35 @@ pub fn specs() -> Vec<CommandSpec> { has_doc, page_options ), - cmd!(noundo "layout.selectPage", "Select Page", [], None, "{page: 1-based | null} — the page the Page tool selects (its handles, the Control panel)", has_doc, |s, p| { + cmd!( + noundo "layout.selectPage", + "Select Page", + [], + None, + "{page: 1-based | null, ids?: [item ids] (objects selected with it: the Page tool on an object-based page)} — the page the Page tool selects (its handles, the Control panel)", + has_doc, + |s, p| { let n = s.doc()?.doc.page_count(); let page = match p.get("page") { None | Some(Value::Null) => None, Some(v) => Some(v.as_u64().filter(|v| *v >= 1 && *v as usize <= n).ok_or_else(|| bad("layout.selectPage", format!("`page`: 1–{n}")))? as usize - 1), }; + let ids: Vec<designcraft_doc::ItemId> = { + let d = &s.doc()?.doc; + p.get("ids") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(Value::as_u64).map(designcraft_doc::ItemId).filter(|id| d.item(*id).is_some()).collect()) + .unwrap_or_default() + }; let st = s.doc_mut()?; if page.is_some() { - st.selection = Default::default(); + st.selection = designcraft_doc::Selection::items(ids); } st.selection.page = page; st.revision += 1; Ok(json!({"page": page.map(|p| p + 1)})) - }), + } + ), cmd!( "layout.section", "Numbering & Section Options…", diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9557e03cf..af54bb942 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -667,3 +667,59 @@ fn controlled_by_parent_takes_the_parent_pages_rule() { assert!(s.doc().unwrap().doc.spreads[0].pages[0].show_parent_overlay); assert!(s.execute("layout.pageOptions", &json!({"pages": [2], "showParentOverlay": true})).is_err()); } + +#[test] +fn page_tool_pins_and_locks_objects_on_object_based_pages() { + use designcraft_tools::{CanvasLayout, Mods, PointerEvent, PointerKind}; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 612, "height": 792, "facingPages": false})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [256, 72, 356, 132]})).unwrap()["id"].as_u64().unwrap(); + s.execute("liquid.pageRule", &json!({"rule": "objectBased"})).unwrap(); + s.execute("edit.deselectAll", &json!({})).unwrap(); + s.set_tool("page"); + let v = ViewInfo::at_zoom(1.0); + let ev = + |kind, p: designcraft_geom::Point, alt: bool| PointerEvent { mods: Mods { alt, ..Default::default() }, ..PointerEvent::new(kind, p.x, p.y) }; + let canvas = |s: &Session| { + let d = &s.doc().unwrap().doc; + let l = CanvasLayout::new(d, false); + ( + l.page_rect(d, 0).unwrap(), + l.xf(designcraft_doc::SpreadRef::Doc(0)).transform_rect_bbox(d.item(designcraft_doc::ItemId(id)).unwrap().bounds()), + ) + }; + let click = |s: &mut Session, p: designcraft_geom::Point| { + s.pointer(&ev(PointerKind::Down, p, false), v).unwrap(); + s.pointer(&ev(PointerKind::Up, p, false), v).unwrap(); + }; + let liquid = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().liquid; + // A click on the object selects it with its page; its pins and locks show. + let (_, o) = canvas(&s); + click(&mut s, o.center()); + let sel = s.doc().unwrap().selection.clone(); + assert_eq!((sel.page, sel.items.clone()), (Some(0), vec![designcraft_doc::ItemId(id)])); + assert!( + s.overlays(v) + .iter() + .any(|o| matches!(o, designcraft_tools::Overlay::LiquidPins { pins: [false, false, false, false], resize: [true, true], .. })) + ); + // The right pin (14 px outside the right edge) and the width lock (10 px right of the centre). + click(&mut s, designcraft_geom::Point::new(o.x1 + 14.0, o.center().y)); + click(&mut s, designcraft_geom::Point::new(o.center().x + 10.0, o.center().y)); + let l = liquid(&s).unwrap(); + assert!(l.pin_right && !l.resize_width && l.resize_height && !l.pin_left, "{l:?}"); + // Alt-drag the page 100 pt wider: the pinned, width-locked frame keeps its size by the right edge. + let (p, _) = canvas(&s); + let right = designcraft_geom::Point::new(p.x1, p.center().y); + s.pointer(&ev(PointerKind::Down, right, true), v).unwrap(); + s.pointer(&ev(PointerKind::Drag, right + designcraft_geom::Vec2::new(100.0, 0.0), true), v).unwrap(); + s.pointer(&ev(PointerKind::Up, right + designcraft_geom::Vec2::new(100.0, 0.0), true), v).unwrap(); + let d = &s.doc().unwrap().doc; + let (pg, b) = (&d.spreads[0].pages[0], d.item(designcraft_doc::ItemId(id)).unwrap().bounds()); + assert_eq!(pg.width, 712.0); + assert!((b.width() - 100.0).abs() < 1e-6 && (pg.x + pg.width - b.x1 - 256.0).abs() < 1e-6, "{b:?}"); + // Hovering a pin shows its tooltip. + let (_, o) = canvas(&s); + s.pointer(&ev(PointerKind::Move, designcraft_geom::Point::new(o.x1 + 14.0, o.center().y), false), v).unwrap(); + assert!(s.overlays(v).iter().any(|o| matches!(o, designcraft_tools::Overlay::Tip { text, .. } if text.starts_with("Object is pinned")))); +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index dd09e9c25..db983fab5 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -15,6 +15,7 @@ mod frame; mod gradient; pub mod layout; mod nav; +pub use nav::{LOCK_OFFSET_PX, PIN_OFFSET_PX}; mod pen; mod pencil; pub mod select; @@ -322,6 +323,20 @@ pub enum Overlay { p: Point, text: String, }, + /// A tooltip near the pointer (English; the UI translates it). + Tip { + p: Point, + text: String, + }, + /// An object's liquid pins and resize locks on an object-based page (Page tool), canvas + /// coordinates: pinned sides (top, right, bottom, left) and whether width and height follow the + /// page. + LiquidPins { + object: Rect, + page: Rect, + pins: [bool; 4], + resize: [bool; 2], + }, } /// Correction from a snap, in spread coordinates for `delta`. diff --git a/crates/tools/src/nav.rs b/crates/tools/src/nav.rs index 54db73d06..bd53e4cac 100644 --- a/crates/tools/src/nav.rs +++ b/crates/tools/src/nav.rs @@ -168,6 +168,75 @@ impl Tool for PlaceGun { #[derive(Default)] pub struct PageTool { drag: Option<PageDrag>, + /// The pin or lock under the pointer (for its tooltip). + hover: Option<(LiquidControl, Point)>, +} + +/// An object's liquid control on an object-based page: a side's pin (top, right, bottom, left) or +/// the width/height lock. +#[derive(Clone, Copy, Debug, PartialEq)] +enum LiquidControl { + Pin(usize), + Width, + Height, +} + +impl LiquidControl { + /// The `liquid.object` parameter it toggles. + fn key(self) -> &'static str { + match self { + LiquidControl::Pin(0) => "pinTop", + LiquidControl::Pin(1) => "pinRight", + LiquidControl::Pin(2) => "pinBottom", + LiquidControl::Pin(_) => "pinLeft", + LiquidControl::Width => "resizeWidth", + LiquidControl::Height => "resizeHeight", + } + } + + /// InDesign's tooltip for the control in its current state. + fn tip(self, l: &designcraft_doc::ObjectLiquid) -> &'static str { + match self { + LiquidControl::Pin(i) if [l.pin_top, l.pin_right, l.pin_bottom, l.pin_left][i.min(3)] => { + "Object is pinned relative to the page edge. Click to release." + } + LiquidControl::Pin(_) => "Click to pin the object relative to the page edge.", + LiquidControl::Width if l.resize_width => "The object changes width as the page changes width. Click to lock the object width.", + LiquidControl::Width => "The object's width is locked. Click to let the object change width as the page changes width.", + LiquidControl::Height if l.resize_height => "The object changes height as the page changes height. Click to lock the object height.", + LiquidControl::Height => "The object's height is locked. Click to let the object change height as the page changes height.", + } + } + + fn value(self, l: &designcraft_doc::ObjectLiquid) -> bool { + match self { + LiquidControl::Pin(0) => l.pin_top, + LiquidControl::Pin(1) => l.pin_right, + LiquidControl::Pin(2) => l.pin_bottom, + LiquidControl::Pin(_) => l.pin_left, + LiquidControl::Width => l.resize_width, + LiquidControl::Height => l.resize_height, + } + } +} + +/// Screen pixels between an object's edge and its pin (the canvas draws them there too). +pub const PIN_OFFSET_PX: f64 = 14.0; +/// Screen pixels between an object's centre and its width/height locks. +pub const LOCK_OFFSET_PX: f64 = 10.0; + +/// Where an object's liquid controls sit (canvas): pins outside each edge's middle, the width lock +/// right of the centre, the height lock above it. +fn liquid_control_points(cx: &ToolContext, r: designcraft_geom::Rect) -> [(LiquidControl, Point); 6] { + let (c, o, k) = (r.center(), cx.tol(PIN_OFFSET_PX), cx.tol(LOCK_OFFSET_PX)); + [ + (LiquidControl::Pin(0), Point::new(c.x, r.y0 - o)), + (LiquidControl::Pin(1), Point::new(r.x1 + o, c.y)), + (LiquidControl::Pin(2), Point::new(c.x, r.y1 + o)), + (LiquidControl::Pin(3), Point::new(r.x0 - o, c.y)), + (LiquidControl::Width, Point::new(c.x + k, c.y)), + (LiquidControl::Height, Point::new(c.x, c.y - k)), + ] } /// A page handle being dragged. @@ -202,7 +271,43 @@ pub fn rule_label(rule: designcraft_doc::LiquidRule) -> &'static str { } } +/// Whether page `abs` follows the object-based rule (its own, or its parent page's). +fn object_based(doc: &designcraft_doc::Document, abs: usize) -> bool { + use designcraft_doc::LiquidRule as R; + let Some((si, pi)) = doc.page_loc(abs) else { return false }; + match doc.spreads[si].pages[pi].liquid { + R::ObjectBased => true, + R::ControlledByParent => { + doc.parent_page_for(abs).and_then(|(ppi, ppg)| doc.parents.get(ppi)?.pages.get(ppg).map(|p| p.liquid == R::ObjectBased)).unwrap_or(false) + } + _ => false, + } +} + impl PageTool { + /// The selected object's liquid settings, canvas bounds and page bounds when the Page tool + /// shows its pins: one object selected on the selected page, whose rule is object-based. + fn liquid_target( + cx: &ToolContext, + ) -> Option<(designcraft_doc::ItemId, designcraft_doc::ObjectLiquid, designcraft_geom::Rect, designcraft_geom::Rect)> { + let page = cx.selection.page?; + let [id] = cx.selection.items.as_slice() else { return None }; + let (si, _) = cx.doc.page_loc(page)?; + if !object_based(cx.doc, page) || cx.doc.page_of_item(*id) != Some(page) { + return None; + } + let it = cx.doc.item(*id)?; + let l = it.liquid.unwrap_or(designcraft_doc::ObjectLiquid { resize_width: true, resize_height: true, ..Default::default() }); + let r = cx.layout.xf(designcraft_doc::SpreadRef::Doc(si)).transform_rect_bbox(it.bounds()); + Some((*id, l, r, cx.layout.page_rect(cx.doc, page)?)) + } + + /// The selected object's pin or lock under `p`. + fn control_at(cx: &ToolContext, p: Point) -> Option<(designcraft_doc::ItemId, designcraft_doc::ObjectLiquid, LiquidControl)> { + let (id, l, r, _) = Self::liquid_target(cx)?; + liquid_control_points(cx, r).into_iter().find(|(_, q)| (q.x - p.x).hypot(q.y - p.y) <= cx.tol(6.0)).map(|(c, _)| (id, l, c)) + } + /// The selected page's handle under `p`. fn handle_at(&self, cx: &ToolContext, p: Point) -> Option<(usize, usize)> { let page = cx.selection.page?; @@ -255,12 +360,21 @@ impl Tool for PageTool { self.drag = Some(PageDrag { page, handle, start: ev.pos, cur: ev.pos, size: (pg.width, pg.height), alt: ev.mods.alt }); return vec![Action::Begin("Resize Page".into())]; } - // A click selects the page under the pointer (none on the pasteboard). + // An object-based page's object: a click on a pin or lock toggles it. + if let Some((id, l, c)) = Self::control_at(cx, ev.pos) { + return vec![Action::Exec("liquid.object".into(), json!({"ids": [id.0], c.key(): !c.value(&l)}))]; + } + // A click selects the page under the pointer (none on the pasteboard), and on an + // object-based page the object clicked too. let page = (0..cx.doc.page_count()).find(|p| cx.layout.page_rect(cx.doc, *p).is_some_and(|r| r.contains(ev.pos))); - if page == cx.selection.page { + let object = page + .filter(|p| object_based(cx.doc, *p)) + .and_then(|p| cx.hit(ev.pos).map(|(_, id)| id).filter(|id| cx.doc.page_of_item(*id) == Some(p))); + let ids: Vec<u64> = object.map(|id| id.0).into_iter().collect(); + if page == cx.selection.page && cx.selection.items.iter().map(|i| i.0).eq(ids.iter().copied()) { return vec![]; } - vec![Action::Exec("layout.selectPage".into(), json!({"page": page.map(|p| p + 1)}))] + vec![Action::Exec("layout.selectPage".into(), json!({"page": page.map(|p| p + 1), "ids": ids}))] } PointerKind::Drag => { let Some(d) = self.drag.as_mut() else { return vec![] }; @@ -285,7 +399,10 @@ impl Tool for PageTool { let pg = &cx.doc.spreads[si].pages[pi]; vec![Action::Dialog("cmd:layout.pageSize".into(), json!({"pages": [page + 1], "width": pg.width, "height": pg.height}))] } - PointerKind::Move => vec![], + PointerKind::Move => { + self.hover = Self::control_at(cx, ev.pos).map(|(_, _, c)| (c, ev.pos)); + vec![] + } } } fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> { @@ -294,6 +411,17 @@ impl Tool for PageTool { let Some((si, pi)) = cx.doc.page_loc(page) else { return vec![] }; let pg = &cx.doc.spreads[si].pages[pi]; let mut v = vec![Overlay::PageHandles(r), Overlay::Tag { p: Point::new(r.x0, r.y0 - cx.tol(6.0)), text: rule_label(pg.liquid).into() }]; + if let Some((_, l, object, page)) = Self::liquid_target(cx) { + v.push(Overlay::LiquidPins { + object, + page, + pins: [l.pin_top, l.pin_right, l.pin_bottom, l.pin_left], + resize: [l.resize_width, l.resize_height], + }); + if let Some((c, p)) = self.hover { + v.push(Overlay::Tip { p, text: c.tip(&l).into() }); + } + } if let Some(d) = self.drag { v.push(Overlay::Measure { p: d.cur, @@ -307,6 +435,9 @@ impl Tool for PageTool { v } fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor { + if self.drag.is_none() && Self::control_at(cx, p).is_some() { + return Cursor::Hand; + } let handle = self.drag.map(|d| d.handle).or_else(|| self.handle_at(cx, p).map(|(_, h)| h)); match handle { Some(1 | 7) => Cursor::ResizeV, diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index b2e597242..81d67c309 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -235,7 +235,7 @@ fn page_tool_selects_a_page_and_opens_page_size_on_double_click() { let (x, y) = (200.0 + off.x + d.spreads[0].pages[0].x, 200.0 + off.y); let mut t = create("page"); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x, y)); - assert_eq!(a, vec![Action::Exec("layout.selectPage".into(), serde_json::json!({"page": 1}))]); + assert_eq!(a, vec![Action::Exec("layout.selectPage".into(), serde_json::json!({"page": 1, "ids": []}))]); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::DoubleClick, x, y)); assert_eq!(a, vec![Action::Dialog("cmd:layout.pageSize".into(), serde_json::json!({"pages": [1], "width": 612.0, "height": 792.0}))]); // The selected page shows its handles and its liquid page rule. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 003549a42..f20e64022 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1268,11 +1268,63 @@ fn draw_tool_overlays(app: &mut DesignApp, painter: &egui::Painter, xf: &Xf) { painter.rect_stroke(r, 2.0, Stroke::new(1.0, Color32::from_rgb(79, 153, 255)), StrokeKind::Inside); painter.galley(r.min + vec2(4.0, 2.0), g, tok.measure_text); } + Overlay::Tip { p, text } => { + let text = crate::i18n::tr(&app.ui.language, &text).to_owned(); + let s = xf.to_screen(p) + vec2(14.0, 14.0); + let g = painter.layout(text, egui::FontId::proportional(11.0), tok.measure_text, 260.0); + let r = Rect::from_min_size(s, g.size() + vec2(10.0, 6.0)); + painter.rect_filled(r, 3.0, tok.measure_bg); + painter.galley(r.min + vec2(5.0, 3.0), g, tok.measure_text); + } + Overlay::LiquidPins { object, page, pins, resize } => draw_liquid_pins(painter, xf.rect(object), xf.rect(page), pins, resize), Overlay::Path { .. } => {} } } } +/// An object's liquid controls on an object-based page, as InDesign draws them in orange: a +/// dashed line across the object for width and height with a padlock (closed: locked), and a pin +/// outside each edge (pinned: a line to the page edge). +fn draw_liquid_pins(painter: &egui::Painter, o: Rect, page: Rect, pins: [bool; 4], resize: [bool; 2]) { + let col = Color32::from_rgb(178, 88, 18); + let st = Stroke::new(1.5, col); + let c = o.center(); + let mids = [pos2(c.x, o.min.y), pos2(o.max.x, c.y), pos2(c.x, o.max.y), pos2(o.min.x, c.y)]; + // The width and height lines between the edges' middles. + for (a, b) in [(mids[3], mids[1]), (mids[0], mids[2])] { + dashed(painter, a, b, Stroke::new(1.0, col), 3.0, 3.0); + } + for m in mids { + painter.circle_filled(m, 3.5, col); + } + let (po, lo) = (designcraft_tools::PIN_OFFSET_PX as f32, designcraft_tools::LOCK_OFFSET_PX as f32); + let outward = [vec2(0.0, -1.0), vec2(1.0, 0.0), vec2(0.0, 1.0), vec2(-1.0, 0.0)]; + let edge = [pos2(c.x, page.min.y), pos2(page.max.x, c.y), pos2(c.x, page.max.y), pos2(page.min.x, c.y)]; + for i in 0..4 { + if pins[i] { + painter.line_segment([mids[i], edge[i]], st); + painter.circle_filled(edge[i], 4.0, col); + } else { + painter.circle_stroke(mids[i] + outward[i] * po, 4.0, st); + } + } + // Padlocks: the width lock right of the centre, the height lock above it. + for (at, locked) in [(c + vec2(lo, 0.0), !resize[0]), (c + vec2(0.0, -lo), !resize[1])] { + let body = Rect::from_center_size(at + vec2(0.0, 2.0), vec2(8.0, 6.0)); + painter.rect_filled(body.expand(1.0), 1.0, Color32::WHITE); + painter.rect_filled(body, 1.0, col); + // The shackle: over the body when locked, swung open when not. + let base = if locked { body.center().x } else { body.center().x + 3.0 }; + let pts: Vec<_> = (0..=8) + .map(|k| { + let a = std::f32::consts::PI * k as f32 / 8.0; + pos2(base - 2.6 * a.cos(), body.min.y - 3.0 * a.sin()) + }) + .collect(); + painter.add(egui::Shape::line(pts, Stroke::new(1.3, col))); + } +} + #[allow(clippy::too_many_arguments)] fn draw_rulers(app: &DesignApp, ui: &egui::Ui, full: Rect, rect: Rect, xf: &Xf, doc: &Document, layout: &CanvasLayout, t: &Tokens) { let painter = ui.painter_at(full); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index ab6b52fc2..5fd1d5fd8 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -2530,6 +2530,66 @@ const TABLE: &[(&str, [&str; 5])] = &[ ["Durch übergeordnete Seite gesteuert", "Contrôlé par le parent", "Controlado por principal", "親ページで制御", "由父页面控制"], ), ("US Business Card", ["US-Visitenkarte", "Carte de visite US", "Tarjeta de visita EE. UU.", "US 名刺", "美国名片"]), + ( + "Object is pinned relative to the page edge. Click to release.", + [ + "Das Objekt ist relativ zum Seitenrand fixiert. Zum Lösen klicken.", + "L’objet est épinglé par rapport au bord de la page. Cliquez pour le libérer.", + "El objeto está fijado respecto al borde de la página. Haga clic para soltarlo.", + "オブジェクトはページの端を基準に固定されています。クリックすると解除します。", + "对象已相对于页面边缘固定。单击可释放。", + ], + ), + ( + "Click to pin the object relative to the page edge.", + [ + "Klicken, um das Objekt relativ zum Seitenrand zu fixieren.", + "Cliquez pour épingler l’objet par rapport au bord de la page.", + "Haga clic para fijar el objeto respecto al borde de la página.", + "クリックすると、オブジェクトをページの端を基準に固定します。", + "单击可将对象相对于页面边缘固定。", + ], + ), + ( + "The object changes width as the page changes width. Click to lock the object width.", + [ + "Die Objektbreite ändert sich mit der Seitenbreite. Zum Sperren der Breite klicken.", + "La largeur de l’objet suit celle de la page. Cliquez pour verrouiller la largeur.", + "El ancho del objeto cambia con el de la página. Haga clic para bloquear el ancho.", + "オブジェクトの幅はページの幅に合わせて変わります。クリックすると幅をロックします。", + "对象宽度随页面宽度变化。单击可锁定对象宽度。", + ], + ), + ( + "The object's width is locked. Click to let the object change width as the page changes width.", + [ + "Die Objektbreite ist gesperrt. Klicken, damit sie sich mit der Seitenbreite ändert.", + "La largeur de l’objet est verrouillée. Cliquez pour qu’elle suive celle de la page.", + "El ancho del objeto está bloqueado. Haga clic para que cambie con el de la página.", + "オブジェクトの幅はロックされています。クリックすると、ページの幅に合わせて変わります。", + "对象宽度已锁定。单击可让其随页面宽度变化。", + ], + ), + ( + "The object changes height as the page changes height. Click to lock the object height.", + [ + "Die Objekthöhe ändert sich mit der Seitenhöhe. Zum Sperren der Höhe klicken.", + "La hauteur de l’objet suit celle de la page. Cliquez pour verrouiller la hauteur.", + "El alto del objeto cambia con el de la página. Haga clic para bloquear el alto.", + "オブジェクトの高さはページの高さに合わせて変わります。クリックすると高さをロックします。", + "对象高度随页面高度变化。单击可锁定对象高度。", + ], + ), + ( + "The object's height is locked. Click to let the object change height as the page changes height.", + [ + "Die Objekthöhe ist gesperrt. Klicken, damit sie sich mit der Seitenhöhe ändert.", + "La hauteur de l’objet est verrouillée. Cliquez pour qu’elle suive celle de la page.", + "El alto del objeto está bloqueado. Haga clic para que cambie con el de la página.", + "オブジェクトの高さはロックされています。クリックすると、ページの高さに合わせて変わります。", + "对象高度已锁定。单击可让其随页面高度变化。", + ], + ), ("Loop", ["Loop", "Loop", "Loop", "Loop", "循环播放"]), ("Object-based", ["Object-based", "Object-based", "Object-based", "Object-based", "基于对象"]), ("Off", ["Off", "Off", "Off", "Off", "关闭"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 84b003e07..b94d63e74 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -717,6 +717,24 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Show Parent Page Overlay", "إظهار تراكب الصفحة الأصل"), ("Controlled by Parent", "تتحكم فيه الصفحة الأصل"), ("US Business Card", "بطاقة عمل أمريكية"), + ("Object is pinned relative to the page edge. Click to release.", "الكائن مثبت بالنسبة إلى حافة الصفحة. انقر لتحريره."), + ("Click to pin the object relative to the page edge.", "انقر لتثبيت الكائن بالنسبة إلى حافة الصفحة."), + ( + "The object changes width as the page changes width. Click to lock the object width.", + "يتغير عرض الكائن مع تغير عرض الصفحة. انقر لقفل عرض الكائن.", + ), + ( + "The object's width is locked. Click to let the object change width as the page changes width.", + "عرض الكائن مقفل. انقر للسماح بتغيره مع عرض الصفحة.", + ), + ( + "The object changes height as the page changes height. Click to lock the object height.", + "يتغير ارتفاع الكائن مع تغير ارتفاع الصفحة. انقر لقفل ارتفاع الكائن.", + ), + ( + "The object's height is locked. Click to let the object change height as the page changes height.", + "ارتفاع الكائن مقفل. انقر للسماح بتغيره مع ارتفاع الصفحة.", + ), ("Loop", "تكرار"), ("Object-based", "استنادًا إلى الكائنات"), ("Play on Page Load", "تشغيل عند تحميل الصفحة"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 8195bac61..eb93f1a91 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -1303,6 +1303,24 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Show Parent Page Overlay", "Mostra sovrapposizione pagina principale"), ("Controlled by Parent", "Controllato dalla pagina principale"), ("US Business Card", "Biglietto da visita USA"), + ("Object is pinned relative to the page edge. Click to release.", "L’oggetto è fissato rispetto al bordo della pagina. Fai clic per sbloccarlo."), + ("Click to pin the object relative to the page edge.", "Fai clic per fissare l’oggetto rispetto al bordo della pagina."), + ( + "The object changes width as the page changes width. Click to lock the object width.", + "La larghezza dell’oggetto cambia con quella della pagina. Fai clic per bloccarla.", + ), + ( + "The object's width is locked. Click to let the object change width as the page changes width.", + "La larghezza dell’oggetto è bloccata. Fai clic perché cambi con quella della pagina.", + ), + ( + "The object changes height as the page changes height. Click to lock the object height.", + "L’altezza dell’oggetto cambia con quella della pagina. Fai clic per bloccarla.", + ), + ( + "The object's height is locked. Click to let the object change height as the page changes height.", + "L’altezza dell’oggetto è bloccata. Fai clic perché cambi con quella della pagina.", + ), ("Loop", "Ciclo continuo"), ("Object-based", "Basato su oggetti"), ("Off", "Disattivato"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index ead7f576d..6d805ec39 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -1299,6 +1299,24 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Show Parent Page Overlay", "親ページオーバーレイを表示"), ("Controlled by Parent", "親ページで制御"), ("US Business Card", "US 名刺"), + ("Object is pinned relative to the page edge. Click to release.", "オブジェクトはページの端を基準に固定されています。クリックすると解除します。"), + ("Click to pin the object relative to the page edge.", "クリックすると、オブジェクトをページの端を基準に固定します。"), + ( + "The object changes width as the page changes width. Click to lock the object width.", + "オブジェクトの幅はページの幅に合わせて変わります。クリックすると幅をロックします。", + ), + ( + "The object's width is locked. Click to let the object change width as the page changes width.", + "オブジェクトの幅はロックされています。クリックすると、ページの幅に合わせて変わります。", + ), + ( + "The object changes height as the page changes height. Click to lock the object height.", + "オブジェクトの高さはページの高さに合わせて変わります。クリックすると高さをロックします。", + ), + ( + "The object's height is locked. Click to let the object change height as the page changes height.", + "オブジェクトの高さはロックされています。クリックすると、ページの高さに合わせて変わります。", + ), ("Loop", "繰り返し再生"), ("Object-based", "オブジェクト基準"), ("Off", "オフ"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 52874f549..5b61cc42e 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -716,6 +716,24 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Show Parent Page Overlay", "Mostrar sobreposição da página principal"), ("Controlled by Parent", "Controlado pela página principal"), ("US Business Card", "Cartão de visita EUA"), + ("Object is pinned relative to the page edge. Click to release.", "O objeto está fixado em relação à borda da página. Clique para liberar."), + ("Click to pin the object relative to the page edge.", "Clique para fixar o objeto em relação à borda da página."), + ( + "The object changes width as the page changes width. Click to lock the object width.", + "A largura do objeto muda com a da página. Clique para bloquear a largura.", + ), + ( + "The object's width is locked. Click to let the object change width as the page changes width.", + "A largura do objeto está bloqueada. Clique para que mude com a da página.", + ), + ( + "The object changes height as the page changes height. Click to lock the object height.", + "A altura do objeto muda com a da página. Clique para bloquear a altura.", + ), + ( + "The object's height is locked. Click to let the object change height as the page changes height.", + "A altura do objeto está bloqueada. Clique para que mude com a da página.", + ), ("Loop", "Repetir"), ("Object-based", "Baseado em objetos"), ("Play on Page Load", "Reproduzir ao carregar a página"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 68e15e629..85ab20294 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -1300,6 +1300,24 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Show Parent Page Overlay", "Показати накладення батьківської сторінки"), ("Controlled by Parent", "Керується батьківською сторінкою"), ("US Business Card", "Візитна картка (США)"), + ("Object is pinned relative to the page edge. Click to release.", "Об’єкт закріплено відносно краю сторінки. Клацніть, щоб звільнити."), + ("Click to pin the object relative to the page edge.", "Клацніть, щоб закріпити об’єкт відносно краю сторінки."), + ( + "The object changes width as the page changes width. Click to lock the object width.", + "Ширина об’єкта змінюється разом зі сторінкою. Клацніть, щоб заблокувати ширину.", + ), + ( + "The object's width is locked. Click to let the object change width as the page changes width.", + "Ширину об’єкта заблоковано. Клацніть, щоб вона змінювалася разом зі сторінкою.", + ), + ( + "The object changes height as the page changes height. Click to lock the object height.", + "Висота об’єкта змінюється разом зі сторінкою. Клацніть, щоб заблокувати висоту.", + ), + ( + "The object's height is locked. Click to let the object change height as the page changes height.", + "Висоту об’єкта заблоковано. Клацніть, щоб вона змінювалася разом зі сторінкою.", + ), ("Loop", "Зациклити"), ("Object-based", "На основі об’єктів"), ("Off", "Вимкнено"), diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cbcebc37b..816f5940d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -396,7 +396,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Type on a Path (⇧T tool, `type.onPath`): a story along any line, curve or shape on screen and in PDF; Type on a Path Options (start, flip, baseline/ascender/descender/centre) and Delete Type from Path. Round-trips through IDML as TextPath; on-path caret drawing is still to come. - Preferences › Appearance of Black: 100% K on screen accurately (dark grey) or as rich black (`window.richBlack`); the printing/export side is still to come. - Spreads of up to 10 pages: Add to Previous Spread and Allow Spread to Shuffle (Pages panel; `layout.pages.toSpread`, `layout.spreadShuffle`); inserted pages go around kept spreads. -- Page tool (⇧P) and `layout.pageSize`: pages of their own size (or a preset); the spine stays put and objects keep their place on their page. A click selects a page (handles, its liquid page rule tagged above it); dragging a handle previews the liquid rule and springs back, Alt-drag resizes, Shift keeps proportions, with a W/H readout; `anchor` keeps a reference point in place; double-click for exact sizes. With the Page tool the Control panel shows the page (reference point, W/H, preset, orientation, Liquid Page Rule including Controlled by Parent, Show Parent Page Overlay); X/Y and Objects Move with Page are shown, but pages don't move within their spread yet. +- Page tool (⇧P) and `layout.pageSize`: pages of their own size (or a preset); the spine stays put and objects keep their place on their page. A click selects a page (handles, its liquid page rule tagged above it); dragging a handle previews the liquid rule and springs back, Alt-drag resizes, Shift keeps proportions, with a W/H readout; `anchor` keeps a reference point in place; double-click for exact sizes. With the Page tool the Control panel shows the page (reference point, W/H, preset, orientation, Liquid Page Rule including Controlled by Parent, Show Parent Page Overlay); X/Y and Objects Move with Page are shown, but pages don't move within their spread yet. On an object-based page it selects objects and shows their pins and width/height locks on the canvas (click to toggle, with tooltips). - File › Print (⌘P): printer, copies, page range, spreads, marks, bleed; printed as PDF through the system spooler (`file.print`, `file.printers`, `dryRun` for agents). Shortcuts now act like choosing the menu item (dialogs open), and UI commands win shortcut ties (⌘N opens New Document). - Nested styles (through/up to N sentences, words, characters, letters, digits, tabs, breaks, spaces or given characters) and GREP styles (regex → character style), edited in Paragraph Style Options and composed under local formatting; `ui.dialog.open` for agents. Nested line styles are still to come; nested and GREP styles round-trip through IDML. - Table and cell styles (round-trip through IDML): cell styles (fill, insets, vertical justification, strokes, paragraph style) and table styles (region cell styles, border, alternating rows, spacing); apply from the Table panel, edits re-apply to every user; `style.cell.*`, `style.table.*`. From 9da44a042ff0d4d3eff1a5c5bb7a8dd234995960 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Wed, 14 Oct 2026 01:29:44 +0200 Subject: [PATCH 184/238] feat(ui): Liquid Layout panel in InDesign's layout; Create Alternate Layout in the Layout menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chris Cicchino's "Adobe InDesign – Page Tool and Liquid Layout" (2:47, 7:24) uses the Liquid Layout panel: "Liquid Page Rule:" with "Page: n", Content › Auto-Fit, and Object Constraints › Resize with Page (Height, Width) and Pin (Top, Left, Bottom, Right), greyed until an object on an object-based page is selected. DesignCraft's panel had a "Page 1" label, the rule, six wrapped checkboxes ("Resize width", "Pin top"…) only once an object was selected, and a Create Alternate Layout form. - Liquid Layout panel: InDesign's layout and labels; the page is the one the Page tool selected (else the page in view); Auto-Fit (graphic frames) runs object.fittingOptions; the constraints apply on object-based pages (also through Controlled by Parent) and stay greyed otherwise. The page's liquid guides stay listed below for now. - Layout menu: Create Alternate Layout… (as in InDesign). - Translations for the new labels. Builds on #864 (rule list, Controlled by Parent) and #862. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 1 + crates/ui-egui/src/i18n.rs | 8 ++ crates/ui-egui/src/i18n/ar.rs | 5 + crates/ui-egui/src/i18n/it.rs | 5 + crates/ui-egui/src/i18n/ja.rs | 5 + crates/ui-egui/src/i18n/pt_br.rs | 5 + crates/ui-egui/src/i18n/uk.rs | 5 + crates/ui-egui/src/menus.rs | 1 + crates/ui-egui/src/panels/interactive.rs | 170 +++++++++++++++++------ 9 files changed, 160 insertions(+), 45 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d9957d5d1..522ea8daa 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-14** · The Liquid Layout panel follows InDesign's: Liquid Page Rule with the page number, Content › Auto-Fit, and Object Constraints › Resize with Page and Pin for the selected object on an object-based page; Create Alternate Layout moved to the Layout menu. No readiness percentage change. - **2026-10-14** · With the Page tool, the Control panel shows the page as InDesign's does: W/H from a reference point, page-size preset (US Business Card added), orientation, Liquid Page Rule (now with Controlled by Parent) and Show Parent Page Overlay, which draws the parent page's size over a resized page. No readiness percentage change. - **2026-10-14** · The Page tool works as InDesign's: a click selects a page and shows its handles and liquid page rule; dragging a handle previews the rule and springs back, Alt-drag resizes the page from the opposite edge, Shift keeps proportions, with a W/H readout. No readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index ab6b52fc2..4d49c3152 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -2498,6 +2498,14 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Added", ["Added", "Added", "Added", "Added", "已添加"]), ("Deleted", ["Deleted", "Deleted", "Deleted", "Deleted", "已删除"]), ("Guide-based", ["Guide-based", "Guide-based", "Guide-based", "Guide-based", "基于参考线"]), + ("Page:", ["Seite:", "Page :", "Página:", "ページ:", "页面:"]), + ("Content:", ["Inhalt:", "Contenu :", "Contenido:", "コンテンツ:", "内容:"]), + ("Object Constraints:", ["Objektbeschränkungen:", "Contraintes d’objet :", "Restricciones de objeto:", "オブジェクトの制約:", "对象约束:"]), + ( + "Resize with Page:", + ["Mit Seite skalieren:", "Redimensionner avec la page :", "Cambiar tamaño con la página:", "ページと共にサイズ変更:", "随页面调整大小:"], + ), + ("Pin:", ["Fixieren:", "Épingler :", "Fijar:", "固定:", "固定:"]), ("Object-Based", ["Object-Based", "Object-Based", "Object-Based", "Object-Based", "基于对象"]), ("Guide-Based", ["Guide-Based", "Guide-Based", "Guide-Based", "Guide-Based", "基于参考线"]), ("Resize Page", ["Seitengröße ändern", "Redimensionner la page", "Cambiar tamaño de página", "ページのサイズを変更", "调整页面大小"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 84b003e07..9231209ed 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -709,6 +709,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Added", "مضاف"), ("Deleted", "محذوف"), ("Guide-based", "استنادًا إلى الأدلة"), + ("Page:", "الصفحة:"), + ("Content:", "المحتوى:"), + ("Object Constraints:", "قيود الكائن:"), + ("Resize with Page:", "تغيير الحجم مع الصفحة:"), + ("Pin:", "تثبيت:"), ("Object-Based", "استنادًا إلى الكائنات"), ("Guide-Based", "استنادًا إلى الأدلة"), ("Resize Page", "تغيير حجم الصفحة"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 8195bac61..591b4904e 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -1295,6 +1295,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Added", "Aggiunto"), ("Deleted", "Eliminato"), ("Guide-based", "Basato su guide"), + ("Page:", "Pagina:"), + ("Content:", "Contenuto:"), + ("Object Constraints:", "Vincoli oggetto:"), + ("Resize with Page:", "Ridimensiona con la pagina:"), + ("Pin:", "Fissa:"), ("Object-Based", "Basato su oggetti"), ("Guide-Based", "Basato su guide"), ("Resize Page", "Ridimensiona pagina"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index ead7f576d..c125db3f0 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -1291,6 +1291,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Added", "追加済み"), ("Deleted", "削除済み"), ("Guide-based", "ガイド基準"), + ("Page:", "ページ:"), + ("Content:", "コンテンツ:"), + ("Object Constraints:", "オブジェクトの制約:"), + ("Resize with Page:", "ページと共にサイズ変更:"), + ("Pin:", "固定:"), ("Object-Based", "オブジェクト基準"), ("Guide-Based", "ガイド基準"), ("Resize Page", "ページのサイズを変更"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 52874f549..0f5d32322 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -708,6 +708,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Added", "Adicionado"), ("Deleted", "Excluído"), ("Guide-based", "Baseado em guias"), + ("Page:", "Página:"), + ("Content:", "Conteúdo:"), + ("Object Constraints:", "Restrições de objeto:"), + ("Resize with Page:", "Redimensionar com a página:"), + ("Pin:", "Fixar:"), ("Object-Based", "Baseado em objetos"), ("Guide-Based", "Baseado em guias"), ("Resize Page", "Redimensionar página"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 68e15e629..c7e73bd4e 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -1292,6 +1292,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Added", "Додано"), ("Deleted", "Видалено"), ("Guide-based", "На основі напрямних"), + ("Page:", "Сторінка:"), + ("Content:", "Вміст:"), + ("Object Constraints:", "Обмеження об’єкта:"), + ("Resize with Page:", "Змінювати розмір зі сторінкою:"), + ("Pin:", "Закріпити:"), ("Object-Based", "На основі об’єктів"), ("Guide-Based", "На основі напрямних"), ("Resize Page", "Змінити розмір сторінки"), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 872cb81d0..26715dcd1 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -308,6 +308,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "<", "cmd:layout.marginsAndColumns", "cmd:layout.createGuides", + "cmd:layout.createAlternate", "-", "ui:view.goToPage", "-", diff --git a/crates/ui-egui/src/panels/interactive.rs b/crates/ui-egui/src/panels/interactive.rs index cd36926fa..0968c9017 100644 --- a/crates/ui-egui/src/panels/interactive.rs +++ b/crates/ui-egui/src/panels/interactive.rs @@ -501,50 +501,104 @@ pub fn liquid_rule_label(rule: designcraft_doc::LiquidRule) -> &'static str { LIQUID_RULES.iter().find(|(r, _)| *r == rule).map_or("Off", |(_, l)| l) } -/// Liquid Layout panel: the current page's rule, the selection's pins (object-based), the page's -/// liquid guides, and Create Alternate Layout. +/// Whether page `abs` follows the object-based rule (its own, or its parent page's). +fn object_based(d: &designcraft_doc::Document, abs: usize) -> bool { + use designcraft_doc::LiquidRule as L; + let Some((si, pi)) = d.page_loc(abs) else { return false }; + match d.spreads[si].pages[pi].liquid { + L::ObjectBased => true, + L::ControlledByParent => { + d.parent_page_for(abs).and_then(|(ppi, ppg)| d.parents.get(ppi)?.pages.get(ppg).map(|p| p.liquid == L::ObjectBased)).unwrap_or(false) + } + _ => false, + } +} + +/// Liquid Layout panel, laid out as InDesign's: the page's Liquid Page Rule (with "Page: n"), +/// Content › Auto-Fit, and Object Constraints › Resize with Page (Height, Width) and Pin (Top, +/// Left, Bottom, Right) for the selected object on an object-based page. The page's liquid +/// guides follow (Create Alternate Layout is in the Layout menu). pub fn liquid(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - let Some(page) = crate::canvas::current_page(app) else { - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "No document.")).size(11.0).color(t.text_dim)); + let lang = app.ui.language.clone(); + // The Page tool's page, the page in view, or (before the canvas lays out) the first. + let Some(page) = app.session.active().map(|s| s.selection.page.or_else(|| crate::canvas::current_page(app)).unwrap_or(0)) else { + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&lang, "No document.")).size(11.0).color(t.text_dim)); return; }; let Some(d) = app.session.active().map(|d| d.doc.clone()) else { return }; let Some((si, pi)) = d.page_loc(page) else { return }; let pg = d.spreads[si].pages[pi].clone(); - use designcraft_doc::LiquidRule as L; - crate::rtl::label(ui, egui::RichText::new(format!("{} {}", crate::i18n::tr(&app.ui.language, "Page"), d.page_name(page))).strong()); - let mut rule = pg.liquid; + ui.horizontal(|ui| { + crate::widgets::caption(ui, crate::i18n::tr(&lang, "Liquid Page Rule:")); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + crate::widgets::caption(ui, &format!("{} {}", crate::i18n::tr(&lang, "Page:"), d.page_name(page))); + }); + }); egui::ComboBox::from_id_salt("liquid_rule") - .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, liquid_rule_label(rule)))) + .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&lang, liquid_rule_label(pg.liquid)))) + .width(190.0) .show_ui(ui, |ui| { for (r, label) in LIQUID_RULES { - ui.selectable_value(&mut rule, r, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))); - } - }); - if rule != pg.liquid { - let _ = app.run("liquid.pageRule", json!({"rule": rule, "pages": [page + 1]})); - } - if rule == L::ObjectBased - && let Some(id) = app.session.active().and_then(|s| s.selection.items.first().copied()) - && let Some(it) = d.item(id) - { - ui.separator(); - let l = it.liquid.unwrap_or(designcraft_doc::ObjectLiquid { resize_width: true, resize_height: true, ..Default::default() }); - let mut v = [l.resize_width, l.resize_height, l.pin_top, l.pin_bottom, l.pin_left, l.pin_right]; - let keys = ["resizeWidth", "resizeHeight", "pinTop", "pinBottom", "pinLeft", "pinRight"]; - let labels = ["Resize width", "Resize height", "Pin top", "Pin bottom", "Pin left", "Pin right"]; - ui.horizontal_wrapped(|ui| { - for ((on, key), label) in v.iter_mut().zip(keys).zip(labels) { - if ui.checkbox(on, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).changed() { - let _ = app.run("liquid.object", json!({key: *on})); + if ui.selectable_label(pg.liquid == r, crate::rtl::widget(ui, crate::i18n::tr(&lang, label))).clicked() && r != pg.liquid { + let _ = app.run("liquid.pageRule", json!({"rule": r, "pages": [page + 1]})); } } }); - } + ui.separator(); + // The selected object (one on this page). + let item = app + .session + .active() + .and_then(|s| s.selection.items.first().copied()) + .and_then(|id| d.item(id)) + .filter(|it| d.page_of_item(it.id) == Some(page)); + // Content: Auto-Fit (graphic frames). + crate::widgets::caption(ui, crate::i18n::tr(&lang, "Content:")); + let graphic = item.and_then(|it| if let designcraft_doc::Content::Graphic(g) = &it.content { Some(g.auto_fit) } else { None }); + let mut auto = graphic.is_some_and(|f| f != designcraft_doc::Fitting::None); + ui.indent("liquid_content", |ui| { + let r = ui.add_enabled(graphic.is_some(), egui::Checkbox::new(&mut auto, crate::rtl::widget(ui, crate::i18n::tr(&lang, "Auto-Fit")))); + if r.changed() { + let _ = app.run("object.fittingOptions", json!({"autoFit": auto})); + } + }); + // Object Constraints: for an object on an object-based page. + crate::widgets::caption(ui, crate::i18n::tr(&lang, "Object Constraints:")); + let on = item.is_some() && object_based(&d, page); + let l = item + .filter(|_| on) + .map(|it| it.liquid.unwrap_or(designcraft_doc::ObjectLiquid { resize_width: true, resize_height: true, ..Default::default() })) + .unwrap_or_default(); + ui.indent("liquid_constraints", |ui| { + ui.add_enabled_ui(on, |ui| { + let rows: [(&str, [(&str, &str, bool); 2]); 2] = [ + ("Resize with Page:", [("Height", "resizeHeight", l.resize_height), ("Width", "resizeWidth", l.resize_width)]), + ("Pin:", [("Top", "pinTop", l.pin_top), ("Left", "pinLeft", l.pin_left)]), + ]; + for (title, boxes) in rows { + crate::widgets::caption(ui, crate::i18n::tr(&lang, title)); + let mut grid_boxes = vec![boxes.to_vec()]; + if title == "Pin:" { + grid_boxes.push(vec![("Bottom", "pinBottom", l.pin_bottom), ("Right", "pinRight", l.pin_right)]); + } + egui::Grid::new(("liquid_grid", title)).num_columns(2).min_col_width(90.0).show(ui, |ui| { + for row in grid_boxes { + for (label, key, value) in row { + let mut v = value; + if ui.checkbox(&mut v, crate::rtl::widget(ui, crate::i18n::tr(&lang, label))).changed() { + let _ = app.run("liquid.object", json!({key: v})); + } + } + ui.end_row(); + } + }); + } + }); + }); if !pg.guides.is_empty() { ui.separator(); - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Liquid guides")).size(11.0).color(t.text_dim)); + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&lang, "Liquid guides")).size(11.0).color(t.text_dim)); for (gi, g) in pg.guides.iter().enumerate() { let mut on = g.liquid; let label = format!("{:?} at {:.1}", g.orientation, g.position); @@ -553,22 +607,6 @@ pub fn liquid(app: &mut DesignApp, ui: &mut egui::Ui) { } } } - ui.separator(); - let key = egui::Id::new("alt_layout"); - let (mut name, mut w, mut h): (String, f64, f64) = ui.data(|x| x.get_temp(key)).unwrap_or(("Tablet H".into(), 1024.0, 768.0)); - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Create Alternate Layout")).size(11.0).color(t.text_dim)); - ui.horizontal(|ui| { - ui.add(egui::TextEdit::singleline(&mut name).desired_width(90.0)); - ui.add(egui::DragValue::new(&mut w).range(1.0..=15552.0).suffix(" pt")); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "×")); - ui.add(egui::DragValue::new(&mut h).range(1.0..=15552.0).suffix(" pt")); - }); - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Create"))).clicked() - && let Err(e) = app.run("layout.createAlternate", json!({"name": name, "width": w, "height": h})) - { - app.status(format!("Alternate layout: {e}")); - } - ui.data_mut(|x| x.insert_temp(key, (name, w, h))); } /// Media panel: the selected video or sound's playback options and poster. @@ -734,3 +772,45 @@ pub fn track_changes(app: &mut DesignApp, ui: &mut egui::Ui) { } }); } + +#[cfg(test)] +mod liquid_panel_tests { + use egui_kittest::{Harness, kittest::Queryable}; + use serde_json::json; + + use super::*; + + #[test] + fn object_constraints_set_the_selected_objects_pins_and_resizing() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(260.0, 420.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::<bool>(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + liquid(app, ui); + }, + app, + ); + h.run_steps(3); + let l = |h: &Harness<'_, DesignApp>| h.state().session.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().liquid; + // With the rule off the constraints don't apply. + h.get_by_label("Right").click(); + h.run_steps(2); + assert_eq!(l(&h), None); + h.state_mut().run("liquid.pageRule", json!({"rule": "objectBased"})).unwrap(); + h.run_steps(2); + h.get_by_label("Right").click(); + h.run_steps(2); + h.get_by_label("Width").click(); + h.run_steps(2); + let v = l(&h).unwrap(); + assert!(v.pin_right && !v.resize_width && v.resize_height, "{v:?}"); + let _ = h.get_by_label("Auto-Fit"); + } +} From db19963bad7969fb6832483c8ae1fe6c79508c09 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Wed, 14 Oct 2026 01:37:14 +0200 Subject: [PATCH 185/238] feat(ui): the Page tool draws liquid guides from the rulers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chris Cicchino's "Adobe InDesign – Page Tool and Liquid Layout" (11:00– 13:30) drags guides out of the rulers with the Page tool on a guide-based page: "these guides are a little bit different... they are a dashed line, a cyan dashed line" - liquid guides, which let the objects they cross stretch with the page. DesignCraft drew liquid guides dashed and applied them, but a guide could only become liquid through the Liquid Layout panel's list: ruler drags always made ruler guides. - canvas: with the Page tool, a page guide dragged from a ruler is a liquid guide; its preview is dashed while it's dragged. - engine: guide.add takes `liquid`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 1 + crates/engine/src/cmd/guides.rs | 12 +++++++-- crates/ui-egui/src/canvas.rs | 48 +++++++++++++++++++++++++++++++-- 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..92513fc39 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-14** · With the Page tool, a guide dragged from a ruler is a liquid guide (dashed), as in InDesign; guide-based pages stretch the objects it crosses. No readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/crates/engine/src/cmd/guides.rs b/crates/engine/src/cmd/guides.rs index 1111cb3b0..f4fb8e3dd 100644 --- a/crates/engine/src/cmd/guides.rs +++ b/crates/engine/src/cmd/guides.rs @@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> { "New Guide", [], None, - "{orientation: horizontal|vertical, position (spread coordinate), spread?, page? (index in the spread; default: the page under `at` or the position), at? (the other coordinate), spreadGuide?: bool} → {page, index}", + "{orientation: horizontal|vertical, position (spread coordinate), spread?, page? (index in the spread; default: the page under `at` or the position), at? (the other coordinate), spreadGuide?: bool, liquid?: bool (a liquid guide, as the Page tool draws from the rulers)} → {page, index}", has_doc, add ), @@ -98,6 +98,7 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> { let pos = finite_number(p, "position", "guide.add")?.ok_or_else(|| bad("guide.add", "missing position"))?; let r = super::spread_param(p, "guide.add")?; let spread_guide = p.get("spreadGuide").and_then(Value::as_bool).unwrap_or(false); + let liquid = p.get("liquid").and_then(Value::as_bool).unwrap_or(false); let d = &s.doc()?.doc; let pi = match optional_usize(p, "page", "guide.add")? { Some(pi) => pi, @@ -114,7 +115,14 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> { s.edit(|d, _| { let sp = d.spread_mut(r).ok_or_else(|| bad("guide.add", "no such spread"))?; let pg = sp.pages.get_mut(pi).ok_or_else(|| bad("guide.add", "no such page"))?; - pg.guides.push(Guide { orientation: o, position: pos, spread: spread_guide, locked: false, layer: Some(layer), liquid: false }); + pg.guides.push(Guide { + orientation: o, + position: pos, + spread: spread_guide, + locked: false, + layer: Some(layer), + liquid: liquid && !spread_guide, + }); Ok(json!({"page": pi, "index": pg.guides.len() - 1})) }) } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 077ae2944..eb74b4f5e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -301,7 +301,12 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { designcraft_doc::Orientation::Horizontal => (pos2(rect.min.x, p.y), pos2(rect.max.x, p.y)), designcraft_doc::Orientation::Vertical => (pos2(p.x, rect.min.y), pos2(p.x, rect.max.y)), }; - painter.line_segment([a, b], Stroke::new(1.0, col)); + // A new guide from the rulers with the Page tool is a liquid guide: dashed. + if g.from.is_none() && app.session.tool_id() == "page" { + dashed(&painter, a, b, Stroke::new(1.0, col), 6.0, 3.0); + } else { + painter.line_segment([a, b], Stroke::new(1.0, col)); + } } draw_frames(app, &painter, &xf, &doc, &layout); if app.ui.hidden_characters { @@ -1760,7 +1765,10 @@ fn guide_drag(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rec let _ = app.run("guide.move", json!({"spread": r, "page": pi, "index": gi, "position": pos_of(sp)})); } (None, true, Some((r, sp, on_page))) => { - let mut params = json!({"spread": r, "orientation": orient, "position": pos_of(sp), "at": sp.x, "spreadGuide": on_page.is_none()}); + // The Page tool draws liquid guides from the rulers. + let liquid = app.session.tool_id() == "page" && on_page.is_some(); + let mut params = + json!({"spread": r, "orientation": orient, "position": pos_of(sp), "at": sp.x, "spreadGuide": on_page.is_none(), "liquid": liquid}); if let Some(pi) = on_page { params["page"] = json!(pi); } @@ -1791,6 +1799,42 @@ mod tests { h } + /// A primary-button drag from `a` to `b` over a few frames. + fn drag(h: &mut Harness<'_, DesignApp>, a: Pos2, b: Pos2) { + h.hover_at(a); + h.step(); + h.event(egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + for k in 1..=4 { + h.hover_at(a + (b - a) * (k as f32 / 4.0)); + h.step(); + } + h.event(egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(3); + } + + #[test] + fn the_page_tool_draws_liquid_guides_from_the_rulers() { + // Chris Cicchino's Page Tool and Liquid Layout video (11:00): with the Page tool, a guide + // dragged from the ruler is a liquid guide (dashed). + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.session.set_tool("page"); + let mut h = harness(app); + let r = h.state().canvas_rect.unwrap(); + let guides = |h: &Harness<'_, DesignApp>| h.state().session.doc().unwrap().doc.spreads[0].pages[0].guides.clone(); + drag(&mut h, pos2(r.center().x, r.min.y - 8.0), r.center()); + let g = guides(&h); + assert_eq!(g.len(), 1, "{g:?}"); + assert!(g[0].liquid && g[0].orientation == designcraft_doc::Orientation::Horizontal); + // With the Selection tool it's a ruler guide. + h.state_mut().session.set_tool("selection"); + drag(&mut h, pos2(r.center().x, r.min.y - 8.0), r.center() + vec2(0.0, 40.0)); + let g = guides(&h); + assert_eq!(g.len(), 2); + assert!(!g[1].liquid); + } + fn right_click(h: &mut Harness<'_, DesignApp>, p: Pos2) { h.hover_at(p); h.step(); From 3939b825d3a5ff6709079b6e6fcdf6a8605dd3d3 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Wed, 14 Oct 2026 02:02:16 +0200 Subject: [PATCH 186/238] feat(ui): Pages panel duplicates pages by Alt-drag and the New Page button; Edit page size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chris Cicchino's "Adobe InDesign – Page Tool and Liquid Layout" (0:42, 2:06) holds Option and drags a page in the Pages panel to copy it, drags a page onto the New Page button to duplicate it ("click and drag onto my plus sign I can duplicate that"), and resizes pages; InDesign's panel also has an Edit page size button. DesignCraft's panel only moved pages on drag, had no Edit page size button, and duplicating a page gave the copy the size of the page before it (not its own) without its liquid rule or guides. A page of its own size also drew its thumbnail at the document's scale, over the panel's footer. - Pages panel: Alt-drag copies the page to where it's dropped; a page dropped on Create new page is duplicated at the end of the document; Edit page size lists the presets (in the page's orientation) and Custom Page Size…; InDesign's tooltips (Create new page, Delete selected pages). - Thumbnails share one scale (the document's page size, smaller when a taller page would pass 1.8 times the usual height); rows fit their spread's tallest page. - engine: layout.pages.duplicateSpread copies the pages' size, margins, columns, guides and liquid rule, takes `atEnd`, and returns the copy's first page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 1 + crates/engine/src/cmd/layout.rs | 53 ++++++++---- crates/engine/src/tests.rs | 26 ++++++ crates/ui-egui/src/i18n.rs | 16 ++++ crates/ui-egui/src/i18n/ar.rs | 4 + crates/ui-egui/src/i18n/it.rs | 4 + crates/ui-egui/src/i18n/ja.rs | 4 + crates/ui-egui/src/i18n/pt_br.rs | 4 + crates/ui-egui/src/i18n/uk.rs | 4 + crates/ui-egui/src/panels/pages.rs | 106 +++++++++++++++++++++++- crates/ui-egui/src/panels/properties.rs | 4 +- 11 files changed, 205 insertions(+), 21 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..cf4d92cff 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-14** · Pages panel: Alt-drag copies a page, a page dropped on Create new page is duplicated at the end, Edit page size offers the presets and Custom Page Size…; duplicated pages keep their own size, rule and guides; thumbnails of pages of their own size stay in their rows. No readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index bd5320799..663e10791 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -135,21 +135,44 @@ pub fn specs() -> Vec<CommandSpec> { ok() }) }), - cmd!("layout.pages.duplicateSpread", "Duplicate Spread", ["Layout", "Pages"], None, "{spread}", has_doc, |s, p| { - let si = p.get("spread").and_then(Value::as_u64).unwrap_or(0) as usize; - s.edit(|d, _| { - let sp = d.spreads.get(si).cloned().ok_or_else(|| bad("layout.pages.duplicateSpread", "no such spread"))?; - let after = d.first_page_of_spread(si) + sp.pages.len() - 1; - let parent = sp.pages.first().and_then(|p| p.parent); - d.insert_pages(Some(after), sp.pages.len(), parent)?; - // Copy items onto the new spread(s) at the same positions. - let src = d.clone(); - let ids: Vec<_> = sp.items.iter().map(|i| i.id).collect(); - let (nsi, _) = d.page_loc(after + 1).ok_or_else(|| bad("layout.pages.duplicateSpread", "insert failed"))?; - super::object::duplicate_from(d, &src, &ids, designcraft_doc::SpreadRef::Doc(nsi), designcraft_geom::Vec2::ZERO)?; - ok() - }) - }), + cmd!( + "layout.pages.duplicateSpread", + "Duplicate Spread", + ["Layout", "Pages"], + None, + "{spread, atEnd?: bool (Duplicate Spread at End of Document; the Pages panel's New Page button takes a dropped page there)} → {page: the copy's first page (0-based)} — the spread's pages (size, margins, columns, guides, liquid rule) and items", + has_doc, + |s, p| { + let si = p.get("spread").and_then(Value::as_u64).unwrap_or(0) as usize; + let at_end = p.get("atEnd").and_then(Value::as_bool).unwrap_or(false); + s.edit(|d, _| { + let sp = d.spreads.get(si).cloned().ok_or_else(|| bad("layout.pages.duplicateSpread", "no such spread"))?; + let after = if at_end { d.page_count() - 1 } else { d.first_page_of_spread(si) + sp.pages.len() - 1 }; + let parent = sp.pages.first().and_then(|p| p.parent); + d.insert_pages(Some(after), sp.pages.len(), parent)?; + // The copies are the pages they copy: size, margins, columns, guides, liquid rule. + for (k, src) in sp.pages.iter().enumerate() { + let Some((nsi, npi)) = d.page_loc(after + 1 + k) else { continue }; + let nsp = std::sync::Arc::make_mut(&mut d.spreads[nsi]); + let pg = &mut nsp.pages[npi]; + let dx = pg.x - src.x; + (pg.width, pg.height, pg.margins, pg.columns, pg.liquid) = + (src.width, src.height, src.margins, src.columns.clone(), src.liquid); + pg.guides = src.guides.clone(); + for g in pg.guides.iter_mut().filter(|g| g.orientation == designcraft_doc::Orientation::Vertical) { + g.position += dx; + } + nsp.relayout(); + } + // Copy items onto the new spread(s) at the same positions. + let src = d.clone(); + let ids: Vec<_> = sp.items.iter().map(|i| i.id).collect(); + let (nsi, _) = d.page_loc(after + 1).ok_or_else(|| bad("layout.pages.duplicateSpread", "insert failed"))?; + super::object::duplicate_from(d, &src, &ids, designcraft_doc::SpreadRef::Doc(nsi), designcraft_geom::Vec2::ZERO)?; + Ok(json!({"page": after + 1})) + }) + } + ), cmd!( "layout.pages.applyParent", "Apply Parent to Pages…", diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 17d1ed098..f896ab3cd 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -569,3 +569,29 @@ fn commands_take_non_object_params_without_panicking() { assert!(r.is_ok(), "{p}: {r:?}"); } } + +#[test] +fn duplicating_a_page_keeps_its_own_size_rule_guides_and_objects() { + // Chris Cicchino's Page Tool video (2:06): the letterhead page is duplicated, then resized. + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 252, "height": 144, "facingPages": false})).unwrap(); + s.execute("layout.pages.insert", &json!({"count": 1})).unwrap(); + s.execute("layout.pageSize", &json!({"pages": [2], "width": 612, "height": 792})).unwrap(); + s.execute("liquid.pageRule", &json!({"rule": "scale", "pages": [2]})).unwrap(); + s.execute("guide.add", &json!({"spread": 1, "page": 0, "orientation": "vertical", "position": 300})).unwrap(); + s.execute("frame.create", &json!({"spread": 1, "rect": [100, 100, 200, 200]})).unwrap(); + let r = s.execute("layout.pages.duplicateSpread", &json!({"spread": 1})).unwrap(); + assert_eq!(r["page"], 2); + let d = s.doc().unwrap().doc.clone(); + let (si, pi) = d.page_loc(2).unwrap(); + let pg = &d.spreads[si].pages[pi]; + assert_eq!((pg.width, pg.height, pg.liquid), (612.0, 792.0, designcraft_doc::LiquidRule::Scale)); + assert_eq!(pg.guides.len(), 1); + assert_eq!(d.spreads[si].items.len(), 1); + // At the end of the document (the New Page button): the business card, not the last page's size. + let r = s.execute("layout.pages.duplicateSpread", &json!({"spread": 0, "atEnd": true})).unwrap(); + assert_eq!(r["page"], 3); + let d = s.doc().unwrap().doc.clone(); + let (si, pi) = d.page_loc(3).unwrap(); + assert_eq!((d.spreads[si].pages[pi].width, d.spreads[si].pages[pi].height), (252.0, 144.0)); +} diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 5252c3881..93728dde5 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -612,6 +612,22 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Edit Page", ["Edit Page", "Edit Page", "Edit Page", "Edit Page", "编辑页面"]), ("Import File", ["Import File", "Import File", "Import File", "Import File", "导入文件"]), ("Insert Page", ["Insert Page", "Insert Page", "Insert Page", "Insert Page", "插入页面"]), + ( + "Delete selected pages", + ["Ausgewählte Seiten löschen", "Supprimer les pages sélectionnées", "Eliminar páginas seleccionadas", "選択したページを削除", "删除选定页面"], + ), + ("Create new page", ["Neue Seite erstellen", "Créer une page", "Crear página nueva", "新規ページを作成", "新建页面"]), + ("Edit page size", ["Seitenformat bearbeiten", "Modifier le format de page", "Editar tamaño de página", "ページサイズを編集", "编辑页面大小"]), + ( + "Custom Page Size…", + [ + "Benutzerdefiniertes Seitenformat …", + "Format de page personnalisé…", + "Tamaño de página personalizado…", + "カスタムページサイズ…", + "自定页面大小…", + ], + ), ("Hide Bar", ["Hide Bar", "Hide Bar", "Hide Bar", "Hide Bar", "隐藏栏"]), ("Properties Panel", ["Properties Panel", "Properties Panel", "Properties Panel", "Properties Panel", "属性面板"]), ("New Workspace…", ["New Workspace…", "New Workspace…", "New Workspace…", "New Workspace…", "新建工作区…"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index a182d09ec..4373051b8 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -270,6 +270,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Quick Actions", "الإجراءات السريعة"), ("Import File", "استيراد ملف"), ("Insert Page", "إدراج صفحة"), + ("Delete selected pages", "حذف الصفحات المحددة"), + ("Create new page", "إنشاء صفحة جديدة"), + ("Edit page size", "تحرير حجم الصفحة"), + ("Custom Page Size…", "حجم صفحة مخصص…"), ("Edit Page", "تحرير الصفحة"), ("Adjust Layout", "ضبط التخطيط"), ("Smart Guides", "أدلة ذكية"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 75ece03f6..f86e1b196 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -363,6 +363,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Edit Page", "Modifica pagina"), ("Import File", "Importa file"), ("Insert Page", "Inserisci pagina"), + ("Delete selected pages", "Elimina pagine selezionate"), + ("Create new page", "Crea nuova pagina"), + ("Edit page size", "Modifica dimensioni pagina"), + ("Custom Page Size…", "Dimensioni pagina personalizzate…"), ("Hide Bar", "Nascondi barra"), ("Properties Panel", "Pannello Proprietà"), ("New Workspace…", "Nuova area di lavoro…"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 642b4a4c0..53833dd01 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -365,6 +365,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Edit Page", "ページを編集"), ("Import File", "ファイルを読み込み"), ("Insert Page", "ページを挿入"), + ("Delete selected pages", "選択したページを削除"), + ("Create new page", "新規ページを作成"), + ("Edit page size", "ページサイズを編集"), + ("Custom Page Size…", "カスタムページサイズ…"), ("Hide Bar", "バーを隠す"), ("Properties Panel", "プロパティパネル"), ("New Workspace…", "新規ワークスペース…"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 982dfb20d..25229c806 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -271,6 +271,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Quick Actions", "Ações rápidas"), ("Import File", "Importar arquivo"), ("Insert Page", "Inserir página"), + ("Delete selected pages", "Excluir páginas selecionadas"), + ("Create new page", "Criar nova página"), + ("Edit page size", "Editar tamanho da página"), + ("Custom Page Size…", "Tamanho de página personalizado…"), ("Edit Page", "Editar página"), ("Adjust Layout", "Ajustar layout"), ("Smart Guides", "Guias inteligentes"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index f3bc8fb35..7adad1644 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -366,6 +366,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Edit Page", "Редагувати сторінку"), ("Import File", "Імпортувати файл"), ("Insert Page", "Вставити сторінку"), + ("Delete selected pages", "Видалити вибрані сторінки"), + ("Create new page", "Створити нову сторінку"), + ("Edit page size", "Змінити розмір сторінки"), + ("Custom Page Size…", "Власний розмір сторінки…"), ("Hide Bar", "Сховати панель"), ("Properties Panel", "Панель властивостей"), ("New Workspace…", "Нове робоче середовище…"), diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index ae323225b..d70b3427e 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -114,11 +114,16 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let cur_spread = doc.page_loc(cur).map(|l| l.0); let center = ui.available_width() / 2.0; let dragging = egui::DragAndDrop::has_any_payload(ui.ctx()); + let tallest = doc.spreads.iter().flat_map(|sp| sp.pages.iter().map(|p| p.height)).fold(doc.settings.page_height, f64::max).max(1.0); for (si, sp) in doc.spreads.iter().enumerate() { let first = doc.first_page_of_spread(si); - let scale = THUMB_H / doc.settings.page_height.max(1.0) as f32; + // One scale for the whole document (pages of their own size show in proportion): the + // document's page size at the usual height, unless a taller page would pass 1.8 times it. + // Each row is as tall as its spread's tallest page. + let scale = (THUMB_H / doc.settings.page_height.max(1.0) as f32).min(1.8 * THUMB_H / tallest as f32); + let row_h = sp.pages.iter().map(|p| p.height as f32 * scale).fold(0.0, f32::max).max(THUMB_H * 0.25); let spine = sp.spine_x() as f32; - let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 8.0 + THUMB_H + 22.0), Sense::hover()); + let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 8.0 + row_h + 22.0), Sense::hover()); let top = row.min.y + 8.0; // Marker above the current spread. if cur_spread == Some(si) && !editing_parents { @@ -197,15 +202,55 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { .color(t.text_dim), ); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if icons::button(ui, "trash", 20.0, false, crate::i18n::tr(&app.ui.language, "Delete Page")).clicked() { + if icons::button(ui, "trash", 20.0, false, crate::i18n::tr(&app.ui.language, "Delete selected pages")).clicked() { let _ = app.run("layout.pages.delete", json!({"pages": [cur]})); } - if icons::button(ui, "plus", 20.0, false, crate::i18n::tr(&app.ui.language, "Insert Page")).clicked() { + // Create new page; a page dropped on it is duplicated at the end of the document. + let plus = icons::button(ui, "plus", 20.0, dragging, crate::i18n::tr(&app.ui.language, "Create new page")); + if let Some(payload) = plus.dnd_release_payload::<PageDrag>() { + if let PageDrag::Page(from) = *payload + && let Some((si, _)) = doc.page_loc(from) + { + let _ = app.run("layout.pages.duplicateSpread", json!({"spread": si, "atEnd": true})); + } + } else if plus.clicked() { let _ = app.run("layout.pages.insert", json!({"after": cur, "count": 1})); } + // Edit page size: InDesign's presets for the current page, or Custom Page Size…. + let size = icons::button(ui, "panel-pages", 20.0, false, crate::i18n::tr(&app.ui.language, "Edit page size")); + egui::Popup::menu(&size).show(|ui| { + let Some((si, pi)) = doc.page_loc(cur) else { return }; + let pg = &doc.spreads[si].pages[pi]; + let landscape = pg.width > pg.height; + let current = crate::panels::properties::preset_name(pg.width, pg.height); + for (name, w, h) in crate::panels::properties::PAGE_PRESETS { + let (w, h) = if landscape { (h, w) } else { (w, h) }; + if ui.selectable_label(current == *name, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, name))).clicked() { + let _ = app.run("layout.pageSize", json!({"pages": [cur + 1], "width": w, "height": h})); + ui.close(); + } + } + ui.separator(); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Custom Page Size…"))).clicked() { + app.ui.dialog = + Some(crate::dialogs::Dialog::new("cmd:layout.pageSize", json!({"pages": [cur + 1], "width": pg.width, "height": pg.height}))); + ui.close(); + } + }); }); }); + // Alt-drag (InDesign's Option-drag) copies the page to where it's dropped. + let copy = ui.input(|i| i.modifiers.alt); match drop { + Some((PageDrag::Page(from), to)) if copy => { + if let Some((si, _)) = doc.page_loc(from) + && let Ok(r) = app.run("layout.pages.duplicateSpread", json!({"spread": si})) + && let Some(new) = r["page"].as_u64().map(|p| p as usize) + && new != to + { + let _ = app.run("layout.pages.move", json!({"from": new, "to": to})); + } + } Some((PageDrag::Page(from), to)) if from != to => { let _ = app.run("layout.pages.move", json!({"from": from, "to": to})); } @@ -289,3 +334,56 @@ fn drag_ghost(ui: &egui::Ui, label: &str, t: &Tokens) { painter.rect(r, 2.0, t.panel_darker, Stroke::new(1.0, t.field_border), StrokeKind::Inside); painter.galley(r.min + vec2(5.0, 3.0), g, t.text_strong); } + +#[cfg(test)] +mod tests { + use egui_kittest::{Harness, kittest::Queryable}; + use serde_json::json; + + use super::*; + + fn harness(app: DesignApp) -> Harness<'static, DesignApp> { + let mut h = Harness::builder().with_size(vec2(280.0, 520.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::<bool>(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + show(app, ui); + }, + app, + ); + h.run_steps(3); + h + } + + /// Release a dragged page over `at`. + fn drop_page(h: &mut Harness<'_, DesignApp>, page: usize, at: egui::Pos2, alt: bool) { + let modifiers = egui::Modifiers { alt, ..Default::default() }; + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers }); + egui::DragAndDrop::set_payload(&h.ctx, PageDrag::Page(page)); + h.event(egui::Event::PointerMoved(at)); + h.run_steps(2); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers }); + h.run_steps(3); + } + + #[test] + fn a_page_dropped_on_create_new_page_is_duplicated_at_the_end() { + // Chris Cicchino's Page Tool video (2:06): "click and drag onto my plus sign I can duplicate that". + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 252, "height": 144, "facingPages": false})).unwrap(); + app.run("layout.pages.insert", json!({"count": 1})).unwrap(); + app.run("layout.pageSize", json!({"pages": [2], "width": 612, "height": 792})).unwrap(); + let mut h = harness(app); + let plus = h.get_by_label("Create new page").rect().center(); + drop_page(&mut h, 1, plus, false); + let d = h.state().session.doc().unwrap().doc.clone(); + assert_eq!(d.page_count(), 3); + let (si, pi) = d.page_loc(2).unwrap(); + assert_eq!(d.spreads[si].pages[pi].width, 612.0, "a copy of the letterhead"); + let _ = h.get_by_label("Edit page size"); + } +} diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index d9c74e40d..89e12890c 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -160,7 +160,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { // ---------------------------------------------------------------- No Selection /// Page-size presets for the Document dropdown (portrait, points). -const PAGE_PRESETS: &[(&str, f64, f64)] = &[ +pub(crate) const PAGE_PRESETS: &[(&str, f64, f64)] = &[ ("Letter", 612.0, 792.0), ("Legal", 612.0, 1008.0), ("Tabloid", 792.0, 1224.0), @@ -173,7 +173,7 @@ const PAGE_PRESETS: &[(&str, f64, f64)] = &[ ("B5", 498.898, 708.661), ]; -fn preset_name(w: f64, h: f64) -> &'static str { +pub(crate) fn preset_name(w: f64, h: f64) -> &'static str { let (a, b) = (w.min(h), w.max(h)); PAGE_PRESETS.iter().find(|p| (p.1 - a).abs() < 0.6 && (p.2 - b).abs() < 0.6).map(|p| p.0).unwrap_or("Custom") } From 8156e575bc1b403e6c62054fca132e4a35ff927e Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Wed, 14 Oct 2026 02:15:27 +0200 Subject: [PATCH 187/238] feat(ui): Pages panel menu in InDesign's order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Pages panel menu in Chris Cicchino's "Adobe InDesign – Page Tool and Liquid Layout" (0:47) reads: Insert Pages…, Move Pages…, Duplicate Spread, Duplicate Spread at End of Document, Delete Spread, … New Parent…, Apply Parent to Pages…, Override All Parent Page Items, Parent Pages ▸, Create Alternate Layout…, Numbering & Section Options…, Allow Selected Spread to Shuffle. DesignCraft's menu (and the page thumbnails' context menu) had Insert Page After, Delete Page, Duplicate Spread, Add to Previous Spread, Allow Spread to Shuffle and the parent items in another order. - Pages panel menu and page context menu in InDesign's order and labels: Insert Pages… and Move Pages… open their dialogs for the page; Delete Spread for a spread of several pages; Parent Pages ▸ holds Remove All Local Overrides and Detach All Objects from Parent; Create Alternate Layout… and Numbering & Section Options… open theirs. Add to Previous Spread stays last (InDesign drags pages into spreads instead). - Translations (pt-BR gains the existing items it lacked). Builds on #869 (Duplicate Spread at End of Document). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 1 + crates/ui-egui/src/i18n.rs | 22 ++++ crates/ui-egui/src/i18n/ar.rs | 4 + crates/ui-egui/src/i18n/it.rs | 4 + crates/ui-egui/src/i18n/ja.rs | 3 + crates/ui-egui/src/i18n/pt_br.rs | 12 +++ crates/ui-egui/src/i18n/uk.rs | 4 + crates/ui-egui/src/panel_docking_tests.rs | 2 +- crates/ui-egui/src/panels/pages.rs | 119 +++++++++++++++++----- 9 files changed, 142 insertions(+), 29 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index cf4d92cff..c091b6658 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-14** · The Pages panel menu follows InDesign's order: Insert Pages…, Move Pages…, Duplicate Spread (and at End of Document), Delete Page/Spread, New Parent…, Apply Parent to Pages, Override All Parent Page Items, Parent Pages, Create Alternate Layout…, Numbering & Section Options…, Allow Selected Spread to Shuffle. No readiness percentage change. - **2026-10-14** · Pages panel: Alt-drag copies a page, a page dropped on Create new page is duplicated at the end, Edit page size offers the presets and Custom Page Size…; duplicated pages keep their own size, rule and guides; thumbnails of pages of their own size stay in their rows. No readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 93728dde5..7ab2e23f3 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -296,6 +296,28 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Document Setup", ["Document Setup", "Document Setup", "Document Setup", "Document Setup", "文档设置"]), ("Drop Shadow", ["Drop Shadow", "Drop Shadow", "Drop Shadow", "Drop Shadow", "投影"]), ("Duplicate Spread", ["Duplicate Spread", "Duplicate Spread", "Duplicate Spread", "Duplicate Spread", "复制跨页"]), + ( + "Duplicate Spread at End of Document", + [ + "Druckbogen am Dokumentende duplizieren", + "Dupliquer la planche à la fin du document", + "Duplicar pliego al final del documento", + "ドキュメントの最後にスプレッドを複製", + "在文档末尾复制跨页", + ], + ), + ("Delete Spread", ["Druckbogen löschen", "Supprimer la planche", "Eliminar pliego", "スプレッドを削除", "删除跨页"]), + ("Parent Pages", ["Übergeordnete Seiten", "Pages parents", "Páginas principales", "親ページ", "父页面"]), + ( + "Apply Parent to Pages", + [ + "Übergeordnete Seite auf Seiten anwenden", + "Appliquer le parent aux pages", + "Aplicar principal a páginas", + "親ページを適用", + "将父页面应用于页面", + ], + ), ("Email", ["Email", "Email", "Email", "Email", "电子邮件"]), ("Embed", ["Embed", "Embed", "Embed", "Embed", "嵌入"]), ("Export PDF", ["PDF exportieren", "Exporter en PDF", "Exportar PDF", "PDF を書き出し", "导出PDF"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 4373051b8..75dd0fea0 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -864,6 +864,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Document Grid", "شبكة المستند"), ("Document Recovery Data", "بيانات استرداد المستند"), ("Duplicate Spread", "تكرار الصفحات المتقابلة"), + ("Duplicate Spread at End of Document", "تكرار الانتشار في نهاية المستند"), + ("Delete Spread", "حذف الانتشار"), + ("Parent Pages", "الصفحات الأصل"), + ("Apply Parent to Pages", "تطبيق الأصل على الصفحات"), ("Email", "بريد إلكتروني"), ("Embed", "تضمين"), ("Fast", "سريع"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index f86e1b196..735bd38c5 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -177,6 +177,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Document Setup", "Impostazione documento"), ("Drop Shadow", "Ombra esterna"), ("Duplicate Spread", "Duplica doppia pagina"), + ("Duplicate Spread at End of Document", "Duplica pagine affiancate alla fine del documento"), + ("Delete Spread", "Elimina pagine affiancate"), + ("Parent Pages", "Pagine principali"), + ("Apply Parent to Pages", "Applica pagina principale alle pagine"), ("Email", "E-mail"), ("Embed", "Incorpora"), ("Export XML…", "Esporta XML…"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 53833dd01..87c0aea64 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -179,6 +179,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Document Setup", "ドキュメント設定"), ("Drop Shadow", "ドロップシャドウ"), ("Duplicate Spread", "スプレッドを複製"), + ("Duplicate Spread at End of Document", "ドキュメントの最後にスプレッドを複製"), + ("Delete Spread", "スプレッドを削除"), + ("Parent Pages", "親ページ"), ("Email", "メール"), ("Embed", "埋め込み"), ("Export XML…", "XML を書き出し…"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 25229c806..d4456e5e2 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -866,6 +866,18 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Document Grid", "Grade do documento"), ("Document Recovery Data", "Dados de recuperação do documento"), ("Duplicate Spread", "Duplicar página dupla"), + ("Insert Pages…", "Inserir páginas…"), + ("Move Pages…", "Mover páginas…"), + ("New Parent…", "Nova página principal…"), + ("Override All Parent Page Items", "Substituir todos os itens da página principal"), + ("Remove All Local Overrides", "Remover todas as substituições locais"), + ("Detach All Objects from Parent", "Desanexar todos os objetos da página principal"), + ("Create Alternate Layout…", "Criar layout alternativo…"), + ("Allow Selected Spread to Shuffle", "Permitir que a página espelhada selecionada seja reorganizada"), + ("Duplicate Spread at End of Document", "Duplicar página espelhada no final do documento"), + ("Delete Spread", "Excluir página espelhada"), + ("Parent Pages", "Páginas principais"), + ("Apply Parent to Pages", "Aplicar página principal às páginas"), ("Email", "E-mail"), ("Embed", "Embutir"), ("Fast", "Rápido"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 7adad1644..4cc572cd9 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -180,6 +180,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Document Setup", "Параметри документа"), ("Drop Shadow", "Тінь"), ("Duplicate Spread", "Дублювати розворот"), + ("Duplicate Spread at End of Document", "Дублювати розворот у кінці документа"), + ("Delete Spread", "Видалити розворот"), + ("Parent Pages", "Батьківські сторінки"), + ("Apply Parent to Pages", "Застосувати батьківську до сторінок"), ("Email", "Електронна пошта"), ("Embed", "Вбудувати"), ("Export XML…", "Експортувати XML…"), diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index de08e3cf5..56b906211 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -617,7 +617,7 @@ fn legacy_dock_menu_button_opens_the_active_panel_menu() { } h.run_steps(2); assert!(egui::Popup::is_any_open(&h.ctx), "the three-line button must open a menu"); - h.get_by_label("Insert Page After").click(); + h.get_by_label("Duplicate Spread").click(); h.run_steps(3); assert_eq!(h.state().session.active().unwrap().doc.page_count(), pages + 1); assert!(!egui::Popup::is_any_open(&h.ctx)); diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index d70b3427e..dfc69dfff 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -271,47 +271,39 @@ pub fn panel_menu(app: &mut DesignApp, ui: &mut egui::Ui) { } fn page_menu(app: &mut DesignApp, ui: &mut egui::Ui, doc: &designcraft_doc::Document, abs: usize, si: usize) { - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Insert Page After"))).clicked() { - let _ = app.run("layout.pages.insert", json!({"after": abs, "count": 1})); + let lang = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang, s).to_owned(); + let pages_in_spread = doc.spreads.get(si).map_or(1, |sp| sp.pages.len()); + let item = |ui: &mut egui::Ui, label: &str| ui.button(crate::rtl::widget(ui, tr(label))).clicked(); + // InDesign's order: pages, parents, layouts and numbering, shuffling. + if item(ui, "Insert Pages…") { + app.ui.dialog = Some(crate::dialogs::Dialog::new("cmd:layout.pages.insert", json!({"after": abs, "count": 1}))); ui.close(); } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Delete Page"))).clicked() { - let _ = app.run("layout.pages.delete", json!({"pages": [abs]})); + if item(ui, "Move Pages…") { + app.ui.dialog = Some(crate::dialogs::Dialog::new("cmd:layout.pages.move", json!({"from": abs, "to": abs}))); ui.close(); } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Duplicate Spread"))).clicked() { + if item(ui, "Duplicate Spread") { let _ = app.run("layout.pages.duplicateSpread", json!({"spread": si})); ui.close(); } - if si > 0 - && ui - .button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Add to Previous Spread"))) - .on_hover_ui(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Spreads of up to 10 pages")); - }) - .clicked() - { - let _ = app.run("layout.pages.toSpread", json!({"page": abs + 1, "spread": si - 1})); + if item(ui, "Duplicate Spread at End of Document") { + let _ = app.run("layout.pages.duplicateSpread", json!({"spread": si, "atEnd": true})); ui.close(); } - let mut shuffle = doc.spreads.get(si).is_none_or(|sp| sp.allow_shuffle); - if ui.checkbox(&mut shuffle, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Allow Spread to Shuffle"))).clicked() { - let _ = app.run("layout.spreadShuffle", json!({"spread": si, "allow": shuffle})); + if item(ui, if pages_in_spread > 1 { "Delete Spread" } else { "Delete Page" }) { + let first = doc.first_page_of_spread(si); + let _ = app.run("layout.pages.delete", json!({"pages": (first..first + pages_in_spread).collect::<Vec<_>>()})); ui.close(); } ui.separator(); - for (id, label) in [ - ("layout.overrideParentItems", "Override All Parent Page Items"), - ("layout.removeOverrides", "Remove All Local Overrides"), - ("layout.detachAll", "Detach All Objects from Parent"), - ] { - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() { - let _ = app.run(id, json!({"page": abs})); - ui.close(); - } + if item(ui, "New Parent…") { + crate::menus::activate(app, "layout.parents.new", &serde_json::Value::Null); + ui.close(); } - crate::menus::menu_button(ui, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Apply Parent")), |ui| { - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "[None]"))).clicked() { + crate::menus::menu_button(ui, crate::rtl::widget(ui, tr("Apply Parent to Pages")), |ui| { + if ui.button(crate::rtl::widget(ui, tr("[None]"))).clicked() { let _ = app.run("layout.pages.applyParent", json!({"pages": [abs], "parent": null})); ui.close(); } @@ -323,6 +315,45 @@ fn page_menu(app: &mut DesignApp, ui: &mut egui::Ui, doc: &designcraft_doc::Docu } } }); + if item(ui, "Override All Parent Page Items") { + let _ = app.run("layout.overrideParentItems", json!({"page": abs})); + ui.close(); + } + crate::menus::menu_button(ui, crate::rtl::widget(ui, tr("Parent Pages")), |ui| { + for (id, label) in [("layout.removeOverrides", "Remove All Local Overrides"), ("layout.detachAll", "Detach All Objects from Parent")] { + if ui.button(crate::rtl::widget(ui, tr(label))).clicked() { + let _ = app.run(id, json!({"page": abs})); + ui.close(); + } + } + }); + ui.separator(); + if item(ui, "Create Alternate Layout…") { + crate::menus::activate(app, "layout.createAlternate", &serde_json::Value::Null); + ui.close(); + } + if item(ui, "Numbering & Section Options…") { + crate::menus::activate(app, "layout.section", &serde_json::Value::Null); + ui.close(); + } + ui.separator(); + let mut shuffle = doc.spreads.get(si).is_none_or(|sp| sp.allow_shuffle); + if ui.checkbox(&mut shuffle, crate::rtl::widget(ui, tr("Allow Selected Spread to Shuffle"))).clicked() { + let _ = app.run("layout.spreadShuffle", json!({"spread": si, "allow": shuffle})); + ui.close(); + } + // Not in InDesign's menu (it drags pages into spreads): kept for spreads of up to 10 pages. + if si > 0 + && ui + .button(crate::rtl::widget(ui, tr("Add to Previous Spread"))) + .on_hover_ui(|ui| { + crate::rtl::label(ui, tr("Spreads of up to 10 pages")); + }) + .clicked() + { + let _ = app.run("layout.pages.toSpread", json!({"page": abs + 1, "spread": si - 1})); + ui.close(); + } } /// A small label following the pointer while dragging. @@ -370,6 +401,38 @@ mod tests { h.run_steps(3); } + #[test] + fn the_panel_menu_follows_indesigns_order() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 2, "facingPages": false})).unwrap(); + let mut h = Harness::builder().with_size(vec2(320.0, 600.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::<bool>(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + let doc = app.session.doc().unwrap().doc.clone(); + page_menu(app, ui, &doc, 0, 0); + }, + app, + ); + h.run_steps(3); + // InDesign's items, top to bottom. + let ys: Vec<f32> = ["Insert Pages…", "Move Pages…", "Duplicate Spread", "Duplicate Spread at End of Document", "Delete Page", "New Parent…"] + .iter() + .map(|l| h.get_by_label(l).rect().center().y) + .collect(); + assert!(ys.windows(2).all(|w| w[0] < w[1]), "{ys:?}"); + h.get_by_label("Duplicate Spread at End of Document").click(); + h.run_steps(3); + assert_eq!(h.state().session.doc().unwrap().doc.page_count(), 3); + h.get_by_label("Insert Pages…").click(); + h.run_steps(3); + assert!(h.state().ui.dialog.as_ref().is_some_and(|d| d.id == "cmd:layout.pages.insert")); + } + #[test] fn a_page_dropped_on_create_new_page_is_duplicated_at_the_end() { // Chris Cicchino's Page Tool video (2:06): "click and drag onto my plus sign I can duplicate that". From 0f4f382b0eb6336312e381686af97958e9108b67 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:41:46 -0400 Subject: [PATCH 188/238] feat(text): improve editing, snapping, and threading Add multi-click text selection, pending caret formatting and clipboard support, text-aware colour controls, guide/grid snapping, and frame threading with loaded-port interactions. Include the layout, drop-cap and IDML updates carried from #83, with safeguards for generated and linked stories, external text references, local IDs, locked frames and atomic colour edits. Fixes #23. Co-authored-by: Sean Hammond <sean.hammond@und.edu> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- Cargo.lock | 3 + ROADMAP.md | 31 +- apps/designcraft/Cargo.toml | 1 + apps/designcraft/src/main.rs | 1 + crates/compose/Cargo.toml | 1 + crates/compose/src/lib.rs | 627 ++++++++-- crates/compose/src/notes.rs | 7 +- crates/compose/src/overlay.rs | 43 +- crates/compose/src/shape.rs | 55 +- crates/compose/src/table.rs | 14 +- crates/compose/src/tests.rs | 1110 ++++++++++++++++- crates/doc/src/attrs.rs | 32 +- crates/doc/src/edit.rs | 292 ++++- crates/doc/src/ids.rs | 21 + crates/doc/src/item.rs | 54 +- crates/doc/src/lib.rs | 8 +- crates/doc/src/notes.rs | 2 +- crates/doc/src/page.rs | 18 +- crates/doc/src/slice.rs | 108 ++ crates/doc/src/styles.rs | 4 +- crates/doc/src/tests.rs | 120 ++ crates/engine/Cargo.toml | 1 + crates/engine/src/cmd/edit.rs | 172 ++- crates/engine/src/cmd/file.rs | 20 + crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/notes.rs | 2 +- crates/engine/src/cmd/object.rs | 2 +- crates/engine/src/cmd/place_text.rs | 16 +- crates/engine/src/cmd/style.rs | 187 ++- crates/engine/src/cmd/text.rs | 629 +++++++++- crates/engine/src/cmd/text_color_tests.rs | 181 +++ crates/engine/src/cmd/thread.rs | 396 ++++++ crates/engine/src/lib.rs | 5 +- crates/engine/src/tests.rs | 100 ++ crates/engine/src/tests_idml.rs | 112 ++ crates/engine/tests/primary_frame_columns.rs | 34 + .../engine/tests/text_thread_regressions.rs | 277 ++++ crates/engine/tests/thread_color_safety.rs | 223 ++++ crates/fonts/src/fontdb.rs | 201 ++- crates/fonts/src/lib.rs | 16 + crates/fonts/src/tests_vertical.rs | 38 +- crates/fonts/src/vertical.rs | 41 +- crates/format/src/lib.rs | 190 ++- crates/geom/src/corners.rs | 26 + crates/idml/src/export.rs | 48 +- crates/idml/src/import.rs | 466 ++++++- crates/idml/src/names.rs | 6 +- crates/idml/src/tests.rs | 690 ++++++++++ crates/mcp/src/backend.rs | 2 + crates/mcp/src/headless.rs | 3 +- crates/mcp/src/tools.rs | 1 + crates/pdf/src/tests.rs | 12 + crates/render/src/lib.rs | 31 + crates/render/src/text.rs | 4 +- crates/tools/src/frame.rs | 76 +- crates/tools/src/lib.rs | 20 +- crates/tools/src/select.rs | 108 +- crates/tools/src/snap.rs | 35 +- crates/tools/src/tests.rs | 283 +++++ crates/tools/src/text.rs | 44 +- crates/tools/src/thread.rs | 259 ++++ crates/ui-egui/src/canvas.rs | 166 ++- crates/ui-egui/src/control.rs | 166 ++- crates/ui-egui/src/dialogs.rs | 62 +- crates/ui-egui/src/i18n.rs | 30 +- crates/ui-egui/src/i18n/ar.rs | 11 +- crates/ui-egui/src/i18n/it.rs | 3 +- crates/ui-egui/src/i18n/ja.rs | 3 +- crates/ui-egui/src/i18n/pt_br.rs | 3 +- crates/ui-egui/src/i18n/uk.rs | 3 +- crates/ui-egui/src/lib.rs | 158 ++- crates/ui-egui/src/menus.rs | 150 +++ crates/ui-egui/src/panels/mod.rs | 38 + crates/ui-egui/src/panels/properties.rs | 39 + crates/ui-egui/src/panels/swatches.rs | 45 +- crates/ui-egui/src/toolbar.rs | 110 +- docs/control-protocol.md | 2 +- docs/gaps.md | 40 +- docs/parity-checklist.md | 5 +- docs/roadmap.md | 16 +- docs/target-app-parity.md | 19 +- 81 files changed, 7920 insertions(+), 660 deletions(-) create mode 100644 crates/engine/src/cmd/text_color_tests.rs create mode 100644 crates/engine/src/cmd/thread.rs create mode 100644 crates/engine/tests/primary_frame_columns.rs create mode 100644 crates/engine/tests/text_thread_regressions.rs create mode 100644 crates/engine/tests/thread_color_safety.rs create mode 100644 crates/tools/src/thread.rs diff --git a/Cargo.lock b/Cargo.lock index ef525c84e..138a753db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -889,6 +889,7 @@ dependencies = [ name = "designcraft" version = "0.7.1" dependencies = [ + "arboard", "designcraft-engine", "designcraft-ui-egui", "eframe", @@ -945,6 +946,7 @@ dependencies = [ "regex", "unicode-bidi", "unicode-bidi-mirroring", + "unicode-segmentation", ] [[package]] @@ -989,6 +991,7 @@ dependencies = [ "serde_json", "tempfile", "thiserror 2.0.21", + "unicode-segmentation", "wasm-bindgen-test", "zip", ] diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..abcd4990b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,8 +1,8 @@ # DesignCraft roadmap -**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%) +**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%) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (cancelable macOS Dock Quit) · **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 DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. @@ -25,15 +25,14 @@ format + IDML), scriptable by agents (MCP), and available on the web. | Essentials user | ~54% | 135–250 (~50% parallel) | start-up robustness, context menus and copy/paste, threading, fonts, Windows printing | **Why pre-alpha:** it fails one of InDesign's core workflows, the alpha gate in -[docs/roadmap.md](docs/roadmap.md#alpha-gate). A user can't thread a story from one frame to -another: the in and out ports are drawn, but clicking the out port does nothing and there is no -thread or unthread command. Long text flows only through autoflow on Place and Primary Text -Frame, which users report adding and keeping pages wrongly (#165, #140, #194, #352). Everything -else in the gate works end to end on macOS (document setup, styles, images, object editing, PDF -export, save and reopen), and ready for real work (~47%) is above the alpha bar, so manual -threading is the whole distance to alpha. Beyond that, beta needs INDD opening, IDML proven on -real files, composition compared with InDesign's, and basic editing fixed on Windows and Linux. -Details: [docs/target-app-parity.md](docs/target-app-parity.md). +[docs/roadmap.md](docs/roadmap.md#alpha-gate). Text threads by hand (click a port, then click or +draw a frame; thread and unthread commands), but Primary Text Frame autoflow adds pages users +can't delete (#165). Everything else in the gate works end to end on macOS (document setup, +styles, images, object editing, PDF export, save and reopen), and ready for real work (~47%) is +above the alpha bar, so the Primary Text Frame page fixes are the whole distance to alpha. +Beyond that, beta needs INDD opening, IDML proven on real files, composition compared with +InDesign's, and basic editing fixed on Windows and Linux. Details: +[docs/target-app-parity.md](docs/target-app-parity.md). ## By dimension @@ -106,7 +105,7 @@ Also shipped: Italian (85%), Ukrainian (83%). Ranked; detail and estimates in [docs/roadmap.md](docs/roadmap.md), the full list in [docs/gaps.md](docs/gaps.md). -1. **Alpha gate:** manual text threading (out-port click, thread/unthread commands) and Primary Text Frame fixes (#165) (15–25 h) +1. **Alpha gate:** Primary Text Frame page fixes: autoflow adds pages users can't delete (#165) (5–7 h) 2. Basic editing on every platform: copy/paste, context menus, caret formatting (30–50 h) 3. CI running `cargo xtask ci` on pull requests (4–8 h) 4. Open INDD through a clean-room converter, owner decision on #114 (20–60 h) @@ -129,6 +128,13 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-12** · Text threads by hand: click a selected text frame's in or out port to load + the text cursor, then click a frame to thread it, click empty space for a column-wide frame or + drag one (it snaps like frame drawing); Shift-click autoflows, Alt-click stays loaded on the + frame just added; click the linked frame or double-click a port to break the thread. + `text.thread`, `text.unthread`, `text.removeFromThread` with undo; locked frames and layers + refuse threads. The alpha gate now waits only on the Primary Text Frame page fixes (#165). No + readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. @@ -195,6 +201,7 @@ Newest first. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Manual text threading landed; alpha distance 15–25 h → 5–7 h (Primary Text Frame page fixes, #165) | | 2026-10-10 | minor | Full number 42% → 47% (method aligned with the standard: plain weighted sum, no new evidence); readiness table with hours per audience | | 2026-10-10 | minor | Headline adds mainstream practitioner (~42%) and essentials user (~54%); method in docs/target-app-parity.md | | 2026-10-10 | minor | Stage alpha → pre-alpha under the new core-workflow gate: manual text threading is missing; alpha gate table added to docs/roadmap.md | diff --git a/apps/designcraft/Cargo.toml b/apps/designcraft/Cargo.toml index b65efbe48..0f08ae8ed 100644 --- a/apps/designcraft/Cargo.toml +++ b/apps/designcraft/Cargo.toml @@ -25,6 +25,7 @@ winresource = "0.1" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rfd = { workspace = true } +arboard = { workspace = true } [target.'cfg(all(unix, not(target_os = "macos")))'.dependencies] # The same winit eframe uses: to pick the X11 backend (file drag-and-drop) in main.rs. diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index 26ce2ee4f..2ac607bb8 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -210,6 +210,7 @@ fn services() -> Services { pick_save: Some(Box::new(|name: &str| rfd::FileDialog::new().set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()))), read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), write: Some(Box::new(|p: &str, b: &[u8]| designcraft_engine::atomic::write_atomic(std::path::Path::new(p), b).map_err(|e| e.to_string()))), + clipboard_text: Some(Box::new(|| arboard::Clipboard::new().and_then(|mut c| c.get_text()).ok())), ..Default::default() } } diff --git a/crates/compose/Cargo.toml b/crates/compose/Cargo.toml index b8993bcfb..b99e108dd 100644 --- a/crates/compose/Cargo.toml +++ b/crates/compose/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [dependencies] unicode-bidi = { workspace = true } +unicode-segmentation = { workspace = true } unicode-bidi-mirroring = { workspace = true } designcraft-geom = { workspace = true } designcraft-color = { workspace = true } diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index cd0e7cbd9..4ed599b04 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -38,9 +38,9 @@ use designcraft_geom::{Point, Rect}; use crate::breaker::{Break, Spacing}; pub use crate::cache::Cache; use crate::notes::Notes; -pub use crate::notes::{find_note, hit_note, note_caret}; +pub use crate::notes::{find_note, hit_note, hit_note_with, note_caret}; use crate::shape::{Glyph, StyleTable, SubstCtx}; -pub use crate::table::{PlacedCell, StrokeSeg, TableFrag, cell_caret, find_cell, hit_cell}; +pub use crate::table::{PlacedCell, StrokeSeg, TableFrag, cell_caret, find_cell, hit_cell, hit_cell_with}; /// Character appearance shared by many glyphs (indexed from [`PlacedGlyph::style`]). #[derive(Clone, Debug, PartialEq)] @@ -197,6 +197,20 @@ pub struct Line { pub hj: u8, /// The paragraph's Keep Options couldn't be honoured here (Highlight Keep Violations). pub keep_violation: bool, + /// The drop cap a paragraph's first line starts with (its first glyphs, set down to a later + /// line's baseline). + pub drop_cap: Option<DropCapBox>, +} + +/// Where a drop cap sits (frame inner space). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct DropCapBox { + /// Story bytes from its line's start up to here are the drop cap's. + pub end: usize, + /// Its baseline. + pub baseline: f64, + /// The area it covers: from its line's top down to the text below its baseline. + pub rect: Rect, } /// A paragraph rule or shading rectangle in frame inner space. @@ -316,7 +330,8 @@ pub struct FrameSpec { impl FrameSpec { pub fn columns(&self) -> Vec<Rect> { let a = self.area; - let n = self.opts.columns.max(1); + // Primary frames inherit the document limit (216), beyond the interactive frame editor's 40. + let n = self.opts.columns.clamp(1, 216); match self.opts.columns_kind { designcraft_doc::ColumnsKind::FixedWidth if self.opts.column_width > 0.0 => (0..n) .map(|i| { @@ -652,6 +667,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C notes.number_refs(doc, prange.clone(), cur_frame.and_then(|f| f.page), &mut sub); } let sub_objects = sub.objects.clone(); + let drop_end = drop_cap_end(&story.text, prange.clone(), &pp, sub.vertical); let mut table = StyleTable { styles: &mut styles_tab, missing: &mut missing_fonts }; let env = shape::TypeEnv { auto_leading: pp.auto_leading, adv: doc.settings.advanced_type, glyph_fallback: doc.settings.glyph_fallback }; let mut sp = shape::shape_para( @@ -667,7 +683,22 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C &pp.nested_styles, &pp.grep_styles, &[], + (drop_end, &pp.drop_cap_style), ); + // Drop cap: the size and indent come from the text beside it (`base_*` when there's none). + let base_leading = match base_chars.leading { + designcraft_doc::Leading::Auto => base_chars.size * pp.auto_leading, + designcraft_doc::Leading::Points(v) => v, + }; + let base_cap = { + let face = db.face(&base_chars.font_family, &base_chars.font_style); + face.cap_height * base_chars.size / face.upem * base_chars.v_scale + }; + let drop_grid = cur_frame.and_then(|f| f.grid).filter(|_| pp.grid_align == GridAlign::AllLines).map(|g| g.1); + // Align Left Edge (left-to-right paragraphs). + let drop_align = pp.drop_cap_align_left && pp.direction == designcraft_doc::TextDirection::LeftToRight; + // Tab stops for the drop cap, with the start-side indent (the right one, right to left). + let drop_tabs = (&pp.tabs[..], if pp.direction == designcraft_doc::TextDirection::RightToLeft { pp.right_indent } else { pp.left_indent }); if !pp.nested_line_styles.is_empty() && cur.fi < frames.len() { // Nested line styles: find where the first lines end in this column, restyle them, and // look again (the style changes the widths) until the lines settle. @@ -677,9 +708,17 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let spacing = spacing_for(&pp, base_chars.size); let mut lines: Vec<(std::ops::Range<usize>, String)> = Vec::new(); for _ in 0..3 { - let width = |j: usize| (col.width() - pp.left_indent - pp.right_indent - if j == 0 { pp.first_line_indent } else { 0.0 }).max(1.0); - // The same spacing, hyphenation and breaker as the layout below. + // The same drop cap, spacing, hyphenation and breaker as the layout below. let mut gl = sp.glyphs.clone(); + let dc = split_drop_cap(db, &mut gl, drop_end, pp.drop_cap_lines, base_leading, base_cap, drop_grid, drop_align, drop_tabs); + let width = |j: usize| { + let ind = match &dc { + Some(dc) if j < dc.lines => dc.indent(), + _ if j == 0 => pp.first_line_indent, + _ => 0.0, + }; + (col.width() - pp.left_indent - pp.right_indent - ind).max(1.0) + }; apply_desired_spacing(&mut gl, &pp); let hy = hyphenation_points(&story.text, &gl, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars)); let breaks = if pp.composer == Composer::SingleLine || gl.iter().any(|g| g.ch == '\t') || gl.len() > 4000 { @@ -725,30 +764,46 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C &pp.nested_styles, &pp.grep_styles, &lines, + (drop_end, &pp.drop_cap_style), ); } } + // Rules in Text Color: the colour of the paragraph's first character (rule above) and last + // character (rule below). + let text_color = |g: Option<&Glyph>| { + g.and_then(|g| table.styles.get(g.style as usize)) + .map_or((base_chars.fill.clone(), base_chars.fill_tint), |s| (s.fill.clone(), s.fill_tint)) + }; + let (above, below) = (text_color(sp.glyphs.iter().find(|g| g.len > 0)), text_color(sp.glyphs.iter().rev().find(|g| g.len > 0))); + let rule_color = |r: &designcraft_doc::Rule, text: &(String, f32)| { + if r.color == designcraft_doc::TEXT_COLOR { text.clone() } else { (r.color.clone(), r.tint) } + }; + let drop_cap = split_drop_cap(db, &mut sp.glyphs, drop_end, pp.drop_cap_lines, base_leading, base_cap, drop_grid, drop_align, drop_tabs); // List labels take the default super/subscript settings. let label_env = shape::TypeEnv { adv: Default::default(), ..env }; - match pp.list_type { + let list_label = match pp.list_type { // An empty paragraph has no bullet or number, and the numbering carries on past it. - designcraft_doc::ListType::Numbers | designcraft_doc::ListType::Bullets if prange.is_empty() => {} + designcraft_doc::ListType::Numbers | designcraft_doc::ListType::Bullets if prange.is_empty() => None, designcraft_doc::ListType::Numbers if !pp.list_name.is_empty() => { // A named list: carries on past other paragraphs (and from earlier stories). let n = named_numbers.get(pi).copied().flatten().unwrap_or(1); - let label = pp.number_label(n); - prepend_label(db, &mut sp.glyphs, &label, prange.start, &base_chars, label_env, &mut table); + Some(pp.number_label(n)) } designcraft_doc::ListType::Numbers => { list_counter = pp.start_at.map_or(list_counter.saturating_add(1), |s| s.max(1)); - let label = pp.number_label(list_counter); - prepend_label(db, &mut sp.glyphs, &label, prange.start, &base_chars, label_env, &mut table); + Some(pp.number_label(list_counter)) } - designcraft_doc::ListType::Bullets => { - let label = pp.bullet_label(); - prepend_label(db, &mut sp.glyphs, &label, prange.start, &base_chars, label_env, &mut table); + designcraft_doc::ListType::Bullets => Some(pp.bullet_label()), + designcraft_doc::ListType::None => { + list_counter = 0; + None + } + }; + if let Some(label) = list_label { + let n = prepend_label(db, &mut sp.glyphs, &label, prange.start, &base_chars, label_env, &mut table); + for g in sp.glyphs.iter_mut().take(n) { + g.list_label = true; } - designcraft_doc::ListType::None => list_counter = 0, } if pi == 0 && let Some(label) = &opts.label @@ -765,10 +820,6 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C apply_desired_spacing(&mut glyphs, &pp); let hyph_after = hyphenation_points(&story.text, &glyphs, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars)); let base_size = base_chars.size; - let base_leading = match base_chars.leading { - designcraft_doc::Leading::Auto => base_size * pp.auto_leading, - designcraft_doc::Leading::Points(v) => v, - }; let spacing = spacing_for(&pp, base_size); // A paragraph set across the columns moves on to the next frame where others move on to // the next column. @@ -868,6 +919,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let mut col_first_line = 0usize; let has_tabs = glyphs.iter().any(|g| g.ch == '\t'); let mut first_line_rect: Option<(usize, f64, f64, f64)> = None; // frame, baseline, ascent, x-span + let rtl = pp.direction == designcraft_doc::TextDirection::RightToLeft; loop { if cur.fi >= frames.len() { if let Some(j) = trial_failed(&mut trial, &mut limits, balance_runs < balance_budget) { @@ -888,11 +940,22 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let col = if spanning { span_rect(&cols[cur.fi], Rect::new(col.x0, col.y0, col.x1, base.y1), pp.span_columns) } else { col }; let col = cur.split_rect(col); // Estimate slots for the breaker with the paragraph's base leading. - let est_first = cur.next_baseline(f, col, base_leading, base_chars.size * 0.75, &pp); - let slots = estimate_slots(f, col, est_first, base_leading, base_chars.size, &glyphs[g0..], &pp, line_no); + let est_asc = base_chars.size * 0.75; + let est_tops = Tops { typo_ascent: est_asc, cap: est_asc * 0.72, xh: est_asc * 0.5, em: base_chars.size * 0.88 }; + let est_first = cur.next_baseline(f, col, base_leading, est_asc, est_tops, &pp); + let est_jump = cur.jump(f, base_leading, est_asc, est_tops); + let slots = estimate_slots(f, col, est_first, est_jump, base_leading, base_chars.size, &glyphs[g0..], &pp, line_no); let width = |j: usize| -> f64 { let (x0, x1) = slots.get(j).copied().unwrap_or((col.x0, col.x1)); - let ind = pp.left_indent + pp.right_indent + if line_no + j == 0 { pp.first_line_indent } else { 0.0 }; + // Beside the drop cap (in the column the paragraph starts in), its width; else the + // first-line indent. + let ind = pp.left_indent + + pp.right_indent + + match &drop_cap { + Some(dc) if line_no + j < dc.lines && col_first_line == 0 => dc.indent(), + _ if line_no + j == 0 => pp.first_line_indent, + _ => 0.0, + }; (x1 - x0 - ind).max(1.0) }; let rest = &glyphs[g0..]; @@ -914,39 +977,36 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C for (k, b) in breaks.iter().enumerate() { let (s, e) = (g0 + b.start, g0 + b.end); let line_glyphs = &glyphs[s..e.max(s)]; - let (asc, desc, lead) = line_metrics(line_glyphs, &glyphs, s, base_leading, base_chars.size, db, &base_chars); + let (asc, tops, desc, lead) = line_metrics(line_glyphs, &glyphs, s, base_leading, base_chars.size, db, &base_chars); let reference = cjk_line_reference(line_glyphs); - let mut baseline = cur.next_baseline(f, col, lead, asc, &pp); + // Baseline grid: the next grid line at or below. + let grid = f.grid.filter(|&(_, inc)| { + inc > 0.0 && (pp.grid_align == GridAlign::AllLines || (pp.grid_align == GridAlign::FirstLineOnly && line_no == 0)) + }); + let snap = |y: f64| grid.map_or(y, |(g_start, inc)| g_start + ((y - g_start) / inc - 1e-6).ceil() * inc); + let mut baseline = cur.next_baseline(f, col, lead, asc, tops, &pp); if cur.last_baseline.is_some() { baseline += cur.last_reference - reference; } - // Baseline grid. - if let Some((g_start, inc)) = f.grid - && (pp.grid_align == GridAlign::AllLines || (pp.grid_align == GridAlign::FirstLineOnly && line_no == 0)) - && inc > 0.0 - { - let n = ((baseline - g_start) / inc - 1e-6).ceil(); - baseline = g_start + n * inc; - } - // Wrap: push the line down until a slot exists. + baseline = snap(baseline); + // Wrap: push the line down to the first position with a slot (back on the grid). let (mut x0, mut x1) = (col.x0, col.x1); if !f.exclusions.is_empty() { + let jump = cur.jump(f, lead, asc, tops); let mut tries = 0; loop { - match free_slot(f, col, baseline - asc, baseline + desc, base_size) { - Some((a, b)) => { - x0 = a; - x1 = b; - break; - } - None => { - baseline += 1.0; - tries += 1; - if baseline > col.y1 || tries > 4000 { - break; - } - } + let Some((nb, (a, z))) = wrap_slot(f, col, baseline, asc, desc, jump, base_size) else { + // No room in this column. + baseline = baseline.max(col.y1) + 1.0; + break; + }; + let pushed = nb > baseline; + (baseline, x0, x1) = (nb, a, z); + tries += 1; + if !pushed || grid.is_none() || tries > 4000 { + break; } + baseline = snap(baseline); } } let capped = cur.split.is_none() && line_cap[pi] == Some(line_no) && line_no > col_first_line; @@ -1022,9 +1082,15 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C moved = true; break; } - let ind_l = pp.left_indent + if line_no == 0 { pp.first_line_indent } else { 0.0 }; + // A drop cap sits at the start-side indent; the first-line indent doesn't move it. + let beside = drop_cap.as_ref().filter(|dc| line_no < dc.lines && col_first_line == 0).map(DropCap::indent); + let (ind_l, ind_r) = match beside { + Some(w) if rtl => (pp.left_indent, pp.right_indent + w), + Some(w) => (pp.left_indent + w, pp.right_indent), + None => (pp.left_indent + if line_no == 0 { pp.first_line_indent } else { 0.0 }, pp.right_indent), + }; let lx0 = x0 + ind_l; - let lx1 = x1 - pp.right_indent; + let lx1 = x1 - ind_r; let last = k + 1 == breaks.len(); // The break character that ends this line, if any (it is the line's last glyph). let brk = if b.forced && e > s { @@ -1038,8 +1104,30 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let forced_mid = brk == Some(story::FORCED_LINE_BREAK) && !last; let (mut placed, end_x, ratio) = layout_line(&glyphs, s, e, b.hyphen, lx0, lx1, col.x0, &pp, &spacing, ends_para, forced_mid, f.left_page, &bidi_info); - // Warichu runs before ruby so a reading is placed over the stacked note. + // Warichu runs before ruby so a reading is placed over the stacked note, and before + // the trimmed spaces and the drop cap join the line, so neither is stacked. let end_x = end_x + warichu::place(&styles_tab, &mut placed); + // The spaces the break trimmed off the end follow the line undrawn, so the caret + // and the selection reach them; alignment and justification ignore them. + if pp.direction != designcraft_doc::TextDirection::RightToLeft && !placed.iter().any(|g| g.rtl) { + let mut x = end_x; + for g in glyphs.get(e..(g0 + b.next).min(glyphs.len())).unwrap_or(&[]).iter().filter(|g| g.is_space()) { + placed.push(PlacedGlyph { visible: false, ..place(g, x) }); + x += g.adv; + } + } + let mut drop_box = None; + if line_no == 0 + && let Some(dc) = &drop_cap + { + let at = if rtl { x1 - pp.right_indent - dc.width } else { x0 + pp.left_indent - dc.lsb }; + placed.splice(0..0, place_drop_cap(dc, at, rtl)); + drop_box = Some(DropCapBox { + end: dc.end, + baseline: baseline + dc.drop, + rect: Rect::new(at, baseline - asc, at + dc.width, baseline + dc.drop + desc), + }); + } ruby::annotate(db, &styles_tab, &mut placed, doc.settings.glyph_fallback); let range_end = if last { prange.end } else { glyphs.get(g0 + b.next).map(|g| g.byte).unwrap_or(prange.end) }; let range_start = glyphs.get(s).map(|g| g.byte).unwrap_or(prange.start).min(range_end); @@ -1071,6 +1159,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C spacing: ratio, hj: hj_severity(ratio, pp.word_space_min, pp.word_space_max), keep_violation: false, + drop_cap: drop_box, }); for k in line_notes { notes.place(doc, k, cur.fi, cur.col, col_w, f, opts); @@ -1147,11 +1236,8 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C .find(|l| l.para == pi && l.first_in_para) .map(|l| if r.column_width { (l.x0, l.x1) } else { (l.x0, l.end_x) }) .unwrap_or((0.0, 0.0)); - ft.decos.push(Deco { - rect: Rect::new(col.0 + r.left_indent, y - r.weight, col.1 - r.right_indent, y), - color: r.color.clone(), - tint: r.tint, - }); + let (color, tint) = rule_color(r, &above); + ft.decos.push(Deco { rect: Rect::new(col.0 + r.left_indent, y - r.weight, col.1 - r.right_indent, y), color, tint }); } } para_box_decos(&mut out, pi, &pp); @@ -1162,7 +1248,8 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let r = &pp.rule_below; let y = l.baseline + r.offset; let (x0, x1) = if r.column_width { (l.x0, l.x1) } else { (l.x0, l.end_x) }; - ft.decos.push(Deco { rect: Rect::new(x0 + r.left_indent, y, x1 - r.right_indent, y + r.weight), color: r.color.clone(), tint: r.tint }); + let (color, tint) = rule_color(r, &below); + ft.decos.push(Deco { rect: Rect::new(x0 + r.left_indent, y, x1 - r.right_indent, y + r.weight), color, tint }); } // The end of a split block: text after it continues below its deepest sub-column, which // are balanced first. A block that ends the story fills its sub-columns in turn. @@ -1996,22 +2083,56 @@ impl Cursor { self.next_frame(); self.fi = fi; } - /// Baseline for the next line with leading `lead` and ascent `asc`. - fn next_baseline(&self, f: &FrameSpec, col: Rect, lead: f64, asc: f64, _pp: &ParaProps) -> f64 { + /// Baseline for the next line with leading `lead`, ascent `asc` and first baseline heights + /// `tops`. + fn next_baseline(&self, f: &FrameSpec, col: Rect, lead: f64, asc: f64, tops: Tops, _pp: &ParaProps) -> f64 { match self.last_baseline { Some(b) => b + lead + self.pending, - None => { - let off = match f.opts.first_baseline { - FirstBaseline::Ascent => asc, - FirstBaseline::CapHeight => asc * 0.72, - FirstBaseline::XHeight => asc * 0.5, - FirstBaseline::Leading => lead, - FirstBaseline::Fixed => 0.0, - }; - col.y0 + off.max(f.opts.first_baseline_min) - } + None => col.y0 + first_baseline_offset(f, lead, asc, tops), } } + /// How the next line clears a Jump Object wrap: by whole leadings below the line above, or, first + /// in its column, as at the top of a frame whose top is the wrap bottom. + fn jump(&self, f: &FrameSpec, lead: f64, asc: f64, tops: Tops) -> Jump { + match self.last_baseline { + Some(_) => Jump { top: lead, step: lead }, + None => Jump { top: first_baseline_offset(f, lead, asc, tops), step: 0.0 }, + } + } +} + +/// The frame's first baseline offset below the top of a column, for a line with leading `lead`, +/// ascent `asc` and first baseline heights `tops`. +fn first_baseline_offset(f: &FrameSpec, lead: f64, asc: f64, tops: Tops) -> f64 { + let off = match f.opts.first_baseline { + // Vertical frames measure from the ascent. + FirstBaseline::Ascent | FirstBaseline::EmboxHeight if f.vertical => asc, + FirstBaseline::CapHeight if f.vertical => asc * 0.72, + FirstBaseline::XHeight if f.vertical => asc * 0.5, + FirstBaseline::Ascent => tops.typo_ascent, + FirstBaseline::CapHeight => tops.cap, + FirstBaseline::XHeight => tops.xh, + FirstBaseline::EmboxHeight => tops.em, + FirstBaseline::Leading => lead, + FirstBaseline::Fixed => 0.0, + }; + off.max(f.opts.first_baseline_min) +} + +/// A line's tallest typographic ascender, cap height, x height and em box top above its baseline: +/// the first baseline offsets. +#[derive(Clone, Copy, Default)] +struct Tops { + typo_ascent: f64, + cap: f64, + xh: f64, + em: f64, +} + +/// The em box top above the baseline at `size` ([`designcraft_fonts::FontFace::em_box`]). +fn em_box_top(face: &designcraft_fonts::FontFace, size: f64) -> f64 { + let em = Some(face.em_box().0 / face.upem).filter(|e| e.is_finite()).unwrap_or(0.88); + em * size } /// Em box relative to the baseline. Font ascender/descender proportions locate it; @@ -2054,24 +2175,103 @@ fn line_metrics( base_size: f64, db: &ScopedFonts<'_>, base: &designcraft_doc::CharProps, -) -> (f64, f64, f64) { +) -> (f64, Tops, f64, f64) { let src: &[Glyph] = if line.is_empty() { all.get(s..(s + 1).min(all.len())).unwrap_or(&[]) } else { line }; + let face = || db.face(&base.font_family, &base.font_style); if src.is_empty() { - let face = db.face(&base.font_family, &base.font_style); + let face = face(); let k = base_size / face.upem; - return (face.ascent * k, face.descent * k, base_leading); + let tops = Tops { typo_ascent: face.typo_ascent * k, cap: face.cap_height * k, xh: face.x_height * k, em: em_box_top(&face, base_size) }; + return (face.ascent * k, tops, face.descent * k, base_leading); } let mut asc: f64 = 0.0; + let mut tops = Tops::default(); let mut desc: f64 = 0.0; let mut lead: f64 = 0.0; let reference = src.iter().max_by(|a, b| a.size.total_cmp(&b.size)); + let mut own_text = false; for g in src { - let shift = g.shift + reference.map_or(0.0, |r| cjk_alignment_shift(g, r)); - asc = asc.max(g.ascent + shift.max(0.0)); + let align = reference.map_or(0.0, |r| cjk_alignment_shift(g, r)); + let shift = g.shift + align; + let up = shift.max(0.0); + asc = asc.max(g.ascent + up); + // A list's bullet or number doesn't raise the Ascent offset: only the line's own text does. + if !g.list_label { + own_text = true; + tops.typo_ascent = tops.typo_ascent.max(g.typo_ascent + up); + // The em box sits at the unshifted baseline: a baseline shift moves only the drawn glyphs. + tops.em = tops.em.max(em_box_top(&g.face, g.size) + align.max(0.0)); + } + tops.cap = tops.cap.max(g.cap + up); + tops.xh = tops.xh.max(g.xh + up); desc = desc.max(g.descent - shift.min(0.0)); lead = lead.max(g.leading); } - (asc, desc, lead) + if !own_text { + // Only a label (an empty list paragraph): as an empty paragraph. + let face = face(); + tops.typo_ascent = face.typo_ascent * base_size / face.upem; + tops.em = em_box_top(&face, base_size); + } + (asc, tops, desc, lead) +} + +/// How a line clears Jump Object wraps. The line occupies `[baseline − top, baseline]` (its leading, +/// or its first baseline offset at the top of a column) and must not overlap the wrap; it may hang its +/// descenders into the wrap. Below a wrap it moves down from its place in whole `step`s until it clears +/// the wrap bottom (`step` 0: its top lands on the wrap bottom). +#[derive(Clone, Copy, Debug)] +struct Jump { + top: f64, + step: f64, +} + +/// Slack for float error when a line is tested against a Jump Object wrap's edges. +const JUMP_EPS: f64 = 1e-6; + +/// The first baseline at or below `baseline` where a line with ascent `asc` and descent `desc` has a +/// free slot in `col`, and that slot; None if there is none above the column bottom. +/// +/// A line under a Jump Object wrap moves below it as `jump` says. Other wraps are rectangles, so the +/// free space only grows where one in the line's band ends: the search jumps from one such bottom +/// edge to the next, and a line that clears a wrap has its top exactly on the wrap's bottom. +fn wrap_slot(f: &FrameSpec, col: Rect, baseline: f64, asc: f64, desc: f64, jump: Jump, size: f64) -> Option<(f64, (f64, f64))> { + let top = jump.top.max(0.0); + let mut b = baseline; + // Each step passes at least one wrap; the cap bounds hostile geometry. + for _ in 0..4000 { + let jumped = f + .exclusions + .iter() + .filter(|e| e.mode == WrapMode::JumpObject) + .map(|e| e.rect) + .filter(|r| r.x0 < col.x1 && r.x1 > col.x0 && b > r.y0 + JUMP_EPS && b - top < r.y1 - JUMP_EPS) + .fold(f64::NEG_INFINITY, |m, r| m.max(r.y1)); + b = if jumped > f64::NEG_INFINITY { + if jump.step > 0.0 { + // Whole steps from the line's own place (at least one step past the line above). + baseline + ((jumped + top - baseline - JUMP_EPS / 2.0) / jump.step).ceil().max(0.0) * jump.step + } else { + jumped + top + } + } else if let Some(slot) = free_slot(f, col, b - asc, b + desc, size) { + return Some((b, slot)); + } else { + let (y0, y1) = (b - asc, b + desc); + let edge = f + .exclusions + .iter() + .filter(|e| e.mode != WrapMode::JumpObject) + .map(|e| e.rect) + .filter(|r| r.y0 < y1 && r.y1 > y0) + .fold(f64::INFINITY, |m, r| m.min(r.y1)); + edge + asc + }; + if b.is_nan() || b > col.y1 { + return None; + } + } + None } /// Widest free horizontal interval of `col` in the band, or None if blocked. @@ -2082,7 +2282,6 @@ fn free_slot(f: &FrameSpec, col: Rect, y0: f64, y1: f64, size: f64) -> Option<(f let overlaps_band = r.y0 < y1 && r.y1 > y0; match ex.mode { WrapMode::JumpToNextColumn if y1 > r.y0 && r.x0 < col.x1 && r.x1 > col.x0 => return None, - WrapMode::JumpObject if overlaps_band && r.x0 < col.x1 && r.x1 > col.x0 => return None, WrapMode::BoundingBox | WrapMode::Contour if overlaps_band => { let mut next = Vec::new(); for (a, b) in free { @@ -2106,7 +2305,17 @@ fn free_slot(f: &FrameSpec, col: Rect, y0: f64, y1: f64, size: f64) -> Option<(f } #[allow(clippy::too_many_arguments)] -fn estimate_slots(f: &FrameSpec, col: Rect, first: f64, lead: f64, size: f64, glyphs: &[Glyph], pp: &ParaProps, line_no: usize) -> Vec<(f64, f64)> { +fn estimate_slots( + f: &FrameSpec, + col: Rect, + first: f64, + jump: Jump, + lead: f64, + size: f64, + glyphs: &[Glyph], + pp: &ParaProps, + line_no: usize, +) -> Vec<(f64, f64)> { let _ = (pp, line_no); if f.exclusions.is_empty() { return vec![]; @@ -2116,20 +2325,21 @@ fn estimate_slots(f: &FrameSpec, col: Rect, first: f64, lead: f64, size: f64, gl let n = ((total / (col.width().max(10.0) * 0.5)).ceil() as usize + 2).min(2000); let mut v = Vec::with_capacity(n); let mut b = first; + let mut jump = jump; for _ in 0..n { - let mut tries = 0; - let slot = loop { - if let Some(s) = free_slot(f, col, b - size * 0.8, b + size * 0.25, size) { - break s; + let slot = match wrap_slot(f, col, b, size * 0.8, size * 0.25, jump, size) { + Some((nb, s)) => { + b = nb; + s } - b += 1.0; - tries += 1; - if b > col.y1 || tries > 4000 { - break (col.x0, col.x1); + None => { + b = b.max(col.y1); + (col.x0, col.x1) } }; v.push(slot); b += lead; + jump = Jump { top: lead, step: lead }; } v } @@ -2656,6 +2866,147 @@ fn place(g: &Glyph, x: f64) -> PlacedGlyph { } } +/// Drop caps are at most this many lines and characters (larger values are capped). +pub const MAX_DROP_CAP_LINES: u32 = 25; +pub const MAX_DROP_CAP_CHARS: u32 = 150; + +/// Byte where paragraph `prange`'s drop cap ends: after its first `drop_cap_chars` characters +/// (code points, as InDesign counts them, spaces included), extended to the end of the grapheme +/// cluster they end in so a letter keeps its combining marks; all of them in a shorter paragraph. +/// `prange.start` when it has none: no lines or characters, or vertical text (which sets no drop +/// caps). +fn drop_cap_end(text: &str, prange: Range<usize>, pp: &ParaProps, vertical: bool) -> usize { + use unicode_segmentation::UnicodeSegmentation as _; + if vertical || pp.drop_cap_lines == 0 || pp.drop_cap_chars == 0 { + return prange.start; + } + let Some(para) = text.get(prange.clone()) else { return prange.start }; + let para = para.trim_end_matches('\n'); + let n = pp.drop_cap_chars.min(MAX_DROP_CAP_CHARS) as usize; + let end = para.char_indices().nth(n).map_or(para.len(), |(i, _)| i); + prange.start + para.grapheme_indices(true).map(|(i, _)| i).find(|i| *i >= end).unwrap_or(para.len()) +} + +/// A paragraph's drop cap: its first glyphs, enlarged to reach down `lines` lines. +struct DropCap { + glyphs: Vec<Glyph>, + /// Story byte it ends at. + end: usize, + lines: usize, + /// Its advance. + width: f64, + /// How far its origin sits left of the indent: its first letter's left side bearing with + /// Align Left Edge, else 0. + lsb: f64, + /// 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, +} + +/// 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 +/// 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 +/// both measured from the column's right edge). Spaces right after it join it at its size without +/// widening it. +#[allow(clippy::too_many_arguments)] +fn split_drop_cap( + db: &ScopedFonts<'_>, + glyphs: &mut Vec<Glyph>, + end: usize, + lines: u32, + base_leading: f64, + base_cap: f64, + grid: Option<f64>, + align_left: bool, + tabs: (&[designcraft_doc::TabStop], f64), +) -> Option<DropCap> { + let n = glyphs.iter().take_while(|g| g.byte < end).count(); + if n == 0 { + return None; + } + let lines = lines.clamp(1, MAX_DROP_CAP_LINES) as usize; + let mut dc: Vec<Glyph> = glyphs.drain(..n).collect(); + let spaces = glyphs.iter().take_while(|g| g.is_space()).count(); + let mut after: Vec<Glyph> = 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 = 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 }; + 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()) { + g.adv *= k; + g.dx *= k; + g.dy *= k; + g.sx *= k; + g.sy *= k; + g.ascent *= k; + g.typo_ascent *= k; + g.descent *= k; + g.cap *= k; + g.xh *= k; + g.size *= k; + g.space *= k; + } + let lsb = match dc.first() { + Some(g) if align_left => { + let ink = designcraft_geom::Shape::bounding_box(&*db.outline(g.face.get(), g.gid)); + let lsb = g.dx + ink.x0 * g.sx; + if ink.width() > 0.0 && lsb.is_finite() { lsb } else { 0.0 } + } + _ => 0.0, + }; + // Tabs advance to the paragraph's stops, measured from the column's start edge. Right to left, + // its glyphs run from that (right) edge in this order (see `place_drop_cap`). + let (stops, indent) = tabs; + let mut x = indent - lsb; + for i in 0..dc.len() { + let tab = match dc.get(i) { + Some(g) if g.ch == '\t' => Some(tab_advance(stops, indent, x, dc.get(i + 1..).unwrap_or_default()).0), + _ => None, + }; + if let (Some(w), Some(g)) = (tab, dc.get_mut(i)) { + g.adv = w; + } + x += dc.get(i).map_or(0.0, |g| g.adv); + } + let width = dc.iter().map(|g| g.adv).sum(); + dc.append(&mut after); + Some(DropCap { glyphs: dc, end, lines, width, drop, lsb }) +} + +impl DropCap { + /// How far the lines beside it are indented: to the end of its advance. + fn indent(&self) -> f64 { + self.width - self.lsb + } +} + +/// The drop cap's glyphs set from `at` (right to left: mirrored, its first character on the right). +fn place_drop_cap(dc: &DropCap, at: f64, rtl: bool) -> Vec<PlacedGlyph> { + let mut x = at; + let mut out = Vec::with_capacity(dc.glyphs.len()); + for g in &dc.glyphs { + let pen = if rtl { at + at + dc.width - x - g.adv } else { x }; + let mut p = place(g, pen); + p.y += dc.drop; + p.rtl = rtl; + out.push(p); + x += g.adv; + } + out +} + +/// Put generated `label` text before the paragraph's glyphs; returns how many glyphs it added. fn prepend_label( db: &ScopedFonts<'_>, glyphs: &mut Vec<Glyph>, @@ -2664,12 +3015,12 @@ fn prepend_label( base: &designcraft_doc::CharProps, env: shape::TypeEnv, table: &mut StyleTable<'_>, -) { +) -> usize { // Shape the label as a tiny standalone story so it uses the paragraph's base character style. let mut tmp = Story::new(StoryId(0)); tmp.insert(0, label); let styles = designcraft_doc::Styles::default(); - let shaped = shape::shape_para(db, &styles, &tmp, 0, 0..label.len(), base, env, &SubstCtx::default(), table, &[], &[], &[]); + let shaped = shape::shape_para(db, &styles, &tmp, 0, 0..label.len(), base, env, &SubstCtx::default(), table, &[], &[], &[], (0, "")); let mut pre: Vec<Glyph> = shaped .glyphs .into_iter() @@ -2679,8 +3030,10 @@ fn prepend_label( g }) .collect(); + let n = pre.len(); pre.append(glyphs); *glyphs = pre; + n } type LimitsKey = (usize, usize, usize, bool); @@ -2948,6 +3301,12 @@ pub fn highlight_quads(l: &Line, s: usize, e: usize, past_end: bool) -> Vec<[Poi /// A stacked warichu row reports that row's baseline and em, not the parent line's. pub fn caret(cs: &ComposedStory, pos: usize) -> Option<(usize, f64, f64, f64, f64)> { let (fi, l) = caret_line(cs, pos)?; + // In the drop cap the caret is as tall as it. + if let Some(dc) = l.drop_cap + && pos < dc.end + { + return Some((fi, caret_x(l, pos), dc.baseline, dc.baseline - dc.rect.y0, l.descent)); + } let x = caret_x(l, pos); let glyphs = line_glyphs(l); let metrics = caret_anchor(l, pos).filter(|g| on_stacked_row(&glyphs, g)).map(|g| { @@ -2995,6 +3354,8 @@ pub fn caret_x(l: &Line, pos: usize) -> f64 { } match l.glyphs.iter().rev().find(|g| g.len > 0) { Some(g) if g.rtl => g.x, + // After the spaces that end the line. + Some(g) if !g.visible && g.x >= l.end_x - 1e-9 => g.x + g.adv, _ => l.end_x, } } @@ -3028,19 +3389,57 @@ pub fn line_rtl(cs: &ComposedStory, pos: usize) -> bool { caret_line(cs, pos).and_then(|(_, l)| l.glyphs.iter().find(|g| g.len > 0)).is_some_and(|g| g.rtl) } +/// The line of frame `ft` closest to point `p` vertically (within its column). +fn hit_line(ft: &FrameText, p: Point) -> Option<&Line> { + ft.lines + .iter() + .filter(|l| p.x >= l.x0 - 20.0 && p.x <= l.x1 + 20.0 || ft.columns.len() <= 1) + .min_by(|a, b| line_dist(a, p.y).total_cmp(&line_dist(b, p.y))) + .or_else(|| ft.lines.first()) +} + +/// Story byte of the character drawn under point `p` (frame inner space) in frame `fi`: the +/// glyph whose advance spans `p.x` on the closest line, else the one nearest it. For selecting +/// the word (or the spaces) under a double click; [`hit`] finds a caret position instead. +pub fn hit_char(cs: &ComposedStory, fi: usize, p: Point) -> Option<usize> { + let ft = cs.frames.get(fi)?; + let Some(l) = hit_line(ft, p) else { return Some(ft.range.start) }; + let dist = |g: &&PlacedGlyph| { + let (a, b) = (g.x.min(g.x + g.adv), g.x.max(g.x + g.adv)); + if p.x < a { + a - p.x + } else if p.x >= b { + p.x - b + } else { + 0.0 + } + }; + // On a drop cap (it reaches down beside the lines after its own): its character under `p`. + for dl in &ft.lines { + let Some(dc) = dl.drop_cap.filter(|dc| dc.rect.contains(p)) else { continue }; + return dl.glyphs.iter().filter(|g| g.len > 0 && g.byte < dc.end).min_by(|a, b| dist(a).total_cmp(&dist(b))).map(|g| g.byte); + } + let under = l.glyphs.iter().filter(|g| g.len > 0).min_by(|a, b| dist(a).total_cmp(&dist(b))); + Some(under.map_or(l.range.start, |g| g.byte)) +} + /// Story byte nearest to point `p` (frame inner space) in frame `fi`. pub fn hit(cs: &ComposedStory, fi: usize, p: Point) -> Option<usize> { let ft = cs.frames.get(fi)?; if ft.lines.is_empty() { return Some(ft.range.start); } - // Closest line vertically (within its column). - let l = ft - .lines - .iter() - .filter(|l| p.x >= l.x0 - 20.0 && p.x <= l.x1 + 20.0 || ft.columns.len() <= 1) - .min_by(|a, b| line_dist(a, p.y).total_cmp(&line_dist(b, p.y))) - .or_else(|| ft.lines.first())?; + // On a drop cap (it reaches down beside the lines after its own): the nearest caret position in it. + for l in &ft.lines { + let Some(dc) = l.drop_cap.filter(|dc| dc.rect.contains(p)) else { continue }; + return l + .glyphs + .iter() + .filter(|g| g.len > 0 && g.byte < dc.end) + .flat_map(|g| [g.byte, g.byte + g.len]) + .min_by(|a, b| (caret_x(l, *a) - p.x).abs().total_cmp(&(caret_x(l, *b) - p.x).abs())); + } + let l = hit_line(ft, p)?; if l.glyphs.iter().any(|g| g.rtl) { // Bidi: the caret position drawn nearest the point. return caret_stops(l).into_iter().min_by(|a, b| (a.1 - p.x).abs().total_cmp(&(b.1 - p.x).abs())).map(|s| s.0); @@ -3275,3 +3674,47 @@ pub fn path_glyphs( } runs } + +#[cfg(test)] +mod column_allocation_safety_tests { + use super::*; + + fn frame(kind: designcraft_doc::ColumnsKind) -> FrameSpec { + FrameSpec { + id: ItemId(1), + area: Rect::new(0., 0., 300., 100.), + opts: TextFrameOptions { columns: 217, columns_kind: kind, column_width: 20., gutter: 12., ..Default::default() }, + vertical: false, + exclusions: vec![], + page_name: None, + page: None, + grid: None, + left_page: false, + page_rect: None, + } + } + fn check(kind: designcraft_doc::ColumnsKind) { + let mut f = frame(kind); + // Fail safely at a small boundary before exercising hostile native options. + assert_eq!(f.columns().len(), 216); + f.opts.columns = u32::MAX; + assert_eq!(f.columns().len(), 216); + f.opts.columns = 0; + assert_eq!(f.columns().len(), 1); + f.opts.columns = 2; + let expected = if kind == designcraft_doc::ColumnsKind::FixedWidth { + vec![Rect::new(0., 0., 20., 100.), Rect::new(32., 0., 52., 100.)] + } else { + vec![Rect::new(0., 0., 144., 100.), Rect::new(156., 0., 300., 100.)] + }; + assert_eq!(f.columns(), expected); + } + #[test] + fn column_allocation_caps_equal_width_frame_count() { + check(designcraft_doc::ColumnsKind::FixedNumber); + } + #[test] + fn column_allocation_caps_fixed_width_frame_count() { + check(designcraft_doc::ColumnsKind::FixedWidth); + } +} diff --git a/crates/compose/src/notes.rs b/crates/compose/src/notes.rs index 32b2bbec2..a37c1aa49 100644 --- a/crates/compose/src/notes.rs +++ b/crates/compose/src/notes.rs @@ -197,10 +197,15 @@ pub fn find_note(cs: &ComposedStory, id: u64) -> Option<(usize, &PlacedNote)> { /// Footnote text hit at frame-inner point `p`: (footnote id, byte in the footnote's story). pub fn hit_note(cs: &ComposedStory, fi: usize, p: Point) -> Option<(u64, usize)> { + hit_note_with(cs, fi, p, crate::hit) +} + +/// [`hit_note`] with the hit test for the note's text (see [`crate::hit_cell_with`]). +pub fn hit_note_with(cs: &ComposedStory, fi: usize, p: Point, hit: fn(&ComposedStory, usize, Point) -> Option<usize>) -> Option<(u64, usize)> { let ft = cs.frames.get(fi)?; let n = ft.notes.iter().find(|n| n.rect.contains(p))?; let local = Point::new(p.x - n.origin.x, p.y - n.origin.y); - Some((n.id, crate::hit(&n.text, 0, local).unwrap_or(0))) + Some((n.id, hit(&n.text, 0, local).unwrap_or(0))) } /// Caret geometry for byte `pos` of a footnote's story, in frame inner space: diff --git a/crates/compose/src/overlay.rs b/crates/compose/src/overlay.rs index d763a5898..f34ed2925 100644 --- a/crates/compose/src/overlay.rs +++ b/crates/compose/src/overlay.rs @@ -22,12 +22,15 @@ fn is_event(u: &NestedUntil, c: char, prev: Option<char>, next: Option<char>) -> NestedUntil::ForcedLineBreak => c == designcraft_doc::FORCED_LINE_BREAK, NestedUntil::EmSpace => c == '\u{2003}', NestedUntil::EnSpace => c == '\u{2002}', + // Counted by position, not by character (see `overlays`). + NestedUntil::Dropcap => false, NestedUntil::Chars(s) => s.contains(c), } } /// Character-style ranges for the paragraph `text[range]`, in application order (later wins). -pub(crate) fn overlays(text: &str, range: Range<usize>, nested: &[NestedStyle], grep: &[GrepStyle]) -> Vec<(Range<usize>, String)> { +/// The paragraph's drop cap ends at `drop_end` (`range.start` without one). +pub(crate) fn overlays(text: &str, range: Range<usize>, nested: &[NestedStyle], grep: &[GrepStyle], drop_end: usize) -> Vec<(Range<usize>, String)> { let mut out = Vec::new(); let para = &text[range.clone()]; // The paragraph's own end mark isn't styled. @@ -37,17 +40,22 @@ pub(crate) fn overlays(text: &str, range: Range<usize>, nested: &[NestedStyle], if pos >= end { break; } - let mut count = 0; let mut stop = end; - let chars: Vec<(usize, char)> = text[pos..end].char_indices().map(|(i, c)| (pos + i, c)).collect(); - for (k, &(i, c)) in chars.iter().enumerate() { - let prev = if k > 0 { Some(chars[k - 1].1) } else { None }; - let next = chars.get(k + 1).map(|x| x.1); - if is_event(&ns.until, c, prev, next) { - count += 1; - if count >= ns.count.max(1) { - stop = if ns.through { i + c.len_utf8() } else { i }; - break; + if ns.until == NestedUntil::Dropcap { + // Through the drop cap runs to its end; up to it (it starts the paragraph) is nothing. + stop = if ns.through { drop_end.max(pos).min(end) } else { pos }; + } else { + let mut count = 0; + let chars: Vec<(usize, char)> = text[pos..end].char_indices().map(|(i, c)| (pos + i, c)).collect(); + for (k, &(i, c)) in chars.iter().enumerate() { + let prev = if k > 0 { Some(chars[k - 1].1) } else { None }; + let next = chars.get(k + 1).map(|x| x.1); + if is_event(&ns.until, c, prev, next) { + count += 1; + if count >= ns.count.max(1) { + stop = if ns.through { i + c.len_utf8() } else { i }; + break; + } } } } @@ -84,14 +92,21 @@ mod tests { let text = "Q: what is 42? A drop of 7 words.\n".to_string(); let n = |style: &str, through: bool, count: u32, until: NestedUntil| NestedStyle { style: style.into(), through, count, until }; // Through the first ':' then up to the second word end. - let o = overlays(&text, 0..text.len(), &[n("Lead", true, 1, NestedUntil::Chars(":".into())), n("Next", false, 2, NestedUntil::Words)], &[]); + let o = + overlays(&text, 0..text.len(), &[n("Lead", true, 1, NestedUntil::Chars(":".into())), n("Next", false, 2, NestedUntil::Words)], &[], 0); assert_eq!(&text[o[0].0.clone()], "Q:"); assert_eq!(&text[o[1].0.clone()], " what is", "up to the end of the second word"); // Digits by GREP. - let o = overlays(&text, 0..text.len(), &[], &[GrepStyle { style: "Num".into(), pattern: r"\d+".into() }]); + let o = overlays(&text, 0..text.len(), &[], &[GrepStyle { style: "Num".into(), pattern: r"\d+".into() }], 0); let got: Vec<&str> = o.iter().map(|(r, _)| &text[r.clone()]).collect(); assert_eq!(got, ["42", "7"]); + // Through the drop cap (its first 2 bytes here); up to it is nothing; without one, nothing. + let d = |through: bool| n("Cap", through, 1, NestedUntil::Dropcap); + let o = overlays(&text, 0..text.len(), &[d(true), n("Next", true, 1, NestedUntil::Words)], &[], 2); + assert_eq!((&text[o[0].0.clone()], &text[o[1].0.clone()]), ("Q:", " what ")); + assert_eq!(&text[overlays(&text, 0..text.len(), &[d(false), n("Next", true, 1, NestedUntil::Words)], &[], 2)[0].0.clone()], "Q: "); + assert!(overlays(&text, 0..text.len(), &[d(true)], &[], 0).is_empty()); // A bad pattern is ignored. - assert!(overlays(&text, 0..text.len(), &[], &[GrepStyle { style: "X".into(), pattern: "(".into() }]).is_empty()); + assert!(overlays(&text, 0..text.len(), &[], &[GrepStyle { style: "X".into(), pattern: "(".into() }], 0).is_empty()); } } diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index 1e1af40a7..cfab72107 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -28,9 +28,14 @@ pub struct Glyph { /// Baseline shift (positive = up), including super/subscript. pub shift: f64, pub ascent: f64, + /// The font's typographic ascender at the point size (vertical scale doesn't apply): the Ascent + /// first baseline offset. + pub typo_ascent: f64, pub descent: f64, /// The leading this character asks for (absolute, or auto = size × auto %). pub leading: f64, + /// Cap height and x height, scaled like `ascent`: the Cap Height and x Height first baseline + /// offsets. pub cap: f64, pub xh: f64, pub size: f64, @@ -64,6 +69,9 @@ pub struct Glyph { /// Upright in a vertical frame: shaped top to bottom (`adv` is its vertical advance) and hung /// from its vertical origin on the line's centre (see [`crate::PlacedGlyph::vertical_xf`]). pub upright: bool, + /// Part of the bullet or number the paragraph's list adds: not the line's own text, so it + /// doesn't raise the Ascent first baseline. + pub list_label: bool, } impl Glyph { @@ -185,7 +193,8 @@ impl StyleTable<'_> { } } -/// Resolve and shape one paragraph. +/// Resolve and shape one paragraph. Its drop cap ends at byte `drop.0` (`range.start` without +/// one) and takes the character style `drop.1` ("" = none). pub(crate) fn shape_para( db: &ScopedFonts<'_>, styles: &Styles, @@ -199,13 +208,37 @@ pub(crate) fn shape_para( nested: &[designcraft_doc::NestedStyle], grep: &[designcraft_doc::GrepStyle], lines: &[(std::ops::Range<usize>, String)], + drop: (usize, &str), ) -> ShapedPara { let _ = pi; let mut glyphs = Vec::with_capacity(range.len()); - // Nested line styles lie under nested and GREP styles (later overlays win). + // Nested line styles lie under nested and GREP styles, and the drop cap's style over them all + // (later overlays win). let mut overlays: Vec<(std::ops::Range<usize>, String)> = lines.to_vec(); + // The drop cap's style is the nested styles' leading "through 1 Dropcap" entry (the form IDML + // stores it in). Set (`[None]` included), it restyles that entry; a real one stands in for a + // missing entry. Either way the nested styles after it start after the drop cap. + let set = Some(drop.1).filter(|s| !s.is_empty()); + let cap_style = set.filter(|s| *s != designcraft_doc::NO_CHAR_STYLE); + let with_cap: Vec<designcraft_doc::NestedStyle>; + let nested = match set { + Some(s) if nested.first().is_some_and(designcraft_doc::NestedStyle::is_drop_cap) => { + with_cap = std::iter::once(designcraft_doc::NestedStyle::drop_cap(s)).chain(nested.iter().skip(1).cloned()).collect(); + &with_cap[..] + } + Some(s) if cap_style.is_some() && !nested.is_empty() => { + with_cap = std::iter::once(designcraft_doc::NestedStyle::drop_cap(s)).chain(nested.iter().cloned()).collect(); + &with_cap[..] + } + _ => nested, + }; if !nested.is_empty() || !grep.is_empty() { - overlays.extend(crate::overlay::overlays(&story.text, range.clone(), nested, grep)); + overlays.extend(crate::overlay::overlays(&story.text, range.clone(), nested, grep, drop.0)); + } + if let Some(s) = cap_style + && drop.0 > range.start + { + overlays.push((range.start..drop.0, s.to_string())); } // Each run, cut where nested / GREP styles start and end. let mut segments: Vec<(usize, usize, Option<&str>, &designcraft_doc::CharFormat)> = Vec::new(); @@ -273,7 +306,7 @@ pub(crate) fn shape_para( } let mut g = control_glyph(&face, &props, auto_leading, style, a + i, c); g.ch = HIDDEN; - (g.ascent, g.descent, g.leading, g.cap, g.xh) = (0.0, 0.0, 0.0, 0.0, 0.0); + (g.ascent, g.typo_ascent, g.descent, g.leading, g.cap, g.xh) = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0); glyphs.push(g); } continue; @@ -597,10 +630,18 @@ fn shape_run_raw( Some(y) => { g.adv = o.w; g.ascent = g.ascent.max(o.h + y); + g.typo_ascent = g.typo_ascent.max(o.h + y); + g.cap = g.cap.max(o.h + y); + g.xh = g.xh.max(o.h + y); g.descent = g.descent.max(-y); } // Pushes its line down by its height and spacing. - None => g.ascent += o.h + o.space, + None => { + g.ascent += o.h + o.space; + g.typo_ascent += o.h + o.space; + g.cap += o.h + o.space; + g.xh += o.h + o.space; + } } if auto || o.y_offset.is_none() { g.leading = g.leading.max(g.ascent + g.descent); @@ -771,6 +812,7 @@ fn control_glyph(face: &Arc<FontFace>, p: &CharProps, auto_leading: TypeEnv, sty sy: k * p.v_scale, shift, ascent, + typo_ascent: face.typo_ascent * k, descent, leading, cap, @@ -796,6 +838,7 @@ fn control_glyph(face: &Arc<FontFace>, p: &CharProps, auto_leading: TypeEnv, sty shaping_rtl: false, lang: designcraft_doc::language_tag(&p.language), upright: false, + list_label: false, } } @@ -889,6 +932,7 @@ fn shape_segment( sy: k * p.v_scale, shift, ascent, + typo_ascent: face.typo_ascent * k, descent, leading, cap, @@ -918,6 +962,7 @@ fn shape_segment( shaping_rtl: sg.rtl, lang, upright: up, + list_label: false, }); } } diff --git a/crates/compose/src/table.rs b/crates/compose/src/table.rs index c70f477be..ed967561c 100644 --- a/crates/compose/src/table.rs +++ b/crates/compose/src/table.rs @@ -442,6 +442,7 @@ fn emit( spacing: 1.0, hj: 0, keep_violation: false, + drop_cap: None, }); ft.tables.push(TableFrag { table: table.id, @@ -602,6 +603,17 @@ fn fragment_strokes( /// The cell under `p` (frame inner space) in frame `fi`: (table, row, col, story byte in the cell). pub fn hit_cell(cs: &ComposedStory, fi: usize, p: Point) -> Option<(u64, usize, usize, usize)> { + hit_cell_with(cs, fi, p, crate::hit) +} + +/// [`hit_cell`] with the hit test for the cell's text: [`crate::hit`] (caret position) or +/// [`crate::hit_char`] (character under the point). +pub fn hit_cell_with( + cs: &ComposedStory, + fi: usize, + p: Point, + hit: fn(&ComposedStory, usize, Point) -> Option<usize>, +) -> Option<(u64, usize, usize, usize)> { let ft = cs.frames.get(fi)?; for t in &ft.tables { if !t.rect.contains(p) { @@ -610,7 +622,7 @@ pub fn hit_cell(cs: &ComposedStory, fi: usize, p: Point) -> Option<(u64, usize, for c in &t.cells { if c.rect.contains(p) { let local = Point::new(p.x - c.origin.x, p.y - c.origin.y); - let b = crate::hit(&c.text, 0, local).unwrap_or(0); + let b = hit(&c.text, 0, local).unwrap_or(0); return Some((t.table, c.row, c.col, b)); } } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 7a0e790d5..ce8964bfe 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -146,6 +146,241 @@ fn wrap_pushes_text_aside() { assert!(below.x0 < 1.0); } +/// Insert rectangles with wrap, given as (bounds, [top, left, bottom, right] offsets, mode). +fn add_wrap_objects(d: &mut Document, objects: &[(Rect, [f64; 4], WrapMode)]) { + let lid = d.default_layer(); + for &(r, offsets, mode) in objects { + let id = ItemId(d.alloc()); + let mut it = designcraft_doc::Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(r)); + it.wrap.mode = mode; + it.wrap.offsets = offsets; + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + } +} + +/// 10 pt on 12 pt text in a 288 × 528 frame at (36, 36), with wrap objects. +fn wrap_doc(para: ParaAttrs, objects: &[(Rect, [f64; 4], WrapMode)]) -> (Document, StoryId) { + let text = [LOREM; 6].join(" "); + let (mut d, sid, _) = doc_with(&text, Rect::new(36.0, 36.0, 324.0, 564.0), para); + d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| { + f.over.size = Some(10.0); + f.over.leading = Some(designcraft_doc::Leading::Points(12.0)); + }); + add_wrap_objects(&mut d, objects); + (d, sid) +} + +/// The first line whose baseline is below the top of `ex`. +fn first_line_below<'a>(cs: &'a ComposedStory, ex: &Exclusion) -> &'a Line { + cs.frames[0].lines.iter().find(|l| l.baseline > ex.rect.y0).unwrap() +} + +/// 14 pt on 18 pt `text` in a frame from y 62.36 to 532.91 with first baseline offset Leading, so lines +/// sit at 80.36 + n × 18 (the setup measured in InDesign), with Jump Object wraps given as +/// (bounds, [top, left, bottom, right] offsets). +fn jump_doc(text: &str, objects: &[(Rect, [f64; 4])]) -> (Document, StoryId, ItemId) { + let (mut d, sid, fid) = doc_with(text, Rect::new(36.0, 62.36, 324.0, 532.91), ParaAttrs::default()); + d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| { + f.over.size = Some(14.0); + f.over.leading = Some(designcraft_doc::Leading::Points(18.0)); + }); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.first_baseline = FirstBaseline::Leading; + let objects: Vec<_> = objects.iter().map(|&(r, o)| (r, o, WrapMode::JumpObject)).collect(); + add_wrap_objects(&mut d, &objects); + (d, sid, fid) +} + +/// Baselines of the last line above the top of the first wrap and of the first line below it. +fn around_wrap(d: &Document, sid: StoryId) -> (Option<f64>, f64) { + let specs = frame_specs(d, sid); + let ex = &specs[0].exclusions[0]; + let cs = compose_story(d, sid, &ComposeOptions::default()); + let lines = &cs.frames[0].lines; + let above = lines.iter().rev().find(|l| l.baseline <= ex.rect.y0 + 1e-6).map(|l| l.baseline); + (above, first_line_below(&cs, ex).baseline) +} + +fn near(a: f64, b: f64) -> bool { + (a - b).abs() < 0.01 +} + +#[test] +fn jump_object_moves_the_next_line_down_by_whole_leadings() { + // As measured: last baseline above 386.41, wrap bottom 472.77 → 494.40 (6 leadings). Here the last + // line above is at 386.36 and the wrap ends 86.36 below it. + let text = [LOREM; 8].join(" "); + let (d, sid, _) = jump_doc(&text, &[(Rect::new(36.0, 395.0, 200.0, 472.72), [0.0; 4])]); + let (above, below) = around_wrap(&d, sid); + assert!(near(above.unwrap(), 386.36), "{above:?}"); + assert!(near(below, 386.36 + 6.0 * 18.0), "{below}"); + // The lines after it keep the leading. + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let next = cs.frames[0].lines.iter().find(|l| l.baseline > below + 1.0).unwrap(); + assert!(near(next.baseline, below + 18.0)); +} + +#[test] +fn jump_object_clears_the_wrap_with_the_leading_not_the_ascent() { + // As measured: last 152.38, wrap bottom 246.90 → 278.38. Clearing the wrap with the line's ascent + // instead of its leading would put the line one leading higher (here the wrap ends a little higher, + // as the test font's ascent is larger). + let text = [LOREM; 8].join(" "); + let (d, sid, _) = jump_doc(&text, &[(Rect::new(36.0, 160.0, 324.0, 244.0), [0.0; 4])]); + let (above, below) = around_wrap(&d, sid); + assert!(near(above.unwrap(), 152.36), "{above:?}"); + assert!(near(below, 152.36 + 7.0 * 18.0), "{below}"); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let asc = cs.frames[0].lines[0].ascent; + let by_ascent = (1..).map(|k| 152.36 + k as f64 * 18.0).find(|b| b - asc >= 244.0).unwrap(); + assert!(!near(by_ascent, below), "the case must tell the two rules apart ({asc})"); +} + +#[test] +fn jump_object_adds_space_before_once() { + // As measured: a heading at 78.36, then a paragraph with 60 pt space before, wrap bottom 439.71 → + // 462.36 = 78.36 + 60 + 18 × 18. + let text = format!("Heading\n{}", [LOREM; 6].join(" ")); + let (mut d, sid, _) = jump_doc(&text, &[(Rect::new(36.0, 90.0, 324.0, 439.71), [0.0; 4])]); + d.story_mut(sid).unwrap().format_paras(10..10, |p| p.para.space_before = Some(60.0)); + let (above, below) = around_wrap(&d, sid); + assert!(near(above.unwrap(), 80.36), "{above:?}"); + assert!(near(below, 80.36 + 60.0 + 18.0 * 18.0), "{below}"); +} + +#[test] +fn jump_object_at_the_frame_top_starts_text_like_a_frame_top() { + // As measured: wrap bottom 266.83 → first baseline 284.835 (Leading offset: one leading below). + let text = [LOREM; 8].join(" "); + let (mut d, sid, fid) = jump_doc(&text, &[(Rect::new(36.0, 50.0, 324.0, 266.83), [0.0; 4])]); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = &cs.frames[0].lines; + assert!(near(lines[0].baseline, 266.83 + 18.0), "{}", lines[0].baseline); + assert!(near(lines[1].baseline, 266.83 + 36.0), "{}", lines[1].baseline); + // Ascent offset: the line's ascent below the wrap. + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.first_baseline = FirstBaseline::Ascent; + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let l = &cs.frames[0].lines[0]; + assert!(near(l.baseline, 266.83 + l.ascent), "{} vs {}", l.baseline, l.ascent); +} + +#[test] +fn line_above_jump_object_stays_while_its_baseline_is_above_the_wrap() { + let text = [LOREM; 8].join(" "); + // The line at 386.36 hangs its descenders into the object and stays. + let (d, sid, _) = jump_doc(&text, &[(Rect::new(36.0, 386.36, 324.0, 420.0), [0.0; 4])]); + let (above, below) = around_wrap(&d, sid); + assert!(near(above.unwrap(), 386.36), "{above:?}"); + assert!(near(below, 440.36), "{below}"); + // A wrap starting just above its baseline makes it jump. + let (d, sid, _) = jump_doc(&text, &[(Rect::new(36.0, 386.3, 324.0, 420.0), [0.0; 4])]); + let (above, below) = around_wrap(&d, sid); + assert!(near(above.unwrap(), 368.36), "{above:?}"); + assert!(near(below, 440.36), "{below}"); +} + +#[test] +fn negative_jump_offsets_shrink_the_wrap() { + let text = [LOREM; 8].join(" "); + let (d, sid, _) = jump_doc(&text, &[(Rect::new(36.0, 300.0, 324.0, 500.0), [-20.0, 0.0, -30.0, 0.0])]); + let specs = frame_specs(&d, sid); + let r = specs[0].exclusions[0].rect; + assert!(near(r.y0, 320.0) && near(r.y1, 470.0), "{r:?}"); + let (above, below) = around_wrap(&d, sid); + assert!(near(above.unwrap(), 314.36), "{above:?}"); + assert!(near(below, 494.36), "{below}"); +} + +#[test] +fn jump_object_down_to_the_frame_bottom_sends_text_to_the_next_frame() { + let text = [LOREM; 8].join(" "); + let (mut d, sid, a) = jump_doc(&text, &[(Rect::new(36.0, 300.0, 324.0, 540.0), [0.0; 4])]); + let lid = d.default_layer(); + let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(350.0, 62.36, 550.0, 532.91), lid, "", ParaFormat::default()).unwrap(); + d.item_mut(b).unwrap().text_frame_mut().unwrap().options.first_baseline = FirstBaseline::Leading; + d.thread(a, b).unwrap(); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let last = cs.frames[0].lines.last().unwrap(); + assert!(near(last.baseline, 296.36), "{}", last.baseline); + assert!(near(cs.frames[1].lines[0].baseline, 62.36 + 18.0), "{}", cs.frames[1].lines[0].baseline); + assert_eq!(cs.frames[0].range.end, cs.frames[1].range.start); +} + +#[test] +fn full_width_bounding_box_wrap_resumes_exactly_below() { + // A bounding-box wrap across the whole column leaves no slot: the line jumps it like Jump Object. + let (d, sid) = wrap_doc(ParaAttrs::default(), &[(Rect::new(20.0, 100.0, 340.0, 150.25), [0.0; 4], WrapMode::BoundingBox)]); + let specs = frame_specs(&d, sid); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let ex = &specs[0].exclusions[0]; + let l = first_line_below(&cs, ex); + assert!((l.baseline - l.ascent - ex.rect.y1).abs() < 0.01, "{} vs {}", l.baseline - l.ascent, ex.rect.y1); + // A slot narrower than 1.5 × the size is no slot either. + let (d, sid) = wrap_doc(ParaAttrs::default(), &[(Rect::new(50.0, 100.0, 340.0, 150.25), [0.0; 4], WrapMode::BoundingBox)]); + let specs = frame_specs(&d, sid); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let ex = &specs[0].exclusions[0]; + let l = first_line_below(&cs, ex); + assert!((l.baseline - l.ascent - ex.rect.y1).abs() < 0.01, "{} vs {}", l.baseline - l.ascent, ex.rect.y1); +} + +#[test] +fn jump_object_keeps_grid_aligned_lines_on_the_grid() { + // The wrap ends at 166.5, off the 12 pt grid; a second object blocks the grid line the line jumps + // to, so the line moves on to the grid line after that object. + let objects = [ + (Rect::new(36.0, 100.0, 200.0, 160.5), [0.0, 0.0, 6.0, 0.0], WrapMode::JumpObject), + (Rect::new(250.0, 178.0, 324.0, 190.0), [0.0; 4], WrapMode::JumpObject), + ]; + let (d, sid) = wrap_doc(ParaAttrs { grid_align: Some(GridAlign::AllLines), ..Default::default() }, &objects); + let specs = frame_specs(&d, sid); + let (g0, inc) = specs[0].grid.unwrap(); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let on_grid = |b: f64| (((b - g0) / inc).round() * inc + g0 - b).abs() < 1e-6; + let lines = &cs.frames[0].lines; + assert!(lines.iter().all(|l| on_grid(l.baseline)), "{:?}", lines.iter().map(|l| l.baseline).collect::<Vec<_>>()); + let exs = &specs[0].exclusions; + let l = first_line_below(&cs, &exs[0]); + // Naive oracle: the first grid line whose leading band clears the objects. + let clear = |b: f64, exs: &[Exclusion]| exs.iter().all(|e| b <= e.rect.y0 || b - l.leading >= e.rect.y1); + let want = |exs: &[Exclusion]| (0..).map(|n| g0 + n as f64 * inc).find(|&b| b > exs[0].rect.y0 && clear(b, exs)).unwrap(); + assert!((l.baseline - want(exs)).abs() < 1e-6, "baseline {} should be on grid line {}", l.baseline, want(exs)); + assert!(want(&exs[..1]) < want(exs), "the second object pushed the line again"); + + // First line only: a later line of the paragraph jumps by whole leadings from the line above. + let (d, sid) = wrap_doc(ParaAttrs { grid_align: Some(GridAlign::FirstLineOnly), ..Default::default() }, &objects[..1]); + let specs = frame_specs(&d, sid); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let ex = &specs[0].exclusions[0]; + let l = first_line_below(&cs, ex); + assert!(!l.first_in_para); + let above = cs.frames[0].lines.iter().rev().find(|a| a.baseline <= ex.rect.y0).unwrap().baseline; + let want = (1..).map(|k| above + k as f64 * 12.0).find(|b| b - 12.0 >= ex.rect.y1).unwrap(); + assert!((l.baseline - want).abs() < 1e-6, "{} vs {want}", l.baseline); +} + +#[test] +fn hostile_wrap_geometry_terminates() { + let (d, sid) = wrap_doc(ParaAttrs { grid_align: Some(GridAlign::AllLines), ..Default::default() }, &[]); + let mut specs = frame_specs(&d, sid); + let ex = |x0: f64, y0: f64, x1: f64, y1: f64, mode| Exclusion { rect: Rect::new(x0, y0, x1, y1), mode }; + let f = &mut specs[0]; + f.exclusions.push(ex(f64::NAN, f64::NAN, f64::NAN, f64::NAN, WrapMode::JumpObject)); + f.exclusions.push(ex(-1e308, 300.0, 1e308, f64::INFINITY, WrapMode::JumpObject)); + f.exclusions.push(ex(0.0, f64::NEG_INFINITY, 400.0, 40.0, WrapMode::BoundingBox)); + // A wall of overlapping strips, each ending 0.25 pt below the previous. + for i in 0..3000 { + let y = 60.0 + i as f64 * 0.25; + f.exclusions.push(ex(0.0, y, 400.0, y + 1.0, if i % 2 == 0 { WrapMode::JumpObject } else { WrapMode::BoundingBox })); + } + f.grid = Some((0.0, 1e-300)); + let story = d.story(sid).unwrap(); + let cs = compose(&d, story, &specs, &ComposeOptions::default()); + assert!(cs.is_overset()); + for l in &cs.frames[0].lines { + assert!(l.baseline.is_finite() && l.baseline - l.ascent >= 40.0 && l.baseline + l.descent <= 300.0, "{}", l.baseline); + } +} + #[test] fn paragraph_composer_is_no_worse_than_greedy() { // Sum of squared slack over lines (excluding last) should not exceed the greedy result. @@ -374,7 +609,11 @@ fn keep_lines_together_moves_the_paragraph() { let (mut d, sid, _) = keep_doc(n - 3, &[&long], h); let cs = compose_story(&d, sid, &ComposeOptions::default()); assert!(column_of_para(&cs, n - 3).contains(&0)); - d.story_mut(sid).unwrap().paras[n - 3].para.keep_lines_together = Some(true); + { + let p = &mut d.story_mut(sid).unwrap().paras[n - 3].para; + p.keep_lines_together = Some(true); + p.keep_all_lines = Some(true); + } let cs = compose_story(&d, sid, &ComposeOptions::default()); assert!(column_of_para(&cs, n - 3).iter().all(|&c| c == 1)); } @@ -1474,6 +1713,849 @@ fn only_english_text_gets_english_hyphenation() { assert_eq!(hyphenated(&d2, sid), 0, "French isn't hyphenated with English rules"); } +/// OS/2 field offsets: sTypoAscender, sTypoDescender, the high half of ulCodePageRange1, sxHeight, +/// sCapHeight. +const TYPO_ASCENDER: usize = 68; +const TYPO_DESCENDER: usize = 70; +const CODE_PAGES_HIGH: usize = 78; +const X_HEIGHT: usize = 86; +const CAP_HEIGHT: usize = 88; + +/// Source Sans 3 Regular (1000 units per em; OS/2 typo ascender = hhea ascender = 1000, cap height +/// 660, x height 486) renamed to `family` (13 characters, the length of "Source Sans 3"), with the +/// OS/2 fields at the given offsets set, or without an OS/2 table for `None`. +fn test_font(family: &str, os2_fields: Option<&[(usize, i16)]>) -> Vec<u8> { + let mut b = designcraft_fonts::bundled()[0].to_vec(); + assert_eq!(family.len(), "Source Sans 3".len()); + let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>(); + for (from, to) in [(utf16("Source Sans 3"), utf16(family)), (b"Source Sans 3".to_vec(), family.as_bytes().to_vec())] { + let mut i = 0; + while let Some(p) = b[i..].windows(from.len()).position(|w| w == from.as_slice()) { + b[i + p..i + p + to.len()].copy_from_slice(&to); + i += p + to.len(); + } + } + let tables = u16::from_be_bytes([b[4], b[5]]) as usize; + let rec = (0..tables).map(|t| 12 + 16 * t).find(|&r| &b[r..r + 4] == b"OS/2").unwrap(); + match os2_fields { + Some(fields) => { + let os2 = u32::from_be_bytes(b[rec + 8..rec + 12].try_into().unwrap()) as usize; + for &(at, v) in fields { + b[os2 + at..os2 + at + 2].copy_from_slice(&v.to_be_bytes()); + } + } + // Still sorted between its neighbours, so the table directory stays valid. + None => b[rec..rec + 4].copy_from_slice(b"OS/1"), + } + b +} + +/// [`test_font`] with OS/2 `sTypoAscender` set to `typo_ascender`, or without OS/2 for `None`. +fn typo_test_font(family: &str, typo_ascender: Option<i16>) -> Vec<u8> { + test_font(family, typo_ascender.map(|v| [(TYPO_ASCENDER, v)]).as_ref().map(|f| f.as_slice())) +} + +/// First baseline (frame space) of a 20 pt line in `family` in a frame at y = 36 with a 4 pt top +/// inset, set up by `opts`. +fn first_baseline_in(family: &str, opts: impl Fn(&mut designcraft_doc::TextFrameOptions)) -> f64 { + first_baseline_in_dir(family, false, opts) +} + +/// [`first_baseline_in`] in a vertical story when `vertical`. +fn first_baseline_in_dir(family: &str, vertical: bool, opts: impl Fn(&mut designcraft_doc::TextFrameOptions)) -> f64 { + let (mut d, sid, fid) = doc_with("Hxg Hxg", Rect::new(36.0, 36.0, 300.0, 300.0), ParaAttrs::default()); + d.story_mut(sid).unwrap().format_chars(0..7, |f| { + f.over.font_family = Some(family.into()); + f.over.font_style = Some("Regular".into()); + f.over.size = Some(20.0); + }); + d.story_mut(sid).unwrap().vertical = vertical; + let tf = d.item_mut(fid).unwrap().text_frame_mut().unwrap(); + tf.options.inset = [4.0, 0.0, 0.0, 0.0]; + opts(&mut tf.options); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + cs.frames[0].lines[0].baseline +} + +#[test] +fn ascent_first_baseline_uses_the_typographic_ascender() { + use designcraft_doc::FirstBaseline; + let db = designcraft_fonts::FontDb::global(); + db.add_font(typo_test_font("TypoAscent 07", Some(700))); + let face = db.face("TypoAscent 07", "Regular"); + assert_eq!(face.family, "TypoAscent 07"); + assert_eq!((face.ascent, face.typo_ascent), (1000.0, 700.0), "hhea 1 em, typo 0.7 em"); + let top = 36.0 + 4.0; + let at = |kind: FirstBaseline, min: f64| { + first_baseline_in("TypoAscent 07", |o| { + o.first_baseline = kind; + o.first_baseline_min = min; + }) + }; + let close = |a: f64, b: f64| (a - b).abs() < 0.01; + // Ascent: top + typo ascender (0.7 em of 20 pt), not the hhea ascender (1 em). + assert!(close(at(FirstBaseline::Ascent, 0.0), top + 14.0), "{}", at(FirstBaseline::Ascent, 0.0)); + // The minimum offset still wins when it is larger. + assert!(close(at(FirstBaseline::Ascent, 18.0), top + 18.0)); + assert!(close(at(FirstBaseline::Ascent, 10.0), top + 14.0)); + // The other kinds don't use it. + assert!(close(at(FirstBaseline::CapHeight, 0.0), top + 0.660 * 20.0)); + assert!(close(at(FirstBaseline::XHeight, 0.0), top + 0.486 * 20.0)); + assert!(close(at(FirstBaseline::Leading, 0.0), top + 24.0)); + assert!(close(at(FirstBaseline::Fixed, 0.0), top)); + // A mixed first line takes its tallest typographic ascender: 0.7 × 20 pt beats 1 em × 10 pt … + let (mut d, sid, fid) = doc_with("Hxg Hxg", Rect::new(36.0, 36.0, 300.0, 300.0), ParaAttrs::default()); + let st = d.story_mut(sid).unwrap(); + st.format_chars(0..3, |f| { + f.over.font_family = Some("TypoAscent 07".into()); + f.over.size = Some(20.0); + }); + st.format_chars(3..7, |f| { + f.over.font_family = Some("Source Sans 3".into()); + f.over.size = Some(10.0); + }); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.inset = [4.0, 0.0, 0.0, 0.0]; + let first = |d: &Document| compose_story(d, sid, &ComposeOptions::default()).frames[0].lines[0].baseline; + assert!(close(first(&d), top + 14.0), "{}", first(&d)); + // … and 1 em × 16 pt beats it. + d.story_mut(sid).unwrap().format_chars(3..7, |f| f.over.size = Some(16.0)); + assert!(close(first(&d), top + 16.0), "{}", first(&d)); +} + +#[test] +fn em_box_first_baseline_follows_base_then_the_typo_box_or_the_cap_height() { + use designcraft_doc::FirstBaseline; + let db = designcraft_fonts::FontDb::global(); + // [`test_font`] (typo descender -326, cap height 660), with or without its `BASE` table, whose + // em box bottom (`ideo`) is at -170 units. + let add = |family: &str, base: bool, fields: &[(usize, i16)]| { + let mut b = test_font(family, Some(fields)); + if !base { + let tables = u16::from_be_bytes([b[4], b[5]]) as usize; + let rec = (0..tables).map(|t| 12 + 16 * t).find(|&r| &b[r..r + 4] == b"BASE").unwrap(); + // Still sorted between its neighbours, so the table directory stays valid. + b[rec..rec + 4].copy_from_slice(b"BASX"); + } + db.add_font(b); + db.face(family, "Regular").declared_em_box() + }; + // Typo ascender − descender = 1 em in a CJK font (JIS code page, bit 17): the typo ascender is + // the top. + let typo = [(TYPO_ASCENDER, 850), (TYPO_DESCENDER, -150)]; + assert_eq!(add("EmBoxTypo 850", false, &[typo[0], typo[1], (CODE_PAGES_HIGH, 1 << 1)]), Some((850.0, -150.0))); + // In a Latin font the cap height decides: 0.5 em + 660 / 2. + assert_eq!(add("EmBoxLatn 850", false, &typo), Some((830.0, -170.0))); + // Otherwise the em box is centred on half the cap height: 0.5 em + 600 / 2. + assert_eq!(add("EmBoxCapH 600", false, &[(TYPO_ASCENDER, 700), (CAP_HEIGHT, 600)]), Some((800.0, -200.0))); + // No OS/2 cap height: the H glyph's top, 656. + assert_eq!(add("EmBoxGlyphH 0", false, &[(TYPO_ASCENDER, 700), (CAP_HEIGHT, 0)]), Some((828.0, -172.0))); + // A `BASE` em box comes first. + assert_eq!(add("EmBoxBase 850", true, &[(TYPO_ASCENDER, 850), (TYPO_DESCENDER, -150)]), Some((830.0, -170.0))); + let (mut d, sid, fid) = doc_with("Hxg Hxg", Rect::new(36.0, 36.0, 300.0, 300.0), ParaAttrs::default()); + let mut first = |family: &str, sizes: [f64; 2], kind: FirstBaseline| { + let st = d.story_mut(sid).unwrap(); + for (range, size) in [(0..4, sizes[0]), (4..7, sizes[1])] { + st.format_chars(range, |f| { + f.over.font_family = Some(family.into()); + f.over.font_style = Some("Regular".into()); + f.over.size = Some(size); + }); + } + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.first_baseline = kind; + compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0].baseline - 36.0 + }; + let close = |a: f64, b: f64| (a - b).abs() < 0.01; + let asc = first("EmBoxCapH 600", [10.0; 2], FirstBaseline::Ascent); + assert!(close(asc, 7.0), "{asc}"); + for (family, top) in [("EmBoxTypo 850", 8.5), ("EmBoxLatn 850", 8.3), ("EmBoxCapH 600", 8.0), ("EmBoxGlyphH 0", 8.28), ("EmBoxBase 850", 8.3)] { + let emb = first(family, [10.0; 2], FirstBaseline::EmboxHeight); + assert!(close(emb, top), "{family}: {emb}"); + } + // A mixed first line takes its largest size. + let emb = first("EmBoxCapH 600", [10.0, 20.0], FirstBaseline::EmboxHeight); + assert!(close(emb, 16.0), "{emb}"); +} + +#[test] +fn em_box_first_baseline_in_a_latin_font_with_a_one_em_typo_box_follows_the_cap_height() { + use designcraft_doc::FirstBaseline; + // DejaVu Serif: 2048 units per em, typo ascender 1556 and descender -492 (one em), cap height + // 1493. Read from the system fonts; the test skips where it isn't installed. + let Some(bytes) = designcraft_fonts::system_font_dirs().iter().find_map(|dir| { + ["truetype/dejavu/DejaVuSerif.ttf", "dejavu/DejaVuSerif.ttf", "DejaVuSerif.ttf"].iter().find_map(|p| std::fs::read(dir.join(p)).ok()) + }) else { + eprintln!("skipped: DejaVu Serif isn't installed"); + return; + }; + let db = designcraft_fonts::FontDb::global(); + db.add_font(bytes); + let face = db.face("DejaVu Serif", "Book"); + assert_eq!(face.family, "DejaVu Serif"); + let (mut d, sid, fid) = doc_with("Hxg Hxg", Rect::new(36.0, 36.0, 300.0, 300.0), ParaAttrs::default()); + let st = d.story_mut(sid).unwrap(); + let n = st.len(); + st.format_chars(0..n, |f| { + f.over.font_family = Some("DejaVu Serif".into()); + f.over.font_style = Some("Book".into()); + f.over.size = Some(10.0); + }); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.first_baseline = FirstBaseline::EmboxHeight; + let first = compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0].baseline - 36.0; + // 0.5 em + cap height / 2, not the 1556-unit typo ascender (7.60 pt). + let expected = 5.0 + 1493.0 / 2048.0 * 5.0; + assert!((first - expected).abs() < 0.01, "{first}, expected {expected}"); +} + +#[test] +fn em_box_first_baseline_ignores_baseline_shift() { + use designcraft_doc::FirstBaseline; + let db = designcraft_fonts::FontDb::global(); + // Source Sans 3's `BASE` em box tops 0.83 em above the baseline. + db.add_font(test_font("EmBoxShft 830", Some(&[]))); + let (mut d, sid, fid) = doc_with("Hxg Hxg", Rect::new(36.0, 36.0, 300.0, 300.0), ParaAttrs::default()); + let st = d.story_mut(sid).unwrap(); + st.format_chars(0..7, |f| { + f.over.font_family = Some("EmBoxShft 830".into()); + f.over.font_style = Some("Regular".into()); + f.over.size = Some(10.0); + }); + st.format_chars(4..7, |f| f.over.baseline_shift = Some(1.0)); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.first_baseline = FirstBaseline::EmboxHeight; + let frames = compose_story(&d, sid, &ComposeOptions::default()).frames; + let line = &frames[0].lines[0]; + // The unshifted run's baseline stays 8.3 pt below the frame top; the shifted run is drawn 1 pt + // higher. + assert!((line.baseline - 36.0 - 8.3).abs() < 0.01, "{}", line.baseline); + let ys: Vec<f64> = line.glyphs.iter().map(|g| g.y).collect(); + assert_eq!(ys.len(), 7, "{ys:?}"); + assert!(ys[..3].iter().all(|y| y.abs() < 0.01), "{ys:?}"); + assert!(ys[4..].iter().all(|y| (y + 1.0).abs() < 0.01), "{ys:?}"); +} + +#[test] +fn ascent_first_baseline_falls_back_to_the_ascent_without_a_usable_typo_ascender() { + let db = designcraft_fonts::FontDb::global(); + // No OS/2 table, a negative and an absurd typo ascender: the (hhea) ascent, 1 em. + for (family, typo) in [("TypoAscentNoO", None), ("TypoAscentNeg", Some(-300)), ("TypoAscentBig", Some(i16::MAX))] { + db.add_font(typo_test_font(family, typo)); + let face = db.face(family, "Regular"); + assert_eq!(face.family, family); + assert_eq!(face.typo_ascent, face.ascent, "{family}"); + let b = first_baseline_in(family, |_| {}); + assert!((b - (36.0 + 4.0 + 20.0)).abs() < 0.01, "{family}: {b}"); + } +} + +#[test] +fn vertical_frames_keep_the_ascent_first_baseline() { + let db = designcraft_fonts::FontDb::global(); + db.add_font(typo_test_font("TypoAscentVrt", Some(700))); + let b = first_baseline_in_dir("TypoAscentVrt", true, |o| { + o.inset = [0.0; 4]; + }); + // Composed in the turned box (no inset): the hhea ascent, 1 em of 20 pt. + assert!((b - 20.0).abs() < 0.01, "{b}"); +} + +#[test] +fn a_visible_frame_stroke_insets_the_text() { + use designcraft_doc::{Stroke, StrokeAlign}; + let line = |stroke: Option<(StrokeAlign, &str)>| { + let (mut d, sid, fid) = doc_with("Hxg", Rect::new(36.0, 36.0, 300.0, 300.0), ParaAttrs::default()); + let it = d.item_mut(fid).unwrap(); + if let Some((align, swatch)) = stroke { + it.stroke = Stroke { weight: 4.0, align, swatch: swatch.into(), ..Stroke::default() }; + } + it.text_frame_mut().unwrap().options.inset = [4.0; 4]; + let l = &compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0]; + (l.baseline, l.x0, l.x1) + }; + let black = designcraft_color::swatch::BLACK; + let (b, x0, x1) = line(None); + // The part of the 4 pt stroke inside the frame: half of it centred, all of it inside, none + // outside or without a colour; on the left and right as on the top. + for (stroke, d) in [ + ((StrokeAlign::Center, black), 2.0), + ((StrokeAlign::Inside, black), 4.0), + ((StrokeAlign::Outside, black), 0.0), + ((StrokeAlign::Inside, designcraft_color::swatch::NONE), 0.0), + ] { + let (sb, sx0, sx1) = line(Some(stroke)); + assert!((sb - (b + d)).abs() < 1e-6, "{stroke:?}: baseline {sb} vs {b}"); + assert!((sx0 - (x0 + d)).abs() < 1e-6 && (sx1 - (x1 - d)).abs() < 1e-6, "{stroke:?}: {sx0}..{sx1} vs {x0}..{x1}"); + } +} + +#[test] +fn list_labels_do_not_raise_the_ascent_first_baseline() { + use designcraft_doc::{CharAttrs, ListType}; + // The paragraph's own character size (the label's) is 40 pt; its text is 10 pt (Source Sans 3: + // typo ascender 1 em). + let line = |text: &str, list: ListType| { + let (mut d, sid, _) = doc_with(text, Rect::new(36.0, 36.0, 300.0, 300.0), ParaAttrs { list_type: Some(list), ..Default::default() }); + let st = d.story_mut(sid).unwrap(); + for p in &mut st.paras { + p.chars = CharAttrs { font_family: Some("Source Sans 3".into()), size: Some(40.0), ..Default::default() }; + } + st.format_chars(0..text.len(), |f| f.over.size = Some(10.0)); + let l = compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0].clone(); + (l.baseline, l.ascent, l.leading) + }; + let (b, asc, lead) = line("Hxg Hxg", ListType::None); + // 1 em of the 10 pt text below the frame top. + assert!((b - 46.0).abs() < 0.01, "{b}"); + for list in [ListType::Bullets, ListType::Numbers] { + let (lb, lasc, llead) = line("Hxg Hxg", list); + assert!((lb - b).abs() < 1e-6, "{list:?}: {lb} vs {b}"); + // The 40 pt label still sets the line's ascent and leading. + assert!(lasc > asc + 20.0 && (llead - 48.0).abs() < 1e-6 && (lead - 12.0).abs() < 1e-6, "{list:?}: {lasc} {asc} {llead} {lead}"); + } + // An empty bulleted paragraph sits like an empty paragraph. + let (eb, _, _) = line("", ListType::None); + let (ebl, _, _) = line("", ListType::Bullets); + assert!((ebl - eb).abs() < 1e-6, "{ebl} vs {eb}"); +} + +#[test] +fn spaces_count_toward_the_ascent_first_baseline() { + // 10 pt text with a 40 pt space between its words (Source Sans 3: typo ascender 1 em). + let (mut d, sid, _) = doc_with("Hxg Hxg", Rect::new(36.0, 36.0, 300.0, 300.0), ParaAttrs::default()); + let st = d.story_mut(sid).unwrap(); + st.format_chars(0..7, |f| { + f.over.font_family = Some("Source Sans 3".into()); + f.over.size = Some(10.0); + }); + st.format_chars(3..4, |f| f.over.size = Some(40.0)); + let l = &compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0]; + assert!((l.baseline - (36.0 + 40.0)).abs() < 0.01, "{}", l.baseline); +} + +#[test] +fn vertical_scale_does_not_move_the_ascent_first_baseline() { + let db = designcraft_fonts::FontDb::global(); + db.add_font(typo_test_font("TypoAscentVSc", Some(700))); + let line = |v_scale: f64| { + let (mut d, sid, fid) = doc_with("Hxg", Rect::new(36.0, 36.0, 300.0, 300.0), ParaAttrs::default()); + d.story_mut(sid).unwrap().format_chars(0..3, |f| { + f.over.font_family = Some("TypoAscentVSc".into()); + f.over.size = Some(20.0); + f.over.v_scale = Some(v_scale); + }); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.inset = [4.0, 0.0, 0.0, 0.0]; + let l = &compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0]; + (l.baseline, l.ascent) + }; + let ((b100, a100), (b200, a200)) = (line(1.0), line(2.0)); + // The typo ascender of the nominal size (0.7 × 20 pt) at 100 % and at 200 %. + assert!((b100 - (36.0 + 4.0 + 14.0)).abs() < 0.01, "{b100}"); + assert!((b200 - b100).abs() < 0.01, "{b200} vs {b100}"); + // The line box keeps the scaled ascent. + assert!((a100 - 20.0).abs() < 0.01 && (a200 - 40.0).abs() < 0.01, "{a100} {a200}"); +} + +#[test] +fn cap_height_and_x_height_first_baselines_use_the_fonts_cap_and_x_heights() { + use designcraft_doc::FirstBaseline; + let db = designcraft_fonts::FontDb::global(); + db.add_font(test_font("CapXHeight 01", Some(&[(CAP_HEIGHT, 500), (X_HEIGHT, 300)]))); + let face = db.face("CapXHeight 01", "Regular"); + assert_eq!(face.family, "CapXHeight 01"); + assert_eq!((face.ascent, face.cap_height, face.x_height), (1000.0, 500.0, 300.0)); + let top = 36.0 + 4.0; + let at = |kind: FirstBaseline, min: f64| { + first_baseline_in("CapXHeight 01", |o| { + o.first_baseline = kind; + o.first_baseline_min = min; + }) + }; + let close = |a: f64, b: f64| (a - b).abs() < 0.01; + // Cap height 0.5 em and x height 0.3 em of 20 pt, not 0.72 / 0.5 of the 1 em ascent. + assert!(close(at(FirstBaseline::CapHeight, 0.0), top + 10.0), "{}", at(FirstBaseline::CapHeight, 0.0)); + assert!(close(at(FirstBaseline::XHeight, 0.0), top + 6.0), "{}", at(FirstBaseline::XHeight, 0.0)); + // The minimum offset still wins when it is larger. + assert!(close(at(FirstBaseline::CapHeight, 12.0), top + 12.0)); + assert!(close(at(FirstBaseline::XHeight, 5.0), top + 6.0)); + // Vertical scale and a raised baseline count, as for the ascent. + let (mut d, sid, fid) = doc_with("Hxg Hxg", Rect::new(36.0, 36.0, 300.0, 300.0), ParaAttrs::default()); + let st = d.story_mut(sid).unwrap(); + st.format_chars(0..7, |f| { + f.over.font_family = Some("CapXHeight 01".into()); + f.over.size = Some(20.0); + f.over.v_scale = Some(1.5); + }); + st.format_chars(4..5, |f| f.over.baseline_shift = Some(1.0)); + let tf = d.item_mut(fid).unwrap().text_frame_mut().unwrap(); + tf.options.inset = [4.0, 0.0, 0.0, 0.0]; + tf.options.first_baseline = FirstBaseline::CapHeight; + let first = |d: &Document| compose_story(d, sid, &ComposeOptions::default()).frames[0].lines[0].baseline; + assert!(close(first(&d), top + 15.0 + 1.0), "{}", first(&d)); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.first_baseline = FirstBaseline::XHeight; + assert!(close(first(&d), top + 9.0 + 1.0), "{}", first(&d)); +} + +#[test] +fn cap_height_and_x_height_fall_back_without_usable_os2_values() { + use designcraft_doc::FirstBaseline; + let db = designcraft_fonts::FontDb::global(); + // No OS/2 table, zero / negative and absurd values: the top of the H (0.656 em) and of the x + // (0.486 em). + let zero: &[(usize, i16)] = &[(CAP_HEIGHT, 0), (X_HEIGHT, -40)]; + let big: &[(usize, i16)] = &[(CAP_HEIGHT, i16::MAX), (X_HEIGHT, i16::MAX)]; + for (family, fields) in [("CapXHeightNoO", None), ("CapXHeightNeg", Some(zero)), ("CapXHeightBig", Some(big))] { + db.add_font(test_font(family, fields)); + let face = db.face(family, "Regular"); + assert_eq!(face.family, family); + assert_eq!((face.cap_height, face.x_height), (656.0, 486.0), "{family}"); + let cap = first_baseline_in(family, |o| o.first_baseline = FirstBaseline::CapHeight); + let x = first_baseline_in(family, |o| o.first_baseline = FirstBaseline::XHeight); + assert!((cap - (40.0 + 13.12)).abs() < 0.01 && (x - (40.0 + 9.72)).abs() < 0.01, "{family}: {cap} {x}"); + } +} + +#[test] +fn vertical_frames_keep_the_cap_height_and_x_height_first_baselines() { + use designcraft_doc::FirstBaseline; + let db = designcraft_fonts::FontDb::global(); + db.add_font(test_font("CapXHeightVrt", Some(&[(CAP_HEIGHT, 500), (X_HEIGHT, 300)]))); + let at = |kind: FirstBaseline| { + first_baseline_in_dir("CapXHeightVrt", true, |o| { + o.first_baseline = kind; + o.inset = [0.0; 4]; + }) + }; + // 0.72 and 0.5 of the 1 em ascent of 20 pt. + assert!((at(FirstBaseline::CapHeight) - 14.4).abs() < 0.01, "{}", at(FirstBaseline::CapHeight)); + assert!((at(FirstBaseline::XHeight) - 10.0).abs() < 0.01, "{}", at(FirstBaseline::XHeight)); +} + +// ---------- drop caps ---------- + +/// `text` set 10 pt on 12 pt leading in a 300 pt measure, with `para` on top. +fn drop_doc(text: &str, para: ParaAttrs) -> (Document, StoryId, ItemId) { + drop_doc_in(text, Rect::new(0.0, 0.0, 300.0, 1000.0), para) +} + +fn drop_doc_in(text: &str, rect: Rect, para: ParaAttrs) -> (Document, StoryId, ItemId) { + let (mut d, sid, fid) = doc_with(text, rect, para); + for p in &mut d.story_mut(sid).unwrap().paras { + p.chars = designcraft_doc::CharAttrs { size: Some(10.0), leading: Some(designcraft_doc::Leading::Points(12.0)), ..Default::default() }; + } + (d, sid, fid) +} + +fn drop_cap(lines: u32, chars: u32) -> ParaAttrs { + ParaAttrs { drop_cap_lines: Some(lines), drop_cap_chars: Some(chars), ..Default::default() } +} + +/// Cap height of a placed glyph, in points. +fn cap_of(g: &PlacedGlyph) -> f64 { + g.face.cap_height * g.sy +} + +/// x of the first glyph of `l` set from story byte `from` on. +fn text_x(l: &Line, from: usize) -> f64 { + l.glyphs.iter().find(|g| g.len > 0 && g.byte >= from).map(|g| g.x).unwrap() +} + +#[test] +fn drop_cap_spans_lines_and_indents_them() { + let text = [LOREM; 2].join(" "); + let (plain, psid, _) = drop_doc(&text, ParaAttrs::default()); + let plain = compose_story(&plain, psid, &ComposeOptions::default()); + let (d, sid, _) = drop_doc(&text, drop_cap(3, 1)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + let pl = all_lines(&plain); + assert!(lines.len() > 4); + // Baselines don't move. + for (k, l) in lines.iter().enumerate() { + assert!((l.baseline - (pl[0].baseline + 12.0 * k as f64)).abs() < 1e-6, "line {k} at {}", l.baseline); + } + // The drop cap: the first glyph, on line 1, its baseline on line 3's and its cap top on line 1's. + let dc = &lines[0].glyphs[0]; + assert_eq!((dc.byte, dc.len), (0, 1)); + assert!((lines[0].baseline + dc.y - lines[2].baseline).abs() < 1e-6, "drop cap baseline {}", lines[0].baseline + dc.y); + let body = lines[0].glyphs.iter().find(|g| g.byte == 1).unwrap(); + let top = lines[2].baseline - cap_of(dc); + assert!((top - (lines[0].baseline - cap_of(body))).abs() < 0.01, "cap top {top}"); + assert!(dc.sy > body.sy * 3.0); + // Lines 1–3 start after it, line 4 at the frame edge. + let right = dc.x + dc.adv; + assert!(right > 20.0); + for l in &lines[..3] { + assert!(text_x(l, 1) >= right - 1e-6, "{} < {right}", text_x(l, 1)); + assert!(l.end_x <= l.x1 + 0.5); + } + assert!(text_x(lines[3], 1) < 0.5); + // The text stays in order: the drop cap starts line 1 (exports and caret read it there). + assert_eq!(lines[0].range.start, 0); +} + +#[test] +fn drop_cap_of_two_characters_as_a_local_override() { + let (d, sid, _) = drop_doc(LOREM, drop_cap(2, 2)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + let (a, b) = (&lines[0].glyphs[0], &lines[0].glyphs[1]); + assert_eq!((a.byte, b.byte), (0, 1)); + let body = lines[0].glyphs.iter().find(|g| g.byte == 2).unwrap(); + for g in [a, b] { + assert!((lines[0].baseline + g.y - lines[1].baseline).abs() < 1e-6); + assert!(g.sy > body.sy * 1.5); + } + assert!((b.x - (a.x + a.adv)).abs() < 1e-6, "the drop cap characters sit side by side"); + let right = b.x + b.adv; + assert!(text_x(lines[0], 2) >= right - 1e-6 && text_x(lines[1], 2) >= right - 1e-6); + assert!(text_x(lines[2], 2) < 0.5); +} + +#[test] +fn short_paragraph_keeps_its_drop_cap_and_the_next_paragraph_is_not_indented() { + let (mut d, sid, _) = drop_doc(&format!("Short.\n{LOREM}"), drop_cap(3, 1)); + d.story_mut(sid).unwrap().paras[1].para = ParaAttrs::default(); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + assert_eq!(lines.iter().filter(|l| l.para == 0).count(), 1); + let dc = &lines[0].glyphs[0]; + // Still three lines tall (it may overlap the next paragraph, which isn't indented). + assert!((dc.y - 24.0).abs() < 1e-6, "{}", dc.y); + assert!(text_x(lines[1], 7) < 0.5); +} + +#[test] +fn drop_cap_counts_code_points_without_splitting_clusters_and_clamps_to_the_paragraph() { + // Four characters: a letter, its combining mark, another letter and a no-break space. + let (d, sid, _) = drop_doc("A\u{301}B\u{a0}rest of the words", drop_cap(2, 4)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let l = &cs.frames[0].lines[0]; + let big: Vec<usize> = l.glyphs.iter().filter(|g| g.len > 0 && g.y > 1.0).map(|g| g.byte).collect(); + assert!(big.contains(&0) && big.contains(&3), "{big:?}"); + assert!(big.iter().all(|b| *b < 6), "the r isn't part of it: {big:?}"); + // One character that has a combining mark after it takes the mark too. + let (d, sid, _) = drop_doc("e\u{301}tude and more words", drop_cap(2, 1)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let l = &cs.frames[0].lines[0]; + let big: Vec<usize> = l.glyphs.iter().filter(|g| g.y > 1.0).map(|g| g.byte).collect(); + assert!(!big.is_empty() && big.iter().all(|b| *b < 3), "{big:?}"); + assert!(l.glyphs.iter().any(|g| g.byte == 0 && g.y > 1.0)); + // More characters than the paragraph has: all of them, and nothing of the next paragraph. + let (mut d, sid, _) = drop_doc("Hi\nNext", drop_cap(2, 50)); + d.story_mut(sid).unwrap().paras[1].para = ParaAttrs::default(); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + assert!(lines[0].glyphs.iter().filter(|g| g.len > 0).all(|g| g.y > 1.0)); + assert!(lines[1].glyphs.iter().all(|g| g.y.abs() < 1e-6)); +} + +#[test] +fn hostile_drop_cap_values_do_not_panic() { + for (lines, chars) in [(u32::MAX, u32::MAX), (1_000_000, 1), (2, 0), (0, 3)] { + let (d, sid, _) = drop_doc(LOREM, drop_cap(lines, chars)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let g = &cs.frames[0].lines[0].glyphs[0]; + assert!(g.y.is_finite() && g.sy.is_finite() && g.y <= 12.0 * 25.0, "{lines}/{chars}: {}", g.y); + } + // No lines or no characters: no drop cap. + let (d, sid, _) = drop_doc(LOREM, drop_cap(0, 3)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(cs.frames[0].lines[0].glyphs[0].y.abs() < 1e-6); +} + +#[test] +fn one_line_drop_cap_is_not_enlarged() { + let (d, sid, _) = drop_doc(LOREM, drop_cap(1, 1)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let l = &cs.frames[0].lines[0]; + let (dc, body) = (&l.glyphs[0], l.glyphs.iter().find(|g| g.byte == 1).unwrap()); + assert!((cap_of(dc) - cap_of(body)).abs() < 0.01 && dc.y.abs() < 1e-6); +} + +#[test] +fn drop_cap_stays_with_the_first_line_across_a_column_break() { + // Two lines fit a column: line 3 starts the second column, at its edge. + let (mut d, sid, fid) = drop_doc_in(&[LOREM; 2].join(" "), Rect::new(0.0, 0.0, 400.0, 30.0), drop_cap(3, 1)); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2; + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let ft = &cs.frames[0]; + let col1: Vec<&Line> = ft.lines.iter().filter(|l| l.column == 1).collect(); + assert_eq!(ft.lines.iter().filter(|l| l.column == 0).count(), 2, "{:?}", ft.lines.iter().map(|l| l.column).collect::<Vec<_>>()); + let dc = &ft.lines[0].glyphs[0]; + assert!((dc.y - 24.0).abs() < 1e-6, "{}", dc.y); + assert!(text_x(&ft.lines[1], 1) >= dc.x + dc.adv - 1e-6); + assert!((text_x(col1[0], 1) - ft.columns[1].x0).abs() < 0.5); +} + +#[test] +fn drop_cap_in_right_to_left_paragraphs_is_on_the_right() { + let para = ParaAttrs { direction: Some(designcraft_doc::TextDirection::RightToLeft), align: Some(Align::Right), ..drop_cap(2, 1) }; + let (d, sid, _) = drop_doc(LOREM, para); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + let dc = lines[0].glyphs.iter().find(|g| g.byte == 0).unwrap(); + assert!((dc.x + dc.adv - 300.0).abs() < 0.5, "{}", dc.x + dc.adv); + for l in &lines[..2] { + assert!(l.glyphs.iter().filter(|g| g.byte > 0 && g.visible).all(|g| g.x + g.adv <= dc.x + 0.5)); + } + assert!(lines[2].end_x > 299.0 || lines[2].glyphs.iter().any(|g| g.x + g.adv > 299.0)); +} + +#[test] +fn vertical_frames_set_no_drop_cap() { + let (mut d, sid, _) = drop_doc(LOREM, drop_cap(3, 1)); + d.story_mut(sid).unwrap().vertical = true; + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(cs.frames[0].lines[0].glyphs[0].y.abs() < 1e-6); +} + +#[test] +fn drop_cap_takes_its_character_style_and_nested_styles_count_it() { + let (mut d, sid, _) = drop_doc(LOREM, ParaAttrs { drop_cap_style: Some("Initial".into()), ..drop_cap(2, 3) }); + for (name, fill) in [("Initial", "Initial Red"), ("Lead", "Lead Blue")] { + std::sync::Arc::make_mut(&mut d.styles).character.push(designcraft_doc::CharacterStyle { + name: name.into(), + based_on: None, + chars: designcraft_doc::CharAttrs { fill: Some(fill.into()), ..Default::default() }, + shortcut: String::new(), + }); + } + let fill = |cs: &ComposedStory, g: &PlacedGlyph| cs.styles[g.style as usize].fill.clone(); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let l = &cs.frames[0].lines[0]; + let at = |b: usize| l.glyphs.iter().find(|g| g.byte == b).unwrap(); + assert_eq!(fill(&cs, at(0)), "Initial Red"); + assert_eq!(fill(&cs, at(2)), "Initial Red"); + assert_eq!(fill(&cs, at(3)), "[Black]"); + // The drop cap's style is a leading "through 1 drop cap" nested style: the nested styles start + // after the drop cap. + let one = designcraft_doc::NestedStyle { style: "Lead".into(), through: true, count: 1, until: designcraft_doc::NestedUntil::Characters }; + d.story_mut(sid).unwrap().paras[0].para.nested_styles = Some(vec![one]); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let l = &cs.frames[0].lines[0]; + let at = |b: usize| l.glyphs.iter().find(|g| g.byte == b).unwrap(); + assert_eq!(fill(&cs, at(0)), "Initial Red"); + assert_eq!((fill(&cs, at(3)), fill(&cs, at(4))), ("Lead Blue".to_string(), "[Black]".to_string())); + // A nested style "through 1 drop cap" covers the drop cap; the next one starts after it. + let ns = |style: &str, until: designcraft_doc::NestedUntil| designcraft_doc::NestedStyle { style: style.into(), through: true, count: 1, until }; + d.story_mut(sid).unwrap().paras[0].para = ParaAttrs { + nested_styles: Some(vec![ns("Lead", designcraft_doc::NestedUntil::Dropcap), ns("Initial", designcraft_doc::NestedUntil::Words)]), + ..drop_cap(2, 3) + }; + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let l = &cs.frames[0].lines[0]; + let at = |b: usize| l.glyphs.iter().find(|g| g.byte == b).unwrap(); + assert_eq!(fill(&cs, at(0)), "Lead Blue"); + assert_eq!(fill(&cs, at(2)), "Lead Blue"); + assert_eq!(fill(&cs, at(3)), "Initial Red", "the second nested style starts after the drop cap"); + let first_space = LOREM.find(' ').unwrap(); + assert_eq!(fill(&cs, at(first_space + 1)), "[Black]"); +} + +#[test] +fn drop_cap_caret_and_hit_testing() { + let (d, sid, _) = drop_doc(LOREM, drop_cap(3, 1)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + let dc = lines[0].glyphs[0].clone(); + let drop_box = lines[0].drop_cap.unwrap().rect; + assert!(drop_box.y1 > lines[2].baseline - 0.01 && drop_box.y0 < lines[0].baseline - 5.0, "{drop_box:?}"); + // A click on the lower half of the big letter (beside line 3) is at the drop cap. + let y = lines[2].baseline - 2.0; + assert_eq!(hit(&cs, 0, Point::new(dc.x + dc.adv * 0.25, y)), Some(0)); + assert_eq!(hit(&cs, 0, Point::new(dc.x + dc.adv * 0.75, y)), Some(1)); + // A double click there is on the drop cap's character, not on line 3's text. + assert_eq!(hit_char(&cs, 0, Point::new(dc.x + dc.adv * 0.5, y)), Some(0)); + // The caret before the drop cap is as tall as it; after it, beside line 1's text. + let (_, x, bl, asc, _) = caret(&cs, 0).unwrap(); + assert!((x - dc.x).abs() < 1e-6 && (bl - lines[2].baseline).abs() < 1e-6 && asc > 24.0, "{x} {bl} {asc}"); + let (_, x, bl, _, _) = caret(&cs, 1).unwrap(); + assert!(x >= dc.x + dc.adv - 1e-6 && (bl - lines[0].baseline).abs() < 1e-6); +} + +#[test] +fn drop_cap_on_a_baseline_grid_reaches_down_by_grid_steps() { + // Lines on a 15 pt baseline grid (not the 12 pt leading): the drop cap reaches down two grid + // steps, to line 3. + let (mut d, sid, _) = drop_doc(LOREM, ParaAttrs { grid_align: Some(designcraft_doc::GridAlign::AllLines), ..drop_cap(3, 1) }); + d.settings.baseline_grid.increment = 15.0; + d.settings.baseline_grid.start = 0.0; + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + assert!((lines[1].baseline - lines[0].baseline - 15.0).abs() < 1e-6); + let dc = &lines[0].glyphs[0]; + assert!((lines[0].baseline + dc.y - lines[2].baseline).abs() < 1e-6); + let b = lines[0].drop_cap.unwrap(); + assert!((b.baseline - lines[2].baseline).abs() < 1e-6 && b.rect.y1 > lines[2].baseline); + // Scaled to the grid's line pitch: its cap top is still line 1's. + let body = lines[0].glyphs.iter().find(|g| g.byte == 1).unwrap(); + assert!((lines[2].baseline - cap_of(dc) - (lines[0].baseline - cap_of(body))).abs() < 0.01); +} + +/// A line with a larger auto leading doesn't move the drop cap: it reaches down `lines − 1` +/// leadings of the text after it, not to where line N is set. +#[test] +fn drop_cap_reaches_down_by_the_text_leading_past_a_line_with_larger_leading() { + use designcraft_doc::{CharAttrs, Composer, Leading}; + let text = [LOREM; 2].join(" "); + let para = ParaAttrs { composer: Some(Composer::SingleLine), ..drop_cap(3, 1) }; + let (mut d, sid, _) = doc_with(&text, Rect::new(0.0, 0.0, 300.0, 1000.0), para); + d.story_mut(sid).unwrap().paras[0].chars = CharAttrs { size: Some(12.0), leading: Some(Leading::Auto), ..Default::default() }; + // A larger word starts line 2, so lines 2 and 3 are set further down than 2 × 14.4 pt. + let start2 = all_lines(&compose_story(&d, sid, &ComposeOptions::default()))[1].range.start; + d.story_mut(sid).unwrap().format_chars(start2..start2 + 3, |f| f.over.size = Some(20.0)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + assert_eq!(lines[1].range.start, start2); + assert!(lines[2].baseline - lines[0].baseline > 2.0 * 14.4 + 5.0, "{} {}", lines[0].baseline, lines[2].baseline); + let dc = &lines[0].glyphs[0]; + assert!((dc.y - 2.0 * 14.4).abs() < 1e-6, "drop cap baseline {} below line 1", dc.y); + let b = lines[0].drop_cap.unwrap(); + assert!((b.baseline - (lines[0].baseline + 2.0 * 14.4)).abs() < 1e-6, "{}", b.baseline); +} + +/// A space after the drop cap is set at its size on its baseline and doesn't widen the indent: the +/// lines beside it start where its characters end. +#[test] +fn space_after_a_drop_cap_does_not_widen_the_indent() { + let (d, sid, _) = drop_doc(&format!("In 2003 {LOREM}"), drop_cap(2, 2)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + let (i, n) = (&lines[0].glyphs[0], &lines[0].glyphs[1]); + assert_eq!((i.byte, n.byte), (0, 1)); + let end = n.x + n.adv; + for l in &lines[..2] { + assert!((text_x(l, 3) - end).abs() < 1e-6, "{} vs {end}", text_x(l, 3)); + } + let space = lines[0].glyphs.iter().find(|g| g.byte == 2).unwrap(); + assert!((space.sy - n.sy).abs() < 1e-9 && (space.y - n.y).abs() < 1e-9, "the space is set like the drop cap"); + assert_eq!(lines[0].drop_cap.unwrap().end, 3); +} + +/// A one-line drop cap keeps its characters' own size (a smaller verse number stays smaller), and +/// the tabs in it advance to the paragraph's tab stops. +#[test] +fn one_line_drop_cap_keeps_its_size_and_sets_its_tabs() { + let stop = |position, align| designcraft_doc::TabStop { position, align, leader: String::new(), align_on: String::new() }; + let para = ParaAttrs { tabs: Some(vec![stop(30.0, TabAlign::Right), stop(40.0, TabAlign::Left)]), ..drop_cap(1, 3) }; + let (mut d, sid, _) = drop_doc(&format!("\t7\t{LOREM}"), para); + d.story_mut(sid).unwrap().format_chars(0..3, |f| f.over.size = Some(8.0)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let l = &cs.frames[0].lines[0]; + let at = |b: usize| l.glyphs.iter().find(|g| g.byte == b).unwrap(); + let (seven, body) = (at(1), at(3)); + assert!((seven.sy / body.sy - 0.8).abs() < 1e-9, "{} vs {}", seven.sy, body.sy); + assert!(seven.y.abs() < 1e-9); + assert!((seven.x + seven.adv - 30.0).abs() < 1e-6, "the number ends at the right tab: {}", seven.x + seven.adv); + assert!((text_x(l, 3) - 40.0).abs() < 1e-6, "the text starts at the left tab: {}", text_x(l, 3)); +} + +/// Right to left, the tabs in a drop cap advance to tab stops measured from the column's right +/// edge. +#[test] +fn right_to_left_drop_cap_sets_its_tabs_from_the_right_edge() { + let stop = |position, align| designcraft_doc::TabStop { position, align, leader: String::new(), align_on: String::new() }; + let para = ParaAttrs { + direction: Some(designcraft_doc::TextDirection::RightToLeft), + align: Some(Align::Right), + tabs: Some(vec![stop(30.0, TabAlign::Right), stop(40.0, TabAlign::Left)]), + ..drop_cap(1, 3) + }; + let (d, sid, _) = drop_doc(&format!("\t7\t{LOREM}"), para); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let l = &cs.frames[0].lines[0]; + let seven = l.glyphs.iter().find(|g| g.byte == 1).unwrap(); + // The 300 pt column's right edge is at 300: the number's far (left) end is 30 pt from it. + assert!((seven.x - 270.0).abs() < 1e-6, "the number ends at the right tab: {}", seven.x); + // The text beside it starts at the left tab, 40 pt from the edge. + let right = l.glyphs.iter().filter(|g| g.byte >= 3 && g.visible).map(|g| g.x + g.adv).fold(f64::NEG_INFINITY, f64::max); + assert!(right <= 260.0 + 1e-6 && right > 255.0, "text ends at {right}"); +} + +/// 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] +fn text_color_rules_follow_the_text() { + use designcraft_doc::{CharAttrs, CharFormat, Rule, TEXT_COLOR}; + let rule = Rule { on: true, weight: 2.0, ..Default::default() }; + let para = ParaAttrs { rule_above: Some(rule.clone()), rule_below: Some(rule.clone()), ..Default::default() }; + let (mut d, sid, fid) = doc_with("Red then blue", Rect::new(0.0, 0.0, 300.0, 200.0), para); + { + let s = d.story_mut(sid).unwrap(); + s.format_chars(0..3, |f: &mut CharFormat| f.over.merge(&CharAttrs { fill: Some("Red".into()), fill_tint: Some(0.5), ..Default::default() })); + s.format_chars(9..13, |f: &mut CharFormat| f.over.fill = Some("Blue".into())); + } + let decos = |d: &Document| { + let cs = compose_story(d, sid, &ComposeOptions::default()); + let mut v: Vec<(f64, String, f32)> = cs.frame(fid).unwrap().decos.iter().map(|dc| (dc.rect.y0, dc.color.clone(), dc.tint)).collect(); + v.sort_by(|a, b| a.0.total_cmp(&b.0)); + v.into_iter().map(|(_, c, t)| (c, t)).collect::<Vec<_>>() + }; + assert_eq!(rule.color, TEXT_COLOR); + assert_eq!(decos(&d), vec![("Red".to_string(), 0.5), ("Blue".to_string(), 1.0)]); + let swatch = Rule { color: "[Black]".into(), tint: 0.3, ..rule }; + d.story_mut(sid).unwrap().paras[0].para.rule_above = Some(swatch); + assert_eq!(decos(&d)[0], ("[Black]".to_string(), 0.3)); +} + +/// Align Left Edge (InDesign's default): the drop cap's origin moves left by its first letter's +/// left side bearing so its ink starts at the indent, and the lines beside it start where its +/// advance ends. Measured case: 14/18 text, two lines, one character. +#[test] +fn drop_cap_aligns_its_left_edge() { + let text = format!("Once {LOREM}"); + let compose = |align: Option<bool>| { + let para = ParaAttrs { left_indent: Some(10.0), drop_cap_align_left: align, ..drop_cap(2, 1) }; + let (mut d, sid, _) = doc_with(&text, Rect::new(0.0, 0.0, 300.0, 1000.0), para); + d.story_mut(sid).unwrap().paras[0].chars = + designcraft_doc::CharAttrs { size: Some(14.0), leading: Some(designcraft_doc::Leading::Points(18.0)), ..Default::default() }; + compose_story(&d, sid, &ComposeOptions::default()) + }; + use designcraft_geom::Shape as _; + let ink_left = |g: &PlacedGlyph| g.x + designcraft_fonts::FontDb::global().outline(g.face.get(), g.gid).bounding_box().x0 * g.sx; + for align in [None, Some(true), Some(false)] { + let cs = compose(align); + let lines = all_lines(&cs); + let dc = &lines[0].glyphs[0]; + assert_eq!(dc.byte, 0); + let lsb = ink_left(dc) - dc.x; + assert!(lsb > 0.5, "the O has a visible side bearing at this size: {lsb}"); + if align != Some(false) { + assert!((ink_left(dc) - 10.0).abs() < 1e-6, "{align:?}: ink at {}", ink_left(dc)); + } else { + assert!((dc.x - 10.0).abs() < 1e-6, "origin at {}", dc.x); + } + for l in &lines[..2] { + assert!((text_x(l, 1) - (dc.x + dc.adv)).abs() < 1e-6, "{align:?}: {} vs {}", text_x(l, 1), dc.x + dc.adv); + } + assert!((text_x(lines[2], 1) - 10.0).abs() < 1e-6); + } +} + +#[test] +fn caret_follows_spaces_typed_at_the_end() { + let x_at_end = |text: &str| { + let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs::default()); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let end_x = cs.frames[0].lines[0].end_x; + (caret(&cs, text.len()).unwrap().1, end_x) + }; + let (word, word_end) = x_at_end("Word"); + let (one, one_end) = x_at_end("Word "); + let (two, _) = x_at_end("Word "); + assert!(one > word + 1.0 && two > one + 1.0, "{word} {one} {two}"); + // The spaces don't count for the line's own length (alignment, justification). + assert!((one_end - word_end).abs() < 1e-9); + // Wrapped lines keep their spaces too: a caret between two spaces at a line end sits after + // the first one. + let text = "Lorem ipsum dolor sit amet consectetur"; + let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 140.0, 100.0), ParaAttrs::default()); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let l0 = &cs.frames[0].lines[0]; + if l0.range.end == text.find("consectetur").unwrap() { + let mid = text.find(" ").unwrap() + 1; + assert!(caret(&cs, mid).unwrap().1 > l0.end_x, "between the trailing spaces"); + } +} + #[test] fn spanish_text_gets_spanish_hyphenation() { let text = "La transición democrática fue una construcción colectiva extraordinariamente compleja y desesperadamente necesaria."; @@ -2338,6 +3420,32 @@ fn vertical_lines_fit_the_em_box() { } } +/// A drop cap set in one of the document's own fonts measures its side bearing in that font. +#[test] +fn drop_cap_in_a_document_font_aligns_its_left_edge() { + use designcraft_geom::Shape as _; + const FAMILY: &str = "DocFont Drop Cap"; + let text = format!("Once {LOREM}"); + let mut chars: Vec<char> = text.chars().collect(); + chars.sort_unstable(); + chars.dedup(); + let folder = std::env::temp_dir().join(format!("dc-compose-dropcap-docfont-{}", std::process::id())); + std::fs::create_dir_all(&folder).unwrap(); + std::fs::write(folder.join("dropcap.ttf"), designcraft_fonts::testing::font_with_glyph(FAMILY, &chars, 'O').unwrap()).unwrap(); + let scope = designcraft_fonts::FontDb::global().load_document_fonts(&folder).scope; + let para = ParaAttrs { left_indent: Some(10.0), ..drop_cap(2, 1) }; + let (mut d, sid, _) = doc_with(&text, Rect::new(0.0, 0.0, 300.0, 1000.0), para); + d.font_scope = scope; + d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| f.over.font_family = Some(FAMILY.into())); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let dc = &all_lines(&cs)[0].glyphs[0]; + assert_eq!(dc.face.family, FAMILY); + let ink_left = dc.x + designcraft_fonts::FontDb::global().outline(dc.face.get(), dc.gid).bounding_box().x0 * dc.sx; + assert!(ink_left - dc.x > 0.5, "the O has a visible side bearing"); + assert!((ink_left - 10.0).abs() < 1e-6, "ink at {ink_left}"); + let _ = std::fs::remove_dir_all(&folder); +} + /// Lines of `text` set in a frame `width` wide, with `tracking` on every character. fn tracked_lines(text: &str, width: f64, para: ParaAttrs, tracking: f64) -> Vec<Line> { let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, width, 4000.0), para); diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index b99740e2c..7215c156e 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -431,6 +431,8 @@ pub enum NestedUntil { ForcedLineBreak, EmSpace, EnSpace, + /// The paragraph's drop cap characters. + Dropcap, /// Any of these characters. Chars(String), } @@ -467,6 +469,18 @@ impl Default for NestedStyle { } } +impl NestedStyle { + /// The drop cap's character style as a nested style: `style` through 1 drop cap. + pub fn drop_cap(style: &str) -> Self { + NestedStyle { style: style.into(), through: true, count: 1, until: NestedUntil::Dropcap } + } + + /// Is this the form a drop cap's character style takes at the start of a nested style list? + pub fn is_drop_cap(&self) -> bool { + self.until == NestedUntil::Dropcap && self.through && self.count == 1 + } +} + /// A character style applied to every match of a regular expression in the paragraph. #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] @@ -530,12 +544,16 @@ pub struct TabStop { pub align_on: String, } +/// The colour of a paragraph rule that follows the paragraph's text colour. +pub const TEXT_COLOR: &str = "Text Color"; + /// A paragraph rule (Rule Above / Rule Below). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct Rule { pub on: bool, pub weight: f64, + /// A swatch, or [`TEXT_COLOR`]. pub color: String, pub tint: f32, /// Width: column (true) or text (false). @@ -547,7 +565,7 @@ pub struct Rule { impl Default for Rule { fn default() -> Self { - Rule { on: false, weight: 1.0, color: "[Black]".into(), tint: 1.0, column_width: true, offset: 0.0, left_indent: 0.0, right_indent: 0.0 } + Rule { on: false, weight: 1.0, color: TEXT_COLOR.into(), tint: 1.0, column_width: true, offset: 0.0, left_indent: 0.0, right_indent: 0.0 } } } @@ -809,8 +827,17 @@ attr_set! { last_line_indent: f64 = 0.0, space_before: f64 = 0.0, space_after: f64 = 0.0, + /// Drop cap: the paragraph's first `drop_cap_chars` characters (code points, never + /// splitting a base from its combining marks) set `drop_cap_lines` lines tall, in the + /// character style `drop_cap_style` ("" or `[None]`: the text's own formatting). drop_cap_lines: u32 = 0, drop_cap_chars: u32 = 0, + drop_cap_style: String = String::new(), + /// Align Left Edge: the drop cap's ink starts at the indent (its first letter's left side + /// bearing is taken off), and the lines beside it start where its advance ends. + drop_cap_align_left: bool = true, + /// Scale for Descenders (kept and round-tripped; doesn't change the layout). + drop_cap_scale_descenders: bool = false, /// Nested styles, in order from the paragraph start. nested_styles: Vec<NestedStyle> = Vec::new(), /// GREP styles: a character style for every match of a pattern (applied after nested). @@ -855,7 +882,8 @@ attr_set! { // Keeps keep_with_next: u32 = 0, keep_lines_together: bool = false, - keep_all_lines: bool = true, + /// All lines in paragraph (true) or at start/end of paragraph (false). + keep_all_lines: bool = false, keep_first: u32 = 2, keep_last: u32 = 2, start_paragraph: StartParagraph = StartParagraph::Anywhere, diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index 38de38ac7..7b2168e30 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -200,87 +200,229 @@ impl Document { Ok((id, sid)) } - /// Thread `from` → `to`: `to` joins `from`'s story right after `from`. If `to` held its own text, - /// that text is appended to the story (as a new paragraph) and its old story is removed. + /// Thread `from` → `to`: `to` joins `from`'s story right after `from` (between `from` and the + /// frame that followed it, if any). If `to` held its own text, that text is appended to the + /// story (as new paragraphs) and its old story is removed; frames that followed `to` come + /// along, frames before it keep an empty story of their own. An empty (unassigned) `from` or + /// `to` becomes a text frame. `from`'s story keeps its id. pub fn thread(&mut self, from: ItemId, to: ItemId) -> Result<()> { if from == to { return Err(DocError::Invalid("cannot thread a frame to itself".into())); } - let fs = self.item(from).and_then(|i| i.text_frame()).map(|t| t.story).ok_or(DocError::NoItem(from))?; - // Convert unassigned/graphic-less frames into text frames. - let ts = match self.item(to).ok_or(DocError::NoItem(to))?.content.clone() { - Content::Text(t) => Some(t.story), - Content::Unassigned => None, - _ => return Err(DocError::Invalid("can only thread to text or empty frames".into())), + self.check_thread_links(from, to, false)?; + let ts = self.frame_story(to)?; + let fs = match self.frame_story(from)? { + Some(s) => s, + None => self.give_empty_story(from)?, }; if ts == Some(fs) { return Err(DocError::Invalid("frames are already in the same story".into())); } - if let Some(ts) = ts { - let other = self.stories.remove(&ts).ok_or(DocError::NoStory(ts))?; - let other = Arc::unwrap_or_clone(other); - // Frames of the other story after `to` follow along; frames before it are left as empty new stories. - let pos_to = other.frames.iter().position(|f| *f == to).unwrap_or(0); - let before: Vec<ItemId> = other.frames[..pos_to].to_vec(); - let after: Vec<ItemId> = other.frames[pos_to..].to_vec(); - { - let st = self.story_mut(fs).ok_or(DocError::NoStory(fs))?; - if !other.text.is_empty() { - st.text.push('\n'); - st.text.push_str(&other.text); - st.paras.extend(other.paras.iter().cloned()); - // Char runs: '\n' takes the last run's format, then the other story's runs. - if let Some(last) = st.chars.last_mut() { - last.len += 1; - } - st.chars.extend(other.chars.iter().filter(|r| r.len > 0).cloned()); - st.tables.extend(other.tables.iter().map(|(k, v)| (*k, v.clone()))); - for n in &other.notes { - let id = st.next_note_id(); - st.notes.push(Arc::new(crate::Footnote { id, text: n.text.clone() })); - } - st.anchors.extend(other.anchors.iter().cloned()); - st.xrefs.extend(other.xrefs.iter().cloned()); - st.index_refs.extend(other.index_refs.iter().cloned()); - st.objects.extend(other.objects.iter().cloned()); - st.rev += 1; - } - let at = st.frames.iter().position(|f| *f == from).map_or(st.frames.len(), |p| p + 1); - for (k, f) in after.iter().enumerate() { - st.frames.insert(at + k, *f); - } - } - for f in &after { - if let Some(t) = self.item_mut(*f).and_then(Item::text_frame_mut) { - t.story = fs; - } - } - if !before.is_empty() { - let nid = StoryId(self.alloc()); - let mut ns = Story::new(nid); - ns.frames = before.clone(); - ns.vertical = other.vertical; - ns.direction = other.direction; - for f in &before { - if let Some(t) = self.item_mut(*f).and_then(Item::text_frame_mut) { - t.story = nid; - } - } - self.stories.insert(nid, Arc::new(ns)); - } - } else { + let Some(ts) = ts else { let st = self.story_mut(fs).ok_or(DocError::NoStory(fs))?; let at = st.frames.iter().position(|f| *f == from).map_or(st.frames.len(), |p| p + 1); st.frames.insert(at, to); + st.rev += 1; let it = self.item_mut(to).ok_or(DocError::NoItem(to))?; it.content = Content::Text(TextFrame { story: fs, options: TextFrameOptions::default() }); - } - if let Some(st) = self.story_mut(fs) { + return Ok(()); + }; + let mut other = Arc::unwrap_or_clone(self.stories.remove(&ts).ok_or(DocError::NoStory(ts))?); + // Frames of the other story from `to` on follow along; frames before it are left as an empty new story. + let pos_to = other.frames.iter().position(|f| *f == to).unwrap_or(0); + let after = other.frames.split_off(pos_to); + let before = std::mem::take(&mut other.frames); + let (vertical, direction) = (other.vertical, other.direction); + let offset; + { + let st = self.story_mut(fs).ok_or(DocError::NoStory(fs))?; + offset = if st.text.is_empty() { 0 } else { st.text.len().saturating_add(1) }; + // The story keeps its own direction, even when it was empty and takes the other's text. + let own = (st.vertical, st.direction); + st.append_story(other); + (st.vertical, st.direction) = own; + let at = st.frames.iter().position(|f| *f == from).map_or(st.frames.len(), |p| p + 1); + for (k, f) in after.iter().enumerate() { + st.frames.insert(at + k, *f); + } st.rev += 1; } + self.remap_thread_story_refs(ts, fs, offset); + for f in &after { + if let Some(t) = self.item_mut(*f).and_then(Item::text_frame_mut) { + t.story = fs; + } + } + if !before.is_empty() { + let nid = StoryId(self.alloc()); + let mut ns = Story::new(nid); + ns.frames = before.clone(); + ns.vertical = vertical; + ns.direction = direction; + for f in &before { + if let Some(t) = self.item_mut(*f).and_then(Item::text_frame_mut) { + t.story = nid; + } + } + self.stories.insert(nid, Arc::new(ns)); + } Ok(()) } + /// [`Document::thread`], except that an empty `from` alone in its story, threaded in front of + /// the first frame of `to`'s story, joins that story: the story with the text keeps its id + /// (and whatever refers to it). + pub fn thread_keeping_story(&mut self, from: ItemId, to: ItemId) -> Result<()> { + let fs = self.frame_story(from)?; + let from_empty = fs.is_none_or(|fs| self.story(fs).is_some_and(|s| s.text.is_empty() && s.frames == [from])); + let ts = self.frame_story(to)?; + let to_first = ts.and_then(|ts| self.story(ts)).is_some_and(|s| s.frames.first() == Some(&to)); + let (Some(ts), true, true) = (ts, from_empty, to_first && from != to) else { return self.thread(from, to) }; + self.check_thread_links(from, to, true)?; + if let Some(fs) = fs { + self.stories.remove(&fs); + self.remap_thread_story_refs(fs, ts, 0); + } + let st = self.story_mut(ts).ok_or(DocError::NoStory(ts))?; + st.frames.insert(0, from); + st.rev += 1; + let it = self.item_mut(from).ok_or(DocError::NoItem(from))?; + match it.text_frame_mut() { + Some(t) => t.story = ts, + None => it.content = Content::Text(TextFrame { story: ts, options: TextFrameOptions::default() }), + } + Ok(()) + } + + /// Story-external text addresses move with the incoming text when threading merges stories. + /// Linked children follow the surviving whole story, including for an empty removed parent; + /// their copied content stays unchanged until an explicit Update Link. + fn remap_thread_story_refs(&mut self, from: StoryId, to: StoryId, offset: usize) { + // The child's old snapshot is not a snapshot of the newly merged parent. Force it + // out of date even when the old and surviving revision numbers happen to coincide. + let stale_rev = self.story(to).map_or(0, |st| st.rev.wrapping_sub(1)); + for st in self.stories.values_mut() { + if st.link.is_some_and(|(parent, _)| parent == from) { + Arc::make_mut(st).link = Some((to, stale_rev)); + } + } + for link in &mut self.hyperlinks { + if let crate::HyperlinkSource::Text { story, start, end } = &mut link.source + && *story == from + { + *story = to; + *start = start.saturating_add(offset); + *end = end.saturating_add(offset); + } + } + for placeholder in &mut self.data_merge.placeholders { + if let crate::PlaceholderAnchor::Text { story, start, end } = &mut placeholder.anchor + && *story == from + { + *story = to; + *start = start.saturating_add(offset); + *end = end.saturating_add(offset); + } + } + if self.settings.primary_story == Some(from) { + self.settings.primary_story = Some(to); + } + } + + /// A linked child is replaced wholesale on update. Only empty, unlinked frame + /// extensions can join it without losing a link or newly appended body text. + fn check_thread_links(&self, from: ItemId, to: ItemId, keep_to: bool) -> Result<()> { + let fs = self.frame_story(from)?; + let ts = self.frame_story(to)?; + let first = fs.and_then(|id| self.story(id)); + let second = ts.and_then(|id| self.story(id)); + let incompatible = if keep_to { + first.is_some_and(|st| st.link.is_some()) || second.is_some_and(|st| st.link.is_some_and(|(parent, _)| Some(parent) == fs)) + } else { + second.is_some_and(|st| st.link.is_some()) + || first.is_some_and(|st| { + st.link.is_some() + && second.is_some_and(|incoming| !incoming.text.is_empty() || st.link.is_some_and(|(parent, _)| parent == incoming.id)) + }) + }; + if incompatible { + return Err(DocError::Invalid("unlink the linked story before merging its text".into())); + } + Ok(()) + } + + /// Can a user thread `from` → `to`? Both are text frames or empty frames (not lines), on + /// document spreads or on the same parent spread, not in one story already, and no frame + /// threads into `to` yet. [`Document::thread`] itself is less strict (import and autoflow). + pub fn check_thread(&self, from: ItemId, to: ItemId) -> Result<()> { + if from == to { + return Err(DocError::Invalid("cannot thread a frame to itself".into())); + } + for id in [from, to] { + let it = self.item(id).ok_or(DocError::NoItem(id))?; + if !matches!(it.content, Content::Text(_) | Content::Unassigned) || it.shape == Shape::GraphicLine { + return Err(DocError::Invalid(format!("{id} is not a text frame or an empty frame"))); + } + if it.locked || self.layer(it.layer).is_some_and(|l| l.locked) { + return Err(DocError::Invalid(format!("{id} is locked or on a locked layer"))); + } + } + let (Some(fl), Some(tl)) = (self.find(from), self.find(to)) else { return Err(DocError::NoItem(to)) }; + let same_place = match (fl.spread, tl.spread) { + (SpreadRef::Doc(_), SpreadRef::Doc(_)) => true, + (a, b) => a == b, + }; + if !same_place { + return Err(DocError::Invalid("frames on a parent spread thread only to frames on the same parent".into())); + } + let story = |id: ItemId| self.item(id).and_then(|i| i.text_frame()).map(|t| t.story); + if story(from).is_some() && story(from) == story(to) { + return Err(DocError::Invalid("the frames are already in the same thread".into())); + } + // Generated text is rebuilt from its source data. Merging body text into it would + // silently discard that text on the next update (endnotes update after every edit). + let generated = |sid| { + self.endnote_story == Some(sid) + || self.toc.as_ref().is_some_and(|t| t.story == sid) + || self.index.as_ref().is_some_and(|i| i.story == sid) + }; + let (fs, ts) = (story(from), story(to)); + if fs.is_some_and(generated) && ts.and_then(|sid| self.story(sid)).is_some_and(|st| !st.text.is_empty()) { + return Err(DocError::Invalid("cannot merge body text into a generated story".into())); + } + if ts.is_some_and(generated) && fs.and_then(|sid| self.story(sid)).is_some_and(|st| !st.text.is_empty() || st.frames != [from]) { + return Err(DocError::Invalid("a generated story can only take a new empty first frame".into())); + } + let keep_to = fs.is_none_or(|sid| self.story(sid).is_some_and(|st| st.text.is_empty() && st.frames == [from])) + && ts.and_then(|sid| self.story(sid)).is_some_and(|st| st.frames.first() == Some(&to)); + self.check_thread_links(from, to, keep_to)?; + if self.prev_frame(to).is_some() { + return Err(DocError::Invalid(format!("text already flows into {to} from another frame: break that thread first"))); + } + Ok(()) + } + + /// The story of a frame that can take part in a thread: `Some` for a text frame, `None` for + /// an empty (unassigned) frame. + fn frame_story(&self, id: ItemId) -> Result<Option<StoryId>> { + match &self.item(id).ok_or(DocError::NoItem(id))?.content { + Content::Text(t) => Ok(Some(t.story)), + Content::Unassigned => Ok(None), + _ => Err(DocError::Invalid("can only thread text frames or empty frames".into())), + } + } + + /// Make an empty frame a text frame with a new empty story. + fn give_empty_story(&mut self, id: ItemId) -> Result<StoryId> { + let sid = StoryId(self.alloc()); + let mut st = Story::new(sid); + st.frames.push(id); + let it = self.item_mut(id).ok_or(DocError::NoItem(id))?; + it.content = Content::Text(TextFrame { story: sid, options: TextFrameOptions::default() }); + self.stories.insert(sid, Arc::new(st)); + Ok(sid) + } + /// Break the thread after `frame`: the following frames become a new empty story (the text /// stays with the first part and may become overset). pub fn unthread_after(&mut self, frame: ItemId) -> Result<()> { @@ -309,6 +451,30 @@ impl Document { Ok(()) } + /// Take `frame` out of its thread: the frames before and after it join up and keep the text, + /// `frame` stays on the page with a new empty story. + pub fn remove_from_thread(&mut self, frame: ItemId) -> Result<()> { + let sid = self.item(frame).and_then(|i| i.text_frame()).map(|t| t.story).ok_or(DocError::NoItem(frame))?; + let (vertical, direction) = { + let st = self.story_mut(sid).ok_or(DocError::NoStory(sid))?; + if st.frames.len() < 2 { + return Err(DocError::Invalid(format!("frame {frame} is not threaded to another frame"))); + } + st.frames.retain(|f| *f != frame); + st.rev += 1; + (st.vertical, st.direction) + }; + let nid = StoryId(self.alloc()); + let mut ns = Story::new(nid); + ns.frames = vec![frame]; + ns.vertical = vertical; + ns.direction = direction; + self.stories.insert(nid, Arc::new(ns)); + let t = self.item_mut(frame).and_then(Item::text_frame_mut).ok_or(DocError::NoItem(frame))?; + t.story = nid; + Ok(()) + } + /// Next / previous frame in a thread. pub fn next_frame(&self, frame: ItemId) -> Option<ItemId> { let st = self.story(self.item(frame)?.text_frame()?.story)?; diff --git a/crates/doc/src/ids.rs b/crates/doc/src/ids.rs index 3dbb94c44..fed2cb8fc 100644 --- a/crates/doc/src/ids.rs +++ b/crates/doc/src/ids.rs @@ -21,3 +21,24 @@ id_type!(SpreadId, "sp"); id_type!(PageId, "p"); id_type!(LayerId, "l"); id_type!(AssetId, "a"); + +/// Fresh story-local id. Keep monotonically increasing ids normally; a loaded MAX id must +/// not make the next insertion overwrite it. In that case choose the first unused gap. +pub(crate) fn next_local_id(ids: impl Iterator<Item = u64> + Clone) -> u64 { + if let Some(next) = ids.clone().max().unwrap_or(0).checked_add(1) { + return next; + } + let mut used: Vec<_> = ids.collect(); + used.sort_unstable(); + let mut next = 1u64; + for id in used { + if id > next { + break; + } + if id == next { + // An allocated Vec cannot contain every u64 id, so this always finds a gap. + next = next.saturating_add(1); + } + } + next +} diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 0a701998e..70d56cefe 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -281,6 +281,10 @@ pub enum FirstBaseline { Leading, XHeight, Fixed, + /// The top of the font's em box: its `BASE` em box; else, for a CJK font, its OS/2 typo + /// ascender when the typo ascender and descender span one em; else half an em above half the + /// cap height. + EmboxHeight, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -321,7 +325,7 @@ pub struct TextFrameOptions { pub first_baseline_min: f64, pub ignore_wrap: bool, pub auto_size: AutoSize, - /// Reference point (0..9) that stays fixed when auto-sizing. + /// Reference point (0..9, row-major from the top left) that stays fixed when auto-sizing. pub auto_size_ref: u8, pub column_rule: bool, pub column_rule_weight: f64, @@ -382,7 +386,7 @@ impl Default for TextFrameOptions { first_baseline_min: 0.0, ignore_wrap: false, auto_size: AutoSize::Off, - auto_size_ref: 1, + auto_size_ref: 4, column_rule: false, column_rule_weight: 1.0, column_rule_color: designcraft_color::swatch::BLACK.into(), @@ -1156,13 +1160,29 @@ impl Item { b.inflate(w, w) } - /// The frame's text area (inner space) after inset. + /// The text frame's text area (inner space): inside the part of a visible stroke that lies + /// within the frame, then the inset, on each edge. pub fn text_area(&self) -> Rect { let r = self.inner_bounds(); - let inset = self.text_frame().map(|t| t.options.inset).unwrap_or([0.0; 4]); + let s = self.stroke_inside(); + let inset = self.text_frame().map(|t| t.options.inset).unwrap_or([0.0; 4]).map(|v| v + s); Rect::new(r.x0 + inset[1], r.y0 + inset[0], (r.x1 - inset[3]).max(r.x0 + inset[1]), (r.y1 - inset[2]).max(r.y0 + inset[0])) } + /// How far a visible stroke reaches inside the path: half its weight centred, all of it inside, + /// none outside. + fn stroke_inside(&self) -> f64 { + let w = self.stroke.weight; + if self.stroke.is_none() || !w.is_finite() { + return 0.0; + } + match self.stroke.align { + StrokeAlign::Center => w / 2.0, + StrokeAlign::Inside => w, + StrokeAlign::Outside => 0.0, + } + } + /// Spread-space centre. pub fn center(&self) -> Point { self.bounds().center() @@ -1207,6 +1227,32 @@ mod tests { assert_eq!(it.visible_bounds(), Rect::new(8.0, 18.0, 112.0, 72.0)); } + #[test] + fn text_area_starts_inside_the_stroke() { + let mut it = Item::new(ItemId(1), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0))); + it.content = Content::Text(TextFrame { story: StoryId(2), options: TextFrameOptions { inset: [5.0, 6.0, 7.0, 8.0], ..Default::default() } }); + let unstroked = Rect::new(6.0, 5.0, 92.0, 43.0); + let stroked = |align: StrokeAlign, swatch: &str| { + let mut it = it.clone(); + it.stroke = Stroke { weight: 4.0, align, swatch: swatch.into(), ..Stroke::default() }; + it.text_area() + }; + let black = designcraft_color::swatch::BLACK; + // The part of the stroke inside the frame, on every edge, then the inset. + assert_eq!(stroked(StrokeAlign::Center, black), Rect::new(8.0, 7.0, 90.0, 41.0)); + assert_eq!(stroked(StrokeAlign::Inside, black), Rect::new(10.0, 9.0, 88.0, 39.0)); + assert_eq!(stroked(StrokeAlign::Outside, black), unstroked); + // A stroke without a colour doesn't count. + assert_eq!(stroked(StrokeAlign::Inside, designcraft_color::swatch::NONE), unstroked); + // Nor does a weight that isn't a number. + it.stroke = Stroke { weight: f64::NAN, ..Stroke::default() }; + assert_eq!(it.text_area(), unstroked); + // A stroke wider than the frame leaves no room for text. + it.stroke = Stroke { weight: 200.0, align: StrokeAlign::Inside, ..Stroke::default() }; + let a = it.text_area(); + assert!(a.width() == 0.0 && a.height() == 0.0, "{a:?}"); + } + #[test] fn group_bounds_union_children() { let a = Item::new(ItemId(1), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0))); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index a643cc08d..c1c55dcfb 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -143,7 +143,7 @@ impl Default for DocumentGrid { } /// A named numbered list. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] pub struct NumberedList { pub name: String, @@ -151,12 +151,6 @@ pub struct NumberedList { pub continue_across_stories: bool, } -impl Default for NumberedList { - fn default() -> Self { - NumberedList { name: String::new(), continue_across_stories: true } - } -} - /// A conditional-text condition: its indicator colour (screen only) and whether text with it shows. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/crates/doc/src/notes.rs b/crates/doc/src/notes.rs index b681542cb..46a339653 100644 --- a/crates/doc/src/notes.rs +++ b/crates/doc/src/notes.rs @@ -178,7 +178,7 @@ impl Story { } pub(crate) fn next_note_id(&self) -> u64 { - self.notes.iter().map(|n| n.id).max().unwrap_or(0) + 1 + crate::ids::next_local_id(self.notes.iter().map(|n| n.id)) } /// Insert empty footnotes for `n` new reference characters before which `k` references sit. diff --git a/crates/doc/src/page.rs b/crates/doc/src/page.rs index f1eaa48ad..1fa753b3b 100644 --- a/crates/doc/src/page.rs +++ b/crates/doc/src/page.rs @@ -246,7 +246,8 @@ impl Page { /// Split `area` into `n` equal columns separated by `gutter`. pub fn column_rects(area: Rect, n: u32, gutter: f64) -> Vec<Rect> { - let n = n.max(1) as usize; + // The public allocator also receives native/imported options, outside command validation. + let n = n.clamp(1, 216) as usize; let w = ((area.width() - gutter * (n as f64 - 1.0)) / n as f64).max(0.0); (0..n).map(|i| Rect::new(area.x0 + i as f64 * (w + gutter), area.y0, area.x0 + i as f64 * (w + gutter) + w, area.y1)).collect() } @@ -403,3 +404,18 @@ mod tests { assert_eq!(s.page_at_x(5000.0), Some(1)); } } + +#[cfg(test)] +mod column_allocation_safety_tests { + use super::*; + + #[test] + fn column_allocation_caps_page_count_and_preserves_geometry() { + let area = Rect::new(0., 0., 300., 100.); + // Fail safely at a small boundary before exercising hostile counts. + assert_eq!(column_rects(area, 217, 0.).len(), 216); + assert_eq!(column_rects(area, u32::MAX, 0.).len(), 216); + assert_eq!(column_rects(area, 0, 12.), vec![area]); + assert_eq!(column_rects(area, 2, 12.), vec![Rect::new(0., 0., 144., 100.), Rect::new(156., 0., 300., 100.)]); + } +} diff --git a/crates/doc/src/slice.rs b/crates/doc/src/slice.rs index f85ec4adc..ebf59b6a8 100644 --- a/crates/doc/src/slice.rs +++ b/crates/doc/src/slice.rs @@ -126,6 +126,66 @@ impl Story { self.rev += 1; end } + + /// Append the whole of `other` as new paragraphs after this story's text (threading two + /// stories merges them this way). Its formatting, notes, endnotes, editorial notes, markers, + /// anchored objects and tables come along; footnotes, endnotes, editorial notes and tables get + /// ids that are free in this story. An empty story simply takes `other`'s content. Frames stay. + pub fn append_story(&mut self, mut other: Story) { + if other.text.is_empty() { + return; + } + if self.text.is_empty() { + let (id, frames, rev, link) = (self.id, std::mem::take(&mut self.frames), self.rev, self.link.take()); + *self = Story { id, frames, rev: rev.saturating_add(1), link, ..other }; + return; + } + if !other.tables.is_empty() { + let mut ids = std::collections::HashMap::new(); + let mut next = crate::ids::next_local_id(self.tables.keys().copied()); + for (old, t) in std::mem::take(&mut other.tables) { + let new = next; + let mut t = Arc::unwrap_or_clone(t); + t.id = new; + ids.insert(old, new); + self.tables.insert(new, Arc::new(t)); + // Continue through free ids without rescanning the table map for its maximum. + // On exhaustion wrap to gaps; an allocated map cannot occupy every u64 id. + next = next.checked_add(1).unwrap_or(1); + while self.tables.contains_key(&next) { + next = next.checked_add(1).unwrap_or(1); + } + } + for p in &mut other.paras { + p.table = p.table.and_then(|id| ids.get(&id).copied()); + } + } + self.text.push('\n'); + self.text.push_str(&other.text); + // The paragraph end takes the last run's format. + if let Some(last) = self.chars.last_mut() { + last.len += 1; + } + self.chars.extend(other.chars.into_iter().filter(|r| r.len > 0)); + self.paras.extend(other.paras); + for n in other.notes { + let id = self.next_note_id(); + self.notes.push(Arc::new(crate::notes::Footnote { id, text: n.text.clone() })); + } + for n in other.endnotes { + let id = crate::ids::next_local_id(self.endnotes.iter().map(|x| x.id)); + self.endnotes.push(Arc::new(crate::notes::Footnote { id, text: n.text.clone() })); + } + for n in other.editorial { + let id = crate::ids::next_local_id(self.editorial.iter().map(|x| x.id)); + self.editorial.push(Arc::new(crate::endnotes::EditorialNote { id, ..(*n).clone() })); + } + self.anchors.extend(other.anchors); + self.xrefs.extend(other.xrefs); + self.index_refs.extend(other.index_refs); + self.objects.extend(other.objects); + self.rev = self.rev.saturating_add(1); + } } #[cfg(test)] @@ -159,4 +219,52 @@ mod tests { assert_eq!(part.notes.len(), 1); assert!(a.extract(2..8).notes.is_empty()); } + + #[test] + fn append_tables_preserves_sparse_and_wrapping_ids() { + for existing in [vec![1, 3, u64::MAX], vec![1, 3, u64::MAX - 1], vec![1, 3, 8]] { + let story = |id, ids: &[u64]| { + let mut s = Story::with_text(StoryId(id), &vec![crate::TABLE_ANCHOR.to_string(); ids.len()].join("\n"), ParaFormat::default()); + for (p, &tid) in s.paras.iter_mut().zip(ids) { + p.table = Some(tid); + let mut t = crate::table::Table::new(tid, 1, 1, 0, 0, 100.0); + t.cells[0].text = Story::with_text(StoryId(0), &format!("{id}:{tid}"), ParaFormat::default()); + s.tables.insert(tid, Arc::new(t)); + } + s + }; + let mut a = story(1, &existing); + let b = story(2, &[1, 2, 3]); + a.check().unwrap(); + b.check().unwrap(); + a.append_story(b); + a.check().unwrap(); + assert_eq!(a.tables.len(), 6); + for (p, tid) in a.paras.iter().take(3).zip(&existing) { + assert_eq!(p.table, Some(*tid)); + assert_eq!(a.tables[tid].cells[0].text.text, format!("1:{tid}")); + } + for (p, tid) in a.paras.iter().skip(3).zip(1..=3) { + assert_eq!(a.tables[&p.table.unwrap()].cells[0].text.text, format!("2:{tid}")); + } + } + } + + #[test] + fn append_story_with_maximal_ids_does_not_overflow() { + let mut a = Story::with_text(StoryId(1), "Alpha", ParaFormat::default()); + a.tables.insert(u64::MAX, Arc::new(crate::table::Table::new(u64::MAX, 1, 1, 0, 0, 100.0))); + let note = |id| Arc::new(crate::notes::Footnote { id, text: Story::with_text(StoryId(0), "n", ParaFormat::default()) }); + a.endnotes.push(note(u64::MAX)); + a.notes.push(note(u64::MAX)); + a.rev = u64::MAX; + let mut b = Story::with_text(StoryId(2), "Beta", ParaFormat::default()); + b.tables.insert(1, Arc::new(crate::table::Table::new(1, 1, 1, 0, 0, 100.0))); + b.endnotes.push(note(1)); + b.notes.push(note(1)); + a.append_story(b); + assert_eq!(a.text, "Alpha\nBeta"); + assert_eq!(a.endnotes.len(), 2); + assert_eq!(a.notes.len(), 2); + } } diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index e348ff295..55caf30f1 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -692,8 +692,8 @@ impl Styles { self.object.iter().find(|s| s.name == name) } - /// The paragraph style chain from root to `name` (cycle-safe). - fn para_chain(&self, name: &str) -> Vec<&ParagraphStyle> { + /// The paragraph style chain from root to `name` (cycle-safe): resolution starts at its first. + pub fn para_chain(&self, name: &str) -> Vec<&ParagraphStyle> { let mut chain = Vec::new(); let mut cur = self.para(name); while let Some(s) = cur { diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 9ee28a139..8af281d3b 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -42,6 +42,126 @@ fn thread_to_empty_frame_converts_it() { assert!(d.thread(a, a).is_err()); } +#[test] +fn threading_merges_tables_notes_and_endnotes() { + let mut d = doc(); + let lid = d.default_layer(); + let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "first", ParaFormat::default()).unwrap(); + let (b, sb) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "second", ParaFormat::default()).unwrap(); + for (sid, note) in [(sa, "Note A"), (sb, "Note B")] { + let st = d.story_mut(sid).unwrap(); + st.insert_note(3, note, ParaFormat::default()); + // Both stories hold a table with id 1. + let n = st.len(); + st.insert_table(n, crate::table::Table::new(1, 2, 2, 0, 0, 80.0)); + } + let st = d.story_mut(sb).unwrap(); + st.insert_endnote(2, "End B", ParaFormat::default()); + st.insert(1, &crate::NOTE_MARK.to_string()); + d.thread(a, b).unwrap(); + d.check().unwrap(); + let st = d.story(sa).unwrap(); + assert!(d.story(sb).is_none()); + assert_eq!(st.tables.len(), 2); + assert_eq!(st.notes.len(), 2); + assert_eq!(st.notes[1].text.text, "Note B"); + assert_eq!(st.endnotes.len(), 1); + assert_eq!(st.endnotes[0].text.text, "End B"); + assert_eq!(st.editorial.len(), 1); +} + +#[test] +fn empty_frame_threaded_before_a_story_joins_it_at_the_front() { + let mut d = doc(); + let lid = d.default_layer(); + let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "", ParaFormat::default()).unwrap(); + let (b, sb) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "Hello", ParaFormat::default()).unwrap(); + d.thread_keeping_story(a, b).unwrap(); + d.check().unwrap(); + assert!(d.story(sa).is_none()); + let st = d.story(sb).unwrap(); + assert_eq!(st.frames, vec![a, b]); + assert_eq!(st.text, "Hello"); +} + +#[test] +fn empty_story_takes_the_threaded_text_without_a_blank_paragraph() { + let mut d = doc(); + let lid = d.default_layer(); + let (a, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "", ParaFormat::default()).unwrap(); + let (a2, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 110.0, 100.0, 200.0), lid, "", ParaFormat::default()).unwrap(); + d.thread(a, a2).unwrap(); + let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "Hello", ParaFormat::default()).unwrap(); + d.thread(a2, b).unwrap(); + d.check().unwrap(); + let st = d.story(d.item(a).unwrap().text_frame().unwrap().story).unwrap(); + assert_eq!(st.frames, vec![a, a2, b]); + assert_eq!(st.text, "Hello"); +} + +#[test] +fn threading_from_an_empty_frame_makes_it_a_text_frame() { + let mut d = doc(); + let lid = d.default_layer(); + let id = ItemId(d.alloc()); + let it = Item::new(id, lid, Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(0.0, 200.0, 50.0, 300.0))); + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "Hello", ParaFormat::default()).unwrap(); + d.thread(id, b).unwrap(); + d.check().unwrap(); + let st = d.story(d.item(id).unwrap().text_frame().unwrap().story).unwrap(); + assert_eq!(st.frames, vec![id, b]); + assert_eq!(st.text, "Hello"); +} + +#[test] +fn removing_a_frame_from_its_thread_keeps_the_text_in_the_others() { + let mut d = doc(); + let lid = d.default_layer(); + let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "words", ParaFormat::default()).unwrap(); + let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "", ParaFormat::default()).unwrap(); + let (c, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(210.0, 10.0, 300.0, 100.0), lid, "", ParaFormat::default()).unwrap(); + d.thread(a, b).unwrap(); + d.thread(b, c).unwrap(); + d.remove_from_thread(b).unwrap(); + d.check().unwrap(); + assert_eq!(d.story(sa).unwrap().frames, vec![a, c]); + assert_eq!(d.story(sa).unwrap().text, "words"); + let own = d.item(b).unwrap().text_frame().unwrap().story; + assert_ne!(own, sa); + assert!(d.story(own).unwrap().text.is_empty()); + // A frame alone in its story has no thread to leave. + assert!(d.remove_from_thread(b).is_err()); +} + +#[test] +fn a_frame_removed_from_a_vertical_thread_stays_vertical() { + let mut d = doc(); + let lid = d.default_layer(); + let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "縦書き", ParaFormat::default()).unwrap(); + let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "", ParaFormat::default()).unwrap(); + d.story_mut(sa).unwrap().vertical = true; + d.thread(a, b).unwrap(); + d.remove_from_thread(b).unwrap(); + d.check().unwrap(); + assert!(d.frame_vertical(d.item(a).unwrap())); + assert!(d.frame_vertical(d.item(b).unwrap()), "the removed frame keeps the story's direction"); +} + +#[test] +fn a_frame_removed_from_a_right_to_left_thread_keeps_its_column_order() { + let mut d = doc(); + let lid = d.default_layer(); + let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "نص", ParaFormat::default()).unwrap(); + let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "", ParaFormat::default()).unwrap(); + d.story_mut(sa).unwrap().direction = crate::TextDirection::RightToLeft; + d.thread(a, b).unwrap(); + d.remove_from_thread(b).unwrap(); + d.check().unwrap(); + let own = d.item(b).unwrap().text_frame().unwrap().story; + assert_eq!(d.story(own).unwrap().direction, crate::TextDirection::RightToLeft); +} + #[test] fn frames_keep_their_story_direction_through_threading() { let mut d = doc(); diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 62578ba2f..1de90d145 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -30,6 +30,7 @@ log = { workspace = true } regex = { workspace = true } quick-xml = { workspace = true } caseless = { workspace = true } +unicode-segmentation = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] tempfile = { workspace = true } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index e7190acb4..0885d1554 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -20,13 +20,6 @@ fn has_clip(s: &Session) -> std::result::Result<(), String> { if s.clipboard.is_some() || text { Ok(()) } else { Err("clipboard is empty".into()) } } -fn has_clip_or_text(s: &Session) -> std::result::Result<(), String> { - if s.active().is_some_and(|d| d.selection.text.is_some()) { - return Ok(()); - } - has_clip(s) -} - fn has_selection_or_text(s: &Session) -> std::result::Result<(), String> { if s.active().and_then(|d| d.selection.text).is_some_and(|t| !t.is_caret()) { return Ok(()); @@ -88,7 +81,73 @@ fn paste_text(s: &mut Session, text: Option<String>, formatted: bool) -> Result< }); } let text = text.or_else(|| clip.map(|(_, p)| p)).ok_or_else(|| super::bad("edit.paste", "clipboard is empty"))?; - s.execute("text.insert", &json!({"text": text.replace("\r\n", "\n").replace('\r', "\n"), "raw": true})) + let text = outside_text(&text)?; + s.execute("text.insert", &json!({"text": text, "raw": true})) +} + +/// Text from the system clipboard as story text: Unix line ends, and none of the private-use +/// characters (U+E000–U+E1FF) that stories use as markers for breaks, footnotes, tables and +/// variables, since a stray one would be read as a marker without its payload. +fn outside_text(text: &str) -> Result<String> { + let text: String = text.replace("\r\n", "\n").replace('\r', "\n").chars().filter(|c| !('\u{E000}'..='\u{E1FF}').contains(c)).collect(); + if text.is_empty() { Err(super::bad("edit.paste", "the clipboard holds no text")) } else { Ok(text) } +} + +/// Pasted text with no insertion point: a new text frame holding it, as wide as the first column +/// of the page's margin area and as tall as the text (at most the margin area's height), centred +/// on `at` (spread coordinates) or else on the margin area. The frame ends up selected. With +/// `slice` (the text copied here), the frame's story is that formatted copy. +fn paste_text_frame(s: &mut Session, p: &Value, text: &str, slice: Option<&designcraft_doc::Story>) -> Result<Value> { + use designcraft_geom::{Point, Rect, shapes}; + const LIMIT: f64 = 1.0e5; + let sr = super::spread_param(p, "spread")?; + let at = super::point_param(p, "at") + .filter(|a| a.x.is_finite() && a.y.is_finite()) + .map(|a| Point::new(a.x.clamp(-LIMIT, LIMIT), a.y.clamp(-LIMIT, LIMIT))); + let lid = s.doc()?.active_layer; + let cache = s.cache.clone(); + let text = if slice.is_some() { String::new() } else { outside_text(text)? }; + s.edit(|d, sel| { + let sp = d.spread(sr).ok_or_else(|| super::bad("edit.paste", "no such spread"))?; + let page = at.and_then(|a| sp.page_at_x(a.x)).and_then(|i| sp.pages.get(i)).or_else(|| sp.pages.first()); + let page = page.ok_or_else(|| super::bad("edit.paste", "the spread has no pages"))?; + let margins = page.margin_rect(); + let column = page.column_rects().first().copied().unwrap_or(margins); + let w = column.width().max(12.0); + let max_h = margins.height().max(12.0); + let c = at.unwrap_or(Point::new(column.center().x, margins.center().y)); + let rect = |h: f64| Rect::new(c.x - w / 2.0, c.y - h / 2.0, c.x + w / 2.0, c.y + h / 2.0); + let para = match slice.and_then(|sl| sl.paras.first()) { + Some(pf) => designcraft_doc::ParaFormat { table: None, ..pf.clone() }, + None => designcraft_doc::ParaFormat { style: d.styles.default_paragraph.clone(), ..Default::default() }, + }; + let (id, sid) = d.add_text_frame(sr, rect(max_h), lid, &text, para)?; + if let Some(sl) = slice { + d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?.insert_story(0, sl); + } + // Shrink to the composed text unless it already fills the frame. + let composed = cache.get(d, sid, None); + if !composed.is_overset() + && let Some(ft) = composed.frame(id) + { + let inset = d.item(id).and_then(|i| i.text_frame()).map_or(0.0, |tf| tf.options.inset[0] + tf.options.inset[2]); + let h = (ft.content_height + inset + 0.5).clamp(12.0, max_h); + if h.is_finite() + && h < max_h + && let Some(it) = d.item_mut(id) + { + it.path = shapes::rectangle(rect(h)); + // Keep the full height if the last line no longer fits. + if cache.get(d, sid, None).is_overset() + && let Some(it) = d.item_mut(id) + { + it.path = shapes::rectangle(rect(max_h)); + } + } + } + *sel = Selection::items(vec![id]); + Ok(json!({"id": id.0, "story": sid.0})) + }) } fn strip_objects(st: &designcraft_doc::Story) -> designcraft_doc::Story { @@ -110,6 +169,8 @@ pub fn specs() -> Vec<CommandSpec> { st.doc = e.doc; st.selection = e.selection; st.revision += 1; + // Pending typing overrides belong to the current edit state, not a restored snapshot. + s.typing_format = None; Ok(json!({"undone": e.label})) }), cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, |s, _| { @@ -119,6 +180,7 @@ pub fn specs() -> Vec<CommandSpec> { st.doc = e.doc; st.selection = e.selection; st.revision += 1; + s.typing_format = None; Ok(json!({"redone": e.label})) }), cmd!(noundo "selection.set", "Select", [], None, "{ids: [id], add?: bool, content?: bool}", has_doc, |s, p| { @@ -227,11 +289,18 @@ pub fn specs() -> Vec<CommandSpec> { "Paste", ["Edit"], Some("Cmd+V"), - "{inPlace?: bool, text?: the system clipboard's text (pastes it unless it is what was copied here)}", - has_clip_or_text, + "{inPlace?: bool, text?: the system clipboard's text (pastes it unless it is what was copied here; with no insertion point it becomes a new text frame), spread?, at?: [x, y] (spread coords: the new text frame's centre)}", + // The system clipboard may hold text from another app, so Paste is always available. + has_doc, |s, p| { + let text = super::str_param(p, "text").filter(|t| !t.is_empty()); if s.doc()?.selection.text.is_some() { - return paste_text(s, super::str_param(p, "text").map(str::to_string), true); + return paste_text(s, text.map(str::to_string), true); + } + if let Some(text) = text { + // What our own text copy put on the clipboard keeps its formatting. + let slice = s.text_clipboard.clone().filter(|(_, plain)| text.replace("\r\n", "\n") == *plain).map(|(slice, _)| slice); + return paste_text_frame(s, p, text, slice.as_deref()); } let Some(clip) = s.clipboard.clone() else { return Err(super::bad("edit.paste", "clipboard is empty")); @@ -316,6 +385,87 @@ mod text_clipboard_tests { s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story } + #[test] + fn paste_text_with_no_insertion_point_makes_a_text_frame() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let items = |s: &Session| s.doc().unwrap().doc.spreads[0].items.len(); + // Text from another app, nothing copied here: a new, selected text frame. + let r = s.execute("edit.paste", &json!({"text": "From elsewhere\r\nSecond line"})).unwrap(); + let id = ItemId(r["id"].as_u64().unwrap()); + let d = s.doc().unwrap(); + assert_eq!(d.selection.items, vec![id]); + assert!(d.selection.text.is_none(), "selected as an object"); + let it = d.doc.item(id).unwrap(); + assert_eq!(d.doc.story(it.text_frame().unwrap().story).unwrap().text, "From elsewhere\nSecond line"); + let page = &d.doc.spreads[0].pages[0]; + let (b, m) = (it.bounds(), page.margin_rect()); + assert!((b.width() - page.column_rects()[0].width()).abs() < 1e-6, "{b:?}"); + assert!(b.height() < m.height() / 2.0, "fits two lines: {b:?}"); + assert!((b.center().y - m.center().y).abs() < 1e-6, "{b:?}"); + assert!(!s.cache.get(&d.doc, it.text_frame().unwrap().story, None).is_overset()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(items(&s), 0, "one undo removes it"); + // Centred on `at`; hostile numbers are clamped. + let r = s.execute("edit.paste", &json!({"text": "Here", "at": [200.0, 300.0]})).unwrap(); + let b = s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().bounds(); + assert!((b.center().x - 200.0).abs() < 1e-6 && (b.center().y - 300.0).abs() < 1e-6, "{b:?}"); + assert!(s.execute("edit.paste", &json!({"text": "Far", "at": [1e300, -1e300], "spread": 99})).is_err()); + s.execute("edit.paste", &json!({"text": "Far", "at": [1e300, -1e300]})).unwrap(); + // Copied objects paste when no text is given. + s.execute("edit.deselectAll", &json!({})).unwrap(); + let rect = s.execute("frame.create", &json!({"rect": [72, 72, 200, 160]})).unwrap()["id"].clone(); + s.execute("selection.set", &json!({"ids": [rect]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + let n = items(&s); + let r = s.execute("edit.paste", &json!({})).unwrap(); + assert_eq!(r["ids"].as_array().unwrap().len(), 1); + assert_eq!(items(&s), n + 1); + assert!(s.doc().unwrap().doc.item(ItemId(r["ids"][0].as_u64().unwrap())).unwrap().text_frame().is_none()); + s.doc().unwrap().doc.check().unwrap(); + } + + #[test] + fn outside_text_never_pastes_markers() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + // Private-use characters from another app would read as footnote, table or variable markers. + let dirty = "a\u{E00A}b\u{E009}c\u{E1FF}d\u{E200}"; + let r = s.execute("edit.paste", &json!({"text": dirty})).unwrap(); + let st = s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + assert_eq!(s.doc().unwrap().doc.story(st).unwrap().text, "abcd\u{E200}"); + s.doc().unwrap().doc.check().unwrap(); + let b = frame(&mut s, "Target"); + s.execute("text.select", &json!({"story": b.0, "anchor": 6, "focus": 6})).unwrap(); + s.execute("edit.paste", &json!({"text": dirty})).unwrap(); + s.execute("edit.pasteWithoutFormatting", &json!({"text": dirty})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(b).unwrap().text, "Targetabcd\u{E200}abcd\u{E200}"); + assert!(s.execute("edit.paste", &json!({"text": "\u{E00A}"})).is_err()); + s.doc().unwrap().doc.check().unwrap(); + } + + #[test] + fn copied_text_pasted_with_no_insertion_point_keeps_its_formatting() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = frame(&mut s, "Bold words\nSecond"); + s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": 4})).unwrap(); + s.execute("type.bold", &json!({})).unwrap(); + s.execute("text.select", &json!({"story": a.0, "anchor": 11, "focus": 17})).unwrap(); + s.execute("type.para", &json!({"attrs": {"align": "center"}})).unwrap(); + s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": 17})).unwrap(); + let copied = s.execute("edit.copy", &json!({})).unwrap()["text"].as_str().unwrap().to_string(); + s.execute("edit.deselectAll", &json!({})).unwrap(); + let r = s.execute("edit.paste", &json!({"text": copied})).unwrap(); + let d = s.doc().unwrap(); + let st = d.doc.story(d.doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story).unwrap(); + assert_eq!(st.text, "Bold words\nSecond"); + assert_eq!(st.char_format_at(2).over.font_style.as_deref(), Some("Bold")); + assert_eq!(st.char_format_at(7).over.font_style, None); + assert_eq!(st.paras[1], d.doc.story(a).unwrap().paras[1]); + d.doc.check().unwrap(); + } + #[test] fn formatted_copy_paste_and_plain_paste() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index a1919b89e..4b048674d 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -989,4 +989,24 @@ mod document_fonts_tests { assert!(glyph_faces(&s).iter().all(|(_, f)| f.family == FAMILY)); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn bold_and_italic_link_the_document_fonts_styles() { + const FAMILY: &str = "DocFont Linked"; + let dir = temp_dir("linked"); + std::fs::write(dir.join(DOCUMENT_FONTS_FOLDER).join("linked.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap(); + let path = dir.join("Linked.designcraft"); + write_document(&path, FAMILY); + + let mut s = Session::new(); + open(&mut s, &path); + let sid = *s.doc().unwrap().doc.stories.keys().next().unwrap(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + // The family is the document's alone: its Regular is the style without bold or italic. + let r = s.execute("type.bold", &json!({"on": false})).unwrap(); + assert_eq!(r["fontStyle"], "Regular", "{r}"); + let r = s.execute("type.italic", &json!({"on": false})).unwrap(); + assert_eq!(r["fontStyle"], "Regular", "{r}"); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 31e4544f4..affe07126 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -50,6 +50,7 @@ mod strokes; mod style; pub mod table; pub mod text; +mod thread; mod toc; mod transitions; mod variables; @@ -184,6 +185,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(package::specs()); v.extend(print::specs()); v.extend(text::specs()); + v.extend(thread::specs()); v.extend(style::specs()); v.extend(table::specs()); v.extend(layout::specs()); diff --git a/crates/engine/src/cmd/notes.rs b/crates/engine/src/cmd/notes.rs index c17dc6cdb..e3b3ccc52 100644 --- a/crates/engine/src/cmd/notes.rs +++ b/crates/engine/src/cmd/notes.rs @@ -22,7 +22,7 @@ pub fn specs() -> Vec<CommandSpec> { "Document Footnote Options…", ["Type"], None, - "{style?: arabic|upperRoman|lowerRoman|upperLetters|lowerLetters|symbols, startAt?, restart?: never|page|spread|section, prefix?, suffix?, affixIn?: none|reference|text|both, refPosition?: superscript|subscript|normal|otSuperscript, refCharStyle?, paraStyle?, separator?, spaceBefore?, spaceBetween?, firstBaseline?, firstBaselineMin?, spanColumns?, rule?: {on, weight, color, tint, width, offset, leftIndent}} → the options (no params: just read them)", + "{style?: arabic|upperRoman|lowerRoman|upperLetters|lowerLetters|symbols, startAt?, restart?: never|page|spread|section, prefix?, suffix?, affixIn?: none|reference|text|both, refPosition?: superscript|subscript|normal|otSuperscript, refCharStyle?, paraStyle?, separator?, spaceBefore?, spaceBetween?, firstBaseline?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, firstBaselineMin?, spanColumns?, rule?: {on, weight, color, tint, width, offset, leftIndent}} → the options (no params: just read them)", has_doc, options ), diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index aa5a2cc01..5e9ed3912 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -532,7 +532,7 @@ pub fn specs() -> Vec<CommandSpec> { "Text Frame Options…", ["Object"], Some("Cmd+B"), - "{columns?, gutter?, inset?: number|[t,l,b,r] (null keeps that side), verticalJustification?: top|center|bottom|justify, firstBaseline?, autoSize?, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}", + "{columns?, gutter?, inset?: number|[t,l,b,r] (null keeps that side), verticalJustification?: top|center|bottom|justify, firstBaseline?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, autoSize?, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}", has_selection, text_frame_options ), diff --git a/crates/engine/src/cmd/place_text.rs b/crates/engine/src/cmd/place_text.rs index 97917ee3b..5bbb59ac1 100644 --- a/crates/engine/src/cmd/place_text.rs +++ b/crates/engine/src/cmd/place_text.rs @@ -124,19 +124,25 @@ fn fit(d: &Document, sid: StoryId) -> (Option<usize>, usize, usize) { } /// Thread new frames on new pages (margin rectangles) until the story fits. Returns pages added. -pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize) -> Result<usize> { +/// 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) -> Result<usize> { + let last = d.story(sid).and_then(|s| s.frames.last().copied()).ok_or_else(|| bad(cmd, "the story has no frame"))?; + if !matches!(d.find(last).map(|l| l.spread), Some(SpreadRef::Doc(_))) { + return Err(bad(cmd, "autoflow works only from a frame on a document page")); + } let mut added = 0; loop { let (overset, held, full) = fit(d, sid); let Some(at) = overset else { return Ok(added) }; if added >= max_pages { - return Err(bad("file.place", format!("autoflow stopped after {max_pages} pages"))); + return Err(bad(cmd, format!("autoflow stopped after {max_pages} pages"))); } let len = d.story(sid).map_or(0, |s| s.len()); // Frames still needed at the rate the full ones are filled (at least one); with no full // frame to go by yet, one more page. let want = held.checked_div(full).map_or(1, |per_frame| len.saturating_sub(at).div_ceil(per_frame.max(1))).clamp(1, max_pages - added); - let last = *d.story(sid).and_then(|s| s.frames.last()).ok_or_else(|| bad("file.place", "the story has no frame"))?; + let last = *d.story(sid).and_then(|s| s.frames.last()).ok_or_else(|| bad(cmd, "the story has no frame"))?; let mut page = d.page_of_item(last).unwrap_or(d.page_count().saturating_sub(1)); let parent = d.page_loc(page).and_then(|(si, pi)| d.spreads[si].pages[pi].parent); d.insert_pages(Some(page), want, parent)?; @@ -144,7 +150,7 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize) -> Resu let mut prev = last; for _ in 0..want { page += 1; - let (si, pi) = d.page_loc(page).ok_or_else(|| bad("file.place", "page insert failed"))?; + let (si, pi) = d.page_loc(page).ok_or_else(|| bad(cmd, "page insert failed"))?; let r = d.spreads[si].pages[pi].margin_rect(); let (fid, _) = d.add_text_frame(SpreadRef::Doc(si), r, layer, "", ParaFormat::default())?; d.thread(prev, fid)?; @@ -230,7 +236,7 @@ pub(super) fn place_text(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) - *sel = designcraft_doc::Selection::items(vec![fid]); sid }; - let pages = if autoflow_on { autoflow(d, sid, 2000)? } else { 0 }; + let pages = if autoflow_on { autoflow(d, sid, 2000, "file.place")? } else { 0 }; let overset = fit(d, sid).0.is_some(); Ok(json!({"story": sid.0, "pagesAdded": pages, "overset": overset, "warnings": warnings})) }) diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index c4d20f099..0280612d1 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -180,7 +180,7 @@ pub fn specs() -> Vec<CommandSpec> { "Apply Color", [], None, - "{color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b](0..255), target?: fill|stroke, text?: bool, ids?} — an unnamed colour on the selected text or objects; `text` also targets the text of selected frames", + "{color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b](0..255), target?: fill|stroke, text?: bool, ids?} — an unnamed colour (not in the Swatches panel until Add to Swatches) on the selection; `text`: on the selected text, or the text of the selected frames (Formatting Affects Text)", has_text_or_selection, apply_color ), @@ -638,10 +638,9 @@ fn create_char(s: &mut Session, p: &Value) -> Result<Value> { && let Some(target) = super::text::format_targets_pub(s).first() { let st = s.doc()?; - let story = st.doc.text_story(target.story, target.cell).ok_or(designcraft_doc::DocError::NoStory(target.story))?; // Match paragraph fromSelection's first-character/caret policy, but keep paragraph // attributes inherited instead of freezing the fully resolved character properties. - let format = if target.range.is_empty() { story.char_format_at(target.range.start) } else { story.format_after(target.range.start) }; + let format = super::text::effective_char_format(s, target)?; let mut captured = st.doc.styles.char_style_attrs(&format.style); captured.merge(&format.over); // Replay the explicit fields so null clears a captured value rather than looking absent. @@ -986,47 +985,30 @@ fn apply_color(s: &mut Session, p: &Value) -> Result<Value> { let named = p.get("ids").is_some() || p.get("id").is_some(); let text = !named && (p.get("text").and_then(Value::as_bool).unwrap_or(false) || s.doc()?.selection.text.is_some() || s.doc()?.selection.cells.is_some()); - if text { - let targets = super::text::format_targets(s); - // A caret in existing text has no editable range. Refuse it before creating a swatch - // or history; an empty story has a format run that the next insertion inherits. - if !targets - .iter() - .any(|t| !t.range.is_empty() || s.active().is_some_and(|d| d.doc.text_story(t.story, t.cell).is_some_and(|st| st.is_empty()))) - { - return Err(bad("object.color", "select the text to colour")); - } - return s.edit(|d, _| { - let name = color_swatch(d, color); - for t in &targets { - let Some(story) = d.text_story_mut(t.story, t.cell) else { continue }; - let set = |f: &mut designcraft_doc::CharFormat| { - if stroke { - (f.over.stroke, f.over.stroke_tint) = (Some(name.clone()), Some(1.0)); - } else { - (f.over.fill, f.over.fill_tint) = (Some(name.clone()), Some(1.0)); - } - }; - if t.range.is_empty() { - if story.is_empty() - && let Some(run) = story.chars.first_mut() - { - set(&mut run.format); - story.rev = story.rev.wrapping_add(1); - } - } else { - story.format_chars(t.range.clone(), set); - } - } - Ok(json!({"swatch": name})) - }); + let (ids, texts) = if text { (vec![], super::text::format_targets_pub(s)) } else { (super::targets(s, p)?, vec![]) }; + if text && texts.is_empty() { + return Err(bad("object.color", "select the text to colour")); } - let ids = super::targets(s, p)?; - s.edit(|d, _| { + // A caret: the colour is for the next typing (once the swatch exists). + let caret = matches!(texts.as_slice(), [t] if t.range.is_empty()); + let r = s.edit(|d, _| { let name = color_swatch(d, color); color_items(d, &ids, &name, stroke); + if !caret { + let a = if stroke { + CharAttrs { stroke: Some(name.clone()), stroke_tint: Some(1.0), ..Default::default() } + } else { + CharAttrs { fill: Some(name.clone()), fill_tint: Some(1.0), ..Default::default() } + }; + super::text::apply_char_attrs(d, &texts, &a, &[]); + } Ok(json!({"swatch": name})) - }) + })?; + if caret && let Some(name) = r.get("swatch").cloned() { + let attrs = if stroke { json!({"stroke": name, "strokeTint": 1.0}) } else { json!({"fill": name, "fillTint": 1.0}) }; + super::text::format_chars(s, &attrs)?; + } + Ok(r) } /// A process swatch holding exactly `color`, else a new unnamed one (hidden from the Swatches @@ -1153,7 +1135,7 @@ fn add_to_swatches(s: &mut Session, p: &Value) -> Result<Value> { } }; let rename = str_param(p, "name").map(str::to_string); - s.edit(|d, _| { + let result = s.edit(|d, _| { let w = d.swatches.iter_mut().find(|w| w.name == name).ok_or_else(|| bad("swatch.addToSwatches", format!("no swatch `{name}`")))?; if w.locked { return Err(bad("swatch.addToSwatches", "special swatches are already listed")); @@ -1180,8 +1162,84 @@ fn add_to_swatches(s: &mut Session, p: &Value) -> Result<Value> { rename_in(std::sync::Arc::make_mut(top), &name, &new); } } + for st in d.stories.values_mut() { + let mut copy = (**st).clone(); + if rename_in_story(&mut copy, &name, &new, 0)? { + *st = std::sync::Arc::new(copy); + } + } Ok(json!({"name": new})) - }) + })?; + if let Some(new) = result.get("name").and_then(Value::as_str) { + super::text::rename_typing_swatch(s, &name, new); + } + Ok(result) +} + +/// Point text colours (character fill, stroke, underline and strikethrough; paragraph shading, +/// border and rules; table cells, borders and alternating fills) at a renamed swatch, in a story +/// and the texts inside it (table cells, footnotes, endnotes). Returns whether anything changed. +fn rename_in_story(st: &mut designcraft_doc::Story, from: &str, to: &str, depth: usize) -> Result<bool> { + // Refuse the whole edit instead of leaving deeper references pointing at a removed name. + if depth > 32 { + return Err(bad("swatch.addToSwatches", "story nesting exceeds 32 levels")); + } + fn fix(s: &mut String, from: &str, to: &str) -> bool { + let hit = s == from; + if hit { + *s = to.to_string(); + } + hit + } + let mut n = 0; + let mut f = |s: &mut String| n += usize::from(fix(s, from, to)); + for r in &mut st.chars { + let o = &mut r.format.over; + for c in [&mut o.fill, &mut o.stroke, &mut o.underline_color, &mut o.strikethrough_color].into_iter().flatten() { + f(c); + } + } + for pf in &mut st.paras { + let a = &mut pf.para; + for c in [&mut a.shading_color, &mut a.border_color].into_iter().flatten() { + f(c); + } + for r in [&mut a.rule_above, &mut a.rule_below].into_iter().flatten() { + f(&mut r.color); + } + } + let mut nested = false; + for t in st.tables.values_mut() { + let t = std::sync::Arc::make_mut(t); + let o = &mut t.options; + f(&mut o.border.color); + for b in o.borders.iter_mut().flatten() { + f(&mut b.color); + } + for a in [&mut o.alt_rows, &mut o.alt_cols].into_iter().flatten() { + f(&mut a.first_color); + f(&mut a.next_color); + } + for k in [&mut o.row_strokes, &mut o.column_strokes].into_iter().flatten() { + f(&mut k.first_stroke.color); + f(&mut k.next_stroke.color); + } + for c in &mut t.cells { + f(&mut c.fill); + for k in &mut c.strokes { + f(&mut k.color); + } + nested |= rename_in_story(&mut c.text, from, to, depth + 1)?; + } + } + for note in st.notes.iter_mut().chain(st.endnotes.iter_mut()) { + nested |= rename_in_story(&mut std::sync::Arc::make_mut(note).text, from, to, depth + 1)?; + } + let changed = n > 0 || nested; + if changed { + st.rev = st.rev.wrapping_add(1); + } + Ok(changed) } /// Point fills and strokes of an item (and its children) at a renamed swatch. @@ -1495,6 +1553,40 @@ mod color_tests { s.execute("object.color", &json!({"color": {"c": 10, "m": 20, "y": 30, "k": 0}})).unwrap(); assert_eq!(s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch, "Sand"); } + + #[test] + fn add_to_swatches_renames_the_colour_in_text_cells_and_notes() { + use std::sync::Arc; + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Hello world"})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + s.execute("text.select", &json!({"story": sid.0, "anchor": 11})).unwrap(); + s.execute("footnote.insert", &json!({"text": "note"})).unwrap(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 1, "focus": 4})).unwrap(); + let old = s.execute("object.color", &json!({"color": {"c": 10, "m": 20, "y": 30, "k": 0}})).unwrap()["swatch"].as_str().unwrap().to_string(); + // The same unnamed colour on a cell, the cell's text and the footnote's text. + s.edit(|d, _| { + let st = d.story_mut(sid).unwrap(); + let t = Arc::make_mut(st.tables.values_mut().next().unwrap()); + t.cells[0].fill = old.clone(); + t.cells[0].text.chars[0].format.over.fill = Some(old.clone()); + Arc::make_mut(&mut st.notes[0]).text.chars[0].format.over.stroke = Some(old.clone()); + Ok(()) + }) + .unwrap(); + s.execute("swatch.addToSwatches", &json!({"swatch": old, "name": "Sand"})).unwrap(); + let d = &s.doc().unwrap().doc; + let st = d.story(sid).unwrap(); + assert_eq!(st.char_format_at(2).over.fill.as_deref(), Some("Sand")); + let cell = &st.tables.values().next().unwrap().cells[0]; + assert_eq!(cell.fill, "Sand"); + assert_eq!(cell.text.chars[0].format.over.fill.as_deref(), Some("Sand")); + assert_eq!(st.notes[0].text.chars[0].format.over.stroke.as_deref(), Some("Sand")); + d.check().unwrap(); + } } #[cfg(test)] @@ -1708,15 +1800,16 @@ mod sample_color_tests { } #[test] - fn nothing_to_sample_or_nothing_to_colour_is_refused_without_an_edit() { + fn nothing_to_sample_is_refused_without_an_edit() { let (mut s, _) = session_with_image(); let f = s.execute("frame.create", &json!({"rect": [300, 300, 500, 360], "content": "text", "text": "Hi"})).unwrap(); let fid = ItemId(f["id"].as_u64().unwrap()); let story = s.doc().unwrap().doc.item(fid).unwrap().text_frame().unwrap().story; s.execute("text.select", &json!({"story": story.0, "anchor": 1, "focus": 1})).unwrap(); + let sampled = s.execute("color.sample", &json!({"at": [100, 100]})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], sampled["swatch"]); + assert_eq!(s.doc().unwrap().doc.story(story).unwrap().text, "Hi", "caret color waits for typing"); let undo = s.doc().unwrap().history.undo.len(); - let caret = s.execute("color.sample", &json!({"at": [100, 100]})).unwrap_err().to_string(); - assert!(caret.contains("select the text"), "{caret}"); s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 2})).unwrap(); let none = s.execute("color.sample", &json!({"at": [600, 600]})).unwrap_err().to_string(); assert!(none.contains("no placed image"), "{none}"); @@ -1751,3 +1844,7 @@ mod sample_color_tests { } } } + +#[cfg(test)] +#[path = "text_color_tests.rs"] +mod text_color_tests; diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index b57e9e88a..e928ce472 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -22,22 +22,25 @@ pub fn specs() -> Vec<CommandSpec> { release ), cmd!(noundo "text.extendTo", "Extend Text Selection", [], None, "{frame, point}", has_doc, |s, p| place(s, p, true)), - cmd!(noundo "text.selectWord", "Select Word", [], None, "{frame, point}", has_doc, |s, p| { - place(s, p, false)?; - let st = s.doc_mut()?; - let Some(t) = st.selection.text else { return ok() }; - let text = &st.doc.text_story(t.story, t.cell).map(|x| x.text.clone()).unwrap_or_default(); - let (a, b) = word_bounds(text, t.focus); - st.selection.text = Some(TextSel { anchor: a, focus: b, ..t }); - ok() + cmd!(noundo "text.selectWord", "Select Word", [], None, "{frame, point} — the word (or run of spaces) under the point", has_doc, |s, p| { + select_at(s, p, "word") }), - cmd!(noundo "text.selectSentence", "Select Sentence", [], None, "{frame, point} — the sentence under the point (triple click)", has_doc, |s, p| { + cmd!( + noundo "text.selectAt", + "Select Text Unit", + [], + None, + "{frame, point, unit: word|line|paragraph|story} — what a double, triple, quadruple or quintuple click selects: the word (or run of spaces) under the point, its line, its paragraph with the paragraph return, or the whole story", + has_doc, + |s, p| select_at(s, p, str_param(p, "unit").unwrap_or("word")) + ), + cmd!(noundo "text.selectSentence", "Select Sentence", [], None, "{frame, point} — the sentence under the point", has_doc, |s, p| { select_around(s, p, "text.selectSentence", sentence_bounds) }), - cmd!(noundo "text.selectParagraph", "Select Paragraph", [], None, "{frame, point} — the paragraph under the point (quadruple click)", has_doc, |s, p| { + cmd!(noundo "text.selectParagraph", "Select Paragraph", [], None, "{frame, point} — the paragraph under the point, without its paragraph return", has_doc, |s, p| { select_around(s, p, "text.selectParagraph", para_bounds) }), - cmd!(noundo "text.selectStory", "Select Story", [], None, "{frame, point} — all the text of the story under the point, threaded frames included (quintuple click)", has_doc, |s, p| { + cmd!(noundo "text.selectStory", "Select Story", [], None, "{frame, point} — all the text of the story under the point, threaded frames included", has_doc, |s, p| { select_around(s, p, "text.selectStory", |t, _| (0, t.len())) }), cmd!(noundo "text.select", "Select Text", [], None, "{story, anchor, focus} (UTF-8 byte offsets into the story text, as find.find reports them: á or — counts 2 or 3)", has_doc, |s, p| { @@ -48,6 +51,7 @@ pub fn specs() -> Vec<CommandSpec> { let f = floor_char_boundary(text, p.get("focus").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(a)); st.selection = Selection::text(TextSel { story: sid, anchor: a, focus: f, frame: None, cell: None }); st.revision += 1; + s.typing_format = None; ok() }), cmd!("text.insert", "Type", [], None, "{text, raw?: bool (no typographer's quotes)} — replaces the selected text", has_text, insert), @@ -144,7 +148,7 @@ pub fn specs() -> Vec<CommandSpec> { "Paragraph Formatting", [], None, - "{attrs: {align?, leftIndent?, firstLineIndent?, spaceBefore?, spaceAfter?, dropCapLines?, hyphenate?, composer?, tabs?, ruleAbove?/ruleBelow?: {on?, weight?, color?, tint?, columnWidth?, offset?, leftIndent?, rightIndent?} (only the named rule fields change), …}}", + "{attrs: {align?, leftIndent?, firstLineIndent?, spaceBefore?, spaceAfter?, dropCapLines?, dropCapChars?, dropCapStyle?, dropCapAlignLeft?, dropCapScaleDescenders?, hyphenate?, composer?, tabs?, ruleAbove?/ruleBelow?: {on?, weight?, color?, tint?, columnWidth?, offset?, leftIndent?, rightIndent?} (only the named rule fields change), …}}", has_text_or_frames, |s, p| format_paras(s, p.get("attrs").unwrap_or(p)) ), @@ -161,8 +165,33 @@ pub fn specs() -> Vec<CommandSpec> { s, &json!({"align": "leftJustified"}) )), - cmd!("type.bold", "Bold", [], Some("Cmd+Shift+B"), "{}", has_text_or_frames, |s, _| format_chars(s, &json!({"fontStyle": "Bold"}))), - cmd!("type.italic", "Italic", [], Some("Cmd+Shift+I"), "{}", has_text_or_frames, |s, _| format_chars(s, &json!({"fontStyle": "Italic"}))), + cmd!( + "type.bold", + "Bold", + [], + Some("Cmd+Shift+B"), + "{on?: bool} — the family's bold style, keeping italic (style linking); toggles when `on` is left out", + has_text_or_frames, + |s, p| link_style(s, p, true) + ), + cmd!( + "type.italic", + "Italic", + [], + Some("Cmd+Shift+I"), + "{on?: bool} — the family's italic style, keeping the weight (style linking); toggles when `on` is left out", + has_text_or_frames, + |s, p| link_style(s, p, false) + ), + cmd!( + "type.swapFillStroke", + "Swap Text Fill and Stroke", + [], + None, + "{} — swap each selected character's resolved fill/stroke and tints; at a caret, swap the next typing's colors", + has_text_or_frames, + swap_fill_stroke + ), cmd!("type.underline", "Underline", [], Some("Cmd+Shift+U"), "{}", has_text_or_frames, |s, _| format_chars(s, &json!({"underline": true}))), cmd!("type.allCaps", "All Caps", [], Some("Cmd+Shift+K"), "{}", has_text_or_frames, |s, _| format_chars( s, @@ -280,6 +309,17 @@ pub(crate) fn story_of(s: &Session, p: &Value) -> Option<StoryId> { /// Story byte at spread point `pt` in text frame `frame` (in a table cell's story when the point is in a cell). fn hit_byte(s: &Session, frame: ItemId, pt: Point) -> Option<(StoryId, usize, Option<CellAddr>)> { + hit_byte_with(s, frame, pt, compose::hit) +} + +/// [`hit_byte`] with the hit test: [`compose::hit`] (caret position) or [`compose::hit_char`] +/// (the character under the point). +fn hit_byte_with( + s: &Session, + frame: ItemId, + pt: Point, + hit: fn(&compose::ComposedStory, usize, Point) -> Option<usize>, +) -> Option<(StoryId, usize, Option<CellAddr>)> { let st = s.active()?; let loc = st.doc.find(frame)?; let it = st.doc.item_at(&loc)?; @@ -288,18 +328,94 @@ fn hit_byte(s: &Session, frame: ItemId, pt: Point) -> Option<(StoryId, usize, Op let inner = xf.inverse() * pt; let cs = s.cache.get(&st.doc, sid, None); let fi = cs.frames.iter().position(|f| f.frame == frame)?; - // Type on a path: the click lands on the line where it is along the path. + // Map path-text hits along the curve before choosing the character unit. let inner = cs.frames.get(fi).and_then(|ft| compose::PathTextMap::new(it, ft)).map_or(inner, |m| m.to_line(inner)); - if let Some((table, row, col, b)) = compose::hit_cell(&cs, fi, inner) { + if let Some((table, row, col, b)) = compose::hit_cell_with(&cs, fi, inner, hit) { return Some((sid, b, Some(CellAddr { table, row, col }))); } - if let Some((id, b)) = compose::hit_note(&cs, fi, inner) { + if let Some((id, b)) = compose::hit_note_with(&cs, fi, inner, hit) { return Some((sid, b, Some(CellAddr::footnote(id)))); } - let b = compose::hit(&cs, fi, inner).unwrap_or(0); + let b = hit(&cs, fi, inner).unwrap_or(0); Some((sid, b, None)) } +/// Select the word, line, paragraph or story at `point` in `frame` (multiple clicks). +fn select_at(s: &mut Session, p: &Value, unit: &str) -> Result<Value> { + const ID: &str = "text.selectAt"; + let frame = id_param(p, "frame").ok_or_else(|| bad(ID, "missing frame"))?; + let pt = point_param(p, "point").ok_or_else(|| bad(ID, "missing point"))?; + let (sid, b, cell) = hit_byte_with(s, frame, pt, compose::hit_char).ok_or_else(|| bad(ID, "not a text frame"))?; + let st = s.doc()?; + let story = st.doc.text_story(sid, cell).ok_or(designcraft_doc::DocError::NoStory(sid))?; + let text = story.text.as_str(); + let b = floor_char_boundary(text, b); + let (a, e) = match unit { + "word" => word_or_spaces_at(text, b), + "line" => { + let cs = s.cache.get(&st.doc, sid, None); + line_at(&text_composition(cs, cell), b).unwrap_or((b, b)) + } + // The paragraph with its return (the last paragraph has none). + "paragraph" => story.para_ranges().get(story.para_at(b)).map_or((b, b), |r| (r.start, (r.end + 1).min(text.len()))), + "story" => (0, text.len()), + other => return Err(bad(ID, format!("unknown unit `{other}` (word, line, paragraph or story)"))), + }; + s.text_drag = false; + s.typing_format = None; + let st = s.doc_mut()?; + st.selection = Selection::text(TextSel { story: sid, anchor: a, focus: e, frame: Some(frame), cell }); + st.revision += 1; + Ok(json!({"story": sid.0, "anchor": a, "focus": e})) +} + +/// The word at byte `i`; on spaces the run of spaces; on anything else that one character +/// (grapheme cluster). At the end of a paragraph, what ends it. Grapheme clusters are never split: +/// combining marks, viramas, variation selectors and emoji ZWJ sequences stay with their base. +fn word_or_spaces_at(s: &str, i: usize) -> (usize, usize) { + use unicode_segmentation::UnicodeSegmentation; + let i = floor_char_boundary(s, i.min(s.len())); + // Clusters never span a paragraph return, so the paragraph is enough. + let ps = s[..i].rfind('\n').map_or(0, |k| k + 1); + let pe = s[i..].find('\n').map_or(s.len(), |k| i + k); + let g: Vec<(usize, &str)> = s[ps..pe].grapheme_indices(true).collect(); + let Some(k) = g.iter().rposition(|(o, _)| ps + o <= i) else { return (i, i) }; + let kind = |c: &str| match c.chars().next() { + Some(c) if is_word(c) => 1, + Some(c) if c.is_whitespace() => 2, + _ => 0, + }; + let span = |a: usize, e: usize| g.get(a).zip(g.get(e)).map_or((i, i), |((oa, _), (oe, ge))| (ps + oa, ps + oe + ge.len())); + let kd = g.get(k).map_or(0, |(_, c)| kind(c)); + if kd == 0 { + return span(k, k); + } + let mut a = k; + while a > 0 && g.get(a - 1).is_some_and(|(_, c)| kind(c) == kd) { + a -= 1; + } + let mut e = k; + while g.get(e + 1).is_some_and(|(_, c)| kind(c) == kd) { + e += 1; + } + span(a, e) +} + +/// The composition whose lines hold a story's text: the story's own, a table cell's or a footnote's. +fn text_composition(cs: std::sync::Arc<compose::ComposedStory>, cell: Option<CellAddr>) -> std::sync::Arc<compose::ComposedStory> { + match cell { + Some(c) if c.footnote_id().is_some() => compose::find_note(&cs, c.row as u64).map(|(_, n)| n.text.clone()).unwrap_or(cs), + Some(c) => compose::find_cell(&cs, c.table, c.row, c.col).map(|(_, _, pc)| pc.text.clone()).unwrap_or(cs), + None => cs, + } +} + +/// The composed line holding the character at byte `pos`. +fn line_at(cs: &compose::ComposedStory, pos: usize) -> Option<(usize, usize)> { + let lines = || cs.frames.iter().flat_map(|f| f.lines.iter()); + lines().find(|l| l.range.contains(&pos)).map(|l| (l.range.start, l.range.end)).or_else(|| line_of(cs, pos)) +} + fn place(s: &mut Session, p: &Value, extend: bool) -> Result<Value> { let frame = id_param(p, "frame").ok_or_else(|| bad("text.placeCaret", "missing frame"))?; let pt = point_param(p, "point").unwrap_or(Point::ZERO); @@ -318,6 +434,7 @@ fn place(s: &mut Session, p: &Value, extend: bool) -> Result<Value> { } else if s.text_drag { return Ok(json!({"story": sid.0, "dragging": true})); } + s.typing_format = None; let st = s.doc_mut()?; // Dragging from one cell into another selects cells. if extend @@ -385,7 +502,8 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> { let text = if s.prefs.typographers_quotes && !raw { smart_quotes(s, &text) } else { text }; let tracking = s.doc()?.doc.settings.track_changes; let autocorrect = (s.prefs.autocorrect && !tracking && !raw).then(|| s.prefs.autocorrect_list.clone()); - s.edit(|d, sel| { + let typing = typing_format(s).cloned(); + let result = s.edit(|d, sel| { let t = sel.text.ok_or_else(|| bad("text.insert", "no insertion point"))?; let st = d.text_story_mut(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?; let r = t.range(); @@ -445,6 +563,9 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> { // Track Changes: the replaced text is marked deleted, the typing inserted after it. let at = super::changes::mark_deleted(st, r.clone()); let mut fmt = st.char_format_at(at).clone(); + if let Some(f) = &typing { + apply_typing_format(&mut fmt, f); + } fmt.over.change = Some(designcraft_doc::ChangeMark::Inserted); st.insert_with(at, &text, fmt); let pos = at + text.len() - trail; @@ -452,10 +573,19 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> { return Ok(json!({"pos": pos})); } st.replace(r.clone(), &text); + if let Some(f) = &typing + && !text.is_empty() + { + st.format_chars(r.start..r.start + text.len(), |fmt| apply_typing_format(fmt, f)); + } let pos = r.start + text.len() - trail; sel.text = Some(TextSel { anchor: pos, focus: pos, ..t }); Ok(json!({"pos": pos})) - }) + }); + if result.is_ok() { + s.typing_format = None; + } + result } fn smart_quotes(s: &Session, text: &str) -> String { @@ -632,12 +762,6 @@ pub fn prev_word(s: &str, i: usize) -> usize { } 0 } -fn word_bounds(s: &str, i: usize) -> (usize, usize) { - let a = s[..i.min(s.len())].char_indices().rev().take_while(|(_, c)| is_word(*c)).last().map(|(k, _)| k).unwrap_or(i); - let b = s[i.min(s.len())..].char_indices().find(|(_, c)| !is_word(*c)).map(|(k, _)| i + k).unwrap_or(s.len()); - (a, b) -} - /// Selects `bounds(text, byte)` around the byte under `point` in `frame`. Unlike a caret /// placement, a press inside the current selection is no start of a text drag here: it is the /// third or later click of a row, which lands in the word or sentence just selected. @@ -732,13 +856,8 @@ fn move_caret(s: &mut Session, p: &Value) -> Result<Value> { let t = st.selection.text.ok_or_else(|| bad("text.move", "no caret"))?; let story = st.doc.text_story(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?; let text = story.text.clone(); - let cs = s.cache.get(&st.doc, t.story, None); // In a cell, lines come from the cell's own composition. - let cs = match t.cell { - Some(c) if c.footnote_id().is_some() => compose::find_note(&cs, c.row as u64).map(|(_, n)| n.text.clone()).unwrap_or(cs), - Some(c) => compose::find_cell(&cs, c.table, c.row, c.col).map(|(_, _, pc)| pc.text.clone()).unwrap_or(cs), - None => cs, - }; + let cs = text_composition(s.cache.get(&st.doc, t.story, None), t.cell); let pos = t.focus.min(text.len()); // Vertical lines run top to bottom and follow each other right to left: Down and Up move along // the line (Right and Left of horizontal text), Left and Right to the next and previous line. @@ -779,6 +898,7 @@ fn move_caret(s: &mut Session, p: &Value) -> Result<Value> { "storyEnd" => text.len(), other => return Err(bad("text.move", format!("unknown dir `{other}`"))), }; + s.typing_format = None; let stm = s.doc_mut()?; stm.selection.text = Some(if extend { TextSel { focus: new, ..t } } else { TextSel { anchor: new, focus: new, ..t } }); stm.revision += 1; @@ -849,6 +969,23 @@ pub(crate) fn format_targets(s: &Session) -> Vec<Target> { v } +/// Character formatting chosen at a bare caret in text: applied to what is typed there next. +#[derive(Clone, Debug)] +pub(crate) struct TypingFormat { + doc: u64, + at: TextSel, + attrs: CharAttrs, + /// Overrides removed (`null` in `type.char`). + cleared: Vec<String>, +} + +/// The typing format for the active document's caret, if it was chosen at this caret. +fn typing_format(s: &Session) -> Option<&TypingFormat> { + let st = s.active()?; + let t = st.selection.text?; + s.typing_format.as_ref().filter(|f| f.doc == st.uid && f.at.story == t.story && f.at.cell == t.cell && f.at.range() == t.range()) +} + fn warichu_cmd(s: &mut Session, p: &Value) -> Result<Value> { let has_opts = ["lines", "size", "lineSpacing", "align", "charsBeforeBreak", "charsAfterBreak"].iter().any(|k| p.get(k).is_some()); let on = match p.get("on").and_then(Value::as_bool) { @@ -908,32 +1045,50 @@ pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result<Value> { validate_char_fill(&s.doc()?.doc, &a, "type.char")?; let cleared: Vec<String> = attrs.as_object().map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect()).unwrap_or_default(); let targets = format_targets(s); + // A caret in text: the format is for the next typing. + if let [t] = targets.as_slice() + && t.range.is_empty() + && let Some(at) = s.doc()?.selection.text + && s.doc()?.doc.text_story(t.story, t.cell).is_some_and(|st| !st.is_empty()) + { + let doc = s.doc()?.uid; + let mut f = typing_format(s).cloned().unwrap_or(TypingFormat { doc, at, attrs: CharAttrs::default(), cleared: vec![] }); + f.attrs.merge(&a); + f.cleared.retain(|k| attrs.get(k).is_none_or(|v| v.is_null()) && !cleared.contains(k)); + f.cleared.extend(cleared); + s.typing_format = Some(f); + return ok(); + } s.edit(|d, _| { - for t in &targets { - let r = &t.range; - let Some(st) = d.text_story_mut(t.story, t.cell) else { continue }; - if r.is_empty() { - // Caret: change the typing format (the empty run at the caret). - let pos = r.start; - if st.is_empty() { - st.chars[0].format.over.merge(&a); - st.rev += 1; - } else { - let _ = pos; - } - continue; - } - st.format_chars(r.clone(), |f| { - f.over.merge(&a); - for k in &cleared { - let _ = f.over.set_json(k, &Value::Null); - } - }); - } + apply_char_attrs(d, &targets, &a, &cleared); ok() }) } +/// Character attributes `a` (and the overrides `cleared` removed) over each target's text. +pub(crate) fn apply_char_attrs(d: &mut designcraft_doc::Document, targets: &[Target], a: &CharAttrs, cleared: &[String]) { + for t in targets { + let r = &t.range; + let Some(st) = d.text_story_mut(t.story, t.cell) else { continue }; + if r.is_empty() { + // An empty story: its one (empty) run is the format typing takes. + if st.is_empty() + && let Some(run) = st.chars.first_mut() + { + run.format.over.merge(a); + st.rev += 1; + } + continue; + } + st.format_chars(r.clone(), |f| { + f.over.merge(a); + for k in cleared { + let _ = f.over.set_json(k, &Value::Null); + } + }); + } +} + pub(crate) fn format_paras(s: &mut Session, attrs: &Value) -> Result<Value> { let empty = serde_json::Map::new(); let obj = attrs.as_object().unwrap_or(&empty); @@ -1056,14 +1211,178 @@ fn selection_attrs(s: &mut Session, _p: &Value) -> Result<Value> { let pi = story.para_at(r.start); let pf = &story.paras[pi]; let (pp, base) = st.doc.styles.resolve_para(pf); - let cf = if r.is_empty() { story.char_format_at(r.start) } else { story.format_after(r.start) }; - let cp = st.doc.styles.resolve_char(&base, cf); - // The selected text (its start, on one line), for previews such as the font menu's. + let cf = effective_char_format(s, &Target { story: sid, cell, range: r.clone() })?; + let cp = st.doc.styles.resolve_char(&base, &cf); let selected = selected_text_preview(story.text.get(r.start..r.end).unwrap_or_default().chars()); Ok(json!({"story": sid.0, "paragraphStyle": pf.style, "characterStyle": cf.style, "para": pp, "chars": cp, "paraOverrides": pf.para.count(), "charOverrides": cf.over.count(), "selectedText": selected.trim()})) } +/// The first target character, with any pending caret overrides. Keep character-style +/// inheritance separate from paragraph defaults for style creation from the selection. +pub(crate) fn effective_char_format(s: &Session, target: &Target) -> Result<designcraft_doc::CharFormat> { + let story = s.doc()?.doc.text_story(target.story, target.cell).ok_or(designcraft_doc::DocError::NoStory(target.story))?; + let mut format = if target.range.is_empty() { story.char_format_at(target.range.start) } else { story.format_after(target.range.start) }.clone(); + if let Some(pending) = typing_format(s) + && target.story == pending.at.story + && target.cell == pending.at.cell + && target.range == pending.at.range() + { + apply_typing_format(&mut format, pending); + } + Ok(format) +} + +/// A swatch promoted to a named color keeps any not-yet-inserted text pointing at it. +pub(crate) fn rename_typing_swatch(s: &mut Session, from: &str, to: &str) { + let Some(uid) = s.active().map(|st| st.uid) else { return }; + if let Some(pending) = &mut s.typing_format + && pending.doc == uid + { + for name in [&mut pending.attrs.fill, &mut pending.attrs.stroke, &mut pending.attrs.underline_color, &mut pending.attrs.strikethrough_color] + .into_iter() + .flatten() + { + if name == from { + *name = to.to_string(); + } + } + } +} + +fn swapped_colors(cp: &designcraft_doc::CharProps) -> CharAttrs { + CharAttrs { + fill: Some(cp.stroke.clone()), + fill_tint: Some(cp.stroke_tint), + stroke: Some(cp.fill.clone()), + stroke_tint: Some(cp.fill_tint), + ..Default::default() + } +} + +/// Resolve each paragraph/run before editing: a mixed selection must not take the first run's colors. +fn swap_fill_stroke(s: &mut Session, _p: &Value) -> Result<Value> { + const ID: &str = "type.swapFillStroke"; + let targets = format_targets(s); + if let [target] = targets.as_slice() + && target.range.is_empty() + && s.doc()?.selection.text.is_some() + { + let st = s.doc()?; + let story = st.doc.text_story(target.story, target.cell).ok_or(designcraft_doc::DocError::NoStory(target.story))?; + let pf = story.paras.get(story.para_at(target.range.start)).ok_or_else(|| bad(ID, "missing paragraph"))?; + let (_, base) = st.doc.styles.resolve_para(pf); + let cp = st.doc.styles.resolve_char(&base, &effective_char_format(s, target)?); + return format_chars(s, &json!({"fill": cp.stroke, "fillTint": cp.stroke_tint, "stroke": cp.fill, "strokeTint": cp.fill_tint})); + } + let mut edits = Vec::new(); + let d = &s.doc()?.doc; + for target in targets { + let story = d.text_story(target.story, target.cell).ok_or(designcraft_doc::DocError::NoStory(target.story))?; + if story.is_empty() { + let pf = story.paras.first().ok_or_else(|| bad(ID, "missing paragraph"))?; + let (_, base) = d.styles.resolve_para(pf); + let attrs = swapped_colors(&d.styles.resolve_char(&base, story.char_format_at(0))); + validate_char_fill(d, &attrs, ID)?; + edits.push((target, attrs)); + continue; + } + let mut runs = story.runs().peekable(); + for (pi, para) in story.para_ranges().into_iter().enumerate() { + let start = para.start.max(target.range.start); + let end = para.end.saturating_add(1).min(story.len()).min(target.range.end); + if start >= end { + continue; + } + let pf = story.paras.get(pi).ok_or_else(|| bad(ID, "missing paragraph"))?; + let (_, base) = d.styles.resolve_para(pf); + while let Some((range, format)) = runs.peek() { + if range.end <= start { + runs.next(); + continue; + } + if range.start >= end { + break; + } + let attrs = swapped_colors(&d.styles.resolve_char(&base, format)); + validate_char_fill(d, &attrs, ID)?; + edits.push((Target { range: start.max(range.start)..end.min(range.end), ..target.clone() }, attrs)); + if range.end > end { + break; + } + runs.next(); + } + } + } + s.edit(|d, _| { + for (target, attrs) in &edits { + apply_char_attrs(d, std::slice::from_ref(target), attrs, &[]); + } + ok() + }) +} + +fn apply_typing_format(cf: &mut designcraft_doc::CharFormat, f: &TypingFormat) { + cf.over.merge(&f.attrs); + for k in &f.cleared { + let _ = cf.over.set_json(k, &Value::Null); + } +} + +/// Bold (`bold`) or italic from the font family (style linking), per run of the selection: on or +/// off as asked, else the opposite of the first selected character. +fn link_style(s: &mut Session, p: &Value, bold: bool) -> Result<Value> { + let id = if bold { "type.bold" } else { "type.italic" }; + let cur = selection_attrs(s, &json!({}))?; + let family = cur["chars"]["fontFamily"].as_str().unwrap_or_default().to_string(); + let style = cur["chars"]["fontStyle"].as_str().unwrap_or_default().to_string(); + // The document's own fonts count too. + let db = designcraft_fonts::FontDb::global().scoped(s.doc()?.doc.font_scope); + let (weight, italic) = db.traits_of(&family, &style); + let on = p.get("on").and_then(Value::as_bool).unwrap_or(if bold { weight < 650.0 } else { !italic }); + let (want_bold, want_italic) = if bold { (Some(on), None) } else { (None, Some(on)) }; + let targets = format_targets(s); + // A caret: the linked style of the format there, for the next typing. + if let [t] = targets.as_slice() + && t.range.is_empty() + { + let linked = db.linked_style(&family, &style, want_bold, want_italic).ok_or_else(|| bad(id, format!("{family} has no style for that")))?; + format_chars(s, &json!({"fontStyle": linked}))?; + return Ok(json!({"on": on, "fontStyle": linked})); + } + let mut changed = 0; + let r = s.edit(|d, _| { + let styles = d.styles.clone(); + for t in &targets { + let Some(st) = d.text_story_mut(t.story, t.cell) else { continue }; + for (pi, pr) in st.para_ranges().into_iter().enumerate() { + let (a, b) = (pr.start.max(t.range.start), pr.end.min(t.range.end)); + if a >= b { + continue; + } + let Some(pf) = st.paras.get(pi) else { continue }; + let (_, pc) = styles.resolve_para(pf); + st.format_chars(a..b, |f| { + let cp = styles.resolve_char(&pc, f); + if let Some(linked) = db.linked_style(&cp.font_family, &cp.font_style, want_bold, want_italic) + && linked != cp.font_style + { + f.over.font_style = Some(linked); + changed += 1; + } + }); + } + } + // No undo step for nothing. + if changed == 0 { + return Err(bad(id, "nothing to change")); + } + ok() + }); + r?; + Ok(json!({"on": on, "changed": changed})) +} + /// Our own filler text (not Adobe's). pub const PLACEHOLDER: &str = "Ovid ellum quisque arcet velut tempora sint, ne veriora pareant laudemque fieri. Lorem novum strata ponet amicos, \ et tamen uti ferrum caelo tempus mollit. Quisque sinat oppida retro, nec tenuere longas animi semper vias. \ @@ -1363,6 +1682,202 @@ mod nested_style_tests { } } +#[cfg(test)] +mod multi_click_tests { + use serde_json::{Value, json}; + + use crate::Session; + + #[test] + fn moving_the_caret_drops_the_pending_typing_format() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": "Hello world"})).unwrap(); + let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap()); + let size_at = |s: &Session, b: usize| s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().char_format_at(b).over.size; + let pending = |s: &mut Session| { + s.execute("text.select", &json!({"story": sid, "anchor": 5})).unwrap(); + s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap(); + }; + // Away and back by arrow keys, by a click, by selecting and by double-clicking: typing at the + // same caret is in the text's own format. + let moves: Vec<Box<dyn Fn(&mut Session)>> = vec![ + Box::new(|s| { + s.execute("text.move", &json!({"dir": "right"})).unwrap(); + s.execute("text.move", &json!({"dir": "left"})).unwrap(); + }), + Box::new(|s| { + s.execute("text.placeCaret", &json!({"frame": fid, "point": [80, 80]})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 5})).unwrap(); + }), + Box::new(|s| { + s.execute("text.selectAt", &json!({"frame": fid, "point": [80, 80], "unit": "story"})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 5})).unwrap(); + }), + ]; + for m in &moves { + pending(&mut s); + m(&mut s); + s.execute("text.insert", &json!({"text": "x"})).unwrap(); + assert_eq!(size_at(&s, 6), None); + s.execute("edit.undo", &json!({})).unwrap(); + } + // Without a move it applies. + pending(&mut s); + s.execute("text.insert", &json!({"text": "x"})).unwrap(); + assert_eq!(size_at(&s, 6), Some(30.0)); + } + + #[test] + fn word_selection_keeps_grapheme_clusters_whole() { + use super::word_or_spaces_at as word; + // Decomposed "café": the combining acute belongs to the word, wherever the click lands. + let s = "un cafe\u{301} noir"; + let cafe = (3, 3 + "cafe\u{301}".len()); + assert_eq!(word(s, 4), cafe); + assert_eq!(word(s, 7), cafe, "on the combining mark"); + // Devanagari with a virama (U+094D) and a dependent vowel sign. + let s = "\u{928}\u{92E}\u{938}\u{94D}\u{924}\u{947} x"; + let w = "\u{928}\u{92E}\u{938}\u{94D}\u{924}\u{947}".len(); + assert_eq!(word(s, 0), (0, w)); + assert_eq!(word(s, 9), (0, w), "on the virama"); + // An emoji ZWJ sequence and a variation selector stay one character. + let s = "a \u{1F469}\u{200D}\u{1F4BB} b \u{263A}\u{FE0F}"; + let e = (2, 2 + "\u{1F469}\u{200D}\u{1F4BB}".len()); + assert_eq!(word(s, 2), e); + assert_eq!(word(s, 6), e, "on the joiner"); + assert_eq!(word(s, s.len() - 1), (s.len() - 6, s.len()), "at the end: the last character whole"); + // Empty paragraphs select nothing. + assert_eq!(word("a\n\nb", 2), (2, 2)); + } + + #[test] + fn clicks_select_words_spaces_lines_paragraphs_and_the_story() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let text = "Hello brave world.\nSecond paragraph here."; + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": text})).unwrap(); + let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap()); + // Spread point over the right half of the glyph at byte `b`. + let at = |s: &Session, b: usize| { + let d = &s.doc().unwrap().doc; + let cs = designcraft_compose::compose_story(d, designcraft_doc::StoryId(sid), &Default::default()); + let (l, g) = cs.frames[0].lines.iter().find_map(|l| l.glyphs.iter().find(|g| g.byte == b && g.len > 0).map(|g| (l, g))).unwrap(); + json!([g.x + g.adv * 0.75, l.baseline - 3.0]) + }; + let mut select = |unit: &str, b: usize| { + let p = at(&s, b); + let r: Value = s.execute("text.selectAt", &json!({"frame": fid, "point": p, "unit": unit})).unwrap(); + (r["anchor"].as_u64().unwrap() as usize, r["focus"].as_u64().unwrap() as usize) + }; + assert_eq!(select("word", 1), (0, 5), "inside a word"); + assert_eq!(select("word", 4), (0, 5), "its last letter"); + assert_eq!(select("word", 5), (5, 7), "the run of spaces after it"); + assert_eq!(select("word", 18), (18, 19), "punctuation alone"); + assert_eq!(select("line", 9), (0, 19)); + assert_eq!(select("paragraph", 9), (0, 20), "with its return"); + assert_eq!(select("paragraph", 22), (20, text.len())); + assert_eq!(select("story", 9), (0, text.len())); + // A second click inside the selected word selects again rather than starting a text drag. + s.execute("text.select", &json!({"story": sid, "anchor": 7, "focus": 12})).unwrap(); + let p = at(&s, 9); + s.execute("text.selectAt", &json!({"frame": fid, "point": p, "unit": "line"})).unwrap(); + let t = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((t.anchor, t.focus), (0, 19)); + s.execute("text.release", &json!({"frame": fid, "point": p, "moved": false})).unwrap(); + let t = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((t.anchor, t.focus), (0, 19), "releasing keeps it"); + // The older word command agrees. + let p = at(&s, 8); + s.execute("text.selectWord", &json!({"frame": fid, "point": p})).unwrap(); + let t = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((t.anchor, t.focus), (7, 12)); + assert!(s.execute("text.selectAt", &json!({"frame": fid, "point": p, "unit": "sentence"})).is_err()); + assert!(s.execute("text.selectAt", &json!({"frame": fid, "unit": "word"})).is_err(), "no point"); + } +} + +#[cfg(test)] +mod style_link_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn bold_and_italic_follow_the_family_and_toggle() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": "plain words here"})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + let style_at = |s: &mut Session, b: usize| { + s.execute("text.select", &json!({"story": sid, "anchor": b, "focus": b + 1})).unwrap(); + s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fontStyle"].as_str().unwrap().to_string() + }; + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 5})).unwrap(); + s.execute("type.italic", &json!({})).unwrap(); + assert_eq!(style_at(&mut s, 0), "Italic"); + // Bold over italic and plain text: each run takes its own linked style. + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 11})).unwrap(); + s.execute("type.bold", &json!({})).unwrap(); + assert_eq!(style_at(&mut s, 0), "Bold Italic"); + assert_eq!(style_at(&mut s, 7), "Bold"); + // Again: off, back to each run's regular weight. + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 11})).unwrap(); + s.execute("type.bold", &json!({})).unwrap(); + assert_eq!(style_at(&mut s, 0), "Italic"); + assert_eq!(style_at(&mut s, 7), "Regular"); + // Nothing to change: an error and no undo step. + s.execute("text.select", &json!({"story": sid, "anchor": 6, "focus": 11})).unwrap(); + let steps = s.doc().unwrap().history.undo.len(); + assert!(s.execute("type.bold", &json!({"on": false})).is_err()); + assert_eq!(s.doc().unwrap().history.undo.len(), steps); + // At a caret the style is for what is typed next. + let end = "plain words here".len(); + s.execute("text.select", &json!({"story": sid, "anchor": end, "focus": end})).unwrap(); + s.execute("type.bold", &json!({})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fontStyle"], "Bold"); + s.execute("text.insert", &json!({"text": " now"})).unwrap(); + assert_eq!(style_at(&mut s, end + 1), "Bold"); + assert_eq!(style_at(&mut s, end - 1), "Regular", "text before the caret is unchanged"); + // Moving the caret forgets it. + s.execute("text.select", &json!({"story": sid, "anchor": 2, "focus": 2})).unwrap(); + s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 3, "focus": 3})).unwrap(); + s.execute("text.insert", &json!({"text": "x"})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 3, "focus": 4})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"], 12.0); + } +} + +#[cfg(test)] +mod text_color_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn unnamed_colours_go_to_text_when_asked() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "colour me", "caret": false})).unwrap(); + let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap()); + s.execute("selection.set", &json!({"ids": [fid]})).unwrap(); + let fill_of_frame = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(fid)).unwrap().fill.swatch.clone(); + let before = fill_of_frame(&s); + let r = s.execute("object.color", &json!({"color": "#ff0000", "text": true})).unwrap(); + let red = r["swatch"].as_str().unwrap().to_string(); + assert_eq!(fill_of_frame(&s), before, "the frame keeps its fill"); + let story = s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().clone(); + assert_eq!(story.char_format_at(3).over.fill.as_deref(), Some(red.as_str())); + // A text selection takes it without asking. + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 3})).unwrap(); + let r = s.execute("object.color", &json!({"color": "#00ff00", "target": "stroke"})).unwrap(); + let story = s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().clone(); + assert_eq!(story.format_after(0).over.stroke.as_deref(), r["swatch"].as_str()); + assert_eq!(s.doc().unwrap().history.undo.len(), 3, "the frame, then one undo step per colour"); + } +} + #[cfg(test)] mod drag_text_tests { use serde_json::json; @@ -2009,7 +2524,7 @@ mod story_query_contract_tests { } #[cfg(test)] -mod multi_click_tests { +mod sentence_selection_tests { use super::{para_bounds, sentence_bounds}; fn sentence(s: &str, at: &str) -> String { diff --git a/crates/engine/src/cmd/text_color_tests.rs b/crates/engine/src/cmd/text_color_tests.rs new file mode 100644 index 000000000..5f2c35765 --- /dev/null +++ b/crates/engine/src/cmd/text_color_tests.rs @@ -0,0 +1,181 @@ +//! Cross-command caret color and character-style regressions. +use crate::Session; +use designcraft_doc::StoryId; +use serde_json::{Value, json}; + +fn setup(text: &str) -> (Session, u64) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let f = s.execute("frame.create", &json!({"rect": [36,36,300,200], "content":"text", "text":text})).unwrap(); + let sid = f["story"].as_u64().unwrap(); + s.execute("text.select", &json!({"story":sid,"anchor":text.len()})).unwrap(); + (s, sid) +} +fn attrs(s: &mut Session) -> Value { + s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"].clone() +} +#[test] +fn integration_color_character_style_captures_pending_and_null() { + let (mut s, sid) = setup("hello"); + s.execute("type.char", &json!({"size":30,"tracking":40,"fill":"[Paper]"})).unwrap(); + s.execute("style.character.create", &json!({"name":"Pending","fromSelection":true,"chars":{"tracking":null}})).unwrap(); + let a = &s.doc().unwrap().doc.styles.char_style("Pending").unwrap().chars; + assert_eq!(a.size, Some(30.0)); + assert_eq!(a.fill.as_deref(), Some("[Paper]")); + assert_eq!(a.tracking, None); + assert_eq!(s.doc().unwrap().doc.story(StoryId(sid)).unwrap().text, "hello"); +} +#[test] +fn integration_color_pending_follows_promoted_swatch() { + for target in ["fill", "stroke"] { + let (mut s, sid) = setup("hello"); + let old = s.execute("object.color", &json!({"color":"#123456","target":target})).unwrap()["swatch"].clone(); + s.execute("swatch.addToSwatches", &json!({"swatch":old,"name":"Brand"})).unwrap(); + assert_eq!(attrs(&mut s)[target], "Brand"); + s.execute("text.insert", &json!({"text":"X"})).unwrap(); + let st = s.doc().unwrap().doc.story(StoryId(sid)).unwrap(); + let f = &st.format_after(5).over; + assert_eq!(if target == "fill" { f.fill.as_deref() } else { f.stroke.as_deref() }, Some("Brand")); + } +} +#[test] +fn integration_color_undo_discards_missing_pending_swatch() { + let (mut s, sid) = setup("hello"); + let old = s.execute("object.color", &json!({"color":"#123456"})).unwrap()["swatch"].as_str().unwrap().to_string(); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.swatch(&old).is_none()); + assert_ne!(attrs(&mut s)["fill"], old); + s.execute("text.insert", &json!({"text":"X"})).unwrap(); + assert_ne!(s.doc().unwrap().doc.story(StoryId(sid)).unwrap().format_after(5).over.fill.as_deref(), Some(old.as_str())); +} +#[test] +fn integration_color_empty_story_uses_one_undo_step() { + let (mut s, sid) = setup(""); + let before = serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap(); + let n = s.doc().unwrap().history.undo.len(); + s.execute("object.color", &json!({"color":"#123456"})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), n + 1); + let colored = serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap(), before); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap(), colored); + s.execute("text.insert", &json!({"text":"X"})).unwrap(); + assert!(s.doc().unwrap().doc.story(StoryId(sid)).unwrap().format_after(0).over.fill.is_some()); +} + +#[test] +fn integration_color_swap_resolves_each_paragraph_and_keeps_unselected_text() { + let (mut s, sid) = setup("ab\ncd"); + for (name, fill, stroke, ft, st, start, end) in + [("First", "[Paper]", "[Black]", 0.2, 0.8, 0, 2), ("Second", "[Black]", "[Paper]", 0.4, 0.6, 3, 5)] + { + s.execute("style.paragraph.create", &json!({"name":name,"chars":{"fill":fill,"stroke":stroke,"fillTint":ft,"strokeTint":st}})).unwrap(); + s.execute("text.select", &json!({"story":sid,"anchor":start,"focus":end})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name":name})).unwrap(); + } + s.execute("text.select", &json!({"story":sid,"anchor":1,"focus":4})).unwrap(); + let before = serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap(); + let n = s.doc().unwrap().history.undo.len(); + s.execute("type.swapFillStroke", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), n + 1); + for (pos, fill, stroke, ft, st) in [ + (0, "[Paper]", "[Black]", 0.2, 0.8), + (1, "[Black]", "[Paper]", 0.8, 0.2), + (2, "[Black]", "[Paper]", 0.8, 0.2), + (3, "[Paper]", "[Black]", 0.6, 0.4), + (4, "[Black]", "[Paper]", 0.4, 0.6), + ] { + s.execute("text.select", &json!({"story":sid,"anchor":pos,"focus":pos+1})).unwrap(); + let a = attrs(&mut s); + assert_eq!(a["fill"], fill); + assert_eq!(a["stroke"], stroke); + assert!((a["fillTint"].as_f64().unwrap() - ft).abs() < 1e-6); + assert!((a["strokeTint"].as_f64().unwrap() - st).abs() < 1e-6); + } + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap(), before); +} + +#[test] +fn integration_color_swap_caret_applies_only_to_next_typing() { + let (mut s, sid) = setup("hello"); + s.execute("type.char", &json!({"fill":"[Paper]","stroke":"[Black]","fillTint":0.2,"strokeTint":0.8})).unwrap(); + let before = serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap(); + s.execute("type.swapFillStroke", &json!({})).unwrap(); + assert_eq!(serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap(), before); + let a = attrs(&mut s); + assert_eq!(a["fill"], "[Black]"); + assert_eq!(a["stroke"], "[Paper]"); + s.execute("text.insert", &json!({"text":"X"})).unwrap(); + let st = s.doc().unwrap().doc.story(StoryId(sid)).unwrap(); + assert_eq!(st.text, "helloX"); + assert_eq!(st.format_after(0).over.fill, None); + assert_eq!(st.format_after(5).over.stroke.as_deref(), Some("[Paper]")); + assert_eq!(st.format_after(5).over.fill_tint, Some(0.8)); +} + +#[test] +fn integration_color_swap_selected_empty_cells_keeps_each_format() { + let (mut s, sid) = setup("hello"); + let result = s.execute("table.insert", &json!({"rows":1,"cols":2})).unwrap(); + let tid = result["table"].as_u64().unwrap(); + s.edit(|d, _| { + let table = std::sync::Arc::make_mut(d.story_mut(StoryId(sid)).unwrap().tables.get_mut(&tid).unwrap()); + for (c, fill, stroke) in [(0, "[Paper]", "[Black]"), (1, "[Black]", "[Paper]")] { + let attrs = &mut table.cell_mut(0, c).unwrap().text.chars[0].format.over; + attrs.fill = Some(fill.into()); + attrs.stroke = Some(stroke.into()); + } + Ok(()) + }) + .unwrap(); + s.execute("table.select", &json!({"story":sid,"table":tid,"what":"table"})).unwrap(); + let before = serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap(); + s.execute("type.swapFillStroke", &json!({})).unwrap(); + let table = &s.doc().unwrap().doc.story(StoryId(sid)).unwrap().tables[&tid]; + assert_eq!(table.cell(0, 0).unwrap().text.chars[0].format.over.fill.as_deref(), Some("[Black]")); + assert_eq!(table.cell(0, 1).unwrap().text.chars[0].format.over.fill.as_deref(), Some("[Paper]")); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap(), before); +} + +#[test] +fn integration_color_failed_promotion_preserves_pending_and_history() { + let (mut s, _) = setup("hello"); + s.execute("swatch.create", &json!({"name":"Brand","color":"#654321"})).unwrap(); + let old = s.execute("object.color", &json!({"color":"#123456"})).unwrap()["swatch"].clone(); + let before = s.doc().unwrap().clone(); + let pending = attrs(&mut s); + assert!(s.execute("swatch.addToSwatches", &json!({"swatch":old,"name":"Brand"})).is_err()); + assert_eq!(attrs(&mut s), pending); + assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &before.doc)); + assert_eq!(s.doc().unwrap().history.undo.len(), before.history.undo.len()); +} + +#[test] +fn integration_color_promote_undo_redo_never_reuses_a_missing_pending_name() { + let (mut s, _) = setup("hello"); + let old = s.execute("object.color", &json!({"color":"#123456"})).unwrap()["swatch"].clone(); + s.execute("swatch.addToSwatches", &json!({"swatch":old,"name":"Brand"})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + let fill = attrs(&mut s)["fill"].as_str().unwrap().to_string(); + assert!(s.doc().unwrap().doc.swatch(&fill).is_some()); + s.execute("edit.redo", &json!({})).unwrap(); + let fill = attrs(&mut s)["fill"].as_str().unwrap().to_string(); + assert!(s.doc().unwrap().doc.swatch(&fill).is_some()); +} + +#[test] +fn integration_color_swap_validates_new_fill_before_any_edit() { + let (mut s, sid) = setup("hello"); + s.execute("text.select", &json!({"story":sid,"anchor":0,"focus":5})).unwrap(); + s.execute("type.char", &json!({"fill":"[Paper]","stroke":"Missing Ink"})).unwrap(); + let before = s.doc().unwrap().clone(); + let journal = s.journal.clone(); + let error = s.execute("type.swapFillStroke", &json!({})).unwrap_err().to_string(); + assert!(error.contains("Missing Ink"), "{error}"); + assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &before.doc)); + assert_eq!(s.doc().unwrap().history.undo.len(), before.history.undo.len()); + assert_eq!(s.journal, journal); +} diff --git a/crates/engine/src/cmd/thread.rs b/crates/engine/src/cmd/thread.rs new file mode 100644 index 000000000..bdc2ffafd --- /dev/null +++ b/crates/engine/src/cmd/thread.rs @@ -0,0 +1,396 @@ +//! Threading text frames: thread a frame after another (their stories merge), break a thread, +//! take a frame out of its thread. The Selection tool's loaded text cursor runs these. + +use designcraft_doc::{Document, ItemId, ParaFormat, Selection, StoryId}; +use designcraft_geom::Rect; +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, bool_or, cmd, has_doc, id_param, rect_param, spread_param, str_param}; +use crate::{Result, Session}; + +/// Pages Shift-click autoflow may add before it gives up. +const AUTOFLOW_MAX_PAGES: usize = 2000; + +pub fn specs() -> Vec<CommandSpec> { + vec![ + cmd!( + "text.thread", + "Thread Text Frames", + [], + None, + "{from?, to?, rect?: [x0,y0,x1,y1] (spread coords), spread?, autoflow?: bool} — threads frame `to` right after frame `from` (between `from` and the frame that followed it, if any). `to` is an empty frame or a text frame nothing threads into yet; its text joins the end of the story. Give `rect` in place of `from` or `to` to thread a new text frame made there on `spread`: `{from, rect}` adds it after `from`, `{rect, to}` in front of `to`. autoflow: then add pages with threaded frames until the story fits. Selects the frame that joined the thread. → {story, from, to, overset, pagesAdded}", + has_doc, + thread + ), + cmd!( + "text.unthread", + "Break Thread", + [], + None, + "{frame?, side?: after|before} — breaks the thread after (or before) `frame` (default: the selected text frame). The frames from the break on stay threaded to each other but become empty; the text stays in the story, overset if the earlier frames can't hold it", + has_doc, + unthread + ), + cmd!( + "text.removeFromThread", + "Remove Frame from Thread", + [], + None, + "{frame?} — takes `frame` (default: the selected text frame) out of its thread: the frames before and after it join up and keep the text, `frame` stays empty", + has_doc, + remove_from_thread + ), + ] +} + +/// `frame` param, or the first selected text frame. +fn frame_param(s: &Session, p: &Value, cmd: &str) -> Result<ItemId> { + let st = s.doc()?; + let id = id_param(p, "frame") + .or_else(|| st.selection.items.iter().copied().find(|i| st.doc.item(*i).is_some_and(|it| it.is_text_frame()))) + .ok_or_else(|| bad(cmd, "no text frame given or selected"))?; + if !st.doc.item(id).is_some_and(|it| it.is_text_frame()) { + return Err(bad(cmd, format!("{id} is not a text frame"))); + } + check_editable(&st.doc, id, cmd)?; + Ok(id) +} + +fn check_editable(d: &Document, id: ItemId, cmd: &str) -> Result<()> { + let it = d.item(id).ok_or(designcraft_doc::DocError::NoItem(id))?; + if it.locked || d.layer(it.layer).is_some_and(|l| l.locked) { + return Err(bad(cmd, format!("{id} is locked or on a locked layer"))); + } + Ok(()) +} + +/// A rect param made safe for a frame: finite, ordered, at least half a point each way. +fn frame_rect(p: &Value) -> Result<Option<Rect>> { + let Some(r) = rect_param(p, "rect") else { return Ok(None) }; + if ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) { + return Err(bad("text.thread", "rect must be finite")); + } + let (x0, x1) = (r.x0.min(r.x1), r.x0.max(r.x1)); + let (y0, y1) = (r.y0.min(r.y1), r.y0.max(r.y1)); + Ok(Some(Rect::new(x0, y0, x1.max(x0 + 0.5), y1.max(y0 + 0.5)))) +} + +fn thread(s: &mut Session, p: &Value) -> Result<Value> { + let (from, to, rect) = (id_param(p, "from"), id_param(p, "to"), frame_rect(p)?); + let sr = spread_param(p, "spread")?; + let autoflow = bool_or(p, "autoflow", false); + let layer = s.doc()?.active_layer; + s.edit(|d, sel| { + let mut new_frame = || -> Result<ItemId> { + let r = rect.ok_or_else(|| bad("text.thread", "give two of from, to and rect"))?; + if d.layer(layer).is_none_or(|l| l.locked || !l.visible) { + return Err(bad("text.thread", "the active layer is locked or hidden")); + } + let para = ParaFormat { style: d.styles.default_paragraph.clone(), ..Default::default() }; + Ok(d.add_text_frame(sr, r, layer, "", para)?.0) + }; + let (from, to, joined) = match (from, to) { + (Some(f), Some(t)) if rect.is_none() => (f, t, t), + (Some(f), None) => { + let t = new_frame()?; + (f, t, t) + } + (None, Some(t)) => { + let f = new_frame()?; + (f, t, f) + } + _ => return Err(bad("text.thread", "give two of from, to and rect")), + }; + d.check_thread(from, to)?; + d.thread_keeping_story(from, to)?; + let sid = d.item(from).and_then(|i| i.text_frame()).map(|t| t.story).ok_or_else(|| bad("text.thread", "threading lost the story"))?; + let pages = if autoflow { super::place_text_autoflow(d, sid, AUTOFLOW_MAX_PAGES, "text.thread")? } else { 0 }; + *sel = Selection::items(vec![joined]); + Ok(json!({"story": sid.0, "from": from.0, "to": to.0, "overset": overset(d, sid), "pagesAdded": pages})) + }) +} + +fn overset(d: &Document, sid: StoryId) -> bool { + designcraft_compose::compose_story(d, sid, &Default::default()).overset_at.is_some() +} + +fn unthread(s: &mut Session, p: &Value) -> Result<Value> { + const CMD: &str = "text.unthread"; + let frame = frame_param(s, p, CMD)?; + let d0 = &s.doc()?.doc; + let at = match str_param(p, "side").unwrap_or("after") { + "after" => d0.next_frame(frame).map(|_| frame).ok_or_else(|| bad(CMD, format!("no frame follows {frame} in its thread")))?, + "before" => d0.prev_frame(frame).ok_or_else(|| bad(CMD, format!("no frame comes before {frame} in its thread")))?, + other => return Err(bad(CMD, format!("unknown side `{other}` (after, before)"))), + }; + check_editable(d0, at, CMD)?; + if let Some(next) = d0.next_frame(at) { + check_editable(d0, next, CMD)?; + } + s.edit(|d, _| { + d.unthread_after(at)?; + let sid = d.item(at).and_then(|i| i.text_frame()).map(|t| t.story).ok_or_else(|| bad(CMD, "no story"))?; + Ok(json!({"story": sid.0, "overset": overset(d, sid)})) + }) +} + +fn remove_from_thread(s: &mut Session, p: &Value) -> Result<Value> { + let frame = frame_param(s, p, "text.removeFromThread")?; + let d = &s.doc()?.doc; + for neighbour in [d.prev_frame(frame), d.next_frame(frame)].into_iter().flatten() { + check_editable(d, neighbour, "text.removeFromThread")?; + } + s.edit(|d, _| { + d.remove_from_thread(frame)?; + Ok(json!({})) + }) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::Session; + + fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 2})).unwrap(); + s + } + + fn text_frame(s: &mut Session, rect: [f64; 4], text: &str) -> (u64, u64) { + let r = s.execute("frame.create", &json!({"rect": rect, "content": "text", "text": text, "caret": false})).unwrap(); + (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap()) + } + + fn story(s: &mut Session, frame: u64) -> serde_json::Value { + s.execute("story.get", &json!({"frame": frame})).unwrap() + } + + fn frames(s: &mut Session, frame: u64) -> Vec<u64> { + story(s, frame)["frames"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect() + } + + const LONG: &str = "Threaded text keeps flowing from one frame into the next one. "; + + #[test] + fn thread_into_an_empty_frame_and_undo() { + let mut s = session(); + let (a, sid) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &LONG.repeat(10)); + let b = s.execute("frame.create", &json!({"rect": [72, 300, 200, 400], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + assert!(!story(&mut s, a)["overset"].is_null(), "the first frame is overset"); + let r = s.execute("text.thread", &json!({"from": a, "to": b})).unwrap(); + assert_eq!(r["story"].as_u64(), Some(sid)); + assert_eq!(frames(&mut s, a), [a, b]); + let d = &s.doc().unwrap().doc; + assert!(d.item(designcraft_doc::ItemId(b)).unwrap().is_text_frame()); + assert_eq!(s.doc().unwrap().selection.items, [designcraft_doc::ItemId(b)]); + // The text now reaches the second frame. + let cs = designcraft_compose::compose_story(&s.doc().unwrap().doc, designcraft_doc::StoryId(sid), &Default::default()); + assert!(cs.frames.iter().any(|f| f.frame.0 == b && !f.range.is_empty()), "text flows into the new frame"); + s.doc().unwrap().doc.check().unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(frames(&mut s, a), [a]); + assert!(!s.doc().unwrap().doc.item(designcraft_doc::ItemId(b)).unwrap().is_text_frame()); + } + + #[test] + fn thread_into_a_text_frame_merges_its_story() { + let mut s = session(); + let (a, sa) = text_frame(&mut s, [72.0, 72.0, 200.0, 200.0], "First"); + let (b, sb) = text_frame(&mut s, [72.0, 300.0, 200.0, 400.0], "Second"); + s.execute("text.thread", &json!({"from": a, "to": b})).unwrap(); + let st = story(&mut s, b); + assert_eq!(st["id"].as_u64(), Some(sa)); + assert_eq!(st["text"], "First\nSecond"); + assert!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sb)).is_none()); + s.doc().unwrap().doc.check().unwrap(); + } + + #[test] + fn thread_into_the_middle_of_a_thread() { + let mut s = session(); + let (a, _) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &LONG.repeat(8)); + let (c, _) = text_frame(&mut s, [72.0, 500.0, 200.0, 600.0], ""); + s.execute("text.thread", &json!({"from": a, "to": c})).unwrap(); + let (b, _) = text_frame(&mut s, [72.0, 300.0, 200.0, 400.0], ""); + s.execute("text.thread", &json!({"from": a, "to": b})).unwrap(); + assert_eq!(frames(&mut s, a), [a, b, c]); + s.doc().unwrap().doc.check().unwrap(); + } + + #[test] + fn bad_threads_are_errors_and_change_nothing() { + let mut s = session(); + let (a, _) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], "a"); + let (b, _) = text_frame(&mut s, [72.0, 300.0, 200.0, 400.0], "b"); + let (x, _) = text_frame(&mut s, [300.0, 72.0, 400.0, 120.0], "x"); + let (y, _) = text_frame(&mut s, [300.0, 300.0, 400.0, 400.0], "y"); + let g = s.execute("line.create", &json!({"a": [300, 500], "b": [400, 600]})).unwrap()["id"].as_u64().unwrap(); + let group = { + let p = s.execute("frame.create", &json!({"rect": [500, 500, 550, 550], "content": "unassigned"})).unwrap()["id"].clone(); + let q = s.execute("frame.create", &json!({"rect": [560, 500, 600, 550], "content": "unassigned"})).unwrap()["id"].clone(); + s.execute("selection.set", &json!({"ids": [p, q]})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + s.doc().unwrap().selection.items[0].0 + }; + s.execute("text.thread", &json!({"from": a, "to": b})).unwrap(); + s.execute("text.thread", &json!({"from": x, "to": y})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let doc = s.doc().unwrap().doc.clone(); + for p in [ + json!({"from": a, "to": a}), + json!({"from": a, "to": g}), + json!({"from": g, "to": a}), + json!({"from": a, "to": group}), + // Earlier in the same thread. + json!({"from": b, "to": a}), + // Text already flows into y from x. + json!({"from": a, "to": y}), + json!({"from": a, "to": 999_999}), + json!({"from": a}), + json!({"from": a, "to": b, "rect": [0, 0, 10, 10]}), + json!({"rect": [0, 0, 10, 10]}), + json!({"from": a, "rect": [0, 0, 10, 10], "spread": 99}), + json!({"from": "a", "to": [1]}), + json!([1, 2]), + ] { + assert!(s.execute("text.thread", &p).is_err(), "{p}"); + } + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + assert_eq!(*s.doc().unwrap().doc, *doc); + } + + #[test] + fn locked_frames_and_layers_refuse_threads() { + let mut s = session(); + let (a, _) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &LONG.repeat(8)); + let (b, _) = text_frame(&mut s, [72.0, 300.0, 200.0, 400.0], ""); + let layer = s.doc().unwrap().active_layer.0; + let undo = s.doc().unwrap().history.undo.len(); + s.execute("object.lock", &json!({"ids": [b]})).unwrap(); + assert!(s.execute("text.thread", &json!({"from": a, "to": b})).is_err(), "locked frame"); + assert!(s.execute("text.thread", &json!({"from": b, "to": a})).is_err(), "locked frame"); + s.execute("edit.undo", &json!({})).unwrap(); + for (set, rect) in [(json!({"locked": true}), false), (json!({"locked": true}), true), (json!({"visible": false}), true)] { + let mut p = json!({"id": layer}); + p.as_object_mut().unwrap().extend(set.as_object().unwrap().clone()); + s.execute("layer.set", &p).unwrap(); + let t = if rect { json!({"from": a, "rect": [300, 300, 400, 400]}) } else { json!({"from": a, "to": b}) }; + assert!(s.execute("text.thread", &t).is_err(), "{p} {t}"); + s.execute("edit.undo", &json!({})).unwrap(); + } + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + assert_eq!(frames(&mut s, a), [a]); + } + + #[test] + fn thread_a_new_frame_after_or_in_front() { + let mut s = session(); + let (a, sa) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &LONG.repeat(8)); + let r = s.execute("text.thread", &json!({"from": a, "rect": [72, 300, 200, 400]})).unwrap(); + let n = r["to"].as_u64().unwrap(); + assert_eq!(frames(&mut s, a), [a, n]); + assert_eq!(s.doc().unwrap().selection.items, [designcraft_doc::ItemId(n)]); + let b = s.doc().unwrap().doc.item(designcraft_doc::ItemId(n)).unwrap().bounds(); + assert_eq!(b, Rect::new(72.0, 300.0, 200.0, 400.0)); + // In front of the first frame: the story keeps its id and text. + let r = s.execute("text.thread", &json!({"rect": [300, 72, 400, 120], "to": a})).unwrap(); + let m = r["from"].as_u64().unwrap(); + assert_eq!(r["story"].as_u64(), Some(sa)); + assert_eq!(frames(&mut s, a), [m, a, n]); + assert_eq!(story(&mut s, a)["text"], LONG.repeat(8)); + s.doc().unwrap().doc.check().unwrap(); + // One undo step each. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(frames(&mut s, a), [a, n]); + } + + use designcraft_geom::Rect; + + #[test] + fn thread_with_autoflow_adds_pages_until_the_text_fits() { + let mut s = session(); + let (a, _) = text_frame(&mut s, [72.0, 72.0, 300.0, 200.0], &LONG.repeat(300)); + let r = s.execute("text.thread", &json!({"from": a, "rect": [72, 300, 300, 700], "autoflow": true})).unwrap(); + assert_eq!(r["overset"], false); + assert!(r["pagesAdded"].as_u64().unwrap() >= 1, "{r}"); + assert!(frames(&mut s, a).len() >= 3); + s.doc().unwrap().doc.check().unwrap(); + // One undo removes the frames and the pages. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(frames(&mut s, a), [a]); + assert_eq!(s.doc().unwrap().doc.page_count(), 2); + } + + #[test] + fn autoflow_from_a_parent_frame_is_an_error() { + let mut s = session(); + let parent = json!({"kind": "parent", "index": 0}); + let r = s.execute( + "frame.create", + &json!({"rect": [72, 72, 300, 200], "content": "text", "text": LONG.repeat(300), "caret": false, "spread": parent}), + ); + let a = r.unwrap()["id"].as_u64().unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let doc = s.doc().unwrap().doc.clone(); + let e = s.execute("text.thread", &json!({"from": a, "rect": [72, 300, 300, 700], "spread": parent, "autoflow": true})).unwrap_err(); + assert!(e.to_string().contains("text.thread"), "{e}"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + assert_eq!(*s.doc().unwrap().doc, *doc); + assert_eq!(s.doc().unwrap().doc.page_count(), 2); + } + + #[test] + fn unthread_after_and_before() { + let mut s = session(); + let text = LONG.repeat(12); + let (a, sa) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &text); + let (b, _) = text_frame(&mut s, [72.0, 200.0, 200.0, 250.0], ""); + let (c, _) = text_frame(&mut s, [72.0, 300.0, 200.0, 350.0], ""); + s.execute("text.thread", &json!({"from": a, "to": b})).unwrap(); + s.execute("text.thread", &json!({"from": b, "to": c})).unwrap(); + // Break after the first frame: b and c stay threaded to each other, empty. + let r = s.execute("text.unthread", &json!({"frame": a})).unwrap(); + assert_eq!(r["story"].as_u64(), Some(sa)); + assert_eq!(r["overset"], true); + assert_eq!(frames(&mut s, a), [a]); + assert_eq!(story(&mut s, a)["text"], text); + assert_eq!(frames(&mut s, b), [b, c]); + assert_eq!(story(&mut s, b)["text"], ""); + s.doc().unwrap().doc.check().unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(frames(&mut s, a), [a, b, c]); + // Break before the last frame, with the frame selected. + s.execute("selection.set", &json!({"ids": [c]})).unwrap(); + s.execute("text.unthread", &json!({"side": "before"})).unwrap(); + assert_eq!(frames(&mut s, a), [a, b]); + assert_eq!(frames(&mut s, c), [c]); + // Nothing to break. + assert!(s.execute("text.unthread", &json!({"frame": b})).is_err()); + assert!(s.execute("text.unthread", &json!({"frame": a, "side": "before"})).is_err()); + assert!(s.execute("text.unthread", &json!({"frame": a, "side": "sideways"})).is_err()); + assert!(s.execute("text.unthread", &json!({"frame": 999_999})).is_err()); + } + + #[test] + fn deleting_or_removing_a_threaded_frame_keeps_the_text_flowing() { + let mut s = session(); + let text = LONG.repeat(12); + let (a, _) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &text); + let (b, _) = text_frame(&mut s, [72.0, 200.0, 200.0, 250.0], ""); + let (c, _) = text_frame(&mut s, [72.0, 300.0, 200.0, 350.0], ""); + s.execute("text.thread", &json!({"from": a, "to": b})).unwrap(); + s.execute("text.thread", &json!({"from": b, "to": c})).unwrap(); + s.execute("text.removeFromThread", &json!({"frame": b})).unwrap(); + assert_eq!(frames(&mut s, a), [a, c]); + assert_eq!(story(&mut s, a)["text"], text); + assert_eq!(story(&mut s, b)["text"], ""); + assert!(s.execute("text.removeFromThread", &json!({"frame": b})).is_err()); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.clear", &json!({"ids": [b]})).unwrap(); + assert_eq!(frames(&mut s, a), [a, c]); + assert_eq!(story(&mut s, a)["text"], text); + s.doc().unwrap().doc.check().unwrap(); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index caeeb80ba..900bd8863 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -276,6 +276,8 @@ pub struct Session { pub(crate) transforms: (Vec<(String, Value)>, Vec<designcraft_doc::ItemId>, bool), /// Drag and drop text editing: the press landed in the selected text. pub(crate) text_drag: bool, + /// Character formatting chosen at a bare caret, for the text typed there next. + pub(crate) typing_format: Option<cmd::text::TypingFormat>, /// The open Object Library (File › New / Open Library). pub library: Option<cmd::library::Library>, /// The open book (File › Open Book). @@ -307,6 +309,7 @@ impl Session { loaded: None, transforms: Default::default(), text_drag: false, + typing_format: None, library: None, conveyor: Vec::new(), book: None, @@ -475,7 +478,7 @@ impl Session { } let mut d = (*st.doc).clone(); if overset { - let _ = cmd::place_text_autoflow(&mut d, sid, 500); + let _ = cmd::place_text_autoflow(&mut d, sid, 500, "Smart Text Reflow"); } else { // Drop empty trailing frames on the last pages (one frame, nothing else there). while let Some(story) = d.story(sid) { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 17d1ed098..9d6308983 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -569,3 +569,103 @@ fn commands_take_non_object_params_without_panicking() { assert!(r.is_ok(), "{p}: {r:?}"); } } + +/// Load the text from a frame's out port with the Selection tool and click on the pasteboard: +/// a new frame is threaded there and the overset text flows into it. +#[test] +fn threading_with_the_loaded_text_cursor_on_the_sample() { + use designcraft_tools::{PointerEvent, PointerKind}; + let mut s = Session::new(); + s.execute("file.newSample", &json!({})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let (sid, first) = d.stories.values().find(|st| st.frames.len() >= 2).map(|st| (st.id, st.frames[0])).expect("a threaded story"); + // Break the thread so the first frame is overset, and select it. + s.execute("text.unthread", &json!({"frame": first.0})).unwrap(); + assert!(s.cache.get(&s.doc().unwrap().doc, sid, None).is_overset()); + s.execute("selection.set", &json!({"ids": [first.0]})).unwrap(); + s.set_tool("selection"); + let v = ViewInfo::at_zoom(1.0); + let layout = s.layout(); + let port = designcraft_tools::thread::port_center(&s.doc().unwrap().doc, &layout, first, true, v.zoom).unwrap(); + let ev = |k, p: designcraft_geom::Point| PointerEvent::new(k, p.x, p.y); + s.pointer(&ev(PointerKind::Down, port), v).unwrap(); + s.pointer(&ev(PointerKind::Up, port), v).unwrap(); + assert_eq!(s.cursor(port + designcraft_geom::Vec2::new(-2000.0, 0.0), Default::default(), v), designcraft_tools::Cursor::LoadedText); + // On the pasteboard left of the first spread. + let b = layout.slots[0].bounds; + let at = designcraft_geom::Point::new(b.x0 - 300.0, b.y0 + 100.0); + let undo = s.doc().unwrap().history.undo.len(); + s.pointer(&ev(PointerKind::Down, at), v).unwrap(); + s.pointer(&ev(PointerKind::Up, at), v).unwrap(); + let st = s.doc().unwrap(); + assert_eq!(st.history.undo.len(), undo + 1, "one undo step"); + let frames = st.doc.story(sid).unwrap().frames.clone(); + assert_eq!(frames.len(), 2); + let new = frames[1]; + assert_eq!(st.selection.items, [new]); + let cs = s.cache.get(&s.doc().unwrap().doc, sid, None); + assert!(cs.frames.iter().any(|f| f.frame == new && !f.range.is_empty()), "the text flows into the new frame"); + let nb = s.doc().unwrap().doc.item(new).unwrap().bounds(); + assert!((nb.y0 - layout.to_spread(designcraft_doc::SpreadRef::Doc(0), at).y).abs() < 1e-6, "{nb:?}"); +} + +/// Drag a selection handle of item 0 on spread 0 from the edge point `at` by `by` (canvas units). +fn drag_handle(s: &mut Session, at: designcraft_geom::Point, by: designcraft_geom::Vec2, v: ViewInfo) { + use designcraft_tools::{PointerEvent, PointerKind}; + let p = s.layout().to_canvas(designcraft_doc::SpreadRef::Doc(0), at); + s.pointer(&PointerEvent::new(PointerKind::Down, p.x, p.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, p.x + by.x / 2.0, p.y + by.y / 2.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, p.x + by.x, p.y + by.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, p.x + by.x, p.y + by.y), v).unwrap(); +} + +#[test] +fn text_frame_resize_snaps_to_the_margins() { + use designcraft_geom::{Point, Vec2}; + for zoom in [1.0, 0.5] { + let mut s = Session::new(); + s.execute("file.newSample", &json!({})).unwrap(); + let margin = s.doc().unwrap().doc.spreads[0].pages[0].margin_rect(); + let (x0, y0) = (margin.x0 + 40.0, margin.y0 + 40.0); + s.execute("frame.create", &json!({"rect": [x0, y0, x0 + 200.0, y0 + 200.0], "content": "text"})).unwrap(); + s.set_tool("selection"); + let v = ViewInfo::at_zoom(zoom); + let b0 = s.doc().unwrap().doc.spreads[0].items.last().unwrap().bounds(); + // Right handle to 1.5 pt short of the right margin. + drag_handle(&mut s, Point::new(b0.x1, b0.center().y), Vec2::new(margin.x1 - 1.5 - b0.x1, 0.0), v); + let b = s.doc().unwrap().doc.spreads[0].items.last().unwrap().bounds(); + assert!((b.x1 - margin.x1).abs() < 1e-6, "zoom {zoom}: right edge {} not on the margin {}", b.x1, margin.x1); + // Bottom handle to 1.5 pt past the bottom margin. + drag_handle(&mut s, Point::new(b.center().x, b.y1), Vec2::new(0.0, margin.y1 + 1.5 - b.y1), v); + let b = s.doc().unwrap().doc.spreads[0].items.last().unwrap().bounds(); + assert!((b.y1 - margin.y1).abs() < 1e-6, "zoom {zoom}: bottom edge {} not on the margin {}", b.y1, margin.y1); + // Top-left corner handle onto the top and left margins. + drag_handle(&mut s, Point::new(b.x0, b.y0), Vec2::new(margin.x0 + 1.0 - b.x0, margin.y0 - 1.0 - b.y0), v); + let b = s.doc().unwrap().doc.spreads[0].items.last().unwrap().bounds(); + assert!((b.x0 - margin.x0).abs() < 1e-6 && (b.y0 - margin.y0).abs() < 1e-6, "zoom {zoom}: corner {b:?} not on the margins {margin:?}"); + } +} + +#[test] +fn type_tool_frames_snap_to_the_margins() { + use designcraft_geom::Point; + use designcraft_tools::{PointerEvent, PointerKind}; + let mut s = session(); + let margin = s.doc().unwrap().doc.spreads[0].pages[0].margin_rect(); + s.set_tool("type"); + let v = ViewInfo::at_zoom(1.0); + let c = |s: &Session, x: f64, y: f64| s.layout().to_canvas(designcraft_doc::SpreadRef::Doc(0), Point::new(x, y)); + // From 1.5 pt off the top-left margin corner to 1.5 pt off the right margin. + let a = c(&s, margin.x0 + 1.5, margin.y0 + 1.5); + let b = c(&s, margin.x1 - 1.5, margin.y0 + 101.5); + let before = s.doc().unwrap().doc.spreads[0].items.len(); + s.pointer(&PointerEvent::new(PointerKind::Down, a.x, a.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, (a.x + b.x) / 2.0, b.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, b.x, b.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, b.x, b.y), v).unwrap(); + let items = &s.doc().unwrap().doc.spreads[0].items; + assert_eq!(items.len(), before + 1, "a frame was drawn"); + let r = items.last().unwrap().bounds(); + assert!((r.x0 - margin.x0).abs() < 1e-6 && (r.y0 - margin.y0).abs() < 1e-6, "press point {r:?} vs {margin:?}"); + assert!((r.x1 - margin.x1).abs() < 1e-6, "right edge {} vs {}", r.x1, margin.x1); +} diff --git a/crates/engine/src/tests_idml.rs b/crates/engine/src/tests_idml.rs index cc40768ea..f16d3efc2 100644 --- a/crates/engine/src/tests_idml.rs +++ b/crates/engine/src/tests_idml.rs @@ -91,6 +91,91 @@ fn magazine_round_trips_through_idml() { assert_eq!(items(&again.spreads).len(), items(&back.spreads).len()); } +/// A one-page IDML package: two threaded frames side by side, 19 lines each (10 pt on 12 pt), +/// paragraphs of `lines` forced-break lines in style `Body`, which carries `body_attrs`. +fn keep_lines_idml(body_attrs: &str, lines: &[usize]) -> Vec<u8> { + use std::io::Write; + let paras: String = lines + .iter() + .enumerate() + .map(|(p, &n)| { + let text = (0..n).map(|k| format!("P{p} line {k}")).collect::<Vec<_>>().join("\u{2028}"); + format!( + r#"<ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/Body"><CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content>{text}</Content>{}</CharacterStyleRange></ParagraphStyleRange>"#, + if p + 1 < lines.len() { "<Br/>" } else { "" } + ) + }) + .collect(); + let frame = |me: &str, x: f64, prev: &str, next: &str| { + let pts: String = [(x, 36.0), (x, 276.0), (x + 180.0, 276.0), (x + 180.0, 36.0)] + .iter() + .map(|(x, y)| format!(r#"<PathPointType Anchor="{x} {y}" LeftDirection="{x} {y}" RightDirection="{x} {y}"/>"#)) + .collect(); + format!( + r#"<TextFrame Self="{me}" ParentStory="s1" PreviousTextFrame="{prev}" NextTextFrame="{next}" ItemTransform="1 0 0 1 0 0"><Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>{pts}</PathPointArray></GeometryPathType></PathGeometry></Properties></TextFrame>"# + ) + }; + let designmap = format!( + r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<Document xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0" Self="d"> +<DocumentPreference PageWidth="450" PageHeight="312" FacingPages="false"/> +<RootCharacterStyleGroup Self="rc"><CharacterStyle Self="CharacterStyle/$ID/[No character style]" Name="$ID/[No character style]"/></RootCharacterStyleGroup> +<RootParagraphStyleGroup Self="rp"> +<ParagraphStyle Self="ParagraphStyle/$ID/[No paragraph style]" Name="$ID/[No paragraph style]" PointSize="12"/> +<ParagraphStyle Self="ParagraphStyle/Body" Name="Body" PointSize="10" {body_attrs}><Properties><BasedOn type="object">ParagraphStyle/$ID/[No paragraph style]</BasedOn><Leading type="unit">12</Leading></Properties></ParagraphStyle> +</RootParagraphStyleGroup> +<Story Self="s1">{paras}</Story> +<Spread Self="sp1" ItemTransform="1 0 0 1 0 0"><Page Self="p1" GeometricBounds="0 0 312 450" ItemTransform="1 0 0 1 0 0"/>{}{}</Spread> +</Document>"#, + frame("t1", 36.0, "n", "t2"), + frame("t2", 234.0, "t1", "n") + ); + let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + w.start_file("mimetype", stored).unwrap(); + w.write_all(b"application/vnd.adobe.indesign-idml-package").unwrap(); + w.start_file("designmap.xml", zip::write::SimpleFileOptions::default()).unwrap(); + w.write_all(designmap.as_bytes()).unwrap(); + w.finish().unwrap().into_inner() +} + +/// Lines of paragraph `para` in each frame of the story. +fn para_lines_per_frame(d: &Document, para: usize) -> Vec<usize> { + let sid = *d.stories.keys().next().unwrap(); + let cs = designcraft_compose::compose_story(d, sid, &Default::default()); + cs.frames.iter().map(|f| f.lines.iter().filter(|l| l.para == para).count()).collect() +} + +/// Keep Lines Together without a mode in the IDML: InDesign's default, at start/end of paragraph. +/// A 10-line paragraph starting 6 lines above the first frame's bottom splits 6/4. +#[test] +fn idml_keep_lines_without_mode_split_at_start_and_end() { + let keep = r#"KeepLinesTogether="true" KeepFirstLines="2" KeepLastLines="2""#; + let d = designcraft_idml::import_idml(&keep_lines_idml(keep, &[13, 10, 2])).unwrap(); + assert_eq!(para_lines_per_frame(&d, 0), [13, 0]); + assert_eq!(para_lines_per_frame(&d, 1), [6, 4]); + // All lines in paragraph, written explicitly: the paragraph moves whole. + let d = designcraft_idml::import_idml(&keep_lines_idml(&format!(r#"{keep} KeepAllLinesTogether="true""#), &[13, 10, 2])).unwrap(); + assert_eq!(para_lines_per_frame(&d, 1), [0, 10]); +} + +/// A document saved in format 1 with Keep Lines Together and no mode still keeps all lines +/// together after loading; saved now, it keeps lines at start/end of paragraph. +#[test] +fn format_1_keep_lines_without_mode_lays_out_all_lines() { + use std::io::Write; + let d = designcraft_idml::import_idml(&keep_lines_idml(r#"KeepLinesTogether="true""#, &[13, 10, 2])).unwrap(); + let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + w.start_file("meta.json", zip::write::SimpleFileOptions::default()).unwrap(); + w.write_all(br#"{"format": "designcraft", "version": 1}"#).unwrap(); + w.start_file("document.json", zip::write::SimpleFileOptions::default()).unwrap(); + w.write_all(&serde_json::to_vec(&d).unwrap()).unwrap(); + let v1 = designcraft_format::load(&w.finish().unwrap().into_inner()).unwrap(); + assert_eq!(para_lines_per_frame(&v1, 1), [0, 10]); + let now = designcraft_format::load(&designcraft_format::save(&d).unwrap()).unwrap(); + assert_eq!(para_lines_per_frame(&now, 1), [6, 4]); +} + #[test] fn idml_commands() { let mut s = Session::new(); @@ -251,3 +336,30 @@ fn named_lists_round_trip_through_idml() { assert_eq!(st.paras[0].para.list_name.as_deref(), Some("Steps")); assert_eq!(st.paras[0].para.start_at, Some(Some(5))); } + +#[test] +fn type_para_sets_a_drop_cap_that_survives_idml() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("style.character.create", &json!({"name": "Initial", "chars": {"fontStyle": "Bold"}})).unwrap(); + let text = "Once upon a time there was a paragraph long enough to run beside its drop cap for a few lines and then some more."; + let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 400], "content": "text", "text": text})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 3, "focus": 3})).unwrap(); + s.execute("type.para", &json!({"attrs": {"dropCapLines": 3, "dropCapChars": 1, "dropCapStyle": "Initial", "dropCapAlignLeft": false}})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let cs = designcraft_compose::compose_story(&d, designcraft_doc::StoryId(sid), &Default::default()); + let lines = &cs.frames[0].lines; + let dc = &lines[0].glyphs[0]; + assert_eq!(dc.byte, 0); + assert!((lines[0].baseline + dc.y - lines[2].baseline).abs() < 1e-6, "on line 3's baseline"); + assert!(lines[0].drop_cap.is_some_and(|b| b.end == 1)); + let p = &d.story(designcraft_doc::StoryId(sid)).unwrap().paras[0].para; + assert_eq!((p.drop_cap_lines, p.drop_cap_chars, p.drop_cap_style.as_deref()), (Some(3), Some(1), Some("Initial"))); + let back = designcraft_idml::import_idml(&designcraft_idml::export_idml(&d)).unwrap(); + let p = &back.stories.values().find(|st| st.text.starts_with("Once")).unwrap().paras[0].para; + assert_eq!((p.drop_cap_lines, p.drop_cap_chars, p.drop_cap_align_left), (Some(3), Some(1), Some(false))); + // The drop cap's character style goes through IDML as a leading Dropcap nested style. + assert_eq!(p.drop_cap_style.as_deref(), Some("Initial")); + assert_eq!(p.nested_styles, Some(vec![designcraft_doc::NestedStyle::drop_cap("Initial")])); +} diff --git a/crates/engine/tests/primary_frame_columns.rs b/crates/engine/tests/primary_frame_columns.rs new file mode 100644 index 000000000..1a08a7081 --- /dev/null +++ b/crates/engine/tests/primary_frame_columns.rs @@ -0,0 +1,34 @@ +//! Primary text frames retain all columns supported by the new-document command. +use designcraft_doc::Document; +use designcraft_engine::Session; +use serde_json::json; + +fn assert_primary_columns(doc: &Document, expected: u32) { + let mut frames = 0; + for story in doc.stories.values() { + for spec in designcraft_compose::frame_specs(doc, story.id) { + assert_eq!(spec.opts.columns, expected); + let columns = spec.columns(); + assert_eq!(columns.len(), expected as usize); + assert!(columns.iter().all(|r| r.width() > 0. && [r.x0, r.x1, r.y0, r.y1].into_iter().all(f64::is_finite))); + frames += 1; + } + } + assert!(frames > 0, "the primary text frame must exist"); +} + +#[test] +fn primary_columns_survive_composition_native_reopen_and_idml_round_trip() { + for columns in [41, 216] { + let mut s = Session::new(); + s.execute("file.new", &json!({"columns":columns,"gutter":0,"primaryTextFrame":true})).unwrap(); + assert_primary_columns(&s.doc().unwrap().doc, columns); + let bytes = s.execute("file.serialize", &json!({})).unwrap(); + let mut reopened = Session::new(); + reopened.execute("file.openBytes", &json!({"name":"primary-columns.designcraft","base64":bytes["base64"]})).unwrap(); + assert_primary_columns(&reopened.doc().unwrap().doc, columns); + let idml = designcraft_idml::export_idml(&reopened.doc().unwrap().doc); + let imported = designcraft_idml::import_idml(&idml).unwrap(); + assert_primary_columns(&imported, columns); + } +} diff --git a/crates/engine/tests/text_thread_regressions.rs b/crates/engine/tests/text_thread_regressions.rs new file mode 100644 index 000000000..2bca1b06d --- /dev/null +++ b/crates/engine/tests/text_thread_regressions.rs @@ -0,0 +1,277 @@ +use designcraft_doc::{ParaFormat, Story, StoryId}; +use designcraft_engine::Session; +use serde_json::{Value, json}; +use std::sync::Arc; +fn setup() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s +} +fn frame(s: &mut Session, text: &str) -> (u64, u64) { + let r = s.execute("frame.create", &json!({"rect":[72,72,300,300],"content":"text","text":text,"caret":false})).unwrap(); + (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap()) +} +fn snapshot(s: &Session) -> Value { + let d = s.doc().unwrap(); + json!({"doc":d.doc,"selection":d.selection,"revision":d.revision,"dirty":d.is_dirty(),"undo":d.history.undo.len(),"redo":d.history.redo.len(),"journal":s.journal}) +} +#[test] +fn rejected_thread_is_atomic_with_new_frame_and_redo() { + let mut s = setup(); + let (a, _) = frame(&mut s, "hello"); + frame(&mut s, "undo"); + s.execute("edit.undo", &json!({})).unwrap(); + let before = snapshot(&s); + assert!(s.execute("text.thread", &json!({"from":999999,"rect":[0,0,100,100]})).is_err()); + assert_eq!(snapshot(&s), before); + assert!(s.execute("text.thread", &json!({"from":a,"rect":[0,0,100,100],"spread":999})).is_err()); + assert_eq!(snapshot(&s), before); +} +#[test] +fn locked_frame_cannot_be_unthreaded() { + let mut s = setup(); + let (a, _) = frame(&mut s, "hello"); + let (b, _) = frame(&mut s, ""); + s.execute("text.thread", &json!({"from":a,"to":b})).unwrap(); + s.execute("object.lock", &json!({"ids":[a]})).unwrap(); + let before = snapshot(&s); + let r = s.execute("text.unthread", &json!({"frame":a})); + assert!(r.is_err(), "unthread accepted locked frame: {r:?}"); + assert_eq!(snapshot(&s), before); +} +#[test] +fn locked_frame_cannot_be_removed_from_thread() { + let mut s = setup(); + let (a, _) = frame(&mut s, "hello"); + let (b, _) = frame(&mut s, ""); + s.execute("text.thread", &json!({"from":a,"to":b})).unwrap(); + s.execute("object.lock", &json!({"ids":[a]})).unwrap(); + let before = snapshot(&s); + let r = s.execute("text.removeFromThread", &json!({"frame":a})); + assert!(r.is_err(), "remove accepted locked frame: {r:?}"); + assert_eq!(snapshot(&s), before); +} +#[test] +fn rejected_selection_preserves_pending_typing() { + let mut s = setup(); + let (_, sid) = frame(&mut s, "hello"); + s.execute("text.select", &json!({"story":sid,"anchor":3})).unwrap(); + s.execute("type.char", &json!({"attrs":{"size":30}})).unwrap(); + let before = s.execute("type.selectionAttrs", &json!({})).unwrap(); + assert!(s.execute("text.select", &json!({"story":999999,"anchor":0})).is_err()); + let after = s.execute("type.selectionAttrs", &json!({})).unwrap(); + assert_eq!(after, before, "rejected selection reset pending typing"); +} +#[test] +fn append_max_id_preserves_both_tables() { + use designcraft_doc::table::Table; + let mut a = Story::with_text(StoryId(1), &designcraft_doc::TABLE_ANCHOR.to_string(), ParaFormat::default()); + let mut b = Story::with_text(StoryId(2), &designcraft_doc::TABLE_ANCHOR.to_string(), ParaFormat::default()); + let mut ta = Table::new(u64::MAX, 1, 1, 0, 0, 100.); + ta.cells[0].text = Story::with_text(StoryId(0), "ORIGINAL", ParaFormat::default()); + a.tables.insert(u64::MAX, Arc::new(ta)); + let mut tb = Table::new(1, 1, 1, 0, 0, 100.); + tb.cells[0].text = Story::with_text(StoryId(0), "INCOMING", ParaFormat::default()); + b.tables.insert(1, Arc::new(tb)); + a.paras[0].table = Some(u64::MAX); + b.paras[0].table = Some(1); + a.check().unwrap(); + b.check().unwrap(); + a.append_story(b); + let contents: Vec<_> = a.tables.values().map(|t| t.cells[0].text.text.clone()).collect(); + assert!(contents.contains(&"ORIGINAL".into()) && contents.contains(&"INCOMING".into()), "table loss: {contents:?}"); +} +#[test] +fn idml_dropcap_detail_preserves_inherited_other_bit() { + use designcraft_doc::{Document, ParaAttrs, ParagraphStyle, build::NewDocument}; + let mut d = Document::new(&NewDocument::default()); + let parent = ParagraphStyle { + name: "Parent".into(), + based_on: None, + next_style: None, + para: ParaAttrs { drop_cap_align_left: Some(false), drop_cap_scale_descenders: Some(true), ..Default::default() }, + chars: Default::default(), + shortcut: String::new(), + }; + let child = ParagraphStyle { + name: "Child".into(), + based_on: Some("Parent".into()), + next_style: None, + para: ParaAttrs { drop_cap_scale_descenders: Some(false), ..Default::default() }, + chars: Default::default(), + shortcut: String::new(), + }; + d.styles_mut().paragraph.extend([parent, child]); + let before = d.styles.resolve_para_style("Child").0; + let bytes = designcraft_idml::export_idml(&d); + let back = designcraft_idml::import_idml(&bytes).unwrap(); + let after = back.styles.resolve_para_style("Child").0; + assert_eq!( + (after.drop_cap_align_left, after.drop_cap_scale_descenders), + (before.drop_cap_align_left, before.drop_cap_scale_descenders), + "IDML export resets inherited align-left bit" + ); +} +#[test] +fn threading_preserves_incoming_text_hyperlink() { + let mut s = setup(); + let (a, sa) = frame(&mut s, "First"); + let (b, sb) = frame(&mut s, "Second"); + s.execute("text.select", &json!({"story":sb,"anchor":0,"focus":6})).unwrap(); + s.execute("hyperlink.create", &json!({"url":"https://example.com"})).unwrap(); + s.execute("text.thread", &json!({"from":a,"to":b})).unwrap(); + let d = &s.doc().unwrap().doc; + let back = designcraft_format::load(&designcraft_format::save(d).unwrap()).unwrap(); + match &back.hyperlinks[0].source { + designcraft_doc::HyperlinkSource::Text { story, start, end } => { + assert_eq!(story.0, sa, "hyperlink points to deleted incoming story"); + assert_eq!((*start, *end), (6, 12)); + } + _ => panic!("wrong source"), + }; +} +#[test] +fn thread_cannot_destroy_body_by_joining_generated_endnotes() { + let mut s = setup(); + let (body, sid) = frame(&mut s, "KEEP THIS BODY"); + s.execute("text.select", &json!({"story":sid,"anchor":14})).unwrap(); + let e = s.execute("endnote.insert", &json!({"text":"a note"})).unwrap(); + let ef = e["frame"].as_u64().unwrap(); + let before = snapshot(&s); + let r = s.execute("text.thread", &json!({"from":ef,"to":body})); + assert!( + r.is_err(), + "thread into generated endnotes accepted: {r:?}; stories: {:?}", + s.doc().unwrap().doc.stories.values().map(|s| &s.text).collect::<Vec<_>>() + ); + assert_eq!(snapshot(&s), before); +} +#[test] +fn generated_endnotes_accept_empty_extensions_but_reject_reverse_body_merge() { + let mut s = setup(); + let (body, sid) = frame(&mut s, "Body"); + s.execute("text.select", &json!({"story":sid,"anchor":4})).unwrap(); + let r = s.execute("endnote.insert", &json!({"text":"note"})).unwrap(); + let ef = r["frame"].as_u64().unwrap(); + let esid = s.doc().unwrap().doc.endnote_story.unwrap(); + let before = snapshot(&s); + assert!(s.execute("text.thread", &json!({"from":body,"to":ef})).is_err()); + assert_eq!(snapshot(&s), before); + s.execute("text.thread", &json!({"from":ef,"rect":[72,72,300,300]})).unwrap(); + assert_eq!(s.doc().unwrap().doc.endnote_story, Some(esid)); + s.execute("text.thread", &json!({"to":ef,"rect":[72,72,300,300]})).unwrap(); + assert_eq!(s.doc().unwrap().doc.endnote_story, Some(esid)); + assert_eq!(s.doc().unwrap().doc.story(esid).unwrap().frames.len(), 3); + assert!(s.doc().unwrap().doc.story(StoryId(sid)).unwrap().text.starts_with("Body")); + s.doc().unwrap().doc.check().unwrap(); +} +#[test] +fn threading_moves_data_placeholders_and_primary_story_with_text() { + use designcraft_doc::{Placeholder, PlaceholderAnchor, PlaceholderRole}; + let mut s = setup(); + let (a, sa) = frame(&mut s, "First"); + let (b, sb) = frame(&mut s, "<<name>>"); + s.edit(|d, _| { + d.settings.primary_story = Some(StoryId(sb)); + d.data_merge.placeholders.push(Placeholder { + id: 123, + source_id: 0, + field: "name".into(), + role: PlaceholderRole::Text, + anchor: PlaceholderAnchor::Text { story: StoryId(sb), start: 0, end: 8 }, + }); + Ok(()) + }) + .unwrap(); + let before = s.doc().unwrap().doc.clone(); + s.execute("text.thread", &json!({"from":a,"to":b})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.settings.primary_story, Some(StoryId(sa))); + assert_eq!(d.data_merge.placeholders.len(), 1); + assert_eq!(d.data_merge.placeholders[0].anchor, PlaceholderAnchor::Text { story: StoryId(sa), start: 6, end: 14 }); + let after = d.clone(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, before); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, after); +} +#[test] +fn append_max_ids_preserves_addressable_footnotes_and_endnotes() { + let mut a = Story::with_text(StoryId(1), "A", ParaFormat::default()); + let mut b = Story::with_text(StoryId(2), "B", ParaFormat::default()); + a.insert_note(1, "original footnote", ParaFormat::default()); + b.insert_note(1, "incoming footnote", ParaFormat::default()); + Arc::make_mut(&mut a.notes[0]).id = u64::MAX; + a.insert_endnote(1, "original endnote", ParaFormat::default()); + b.insert_endnote(1, "incoming endnote", ParaFormat::default()); + Arc::make_mut(&mut a.endnotes[0]).id = u64::MAX; + a.check().unwrap(); + b.check().unwrap(); + a.append_story(b); + a.check().unwrap(); + assert_ne!(a.notes[0].id, a.notes[1].id); + assert_ne!(a.endnotes[0].id, a.endnotes[1].id); + assert_eq!(a.notes[0].text.text, "original footnote"); + assert_eq!(a.endnotes[1].text.text, "incoming endnote"); +} +#[test] +fn locked_layer_and_neighbour_reject_unthread_atomically() { + for lock_layer in [false, true] { + let mut s = setup(); + let (a, _) = frame(&mut s, "A"); + let (b, _) = frame(&mut s, "B"); + s.execute("text.thread", &json!({"from":a,"to":b})).unwrap(); + if lock_layer { + let lid = s.doc().unwrap().active_layer.0; + s.execute("layer.set", &json!({"id":lid,"locked":true})).unwrap(); + } else { + s.execute("object.lock", &json!({"ids":[b]})).unwrap(); + } + let before = snapshot(&s); + assert!(s.execute("text.unthread", &json!({"frame":a})).is_err()); + assert_eq!(snapshot(&s), before); + } +} +#[test] +fn idml_partial_dropcap_bits_resolve_both_style_and_paragraph_inheritance() { + use designcraft_doc::{Document, ParaAttrs, ParagraphStyle, SpreadRef, build::NewDocument}; + use designcraft_geom::Rect; + for parent_align in [false, true] { + for parent_scale in [false, true] { + for local_align in [false, true] { + let mut d = Document::new(&NewDocument::default()); + d.styles_mut().paragraph.push(ParagraphStyle { + name: "Parent".into(), + based_on: None, + next_style: None, + para: ParaAttrs { drop_cap_align_left: Some(parent_align), drop_cap_scale_descenders: Some(parent_scale), ..Default::default() }, + chars: Default::default(), + shortcut: String::new(), + }); + let layer = d.default_layer(); + let (_, sid) = d + .add_text_frame( + SpreadRef::Doc(0), + Rect::new(10., 10., 200., 200.), + layer, + "Body", + ParaFormat { + style: "Parent".into(), + para: ParaAttrs { drop_cap_align_left: Some(local_align), ..Default::default() }, + ..Default::default() + }, + ) + .unwrap(); + let bytes = designcraft_idml::export_idml(&d); + let back = designcraft_idml::import_idml(&bytes).unwrap(); + let st = back.stories.values().find(|s| s.text == "Body").unwrap(); + let actual = back.styles.resolve_para(&st.paras[0]).0; + let expected = d.styles.resolve_para(&d.story(sid).unwrap().paras[0]).0; + assert_eq!( + (actual.drop_cap_align_left, actual.drop_cap_scale_descenders), + (expected.drop_cap_align_left, expected.drop_cap_scale_descenders) + ); + } + } + } +} diff --git a/crates/engine/tests/thread_color_safety.rs b/crates/engine/tests/thread_color_safety.rs new file mode 100644 index 000000000..501df92b9 --- /dev/null +++ b/crates/engine/tests/thread_color_safety.rs @@ -0,0 +1,223 @@ +//! Linked-story threading and text-color transaction safety. +use designcraft_doc::{ParaFormat, Story, StoryId, table::Table}; +use designcraft_engine::Session; +use serde_json::{Value, json}; +use std::sync::Arc; + +fn setup() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s +} +fn frame(s: &mut Session, text: &str) -> (u64, StoryId) { + let f = s.execute("frame.create", &json!({"rect":[36,36,200,100],"content":"text","text":text,"caret":false})).unwrap(); + (f["id"].as_u64().unwrap(), StoryId(f["story"].as_u64().unwrap())) +} +fn child(s: &mut Session, parent: StoryId) -> (u64, StoryId) { + let f = s.execute("story.placeAndLink", &json!({"story":parent.0,"rect":[36,150,200,100]})).unwrap(); + (f["id"].as_u64().unwrap(), StoryId(f["story"].as_u64().unwrap())) +} +fn state(s: &Session) -> Value { + let d = s.doc().unwrap(); + json!({"doc":d.doc,"selection":d.selection,"revision":d.revision,"dirty":d.is_dirty(),"undo":format!("{:?}",d.history),"journal":s.journal}) +} +fn link(s: &mut Session, sid: StoryId) -> Value { + s.execute("story.links", &json!({})).unwrap().as_array().unwrap().iter().find(|l| l["story"] == sid.0).unwrap().clone() +} + +#[test] +fn safety_linked_parent_follows_merge_and_updates_after_native_reopen() { + let mut s = setup(); + let (first, survivor) = frame(&mut s, "PREFIX"); + let (incoming, parent) = frame(&mut s, "PARENT"); + let (_, linked) = child(&mut s, parent); + // A coincident old parent revision must not hide the change of parent identity/content. + let future_rev = s.doc().unwrap().doc.story(survivor).unwrap().rev + 2; + s.edit(|d, _| { + d.story_mut(linked).unwrap().link = Some((parent, future_rev)); + Ok(()) + }) + .unwrap(); + s.execute("text.thread", &json!({"from":first,"to":incoming})).unwrap(); + assert_eq!(link(&mut s, linked)["parent"], survivor.0); + assert_eq!(link(&mut s, linked)["outOfDate"], true); + assert_eq!(s.doc().unwrap().doc.story(linked).unwrap().text, "PARENT"); + let bytes = s.execute("file.serialize", &json!({})).unwrap(); + let mut reopened = Session::new(); + reopened.execute("file.openBytes", &json!({"name":"links.designcraft","base64":bytes["base64"]})).unwrap(); + assert_eq!(link(&mut reopened, linked)["parent"], survivor.0); + reopened.execute("story.updateLink", &json!({"story":linked.0})).unwrap(); + assert_eq!(reopened.doc().unwrap().doc.story(linked).unwrap().text, "PREFIX\nPARENT"); + assert_eq!(link(&mut reopened, linked)["outOfDate"], false); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(link(&mut s, linked)["parent"], parent.0); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(link(&mut s, linked)["parent"], survivor.0); +} + +#[test] +fn safety_linked_child_cannot_merge_body_text_in_either_direction() { + for reverse in [false, true] { + let mut s = setup(); + let (_, parent) = frame(&mut s, "SOURCE"); + let (linked, child) = child(&mut s, parent); + let (body, _) = frame(&mut s, "BODY"); + let (from, to) = if reverse { (body, linked) } else { (linked, body) }; + let before = state(&s); + let error = s.execute("text.thread", &json!({"from":from,"to":to})).unwrap_err(); + assert!(error.to_string().contains("linked story")); + assert_eq!(state(&s), before); + s.execute("story.updateLink", &json!({"story":child.0})).unwrap(); + assert!(s.doc().unwrap().doc.stories.values().any(|st| st.text == "BODY")); + } +} + +#[test] +fn safety_linked_child_allows_empty_extensions_in_both_directions() { + for reverse in [false, true] { + let mut s = setup(); + let (_, parent) = frame(&mut s, "SOURCE"); + let (linked, child) = child(&mut s, parent); + let (empty, _) = frame(&mut s, ""); + let (from, to) = if reverse { (empty, linked) } else { (linked, empty) }; + s.execute("text.thread", &json!({"from":from,"to":to})).unwrap(); + assert_eq!(link(&mut s, child)["parent"], parent.0); + s.execute("story.setText", &json!({"story":parent.0,"text":"UPDATED"})).unwrap(); + assert_eq!(link(&mut s, child)["outOfDate"], true); + s.execute("story.updateLink", &json!({"story":child.0})).unwrap(); + let st = s.doc().unwrap().doc.story(child).unwrap(); + assert_eq!(st.text, "UPDATED"); + assert_eq!(st.frames.len(), 2); + assert_eq!(link(&mut s, child)["outOfDate"], false); + } +} + +#[test] +fn safety_linked_empty_parent_cannot_be_merged_into_its_child() { + let mut s = setup(); + let (parent_frame, parent) = frame(&mut s, ""); + let (linked, _) = child(&mut s, parent); + let before = state(&s); + assert!(s.execute("text.thread", &json!({"from":parent_frame,"to":linked})).is_err()); + assert_eq!(state(&s), before); +} + +#[test] +fn safety_text_color_without_target_is_atomic_and_carets_remain_valid() { + let mut s = setup(); + let f = s.execute("frame.create", &json!({"rect":[36,36,200,100]})).unwrap(); + s.execute("selection.set", &json!({"ids":[f["id"]]})).unwrap(); + let before = state(&s); + assert!(s.execute("object.color", &json!({"color":"#123456","text":true})).is_err()); + assert_eq!(state(&s), before); + for text in ["", "Words"] { + let (_, sid) = frame(&mut s, text); + s.execute("text.select", &json!({"story":sid.0,"anchor":0})).unwrap(); + let color = s.execute("object.color", &json!({"color":"#654321","text":true})).unwrap(); + s.execute("text.insert", &json!({"text":"X"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().format_after(0).over.fill.as_deref(), color["swatch"].as_str()); + } +} + +fn nested_story(depth: usize, name: &str) -> Story { + let mut st = Story::with_text(StoryId(0), "leaf", ParaFormat::default()); + st.chars[0].format.over.fill = Some(name.into()); + for _ in 0..depth { + let mut outer = Story::with_text(StoryId(0), &designcraft_doc::TABLE_ANCHOR.to_string(), ParaFormat::default()); + let mut t = Table::new(1, 1, 1, 0, 0, 100.); + t.cells[0].text = st; + outer.tables.insert(1, Arc::new(t)); + outer.paras[0].table = Some(1); + st = outer; + } + st +} + +#[test] +fn safety_deep_swatch_rename_rejects_atomically_with_pending_color() { + // A small boundary probe, never an attempted stack-overflow reproduction. + for depth in [33, 34] { + let mut s = setup(); + let (_, sid) = frame(&mut s, "root"); + s.execute("text.select", &json!({"story":sid.0,"anchor":0})).unwrap(); + let old = s.execute("object.color", &json!({"color":"#123456"})).unwrap()["swatch"].as_str().unwrap().to_string(); + s.edit(|d, _| { + let st = d.story_mut(sid).unwrap(); + let frames = st.frames.clone(); + *st = nested_story(depth, &old); + st.id = sid; + st.frames = frames; + Ok(()) + }) + .unwrap(); + let pending = s.execute("type.selectionAttrs", &json!({})).unwrap(); + let before = state(&s); + let doc = s.doc().unwrap().doc.clone(); + let error = s.execute("swatch.addToSwatches", &json!({"swatch":old,"name":"Brand"})).unwrap_err(); + assert!(error.to_string().contains("nesting")); + assert!(Arc::ptr_eq(&doc, &s.doc().unwrap().doc)); + assert_eq!(state(&s), before); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap(), pending); + } +} + +#[test] +fn safety_swatch_rename_reaches_last_supported_depth() { + let mut s = setup(); + let (_, sid) = frame(&mut s, "root"); + s.execute("text.select", &json!({"story":sid.0,"anchor":0})).unwrap(); + let old = s.execute("object.color", &json!({"color":"#123456"})).unwrap()["swatch"].as_str().unwrap().to_string(); + s.edit(|d, _| { + let st = d.story_mut(sid).unwrap(); + let frames = st.frames.clone(); + *st = nested_story(32, &old); + st.id = sid; + st.frames = frames; + Ok(()) + }) + .unwrap(); + s.execute("swatch.addToSwatches", &json!({"swatch":old,"name":"Brand"})).unwrap(); + let mut st = s.doc().unwrap().doc.story(sid).unwrap(); + for _ in 0..32 { + st = &st.tables[&1].cells[0].text; + } + assert_eq!(st.format_after(0).over.fill.as_deref(), Some("Brand")); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], "Brand"); +} + +#[test] +fn safety_empty_linked_parent_follows_survivor_only_on_explicit_update() { + for incoming_empty in [false, true] { + for alternate_layout in [false, true] { + let mut s = setup(); + let (empty, parent) = frame(&mut s, ""); + let linked = if alternate_layout { + s.execute("layout.createAlternate", &json!({"name":"Alternate","width":600,"height":800})).unwrap(); + let links = s.execute("story.links", &json!({})).unwrap(); + StoryId(links.as_array().unwrap().iter().find(|l| l["parent"] == parent.0).unwrap()["story"].as_u64().unwrap()) + } else { + child(&mut s, parent).1 + }; + let (body, survivor) = frame(&mut s, "BODY"); + let (from, to) = if incoming_empty { (body, empty) } else { (empty, body) }; + s.execute("text.thread", &json!({"from":from,"to":to})).unwrap(); + assert!(s.doc().unwrap().doc.story(parent).is_none()); + assert_eq!(link(&mut s, linked)["parent"], survivor.0); + assert_eq!(link(&mut s, linked)["outOfDate"], true); + assert_eq!(s.doc().unwrap().doc.story(linked).unwrap().text, ""); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(link(&mut s, linked)["parent"], parent.0); + assert_eq!(s.doc().unwrap().doc.story(linked).unwrap().text, ""); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(link(&mut s, linked)["parent"], survivor.0); + let bytes = s.execute("file.serialize", &json!({})).unwrap(); + let mut reopened = Session::new(); + reopened.execute("file.openBytes", &json!({"name":"empty-parent.designcraft","base64":bytes["base64"]})).unwrap(); + assert_eq!(link(&mut reopened, linked)["outOfDate"], true); + assert_eq!(reopened.doc().unwrap().doc.story(linked).unwrap().text, ""); + reopened.execute("story.updateLink", &json!({"story":linked.0})).unwrap(); + assert_eq!(reopened.doc().unwrap().doc.story(linked).unwrap().text, "BODY"); + assert_eq!(link(&mut reopened, linked)["outOfDate"], false); + } + } +} diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index bbe08af09..bc9c9e65f 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -103,10 +103,14 @@ pub struct FontFace { pub upem: f64, /// Ascender in font units (positive = up). pub ascent: f64, + /// OS/2 typographic ascender in font units (the ascent when the font has no usable one): the + /// Ascent first baseline offset. + pub typo_ascent: f64, /// Descender in font units (positive = down). pub descent: f64, - /// Cap height and x height in font units (estimated from the ascent when the font has no OS/2 - /// values). + /// Cap height and x height in font units (without plausible OS/2 values, the cap height is the + /// top of the H, and either is estimated from the ascent): the Cap Height and x Height first + /// baseline offsets. pub cap_height: f64, pub x_height: f64, pub shaper: harfrust::ShaperData, @@ -118,7 +122,7 @@ pub struct FontFace { /// Basic Multilingual Plane coverage bitset, built on first use. bmp: std::sync::OnceLock<Box<[u64]>>, /// The ideographic em box, read on first use. - em: std::sync::OnceLock<(f64, f64)>, + em: std::sync::OnceLock<((f64, f64), bool)>, /// The font menu group and native family name, read on first use. group: std::sync::OnceLock<(FontGroup, Option<String>)>, /// Format, class and PANOSE numbers (the font menus), read on first use. @@ -287,11 +291,17 @@ impl FontFace { pub fn v_origin(&self, gid: u32) -> f64 { crate::vertical::VMetrics::new(self).origin(GlyphId::new(gid)) } - /// The ideographic em box (top, bottom) in font units, y up (Japanese fonts: typically 880, - /// -120): `BASE` `idtp`/`ideo`, else the OS/2 typo ascender/descender, else the ascender and - /// descender, the last two centred on one em. + /// The ideographic em box (top, bottom) in font units, y up, one em tall (Japanese fonts: + /// typically 880, -120): `BASE` `idtp`/`ideo`; else, for a CJK font, from the OS/2 typo + /// ascender when the typo ascender and descender span one em; else centred on half the cap + /// height (OS/2, else the `H` glyph's top); else 0.88 em above the baseline. pub fn em_box(&self) -> (f64, f64) { - *self.em.get_or_init(|| crate::vertical::em_box(self)) + self.em.get_or_init(|| crate::vertical::em_box(self)).0 + } + /// [`Self::em_box`] when the font's tables give it, `None` for the 0.88 em fallback. + pub fn declared_em_box(&self) -> Option<(f64, f64)> { + let (b, declared) = *self.em.get_or_init(|| crate::vertical::em_box(self)); + declared.then_some(b) } /// The font menu group the font is for and, for a CJK group, its family name in that language /// (see [`crate::FamilyInfo`]); read from the font once. @@ -728,6 +738,7 @@ fn make_face(bytes: FontBytes, source: FontSource, index: u32, family: String, s let settings: Vec<(skrifa::Tag, f32)> = coords.iter().map(|(t, v)| (skrifa::Tag::new(t), *v)).collect(); let location = if coords.is_empty() { Location::default() } else { f.axes().location(settings.iter().copied()) }; let m = f.metrics(Size::unscaled(), &location); + let upem = m.units_per_em.max(1) as f64; let a = f.attributes(); let hb = harfrust::FontRef::from_index(data, index).ok()?; let shaper = harfrust::ShaperData::new(&hb); @@ -745,11 +756,19 @@ fn make_face(bytes: FontBytes, source: FontSource, index: u32, family: String, s style, weight, italic, - upem: m.units_per_em.max(1) as f64, + upem, ascent: m.ascent as f64, + typo_ascent: typo_ascent(&f, &location, upem).unwrap_or(m.ascent as f64), descent: -(m.descent as f64), - cap_height: m.cap_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.72), - x_height: m.x_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.5), + // Fonts with an OS/2 table older than version 2 don't declare these: measure them from + // flat-topped letters before falling back to a share of the ascent, which can be far off + // (drop caps are sized from the cap height). + cap_height: plausible(m.cap_height.map(f64::from), upem) + .or_else(|| measured_top(&f, (&location).into(), &['H', 'I', 'E', 'T'], upem)) + .unwrap_or(m.ascent as f64 * 0.72), + x_height: plausible(m.x_height.map(f64::from), upem) + .or_else(|| measured_top(&f, (&location).into(), &['x', 'z', 'v'], upem)) + .unwrap_or(m.ascent as f64 * 0.5), shaper, coords, location, @@ -764,6 +783,54 @@ fn make_face(bytes: FontBytes, source: FontSource, index: u32, family: String, s }) } +/// A font's height above the baseline (font units) if it is plausible: above 0 and at most 4 em. +pub(crate) fn plausible(v: Option<f64>, upem: f64) -> Option<f64> { + v.filter(|v| *v > 0.0 && *v <= 4.0 * upem) +} + +/// The top of the first of `chars` the font has a non-empty outline for, in font units: the +/// highest point of its unhinted outline at `location`, if [`plausible`]. For flat-topped letters +/// that is the cap height (`H`) or x-height (`x`). +pub(crate) fn measured_top(f: &skrifa::FontRef<'_>, location: LocationRef<'_>, chars: &[char], upem: f64) -> Option<f64> { + struct Top(f32); + impl OutlinePen for Top { + fn move_to(&mut self, _x: f32, y: f32) { + self.0 = self.0.max(y); + } + fn line_to(&mut self, _x: f32, y: f32) { + self.0 = self.0.max(y); + } + fn quad_to(&mut self, _cx: f32, _cy: f32, _x: f32, y: f32) { + self.0 = self.0.max(y); + } + fn curve_to(&mut self, _cx0: f32, _cy0: f32, _cx1: f32, _cy1: f32, _x: f32, y: f32) { + self.0 = self.0.max(y); + } + fn close(&mut self) {} + } + let cmap = f.charmap(); + let glyphs = f.outline_glyphs(); + chars.iter().find_map(|&c| { + let g = glyphs.get(cmap.map(c)?)?; + let mut pen = Top(f32::NEG_INFINITY); + g.draw(DrawSettings::unhinted(Size::unscaled(), location), &mut pen).ok()?; + plausible(Some(f64::from(pen.0)), upem) + }) +} + +/// The OS/2 typographic ascender in font units at `location`, if the font has an OS/2 table and +/// the value is [`plausible`]. +fn typo_ascent(f: &skrifa::FontRef<'_>, location: &Location, upem: f64) -> Option<f64> { + use skrifa::raw::TableProvider; + let mut v = f.os2().ok()?.s_typo_ascender() as f64; + if !location.coords().is_empty() + && let Ok(mvar) = f.mvar() + { + v += mvar.metric_delta(skrifa::raw::tables::mvar::tags::HASC, location.coords()).map_or(0.0, |d| d.to_f64()); + } + plausible(Some(v), upem) +} + /// `family` without the font-format suffix layout apps append when a family is installed in /// several formats (`Minion Pro (OTF)` → `Minion Pro`), if it has one. fn without_format_suffix(family: &str) -> Option<&str> { @@ -886,6 +953,16 @@ impl FontDb { self.scoped(0).styles(family) } + /// The shared fonts' weight and italic of a style of `family` (see [`ScopedFonts::traits_of`]). + pub fn traits_of(&self, family: &str, style: &str) -> (f32, bool) { + self.scoped(0).traits_of(family, style) + } + + /// Style linking among the shared fonts (see [`ScopedFonts::linked_style`]). + pub fn linked_style(&self, family: &str, current: &str, bold: Option<bool>, italic: Option<bool>) -> Option<String> { + self.scoped(0).linked_style(family, current, bold, italic) + } + /// Add a user font (TTF/OTF/TTC bytes). Returns the number of faces added (0 if unparseable or /// every face was already present). pub fn add_font(&self, bytes: Vec<u8>) -> usize { @@ -1279,14 +1356,59 @@ impl ScopedFonts<'_> { /// Style names available for `family` (Regular first, then by weight). pub fn styles(&self, family: &str) -> Vec<String> { - let v = self.exact_styles(family); + self.style_traits(family).into_iter().map(|t| t.2).collect() + } + + /// Weight and italic of a style of `family`: from the font when it is known, else from the + /// style's name. + pub fn traits_of(&self, family: &str, style: &str) -> (f32, bool) { + let base = base_style(style); + self.style_traits(family) + .into_iter() + .find(|t| t.2.eq_ignore_ascii_case(base)) + .map_or((style_weight(base), style_italic(base)), |(italic, weight, _)| (weight, italic)) + } + + /// Style linking: the style of `family` that sets bold (`bold`) and italic (`italic`) as asked + /// and keeps the other from `current` (Bold from Italic is Bold Italic). `None` when the + /// family has no such style. + pub fn linked_style(&self, family: &str, current: &str, bold: Option<bool>, italic: Option<bool>) -> Option<String> { + let (weight, cur_italic) = self.traits_of(family, current); + let want_italic = italic.unwrap_or(cur_italic); + let want_weight = match bold { + Some(true) => 700.0, + Some(false) => 400.0, + None => weight, + }; + // Width words (Condensed, Extended…) of the current style are kept when the family allows. + const WIDTHS: &[&str] = &["condensed", "compressed", "narrow", "extended", "expanded", "wide"]; + let widths = |s: &str| { + let n = norm(s); + WIDTHS.iter().filter(|w| n.contains(*w)).count() + }; + let cur_widths = widths(current); + self.style_traits(family) + .into_iter() + .filter(|(i, w, _)| *i == want_italic && bold.is_none_or(|b| b == (*w >= 600.0))) + .min_by(|a, b| { + let key = |t: &(bool, f32, String)| ((t.1 - want_weight).abs(), usize::from(widths(&t.2) != cur_widths), t.2.len()); + let (ka, kb) = (key(a), key(b)); + ka.0.total_cmp(&kb.0).then(ka.1.cmp(&kb.1)).then(ka.2.cmp(&kb.2)) + }) + .map(|t| t.2) + } + + /// `(italic, weight, name)` of each style of `family` (the document's, loaded, or installed), + /// Regular first, then by weight. + fn style_traits(&self, family: &str) -> Vec<(bool, f32, String)> { + let v = self.exact_style_traits(family); match without_format_suffix(family) { - Some(base) if v.is_empty() => self.exact_styles(base), + Some(base) if v.is_empty() => self.exact_style_traits(base), _ => v, } } - fn exact_styles(&self, family: &str) -> Vec<String> { + fn exact_style_traits(&self, family: &str) -> Vec<(bool, f32, String)> { let mut v: Vec<(bool, f32, String)> = self .own() .iter() @@ -1302,7 +1424,7 @@ impl ScopedFonts<'_> { } v.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1)).then(a.2.cmp(&b.2))); v.dedup_by(|a, b| a.2 == b.2); - v.into_iter().map(|t| t.2).collect() + v } /// Resolve a family + style to a face, falling back to the closest style of the family, then to @@ -1617,3 +1739,54 @@ mod tests_docfonts; #[cfg(test)] #[path = "tests_vertical.rs"] mod tests_vertical; + +#[cfg(test)] +mod tests_heights { + use super::*; + use crate::testing::{font_mapping, with_table_u16}; + + fn face_of(bytes: Vec<u8>) -> FontFace { + make_face(FontBytes::Owned(Arc::new(bytes)), FontSource::Memory, 0, "Test".into(), "Regular".into(), Vec::new()).unwrap() + } + + /// `font` with OS/2 sxHeight and sCapHeight set to 0 (undeclared). + fn undeclared(font: Vec<u8>) -> Vec<u8> { + with_table_u16(with_table_u16(font, b"OS/2", 86, 0).unwrap(), b"OS/2", 88, 0).unwrap() + } + + /// The top of Source Sans 3's glyph for `c` from its bounding box (font units). + fn bbox_top(c: char) -> f64 { + let f = skrifa::FontRef::new(BUNDLED[0]).unwrap(); + f.glyph_metrics(Size::unscaled(), LocationRef::default()).bounds(f.charmap().map(c).unwrap()).unwrap().y_max as f64 + } + + /// A font that doesn't declare its cap height and x-height gets them measured from its + /// flat-topped letters, also when it lacks the first choices `H` and `x`; a font that + /// declares them keeps them. + #[test] + fn undeclared_heights_are_measured_from_outlines() { + let glyphs = [('H', 'H'), ('x', 'x'), ('E', 'E'), ('z', 'z')]; + for (glyphs, cap, x) in [(&glyphs[..], 'H', 'x'), (&glyphs[2..], 'E', 'z')] { + let face = face_of(undeclared(font_mapping("Heights", glyphs).unwrap())); + let (cap, x) = (bbox_top(cap), bbox_top(x)); + assert!(cap > x && x > 0.0); + assert!((face.cap_height - cap).abs() <= 1.0, "{glyphs:?}: cap height {} vs {cap}", face.cap_height); + assert!((face.x_height - x).abs() <= 1.0, "{glyphs:?}: x-height {} vs {x}", face.x_height); + } + let declared = face_of(font_mapping("Heights", &glyphs).unwrap()); + assert_eq!((declared.cap_height, declared.x_height), (660.0, 486.0), "Source Sans 3 Regular's OS/2 values"); + // Declared values win over the outlines: `H` drawn as `x` doesn't lower the cap height. + let face = face_of(font_mapping("Heights", &[('H', 'x'), ('x', 'H')]).unwrap()); + assert_eq!((face.cap_height, face.x_height), (declared.cap_height, declared.x_height)); + } + + /// Without any of the letters, or with only empty outlines, the heights fall back to a share + /// of the ascent. + #[test] + fn heights_without_measurable_letters_fall_back_to_the_ascent() { + for glyphs in [&[('a', 'a')][..], &[('H', ' '), ('x', ' ')]] { + let face = face_of(undeclared(font_mapping("Heights", glyphs).unwrap())); + assert_eq!((face.cap_height, face.x_height), (face.ascent * 0.72, face.ascent * 0.5), "{glyphs:?}"); + } + } +} diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index dd4583701..5ff395df8 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -349,6 +349,22 @@ mod tests { } } + #[test] + fn style_linking_finds_bold_and_italic_in_the_family() { + let db = FontDb::with_font_dirs(vec![]); + let link = |cur: &str, bold: Option<bool>, italic: Option<bool>| db.linked_style("Source Serif 4", cur, bold, italic); + assert_eq!(link("Regular", Some(true), None).as_deref(), Some("Bold")); + assert_eq!(link("Italic", Some(true), None).as_deref(), Some("Bold Italic"), "keeps the italic"); + assert_eq!(link("Bold", None, Some(true)).as_deref(), Some("Bold Italic"), "keeps the weight"); + assert_eq!(link("Bold Italic", Some(false), None).as_deref(), Some("Italic")); + assert_eq!(link("Bold Italic", None, Some(false)).as_deref(), Some("Bold")); + assert_eq!(link("Semibold", Some(false), None).as_deref(), Some("Regular")); + assert_eq!(db.traits_of("Source Serif 4", "Bold Italic"), (700.0, true)); + // Source Sans 3 is bundled without a bold italic. + assert_eq!(db.linked_style("Source Sans 3", "Italic", Some(true), None), None); + assert_eq!(db.linked_style("No Such Family", "Regular", Some(true), None), None); + } + #[test] fn shaping_produces_clusters_and_advances() { let face = FontDb::global().face(DEFAULT_FAMILY, "Regular"); diff --git a/crates/fonts/src/tests_vertical.rs b/crates/fonts/src/tests_vertical.rs index 729ba3b74..4f1e82c20 100644 --- a/crates/fonts/src/tests_vertical.rs +++ b/crates/fonts/src/tests_vertical.rs @@ -6,7 +6,7 @@ use skrifa::raw::TableProvider; use super::*; -use crate::testing::{font_mapping, font_with, with_table_len, with_table_u16, with_vmtx, with_vorg}; +use crate::testing::{font_mapping, font_with, with_code_pages, with_table_len, with_table_u16, with_vmtx, with_vorg}; use crate::{ShapeContext, feature, shape_vertical, shape_with_context}; /// Text in Japanese. @@ -111,14 +111,36 @@ fn fonts_without_vmtx_use_the_em_box() { assert_eq!(face.em_box(), (830.0, -170.0)); assert_eq!(face.v_advance(gid), face.upem); assert_eq!(face.v_origin(gid), 830.0); - // Without BASE either: the OS/2 typo ascender and descender (1000, -326), centred on one em. + // Without BASE either: the typo ascender and descender (1000, -326) span more than one em, so + // the em box is centred on half the OS/2 cap height (660). let face = face_of(with_table_len(font_with("No Base", &['一']).unwrap(), b"BASE", 0).unwrap()); - assert_eq!(face.em_box(), (837.0, -163.0)); - // Without OS/2: the ascender and descender, the same way. - let face = face_of(with_table_len(with_table_len(font_with("No OS2", &['一']).unwrap(), b"BASE", 0).unwrap(), b"OS/2", 0).unwrap()); - let (top, bottom) = face.em_box(); - assert_eq!(top - bottom, face.upem); - assert!((top + bottom - (face.ascent - face.descent)).abs() < 1e-9, "{top} {bottom}"); + assert_eq!(face.em_box(), (830.0, -170.0)); + assert_eq!(face.declared_em_box(), Some((830.0, -170.0))); + // Typo ascender and descender one em apart: a CJK font (JIS code page) tops at the typo + // ascender; a Latin one (Source Sans 3's own code pages) still centres on the cap height. + let typo = |family: &str, code_pages: Option<u32>| { + let font = with_table_u16(font_with(family, &['一']).unwrap(), b"OS/2", 68, 860).unwrap(); + let font = with_table_u16(font, b"OS/2", 70, u16::from_be_bytes((-140i16).to_be_bytes())).unwrap(); + let font = match code_pages { + Some(r1) => with_code_pages(font, r1, 0).unwrap(), + None => font, + }; + face_of(with_table_len(font, b"BASE", 0).unwrap()) + }; + let face = typo("Typo Em CJK", Some(1 << 17)); + assert_ne!(face.menu_group().0, crate::FontGroup::Western); + assert_eq!(face.em_box(), (860.0, -140.0)); + let face = typo("Typo Em Latin", None); + assert_eq!(face.menu_group().0, crate::FontGroup::Western); + assert_eq!(face.em_box(), (830.0, -170.0)); + // Without OS/2: centred on half the H glyph's height (656). + let no_os2 = |font: Vec<u8>| face_of(with_table_len(with_table_len(font, b"BASE", 0).unwrap(), b"OS/2", 0).unwrap()); + let face = no_os2(font_mapping("No OS2", &[('一', 'X'), ('H', 'H')]).unwrap()); + assert_eq!(face.em_box(), (828.0, -172.0)); + // Without an H either: 0.88 em above the baseline, which the font doesn't declare. + let face = no_os2(font_with("No OS2 No H", &['一']).unwrap()); + assert_eq!(face.em_box(), (880.0, -120.0)); + assert_eq!(face.declared_em_box(), None); } #[test] diff --git a/crates/fonts/src/vertical.rs b/crates/fonts/src/vertical.rs index d360b6f2b..6483f4165 100644 --- a/crates/fonts/src/vertical.rs +++ b/crates/fonts/src/vertical.rs @@ -1,6 +1,6 @@ //! Vertical metrics for upright glyphs in vertical text: advances down the line (`vmtx`), vertical //! origins (`VORG`, else `vmtx` and the glyph's top) and the ideographic em box (`BASE`, else -//! OS/2). Fonts without them fall back to the em box: one em down the line, the origin at its top. +//! OS/2 metrics). Fonts without them fall back to the em box: one em down the line, the origin at its top. use skrifa::instance::Size; use skrifa::metrics::GlyphMetrics; @@ -53,25 +53,30 @@ impl<'a> VMetrics<'a> { } } -/// The ideographic em box (top, bottom) in font units, y up: `BASE` `idtp`/`ideo`, else the OS/2 -/// typo ascender and descender, else the ascender and descender; the last two centred on one em. -pub(crate) fn em_box(face: &FontFace) -> (f64, f64) { +/// The ideographic em box (top, bottom) in font units, y up, one em tall: `BASE` `idtp`/`ideo`; +/// else, for a CJK font (its [`crate::FontGroup`] isn't Western), topped by the OS/2 typo ascender +/// when the typo ascender and descender span exactly one em; else centred on half the cap height +/// (OS/2 `sCapHeight`, else the `H` glyph's top); else 0.88 em above the baseline. The flag is +/// false for that last fallback. +pub(crate) fn em_box(face: &FontFace) -> ((f64, f64), bool) { let upem = face.upem; - let font = face.skrifa(); - if let Some(b) = font.as_ref().and_then(|f| base_em_box(f, upem)) { - return b; + let fallback = ((upem * 0.88, upem * -0.12), false); + let Some(font) = face.skrifa() else { return fallback }; + if let Some(b) = base_em_box(&font, upem) { + return (b, true); } - let centred = |asc: f64, desc: f64| { - let span = asc - desc; - (span > 0.0 && span.is_finite()).then(|| { - let top = asc - (span - upem) / 2.0; - (top, top - upem) - }) - }; - font.and_then(|f| f.os2().ok()) - .and_then(|t| centred(f64::from(t.s_typo_ascender()), f64::from(t.s_typo_descender()))) - .or_else(|| centred(face.ascent, -face.descent)) - .unwrap_or((upem * 0.88, upem * -0.12)) + // Latin fonts with a one-em typo box (DejaVu) still centre on the cap height. + let cjk = face.menu_group().0 != crate::FontGroup::Western; + let typo_top = font.os2().ok().filter(|_| cjk).and_then(|t| { + let (asc, desc) = (f64::from(t.s_typo_ascender()), f64::from(t.s_typo_descender())); + (asc - desc == upem && asc > 0.0 && asc <= upem).then_some(asc) + }); + let top = typo_top.or_else(|| { + let declared = font.metrics(Size::unscaled(), face.location()).cap_height.map(f64::from); + let cap = crate::fontdb::plausible(declared, upem).or_else(|| crate::fontdb::measured_top(&font, face.location(), &['H'], upem))?; + Some((upem + cap) / 2.0) + }); + top.map_or(fallback, |top| ((top, top - upem), true)) } /// The em box from the `BASE` table's horizontal axis: the Han script's values, else the default diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 408bb3ccc..35edb48b8 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -6,7 +6,8 @@ //! the ones the document references (see [`Document::compact_assets`]) //! - `meta.json`: format version and generator //! -//! Older single-file JSON documents (assets as base64 in `assetData`) still open. +//! Older single-file JSON documents (assets as base64 in `assetData`) still open. Documents +//! from older format versions are upgraded on load so they lay out as they did. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] @@ -14,11 +15,11 @@ use std::collections::{HashMap, HashSet}; use std::io::{Cursor, Read, Write}; use std::sync::Arc; -use designcraft_doc::{AssetId, Document}; +use designcraft_doc::{AssetId, Document, ParaAttrs, Story}; use serde_json::{Value, json}; pub const MIME: &str = "application/vnd.designcraft+zip"; -pub const VERSION: u32 = 1; +pub const VERSION: u32 = 2; #[derive(Debug, thiserror::Error)] pub enum FormatError { @@ -93,16 +94,16 @@ fn load_zip(bytes: &[u8]) -> Result<Document, FormatError> { f.read_to_end(&mut v).ok()?; Some(v) }; - if let Some(meta) = read(&mut z, "meta.json") - && let Ok(m) = serde_json::from_slice::<Value>(&meta) - && let Some(v) = m.get("version").and_then(Value::as_u64) - && v as u32 > VERSION - { - return Err(FormatError::TooNew(v as u32)); + let version = read(&mut z, "meta.json") + .and_then(|meta| serde_json::from_slice::<Value>(&meta).ok()) + .and_then(|m| m.get("version").and_then(Value::as_u64)) + .map_or(1, |v| u32::try_from(v).unwrap_or(u32::MAX)); + if version > VERSION { + return Err(FormatError::TooNew(version)); } let json = read(&mut z, "document.json").ok_or_else(|| FormatError::NotOurs("missing document.json".into()))?; let mut v: Value = serde_json::from_slice(&json).map_err(|e| FormatError::NotOurs(e.to_string()))?; - upgrade(&mut v); + upgrade_json(&mut v); let mut doc: Document = serde_json::from_value(v).map_err(|e| FormatError::NotOurs(e.to_string()))?; let names: Vec<String> = z.file_names().map(str::to_string).collect(); // Files saved with one copy per placement: identical copies share one buffer as they are @@ -124,14 +125,62 @@ fn load_zip(bytes: &[u8]) -> Result<Document, FormatError> { Arc::make_mut(a).data = data; } } + upgrade(&mut doc, version); doc.merge_duplicate_assets(); doc.check().map_err(|e| FormatError::NotOurs(e.to_string()))?; Ok(doc) } +/// Bring a document saved in format `from` up to [`VERSION`]. +fn upgrade(doc: &mut Document, from: u32) { + if from < 2 { + // Format 1 laid out an unset Keep Lines Together mode as all lines in paragraph, and drop + // caps without Align Left Edge. + pin_para_default(doc, |a| &mut a.keep_all_lines, true); + pin_para_default(doc, |a| &mut a.drop_cap_align_left, false); + } +} + +/// Pin a paragraph attribute to the value an older format resolved it to where nothing set it: +/// at the start of each paragraph style chain that leaves it unset, and on paragraphs whose style +/// is missing. +fn pin_para_default<T: Clone>(doc: &mut Document, field: fn(&mut ParaAttrs) -> &mut Option<T>, old: T) { + let unset = |a: &ParaAttrs| field(&mut a.clone()).is_none(); + let roots: std::collections::HashSet<String> = doc + .styles + .paragraph + .iter() + .filter_map(|s| { + let chain = doc.styles.para_chain(&s.name); + if chain.iter().all(|c| unset(&c.para)) { chain.first().map(|c| c.name.clone()) } else { None } + }) + .collect(); + if !roots.is_empty() { + for s in doc.styles_mut().paragraph.iter_mut().filter(|s| roots.contains(&s.name)) { + *field(&mut s.para) = Some(old.clone()); + } + } + let styles = doc.styles.clone(); + let mut pin = |st: &mut Story| { + for p in st.paras.iter_mut().filter(|p| styles.para(&p.style).is_none()) { + let v = field(&mut p.para); + if v.is_none() { + *v = Some(old.clone()); + } + } + }; + for st in doc.stories.values_mut() { + let st = Arc::make_mut(st); + st.for_each_text_mut(&mut pin); + for n in &mut st.endnotes { + Arc::make_mut(n).text.for_each_text_mut(&mut pin); + } + } +} + fn load_legacy_json(bytes: &[u8]) -> Result<Document, FormatError> { let mut v: Value = serde_json::from_slice(bytes).map_err(|e| FormatError::NotOurs(e.to_string()))?; - upgrade(&mut v); + upgrade_json(&mut v); let mut doc: Document = serde_json::from_value(v.clone()).map_err(|e| FormatError::NotOurs(e.to_string()))?; if let Some(data) = v.get("assetData").and_then(Value::as_object) { for (k, s) in data { @@ -142,6 +191,7 @@ fn load_legacy_json(bytes: &[u8]) -> Result<Document, FormatError> { } } } + upgrade(&mut doc, 1); doc.merge_duplicate_assets(); doc.check().map_err(|e| FormatError::NotOurs(e.to_string()))?; Ok(doc) @@ -150,7 +200,7 @@ fn load_legacy_json(bytes: &[u8]) -> Result<Document, FormatError> { /// Bring older documents' JSON up to the current model. Vertical Type was a text frame option /// (`content.options.vertical`); it is the story's direction, which a story takes from its first /// frame. -fn upgrade(doc: &mut Value) { +fn upgrade_json(doc: &mut Value) { let mut vertical = HashSet::new(); vertical_frames(doc, &mut vertical, 0); if vertical.is_empty() { @@ -293,6 +343,122 @@ mod tests { assert_eq!(back.stories, d.stories); } + /// `doc` as format 1 wrote it. + fn save_v1(doc: &Document) -> Vec<u8> { + let mut z = zip::ZipWriter::new(Cursor::new(Vec::new())); + z.start_file("meta.json", zip::write::SimpleFileOptions::default()).unwrap(); + z.write_all(br#"{"format": "designcraft", "version": 1}"#).unwrap(); + z.start_file("document.json", zip::write::SimpleFileOptions::default()).unwrap(); + z.write_all(&serde_json::to_vec(doc).unwrap()).unwrap(); + z.finish().unwrap().into_inner() + } + + fn para_style(name: &str, based_on: Option<&str>, keep_all_lines: Option<bool>) -> designcraft_doc::ParagraphStyle { + designcraft_doc::ParagraphStyle { + name: name.into(), + based_on: based_on.map(str::to_string), + next_style: None, + para: designcraft_doc::ParaAttrs { keep_lines_together: Some(true), keep_all_lines, ..Default::default() }, + chars: Default::default(), + shortcut: String::new(), + } + } + + /// Format 1 resolved an unset Keep Lines Together mode as all lines: those documents still + /// do after loading, wherever the mode comes from. New documents keep lines at start/end. + #[test] + fn format_1_keep_lines_without_mode_stays_all_lines() { + let mut d = Document::new(&NewDocument::default()); + let st = d.styles_mut(); + st.paragraph.push(para_style("Root", None, None)); + st.paragraph.push(para_style("Child", Some("Root"), None)); + st.paragraph.push(para_style("Explicit", None, Some(false))); + st.paragraph.push(para_style("Under explicit", Some("Explicit"), None)); + st.paragraph.push(para_style("Loop A", Some("Loop B"), None)); + st.paragraph.push(para_style("Loop B", Some("Loop A"), None)); + let lid = d.default_layer(); + let names = [designcraft_doc::story::BASIC_PARAGRAPH, "Root", "Child", "Explicit", "Under explicit", "Loop A", "Loop B", "Missing"]; + let text = names.join("\n"); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 200.0, 200.0), lid, &text, ParaFormat::default()).unwrap(); + { + let s = d.story_mut(sid).unwrap(); + for (p, name) in s.paras.iter_mut().zip(names) { + p.style = name.into(); + p.para.keep_lines_together = Some(true); + } + s.insert_note(1, "Note", ParaFormat { style: "Missing".into(), ..Default::default() }); + } + let back = load(&save_v1(&d)).unwrap(); + let resolve = |f: &ParaFormat| back.styles.resolve_para(f).0; + let story = back.story(sid).unwrap(); + for (p, name) in story.paras.iter().zip(names) { + let pp = resolve(p); + assert!(pp.keep_lines_together); + assert_eq!(pp.keep_all_lines, !name.contains("xplicit"), "{name}"); + } + assert!(resolve(&story.notes[0].text.paras[0]).keep_all_lines, "footnote with a missing style"); + assert!(back.styles.resolve_para_style("Loop A").0.keep_all_lines && back.styles.resolve_para_style("Loop B").0.keep_all_lines); + // Pinned where the chains start; explicit and inheriting styles are left as they were. + assert_eq!(back.styles.para("Root").unwrap().para.keep_all_lines, Some(true)); + assert_eq!(back.styles.para("Child").unwrap().para.keep_all_lines, None); + assert_eq!(back.styles.para("Under explicit").unwrap().para.keep_all_lines, None); + // The current format keeps the new default: at start/end of paragraph. + let now = load(&save(&d).unwrap()).unwrap(); + let p = &now.story(sid).unwrap().paras[0]; + assert!(!now.styles.resolve_para(p).0.keep_all_lines); + assert!(!designcraft_doc::ParaProps::default().keep_all_lines); + } + + /// Format 1 wrote paragraph rules, text frame options and numbered lists in full, so their + /// old defaults (black rules, auto-size from the top centre, lists continuing across stories) + /// load as saved. New documents take InDesign's. + #[test] + fn format_1_rules_frames_and_lists_load_as_saved() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let old_rule = designcraft_doc::Rule { on: true, color: designcraft_doc::color::swatch::BLACK.into(), ..Default::default() }; + let (fid, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 200.0, 200.0), lid, "Ruled", ParaFormat::default()).unwrap(); + d.story_mut(sid).unwrap().paras[0].para.rule_above = Some(old_rule.clone()); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.auto_size_ref = 1; + d.settings.lists.push(designcraft_doc::NumberedList { name: "Steps".into(), continue_across_stories: true }); + let back = load(&save_v1(&d)).unwrap(); + assert_eq!(back.story(sid).unwrap().paras[0].para.rule_above, Some(old_rule)); + assert_eq!(back.item(fid).unwrap().text_frame().unwrap().options.auto_size_ref, 1); + assert!(back.settings.lists[0].continue_across_stories); + assert_eq!(designcraft_doc::Rule::default().color, designcraft_doc::TEXT_COLOR); + assert_eq!(designcraft_doc::TextFrameOptions::default().auto_size_ref, 4); + assert!(!designcraft_doc::NumberedList::default().continue_across_stories); + } + + /// Format 1 set drop caps without Align Left Edge: those documents still do where nothing set + /// it. New documents align the drop cap's left edge. + #[test] + fn format_1_drop_caps_stay_unaligned() { + let mut d = Document::new(&NewDocument::default()); + let st = d.styles_mut(); + st.paragraph.push(para_style("Opener", None, None)); + st.paragraph.push(designcraft_doc::ParagraphStyle { + para: designcraft_doc::ParaAttrs { drop_cap_align_left: Some(true), ..Default::default() }, + ..para_style("Aligned", None, None) + }); + let lid = d.default_layer(); + let (_, sid) = + d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 200.0, 200.0), lid, "One\nTwo\nThree", ParaFormat::default()).unwrap(); + { + let s = d.story_mut(sid).unwrap(); + for (p, name) in s.paras.iter_mut().zip(["Opener", "Aligned", "Missing"]) { + p.style = name.into(); + p.para.drop_cap_lines = Some(2); + p.para.drop_cap_chars = Some(1); + } + } + let back = load(&save_v1(&d)).unwrap(); + let aligned: Vec<bool> = back.story(sid).unwrap().paras.iter().map(|p| back.styles.resolve_para(p).0.drop_cap_align_left).collect(); + assert_eq!(aligned, [false, true, false]); + let now = load(&save(&d).unwrap()).unwrap(); + assert!(now.styles.resolve_para(&now.story(sid).unwrap().paras[0]).0.drop_cap_align_left); + } + #[test] fn saves_hold_each_used_file_once() { let mut d = Document::new(&NewDocument::default()); diff --git a/crates/geom/src/corners.rs b/crates/geom/src/corners.rs index 6b0c9cb0a..3eecacdc8 100644 --- a/crates/geom/src/corners.rs +++ b/crates/geom/src/corners.rs @@ -221,6 +221,32 @@ mod tests { assert!((a - std::f64::consts::PI * 100.0).abs() < 1.0, "{a}"); } + /// InDesign draws a 28.35 pt radius on a 51.02 pt tall frame as 25.51 (half the shorter side), + /// as a circular arc (handles 0.5523 × the radius). + #[test] + fn radius_is_clamped_to_half_the_shorter_side() { + let r = rectangle(Rect::new(0.0, 0.0, 200.0, 51.02)); + let bp = apply(&r, &CornerOptions::uniform(CornerShape::Rounded, 28.35)); + let half = 51.02 / 2.0; + let mut arcs = 0; + let mut last = Point::ZERO; + for el in bp.elements() { + match *el { + kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) => last = p, + kurbo::PathEl::CurveTo(c1, c2, p) => { + assert!(((p - last).x.abs() - half).abs() < 1e-9 && ((p - last).y.abs() - half).abs() < 1e-9, "{last:?} → {p:?}"); + assert!(((c1 - last).hypot() - half * crate::shapes::KAPPA).abs() < 1e-9); + assert!(((c2 - p).hypot() - half * crate::shapes::KAPPA).abs() < 1e-9); + arcs += 1; + last = p; + } + _ => {} + } + } + assert_eq!(arcs, 4); + assert!((crate::shapes::KAPPA - 0.5523).abs() < 1e-4); + } + fn nearest(bp: &BezPath, p: Point) -> f64 { use kurbo::ParamCurveNearest; bp.segments().map(|s| s.nearest(p, 1e-6).distance_sq.sqrt()).fold(f64::INFINITY, f64::min) diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index d01abe9e7..b7e5ca3c6 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -866,7 +866,8 @@ impl<'a> Ex<'a> { } (ps.para.clone(), ps.chars.clone()) }; - self.para_attrs(&mut el, &mut props, &pa); + let parent = (ps.name != designcraft_doc::NO_PARA_STYLE).then(|| ps.based_on.as_deref().unwrap_or(designcraft_doc::NO_PARA_STYLE)); + self.para_attrs(&mut el, &mut props, &pa, parent); self.char_attrs(&mut el, &mut props, &ca); pg.insert(&ps.name, with_props(el, props)); } @@ -1244,7 +1245,9 @@ impl<'a> Ex<'a> { } } - fn para_attrs(&mut self, el: &mut El, props: &mut Vec<El>, a: &ParaAttrs) { + /// Write paragraph attributes `a`, set over the resolved paragraph style `parent` (`None` for the + /// root style). + fn para_attrs(&mut self, el: &mut El, props: &mut Vec<El>, a: &ParaAttrs, parent: Option<&str>) { if let Some(v) = &a.mojikumi { props.push(p("Mojikumi", if v.starts_with("MojikumiTable/") { "object" } else { "enumeration" }, v)); } @@ -1292,8 +1295,31 @@ impl<'a> Ex<'a> { None => el.set("NumberingContinue", "true"), } } - // Nested and GREP styles: property lists of records. - if let Some(list) = &a.nested_styles { + // Nested and GREP styles: property lists of records. The drop cap's character style is the + // nested style list's leading "through 1 Dropcap" entry (IDML has no attribute for it): a + // drop cap style set here (`[None]` included) restyles that entry, and a real one is + // inserted where there is none. Without a list of its own, the inherited list is written + // when that is what carries the drop cap style, since any list replaces the inherited one. + let real = |s: &str| !s.is_empty() && s != st::NO_CHAR_STYLE; + let cap = a.drop_cap_style.as_deref().filter(|s| !s.is_empty()); + let mut list = a.nested_styles.clone(); + if let (Some(cap), None) = (cap, &list) { + let inherited = parent.map(|n| self.d.styles.resolve_para_style(n).0); + let inherited_cap = inherited + .as_ref() + .is_some_and(|pp| real(&pp.drop_cap_style) || pp.nested_styles.first().is_some_and(|ns| ns.is_drop_cap() && real(&ns.style))); + if real(cap) || inherited_cap { + list = Some(inherited.map(|pp| pp.nested_styles).unwrap_or_default()); + } + } + if let (Some(cap), Some(list)) = (cap, list.as_mut()) { + match list.first_mut() { + Some(ns) if ns.is_drop_cap() => ns.style = cap.to_string(), + _ if real(cap) => list.insert(0, designcraft_doc::NestedStyle::drop_cap(cap)), + _ => {} + } + } + if let Some(list) = &list { let mut l = El::new("AllNestedStyles").attr("type", "list"); for ns in list { let (ty, delim) = names::nested_until_out(&ns.until); @@ -1377,6 +1403,12 @@ impl<'a> Ex<'a> { n!(space_after, "SpaceAfter"); n!(drop_cap_lines, "DropCapLines"); n!(drop_cap_chars, "DropCapCharacters"); + if a.drop_cap_align_left.is_some() || a.drop_cap_scale_descenders.is_some() { + let d = parent.map(|name| self.d.styles.resolve_para_style(name).0).unwrap_or_default(); + let bits = u32::from(a.drop_cap_align_left.unwrap_or(d.drop_cap_align_left)) + | u32::from(a.drop_cap_scale_descenders.unwrap_or(d.drop_cap_scale_descenders)) << 1; + el.set("DropcapDetail", bits); + } if let Some(g) = a.grid_align { match g { designcraft_doc::GridAlign::None => el.set("GridAlignment", "None"), @@ -1528,7 +1560,11 @@ impl<'a> Ex<'a> { el.set(&format!("{k}Offset"), num(r.offset)); el.set(&format!("{k}LeftIndent"), num(r.left_indent)); el.set(&format!("{k}RightIndent"), num(r.right_indent)); - props.push(p(&format!("{k}Color"), "object", self.sw(&r.color))); + props.push(if r.color == designcraft_doc::TEXT_COLOR { + p(&format!("{k}Color"), "string", designcraft_doc::TEXT_COLOR) + } else { + p(&format!("{k}Color"), "object", self.sw(&r.color)) + }); } fn stroke_attrs(&self, el: &mut El, s: &designcraft_doc::Stroke) { @@ -2404,7 +2440,7 @@ impl<'a> Ex<'a> { fn psr_el(&mut self, pf: &ParaFormat) -> El { let mut el = El::new("ParagraphStyleRange").attr("AppliedParagraphStyle", names::style_self("ParagraphStyle", PARA_BUILTINS, &pf.style)); let mut props = Vec::new(); - self.para_attrs(&mut el, &mut props, &pf.para); + self.para_attrs(&mut el, &mut props, &pf.para, Some(&pf.style)); self.char_attrs(&mut el, &mut props, &pf.chars); with_props(el, props) } diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 59ca44be7..1a4fa65e3 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -253,9 +253,12 @@ fn parse_point(s: Option<&str>) -> Option<Point> { (v.len() >= 2).then(|| Point::new(v[0], v[1])) } -/// IDML tint (percent, `-1` = default) → 0..1. +/// InDesign's corner size where a file gives a corner shape without one. +const INDESIGN_CORNER_SIZE: f64 = 12.0; + +/// IDML tint (percent; `-1` = the colour's own tint, 100 %) → 0..1. fn tint(v: Option<f64>) -> Option<f32> { - v.map(|t| if t < 0.0 { 1.0 } else { (t / 100.0) as f32 }) + v.map(|t| if t < 0.0 || t.is_nan() { 1.0 } else { (t / 100.0).min(1.0) as f32 }) } impl<'r> Importer<'r> { @@ -788,10 +791,10 @@ impl<'r> Importer<'r> { /// A swatch reference (Self id) → our swatch name; unnamed colours become value-named swatches. /// A `TextFramePreference`, with its column rule colour resolved to a swatch. - fn frame_options(&mut self, e: &El) -> TextFrameOptions { - let mut o = text_frame_options(e); - if let Some(r) = e.get("ColumnRuleStrokeColor") { - o.column_rule_color = self.swatch_ref(r); + fn frame_options(&mut self, prefs: &[&El]) -> TextFrameOptions { + let mut o = text_frame_options(prefs); + if let Some(r) = Prefs(prefs).prop("ColumnRuleStrokeColor") { + o.column_rule_color = self.swatch_ref(&r); } o } @@ -852,7 +855,7 @@ impl<'r> Importer<'r> { let name = e.get("Name").unwrap_or("").to_string(); if !name.is_empty() && !name.starts_with("$ID/") && !self.lists.iter().any(|l| l.name == name) { self.lists - .push(designcraft_doc::NumberedList { name, continue_across_stories: e.get("ContinueNumbersAcrossStories") != Some("false") }); + .push(designcraft_doc::NumberedList { name, continue_across_stories: e.get("ContinueNumbersAcrossStories") == Some("true") }); } } let mut paras: Vec<(String, El)> = Vec::new(); @@ -1000,7 +1003,7 @@ impl<'r> Importer<'r> { .then(|| e.get("AppliedParagraphStyle").map(|r| self.para_style_ref(r))) .flatten(); let text_frame = if e.get("EnableTextFrameGeneralOptions") == Some("true") { - e.find("TextFramePreference").map(|t| self.frame_options(t)) + e.find("TextFramePreference").map(|t| self.frame_options(&[t])) } else { None }; @@ -1118,7 +1121,7 @@ impl<'r> Importer<'r> { o.span_columns = e.boolean("EnableStraddling").unwrap_or(false); o.rule.on = e.boolean("RuleOn").unwrap_or(true); o.rule.weight = e.num("RuleLineWeight").unwrap_or(1.0); - o.rule.tint = (e.num("RuleTint").unwrap_or(100.0) / 100.0).clamp(0.0, 1.0) as f32; + o.rule.tint = tint(e.num("RuleTint")).unwrap_or(1.0); o.rule.left_indent = e.num("RuleLeftIndent").unwrap_or(0.0); o.rule.width = e.num("RuleWidth").unwrap_or(72.0); o.rule.offset = e.num("RuleOffset").unwrap_or(0.0); @@ -1202,7 +1205,7 @@ impl<'r> Importer<'r> { // An explicit Text Color resets an inherited swatch; absence still inherits. // Resolve the sentinel before swatch_ref, which maps unknown names to [None]. let color = e.prop(&format!("{k}Color")).map(|r| if r.trim() == "Text Color" { String::new() } else { self.swatch_ref(r.trim()) }); - let tint = e.num(&format!("{k}Tint")).filter(|v| *v >= 0.0).map(|v| (v / 100.0) as f32); + let tint = tint(e.num(&format!("{k}Tint"))); if k == "Underline" { a.underline_weight = num("Weight").map(Some); a.underline_offset = num("Offset").map(Some); @@ -1336,6 +1339,9 @@ impl<'r> Importer<'r> { } }) .collect(); + // IDML has no drop cap style attribute: the drop cap's character style is the list's + // leading "through 1 Dropcap" nested style. + a.drop_cap_style = v.first().filter(|ns| ns.is_drop_cap()).map(|ns| ns.style.clone()); a.nested_styles = Some(v); } if let Some(l) = e.prop_el("AllNestedLineStyles") { @@ -1364,6 +1370,12 @@ impl<'r> Importer<'r> { a.space_after = e.num("SpaceAfter"); a.drop_cap_lines = u("DropCapLines"); a.drop_cap_chars = u("DropCapCharacters"); + // DropcapDetail bits: 1 = Align Left Edge, 2 = Scale for Descenders. + if let Some(v) = e.num("DropcapDetail").filter(|v| v.is_finite() && *v >= 0.0) { + let bits = v as u32; + a.drop_cap_align_left = Some(bits & 1 != 0); + a.drop_cap_scale_descenders = Some(bits & 2 != 0); + } if let Some(g) = e.prop("GridAlignment") { a.grid_align = Some(if g == "None" { GridAlign::None @@ -1475,7 +1487,9 @@ impl<'r> Importer<'r> { } if let Some(c) = e.prop(&format!("{k}Color")) { let c = c.trim(); - if c.contains('/') { + if c == designcraft_doc::TEXT_COLOR { + r.color = c.to_string(); + } else if c.contains('/') { r.color = self.swatch_ref(c); } } @@ -2006,25 +2020,33 @@ impl<'r> Importer<'r> { }) } - /// Resolve an item attribute, falling back to its object style chain. - fn attr_or_style(&self, e: &El, k: &str) -> Option<String> { - if let Some(v) = e.get(k) { - return Some(v.to_string()); - } + /// An item's object style chain: its applied style, the styles that one is based on, then + /// `[None]` (cycle-safe). + fn object_style_chain(&self, e: &El) -> Vec<&El> { + let mut chain: Vec<&El> = Vec::new(); let mut os = e.get("AppliedObjectStyle").map(str::to_string); - let mut depth = 0; while let Some(id) = os { - depth += 1; - if depth > 16 { + let Some(s) = self.object_els.get(&id) else { break }; + if chain.len() >= 16 || chain.iter().any(|c| std::ptr::eq(*c, s)) { break; } - let Some(s) = self.object_els.get(&id) else { break }; - if let Some(v) = s.get(k) { - return Some(v.to_string()); - } - os = s.prop("BasedOn").map(|b| if b.starts_with("ObjectStyle/") { b } else { format!("ObjectStyle/{}", names::escape_id(&b)) }); + chain.push(s); + os = s.prop("BasedOn").map(|b| { + let b = b.trim(); + if b.starts_with("ObjectStyle/") { b.to_string() } else { format!("ObjectStyle/{}", names::escape_id(b)) } + }); } - None + if let Some(none) = self.object_els.get("ObjectStyle/$ID/[None]") + && !chain.iter().any(|c| std::ptr::eq(*c, none)) + { + chain.push(none); + } + chain + } + + /// Resolve an item attribute, falling back to its object style chain. + fn attr_or_style(&self, e: &El, k: &str) -> Option<String> { + e.get(k).or_else(|| self.object_style_chain(e).into_iter().find_map(|s| s.get(k))).map(str::to_string) } fn stroke_from(&mut self, e: &El, item: Option<&El>) -> Stroke { @@ -2130,19 +2152,21 @@ impl<'r> Importer<'r> { it.stroke.weight = 1.0; } } - // Corners. - let cn = crate::export::corner_names(&it.path); - // The legacy all-corners attributes apply only when no per-corner attribute is present. - let per_corner = cn.iter().any(|n| e.get(&format!("{n}CornerOption")).is_some()); - let legacy = if per_corner { (None, None) } else { (e.get("CornerOption"), e.num("CornerRadius")) }; - let mut corners = CornerOptions::default(); - for (i, n) in cn.iter().enumerate() { - let shape = e.get(&format!("{n}CornerOption")).or(legacy.0).map(names::corner_in).unwrap_or_default(); - let size = e.num(&format!("{n}CornerRadius")).or(legacy.1).unwrap_or(0.0); - corners.corners[i] = Corner { shape, size }; - } - if !corners.is_none() { - it.corners = corners; + // Corners: each corner's shape and radius come from the item, then its object style chain; + // at each level a per-corner attribute wins over the legacy all-corners one. + if tag != "Group" { + let levels: Vec<&El> = std::iter::once(e).chain(self.object_style_chain(e)).collect(); + let corner = + |n: &str, what: &str| levels.iter().find_map(|s| s.get(&format!("{n}Corner{what}")).or_else(|| s.get(&format!("Corner{what}")))); + let mut corners = CornerOptions::default(); + for (i, n) in crate::export::corner_names(&it.path).iter().enumerate() { + let shape = corner(n, "Option").map(names::corner_in).unwrap_or_default(); + let size = corner(n, "Radius").and_then(|v| v.trim().parse::<f64>().ok()).filter(|v| v.is_finite()).unwrap_or(INDESIGN_CORNER_SIZE); + corners.corners[i] = Corner { shape, size }; + } + if !corners.is_none() { + it.corners = corners; + } } // Transparency. if let Some(t) = e.find("TransparencySetting") { @@ -2227,7 +2251,13 @@ impl<'r> Importer<'r> { sid } }; - let mut options = e.find("TextFramePreference").map(|t| self.frame_options(t)).unwrap_or_default(); + // Options the frame doesn't set come from its object style chain. + let prefs: Vec<&El> = std::iter::once(e).chain(self.object_style_chain(e)).filter_map(|s| s.find("TextFramePreference")).collect(); + let mut options = text_frame_options(&prefs); + // `prefs` borrows the object styles, so the rule colour's swatch is resolved after it. + if let Some(r) = Prefs(&prefs).prop("ColumnRuleStrokeColor") { + options.column_rule_color = self.swatch_ref(&r); + } if let Some(g) = e.find("BaselineFrameGridOption") && g.get("UseCustomBaselineFrameGrid") == Some("true") { @@ -2403,12 +2433,35 @@ impl<'r> Importer<'r> { self.spreads.iter().chain(self.parents.iter()).find_map(|s| find(&s.items, id)) } + /// A nested style list replaces the one a style or paragraph inherits, and with it the drop + /// cap style (the list's leading "through 1 Dropcap" entry, read into the drop cap style). A + /// list of its own without that entry clears an inherited drop cap style. + fn clear_inherited_drop_cap_styles(&mut self) { + let clears = |a: &designcraft_doc::ParaAttrs| a.drop_cap_style.is_none() && a.nested_styles.is_some(); + let cleared: Vec<String> = self + .styles + .paragraph + .iter() + .filter(|s| clears(&s.para) && inherits_drop_cap_style(&self.styles, s.based_on.as_deref())) + .map(|s| s.name.clone()) + .collect(); + for name in cleared { + if let Some(s) = self.styles.para_mut(&name) { + s.para.drop_cap_style = Some(st::NO_CHAR_STYLE.into()); + } + } + for story in self.stories.values_mut() { + clear_story_drop_cap_styles(&self.styles, story, 0); + } + } + fn finish(mut self, root: &El) -> Result<Document> { let title = root.get("Name").map(|n| n.trim_end_matches(".indd").to_string()).filter(|n| !n.is_empty()).unwrap_or_else(|| "Untitled".into()); if self.spreads.is_empty() { return Err(IdmlError::Invalid("document has no spreads".into())); } self.settings.lists = std::mem::take(&mut self.lists); + self.clear_inherited_drop_cap_styles(); // Make sure built-in paragraph styles exist and are first. let d = Document { title, @@ -2525,24 +2578,26 @@ fn ace_char(v: &str) -> Option<char> { } fn margins_of(m: &El) -> (Margins, Columns) { - let g = |k: &str, d: f64| m.num(k).unwrap_or(d); + let g = |k: &str, d: f64| m.num(k).filter(|v| v.is_finite()).unwrap_or(d); ( Margins { top: g("Top", 36.0), bottom: g("Bottom", 36.0), inside: g("Left", 36.0), outside: g("Right", 36.0) }, - Columns { count: g("ColumnCount", 1.0).max(1.0) as u32, gutter: g("ColumnGutter", 12.0), positions: None }, + Columns { count: g("ColumnCount", 1.0).clamp(1.0, 216.0) as u32, gutter: g("ColumnGutter", 12.0), positions: None }, ) } -fn text_frame_options(e: &El) -> TextFrameOptions { +/// Text Frame Options from `TextFramePreference` elements: each attribute from the first that sets it. +fn text_frame_options(prefs: &[&El]) -> TextFrameOptions { + let e = Prefs(prefs); let mut o = TextFrameOptions::default(); if let Some(v) = e.num("TextColumnCount") { - o.columns = v.max(1.0) as u32; + o.columns = v.clamp(1.0, 216.0) as u32; } if let Some(v) = e.num("TextColumnGutter") { o.gutter = v; } - o.columns_kind = if e.get("UseFixedColumnWidth") == Some("true") { + o.columns_kind = if e.prop("UseFixedColumnWidth").as_deref().map(str::trim) == Some("true") { ColumnsKind::FixedWidth - } else if e.get("UseFlexibleColumnWidth") == Some("true") { + } else if e.prop("UseFlexibleColumnWidth").as_deref().map(str::trim) == Some("true") { ColumnsKind::FlexibleWidth } else { ColumnsKind::FixedNumber @@ -2552,31 +2607,33 @@ fn text_frame_options(e: &El) -> TextFrameOptions { { o.column_width = v; } - o.balance_columns = e.get("VerticalBalanceColumns") == Some("true"); - if let Some(inset) = inset_spacing(e) { + o.balance_columns = e.prop("VerticalBalanceColumns").as_deref().map(str::trim) == Some("true"); + if let Some(p) = prefs.iter().find(|p| p.prop("InsetSpacing").is_some()) + && let Some(inset) = inset_spacing(p) + { o.inset = inset; } - if let Some(v) = e.get("VerticalJustification") { - o.vertical_justification = names::vj_in(v); + if let Some(v) = e.prop("VerticalJustification") { + o.vertical_justification = names::vj_in(v.trim()); } if let Some(v) = e.num("VerticalThreshold") { o.vj_paragraph_spacing_limit = v; } - if let Some(v) = e.get("FirstBaselineOffset") { - o.first_baseline = names::first_baseline_in(v); + if let Some(v) = e.prop("FirstBaselineOffset") { + o.first_baseline = names::first_baseline_in(v.trim()); } if let Some(v) = e.num("MinimumFirstBaselineOffset") { o.first_baseline_min = v; } - o.ignore_wrap = e.get("IgnoreWrap") == Some("true"); - if let Some(v) = e.get("AutoSizingType") { - o.auto_size = names::auto_size_in(v); + o.ignore_wrap = e.prop("IgnoreWrap").as_deref().map(str::trim) == Some("true"); + if let Some(v) = e.prop("AutoSizingType") { + o.auto_size = names::auto_size_in(v.trim()); } - if let Some(v) = e.get("AutoSizingReferencePoint") { - o.auto_size_ref = names::REF_POINTS.iter().position(|p| *p == v).unwrap_or(1) as u8; + if let Some(v) = e.prop("AutoSizingReferencePoint") { + o.auto_size_ref = names::REF_POINTS.iter().position(|p| *p == v.trim()).unwrap_or(1) as u8; } // Column rules (the colour is a swatch reference: see `Importer::frame_options`). - o.column_rule = e.get("ColumnRuleOverride") == Some("true"); + o.column_rule = e.prop("ColumnRuleOverride").as_deref().map(str::trim) == Some("true"); let finite = |k: &str| e.num(k).filter(|v| v.is_finite()); if let Some(v) = finite("ColumnRuleStrokeWidth") { o.column_rule_weight = v.clamp(0.0, 1000.0); @@ -2596,6 +2653,60 @@ fn text_frame_options(e: &El) -> TextFrameOptions { o } +/// Deepest table-in-cell or note nesting the drop cap pass walks into. +const MAX_STORY_NESTING: usize = 32; + +/// [`Importer::clear_inherited_drop_cap_styles`] for one story's paragraphs and those of the table +/// cells, footnotes and endnotes in it. +fn clear_story_drop_cap_styles(styles: &Styles, story: &mut Story, depth: usize) { + for p in &mut story.paras { + if p.para.drop_cap_style.is_none() && p.para.nested_styles.is_some() && inherits_drop_cap_style(styles, Some(&p.style)) { + p.para.drop_cap_style = Some(st::NO_CHAR_STYLE.into()); + } + } + if depth >= MAX_STORY_NESTING { + return; + } + for table in story.tables.values_mut() { + for cell in &mut Arc::make_mut(table).cells { + clear_story_drop_cap_styles(styles, &mut cell.text, depth + 1); + } + } + for note in story.notes.iter_mut().chain(story.endnotes.iter_mut()) { + clear_story_drop_cap_styles(styles, &mut Arc::make_mut(note).text, depth + 1); + } +} + +/// Does paragraph style `name` (and its based-on chain) set a drop cap character style? A style +/// with a nested style list of its own and no drop cap style has none. +fn inherits_drop_cap_style<'a>(styles: &'a Styles, mut name: Option<&'a str>) -> bool { + let mut seen = HashSet::new(); + while let Some(n) = name { + let Some(s) = styles.para(n).filter(|_| seen.insert(n)) else { break }; + if let Some(c) = s.para.drop_cap_style.as_deref() { + return !c.is_empty() && c != st::NO_CHAR_STYLE; + } + if s.para.nested_styles.is_some() { + return false; + } + name = s.based_on.as_deref(); + } + false +} + +/// Property lookup over preference levels: the nearest level wins, attributes before children. +struct Prefs<'a>(&'a [&'a El]); + +impl Prefs<'_> { + fn prop(&self, k: &str) -> Option<String> { + self.0.iter().find_map(|e| e.prop(k)) + } + fn num(&self, k: &str) -> Option<f64> { + // The nearest preference wins; within it, attributes precede property children. + self.prop(k).and_then(|v| v.trim().parse::<f64>().ok()).filter(|v| v.is_finite()) + } +} + /// A scalar applies to every edge; a list is top, left, bottom, right. Keep the /// existing property-element precedence over the legacy scalar attribute, even /// when the property is malformed. Finite negative insets remain supported. @@ -2652,3 +2763,244 @@ pub(crate) fn uri_to_path(uri: &str) -> String { }; percent_decode(rest) } + +#[cfg(test)] +mod numeric_property_tests { + use super::*; + + fn pref(attrs: &[(&str, &str)], props: &[(&str, &str)]) -> El { + let mut e = El::new("TextFramePreference"); + for (key, value) in attrs { + e.set(key, *value); + } + let mut p = El::new("Properties"); + for (key, value) in props { + p.push(El::new(key).text(*value)); + } + e.push(p); + e + } + + #[test] + fn numeric_property_forms_import_every_frame_option() { + let local = pref( + &[("UseFixedColumnWidth", "true")], + &[ + ("TextColumnCount", "3"), + ("TextColumnGutter", "17"), + ("TextColumnFixedWidth", "123"), + ("VerticalThreshold", "19"), + ("MinimumFirstBaselineOffset", "8"), + ("ColumnRuleStrokeWidth", "2"), + ("ColumnRuleStrokeTint", "25"), + ("ColumnRuleOffset", "3"), + ("ColumnRuleTopInset", "4"), + ("ColumnRuleBottomInset", "5"), + ], + ); + let o = text_frame_options(&[&local]); + assert_eq!((o.columns, o.gutter, o.column_width), (3, 17., 123.)); + assert_eq!((o.vj_paragraph_spacing_limit, o.first_baseline_min), (19., 8.)); + assert_eq!( + (o.column_rule_weight, o.column_rule_tint, o.column_rule_offset, o.column_rule_top_inset, o.column_rule_bottom_inset), + (2., 0.25, 3., 4., 5.) + ); + } + + #[test] + fn numeric_property_precedence_is_nearest_level_then_attribute() { + let local = pref(&[("TextColumnCount", "4")], &[("TextColumnCount", "5"), ("TextColumnGutter", "17")]); + let child = pref( + &[("TextColumnGutter", "90"), ("MinimumFirstBaselineOffset", "8")], + &[("MinimumFirstBaselineOffset", "9"), ("VerticalThreshold", "19")], + ); + let base = pref(&[("VerticalThreshold", "99")], &[("ColumnRuleStrokeWidth", "2")]); + let o = text_frame_options(&[&local, &child, &base]); + assert_eq!((o.columns, o.gutter, o.first_baseline_min, o.vj_paragraph_spacing_limit, o.column_rule_weight), (4, 17., 8., 19., 2.)); + // An explicit invalid value still masks inherited values, as attribute lookup did. + let invalid = pref(&[("TextColumnCount", "bad")], &[("TextColumnCount", "6")]); + assert_eq!(text_frame_options(&[&invalid, &local]).columns, TextFrameOptions::default().columns); + } +} + +#[cfg(test)] +mod frame_preference_safety_tests { + use super::*; + + fn pref(attrs: &[(&str, &str)], props: &[(&str, &str)]) -> El { + let mut e = El::new("TextFramePreference"); + for (key, value) in attrs { + e.set(key, *value); + } + let mut p = El::new("Properties"); + for (key, value) in props { + p.push(El::new(key).text(*value)); + } + e.push(p); + e + } + + #[test] + fn frame_preference_nonfinite_numbers_keep_finite_defaults() { + const KEYS: [&str; 10] = [ + "TextColumnCount", + "TextColumnGutter", + "TextColumnFixedWidth", + "VerticalThreshold", + "MinimumFirstBaselineOffset", + "ColumnRuleStrokeWidth", + "ColumnRuleStrokeTint", + "ColumnRuleOffset", + "ColumnRuleTopInset", + "ColumnRuleBottomInset", + ]; + let fallback = pref(&[("UseFixedColumnWidth", "true")], &[]); + let expected = text_frame_options(&[&fallback]); + for value in ["NaN", "inf", "-inf", "1e309"] { + for attribute in [true, false] { + let values: Vec<_> = KEYS.iter().map(|key| (*key, value)).collect(); + let mut invalid = if attribute { pref(&values, &[]) } else { pref(&[], &values) }; + invalid.set("UseFixedColumnWidth", "true"); + let inherited = pref(&[], &[("TextColumnCount", "7"), ("TextColumnGutter", "9")]); + assert_eq!(text_frame_options(&[&invalid, &inherited]), expected, "{value}, attribute={attribute}"); + } + } + } + + #[test] + fn frame_preference_property_booleans_enums_and_precedence() { + let base = pref( + &[ + ("UseFixedColumnWidth", "true"), + ("UseFlexibleColumnWidth", "false"), + ("VerticalBalanceColumns", "false"), + ("IgnoreWrap", "false"), + ("ColumnRuleOverride", "false"), + ("VerticalJustification", "TopAlign"), + ("FirstBaselineOffset", "AscentOffset"), + ("AutoSizingType", "Off"), + ("AutoSizingReferencePoint", "TopCenterPoint"), + ], + &[], + ); + let local = pref( + &[], + &[ + ("UseFixedColumnWidth", "false"), + ("UseFlexibleColumnWidth", "true"), + ("VerticalBalanceColumns", "true"), + ("IgnoreWrap", "true"), + ("ColumnRuleOverride", "true"), + ("VerticalJustification", "BottomAlign"), + ("FirstBaselineOffset", "FixedHeight"), + ("AutoSizingType", "HeightAndWidth"), + ("AutoSizingReferencePoint", "BottomRightPoint"), + ], + ); + let o = text_frame_options(&[&local, &base]); + assert_eq!(o.columns_kind, ColumnsKind::FlexibleWidth); + assert!(o.balance_columns && o.ignore_wrap && o.column_rule); + assert_eq!(o.vertical_justification, designcraft_doc::VerticalJustification::Bottom); + assert_eq!(o.first_baseline, designcraft_doc::FirstBaseline::Fixed); + assert_eq!(o.auto_size, designcraft_doc::AutoSize::HeightAndWidth); + assert_eq!(o.auto_size_ref, 8); + let fixed = pref(&[], &[("UseFixedColumnWidth", "true")]); + assert_eq!(text_frame_options(&[&fixed, &local, &base]).columns_kind, ColumnsKind::FixedWidth); + // At one level an attribute wins over a conflicting property child, for every key. + let mut same_level = local.clone(); + for (key, value) in &base.attrs { + same_level.set(key, value); + } + assert_eq!(text_frame_options(&[&same_level, &local]), text_frame_options(&[&base])); + // Malformed nearer values mask ancestors and use the normal per-field fallback. + let mut malformed = El::new("TextFramePreference"); + for (key, _) in &base.attrs { + malformed.set(key, "invalid"); + } + assert_eq!(text_frame_options(&[&malformed, &local]), TextFrameOptions { auto_size_ref: 1, ..Default::default() }); + } +} + +#[cfg(test)] +mod page_column_import_safety_tests { + use super::*; + + #[test] + fn frame_preference_page_columns_are_bounded_and_finite() { + for (raw, expected) in [("4000000000", 216), ("1e300", 216), ("-10", 1), ("NaN", 1), ("inf", 1)] { + let m = El::new("MarginPreference") + .attr("ColumnCount", raw) + .attr("ColumnGutter", "NaN") + .attr("Top", "inf") + .attr("Bottom", "-inf") + .attr("Left", "NaN") + .attr("Right", "1e309"); + let (margins, columns) = margins_of(&m); + assert_eq!(columns.count, expected); + assert_eq!(columns.gutter, 12.); + assert_eq!(margins, Margins::uniform(36.)); + } + } +} + +#[cfg(test)] +mod frame_preference_scalar_tests { + use super::*; + + #[test] + fn frame_preference_scalar_whitespace() { + let values = [ + ("UseFixedColumnWidth", "true"), + ("UseFlexibleColumnWidth", "false"), + ("VerticalBalanceColumns", "true"), + ("IgnoreWrap", "true"), + ("ColumnRuleOverride", "true"), + ("VerticalJustification", "BottomAlign"), + ("FirstBaselineOffset", "FixedHeight"), + ("AutoSizingType", "HeightAndWidth"), + ("AutoSizingReferencePoint", "BottomRightPoint"), + ]; + let preference = |properties: bool, padded: bool, fixed: bool| { + let mut e = El::new("TextFramePreference"); + let mut props = El::new("Properties"); + for (key, raw) in values { + let raw = match key { + "UseFixedColumnWidth" if !fixed => "false", + "UseFlexibleColumnWidth" if !fixed => "true", + _ => raw, + }; + let value = if padded { format!("\n {raw}\t ") } else { raw.to_string() }; + if properties { + props.push(El::new(key).text(value)); + } else { + e.set(key, value); + } + } + e.push(props); + e + }; + for fixed in [false, true] { + let clean = preference(false, false, fixed); + let expected = text_frame_options(&[&clean]); + assert_eq!(expected.columns_kind, if fixed { ColumnsKind::FixedWidth } else { ColumnsKind::FlexibleWidth }); + assert!(expected.balance_columns && expected.ignore_wrap && expected.column_rule); + assert_eq!(expected.vertical_justification, designcraft_doc::VerticalJustification::Bottom); + assert_eq!(expected.first_baseline, designcraft_doc::FirstBaseline::Fixed); + assert_eq!(expected.auto_size, designcraft_doc::AutoSize::HeightAndWidth); + assert_eq!(expected.auto_size_ref, 8); + for properties in [false, true] { + let padded = preference(properties, true, fixed); + assert_eq!(text_frame_options(&[&padded]), expected); + } + // A malformed nearest attribute masks both its own valid child and farther values. + let mut invalid = preference(true, true, fixed); + for (key, _) in values { + invalid.set(key, " invalid "); + } + assert_eq!(text_frame_options(&[&invalid, &clean]), TextFrameOptions { auto_size_ref: 1, ..Default::default() }); + } + // Object identities use the raw property lookup, not scalar whitespace normalization. + let exact = El::new("TextFramePreference").child(El::new("Properties").child(El::new("ColumnRuleStrokeColor").text(" Color/Named "))); + assert_eq!(Prefs(&[&exact]).prop("ColumnRuleStrokeColor").as_deref(), Some(" Color/Named ")); + } +} diff --git a/crates/idml/src/names.rs b/crates/idml/src/names.rs index 1df0f56d5..5a47f194b 100644 --- a/crates/idml/src/names.rs +++ b/crates/idml/src/names.rs @@ -424,10 +424,11 @@ pub fn first_baseline_out(f: FirstBaseline) -> &'static str { FirstBaseline::Leading => "LeadingOffset", FirstBaseline::XHeight => "XHeight", FirstBaseline::Fixed => "FixedHeight", + FirstBaseline::EmboxHeight => "EmboxHeight", } } pub fn first_baseline_in(s: &str) -> FirstBaseline { - [FirstBaseline::CapHeight, FirstBaseline::Leading, FirstBaseline::XHeight, FirstBaseline::Fixed] + [FirstBaseline::CapHeight, FirstBaseline::Leading, FirstBaseline::XHeight, FirstBaseline::Fixed, FirstBaseline::EmboxHeight] .into_iter() .find(|m| first_baseline_out(*m) == s) .unwrap_or(FirstBaseline::Ascent) @@ -447,6 +448,7 @@ pub fn auto_size_in(s: &str) -> AutoSize { .find(|m| auto_size_out(*m) == s) .unwrap_or(AutoSize::Off) } + /// Auto-size reference points in our 0..9 order (row-major, top-left first). pub const REF_POINTS: [&str; 9] = [ "TopLeftPoint", @@ -635,6 +637,7 @@ pub fn nested_until_out(u: &designcraft_doc::NestedUntil) -> (&'static str, Stri N::ForcedLineBreak => e("ForcedLineBreak"), N::EmSpace => e("EmSpaces"), N::EnSpace => e("EnSpaces"), + N::Dropcap => e("Dropcap"), N::Chars(c) => ("string", c.clone()), } } @@ -653,6 +656,7 @@ pub fn nested_until_in(ty: &str, v: &str) -> designcraft_doc::NestedUntil { "ForcedLineBreak" => N::ForcedLineBreak, "EmSpaces" => N::EmSpace, "EnSpaces" => N::EnSpace, + "Dropcap" => N::Dropcap, _ => N::Words, } } diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index a2ee8f2d5..57801e34f 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -32,6 +32,20 @@ fn zip_files_plain() -> Vec<u8> { w.finish().unwrap().into_inner() } +/// The text of every part of the package `bytes` whose name starts with `prefix`. +fn zip_text(bytes: &[u8], prefix: &str) -> String { + use std::io::Read; + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut out = String::new(); + for i in 0..z.len() { + let mut f = z.by_index(i).unwrap(); + if f.name().starts_with(prefix) { + f.read_to_string(&mut out).unwrap(); + } + } + out +} + /// A hand-written, minimal IDML document: one facing-pages spread with a right page, a /// threaded story across two frames, a tint and a grouped paragraph style. const DESIGNMAP: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?> @@ -366,6 +380,17 @@ fn imports_scalar_inset_properties_like_lists_and_attributes() { assert_eq!(frame_inset(&round_trip), [9.0; 4]); } +#[test] +fn em_box_first_baseline_offset_round_trips() { + use designcraft_doc::FirstBaseline; + let first = |d: &Document| d.spreads[0].items[0].text_frame().unwrap().options.first_baseline; + let d = inset_fixture(r#"<TextFramePreference FirstBaselineOffset="EmboxHeight"/>"#, ""); + assert_eq!(first(&d), FirstBaseline::EmboxHeight); + assert_eq!(first(&import_idml(&export_idml(&d)).unwrap()), FirstBaseline::EmboxHeight); + let unknown = inset_fixture(r#"<TextFramePreference FirstBaselineOffset="SomethingElse"/>"#, ""); + assert_eq!(first(&unknown), FirstBaseline::Ascent); +} + #[test] fn inset_property_keeps_precedence_over_attribute() { for (property, expected) in [ @@ -1456,6 +1481,628 @@ fn list_numbering_format_expression_and_bullet_import_and_round_trip() { check(&import_idml(&export_idml(&d)).unwrap()); } +fn fixture_with_styles(styles: &str) -> Vec<u8> { + zip_files(&[ + ("designmap.xml", DESIGNMAP), + ("Resources/Graphic.xml", GRAPHIC), + ("Resources/Styles.xml", styles), + ("Resources/Preferences.xml", PREFS), + ("MasterSpreads/MasterSpread_m1.xml", MASTER), + ("Spreads/Spread_sp1.xml", SPREAD), + ("Stories/Story_s1.xml", STORY), + ]) +} + +/// Paragraph styles `Body` (based on the root) and `Loose` (no BasedOn), both with `attrs`. +fn keep_styles(attrs: &str) -> String { + format!( + r#"<?xml version="1.0" encoding="UTF-8"?> +<idPkg:Styles xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0"> + <RootParagraphStyleGroup Self="rp"> + <ParagraphStyle Self="ParagraphStyle/$ID/[No paragraph style]" Name="$ID/[No paragraph style]" PointSize="12"/> + <ParagraphStyle Self="ParagraphStyle/Body" Name="Body" {attrs}> + <Properties><BasedOn type="object">ParagraphStyle/$ID/[No paragraph style]</BasedOn></Properties> + </ParagraphStyle> + <ParagraphStyle Self="ParagraphStyle/Loose" Name="Loose" {attrs}/> + </RootParagraphStyleGroup> +</idPkg:Styles>"# + ) +} + +/// Keep Lines Together whose mode no style sets is InDesign's default: at start/end of paragraph. +/// An explicit mode still wins. +#[test] +fn keep_lines_mode_defaults_to_start_and_end() { + let keep = r#"KeepLinesTogether="true" KeepFirstLines="2" KeepLastLines="2""#; + for (attrs, all) in [(keep.to_string(), false), (format!(r#"{keep} KeepAllLinesTogether="true""#), true)] { + let d = import_idml_with(&fixture_with_styles(&keep_styles(&attrs)), &|_| None).unwrap(); + for name in ["Body", "Loose"] { + let (pp, _) = d.styles.resolve_para_style(name); + assert_eq!((pp.keep_lines_together, pp.keep_all_lines, pp.keep_first, pp.keep_last), (true, all, 2, 2), "{name}: {attrs}"); + } + } +} + +type Resolved = (designcraft_doc::ParaProps, designcraft_doc::CharProps); + +/// (IDML attribute, InDesign's default as IDML writes it, another value, the resolved field, +/// its value for InDesign's default, its value for the other value). +type AttrRow = (&'static str, &'static str, &'static str, fn(&Resolved) -> String, String, String); + +macro_rules! attr_rows { + ($($attr:literal: $default:literal => $dv:expr, $other:literal => $ov:expr, |$r:ident| $field:expr;)*) => { + vec![$(($attr, $default, $other, (|$r: &Resolved| format!("{:?}", $field)) as fn(&Resolved) -> String, format!("{:?}", $dv), format!("{:?}", $ov)),)*] + }; +} + +/// InDesign's defaults for a new document's [No Paragraph Style], for every paragraph and +/// character attribute the importer reads (except the font family: see below). +fn indesign_text_defaults() -> Vec<AttrRow> { + use designcraft_doc::{Align, Capitalization, Composer, Digits, GridAlign, Leading, ListType, Position, StartParagraph, TextDirection}; + attr_rows! { + "Justification": "LeftAlign" => Align::Left, "CenterAlign" => Align::Center, |r| r.0.align; + "ParagraphDirection": "LeftToRightDirection" => TextDirection::LeftToRight, "RightToLeftDirection" => TextDirection::RightToLeft, |r| r.0.direction; + "LeftIndent": "0" => 0.0, "6" => 6.0, |r| r.0.left_indent; + "RightIndent": "0" => 0.0, "6" => 6.0, |r| r.0.right_indent; + "FirstLineIndent": "0" => 0.0, "6" => 6.0, |r| r.0.first_line_indent; + "LastLineIndent": "0" => 0.0, "6" => 6.0, |r| r.0.last_line_indent; + "SpaceBefore": "0" => 0.0, "6" => 6.0, |r| r.0.space_before; + "SpaceAfter": "0" => 0.0, "6" => 6.0, |r| r.0.space_after; + "DropCapLines": "0" => 0, "3" => 3, |r| r.0.drop_cap_lines; + "DropCapCharacters": "0" => 0, "1" => 1, |r| r.0.drop_cap_chars; + "DropcapDetail": "1" => (true, false), "2" => (false, true), |r| (r.0.drop_cap_align_left, r.0.drop_cap_scale_descenders); + "GridAlignment": "None" => GridAlign::None, "AlignToBaseline" => GridAlign::AllLines, |r| r.0.grid_align; + "Composer": "HL Composer" => Composer::Paragraph, "HL Single" => Composer::SingleLine, |r| r.0.composer; + "Hyphenation": "true" => true, "false" => false, |r| r.0.hyphenate; + "HyphenateWordsLongerThan": "5" => 5, "7" => 7, |r| r.0.hyph_min_word; + "HyphenateAfterFirst": "2" => 2, "3" => 3, |r| r.0.hyph_after_first; + "HyphenateBeforeLast": "2" => 2, "3" => 3, |r| r.0.hyph_before_last; + "HyphenateLadderLimit": "3" => 3, "0" => 0, |r| r.0.hyph_limit; + "HyphenationZone": "36" => 36.0, "18" => 18.0, |r| r.0.hyph_zone; + "HyphenateCapitalizedWords": "true" => true, "false" => false, |r| r.0.hyph_capitalized; + "HyphenateLastWord": "true" => true, "false" => false, |r| r.0.hyph_last_word; + "HyphenateAcrossColumns": "true" => true, "false" => false, |r| r.0.hyph_across_column; + "HyphenWeight": "5" => 0.5, "9" => 0.9, |r| r.0.hyph_weight; + "MinimumWordSpacing": "80" => 0.8, "70" => 0.7, |r| r.0.word_space_min; + "DesiredWordSpacing": "100" => 1.0, "90" => 0.9, |r| r.0.word_space_desired; + "MaximumWordSpacing": "133" => 1.33, "150" => 1.5, |r| r.0.word_space_max; + "MinimumLetterSpacing": "0" => 0.0, "-5" => -0.05, |r| r.0.letter_space_min; + "DesiredLetterSpacing": "0" => 0.0, "1" => 0.01, |r| r.0.letter_space_desired; + "MaximumLetterSpacing": "0" => 0.0, "5" => 0.05, |r| r.0.letter_space_max; + "MinimumGlyphScaling": "100" => 1.0, "97" => 0.97, |r| r.0.glyph_scale_min; + "DesiredGlyphScaling": "100" => 1.0, "101" => 1.01, |r| r.0.glyph_scale_desired; + "MaximumGlyphScaling": "100" => 1.0, "103" => 1.03, |r| r.0.glyph_scale_max; + "AutoLeading": "120" => 1.2, "100" => 1.0, |r| r.0.auto_leading; + "SingleWordJustification": "FullyJustified" => Align::FullyJustified, "LeftAlign" => Align::Left, |r| r.0.single_word_justify; + "KeepWithNext": "0" => 0, "1" => 1, |r| r.0.keep_with_next; + "KeepLinesTogether": "false" => false, "true" => true, |r| r.0.keep_lines_together; + "KeepAllLinesTogether": "false" => false, "true" => true, |r| r.0.keep_all_lines; + "KeepFirstLines": "2" => 2, "3" => 3, |r| r.0.keep_first; + "KeepLastLines": "2" => 2, "3" => 3, |r| r.0.keep_last; + "StartParagraph": "Anywhere" => StartParagraph::Anywhere, "NextColumn" => StartParagraph::NextColumn, |r| r.0.start_paragraph; + "BulletsAndNumberingListType": "NoList" => ListType::None, "BulletList" => ListType::Bullets, |r| r.0.list_type; + "BalanceRaggedLines": "NoBalancing" => false, "FullyBalanced" => true, |r| r.0.balance_ragged; + "Kashidas": "DefaultKashidas" => true, "KashidasOff" => false, |r| r.0.kashidas; + "RuleAbove": "false" => false, "true" => true, |r| r.0.rule_above.on; + "RuleAboveColor": "Text Color" => designcraft_doc::TEXT_COLOR, "Color/Black" => "[Black]", |r| r.0.rule_above.color; + "RuleAboveTint": "-1" => 1.0f32, "50" => 0.5f32, |r| r.0.rule_above.tint; + "RuleBelow": "false" => false, "true" => true, |r| r.0.rule_below.on; + "RuleBelowColor": "Text Color" => designcraft_doc::TEXT_COLOR, "Color/Black" => "[Black]", |r| r.0.rule_below.color; + "RuleBelowTint": "-1" => 1.0f32, "50" => 0.5f32, |r| r.0.rule_below.tint; + "ParagraphShadingOn": "false" => false, "true" => true, |r| r.0.shading_on; + "ParagraphShadingColor": "Color/Black" => "[Black]", "Swatch/None" => "[None]", |r| r.0.shading_color; + "ParagraphShadingTint": "20" => 0.2f32, "50" => 0.5f32, |r| r.0.shading_tint; + "ParagraphBorderOn": "false" => false, "true" => true, |r| r.0.border_on; + "ParagraphBorderColor": "Color/Black" => "[Black]", "Swatch/None" => "[None]", |r| r.0.border_color; + "ParagraphBorderTint": "-1" => 1.0f32, "50" => 0.5f32, |r| r.0.border_tint; + "FontStyle": "Regular" => "Regular", "Bold" => "Bold", |r| r.1.font_style; + "PointSize": "12" => 12.0, "9" => 9.0, |r| r.1.size; + "Leading": "Auto" => Leading::Auto, "14" => Leading::Points(14.0), |r| r.1.leading; + "KerningMethod": "$ID/Metrics" => designcraft_doc::Kerning::Metrics, "$ID/Optical" => designcraft_doc::Kerning::Optical, |r| r.1.kerning; + "Tracking": "0" => 0.0, "20" => 20.0, |r| r.1.tracking; + "HorizontalScale": "100" => 1.0, "90" => 0.9, |r| r.1.h_scale; + "VerticalScale": "100" => 1.0, "90" => 0.9, |r| r.1.v_scale; + "BaselineShift": "0" => 0.0, "2" => 2.0, |r| r.1.baseline_shift; + "Skew": "0" => 0.0, "10" => 10.0, |r| r.1.skew; + "FillColor": "Color/Black" => "[Black]", "Swatch/None" => "[None]", |r| r.1.fill; + "FillTint": "-1" => 1.0, "50" => 0.5, |r| r.1.fill_tint; + "StrokeColor": "Swatch/None" => "[None]", "Color/Black" => "[Black]", |r| r.1.stroke; + "StrokeTint": "-1" => 1.0, "50" => 0.5, |r| r.1.stroke_tint; + "StrokeWeight": "1" => 1.0, "2" => 2.0, |r| r.1.stroke_weight; + "Capitalization": "Normal" => Capitalization::Normal, "AllCaps" => Capitalization::AllCaps, |r| r.1.capitalization; + "Position": "Normal" => Position::Normal, "Superscript" => Position::Superscript, |r| r.1.position; + "Underline": "false" => false, "true" => true, |r| r.1.underline; + "StrikeThru": "false" => false, "true" => true, |r| r.1.strikethrough; + "UnderlineTint": "-1" => 1.0f32, "50" => 0.5f32, |r| r.1.underline_tint; + "StrikeThruTint": "-1" => 1.0f32, "50" => 0.5f32, |r| r.1.strikethrough_tint; + "Ligatures": "true" => true, "false" => false, |r| r.1.ligatures; + "NoBreak": "false" => false, "true" => true, |r| r.1.no_break; + "OTFContextualAlternate": "true" => true, "false" => false, |r| designcraft_doc::otf::is_on(&r.1.otf_features, "calt"); + "DigitsType": "DefaultDigits" => Digits::Default, "ArabicDigits" => Digits::Arabic, |r| r.1.digits; + "AppliedLanguage": "$ID/English: USA" => "English: USA", "$ID/German: 2006 Reform" => "German: 2006 Reform", |r| r.1.language; + } +} + +/// [No paragraph style] with `root` attributes and `Body`, based on it, with `body` attributes. +fn text_styles(root: &str, body: &str) -> String { + format!( + r#"<?xml version="1.0" encoding="UTF-8"?> +<idPkg:Styles xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0"> + <RootParagraphStyleGroup Self="rp"> + <ParagraphStyle Self="ParagraphStyle/$ID/[No paragraph style]" Name="$ID/[No paragraph style]" {root}/> + <ParagraphStyle Self="ParagraphStyle/Body" Name="Body" {body}> + <Properties><BasedOn type="object">ParagraphStyle/$ID/[No paragraph style]</BasedOn></Properties> + </ParagraphStyle> + </RootParagraphStyleGroup> +</idPkg:Styles>"# + ) +} + +fn resolve_body(root: &str, body: &str) -> Resolved { + let d = import_idml_with(&fixture_with_styles(&text_styles(root, body)), &|_| None).unwrap(); + d.styles.resolve_para_style("Body") +} + +/// Text attributes an IDML file leaves unset are InDesign's defaults; written ones win, and a +/// file that writes every default (as InDesign's own exports do) imports the same. +#[test] +fn absent_text_attributes_take_indesign_defaults() { + let rows = indesign_text_defaults(); + let all = |pick: fn(&AttrRow) -> &str| rows.iter().map(|r| format!(r#"{}="{}""#, r.0, pick(r))).collect::<Vec<_>>().join(" "); + let (defaults, others) = (all(|r| r.1), all(|r| r.2)); + let omitted = resolve_body("", ""); + let written = resolve_body(&defaults, ""); + for (attr, _, _, field, want, _) in &rows { + assert_eq!(field(&omitted), *want, "{attr} omitted"); + assert_eq!(field(&written), *want, "{attr} written as InDesign's default"); + } + assert_eq!(omitted, written); + for (root, body) in [(others.as_str(), ""), ("", others.as_str())] { + let r = resolve_body(root, body); + for (attr, _, _, field, _, want) in &rows { + assert_eq!(field(&r), *want, "{attr} written on the {}", if root.is_empty() { "style" } else { "root" }); + } + } + // Product difference: InDesign's default face isn't available, so unset fonts are DesignCraft's. + assert_eq!(omitted.1.font_family, designcraft_doc::CharProps::default().font_family); +} + +/// A tint of -1 is the colour's own tint (100 %): written on a style, it replaces its parent's. +/// Tints are never negative or above 100 %. +#[test] +fn minus_one_tint_is_the_colours_own() { + let tints = |v: &str| { + ["UnderlineTint", "StrikeThruTint", "FillTint", "StrokeTint", "ParagraphShadingTint", "ParagraphBorderTint", "RuleAboveTint", "RuleBelowTint"] + .map(|k| format!(r#"{k}="{v}""#)) + .join(" ") + + r#" RuleAbove="true" RuleBelow="true""# + }; + for (body, want) in [("-1", 1.0), ("150", 1.0)] { + let r = resolve_body(&tints("50"), &tints(body)); + let got = [ + r.1.underline_tint, + r.1.strikethrough_tint, + r.1.fill_tint, + r.1.stroke_tint, + r.0.shading_tint, + r.0.border_tint, + r.0.rule_above.tint, + r.0.rule_below.tint, + ]; + assert_eq!(got, [want; 8], "{body}"); + } + // Footnote rules, table fills and strokes, cell fills. + let prefs = PREFS.replace("</idPkg:Preferences>", r#"<FootnoteOption RuleTint="-1"/></idPkg:Preferences>"#); + let story = r#"<?xml version="1.0" encoding="UTF-8"?> +<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0"> +<Story Self="s1"><ParagraphStyleRange><CharacterStyleRange><Table Self="tb" StartRowFillCount="1" StartRowFillTint="-1" EndRowFillTint="-1" TopBorderStrokeTint="-1"> +<Row Self="r0" Name="0"/><Column Self="c0" Name="0"/><Cell Self="ce" Name="0:0" FillColor="Color/Black" FillTint="-1" TopEdgeStrokeTint="-1"/></Table></CharacterStyleRange></ParagraphStyleRange></Story> +</idPkg:Story>"#; + let d = import_idml_with( + &zip_files(&[ + ("designmap.xml", DESIGNMAP), + ("Resources/Graphic.xml", GRAPHIC), + ("Resources/Styles.xml", STYLES), + ("Resources/Preferences.xml", &prefs), + ("MasterSpreads/MasterSpread_m1.xml", MASTER), + ("Spreads/Spread_sp1.xml", SPREAD), + ("Stories/Story_s1.xml", story), + ]), + &|_| None, + ) + .unwrap(); + assert_eq!(d.footnote_options.rule.tint, 1.0); + let t = d.stories.values().flat_map(|s| s.tables.values()).next().unwrap(); + let alt = t.options.alt_rows.as_ref().unwrap(); + assert_eq!((alt.first_tint, alt.next_tint, t.options.border.tint), (1.0, 1.0, 1.0)); + let c = t.cell(0, 0).unwrap(); + assert_eq!((c.fill_tint, c.strokes[0].tint), (1.0, 1.0)); +} + +/// Rules in Text Color take the colour of the paragraph's text; they round-trip as Text Color. +#[test] +fn text_color_rules_round_trip() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let rule = designcraft_doc::Rule { on: true, ..Default::default() }; + assert_eq!(rule.color, designcraft_doc::TEXT_COLOR); + let para = designcraft_doc::ParaAttrs { + rule_above: Some(rule.clone()), + rule_below: Some(designcraft_doc::Rule { color: "[Black]".into(), ..rule }), + kashidas: Some(false), + ..Default::default() + }; + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(40.0, 40.0, 400.0, 400.0), lid, "Ruled", ParaFormat::default()).unwrap(); + d.story_mut(sid).unwrap().paras[0].para = para.clone(); + let bytes = export_idml(&d); + let story = zip_text(&bytes, "Stories/"); + assert!(story.contains(r#"<RuleAboveColor type="string">Text Color</RuleAboveColor>"#), "{story}"); + assert!(story.contains(r#"Kashidas="KashidasOff""#), "{story}"); + let back = import_idml(&bytes).unwrap(); + let p = &back.stories.values().find(|s| s.text == "Ruled").unwrap().paras[0]; + assert_eq!((p.para.rule_above.as_ref(), p.para.rule_below.as_ref()), (para.rule_above.as_ref(), para.rule_below.as_ref())); + assert_eq!(p.para.kashidas, Some(false)); +} + +/// `[Basic Paragraph]` is InDesign's `$ID/NormalParagraphStyle`, both ways. +#[test] +fn basic_paragraph_is_normal_paragraph_style() { + let d = import_idml_with(&fixture(), &|_| None).unwrap(); + let basic = d.styles.para(st::BASIC_PARAGRAPH).unwrap(); + assert_eq!(basic.based_on.as_deref(), Some(designcraft_doc::NO_PARA_STYLE)); + assert_eq!(d.styles.para("Text/Body").unwrap().based_on.as_deref(), Some(st::BASIC_PARAGRAPH)); + let s = d.stories.values().find(|s| s.text.starts_with("Hello")).unwrap(); + assert_eq!(s.paras[1].style, st::BASIC_PARAGRAPH); + assert!(d.styles.paragraph.iter().all(|p| !p.name.contains("NormalParagraphStyle"))); + let bytes = export_idml(&d); + let styles = zip_text(&bytes, "Resources/Styles.xml"); + assert!(styles.contains(r#"Self="ParagraphStyle/$ID/NormalParagraphStyle" Name="$ID/NormalParagraphStyle""#), "{styles}"); + assert!(zip_text(&bytes, "Stories/").contains(r#"AppliedParagraphStyle="ParagraphStyle/$ID/NormalParagraphStyle""#)); + let back = import_idml(&bytes).unwrap(); + assert_eq!(back.styles.paragraph.iter().filter(|p| p.name == st::BASIC_PARAGRAPH).count(), 1); +} + +/// Page items, frames, tables and preferences an IDML file leaves unset are InDesign's defaults +/// for a new document. +#[test] +fn absent_item_and_document_attributes_take_indesign_defaults() { + use designcraft_doc::{Cap, FirstBaseline, Join, StrokeAlign, VerticalJustification}; + use designcraft_geom::corners::CornerShape; + let path = |x: f64| { + let pts: String = [(x, 0.0), (x, 50.0), (x + 50.0, 50.0), (x + 50.0, 0.0)] + .iter() + .map(|(x, y)| format!(r#"<PathPointType Anchor="{x} {y}" LeftDirection="{x} {y}" RightDirection="{x} {y}"/>"#)) + .collect(); + format!( + r#"<Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>{pts}</PathPointArray></GeometryPathType></PathGeometry></Properties>"# + ) + }; + let designmap = format!( + r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<Document xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0" Self="d"> +<NumberingList Self="NumberingList/Steps" Name="Steps"/> +<RootTableStyleGroup Self="rts"><TableStyle Self="TableStyle/Banded" Name="Banded" StartRowFillCount="1"/></RootTableStyleGroup> +<Story Self="s1"><ParagraphStyleRange><CharacterStyleRange><Table Self="tb" StartRowFillCount="1" StartColumnFillCount="2"><Row Self="r0" Name="0"/><Column Self="c0" Name="0"/><Cell Self="ce" Name="0:0"/></Table></CharacterStyleRange></ParagraphStyleRange></Story> +<Spread Self="sp1"><Page Self="p1"/> +<Rectangle Self="plain">{}</Rectangle> +<Rectangle Self="round" TopLeftCornerOption="RoundedCorner" TopRightCornerOption="RoundedCorner" BottomLeftCornerOption="RoundedCorner" BottomRightCornerOption="RoundedCorner">{}</Rectangle> +<Rectangle Self="legacy" CornerOption="RoundedCorner">{}</Rectangle> +<Rectangle Self="sized" TopLeftCornerOption="RoundedCorner" TopLeftCornerRadius="5">{}</Rectangle> +<TextFrame Self="tf" ParentStory="s1">{}</TextFrame> +</Spread> +</Document>"#, + path(0.0), + path(100.0), + path(200.0), + path(300.0), + path(400.0) + ); + let d = import_idml_with(&zip_files(&[("designmap.xml", &designmap)]), &|_| None).unwrap(); + // Document Setup, margins and columns, grids, Advanced Type, transparency, black. + let s = &d.settings; + assert_eq!((s.page_width, s.page_height, s.facing_pages, s.intent), (612.0, 792.0, true, designcraft_doc::Intent::Print)); + assert_eq!((s.bleed, s.slug), ([0.0; 4], [0.0; 4])); + let page = &d.spreads[0].pages[0]; + assert_eq!((page.margins.top, page.margins.bottom, page.margins.inside, page.margins.outside), (36.0, 36.0, 36.0, 36.0)); + assert_eq!((page.columns.count, page.columns.gutter), (1, 12.0)); + assert_eq!((s.baseline_grid.start, s.baseline_grid.increment, s.baseline_grid.view_threshold), (36.0, 12.0, 0.75)); + assert_eq!((s.grid.horizontal, s.grid.vertical, s.grid.subdivisions, s.grid.in_back), (72.0, 72.0, 8, true)); + let a = &s.advanced_type; + assert_eq!((a.superscript_size, a.superscript_position, a.subscript_size, a.subscript_position), (58.3, 33.3, 58.3, 33.3)); + assert_eq!((s.blend_space, s.overprint_black, s.keyboard_increment), (designcraft_doc::BlendSpace::Cmyk, true, 1.0)); + // Fill, stroke, transparency, wrap and corners of a frame with no attributes. + let item = |n: usize| d.spreads[0].items[n].clone(); + let plain = item(0); + assert!(plain.fill.is_none() && plain.stroke.is_none()); + let st = &plain.stroke; + assert_eq!((st.weight, st.miter_limit, st.cap, st.join, st.align), (1.0, 4.0, Cap::Butt, Join::Miter, StrokeAlign::Center)); + assert_eq!((plain.opacity, plain.blend, plain.wrap.mode), (1.0, designcraft_color::BlendMode::Normal, designcraft_doc::WrapMode::None)); + assert!(plain.corners.is_none()); + // A corner shape without a size: 12 pt. + for n in [1, 2] { + assert_eq!(item(n).corners.corners.map(|c| (c.shape, c.size)), [(CornerShape::Rounded, 12.0); 4], "item {n}"); + } + assert_eq!(item(3).corners.corners[0].size, 5.0); + // Text Frame Options. + let tf = item(4); + let o = &tf.text_frame().unwrap().options; + assert_eq!((o.columns, o.gutter, o.inset, o.balance_columns, o.ignore_wrap), (1, 12.0, [0.0; 4], false, false)); + assert_eq!((o.vertical_justification, o.first_baseline, o.first_baseline_min), (VerticalJustification::Top, FirstBaseline::Ascent, 0.0)); + assert_eq!((o.auto_size, o.auto_size_ref), (designcraft_doc::AutoSize::Off, 4), "auto-size from the centre"); + // Numbered lists don't continue across stories. + assert_eq!(d.settings.lists, vec![designcraft_doc::NumberedList { name: "Steps".into(), continue_across_stories: false }]); + // Table and cell options. + let t = d.stories.values().flat_map(|s| s.tables.values()).next().unwrap(); + assert_eq!((t.options.space_before, t.options.space_after, t.options.repeat_header), (4.0, -4.0, true)); + assert_eq!((t.options.border.weight, t.options.border.color.as_str()), (1.0, "[Black]")); + assert_eq!((t.rows[0].mode, t.rows[0].height), (designcraft_doc::RowHeightMode::AtLeast, 3.0)); + // Alternating fills: the first rows or columns Black 20 %, the next None. + let fills = |a: &designcraft_doc::AltFills| (a.first_color.clone(), a.first_tint, a.next_color.clone(), a.next_tint, a.next); + let want = ("[Black]".to_string(), 0.2f32, "[None]".to_string(), 1.0f32, 1); + assert_eq!(fills(t.options.alt_rows.as_ref().unwrap()), want); + let cols = t.options.alt_cols.as_ref().unwrap(); + assert_eq!((fills(cols), cols.first), (want.clone(), 2)); + let banded = d.styles.table.iter().find(|s| s.name == "Banded").unwrap(); + let mut bt = designcraft_doc::Table::new(0, 1, 1, 0, 0, 100.0); + d.styles.resolve_table_style(&banded.name).apply_to(&mut bt, &d.styles.cell); + assert_eq!(fills(bt.options.alt_rows.as_ref().unwrap()), want); + let c = t.cell(0, 0).unwrap(); + assert_eq!((c.insets, c.vj, c.fill.as_str()), ([4.0; 4], VerticalJustification::Top, "[None]")); + for e in &c.strokes { + assert_eq!((e.weight, e.color.as_str(), e.tint, &e.kind), (1.0, "[Black]", 1.0, &designcraft_doc::StrokeType::Solid)); + } +} + +/// IDML has no attribute for a drop cap's character style: it is the nested style list's leading +/// "through 1 Dropcap" entry, read into the drop cap style and written back from it. +#[test] +fn drop_caps_import_and_round_trip() { + // A style with a drop cap and its character style; a paragraph overriding both locally. + let opener = r#"<ParagraphStyle Self="ParagraphStyle/Opener" Name="Opener" DropCapCharacters="1" DropCapLines="3"> + <Properties><AllNestedStyles type="list"><ListItem type="record"> + <AppliedCharacterStyle type="object">CharacterStyle/Strong</AppliedCharacterStyle><Delimiter type="enumeration">Dropcap</Delimiter> + <Repetition type="long">1</Repetition><Inclusive type="boolean">true</Inclusive></ListItem></AllNestedStyles></Properties> + </ParagraphStyle> + </RootParagraphStyleGroup>"#; + let styles = STYLES.replace("</RootParagraphStyleGroup>", opener); + let story = r#"<?xml version="1.0" encoding="UTF-8"?> +<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0"> + <Story Self="s1"> + <ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/Opener" DropCapCharacters="2" DropCapLines="2"> + <Properties><AllNestedStyles type="list"><ListItem type="record"> + <AppliedCharacterStyle type="object">CharacterStyle/$ID/[No character style]</AppliedCharacterStyle><Delimiter type="enumeration">Dropcap</Delimiter> + <Repetition type="long">1</Repetition><Inclusive type="boolean">true</Inclusive></ListItem></AllNestedStyles></Properties> + <CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content>Once upon a time</Content></CharacterStyleRange> + </ParagraphStyleRange> + </Story> +</idPkg:Story>"#; + let bytes = zip_files(&[ + ("designmap.xml", DESIGNMAP), + ("Resources/Graphic.xml", GRAPHIC), + ("Resources/Styles.xml", &styles), + ("Resources/Preferences.xml", PREFS), + ("MasterSpreads/MasterSpread_m1.xml", MASTER), + ("Spreads/Spread_sp1.xml", SPREAD), + ("Stories/Story_s1.xml", story), + ]); + let check = |d: &Document| { + let st = d.styles.para("Opener").unwrap(); + assert_eq!((st.para.drop_cap_chars, st.para.drop_cap_lines), (Some(1), Some(3))); + assert_eq!(st.para.drop_cap_style.as_deref(), Some("Strong")); + assert_eq!(st.para.nested_styles, Some(vec![designcraft_doc::NestedStyle::drop_cap("Strong")]), "one entry, not duplicated"); + let p = &d.stories.values().find(|s| s.text.starts_with("Once")).unwrap().paras[0]; + assert_eq!((p.para.drop_cap_chars, p.para.drop_cap_lines), (Some(2), Some(2))); + assert_eq!(p.para.drop_cap_style.as_deref(), Some(st::NO_CHAR_STYLE)); + }; + let d = import_idml_with(&bytes, &|_| None).unwrap(); + check(&d); + let bytes = export_idml(&d); + assert!(!zip_text(&bytes, "").contains("DropCapStyle"), "not an IDML attribute"); + check(&import_idml(&bytes).unwrap()); + // A drop cap style set without nested styles is written as one; Align Left Edge and Scale for + // Descenders as DropcapDetail. + let mut d = d; + if let Some(s) = d.styles_mut().para_mut("Opener") { + s.para.nested_styles = None; + s.para.drop_cap_align_left = Some(false); + s.para.drop_cap_scale_descenders = Some(true); + } + let bytes = export_idml(&d); + assert!(!zip_text(&bytes, "").contains("DropCapStyle")); + assert!(zip_text(&bytes, "Resources/Styles.xml").contains(r#"DropcapDetail="2""#)); + let back = import_idml(&bytes).unwrap(); + let st = back.styles.para("Opener").unwrap(); + assert_eq!((st.para.drop_cap_chars, st.para.drop_cap_lines, st.para.drop_cap_style.as_deref()), (Some(1), Some(3), Some("Strong"))); + assert_eq!((st.para.drop_cap_align_left, st.para.drop_cap_scale_descenders), (Some(false), Some(true))); + assert_eq!(st.para.nested_styles, Some(vec![designcraft_doc::NestedStyle::drop_cap("Strong")])); +} + +/// IDML styles and stories with an `Opener` style whose drop cap style is `Strong`, plus `styles` +/// (more paragraph styles) and `paras` (the story's paragraph ranges). `WORDS` in them stands for +/// a nested style list of `Strong` through 1 word. +fn drop_cap_fixture(styles: &str, paras: &str) -> Vec<u8> { + let nested = |style: &str, delim: &str| { + format!( + r#"<AllNestedStyles type="list"><ListItem type="record"><AppliedCharacterStyle type="object">CharacterStyle/{style}</AppliedCharacterStyle><Delimiter type="enumeration">{delim}</Delimiter><Repetition type="long">1</Repetition><Inclusive type="boolean">true</Inclusive></ListItem></AllNestedStyles>"# + ) + }; + let words = nested("Strong", "AnyWord"); + let opener = format!( + r#"<ParagraphStyle Self="ParagraphStyle/Opener" Name="Opener" DropCapCharacters="1" DropCapLines="3"><Properties>{}</Properties></ParagraphStyle> + {} + </RootParagraphStyleGroup>"#, + nested("Strong", "Dropcap"), + styles.replace("WORDS", &words) + ); + let styles = STYLES.replace("</RootParagraphStyleGroup>", &opener); + let story = format!( + r#"<?xml version="1.0" encoding="UTF-8"?> +<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0"> + <Story Self="s1">{}</Story> +</idPkg:Story>"#, + paras.replace("WORDS", &words) + ); + zip_files(&[ + ("designmap.xml", DESIGNMAP), + ("Resources/Graphic.xml", GRAPHIC), + ("Resources/Styles.xml", &styles), + ("Resources/Preferences.xml", PREFS), + ("MasterSpreads/MasterSpread_m1.xml", MASTER), + ("Spreads/Spread_sp1.xml", SPREAD), + ("Stories/Story_s1.xml", &story), + ]) +} + +/// A nested style list replaces the inherited one, drop cap style entry included: a style or +/// paragraph with a list of its own and no Dropcap entry has no drop cap style. +#[test] +fn own_nested_styles_clear_an_inherited_drop_cap_style() { + let styles = r#"<ParagraphStyle Self="ParagraphStyle/Child" Name="Child"><Properties><BasedOn type="object">ParagraphStyle/Opener</BasedOn>WORDS</Properties></ParagraphStyle> + <ParagraphStyle Self="ParagraphStyle/Plain" Name="Plain"><Properties>WORDS</Properties></ParagraphStyle>"#; + let paras = r#"<ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/Opener"><Properties>WORDS</Properties> + <CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content>Cleared</Content><Br/></CharacterStyleRange> + </ParagraphStyleRange> + <ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/Opener"> + <CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content>Inherited</Content></CharacterStyleRange> + </ParagraphStyleRange>"#; + let d = import_idml_with(&drop_cap_fixture(styles, paras), &|_| None).unwrap(); + let cap = |name: &str| d.styles.para(name).unwrap().para.drop_cap_style.clone(); + assert_eq!(cap("Opener").as_deref(), Some("Strong")); + assert_eq!(cap("Child").as_deref(), Some(st::NO_CHAR_STYLE), "its own list replaces Opener's"); + assert_eq!(cap("Plain"), None, "nothing to clear"); + let story = d.stories.values().find(|s| s.text.starts_with("Cleared")).unwrap(); + assert_eq!(story.paras[0].para.drop_cap_style.as_deref(), Some(st::NO_CHAR_STYLE)); + assert_eq!(story.paras[1].para.drop_cap_style, None); + assert_eq!(d.styles.resolve_para(&story.paras[1]).0.drop_cap_style, "Strong"); +} + +/// Paragraphs in table cells and footnotes are cleared like story paragraphs. +#[test] +fn own_nested_styles_clear_an_inherited_drop_cap_style_in_cells_and_notes() { + let opener = |text: &str| { + format!( + r#"<ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/Opener"><Properties>WORDS</Properties><CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content>{text}</Content></CharacterStyleRange></ParagraphStyleRange>"# + ) + }; + let paras = format!( + r#"<ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/$ID/NormalParagraphStyle"><CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"> +<Content>Body</Content><Footnote>{}</Footnote> +<Table HeaderRowCount="0" FooterRowCount="0"><Row Name="0" MinimumHeight="24"/><Column Name="0" SingleColumnWidth="60"/><Cell Name="0:0">{}</Cell></Table> +</CharacterStyleRange></ParagraphStyleRange>"#, + opener("Note"), + opener("Cell") + ); + let d = import_idml_with(&drop_cap_fixture("", ¶s), &|_| None).unwrap(); + let story = d.stories.values().find(|s| !s.notes.is_empty()).expect("story with a footnote"); + let note = &story.notes[0].text; + assert_eq!(note.paras[0].para.drop_cap_style.as_deref(), Some(st::NO_CHAR_STYLE), "{:?}", note.text); + let cell = &story.tables.values().next().expect("a table").cell(0, 0).unwrap().text; + assert_eq!(cell.paras[0].para.drop_cap_style.as_deref(), Some(st::NO_CHAR_STYLE), "{:?}", cell.text); +} + +/// A paragraph that clears its style's drop cap style without nested styles of its own round-trips: +/// it is written with the style's list, the Dropcap entry set to [None]. +#[test] +fn a_cleared_drop_cap_style_round_trips() { + let paras = r#"<ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/Opener"> + <CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content>Once upon a time</Content></CharacterStyleRange> + </ParagraphStyleRange>"#; + let mut d = import_idml_with(&drop_cap_fixture("", paras), &|_| None).unwrap(); + let sid = *d.stories.keys().next().unwrap(); + let clear = |d: &mut Document| d.story_mut(sid).unwrap().paras[0].para.drop_cap_style = Some(st::NO_CHAR_STYLE.into()); + let resolved = |d: &Document| { + let st = d.stories.values().find(|s| s.text.starts_with("Once")).unwrap(); + d.styles.resolve_para(&st.paras[0]).0 + }; + clear(&mut d); + let back = import_idml(&export_idml(&d)).unwrap(); + let pp = resolved(&back); + assert_eq!(pp.drop_cap_style, st::NO_CHAR_STYLE); + assert_eq!(pp.nested_styles, vec![designcraft_doc::NestedStyle::drop_cap(st::NO_CHAR_STYLE)]); + // The style's drop cap style without nested styles: the paragraph is written with an empty + // list, which clears it on import. + if let Some(s) = d.styles_mut().para_mut("Opener") { + s.para.nested_styles = None; + } + assert_eq!(resolved(&d).drop_cap_style, st::NO_CHAR_STYLE); + let back = import_idml(&export_idml(&d)).unwrap(); + assert_eq!(back.styles.para("Opener").unwrap().para.drop_cap_style.as_deref(), Some("Strong")); + let pp = resolved(&back); + assert_eq!(pp.drop_cap_style, st::NO_CHAR_STYLE); + assert!(pp.nested_styles.iter().all(|ns| ns.style != "Strong"), "{:?}", pp.nested_styles); +} + +/// Frames take the corners and Text Frame Options they don't write from their object style chain, +/// then `[None]`. Per-corner attributes win over the legacy all-corners pair, at each level; a +/// corner shape without a radius takes the chain's radius. +#[test] +fn frames_inherit_object_style_corners_and_text_frame_options() { + use designcraft_doc::{AutoSize, FirstBaseline, VerticalJustification}; + use designcraft_geom::corners::CornerShape as C; + // Anchors in the order top-left, bottom-left, bottom-right, top-right. + let path = |x: f64| { + let pts: String = [(x, 0.0), (x, 50.0), (x + 50.0, 50.0), (x + 50.0, 0.0)] + .iter() + .map(|(x, y)| format!(r#"<PathPointType Anchor="{x} {y}" LeftDirection="{x} {y}" RightDirection="{x} {y}"/>"#)) + .collect(); + format!( + r#"<Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>{pts}</PathPointArray></GeometryPathType></PathGeometry></Properties>"# + ) + }; + let corners = |opt: &str, r: &str| { + ["TopLeft", "TopRight", "BottomLeft", "BottomRight"].map(|n| format!(r#"{n}CornerOption="{opt}" {n}CornerRadius="{r}""#)).join(" ") + }; + let inset = |v: f64| { + format!( + r#"<Properties><InsetSpacing type="list">{}</InsetSpacing></Properties>"#, + r#"<ListItem type="unit">V</ListItem>"#.replace('V', &v.to_string()).repeat(4) + ) + }; + let designmap = format!( + r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<Document xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="20.2" Self="d"> +<RootObjectStyleGroup Self="ro"> + <ObjectStyle Self="ObjectStyle/$ID/[None]" Name="$ID/[None]" {none}> + <TextFramePreference TextColumnCount="1" TextColumnGutter="12" AutoSizingReferencePoint="CenterPoint" AutoSizingType="Off"/> + </ObjectStyle> + <ObjectStyle Self="ObjectStyle/Rounded" Name="Rounded" TopLeftCornerOption="RoundedCorner" TopRightCornerOption="RoundedCorner" BottomLeftCornerOption="RoundedCorner" BottomRightCornerOption="RoundedCorner" TopLeftCornerRadius="6"> + <Properties><BasedOn type="object">ObjectStyle/$ID/[None]</BasedOn></Properties> + <TextFramePreference TextColumnCount="2" TextColumnGutter="18" VerticalJustification="CenterAlign" FirstBaselineOffset="LeadingOffset" MinimumFirstBaselineOffset="4" IgnoreWrap="true" AutoSizingType="HeightOnly">{inset}</TextFramePreference> + </ObjectStyle> + <ObjectStyle Self="ObjectStyle/Child" Name="Child" TopRightCornerOption="BevelCorner"> + <Properties><BasedOn type="object">ObjectStyle/Rounded</BasedOn></Properties> + </ObjectStyle> +</RootObjectStyleGroup> +<Story Self="s1"/> +<Spread Self="sp1"><Page Self="p1"/> +<TextFrame Self="tf" ParentStory="s1" AppliedObjectStyle="ObjectStyle/Child" BottomRightCornerOption="InverseRoundedCorner" BottomRightCornerRadius="11.34">{p0}<TextFramePreference TextColumnCount="3"/></TextFrame> +<Rectangle Self="legacy" AppliedObjectStyle="ObjectStyle/Rounded" CornerOption="BevelCorner" CornerRadius="5" TopLeftCornerOption="InsetCorner">{p1}</Rectangle> +<Rectangle Self="one" TopRightCornerOption="RoundedCorner" TopRightCornerRadius="11.34">{p2}</Rectangle> +<Rectangle Self="plain" AppliedObjectStyle="ObjectStyle/Rounded">{p3}</Rectangle> +</Spread> +</Document>"#, + none = corners("None", "12"), + inset = inset(3.0), + p0 = path(0.0), + p1 = path(100.0), + p2 = path(200.0), + p3 = path(300.0), + ); + let d = import_idml_with(&zip_files(&[("designmap.xml", &designmap)]), &|_| None).unwrap(); + let item = |n: usize| d.spreads[0].items[n].clone(); + // Path order: top-left, bottom-left, bottom-right, top-right. + let shapes = |n: usize| item(n).corners.corners.map(|c| (c.shape, c.size)); + assert_eq!(shapes(0), [(C::Rounded, 6.0), (C::Rounded, 12.0), (C::InverseRounded, 11.34), (C::Bevel, 12.0)]); + assert_eq!(shapes(1), [(C::Inset, 5.0), (C::Bevel, 5.0), (C::Bevel, 5.0), (C::Bevel, 5.0)]); + assert!(item(2).corners.corners.iter().enumerate().all(|(i, c)| (c.shape == C::Rounded) == (i == 3))); + assert_eq!(item(2).corners.corners[3].size, 11.34); + assert_eq!(shapes(3), [(C::Rounded, 6.0), (C::Rounded, 12.0), (C::Rounded, 12.0), (C::Rounded, 12.0)]); + let tf = item(0); + let o = &tf.text_frame().unwrap().options; + assert_eq!((o.columns, o.gutter, o.inset), (3, 18.0, [3.0; 4])); + assert_eq!((o.vertical_justification, o.first_baseline, o.first_baseline_min), (VerticalJustification::Center, FirstBaseline::Leading, 4.0)); + assert_eq!((o.ignore_wrap, o.auto_size, o.auto_size_ref), (true, AutoSize::HeightOnly, 4)); +} #[test] fn type_on_a_path_keeps_its_start_and_end_brackets() { use designcraft_geom::{Anchor, PathData, Point, SubPath}; @@ -1479,3 +2126,46 @@ fn type_on_a_path_keeps_its_start_and_end_brackets() { assert_eq!(pt.end.map(|e| e.round()), end, "an end bracket at the path's end is no end bracket"); } } + +#[test] +fn frame_preference_import_caps_column_count_without_composition() { + for (raw, expected) in [("4000000000", 216), ("1e300", 216), ("-100", 1), ("0", 1), ("NaN", 1), ("inf", 1), ("-inf", 1)] { + for attribute in [true, false] { + let preference = if attribute { + format!(r#"<TextFramePreference TextColumnCount="{raw}"/>"#) + } else { + format!( + r#"<TextFramePreference><Properties><TextColumnCount type="number">{raw}</TextColumnCount></Properties></TextFramePreference>"# + ) + }; + let document = format!( + r#"<Document><Story Self="s"/><Spread Self="spread"><Page Self="page"/><TextFrame Self="frame" ParentStory="s">{preference}</TextFrame></Spread></Document>"# + ); + let d = import_idml_with(&zip_files(&[("designmap.xml", &document)]), &|_| None).unwrap(); + let frame = d.spreads[0].items[0].text_frame().unwrap(); + // Inspect imported data only: never compose an unbounded input value. + assert_eq!(frame.options.columns, expected, "{raw}, attribute={attribute}"); + } + } +} + +#[test] +fn frame_preference_property_color_resolves_for_frames_and_object_styles() { + let document = r#"<Document> +<Color Self="Color/Near" Model="Process" Space="RGB" ColorValue="255 0 0" Name="Near" Visible="true"/> +<Color Self="Color/Far" Model="Process" Space="RGB" ColorValue="0 0 255" Name="Far" Visible="true"/> +<RootObjectStyleGroup Self="root"> +<ObjectStyle Self="ObjectStyle/Base" Name="Base" EnableTextFrameGeneralOptions="true"><TextFramePreference ColumnRuleStrokeColor="Color/Far"/></ObjectStyle> +<ObjectStyle Self="ObjectStyle/Child" Name="Child" EnableTextFrameGeneralOptions="true"><Properties><BasedOn type="object">ObjectStyle/Base</BasedOn></Properties><TextFramePreference><Properties><ColumnRuleStrokeColor type="object">Color/Near</ColumnRuleStrokeColor></Properties></TextFramePreference></ObjectStyle> +</RootObjectStyleGroup> +<Story Self="s"/><Spread Self="spread"><Page Self="page"/> +<TextFrame Self="local" ParentStory="s" AppliedObjectStyle="ObjectStyle/Base"><TextFramePreference><Properties><ColumnRuleStrokeColor type="object">Color/Near</ColumnRuleStrokeColor></Properties></TextFramePreference></TextFrame> +<TextFrame Self="inherited" ParentStory="s" AppliedObjectStyle="ObjectStyle/Child"/> +<TextFrame Self="attribute" ParentStory="s" AppliedObjectStyle="ObjectStyle/Child"><TextFramePreference ColumnRuleStrokeColor="Color/Far"><Properties><ColumnRuleStrokeColor type="object">Color/Near</ColumnRuleStrokeColor></Properties></TextFramePreference></TextFrame> +<TextFrame Self="invalid" ParentStory="s" AppliedObjectStyle="ObjectStyle/Child"><TextFramePreference><Properties><ColumnRuleStrokeColor type="object">missing</ColumnRuleStrokeColor></Properties></TextFramePreference></TextFrame> +</Spread></Document>"#; + let d = import_idml_with(&zip_files(&[("designmap.xml", document)]), &|_| None).unwrap(); + let colors: Vec<_> = d.spreads[0].items.iter().map(|it| it.text_frame().unwrap().options.column_rule_color.as_str()).collect(); + assert_eq!(colors, vec!["Near", "Near", "Far", designcraft_color::swatch::NONE]); + assert_eq!(d.styles.object_style("Child").unwrap().text_frame.as_ref().unwrap().column_rule_color, "Near"); +} diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index debdbb124..73da6c93b 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -172,6 +172,8 @@ mod tests { Err(e) => panic!("accept failed: {e}"), } }; + // The listener polls accept; the connection blocks while waiting for its request. + stream.set_nonblocking(false).unwrap(); stream.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); stream.set_write_timeout(Some(Duration::from_secs(2))).unwrap(); let mut reader = BufReader::new(stream.try_clone().unwrap()); diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index de0353575..cd55e3415 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -123,7 +123,8 @@ impl Headless { let x = e.get("x").and_then(Value::as_f64).ok_or("pointer event needs numeric `x`")?; let y = e.get("y").and_then(Value::as_f64).ok_or("pointer event needs numeric `y`")?; let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base); - self.session.pointer(&PointerEvent { kind, pos: Point::new(x, y), mods, clicks: 0 }, VIEW).map_err(|e| e.to_string())?; + let clicks = e.get("clicks").and_then(Value::as_u64).map_or(1, |n| n.clamp(1, 255) as u8); + self.session.pointer(&PointerEvent { kind, pos: Point::new(x, y), mods, clicks }, VIEW).map_err(|e| e.to_string())?; } let requests: Vec<Value> = self.session.ui_requests.drain(..).map(|r| serde_json::to_value(r).unwrap_or_default()).collect(); Ok(json!({"selection": self.selection(), "tool": self.session.tool_id(), "requests": requests})) diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index c51bac8ed..f6d2974b0 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -279,6 +279,7 @@ pub fn tool_definitions() -> Vec<Value> { "y": num("y (pt)"), "space": {"type": "string", "enum": ["doc", "screen"], "description": "Coordinate space (default doc)"}, "mods": mods_schema(), + "clicks": {"type": "integer", "minimum": 1, "description": "On a down: presses in a row it completes (2 double, 3 triple…; default 1)"}, }), &["kind", "x", "y"]), }, "mods": mods_schema(), diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 9ac53d057..1aa1a33d5 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -202,6 +202,18 @@ fn tables_export_cell_text() { } } +#[test] +fn drop_cap_text_extracts_in_order() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let para = designcraft_doc::ParaAttrs { drop_cap_lines: Some(3), drop_cap_chars: Some(2), ..Default::default() }; + let text = "Drop caps read in order with the rest of the paragraph, which runs on for a few lines beside the enlarged letters."; + d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 236.0, 300.0), lid, text, ParaFormat { para, ..Default::default() }).unwrap(); + let bytes = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap(); + let flat: String = extract_text(&bytes)[0].split_whitespace().collect::<Vec<_>>().join(" "); + assert!(flat.starts_with("Drop caps read in order"), "{flat}"); +} + /// A 4×2 CMYK TIFF: the left half 100% K (text in a scan), the right half C0 M100 Y100 K0. fn cmyk_tiff() -> (Vec<u8>, Vec<u8>) { let (k, red) = ([0u8, 0, 0, 255], [0u8, 255, 255, 0]); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 2cc5c548f..dd2225b74 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1246,6 +1246,37 @@ mod tests { assert!(r.stats.glyphs >= 15); } + /// An inside-aligned stroke is the stroke of the path inset by half its weight: a band from the + /// edge inwards, its rounded corners concentric with the frame's. + #[test] + fn inside_strokes_are_inset_by_half_the_weight() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let id = designcraft_doc::ItemId(d.alloc()); + let mut it = + Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 300.0, 300.0))); + it.stroke = designcraft_doc::Stroke { + swatch: designcraft_color::swatch::BLACK.into(), + weight: 10.0, + align: designcraft_doc::StrokeAlign::Inside, + ..Default::default() + }; + it.corners = designcraft_geom::corners::CornerOptions::uniform(designcraft_geom::corners::CornerShape::Rounded, 20.0); + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + let cache = Cache::new(); + let mut r = Renderer::new(); + r.threads = 0; + let img = r.render_page(&d, &cache, 0, 1.0, false, &RenderOptions::default()).unwrap(); + let ink = |x: u32, y: u32| img.pixel(x, y)[0] < 64; + let paper = |x: u32, y: u32| img.pixel(x, y)[0] > 192; + // Straight edges: ink from the edge to 10 pt inside, nothing outside. + assert!(paper(97, 200) && ink(101, 200) && ink(108, 200) && paper(112, 200)); + assert!(ink(298, 200) && paper(302, 200) && paper(288, 200)); + // Rounded corner centred at (120, 120): ink between radii 10 and 20, paper outside the arc + // and inside the band. + assert!(paper(103, 103) && ink(108, 108) && paper(114, 114)); + } + #[test] fn arrowheads_draw_at_line_ends() { let mut d = Document::new(&NewDocument::default()); diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index cc46ad729..96e3840ce 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -192,7 +192,9 @@ fn line_box(l: &Line) -> Option<Rect> { let x0 = l.glyphs.iter().map(|g| g.x).fold(first.x, f64::min); let x1 = l.glyphs.iter().map(|g| g.x + g.adv.max(0.0)).fold(l.end_x, f64::max); let pad = l.ascent.max(1.0); - Some(Rect::new(x0 - pad, l.baseline - l.ascent - pad, x1 + pad, l.baseline + l.descent.max(0.0) + pad)) + // A drop cap reaches down past its line. + let bottom = l.drop_cap.map_or(l.baseline + l.descent.max(0.0), |dc| dc.rect.y1.max(l.baseline + l.descent.max(0.0))); + Some(Rect::new(x0 - pad, l.baseline - l.ascent - pad, x1 + pad, bottom + pad)) } impl Renderer { diff --git a/crates/tools/src/frame.rs b/crates/tools/src/frame.rs index 87b4284cc..4b52e2ee5 100644 --- a/crates/tools/src/frame.rs +++ b/crates/tools/src/frame.rs @@ -50,6 +50,46 @@ impl FrameTool { } } +/// The rectangle a frame-drawing drag from `sa` to `end` (spread space) makes, snapped when +/// snapping is on, and the snap guides to draw. Shift constrains the shape first; only the +/// driving edge snaps, then the other follows. Alt draws from the centre. +pub(crate) fn snapped_drag_rect(cx: &ToolContext, sr: SpreadRef, sa: Point, end: Point, mods: Mods) -> (Rect, Vec<crate::Overlay>) { + let r = drag_rect(sa, end, mods); + if !cx.snap.any() { + return (r, vec![]); + } + let (mut x_edges, mut y_edges) = moving_edges(sa, end, r, mods.alt); + let driver_x = (end.x - sa.x).abs() >= (end.y - sa.y).abs(); + if mods.shift { + if driver_x { + y_edges = [false, false, false]; + } else { + x_edges = [false, false, false]; + } + } + let hit = crate::snap::snap( + cx, + SnapRequest { + spread: sr, + gesture: Gesture::Create, + rect: r, + x_edges, + y_edges, + exclude: &[], + copying: false, + lengths: [length_on(x_edges, r.width()), length_on(y_edges, r.height())], + angle: None, + radius: 0.0, + pointer: end, + }, + ); + let mut r = crate::snap::nudge_edges(r, x_edges, y_edges, hit.delta); + if mods.shift { + r = restore_shift_frame(sa, end, r, driver_x, mods.alt); + } + (r, hit.guides) +} + pub fn drag_rect(a: Point, b: Point, m: Mods) -> Rect { let (mut dx, mut dy) = (b.x - a.x, b.y - a.y); if m.shift { @@ -365,41 +405,9 @@ impl Tool for FrameTool { } out.push(Action::Preview("line.create".into(), json!({"spread": spread_json(sr), "a": [sa.x, sa.y], "b": [b.x, b.y]}))); } else { - // Shift constrains the shape first. Only the driving edge snaps, then the other follows. let end = cx.layout.to_spread(sr, ev.pos); - let mut r = drag_rect(sa, end, ev.mods); - if cx.snap.any() { - let (mut x_edges, mut y_edges) = moving_edges(sa, end, r, ev.mods.alt); - let driver_x = (end.x - sa.x).abs() >= (end.y - sa.y).abs(); - if ev.mods.shift { - if driver_x { - y_edges = [false, false, false]; - } else { - x_edges = [false, false, false]; - } - } - let hit = crate::snap::snap( - cx, - SnapRequest { - spread: sr, - gesture: Gesture::Create, - rect: r, - x_edges, - y_edges, - exclude: &[], - copying: false, - lengths: [length_on(x_edges, r.width()), length_on(y_edges, r.height())], - angle: None, - radius: 0.0, - pointer: end, - }, - ); - r = crate::snap::nudge_edges(r, x_edges, y_edges, hit.delta); - if ev.mods.shift { - r = restore_shift_frame(sa, end, r, driver_x, ev.mods.alt); - } - self.guides = hit.guides; - } + let (r, guides) = snapped_drag_rect(cx, sr, sa, end, ev.mods); + self.guides = guides; self.last = Some((spread_json(sr), r)); out.push(self.preview(spread_json(sr), r)); } diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index f3de390a4..8dbc262ba 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -20,6 +20,7 @@ mod pencil; pub mod select; pub mod snap; mod text; +pub mod thread; mod xform; use designcraft_compose::Cache; @@ -63,21 +64,28 @@ pub struct PointerEvent { pub pos: Point, #[serde(default)] pub mods: Mods, - /// Presses in quick succession at about the same spot, this one included: 2 on a - /// `DoubleClick`, 3 or more on a `Down` (the Type tool selects a sentence, paragraph, story). - /// 0 or 1 is a single press. + /// On a press: how many presses in quick succession at this spot it completes (2 = the + /// second of a double click, 3 = a triple click…). 0 counts as 1. #[serde(default)] pub clicks: u8, } impl PointerEvent { pub fn new(kind: PointerKind, x: f64, y: f64) -> Self { - Self { kind, pos: Point::new(x, y), mods: Mods::default(), clicks: 0 } + Self { kind, pos: Point::new(x, y), mods: Mods::default(), clicks: 1 } } pub fn with_mods(mut self, m: Mods) -> Self { self.mods = m; self } + pub fn with_clicks(mut self, n: u8) -> Self { + self.clicks = n; + self + } + /// Presses in a row: at least 2 for [`PointerKind::DoubleClick`], at least 1 otherwise. + pub fn click_count(&self) -> u8 { + if self.kind == PointerKind::DoubleClick { self.clicks.max(2) } else { self.clicks.max(1) } + } } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -351,6 +359,10 @@ pub enum Cursor { ZoomOut, Eyedropper, LoadedText, + /// Loaded text over a frame a click threads to. + ThreadLink, + /// Loaded text over the frame a click unthreads from. + Unthread, LoadedGraphic, NotAllowed, } diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index fc9a8c316..b2ac97731 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -3,11 +3,13 @@ //! Click selects the frontmost item (Shift toggles), drag moves (Alt duplicates, Shift constrains), //! the 8 bounding-box handles resize (Shift keeps proportions, Alt from centre, Cmd scales content), //! empty-canvas drags make a marquee, double-clicking a text frame switches to the Type tool. +//! A click on a selected text frame's in or out port loads the text cursor (see [`crate::thread`]). use designcraft_doc::{ItemId, SpreadRef}; use designcraft_geom::{Affine, Point, Rect, Vec2}; use serde_json::{Value, json}; +use crate::thread::Loaded; use crate::{Action, Cursor, Gesture, Mods, Overlay, PointerEvent, PointerKind, SnapRequest, Tool, ToolContext, ToolKey, rect_json, spread_json}; #[derive(Clone, Debug)] @@ -38,6 +40,11 @@ enum Drag { start: Point, cur: Point, }, + /// A press with the loaded text cursor. + Place { + start: Point, + cur: Point, + }, /// Dragging a type-on-a-path bracket along its path (`end`: the end bracket). Bracket { id: ItemId, @@ -149,11 +156,57 @@ pub struct SelectionTool { guides: Vec<Overlay>, /// Selected item to toggle if this Shift press never becomes a drag. shift_release: Option<u64>, + /// The loaded text cursor. + loaded: Option<Loaded>, } impl SelectionTool { pub fn new(direct: bool) -> Self { - Self { direct, drag: Drag::None, hover_handle: None, guides: vec![], shift_release: None } + Self { direct, drag: Drag::None, hover_handle: None, guides: vec![], shift_release: None, loaded: None } + } + + /// Ports and the loaded text cursor. None: not a threading event. + fn thread_pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Option<Vec<Action>> { + let p = ev.pos; + let exec = |c: Option<(&'static str, Value)>| c.map(|(id, params)| Action::Exec(id.into(), params)).into_iter().collect::<Vec<_>>(); + // A double click: sent as such, or the canvas's second press. + if matches!(ev.kind, PointerKind::Down | PointerKind::DoubleClick) + && ev.click_count() == 2 + && let Some((frame, out)) = crate::thread::port_at(cx, p) + { + self.loaded = None; + self.drag = Drag::None; + return Some(exec(crate::thread::unthread_at_port(cx.doc, frame, out))); + } + self.loaded = self.loaded.and_then(|l| l.resolve(cx.doc)); + match (ev.kind, self.loaded) { + (PointerKind::Down | PointerKind::DoubleClick, _) if let Some((frame, out)) = crate::thread::port_at(cx, p) => { + self.loaded = Some(Loaded::new(frame, out)); + self.drag = Drag::None; + Some(vec![]) + } + (PointerKind::Down | PointerKind::DoubleClick, Some(_)) => { + self.drag = Drag::Place { start: p, cur: p }; + Some(vec![]) + } + (PointerKind::Drag, Some(_)) => { + if let Drag::Place { start, .. } = self.drag { + self.drag = Drag::Place { start, cur: p }; + } + Some(vec![]) + } + (PointerKind::Up, Some(l)) => { + let Drag::Place { start, .. } = std::mem::replace(&mut self.drag, Drag::None) else { return Some(vec![]) }; + let clicked = l.click(cx, start, p, ev.mods); + // Alt (semi-autoflow) stays loaded with what is left; an invalid target keeps the load. + if let Some(c) = &clicked { + self.loaded = c.joins.filter(|_| ev.mods.alt && !ev.mods.shift).map(|j| Loaded { advance: Some(j), ..l }); + } + Some(exec(clicked.map(|c| (c.id, c.params)))) + } + (PointerKind::Move, Some(_)) => Some(vec![]), + _ => None, + } } } @@ -420,6 +473,19 @@ impl Tool for SelectionTool { fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> { let p = ev.pos; + if !self.direct + && let Some(actions) = self.thread_pointer(cx, ev) + { + return actions; + } + // The second press of a double click on a text frame: edit its text. + if matches!(ev.kind, PointerKind::Down | PointerKind::DoubleClick) + && ev.click_count() == 2 + && let Some(actions) = edit_text_at(cx, p) + { + self.drag = Drag::None; + return actions; + } match ev.kind { PointerKind::Move => { self.hover_handle = handle_at(cx, p); @@ -656,6 +722,7 @@ impl Tool for SelectionTool { self.drag = Drag::Marquee { start, cur: p }; vec![] } + Drag::Place { .. } => vec![], Drag::Rotate { center, start_angle, start_rotation } => { let mut a = ((p - center).atan2() - start_angle).to_degrees(); self.guides.clear(); @@ -769,26 +836,19 @@ impl Tool for SelectionTool { } vec![Action::Exec("selection.set".into(), json!({"ids": ids, "add": ev.mods.shift}))] } - Drag::None => vec![], + Drag::None | Drag::Place { .. } => vec![], } } - PointerKind::DoubleClick => { - if let Some((_, id)) = cx.hit(p) - && cx.doc.item(id).is_some_and(|i| i.is_text_frame()) - { - let (sr, sp) = cx.layout.spread_at(p).unwrap_or((SpreadRef::Doc(0), p)); - let _ = sr; - return vec![ - Action::SwitchTool("type".into()), - Action::Exec("text.placeCaret".into(), json!({"frame": id.0, "point": [sp.x, sp.y]})), - ]; - } - vec![] - } + PointerKind::DoubleClick => vec![], } } fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec<Action> { + if key == ToolKey::Escape && self.loaded.take().is_some() { + // Unload; the selection stays. Cancel (no interaction runs) marks the key handled. + self.drag = Drag::None; + return vec![Action::Cancel]; + } let inc = cx.doc.settings.keyboard_increment * if mods.shift { 10.0 } else { 1.0 }; let mv = |dx: f64, dy: f64| vec![Action::Exec("transform.move".into(), json!({"dx": dx, "dy": dy, "copy": mods.alt}))]; if cx.selection.items.is_empty() { @@ -807,6 +867,11 @@ impl Tool for SelectionTool { fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> { match &self.drag { + Drag::Place { start, cur } if crate::thread::is_drag(cx, *start, *cur) => { + let Some((sr, r, guides)) = crate::thread::drag_frame(cx, *start, *cur) else { return vec![] }; + let r = Rect::from_points(cx.layout.to_canvas(sr, Point::new(r.x0, r.y0)), cx.layout.to_canvas(sr, Point::new(r.x1, r.y1))); + std::iter::once(Overlay::Marquee(r)).chain(guides).collect() + } Drag::Marquee { start, cur } => vec![Overlay::Marquee(Rect::from_points(*start, *cur))], Drag::Move { .. } | Drag::Resize { .. } | Drag::Rotate { .. } | Drag::Anchor { .. } => self.guides.clone(), Drag::None | Drag::Bracket { .. } => brackets(cx), @@ -821,6 +886,9 @@ impl Tool for SelectionTool { if self.direct { return Cursor::ArrowHollow; } + if let Some(l) = self.loaded.and_then(|l| l.resolve(cx.doc)) { + return if crate::thread::port_at(cx, p).is_some() { Cursor::LoadedText } else { l.cursor(cx, p) }; + } match self.drag { Drag::Move { .. } => return Cursor::Move, Drag::Resize { handle, .. } => return handle_cursor(handle), @@ -868,3 +936,13 @@ fn handle_cursor(h: usize) -> Cursor { _ => Cursor::ResizeH, } } + +/// Switch to the Type tool with the caret at `p` when it is over a text frame. +fn edit_text_at(cx: &ToolContext, p: Point) -> Option<Vec<Action>> { + let (_, id) = cx.hit(p)?; + if !cx.doc.item(id).is_some_and(|i| i.is_text_frame()) { + return None; + } + let (_, sp) = cx.layout.spread_at(p).unwrap_or((SpreadRef::Doc(0), p)); + Some(vec![Action::SwitchTool("type".into()), Action::Exec("text.placeCaret".into(), json!({"frame": id.0, "point": [sp.x, sp.y]}))]) +} diff --git a/crates/tools/src/snap.rs b/crates/tools/src/snap.rs index 6d5c8f6a5..142525dec 100644 --- a/crates/tools/src/snap.rs +++ b/crates/tools/src/snap.rs @@ -1,6 +1,8 @@ //! Snapping and smart guides. //! -//! [`snap`] tries each pass on a free axis and keeps the first hit inside the snap zone. +//! [`snap`] tries each pass on a free axis and keeps the first hit inside the snap zone; the +//! baseline grid and the guides (ruler guides, margins, columns) count as one pass where the +//! nearer target wins. //! Ruler guides, margins, and columns draw nothing. Alignment draws [`Overlay::Guide`] in //! canvas coordinates. Equal spacing draws [`Overlay::Gap`] the same way, through one //! spread-to-canvas transform. A rotation match draws [`Overlay::Measure`] at the pointer @@ -374,13 +376,22 @@ fn format_angle(deg: f64) -> String { fn first_on_axis(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, tol: f64) -> Option<AxisHit> { pass_grid(cx, req, axis, tol) - .or_else(|| pass_baseline(cx, req, axis, tol)) - .or_else(|| pass_guides(cx, req, axis, tol)) + .or_else(|| nearer(pass_baseline(cx, req, axis, tol), pass_guides(cx, req, axis, tol))) .or_else(|| pass_align(cx, req, axis, tol)) .or_else(|| pass_spacing(cx, req, axis, tol)) .or_else(|| pass_dimensions(cx, req, axis, tol)) } +/// The baseline grid and the guides (ruler guides, margins, columns) compete by distance, so a +/// margin is reachable between the lines of a fine baseline grid at any zoom. A tie goes to the +/// guide. +fn nearer(baseline: Option<AxisHit>, guide: Option<AxisHit>) -> Option<AxisHit> { + match (baseline, guide) { + (Some(b), Some(g)) => Some(if b.delta.abs() < g.delta.abs() { b } else { g }), + (b, g) => g.or(b), + } +} + /// Document grid. No overlay. /// /// The interval is the major spacing divided by the subdivision count. A count below 1 counts @@ -1747,6 +1758,24 @@ mod tests { assert_eq!(hit.delta.y, 0.0); } + #[test] + fn nearer_of_margin_and_baseline_wins() { + // Bottom margin at 792 - 54 = 738, between baseline lines 732 and 744. + let doc = Document::new(&NewDocument { + margins: designcraft_doc::Margins { top: 54.0, bottom: 54.0, inside: 54.0, outside: 42.0 }, + ..Default::default() + }); + let cache = Cache::new(); + let layout = CanvasLayout::new(&doc, false); + let sel = Selection::default(); + // At 50% the 4 px zone is 8 pt: both the margin and a baseline line are in reach. + let cx = ToolContext { zoom: 0.5, ..ctx_on(&doc, &sel, &cache, &layout) }; + let bottom = |y1: f64| SnapRequest { y_edges: [false, false, true], gesture: Gesture::Resize, ..y_req(Rect::new(100.0, 600.0, 140.0, y1)) }; + assert!((snap(&cx, bottom(739.5)).delta.y - -1.5).abs() < 1e-6, "the margin is nearer"); + assert!((snap(&cx, bottom(742.5)).delta.y - 1.5).abs() < 1e-6, "the baseline line is nearer"); + assert!((snap(&cx, bottom(741.0)).delta.y - -3.0).abs() < 1e-6, "a tie goes to the margin"); + } + #[test] fn rotated_item_matches_its_own_side() { let mut doc = Document::new(&NewDocument::default()); diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 6776902aa..6df86485a 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -706,6 +706,289 @@ fn pen_click_snaps_to_a_guide() { assert!((x - 100.0).abs() < 1e-6, "anchor x {x}"); } +/// A text frame with overset text (selected), an empty frame and a threaded pair x → y, on page 1. +struct ThreadDoc { + d: Document, + l: CanvasLayout, + s: Selection, + c: Cache, + /// The selected text frame. + a: ItemId, + /// An empty (unassigned) frame. + e: ItemId, + x: ItemId, + y: ItemId, +} + +fn thread_doc() -> ThreadDoc { + let mut d = Document::new(&NewDocument { pages: 2, ..Default::default() }); + let lid = d.default_layer(); + let (a, _) = d + .add_text_frame(SpreadRef::Doc(0), Rect::new(100.0, 100.0, 250.0, 160.0), lid, &"Words flow on. ".repeat(40), ParaFormat::default()) + .unwrap(); + let e = ItemId(d.alloc()); + d.insert_item(SpreadRef::Doc(0), Item::new(e, lid, Shape::Rectangle, shapes::rectangle(Rect::new(100.0, 400.0, 250.0, 500.0))), None).unwrap(); + let (x, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(300.0, 100.0, 450.0, 160.0), lid, "x", ParaFormat::default()).unwrap(); + let (y, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(300.0, 400.0, 450.0, 500.0), lid, "", ParaFormat::default()).unwrap(); + d.thread(x, y).unwrap(); + let l = CanvasLayout::new(&d, false); + ThreadDoc { s: Selection::items(vec![a]), c: Cache::new(), l, d, a, e, x, y } +} + +impl ThreadDoc { + fn cx(&self) -> ToolContext<'_> { + ctx(&self.d, &self.s, &self.c, &self.l) + } + /// Canvas point of a spread-0 point. + fn at(&self, x: f64, y: f64) -> Point { + self.l.to_canvas(SpreadRef::Doc(0), Point::new(x, y)) + } + fn port(&self, id: ItemId, out: bool) -> Point { + thread::port_center(&self.d, &self.l, id, out, 1.0).unwrap() + } +} + +fn click(t: &mut Box<dyn Tool>, cx: &ToolContext, p: Point, mods: Mods) -> Vec<Action> { + let mut v = t.pointer(cx, &PointerEvent::new(PointerKind::Down, p.x, p.y).with_mods(mods)); + v.extend(t.pointer(cx, &PointerEvent::new(PointerKind::Up, p.x, p.y).with_mods(mods))); + v +} + +fn thread_exec(p: serde_json::Value) -> Vec<Action> { + vec![Action::Exec("text.thread".into(), p)] +} + +#[test] +fn out_port_loads_text_and_a_click_threads_an_empty_frame() { + let td = thread_doc(); + let cx = td.cx(); + let mut t = create("selection"); + let port = td.port(td.a, true); + // The out port sits on the right edge, 12 px above the bottom-right corner. + assert!((port - td.at(250.0, 148.0)).hypot() < 1e-9, "{port:?}"); + // A press just off the port's centre still loads (any zoom: tolerance is in pixels). + assert!(click(&mut t, &cx, port + designcraft_geom::Vec2::new(3.0, -3.0), Mods::default()).is_empty()); + assert_eq!(t.cursor(&cx, td.at(500.0, 700.0), Mods::default()), Cursor::LoadedText); + let over_e = td.at(175.0, 450.0); + assert_eq!(t.cursor(&cx, over_e, Mods::default()), Cursor::ThreadLink); + // A frame something already threads into can't join; neither can the loaded frame itself. + assert_eq!(t.cursor(&cx, td.at(375.0, 450.0), Mods::default()), Cursor::NotAllowed); + assert_eq!(t.cursor(&cx, td.at(175.0, 130.0), Mods::default()), Cursor::LoadedText); + assert_eq!(click(&mut t, &cx, over_e, Mods::default()), thread_exec(serde_json::json!({"from": td.a.0, "to": td.e.0}))); + // Unloaded again: the cursor and clicks are back to normal. + assert_eq!(t.cursor(&cx, over_e, Mods::default()), Cursor::Arrow); +} + +#[test] +fn ports_hit_at_any_zoom() { + let td = thread_doc(); + let mut cx = td.cx(); + cx.zoom = 4.0; + let port = thread::port_center(&td.d, &td.l, td.a, false, 4.0).unwrap(); + assert!((port - td.at(100.0, 100.0 + 14.5 / 4.0)).hypot() < 1e-9); + assert_eq!(thread::port_at(&cx, port + designcraft_geom::Vec2::new(1.5, 1.5)), Some((td.a, false))); + assert_eq!(thread::port_at(&cx, port + designcraft_geom::Vec2::new(4.0, 0.0)), None, "8 screen px away at 4×"); +} + +#[test] +fn loaded_click_on_empty_page_makes_a_column_frame() { + let td = thread_doc(); + let cx = td.cx(); + let mut t = create("selection"); + click(&mut t, &cx, td.port(td.a, true), Mods::default()); + let col = td.d.spreads[0].pages[0].column_rects()[0]; + let a = click(&mut t, &cx, td.at(200.0, 600.0), Mods::default()); + assert_eq!(a, thread_exec(serde_json::json!({"from": td.a.0, "rect": [col.x0, 600.0, col.x1, col.y1], "spread": {"kind": "doc", "index": 0}}))); +} + +#[test] +fn loaded_drag_makes_a_frame_of_that_size_and_shift_autoflows() { + let td = thread_doc(); + let cx = td.cx(); + let mut t = create("selection"); + click(&mut t, &cx, td.port(td.a, true), Mods::default()); + let (p0, p1) = (td.at(320.0, 560.0), td.at(470.0, 700.0)); + let shift = Mods { shift: true, ..Default::default() }; + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, p0.x, p0.y).with_mods(shift)).is_empty()); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, p1.x, p1.y).with_mods(shift)).is_empty()); + assert_eq!(t.overlays(&cx), vec![Overlay::Marquee(Rect::from_points(p0, p1))]); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, p1.x, p1.y).with_mods(shift)); + assert_eq!( + a, + thread_exec( + serde_json::json!({"from": td.a.0, "rect": [320.0, 560.0, 470.0, 700.0], "spread": {"kind": "doc", "index": 0}, "autoflow": true}) + ) + ); + assert_eq!(t.cursor(&cx, td.at(500.0, 700.0), Mods::default()), Cursor::Arrow, "autoflow unloads"); +} + +#[test] +fn loaded_drag_snaps_like_frame_drawing() { + let td = thread_doc(); + let cx = ToolContext { snap: SnapView::FACTORY, ..td.cx() }; + let mut t = create("selection"); + click(&mut t, &cx, td.port(td.a, true), Mods::default()); + let col = td.d.spreads[0].pages[0].column_rects()[0]; + // Press and release a point or two inside the column's edges: both sides snap to them. + let (p0, p1) = (td.at(col.x0 + 1.5, 560.0), td.at(col.x1 - 2.0, 650.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, p0.x, p0.y)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, p1.x, p1.y)); + let Some(Overlay::Marquee(m)) = t.overlays(&cx).first().cloned() else { panic!("no marquee") }; + assert!((m.x0 - td.at(col.x0, 0.0).x).abs() < 1e-9 && (m.x1 - td.at(col.x1, 0.0).x).abs() < 1e-9, "{m:?}"); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, p1.x, p1.y)); + let [Action::Exec(id, p)] = a.as_slice() else { panic!("{a:?}") }; + assert_eq!(id, "text.thread"); + let r: Vec<f64> = p["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect(); + assert_eq!((r[0], r[2]), (col.x0, col.x1), "{p}"); +} + +#[test] +fn alt_click_stays_loaded() { + let mut td = thread_doc(); + let original = td.d.clone(); + let mut t = create("selection"); + let alt = Mods { alt: true, ..Default::default() }; + let col = td.d.spreads[0].pages[0].column_rects()[0]; + let empty = td.at(200.0, 600.0); + let new_frame_from = + |f: ItemId| thread_exec(serde_json::json!({"from": f.0, "rect": [col.x0, 600.0, col.x1, col.y1], "spread": {"kind": "doc", "index": 0}})); + { + let cx = td.cx(); + click(&mut t, &cx, td.port(td.a, true), Mods::default()); + assert_eq!(click(&mut t, &cx, td.at(175.0, 450.0), alt), thread_exec(serde_json::json!({"from": td.a.0, "to": td.e.0}))); + assert_eq!(t.cursor(&cx, td.at(500.0, 700.0), Mods::default()), Cursor::LoadedText); + // The thread failed (the document is unchanged): still loaded from a. + assert_eq!(click(&mut t, &cx, empty, alt), new_frame_from(td.a)); + } + // That click's command ran: the next click threads after the frame it added. + let (n, _) = + td.d.add_text_frame(SpreadRef::Doc(0), Rect::new(col.x0, 600.0, col.x1, col.y1), td.d.default_layer(), "", ParaFormat::default()).unwrap(); + td.d.thread(td.a, n).unwrap(); + { + let cx = td.cx(); + assert_eq!(click(&mut t, &cx, td.at(175.0, 450.0), alt), thread_exec(serde_json::json!({"from": n.0, "to": td.e.0}))); + } + // Undone: back on a, not on a's old neighbour or the frame the undo removed. + td.d = original; + { + let cx = td.cx(); + assert_eq!(click(&mut t, &cx, empty, Mods::default()), new_frame_from(td.a)); + } + // From x, which threads into y: when threading e in between fails, x stays loaded, not y. + let td = ThreadDoc { s: Selection::items(vec![td.x]), ..td }; + let cx = td.cx(); + click(&mut t, &cx, td.port(td.x, true), Mods::default()); + assert_eq!(click(&mut t, &cx, td.at(175.0, 450.0), alt), thread_exec(serde_json::json!({"from": td.x.0, "to": td.e.0}))); + assert_eq!(click(&mut t, &cx, empty, Mods::default()), new_frame_from(td.x)); +} + +#[test] +fn escape_unloads_and_keeps_the_selection() { + let td = thread_doc(); + let cx = td.cx(); + let mut t = create("selection"); + click(&mut t, &cx, td.port(td.a, true), Mods::default()); + assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel]); + assert_eq!(t.cursor(&cx, td.at(500.0, 700.0), Mods::default()), Cursor::Arrow); + // An ordinary click on empty space deselects again. + let p = td.at(500.0, 700.0); + assert_eq!( + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, p.x, p.y)), + vec![Action::Exec("selection.set".into(), serde_json::json!({"ids": []}))] + ); +} + +#[test] +fn loaded_click_on_the_linked_frame_unthreads() { + // x is selected; its out port links to y. + let td = thread_doc(); + let td = ThreadDoc { s: Selection::items(vec![td.x]), ..td }; + let cx = td.cx(); + let mut t = create("selection"); + click(&mut t, &cx, td.port(td.x, true), Mods::default()); + let over_y = td.at(375.0, 450.0); + assert_eq!(t.cursor(&cx, over_y, Mods::default()), Cursor::Unthread); + assert_eq!( + click(&mut t, &cx, over_y, Mods::default()), + vec![Action::Exec("text.unthread".into(), serde_json::json!({"frame": td.x.0, "side": "after"}))] + ); + // From y's in port, x is the frame before. + let td = ThreadDoc { s: Selection::items(vec![td.y]), ..td }; + let cx = td.cx(); + click(&mut t, &cx, td.port(td.y, false), Mods::default()); + assert_eq!( + click(&mut t, &cx, td.at(375.0, 130.0), Mods::default()), + vec![Action::Exec("text.unthread".into(), serde_json::json!({"frame": td.y.0, "side": "before"}))] + ); +} + +#[test] +fn in_port_threads_in_front() { + let td = thread_doc(); + let cx = td.cx(); + let mut t = create("selection"); + click(&mut t, &cx, td.port(td.a, false), Mods::default()); + assert_eq!(click(&mut t, &cx, td.at(175.0, 450.0), Mods::default()), thread_exec(serde_json::json!({"from": td.e.0, "to": td.a.0}))); +} + +#[test] +fn double_click_on_a_port_breaks_its_link() { + let td = thread_doc(); + let td = ThreadDoc { s: Selection::items(vec![td.x]), ..td }; + let cx = td.cx(); + // A double click sent as such (control channel), and the canvas's second press. + let second = |p: Point| [PointerEvent::new(PointerKind::DoubleClick, p.x, p.y), PointerEvent::new(PointerKind::Down, p.x, p.y).with_clicks(2)]; + let p = td.port(td.x, true); + for ev in second(p) { + let mut t = create("selection"); + click(&mut t, &cx, p, Mods::default()); + let a = t.pointer(&cx, &ev); + assert_eq!(a, vec![Action::Exec("text.unthread".into(), serde_json::json!({"frame": td.x.0, "side": "after"}))], "{ev:?}"); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, p.x, p.y)).is_empty()); + assert_eq!(t.cursor(&cx, td.at(500.0, 700.0), Mods::default()), Cursor::Arrow); + // The in port of the first frame has no link: nothing to break. + let q = td.port(td.x, false); + for ev in second(q) { + assert!(t.pointer(&cx, &ev).is_empty(), "{ev:?}"); + } + } +} + +#[test] +fn clicks_in_a_row_edit_and_select_text() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let (fid, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(100.0, 100.0, 300.0, 200.0), lid, "Two words", ParaFormat::default()).unwrap(); + let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); + let cx = ctx(&d, &s, &c, &l); + let off = l.xf(SpreadRef::Doc(0)).translation(); + let (x, y) = (150.0 + off.x, 110.0 + off.y); + let press = |n: u8| PointerEvent::new(PointerKind::Down, x, y).with_clicks(n); + // Selection tool: the second press of a double click edits the text there. + let mut t = create("selection"); + let a = t.pointer(&cx, &press(1)); + assert!(matches!(&a[0], Action::Exec(id, _) if id == "selection.set"), "{a:?}"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x, y)); + let a = t.pointer(&cx, &press(2)); + assert_eq!(a.first(), Some(&Action::SwitchTool("type".into()))); + assert!(matches!(&a[1], Action::Exec(id, p) if id == "text.placeCaret" && p["frame"] == fid.0), "{a:?}"); + // Type tool: word, line, paragraph, then the story. + let mut t = create("type"); + let unit = |a: Vec<Action>| match a.as_slice() { + [Action::Exec(id, p)] if id == "text.selectAt" => p["unit"].as_str().map(str::to_string), + [Action::Exec(id, _)] if id == "text.placeCaret" => None, + other => panic!("{other:?}"), + }; + assert_eq!(unit(t.pointer(&cx, &press(1))), None); + for (n, want) in [(2, "word"), (3, "line"), (4, "paragraph"), (5, "story"), (9, "story")] { + assert_eq!(unit(t.pointer(&cx, &press(n))).as_deref(), Some(want), "{n} clicks"); + } + // A double click sent as such (control channel) selects a word too. + assert_eq!(unit(t.pointer(&cx, &PointerEvent::new(PointerKind::DoubleClick, x, y))).as_deref(), Some("word")); + // Moving a little while pressed keeps the unit selected. + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x + 2.0, y)).is_empty()); +} + #[test] fn pen_continues_an_open_path_from_either_end_point() { let mut d = Document::new(&NewDocument::default()); diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index f166cf8d6..e6ef1a5fe 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -5,7 +5,7 @@ use designcraft_geom::Point; use serde_json::json; -use crate::{Action, Cursor, Mods, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, frame::drag_rect, rect_json, spread_json}; +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, frame::snapped_drag_rect, rect_json, spread_json}; #[derive(Default)] pub struct TypeTool { @@ -17,6 +17,10 @@ pub struct TypeTool { /// A press on a table whose whole rows or columns are selected: (frame, press point in /// spread space) — a drag moves them, a click places the caret. cell_drag: Option<(u64, Point)>, + /// Snap guides while drawing a frame. + guides: Vec<Overlay>, + /// The press selected a word, line, paragraph or story (several clicks): drags keep it. + unit_selected: bool, } /// Are whole rows or whole columns of a table selected? @@ -45,33 +49,39 @@ impl Tool for TypeTool { self.drawing = false; self.selecting = None; self.cell_drag = None; + self.unit_selected = false; if let Some((_, id)) = cx.hit(ev.pos) && let Some(it) = cx.doc.item(id) && !matches!(it.content, designcraft_doc::Content::Graphic(_) | designcraft_doc::Content::Group { .. }) { let sp = cx.layout.spread_at(ev.pos).map(|(_, p)| p).unwrap_or(ev.pos); - // Clicks in a row select a word, a sentence, a paragraph, then the whole story. - let unit = match (ev.kind, ev.clicks) { - (PointerKind::DoubleClick, _) => Some("text.selectWord"), - (_, 3) => Some("text.selectSentence"), - (_, 4) => Some("text.selectParagraph"), - (_, 5..) => Some("text.selectStory"), - _ => None, - }; + let clicks = ev.click_count(); // Whole rows/columns selected: wait to see whether this is a drag. - if unit.is_none() && !ev.mods.shift && whole_rows_or_cols(cx) { + if clicks == 1 && !ev.mods.shift && whole_rows_or_cols(cx) { self.cell_drag = Some((id.0, sp)); return vec![]; } self.selecting = Some(id.0); - let cmd = unit.unwrap_or(if ev.mods.shift { "text.extendTo" } else { "text.placeCaret" }); + // Two presses select a word (or a run of spaces), three a line, four a + // paragraph, five the story. + if clicks >= 2 { + self.unit_selected = true; + let unit = match clicks { + 2 => "word", + 3 => "line", + 4 => "paragraph", + _ => "story", + }; + return vec![Action::Exec("text.selectAt".into(), json!({"frame": id.0, "point": [sp.x, sp.y], "unit": unit}))]; + } + let cmd = if ev.mods.shift { "text.extendTo" } else { "text.placeCaret" }; return vec![Action::Exec(cmd.into(), json!({"frame": id.0, "point": [sp.x, sp.y]}))]; } vec![] } PointerKind::Drag => { let Some(a) = self.start else { return vec![] }; - if self.cell_drag.is_some() { + if self.cell_drag.is_some() || self.unit_selected { return vec![]; } if let Some(fid) = self.selecting { @@ -82,7 +92,10 @@ impl Tool for TypeTool { return vec![]; } let Some((sr, sa)) = cx.layout.spread_at(a) else { return vec![] }; - let r = drag_rect(sa, cx.layout.to_spread(sr, ev.pos), ev.mods); + // Drawn like the frame tools: the press point and the moving edges snap. + let sa = if cx.snap.any() { crate::snap::snap_point(cx, sr, sa).0 } else { sa }; + let (r, guides) = snapped_drag_rect(cx, sr, sa, cx.layout.to_spread(sr, ev.pos), ev.mods); + self.guides = guides; let mut out = vec![]; if !self.drawing { self.drawing = true; @@ -95,6 +108,7 @@ impl Tool for TypeTool { out } PointerKind::Up => { + self.guides.clear(); let start = self.start.take(); if let Some((fid, from)) = self.cell_drag.take() { let sp = cx.layout.spread_at(ev.pos).map(|(_, p)| p).unwrap_or(ev.pos); @@ -147,6 +161,10 @@ impl Tool for TypeTool { } } + fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> { + if self.drawing { self.guides.clone() } else { vec![] } + } + fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor { match cx.hit(p) { Some((_, id)) if cx.doc.item(id).is_some_and(|i| i.is_text_frame()) => Cursor::Text, diff --git a/crates/tools/src/thread.rs b/crates/tools/src/thread.rs new file mode 100644 index 000000000..0a1d69f9e --- /dev/null +++ b/crates/tools/src/thread.rs @@ -0,0 +1,259 @@ +//! Text threads with the Selection tool: the in and out ports of selected text frames, and the +//! loaded text cursor a click on a port gives. +//! +//! Loaded from an out port, a click threads the clicked frame after the port's frame; loaded +//! from an in port, in front of it. A click on empty space makes a frame the width of the column +//! there, a drag makes one of the dragged size. Clicking the frame on the other side of an +//! existing link breaks the thread there. Shift autoflows, Alt stays loaded with the rest. + +use designcraft_doc::{Document, Item, ItemId, SpreadRef}; +use designcraft_geom::{Point, Rect}; +use serde_json::{Value, json}; + +use crate::{CanvasLayout, Cursor, Mods, Overlay, ToolContext, rect_json, spread_json}; + +/// Port square size, in screen pixels. +pub const PORT_SIZE_PX: f64 = 8.5; +/// The in port sits this far below the top-left corner, the out port this far above the +/// bottom-right corner (screen pixels along the frame edge). +const IN_PORT_PX: f64 = 14.5; +const OUT_PORT_PX: f64 = 12.0; +/// Screen pixels around a port that still hit it. +const PORT_SLOP_PX: f64 = 2.0; +/// A click frame shorter than this (the click was at or below the bottom margin) gets +/// [`CLICK_FRAME_HEIGHT`] instead. +const MIN_CLICK_FRAME_HEIGHT: f64 = 12.0; +const CLICK_FRAME_HEIGHT: f64 = 72.0; + +/// Canvas centre of a text frame's in port (on its left edge below the top-left corner) or out +/// port (on its right edge above the bottom-right corner), at `zoom` screen pixels per point. +pub fn port_center(doc: &Document, layout: &CanvasLayout, id: ItemId, out: bool, zoom: f64) -> Option<Point> { + let it = doc.item(id)?; + let loc = doc.find(id)?; + let m = layout.xf(loc.spread) * doc.parent_xf(&loc) * it.xf; + let r = it.inner_bounds(); + let (corner, toward) = if out { (Point::new(r.x1, r.y1), Point::new(r.x1, r.y0)) } else { (Point::new(r.x0, r.y0), Point::new(r.x0, r.y1)) }; + let c = m * corner; + let edge = (m * toward) - c; + let len = edge.hypot(); + let step = (if out { OUT_PORT_PX } else { IN_PORT_PX }) / zoom.max(1e-9); + if !(len.is_finite() && len > 1e-9 && step.is_finite()) { + return Some(c); + } + // Along the edge, never past its middle on a tiny frame. + Some(c + edge * (step.min(len / 2.0) / len)) +} + +/// The port of a selected text frame under `p` (canvas): (frame, out port?). +pub fn port_at(cx: &ToolContext, p: Point) -> Option<(ItemId, bool)> { + let half = cx.tol(PORT_SIZE_PX / 2.0 + PORT_SLOP_PX); + for id in &cx.selection.items { + if !cx.doc.item(*id).is_some_and(Item::is_text_frame) { + continue; + } + for out in [true, false] { + if let Some(c) = port_center(cx.doc, cx.layout, *id, out, cx.zoom) + && (c.x - p.x).abs() <= half + && (c.y - p.y).abs() <= half + { + return Some((*id, out)); + } + } + } + None +} + +/// The frame a click with the loaded cursor makes on empty space (spread coordinates): the width +/// of the column under the click (the nearest column of the nearest page), from the click down +/// to the bottom margin. Off the page's columns it starts at the click, a column wide. +pub fn click_frame_rect(doc: &Document, sr: SpreadRef, p: Point) -> Option<Rect> { + let sp = doc.spread(sr)?; + let page = sp.pages.get(sp.page_at_x(p.x)?)?; + let cols = page.column_rects(); + let dist = |c: &Rect| { + if p.x < c.x0 { + c.x0 - p.x + } else if p.x > c.x1 { + p.x - c.x1 + } else { + 0.0 + } + }; + let col = cols.iter().min_by(|a, b| dist(a).total_cmp(&dist(b)))?; + let on_page = p.x >= page.x && p.x <= page.x + page.width; + let (x0, x1) = if on_page { (col.x0, col.x1) } else { (p.x, p.x + col.width()) }; + let y1 = if col.y1 - p.y >= MIN_CLICK_FRAME_HEIGHT { col.y1 } else { p.y + CLICK_FRAME_HEIGHT }; + let r = Rect::new(x0, p.y, x1.max(x0 + 1.0), y1); + [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()).then_some(r) +} + +/// Is a press at `start` released at `p` (canvas) a drag rather than a click? +pub(crate) fn is_drag(cx: &ToolContext, start: Point, p: Point) -> bool { + (p - start).hypot() >= cx.tol(3.0) +} + +/// The frame a drag with the loaded cursor from `start` to `p` (canvas) makes, in spread +/// coordinates, and the snap guides to draw. It is drawn like the frame tools (the press point +/// and the moving edges snap) without their modifiers: Shift and Alt autoflow here. +pub(crate) fn drag_frame(cx: &ToolContext, start: Point, p: Point) -> Option<(SpreadRef, Rect, Vec<Overlay>)> { + let (sr, sa) = cx.layout.spread_at(start)?; + let sa = if cx.snap.any() { crate::snap::snap_point(cx, sr, sa).0 } else { sa }; + let (r, guides) = crate::frame::snapped_drag_rect(cx, sr, sa, cx.layout.to_spread(sr, p), Mods::default()); + Some((sr, r, guides)) +} + +/// The loaded text cursor. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Loaded { + /// The frame whose port was clicked, or the frame semi-autoflow moved on to. + pub frame: ItemId, + /// Loaded from the out port (thread after `frame`) or the in port (in front of it). + pub out: bool, + /// Semi-autoflow: the frame expected to join on this side once the click's command has run. + /// The load moves on to it only if the document has it there. + pub advance: Option<Joins>, + /// The frame semi-autoflow moved on from: loaded again once `frame` is no longer its + /// neighbour on this side (undo). + pub back: Option<ItemId>, +} + +/// The frame a `text.thread` from the loaded cursor adds on the loaded side. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Joins { + /// This existing frame. + Frame(ItemId), + /// A new frame: any frame other than the neighbour there before the click (`None`: none). + NewFrameReplacing(Option<ItemId>), +} + +/// A click with the loaded cursor: the command, and for `text.thread` the frame it adds. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Clicked { + pub id: &'static str, + pub params: Value, + pub joins: Option<Joins>, +} + +/// What a click with the loaded cursor does at a point. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Target { + /// Nothing there: a new frame. + Empty, + /// A frame that can join the thread. + Thread(ItemId), + /// The frame on the other side of the port's link: break the thread there. + Unthread, + /// A frame that can't join (the loaded frame itself, a picture, a frame text flows into…). + Invalid, +} + +impl Loaded { + pub fn new(frame: ItemId, out: bool) -> Self { + Self { frame, out, advance: None, back: None } + } + + /// The loaded frame now, or None when that frame is gone (undo, delete). A semi-autoflow load + /// moves on to the frame its click added once the document has it there, and goes back to + /// the frame it came from once an undo takes that frame out again. + pub fn resolve(self, doc: &Document) -> Option<Loaded> { + let usable = |id: ItemId| doc.item(id).is_some_and(Item::is_text_frame); + let mut l = self; + if let Some(b) = l.back + && usable(b) + && Loaded::new(b, l.out).neighbour(doc) != Some(l.frame) + { + l = Loaded::new(b, l.out); + } + if let Some(joins) = l.advance.take() + && let Some(n) = l.neighbour(doc) + && usable(n) + { + let arrived = match joins { + Joins::Frame(f) => n == f, + Joins::NewFrameReplacing(before) => Some(n) != before, + }; + if arrived { + l = Loaded { frame: n, out: l.out, advance: None, back: Some(l.frame) }; + } + } + usable(l.frame).then_some(l) + } + + /// The next frame from an out port, the previous one from an in port. + fn neighbour(self, doc: &Document) -> Option<ItemId> { + if self.out { doc.next_frame(self.frame) } else { doc.prev_frame(self.frame) } + } + + /// `text.thread` `from`/`to` for joining frame `t` (None: a new frame) on this side. + fn pair(self, doc: &Document, t: Option<ItemId>) -> (Option<ItemId>, Option<ItemId>) { + match (self.out, doc.prev_frame(self.frame)) { + (true, _) => (Some(self.frame), t), + (false, Some(prev)) => (Some(prev), t), + (false, None) => (t, Some(self.frame)), + } + } + + pub fn target(self, cx: &ToolContext, p: Point) -> Target { + let Some((_, id)) = cx.hit(p) else { return Target::Empty }; + if Some(id) == self.neighbour(cx.doc) { + return Target::Unthread; + } + match self.pair(cx.doc, Some(id)) { + (Some(from), Some(to)) if cx.doc.check_thread(from, to).is_ok() => Target::Thread(id), + _ => Target::Invalid, + } + } + + pub fn cursor(self, cx: &ToolContext, p: Point) -> Cursor { + match self.target(cx, p) { + Target::Empty => Cursor::LoadedText, + Target::Thread(_) => Cursor::ThreadLink, + Target::Unthread => Cursor::Unthread, + Target::Invalid if cx.hit(p).is_some_and(|(_, id)| id == self.frame) => Cursor::LoadedText, + Target::Invalid => Cursor::NotAllowed, + } + } + + /// The command for a click at `p`, or a drag from `start` to `p` (canvas). None: nothing to do. + pub fn click(self, cx: &ToolContext, start: Point, p: Point, mods: Mods) -> Option<Clicked> { + let new_frame = Joins::NewFrameReplacing(self.neighbour(cx.doc)); + let (pair, new_rect, joins) = if is_drag(cx, start, p) { + let (sr, r, _) = drag_frame(cx, start, p)?; + (self.pair(cx.doc, None), Some((sr, r)), new_frame) + } else { + match self.target(cx, p) { + Target::Unthread => { + let side = if self.out { "after" } else { "before" }; + return Some(Clicked { id: "text.unthread", params: json!({"frame": self.frame.0, "side": side}), joins: None }); + } + Target::Invalid => return None, + Target::Thread(t) => (self.pair(cx.doc, Some(t)), None, Joins::Frame(t)), + Target::Empty => { + let (sr, sp) = cx.layout.spread_at(p)?; + (self.pair(cx.doc, None), Some((sr, click_frame_rect(cx.doc, sr, sp)?)), new_frame) + } + } + }; + let mut params = serde_json::Map::new(); + if let Some(f) = pair.0 { + params.insert("from".into(), json!(f.0)); + } + if let Some(t) = pair.1 { + params.insert("to".into(), json!(t.0)); + } + if let Some((sr, r)) = new_rect { + params.insert("rect".into(), rect_json(r)); + params.insert("spread".into(), spread_json(sr)); + } + if mods.shift { + params.insert("autoflow".into(), json!(true)); + } + Some(Clicked { id: "text.thread", params: Value::Object(params), joins: Some(joins) }) + } +} + +/// Double-click on a port: break the link it shows, if it has one. +pub(crate) fn unthread_at_port(doc: &Document, frame: ItemId, out: bool) -> Option<(&'static str, Value)> { + let linked = if out { doc.next_frame(frame).is_some() } else { doc.prev_frame(frame).is_some() }; + linked.then(|| ("text.unthread", json!({"frame": frame.0, "side": if out { "after" } else { "before" }}))) +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 077ae2944..b2ce3172a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -139,6 +139,17 @@ pub fn current_slot(app: &DesignApp, layout: &CanvasLayout) -> Option<usize> { .map(|(i, _)| i) } +/// The spread nearest the view centre and the centre in that spread's coordinates. +pub fn view_centre(app: &DesignApp) -> Option<(SpreadRef, Point)> { + let st = app.session.active()?; + let layout = CanvasLayout::new(&st.doc, st.editing_parents); + let slot = layout.slots.get(current_slot(app, &layout)?)?; + let v = app.view()?; + let r = app.canvas_rect?; + let c = Point::new(v.origin.x + r.width() as f64 / 2.0 / v.zoom, v.origin.y + r.height() as f64 / 2.0 / v.zoom); + Some((slot.spread, slot.to_spread(c))) +} + /// Absolute page index under the view centre. pub fn current_page(app: &DesignApp) -> Option<usize> { let st = app.session.active()?; @@ -338,6 +349,36 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { app.session.cursor(xf.to_canvas(p), ui.input(|i| mods(i, false)), app.view_info()) }; ui.ctx().set_cursor_icon(cursor_icon(c)); + draw_cursor_badge(&painter, p, c, &t); + } +} + +/// The loaded text cursor's badge beside the system cursor: lines of text, a closed chain +/// (a click threads) or a broken one (a click unthreads). Drawn in code. +fn draw_cursor_badge(painter: &egui::Painter, p: Pos2, c: Cursor, t: &Tokens) { + if !matches!(c, Cursor::LoadedText | Cursor::ThreadLink | Cursor::Unthread) { + return; + } + let r = Rect::from_min_size(p + vec2(12.0, 12.0), vec2(20.0, 16.0)); + painter.rect_filled(r, 3.0, t.measure_bg); + let ink = Stroke::new(1.5, t.measure_text); + let c0 = r.center(); + match c { + Cursor::LoadedText => { + for (dy, w) in [(-4.0, 12.0), (0.0, 12.0), (4.0, 8.0)] { + painter.line_segment([pos2(c0.x - 6.0, c0.y + dy), pos2(c0.x - 6.0 + w, c0.y + dy)], ink); + } + } + _ => { + let gap = if c == Cursor::Unthread { 2.5 } else { -2.0 }; + for side in [-1.0, 1.0] { + let link = Rect::from_center_size(pos2(c0.x + side * (3.5 + gap / 2.0), c0.y), vec2(9.0, 6.0)); + painter.rect_stroke(link, 3.0, ink, StrokeKind::Middle); + } + if c == Cursor::Unthread { + painter.line_segment([pos2(c0.x + 2.0, c0.y - 6.0), pos2(c0.x - 2.0, c0.y + 6.0)], ink); + } + } } } @@ -361,6 +402,8 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon { Cursor::ZoomOut => C::ZoomOut, Cursor::Eyedropper => C::Crosshair, Cursor::LoadedText | Cursor::LoadedGraphic => C::Copy, + Cursor::ThreadLink => C::Alias, + Cursor::Unthread => C::NoDrop, Cursor::NotAllowed => C::NotAllowed, } } @@ -810,7 +853,7 @@ fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Docum union = Some(union.map_or(b, |u| u.union(b))); // Text frame ports. if let Content::Text(tf) = &it.content { - draw_ports(app, painter, xf, doc, layout, it, tf.story, a, color); + draw_ports(app, painter, xf, doc, layout, it, tf.story, color); } if let Content::Graphic(g) = &it.content && sel.content @@ -903,7 +946,6 @@ fn draw_ports( layout: &CanvasLayout, it: &Item, sid: designcraft_doc::StoryId, - a: Affine, color: Color32, ) { let Some(story) = doc.story(sid) else { return }; @@ -911,14 +953,11 @@ fn draw_ports( let is_last = pos + 1 == story.frames.len(); let cs = app.session.cache.get(doc, sid, None); let overset = is_last && cs.is_overset(); - let s = 8.5; - let _ = layout; - let r = it.inner_bounds(); - let m = a * it.xf; + let s = designcraft_tools::thread::PORT_SIZE_PX as f32; // In port on the left edge below the top-left corner; out port on the right edge above the - // bottom-right corner (screen-space offsets). - let inp = xf.to_screen(m * Point::new(r.x0, r.y0)) + vec2(0.0, 14.5); - let outp = xf.to_screen(m * Point::new(r.x1, r.y1)) - vec2(0.0, 12.0); + // bottom-right corner. The Selection tool hit-tests the same places. + let port = |out: bool| designcraft_tools::thread::port_center(doc, layout, it.id, out, xf.zoom).map(|c| xf.to_screen(c)); + let (Some(inp), Some(outp)) = (port(false), port(true)) else { return }; for (c, has_link, is_out) in [(inp, pos > 0, false), (outp, !is_last, true)] { let pr = Rect::from_center_size(c, vec2(s, s)); painter.rect_filled(pr, 0.0, Color32::WHITE); @@ -1011,6 +1050,15 @@ fn draw_text_selection( } let past = e == l.range.end && range.end > l.range.end; quads.extend(compose::highlight_quads(l, s, e, past)); + // A selected drop cap is highlighted down to its baseline. + if let Some(dc) = l.drop_cap { + let sel = l.glyphs.iter().filter(|g| g.len > 0 && g.byte < dc.end && g.byte >= s && g.byte < e); + let (a, b) = sel.fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), g| (a.min(g.x), b.max(g.x + g.adv))); + if a < b { + let r = dc.rect; + quads.push([Point::new(a, r.y0), Point::new(b, r.y0), Point::new(b, r.y1), Point::new(a, r.y1)]); + } + } for (gi, g) in l.glyphs.iter().enumerate() { if g.visible && g.len > 0 && g.byte >= s && g.byte < e { glyphs.push((li, gi)); @@ -1366,19 +1414,6 @@ fn fmt_tick(v: f64) -> String { if (v - r).abs() < 1e-6 { format!("{}", r as i64) } else { format!("{v:.1}") } } -/// Presses in a row, this one included: each within egui's double-click delay and distance of the -/// one before. Counted on the press, as text selection wants (egui counts releases, up to three). -fn count_click(ui: &egui::Ui, id: egui::Id, at: Pos2, now: f64) -> u8 { - let (delay, dist) = ui.ctx().options(|o| (o.input_options.max_double_click_delay, o.input_options.max_click_dist)); - let last: Option<(f64, Pos2, u8)> = ui.data(|d| d.get_temp(id)); - let n = match last { - Some((t, p, n)) if now - t < delay && p.distance(at) < dist => n.saturating_add(1), - _ => 1, - }; - ui.data_mut(|d| d.insert_temp(id, (now, at, n))); - n -} - fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rect: Rect) { let Some(v) = app.view().copied() else { return }; let xf = Xf::new(rect, &v); @@ -1421,15 +1456,19 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r let mut events: Vec<PointerEvent> = Vec::new(); let down_id = egui::Id::new(("canvas_pointer_down", app.pane)); let mut down: bool = ui.data(|d| d.get_temp(down_id)).unwrap_or(false); + let clicks_id = egui::Id::new(("canvas_clicks", app.pane)); let (pressed, released, origin, latest, now) = ui.input(|i| (i.pointer.primary_pressed(), i.pointer.primary_released(), i.pointer.press_origin(), i.pointer.latest_pos(), i.time)); if pressed && !space && let Some(o) = origin.filter(|o| (if xf.rot == 0 { rect.contains(*o) } else { resp.rect.contains(*o) }) && resp.hovered()) { - let clicks = count_click(ui, egui::Id::new(("canvas_clicks", app.pane)), o, now); - let kind = if clicks == 2 { PointerKind::DoubleClick } else { PointerKind::Down }; - events.push(PointerEvent { kind, pos: pos(o), mods: m, clicks }); + // Count presses in a row here: egui reports double and triple clicks only on release and + // stops at three, but a press must know (word, line, paragraph, story). + let (delay, dist) = ui.ctx().options(|o| (o.input_options.max_double_click_delay, o.input_options.max_click_dist)); + let n = press_count(ui.data(|d| d.get_temp(clicks_id)), o, now, delay, dist); + ui.data_mut(|d| d.insert_temp(clicks_id, Clicks { at: f64::NEG_INFINITY, pos: o, n })); + events.push(PointerEvent { kind: PointerKind::Down, pos: pos(o), mods: m, clicks: n }); down = true; } if down @@ -1437,7 +1476,7 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r && resp.drag_delta() != egui::Vec2::ZERO && let Some(p) = latest { - events.push(PointerEvent { kind: PointerKind::Drag, pos: pos(p), mods: m, clicks: 0 }); + events.push(PointerEvent { kind: PointerKind::Drag, pos: pos(p), mods: m, clicks: 1 }); } // Power Zoom: holding the Hand tool still for half a second turns the press into one. let hold_id = egui::Id::new(("canvas_hold", app.pane)); @@ -1447,7 +1486,7 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r let still = origin_pos.zip(latest).is_some_and(|(o, l)| (o - l).length() < 3.0); if !fired && still && now - start >= 0.5 { let p = pos(latest.unwrap_or(rect.center())); - events.push(PointerEvent { kind: PointerKind::Up, pos: p, mods: m, clicks: 0 }); + events.push(PointerEvent { kind: PointerKind::Up, pos: p, mods: m, clicks: 1 }); events.push(PointerEvent { kind: PointerKind::Down, pos: p, mods: Mods { alt: true, ..m }, clicks: 1 }); ui.data_mut(|d| d.insert_temp(hold_id, (start, true))); } else { @@ -1460,11 +1499,15 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r ui.data_mut(|d| d.remove::<(f64, bool)>(hold_id)); } if down && released { + if let Some(c) = ui.data(|d| d.get_temp::<Clicks>(clicks_id)) { + let dist = ui.ctx().options(|o| o.input_options.max_click_dist); + ui.data_mut(|d| d.insert_temp(clicks_id, release_clicks(c, latest.unwrap_or(c.pos), now, dist))); + } let p = latest.unwrap_or(rect.center()); - events.push(PointerEvent { kind: PointerKind::Up, pos: pos(p), mods: m, clicks: 0 }); + events.push(PointerEvent { kind: PointerKind::Up, pos: pos(p), mods: m, clicks: 1 }); down = false; } else if !down && let Some(p) = resp.hover_pos() { - events.push(PointerEvent { kind: PointerKind::Move, pos: pos(p), mods: m, clicks: 0 }); + events.push(PointerEvent { kind: PointerKind::Move, pos: pos(p), mods: m, clicks: 1 }); } ui.data_mut(|d| d.insert_temp(down_id, down)); for e in events { @@ -1494,11 +1537,20 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r let _ = app.run(id, json!({"text": t})); } egui::Event::Copy | egui::Event::Cut if wants_text => { + // `run` puts the copied text on the system clipboard. let id = if matches!(e, egui::Event::Cut) { "edit.cut" } else { "edit.copy" }; - if let Ok(r) = app.run(id, json!({})) - && let Some(t) = r.get("text").and_then(serde_json::Value::as_str) - { - ui.ctx().copy_text(t.to_string()); + let _ = app.run(id, json!({})); + } + // Without a native menu bar the copy and paste keys arrive only as these events. + egui::Event::Copy | egui::Event::Cut | egui::Event::Paste(_) if !app.native_menu => { + let (id, params) = match e { + egui::Event::Copy => ("edit.copy", serde_json::Value::Null), + egui::Event::Cut => ("edit.cut", serde_json::Value::Null), + egui::Event::Paste(t) => ("edit.paste", json!({"text": t})), + _ => continue, + }; + if crate::menus::enabled(app, id) { + crate::menus::activate(app, id, ¶ms); } } egui::Event::Key { key, pressed: true, modifiers, .. } => { @@ -1771,6 +1823,31 @@ fn guide_drag(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rec true } +/// Presses in a row on the canvas: the last press's count, and where and when it was released +/// (`at` is −∞ while the button is down). +#[derive(Clone, Copy, Debug)] +struct Clicks { + at: f64, + pos: Pos2, + n: u8, +} + +/// The count of a press at `pos` at time `now`: one more than the last press when it comes within +/// the double-click delay of the last release and near where that release was, else 1. +fn press_count(last: Option<Clicks>, pos: Pos2, now: f64, delay: f64, dist: f32) -> u8 { + match last { + Some(c) if now - c.at <= delay && (pos - c.pos).length() <= dist => c.n.saturating_add(1), + _ => 1, + } +} + +/// The record a release at `pos` leaves: a release away from its press ended a drag, which is +/// not a click, so the next press counts from one. +fn release_clicks(press: Clicks, pos: Pos2, now: f64, dist: f32) -> Clicks { + let n = if (pos - press.pos).length() <= dist { press.n } else { 0 }; + Clicks { at: now, pos, n } +} + #[cfg(test)] mod tests { use designcraft_geom::Unit; @@ -1778,6 +1855,23 @@ mod tests { use super::*; + #[test] + fn a_press_after_a_drag_is_not_a_double_click() { + let (delay, dist) = (0.3, 6.0); + let press = |last, x: f32, now| press_count(last, pos2(x, 0.0), now, delay, dist); + // Click, click: a double click. + let n = press(None, 0.0, 0.0); + let up = release_clicks(Clicks { at: f64::NEG_INFINITY, pos: pos2(0.0, 0.0), n }, pos2(0.0, 0.0), 0.1, dist); + assert_eq!(press(Some(up), 1.0, 0.2), 2); + // A drag from 0 to 100, then a fast press where it started or where it ended: a single click. + let up = release_clicks(Clicks { at: f64::NEG_INFINITY, pos: pos2(0.0, 0.0), n: 1 }, pos2(100.0, 0.0), 0.1, dist); + assert_eq!(press(Some(up), 0.0, 0.2), 1); + assert_eq!(press(Some(up), 100.0, 0.2), 1); + // Too slow: a single click. + let up = release_clicks(Clicks { at: f64::NEG_INFINITY, pos: pos2(0.0, 0.0), n: 1 }, pos2(0.0, 0.0), 0.1, dist); + assert_eq!(press(Some(up), 0.0, 1.0), 1); + } + 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| { @@ -1881,10 +1975,10 @@ mod tests { } #[test] - fn clicks_in_a_row_select_word_sentence_paragraph_and_story() { + fn clicks_in_a_row_select_word_line_paragraph_with_return_and_story() { let text = "One two. Three four! Five six.\nSecond para here."; let (mut h, id) = typing(text); - // Inside "Three", the second sentence: its start is not the paragraph's. + // Inside "Three": double-click selects the word, then line and paragraph with return. let p = screen_at(&mut h, id, 10..14); h.hover_at(p); h.step(); @@ -1894,7 +1988,7 @@ mod tests { let r = selected(&h); got.push(text[r].to_string()); } - assert_eq!(got[1..], ["Three", "Three four! ", "One two. Three four! Five six.", text], "{got:?}"); + assert_eq!(got[1..], ["Three", "One two. Three four! Five six.", "One two. Three four! Five six.\n", text], "{got:?}"); assert!(got[0].is_empty(), "one click places the caret: {got:?}"); } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 5a4de0058..15da79df8 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -100,7 +100,16 @@ fn key_from(name: &str) -> Option<egui::Key> { }) } +/// Handle one control request. Commands it runs leave the user's system clipboard alone. pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { + app.install_clipboard_guard(ctx); + let was = std::mem::replace(&mut app.in_control, true); + let out = handle_request(app, ctx, req); + app.in_control = was; + out +} + +fn handle_request(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { let p = &req.params; let s = |k: &str| p.get(k).and_then(Value::as_str); match req.method.as_str() { @@ -127,6 +136,7 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> let base_mods: Mods = p.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or_default(); let view = app.view_info(); let xf = app.canvas_rect.zip(app.view().copied()).map(|(rect, v)| Xf::new(rect, &v)); + let mut last = None; for e in events { let kind = match e.get("kind").and_then(Value::as_str).unwrap_or("") { "down" => PointerKind::Down, @@ -147,13 +157,19 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> designcraft_geom::Point::new(x, y) }; let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base_mods); - if let Err(e) = app.session.pointer(&PointerEvent { kind, pos, mods, clicks: 0 }, view) { + let clicks = e.get("clicks").and_then(Value::as_u64).map_or(1, |n| n.clamp(1, 255) as u8); + if let Err(e) = app.session.pointer(&PointerEvent { kind, pos, mods, clicks }, view) { return err(e); } app.after_engine(); + last = Some((pos, mods)); } ctx.request_repaint(); - wrap(app.run("document.inspect", json!({})).map(|d| json!({"selection": d["selection"], "tool": app.session.tool_id()}))) + // The tool's cursor where the last event was. + let cursor = last.map(|(pos, mods)| app.session.cursor(pos, mods, view)); + wrap( + app.run("document.inspect", json!({})).map(|d| json!({"selection": d["selection"], "tool": app.session.tool_id(), "cursor": cursor})), + ) } "ui.key" => { let Some(k) = s("key").and_then(key_from) else { return err("unknown or missing `key`") }; @@ -165,21 +181,28 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> mac_cmd: b("cmd") && cfg!(target_os = "macos"), command: b("cmd"), }; - app.synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: m }); - app.synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: m }); + app.control_synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: m }); + app.control_synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: m }); if let Some(t) = s("text") { - app.synthetic.push(egui::Event::Text(t.to_string())); + app.control_synthetic.push(egui::Event::Text(t.to_string())); } ctx.request_repaint(); ok(Value::Null) } "ui.move" => { let pos = egui::pos2(p.get("x").and_then(Value::as_f64).unwrap_or(0.0) as f32, p.get("y").and_then(Value::as_f64).unwrap_or(0.0) as f32); - app.synthetic.push(egui::Event::PointerMoved(pos)); + app.control_synthetic.push(egui::Event::PointerMoved(pos)); ctx.request_repaint(); ok(Value::Null) } "ui.click" | "ui.drag" => { + let count = match p.get("count") { + None => 1, + Some(v) => match v.as_u64().filter(|n| (1..=255).contains(n)) { + Some(n) => n, + None => return err("`count` must be an integer from 1 through 255"), + }, + }; // Screen-space (egui points) pointer input through egui itself: reaches every widget. let f = |k: &str| p.get(k).and_then(Value::as_f64).unwrap_or(0.0) as f32; let button = match s("button") { @@ -197,21 +220,20 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> }; let a = egui::pos2(f("x"), f("y")); let end = if req.method == "ui.drag" { egui::pos2(f("toX"), f("toY")) } else { a }; - app.synthetic.push(egui::Event::PointerMoved(a)); - app.synthetic.push(egui::Event::PointerButton { pos: a, button, pressed: true, modifiers }); + app.control_synthetic.push(egui::Event::PointerMoved(a)); + app.control_synthetic.push(egui::Event::PointerButton { pos: a, button, pressed: true, modifiers }); if req.method == "ui.drag" { // From the caller: a huge count would queue events until memory runs out. let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).clamp(1, 10_000); for i in 1..=steps { let t = i as f32 / steps as f32; - app.synthetic.push(egui::Event::PointerMoved(a + (end - a) * t)); + app.control_synthetic.push(egui::Event::PointerMoved(a + (end - a) * t)); } } - app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: false, modifiers }); - let count = p.get("count").and_then(Value::as_u64).unwrap_or(1); + app.control_synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: false, modifiers }); for _ in 1..count { - app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: true, modifiers }); - app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: false, modifiers }); + app.control_synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: true, modifiers }); + app.control_synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: false, modifiers }); } ctx.request_repaint(); ok(Value::Null) @@ -221,7 +243,7 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> // Focused widgets retain the synthetic keyboard route. Document automation uses // the explicit caret independently of the active tool, unlike ordinary typing. if ctx.text_edit_focused() || app.ui.dialog.is_some() || app.ui.palette.is_some() { - app.synthetic.push(egui::Event::Text(text.to_string())); + app.control_synthetic.push(egui::Event::Text(text.to_string())); ctx.request_repaint(); ok(Value::Null) } else { @@ -439,7 +461,7 @@ mod automation_contract_tests { } assert!(ctx.text_edit_focused()); assert_eq!(call(&mut app, &ctx, "ui.text", json!({"text":"field"}))["ok"], true); - let events = std::mem::take(&mut app.synthetic); + let events = std::mem::take(&mut app.control_synthetic); frame(&mut app, &ctx, events); assert_eq!(app.ui.palette.as_deref(), Some("field")); assert_eq!(app.session.execute("story.get", &json!({"story":f["story"]})).unwrap()["text"], "Hello"); @@ -476,3 +498,117 @@ mod automation_contract_tests { assert!(writes.lock().unwrap().is_empty()); } } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + + #[test] + fn control_paste_never_reads_the_system_clipboard() { + let calls = Arc::new(AtomicUsize::new(0)); + let seen = calls.clone(); + let services = crate::Services { + clipboard_text: Some(Box::new(move || { + seen.fetch_add(1, Ordering::SeqCst); + Some("SECRET".into()) + })), + ..Default::default() + }; + let mut app = DesignApp::new(designcraft_engine::Session::new(), services); + app.run("file.new", json!({})).unwrap(); + let r = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "words"})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + assert!(app.session.active().unwrap().selection.text.is_some(), "the new frame is being typed in"); + let text = |app: &DesignApp| app.session.active().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().text.clone(); + let ctx = egui::Context::default(); + let (req, _rx) = ControlRequest::new("engine.execute", json!({"command": "edit.paste"})); + let _ = handle(&mut app, &ctx, &req); + assert_eq!(calls.load(Ordering::SeqCst), 0, "the control channel must not read the system clipboard"); + assert!(!text(&app).contains("SECRET")); + assert!(!app.in_control); + // The same command chosen by the user does paste the system clipboard. + let _ = app.run("edit.paste", json!({})); + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert!(text(&app).contains("SECRET")); + } +} + +#[cfg(test)] +mod deferred_clipboard_tests { + use super::*; + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + + #[test] + fn synthetic_paste_keeps_mock_clipboard_private() { + let reads = Arc::new(AtomicUsize::new(0)); + let observed = reads.clone(); + let mut app = DesignApp::new( + designcraft_engine::Session::new(), + crate::Services { + clipboard_text: Some(Box::new(move || { + observed.fetch_add(1, Ordering::SeqCst); + Some("SYNTHETIC_CLIPBOARD_SENTINEL".to_string()) + })), + ..Default::default() + }, + ); + app.run("file.new", json!({})).unwrap(); + let frame = app.run("frame.create", json!({"content":"text", "text":"Original", "rect":[72,72,200,100], "caret":false})).unwrap(); + app.run("selection.set", json!({"ids":[frame["id"]]})).unwrap(); + assert!(!app.session.wants_text()); + let ctx = egui::Context::default(); + let (req, _) = ControlRequest::new("ui.key", json!({"key":"V", "cmd":true})); + assert!(matches!(handle(&mut app, &ctx, &req), Outcome::Done(v) if v["ok"]==true)); + assert!(!app.in_control); + for step in 0..3 { + let mut raw = egui::RawInput { time: Some(step as f64 * 0.05), ..Default::default() }; + app.raw_input_hook(&mut raw); + ctx.run_ui(raw, |ui| menus::shortcuts(&mut app, ui.ctx())).textures_delta.clear(); + } + let texts: Vec<String> = app.session.active().unwrap().doc.stories.values().map(|story| story.text.clone()).collect(); + assert_eq!(reads.load(Ordering::SeqCst), 0, "synthetic command read mocked system clipboard; stories={texts:?}"); + assert!(!texts.iter().any(|text| text.contains("SYNTHETIC_CLIPBOARD_SENTINEL"))); + // Once control input is drained, an ordinary user paste still reads its clipboard. + let mut raw = egui::RawInput::default(); + app.raw_input_hook(&mut raw); + app.run("edit.paste", json!({})).unwrap(); + assert_eq!(reads.load(Ordering::SeqCst), 1); + } + #[test] + fn deferred_control_widget_copy_leaves_native_clipboard_output_alone() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + let (req, _) = ControlRequest::new("ui.move", json!({"x":10,"y":10})); + let _ = handle(&mut app, &ctx, &req); + let mut raw = egui::RawInput::default(); + app.raw_input_hook(&mut raw); + let mut private = ctx.run_ui(raw, |ui| ui.ctx().copy_text("control field".into())); + assert!(!private.platform_output.commands.iter().any(|cmd| matches!(cmd, egui::OutputCommand::CopyText(_)))); + private.textures_delta.clear(); + let mut raw = egui::RawInput::default(); + app.raw_input_hook(&mut raw); + let mut native = ctx.run_ui(raw, |ui| ui.ctx().copy_text("native field".into())); + assert!(native.platform_output.commands.iter().any(|cmd| matches!(cmd, egui::OutputCommand::CopyText(text) if text=="native field"))); + native.textures_delta.clear(); + } + + #[test] + fn control_click_rejects_unbounded_counts_before_queuing_input() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + for count in [json!(u64::MAX), json!(256), json!(0), json!(-1), json!(1.5)] { + let (req, _) = ControlRequest::new("ui.click", json!({"x":10,"y":10,"count":count})); + assert!(matches!(handle(&mut app, &ctx, &req), Outcome::Done(v) if v["ok"]==false)); + assert!(app.control_synthetic.is_empty()); + assert!(app.synthetic.is_empty()); + assert!(!app.in_control); + } + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 695a66af1..17a68e7af 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1908,7 +1908,7 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> { } "colorPicker" => { let hex = d.s("hex"); - app.run("object.color", json!({"color": hex, "target": d.s("target")})) + app.run("object.color", json!({"color": hex, "target": d.s("target"), "text": d.b("text")})) } "layerOptions" => { let hidden: Vec<Value> = d.fields.get("layers").and_then(Value::as_array).map(|a| a.iter().filter(|l| l["visible"] == false).map(|l| l["name"].clone()).collect()).unwrap_or_default(); @@ -2312,6 +2312,41 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo }); } "nested" => { + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Drop Caps")).font(semibold(12.0))); + egui::Grid::new("psdc").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + for (label, key, max) in [ + ("Lines:", "dropCapLines", designcraft_compose::MAX_DROP_CAP_LINES), + ("Characters:", "dropCapChars", designcraft_compose::MAX_DROP_CAP_CHARS), + ] { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, label)); + let v = cur(d, &format!("p.{key}"), &pv[key]).as_f64(); + if let Some(n) = crate::widgets::number(ui, &format!("ps{key}"), v, "", 60.0, 0) { + d.fields.insert(format!("p.{key}"), json!((n.max(0.0) as u64).min(max as u64))); + } + ui.end_row(); + } + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Character Style:")); + let style = cur(d, "p.dropCapStyle", &pv["dropCapStyle"]).as_str().unwrap_or("").to_string(); + let style = if style.is_empty() { designcraft_doc::NO_CHAR_STYLE.to_string() } else { style }; + egui::ComboBox::from_id_salt("ps_dropcap_style") + .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &style))) + .width(160.0) + .show_ui(ui, |ui| { + for c in &cnames { + if ui.selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, c))).clicked() { + d.fields.insert("p.dropCapStyle".into(), json!(c)); + } + } + }); + ui.end_row(); + }); + for (label, key) in [("Align Left Edge", "dropCapAlignLeft"), ("Scale for Descenders", "dropCapScaleDescenders")] { + let mut on = cur(d, &format!("p.{key}"), &pv[key]).as_bool().unwrap_or(false); + if ui.checkbox(&mut on, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).changed() { + d.fields.insert(format!("p.{key}"), json!(on)); + } + } + ui.add_space(8.0); crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Nested Styles")).font(semibold(12.0))); let mut list: Vec<Value> = cur(d, "p.nestedStyles", &pv["nestedStyles"]).as_array().cloned().unwrap_or_default(); let mut changed = false; @@ -2367,9 +2402,19 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo )) .width(100.0) .show_ui(ui, |ui| { - for k in - ["sentences", "words", "characters", "letters", "digits", "tab", "forcedLineBreak", "emSpace", "enSpace", "chars"] - { + for k in [ + "sentences", + "words", + "characters", + "letters", + "digits", + "tab", + "forcedLineBreak", + "emSpace", + "enSpace", + "dropcap", + "chars", + ] { if ui.selectable_label(k == kind, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, k))).clicked() { ns["until"] = if k == "chars" { json!({"kind": "chars", "chars": ":"}) } else { json!({"kind": k}) }; changed = true; @@ -3352,7 +3397,14 @@ fn footnote_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { ui, d, "firstBaseline", - &[("ascent", "Ascent"), ("capHeight", "Cap Height"), ("leading", "Leading"), ("xHeight", "x Height"), ("fixed", "Fixed")], + &[ + ("ascent", "Ascent"), + ("capHeight", "Cap Height"), + ("leading", "Leading"), + ("xHeight", "x Height"), + ("emboxHeight", "Em Box Height"), + ("fixed", "Fixed"), + ], ); ui.end_row(); crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Min:")); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 5252c3881..6d6503a00 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -1783,13 +1783,13 @@ const TABLE: &[(&str, [&str; 5])] = &[ ], ), ( - "Select an object to color its fill or stroke.", + "Select an object or text to color its fill or stroke.", [ - "Select an object to color its fill or stroke.", - "Select an object to color its fill or stroke.", - "Select an object to color its fill or stroke.", - "Select an object to color its fill or stroke.", - "选择一个对象以设置其填色或描边颜色。", + "Select an object or text to color its fill or stroke.", + "Select an object or text to color its fill or stroke.", + "Select an object or text to color its fill or stroke.", + "Select an object or text to color its fill or stroke.", + "选择一个对象或文本以设置其填色或描边颜色。", ], ), ( @@ -1969,6 +1969,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Cap Height", ["Cap Height", "Cap Height", "Cap Height", "Cap Height", "大写字母高度"]), ("Leading", ["Leading", "Leading", "Leading", "Leading", "行距"]), ("x Height", ["x Height", "x Height", "x Height", "x Height", "x 字高"]), + ("Em Box Height", ["Em Box Height", "Em Box Height", "Em Box Height", "Em Box Height", "全角字框高度"]), ("Fixed", ["Fixed", "Fixed", "Fixed", "Fixed", "固定"]), ("Never (continuous)", ["Never (continuous)", "Never (continuous)", "Never (continuous)", "Never (continuous)", "从不(连续编号)"]), ("Page", ["Page", "Page", "Page", "Page", "页面"]), @@ -1994,6 +1995,23 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("forcedLineBreak", ["forcedLineBreak", "forcedLineBreak", "forcedLineBreak", "forcedLineBreak", "强制换行符"]), ("emSpace", ["emSpace", "emSpace", "emSpace", "emSpace", "全角空格"]), ("enSpace", ["enSpace", "enSpace", "enSpace", "enSpace", "半角空格"]), + ("dropcap", ["Initiale", "lettrine", "capitular", "ドロップキャップ", "首字下沉"]), + ("Drop Caps", ["Initialen", "Lettrines", "Capitulares", "ドロップキャップ", "首字下沉"]), + ("Lines:", ["Zeilen:", "Lignes :", "Líneas:", "行数:", "行数:"]), + ("Characters:", ["Zeichen:", "Caractères :", "Caracteres:", "文字数:", "字符数:"]), + ("Drop Lines", ["Initialzeilen", "Lignes lettrine", "Líneas capitular", "ドロップ行数", "下沉行数"]), + ("Drop Chars", ["Initialzeichen", "Car. lettrine", "Car. capitular", "ドロップ文字数", "下沉字符数"]), + ("Align Left Edge", ["Linke Kante ausrichten", "Aligner le bord gauche", "Alinear borde izquierdo", "左端揃え", "左边缘对齐"]), + ( + "Scale for Descenders", + [ + "Für Unterlängen skalieren", + "Mise à l'échelle pour les jambages", + "Escalar para trazos descendentes", + "ディセンダーに合わせて拡大・縮小", + "为下伸部分缩放", + ], + ), ("chars", ["chars", "chars", "chars", "chars", "指定字符"]), ("[Automatic]", ["[Automatic]", "[Automatic]", "[Automatic]", "[Automatic]", "[自动]"]), ("Tracking:", ["Tracking:", "Tracking:", "Tracking:", "Tracking:", "字距调整:"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index a182d09ec..769511968 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -299,6 +299,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Ascent", "الصعود"), ("Cap Height", "ارتفاع الأحرف الكبيرة"), ("x Height", "ارتفاع x"), + ("Em Box Height", "ارتفاع مربع em"), ("Fixed", "ثابت"), ("Never (continuous)", "أبدًا (ترقيم متصل)"), ("Spread", "صفحتان متقابلتان"), @@ -323,6 +324,14 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("forcedLineBreak", "فاصل سطر إجباري"), ("emSpace", "مسافة em"), ("enSpace", "مسافة en"), + ("dropcap", "الحرف الاستهلالي"), + ("Drop Caps", "الأحرف الاستهلالية"), + ("Lines:", "الأسطر:"), + ("Characters:", "الأحرف:"), + ("Drop Lines", "أسطر الاستهلال"), + ("Drop Chars", "أحرف الاستهلال"), + ("Align Left Edge", "محاذاة الحافة اليسرى"), + ("Scale for Descenders", "تحجيم للأحرف الهابطة"), ("chars", "محارف محددة"), ("[Automatic]", "[تلقائي]"), ("Size:", "الحجم:"), @@ -971,7 +980,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ "Select a placed video or sound (File › Place a .mp4, .mov, .mp3, .wav …).", "حدد فيديو أو صوتًا مدرجًا (ملف › إدراج ملف .mp4 أو .mov أو .mp3 أو .wav …).", ), - ("Select an object to color its fill or stroke.", "حدد كائنًا لتلوين تعبئته أو حده."), + ("Select an object or text to color its fill or stroke.", "حدد كائنًا أو نصًا لتلوين تعبئته أو حده."), ("Select an object to give its fill a gradient.", "حدد كائنًا لإضافة تدرج إلى تعبئته."), ("Select an object to make it a button.", "حدد كائنًا لتحويله إلى زر."), ("Select an object.", "حدد كائنًا."), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 75ece03f6..747511ae8 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -919,7 +919,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ "Select a placed video or sound (File › Place a .mp4, .mov, .mp3, .wav …).", "Seleziona un video o un audio inserito (File › Inserisci un .mp4, .mov, .mp3, .wav …).", ), - ("Select an object to color its fill or stroke.", "Seleziona un oggetto per colorarne il riempimento o la traccia."), + ("Select an object or text to color its fill or stroke.", "Seleziona un oggetto o del testo per colorarne il riempimento o la traccia."), ("Select an object to give its fill a gradient.", "Seleziona un oggetto per applicare una sfumatura al suo riempimento."), ("Select an object to make it a button.", "Seleziona un oggetto per trasformarlo in pulsante."), ( @@ -980,6 +980,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cap Height", "Altezza maiuscole"), ("Leading", "Interlinea"), ("x Height", "Altezza x"), + ("Em Box Height", "Altezza riquadro em"), ("Fixed", "Fisso"), ("Never (continuous)", "Mai (continua)"), ("Page", "Pagina"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 642b4a4c0..263d4226a 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -915,7 +915,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ "Select a placed video or sound (File › Place a .mp4, .mov, .mp3, .wav …).", "配置した動画または音声を選択します (ファイル › 配置で .mp4、.mov、.mp3、.wav などを配置)。", ), - ("Select an object to color its fill or stroke.", "塗りや線にカラーを設定するオブジェクトを選択してください。"), + ("Select an object or text to color its fill or stroke.", "塗りや線にカラーを設定するオブジェクトまたはテキストを選択してください。"), ("Select an object to give its fill a gradient.", "塗りにグラデーションを適用するオブジェクトを選択してください。"), ("Select an object to make it a button.", "ボタンにするオブジェクトを選択します。"), ( @@ -976,6 +976,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cap Height", "大文字の高さ"), ("Leading", "行送り"), ("x Height", "x の高さ"), + ("Em Box Height", "仮想ボディの高さ"), ("Fixed", "固定"), ("Never (continuous)", "振り直しなし (通し番号)"), ("Page", "ページ"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 982dfb20d..3f955f68f 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -300,6 +300,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Ascent", "Ascendente"), ("Cap Height", "Altura da caixa alta"), ("x Height", "Altura do x"), + ("Em Box Height", "Altura da caixa eme"), ("Fixed", "Fixo"), ("Never (continuous)", "Nunca (contínuo)"), ("Spread", "Página dupla"), @@ -975,7 +976,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Save recovery data every:", "Salvar dados de recuperação a cada:"), ("Saves which bars show and where the panels are.", "Salva quais barras são exibidas e onde ficam os painéis."), ("Search:", "Pesquisar:"), - ("Select an object to color its fill or stroke.", "Selecione um objeto para colorir seu preenchimento ou traçado."), + ("Select an object or text to color its fill or stroke.", "Selecione um objeto ou texto para colorir seu preenchimento ou traçado."), ("Select an object to give its fill a gradient.", "Selecione um objeto para dar um gradiente ao seu preenchimento."), ("Select an object to make it a button.", "Selecione um objeto para transformá-lo em botão."), ("Select an object.", "Selecione um objeto."), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index f3bc8fb35..efccfc166 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -916,7 +916,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ "Select a placed video or sound (File › Place a .mp4, .mov, .mp3, .wav …).", "Виділіть розміщене відео або звук («Файл» › «Розмістити» файл .mp4, .mov, .mp3, .wav …).", ), - ("Select an object to color its fill or stroke.", "Виділіть об’єкт, щоб змінити колір заливки або обведення."), + ("Select an object or text to color its fill or stroke.", "Виділіть об’єкт або текст, щоб змінити колір заливки або обведення."), ("Select an object to give its fill a gradient.", "Виділіть об’єкт, щоб застосувати градієнт до заливки."), ("Select an object to make it a button.", "Виділіть об’єкт, щоб перетворити його на кнопку."), ( @@ -977,6 +977,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cap Height", "Висота великих літер"), ("Leading", "Міжрядковий інтервал"), ("x Height", "Висота малих літер"), + ("Em Box Height", "Висота em-квадрата"), ("Fixed", "Фіксований"), ("Never (continuous)", "Ніколи (наскрізна нумерація)"), ("Page", "Сторінка"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fc7a3e634..1db2f591d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -53,6 +53,7 @@ pub struct ImportedFile { } pub type OpenAsyncFn = Box<dyn FnMut(ImportRequest)>; pub type DownloadFn = Box<dyn FnMut(&str, &[u8])>; +pub type ClipboardFn = Box<dyn FnMut() -> Option<String>>; /// Files delivered asynchronously with their original operation and document identity. pub type Inbox = std::sync::Arc<std::sync::Mutex<Vec<ImportedFile>>>; @@ -73,6 +74,9 @@ pub struct Services { pub download: Option<DownloadFn>, /// Files delivered asynchronously, drained every frame with their captured request context. pub inbox: Option<Inbox>, + /// The system clipboard's text, for Paste chosen from a menu (egui only delivers it with the + /// paste keys, which a native menu bar takes first). + pub clipboard_text: Option<ClipboardFn>, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -264,6 +268,9 @@ pub struct UiState { pub smart_spacing: bool, #[serde(default = "default_zone")] pub snap_zone: f64, + /// Tools panel: Formatting Affects Text (J): with text frames selected, colour edits go to + /// their text instead of the frames. + pub formatting_affects_text: bool, /// Window > Contextual Task Bar. pub task_bar: bool, /// Where the Contextual Task Bar is pinned: its top-left, in points from the canvas's top-left @@ -356,6 +363,7 @@ impl Default for UiState { smart_dimensions: true, smart_spacing: true, snap_zone: 4.0, + formatting_affects_text: false, task_bar: true, task_bar_pin: None, task_bar_at: None, @@ -496,6 +504,10 @@ pub struct DesignApp { pub color_applied: bool, pub perf: Perf, pub synthetic: Vec<egui::Event>, + /// Control input stays separate from native-menu input until its frame is processed. + pub(crate) control_synthetic: Vec<egui::Event>, + control_input_frame: std::sync::Arc<std::sync::atomic::AtomicBool>, + clipboard_guard_installed: bool, /// Story open in the Story Editor. pub story_editor: Option<designcraft_doc::StoryId>, control_rx: Option<Receiver<ControlRequest>>, @@ -515,6 +527,15 @@ pub struct DesignApp { last_time: f64, /// When recovery data was last written (seconds, egui time). pub last_recovery: f64, + /// The egui context (set by the first frame): copied text goes to the system clipboard through it. + egui_ctx: Option<egui::Context>, + /// A control-channel request is being handled: commands it runs never read or write the + /// user's system clipboard (a control client must not see or replace it). + pub(crate) in_control: bool, + /// The system clipboard's text that stands for the last object copy: what that copy put there + /// (no native menu: without it the paste keys produce no paste event), or what it held then + /// (native menu). It is never pasted as text: Paste with it pastes the copied objects. + object_clip_text: Option<String>, /// Quit was chosen: close the window on the next frame, as its close button does. quit_requested: bool, /// Close the window without asking about unsaved changes (the control channel's `app.quit`). @@ -550,6 +571,9 @@ impl DesignApp { color_applied: false, perf: Perf::default(), synthetic: vec![], + control_synthetic: vec![], + control_input_frame: Default::default(), + clipboard_guard_installed: false, story_editor: None, control_rx: None, pending_shots: vec![], @@ -564,6 +588,9 @@ impl DesignApp { native_shortcuts: Default::default(), last_time: 0.0, last_recovery: 0.0, + egui_ctx: None, + in_control: false, + object_clip_text: None, quit_requested: false, quit_without_asking: false, quitting: None, @@ -610,14 +637,102 @@ impl DesignApp { if let Some(r) = menus::run_ui(self, id, ¶ms) { return r; } + // Only user-initiated runs touch the system clipboard, never the control channel's. + let system_clipboard = !self.in_control && !self.control_input_frame.load(std::sync::atomic::Ordering::Relaxed); + let params = if system_clipboard { self.with_system_clipboard(id, params) } else { params }; let r = self.session.execute(id, ¶ms).map_err(|e| e.to_string()); self.after_engine(); - if let Err(e) = &r { - self.status(e.clone()); + match &r { + Err(e) => self.status(e.clone()), + // Copied text goes to the system clipboard too, whichever way Copy was chosen. + Ok(v) if system_clipboard && matches!(id, "edit.copy" | "edit.cut") => { + let text = match v.get("text").and_then(Value::as_str) { + Some(t) => { + self.object_clip_text = None; + Some(t.to_string()) + } + None if !self.native_menu => { + self.object_clip_text = Some(self.copied_objects_text()); + self.object_clip_text.clone() + } + None => { + self.object_clip_text = self.services.clipboard_text.as_mut().and_then(|f| f()); + None + } + }; + if let (Some(t), Some(ctx)) = (text, &self.egui_ctx) { + ctx.copy_text(t); + ctx.request_repaint(); + } + } + Ok(_) => {} } r } + /// Paste without the clipboard's text (a menu, not the paste keys): read it here. With no + /// insertion point, text that doesn't stand for copied objects becomes a new text frame + /// centred in the view. + fn with_system_clipboard(&mut self, id: &str, mut params: Value) -> Value { + let typing = self.session.active().is_some_and(|d| d.selection.text.is_some()); + if !matches!(id, "edit.paste" | "edit.pasteWithoutFormatting") || !(typing || id == "edit.paste") { + return params; + } + if params.get("text").is_none() + && let Some(text) = self.services.clipboard_text.as_mut().and_then(|f| f()) + && let Some(o) = params.as_object_mut() + { + o.insert("text".into(), Value::String(text)); + } + // Still what an object copy put there: paste those objects, not the placeholder text. + let norm = |t: &str| t.replace("\r\n", "\n"); + if let Some(copied) = &self.object_clip_text + && params.get("text").and_then(Value::as_str).is_some_and(|t| norm(t) == norm(copied)) + && let Some(o) = params.as_object_mut() + { + o.remove("text"); + } + if !typing + && params.get("text").and_then(Value::as_str).is_some_and(|t| !t.is_empty()) + && let Some((spread, at)) = canvas::view_centre(self) + && let Ok(spread) = serde_json::to_value(spread) + && let Some(o) = params.as_object_mut() + { + o.entry("spread").or_insert(spread); + o.entry("at").or_insert(json!([at.x, at.y])); + } + params + } + + /// Plain text for the system clipboard after copying objects: the copied text frames' stories, + /// or U+FFFC (object replacement character) when none has text. + fn copied_objects_text(&self) -> String { + let mut stories = Vec::new(); + if let Some(clip) = &self.session.clipboard + && let Some(sp) = clip.spreads.first() + { + for it in &sp.items { + it.walk(&mut |i| { + if let Some(tf) = i.text_frame() + && !stories.contains(&tf.story) + { + stories.push(tf.story); + } + }); + } + } + let text = stories + .iter() + .filter_map(|id| self.session.clipboard.as_ref()?.story(*id)) + .map(|st| { + st.text.chars().filter(|c| !('\u{E000}'..='\u{E1FF}').contains(c)).map(|c| if c == '\u{2028}' { '\n' } else { c }).collect::<String>() + }) + .filter(|t| !t.is_empty()) + .collect::<Vec<_>>() + .join("\n"); + if text.is_empty() { '\u{FFFC}'.to_string() } else { text } + } + /// Handle requests produced by tools/commands (dialogs, view changes, file pickers). pub fn after_engine(&mut self) { let reqs = std::mem::take(&mut self.session.ui_requests); @@ -761,6 +876,9 @@ impl DesignApp { /// Per-frame logic before layout. pub fn logic(&mut self, ctx: &egui::Context) { + if self.egui_ctx.is_none() { + self.egui_ctx = Some(ctx.clone()); + } if !self.color_applied { self.color_applied = true; if let Some(cs) = self.ui.color_settings.clone() { @@ -807,7 +925,7 @@ impl DesignApp { if self.fonts_ready { self.drain_inbox(); } - if !self.synthetic.is_empty() { + if !self.synthetic.is_empty() || !self.control_synthetic.is_empty() { ctx.request_repaint(); } self.collect_screenshots(ctx); @@ -1005,19 +1123,39 @@ impl DesignApp { r } - /// Inject synthetic events (one pointer event per frame). - pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) { - if self.synthetic.is_empty() { + /// Keep widget clipboard output private while a queued control event is running. + pub(crate) fn install_clipboard_guard(&mut self, ctx: &egui::Context) { + if self.clipboard_guard_installed { return; } - let n = match self.synthetic[0] { + let private = self.control_input_frame.clone(); + ctx.on_end_pass( + "control clipboard", + std::sync::Arc::new(move |ui| { + if private.load(std::sync::atomic::Ordering::Relaxed) { + ui.ctx().output_mut(|o| { + o.commands.retain(|cmd| !matches!(cmd, egui::OutputCommand::CopyText(_) | egui::OutputCommand::CopyImage(_))) + }); + } + }), + ); + self.clipboard_guard_installed = true; + } + + /// Inject synthetic events (one pointer event per frame), retaining control provenance. + pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) { + let private = !self.control_synthetic.is_empty(); + self.control_input_frame.store(private, std::sync::atomic::Ordering::Relaxed); + let events = if private { &mut self.control_synthetic } else { &mut self.synthetic }; + let Some(first) = events.first() else { return }; + let n = match first { egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } => 1, - _ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1), + _ => events.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(events.len(), |i| i + 1), }; - if let Some(egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. }) = self.synthetic.first() { + if let egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. } = first { raw.events.push(egui::Event::PointerMoved(*p)); } - raw.events.extend(self.synthetic.drain(..n)); + raw.events.extend(events.drain(..n)); } /// Lay out the whole window. diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 872cb81d0..dc8b88bf2 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -86,6 +86,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("view.zoom", "Zoom To", None, "{zoom: 1.0 = 100%}"), ("view.screenMode", "Screen Mode", None, "{mode: normal|preview|bleed|slug|presentation}"), ("view.togglePreview", "Toggle Normal/Preview", Some("W"), "{}"), + ( + "app.formattingAffectsText", + "Formatting Affects Text", + Some("J"), + "{on?: bool} — with text frames selected, colours go to their text (on) or the frames (off); toggles without `on`", + ), ( "color.defaultFillStroke", "Default Fill and Stroke", @@ -1320,6 +1326,10 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value, "zone": ui.snap_zone, })) } + "app.formattingAffectsText" => { + app.ui.formatting_affects_text = p.get("on").and_then(Value::as_bool).unwrap_or(!app.ui.formatting_affects_text); + Ok(json!({"on": app.ui.formatting_affects_text})) + } "window.richBlack" => { app.ui.rich_black = p.get("on").and_then(Value::as_bool).unwrap_or(!app.ui.rich_black); app.canvas.shown = None; @@ -2482,6 +2492,35 @@ mod tests { assert!(!egui::Popup::is_any_open(&ctx), "choosing a command dismisses the menu"); } + #[test] + fn formatting_affects_text_sends_swatches_to_the_text() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + for (name, color) in [("Cyan", "#00ffff"), ("Magenta", "#ff00ff")] { + let created = app.run("swatch.create", json!({"name": name, "color": color})).unwrap(); + assert_eq!(created["name"], name); + } + let r = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "words", "caret": false})).unwrap(); + let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap()); + app.run("selection.set", json!({"ids": [fid]})).unwrap(); + let text_fill = |app: &crate::DesignApp| { + let st = app.session.active().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().clone(); + st.char_format_at(2).over.fill.clone() + }; + let frame_fill = |app: &crate::DesignApp| app.session.active().unwrap().doc.item(designcraft_doc::ItemId(fid)).unwrap().fill.swatch.clone(); + assert!(!crate::panels::colors_affect_text(&app)); + crate::panels::apply_swatch(&mut app, false, "Cyan", None).unwrap(); + assert_eq!(frame_fill(&app), "Cyan"); + assert_eq!(text_fill(&app), None); + let r = run_ui(&mut app, "app.formattingAffectsText", &json!({})).unwrap().unwrap(); + assert_eq!(r["on"], true, "toggles on"); + assert!(crate::panels::colors_affect_text(&app)); + crate::panels::apply_swatch(&mut app, false, "Magenta", Some(0.5)).unwrap(); + assert_eq!(frame_fill(&app), "Cyan", "the frame is left alone"); + assert_eq!(text_fill(&app).as_deref(), Some("Magenta")); + assert_eq!(crate::panels::color_target(&mut app).map(|t| (t.0, t.1)), Some(("Magenta".to_string(), 0.5))); + } + #[test] fn transform_menu_items_open_their_dialogs() { for id in ["transform.move", "transform.scale", "transform.rotate", "transform.shear"] { @@ -3127,6 +3166,117 @@ mod tests { assert!((a - b).abs() < 0.5, "{:?} at x {a}, {:?} at x {b}", plain.label, with_sc.label); } + #[test] + fn copy_and_paste_keys_work_for_objects_without_a_native_menu() { + // Without a native menu the paste key arrives only as a paste event, which needs text on + // the system clipboard (#164, #185). + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({})).unwrap(); + let ctx = egui::Context::default(); + let frame = |app: &mut crate::DesignApp, events: Vec<egui::Event>| { + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }); + out.textures_delta.clear(); + out.platform_output.commands + }; + let count = |app: &crate::DesignApp| app.session.active().unwrap().doc.spreads[0].items.len(); + frame(&mut app, vec![]); + frame(&mut app, vec![]); + let id = app.session.execute("frame.create", &json!({"rect": [72, 72, 200, 160]})).unwrap()["id"].clone(); + app.session.execute("selection.set", &json!({"ids": [id]})).unwrap(); + let n = count(&app); + let copied = frame(&mut app, vec![egui::Event::Copy]) + .into_iter() + .find_map(|c| match c { + egui::OutputCommand::CopyText(t) => Some(t), + _ => None, + }) + .expect("copying objects puts text on the system clipboard"); + assert!(!copied.is_empty()); + frame(&mut app, vec![egui::Event::Paste(copied.clone())]); + assert_eq!(count(&app), n + 1, "the paste key pastes the copied rectangle"); + + // Pasting into text anchors the copied object; the clipboard placeholder isn't typed. + let r = app.session.execute("frame.create", &json!({"rect": [72, 300, 400, 400], "content": "text", "text": "Hello"})).unwrap(); + let story = r["story"].as_u64().unwrap(); + app.session.execute("tool.select", &json!({"tool": "type"})).unwrap(); + app.session.execute("text.select", &json!({"story": story, "anchor": 5, "focus": 5})).unwrap(); + frame(&mut app, vec![egui::Event::Paste(copied.clone())]); + // Shift pastes without formatting. + frame(&mut app, vec![egui::Event::ModifiersChanged(egui::Modifiers::SHIFT), egui::Event::Paste(copied)]); + let text = app.session.active().unwrap().doc.story(designcraft_doc::StoryId(story)).unwrap().text.clone(); + assert!(text.starts_with("Hello") && !text.contains('\u{FFFC}'), "{text:?}"); + assert!(text.contains(designcraft_doc::OBJECT_MARK), "{text:?}"); + } + + #[test] + fn paste_with_no_insertion_point_makes_a_text_frame_from_other_apps_text() { + // #518: text copied in another app pastes as a new, selected text frame; the text an + // object copy stands for still pastes the objects. One paste each way, with and without + // a native menu. + let selected_text = |app: &crate::DesignApp| { + let d = app.session.active().unwrap(); + let it = d.doc.item(*d.selection.items.first()?)?; + Some(d.doc.story(it.text_frame()?.story)?.text.clone()) + }; + let ctx = egui::Context::default(); + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({})).unwrap(); + native_frame(&mut app, &ctx, vec![], None); + native_frame(&mut app, &ctx, vec![], None); + // The paste keys without a native menu: a paste event. + native_frame(&mut app, &ctx, vec![egui::Event::Paste("From elsewhere".into())], None); + assert_eq!(document_items(&app), (1, 1), "pasted once"); + assert_eq!(selected_text(&app).as_deref(), Some("From elsewhere")); + let id = app.session.execute("frame.create", &json!({"rect": [72, 72, 200, 160]})).unwrap()["id"].clone(); + app.session.execute("selection.set", &json!({"ids": [id]})).unwrap(); + let copied = native_frame(&mut app, &ctx, vec![egui::Event::Copy], None) + .platform_output + .commands + .into_iter() + .find_map(|c| match c { + egui::OutputCommand::CopyText(t) => Some(t), + _ => None, + }) + .expect("copying objects puts text on the system clipboard"); + native_frame(&mut app, &ctx, vec![egui::Event::Paste(copied)], None); + assert_eq!(document_items(&app), (3, 1)); + assert_eq!(selected_text(&app), None, "the copied rectangle, not a text frame"); + + // A native menu: Paste reads the system clipboard. + let clip = std::rc::Rc::new(std::cell::RefCell::new(Some("Menu text".to_string()))); + let read = clip.clone(); + let services = crate::Services { clipboard_text: Some(Box::new(move || read.borrow().clone())), ..Default::default() }; + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), services); + app.native_menu = true; + app.session.execute("file.new", &json!({})).unwrap(); + native_frame(&mut app, &ctx, vec![], None); + native_frame(&mut app, &ctx, vec![], None); + native_frame(&mut app, &ctx, vec![], Some("edit.paste")); + native_frame(&mut app, &ctx, vec![], None); + assert_eq!(document_items(&app), (1, 1), "pasted once"); + assert_eq!(selected_text(&app).as_deref(), Some("Menu text")); + // Objects copied since that text was copied paste as objects. + let id = app.session.execute("frame.create", &json!({"rect": [72, 72, 200, 160]})).unwrap()["id"].clone(); + app.session.execute("selection.set", &json!({"ids": [id]})).unwrap(); + native_frame(&mut app, &ctx, vec![], Some("edit.copy")); + native_frame(&mut app, &ctx, vec![], Some("edit.paste")); + assert_eq!(document_items(&app), (3, 1)); + assert_eq!(selected_text(&app), None, "the copied rectangle, not a text frame"); + // Newer text from another app wins. + *clip.borrow_mut() = Some("Newer".into()); + native_frame(&mut app, &ctx, vec![], Some("edit.paste")); + assert_eq!(document_items(&app), (4, 1)); + assert_eq!(selected_text(&app).as_deref(), Some("Newer")); + } + #[test] fn sample_scripts_run() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 581f6747e..baf9a1251 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -46,6 +46,44 @@ pub struct SelInfo { pub locked: bool, } +/// Colour edits go to text: text is selected, or text frames are selected with Formatting +/// Affects Text on. +pub fn colors_affect_text(app: &DesignApp) -> bool { + let Some(st) = app.session.active() else { return false }; + st.selection.text.is_some() + || (app.ui.formatting_affects_text && st.selection.items.iter().any(|id| st.doc.item(*id).is_some_and(|i| i.is_text_frame()))) +} + +/// Fill and stroke swatches and tints of what colour edits apply to: the text (see +/// [`colors_affect_text`]) or the first selected object. +pub fn color_target(app: &mut DesignApp) -> Option<(String, f32, String, f32)> { + if colors_affect_text(app) { + let a = text_attrs(app)?; + let c = &a["chars"]; + let tint = |k: &str| c[k].as_f64().unwrap_or(1.0) as f32; + return Some((c["fill"].as_str()?.to_string(), tint("fillTint"), c["stroke"].as_str()?.to_string(), tint("strokeTint"))); + } + let i = sel_info(app)?; + Some((i.fill, sel_tint(app, false), i.stroke, sel_tint(app, true))) +} + +/// Apply a swatch (and tint) to the fill or stroke of what colour edits apply to. +pub fn apply_swatch(app: &mut DesignApp, stroke: bool, swatch: &str, tint: Option<f32>) -> Result<Value, String> { + if colors_affect_text(app) { + let (k, kt) = if stroke { ("stroke", "strokeTint") } else { ("fill", "fillTint") }; + let mut attrs = json!({k: swatch}); + if let Some(t) = tint { + attrs[kt] = json!(t); + } + return app.run("type.char", json!({ "attrs": attrs })); + } + let mut p = json!({"swatch": swatch}); + if let Some(t) = tint { + p["tint"] = json!(t); + } + app.run(if stroke { "object.stroke" } else { "object.fill" }, p) +} + /// Fill (or stroke) tint of the first selected item (1 = 100%). pub fn sel_tint(app: &DesignApp, stroke: bool) -> f32 { let Some(st) = app.session.active() else { return 1.0 }; diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index d9c74e40d..4e207c44f 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1347,6 +1347,34 @@ pub fn character_panel(app: &mut DesignApp, ui: &mut egui::Ui) { crate::rtl::label(ui, c["fill"].as_str().unwrap_or("")); }); ui.horizontal(|ui| { + // Bold and Italic pick the family's own styles (style linking). + let (weight, italic) = super::fonts(app).traits_of(c["fontFamily"].as_str().unwrap_or_default(), c["fontStyle"].as_str().unwrap_or_default()); + let tip = |app: &DesignApp, name: &str, id: &str| { + let name = crate::i18n::tr(&app.ui.language, name); + match crate::menus::shortcut_of(app, id) { + Some(sc) => format!("{name} ({})", crate::menus::shortcut_text(&sc)), + None => name.to_string(), + } + }; + let (bold_tip, italic_tip) = (tip(app, "Bold", "type.bold"), tip(app, "Italic", "type.italic")); + if ui + .selectable_label(weight >= 650.0, egui::RichText::new("B").strong()) + .on_hover_ui(|ui| { + crate::rtl::label(ui, bold_tip); + }) + .clicked() + { + let _ = app.run("type.bold", json!({})); + } + if ui + .selectable_label(italic, egui::RichText::new("I").italics()) + .on_hover_ui(|ui| { + crate::rtl::label(ui, italic_tip); + }) + .clicked() + { + let _ = app.run("type.italic", json!({})); + } let caps = c["capitalization"].as_str() == Some("allCaps"); if ui .selectable_label(caps, "TT") @@ -1523,6 +1551,17 @@ pub fn paragraph_panel(app: &mut DesignApp, ui: &mut egui::Ui) { } ui.end_row(); } + // Drop cap: number of lines and characters. + for (label, key, max, id) in [ + ("Drop Lines", "dropCapLines", designcraft_compose::MAX_DROP_CAP_LINES, "ppdl"), + ("Drop Chars", "dropCapChars", designcraft_compose::MAX_DROP_CAP_CHARS, "ppdc"), + ] { + caption(ui, crate::i18n::tr(&app.ui.language, label)); + if let Some(v) = number(ui, id, p[key].as_f64(), "", 64.0, 0) { + let _ = app.run("type.para", json!({"attrs": {key: (v.max(0.0) as u64).min(max as u64)}})); + } + } + ui.end_row(); }); ui.horizontal(|ui| { let mut h = p["hyphenate"].as_bool().unwrap_or(true); diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 7544347a9..967ff38f4 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -11,8 +11,8 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let Some(st) = app.session.active() else { return }; let doc = st.doc.clone(); - let text = st.selection.text.is_some(); - let fill_cur = super::sel_info(app).map(|i| i.fill); + let text = super::colors_affect_text(app); + let fill_cur = super::color_target(app).map(|t| t.0); ui.horizontal(|ui| { crate::rtl::label( ui, @@ -25,7 +25,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { // colour groups are listed under their folder. let grouped: Vec<&str> = doc.color_groups.iter().flat_map(|g| g.swatches.iter().map(String::as_str)).collect(); for sw in doc.swatches.iter().filter(|w| !w.hidden && !grouped.contains(&w.name.as_str())) { - swatch_row(app, ui, &doc, sw, 0.0, fill_cur.as_deref(), text); + swatch_row(app, ui, &doc, sw, 0.0, fill_cur.as_deref()); } for g in &doc.color_groups { let open_id = egui::Id::new(("color_group_open", &g.name)); @@ -58,7 +58,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { if open { for n in &g.swatches { if let Some(sw) = doc.swatches.iter().find(|w| w.name == *n) { - swatch_row(app, ui, &doc, sw, 16.0, fill_cur.as_deref(), text); + swatch_row(app, ui, &doc, sw, 16.0, fill_cur.as_deref()); } } } @@ -114,8 +114,8 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { }); } -/// One Swatches panel row: chip, name, type; click applies to fill (or text), the context menu -/// to stroke, colour groups and delete. +/// One Swatches panel row: chip, name, type; click applies to the fill (of the frames or their +/// text, see [`super::colors_affect_text`]), the context menu to the stroke, colour groups and delete. fn swatch_row( app: &mut DesignApp, ui: &mut egui::Ui, @@ -123,7 +123,6 @@ fn swatch_row( sw: &designcraft_color::swatch::Swatch, indent: f32, fill_cur: Option<&str>, - text: bool, ) { let t = Tokens::get(ui.ctx()); let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); @@ -155,12 +154,11 @@ fn swatch_row( } ui.painter().text(row.right_center() - vec2(6.0, 0.0), egui::Align2::RIGHT_CENTER, kind, egui::FontId::proportional(10.5), t.text_dim); if resp.clicked() { - let r = if text { app.run("type.char", json!({"attrs": {"fill": sw.name}})) } else { app.run("object.fill", json!({"swatch": sw.name})) }; - let _ = r; + let _ = super::apply_swatch(app, false, &sw.name, None); } resp.context_menu(|ui| { if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Apply to Stroke"))).clicked() { - let _ = app.run("object.stroke", json!({"swatch": sw.name})); + let _ = super::apply_swatch(app, true, &sw.name, None); ui.close(); } if !sw.locked { @@ -201,24 +199,25 @@ pub enum ColorMode { /// swatch), the spectrum ramp, Add to Swatches. Edits apply unnamed colours (`object.color`). pub fn color_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - let Some(i) = super::sel_info(app) else { + let Some((fill, fill_tint, stroke_sw, stroke_tint)) = super::color_target(app) else { ui.label(crate::rtl::widget( ui, - egui::RichText::new(crate::i18n::tr(&app.ui.language, "Select an object to color its fill or stroke.")).color(t.text_dim), + egui::RichText::new(crate::i18n::tr(&app.ui.language, "Select an object or text to color its fill or stroke.")).color(t.text_dim), )); return; }; + let text = super::colors_affect_text(app); let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return }; let mode_id = egui::Id::new("color_panel_mode"); let mut stroke = app.ui.stroke_front; let default_mode = if doc.settings.intent == designcraft_doc::Intent::Print { ColorMode::Cmyk } else { ColorMode::Rgb }; let mut mode: ColorMode = ui.data(|d| d.get_temp(mode_id)).unwrap_or(default_mode); let target = if stroke { "stroke" } else { "fill" }; - let name = if stroke { i.stroke.clone() } else { i.fill.clone() }; + let name = if stroke { stroke_sw.clone() } else { fill.clone() }; // Proxy: fill (front) and stroke chips; click to choose, double-click for the Color Picker. ui.horizontal(|ui| { for (k, label) in [(false, "Fill"), (true, "Stroke")] { - let n = if k { &i.stroke } else { &i.fill }; + let n = if k { &stroke_sw } else { &fill }; let (c, g) = crate::widgets::swatch_colors(&doc, n, 1.0); let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); crate::widgets::paint_chip(ui.painter(), r.shrink(2.0), c, g); @@ -233,7 +232,8 @@ pub fn color_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let rgb = doc.resolve_color(n, 1.0).map(|c| c.to_rgb()).unwrap_or([0.0; 3]); let hex = format!("#{:02x}{:02x}{:02x}", (rgb[0] * 255.0).round() as u8, (rgb[1] * 255.0).round() as u8, (rgb[2] * 255.0).round() as u8); - app.ui.dialog = Some(crate::dialogs::Dialog::new("colorPicker", json!({"target": if k { "stroke" } else { "fill" }, "hex": hex}))); + app.ui.dialog = + Some(crate::dialogs::Dialog::new("colorPicker", json!({"target": if k { "stroke" } else { "fill" }, "hex": hex, "text": text}))); } } ui.label(egui::RichText::new(&name).size(11.0).color(t.text_dim)); @@ -265,13 +265,12 @@ pub fn color_panel(app: &mut DesignApp, ui: &mut egui::Ui) { }; // A named swatch shows its tint; mixing its values makes an unnamed colour. if let Some(sw) = sw.as_ref().filter(|w| !w.hidden && !w.locked) { - let cur = if stroke { super::sel_tint(app, true) } else { super::sel_tint(app, false) }; + let cur = if stroke { stroke_tint } else { fill_tint }; let mut tint = cur * 100.0; ui.horizontal(|ui| { ui.label("T"); if ui.add(egui::Slider::new(&mut tint, 0.0..=100.0).suffix("%").integer()).drag_stopped() { - let cmd = if stroke { "object.stroke" } else { "object.fill" }; - let _ = app.run(cmd, json!({"swatch": sw.name, "tint": tint / 100.0})); + let _ = super::apply_swatch(app, stroke, &sw.name, Some(tint / 100.0)); } }); } @@ -307,13 +306,13 @@ pub fn color_panel(app: &mut DesignApp, ui: &mut egui::Ui) { } } if let Some(color) = send { - let _ = app.run("object.color", json!({"color": color, "target": target})); + let _ = app.run("object.color", json!({"color": color, "target": target, "text": text})); } spectrum(app, ui, target); ui.horizontal(|ui| { let unnamed = sw.as_ref().is_some_and(|w| w.hidden); if ui.add_enabled(unnamed, egui::Button::new(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Add to Swatches")))).clicked() { - let _ = app.run("swatch.addToSwatches", json!({"target": target})); + let _ = app.run("swatch.addToSwatches", json!({"swatch": name, "target": target})); } }); } @@ -383,7 +382,11 @@ fn spectrum(app: &mut DesignApp, ui: &mut egui::Ui, target: &str) { && let Some(p) = resp.interact_pointer_pos() { let c = at(((p.x - r.min.x) / r.width()).clamp(0.0, 1.0), ((p.y - r.min.y) / r.height()).clamp(0.0, 1.0)); - let _ = app.run("object.color", json!({"color": [(c[0] * 255.0).round(), (c[1] * 255.0).round(), (c[2] * 255.0).round()], "target": target})); + let text = super::colors_affect_text(app); + let _ = app.run( + "object.color", + json!({"color": [(c[0] * 255.0).round(), (c[1] * 255.0).round(), (c[2] * 255.0).round()], "target": target, "text": text}), + ); } } diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 20f49475d..6e7d98487 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -194,13 +194,12 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { /// utility button and the Screen Mode well. fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) { let t = Tokens::get(ui.ctx()); - let info = crate::panels::sel_info(app); + let target = crate::panels::color_target(app); let doc = app.session.active().map(|d| d.doc.clone()); - let text_id = egui::Id::new("proxy_text"); let stroke_front = app.ui.stroke_front; - let text_mode: bool = ui.data(|d| d.get_temp(text_id)).unwrap_or(false); - let (fill_sw, stroke_sw) = match &info { - Some(i) => (i.fill.clone(), i.stroke.clone()), + let text_mode = crate::panels::colors_affect_text(app) || app.ui.formatting_affects_text; + let (fill_sw, stroke_sw) = match &target { + Some((fill, _, stroke, _)) => (fill.clone(), stroke.clone()), None => ("[None]".to_string(), "[Black]".to_string()), }; let x0 = ui.max_rect().min.x + (width - 30.0) / 2.0; @@ -293,7 +292,10 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) { let rgb = doc.as_ref().and_then(|d| d.resolve_color(¤t, 1.0)).map_or([0.0; 3], |c| c.to_rgb()); let byte = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; let hex = format!("#{:02x}{:02x}{:02x}", byte(rgb[0]), byte(rgb[1]), byte(rgb[2])); - app.ui.dialog = Some(crate::dialogs::Dialog::new("colorPicker", json!({"target": if stroke { "stroke" } else { "fill" }, "hex": hex}))); + app.ui.dialog = Some(crate::dialogs::Dialog::new( + "colorPicker", + json!({"target": if stroke { "stroke" } else { "fill" }, "hex": hex, "text": crate::panels::colors_affect_text(app)}), + )); } ui.add_space(3.0); // Formatting affects container / text. @@ -314,7 +316,7 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) { }) .clicked() { - ui.data_mut(|d| d.insert_temp(text_id, k == 1)); + let _ = app.run("app.formattingAffectsText", json!({"on": k == 1})); } } ui.add_space(5.0); @@ -328,14 +330,13 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) { let chip = egui::Rect::from_center_size(well.center(), vec2(14.0, 14.0)); crate::widgets::paint_chip(ui.painter(), chip, None, None); flyout_triangle(ui.painter(), well, t.icon); - let target = if stroke_front { "object.stroke" } else { "object.fill" }; if resp.clicked() { let _ = app.run("color.applyNone", json!({})); } egui::Popup::context_menu(&resp).show(|ui| { for (label, sw) in [("Apply Color", "[Black]"), ("Apply None", "[None]")] { if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() { - let _ = app.run(target, json!({"swatch": sw})); + let _ = crate::panels::apply_swatch(app, stroke_front, sw, None); ui.close(); } } @@ -425,12 +426,16 @@ fn tool_button(ui: &mut egui::Ui, icon: &str, active: bool, tip: &str) -> egui:: /// Default Fill and Stroke (D): no fill, a black stroke. pub(crate) fn default_fill_stroke(app: &mut DesignApp) -> Result<Value, String> { - app.run("object.defaultFillStroke", json!({})) + if crate::panels::colors_affect_text(app) { + app.run("type.char", json!({"fill": "[Black]", "fillTint": 1.0, "stroke": "[None]", "strokeTint": 1.0})) + } else { + app.run("object.defaultFillStroke", json!({})) + } } /// Swap Fill and Stroke (Shift+X): each selected object's fill and stroke trade places. pub(crate) fn swap_fill_stroke(app: &mut DesignApp) -> Result<Value, String> { - app.run("object.swapFillStroke", json!({})) + if crate::panels::colors_affect_text(app) { app.run("type.swapFillStroke", json!({})) } else { app.run("object.swapFillStroke", json!({})) } } /// Fill/Stroke toggle (X): brings the other of fill and stroke to the front of the proxy. @@ -441,8 +446,7 @@ pub(crate) fn toggle_fill_stroke(app: &mut DesignApp) -> Result<Value, String> { /// Apply None (/): to the fill or the stroke, whichever is in front of the proxy. pub(crate) fn apply_none(app: &mut DesignApp) -> Result<Value, String> { - let target = if app.ui.stroke_front { "object.stroke" } else { "object.fill" }; - app.run(target, json!({"swatch": "[None]"})) + crate::panels::apply_swatch(app, app.ui.stroke_front, "[None]", None) } #[cfg(test)] @@ -585,3 +589,83 @@ mod tests { assert!(app.ui.stroke_front, "X while typing leaves the proxy alone"); } } + +#[cfg(test)] +mod text_color_tests { + use super::*; + use designcraft_doc::{ItemId, StoryId}; + + #[test] + fn integration_color_swap_preserves_each_run_and_frame() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let mut frames = vec![]; + let mut stories = vec![]; + for text in ["abcd", "efgh"] { + let f = app.run("frame.create", json!({"rect":[36,36,300,200],"content":"text","text":text})).unwrap(); + frames.push(f["id"].clone()); + stories.push(f["story"].as_u64().unwrap()); + } + let pairs = [("[Paper]", "[Black]", 0.2, 0.8), ("[Black]", "[Paper]", 0.4, 0.6)]; + for (i, sid) in stories.iter().enumerate() { + for j in 0..2 { + let (fill, stroke, ft, st) = pairs[(i + j) % 2]; + app.run("text.select", json!({"story":sid,"anchor":j*2,"focus":j*2+2})).unwrap(); + app.run("type.char", json!({"fill":fill,"stroke":stroke,"fillTint":ft,"strokeTint":st})).unwrap(); + } + } + app.run("selection.set", json!({"ids":frames})).unwrap(); + app.run("app.formattingAffectsText", json!({"on":true})).unwrap(); + let before = serde_json::to_value(app.session.doc().unwrap().doc.as_ref()).unwrap(); + let n = app.session.doc().unwrap().history.undo.len(); + swap_fill_stroke(&mut app).unwrap(); + assert_eq!(app.session.doc().unwrap().history.undo.len(), n + 1); + for (i, sid) in stories.iter().enumerate() { + let story = app.session.doc().unwrap().doc.story(StoryId(*sid)).unwrap(); + for j in 0..2 { + let (fill, stroke, ft, st) = pairs[(i + j) % 2]; + let a = &story.format_after(j * 2).over; + assert_eq!((a.fill.as_deref(), a.stroke.as_deref(), a.fill_tint, a.stroke_tint), (Some(stroke), Some(fill), Some(st), Some(ft))); + } + } + let swapped = serde_json::to_value(app.session.doc().unwrap().doc.as_ref()).unwrap(); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(serde_json::to_value(app.session.doc().unwrap().doc.as_ref()).unwrap(), before); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(serde_json::to_value(app.session.doc().unwrap().doc.as_ref()).unwrap(), swapped); + } + + #[test] + fn integration_color_toolbar_picker_targets_selected_frame_text() { + use egui_kittest::kittest::Queryable; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect":[36,36,300,200],"content":"text","text":"hello"})).unwrap(); + app.run("selection.set", json!({"ids":[f["id"]]})).unwrap(); + app.run("app.formattingAffectsText", json!({"on":true})).unwrap(); + let before = app.session.doc().unwrap().doc.item(ItemId(f["id"].as_u64().unwrap())).unwrap().fill.clone(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(0.001).build_ui_state( + |ui, app: &mut DesignApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(6); + let at = h.get_by_label("Fill (X)").rect().center(); + h.hover_at(at); + h.step(); + for pressed in [true, false, true, false] { + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + h.step(); + } + h.run_steps(3); + let d = h.state().ui.dialog.clone().unwrap(); + assert_eq!(d.id, "colorPicker"); + assert_eq!(d.fields["text"], true); + h.state_mut().run("object.color", json!({"color":"#123456","target":"fill","text":d.fields["text"]})).unwrap(); + let doc = &h.state().session.doc().unwrap().doc; + assert_eq!(doc.item(ItemId(f["id"].as_u64().unwrap())).unwrap().fill, before); + assert!(doc.story(StoryId(f["story"].as_u64().unwrap())).unwrap().format_after(0).over.fill.is_some()); + } +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 9e5145616..439987252 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -37,7 +37,7 @@ so only enable it while you use it. Transport: `apps/designcraft/src/control_ser | `ui.inspect` | — | Tool, UI state, view (zoom/origin), canvas rect, perf | | `ui.menu.list` / `ui.tool.list` | — | Menu tree / Tools panel groups | | `ui.tool.select` | `{tool}` | Select a tool (`selection`, `type`, `rectangleFrame`, …) | -| `ui.pointer` | `{events:[{kind: down\|drag\|up\|move\|doubleclick, x, y, space?: "screen"\|"canvas"}], mods?}` | Drive the active tool through the same code path as the mouse | +| `ui.pointer` | `{events:[{kind: down\|drag\|up\|move\|doubleclick, x, y, space?: "screen"\|"canvas", clicks?}], mods?}` | Drive the active tool through the same code path as the mouse; `clicks` on a `down` is the press count (3 = triple click); returns the selection, the tool and its cursor at the last event (e.g. `LoadedText`, `ThreadLink`, `Unthread`) | | `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Synthetic keyboard input (typing into a text frame) | | `ui.move` / `ui.click` / `ui.drag` | screen points, `button?: left\|right\|middle` | Real egui pointer input — reaches every widget, menu and panel | | `ui.set` | `{brightness?, panel?, rulers?, guides?, frameEdges?, baselineGrid?, textThreads?, screenMode?, zoom?, page?, fit?}` | UI state | diff --git a/docs/gaps.md b/docs/gaps.md index ebd2d7da5..41a0af1f3 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-12 · **Change:** minor (book chapter update safety) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (gap 0 shrunk: text threads by hand; threading gaps added) · **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. @@ -14,20 +14,14 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · ## Alpha blocker (the core-workflow gate in [roadmap.md](roadmap.md#alpha-gate)) -### 0. Text can't be threaded by hand (F, U) · **ALPHA BLOCKER** -- **Missing:** threading a story from one frame to another. The canvas draws in and out ports - (`crates/ui-egui/src/canvas.rs`), but clicking the out port does nothing: no loaded text - cursor, no click-a-frame or draw-a-frame to continue the story, no unthread by double-clicking - a port, and no `frame.thread` / `frame.unthread` command for the control channel or MCP - (`Document::thread` exists in `crates/doc/src/edit.rs` but only autoflow, IDML import and the - sample call it). Long text flows only through autoflow on Place and Primary Text Frame, which - users report adding pages they can't delete (#165). -- **Evidence:** #352, #194, #165, #140 (Windows and unspecified platforms; the code shows it's - missing everywhere). -- **Impact:** a story can't run from page 2 to page 7, or from one column frame to the next - that the designer drew. That is InDesign's core text workflow. -- **Estimate:** 15–25 h (commands with undo and tests 4–6, out-port loaded cursor and - click/draw/unthread 6–12, Primary Text Frame page add/delete fixes 5–7). +### 0. Primary Text Frame autoflow adds pages users can't delete (F) · **ALPHA BLOCKER** +- **Missing:** fixes to Smart Text Reflow on the Primary Text Frame, which users report adding + pages they can't delete (#165). Threading by hand works (ports, loaded text cursor, `text.thread` / `text.unthread` / + `text.removeFromThread`). +- **Evidence:** #165. +- **Impact:** a long story in a Primary Text Frame document can leave pages the designer can't + remove. +- **Estimate:** 5–7 h. - **Doc:** [ui-parity.md](ui-parity.md) ## Blocking (beta can't be declared while these are open) @@ -35,7 +29,7 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · ### 1. Basic editing fails for many users (U, S) - **Missing:** object copy/paste on Windows and Linux (#351, #348, #312, #185, #164, #201, text RTF paste #212); Paste in Place onto the selected page (#348); right-click context menus that don't - open (#238, #187, #160, #312); threading (gap 0); double-click word selection in fields + open (#238, #187, #160, #312); double-click word selection in fields (#189); character formatting set at a caret is discarded (#289). - **Evidence:** 16 issues from 2026-10-05 to 2026-10-10, mostly against the 0.4/0.5 release builds. - **Impact:** a layout artist can't finish the simplest job. This dominates the @@ -192,6 +186,19 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · parallel story composition. - **Estimate:** 40–80 h. +### 26. Autoflow fills the following pages before it adds pages (F) +- **Missing:** Shift-click autoflow (and autoflow on Place) always adds new pages after the + story's last frame, one margin-sized frame each. InDesign first threads a frame per column on + the existing pages that follow, then adds pages at the end of the document. +- **Impact:** autoflowing into a document that already has pages inserts new pages in the + middle of it instead of using the empty pages. +- **Estimate:** 4–8 h. + +### 27. Loaded text cursor can't thread into a frame inside a group (F, U) +- **Missing:** a click with the loaded text cursor on a text frame inside a group hits the group, + so the cursor shows it can't thread there. +- **Estimate:** 2–4 h. + ## Lower ### 21. Localization breadth (L) @@ -227,5 +234,6 @@ Cue. These are excluded from every percentage. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Gap 0 shrunk to the Primary Text Frame page fixes (#165): text threads by hand; gaps added for autoflow onto existing pages and threading into grouped frames | | 2026-10-10 | minor | Gap 0 added and marked alpha blocker: manual text threading is missing (stage re-normalized to pre-alpha) | | 2026-10-10 | major | Created: 25 ranked gaps from the full re-measure against InDesign 2026 21.6 and issues #1–#352 | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index d2d1ac11c..b8b71540a 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -16,12 +16,12 @@ of in-scope menu items (75% against the documented menu tree) (see [target-app-p bound. <!-- SUMMARY --> -**Weighted breadth parity: 99%** over 275 features. +**Weighted breadth parity: 99%** over 276 features. | Priority | Features | Done | Partial | Missing | Parity | |---|---|---|---|---|---| | P0 | 102 | 100 | 2 | 0 | 99% | -| P1 | 97 | 97 | 0 | 0 | 100% | +| P1 | 98 | 98 | 0 | 0 | 100% | | P2 | 76 | 72 | 4 | 0 | 97% | | Area | Parity | @@ -235,6 +235,7 @@ Open P0 items: Vector/page formats (PDF/AI/SVG/EPS place), Export to PDF (print) | Typography | Single-line Composer | P0 | D | | Typography | Tabs, leaders, right-indent tab | P0 | D | | Typography | Nested / line / GREP styles | P1 | D | +| Typography | Drop caps | P1 | D | | Typography | Paragraph rules | P1 | D | | Typography | Paragraph borders & shading | P1 | D | | Typography | Balance ragged lines | P1 | D | diff --git a/docs/roadmap.md b/docs/roadmap.md index d80fe7aaa..85424132d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # DesignCraft roadmap (detail) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (stage re-normalized to pre-alpha by the core-workflow gate; earlier today: major, milestones re-assessed; beta plan from the full re-measure) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text threads by hand; the gate waits on the Primary Text Frame page fixes) · **Target:** Adobe InDesign 2026 (21.6) Forward-looking plan. The one-page summary is [ROADMAP.md](../ROADMAP.md); numbers are in [target-app-parity.md](target-app-parity.md); the work list is [gaps.md](gaps.md). Hours are @@ -18,7 +18,7 @@ but are next in line. | Core workflow | Works end to end? | Evidence | Hours to pass | |---|---|---|---| | Set up a document (size, margins, columns, parent pages, page numbers, sections); save and reopen | yes | New Document, Document Setup, parent pages and overrides, Numbering & Section Options; `.designcraft` round-trip tests; recovery | 0 | -| Flow a long story across frames and pages: thread, unthread, autoflow | **no** | out/in ports drawn but no out-port click, loaded text cursor or `thread`/`unthread` command; only autoflow on Place and Primary Text Frame, which adds pages users can't delete (#165, #140, #194, #352) | 15–25 | +| Flow a long story across frames and pages: thread, unthread, autoflow | **no** | threading by hand works (ports, loaded text cursor, `text.thread`/`text.unthread`/`text.removeFromThread`); Primary Text Frame autoflow adds pages users can't delete (#165) | 5–7 | | Format text with paragraph and character styles | partial (not blocking) | Paragraph/Character Style Options, Quick Apply, based-on, nested/GREP styles work; style edit/delete from the panel reported (#169); formatting set at a caret is discarded (#289) | 6–12 | | Place, crop and fit images; wrap text around them | yes | place gun, frame fitting and Fitting Options, content grabber, text wrap, Links panel | 0 | | Arrange and edit objects: select, move, transform, duplicate, copy/paste, align, layers | partial (not blocking on macOS) | all present and working on macOS; Paste in Place lands on page 1 (#348); right-click menus fail for some macOS users (#160, #238); copy/paste and context menus fail on Windows/Linux (#351, #185, #164) | 10–20 | @@ -27,16 +27,15 @@ but are next in line. Exchanging files with InDesign users (open INDD, round-trip IDML) is a beta requirement, not part of the alpha gate: IDML works both ways today and INDD is gap 2. -**Distance to alpha:** ~15–25 h (the threading row). Ready for real work (~47%) is already above +**Distance to alpha:** ~5–7 h (the threading row: Primary Text Frame page fixes). Ready for real work (~47%) is already above the ~40% alpha bar. ## Current focus In order. Each closes a beta blocker from [gaps.md](gaps.md). -0. **Alpha gate: manual text threading** (gap 0): `frame.thread` / `frame.unthread` commands, - out-port click with a loaded text cursor (click a frame or draw one), unthread by - double-clicking a port, Primary Text Frame page fixes (#165). 15–25 h. +0. **Alpha gate: Primary Text Frame page fixes** (gap 0): autoflow adds pages users can't + delete (#165). 5–7 h. 1. **Basic editing works on every platform** (gap 1): object copy/paste, Paste in Place, context menus, caret formatting, reproduced and pinned with control-channel tests on macOS, Windows and Linux. 30–50 h. 2. **CI on pull requests** (gap 7): `cargo xtask ci` on every PR, at least macOS and Linux; @@ -58,7 +57,7 @@ set exists; depth gaps are in gaps.md. |---|---|---| | M0 | Skeleton + vertical slice | ✅ | | M1 | Selection, transform, layers, pages, MCP | ✅ core; handle and snapping issues (#186, #188, #337) | -| M2 | Type I (Type tool, threading, Character/Paragraph, composer) | partial: Type tool and composer ✅; **manual threading missing** (alpha blocker); caret formatting (#289); no IME (#322) | +| M2 | Type I (Type tool, threading, Character/Paragraph, composer) | partial: Type tool, composer and threading ✅; Primary Text Frame pages (#165, alpha blocker); caret formatting (#289); no IME (#322) | | M3 | Styles (nested/GREP, bullets, keeps, span columns) | ✅; style editing reliability (#169) | | M4 | Color & effects | ✅ core; colour management depth | | M5 | Graphics & links | ✅ core; EPS preview only | @@ -71,7 +70,7 @@ set exists; depth gaps are in gaps.md. | M12 | Interactive & digital (EPUB, HTML, interactive PDF) | present; HTML basic (#314), fixed-layout EPUB as images | | M13 | Automation (scripts, batch) | ✅ own command scripts, CLI, MCP; ExtendScript/UXP compatibility ✗ | | M14 | 1.0 polish & packaging | packaging ✅ (macOS, Windows x64/ARM64, Linux deb/AppImage/Flatpak, FreeBSD, web); polish not started | -| **A1** | **Alpha** (core-workflow gate) | blocked by manual text threading; see Alpha gate | +| **A1** | **Alpha** (core-workflow gate) | blocked by the Primary Text Frame page fixes (#165); see Alpha gate | | **B1** | **Beta** (ready ≥ 75%, INDD/IDML reliable) | not started as a milestone; see below | ## To beta @@ -110,6 +109,7 @@ output intent · EPS place") is done except Books depth and EPS (preview only). | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Threading row: threading by hand works; the gate waits on the Primary Text Frame page fixes (#165), 5–7 h | | 2026-10-10 | trivial | Ready figure 42% → 47% (method aligned with the standard); beta distance 28 points | | 2026-10-10 | minor | Alpha gate table added (6 core workflows); gate fails on manual threading, stage pre-alpha; A1 milestone and focus item 0 | | 2026-10-10 | major | Created: milestones moved from ROADMAP.md and re-assessed; Current focus and the beta plan from the full re-measure | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index eb7d7e5c1..daaa3f44c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # DesignCraft parity with Adobe InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (cancelable macOS Dock Quit) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text threads by hand) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) The authoritative assessment of how close DesignCraft is to Adobe InDesign. [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) lists every known shortfall one at a time; the feature-by-feature @@ -16,7 +16,7 @@ presence checklist is [parity-checklist.md](parity-checklist.md) (`cargo xtask p | Essentials user (flyers, newsletters: the core only) | **~54%** | 135–250 h to ~95%; estimated, [method below](#essentials-user-54) | | Remaining effort to **beta** (~75% ready, main format reliable; 28 points away) | **420–750 Opus 5.5 agent-hours** | estimated | | Remaining effort to **full parity** | **1,150–1,950 Opus 5.5 agent-hours** | estimated | -| Stage | **pre-alpha** (fails the core-workflow gate: no manual text threading); ~15–25 h to alpha | see [roadmap.md](roadmap.md#alpha-gate) | +| Stage | **pre-alpha** (fails the core-workflow gate: Primary Text Frame adds pages users can't delete, #165); ~5–7 h to alpha | see [roadmap.md](roadmap.md#alpha-gate) | ## How breadth was measured @@ -77,7 +77,7 @@ standard: 25×55 + 15×45 + 20×40 + 15×45 + 10×45 + 5×65 + 3×45 + 3×55 + 2 today applied a further ×0.9 for cross-cutting core-path failures and reported 42%. That multiplicative step was dropped to match the standard (discounts belong only to the mainstream and essentials numbers). Those failures are already in the UI/UX (45%) and Stability (45%) rows, -and the missing manual threading is what holds the stage at pre-alpha through the gate. +and the Primary Text Frame page fixes (#165) are what hold the stage at pre-alpha through the gate. ## Mainstream practitioner and essentials user @@ -108,7 +108,7 @@ twice. | Area | Weight | Depth | Evidence | |---|---|---|---| | Document setup: pages, margins, columns, parents, sections | 10% | 65% | features area table | -| Text frames: type, select, edit, thread, autoflow | 15% | 45% | manual threading missing; autoflow and Primary Text Frame only | +| Text frames: type, select, edit, thread, autoflow | 15% | 45% | threading by hand, autoflow; Primary Text Frame adds pages users can't delete (#165) | | Typography: composer, H&J, OpenType, tabs, lists, keeps | 15% | 65% | typography-parity.md: 18 ✓ / 16 ~; Tabs panel missing; InDesign-identical breaks counted under exchange | | Paragraph, character and object styles | 10% | 65% | dialogs, based-on, nested/GREP, Quick Apply | | Frames, shapes, transform, arrange, align | 10% | 70% | full tool set; copy/paste problems counted under interaction | @@ -211,7 +211,7 @@ table is the one summed. | Fill, stroke, colour | 100% | 60% | 20–35 | hex entry (#323); Assign Profiles / Convert to Profile; color-managed proofing depth; Adobe Color Themes OOS | | Effects & transparency | 100% | 50% | 20–30 | effects fidelity vs InDesign unmeasured; Clear Effects / Clear All Transparency; flattener depth | | Placing & links | 93% | 55% | 20–35 | EPS is preview-only (no PostScript interpreter); Edit Original; placed PDF layer visibility; INDD page place | -| Type & text frames | 99% | 45% | 40–60 | **no manual threading** (ports drawn, no out-port click, no thread/unthread command: alpha blocker; #352, #194, #165, #140); caret formatting discarded (#289); canvas IME (#322); Notes/Track Changes menus; frame grids | +| Type & text frames | 99% | 45% | 40–60 | Primary Text Frame pages users can't delete (alpha blocker, #165); autoflow doesn't fill existing pages; no threading into grouped frames with the loaded cursor; caret formatting discarded (#289); canvas IME (#322); Notes/Track Changes menus; frame grids | | Typography | 100% | 45% | (typography dimension) | see [typography-parity.md](typography-parity.md) | | Styles | 100% | 60% | 15–25 | Paragraph Styles edit/delete reported failing (#169); style options depth | | Tables | 100% | 55% | 25–40 | anchored objects in cells not drawn (#163); Table ▸ Table Options submenus, Diagonal Lines, Convert Rows, Edit Header/Footer | @@ -265,14 +265,14 @@ The inventory below was the "Working today" list of ROADMAP.md (moved here 2026- It is evidence of breadth: each item exists. It is not a claim of InDesign-level depth. - Document model: facing/non-facing spreads, parent pages with page-number markers, sections, layers, frames (text / graphic / unassigned), threaded stories, paragraph/character/object styles with based-on, swatches (process/spot/tints/gradients), corner options, text wrap, drop shadow, opacity/blend. -- Text engine: Knuth–Plass paragraph composer and single-line composer, dictionary hyphenation (public-domain Moby list + our own trained Liang patterns), justification with word/letter/glyph-scaling ranges, keep options (keep with next, keep lines together, widows/orphans), balance ragged lines, optical margin alignment, hyphenation zone/limit, columns, threading across frames and spreads, tabs, bullets/numbering, rules, shading, baseline grid, first-baseline options, vertical justification, wrap exclusions, overset detection, caret/hit testing. +- Text engine: Knuth–Plass paragraph composer and single-line composer, dictionary hyphenation (public-domain Moby list + our own trained Liang patterns), justification with word/letter/glyph-scaling ranges, keep options (keep with next, keep lines together — at start/end of paragraph by default, as in InDesign — widows/orphans), balance ragged lines, optical margin alignment, hyphenation zone/limit, columns, threading across frames and spreads, tabs, bullets/numbering, rules, shading, baseline grid, first-baseline options, vertical justification, wrap exclusions, overset detection, caret/hit testing. - FID2: IDML underline/strikethrough Text Color resets survive style inheritance and roundtrip. Raster and PDF paint underlines behind glyphs, retain strikethrough above, and center rules on their offsets; synthetic overlap tests cover both backends. - Rendering: vello_cpu (SIMD, multithreaded) — pages, items, images with mip levels, gradients, composed text. - UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts and the Fill/Stroke proxy (D, X, Shift+X, / as `color.*` commands), document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches, Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document and Preferences dialogs, Quick Apply (⌘Return), 4 brightness themes. - Pen tool, Direct Selection anchor/handle editing, rotate from corners, Rotate/Scale/Shear tools, Eyedropper, snapping & smart guides, Align/Distribute, Find/Change (text + GREP), Story Editor, Paragraph Style Options, Effects panel, hidden characters, native macOS menu bar, measured InDesign 2026 look (Medium Dark): Properties sections per selection state, 21 pt spinner fields with arithmetic, Contextual Task Bar, inverse (black) text selection, Pages panel with drag-and-drop reorder / parent apply, Layers panel with per-object rows. - Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection, Type (draw frame, click caret, select, type), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom. - PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`. File › Export PDF… now opens an "Export PDF" options dialog (General / Compression / Marks and Bleeds / Advanced) and remembers the last-used settings across exports and sessions. -- IDML interchange (`designcraft-idml`): export and import of swatches, styles, fonts, preferences, parent spreads, spreads/pages, frames, groups, images (embedded or linked), formatted threaded stories — opens in InDesign 2026 and round-trips InDesign-exported files (`file.exportIdml`, `file.openIdml`, File → Export IDML…, CLI `--in x.idml` / `--export x.idml`). +- IDML interchange (`designcraft-idml`): export and import of swatches, styles, fonts, preferences, parent spreads, spreads/pages, frames, groups, images (embedded or linked), formatted threaded stories; attributes a file leaves unset take InDesign's defaults (Keep Lines Together at start/end of paragraph, 12 pt corners, paragraph rules in Text Color, auto-sizing from the centre, numbered lists restarting in each story, alternating table fills Black 20 % / None; a tint of −1 is the colour's own; frames take the fill, stroke, corners and Text Frame Options they don't write from their object style chain, then [None]) — opens in InDesign 2026 and round-trips InDesign-exported files (`file.exportIdml`, `file.openIdml`, File → Export IDML…, CLI `--in x.idml` / `--export x.idml`). - Tables (M8): tables anchored in stories (header/footer/body rows, merged cells, per-edge strokes, fills + alternating fills, insets, vertical justification, at-least/exact row heights); composed into the text column (columns scale to fit, rows break across columns/frames with repeating headers/footers, overset); rendered, exported to PDF (real text) and IDML (export + import); Table menu, Table panel, Create Table dialog, caret/typing/Tab navigation and cell selection in cells; `table.*` commands. - Hyperlinks (text or frames → URL / e-mail / page) and bookmarks, exported as PDF link annotations and outline. - EPUB 3 (reflowable) export: stories in reading order, CSS from paragraph/character styles, images, navigation (`file.exportEpub`, CLI `--export x.epub`). @@ -295,6 +295,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Graphics backend chosen before the window exists (DirectX 12 on Windows, Vulkan on Linux, Metal on macOS, OpenGL as the fallback; `WGPU_BACKEND` overrides) with a start-up fallback: a backend whose start never showed a frame is skipped at the next start and the status bar says so (`gpu.json`). Found on an AMD hybrid-graphics laptop whose Vulkan driver faulted at the first present. - Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool). - Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`. +- Threading: click a selected text frame's in or out port (overset or not) to load the text cursor, then click a frame to thread it (stories merge), click empty space for a column-wide frame down to the bottom margin, or drag a frame; Shift-click autoflows, Alt-click stays loaded; click the linked frame or double-click a port to break the thread; Esc unloads. Link and unthread cursors show where a click threads or unthreads. `text.thread`, `text.unthread`, `text.removeFromThread`. - Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow adds pages and threaded frames until the text fits; Remove Styles option (`designcraft-textimport`). - Gradient Feather (⇧G tool, `object.gradientFeather`): objects fade along a linear or radial opacity gradient, on screen and as a soft mask in PDF. - Script Label (`object.label`, `object.findByLabel`), alt text (`object.altText`), Isolate Blending / Knockout Group (`object.transparencyGroup`; on screen, PDF isolates), Table ▸ Sort (`table.sortRows`, numeric-aware, header/footer fixed). @@ -323,6 +324,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Appearance of Black complete: Printing/Exporting rich black for RGB output (PNG), Overprint [Black] Swatch at 100% (document, IDML OverprintBlack). - Hyperlinks and Bookmarks panels (Window ▸ Interactive): new from the selection, edit (`hyperlink.edit`), go to source (`hyperlink.goToSource`), rename bookmarks (`bookmark.rename`), go to page, delete. - Nested line styles (`nestedLineStyles` paragraph attribute, Paragraph Style Options › Drop Caps and Nested Styles, IDML AllNestedLineStyles): the first lines restyle until the line breaks settle. +- Drop caps (`type.para {dropCapLines, dropCapChars, dropCapStyle, dropCapAlignLeft, dropCapScaleDescenders}`, Paragraph panel, Paragraph Style Options › Drop Caps and Nested Styles; IDML DropCapLines/DropCapCharacters/DropcapDetail, the drop cap style as the leading "through 1 Dropcap" nested style): the first characters (code points, as InDesign counts them, never splitting a letter from its combining marks; up to 150) are enlarged from the first line's cap height down N − 1 leadings of the text after them (up to 25 lines; grid steps on a baseline grid), the lines beside them are indented by their advance (on the right in right-to-left paragraphs), spaces right after them are set at their size without widening the indent, baselines don't move; a one-line drop cap keeps a smaller size of its own and sets its tabs at the paragraph's tab stops; Align Left Edge (on by default) moves the drop cap left by its first letter's side bearing so its ink starts at the indent; Scale for Descenders is kept but doesn't change the layout yet; a drop cap character style, and nested styles through the drop cap (Dropcap delimiter). Drawn on screen and in PDF (text in order), with caret, hit testing and selection over the enlarged letters. Vertical text sets no drop caps. - User dictionary hyphenation exceptions (`hyphenation.addException/removeException/list`, Edit ▸ Spelling ▸ User Dictionary): `ex~am~ple` breaks only at `~`, a plain word never hyphenates. - Frame Fitting Options (`object.fittingOptions`, Object ▸ Fitting ▸ Frame Fitting Options…): Auto-Fit refits on resize, fitting, Align From reference point, crop amounts; IDML FrameFittingOption. - Paragraph borders (per-side weights, colour, tint, offsets) and shading offsets, drawn per column part of split paragraphs (also when overset); Paragraph panel controls; IDML ParagraphBorder*/ParagraphShading*Offset. @@ -365,7 +367,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Split Window / New Window (`window.split`, `window.newWindow`, Window ▸ Arrange): two side-by-side views of the document with their own zoom, scroll and render cache. New Window puts the second view in its own OS window (an egui viewport; a floating window on the web). - Transparency flattener (`file.exportPdf {flatten: high|medium|low|ppi}`, Edit ▸ Transparency Flattener Presets): each object involving transparency becomes an opaque image of its area over what's beneath it, so everything else stays vector (spreads with transparent parent items are rasterised whole). - Video and sound (place .mp4/.mov/.webm/.mp3/.wav…, `media.options/get`, Media panel): media frames show a poster or placeholder on screen, in print and in PDF, and export as HTML5 `<video>`/`<audio>` in EPUB. Interactive PDF export (`file.exportPdf {media: true}`, File ▸ Export PDF (Interactive)) embeds each file and plays it in a Screen annotation through a rendition action (controls, loop, play on page load). No in-app playback. -- 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 for right-to-left text; IDML DigitsType) draw 0–9 in another script. Kashidas (`kashidas` paragraph attribute, on by default): 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. +- 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 for right-to-left text; 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 (Edit ▸ Interface Language, `app.language`): 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. - 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 our own Generic CMYK ICC 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. @@ -445,6 +447,7 @@ Task notes that headed ROADMAP.md: | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Text threads by hand; stage note and distance to alpha follow the gate (5–7 h, #165); percentages unchanged | | 2026-10-10 | minor | Full number 42% → 47%: method aligned with the standard (plain weighted sum, the ×0.9 discount dropped), no new evidence; readiness table with hours for full, mainstream and essentials; beta distance 33 → 28 points | | 2026-10-10 | minor | Added mainstream practitioner (~42%) and essentials user (~54%) numbers with written weights and discounts and a user-evidence count; full number (42%) now written as 47.2% × 0.9, value unchanged | | 2026-10-10 | minor | Stage alpha → pre-alpha under the core-workflow gate (manual threading missing); Type & text frames row updated | From 87682c36747937501ef8bd8e1e2c19b6f49e2d6f Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Tue, 13 Oct 2026 22:33:46 +0000 Subject: [PATCH 189/238] Integration: #836 a grey stop beside a CMYK stop exports as the K ink, keeping the PDF shading in DeviceCMYK (cherry picked from commit f28456d9d33930fcdd48b1c74460d72fe47a6d77) --- crates/color/src/lib.rs | 1 - crates/pdf/src/export.rs | 10 +++++++++- crates/pdf/tests/gradient_color_spaces.rs | 4 +++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 73a8cb269..24c2798a2 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -169,7 +169,6 @@ impl Color { let [r, g, b] = self.to_rgb(); Color::rgb(1.0 - r, 1.0 - g, 1.0 - b) } - /// Linear interpolation in display RGB. /// The colour `t` of the way to `other`. Process inks blend as inks (CMYK with CMYK or grey, /// grey with grey), as InDesign blends a gradient's stops; anything else blends in RGB. pub fn lerp(&self, other: &Color, t: f32) -> Color { diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index dcf8abb83..d66ad6438 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -981,7 +981,15 @@ impl Exporter<'_> { let (swatch, tint, angle) = (fill.swatch.as_str(), fill.tint, fill.gradient_angle); if let Some(g) = designcraft_color::swatch::resolve_gradient(&self.doc.swatches, swatch) { let kind = g.kind; - let expanded = g.expanded_stops(); + let mut expanded = g.expanded_stops(); + // Grey and CMYK stops blend as inks, so a grey stop beside a CMYK one is the K ink. + if !self.rgb_only && expanded.iter().any(|(_, c, _)| matches!(c, Color::Cmyk { .. })) { + for (_, c, _) in &mut expanded { + if let Color::Gray { k } = *c { + *c = Color::cmyk(0.0, 0.0, 0.0, k); + } + } + } // A PDF shading needs one colour space. Midpoint expansion can introduce RGB even // when every authored stop is CMYK/Gray; PDF/A can also change the output space. let components = |c: &Color| match c { diff --git a/crates/pdf/tests/gradient_color_spaces.rs b/crates/pdf/tests/gradient_color_spaces.rs index f9892965b..05806a120 100644 --- a/crates/pdf/tests/gradient_color_spaces.rs +++ b/crates/pdf/tests/gradient_color_spaces.rs @@ -53,7 +53,7 @@ fn mixed_gradient_export_survives_in_subprocess() { let rgb = Color::rgb(1.0, 0.0, 0.0); let cmyk = Color::cmyk(1.0, 0.0, 0.0, 0.0); let gray = Color::gray(0.4); - for colors in [vec![cmyk, rgb], vec![rgb, cmyk], vec![rgb, gray], vec![gray, cmyk], vec![gray, rgb, cmyk]] { + for colors in [vec![cmyk, rgb], vec![rgb, cmyk], vec![rgb, gray], vec![gray, rgb, cmyk]] { for kind in [GradientKind::Linear, GradientKind::Radial] { for standard in [Standard::None, Standard::PdfX4, Standard::PdfA2b] { let d = document(&colors, kind, 0.5, 0.7); @@ -107,6 +107,8 @@ fn homogeneous_gradients_keep_device_spaces_and_components() { ([Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)], "DeviceRGB", vec![1.0, 0.0, 0.0], vec![0.0, 0.0, 1.0]), ([Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::cmyk(0.0, 1.0, 0.0, 0.0)], "DeviceCMYK", vec![1.0, 0.0, 0.0, 0.0], vec![0.0, 1.0, 0.0, 0.0]), ([Color::gray(0.2), Color::gray(0.8)], "DeviceGray", vec![0.8], vec![0.2]), + // Grey beside CMYK is the K ink, so the shading stays CMYK. + ([Color::gray(0.4), Color::cmyk(1.0, 0.0, 0.0, 0.0)], "DeviceCMYK", vec![0.0, 0.0, 0.0, 0.4], vec![1.0, 0.0, 0.0, 0.0]), ] { for standard in [Standard::None, Standard::PdfX4] { let report = export_pdf_with_report( From 7d601b5903a59f05204594c47c9543162142d938 Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Wed, 14 Oct 2026 01:39:08 +0000 Subject: [PATCH 190/238] Integration: #864 drops the Controlled by Parent rule from the menus and the Objects Move with Page checkbox (neither has an effect yet) (cherry picked from commit 47b51d637756a468555feeac60965d3c94ab05d1) --- ROADMAP.md | 2 +- crates/ui-egui/src/chrome.rs | 13 ++++++------- crates/ui-egui/src/i18n.rs | 10 ---------- crates/ui-egui/src/i18n/ar.rs | 1 - crates/ui-egui/src/i18n/it.rs | 1 - crates/ui-egui/src/i18n/ja.rs | 1 - crates/ui-egui/src/i18n/pt_br.rs | 1 - crates/ui-egui/src/i18n/uk.rs | 1 - crates/ui-egui/src/panels/interactive.rs | 3 +-- docs/target-app-parity.md | 2 +- 10 files changed, 9 insertions(+), 26 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 573b0a37b..8ac26c2a1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -128,7 +128,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. -- **2026-10-14** · With the Page tool, the Control panel shows the page as InDesign's does: W/H from a reference point, page-size preset (US Business Card added), orientation, Liquid Page Rule (now with Controlled by Parent) and Show Parent Page Overlay, which draws the parent page's size over a resized page. No readiness percentage change. +- **2026-10-14** · With the Page tool, the Control panel shows the page as InDesign's does: W/H from a reference point, page-size preset (US Business Card added), orientation, Liquid Page Rule and Show Parent Page Overlay, which draws the parent page's size over a resized page. No readiness percentage change. - **2026-10-14** · The Page tool works as InDesign's: a click selects a page and shows its handles and liquid page rule; dragging a handle previews the rule and springs back, Alt-drag resizes the page from the opposite edge, Shift keeps proportions, with a W/H readout. No readiness percentage change. - **2026-10-12** · Text threads by hand: click a selected text frame's in or out port to load the text cursor, then click a frame to thread it, click empty space for a column-wide frame or diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index f3a17ebf9..44a970afb 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -195,7 +195,7 @@ pub fn control_bar(app: &mut DesignApp, ui: &mut egui::Ui) { } /// The Control panel with the Page tool, as InDesign's: reference point, X/Y, W/H, page-size -/// preset, orientation, Liquid Page Rule, Objects Move with Page and Show Parent Page Overlay, for +/// preset, orientation, Liquid Page Rule and Show Parent Page Overlay, for /// the page the Page tool selected (else the page in view). W/H keep the reference point in place. fn control_page(app: &mut DesignApp, ui: &mut egui::Ui) { let lang = app.ui.language.clone(); @@ -274,9 +274,6 @@ fn control_page(app: &mut DesignApp, ui: &mut egui::Ui) { }); vsep(ui); ui.vertical(|ui| { - // Objects follow a page that moves in its spread; pages don't move yet, so it's always on. - let mut follow = true; - ui.add_enabled(false, egui::Checkbox::new(&mut follow, crate::rtl::widget(ui, crate::i18n::tr(&lang, "Objects Move with Page")))); let mut overlay = pg.show_parent_overlay; if ui.checkbox(&mut overlay, crate::rtl::widget(ui, crate::i18n::tr(&lang, "Show Parent Page Overlay"))).changed() { let _ = app.run("layout.pageOptions", json!({"pages": [n], "showParentOverlay": overlay})); @@ -930,9 +927,11 @@ mod tests { app.run("file.new", serde_json::json!({"width": 252, "height": 144, "facingPages": false})).unwrap(); app.session.set_tool("page"); let mut h = test_window::open(app, vec2(1800.0, 600.0)); - for label in ["Liquid Page Rule:", "Objects Move with Page"] { - let _ = h.get_by_label(label); - } + let _ = h.get_by_label("Liquid Page Rule:"); + // Pages don't move within their spread, so there is no "Objects Move with Page" option, and + // no "Controlled by Parent" rule until parent pages have rules of their own. + assert!(h.query_by_label("Objects Move with Page").is_none()); + assert!(!crate::panels::interactive::LIQUID_RULES.iter().any(|(r, _)| *r == designcraft_doc::LiquidRule::ControlledByParent)); assert_eq!(crate::panels::properties::preset_name(252.0, 144.0), "US Business Card"); h.get_by_label("Show Parent Page Overlay").click(); h.run_steps(4); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 5dc452a2a..1375093da 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -2523,16 +2523,6 @@ const TABLE: &[(&str, [&str; 5])] = &[ "Liquid Page Rule:", ["Regel für flüssige Seiten:", "Règle de page liquide :", "Regla de página líquida:", "リキッドページルール:", "自适应页面规则:"], ), - ( - "Objects Move with Page", - [ - "Objekte mit Seite verschieben", - "Déplacer les objets avec la page", - "Los objetos se mueven con la página", - "オブジェクトをページと共に移動", - "对象随页面移动", - ], - ), ( "Show Parent Page Overlay", [ diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 66ca5390a..1daa0101d 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -722,7 +722,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Guide-Based", "استنادًا إلى الأدلة"), ("Resize Page", "تغيير حجم الصفحة"), ("Liquid Page Rule:", "قاعدة الصفحة المرنة:"), - ("Objects Move with Page", "تحريك الكائنات مع الصفحة"), ("Show Parent Page Overlay", "إظهار تراكب الصفحة الأصل"), ("Controlled by Parent", "تتحكم فيه الصفحة الأصل"), ("US Business Card", "بطاقة عمل أمريكية"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index aa630d3c9..d9435a316 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -1300,7 +1300,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Guide-Based", "Basato su guide"), ("Resize Page", "Ridimensiona pagina"), ("Liquid Page Rule:", "Regola pagina liquida:"), - ("Objects Move with Page", "Sposta oggetti con la pagina"), ("Show Parent Page Overlay", "Mostra sovrapposizione pagina principale"), ("Controlled by Parent", "Controllato dalla pagina principale"), ("US Business Card", "Biglietto da visita USA"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 88e930479..f35d4084a 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -1296,7 +1296,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Guide-Based", "ガイド基準"), ("Resize Page", "ページのサイズを変更"), ("Liquid Page Rule:", "リキッドページルール:"), - ("Objects Move with Page", "オブジェクトをページと共に移動"), ("Show Parent Page Overlay", "親ページオーバーレイを表示"), ("Controlled by Parent", "親ページで制御"), ("US Business Card", "US 名刺"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 0005cc07a..6a1673503 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -713,7 +713,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Guide-Based", "Baseado em guias"), ("Resize Page", "Redimensionar página"), ("Liquid Page Rule:", "Regra de página líquida:"), - ("Objects Move with Page", "Objetos se movem com a página"), ("Show Parent Page Overlay", "Mostrar sobreposição da página principal"), ("Controlled by Parent", "Controlado pela página principal"), ("US Business Card", "Cartão de visita EUA"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 32ac4864f..5e95517ca 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -1297,7 +1297,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Guide-Based", "На основі напрямних"), ("Resize Page", "Змінити розмір сторінки"), ("Liquid Page Rule:", "Правило гнучкої сторінки:"), - ("Objects Move with Page", "Об’єкти рухаються зі сторінкою"), ("Show Parent Page Overlay", "Показати накладення батьківської сторінки"), ("Controlled by Parent", "Керується батьківською сторінкою"), ("US Business Card", "Візитна картка (США)"), diff --git a/crates/ui-egui/src/panels/interactive.rs b/crates/ui-egui/src/panels/interactive.rs index cd36926fa..876fc23b5 100644 --- a/crates/ui-egui/src/panels/interactive.rs +++ b/crates/ui-egui/src/panels/interactive.rs @@ -487,13 +487,12 @@ pub fn buttons(app: &mut DesignApp, ui: &mut egui::Ui) { } /// Liquid page rules in InDesign's order, with their menu labels. -pub const LIQUID_RULES: [(designcraft_doc::LiquidRule, &str); 6] = [ +pub const LIQUID_RULES: [(designcraft_doc::LiquidRule, &str); 5] = [ (designcraft_doc::LiquidRule::Off, "Off"), (designcraft_doc::LiquidRule::Scale, "Scale"), (designcraft_doc::LiquidRule::ReCenter, "Re-center"), (designcraft_doc::LiquidRule::ObjectBased, "Object-based"), (designcraft_doc::LiquidRule::GuideBased, "Guide-based"), - (designcraft_doc::LiquidRule::ControlledByParent, "Controlled by Parent"), ]; /// A liquid page rule's menu label. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 95c9fd0ad..5011b3450 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -398,7 +398,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Type on a Path (⇧T tool, `type.onPath`): a story along any line, curve or shape on screen and in PDF; Type on a Path Options (start, flip, baseline/ascender/descender/centre) and Delete Type from Path. Round-trips through IDML as TextPath; on-path caret drawing is still to come. - Preferences › Appearance of Black: 100% K on screen accurately (dark grey) or as rich black (`window.richBlack`); the printing/export side is still to come. - Spreads of up to 10 pages: Add to Previous Spread and Allow Spread to Shuffle (Pages panel; `layout.pages.toSpread`, `layout.spreadShuffle`); inserted pages go around kept spreads. -- Page tool (⇧P) and `layout.pageSize`: pages of their own size (or a preset); the spine stays put and objects keep their place on their page. A click selects a page (handles, its liquid page rule tagged above it); dragging a handle previews the liquid rule and springs back, Alt-drag resizes, Shift keeps proportions, with a W/H readout; `anchor` keeps a reference point in place; double-click for exact sizes. With the Page tool the Control panel shows the page (reference point, W/H, preset, orientation, Liquid Page Rule including Controlled by Parent, Show Parent Page Overlay); X/Y and Objects Move with Page are shown, but pages don't move within their spread yet. +- Page tool (⇧P) and `layout.pageSize`: pages of their own size (or a preset); the spine stays put and objects keep their place on their page. A click selects a page (handles, its liquid page rule tagged above it); dragging a handle previews the liquid rule and springs back, Alt-drag resizes, Shift keeps proportions, with a W/H readout; `anchor` keeps a reference point in place; double-click for exact sizes. With the Page tool the Control panel shows the page (reference point, W/H, preset, orientation, Liquid Page Rule, Show Parent Page Overlay); X/Y are shown, but pages don't move within their spread yet. - File › Print (⌘P): printer, copies, page range, spreads, marks, bleed; printed as PDF through the system spooler (`file.print`, `file.printers`, `dryRun` for agents). Shortcuts now act like choosing the menu item (dialogs open), and UI commands win shortcut ties (⌘N opens New Document). - Nested styles (through/up to N sentences, words, characters, letters, digits, tabs, breaks, spaces or given characters) and GREP styles (regex → character style), edited in Paragraph Style Options and composed under local formatting; `ui.dialog.open` for agents. Nested line styles are still to come; nested and GREP styles round-trip through IDML. - Table and cell styles (round-trip through IDML): cell styles (fill, insets, vertical justification, strokes, paragraph style) and table styles (region cell styles, border, alternating rows, spacing); apply from the Table panel, edits re-apply to every user; `style.cell.*`, `style.table.*`. From f4efb7cc5cd363454141d108f184784e526b8cd5 Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Wed, 14 Oct 2026 01:38:39 +0000 Subject: [PATCH 191/238] Integration: #869 Alt-drag copies a page in one command and one undo step (layout.pages.duplicateSpread takes to) (cherry picked from commit 7889b25a379fdf27dd128bc49512bc3c0cbfcbfd) --- crates/engine/src/cmd/layout.rs | 23 +++++++++++++++++++++-- crates/engine/src/tests.rs | 28 ++++++++++++++++++++++++++++ crates/ui-egui/src/panels/pages.rs | 24 ++++++++++++------------ 3 files changed, 61 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 663e10791..7a6b05054 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -140,13 +140,18 @@ pub fn specs() -> Vec<CommandSpec> { "Duplicate Spread", ["Layout", "Pages"], None, - "{spread, atEnd?: bool (Duplicate Spread at End of Document; the Pages panel's New Page button takes a dropped page there)} → {page: the copy's first page (0-based)} — the spread's pages (size, margins, columns, guides, liquid rule) and items", + "{spread, atEnd?: bool (Duplicate Spread at End of Document; the Pages panel's New Page button takes a dropped page there), to?: page index the copied pages move to, in the same undo step (the Pages panel's Alt-drag)} → {page: the copy's first page (0-based)} — the spread's pages (size, margins, columns, guides, liquid rule) and items", has_doc, |s, p| { let si = p.get("spread").and_then(Value::as_u64).unwrap_or(0) as usize; let at_end = p.get("atEnd").and_then(Value::as_bool).unwrap_or(false); + let to = p.get("to").and_then(Value::as_u64).map(|t| t as usize); s.edit(|d, _| { let sp = d.spreads.get(si).cloned().ok_or_else(|| bad("layout.pages.duplicateSpread", "no such spread"))?; + // The copies land at `to`..`to + n` of the document with them in it. + if to.is_some_and(|t| t > d.page_count()) { + return Err(bad("layout.pages.duplicateSpread", "no such page")); + } let after = if at_end { d.page_count() - 1 } else { d.first_page_of_spread(si) + sp.pages.len() - 1 }; let parent = sp.pages.first().and_then(|p| p.parent); d.insert_pages(Some(after), sp.pages.len(), parent)?; @@ -169,7 +174,21 @@ pub fn specs() -> Vec<CommandSpec> { let ids: Vec<_> = sp.items.iter().map(|i| i.id).collect(); let (nsi, _) = d.page_loc(after + 1).ok_or_else(|| bad("layout.pages.duplicateSpread", "insert failed"))?; super::object::duplicate_from(d, &src, &ids, designcraft_doc::SpreadRef::Doc(nsi), designcraft_geom::Vec2::ZERO)?; - Ok(json!({"page": after + 1})) + let first = after + 1; + let Some(to) = to else { return Ok(json!({"page": first})) }; + // Moving a page shifts the ones between; order the moves so each copy keeps + // its place among the others. + let n = sp.pages.len(); + if first < to { + for k in (0..n).rev() { + d.move_page(first + k, to + k)?; + } + } else { + for k in 0..n { + d.move_page(first + k, to + k)?; + } + } + Ok(json!({"page": to})) }) } ), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 0e7a011f7..962a0f69d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -695,3 +695,31 @@ fn type_tool_frames_snap_to_the_margins() { assert!((r.x0 - margin.x0).abs() < 1e-6 && (r.y0 - margin.y0).abs() < 1e-6, "press point {r:?} vs {margin:?}"); assert!((r.x1 - margin.x1).abs() < 1e-6, "right edge {} vs {}", r.x1, margin.x1); } + +#[test] +fn duplicating_a_page_to_an_index_is_one_undo_step() { + let mut s = Session::new(); + s.execute("file.new", &json!({"facingPages": false})).unwrap(); + s.execute("layout.pages.insert", &json!({"count": 2})).unwrap(); + for (page, width) in [(1, 300), (2, 400), (3, 500)] { + s.execute("layout.pageSize", &json!({"pages": [page], "width": width, "height": 600})).unwrap(); + } + let widths = |s: &Session| { + let d = &s.doc().unwrap().doc; + (0..d.page_count()).map(|i| d.page_loc(i).map(|(si, pi)| d.spreads[si].pages[pi].width)).collect::<Option<Vec<_>>>().unwrap() + }; + // Backwards: the last page copied to the front. + let r = s.execute("layout.pages.duplicateSpread", &json!({"spread": 2, "to": 0})).unwrap(); + assert_eq!(r["page"], 0); + assert_eq!(widths(&s), [500.0, 300.0, 400.0, 500.0]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(widths(&s), [300.0, 400.0, 500.0]); + // Forwards: the first page copied to the end. + s.execute("layout.pages.duplicateSpread", &json!({"spread": 0, "to": 3})).unwrap(); + assert_eq!(widths(&s), [300.0, 400.0, 500.0, 300.0]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(widths(&s), [300.0, 400.0, 500.0]); + // Past the end: refused, nothing changes. + assert!(s.execute("layout.pages.duplicateSpread", &json!({"spread": 0, "to": 4})).is_err()); + assert_eq!(widths(&s), [300.0, 400.0, 500.0]); +} diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index d70b3427e..a9e4c65cc 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -239,21 +239,21 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { }); }); }); - // Alt-drag (InDesign's Option-drag) copies the page to where it's dropped. - let copy = ui.input(|i| i.modifiers.alt); + // Alt-drag copies a page that is a spread of its own to where it's dropped, in one undo + // step; a page from a larger spread moves as usual. + let alt = ui.input(|i| i.modifiers.alt); + let copy = |from: usize| { + let (si, _) = doc.page_loc(from)?; + (alt && doc.spreads.get(si).is_some_and(|s| s.pages.len() == 1)).then_some(si) + }; match drop { - Some((PageDrag::Page(from), to)) if copy => { - if let Some((si, _)) = doc.page_loc(from) - && let Ok(r) = app.run("layout.pages.duplicateSpread", json!({"spread": si})) - && let Some(new) = r["page"].as_u64().map(|p| p as usize) - && new != to - { - let _ = app.run("layout.pages.move", json!({"from": new, "to": to})); + Some((PageDrag::Page(from), to)) => { + if let Some(si) = copy(from) { + let _ = app.run("layout.pages.duplicateSpread", json!({"spread": si, "to": to})); + } else if from != to { + let _ = app.run("layout.pages.move", json!({"from": from, "to": to})); } } - Some((PageDrag::Page(from), to)) if from != to => { - let _ = app.run("layout.pages.move", json!({"from": from, "to": to})); - } Some((PageDrag::Parent(prefix), to)) => { let _ = app.run("layout.pages.applyParent", json!({"pages": [to], "parent": prefix})); } From 1758642a96da8d0db6cf00b6126511ab3224c189 Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Wed, 14 Oct 2026 01:39:48 +0000 Subject: [PATCH 192/238] Integration: #870 the page menu always offers Delete Page for the clicked page, Delete Spread separately for multi-page spreads (cherry picked from commit 9912a386a84baaf91f426482d7b9d88d3cd9994a) --- crates/ui-egui/src/panels/pages.rs | 41 ++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index d708bfdee..ad8a8ab86 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -292,9 +292,13 @@ fn page_menu(app: &mut DesignApp, ui: &mut egui::Ui, doc: &designcraft_doc::Docu let _ = app.run("layout.pages.duplicateSpread", json!({"spread": si, "atEnd": true})); ui.close(); } - if item(ui, if pages_in_spread > 1 { "Delete Spread" } else { "Delete Page" }) { + if item(ui, "Delete Page") { + let _ = app.run("layout.pages.delete", json!({"pages": [abs]})); + ui.close(); + } + if pages_in_spread > 1 && item(ui, "Delete Spread") { let first = doc.first_page_of_spread(si); - let _ = app.run("layout.pages.delete", json!({"pages": (first..first + pages_in_spread).collect::<Vec<_>>()})); + let _ = app.run("layout.pages.delete", json!({"pages": (first..first.saturating_add(pages_in_spread)).collect::<Vec<_>>()})); ui.close(); } ui.separator(); @@ -401,12 +405,10 @@ mod tests { h.run_steps(3); } - #[test] - fn the_panel_menu_follows_indesigns_order() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({"pages": 2, "facingPages": false})).unwrap(); + /// The page menu for page `abs` of spread `si`. + fn menu_harness(app: DesignApp, abs: usize, si: usize) -> Harness<'static, DesignApp> { let mut h = Harness::builder().with_size(vec2(320.0, 600.0)).build_ui_state( - |ui, app: &mut DesignApp| { + move |ui, app: &mut DesignApp| { let key = egui::Id::new("test_fonts"); if ui.data(|d| d.get_temp::<bool>(key)).is_none() { crate::theme::install_fonts(ui.ctx(), ""); @@ -414,11 +416,34 @@ mod tests { return; } let doc = app.session.doc().unwrap().doc.clone(); - page_menu(app, ui, &doc, 0, 0); + page_menu(app, ui, &doc, abs, si); }, app, ); h.run_steps(3); + h + } + + #[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()); + app.run("file.new", json!({"pages": 5, "facingPages": true})).unwrap(); + let doc = app.session.doc().unwrap().doc.clone(); + let (si, _) = doc.page_loc(2).unwrap(); + assert_eq!(doc.spreads[si].pages.len(), 2); + let mut h = menu_harness(app, 2, si); + let _ = h.get_by_label("Delete Spread"); + h.get_by_label("Delete Page").click(); + h.run_steps(3); + assert_eq!(h.state().session.doc().unwrap().doc.page_count(), 4, "only the clicked page"); + } + + #[test] + fn the_panel_menu_follows_indesigns_order() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 2, "facingPages": false})).unwrap(); + let mut h = menu_harness(app, 0, 0); + assert!(h.query_by_label("Delete Spread").is_none(), "a single-page spread has no Delete Spread"); // InDesign's items, top to bottom. let ys: Vec<f32> = ["Insert Pages…", "Move Pages…", "Duplicate Spread", "Duplicate Spread at End of Document", "Delete Page", "New Parent…"] .iter() From be98657a221dac9af5c540520eb78e4f9fdfe51f Mon Sep 17 00:00:00 2001 From: Andres Torresi <andrestorresi@gmail.com> Date: Wed, 14 Oct 2026 08:42:28 +0100 Subject: [PATCH 193/238] feat(text): Typography panel lists and sets the OpenType features of the selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Typography panel (Window ▸ Show Panel, and the icon column) lists the features of the selected text's fonts, merged over those fonts, grouped as Ligatures, Alternates, Stylistic Sets (named by the font), Figure Style, Figure Width, Figure Position and Capitals. A section or choice appears only when a font offers it. Each feature is a checkbox: checked when all of the selection has it, a dash when only some of it does, empty when none does. A feature only some of the selection's fonts offer is italic. Mutually exclusive choices are checkboxes too, and choosing one turns the others off. type.openTypeFeatures reports each feature's applied and available state over the selection. type.feature turns features on or off across the selection, keeping each run's other features. The fonts crate reads GSUB and GPOS feature lists and stylistic set names. The Character panel's OpenType menu is now a button that opens or focuses the Typography panel. Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com> --- crates/doc/src/otf.rs | 2 +- crates/engine/src/cmd/text.rs | 172 ++++++++++++++++++ crates/fonts/src/features.rs | 52 ++++++ crates/fonts/src/lib.rs | 2 + crates/ui-egui/src/dock.rs | 2 + crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/panels/properties.rs | 48 ++--- crates/ui-egui/src/panels/typography.rs | 225 ++++++++++++++++++++++++ 8 files changed, 470 insertions(+), 34 deletions(-) create mode 100644 crates/fonts/src/features.rs create mode 100644 crates/ui-egui/src/panels/typography.rs diff --git a/crates/doc/src/otf.rs b/crates/doc/src/otf.rs index e2eb9408b..5e246b8bf 100644 --- a/crates/doc/src/otf.rs +++ b/crates/doc/src/otf.rs @@ -31,7 +31,7 @@ pub const FIGURES: &[(&str, &str, &[&str], &str)] = &[ /// Features shaping turns on by itself: "off" is an explicit `-tag`. fn default_on(tag: &str) -> bool { - matches!(tag, "calt" | "locl" | "mark") + matches!(tag, "calt" | "liga" | "locl" | "mark") } pub fn is_on(list: &[String], tag: &str) -> bool { diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 0b87820ff..724354c02 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -292,6 +292,16 @@ pub fn specs() -> Vec<CommandSpec> { break_link ), cmd!(query "type.selectionAttrs", "Selection Attributes", [], None, "{} → resolved character/paragraph attributes at the text selection, and its start as `selectedText` (one line, up to 60 characters)", has_doc, selection_attrs), + cmd!(query "type.openTypeFeatures", "OpenType Features", [], None, "{} → the OpenType features and stylistic sets the selected text's fonts offer, each with its applied and available state (none, some or all of the selection)", has_doc, open_type_features), + cmd!( + "type.feature", + "Set OpenType Features", + [], + None, + "{features: {tag: true|false, …}} — turns OpenType features on or off across the selected text, keeping each run's other features", + has_doc, + set_features + ), ] } @@ -1378,6 +1388,125 @@ fn link_style(s: &mut Session, p: &Value, bold: bool) -> Result<Value> { Ok(json!({"on": on, "changed": changed})) } +/// One stretch of the selection: its length in bytes, the font it is set in and its features. +struct FeatureSegment { + len: usize, + font: (String, String), + applied: Vec<String>, +} + +/// The OpenType features of the fonts in the selection, with how much of the selection each one is +/// applied to and how much of it the fonts offer. A caret counts as one byte. `state` is `none`, +/// `some` or `all`. +fn open_type_features(s: &mut Session, _p: &Value) -> Result<Value> { + let st = s.doc()?; + let mut segments: Vec<FeatureSegment> = Vec::new(); + for Target { story: sid, cell, range: r } in format_targets(s) { + let Some(story) = st.doc.text_story(sid, cell) else { continue }; + let covered: Vec<(usize, usize, designcraft_doc::story::CharFormat)> = if r.is_empty() { + vec![(r.start, 1, story.format_after(r.start).clone())] + } else { + story + .runs() + .filter(|(run, _)| run.start < r.end && run.end > r.start) + .map(|(run, f)| (run.start, run.end.min(r.end).saturating_sub(run.start.max(r.start)), f.clone())) + .collect() + }; + for (pos, len, cf) in covered { + let Some(para) = story.paras.get(story.para_at(pos)) else { continue }; + let (_, base) = st.doc.styles.resolve_para(para); + let cp = st.doc.styles.resolve_char(&base, &cf); + segments.push(FeatureSegment { len, font: (cp.font_family, cp.font_style), applied: cp.otf_features }); + } + } + if segments.is_empty() { + let (_, base) = st.doc.styles.resolve_para_style(designcraft_doc::story::BASIC_PARAGRAPH); + let cp = st.doc.styles.resolve_char(&base, &designcraft_doc::story::CharFormat::default()); + segments.push(FeatureSegment { len: 1, font: (cp.font_family, cp.font_style), applied: cp.otf_features }); + } + let db = designcraft_fonts::FontDb::global(); + let mut fonts: std::collections::BTreeMap<(String, String), designcraft_fonts::FontFeatures> = std::collections::BTreeMap::new(); + for seg in &segments { + fonts.entry(seg.font.clone()).or_insert_with(|| designcraft_fonts::font_features(&db.face(&seg.font.0, &seg.font.1))); + } + let total: usize = segments.iter().map(|seg| seg.len).sum::<usize>().max(1); + let state = |covered: usize| match covered { + 0 => "none", + n if n >= total => "all", + _ => "some", + }; + // Bytes the feature is applied to, and bytes whose font offers it. + let measure = |tag: &str| { + let (mut applied, mut available) = (0, 0); + for seg in &segments { + if designcraft_doc::otf::is_on(&seg.applied, tag) { + applied += seg.len; + } + if fonts.get(&seg.font).is_some_and(|f| f.tags.contains(tag)) { + available += seg.len; + } + } + (state(applied), state(available)) + }; + let tags: std::collections::BTreeSet<String> = fonts.values().flat_map(|f| f.tags.iter().cloned()).collect(); + let mut features = Vec::new(); + for tag in tags.iter().filter(|t| !(t.len() == 4 && t.starts_with("ss") && t[2..].parse::<u32>().is_ok())) { + let (applied, available) = measure(tag); + features.push(json!({"tag": tag, "applied": applied, "available": available})); + } + // A stylistic set's name is shared when every font that offers it gives it the same name. + let mut sets = Vec::new(); + for set in 1..=20u32 { + let tag = format!("ss{set:02}"); + if !tags.contains(&tag) { + continue; + } + let names: std::collections::BTreeSet<&String> = fonts.values().filter_map(|f| f.stylistic_sets.get(&set)).collect(); + let name = if names.len() == 1 { names.first().map(|n| n.to_string()) } else { None }; + let (applied, available) = measure(&tag); + sets.push(json!({"set": set, "tag": tag, "name": name, "applied": applied, "available": available})); + } + let used: Vec<Value> = fonts.keys().map(|(family, style)| json!({"family": family, "style": style})).collect(); + Ok(json!({"fonts": used, "features": features, "stylisticSets": sets})) +} + +/// Turns OpenType features on or off across the selected text: `features` maps tags to whether they +/// are on. Each run keeps its other features, whichever font it is set in, and the change is one +/// undo step. +fn set_features(s: &mut Session, p: &Value) -> Result<Value> { + const ID: &str = "type.feature"; + let changes: Vec<(String, bool)> = p + .get("features") + .and_then(Value::as_object) + .ok_or_else(|| bad(ID, "missing features: a map of tags to true or false"))? + .iter() + .map(|(tag, on)| Ok((tag.clone(), on.as_bool().ok_or_else(|| bad(ID, format!("`{tag}` needs true or false")))?))) + .collect::<Result<_>>()?; + let targets: Vec<Target> = format_targets(s).into_iter().filter(|t| !t.range.is_empty()).collect(); + if targets.is_empty() { + return Err(bad(ID, "select text")); + } + let styles = s.doc()?.doc.styles.clone(); + s.edit(|d, _| { + for t in &targets { + let Some(st) = d.text_story_mut(t.story, t.cell) else { continue }; + let runs: Vec<(std::ops::Range<usize>, designcraft_doc::story::CharFormat)> = + st.runs().filter(|(run, _)| run.start < t.range.end && run.end > t.range.start).map(|(run, f)| (run.clone(), f.clone())).collect(); + for (run, format) in runs { + let Some(para) = st.paras.get(st.para_at(run.start)).cloned() else { continue }; + let (_, base) = styles.resolve_para(¶); + let mut list = styles.resolve_char(&base, &format).otf_features; + for (tag, on) in &changes { + designcraft_doc::otf::set(&mut list, tag, *on); + } + let range = run.start.max(t.range.start)..run.end.min(t.range.end); + st.format_chars(range, |f| f.over.otf_features = Some(list.clone())); + } + } + ok() + }) +} + /// Our own filler text (not Adobe's). pub const PLACEHOLDER: &str = "Ovid ellum quisque arcet velut tempora sint, ne veriora pareant laudemque fieri. Lorem novum strata ponet amicos, \ et tamen uti ferrum caelo tempus mollit. Quisque sinat oppida retro, nec tenuere longas animi semper vias. \ @@ -2558,3 +2687,46 @@ mod sentence_selection_tests { assert_eq!(para_bounds(s, s.len()), (s.len(), s.len())); } } + +#[cfg(test)] +mod open_type_features_tests { + use serde_json::{Value, json}; + + use crate::Session; + + fn feature<'a>(v: &'a Value, tag: &str) -> &'a Value { + v["features"].as_array().unwrap().iter().find(|f| f["tag"] == tag).unwrap_or(&Value::Null) + } + + #[test] + fn the_selected_fonts_features_are_listed_with_their_state() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Hello fi"})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 8})).unwrap(); + let v = s.execute("type.openTypeFeatures", &json!({})).unwrap(); + assert_eq!(v["fonts"][0]["family"], "Source Serif 4", "{v}"); + assert_eq!(feature(&v, "liga")["applied"], "all", "liga is on by default: {v}"); + assert_eq!(feature(&v, "kern")["available"], "all", "{v}"); + } + + #[test] + fn a_feature_on_part_of_the_selection_is_some_and_on_all_of_it_is_all() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Hello world"})).unwrap(); + let story = r["story"].clone(); + s.execute("text.select", &json!({"story": story, "anchor": 0, "focus": 5})).unwrap(); + s.execute("type.feature", &json!({"features": {"lnum": true}})).unwrap(); + s.execute("text.select", &json!({"story": story, "anchor": 0, "focus": 11})).unwrap(); + let v = s.execute("type.openTypeFeatures", &json!({})).unwrap(); + assert_eq!(feature(&v, "lnum")["applied"], "some", "{v}"); + s.execute("text.select", &json!({"story": story, "anchor": 0, "focus": 5})).unwrap(); + let v = s.execute("type.openTypeFeatures", &json!({})).unwrap(); + assert_eq!(feature(&v, "lnum")["applied"], "all", "{v}"); + s.execute("type.feature", &json!({"features": {"lnum": false}})).unwrap(); + s.execute("text.select", &json!({"story": story, "anchor": 0, "focus": 11})).unwrap(); + let v = s.execute("type.openTypeFeatures", &json!({})).unwrap(); + assert_eq!(feature(&v, "lnum")["applied"], "none", "{v}"); + } +} diff --git a/crates/fonts/src/features.rs b/crates/fonts/src/features.rs new file mode 100644 index 000000000..22dc80dec --- /dev/null +++ b/crates/fonts/src/features.rs @@ -0,0 +1,52 @@ +//! The OpenType features a font offers (its GSUB and GPOS feature lists) and the names it gives +//! its stylistic sets, for the Typography panel to show only what the selected fonts can do. + +use std::collections::{BTreeMap, BTreeSet}; + +use skrifa::MetadataProvider; +use skrifa::raw::TableProvider; +use skrifa::raw::tables::layout::FeatureParams; + +use crate::FontFace; + +/// What one font's layout tables offer. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct FontFeatures { + /// The feature tags its GSUB and GPOS tables define, such as `liga` or `ss01`. + pub tags: BTreeSet<String>, + /// The name each stylistic set (1 to 20) gives itself, from its feature parameters. + pub stylistic_sets: BTreeMap<u32, String>, +} + +/// The features `face` offers. A face without layout tables offers none. +pub fn font_features(face: &FontFace) -> FontFeatures { + let mut out = FontFeatures::default(); + let Some(font) = face.skrifa() else { return out }; + let mut lists = Vec::new(); + if let Ok(gsub) = font.gsub() + && let Ok(list) = gsub.feature_list() + { + lists.push(list); + } + if let Ok(gpos) = font.gpos() + && let Ok(list) = gpos.feature_list() + { + lists.push(list); + } + for list in &lists { + for record in list.feature_records() { + let tag = record.feature_tag().to_string(); + let set = tag.strip_prefix("ss").and_then(|n| n.parse::<u32>().ok()).filter(|n| (1..=20).contains(n)); + if let Some(set) = set + && !out.stylistic_sets.contains_key(&set) + && let Ok(feature) = record.feature(list.offset_data()) + && let Some(Ok(FeatureParams::StylisticSet(params))) = feature.feature_params() + && let Some(name) = font.localized_strings(params.ui_name_id()).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty()) + { + out.stylistic_sets.insert(set, name); + } + out.tags.insert(tag); + } + } + out +} diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 5ff395df8..efecc8c46 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -7,6 +7,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +mod features; mod fontdb; mod group; mod kind; @@ -14,6 +15,7 @@ mod kind; pub mod testing; 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, system_font_dirs, diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 0d581e992..e2ec45078 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -20,6 +20,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ("paragraphStyles", "Paragraph Styles", "panel-pstyles"), ("characterStyles", "Character Styles", "panel-cstyles"), ("character", "Character", "panel-character"), + ("typography", "Typography", "panel-character"), ("glyphs", "Glyphs", "panel-character"), ("paragraph", "Paragraph", "panel-paragraph"), ("textWrap", "Text Wrap", "panel-wrap"), @@ -205,6 +206,7 @@ pub fn panel_body(app: &mut DesignApp, ui: &mut egui::Ui, id: &str) { "characterStyles" => panels::styles::character(app, ui), "stroke" => panels::properties::stroke_panel(app, ui), "character" => panels::properties::character_panel(app, ui), + "typography" => panels::typography::show(app, ui), "glyphs" => panels::glyphs::show(app, ui), "paragraph" => panels::properties::paragraph_panel(app, ui), "textWrap" => panels::properties::wrap_panel(app, ui), diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index baf9a1251..2d2f46989 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -12,6 +12,7 @@ pub mod properties; pub mod styles; pub mod swatches; pub mod table; +pub mod typography; use designcraft_doc::{Content, StrokeAlign, WrapMode}; use designcraft_geom::Rect; diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 4e207c44f..8c74b62ae 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1415,9 +1415,15 @@ pub fn character_panel(app: &mut DesignApp, ui: &mut egui::Ui) { { let _ = app.run("type.char", json!({"attrs": {"position": if sup { "normal" } else { "superscript" }}})); } - let features: Vec<String> = - c["otfFeatures"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default(); - crate::menus::menu_button(ui, "OpenType", |ui| open_type_menu(app, ui, &features)); + if ui + .button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "OpenType"))) + .on_hover_ui(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Show the Typography panel")); + }) + .clicked() + { + show_typography_panel(app); + } }); // Underline / Strikethrough Options. for (key, title) in [("underline", "Underline Options"), ("strikethrough", "Strikethrough Options")] { @@ -1452,37 +1458,13 @@ pub fn character_panel(app: &mut DesignApp, ui: &mut egui::Ui) { } } -/// The Character panel's OpenType menu: feature toggles, figure styles and stylistic sets. -pub fn open_type_menu(app: &mut DesignApp, ui: &mut egui::Ui, features: &[String]) { - use designcraft_doc::otf; - ui.set_min_width(220.0); - for (tag, label, _) in otf::TOGGLES { - let mut on = otf::is_on(features, tag); - if ui.checkbox(&mut on, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() { - let _ = app.run("type.openType", json!({"feature": tag, "on": on})); - ui.close(); - } +/// Opens the Typography panel, or focuses it when it is already showing. `window.panel` toggles a +/// flyout, so it is skipped while the Typography flyout is open. +pub fn show_typography_panel(app: &mut DesignApp) { + if app.ui.open_panel.as_deref() == Some("typography") { + return; } - ui.separator(); - let fig = otf::figures(features); - for (id, label, ..) in otf::FIGURES { - if ui.radio(fig == *id, *label).clicked() { - let _ = app.run("type.openType", json!({"figures": id})); - ui.close(); - } - } - ui.separator(); - crate::menus::menu_button(ui, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Stylistic Sets")), |ui| { - let mask = otf::stylistic_sets(features); - for n in 1..=20u32 { - let mut on = mask & (1 << (n - 1)) != 0; - if ui.checkbox(&mut on, crate::rtl::widget(ui, format!("{} {n}", crate::i18n::tr(&app.ui.language, "Set")))).clicked() { - let sets: Vec<u32> = (1..=20).filter(|k| if *k == n { on } else { mask & (1 << (k - 1)) != 0 }).collect(); - let _ = app.run("type.openType", json!({"stylisticSets": sets})); - ui.close(); - } - } - }); + let _ = app.run("window.panel", json!({"panel": "typography"})); } pub fn paragraph_panel(app: &mut DesignApp, ui: &mut egui::Ui) { diff --git a/crates/ui-egui/src/panels/typography.rs b/crates/ui-egui/src/panels/typography.rs new file mode 100644 index 000000000..ef34a0110 --- /dev/null +++ b/crates/ui-egui/src/panels/typography.rs @@ -0,0 +1,225 @@ +//! The Typography panel: the OpenType features the fonts of the selected text offer, grouped as +//! Affinity's are. Every feature is a checkbox that shows how much of the selection it is applied to +//! (checked: all of it; a dash: some of it; empty: none). A feature only some of the selection's fonts +//! offer is italic. Changes apply to the selection through `type.feature`. + +use std::collections::BTreeMap; + +use serde_json::{Value, json}; + +use crate::DesignApp; + +/// A feature's state over the selection: applied to none of it, some of it, or all of it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +enum Covered { + None, + Some, + All, +} + +impl Covered { + fn parse(s: &str) -> Self { + match s { + "all" => Covered::All, + "some" => Covered::Some, + _ => Covered::None, + } + } +} + +/// A switch: its OpenType tag and menu label. +type Switch = (&'static str, &'static str); + +/// A group of mutually exclusive choices: each one's label and the tags it turns on. +type Choice = (&'static str, &'static [&'static str]); + +const LIGATURES: &[Switch] = + &[("liga", "Standard Ligatures"), ("dlig", "Discretionary Ligatures"), ("hlig", "Historical Ligatures"), ("clig", "Contextual Ligatures")]; + +const ALTERNATES: &[Switch] = &[ + ("calt", "Contextual Alternates"), + ("salt", "Stylistic Alternates"), + ("swsh", "Swash"), + ("titl", "Titling Alternates"), + ("hist", "Historical Forms"), + ("ornm", "Ornaments"), + ("zero", "Slashed Zero"), +]; + +const FIGURE_STYLE: &[Choice] = &[("Lining", &["lnum"]), ("Oldstyle", &["onum"])]; + +const FIGURE_WIDTH: &[Choice] = &[("Tabular", &["tnum"]), ("Proportional", &["pnum"])]; + +const FIGURE_POSITION: &[Choice] = &[ + ("Numerator", &["numr"]), + ("Denominator", &["dnom"]), + ("Superscript", &["sups"]), + ("Subscript", &["subs"]), + ("Scientific Inferiors", &["sinf"]), +]; + +const FRACTION_AND_ORDINAL: &[Switch] = &[("frac", "Fractions"), ("ordn", "Ordinal")]; + +const CAPITALS: &[Choice] = &[("Caps to Small Caps", &["c2sc"]), ("Small Caps", &["smcp"]), ("All Small Caps", &["c2sc", "smcp"])]; + +/// A feature the selection's fonts offer, and its state over the selection. +#[derive(Clone, Copy)] +struct Feature { + applied: Covered, + available: Covered, +} + +/// What the selection's fonts offer: each feature's state, and each stylistic set's name and state. +#[derive(Default)] +struct Offered { + features: BTreeMap<String, Feature>, + sets: Vec<Set>, +} + +struct Set { + number: u32, + tag: String, + name: Option<String>, + feature: Feature, +} + +impl Offered { + fn get(&self, tag: &str) -> Option<Feature> { + self.features.get(tag).copied() + } + + /// A choice's state: all of its tags applied is all, none is none, otherwise some. Its + /// availability is the least of its tags'. None when no tag is offered. + fn choice(&self, tags: &[&str]) -> Option<Feature> { + let found: Vec<Feature> = tags.iter().filter_map(|t| self.get(t)).collect(); + if found.is_empty() { + return None; + } + let applied = if found.iter().all(|f| f.applied == Covered::All) { + Covered::All + } else if found.iter().all(|f| f.applied == Covered::None) { + Covered::None + } else { + Covered::Some + }; + let available = found.iter().map(|f| f.available).min().unwrap_or(Covered::None); + Some(Feature { applied, available }) + } +} + +pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { + if super::text_attrs(app).is_none() { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Select text or a text frame.")); + return; + } + let offered = offered(app); + if offered.features.is_empty() && offered.sets.is_empty() { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "The selected fonts have no OpenType features.")); + return; + } + let lang = app.ui.language.clone(); + // Changes this frame: each feature tag to the state it is set to. + let mut changes: BTreeMap<String, bool> = BTreeMap::new(); + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&lang, "Typography")).strong()); + egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { + section(ui, &lang, "Ligatures", |ui| switches(ui, &lang, &offered, LIGATURES, &mut changes)); + section(ui, &lang, "Alternates", |ui| switches(ui, &lang, &offered, ALTERNATES, &mut changes)); + section(ui, &lang, "Stylistic Sets", |ui| stylistic_sets(ui, &lang, &offered, &mut changes)); + section(ui, &lang, "Figure Style", |ui| choices(ui, &lang, &offered, FIGURE_STYLE, &mut changes)); + section(ui, &lang, "Figure Width", |ui| choices(ui, &lang, &offered, FIGURE_WIDTH, &mut changes)); + section(ui, &lang, "Figure Position", |ui| { + choices(ui, &lang, &offered, FIGURE_POSITION, &mut changes); + switches(ui, &lang, &offered, FRACTION_AND_ORDINAL, &mut changes); + }); + section(ui, &lang, "Capitals", |ui| choices(ui, &lang, &offered, CAPITALS, &mut changes)); + }); + if !changes.is_empty() { + let _ = app.run("type.feature", json!({"features": changes})); + } +} + +/// A collapsible section, open at first. Its body runs only when it has something to show. +fn section(ui: &mut egui::Ui, lang: &str, title: &str, body: impl FnOnce(&mut egui::Ui)) { + egui::CollapsingHeader::new(crate::i18n::tr(lang, title)).id_salt(title).default_open(true).show(ui, body); +} + +/// A checkbox for a feature's state: checked when all of the selection has it, a dash when some of +/// it does, italic when only some of the selection's fonts offer it. Returns the state to set when +/// clicked. +fn tri_checkbox(ui: &mut egui::Ui, text: &str, feature: Feature) -> Option<bool> { + let mut on = feature.applied == Covered::All; + let mut label = egui::RichText::new(text); + if feature.available == Covered::Some { + label = label.italics(); + } + let response = ui.add(egui::Checkbox::new(&mut on, crate::rtl::widget(ui, label)).indeterminate(feature.applied == Covered::Some)); + response.clicked().then_some(feature.applied != Covered::All) +} + +/// On/off switches the fonts offer. +fn switches(ui: &mut egui::Ui, lang: &str, offered: &Offered, items: &[Switch], changes: &mut BTreeMap<String, bool>) { + for (tag, label) in items { + let Some(feature) = offered.get(tag) else { continue }; + if let Some(on) = tri_checkbox(ui, crate::i18n::tr(lang, label), feature) { + changes.insert(tag.to_string(), on); + } + } +} + +/// Mutually exclusive choices, as checkboxes: choosing one turns the others off. Choosing a checked +/// one turns it off. +fn choices(ui: &mut egui::Ui, lang: &str, offered: &Offered, items: &[Choice], changes: &mut BTreeMap<String, bool>) { + for (i, (label, tags)) in items.iter().enumerate() { + let Some(feature) = offered.choice(tags) else { continue }; + let Some(on) = tri_checkbox(ui, crate::i18n::tr(lang, label), feature) else { continue }; + for t in *tags { + changes.insert(t.to_string(), on); + } + if on { + for (j, (_, others)) in items.iter().enumerate() { + if j == i { + continue; + } + for t in others.iter().filter(|t| !tags.contains(t)) { + changes.insert(t.to_string(), false); + } + } + } + } +} + +/// Stylistic sets by the name their fonts give them, or the generic `Stylistic Set NN` when the +/// selection's fonts name them differently. +fn stylistic_sets(ui: &mut egui::Ui, lang: &str, offered: &Offered, changes: &mut BTreeMap<String, bool>) { + for set in &offered.sets { + let label = match &set.name { + Some(name) => format!("{name} ({})", set.tag), + None => format!("{} {:02}", crate::i18n::tr(lang, "Stylistic Set"), set.number), + }; + if let Some(on) = tri_checkbox(ui, &label, set.feature) { + changes.insert(set.tag.clone(), on); + } + } +} + +/// The features the selection's fonts offer, with their state over the selection. +fn offered(app: &mut DesignApp) -> Offered { + let v = app.session.execute("type.openTypeFeatures", &json!({})).unwrap_or(Value::Null); + let mut out = Offered::default(); + for f in v["features"].as_array().into_iter().flatten() { + let (Some(tag), Some(applied), Some(available)) = (f["tag"].as_str(), f["applied"].as_str(), f["available"].as_str()) else { continue }; + out.features.insert(tag.to_string(), Feature { applied: Covered::parse(applied), available: Covered::parse(available) }); + } + for s in v["stylisticSets"].as_array().into_iter().flatten() { + let (Some(number), Some(tag), Some(applied), Some(available)) = + (s["set"].as_u64(), s["tag"].as_str(), s["applied"].as_str(), s["available"].as_str()) + else { + continue; + }; + let Ok(number) = u32::try_from(number) else { continue }; + let feature = Feature { applied: Covered::parse(applied), available: Covered::parse(available) }; + out.features.insert(tag.to_string(), feature); + out.sets.push(Set { number, tag: tag.to_string(), name: s["name"].as_str().map(str::to_string), feature }); + } + out +} From fbfc1dca7620e33d992053f157a0bbde71efc923 Mon Sep 17 00:00:00 2001 From: Andres Torresi <andrestorresi@gmail.com> Date: Wed, 14 Oct 2026 08:44:07 +0100 Subject: [PATCH 194/238] feat(ui): the Typography panel has its own OT icon in the dock The icon column's Typography entry reused the Character icon. It now uses an "OT" glyph (panel-typography), and a test checks it is neither the placeholder nor the Character icon. Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/dock.rs | 2 +- crates/ui-egui/src/icons.rs | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index e2ec45078..9b19f2302 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -20,7 +20,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ("paragraphStyles", "Paragraph Styles", "panel-pstyles"), ("characterStyles", "Character Styles", "panel-cstyles"), ("character", "Character", "panel-character"), - ("typography", "Typography", "panel-character"), + ("typography", "Typography", "panel-typography"), ("glyphs", "Glyphs", "panel-character"), ("paragraph", "Paragraph", "panel-paragraph"), ("textWrap", "Text Wrap", "panel-wrap"), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index c93464374..6182c4a92 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -301,6 +301,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) { } "panel-effects" => pen.text(10.0, 10.0, "fx", 11.0), "panel-character" => pen.text(10.0, 10.5, "A", 15.0), + "panel-typography" => pen.text(10.0, 10.5, "OT", 11.5), "panel-paragraph" => pen.text(10.0, 10.5, "¶", 15.0), "panel-pstyles" => { pen.text(8.0, 10.0, "¶", 13.0); @@ -878,6 +879,7 @@ mod tests { /// What icon `name` paints into a 20×20 rect, as the painter's shape list. fn drawing(name: &str) -> String { let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx, "en"); let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { paint(ui.painter(), Rect::from_min_size(pos2(0.0, 0.0), vec2(20.0, 20.0)), name, Color32::WHITE); }); @@ -897,4 +899,12 @@ mod tests { assert_ne!(drawn[0], drawn[2], "Shear draws like Free Transform"); assert_ne!(drawn[1], drawn[2], "Scale and Shear draw alike"); } + + #[test] + fn typography_panel_has_its_own_icon() { + let unknown = drawing("no-such-icon"); + let typography = drawing("panel-typography"); + assert_ne!(typography, unknown, "panel-typography falls back to the placeholder"); + assert_ne!(typography, drawing("panel-character"), "Typography draws the Character icon"); + } } From 23e1680e7d6c23d120ca347bf84c51eb7e682a18 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Wed, 14 Oct 2026 10:27:38 +0200 Subject: [PATCH 195/238] feat(ui): Swatches panel header like InDesign's; swatches go to the fill or stroke in front LYH Studio episode 6 colours the "1969" headline (8:28) and the loop's stroke (9:15) from the Swatches panel, whose header holds InDesign's Fill/Stroke proxy, Formatting Affects Container/Text and the Tint field. - Header: a Fill/Stroke proxy showing the colours of what colour edits apply to (panels::color_target; a click brings the other to the front, as X does), the container/text buttons (app.formattingAffectsText) and the tint of the colour in front. - The Tint field sends only the tint, so each selected object (or run of text) keeps its own swatch: object.fill takes a tint without a swatch, and a stroke tint alone doesn't give a stroke-less object a stroke. - Clicking a swatch applies it to the fill or stroke in front (panels::apply_swatch, to the text when colours affect text); that row is highlighted. - Bottom row: New Color Group, New Swatch, Delete Swatch (the swatch last clicked). Rows and the proxy are named for accessibility (and tests). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 1 + crates/engine/src/cmd/object.rs | 73 ++++++++- crates/ui-egui/src/panels/swatches.rs | 220 ++++++++++++++++++++++++-- docs/target-app-parity.md | 2 +- 4 files changed, 272 insertions(+), 24 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c60a04107..8cfb4a1b4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -128,6 +128,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-14** · The Swatches panel has InDesign's header: a Fill/Stroke proxy, Formatting Affects Container/Text and the Tint of the colour in front (a tint alone keeps each object's own swatch). Clicking a swatch applies it to the fill or stroke in front (of the text, when formatting affects text), and the bottom row has New Color Group, New Swatch and Delete Swatch. No readiness percentage change. - **2026-10-12** · Text threads by hand: click a selected text frame's in or out port to load the text cursor, then click a frame to thread it, click empty space for a column-wide frame or drag one (it snaps like frame drawing); Shift-click autoflows, Alt-click stays loaded on the diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 5e9ed3912..82c58c50b 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -179,12 +179,35 @@ pub fn specs() -> Vec<CommandSpec> { ) } ), - cmd!("object.fill", "Fill", [], None, "{swatch (a swatch's name, e.g. [Paper]), tint?: 0..1, ids?}", has_selection, |s, p| { - let sw = str_param(p, "swatch").ok_or_else(|| bad("object.fill", "missing swatch"))?.to_string(); - known_swatch(&s.doc()?.doc, &sw, "object.fill", "swatch")?; - let tint = f64_or(p, "tint", 1.0) as f32; - set_flag(s, p, move |i| i.fill = Fill { swatch: sw.clone(), tint, ..i.fill.clone() }, false) - }), + cmd!( + "object.fill", + "Fill", + [], + None, + "{swatch? (a swatch's name, e.g. [Paper]), tint?: 0..1, ids?} — at least one; a tint alone keeps each object's own swatch (the Swatches panel's Tint field)", + has_selection, + |s, p| { + let sw = str_param(p, "swatch").map(str::to_string); + let tint = match p.get("tint") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_f64().filter(|t| (0.0..=1.0).contains(t)).ok_or_else(|| bad("object.fill", "`tint` is 0..1"))? as f32), + }; + match &sw { + Some(sw) => known_swatch(&s.doc()?.doc, sw, "object.fill", "swatch")?, + None if tint.is_none() => return Err(bad("object.fill", "missing swatch")), + None => {} + } + set_flag( + s, + p, + move |i| match &sw { + Some(sw) => i.fill = Fill { swatch: sw.clone(), tint: tint.unwrap_or(1.0), ..i.fill.clone() }, + None => i.fill.tint = tint.unwrap_or(i.fill.tint), + }, + false, + ) + } + ), cmd!( "object.swapFillStroke", "Swap Fill and Stroke", @@ -217,10 +240,15 @@ pub fn specs() -> Vec<CommandSpec> { } } let p2 = p.clone(); + // A tint alone (the Swatches panel's Tint field) recolours strokes, it doesn't add one. + let tint_only = p.as_object().is_some_and(|o| o.contains_key("tint") && o.keys().all(|k| k == "tint" || k == "ids")); set_flag( s, p, move |i| { + if tint_only && i.stroke.is_none() { + return; + } let mut st = if i.stroke.is_none() && p2.get("swatch").is_none() { Stroke::default() } else { i.stroke.clone() }; if let Some(v) = p2.get("swatch").and_then(Value::as_str) { st.swatch = v.into(); @@ -3327,3 +3355,36 @@ mod spread_target_tests { assert!(s.doc().unwrap().doc.spreads[0].items.is_empty()); } } + +#[cfg(test)] +mod fill_tint_tests { + use super::*; + + #[test] + fn a_tint_alone_keeps_each_objects_own_swatch() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = s.execute("frame.create", &json!({"rect": [36, 36, 100, 100]})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("frame.create", &json!({"rect": [136, 36, 200, 100]})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.fill", &json!({"ids": [a], "swatch": "C=100 M=0 Y=0 K=0"})).unwrap(); + s.execute("object.fill", &json!({"ids": [b], "swatch": "C=0 M=100 Y=0 K=0"})).unwrap(); + s.execute("object.fill", &json!({"ids": [a, b], "tint": 0.4})).unwrap(); + let fill_of = |s: &Session, id: u64| s.doc().unwrap().doc.item(ItemId(id)).unwrap().fill.clone(); + assert_eq!((fill_of(&s, a).swatch.as_str(), fill_of(&s, a).tint), ("C=100 M=0 Y=0 K=0", 0.4)); + assert_eq!((fill_of(&s, b).swatch.as_str(), fill_of(&s, b).tint), ("C=0 M=100 Y=0 K=0", 0.4)); + // A swatch without a tint is at 100 %, as before. + s.execute("object.fill", &json!({"ids": [a], "swatch": "[Black]"})).unwrap(); + assert_eq!(fill_of(&s, a).tint, 1.0); + // Neither, or a tint out of range: refused. + assert!(s.execute("object.fill", &json!({"ids": [a]})).is_err()); + assert!(s.execute("object.fill", &json!({"ids": [a], "tint": 2})).is_err()); + assert!(s.execute("object.fill", &json!({"ids": [a], "tint": "x"})).is_err()); + // A stroke tint alone: stroked objects take it, an object without a stroke keeps none. + s.execute("object.stroke", &json!({"ids": [a], "swatch": "C=100 M=0 Y=0 K=0", "weight": 2})).unwrap(); + s.execute("object.stroke", &json!({"ids": [b], "swatch": "[None]"})).unwrap(); + s.execute("object.stroke", &json!({"ids": [a, b], "tint": 0.5})).unwrap(); + let stroke_of = |s: &Session, id: u64| s.doc().unwrap().doc.item(ItemId(id)).unwrap().stroke.clone(); + assert_eq!((stroke_of(&s, a).swatch.as_str(), stroke_of(&s, a).tint, stroke_of(&s, a).weight), ("C=100 M=0 Y=0 K=0", 0.5, 2.0)); + assert!(stroke_of(&s, b).is_none()); + } +} diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 967ff38f4..aea4e4acc 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -11,16 +11,10 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let Some(st) = app.session.active() else { return }; let doc = st.doc.clone(); - let text = super::colors_affect_text(app); - let fill_cur = super::color_target(app).map(|t| t.0); - ui.horizontal(|ui| { - crate::rtl::label( - ui, - egui::RichText::new(crate::i18n::tr(&app.ui.language, if text { "Applies to text" } else { "Applies to fill" })) - .size(11.0) - .color(t.text_dim), - ); - }); + let target = super::color_target(app); + header(app, ui, &doc, target.as_ref()); + // The row of the colour in front of the proxy is highlighted. + let fill_cur = target.map(|(fill, _, stroke, _)| if app.ui.stroke_front { stroke } else { fill }); // Unnamed colours (mixed in the Color panel) aren't listed until Add to Swatches; swatches in // colour groups are listed under their folder. let grouped: Vec<&str> = doc.color_groups.iter().flat_map(|g| g.swatches.iter().map(String::as_str)).collect(); @@ -64,15 +58,25 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } } ui.add_space(6.0); + // InDesign's bottom row: New Color Group, New Swatch, Delete Swatch (the one last clicked). + let picked: Option<String> = ui.data(|d| d.get_temp(egui::Id::new(PICKED))).filter(|n: &String| doc.swatch(n).is_some_and(|w| !w.locked)); ui.horizontal(|ui| { - ui.label(crate::rtl::widget( - ui, - egui::RichText::new(crate::i18n::tr(&app.ui.language, "Right-click: stroke, groups")).size(10.5).color(t.text_disabled), - )); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let del = ui + .add_enabled_ui(picked.is_some(), |ui| icons::button(ui, "trash", 20.0, false, crate::i18n::tr(&app.ui.language, "Delete Swatch"))) + .inner; + if del.clicked() + && let Some(name) = &picked + && app.run("swatch.delete", json!({"name": name})).is_ok() + { + ui.data_mut(|d| d.remove::<String>(egui::Id::new(PICKED))); + } if icons::button(ui, "plus", 20.0, false, crate::i18n::tr(&app.ui.language, "New Swatch")).clicked() { let _ = app.run("swatch.create", json!({"color": {"c": 0, "m": 50, "y": 100, "k": 0}})); } + if folder_button(ui, crate::i18n::tr(&app.ui.language, "New Color Group")).clicked() { + let _ = app.run("swatch.newColorGroup", json!({})); + } crate::menus::menu_button(ui, "☰", |ui| { crate::menus::menu_button(ui, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Ink Manager")), |ui| { let Ok(l) = app.session.execute("ink.list", &json!({})) else { return }; @@ -114,8 +118,99 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { }); } -/// One Swatches panel row: chip, name, type; click applies to the fill (of the frames or their -/// text, see [`super::colors_affect_text`]), the context menu to the stroke, colour groups and delete. +/// Temp-data key of the swatch last clicked (Delete Swatch removes it). +const PICKED: &str = "swatches_picked"; + +/// The Swatches panel's top row, as InDesign's: a Fill/Stroke proxy (click: the other in front), +/// Formatting Affects Container / Text, and the tint of the colour in front. `target` is +/// [`super::color_target`]: fill and stroke swatches and tints of the text or the first object. +fn header(app: &mut DesignApp, ui: &mut egui::Ui, doc: &designcraft_doc::Document, target: Option<&(String, f32, String, f32)>) { + let t = Tokens::get(ui.ctx()); + let (fill_sw, stroke_sw) = target.map_or(("[None]", "[Black]"), |(f, _, s, _)| (f.as_str(), s.as_str())); + let text = super::colors_affect_text(app) || app.ui.formatting_affects_text; + let stroke_front = app.ui.stroke_front; + ui.add_space(3.0); + ui.horizontal(|ui| { + let (r, resp) = ui.allocate_exact_size(vec2(27.0, 27.0), Sense::click()); + let tip = crate::i18n::tr(&app.ui.language, if stroke_front { "Stroke" } else { "Fill" }); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip)); + let fill_r = egui::Rect::from_min_size(r.min + vec2(1.0, 2.0), vec2(16.0, 16.0)); + let stroke_r = egui::Rect::from_min_size(r.min + vec2(10.0, 10.0), vec2(16.0, 16.0)); + let paint = |stroke: bool| { + let p = ui.painter(); + if stroke { + // The stroke: a frame of its colour. + let (c, g) = crate::widgets::swatch_colors(doc, stroke_sw, 1.0); + crate::widgets::paint_chip(p, stroke_r, c, g); + p.rect_filled(stroke_r.shrink(4.0), 0.0, t.panel); + p.rect_stroke(stroke_r.shrink(4.0), 0.0, egui::Stroke::new(1.0, t.text_dim), egui::StrokeKind::Outside); + p.rect_stroke(stroke_r, 0.0, egui::Stroke::new(1.0, t.text_dim), egui::StrokeKind::Outside); + } else { + let (c, g) = crate::widgets::swatch_colors(doc, fill_sw, 1.0); + crate::widgets::paint_chip(p, fill_r, c, g); + p.rect_stroke(fill_r, 0.0, egui::Stroke::new(1.0, t.text_dim), egui::StrokeKind::Outside); + } + }; + // The one in front is drawn last. + paint(!stroke_front); + paint(stroke_front); + if resp + .on_hover_ui(|ui| { + crate::rtl::label(ui, tip); + }) + .clicked() + { + let _ = app.run("color.toggleFillStroke", json!({})); + } + ui.add_space(4.0); + for (k, (icon, tip)) in + [("format-container", "Formatting affects container (J)"), ("format-text", "Formatting affects text (J)")].into_iter().enumerate() + { + if icons::button(ui, icon, 20.0, text == (k == 1), crate::i18n::tr(&app.ui.language, tip)).clicked() { + let _ = app.run("app.formattingAffectsText", json!({"on": k == 1})); + } + } + ui.add_space(6.0); + crate::widgets::caption(ui, crate::i18n::tr(&app.ui.language, "Tint:")); + let tint = target.map(|(_, ft, _, st)| f64::from(if stroke_front { *st } else { *ft }) * 100.0); + if let Some(v) = crate::widgets::number(ui, "swatches_tint", tint, "", 44.0, 0) { + let _ = set_tint(app, v); + } + crate::widgets::caption(ui, "%"); + }); +} + +/// The Tint field: `percent` of the colour in front of the proxy, for the text or each selected +/// object, which keep their own swatches. +fn set_tint(app: &mut DesignApp, percent: f64) -> Result<serde_json::Value, String> { + let v = percent.clamp(0.0, 100.0) / 100.0; + let stroke = app.ui.stroke_front; + if super::colors_affect_text(app) { + app.run("type.char", json!({"attrs": {if stroke { "strokeTint" } else { "fillTint" }: v}})) + } else { + app.run(if stroke { "object.stroke" } else { "object.fill" }, json!({"tint": v})) + } +} + +/// A folder button (New Color Group), drawn like the colour groups' folders. +fn folder_button(ui: &mut egui::Ui, tip: &str) -> egui::Response { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(20.0, 20.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip)); + if resp.hovered() { + ui.painter().rect_filled(r, 3.0, t.hover); + } + let f = egui::Rect::from_center_size(r.center(), vec2(14.0, 10.0)); + ui.painter().rect_stroke(f, 1.5, egui::Stroke::new(1.2, t.icon), egui::StrokeKind::Inside); + ui.painter().line_segment([f.left_top() + vec2(1.0, 3.0), f.right_top() + vec2(-1.0, 3.0)], egui::Stroke::new(1.0, t.icon)); + resp.on_hover_ui(|ui| { + crate::rtl::label(ui, tip); + }) +} + +/// One Swatches panel row: chip, name, type; click applies it to the fill or stroke in front of +/// the proxy (of the frames or their text, see [`super::colors_affect_text`]), the context menu +/// to the stroke, colour groups and delete. fn swatch_row( app: &mut DesignApp, ui: &mut egui::Ui, @@ -126,6 +221,7 @@ fn swatch_row( ) { let t = Tokens::get(ui.ctx()); let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &sw.name)); if fill_cur == Some(sw.name.as_str()) { ui.painter().rect_filled(row, 0.0, t.row_selected); } else if resp.hovered() { @@ -154,7 +250,8 @@ fn swatch_row( } ui.painter().text(row.right_center() - vec2(6.0, 0.0), egui::Align2::RIGHT_CENTER, kind, egui::FontId::proportional(10.5), t.text_dim); if resp.clicked() { - let _ = super::apply_swatch(app, false, &sw.name, None); + ui.data_mut(|d| d.insert_temp(egui::Id::new(PICKED), sw.name.clone())); + let _ = super::apply_swatch(app, app.ui.stroke_front, &sw.name, None); } resp.context_menu(|ui| { if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Apply to Stroke"))).clicked() { @@ -573,3 +670,92 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) { }); } } + +#[cfg(test)] +mod swatches_header_tests { + use egui_kittest::{Harness, kittest::Queryable}; + use serde_json::json; + + use super::*; + + fn harness(app: DesignApp) -> Harness<'static, DesignApp> { + Harness::builder().with_size(vec2(300.0, 520.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let id = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::<bool>(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + show(app, ui); + }, + app, + ) + } + + fn items(app: &DesignApp) -> Vec<designcraft_doc::Item> { + let st = app.session.doc().unwrap(); + st.selection.items.iter().map(|id| st.doc.item(*id).unwrap().clone()).collect() + } + + #[test] + fn swatches_go_to_the_fill_or_stroke_in_front_and_the_bottom_row_deletes_the_one_clicked() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [36, 36, 200, 200], "content": "unassigned"})).unwrap(); + app.run("swatch.create", json!({"name": "Brand", "color": "#108080"})).unwrap(); + let mut h = harness(app); + h.run_steps(3); + // Fill in front: a swatch is the fill. + h.get_by_label("C=0 M=100 Y=0 K=0").click(); + h.run_steps(2); + assert_eq!(items(h.state())[0].fill.swatch, "C=0 M=100 Y=0 K=0"); + // The proxy brings the stroke to the front; the next swatch is the stroke. + h.get_by_label("Fill").click(); + h.run_steps(2); + assert!(h.state().ui.stroke_front); + h.get_by_label("C=100 M=0 Y=0 K=0").click(); + h.run_steps(2); + let it = &items(h.state())[0]; + assert_eq!((it.fill.swatch.as_str(), it.stroke.swatch.as_str()), ("C=0 M=100 Y=0 K=0", "C=100 M=0 Y=0 K=0")); + // Formatting affects text, from the panel. + h.get_by_label("Formatting affects text (J)").click(); + h.run_steps(2); + assert!(h.state().ui.formatting_affects_text); + h.get_by_label("Formatting affects container (J)").click(); + h.run_steps(2); + assert!(!h.state().ui.formatting_affects_text); + // Delete Swatch removes the swatch last clicked. + h.get_by_label("Brand").click(); + h.run_steps(2); + h.get_by_label("Delete Swatch").click(); + h.run_steps(2); + assert!(h.state().session.doc().unwrap().doc.swatch("Brand").is_none()); + } + + #[test] + fn the_tint_field_keeps_each_objects_own_swatch() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let a = app.run("frame.create", json!({"rect": [36, 36, 100, 100], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + let b = app.run("frame.create", json!({"rect": [136, 36, 200, 100], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + app.run("object.fill", json!({"ids": [a], "swatch": "C=100 M=0 Y=0 K=0"})).unwrap(); + app.run("object.fill", json!({"ids": [b], "swatch": "C=0 M=100 Y=0 K=0"})).unwrap(); + app.run("object.stroke", json!({"ids": [a, b], "swatch": "[Black]"})).unwrap(); + app.run("selection.set", json!({"ids": [a, b]})).unwrap(); + let mut h = harness(app); + h.run_steps(3); + // The field shows the first selected object's fill tint. + assert!(h.query_all_by_value("100").next().is_some()); + set_tint(h.state_mut(), 40.0).unwrap(); + h.run_steps(2); + assert!(h.query_all_by_value("40").next().is_some(), "the field follows"); + // The stroke in front: the strokes' tints. + h.state_mut().ui.stroke_front = true; + set_tint(h.state_mut(), 70.0).unwrap(); + assert!(items(h.state()).iter().all(|i| i.stroke.tint == 0.7)); + let it = items(h.state()); + assert_eq!((it[0].fill.swatch.as_str(), it[0].fill.tint), ("C=100 M=0 Y=0 K=0", 0.4)); + assert_eq!((it[1].fill.swatch.as_str(), it[1].fill.tint), ("C=0 M=100 Y=0 K=0", 0.4)); + } +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index daaa3f44c..04499ff5a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -268,7 +268,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Text engine: Knuth–Plass paragraph composer and single-line composer, dictionary hyphenation (public-domain Moby list + our own trained Liang patterns), justification with word/letter/glyph-scaling ranges, keep options (keep with next, keep lines together — at start/end of paragraph by default, as in InDesign — widows/orphans), balance ragged lines, optical margin alignment, hyphenation zone/limit, columns, threading across frames and spreads, tabs, bullets/numbering, rules, shading, baseline grid, first-baseline options, vertical justification, wrap exclusions, overset detection, caret/hit testing. - FID2: IDML underline/strikethrough Text Color resets survive style inheritance and roundtrip. Raster and PDF paint underlines behind glyphs, retain strikethrough above, and center rules on their offsets; synthetic overlap tests cover both backends. - Rendering: vello_cpu (SIMD, multithreaded) — pages, items, images with mip levels, gradients, composed text. -- UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts and the Fill/Stroke proxy (D, X, Shift+X, / as `color.*` commands), document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches, Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document and Preferences dialogs, Quick Apply (⌘Return), 4 brightness themes. +- UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts and the Fill/Stroke proxy (D, X, Shift+X, / as `color.*` commands), document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches (InDesign's header: Fill/Stroke proxy, Formatting Affects Container/Text, Tint; a swatch goes to the fill or stroke in front; New Color Group / New Swatch / Delete Swatch row), Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document and Preferences dialogs, Quick Apply (⌘Return), 4 brightness themes. - Pen tool, Direct Selection anchor/handle editing, rotate from corners, Rotate/Scale/Shear tools, Eyedropper, snapping & smart guides, Align/Distribute, Find/Change (text + GREP), Story Editor, Paragraph Style Options, Effects panel, hidden characters, native macOS menu bar, measured InDesign 2026 look (Medium Dark): Properties sections per selection state, 21 pt spinner fields with arithmetic, Contextual Task Bar, inverse (black) text selection, Pages panel with drag-and-drop reorder / parent apply, Layers panel with per-object rows. - Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection, Type (draw frame, click caret, select, type), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom. - PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`. File › Export PDF… now opens an "Export PDF" options dialog (General / Compression / Marks and Bleeds / Advanced) and remembers the last-used settings across exports and sessions. From a2c0cb79cec994d1a18895d2eddcb4aba15a66d9 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Wed, 14 Oct 2026 10:39:03 +0200 Subject: [PATCH 196/238] A radial gradient on a box with no width or height is a circle across it Review fix from aiongg (6db40bdb, the radial_ellipse part), ported to this branch: a straight line's box has no ellipse, so its stroke's radial gradient showed only the last stop once #823's gradient strokes land. Co-authored-by: aiongg <aiongtaigi@gmail.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/doc/src/item.rs | 8 +++++++- crates/engine/src/cmd/style.rs | 16 ++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index a3b724b0d..9fdb00f2b 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -44,7 +44,13 @@ impl Fill { let r = designcraft_geom::Vec2::new(x1 - x0, (y1 - y0) / k).hypot(); (designcraft_geom::Point::new(x0, y0), r, r * k) } - None => (b.center(), b.width().max(1e-9) / 2.0, b.height().max(1e-9) / 2.0), + // A box with no width or height (a straight line's) has no ellipse: a circle across + // its length. + None if b.width() <= 1e-9 || b.height() <= 1e-9 => { + let r = b.width().max(b.height()).max(1e-3) / 2.0; + (b.center(), r, r) + } + None => (b.center(), b.width() / 2.0, b.height() / 2.0), } } diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 8ce7a3854..614a5101e 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -1880,6 +1880,22 @@ mod radial_gradient_tests { let (c, side, top) = sides(&r.render_page(&d, &p.cache, 0, 1.0, false, &Default::default()).unwrap()); assert!(c < 30 && side > 220 && top > 220, "pdf: centre {c}, side {side}, top {top}"); } + + #[test] + fn a_box_with_no_height_or_width_has_a_circle_across_its_length() { + let f = designcraft_doc::Fill::swatch("g"); + let line = designcraft_geom::Rect::new(20.0, 60.0, 180.0, 60.0); + let (c, rx, ry) = f.radial_ellipse(line); + assert_eq!((c.x, c.y, rx, ry), (100.0, 60.0, 80.0, 80.0)); + let (_, rx, ry) = f.radial_ellipse(designcraft_geom::Rect::new(10.0, 0.0, 10.0, 50.0)); + assert_eq!((rx, ry), (25.0, 25.0)); + // A point: a tiny circle, never a zero radius. + let (_, rx, ry) = f.radial_ellipse(designcraft_geom::Rect::new(5.0, 5.0, 5.0, 5.0)); + assert!(rx > 0.0 && rx == ry); + // A box keeps its ellipse. + let (_, rx, ry) = f.radial_ellipse(designcraft_geom::Rect::new(0.0, 0.0, 100.0, 200.0)); + assert_eq!((rx, ry), (50.0, 100.0)); + } } #[cfg(test)] From 188c4f7007f0bb05dfc198d7dae478e4149f796e Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Wed, 14 Oct 2026 12:03:58 +0200 Subject: [PATCH 197/238] Facing pages take their side from their section's page numbers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chris Nielsen's "Numbering Page Sections" (7:03): starting a section at page 1 on a left page (the spread 8–9 selected) turns it into a lone right-hand page 1 in InDesign, and the pages after it shuffle. DesignCraft kept the page on the left, so odd page numbers sat on left pages. - Document::repaginate gives each facing page the side of its number in its section (odd: right, or left bound right to left). Document Setup's Start Page # is one case of it. - layout.section repaginates when a section starts, changes or is removed; objects go with their pages, in one undo step. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 1 + crates/doc/src/build.rs | 20 ++++++-- crates/engine/src/cmd/layout.rs | 90 +++++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c60a04107..f1195daed 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -128,6 +128,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-14** · In a facing document, pages take their side from their section's page numbers, as InDesign shuffles them: a section that starts at 1 on a left page moves it to the right and the pages after it follow, with their objects. No readiness percentage change. - **2026-10-12** · Text threads by hand: click a selected text frame's in or out port to load the text cursor, then click a frame to thread it, click empty space for a column-wide frame or drag one (it snaps like frame drawing); Shift-click autoflows, Alt-click stays loaded on the diff --git a/crates/doc/src/build.rs b/crates/doc/src/build.rs index c274f2130..6c6ab0b8c 100644 --- a/crates/doc/src/build.rs +++ b/crates/doc/src/build.rs @@ -267,15 +267,26 @@ impl Document { /// Redistribute document pages into spreads (facing: page 1 alone on the right, then pairs). /// Items move with the page their centre is on. Spreads with `allow_shuffle == false` are kept. + /// Facing pages take their side from their page number, as InDesign shuffles them: an odd + /// number is a right page (a left page bound right to left), so a section that starts at 1 on + /// a left page moves it to the right and the pages after it follow. pub fn repaginate(&mut self) { - // Collect pages with their items (items in page-relative coordinates). + let odd: Vec<bool> = (0..self.page_count()).map(|i| self.page_number(i) % 2 == 1).collect(); + // Collect pages with their items (items in page-relative coordinates) and their numbers' parity. let mut pages: Vec<(Page, Vec<Arc<Item>>)> = Vec::new(); + let mut parity: Vec<bool> = Vec::new(); let mut kept: Vec<(usize, Arc<Spread>)> = Vec::new(); + let mut abs = 0; for sp in self.spreads.drain(..) { if !sp.allow_shuffle && sp.pages.len() > 1 { + abs += sp.pages.len(); kept.push((pages.len(), sp)); continue; } + for _ in &sp.pages { + parity.push(odd.get(abs).copied().unwrap_or(abs % 2 == 0)); + abs += 1; + } let mut sp = Arc::unwrap_or_clone(sp); let mut per: Vec<Vec<Arc<Item>>> = vec![Vec::new(); sp.pages.len()]; for it in std::mem::take(&mut sp.items) { @@ -326,13 +337,12 @@ impl Document { } spreads.push(sp); }; - // An even start page number (Document Setup › Start Page #) begins with a left page. - let shift = usize::from(self.sections.iter().find(|x| x.start == 0).and_then(|x| x.start_number).is_some_and(|n| n % 2 == 0)); for (i, (mut p, items)) in pages.into_iter().enumerate() { if facing { - // Page i (0-based) is a right page when its number is odd (1, 3, 5 …). + // A page is a right page when its number is odd (1, 3, 5 …), counting each + // section from its start number (Document Setup › Start Page # included). // Bound right to left, odd pages are left pages and spreads start on the right. - let odd = (i + shift) % 2 == 0; + let odd = parity.get(i).copied().unwrap_or(i % 2 == 0); p.side = if odd != rtl { PageSide::Right } else { PageSide::Left }; let starts = if rtl { PageSide::Right } else { PageSide::Left }; if p.side == starts && !cur.is_empty() { diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index bd5320799..6cf6432e9 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -398,6 +398,8 @@ pub fn specs() -> Vec<CommandSpec> { s.edit(|d, _| { d.sections.retain(|x| x.start != start || start == 0); if p.get("remove").and_then(Value::as_bool).unwrap_or(false) && start != 0 { + // The pages after it follow the previous section's numbers again. + d.repaginate(); return ok(); } let mut sec = d.sections.iter().find(|x| x.start == start).cloned().unwrap_or(designcraft_doc::Section { @@ -428,6 +430,8 @@ pub fn specs() -> Vec<CommandSpec> { d.sections.retain(|x| x.start != start); d.sections.push(sec); d.sections.sort_by_key(|x| x.start); + // Facing pages take their sides from their numbers: odd on the right. + d.repaginate(); Ok(json!({"names": (0..d.page_count()).map(|i| d.page_name(i)).collect::<Vec<_>>()})) }) } @@ -921,3 +925,89 @@ mod adjust_tests { assert_eq!(b, b2); } } + +#[cfg(test)] +mod section_side_tests { + use serde_json::json; + + use crate::Session; + + /// Each spread's pages as (name, side), side `L`, `R` or `S`. + fn spreads(s: &Session) -> Vec<Vec<(String, char)>> { + let d = &s.doc().unwrap().doc; + let mut abs = 0; + d.spreads + .iter() + .map(|sp| { + sp.pages + .iter() + .map(|p| { + let side = match p.side { + designcraft_doc::PageSide::Left => 'L', + designcraft_doc::PageSide::Right => 'R', + designcraft_doc::PageSide::Single => 'S', + }; + abs += 1; + (d.page_name(abs - 1), side) + }) + .collect() + }) + .collect() + } + + fn pages(v: &[&[(&str, char)]]) -> Vec<Vec<(String, char)>> { + v.iter().map(|sp| sp.iter().map(|(n, c)| (n.to_string(), *c)).collect()).collect() + } + + #[test] + fn a_section_starting_at_one_on_a_left_page_moves_it_to_the_right() { + // Chris Nielsen's "Numbering Page Sections" (7:03): with the spread 8–9 selected, the + // section starts on page 8, which becomes a lone right-hand page 1; the pages after it + // shuffle and their objects go with them. Undo puts them back. + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 612, "height": 792, "facingPages": true, "pages": 10})).unwrap(); + let id = s.execute("frame.create", &json!({"spread": 4, "rect": [700, 100, 800, 200], "content": "text", "text": "TOPIC"})).unwrap()["id"] + .as_u64() + .unwrap(); + let page_of = |s: &Session| { + let d = &s.doc().unwrap().doc; + d.page_of_item(designcraft_doc::ItemId(id)).unwrap() + }; + assert_eq!(page_of(&s), 8, "page 9, the right page of the spread 8–9"); + let before = spreads(&s); + s.execute("layout.section", &json!({"page": 8, "startNumber": 1})).unwrap(); + assert_eq!( + spreads(&s), + pages(&[ + &[("1", 'R')], + &[("2", 'L'), ("3", 'R')], + &[("4", 'L'), ("5", 'R')], + &[("6", 'L'), ("7", 'R')], + &[("1", 'R')], + &[("2", 'L'), ("3", 'R')], + ]) + ); + assert_eq!(page_of(&s), 8, "the object stays on its page, now the left page of a spread"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spreads(&s), before); + // On a right page (the video's page 9) nothing moves; an even start on a right page moves + // it to the left. + s.execute("layout.section", &json!({"page": 9, "startNumber": 1})).unwrap(); + assert_eq!(spreads(&s)[4], pages(&[&[("8", 'L'), ("1", 'R')]])[0]); + s.execute("layout.section", &json!({"page": 9, "startNumber": 2})).unwrap(); + assert_eq!(spreads(&s)[4..], pages(&[&[("8", 'L')], &[("2", 'L'), ("3", 'R')]])); + // Removing the section numbers the pages on from 9 again. + s.execute("layout.section", &json!({"page": 9, "remove": true})).unwrap(); + assert_eq!(spreads(&s), before); + } + + #[test] + fn single_sided_documents_keep_their_pages() { + let mut s = Session::new(); + s.execute("file.new", &json!({"facingPages": false, "pages": 4})).unwrap(); + s.execute("layout.section", &json!({"page": 2, "startNumber": 1})).unwrap(); + let sp = spreads(&s); + assert_eq!(sp.len(), 4); + assert!(sp.iter().all(|p| p.len() == 1 && p[0].1 == 'S'), "{sp:?}"); + } +} From f4aa93f17d0b92a93f387b99d4f64ec4b35f3d21 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Wed, 14 Oct 2026 12:38:39 +0200 Subject: [PATCH 198/238] =?UTF-8?q?Type=20menu=20in=20InDesign's=20order;?= =?UTF-8?q?=20Character=20=E2=8C=98T,=20Paragraph=20=E2=8C=A5=E2=8C=98T=20?= =?UTF-8?q?and=20Current=20Page=20Number=20=E2=8C=A5=E2=87=A7=E2=8C=98N?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chris Nielsen's "Numbering Page Sections" (2:09) reaches Insert Special Character › Markers › Current Page Number through InDesign's Type menu: Font, Size, Character, Paragraph, Tabs, Glyphs, Story, the style panels, Create Outlines, Find Font, Change Case, Type on a Path, Notes, Track Changes, the footnote and endnote items, Hyperlinks & Cross-References, Text Variables, Bulleted & Numbered Lists, the insert submenus, Fill with Placeholder Text and Show Hidden Characters. DesignCraft's began with Find/ Replace Font and Create Outlines and held alignment, Bold/Italic and point size steps InDesign keeps out of its menus. - Size ▸ (6–72 pt), Character, Paragraph, Glyphs, Character Styles, Paragraph Styles (InDesign's shortcuts: ⌘T, ⌥⌘T, ⌥⇧F11, ⇧⌘F11, ⌘F11), then InDesign's order. Notes ▸ holds New Note and the Notes panel. - Current Page Number carries ⌥⇧⌘N (app.insertCurrentPageNumber). - Alignment, Bold/Italic/Underline/All Caps, point size steps and Paragraph Rules leave the menu; their shortcuts and panel buttons stay. - DesignCraft's math and CJK items follow after Show Hidden Characters. - Labels "Bulleted & Numbered Lists" and "Show Hidden Characters", with translations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 1 + crates/ui-egui/src/i18n.rs | 11 ++ crates/ui-egui/src/i18n/ar.rs | 2 + crates/ui-egui/src/i18n/it.rs | 2 + crates/ui-egui/src/i18n/ja.rs | 1 + crates/ui-egui/src/i18n/pt_br.rs | 5 + crates/ui-egui/src/i18n/uk.rs | 2 + crates/ui-egui/src/menus.rs | 217 ++++++++++++++++++++++++------- 8 files changed, 192 insertions(+), 49 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c60a04107..2e164537d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -128,6 +128,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-14** · The Type menu follows InDesign's order (Size, Character ⌘T, Paragraph ⌥⌘T, Glyphs, the style panels, Notes, footnotes and endnotes, then the insert submenus and Show Hidden Characters); Current Page Number has ⌥⇧⌘N. No readiness percentage change. - **2026-10-12** · Text threads by hand: click a selected text frame's in or out port to load the text cursor, then click a frame to thread it, click empty space for a column-wide frame or drag one (it snaps like frame drawing); Shift-click autoflows, Alt-click stays loaded on the diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 6d6503a00..0982894cf 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -55,6 +55,17 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Cut", ["Ausschneiden", "Couper", "Cortar", "カット", "剪切"]), ("Copy", ["Kopieren", "Copier", "Copiar", "コピー", "复制"]), ("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]), + ( + "Bulleted & Numbered Lists", + [ + "Aufzählungszeichen und nummerierte Listen", + "Listes à puces et numérotées", + "Listas con viñetas y numeradas", + "箇条書き", + "项目符号列表和编号列表", + ], + ), + ("Current Page Number", ["Aktuelle Seitenzahl", "Numéro de page actuelle", "Número de página actual", "現在のページ番号", "当前页码"]), ("Reverse Path", ["Pfadrichtung umkehren", "Inverser le tracé", "Invertir trazado", "パスの方向反転", "反转路径"]), ("Set Anchor", ["Ankerpunkt festlegen", "Définir le point d’ancrage", "Definir punto de ancla", "アンカーポイントを設定", "设置锚点"]), ("Clear", ["Löschen", "Effacer", "Borrar", "消去", "清除"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 769511968..2707bd27b 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Bulleted & Numbered Lists", "القوائم ذات التعداد النقطي والرقمي"), + ("Current Page Number", "رقم الصفحة الحالية"), ("Reverse Path", "عكس المسار"), ("Set Anchor", "تعيين نقطة الارتساء"), ("Clear", "مسح"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 747511ae8..00b082e01 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Bulleted & Numbered Lists", "Elenchi puntati e numerati"), + ("Current Page Number", "Numero di pagina corrente"), ("Reverse Path", "Inverti tracciato"), ("Set Anchor", "Imposta ancoraggio"), ("Clear", "Cancella"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 263d4226a..133ae2927 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -30,6 +30,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "カット"), ("Copy", "コピー"), ("Paste", "ペースト"), + ("Bulleted & Numbered Lists", "箇条書き"), ("Reverse Path", "パスの方向反転"), ("Set Anchor", "アンカーポイントを設定"), ("Clear", "消去"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 3f955f68f..0d4de685e 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Bulleted & Numbered Lists", "Listas com marcadores e numeradas"), + ("Show Hidden Characters", "Mostrar caracteres ocultos"), + ("Current Page Number", "Número da página atual"), + ("New Note", "Nova nota"), + ("Insert Endnote", "Inserir nota de fim"), ("Reverse Path", "Inverter demarcador"), ("Set Anchor", "Definir ponto de ancoragem"), ("Clear", "Limpar"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index efccfc166..36e75adc7 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Bulleted & Numbered Lists", "Марковані й нумеровані списки"), + ("Current Page Number", "Номер поточної сторінки"), ("Reverse Path", "Розвернути контур"), ("Set Anchor", "Задати прив’язку"), ("Clear", "Очистити"), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index dc8b88bf2..7d3163a3f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -75,6 +75,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("app.deletePage", "Delete Page", None, "{} — deletes the page in view"), ("app.duplicateSpread", "Duplicate Spread", None, "{} — duplicates the spread in view (pages and items)"), ("app.tablePanel", "Table Panel", Some("Shift+F9"), "{}"), + ("app.characterPanel", "Character", Some("Cmd+T"), "{} — the Character panel"), + ("app.paragraphPanel", "Paragraph", Some("Cmd+Alt+T"), "{} — the Paragraph panel"), + ("app.glyphsPanel", "Glyphs", Some("Alt+Shift+F11"), "{} — the Glyphs panel"), + ("app.characterStylesPanel", "Character Styles", Some("Cmd+Shift+F11"), "{} — the Character Styles panel"), + ("app.paragraphStylesPanel", "Paragraph Styles", Some("Cmd+F11"), "{} — the Paragraph Styles panel"), + ("app.insertCurrentPageNumber", "Current Page Number", Some("Cmd+Alt+Shift+N"), "{} — the Current Page Number marker at the insertion point"), ("app.storyEditor", "Edit in Story Editor", Some("Cmd+Y"), "{story?}"), ("view.zoomIn", "Zoom In", Some("Cmd+="), "{}"), ("view.zoomOut", "Zoom Out", Some("Cmd+-"), "{}"), @@ -120,7 +126,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ), ("view.baselineGrid", "Show/Hide Baseline Grid", Some("Cmd+Alt+'"), "{}"), ("view.textThreads", "Show/Hide Text Threads", Some("Cmd+Alt+Y"), "{}"), - ("view.hiddenCharacters", "Show/Hide Hidden Characters", Some("Cmd+Alt+I"), "{}"), + ("view.hiddenCharacters", "Show Hidden Characters", Some("Cmd+Alt+I"), "{}"), ("view.taggedFrames", "Show/Hide Tagged Frames", None, "{} — XML-tagged frames outlined in their tag colour"), ("window.hidePanels", "Show/Hide Panels", Some("Tab"), "{} — every panel, the Tools panel included"), ("window.hidePanelsExceptTools", "Show/Hide Panels Except Tools", Some("Shift+Tab"), "{}"), @@ -330,18 +336,76 @@ pub const MENUS: &[(&str, &[&str])] = &[ ( "Type", &[ - "ui:app.findFontDialog", + ">Size", + "cmd:type.char|6 pt|{\"attrs\": {\"size\": 6}}", + "cmd:type.char|7 pt|{\"attrs\": {\"size\": 7}}", + "cmd:type.char|8 pt|{\"attrs\": {\"size\": 8}}", + "cmd:type.char|9 pt|{\"attrs\": {\"size\": 9}}", + "cmd:type.char|10 pt|{\"attrs\": {\"size\": 10}}", + "cmd:type.char|11 pt|{\"attrs\": {\"size\": 11}}", + "cmd:type.char|12 pt|{\"attrs\": {\"size\": 12}}", + "cmd:type.char|14 pt|{\"attrs\": {\"size\": 14}}", + "cmd:type.char|18 pt|{\"attrs\": {\"size\": 18}}", + "cmd:type.char|24 pt|{\"attrs\": {\"size\": 24}}", + "cmd:type.char|30 pt|{\"attrs\": {\"size\": 30}}", + "cmd:type.char|36 pt|{\"attrs\": {\"size\": 36}}", + "cmd:type.char|48 pt|{\"attrs\": {\"size\": 48}}", + "cmd:type.char|60 pt|{\"attrs\": {\"size\": 60}}", + "cmd:type.char|72 pt|{\"attrs\": {\"size\": 72}}", + "<", + "-", + "ui:app.characterPanel", + "ui:app.paragraphPanel", + "ui:app.glyphsPanel", + "-", + "ui:app.characterStylesPanel", + "ui:app.paragraphStylesPanel", "-", "cmd:type.createOutlines", - ">Bulleted and Numbered Lists", - "cmd:list.define", + "ui:app.findFontDialog", + ">Change Case", + "cmd:type.changeCase|UPPERCASE|{\"case\": \"upper\"}", + "cmd:type.changeCase|lowercase|{\"case\": \"lower\"}", + "cmd:type.changeCase|Title Case|{\"case\": \"title\"}", + "cmd:type.changeCase|Sentence case|{\"case\": \"sentence\"}", "<", + "-", ">Type on a Path", "cmd:type.pathOptions", "cmd:type.pathOptions|Delete Type from Path|{\"delete\": true}", "<", - "ui:window.panel|Glyphs|{\"panel\": \"glyphs\"}", - "cmd:type.fillWithPlaceholder", + "-", + ">Notes", + "cmd:note.new", + "ui:window.panel|Notes|{\"panel\": \"notes\"}", + "<", + ">Track Changes", + "cmd:changes.track", + "cmd:changes.acceptAll", + "cmd:changes.rejectAll", + "<", + "-", + "cmd:footnote.insert", + "ui:app.footnoteOptionsDialog", + "cmd:endnote.insert", + "cmd:footnote.goToReference", + "-", + ">Hyperlinks & Cross-References", + "cmd:hyperlink.create", + "cmd:anchor.create", + "-", + "ui:app.insertXrefDialog", + "cmd:xref.defineFormat", + "<", + ">Text Variables", + "cmd:variables.define", + "cmd:variables.insert", + "<", + "-", + ">Bulleted & Numbered Lists", + "cmd:list.define", + "<", + "-", ">Insert Special Character", ">Symbols", "cmd:text.insert|Bullet Character|{\"text\": \"\u{2022}\", \"raw\": true}", @@ -353,7 +417,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:text.insert|Trademark Symbol|{\"text\": \"\u{2122}\", \"raw\": true}", "<", ">Markers", - "cmd:text.insert|Current Page Number|{\"text\": \"\u{e000}\", \"raw\": true}", + "ui:app.insertCurrentPageNumber", "cmd:text.insert|Next Page Number|{\"text\": \"\u{e007}\", \"raw\": true}", "cmd:text.insert|Previous Page Number|{\"text\": \"\u{e008}\", \"raw\": true}", "cmd:text.insert|Section Marker|{\"text\": \"\u{e001}\", \"raw\": true}", @@ -400,44 +464,10 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:text.insert|Paragraph Return|{\"text\": \"\\n\", \"raw\": true}", "cmd:text.insert|Forced Line Break|{\"text\": \"\u{2028}\", \"raw\": true}", "<", - "-", - ">Change Case", - "cmd:type.changeCase|UPPERCASE|{\"case\": \"upper\"}", - "cmd:type.changeCase|lowercase|{\"case\": \"lower\"}", - "cmd:type.changeCase|Title Case|{\"case\": \"title\"}", - "cmd:type.changeCase|Sentence case|{\"case\": \"sentence\"}", - "<", - "-", - "cmd:type.alignLeft", - "cmd:type.alignCenter", - "cmd:type.alignRight", - "cmd:type.justify", - "ui:app.paragraphRulesDialog", - "-", - "cmd:type.bold", - "cmd:type.italic", - "cmd:type.underline", - "cmd:type.allCaps", - "-", - "cmd:type.sizeUp", - "cmd:type.sizeDown", + "cmd:type.fillWithPlaceholder", "-", "ui:view.hiddenCharacters", "-", - ">Hyperlinks & Cross-References", - "cmd:hyperlink.create", - "cmd:anchor.create", - "-", - "ui:app.insertXrefDialog", - "cmd:xref.defineFormat", - "<", - ">Text Variables", - "cmd:variables.define", - "cmd:variables.insert", - "<", - "-", - "cmd:footnote.insert", - "cmd:endnote.insert", "cmd:math.insert", ">Story Direction", "cmd:type.storyDirection|Horizontal|{\"vertical\": false}", @@ -447,13 +477,6 @@ pub const MENUS: &[(&str, &[&str])] = &[ "ui:app.rubyDialog", "cmd:type.kenten", "cmd:type.warichu", - ">Track Changes", - "cmd:changes.track", - "cmd:changes.acceptAll", - "cmd:changes.rejectAll", - "<", - "ui:app.footnoteOptionsDialog", - "cmd:footnote.goToReference", ], ), ( @@ -1264,6 +1287,17 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value, } Ok(Value::Null) } + "app.characterPanel" | "app.paragraphPanel" | "app.glyphsPanel" | "app.characterStylesPanel" | "app.paragraphStylesPanel" => { + let panel = match id { + "app.characterPanel" => "character", + "app.paragraphPanel" => "paragraph", + "app.glyphsPanel" => "glyphs", + "app.characterStylesPanel" => "characterStyles", + _ => "paragraphStyles", + }; + run_ui(app, "window.panel", &json!({"panel": panel})).unwrap_or(Ok(Value::Null)) + } + "app.insertCurrentPageNumber" => app.run("text.insert", json!({"text": "\u{e000}", "raw": true})), "window.panel" => { let panel = p.get("panel").and_then(Value::as_str).unwrap_or("properties").to_string(); if crate::dock::DOCK_TABS.iter().any(|(id, _, _)| *id == panel) { @@ -3511,6 +3545,91 @@ mod tests { assert!(all.iter().any(|(l, id, p)| l == "Fill Frame Proportionally" && id == "object.fit" && p["mode"] == "fillProportionally")); } + #[test] + fn the_type_menu_follows_indesigns_order() { + // InDesign's Type menu, as Chris Nielsen opens it in "Numbering Page Sections" (2:09). + let ty = menu_tree().into_iter().find(|(m, _)| *m == "Type").unwrap().1; + let labels: Vec<String> = ty + .iter() + .map(|i| match i { + Item::Sep => "-".to_string(), + Item::Sub(n, _) => format!("{n} >"), + Item::Cmd { label, .. } => label.clone(), + }) + .collect(); + let expected = [ + "Size >", + "-", + "Character", + "Paragraph", + "Glyphs", + "-", + "Character Styles", + "Paragraph Styles", + "-", + "Create Outlines", + "Find/Replace Font…", + "Change Case >", + "-", + "Type on a Path >", + "-", + "Notes >", + "Track Changes >", + "-", + "Insert Footnote", + "Document Footnote Options…", + "Insert Endnote", + ]; + assert_eq!(&labels[..expected.len()], expected, "{labels:?}"); + let pos = |l: &str| labels.iter().position(|x| x == l).unwrap_or_else(|| panic!("{l} in {labels:?}")); + assert!(pos("Hyperlinks & Cross-References >") < pos("Text Variables >")); + assert!(pos("Text Variables >") < pos("Bulleted & Numbered Lists >")); + assert!(pos("Bulleted & Numbered Lists >") < pos("Insert Special Character >")); + assert!(pos("Insert Special Character >") < pos("Insert White Space >")); + assert!(pos("Insert Break Character >") < pos("Fill with Placeholder Text")); + assert!(pos("Fill with Placeholder Text") < pos("Show Hidden Characters")); + // Alignment, styles and point sizes keep their shortcuts, out of the menu as in InDesign. + for gone in ["Align Left", "Bold", "Increase Point Size", "Paragraph Rules…"] { + assert!(!labels.iter().any(|l| l == gone), "{gone}"); + } + let app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let all = effective_shortcuts(&app); + for (id, sc) in [ + ("type.alignLeft", "Cmd+Shift+L"), + ("app.characterPanel", "Cmd+T"), + ("app.paragraphPanel", "Cmd+Alt+T"), + ("app.insertCurrentPageNumber", "Cmd+Alt+Shift+N"), + ] { + assert_eq!(all.iter().find(|(i, _)| i == id).map(|(_, s)| s.as_str()), Some(sc), "{id}"); + } + // Markers › Current Page Number carries ⌥⇧⌘N. + let markers = ty + .iter() + .find_map(|i| match i { + Item::Sub(n, c) if n == "Insert Special Character" => { + c.iter().find_map(|i| if let Item::Sub(n, c) = i { (n == "Markers").then(|| c.clone()) } else { None }) + } + _ => None, + }) + .unwrap(); + assert!(matches!(&markers[0], Item::Cmd { label, shortcut: Some("Cmd+Alt+Shift+N"), .. } if label == "Current Page Number")); + } + + #[test] + fn type_panel_items_open_their_panels_and_the_marker_shortcut_inserts_a_page_number() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + activate(&mut app, "app.characterPanel", &Value::Null); + assert_eq!(app.ui.open_panel.as_deref(), Some("character")); + activate(&mut app, "app.paragraphStylesPanel", &Value::Null); + assert_eq!(app.ui.open_panel.as_deref(), Some("paragraphStyles")); + let story = app.run("frame.create", json!({"rect": [36, 36, 200, 100], "content": "text", "text": "Page "})).unwrap()["story"].clone(); + app.run("text.select", json!({"story": story, "anchor": 5, "focus": 5})).unwrap(); + activate(&mut app, "app.insertCurrentPageNumber", &Value::Null); + let d = &app.session.doc().unwrap().doc; + assert_eq!(d.story(designcraft_doc::StoryId(story.as_u64().unwrap())).unwrap().text, "Page \u{e000}"); + } + #[test] fn custom_workspaces_save_apply_and_delete() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From 69240ab8c2db57d57e68c0c49a173c47dd2966d8 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Wed, 14 Oct 2026 12:42:45 +0200 Subject: [PATCH 199/238] ui_shot: render every script line, so long scripts don't time out on the GPU Replaying Chris Nielsen's "Numbering Page Sections" on a 50-page facing document, a script that set seven sections without screenshots in between ended in "render failed: PollError: The requested Wait timed out before the submission was completed". Harness::step doesn't render, so the texture changes of every frame (each edit redraws the Pages panel's 50 thumbnails) waited for the screenshot's render, which then uploaded them all at once. ui_shot now renders after each script line (the image is dropped), so texture changes are applied as they come. The same script renders. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/examples/ui_shot.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/ui-egui/examples/ui_shot.rs b/crates/ui-egui/examples/ui_shot.rs index c0bf38943..0373a578d 100644 --- a/crates/ui-egui/examples/ui_shot.rs +++ b/crates/ui-egui/examples/ui_shot.rs @@ -78,6 +78,12 @@ fn main() { } step_n(&mut harness, 2); } + // Frames that aren't rendered keep their texture changes for the next render: a script + // that edits a long document (every edit redraws the Pages panel's thumbnails) without + // screenshots piled them up until the shot's render timed out on the GPU. Render each line. + if l.get("shot").is_none() { + let _ = harness.render(); + } } } From edb268af12239cc474759f8ab1029f39b59c7898 Mon Sep 17 00:00:00 2001 From: Govinda Roy <govindaroy.ofc94@gmail.com> Date: Wed, 14 Oct 2026 18:22:44 +0600 Subject: [PATCH 200/238] Text: kerning takes a typed value, and the Character panel has it The kerning control in Properties was a menu of Metrics, Optical and 0: manual kerning, which the engine already supports in 1/1000 em, couldn't be entered, and the Character panel had no kerning at all (#896). - One kerning control: its label shows Metrics, Optical, 0 or the manual value; its menu keeps the three choices and adds a number field with a spinner for a manual value (-1000 to 10000). - Properties and the Character panel (next to Tracking) both use it. Fixes #896. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/panels/properties.rs | 112 ++++++++++++++++++++---- 1 file changed, 93 insertions(+), 19 deletions(-) diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 4e207c44f..9e6030bd5 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1087,24 +1087,7 @@ fn character_section(app: &mut DesignApp, ui: &mut Ui) { } // Kerning / tracking. icons::paint(ui.painter(), sub(b, 0.0, 27.0, 19.0, 19.0), "kerning", t.icon); - let kern = c["kerning"]["kind"].as_str().unwrap_or("metrics").to_string(); - let kern_label = match kern.as_str() { - "optical" => "Optical".to_string(), - "none" => "0".to_string(), - "manual" => format!("{}", c["kerning"]["value"].as_f64().unwrap_or(0.0)), - _ => "Metrics".to_string(), - }; - place(ui, sub(b, 24.0, 26.0, half - 24.0, FIELD_H), |ui| { - let resp = widgets::dropdown(ui, crate::i18n::tr(&app.ui.language, &kern_label), half - 24.0); - egui::Popup::menu(&resp).show(|ui| { - for (label, v) in [("Metrics", json!({"kind": "metrics"})), ("Optical", json!({"kind": "optical"})), ("0", json!({"kind": "none"}))] { - if ui.selectable_label(kern_label == label, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() { - let _ = app.run("type.char", json!({"attrs": {"kerning": v}})); - ui.close(); - } - } - }); - }); + place(ui, sub(b, 24.0, 26.0, half - 24.0, FIELD_H), |ui| kerning_control(app, ui, "ckern", &c["kerning"], half - 24.0)); icons::paint(ui.painter(), sub(b, half + 10.0, 27.0, 19.0, 19.0), "tracking", t.icon); const TRACK: &[f64] = &[-100.0, -75.0, -50.0, -25.0, -10.0, -5.0, 0.0, 5.0, 10.0, 25.0, 50.0, 75.0, 100.0, 200.0]; if let Some(v) = place(ui, sub(b, half + 34.0, 26.0, lw, FIELD_H), |ui| { @@ -1277,6 +1260,37 @@ fn language_menu(app: &mut DesignApp, ui: &mut Ui, cur: &str) { } } +/// Kerning for the text selection: the label shows Metrics, Optical, 0 or the manual value; its +/// menu sets one of the first three or a manual value in 1/1000 em (typed or stepped). +fn kerning_control(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, k: &Value, width: f32) { + let kind = k["kind"].as_str().unwrap_or("metrics"); + let manual = k["value"].as_f64().filter(|v| kind == "manual" && v.is_finite()); + let label = match (kind, manual) { + ("optical", _) => "Optical".to_string(), + ("none", _) => "0".to_string(), + ("manual", Some(v)) => format!("{v}"), + _ => "Metrics".to_string(), + }; + let resp = widgets::dropdown(ui, crate::i18n::tr(&app.ui.language, &label), width); + let name = crate::i18n::tr(&app.ui.language, "Kerning"); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, name)); + egui::Popup::menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + for (text, v) in [("Metrics", json!({"kind": "metrics"})), ("Optical", json!({"kind": "optical"})), ("0", json!({"kind": "none"}))] { + if ui.selectable_label(label == text, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, text))).clicked() { + let _ = app.run("type.char", json!({"attrs": {"kerning": v}})); + ui.close(); + } + } + ui.separator(); + // A manual value: between the selected character and the next, in 1/1000 em. + let field = format!("{id}_manual"); + if let Some(v) = NumField::number(&field, Some(manual.unwrap_or(0.0)), "", 0).width(90.0).spinner().step(5.0).range(-1000.0, 10000.0).show(ui) + { + let _ = app.run("type.char", json!({"attrs": {"kerning": {"kind": "manual", "value": v}}})); + } + }); +} + pub fn character_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let Some(a) = text_attrs(app) else { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Select text or a text frame.")); @@ -1329,10 +1343,13 @@ pub fn character_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let _ = app.run("type.char", json!({"attrs": {"leading": {"kind": "points", "value": v}}})); } ui.end_row(); + caption(ui, crate::i18n::tr(&app.ui.language, "Kerning")); + kerning_control(app, ui, "pck", &c["kerning"], 60.0); caption(ui, crate::i18n::tr(&app.ui.language, "Tracking")); - if let Some(v) = number(ui, "pct", c["tracking"].as_f64(), "", 60.0, 0) { + if let Some(v) = number(ui, "pct", c["tracking"].as_f64(), "", 70.0, 0) { let _ = app.run("type.char", json!({"attrs": {"tracking": v}})); } + ui.end_row(); caption(ui, crate::i18n::tr(&app.ui.language, "Baseline")); if let Some(v) = number(ui, "pcb", c["baselineShift"].as_f64(), " pt", 70.0, 2) { let _ = app.run("type.char", json!({"attrs": {"baselineShift": v}})); @@ -2306,6 +2323,63 @@ mod tests { assert_eq!(language(h.state_mut()), "French"); } + fn kerning(app: &mut DesignApp) -> Value { + text_attrs(app).unwrap()["chars"]["kerning"].clone() + } + + /// A harness drawing `panel` for `app` (fonts installed before the first frame). + fn panel_harness(app: DesignApp, panel: fn(&mut DesignApp, &mut egui::Ui)) -> Harness<'static, DesignApp> { + Harness::builder().with_size(vec2(320.0, 1400.0)).build_ui_state( + move |ui, app: &mut DesignApp| { + let id = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::<bool>(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + panel(app, ui); + }, + app, + ) + } + + fn text_app() -> DesignApp { + 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, 300, 200], "content": "text", "text": "AVATAR", "caret": true})).unwrap(); + app.run("edit.selectAll", json!({})).unwrap(); + app + } + + #[test] + fn properties_kerning_menu_sets_zero_kerning() { + let mut h = panel_harness(text_app(), show); + h.run_steps(3); + assert_eq!(kerning(h.state_mut())["kind"], "metrics"); + h.get_by_label("Kerning").click(); + h.run_steps(2); + h.get_by_label("0").click(); + h.run_steps(2); + assert_eq!(kerning(h.state_mut())["kind"], "none"); + } + + #[test] + fn character_panel_has_kerning_and_takes_a_typed_manual_value() { + let mut h = panel_harness(text_app(), character_panel); + h.run_steps(3); + // The caption and the control share the name: click the control. + h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Kerning").click(); + h.run_steps(2); + // The menu's field is drawn after the panel's own fields. + let field = h.get_all_by_role(egui::accesskit::Role::TextInput).last().unwrap(); + field.focus(); + field.type_text("-40"); + h.run_steps(1); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(kerning(h.state_mut()), json!({"kind": "manual", "value": -40.0})); + } + #[test] fn clicking_a_swatch_chip_in_the_appearance_fill_menu_applies_it() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From 6cacf4e8b7764e7e31218f092f2e31341d83bb7c Mon Sep 17 00:00:00 2001 From: ujo4eva <92157639+ujo4eva@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:54:12 +0100 Subject: [PATCH 201/238] Linux: build an Arch package (.pkg.tar.zst) package.sh now also builds designcraft-<ver>-linux-<arch>.pkg.tar.zst with nfpm's archlinux packager, from the same nfpm.yaml. It is named by the bundle id (ai.storyteller.designcraft), provides designcraft and conflicts with designcraft, designcraft-bin and designcraft-git, so an AUR helper doesn't take it over and ArtCraft Launcher can tell it from an AUR build. The README lists it with the other Linux downloads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- README.md | 1 + packaging/linux/nfpm.yaml | 20 +++++++++++++++++++- packaging/linux/package.sh | 21 +++++++++++++++------ 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 0ec6540ba..7268b3d3d 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,7 @@ Installers and executables are code-signed. | Flatpak | `designcraft-<ver>-linux-x86_64.flatpak` | `designcraft-<ver>-linux-aarch64.flatpak` | Sandboxed; `flatpak install --user <file>` | | Debian/Ubuntu | `designcraft-<ver>-linux-x86_64.deb` | `designcraft-<ver>-linux-aarch64.deb` | | | Fedora/RHEL/openSUSE | `designcraft-<ver>-linux-x86_64.rpm` | `designcraft-<ver>-linux-aarch64.rpm` | | +| Arch Linux/Manjaro | `designcraft-<ver>-linux-x86_64.pkg.tar.zst` | `designcraft-<ver>-linux-aarch64.pkg.tar.zst` | `sudo pacman -U <file>`; installs as `ai.storyteller.designcraft`, replacing an AUR package | | Tarball | `designcraft-<ver>-linux-x86_64.tar.gz` | `designcraft-<ver>-linux-aarch64.tar.gz` | Unpack anywhere | RISC-V (riscv64): `designcraft-<ver>-linux-riscv64.tar.gz` only, cross-compiled; needs glibc 2.39+ (Ubuntu 24.04 or newer). diff --git a/packaging/linux/nfpm.yaml b/packaging/linux/nfpm.yaml index e2ce72b10..350db2e0f 100644 --- a/packaging/linux/nfpm.yaml +++ b/packaging/linux/nfpm.yaml @@ -1,4 +1,4 @@ -# .deb and .rpm packages (nfpm: https://nfpm.goreleaser.com). Run by package.sh, which stages +# .deb/.rpm/Arch packages (nfpm: https://nfpm.goreleaser.com). Run by package.sh, which stages # the file tree, substitutes its path for ${STAGE} (nfpm doesn't expand env vars in `src`) and # exports VERSION and NFPM_ARCH. # @@ -53,6 +53,24 @@ overrides: - libXrandr - mesa-vulkan-drivers - xdg-desktop-portal + # Named ai.storyteller.designcraft, not designcraft: the AUR already has designcraft, designcraft-bin and + # designcraft-git, and an AUR helper treats any installed package with an AUR name as its own and + # rebuilds it on the next update. ArtCraft Launcher also uses the bundle id to tell this package + # (which it updates with `pacman -U`) from an AUR build (which it leaves to the AUR helper). + # nfpm has no optdepends for Arch, so the recommends above have no equivalent here. + archlinux: + depends: + - glibc + - gcc-libs + - hicolor-icon-theme + - libxkbcommon + - vulkan-icd-loader + provides: + - designcraft + conflicts: + - designcraft + - designcraft-bin + - designcraft-git contents: - src: ${STAGE}/usr/bin/designcraft diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index fd7fc4ef8..d6391a9cd 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -5,11 +5,12 @@ # $DIST/designcraft-<version>-linux-<arch>.AppImage.zsync delta updates (needs zsyncmake) # $DIST/designcraft-<version>-linux-<arch>.deb Debian, Ubuntu, Mint, Pop!_OS, ... # $DIST/designcraft-<version>-linux-<arch>.rpm Fedora, openSUSE, RHEL, ... +# $DIST/designcraft-<version>-linux-<arch>.pkg.tar.zst Arch, Manjaro, EndeavourOS, ... # $DIST/designcraft-<version>-linux-<arch>.tar.gz plain FHS-style tree (bin/, share/) # -# Usage: packaging/linux/package.sh [--skip-build] [--formats "appimage deb rpm tar"] +# Usage: packaging/linux/package.sh [--skip-build] [--formats "appimage deb rpm arch tar"] # -# Needs: cargo; nfpm for deb/rpm (https://nfpm.goreleaser.com); appimagetool for the AppImage +# Needs: cargo; nfpm for deb/rpm/arch (https://nfpm.goreleaser.com); appimagetool for the AppImage # (downloaded into $CARGO_TARGET_DIR if missing). Build on an old distro (CI: Ubuntu 22.04, # glibc 2.35) so the binaries run on newer ones. Optional: desktop-file-validate, appstreamcli, # zsyncmake (the zsync package) for the AppImage's .zsync. @@ -20,12 +21,12 @@ HERE="$ROOT/packaging/linux" APP_ID=ai.storyteller.designcraft SKIP_BUILD=0 -FORMATS="appimage deb rpm tar" +FORMATS="appimage deb rpm arch tar" while [ $# -gt 0 ]; do case "$1" in --skip-build) SKIP_BUILD=1; shift ;; --formats) FORMATS="$2"; shift 2 ;; - -h | --help) sed -n '2,15p' "$0"; exit 0 ;; + -h | --help) sed -n '2,16p' "$0"; exit 0 ;; *) echo "unknown argument: $1" >&2; exit 2 ;; esac done @@ -85,8 +86,8 @@ if has tar; then echo "wrote $DIST/$BASENAME.tar.gz" fi -# ---- .deb / .rpm -------------------------------------------------------------------------------- -if has deb || has rpm; then +# ---- .deb / .rpm / .pkg.tar.zst ----------------------------------------------------------------- +if has deb || has rpm || has arch; then command -v nfpm >/dev/null || { echo "error: nfpm not found (https://nfpm.goreleaser.com/install/)" >&2; exit 1; } export VERSION export NFPM_ARCH="$DEB_ARCH" @@ -95,6 +96,14 @@ if has deb || has rpm; then for fmt in deb rpm; do if has "$fmt"; then (cd "$ROOT" && nfpm package -f "$WORK/nfpm.yaml" -p "$fmt" -t "$DIST/$BASENAME.$fmt"); fi done + # The Arch package is named by the bundle id (see `archlinux` in nfpm.yaml), which nfpm can't + # override per format, and `archlinux.packager` isn't env-expanded, so both are written here. + if has arch; then + sed "s|^name: designcraft\$|name: $APP_ID|" "$WORK/nfpm.yaml" >"$WORK/nfpm-arch.yaml" + grep -qx "name: $APP_ID" "$WORK/nfpm-arch.yaml" || { echo "error: couldn't rename the Arch package" >&2; exit 1; } + printf 'archlinux:\n packager: "%s"\n' "$DESIGNCRAFT_MAINTAINER" >>"$WORK/nfpm-arch.yaml" + (cd "$ROOT" && nfpm package -f "$WORK/nfpm-arch.yaml" -p archlinux -t "$DIST/$BASENAME.pkg.tar.zst") + fi fi # ---- AppImage ----------------------------------------------------------------------------------- From 11a51d5b665aec8d6e675bd107a8a01514f10d00 Mon Sep 17 00:00:00 2001 From: Brandon Thomas <brandon@storyteller.ai> Date: Wed, 14 Oct 2026 09:06:57 -0400 Subject: [PATCH 202/238] Release 0.8.0 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- Cargo.lock | 40 ++++++++++++++++++++-------------------- Cargo.toml | 2 +- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 81e7a5c6b..e77a81be4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -887,7 +887,7 @@ dependencies = [ [[package]] name = "designcraft" -version = "0.7.1" +version = "0.8.0" dependencies = [ "arboard", "designcraft-engine", @@ -905,7 +905,7 @@ dependencies = [ [[package]] name = "designcraft-cli" -version = "0.7.1" +version = "0.8.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -921,7 +921,7 @@ dependencies = [ [[package]] name = "designcraft-color" -version = "0.7.1" +version = "0.8.0" dependencies = [ "kurbo", "moxcms", @@ -933,7 +933,7 @@ dependencies = [ [[package]] name = "designcraft-compose" -version = "0.7.1" +version = "0.8.0" dependencies = [ "designcraft-color", "designcraft-doc", @@ -951,7 +951,7 @@ dependencies = [ [[package]] name = "designcraft-doc" -version = "0.7.1" +version = "0.8.0" dependencies = [ "designcraft-color", "designcraft-geom", @@ -963,7 +963,7 @@ dependencies = [ [[package]] name = "designcraft-engine" -version = "0.7.1" +version = "0.8.0" dependencies = [ "caseless", "craft-print", @@ -998,7 +998,7 @@ dependencies = [ [[package]] name = "designcraft-epub" -version = "0.7.1" +version = "0.8.0" dependencies = [ "designcraft-color", "designcraft-doc", @@ -1007,7 +1007,7 @@ dependencies = [ [[package]] name = "designcraft-fonts" -version = "0.7.1" +version = "0.8.0" dependencies = [ "harfrust 0.13.3", "kurbo", @@ -1019,7 +1019,7 @@ dependencies = [ [[package]] name = "designcraft-format" -version = "0.7.1" +version = "0.8.0" dependencies = [ "designcraft-doc", "serde", @@ -1030,7 +1030,7 @@ dependencies = [ [[package]] name = "designcraft-geom" -version = "0.7.1" +version = "0.8.0" dependencies = [ "flo_curves", "kurbo", @@ -1043,7 +1043,7 @@ dependencies = [ [[package]] name = "designcraft-idml" -version = "0.7.1" +version = "0.8.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1056,7 +1056,7 @@ dependencies = [ [[package]] name = "designcraft-images" -version = "0.7.1" +version = "0.8.0" dependencies = [ "designcraft-color", "designcraft-fonts", @@ -1067,7 +1067,7 @@ dependencies = [ [[package]] name = "designcraft-mcp" -version = "0.7.1" +version = "0.8.0" dependencies = [ "designcraft-engine", "designcraft-geom", @@ -1080,7 +1080,7 @@ dependencies = [ [[package]] name = "designcraft-pdf" -version = "0.7.1" +version = "0.8.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1102,7 +1102,7 @@ dependencies = [ [[package]] name = "designcraft-render" -version = "0.7.1" +version = "0.8.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1120,7 +1120,7 @@ dependencies = [ [[package]] name = "designcraft-textimport" -version = "0.7.1" +version = "0.8.0" dependencies = [ "designcraft-doc", "quick-xml 0.38.4", @@ -1129,7 +1129,7 @@ dependencies = [ [[package]] name = "designcraft-tools" -version = "0.7.1" +version = "0.8.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1141,7 +1141,7 @@ dependencies = [ [[package]] name = "designcraft-ui-egui" -version = "0.7.1" +version = "0.8.0" dependencies = [ "craft-ui", "designcraft-color", @@ -1169,7 +1169,7 @@ dependencies = [ [[package]] name = "designcraft-web" -version = "0.7.1" +version = "0.8.0" dependencies = [ "designcraft-engine", "designcraft-format", @@ -5697,7 +5697,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.7.1" +version = "0.8.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 9ce011b57..e76609e08 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"] [workspace.package] -version = "0.7.1" +version = "0.8.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From c7b3ee4f45983d2f2b45d5a0133fe77117e34c85 Mon Sep 17 00:00:00 2001 From: Govinda Roy <govindaroy.ofc94@gmail.com> Date: Wed, 14 Oct 2026 19:39:37 +0600 Subject: [PATCH 203/238] Layers panel: a new selection opens its layer and scrolls its row into view Selecting an object on the canvas left the Layers panel as it was: its layer could be closed and its row out of view. A new selection now opens the layer it is on and scrolls its row into view once (closing or scrolling afterwards is left alone); selected rows are highlighted, and an object selected inside a group shows as its group. Unnamed image frames are listed by their file name, as InDesign lists placed images. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/panels/layers.rs | 54 ++++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index a9c842116..8f5ef35a4 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -13,7 +13,8 @@ const ROW_H: f32 = 24.0; /// Width of the eye and lock columns. const COL_W: f32 = 22.0; -/// Display name of an item: its own name, the first words of a text frame's story, or the kind. +/// Display name of an item: its own name, the first words of a text frame's story, the file a +/// graphic frame shows (as InDesign lists placed images), or the kind. pub fn item_label(doc: &Document, it: &Item) -> String { if !it.name.is_empty() { return it.name.clone(); @@ -41,6 +42,9 @@ pub fn item_label(doc: &Document, it: &Item) -> String { return format!("<{short}{ell}>"); } } + if let Some(name) = it.graphic().and_then(|g| doc.assets.get(&g.asset)).map(|a| a.name.trim()).filter(|n| !n.is_empty()) { + return format!("<{name}>"); + } it.default_label().to_string() } @@ -111,6 +115,15 @@ pub fn show(app: &mut DesignApp, ui: &mut Ui) { let active = st.active_layer; let selected: Vec<ItemId> = st.selection.items.clone(); let sel_layers: Vec<_> = selected.iter().filter_map(|i| doc.item(*i).map(|it| it.layer)).collect(); + // The rows stand for top-level objects: an object selected inside a group shows as its group. + let shown: Vec<ItemId> = selected.iter().map(|i| doc.top_level_of(*i).unwrap_or(*i)).collect(); + // A new selection (made on the canvas or anywhere else) is revealed: its layer opens and its row + // scrolls into view, once, so the panel can still be scrolled and closed freely afterwards. + let last_id = egui::Id::new("layers_revealed_selection"); + let now: Vec<u64> = shown.iter().map(|i| i.0).collect(); + let reveal = !now.is_empty() && ui.data(|d| d.get_temp::<Vec<u64>>(last_id)).as_ref() != Some(&now); + ui.data_mut(|d| d.insert_temp(last_id, now)); + let mut scrolled = false; // Objects on the current spread, topmost first. let cur = crate::canvas::current_page(app).unwrap_or(0); let spread_items: Vec<std::sync::Arc<Item>> = @@ -118,7 +131,11 @@ pub fn show(app: &mut DesignApp, ui: &mut Ui) { ui.spacing_mut().item_spacing.y = 0.0; for l in doc.layers.iter().rev() { let open_id = egui::Id::new(("layer_open", l.id.0)); - let open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(l.id == active); + let mut open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(l.id == active); + if reveal && sel_layers.contains(&l.id) && !open { + open = true; + ui.data_mut(|d| d.insert_temp(open_id, true)); + } let lc = c32(l.color); let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), Sense::click()); if l.id == active { @@ -201,8 +218,14 @@ pub fn show(app: &mut DesignApp, ui: &mut Ui) { } for it in spread_items.iter().filter(|i| i.layer == l.id) { let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), Sense::click()); - let is_sel = selected.contains(&it.id); - if resp.hovered() { + let is_sel = shown.contains(&it.id); + if is_sel { + ui.painter().rect_filled(row, 0.0, t.row_selected); + if reveal && !scrolled { + resp.scroll_to_me(Some(egui::Align::Center)); + scrolled = true; + } + } else if resp.hovered() { ui.painter().rect_filled(row, 0.0, t.hover); } let (eye, lock) = eye_lock(ui, &app.ui.language, row, !it.hidden, it.locked, !l.visible, ("item", it.id.0)); @@ -267,4 +290,27 @@ mod tests { let rr = doc.item(ItemId(rect["id"].as_u64().unwrap())).unwrap(); assert_eq!(item_label(doc, rr), "<rectangle>"); } + + #[test] + fn selecting_an_object_opens_its_layer_in_the_panel() { + use egui_kittest::Harness; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 200, 200]})).unwrap()["id"].as_u64().unwrap(); + let first = app.session.active().unwrap().doc.item(ItemId(id)).unwrap().layer; + // A new layer becomes the active one: the first layer's row starts closed. + app.run("layer.new", json!({})).unwrap(); + app.run("selection.set", json!({"ids": []})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(260.0, 400.0)).build_ui_state(|ui, app: &mut DesignApp| show(app, ui), app); + h.run_steps(2); + let open = |h: &Harness<'_, DesignApp>| h.ctx.data(|d| d.get_temp::<bool>(egui::Id::new(("layer_open", first.0)))).unwrap_or(false); + assert!(!open(&h), "closed while nothing on it is selected"); + h.state_mut().run("selection.set", json!({"ids": [id]})).unwrap(); + h.run_steps(2); + assert!(open(&h), "selecting the object opens its layer"); + // Closed again by hand, it stays closed while the selection stays the same. + h.ctx.data_mut(|d| d.insert_temp(egui::Id::new(("layer_open", first.0)), false)); + h.run_steps(2); + assert!(!open(&h)); + } } From 44b24d32ba99240d3b919d15c8ace911fcd05a86 Mon Sep 17 00:00:00 2001 From: noah1400 <48360809+noah1400@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:18:13 +0200 Subject: [PATCH 204/238] Render: wide effect blurs are drawn at a lower resolution (#904) A drop shadow, inner shadow or outer glow with a large size needed gigabytes for one render: the multithreaded pipeline's offscreen image grew by three times the blur on every side, and vello pads its own filter layer by as much again, on single-threaded contexts too. A blur wider than 1,024 device pixels is now drawn offscreen at a lower resolution, where it is that wide, and scaled back up. It looks the same, and both buffers stay close to the window's size. --- crates/render/src/fx.rs | 76 +++++++++++++++++++++++++++++++---------- 1 file changed, 58 insertions(+), 18 deletions(-) diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 6b29516f4..cc9f717a2 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -4,7 +4,7 @@ //! vello_cpu only supports filter layers in single-threaded contexts, so on the multithreaded //! pipeline each effect is rendered into an offscreen single-threaded context cropped to its //! reach and composited back as an image ([`Renderer::with_filters`]); single-threaded contexts -//! draw directly. +//! draw directly. A very wide blur goes offscreen in both, at a lower resolution. //! //! - Drop shadow / outer glow: the object's silhouette (everything it paints: fill, image, text, //! stroke) in a `DropShadowOnly` layer under the object. `size` sets the blur (σ = size / 2), @@ -337,8 +337,9 @@ impl Renderer { } /// Run `draw`, which pushes filter layers. Single-threaded contexts draw directly. On a - /// multithreaded context the layer is drawn into an offscreen single-threaded context - /// covering `reach` (spread space) plus the blur's spread, then composited back. + /// multithreaded context, or for a blur wider than [`MAX_BLUR_SPREAD`], the layer is drawn into + /// an offscreen single-threaded context covering `reach` (spread space) plus the blur's spread, + /// then composited back. pub(crate) fn with_filters( &mut self, ctx: &mut RenderContext, @@ -347,28 +348,67 @@ impl Renderer { sigma: f64, draw: impl FnOnce(&mut Self, &mut RenderContext, &Frame), ) { - if !f.mt { - return draw(self, ctx, f); - } // 3σ of the Gaussian in pixels, plus a pixel of antialiasing. let spread = sigma.max(0.0) * 3.0 / f.px + 2.0; - let screen = Rect::new(0.0, 0.0, ctx.width() as f64, ctx.height() as f64).inflate(spread, spread); - let r = f.view.transform_rect_bbox(reach).inflate(spread, spread).intersect(screen); - let (x0, y0) = (r.x0.floor(), r.y0.floor()); - let (w, h) = ((r.x1.ceil() - x0).min(crate::MAX_SIDE as f64), (r.y1.ceil() - y0).min(crate::MAX_SIDE as f64)); - if w < 1.0 || h < 1.0 { - return; + if !f.mt && spread <= MAX_BLUR_SPREAD { + return draw(self, ctx, f); } - let (w, h) = (w as u16, h as u16); - let mut off = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }); - let shifted = Frame { mt: false, view: Affine::translate((-x0, -y0)) * f.view, ..*f }; + let Some(o) = offscreen(f.view.transform_rect_bbox(reach), spread, ctx.width(), ctx.height()) else { return }; + let mut off = RenderContext::new_with(o.width, o.height, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }); + let view = Affine::scale(1.0 / o.scale) * Affine::translate((-o.x0, -o.y0)) * f.view; + let shifted = Frame { mt: false, view, px: f.px * o.scale, ..*f }; draw(self, &mut off, &shifted); off.flush(); - let mut pm = vello_cpu::Pixmap::new(w, h); + let mut pm = vello_cpu::Pixmap::new(o.width, o.height); off.render(&mut pm, &mut self.resources); - ctx.set_transform(Affine::translate((x0, y0))); + ctx.set_transform(Affine::translate((o.x0, o.y0)) * Affine::scale(o.scale)); ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(std::sync::Arc::new(pm)), sampler: peniko::ImageSampler::default() }); - ctx.fill_rect(&Rect::new(0.0, 0.0, w as f64, h as f64)); + ctx.fill_rect(&Rect::new(0.0, 0.0, f64::from(o.width), f64::from(o.height))); ctx.set_transform(Affine::IDENTITY); } } + +/// The widest blur (3σ, device pixels) drawn at full resolution. vello pads a filter layer by the +/// blur's width on every side, so a wide blur needs gigabytes; it is drawn at a lower resolution +/// instead, where it is this wide, and has no detail to lose there. +const MAX_BLUR_SPREAD: f64 = 1024.0; + +/// An offscreen image for [`Renderer::with_filters`]: where it goes on the device, how many device +/// pixels one of its pixels covers, and its size. +#[derive(Debug, PartialEq)] +struct Offscreen { + x0: f64, + y0: f64, + scale: f64, + width: u16, + height: u16, +} + +/// The offscreen image an effect is drawn in: `reach` (device pixels) widened by the blur's +/// `spread`, within the window widened the same way, at a resolution that keeps the blur within +/// [`MAX_BLUR_SPREAD`], at most `MAX_SIDE` a side. `None` when none of it shows. +fn offscreen(reach: Rect, spread: f64, width: u16, height: u16) -> Option<Offscreen> { + let screen = Rect::new(0.0, 0.0, f64::from(width), f64::from(height)).inflate(spread, spread); + let r = reach.inflate(spread, spread).intersect(screen); + let (x0, y0) = (r.x0.floor(), r.y0.floor()); + let scale = (spread / MAX_BLUR_SPREAD).max(1.0); + let side = |len: f64| (len / scale).ceil().min(crate::MAX_SIDE as f64); + let (w, h) = (side(r.x1.ceil() - x0), side(r.y1.ceil() - y0)); + (w >= 1.0 && h >= 1.0).then_some(Offscreen { x0, y0, scale, width: w as u16, height: h as u16 }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_wide_blur_is_drawn_offscreen_at_a_lower_resolution() { + let object = Rect::new(150.0, 150.0, 350.0, 350.0); + let small = Offscreen { x0: 140.0, y0: 140.0, scale: 1.0, width: 220, height: 220 }; + assert_eq!(offscreen(object, 10.0, 1440, 900), Some(small)); + // A 10,000 pt drop shadow at 100%: 3σ is 15,000 px, which asked for 30,204 px a side and as + // much padding again inside vello's filter layer. Drawn at 1/14.65 it is 1,024 px wide. + let wide = Offscreen { x0: -14852.0, y0: -14852.0, scale: 15_002.0 / 1024.0, width: 2062, height: 2062 }; + assert_eq!(offscreen(object, 15_002.0, 1440, 900), Some(wide)); + } +} From 455442615b1c8b7613ab9d702dbaf604a70df5fd Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <marcoarnulfo1@gmail.com> Date: Wed, 14 Oct 2026 16:29:10 +0200 Subject: [PATCH 205/238] Color parsing: bare CMYK is always 0..100, so C 1 is a 1 % tint (fixes #910) --- ROADMAP.md | 2 ++ crates/engine/src/cmd/style.rs | 51 +++++++++++++++++++++++++++------- 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c60a04107..b6e79ba9d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -128,6 +128,8 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-14** · Colour parameters: a bare `{c,m,y,k}` is always read as 0..100, so C 1 is a 1 % tint and no longer + solid cyan; fractions take the colour's own `{model: "cmyk", …}` form (#910). No readiness percentage change. - **2026-10-12** · Text threads by hand: click a selected text frame's in or out port to load the text cursor, then click a frame to thread it, click empty space for a column-wide frame or drag one (it snaps like frame drawing); Shift-click autoflows, Alt-click stays loaded on the diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 0280612d1..8cd0a0230 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -135,7 +135,7 @@ pub fn specs() -> Vec<CommandSpec> { "New Color Swatch…", [], None, - "{name?, color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b], spot?: bool}", + "{name?, color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b]|{model: cmyk|rgb|gray, …}(0..1), spot?: bool}", has_doc, create_swatch ), @@ -144,7 +144,7 @@ pub fn specs() -> Vec<CommandSpec> { "Gradient", [], None, - "{kind?: linear|radial, stops?: [{location: 0–100, color: \"#rrggbb\"|{c,m,y,k}|[r,g,b], opacity?: 0–100, midpoint?: 13–87}], reverse?: bool, angle?: degrees, from?: [x,y], to?: [x,y] (spread coords: the Gradient Swatch tool's drag), ids?} → {swatch, kind, stops} — edits the fill's gradient (an unnamed gradient unless it equals a gradient swatch)", + "{kind?: linear|radial, stops?: [{location: 0–100, color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b]|{model, …}(0..1), opacity?: 0–100, midpoint?: 13–87}], reverse?: bool, angle?: degrees, from?: [x,y], to?: [x,y] (spread coords: the Gradient Swatch tool's drag), ids?} → {swatch, kind, stops} — edits the fill's gradient (an unnamed gradient unless it equals a gradient swatch)", super::has_selection, apply_gradient ), @@ -180,7 +180,7 @@ pub fn specs() -> Vec<CommandSpec> { "Apply Color", [], None, - "{color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b](0..255), target?: fill|stroke, text?: bool, ids?} — an unnamed colour (not in the Swatches panel until Add to Swatches) on the selection; `text`: on the selected text, or the text of the selected frames (Formatting Affects Text)", + "{color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b](0..255)|{model: cmyk|rgb|gray, …}(0..1), target?: fill|stroke, text?: bool, ids?} — an unnamed colour (not in the Swatches panel until Add to Swatches) on the selection; `text`: on the selected text, or the text of the selected frames (Formatting Affects Text)", has_text_or_selection, apply_color ), @@ -747,18 +747,20 @@ pub fn parse_color(v: &Value) -> Option<designcraft_color::Color> { use designcraft_color::Color; match v { Value::String(s) => Color::from_hex(s), - // One scale per colour, not per component: {c: 0.7, y: 12.2} is C 0.7 %, Y 12.2 %. Only a colour whose + // One scale per colour, not per component: [1, 128, 255] is 0..255. Only an RGB array whose // components are all within 0..1 is read as fractions (the older form). Value::Array(a) if a.len() >= 3 => { let v = [a[0].as_f64()?, a[1].as_f64()?, a[2].as_f64()?]; let scale = if v.iter().any(|&x| x > 1.0) { 255.0 } else { 1.0 }; Some(Color::rgb((v[0] / scale) as f32, (v[1] / scale) as f32, (v[2] / scale) as f32)) } + // `{model, …}` is the colour's own form (as `color.convert` takes and returns it): fractions. + Value::Object(o) if o.contains_key("model") => serde_json::from_value(v.clone()).ok(), + // Bare CMYK is always 0..100, as documented: C 1 % is a real tint, so values of 1 or less can't + // mean fractions (#910). Value::Object(o) => { - let g = |k: &str| o.get(k).and_then(Value::as_f64); - let v = [g("c")?, g("m")?, g("y")?, g("k")?]; - let scale = if v.iter().any(|&x| x > 1.0) { 100.0 } else { 1.0 }; - Some(Color::cmyk((v[0] / scale) as f32, (v[1] / scale) as f32, (v[2] / scale) as f32, (v[3] / scale) as f32)) + let g = |k: &str| o.get(k).and_then(Value::as_f64).map(|x| (x / 100.0) as f32); + Some(Color::cmyk(g("c")?, g("m")?, g("y")?, g("k")?)) } _ => None, } @@ -1415,14 +1417,43 @@ mod color_tests { // {"c": 0.7, ..., "y": 12.2} is a pale cream (C 0.7 %, Y 12.2 %), not C 70 %. let Some(Color::Cmyk { c, m, y, k }) = parse_color(&json!({"c": 0.7, "m": 0, "y": 12.2, "k": 0})) else { panic!("not CMYK") }; assert!((c - 0.007).abs() < 1e-6 && m == 0.0 && (y - 0.122).abs() < 1e-6 && k == 0.0, "got {c} {m} {y} {k}"); - // Fractions (every component within 0..1) keep working. - let Some(Color::Cmyk { c, k, .. }) = parse_color(&json!({"c": 0.5, "m": 0, "y": 0, "k": 1})) else { panic!("not CMYK") }; + // Fractions take the colour's own form, with its model. + let Some(Color::Cmyk { c, k, .. }) = parse_color(&json!({"model": "cmyk", "c": 0.5, "m": 0, "y": 0, "k": 1})) else { panic!("not CMYK") }; assert!((c - 0.5).abs() < 1e-6 && (k - 1.0).abs() < 1e-6); // RGB arrays follow the same rule: [1, 128, 255] is 0..255. let Some(Color::Rgb { r, g, b }) = parse_color(&json!([1, 128, 255])) else { panic!("not RGB") }; assert!((r - 1.0 / 255.0).abs() < 1e-6 && (g - 128.0 / 255.0).abs() < 1e-6 && (b - 1.0).abs() < 1e-6); } + /// #910: `{c: 1, m: 0, y: 0, k: 0}` is a 1 % cyan tint, not solid cyan, though every component is within 0..1. + #[test] + fn cmyk_percentages_of_one_or_less_stay_percent() { + use designcraft_color::Color; + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [0, 0, 50, 50]})).unwrap()["id"].as_u64().unwrap(); + s.execute("swatch.create", &json!({"name": "Tint1", "color": {"c": 1, "m": 0, "y": 0, "k": 0}})).unwrap(); + s.execute("object.fill", &json!({"ids": [id], "swatch": "Tint1"})).unwrap(); + let d = &s.doc().unwrap().doc; + let Some(Color::Cmyk { c, m, y, k }) = d.resolve_color("Tint1", 1.0) else { panic!("not CMYK") }; + assert!((c - 0.01).abs() < 1e-6 && m == 0.0 && y == 0.0 && k == 0.0, "got {c} {m} {y} {k}"); + let pdf = designcraft_pdf::export_pdf(d, &s.cache, &designcraft_pdf::PdfOptions { compress: false, ..Default::default() }).unwrap(); + let text = String::from_utf8_lossy(&pdf); + // Operands of every `k` (DeviceCMYK fill), last first: [k, y, m, c]. Inks are written in 1/255 steps (C 1 % is 3/255). + let fills: Vec<Vec<f32>> = + text.split(" k ").filter_map(|before| before.rsplit(' ').take(4).map(|x| x.parse().ok()).collect::<Option<Vec<f32>>>()).collect(); + let cyan = |want: f32| fills.iter().any(|f| (f[3] - want).abs() < 0.5 / 255.0 + 1e-6 && f[..3] == [0.0; 3]); + assert!(cyan(0.01), "C 1 % in the PDF: {fills:?}"); + assert!(!cyan(1.0), "no solid cyan: {fills:?}"); + // A light tint whose components are all 1 or less, through object.color. + s.execute("selection.set", &json!({"ids": [id]})).unwrap(); + s.execute("object.color", &json!({"color": {"c": 0.5, "m": 0.5, "y": 0, "k": 0}})).unwrap(); + let d = &s.doc().unwrap().doc; + let fill = &d.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch; + let Some(Color::Cmyk { c, m, .. }) = d.resolve_color(fill, 1.0) else { panic!("not CMYK") }; + assert!((c - 0.005).abs() < 1e-6 && (m - 0.005).abs() < 1e-6, "got {c} {m}"); + } + #[test] fn style_groups_rename_every_use() { let mut s = Session::new(); From b12e94c11d0e302adc20fa8f353e8d0f42fc7b6a Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <marcoarnulfo1@gmail.com> Date: Wed, 14 Oct 2026 16:37:55 +0200 Subject: [PATCH 206/238] Package: a missing link stays missing, with no empty file in Links/ (fixes #908) --- crates/engine/src/cmd/package.rs | 48 ++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index 25f09537c..8daa68e4b 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -292,6 +292,11 @@ fn write_links(d: &Document, root: &OutputRoot, dir: &Path, only: Option<&[Asset continue; } let Some(a) = d.assets.get(&id) else { continue }; + // A missing link has no bytes: an empty file under its name would read as present, so it + // keeps its old (missing) path instead. + if a.data.is_empty() { + continue; + } let name = link_name(a.link.as_deref().unwrap_or(&a.name), id, &mut taken); let path = dir.join(&name); root.write(&path, a.data.as_slice())?; @@ -472,6 +477,49 @@ mod tests { std::fs::remove_dir_all(dir).unwrap(); } + /// A link whose file is gone has no bytes to copy: the package leaves it out of Links/, the + /// report lists it as missing, and the packaged document keeps it missing (#908). + #[test] + fn package_leaves_missing_links_missing() { + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("dc-package-missing-{}-{nonce}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40"><rect width="40" height="40"/></svg>"##; + let (gone, kept) = (dir.join("gone.svg"), dir.join("kept.svg")); + std::fs::write(&gone, svg).unwrap(); + std::fs::write(&kept, svg.replace("<rect", "<circle r=\"9\"/><rect")).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({"title": "TwoLinks"})).unwrap(); + s.execute("file.place", &json!({"path": gone.to_string_lossy(), "x": 10, "y": 10})).unwrap(); + s.execute("edit.deselectAll", &json!({})).unwrap(); + s.execute("file.place", &json!({"path": kept.to_string_lossy(), "x": 10, "y": 200})).unwrap(); + let idml = dir.join("TwoLinks.idml"); + s.execute("file.exportIdml", &json!({"path": idml.to_string_lossy(), "embedImages": false})).unwrap(); + std::fs::remove_file(&gone).unwrap(); + let mut s = Session::new(); + s.execute("file.openIdml", &json!({"path": idml.to_string_lossy()})).unwrap(); + let status = |s: &mut Session, name: &str| { + let links = s.execute("links.list", &json!({})).unwrap(); + links.as_array().unwrap().iter().find(|l| l["name"] == name).map(|l| l["status"].clone()) + }; + assert_eq!(status(&mut s, "gone.svg"), Some(json!("missing"))); + + let out = dir.join("Pkg"); + let r = s.execute("file.package", &json!({"dir": out.to_string_lossy(), "idml": true})).unwrap(); + assert!(!out.join("Links").join("gone.svg").exists(), "a missing link is not copied"); + assert!(out.join("Links").join("kept.svg").exists()); + assert!(!r["files"].to_string().contains("gone.svg"), "{}", r["files"]); + let report = r["report"].as_str().unwrap(); + assert!(report.contains("gone.svg (missing)"), "{report}"); + for (command, extension) in [("file.open", "designcraft"), ("file.openIdml", "idml")] { + let mut opened = Session::new(); + opened.execute(command, &json!({"path": out.join(format!("TwoLinks.{extension}")).to_string_lossy()})).unwrap(); + assert_eq!(status(&mut opened, "gone.svg"), Some(json!("missing")), "{extension}"); + assert_eq!(status(&mut opened, "kept.svg"), Some(json!("ok")), "{extension}"); + } + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] fn package_writes_document_links_and_report() { let dir = std::env::temp_dir().join(format!("dc-package-{}", std::process::id())); From a0de7a1e07a6f0b788e7ebbbe6a074e1d3d94fbe Mon Sep 17 00:00:00 2001 From: ujo4eva <92157639+ujo4eva@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:41:04 +0100 Subject: [PATCH 207/238] Linux: Arch package without pacman's permission and mtime warnings nfpm (2.47, 2.48) writes Go's directory bit into the mode of each directory a `type: tree` entry walks (0o20000000755), so installing the package warned "directory permissions differ" on every shared directory under /usr/share. It also stamps the files and .MTREE a moment apart, so `pacman -Qkk` reported most files as modified. The Arch build now lists usr/share file by file (nfpm then gives the parent directories a plain 0755) and fixes the time with SOURCE_DATE_EPOCH (the commit time unless set), which also makes the package reproducible. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- packaging/linux/package.sh | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index d6391a9cd..2d7b80ee8 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -102,7 +102,29 @@ if has deb || has rpm || has arch; then sed "s|^name: designcraft\$|name: $APP_ID|" "$WORK/nfpm.yaml" >"$WORK/nfpm-arch.yaml" grep -qx "name: $APP_ID" "$WORK/nfpm-arch.yaml" || { echo "error: couldn't rename the Arch package" >&2; exit 1; } printf 'archlinux:\n packager: "%s"\n' "$DESIGNCRAFT_MAINTAINER" >>"$WORK/nfpm-arch.yaml" - (cd "$ROOT" && nfpm package -f "$WORK/nfpm-arch.yaml" -p archlinux -t "$DIST/$BASENAME.pkg.tar.zst") + # nfpm (2.47, 2.48) writes Go's directory bit into the mode of each directory a `type: tree` + # entry walks (0o20000000755), and pacman then warns "directory permissions differ" on every + # shared directory under /usr/share. List the tree's files one by one instead: nfpm gives their + # parent directories a plain 0755. + if find "$STAGE/usr/share" ! -type d ! -type f | grep -q .; then + echo "error: only plain files can be listed in the Arch package's usr/share" >&2; exit 1 + fi + find "$STAGE/usr/share" -type f | LC_ALL=C sort | while IFS= read -r f; do + printf ' - src: "%s"\n dst: "%s"\n' "$f" "${f#"$STAGE"}" + done >"$WORK/arch-share.yaml" + awk -v files="$WORK/arch-share.yaml" -v tree=" - src: $STAGE/usr/share/" ' + $0 == tree { while ((getline line < files) > 0) print line; skip = 2; next } + skip > 0 { skip--; next } + { print } + ' "$WORK/nfpm-arch.yaml" >"$WORK/nfpm-arch.yaml.new" + mv "$WORK/nfpm-arch.yaml.new" "$WORK/nfpm-arch.yaml" + if grep -q 'type: tree' "$WORK/nfpm-arch.yaml"; then + echo "error: couldn't list the Arch package's files" >&2; exit 1 + fi + # Without a fixed time nfpm stamps the files and .MTREE a moment apart, and `pacman -Qkk` + # reports every file as modified. The commit time also makes the package reproducible. + epoch="${SOURCE_DATE_EPOCH:-$(git -C "$ROOT" log -1 --format=%ct 2>/dev/null || date +%s)}" + (cd "$ROOT" && SOURCE_DATE_EPOCH="$epoch" nfpm package -f "$WORK/nfpm-arch.yaml" -p archlinux -t "$DIST/$BASENAME.pkg.tar.zst") fi fi From 52eac14f2c6399ea6c275a43b77ca69307d9e8ff Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <marcoarnulfo1@gmail.com> Date: Wed, 14 Oct 2026 16:48:52 +0200 Subject: [PATCH 208/238] PDF: a placed PDF 1.7 with /Version /1.7 in its catalog goes into PDF/X-4 exports (fixes #907) --- crates/engine/src/cmd/export.rs | 39 +++++++++++++++++++++++++++++++++ crates/pdf/src/export.rs | 31 +++++++++++++++++++++++--- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 8e0f04d69..ef655b500 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -850,6 +850,45 @@ mod placed_pdf_print_tests { let e = s.execute("file.exportPdf", &json!({"standard": "x4"})).unwrap_err().to_string(); assert!(e.contains("logo.pdf is PDF 2.0") && e.contains("PDF 1.6"), "{e}"); } + + /// A one-page PDF with a black box whose catalog says `/Version /1.7`, as TCPDF writes it. + fn catalog_v17(header: &str) -> Vec<u8> { + let content = "0 0 0 1 k 0 0 200 200 re f"; + let mut pdf = format!("{header}\n").into_bytes(); + let mut offsets = Vec::new(); + for body in [ + "<</Type/Catalog/Pages 2 0 R/Version /1.7>>".to_owned(), + "<</Type/Pages/Kids[3 0 R]/Count 1>>".to_owned(), + "<</Type/Page/Parent 2 0 R/MediaBox[0 0 200 200]/Contents 4 0 R/Resources<<>>>>".to_owned(), + format!("<</Length {}>>\nstream\n{content}\nendstream", content.len()), + ] { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", offsets.len()).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(b"xref\n0 5\n0000000000 65535 f\r\n"); + for offset in offsets { + pdf.extend_from_slice(format!("{offset:010} 00000 n\r\n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<</Size 5/Root 1 0 R>>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + pdf + } + + /// A placed PDF whose catalog `/Version /1.7` overrides its header failed the PDF/X-4 export + /// with "logo.pdf is PDF 1.7", though a 1.7 header alone is relabelled (#907). + #[test] + fn catalog_version_17_placed_in_pdfx4() { + for header in ["%PDF-1.7", "%PDF-1.4"] { + let pdf = catalog_v17(header); + assert_eq!(hayro_syntax::Pdf::new(pdf.clone()).unwrap().version(), hayro_syntax::PdfVersion::Pdf17); + let mut s = page_with(&pdf); + let r = s.execute("file.exportPdf", &json!({"standard": "x4"})).unwrap_or_else(|e| panic!("{header}: {e}")); + assert!(r["warnings"].to_string().contains("logo.pdf: a PDF 1.7 placed in a PDF 1.6 file"), "{}", r["warnings"]); + let out = base64_decode(r["base64"].as_str().unwrap()); + assert!(out.starts_with(b"%PDF-1.6")); + assert_eq!(hayro_syntax::Pdf::new(out).unwrap().version(), hayro_syntax::PdfVersion::Pdf16); + } + } } #[cfg(test)] diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index dcf8abb83..45b87e6f5 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -1172,7 +1172,8 @@ impl Exporter<'_> { /// A placed PDF as krilla embeds it. krilla refuses a PDF newer than the file it writes (the /// export fails at the end with `VersionMismatch(Pdf17)`), and PDF/X-4 is PDF 1.6 while /// VectorCraft and most tools write 1.7. PDF 1.7 changed nothing in how a page draws, so a 1.7 - /// file goes in as 1.6 (its header relabelled, as `qpdf --force-version=1.6` does). A newer + /// file goes in as 1.6 (its header and catalog `/Version` relabelled, as + /// `qpdf --force-version=1.6` does). A newer /// file is left out and the export fails naming it ([`Exporter::check_placed_versions`]). fn placed_pdf(&mut self, asset: &designcraft_doc::Asset) -> Option<krilla::pdf::PdfDocument> { use hayro_syntax::PdfVersion as V; @@ -1193,8 +1194,8 @@ impl Exporter<'_> { } if have == V::Pdf17 && max == V::Pdf16 - && let Some(bytes) = relabel_pdf_header(&asset.data, b"%PDF-1.7", b"%PDF-1.6") - && let Ok(pdf) = krilla::pdf::Pdf::new(Arc::new(bytes)) + && let bytes = relabel_pdf_header(&asset.data, b"%PDF-1.7", b"%PDF-1.6").unwrap_or_else(|| asset.data.to_vec()) + && let Ok(pdf) = krilla::pdf::Pdf::new(Arc::new(relabel_catalog_version(bytes))) && pdf.version() <= max { self.warn(format!("{}: a PDF 1.7 placed in a PDF 1.6 file, embedded as PDF 1.6", asset.name)); @@ -1360,6 +1361,30 @@ fn relabel_pdf_header(data: &[u8], from: &[u8], to: &[u8]) -> Option<Vec<u8>> { Some(out) } +/// `data` with every `/Version /1.7` changed to `/Version /1.6` in place, so the xref offsets +/// hold. The catalog's `/Version` wins over the header (TCPDF writes both), so relabelling the +/// header alone leaves the file a PDF 1.7. A catalog inside a compressed object stream is left as +/// it is, and the version check still names the file. +fn relabel_catalog_version(mut data: Vec<u8>) -> Vec<u8> { + const KEY: &[u8] = b"/Version"; + let mut from = 0; + while let Some(at) = data.get(from..).and_then(|rest| rest.windows(KEY.len()).position(|w| w == KEY)) { + let mut i = from + at + KEY.len(); + while data.get(i).is_some_and(|b| b" \t\r\n\x0c\0".contains(b)) { + i += 1; + } + let ends = data.get(i + 4).is_none_or(|b| !b.is_ascii_alphanumeric() && *b != b'.'); + if data.get(i..i + 4) == Some(b"/1.7".as_slice()) + && ends + && let Some(b) = data.get_mut(i + 3) + { + *b = b'6'; + } + from = i; + } + data +} + /// The sRGB profile (ICC v2.1) that untagged RGB images carry in PDF/X-4. pub(crate) static SRGB_ICC: &[u8] = include_bytes!("../../../assets/icc/sRGB-v2-magic.icc"); From cbba1fe7e1cc1e148e0e862090938f704b86e0ab Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Wed, 14 Oct 2026 18:15:14 +0200 Subject: [PATCH 209/238] Tools panel as InDesign's: Gradient Feather a tool of its own; two-column layout; View Options; Apply Color and Apply Gradient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chris Nielsen's "The Adobe InDesign Toolbox" (InDesign 2020) walks the Tools panel in one and two columns. Compared with it: - Gradient Swatch and Gradient Feather are separate tools (one flyout here before). - In two columns InDesign lays out Content Collector and Content Placer, Apply Color / Apply Gradient / Apply None and Normal / Preview (with Bleed, Slug, Presentation in its flyout) side by side (SPLIT_IN_TWO_COLUMNS). One column keeps one well with a flyout, which now names what is in front (Apply Color, Apply Gradient or Apply None). - The utility button is View Options: Frame Edges, Rulers, Guides, Smart Guides, Baseline Grid and Hidden Characters, checked when shown, with their shortcuts (17:40). It was Text Frame Options (Object › Text Frame Options…, ⌘B, still opens it). - Apply Color (,) applies the colour applied last ([Black] at first) and Apply Gradient (.) the gradient applied last, else the document's first, else white to black (InDesign's default), to the fill or stroke in front. The flyout had Apply Color as [Black] and no Apply Gradient. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 1 + crates/tools/src/catalog.rs | 13 +- crates/tools/src/lib.rs | 2 +- crates/ui-egui/src/i18n.rs | 6 + crates/ui-egui/src/i18n/ar.rs | 5 + crates/ui-egui/src/i18n/it.rs | 5 + crates/ui-egui/src/i18n/ja.rs | 5 + crates/ui-egui/src/i18n/pt_br.rs | 5 + crates/ui-egui/src/i18n/uk.rs | 5 + crates/ui-egui/src/icons.rs | 13 ++ crates/ui-egui/src/lib.rs | 8 + crates/ui-egui/src/menus.rs | 9 + crates/ui-egui/src/panels/mod.rs | 14 ++ crates/ui-egui/src/toolbar.rs | 283 +++++++++++++++++++++++++++---- 14 files changed, 336 insertions(+), 38 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c60a04107..0c39b7ce4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -128,6 +128,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-14** · The Tools panel follows InDesign's: Gradient Swatch and Gradient Feather are tools of their own; in two columns Content Collector and Placer, Apply Color / Gradient / None and Normal / Preview each get a button; View Options holds Frame Edges, Rulers, Guides, Smart Guides, Baseline Grid and Hidden Characters; Apply Color (,) and Apply Gradient (.) reuse the last colour and gradient. No readiness percentage change. - **2026-10-12** · Text threads by hand: click a selected text frame's in or out port to load the text cursor, then click a frame to thread it, click empty space for a column-wide frame or drag one (it snaps like frame drawing); Shift-click autoflows, Alt-click stays loaded on the diff --git a/crates/tools/src/catalog.rs b/crates/tools/src/catalog.rs index be36947cf..054a2ca45 100644 --- a/crates/tools/src/catalog.rs +++ b/crates/tools/src/catalog.rs @@ -16,6 +16,9 @@ const fn t(id: &'static str, label: &'static str, shortcut: Option<&'static str> /// Tools-panel groups in order (the first tool of each group shows by default). A `None` group /// marks a separator. +/// +/// In two columns InDesign lays some groups out tool by tool (Content Collector and Content +/// Placer side by side): see [`SPLIT_IN_TWO_COLUMNS`]. pub const TOOL_GROUPS: &[&[ToolInfo]] = &[ &[t("selection", "Selection Tool", Some("V"), "tool-selection")], &[t("directSelection", "Direct Selection Tool", Some("A"), "tool-direct")], @@ -62,10 +65,8 @@ pub const TOOL_GROUPS: &[&[ToolInfo]] = &[ t("scale", "Scale Tool", Some("S"), "tool-scale"), t("shear", "Shear Tool", Some("O"), "tool-shear"), ], - &[ - t("gradientSwatch", "Gradient Swatch Tool", Some("G"), "tool-gradient"), - t("gradientFeather", "Gradient Feather Tool", Some("Shift+G"), "tool-gradient-feather"), - ], + &[t("gradientSwatch", "Gradient Swatch Tool", Some("G"), "tool-gradient")], + &[t("gradientFeather", "Gradient Feather Tool", Some("Shift+G"), "tool-gradient-feather")], &[], &[t("note", "Note Tool", None, "tool-note")], &[ @@ -77,6 +78,10 @@ pub const TOOL_GROUPS: &[&[ToolInfo]] = &[ &[t("zoom", "Zoom Tool", Some("Z"), "tool-zoom")], ]; +/// Groups (by their first tool) whose tools get a button each when the Tools panel has two +/// columns, as InDesign's does. +pub const SPLIT_IN_TWO_COLUMNS: &[&str] = &["contentCollector"]; + pub fn tool_info(id: &str) -> Option<ToolInfo> { TOOL_GROUPS.iter().flat_map(|g| g.iter()).find(|t| t.id == id).copied() } diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 8dbc262ba..5e1e199c8 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -29,7 +29,7 @@ use designcraft_geom::{Affine, BezPath, Point, Rect, Unit, Vec2}; use serde::{Deserialize, Serialize}; use serde_json::Value; -pub use catalog::{TOOL_GROUPS, ToolInfo, tool_for_shortcut, tool_info}; +pub use catalog::{SPLIT_IN_TWO_COLUMNS, TOOL_GROUPS, ToolInfo, tool_for_shortcut, tool_info}; pub use layout::CanvasLayout; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 6d6503a00..73f8844c7 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -55,6 +55,12 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Cut", ["Ausschneiden", "Couper", "Cortar", "カット", "剪切"]), ("Copy", ["Kopieren", "Copier", "Copiar", "コピー", "复制"]), ("Paste", ["Einfügen", "Coller", "Pegar", "ペースト", "粘贴"]), + ("Apply Color (,)", ["Farbe anwenden (,)", "Appliquer la couleur (,)", "Aplicar color (,)", "カラーを適用 (,)", "应用颜色 (,)"]), + ("Apply Gradient (.)", ["Verlauf anwenden (.)", "Appliquer le dégradé (.)", "Aplicar degradado (.)", "グラデーションを適用 (.)", "应用渐变 (.)"]), + ("View Options", ["Ansichtsoptionen", "Options d’affichage", "Opciones de visualización", "表示オプション", "视图选项"]), + ("Frame Edges", ["Rahmenkanten", "Bords du bloc", "Bordes del marco", "フレーム枠", "框架边缘"]), + ("Rulers", ["Lineale", "Règles", "Reglas", "定規", "标尺"]), + ("Hidden Characters", ["Verborgene Zeichen", "Caractères masqués", "Caracteres ocultos", "制御文字", "隐藏字符"]), ("Reverse Path", ["Pfadrichtung umkehren", "Inverser le tracé", "Invertir trazado", "パスの方向反転", "反转路径"]), ("Set Anchor", ["Ankerpunkt festlegen", "Définir le point d’ancrage", "Definir punto de ancla", "アンカーポイントを設定", "设置锚点"]), ("Clear", ["Löschen", "Effacer", "Borrar", "消去", "清除"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 769511968..dc5648ad5 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Apply Color (,)", "تطبيق اللون (,)"), + ("Apply Gradient (.)", "تطبيق التدرج (.)"), + ("View Options", "خيارات العرض"), + ("Frame Edges", "حواف الإطار"), + ("Hidden Characters", "الأحرف المخفية"), ("Reverse Path", "عكس المسار"), ("Set Anchor", "تعيين نقطة الارتساء"), ("Clear", "مسح"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 747511ae8..c01935da5 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Apply Color (,)", "Applica colore (,)"), + ("Apply Gradient (.)", "Applica sfumatura (.)"), + ("View Options", "Opzioni di visualizzazione"), + ("Frame Edges", "Bordi cornice"), + ("Hidden Characters", "Caratteri nascosti"), ("Reverse Path", "Inverti tracciato"), ("Set Anchor", "Imposta ancoraggio"), ("Clear", "Cancella"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 263d4226a..9a07eee07 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -30,6 +30,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "カット"), ("Copy", "コピー"), ("Paste", "ペースト"), + ("Apply Color (,)", "カラーを適用 (,)"), + ("Apply Gradient (.)", "グラデーションを適用 (.)"), + ("View Options", "表示オプション"), + ("Frame Edges", "フレーム枠"), + ("Hidden Characters", "制御文字"), ("Reverse Path", "パスの方向反転"), ("Set Anchor", "アンカーポイントを設定"), ("Clear", "消去"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 3f955f68f..c5a57befd 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Apply Color (,)", "Aplicar cor (,)"), + ("Apply Gradient (.)", "Aplicar degradê (.)"), + ("View Options", "Opções de visualização"), + ("Frame Edges", "Bordas do quadro"), + ("Hidden Characters", "Caracteres ocultos"), ("Reverse Path", "Inverter demarcador"), ("Set Anchor", "Definir ponto de ancoragem"), ("Clear", "Limpar"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index efccfc166..369d30bf7 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -31,6 +31,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Apply Color (,)", "Застосувати колір (,)"), + ("Apply Gradient (.)", "Застосувати градієнт (.)"), + ("View Options", "Параметри перегляду"), + ("Frame Edges", "Краї фреймів"), + ("Hidden Characters", "Приховані символи"), ("Reverse Path", "Розвернути контур"), ("Set Anchor", "Задати прив’язку"), ("Clear", "Очистити"), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index c93464374..f9e96526e 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -454,6 +454,19 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) { pen.line(&[(5.0, 10.0), (4.0, 10.0), (4.0, 17.0), (16.0, 17.0), (16.0, 10.0), (15.0, 10.0)]); } "screen-normal" => pen.rect(3.0, 4.0, 17.0, 16.0), + // View Options: a frame's dashed edges with a ruler's ticks above. + "view-options" => { + pen.w = 1.1; + for (x0, x1) in [(4.0, 6.5), (8.5, 11.5), (13.5, 16.0)] { + pen.line(&[(x0, 9.0), (x1, 9.0)]); + pen.line(&[(x0, 17.0), (x1, 17.0)]); + } + pen.line(&[(4.0, 9.0), (4.0, 17.0)]); + pen.line(&[(16.0, 9.0), (16.0, 17.0)]); + for x in [5.0, 8.0, 11.0, 14.0] { + pen.line(&[(x, 4.0), (x, 6.0)]); + } + } "screen-preview" => { pen.rect(3.0, 4.0, 17.0, 16.0); pen.frect(6.0, 7.0, 14.0, 13.0); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1db2f591d..f5ccb7d36 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -294,6 +294,12 @@ pub struct UiState { pub palette: Option<String>, #[serde(skip)] pub flyout: Option<usize>, + /// The colour and gradient swatches applied last: what Apply Color (,) and Apply Gradient (.) + /// apply again. + #[serde(skip)] + pub last_color: Option<String>, + #[serde(skip)] + pub last_gradient: Option<String>, /// Fill/Stroke proxy (Tools and Color panels): the stroke is in front, so Apply None and the /// Color panel act on it (X toggles). #[serde(skip)] @@ -374,6 +380,8 @@ impl Default for UiState { dialog: None, palette: None, flyout: None, + last_color: None, + last_gradient: None, stroke_front: false, } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index dc8b88bf2..ad37fad6b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -106,6 +106,13 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ), ("color.toggleFillStroke", "Toggle Fill and Stroke", Some("X"), "{} → {strokeFront} — which of fill and stroke the proxy has in front"), ("color.applyNone", "Apply None", Some("/"), "{} — [None] for the fill or stroke in front of the proxy"), + ("color.applyColor", "Apply Color", Some(","), "{} — the colour applied last ([Black] at first) to the fill or stroke in front of the proxy"), + ( + "color.applyGradient", + "Apply Gradient", + Some("."), + "{} — the gradient applied last (else the document's first, else white to black) to the fill or stroke in front of the proxy", + ), ("view.frameEdges", "Show/Hide Frame Edges", Some("Cmd+H"), "{}"), ("view.rulers", "Show/Hide Rulers", Some("Cmd+R"), "{}"), ("view.guides", "Show/Hide Guides", Some("Cmd+;"), "{}"), @@ -1151,6 +1158,8 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value, "color.defaultFillStroke" => crate::toolbar::default_fill_stroke(app), "color.swapFillStroke" => crate::toolbar::swap_fill_stroke(app), "color.toggleFillStroke" => crate::toolbar::toggle_fill_stroke(app), + "color.applyColor" => crate::toolbar::apply_color(app), + "color.applyGradient" => crate::toolbar::apply_gradient(app), "color.applyNone" => crate::toolbar::apply_none(app), "view.frameEdges" => flag(&mut app.ui.frame_edges), "view.overprintPreview" => { diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index baf9a1251..fbfca247a 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -69,6 +69,7 @@ pub fn color_target(app: &mut DesignApp) -> Option<(String, f32, String, f32)> { /// Apply a swatch (and tint) to the fill or stroke of what colour edits apply to. pub fn apply_swatch(app: &mut DesignApp, stroke: bool, swatch: &str, tint: Option<f32>) -> Result<Value, String> { + remember_swatch(app, swatch); if colors_affect_text(app) { let (k, kt) = if stroke { ("stroke", "strokeTint") } else { ("fill", "fillTint") }; let mut attrs = json!({k: swatch}); @@ -84,6 +85,19 @@ pub fn apply_swatch(app: &mut DesignApp, stroke: bool, swatch: &str, tint: Optio app.run(if stroke { "object.stroke" } else { "object.fill" }, p) } +/// Keep `swatch` as the colour or gradient Apply Color / Apply Gradient use next. +pub fn remember_swatch(app: &mut DesignApp, swatch: &str) { + let Some(st) = app.session.active() else { return }; + if swatch == designcraft_color::swatch::NONE { + return; + } + if designcraft_color::swatch::resolve_gradient(&st.doc.swatches, swatch).is_some() { + app.ui.last_gradient = Some(swatch.to_string()); + } else if st.doc.resolve_color(swatch, 1.0).is_some() { + app.ui.last_color = Some(swatch.to_string()); + } +} + /// Fill (or stroke) tint of the first selected item (1 = 100%). pub fn sel_tint(app: &DesignApp, stroke: bool) -> f32 { let Some(st) = app.session.active() else { return 1.0 }; diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 6e7d98487..7e6ca1cd2 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -1,7 +1,7 @@ //! The Tools panel (left): single or double column, tool groups with flyouts, fill/stroke proxy, //! screen mode buttons. -use designcraft_tools::TOOL_GROUPS; +use designcraft_tools::{SPLIT_IN_TWO_COLUMNS, TOOL_GROUPS}; use egui::{Color32, Sense, Stroke, StrokeKind, vec2}; use serde_json::{Value, json}; @@ -45,13 +45,17 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { ui.add_space(2.0); let current = app.session.tool_id(); let mut chosen: Option<&'static str> = None; - let mut row_items = Vec::new(); + // A slot is a group (its current tool shown, the others in its flyout) or, in two + // columns, one tool of a group InDesign lays out tool by tool. + let mut row_items: Vec<Option<(usize, Option<usize>)>> = Vec::new(); for (gi, g) in TOOL_GROUPS.iter().enumerate() { if g.is_empty() { row_items.push(None); - continue; + } else if cols == 2 && SPLIT_IN_TWO_COLUMNS.contains(&g[0].id) { + row_items.extend((0..g.len()).map(|k| Some((gi, Some(k))))); + } else { + row_items.push(Some((gi, None))); } - row_items.push(Some(gi)); } let mut i = 0; while i < row_items.len() { @@ -70,8 +74,12 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { ui.add_space(8.0); ui.spacing_mut().item_spacing.x = 4.0; for _ in 0..cols { - let Some(Some(gi)) = row_items.get(i).copied() else { break }; - let g = TOOL_GROUPS[gi]; + let Some(Some((gi, pinned))) = row_items.get(i).copied() else { break }; + let group = TOOL_GROUPS[gi]; + let g = match pinned { + Some(k) => &group[k..=k], + None => group, + }; let shown = g.iter().find(|tl| tl.id == current).unwrap_or(&g[0]); let active = g.iter().any(|tl| tl.id == current); let tip = match shown.shortcut { @@ -173,7 +181,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { app.ui.flyout = None; } ui.add_space(8.0); - fill_stroke_proxy(app, ui, width); + fill_stroke_proxy(app, ui, width, cols); }); if cols == 1 { let h = scroll.content_size.y; @@ -192,7 +200,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { /// The area under the tools (InDesign 2026 §3 group 5): mini Default Fill/Stroke and Swap, the /// overlapping Fill/Stroke proxy, Formatting affects container / text, the Apply None well, a /// utility button and the Screen Mode well. -fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) { +fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32, cols: usize) { let t = Tokens::get(ui.ctx()); let target = crate::panels::color_target(app); let doc = app.session.active().map(|d| d.doc.clone()); @@ -320,42 +328,147 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) { } } ui.add_space(5.0); - // Apply None well (flyout: Apply Color / Gradient / None). + // Apply Color / Apply Gradient / Apply None: three buttons in two columns, as InDesign lays + // them out; one well with the kind in front (and a flyout of the three) in one column. + let front_sw = if stroke_front { &stroke_sw } else { &fill_sw }; + let front_kind = match doc.as_ref() { + Some(_) if front_sw == designcraft_color::swatch::NONE => 2, + Some(d) if designcraft_color::swatch::resolve_gradient(&d.swatches, front_sw).is_some() => 1, + _ => 0, + }; + let applies: [(&str, &str); 3] = + [("Apply Color (,)", "color.applyColor"), ("Apply Gradient (.)", "color.applyGradient"), ("Apply None (/)", "color.applyNone")]; + // Apply Color shows the colour it applies. + let last_color = app.ui.last_color.as_deref().and_then(|sw| doc.as_ref().and_then(|d| crate::widgets::swatch_colors(d, sw, 1.0).0)); + let paint_kind = |p: &egui::Painter, r: egui::Rect, k: usize| { + let chip = egui::Rect::from_center_size(r.center(), vec2(13.0, 13.0)); + match k { + 0 => { + p.rect_filled(chip, 0.0, last_color.unwrap_or(Color32::BLACK)); + } + 1 => crate::widgets::paint_chip(p, chip, None, Some((Color32::WHITE, Color32::BLACK))), + _ => crate::widgets::paint_chip(p, chip, None, None), + } + p.rect_stroke(chip, 0.0, Stroke::new(0.5, t.border), StrokeKind::Outside); + }; let (row, _) = ui.allocate_exact_size(vec2(width, 21.0), Sense::hover()); - let well = egui::Rect::from_center_size(egui::pos2(row.min.x + width / 2.0, row.center().y), vec2(28.0, 21.0)); - let resp = ui.interact(well, ui.id().with("proxy_apply"), Sense::click()).on_hover_ui(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Apply None (/)")); - }); - ui.painter().rect(well, 2.0, t.well, Stroke::new(1.0, t.well_rim), StrokeKind::Outside); - let chip = egui::Rect::from_center_size(well.center(), vec2(14.0, 14.0)); - crate::widgets::paint_chip(ui.painter(), chip, None, None); - flyout_triangle(ui.painter(), well, t.icon); - if resp.clicked() { - let _ = app.run("color.applyNone", json!({})); - } - egui::Popup::context_menu(&resp).show(|ui| { - for (label, sw) in [("Apply Color", "[Black]"), ("Apply None", "[None]")] { - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() { - let _ = crate::panels::apply_swatch(app, stroke_front, sw, None); - ui.close(); + if cols == 2 { + let pitch = 19.0; + for (k, (tip, id)) in applies.into_iter().enumerate() { + let c = egui::pos2(row.min.x + width / 2.0 + (k as f32 - 1.0) * pitch, row.center().y); + let r = egui::Rect::from_center_size(c, vec2(18.0, 18.0)); + let resp = ui.interact(r, ui.id().with(("proxy_apply", k)), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, crate::i18n::tr(&app.ui.language, tip))); + if k == front_kind { + ui.painter().rect(r, 2.0, t.well, Stroke::new(1.0, t.well_rim), StrokeKind::Outside); + } + paint_kind(ui.painter(), r, k); + if resp + .on_hover_ui(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, tip)); + }) + .clicked() + { + let _ = app.run(id, json!({})); } } - }); + } else { + let well = egui::Rect::from_center_size(egui::pos2(row.min.x + width / 2.0, row.center().y), vec2(28.0, 21.0)); + let (tip, id) = applies[front_kind]; + let resp = ui.interact(well, ui.id().with("proxy_apply"), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, crate::i18n::tr(&app.ui.language, tip))); + let resp = resp.on_hover_ui(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, tip)); + }); + ui.painter().rect(well, 2.0, t.well, Stroke::new(1.0, t.well_rim), StrokeKind::Outside); + paint_kind(ui.painter(), well, front_kind); + flyout_triangle(ui.painter(), well, t.icon); + if resp.clicked() { + let _ = app.run(id, json!({})); + } + egui::Popup::context_menu(&resp).show(|ui| { + for (tip, id) in applies { + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, tip))).clicked() { + let _ = app.run(id, json!({})); + ui.close(); + } + } + }); + } ui.add_space(6.0); separator(ui, &t); - // Utility (frame options). + // View Options (InDesign's: Frame Edges, Rulers, Guides, Smart Guides, Baseline Grid, Hidden + // Characters, each checked when shown). ui.vertical_centered(|ui| { - if tool_button(ui, "frame-options", false, crate::i18n::tr(&app.ui.language, "Text Frame Options")).clicked() { - app.ui.dialog = Some(crate::dialogs::Dialog::new("textFrameOptions", json!({}))); - } + let resp = tool_button(ui, "view-options", false, crate::i18n::tr(&app.ui.language, "View Options")); + flyout_triangle(ui.painter(), resp.rect.shrink2(vec2(2.0, 2.0)), t.icon); + let open = resp.clicked(); + let popup = egui::Popup::menu(&resp).open_memory(open.then_some(egui::SetOpenCommand::Toggle)); + popup.show(|ui| { + for (k, id) in + ["view.frameEdges", "view.rulers", "-", "view.guides", "view.smartGuides", "view.baselineGrid", "-", "view.hiddenCharacters"] + .into_iter() + .enumerate() + { + if id == "-" { + ui.separator(); + continue; + } + let on = crate::menus::checked(app, id, &Value::Null).unwrap_or(false); + let label = VIEW_OPTIONS[k]; + let sc = crate::menus::ui_label(id).and_then(|c| c.1).unwrap_or(""); + let text = format!("{} {}", if on { "✓" } else { " " }, crate::i18n::tr(&app.ui.language, label)); + if ui.add(egui::Button::new(crate::rtl::widget(ui, &text)).shortcut_text(sc)).clicked() { + let _ = app.run(id, json!({})); + ui.close(); + } + } + }); }); separator(ui, &t); - // Screen Mode well with flyout. + // Screen Mode: Normal and Preview (with Bleed, Slug and Presentation) side by side in two + // columns, as InDesign's; one well with the five in its flyout in one column. let (row, _) = ui.allocate_exact_size(vec2(width, 22.0), Sense::hover()); + let mode = app.ui.screen_mode; + let modes = [("Preview", "preview"), ("Bleed", "bleed"), ("Slug", "slug"), ("Presentation", "presentation")]; + if cols == 2 { + let normal = egui::Rect::from_center_size(egui::pos2(row.min.x + width / 2.0 - 15.0, row.center().y), vec2(28.0, 21.0)); + let other = egui::Rect::from_center_size(egui::pos2(row.min.x + width / 2.0 + 15.0, row.center().y), vec2(28.0, 21.0)); + let is_normal = mode == crate::ScreenMode::Normal; + for (r, on, icon) in [(normal, is_normal, "screen-normal"), (other, !is_normal, "screen-preview")] { + if on { + ui.painter().rect(r, 2.0, t.well, Stroke::new(1.0, t.well_rim), StrokeKind::Outside); + } + icons::paint(ui.painter(), egui::Rect::from_center_size(r.center(), vec2(15.0, 15.0)), icon, if on { t.text_strong } else { t.icon }); + } + flyout_triangle(ui.painter(), other, t.icon); + let tr = |s: &str| crate::i18n::tr(&app.ui.language, s).to_string(); + let (normal_tip, other_tip) = (tr("Normal"), format!("{} (W)", tr("Preview"))); + let resp = ui.interact(normal, ui.id().with("screen_normal"), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &normal_tip)); + if resp.on_hover_text(&normal_tip).clicked() { + let _ = app.run("view.screenMode", json!({"mode": "normal"})); + } + let resp = ui.interact(other, ui.id().with("screen_mode"), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &other_tip)); + let resp = resp.on_hover_text(&other_tip); + if resp.clicked() { + let _ = app.run("view.screenMode", json!({"mode": "preview"})); + } + egui::Popup::context_menu(&resp).show(|ui| { + for (label, m) in modes { + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() { + let _ = app.run("view.screenMode", json!({"mode": m})); + ui.close(); + } + } + }); + return; + } let well = egui::Rect::from_center_size(egui::pos2(row.min.x + width / 2.0, row.center().y), vec2(28.0, 21.0)); let resp = ui.interact(well, ui.id().with("screen_mode"), Sense::click()); ui.painter().rect(well, 2.0, t.well, Stroke::new(1.0, t.well_rim), StrokeKind::Outside); - let preview = app.ui.screen_mode == crate::ScreenMode::Preview; + let preview = mode == crate::ScreenMode::Preview; icons::paint( ui.painter(), egui::Rect::from_center_size(well.center(), vec2(15.0, 15.0)), @@ -370,7 +483,7 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) { let _ = app.run("view.togglePreview", json!({})); } egui::Popup::context_menu(&resp).show(|ui| { - for (label, mode) in [("Normal", "normal"), ("Preview", "preview"), ("Bleed", "bleed"), ("Slug", "slug"), ("Presentation", "presentation")] { + for (label, mode) in std::iter::once(("Normal", "normal")).chain(modes) { if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() { let _ = app.run("view.screenMode", json!({"mode": mode})); ui.close(); @@ -379,6 +492,9 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) { }); } +/// The View Options flyout's labels, by position (separators keep theirs empty). +const VIEW_OPTIONS: [&str; 8] = ["Frame Edges", "Rulers", "", "Guides", "Smart Guides", "Baseline Grid", "", "Hidden Characters"]; + /// The Object › Transform command a double-clicked tool opens (the Selection tool opens Move). fn transform_dialog_of(tool: &str) -> Option<&'static str> { match tool { @@ -444,6 +560,44 @@ pub(crate) fn toggle_fill_stroke(app: &mut DesignApp) -> Result<Value, String> { Ok(json!({"strokeFront": app.ui.stroke_front})) } +/// Apply Color (,): the colour applied last ([Black] at first) to the fill or stroke in front. +pub(crate) fn apply_color(app: &mut DesignApp) -> Result<Value, String> { + let sw = app.ui.last_color.clone().filter(|s| app.session.active().is_some_and(|d| d.doc.swatch(s).is_some())); + crate::panels::apply_swatch(app, app.ui.stroke_front, sw.as_deref().unwrap_or("[Black]"), None) +} + +/// Apply Gradient (.): the gradient applied last, else the document's first gradient swatch, else +/// a new white-to-black gradient (InDesign's default), on the fill or stroke in front. +pub(crate) fn apply_gradient(app: &mut DesignApp) -> Result<Value, String> { + let st = app.session.active().ok_or("no document")?; + let known = app + .ui + .last_gradient + .clone() + .filter(|s| designcraft_color::swatch::resolve_gradient(&st.doc.swatches, s).is_some()) + .or_else(|| st.doc.swatches.iter().find(|w| matches!(w.value, designcraft_color::SwatchValue::Gradient { .. })).map(|w| w.name.clone())); + let stroke = app.ui.stroke_front; + if let Some(sw) = known { + return crate::panels::apply_swatch(app, stroke, &sw, None); + } + if crate::panels::colors_affect_text(app) { + return Err("no gradient to apply yet: make one in the Gradient panel".into()); + } + let fill = crate::panels::color_target(app).map(|t| t.0); + let stops = json!([{"location": 0, "color": "#ffffff"}, {"location": 100, "color": "#000000"}]); + let r = app.run("object.gradient", json!({"stops": stops}))?; + let sw = r["swatch"].as_str().unwrap_or_default().to_string(); + app.ui.last_gradient = Some(sw.clone()); + if stroke { + // The gradient was made on the fill: it goes to the stroke, the fill gets its swatch back. + crate::panels::apply_swatch(app, true, &sw, None)?; + if let Some(f) = fill { + app.run("object.fill", json!({"swatch": f}))?; + } + } + Ok(r) +} + /// Apply None (/): to the fill or the stroke, whichever is in front of the proxy. pub(crate) fn apply_none(app: &mut DesignApp) -> Result<Value, String> { crate::panels::apply_swatch(app, app.ui.stroke_front, "[None]", None) @@ -550,6 +704,69 @@ mod tests { wheel(&mut h, panel.center(), vec2(0.0, -2000.0)); assert_eq!((rect(&h, LAST_TOOL), rect(&h, FILL)), before); } + #[test] + fn the_tools_panel_lays_out_like_indesigns() { + // Chris Nielsen's "The Adobe InDesign Toolbox" (InDesign 2020): Gradient Swatch and Gradient + // Feather are tools of their own; in two columns Content Collector and Placer, Apply + // Color / Gradient / None and Normal / Preview each get a button. + for double in [false, true] { + let h = test_window::open(app(double), vec2(1400.0, 900.0)); + let _ = rect(&h, "Gradient Swatch Tool (G)"); + let _ = rect(&h, "Gradient Feather Tool (Shift+G)"); + let _ = rect(&h, "View Options"); + let split = ["Content Placer Tool (B)", "Apply Color (,)", "Apply Gradient (.)", "Normal"]; + for label in split { + assert_eq!(h.query_by_label(label).is_some(), double, "double column {double}: {label}"); + } + let _ = rect(&h, "Content Collector Tool (B)"); + // One column: the Apply well names what is in front (no fill: Apply None). + let _ = rect(&h, "Apply None (/)"); + if double { + let (a, b) = (rect(&h, "Content Collector Tool (B)"), rect(&h, "Content Placer Tool (B)")); + assert!((a.center().y - b.center().y).abs() < 1.0 && a.center().x < b.center().x, "side by side: {a:?} {b:?}"); + let (n, p) = (rect(&h, "Normal"), rect(&h, "Preview (W)")); + assert!((n.center().y - p.center().y).abs() < 1.0, "Normal and Preview side by side"); + } + } + } + + #[test] + fn apply_color_and_gradient_reuse_the_last_ones() { + use serde_json::json; + let mut app = app(true); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 200, 200], "content": "unassigned"})).unwrap()["id"].clone(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + let fill = |app: &crate::DesignApp| { + let st = app.session.active().unwrap(); + let it = st.doc.item(st.selection.items[0]).unwrap(); + (it.fill.swatch.clone(), it.stroke.swatch.clone()) + }; + // Apply Color: [Black] at first, then the colour last applied. + app.run("color.applyColor", json!({})).unwrap(); + assert_eq!(fill(&app).0, "[Black]"); + crate::panels::apply_swatch(&mut app, false, "C=100 M=0 Y=0 K=0", None).unwrap(); + app.run("color.applyNone", json!({})).unwrap(); + assert_eq!(fill(&app).0, "[None]"); + app.run("color.applyColor", json!({})).unwrap(); + assert_eq!(fill(&app).0, "C=100 M=0 Y=0 K=0"); + // Apply Gradient: white to black when there's none yet, then that one again. + app.run("color.applyGradient", json!({})).unwrap(); + let g = fill(&app).0; + let doc = &app.session.active().unwrap().doc; + let grad = designcraft_color::swatch::resolve_gradient(&doc.swatches, &g).expect("a gradient"); + assert_eq!((grad.stops[0].color.to_rgb(), grad.stops[1].color.to_rgb()), ([1.0; 3], [0.0; 3])); + app.run("color.applyColor", json!({})).unwrap(); + // With the stroke in front, it goes to the stroke; the fill keeps its colour. + app.ui.stroke_front = true; + app.run("color.applyGradient", json!({})).unwrap(); + assert_eq!(fill(&app), ("C=100 M=0 Y=0 K=0".to_string(), g)); + // The shortcuts: , and . + for (id, sc) in [("color.applyColor", ","), ("color.applyGradient", ".")] { + assert_eq!(crate::menus::ui_label(id).and_then(|c| c.1), Some(sc)); + } + } + /// The Fill/Stroke proxy's keys run on the selection and stay out of text being typed. #[test] fn fill_stroke_proxy_keys() { From a65a83b765ba9db4ed2919c64d89d45621f82f95 Mon Sep 17 00:00:00 2001 From: tydude001 <tylergrose321@gmail.com> Date: Wed, 14 Oct 2026 11:36:19 -0500 Subject: [PATCH 210/238] packaging: RPM requires library sonames, so openSUSE can install it DesignCraft's RPM requires Fedora package names: `libxkbcommon`, `(vulkan-loader or libglvnd-egl)`. openSUSE has no `libxkbcommon`, `vulkan-loader` or `libglvnd-egl` package, so zypper refuses it with `nothing provides 'libxkbcommon'` (exit 4, which the ArtCraft Launcher shows as "error 4"; storytold/craft-launcher#341). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- packaging/linux/nfpm.yaml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packaging/linux/nfpm.yaml b/packaging/linux/nfpm.yaml index e2ce72b10..7e146d1a0 100644 --- a/packaging/linux/nfpm.yaml +++ b/packaging/linux/nfpm.yaml @@ -42,9 +42,16 @@ overrides: - mesa-vulkan-drivers - xdg-desktop-portal rpm: + # Library sonames rather than package names, so the same requirements resolve on Fedora/RHEL + # and openSUSE (which has no libxkbcommon, vulkan-loader or libglvnd-egl package). The glibc + # floor names libm because the binaries' GLIBC_2.35 symbols (hypot, hypotf) are libm's: RHEL 9's + # glibc 2.34 provides libc.so.6(GLIBC_2.35) through backports, but not this, so it refuses. depends: - - libxkbcommon - - (vulkan-loader or libglvnd-egl) + - libc.so.6()(64bit) + - libm.so.6(GLIBC_2.35)(64bit) + - libgcc_s.so.1()(64bit) + - libxkbcommon.so.0()(64bit) + - (libvulkan.so.1()(64bit) or libEGL.so.1()(64bit)) recommends: - libwayland-client - libX11 From 20d30e226605b7bd515127edb81b37653e4f8f7a Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <marcoarnulfo1@gmail.com> Date: Wed, 14 Oct 2026 18:46:57 +0200 Subject: [PATCH 211/238] Images: indexed-colour TIFFs decode through their palette (fixes #927) --- crates/images/src/lib.rs | 152 +++++++++++++++++++++++++++++++++++++ docs/file-format-parity.md | 2 +- 2 files changed, 153 insertions(+), 1 deletion(-) diff --git a/crates/images/src/lib.rs b/crates/images/src/lib.rs index 7ce48c0a3..d59f4741c 100644 --- a/crates/images/src/lib.rs +++ b/crates/images/src/lib.rs @@ -150,6 +150,9 @@ pub fn natural_size(bytes: &[u8]) -> Option<(f64, f64)> { /// Pixel dimensions of a raster. pub fn pixel_size(bytes: &[u8]) -> Option<(u32, u32)> { + if let Some(p) = palette_tiff(bytes) { + return Some((p.width, p.height)); + } if is_psd(bytes) && bytes.len() >= 26 { // Header: signature, version, 6 reserved, channels, height, width (big-endian). let be = |i: usize| u32::from_be_bytes([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]]); @@ -163,6 +166,10 @@ pub fn decode_rgba(bytes: &[u8]) -> Option<image::RgbaImage> { if is_psd(bytes) { return psd::decode(bytes); } + // `image` rejects indexed-colour TIFFs (an old GIF logo saved from Photoshop). + if let Some(img) = decode_palette_tiff(bytes) { + return Some(img); + } // An EPS is drawn as its proxy (preview, simple PostScript or placeholder); the document // keeps the EPS itself. if is_eps(bytes) { @@ -228,6 +235,98 @@ fn tiff_decoder(bytes: &[u8]) -> Option<tiff::decoder::Decoder<std::io::Cursor<& tiff::decoder::Decoder::new(std::io::Cursor::new(bytes)).ok() } +/// An indexed-colour TIFF (PhotometricInterpretation 3): its size, bits per index and ColorMap +/// in 8 bits per channel. +struct PaletteTiff { + width: u32, + height: u32, + bits: u8, + colours: Vec<[u8; 3]>, +} + +/// The header of a palette TIFF with 1-, 2-, 4- or 8-bit indices and a full ColorMap, which +/// neither `image` nor `tiff` decodes. `None` for anything else. +fn palette_tiff(bytes: &[u8]) -> Option<PaletteTiff> { + use tiff::tags::Tag; + let mut d = tiff_decoder(bytes)?; + if d.find_tag_unsigned::<u16>(Tag::PhotometricInterpretation).ok()?? != 3 { + return None; + } + if d.find_tag_unsigned::<u16>(Tag::SamplesPerPixel).ok()?.unwrap_or(1) != 1 { + return None; + } + let bits = d.find_tag_unsigned::<u8>(Tag::BitsPerSample).ok()?.unwrap_or(1); + if !matches!(bits, 1 | 2 | 4 | 8) { + return None; + } + // 2^bits reds, then greens, then blues; 16-bit entries whose high byte is the 8-bit value. + let map = d.find_tag_unsigned_vec::<u16>(Tag::ColorMap).ok()??; + let n = 1usize << bits; + let (r, gb) = map.split_at_checked(n)?; + let (g, b) = gb.split_at_checked(n)?; + if b.len() != n { + return None; + } + let colours = r.iter().zip(g).zip(b).map(|((&r, &g), &b)| [r, g, b].map(|v| (v >> 8) as u8)).collect(); + let (width, height) = d.dimensions().ok()?; + Some(PaletteTiff { width, height, bits, colours }) +} + +/// A palette TIFF as RGBA, each index looked up in the ColorMap. +fn decode_palette_tiff(bytes: &[u8]) -> Option<image::RgbaImage> { + let p = palette_tiff(bytes)?; + // Read as gray, `tiff` hands back the indices themselves, whatever the compression or layout. + let gray = with_photometric(bytes, 1)?; + let tiff::decoder::DecodingResult::U8(px) = tiff_decoder(&gray)?.read_image().ok()? else { return None }; + let (w, h) = (usize::try_from(p.width).ok()?, usize::try_from(p.height).ok()?); + let bits = usize::from(p.bits); + // Indices below 8 bits are packed high bits first, each row starting on a byte. + let row = w.checked_mul(bits)?.div_ceil(8); + if row == 0 || px.len() < row.checked_mul(h)? { + return None; + } + let per_byte = 8 / bits; + let mask = ((1u16 << bits) - 1) as u8; + let mut out = Vec::new(); + out.try_reserve_exact(w.checked_mul(h)?.checked_mul(4)?).ok()?; + for line in px.chunks_exact(row).take(h) { + for x in 0..w { + let shift = 8 - bits * (x % per_byte + 1); + let i = (line.get(x / per_byte)? >> shift) & mask; + let [r, g, b] = *p.colours.get(usize::from(i))?; + out.extend([r, g, b, 255]); + } + } + image::RgbaImage::from_raw(p.width, p.height, out) +} + +/// A copy of a TIFF with its first image's PhotometricInterpretation (tag 262, one SHORT) set +/// to `value`. Classic and BigTIFF, either byte order. +fn with_photometric(bytes: &[u8], value: u16) -> Option<Vec<u8>> { + let le = bytes.starts_with(b"II"); + let int = |at: usize, len: usize| -> Option<usize> { + let b = bytes.get(at..at.checked_add(len)?)?; + let v = |acc: u64, &x: &u8| acc << 8 | u64::from(x); + usize::try_from(if le { b.iter().rev().fold(0, v) } else { b.iter().fold(0, v) }).ok() + }; + // Classic: 4-byte offsets and value counts, 2-byte entry count, 12-byte entries. BigTIFF: + // 8, 8, 8 and 20. + let (ifd, word, count_len, entry_len) = if int(2, 2)? == 43 { (int(8, 8)?, 8, 8, 20) } else { (int(4, 4)?, 4, 2, 12) }; + let count = int(ifd, count_len)?; + for k in 0..count { + let at = ifd.checked_add(count_len)?.checked_add(k.checked_mul(entry_len)?)?; + // Tag, type (3 = SHORT), value count, then the value itself. + if int(at, 2)? == 262 && int(at + 2, 2)? == 3 && int(at + 4, word)? == 1 { + let mut out = bytes.to_vec(); + let v = if le { value.to_le_bytes() } else { value.to_be_bytes() }; + let value_at = at + 4 + word; + out.get_mut(value_at..value_at + 2)?.copy_from_slice(&v); + return Some(out); + } + } + None +} + /// A TIFF whose pixels are CMYK (PhotometricInterpretation Separated, four inks). pub fn is_cmyk_tiff(bytes: &[u8]) -> bool { tiff_decoder(bytes).and_then(|mut d| d.colortype().ok()).is_some_and(|c| matches!(c, tiff::ColorType::CMYK(_) | tiff::ColorType::CMYKA(_))) @@ -435,6 +534,59 @@ mod tests { assert_eq!(decode_cmyk_tiff(&b[..b.len() / 2]), None, "truncated"); } + /// Indexed colour (PhotometricInterpretation 3), which the `tiff` encoder has no type for. + struct Palette<const BITS: u16>; + impl<const BITS: u16> tiff::encoder::colortype::ColorType for Palette<BITS> { + type Inner = u8; + const TIFF_VALUE: tiff::tags::PhotometricInterpretation = tiff::tags::PhotometricInterpretation::RGBPalette; + const BITS_PER_SAMPLE: &'static [u16] = &[BITS]; + const SAMPLE_FORMAT: &'static [tiff::tags::SampleFormat] = &[tiff::tags::SampleFormat::Uint]; + fn horizontal_predict(row: &[u8], result: &mut Vec<u8>) { + result.extend_from_slice(row); + } + } + + /// A palette TIFF as Photoshop and Pillow write it: 16-bit ColorMap entries (reds, then + /// greens, then blues), each 8-bit value times 256. + fn palette_tiff<const BITS: u16>(w: u32, h: u32, px: &[u8], colours: &[[u8; 3]], lzw: bool) -> Vec<u8> { + let n = 1usize << BITS; + let mut map = vec![0u16; 3 * n]; + for (i, c) in colours.iter().enumerate() { + for (ch, &v) in c.iter().enumerate() { + map[ch * n + i] = u16::from(v) << 8; + } + } + let mut b = Vec::new(); + let mut enc = tiff::encoder::TiffEncoder::new(std::io::Cursor::new(&mut b)).unwrap(); + if lzw { + enc = enc.with_compression(tiff::encoder::Compression::Lzw); + } + let mut img = enc.new_image::<Palette<BITS>>(w, h).unwrap(); + img.encoder().write_tag(tiff::tags::Tag::ColorMap, &map[..]).unwrap(); + img.write_data(px).unwrap(); + b + } + + /// Indexed-colour TIFFs (an old GIF logo saved from Photoshop) are sized and drawn through + /// their palette, uncompressed or LZW, 8- or 4-bit. + #[test] + fn palette_tiff_decodes_through_its_colormap() { + let colours = [[0, 128, 255], [255, 0, 0], [10, 20, 30]]; + for lzw in [false, true] { + let b = palette_tiff::<8>(3, 1, &[2, 0, 1], &colours, lzw); + assert_eq!(pixel_size(&b), Some((3, 1)), "lzw {lzw}"); + let img = decode_rgba(&b).expect("decodes"); + let px: Vec<_> = img.pixels().map(|p| p.0).collect(); + assert_eq!(px, [[10, 20, 30, 255], [0, 128, 255, 255], [255, 0, 0, 255]], "lzw {lzw}"); + } + // Two indices per byte, high nibble first: 2, 1 | 0, 2 (the encoder counts samples, not + // bytes, so the strip carries two spare bytes the decoder doesn't read). + let b = palette_tiff::<4>(2, 2, &[0x21, 0x02, 0, 0], &colours, false); + assert_eq!(pixel_size(&b), Some((2, 2))); + let px: Vec<_> = decode_rgba(&b).expect("decodes").pixels().map(|p| p.0).collect(); + assert_eq!(px, [[10, 20, 30, 255], [255, 0, 0, 255], [0, 128, 255, 255], [10, 20, 30, 255]]); + } + #[test] fn bmp_decodes() { let mut bmp = Vec::new(); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 06f59de94..0d5a61d54 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -55,7 +55,7 @@ score below). |---|---|---|---|---|---| | PDF / AI (PDF-compatible) | `.pdf`, `.ai` | ✓ page boxes, layers, pages | ✓ rendered with hayro, embedded as vector in PDF export, crop to page boxes | layer visibility depth; editable vectors not offered (#227) | 3% | | JPEG, PNG, GIF, BMP, WebP | | ✓ | ✓ | CMYK JPEG/TIFF keep inks in PDF (#35) | 2% | -| TIFF | `.tif` | ✓ incl. CMYK, alpha, paths | ✓ incl. CMYK | clipping paths from 8BIM resources unverified | 1% | +| TIFF | `.tif` | ✓ incl. CMYK, alpha, indexed colour, paths | ✓ incl. CMYK | clipping paths from 8BIM resources unverified | 1% | | PSD | `.psd` | ✓ layers, comps, alpha, paths | ~ composite + Object Layer Options | layer comps partial | 1% | | EPS | `.eps` | ✓ (PostScript) | ~ preview only (no interpreter) | | <1% | | SVG | `.svg` | ✓ | ✓ (usvg) | | <1% | From 23ac4ce3f15c5e4ee2b7f474c9375e0c20da6224 Mon Sep 17 00:00:00 2001 From: Brandon Thomas <echelon@gmail.com> Date: Wed, 14 Oct 2026 12:51:12 -0400 Subject: [PATCH 212/238] fix: integrate batched community PRs (#845's Find scope test uses the Page tool's double-click from #862) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/dialogs.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b40a0504a..bf096ce31 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4429,8 +4429,9 @@ mod tests { let off = app.session.layout().xf(designcraft_engine::doc::SpreadRef::Doc(0)).translation(); let x = off.x + before.doc.spreads[0].pages[0].x + 200.0; let y = off.y + 200.0; + // The Page tool opens Page Size on a double-click (a click selects the page). let events = if tool == "page" { - json!([{"kind": "up", "x": x, "y": y}]) + json!([{"kind": "doubleclick", "x": x, "y": y}]) } else { json!([{"kind": "down", "x": x, "y": y}, {"kind": "up", "x": x, "y": y}]) }; From 19bacaadd7f8e9c1fd532466a405da2a08322b8e Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze <david.machakhelidze@gmail.com> Date: Wed, 14 Oct 2026 02:41:51 +0400 Subject: [PATCH 213/238] i18n: Georgian (ka) interface language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds i18n/ka.rs (1,734 entries: every key of the shared, Arabic, Brazilian Portuguese, Japanese, Italian and Ukrainian tables, plus labels only the code and command table use), registered as ქართული in LANGUAGES and the Interface Language menu. Count captions use the category-first form Ukrainian already uses (გვერდი: 5), since Georgian nouns stay singular after a numeral. Tests: every Georgian key resolves, placeholders and ellipses are preserved, no Mtavruli, every menu title, item and submenu is translated, and switching to ka renders. Docs: localization-parity.md and ROADMAP. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- ROADMAP.md | 4 +- crates/ui-egui/src/i18n.rs | 41 +- crates/ui-egui/src/i18n/ka.rs | 1777 +++++++++++++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 28 +- docs/localization-parity.md | 8 +- 5 files changed, 1848 insertions(+), 10 deletions(-) create mode 100644 crates/ui-egui/src/i18n/ka.rs diff --git a/ROADMAP.md b/ROADMAP.md index 0dbb60372..12a7da03f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -99,7 +99,7 @@ Interface strings translated, of 1,724 measured. Detail: [docs/localization-pari | Korean | ko | none | 0% | | Vietnamese | vi | none | 0% | -Also shipped: Italian (85%), Ukrainian (83%). +Also shipped: Italian (85%), Ukrainian (83%), Georgian (100%). ## Upcoming @@ -150,6 +150,8 @@ Newest first. `text.thread`, `text.unthread`, `text.removeFromThread` with undo; locked frames and layers refuse threads. The alpha gate now waits only on the Primary Text Frame page fixes (#165). No readiness percentage change. +- **2026-10-14** · Georgian interface (`ka`, Edit ▸ Interface Language ▸ ქართული): every catalog key + translated, count captions as `noun: n`. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 04ee153c0..8c14d97c4 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -6,6 +6,7 @@ use std::{collections::HashMap, sync::OnceLock}; mod ar; mod it; mod ja; +mod ka; mod pt_br; mod uk; @@ -21,6 +22,7 @@ pub const LANGUAGES: &[(&str, &str)] = &[ ("pt-br", "Português (Brasil)"), ("it", "Italiano"), ("uk", "Українська"), + ("ka", "ქართული"), ]; /// English → [German, French, Spanish, Japanese, Simplified Chinese]. @@ -3014,6 +3016,7 @@ pub fn tr<'a>(lang: &str, s: &'a str) -> &'a str { static UKRAINIAN: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new(); static PORTUGUESE_BR: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new(); static ITALIAN: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new(); + static GEORGIAN: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new(); if lang == "ar" { return ARABIC.get_or_init(|| ar::TABLE.iter().copied().collect()).get(s).copied().unwrap_or(s); } @@ -3030,6 +3033,9 @@ pub fn tr<'a>(lang: &str, s: &'a str) -> &'a str { if lang == "it" { return ITALIAN.get_or_init(|| it::TABLE.iter().copied().collect()).get(s).copied().unwrap_or(s); } + if lang == "ka" { + return GEORGIAN.get_or_init(|| ka::TABLE.iter().copied().collect()).get(s).copied().unwrap_or(s); + } if lang == "uk" { return UKRAINIAN.get_or_init(|| uk::TABLE.iter().copied().collect()).get(s).copied().unwrap_or(s); } @@ -3050,10 +3056,11 @@ pub(crate) fn ukrainian_catalog() -> Vec<&'static str> { uk::TABLE.iter().map(|(_, text)| *text).chain(uk::CONTEXT.iter().map(|(_, _, text)| *text)).collect() } -/// Count captions in Ukrainian put the category before the value so every count uses -/// a grammatical form; other languages keep the existing value-first caption. +/// Count captions in Ukrainian and Georgian put the category before the value so every count +/// uses one grammatical form (Georgian nouns stay singular after numerals: `გვერდი: 5`); other +/// languages keep the existing value-first caption. pub fn count_label(lang: &str, noun: &str, count: usize) -> String { - if lang == "uk" { format!("{}: {count}", tr(lang, noun)) } else { format!("{count} {}", tr(lang, noun)) } + if matches!(lang, "uk" | "ka") { format!("{}: {count}", tr(lang, noun)) } else { format!("{count} {}", tr(lang, noun)) } } /// Localize reserved built-in style names only; user-defined names are document data. @@ -3099,8 +3106,11 @@ mod tests { for (key, expected) in uk::TABLE { assert_eq!(tr("uk", key), *expected, "uk: {key}"); } + for (key, expected) in ka::TABLE { + assert_eq!(tr("ka", key), *expected, "ka: {key}"); + } let unknown = String::from("A user-defined untranslated label"); - for lang in ["uk", "ar", "pt-br", "it", "zh", "ja", "de", "fr", "es", "", "unknown"] { + for lang in ["uk", "ar", "pt-br", "it", "ka", "zh", "ja", "de", "fr", "es", "", "unknown"] { assert!(std::ptr::eq(tr(lang, &unknown), unknown.as_str())); } } @@ -3185,6 +3195,29 @@ mod tests { assert_eq!(workspace_name("uk", "My workspace"), "My workspace"); } + #[test] + fn georgian_catalog_preserves_placeholders_and_counts() { + let catalog: HashMap<_, _> = ka::TABLE.iter().copied().collect(); + assert_eq!(catalog.len(), ka::TABLE.len(), "duplicate Georgian keys"); + let placeholders = |s: &str| { + s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name.to_owned())).collect::<std::collections::BTreeSet<_>>() + }; + for (key, translation) in ka::TABLE { + assert!(!translation.trim().is_empty(), "empty {key}"); + assert_eq!(placeholders(key), placeholders(translation), "placeholders: {key}"); + assert_eq!(key.ends_with('…'), translation.ends_with('…'), "ellipsis: {key}"); + // Georgian has no capitals in running text: Mtavruli never appears in the interface. + assert!(!translation.chars().any(|c| ('\u{1C90}'..='\u{1CBF}').contains(&c)), "Mtavruli: {key}"); + } + assert_eq!(tr("ka", "File"), "ფაილი"); + assert_eq!(tr("ka", "Save As…"), "შენახვა როგორც…"); + assert_eq!(tr("ka", "Unknown label"), "Unknown label"); + assert!(!is_rtl("ka")); + for n in [1, 2, 5, 21] { + assert_eq!(count_label("ka", "pages", n), format!("გვერდი: {n}")); + } + } + #[test] fn ukrainian_counts_use_category_captions_for_every_number() { for n in [0, 1, 2, 4, 5, 11, 21, 22, 25] { diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs new file mode 100644 index 000000000..10fb84d3e --- /dev/null +++ b/crates/ui-egui/src/i18n/ka.rs @@ -0,0 +1,1777 @@ +//! Original Georgian interface translations, written from the English source following the shared +//! Georgian glossary of the ArtCraft apps (commands as verbal nouns: შენახვა, გახსნა; no capitals, +//! since Georgian has no case; counts as "noun: n"). Every key of the shared, Arabic, Brazilian +//! Portuguese, Japanese, Italian and Ukrainian tables has a row here, plus a few labels that only +//! the code or the command table use. +pub(super) const TABLE: &[(&str, &str)] = &[ + ("File", "ფაილი"), + ("Edit", "რედაქტირება"), + ("Layout", "განლაგება"), + ("Type", "ტექსტი"), + ("Object", "ობიექტი"), + ("Table", "ცხრილი"), + ("View", "ხედი"), + ("Window", "ფანჯარა"), + ("Help", "დახმარება"), + ("New", "ახალი"), + ("New Document…", "ახალი დოკუმენტი…"), + ("Open…", "გახსნა…"), + ("Close", "დახურვა"), + ("Save", "შენახვა"), + ("Save As…", "შენახვა როგორც…"), + ("Save a Copy…", "ასლის შენახვა…"), + ("Revert", "წინა ვერსიაზე დაბრუნება"), + ("Place…", "განთავსება…"), + ("Export PDF…", "PDF ექსპორტი…"), + ("Print…", "დაბეჭდვა…"), + ("Document Setup…", "დოკუმენტის პარამეტრები…"), + ("Package…", "შეფუთვა…"), + ("Undo", "დაბრუნება"), + ("Redo", "ხელახლა შესრულება"), + ("Cut", "ამოჭრა"), + ("Copy", "კოპირება"), + ("Paste", "ჩასმა"), + ("Reverse Path", "ტრაექტორიის შებრუნება"), + ("Set Anchor", "საყრდენი წერტილის დაყენება"), + ("Clear", "გასუფთავება"), + ("Duplicate", "დუბლირება"), + ("Select All", "ყველაფრის მონიშვნა"), + ("Deselect All", "ყველა მონიშვნის მოხსნა"), + ("Find/Change…", "ძებნა/ჩანაცვლება…"), + ("Check Spelling…", "მართლწერის შემოწმება…"), + ("Preferences…", "პარამეტრები…"), + ("Keyboard Shortcuts…", "კლავიატურის მალსახმობები…"), + ("Interface Language", "ინტერფეისის ენა"), + ("Pages", "გვერდები"), + ("Margins and Columns…", "ველები და სვეტები…"), + ("Create Guides…", "დამხმარე ხაზების შექმნა…"), + ("Numbering & Section Options…", "დანომვრისა და სექციის პარამეტრები…"), + ("Story Direction", "ტექსტის მიმართულება"), + ("Horizontal", "ჰორიზონტალური"), + ("Vertical", "ვერტიკალური"), + ("Tate-Chu-Yoko", "ტატე-ჩუ-იოკო"), + ("Ruby…", "რუბი…"), + ("Kenten", "კენტენი"), + ("Warichu", "ვარიჩუ"), + ("Font", "შრიფტი"), + ("Size", "ზომა"), + ("Character", "სიმბოლო"), + ("Paragraph", "აბზაცი"), + ("Tabs", "ტაბულაცია"), + ("Glyphs", "გლიფები"), + ("Story", "ტექსტური ნაკადი"), + ("Character Styles", "სიმბოლოს სტილები"), + ("Paragraph Styles", "აბზაცის სტილები"), + ("Create Outlines", "კონტურებად გარდაქმნა"), + ("Show Hidden Characters", "დამალული სიმბოლოების ჩვენება"), + ("Transform", "გარდაქმნა"), + ("Arrange", "დალაგება"), + ("Group", "დაჯგუფება"), + ("Ungroup", "დაჯგუფების მოხსნა"), + ("Lock", "ჩაკეტვა"), + ("Hide", "დამალვა"), + ("Effects", "ეფექტები"), + ("Fitting", "ზომაზე მორგება"), + ("Text Frame Options…", "ტექსტური ჩარჩოს პარამეტრები…"), + ("Insert Table…", "ცხრილის ჩასმა…"), + ("Merge Cells", "უჯრების შერწყმა"), + ("Unmerge Cells", "უჯრების შერწყმის გაუქმება"), + ("Insert", "ჩასმა"), + ("Delete", "წაშლა"), + ("Zoom In", "გადიდება"), + ("Zoom Out", "დაპატარავება"), + ("Fit Page in Window", "გვერდის ფანჯარაში ჩატევა"), + ("Fit Spread in Window", "გაშლის ფანჯარაში ჩატევა"), + ("Actual Size", "რეალური ზომა"), + ("Screen Mode", "ეკრანის რეჟიმი"), + ("Grids & Guides", "ბადე და დამხმარე ხაზები"), + ("Show Rulers", "სახაზავების ჩვენება"), + ("Snap to Guides", "დამხმარე ხაზებზე მიბმა"), + ("Snap to Document Grid", "დოკუმენტის ბადეზე მიბმა"), + ("Smart Guides", "ჭკვიანი დამხმარე ხაზები"), + ("Align to Object Edges", "ობიექტების კიდეებზე სწორება"), + ("Align to Object Centers", "ობიექტების ცენტრებზე სწორება"), + ("Smart Dimensions", "ჭკვიანი ზომები"), + ("Smart Spacing", "ჭკვიანი დაშორებები"), + ("Snap to Zone", "მიბმის ზონა"), + ("Properties", "თვისებები"), + ("Layers", "შრეები"), + ("Links", "ბმულები"), + ("Swatches", "ფერის ნიმუშები"), + ("Color", "ფერი"), + ("Stroke", "კონტური"), + ("Gradient", "გრადიენტი"), + ("Align", "სწორება"), + ("Pathfinder", "ტრაექტორიების ოპერაციები"), + ("Info", "ინფორმაცია"), + ("Library", "ბიბლიოთეკა"), + ("Book", "წიგნი"), + ("Index", "ინდექსი"), + ("Hyperlinks", "ჰიპერბმულები"), + ("Bookmarks", "სანიშნეები"), + ("Articles", "სტატიები"), + ("Tags", "ტეგები"), + ("Media", "მედია"), + ("Object States", "ობიექტის მდგომარეობები"), + ("Buttons and Forms", "ღილაკები და ფორმები"), + ("Liquid Layout", "მოქნილი განლაგება"), + ("Workspace", "სამუშაო სივრცე"), + ("Split Window", "ფანჯრის გაყოფა"), + ("New Window", "ახალი ფანჯარა"), + ("Show All Menu Items", "მენიუს ყველა პუნქტის ჩვენება"), + ("Accept All", "ყველას მიღება"), + ("Add", "დამატება"), + ("Add Document…", "დოკუმენტის დამატება…"), + ("Add to Previous Spread", "წინა გაშლაზე დამატება"), + ("Align From:", "სწორება საიდან:"), + ("Align to baseline grid", "საბაზისო ხაზების ბადეზე სწორება"), + ("Alignment:", "სწორება:"), + ("All Spots to Process", "ყველა სპოტ-ფერის პროცესულად გარდაქმნა"), + ("Allow Spread to Shuffle", "გაშლის გვერდების გადანაწილების დაშვება"), + ("Alternating Row Fills", "სტრიქონების მონაცვლე შევსება"), + ("Appearance", "იერსახე"), + ("Apply Gradient", "გრადიენტის გამოყენება"), + ("Apply Parent", "საბაზო გვერდის გამოყენება"), + ("Apply Style:", "სტილის გამოყენება:"), + ("Apply to All Spreads", "ყველა გაშლაზე გამოყენება"), + ("Apply to Stroke", "კონტურზე გამოყენება"), + ("Art", "ნახატი"), + ("Based On:", "საფუძველი:"), + ("Bleed", "ჭრისპირა არე"), + ("Bleed Marks", "ჭრისპირა არის ნიშნები"), + ("Body Rows", "ძირითადი სტრიქონები"), + ("Bottom", "ქვემოთ"), + ("Break Link to Style", "სტილთან კავშირის გაწყვეტა"), + ("Bullets and Numbering", "ბურთულები და დანომვრა"), + ("Business Card", "სავიზიტო ბარათი"), + ("CMYK", "CMYK"), + ("Cancel", "გაუქმება"), + ("Can't Open the File", "ფაილის გახსნა ვერ მოხერხდა"), + ("Can't Place the File", "ფაილის განთავსება ვერ მოხერხდა"), + ("Center Content", "შიგთავსის ცენტრირება"), + ("Change All", "ყველას ჩანაცვლება"), + ("Change to:", "ჩანაცვლება:"), + ("Character Style:", "სიმბოლოს სტილი:"), + ("Checked by default", "ნაგულისხმევად მონიშნული"), + ("Choices", "ვარიანტები"), + ("Choose Image…", "გამოსახულების არჩევა…"), + ("Class:", "კლასი:"), + ("Color Picker", "ფერის ამრჩევი"), + ("Color Settings", "ფერის პარამეტრები"), + ("Color:", "ფერი:"), + ("Columns", "სვეტები"), + ("Composer:", "აწყობის რეჟიმი:"), + ("Copies:", "ასლები:"), + ("Count", "რაოდენობა"), + ("Create", "შექმნა"), + ("Create Table", "ცხრილის შექმნა"), + ("Create Table…", "ცხრილის შექმნა…"), + ("Crop", "ჩამოჭრა"), + ("Crop Marks", "ჭრის ნიშნები"), + ("Crop to:", "ჩამოჭრა:"), + ("Cursor Key:", "კურსორის კლავიშები:"), + ("Default", "ნაგულისხმევი"), + ("Default View:", "ნაგულისხმევი ხედი:"), + ("Delete Column", "სვეტის წაშლა"), + ("Delete Condition", "პირობის წაშლა"), + ("Delete Layer", "შრის წაშლა"), + ("Delete Page", "გვერდის წაშლა"), + ("Delete Row", "სტრიქონის წაშლა"), + ("Delete Swatch", "ფერის ნიმუშის წაშლა"), + ("Delete Unused Layers", "გამოუყენებელი შრეების წაშლა"), + ("Desired", "სასურველი"), + ("Dialog", "დიალოგი"), + ("Document", "დოკუმენტი"), + ("Document Setup", "დოკუმენტის პარამეტრები"), + ("Drop Shadow", "ჩრდილი"), + ("Duplicate Spread", "გაშლის დუბლირება"), + ("Email", "ელფოსტა"), + ("Embed", "ჩაშენება"), + ("Export PDF", "PDF ექსპორტი"), + ("Export XML…", "XML ექსპორტი…"), + ("Fast", "სწრაფი"), + ("Fill Frame Proportionally", "ჩარჩოს პროპორციულად შევსება"), + ("Find All", "ყველას ძებნა"), + ("Find Next", "შემდეგის ძებნა"), + ("Find what:", "რა მოიძებნოს:"), + ("Find/Change", "ძებნა/ჩანაცვლება"), + ("Find/Replace Font", "შრიფტის ძებნა/ჩანაცვლება"), + ("Fit Content Proportionally", "შიგთავსის პროპორციულად მორგება"), + ("Fit Content to Frame", "შიგთავსის ჩარჩოზე მორგება"), + ("Fitting:", "ზომაზე მორგება:"), + ("Flattener preset", "გაბრტყელების პრესეტი"), + ("Font Family:", "შრიფტის ოჯახი:"), + ("Font Size:", "შრიფტის ზომა:"), + ("Font Style:", "შრიფტის სტილი:"), + ("Footer Rows", "ქვედა კოლონტიტულის სტრიქონები"), + ("Footnote Options", "სქოლიოს პარამეტრები"), + ("Form Field", "ფორმის ველი"), + ("Format:", "ფორმატი:"), + ("Frame Fitting", "ჩარჩოს ზომაზე მორგება"), + ("Frame Fitting Options", "ჩარჩოს ზომაზე მორგების პარამეტრები"), + ("Generate QR Code", "QR კოდის შექმნა"), + ("Go To", "გადასვლა"), + ("Go to Page", "გვერდზე გადასვლა"), + ("Gutter", "შუალედი"), + ("Header Rows", "სათაურის სტრიქონები"), + ("Height", "სიმაღლე"), + ("Height:", "სიმაღლე:"), + ("High Quality", "მაღალი ხარისხი"), + ("Desktop Printing", "სამაგიდო ბეჭდვა"), + ("Horizontal Gridline Every:", "ჰორიზონტალური ბადის ხაზი ყოველ:"), + ("Horizontal Margins:", "ჰორიზონტალური ველები:"), + ("Hyphenate", "სიტყვების გადატანა"), + ("Import Options", "იმპორტის პარამეტრები"), + ("Import XML…", "XML იმპორტი…"), + ("Include document bleed", "დოკუმენტის ჭრისპირა არის ჩართვა"), + ("Increment Every:", "ბიჯი ყოველ:"), + ("Ink Manager", "საღებავების მართვა"), + ("Insert Column", "სვეტის ჩასმა"), + ("Insert Page After", "გვერდის ჩასმა შემდეგ"), + ("Insert Row", "სტრიქონის ჩასმა"), + ("Inset spacing", "შიდა დაშორება"), + ("Inside", "შიგნით"), + ("Intent:", "განზრახვა:"), + ("Keyboard Shortcuts", "კლავიატურის მალსახმობები"), + ("Kind:", "სახეობა:"), + ("Lab", "Lab"), + ("Leading:", "სტრიქონთაშორისი ინტერვალი:"), + ("Left Indent:", "მარცხენა შეწევა:"), + ("Link To:", "ბმული:"), + ("Load Profile…", "პროფილის ჩატვირთვა…"), + ("Load Swatches…", "ფერის ნიმუშების ჩატვირთვა…"), + ("Load…", "ჩატვირთვა…"), + ("Make Compound", "შედგენილის შექმნა"), + ("Margins", "ველები"), + ("Marks and Bleeds", "ნიშნები და ჭრისპირა არეები"), + ("Maximum", "მაქსიმუმი"), + ("Menu Customization", "მენიუს მორგება"), + ("Merge into Active Layer", "აქტიურ შრესთან შერწყმა"), + ("Min:", "მინ.:"), + ("Minimum", "მინიმუმი"), + ("Minimum Space Before First Footnote:", "მინიმალური დაშორება პირველ სქოლიომდე:"), + ("Misspelled word → Correction (one per line):", "შეცდომით დაწერილი სიტყვა → შესწორება (თითო სტრიქონზე):"), + ("Mobile", "მობილური"), + ("Move Selection Here", "მონიშნულის აქ გადატანა"), + ("Move to Color Group", "ფერების ჯგუფში გადატანა"), + ("Move to Group", "ჯგუფში გადატანა"), + ("Multiline", "მრავალსტრიქონიანი"), + ("Name", "სახელი"), + ("Name:", "სახელი:"), + ("New Bookmark for This Page", "ახალი სანიშნე ამ გვერდისთვის"), + ("New Book…", "ახალი წიგნი…"), + ("New Color Group", "ფერების ახალი ჯგუფი"), + ("New Color Group with Swatch", "ფერების ახალი ჯგუფი ნიმუშით"), + ("New Cross-Reference", "ახალი ჯვარედინი მიმართვა"), + ("New Document", "ახალი დოკუმენტი"), + ("New GREP Style", "ახალი GREP სტილი"), + ("New Library…", "ახალი ბიბლიოთეკა…"), + ("New Line Style", "ხაზის ახალი სტილი"), + ("New Nested Style", "ახალი ჩადგმული სტილი"), + ("New Workspace", "ახალი სამუშაო სივრცე"), + ("No spot inks", "სპოტ-საღებავები არ არის"), + ("None", "არცერთი"), + ("Number of Pages:", "გვერდების რაოდენობა:"), + ("Number of Sides:", "კუთხეების რაოდენობა:"), + ("Number of columns", "სვეტების რაოდენობა"), + ("Object Layer Options", "ობიექტის შრეების პარამეტრები"), + ("Offset:", "წანაცვლება:"), + ("On Release", "აშვებისას"), + ("On Screen:", "ეკრანზე:"), + ("OpenType", "OpenType"), + ("Orientation:", "ორიენტაცია:"), + ("Outside", "გარეთ"), + ("Page Information", "გვერდის ინფორმაცია"), + ("Page Range", "გვერდების დიაპაზონი"), + ("Page Range:", "გვერდების დიაპაზონი:"), + ("Pages:", "გვერდები:"), + ("Paragraph Composer", "აბზაცის მიხედვით აწყობა"), + ("Paragraph Style Options", "აბზაცის სტილის პარამეტრები"), + ("Paragraph Style:", "აბზაცის სტილი:"), + ("PDF/A-2b", "PDF/A-2b"), + ("PDF/X-4", "PDF/X-4"), + ("Place PDF", "PDF-ის განთავსება"), + ("Plain Text", "უბრალო ტექსტი"), + ("Polygon Settings", "მრავალკუთხედის პარამეტრები"), + ("Position", "პოზიცია"), + ("Position:", "პოზიცია:"), + ("Poster", "პოსტერი"), + ("Preferences", "პარამეტრები"), + ("Prefix:", "პრეფიქსი:"), + ("Preset", "პრესეტი"), + ("Preset:", "პრესეტი:"), + ("Commercial Printing", "კომერციული ბეჭდვა"), + ("Print", "ბეჭდვა"), + ("Printer:", "პრინტერი:"), + ("Printing / Exporting:", "ბეჭდვა / ექსპორტირება:"), + ("Quick Actions", "სწრაფი მოქმედებები"), + ("RGB", "RGB"), + ("Rectangle", "მართკუთხედი"), + ("Reject All", "ყველას უარყოფა"), + ("Relative To:", "შეფარდებით:"), + ("Release", "გათავისუფლება"), + ("Relink to Folder…", "საქაღალდეში ხელახლა დაკავშირება…"), + ("Relink…", "ხელახლა დაკავშირება…"), + ("Remove Conditions from Selection", "მონიშნულიდან პირობების მოხსნა"), + ("Repeat Header Rows", "სათაურის სტრიქონების გამეორება"), + ("Required", "სავალდებულო"), + ("Reset All to Defaults", "ყველაფრის ნაგულისხმევზე დაბრუნება"), + ("Restart Numbering Every:", "დანომვრის თავიდან დაწყება ყოველ:"), + ("Reverse", "შებრუნება"), + ("Save Swatches for Exchange…", "ფერის ნიმუშების შენახვა გასაცვლელად…"), + ("Save recovery data every:", "აღდგენის მონაცემების შენახვა ყოველ:"), + ("Search:", "ძიება:"), + ("Select an object.", "მონიშნეთ ობიექტი."), + ("Select text or a text frame.", "მონიშნეთ ტექსტი ან ტექსტური ჩარჩო."), + ("Separator:", "გამყოფი:"), + ("Show Prefix/Suffix in:", "პრეფიქსის/სუფიქსის ჩვენება:"), + ("Show Tag Markers", "ტეგის მარკერების ჩვენება"), + ("Single-line", "ერთსტრიქონიანი"), + ("Single-line Composer", "სტრიქონის მიხედვით აწყობა"), + ("Size:", "ზომა:"), + ("Screen and Email", "ეკრანი და ელფოსტა"), + ("Space Between Footnotes:", "დაშორება სქოლიოებს შორის:"), + ("Speed", "სიჩქარე"), + ("Standard", "სტანდარტული"), + ("Standard:", "სტანდარტი:"), + ("Star Inset:", "ვარსკვლავის ჩაღრმავება:"), + ("Start Page #:", "საწყისი გვერდის №:"), + ("Start at:", "დაწყება:"), + ("Start:", "დასაწყისი:"), + ("Style Name:", "სტილის სახელი:"), + ("Style:", "სტილი:"), + ("Stylistic Sets", "სტილისტური ნაკრებები"), + ("Subdivisions:", "ქვედანაყოფები:"), + ("Suffix:", "სუფიქსი:"), + ("Synchronize", "სინქრონიზაცია"), + ("Tag:", "ტეგი:"), + ("Tagged PDF", "ტეგირებული PDF"), + ("Text Frame", "ტექსტური ჩარჩო"), + ("Text Frame Options", "ტექსტური ჩარჩოს პარამეტრები"), + ("Text Message", "ტექსტური შეტყობინება"), + ("Text Style", "ტექსტის სტილი"), + ("Text Wrap", "ტექსტით გარშემოვლება"), + ("This PDF has no layers.", "ამ PDF-ს შრეები არ აქვს."), + ("To Text:", "ტექსტამდე:"), + ("Top", "ზემოთ"), + ("Track Changes in All Stories", "ცვლილებების ჩაწერა ყველა ტექსტურ ნაკადში"), + ("Trim", "საბოლოო ფორმატი"), + ("Type:", "ტიპი:"), + ("Typical", "ტიპური"), + ("UI Size:", "ინტერფეისის ზომა:"), + ("Ungroup Color Group", "ფერების ჯგუფის დაშლა"), + ("Unmerge", "შერწყმის გაუქმება"), + ("Update", "განახლება"), + ("Update Numbering", "დანომვრის განახლება"), + ("Use Global Light", "გლობალური განათების გამოყენება"), + ("User Dictionary", "მომხმარებლის ლექსიკონი"), + ("Vertical Gridline Every:", "ვერტიკალური ბადის ხაზი ყოველ:"), + ("Vertical Margins:", "ვერტიკალური ველები:"), + ("Vertical justification", "ვერტიკალური სწორება"), + ("View Threshold:", "ხილვადობის ზღვარი:"), + ("Web", "ვები"), + ("Web Hyperlink", "ვებბმული"), + ("Weight:", "სისქე:"), + ("Width", "სიგანე"), + ("Width:", "სიგანე:"), + ("Words, one per line:", "სიტყვები, თითო სტრიქონზე:"), + ("Multiple Objects", "რამდენიმე ობიექტი"), + ("Ellipse", "ელიფსი"), + ("Polygon", "მრავალკუთხედი"), + ("Characters", "სიმბოლოები"), + ("Image", "გამოსახულება"), + ("Line", "ხაზი"), + ("Path", "ტრაექტორია"), + ("No Selection", "მონიშნული არაფერია"), + ("Adjust Layout", "განლაგების მისადაგება"), + ("Edit Page", "გვერდის რედაქტირება"), + ("Import File", "ფაილის იმპორტი"), + ("Insert Page", "გვერდის ჩასმა"), + ("Hide Bar", "ზოლის დამალვა"), + ("Properties Panel", "თვისებების პანელი"), + ("New Workspace…", "ახალი სამუშაო სივრცე…"), + ("Float panel", "მცურავი პანელი"), + ("Dock panel", "პანელის მიმაგრება"), + ("Fit frame height to text", "ჩარჩოს სიმაღლის ტექსტზე მორგება"), + ("More Options", "მეტი პარამეტრი"), + ("Flip Horizontal", "ჰორიზონტალურად არეკვლა"), + ("Rotate 90° Clockwise", "90°-ით შემობრუნება საათის ისრის მიმართულებით"), + ("Flip Vertical", "ვერტიკალურად არეკვლა"), + ("Default Fill and Stroke (D)", "ნაგულისხმევი შევსება და კონტური (D)"), + ("Swap Fill and Stroke (Shift+X)", "შევსებისა და კონტურის გაცვლა (Shift+X)"), + ("Fill (X)", "შევსება (X)"), + ("Stroke (X)", "კონტური (X)"), + ("Apply None (/)", "არცერთი (/)"), + ("Default Fill and Stroke", "ნაგულისხმევი შევსება და კონტური"), + ("Swap Fill and Stroke", "შევსებისა და კონტურის გაცვლა"), + ("Toggle Fill and Stroke", "შევსებისა და კონტურის გადართვა"), + ("Reverse the gradient", "გრადიენტის შებრუნება"), + ("Style source", "სტილების წყარო"), + ("Open", "გახსნა"), + ("All Caps", "მთავრული"), + ("Underline", "ხაზგასმული"), + ("Strikethrough", "გადახაზული"), + ("Superscript", "ზედა ინდექსი"), + ("Pick a file in the folder holding the moved graphics", "აირჩიეთ ფაილი საქაღალდეში, სადაც გადატანილი გრაფიკაა"), + ("Show Favorites Only", "მხოლოდ რჩეულების ჩვენება"), + ("Select", "მონიშვნა"), + ("Hyperlink from the selected text or frames", "ჰიპერბმული მონიშნული ტექსტიდან ან ჩარჩოებიდან"), + ("Include when exporting", "ექსპორტისას ჩართვა"), + ("Add the selection", "მონიშნულის დამატება"), + ("Tag the selection", "მონიშნულისთვის ტეგის მინიჭება"), + ("Separated by commas", "მძიმით გამოყოფილი"), + ("Show in the text", "ტექსტში ჩვენება"), + ("Show / hide text with this condition", "ამ პირობის მქონე ტექსტის ჩვენება / დამალვა"), + ("Spreads of up to 10 pages", "გაშლები 10 გვერდამდე"), + ("for", "მომდევნო"), + ("About DesignCraft", "DesignCraft-ის შესახებ"), + ("Quit DesignCraft", "DesignCraft-იდან გასვლა"), + ("Import Warnings", "იმპორტის გაფრთხილებები"), + ("Accept All Changes", "ყველა ცვლილების მიღება"), + ("Activate Document", "დოკუმენტის გააქტიურება"), + ("Add Anchor Point", "საყრდენი წერტილის დამატება"), + ("Add Document", "დოკუმენტის დამატება"), + ("Add Hyphenation Exception", "გადატანის გამონაკლისის დამატება"), + ("Add Item", "ელემენტის დამატება"), + ("Add Page to Spread", "გვერდის გაშლაზე დამატება"), + ("Add to Swatches", "ფერის ნიმუშებში დამატება"), + ("Allow Selected Spread to Shuffle", "მონიშნული გაშლის გვერდების გადანაწილების დაშვება"), + ("Anchored Object Options…", "მიმაგრებული ობიექტის პარამეტრები…"), + ("Anchored Objects", "მიმაგრებული ობიექტები"), + ("Appearance of Black", "შავის იერსახე"), + ("Apply Attributes (Eyedropper)", "ატრიბუტების გამოყენება (პიპეტი)"), + ("Apply Cell Style", "უჯრის სტილის გამოყენება"), + ("Apply Color", "ფერის გამოყენება"), + ("Repeat", "გამეორება"), + ("Count:", "რაოდენობა:"), + ("Create as a grid", "ბადედ შექმნა"), + ("Rows:", "სტრიქონები:"), + ("Invalid value", "არასწორი მნიშვნელობა"), + ("Isolate Blending", "შერევის იზოლირება"), + ("Knockout Group", "ჩაკვეთილი ჯგუფი"), + ("Object:", "ობიექტი:"), + ("Group:", "ჯგუფი:"), + ("Sample Color (Eyedropper)", "ფერის აღება (პიპეტი)"), + ("Apply Condition", "პირობის გამოყენება"), + ("Apply Parent to Pages…", "საბაზო გვერდის გამოყენება გვერდებზე…"), + ("ArtCraft App Page…", "ArtCraft-ის აპლიკაციის გვერდი…"), + ("ArtCraft Website…", "ArtCraft-ის ვებსაიტი…"), + ("Attributes", "ატრიბუტები"), + ("Bevel and Emboss", "ფასკა და რელიეფი"), + ("Buttons", "ღილაკები"), + ("Cell Options", "უჯრის პარამეტრები"), + ("Cell Style Options…", "უჯრის სტილის პარამეტრები…"), + ("Change All (Object)", "ყველას ჩანაცვლება (ობიექტი)"), + ("Change Case", "რეგისტრის შეცვლა"), + ("Changes", "ცვლილებები"), + ("Character Formatting", "სიმბოლოს ფორმატირება"), + ("Clear Conveyor", "კონვეიერის გასუფთავება"), + ("Clear Transformations", "გარდაქმნების გასუფთავება"), + ("Clipping Path…", "ჩაჭრის ტრაექტორია…"), + ("Close Book", "წიგნის დახურვა"), + ("Close Library", "ბიბლიოთეკის დახურვა"), + ("Collect", "შეგროვება"), + ("Color Settings…", "ფერის პარამეტრები…"), + ("Community & Project Links", "საზოგადოება და პროექტის ბმულები"), + ("Compress images", "გამოსახულებების შეკუმშვა"), + ("Compression", "შეკუმშვა"), + ("Conditions", "პირობები"), + ("Container", "კონტეინერი"), + ("Content", "შიგთავსი"), + ("Contextual Task Bar", "კონტექსტური ამოცანების ზოლი"), + ("Control", "მართვა"), + ("Convert Cell to Graphic Cell", "უჯრის გრაფიკულ უჯრად გარდაქმნა"), + ("Convert Cell to Graphic Cell…", "უჯრის გრაფიკულ უჯრად გარდაქმნა…"), + ("Convert Cell to Text Cell", "უჯრის ტექსტურ უჯრად გარდაქმნა"), + ("Convert Color", "ფერის გარდაქმნა"), + ("Convert Direction Point", "მიმართულების წერტილის გარდაქმნა"), + ("Convert Note to Text", "შენიშვნის ტექსტად გარდაქმნა"), + ("Convert Selection to Multi-State Object", "მონიშნულის მრავალმდგომარეობიან ობიექტად გარდაქმნა"), + ("Convert Shape", "ფიგურის გარდაქმნა"), + ("Convert Table to Text", "ცხრილის ტექსტად გარდაქმნა"), + ("Convert Text to Table", "ტექსტის ცხრილად გარდაქმნა"), + ("Convert to Button", "ღილაკად გარდაქმნა"), + ("Convert to Form Field", "ფორმის ველად გარდაქმნა"), + ("Conveyor", "კონვეიერი"), + ("Copy Link(s) To…", "ბმული ფაილების კოპირება…"), + ("Corner Options…", "კუთხეების პარამეტრები…"), + ("Create Alternate Layout…", "ალტერნატიული განლაგების შექმნა…"), + ("Create Frame", "ჩარჩოს შექმნა"), + ("Create Merged Document…", "შერწყმული დოკუმენტის შექმნა…"), + ("Cross-Reference Formats", "ჯვარედინი მიმართვის ფორმატები"), + ("Cross-References", "ჯვარედინი მიმართვები"), + ("Data Merge Fields", "მონაცემთა შერწყმის ველები"), + ("Define Cross-Reference Format…", "ჯვარედინი მიმართვის ფორმატის განსაზღვრა…"), + ("Define Lists…", "სიების განსაზღვრა…"), + ("Define Text Variable…", "ტექსტური ცვლადის განსაზღვრა…"), + ("Delete All Guides on Spread", "გაშლის ყველა დამხმარე ხაზის წაშლა"), + ("Delete Anchor Point", "საყრდენი წერტილის წაშლა"), + ("Delete Item", "ელემენტის წაშლა"), + ("Delete Text Variable", "ტექსტური ცვლადის წაშლა"), + ("Delete Workspace…", "სამუშაო სივრცის წაშლა…"), + ("DesignCraft Website…", "DesignCraft-ის ვებსაიტი…"), + ("DesignCraft on GitHub…", "DesignCraft GitHub-ზე…"), + ("Detach All Objects from Parent", "ყველა ობიექტის საბაზო გვერდისგან მოწყვეტა"), + ("Directional Feather", "კიდეების მიმართული შერბილება"), + ("Distribute", "განაწილება"), + ("Dock Panel", "პანელის მიმაგრება"), + ("Document Endnote Options…", "დოკუმენტის ბოლო სქოლიოების პარამეტრები…"), + ("Document Footnote Options…", "დოკუმენტის სქოლიოების პარამეტრები…"), + ("Document Preferences", "დოკუმენტის პარამეტრები"), + ("Document Presets", "დოკუმენტის პრესეტები"), + ("Document…", "დოკუმენტი…"), + ("Dynamic Spelling", "მართლწერის შემოწმება აკრეფისას"), + ("Edit Parents", "საბაზო გვერდების რედაქტირება"), + ("Edit Story", "ტექსტური ნაკადის რედაქტირება"), + ("Edit in Story Editor", "ტექსტის რედაქტორში რედაქტირება"), + ("Endnotes", "დოკუმენტის სქოლიოები"), + ("Entire Pasteboard", "მთელი სამუშაო არე"), + ("Erase", "წაშლა"), + ("Export Book to PDF…", "წიგნის PDF ექსპორტი…"), + ("Export EPUB (Fixed Layout)…", "EPUB ექსპორტი (ფიქსირებული განლაგება)…"), + ("Export EPUB (Reflowable)…", "EPUB ექსპორტი (ადაპტური განლაგება)…"), + ("Export EPUB…", "EPUB ექსპორტი…"), + ("Export HTML…", "HTML ექსპორტი…"), + ("Export IDML…", "IDML ექსპორტი…"), + ("Export PDF (Interactive)…", "PDF ექსპორტი (ინტერაქტიური)…"), + ("Export Page as PNG…", "გვერდის PNG ფაილად ექსპორტირება…"), + ("Export Selection as Snippet", "მონიშნულის ფრაგმენტად ექსპორტი"), + ("Export Tagging", "ექსპორტის ტეგები"), + ("Export Text…", "ტექსტის ექსპორტი…"), + ("Extend Text Selection", "ტექსტის მონიშვნის გაფართოება"), + ("Fast Display", "სწრაფი ჩვენება"), + ("Find", "ძებნა"), + ("Find Object", "ობიექტის ძებნა"), + ("Find by Script Label", "სკრიპტის ჭდით ძებნა"), + ("Find/Replace Font…", "შრიფტის ძებნა/ჩანაცვლება…"), + ("First Object Above", "პირველი ობიექტი ზემოთ"), + ("Fit Selection in Window", "მონიშნულის ფანჯარაში ჩატევა"), + ("Flattener Preview", "გაბრტყელების წინასწარი ნახვა"), + ("Float Panel", "მცურავი პანელი"), + ("Fonts in Document", "დოკუმენტის შრიფტები"), + ("Footnotes", "სქოლიოები"), + ("Form Fields", "ფორმის ველები"), + ("Frame Fitting Options…", "ჩარჩოს ზომაზე მორგების პარამეტრები…"), + ("Generate Index…", "ინდექსის შექმნა…"), + ("Generate QR Code…", "QR კოდის შექმნა…"), + ("Generate Static Caption", "სტატიკური წარწერის შექმნა"), + ("Get Story", "ტექსტური ნაკადის მიღება"), + ("Get Table", "ცხრილის მიღება"), + ("Global Light…", "გლობალური განათება…"), + ("Go To Link", "ბმულზე გადასვლა"), + ("Go To Source", "წყაროზე გადასვლა"), + ("Go to Footnote Reference", "სქოლიოს მითითებაზე გადასვლა"), + ("Go to Page…", "გვერდზე გადასვლა…"), + ("Gradient Feather", "კიდეების გრადიენტული შერბილება"), + ("Gridify", "ბადედ განლაგება"), + ("Group Transparency", "ჯგუფის გამჭვირვალობა"), + ("Guides", "დამხმარე ხაზები"), + ("Hide Menu Item", "მენიუს პუნქტის დამალვა"), + ("Hide/Lock Others", "დანარჩენების დამალვა/ჩაკეტვა"), + ("High Quality Display", "მაღალი ხარისხის ჩვენება"), + ("History", "ისტორია"), + ("Hyphenation Exceptions", "გადატანის გამონაკლისები"), + ("Import Options: Styles", "იმპორტის პარამეტრები: სტილები"), + ("Index Page References", "ინდექსის გვერდის მითითებები"), + ("Inner Glow", "შიდა ნათება"), + ("Inner Shadow", "შიდა ჩრდილი"), + ("Insert Anchored Object…", "მიმაგრებული ობიექტის ჩასმა…"), + ("Insert Cross-Reference…", "ჯვარედინი მიმართვის ჩასმა…"), + ("Insert Endnote", "დოკუმენტის სქოლიოს ჩასმა"), + ("Insert Footnote", "სქოლიოს ჩასმა"), + ("Insert Math Expression…", "მათემატიკური გამოსახულების ჩასმა…"), + ("Insert Pages…", "გვერდების ჩასმა…"), + ("Insert Variable", "ცვლადის ჩასმა"), + ("Inspect Document", "დოკუმენტის შემოწმება"), + ("Interface Color Theme", "ინტერფეისის ფერთა თემა"), + ("Join the ArtCraft Discord…", "შემოუერთდით ArtCraft-ის Discord-ს…"), + ("Last Object Below", "ბოლო ობიექტი ქვემოთ"), + ("Library Contents", "ბიბლიოთეკის შიგთავსი"), + ("Library Items", "ბიბლიოთეკის ელემენტები"), + ("Linked Stories", "დაკავშირებული ტექსტური ნაკადები"), + ("Liquid Page Rule", "გვერდის მოქნილი წესი"), + ("List Documents", "დოკუმენტების სია"), + ("List Styles", "სტილების სია"), + ("List Tools", "ხელსაწყოების სია"), + ("Load Place Cursor", "განთავსების კურსორის ჩატვირთვა"), + ("Make Compound Path", "შედგენილი ტრაექტორიის შექმნა"), + ("Map Styles to Tags", "სტილების ტეგებთან შესაბამისობა"), + ("Math Expression SVG", "მათემატიკური გამოსახულება SVG"), + ("Media Options", "მედიის პარამეტრები"), + ("Menus…", "მენიუები…"), + ("Merge Layers", "შრეების შერწყმა"), + ("Move Caret", "კურსორის გადაადგილება"), + ("Move Gap", "ღრეჩოს გადაადგილება"), + ("Move Row", "სტრიქონის გადატანა"), + ("New Book", "ახალი წიგნი"), + ("New Cell Style…", "უჯრის ახალი სტილი…"), + ("New Color Swatch…", "ფერის ახალი ნიმუში…"), + ("New Condition…", "ახალი პირობა…"), + ("New Guide", "ახალი დამხმარე ხაზი"), + ("New Hyperlink Destination…", "ჰიპერბმულის ახალი დანიშნულება…"), + ("New Hyperlink…", "ახალი ჰიპერბმული…"), + ("New Library", "ახალი ბიბლიოთეკა"), + ("New Page Reference…", "გვერდის ახალი მითითება…"), + ("New Paragraph Style…", "აბზაცის ახალი სტილი…"), + ("New Stroke Style…", "კონტურის ახალი სტილი…"), + ("New Table Style…", "ცხრილის ახალი სტილი…"), + ("Next Cell", "შემდეგი უჯრა"), + ("Next Document", "შემდეგი დოკუმენტი"), + ("Next Object Above", "შემდეგი ობიექტი ზემოთ"), + ("Next Object Below", "შემდეგი ობიექტი ქვემოთ"), + ("Next Object in Group", "ჯგუფის შემდეგი ობიექტი"), + ("Notes", "შენიშვნები"), + ("Object Export Options", "ობიექტის ექსპორტის პარამეტრები"), + ("Object Export Options…", "ობიექტის ექსპორტის პარამეტრები…"), + ("Object Layer Options…", "ობიექტის შრეების პარამეტრები…"), + ("Object Layers", "ობიექტის შრეები"), + ("Object Liquid Settings", "ობიექტის მოქნილი პარამეტრები"), + ("Open Book", "წიგნის გახსნა"), + ("Open Bytes", "ბაიტების გახსნა"), + ("Open IDML", "IDML-ის გახსნა"), + ("Open Library", "ბიბლიოთეკის გახსნა"), + ("Outer Glow", "გარე ნათება"), + ("Overprint Preview", "ზედდაბეჭდვის წინასწარი ნახვა"), + ("Override All Parent Page Items", "საბაზო გვერდის ყველა ელემენტის გადაფარვა"), + ("Page Size", "გვერდის ზომა"), + ("Page Transition", "გვერდის გადასვლა"), + ("Page Transitions", "გვერდების გადასვლები"), + ("Paragraph Formatting", "აბზაცის ფორმატირება"), + ("Paragraph Style Options…", "აბზაცის სტილის პარამეტრები…"), + ("Paste Into", "შიგნით ჩასმა"), + ("Paste without Formatting", "ფორმატირების გარეშე ჩასმა"), + ("Place", "განთავსება"), + ("Place Caret", "კურსორის დასმა"), + ("Place Item", "ელემენტის განთავსება"), + ("Place Snippet", "ფრაგმენტის განთავსება"), + ("Place and Link", "განთავსება და დაკავშირება"), + ("Place with Import Options…", "განთავსება იმპორტის პარამეტრებით…"), + ("Polygon Settings…", "მრავალკუთხედის პარამეტრები…"), + ("Preflight Document", "დოკუმენტის წინასწარი შემოწმება"), + ("Previous Cell", "წინა უჯრა"), + ("Previous Document", "წინა დოკუმენტი"), + ("Previous Object in Group", "ჯგუფის წინა ობიექტი"), + ("Primary Text Frame", "ძირითადი ტექსტური ჩარჩო"), + ("Print Booklet…", "ბროშურის დაბეჭდვა…"), + ("Printers", "პრინტერები"), + ("Proof Colors", "ფერების საცდელი ჩვენება"), + ("Proof Setup", "საცდელი ჩვენების პარამეტრები"), + ("Quick Apply…", "სწრაფი გამოყენება…"), + ("Recover Documents", "დოკუმენტების აღდგენა"), + ("Recovery Data", "აღდგენის მონაცემები"), + ("Reject All Changes", "ყველა ცვლილების უარყოფა"), + ("Release Compound Path", "შედგენილი ტრაექტორიის მოხსნა"), + ("Remove All Local Overrides", "ყველა ლოკალური გადაფარვის მოხსნა"), + ("Remove Document", "დოკუმენტის მოცილება"), + ("Report an Issue…", "ხარვეზის შეტყობინება…"), + ("Reset Shortcuts", "მალსახმობების ჩამოყრა"), + ("Reset Workspace", "სამუშაო სივრცის ჩამოყრა"), + ("Resize", "ზომის შეცვლა"), + ("Rotate Spread", "გაშლის შემობრუნება"), + ("Run Script", "სკრიპტის გაშვება"), + ("Sample Document", "სანიმუშო დოკუმენტი"), + ("Satin", "ატლასი"), + ("Save Recovery Data", "აღდგენის მონაცემების შენახვა"), + ("Script Label", "სკრიპტის ჭდე"), + ("Select All on Layer", "შრეზე ყველაფრის მონიშვნა"), + ("Select Cells", "უჯრების მონიშვნა"), + ("Select Column", "სვეტის მონიშვნა"), + ("Select Frame", "ჩარჩოს მონიშვნა"), + ("Select Row", "ცხრილის სტრიქონის მონიშვნა"), + ("Select Table", "ცხრილის მონიშვნა"), + ("Select Text", "ტექსტის მონიშვნა"), + ("Select Tool", "ხელსაწყოს არჩევა"), + ("Select Word", "სიტყვის მონიშვნა"), + ("Selection Attributes", "მონიშნულის ატრიბუტები"), + ("Separations Preview", "სეპარაციის წინასწარი ნახვა"), + ("Serialize", "სერიალიზაცია"), + ("Set Active Layer", "აქტიური შრის დაყენება"), + ("Set Shortcut", "მალსახმობის მინიჭება"), + ("Set Visibility / Lock", "ხილვადობის / ჩაკეტვის დაყენება"), + ("Show Panel", "პანელის ჩვენება"), + ("Show/Hide Baseline Grid", "საბაზისო ხაზების ბადის ჩვენება/დამალვა"), + ("Show/Hide Frame Edges", "ჩარჩოს კიდეების ჩვენება/დამალვა"), + ("Show/Hide Guides", "დამხმარე ხაზების ჩვენება/დამალვა"), + ("Show/Hide Hidden Characters", "დამალული სიმბოლოების ჩვენება/დამალვა"), + ("Show/Hide Panels", "პანელების ჩვენება/დამალვა"), + ("Show/Hide Panels Except Tools", "პანელების ჩვენება/დამალვა, ხელსაწყოების გარდა"), + ("Show/Hide Rulers", "სახაზავების ჩვენება/დამალვა"), + ("Show/Hide Tagged Frames", "ტეგირებული ჩარჩოების ჩვენება/დამალვა"), + ("Show/Hide Text Threads", "ტექსტის კავშირების ჩვენება/დამალვა"), + ("Smooth", "გლუვი"), + ("Sort", "სორტირება"), + ("Split Cell Horizontally", "უჯრის ჰორიზონტალურად გაყოფა"), + ("Split Cell Vertically", "უჯრის ვერტიკალურად გაყოფა"), + ("Split Path", "ტრაექტორიის გაყოფა"), + ("Step and Repeat…", "ბიჯით გამეორება…"), + ("Stroke Styles", "კონტურის სტილები"), + ("Structure", "სტრუქტურა"), + ("Style Source", "სტილების წყარო"), + ("Synchronize Book", "წიგნის სინქრონიზაცია"), + ("Table Options", "ცხრილის პარამეტრები"), + ("Table Panel", "ცხრილის პანელი"), + ("Table Style Options…", "ცხრილის სტილის პარამეტრები…"), + ("Table of Contents Entries", "სარჩევის ჩანაწერები"), + ("Table of Contents…", "სარჩევი…"), + ("Text Anchors", "ტექსტის ღუზები"), + ("Text Variables", "ტექსტური ცვლადები"), + ("Toggle Normal/Preview", "ჩვეულებრივი რეჟიმი/წინასწარი ნახვა"), + ("Toggle Selection", "მონიშვნის გადართვა"), + ("Tools: Double Column", "ხელსაწყოები: ორ სვეტად"), + ("Transform Again", "გარდაქმნის გამეორება"), + ("Transform Panel", "გარდაქმნის პანელი"), + ("Transform Values", "გარდაქმნის მნიშვნელობები"), + ("Transparency Blend Space", "გამჭვირვალობის შერევის ფერთა სივრცე"), + ("Transparency Flattener Presets", "გამჭვირვალობის გაბრტყელების პრესეტები"), + ("Type on a Path", "ტექსტი ტრაექტორიაზე"), + ("Type on a Path Options…", "ტრაექტორიაზე ტექსტის პარამეტრები…"), + ("Typical Display", "ტიპური ჩვენება"), + ("UI Scaling", "ინტერფეისის მასშტაბირება"), + ("Update Link", "ბმულის განახლება"), + ("User Dictionary Words", "მომხმარებლის ლექსიკონის სიტყვები"), + ("User Dictionary…", "მომხმარებლის ლექსიკონი…"), + ("Zoom To", "მასშტაბი"), + ("⇧", "⇧"), + ("⌃", "⌃"), + ("⌘", "⌘"), + ("⌥", "⌥"), + ("Accept Change", "ცვლილების მიღება"), + ("Add Anchor", "ღუზის დამატება"), + ("Add Selection to Article", "მონიშნულის სტატიაში დამატება"), + ("Add Unnamed Colors", "უსახელო ფერების დამატება"), + ("Add to Dictionary", "ლექსიკონში დამატება"), + ("Align Center", "ცენტრში სწორება"), + ("Align Left", "მარცხნივ სწორება"), + ("Align Right", "მარჯვნივ სწორება"), + ("Apply Character Style", "სიმბოლოს სტილის გამოყენება"), + ("Apply Object Style", "ობიექტის სტილის გამოყენება"), + ("Apply Paragraph Style", "აბზაცის სტილის გამოყენება"), + ("Apply Table Style", "ცხრილის სტილის გამოყენება"), + ("Article Options", "სტატიის პარამეტრები"), + ("Basic Feather", "კიდეების მარტივი შერბილება"), + ("Bold", "მუქი"), + ("Bring Forward", "წინ გადმოტანა"), + ("Bring to Front", "წინა პლანზე გადმოტანა"), + ("Change", "ჩანაცვლება"), + ("Character Style Options…", "სიმბოლოს სტილის პარამეტრები…"), + ("Close Path", "ტრაექტორიის შეკვრა"), + ("Color Group Options", "ფერების ჯგუფის პარამეტრები"), + ("Column Width", "სვეტის სიგანე"), + ("Column Rules", "სვეტებს შორის ხაზები"), + ("Insert Column Rule", "სვეტებს შორის ხაზის ჩასმა"), + ("Tint %", "ტონირება %"), + ("Top Inset", "ზედა შეწევა"), + ("Bottom Inset", "ქვედა შეწევა"), + ("Horizontal Offset", "ჰორიზონტალური წანაცვლება"), + ("Condition Options…", "პირობის პარამეტრები…"), + ("Convert to Liquid Guide", "მოქნილ დამხმარე ხაზად გარდაქმნა"), + ("Convert to Object", "ობიექტად გარდაქმნა"), + ("Create Line", "ხაზის შექმნა"), + ("Create Path", "ტრაექტორიის შექმნა"), + ("Decrease Point Size", "შრიფტის ზომის შემცირება"), + ("Delete Article", "სტატიის წაშლა"), + ("Delete Bookmark", "სანიშნის წაშლა"), + ("Delete Endnote", "დოკუმენტის სქოლიოს წაშლა"), + ("Delete Footnote", "სქოლიოს წაშლა"), + ("Delete Guide", "დამხმარე ხაზის წაშლა"), + ("Delete Hyperlink", "ჰიპერბმულის წაშლა"), + ("Delete List", "სიის წაშლა"), + ("Delete Note", "შენიშვნის წაშლა"), + ("Delete Pages", "გვერდების წაშლა"), + ("Delete Paragraph Style", "აბზაცის სტილის წაშლა"), + ("Delete Stroke Style", "კონტურის სტილის წაშლა"), + ("Delete Table", "ცხრილის წაშლა"), + ("Delete Tag", "ტეგის წაშლა"), + ("Delete Text", "ტექსტის წაშლა"), + ("Distribute Columns Evenly", "სვეტების თანაბრად განაწილება"), + ("Edit Endnote", "დოკუმენტის სქოლიოს რედაქტირება"), + ("Edit Note", "შენიშვნის რედაქტირება"), + ("Embed Link", "ბმული ფაილის ჩაშენება"), + ("Fill", "შევსება"), + ("Fill with Placeholder Text", "შემავსებელი ტექსტით შევსება"), + ("Flip", "არეკვლა"), + ("Hyperlink Options…", "ჰიპერბმულის პარამეტრები…"), + ("Increase Point Size", "შრიფტის ზომის გაზრდა"), + ("Insert Column Left", "სვეტის ჩასმა მარცხნივ"), + ("Insert Column Right", "სვეტის ჩასმა მარჯვნივ"), + ("Insert Row Above", "სტრიქონის ჩასმა ზემოთ"), + ("Insert Row Below", "სტრიქონის ჩასმა ქვემოთ"), + ("Italic", "დახრილი"), + ("Justify Left", "ორმხრივი სწორება, ბოლო სტრიქონი მარცხნივ"), + ("Layer Options…", "შრის პარამეტრები…"), + ("Move", "გადაადგილება"), + ("Move Anchors", "საყრდენი წერტილების გადაადგილება"), + ("Move Column", "სვეტის გადატანა"), + ("Move Guide", "დამხმარე ხაზის გადაადგილება"), + ("Move Layer", "შრის გადაადგილება"), + ("Move Pages…", "გვერდების გადატანა…"), + ("Move to Layer", "შრეზე გადატანა"), + ("New Article", "ახალი სტატია"), + ("New Bookmark", "ახალი სანიშნე"), + ("New Character Style…", "სიმბოლოს ახალი სტილი…"), + ("New Layer…", "ახალი შრე…"), + ("New Note", "ახალი შენიშვნა"), + ("New Object Style…", "ობიექტის ახალი სტილი…"), + ("New Parent…", "ახალი საბაზო გვერდი…"), + ("New Tag", "ახალი ტეგი"), + ("Opacity", "გაუმჭვირვალობა"), + ("Paste in Place", "ადგილზე ჩასმა"), + ("Place Loaded Graphic", "ჩატვირთული გრაფიკის განთავსება"), + ("Reject Change", "ცვლილების უარყოფა"), + ("Release State to Objects", "მდგომარეობის ობიექტებად გარდაქმნა"), + ("Remove Hyphenation Exception", "გადატანის გამონაკლისის მოცილება"), + ("Remove from Article", "სტატიიდან მოცილება"), + ("Rename", "სახელის შეცვლა"), + ("Rename Bookmark", "სანიშნის სახელის შეცვლა"), + ("Rotate", "შემობრუნება"), + ("Row Height", "სტრიქონის სიმაღლე"), + ("Scale", "მასშტაბირება"), + ("Send Backward", "უკან გადატანა"), + ("Send to Back", "უკანა პლანზე გადატანა"), + ("Set Cross-Reference Format", "ჯვარედინი მიმართვის ფორმატის დაყენება"), + ("Set Footnote Text", "სქოლიოს ტექსტის დაყენება"), + ("Set Story Text", "ტექსტური ნაკადის ტექსტის დაყენება"), + ("Shear", "დახრა"), + ("Show All on Spread", "გაშლაზე ყველაფრის ჩვენება"), + ("Show State", "მდგომარეობის ჩვენება"), + ("State Options", "მდგომარეობის პარამეტრები"), + ("Tag Frame", "ჩარჩოსთვის ტეგის მინიჭება"), + ("Tag Text", "ტექსტისთვის ტეგის მინიჭება"), + ("Track Changes", "ცვლილებების ჩაწერა"), + ("Unlink", "კავშირის გაწყვეტა"), + ("Unlock All on Spread", "გაშლაზე ყველაფრის განბლოკვა"), + ("Update Index", "ინდექსის განახლება"), + ("Update Table of Contents", "სარჩევის განახლება"), + ("Bulleted and Numbered Lists", "ბურთულებიანი და დანომრილი სიები"), + ("Insert Special Character", "სპეციალური სიმბოლოს ჩასმა"), + ("Insert White Space", "ჰარის სიმბოლოს ჩასმა"), + ("Insert Break Character", "გამყოფი სიმბოლოს ჩასმა"), + ("Hyperlinks Cross-References", "ჰიპერბმულები, ჯვარედინი მიმართვები"), + ("Document Footnote Options...", "დოკუმენტის სქოლიოების პარამეტრები..."), + (" Close ", " დახურვა "), + (" New file ", " ახალი ფაილი "), + (" OK ", " OK "), + (" Open ", " გახსნა "), + (" Open sample magazine ", " სანიმუშო ჟურნალის გახსნა "), + ("(Top Level)", "(ზედა დონე)"), + ("(import)", "(იმპორტი)"), + ("Anchored Object", "მიმაგრებული ობიექტი"), + ("Apply Attributes (Eyedropper", "ატრიბუტების გამოყენება (პიპეტი"), + ("Baseline Grid", "საბაზისო ხაზების ბადე"), + ("Bleed and Slug", "ჭრისპირა არე და საინფორმაციო ზოლი"), + ("Buttons act in interactive PDF export.", "ღილაკები მოქმედებს ინტერაქტიური PDF-ის ექსპორტისას."), + ("Captions", "წარწერები"), + ("Change All (Object", "ყველას ჩანაცვლება (ობიექტი"), + ("Character Settings", "სიმბოლოს პარამეტრები"), + ("Choose a link.", "აირჩიეთ ბმული."), + ("Clipping Path", "ჩაჭრის ტრაექტორია"), + ( + "Collect objects with the Content Collector (B); place them with the Content Placer.", + "შეაგროვეთ ობიექტები შიგთავსის შემგროვებლით (B) და განათავსეთ შიგთავსის განმათავსებლით.", + ), + ("Content Fitting", "შიგთავსის ზომაზე მორგება"), + ("Convert Cell Type", "უჯრის ტიპის გარდაქმნა"), + ("Copy Link(s", "ბმული ფაილების კოპირება"), + ("Create Alternate Layout", "ალტერნატიული განლაგების შექმნა"), + ("Crop Amount", "ჩამოჭრის ზომა"), + ("Cross-Reference Format", "ჯვარედინი მიმართვის ფორმატი"), + ("Delete Workspace", "სამუშაო სივრცის წაშლა"), + ("DesignCraft", "DesignCraft"), + ("Display Performance", "ჩვენების წარმადობა"), + ("Document Grid", "დოკუმენტის ბადე"), + ("Document Recovery Data", "დოკუმენტის აღდგენის მონაცემები"), + ("EPUB and HTML", "EPUB და HTML"), + ("Export", "ექსპორტი"), + ("Export EPUB (Fixed Layout", "EPUB ექსპორტი (ფიქსირებული განლაგება"), + ("Export EPUB (Reflowable", "EPUB ექსპორტი (ადაპტური განლაგება"), + ("Facing Pages", "მოპირდაპირე გვერდები"), + ("GREP Styles", "GREP სტილები"), + ( + "Get help, share your layouts and shape what we build next.", + "მიიღეთ დახმარება, გააზიარეთ თქვენი განლაგებები და გავლენა მოახდინეთ იმაზე, რას შევქმნით შემდეგ.", + ), + ("Highlight", "გამოკვეთა"), + ("Hyperlinks & Cross-References", "ჰიპერბმულები და ჯვარედინი მიმართვები"), + ("Hyphens and Dashes", "დეფისები და ტირეები"), + ("Interactive", "ინტერაქტიური"), + ("Invert", "ინვერსია"), + ("Join the ArtCraft community", "შემოუერთდით ArtCraft-ის საზოგადოებას"), + ("Keyboard Increments", "კლავიატურით ბიჯები"), + ("Liquid guides", "მოქნილი დამხმარე ხაზები"), + ("Markers", "მარკერები"), + ("More ArtCraft apps", "ArtCraft-ის სხვა აპლიკაციები"), + ("Nested Line Styles", "ხაზის ჩადგმული სტილები"), + ("Nested Styles", "ჩადგმული სტილები"), + ("No articles: exports follow page order.", "სტატიები არ არის: ექსპორტი გვერდების რიგს მიჰყვება."), + ("No book open.", "წიგნი გახსნილი არ არის."), + ("No bookmarks. They become the PDF outline.", "სანიშნეები არ არის. ისინი PDF-ის სარჩევად იქცევა."), + ("No changes.", "ცვლილებები არ არის."), + ( + "No conditions. Create one, then select text and click it to apply.", + "პირობები არ არის. შექმენით პირობა, შემდეგ მონიშნეთ ტექსტი და დაწკაპეთ პირობაზე მის გამოსაყენებლად.", + ), + ("No document open", "დოკუმენტი გახსნილი არ არის"), + ("No document.", "დოკუმენტი არ არის."), + ( + "No hyperlinks. Select text or a frame, type a URL and click New.", + "ჰიპერბმულები არ არის. მონიშნეთ ტექსტი ან ჩარჩო, შეიყვანეთ URL და დაწკაპეთ „ახალი“-ზე.", + ), + ("No library open.", "ბიბლიოთეკა გახსნილი არ არის."), + ("No notes in this document.", "ამ დოკუმენტში შენიშვნები არ არის."), + ("No placed graphics.", "განთავსებული გრაფიკა არ არის."), + ("No scripts. Add one below.", "სკრიპტები არ არის. დაამატეთ ქვემოთ."), + ("No text anchors in this document.", "ამ დოკუმენტში ტექსტის ღუზები არ არის."), + ("OK changes all: text and paragraph/character styles.", "OK ყველაფერს ცვლის: ტექსტს და აბზაცის/სიმბოლოს სტილებს."), + ("OVERSET", "ტექსტი არ ეტევა"), + ("Open source. No telemetry.", "ღია კოდი. ტელემეტრიის გარეშე."), + ("Options", "პარამეტრები"), + ("Options for Black on RGB and Grayscale Devices", "შავის პარამეტრები RGB და რუხი ტონების მოწყობილობებისთვის"), + ("Other", "სხვა"), + ("Output", "გამოტანა"), + ("Page Numbering", "გვერდების დანომვრა"), + ( + "Page layout for print and screen — fast, open and scriptable.", + "გვერდების განლაგება ბეჭდვისა და ეკრანისთვის — სწრაფი, ღია და სკრიპტებით მართვადი.", + ), + ("Page · PPI", "გვერდი · PPI"), + ("Part of the ArtCraft suite of open creative tools.", "ArtCraft-ის ღია შემოქმედებითი ხელსაწყოების ნაკრების ნაწილი."), + ("Pasteboard Options", "სამუშაო არის პარამეტრები"), + ("Paths", "ტრაექტორიები"), + ("Place the insertion point in a table, or create one.", "მოათავსეთ კურსორი ცხრილში ან შექმენით ცხრილი."), + ("Plays in EPUB export; print and PDF show the poster.", "იკვრება EPUB ექსპორტში; ბეჭდვასა და PDF-ში პოსტერი ჩანს."), + ("Quotation Marks", "ბრჭყალები"), + ("Recently Used", "ბოლოს გამოყენებული"), + ("Replace With", "ჩანაცვლება"), + ("Right-click: stroke, groups", "მარჯვენა დაწკაპუნება: კონტური, ჯგუფები"), + ("Ruler Units", "სახაზავის ერთეულები"), + ("Saves which bars show and where the panels are.", "ინახავს, რომელი ზოლები ჩანს და სად არის პანელები."), + ( + "Select a placed video or sound (File › Place a .mp4, .mov, .mp3, .wav …).", + "მონიშნეთ განთავსებული ვიდეო ან ხმა (ფაილი › განთავსება: .mp4, .mov, .mp3, .wav …).", + ), + ("Select an object to color its fill or stroke.", "მონიშნეთ ობიექტი მისი შევსების ან კონტურის ფერის შესაცვლელად."), + ("Select an object to give its fill a gradient.", "მონიშნეთ ობიექტი მისი შევსებისთვის გრადიენტის მისაცემად."), + ("Select an object to make it a button.", "მონიშნეთ ობიექტი მის ღილაკად გადასაქცევად."), + ( + "Select two or more objects (or a group) to make a multi-state object.", + "მრავალმდგომარეობიანი ობიექტის შესაქმნელად მონიშნეთ ორი ან მეტი ობიექტი (ან ჯგუფი).", + ), + ("Show", "ჩვენება"), + ("Smart Text Reflow", "ტექსტის ჭკვიანი გადადინება"), + ("Spelling", "მართლწერა"), + ("Start a new document", "ახალი დოკუმენტის შექმნა"), + ("Status", "სტატუსი"), + ("Style Mapping", "სტილების შესაბამისობა"), + ("Style Name Conflicts", "სტილების სახელების კონფლიქტები"), + ("Symbols", "სიმბოლოები"), + ("System default", "სისტემის ნაგულისხმევი"), + ("Table Dimensions", "ცხრილის ზომები"), + ("Text Display Options", "ტექსტის ჩვენების პარამეტრები"), + ("The fill isn't a gradient.", "შევსება გრადიენტი არ არის."), + ("The library is empty. Select objects and Add Selection.", "ბიბლიოთეკა ცარიელია. მონიშნეთ ობიექტები და დაწკაპეთ „მონიშნულის დამატება“-ზე."), + ("Transitions play in interactive PDF (full-screen mode).", "გადასვლები იკვრება ინტერაქტიურ PDF-ში (სრულეკრანიან რეჟიმში)."), + ("Type & Tables", "ტექსტი და ცხრილები"), + ("Type Options", "ტექსტის პარამეტრები"), + ( + "Type a word with ~ at each allowed break (ex~am~ple); without ~ the word is never hyphenated.", + "აკრიფეთ სიტყვა ~ ნიშნით ყოველ დასაშვებ გადატანის ადგილას (მა~გა~ლი~თი); ~-ის გარეშე სიტყვა არასოდეს გადაიტანება.", + ), + ("User Dictionary (this document)", "მომხმარებლის ლექსიკონი (ეს დოკუმენტი)"), + ("Utilities", "დამხმარე საშუალებები"), + ("Variable Font", "ცვლადი შრიფტი"), + ("Welcome to DesignCraft", "მოგესალმებათ DesignCraft"), + ("When Scaling", "მასშტაბირებისას"), + ("[Basic] (working) ▾", "[საბაზო] (სამუშაო) ▾"), + ("[Black] Overprint", "[შავი] — ზედდაბეჭდვა"), + ("[No Group]", "[ჯგუფის გარეშე]"), + ("[None]", "[არცერთი]"), + ("[None] — choose a colour on the ramp.", "[არცერთი] — აირჩიეთ ფერი სკალაზე."), + ("discord.gg/artcraft — help, feedback and show-and-tell", "discord.gg/artcraft — დახმარება, გამოხმაურება და ნამუშევრების ჩვენება"), + ("⌥-click clears overrides", "⌥-დაწკაპუნება ასუფთავებს გადაფარვებს"), + ("⚠ out of date", "⚠ მოძველებულია"), + ("General", "ზოგადი"), + ("Basic Character Formats", "სიმბოლოს საბაზო ფორმატები"), + ("Indents and Spacing", "შეწევა და ინტერვალი"), + ("Hyphenation", "სიტყვების გადატანა"), + ("Justification", "სწორება"), + ("Drop Caps and Nested Styles", "ინიციალები და ჩადგმული სტილები"), + ("GREP Style", "GREP სტილი"), + ("Character Color", "სიმბოლოს ფერი"), + ("Numbering and Formatting", "დანომვრა და ფორმატირება"), + ("Spacing Options", "დაშორების პარამეტრები"), + ("First Baseline", "პირველი საბაზისო ხაზი"), + ("Rule Above", "ხაზი ზემოთ"), + ("Numbering", "დანომვრა"), + ("Footnote Reference Number in Text", "სქოლიოს მითითების ნომერი ტექსტში"), + ("Footnote Formatting", "სქოლიოს ფორმატირება"), + ("Span Footnotes Across Columns", "სქოლიოების სვეტებზე გაშლა"), + ("Rule On", "ხაზი ჩართულია"), + ("Ascent", "ზედა გამონაშვერი"), + ("Cap Height", "მთავრული ასოს სიმაღლე"), + ("Leading", "სტრიქონთაშორისი ინტერვალი"), + ("x Height", "x-სიმაღლე"), + ("Fixed", "ფიქსირებული"), + ("Never (continuous)", "არასოდეს (უწყვეტი)"), + ("Page", "გვერდი"), + ("Spread", "გაშლა"), + ("Section", "სექცია"), + ("Footnote Reference", "სქოლიოს მითითება"), + ("Footnote Text", "სქოლიოს ტექსტი"), + ("Both", "ორივე"), + ("Apply Superscript", "ზედა ინდექსის გამოყენება"), + ("Apply Subscript", "ქვედა ინდექსის გამოყენება"), + ("Apply Normal", "ჩვეულებრივის გამოყენება"), + ("OpenType Superscript", "OpenType ზედა ინდექსი"), + ("through", "ჩათვლით"), + ("up to", "ჩაუთვლელად"), + ("line", "სტრიქონი"), + ("lines", "სტრიქონი"), + ("sentences", "წინადადება"), + ("words", "სიტყვა"), + ("characters", "სიმბოლო"), + ("letters", "ასო"), + ("digits", "ციფრი"), + ("tab", "ტაბულაცია"), + ("forcedLineBreak", "სტრიქონის იძულებითი გადატანა"), + ("emSpace", "გრძელი შორისი"), + ("enSpace", "მოკლე შორისი"), + ("chars", "სიმბოლო"), + ("[Automatic]", "[ავტომატური]"), + ("Tracking:", "ტრეკინგი:"), + ("Right Indent:", "მარჯვენა შეწევა:"), + ("First Line Indent:", "პირველი სტრიქონის შეწევა:"), + ("Space Before:", "დაშორება წინ:"), + ("Space After:", "დაშორება შემდეგ:"), + ("Words with at Least:", "სიტყვები მინიმუმ:"), + ("After First:", "პირველის შემდეგ:"), + ("Before Last:", "ბოლომდე:"), + ("Hyphen Limit:", "გადატანების ზღვარი:"), + ("Word Spacing:", "სიტყვებს შორის დაშორება:"), + ("Letter Spacing:", "ასოებს შორის დაშორება:"), + ("Glyph Scaling:", "გლიფების მასშტაბირება:"), + ("Insert Kashidas:", "ქაშიდების ჩასმა:"), + ("In justified Arabic text", "ორმხრივად სწორებულ არაბულ ტექსტში"), + ("Korean Line Breaks:", "კორეული სტრიქონების წყვეტა:"), + ("Between syllables (not only at spaces)", "მარცვლებს შორის (არა მხოლოდ ჰარებთან)"), + ("Binding:", "აკინძვა:"), + ("Ruby:", "რუბი:"), + ("Set over the selected text; empty removes it.", "დაისმება მონიშნული ტექსტის თავზე; ცარიელი წაშლის მას."), + ("[Basic Paragraph]", "[საბაზო აბზაცი]"), + ("[No Paragraph Style]", "[აბზაცის სტილის გარეშე]"), + ("Align left", "მარცხნივ სწორება"), + ("Align center", "ცენტრში სწორება"), + ("Align right", "მარჯვნივ სწორება"), + ("Justify with last line aligned left", "ორმხრივი სწორება, ბოლო სტრიქონი მარცხნივ"), + ("Justify with last line aligned center", "ორმხრივი სწორება, ბოლო სტრიქონი ცენტრში"), + ("Justify with last line aligned right", "ორმხრივი სწორება, ბოლო სტრიქონი მარჯვნივ"), + ("Justify all lines", "ყველა სტრიქონის ორმხრივი სწორება"), + ("Align towards spine", "ყუისკენ სწორება"), + ("Align away from spine", "ყუიდან მოშორებით სწორება"), + ("Create New Style from Selection", "მონიშნულიდან ახალი სტილის შექმნა"), + ("No text selected", "ტექსტი მონიშნული არ არის"), + ("Search", "ძიება"), + ("Essentials", "ძირითადი"), + ("Advanced", "დამატებითი"), + ("Digital Publishing", "ციფრული გამოცემა"), + ("Interactive for PDF", "ინტერაქტიური PDF-ისთვის"), + ("Printing and Proofing", "ბეჭდვა და კორექტურა"), + ("Typography", "ტიპოგრაფია"), + ("Rulers & Grids", "სახაზავები და ბადეები"), + ("No errors", "შეცდომები არ არის"), + ("error", "შეცდომა"), + ("errors", "შეცდომა"), + ("Reset", "ჩამოყრა"), + ("Right to Left", "მარჯვნიდან მარცხნივ"), + ("Left to Right", "მარცხნიდან მარჯვნივ"), + ("Portrait", "ვერტიკალური"), + ("Landscape", "ჰორიზონტალური"), + ("Display All Blacks as Rich Black", "ყველა შავის მდიდარ შავად ჩვენება"), + ("Display All Blacks Accurately", "ყველა შავის ზუსტად ჩვენება"), + ("Output All Blacks as Rich Black", "ყველა შავის მდიდარ შავად გამოტანა"), + ("Output All Blacks Accurately", "ყველა შავის ზუსტად გამოტანა"), + ("Top Margin", "ზედა ველი"), + ("Top of Page", "გვერდის თავში"), + ("Paragraph Styles panel", "აბზაცის სტილების პანელი"), + ("Character Styles panel", "სიმბოლოს სტილების პანელი"), + ("Create new style", "ახალი სტილის შექმნა"), + ("Clear overrides", "გადაფარვების გასუფთავება"), + ("Apply", "გამოყენება"), + ("Accept", "მიღება"), + ("Reject", "უარყოფა"), + ("Add Selection", "მონიშნულის დამატება"), + ("Align Stroke:", "კონტურის სწორება:"), + ("Angle", "კუთხე"), + ("Angle:", "კუთხე:"), + ("Baseline", "საბაზისო ხაზი"), + ("Cap:", "დაბოლოება:"), + ("Cell Text", "უჯრის ტექსტი"), + ("Cell Type", "უჯრის ტიპი"), + ("Composer", "აწყობის რეჟიმი"), + ("Convert to Text", "ტექსტად გარდაქმნა"), + ("Create New Layer", "ახალი შრის შექმნა"), + ("Delete DTD", "DTD-ის წაშლა"), + ("Digits", "ციფრები"), + ("Direction", "მიმართულება"), + ("Distance", "მანძილი"), + ("Distribute ↔", "განაწილება ↔"), + ("Distribute ↕", "განაწილება ↕"), + ("Effects: Drop Shadow", "ეფექტები: ჩრდილი"), + ("Favorites", "რჩეულები"), + ("First Spread", "პირველი გაშლა"), + ("Gap Color:", "შუალედის ფერი:"), + ("Graphic…", "გრაფიკა…"), + ("Home", "საწყისი ეკრანი"), + ("Join:", "შეერთება:"), + ("Language", "ენა"), + ("Last Spread", "ბოლო გაშლა"), + ("Load DTD…", "DTD-ის ჩატვირთვა…"), + ("Location:", "მდებარეობა:"), + ("Lock Guides", "დამხმარე ხაზების ჩაკეტვა"), + ("Miter Limit:", "მახვილის ზღვარი:"), + ("New Article from Selection", "ახალი სტატია მონიშნულიდან"), + ("New Note at Cursor", "ახალი შენიშვნა კურსორთან"), + ("New Swatch", "ფერის ახალი ნიმუში"), + ("New condition name", "ახალი პირობის სახელი"), + ("New group…", "ახალი ჯგუფი…"), + ("New tag", "ახალი ტეგი"), + ("Next Spread", "შემდეგი გაშლა"), + ("Note text", "შენიშვნის ტექსტი"), + ("Offset", "წანაცვლება"), + ("Offsets", "წანაცვლებები"), + ("Paragraph alignment", "აბზაცის სწორება"), + ("Previous Spread", "წინა გაშლა"), + ("Release to Objects", "ობიექტებად დაშლა"), + ("Remove", "მოცილება"), + ("Rotate 90° Counterclockwise", "90°-ით შემობრუნება საათის ისრის საწინააღმდეგოდ"), + ("Rotation Angle", "შემობრუნების კუთხე"), + ("Rows", "სტრიქონები"), + ("Run", "გაშვება"), + ("Scale X Percentage", "მასშტაბი X, პროცენტი"), + ("Scale Y Percentage", "მასშტაბი Y, პროცენტი"), + ("Search commands", "ბრძანებების ძიება"), + ("Search fonts", "შრიფტების ძიება"), + ("Search styles and commands…", "სტილებისა და ბრძანებების ძიება…"), + ("Search: character or U+code", "ძიება: სიმბოლო ან U+კოდი"), + ("Share", "გაზიარება"), + ("Shear X Angle", "დახრის კუთხე X"), + ("Show Baseline Grid", "საბაზისო ხაზების ბადის ჩვენება"), + ("Show Document Grid", "დოკუმენტის ბადის ჩვენება"), + ("Show Guides", "დამხმარე ხაზების ჩვენება"), + ("Stop Color:", "გადასვლის წერტილის ფერი:"), + ("Style", "სტილი"), + ("Styles panel", "სტილების პანელი"), + ("Text", "ტექსტი"), + ("The structure is valid.", "სტრუქტურა სწორია."), + ("Toggle double column", "ორ სვეტად ჩვენების გადართვა"), + ("Tracking", "ტრეკინგი"), + ("Untag Selection", "მონიშნულისთვის ტეგის მოხსნა"), + ("Validate", "შემოწმება"), + ("Weight", "სისქე"), + ("Subtract", "გამოკლება"), + ("Intersect", "გადაკვეთა"), + ("Exclude Overlap", "გადაფარვის გამორიცხვა"), + ("Minus Back", "უკანას გამოკლება"), + ("Align left edges", "მარცხენა კიდეების სწორება"), + ("Align horizontal centers", "ჰორიზონტალური ცენტრების სწორება"), + ("Align right edges", "მარჯვენა კიდეების სწორება"), + ("Align top edges", "ზედა კიდეების სწორება"), + ("Align vertical centers", "ვერტიკალური ცენტრების სწორება"), + ("Align bottom edges", "ქვედა კიდეების სწორება"), + ("Align to Selection", "მონიშნულზე სწორება"), + ("Align to Key Object", "საკვანძო ობიექტზე სწორება"), + ("Align to Margins", "ველებზე სწორება"), + ("Align to Page", "გვერდზე სწორება"), + ("Align to Spread", "გაშლაზე სწორება"), + ("No Text Wrap", "ტექსტით გარშემოვლების გარეშე"), + ("Wrap Around Bounding Box", "შემომსაზღვრელი ჩარჩოს ტექსტით გარშემოვლება"), + ("Wrap Around Object Shape", "ობიექტის ფორმის ტექსტით გარშემოვლება"), + ("Jump Object", "ობიექტზე გადახტომა"), + ("Jump to Next Column", "შემდეგ სვეტზე გადასვლა"), + ("Bulleted List", "ბურთულებიანი სია"), + ("Numbered List", "დანომრილი სია"), + ("UPPERCASE", "მთავრული"), + ("Small Caps", "პატარა მთავრული"), + ("Normal", "ჩვეულებრივი"), + ("Butt", "ბრტყელი"), + ("Round", "მომრგვალებული"), + ("Projecting", "გამოშვერილი"), + ("Miter", "მახვილი"), + ("Bevel", "წაკვეთილი"), + ("Center", "ცენტრში"), + ("Start/End:", "დასაწყისი/დასასრული:"), + ("Left", "მარცხნივ"), + ("Right", "მარჯვნივ"), + ("First", "პირველი"), + ("Last", "ბოლო"), + ("Before", "წინ"), + ("After", "შემდეგ"), + ("Shading", "ფონი"), + ("Border", "ჩარჩო"), + ("Overprint Fill", "შევსების ზედდაბეჭდვა"), + ("Overprint Stroke", "კონტურის ზედდაბეჭდვა"), + ("Overprint Gap", "ღრეჩოს ზედდაბეჭდვა"), + ("Nonprinting", "არ იბეჭდება"), + ("At Least", "მინიმუმ"), + ("Exactly", "ზუსტად"), + ("Justify", "ორმხრივი სწორება"), + ("Underline Options", "ხაზგასმის პარამეტრები"), + ("Strikethrough Options", "გადახაზვის პარამეტრები"), + ("Character Style Options", "სიმბოლოს სტილის პარამეტრები"), + ("Advanced Character Formats", "სიმბოლოს დამატებითი ფორმატები"), + ("OpenType Features", "OpenType შესაძლებლობები"), + ("Style Settings:", "სტილის პარამეტრები:"), + ("Kerning:", "კერნინგი:"), + ("Case:", "რეგისტრი:"), + ("OpenType All Small Caps", "OpenType: ყველა პატარა მთავრული"), + ("Subscript", "ქვედა ინდექსი"), + ("OpenType Subscript", "OpenType ქვედა ინდექსი"), + ("OpenType Numerator", "OpenType მრიცხველი"), + ("OpenType Denominator", "OpenType მნიშვნელი"), + ("Ligatures", "ლიგატურები"), + ("No Break", "გადატანის გარეშე"), + ("Horizontal Scale:", "ჰორიზონტალური მასშტაბი:"), + ("Vertical Scale:", "ვერტიკალური მასშტაბი:"), + ("Baseline Shift:", "საბაზისო ხაზის წანაცვლება:"), + ("Skew:", "დახრა:"), + ("Language:", "ენა:"), + ("Figure Style:", "ციფრების სტილი:"), + ("Underline On", "ხაზგასმა ჩართულია"), + ("Strikethrough On", "გადახაზვა ჩართულია"), + ("Edit \"{name}\"…", "„{name}“-ის რედაქტირება…"), + ("Discretionary Ligatures", "დამატებითი ლიგატურები"), + ("Fractions", "წილადები"), + ("Ordinal", "რიგობითი"), + ("Swash", "მოხატული ასოები (swash)"), + ("Titling Alternates", "სათაურის ალტერნატივები"), + ("Contextual Alternates", "კონტექსტური ალტერნატივები"), + ("Slashed Zero", "გადახაზული ნული"), + ("Tabular Lining", "ცხრილური ხაზოვანი"), + ("Proportional Oldstyle", "პროპორციული ძველებური"), + ("Proportional Lining", "პროპორციული ხაზოვანი"), + ("Tabular Oldstyle", "ცხრილური ძველებური"), + ("Default Figure Style", "ციფრების ნაგულისხმევი სტილი"), + ("Not a button", "ღილაკი არ არის"), + ("Go To Next Page", "შემდეგ გვერდზე გადასვლა"), + ("Go To Previous Page", "წინა გვერდზე გადასვლა"), + ("Go To First Page", "პირველ გვერდზე გადასვლა"), + ("Go To Last Page", "ბოლო გვერდზე გადასვლა"), + ("Go To Page", "გვერდზე გადასვლა"), + ("Go To URL", "URL-ზე გადასვლა"), + ("Not a form field", "ფორმის ველი არ არის"), + ("Text Field", "ტექსტური ველი"), + ("Check Box", "მოსანიშნი ველი"), + ("Radio Button", "გადამრთველი"), + ("Combo Box", "ჩამოსაშლელი სია"), + ("List Box", "სიის ველი"), + ("Signature", "ხელმოწერა"), + ("Resize width", "სიგანის შეცვლა"), + ("Resize height", "სიმაღლის შეცვლა"), + ("Pin top", "ზემოთ ჩამაგრება"), + ("Pin bottom", "ქვემოთ ჩამაგრება"), + ("Pin left", "მარცხნივ ჩამაგრება"), + ("Pin right", "მარჯვნივ ჩამაგრება"), + ("Inward", "შიგნით"), + ("Metrics", "მეტრიკა"), + ("Optical", "ოპტიკური"), + ("Applies to fill", "ეხება შევსებას"), + ("Applies to text", "ეხება ტექსტს"), + ("Gray", "ნაცრისფერი"), + ("Linear", "წრფივი"), + ("Radial", "რადიალური"), + ("Arabic", "არაბული"), + ("Beveled Rectangle", "წაკვეთილი მართკუთხედი"), + ("Chinese", "ჩინური"), + ("Constrain proportions for width and height", "სიგანისა და სიმაღლის პროპორციის შენარჩუნება"), + ("Corner", "კუთხე"), + ("Custom", "მორგებული"), + ("Czech", "ჩეხური"), + ("Danish", "დანიური"), + ("Dashed", "ტირეებიანი"), + ("Dotted", "პუნქტირი"), + ("Dutch: 2005 Reform", "ნიდერლანდური: 2005 წლის რეფორმა"), + ("Embedded", "ჩაშენებული"), + ("English: Canadian", "ინგლისური: კანადა"), + ("English: UK", "ინგლისური: ბრიტანეთი"), + ("English: USA", "ინგლისური: აშშ"), + ("Farsi", "სპარსული"), + ("Finnish", "ფინური"), + ("Fit Frame to Content", "ჩარჩოს შიგთავსზე მორგება"), + ("French", "ფრანგული"), + ("French: Canadian", "ფრანგული: კანადა"), + ("German: 2006 Reform", "გერმანული: 2006 წლის რეფორმა"), + ("German: Swiss 2006 Reform", "გერმანული: შვეიცარია, 2006 წლის რეფორმა"), + ("Greek", "ბერძნული"), + ("Hebrew", "ივრითი"), + ("Hindi", "ჰინდი"), + ("Hungarian", "უნგრული"), + ("Inverse Rounded Rectangle", "შებრუნებული მომრგვალებული მართკუთხედი"), + ("Italian", "იტალიური"), + ("Japanese", "იაპონური"), + ("Korean", "კორეული"), + ("Make all settings the same", "ყველა პარამეტრის გატოლება"), + ("Native", "ენის მიხედვით"), + ("Norwegian: Bokmål", "ნორვეგიული: ბუკმოლი"), + ("Orthogonal Line", "ორთოგონალური ხაზი"), + ("Polish", "პოლონური"), + ("Portuguese", "პორტუგალიური"), + ("Portuguese: Brazilian", "პორტუგალიური: ბრაზილია"), + ("Rounded Rectangle", "მომრგვალებული მართკუთხედი"), + ("Russian", "რუსული"), + ("Solid", "უწყვეტი"), + ("Spanish", "ესპანური"), + ("Story Editor", "ტექსტის რედაქტორი"), + ("Swedish", "შვედური"), + ("Thick - Thick", "სქელი - სქელი"), + ("Thick - Thin", "სქელი - თხელი"), + ("Thin - Thick", "თხელი - სქელი"), + ("Thin - Thin", "თხელი - თხელი"), + ("Thin - Thick - Thin", "თხელი - სქელი - თხელი"), + ("Thick - Thin - Thick", "სქელი - თხელი - სქელი"), + ("Wavy", "ტალღოვანი"), + ("Straight Hash", "სწორი დაშტრიხვა"), + ("Stripes", "ზოლები"), + ("Triangle", "სამკუთხედი"), + ("Turkish", "თურქული"), + ("Ukrainian", "უკრაინული"), + ("Georgian", "ქართული"), + ("Cell Style", "უჯრის სტილი"), + ("Table Style", "ცხრილის სტილი"), + ("Col ←", "სვეტი ←"), + ("Col →", "სვეტი →"), + ("Row ↑", "სტრიქონი ↑"), + ("Row ↓", "სტრიქონი ↓"), + ("Hide Others", "სხვების დამალვა"), + ("Lock Others", "დანარჩენების ჩაკეტვა"), + ("Show All Layers", "ყველა შრის ჩვენება"), + ("Unlock", "განბლოკვა"), + ("Unlock All Layers", "ყველა შრის განბლოკვა"), + ("Added", "დამატებულია"), + ("Deleted", "წაშლილია"), + ("Guide-based", "დამხმარე ხაზებზე დაფუძნებული"), + ("Loop", "ციკლი"), + ("Object-based", "ობიექტებზე დაფუძნებული"), + ("Off", "გამორთული"), + ("Play on Page Load", "გვერდის ჩატვირთვისას დაკვრა"), + ("Re-center", "ხელახლა ცენტრირება"), + ("Show Controls", "მართვის ელემენტების ჩვენება"), + ("Signature Field", "ხელმოწერის ველი"), + ("Select text to apply a condition", "პირობის გამოსაყენებლად მონიშნეთ ტექსტი"), + ("Blinds", "ჟალუზები"), + ("Box", "ჩარჩო"), + ("Comb", "სავარცხელი"), + ("Cover", "დაფარვა"), + ("Dissolve", "გაფანტვა"), + ("Fade", "მილევა"), + ("Push", "გადაწევა"), + ("Split", "გაყოფა"), + ("Uncover", "გადახსნა"), + ("Wipe", "წმენდა"), + ("Simple", "მარტივი"), + ("Simple - Wide", "მარტივი - ფართო"), + ("Triangle - Wide", "სამკუთხედი - ფართო"), + ("Barbed", "წვეტიანი"), + ("Curved", "მრუდი"), + ("Circle", "წრე"), + ("Circle - Solid", "წრე - შევსებული"), + ("Square", "კვადრატი"), + ("Square - Solid", "კვადრატი - შევსებული"), + ("Bar", "ზოლი"), + ("Fit Text", "ტექსტის მორგება"), + ("Preview (W)", "წინასწარი ნახვა (W)"), + ("Normal (W)", "ჩვეულებრივი (W)"), + ("objects", "ობიექტი"), + ("pages", "გვერდი"), + ("stories", "ტექსტური ნაკადი"), + ("items", "ელემენტი"), + ("warnings", "გაფრთხილება"), + ("Render", "რენდერი"), + ("Set", "დაყენება"), + ("unused", "გამოუყენებელი"), + ("⚠ Missing", "⚠ აკლია"), + ("▲ Modified", "▲ შეცვლილია"), + ("(Text Color)", "(ტექსტის ფერი)"), + ("{count} glyphs — click to insert at the text cursor", "გლიფი: {count} — დაწკაპეთ ტექსტის კურსორთან ჩასასმელად"), + ("{pages} Pages in {spreads} Spreads", "გვერდი: {pages}; გაშლა: {spreads}"), + (" (auto)", " (ავტო)"), + ("Absolute Numbering (instead of Section Numbering)", "აბსოლუტური დანომვრა (სექციების დანომვრის ნაცვლად)"), + ( + "Add and remove pages as the primary text frame's story grows and shrinks", + "გვერდების დამატება და წაშლა ძირითადი ტექსტური ჩარჩოს ნაკადის ზრდისა და შემცირებისას", + ), + ("Added Text (highlighted)", "დამატებული ტექსტი (გამოკვეთილი)"), + ("Adjust Layout (objects follow the new page size)", "განლაგების მისადაგება (ობიექტები გვერდის ახალ ზომას მიჰყვება)"), + ("Auto-Fit", "ავტომორგება"), + ("Case sensitive", "რეგისტრის გათვალისწინებით"), + ("Custom Tracking/Kerning", "ტრეკინგის/კერნინგის ხელით მითითება"), + ("Dimensions Include Stroke Weight", "ზომები კონტურის სისქის ჩათვლით"), + ("Enable Autocorrect", "ავტოშესწორების ჩართვა"), + ("Enable Dynamic Spelling (underline misspelled words)", "აკრეფისას მართლწერის შემოწმების ჩართვა (შეცდომით დაწერილი სიტყვების ხაზგასმა)"), + ("Grids in Back", "ბადე უკანა პლანზე"), + ("H&J Violations", "გადატანისა და სწორების დარღვევები"), + ("Include Bleed", "ჭრისპირა არის ჩართვა"), + ("Include Stroke Weight", "კონტურის სისქის გათვალისწინება"), + ("Join our Discord", "შემოგვიერთდით Discord-ზე"), + ("Keep Violations", "სტრიქონების ერთად შენარჩუნების დარღვევები"), + ("Overprint [Black] Swatch at 100%", "[შავი] ნიმუშის ზედდაბეჭდვა 100%-ზე"), + ("Printer's Marks", "საბეჭდი ნიშნები"), + ("Remove Styles and Formatting from Text and Tables", "ტექსტიდან და ცხრილებიდან სტილებისა და ფორმატირების მოცილება"), + ("Ruby", "რუბი"), + ("Show Note Anchors in Layout View", "შენიშვნების ღუზების ჩვენება განლაგების ხედში"), + ("Spreads", "გაშლები"), + ("Substituted Fonts", "ჩანაცვლებული შრიფტები"), + ("Transformations are Totals", "გარდაქმნები ჯამურია"), + ("Use Black Point Compensation", "შავი წერტილის კომპენსაციის გამოყენება"), + ("Use Typographer's Quotes", "ტიპოგრაფიული ბრჭყალების გამოყენება"), + ("Whole word", "მთლიანი სიტყვა"), + ("Interface", "ინტერფეისი"), + ("Advanced Type", "ტექსტის დამატებითი პარამეტრები"), + ("Composition", "კომპოზიცია"), + ("Show Font Names in English", "შრიფტების სახელების ინგლისურად ჩვენება"), + ("Missing Glyphs", "გამოტოვებული გლიფები"), + ("Draw Missing Glyphs from Fallback Fonts", "გამოტოვებული გლიფების სათადარიგო შრიფტებით დახატვა"), + ("Units & Increments", "ერთეულები და ბიჯები"), + ("Grids", "ბადეები"), + ("Guides & Pasteboard", "დამხმარე ხაზები და სამუშაო არე"), + ("Dictionary", "ლექსიკონი"), + ("Autocorrect", "ავტოშესწორება"), + ("Story Editor Display", "ტექსტის რედაქტორის ჩვენება"), + ("File Handling", "ფაილებთან მუშაობა"), + ("Formatting affects container (J)", "ფორმატირება ეხება კონტეინერს (J)"), + ("Formatting affects text (J)", "ფორმატირება ეხება ტექსტს (J)"), + ("Apply None", "არცერთი"), + ("Height Only", "მხოლოდ სიმაღლე"), + ("Width Only", "მხოლოდ სიგანე"), + ("Height and Width", "სიმაღლე და სიგანე"), + ("Height only", "მხოლოდ სიმაღლე"), + ("Width only", "მხოლოდ სიგანე"), + ("Height and width", "სიმაღლე და სიგანე"), + ("Version", "ვერსია"), + ("{count} characters don't fit", "არ ეტევა სიმბოლო: {count}"), + ("document", "დოკუმენტი"), + ("story", "ტექსტური ნაკადი"), + ("selection", "მონიშნული"), + ("top", "ზემოთ"), + ("center", "ცენტრში"), + ("bottom", "ქვემოთ"), + ("justify", "ორმხრივი სწორება"), + ("text", "ტექსტი"), + ("graphic", "გრაფიკა"), + ("unassigned", "მიუნიჭებელი"), + ("Perceptual", "აღქმითი"), + ("Relative Colorimetric", "შეფარდებით კოლორიმეტრული"), + ("Saturation", "გაჯერება"), + ("Absolute Colorimetric", "აბსოლუტური კოლორიმეტრული"), + ("No conflicts", "კონფლიქტები არ არის"), + ("Page (1–{count}):", "გვერდი (1–{count}):"), + ("Fonts in Document: {count} Missing: {missing}", "დოკუმენტის შრიფტები: {count} აკლია: {missing}"), + ("No data source", "მონაცემთა წყარო არ არის"), + ("Select Data Source…", "მონაცემთა წყაროს არჩევა…"), + ("Data Merge", "მონაცემთა შერწყმა"), + ("Preview", "წინასწარი ნახვა"), + ("Previous", "წინა"), + ("Next", "შემდეგი"), + ("Record", "ჩანაწერი"), + ("Merge options", "შერწყმის პარამეტრები"), + ("All records", "ყველა ჩანაწერი"), + ("One record", "ერთი ჩანაწერი"), + ("Range", "დიაპაზონი"), + ("Records per page", "ჩანაწერი გვერდზე"), + ("Single record", "ერთი ჩანაწერი"), + ("Multiple records", "რამდენიმე ჩანაწერი"), + ("Rows first", "ჯერ სტრიქონები"), + ("Columns first", "ჯერ სვეტები"), + ("Column spacing", "სვეტებს შორის დაშორება"), + ("Row spacing", "სტრიქონებს შორის დაშორება"), + ("Fit proportionally", "პროპორციულად მორგება"), + ("Fill proportionally", "პროპორციულად შევსება"), + ("Fit content to frame", "შიგთავსის ჩარჩოზე მორგება"), + ("Center in frame", "ჩარჩოში ცენტრირება"), + ("Link images", "გამოსახულებების დაკავშირება"), + ("Limit", "ზღვარი"), + ("Bind to frame", "ჩარჩოსთან მიბმა"), + ("Insert field", "ველის ჩასმა"), + ("Pass csv, rows, or bytes from the control channel.", "გადაეცით csv, rows ან bytes მართვის არხიდან."), + ("Paragraph Rules…", "აბზაცის ხაზები…"), + ("Paragraph Rules", "აბზაცის ხაზები"), + ("Rule Below", "ხაზი ქვემოთ"), + ("Tint:", "ტონირება:"), + ("Presentation", "პრეზენტაცია"), + ("Column", "სვეტი"), + ("Panels", "პანელები"), + ("Next Page", "შემდეგი გვერდი"), + ("Previous Page", "წინა გვერდი"), + ("First Page", "პირველი გვერდი"), + ("Last Page", "ბოლო გვერდი"), + ("Object Styles", "ობიექტის სტილები"), + ("Row", "სტრიქონი"), + ("Rulers", "სახაზავები"), + ("Hide Guides", "დამხმარე ხაზების დამალვა"), + ("Hide Rulers", "სახაზავების დამალვა"), + ("Extras", "დამატებითი ელემენტები"), + ("Colour", "ფერი"), + ("Transparency", "გამჭვირვალობა"), + ("Community", "საზოგადოება"), + ("Join the community", "შემოუერთდით საზოგადოებას"), + ("OK", "OK"), + ("Done", "მზადაა"), + ("Orientation", "ორიენტაცია"), + ("Units", "ერთეულები"), + ("Number of Pages", "გვერდების რაოდენობა"), + ("Start Page #", "საწყისი გვერდის №"), + ("Slug", "საინფორმაციო ზოლი"), + ("Binding", "აკინძვა"), + ("Rotation", "შემობრუნება"), + ("Flex Layout", "მოქნილი განლაგება"), + ("Auto Size", "ავტომატური ზომა"), + ("Kerning", "კერნინგი"), + ("Alignment", "სწორება"), + ("Indent", "შეწევა"), + ("Space Before", "დაშორება წინ"), + ("Space After", "დაშორება შემდეგ"), + ("New Layer", "ახალი შრე"), + ("Colour Picker", "ფერის ამრჩევი"), + ("Find What", "რა მოიძებნოს"), + ("Change To", "რით ჩანაცვლდეს"), + ("Selection", "მონიშნული"), + ("All Documents", "ყველა დოკუმენტი"), + ("On", "ჩართული"), + ("Anchored Object Options", "მიმაგრებული ობიექტის პარამეტრები"), + ("Apply Parent to Pages", "საბაზო გვერდის გამოყენება გვერდებზე"), + ("ArtCraft App Page", "ArtCraft-ის აპლიკაციის გვერდი"), + ("ArtCraft Website", "ArtCraft-ის ვებსაიტი"), + ("Check Spelling", "მართლწერის შემოწმება"), + ("Corner Options", "კუთხეების პარამეტრები"), + ("Create Guides", "დამხმარე ხაზების შექმნა"), + ("Define Cross-Reference Format", "ჯვარედინი მიმართვის ფორმატის განსაზღვრა"), + ("Define Lists", "სიების განსაზღვრა"), + ("Define Text Variable", "ტექსტური ცვლადის განსაზღვრა"), + ("DesignCraft on GitHub", "DesignCraft GitHub-ზე"), + ("DesignCraft Website", "DesignCraft-ის ვებსაიტი"), + ("Document Endnote Options", "დოკუმენტის ბოლო სქოლიოების პარამეტრები"), + ("Document Footnote Options", "დოკუმენტის სქოლიოების პარამეტრები"), + ("Export EPUB", "EPUB ექსპორტი"), + ("Export EPUB (Fixed Layout)", "EPUB ექსპორტი (ფიქსირებული განლაგება)"), + ("Export EPUB (Reflowable)", "EPUB ექსპორტი (ადაპტური განლაგება)"), + ("Export HTML", "HTML ექსპორტი"), + ("Export IDML", "IDML ექსპორტი"), + ("Export Page as PNG", "გვერდის PNG ფაილად ექსპორტირება"), + ("Export PDF (Interactive)", "PDF ექსპორტი (ინტერაქტიური)"), + ("Export Text", "ტექსტის ექსპორტი"), + ("Export XML", "XML ექსპორტი"), + ("Generate Index", "ინდექსის შექმნა"), + ("Global Light", "გლობალური განათება"), + ("Graphic", "გრაფიკა"), + ("Import XML", "XML იმპორტი"), + ("Insert Anchored Object", "მიმაგრებული ობიექტის ჩასმა"), + ("Insert Cross-Reference", "ჯვარედინი მიმართვის ჩასმა"), + ("Insert Math Expression", "მათემატიკური გამოსახულების ჩასმა"), + ("Insert Pages", "გვერდების ჩასმა"), + ("Join the ArtCraft Discord", "შემოუერთდით ArtCraft-ის Discord-ს"), + ("Load DTD", "DTD-ის ჩატვირთვა"), + ("Load Swatches", "ფერის ნიმუშების ჩატვირთვა"), + ("Margins and Columns", "ველები და სვეტები"), + ("Menus", "მენიუები"), + ("Move Pages", "გვერდების გადატანა"), + ("New Condition", "ახალი პირობა"), + ("New Hyperlink", "ახალი ჰიპერბმული"), + ("New Hyperlink Destination", "ჰიპერბმულის ახალი დანიშნულება"), + ("New Page Reference", "გვერდის ახალი მითითება"), + ("New Parent", "ახალი საბაზო გვერდი"), + ("New Stroke Style", "კონტურის ახალი სტილი"), + ("Numbering & Section Options", "დანომვრისა და სექციის პარამეტრები"), + ("Package", "შეფუთვა"), + ("Place with Import Options", "განთავსება იმპორტის პარამეტრებით"), + ("Print Booklet", "ბროშურის დაბეჭდვა"), + ("Quick Apply", "სწრაფი გამოყენება"), + ("Relink", "ხელახლა დაკავშირება"), + ("Relink to Folder", "საქაღალდეში ხელახლა დაკავშირება"), + ("Report an Issue", "ხარვეზის შეტყობინება"), + ("Save a Copy", "ასლის შენახვა"), + ("Save As", "შენახვა როგორც"), + ("Save Swatches for Exchange", "ფერის ნიმუშების შენახვა გასაცვლელად"), + ("Step and Repeat", "ბიჯით გამეორება"), + ("Table of Contents", "სარჩევი"), + ("Type on a Path Options", "ტრაექტორიაზე ტექსტის პარამეტრები"), + ("Edit/Interface Language", "რედაქტირება/ინტერფეისის ენა"), + ("Edit/Spelling", "რედაქტირება/მართლწერა"), + ("Edit/Transparency Blend Space", "რედაქტირება/გამჭვირვალობის შერევის ფერთა სივრცე"), + ("Edit/Transparency Flattener Presets", "რედაქტირება/გამჭვირვალობის გაბრტყელების პრესეტები"), + ("File/Export", "ფაილი/ექსპორტი"), + ("Layout/Index", "განლაგება/ინდექსი"), + ("Layout/Pages", "განლაგება/გვერდები"), + ("Object/Anchored Object", "ობიექტი/მიმაგრებული ობიექტი"), + ("Object/Arrange", "ობიექტი/დალაგება"), + ("Object/Captions", "ობიექტი/წარწერები"), + ("Object/Clipping Path", "ობიექტი/ჩაჭრის ტრაექტორია"), + ("Object/Content", "ობიექტი/შიგთავსი"), + ("Object/Convert Shape", "ობიექტი/ფიგურის გარდაქმნა"), + ("Object/Effects", "ობიექტი/ეფექტები"), + ("Object/Fitting", "ობიექტი/ზომაზე მორგება"), + ("Object/Pathfinder", "ობიექტი/ტრაექტორიების ოპერაციები"), + ("Object/Paths", "ობიექტი/ტრაექტორიები"), + ("Object/Select", "ობიექტი/მონიშვნა"), + ("Object/Transform", "ობიექტი/გარდაქმნა"), + ("Object/Transform Again", "ობიექტი/გარდაქმნის გამეორება"), + ("Table/Convert Cell Type", "ცხრილი/უჯრის ტიპის გარდაქმნა"), + ("Table/Delete", "ცხრილი/წაშლა"), + ("Table/Insert", "ცხრილი/ჩასმა"), + ("Table/Select", "ცხრილი/მონიშვნა"), + ("Type/Bulleted and Numbered Lists", "ტექსტი/ბურთულებიანი და დანომრილი სიები"), + ("Type/Change Case", "ტექსტი/რეგისტრის შეცვლა"), + ("Type/Hyperlinks & Cross-References", "ტექსტი/ჰიპერბმულები და ჯვარედინი მიმართვები"), + ("Type/Insert Break Character", "ტექსტი/გამყოფი სიმბოლოს ჩასმა"), + ("Type/Insert Special Character/Hyphens and Dashes", "ტექსტი/სპეციალური სიმბოლოს ჩასმა/დეფისები და ტირეები"), + ("Type/Insert Special Character/Markers", "ტექსტი/სპეციალური სიმბოლოს ჩასმა/მარკერები"), + ("Type/Insert Special Character/Other", "ტექსტი/სპეციალური სიმბოლოს ჩასმა/სხვა"), + ("Type/Insert Special Character/Quotation Marks", "ტექსტი/სპეციალური სიმბოლოს ჩასმა/ბრჭყალები"), + ("Type/Insert Special Character/Symbols", "ტექსტი/სპეციალური სიმბოლოს ჩასმა/სიმბოლოები"), + ("Type/Insert White Space", "ტექსტი/ჰარის სიმბოლოს ჩასმა"), + ("Type/Story Direction", "ტექსტი/ტექსტის მიმართულება"), + ("Type/Text Variables", "ტექსტი/ტექსტური ცვლადები"), + ("Type/Track Changes", "ტექსტი/ცვლილებების ჩაწერა"), + ("Type/Type on a Path", "ტექსტი/ტექსტი ტრაექტორიაზე"), + ("View/Display Performance", "ხედი/ჩვენების წარმადობა"), + ("View/Extras", "ხედი/დამატებითი ელემენტები"), + ("View/Grids & Guides", "ხედი/ბადეები და დამხმარე ხაზები"), + ("View/Proof Setup", "ხედი/საცდელი ჩვენების პარამეტრები"), + ("View/Rotate Spread", "ხედი/გაშლის შემობრუნება"), + ("View/Screen Mode", "ხედი/ეკრანის რეჟიმი"), + ("View/Separations Preview", "ხედი/სეპარაციის წინასწარი ნახვა"), + ("View/Structure", "ხედი/სტრუქტურა"), + ("Window/Arrange", "ფანჯარა/დალაგება"), + ("Window/Interactive", "ფანჯარა/ინტერაქტიური"), + ("Window/Interface Color Theme", "ფანჯარა/ინტერფეისის ფერთა თემა"), + ("Window/Output", "ფანჯარა/გამოტანა"), + ("Window/Panels", "ფანჯარა/პანელები"), + ("Window/Type & Tables", "ფანჯარა/ტექსტი და ცხრილები"), + ("Window/Utilities", "ფანჯარა/დამხმარე საშუალებები"), + ("90° CCW", "90° საათის ისრის საწინააღმდეგოდ"), + ("90° CW", "90° საათის ისრის მიმართულებით"), + ("a, b, c, d...", "a, b, c, d..."), + ("A, B, C, D...", "A, B, C, D..."), + ("Alpha Channel", "ალფა-არხი"), + ("Black", "შავი"), + ("Bleed:", "ჭრისპირა არე:"), + ("Bottom:", "ქვემოთ:"), + ("Bullet Character", "ბურთულის სიმბოლო"), + ("Clear Rotation", "შემობრუნების გასუფთავება"), + ("Closed Path", "შეკრული ტრაექტორია"), + ("CMYK:", "CMYK:"), + ("Color Blindness – Deuteranopia", "ფერითი სიბრმავე – დეიტერანოპია"), + ("Color Blindness – Protanopia", "ფერითი სიბრმავე – პროტანოპია"), + ("Column Break", "სვეტის გამყოფი"), + ("Columns:", "სვეტები:"), + ("Conditional Text", "პირობითი ტექსტი"), + ("Copyright Symbol", "საავტორო უფლების სიმბოლო"), + ("Current Page Number", "მიმდინარე გვერდის ნომერი"), + ("Cyan", "ცისფერი"), + ("Dark", "ბნელი"), + ("Delete Type from Path", "ტრაექტორიიდან ტექსტის წაშლა"), + ("Detect Edges", "კიდეების ამოცნობა"), + ("Discretionary Hyphen", "პირობითი დეფისი"), + ("Document CMYK", "დოკუმენტის CMYK"), + ("Document RGB", "დოკუმენტის RGB"), + ("Double Left Quotation Marks", "ორმაგი მარცხენა ბრჭყალი"), + ("Double Right Quotation Marks", "ორმაგი მარჯვენა ბრჭყალი"), + ("Ellipsis", "მრავალწერტილი"), + ("Em Dash", "გრძელი ტირე"), + ("Em Space", "გრძელი შორისი"), + ("En Dash", "მოკლე ტირე"), + ("En Space", "მოკლე შორისი"), + ("Figure Space", "ციფრის შორისი"), + ("Flush Space", "შემავსებელი შორისი"), + ("Forced Line Break", "სტრიქონის იძულებითი წყვეტა"), + ("Frame Break", "ჩარჩოს გამყოფი"), + ("Generate Live Caption", "ცოცხალი წარწერის შექმნა"), + ("GREP", "GREP"), + ("Hair Space", "თმისებრი შორისი"), + ("High Contrast", "მაღალი კონტრასტი"), + ("High Resolution", "მაღალი გარჩევადობა"), + ("Horizontal:", "ჰორიზონტალური:"), + ("i, ii, iii, iv...", "i, ii, iii, iv..."), + ("I, II, III, IV...", "I, II, III, IV..."), + ("Indent to Here", "აქამდე შეწევა"), + ("Ink Limit 300%", "საღებავის ზღვარი 300%"), + ("Internet Standard RGB (sRGB)", "ინტერნეტის სტანდარტული RGB (sRGB)"), + ("Left:", "მარცხნივ:"), + ("Legacy Macintosh RGB", "მოძველებული Macintosh RGB"), + ("Light", "ღია"), + ("Low Resolution", "დაბალი გარჩევადობა"), + ("lowercase", "პატარა ასოები"), + ("Magenta", "მეწამული"), + ("Margins:", "ველები:"), + ("Medium Dark", "საშუალოდ ბნელი"), + ("Medium Light", "საშუალოდ ღია"), + ("Medium Resolution", "საშუალო გარჩევადობა"), + ("Monitor RGB", "მონიტორის RGB"), + ("Next Page Number", "შემდეგი გვერდის ნომერი"), + ("Nonbreaking Hyphen", "უწყვეტი დეფისი"), + ("Nonbreaking Space", "უწყვეტი შორისი"), + ("None (keep transparency)", "არცერთი (გამჭვირვალობის შენარჩუნება)"), + ("Open Path", "ღია ტრაექტორია"), + ("Page Break", "გვერდის გამყოფი"), + ("Paragraph Return", "აბზაცის დასასრული"), + ("Paragraph Symbol", "აბზაცის სიმბოლო"), + ("Picas", "პიკები"), + ("Preflight", "ბეჭდვისწინა შემოწმება"), + ("Previous Page Number", "წინა გვერდის ნომერი"), + ("Punctuation Space", "პუნქტუაციის შორისი"), + ("Quarter Space", "მეოთხედი შორისი"), + ("Registered Trademark Symbol", "რეგისტრირებული სავაჭრო ნიშნის სიმბოლო"), + ("RGB:", "RGB:"), + ("Right Indent Tab", "მარჯვენა შეწევის ტაბულაცია"), + ("Right:", "მარჯვნივ:"), + ("Scripts", "სკრიპტები"), + ("Section Marker", "სექციის მარკერი"), + ("Section Symbol", "სექციის ნიშანი"), + ("Sentence case", "წინადადების რეგისტრი"), + ("Simulate Paper Color", "ქაღალდის ფერის იმიტაცია"), + ("Single Left Quotation Mark", "ერთმაგი მარცხენა ბრჭყალი"), + ("Single Right Quotation Mark", "ერთმაგი მარჯვენა ბრჭყალი"), + ("Sixth Space", "მეექვსედი შორისი"), + ("Slug:", "საინფორმაციო ზოლი:"), + ("Smart Guide Options", "ჭკვიანი დამხმარე ხაზების პარამეტრები"), + ("Story Column Direction", "ტექსტური ნაკადის სვეტების მიმართულება"), + ("Straight Double Quotation Marks", "სწორი ორმაგი ბრჭყალები"), + ("Straight Single Quotation Mark (Apostrophe)", "სწორი ერთმაგი ბრჭყალი (აპოსტროფი)"), + ("Subscript:", "ქვედა ინდექსი:"), + ("Superscript:", "ზედა ინდექსი:"), + ("Tab", "ტაბულაცია"), + ("Thin Space", "ვიწრო შორისი"), + ("Third Space", "მესამედი შორისი"), + ("Title Case", "ყოველი სიტყვა მთავრულით"), + ("Top:", "ზემოთ:"), + ("Trademark Symbol", "სავაჭრო ნიშნის სიმბოლო"), + ("Transform Again Individually", "გარდაქმნის ცალ-ცალკე გამეორება"), + ("Transform Sequence Again", "გარდაქმნების მიმდევრობის გამეორება"), + ("Transform Sequence Again Individually", "გარდაქმნების მიმდევრობის ცალ-ცალკე გამეორება"), + ("Unassigned", "მიუნიჭებელი"), + ("URL:", "URL:"), + ("Vertical:", "ვერტიკალური:"), + ("Working CMYK", "სამუშაო CMYK"), + ("Yellow", "ყვითელი"), + ("Close Panel", "პანელის დახურვა"), + ("Distribute Rows Evenly", "სტრიქონების თანაბრად განაწილება"), + ("Don't Save", "არ შეინახო"), + ("Group with", "ერთად დაჯგუფება"), + ("Pin Bar Position", "ზოლის პოზიციის ჩამაგრება"), + ("Reset Bar Position", "ზოლის პოზიციის ჩამოყრა"), + ("Resize panel window", "პანელის ფანჯრის ზომის შეცვლა"), + ("Resize panels", "პანელების ზომის შეცვლა"), + ("Unsaved Changes", "შეუნახავი ცვლილებები"), + ("“{}” has changes that are not saved. Save them before closing?", "„{}“ შეიცავს შეუნახავ ცვლილებებს. შევინახოთ დახურვამდე?"), + ("Even Page Break", "ლუწი გვერდის გამყოფი"), + ("Odd Page Break", "კენტი გვერდის გამყოფი"), + ("Data Fields", "მონაცემთა ველები"), + ("Data Merge Options", "მონაცემთა შერწყმის პარამეტრები"), + ("Drag Rows or Columns", "სტრიქონების ან სვეტების გადატანა"), + ("Insert Field", "ველის ჩასმა"), + ("List Composite Fonts", "შედგენილი შრიფტების სია"), + ("Preview Record", "ჩანაწერის წინასწარი ნახვა"), + ("Remove Data Source", "მონაცემთა წყაროს მოცილება"), + ("Remove Field", "ველის წაშლა"), + ("Set Composite Font", "შედგენილი შრიფტის დაყენება"), + ("Stop Preview", "წინასწარი ნახვის შეწყვეტა"), + ("Update Data Source", "მონაცემთა წყაროს განახლება"), + ("Validate from Root Element", "ძირეული ელემენტიდან შემოწმება"), +]; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 2b13983eb..d1680f91c 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -39,7 +39,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ "app.language", "Interface Language", None, - "{lang: \"\"|de|fr|es|ja|zh|ar|pt-br|it|uk} — menus and panel names (the macOS menu bar follows on the next launch)", + "{lang: \"\"|de|fr|es|ja|zh|ar|pt-br|it|uk|ka} — menus and panel names (the macOS menu bar follows on the next launch)", ), ( "app.flattener", @@ -292,6 +292,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "ui:app.language|Português (Brasil)|{\"lang\": \"pt-br\"}", "ui:app.language|Italiano|{\"lang\": \"it\"}", "ui:app.language|Українська|{\"lang\": \"uk\"}", + "ui:app.language|ქართული|{\"lang\": \"ka\"}", "<", ">Transparency Flattener Presets", "ui:app.flattener|None (keep transparency)|{\"preset\": \"\"}", @@ -2867,6 +2868,29 @@ mod tests { assert_ne!(crate::i18n::tr("it", title), title, "{title}"); } } + + run_ui(&mut app, "app.language", &json!({"lang": "ka"})).unwrap().unwrap(); + assert_eq!(app.ui.language, "ka"); + assert_eq!(checked(&app, "app.language", &json!({"lang": "ka"})), Some(true)); + // Every menu title, item and submenu is translated into Georgian. + let mut untranslated = Vec::new(); + for (title, entries) in menu_tree() { + let mut labels = vec![title.to_string()]; + let mut stack: Vec<&Item> = entries.iter().collect(); + while let Some(entry) = stack.pop() { + match entry { + Item::Sub(label, children) => { + labels.push(label.clone()); + stack.extend(children.iter()); + } + Item::Cmd { label, id, .. } if id != "app.language" => labels.push(label.clone()), + _ => {} + } + } + // Labels without letters ("180°") read the same in every language. + untranslated.extend(labels.into_iter().filter(|l| l.chars().any(char::is_alphabetic) && crate::i18n::tr("ka", l) == l.as_str())); + } + assert!(untranslated.is_empty(), "untranslated Georgian menu labels: {untranslated:?}"); } #[test] @@ -2920,7 +2944,7 @@ mod tests { crate::dialogs::confirm(&mut app).unwrap(); frame(&mut app); assert_eq!(app.session.documents().len(), 1); - for lang in ["uk", "zh", "", "ar", "pt-br", "it"] { + for lang in ["uk", "zh", "", "ar", "pt-br", "it", "ka"] { app.run("app.language", json!({"lang": lang})).unwrap(); frame(&mut app); } diff --git a/docs/localization-parity.md b/docs/localization-parity.md index d36053202..9fdcb8bd8 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first version; catalogs measured) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Georgian interface) · **Target:** Adobe InDesign 2026 (21.6) Per-language status of the DesignCraft interface and of document typesetting in each script. Script-specific typesetting detail: [cjk.md](cjk.md), [cjk-typography.md](cjk-typography.md), @@ -11,7 +11,7 @@ Script-specific typesetting detail: [cjk.md](cjk.md), [cjk-typography.md](cjk-ty ## How it was measured Interface strings live in `crates/ui-egui/src/i18n.rs` (a shared table with German, French, -Spanish, Japanese and Simplified Chinese columns) and `crates/ui-egui/src/i18n/{ar,it,ja,pt_br,uk}.rs`. +Spanish, Japanese and Simplified Chinese columns) and `crates/ui-egui/src/i18n/{ar,it,ja,ka,pt_br,uk}.rs`. Untranslated strings fall back to English. A script (2026-10-10) built the set of English interface strings as the union of every catalog's keys, every engine command label and every literal passed to `tr`/`t` in the UI crate: **1,724 strings**. A further ~145 capitalized literals @@ -49,8 +49,9 @@ string is translated and its script renders and edits correctly; none but Englis |---|---|---|---| | Italian | it | 1,464 (85%) | partial | | Ukrainian | uk | 1,432 (83%), plural categories handled | partial | +| Georgian | ka | 1,724 (~100%: every catalog key plus the command labels the others lack), count captions as `noun: n` | partial (reviewed by a native speaker) | -DesignCraft ships **2** interface languages beyond the twelve; InDesign ships 17 beyond its +DesignCraft ships **3** interface languages beyond the twelve; InDesign ships 17 beyond its eight of the twelve (cs, da, el, en_GB, fi, hu, it, nb, nl, pl, ro, ru, sq, sv, tr, uk, zh_TW). ## Document languages @@ -77,4 +78,5 @@ locale shaping. See [typography-parity.md](typography-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Georgian (`ka`) interface added | | 2026-10-10 | major | Created: catalogs measured (1,724 strings), the twelve key languages plus Italian and Ukrainian | From 5d4ed2a663f8cafb4b507eaa6d7ab7be4ec89eb9 Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze <david.machakhelidze@gmail.com> Date: Wed, 14 Oct 2026 02:41:51 +0400 Subject: [PATCH 214/238] text: Georgian document language; no initial capitals for Georgian Georgian is a Properties text language (IDML "Georgian" maps to ka). Georgian has no initial capitals: Mtavruli is only for all-caps text, so Change Case title and sentence case and index section heads leave Mkhedruli as it is (doc::capitalizes); upper case still gives Mtavruli. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- crates/doc/src/attrs.rs | 9 +++++++++ crates/doc/src/index.rs | 15 ++++++++++++++- crates/engine/src/cmd/edit.rs | 16 ++++++++++++++++ crates/engine/src/cmd/text.rs | 6 ++++-- crates/ui-egui/src/panels/properties.rs | 1 + 5 files changed, 44 insertions(+), 3 deletions(-) diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index 7215c156e..274a79f29 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -190,6 +190,13 @@ impl Digits { } } +/// Does `c` belong to a script that capitalizes the first letter of sentences, words and headings? +/// Georgian doesn't: Unicode maps Mkhedruli to Mtavruli capitals, but those are only for all-caps +/// text, never for a sentence's, a title's or an index heading's first letter. +pub fn capitalizes(c: char) -> bool { + !matches!(c, '\u{10A0}'..='\u{10FF}' | '\u{1C90}'..='\u{1CBF}' | '\u{2D00}'..='\u{2D2F}') +} + /// The BCP 47 tag of a language name as InDesign and IDML write it ("English: USA", "Chinese: /// Simplified", or a locale code such as "de_DE_2006"), for the shaper's localized forms (`locl`) /// and everything that depends on the language (quotes, hyphenation and spelling, line breaking, @@ -272,6 +279,7 @@ pub fn language_tag(language: &str) -> Option<&'static str> { "persian" | "farsi" => "fa", "urdu" => "ur", "hebrew" => "he", + "georgian" => "ka", _ => return None, }) } @@ -1004,6 +1012,7 @@ mod tests { assert_eq!(language_tag("English: USA"), Some("en-US")); assert_eq!(language_tag("English: Australian"), Some("en")); assert_eq!(language_tag("Turkish"), Some("tr")); + assert_eq!(language_tag("Georgian"), Some("ka")); assert_eq!(language_tag("Japanese"), Some("ja")); assert_eq!(language_tag("Korean"), Some("ko")); assert_eq!(language_tag("Chinese"), Some("zh")); diff --git a/crates/doc/src/index.rs b/crates/doc/src/index.rs index 0c2e58f4d..c885b7da1 100644 --- a/crates/doc/src/index.rs +++ b/crates/doc/src/index.rs @@ -137,7 +137,12 @@ pub fn build(spec: &IndexSpec, refs: &[Resolved], page_name: &dyn Fn(usize) -> S let mut heading: Option<char> = None; for (key, n) in &root { if spec.section_headings { - let c = key.chars().next().map(|c| if c.is_alphabetic() { c.to_uppercase().next().unwrap_or(c) } else { '#' }); + // Georgian headings stay Mkhedruli (no capitals; Mtavruli is only for all-caps text). + let c = key.chars().next().map(|c| match c { + c if !c.is_alphabetic() => '#', + c if crate::capitalizes(c) => c.to_uppercase().next().unwrap_or(c), + c => c, + }); if c != heading { heading = c; if let Some(c) = c { @@ -243,4 +248,12 @@ mod tests { let run_in = build(&IndexSpec { run_in: true, section_headings: false, title: String::new(), ..spec }, &refs[..2], &|p| (p + 1).to_string()); assert_eq!(run_in, [("Index Level 1".to_string(), "Typography 2\u{2013}4: kerning 5".to_string())]); } + #[test] + fn georgian_section_heads_stay_mkhedruli() { + let refs = vec![r(&["ასო"], IndexRange::CurrentPage, Some((0, 0))), r(&["ბადე"], IndexRange::CurrentPage, Some((1, 1)))]; + let out = build(&IndexSpec::default(), &refs, &|p| (p + 1).to_string()); + let heads: Vec<&str> = out.iter().filter(|(s, _)| s == "Index Section Head").map(|(_, t)| t.as_str()).collect(); + assert_eq!(heads, ["ა", "ბ"]); + assert!(crate::capitalizes('a') && !crate::capitalizes('ა') && !crate::capitalizes('Ა')); + } } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index e3c985406..01263478a 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -649,6 +649,22 @@ mod text_clipboard_tests { assert_eq!(s.doc().unwrap().selection.text.unwrap().range(), 0..st.len()); st.check().unwrap(); } + + #[test] + fn change_case_leaves_georgian_without_initial_capitals() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = frame(&mut s, "ᲒᲐᲛᲐᲠᲯᲝᲑᲐ ᲛᲡᲝᲤᲚᲘᲝ. ᲐᲮᲐᲚᲘ word"); + let len = s.doc().unwrap().doc.story(a).unwrap().len(); + s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": len})).unwrap(); + // Georgian has no title or sentence capitals: Mtavruli is only for all-caps text. + s.execute("type.changeCase", &json!({"case": "title"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(a).unwrap().text, "გამარჯობა მსოფლიო. ახალი Word"); + s.execute("type.changeCase", &json!({"case": "sentence"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(a).unwrap().text, "გამარჯობა მსოფლიო. ახალი word"); + s.execute("type.changeCase", &json!({"case": "upper"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(a).unwrap().text, "ᲒᲐᲛᲐᲠᲯᲝᲑᲐ ᲛᲡᲝᲤᲚᲘᲝ. ᲐᲮᲐᲚᲘ WORD"); + } } fn paste_into(s: &mut Session, p: &Value) -> Result<Value> { diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index add94aca9..d7e8b0c88 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -1436,19 +1436,21 @@ pub(crate) fn case_map(src: &str, case: &str) -> Vec<String> { let word_start = !(prev.is_alphanumeric() || matches!(prev, '\'' | '\u{2019}')); let up = |c: char| c.to_uppercase().collect::<String>(); let low = |c: char| c.to_lowercase().collect::<String>(); + // Title and sentence case capitalize only scripts with initial capitals (not Georgian). + let cap = |c: char| if designcraft_doc::capitalizes(c) { up(c) } else { low(c) }; let m = match case { "upper" => up(c), "lower" => low(c), "title" => { if word_start { - up(c) + cap(c) } else { low(c) } } _ => { if sentence_start && c.is_alphanumeric() { - up(c) + cap(c) } else { low(c) } diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 358ec76d3..c2d17370f 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1257,6 +1257,7 @@ pub(crate) const LANGUAGES: &[&str] = &[ "Greek", "Russian", "Ukrainian", + "Georgian", "Turkish", "Japanese", "Chinese", From 44b83aa6a300c97dc238d5201b04359c7f18b0ae Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze <david.machakhelidze@gmail.com> Date: Wed, 14 Oct 2026 20:55:53 +0400 Subject: [PATCH 215/238] i18n(ka): catalog follows the reviewed translations Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- crates/ui-egui/src/i18n/ka.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 10fb84d3e..bf0e67201 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -403,6 +403,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Stroke (X)", "კონტური (X)"), ("Apply None (/)", "არცერთი (/)"), ("Default Fill and Stroke", "ნაგულისხმევი შევსება და კონტური"), + ("Formatting Affects Text", "ფორმატირება ეხება ტექსტს"), ("Swap Fill and Stroke", "შევსებისა და კონტურის გაცვლა"), ("Toggle Fill and Stroke", "შევსებისა და კონტურის გადართვა"), ("Reverse the gradient", "გრადიენტის შებრუნება"), @@ -949,7 +950,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ "Select a placed video or sound (File › Place a .mp4, .mov, .mp3, .wav …).", "მონიშნეთ განთავსებული ვიდეო ან ხმა (ფაილი › განთავსება: .mp4, .mov, .mp3, .wav …).", ), - ("Select an object to color its fill or stroke.", "მონიშნეთ ობიექტი მისი შევსების ან კონტურის ფერის შესაცვლელად."), + ("Select an object or text to color its fill or stroke.", "მონიშნეთ ობიექტი ან ტექსტი მისი შევსების ან კონტურის ფერის შესაცვლელად."), ("Select an object to give its fill a gradient.", "მონიშნეთ ობიექტი მისი შევსებისთვის გრადიენტის მისაცემად."), ("Select an object to make it a button.", "მონიშნეთ ობიექტი მის ღილაკად გადასაქცევად."), ( @@ -1010,6 +1011,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cap Height", "მთავრული ასოს სიმაღლე"), ("Leading", "სტრიქონთაშორისი ინტერვალი"), ("x Height", "x-სიმაღლე"), + ("Em Box Height", "em-კვადრატის სიმაღლე"), ("Fixed", "ფიქსირებული"), ("Never (continuous)", "არასოდეს (უწყვეტი)"), ("Page", "გვერდი"), @@ -1035,6 +1037,14 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("forcedLineBreak", "სტრიქონის იძულებითი გადატანა"), ("emSpace", "გრძელი შორისი"), ("enSpace", "მოკლე შორისი"), + ("dropcap", "ინიციალი"), + ("Drop Caps", "ინიციალები"), + ("Lines:", "სტრიქონები:"), + ("Characters:", "სიმბოლოები:"), + ("Drop Lines", "ინიციალის სტრიქონები"), + ("Drop Chars", "ინიციალის სიმბოლოები"), + ("Align Left Edge", "მარცხენა კიდის სწორება"), + ("Scale for Descenders", "მასშტაბირება ქვედა გამონაშვერების მიხედვით"), ("chars", "სიმბოლო"), ("[Automatic]", "[ავტომატური]"), ("Tracking:", "ტრეკინგი:"), From 8648cb0020c18ad7f267787057c5f3ee6c30619b Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Wed, 14 Oct 2026 17:08:35 +0000 Subject: [PATCH 216/238] Place: a dropped file lands on the spread under the pointer (#931) A file dropped on the window is placed on the spread under the pointer, or the spread under the view centre when the pointer isn't over the canvas, a parent spread included while parents are being edited: a graphic's top left goes to that point, text into a new frame on the document page there. Before, every dropped file went to the first spread. --- crates/ui-egui/src/canvas.rs | 10 ++++++++ crates/ui-egui/src/lib.rs | 47 ++++++++++++++++++++++++++++++++---- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 5d8218b77..0564d1b74 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -150,6 +150,16 @@ pub fn view_centre(app: &DesignApp) -> Option<(SpreadRef, Point)> { Some((slot.spread, slot.to_spread(c))) } +/// Where a file dropped on the window lands: the spread under the pointer, or under the view +/// centre when the pointer isn't over the canvas, and that point in spread coordinates. +pub fn drop_point(app: &DesignApp, screen: Option<Pos2>) -> Option<(SpreadRef, Point)> { + let rect = app.canvas_rect?; + let Some(pos) = screen.filter(|p| rect.contains(*p)) else { return view_centre(app) }; + let st = app.session.active()?; + let layout = CanvasLayout::new(&st.doc, st.editing_parents); + layout.spread_at(Xf::new(rect, app.view()?).to_canvas(pos)) +} + /// Absolute page index under the view centre. pub fn current_page(app: &DesignApp) -> Option<usize> { let st = app.session.active()?; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 22f35801f..928ff210e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -934,10 +934,11 @@ impl DesignApp { menus::shortcuts(self, ctx); } #[cfg(not(target_arch = "wasm32"))] - for f in ctx.input(|i| i.raw.dropped_files.clone()) { + let (dropped, pointer) = ctx.input(|i| (i.raw.dropped_files.clone(), i.pointer.hover_pos())); + for f in dropped { let p = f.path().to_string_lossy().to_string(); if !p.is_empty() { - let _ = self.open_dropped(&p); + let _ = self.open_dropped_at(&p, pointer); } } self.handle_quit(ctx); @@ -1087,10 +1088,29 @@ impl DesignApp { } } - /// A file dropped on the window: documents open, anything else is placed. + /// A file dropped on the window: documents open, anything else is placed on the spread in + /// view (a parent spread while parents are being edited). pub fn open_dropped(&mut self, path: &str) -> Result<Value, String> { - let cmd = if opens_as_document(path) { "file.open" } else { "file.place" }; - self.open_file(cmd, json!({"path": path})) + self.open_dropped_at(path, None) + } + + /// [`Self::open_dropped`] at the pointer: a graphic's top left lands where the file was + /// dropped on the canvas, text goes to a new frame on the page there. + pub fn open_dropped_at(&mut self, path: &str, pointer: Option<egui::Pos2>) -> Result<Value, String> { + if opens_as_document(path) { + return self.open_file("file.open", json!({"path": path})); + } + let mut params = json!({"path": path}); + if let (Some((sr, at)), Some(o)) = (canvas::drop_point(self, pointer), params.as_object_mut()) { + o.insert("spread".into(), serde_json::to_value(sr).unwrap_or(Value::Null)); + o.insert("x".into(), json!(at.x)); + o.insert("y".into(), json!(at.y)); + if let (designcraft_doc::SpreadRef::Doc(si), Some(st)) = (sr, self.session.active()) { + let pi = st.doc.spreads.get(si).and_then(|s| s.page_at_x(at.x)).unwrap_or(0); + o.insert("page".into(), json!(st.doc.first_page_of_spread(si) + pi + 1)); + } + } + self.open_file("file.place", params) } /// File › Place: the file fills the selected empty or graphic frame; with none, an image loads @@ -2035,6 +2055,23 @@ mod tests { assert_eq!(alert(&app), ("Can't Place the File".to_string(), e)); } + #[test] + fn a_file_dropped_while_a_parent_is_open_lands_on_the_parent() { + let mut app = DesignApp::new(Session::new(), Services::default()); + app.run("file.new", json!({"pages": 3})).unwrap(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))); + app.run("layout.parents.edit", json!({"on": true})).unwrap(); + let _ = app.view_mut(); + let path = std::env::temp_dir().join(format!("designcraft-ui-{}-drop.png", std::process::id())); + std::fs::write(&path, designcraft_render::Rendered { width: 10, height: 10, pixels: vec![200; 400] }.to_png()).unwrap(); + let r = app.open_dropped(&path.to_string_lossy()); + let _ = std::fs::remove_file(&path); + r.unwrap(); + let d = &app.session.doc().unwrap().doc; + assert_eq!(d.parents[0].items.len(), 1, "the graphic is on the parent spread"); + assert!(d.spreads.iter().all(|s| s.items.is_empty()), "no document page gets it"); + } + #[test] fn file_open_failures_show_an_alert() { let path = unreadable_file("open.designcraft"); From f1118d826e4d3bd9cdc5bcafa3e41f8a42abf256 Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Wed, 14 Oct 2026 17:15:50 +0000 Subject: [PATCH 217/238] Place: the dropped-file loop stays out of the web build --- crates/ui-egui/src/lib.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 928ff210e..c1ca016bd 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -934,11 +934,13 @@ impl DesignApp { menus::shortcuts(self, ctx); } #[cfg(not(target_arch = "wasm32"))] - let (dropped, pointer) = ctx.input(|i| (i.raw.dropped_files.clone(), i.pointer.hover_pos())); - for f in dropped { - let p = f.path().to_string_lossy().to_string(); - if !p.is_empty() { - let _ = self.open_dropped_at(&p, pointer); + { + let (dropped, pointer) = ctx.input(|i| (i.raw.dropped_files.clone(), i.pointer.hover_pos())); + for f in dropped { + let p = f.path().to_string_lossy().to_string(); + if !p.is_empty() { + let _ = self.open_dropped_at(&p, pointer); + } } } self.handle_quit(ctx); From 11b1566c099aff00974010a756bc8204c240a106 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Wed, 14 Oct 2026 19:22:54 +0200 Subject: [PATCH 218/238] Type tool: a press over a locked empty frame draws a new text frame instead of converting it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adobe's "Set up a modern magazine layout" (Brian Wood, 2:12–4:23) locks a page frame drawn round the spread (Cmd+L), then draws the text frames on top of it with the Type tool. In DesignCraft the press landed in the locked frame, which the Type tool turned into a text frame, putting the caret in it; no new frame was drawn. - The Type tool takes a hit only on text frames (locked or not, as InDesign lets you edit their text) and unlocked empty frames; over a locked empty frame, the press starts a new text frame. - text.placeCaret refuses to convert a locked empty frame (or one on a locked layer). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 1 + crates/engine/src/cmd/text.rs | 9 +++++++-- crates/engine/src/tests.rs | 35 +++++++++++++++++++++++++++++++++++ crates/tools/src/text.rs | 3 +++ 4 files changed, 46 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9f8168e50..1302df7c2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-14** · The Type tool draws a new text frame over a locked empty frame, as InDesign does, instead of turning the locked frame into a text frame. No readiness percentage change. - **2026-10-13** · Object › Fitting has InDesign's shortcuts: Fill Frame Proportionally ⌘⌥⇧C, Fit Content Proportionally ⌘⌥⇧E, Fit Frame to Content ⌘⌥C, Fit Content to Frame ⌘⌥E, Center Content ⌘⇧E. No readiness percentage change. - **2026-10-13** · Gradients between process colours blend as inks, as InDesign shows them: magenta to cyan passes through C 50 M 50 (a light lilac) on screen instead of the darker RGB mix, and a midpoint between CMYK or grey stops no longer turns the PDF shading RGB. No readiness percentage change. - **2026-10-14** · The Swatches panel has InDesign's header: a Fill/Stroke proxy, Formatting Affects Container/Text and the Tint of the colour in front (a tint alone keeps each object's own swatch). Clicking a swatch applies it to the fill or stroke in front (of the text, when formatting affects text), and the bottom row has New Color Group, New Swatch and Delete Swatch. No readiness percentage change. diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index add94aca9..990a98db7 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -420,8 +420,13 @@ fn line_at(cs: &compose::ComposedStory, pos: usize) -> Option<(usize, usize)> { fn place(s: &mut Session, p: &Value, extend: bool) -> Result<Value> { let frame = id_param(p, "frame").ok_or_else(|| bad("text.placeCaret", "missing frame"))?; let pt = point_param(p, "point").unwrap_or(Point::ZERO); - // Empty frames become text frames. - let needs_convert = s.doc()?.doc.item(frame).is_some_and(|i| matches!(i.content, Content::Unassigned)); + // Empty frames become text frames; a locked one stays as it is. + let st = s.doc()?; + let locked = st.doc.item(frame).is_some_and(|i| i.locked || st.doc.layer(i.layer).is_some_and(|l| l.locked)); + let needs_convert = st.doc.item(frame).is_some_and(|i| matches!(i.content, Content::Unassigned)); + if needs_convert && locked { + return Err(bad("text.placeCaret", "the frame is locked")); + } if needs_convert { s.execute("object.content", &json!({"ids": [frame.0], "type": "text"}))?; } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 712dc06bc..a025a5385 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -877,3 +877,38 @@ fn duplicating_a_page_to_an_index_is_one_undo_step() { assert!(s.execute("layout.pages.duplicateSpread", &json!({"spread": 0, "to": 4})).is_err()); assert_eq!(widths(&s), [300.0, 400.0, 500.0]); } + +#[test] +fn the_type_tool_draws_a_text_frame_over_a_locked_frame() { + // Adobe's "Set up a modern magazine layout" (4:03): with the page frame locked (Cmd+L), the + // Type tool draws text frames on top of it; the locked frame stays an empty frame. + use designcraft_tools::{PointerEvent, PointerKind, SnapView}; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 612, "height": 792, "facingPages": false})).unwrap(); + let big = s.execute("frame.create", &json!({"rect": [36, 36, 576, 756], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.stroke", &json!({"ids": [big], "swatch": "[Black]", "weight": 6})).unwrap(); + s.execute("object.lock", &json!({"ids": [big]})).unwrap(); + s.execute("edit.deselectAll", &json!({})).unwrap(); + s.set_tool("type"); + let v = ViewInfo { snap: SnapView::OFF, ..ViewInfo::at_zoom(1.0) }; + let layout = s.layout(); + let at = |x: f64, y: f64| layout.to_canvas(designcraft_doc::SpreadRef::Doc(0), designcraft_geom::Point::new(x, y)); + let (a, b) = (at(100.0, 100.0), at(300.0, 200.0)); + s.pointer(&PointerEvent::new(PointerKind::Down, a.x, a.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 200.0 + a.x - 100.0, a.y + 50.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, b.x, b.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, b.x, b.y), v).unwrap(); + let d = &s.doc().unwrap().doc; + let it = d.item(designcraft_doc::ItemId(big)).unwrap(); + assert!(matches!(it.content, designcraft_doc::Content::Unassigned), "the locked frame stays empty"); + let frames: Vec<_> = d.spreads[0].items.iter().filter(|i| i.is_text_frame()).collect(); + assert_eq!(frames.len(), 1, "a new text frame"); + let r = frames[0].bounds(); + assert!((r.x0 - 100.0).abs() < 1e-6 && (r.y1 - 200.0).abs() < 1e-6, "{r:?}"); + // The engine refuses to turn a locked empty frame into a text frame. + assert!(s.execute("text.placeCaret", &json!({"frame": big, "point": [300, 400]})).is_err()); + // Unlocked, a press inside converts it, as before. + s.execute("object.unlockAll", &json!({})).unwrap(); + s.execute("text.placeCaret", &json!({"frame": big, "point": [300, 400]})).unwrap(); + assert!(s.doc().unwrap().doc.item(designcraft_doc::ItemId(big)).unwrap().is_text_frame()); +} diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index e6ef1a5fe..e2fe043de 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -50,9 +50,12 @@ impl Tool for TypeTool { self.selecting = None; self.cell_drag = None; self.unit_selected = false; + // Text frames take the caret (locked ones too, as in InDesign); an empty frame + // becomes one, unless it's locked: then the press draws a new text frame over it. if let Some((_, id)) = cx.hit(ev.pos) && let Some(it) = cx.doc.item(id) && !matches!(it.content, designcraft_doc::Content::Graphic(_) | designcraft_doc::Content::Group { .. }) + && (it.is_text_frame() || !it.locked) { let sp = cx.layout.spread_at(ev.pos).map(|(_, p)| p).unwrap_or(ev.pos); let clicks = ev.click_count(); From 149eaf3095bf43040535f44fcbd577152276775a Mon Sep 17 00:00:00 2001 From: Govinda Roy <govindaroy.ofc94@gmail.com> Date: Wed, 14 Oct 2026 19:52:22 +0600 Subject: [PATCH 219/238] Layers panel: double-click an object row to rename it The object's name turns into a field: Enter or a click elsewhere renames it (object.rename), Esc leaves it as it was, and renaming to nothing goes back to the automatic label. Layer rows keep double-click for Layer Options (#416). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/panels/layers.rs | 101 +++++++++++++++++++++++++--- 1 file changed, 93 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 8f5ef35a4..143be8117 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -230,19 +230,37 @@ pub fn show(app: &mut DesignApp, ui: &mut Ui) { } let (eye, lock) = eye_lock(ui, &app.ui.language, row, !it.hidden, it.locked, !l.visible, ("item", it.id.0)); let name = item_label(&doc, it); - let clip = Rect::from_min_max(row.min, pos2(row.max.x - 26.0, row.max.y)); - ui.painter().with_clip_rect(clip).text( - row.min + vec2(2.0 * COL_W + 30.0, ROW_H / 2.0), - egui::Align2::LEFT_CENTER, - &name, - egui::FontId::proportional(11.0), - if it.hidden { t.text_dim } else { t.text }, - ); + let field = Rect::from_min_max(row.min + vec2(2.0 * COL_W + 28.0, 3.0), pos2(row.max.x - 28.0, row.max.y - 3.0)); + let renaming = match rename_field(ui, field, it.id.0) { + // An empty name goes back to the automatic label. + Some(Some(new)) => { + if new != it.name { + let _ = app.run("object.rename", json!({"id": it.id.0, "name": new})); + } + false + } + Some(None) => false, + None => ui.data(|d| d.get_temp::<(RenameKey, String)>(egui::Id::new(RENAME))).is_some_and(|(k, _)| k == it.id.0), + }; + if !renaming { + let clip = Rect::from_min_max(row.min, pos2(row.max.x - 26.0, row.max.y)); + ui.painter().with_clip_rect(clip).text( + row.min + vec2(2.0 * COL_W + 30.0, ROW_H / 2.0), + egui::Align2::LEFT_CENTER, + &name, + egui::FontId::proportional(11.0), + if it.hidden { t.text_dim } else { t.text }, + ); + } let sq = selection_square(ui, &app.ui.language, row, lc, is_sel, true, ("item", it.id.0)); if eye { let _ = app.run("object.setFlags", json!({"ids": [it.id.0], "hidden": !it.hidden})); } else if lock { let _ = app.run("object.setFlags", json!({"ids": [it.id.0], "locked": !it.locked})); + } else if resp.double_clicked() { + // Its own name, or the label it shows without the brackets. + let current = if it.name.is_empty() { name.trim_start_matches('<').trim_end_matches('>').to_string() } else { it.name.clone() }; + start_rename(ui, it.id.0, ¤t); } else if sq || resp.clicked() { let add = ui.input(|i| i.modifiers.shift || i.modifiers.command); let _ = app.run("selection.set", json!({"ids": [it.id.0], "add": add})); @@ -273,6 +291,45 @@ pub fn show(app: &mut DesignApp, ui: &mut Ui) { }); } +/// The object whose row is being renamed. +type RenameKey = u64; +const RENAME: &str = "layers_rename"; + +/// The name field of the row being renamed (double-click), in `r`: `Some(Some(name))` when Enter +/// or a click elsewhere confirms it, `Some(None)` when Esc cancels, `None` while typing or when +/// this row isn't being renamed. +fn rename_field(ui: &mut Ui, r: Rect, key: RenameKey) -> Option<Option<String>> { + let id = egui::Id::new(RENAME); + let (k, mut text): (RenameKey, String) = ui.data(|d| d.get_temp(id))?; + if k != key { + return None; + } + let edit = ui.put(r, egui::TextEdit::singleline(&mut text).font(egui::FontId::proportional(11.0)).margin(vec2(2.0, 1.0))); + if !edit.has_focus() && !edit.lost_focus() { + edit.request_focus(); + } + let done = if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + Some(None) + } else if edit.lost_focus() { + Some(Some(text.trim().to_string())) + } else { + None + }; + ui.data_mut(|d| { + if done.is_some() { + d.remove::<(RenameKey, String)>(id); + } else { + d.insert_temp(id, (key, text)); + } + }); + done +} + +/// Start renaming row `key`, its field showing `name`. +fn start_rename(ui: &Ui, key: RenameKey, name: &str) { + ui.data_mut(|d| d.insert_temp(egui::Id::new(RENAME), (key, name.to_string()))); +} + #[cfg(test)] mod tests { use super::*; @@ -313,4 +370,32 @@ mod tests { h.run_steps(2); assert!(!open(&h)); } + + #[test] + fn rows_rename_in_place() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 200, 200]})).unwrap()["id"].as_u64().unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(260.0, 400.0)).build_ui_state(|ui, app: &mut DesignApp| show(app, ui), app); + h.run_steps(2); + let rename = |h: &mut Harness<'_, DesignApp>, key: RenameKey, typed: &str| { + // As a double-click starts it (with an emptied field here, so the typing is the name). + h.ctx.data_mut(|d| d.insert_temp(egui::Id::new(RENAME), (key, String::new()))); + h.run_steps(2); + h.get_by_role(egui::accesskit::Role::TextInput).type_text(typed); + h.run_steps(1); + h.key_press(egui::Key::Enter); + h.run_steps(2); + }; + rename(&mut h, id, "Hero bottle"); + assert_eq!(h.state().session.active().unwrap().doc.item(ItemId(id)).unwrap().name, "Hero bottle"); + // Esc leaves the name as it was. + h.ctx.data_mut(|d| d.insert_temp(egui::Id::new(RENAME), (id, String::new()))); + h.run_steps(2); + h.get_by_role(egui::accesskit::Role::TextInput).type_text("Oops"); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert_eq!(h.state().session.active().unwrap().doc.item(ItemId(id)).unwrap().name, "Hero bottle"); + } } From d3309401f5a5d412023fc245c31caa802b6f74cb Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Wed, 14 Oct 2026 20:30:21 +0200 Subject: [PATCH 220/238] Show 100% black as rich black on screen by default, as InDesign does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InDesign's Preferences › Appearance of Black defaults to Display All Blacks as Rich Black on screen, so [Black] text and strokes look pure black (RGB 0,0,0), as in Adobe's magazine-layout tutorial. DesignCraft defaulted to Display All Blacks Accurately (#343334). Settings saved with an explicit choice keep it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 1 + crates/ui-egui/src/lib.rs | 16 ++++++++++++++-- docs/target-app-parity.md | 2 +- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9f8168e50..1933e6da1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-14** · 100% black shows as pure black on screen by default, as InDesign's Appearance of Black does out of the box (Display All Blacks as Rich Black); Preferences › Appearance of Black › Display All Blacks Accurately still shows it as the dark grey it prints, and a saved choice is kept. No readiness percentage change. - **2026-10-13** · Object › Fitting has InDesign's shortcuts: Fill Frame Proportionally ⌘⌥⇧C, Fit Content Proportionally ⌘⌥⇧E, Fit Frame to Content ⌘⌥C, Fit Content to Frame ⌘⌥E, Center Content ⌘⇧E. No readiness percentage change. - **2026-10-13** · Gradients between process colours blend as inks, as InDesign shows them: magenta to cyan passes through C 50 M 50 (a light lilac) on screen instead of the darker RGB mix, and a midpoint between CMYK or grey stops no longer turns the PDF shading RGB. No readiness percentage change. - **2026-10-14** · The Swatches panel has InDesign's header: a Fill/Stroke proxy, Formatting Affects Container/Text and the Tint of the colour in front (a tint alone keeps each object's own swatch). Clicking a swatch applies it to the fill or stroke in front (of the text, when formatting affects text), and the bottom row has New Color Group, New Swatch and Delete Swatch. No readiness percentage change. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4b5c63f98..6d712d0a8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -214,7 +214,8 @@ pub struct UiState { pub display_quality: designcraft_render::DisplayQuality, /// Preferences › Interface › UI scaling (1 = 100%). pub ui_scale: f32, - /// Preferences › Appearance of Black: 100% K on screen as rich black. + /// Preferences › Appearance of Black: 100% K on screen as rich black (InDesign's default, + /// Display All Blacks as Rich Black; off: accurately, as dark grey). pub rich_black: bool, /// View › Overprint Preview. pub overprint_preview: bool, @@ -334,7 +335,7 @@ impl Default for UiState { ink_limit: None, display_quality: designcraft_render::DisplayQuality::High, ui_scale: 1.0, - rich_black: false, + rich_black: true, overprint_preview: false, shortcuts: Default::default(), control_bar: false, @@ -1414,6 +1415,17 @@ pub fn drop_key_name_text(raw: &mut egui::RawInput, text_field_focused: bool) { mod tests { use super::*; + /// InDesign shows 100% K as pure black on screen unless Appearance of Black says otherwise + /// (Display All Blacks as Rich Black is its default); settings saved without the field get it too. + #[test] + fn black_shows_as_rich_black_by_default() { + assert!(UiState::default().rich_black); + let saved: UiState = serde_json::from_str("{}").unwrap(); + assert!(saved.rich_black); + let accurate: UiState = serde_json::from_str(r#"{"richBlack": false}"#).unwrap(); + assert!(!accurate.rich_black, "a saved choice is kept"); + } + /// 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()); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d4a97fad6..84fb20b12 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -396,7 +396,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Define Lists: named numbered lists that continue across stories in page order (`list.define`, paragraph `listName` / `startAt`), round-tripped through IDML; `null` in `type.para` / `type.char` now removes an override. - View › Overprint Preview (⌥⇧⌘Y): overprinting fills, strokes and 100% [Black] text mix with what's beneath on screen. - Type on a Path (⇧T tool, `type.onPath`): a story along any line, curve or shape on screen and in PDF; Type on a Path Options (start, flip, baseline/ascender/descender/centre) and Delete Type from Path. Round-trips through IDML as TextPath; on-path caret drawing is still to come. -- Preferences › Appearance of Black: 100% K on screen accurately (dark grey) or as rich black (`window.richBlack`); the printing/export side is still to come. +- Preferences › Appearance of Black: 100% K on screen as rich black (InDesign's default) or accurately as dark grey (`window.richBlack`); the printing/export side is still to come. - Spreads of up to 10 pages: Add to Previous Spread and Allow Spread to Shuffle (Pages panel; `layout.pages.toSpread`, `layout.spreadShuffle`); inserted pages go around kept spreads. - Page tool (⇧P) and `layout.pageSize`: pages of their own size (or a preset); the spine stays put and objects keep their place on their page. A click selects a page (handles, its liquid page rule tagged above it); dragging a handle previews the liquid rule and springs back, Alt-drag resizes, Shift keeps proportions, with a W/H readout; `anchor` keeps a reference point in place; double-click for exact sizes. With the Page tool the Control panel shows the page (reference point, W/H, preset, orientation, Liquid Page Rule including Controlled by Parent, Show Parent Page Overlay); X/Y and Objects Move with Page are shown, but pages don't move within their spread yet. On an object-based page it selects objects and shows their pins and width/height locks on the canvas (click to toggle, with tooltips). - Page tool (⇧P) and `layout.pageSize`: pages of their own size (or a preset); the spine stays put and objects keep their place on their page. A click selects a page (handles, its liquid page rule tagged above it); dragging a handle previews the liquid rule and springs back, Alt-drag resizes, Shift keeps proportions, with a W/H readout; `anchor` keeps a reference point in place; double-click for exact sizes. With the Page tool the Control panel shows the page (reference point, W/H, preset, orientation, Liquid Page Rule, Show Parent Page Overlay); X/Y are shown, but pages don't move within their spread yet. From 4516989e51b7f688e3f7e7dbff4a1ba0e354109a Mon Sep 17 00:00:00 2001 From: zydvc26s67-web <zydvc26s67@privaterelay.appleid.com> Date: Wed, 14 Oct 2026 12:05:35 -0700 Subject: [PATCH 221/238] Type: let a blank line's hidden paragraph mark take leading immediately MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting an empty paragraph (a blank line) puts a collapsed caret on its hidden paragraph mark, the only character on that line. Previously type.char either deferred the formatting to the next typed character (in a non-empty story) or dropped it on the floor for composition, so there was no way to change a blank line's height — matching InDesign's ability to select the pilcrow and adjust leading. - engine/cmd/text.rs: recognize an empty paragraph's own caret position (empty_para_mark) and apply character attributes straight to that byte's run instead of deferring to typing format. - compose/lib.rs: an empty paragraph has no glyphs of its own, so resolve the hidden mark's run (if overridden) for the paragraph's base leading instead of always falling back to the paragraph's plain default. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/compose/src/lib.rs | 14 ++++++-- crates/engine/src/cmd/text.rs | 61 ++++++++++++++++++++++++++++++----- 2 files changed, 65 insertions(+), 10 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index c6312e9f5..a62ce4c30 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -688,9 +688,19 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C &[], (drop_end, &pp.drop_cap_style), ); + // An empty paragraph has no glyphs of its own, so its only addressable character is its + // hidden paragraph mark (the `\n` just past `prange.end`). A leading/size override applied + // there (e.g. selecting the pilcrow to adjust line height) still needs to set the line's + // height, so resolve the mark's own run instead of the paragraph's plain default when present. + let mark_chars = prange + .is_empty() + .then(|| story.runs().find(|(r, _)| r.contains(&prange.end))) + .flatten() + .map(|(_, fmt)| doc.styles.resolve_char(&base_chars, fmt)); + let leading_chars = mark_chars.as_ref().unwrap_or(&base_chars); // Drop cap: the size and indent come from the text beside it (`base_*` when there's none). - let base_leading = match base_chars.leading { - designcraft_doc::Leading::Auto => base_chars.size * pp.auto_leading, + let base_leading = match leading_chars.leading { + designcraft_doc::Leading::Auto => leading_chars.size * pp.auto_leading, designcraft_doc::Leading::Points(v) => v, }; let base_cap = { diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index add94aca9..d07c1cd4c 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -1031,6 +1031,14 @@ pub(crate) fn validate_char_fill(document: &designcraft_doc::Document, attrs: &C Ok(()) } +/// Byte `pos` is the hidden paragraph mark of an *empty* paragraph: the only character on that +/// line, and so the one a line-affecting attribute like leading can land on right away instead of +/// waiting for the next typed character (otherwise an empty line could never get its own leading). +fn empty_para_mark(story: &designcraft_doc::Story, pos: usize) -> bool { + let ranges = story.para_ranges(); + ranges.get(story.para_at(pos)).is_some_and(|r| r.start == pos && r.end == pos) && story.text.as_bytes().get(pos) == Some(&b'\n') +} + pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result<Value> { let mut a = CharAttrs::default(); if let Some(o) = attrs.as_object() { @@ -1041,11 +1049,13 @@ pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result<Value> { validate_char_fill(&s.doc()?.doc, &a, "type.char")?; let cleared: Vec<String> = attrs.as_object().map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect()).unwrap_or_default(); let targets = format_targets(s); - // A caret in text: the format is for the next typing. + // A caret in text: the format is for the next typing — unless it sits on an empty paragraph's + // hidden mark, which takes the format immediately (there is nothing else on that line to type + // into first). if let [t] = targets.as_slice() && t.range.is_empty() && let Some(at) = s.doc()?.selection.text - && s.doc()?.doc.text_story(t.story, t.cell).is_some_and(|st| !st.is_empty()) + && s.doc()?.doc.text_story(t.story, t.cell).is_some_and(|st| !st.is_empty() && !empty_para_mark(st, t.range.start)) { let doc = s.doc()?.uid; let mut f = typing_format(s).cloned().unwrap_or(TypingFormat { doc, at, attrs: CharAttrs::default(), cleared: vec![] }); @@ -1067,12 +1077,21 @@ pub(crate) fn apply_char_attrs(d: &mut designcraft_doc::Document, targets: &[Tar let r = &t.range; let Some(st) = d.text_story_mut(t.story, t.cell) else { continue }; if r.is_empty() { - // An empty story: its one (empty) run is the format typing takes. - if st.is_empty() - && let Some(run) = st.chars.first_mut() - { - run.format.over.merge(a); - st.rev += 1; + if st.is_empty() { + // An empty story: its one (empty) run is the format typing takes. + if let Some(run) = st.chars.first_mut() { + run.format.over.merge(a); + st.rev += 1; + } + } else if empty_para_mark(st, r.start) { + // An empty paragraph elsewhere in the story: its hidden paragraph mark is its only + // character, and the one that carries line-affecting attributes like leading. + st.format_chars(r.start..r.start + 1, |f| { + f.over.merge(a); + for k in cleared { + let _ = f.over.set_json(k, &Value::Null); + } + }); } continue; } @@ -1639,6 +1658,32 @@ What's new in typography this season, and why it matters to every designer"}), s.execute("type.openType", &json!({"figures": "proportionalOldstyle"})).unwrap(); assert_ne!(gids(&s), lining); } + + /// A blank line (an empty paragraph) has no characters to select, but its hidden paragraph + /// mark can still take a leading change right away: otherwise there'd be no way to adjust the + /// line height of a blank line at all. + #[test] + fn leading_applies_to_an_empty_lines_hidden_paragraph_mark() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let text = "Para one\n\nPara two"; + let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": text})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + // The blank line between the two paragraphs: a collapsed caret, since it has no text. + let blank = text.find("\n\n").unwrap() + 1; + s.execute("text.select", &json!({"story": sid.0, "anchor": blank})).unwrap(); + s.execute("type.char", &json!({"attrs": {"leading": {"kind": "points", "value": 40.0}}})).unwrap(); + let st = s.doc().unwrap().doc.story(sid).unwrap(); + // `char_format_at` resolves a boundary to the *preceding* run (new typing's format), so + // check just past the mark's own one-byte run, which the mark alone occupies. + assert_eq!(st.char_format_at(blank + 1).over.leading, Some(designcraft_doc::Leading::Points(40.0))); + // No pending typing format was left behind: it applied immediately. + assert!(s.typing_format.is_none()); + let d = s.doc().unwrap().doc.clone(); + let cs = designcraft_compose::compose_story(&d, sid, &Default::default()); + let blank_line = cs.frames[0].lines.iter().find(|l| l.range == (blank..blank)).unwrap(); + assert_eq!(blank_line.leading, 40.0, "the blank line's own height changed"); + } } #[cfg(test)] From 8b834d053383d6a3ac95f5a5ef240755f2d28877 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:17:09 +0000 Subject: [PATCH 222/238] Fix overflow in layout.pages.insert when `after` is u64::MAX Found while testing crates/doc/src/build.rs:377 (Document::insert_pages), called from crates/engine/src/cmd/layout.rs:91 Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- crates/doc/src/build.rs | 2 +- crates/engine/src/cmd/layout.rs | 2 +- crates/engine/tests/insert_pages_bounds.rs | 16 ++++++++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 crates/engine/tests/insert_pages_bounds.rs diff --git a/crates/doc/src/build.rs b/crates/doc/src/build.rs index 574049d76..ea4dc5cd5 100644 --- a/crates/doc/src/build.rs +++ b/crates/doc/src/build.rs @@ -374,7 +374,7 @@ impl Document { return Ok(vec![]); } let n = self.page_count(); - let at = after.map_or(0, |a| (a + 1).min(n)); + let at = after.map_or(0, |a| a.saturating_add(1).min(n)); let template = self.page(at.saturating_sub(1).min(n.saturating_sub(1))).cloned(); let (cols, gutter, margins) = template.as_ref().map(|t| (t.columns.count, t.columns.gutter, t.margins)).unwrap_or((1, 12.0, Margins::uniform(36.0))); diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 6d6e06ee1..9ae584fe1 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -88,7 +88,7 @@ pub fn specs() -> Vec<CommandSpec> { let count = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 9999) as usize; let st = s.doc()?; let n = st.doc.page_count(); - let after = p.get("after").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(n - 1); + let after = p.get("after").and_then(Value::as_u64).map(|v| usize::try_from(v).unwrap_or(usize::MAX)).unwrap_or(n.saturating_sub(1)); let parent = parent_ref(&st.doc, p.get("parent")).unwrap_or_else(|| st.doc.parents.first().map(|x| x.id)); s.edit(|d, _| { let ids = d.insert_pages(Some(after), count, parent)?; diff --git a/crates/engine/tests/insert_pages_bounds.rs b/crates/engine/tests/insert_pages_bounds.rs new file mode 100644 index 000000000..e96a6cc4b --- /dev/null +++ b/crates/engine/tests/insert_pages_bounds.rs @@ -0,0 +1,16 @@ +//! `layout.pages.insert` clamps an out-of-range `after` to the end instead of overflowing. +use designcraft_engine::Session; +use serde_json::json; + +#[test] +fn insert_after_u64_max_appends_at_the_end() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages":2})).unwrap(); + let before: Vec<_> = s.doc().unwrap().doc.pages().map(|(_, _, p)| p.id).collect(); + let out = s.execute("layout.pages.insert", &json!({"after": u64::MAX, "count": 2})).unwrap(); + assert_eq!(out["total"], 4); + let after: Vec<_> = s.doc().unwrap().doc.pages().map(|(_, _, p)| p.id).collect(); + assert_eq!(after.len(), 4); + assert_eq!(after[..2], before[..], "the original pages must stay first; new pages go at the end"); + assert!(after[2..].iter().all(|id| !before.contains(id))); +} From d5d86217808234adfc69a0e238ded4d1116559cb Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Wed, 14 Oct 2026 21:22:17 +0200 Subject: [PATCH 223/238] Pathfinder keeps the frontmost object, as InDesign does InDesign's Pathfinder result takes the attributes (and content) of the frontmost object, except Subtract, which keeps the backmost. DesignCraft kept the backmost for every op but Minus Back, so a photo placed over strips and intersected with them was deleted and the black strips stayed (Zero2Graphic's magazine tutorial, 7:19). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 1 + crates/engine/src/cmd/paths.rs | 55 ++++++++++++++++++++++++++++++++-- docs/target-app-parity.md | 2 +- 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9f8168e50..4b25d802c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-14** · Pathfinder keeps the frontmost object, as InDesign does: a photo placed over shapes and intersected with them shows through the shapes (it was dropped, and the shapes kept their black fill); Add, Exclude Overlap and Minus Back take the front object's fill too, Subtract still keeps the back one. No readiness percentage change. - **2026-10-13** · Object › Fitting has InDesign's shortcuts: Fill Frame Proportionally ⌘⌥⇧C, Fit Content Proportionally ⌘⌥⇧E, Fit Frame to Content ⌘⌥C, Fit Content to Frame ⌘⌥E, Center Content ⌘⇧E. No readiness percentage change. - **2026-10-13** · Gradients between process colours blend as inks, as InDesign shows them: magenta to cyan passes through C 50 M 50 (a light lilac) on screen instead of the darker RGB mix, and a midpoint between CMYK or grey stops no longer turns the PDF shading RGB. No readiness percentage change. - **2026-10-14** · The Swatches panel has InDesign's header: a Fill/Stroke proxy, Formatting Affects Container/Text and the Tint of the colour in front (a tint alone keeps each object's own swatch). Clicking a swatch applies it to the fill or stroke in front (of the text, when formatting affects text), and the bottom row has New Color Group, New Swatch and Delete Swatch. No readiness percentage change. diff --git a/crates/engine/src/cmd/paths.rs b/crates/engine/src/cmd/paths.rs index b9d479067..9cfefdb4c 100644 --- a/crates/engine/src/cmd/paths.rs +++ b/crates/engine/src/cmd/paths.rs @@ -44,7 +44,7 @@ pub fn specs() -> Vec<CommandSpec> { "Pathfinder", ["Object", "Pathfinder"], None, - "{op: add|subtract|intersect|exclude|minusBack, ids?} — combine the selected shapes into one (the backmost keeps its look; the frontmost for minusBack) → {id}", + "{op: add|subtract|intersect|exclude|minusBack, ids?} — combine the selected shapes into one, which keeps the frontmost object's look and content (a placed graphic stays in it); Subtract keeps the backmost → {id}", has_selection, pathfinder ), @@ -145,7 +145,9 @@ fn pathfinder(s: &mut Session, p: &Value) -> Result<Value> { if order.len() < 2 { return Err(bad("object.pathfinder", "select two or more shapes on one spread (not text frames or groups)")); } - let keep = if op == Op::MinusBack { order[order.len() - 1] } else { order[0] }; + // InDesign: the result takes the frontmost object's look and content (a photo placed over + // shapes shows through their intersection); Subtract keeps the backmost, the front cuts it. + let keep = if op == Op::Subtract { order[0] } else { order[order.len() - 1] }; let shapes: Vec<PathData> = order.iter().filter_map(|id| spread_xf(d, *id).and_then(|(_, xf)| Some(d.item(*id)?.path.transformed(xf)))).collect(); let Some(result) = combine(op, &shapes) else { return Err(bad("object.pathfinder", "the shapes don't overlap: nothing is left")) }; @@ -417,6 +419,55 @@ mod tests { assert!(s.execute("object.pathfinder", &json!({"op": "nope"})).is_err()); } + /// The result keeps the frontmost object's look (Subtract: the backmost's), as InDesign's. + #[test] + fn pathfinder_keeps_the_frontmost_object() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("swatch.create", &json!({"name": "Red", "color": [255, 0, 0]})).unwrap(); + for op in ["add", "intersect", "exclude", "minusBack"] { + let a = s.execute("frame.create", &json!({"rect": [100, 100, 300, 300]})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("frame.create", &json!({"rect": [200, 100, 400, 300]})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.fill", &json!({"ids": [a], "swatch": "[Black]"})).unwrap(); + s.execute("object.fill", &json!({"ids": [b], "swatch": "Red"})).unwrap(); + let r = s.execute("object.pathfinder", &json!({"op": op, "ids": [a, b]})).unwrap(); + assert_eq!(r["id"], b, "{op}: the front object stays"); + let d = s.doc().unwrap().doc.clone(); + assert!(d.item(ItemId(a)).is_none(), "{op}"); + assert_eq!(d.item(ItemId(b)).unwrap().fill.swatch, "Red", "{op}"); + s.execute("edit.clear", &json!({"ids": [b]})).unwrap(); + } + } + + /// A photo placed over strips and intersected with them shows through the strips (it was + /// dropped: the black strips were kept). + #[test] + fn pathfinder_intersect_keeps_a_placed_photo_in_front() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = s.execute("frame.create", &json!({"rect": [100, 100, 300, 140]})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("frame.create", &json!({"rect": [100, 160, 300, 200]})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.fill", &json!({"ids": [a, b], "swatch": "[Black]"})).unwrap(); + let strips = s.execute("object.pathfinder", &json!({"op": "add", "ids": [a, b]})).unwrap()["id"].as_u64().unwrap(); + let png = designcraft_render::Rendered { width: 20, height: 20, pixels: [255u8, 255, 255, 255].repeat(400) }.to_png(); + let photo = s.execute("frame.create", &json!({"rect": [80, 80, 320, 220], "content": "graphic"})).unwrap()["id"].as_u64().unwrap(); + s.execute("file.place", &json!({"base64": super::super::base64_encode(&png), "name": "white.png", "frame": photo})).unwrap(); + s.execute("object.fit", &json!({"mode": "fillProportionally", "ids": [photo]})).unwrap(); + let r = s.execute("object.pathfinder", &json!({"op": "intersect", "ids": [strips, photo]})).unwrap(); + assert_eq!(r["id"], photo); + let d = s.doc().unwrap().doc.clone(); + assert!(d.item(ItemId(strips)).is_none()); + let it = d.item(ItemId(photo)).unwrap(); + assert!(matches!(it.content, Content::Graphic(_)), "the photo stays in the result"); + assert_eq!(it.path.subpaths.len(), 2, "cut to the two strips"); + let b = it.bounds(); + assert!((b.x0 - 100.0).abs() < 0.5 && (b.y0 - 100.0).abs() < 0.5 && (b.x1 - 300.0).abs() < 0.5 && (b.y1 - 200.0).abs() < 0.5, "{b:?}"); + let mut rr = designcraft_render::Renderer::new(); + rr.threads = 0; + let img = rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap(); + assert!(img.pixel(200, 120)[0] > 230, "the photo (white) shows in a strip: {:?}", img.pixel(200, 120)); + } + #[test] fn gradient_feather_fades_on_screen_and_in_pdf() { let mut s = Session::new(); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d4a97fad6..a589ac3e6 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -416,7 +416,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - File › Package (⌥⇧⌘P): the document relinked to a Links folder, the placed files, the font files that draw its text in a Document Fonts folder (the fonts it names and any fallback fonts that draw characters those lack; except fonts whose licence restricts it, OS/2 `fsType`), an IDML copy, an optional PDF and a report (fonts, fallback fonts, links, preflight, instructions); Copy Links To (`links.copyTo`). - Document fonts: opening a `.designcraft` or `.idml` file loads the fonts in the `Document Fonts` folder beside it (as Package writes it), ahead of installed fonts with the same name, so a packaged document opens with its fonts on another machine. Damaged, oversized (over 256 MB per file, 512 MB in all) and surplus (over 200) font files are skipped and listed in `file.open`'s `warnings`; `font.list` gives each font's `source`. Document fonts belong to their document, as in InDesign: other open documents don't see them (each document with a same-named font draws with its own), and closing the document (or reverting it, or a book chapter's export ending) makes them unavailable. Their files stay in memory until DesignCraft quits, so reopening a document doesn't read or load them again; on the web there is no folder to read. - Load Swatches / Save Swatches for Exchange (.ase: CMYK, RGB, Lab, Gray; spot or process) from the Swatches panel menu; `swatch.load` / `swatch.save`. -- Pathfinder (Object › Pathfinder and a Pathfinder panel): Add, Subtract, Intersect, Exclude Overlap, Minus Back; curves stay curves (flo_curves); `object.pathfinder`. +- Pathfinder (Object › Pathfinder and a Pathfinder panel): Add, Subtract, Intersect, Exclude Overlap, Minus Back; curves stay curves (flo_curves); the result keeps the frontmost object's look and content, so a photo intersected with shapes behind it shows through them (Subtract keeps the backmost); `object.pathfinder`. - Make / Release Compound Path (⌘8; nested paths become holes), Create Outlines (⇧⌘O; one path per text colour), Scissors tool (C) with `path.split`. - Transform Again / Individually / Sequence Again (⌥⌘3; a moved copy repeats like Step and Repeat), Clear Transformations; Layers: Merge, Delete Unused, Hide/Lock Others, Show/Unlock All; Break Link to Style (paragraph and character). - Floating panels: tear a panel off the dock (its float button, or drag its header away) into a movable, resizable window; Dock puts it back; positions persist; `window.floatPanel` / `window.dockPanel`. From 2302b7ec7f01d6301365383275b2001a2f7a3714 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:42:32 +0000 Subject: [PATCH 224/238] Keep viewBox-only placed SVGs at viewBox size on IDML import Fixes #950 Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- crates/idml/src/import.rs | 44 ++++++++++++++++++++++++++++++--- crates/idml/src/tests/assets.rs | 43 ++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 51dc66d84..d2c6dd534 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -242,6 +242,40 @@ fn nums(s: &str) -> Vec<f64> { s.split_whitespace().filter_map(|v| v.parse().ok()).collect() } +/// 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)> { + use quick_xml::events::Event; + if data.iter().find(|b| !b.is_ascii_whitespace()) != Some(&b'<') { + return None; + } + let mut r = quick_xml::Reader::from_reader(data); + let mut buf = Vec::new(); + let root = loop { + match r.read_event_into(&mut buf).ok()? { + Event::Start(e) | Event::Empty(e) => break e.into_owned(), + Event::Eof => return None, + _ => buf.clear(), + } + }; + if root.local_name().as_ref() != b"svg" { + return None; + } + let attr = |name: &[u8]| { + root.attributes().with_checks(false).flatten().find(|a| a.key.as_ref() == name).and_then(|a| a.unescape_value().ok().map(|v| v.to_string())) + }; + let absolute = |name: &[u8]| attr(name).is_some_and(|v| !v.trim().ends_with('%')); + if absolute(b"width") || absolute(b"height") { + return None; + } + let vb: Vec<f64> = + attr(b"viewBox")?.split(|c: char| c.is_whitespace() || c == ',').filter(|v| !v.is_empty()).filter_map(|v| v.parse().ok()).collect(); + match vb[..] { + [_, _, w, h] if w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0 => Some((w, h)), + _ => None, + } +} + fn parse_affine(s: Option<&str>) -> Affine { match s.map(nums) { Some(v) if v.len() == 6 => Affine::new([v[0], v[1], v[2], v[3], v[4], v[5]]), @@ -2366,7 +2400,7 @@ impl<'r> Importer<'r> { let size = ((r - l).abs(), (b - t).abs()); let link = g.find("Link"); let uri = link.and_then(|k| k.get("LinkResourceURI")).map(uri_to_path); - let graphic = |asset| { + let graphic = |asset, size| { Content::Graphic(designcraft_doc::Graphic { asset, size, @@ -2383,13 +2417,17 @@ impl<'r> Importer<'r> { { // Every placement of a linked file shares one asset; read the file once. if let Some(id) = self.linked_assets.get(p) { - return graphic(*id); + let size = self.assets.get(id).and_then(|a| svg_viewbox_size(&a.data)).unwrap_or(size); + return graphic(*id, size); } if let Some(d) = (self.read_link)(p) { data = d; read_from = Some(p.clone()); } } + // InDesign draws a viewBox-only SVG at one unit to the point: its GraphicBounds are the + // art's bounds, which can overhang the viewBox, so they must not size the viewBox. + let size = svg_viewbox_size(&data).unwrap_or(size); let (mime, px) = sniff_image(&data); let mime = mime.map(str::to_string).unwrap_or_else(|| match link.and_then(|k| k.get("LinkResourceFormat")).unwrap_or("") { f if f.contains("JPEG") => "image/jpeg".into(), @@ -2411,7 +2449,7 @@ impl<'r> Importer<'r> { if let Some(p) = read_from { self.linked_assets.insert(p, id); } - graphic(id) + graphic(id, size) } fn link_threads(&mut self) { diff --git a/crates/idml/src/tests/assets.rs b/crates/idml/src/tests/assets.rs index dd2761442..8c1ea2ee0 100644 --- a/crates/idml/src/tests/assets.rs +++ b/crates/idml/src/tests/assets.rs @@ -108,3 +108,46 @@ fn one_file_placed_several_times_imports_as_one_asset() { } } } + +/// An `<SVG>` placement linking `uri`, whose GraphicBounds are `bounds` (left top right bottom). +fn svg(n: usize, uri: &str, bounds: [f64; 4]) -> String { + let [l, t, r, b] = bounds; + format!( + r#"<SVG Self="sv{n}" ItemTransform="1 0 0 1 0 0"> + <Properties><GraphicBounds Left="{l}" Top="{t}" Right="{r}" Bottom="{b}"/></Properties> + <Link Self="lk{n}" LinkResourceURI="{uri}" LinkResourceFormat="$ID/SVG" StoredState="Normal"/> + </SVG>"# + ) +} + +#[test] +fn placed_svg_with_only_a_viewbox_keeps_its_viewbox_size() { + const OVERHANG: &str = + r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 100"><line x1="-10" y1="50" x2="0" y2="50" stroke="#000"/></svg>"##; + const SIZED: &str = + r#"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100" viewBox="0 0 200 100"><rect width="200" height="100"/></svg>"#; + let reader = |p: &str| match p { + "/art/overhang.svg" => Some(OVERHANG.as_bytes().to_vec()), + "/art/sized.svg" => Some(SIZED.as_bytes().to_vec()), + _ => None, + }; + // GraphicBounds are the art's bounds: 211 wide for the overhanging one, placed twice (the second + // reuses the first's asset); a sized SVG is still stretched onto its bounds. + let frames = vec![ + frame(1, 0.0, 0.0, &svg(1, "file:/art/overhang.svg", [-11.0, 0.0, 200.0, 100.0])), + frame(2, 100.0, 0.0, &svg(2, "file:/art/overhang.svg", [-11.0, 0.0, 200.0, 100.0])), + frame(3, 200.0, 0.0, &svg(3, "file:/art/sized.svg", [-10.0, 0.0, 200.0, 100.0])), + ]; + let d = import_idml_with(&package(&frames), &reader).unwrap(); + let mut gs: Vec<_> = d.spreads[0] + .items + .iter() + .filter_map(|i| match &i.content { + Content::Graphic(g) => Some((i.xf.translation().x, g.size, g.xf.translation().x)), + _ => None, + }) + .collect(); + gs.sort_by(|a, b| a.0.total_cmp(&b.0)); + let got: Vec<_> = gs.into_iter().map(|(_, size, x)| (size, x)).collect(); + assert_eq!(got, vec![((200.0, 100.0), -11.0), ((200.0, 100.0), -11.0), ((210.0, 100.0), -10.0)]); +} From e45bb864ed426199297ed693acc697edc152f9bb Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:52:43 +0300 Subject: [PATCH 225/238] math: cap LaTeX expression nesting depth so hostile input can't overflow the stack `math.svg` and `math.insert` take an unbounded `latex` string, and the parser's arg/atom/list/list_until_right recursive descent recursed once per group: 100k nested braces, `\frac`s or `\left(`s overflowed the stack (an abort the command guard cannot catch). Past MAX_NESTING (256) groups stop descending, so the rest of the input is consumed iteratively. Regression test: 100k nested braces, `\frac`s and `\left(`s typeset to an SVG; the old code aborted with a stack overflow. --- crates/engine/src/math.rs | 51 +++++++++++++++++++++++++++++++++------ 1 file changed, 44 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/math.rs b/crates/engine/src/math.rs index 97327ed81..71d873ae2 100644 --- a/crates/engine/src/math.rs +++ b/crates/engine/src/math.rs @@ -160,9 +160,15 @@ fn big_op(name: &str) -> Option<&'static str> { }) } +/// Deepest expression nesting parsed. Recursive descent must not overflow the stack on hostile +/// input (`\frac\frac…`, `{{{{…`, `\left(\left(…`); past the cap groups stop descending. +const MAX_NESTING: usize = 256; + struct Parser<'a> { s: &'a [char], i: usize, + /// Enclosing group depth, at most [`MAX_NESTING`]. + depth: usize, } impl Parser<'_> { @@ -220,14 +226,19 @@ impl Parser<'_> { out } fn arg(&mut self) -> Node { + if self.depth >= MAX_NESTING { + return Node::Group(Vec::new()); + } + self.depth += 1; self.skip_ws(); - if self.peek() == Some('{') { + let n = if self.peek() == Some('{') { self.i += 1; - let g = self.list(Some('}')); - Node::Group(g) + Node::Group(self.list(Some('}'))) } else { self.atom().unwrap_or(Node::Group(vec![])) - } + }; + self.depth -= 1; + n } fn delimiter(&mut self) -> String { self.skip_ws(); @@ -280,7 +291,7 @@ impl Parser<'_> { } s.push(c); } - Box::new(parse(&s)) + Box::new(parse_at(&s, self.depth + 1)) }); let a = self.arg(); Node::Sqrt(idx, Box::new(a)) @@ -329,6 +340,10 @@ impl Parser<'_> { }) } fn list(&mut self, end: Option<char>) -> Vec<Node> { + if self.depth >= MAX_NESTING { + return Vec::new(); + } + self.depth += 1; let mut out: Vec<Node> = Vec::new(); loop { self.skip_ws(); @@ -362,9 +377,13 @@ impl Parser<'_> { } } } + self.depth -= 1; out } fn list_until_right(&mut self) -> Vec<Node> { + if self.depth >= MAX_NESTING { + return Vec::new(); + } let mut out = Vec::new(); loop { self.skip_ws(); @@ -375,7 +394,7 @@ impl Parser<'_> { self.i += 6; break; } - let mut p = Parser { s: self.s, i: self.i }; + let mut p = Parser { s: self.s, i: self.i, depth: self.depth + 1 }; let one = p.list_one(); self.i = p.i; out.extend(one); @@ -410,8 +429,12 @@ impl Parser<'_> { } fn parse(src: &str) -> Node { + parse_at(src, 0) +} + +fn parse_at(src: &str, depth: usize) -> Node { let chars: Vec<char> = src.chars().collect(); - let mut p = Parser { s: &chars, i: 0 }; + let mut p = Parser { s: &chars, i: 0, depth }; Node::Group(p.list(None)) } @@ -714,4 +737,18 @@ mod tests { let (svg, _) = to_svg(r"\sqrt{a^2+b^2} \ne \pi", 14.0); assert!(designcraft_images::svg_tree(&svg).is_some()); } + + /// Hostile input must not overflow the stack: nesting past the cap stops descending instead + /// of recursing once per group (100k levels used to abort the process). + #[test] + fn an_over_deep_expression_is_bounded() { + for latex in [ + format!("{}x{}", "{".repeat(100_000), "}".repeat(100_000)), + format!("{}x", r"\frac".repeat(100_000)), + format!("{}x", r"\left(".repeat(100_000)), + ] { + let (svg, _) = to_svg(&latex, 12.0); + assert!(!svg.is_empty()); + } + } } From 0fe2373c741dd9eb913a0b41ed30f572347427fa Mon Sep 17 00:00:00 2001 From: IanHollow <72767437+IanHollow@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:14:07 -0700 Subject: [PATCH 226/238] Fix the AppStream screenshot URL and caption Assisted-by: OpenAI Codex (GPT-6) Signed-off-by: IanHollow <72767437+IanHollow@users.noreply.github.com> --- packaging/linux/ai.storyteller.designcraft.metainfo.xml.in | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packaging/linux/ai.storyteller.designcraft.metainfo.xml.in b/packaging/linux/ai.storyteller.designcraft.metainfo.xml.in index ea548de84..f3fd91535 100644 --- a/packaging/linux/ai.storyteller.designcraft.metainfo.xml.in +++ b/packaging/linux/ai.storyteller.designcraft.metainfo.xml.in @@ -35,8 +35,8 @@ </keywords> <screenshots> <screenshot type="default"> - <caption>Editing The Great Wave off Kanagawa with type layers and adjustment layers</caption> - <image>https://raw.githubusercontent.com/storytold/designcraft/main/docs/images/designcraft-demo.jpg</image> + <caption>Magazine spread with threaded text frames and layout properties</caption> + <image type="source" width="1600" height="1000">https://raw.githubusercontent.com/storytold/designcraft/main/docs/images/ui-spread.png</image> </screenshot> </screenshots> <branding> From 7a1ba45d1891cf4e6ef7f23f1a11db2f26aa2943 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:47:55 +0300 Subject: [PATCH 227/238] layout.section: keep the settings the command does not name The command removed the section at a nonzero start with retain() and only then looked it up to keep its properties, so the lookup always fell back to the defaults: editing one field (a marker, a start number) silently reset the style, prefix, include-prefix flag and start number. The existing section is now read before the list is rebuilt. Regression test: a Roman-numbered page-2 section with prefix A- and marker First, then marker Second keeps startNumber, style, prefix and includePrefix. --- crates/engine/src/cmd/layout.rs | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 6d6e06ee1..79a555f3b 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -476,11 +476,14 @@ pub fn specs() -> Vec<CommandSpec> { let start = page - 1; let p = p.clone(); s.edit(|d, _| { + // The settings the command does not name stay as the section was: read it + // before the list is rebuilt (#717). + let existing = d.sections.iter().find(|x| x.start == start).cloned(); d.sections.retain(|x| x.start != start || start == 0); if p.get("remove").and_then(Value::as_bool).unwrap_or(false) && start != 0 { return ok(); } - let mut sec = d.sections.iter().find(|x| x.start == start).cloned().unwrap_or(designcraft_doc::Section { + let mut sec = existing.unwrap_or(designcraft_doc::Section { start, start_number: None, style: Default::default(), @@ -1034,6 +1037,28 @@ mod page_numbering_view_tests { assert_eq!(s.resolve_page("1"), Some(0)); assert_eq!(s.resolve_page("9"), None); } + + /// `layout.section` edits one named setting; the others stay as the section was (#717). + #[test] + fn updating_a_section_keeps_settings_the_command_does_not_name() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 4})).unwrap(); + s.execute( + "layout.section", + &json!({"page": 2, "startNumber": 1, "style": "lowerRoman", "prefix": "A-", "includePrefix": true, "marker": "First"}), + ) + .unwrap(); + assert_eq!(s.page_label(1), "A-i"); + s.execute("layout.section", &json!({"page": 2, "marker": "Second"})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let sec = d.sections.iter().find(|x| x.start == 1).expect("the page-2 section is still the only one there"); + assert_eq!(sec.marker, "Second"); + assert_eq!(sec.start_number, Some(1), "startNumber is kept"); + assert_eq!(sec.style, designcraft_doc::NumberStyle::LowerRoman, "style is kept"); + assert_eq!(sec.prefix, "A-", "prefix is kept"); + assert!(sec.include_prefix, "includePrefix is kept"); + assert_eq!(s.page_label(1), "A-i"); + } } #[cfg(test)] From 59bdb371ff82d15318132fa3ab234151a9061e43 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:54:04 +0300 Subject: [PATCH 228/238] dtd: cap parameter-entity expansion instead of trusting a pass count parse() expanded parameter entities with up to eight passes of whole-string replacement, each pass cloning the text and re-inserting the entities' original values. A pass count is not an output bound: a few hundred bytes of nested entities inflate multiplicatively (a 449-byte DTD reaches ~4 MB here, and larger chains would go to gigabytes), so a small file could exhaust memory and CPU. Expansion now stops once it has added more than 1 MiB over the input. Regression test: a nested-entity DTD under 500 bytes is rejected with the budget error, and a small inline entity still parses. --- crates/engine/src/dtd.rs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/crates/engine/src/dtd.rs b/crates/engine/src/dtd.rs index f20261a1e..7f752efaa 100644 --- a/crates/engine/src/dtd.rs +++ b/crates/engine/src/dtd.rs @@ -3,6 +3,10 @@ use std::collections::HashMap; +/// How much text parameter entities may add during expansion (a pass count is not an output +/// bound: each pass replaces whole strings, so references can inflate multiplicatively). +const MAX_EXPANDED_BYTES: usize = 1024 * 1024; + /// A content particle: a name, a sequence, a choice, or one of those repeated (`?`, `*`, `+`). #[derive(Clone, Debug, PartialEq)] enum Cp { @@ -177,10 +181,17 @@ pub fn parse(src: &str) -> Result<Dtd, String> { } } let mut text = src.to_string(); + // A pass count bounds the work, not the output: each pass replaces whole strings with the + // entities' original values, so a few hundred bytes of nested parameter entities can inflate + // to gigabytes. Expansion may not add more than this much text over the input. + let limit = src.len().saturating_add(MAX_EXPANDED_BYTES); for _ in 0..8 { let before = text.clone(); for (n, v) in &ents { text = text.replace(&format!("%{n};"), v); + if text.len() > limit { + return Err(format!("parameter entities expand the DTD beyond {limit} bytes")); + } } if text == before { break; @@ -434,6 +445,23 @@ mod tests { assert!(parse("just text").is_err()); } + /// A few hundred bytes of nested parameter entities used to inflate multiplicatively across + /// the eight wholly-string-replacing passes (#713); expansion is capped. + #[test] + fn parameter_entities_cannot_expand_without_a_budget() { + let mut src = String::from("<!ENTITY % e1 \"xx\">\n"); + for i in 2..=6 { + src.push_str(&format!("<!ENTITY % e{i} \"{}\">\n", format!("%e{};", i - 1).repeat(16))); + } + src.push_str("<!ELEMENT r (%e6;)>"); + assert!(src.len() < 500, "the payload is small: {} bytes", src.len()); + let err = parse(&src).unwrap_err(); + assert!(err.contains("expand the DTD beyond"), "{err}"); + // Entities that expand a little still parse. + let dtd = parse("<!ENTITY % inline \"#PCDATA | em\">\n<!ELEMENT r (%inline;)*>").unwrap(); + assert_eq!(dtd.element_names().collect::<Vec<_>>(), ["r"]); + } + #[test] fn repetition_matches_like_a_regular_expression() { let dtd = parse("<!ELEMENT r ((a, b?)+, c*)><!ELEMENT a EMPTY><!ELEMENT b EMPTY><!ELEMENT c EMPTY>").unwrap(); From a393ab2018e845e0303da60205b80904a8b16ecc Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:57:21 +0300 Subject: [PATCH 229/238] script.run: roll a failed script back instead of stranding its edits A script whose later step failed kept the successful prefix in the document but truncated the prefix's undo entries (and returned before the command's aggregate undo entry was recorded), so the edits could not be undone at all. A failed script now restores the document, selection and history from before it started: all-or-nothing. Regression test: frame.create followed by an unknown command is an error, the document has no frame, nothing is left to undo, and the next edit undoes normally. The one-undo-step success path still holds. --- crates/engine/src/cmd/scripts.rs | 40 +++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs index 0fa89c598..5b6d288aa 100644 --- a/crates/engine/src/cmd/scripts.rs +++ b/crates/engine/src/cmd/scripts.rs @@ -1,5 +1,7 @@ //! Window › Utilities › Scripts: run a command script (see [`crate::script`]) as one undo step. +use std::sync::Arc; + use serde_json::{Value, json}; use super::{CommandSpec, always, bad, cmd, str_param}; @@ -8,15 +10,23 @@ use crate::Result; fn run(s: &mut crate::Session, p: &Value) -> Result<Value> { let text = str_param(p, "text").ok_or_else(|| bad("script.run", "`text` required"))?; let steps = crate::script::parse(text).map_err(|e| bad("script.run", e))?; - // The steps' own undo entries collapse into this command's single one. - let uid = s.active().map(|d| d.uid); - let before = s.active().map_or(0, |d| d.history.undo.len()); + // The steps' own undo entries collapse into this command's single one. A step that fails + // rolls the successful prefix back (document, selection and history), so a half-run script + // never strands edits that can't be undone (#709). + let before = s.active().map(|d| (d.uid, d.doc.clone(), d.selection.clone(), d.history.clone())); let report = s.run_script(&steps); if let Some(st) = s.active_mut() - && Some(st.uid) == uid - && st.history.undo.len() > before + && let Some((uid, doc, selection, history)) = &before + && st.uid == *uid { - st.history.undo.truncate(before); + if report.failed.is_some() { + st.doc = Arc::clone(doc); + st.selection = selection.clone(); + st.history = history.clone(); + st.revision = st.revision.saturating_add(1); + } else if st.history.undo.len() > history.undo.len() { + st.history.undo.truncate(history.undo.len()); + } } let out = json!({"results": report.results, "steps": steps.len()}); match report.failed { @@ -56,4 +66,22 @@ mod tests { assert_eq!(count(&s), 0, "one undo removes the whole script's work"); assert!(s.execute("script.run", &json!({"text": "nope.command"})).is_err()); } + + /// A script that fails after a successful step leaves the document and its history exactly as + /// they were: the half-run prefix is not stranded without an undo entry (#709). + #[test] + fn a_failed_script_rolls_the_successful_prefix_back() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let count = |s: &Session| s.doc().unwrap().doc.spreads[0].items.len(); + let err = s.execute("script.run", &json!({"text": "frame.create {\"rect\": [72, 72, 200, 200]}\nnope.command"})).unwrap_err(); + assert!(err.to_string().contains("step 1") && err.to_string().contains("nope.command"), "{err}"); + assert_eq!(count(&s), 0, "the successful prefix is rolled back"); + assert!(s.execute("edit.undo", &json!({})).is_err(), "nothing was left to undo"); + // The session keeps working and its undo history is as it was. + s.execute("frame.create", &json!({"rect": [72, 72, 200, 200]})).unwrap(); + assert_eq!(count(&s), 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(count(&s), 0); + } } From e8dd2636f5e8c2d6f953bdd4949bc6ace6875631 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:01:55 +0300 Subject: [PATCH 230/238] guard: restore the whole command transaction on a panic The guard restored only the active document's content and selection, so a command that edited through a nested successful command and then panicked left the nested edit's undo entry (and no redo) behind, and a command that opened or switched documents restored nothing at all. The guard now snapshots every open document with its history and selection, the active index and the journal, and restores them on a panic. Regression tests: a nested edit followed by a panic leaves the history and redo exactly as they were, and a command that opens a second document and panics leaves only the original open. --- crates/engine/src/guard.rs | 63 +++++++++++++++++++++++++++++++++----- 1 file changed, 56 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/guard.rs b/crates/engine/src/guard.rs index 9a1df3eef..74e16b882 100644 --- a/crates/engine/src/guard.rs +++ b/crates/engine/src/guard.rs @@ -35,17 +35,23 @@ pub fn install_panic_hook() { impl Session { /// Run command `id` (`f`) inside the guard. pub(crate) fn guarded(&mut self, id: &str, f: impl FnOnce(&mut Session) -> Result<Value>) -> Result<Value> { - let before = self.active().map(|d| (d.uid, d.doc.clone(), d.selection.clone(), d.revision)); + // The whole command transaction: every open document with its undo/redo history and + // selection, which one is active, and the journal (nested commands journal too). + let docs = self.docs.clone(); + let active = self.active; + let revision = self.active().map_or(0, |d| d.revision); + let journal = self.journal.len(); match catch_unwind(AssertUnwindSafe(|| f(self))) { Ok(r) => r, Err(payload) => { let msg = panic_message(payload.as_ref()); - // Keep the document as it was before the command. - if let (Some((uid, doc, selection, revision)), Some(st)) = (before, self.active_mut()) - && st.uid == uid - { - st.doc = doc; - st.selection = selection; + // Keep the documents as they were before the command: a nested edit that + // succeeded before the panic is rolled back with its history entry, a document + // opened or switched by the command is gone, and redo it cleared is back. + self.docs = docs; + self.active = active; + self.journal.truncate(journal); + if let Some(st) = self.active_mut() { st.interaction = None; st.revision = revision.max(st.revision).saturating_add(1); } @@ -86,4 +92,47 @@ mod tests { // The session keeps working. s.execute("frame.create", &json!({"rect": [36, 236, 200, 400]})).unwrap(); } + + /// A nested edit that succeeded before the panic is rolled back with its history entry, and + /// the redo it cleared is back (#742). + #[test] + fn a_panicking_command_restores_history_with_the_document() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("frame.create", &json!({"rect": [36, 36, 200, 200]})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + let (undo, redo) = { + let h = &s.active().unwrap().history; + (h.undo.len(), h.redo.len()) + }; + assert_eq!(redo, 1, "there is redo to lose"); + let err = s + .guarded("test.panic", |s| { + s.execute("frame.create", &json!({"rect": [36, 236, 200, 400]}))?; + panic!("boom"); + }) + .unwrap_err(); + assert!(err.to_string().contains("boom"), "{err}"); + let st = s.active().unwrap(); + assert_eq!(st.history.undo.len(), undo, "the nested edit's undo entry is gone with the edit"); + assert_eq!(st.history.redo.len(), redo, "the redo the nested edit cleared is back"); + assert_eq!(st.doc.spreads[0].items.len(), 0, "the document is the one from before the command"); + } + + /// A command that opens another document and then panics leaves none of it behind (#742). + #[test] + fn a_panicking_command_restores_the_document_set() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let err = s + .guarded("test.panic", |s| { + s.execute("file.new", &json!({}))?; + assert_eq!(s.docs.len(), 2); + panic!("boom"); + }) + .unwrap_err(); + assert!(err.to_string().contains("boom"), "{err}"); + assert_eq!(s.docs.len(), 1, "the document the command opened is gone"); + assert_eq!(s.active, Some(0), "the document from before the command is active"); + } } From 939d5676cb5ef970650f6aa1704611097fd8f202 Mon Sep 17 00:00:00 2001 From: Guillem Hermida <guillemhr2@gmail.com> Date: Wed, 14 Oct 2026 23:17:18 +0200 Subject: [PATCH 231/238] Color panel: the Tint field keeps typed text and applies it on Enter or leaving the field The Tint row's value box was the egui Slider's own DragValue. The panel reads the tint back from the document every frame, so egui dropped the text being edited after the first digit (100 -> 6 -> 100 -> 1000, as #945 reports for the CMYK fields), and a typed tint was never applied because the panel only applied on the slider's drag_stopped(). The slider keeps applying on release, without its own value box; the value is a widgets::NumField that buffers text while focused and applies on Enter or focus loss, clamped to 0-100 %. Part of #945. --- ROADMAP.md | 1 + .../ui-egui/src/panels/color_panel_tests.rs | 83 +++++++++++++++++++ crates/ui-egui/src/panels/mod.rs | 3 + crates/ui-egui/src/panels/swatches.rs | 17 +++- 4 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/src/panels/color_panel_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 9f8168e50..dda2d5e99 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-14** · The Color panel's Tint field keeps what is typed while it has focus and applies it on Enter or when it loses focus, clamped to 0–100 %. Typing 60 over 100 % no longer snaps back to 100 after the first digit (10060), and a typed tint is no longer ignored. No readiness percentage change. - **2026-10-13** · Object › Fitting has InDesign's shortcuts: Fill Frame Proportionally ⌘⌥⇧C, Fit Content Proportionally ⌘⌥⇧E, Fit Frame to Content ⌘⌥C, Fit Content to Frame ⌘⌥E, Center Content ⌘⇧E. No readiness percentage change. - **2026-10-13** · Gradients between process colours blend as inks, as InDesign shows them: magenta to cyan passes through C 50 M 50 (a light lilac) on screen instead of the darker RGB mix, and a midpoint between CMYK or grey stops no longer turns the PDF shading RGB. No readiness percentage change. - **2026-10-14** · The Swatches panel has InDesign's header: a Fill/Stroke proxy, Formatting Affects Container/Text and the Tint of the colour in front (a tint alone keeps each object's own swatch). Clicking a swatch applies it to the fill or stroke in front (of the text, when formatting affects text), and the bottom row has New Color Group, New Swatch and Delete Swatch. No readiness percentage change. diff --git a/crates/ui-egui/src/panels/color_panel_tests.rs b/crates/ui-egui/src/panels/color_panel_tests.rs new file mode 100644 index 000000000..cd102b2e3 --- /dev/null +++ b/crates/ui-egui/src/panels/color_panel_tests.rs @@ -0,0 +1,83 @@ +//! Color panel: the Tint field of a named swatch. + +use egui::accesskit::Role; +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::DesignApp; + +fn harness(app: DesignApp) -> Harness<'static, DesignApp> { + Harness::builder().with_size(vec2(320.0, 320.0)).build_ui_state(|ui, app: &mut DesignApp| super::swatches::color_panel(app, ui), app) +} + +fn fill_tint(app: &DesignApp, id: u64) -> f32 { + app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().fill.tint +} + +/// The Tint row is the panel's first value field, above the C/M/Y/K fields. +fn click_tint_field(h: &mut Harness<'static, DesignApp>) { + let at = h + .query_all_by(|n| matches!(n.role(), Role::SpinButton | Role::TextInput)) + .map(|n| n.rect()) + .min_by(|a, b| a.min.y.total_cmp(&b.min.y)) + .unwrap() + .center(); + h.hover_at(at); + h.drag_at(at); + h.drop_at(at); + h.run_steps(2); +} + +fn typed(h: &Harness<'static, DesignApp>) -> Option<String> { + h.query_by(|n| n.is_focused()).and_then(|n| n.value()) +} + +/// Typing "60" over "100%" keeps the text while the field has focus (it used to snap back to +/// 100 after the first digit, giving 10060), applies it on Enter, and a value out of range is +/// clamped when the field loses focus. +#[test] +fn a_typed_tint_is_kept_while_typing_and_applied_on_enter_or_leaving_the_field() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [36, 36, 200, 200], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + app.run("object.fill", json!({"ids": [id], "swatch": "C=100 M=0 Y=0 K=0"})).unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + let mut h = harness(app); + h.run_steps(3); + click_tint_field(&mut h); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.run_steps(2); + for digit in ["6", "0"] { + h.event(egui::Event::Text(digit.into())); + h.run_steps(3); + } + assert_eq!(typed(&h).as_deref(), Some("60"), "the typed text stays while the field has focus"); + assert_eq!(fill_tint(h.state(), id), 1.0, "nothing is applied while typing"); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert!((fill_tint(h.state(), id) - 0.6).abs() < 1e-6, "Enter applies 60 %: {}", fill_tint(h.state(), id)); + assert_eq!(h.state().session.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch, "C=100 M=0 Y=0 K=0"); + // Out of range: clamped to 100 % when focus moves away. + click_tint_field(&mut h); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text("150".into())); + h.run_steps(3); + let away = egui::pos2(300.0, 310.0); + h.hover_at(away); + h.drag_at(away); + h.drop_at(away); + h.run_steps(3); + assert_eq!(fill_tint(h.state(), id), 1.0, "150 is clamped to 100 % when the field loses focus"); + // The slider still applies on release. + let track = h.get_by_role(Role::Slider).rect(); + h.hover_at(track.right_center()); + h.drag_at(track.right_center()); + h.run_steps(1); + h.hover_at(track.left_center()); + h.run_steps(1); + h.drop_at(track.left_center()); + h.run_steps(3); + assert_eq!(fill_tint(h.state(), id), 0.0, "dragging the slider to the left end applies 0 %"); +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index baf9a1251..1feaaa6e1 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -13,6 +13,9 @@ pub mod styles; pub mod swatches; pub mod table; +#[cfg(test)] +mod color_panel_tests; + use designcraft_doc::{Content, StrokeAlign, WrapMode}; use designcraft_geom::Rect; use egui::vec2; diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index aea4e4acc..7ea1d8a0a 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -364,12 +364,25 @@ pub fn color_panel(app: &mut DesignApp, ui: &mut egui::Ui) { if let Some(sw) = sw.as_ref().filter(|w| !w.hidden && !w.locked) { let cur = if stroke { stroke_tint } else { fill_tint }; let mut tint = cur * 100.0; + let mut commit = None; ui.horizontal(|ui| { ui.label("T"); - if ui.add(egui::Slider::new(&mut tint, 0.0..=100.0).suffix("%").integer()).drag_stopped() { - let _ = super::apply_swatch(app, stroke, &sw.name, Some(tint / 100.0)); + // The slider applies on release. The field keeps typed text while it has focus (the + // tint is read back from the document every frame) and applies it, clamped, on Enter + // or when it loses focus. + ui.spacing_mut().slider_width = (ui.available_width() - 60.0).max(60.0); + if ui.add(egui::Slider::new(&mut tint, 0.0..=100.0).integer().show_value(false)).drag_stopped() { + commit = Some(tint); + } + if let Some(v) = + crate::widgets::NumField::number("color_tint", Some(f64::from(cur) * 100.0), "%", 0).width(44.0).range(0.0, 100.0).show(ui) + { + commit = Some(v as f32); } }); + if let Some(v) = commit { + let _ = super::apply_swatch(app, stroke, &sw.name, Some(v / 100.0)); + } } let mut send: Option<serde_json::Value> = None; match mode { From d52ef6b6b9ce9539134c109ba1d9df07dbab3cc5 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 15 Oct 2026 00:18:55 +0200 Subject: [PATCH 232/238] A line is set when its baseline fits in the frame, as in InDesign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InDesign sets a text line when its baseline fits in the frame; the descenders may hang below the frame's bottom. DesignCraft required the baseline plus the descent to fit, so a frame sized to a heading's caps oversets: Zero2Graphic's magazine tutorial draws 2.7682 cm frames for 103 pt headings (control bar, 4:12–5:44), and all three were overset. The keep-lines IDML fixture's frames are now 236 pt tall, so they hold the same 19 lines under either rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 1 + crates/compose/src/lib.rs | 3 ++- crates/engine/src/tests.rs | 20 ++++++++++++++++++++ crates/engine/src/tests_idml.rs | 3 ++- 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9f8168e50..06fcc7e9c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-15** · A line of text is set when its baseline fits in the frame, as in InDesign: descenders may hang below the frame's bottom. A frame sized to the cap height of a big heading (103 pt in a 2.77 cm frame, as Zero2Graphic's magazine tutorial draws them) no longer oversets. No readiness percentage change. - **2026-10-13** · Object › Fitting has InDesign's shortcuts: Fill Frame Proportionally ⌘⌥⇧C, Fit Content Proportionally ⌘⌥⇧E, Fit Frame to Content ⌘⌥C, Fit Content to Frame ⌘⌥E, Center Content ⌘⇧E. No readiness percentage change. - **2026-10-13** · Gradients between process colours blend as inks, as InDesign shows them: magenta to cyan passes through C 50 M 50 (a light lilac) on screen instead of the darker RGB mix, and a midpoint between CMYK or grey stops no longer turns the PDF shading RGB. No readiness percentage change. - **2026-10-14** · The Swatches panel has InDesign's header: a Fill/Stroke proxy, Formatting Affects Container/Text and the Tint of the colour in front (a tint alone keeps each object's own swatch). Clicking a swatch applies it to the fill or stroke in front (of the text, when formatting affects text), and the bottom row has New Color Group, New Swatch and Delete Swatch. No readiness percentage change. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index c6312e9f5..0f260ecf5 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1024,7 +1024,8 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let col_w = cols[cur.fi][cur.col.min(cols[cur.fi].len() - 1)].width(); let reserve = notes.reserve(doc, cur.fi, cur.col, &line_notes, col_w, f, opts); let reserve = if cur.last_baseline.is_none() { notes.reserve(doc, cur.fi, cur.col, &[], col_w, f, opts) } else { reserve }; - let fits = baseline + desc <= col.y1 - reserve + 0.01 && !capped; + // As in InDesign, a line is set when its baseline fits: its descenders may hang below the frame. + let fits = baseline <= col.y1 - reserve + 0.01 && !capped; if !fits { if cur.can_next_sub() { g0 = s; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 712dc06bc..01e15ee2d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -877,3 +877,23 @@ fn duplicating_a_page_to_an_index_is_one_undo_step() { assert!(s.execute("layout.pages.duplicateSpread", &json!({"spread": 0, "to": 4})).is_err()); assert_eq!(widths(&s), [300.0, 400.0, 500.0]); } + +/// A line is set when its baseline fits in the frame; its descenders may hang below the frame, as +/// in InDesign. A 103 pt heading in a 78.5 pt frame (Cap Height first baseline) was overset. +#[test] +fn a_line_fits_when_its_baseline_does() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let overset = |s: &mut Session, height: f64| { + let r = s.execute("frame.create", &json!({"rect": [36, 36, 400, 36.0 + height], "content": "text", "text": "Gyp"})).unwrap(); + s.execute("object.textFrameOptions", &json!({"ids": [r["id"]], "firstBaseline": "capHeight", "inset": 0})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 3})).unwrap(); + s.execute("type.char", &json!({"attrs": {"size": 103}})).unwrap(); + let st = s.execute("story.get", &json!({"story": r["story"]})).unwrap(); + !st["overset"].is_null() + }; + // Cap height of the default font at 103 pt is about 70 pt: the baseline fits in 78.5 pt, the + // descent (about 25 pt more) doesn't. + assert!(!overset(&mut s, 78.5), "the line is set; its descenders hang below the frame"); + assert!(overset(&mut s, 50.0), "a baseline below the frame still oversets the line"); +} diff --git a/crates/engine/src/tests_idml.rs b/crates/engine/src/tests_idml.rs index 5e84532cf..c6ecc539c 100644 --- a/crates/engine/src/tests_idml.rs +++ b/crates/engine/src/tests_idml.rs @@ -107,7 +107,8 @@ fn keep_lines_idml(body_attrs: &str, lines: &[usize]) -> Vec<u8> { }) .collect(); let frame = |me: &str, x: f64, prev: &str, next: &str| { - let pts: String = [(x, 36.0), (x, 276.0), (x + 180.0, 276.0), (x + 180.0, 36.0)] + // 236 pt tall: 19 lines of 12 pt leading, whether or not the last line's descent must fit. + let pts: String = [(x, 36.0), (x, 272.0), (x + 180.0, 272.0), (x + 180.0, 36.0)] .iter() .map(|(x, y)| format!(r#"<PathPointType Anchor="{x} {y}" LeftDirection="{x} {y}" RightDirection="{x} {y}"/>"#)) .collect(); From 099fbd23ee2179dd460a3e9e23652fb063baa346 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 15 Oct 2026 00:54:34 +0200 Subject: [PATCH 233/238] IDML: import and export ruler guides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guides in an IDML page (<Guide> children of <Page>) were dropped on import, and DesignCraft's own guides weren't written. Montilla Design's Flex Layout file keeps its 16 column guides on the B-Parent page; none came in. Import: Location is measured from View Preferences › Ruler Origin (the spread's top left, the page's, or the spine); Orientation, FitToPage (off: a spread guide), Locked and ItemLayer are kept. Export writes each page's guides under its <Page> in spread-origin coordinates, which is the ruler origin DesignCraft writes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 1 + crates/idml/src/export.rs | 20 +++++++++++++++ crates/idml/src/import.rs | 52 ++++++++++++++++++++++++++++++++++++--- crates/idml/src/tests.rs | 51 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 121 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9f8168e50..bad2c9d04 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-15** · IDML: ruler guides import and export (they were dropped). Each page's guides come in measured from the document's ruler origin (spread, page or spine), with Fit to Page, Locked and their layer, parent pages' included, and go out again under their pages. No readiness percentage change. - **2026-10-13** · Object › Fitting has InDesign's shortcuts: Fill Frame Proportionally ⌘⌥⇧C, Fit Content Proportionally ⌘⌥⇧E, Fit Frame to Content ⌘⌥C, Fit Content to Frame ⌘⌥E, Center Content ⌘⇧E. No readiness percentage change. - **2026-10-13** · Gradients between process colours blend as inks, as InDesign shows them: magenta to cyan passes through C 50 M 50 (a light lilac) on screen instead of the darker RGB mix, and a midpoint between CMYK or grey stops no longer turns the PDF shading RGB. No readiness percentage change. - **2026-10-14** · The Swatches panel has InDesign's header: a Fill/Stroke proxy, Formatting Affects Container/Text and the Tint of the colour in front (a tint alone keeps each object's own swatch). Clicking a swatch applies it to the fill or stroke in front (of the text, when formatting affects text), and the bottom row has New Color Group, New Swatch and Delete Swatch. No readiness percentage change. diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 0b9435c71..1a5c9df77 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1667,6 +1667,26 @@ impl<'a> Ex<'a> { .attr("GeometricBounds", format!("0 0 {} {}", num(pg.height), num(pg.width))) .attr("ItemTransform", format!("1 0 0 1 {} {}", num(pg.x - ox), num(-oy))) .child(margin_el(pg)); + let mut pe = pe; + // Ruler guides, measured from the spread's top left (RulerOrigin is SpreadOrigin). + let left = sp.pages.iter().map(|p| p.x).fold(f64::INFINITY, f64::min); + let left = if left.is_finite() { left } else { 0.0 }; + for g in &pg.guides { + let vertical = g.orientation == designcraft_doc::Orientation::Vertical; + let mut ge = El::new("Guide") + .attr("Self", format!("{}g{}", uid(pg.id.0), pe.elements().count())) + .attr("Orientation", if vertical { "Vertical" } else { "Horizontal" }) + .attr("Location", num(if vertical { g.position - left } else { g.position })) + .attr("FitToPage", bool_s(!g.spread)) + .attr("ViewThreshold", "5") + .attr("Locked", bool_s(g.locked)) + .attr("PageIndex", (first_page + i).to_string()) + .attr("GuideType", "Ruler"); + if let Some(l) = g.layer { + ge.set("ItemLayer", uid(l.0)); + } + pe.push(ge); + } el.push(pe); } let origin = Affine::translate((-ox, -oy)); diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 51dc66d84..7dd5867b6 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -9,8 +9,8 @@ use designcraft_color::{Color, ColorType, Gradient, GradientKind, GradientStop, use designcraft_doc::{ Asset, AssetId, BaselineGrid, CellRange, CharAttrs, CharFormat, CharRun, CharacterStyle, Columns, ColumnsKind, Content, DocSettings, Document, DropShadow, Effects, Fill, GridAlign, GridRelative, Guide, Item, ItemId, Kerning, LAYER_COLORS, Layer, LayerId, ListType, Margins, ObjectStyle, - Page, PageId, PageSide, ParaAttrs, ParaFormat, ParagraphStyle, ParentInfo, RowHeightMode, Rule, Section, Shape, SpanColumns, Spread, SpreadId, - Story, StoryId, Stroke, Styles, TabStop, Table, TextFrame, TextFrameOptions, TextWrap, story as st, + Orientation, Page, PageId, PageSide, ParaAttrs, ParaFormat, ParagraphStyle, ParentInfo, RowHeightMode, Rule, Section, Shape, SpanColumns, Spread, + SpreadId, Story, StoryId, Stroke, Styles, TabStop, Table, TextFrame, TextFrameOptions, TextWrap, story as st, }; use designcraft_geom::corners::{Corner, CornerOptions}; use designcraft_geom::{Affine, Anchor, PathData, Point, Rect, SubPath}; @@ -116,6 +116,14 @@ pub fn import_idml_report(bytes: &[u8], read_link: &dyn Fn(&str) -> Option<Vec<u Ok(Imported { document, warnings }) } +/// View Preferences › Ruler Origin. +#[derive(Clone, Copy, PartialEq, Eq)] +enum RulerOrigin { + Spread, + Page, + Spine, +} + /// A package part name in the form the lookup compares: InDesign and other IDML writers differ /// in percent-encoding, case, path separators and a leading `./` or `/`. fn normalize_part_name(name: &str) -> String { @@ -159,6 +167,8 @@ struct ItemCtx { struct Importer<'r> { read_link: &'r dyn Fn(&str) -> Option<Vec<u8>>, + /// View Preferences › Ruler Origin: where a guide's Location is measured from. + ruler_origin: RulerOrigin, next_id: u64, settings: DocSettings, default_margins: Option<(Margins, Columns)>, @@ -275,6 +285,7 @@ impl<'r> Importer<'r> { styles.paragraph.iter_mut().for_each(|s| s.based_on = None); Importer { read_link, + ruler_origin: RulerOrigin::Spread, next_id: 0, settings: DocSettings::default(), default_margins: None, @@ -362,6 +373,11 @@ impl<'r> Importer<'r> { if let Some(v) = e.num("CursorKeyIncrement") { self.settings.keyboard_increment = v; } + self.ruler_origin = match e.get("RulerOrigin") { + Some("PageOrigin") => RulerOrigin::Page, + Some("SpineOrigin") => RulerOrigin::Spine, + _ => RulerOrigin::Spread, + }; } "GridPreference" => self.grid_prefs(e), "PasteboardPreference" => { @@ -1964,6 +1980,33 @@ impl<'r> Importer<'r> { // ---------- spreads and items ---------- + /// Ruler guides of a `<Page>` (its `<Guide>` children), `page` its rectangle in our spread + /// coordinates. A guide's Location is measured from the ruler origin: the spread's top left, the + /// page's, or the spine. Fit to Page off: the guide crosses the spread. + fn guides_of(&self, p: &El, page: Rect, spine: f64) -> Vec<Guide> { + p.find_all("Guide") + .filter_map(|g| { + let at = g.num("Location").filter(|v| v.is_finite() && v.abs() < 1e7)?; + let orientation = if g.get("Orientation") == Some("Horizontal") { Orientation::Horizontal } else { Orientation::Vertical }; + let position = match (orientation, self.ruler_origin) { + (Orientation::Horizontal, RulerOrigin::Page) => page.y0 + at, + (Orientation::Horizontal, _) => at, + (Orientation::Vertical, RulerOrigin::Page) => page.x0 + at, + (Orientation::Vertical, RulerOrigin::Spine) => spine + at, + (Orientation::Vertical, RulerOrigin::Spread) => at, + }; + Some(Guide { + orientation, + position, + spread: g.get("FitToPage") == Some("false"), + locked: g.get("Locked") == Some("true"), + layer: g.get("ItemLayer").and_then(|l| self.layer_ids.get(l)).copied(), + liquid: false, + }) + }) + .collect() + } + fn spread(&mut self, e: &El, id: SpreadId, master: bool) -> Spread { let facing = self.settings.facing_pages; // Page rectangles in IDML spread coordinates. @@ -1981,6 +2024,9 @@ impl<'r> Importer<'r> { let min_y = pages.iter().map(|(r, _)| r.y0).fold(f64::INFINITY, f64::min); let (min_x, min_y) = if min_x.is_finite() { (min_x, min_y) } else { (0.0, 0.0) }; let shift = Affine::translate((-min_x, -min_y)); + // The spine: between a left and a right page (else the spread's left edge). + let spine = pages.iter().map(|(r, _)| r.x0 - min_x).filter(|x| *x > 1e-6).fold(f64::INFINITY, f64::min); + let spine = if spine.is_finite() && facing { spine } else { 0.0 }; let show = e.get("ShowMasterItems") != Some("false"); let first_abs: usize = self.spreads.iter().map(|s| s.pages.len()).sum(); let mut out_pages = Vec::new(); @@ -2013,7 +2059,7 @@ impl<'r> Importer<'r> { parent, side, overridden: vec![], - guides: Vec::<Guide>::new(), + guides: self.guides_of(p, Rect::new(r.x0 - min_x, r.y0 - min_y, r.x1 - min_x, r.y1 - min_y), spine), show_parent_items: show, liquid: Default::default(), transition: None, diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 57801e34f..934281734 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -2169,3 +2169,54 @@ fn frame_preference_property_color_resolves_for_frames_and_object_styles() { assert_eq!(colors, vec!["Near", "Near", "Far", designcraft_color::swatch::NONE]); assert_eq!(d.styles.object_style("Child").unwrap().text_frame.as_ref().unwrap().column_rule_color, "Near"); } + +/// A two-page spread with ruler guides on its pages (and one on the parent), in `origin` ruler +/// coordinates. +fn guides_package(origin: &str) -> Vec<u8> { + let prefs = PREFS.replace("</idPkg:Preferences>", &format!(r#"<ViewPreference RulerOrigin="{origin}"/></idPkg:Preferences>"#)); + let spread = SPREAD.replace( + r#"<Page Self="p2" AppliedMaster="m1" GeometricBounds="0 0 700 500" ItemTransform="1 0 0 1 0 -350"/>"#, + r#"<Page Self="p2" AppliedMaster="m1" GeometricBounds="0 0 700 500" ItemTransform="1 0 0 1 0 -350"> + <Guide Self="g1" Orientation="Vertical" Location="40" FitToPage="true" ItemLayer="L1" Locked="true"/> + <Guide Self="g2" Orientation="Horizontal" Location="100" FitToPage="false"/> + </Page>"#, + ); + let master = MASTER.replace( + r#"<Page Self="mp1" GeometricBounds="0 0 700 500" ItemTransform="1 0 0 1 -500 -350"/>"#, + r#"<Page Self="mp1" GeometricBounds="0 0 700 500" ItemTransform="1 0 0 1 -500 -350"><Guide Self="mg" Orientation="Vertical" Location="84"/></Page>"#, + ); + zip_files(&[ + ("designmap.xml", DESIGNMAP), + ("Resources/Graphic.xml", GRAPHIC), + ("Resources/Styles.xml", STYLES), + ("Resources/Preferences.xml", &prefs), + ("MasterSpreads/MasterSpread_m1.xml", &master), + ("Spreads/Spread_sp1.xml", &spread), + ("Stories/Story_s1.xml", STORY), + ]) +} + +/// Ruler guides come in from IDML (they were dropped): on their page, measured from the ruler +/// origin, with Fit to Page, Locked and their layer; parent pages' too. They go out again. +#[test] +fn ruler_guides_import_and_round_trip() { + use designcraft_doc::Orientation::{Horizontal, Vertical}; + let guides = |d: &Document| { + let p = &d.spreads[0].pages[1]; + p.guides.iter().map(|g| (g.orientation, g.position, g.spread, g.locked, g.layer.is_some())).collect::<Vec<_>>() + }; + // The second page starts at x 500 in our spread coordinates. + let d = import_idml(&guides_package("PageOrigin")).unwrap(); + assert_eq!(guides(&d), [(Vertical, 540.0, false, true, true), (Horizontal, 100.0, true, false, false)]); + let d = import_idml(&guides_package("SpineOrigin")).unwrap(); + assert_eq!(guides(&d)[0].1, 540.0, "the spine is the second page's left edge"); + let d = import_idml(&guides_package("SpreadOrigin")).unwrap(); + assert_eq!(guides(&d)[0].1, 40.0); + assert_eq!(d.parents[0].pages[0].guides.len(), 1, "a parent page's guide"); + assert_eq!(d.parents[0].pages[0].guides[0].position, 84.0); + // Round trip: written under each page, read back where they were. + let d = import_idml(&guides_package("PageOrigin")).unwrap(); + let back = import_idml(&export_idml(&d)).unwrap(); + assert_eq!(guides(&back), guides(&d)); + assert_eq!(back.parents[0].pages[0].guides.len(), 1); +} From 89f6adb6fbc3d9d5a19d0be177c19daede7794d0 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 15 Oct 2026 01:25:04 +0200 Subject: [PATCH 234/238] Find Font: a style that differs only in spacing isn't missing Montilla Design's Flex Layout file names its body face "Regular " (with a trailing space, as the designer's copy of Articulat CF did). Face lookup already ignores spacing and case, so the text is drawn in Regular, but font.list (Find Font) compared names exactly and reported the style missing. It now uses the lookup's comparison (designcraft_fonts::same_style). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 1 + crates/engine/src/cmd/fonts.rs | 20 +++++++++++++++++++- crates/fonts/src/fontdb.rs | 6 ++++++ crates/fonts/src/lib.rs | 2 +- 4 files changed, 27 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9f8168e50..0e9bd51a9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-15** · Find Font no longer reports a style missing when it differs from the installed one only in spacing or case (InDesign files can name `Regular ` with a trailing space); the text was already drawn in the right face. No readiness percentage change. - **2026-10-13** · Object › Fitting has InDesign's shortcuts: Fill Frame Proportionally ⌘⌥⇧C, Fit Content Proportionally ⌘⌥⇧E, Fit Frame to Content ⌘⌥C, Fit Content to Frame ⌘⌥E, Center Content ⌘⇧E. No readiness percentage change. - **2026-10-13** · Gradients between process colours blend as inks, as InDesign shows them: magenta to cyan passes through C 50 M 50 (a light lilac) on screen instead of the darker RGB mix, and a midpoint between CMYK or grey stops no longer turns the PDF shading RGB. No readiness percentage change. - **2026-10-14** · The Swatches panel has InDesign's header: a Fill/Stroke proxy, Formatting Affects Container/Text and the Tint of the colour in front (a tint alone keeps each object's own swatch). Clicking a swatch applies it to the fill or stroke in front (of the text, when formatting affects text), and the bottom row has New Color Group, New Swatch and Delete Swatch. No readiness percentage change. diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index 3b173c199..f119dd087 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -73,7 +73,7 @@ fn list(d: &Document) -> Vec<Value> { .into_iter() .map(|((family, style), n)| { let missing = !db.has_family(&family); - let style_missing = !missing && !db.styles(&family).iter().any(|s| s.eq_ignore_ascii_case(&style)); + let style_missing = !missing && !db.styles(&family).iter().any(|s| designcraft_fonts::same_style(s, &style)); let source = (!missing).then(|| match db.face(&family, &style).source { FontSource::Bundled => "bundled", FontSource::Installed(_) => "installed", @@ -225,4 +225,22 @@ mod tests { assert_eq!((&l[0]["family"], &l[0]["missing"]), (&json!("Nope (OTF)"), &json!(true)), "{l}"); assert!(kinds(&mut s).iter().any(|k| k == "missingFont")); } + + /// A style name that differs only in spacing (Montilla Design's file sets `Regular ` with a + /// trailing space) is the face the text is drawn in, not a missing style. + #[test] + fn a_style_with_extra_spacing_is_not_missing() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Text"})).unwrap(); + let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 4})).unwrap(); + s.execute("type.char", &json!({"fontFamily": "Source Serif 4", "fontStyle": "Regular "})).unwrap(); + let l = s.execute("font.list", &json!({})).unwrap(); + let entry = l.as_array().unwrap().iter().find(|f| f["style"] == "Regular ").unwrap(); + assert_eq!((&entry["missing"], &entry["styleMissing"]), (&json!(false), &json!(false)), "{l}"); + s.execute("type.char", &json!({"fontFamily": "Source Serif 4", "fontStyle": "Nope"})).unwrap(); + let l = s.execute("font.list", &json!({})).unwrap(); + assert_eq!(l[0]["styleMissing"], json!(true), "{l}"); + } } diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index bc9c9e65f..0ee10a6b4 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -845,6 +845,12 @@ pub fn base_style(style: &str) -> &str { style.split('{').next().unwrap_or(style).trim() } +/// Do two style names name the same face? Spacing, punctuation and case don't count, as when a face +/// is looked up (InDesign documents can carry `Regular ` with a trailing space). +pub fn same_style(a: &str, b: &str) -> bool { + norm(a) == norm(b) +} + fn norm(s: &str) -> String { s.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect() } diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 5ff395df8..088fba02c 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -16,7 +16,7 @@ mod vertical; 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, system_font_dirs, + MAX_DOCUMENT_FONTS_TOTAL, ScopedFonts, base_style, bundled, same_style, system_font_dirs, }; pub use group::{FamilyInfo, FontGroup, sort_for_menu}; pub use harfrust::Feature; From 2dcef6c951cea7a8638ef930cad8edf6827ee39e Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze <david.machakhelidze@gmail.com> Date: Thu, 15 Oct 2026 03:38:25 +0400 Subject: [PATCH 235/238] i18n(ka): bring the Georgian catalog up to date Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/i18n/ka.rs | 37 +++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index bf0e67201..8350f2c53 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -1784,4 +1784,41 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Stop Preview", "წინასწარი ნახვის შეწყვეტა"), ("Update Data Source", "მონაცემთა წყაროს განახლება"), ("Validate from Root Element", "ძირეული ელემენტიდან შემოწმება"), + ("Duplicate Spread at End of Document", "გაშლის დუბლირება დოკუმენტის ბოლოში"), + ("Delete Spread", "გაშლის წაშლა"), + ("Parent Pages", "საბაზო გვერდები"), + ("Delete selected pages", "მონიშნული გვერდების წაშლა"), + ("Create new page", "ახალი გვერდის შექმნა"), + ("Edit page size", "გვერდის ზომის რედაქტირება"), + ("Custom Page Size…", "გვერდის მორგებული ზომა…"), + ("Page:", "გვერდი:"), + ("Content:", "შიგთავსი:"), + ("Object Constraints:", "ობიექტის შეზღუდვები:"), + ("Resize with Page:", "ზომის შეცვლა გვერდთან ერთად:"), + ("Pin:", "სამაგრი:"), + ("Object-Based", "ობიექტებზე დაფუძნებული"), + ("Guide-Based", "დამხმარე ხაზებზე დაფუძნებული"), + ("Resize Page", "გვერდის ზომის შეცვლა"), + ("Liquid Page Rule:", "გვერდის მოქნილი წესი:"), + ("Show Parent Page Overlay", "საბაზო გვერდის გადაფარვის ჩვენება"), + ("Controlled by Parent", "იმართება საბაზო გვერდიდან"), + ("US Business Card", "სავიზიტო ბარათი (აშშ)"), + ("Object is pinned relative to the page edge. Click to release.", "ობიექტი მიმაგრებულია გვერდის კიდესთან. დაწკაპეთ გასათავისუფლებლად."), + ("Click to pin the object relative to the page edge.", "დაწკაპეთ ობიექტის გვერდის კიდესთან მისამაგრებლად."), + ( + "The object changes width as the page changes width. Click to lock the object width.", + "ობიექტის სიგანე გვერდის სიგანესთან ერთად იცვლება. დაწკაპეთ ობიექტის სიგანის ჩასაკეტად.", + ), + ( + "The object's width is locked. Click to let the object change width as the page changes width.", + "ობიექტის სიგანე ჩაკეტილია. დაწკაპეთ, რომ ობიექტის სიგანე გვერდის სიგანესთან ერთად შეიცვალოს.", + ), + ( + "The object changes height as the page changes height. Click to lock the object height.", + "ობიექტის სიმაღლე გვერდის სიმაღლესთან ერთად იცვლება. დაწკაპეთ ობიექტის სიმაღლის ჩასაკეტად.", + ), + ( + "The object's height is locked. Click to let the object change height as the page changes height.", + "ობიექტის სიმაღლე ჩაკეტილია. დაწკაპეთ, რომ ობიექტის სიმაღლე გვერდის სიმაღლესთან ერთად შეიცვალოს.", + ), ]; From ccf4f4aee4bd2af0bd1b132388dbc04e11c11df9 Mon Sep 17 00:00:00 2001 From: moppeeer <svlmrc@gmail.com> Date: Thu, 15 Oct 2026 01:40:50 +0200 Subject: [PATCH 236/238] An empty last paragraph isn't overset text, as in InDesign A story that ends with a paragraph return has an empty last paragraph. When it didn't fit, DesignCraft marked the story overset at the end of its text (Preflight: "Overset text: 0 characters"; the out port turned red). InDesign doesn't: Montilla Design's Flex Layout file has a caption ending with a return, fitted to its three lines, and InDesign reports no errors. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 1 + crates/compose/src/lib.rs | 5 +++++ crates/compose/src/tests.rs | 16 ++++++++++++++++ 3 files changed, 22 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 9f8168e50..a800b9078 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-15** · A story that ends with a return is no longer overset when only its empty last paragraph doesn't fit, as in InDesign (Preflight said "Overset text: 0 characters" for a caption InDesign shows with no errors). No readiness percentage change. - **2026-10-13** · Object › Fitting has InDesign's shortcuts: Fill Frame Proportionally ⌘⌥⇧C, Fit Content Proportionally ⌘⌥⇧E, Fit Frame to Content ⌘⌥C, Fit Content to Frame ⌘⌥E, Center Content ⌘⇧E. No readiness percentage change. - **2026-10-13** · Gradients between process colours blend as inks, as InDesign shows them: magenta to cyan passes through C 50 M 50 (a light lilac) on screen instead of the darker RGB mix, and a midpoint between CMYK or grey stops no longer turns the PDF shading RGB. No readiness percentage change. - **2026-10-14** · The Swatches panel has InDesign's header: a Fill/Stroke proxy, Formatting Affects Container/Text and the Tint of the colour in front (a tint alone keeps each object's own swatch). Clicking a swatch applies it to the fill or stroke in front (of the text, when formatting affects text), and the bottom row has New Color Group, New Swatch and Delete Swatch. No readiness percentage change. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index c6312e9f5..ec7d6d30d 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -936,6 +936,11 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C pi = j; continue 'paras; } + // An empty last paragraph (the story ends with a return) isn't overset text, as in + // InDesign: nothing of it is left to show. + if prange.is_empty() && prange.start >= story.text.len() { + break 'paras; + } out.overset_at = Some(glyphs.get(g0).map(|g| g.byte).unwrap_or(prange.start)); // The part of the paragraph that fits keeps its shading and border. para_box_decos(&mut out, pi, &pp); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index a20ec78b3..b226c15fd 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -4060,3 +4060,19 @@ fn empty_paragraphs_get_no_bullet_or_number() { assert_eq!(list_labels(&cs, LABEL_CHARS), ["1.", "", "2.", ""], "list `{name}`"); } } + +/// A story that ends with a return isn't overset when only its empty last paragraph doesn't fit, +/// as in InDesign (Montilla Design's file: a caption fitted to its three lines, "No errors"). Text +/// that doesn't fit still is. +#[test] +fn an_empty_last_paragraph_isnt_overset() { + // 12 pt, 14.4 leading: two lines fit in 35 pt, a third (baseline ~40) doesn't. + let (d, sid, _) = doc_with("One\nTwo\n", Rect::new(0.0, 0.0, 200.0, 35.0), ParaAttrs::default()); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert_eq!(all_lines(&cs).len(), 2); + assert!(!cs.is_overset(), "only the empty paragraph after the last return is left"); + let (d, sid, _) = doc_with("One\nTwo\nThree", Rect::new(0.0, 0.0, 200.0, 35.0), ParaAttrs::default()); + assert!(compose_story(&d, sid, &ComposeOptions::default()).is_overset()); + let (d, sid, _) = doc_with("One\nTwo\n\n", Rect::new(0.0, 0.0, 200.0, 35.0), ParaAttrs::default()); + assert!(compose_story(&d, sid, &ComposeOptions::default()).is_overset(), "an empty paragraph before the last one is text"); +} From edfc98ffd702300ff5f361389a3077c0f057d365 Mon Sep 17 00:00:00 2001 From: Brandon Thomas <echelon@gmail.com> Date: Wed, 14 Oct 2026 22:05:13 -0400 Subject: [PATCH 237/238] fix: integrate batched community PRs (main's new tests with #559's FrameSpec.section_marker and #604's load_document_fonts guard) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/compose/src/lib.rs | 1 + crates/compose/src/tests.rs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index cef3b674f..3008eac84 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -3749,6 +3749,7 @@ mod column_allocation_safety_tests { vertical: false, exclusions: vec![], page_name: None, + section_marker: None, page: None, grid: None, left_page: false, diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index e38e236dd..ece34fc59 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -3577,7 +3577,7 @@ fn drop_cap_in_a_document_font_aligns_its_left_edge() { let folder = std::env::temp_dir().join(format!("dc-compose-dropcap-docfont-{}", std::process::id())); std::fs::create_dir_all(&folder).unwrap(); std::fs::write(folder.join("dropcap.ttf"), designcraft_fonts::testing::font_with_glyph(FAMILY, &chars, 'O').unwrap()).unwrap(); - let scope = designcraft_fonts::FontDb::global().load_document_fonts(&folder).scope; + let scope = designcraft_fonts::FontDb::global().load_document_fonts(&folder, &|_| Ok(())).scope; let para = ParaAttrs { left_indent: Some(10.0), ..drop_cap(2, 1) }; let (mut d, sid, _) = doc_with(&text, Rect::new(0.0, 0.0, 300.0, 1000.0), para); d.font_scope = scope; From 8da5bb5bab6e7be6c83c3402b4d45b8fab92474b Mon Sep 17 00:00:00 2001 From: Brandon Thomas <echelon@gmail.com> Date: Wed, 14 Oct 2026 22:26:51 -0400 Subject: [PATCH 238/238] =?UTF-8?q?fix:=20integrate=20batched=20community?= =?UTF-8?q?=20PRs=20(pt-BR/Arabic=20catalogs=20drop=20keys=20main=20alread?= =?UTF-8?q?y=20translates;=20Typography=20in=20Window=20=E2=80=BA=20Type?= =?UTF-8?q?=20&=20Tables;=20Georgian=20for=20the=20new=20menu=20labels)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/i18n/ar.rs | 5 ----- crates/ui-egui/src/i18n/ka.rs | 5 +++++ crates/ui-egui/src/i18n/pt_br.rs | 11 ----------- crates/ui-egui/src/menus.rs | 7 +++++-- 4 files changed, 10 insertions(+), 18 deletions(-) diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 4cdcacbbe..eae948581 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -1549,7 +1549,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("All records", "كل السجلات"), ("Alpha Channel", "قناة ألفا"), ("Anchored Object Options", "خيارات الكائن المرسّى"), - ("Apply Parent to Pages", "تطبيق الأصل على الصفحات"), ("ArtCraft App Page", "صفحة تطبيق ArtCraft"), ("ArtCraft Website", "موقع ArtCraft"), ("Bind to frame", "ربط بالإطار"), @@ -1567,12 +1566,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Column Break", "فاصل عمود"), ("Column spacing", "تباعد الأعمدة"), ("Columns first", "الأعمدة أولاً"), - ("Columns:", "الأعمدة:"), ("Conditional Text", "نص شرطي"), ("Copyright Symbol", "رمز حقوق النشر"), ("Corner Options", "خيارات الزوايا"), ("Create Guides", "إنشاء أدلة"), - ("Current Page Number", "رقم الصفحة الحالية"), ("Cyan", "سماوي"), ("Dark", "داكن"), ("Data Merge", "دمج البيانات"), @@ -1624,7 +1621,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Hair Space", "مسافة شعرية"), ("High Contrast", "تباين عالٍ"), ("High Resolution", "دقة عالية"), - ("Horizontal:", "أفقي:"), ("I, II, III, IV...", "أ، ب، ج، د..."), ("Import XML", "استيراد XML"), ("Indent to Here", "إزاحة بادئة إلى هنا"), @@ -1766,7 +1762,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Type/Type on a Path", "نص/النص على مسار"), ("URL:", "URL:"), ("Unassigned", "غير معيّن"), - ("Vertical:", "رأسي:"), ("View/Display Performance", "عرض/أداء العرض"), ("View/Grids & Guides", "عرض/الشبكات والأدلة"), ("Window/Arrange", "نافذة/ترتيب"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 8350f2c53..7b8614369 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -1821,4 +1821,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ "The object's height is locked. Click to let the object change height as the page changes height.", "ობიექტის სიმაღლე ჩაკეტილია. დაწკაპეთ, რომ ობიექტის სიმაღლე გვერდის სიმაღლესთან ერთად შეიცვალოს.", ), + ("Bulleted & Numbered Lists", "მარკირებული და დანომრილი სიები"), + ("Styles", "სტილები"), + ("Object & Layout", "ობიექტი და განლაგება"), + ("Editorial", "სარედაქციო"), + ("Essentials Classic", "ძირითადი (კლასიკური)"), ]; diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 583371a7a..223a350a9 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -1157,7 +1157,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Tabs", "Tabulações"), ("Story", "Matéria"), ("Create Outlines", "Criar contornos"), - ("Show Hidden Characters", "Mostrar caracteres ocultos"), ("Unmerge Cells", "Desfazer mesclagem de células"), ("Snap to Guides", "Alinhar às guias"), ("Snap to Document Grid", "Alinhar à grade do documento"), @@ -1185,7 +1184,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Add Hyphenation Exception", "Adicionar exceção de hifenização"), ("Add Item", "Adicionar item"), ("Add Page to Spread", "Adicionar página ao spread"), - ("Allow Selected Spread to Shuffle", "Permitir que o spread selecionado seja reorganizado"), ("Anchored Object Options…", "Opções de objeto ancorado…"), ("Anchored Objects", "Objetos ancorados"), ("Apply Attributes (Eyedropper)", "Aplicar atributos (conta-gotas)"), @@ -1225,7 +1223,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Convert to Form Field", "Converter em campo de formulário"), ("Copy Link(s) To…", "Copiar link(s) para…"), ("Corner Options…", "Opções de canto…"), - ("Create Alternate Layout…", "Criar layout alternativo…"), ("Create Frame", "Criar quadro"), ("Create Merged Document…", "Criar documento mesclado…"), ("Cross-Reference Formats", "Formatos de referência cruzada"), @@ -1241,7 +1238,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Delete Workspace…", "Excluir espaço de trabalho…"), ("DesignCraft Website…", "Site do DesignCraft…"), ("DesignCraft on GitHub…", "DesignCraft no GitHub…"), - ("Detach All Objects from Parent", "Desvincular todos os objetos da página-mestre"), ("Directional Feather", "Esmaecimento direcional"), ("Distribute", "Distribuir"), ("Dock Panel", "Encaixar painel"), @@ -1289,9 +1285,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Inner Shadow", "Sombra interna"), ("Insert Anchored Object…", "Inserir objeto ancorado…"), ("Insert Cross-Reference…", "Inserir referência cruzada…"), - ("Insert Endnote", "Inserir nota de fim"), ("Insert Math Expression…", "Inserir expressão matemática…"), - ("Insert Pages…", "Inserir páginas…"), ("Insert Variable", "Inserir variável"), ("Inspect Document", "Inspecionar documento"), ("Interface Color Theme", "Tema de cores da interface"), @@ -1340,7 +1334,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Open Library", "Abrir biblioteca"), ("Outer Glow", "Brilho externo"), ("Overprint Preview", "Visualização de sobreimpressão"), - ("Override All Parent Page Items", "Substituir todos os itens da página-mestre"), ("Page Transition", "Transição de página"), ("Page Transitions", "Transições de página"), ("Paragraph Formatting", "Formatação de parágrafo"), @@ -1363,7 +1356,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Recovery Data", "Dados de recuperação"), ("Reject All Changes", "Rejeitar todas as alterações"), ("Release Compound Path", "Liberar traçado composto"), - ("Remove All Local Overrides", "Remover todas as substituições locais"), ("Remove Document", "Remover documento"), ("Report an Issue…", "Relatar um problema…"), ("Reset Shortcuts", "Redefinir atalhos"), @@ -1487,14 +1479,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Move Column", "Mover coluna"), ("Move Guide", "Mover guia"), ("Move Layer", "Mover camada"), - ("Move Pages…", "Mover páginas…"), ("Move to Layer", "Mover para a camada"), ("New Article", "Novo artigo"), ("New Bookmark", "Novo marcador"), ("New Layer…", "Nova camada…"), - ("New Note", "Nova nota"), ("New Object Style…", "Novo estilo de objeto…"), - ("New Parent…", "Nova página-mestre…"), ("New Tag", "Nova tag"), ("Paste in Place", "Colar no mesmo lugar"), ("Place Loaded Graphic", "Inserir gráfico carregado"), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 2a4690326..86c2088e8 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -777,6 +777,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "ui:window.panel|Glyphs|{\"panel\": \"glyphs\"}", "ui:window.panel|Paragraph|{\"panel\": \"paragraph\"}", "ui:window.panel|Table|{\"panel\": \"table\"}", + "ui:window.panel|Typography|{\"panel\": \"typography\"}", "<", ">Utilities", "ui:window.panel|Book|{\"panel\": \"book\"}", @@ -2993,8 +2994,10 @@ mod tests { _ => {} } } - // Labels without letters ("180°") read the same in every language. - untranslated.extend(labels.into_iter().filter(|l| l.chars().any(char::is_alphabetic) && crate::i18n::tr("ka", l) == l.as_str())); + // Labels without letters ("180°") or point sizes ("72 pt") read the same in every language. + let size = |l: &str| l.strip_suffix(" pt").is_some_and(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit())); + untranslated + .extend(labels.into_iter().filter(|l| l.chars().any(char::is_alphabetic) && !size(l) && crate::i18n::tr("ka", l) == l.as_str())); } assert!(untranslated.is_empty(), "untranslated Georgian menu labels: {untranslated:?}"); }