From 2d6213d40d0663484a254d8b495a46ac10aeb9c2 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 6 Oct 2026 13:07:55 -0400 Subject: [PATCH 01/56] Text editing: clicks, trailing spaces, bold/italic, clipboard, formatting affects text (#23) - Double-clicking a text frame with the Selection tool edits its text. The canvas counts presses itself (egui reports double clicks only on release, so the press never saw them): 2 clicks select the word or run of spaces under the pointer, 3 the line, 4 the paragraph, 5 the story (`text.selectAt`; pointer events carry `clicks`). - The caret moves past spaces typed at the end of a line: trailing spaces follow the line undrawn instead of being dropped. - Bold and Italic (Shift+Cmd+B / Shift+Cmd+I, B and I in the Character panel) toggle the family's own styles per run, keeping the other trait (Italic + Bold = Bold Italic). Formatting chosen at a caret applies to what is typed next. - Copy, Cut and Paste reach the system clipboard however they are chosen, including from the native macOS menu; without a native menu the copy and paste keys work for objects too. - Formatting Affects Text (J, Tools panel) sends swatches, colours, tints, None, Swap and Default to the text of the selected frames; the proxy and the Swatches and Color panels show the text's colours. --- Cargo.lock | 1 + apps/designcraft/Cargo.toml | 1 + apps/designcraft/src/main.rs | 1 + crates/compose/src/lib.rs | 52 ++- crates/compose/src/notes.rs | 7 +- crates/compose/src/table.rs | 13 +- crates/compose/src/tests.rs | 26 ++ crates/engine/src/cmd/style.rs | 26 +- crates/engine/src/cmd/text.rs | 400 +++++++++++++++++++++--- crates/engine/src/lib.rs | 3 + crates/fonts/src/fontdb.rs | 46 ++- crates/fonts/src/lib.rs | 16 + crates/mcp/src/headless.rs | 3 +- crates/mcp/src/tools.rs | 1 + crates/tools/src/lib.rs | 14 +- crates/tools/src/select.rs | 32 +- crates/tools/src/tests.rs | 35 +++ crates/tools/src/text.rs | 28 +- crates/ui-egui/src/canvas.rs | 50 +-- crates/ui-egui/src/control.rs | 3 +- crates/ui-egui/src/dialogs.rs | 2 +- crates/ui-egui/src/i18n.rs | 12 +- crates/ui-egui/src/i18n/ar.rs | 2 +- crates/ui-egui/src/lib.rs | 41 ++- crates/ui-egui/src/menus.rs | 35 +++ crates/ui-egui/src/panels/mod.rs | 38 +++ crates/ui-egui/src/panels/properties.rs | 29 ++ crates/ui-egui/src/panels/swatches.rs | 45 +-- crates/ui-egui/src/toolbar.rs | 26 +- docs/control-protocol.md | 2 +- 30 files changed, 842 insertions(+), 148 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e5ae30382..a2b4e6f3a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -857,6 +857,7 @@ dependencies = [ name = "designcraft" version = "0.2.1" dependencies = [ + "arboard", "designcraft-engine", "designcraft-ui-egui", "eframe", diff --git a/apps/designcraft/Cargo.toml b/apps/designcraft/Cargo.toml index eaacdd328..737eb00a5 100644 --- a/apps/designcraft/Cargo.toml +++ b/apps/designcraft/Cargo.toml @@ -23,6 +23,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 2fb3a1fb9..228e4dd9f 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -152,6 +152,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()))), + clipboard_text: Some(Box::new(|| arboard::Clipboard::new().and_then(|mut c| c.get_text()).ok())), ..Default::default() } } diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 9f91aa68f..25361b636 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -35,9 +35,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)] @@ -795,6 +795,15 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo let last = k + 1 == breaks.len(); let (mut placed, end_x, ratio) = layout_line(&glyphs, s, e, b.hyphen, lx0, lx1, col.x0, &pp, &spacing, last, b.forced && !last, f.left_page); + // 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 !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; + } + } ruby::annotate(&styles_tab, &mut placed); 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); @@ -2047,6 +2056,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, } } @@ -2080,19 +2091,42 @@ 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 { + 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 + } + }; + 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 { 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())?; + 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); diff --git a/crates/compose/src/notes.rs b/crates/compose/src/notes.rs index d806b5460..4fe538fd6 100644 --- a/crates/compose/src/notes.rs +++ b/crates/compose/src/notes.rs @@ -196,10 +196,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) -> 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/table.rs b/crates/compose/src/table.rs index bda001820..0a7ca62fb 100644 --- a/crates/compose/src/table.rs +++ b/crates/compose/src/table.rs @@ -428,6 +428,17 @@ fn emit( /// 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, +) -> Option<(u64, usize, usize, usize)> { let ft = cs.frames.get(fi)?; for t in &ft.tables { if !t.rect.contains(p) { @@ -436,7 +447,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 511a703a5..1d7588cf8 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1027,3 +1027,29 @@ fn only_english_text_gets_english_hyphenation() { st.format_chars(0..n, |f| f.over.language = Some("French".into())); assert_eq!(hyphenated(&d2, sid), 0, "French isn't hyphenated with English rules"); } + +#[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"); + } +} diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index d1fccebf7..2dd112206 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -127,8 +127,8 @@ pub fn specs() -> Vec { "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 (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)", + |s| super::has_text(s).or_else(|_| super::has_selection(s)), apply_color ), cmd!( @@ -821,8 +821,11 @@ fn apply_gradient(s: &mut Session, p: &Value) -> Result { fn apply_color(s: &mut Session, p: &Value) -> Result { 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 ids = super::targets(s, p)?; - s.edit(|d, _| { + let text = p.get("text").and_then(Value::as_bool).unwrap_or(false) || s.doc()?.selection.text.is_some(); + let (ids, texts) = if text { (vec![], super::text::format_targets_pub(s)) } else { (super::targets(s, p)?, vec![]) }; + // 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, _| { // Reuse a swatch holding exactly this colour, else add an unnamed one. let existing = d .swatches @@ -854,8 +857,21 @@ fn apply_color(s: &mut Session, p: &Value) -> Result { } } } + 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) } fn add_to_swatches(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index a3cb09ca3..553e27721 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -22,15 +22,18 @@ pub fn specs() -> Vec { 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.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, paragraph or the whole story", + has_doc, + |s, p| select_at(s, p, str_param(p, "unit").unwrap_or("word")) + ), 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()?; @@ -132,8 +135,24 @@ pub fn specs() -> Vec { 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.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, @@ -233,6 +252,17 @@ pub(crate) fn story_of(s: &Session, p: &Value) -> Option { /// 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)> { + 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, +) -> Option<(StoryId, usize, Option)> { let st = s.active()?; let loc = st.doc.find(frame)?; let it = st.doc.item_at(&loc)?; @@ -241,16 +271,76 @@ 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)?; - 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 { + const ID: &str = "text.selectAt"; + 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_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)) + } + "paragraph" => story.para_ranges().get(story.para_at(b)).map_or((b, b), |r| (r.start, r.end)), + "story" => (0, text.len()), + other => return Err(bad(ID, format!("unknown unit `{other}` (word, line, paragraph or story)"))), + }; + s.text_drag = false; + 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. At +/// the end of a paragraph, what ends it. +fn word_or_spaces_at(s: &str, i: usize) -> (usize, usize) { + let at = |i: usize| s.get(i..).and_then(|r| r.chars().next()).filter(|c| *c != '\n'); + let i = if at(i).is_none() && i > 0 { prev_char(s, i) } else { i }; + let Some(c) = at(i) else { return (i, i) }; + let space = |c: char| c.is_whitespace() && c != '\n'; + if is_word(c) { + word_bounds(s, i) + } else if space(c) { + let a = s[..i].char_indices().rev().take_while(|(_, c)| space(*c)).last().map_or(i, |(k, _)| k); + let e = s[i..].char_indices().find(|(_, c)| !space(*c)).map_or(s.len(), |(k, _)| i + k); + (a, e) + } else { + (i, i + c.len_utf8()) + } +} + +/// 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, cell: Option) -> std::sync::Arc { + 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 { let frame = id_param(p, "frame").ok_or_else(|| bad("text.placeCaret", "missing frame"))?; let pt = point_param(p, "point").unwrap_or(Point::ZERO); @@ -336,6 +426,8 @@ fn insert(s: &mut Session, p: &Value) -> Result { 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()); + let typing = typing_format(s).cloned(); + s.typing_format = None; 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))?; @@ -383,6 +475,9 @@ fn insert(s: &mut Session, p: &Value) -> Result { // 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; @@ -390,6 +485,11 @@ fn insert(s: &mut Session, p: &Value) -> Result { 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})) @@ -582,13 +682,8 @@ fn move_caret(s: &mut Session, p: &Value) -> Result { 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()); let collapse_to = |left: bool| if left { t.range().start } else { t.range().end }; let new = match dir.as_str() { @@ -680,6 +775,23 @@ pub(crate) fn format_targets(s: &Session) -> Vec { 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, +} + +/// 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()) +} + pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result { let mut a = CharAttrs::default(); if let Some(o) = attrs.as_object() { @@ -689,32 +801,50 @@ pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result { } let cleared: Vec = 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_some_and(|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 { let mut a = ParaAttrs::default(); if let Some(o) = attrs.as_object() { @@ -818,12 +948,71 @@ fn selection_attrs(s: &mut Session, _p: &Value) -> Result { 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); + let mut cf = if r.is_empty() { story.char_format_at(r.start) } else { story.format_after(r.start) }.clone(); + if let Some(f) = typing_format(s) { + apply_typing_format(&mut cf, f); + } + let cp = st.doc.styles.resolve_char(&base, &cf); Ok(json!({"story": sid.0, "paragraphStyle": pf.style, "characterStyle": cf.style, "para": pp, "chars": cp, "paraOverrides": pf.para.count(), "charOverrides": cf.over.count()})) } +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 { + 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(); + let db = designcraft_fonts::FontDb::global(); + 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; + } + }); + } + } + 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. \ @@ -1092,6 +1281,133 @@ mod nested_style_tests { } } +#[cfg(test)] +mod multi_click_tests { + use serde_json::{Value, json}; + + use crate::Session; + + #[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, 19)); + 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()); + } +} + +#[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"); + // 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; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 03562bbf1..922cabda5 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -242,6 +242,8 @@ pub struct Session { pub(crate) transforms: (Vec<(String, Value)>, Vec, 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, /// The open Object Library (File › New / Open Library). pub library: Option, /// The open book (File › Open Book). @@ -273,6 +275,7 @@ impl Session { loaded: None, transforms: Default::default(), text_drag: false, + typing_format: None, library: None, conveyor: Vec::new(), book: None, diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index f67ff593d..d6072a435 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -566,6 +566,50 @@ impl FontDb { /// Style names available for `family` (Regular first, then by weight). pub fn styles(&self, family: &str) -> Vec { + 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, italic: Option) -> Option { + 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`, Regular first, then by weight. + fn style_traits(&self, family: &str) -> Vec<(bool, f32, String)> { let mut v: Vec<(bool, f32, String)> = self .read_faces() .iter() @@ -580,7 +624,7 @@ impl FontDb { } 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 } /// Add a user font (TTF/OTF/TTC bytes). Returns the number of faces added (0 if unparseable or diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 60c13f232..4936aa1f3 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -203,6 +203,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, italic: Option| 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/mcp/src/headless.rs b/crates/mcp/src/headless.rs index ee9d11d19..60bc57707 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 }, 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 = 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 dc2604c5b..2bf90c36b 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -277,6 +277,7 @@ pub fn tool_definitions() -> Vec { "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/tools/src/lib.rs b/crates/tools/src/lib.rs index 247494f84..016df8783 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -63,16 +63,28 @@ pub struct PointerEvent { pub pos: Point, #[serde(default)] pub mods: Mods, + /// 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() } + 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)] diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 8f59c3503..457625fc6 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -371,6 +371,14 @@ impl Tool for SelectionTool { fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { let p = ev.pos; + // 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); @@ -706,19 +714,7 @@ impl Tool for SelectionTool { Drag::None => 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![], } } @@ -777,3 +773,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> { + 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/tests.rs b/crates/tools/src/tests.rs index 6c75b0da6..902c44949 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -705,3 +705,38 @@ 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 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| 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()); +} diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index 13a93f23a..e84f2644c 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -17,6 +17,8 @@ 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)>, + /// 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,31 +47,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); + let clicks = ev.click_count(); // 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 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 = if ev.kind == PointerKind::DoubleClick { - "text.selectWord" - } else 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 { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index e1562c602..22b833cd1 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1364,20 +1364,21 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r let mut events: Vec = 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 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()) { - events.push(PointerEvent { kind: if dbl { PointerKind::DoubleClick } else { PointerKind::Down }, pos: pos(o), mods: m }); + // 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). + // Like egui, the next press must come within the double-click delay of the last release. + let (delay, dist) = ui.ctx().options(|o| (o.input_options.max_double_click_delay, o.input_options.max_click_dist)); + let (released_at, last_pos, last_n): (f64, Pos2, u8) = ui.data(|d| d.get_temp(clicks_id)).unwrap_or((f64::NEG_INFINITY, o, 0)); + let n = if now - released_at <= delay && (o - last_pos).length() <= dist { last_n.saturating_add(1) } else { 1 }; + ui.data_mut(|d| d.insert_temp(clicks_id, (f64::NEG_INFINITY, o, n))); + events.push(PointerEvent { kind: PointerKind::Down, pos: pos(o), mods: m, clicks: n }); down = true; } if down @@ -1385,7 +1386,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: 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)); @@ -1395,8 +1396,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: 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 { ui.data_mut(|d| d.insert_temp(hold_id, (start, fired))); @@ -1408,11 +1409,14 @@ 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((_, at, n)) = ui.data(|d| d.get_temp::<(f64, Pos2, u8)>(clicks_id)) { + ui.data_mut(|d| d.insert_temp(clicks_id, (now, at, n))); + } 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: 1 }); 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: 1 }); } ui.data_mut(|d| d.insert_temp(down_id, down)); for e in events { @@ -1442,11 +1446,19 @@ 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 = match e { + egui::Event::Copy => "edit.copy", + egui::Event::Cut => "edit.cut", + _ => "edit.paste", + }; + if crate::menus::enabled(app, id) { + crate::menus::activate(app, id, &serde_json::Value::Null); } } egui::Event::Key { key, pressed: true, modifiers, .. } => { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 7ff3d79c8..9afde404e 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -146,7 +146,8 @@ 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) { + 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(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index bfa83d4a9..ac512894b 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1478,7 +1478,7 @@ pub fn confirm(app: &mut DesignApp) -> Result { } "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 = 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(); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 60fae75f3..7329f93ec 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -1680,13 +1680,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.", + "选择一个对象或文本以设置其填色或描边颜色。", ], ), ( diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 03f97a62b..72d43ea54 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -912,7 +912,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/lib.rs b/crates/ui-egui/src/lib.rs index 10a600089..47557a871 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -39,6 +39,7 @@ pub type WriteFn = Box Result<(), String>>; pub type PickFn = Box Option>; pub type OpenAsyncFn = Box; pub type DownloadFn = Box; +pub type ClipboardFn = Box Option>; /// Files `(name, bytes)` delivered asynchronously by the host (web file picker, dropped files). pub type Inbox = std::sync::Arc)>>>; @@ -60,6 +61,9 @@ pub struct Services { /// Files delivered asynchronously, drained every frame: `.designcraft` → `file.openBytes`, /// anything else → `file.place`. pub inbox: Option, + /// 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, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -186,6 +190,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, /// Help › About DesignCraft is open. @@ -259,6 +266,7 @@ impl Default for UiState { smart_dimensions: true, smart_spacing: true, snap_zone: 4.0, + formatting_affects_text: false, task_bar: true, about: false, pending_urls: Vec::new(), @@ -413,6 +421,8 @@ 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, } impl DesignApp { @@ -449,6 +459,7 @@ impl DesignApp { native_shortcuts: Default::default(), last_time: 0.0, last_recovery: 0.0, + egui_ctx: None, } } @@ -492,14 +503,37 @@ impl DesignApp { if let Some(r) = menus::run_ui(self, id, ¶ms) { return r; } + let params = self.with_system_clipboard(id, 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 matches!(id, "edit.copy" | "edit.cut") => { + if let (Some(t), Some(ctx)) = (v.get("text").and_then(Value::as_str), &self.egui_ctx) { + ctx.copy_text(t.to_string()); + ctx.request_repaint(); + } + } + Ok(_) => {} } r } + /// Paste into text without the clipboard's text (a menu, not the paste keys): read it here. + 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 typing + && matches!(id, "edit.paste" | "edit.pasteWithoutFormatting") + && 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)); + } + params + } + /// 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); @@ -575,6 +609,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() { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index c7b9f1928..8d929976a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -79,6 +79,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`", + ), ("view.frameEdges", "Show/Hide Frame Edges", Some("Cmd+H"), "{}"), ("view.rulers", "Show/Hide Rulers", Some("Cmd+R"), "{}"), ("view.guides", "Show/Hide Guides", Some("Cmd+;"), "{}"), @@ -1222,6 +1228,10 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option { + 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; @@ -1996,6 +2006,31 @@ mod tests { } } + #[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(); + 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 snap_preferences_are_a_command() { 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 0ef125619..706b22521 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -45,6 +45,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) -> Result { + 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 bf3f0b3af..b9d5c0b35 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1287,6 +1287,35 @@ 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) = + designcraft_fonts::FontDb::global().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") diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index d80b72883..f3e0a5ce7 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()); } } } @@ -112,8 +112,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, @@ -121,7 +121,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()); @@ -153,12 +152,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 { @@ -199,13 +197,14 @@ 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 stroke_id = egui::Id::new("color_panel_stroke"); let mode_id = egui::Id::new("color_panel_mode"); @@ -213,11 +212,11 @@ pub fn color_panel(app: &mut DesignApp, ui: &mut egui::Ui) { 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); @@ -232,7 +231,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)); @@ -266,13 +266,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)); } }); } @@ -308,13 +307,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})); } }); } @@ -384,7 +383,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 4f110df4d..fe3163d9b 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -173,14 +173,13 @@ 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 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 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; @@ -195,8 +194,10 @@ 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]"})); + // Text defaults to a black fill and no stroke; objects to no fill and a black stroke. + let (fill, stroke) = if crate::panels::colors_affect_text(app) { ("[Black]", "[None]") } else { ("[None]", "[Black]") }; + let _ = crate::panels::apply_swatch(app, false, fill, None); + let _ = crate::panels::apply_swatch(app, true, stroke, None); } 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); @@ -207,8 +208,8 @@ 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 _ = crate::panels::apply_swatch(app, false, &stroke_sw, None); + let _ = crate::panels::apply_swatch(app, true, &fill_sw, None); } ui.add_space(2.0); // Proxy: 19.5 pt squares, fill top-left, stroke bottom-right. @@ -279,7 +280,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); @@ -293,14 +294,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(target, json!({"swatch": "[None]"})); + let _ = crate::panels::apply_swatch(app, stroke_front, "[None]", None); } 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(); } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 0c6f334e1..86364b50c 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -16,7 +16,7 @@ Start the app with `--control ` (or `DESIGNCRAFT_CONTROL_PORT`). The serve | `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) | | `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 | 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 | From 576fde980bbf0c990520ecfd6208575e5e652dd3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 6 Oct 2026 13:07:55 -0400 Subject: [PATCH 02/56] Snap: the nearer of a margin and the baseline grid wins; Type tool frames snap Resizing or drawing at a zoom where the snap zone covers the baseline grid's spacing (66% and below with a 12 pt grid) always snapped to a baseline line, so frames could not reach the top or bottom margin. The baseline grid and the guides (ruler guides, margins, columns) now compete by distance and a tie goes to the guide. Frames drawn with the Type tool snapped nothing; they now snap like the frame tools' (press point and moving edges, with Shift and Alt). --- crates/engine/src/tests.rs | 61 ++++++++++++++++++++++++++++++ crates/tools/src/frame.rs | 76 +++++++++++++++++++++----------------- crates/tools/src/snap.rs | 35 ++++++++++++++++-- crates/tools/src/text.rs | 14 ++++++- 4 files changed, 147 insertions(+), 39 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 8f2f39b0f..8b4e7713e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -537,3 +537,64 @@ fn commands_take_non_object_params_without_panicking() { assert!(r.is_ok(), "{p}: {r:?}"); } } + +/// 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/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) { + 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/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 { 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, guide: Option) -> Option { + 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/text.rs b/crates/tools/src/text.rs index e84f2644c..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,8 @@ 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, /// The press selected a word, line, paragraph or story (several clicks): drags keep it. unit_selected: bool, } @@ -90,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; @@ -103,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); @@ -155,6 +161,10 @@ impl Tool for TypeTool { } } + fn overlays(&self, _cx: &ToolContext) -> Vec { + 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, From 6104f2ced1f1e23dfd2dd5a6b87cead34259e78a Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 6 Oct 2026 16:14:18 -0400 Subject: [PATCH 03/56] Threading: thread and unthread commands --- crates/doc/src/edit.rs | 198 +++++++++++++------ crates/doc/src/slice.rs | 53 +++++ crates/doc/src/tests.rs | 92 +++++++++ crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/thread.rs | 335 ++++++++++++++++++++++++++++++++ 5 files changed, 619 insertions(+), 61 deletions(-) create mode 100644 crates/engine/src/cmd/thread.rs diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index a1f21f975..ed6ace82e 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -193,85 +193,140 @@ 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())), + 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 = other.frames[..pos_to].to_vec(); - let after: Vec = 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(); - 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 st = self.story_mut(fs).ok_or(DocError::NoStory(fs))?; + st.append_story(other); + 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; } + 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(); + 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) }; + if let Some(fs) = fs { + self.stories.remove(&fs); + } + 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(()) + } + + /// 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"))); + } + } + 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())); + } + 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> { + 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 { + 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<()> { @@ -298,6 +353,27 @@ 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 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; + } + let nid = StoryId(self.alloc()); + let mut ns = Story::new(nid); + ns.frames = vec![frame]; + 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 { let st = self.story(self.item(frame)?.text_frame()?.story)?; diff --git a/crates/doc/src/slice.rs b/crates/doc/src/slice.rs index b376bf0e6..1d7067e8c 100644 --- a/crates/doc/src/slice.rs +++ b/crates/doc/src/slice.rs @@ -125,6 +125,59 @@ 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 + 1, link, ..other }; + return; + } + if !other.tables.is_empty() { + let first = self.tables.keys().max().copied().unwrap_or(0) + 1; + let mut ids = std::collections::HashMap::new(); + for (new, (old, t)) in (first..).zip(std::mem::take(&mut other.tables)) { + let mut t = Arc::unwrap_or_clone(t); + t.id = new; + ids.insert(old, new); + self.tables.insert(new, Arc::new(t)); + } + 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 = self.endnotes.iter().map(|x| x.id).max().unwrap_or(0) + 1; + self.endnotes.push(Arc::new(crate::notes::Footnote { id, text: n.text.clone() })); + } + for n in other.editorial { + let id = self.editorial.iter().map(|x| x.id).max().unwrap_or(0) + 1; + 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 += 1; + } } #[cfg(test)] diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index c3ba44dfd..e248d72eb 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -42,6 +42,98 @@ 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 removing_last_frame_deletes_story() { let mut d = doc(); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 941473561..6413445c9 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/thread.rs b/crates/engine/src/cmd/thread.rs new file mode 100644 index 000000000..ff360e00a --- /dev/null +++ b/crates/engine/src/cmd/thread.rs @@ -0,0 +1,335 @@ +//! 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 { + 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 { + 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"))); + } + Ok(id) +} + +/// A rect param made safe for a frame: finite, ordered, at least half a point each way. +fn frame_rect(p: &Value) -> Result> { + 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 { + 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 { + let r = rect.ok_or_else(|| bad("text.thread", "give two of from, to and rect"))?; + 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)? } 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 { + 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)"))), + }; + 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 { + let frame = frame_param(s, p, "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 { + 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 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 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(); + } +} From 786129723795f7fe448ffaf6cc5d8668c0488878 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 6 Oct 2026 16:24:54 -0400 Subject: [PATCH 04/56] Threading: load text from a port and click to thread --- ROADMAP.md | 1 + crates/engine/src/tests.rs | 39 +++++++ crates/tools/src/lib.rs | 5 + crates/tools/src/select.rs | 74 ++++++++++++- crates/tools/src/tests.rs | 199 ++++++++++++++++++++++++++++++++++ crates/tools/src/thread.rs | 199 ++++++++++++++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 46 ++++++-- crates/ui-egui/src/control.rs | 8 +- docs/control-protocol.md | 2 +- 9 files changed, 559 insertions(+), 14 deletions(-) create mode 100644 crates/tools/src/thread.rs diff --git a/ROADMAP.md b/ROADMAP.md index ec17b7119..6da50141b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -29,6 +29,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - 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`. - 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). diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 8b4e7713e..feafdb441 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -538,6 +538,45 @@ fn commands_take_non_object_params_without_panicking() { } } +/// 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}; diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 016df8783..cdeaee792 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; @@ -354,6 +355,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 457625fc6..84e12d3b3 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, + }, Anchor { id: u64, si: usize, @@ -100,11 +107,58 @@ pub struct SelectionTool { guides: Vec, /// Selected item to toggle if this Shift press never becomes a drag. shift_release: Option, + /// The loaded text cursor. + loaded: Option, } 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> { + let p = ev.pos; + let exec = |c: Option<(&'static str, Value)>| c.map(|(id, params)| Action::Exec(id.into(), params)).into_iter().collect::>(); + // 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 { frame, out, advance: false }); + 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 cmd = l.click(cx, start, p, ev.mods); + // Alt (semi-autoflow) stays loaded with what is left; an invalid target keeps the load. + if cmd.is_some() { + self.loaded = (ev.mods.alt && !ev.mods.shift && cmd.as_ref().is_some_and(|c| c.0 == "text.thread")) + .then_some(Loaded { advance: true, ..l }); + } + Some(exec(cmd)) + } + (PointerKind::Move, Some(_)) => Some(vec![]), + _ => None, + } } } @@ -371,6 +425,11 @@ impl Tool for SelectionTool { fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { 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 @@ -600,6 +659,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(); @@ -711,7 +771,7 @@ 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 => vec![], @@ -719,6 +779,11 @@ impl Tool for SelectionTool { } fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec { + 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() { @@ -737,7 +802,7 @@ impl Tool for SelectionTool { fn overlays(&self, _cx: &ToolContext) -> Vec { match &self.drag { - Drag::Marquee { start, cur } => vec![Overlay::Marquee(Rect::from_points(*start, *cur))], + Drag::Marquee { start, cur } | Drag::Place { start, cur } => vec![Overlay::Marquee(Rect::from_points(*start, *cur))], Drag::Move { .. } | Drag::Resize { .. } | Drag::Rotate { .. } | Drag::Anchor { .. } => self.guides.clone(), _ => vec![], } @@ -747,6 +812,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), diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 902c44949..f898755bf 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -706,6 +706,205 @@ 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, cx: &ToolContext, p: Point, mods: Mods) -> Vec { + 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 { + 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 alt_click_stays_loaded() { + 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 alt = Mods { alt: true, ..Default::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); +} + +#[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()); diff --git a/crates/tools/src/thread.rs b/crates/tools/src/thread.rs new file mode 100644 index 000000000..7e6500fbb --- /dev/null +++ b/crates/tools/src/thread.rs @@ -0,0 +1,199 @@ +//! 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, 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 { + 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 { + 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) +} + +/// The loaded text cursor. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Loaded { + /// The frame whose port was clicked. + 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 just threaded on that side takes over once the document has it. + pub advance: bool, +} + +/// 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 { + /// The loaded frame now (an advancing load moves on to the frame threaded last), or None + /// when that frame is gone (undo, delete). + pub fn resolve(self, doc: &Document) -> Option { + let frame = if self.advance { self.neighbour(doc).unwrap_or(self.frame) } else { self.frame }; + doc.item(frame).filter(|i| i.is_text_frame()).map(|_| Loaded { frame, out: self.out, advance: false }) + } + + /// The next frame from an out port, the previous one from an in port. + fn neighbour(self, doc: &Document) -> Option { + 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) -> (Option, Option) { + 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<(&'static str, Value)> { + let dragged = (p - start).hypot() >= cx.tol(3.0); + let (pair, new_rect) = if dragged { + let (sr, a) = cx.layout.spread_at(start)?; + (self.pair(cx.doc, None), Some((sr, Rect::from_points(a, cx.layout.to_spread(sr, p))))) + } else { + match self.target(cx, p) { + Target::Unthread => { + let side = if self.out { "after" } else { "before" }; + return Some(("text.unthread", json!({"frame": self.frame.0, "side": side}))); + } + Target::Invalid => return None, + Target::Thread(t) => (self.pair(cx.doc, Some(t)), None), + Target::Empty => { + let (sr, sp) = cx.layout.spread_at(p)?; + (self.pair(cx.doc, None), Some((sr, click_frame_rect(cx.doc, sr, sp)?))) + } + } + }; + 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(("text.thread", Value::Object(params))) + } +} + +/// 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 22b833cd1..164e953f5 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -330,6 +330,36 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let space = ui.input(|i| i.key_down(egui::Key::Space)) && !app.session.wants_text(); let c = if space { Cursor::Hand } else { 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); + } + } } } @@ -352,6 +382,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, } } @@ -801,7 +833,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 @@ -894,7 +926,6 @@ fn draw_ports( layout: &CanvasLayout, it: &Item, sid: designcraft_doc::StoryId, - a: Affine, color: Color32, ) { let Some(story) = doc.story(sid) else { return }; @@ -902,14 +933,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); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 9afde404e..a8d386775 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -126,6 +126,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, @@ -151,9 +152,14 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> 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`") }; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 86364b50c..76c9308c9 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -16,7 +16,7 @@ Start the app with `--control ` (or `DESIGNCRAFT_CONTROL_PORT`). The serve | `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", 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) | +| `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 | 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 | From 84361ca9f9bf8a2fbee632f668fec092585538fc Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 6 Oct 2026 22:48:22 -0400 Subject: [PATCH 05/56] Text: the Ascent first baseline uses the font's typographic ascender --- crates/compose/src/lib.rs | 23 ++++--- crates/compose/src/shape.rs | 12 +++- crates/compose/src/tests.rs | 126 ++++++++++++++++++++++++++++++++++++ crates/fonts/src/fontdb.rs | 18 ++++++ 4 files changed, 168 insertions(+), 11 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 9f91aa68f..a4bdfe320 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -688,7 +688,7 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo _ => 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 est_first = cur.next_baseline(f, col, base_leading, base_chars.size * 0.75, 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 width = |j: usize| -> f64 { let (x0, x1) = slots.get(j).copied().unwrap_or((col.x0, col.x1)); @@ -708,8 +708,8 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo 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 mut baseline = cur.next_baseline(f, col, lead, asc, &pp); + let (asc, typo_asc, desc, lead) = line_metrics(line_glyphs, &glyphs, s, base_leading, base_chars.size, db, &base_chars); + let mut baseline = cur.next_baseline(f, col, lead, asc, typo_asc, &pp); // Baseline grid. if let Some((g_start, inc)) = f.grid && (pp.grid_align == GridAlign::AllLines || (pp.grid_align == GridAlign::FirstLineOnly && line_no == 0)) @@ -1287,13 +1287,16 @@ impl Cursor { self.last_baseline = None; self.pending = 0.0; } - /// 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 typographic ascender + /// `typo_asc`. + fn next_baseline(&self, f: &FrameSpec, col: Rect, lead: f64, asc: f64, typo_asc: f64, _pp: &ParaProps) -> f64 { match self.last_baseline { Some(b) => b + lead + self.pending, None => { let off = match f.opts.first_baseline { - FirstBaseline::Ascent => asc, + // Vertical frames keep the ascent. + FirstBaseline::Ascent if f.opts.vertical => asc, + FirstBaseline::Ascent => typo_asc, FirstBaseline::CapHeight => asc * 0.72, FirstBaseline::XHeight => asc * 0.5, FirstBaseline::Leading => lead, @@ -1313,23 +1316,25 @@ fn line_metrics( base_size: f64, db: &FontDb, base: &designcraft_doc::CharProps, -) -> (f64, f64, f64) { +) -> (f64, f64, f64, f64) { let _ = db; let src: &[Glyph] = if line.is_empty() { all.get(s..(s + 1).min(all.len())).unwrap_or(&[]) } else { line }; if src.is_empty() { let face = FontDb::global().face(&base.font_family, &base.font_style); let k = base_size / face.upem; - return (face.ascent * k, face.descent * k, base_leading); + return (face.ascent * k, face.typo_ascent * k, face.descent * k, base_leading); } let mut asc: f64 = 0.0; + let mut typo_asc: f64 = 0.0; let mut desc: f64 = 0.0; let mut lead: f64 = 0.0; for g in src { asc = asc.max(g.ascent + g.shift.max(0.0)); + typo_asc = typo_asc.max(g.typo_ascent + g.shift.max(0.0)); desc = desc.max(g.descent - g.shift.min(0.0)); lead = lead.max(g.leading); } - (asc, desc, lead) + (asc, typo_asc, desc, lead) } /// Widest free horizontal interval of `col` in the band, or None if blocked. diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index 8de7dc164..a91927f92 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -28,6 +28,8 @@ pub struct Glyph { /// Baseline shift (positive = up), including super/subscript. pub shift: f64, pub ascent: f64, + /// The font's typographic ascender, scaled like `ascent`: 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, @@ -219,7 +221,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; @@ -415,10 +417,14 @@ fn shape_run( 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.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; + } } if auto || o.y_offset.is_none() { g.leading = g.leading.max(g.ascent + g.descent); @@ -574,6 +580,7 @@ fn control_glyph(face: &Arc, p: &CharProps, auto_leading: TypeEnv, sty sy: k * p.v_scale, shift, ascent, + typo_ascent: face.typo_ascent * k * p.v_scale, descent, leading, cap, @@ -644,6 +651,7 @@ fn shape_segment( sy: k * p.v_scale, shift, ascent, + typo_ascent: face.typo_ascent * k * p.v_scale, descent, leading, cap, diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 511a703a5..0addee2f1 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1027,3 +1027,129 @@ fn only_english_text_gets_english_hyphenation() { st.format_chars(0..n, |f| f.over.language = Some("French".into())); assert_eq!(hyphenated(&d2, sid), 0, "French isn't hyphenated with English rules"); } + +/// Source Sans 3 Regular (OS/2 typo ascender = hhea ascender = 1000/1000 em) renamed to `family` +/// (13 characters, the length of "Source Sans 3"), with OS/2 `sTypoAscender` set to +/// `typo_ascender`, or without an OS/2 table for `None`. +fn typo_test_font(family: &str, typo_ascender: Option) -> Vec { + 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::>(); + 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 typo_ascender { + Some(v) => { + let os2 = u32::from_be_bytes(b[rec + 8..rec + 12].try_into().unwrap()) as usize; + b[os2 + 68..os2 + 70].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 +} + +/// 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 { + 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); + }); + 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 are unchanged. + assert!(close(at(FirstBaseline::CapHeight, 0.0), top + 20.0 * 0.72)); + assert!(close(at(FirstBaseline::XHeight, 0.0), top + 10.0)); + assert!(close(at(FirstBaseline::Leading, 0.0), top + 24.0)); + assert!(close(at(FirstBaseline::Fixed, 0.0), top)); + // Vertical scale scales it like the ascent. + 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("TypoAscent 07".into()); + f.over.size = Some(20.0); + f.over.v_scale = Some(1.5); + }); + 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]; + assert!(close(l.baseline, top + 21.0), "{}", l.baseline); + // The line box keeps the font's ascent. + assert!(close(l.ascent, 30.0), "{}", l.ascent); + // 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 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("TypoAscentVrt", |o| { + o.vertical = true; + 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}"); +} diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index f67ff593d..833711b1e 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -58,6 +58,9 @@ 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 @@ -449,6 +452,7 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords italic, upem: m.units_per_em.max(1) as f64, ascent: m.ascent as f64, + typo_ascent: typo_ascent(&f, &location, m.units_per_em.max(1) as f64, 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), @@ -462,6 +466,20 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords }) } +/// The OS/2 typographic ascender in font units at `location`, or `fallback` when the font has no +/// OS/2 table or the value isn't a plausible ascender (not above 0, or over 4 em). +fn typo_ascent(f: &skrifa::FontRef<'_>, location: &Location, upem: f64, fallback: f64) -> f64 { + use skrifa::raw::TableProvider; + let Ok(os2) = f.os2() else { return fallback }; + let mut v = os2.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()); + } + if v > 0.0 && v <= 4.0 * upem { v } else { fallback } +} + /// The named style part of a style with axis settings (`Bold {wght:650}` → `Bold`). pub fn base_style(style: &str) -> &str { style.split('{').next().unwrap_or(style).trim() From c246f1d34c3ca80bd2651fd2524469da6779afed Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 6 Oct 2026 22:52:50 -0400 Subject: [PATCH 06/56] Text: Cap Height and x Height first baselines use the font's cap and x heights --- crates/compose/src/lib.rs | 47 +++++++++++----- crates/compose/src/shape.rs | 6 ++ crates/compose/src/tests.rs | 107 ++++++++++++++++++++++++++++++++---- crates/fonts/src/fontdb.rs | 29 ++++++---- 4 files changed, 152 insertions(+), 37 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index a4bdfe320..725abb58a 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -688,7 +688,9 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo _ => 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, base_chars.size * 0.75, &pp); + 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 }; + let est_first = cur.next_baseline(f, col, base_leading, est_asc, est_tops, &pp); let slots = estimate_slots(f, col, est_first, 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)); @@ -708,8 +710,8 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo 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, typo_asc, desc, lead) = line_metrics(line_glyphs, &glyphs, s, base_leading, base_chars.size, db, &base_chars); - let mut baseline = cur.next_baseline(f, col, lead, asc, typo_asc, &pp); + let (asc, tops, desc, lead) = line_metrics(line_glyphs, &glyphs, s, base_leading, base_chars.size, db, &base_chars); + let mut baseline = cur.next_baseline(f, col, lead, asc, tops, &pp); // Baseline grid. if let Some((g_start, inc)) = f.grid && (pp.grid_align == GridAlign::AllLines || (pp.grid_align == GridAlign::FirstLineOnly && line_no == 0)) @@ -1287,18 +1289,20 @@ impl Cursor { self.last_baseline = None; self.pending = 0.0; } - /// Baseline for the next line with leading `lead`, ascent `asc` and typographic ascender - /// `typo_asc`. - fn next_baseline(&self, f: &FrameSpec, col: Rect, lead: f64, asc: f64, typo_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 { - // Vertical frames keep the ascent. + // Vertical frames measure from the ascent. FirstBaseline::Ascent if f.opts.vertical => asc, - FirstBaseline::Ascent => typo_asc, - FirstBaseline::CapHeight => asc * 0.72, - FirstBaseline::XHeight => asc * 0.5, + FirstBaseline::CapHeight if f.opts.vertical => asc * 0.72, + FirstBaseline::XHeight if f.opts.vertical => asc * 0.5, + FirstBaseline::Ascent => tops.typo_ascent, + FirstBaseline::CapHeight => tops.cap, + FirstBaseline::XHeight => tops.xh, FirstBaseline::Leading => lead, FirstBaseline::Fixed => 0.0, }; @@ -1308,6 +1312,15 @@ impl Cursor { } } +/// A line's tallest typographic ascender, cap height and x height above its baseline: the +/// first baseline offsets. +#[derive(Clone, Copy, Default)] +struct Tops { + typo_ascent: f64, + cap: f64, + xh: f64, +} + fn line_metrics( line: &[Glyph], all: &[Glyph], @@ -1316,25 +1329,29 @@ fn line_metrics( base_size: f64, db: &FontDb, base: &designcraft_doc::CharProps, -) -> (f64, f64, f64, f64) { +) -> (f64, Tops, f64, f64) { let _ = db; let src: &[Glyph] = if line.is_empty() { all.get(s..(s + 1).min(all.len())).unwrap_or(&[]) } else { line }; if src.is_empty() { let face = FontDb::global().face(&base.font_family, &base.font_style); let k = base_size / face.upem; - return (face.ascent * k, face.typo_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 }; + return (face.ascent * k, tops, face.descent * k, base_leading); } let mut asc: f64 = 0.0; - let mut typo_asc: f64 = 0.0; + let mut tops = Tops::default(); let mut desc: f64 = 0.0; let mut lead: f64 = 0.0; for g in src { asc = asc.max(g.ascent + g.shift.max(0.0)); - typo_asc = typo_asc.max(g.typo_ascent + g.shift.max(0.0)); + let up = g.shift.max(0.0); + tops.typo_ascent = tops.typo_ascent.max(g.typo_ascent + up); + tops.cap = tops.cap.max(g.cap + up); + tops.xh = tops.xh.max(g.xh + up); desc = desc.max(g.descent - g.shift.min(0.0)); lead = lead.max(g.leading); } - (asc, typo_asc, desc, lead) + (asc, tops, desc, lead) } /// Widest free horizontal interval of `col` in the band, or None if blocked. diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index a91927f92..0c2002123 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -33,6 +33,8 @@ pub struct Glyph { 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, @@ -418,12 +420,16 @@ fn shape_run( 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; 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() { diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 0addee2f1..1edef7739 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1028,10 +1028,15 @@ fn only_english_text_gets_english_hyphenation() { assert_eq!(hyphenated(&d2, sid), 0, "French isn't hyphenated with English rules"); } -/// Source Sans 3 Regular (OS/2 typo ascender = hhea ascender = 1000/1000 em) renamed to `family` -/// (13 characters, the length of "Source Sans 3"), with OS/2 `sTypoAscender` set to -/// `typo_ascender`, or without an OS/2 table for `None`. -fn typo_test_font(family: &str, typo_ascender: Option) -> Vec { +/// OS/2 field offsets: sTypoAscender, sxHeight, sCapHeight. +const TYPO_ASCENDER: usize = 68; +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 { 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::>(); @@ -1044,10 +1049,12 @@ fn typo_test_font(family: &str, typo_ascender: Option) -> Vec { } 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 typo_ascender { - Some(v) => { + match os2_fields { + Some(fields) => { let os2 = u32::from_be_bytes(b[rec + 8..rec + 12].try_into().unwrap()) as usize; - b[os2 + 68..os2 + 70].copy_from_slice(&v.to_be_bytes()); + 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"), @@ -1055,6 +1062,11 @@ fn typo_test_font(family: &str, typo_ascender: Option) -> Vec { 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) -> Vec { + 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 { @@ -1092,9 +1104,9 @@ fn ascent_first_baseline_uses_the_typographic_ascender() { // 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 are unchanged. - assert!(close(at(FirstBaseline::CapHeight, 0.0), top + 20.0 * 0.72)); - assert!(close(at(FirstBaseline::XHeight, 0.0), top + 10.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)); // Vertical scale scales it like the ascent. @@ -1153,3 +1165,78 @@ fn vertical_frames_keep_the_ascent_first_baseline() { // 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 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: 0.72 and 0.5 of the (1 em) ascent. + 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), (face.ascent * 0.72, face.ascent * 0.5), "{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 + 14.4)).abs() < 0.01 && (x - (40.0 + 10.0)).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("CapXHeightVrt", |o| { + o.first_baseline = kind; + o.vertical = true; + 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)); +} diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 833711b1e..9398af69a 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -63,8 +63,8 @@ pub struct FontFace { 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 (estimated from the ascent when the font has no + /// plausible OS/2 values): the Cap Height and x Height first baseline offsets. pub cap_height: f64, pub x_height: f64, pub shaper: harfrust::ShaperData, @@ -433,6 +433,7 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords 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); @@ -450,12 +451,12 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords style, weight, italic, - upem: m.units_per_em.max(1) as f64, + upem, ascent: m.ascent as f64, - typo_ascent: typo_ascent(&f, &location, m.units_per_em.max(1) as f64, 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), + cap_height: plausible(m.cap_height.map(f64::from), upem).unwrap_or(m.ascent as f64 * 0.72), + x_height: plausible(m.x_height.map(f64::from), upem).unwrap_or(m.ascent as f64 * 0.5), shaper, coords, location, @@ -466,18 +467,22 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords }) } -/// The OS/2 typographic ascender in font units at `location`, or `fallback` when the font has no -/// OS/2 table or the value isn't a plausible ascender (not above 0, or over 4 em). -fn typo_ascent(f: &skrifa::FontRef<'_>, location: &Location, upem: f64, fallback: f64) -> f64 { +/// A font's height above the baseline (font units) if it is plausible: above 0 and at most 4 em. +fn plausible(v: Option, upem: f64) -> Option { + v.filter(|v| *v > 0.0 && *v <= 4.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 { use skrifa::raw::TableProvider; - let Ok(os2) = f.os2() else { return fallback }; - let mut v = os2.s_typo_ascender() as f64; + 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()); } - if v > 0.0 && v <= 4.0 * upem { v } else { fallback } + plausible(Some(v), upem) } /// The named style part of a style with axis settings (`Bold {wght:650}` → `Bold`). From b825cf3e107774d6a1b71fade2bad7fc65bedadc Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 6 Oct 2026 20:03:33 -0400 Subject: [PATCH 07/56] Text wrap: lines jump an object by whole leadings; a column's first line starts below a top object A line clears a Jump Object wrap with its leading box, [baseline - leading, baseline]. It stays above the object while its baseline is at or above the wrap top, so its descenders may hang into the wrap. The next line moves down by whole leadings from the line above, after any space before or after, until its leading box clears the wrap bottom. A line that would start a column under an object starts as if the frame top were the wrap bottom (the frame's first baseline offset below it). Wrap offsets, negative ones included, set the wrap's edges. Other wraps that leave no usable slot, such as a full-width bounding box, push the line so its top lands exactly on the wrap bottom: the search jumps from one exclusion bottom edge to the next. Lines aligned to the baseline grid snap to the next grid line after a push and are re-checked, so they stay on the grid. The breaker's slot estimate uses the same rules. --- crates/compose/src/lib.rs | 173 ++++++++++++++++++-------- crates/compose/src/tests.rs | 235 ++++++++++++++++++++++++++++++++++++ 2 files changed, 360 insertions(+), 48 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 725abb58a..9cc863613 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -691,7 +691,8 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo 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 }; let est_first = cur.next_baseline(f, col, base_leading, est_asc, est_tops, &pp); - let slots = estimate_slots(f, col, est_first, base_leading, base_chars.size, &glyphs[g0..], &pp, line_no); + 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 }; @@ -711,34 +712,30 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo let (s, e) = (g0 + b.start, g0 + b.end); let line_glyphs = &glyphs[s..e.max(s)]; let (asc, tops, desc, lead) = line_metrics(line_glyphs, &glyphs, s, base_leading, base_chars.size, db, &base_chars); - let mut baseline = cur.next_baseline(f, col, lead, asc, tops, &pp); - // 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 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 = snap(cur.next_baseline(f, col, lead, asc, tops, &pp)); + // 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 = line_cap[pi] == Some(line_no) && line_no > col_first_line; @@ -1294,22 +1291,34 @@ impl Cursor { 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 { - // Vertical frames measure from the ascent. - FirstBaseline::Ascent if f.opts.vertical => asc, - FirstBaseline::CapHeight if f.opts.vertical => asc * 0.72, - FirstBaseline::XHeight if f.opts.vertical => asc * 0.5, - FirstBaseline::Ascent => tops.typo_ascent, - FirstBaseline::CapHeight => tops.cap, - FirstBaseline::XHeight => tops.xh, - 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 if f.opts.vertical => asc, + FirstBaseline::CapHeight if f.opts.vertical => asc * 0.72, + FirstBaseline::XHeight if f.opts.vertical => asc * 0.5, + FirstBaseline::Ascent => tops.typo_ascent, + FirstBaseline::CapHeight => tops.cap, + FirstBaseline::XHeight => tops.xh, + FirstBaseline::Leading => lead, + FirstBaseline::Fixed => 0.0, + }; + off.max(f.opts.first_baseline_min) } /// A line's tallest typographic ascender, cap height and x height above its baseline: the @@ -1354,6 +1363,64 @@ fn line_metrics( (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. fn free_slot(f: &FrameSpec, col: Rect, y0: f64, y1: f64, size: f64) -> Option<(f64, f64)> { let mut free = vec![(col.x0, col.x1)]; @@ -1362,7 +1429,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 { @@ -1386,7 +1452,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![]; @@ -1396,20 +1472,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 } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 1edef7739..672d5d97e 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) { + 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::>()); + 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. From 47d3f5b4bf8cbc679cdaa883bc750ddbb383665a Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 6 Oct 2026 19:52:41 -0400 Subject: [PATCH 08/56] Text: drop caps --- Cargo.lock | 1 + ROADMAP.md | 1 + crates/compose/Cargo.toml | 1 + crates/compose/src/lib.rs | 204 ++++++++++++++++++++++++-- crates/compose/src/overlay.rs | 43 ++++-- crates/compose/src/shape.rs | 12 +- crates/compose/src/table.rs | 1 + crates/compose/src/tests.rs | 250 +++++++++++++++++++++++++++++++- crates/doc/src/attrs.rs | 6 + crates/engine/src/cmd/text.rs | 2 +- crates/engine/src/tests_idml.rs | 24 +++ crates/fonts/src/fontdb.rs | 13 +- crates/idml/src/export.rs | 4 + crates/idml/src/import.rs | 1 + crates/idml/src/names.rs | 2 + crates/idml/src/tests.rs | 43 ++++++ crates/pdf/src/tests.rs | 12 ++ crates/render/src/text.rs | 4 +- docs/parity.md | 5 +- 19 files changed, 593 insertions(+), 36 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e5ae30382..27a7e5ebb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -910,6 +910,7 @@ dependencies = [ "proptest", "regex", "unicode-bidi", + "unicode-segmentation", ] [[package]] diff --git a/ROADMAP.md b/ROADMAP.md index ec17b7119..87ce118f3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -57,6 +57,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - 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}`, Paragraph panel, Paragraph Style Options › Drop Caps and Nested Styles; IDML DropCapLines/DropCapCharacters/DropCapStyle): the first characters (grapheme clusters, up to 150) are enlarged from the first line's cap height down to the baseline of line N (up to 25), the lines beside them are indented by their advance (on the right in right-to-left paragraphs), baselines don't move; 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. diff --git a/crates/compose/Cargo.toml b/crates/compose/Cargo.toml index b03b1aa83..19ab90a71 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 } designcraft-geom = { workspace = true } designcraft-color = { workspace = true } designcraft-doc = { workspace = true } diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 9cc863613..06aabd89e 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -160,6 +160,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, +} + +/// 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. @@ -545,6 +559,7 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo 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 }; let mut sp = shape::shape_para( @@ -560,7 +575,18 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo &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); 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. @@ -569,9 +595,17 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo let spacing = spacing_for(&pp, base_chars.size); let mut lines: Vec<(std::ops::Range, 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(&mut gl, drop_end, pp.drop_cap_lines, base_leading, base_cap, drop_grid); + let width = |j: usize| { + let ind = match &dc { + Some(dc) if j < dc.lines => dc.width, + _ 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 { @@ -613,9 +647,11 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo &pp.nested_styles, &pp.grep_styles, &lines, + (drop_end, &pp.drop_cap_style), ); } } + let drop_cap = split_drop_cap(&mut sp.glyphs, drop_end, pp.drop_cap_lines, base_leading, base_cap, drop_grid); match pp.list_type { designcraft_doc::ListType::Numbers if !pp.list_name.is_empty() => { // A named list: carries on past other paragraphs (and from earlier stories). @@ -643,10 +679,6 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo 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); // Paragraph start options. if force_col[pi] { @@ -671,6 +703,10 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo 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; + // The drop cap's line (frame, index) and the baseline it sits on, once that line is set. + let mut drop_line: Option<(usize, usize)> = None; + let mut drop_baseline: Option = None; loop { if cur.fi >= frames.len() { out.overset_at = Some(glyphs.get(g0).map(|g| g.byte).unwrap_or(prange.start)); @@ -695,7 +731,15 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo 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.width, + _ if line_no + j == 0 => pp.first_line_indent, + _ => 0.0, + }; (x1 - x0 - ind).max(1.0) }; let rest = &glyphs[g0..]; @@ -788,12 +832,30 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo 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(|dc| dc.width); + 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(); let (mut placed, end_x, ratio) = layout_line(&glyphs, s, e, b.hyphen, lx0, lx1, col.x0, &pp, &spacing, last, b.forced && !last, f.left_page); + 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 }; + 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(&styles_tab, &mut placed); 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); @@ -820,7 +882,17 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo spacing: ratio, hj: hj_severity(ratio, pp.word_space_min, pp.word_space_max), keep_violation: false, + drop_cap: drop_box, }); + if drop_box.is_some() { + drop_line = Some((cur.fi, ft.lines.len() - 1)); + } + if let Some(dc) = &drop_cap + && line_no + 1 == dc.lines + && col_first_line == 0 + { + drop_baseline = Some(baseline); + } for k in line_notes { notes.place(doc, k, cur.fi, cur.col, col_w, f, opts); } @@ -868,6 +940,19 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo break; } } + // The drop cap sits on its last line's baseline as set (it was placed at the leading's). + if let (Some((fi, li)), Some(b), Some(n)) = (drop_line, drop_baseline, drop_cap.as_ref().map(|dc| dc.glyphs.len())) + && let Some(l) = out.frames.get_mut(fi).and_then(|f| f.lines.get_mut(li)) + && let Some(dc) = l.drop_cap.as_mut() + { + let d = b - dc.baseline; + dc.baseline = b; + dc.rect.y1 += d; + // Its glyphs start the line. + for g in l.glyphs.iter_mut().take(n) { + g.y += d; + } + } info.push(ParaInfo { start: start_at, at_top, @@ -1939,6 +2024,90 @@ 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 +/// (grapheme clusters, spaces included; 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, 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; + prange.start + para.grapheme_indices(true).nth(n).map_or(para.len(), |(i, _)| i) +} + +/// A paragraph's drop cap: its first glyphs, enlarged to reach down `lines` lines. +struct DropCap { + glyphs: Vec, + /// Story byte it ends at. + end: usize, + lines: usize, + /// Its advance: how far the lines beside it are indented. + width: f64, + /// Its baseline below the first line's, at the paragraph's leading. + 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. +/// On a baseline grid of increment `grid`, lines are that many grid steps apart. +fn split_drop_cap(glyphs: &mut Vec, end: usize, lines: u32, base_leading: f64, base_cap: f64, grid: Option) -> Option { + 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 = glyphs.drain(..n).collect(); + 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 k.is_finite() && k > 0.0 { k.min(1000.0) } else { 1.0 }; + for g in &mut dc { + 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 width = dc.iter().map(|g| g.adv).sum(); + Some(DropCap { glyphs: dc, end, lines, width, drop }) +} + +/// 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 { + 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 +} + fn prepend_label( db: &FontDb, glyphs: &mut Vec, @@ -1965,6 +2134,7 @@ fn prepend_label( &[], &[], &[], + (0, ""), ); let mut pre: Vec = shaped .glyphs @@ -2106,6 +2276,12 @@ fn vertical_justify(ft: &mut FrameText, f: &FrameSpec) { /// Caret geometry for story byte `pos`: (frame index, x, baseline, ascent, descent). 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)); + } Some((fi, caret_x(l, pos), l.baseline, l.ascent, l.descent)) } @@ -2185,6 +2361,16 @@ pub fn hit(cs: &ComposedStory, fi: usize, p: Point) -> Option { if ft.lines.is_empty() { return Some(ft.range.start); } + // 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())); + } // Closest line vertically (within its column). let l = ft .lines 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, next: Option) -> 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, nested: &[NestedStyle], grep: &[GrepStyle]) -> Vec<(Range, String)> { +/// The paragraph's drop cap ends at `drop_end` (`range.start` without one). +pub(crate) fn overlays(text: &str, range: Range, nested: &[NestedStyle], grep: &[GrepStyle], drop_end: usize) -> Vec<(Range, 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, 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 0c2002123..14a5ffd34 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -156,7 +156,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: &FontDb, styles: &Styles, @@ -170,13 +171,18 @@ pub(crate) fn shape_para( nested: &[designcraft_doc::NestedStyle], grep: &[designcraft_doc::GrepStyle], lines: &[(std::ops::Range, 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, String)> = lines.to_vec(); 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 drop.0 > range.start && !drop.1.is_empty() && drop.1 != designcraft_doc::NO_CHAR_STYLE { + overlays.push((range.start..drop.0, drop.1.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(); diff --git a/crates/compose/src/table.rs b/crates/compose/src/table.rs index bda001820..4aaeeb794 100644 --- a/crates/compose/src/table.rs +++ b/crates/compose/src/table.rs @@ -410,6 +410,7 @@ fn emit( spacing: 1.0, hj: 0, keep_violation: false, + drop_cap: None, }); ft.tables.push(TableFrag { table: table.id, diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 672d5d97e..4f129c22b 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1445,17 +1445,18 @@ fn cap_height_and_x_height_first_baselines_use_the_fonts_cap_and_x_heights() { 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: 0.72 and 0.5 of the (1 em) ascent. + // No OS/2 table, zero / negative and absurd values: the top of the H (0.656 em) and 0.5 of the + // (1 em) ascent. 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), (face.ascent * 0.72, face.ascent * 0.5), "{family}"); + assert_eq!((face.cap_height, face.x_height), (656.0, face.ascent * 0.5), "{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 + 14.4)).abs() < 0.01 && (x - (40.0 + 10.0)).abs() < 0.01, "{family}: {cap} {x}"); + assert!((cap - (40.0 + 13.12)).abs() < 0.01 && (x - (40.0 + 10.0)).abs() < 0.01, "{family}: {cap} {x}"); } } @@ -1475,3 +1476,246 @@ fn vertical_frames_keep_the_cap_height_and_x_height_first_baselines() { 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_grapheme_clusters_and_clamps_to_the_paragraph() { + // An accented letter written with a combining mark is one character. + 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 = 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::>()); + 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, fid) = drop_doc(LOREM, drop_cap(3, 1)); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.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]"); + // 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)); + // 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_sits_on_its_line_as_set() { + // Lines on a 15 pt baseline grid (not the 12 pt leading): the drop cap follows 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); +} diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index 14f76ca5d..7f456948c 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -289,6 +289,8 @@ pub enum NestedUntil { ForcedLineBreak, EmSpace, EnSpace, + /// The paragraph's drop cap characters. + Dropcap, /// Any of these characters. Chars(String), } @@ -562,8 +564,12 @@ 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 (grapheme clusters) 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(), /// Nested styles, in order from the paragraph start. nested_styles: Vec = Vec::new(), /// GREP styles: a character style for every match of a pattern (applied after nested). diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index a3cb09ca3..3789a1ac9 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -115,7 +115,7 @@ pub fn specs() -> Vec { "Paragraph Formatting", [], None, - "{attrs: {align?, leftIndent?, firstLineIndent?, spaceBefore?, spaceAfter?, dropCapLines?, hyphenate?, composer?, tabs?, …}}", + "{attrs: {align?, leftIndent?, firstLineIndent?, spaceBefore?, spaceAfter?, dropCapLines?, dropCapChars?, dropCapStyle?, hyphenate?, composer?, tabs?, …}}", has_text_or_frames, |s, p| format_paras(s, p.get("attrs").unwrap_or(p)) ), diff --git a/crates/engine/src/tests_idml.rs b/crates/engine/src/tests_idml.rs index cc40768ea..916872027 100644 --- a/crates/engine/src/tests_idml.rs +++ b/crates/engine/src/tests_idml.rs @@ -251,3 +251,27 @@ 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"}})).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_style.as_deref()), (Some(3), Some(1), Some("Initial"))); +} diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 9398af69a..5008345e5 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -63,8 +63,9 @@ pub struct FontFace { 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 - /// plausible OS/2 values): the Cap Height and x Height first baseline offsets. + /// 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, @@ -455,7 +456,13 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords 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: plausible(m.cap_height.map(f64::from), upem).unwrap_or(m.ascent as f64 * 0.72), + cap_height: plausible(m.cap_height.map(f64::from), upem) + .or_else(|| { + // No OS/2 cap height: the top of the H. + let gid = f.charmap().map('H')?; + plausible(f.glyph_metrics(Size::unscaled(), &location).bounds(gid).map(|b| b.y_max as f64), upem) + }) + .unwrap_or(m.ascent as f64 * 0.72), x_height: plausible(m.x_height.map(f64::from), upem).unwrap_or(m.ascent as f64 * 0.5), shaper, coords, diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 6e999d52c..e9aeae3da 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1150,6 +1150,10 @@ impl<'a> Ex<'a> { n!(space_after, "SpaceAfter"); n!(drop_cap_lines, "DropCapLines"); n!(drop_cap_chars, "DropCapCharacters"); + if let Some(s) = &a.drop_cap_style { + let s = if s.is_empty() { designcraft_doc::NO_CHAR_STYLE } else { s.as_str() }; + el.set("DropCapStyle", names::style_self("CharacterStyle", CHAR_BUILTINS, s)); + } if let Some(g) = a.grid_align { match g { designcraft_doc::GridAlign::None => el.set("GridAlignment", "None"), diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 8cf17fe04..107f72077 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1156,6 +1156,7 @@ impl<'r> Importer<'r> { a.space_after = e.num("SpaceAfter"); a.drop_cap_lines = u("DropCapLines"); a.drop_cap_chars = u("DropCapCharacters"); + a.drop_cap_style = e.prop("DropCapStyle").map(|r| self.char_style_ref(r.trim())); if let Some(g) = e.prop("GridAlignment") { a.grid_align = Some(if g == "None" { GridAlign::None diff --git a/crates/idml/src/names.rs b/crates/idml/src/names.rs index ae2805bda..fbe03666d 100644 --- a/crates/idml/src/names.rs +++ b/crates/idml/src/names.rs @@ -597,6 +597,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()), } } @@ -615,6 +616,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 2d4d23de6..02fe63ff8 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -560,3 +560,46 @@ fn vertical_story_orientation_round_trips() { let frame = back.spreads[0].items.iter().find_map(|i| i.text_frame()).unwrap(); assert!(frame.options.vertical); } + +#[test] +fn drop_caps_import_and_round_trip() { + // A style with a drop cap, its character style and a nested style through it; a paragraph + // overriding it locally. + let opener = r#" + CharacterStyle/Strong + CharacterStyle/StrongDropcap + 1true + + "#; + let styles = STYLES.replace("", opener); + let story = r#" + + + + Once upon a time + + +"#; + 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")); + let ns = st.para.nested_styles.clone().unwrap(); + assert_eq!((ns[0].style.as_str(), &ns[0].until), ("Strong", &designcraft_doc::NestedUntil::Dropcap)); + 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); + check(&import_idml(&export_idml(&d)).unwrap()); +} diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 82ce3c821..0fe8019b7 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -201,3 +201,15 @@ fn tables_export_cell_text() { assert!(text.contains(w), "{w} missing from {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::>().join(" "); + assert!(flat.starts_with("Drop caps read in order"), "{flat}"); +} diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 165366eb7..567624d49 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -174,7 +174,9 @@ fn line_box(l: &Line) -> Option { 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/docs/parity.md b/docs/parity.md index 94b364735..7df3b663d 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -5,12 +5,12 @@ The summary is generated by `cargo xtask parity` from the table at the bottom of 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. -**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 | @@ -224,6 +224,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 | From 88b3be01362e21b7c2d25ea7c41b5ff6d04cb8da Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 6 Oct 2026 19:52:41 -0400 Subject: [PATCH 09/56] UI: drop cap fields, selection over drop caps --- crates/ui-egui/src/canvas.rs | 9 +++++ crates/ui-egui/src/dialogs.rs | 45 +++++++++++++++++++++++-- crates/ui-egui/src/i18n.rs | 6 ++++ crates/ui-egui/src/i18n/ar.rs | 6 ++++ crates/ui-egui/src/panels/properties.rs | 11 ++++++ 5 files changed, 74 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index e1562c602..98a30f0d4 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1004,6 +1004,15 @@ fn draw_text_selection( 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)); } + // 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)); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index bfa83d4a9..35deb1081 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1771,6 +1771,35 @@ 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(); + }); + 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 = cur(d, "p.nestedStyles", &pv["nestedStyles"]).as_array().cloned().unwrap_or_default(); let mut changed = false; @@ -1814,9 +1843,19 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, &kind))) .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; diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 60fae75f3..a70bbe22a 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -1891,6 +1891,12 @@ 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", "ドロップ文字数", "下沉字符数"]), ("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 03f97a62b..3180bedea 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -317,6 +317,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("forcedLineBreak", "فاصل سطر إجباري"), ("emSpace", "مسافة em"), ("enSpace", "مسافة en"), + ("dropcap", "الحرف الاستهلالي"), + ("Drop Caps", "الأحرف الاستهلالية"), + ("Lines:", "الأسطر:"), + ("Characters:", "الأحرف:"), + ("Drop Lines", "أسطر الاستهلال"), + ("Drop Chars", "أحرف الاستهلال"), ("chars", "محارف محددة"), ("[Automatic]", "[تلقائي]"), ("Size:", "الحجم:"), diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index bf3f0b3af..817215226 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1452,6 +1452,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); From 3daf7fa1cf03c57cf63f3352c7d7f6cce96f31c7 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 6 Oct 2026 19:03:47 -0400 Subject: [PATCH 10/56] IDML: Keep Lines Together defaults to start/end of paragraph Keep Lines Together now defaults to "At Start/End of Paragraph" (2/2): new paragraphs, and IDML that sets Keep Lines Together without KeepAllLinesTogether anywhere in the style chain, keep lines at the start and end instead of moving the whole paragraph. The native format is now version 2. Loading a version-1 document pins "all lines" where that document resolved it from the old default (the start of each paragraph style chain that leaves it unset, and paragraphs whose style is missing), so saved documents keep their layout. --- ROADMAP.md | 4 +- crates/compose/src/tests.rs | 6 +- crates/doc/src/attrs.rs | 3 +- crates/doc/src/styles.rs | 4 +- crates/engine/src/tests_idml.rs | 85 +++++++++++++++++++++ crates/format/src/lib.rs | 127 +++++++++++++++++++++++++++++--- crates/idml/src/tests.rs | 42 +++++++++++ 7 files changed, 256 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 87ce118f3..e45c408a4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,13 +6,13 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope **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. +- 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. - 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`. -- 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) — 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`). diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 4f129c22b..11790836e 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -592,7 +592,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)); } diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index 7f456948c..10b3c7a60 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -608,7 +608,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/styles.rs b/crates/doc/src/styles.rs index d294a9509..3a5b340a7 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -273,8 +273,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/engine/src/tests_idml.rs b/crates/engine/src/tests_idml.rs index 916872027..2ac666291 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 { + 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::>().join("\u{2028}"); + format!( + r#"{text}{}"#, + if p + 1 < lines.len() { "
" } 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#""#)) + .collect(); + format!( + r#"{pts}"# + ) + }; + let designmap = format!( + r#" + + + + + +ParagraphStyle/$ID/[No paragraph style]12 + +{paras} +{}{} +"#, + 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 { + 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(); diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 5b4896ccc..e92b6ba08 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -5,18 +5,19 @@ //! - `assets/.`: embedded placed files, byte-for-byte //! - `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)] use std::io::{Cursor, Read, Write}; use std::sync::Arc; -use designcraft_doc::{AssetId, Document}; +use designcraft_doc::{AssetId, Document, 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 { @@ -87,12 +88,12 @@ fn load_zip(bytes: &[u8]) -> Result { 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::(&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::(&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 doc: Document = serde_json::from_slice(&json).map_err(|e| FormatError::NotOurs(e.to_string()))?; @@ -104,10 +105,51 @@ fn load_zip(bytes: &[u8]) -> Result { Arc::make_mut(a).data = Arc::new(data); } } + upgrade(&mut doc, version); 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 { + pin_keep_all_lines(doc); + } +} + +/// Format 1 laid out an unset Keep Lines Together mode as all lines in paragraph. Pin that where +/// nothing set it: at the start of each paragraph style chain, and on paragraphs whose style is +/// missing. +fn pin_keep_all_lines(doc: &mut Document) { + let roots: std::collections::HashSet = doc + .styles + .paragraph + .iter() + .filter_map(|s| { + let chain = doc.styles.para_chain(&s.name); + if chain.iter().any(|c| c.para.keep_all_lines.is_some()) { None } else { chain.first().map(|c| c.name.clone()) } + }) + .collect(); + if !roots.is_empty() { + for s in doc.styles_mut().paragraph.iter_mut().filter(|s| roots.contains(&s.name)) { + s.para.keep_all_lines = Some(true); + } + } + let styles = doc.styles.clone(); + let mut pin = |st: &mut Story| { + for p in st.paras.iter_mut().filter(|p| p.para.keep_all_lines.is_none() && styles.para(&p.style).is_none()) { + p.para.keep_all_lines = Some(true); + } + }; + 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 { let v: Value = serde_json::from_slice(bytes).map_err(|e| FormatError::NotOurs(e.to_string()))?; let mut doc: Document = serde_json::from_value(v.clone()).map_err(|e| FormatError::NotOurs(e.to_string()))?; @@ -120,6 +162,7 @@ fn load_legacy_json(bytes: &[u8]) -> Result { } } } + upgrade(&mut doc, 1); doc.check().map_err(|e| FormatError::NotOurs(e.to_string()))?; Ok(doc) } @@ -179,6 +222,72 @@ mod tests { assert_eq!(back.stories, d.stories); } + /// `doc` as format 1 wrote it. + fn save_v1(doc: &Document) -> Vec { + 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) -> 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); + } + #[test] fn rejects_garbage_and_newer_versions() { assert!(load(b"nope").is_err()); diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 02fe63ff8..249d29c0e 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -194,6 +194,48 @@ fn fixture_with_story(story: &str) -> Vec { ]) } +fn fixture_with_styles(styles: &str) -> Vec { + 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#" + + + + + ParagraphStyle/$ID/[No paragraph style] + + + +"# + ) +} + +/// 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}"); + } + } +} + #[test] fn imports_hand_written_fixture() { let d = import_idml_with(&fixture(), &|_| None).unwrap(); From 4a2a9ff7ddf71693583c9f3cf645110d19d2ce97 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 6 Oct 2026 19:08:26 -0400 Subject: [PATCH 11/56] IDML: absent attributes take the defaults IDML files assume Audit of every attribute the IDML importer reads against the value an IDML file assumes when it leaves the attribute out. Paragraph, character, text frame, page item, table and preference values already match except the corner size: a corner shape without a radius is now 12 pt instead of a square corner. Table-driven tests pin those defaults for the text attributes (omitted, written as defaults, written otherwise) and for page items, frames, tables and document preferences. --- ROADMAP.md | 2 +- crates/idml/src/import.rs | 5 +- crates/idml/src/tests.rs | 209 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 214 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e45c408a4..2bc132df3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,7 +12,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - 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`. -- 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) — 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) — 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`). diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 107f72077..b67a29a04 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -158,6 +158,9 @@ fn parse_point(s: Option<&str>) -> Option { (v.len() >= 2).then(|| Point::new(v[0], v[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` = default) → 0..1. fn tint(v: Option) -> Option { v.map(|t| if t < 0.0 { 1.0 } else { (t / 100.0) as f32 }) @@ -1850,7 +1853,7 @@ impl<'r> Importer<'r> { 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); + let size = e.num(&format!("{n}CornerRadius")).or(legacy.1).unwrap_or(INDESIGN_CORNER_SIZE); corners.corners[i] = Corner { shape, size }; } if !corners.is_none() { diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 249d29c0e..c8251e6d2 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -236,6 +236,215 @@ fn keep_lines_mode_defaults_to_start_and_end() { } } +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 { + 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; + "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; + "RuleAbove": "false" => false, "true" => true, |r| r.0.rule_above.on; + "RuleBelow": "false" => false, "true" => true, |r| r.0.rule_below.on; + "ParagraphShadingOn": "false" => false, "true" => true, |r| r.0.shading_on; + "ParagraphBorderOn": "false" => false, "true" => true, |r| r.0.border_on; + "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; + "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#" + + + + + ParagraphStyle/$ID/[No paragraph style] + + +"# + ) +} + +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::>().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); +} + +/// 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#""#)) + .collect(); + format!( + r#"{pts}"# + ) + }; + let designmap = format!( + r#" + +
+ +{} +{} +{} +{} +{} + +
"#, + 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, designcraft_doc::AutoSize::Off); + // 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)); + 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)); + } +} + #[test] fn imports_hand_written_fixture() { let d = import_idml_with(&fixture(), &|_| None).unwrap(); From 7a72faf740313cce2eeffabb4ce42b147c4b5536 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 7 Oct 2026 00:54:16 -0400 Subject: [PATCH 12/56] IDML: rules follow the text colour; defaults for auto-size, numbering and table fills Paragraph rules can be in Text Color: they take the colour of the paragraph's first character (rule above) or last character (rule below). That is the default, and IDML maps RuleAboveColor / RuleBelowColor "Text Color" both ways. Defaults for what a file leaves unset, in the model and in IDML import: text frames auto-size from the centre, numbered lists don't continue across stories, and alternating table and table style fills start Black 20 % then None 100 %. Kashidas (DefaultKashidas / KashidasOff) import and export. A tint of -1 is the colour's own tint everywhere tints are imported (underline and strikethrough, footnote rules included), and tints are capped at 100 %. Format 1 documents load as saved: they wrote rules, text frame options, lists and table fills in full, so nothing relied on the old defaults. --- ROADMAP.md | 4 +- crates/compose/src/lib.rs | 20 ++++-- crates/compose/src/tests.rs | 26 +++++++ crates/doc/src/attrs.rs | 6 +- crates/doc/src/item.rs | 4 +- crates/doc/src/lib.rs | 8 +-- crates/format/src/lib.rs | 21 ++++++ crates/idml/src/export.rs | 9 ++- crates/idml/src/import.rs | 66 ++++++++--------- crates/idml/src/names.rs | 8 +++ crates/idml/src/tests.rs | 136 +++++++++++++++++++++++++++++++++++- 11 files changed, 251 insertions(+), 57 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 2bc132df3..8bd7fb234 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,7 +12,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - 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`. -- 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) — 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) — 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`). @@ -97,7 +97,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - 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 `