From 0f4f382b0eb6336312e381686af97958e9108b67 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:41:46 -0400 Subject: [PATCH] feat(text): improve editing, snapping, and threading Add multi-click text selection, pending caret formatting and clipboard support, text-aware colour controls, guide/grid snapping, and frame threading with loaded-port interactions. Include the layout, drop-cap and IDML updates carried from #83, with safeguards for generated and linked stories, external text references, local IDs, locked frames and atomic colour edits. Fixes #23. Co-authored-by: Sean Hammond Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 3 + ROADMAP.md | 31 +- apps/designcraft/Cargo.toml | 1 + apps/designcraft/src/main.rs | 1 + crates/compose/Cargo.toml | 1 + crates/compose/src/lib.rs | 627 ++++++++-- crates/compose/src/notes.rs | 7 +- crates/compose/src/overlay.rs | 43 +- crates/compose/src/shape.rs | 55 +- crates/compose/src/table.rs | 14 +- crates/compose/src/tests.rs | 1110 ++++++++++++++++- crates/doc/src/attrs.rs | 32 +- crates/doc/src/edit.rs | 292 ++++- crates/doc/src/ids.rs | 21 + crates/doc/src/item.rs | 54 +- crates/doc/src/lib.rs | 8 +- crates/doc/src/notes.rs | 2 +- crates/doc/src/page.rs | 18 +- crates/doc/src/slice.rs | 108 ++ crates/doc/src/styles.rs | 4 +- crates/doc/src/tests.rs | 120 ++ crates/engine/Cargo.toml | 1 + crates/engine/src/cmd/edit.rs | 172 ++- crates/engine/src/cmd/file.rs | 20 + crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/notes.rs | 2 +- crates/engine/src/cmd/object.rs | 2 +- crates/engine/src/cmd/place_text.rs | 16 +- crates/engine/src/cmd/style.rs | 187 ++- crates/engine/src/cmd/text.rs | 629 +++++++++- crates/engine/src/cmd/text_color_tests.rs | 181 +++ crates/engine/src/cmd/thread.rs | 396 ++++++ crates/engine/src/lib.rs | 5 +- crates/engine/src/tests.rs | 100 ++ crates/engine/src/tests_idml.rs | 112 ++ crates/engine/tests/primary_frame_columns.rs | 34 + .../engine/tests/text_thread_regressions.rs | 277 ++++ crates/engine/tests/thread_color_safety.rs | 223 ++++ crates/fonts/src/fontdb.rs | 201 ++- crates/fonts/src/lib.rs | 16 + crates/fonts/src/tests_vertical.rs | 38 +- crates/fonts/src/vertical.rs | 41 +- crates/format/src/lib.rs | 190 ++- crates/geom/src/corners.rs | 26 + crates/idml/src/export.rs | 48 +- crates/idml/src/import.rs | 466 ++++++- crates/idml/src/names.rs | 6 +- crates/idml/src/tests.rs | 690 ++++++++++ crates/mcp/src/backend.rs | 2 + crates/mcp/src/headless.rs | 3 +- crates/mcp/src/tools.rs | 1 + crates/pdf/src/tests.rs | 12 + crates/render/src/lib.rs | 31 + crates/render/src/text.rs | 4 +- crates/tools/src/frame.rs | 76 +- crates/tools/src/lib.rs | 20 +- crates/tools/src/select.rs | 108 +- crates/tools/src/snap.rs | 35 +- crates/tools/src/tests.rs | 283 +++++ crates/tools/src/text.rs | 44 +- crates/tools/src/thread.rs | 259 ++++ crates/ui-egui/src/canvas.rs | 166 ++- crates/ui-egui/src/control.rs | 166 ++- crates/ui-egui/src/dialogs.rs | 62 +- crates/ui-egui/src/i18n.rs | 30 +- crates/ui-egui/src/i18n/ar.rs | 11 +- crates/ui-egui/src/i18n/it.rs | 3 +- crates/ui-egui/src/i18n/ja.rs | 3 +- crates/ui-egui/src/i18n/pt_br.rs | 3 +- crates/ui-egui/src/i18n/uk.rs | 3 +- crates/ui-egui/src/lib.rs | 158 ++- crates/ui-egui/src/menus.rs | 150 +++ crates/ui-egui/src/panels/mod.rs | 38 + crates/ui-egui/src/panels/properties.rs | 39 + crates/ui-egui/src/panels/swatches.rs | 45 +- crates/ui-egui/src/toolbar.rs | 110 +- docs/control-protocol.md | 2 +- docs/gaps.md | 40 +- docs/parity-checklist.md | 5 +- docs/roadmap.md | 16 +- docs/target-app-parity.md | 19 +- 81 files changed, 7920 insertions(+), 660 deletions(-) create mode 100644 crates/engine/src/cmd/text_color_tests.rs create mode 100644 crates/engine/src/cmd/thread.rs create mode 100644 crates/engine/tests/primary_frame_columns.rs create mode 100644 crates/engine/tests/text_thread_regressions.rs create mode 100644 crates/engine/tests/thread_color_safety.rs create mode 100644 crates/tools/src/thread.rs diff --git a/Cargo.lock b/Cargo.lock index ef525c8..138a753 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -889,6 +889,7 @@ dependencies = [ name = "designcraft" version = "0.7.1" dependencies = [ + "arboard", "designcraft-engine", "designcraft-ui-egui", "eframe", @@ -945,6 +946,7 @@ dependencies = [ "regex", "unicode-bidi", "unicode-bidi-mirroring", + "unicode-segmentation", ] [[package]] @@ -989,6 +991,7 @@ dependencies = [ "serde_json", "tempfile", "thiserror 2.0.21", + "unicode-segmentation", "wasm-bindgen-test", "zip", ] diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f820..abcd499 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,8 +1,8 @@ # DesignCraft roadmap -**Stage: pre-alpha** · next: alpha, ~0% and ~15–25 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%) +**Stage: pre-alpha** · next: alpha, ~5–7 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (cancelable macOS Dock Quit) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text threads by hand; the alpha gate waits on Primary Text Frame page fixes) · **Target:** Adobe InDesign 2026 DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. @@ -25,15 +25,14 @@ format + IDML), scriptable by agents (MCP), and available on the web. | Essentials user | ~54% | 135–250 (~50% parallel) | start-up robustness, context menus and copy/paste, threading, fonts, Windows printing | **Why pre-alpha:** it fails one of InDesign's core workflows, the alpha gate in -[docs/roadmap.md](docs/roadmap.md#alpha-gate). A user can't thread a story from one frame to -another: the in and out ports are drawn, but clicking the out port does nothing and there is no -thread or unthread command. Long text flows only through autoflow on Place and Primary Text -Frame, which users report adding and keeping pages wrongly (#165, #140, #194, #352). Everything -else in the gate works end to end on macOS (document setup, styles, images, object editing, PDF -export, save and reopen), and ready for real work (~47%) is above the alpha bar, so manual -threading is the whole distance to alpha. Beyond that, beta needs INDD opening, IDML proven on -real files, composition compared with InDesign's, and basic editing fixed on Windows and Linux. -Details: [docs/target-app-parity.md](docs/target-app-parity.md). +[docs/roadmap.md](docs/roadmap.md#alpha-gate). Text threads by hand (click a port, then click or +draw a frame; thread and unthread commands), but Primary Text Frame autoflow adds pages users +can't delete (#165). Everything else in the gate works end to end on macOS (document setup, +styles, images, object editing, PDF export, save and reopen), and ready for real work (~47%) is +above the alpha bar, so the Primary Text Frame page fixes are the whole distance to alpha. +Beyond that, beta needs INDD opening, IDML proven on real files, composition compared with +InDesign's, and basic editing fixed on Windows and Linux. Details: +[docs/target-app-parity.md](docs/target-app-parity.md). ## By dimension @@ -106,7 +105,7 @@ Also shipped: Italian (85%), Ukrainian (83%). Ranked; detail and estimates in [docs/roadmap.md](docs/roadmap.md), the full list in [docs/gaps.md](docs/gaps.md). -1. **Alpha gate:** manual text threading (out-port click, thread/unthread commands) and Primary Text Frame fixes (#165) (15–25 h) +1. **Alpha gate:** Primary Text Frame page fixes: autoflow adds pages users can't delete (#165) (5–7 h) 2. Basic editing on every platform: copy/paste, context menus, caret formatting (30–50 h) 3. CI running `cargo xtask ci` on pull requests (4–8 h) 4. Open INDD through a clean-room converter, owner decision on #114 (20–60 h) @@ -129,6 +128,13 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-12** · Text threads by hand: click a selected text frame's in or out port to load + the text cursor, then click a frame to thread it, click empty space for a column-wide frame or + drag one (it snaps like frame drawing); Shift-click autoflows, Alt-click stays loaded on the + frame just added; click the linked frame or double-click a port to break the thread. + `text.thread`, `text.unthread`, `text.removeFromThread` with undo; locked frames and layers + refuse threads. The alpha gate now waits only on the Primary Text Frame page fixes (#165). No + readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. @@ -195,6 +201,7 @@ Newest first. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Manual text threading landed; alpha distance 15–25 h → 5–7 h (Primary Text Frame page fixes, #165) | | 2026-10-10 | minor | Full number 42% → 47% (method aligned with the standard: plain weighted sum, no new evidence); readiness table with hours per audience | | 2026-10-10 | minor | Headline adds mainstream practitioner (~42%) and essentials user (~54%); method in docs/target-app-parity.md | | 2026-10-10 | minor | Stage alpha → pre-alpha under the new core-workflow gate: manual text threading is missing; alpha gate table added to docs/roadmap.md | diff --git a/apps/designcraft/Cargo.toml b/apps/designcraft/Cargo.toml index b65efbe..0f08ae8 100644 --- a/apps/designcraft/Cargo.toml +++ b/apps/designcraft/Cargo.toml @@ -25,6 +25,7 @@ winresource = "0.1" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rfd = { workspace = true } +arboard = { workspace = true } [target.'cfg(all(unix, not(target_os = "macos")))'.dependencies] # The same winit eframe uses: to pick the X11 backend (file drag-and-drop) in main.rs. diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index 26ce2ee..2ac607b 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -210,6 +210,7 @@ fn services() -> Services { pick_save: Some(Box::new(|name: &str| rfd::FileDialog::new().set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()))), read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), write: Some(Box::new(|p: &str, b: &[u8]| designcraft_engine::atomic::write_atomic(std::path::Path::new(p), b).map_err(|e| e.to_string()))), + clipboard_text: Some(Box::new(|| arboard::Clipboard::new().and_then(|mut c| c.get_text()).ok())), ..Default::default() } } diff --git a/crates/compose/Cargo.toml b/crates/compose/Cargo.toml index b8993bc..b99e108 100644 --- a/crates/compose/Cargo.toml +++ b/crates/compose/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [dependencies] unicode-bidi = { workspace = true } +unicode-segmentation = { workspace = true } unicode-bidi-mirroring = { workspace = true } designcraft-geom = { workspace = true } designcraft-color = { workspace = true } diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index cd0e7cb..4ed599b 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -38,9 +38,9 @@ use designcraft_geom::{Point, Rect}; use crate::breaker::{Break, Spacing}; pub use crate::cache::Cache; use crate::notes::Notes; -pub use crate::notes::{find_note, hit_note, note_caret}; +pub use crate::notes::{find_note, hit_note, hit_note_with, note_caret}; use crate::shape::{Glyph, StyleTable, SubstCtx}; -pub use crate::table::{PlacedCell, StrokeSeg, TableFrag, cell_caret, find_cell, hit_cell}; +pub use crate::table::{PlacedCell, StrokeSeg, TableFrag, cell_caret, find_cell, hit_cell, hit_cell_with}; /// Character appearance shared by many glyphs (indexed from [`PlacedGlyph::style`]). #[derive(Clone, Debug, PartialEq)] @@ -197,6 +197,20 @@ pub struct Line { pub hj: u8, /// The paragraph's Keep Options couldn't be honoured here (Highlight Keep Violations). pub keep_violation: bool, + /// The drop cap a paragraph's first line starts with (its first glyphs, set down to a later + /// line's baseline). + pub drop_cap: Option, +} + +/// Where a drop cap sits (frame inner space). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct DropCapBox { + /// Story bytes from its line's start up to here are the drop cap's. + pub end: usize, + /// Its baseline. + pub baseline: f64, + /// The area it covers: from its line's top down to the text below its baseline. + pub rect: Rect, } /// A paragraph rule or shading rectangle in frame inner space. @@ -316,7 +330,8 @@ pub struct FrameSpec { impl FrameSpec { pub fn columns(&self) -> Vec { let a = self.area; - let n = self.opts.columns.max(1); + // Primary frames inherit the document limit (216), beyond the interactive frame editor's 40. + let n = self.opts.columns.clamp(1, 216); match self.opts.columns_kind { designcraft_doc::ColumnsKind::FixedWidth if self.opts.column_width > 0.0 => (0..n) .map(|i| { @@ -652,6 +667,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C notes.number_refs(doc, prange.clone(), cur_frame.and_then(|f| f.page), &mut sub); } let sub_objects = sub.objects.clone(); + let drop_end = drop_cap_end(&story.text, prange.clone(), &pp, sub.vertical); let mut table = StyleTable { styles: &mut styles_tab, missing: &mut missing_fonts }; let env = shape::TypeEnv { auto_leading: pp.auto_leading, adv: doc.settings.advanced_type, glyph_fallback: doc.settings.glyph_fallback }; let mut sp = shape::shape_para( @@ -667,7 +683,22 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C &pp.nested_styles, &pp.grep_styles, &[], + (drop_end, &pp.drop_cap_style), ); + // Drop cap: the size and indent come from the text beside it (`base_*` when there's none). + let base_leading = match base_chars.leading { + designcraft_doc::Leading::Auto => base_chars.size * pp.auto_leading, + designcraft_doc::Leading::Points(v) => v, + }; + let base_cap = { + let face = db.face(&base_chars.font_family, &base_chars.font_style); + face.cap_height * base_chars.size / face.upem * base_chars.v_scale + }; + let drop_grid = cur_frame.and_then(|f| f.grid).filter(|_| pp.grid_align == GridAlign::AllLines).map(|g| g.1); + // Align Left Edge (left-to-right paragraphs). + let drop_align = pp.drop_cap_align_left && pp.direction == designcraft_doc::TextDirection::LeftToRight; + // Tab stops for the drop cap, with the start-side indent (the right one, right to left). + let drop_tabs = (&pp.tabs[..], if pp.direction == designcraft_doc::TextDirection::RightToLeft { pp.right_indent } else { pp.left_indent }); if !pp.nested_line_styles.is_empty() && cur.fi < frames.len() { // Nested line styles: find where the first lines end in this column, restyle them, and // look again (the style changes the widths) until the lines settle. @@ -677,9 +708,17 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let spacing = spacing_for(&pp, base_chars.size); let mut lines: Vec<(std::ops::Range, String)> = Vec::new(); for _ in 0..3 { - let width = |j: usize| (col.width() - pp.left_indent - pp.right_indent - if j == 0 { pp.first_line_indent } else { 0.0 }).max(1.0); - // The same spacing, hyphenation and breaker as the layout below. + // The same drop cap, spacing, hyphenation and breaker as the layout below. let mut gl = sp.glyphs.clone(); + let dc = split_drop_cap(db, &mut gl, drop_end, pp.drop_cap_lines, base_leading, base_cap, drop_grid, drop_align, drop_tabs); + let width = |j: usize| { + let ind = match &dc { + Some(dc) if j < dc.lines => dc.indent(), + _ if j == 0 => pp.first_line_indent, + _ => 0.0, + }; + (col.width() - pp.left_indent - pp.right_indent - ind).max(1.0) + }; apply_desired_spacing(&mut gl, &pp); let hy = hyphenation_points(&story.text, &gl, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars)); let breaks = if pp.composer == Composer::SingleLine || gl.iter().any(|g| g.ch == '\t') || gl.len() > 4000 { @@ -725,30 +764,46 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C &pp.nested_styles, &pp.grep_styles, &lines, + (drop_end, &pp.drop_cap_style), ); } } + // Rules in Text Color: the colour of the paragraph's first character (rule above) and last + // character (rule below). + let text_color = |g: Option<&Glyph>| { + g.and_then(|g| table.styles.get(g.style as usize)) + .map_or((base_chars.fill.clone(), base_chars.fill_tint), |s| (s.fill.clone(), s.fill_tint)) + }; + let (above, below) = (text_color(sp.glyphs.iter().find(|g| g.len > 0)), text_color(sp.glyphs.iter().rev().find(|g| g.len > 0))); + let rule_color = |r: &designcraft_doc::Rule, text: &(String, f32)| { + if r.color == designcraft_doc::TEXT_COLOR { text.clone() } else { (r.color.clone(), r.tint) } + }; + let drop_cap = split_drop_cap(db, &mut sp.glyphs, drop_end, pp.drop_cap_lines, base_leading, base_cap, drop_grid, drop_align, drop_tabs); // List labels take the default super/subscript settings. let label_env = shape::TypeEnv { adv: Default::default(), ..env }; - match pp.list_type { + let list_label = match pp.list_type { // An empty paragraph has no bullet or number, and the numbering carries on past it. - designcraft_doc::ListType::Numbers | designcraft_doc::ListType::Bullets if prange.is_empty() => {} + designcraft_doc::ListType::Numbers | designcraft_doc::ListType::Bullets if prange.is_empty() => None, designcraft_doc::ListType::Numbers if !pp.list_name.is_empty() => { // A named list: carries on past other paragraphs (and from earlier stories). let n = named_numbers.get(pi).copied().flatten().unwrap_or(1); - let label = pp.number_label(n); - prepend_label(db, &mut sp.glyphs, &label, prange.start, &base_chars, label_env, &mut table); + Some(pp.number_label(n)) } designcraft_doc::ListType::Numbers => { list_counter = pp.start_at.map_or(list_counter.saturating_add(1), |s| s.max(1)); - let label = pp.number_label(list_counter); - prepend_label(db, &mut sp.glyphs, &label, prange.start, &base_chars, label_env, &mut table); + Some(pp.number_label(list_counter)) } - designcraft_doc::ListType::Bullets => { - let label = pp.bullet_label(); - prepend_label(db, &mut sp.glyphs, &label, prange.start, &base_chars, label_env, &mut table); + designcraft_doc::ListType::Bullets => Some(pp.bullet_label()), + designcraft_doc::ListType::None => { + list_counter = 0; + None + } + }; + if let Some(label) = list_label { + let n = prepend_label(db, &mut sp.glyphs, &label, prange.start, &base_chars, label_env, &mut table); + for g in sp.glyphs.iter_mut().take(n) { + g.list_label = true; } - designcraft_doc::ListType::None => list_counter = 0, } if pi == 0 && let Some(label) = &opts.label @@ -765,10 +820,6 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C apply_desired_spacing(&mut glyphs, &pp); let hyph_after = hyphenation_points(&story.text, &glyphs, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars)); let base_size = base_chars.size; - let base_leading = match base_chars.leading { - designcraft_doc::Leading::Auto => base_size * pp.auto_leading, - designcraft_doc::Leading::Points(v) => v, - }; let spacing = spacing_for(&pp, base_size); // A paragraph set across the columns moves on to the next frame where others move on to // the next column. @@ -868,6 +919,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let mut col_first_line = 0usize; let has_tabs = glyphs.iter().any(|g| g.ch == '\t'); let mut first_line_rect: Option<(usize, f64, f64, f64)> = None; // frame, baseline, ascent, x-span + let rtl = pp.direction == designcraft_doc::TextDirection::RightToLeft; loop { if cur.fi >= frames.len() { if let Some(j) = trial_failed(&mut trial, &mut limits, balance_runs < balance_budget) { @@ -888,11 +940,22 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let col = if spanning { span_rect(&cols[cur.fi], Rect::new(col.x0, col.y0, col.x1, base.y1), pp.span_columns) } else { col }; let col = cur.split_rect(col); // Estimate slots for the breaker with the paragraph's base leading. - let est_first = cur.next_baseline(f, col, base_leading, base_chars.size * 0.75, &pp); - let slots = estimate_slots(f, col, est_first, base_leading, base_chars.size, &glyphs[g0..], &pp, line_no); + let est_asc = base_chars.size * 0.75; + let est_tops = Tops { typo_ascent: est_asc, cap: est_asc * 0.72, xh: est_asc * 0.5, em: base_chars.size * 0.88 }; + let est_first = cur.next_baseline(f, col, base_leading, est_asc, est_tops, &pp); + let est_jump = cur.jump(f, base_leading, est_asc, est_tops); + let slots = estimate_slots(f, col, est_first, est_jump, base_leading, base_chars.size, &glyphs[g0..], &pp, line_no); let width = |j: usize| -> f64 { let (x0, x1) = slots.get(j).copied().unwrap_or((col.x0, col.x1)); - let ind = pp.left_indent + pp.right_indent + if line_no + j == 0 { pp.first_line_indent } else { 0.0 }; + // Beside the drop cap (in the column the paragraph starts in), its width; else the + // first-line indent. + let ind = pp.left_indent + + pp.right_indent + + match &drop_cap { + Some(dc) if line_no + j < dc.lines && col_first_line == 0 => dc.indent(), + _ if line_no + j == 0 => pp.first_line_indent, + _ => 0.0, + }; (x1 - x0 - ind).max(1.0) }; let rest = &glyphs[g0..]; @@ -914,39 +977,36 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C for (k, b) in breaks.iter().enumerate() { let (s, e) = (g0 + b.start, g0 + b.end); let line_glyphs = &glyphs[s..e.max(s)]; - let (asc, desc, lead) = line_metrics(line_glyphs, &glyphs, s, base_leading, base_chars.size, db, &base_chars); + let (asc, tops, desc, lead) = line_metrics(line_glyphs, &glyphs, s, base_leading, base_chars.size, db, &base_chars); let reference = cjk_line_reference(line_glyphs); - let mut baseline = cur.next_baseline(f, col, lead, asc, &pp); + // Baseline grid: the next grid line at or below. + let grid = f.grid.filter(|&(_, inc)| { + inc > 0.0 && (pp.grid_align == GridAlign::AllLines || (pp.grid_align == GridAlign::FirstLineOnly && line_no == 0)) + }); + let snap = |y: f64| grid.map_or(y, |(g_start, inc)| g_start + ((y - g_start) / inc - 1e-6).ceil() * inc); + let mut baseline = cur.next_baseline(f, col, lead, asc, tops, &pp); if cur.last_baseline.is_some() { baseline += cur.last_reference - reference; } - // Baseline grid. - if let Some((g_start, inc)) = f.grid - && (pp.grid_align == GridAlign::AllLines || (pp.grid_align == GridAlign::FirstLineOnly && line_no == 0)) - && inc > 0.0 - { - let n = ((baseline - g_start) / inc - 1e-6).ceil(); - baseline = g_start + n * inc; - } - // Wrap: push the line down until a slot exists. + baseline = snap(baseline); + // Wrap: push the line down to the first position with a slot (back on the grid). let (mut x0, mut x1) = (col.x0, col.x1); if !f.exclusions.is_empty() { + let jump = cur.jump(f, lead, asc, tops); let mut tries = 0; loop { - match free_slot(f, col, baseline - asc, baseline + desc, base_size) { - Some((a, b)) => { - x0 = a; - x1 = b; - break; - } - None => { - baseline += 1.0; - tries += 1; - if baseline > col.y1 || tries > 4000 { - break; - } - } + let Some((nb, (a, z))) = wrap_slot(f, col, baseline, asc, desc, jump, base_size) else { + // No room in this column. + baseline = baseline.max(col.y1) + 1.0; + break; + }; + let pushed = nb > baseline; + (baseline, x0, x1) = (nb, a, z); + tries += 1; + if !pushed || grid.is_none() || tries > 4000 { + break; } + baseline = snap(baseline); } } let capped = cur.split.is_none() && line_cap[pi] == Some(line_no) && line_no > col_first_line; @@ -1022,9 +1082,15 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C moved = true; break; } - let ind_l = pp.left_indent + if line_no == 0 { pp.first_line_indent } else { 0.0 }; + // A drop cap sits at the start-side indent; the first-line indent doesn't move it. + let beside = drop_cap.as_ref().filter(|dc| line_no < dc.lines && col_first_line == 0).map(DropCap::indent); + let (ind_l, ind_r) = match beside { + Some(w) if rtl => (pp.left_indent, pp.right_indent + w), + Some(w) => (pp.left_indent + w, pp.right_indent), + None => (pp.left_indent + if line_no == 0 { pp.first_line_indent } else { 0.0 }, pp.right_indent), + }; let lx0 = x0 + ind_l; - let lx1 = x1 - pp.right_indent; + let lx1 = x1 - ind_r; let last = k + 1 == breaks.len(); // The break character that ends this line, if any (it is the line's last glyph). let brk = if b.forced && e > s { @@ -1038,8 +1104,30 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let forced_mid = brk == Some(story::FORCED_LINE_BREAK) && !last; let (mut placed, end_x, ratio) = layout_line(&glyphs, s, e, b.hyphen, lx0, lx1, col.x0, &pp, &spacing, ends_para, forced_mid, f.left_page, &bidi_info); - // Warichu runs before ruby so a reading is placed over the stacked note. + // Warichu runs before ruby so a reading is placed over the stacked note, and before + // the trimmed spaces and the drop cap join the line, so neither is stacked. let end_x = end_x + warichu::place(&styles_tab, &mut placed); + // The spaces the break trimmed off the end follow the line undrawn, so the caret + // and the selection reach them; alignment and justification ignore them. + if pp.direction != designcraft_doc::TextDirection::RightToLeft && !placed.iter().any(|g| g.rtl) { + let mut x = end_x; + for g in glyphs.get(e..(g0 + b.next).min(glyphs.len())).unwrap_or(&[]).iter().filter(|g| g.is_space()) { + placed.push(PlacedGlyph { visible: false, ..place(g, x) }); + x += g.adv; + } + } + let mut drop_box = None; + if line_no == 0 + && let Some(dc) = &drop_cap + { + let at = if rtl { x1 - pp.right_indent - dc.width } else { x0 + pp.left_indent - dc.lsb }; + placed.splice(0..0, place_drop_cap(dc, at, rtl)); + drop_box = Some(DropCapBox { + end: dc.end, + baseline: baseline + dc.drop, + rect: Rect::new(at, baseline - asc, at + dc.width, baseline + dc.drop + desc), + }); + } ruby::annotate(db, &styles_tab, &mut placed, doc.settings.glyph_fallback); let range_end = if last { prange.end } else { glyphs.get(g0 + b.next).map(|g| g.byte).unwrap_or(prange.end) }; let range_start = glyphs.get(s).map(|g| g.byte).unwrap_or(prange.start).min(range_end); @@ -1071,6 +1159,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C spacing: ratio, hj: hj_severity(ratio, pp.word_space_min, pp.word_space_max), keep_violation: false, + drop_cap: drop_box, }); for k in line_notes { notes.place(doc, k, cur.fi, cur.col, col_w, f, opts); @@ -1147,11 +1236,8 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C .find(|l| l.para == pi && l.first_in_para) .map(|l| if r.column_width { (l.x0, l.x1) } else { (l.x0, l.end_x) }) .unwrap_or((0.0, 0.0)); - ft.decos.push(Deco { - rect: Rect::new(col.0 + r.left_indent, y - r.weight, col.1 - r.right_indent, y), - color: r.color.clone(), - tint: r.tint, - }); + let (color, tint) = rule_color(r, &above); + ft.decos.push(Deco { rect: Rect::new(col.0 + r.left_indent, y - r.weight, col.1 - r.right_indent, y), color, tint }); } } para_box_decos(&mut out, pi, &pp); @@ -1162,7 +1248,8 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let r = &pp.rule_below; let y = l.baseline + r.offset; let (x0, x1) = if r.column_width { (l.x0, l.x1) } else { (l.x0, l.end_x) }; - ft.decos.push(Deco { rect: Rect::new(x0 + r.left_indent, y, x1 - r.right_indent, y + r.weight), color: r.color.clone(), tint: r.tint }); + let (color, tint) = rule_color(r, &below); + ft.decos.push(Deco { rect: Rect::new(x0 + r.left_indent, y, x1 - r.right_indent, y + r.weight), color, tint }); } // The end of a split block: text after it continues below its deepest sub-column, which // are balanced first. A block that ends the story fills its sub-columns in turn. @@ -1996,22 +2083,56 @@ impl Cursor { self.next_frame(); self.fi = fi; } - /// Baseline for the next line with leading `lead` and ascent `asc`. - fn next_baseline(&self, f: &FrameSpec, col: Rect, lead: f64, asc: f64, _pp: &ParaProps) -> f64 { + /// Baseline for the next line with leading `lead`, ascent `asc` and first baseline heights + /// `tops`. + fn next_baseline(&self, f: &FrameSpec, col: Rect, lead: f64, asc: f64, tops: Tops, _pp: &ParaProps) -> f64 { match self.last_baseline { Some(b) => b + lead + self.pending, - None => { - let off = match f.opts.first_baseline { - FirstBaseline::Ascent => asc, - FirstBaseline::CapHeight => asc * 0.72, - FirstBaseline::XHeight => asc * 0.5, - FirstBaseline::Leading => lead, - FirstBaseline::Fixed => 0.0, - }; - col.y0 + off.max(f.opts.first_baseline_min) - } + None => col.y0 + first_baseline_offset(f, lead, asc, tops), } } + /// How the next line clears a Jump Object wrap: by whole leadings below the line above, or, first + /// in its column, as at the top of a frame whose top is the wrap bottom. + fn jump(&self, f: &FrameSpec, lead: f64, asc: f64, tops: Tops) -> Jump { + match self.last_baseline { + Some(_) => Jump { top: lead, step: lead }, + None => Jump { top: first_baseline_offset(f, lead, asc, tops), step: 0.0 }, + } + } +} + +/// The frame's first baseline offset below the top of a column, for a line with leading `lead`, +/// ascent `asc` and first baseline heights `tops`. +fn first_baseline_offset(f: &FrameSpec, lead: f64, asc: f64, tops: Tops) -> f64 { + let off = match f.opts.first_baseline { + // Vertical frames measure from the ascent. + FirstBaseline::Ascent | FirstBaseline::EmboxHeight if f.vertical => asc, + FirstBaseline::CapHeight if f.vertical => asc * 0.72, + FirstBaseline::XHeight if f.vertical => asc * 0.5, + FirstBaseline::Ascent => tops.typo_ascent, + FirstBaseline::CapHeight => tops.cap, + FirstBaseline::XHeight => tops.xh, + FirstBaseline::EmboxHeight => tops.em, + FirstBaseline::Leading => lead, + FirstBaseline::Fixed => 0.0, + }; + off.max(f.opts.first_baseline_min) +} + +/// A line's tallest typographic ascender, cap height, x height and em box top above its baseline: +/// the first baseline offsets. +#[derive(Clone, Copy, Default)] +struct Tops { + typo_ascent: f64, + cap: f64, + xh: f64, + em: f64, +} + +/// The em box top above the baseline at `size` ([`designcraft_fonts::FontFace::em_box`]). +fn em_box_top(face: &designcraft_fonts::FontFace, size: f64) -> f64 { + let em = Some(face.em_box().0 / face.upem).filter(|e| e.is_finite()).unwrap_or(0.88); + em * size } /// Em box relative to the baseline. Font ascender/descender proportions locate it; @@ -2054,24 +2175,103 @@ fn line_metrics( base_size: f64, db: &ScopedFonts<'_>, base: &designcraft_doc::CharProps, -) -> (f64, f64, f64) { +) -> (f64, Tops, f64, f64) { let src: &[Glyph] = if line.is_empty() { all.get(s..(s + 1).min(all.len())).unwrap_or(&[]) } else { line }; + let face = || db.face(&base.font_family, &base.font_style); if src.is_empty() { - let face = db.face(&base.font_family, &base.font_style); + let face = face(); let k = base_size / face.upem; - return (face.ascent * k, face.descent * k, base_leading); + let tops = Tops { typo_ascent: face.typo_ascent * k, cap: face.cap_height * k, xh: face.x_height * k, em: em_box_top(&face, base_size) }; + return (face.ascent * k, tops, face.descent * k, base_leading); } let mut asc: f64 = 0.0; + let mut tops = Tops::default(); let mut desc: f64 = 0.0; let mut lead: f64 = 0.0; let reference = src.iter().max_by(|a, b| a.size.total_cmp(&b.size)); + let mut own_text = false; for g in src { - let shift = g.shift + reference.map_or(0.0, |r| cjk_alignment_shift(g, r)); - asc = asc.max(g.ascent + shift.max(0.0)); + let align = reference.map_or(0.0, |r| cjk_alignment_shift(g, r)); + let shift = g.shift + align; + let up = shift.max(0.0); + asc = asc.max(g.ascent + up); + // A list's bullet or number doesn't raise the Ascent offset: only the line's own text does. + if !g.list_label { + own_text = true; + tops.typo_ascent = tops.typo_ascent.max(g.typo_ascent + up); + // The em box sits at the unshifted baseline: a baseline shift moves only the drawn glyphs. + tops.em = tops.em.max(em_box_top(&g.face, g.size) + align.max(0.0)); + } + tops.cap = tops.cap.max(g.cap + up); + tops.xh = tops.xh.max(g.xh + up); desc = desc.max(g.descent - shift.min(0.0)); lead = lead.max(g.leading); } - (asc, desc, lead) + if !own_text { + // Only a label (an empty list paragraph): as an empty paragraph. + let face = face(); + tops.typo_ascent = face.typo_ascent * base_size / face.upem; + tops.em = em_box_top(&face, base_size); + } + (asc, tops, desc, lead) +} + +/// How a line clears Jump Object wraps. The line occupies `[baseline − top, baseline]` (its leading, +/// or its first baseline offset at the top of a column) and must not overlap the wrap; it may hang its +/// descenders into the wrap. Below a wrap it moves down from its place in whole `step`s until it clears +/// the wrap bottom (`step` 0: its top lands on the wrap bottom). +#[derive(Clone, Copy, Debug)] +struct Jump { + top: f64, + step: f64, +} + +/// Slack for float error when a line is tested against a Jump Object wrap's edges. +const JUMP_EPS: f64 = 1e-6; + +/// The first baseline at or below `baseline` where a line with ascent `asc` and descent `desc` has a +/// free slot in `col`, and that slot; None if there is none above the column bottom. +/// +/// A line under a Jump Object wrap moves below it as `jump` says. Other wraps are rectangles, so the +/// free space only grows where one in the line's band ends: the search jumps from one such bottom +/// edge to the next, and a line that clears a wrap has its top exactly on the wrap's bottom. +fn wrap_slot(f: &FrameSpec, col: Rect, baseline: f64, asc: f64, desc: f64, jump: Jump, size: f64) -> Option<(f64, (f64, f64))> { + let top = jump.top.max(0.0); + let mut b = baseline; + // Each step passes at least one wrap; the cap bounds hostile geometry. + for _ in 0..4000 { + let jumped = f + .exclusions + .iter() + .filter(|e| e.mode == WrapMode::JumpObject) + .map(|e| e.rect) + .filter(|r| r.x0 < col.x1 && r.x1 > col.x0 && b > r.y0 + JUMP_EPS && b - top < r.y1 - JUMP_EPS) + .fold(f64::NEG_INFINITY, |m, r| m.max(r.y1)); + b = if jumped > f64::NEG_INFINITY { + if jump.step > 0.0 { + // Whole steps from the line's own place (at least one step past the line above). + baseline + ((jumped + top - baseline - JUMP_EPS / 2.0) / jump.step).ceil().max(0.0) * jump.step + } else { + jumped + top + } + } else if let Some(slot) = free_slot(f, col, b - asc, b + desc, size) { + return Some((b, slot)); + } else { + let (y0, y1) = (b - asc, b + desc); + let edge = f + .exclusions + .iter() + .filter(|e| e.mode != WrapMode::JumpObject) + .map(|e| e.rect) + .filter(|r| r.y0 < y1 && r.y1 > y0) + .fold(f64::INFINITY, |m, r| m.min(r.y1)); + edge + asc + }; + if b.is_nan() || b > col.y1 { + return None; + } + } + None } /// Widest free horizontal interval of `col` in the band, or None if blocked. @@ -2082,7 +2282,6 @@ fn free_slot(f: &FrameSpec, col: Rect, y0: f64, y1: f64, size: f64) -> Option<(f let overlaps_band = r.y0 < y1 && r.y1 > y0; match ex.mode { WrapMode::JumpToNextColumn if y1 > r.y0 && r.x0 < col.x1 && r.x1 > col.x0 => return None, - WrapMode::JumpObject if overlaps_band && r.x0 < col.x1 && r.x1 > col.x0 => return None, WrapMode::BoundingBox | WrapMode::Contour if overlaps_band => { let mut next = Vec::new(); for (a, b) in free { @@ -2106,7 +2305,17 @@ fn free_slot(f: &FrameSpec, col: Rect, y0: f64, y1: f64, size: f64) -> Option<(f } #[allow(clippy::too_many_arguments)] -fn estimate_slots(f: &FrameSpec, col: Rect, first: f64, lead: f64, size: f64, glyphs: &[Glyph], pp: &ParaProps, line_no: usize) -> Vec<(f64, f64)> { +fn estimate_slots( + f: &FrameSpec, + col: Rect, + first: f64, + jump: Jump, + lead: f64, + size: f64, + glyphs: &[Glyph], + pp: &ParaProps, + line_no: usize, +) -> Vec<(f64, f64)> { let _ = (pp, line_no); if f.exclusions.is_empty() { return vec![]; @@ -2116,20 +2325,21 @@ fn estimate_slots(f: &FrameSpec, col: Rect, first: f64, lead: f64, size: f64, gl let n = ((total / (col.width().max(10.0) * 0.5)).ceil() as usize + 2).min(2000); let mut v = Vec::with_capacity(n); let mut b = first; + let mut jump = jump; for _ in 0..n { - let mut tries = 0; - let slot = loop { - if let Some(s) = free_slot(f, col, b - size * 0.8, b + size * 0.25, size) { - break s; + let slot = match wrap_slot(f, col, b, size * 0.8, size * 0.25, jump, size) { + Some((nb, s)) => { + b = nb; + s } - b += 1.0; - tries += 1; - if b > col.y1 || tries > 4000 { - break (col.x0, col.x1); + None => { + b = b.max(col.y1); + (col.x0, col.x1) } }; v.push(slot); b += lead; + jump = Jump { top: lead, step: lead }; } v } @@ -2656,6 +2866,147 @@ fn place(g: &Glyph, x: f64) -> PlacedGlyph { } } +/// Drop caps are at most this many lines and characters (larger values are capped). +pub const MAX_DROP_CAP_LINES: u32 = 25; +pub const MAX_DROP_CAP_CHARS: u32 = 150; + +/// Byte where paragraph `prange`'s drop cap ends: after its first `drop_cap_chars` characters +/// (code points, as InDesign counts them, spaces included), extended to the end of the grapheme +/// cluster they end in so a letter keeps its combining marks; all of them in a shorter paragraph. +/// `prange.start` when it has none: no lines or characters, or vertical text (which sets no drop +/// caps). +fn drop_cap_end(text: &str, prange: Range, pp: &ParaProps, vertical: bool) -> usize { + use unicode_segmentation::UnicodeSegmentation as _; + if vertical || pp.drop_cap_lines == 0 || pp.drop_cap_chars == 0 { + return prange.start; + } + let Some(para) = text.get(prange.clone()) else { return prange.start }; + let para = para.trim_end_matches('\n'); + let n = pp.drop_cap_chars.min(MAX_DROP_CAP_CHARS) as usize; + let end = para.char_indices().nth(n).map_or(para.len(), |(i, _)| i); + prange.start + para.grapheme_indices(true).map(|(i, _)| i).find(|i| *i >= end).unwrap_or(para.len()) +} + +/// A paragraph's drop cap: its first glyphs, enlarged to reach down `lines` lines. +struct DropCap { + glyphs: Vec, + /// Story byte it ends at. + end: usize, + lines: usize, + /// Its advance. + width: f64, + /// How far its origin sits left of the indent: its first letter's left side bearing with + /// Align Left Edge, else 0. + lsb: f64, + /// Its baseline below the first line's: `lines − 1` leadings of the text after it (grid steps + /// on a baseline grid), wherever line `lines` is set. + drop: f64, +} + +/// Take the drop cap (the glyphs before byte `end`) off the front of a paragraph's glyphs and scale +/// it so its cap height reaches from the first line's cap height down to line `lines`' baseline, +/// measured in the leading and cap height of the text after it. A one-line drop cap keeps its own +/// size when that is smaller. On a baseline grid of increment `grid`, lines are that many grid +/// steps apart. With `align_left` its ink starts at the indent (see [`DropCap::lsb`]). Tabs inside +/// it advance to the stops of `tabs` (stops and the start-side indent; in right-to-left paragraphs +/// both measured from the column's right edge). Spaces right after it join it at its size without +/// widening it. +#[allow(clippy::too_many_arguments)] +fn split_drop_cap( + db: &ScopedFonts<'_>, + glyphs: &mut Vec, + end: usize, + lines: u32, + base_leading: f64, + base_cap: f64, + grid: Option, + align_left: bool, + tabs: (&[designcraft_doc::TabStop], f64), +) -> 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 spaces = glyphs.iter().take_while(|g| g.is_space()).count(); + let mut after: Vec = glyphs.drain(..spaces).collect(); + let end = after.last().map_or(end, |g| g.byte.saturating_add(g.len).max(end)); + let body = glyphs.iter().find(|g| g.len > 0 && g.cap > 0.0); + let (lead, cap) = body.map_or((base_leading, base_cap), |g| (g.leading, g.cap)); + let lead = match grid.map(|inc| (lead / inc - 1e-6).ceil().max(1.0) * inc) { + Some(pitch) if pitch.is_finite() && pitch > 0.0 => pitch, + _ => lead, + }; + let drop = (lines - 1) as f64 * lead; + let own = dc.iter().map(|g| g.cap).fold(0.0, f64::max); + let k = if own > 0.0 { (drop + cap) / own } else { 1.0 }; + let k = if lines == 1 { k.min(1.0) } else { k }; + let k = if k.is_finite() && k > 0.0 { k.min(1000.0) } else { 1.0 }; + for g in dc.iter_mut().chain(after.iter_mut()) { + g.adv *= k; + g.dx *= k; + g.dy *= k; + g.sx *= k; + g.sy *= k; + g.ascent *= k; + g.typo_ascent *= k; + g.descent *= k; + g.cap *= k; + g.xh *= k; + g.size *= k; + g.space *= k; + } + let lsb = match dc.first() { + Some(g) if align_left => { + let ink = designcraft_geom::Shape::bounding_box(&*db.outline(g.face.get(), g.gid)); + let lsb = g.dx + ink.x0 * g.sx; + if ink.width() > 0.0 && lsb.is_finite() { lsb } else { 0.0 } + } + _ => 0.0, + }; + // Tabs advance to the paragraph's stops, measured from the column's start edge. Right to left, + // its glyphs run from that (right) edge in this order (see `place_drop_cap`). + let (stops, indent) = tabs; + let mut x = indent - lsb; + for i in 0..dc.len() { + let tab = match dc.get(i) { + Some(g) if g.ch == '\t' => Some(tab_advance(stops, indent, x, dc.get(i + 1..).unwrap_or_default()).0), + _ => None, + }; + if let (Some(w), Some(g)) = (tab, dc.get_mut(i)) { + g.adv = w; + } + x += dc.get(i).map_or(0.0, |g| g.adv); + } + let width = dc.iter().map(|g| g.adv).sum(); + dc.append(&mut after); + Some(DropCap { glyphs: dc, end, lines, width, drop, lsb }) +} + +impl DropCap { + /// How far the lines beside it are indented: to the end of its advance. + fn indent(&self) -> f64 { + self.width - self.lsb + } +} + +/// The drop cap's glyphs set from `at` (right to left: mirrored, its first character on the right). +fn place_drop_cap(dc: &DropCap, at: f64, rtl: bool) -> Vec { + let mut x = at; + let mut out = Vec::with_capacity(dc.glyphs.len()); + for g in &dc.glyphs { + let pen = if rtl { at + at + dc.width - x - g.adv } else { x }; + let mut p = place(g, pen); + p.y += dc.drop; + p.rtl = rtl; + out.push(p); + x += g.adv; + } + out +} + +/// Put generated `label` text before the paragraph's glyphs; returns how many glyphs it added. fn prepend_label( db: &ScopedFonts<'_>, glyphs: &mut Vec, @@ -2664,12 +3015,12 @@ fn prepend_label( base: &designcraft_doc::CharProps, env: shape::TypeEnv, table: &mut StyleTable<'_>, -) { +) -> usize { // Shape the label as a tiny standalone story so it uses the paragraph's base character style. let mut tmp = Story::new(StoryId(0)); tmp.insert(0, label); let styles = designcraft_doc::Styles::default(); - let shaped = shape::shape_para(db, &styles, &tmp, 0, 0..label.len(), base, env, &SubstCtx::default(), table, &[], &[], &[]); + let shaped = shape::shape_para(db, &styles, &tmp, 0, 0..label.len(), base, env, &SubstCtx::default(), table, &[], &[], &[], (0, "")); let mut pre: Vec = shaped .glyphs .into_iter() @@ -2679,8 +3030,10 @@ fn prepend_label( g }) .collect(); + let n = pre.len(); pre.append(glyphs); *glyphs = pre; + n } type LimitsKey = (usize, usize, usize, bool); @@ -2948,6 +3301,12 @@ pub fn highlight_quads(l: &Line, s: usize, e: usize, past_end: bool) -> Vec<[Poi /// A stacked warichu row reports that row's baseline and em, not the parent line's. pub fn caret(cs: &ComposedStory, pos: usize) -> Option<(usize, f64, f64, f64, f64)> { let (fi, l) = caret_line(cs, pos)?; + // In the drop cap the caret is as tall as it. + if let Some(dc) = l.drop_cap + && pos < dc.end + { + return Some((fi, caret_x(l, pos), dc.baseline, dc.baseline - dc.rect.y0, l.descent)); + } let x = caret_x(l, pos); let glyphs = line_glyphs(l); let metrics = caret_anchor(l, pos).filter(|g| on_stacked_row(&glyphs, g)).map(|g| { @@ -2995,6 +3354,8 @@ pub fn caret_x(l: &Line, pos: usize) -> f64 { } match l.glyphs.iter().rev().find(|g| g.len > 0) { Some(g) if g.rtl => g.x, + // After the spaces that end the line. + Some(g) if !g.visible && g.x >= l.end_x - 1e-9 => g.x + g.adv, _ => l.end_x, } } @@ -3028,19 +3389,57 @@ pub fn line_rtl(cs: &ComposedStory, pos: usize) -> bool { caret_line(cs, pos).and_then(|(_, l)| l.glyphs.iter().find(|g| g.len > 0)).is_some_and(|g| g.rtl) } +/// The line of frame `ft` closest to point `p` vertically (within its column). +fn hit_line(ft: &FrameText, p: Point) -> Option<&Line> { + ft.lines + .iter() + .filter(|l| p.x >= l.x0 - 20.0 && p.x <= l.x1 + 20.0 || ft.columns.len() <= 1) + .min_by(|a, b| line_dist(a, p.y).total_cmp(&line_dist(b, p.y))) + .or_else(|| ft.lines.first()) +} + +/// Story byte of the character drawn under point `p` (frame inner space) in frame `fi`: the +/// glyph whose advance spans `p.x` on the closest line, else the one nearest it. For selecting +/// the word (or the spaces) under a double click; [`hit`] finds a caret position instead. +pub fn hit_char(cs: &ComposedStory, fi: usize, p: Point) -> Option { + let ft = cs.frames.get(fi)?; + let Some(l) = hit_line(ft, p) else { return Some(ft.range.start) }; + let dist = |g: &&PlacedGlyph| { + let (a, b) = (g.x.min(g.x + g.adv), g.x.max(g.x + g.adv)); + if p.x < a { + a - p.x + } else if p.x >= b { + p.x - b + } else { + 0.0 + } + }; + // On a drop cap (it reaches down beside the lines after its own): its character under `p`. + for dl in &ft.lines { + let Some(dc) = dl.drop_cap.filter(|dc| dc.rect.contains(p)) else { continue }; + return dl.glyphs.iter().filter(|g| g.len > 0 && g.byte < dc.end).min_by(|a, b| dist(a).total_cmp(&dist(b))).map(|g| g.byte); + } + let under = l.glyphs.iter().filter(|g| g.len > 0).min_by(|a, b| dist(a).total_cmp(&dist(b))); + Some(under.map_or(l.range.start, |g| g.byte)) +} + /// Story byte nearest to point `p` (frame inner space) in frame `fi`. pub fn hit(cs: &ComposedStory, fi: usize, p: Point) -> Option { let ft = cs.frames.get(fi)?; if ft.lines.is_empty() { return Some(ft.range.start); } - // Closest line vertically (within its column). - let l = ft - .lines - .iter() - .filter(|l| p.x >= l.x0 - 20.0 && p.x <= l.x1 + 20.0 || ft.columns.len() <= 1) - .min_by(|a, b| line_dist(a, p.y).total_cmp(&line_dist(b, p.y))) - .or_else(|| ft.lines.first())?; + // On a drop cap (it reaches down beside the lines after its own): the nearest caret position in it. + for l in &ft.lines { + let Some(dc) = l.drop_cap.filter(|dc| dc.rect.contains(p)) else { continue }; + return l + .glyphs + .iter() + .filter(|g| g.len > 0 && g.byte < dc.end) + .flat_map(|g| [g.byte, g.byte + g.len]) + .min_by(|a, b| (caret_x(l, *a) - p.x).abs().total_cmp(&(caret_x(l, *b) - p.x).abs())); + } + let l = hit_line(ft, p)?; if l.glyphs.iter().any(|g| g.rtl) { // Bidi: the caret position drawn nearest the point. return caret_stops(l).into_iter().min_by(|a, b| (a.1 - p.x).abs().total_cmp(&(b.1 - p.x).abs())).map(|s| s.0); @@ -3275,3 +3674,47 @@ pub fn path_glyphs( } runs } + +#[cfg(test)] +mod column_allocation_safety_tests { + use super::*; + + fn frame(kind: designcraft_doc::ColumnsKind) -> FrameSpec { + FrameSpec { + id: ItemId(1), + area: Rect::new(0., 0., 300., 100.), + opts: TextFrameOptions { columns: 217, columns_kind: kind, column_width: 20., gutter: 12., ..Default::default() }, + vertical: false, + exclusions: vec![], + page_name: None, + page: None, + grid: None, + left_page: false, + page_rect: None, + } + } + fn check(kind: designcraft_doc::ColumnsKind) { + let mut f = frame(kind); + // Fail safely at a small boundary before exercising hostile native options. + assert_eq!(f.columns().len(), 216); + f.opts.columns = u32::MAX; + assert_eq!(f.columns().len(), 216); + f.opts.columns = 0; + assert_eq!(f.columns().len(), 1); + f.opts.columns = 2; + let expected = if kind == designcraft_doc::ColumnsKind::FixedWidth { + vec![Rect::new(0., 0., 20., 100.), Rect::new(32., 0., 52., 100.)] + } else { + vec![Rect::new(0., 0., 144., 100.), Rect::new(156., 0., 300., 100.)] + }; + assert_eq!(f.columns(), expected); + } + #[test] + fn column_allocation_caps_equal_width_frame_count() { + check(designcraft_doc::ColumnsKind::FixedNumber); + } + #[test] + fn column_allocation_caps_fixed_width_frame_count() { + check(designcraft_doc::ColumnsKind::FixedWidth); + } +} diff --git a/crates/compose/src/notes.rs b/crates/compose/src/notes.rs index 32b2bbe..a37c1aa 100644 --- a/crates/compose/src/notes.rs +++ b/crates/compose/src/notes.rs @@ -197,10 +197,15 @@ pub fn find_note(cs: &ComposedStory, id: u64) -> Option<(usize, &PlacedNote)> { /// Footnote text hit at frame-inner point `p`: (footnote id, byte in the footnote's story). pub fn hit_note(cs: &ComposedStory, fi: usize, p: Point) -> Option<(u64, usize)> { + hit_note_with(cs, fi, p, crate::hit) +} + +/// [`hit_note`] with the hit test for the note's text (see [`crate::hit_cell_with`]). +pub fn hit_note_with(cs: &ComposedStory, fi: usize, p: Point, hit: fn(&ComposedStory, usize, Point) -> Option) -> Option<(u64, usize)> { let ft = cs.frames.get(fi)?; let n = ft.notes.iter().find(|n| n.rect.contains(p))?; let local = Point::new(p.x - n.origin.x, p.y - n.origin.y); - Some((n.id, crate::hit(&n.text, 0, local).unwrap_or(0))) + Some((n.id, hit(&n.text, 0, local).unwrap_or(0))) } /// Caret geometry for byte `pos` of a footnote's story, in frame inner space: diff --git a/crates/compose/src/overlay.rs b/crates/compose/src/overlay.rs index d763a58..f34ed29 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 1e1af40..cfab721 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -28,9 +28,14 @@ pub struct Glyph { /// Baseline shift (positive = up), including super/subscript. pub shift: f64, pub ascent: f64, + /// The font's typographic ascender at the point size (vertical scale doesn't apply): the Ascent + /// first baseline offset. + pub typo_ascent: f64, pub descent: f64, /// The leading this character asks for (absolute, or auto = size × auto %). pub leading: f64, + /// Cap height and x height, scaled like `ascent`: the Cap Height and x Height first baseline + /// offsets. pub cap: f64, pub xh: f64, pub size: f64, @@ -64,6 +69,9 @@ pub struct Glyph { /// Upright in a vertical frame: shaped top to bottom (`adv` is its vertical advance) and hung /// from its vertical origin on the line's centre (see [`crate::PlacedGlyph::vertical_xf`]). pub upright: bool, + /// Part of the bullet or number the paragraph's list adds: not the line's own text, so it + /// doesn't raise the Ascent first baseline. + pub list_label: bool, } impl Glyph { @@ -185,7 +193,8 @@ impl StyleTable<'_> { } } -/// Resolve and shape one paragraph. +/// Resolve and shape one paragraph. Its drop cap ends at byte `drop.0` (`range.start` without +/// one) and takes the character style `drop.1` ("" = none). pub(crate) fn shape_para( db: &ScopedFonts<'_>, styles: &Styles, @@ -199,13 +208,37 @@ pub(crate) fn shape_para( nested: &[designcraft_doc::NestedStyle], grep: &[designcraft_doc::GrepStyle], lines: &[(std::ops::Range, 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(); + // The drop cap's style is the nested styles' leading "through 1 Dropcap" entry (the form IDML + // stores it in). Set (`[None]` included), it restyles that entry; a real one stands in for a + // missing entry. Either way the nested styles after it start after the drop cap. + let set = Some(drop.1).filter(|s| !s.is_empty()); + let cap_style = set.filter(|s| *s != designcraft_doc::NO_CHAR_STYLE); + let with_cap: Vec; + let nested = match set { + Some(s) if nested.first().is_some_and(designcraft_doc::NestedStyle::is_drop_cap) => { + with_cap = std::iter::once(designcraft_doc::NestedStyle::drop_cap(s)).chain(nested.iter().skip(1).cloned()).collect(); + &with_cap[..] + } + Some(s) if cap_style.is_some() && !nested.is_empty() => { + with_cap = std::iter::once(designcraft_doc::NestedStyle::drop_cap(s)).chain(nested.iter().cloned()).collect(); + &with_cap[..] + } + _ => nested, + }; if !nested.is_empty() || !grep.is_empty() { - overlays.extend(crate::overlay::overlays(&story.text, range.clone(), nested, grep)); + overlays.extend(crate::overlay::overlays(&story.text, range.clone(), nested, grep, drop.0)); + } + if let Some(s) = cap_style + && drop.0 > range.start + { + overlays.push((range.start..drop.0, s.to_string())); } // Each run, cut where nested / GREP styles start and end. let mut segments: Vec<(usize, usize, Option<&str>, &designcraft_doc::CharFormat)> = Vec::new(); @@ -273,7 +306,7 @@ pub(crate) fn shape_para( } let mut g = control_glyph(&face, &props, auto_leading, style, a + i, c); g.ch = HIDDEN; - (g.ascent, g.descent, g.leading, g.cap, g.xh) = (0.0, 0.0, 0.0, 0.0, 0.0); + (g.ascent, g.typo_ascent, g.descent, g.leading, g.cap, g.xh) = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0); glyphs.push(g); } continue; @@ -597,10 +630,18 @@ fn shape_run_raw( Some(y) => { g.adv = o.w; g.ascent = g.ascent.max(o.h + y); + g.typo_ascent = g.typo_ascent.max(o.h + y); + g.cap = g.cap.max(o.h + y); + g.xh = g.xh.max(o.h + y); g.descent = g.descent.max(-y); } // Pushes its line down by its height and spacing. - None => g.ascent += o.h + o.space, + None => { + g.ascent += o.h + o.space; + g.typo_ascent += o.h + o.space; + g.cap += o.h + o.space; + g.xh += o.h + o.space; + } } if auto || o.y_offset.is_none() { g.leading = g.leading.max(g.ascent + g.descent); @@ -771,6 +812,7 @@ fn control_glyph(face: &Arc, p: &CharProps, auto_leading: TypeEnv, sty sy: k * p.v_scale, shift, ascent, + typo_ascent: face.typo_ascent * k, descent, leading, cap, @@ -796,6 +838,7 @@ fn control_glyph(face: &Arc, p: &CharProps, auto_leading: TypeEnv, sty shaping_rtl: false, lang: designcraft_doc::language_tag(&p.language), upright: false, + list_label: false, } } @@ -889,6 +932,7 @@ fn shape_segment( sy: k * p.v_scale, shift, ascent, + typo_ascent: face.typo_ascent * k, descent, leading, cap, @@ -918,6 +962,7 @@ fn shape_segment( shaping_rtl: sg.rtl, lang, upright: up, + list_label: false, }); } } diff --git a/crates/compose/src/table.rs b/crates/compose/src/table.rs index c70f477..ed96756 100644 --- a/crates/compose/src/table.rs +++ b/crates/compose/src/table.rs @@ -442,6 +442,7 @@ fn emit( spacing: 1.0, hj: 0, keep_violation: false, + drop_cap: None, }); ft.tables.push(TableFrag { table: table.id, @@ -602,6 +603,17 @@ fn fragment_strokes( /// The cell under `p` (frame inner space) in frame `fi`: (table, row, col, story byte in the cell). pub fn hit_cell(cs: &ComposedStory, fi: usize, p: Point) -> Option<(u64, usize, usize, usize)> { + hit_cell_with(cs, fi, p, crate::hit) +} + +/// [`hit_cell`] with the hit test for the cell's text: [`crate::hit`] (caret position) or +/// [`crate::hit_char`] (character under the point). +pub fn hit_cell_with( + cs: &ComposedStory, + fi: usize, + p: Point, + hit: fn(&ComposedStory, usize, Point) -> Option, +) -> Option<(u64, usize, usize, usize)> { let ft = cs.frames.get(fi)?; for t in &ft.tables { if !t.rect.contains(p) { @@ -610,7 +622,7 @@ pub fn hit_cell(cs: &ComposedStory, fi: usize, p: Point) -> Option<(u64, usize, for c in &t.cells { if c.rect.contains(p) { let local = Point::new(p.x - c.origin.x, p.y - c.origin.y); - let b = crate::hit(&c.text, 0, local).unwrap_or(0); + let b = hit(&c.text, 0, local).unwrap_or(0); return Some((t.table, c.row, c.col, b)); } } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 7a0e790..ce8964b 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. @@ -374,7 +609,11 @@ fn keep_lines_together_moves_the_paragraph() { let (mut d, sid, _) = keep_doc(n - 3, &[&long], h); let cs = compose_story(&d, sid, &ComposeOptions::default()); assert!(column_of_para(&cs, n - 3).contains(&0)); - d.story_mut(sid).unwrap().paras[n - 3].para.keep_lines_together = Some(true); + { + let p = &mut d.story_mut(sid).unwrap().paras[n - 3].para; + p.keep_lines_together = Some(true); + p.keep_all_lines = Some(true); + } let cs = compose_story(&d, sid, &ComposeOptions::default()); assert!(column_of_para(&cs, n - 3).iter().all(|&c| c == 1)); } @@ -1474,6 +1713,849 @@ fn only_english_text_gets_english_hyphenation() { assert_eq!(hyphenated(&d2, sid), 0, "French isn't hyphenated with English rules"); } +/// OS/2 field offsets: sTypoAscender, sTypoDescender, the high half of ulCodePageRange1, sxHeight, +/// sCapHeight. +const TYPO_ASCENDER: usize = 68; +const TYPO_DESCENDER: usize = 70; +const CODE_PAGES_HIGH: usize = 78; +const X_HEIGHT: usize = 86; +const CAP_HEIGHT: usize = 88; + +/// Source Sans 3 Regular (1000 units per em; OS/2 typo ascender = hhea ascender = 1000, cap height +/// 660, x height 486) renamed to `family` (13 characters, the length of "Source Sans 3"), with the +/// OS/2 fields at the given offsets set, or without an OS/2 table for `None`. +fn test_font(family: &str, os2_fields: Option<&[(usize, i16)]>) -> Vec { + 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 os2_fields { + Some(fields) => { + let os2 = u32::from_be_bytes(b[rec + 8..rec + 12].try_into().unwrap()) as usize; + for &(at, v) in fields { + b[os2 + at..os2 + at + 2].copy_from_slice(&v.to_be_bytes()); + } + } + // Still sorted between its neighbours, so the table directory stays valid. + None => b[rec..rec + 4].copy_from_slice(b"OS/1"), + } + b +} + +/// [`test_font`] with OS/2 `sTypoAscender` set to `typo_ascender`, or without OS/2 for `None`. +fn typo_test_font(family: &str, typo_ascender: Option) -> 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 { + first_baseline_in_dir(family, false, opts) +} + +/// [`first_baseline_in`] in a vertical story when `vertical`. +fn first_baseline_in_dir(family: &str, vertical: bool, opts: impl Fn(&mut designcraft_doc::TextFrameOptions)) -> f64 { + let (mut d, sid, fid) = doc_with("Hxg Hxg", Rect::new(36.0, 36.0, 300.0, 300.0), ParaAttrs::default()); + d.story_mut(sid).unwrap().format_chars(0..7, |f| { + f.over.font_family = Some(family.into()); + f.over.font_style = Some("Regular".into()); + f.over.size = Some(20.0); + }); + d.story_mut(sid).unwrap().vertical = vertical; + let tf = d.item_mut(fid).unwrap().text_frame_mut().unwrap(); + tf.options.inset = [4.0, 0.0, 0.0, 0.0]; + opts(&mut tf.options); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + cs.frames[0].lines[0].baseline +} + +#[test] +fn ascent_first_baseline_uses_the_typographic_ascender() { + use designcraft_doc::FirstBaseline; + let db = designcraft_fonts::FontDb::global(); + db.add_font(typo_test_font("TypoAscent 07", Some(700))); + let face = db.face("TypoAscent 07", "Regular"); + assert_eq!(face.family, "TypoAscent 07"); + assert_eq!((face.ascent, face.typo_ascent), (1000.0, 700.0), "hhea 1 em, typo 0.7 em"); + let top = 36.0 + 4.0; + let at = |kind: FirstBaseline, min: f64| { + first_baseline_in("TypoAscent 07", |o| { + o.first_baseline = kind; + o.first_baseline_min = min; + }) + }; + let close = |a: f64, b: f64| (a - b).abs() < 0.01; + // Ascent: top + typo ascender (0.7 em of 20 pt), not the hhea ascender (1 em). + assert!(close(at(FirstBaseline::Ascent, 0.0), top + 14.0), "{}", at(FirstBaseline::Ascent, 0.0)); + // The minimum offset still wins when it is larger. + assert!(close(at(FirstBaseline::Ascent, 18.0), top + 18.0)); + assert!(close(at(FirstBaseline::Ascent, 10.0), top + 14.0)); + // The other kinds don't use it. + assert!(close(at(FirstBaseline::CapHeight, 0.0), top + 0.660 * 20.0)); + assert!(close(at(FirstBaseline::XHeight, 0.0), top + 0.486 * 20.0)); + assert!(close(at(FirstBaseline::Leading, 0.0), top + 24.0)); + assert!(close(at(FirstBaseline::Fixed, 0.0), top)); + // A mixed first line takes its tallest typographic ascender: 0.7 × 20 pt beats 1 em × 10 pt … + let (mut d, sid, fid) = doc_with("Hxg Hxg", Rect::new(36.0, 36.0, 300.0, 300.0), ParaAttrs::default()); + let st = d.story_mut(sid).unwrap(); + st.format_chars(0..3, |f| { + f.over.font_family = Some("TypoAscent 07".into()); + f.over.size = Some(20.0); + }); + st.format_chars(3..7, |f| { + f.over.font_family = Some("Source Sans 3".into()); + f.over.size = Some(10.0); + }); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.inset = [4.0, 0.0, 0.0, 0.0]; + let first = |d: &Document| compose_story(d, sid, &ComposeOptions::default()).frames[0].lines[0].baseline; + assert!(close(first(&d), top + 14.0), "{}", first(&d)); + // … and 1 em × 16 pt beats it. + d.story_mut(sid).unwrap().format_chars(3..7, |f| f.over.size = Some(16.0)); + assert!(close(first(&d), top + 16.0), "{}", first(&d)); +} + +#[test] +fn em_box_first_baseline_follows_base_then_the_typo_box_or_the_cap_height() { + use designcraft_doc::FirstBaseline; + let db = designcraft_fonts::FontDb::global(); + // [`test_font`] (typo descender -326, cap height 660), with or without its `BASE` table, whose + // em box bottom (`ideo`) is at -170 units. + let add = |family: &str, base: bool, fields: &[(usize, i16)]| { + let mut b = test_font(family, Some(fields)); + if !base { + let tables = u16::from_be_bytes([b[4], b[5]]) as usize; + let rec = (0..tables).map(|t| 12 + 16 * t).find(|&r| &b[r..r + 4] == b"BASE").unwrap(); + // Still sorted between its neighbours, so the table directory stays valid. + b[rec..rec + 4].copy_from_slice(b"BASX"); + } + db.add_font(b); + db.face(family, "Regular").declared_em_box() + }; + // Typo ascender − descender = 1 em in a CJK font (JIS code page, bit 17): the typo ascender is + // the top. + let typo = [(TYPO_ASCENDER, 850), (TYPO_DESCENDER, -150)]; + assert_eq!(add("EmBoxTypo 850", false, &[typo[0], typo[1], (CODE_PAGES_HIGH, 1 << 1)]), Some((850.0, -150.0))); + // In a Latin font the cap height decides: 0.5 em + 660 / 2. + assert_eq!(add("EmBoxLatn 850", false, &typo), Some((830.0, -170.0))); + // Otherwise the em box is centred on half the cap height: 0.5 em + 600 / 2. + assert_eq!(add("EmBoxCapH 600", false, &[(TYPO_ASCENDER, 700), (CAP_HEIGHT, 600)]), Some((800.0, -200.0))); + // No OS/2 cap height: the H glyph's top, 656. + assert_eq!(add("EmBoxGlyphH 0", false, &[(TYPO_ASCENDER, 700), (CAP_HEIGHT, 0)]), Some((828.0, -172.0))); + // A `BASE` em box comes first. + assert_eq!(add("EmBoxBase 850", true, &[(TYPO_ASCENDER, 850), (TYPO_DESCENDER, -150)]), Some((830.0, -170.0))); + let (mut d, sid, fid) = doc_with("Hxg Hxg", Rect::new(36.0, 36.0, 300.0, 300.0), ParaAttrs::default()); + let mut first = |family: &str, sizes: [f64; 2], kind: FirstBaseline| { + let st = d.story_mut(sid).unwrap(); + for (range, size) in [(0..4, sizes[0]), (4..7, sizes[1])] { + st.format_chars(range, |f| { + f.over.font_family = Some(family.into()); + f.over.font_style = Some("Regular".into()); + f.over.size = Some(size); + }); + } + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.first_baseline = kind; + compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0].baseline - 36.0 + }; + let close = |a: f64, b: f64| (a - b).abs() < 0.01; + let asc = first("EmBoxCapH 600", [10.0; 2], FirstBaseline::Ascent); + assert!(close(asc, 7.0), "{asc}"); + for (family, top) in [("EmBoxTypo 850", 8.5), ("EmBoxLatn 850", 8.3), ("EmBoxCapH 600", 8.0), ("EmBoxGlyphH 0", 8.28), ("EmBoxBase 850", 8.3)] { + let emb = first(family, [10.0; 2], FirstBaseline::EmboxHeight); + assert!(close(emb, top), "{family}: {emb}"); + } + // A mixed first line takes its largest size. + let emb = first("EmBoxCapH 600", [10.0, 20.0], FirstBaseline::EmboxHeight); + assert!(close(emb, 16.0), "{emb}"); +} + +#[test] +fn em_box_first_baseline_in_a_latin_font_with_a_one_em_typo_box_follows_the_cap_height() { + use designcraft_doc::FirstBaseline; + // DejaVu Serif: 2048 units per em, typo ascender 1556 and descender -492 (one em), cap height + // 1493. Read from the system fonts; the test skips where it isn't installed. + let Some(bytes) = designcraft_fonts::system_font_dirs().iter().find_map(|dir| { + ["truetype/dejavu/DejaVuSerif.ttf", "dejavu/DejaVuSerif.ttf", "DejaVuSerif.ttf"].iter().find_map(|p| std::fs::read(dir.join(p)).ok()) + }) else { + eprintln!("skipped: DejaVu Serif isn't installed"); + return; + }; + let db = designcraft_fonts::FontDb::global(); + db.add_font(bytes); + let face = db.face("DejaVu Serif", "Book"); + assert_eq!(face.family, "DejaVu Serif"); + let (mut d, sid, fid) = doc_with("Hxg Hxg", Rect::new(36.0, 36.0, 300.0, 300.0), ParaAttrs::default()); + let st = d.story_mut(sid).unwrap(); + let n = st.len(); + st.format_chars(0..n, |f| { + f.over.font_family = Some("DejaVu Serif".into()); + f.over.font_style = Some("Book".into()); + f.over.size = Some(10.0); + }); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.first_baseline = FirstBaseline::EmboxHeight; + let first = compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0].baseline - 36.0; + // 0.5 em + cap height / 2, not the 1556-unit typo ascender (7.60 pt). + let expected = 5.0 + 1493.0 / 2048.0 * 5.0; + assert!((first - expected).abs() < 0.01, "{first}, expected {expected}"); +} + +#[test] +fn em_box_first_baseline_ignores_baseline_shift() { + use designcraft_doc::FirstBaseline; + let db = designcraft_fonts::FontDb::global(); + // Source Sans 3's `BASE` em box tops 0.83 em above the baseline. + db.add_font(test_font("EmBoxShft 830", Some(&[]))); + let (mut d, sid, fid) = doc_with("Hxg Hxg", Rect::new(36.0, 36.0, 300.0, 300.0), ParaAttrs::default()); + let st = d.story_mut(sid).unwrap(); + st.format_chars(0..7, |f| { + f.over.font_family = Some("EmBoxShft 830".into()); + f.over.font_style = Some("Regular".into()); + f.over.size = Some(10.0); + }); + st.format_chars(4..7, |f| f.over.baseline_shift = Some(1.0)); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.first_baseline = FirstBaseline::EmboxHeight; + let frames = compose_story(&d, sid, &ComposeOptions::default()).frames; + let line = &frames[0].lines[0]; + // The unshifted run's baseline stays 8.3 pt below the frame top; the shifted run is drawn 1 pt + // higher. + assert!((line.baseline - 36.0 - 8.3).abs() < 0.01, "{}", line.baseline); + let ys: Vec = line.glyphs.iter().map(|g| g.y).collect(); + assert_eq!(ys.len(), 7, "{ys:?}"); + assert!(ys[..3].iter().all(|y| y.abs() < 0.01), "{ys:?}"); + assert!(ys[4..].iter().all(|y| (y + 1.0).abs() < 0.01), "{ys:?}"); +} + +#[test] +fn ascent_first_baseline_falls_back_to_the_ascent_without_a_usable_typo_ascender() { + let db = designcraft_fonts::FontDb::global(); + // No OS/2 table, a negative and an absurd typo ascender: the (hhea) ascent, 1 em. + for (family, typo) in [("TypoAscentNoO", None), ("TypoAscentNeg", Some(-300)), ("TypoAscentBig", Some(i16::MAX))] { + db.add_font(typo_test_font(family, typo)); + let face = db.face(family, "Regular"); + assert_eq!(face.family, family); + assert_eq!(face.typo_ascent, face.ascent, "{family}"); + let b = first_baseline_in(family, |_| {}); + assert!((b - (36.0 + 4.0 + 20.0)).abs() < 0.01, "{family}: {b}"); + } +} + +#[test] +fn vertical_frames_keep_the_ascent_first_baseline() { + let db = designcraft_fonts::FontDb::global(); + db.add_font(typo_test_font("TypoAscentVrt", Some(700))); + let b = first_baseline_in_dir("TypoAscentVrt", true, |o| { + o.inset = [0.0; 4]; + }); + // Composed in the turned box (no inset): the hhea ascent, 1 em of 20 pt. + assert!((b - 20.0).abs() < 0.01, "{b}"); +} + +#[test] +fn a_visible_frame_stroke_insets_the_text() { + use designcraft_doc::{Stroke, StrokeAlign}; + let line = |stroke: Option<(StrokeAlign, &str)>| { + let (mut d, sid, fid) = doc_with("Hxg", Rect::new(36.0, 36.0, 300.0, 300.0), ParaAttrs::default()); + let it = d.item_mut(fid).unwrap(); + if let Some((align, swatch)) = stroke { + it.stroke = Stroke { weight: 4.0, align, swatch: swatch.into(), ..Stroke::default() }; + } + it.text_frame_mut().unwrap().options.inset = [4.0; 4]; + let l = &compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0]; + (l.baseline, l.x0, l.x1) + }; + let black = designcraft_color::swatch::BLACK; + let (b, x0, x1) = line(None); + // The part of the 4 pt stroke inside the frame: half of it centred, all of it inside, none + // outside or without a colour; on the left and right as on the top. + for (stroke, d) in [ + ((StrokeAlign::Center, black), 2.0), + ((StrokeAlign::Inside, black), 4.0), + ((StrokeAlign::Outside, black), 0.0), + ((StrokeAlign::Inside, designcraft_color::swatch::NONE), 0.0), + ] { + let (sb, sx0, sx1) = line(Some(stroke)); + assert!((sb - (b + d)).abs() < 1e-6, "{stroke:?}: baseline {sb} vs {b}"); + assert!((sx0 - (x0 + d)).abs() < 1e-6 && (sx1 - (x1 - d)).abs() < 1e-6, "{stroke:?}: {sx0}..{sx1} vs {x0}..{x1}"); + } +} + +#[test] +fn list_labels_do_not_raise_the_ascent_first_baseline() { + use designcraft_doc::{CharAttrs, ListType}; + // The paragraph's own character size (the label's) is 40 pt; its text is 10 pt (Source Sans 3: + // typo ascender 1 em). + let line = |text: &str, list: ListType| { + let (mut d, sid, _) = doc_with(text, Rect::new(36.0, 36.0, 300.0, 300.0), ParaAttrs { list_type: Some(list), ..Default::default() }); + let st = d.story_mut(sid).unwrap(); + for p in &mut st.paras { + p.chars = CharAttrs { font_family: Some("Source Sans 3".into()), size: Some(40.0), ..Default::default() }; + } + st.format_chars(0..text.len(), |f| f.over.size = Some(10.0)); + let l = compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0].clone(); + (l.baseline, l.ascent, l.leading) + }; + let (b, asc, lead) = line("Hxg Hxg", ListType::None); + // 1 em of the 10 pt text below the frame top. + assert!((b - 46.0).abs() < 0.01, "{b}"); + for list in [ListType::Bullets, ListType::Numbers] { + let (lb, lasc, llead) = line("Hxg Hxg", list); + assert!((lb - b).abs() < 1e-6, "{list:?}: {lb} vs {b}"); + // The 40 pt label still sets the line's ascent and leading. + assert!(lasc > asc + 20.0 && (llead - 48.0).abs() < 1e-6 && (lead - 12.0).abs() < 1e-6, "{list:?}: {lasc} {asc} {llead} {lead}"); + } + // An empty bulleted paragraph sits like an empty paragraph. + let (eb, _, _) = line("", ListType::None); + let (ebl, _, _) = line("", ListType::Bullets); + assert!((ebl - eb).abs() < 1e-6, "{ebl} vs {eb}"); +} + +#[test] +fn spaces_count_toward_the_ascent_first_baseline() { + // 10 pt text with a 40 pt space between its words (Source Sans 3: typo ascender 1 em). + let (mut d, sid, _) = doc_with("Hxg Hxg", Rect::new(36.0, 36.0, 300.0, 300.0), ParaAttrs::default()); + let st = d.story_mut(sid).unwrap(); + st.format_chars(0..7, |f| { + f.over.font_family = Some("Source Sans 3".into()); + f.over.size = Some(10.0); + }); + st.format_chars(3..4, |f| f.over.size = Some(40.0)); + let l = &compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0]; + assert!((l.baseline - (36.0 + 40.0)).abs() < 0.01, "{}", l.baseline); +} + +#[test] +fn vertical_scale_does_not_move_the_ascent_first_baseline() { + let db = designcraft_fonts::FontDb::global(); + db.add_font(typo_test_font("TypoAscentVSc", Some(700))); + let line = |v_scale: f64| { + let (mut d, sid, fid) = doc_with("Hxg", Rect::new(36.0, 36.0, 300.0, 300.0), ParaAttrs::default()); + d.story_mut(sid).unwrap().format_chars(0..3, |f| { + f.over.font_family = Some("TypoAscentVSc".into()); + f.over.size = Some(20.0); + f.over.v_scale = Some(v_scale); + }); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.inset = [4.0, 0.0, 0.0, 0.0]; + let l = &compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0]; + (l.baseline, l.ascent) + }; + let ((b100, a100), (b200, a200)) = (line(1.0), line(2.0)); + // The typo ascender of the nominal size (0.7 × 20 pt) at 100 % and at 200 %. + assert!((b100 - (36.0 + 4.0 + 14.0)).abs() < 0.01, "{b100}"); + assert!((b200 - b100).abs() < 0.01, "{b200} vs {b100}"); + // The line box keeps the scaled ascent. + assert!((a100 - 20.0).abs() < 0.01 && (a200 - 40.0).abs() < 0.01, "{a100} {a200}"); +} + +#[test] +fn cap_height_and_x_height_first_baselines_use_the_fonts_cap_and_x_heights() { + use designcraft_doc::FirstBaseline; + let db = designcraft_fonts::FontDb::global(); + db.add_font(test_font("CapXHeight 01", Some(&[(CAP_HEIGHT, 500), (X_HEIGHT, 300)]))); + let face = db.face("CapXHeight 01", "Regular"); + assert_eq!(face.family, "CapXHeight 01"); + assert_eq!((face.ascent, face.cap_height, face.x_height), (1000.0, 500.0, 300.0)); + let top = 36.0 + 4.0; + let at = |kind: FirstBaseline, min: f64| { + first_baseline_in("CapXHeight 01", |o| { + o.first_baseline = kind; + o.first_baseline_min = min; + }) + }; + let close = |a: f64, b: f64| (a - b).abs() < 0.01; + // Cap height 0.5 em and x height 0.3 em of 20 pt, not 0.72 / 0.5 of the 1 em ascent. + assert!(close(at(FirstBaseline::CapHeight, 0.0), top + 10.0), "{}", at(FirstBaseline::CapHeight, 0.0)); + assert!(close(at(FirstBaseline::XHeight, 0.0), top + 6.0), "{}", at(FirstBaseline::XHeight, 0.0)); + // The minimum offset still wins when it is larger. + assert!(close(at(FirstBaseline::CapHeight, 12.0), top + 12.0)); + assert!(close(at(FirstBaseline::XHeight, 5.0), top + 6.0)); + // Vertical scale and a raised baseline count, as for the ascent. + let (mut d, sid, fid) = doc_with("Hxg Hxg", Rect::new(36.0, 36.0, 300.0, 300.0), ParaAttrs::default()); + let st = d.story_mut(sid).unwrap(); + st.format_chars(0..7, |f| { + f.over.font_family = Some("CapXHeight 01".into()); + f.over.size = Some(20.0); + f.over.v_scale = Some(1.5); + }); + st.format_chars(4..5, |f| f.over.baseline_shift = Some(1.0)); + let tf = d.item_mut(fid).unwrap().text_frame_mut().unwrap(); + tf.options.inset = [4.0, 0.0, 0.0, 0.0]; + tf.options.first_baseline = FirstBaseline::CapHeight; + let first = |d: &Document| compose_story(d, sid, &ComposeOptions::default()).frames[0].lines[0].baseline; + assert!(close(first(&d), top + 15.0 + 1.0), "{}", first(&d)); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.first_baseline = FirstBaseline::XHeight; + assert!(close(first(&d), top + 9.0 + 1.0), "{}", first(&d)); +} + +#[test] +fn cap_height_and_x_height_fall_back_without_usable_os2_values() { + use designcraft_doc::FirstBaseline; + let db = designcraft_fonts::FontDb::global(); + // No OS/2 table, zero / negative and absurd values: the top of the H (0.656 em) and of the x + // (0.486 em). + let zero: &[(usize, i16)] = &[(CAP_HEIGHT, 0), (X_HEIGHT, -40)]; + let big: &[(usize, i16)] = &[(CAP_HEIGHT, i16::MAX), (X_HEIGHT, i16::MAX)]; + for (family, fields) in [("CapXHeightNoO", None), ("CapXHeightNeg", Some(zero)), ("CapXHeightBig", Some(big))] { + db.add_font(test_font(family, fields)); + let face = db.face(family, "Regular"); + assert_eq!(face.family, family); + assert_eq!((face.cap_height, face.x_height), (656.0, 486.0), "{family}"); + let cap = first_baseline_in(family, |o| o.first_baseline = FirstBaseline::CapHeight); + let x = first_baseline_in(family, |o| o.first_baseline = FirstBaseline::XHeight); + assert!((cap - (40.0 + 13.12)).abs() < 0.01 && (x - (40.0 + 9.72)).abs() < 0.01, "{family}: {cap} {x}"); + } +} + +#[test] +fn vertical_frames_keep_the_cap_height_and_x_height_first_baselines() { + use designcraft_doc::FirstBaseline; + let db = designcraft_fonts::FontDb::global(); + db.add_font(test_font("CapXHeightVrt", Some(&[(CAP_HEIGHT, 500), (X_HEIGHT, 300)]))); + let at = |kind: FirstBaseline| { + first_baseline_in_dir("CapXHeightVrt", true, |o| { + o.first_baseline = kind; + o.inset = [0.0; 4]; + }) + }; + // 0.72 and 0.5 of the 1 em ascent of 20 pt. + assert!((at(FirstBaseline::CapHeight) - 14.4).abs() < 0.01, "{}", at(FirstBaseline::CapHeight)); + assert!((at(FirstBaseline::XHeight) - 10.0).abs() < 0.01, "{}", at(FirstBaseline::XHeight)); +} + +// ---------- drop caps ---------- + +/// `text` set 10 pt on 12 pt leading in a 300 pt measure, with `para` on top. +fn drop_doc(text: &str, para: ParaAttrs) -> (Document, StoryId, ItemId) { + drop_doc_in(text, Rect::new(0.0, 0.0, 300.0, 1000.0), para) +} + +fn drop_doc_in(text: &str, rect: Rect, para: ParaAttrs) -> (Document, StoryId, ItemId) { + let (mut d, sid, fid) = doc_with(text, rect, para); + for p in &mut d.story_mut(sid).unwrap().paras { + p.chars = designcraft_doc::CharAttrs { size: Some(10.0), leading: Some(designcraft_doc::Leading::Points(12.0)), ..Default::default() }; + } + (d, sid, fid) +} + +fn drop_cap(lines: u32, chars: u32) -> ParaAttrs { + ParaAttrs { drop_cap_lines: Some(lines), drop_cap_chars: Some(chars), ..Default::default() } +} + +/// Cap height of a placed glyph, in points. +fn cap_of(g: &PlacedGlyph) -> f64 { + g.face.cap_height * g.sy +} + +/// x of the first glyph of `l` set from story byte `from` on. +fn text_x(l: &Line, from: usize) -> f64 { + l.glyphs.iter().find(|g| g.len > 0 && g.byte >= from).map(|g| g.x).unwrap() +} + +#[test] +fn drop_cap_spans_lines_and_indents_them() { + let text = [LOREM; 2].join(" "); + let (plain, psid, _) = drop_doc(&text, ParaAttrs::default()); + let plain = compose_story(&plain, psid, &ComposeOptions::default()); + let (d, sid, _) = drop_doc(&text, drop_cap(3, 1)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + let pl = all_lines(&plain); + assert!(lines.len() > 4); + // Baselines don't move. + for (k, l) in lines.iter().enumerate() { + assert!((l.baseline - (pl[0].baseline + 12.0 * k as f64)).abs() < 1e-6, "line {k} at {}", l.baseline); + } + // The drop cap: the first glyph, on line 1, its baseline on line 3's and its cap top on line 1's. + let dc = &lines[0].glyphs[0]; + assert_eq!((dc.byte, dc.len), (0, 1)); + assert!((lines[0].baseline + dc.y - lines[2].baseline).abs() < 1e-6, "drop cap baseline {}", lines[0].baseline + dc.y); + let body = lines[0].glyphs.iter().find(|g| g.byte == 1).unwrap(); + let top = lines[2].baseline - cap_of(dc); + assert!((top - (lines[0].baseline - cap_of(body))).abs() < 0.01, "cap top {top}"); + assert!(dc.sy > body.sy * 3.0); + // Lines 1–3 start after it, line 4 at the frame edge. + let right = dc.x + dc.adv; + assert!(right > 20.0); + for l in &lines[..3] { + assert!(text_x(l, 1) >= right - 1e-6, "{} < {right}", text_x(l, 1)); + assert!(l.end_x <= l.x1 + 0.5); + } + assert!(text_x(lines[3], 1) < 0.5); + // The text stays in order: the drop cap starts line 1 (exports and caret read it there). + assert_eq!(lines[0].range.start, 0); +} + +#[test] +fn drop_cap_of_two_characters_as_a_local_override() { + let (d, sid, _) = drop_doc(LOREM, drop_cap(2, 2)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + let (a, b) = (&lines[0].glyphs[0], &lines[0].glyphs[1]); + assert_eq!((a.byte, b.byte), (0, 1)); + let body = lines[0].glyphs.iter().find(|g| g.byte == 2).unwrap(); + for g in [a, b] { + assert!((lines[0].baseline + g.y - lines[1].baseline).abs() < 1e-6); + assert!(g.sy > body.sy * 1.5); + } + assert!((b.x - (a.x + a.adv)).abs() < 1e-6, "the drop cap characters sit side by side"); + let right = b.x + b.adv; + assert!(text_x(lines[0], 2) >= right - 1e-6 && text_x(lines[1], 2) >= right - 1e-6); + assert!(text_x(lines[2], 2) < 0.5); +} + +#[test] +fn short_paragraph_keeps_its_drop_cap_and_the_next_paragraph_is_not_indented() { + let (mut d, sid, _) = drop_doc(&format!("Short.\n{LOREM}"), drop_cap(3, 1)); + d.story_mut(sid).unwrap().paras[1].para = ParaAttrs::default(); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + assert_eq!(lines.iter().filter(|l| l.para == 0).count(), 1); + let dc = &lines[0].glyphs[0]; + // Still three lines tall (it may overlap the next paragraph, which isn't indented). + assert!((dc.y - 24.0).abs() < 1e-6, "{}", dc.y); + assert!(text_x(lines[1], 7) < 0.5); +} + +#[test] +fn drop_cap_counts_code_points_without_splitting_clusters_and_clamps_to_the_paragraph() { + // Four characters: a letter, its combining mark, another letter and a no-break space. + let (d, sid, _) = drop_doc("A\u{301}B\u{a0}rest of the words", drop_cap(2, 4)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let l = &cs.frames[0].lines[0]; + let big: Vec = l.glyphs.iter().filter(|g| g.len > 0 && g.y > 1.0).map(|g| g.byte).collect(); + assert!(big.contains(&0) && big.contains(&3), "{big:?}"); + assert!(big.iter().all(|b| *b < 6), "the r isn't part of it: {big:?}"); + // One character that has a combining mark after it takes the mark too. + let (d, sid, _) = drop_doc("e\u{301}tude and more words", drop_cap(2, 1)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let l = &cs.frames[0].lines[0]; + let big: Vec = 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, _) = drop_doc(LOREM, drop_cap(3, 1)); + d.story_mut(sid).unwrap().vertical = true; + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(cs.frames[0].lines[0].glyphs[0].y.abs() < 1e-6); +} + +#[test] +fn drop_cap_takes_its_character_style_and_nested_styles_count_it() { + let (mut d, sid, _) = drop_doc(LOREM, ParaAttrs { drop_cap_style: Some("Initial".into()), ..drop_cap(2, 3) }); + for (name, fill) in [("Initial", "Initial Red"), ("Lead", "Lead Blue")] { + std::sync::Arc::make_mut(&mut d.styles).character.push(designcraft_doc::CharacterStyle { + name: name.into(), + based_on: None, + chars: designcraft_doc::CharAttrs { fill: Some(fill.into()), ..Default::default() }, + shortcut: String::new(), + }); + } + let fill = |cs: &ComposedStory, g: &PlacedGlyph| cs.styles[g.style as usize].fill.clone(); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let l = &cs.frames[0].lines[0]; + let at = |b: usize| l.glyphs.iter().find(|g| g.byte == b).unwrap(); + assert_eq!(fill(&cs, at(0)), "Initial Red"); + assert_eq!(fill(&cs, at(2)), "Initial Red"); + assert_eq!(fill(&cs, at(3)), "[Black]"); + // The drop cap's style is a leading "through 1 drop cap" nested style: the nested styles start + // after the drop cap. + let one = designcraft_doc::NestedStyle { style: "Lead".into(), through: true, count: 1, until: designcraft_doc::NestedUntil::Characters }; + d.story_mut(sid).unwrap().paras[0].para.nested_styles = Some(vec![one]); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let l = &cs.frames[0].lines[0]; + let at = |b: usize| l.glyphs.iter().find(|g| g.byte == b).unwrap(); + assert_eq!(fill(&cs, at(0)), "Initial Red"); + assert_eq!((fill(&cs, at(3)), fill(&cs, at(4))), ("Lead Blue".to_string(), "[Black]".to_string())); + // A nested style "through 1 drop cap" covers the drop cap; the next one starts after it. + let ns = |style: &str, until: designcraft_doc::NestedUntil| designcraft_doc::NestedStyle { style: style.into(), through: true, count: 1, until }; + d.story_mut(sid).unwrap().paras[0].para = ParaAttrs { + nested_styles: Some(vec![ns("Lead", designcraft_doc::NestedUntil::Dropcap), ns("Initial", designcraft_doc::NestedUntil::Words)]), + ..drop_cap(2, 3) + }; + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let l = &cs.frames[0].lines[0]; + let at = |b: usize| l.glyphs.iter().find(|g| g.byte == b).unwrap(); + assert_eq!(fill(&cs, at(0)), "Lead Blue"); + assert_eq!(fill(&cs, at(2)), "Lead Blue"); + assert_eq!(fill(&cs, at(3)), "Initial Red", "the second nested style starts after the drop cap"); + let first_space = LOREM.find(' ').unwrap(); + assert_eq!(fill(&cs, at(first_space + 1)), "[Black]"); +} + +#[test] +fn drop_cap_caret_and_hit_testing() { + let (d, sid, _) = drop_doc(LOREM, drop_cap(3, 1)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + let dc = lines[0].glyphs[0].clone(); + let drop_box = lines[0].drop_cap.unwrap().rect; + assert!(drop_box.y1 > lines[2].baseline - 0.01 && drop_box.y0 < lines[0].baseline - 5.0, "{drop_box:?}"); + // A click on the lower half of the big letter (beside line 3) is at the drop cap. + let y = lines[2].baseline - 2.0; + assert_eq!(hit(&cs, 0, Point::new(dc.x + dc.adv * 0.25, y)), Some(0)); + assert_eq!(hit(&cs, 0, Point::new(dc.x + dc.adv * 0.75, y)), Some(1)); + // A double click there is on the drop cap's character, not on line 3's text. + assert_eq!(hit_char(&cs, 0, Point::new(dc.x + dc.adv * 0.5, y)), Some(0)); + // The caret before the drop cap is as tall as it; after it, beside line 1's text. + let (_, x, bl, asc, _) = caret(&cs, 0).unwrap(); + assert!((x - dc.x).abs() < 1e-6 && (bl - lines[2].baseline).abs() < 1e-6 && asc > 24.0, "{x} {bl} {asc}"); + let (_, x, bl, _, _) = caret(&cs, 1).unwrap(); + assert!(x >= dc.x + dc.adv - 1e-6 && (bl - lines[0].baseline).abs() < 1e-6); +} + +#[test] +fn drop_cap_on_a_baseline_grid_reaches_down_by_grid_steps() { + // Lines on a 15 pt baseline grid (not the 12 pt leading): the drop cap reaches down two grid + // steps, to line 3. + let (mut d, sid, _) = drop_doc(LOREM, ParaAttrs { grid_align: Some(designcraft_doc::GridAlign::AllLines), ..drop_cap(3, 1) }); + d.settings.baseline_grid.increment = 15.0; + d.settings.baseline_grid.start = 0.0; + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + assert!((lines[1].baseline - lines[0].baseline - 15.0).abs() < 1e-6); + let dc = &lines[0].glyphs[0]; + assert!((lines[0].baseline + dc.y - lines[2].baseline).abs() < 1e-6); + let b = lines[0].drop_cap.unwrap(); + assert!((b.baseline - lines[2].baseline).abs() < 1e-6 && b.rect.y1 > lines[2].baseline); + // Scaled to the grid's line pitch: its cap top is still line 1's. + let body = lines[0].glyphs.iter().find(|g| g.byte == 1).unwrap(); + assert!((lines[2].baseline - cap_of(dc) - (lines[0].baseline - cap_of(body))).abs() < 0.01); +} + +/// A line with a larger auto leading doesn't move the drop cap: it reaches down `lines − 1` +/// leadings of the text after it, not to where line N is set. +#[test] +fn drop_cap_reaches_down_by_the_text_leading_past_a_line_with_larger_leading() { + use designcraft_doc::{CharAttrs, Composer, Leading}; + let text = [LOREM; 2].join(" "); + let para = ParaAttrs { composer: Some(Composer::SingleLine), ..drop_cap(3, 1) }; + let (mut d, sid, _) = doc_with(&text, Rect::new(0.0, 0.0, 300.0, 1000.0), para); + d.story_mut(sid).unwrap().paras[0].chars = CharAttrs { size: Some(12.0), leading: Some(Leading::Auto), ..Default::default() }; + // A larger word starts line 2, so lines 2 and 3 are set further down than 2 × 14.4 pt. + let start2 = all_lines(&compose_story(&d, sid, &ComposeOptions::default()))[1].range.start; + d.story_mut(sid).unwrap().format_chars(start2..start2 + 3, |f| f.over.size = Some(20.0)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + assert_eq!(lines[1].range.start, start2); + assert!(lines[2].baseline - lines[0].baseline > 2.0 * 14.4 + 5.0, "{} {}", lines[0].baseline, lines[2].baseline); + let dc = &lines[0].glyphs[0]; + assert!((dc.y - 2.0 * 14.4).abs() < 1e-6, "drop cap baseline {} below line 1", dc.y); + let b = lines[0].drop_cap.unwrap(); + assert!((b.baseline - (lines[0].baseline + 2.0 * 14.4)).abs() < 1e-6, "{}", b.baseline); +} + +/// A space after the drop cap is set at its size on its baseline and doesn't widen the indent: the +/// lines beside it start where its characters end. +#[test] +fn space_after_a_drop_cap_does_not_widen_the_indent() { + let (d, sid, _) = drop_doc(&format!("In 2003 {LOREM}"), drop_cap(2, 2)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + let (i, n) = (&lines[0].glyphs[0], &lines[0].glyphs[1]); + assert_eq!((i.byte, n.byte), (0, 1)); + let end = n.x + n.adv; + for l in &lines[..2] { + assert!((text_x(l, 3) - end).abs() < 1e-6, "{} vs {end}", text_x(l, 3)); + } + let space = lines[0].glyphs.iter().find(|g| g.byte == 2).unwrap(); + assert!((space.sy - n.sy).abs() < 1e-9 && (space.y - n.y).abs() < 1e-9, "the space is set like the drop cap"); + assert_eq!(lines[0].drop_cap.unwrap().end, 3); +} + +/// A one-line drop cap keeps its characters' own size (a smaller verse number stays smaller), and +/// the tabs in it advance to the paragraph's tab stops. +#[test] +fn one_line_drop_cap_keeps_its_size_and_sets_its_tabs() { + let stop = |position, align| designcraft_doc::TabStop { position, align, leader: String::new(), align_on: String::new() }; + let para = ParaAttrs { tabs: Some(vec![stop(30.0, TabAlign::Right), stop(40.0, TabAlign::Left)]), ..drop_cap(1, 3) }; + let (mut d, sid, _) = drop_doc(&format!("\t7\t{LOREM}"), para); + d.story_mut(sid).unwrap().format_chars(0..3, |f| f.over.size = Some(8.0)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let l = &cs.frames[0].lines[0]; + let at = |b: usize| l.glyphs.iter().find(|g| g.byte == b).unwrap(); + let (seven, body) = (at(1), at(3)); + assert!((seven.sy / body.sy - 0.8).abs() < 1e-9, "{} vs {}", seven.sy, body.sy); + assert!(seven.y.abs() < 1e-9); + assert!((seven.x + seven.adv - 30.0).abs() < 1e-6, "the number ends at the right tab: {}", seven.x + seven.adv); + assert!((text_x(l, 3) - 40.0).abs() < 1e-6, "the text starts at the left tab: {}", text_x(l, 3)); +} + +/// Right to left, the tabs in a drop cap advance to tab stops measured from the column's right +/// edge. +#[test] +fn right_to_left_drop_cap_sets_its_tabs_from_the_right_edge() { + let stop = |position, align| designcraft_doc::TabStop { position, align, leader: String::new(), align_on: String::new() }; + let para = ParaAttrs { + direction: Some(designcraft_doc::TextDirection::RightToLeft), + align: Some(Align::Right), + tabs: Some(vec![stop(30.0, TabAlign::Right), stop(40.0, TabAlign::Left)]), + ..drop_cap(1, 3) + }; + let (d, sid, _) = drop_doc(&format!("\t7\t{LOREM}"), para); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let l = &cs.frames[0].lines[0]; + let seven = l.glyphs.iter().find(|g| g.byte == 1).unwrap(); + // The 300 pt column's right edge is at 300: the number's far (left) end is 30 pt from it. + assert!((seven.x - 270.0).abs() < 1e-6, "the number ends at the right tab: {}", seven.x); + // The text beside it starts at the left tab, 40 pt from the edge. + let right = l.glyphs.iter().filter(|g| g.byte >= 3 && g.visible).map(|g| g.x + g.adv).fold(f64::NEG_INFINITY, f64::max); + assert!(right <= 260.0 + 1e-6 && right > 255.0, "text ends at {right}"); +} + +/// A rule in Text Color takes the colour of the paragraph's text: the first character's for the +/// rule above, the last character's for the rule below. A swatch colour is used as is. +#[test] +fn text_color_rules_follow_the_text() { + use designcraft_doc::{CharAttrs, CharFormat, Rule, TEXT_COLOR}; + let rule = Rule { on: true, weight: 2.0, ..Default::default() }; + let para = ParaAttrs { rule_above: Some(rule.clone()), rule_below: Some(rule.clone()), ..Default::default() }; + let (mut d, sid, fid) = doc_with("Red then blue", Rect::new(0.0, 0.0, 300.0, 200.0), para); + { + let s = d.story_mut(sid).unwrap(); + s.format_chars(0..3, |f: &mut CharFormat| f.over.merge(&CharAttrs { fill: Some("Red".into()), fill_tint: Some(0.5), ..Default::default() })); + s.format_chars(9..13, |f: &mut CharFormat| f.over.fill = Some("Blue".into())); + } + let decos = |d: &Document| { + let cs = compose_story(d, sid, &ComposeOptions::default()); + let mut v: Vec<(f64, String, f32)> = cs.frame(fid).unwrap().decos.iter().map(|dc| (dc.rect.y0, dc.color.clone(), dc.tint)).collect(); + v.sort_by(|a, b| a.0.total_cmp(&b.0)); + v.into_iter().map(|(_, c, t)| (c, t)).collect::>() + }; + assert_eq!(rule.color, TEXT_COLOR); + assert_eq!(decos(&d), vec![("Red".to_string(), 0.5), ("Blue".to_string(), 1.0)]); + let swatch = Rule { color: "[Black]".into(), tint: 0.3, ..rule }; + d.story_mut(sid).unwrap().paras[0].para.rule_above = Some(swatch); + assert_eq!(decos(&d)[0], ("[Black]".to_string(), 0.3)); +} + +/// Align Left Edge (InDesign's default): the drop cap's origin moves left by its first letter's +/// left side bearing so its ink starts at the indent, and the lines beside it start where its +/// advance ends. Measured case: 14/18 text, two lines, one character. +#[test] +fn drop_cap_aligns_its_left_edge() { + let text = format!("Once {LOREM}"); + let compose = |align: Option| { + let para = ParaAttrs { left_indent: Some(10.0), drop_cap_align_left: align, ..drop_cap(2, 1) }; + let (mut d, sid, _) = doc_with(&text, Rect::new(0.0, 0.0, 300.0, 1000.0), para); + d.story_mut(sid).unwrap().paras[0].chars = + designcraft_doc::CharAttrs { size: Some(14.0), leading: Some(designcraft_doc::Leading::Points(18.0)), ..Default::default() }; + compose_story(&d, sid, &ComposeOptions::default()) + }; + use designcraft_geom::Shape as _; + let ink_left = |g: &PlacedGlyph| g.x + designcraft_fonts::FontDb::global().outline(g.face.get(), g.gid).bounding_box().x0 * g.sx; + for align in [None, Some(true), Some(false)] { + let cs = compose(align); + let lines = all_lines(&cs); + let dc = &lines[0].glyphs[0]; + assert_eq!(dc.byte, 0); + let lsb = ink_left(dc) - dc.x; + assert!(lsb > 0.5, "the O has a visible side bearing at this size: {lsb}"); + if align != Some(false) { + assert!((ink_left(dc) - 10.0).abs() < 1e-6, "{align:?}: ink at {}", ink_left(dc)); + } else { + assert!((dc.x - 10.0).abs() < 1e-6, "origin at {}", dc.x); + } + for l in &lines[..2] { + assert!((text_x(l, 1) - (dc.x + dc.adv)).abs() < 1e-6, "{align:?}: {} vs {}", text_x(l, 1), dc.x + dc.adv); + } + assert!((text_x(lines[2], 1) - 10.0).abs() < 1e-6); + } +} + +#[test] +fn caret_follows_spaces_typed_at_the_end() { + let x_at_end = |text: &str| { + let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs::default()); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let end_x = cs.frames[0].lines[0].end_x; + (caret(&cs, text.len()).unwrap().1, end_x) + }; + let (word, word_end) = x_at_end("Word"); + let (one, one_end) = x_at_end("Word "); + let (two, _) = x_at_end("Word "); + assert!(one > word + 1.0 && two > one + 1.0, "{word} {one} {two}"); + // The spaces don't count for the line's own length (alignment, justification). + assert!((one_end - word_end).abs() < 1e-9); + // Wrapped lines keep their spaces too: a caret between two spaces at a line end sits after + // the first one. + let text = "Lorem ipsum dolor sit amet consectetur"; + let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 140.0, 100.0), ParaAttrs::default()); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let l0 = &cs.frames[0].lines[0]; + if l0.range.end == text.find("consectetur").unwrap() { + let mid = text.find(" ").unwrap() + 1; + assert!(caret(&cs, mid).unwrap().1 > l0.end_x, "between the trailing spaces"); + } +} + #[test] fn spanish_text_gets_spanish_hyphenation() { let text = "La transición democrática fue una construcción colectiva extraordinariamente compleja y desesperadamente necesaria."; @@ -2338,6 +3420,32 @@ fn vertical_lines_fit_the_em_box() { } } +/// A drop cap set in one of the document's own fonts measures its side bearing in that font. +#[test] +fn drop_cap_in_a_document_font_aligns_its_left_edge() { + use designcraft_geom::Shape as _; + const FAMILY: &str = "DocFont Drop Cap"; + let text = format!("Once {LOREM}"); + let mut chars: Vec = text.chars().collect(); + chars.sort_unstable(); + chars.dedup(); + let folder = std::env::temp_dir().join(format!("dc-compose-dropcap-docfont-{}", std::process::id())); + std::fs::create_dir_all(&folder).unwrap(); + std::fs::write(folder.join("dropcap.ttf"), designcraft_fonts::testing::font_with_glyph(FAMILY, &chars, 'O').unwrap()).unwrap(); + let scope = designcraft_fonts::FontDb::global().load_document_fonts(&folder).scope; + let para = ParaAttrs { left_indent: Some(10.0), ..drop_cap(2, 1) }; + let (mut d, sid, _) = doc_with(&text, Rect::new(0.0, 0.0, 300.0, 1000.0), para); + d.font_scope = scope; + d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| f.over.font_family = Some(FAMILY.into())); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let dc = &all_lines(&cs)[0].glyphs[0]; + assert_eq!(dc.face.family, FAMILY); + let ink_left = dc.x + designcraft_fonts::FontDb::global().outline(dc.face.get(), dc.gid).bounding_box().x0 * dc.sx; + assert!(ink_left - dc.x > 0.5, "the O has a visible side bearing"); + assert!((ink_left - 10.0).abs() < 1e-6, "ink at {ink_left}"); + let _ = std::fs::remove_dir_all(&folder); +} + /// Lines of `text` set in a frame `width` wide, with `tracking` on every character. fn tracked_lines(text: &str, width: f64, para: ParaAttrs, tracking: f64) -> Vec { let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, width, 4000.0), para); diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index b99740e..7215c15 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -431,6 +431,8 @@ pub enum NestedUntil { ForcedLineBreak, EmSpace, EnSpace, + /// The paragraph's drop cap characters. + Dropcap, /// Any of these characters. Chars(String), } @@ -467,6 +469,18 @@ impl Default for NestedStyle { } } +impl NestedStyle { + /// The drop cap's character style as a nested style: `style` through 1 drop cap. + pub fn drop_cap(style: &str) -> Self { + NestedStyle { style: style.into(), through: true, count: 1, until: NestedUntil::Dropcap } + } + + /// Is this the form a drop cap's character style takes at the start of a nested style list? + pub fn is_drop_cap(&self) -> bool { + self.until == NestedUntil::Dropcap && self.through && self.count == 1 + } +} + /// A character style applied to every match of a regular expression in the paragraph. #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] @@ -530,12 +544,16 @@ pub struct TabStop { pub align_on: String, } +/// The colour of a paragraph rule that follows the paragraph's text colour. +pub const TEXT_COLOR: &str = "Text Color"; + /// A paragraph rule (Rule Above / Rule Below). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct Rule { pub on: bool, pub weight: f64, + /// A swatch, or [`TEXT_COLOR`]. pub color: String, pub tint: f32, /// Width: column (true) or text (false). @@ -547,7 +565,7 @@ pub struct Rule { impl Default for Rule { fn default() -> Self { - Rule { on: false, weight: 1.0, color: "[Black]".into(), tint: 1.0, column_width: true, offset: 0.0, left_indent: 0.0, right_indent: 0.0 } + Rule { on: false, weight: 1.0, color: TEXT_COLOR.into(), tint: 1.0, column_width: true, offset: 0.0, left_indent: 0.0, right_indent: 0.0 } } } @@ -809,8 +827,17 @@ attr_set! { last_line_indent: f64 = 0.0, space_before: f64 = 0.0, space_after: f64 = 0.0, + /// Drop cap: the paragraph's first `drop_cap_chars` characters (code points, never + /// splitting a base from its combining marks) set `drop_cap_lines` lines tall, in the + /// character style `drop_cap_style` ("" or `[None]`: the text's own formatting). drop_cap_lines: u32 = 0, drop_cap_chars: u32 = 0, + drop_cap_style: String = String::new(), + /// Align Left Edge: the drop cap's ink starts at the indent (its first letter's left side + /// bearing is taken off), and the lines beside it start where its advance ends. + drop_cap_align_left: bool = true, + /// Scale for Descenders (kept and round-tripped; doesn't change the layout). + drop_cap_scale_descenders: bool = false, /// Nested styles, in order from the paragraph start. nested_styles: Vec = Vec::new(), /// GREP styles: a character style for every match of a pattern (applied after nested). @@ -855,7 +882,8 @@ attr_set! { // Keeps keep_with_next: u32 = 0, keep_lines_together: bool = false, - keep_all_lines: bool = true, + /// All lines in paragraph (true) or at start/end of paragraph (false). + keep_all_lines: bool = false, keep_first: u32 = 2, keep_last: u32 = 2, start_paragraph: StartParagraph = StartParagraph::Anywhere, diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index 38de38a..7b2168e 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -200,87 +200,229 @@ impl Document { Ok((id, sid)) } - /// Thread `from` → `to`: `to` joins `from`'s story right after `from`. If `to` held its own text, - /// that text is appended to the story (as a new paragraph) and its old story is removed. + /// Thread `from` → `to`: `to` joins `from`'s story right after `from` (between `from` and the + /// frame that followed it, if any). If `to` held its own text, that text is appended to the + /// story (as new paragraphs) and its old story is removed; frames that followed `to` come + /// along, frames before it keep an empty story of their own. An empty (unassigned) `from` or + /// `to` becomes a text frame. `from`'s story keeps its id. pub fn thread(&mut self, from: ItemId, to: ItemId) -> Result<()> { if from == to { return Err(DocError::Invalid("cannot thread a frame to itself".into())); } - let fs = self.item(from).and_then(|i| i.text_frame()).map(|t| t.story).ok_or(DocError::NoItem(from))?; - // Convert unassigned/graphic-less frames into text frames. - let ts = match self.item(to).ok_or(DocError::NoItem(to))?.content.clone() { - Content::Text(t) => Some(t.story), - Content::Unassigned => None, - _ => return Err(DocError::Invalid("can only thread to text or empty frames".into())), + self.check_thread_links(from, to, false)?; + let ts = self.frame_story(to)?; + let fs = match self.frame_story(from)? { + Some(s) => s, + None => self.give_empty_story(from)?, }; if ts == Some(fs) { return Err(DocError::Invalid("frames are already in the same story".into())); } - if let Some(ts) = ts { - let other = self.stories.remove(&ts).ok_or(DocError::NoStory(ts))?; - let other = Arc::unwrap_or_clone(other); - // Frames of the other story after `to` follow along; frames before it are left as empty new stories. - let pos_to = other.frames.iter().position(|f| *f == to).unwrap_or(0); - let before: Vec = 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(); - ns.vertical = other.vertical; - ns.direction = other.direction; - for f in &before { - if let Some(t) = self.item_mut(*f).and_then(Item::text_frame_mut) { - t.story = nid; - } - } - self.stories.insert(nid, Arc::new(ns)); - } - } else { + let Some(ts) = ts else { let st = self.story_mut(fs).ok_or(DocError::NoStory(fs))?; let at = st.frames.iter().position(|f| *f == from).map_or(st.frames.len(), |p| p + 1); st.frames.insert(at, to); + st.rev += 1; let it = self.item_mut(to).ok_or(DocError::NoItem(to))?; it.content = Content::Text(TextFrame { story: fs, options: TextFrameOptions::default() }); - } - if let Some(st) = self.story_mut(fs) { + return Ok(()); + }; + let mut other = Arc::unwrap_or_clone(self.stories.remove(&ts).ok_or(DocError::NoStory(ts))?); + // Frames of the other story from `to` on follow along; frames before it are left as an empty new story. + let pos_to = other.frames.iter().position(|f| *f == to).unwrap_or(0); + let after = other.frames.split_off(pos_to); + let before = std::mem::take(&mut other.frames); + let (vertical, direction) = (other.vertical, other.direction); + let offset; + { + let st = self.story_mut(fs).ok_or(DocError::NoStory(fs))?; + offset = if st.text.is_empty() { 0 } else { st.text.len().saturating_add(1) }; + // The story keeps its own direction, even when it was empty and takes the other's text. + let own = (st.vertical, st.direction); + st.append_story(other); + (st.vertical, st.direction) = own; + let at = st.frames.iter().position(|f| *f == from).map_or(st.frames.len(), |p| p + 1); + for (k, f) in after.iter().enumerate() { + st.frames.insert(at + k, *f); + } st.rev += 1; } + self.remap_thread_story_refs(ts, fs, offset); + for f in &after { + if let Some(t) = self.item_mut(*f).and_then(Item::text_frame_mut) { + t.story = fs; + } + } + if !before.is_empty() { + let nid = StoryId(self.alloc()); + let mut ns = Story::new(nid); + ns.frames = before.clone(); + ns.vertical = vertical; + ns.direction = direction; + for f in &before { + if let Some(t) = self.item_mut(*f).and_then(Item::text_frame_mut) { + t.story = nid; + } + } + self.stories.insert(nid, Arc::new(ns)); + } Ok(()) } + /// [`Document::thread`], except that an empty `from` alone in its story, threaded in front of + /// the first frame of `to`'s story, joins that story: the story with the text keeps its id + /// (and whatever refers to it). + pub fn thread_keeping_story(&mut self, from: ItemId, to: ItemId) -> Result<()> { + let fs = self.frame_story(from)?; + let from_empty = fs.is_none_or(|fs| self.story(fs).is_some_and(|s| s.text.is_empty() && s.frames == [from])); + let ts = self.frame_story(to)?; + let to_first = ts.and_then(|ts| self.story(ts)).is_some_and(|s| s.frames.first() == Some(&to)); + let (Some(ts), true, true) = (ts, from_empty, to_first && from != to) else { return self.thread(from, to) }; + self.check_thread_links(from, to, true)?; + if let Some(fs) = fs { + self.stories.remove(&fs); + self.remap_thread_story_refs(fs, ts, 0); + } + let st = self.story_mut(ts).ok_or(DocError::NoStory(ts))?; + st.frames.insert(0, from); + st.rev += 1; + let it = self.item_mut(from).ok_or(DocError::NoItem(from))?; + match it.text_frame_mut() { + Some(t) => t.story = ts, + None => it.content = Content::Text(TextFrame { story: ts, options: TextFrameOptions::default() }), + } + Ok(()) + } + + /// Story-external text addresses move with the incoming text when threading merges stories. + /// Linked children follow the surviving whole story, including for an empty removed parent; + /// their copied content stays unchanged until an explicit Update Link. + fn remap_thread_story_refs(&mut self, from: StoryId, to: StoryId, offset: usize) { + // The child's old snapshot is not a snapshot of the newly merged parent. Force it + // out of date even when the old and surviving revision numbers happen to coincide. + let stale_rev = self.story(to).map_or(0, |st| st.rev.wrapping_sub(1)); + for st in self.stories.values_mut() { + if st.link.is_some_and(|(parent, _)| parent == from) { + Arc::make_mut(st).link = Some((to, stale_rev)); + } + } + for link in &mut self.hyperlinks { + if let crate::HyperlinkSource::Text { story, start, end } = &mut link.source + && *story == from + { + *story = to; + *start = start.saturating_add(offset); + *end = end.saturating_add(offset); + } + } + for placeholder in &mut self.data_merge.placeholders { + if let crate::PlaceholderAnchor::Text { story, start, end } = &mut placeholder.anchor + && *story == from + { + *story = to; + *start = start.saturating_add(offset); + *end = end.saturating_add(offset); + } + } + if self.settings.primary_story == Some(from) { + self.settings.primary_story = Some(to); + } + } + + /// A linked child is replaced wholesale on update. Only empty, unlinked frame + /// extensions can join it without losing a link or newly appended body text. + fn check_thread_links(&self, from: ItemId, to: ItemId, keep_to: bool) -> Result<()> { + let fs = self.frame_story(from)?; + let ts = self.frame_story(to)?; + let first = fs.and_then(|id| self.story(id)); + let second = ts.and_then(|id| self.story(id)); + let incompatible = if keep_to { + first.is_some_and(|st| st.link.is_some()) || second.is_some_and(|st| st.link.is_some_and(|(parent, _)| Some(parent) == fs)) + } else { + second.is_some_and(|st| st.link.is_some()) + || first.is_some_and(|st| { + st.link.is_some() + && second.is_some_and(|incoming| !incoming.text.is_empty() || st.link.is_some_and(|(parent, _)| parent == incoming.id)) + }) + }; + if incompatible { + return Err(DocError::Invalid("unlink the linked story before merging its text".into())); + } + Ok(()) + } + + /// Can a user thread `from` → `to`? Both are text frames or empty frames (not lines), on + /// document spreads or on the same parent spread, not in one story already, and no frame + /// threads into `to` yet. [`Document::thread`] itself is less strict (import and autoflow). + pub fn check_thread(&self, from: ItemId, to: ItemId) -> Result<()> { + if from == to { + return Err(DocError::Invalid("cannot thread a frame to itself".into())); + } + for id in [from, to] { + let it = self.item(id).ok_or(DocError::NoItem(id))?; + if !matches!(it.content, Content::Text(_) | Content::Unassigned) || it.shape == Shape::GraphicLine { + return Err(DocError::Invalid(format!("{id} is not a text frame or an empty frame"))); + } + if it.locked || self.layer(it.layer).is_some_and(|l| l.locked) { + return Err(DocError::Invalid(format!("{id} is locked or on a locked layer"))); + } + } + let (Some(fl), Some(tl)) = (self.find(from), self.find(to)) else { return Err(DocError::NoItem(to)) }; + let same_place = match (fl.spread, tl.spread) { + (SpreadRef::Doc(_), SpreadRef::Doc(_)) => true, + (a, b) => a == b, + }; + if !same_place { + return Err(DocError::Invalid("frames on a parent spread thread only to frames on the same parent".into())); + } + let story = |id: ItemId| self.item(id).and_then(|i| i.text_frame()).map(|t| t.story); + if story(from).is_some() && story(from) == story(to) { + return Err(DocError::Invalid("the frames are already in the same thread".into())); + } + // Generated text is rebuilt from its source data. Merging body text into it would + // silently discard that text on the next update (endnotes update after every edit). + let generated = |sid| { + self.endnote_story == Some(sid) + || self.toc.as_ref().is_some_and(|t| t.story == sid) + || self.index.as_ref().is_some_and(|i| i.story == sid) + }; + let (fs, ts) = (story(from), story(to)); + if fs.is_some_and(generated) && ts.and_then(|sid| self.story(sid)).is_some_and(|st| !st.text.is_empty()) { + return Err(DocError::Invalid("cannot merge body text into a generated story".into())); + } + if ts.is_some_and(generated) && fs.and_then(|sid| self.story(sid)).is_some_and(|st| !st.text.is_empty() || st.frames != [from]) { + return Err(DocError::Invalid("a generated story can only take a new empty first frame".into())); + } + let keep_to = fs.is_none_or(|sid| self.story(sid).is_some_and(|st| st.text.is_empty() && st.frames == [from])) + && ts.and_then(|sid| self.story(sid)).is_some_and(|st| st.frames.first() == Some(&to)); + self.check_thread_links(from, to, keep_to)?; + if self.prev_frame(to).is_some() { + return Err(DocError::Invalid(format!("text already flows into {to} from another frame: break that thread first"))); + } + Ok(()) + } + + /// The story of a frame that can take part in a thread: `Some` for a text frame, `None` for + /// an empty (unassigned) frame. + fn frame_story(&self, id: ItemId) -> Result> { + 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<()> { @@ -309,6 +451,30 @@ impl Document { Ok(()) } + /// Take `frame` out of its thread: the frames before and after it join up and keep the text, + /// `frame` stays on the page with a new empty story. + pub fn remove_from_thread(&mut self, frame: ItemId) -> Result<()> { + let sid = self.item(frame).and_then(|i| i.text_frame()).map(|t| t.story).ok_or(DocError::NoItem(frame))?; + let (vertical, direction) = { + let st = self.story_mut(sid).ok_or(DocError::NoStory(sid))?; + if st.frames.len() < 2 { + return Err(DocError::Invalid(format!("frame {frame} is not threaded to another frame"))); + } + st.frames.retain(|f| *f != frame); + st.rev += 1; + (st.vertical, st.direction) + }; + let nid = StoryId(self.alloc()); + let mut ns = Story::new(nid); + ns.frames = vec![frame]; + ns.vertical = vertical; + ns.direction = direction; + self.stories.insert(nid, Arc::new(ns)); + let t = self.item_mut(frame).and_then(Item::text_frame_mut).ok_or(DocError::NoItem(frame))?; + t.story = nid; + Ok(()) + } + /// Next / previous frame in a thread. pub fn next_frame(&self, frame: ItemId) -> Option { let st = self.story(self.item(frame)?.text_frame()?.story)?; diff --git a/crates/doc/src/ids.rs b/crates/doc/src/ids.rs index 3dbb94c..fed2cb8 100644 --- a/crates/doc/src/ids.rs +++ b/crates/doc/src/ids.rs @@ -21,3 +21,24 @@ id_type!(SpreadId, "sp"); id_type!(PageId, "p"); id_type!(LayerId, "l"); id_type!(AssetId, "a"); + +/// Fresh story-local id. Keep monotonically increasing ids normally; a loaded MAX id must +/// not make the next insertion overwrite it. In that case choose the first unused gap. +pub(crate) fn next_local_id(ids: impl Iterator + Clone) -> u64 { + if let Some(next) = ids.clone().max().unwrap_or(0).checked_add(1) { + return next; + } + let mut used: Vec<_> = ids.collect(); + used.sort_unstable(); + let mut next = 1u64; + for id in used { + if id > next { + break; + } + if id == next { + // An allocated Vec cannot contain every u64 id, so this always finds a gap. + next = next.saturating_add(1); + } + } + next +} diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 0a70199..70d56ce 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -281,6 +281,10 @@ pub enum FirstBaseline { Leading, XHeight, Fixed, + /// The top of the font's em box: its `BASE` em box; else, for a CJK font, its OS/2 typo + /// ascender when the typo ascender and descender span one em; else half an em above half the + /// cap height. + EmboxHeight, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -321,7 +325,7 @@ pub struct TextFrameOptions { pub first_baseline_min: f64, pub ignore_wrap: bool, pub auto_size: AutoSize, - /// Reference point (0..9) that stays fixed when auto-sizing. + /// Reference point (0..9, row-major from the top left) that stays fixed when auto-sizing. pub auto_size_ref: u8, pub column_rule: bool, pub column_rule_weight: f64, @@ -382,7 +386,7 @@ impl Default for TextFrameOptions { first_baseline_min: 0.0, ignore_wrap: false, auto_size: AutoSize::Off, - auto_size_ref: 1, + auto_size_ref: 4, column_rule: false, column_rule_weight: 1.0, column_rule_color: designcraft_color::swatch::BLACK.into(), @@ -1156,13 +1160,29 @@ impl Item { b.inflate(w, w) } - /// The frame's text area (inner space) after inset. + /// The text frame's text area (inner space): inside the part of a visible stroke that lies + /// within the frame, then the inset, on each edge. pub fn text_area(&self) -> Rect { let r = self.inner_bounds(); - let inset = self.text_frame().map(|t| t.options.inset).unwrap_or([0.0; 4]); + let s = self.stroke_inside(); + let inset = self.text_frame().map(|t| t.options.inset).unwrap_or([0.0; 4]).map(|v| v + s); Rect::new(r.x0 + inset[1], r.y0 + inset[0], (r.x1 - inset[3]).max(r.x0 + inset[1]), (r.y1 - inset[2]).max(r.y0 + inset[0])) } + /// How far a visible stroke reaches inside the path: half its weight centred, all of it inside, + /// none outside. + fn stroke_inside(&self) -> f64 { + let w = self.stroke.weight; + if self.stroke.is_none() || !w.is_finite() { + return 0.0; + } + match self.stroke.align { + StrokeAlign::Center => w / 2.0, + StrokeAlign::Inside => w, + StrokeAlign::Outside => 0.0, + } + } + /// Spread-space centre. pub fn center(&self) -> Point { self.bounds().center() @@ -1207,6 +1227,32 @@ mod tests { assert_eq!(it.visible_bounds(), Rect::new(8.0, 18.0, 112.0, 72.0)); } + #[test] + fn text_area_starts_inside_the_stroke() { + let mut it = Item::new(ItemId(1), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0))); + it.content = Content::Text(TextFrame { story: StoryId(2), options: TextFrameOptions { inset: [5.0, 6.0, 7.0, 8.0], ..Default::default() } }); + let unstroked = Rect::new(6.0, 5.0, 92.0, 43.0); + let stroked = |align: StrokeAlign, swatch: &str| { + let mut it = it.clone(); + it.stroke = Stroke { weight: 4.0, align, swatch: swatch.into(), ..Stroke::default() }; + it.text_area() + }; + let black = designcraft_color::swatch::BLACK; + // The part of the stroke inside the frame, on every edge, then the inset. + assert_eq!(stroked(StrokeAlign::Center, black), Rect::new(8.0, 7.0, 90.0, 41.0)); + assert_eq!(stroked(StrokeAlign::Inside, black), Rect::new(10.0, 9.0, 88.0, 39.0)); + assert_eq!(stroked(StrokeAlign::Outside, black), unstroked); + // A stroke without a colour doesn't count. + assert_eq!(stroked(StrokeAlign::Inside, designcraft_color::swatch::NONE), unstroked); + // Nor does a weight that isn't a number. + it.stroke = Stroke { weight: f64::NAN, ..Stroke::default() }; + assert_eq!(it.text_area(), unstroked); + // A stroke wider than the frame leaves no room for text. + it.stroke = Stroke { weight: 200.0, align: StrokeAlign::Inside, ..Stroke::default() }; + let a = it.text_area(); + assert!(a.width() == 0.0 && a.height() == 0.0, "{a:?}"); + } + #[test] fn group_bounds_union_children() { let a = Item::new(ItemId(1), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0))); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index a643cc0..c1c55dc 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -143,7 +143,7 @@ impl Default for DocumentGrid { } /// A named numbered list. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] pub struct NumberedList { pub name: String, @@ -151,12 +151,6 @@ pub struct NumberedList { pub continue_across_stories: bool, } -impl Default for NumberedList { - fn default() -> Self { - NumberedList { name: String::new(), continue_across_stories: true } - } -} - /// A conditional-text condition: its indicator colour (screen only) and whether text with it shows. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/crates/doc/src/notes.rs b/crates/doc/src/notes.rs index b681542..46a3396 100644 --- a/crates/doc/src/notes.rs +++ b/crates/doc/src/notes.rs @@ -178,7 +178,7 @@ impl Story { } pub(crate) fn next_note_id(&self) -> u64 { - self.notes.iter().map(|n| n.id).max().unwrap_or(0) + 1 + crate::ids::next_local_id(self.notes.iter().map(|n| n.id)) } /// Insert empty footnotes for `n` new reference characters before which `k` references sit. diff --git a/crates/doc/src/page.rs b/crates/doc/src/page.rs index f1eaa48..1fa753b 100644 --- a/crates/doc/src/page.rs +++ b/crates/doc/src/page.rs @@ -246,7 +246,8 @@ impl Page { /// Split `area` into `n` equal columns separated by `gutter`. pub fn column_rects(area: Rect, n: u32, gutter: f64) -> Vec { - let n = n.max(1) as usize; + // The public allocator also receives native/imported options, outside command validation. + let n = n.clamp(1, 216) as usize; let w = ((area.width() - gutter * (n as f64 - 1.0)) / n as f64).max(0.0); (0..n).map(|i| Rect::new(area.x0 + i as f64 * (w + gutter), area.y0, area.x0 + i as f64 * (w + gutter) + w, area.y1)).collect() } @@ -403,3 +404,18 @@ mod tests { assert_eq!(s.page_at_x(5000.0), Some(1)); } } + +#[cfg(test)] +mod column_allocation_safety_tests { + use super::*; + + #[test] + fn column_allocation_caps_page_count_and_preserves_geometry() { + let area = Rect::new(0., 0., 300., 100.); + // Fail safely at a small boundary before exercising hostile counts. + assert_eq!(column_rects(area, 217, 0.).len(), 216); + assert_eq!(column_rects(area, u32::MAX, 0.).len(), 216); + assert_eq!(column_rects(area, 0, 12.), vec![area]); + assert_eq!(column_rects(area, 2, 12.), vec![Rect::new(0., 0., 144., 100.), Rect::new(156., 0., 300., 100.)]); + } +} diff --git a/crates/doc/src/slice.rs b/crates/doc/src/slice.rs index f85ec4a..ebf59b6 100644 --- a/crates/doc/src/slice.rs +++ b/crates/doc/src/slice.rs @@ -126,6 +126,66 @@ impl Story { self.rev += 1; end } + + /// Append the whole of `other` as new paragraphs after this story's text (threading two + /// stories merges them this way). Its formatting, notes, endnotes, editorial notes, markers, + /// anchored objects and tables come along; footnotes, endnotes, editorial notes and tables get + /// ids that are free in this story. An empty story simply takes `other`'s content. Frames stay. + pub fn append_story(&mut self, mut other: Story) { + if other.text.is_empty() { + return; + } + if self.text.is_empty() { + let (id, frames, rev, link) = (self.id, std::mem::take(&mut self.frames), self.rev, self.link.take()); + *self = Story { id, frames, rev: rev.saturating_add(1), link, ..other }; + return; + } + if !other.tables.is_empty() { + let mut ids = std::collections::HashMap::new(); + let mut next = crate::ids::next_local_id(self.tables.keys().copied()); + for (old, t) in std::mem::take(&mut other.tables) { + let new = next; + let mut t = Arc::unwrap_or_clone(t); + t.id = new; + ids.insert(old, new); + self.tables.insert(new, Arc::new(t)); + // Continue through free ids without rescanning the table map for its maximum. + // On exhaustion wrap to gaps; an allocated map cannot occupy every u64 id. + next = next.checked_add(1).unwrap_or(1); + while self.tables.contains_key(&next) { + next = next.checked_add(1).unwrap_or(1); + } + } + for p in &mut other.paras { + p.table = p.table.and_then(|id| ids.get(&id).copied()); + } + } + self.text.push('\n'); + self.text.push_str(&other.text); + // The paragraph end takes the last run's format. + if let Some(last) = self.chars.last_mut() { + last.len += 1; + } + self.chars.extend(other.chars.into_iter().filter(|r| r.len > 0)); + self.paras.extend(other.paras); + for n in other.notes { + let id = self.next_note_id(); + self.notes.push(Arc::new(crate::notes::Footnote { id, text: n.text.clone() })); + } + for n in other.endnotes { + let id = crate::ids::next_local_id(self.endnotes.iter().map(|x| x.id)); + self.endnotes.push(Arc::new(crate::notes::Footnote { id, text: n.text.clone() })); + } + for n in other.editorial { + let id = crate::ids::next_local_id(self.editorial.iter().map(|x| x.id)); + self.editorial.push(Arc::new(crate::endnotes::EditorialNote { id, ..(*n).clone() })); + } + self.anchors.extend(other.anchors); + self.xrefs.extend(other.xrefs); + self.index_refs.extend(other.index_refs); + self.objects.extend(other.objects); + self.rev = self.rev.saturating_add(1); + } } #[cfg(test)] @@ -159,4 +219,52 @@ mod tests { assert_eq!(part.notes.len(), 1); assert!(a.extract(2..8).notes.is_empty()); } + + #[test] + fn append_tables_preserves_sparse_and_wrapping_ids() { + for existing in [vec![1, 3, u64::MAX], vec![1, 3, u64::MAX - 1], vec![1, 3, 8]] { + let story = |id, ids: &[u64]| { + let mut s = Story::with_text(StoryId(id), &vec![crate::TABLE_ANCHOR.to_string(); ids.len()].join("\n"), ParaFormat::default()); + for (p, &tid) in s.paras.iter_mut().zip(ids) { + p.table = Some(tid); + let mut t = crate::table::Table::new(tid, 1, 1, 0, 0, 100.0); + t.cells[0].text = Story::with_text(StoryId(0), &format!("{id}:{tid}"), ParaFormat::default()); + s.tables.insert(tid, Arc::new(t)); + } + s + }; + let mut a = story(1, &existing); + let b = story(2, &[1, 2, 3]); + a.check().unwrap(); + b.check().unwrap(); + a.append_story(b); + a.check().unwrap(); + assert_eq!(a.tables.len(), 6); + for (p, tid) in a.paras.iter().take(3).zip(&existing) { + assert_eq!(p.table, Some(*tid)); + assert_eq!(a.tables[tid].cells[0].text.text, format!("1:{tid}")); + } + for (p, tid) in a.paras.iter().skip(3).zip(1..=3) { + assert_eq!(a.tables[&p.table.unwrap()].cells[0].text.text, format!("2:{tid}")); + } + } + } + + #[test] + fn append_story_with_maximal_ids_does_not_overflow() { + let mut a = Story::with_text(StoryId(1), "Alpha", ParaFormat::default()); + a.tables.insert(u64::MAX, Arc::new(crate::table::Table::new(u64::MAX, 1, 1, 0, 0, 100.0))); + let note = |id| Arc::new(crate::notes::Footnote { id, text: Story::with_text(StoryId(0), "n", ParaFormat::default()) }); + a.endnotes.push(note(u64::MAX)); + a.notes.push(note(u64::MAX)); + a.rev = u64::MAX; + let mut b = Story::with_text(StoryId(2), "Beta", ParaFormat::default()); + b.tables.insert(1, Arc::new(crate::table::Table::new(1, 1, 1, 0, 0, 100.0))); + b.endnotes.push(note(1)); + b.notes.push(note(1)); + a.append_story(b); + assert_eq!(a.text, "Alpha\nBeta"); + assert_eq!(a.endnotes.len(), 2); + assert_eq!(a.notes.len(), 2); + } } diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index e348ff2..55caf30 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -692,8 +692,8 @@ impl Styles { self.object.iter().find(|s| s.name == name) } - /// The paragraph style chain from root to `name` (cycle-safe). - fn para_chain(&self, name: &str) -> Vec<&ParagraphStyle> { + /// The paragraph style chain from root to `name` (cycle-safe): resolution starts at its first. + pub fn para_chain(&self, name: &str) -> Vec<&ParagraphStyle> { let mut chain = Vec::new(); let mut cur = self.para(name); while let Some(s) = cur { diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 9ee28a1..8af281d 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -42,6 +42,126 @@ fn thread_to_empty_frame_converts_it() { assert!(d.thread(a, a).is_err()); } +#[test] +fn threading_merges_tables_notes_and_endnotes() { + let mut d = doc(); + let lid = d.default_layer(); + let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "first", ParaFormat::default()).unwrap(); + let (b, sb) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "second", ParaFormat::default()).unwrap(); + for (sid, note) in [(sa, "Note A"), (sb, "Note B")] { + let st = d.story_mut(sid).unwrap(); + st.insert_note(3, note, ParaFormat::default()); + // Both stories hold a table with id 1. + let n = st.len(); + st.insert_table(n, crate::table::Table::new(1, 2, 2, 0, 0, 80.0)); + } + let st = d.story_mut(sb).unwrap(); + st.insert_endnote(2, "End B", ParaFormat::default()); + st.insert(1, &crate::NOTE_MARK.to_string()); + d.thread(a, b).unwrap(); + d.check().unwrap(); + let st = d.story(sa).unwrap(); + assert!(d.story(sb).is_none()); + assert_eq!(st.tables.len(), 2); + assert_eq!(st.notes.len(), 2); + assert_eq!(st.notes[1].text.text, "Note B"); + assert_eq!(st.endnotes.len(), 1); + assert_eq!(st.endnotes[0].text.text, "End B"); + assert_eq!(st.editorial.len(), 1); +} + +#[test] +fn empty_frame_threaded_before_a_story_joins_it_at_the_front() { + let mut d = doc(); + let lid = d.default_layer(); + let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "", ParaFormat::default()).unwrap(); + let (b, sb) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "Hello", ParaFormat::default()).unwrap(); + d.thread_keeping_story(a, b).unwrap(); + d.check().unwrap(); + assert!(d.story(sa).is_none()); + let st = d.story(sb).unwrap(); + assert_eq!(st.frames, vec![a, b]); + assert_eq!(st.text, "Hello"); +} + +#[test] +fn empty_story_takes_the_threaded_text_without_a_blank_paragraph() { + let mut d = doc(); + let lid = d.default_layer(); + let (a, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "", ParaFormat::default()).unwrap(); + let (a2, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 110.0, 100.0, 200.0), lid, "", ParaFormat::default()).unwrap(); + d.thread(a, a2).unwrap(); + let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "Hello", ParaFormat::default()).unwrap(); + d.thread(a2, b).unwrap(); + d.check().unwrap(); + let st = d.story(d.item(a).unwrap().text_frame().unwrap().story).unwrap(); + assert_eq!(st.frames, vec![a, a2, b]); + assert_eq!(st.text, "Hello"); +} + +#[test] +fn threading_from_an_empty_frame_makes_it_a_text_frame() { + let mut d = doc(); + let lid = d.default_layer(); + let id = ItemId(d.alloc()); + let it = Item::new(id, lid, Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(0.0, 200.0, 50.0, 300.0))); + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "Hello", ParaFormat::default()).unwrap(); + d.thread(id, b).unwrap(); + d.check().unwrap(); + let st = d.story(d.item(id).unwrap().text_frame().unwrap().story).unwrap(); + assert_eq!(st.frames, vec![id, b]); + assert_eq!(st.text, "Hello"); +} + +#[test] +fn removing_a_frame_from_its_thread_keeps_the_text_in_the_others() { + let mut d = doc(); + let lid = d.default_layer(); + let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "words", ParaFormat::default()).unwrap(); + let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "", ParaFormat::default()).unwrap(); + let (c, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(210.0, 10.0, 300.0, 100.0), lid, "", ParaFormat::default()).unwrap(); + d.thread(a, b).unwrap(); + d.thread(b, c).unwrap(); + d.remove_from_thread(b).unwrap(); + d.check().unwrap(); + assert_eq!(d.story(sa).unwrap().frames, vec![a, c]); + assert_eq!(d.story(sa).unwrap().text, "words"); + let own = d.item(b).unwrap().text_frame().unwrap().story; + assert_ne!(own, sa); + assert!(d.story(own).unwrap().text.is_empty()); + // A frame alone in its story has no thread to leave. + assert!(d.remove_from_thread(b).is_err()); +} + +#[test] +fn a_frame_removed_from_a_vertical_thread_stays_vertical() { + let mut d = doc(); + let lid = d.default_layer(); + let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "縦書き", ParaFormat::default()).unwrap(); + let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "", ParaFormat::default()).unwrap(); + d.story_mut(sa).unwrap().vertical = true; + d.thread(a, b).unwrap(); + d.remove_from_thread(b).unwrap(); + d.check().unwrap(); + assert!(d.frame_vertical(d.item(a).unwrap())); + assert!(d.frame_vertical(d.item(b).unwrap()), "the removed frame keeps the story's direction"); +} + +#[test] +fn a_frame_removed_from_a_right_to_left_thread_keeps_its_column_order() { + let mut d = doc(); + let lid = d.default_layer(); + let (a, sa) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "نص", ParaFormat::default()).unwrap(); + let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "", ParaFormat::default()).unwrap(); + d.story_mut(sa).unwrap().direction = crate::TextDirection::RightToLeft; + d.thread(a, b).unwrap(); + d.remove_from_thread(b).unwrap(); + d.check().unwrap(); + let own = d.item(b).unwrap().text_frame().unwrap().story; + assert_eq!(d.story(own).unwrap().direction, crate::TextDirection::RightToLeft); +} + #[test] fn frames_keep_their_story_direction_through_threading() { let mut d = doc(); diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 62578ba..1de90d1 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -30,6 +30,7 @@ log = { workspace = true } regex = { workspace = true } quick-xml = { workspace = true } caseless = { workspace = true } +unicode-segmentation = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] tempfile = { workspace = true } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index e7190ac..0885d15 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -20,13 +20,6 @@ fn has_clip(s: &Session) -> std::result::Result<(), String> { if s.clipboard.is_some() || text { Ok(()) } else { Err("clipboard is empty".into()) } } -fn has_clip_or_text(s: &Session) -> std::result::Result<(), String> { - if s.active().is_some_and(|d| d.selection.text.is_some()) { - return Ok(()); - } - has_clip(s) -} - fn has_selection_or_text(s: &Session) -> std::result::Result<(), String> { if s.active().and_then(|d| d.selection.text).is_some_and(|t| !t.is_caret()) { return Ok(()); @@ -88,7 +81,73 @@ fn paste_text(s: &mut Session, text: Option, formatted: bool) -> Result< }); } let text = text.or_else(|| clip.map(|(_, p)| p)).ok_or_else(|| super::bad("edit.paste", "clipboard is empty"))?; - s.execute("text.insert", &json!({"text": text.replace("\r\n", "\n").replace('\r', "\n"), "raw": true})) + let text = outside_text(&text)?; + s.execute("text.insert", &json!({"text": text, "raw": true})) +} + +/// Text from the system clipboard as story text: Unix line ends, and none of the private-use +/// characters (U+E000–U+E1FF) that stories use as markers for breaks, footnotes, tables and +/// variables, since a stray one would be read as a marker without its payload. +fn outside_text(text: &str) -> Result { + let text: String = text.replace("\r\n", "\n").replace('\r', "\n").chars().filter(|c| !('\u{E000}'..='\u{E1FF}').contains(c)).collect(); + if text.is_empty() { Err(super::bad("edit.paste", "the clipboard holds no text")) } else { Ok(text) } +} + +/// Pasted text with no insertion point: a new text frame holding it, as wide as the first column +/// of the page's margin area and as tall as the text (at most the margin area's height), centred +/// on `at` (spread coordinates) or else on the margin area. The frame ends up selected. With +/// `slice` (the text copied here), the frame's story is that formatted copy. +fn paste_text_frame(s: &mut Session, p: &Value, text: &str, slice: Option<&designcraft_doc::Story>) -> Result { + use designcraft_geom::{Point, Rect, shapes}; + const LIMIT: f64 = 1.0e5; + let sr = super::spread_param(p, "spread")?; + let at = super::point_param(p, "at") + .filter(|a| a.x.is_finite() && a.y.is_finite()) + .map(|a| Point::new(a.x.clamp(-LIMIT, LIMIT), a.y.clamp(-LIMIT, LIMIT))); + let lid = s.doc()?.active_layer; + let cache = s.cache.clone(); + let text = if slice.is_some() { String::new() } else { outside_text(text)? }; + s.edit(|d, sel| { + let sp = d.spread(sr).ok_or_else(|| super::bad("edit.paste", "no such spread"))?; + let page = at.and_then(|a| sp.page_at_x(a.x)).and_then(|i| sp.pages.get(i)).or_else(|| sp.pages.first()); + let page = page.ok_or_else(|| super::bad("edit.paste", "the spread has no pages"))?; + let margins = page.margin_rect(); + let column = page.column_rects().first().copied().unwrap_or(margins); + let w = column.width().max(12.0); + let max_h = margins.height().max(12.0); + let c = at.unwrap_or(Point::new(column.center().x, margins.center().y)); + let rect = |h: f64| Rect::new(c.x - w / 2.0, c.y - h / 2.0, c.x + w / 2.0, c.y + h / 2.0); + let para = match slice.and_then(|sl| sl.paras.first()) { + Some(pf) => designcraft_doc::ParaFormat { table: None, ..pf.clone() }, + None => designcraft_doc::ParaFormat { style: d.styles.default_paragraph.clone(), ..Default::default() }, + }; + let (id, sid) = d.add_text_frame(sr, rect(max_h), lid, &text, para)?; + if let Some(sl) = slice { + d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?.insert_story(0, sl); + } + // Shrink to the composed text unless it already fills the frame. + let composed = cache.get(d, sid, None); + if !composed.is_overset() + && let Some(ft) = composed.frame(id) + { + let inset = d.item(id).and_then(|i| i.text_frame()).map_or(0.0, |tf| tf.options.inset[0] + tf.options.inset[2]); + let h = (ft.content_height + inset + 0.5).clamp(12.0, max_h); + if h.is_finite() + && h < max_h + && let Some(it) = d.item_mut(id) + { + it.path = shapes::rectangle(rect(h)); + // Keep the full height if the last line no longer fits. + if cache.get(d, sid, None).is_overset() + && let Some(it) = d.item_mut(id) + { + it.path = shapes::rectangle(rect(max_h)); + } + } + } + *sel = Selection::items(vec![id]); + Ok(json!({"id": id.0, "story": sid.0})) + }) } fn strip_objects(st: &designcraft_doc::Story) -> designcraft_doc::Story { @@ -110,6 +169,8 @@ pub fn specs() -> Vec { st.doc = e.doc; st.selection = e.selection; st.revision += 1; + // Pending typing overrides belong to the current edit state, not a restored snapshot. + s.typing_format = None; Ok(json!({"undone": e.label})) }), cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, |s, _| { @@ -119,6 +180,7 @@ pub fn specs() -> Vec { st.doc = e.doc; st.selection = e.selection; st.revision += 1; + s.typing_format = None; Ok(json!({"redone": e.label})) }), cmd!(noundo "selection.set", "Select", [], None, "{ids: [id], add?: bool, content?: bool}", has_doc, |s, p| { @@ -227,11 +289,18 @@ pub fn specs() -> Vec { "Paste", ["Edit"], Some("Cmd+V"), - "{inPlace?: bool, text?: the system clipboard's text (pastes it unless it is what was copied here)}", - has_clip_or_text, + "{inPlace?: bool, text?: the system clipboard's text (pastes it unless it is what was copied here; with no insertion point it becomes a new text frame), spread?, at?: [x, y] (spread coords: the new text frame's centre)}", + // The system clipboard may hold text from another app, so Paste is always available. + has_doc, |s, p| { + let text = super::str_param(p, "text").filter(|t| !t.is_empty()); if s.doc()?.selection.text.is_some() { - return paste_text(s, super::str_param(p, "text").map(str::to_string), true); + return paste_text(s, text.map(str::to_string), true); + } + if let Some(text) = text { + // What our own text copy put on the clipboard keeps its formatting. + let slice = s.text_clipboard.clone().filter(|(_, plain)| text.replace("\r\n", "\n") == *plain).map(|(slice, _)| slice); + return paste_text_frame(s, p, text, slice.as_deref()); } let Some(clip) = s.clipboard.clone() else { return Err(super::bad("edit.paste", "clipboard is empty")); @@ -316,6 +385,87 @@ mod text_clipboard_tests { s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story } + #[test] + fn paste_text_with_no_insertion_point_makes_a_text_frame() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let items = |s: &Session| s.doc().unwrap().doc.spreads[0].items.len(); + // Text from another app, nothing copied here: a new, selected text frame. + let r = s.execute("edit.paste", &json!({"text": "From elsewhere\r\nSecond line"})).unwrap(); + let id = ItemId(r["id"].as_u64().unwrap()); + let d = s.doc().unwrap(); + assert_eq!(d.selection.items, vec![id]); + assert!(d.selection.text.is_none(), "selected as an object"); + let it = d.doc.item(id).unwrap(); + assert_eq!(d.doc.story(it.text_frame().unwrap().story).unwrap().text, "From elsewhere\nSecond line"); + let page = &d.doc.spreads[0].pages[0]; + let (b, m) = (it.bounds(), page.margin_rect()); + assert!((b.width() - page.column_rects()[0].width()).abs() < 1e-6, "{b:?}"); + assert!(b.height() < m.height() / 2.0, "fits two lines: {b:?}"); + assert!((b.center().y - m.center().y).abs() < 1e-6, "{b:?}"); + assert!(!s.cache.get(&d.doc, it.text_frame().unwrap().story, None).is_overset()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(items(&s), 0, "one undo removes it"); + // Centred on `at`; hostile numbers are clamped. + let r = s.execute("edit.paste", &json!({"text": "Here", "at": [200.0, 300.0]})).unwrap(); + let b = s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().bounds(); + assert!((b.center().x - 200.0).abs() < 1e-6 && (b.center().y - 300.0).abs() < 1e-6, "{b:?}"); + assert!(s.execute("edit.paste", &json!({"text": "Far", "at": [1e300, -1e300], "spread": 99})).is_err()); + s.execute("edit.paste", &json!({"text": "Far", "at": [1e300, -1e300]})).unwrap(); + // Copied objects paste when no text is given. + s.execute("edit.deselectAll", &json!({})).unwrap(); + let rect = s.execute("frame.create", &json!({"rect": [72, 72, 200, 160]})).unwrap()["id"].clone(); + s.execute("selection.set", &json!({"ids": [rect]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + let n = items(&s); + let r = s.execute("edit.paste", &json!({})).unwrap(); + assert_eq!(r["ids"].as_array().unwrap().len(), 1); + assert_eq!(items(&s), n + 1); + assert!(s.doc().unwrap().doc.item(ItemId(r["ids"][0].as_u64().unwrap())).unwrap().text_frame().is_none()); + s.doc().unwrap().doc.check().unwrap(); + } + + #[test] + fn outside_text_never_pastes_markers() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + // Private-use characters from another app would read as footnote, table or variable markers. + let dirty = "a\u{E00A}b\u{E009}c\u{E1FF}d\u{E200}"; + let r = s.execute("edit.paste", &json!({"text": dirty})).unwrap(); + let st = s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + assert_eq!(s.doc().unwrap().doc.story(st).unwrap().text, "abcd\u{E200}"); + s.doc().unwrap().doc.check().unwrap(); + let b = frame(&mut s, "Target"); + s.execute("text.select", &json!({"story": b.0, "anchor": 6, "focus": 6})).unwrap(); + s.execute("edit.paste", &json!({"text": dirty})).unwrap(); + s.execute("edit.pasteWithoutFormatting", &json!({"text": dirty})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(b).unwrap().text, "Targetabcd\u{E200}abcd\u{E200}"); + assert!(s.execute("edit.paste", &json!({"text": "\u{E00A}"})).is_err()); + s.doc().unwrap().doc.check().unwrap(); + } + + #[test] + fn copied_text_pasted_with_no_insertion_point_keeps_its_formatting() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = frame(&mut s, "Bold words\nSecond"); + s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": 4})).unwrap(); + s.execute("type.bold", &json!({})).unwrap(); + s.execute("text.select", &json!({"story": a.0, "anchor": 11, "focus": 17})).unwrap(); + s.execute("type.para", &json!({"attrs": {"align": "center"}})).unwrap(); + s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": 17})).unwrap(); + let copied = s.execute("edit.copy", &json!({})).unwrap()["text"].as_str().unwrap().to_string(); + s.execute("edit.deselectAll", &json!({})).unwrap(); + let r = s.execute("edit.paste", &json!({"text": copied})).unwrap(); + let d = s.doc().unwrap(); + let st = d.doc.story(d.doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story).unwrap(); + assert_eq!(st.text, "Bold words\nSecond"); + assert_eq!(st.char_format_at(2).over.font_style.as_deref(), Some("Bold")); + assert_eq!(st.char_format_at(7).over.font_style, None); + assert_eq!(st.paras[1], d.doc.story(a).unwrap().paras[1]); + d.doc.check().unwrap(); + } + #[test] fn formatted_copy_paste_and_plain_paste() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index a1919b8..4b04867 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -989,4 +989,24 @@ mod document_fonts_tests { assert!(glyph_faces(&s).iter().all(|(_, f)| f.family == FAMILY)); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn bold_and_italic_link_the_document_fonts_styles() { + const FAMILY: &str = "DocFont Linked"; + let dir = temp_dir("linked"); + std::fs::write(dir.join(DOCUMENT_FONTS_FOLDER).join("linked.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap(); + let path = dir.join("Linked.designcraft"); + write_document(&path, FAMILY); + + let mut s = Session::new(); + open(&mut s, &path); + let sid = *s.doc().unwrap().doc.stories.keys().next().unwrap(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + // The family is the document's alone: its Regular is the style without bold or italic. + let r = s.execute("type.bold", &json!({"on": false})).unwrap(); + assert_eq!(r["fontStyle"], "Regular", "{r}"); + let r = s.execute("type.italic", &json!({"on": false})).unwrap(); + assert_eq!(r["fontStyle"], "Regular", "{r}"); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 31e4544..affe071 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -50,6 +50,7 @@ mod strokes; mod style; pub mod table; pub mod text; +mod thread; mod toc; mod transitions; mod variables; @@ -184,6 +185,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(package::specs()); v.extend(print::specs()); v.extend(text::specs()); + v.extend(thread::specs()); v.extend(style::specs()); v.extend(table::specs()); v.extend(layout::specs()); diff --git a/crates/engine/src/cmd/notes.rs b/crates/engine/src/cmd/notes.rs index c17dc6c..e3b3ccc 100644 --- a/crates/engine/src/cmd/notes.rs +++ b/crates/engine/src/cmd/notes.rs @@ -22,7 +22,7 @@ pub fn specs() -> Vec { "Document Footnote Options…", ["Type"], None, - "{style?: arabic|upperRoman|lowerRoman|upperLetters|lowerLetters|symbols, startAt?, restart?: never|page|spread|section, prefix?, suffix?, affixIn?: none|reference|text|both, refPosition?: superscript|subscript|normal|otSuperscript, refCharStyle?, paraStyle?, separator?, spaceBefore?, spaceBetween?, firstBaseline?, firstBaselineMin?, spanColumns?, rule?: {on, weight, color, tint, width, offset, leftIndent}} → the options (no params: just read them)", + "{style?: arabic|upperRoman|lowerRoman|upperLetters|lowerLetters|symbols, startAt?, restart?: never|page|spread|section, prefix?, suffix?, affixIn?: none|reference|text|both, refPosition?: superscript|subscript|normal|otSuperscript, refCharStyle?, paraStyle?, separator?, spaceBefore?, spaceBetween?, firstBaseline?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, firstBaselineMin?, spanColumns?, rule?: {on, weight, color, tint, width, offset, leftIndent}} → the options (no params: just read them)", has_doc, options ), diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index aa5a2cc..5e9ed39 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -532,7 +532,7 @@ pub fn specs() -> Vec { "Text Frame Options…", ["Object"], Some("Cmd+B"), - "{columns?, gutter?, inset?: number|[t,l,b,r] (null keeps that side), verticalJustification?: top|center|bottom|justify, firstBaseline?, autoSize?, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}", + "{columns?, gutter?, inset?: number|[t,l,b,r] (null keeps that side), verticalJustification?: top|center|bottom|justify, firstBaseline?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, autoSize?, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}", has_selection, text_frame_options ), diff --git a/crates/engine/src/cmd/place_text.rs b/crates/engine/src/cmd/place_text.rs index 97917ee..5bbb59a 100644 --- a/crates/engine/src/cmd/place_text.rs +++ b/crates/engine/src/cmd/place_text.rs @@ -124,19 +124,25 @@ fn fit(d: &Document, sid: StoryId) -> (Option, usize, usize) { } /// Thread new frames on new pages (margin rectangles) until the story fits. Returns pages added. -pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize) -> Result { +/// A story on a parent spread doesn't autoflow: its frames thread only on that parent. Errors +/// name `cmd`, the command that autoflows. +pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &str) -> Result { + let last = d.story(sid).and_then(|s| s.frames.last().copied()).ok_or_else(|| bad(cmd, "the story has no frame"))?; + if !matches!(d.find(last).map(|l| l.spread), Some(SpreadRef::Doc(_))) { + return Err(bad(cmd, "autoflow works only from a frame on a document page")); + } let mut added = 0; loop { let (overset, held, full) = fit(d, sid); let Some(at) = overset else { return Ok(added) }; if added >= max_pages { - return Err(bad("file.place", format!("autoflow stopped after {max_pages} pages"))); + return Err(bad(cmd, format!("autoflow stopped after {max_pages} pages"))); } let len = d.story(sid).map_or(0, |s| s.len()); // Frames still needed at the rate the full ones are filled (at least one); with no full // frame to go by yet, one more page. let want = held.checked_div(full).map_or(1, |per_frame| len.saturating_sub(at).div_ceil(per_frame.max(1))).clamp(1, max_pages - added); - let last = *d.story(sid).and_then(|s| s.frames.last()).ok_or_else(|| bad("file.place", "the story has no frame"))?; + let last = *d.story(sid).and_then(|s| s.frames.last()).ok_or_else(|| bad(cmd, "the story has no frame"))?; let mut page = d.page_of_item(last).unwrap_or(d.page_count().saturating_sub(1)); let parent = d.page_loc(page).and_then(|(si, pi)| d.spreads[si].pages[pi].parent); d.insert_pages(Some(page), want, parent)?; @@ -144,7 +150,7 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize) -> Resu let mut prev = last; for _ in 0..want { page += 1; - let (si, pi) = d.page_loc(page).ok_or_else(|| bad("file.place", "page insert failed"))?; + let (si, pi) = d.page_loc(page).ok_or_else(|| bad(cmd, "page insert failed"))?; let r = d.spreads[si].pages[pi].margin_rect(); let (fid, _) = d.add_text_frame(SpreadRef::Doc(si), r, layer, "", ParaFormat::default())?; d.thread(prev, fid)?; @@ -230,7 +236,7 @@ pub(super) fn place_text(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) - *sel = designcraft_doc::Selection::items(vec![fid]); sid }; - let pages = if autoflow_on { autoflow(d, sid, 2000)? } else { 0 }; + let pages = if autoflow_on { autoflow(d, sid, 2000, "file.place")? } else { 0 }; let overset = fit(d, sid).0.is_some(); Ok(json!({"story": sid.0, "pagesAdded": pages, "overset": overset, "warnings": warnings})) }) diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index c4d20f0..0280612 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -180,7 +180,7 @@ pub fn specs() -> Vec { "Apply Color", [], None, - "{color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b](0..255), target?: fill|stroke, text?: bool, ids?} — an unnamed colour on the selected text or objects; `text` also targets the text of selected frames", + "{color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b](0..255), target?: fill|stroke, text?: bool, ids?} — an unnamed colour (not in the Swatches panel until Add to Swatches) on the selection; `text`: on the selected text, or the text of the selected frames (Formatting Affects Text)", has_text_or_selection, apply_color ), @@ -638,10 +638,9 @@ fn create_char(s: &mut Session, p: &Value) -> Result { && let Some(target) = super::text::format_targets_pub(s).first() { let st = s.doc()?; - let story = st.doc.text_story(target.story, target.cell).ok_or(designcraft_doc::DocError::NoStory(target.story))?; // Match paragraph fromSelection's first-character/caret policy, but keep paragraph // attributes inherited instead of freezing the fully resolved character properties. - let format = if target.range.is_empty() { story.char_format_at(target.range.start) } else { story.format_after(target.range.start) }; + let format = super::text::effective_char_format(s, target)?; let mut captured = st.doc.styles.char_style_attrs(&format.style); captured.merge(&format.over); // Replay the explicit fields so null clears a captured value rather than looking absent. @@ -986,47 +985,30 @@ fn apply_color(s: &mut Session, p: &Value) -> Result { let named = p.get("ids").is_some() || p.get("id").is_some(); let text = !named && (p.get("text").and_then(Value::as_bool).unwrap_or(false) || s.doc()?.selection.text.is_some() || s.doc()?.selection.cells.is_some()); - if text { - let targets = super::text::format_targets(s); - // A caret in existing text has no editable range. Refuse it before creating a swatch - // or history; an empty story has a format run that the next insertion inherits. - if !targets - .iter() - .any(|t| !t.range.is_empty() || s.active().is_some_and(|d| d.doc.text_story(t.story, t.cell).is_some_and(|st| st.is_empty()))) - { - return Err(bad("object.color", "select the text to colour")); - } - return s.edit(|d, _| { - let name = color_swatch(d, color); - for t in &targets { - let Some(story) = d.text_story_mut(t.story, t.cell) else { continue }; - let set = |f: &mut designcraft_doc::CharFormat| { - if stroke { - (f.over.stroke, f.over.stroke_tint) = (Some(name.clone()), Some(1.0)); - } else { - (f.over.fill, f.over.fill_tint) = (Some(name.clone()), Some(1.0)); - } - }; - if t.range.is_empty() { - if story.is_empty() - && let Some(run) = story.chars.first_mut() - { - set(&mut run.format); - story.rev = story.rev.wrapping_add(1); - } - } else { - story.format_chars(t.range.clone(), set); - } - } - Ok(json!({"swatch": name})) - }); + let (ids, texts) = if text { (vec![], super::text::format_targets_pub(s)) } else { (super::targets(s, p)?, vec![]) }; + if text && texts.is_empty() { + return Err(bad("object.color", "select the text to colour")); } - let ids = super::targets(s, p)?; - s.edit(|d, _| { + // A caret: the colour is for the next typing (once the swatch exists). + let caret = matches!(texts.as_slice(), [t] if t.range.is_empty()); + let r = s.edit(|d, _| { let name = color_swatch(d, color); color_items(d, &ids, &name, stroke); + if !caret { + let a = if stroke { + CharAttrs { stroke: Some(name.clone()), stroke_tint: Some(1.0), ..Default::default() } + } else { + CharAttrs { fill: Some(name.clone()), fill_tint: Some(1.0), ..Default::default() } + }; + super::text::apply_char_attrs(d, &texts, &a, &[]); + } Ok(json!({"swatch": name})) - }) + })?; + if caret && let Some(name) = r.get("swatch").cloned() { + let attrs = if stroke { json!({"stroke": name, "strokeTint": 1.0}) } else { json!({"fill": name, "fillTint": 1.0}) }; + super::text::format_chars(s, &attrs)?; + } + Ok(r) } /// A process swatch holding exactly `color`, else a new unnamed one (hidden from the Swatches @@ -1153,7 +1135,7 @@ fn add_to_swatches(s: &mut Session, p: &Value) -> Result { } }; let rename = str_param(p, "name").map(str::to_string); - s.edit(|d, _| { + let result = s.edit(|d, _| { let w = d.swatches.iter_mut().find(|w| w.name == name).ok_or_else(|| bad("swatch.addToSwatches", format!("no swatch `{name}`")))?; if w.locked { return Err(bad("swatch.addToSwatches", "special swatches are already listed")); @@ -1180,8 +1162,84 @@ fn add_to_swatches(s: &mut Session, p: &Value) -> Result { rename_in(std::sync::Arc::make_mut(top), &name, &new); } } + for st in d.stories.values_mut() { + let mut copy = (**st).clone(); + if rename_in_story(&mut copy, &name, &new, 0)? { + *st = std::sync::Arc::new(copy); + } + } Ok(json!({"name": new})) - }) + })?; + if let Some(new) = result.get("name").and_then(Value::as_str) { + super::text::rename_typing_swatch(s, &name, new); + } + Ok(result) +} + +/// Point text colours (character fill, stroke, underline and strikethrough; paragraph shading, +/// border and rules; table cells, borders and alternating fills) at a renamed swatch, in a story +/// and the texts inside it (table cells, footnotes, endnotes). Returns whether anything changed. +fn rename_in_story(st: &mut designcraft_doc::Story, from: &str, to: &str, depth: usize) -> Result { + // Refuse the whole edit instead of leaving deeper references pointing at a removed name. + if depth > 32 { + return Err(bad("swatch.addToSwatches", "story nesting exceeds 32 levels")); + } + fn fix(s: &mut String, from: &str, to: &str) -> bool { + let hit = s == from; + if hit { + *s = to.to_string(); + } + hit + } + let mut n = 0; + let mut f = |s: &mut String| n += usize::from(fix(s, from, to)); + for r in &mut st.chars { + let o = &mut r.format.over; + for c in [&mut o.fill, &mut o.stroke, &mut o.underline_color, &mut o.strikethrough_color].into_iter().flatten() { + f(c); + } + } + for pf in &mut st.paras { + let a = &mut pf.para; + for c in [&mut a.shading_color, &mut a.border_color].into_iter().flatten() { + f(c); + } + for r in [&mut a.rule_above, &mut a.rule_below].into_iter().flatten() { + f(&mut r.color); + } + } + let mut nested = false; + for t in st.tables.values_mut() { + let t = std::sync::Arc::make_mut(t); + let o = &mut t.options; + f(&mut o.border.color); + for b in o.borders.iter_mut().flatten() { + f(&mut b.color); + } + for a in [&mut o.alt_rows, &mut o.alt_cols].into_iter().flatten() { + f(&mut a.first_color); + f(&mut a.next_color); + } + for k in [&mut o.row_strokes, &mut o.column_strokes].into_iter().flatten() { + f(&mut k.first_stroke.color); + f(&mut k.next_stroke.color); + } + for c in &mut t.cells { + f(&mut c.fill); + for k in &mut c.strokes { + f(&mut k.color); + } + nested |= rename_in_story(&mut c.text, from, to, depth + 1)?; + } + } + for note in st.notes.iter_mut().chain(st.endnotes.iter_mut()) { + nested |= rename_in_story(&mut std::sync::Arc::make_mut(note).text, from, to, depth + 1)?; + } + let changed = n > 0 || nested; + if changed { + st.rev = st.rev.wrapping_add(1); + } + Ok(changed) } /// Point fills and strokes of an item (and its children) at a renamed swatch. @@ -1495,6 +1553,40 @@ mod color_tests { s.execute("object.color", &json!({"color": {"c": 10, "m": 20, "y": 30, "k": 0}})).unwrap(); assert_eq!(s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch, "Sand"); } + + #[test] + fn add_to_swatches_renames_the_colour_in_text_cells_and_notes() { + use std::sync::Arc; + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Hello world"})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + s.execute("text.select", &json!({"story": sid.0, "anchor": 11})).unwrap(); + s.execute("footnote.insert", &json!({"text": "note"})).unwrap(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 1, "focus": 4})).unwrap(); + let old = s.execute("object.color", &json!({"color": {"c": 10, "m": 20, "y": 30, "k": 0}})).unwrap()["swatch"].as_str().unwrap().to_string(); + // The same unnamed colour on a cell, the cell's text and the footnote's text. + s.edit(|d, _| { + let st = d.story_mut(sid).unwrap(); + let t = Arc::make_mut(st.tables.values_mut().next().unwrap()); + t.cells[0].fill = old.clone(); + t.cells[0].text.chars[0].format.over.fill = Some(old.clone()); + Arc::make_mut(&mut st.notes[0]).text.chars[0].format.over.stroke = Some(old.clone()); + Ok(()) + }) + .unwrap(); + s.execute("swatch.addToSwatches", &json!({"swatch": old, "name": "Sand"})).unwrap(); + let d = &s.doc().unwrap().doc; + let st = d.story(sid).unwrap(); + assert_eq!(st.char_format_at(2).over.fill.as_deref(), Some("Sand")); + let cell = &st.tables.values().next().unwrap().cells[0]; + assert_eq!(cell.fill, "Sand"); + assert_eq!(cell.text.chars[0].format.over.fill.as_deref(), Some("Sand")); + assert_eq!(st.notes[0].text.chars[0].format.over.stroke.as_deref(), Some("Sand")); + d.check().unwrap(); + } } #[cfg(test)] @@ -1708,15 +1800,16 @@ mod sample_color_tests { } #[test] - fn nothing_to_sample_or_nothing_to_colour_is_refused_without_an_edit() { + fn nothing_to_sample_is_refused_without_an_edit() { let (mut s, _) = session_with_image(); let f = s.execute("frame.create", &json!({"rect": [300, 300, 500, 360], "content": "text", "text": "Hi"})).unwrap(); let fid = ItemId(f["id"].as_u64().unwrap()); let story = s.doc().unwrap().doc.item(fid).unwrap().text_frame().unwrap().story; s.execute("text.select", &json!({"story": story.0, "anchor": 1, "focus": 1})).unwrap(); + let sampled = s.execute("color.sample", &json!({"at": [100, 100]})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], sampled["swatch"]); + assert_eq!(s.doc().unwrap().doc.story(story).unwrap().text, "Hi", "caret color waits for typing"); let undo = s.doc().unwrap().history.undo.len(); - let caret = s.execute("color.sample", &json!({"at": [100, 100]})).unwrap_err().to_string(); - assert!(caret.contains("select the text"), "{caret}"); s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 2})).unwrap(); let none = s.execute("color.sample", &json!({"at": [600, 600]})).unwrap_err().to_string(); assert!(none.contains("no placed image"), "{none}"); @@ -1751,3 +1844,7 @@ mod sample_color_tests { } } } + +#[cfg(test)] +#[path = "text_color_tests.rs"] +mod text_color_tests; diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index b57e9e8..e928ce4 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -22,22 +22,25 @@ 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.selectSentence", "Select Sentence", [], None, "{frame, point} — the sentence under the point (triple click)", has_doc, |s, p| { + cmd!( + noundo "text.selectAt", + "Select Text Unit", + [], + None, + "{frame, point, unit: word|line|paragraph|story} — what a double, triple, quadruple or quintuple click selects: the word (or run of spaces) under the point, its line, its paragraph with the paragraph return, or the whole story", + has_doc, + |s, p| select_at(s, p, str_param(p, "unit").unwrap_or("word")) + ), + cmd!(noundo "text.selectSentence", "Select Sentence", [], None, "{frame, point} — the sentence under the point", has_doc, |s, p| { select_around(s, p, "text.selectSentence", sentence_bounds) }), - cmd!(noundo "text.selectParagraph", "Select Paragraph", [], None, "{frame, point} — the paragraph under the point (quadruple click)", has_doc, |s, p| { + cmd!(noundo "text.selectParagraph", "Select Paragraph", [], None, "{frame, point} — the paragraph under the point, without its paragraph return", has_doc, |s, p| { select_around(s, p, "text.selectParagraph", para_bounds) }), - cmd!(noundo "text.selectStory", "Select Story", [], None, "{frame, point} — all the text of the story under the point, threaded frames included (quintuple click)", has_doc, |s, p| { + cmd!(noundo "text.selectStory", "Select Story", [], None, "{frame, point} — all the text of the story under the point, threaded frames included", has_doc, |s, p| { select_around(s, p, "text.selectStory", |t, _| (0, t.len())) }), cmd!(noundo "text.select", "Select Text", [], None, "{story, anchor, focus} (UTF-8 byte offsets into the story text, as find.find reports them: á or — counts 2 or 3)", has_doc, |s, p| { @@ -48,6 +51,7 @@ pub fn specs() -> Vec { let f = floor_char_boundary(text, p.get("focus").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(a)); st.selection = Selection::text(TextSel { story: sid, anchor: a, focus: f, frame: None, cell: None }); st.revision += 1; + s.typing_format = None; ok() }), cmd!("text.insert", "Type", [], None, "{text, raw?: bool (no typographer's quotes)} — replaces the selected text", has_text, insert), @@ -144,7 +148,7 @@ pub fn specs() -> Vec { "Paragraph Formatting", [], None, - "{attrs: {align?, leftIndent?, firstLineIndent?, spaceBefore?, spaceAfter?, dropCapLines?, hyphenate?, composer?, tabs?, ruleAbove?/ruleBelow?: {on?, weight?, color?, tint?, columnWidth?, offset?, leftIndent?, rightIndent?} (only the named rule fields change), …}}", + "{attrs: {align?, leftIndent?, firstLineIndent?, spaceBefore?, spaceAfter?, dropCapLines?, dropCapChars?, dropCapStyle?, dropCapAlignLeft?, dropCapScaleDescenders?, hyphenate?, composer?, tabs?, ruleAbove?/ruleBelow?: {on?, weight?, color?, tint?, columnWidth?, offset?, leftIndent?, rightIndent?} (only the named rule fields change), …}}", has_text_or_frames, |s, p| format_paras(s, p.get("attrs").unwrap_or(p)) ), @@ -161,8 +165,33 @@ pub fn specs() -> Vec { s, &json!({"align": "leftJustified"}) )), - cmd!("type.bold", "Bold", [], Some("Cmd+Shift+B"), "{}", has_text_or_frames, |s, _| format_chars(s, &json!({"fontStyle": "Bold"}))), - cmd!("type.italic", "Italic", [], Some("Cmd+Shift+I"), "{}", has_text_or_frames, |s, _| format_chars(s, &json!({"fontStyle": "Italic"}))), + cmd!( + "type.bold", + "Bold", + [], + Some("Cmd+Shift+B"), + "{on?: bool} — the family's bold style, keeping italic (style linking); toggles when `on` is left out", + has_text_or_frames, + |s, p| link_style(s, p, true) + ), + cmd!( + "type.italic", + "Italic", + [], + Some("Cmd+Shift+I"), + "{on?: bool} — the family's italic style, keeping the weight (style linking); toggles when `on` is left out", + has_text_or_frames, + |s, p| link_style(s, p, false) + ), + cmd!( + "type.swapFillStroke", + "Swap Text Fill and Stroke", + [], + None, + "{} — swap each selected character's resolved fill/stroke and tints; at a caret, swap the next typing's colors", + has_text_or_frames, + swap_fill_stroke + ), cmd!("type.underline", "Underline", [], Some("Cmd+Shift+U"), "{}", has_text_or_frames, |s, _| format_chars(s, &json!({"underline": true}))), cmd!("type.allCaps", "All Caps", [], Some("Cmd+Shift+K"), "{}", has_text_or_frames, |s, _| format_chars( s, @@ -280,6 +309,17 @@ pub(crate) fn story_of(s: &Session, p: &Value) -> Option { /// 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)?; @@ -288,18 +328,94 @@ fn hit_byte(s: &Session, frame: ItemId, pt: Point) -> Option<(StoryId, usize, Op let inner = xf.inverse() * pt; let cs = s.cache.get(&st.doc, sid, None); let fi = cs.frames.iter().position(|f| f.frame == frame)?; - // Type on a path: the click lands on the line where it is along the path. + // Map path-text hits along the curve before choosing the character unit. let inner = cs.frames.get(fi).and_then(|ft| compose::PathTextMap::new(it, ft)).map_or(inner, |m| m.to_line(inner)); - if let Some((table, row, col, b)) = compose::hit_cell(&cs, fi, inner) { + if let Some((table, row, col, b)) = compose::hit_cell_with(&cs, fi, inner, hit) { return Some((sid, b, Some(CellAddr { table, row, col }))); } - if let Some((id, b)) = compose::hit_note(&cs, fi, inner) { + if let Some((id, b)) = compose::hit_note_with(&cs, fi, inner, hit) { return Some((sid, b, Some(CellAddr::footnote(id)))); } - let b = compose::hit(&cs, fi, inner).unwrap_or(0); + let b = hit(&cs, fi, inner).unwrap_or(0); Some((sid, b, None)) } +/// Select the word, line, paragraph or story at `point` in `frame` (multiple clicks). +fn select_at(s: &mut Session, p: &Value, unit: &str) -> Result { + const ID: &str = "text.selectAt"; + let frame = id_param(p, "frame").ok_or_else(|| bad(ID, "missing frame"))?; + let pt = point_param(p, "point").ok_or_else(|| bad(ID, "missing point"))?; + let (sid, b, cell) = hit_byte_with(s, frame, pt, compose::hit_char).ok_or_else(|| bad(ID, "not a text frame"))?; + let st = s.doc()?; + let story = st.doc.text_story(sid, cell).ok_or(designcraft_doc::DocError::NoStory(sid))?; + let text = story.text.as_str(); + let b = floor_char_boundary(text, b); + let (a, e) = match unit { + "word" => word_or_spaces_at(text, b), + "line" => { + let cs = s.cache.get(&st.doc, sid, None); + line_at(&text_composition(cs, cell), b).unwrap_or((b, b)) + } + // The paragraph with its return (the last paragraph has none). + "paragraph" => story.para_ranges().get(story.para_at(b)).map_or((b, b), |r| (r.start, (r.end + 1).min(text.len()))), + "story" => (0, text.len()), + other => return Err(bad(ID, format!("unknown unit `{other}` (word, line, paragraph or story)"))), + }; + s.text_drag = false; + s.typing_format = None; + let st = s.doc_mut()?; + st.selection = Selection::text(TextSel { story: sid, anchor: a, focus: e, frame: Some(frame), cell }); + st.revision += 1; + Ok(json!({"story": sid.0, "anchor": a, "focus": e})) +} + +/// The word at byte `i`; on spaces the run of spaces; on anything else that one character +/// (grapheme cluster). At the end of a paragraph, what ends it. Grapheme clusters are never split: +/// combining marks, viramas, variation selectors and emoji ZWJ sequences stay with their base. +fn word_or_spaces_at(s: &str, i: usize) -> (usize, usize) { + use unicode_segmentation::UnicodeSegmentation; + let i = floor_char_boundary(s, i.min(s.len())); + // Clusters never span a paragraph return, so the paragraph is enough. + let ps = s[..i].rfind('\n').map_or(0, |k| k + 1); + let pe = s[i..].find('\n').map_or(s.len(), |k| i + k); + let g: Vec<(usize, &str)> = s[ps..pe].grapheme_indices(true).collect(); + let Some(k) = g.iter().rposition(|(o, _)| ps + o <= i) else { return (i, i) }; + let kind = |c: &str| match c.chars().next() { + Some(c) if is_word(c) => 1, + Some(c) if c.is_whitespace() => 2, + _ => 0, + }; + let span = |a: usize, e: usize| g.get(a).zip(g.get(e)).map_or((i, i), |((oa, _), (oe, ge))| (ps + oa, ps + oe + ge.len())); + let kd = g.get(k).map_or(0, |(_, c)| kind(c)); + if kd == 0 { + return span(k, k); + } + let mut a = k; + while a > 0 && g.get(a - 1).is_some_and(|(_, c)| kind(c) == kd) { + a -= 1; + } + let mut e = k; + while g.get(e + 1).is_some_and(|(_, c)| kind(c) == kd) { + e += 1; + } + span(a, e) +} + +/// The composition whose lines hold a story's text: the story's own, a table cell's or a footnote's. +fn text_composition(cs: std::sync::Arc, 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); @@ -318,6 +434,7 @@ fn place(s: &mut Session, p: &Value, extend: bool) -> Result { } else if s.text_drag { return Ok(json!({"story": sid.0, "dragging": true})); } + s.typing_format = None; let st = s.doc_mut()?; // Dragging from one cell into another selects cells. if extend @@ -385,7 +502,8 @@ fn insert(s: &mut Session, p: &Value) -> Result { let text = if s.prefs.typographers_quotes && !raw { smart_quotes(s, &text) } else { text }; let tracking = s.doc()?.doc.settings.track_changes; let autocorrect = (s.prefs.autocorrect && !tracking && !raw).then(|| s.prefs.autocorrect_list.clone()); - s.edit(|d, sel| { + let typing = typing_format(s).cloned(); + let result = s.edit(|d, sel| { let t = sel.text.ok_or_else(|| bad("text.insert", "no insertion point"))?; let st = d.text_story_mut(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?; let r = t.range(); @@ -445,6 +563,9 @@ fn insert(s: &mut Session, p: &Value) -> Result { // Track Changes: the replaced text is marked deleted, the typing inserted after it. let at = super::changes::mark_deleted(st, r.clone()); let mut fmt = st.char_format_at(at).clone(); + if let Some(f) = &typing { + apply_typing_format(&mut fmt, f); + } fmt.over.change = Some(designcraft_doc::ChangeMark::Inserted); st.insert_with(at, &text, fmt); let pos = at + text.len() - trail; @@ -452,10 +573,19 @@ fn insert(s: &mut Session, p: &Value) -> Result { return Ok(json!({"pos": pos})); } st.replace(r.clone(), &text); + if let Some(f) = &typing + && !text.is_empty() + { + st.format_chars(r.start..r.start + text.len(), |fmt| apply_typing_format(fmt, f)); + } let pos = r.start + text.len() - trail; sel.text = Some(TextSel { anchor: pos, focus: pos, ..t }); Ok(json!({"pos": pos})) - }) + }); + if result.is_ok() { + s.typing_format = None; + } + result } fn smart_quotes(s: &Session, text: &str) -> String { @@ -632,12 +762,6 @@ pub fn prev_word(s: &str, i: usize) -> usize { } 0 } -fn word_bounds(s: &str, i: usize) -> (usize, usize) { - let a = s[..i.min(s.len())].char_indices().rev().take_while(|(_, c)| is_word(*c)).last().map(|(k, _)| k).unwrap_or(i); - let b = s[i.min(s.len())..].char_indices().find(|(_, c)| !is_word(*c)).map(|(k, _)| i + k).unwrap_or(s.len()); - (a, b) -} - /// Selects `bounds(text, byte)` around the byte under `point` in `frame`. Unlike a caret /// placement, a press inside the current selection is no start of a text drag here: it is the /// third or later click of a row, which lands in the word or sentence just selected. @@ -732,13 +856,8 @@ fn move_caret(s: &mut Session, p: &Value) -> Result { let t = st.selection.text.ok_or_else(|| bad("text.move", "no caret"))?; let story = st.doc.text_story(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?; let text = story.text.clone(); - let cs = s.cache.get(&st.doc, t.story, None); // In a cell, lines come from the cell's own composition. - let cs = match t.cell { - Some(c) if c.footnote_id().is_some() => compose::find_note(&cs, c.row as u64).map(|(_, n)| n.text.clone()).unwrap_or(cs), - Some(c) => compose::find_cell(&cs, c.table, c.row, c.col).map(|(_, _, pc)| pc.text.clone()).unwrap_or(cs), - None => cs, - }; + let cs = text_composition(s.cache.get(&st.doc, t.story, None), t.cell); let pos = t.focus.min(text.len()); // Vertical lines run top to bottom and follow each other right to left: Down and Up move along // the line (Right and Left of horizontal text), Left and Right to the next and previous line. @@ -779,6 +898,7 @@ fn move_caret(s: &mut Session, p: &Value) -> Result { "storyEnd" => text.len(), other => return Err(bad("text.move", format!("unknown dir `{other}`"))), }; + s.typing_format = None; let stm = s.doc_mut()?; stm.selection.text = Some(if extend { TextSel { focus: new, ..t } } else { TextSel { anchor: new, focus: new, ..t } }); stm.revision += 1; @@ -849,6 +969,23 @@ pub(crate) fn format_targets(s: &Session) -> Vec { 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()) +} + fn warichu_cmd(s: &mut Session, p: &Value) -> Result { let has_opts = ["lines", "size", "lineSpacing", "align", "charsBeforeBreak", "charsAfterBreak"].iter().any(|k| p.get(k).is_some()); let on = match p.get("on").and_then(Value::as_bool) { @@ -908,32 +1045,50 @@ pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result { validate_char_fill(&s.doc()?.doc, &a, "type.char")?; 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_none_or(|v| v.is_null()) && !cleared.contains(k)); + f.cleared.extend(cleared); + s.typing_format = Some(f); + return ok(); + } s.edit(|d, _| { - for t in &targets { - let r = &t.range; - let Some(st) = d.text_story_mut(t.story, t.cell) else { continue }; - if r.is_empty() { - // Caret: change the typing format (the empty run at the caret). - let pos = r.start; - if st.is_empty() { - st.chars[0].format.over.merge(&a); - st.rev += 1; - } else { - let _ = pos; - } - continue; - } - st.format_chars(r.clone(), |f| { - f.over.merge(&a); - for k in &cleared { - let _ = f.over.set_json(k, &Value::Null); - } - }); - } + apply_char_attrs(d, &targets, &a, &cleared); ok() }) } +/// Character attributes `a` (and the overrides `cleared` removed) over each target's text. +pub(crate) fn apply_char_attrs(d: &mut designcraft_doc::Document, targets: &[Target], a: &CharAttrs, cleared: &[String]) { + for t in targets { + let r = &t.range; + let Some(st) = d.text_story_mut(t.story, t.cell) else { continue }; + if r.is_empty() { + // An empty story: its one (empty) run is the format typing takes. + if st.is_empty() + && let Some(run) = st.chars.first_mut() + { + run.format.over.merge(a); + st.rev += 1; + } + continue; + } + st.format_chars(r.clone(), |f| { + f.over.merge(a); + for k in cleared { + let _ = f.over.set_json(k, &Value::Null); + } + }); + } +} + pub(crate) fn format_paras(s: &mut Session, attrs: &Value) -> Result { let empty = serde_json::Map::new(); let obj = attrs.as_object().unwrap_or(&empty); @@ -1056,14 +1211,178 @@ fn selection_attrs(s: &mut Session, _p: &Value) -> Result { let pi = story.para_at(r.start); let pf = &story.paras[pi]; let (pp, base) = st.doc.styles.resolve_para(pf); - let cf = if r.is_empty() { story.char_format_at(r.start) } else { story.format_after(r.start) }; - let cp = st.doc.styles.resolve_char(&base, cf); - // The selected text (its start, on one line), for previews such as the font menu's. + let cf = effective_char_format(s, &Target { story: sid, cell, range: r.clone() })?; + let cp = st.doc.styles.resolve_char(&base, &cf); let selected = selected_text_preview(story.text.get(r.start..r.end).unwrap_or_default().chars()); Ok(json!({"story": sid.0, "paragraphStyle": pf.style, "characterStyle": cf.style, "para": pp, "chars": cp, "paraOverrides": pf.para.count(), "charOverrides": cf.over.count(), "selectedText": selected.trim()})) } +/// The first target character, with any pending caret overrides. Keep character-style +/// inheritance separate from paragraph defaults for style creation from the selection. +pub(crate) fn effective_char_format(s: &Session, target: &Target) -> Result { + let story = s.doc()?.doc.text_story(target.story, target.cell).ok_or(designcraft_doc::DocError::NoStory(target.story))?; + let mut format = if target.range.is_empty() { story.char_format_at(target.range.start) } else { story.format_after(target.range.start) }.clone(); + if let Some(pending) = typing_format(s) + && target.story == pending.at.story + && target.cell == pending.at.cell + && target.range == pending.at.range() + { + apply_typing_format(&mut format, pending); + } + Ok(format) +} + +/// A swatch promoted to a named color keeps any not-yet-inserted text pointing at it. +pub(crate) fn rename_typing_swatch(s: &mut Session, from: &str, to: &str) { + let Some(uid) = s.active().map(|st| st.uid) else { return }; + if let Some(pending) = &mut s.typing_format + && pending.doc == uid + { + for name in [&mut pending.attrs.fill, &mut pending.attrs.stroke, &mut pending.attrs.underline_color, &mut pending.attrs.strikethrough_color] + .into_iter() + .flatten() + { + if name == from { + *name = to.to_string(); + } + } + } +} + +fn swapped_colors(cp: &designcraft_doc::CharProps) -> CharAttrs { + CharAttrs { + fill: Some(cp.stroke.clone()), + fill_tint: Some(cp.stroke_tint), + stroke: Some(cp.fill.clone()), + stroke_tint: Some(cp.fill_tint), + ..Default::default() + } +} + +/// Resolve each paragraph/run before editing: a mixed selection must not take the first run's colors. +fn swap_fill_stroke(s: &mut Session, _p: &Value) -> Result { + const ID: &str = "type.swapFillStroke"; + let targets = format_targets(s); + if let [target] = targets.as_slice() + && target.range.is_empty() + && s.doc()?.selection.text.is_some() + { + let st = s.doc()?; + let story = st.doc.text_story(target.story, target.cell).ok_or(designcraft_doc::DocError::NoStory(target.story))?; + let pf = story.paras.get(story.para_at(target.range.start)).ok_or_else(|| bad(ID, "missing paragraph"))?; + let (_, base) = st.doc.styles.resolve_para(pf); + let cp = st.doc.styles.resolve_char(&base, &effective_char_format(s, target)?); + return format_chars(s, &json!({"fill": cp.stroke, "fillTint": cp.stroke_tint, "stroke": cp.fill, "strokeTint": cp.fill_tint})); + } + let mut edits = Vec::new(); + let d = &s.doc()?.doc; + for target in targets { + let story = d.text_story(target.story, target.cell).ok_or(designcraft_doc::DocError::NoStory(target.story))?; + if story.is_empty() { + let pf = story.paras.first().ok_or_else(|| bad(ID, "missing paragraph"))?; + let (_, base) = d.styles.resolve_para(pf); + let attrs = swapped_colors(&d.styles.resolve_char(&base, story.char_format_at(0))); + validate_char_fill(d, &attrs, ID)?; + edits.push((target, attrs)); + continue; + } + let mut runs = story.runs().peekable(); + for (pi, para) in story.para_ranges().into_iter().enumerate() { + let start = para.start.max(target.range.start); + let end = para.end.saturating_add(1).min(story.len()).min(target.range.end); + if start >= end { + continue; + } + let pf = story.paras.get(pi).ok_or_else(|| bad(ID, "missing paragraph"))?; + let (_, base) = d.styles.resolve_para(pf); + while let Some((range, format)) = runs.peek() { + if range.end <= start { + runs.next(); + continue; + } + if range.start >= end { + break; + } + let attrs = swapped_colors(&d.styles.resolve_char(&base, format)); + validate_char_fill(d, &attrs, ID)?; + edits.push((Target { range: start.max(range.start)..end.min(range.end), ..target.clone() }, attrs)); + if range.end > end { + break; + } + runs.next(); + } + } + } + s.edit(|d, _| { + for (target, attrs) in &edits { + apply_char_attrs(d, std::slice::from_ref(target), attrs, &[]); + } + ok() + }) +} + +fn apply_typing_format(cf: &mut designcraft_doc::CharFormat, f: &TypingFormat) { + cf.over.merge(&f.attrs); + for k in &f.cleared { + let _ = cf.over.set_json(k, &Value::Null); + } +} + +/// Bold (`bold`) or italic from the font family (style linking), per run of the selection: on or +/// off as asked, else the opposite of the first selected character. +fn link_style(s: &mut Session, p: &Value, bold: bool) -> Result { + let id = if bold { "type.bold" } else { "type.italic" }; + let cur = selection_attrs(s, &json!({}))?; + let family = cur["chars"]["fontFamily"].as_str().unwrap_or_default().to_string(); + let style = cur["chars"]["fontStyle"].as_str().unwrap_or_default().to_string(); + // The document's own fonts count too. + let db = designcraft_fonts::FontDb::global().scoped(s.doc()?.doc.font_scope); + let (weight, italic) = db.traits_of(&family, &style); + let on = p.get("on").and_then(Value::as_bool).unwrap_or(if bold { weight < 650.0 } else { !italic }); + let (want_bold, want_italic) = if bold { (Some(on), None) } else { (None, Some(on)) }; + let targets = format_targets(s); + // A caret: the linked style of the format there, for the next typing. + if let [t] = targets.as_slice() + && t.range.is_empty() + { + let linked = db.linked_style(&family, &style, want_bold, want_italic).ok_or_else(|| bad(id, format!("{family} has no style for that")))?; + format_chars(s, &json!({"fontStyle": linked}))?; + return Ok(json!({"on": on, "fontStyle": linked})); + } + let mut changed = 0; + let r = s.edit(|d, _| { + let styles = d.styles.clone(); + for t in &targets { + let Some(st) = d.text_story_mut(t.story, t.cell) else { continue }; + for (pi, pr) in st.para_ranges().into_iter().enumerate() { + let (a, b) = (pr.start.max(t.range.start), pr.end.min(t.range.end)); + if a >= b { + continue; + } + let Some(pf) = st.paras.get(pi) else { continue }; + let (_, pc) = styles.resolve_para(pf); + st.format_chars(a..b, |f| { + let cp = styles.resolve_char(&pc, f); + if let Some(linked) = db.linked_style(&cp.font_family, &cp.font_style, want_bold, want_italic) + && linked != cp.font_style + { + f.over.font_style = Some(linked); + changed += 1; + } + }); + } + } + // No undo step for nothing. + if changed == 0 { + return Err(bad(id, "nothing to change")); + } + ok() + }); + r?; + Ok(json!({"on": on, "changed": changed})) +} + /// Our own filler text (not Adobe's). pub const PLACEHOLDER: &str = "Ovid ellum quisque arcet velut tempora sint, ne veriora pareant laudemque fieri. Lorem novum strata ponet amicos, \ et tamen uti ferrum caelo tempus mollit. Quisque sinat oppida retro, nec tenuere longas animi semper vias. \ @@ -1363,6 +1682,202 @@ mod nested_style_tests { } } +#[cfg(test)] +mod multi_click_tests { + use serde_json::{Value, json}; + + use crate::Session; + + #[test] + fn moving_the_caret_drops_the_pending_typing_format() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": "Hello world"})).unwrap(); + let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap()); + let size_at = |s: &Session, b: usize| s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().char_format_at(b).over.size; + let pending = |s: &mut Session| { + s.execute("text.select", &json!({"story": sid, "anchor": 5})).unwrap(); + s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap(); + }; + // Away and back by arrow keys, by a click, by selecting and by double-clicking: typing at the + // same caret is in the text's own format. + let moves: Vec> = vec![ + Box::new(|s| { + s.execute("text.move", &json!({"dir": "right"})).unwrap(); + s.execute("text.move", &json!({"dir": "left"})).unwrap(); + }), + Box::new(|s| { + s.execute("text.placeCaret", &json!({"frame": fid, "point": [80, 80]})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 5})).unwrap(); + }), + Box::new(|s| { + s.execute("text.selectAt", &json!({"frame": fid, "point": [80, 80], "unit": "story"})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 5})).unwrap(); + }), + ]; + for m in &moves { + pending(&mut s); + m(&mut s); + s.execute("text.insert", &json!({"text": "x"})).unwrap(); + assert_eq!(size_at(&s, 6), None); + s.execute("edit.undo", &json!({})).unwrap(); + } + // Without a move it applies. + pending(&mut s); + s.execute("text.insert", &json!({"text": "x"})).unwrap(); + assert_eq!(size_at(&s, 6), Some(30.0)); + } + + #[test] + fn word_selection_keeps_grapheme_clusters_whole() { + use super::word_or_spaces_at as word; + // Decomposed "café": the combining acute belongs to the word, wherever the click lands. + let s = "un cafe\u{301} noir"; + let cafe = (3, 3 + "cafe\u{301}".len()); + assert_eq!(word(s, 4), cafe); + assert_eq!(word(s, 7), cafe, "on the combining mark"); + // Devanagari with a virama (U+094D) and a dependent vowel sign. + let s = "\u{928}\u{92E}\u{938}\u{94D}\u{924}\u{947} x"; + let w = "\u{928}\u{92E}\u{938}\u{94D}\u{924}\u{947}".len(); + assert_eq!(word(s, 0), (0, w)); + assert_eq!(word(s, 9), (0, w), "on the virama"); + // An emoji ZWJ sequence and a variation selector stay one character. + let s = "a \u{1F469}\u{200D}\u{1F4BB} b \u{263A}\u{FE0F}"; + let e = (2, 2 + "\u{1F469}\u{200D}\u{1F4BB}".len()); + assert_eq!(word(s, 2), e); + assert_eq!(word(s, 6), e, "on the joiner"); + assert_eq!(word(s, s.len() - 1), (s.len() - 6, s.len()), "at the end: the last character whole"); + // Empty paragraphs select nothing. + assert_eq!(word("a\n\nb", 2), (2, 2)); + } + + #[test] + fn clicks_select_words_spaces_lines_paragraphs_and_the_story() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let text = "Hello brave world.\nSecond paragraph here."; + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": text})).unwrap(); + let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap()); + // Spread point over the right half of the glyph at byte `b`. + let at = |s: &Session, b: usize| { + let d = &s.doc().unwrap().doc; + let cs = designcraft_compose::compose_story(d, designcraft_doc::StoryId(sid), &Default::default()); + let (l, g) = cs.frames[0].lines.iter().find_map(|l| l.glyphs.iter().find(|g| g.byte == b && g.len > 0).map(|g| (l, g))).unwrap(); + json!([g.x + g.adv * 0.75, l.baseline - 3.0]) + }; + let mut select = |unit: &str, b: usize| { + let p = at(&s, b); + let r: Value = s.execute("text.selectAt", &json!({"frame": fid, "point": p, "unit": unit})).unwrap(); + (r["anchor"].as_u64().unwrap() as usize, r["focus"].as_u64().unwrap() as usize) + }; + assert_eq!(select("word", 1), (0, 5), "inside a word"); + assert_eq!(select("word", 4), (0, 5), "its last letter"); + assert_eq!(select("word", 5), (5, 7), "the run of spaces after it"); + assert_eq!(select("word", 18), (18, 19), "punctuation alone"); + assert_eq!(select("line", 9), (0, 19)); + assert_eq!(select("paragraph", 9), (0, 20), "with its return"); + assert_eq!(select("paragraph", 22), (20, text.len())); + assert_eq!(select("story", 9), (0, text.len())); + // A second click inside the selected word selects again rather than starting a text drag. + s.execute("text.select", &json!({"story": sid, "anchor": 7, "focus": 12})).unwrap(); + let p = at(&s, 9); + s.execute("text.selectAt", &json!({"frame": fid, "point": p, "unit": "line"})).unwrap(); + let t = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((t.anchor, t.focus), (0, 19)); + s.execute("text.release", &json!({"frame": fid, "point": p, "moved": false})).unwrap(); + let t = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((t.anchor, t.focus), (0, 19), "releasing keeps it"); + // The older word command agrees. + let p = at(&s, 8); + s.execute("text.selectWord", &json!({"frame": fid, "point": p})).unwrap(); + let t = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((t.anchor, t.focus), (7, 12)); + assert!(s.execute("text.selectAt", &json!({"frame": fid, "point": p, "unit": "sentence"})).is_err()); + assert!(s.execute("text.selectAt", &json!({"frame": fid, "unit": "word"})).is_err(), "no point"); + } +} + +#[cfg(test)] +mod style_link_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn bold_and_italic_follow_the_family_and_toggle() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": "plain words here"})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + let style_at = |s: &mut Session, b: usize| { + s.execute("text.select", &json!({"story": sid, "anchor": b, "focus": b + 1})).unwrap(); + s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fontStyle"].as_str().unwrap().to_string() + }; + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 5})).unwrap(); + s.execute("type.italic", &json!({})).unwrap(); + assert_eq!(style_at(&mut s, 0), "Italic"); + // Bold over italic and plain text: each run takes its own linked style. + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 11})).unwrap(); + s.execute("type.bold", &json!({})).unwrap(); + assert_eq!(style_at(&mut s, 0), "Bold Italic"); + assert_eq!(style_at(&mut s, 7), "Bold"); + // Again: off, back to each run's regular weight. + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 11})).unwrap(); + s.execute("type.bold", &json!({})).unwrap(); + assert_eq!(style_at(&mut s, 0), "Italic"); + assert_eq!(style_at(&mut s, 7), "Regular"); + // Nothing to change: an error and no undo step. + s.execute("text.select", &json!({"story": sid, "anchor": 6, "focus": 11})).unwrap(); + let steps = s.doc().unwrap().history.undo.len(); + assert!(s.execute("type.bold", &json!({"on": false})).is_err()); + assert_eq!(s.doc().unwrap().history.undo.len(), steps); + // At a caret the style is for what is typed next. + let end = "plain words here".len(); + s.execute("text.select", &json!({"story": sid, "anchor": end, "focus": end})).unwrap(); + s.execute("type.bold", &json!({})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fontStyle"], "Bold"); + s.execute("text.insert", &json!({"text": " now"})).unwrap(); + assert_eq!(style_at(&mut s, end + 1), "Bold"); + assert_eq!(style_at(&mut s, end - 1), "Regular", "text before the caret is unchanged"); + // Moving the caret forgets it. + s.execute("text.select", &json!({"story": sid, "anchor": 2, "focus": 2})).unwrap(); + s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 3, "focus": 3})).unwrap(); + s.execute("text.insert", &json!({"text": "x"})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 3, "focus": 4})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"], 12.0); + } +} + +#[cfg(test)] +mod text_color_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn unnamed_colours_go_to_text_when_asked() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "colour me", "caret": false})).unwrap(); + let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap()); + s.execute("selection.set", &json!({"ids": [fid]})).unwrap(); + let fill_of_frame = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(fid)).unwrap().fill.swatch.clone(); + let before = fill_of_frame(&s); + let r = s.execute("object.color", &json!({"color": "#ff0000", "text": true})).unwrap(); + let red = r["swatch"].as_str().unwrap().to_string(); + assert_eq!(fill_of_frame(&s), before, "the frame keeps its fill"); + let story = s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().clone(); + assert_eq!(story.char_format_at(3).over.fill.as_deref(), Some(red.as_str())); + // A text selection takes it without asking. + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 3})).unwrap(); + let r = s.execute("object.color", &json!({"color": "#00ff00", "target": "stroke"})).unwrap(); + let story = s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().clone(); + assert_eq!(story.format_after(0).over.stroke.as_deref(), r["swatch"].as_str()); + assert_eq!(s.doc().unwrap().history.undo.len(), 3, "the frame, then one undo step per colour"); + } +} + #[cfg(test)] mod drag_text_tests { use serde_json::json; @@ -2009,7 +2524,7 @@ mod story_query_contract_tests { } #[cfg(test)] -mod multi_click_tests { +mod sentence_selection_tests { use super::{para_bounds, sentence_bounds}; fn sentence(s: &str, at: &str) -> String { diff --git a/crates/engine/src/cmd/text_color_tests.rs b/crates/engine/src/cmd/text_color_tests.rs new file mode 100644 index 0000000..5f2c357 --- /dev/null +++ b/crates/engine/src/cmd/text_color_tests.rs @@ -0,0 +1,181 @@ +//! Cross-command caret color and character-style regressions. +use crate::Session; +use designcraft_doc::StoryId; +use serde_json::{Value, json}; + +fn setup(text: &str) -> (Session, u64) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let f = s.execute("frame.create", &json!({"rect": [36,36,300,200], "content":"text", "text":text})).unwrap(); + let sid = f["story"].as_u64().unwrap(); + s.execute("text.select", &json!({"story":sid,"anchor":text.len()})).unwrap(); + (s, sid) +} +fn attrs(s: &mut Session) -> Value { + s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"].clone() +} +#[test] +fn integration_color_character_style_captures_pending_and_null() { + let (mut s, sid) = setup("hello"); + s.execute("type.char", &json!({"size":30,"tracking":40,"fill":"[Paper]"})).unwrap(); + s.execute("style.character.create", &json!({"name":"Pending","fromSelection":true,"chars":{"tracking":null}})).unwrap(); + let a = &s.doc().unwrap().doc.styles.char_style("Pending").unwrap().chars; + assert_eq!(a.size, Some(30.0)); + assert_eq!(a.fill.as_deref(), Some("[Paper]")); + assert_eq!(a.tracking, None); + assert_eq!(s.doc().unwrap().doc.story(StoryId(sid)).unwrap().text, "hello"); +} +#[test] +fn integration_color_pending_follows_promoted_swatch() { + for target in ["fill", "stroke"] { + let (mut s, sid) = setup("hello"); + let old = s.execute("object.color", &json!({"color":"#123456","target":target})).unwrap()["swatch"].clone(); + s.execute("swatch.addToSwatches", &json!({"swatch":old,"name":"Brand"})).unwrap(); + assert_eq!(attrs(&mut s)[target], "Brand"); + s.execute("text.insert", &json!({"text":"X"})).unwrap(); + let st = s.doc().unwrap().doc.story(StoryId(sid)).unwrap(); + let f = &st.format_after(5).over; + assert_eq!(if target == "fill" { f.fill.as_deref() } else { f.stroke.as_deref() }, Some("Brand")); + } +} +#[test] +fn integration_color_undo_discards_missing_pending_swatch() { + let (mut s, sid) = setup("hello"); + let old = s.execute("object.color", &json!({"color":"#123456"})).unwrap()["swatch"].as_str().unwrap().to_string(); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.swatch(&old).is_none()); + assert_ne!(attrs(&mut s)["fill"], old); + s.execute("text.insert", &json!({"text":"X"})).unwrap(); + assert_ne!(s.doc().unwrap().doc.story(StoryId(sid)).unwrap().format_after(5).over.fill.as_deref(), Some(old.as_str())); +} +#[test] +fn integration_color_empty_story_uses_one_undo_step() { + let (mut s, sid) = setup(""); + let before = serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap(); + let n = s.doc().unwrap().history.undo.len(); + s.execute("object.color", &json!({"color":"#123456"})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), n + 1); + let colored = serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap(), before); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap(), colored); + s.execute("text.insert", &json!({"text":"X"})).unwrap(); + assert!(s.doc().unwrap().doc.story(StoryId(sid)).unwrap().format_after(0).over.fill.is_some()); +} + +#[test] +fn integration_color_swap_resolves_each_paragraph_and_keeps_unselected_text() { + let (mut s, sid) = setup("ab\ncd"); + for (name, fill, stroke, ft, st, start, end) in + [("First", "[Paper]", "[Black]", 0.2, 0.8, 0, 2), ("Second", "[Black]", "[Paper]", 0.4, 0.6, 3, 5)] + { + s.execute("style.paragraph.create", &json!({"name":name,"chars":{"fill":fill,"stroke":stroke,"fillTint":ft,"strokeTint":st}})).unwrap(); + s.execute("text.select", &json!({"story":sid,"anchor":start,"focus":end})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name":name})).unwrap(); + } + s.execute("text.select", &json!({"story":sid,"anchor":1,"focus":4})).unwrap(); + let before = serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap(); + let n = s.doc().unwrap().history.undo.len(); + s.execute("type.swapFillStroke", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), n + 1); + for (pos, fill, stroke, ft, st) in [ + (0, "[Paper]", "[Black]", 0.2, 0.8), + (1, "[Black]", "[Paper]", 0.8, 0.2), + (2, "[Black]", "[Paper]", 0.8, 0.2), + (3, "[Paper]", "[Black]", 0.6, 0.4), + (4, "[Black]", "[Paper]", 0.4, 0.6), + ] { + s.execute("text.select", &json!({"story":sid,"anchor":pos,"focus":pos+1})).unwrap(); + let a = attrs(&mut s); + assert_eq!(a["fill"], fill); + assert_eq!(a["stroke"], stroke); + assert!((a["fillTint"].as_f64().unwrap() - ft).abs() < 1e-6); + assert!((a["strokeTint"].as_f64().unwrap() - st).abs() < 1e-6); + } + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap(), before); +} + +#[test] +fn integration_color_swap_caret_applies_only_to_next_typing() { + let (mut s, sid) = setup("hello"); + s.execute("type.char", &json!({"fill":"[Paper]","stroke":"[Black]","fillTint":0.2,"strokeTint":0.8})).unwrap(); + let before = serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap(); + s.execute("type.swapFillStroke", &json!({})).unwrap(); + assert_eq!(serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap(), before); + let a = attrs(&mut s); + assert_eq!(a["fill"], "[Black]"); + assert_eq!(a["stroke"], "[Paper]"); + s.execute("text.insert", &json!({"text":"X"})).unwrap(); + let st = s.doc().unwrap().doc.story(StoryId(sid)).unwrap(); + assert_eq!(st.text, "helloX"); + assert_eq!(st.format_after(0).over.fill, None); + assert_eq!(st.format_after(5).over.stroke.as_deref(), Some("[Paper]")); + assert_eq!(st.format_after(5).over.fill_tint, Some(0.8)); +} + +#[test] +fn integration_color_swap_selected_empty_cells_keeps_each_format() { + let (mut s, sid) = setup("hello"); + let result = s.execute("table.insert", &json!({"rows":1,"cols":2})).unwrap(); + let tid = result["table"].as_u64().unwrap(); + s.edit(|d, _| { + let table = std::sync::Arc::make_mut(d.story_mut(StoryId(sid)).unwrap().tables.get_mut(&tid).unwrap()); + for (c, fill, stroke) in [(0, "[Paper]", "[Black]"), (1, "[Black]", "[Paper]")] { + let attrs = &mut table.cell_mut(0, c).unwrap().text.chars[0].format.over; + attrs.fill = Some(fill.into()); + attrs.stroke = Some(stroke.into()); + } + Ok(()) + }) + .unwrap(); + s.execute("table.select", &json!({"story":sid,"table":tid,"what":"table"})).unwrap(); + let before = serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap(); + s.execute("type.swapFillStroke", &json!({})).unwrap(); + let table = &s.doc().unwrap().doc.story(StoryId(sid)).unwrap().tables[&tid]; + assert_eq!(table.cell(0, 0).unwrap().text.chars[0].format.over.fill.as_deref(), Some("[Black]")); + assert_eq!(table.cell(0, 1).unwrap().text.chars[0].format.over.fill.as_deref(), Some("[Paper]")); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap(), before); +} + +#[test] +fn integration_color_failed_promotion_preserves_pending_and_history() { + let (mut s, _) = setup("hello"); + s.execute("swatch.create", &json!({"name":"Brand","color":"#654321"})).unwrap(); + let old = s.execute("object.color", &json!({"color":"#123456"})).unwrap()["swatch"].clone(); + let before = s.doc().unwrap().clone(); + let pending = attrs(&mut s); + assert!(s.execute("swatch.addToSwatches", &json!({"swatch":old,"name":"Brand"})).is_err()); + assert_eq!(attrs(&mut s), pending); + assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &before.doc)); + assert_eq!(s.doc().unwrap().history.undo.len(), before.history.undo.len()); +} + +#[test] +fn integration_color_promote_undo_redo_never_reuses_a_missing_pending_name() { + let (mut s, _) = setup("hello"); + let old = s.execute("object.color", &json!({"color":"#123456"})).unwrap()["swatch"].clone(); + s.execute("swatch.addToSwatches", &json!({"swatch":old,"name":"Brand"})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + let fill = attrs(&mut s)["fill"].as_str().unwrap().to_string(); + assert!(s.doc().unwrap().doc.swatch(&fill).is_some()); + s.execute("edit.redo", &json!({})).unwrap(); + let fill = attrs(&mut s)["fill"].as_str().unwrap().to_string(); + assert!(s.doc().unwrap().doc.swatch(&fill).is_some()); +} + +#[test] +fn integration_color_swap_validates_new_fill_before_any_edit() { + let (mut s, sid) = setup("hello"); + s.execute("text.select", &json!({"story":sid,"anchor":0,"focus":5})).unwrap(); + s.execute("type.char", &json!({"fill":"[Paper]","stroke":"Missing Ink"})).unwrap(); + let before = s.doc().unwrap().clone(); + let journal = s.journal.clone(); + let error = s.execute("type.swapFillStroke", &json!({})).unwrap_err().to_string(); + assert!(error.contains("Missing Ink"), "{error}"); + assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &before.doc)); + assert_eq!(s.doc().unwrap().history.undo.len(), before.history.undo.len()); + assert_eq!(s.journal, journal); +} diff --git a/crates/engine/src/cmd/thread.rs b/crates/engine/src/cmd/thread.rs new file mode 100644 index 0000000..bdc2ffa --- /dev/null +++ b/crates/engine/src/cmd/thread.rs @@ -0,0 +1,396 @@ +//! Threading text frames: thread a frame after another (their stories merge), break a thread, +//! take a frame out of its thread. The Selection tool's loaded text cursor runs these. + +use designcraft_doc::{Document, ItemId, ParaFormat, Selection, StoryId}; +use designcraft_geom::Rect; +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, bool_or, cmd, has_doc, id_param, rect_param, spread_param, str_param}; +use crate::{Result, Session}; + +/// Pages Shift-click autoflow may add before it gives up. +const AUTOFLOW_MAX_PAGES: usize = 2000; + +pub fn specs() -> Vec { + 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"))); + } + check_editable(&st.doc, id, cmd)?; + Ok(id) +} + +fn check_editable(d: &Document, id: ItemId, cmd: &str) -> Result<()> { + let it = d.item(id).ok_or(designcraft_doc::DocError::NoItem(id))?; + if it.locked || d.layer(it.layer).is_some_and(|l| l.locked) { + return Err(bad(cmd, format!("{id} is locked or on a locked layer"))); + } + Ok(()) +} + +/// A rect param made safe for a frame: finite, ordered, at least half a point each way. +fn frame_rect(p: &Value) -> Result> { + 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"))?; + if d.layer(layer).is_none_or(|l| l.locked || !l.visible) { + return Err(bad("text.thread", "the active layer is locked or hidden")); + } + let para = ParaFormat { style: d.styles.default_paragraph.clone(), ..Default::default() }; + Ok(d.add_text_frame(sr, r, layer, "", para)?.0) + }; + let (from, to, joined) = match (from, to) { + (Some(f), Some(t)) if rect.is_none() => (f, t, t), + (Some(f), None) => { + let t = new_frame()?; + (f, t, t) + } + (None, Some(t)) => { + let f = new_frame()?; + (f, t, f) + } + _ => return Err(bad("text.thread", "give two of from, to and rect")), + }; + d.check_thread(from, to)?; + d.thread_keeping_story(from, to)?; + let sid = d.item(from).and_then(|i| i.text_frame()).map(|t| t.story).ok_or_else(|| bad("text.thread", "threading lost the story"))?; + let pages = if autoflow { super::place_text_autoflow(d, sid, AUTOFLOW_MAX_PAGES, "text.thread")? } else { 0 }; + *sel = Selection::items(vec![joined]); + Ok(json!({"story": sid.0, "from": from.0, "to": to.0, "overset": overset(d, sid), "pagesAdded": pages})) + }) +} + +fn overset(d: &Document, sid: StoryId) -> bool { + designcraft_compose::compose_story(d, sid, &Default::default()).overset_at.is_some() +} + +fn unthread(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "text.unthread"; + let frame = frame_param(s, p, CMD)?; + let d0 = &s.doc()?.doc; + let at = match str_param(p, "side").unwrap_or("after") { + "after" => d0.next_frame(frame).map(|_| frame).ok_or_else(|| bad(CMD, format!("no frame follows {frame} in its thread")))?, + "before" => d0.prev_frame(frame).ok_or_else(|| bad(CMD, format!("no frame comes before {frame} in its thread")))?, + other => return Err(bad(CMD, format!("unknown side `{other}` (after, before)"))), + }; + check_editable(d0, at, CMD)?; + if let Some(next) = d0.next_frame(at) { + check_editable(d0, next, CMD)?; + } + s.edit(|d, _| { + d.unthread_after(at)?; + let sid = d.item(at).and_then(|i| i.text_frame()).map(|t| t.story).ok_or_else(|| bad(CMD, "no story"))?; + Ok(json!({"story": sid.0, "overset": overset(d, sid)})) + }) +} + +fn remove_from_thread(s: &mut Session, p: &Value) -> Result { + let frame = frame_param(s, p, "text.removeFromThread")?; + let d = &s.doc()?.doc; + for neighbour in [d.prev_frame(frame), d.next_frame(frame)].into_iter().flatten() { + check_editable(d, neighbour, "text.removeFromThread")?; + } + s.edit(|d, _| { + d.remove_from_thread(frame)?; + Ok(json!({})) + }) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::Session; + + fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 2})).unwrap(); + s + } + + fn text_frame(s: &mut Session, rect: [f64; 4], text: &str) -> (u64, u64) { + let r = s.execute("frame.create", &json!({"rect": rect, "content": "text", "text": text, "caret": false})).unwrap(); + (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap()) + } + + fn story(s: &mut Session, frame: u64) -> serde_json::Value { + s.execute("story.get", &json!({"frame": frame})).unwrap() + } + + fn frames(s: &mut Session, frame: u64) -> Vec { + story(s, frame)["frames"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect() + } + + const LONG: &str = "Threaded text keeps flowing from one frame into the next one. "; + + #[test] + fn thread_into_an_empty_frame_and_undo() { + let mut s = session(); + let (a, sid) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &LONG.repeat(10)); + let b = s.execute("frame.create", &json!({"rect": [72, 300, 200, 400], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + assert!(!story(&mut s, a)["overset"].is_null(), "the first frame is overset"); + let r = s.execute("text.thread", &json!({"from": a, "to": b})).unwrap(); + assert_eq!(r["story"].as_u64(), Some(sid)); + assert_eq!(frames(&mut s, a), [a, b]); + let d = &s.doc().unwrap().doc; + assert!(d.item(designcraft_doc::ItemId(b)).unwrap().is_text_frame()); + assert_eq!(s.doc().unwrap().selection.items, [designcraft_doc::ItemId(b)]); + // The text now reaches the second frame. + let cs = designcraft_compose::compose_story(&s.doc().unwrap().doc, designcraft_doc::StoryId(sid), &Default::default()); + assert!(cs.frames.iter().any(|f| f.frame.0 == b && !f.range.is_empty()), "text flows into the new frame"); + s.doc().unwrap().doc.check().unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(frames(&mut s, a), [a]); + assert!(!s.doc().unwrap().doc.item(designcraft_doc::ItemId(b)).unwrap().is_text_frame()); + } + + #[test] + fn thread_into_a_text_frame_merges_its_story() { + let mut s = session(); + let (a, sa) = text_frame(&mut s, [72.0, 72.0, 200.0, 200.0], "First"); + let (b, sb) = text_frame(&mut s, [72.0, 300.0, 200.0, 400.0], "Second"); + s.execute("text.thread", &json!({"from": a, "to": b})).unwrap(); + let st = story(&mut s, b); + assert_eq!(st["id"].as_u64(), Some(sa)); + assert_eq!(st["text"], "First\nSecond"); + assert!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sb)).is_none()); + s.doc().unwrap().doc.check().unwrap(); + } + + #[test] + fn thread_into_the_middle_of_a_thread() { + let mut s = session(); + let (a, _) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &LONG.repeat(8)); + let (c, _) = text_frame(&mut s, [72.0, 500.0, 200.0, 600.0], ""); + s.execute("text.thread", &json!({"from": a, "to": c})).unwrap(); + let (b, _) = text_frame(&mut s, [72.0, 300.0, 200.0, 400.0], ""); + s.execute("text.thread", &json!({"from": a, "to": b})).unwrap(); + assert_eq!(frames(&mut s, a), [a, b, c]); + s.doc().unwrap().doc.check().unwrap(); + } + + #[test] + fn bad_threads_are_errors_and_change_nothing() { + let mut s = session(); + let (a, _) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], "a"); + let (b, _) = text_frame(&mut s, [72.0, 300.0, 200.0, 400.0], "b"); + let (x, _) = text_frame(&mut s, [300.0, 72.0, 400.0, 120.0], "x"); + let (y, _) = text_frame(&mut s, [300.0, 300.0, 400.0, 400.0], "y"); + let g = s.execute("line.create", &json!({"a": [300, 500], "b": [400, 600]})).unwrap()["id"].as_u64().unwrap(); + let group = { + let p = s.execute("frame.create", &json!({"rect": [500, 500, 550, 550], "content": "unassigned"})).unwrap()["id"].clone(); + let q = s.execute("frame.create", &json!({"rect": [560, 500, 600, 550], "content": "unassigned"})).unwrap()["id"].clone(); + s.execute("selection.set", &json!({"ids": [p, q]})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + s.doc().unwrap().selection.items[0].0 + }; + s.execute("text.thread", &json!({"from": a, "to": b})).unwrap(); + s.execute("text.thread", &json!({"from": x, "to": y})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let doc = s.doc().unwrap().doc.clone(); + for p in [ + json!({"from": a, "to": a}), + json!({"from": a, "to": g}), + json!({"from": g, "to": a}), + json!({"from": a, "to": group}), + // Earlier in the same thread. + json!({"from": b, "to": a}), + // Text already flows into y from x. + json!({"from": a, "to": y}), + json!({"from": a, "to": 999_999}), + json!({"from": a}), + json!({"from": a, "to": b, "rect": [0, 0, 10, 10]}), + json!({"rect": [0, 0, 10, 10]}), + json!({"from": a, "rect": [0, 0, 10, 10], "spread": 99}), + json!({"from": "a", "to": [1]}), + json!([1, 2]), + ] { + assert!(s.execute("text.thread", &p).is_err(), "{p}"); + } + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + assert_eq!(*s.doc().unwrap().doc, *doc); + } + + #[test] + fn locked_frames_and_layers_refuse_threads() { + let mut s = session(); + let (a, _) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &LONG.repeat(8)); + let (b, _) = text_frame(&mut s, [72.0, 300.0, 200.0, 400.0], ""); + let layer = s.doc().unwrap().active_layer.0; + let undo = s.doc().unwrap().history.undo.len(); + s.execute("object.lock", &json!({"ids": [b]})).unwrap(); + assert!(s.execute("text.thread", &json!({"from": a, "to": b})).is_err(), "locked frame"); + assert!(s.execute("text.thread", &json!({"from": b, "to": a})).is_err(), "locked frame"); + s.execute("edit.undo", &json!({})).unwrap(); + for (set, rect) in [(json!({"locked": true}), false), (json!({"locked": true}), true), (json!({"visible": false}), true)] { + let mut p = json!({"id": layer}); + p.as_object_mut().unwrap().extend(set.as_object().unwrap().clone()); + s.execute("layer.set", &p).unwrap(); + let t = if rect { json!({"from": a, "rect": [300, 300, 400, 400]}) } else { json!({"from": a, "to": b}) }; + assert!(s.execute("text.thread", &t).is_err(), "{p} {t}"); + s.execute("edit.undo", &json!({})).unwrap(); + } + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + assert_eq!(frames(&mut s, a), [a]); + } + + #[test] + fn thread_a_new_frame_after_or_in_front() { + let mut s = session(); + let (a, sa) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &LONG.repeat(8)); + let r = s.execute("text.thread", &json!({"from": a, "rect": [72, 300, 200, 400]})).unwrap(); + let n = r["to"].as_u64().unwrap(); + assert_eq!(frames(&mut s, a), [a, n]); + assert_eq!(s.doc().unwrap().selection.items, [designcraft_doc::ItemId(n)]); + let b = s.doc().unwrap().doc.item(designcraft_doc::ItemId(n)).unwrap().bounds(); + assert_eq!(b, Rect::new(72.0, 300.0, 200.0, 400.0)); + // In front of the first frame: the story keeps its id and text. + let r = s.execute("text.thread", &json!({"rect": [300, 72, 400, 120], "to": a})).unwrap(); + let m = r["from"].as_u64().unwrap(); + assert_eq!(r["story"].as_u64(), Some(sa)); + assert_eq!(frames(&mut s, a), [m, a, n]); + assert_eq!(story(&mut s, a)["text"], LONG.repeat(8)); + s.doc().unwrap().doc.check().unwrap(); + // One undo step each. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(frames(&mut s, a), [a, n]); + } + + use designcraft_geom::Rect; + + #[test] + fn thread_with_autoflow_adds_pages_until_the_text_fits() { + let mut s = session(); + let (a, _) = text_frame(&mut s, [72.0, 72.0, 300.0, 200.0], &LONG.repeat(300)); + let r = s.execute("text.thread", &json!({"from": a, "rect": [72, 300, 300, 700], "autoflow": true})).unwrap(); + assert_eq!(r["overset"], false); + assert!(r["pagesAdded"].as_u64().unwrap() >= 1, "{r}"); + assert!(frames(&mut s, a).len() >= 3); + s.doc().unwrap().doc.check().unwrap(); + // One undo removes the frames and the pages. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(frames(&mut s, a), [a]); + assert_eq!(s.doc().unwrap().doc.page_count(), 2); + } + + #[test] + fn autoflow_from_a_parent_frame_is_an_error() { + let mut s = session(); + let parent = json!({"kind": "parent", "index": 0}); + let r = s.execute( + "frame.create", + &json!({"rect": [72, 72, 300, 200], "content": "text", "text": LONG.repeat(300), "caret": false, "spread": parent}), + ); + let a = r.unwrap()["id"].as_u64().unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let doc = s.doc().unwrap().doc.clone(); + let e = s.execute("text.thread", &json!({"from": a, "rect": [72, 300, 300, 700], "spread": parent, "autoflow": true})).unwrap_err(); + assert!(e.to_string().contains("text.thread"), "{e}"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + assert_eq!(*s.doc().unwrap().doc, *doc); + assert_eq!(s.doc().unwrap().doc.page_count(), 2); + } + + #[test] + fn unthread_after_and_before() { + let mut s = session(); + let text = LONG.repeat(12); + let (a, sa) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &text); + let (b, _) = text_frame(&mut s, [72.0, 200.0, 200.0, 250.0], ""); + let (c, _) = text_frame(&mut s, [72.0, 300.0, 200.0, 350.0], ""); + s.execute("text.thread", &json!({"from": a, "to": b})).unwrap(); + s.execute("text.thread", &json!({"from": b, "to": c})).unwrap(); + // Break after the first frame: b and c stay threaded to each other, empty. + let r = s.execute("text.unthread", &json!({"frame": a})).unwrap(); + assert_eq!(r["story"].as_u64(), Some(sa)); + assert_eq!(r["overset"], true); + assert_eq!(frames(&mut s, a), [a]); + assert_eq!(story(&mut s, a)["text"], text); + assert_eq!(frames(&mut s, b), [b, c]); + assert_eq!(story(&mut s, b)["text"], ""); + s.doc().unwrap().doc.check().unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(frames(&mut s, a), [a, b, c]); + // Break before the last frame, with the frame selected. + s.execute("selection.set", &json!({"ids": [c]})).unwrap(); + s.execute("text.unthread", &json!({"side": "before"})).unwrap(); + assert_eq!(frames(&mut s, a), [a, b]); + assert_eq!(frames(&mut s, c), [c]); + // Nothing to break. + assert!(s.execute("text.unthread", &json!({"frame": b})).is_err()); + assert!(s.execute("text.unthread", &json!({"frame": a, "side": "before"})).is_err()); + assert!(s.execute("text.unthread", &json!({"frame": a, "side": "sideways"})).is_err()); + assert!(s.execute("text.unthread", &json!({"frame": 999_999})).is_err()); + } + + #[test] + fn deleting_or_removing_a_threaded_frame_keeps_the_text_flowing() { + let mut s = session(); + let text = LONG.repeat(12); + let (a, _) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &text); + let (b, _) = text_frame(&mut s, [72.0, 200.0, 200.0, 250.0], ""); + let (c, _) = text_frame(&mut s, [72.0, 300.0, 200.0, 350.0], ""); + s.execute("text.thread", &json!({"from": a, "to": b})).unwrap(); + s.execute("text.thread", &json!({"from": b, "to": c})).unwrap(); + s.execute("text.removeFromThread", &json!({"frame": b})).unwrap(); + assert_eq!(frames(&mut s, a), [a, c]); + assert_eq!(story(&mut s, a)["text"], text); + assert_eq!(story(&mut s, b)["text"], ""); + assert!(s.execute("text.removeFromThread", &json!({"frame": b})).is_err()); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.clear", &json!({"ids": [b]})).unwrap(); + assert_eq!(frames(&mut s, a), [a, c]); + assert_eq!(story(&mut s, a)["text"], text); + s.doc().unwrap().doc.check().unwrap(); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index caeeb80..900bd88 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -276,6 +276,8 @@ pub struct Session { pub(crate) transforms: (Vec<(String, Value)>, Vec, 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). @@ -307,6 +309,7 @@ impl Session { loaded: None, transforms: Default::default(), text_drag: false, + typing_format: None, library: None, conveyor: Vec::new(), book: None, @@ -475,7 +478,7 @@ impl Session { } let mut d = (*st.doc).clone(); if overset { - let _ = cmd::place_text_autoflow(&mut d, sid, 500); + let _ = cmd::place_text_autoflow(&mut d, sid, 500, "Smart Text Reflow"); } else { // Drop empty trailing frames on the last pages (one frame, nothing else there). while let Some(story) = d.story(sid) { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 17d1ed0..9d63089 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -569,3 +569,103 @@ fn commands_take_non_object_params_without_panicking() { assert!(r.is_ok(), "{p}: {r:?}"); } } + +/// Load the text from a frame's out port with the Selection tool and click on the pasteboard: +/// a new frame is threaded there and the overset text flows into it. +#[test] +fn threading_with_the_loaded_text_cursor_on_the_sample() { + use designcraft_tools::{PointerEvent, PointerKind}; + let mut s = Session::new(); + s.execute("file.newSample", &json!({})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let (sid, first) = d.stories.values().find(|st| st.frames.len() >= 2).map(|st| (st.id, st.frames[0])).expect("a threaded story"); + // Break the thread so the first frame is overset, and select it. + s.execute("text.unthread", &json!({"frame": first.0})).unwrap(); + assert!(s.cache.get(&s.doc().unwrap().doc, sid, None).is_overset()); + s.execute("selection.set", &json!({"ids": [first.0]})).unwrap(); + s.set_tool("selection"); + let v = ViewInfo::at_zoom(1.0); + let layout = s.layout(); + let port = designcraft_tools::thread::port_center(&s.doc().unwrap().doc, &layout, first, true, v.zoom).unwrap(); + let ev = |k, p: designcraft_geom::Point| PointerEvent::new(k, p.x, p.y); + s.pointer(&ev(PointerKind::Down, port), v).unwrap(); + s.pointer(&ev(PointerKind::Up, port), v).unwrap(); + assert_eq!(s.cursor(port + designcraft_geom::Vec2::new(-2000.0, 0.0), Default::default(), v), designcraft_tools::Cursor::LoadedText); + // On the pasteboard left of the first spread. + let b = layout.slots[0].bounds; + let at = designcraft_geom::Point::new(b.x0 - 300.0, b.y0 + 100.0); + let undo = s.doc().unwrap().history.undo.len(); + s.pointer(&ev(PointerKind::Down, at), v).unwrap(); + s.pointer(&ev(PointerKind::Up, at), v).unwrap(); + let st = s.doc().unwrap(); + assert_eq!(st.history.undo.len(), undo + 1, "one undo step"); + let frames = st.doc.story(sid).unwrap().frames.clone(); + assert_eq!(frames.len(), 2); + let new = frames[1]; + assert_eq!(st.selection.items, [new]); + let cs = s.cache.get(&s.doc().unwrap().doc, sid, None); + assert!(cs.frames.iter().any(|f| f.frame == new && !f.range.is_empty()), "the text flows into the new frame"); + let nb = s.doc().unwrap().doc.item(new).unwrap().bounds(); + assert!((nb.y0 - layout.to_spread(designcraft_doc::SpreadRef::Doc(0), at).y).abs() < 1e-6, "{nb:?}"); +} + +/// Drag a selection handle of item 0 on spread 0 from the edge point `at` by `by` (canvas units). +fn drag_handle(s: &mut Session, at: designcraft_geom::Point, by: designcraft_geom::Vec2, v: ViewInfo) { + use designcraft_tools::{PointerEvent, PointerKind}; + let p = s.layout().to_canvas(designcraft_doc::SpreadRef::Doc(0), at); + s.pointer(&PointerEvent::new(PointerKind::Down, p.x, p.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, p.x + by.x / 2.0, p.y + by.y / 2.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, p.x + by.x, p.y + by.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, p.x + by.x, p.y + by.y), v).unwrap(); +} + +#[test] +fn text_frame_resize_snaps_to_the_margins() { + use designcraft_geom::{Point, Vec2}; + for zoom in [1.0, 0.5] { + let mut s = Session::new(); + s.execute("file.newSample", &json!({})).unwrap(); + let margin = s.doc().unwrap().doc.spreads[0].pages[0].margin_rect(); + let (x0, y0) = (margin.x0 + 40.0, margin.y0 + 40.0); + s.execute("frame.create", &json!({"rect": [x0, y0, x0 + 200.0, y0 + 200.0], "content": "text"})).unwrap(); + s.set_tool("selection"); + let v = ViewInfo::at_zoom(zoom); + let b0 = s.doc().unwrap().doc.spreads[0].items.last().unwrap().bounds(); + // Right handle to 1.5 pt short of the right margin. + drag_handle(&mut s, Point::new(b0.x1, b0.center().y), Vec2::new(margin.x1 - 1.5 - b0.x1, 0.0), v); + let b = s.doc().unwrap().doc.spreads[0].items.last().unwrap().bounds(); + assert!((b.x1 - margin.x1).abs() < 1e-6, "zoom {zoom}: right edge {} not on the margin {}", b.x1, margin.x1); + // Bottom handle to 1.5 pt past the bottom margin. + drag_handle(&mut s, Point::new(b.center().x, b.y1), Vec2::new(0.0, margin.y1 + 1.5 - b.y1), v); + let b = s.doc().unwrap().doc.spreads[0].items.last().unwrap().bounds(); + assert!((b.y1 - margin.y1).abs() < 1e-6, "zoom {zoom}: bottom edge {} not on the margin {}", b.y1, margin.y1); + // Top-left corner handle onto the top and left margins. + drag_handle(&mut s, Point::new(b.x0, b.y0), Vec2::new(margin.x0 + 1.0 - b.x0, margin.y0 - 1.0 - b.y0), v); + let b = s.doc().unwrap().doc.spreads[0].items.last().unwrap().bounds(); + assert!((b.x0 - margin.x0).abs() < 1e-6 && (b.y0 - margin.y0).abs() < 1e-6, "zoom {zoom}: corner {b:?} not on the margins {margin:?}"); + } +} + +#[test] +fn type_tool_frames_snap_to_the_margins() { + use designcraft_geom::Point; + use designcraft_tools::{PointerEvent, PointerKind}; + let mut s = session(); + let margin = s.doc().unwrap().doc.spreads[0].pages[0].margin_rect(); + s.set_tool("type"); + let v = ViewInfo::at_zoom(1.0); + let c = |s: &Session, x: f64, y: f64| s.layout().to_canvas(designcraft_doc::SpreadRef::Doc(0), Point::new(x, y)); + // From 1.5 pt off the top-left margin corner to 1.5 pt off the right margin. + let a = c(&s, margin.x0 + 1.5, margin.y0 + 1.5); + let b = c(&s, margin.x1 - 1.5, margin.y0 + 101.5); + let before = s.doc().unwrap().doc.spreads[0].items.len(); + s.pointer(&PointerEvent::new(PointerKind::Down, a.x, a.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, (a.x + b.x) / 2.0, b.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, b.x, b.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, b.x, b.y), v).unwrap(); + let items = &s.doc().unwrap().doc.spreads[0].items; + assert_eq!(items.len(), before + 1, "a frame was drawn"); + let r = items.last().unwrap().bounds(); + assert!((r.x0 - margin.x0).abs() < 1e-6 && (r.y0 - margin.y0).abs() < 1e-6, "press point {r:?} vs {margin:?}"); + assert!((r.x1 - margin.x1).abs() < 1e-6, "right edge {} vs {}", r.x1, margin.x1); +} diff --git a/crates/engine/src/tests_idml.rs b/crates/engine/src/tests_idml.rs index cc40768..f16d3ef 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(); @@ -251,3 +336,30 @@ fn named_lists_round_trip_through_idml() { assert_eq!(st.paras[0].para.list_name.as_deref(), Some("Steps")); assert_eq!(st.paras[0].para.start_at, Some(Some(5))); } + +#[test] +fn type_para_sets_a_drop_cap_that_survives_idml() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("style.character.create", &json!({"name": "Initial", "chars": {"fontStyle": "Bold"}})).unwrap(); + let text = "Once upon a time there was a paragraph long enough to run beside its drop cap for a few lines and then some more."; + let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 400], "content": "text", "text": text})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 3, "focus": 3})).unwrap(); + s.execute("type.para", &json!({"attrs": {"dropCapLines": 3, "dropCapChars": 1, "dropCapStyle": "Initial", "dropCapAlignLeft": false}})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let cs = designcraft_compose::compose_story(&d, designcraft_doc::StoryId(sid), &Default::default()); + let lines = &cs.frames[0].lines; + let dc = &lines[0].glyphs[0]; + assert_eq!(dc.byte, 0); + assert!((lines[0].baseline + dc.y - lines[2].baseline).abs() < 1e-6, "on line 3's baseline"); + assert!(lines[0].drop_cap.is_some_and(|b| b.end == 1)); + let p = &d.story(designcraft_doc::StoryId(sid)).unwrap().paras[0].para; + assert_eq!((p.drop_cap_lines, p.drop_cap_chars, p.drop_cap_style.as_deref()), (Some(3), Some(1), Some("Initial"))); + let back = designcraft_idml::import_idml(&designcraft_idml::export_idml(&d)).unwrap(); + let p = &back.stories.values().find(|st| st.text.starts_with("Once")).unwrap().paras[0].para; + assert_eq!((p.drop_cap_lines, p.drop_cap_chars, p.drop_cap_align_left), (Some(3), Some(1), Some(false))); + // The drop cap's character style goes through IDML as a leading Dropcap nested style. + assert_eq!(p.drop_cap_style.as_deref(), Some("Initial")); + assert_eq!(p.nested_styles, Some(vec![designcraft_doc::NestedStyle::drop_cap("Initial")])); +} diff --git a/crates/engine/tests/primary_frame_columns.rs b/crates/engine/tests/primary_frame_columns.rs new file mode 100644 index 0000000..1a08a70 --- /dev/null +++ b/crates/engine/tests/primary_frame_columns.rs @@ -0,0 +1,34 @@ +//! Primary text frames retain all columns supported by the new-document command. +use designcraft_doc::Document; +use designcraft_engine::Session; +use serde_json::json; + +fn assert_primary_columns(doc: &Document, expected: u32) { + let mut frames = 0; + for story in doc.stories.values() { + for spec in designcraft_compose::frame_specs(doc, story.id) { + assert_eq!(spec.opts.columns, expected); + let columns = spec.columns(); + assert_eq!(columns.len(), expected as usize); + assert!(columns.iter().all(|r| r.width() > 0. && [r.x0, r.x1, r.y0, r.y1].into_iter().all(f64::is_finite))); + frames += 1; + } + } + assert!(frames > 0, "the primary text frame must exist"); +} + +#[test] +fn primary_columns_survive_composition_native_reopen_and_idml_round_trip() { + for columns in [41, 216] { + let mut s = Session::new(); + s.execute("file.new", &json!({"columns":columns,"gutter":0,"primaryTextFrame":true})).unwrap(); + assert_primary_columns(&s.doc().unwrap().doc, columns); + let bytes = s.execute("file.serialize", &json!({})).unwrap(); + let mut reopened = Session::new(); + reopened.execute("file.openBytes", &json!({"name":"primary-columns.designcraft","base64":bytes["base64"]})).unwrap(); + assert_primary_columns(&reopened.doc().unwrap().doc, columns); + let idml = designcraft_idml::export_idml(&reopened.doc().unwrap().doc); + let imported = designcraft_idml::import_idml(&idml).unwrap(); + assert_primary_columns(&imported, columns); + } +} diff --git a/crates/engine/tests/text_thread_regressions.rs b/crates/engine/tests/text_thread_regressions.rs new file mode 100644 index 0000000..2bca1b0 --- /dev/null +++ b/crates/engine/tests/text_thread_regressions.rs @@ -0,0 +1,277 @@ +use designcraft_doc::{ParaFormat, Story, StoryId}; +use designcraft_engine::Session; +use serde_json::{Value, json}; +use std::sync::Arc; +fn setup() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s +} +fn frame(s: &mut Session, text: &str) -> (u64, u64) { + let r = s.execute("frame.create", &json!({"rect":[72,72,300,300],"content":"text","text":text,"caret":false})).unwrap(); + (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap()) +} +fn snapshot(s: &Session) -> Value { + let d = s.doc().unwrap(); + json!({"doc":d.doc,"selection":d.selection,"revision":d.revision,"dirty":d.is_dirty(),"undo":d.history.undo.len(),"redo":d.history.redo.len(),"journal":s.journal}) +} +#[test] +fn rejected_thread_is_atomic_with_new_frame_and_redo() { + let mut s = setup(); + let (a, _) = frame(&mut s, "hello"); + frame(&mut s, "undo"); + s.execute("edit.undo", &json!({})).unwrap(); + let before = snapshot(&s); + assert!(s.execute("text.thread", &json!({"from":999999,"rect":[0,0,100,100]})).is_err()); + assert_eq!(snapshot(&s), before); + assert!(s.execute("text.thread", &json!({"from":a,"rect":[0,0,100,100],"spread":999})).is_err()); + assert_eq!(snapshot(&s), before); +} +#[test] +fn locked_frame_cannot_be_unthreaded() { + let mut s = setup(); + let (a, _) = frame(&mut s, "hello"); + let (b, _) = frame(&mut s, ""); + s.execute("text.thread", &json!({"from":a,"to":b})).unwrap(); + s.execute("object.lock", &json!({"ids":[a]})).unwrap(); + let before = snapshot(&s); + let r = s.execute("text.unthread", &json!({"frame":a})); + assert!(r.is_err(), "unthread accepted locked frame: {r:?}"); + assert_eq!(snapshot(&s), before); +} +#[test] +fn locked_frame_cannot_be_removed_from_thread() { + let mut s = setup(); + let (a, _) = frame(&mut s, "hello"); + let (b, _) = frame(&mut s, ""); + s.execute("text.thread", &json!({"from":a,"to":b})).unwrap(); + s.execute("object.lock", &json!({"ids":[a]})).unwrap(); + let before = snapshot(&s); + let r = s.execute("text.removeFromThread", &json!({"frame":a})); + assert!(r.is_err(), "remove accepted locked frame: {r:?}"); + assert_eq!(snapshot(&s), before); +} +#[test] +fn rejected_selection_preserves_pending_typing() { + let mut s = setup(); + let (_, sid) = frame(&mut s, "hello"); + s.execute("text.select", &json!({"story":sid,"anchor":3})).unwrap(); + s.execute("type.char", &json!({"attrs":{"size":30}})).unwrap(); + let before = s.execute("type.selectionAttrs", &json!({})).unwrap(); + assert!(s.execute("text.select", &json!({"story":999999,"anchor":0})).is_err()); + let after = s.execute("type.selectionAttrs", &json!({})).unwrap(); + assert_eq!(after, before, "rejected selection reset pending typing"); +} +#[test] +fn append_max_id_preserves_both_tables() { + use designcraft_doc::table::Table; + let mut a = Story::with_text(StoryId(1), &designcraft_doc::TABLE_ANCHOR.to_string(), ParaFormat::default()); + let mut b = Story::with_text(StoryId(2), &designcraft_doc::TABLE_ANCHOR.to_string(), ParaFormat::default()); + let mut ta = Table::new(u64::MAX, 1, 1, 0, 0, 100.); + ta.cells[0].text = Story::with_text(StoryId(0), "ORIGINAL", ParaFormat::default()); + a.tables.insert(u64::MAX, Arc::new(ta)); + let mut tb = Table::new(1, 1, 1, 0, 0, 100.); + tb.cells[0].text = Story::with_text(StoryId(0), "INCOMING", ParaFormat::default()); + b.tables.insert(1, Arc::new(tb)); + a.paras[0].table = Some(u64::MAX); + b.paras[0].table = Some(1); + a.check().unwrap(); + b.check().unwrap(); + a.append_story(b); + let contents: Vec<_> = a.tables.values().map(|t| t.cells[0].text.text.clone()).collect(); + assert!(contents.contains(&"ORIGINAL".into()) && contents.contains(&"INCOMING".into()), "table loss: {contents:?}"); +} +#[test] +fn idml_dropcap_detail_preserves_inherited_other_bit() { + use designcraft_doc::{Document, ParaAttrs, ParagraphStyle, build::NewDocument}; + let mut d = Document::new(&NewDocument::default()); + let parent = ParagraphStyle { + name: "Parent".into(), + based_on: None, + next_style: None, + para: ParaAttrs { drop_cap_align_left: Some(false), drop_cap_scale_descenders: Some(true), ..Default::default() }, + chars: Default::default(), + shortcut: String::new(), + }; + let child = ParagraphStyle { + name: "Child".into(), + based_on: Some("Parent".into()), + next_style: None, + para: ParaAttrs { drop_cap_scale_descenders: Some(false), ..Default::default() }, + chars: Default::default(), + shortcut: String::new(), + }; + d.styles_mut().paragraph.extend([parent, child]); + let before = d.styles.resolve_para_style("Child").0; + let bytes = designcraft_idml::export_idml(&d); + let back = designcraft_idml::import_idml(&bytes).unwrap(); + let after = back.styles.resolve_para_style("Child").0; + assert_eq!( + (after.drop_cap_align_left, after.drop_cap_scale_descenders), + (before.drop_cap_align_left, before.drop_cap_scale_descenders), + "IDML export resets inherited align-left bit" + ); +} +#[test] +fn threading_preserves_incoming_text_hyperlink() { + let mut s = setup(); + let (a, sa) = frame(&mut s, "First"); + let (b, sb) = frame(&mut s, "Second"); + s.execute("text.select", &json!({"story":sb,"anchor":0,"focus":6})).unwrap(); + s.execute("hyperlink.create", &json!({"url":"https://example.com"})).unwrap(); + s.execute("text.thread", &json!({"from":a,"to":b})).unwrap(); + let d = &s.doc().unwrap().doc; + let back = designcraft_format::load(&designcraft_format::save(d).unwrap()).unwrap(); + match &back.hyperlinks[0].source { + designcraft_doc::HyperlinkSource::Text { story, start, end } => { + assert_eq!(story.0, sa, "hyperlink points to deleted incoming story"); + assert_eq!((*start, *end), (6, 12)); + } + _ => panic!("wrong source"), + }; +} +#[test] +fn thread_cannot_destroy_body_by_joining_generated_endnotes() { + let mut s = setup(); + let (body, sid) = frame(&mut s, "KEEP THIS BODY"); + s.execute("text.select", &json!({"story":sid,"anchor":14})).unwrap(); + let e = s.execute("endnote.insert", &json!({"text":"a note"})).unwrap(); + let ef = e["frame"].as_u64().unwrap(); + let before = snapshot(&s); + let r = s.execute("text.thread", &json!({"from":ef,"to":body})); + assert!( + r.is_err(), + "thread into generated endnotes accepted: {r:?}; stories: {:?}", + s.doc().unwrap().doc.stories.values().map(|s| &s.text).collect::>() + ); + assert_eq!(snapshot(&s), before); +} +#[test] +fn generated_endnotes_accept_empty_extensions_but_reject_reverse_body_merge() { + let mut s = setup(); + let (body, sid) = frame(&mut s, "Body"); + s.execute("text.select", &json!({"story":sid,"anchor":4})).unwrap(); + let r = s.execute("endnote.insert", &json!({"text":"note"})).unwrap(); + let ef = r["frame"].as_u64().unwrap(); + let esid = s.doc().unwrap().doc.endnote_story.unwrap(); + let before = snapshot(&s); + assert!(s.execute("text.thread", &json!({"from":body,"to":ef})).is_err()); + assert_eq!(snapshot(&s), before); + s.execute("text.thread", &json!({"from":ef,"rect":[72,72,300,300]})).unwrap(); + assert_eq!(s.doc().unwrap().doc.endnote_story, Some(esid)); + s.execute("text.thread", &json!({"to":ef,"rect":[72,72,300,300]})).unwrap(); + assert_eq!(s.doc().unwrap().doc.endnote_story, Some(esid)); + assert_eq!(s.doc().unwrap().doc.story(esid).unwrap().frames.len(), 3); + assert!(s.doc().unwrap().doc.story(StoryId(sid)).unwrap().text.starts_with("Body")); + s.doc().unwrap().doc.check().unwrap(); +} +#[test] +fn threading_moves_data_placeholders_and_primary_story_with_text() { + use designcraft_doc::{Placeholder, PlaceholderAnchor, PlaceholderRole}; + let mut s = setup(); + let (a, sa) = frame(&mut s, "First"); + let (b, sb) = frame(&mut s, "<>"); + s.edit(|d, _| { + d.settings.primary_story = Some(StoryId(sb)); + d.data_merge.placeholders.push(Placeholder { + id: 123, + source_id: 0, + field: "name".into(), + role: PlaceholderRole::Text, + anchor: PlaceholderAnchor::Text { story: StoryId(sb), start: 0, end: 8 }, + }); + Ok(()) + }) + .unwrap(); + let before = s.doc().unwrap().doc.clone(); + s.execute("text.thread", &json!({"from":a,"to":b})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.settings.primary_story, Some(StoryId(sa))); + assert_eq!(d.data_merge.placeholders.len(), 1); + assert_eq!(d.data_merge.placeholders[0].anchor, PlaceholderAnchor::Text { story: StoryId(sa), start: 6, end: 14 }); + let after = d.clone(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, before); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, after); +} +#[test] +fn append_max_ids_preserves_addressable_footnotes_and_endnotes() { + let mut a = Story::with_text(StoryId(1), "A", ParaFormat::default()); + let mut b = Story::with_text(StoryId(2), "B", ParaFormat::default()); + a.insert_note(1, "original footnote", ParaFormat::default()); + b.insert_note(1, "incoming footnote", ParaFormat::default()); + Arc::make_mut(&mut a.notes[0]).id = u64::MAX; + a.insert_endnote(1, "original endnote", ParaFormat::default()); + b.insert_endnote(1, "incoming endnote", ParaFormat::default()); + Arc::make_mut(&mut a.endnotes[0]).id = u64::MAX; + a.check().unwrap(); + b.check().unwrap(); + a.append_story(b); + a.check().unwrap(); + assert_ne!(a.notes[0].id, a.notes[1].id); + assert_ne!(a.endnotes[0].id, a.endnotes[1].id); + assert_eq!(a.notes[0].text.text, "original footnote"); + assert_eq!(a.endnotes[1].text.text, "incoming endnote"); +} +#[test] +fn locked_layer_and_neighbour_reject_unthread_atomically() { + for lock_layer in [false, true] { + let mut s = setup(); + let (a, _) = frame(&mut s, "A"); + let (b, _) = frame(&mut s, "B"); + s.execute("text.thread", &json!({"from":a,"to":b})).unwrap(); + if lock_layer { + let lid = s.doc().unwrap().active_layer.0; + s.execute("layer.set", &json!({"id":lid,"locked":true})).unwrap(); + } else { + s.execute("object.lock", &json!({"ids":[b]})).unwrap(); + } + let before = snapshot(&s); + assert!(s.execute("text.unthread", &json!({"frame":a})).is_err()); + assert_eq!(snapshot(&s), before); + } +} +#[test] +fn idml_partial_dropcap_bits_resolve_both_style_and_paragraph_inheritance() { + use designcraft_doc::{Document, ParaAttrs, ParagraphStyle, SpreadRef, build::NewDocument}; + use designcraft_geom::Rect; + for parent_align in [false, true] { + for parent_scale in [false, true] { + for local_align in [false, true] { + let mut d = Document::new(&NewDocument::default()); + d.styles_mut().paragraph.push(ParagraphStyle { + name: "Parent".into(), + based_on: None, + next_style: None, + para: ParaAttrs { drop_cap_align_left: Some(parent_align), drop_cap_scale_descenders: Some(parent_scale), ..Default::default() }, + chars: Default::default(), + shortcut: String::new(), + }); + let layer = d.default_layer(); + let (_, sid) = d + .add_text_frame( + SpreadRef::Doc(0), + Rect::new(10., 10., 200., 200.), + layer, + "Body", + ParaFormat { + style: "Parent".into(), + para: ParaAttrs { drop_cap_align_left: Some(local_align), ..Default::default() }, + ..Default::default() + }, + ) + .unwrap(); + let bytes = designcraft_idml::export_idml(&d); + let back = designcraft_idml::import_idml(&bytes).unwrap(); + let st = back.stories.values().find(|s| s.text == "Body").unwrap(); + let actual = back.styles.resolve_para(&st.paras[0]).0; + let expected = d.styles.resolve_para(&d.story(sid).unwrap().paras[0]).0; + assert_eq!( + (actual.drop_cap_align_left, actual.drop_cap_scale_descenders), + (expected.drop_cap_align_left, expected.drop_cap_scale_descenders) + ); + } + } + } +} diff --git a/crates/engine/tests/thread_color_safety.rs b/crates/engine/tests/thread_color_safety.rs new file mode 100644 index 0000000..501df92 --- /dev/null +++ b/crates/engine/tests/thread_color_safety.rs @@ -0,0 +1,223 @@ +//! Linked-story threading and text-color transaction safety. +use designcraft_doc::{ParaFormat, Story, StoryId, table::Table}; +use designcraft_engine::Session; +use serde_json::{Value, json}; +use std::sync::Arc; + +fn setup() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s +} +fn frame(s: &mut Session, text: &str) -> (u64, StoryId) { + let f = s.execute("frame.create", &json!({"rect":[36,36,200,100],"content":"text","text":text,"caret":false})).unwrap(); + (f["id"].as_u64().unwrap(), StoryId(f["story"].as_u64().unwrap())) +} +fn child(s: &mut Session, parent: StoryId) -> (u64, StoryId) { + let f = s.execute("story.placeAndLink", &json!({"story":parent.0,"rect":[36,150,200,100]})).unwrap(); + (f["id"].as_u64().unwrap(), StoryId(f["story"].as_u64().unwrap())) +} +fn state(s: &Session) -> Value { + let d = s.doc().unwrap(); + json!({"doc":d.doc,"selection":d.selection,"revision":d.revision,"dirty":d.is_dirty(),"undo":format!("{:?}",d.history),"journal":s.journal}) +} +fn link(s: &mut Session, sid: StoryId) -> Value { + s.execute("story.links", &json!({})).unwrap().as_array().unwrap().iter().find(|l| l["story"] == sid.0).unwrap().clone() +} + +#[test] +fn safety_linked_parent_follows_merge_and_updates_after_native_reopen() { + let mut s = setup(); + let (first, survivor) = frame(&mut s, "PREFIX"); + let (incoming, parent) = frame(&mut s, "PARENT"); + let (_, linked) = child(&mut s, parent); + // A coincident old parent revision must not hide the change of parent identity/content. + let future_rev = s.doc().unwrap().doc.story(survivor).unwrap().rev + 2; + s.edit(|d, _| { + d.story_mut(linked).unwrap().link = Some((parent, future_rev)); + Ok(()) + }) + .unwrap(); + s.execute("text.thread", &json!({"from":first,"to":incoming})).unwrap(); + assert_eq!(link(&mut s, linked)["parent"], survivor.0); + assert_eq!(link(&mut s, linked)["outOfDate"], true); + assert_eq!(s.doc().unwrap().doc.story(linked).unwrap().text, "PARENT"); + let bytes = s.execute("file.serialize", &json!({})).unwrap(); + let mut reopened = Session::new(); + reopened.execute("file.openBytes", &json!({"name":"links.designcraft","base64":bytes["base64"]})).unwrap(); + assert_eq!(link(&mut reopened, linked)["parent"], survivor.0); + reopened.execute("story.updateLink", &json!({"story":linked.0})).unwrap(); + assert_eq!(reopened.doc().unwrap().doc.story(linked).unwrap().text, "PREFIX\nPARENT"); + assert_eq!(link(&mut reopened, linked)["outOfDate"], false); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(link(&mut s, linked)["parent"], parent.0); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(link(&mut s, linked)["parent"], survivor.0); +} + +#[test] +fn safety_linked_child_cannot_merge_body_text_in_either_direction() { + for reverse in [false, true] { + let mut s = setup(); + let (_, parent) = frame(&mut s, "SOURCE"); + let (linked, child) = child(&mut s, parent); + let (body, _) = frame(&mut s, "BODY"); + let (from, to) = if reverse { (body, linked) } else { (linked, body) }; + let before = state(&s); + let error = s.execute("text.thread", &json!({"from":from,"to":to})).unwrap_err(); + assert!(error.to_string().contains("linked story")); + assert_eq!(state(&s), before); + s.execute("story.updateLink", &json!({"story":child.0})).unwrap(); + assert!(s.doc().unwrap().doc.stories.values().any(|st| st.text == "BODY")); + } +} + +#[test] +fn safety_linked_child_allows_empty_extensions_in_both_directions() { + for reverse in [false, true] { + let mut s = setup(); + let (_, parent) = frame(&mut s, "SOURCE"); + let (linked, child) = child(&mut s, parent); + let (empty, _) = frame(&mut s, ""); + let (from, to) = if reverse { (empty, linked) } else { (linked, empty) }; + s.execute("text.thread", &json!({"from":from,"to":to})).unwrap(); + assert_eq!(link(&mut s, child)["parent"], parent.0); + s.execute("story.setText", &json!({"story":parent.0,"text":"UPDATED"})).unwrap(); + assert_eq!(link(&mut s, child)["outOfDate"], true); + s.execute("story.updateLink", &json!({"story":child.0})).unwrap(); + let st = s.doc().unwrap().doc.story(child).unwrap(); + assert_eq!(st.text, "UPDATED"); + assert_eq!(st.frames.len(), 2); + assert_eq!(link(&mut s, child)["outOfDate"], false); + } +} + +#[test] +fn safety_linked_empty_parent_cannot_be_merged_into_its_child() { + let mut s = setup(); + let (parent_frame, parent) = frame(&mut s, ""); + let (linked, _) = child(&mut s, parent); + let before = state(&s); + assert!(s.execute("text.thread", &json!({"from":parent_frame,"to":linked})).is_err()); + assert_eq!(state(&s), before); +} + +#[test] +fn safety_text_color_without_target_is_atomic_and_carets_remain_valid() { + let mut s = setup(); + let f = s.execute("frame.create", &json!({"rect":[36,36,200,100]})).unwrap(); + s.execute("selection.set", &json!({"ids":[f["id"]]})).unwrap(); + let before = state(&s); + assert!(s.execute("object.color", &json!({"color":"#123456","text":true})).is_err()); + assert_eq!(state(&s), before); + for text in ["", "Words"] { + let (_, sid) = frame(&mut s, text); + s.execute("text.select", &json!({"story":sid.0,"anchor":0})).unwrap(); + let color = s.execute("object.color", &json!({"color":"#654321","text":true})).unwrap(); + s.execute("text.insert", &json!({"text":"X"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().format_after(0).over.fill.as_deref(), color["swatch"].as_str()); + } +} + +fn nested_story(depth: usize, name: &str) -> Story { + let mut st = Story::with_text(StoryId(0), "leaf", ParaFormat::default()); + st.chars[0].format.over.fill = Some(name.into()); + for _ in 0..depth { + let mut outer = Story::with_text(StoryId(0), &designcraft_doc::TABLE_ANCHOR.to_string(), ParaFormat::default()); + let mut t = Table::new(1, 1, 1, 0, 0, 100.); + t.cells[0].text = st; + outer.tables.insert(1, Arc::new(t)); + outer.paras[0].table = Some(1); + st = outer; + } + st +} + +#[test] +fn safety_deep_swatch_rename_rejects_atomically_with_pending_color() { + // A small boundary probe, never an attempted stack-overflow reproduction. + for depth in [33, 34] { + let mut s = setup(); + let (_, sid) = frame(&mut s, "root"); + s.execute("text.select", &json!({"story":sid.0,"anchor":0})).unwrap(); + let old = s.execute("object.color", &json!({"color":"#123456"})).unwrap()["swatch"].as_str().unwrap().to_string(); + s.edit(|d, _| { + let st = d.story_mut(sid).unwrap(); + let frames = st.frames.clone(); + *st = nested_story(depth, &old); + st.id = sid; + st.frames = frames; + Ok(()) + }) + .unwrap(); + let pending = s.execute("type.selectionAttrs", &json!({})).unwrap(); + let before = state(&s); + let doc = s.doc().unwrap().doc.clone(); + let error = s.execute("swatch.addToSwatches", &json!({"swatch":old,"name":"Brand"})).unwrap_err(); + assert!(error.to_string().contains("nesting")); + assert!(Arc::ptr_eq(&doc, &s.doc().unwrap().doc)); + assert_eq!(state(&s), before); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap(), pending); + } +} + +#[test] +fn safety_swatch_rename_reaches_last_supported_depth() { + let mut s = setup(); + let (_, sid) = frame(&mut s, "root"); + s.execute("text.select", &json!({"story":sid.0,"anchor":0})).unwrap(); + let old = s.execute("object.color", &json!({"color":"#123456"})).unwrap()["swatch"].as_str().unwrap().to_string(); + s.edit(|d, _| { + let st = d.story_mut(sid).unwrap(); + let frames = st.frames.clone(); + *st = nested_story(32, &old); + st.id = sid; + st.frames = frames; + Ok(()) + }) + .unwrap(); + s.execute("swatch.addToSwatches", &json!({"swatch":old,"name":"Brand"})).unwrap(); + let mut st = s.doc().unwrap().doc.story(sid).unwrap(); + for _ in 0..32 { + st = &st.tables[&1].cells[0].text; + } + assert_eq!(st.format_after(0).over.fill.as_deref(), Some("Brand")); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], "Brand"); +} + +#[test] +fn safety_empty_linked_parent_follows_survivor_only_on_explicit_update() { + for incoming_empty in [false, true] { + for alternate_layout in [false, true] { + let mut s = setup(); + let (empty, parent) = frame(&mut s, ""); + let linked = if alternate_layout { + s.execute("layout.createAlternate", &json!({"name":"Alternate","width":600,"height":800})).unwrap(); + let links = s.execute("story.links", &json!({})).unwrap(); + StoryId(links.as_array().unwrap().iter().find(|l| l["parent"] == parent.0).unwrap()["story"].as_u64().unwrap()) + } else { + child(&mut s, parent).1 + }; + let (body, survivor) = frame(&mut s, "BODY"); + let (from, to) = if incoming_empty { (body, empty) } else { (empty, body) }; + s.execute("text.thread", &json!({"from":from,"to":to})).unwrap(); + assert!(s.doc().unwrap().doc.story(parent).is_none()); + assert_eq!(link(&mut s, linked)["parent"], survivor.0); + assert_eq!(link(&mut s, linked)["outOfDate"], true); + assert_eq!(s.doc().unwrap().doc.story(linked).unwrap().text, ""); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(link(&mut s, linked)["parent"], parent.0); + assert_eq!(s.doc().unwrap().doc.story(linked).unwrap().text, ""); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(link(&mut s, linked)["parent"], survivor.0); + let bytes = s.execute("file.serialize", &json!({})).unwrap(); + let mut reopened = Session::new(); + reopened.execute("file.openBytes", &json!({"name":"empty-parent.designcraft","base64":bytes["base64"]})).unwrap(); + assert_eq!(link(&mut reopened, linked)["outOfDate"], true); + assert_eq!(reopened.doc().unwrap().doc.story(linked).unwrap().text, ""); + reopened.execute("story.updateLink", &json!({"story":linked.0})).unwrap(); + assert_eq!(reopened.doc().unwrap().doc.story(linked).unwrap().text, "BODY"); + assert_eq!(link(&mut reopened, linked)["outOfDate"], false); + } + } +} diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index bbe08af..bc9c9e6 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -103,10 +103,14 @@ pub struct FontFace { pub upem: f64, /// Ascender in font units (positive = up). pub ascent: f64, + /// OS/2 typographic ascender in font units (the ascent when the font has no usable one): the + /// Ascent first baseline offset. + pub typo_ascent: f64, /// Descender in font units (positive = down). pub descent: f64, - /// Cap height and x height in font units (estimated from the ascent when the font has no OS/2 - /// values). + /// Cap height and x height in font units (without plausible OS/2 values, the cap height is the + /// top of the H, and either is estimated from the ascent): the Cap Height and x Height first + /// baseline offsets. pub cap_height: f64, pub x_height: f64, pub shaper: harfrust::ShaperData, @@ -118,7 +122,7 @@ pub struct FontFace { /// Basic Multilingual Plane coverage bitset, built on first use. bmp: std::sync::OnceLock>, /// The ideographic em box, read on first use. - em: std::sync::OnceLock<(f64, f64)>, + em: std::sync::OnceLock<((f64, f64), bool)>, /// The font menu group and native family name, read on first use. group: std::sync::OnceLock<(FontGroup, Option)>, /// Format, class and PANOSE numbers (the font menus), read on first use. @@ -287,11 +291,17 @@ impl FontFace { pub fn v_origin(&self, gid: u32) -> f64 { crate::vertical::VMetrics::new(self).origin(GlyphId::new(gid)) } - /// The ideographic em box (top, bottom) in font units, y up (Japanese fonts: typically 880, - /// -120): `BASE` `idtp`/`ideo`, else the OS/2 typo ascender/descender, else the ascender and - /// descender, the last two centred on one em. + /// The ideographic em box (top, bottom) in font units, y up, one em tall (Japanese fonts: + /// typically 880, -120): `BASE` `idtp`/`ideo`; else, for a CJK font, from the OS/2 typo + /// ascender when the typo ascender and descender span one em; else centred on half the cap + /// height (OS/2, else the `H` glyph's top); else 0.88 em above the baseline. pub fn em_box(&self) -> (f64, f64) { - *self.em.get_or_init(|| crate::vertical::em_box(self)) + self.em.get_or_init(|| crate::vertical::em_box(self)).0 + } + /// [`Self::em_box`] when the font's tables give it, `None` for the 0.88 em fallback. + pub fn declared_em_box(&self) -> Option<(f64, f64)> { + let (b, declared) = *self.em.get_or_init(|| crate::vertical::em_box(self)); + declared.then_some(b) } /// The font menu group the font is for and, for a CJK group, its family name in that language /// (see [`crate::FamilyInfo`]); read from the font once. @@ -728,6 +738,7 @@ fn make_face(bytes: FontBytes, source: FontSource, index: u32, family: String, s let settings: Vec<(skrifa::Tag, f32)> = coords.iter().map(|(t, v)| (skrifa::Tag::new(t), *v)).collect(); let location = if coords.is_empty() { Location::default() } else { f.axes().location(settings.iter().copied()) }; let m = f.metrics(Size::unscaled(), &location); + let upem = m.units_per_em.max(1) as f64; let a = f.attributes(); let hb = harfrust::FontRef::from_index(data, index).ok()?; let shaper = harfrust::ShaperData::new(&hb); @@ -745,11 +756,19 @@ fn make_face(bytes: FontBytes, source: FontSource, index: u32, family: String, s style, weight, italic, - upem: m.units_per_em.max(1) as f64, + upem, ascent: m.ascent as f64, + typo_ascent: typo_ascent(&f, &location, upem).unwrap_or(m.ascent as f64), descent: -(m.descent as f64), - cap_height: m.cap_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.72), - x_height: m.x_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.5), + // Fonts with an OS/2 table older than version 2 don't declare these: measure them from + // flat-topped letters before falling back to a share of the ascent, which can be far off + // (drop caps are sized from the cap height). + cap_height: plausible(m.cap_height.map(f64::from), upem) + .or_else(|| measured_top(&f, (&location).into(), &['H', 'I', 'E', 'T'], upem)) + .unwrap_or(m.ascent as f64 * 0.72), + x_height: plausible(m.x_height.map(f64::from), upem) + .or_else(|| measured_top(&f, (&location).into(), &['x', 'z', 'v'], upem)) + .unwrap_or(m.ascent as f64 * 0.5), shaper, coords, location, @@ -764,6 +783,54 @@ fn make_face(bytes: FontBytes, source: FontSource, index: u32, family: String, s }) } +/// A font's height above the baseline (font units) if it is plausible: above 0 and at most 4 em. +pub(crate) fn plausible(v: Option, upem: f64) -> Option { + v.filter(|v| *v > 0.0 && *v <= 4.0 * upem) +} + +/// The top of the first of `chars` the font has a non-empty outline for, in font units: the +/// highest point of its unhinted outline at `location`, if [`plausible`]. For flat-topped letters +/// that is the cap height (`H`) or x-height (`x`). +pub(crate) fn measured_top(f: &skrifa::FontRef<'_>, location: LocationRef<'_>, chars: &[char], upem: f64) -> Option { + struct Top(f32); + impl OutlinePen for Top { + fn move_to(&mut self, _x: f32, y: f32) { + self.0 = self.0.max(y); + } + fn line_to(&mut self, _x: f32, y: f32) { + self.0 = self.0.max(y); + } + fn quad_to(&mut self, _cx: f32, _cy: f32, _x: f32, y: f32) { + self.0 = self.0.max(y); + } + fn curve_to(&mut self, _cx0: f32, _cy0: f32, _cx1: f32, _cy1: f32, _x: f32, y: f32) { + self.0 = self.0.max(y); + } + fn close(&mut self) {} + } + let cmap = f.charmap(); + let glyphs = f.outline_glyphs(); + chars.iter().find_map(|&c| { + let g = glyphs.get(cmap.map(c)?)?; + let mut pen = Top(f32::NEG_INFINITY); + g.draw(DrawSettings::unhinted(Size::unscaled(), location), &mut pen).ok()?; + plausible(Some(f64::from(pen.0)), upem) + }) +} + +/// The OS/2 typographic ascender in font units at `location`, if the font has an OS/2 table and +/// the value is [`plausible`]. +fn typo_ascent(f: &skrifa::FontRef<'_>, location: &Location, upem: f64) -> Option { + use skrifa::raw::TableProvider; + let mut v = f.os2().ok()?.s_typo_ascender() as f64; + if !location.coords().is_empty() + && let Ok(mvar) = f.mvar() + { + v += mvar.metric_delta(skrifa::raw::tables::mvar::tags::HASC, location.coords()).map_or(0.0, |d| d.to_f64()); + } + plausible(Some(v), upem) +} + /// `family` without the font-format suffix layout apps append when a family is installed in /// several formats (`Minion Pro (OTF)` → `Minion Pro`), if it has one. fn without_format_suffix(family: &str) -> Option<&str> { @@ -886,6 +953,16 @@ impl FontDb { self.scoped(0).styles(family) } + /// The shared fonts' weight and italic of a style of `family` (see [`ScopedFonts::traits_of`]). + pub fn traits_of(&self, family: &str, style: &str) -> (f32, bool) { + self.scoped(0).traits_of(family, style) + } + + /// Style linking among the shared fonts (see [`ScopedFonts::linked_style`]). + pub fn linked_style(&self, family: &str, current: &str, bold: Option, italic: Option) -> Option { + self.scoped(0).linked_style(family, current, bold, italic) + } + /// Add a user font (TTF/OTF/TTC bytes). Returns the number of faces added (0 if unparseable or /// every face was already present). pub fn add_font(&self, bytes: Vec) -> usize { @@ -1279,14 +1356,59 @@ impl ScopedFonts<'_> { /// Style names available for `family` (Regular first, then by weight). pub fn styles(&self, family: &str) -> Vec { - let v = self.exact_styles(family); + self.style_traits(family).into_iter().map(|t| t.2).collect() + } + + /// Weight and italic of a style of `family`: from the font when it is known, else from the + /// style's name. + pub fn traits_of(&self, family: &str, style: &str) -> (f32, bool) { + let base = base_style(style); + self.style_traits(family) + .into_iter() + .find(|t| t.2.eq_ignore_ascii_case(base)) + .map_or((style_weight(base), style_italic(base)), |(italic, weight, _)| (weight, italic)) + } + + /// Style linking: the style of `family` that sets bold (`bold`) and italic (`italic`) as asked + /// and keeps the other from `current` (Bold from Italic is Bold Italic). `None` when the + /// family has no such style. + pub fn linked_style(&self, family: &str, current: &str, bold: Option, 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` (the document's, loaded, or installed), + /// Regular first, then by weight. + fn style_traits(&self, family: &str) -> Vec<(bool, f32, String)> { + let v = self.exact_style_traits(family); match without_format_suffix(family) { - Some(base) if v.is_empty() => self.exact_styles(base), + Some(base) if v.is_empty() => self.exact_style_traits(base), _ => v, } } - fn exact_styles(&self, family: &str) -> Vec { + fn exact_style_traits(&self, family: &str) -> Vec<(bool, f32, String)> { let mut v: Vec<(bool, f32, String)> = self .own() .iter() @@ -1302,7 +1424,7 @@ impl ScopedFonts<'_> { } v.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1)).then(a.2.cmp(&b.2))); v.dedup_by(|a, b| a.2 == b.2); - v.into_iter().map(|t| t.2).collect() + v } /// Resolve a family + style to a face, falling back to the closest style of the family, then to @@ -1617,3 +1739,54 @@ mod tests_docfonts; #[cfg(test)] #[path = "tests_vertical.rs"] mod tests_vertical; + +#[cfg(test)] +mod tests_heights { + use super::*; + use crate::testing::{font_mapping, with_table_u16}; + + fn face_of(bytes: Vec) -> FontFace { + make_face(FontBytes::Owned(Arc::new(bytes)), FontSource::Memory, 0, "Test".into(), "Regular".into(), Vec::new()).unwrap() + } + + /// `font` with OS/2 sxHeight and sCapHeight set to 0 (undeclared). + fn undeclared(font: Vec) -> Vec { + with_table_u16(with_table_u16(font, b"OS/2", 86, 0).unwrap(), b"OS/2", 88, 0).unwrap() + } + + /// The top of Source Sans 3's glyph for `c` from its bounding box (font units). + fn bbox_top(c: char) -> f64 { + let f = skrifa::FontRef::new(BUNDLED[0]).unwrap(); + f.glyph_metrics(Size::unscaled(), LocationRef::default()).bounds(f.charmap().map(c).unwrap()).unwrap().y_max as f64 + } + + /// A font that doesn't declare its cap height and x-height gets them measured from its + /// flat-topped letters, also when it lacks the first choices `H` and `x`; a font that + /// declares them keeps them. + #[test] + fn undeclared_heights_are_measured_from_outlines() { + let glyphs = [('H', 'H'), ('x', 'x'), ('E', 'E'), ('z', 'z')]; + for (glyphs, cap, x) in [(&glyphs[..], 'H', 'x'), (&glyphs[2..], 'E', 'z')] { + let face = face_of(undeclared(font_mapping("Heights", glyphs).unwrap())); + let (cap, x) = (bbox_top(cap), bbox_top(x)); + assert!(cap > x && x > 0.0); + assert!((face.cap_height - cap).abs() <= 1.0, "{glyphs:?}: cap height {} vs {cap}", face.cap_height); + assert!((face.x_height - x).abs() <= 1.0, "{glyphs:?}: x-height {} vs {x}", face.x_height); + } + let declared = face_of(font_mapping("Heights", &glyphs).unwrap()); + assert_eq!((declared.cap_height, declared.x_height), (660.0, 486.0), "Source Sans 3 Regular's OS/2 values"); + // Declared values win over the outlines: `H` drawn as `x` doesn't lower the cap height. + let face = face_of(font_mapping("Heights", &[('H', 'x'), ('x', 'H')]).unwrap()); + assert_eq!((face.cap_height, face.x_height), (declared.cap_height, declared.x_height)); + } + + /// Without any of the letters, or with only empty outlines, the heights fall back to a share + /// of the ascent. + #[test] + fn heights_without_measurable_letters_fall_back_to_the_ascent() { + for glyphs in [&[('a', 'a')][..], &[('H', ' '), ('x', ' ')]] { + let face = face_of(undeclared(font_mapping("Heights", glyphs).unwrap())); + assert_eq!((face.cap_height, face.x_height), (face.ascent * 0.72, face.ascent * 0.5), "{glyphs:?}"); + } + } +} diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index dd45837..5ff395d 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -349,6 +349,22 @@ mod tests { } } + #[test] + fn style_linking_finds_bold_and_italic_in_the_family() { + let db = FontDb::with_font_dirs(vec![]); + let link = |cur: &str, bold: Option, 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/fonts/src/tests_vertical.rs b/crates/fonts/src/tests_vertical.rs index 729ba3b..4f1e82c 100644 --- a/crates/fonts/src/tests_vertical.rs +++ b/crates/fonts/src/tests_vertical.rs @@ -6,7 +6,7 @@ use skrifa::raw::TableProvider; use super::*; -use crate::testing::{font_mapping, font_with, with_table_len, with_table_u16, with_vmtx, with_vorg}; +use crate::testing::{font_mapping, font_with, with_code_pages, with_table_len, with_table_u16, with_vmtx, with_vorg}; use crate::{ShapeContext, feature, shape_vertical, shape_with_context}; /// Text in Japanese. @@ -111,14 +111,36 @@ fn fonts_without_vmtx_use_the_em_box() { assert_eq!(face.em_box(), (830.0, -170.0)); assert_eq!(face.v_advance(gid), face.upem); assert_eq!(face.v_origin(gid), 830.0); - // Without BASE either: the OS/2 typo ascender and descender (1000, -326), centred on one em. + // Without BASE either: the typo ascender and descender (1000, -326) span more than one em, so + // the em box is centred on half the OS/2 cap height (660). let face = face_of(with_table_len(font_with("No Base", &['一']).unwrap(), b"BASE", 0).unwrap()); - assert_eq!(face.em_box(), (837.0, -163.0)); - // Without OS/2: the ascender and descender, the same way. - let face = face_of(with_table_len(with_table_len(font_with("No OS2", &['一']).unwrap(), b"BASE", 0).unwrap(), b"OS/2", 0).unwrap()); - let (top, bottom) = face.em_box(); - assert_eq!(top - bottom, face.upem); - assert!((top + bottom - (face.ascent - face.descent)).abs() < 1e-9, "{top} {bottom}"); + assert_eq!(face.em_box(), (830.0, -170.0)); + assert_eq!(face.declared_em_box(), Some((830.0, -170.0))); + // Typo ascender and descender one em apart: a CJK font (JIS code page) tops at the typo + // ascender; a Latin one (Source Sans 3's own code pages) still centres on the cap height. + let typo = |family: &str, code_pages: Option| { + let font = with_table_u16(font_with(family, &['一']).unwrap(), b"OS/2", 68, 860).unwrap(); + let font = with_table_u16(font, b"OS/2", 70, u16::from_be_bytes((-140i16).to_be_bytes())).unwrap(); + let font = match code_pages { + Some(r1) => with_code_pages(font, r1, 0).unwrap(), + None => font, + }; + face_of(with_table_len(font, b"BASE", 0).unwrap()) + }; + let face = typo("Typo Em CJK", Some(1 << 17)); + assert_ne!(face.menu_group().0, crate::FontGroup::Western); + assert_eq!(face.em_box(), (860.0, -140.0)); + let face = typo("Typo Em Latin", None); + assert_eq!(face.menu_group().0, crate::FontGroup::Western); + assert_eq!(face.em_box(), (830.0, -170.0)); + // Without OS/2: centred on half the H glyph's height (656). + let no_os2 = |font: Vec| face_of(with_table_len(with_table_len(font, b"BASE", 0).unwrap(), b"OS/2", 0).unwrap()); + let face = no_os2(font_mapping("No OS2", &[('一', 'X'), ('H', 'H')]).unwrap()); + assert_eq!(face.em_box(), (828.0, -172.0)); + // Without an H either: 0.88 em above the baseline, which the font doesn't declare. + let face = no_os2(font_with("No OS2 No H", &['一']).unwrap()); + assert_eq!(face.em_box(), (880.0, -120.0)); + assert_eq!(face.declared_em_box(), None); } #[test] diff --git a/crates/fonts/src/vertical.rs b/crates/fonts/src/vertical.rs index d360b6f..6483f41 100644 --- a/crates/fonts/src/vertical.rs +++ b/crates/fonts/src/vertical.rs @@ -1,6 +1,6 @@ //! Vertical metrics for upright glyphs in vertical text: advances down the line (`vmtx`), vertical //! origins (`VORG`, else `vmtx` and the glyph's top) and the ideographic em box (`BASE`, else -//! OS/2). Fonts without them fall back to the em box: one em down the line, the origin at its top. +//! OS/2 metrics). Fonts without them fall back to the em box: one em down the line, the origin at its top. use skrifa::instance::Size; use skrifa::metrics::GlyphMetrics; @@ -53,25 +53,30 @@ impl<'a> VMetrics<'a> { } } -/// The ideographic em box (top, bottom) in font units, y up: `BASE` `idtp`/`ideo`, else the OS/2 -/// typo ascender and descender, else the ascender and descender; the last two centred on one em. -pub(crate) fn em_box(face: &FontFace) -> (f64, f64) { +/// The ideographic em box (top, bottom) in font units, y up, one em tall: `BASE` `idtp`/`ideo`; +/// else, for a CJK font (its [`crate::FontGroup`] isn't Western), topped by the OS/2 typo ascender +/// when the typo ascender and descender span exactly one em; else centred on half the cap height +/// (OS/2 `sCapHeight`, else the `H` glyph's top); else 0.88 em above the baseline. The flag is +/// false for that last fallback. +pub(crate) fn em_box(face: &FontFace) -> ((f64, f64), bool) { let upem = face.upem; - let font = face.skrifa(); - if let Some(b) = font.as_ref().and_then(|f| base_em_box(f, upem)) { - return b; + let fallback = ((upem * 0.88, upem * -0.12), false); + let Some(font) = face.skrifa() else { return fallback }; + if let Some(b) = base_em_box(&font, upem) { + return (b, true); } - let centred = |asc: f64, desc: f64| { - let span = asc - desc; - (span > 0.0 && span.is_finite()).then(|| { - let top = asc - (span - upem) / 2.0; - (top, top - upem) - }) - }; - font.and_then(|f| f.os2().ok()) - .and_then(|t| centred(f64::from(t.s_typo_ascender()), f64::from(t.s_typo_descender()))) - .or_else(|| centred(face.ascent, -face.descent)) - .unwrap_or((upem * 0.88, upem * -0.12)) + // Latin fonts with a one-em typo box (DejaVu) still centre on the cap height. + let cjk = face.menu_group().0 != crate::FontGroup::Western; + let typo_top = font.os2().ok().filter(|_| cjk).and_then(|t| { + let (asc, desc) = (f64::from(t.s_typo_ascender()), f64::from(t.s_typo_descender())); + (asc - desc == upem && asc > 0.0 && asc <= upem).then_some(asc) + }); + let top = typo_top.or_else(|| { + let declared = font.metrics(Size::unscaled(), face.location()).cap_height.map(f64::from); + let cap = crate::fontdb::plausible(declared, upem).or_else(|| crate::fontdb::measured_top(&font, face.location(), &['H'], upem))?; + Some((upem + cap) / 2.0) + }); + top.map_or(fallback, |top| ((top, top - upem), true)) } /// The em box from the `BASE` table's horizontal axis: the Han script's values, else the default diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 408bb3c..35edb48 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -6,7 +6,8 @@ //! the ones the document references (see [`Document::compact_assets`]) //! - `meta.json`: format version and generator //! -//! Older single-file JSON documents (assets as base64 in `assetData`) still open. +//! Older single-file JSON documents (assets as base64 in `assetData`) still open. Documents +//! from older format versions are upgraded on load so they lay out as they did. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] @@ -14,11 +15,11 @@ use std::collections::{HashMap, HashSet}; use std::io::{Cursor, Read, Write}; use std::sync::Arc; -use designcraft_doc::{AssetId, Document}; +use designcraft_doc::{AssetId, Document, ParaAttrs, Story}; use serde_json::{Value, json}; pub const MIME: &str = "application/vnd.designcraft+zip"; -pub const VERSION: u32 = 1; +pub const VERSION: u32 = 2; #[derive(Debug, thiserror::Error)] pub enum FormatError { @@ -93,16 +94,16 @@ fn load_zip(bytes: &[u8]) -> Result { 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 v: Value = serde_json::from_slice(&json).map_err(|e| FormatError::NotOurs(e.to_string()))?; - upgrade(&mut v); + upgrade_json(&mut v); let mut doc: Document = serde_json::from_value(v).map_err(|e| FormatError::NotOurs(e.to_string()))?; let names: Vec = z.file_names().map(str::to_string).collect(); // Files saved with one copy per placement: identical copies share one buffer as they are @@ -124,14 +125,62 @@ fn load_zip(bytes: &[u8]) -> Result { Arc::make_mut(a).data = data; } } + upgrade(&mut doc, version); doc.merge_duplicate_assets(); doc.check().map_err(|e| FormatError::NotOurs(e.to_string()))?; Ok(doc) } +/// Bring a document saved in format `from` up to [`VERSION`]. +fn upgrade(doc: &mut Document, from: u32) { + if from < 2 { + // Format 1 laid out an unset Keep Lines Together mode as all lines in paragraph, and drop + // caps without Align Left Edge. + pin_para_default(doc, |a| &mut a.keep_all_lines, true); + pin_para_default(doc, |a| &mut a.drop_cap_align_left, false); + } +} + +/// Pin a paragraph attribute to the value an older format resolved it to where nothing set it: +/// at the start of each paragraph style chain that leaves it unset, and on paragraphs whose style +/// is missing. +fn pin_para_default(doc: &mut Document, field: fn(&mut ParaAttrs) -> &mut Option, old: T) { + let unset = |a: &ParaAttrs| field(&mut a.clone()).is_none(); + let roots: std::collections::HashSet = doc + .styles + .paragraph + .iter() + .filter_map(|s| { + let chain = doc.styles.para_chain(&s.name); + if chain.iter().all(|c| unset(&c.para)) { chain.first().map(|c| c.name.clone()) } else { None } + }) + .collect(); + if !roots.is_empty() { + for s in doc.styles_mut().paragraph.iter_mut().filter(|s| roots.contains(&s.name)) { + *field(&mut s.para) = Some(old.clone()); + } + } + let styles = doc.styles.clone(); + let mut pin = |st: &mut Story| { + for p in st.paras.iter_mut().filter(|p| styles.para(&p.style).is_none()) { + let v = field(&mut p.para); + if v.is_none() { + *v = Some(old.clone()); + } + } + }; + for st in doc.stories.values_mut() { + let st = Arc::make_mut(st); + st.for_each_text_mut(&mut pin); + for n in &mut st.endnotes { + Arc::make_mut(n).text.for_each_text_mut(&mut pin); + } + } +} + fn load_legacy_json(bytes: &[u8]) -> Result { let mut v: Value = serde_json::from_slice(bytes).map_err(|e| FormatError::NotOurs(e.to_string()))?; - upgrade(&mut v); + upgrade_json(&mut v); let mut doc: Document = serde_json::from_value(v.clone()).map_err(|e| FormatError::NotOurs(e.to_string()))?; if let Some(data) = v.get("assetData").and_then(Value::as_object) { for (k, s) in data { @@ -142,6 +191,7 @@ fn load_legacy_json(bytes: &[u8]) -> Result { } } } + upgrade(&mut doc, 1); doc.merge_duplicate_assets(); doc.check().map_err(|e| FormatError::NotOurs(e.to_string()))?; Ok(doc) @@ -150,7 +200,7 @@ fn load_legacy_json(bytes: &[u8]) -> Result { /// Bring older documents' JSON up to the current model. Vertical Type was a text frame option /// (`content.options.vertical`); it is the story's direction, which a story takes from its first /// frame. -fn upgrade(doc: &mut Value) { +fn upgrade_json(doc: &mut Value) { let mut vertical = HashSet::new(); vertical_frames(doc, &mut vertical, 0); if vertical.is_empty() { @@ -293,6 +343,122 @@ mod tests { assert_eq!(back.stories, d.stories); } + /// `doc` as format 1 wrote it. + fn save_v1(doc: &Document) -> Vec { + 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); + } + + /// Format 1 wrote paragraph rules, text frame options and numbered lists in full, so their + /// old defaults (black rules, auto-size from the top centre, lists continuing across stories) + /// load as saved. New documents take InDesign's. + #[test] + fn format_1_rules_frames_and_lists_load_as_saved() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let old_rule = designcraft_doc::Rule { on: true, color: designcraft_doc::color::swatch::BLACK.into(), ..Default::default() }; + let (fid, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 200.0, 200.0), lid, "Ruled", ParaFormat::default()).unwrap(); + d.story_mut(sid).unwrap().paras[0].para.rule_above = Some(old_rule.clone()); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.auto_size_ref = 1; + d.settings.lists.push(designcraft_doc::NumberedList { name: "Steps".into(), continue_across_stories: true }); + let back = load(&save_v1(&d)).unwrap(); + assert_eq!(back.story(sid).unwrap().paras[0].para.rule_above, Some(old_rule)); + assert_eq!(back.item(fid).unwrap().text_frame().unwrap().options.auto_size_ref, 1); + assert!(back.settings.lists[0].continue_across_stories); + assert_eq!(designcraft_doc::Rule::default().color, designcraft_doc::TEXT_COLOR); + assert_eq!(designcraft_doc::TextFrameOptions::default().auto_size_ref, 4); + assert!(!designcraft_doc::NumberedList::default().continue_across_stories); + } + + /// Format 1 set drop caps without Align Left Edge: those documents still do where nothing set + /// it. New documents align the drop cap's left edge. + #[test] + fn format_1_drop_caps_stay_unaligned() { + let mut d = Document::new(&NewDocument::default()); + let st = d.styles_mut(); + st.paragraph.push(para_style("Opener", None, None)); + st.paragraph.push(designcraft_doc::ParagraphStyle { + para: designcraft_doc::ParaAttrs { drop_cap_align_left: Some(true), ..Default::default() }, + ..para_style("Aligned", None, None) + }); + let lid = d.default_layer(); + let (_, sid) = + d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 200.0, 200.0), lid, "One\nTwo\nThree", ParaFormat::default()).unwrap(); + { + let s = d.story_mut(sid).unwrap(); + for (p, name) in s.paras.iter_mut().zip(["Opener", "Aligned", "Missing"]) { + p.style = name.into(); + p.para.drop_cap_lines = Some(2); + p.para.drop_cap_chars = Some(1); + } + } + let back = load(&save_v1(&d)).unwrap(); + let aligned: Vec = back.story(sid).unwrap().paras.iter().map(|p| back.styles.resolve_para(p).0.drop_cap_align_left).collect(); + assert_eq!(aligned, [false, true, false]); + let now = load(&save(&d).unwrap()).unwrap(); + assert!(now.styles.resolve_para(&now.story(sid).unwrap().paras[0]).0.drop_cap_align_left); + } + #[test] fn saves_hold_each_used_file_once() { let mut d = Document::new(&NewDocument::default()); diff --git a/crates/geom/src/corners.rs b/crates/geom/src/corners.rs index 6b0c9cb..3eecacd 100644 --- a/crates/geom/src/corners.rs +++ b/crates/geom/src/corners.rs @@ -221,6 +221,32 @@ mod tests { assert!((a - std::f64::consts::PI * 100.0).abs() < 1.0, "{a}"); } + /// InDesign draws a 28.35 pt radius on a 51.02 pt tall frame as 25.51 (half the shorter side), + /// as a circular arc (handles 0.5523 × the radius). + #[test] + fn radius_is_clamped_to_half_the_shorter_side() { + let r = rectangle(Rect::new(0.0, 0.0, 200.0, 51.02)); + let bp = apply(&r, &CornerOptions::uniform(CornerShape::Rounded, 28.35)); + let half = 51.02 / 2.0; + let mut arcs = 0; + let mut last = Point::ZERO; + for el in bp.elements() { + match *el { + kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) => last = p, + kurbo::PathEl::CurveTo(c1, c2, p) => { + assert!(((p - last).x.abs() - half).abs() < 1e-9 && ((p - last).y.abs() - half).abs() < 1e-9, "{last:?} → {p:?}"); + assert!(((c1 - last).hypot() - half * crate::shapes::KAPPA).abs() < 1e-9); + assert!(((c2 - p).hypot() - half * crate::shapes::KAPPA).abs() < 1e-9); + arcs += 1; + last = p; + } + _ => {} + } + } + assert_eq!(arcs, 4); + assert!((crate::shapes::KAPPA - 0.5523).abs() < 1e-4); + } + fn nearest(bp: &BezPath, p: Point) -> f64 { use kurbo::ParamCurveNearest; bp.segments().map(|s| s.nearest(p, 1e-6).distance_sq.sqrt()).fold(f64::INFINITY, f64::min) diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index d01abe9..b7e5ca3 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -866,7 +866,8 @@ impl<'a> Ex<'a> { } (ps.para.clone(), ps.chars.clone()) }; - self.para_attrs(&mut el, &mut props, &pa); + let parent = (ps.name != designcraft_doc::NO_PARA_STYLE).then(|| ps.based_on.as_deref().unwrap_or(designcraft_doc::NO_PARA_STYLE)); + self.para_attrs(&mut el, &mut props, &pa, parent); self.char_attrs(&mut el, &mut props, &ca); pg.insert(&ps.name, with_props(el, props)); } @@ -1244,7 +1245,9 @@ impl<'a> Ex<'a> { } } - fn para_attrs(&mut self, el: &mut El, props: &mut Vec, a: &ParaAttrs) { + /// Write paragraph attributes `a`, set over the resolved paragraph style `parent` (`None` for the + /// root style). + fn para_attrs(&mut self, el: &mut El, props: &mut Vec, a: &ParaAttrs, parent: Option<&str>) { if let Some(v) = &a.mojikumi { props.push(p("Mojikumi", if v.starts_with("MojikumiTable/") { "object" } else { "enumeration" }, v)); } @@ -1292,8 +1295,31 @@ impl<'a> Ex<'a> { None => el.set("NumberingContinue", "true"), } } - // Nested and GREP styles: property lists of records. - if let Some(list) = &a.nested_styles { + // Nested and GREP styles: property lists of records. The drop cap's character style is the + // nested style list's leading "through 1 Dropcap" entry (IDML has no attribute for it): a + // drop cap style set here (`[None]` included) restyles that entry, and a real one is + // inserted where there is none. Without a list of its own, the inherited list is written + // when that is what carries the drop cap style, since any list replaces the inherited one. + let real = |s: &str| !s.is_empty() && s != st::NO_CHAR_STYLE; + let cap = a.drop_cap_style.as_deref().filter(|s| !s.is_empty()); + let mut list = a.nested_styles.clone(); + if let (Some(cap), None) = (cap, &list) { + let inherited = parent.map(|n| self.d.styles.resolve_para_style(n).0); + let inherited_cap = inherited + .as_ref() + .is_some_and(|pp| real(&pp.drop_cap_style) || pp.nested_styles.first().is_some_and(|ns| ns.is_drop_cap() && real(&ns.style))); + if real(cap) || inherited_cap { + list = Some(inherited.map(|pp| pp.nested_styles).unwrap_or_default()); + } + } + if let (Some(cap), Some(list)) = (cap, list.as_mut()) { + match list.first_mut() { + Some(ns) if ns.is_drop_cap() => ns.style = cap.to_string(), + _ if real(cap) => list.insert(0, designcraft_doc::NestedStyle::drop_cap(cap)), + _ => {} + } + } + if let Some(list) = &list { let mut l = El::new("AllNestedStyles").attr("type", "list"); for ns in list { let (ty, delim) = names::nested_until_out(&ns.until); @@ -1377,6 +1403,12 @@ impl<'a> Ex<'a> { n!(space_after, "SpaceAfter"); n!(drop_cap_lines, "DropCapLines"); n!(drop_cap_chars, "DropCapCharacters"); + if a.drop_cap_align_left.is_some() || a.drop_cap_scale_descenders.is_some() { + let d = parent.map(|name| self.d.styles.resolve_para_style(name).0).unwrap_or_default(); + let bits = u32::from(a.drop_cap_align_left.unwrap_or(d.drop_cap_align_left)) + | u32::from(a.drop_cap_scale_descenders.unwrap_or(d.drop_cap_scale_descenders)) << 1; + el.set("DropcapDetail", bits); + } if let Some(g) = a.grid_align { match g { designcraft_doc::GridAlign::None => el.set("GridAlignment", "None"), @@ -1528,7 +1560,11 @@ impl<'a> Ex<'a> { el.set(&format!("{k}Offset"), num(r.offset)); el.set(&format!("{k}LeftIndent"), num(r.left_indent)); el.set(&format!("{k}RightIndent"), num(r.right_indent)); - props.push(p(&format!("{k}Color"), "object", self.sw(&r.color))); + props.push(if r.color == designcraft_doc::TEXT_COLOR { + p(&format!("{k}Color"), "string", designcraft_doc::TEXT_COLOR) + } else { + p(&format!("{k}Color"), "object", self.sw(&r.color)) + }); } fn stroke_attrs(&self, el: &mut El, s: &designcraft_doc::Stroke) { @@ -2404,7 +2440,7 @@ impl<'a> Ex<'a> { fn psr_el(&mut self, pf: &ParaFormat) -> El { let mut el = El::new("ParagraphStyleRange").attr("AppliedParagraphStyle", names::style_self("ParagraphStyle", PARA_BUILTINS, &pf.style)); let mut props = Vec::new(); - self.para_attrs(&mut el, &mut props, &pf.para); + self.para_attrs(&mut el, &mut props, &pf.para, Some(&pf.style)); self.char_attrs(&mut el, &mut props, &pf.chars); with_props(el, props) } diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 59ca44b..1a4fa65 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -253,9 +253,12 @@ fn parse_point(s: Option<&str>) -> Option { (v.len() >= 2).then(|| Point::new(v[0], v[1])) } -/// IDML tint (percent, `-1` = default) → 0..1. +/// InDesign's corner size where a file gives a corner shape without one. +const INDESIGN_CORNER_SIZE: f64 = 12.0; + +/// IDML tint (percent; `-1` = the colour's own tint, 100 %) → 0..1. fn tint(v: Option) -> Option { - v.map(|t| if t < 0.0 { 1.0 } else { (t / 100.0) as f32 }) + v.map(|t| if t < 0.0 || t.is_nan() { 1.0 } else { (t / 100.0).min(1.0) as f32 }) } impl<'r> Importer<'r> { @@ -788,10 +791,10 @@ impl<'r> Importer<'r> { /// A swatch reference (Self id) → our swatch name; unnamed colours become value-named swatches. /// A `TextFramePreference`, with its column rule colour resolved to a swatch. - fn frame_options(&mut self, e: &El) -> TextFrameOptions { - let mut o = text_frame_options(e); - if let Some(r) = e.get("ColumnRuleStrokeColor") { - o.column_rule_color = self.swatch_ref(r); + fn frame_options(&mut self, prefs: &[&El]) -> TextFrameOptions { + let mut o = text_frame_options(prefs); + if let Some(r) = Prefs(prefs).prop("ColumnRuleStrokeColor") { + o.column_rule_color = self.swatch_ref(&r); } o } @@ -852,7 +855,7 @@ impl<'r> Importer<'r> { let name = e.get("Name").unwrap_or("").to_string(); if !name.is_empty() && !name.starts_with("$ID/") && !self.lists.iter().any(|l| l.name == name) { self.lists - .push(designcraft_doc::NumberedList { name, continue_across_stories: e.get("ContinueNumbersAcrossStories") != Some("false") }); + .push(designcraft_doc::NumberedList { name, continue_across_stories: e.get("ContinueNumbersAcrossStories") == Some("true") }); } } let mut paras: Vec<(String, El)> = Vec::new(); @@ -1000,7 +1003,7 @@ impl<'r> Importer<'r> { .then(|| e.get("AppliedParagraphStyle").map(|r| self.para_style_ref(r))) .flatten(); let text_frame = if e.get("EnableTextFrameGeneralOptions") == Some("true") { - e.find("TextFramePreference").map(|t| self.frame_options(t)) + e.find("TextFramePreference").map(|t| self.frame_options(&[t])) } else { None }; @@ -1118,7 +1121,7 @@ impl<'r> Importer<'r> { o.span_columns = e.boolean("EnableStraddling").unwrap_or(false); o.rule.on = e.boolean("RuleOn").unwrap_or(true); o.rule.weight = e.num("RuleLineWeight").unwrap_or(1.0); - o.rule.tint = (e.num("RuleTint").unwrap_or(100.0) / 100.0).clamp(0.0, 1.0) as f32; + o.rule.tint = tint(e.num("RuleTint")).unwrap_or(1.0); o.rule.left_indent = e.num("RuleLeftIndent").unwrap_or(0.0); o.rule.width = e.num("RuleWidth").unwrap_or(72.0); o.rule.offset = e.num("RuleOffset").unwrap_or(0.0); @@ -1202,7 +1205,7 @@ impl<'r> Importer<'r> { // An explicit Text Color resets an inherited swatch; absence still inherits. // Resolve the sentinel before swatch_ref, which maps unknown names to [None]. let color = e.prop(&format!("{k}Color")).map(|r| if r.trim() == "Text Color" { String::new() } else { self.swatch_ref(r.trim()) }); - let tint = e.num(&format!("{k}Tint")).filter(|v| *v >= 0.0).map(|v| (v / 100.0) as f32); + let tint = tint(e.num(&format!("{k}Tint"))); if k == "Underline" { a.underline_weight = num("Weight").map(Some); a.underline_offset = num("Offset").map(Some); @@ -1336,6 +1339,9 @@ impl<'r> Importer<'r> { } }) .collect(); + // IDML has no drop cap style attribute: the drop cap's character style is the list's + // leading "through 1 Dropcap" nested style. + a.drop_cap_style = v.first().filter(|ns| ns.is_drop_cap()).map(|ns| ns.style.clone()); a.nested_styles = Some(v); } if let Some(l) = e.prop_el("AllNestedLineStyles") { @@ -1364,6 +1370,12 @@ impl<'r> Importer<'r> { a.space_after = e.num("SpaceAfter"); a.drop_cap_lines = u("DropCapLines"); a.drop_cap_chars = u("DropCapCharacters"); + // DropcapDetail bits: 1 = Align Left Edge, 2 = Scale for Descenders. + if let Some(v) = e.num("DropcapDetail").filter(|v| v.is_finite() && *v >= 0.0) { + let bits = v as u32; + a.drop_cap_align_left = Some(bits & 1 != 0); + a.drop_cap_scale_descenders = Some(bits & 2 != 0); + } if let Some(g) = e.prop("GridAlignment") { a.grid_align = Some(if g == "None" { GridAlign::None @@ -1475,7 +1487,9 @@ impl<'r> Importer<'r> { } if let Some(c) = e.prop(&format!("{k}Color")) { let c = c.trim(); - if c.contains('/') { + if c == designcraft_doc::TEXT_COLOR { + r.color = c.to_string(); + } else if c.contains('/') { r.color = self.swatch_ref(c); } } @@ -2006,25 +2020,33 @@ impl<'r> Importer<'r> { }) } - /// Resolve an item attribute, falling back to its object style chain. - fn attr_or_style(&self, e: &El, k: &str) -> Option { - if let Some(v) = e.get(k) { - return Some(v.to_string()); - } + /// An item's object style chain: its applied style, the styles that one is based on, then + /// `[None]` (cycle-safe). + fn object_style_chain(&self, e: &El) -> Vec<&El> { + let mut chain: Vec<&El> = Vec::new(); let mut os = e.get("AppliedObjectStyle").map(str::to_string); - let mut depth = 0; while let Some(id) = os { - depth += 1; - if depth > 16 { + let Some(s) = self.object_els.get(&id) else { break }; + if chain.len() >= 16 || chain.iter().any(|c| std::ptr::eq(*c, s)) { break; } - let Some(s) = self.object_els.get(&id) else { break }; - if let Some(v) = s.get(k) { - return Some(v.to_string()); - } - os = s.prop("BasedOn").map(|b| if b.starts_with("ObjectStyle/") { b } else { format!("ObjectStyle/{}", names::escape_id(&b)) }); + chain.push(s); + os = s.prop("BasedOn").map(|b| { + let b = b.trim(); + if b.starts_with("ObjectStyle/") { b.to_string() } else { format!("ObjectStyle/{}", names::escape_id(b)) } + }); } - None + if let Some(none) = self.object_els.get("ObjectStyle/$ID/[None]") + && !chain.iter().any(|c| std::ptr::eq(*c, none)) + { + chain.push(none); + } + chain + } + + /// Resolve an item attribute, falling back to its object style chain. + fn attr_or_style(&self, e: &El, k: &str) -> Option { + e.get(k).or_else(|| self.object_style_chain(e).into_iter().find_map(|s| s.get(k))).map(str::to_string) } fn stroke_from(&mut self, e: &El, item: Option<&El>) -> Stroke { @@ -2130,19 +2152,21 @@ impl<'r> Importer<'r> { it.stroke.weight = 1.0; } } - // Corners. - let cn = crate::export::corner_names(&it.path); - // The legacy all-corners attributes apply only when no per-corner attribute is present. - let per_corner = cn.iter().any(|n| e.get(&format!("{n}CornerOption")).is_some()); - let legacy = if per_corner { (None, None) } else { (e.get("CornerOption"), e.num("CornerRadius")) }; - let mut corners = CornerOptions::default(); - for (i, n) in cn.iter().enumerate() { - let shape = e.get(&format!("{n}CornerOption")).or(legacy.0).map(names::corner_in).unwrap_or_default(); - let size = e.num(&format!("{n}CornerRadius")).or(legacy.1).unwrap_or(0.0); - corners.corners[i] = Corner { shape, size }; - } - if !corners.is_none() { - it.corners = corners; + // Corners: each corner's shape and radius come from the item, then its object style chain; + // at each level a per-corner attribute wins over the legacy all-corners one. + if tag != "Group" { + let levels: Vec<&El> = std::iter::once(e).chain(self.object_style_chain(e)).collect(); + let corner = + |n: &str, what: &str| levels.iter().find_map(|s| s.get(&format!("{n}Corner{what}")).or_else(|| s.get(&format!("Corner{what}")))); + let mut corners = CornerOptions::default(); + for (i, n) in crate::export::corner_names(&it.path).iter().enumerate() { + let shape = corner(n, "Option").map(names::corner_in).unwrap_or_default(); + let size = corner(n, "Radius").and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()).unwrap_or(INDESIGN_CORNER_SIZE); + corners.corners[i] = Corner { shape, size }; + } + if !corners.is_none() { + it.corners = corners; + } } // Transparency. if let Some(t) = e.find("TransparencySetting") { @@ -2227,7 +2251,13 @@ impl<'r> Importer<'r> { sid } }; - let mut options = e.find("TextFramePreference").map(|t| self.frame_options(t)).unwrap_or_default(); + // Options the frame doesn't set come from its object style chain. + let prefs: Vec<&El> = std::iter::once(e).chain(self.object_style_chain(e)).filter_map(|s| s.find("TextFramePreference")).collect(); + let mut options = text_frame_options(&prefs); + // `prefs` borrows the object styles, so the rule colour's swatch is resolved after it. + if let Some(r) = Prefs(&prefs).prop("ColumnRuleStrokeColor") { + options.column_rule_color = self.swatch_ref(&r); + } if let Some(g) = e.find("BaselineFrameGridOption") && g.get("UseCustomBaselineFrameGrid") == Some("true") { @@ -2403,12 +2433,35 @@ impl<'r> Importer<'r> { self.spreads.iter().chain(self.parents.iter()).find_map(|s| find(&s.items, id)) } + /// A nested style list replaces the one a style or paragraph inherits, and with it the drop + /// cap style (the list's leading "through 1 Dropcap" entry, read into the drop cap style). A + /// list of its own without that entry clears an inherited drop cap style. + fn clear_inherited_drop_cap_styles(&mut self) { + let clears = |a: &designcraft_doc::ParaAttrs| a.drop_cap_style.is_none() && a.nested_styles.is_some(); + let cleared: Vec = self + .styles + .paragraph + .iter() + .filter(|s| clears(&s.para) && inherits_drop_cap_style(&self.styles, s.based_on.as_deref())) + .map(|s| s.name.clone()) + .collect(); + for name in cleared { + if let Some(s) = self.styles.para_mut(&name) { + s.para.drop_cap_style = Some(st::NO_CHAR_STYLE.into()); + } + } + for story in self.stories.values_mut() { + clear_story_drop_cap_styles(&self.styles, story, 0); + } + } + fn finish(mut self, root: &El) -> Result { let title = root.get("Name").map(|n| n.trim_end_matches(".indd").to_string()).filter(|n| !n.is_empty()).unwrap_or_else(|| "Untitled".into()); if self.spreads.is_empty() { return Err(IdmlError::Invalid("document has no spreads".into())); } self.settings.lists = std::mem::take(&mut self.lists); + self.clear_inherited_drop_cap_styles(); // Make sure built-in paragraph styles exist and are first. let d = Document { title, @@ -2525,24 +2578,26 @@ fn ace_char(v: &str) -> Option { } fn margins_of(m: &El) -> (Margins, Columns) { - let g = |k: &str, d: f64| m.num(k).unwrap_or(d); + let g = |k: &str, d: f64| m.num(k).filter(|v| v.is_finite()).unwrap_or(d); ( Margins { top: g("Top", 36.0), bottom: g("Bottom", 36.0), inside: g("Left", 36.0), outside: g("Right", 36.0) }, - Columns { count: g("ColumnCount", 1.0).max(1.0) as u32, gutter: g("ColumnGutter", 12.0), positions: None }, + Columns { count: g("ColumnCount", 1.0).clamp(1.0, 216.0) as u32, gutter: g("ColumnGutter", 12.0), positions: None }, ) } -fn text_frame_options(e: &El) -> TextFrameOptions { +/// Text Frame Options from `TextFramePreference` elements: each attribute from the first that sets it. +fn text_frame_options(prefs: &[&El]) -> TextFrameOptions { + let e = Prefs(prefs); let mut o = TextFrameOptions::default(); if let Some(v) = e.num("TextColumnCount") { - o.columns = v.max(1.0) as u32; + o.columns = v.clamp(1.0, 216.0) as u32; } if let Some(v) = e.num("TextColumnGutter") { o.gutter = v; } - o.columns_kind = if e.get("UseFixedColumnWidth") == Some("true") { + o.columns_kind = if e.prop("UseFixedColumnWidth").as_deref().map(str::trim) == Some("true") { ColumnsKind::FixedWidth - } else if e.get("UseFlexibleColumnWidth") == Some("true") { + } else if e.prop("UseFlexibleColumnWidth").as_deref().map(str::trim) == Some("true") { ColumnsKind::FlexibleWidth } else { ColumnsKind::FixedNumber @@ -2552,31 +2607,33 @@ fn text_frame_options(e: &El) -> TextFrameOptions { { o.column_width = v; } - o.balance_columns = e.get("VerticalBalanceColumns") == Some("true"); - if let Some(inset) = inset_spacing(e) { + o.balance_columns = e.prop("VerticalBalanceColumns").as_deref().map(str::trim) == Some("true"); + if let Some(p) = prefs.iter().find(|p| p.prop("InsetSpacing").is_some()) + && let Some(inset) = inset_spacing(p) + { o.inset = inset; } - if let Some(v) = e.get("VerticalJustification") { - o.vertical_justification = names::vj_in(v); + if let Some(v) = e.prop("VerticalJustification") { + o.vertical_justification = names::vj_in(v.trim()); } if let Some(v) = e.num("VerticalThreshold") { o.vj_paragraph_spacing_limit = v; } - if let Some(v) = e.get("FirstBaselineOffset") { - o.first_baseline = names::first_baseline_in(v); + if let Some(v) = e.prop("FirstBaselineOffset") { + o.first_baseline = names::first_baseline_in(v.trim()); } if let Some(v) = e.num("MinimumFirstBaselineOffset") { o.first_baseline_min = v; } - o.ignore_wrap = e.get("IgnoreWrap") == Some("true"); - if let Some(v) = e.get("AutoSizingType") { - o.auto_size = names::auto_size_in(v); + o.ignore_wrap = e.prop("IgnoreWrap").as_deref().map(str::trim) == Some("true"); + if let Some(v) = e.prop("AutoSizingType") { + o.auto_size = names::auto_size_in(v.trim()); } - if let Some(v) = e.get("AutoSizingReferencePoint") { - o.auto_size_ref = names::REF_POINTS.iter().position(|p| *p == v).unwrap_or(1) as u8; + if let Some(v) = e.prop("AutoSizingReferencePoint") { + o.auto_size_ref = names::REF_POINTS.iter().position(|p| *p == v.trim()).unwrap_or(1) as u8; } // Column rules (the colour is a swatch reference: see `Importer::frame_options`). - o.column_rule = e.get("ColumnRuleOverride") == Some("true"); + o.column_rule = e.prop("ColumnRuleOverride").as_deref().map(str::trim) == Some("true"); let finite = |k: &str| e.num(k).filter(|v| v.is_finite()); if let Some(v) = finite("ColumnRuleStrokeWidth") { o.column_rule_weight = v.clamp(0.0, 1000.0); @@ -2596,6 +2653,60 @@ fn text_frame_options(e: &El) -> TextFrameOptions { o } +/// Deepest table-in-cell or note nesting the drop cap pass walks into. +const MAX_STORY_NESTING: usize = 32; + +/// [`Importer::clear_inherited_drop_cap_styles`] for one story's paragraphs and those of the table +/// cells, footnotes and endnotes in it. +fn clear_story_drop_cap_styles(styles: &Styles, story: &mut Story, depth: usize) { + for p in &mut story.paras { + if p.para.drop_cap_style.is_none() && p.para.nested_styles.is_some() && inherits_drop_cap_style(styles, Some(&p.style)) { + p.para.drop_cap_style = Some(st::NO_CHAR_STYLE.into()); + } + } + if depth >= MAX_STORY_NESTING { + return; + } + for table in story.tables.values_mut() { + for cell in &mut Arc::make_mut(table).cells { + clear_story_drop_cap_styles(styles, &mut cell.text, depth + 1); + } + } + for note in story.notes.iter_mut().chain(story.endnotes.iter_mut()) { + clear_story_drop_cap_styles(styles, &mut Arc::make_mut(note).text, depth + 1); + } +} + +/// Does paragraph style `name` (and its based-on chain) set a drop cap character style? A style +/// with a nested style list of its own and no drop cap style has none. +fn inherits_drop_cap_style<'a>(styles: &'a Styles, mut name: Option<&'a str>) -> bool { + let mut seen = HashSet::new(); + while let Some(n) = name { + let Some(s) = styles.para(n).filter(|_| seen.insert(n)) else { break }; + if let Some(c) = s.para.drop_cap_style.as_deref() { + return !c.is_empty() && c != st::NO_CHAR_STYLE; + } + if s.para.nested_styles.is_some() { + return false; + } + name = s.based_on.as_deref(); + } + false +} + +/// Property lookup over preference levels: the nearest level wins, attributes before children. +struct Prefs<'a>(&'a [&'a El]); + +impl Prefs<'_> { + fn prop(&self, k: &str) -> Option { + self.0.iter().find_map(|e| e.prop(k)) + } + fn num(&self, k: &str) -> Option { + // The nearest preference wins; within it, attributes precede property children. + self.prop(k).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()) + } +} + /// A scalar applies to every edge; a list is top, left, bottom, right. Keep the /// existing property-element precedence over the legacy scalar attribute, even /// when the property is malformed. Finite negative insets remain supported. @@ -2652,3 +2763,244 @@ pub(crate) fn uri_to_path(uri: &str) -> String { }; percent_decode(rest) } + +#[cfg(test)] +mod numeric_property_tests { + use super::*; + + fn pref(attrs: &[(&str, &str)], props: &[(&str, &str)]) -> El { + let mut e = El::new("TextFramePreference"); + for (key, value) in attrs { + e.set(key, *value); + } + let mut p = El::new("Properties"); + for (key, value) in props { + p.push(El::new(key).text(*value)); + } + e.push(p); + e + } + + #[test] + fn numeric_property_forms_import_every_frame_option() { + let local = pref( + &[("UseFixedColumnWidth", "true")], + &[ + ("TextColumnCount", "3"), + ("TextColumnGutter", "17"), + ("TextColumnFixedWidth", "123"), + ("VerticalThreshold", "19"), + ("MinimumFirstBaselineOffset", "8"), + ("ColumnRuleStrokeWidth", "2"), + ("ColumnRuleStrokeTint", "25"), + ("ColumnRuleOffset", "3"), + ("ColumnRuleTopInset", "4"), + ("ColumnRuleBottomInset", "5"), + ], + ); + let o = text_frame_options(&[&local]); + assert_eq!((o.columns, o.gutter, o.column_width), (3, 17., 123.)); + assert_eq!((o.vj_paragraph_spacing_limit, o.first_baseline_min), (19., 8.)); + assert_eq!( + (o.column_rule_weight, o.column_rule_tint, o.column_rule_offset, o.column_rule_top_inset, o.column_rule_bottom_inset), + (2., 0.25, 3., 4., 5.) + ); + } + + #[test] + fn numeric_property_precedence_is_nearest_level_then_attribute() { + let local = pref(&[("TextColumnCount", "4")], &[("TextColumnCount", "5"), ("TextColumnGutter", "17")]); + let child = pref( + &[("TextColumnGutter", "90"), ("MinimumFirstBaselineOffset", "8")], + &[("MinimumFirstBaselineOffset", "9"), ("VerticalThreshold", "19")], + ); + let base = pref(&[("VerticalThreshold", "99")], &[("ColumnRuleStrokeWidth", "2")]); + let o = text_frame_options(&[&local, &child, &base]); + assert_eq!((o.columns, o.gutter, o.first_baseline_min, o.vj_paragraph_spacing_limit, o.column_rule_weight), (4, 17., 8., 19., 2.)); + // An explicit invalid value still masks inherited values, as attribute lookup did. + let invalid = pref(&[("TextColumnCount", "bad")], &[("TextColumnCount", "6")]); + assert_eq!(text_frame_options(&[&invalid, &local]).columns, TextFrameOptions::default().columns); + } +} + +#[cfg(test)] +mod frame_preference_safety_tests { + use super::*; + + fn pref(attrs: &[(&str, &str)], props: &[(&str, &str)]) -> El { + let mut e = El::new("TextFramePreference"); + for (key, value) in attrs { + e.set(key, *value); + } + let mut p = El::new("Properties"); + for (key, value) in props { + p.push(El::new(key).text(*value)); + } + e.push(p); + e + } + + #[test] + fn frame_preference_nonfinite_numbers_keep_finite_defaults() { + const KEYS: [&str; 10] = [ + "TextColumnCount", + "TextColumnGutter", + "TextColumnFixedWidth", + "VerticalThreshold", + "MinimumFirstBaselineOffset", + "ColumnRuleStrokeWidth", + "ColumnRuleStrokeTint", + "ColumnRuleOffset", + "ColumnRuleTopInset", + "ColumnRuleBottomInset", + ]; + let fallback = pref(&[("UseFixedColumnWidth", "true")], &[]); + let expected = text_frame_options(&[&fallback]); + for value in ["NaN", "inf", "-inf", "1e309"] { + for attribute in [true, false] { + let values: Vec<_> = KEYS.iter().map(|key| (*key, value)).collect(); + let mut invalid = if attribute { pref(&values, &[]) } else { pref(&[], &values) }; + invalid.set("UseFixedColumnWidth", "true"); + let inherited = pref(&[], &[("TextColumnCount", "7"), ("TextColumnGutter", "9")]); + assert_eq!(text_frame_options(&[&invalid, &inherited]), expected, "{value}, attribute={attribute}"); + } + } + } + + #[test] + fn frame_preference_property_booleans_enums_and_precedence() { + let base = pref( + &[ + ("UseFixedColumnWidth", "true"), + ("UseFlexibleColumnWidth", "false"), + ("VerticalBalanceColumns", "false"), + ("IgnoreWrap", "false"), + ("ColumnRuleOverride", "false"), + ("VerticalJustification", "TopAlign"), + ("FirstBaselineOffset", "AscentOffset"), + ("AutoSizingType", "Off"), + ("AutoSizingReferencePoint", "TopCenterPoint"), + ], + &[], + ); + let local = pref( + &[], + &[ + ("UseFixedColumnWidth", "false"), + ("UseFlexibleColumnWidth", "true"), + ("VerticalBalanceColumns", "true"), + ("IgnoreWrap", "true"), + ("ColumnRuleOverride", "true"), + ("VerticalJustification", "BottomAlign"), + ("FirstBaselineOffset", "FixedHeight"), + ("AutoSizingType", "HeightAndWidth"), + ("AutoSizingReferencePoint", "BottomRightPoint"), + ], + ); + let o = text_frame_options(&[&local, &base]); + assert_eq!(o.columns_kind, ColumnsKind::FlexibleWidth); + assert!(o.balance_columns && o.ignore_wrap && o.column_rule); + assert_eq!(o.vertical_justification, designcraft_doc::VerticalJustification::Bottom); + assert_eq!(o.first_baseline, designcraft_doc::FirstBaseline::Fixed); + assert_eq!(o.auto_size, designcraft_doc::AutoSize::HeightAndWidth); + assert_eq!(o.auto_size_ref, 8); + let fixed = pref(&[], &[("UseFixedColumnWidth", "true")]); + assert_eq!(text_frame_options(&[&fixed, &local, &base]).columns_kind, ColumnsKind::FixedWidth); + // At one level an attribute wins over a conflicting property child, for every key. + let mut same_level = local.clone(); + for (key, value) in &base.attrs { + same_level.set(key, value); + } + assert_eq!(text_frame_options(&[&same_level, &local]), text_frame_options(&[&base])); + // Malformed nearer values mask ancestors and use the normal per-field fallback. + let mut malformed = El::new("TextFramePreference"); + for (key, _) in &base.attrs { + malformed.set(key, "invalid"); + } + assert_eq!(text_frame_options(&[&malformed, &local]), TextFrameOptions { auto_size_ref: 1, ..Default::default() }); + } +} + +#[cfg(test)] +mod page_column_import_safety_tests { + use super::*; + + #[test] + fn frame_preference_page_columns_are_bounded_and_finite() { + for (raw, expected) in [("4000000000", 216), ("1e300", 216), ("-10", 1), ("NaN", 1), ("inf", 1)] { + let m = El::new("MarginPreference") + .attr("ColumnCount", raw) + .attr("ColumnGutter", "NaN") + .attr("Top", "inf") + .attr("Bottom", "-inf") + .attr("Left", "NaN") + .attr("Right", "1e309"); + let (margins, columns) = margins_of(&m); + assert_eq!(columns.count, expected); + assert_eq!(columns.gutter, 12.); + assert_eq!(margins, Margins::uniform(36.)); + } + } +} + +#[cfg(test)] +mod frame_preference_scalar_tests { + use super::*; + + #[test] + fn frame_preference_scalar_whitespace() { + let values = [ + ("UseFixedColumnWidth", "true"), + ("UseFlexibleColumnWidth", "false"), + ("VerticalBalanceColumns", "true"), + ("IgnoreWrap", "true"), + ("ColumnRuleOverride", "true"), + ("VerticalJustification", "BottomAlign"), + ("FirstBaselineOffset", "FixedHeight"), + ("AutoSizingType", "HeightAndWidth"), + ("AutoSizingReferencePoint", "BottomRightPoint"), + ]; + let preference = |properties: bool, padded: bool, fixed: bool| { + let mut e = El::new("TextFramePreference"); + let mut props = El::new("Properties"); + for (key, raw) in values { + let raw = match key { + "UseFixedColumnWidth" if !fixed => "false", + "UseFlexibleColumnWidth" if !fixed => "true", + _ => raw, + }; + let value = if padded { format!("\n {raw}\t ") } else { raw.to_string() }; + if properties { + props.push(El::new(key).text(value)); + } else { + e.set(key, value); + } + } + e.push(props); + e + }; + for fixed in [false, true] { + let clean = preference(false, false, fixed); + let expected = text_frame_options(&[&clean]); + assert_eq!(expected.columns_kind, if fixed { ColumnsKind::FixedWidth } else { ColumnsKind::FlexibleWidth }); + assert!(expected.balance_columns && expected.ignore_wrap && expected.column_rule); + assert_eq!(expected.vertical_justification, designcraft_doc::VerticalJustification::Bottom); + assert_eq!(expected.first_baseline, designcraft_doc::FirstBaseline::Fixed); + assert_eq!(expected.auto_size, designcraft_doc::AutoSize::HeightAndWidth); + assert_eq!(expected.auto_size_ref, 8); + for properties in [false, true] { + let padded = preference(properties, true, fixed); + assert_eq!(text_frame_options(&[&padded]), expected); + } + // A malformed nearest attribute masks both its own valid child and farther values. + let mut invalid = preference(true, true, fixed); + for (key, _) in values { + invalid.set(key, " invalid "); + } + assert_eq!(text_frame_options(&[&invalid, &clean]), TextFrameOptions { auto_size_ref: 1, ..Default::default() }); + } + // Object identities use the raw property lookup, not scalar whitespace normalization. + let exact = El::new("TextFramePreference").child(El::new("Properties").child(El::new("ColumnRuleStrokeColor").text(" Color/Named "))); + assert_eq!(Prefs(&[&exact]).prop("ColumnRuleStrokeColor").as_deref(), Some(" Color/Named ")); + } +} diff --git a/crates/idml/src/names.rs b/crates/idml/src/names.rs index 1df0f56..5a47f19 100644 --- a/crates/idml/src/names.rs +++ b/crates/idml/src/names.rs @@ -424,10 +424,11 @@ pub fn first_baseline_out(f: FirstBaseline) -> &'static str { FirstBaseline::Leading => "LeadingOffset", FirstBaseline::XHeight => "XHeight", FirstBaseline::Fixed => "FixedHeight", + FirstBaseline::EmboxHeight => "EmboxHeight", } } pub fn first_baseline_in(s: &str) -> FirstBaseline { - [FirstBaseline::CapHeight, FirstBaseline::Leading, FirstBaseline::XHeight, FirstBaseline::Fixed] + [FirstBaseline::CapHeight, FirstBaseline::Leading, FirstBaseline::XHeight, FirstBaseline::Fixed, FirstBaseline::EmboxHeight] .into_iter() .find(|m| first_baseline_out(*m) == s) .unwrap_or(FirstBaseline::Ascent) @@ -447,6 +448,7 @@ pub fn auto_size_in(s: &str) -> AutoSize { .find(|m| auto_size_out(*m) == s) .unwrap_or(AutoSize::Off) } + /// Auto-size reference points in our 0..9 order (row-major, top-left first). pub const REF_POINTS: [&str; 9] = [ "TopLeftPoint", @@ -635,6 +637,7 @@ pub fn nested_until_out(u: &designcraft_doc::NestedUntil) -> (&'static str, Stri N::ForcedLineBreak => e("ForcedLineBreak"), N::EmSpace => e("EmSpaces"), N::EnSpace => e("EnSpaces"), + N::Dropcap => e("Dropcap"), N::Chars(c) => ("string", c.clone()), } } @@ -653,6 +656,7 @@ pub fn nested_until_in(ty: &str, v: &str) -> designcraft_doc::NestedUntil { "ForcedLineBreak" => N::ForcedLineBreak, "EmSpaces" => N::EmSpace, "EnSpaces" => N::EnSpace, + "Dropcap" => N::Dropcap, _ => N::Words, } } diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index a2ee8f2..57801e3 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -32,6 +32,20 @@ fn zip_files_plain() -> Vec { w.finish().unwrap().into_inner() } +/// The text of every part of the package `bytes` whose name starts with `prefix`. +fn zip_text(bytes: &[u8], prefix: &str) -> String { + use std::io::Read; + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut out = String::new(); + for i in 0..z.len() { + let mut f = z.by_index(i).unwrap(); + if f.name().starts_with(prefix) { + f.read_to_string(&mut out).unwrap(); + } + } + out +} + /// A hand-written, minimal IDML document: one facing-pages spread with a right page, a /// threaded story across two frames, a tint and a grouped paragraph style. const DESIGNMAP: &str = r#" @@ -366,6 +380,17 @@ fn imports_scalar_inset_properties_like_lists_and_attributes() { assert_eq!(frame_inset(&round_trip), [9.0; 4]); } +#[test] +fn em_box_first_baseline_offset_round_trips() { + use designcraft_doc::FirstBaseline; + let first = |d: &Document| d.spreads[0].items[0].text_frame().unwrap().options.first_baseline; + let d = inset_fixture(r#""#, ""); + assert_eq!(first(&d), FirstBaseline::EmboxHeight); + assert_eq!(first(&import_idml(&export_idml(&d)).unwrap()), FirstBaseline::EmboxHeight); + let unknown = inset_fixture(r#""#, ""); + assert_eq!(first(&unknown), FirstBaseline::Ascent); +} + #[test] fn inset_property_keeps_precedence_over_attribute() { for (property, expected) in [ @@ -1456,6 +1481,628 @@ fn list_numbering_format_expression_and_bullet_import_and_round_trip() { check(&import_idml(&export_idml(&d)).unwrap()); } +fn fixture_with_styles(styles: &str) -> Vec { + 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}"); + } + } +} + +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; + "DropcapDetail": "1" => (true, false), "2" => (false, true), |r| (r.0.drop_cap_align_left, r.0.drop_cap_scale_descenders); + "GridAlignment": "None" => GridAlign::None, "AlignToBaseline" => GridAlign::AllLines, |r| r.0.grid_align; + "Composer": "HL Composer" => Composer::Paragraph, "HL Single" => Composer::SingleLine, |r| r.0.composer; + "Hyphenation": "true" => true, "false" => false, |r| r.0.hyphenate; + "HyphenateWordsLongerThan": "5" => 5, "7" => 7, |r| r.0.hyph_min_word; + "HyphenateAfterFirst": "2" => 2, "3" => 3, |r| r.0.hyph_after_first; + "HyphenateBeforeLast": "2" => 2, "3" => 3, |r| r.0.hyph_before_last; + "HyphenateLadderLimit": "3" => 3, "0" => 0, |r| r.0.hyph_limit; + "HyphenationZone": "36" => 36.0, "18" => 18.0, |r| r.0.hyph_zone; + "HyphenateCapitalizedWords": "true" => true, "false" => false, |r| r.0.hyph_capitalized; + "HyphenateLastWord": "true" => true, "false" => false, |r| r.0.hyph_last_word; + "HyphenateAcrossColumns": "true" => true, "false" => false, |r| r.0.hyph_across_column; + "HyphenWeight": "5" => 0.5, "9" => 0.9, |r| r.0.hyph_weight; + "MinimumWordSpacing": "80" => 0.8, "70" => 0.7, |r| r.0.word_space_min; + "DesiredWordSpacing": "100" => 1.0, "90" => 0.9, |r| r.0.word_space_desired; + "MaximumWordSpacing": "133" => 1.33, "150" => 1.5, |r| r.0.word_space_max; + "MinimumLetterSpacing": "0" => 0.0, "-5" => -0.05, |r| r.0.letter_space_min; + "DesiredLetterSpacing": "0" => 0.0, "1" => 0.01, |r| r.0.letter_space_desired; + "MaximumLetterSpacing": "0" => 0.0, "5" => 0.05, |r| r.0.letter_space_max; + "MinimumGlyphScaling": "100" => 1.0, "97" => 0.97, |r| r.0.glyph_scale_min; + "DesiredGlyphScaling": "100" => 1.0, "101" => 1.01, |r| r.0.glyph_scale_desired; + "MaximumGlyphScaling": "100" => 1.0, "103" => 1.03, |r| r.0.glyph_scale_max; + "AutoLeading": "120" => 1.2, "100" => 1.0, |r| r.0.auto_leading; + "SingleWordJustification": "FullyJustified" => Align::FullyJustified, "LeftAlign" => Align::Left, |r| r.0.single_word_justify; + "KeepWithNext": "0" => 0, "1" => 1, |r| r.0.keep_with_next; + "KeepLinesTogether": "false" => false, "true" => true, |r| r.0.keep_lines_together; + "KeepAllLinesTogether": "false" => false, "true" => true, |r| r.0.keep_all_lines; + "KeepFirstLines": "2" => 2, "3" => 3, |r| r.0.keep_first; + "KeepLastLines": "2" => 2, "3" => 3, |r| r.0.keep_last; + "StartParagraph": "Anywhere" => StartParagraph::Anywhere, "NextColumn" => StartParagraph::NextColumn, |r| r.0.start_paragraph; + "BulletsAndNumberingListType": "NoList" => ListType::None, "BulletList" => ListType::Bullets, |r| r.0.list_type; + "BalanceRaggedLines": "NoBalancing" => false, "FullyBalanced" => true, |r| r.0.balance_ragged; + "Kashidas": "DefaultKashidas" => true, "KashidasOff" => false, |r| r.0.kashidas; + "RuleAbove": "false" => false, "true" => true, |r| r.0.rule_above.on; + "RuleAboveColor": "Text Color" => designcraft_doc::TEXT_COLOR, "Color/Black" => "[Black]", |r| r.0.rule_above.color; + "RuleAboveTint": "-1" => 1.0f32, "50" => 0.5f32, |r| r.0.rule_above.tint; + "RuleBelow": "false" => false, "true" => true, |r| r.0.rule_below.on; + "RuleBelowColor": "Text Color" => designcraft_doc::TEXT_COLOR, "Color/Black" => "[Black]", |r| r.0.rule_below.color; + "RuleBelowTint": "-1" => 1.0f32, "50" => 0.5f32, |r| r.0.rule_below.tint; + "ParagraphShadingOn": "false" => false, "true" => true, |r| r.0.shading_on; + "ParagraphShadingColor": "Color/Black" => "[Black]", "Swatch/None" => "[None]", |r| r.0.shading_color; + "ParagraphShadingTint": "20" => 0.2f32, "50" => 0.5f32, |r| r.0.shading_tint; + "ParagraphBorderOn": "false" => false, "true" => true, |r| r.0.border_on; + "ParagraphBorderColor": "Color/Black" => "[Black]", "Swatch/None" => "[None]", |r| r.0.border_color; + "ParagraphBorderTint": "-1" => 1.0f32, "50" => 0.5f32, |r| r.0.border_tint; + "FontStyle": "Regular" => "Regular", "Bold" => "Bold", |r| r.1.font_style; + "PointSize": "12" => 12.0, "9" => 9.0, |r| r.1.size; + "Leading": "Auto" => Leading::Auto, "14" => Leading::Points(14.0), |r| r.1.leading; + "KerningMethod": "$ID/Metrics" => designcraft_doc::Kerning::Metrics, "$ID/Optical" => designcraft_doc::Kerning::Optical, |r| r.1.kerning; + "Tracking": "0" => 0.0, "20" => 20.0, |r| r.1.tracking; + "HorizontalScale": "100" => 1.0, "90" => 0.9, |r| r.1.h_scale; + "VerticalScale": "100" => 1.0, "90" => 0.9, |r| r.1.v_scale; + "BaselineShift": "0" => 0.0, "2" => 2.0, |r| r.1.baseline_shift; + "Skew": "0" => 0.0, "10" => 10.0, |r| r.1.skew; + "FillColor": "Color/Black" => "[Black]", "Swatch/None" => "[None]", |r| r.1.fill; + "FillTint": "-1" => 1.0, "50" => 0.5, |r| r.1.fill_tint; + "StrokeColor": "Swatch/None" => "[None]", "Color/Black" => "[Black]", |r| r.1.stroke; + "StrokeTint": "-1" => 1.0, "50" => 0.5, |r| r.1.stroke_tint; + "StrokeWeight": "1" => 1.0, "2" => 2.0, |r| r.1.stroke_weight; + "Capitalization": "Normal" => Capitalization::Normal, "AllCaps" => Capitalization::AllCaps, |r| r.1.capitalization; + "Position": "Normal" => Position::Normal, "Superscript" => Position::Superscript, |r| r.1.position; + "Underline": "false" => false, "true" => true, |r| r.1.underline; + "StrikeThru": "false" => false, "true" => true, |r| r.1.strikethrough; + "UnderlineTint": "-1" => 1.0f32, "50" => 0.5f32, |r| r.1.underline_tint; + "StrikeThruTint": "-1" => 1.0f32, "50" => 0.5f32, |r| r.1.strikethrough_tint; + "Ligatures": "true" => true, "false" => false, |r| r.1.ligatures; + "NoBreak": "false" => false, "true" => true, |r| r.1.no_break; + "OTFContextualAlternate": "true" => true, "false" => false, |r| designcraft_doc::otf::is_on(&r.1.otf_features, "calt"); + "DigitsType": "DefaultDigits" => Digits::Default, "ArabicDigits" => Digits::Arabic, |r| r.1.digits; + "AppliedLanguage": "$ID/English: USA" => "English: USA", "$ID/German: 2006 Reform" => "German: 2006 Reform", |r| r.1.language; + } +} + +/// [No paragraph style] with `root` attributes and `Body`, based on it, with `body` attributes. +fn text_styles(root: &str, body: &str) -> String { + format!( + r#" + + + + + 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); +} + +/// A tint of -1 is the colour's own tint (100 %): written on a style, it replaces its parent's. +/// Tints are never negative or above 100 %. +#[test] +fn minus_one_tint_is_the_colours_own() { + let tints = |v: &str| { + ["UnderlineTint", "StrikeThruTint", "FillTint", "StrokeTint", "ParagraphShadingTint", "ParagraphBorderTint", "RuleAboveTint", "RuleBelowTint"] + .map(|k| format!(r#"{k}="{v}""#)) + .join(" ") + + r#" RuleAbove="true" RuleBelow="true""# + }; + for (body, want) in [("-1", 1.0), ("150", 1.0)] { + let r = resolve_body(&tints("50"), &tints(body)); + let got = [ + r.1.underline_tint, + r.1.strikethrough_tint, + r.1.fill_tint, + r.1.stroke_tint, + r.0.shading_tint, + r.0.border_tint, + r.0.rule_above.tint, + r.0.rule_below.tint, + ]; + assert_eq!(got, [want; 8], "{body}"); + } + // Footnote rules, table fills and strokes, cell fills. + let prefs = PREFS.replace("", r#""#); + let story = r#" + + +
+
"#; + let d = import_idml_with( + &zip_files(&[ + ("designmap.xml", DESIGNMAP), + ("Resources/Graphic.xml", GRAPHIC), + ("Resources/Styles.xml", STYLES), + ("Resources/Preferences.xml", &prefs), + ("MasterSpreads/MasterSpread_m1.xml", MASTER), + ("Spreads/Spread_sp1.xml", SPREAD), + ("Stories/Story_s1.xml", story), + ]), + &|_| None, + ) + .unwrap(); + assert_eq!(d.footnote_options.rule.tint, 1.0); + let t = d.stories.values().flat_map(|s| s.tables.values()).next().unwrap(); + let alt = t.options.alt_rows.as_ref().unwrap(); + assert_eq!((alt.first_tint, alt.next_tint, t.options.border.tint), (1.0, 1.0, 1.0)); + let c = t.cell(0, 0).unwrap(); + assert_eq!((c.fill_tint, c.strokes[0].tint), (1.0, 1.0)); +} + +/// Rules in Text Color take the colour of the paragraph's text; they round-trip as Text Color. +#[test] +fn text_color_rules_round_trip() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let rule = designcraft_doc::Rule { on: true, ..Default::default() }; + assert_eq!(rule.color, designcraft_doc::TEXT_COLOR); + let para = designcraft_doc::ParaAttrs { + rule_above: Some(rule.clone()), + rule_below: Some(designcraft_doc::Rule { color: "[Black]".into(), ..rule }), + kashidas: Some(false), + ..Default::default() + }; + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(40.0, 40.0, 400.0, 400.0), lid, "Ruled", ParaFormat::default()).unwrap(); + d.story_mut(sid).unwrap().paras[0].para = para.clone(); + let bytes = export_idml(&d); + let story = zip_text(&bytes, "Stories/"); + assert!(story.contains(r#"Text Color"#), "{story}"); + assert!(story.contains(r#"Kashidas="KashidasOff""#), "{story}"); + let back = import_idml(&bytes).unwrap(); + let p = &back.stories.values().find(|s| s.text == "Ruled").unwrap().paras[0]; + assert_eq!((p.para.rule_above.as_ref(), p.para.rule_below.as_ref()), (para.rule_above.as_ref(), para.rule_below.as_ref())); + assert_eq!(p.para.kashidas, Some(false)); +} + +/// `[Basic Paragraph]` is InDesign's `$ID/NormalParagraphStyle`, both ways. +#[test] +fn basic_paragraph_is_normal_paragraph_style() { + let d = import_idml_with(&fixture(), &|_| None).unwrap(); + let basic = d.styles.para(st::BASIC_PARAGRAPH).unwrap(); + assert_eq!(basic.based_on.as_deref(), Some(designcraft_doc::NO_PARA_STYLE)); + assert_eq!(d.styles.para("Text/Body").unwrap().based_on.as_deref(), Some(st::BASIC_PARAGRAPH)); + let s = d.stories.values().find(|s| s.text.starts_with("Hello")).unwrap(); + assert_eq!(s.paras[1].style, st::BASIC_PARAGRAPH); + assert!(d.styles.paragraph.iter().all(|p| !p.name.contains("NormalParagraphStyle"))); + let bytes = export_idml(&d); + let styles = zip_text(&bytes, "Resources/Styles.xml"); + assert!(styles.contains(r#"Self="ParagraphStyle/$ID/NormalParagraphStyle" Name="$ID/NormalParagraphStyle""#), "{styles}"); + assert!(zip_text(&bytes, "Stories/").contains(r#"AppliedParagraphStyle="ParagraphStyle/$ID/NormalParagraphStyle""#)); + let back = import_idml(&bytes).unwrap(); + assert_eq!(back.styles.paragraph.iter().filter(|p| p.name == st::BASIC_PARAGRAPH).count(), 1); +} + +/// Page items, frames, tables and preferences an IDML file leaves unset are InDesign's defaults +/// for a new document. +#[test] +fn absent_item_and_document_attributes_take_indesign_defaults() { + use designcraft_doc::{Cap, FirstBaseline, Join, StrokeAlign, VerticalJustification}; + use designcraft_geom::corners::CornerShape; + let path = |x: f64| { + let pts: String = [(x, 0.0), (x, 50.0), (x + 50.0, 50.0), (x + 50.0, 0.0)] + .iter() + .map(|(x, y)| format!(r#""#)) + .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, o.auto_size_ref), (designcraft_doc::AutoSize::Off, 4), "auto-size from the centre"); + // Numbered lists don't continue across stories. + assert_eq!(d.settings.lists, vec![designcraft_doc::NumberedList { name: "Steps".into(), continue_across_stories: false }]); + // Table and cell options. + let t = d.stories.values().flat_map(|s| s.tables.values()).next().unwrap(); + assert_eq!((t.options.space_before, t.options.space_after, t.options.repeat_header), (4.0, -4.0, true)); + assert_eq!((t.options.border.weight, t.options.border.color.as_str()), (1.0, "[Black]")); + assert_eq!((t.rows[0].mode, t.rows[0].height), (designcraft_doc::RowHeightMode::AtLeast, 3.0)); + // Alternating fills: the first rows or columns Black 20 %, the next None. + let fills = |a: &designcraft_doc::AltFills| (a.first_color.clone(), a.first_tint, a.next_color.clone(), a.next_tint, a.next); + let want = ("[Black]".to_string(), 0.2f32, "[None]".to_string(), 1.0f32, 1); + assert_eq!(fills(t.options.alt_rows.as_ref().unwrap()), want); + let cols = t.options.alt_cols.as_ref().unwrap(); + assert_eq!((fills(cols), cols.first), (want.clone(), 2)); + let banded = d.styles.table.iter().find(|s| s.name == "Banded").unwrap(); + let mut bt = designcraft_doc::Table::new(0, 1, 1, 0, 0, 100.0); + d.styles.resolve_table_style(&banded.name).apply_to(&mut bt, &d.styles.cell); + assert_eq!(fills(bt.options.alt_rows.as_ref().unwrap()), want); + let c = t.cell(0, 0).unwrap(); + assert_eq!((c.insets, c.vj, c.fill.as_str()), ([4.0; 4], VerticalJustification::Top, "[None]")); + for e in &c.strokes { + assert_eq!((e.weight, e.color.as_str(), e.tint, &e.kind), (1.0, "[Black]", 1.0, &designcraft_doc::StrokeType::Solid)); + } +} + +/// IDML has no attribute for a drop cap's character style: it is the nested style list's leading +/// "through 1 Dropcap" entry, read into the drop cap style and written back from it. +#[test] +fn drop_caps_import_and_round_trip() { + // A style with a drop cap and its character style; a paragraph overriding both locally. + let opener = r#" + + CharacterStyle/StrongDropcap + 1true + + "#; + let styles = STYLES.replace("", opener); + let story = r#" + + + + + CharacterStyle/$ID/[No character style]Dropcap + 1true + 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")); + assert_eq!(st.para.nested_styles, Some(vec![designcraft_doc::NestedStyle::drop_cap("Strong")]), "one entry, not duplicated"); + let p = &d.stories.values().find(|s| s.text.starts_with("Once")).unwrap().paras[0]; + assert_eq!((p.para.drop_cap_chars, p.para.drop_cap_lines), (Some(2), Some(2))); + assert_eq!(p.para.drop_cap_style.as_deref(), Some(st::NO_CHAR_STYLE)); + }; + let d = import_idml_with(&bytes, &|_| None).unwrap(); + check(&d); + let bytes = export_idml(&d); + assert!(!zip_text(&bytes, "").contains("DropCapStyle"), "not an IDML attribute"); + check(&import_idml(&bytes).unwrap()); + // A drop cap style set without nested styles is written as one; Align Left Edge and Scale for + // Descenders as DropcapDetail. + let mut d = d; + if let Some(s) = d.styles_mut().para_mut("Opener") { + s.para.nested_styles = None; + s.para.drop_cap_align_left = Some(false); + s.para.drop_cap_scale_descenders = Some(true); + } + let bytes = export_idml(&d); + assert!(!zip_text(&bytes, "").contains("DropCapStyle")); + assert!(zip_text(&bytes, "Resources/Styles.xml").contains(r#"DropcapDetail="2""#)); + let back = import_idml(&bytes).unwrap(); + let st = back.styles.para("Opener").unwrap(); + assert_eq!((st.para.drop_cap_chars, st.para.drop_cap_lines, st.para.drop_cap_style.as_deref()), (Some(1), Some(3), Some("Strong"))); + assert_eq!((st.para.drop_cap_align_left, st.para.drop_cap_scale_descenders), (Some(false), Some(true))); + assert_eq!(st.para.nested_styles, Some(vec![designcraft_doc::NestedStyle::drop_cap("Strong")])); +} + +/// IDML styles and stories with an `Opener` style whose drop cap style is `Strong`, plus `styles` +/// (more paragraph styles) and `paras` (the story's paragraph ranges). `WORDS` in them stands for +/// a nested style list of `Strong` through 1 word. +fn drop_cap_fixture(styles: &str, paras: &str) -> Vec { + let nested = |style: &str, delim: &str| { + format!( + r#"CharacterStyle/{style}{delim}1true"# + ) + }; + let words = nested("Strong", "AnyWord"); + let opener = format!( + r#"{} + {} + "#, + nested("Strong", "Dropcap"), + styles.replace("WORDS", &words) + ); + let styles = STYLES.replace("", &opener); + let story = format!( + r#" + + {} +"#, + paras.replace("WORDS", &words) + ); + zip_files(&[ + ("designmap.xml", DESIGNMAP), + ("Resources/Graphic.xml", GRAPHIC), + ("Resources/Styles.xml", &styles), + ("Resources/Preferences.xml", PREFS), + ("MasterSpreads/MasterSpread_m1.xml", MASTER), + ("Spreads/Spread_sp1.xml", SPREAD), + ("Stories/Story_s1.xml", &story), + ]) +} + +/// A nested style list replaces the inherited one, drop cap style entry included: a style or +/// paragraph with a list of its own and no Dropcap entry has no drop cap style. +#[test] +fn own_nested_styles_clear_an_inherited_drop_cap_style() { + let styles = r#"ParagraphStyle/OpenerWORDS + WORDS"#; + let paras = r#"WORDS + Cleared
+
+ + Inherited + "#; + let d = import_idml_with(&drop_cap_fixture(styles, paras), &|_| None).unwrap(); + let cap = |name: &str| d.styles.para(name).unwrap().para.drop_cap_style.clone(); + assert_eq!(cap("Opener").as_deref(), Some("Strong")); + assert_eq!(cap("Child").as_deref(), Some(st::NO_CHAR_STYLE), "its own list replaces Opener's"); + assert_eq!(cap("Plain"), None, "nothing to clear"); + let story = d.stories.values().find(|s| s.text.starts_with("Cleared")).unwrap(); + assert_eq!(story.paras[0].para.drop_cap_style.as_deref(), Some(st::NO_CHAR_STYLE)); + assert_eq!(story.paras[1].para.drop_cap_style, None); + assert_eq!(d.styles.resolve_para(&story.paras[1]).0.drop_cap_style, "Strong"); +} + +/// Paragraphs in table cells and footnotes are cleared like story paragraphs. +#[test] +fn own_nested_styles_clear_an_inherited_drop_cap_style_in_cells_and_notes() { + let opener = |text: &str| { + format!( + r#"WORDS{text}"# + ) + }; + let paras = format!( + r#" +Body{} +{}
+
"#, + opener("Note"), + opener("Cell") + ); + let d = import_idml_with(&drop_cap_fixture("", ¶s), &|_| None).unwrap(); + let story = d.stories.values().find(|s| !s.notes.is_empty()).expect("story with a footnote"); + let note = &story.notes[0].text; + assert_eq!(note.paras[0].para.drop_cap_style.as_deref(), Some(st::NO_CHAR_STYLE), "{:?}", note.text); + let cell = &story.tables.values().next().expect("a table").cell(0, 0).unwrap().text; + assert_eq!(cell.paras[0].para.drop_cap_style.as_deref(), Some(st::NO_CHAR_STYLE), "{:?}", cell.text); +} + +/// A paragraph that clears its style's drop cap style without nested styles of its own round-trips: +/// it is written with the style's list, the Dropcap entry set to [None]. +#[test] +fn a_cleared_drop_cap_style_round_trips() { + let paras = r#" + Once upon a time + "#; + let mut d = import_idml_with(&drop_cap_fixture("", paras), &|_| None).unwrap(); + let sid = *d.stories.keys().next().unwrap(); + let clear = |d: &mut Document| d.story_mut(sid).unwrap().paras[0].para.drop_cap_style = Some(st::NO_CHAR_STYLE.into()); + let resolved = |d: &Document| { + let st = d.stories.values().find(|s| s.text.starts_with("Once")).unwrap(); + d.styles.resolve_para(&st.paras[0]).0 + }; + clear(&mut d); + let back = import_idml(&export_idml(&d)).unwrap(); + let pp = resolved(&back); + assert_eq!(pp.drop_cap_style, st::NO_CHAR_STYLE); + assert_eq!(pp.nested_styles, vec![designcraft_doc::NestedStyle::drop_cap(st::NO_CHAR_STYLE)]); + // The style's drop cap style without nested styles: the paragraph is written with an empty + // list, which clears it on import. + if let Some(s) = d.styles_mut().para_mut("Opener") { + s.para.nested_styles = None; + } + assert_eq!(resolved(&d).drop_cap_style, st::NO_CHAR_STYLE); + let back = import_idml(&export_idml(&d)).unwrap(); + assert_eq!(back.styles.para("Opener").unwrap().para.drop_cap_style.as_deref(), Some("Strong")); + let pp = resolved(&back); + assert_eq!(pp.drop_cap_style, st::NO_CHAR_STYLE); + assert!(pp.nested_styles.iter().all(|ns| ns.style != "Strong"), "{:?}", pp.nested_styles); +} + +/// Frames take the corners and Text Frame Options they don't write from their object style chain, +/// then `[None]`. Per-corner attributes win over the legacy all-corners pair, at each level; a +/// corner shape without a radius takes the chain's radius. +#[test] +fn frames_inherit_object_style_corners_and_text_frame_options() { + use designcraft_doc::{AutoSize, FirstBaseline, VerticalJustification}; + use designcraft_geom::corners::CornerShape as C; + // Anchors in the order top-left, bottom-left, bottom-right, top-right. + let path = |x: f64| { + let pts: String = [(x, 0.0), (x, 50.0), (x + 50.0, 50.0), (x + 50.0, 0.0)] + .iter() + .map(|(x, y)| format!(r#""#)) + .collect(); + format!( + r#"{pts}"# + ) + }; + let corners = |opt: &str, r: &str| { + ["TopLeft", "TopRight", "BottomLeft", "BottomRight"].map(|n| format!(r#"{n}CornerOption="{opt}" {n}CornerRadius="{r}""#)).join(" ") + }; + let inset = |v: f64| { + format!( + r#"{}"#, + r#"V"#.replace('V', &v.to_string()).repeat(4) + ) + }; + let designmap = format!( + r#" + + + + + + + ObjectStyle/$ID/[None] + {inset} + + + ObjectStyle/Rounded + + + + +{p0} +{p1} +{p2} +{p3} + +"#, + none = corners("None", "12"), + inset = inset(3.0), + p0 = path(0.0), + p1 = path(100.0), + p2 = path(200.0), + p3 = path(300.0), + ); + let d = import_idml_with(&zip_files(&[("designmap.xml", &designmap)]), &|_| None).unwrap(); + let item = |n: usize| d.spreads[0].items[n].clone(); + // Path order: top-left, bottom-left, bottom-right, top-right. + let shapes = |n: usize| item(n).corners.corners.map(|c| (c.shape, c.size)); + assert_eq!(shapes(0), [(C::Rounded, 6.0), (C::Rounded, 12.0), (C::InverseRounded, 11.34), (C::Bevel, 12.0)]); + assert_eq!(shapes(1), [(C::Inset, 5.0), (C::Bevel, 5.0), (C::Bevel, 5.0), (C::Bevel, 5.0)]); + assert!(item(2).corners.corners.iter().enumerate().all(|(i, c)| (c.shape == C::Rounded) == (i == 3))); + assert_eq!(item(2).corners.corners[3].size, 11.34); + assert_eq!(shapes(3), [(C::Rounded, 6.0), (C::Rounded, 12.0), (C::Rounded, 12.0), (C::Rounded, 12.0)]); + let tf = item(0); + let o = &tf.text_frame().unwrap().options; + assert_eq!((o.columns, o.gutter, o.inset), (3, 18.0, [3.0; 4])); + assert_eq!((o.vertical_justification, o.first_baseline, o.first_baseline_min), (VerticalJustification::Center, FirstBaseline::Leading, 4.0)); + assert_eq!((o.ignore_wrap, o.auto_size, o.auto_size_ref), (true, AutoSize::HeightOnly, 4)); +} #[test] fn type_on_a_path_keeps_its_start_and_end_brackets() { use designcraft_geom::{Anchor, PathData, Point, SubPath}; @@ -1479,3 +2126,46 @@ fn type_on_a_path_keeps_its_start_and_end_brackets() { assert_eq!(pt.end.map(|e| e.round()), end, "an end bracket at the path's end is no end bracket"); } } + +#[test] +fn frame_preference_import_caps_column_count_without_composition() { + for (raw, expected) in [("4000000000", 216), ("1e300", 216), ("-100", 1), ("0", 1), ("NaN", 1), ("inf", 1), ("-inf", 1)] { + for attribute in [true, false] { + let preference = if attribute { + format!(r#""#) + } else { + format!( + r#"{raw}"# + ) + }; + let document = format!( + r#"{preference}"# + ); + let d = import_idml_with(&zip_files(&[("designmap.xml", &document)]), &|_| None).unwrap(); + let frame = d.spreads[0].items[0].text_frame().unwrap(); + // Inspect imported data only: never compose an unbounded input value. + assert_eq!(frame.options.columns, expected, "{raw}, attribute={attribute}"); + } + } +} + +#[test] +fn frame_preference_property_color_resolves_for_frames_and_object_styles() { + let document = r#" + + + + +ObjectStyle/BaseColor/Near + + +Color/Near + +Color/Near +missing +"#; + let d = import_idml_with(&zip_files(&[("designmap.xml", document)]), &|_| None).unwrap(); + let colors: Vec<_> = d.spreads[0].items.iter().map(|it| it.text_frame().unwrap().options.column_rule_color.as_str()).collect(); + assert_eq!(colors, vec!["Near", "Near", "Far", designcraft_color::swatch::NONE]); + assert_eq!(d.styles.object_style("Child").unwrap().text_frame.as_ref().unwrap().column_rule_color, "Near"); +} diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index debdbb1..73da6c9 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -172,6 +172,8 @@ mod tests { Err(e) => panic!("accept failed: {e}"), } }; + // The listener polls accept; the connection blocks while waiting for its request. + stream.set_nonblocking(false).unwrap(); stream.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); stream.set_write_timeout(Some(Duration::from_secs(2))).unwrap(); let mut reader = BufReader::new(stream.try_clone().unwrap()); diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index de03535..cd55e34 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -123,7 +123,8 @@ impl Headless { let x = e.get("x").and_then(Value::as_f64).ok_or("pointer event needs numeric `x`")?; let y = e.get("y").and_then(Value::as_f64).ok_or("pointer event needs numeric `y`")?; let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base); - self.session.pointer(&PointerEvent { kind, pos: Point::new(x, y), mods, clicks: 0 }, VIEW).map_err(|e| e.to_string())?; + let clicks = e.get("clicks").and_then(Value::as_u64).map_or(1, |n| n.clamp(1, 255) as u8); + self.session.pointer(&PointerEvent { kind, pos: Point::new(x, y), mods, clicks }, VIEW).map_err(|e| e.to_string())?; } let requests: Vec = 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 c51bac8..f6d2974 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -279,6 +279,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/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 9ac53d0..1aa1a33 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -202,6 +202,18 @@ fn tables_export_cell_text() { } } +#[test] +fn drop_cap_text_extracts_in_order() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let para = designcraft_doc::ParaAttrs { drop_cap_lines: Some(3), drop_cap_chars: Some(2), ..Default::default() }; + let text = "Drop caps read in order with the rest of the paragraph, which runs on for a few lines beside the enlarged letters."; + d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 236.0, 300.0), lid, text, ParaFormat { para, ..Default::default() }).unwrap(); + let bytes = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap(); + let flat: String = extract_text(&bytes)[0].split_whitespace().collect::>().join(" "); + assert!(flat.starts_with("Drop caps read in order"), "{flat}"); +} + /// A 4×2 CMYK TIFF: the left half 100% K (text in a scan), the right half C0 M100 Y100 K0. fn cmyk_tiff() -> (Vec, Vec) { let (k, red) = ([0u8, 0, 0, 255], [0u8, 255, 255, 0]); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 2cc5c54..dd2225b 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1246,6 +1246,37 @@ mod tests { assert!(r.stats.glyphs >= 15); } + /// An inside-aligned stroke is the stroke of the path inset by half its weight: a band from the + /// edge inwards, its rounded corners concentric with the frame's. + #[test] + fn inside_strokes_are_inset_by_half_the_weight() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let id = designcraft_doc::ItemId(d.alloc()); + let mut it = + Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 300.0, 300.0))); + it.stroke = designcraft_doc::Stroke { + swatch: designcraft_color::swatch::BLACK.into(), + weight: 10.0, + align: designcraft_doc::StrokeAlign::Inside, + ..Default::default() + }; + it.corners = designcraft_geom::corners::CornerOptions::uniform(designcraft_geom::corners::CornerShape::Rounded, 20.0); + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + let cache = Cache::new(); + let mut r = Renderer::new(); + r.threads = 0; + let img = r.render_page(&d, &cache, 0, 1.0, false, &RenderOptions::default()).unwrap(); + let ink = |x: u32, y: u32| img.pixel(x, y)[0] < 64; + let paper = |x: u32, y: u32| img.pixel(x, y)[0] > 192; + // Straight edges: ink from the edge to 10 pt inside, nothing outside. + assert!(paper(97, 200) && ink(101, 200) && ink(108, 200) && paper(112, 200)); + assert!(ink(298, 200) && paper(302, 200) && paper(288, 200)); + // Rounded corner centred at (120, 120): ink between radii 10 and 20, paper outside the arc + // and inside the band. + assert!(paper(103, 103) && ink(108, 108) && paper(114, 114)); + } + #[test] fn arrowheads_draw_at_line_ends() { let mut d = Document::new(&NewDocument::default()); diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index cc46ad7..96e3840 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -192,7 +192,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/crates/tools/src/frame.rs b/crates/tools/src/frame.rs index 87b4284..4b52e2e 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/lib.rs b/crates/tools/src/lib.rs index f3de390..8dbc262 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -20,6 +20,7 @@ mod pencil; pub mod select; pub mod snap; mod text; +pub mod thread; mod xform; use designcraft_compose::Cache; @@ -63,21 +64,28 @@ pub struct PointerEvent { pub pos: Point, #[serde(default)] pub mods: Mods, - /// Presses in quick succession at about the same spot, this one included: 2 on a - /// `DoubleClick`, 3 or more on a `Down` (the Type tool selects a sentence, paragraph, story). - /// 0 or 1 is a single press. + /// On a press: how many presses in quick succession at this spot it completes (2 = the + /// second of a double click, 3 = a triple click…). 0 counts as 1. #[serde(default)] pub clicks: u8, } impl PointerEvent { pub fn new(kind: PointerKind, x: f64, y: f64) -> Self { - Self { kind, pos: Point::new(x, y), mods: Mods::default(), clicks: 0 } + Self { kind, pos: Point::new(x, y), mods: Mods::default(), clicks: 1 } } pub fn with_mods(mut self, m: Mods) -> Self { self.mods = m; self } + pub fn with_clicks(mut self, n: u8) -> Self { + self.clicks = n; + self + } + /// Presses in a row: at least 2 for [`PointerKind::DoubleClick`], at least 1 otherwise. + pub fn click_count(&self) -> u8 { + if self.kind == PointerKind::DoubleClick { self.clicks.max(2) } else { self.clicks.max(1) } + } } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -351,6 +359,10 @@ pub enum Cursor { ZoomOut, Eyedropper, LoadedText, + /// Loaded text over a frame a click threads to. + ThreadLink, + /// Loaded text over the frame a click unthreads from. + Unthread, LoadedGraphic, NotAllowed, } diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index fc9a8c3..b2ac977 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -3,11 +3,13 @@ //! Click selects the frontmost item (Shift toggles), drag moves (Alt duplicates, Shift constrains), //! the 8 bounding-box handles resize (Shift keeps proportions, Alt from centre, Cmd scales content), //! empty-canvas drags make a marquee, double-clicking a text frame switches to the Type tool. +//! A click on a selected text frame's in or out port loads the text cursor (see [`crate::thread`]). use designcraft_doc::{ItemId, SpreadRef}; use designcraft_geom::{Affine, Point, Rect, Vec2}; use serde_json::{Value, json}; +use crate::thread::Loaded; use crate::{Action, Cursor, Gesture, Mods, Overlay, PointerEvent, PointerKind, SnapRequest, Tool, ToolContext, ToolKey, rect_json, spread_json}; #[derive(Clone, Debug)] @@ -38,6 +40,11 @@ enum Drag { start: Point, cur: Point, }, + /// A press with the loaded text cursor. + Place { + start: Point, + cur: Point, + }, /// Dragging a type-on-a-path bracket along its path (`end`: the end bracket). Bracket { id: ItemId, @@ -149,11 +156,57 @@ pub struct SelectionTool { guides: Vec, /// 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::new(frame, out)); + self.drag = Drag::None; + Some(vec![]) + } + (PointerKind::Down | PointerKind::DoubleClick, Some(_)) => { + self.drag = Drag::Place { start: p, cur: p }; + Some(vec![]) + } + (PointerKind::Drag, Some(_)) => { + if let Drag::Place { start, .. } = self.drag { + self.drag = Drag::Place { start, cur: p }; + } + Some(vec![]) + } + (PointerKind::Up, Some(l)) => { + let Drag::Place { start, .. } = std::mem::replace(&mut self.drag, Drag::None) else { return Some(vec![]) }; + let clicked = l.click(cx, start, p, ev.mods); + // Alt (semi-autoflow) stays loaded with what is left; an invalid target keeps the load. + if let Some(c) = &clicked { + self.loaded = c.joins.filter(|_| ev.mods.alt && !ev.mods.shift).map(|j| Loaded { advance: Some(j), ..l }); + } + Some(exec(clicked.map(|c| (c.id, c.params)))) + } + (PointerKind::Move, Some(_)) => Some(vec![]), + _ => None, + } } } @@ -420,6 +473,19 @@ impl Tool for SelectionTool { fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { let p = ev.pos; + if !self.direct + && let Some(actions) = self.thread_pointer(cx, ev) + { + return actions; + } + // The second press of a double click on a text frame: edit its text. + if matches!(ev.kind, PointerKind::Down | PointerKind::DoubleClick) + && ev.click_count() == 2 + && let Some(actions) = edit_text_at(cx, p) + { + self.drag = Drag::None; + return actions; + } match ev.kind { PointerKind::Move => { self.hover_handle = handle_at(cx, p); @@ -656,6 +722,7 @@ impl Tool for SelectionTool { self.drag = Drag::Marquee { start, cur: p }; vec![] } + Drag::Place { .. } => vec![], Drag::Rotate { center, start_angle, start_rotation } => { let mut a = ((p - center).atan2() - start_angle).to_degrees(); self.guides.clear(); @@ -769,26 +836,19 @@ impl Tool for SelectionTool { } vec![Action::Exec("selection.set".into(), json!({"ids": ids, "add": ev.mods.shift}))] } - Drag::None => vec![], + Drag::None | Drag::Place { .. } => vec![], } } - PointerKind::DoubleClick => { - if let Some((_, id)) = cx.hit(p) - && cx.doc.item(id).is_some_and(|i| i.is_text_frame()) - { - let (sr, sp) = cx.layout.spread_at(p).unwrap_or((SpreadRef::Doc(0), p)); - let _ = sr; - return vec![ - Action::SwitchTool("type".into()), - Action::Exec("text.placeCaret".into(), json!({"frame": id.0, "point": [sp.x, sp.y]})), - ]; - } - vec![] - } + PointerKind::DoubleClick => vec![], } } fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec { + if key == ToolKey::Escape && self.loaded.take().is_some() { + // Unload; the selection stays. Cancel (no interaction runs) marks the key handled. + self.drag = Drag::None; + return vec![Action::Cancel]; + } let inc = cx.doc.settings.keyboard_increment * if mods.shift { 10.0 } else { 1.0 }; let mv = |dx: f64, dy: f64| vec![Action::Exec("transform.move".into(), json!({"dx": dx, "dy": dy, "copy": mods.alt}))]; if cx.selection.items.is_empty() { @@ -807,6 +867,11 @@ impl Tool for SelectionTool { fn overlays(&self, cx: &ToolContext) -> Vec { match &self.drag { + Drag::Place { start, cur } if crate::thread::is_drag(cx, *start, *cur) => { + let Some((sr, r, guides)) = crate::thread::drag_frame(cx, *start, *cur) else { return vec![] }; + let r = Rect::from_points(cx.layout.to_canvas(sr, Point::new(r.x0, r.y0)), cx.layout.to_canvas(sr, Point::new(r.x1, r.y1))); + std::iter::once(Overlay::Marquee(r)).chain(guides).collect() + } Drag::Marquee { start, cur } => vec![Overlay::Marquee(Rect::from_points(*start, *cur))], Drag::Move { .. } | Drag::Resize { .. } | Drag::Rotate { .. } | Drag::Anchor { .. } => self.guides.clone(), Drag::None | Drag::Bracket { .. } => brackets(cx), @@ -821,6 +886,9 @@ impl Tool for SelectionTool { if self.direct { return Cursor::ArrowHollow; } + if let Some(l) = self.loaded.and_then(|l| l.resolve(cx.doc)) { + return if crate::thread::port_at(cx, p).is_some() { Cursor::LoadedText } else { l.cursor(cx, p) }; + } match self.drag { Drag::Move { .. } => return Cursor::Move, Drag::Resize { handle, .. } => return handle_cursor(handle), @@ -868,3 +936,13 @@ fn handle_cursor(h: usize) -> Cursor { _ => Cursor::ResizeH, } } + +/// Switch to the Type tool with the caret at `p` when it is over a text frame. +fn edit_text_at(cx: &ToolContext, p: Point) -> Option> { + let (_, id) = cx.hit(p)?; + if !cx.doc.item(id).is_some_and(|i| i.is_text_frame()) { + return None; + } + let (_, sp) = cx.layout.spread_at(p).unwrap_or((SpreadRef::Doc(0), p)); + Some(vec![Action::SwitchTool("type".into()), Action::Exec("text.placeCaret".into(), json!({"frame": id.0, "point": [sp.x, sp.y]}))]) +} diff --git a/crates/tools/src/snap.rs b/crates/tools/src/snap.rs index 6d5c8f6..142525d 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/tests.rs b/crates/tools/src/tests.rs index 6776902..6df8648 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -706,6 +706,289 @@ fn pen_click_snaps_to_a_guide() { assert!((x - 100.0).abs() < 1e-6, "anchor x {x}"); } +/// A text frame with overset text (selected), an empty frame and a threaded pair x → y, on page 1. +struct ThreadDoc { + d: Document, + l: CanvasLayout, + s: Selection, + c: Cache, + /// The selected text frame. + a: ItemId, + /// An empty (unassigned) frame. + e: ItemId, + x: ItemId, + y: ItemId, +} + +fn thread_doc() -> ThreadDoc { + let mut d = Document::new(&NewDocument { pages: 2, ..Default::default() }); + let lid = d.default_layer(); + let (a, _) = d + .add_text_frame(SpreadRef::Doc(0), Rect::new(100.0, 100.0, 250.0, 160.0), lid, &"Words flow on. ".repeat(40), ParaFormat::default()) + .unwrap(); + let e = ItemId(d.alloc()); + d.insert_item(SpreadRef::Doc(0), Item::new(e, lid, Shape::Rectangle, shapes::rectangle(Rect::new(100.0, 400.0, 250.0, 500.0))), None).unwrap(); + let (x, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(300.0, 100.0, 450.0, 160.0), lid, "x", ParaFormat::default()).unwrap(); + let (y, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(300.0, 400.0, 450.0, 500.0), lid, "", ParaFormat::default()).unwrap(); + d.thread(x, y).unwrap(); + let l = CanvasLayout::new(&d, false); + ThreadDoc { s: Selection::items(vec![a]), c: Cache::new(), l, d, a, e, x, y } +} + +impl ThreadDoc { + fn cx(&self) -> ToolContext<'_> { + ctx(&self.d, &self.s, &self.c, &self.l) + } + /// Canvas point of a spread-0 point. + fn at(&self, x: f64, y: f64) -> Point { + self.l.to_canvas(SpreadRef::Doc(0), Point::new(x, y)) + } + fn port(&self, id: ItemId, out: bool) -> Point { + thread::port_center(&self.d, &self.l, id, out, 1.0).unwrap() + } +} + +fn click(t: &mut Box, 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 loaded_drag_snaps_like_frame_drawing() { + let td = thread_doc(); + let cx = ToolContext { snap: SnapView::FACTORY, ..td.cx() }; + let mut t = create("selection"); + click(&mut t, &cx, td.port(td.a, true), Mods::default()); + let col = td.d.spreads[0].pages[0].column_rects()[0]; + // Press and release a point or two inside the column's edges: both sides snap to them. + let (p0, p1) = (td.at(col.x0 + 1.5, 560.0), td.at(col.x1 - 2.0, 650.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, p0.x, p0.y)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, p1.x, p1.y)); + let Some(Overlay::Marquee(m)) = t.overlays(&cx).first().cloned() else { panic!("no marquee") }; + assert!((m.x0 - td.at(col.x0, 0.0).x).abs() < 1e-9 && (m.x1 - td.at(col.x1, 0.0).x).abs() < 1e-9, "{m:?}"); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, p1.x, p1.y)); + let [Action::Exec(id, p)] = a.as_slice() else { panic!("{a:?}") }; + assert_eq!(id, "text.thread"); + let r: Vec = p["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect(); + assert_eq!((r[0], r[2]), (col.x0, col.x1), "{p}"); +} + +#[test] +fn alt_click_stays_loaded() { + let mut td = thread_doc(); + let original = td.d.clone(); + let mut t = create("selection"); + let alt = Mods { alt: true, ..Default::default() }; + let col = td.d.spreads[0].pages[0].column_rects()[0]; + let empty = td.at(200.0, 600.0); + let new_frame_from = + |f: ItemId| thread_exec(serde_json::json!({"from": f.0, "rect": [col.x0, 600.0, col.x1, col.y1], "spread": {"kind": "doc", "index": 0}})); + { + let cx = td.cx(); + click(&mut t, &cx, td.port(td.a, true), Mods::default()); + assert_eq!(click(&mut t, &cx, td.at(175.0, 450.0), alt), thread_exec(serde_json::json!({"from": td.a.0, "to": td.e.0}))); + assert_eq!(t.cursor(&cx, td.at(500.0, 700.0), Mods::default()), Cursor::LoadedText); + // The thread failed (the document is unchanged): still loaded from a. + assert_eq!(click(&mut t, &cx, empty, alt), new_frame_from(td.a)); + } + // That click's command ran: the next click threads after the frame it added. + let (n, _) = + td.d.add_text_frame(SpreadRef::Doc(0), Rect::new(col.x0, 600.0, col.x1, col.y1), td.d.default_layer(), "", ParaFormat::default()).unwrap(); + td.d.thread(td.a, n).unwrap(); + { + let cx = td.cx(); + assert_eq!(click(&mut t, &cx, td.at(175.0, 450.0), alt), thread_exec(serde_json::json!({"from": n.0, "to": td.e.0}))); + } + // Undone: back on a, not on a's old neighbour or the frame the undo removed. + td.d = original; + { + let cx = td.cx(); + assert_eq!(click(&mut t, &cx, empty, Mods::default()), new_frame_from(td.a)); + } + // From x, which threads into y: when threading e in between fails, x stays loaded, not y. + let td = ThreadDoc { s: Selection::items(vec![td.x]), ..td }; + let cx = td.cx(); + click(&mut t, &cx, td.port(td.x, true), Mods::default()); + assert_eq!(click(&mut t, &cx, td.at(175.0, 450.0), alt), thread_exec(serde_json::json!({"from": td.x.0, "to": td.e.0}))); + assert_eq!(click(&mut t, &cx, empty, Mods::default()), new_frame_from(td.x)); +} + +#[test] +fn escape_unloads_and_keeps_the_selection() { + let td = thread_doc(); + let cx = td.cx(); + let mut t = create("selection"); + click(&mut t, &cx, td.port(td.a, true), Mods::default()); + assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel]); + assert_eq!(t.cursor(&cx, td.at(500.0, 700.0), Mods::default()), Cursor::Arrow); + // An ordinary click on empty space deselects again. + let p = td.at(500.0, 700.0); + assert_eq!( + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, p.x, p.y)), + vec![Action::Exec("selection.set".into(), serde_json::json!({"ids": []}))] + ); +} + +#[test] +fn loaded_click_on_the_linked_frame_unthreads() { + // x is selected; its out port links to y. + let td = thread_doc(); + let td = ThreadDoc { s: Selection::items(vec![td.x]), ..td }; + let cx = td.cx(); + let mut t = create("selection"); + click(&mut t, &cx, td.port(td.x, true), Mods::default()); + let over_y = td.at(375.0, 450.0); + assert_eq!(t.cursor(&cx, over_y, Mods::default()), Cursor::Unthread); + assert_eq!( + click(&mut t, &cx, over_y, Mods::default()), + vec![Action::Exec("text.unthread".into(), serde_json::json!({"frame": td.x.0, "side": "after"}))] + ); + // From y's in port, x is the frame before. + let td = ThreadDoc { s: Selection::items(vec![td.y]), ..td }; + let cx = td.cx(); + click(&mut t, &cx, td.port(td.y, false), Mods::default()); + assert_eq!( + click(&mut t, &cx, td.at(375.0, 130.0), Mods::default()), + vec![Action::Exec("text.unthread".into(), serde_json::json!({"frame": td.y.0, "side": "before"}))] + ); +} + +#[test] +fn in_port_threads_in_front() { + let td = thread_doc(); + let cx = td.cx(); + let mut t = create("selection"); + click(&mut t, &cx, td.port(td.a, false), Mods::default()); + assert_eq!(click(&mut t, &cx, td.at(175.0, 450.0), Mods::default()), thread_exec(serde_json::json!({"from": td.e.0, "to": td.a.0}))); +} + +#[test] +fn double_click_on_a_port_breaks_its_link() { + let td = thread_doc(); + let td = ThreadDoc { s: Selection::items(vec![td.x]), ..td }; + let cx = td.cx(); + // A double click sent as such (control channel), and the canvas's second press. + let second = |p: Point| [PointerEvent::new(PointerKind::DoubleClick, p.x, p.y), PointerEvent::new(PointerKind::Down, p.x, p.y).with_clicks(2)]; + let p = td.port(td.x, true); + for ev in second(p) { + let mut t = create("selection"); + click(&mut t, &cx, p, Mods::default()); + let a = t.pointer(&cx, &ev); + assert_eq!(a, vec![Action::Exec("text.unthread".into(), serde_json::json!({"frame": td.x.0, "side": "after"}))], "{ev:?}"); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, p.x, p.y)).is_empty()); + assert_eq!(t.cursor(&cx, td.at(500.0, 700.0), Mods::default()), Cursor::Arrow); + // The in port of the first frame has no link: nothing to break. + let q = td.port(td.x, false); + for ev in second(q) { + assert!(t.pointer(&cx, &ev).is_empty(), "{ev:?}"); + } + } +} + +#[test] +fn clicks_in_a_row_edit_and_select_text() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let (fid, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(100.0, 100.0, 300.0, 200.0), lid, "Two words", ParaFormat::default()).unwrap(); + let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); + let cx = ctx(&d, &s, &c, &l); + let off = l.xf(SpreadRef::Doc(0)).translation(); + let (x, y) = (150.0 + off.x, 110.0 + off.y); + let press = |n: u8| PointerEvent::new(PointerKind::Down, x, y).with_clicks(n); + // Selection tool: the second press of a double click edits the text there. + let mut t = create("selection"); + let a = t.pointer(&cx, &press(1)); + assert!(matches!(&a[0], Action::Exec(id, _) if id == "selection.set"), "{a:?}"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x, y)); + let a = t.pointer(&cx, &press(2)); + assert_eq!(a.first(), Some(&Action::SwitchTool("type".into()))); + assert!(matches!(&a[1], Action::Exec(id, p) if id == "text.placeCaret" && p["frame"] == fid.0), "{a:?}"); + // Type tool: word, line, paragraph, then the story. + let mut t = create("type"); + let unit = |a: Vec| match a.as_slice() { + [Action::Exec(id, p)] if id == "text.selectAt" => p["unit"].as_str().map(str::to_string), + [Action::Exec(id, _)] if id == "text.placeCaret" => None, + other => panic!("{other:?}"), + }; + assert_eq!(unit(t.pointer(&cx, &press(1))), None); + for (n, want) in [(2, "word"), (3, "line"), (4, "paragraph"), (5, "story"), (9, "story")] { + assert_eq!(unit(t.pointer(&cx, &press(n))).as_deref(), Some(want), "{n} clicks"); + } + // A double click sent as such (control channel) selects a word too. + assert_eq!(unit(t.pointer(&cx, &PointerEvent::new(PointerKind::DoubleClick, x, y))).as_deref(), Some("word")); + // Moving a little while pressed keeps the unit selected. + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x + 2.0, y)).is_empty()); +} + #[test] fn pen_continues_an_open_path_from_either_end_point() { let mut d = Document::new(&NewDocument::default()); diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index f166cf8..e6ef1a5 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -5,7 +5,7 @@ use designcraft_geom::Point; use serde_json::json; -use crate::{Action, Cursor, Mods, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, frame::drag_rect, rect_json, spread_json}; +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, frame::snapped_drag_rect, rect_json, spread_json}; #[derive(Default)] pub struct TypeTool { @@ -17,6 +17,10 @@ pub struct TypeTool { /// A press on a table whose whole rows or columns are selected: (frame, press point in /// spread space) — a drag moves them, a click places the caret. cell_drag: Option<(u64, Point)>, + /// Snap guides while drawing a frame. + guides: Vec, + /// The press selected a word, line, paragraph or story (several clicks): drags keep it. + unit_selected: bool, } /// Are whole rows or whole columns of a table selected? @@ -45,33 +49,39 @@ impl Tool for TypeTool { self.drawing = false; self.selecting = None; self.cell_drag = None; + self.unit_selected = false; if let Some((_, id)) = cx.hit(ev.pos) && let Some(it) = cx.doc.item(id) && !matches!(it.content, designcraft_doc::Content::Graphic(_) | designcraft_doc::Content::Group { .. }) { let sp = cx.layout.spread_at(ev.pos).map(|(_, p)| p).unwrap_or(ev.pos); - // Clicks in a row select a word, a sentence, a paragraph, then the whole story. - let unit = match (ev.kind, ev.clicks) { - (PointerKind::DoubleClick, _) => Some("text.selectWord"), - (_, 3) => Some("text.selectSentence"), - (_, 4) => Some("text.selectParagraph"), - (_, 5..) => Some("text.selectStory"), - _ => None, - }; + let clicks = ev.click_count(); // Whole rows/columns selected: wait to see whether this is a drag. - if unit.is_none() && !ev.mods.shift && whole_rows_or_cols(cx) { + if clicks == 1 && !ev.mods.shift && whole_rows_or_cols(cx) { self.cell_drag = Some((id.0, sp)); return vec![]; } self.selecting = Some(id.0); - let cmd = unit.unwrap_or(if ev.mods.shift { "text.extendTo" } else { "text.placeCaret" }); + // Two presses select a word (or a run of spaces), three a line, four a + // paragraph, five the story. + if clicks >= 2 { + self.unit_selected = true; + let unit = match clicks { + 2 => "word", + 3 => "line", + 4 => "paragraph", + _ => "story", + }; + return vec![Action::Exec("text.selectAt".into(), json!({"frame": id.0, "point": [sp.x, sp.y], "unit": unit}))]; + } + let cmd = if ev.mods.shift { "text.extendTo" } else { "text.placeCaret" }; return vec![Action::Exec(cmd.into(), json!({"frame": id.0, "point": [sp.x, sp.y]}))]; } vec![] } PointerKind::Drag => { let Some(a) = self.start else { return vec![] }; - if self.cell_drag.is_some() { + if self.cell_drag.is_some() || self.unit_selected { return vec![]; } if let Some(fid) = self.selecting { @@ -82,7 +92,10 @@ impl Tool for TypeTool { return vec![]; } let Some((sr, sa)) = cx.layout.spread_at(a) else { return vec![] }; - let r = drag_rect(sa, cx.layout.to_spread(sr, ev.pos), ev.mods); + // Drawn like the frame tools: the press point and the moving edges snap. + let sa = if cx.snap.any() { crate::snap::snap_point(cx, sr, sa).0 } else { sa }; + let (r, guides) = snapped_drag_rect(cx, sr, sa, cx.layout.to_spread(sr, ev.pos), ev.mods); + self.guides = guides; let mut out = vec![]; if !self.drawing { self.drawing = true; @@ -95,6 +108,7 @@ impl Tool for TypeTool { out } PointerKind::Up => { + self.guides.clear(); let start = self.start.take(); if let Some((fid, from)) = self.cell_drag.take() { let sp = cx.layout.spread_at(ev.pos).map(|(_, p)| p).unwrap_or(ev.pos); @@ -147,6 +161,10 @@ impl Tool for TypeTool { } } + fn overlays(&self, _cx: &ToolContext) -> Vec { + if self.drawing { self.guides.clone() } else { vec![] } + } + fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor { match cx.hit(p) { Some((_, id)) if cx.doc.item(id).is_some_and(|i| i.is_text_frame()) => Cursor::Text, diff --git a/crates/tools/src/thread.rs b/crates/tools/src/thread.rs new file mode 100644 index 0000000..0a1d69f --- /dev/null +++ b/crates/tools/src/thread.rs @@ -0,0 +1,259 @@ +//! Text threads with the Selection tool: the in and out ports of selected text frames, and the +//! loaded text cursor a click on a port gives. +//! +//! Loaded from an out port, a click threads the clicked frame after the port's frame; loaded +//! from an in port, in front of it. A click on empty space makes a frame the width of the column +//! there, a drag makes one of the dragged size. Clicking the frame on the other side of an +//! existing link breaks the thread there. Shift autoflows, Alt stays loaded with the rest. + +use designcraft_doc::{Document, Item, ItemId, SpreadRef}; +use designcraft_geom::{Point, Rect}; +use serde_json::{Value, json}; + +use crate::{CanvasLayout, Cursor, Mods, Overlay, ToolContext, rect_json, spread_json}; + +/// Port square size, in screen pixels. +pub const PORT_SIZE_PX: f64 = 8.5; +/// The in port sits this far below the top-left corner, the out port this far above the +/// bottom-right corner (screen pixels along the frame edge). +const IN_PORT_PX: f64 = 14.5; +const OUT_PORT_PX: f64 = 12.0; +/// Screen pixels around a port that still hit it. +const PORT_SLOP_PX: f64 = 2.0; +/// A click frame shorter than this (the click was at or below the bottom margin) gets +/// [`CLICK_FRAME_HEIGHT`] instead. +const MIN_CLICK_FRAME_HEIGHT: f64 = 12.0; +const CLICK_FRAME_HEIGHT: f64 = 72.0; + +/// Canvas centre of a text frame's in port (on its left edge below the top-left corner) or out +/// port (on its right edge above the bottom-right corner), at `zoom` screen pixels per point. +pub fn port_center(doc: &Document, layout: &CanvasLayout, id: ItemId, out: bool, zoom: f64) -> Option { + 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) +} + +/// Is a press at `start` released at `p` (canvas) a drag rather than a click? +pub(crate) fn is_drag(cx: &ToolContext, start: Point, p: Point) -> bool { + (p - start).hypot() >= cx.tol(3.0) +} + +/// The frame a drag with the loaded cursor from `start` to `p` (canvas) makes, in spread +/// coordinates, and the snap guides to draw. It is drawn like the frame tools (the press point +/// and the moving edges snap) without their modifiers: Shift and Alt autoflow here. +pub(crate) fn drag_frame(cx: &ToolContext, start: Point, p: Point) -> Option<(SpreadRef, Rect, Vec)> { + let (sr, sa) = cx.layout.spread_at(start)?; + let sa = if cx.snap.any() { crate::snap::snap_point(cx, sr, sa).0 } else { sa }; + let (r, guides) = crate::frame::snapped_drag_rect(cx, sr, sa, cx.layout.to_spread(sr, p), Mods::default()); + Some((sr, r, guides)) +} + +/// The loaded text cursor. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Loaded { + /// The frame whose port was clicked, or the frame semi-autoflow moved on to. + pub frame: ItemId, + /// Loaded from the out port (thread after `frame`) or the in port (in front of it). + pub out: bool, + /// Semi-autoflow: the frame expected to join on this side once the click's command has run. + /// The load moves on to it only if the document has it there. + pub advance: Option, + /// The frame semi-autoflow moved on from: loaded again once `frame` is no longer its + /// neighbour on this side (undo). + pub back: Option, +} + +/// The frame a `text.thread` from the loaded cursor adds on the loaded side. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Joins { + /// This existing frame. + Frame(ItemId), + /// A new frame: any frame other than the neighbour there before the click (`None`: none). + NewFrameReplacing(Option), +} + +/// A click with the loaded cursor: the command, and for `text.thread` the frame it adds. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Clicked { + pub id: &'static str, + pub params: Value, + pub joins: Option, +} + +/// What a click with the loaded cursor does at a point. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Target { + /// Nothing there: a new frame. + Empty, + /// A frame that can join the thread. + Thread(ItemId), + /// The frame on the other side of the port's link: break the thread there. + Unthread, + /// A frame that can't join (the loaded frame itself, a picture, a frame text flows into…). + Invalid, +} + +impl Loaded { + pub fn new(frame: ItemId, out: bool) -> Self { + Self { frame, out, advance: None, back: None } + } + + /// The loaded frame now, or None when that frame is gone (undo, delete). A semi-autoflow load + /// moves on to the frame its click added once the document has it there, and goes back to + /// the frame it came from once an undo takes that frame out again. + pub fn resolve(self, doc: &Document) -> Option { + let usable = |id: ItemId| doc.item(id).is_some_and(Item::is_text_frame); + let mut l = self; + if let Some(b) = l.back + && usable(b) + && Loaded::new(b, l.out).neighbour(doc) != Some(l.frame) + { + l = Loaded::new(b, l.out); + } + if let Some(joins) = l.advance.take() + && let Some(n) = l.neighbour(doc) + && usable(n) + { + let arrived = match joins { + Joins::Frame(f) => n == f, + Joins::NewFrameReplacing(before) => Some(n) != before, + }; + if arrived { + l = Loaded { frame: n, out: l.out, advance: None, back: Some(l.frame) }; + } + } + usable(l.frame).then_some(l) + } + + /// The next frame from an out port, the previous one from an in port. + fn neighbour(self, doc: &Document) -> Option { + 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 { + let new_frame = Joins::NewFrameReplacing(self.neighbour(cx.doc)); + let (pair, new_rect, joins) = if is_drag(cx, start, p) { + let (sr, r, _) = drag_frame(cx, start, p)?; + (self.pair(cx.doc, None), Some((sr, r)), new_frame) + } else { + match self.target(cx, p) { + Target::Unthread => { + let side = if self.out { "after" } else { "before" }; + return Some(Clicked { id: "text.unthread", params: json!({"frame": self.frame.0, "side": side}), joins: None }); + } + Target::Invalid => return None, + Target::Thread(t) => (self.pair(cx.doc, Some(t)), None, Joins::Frame(t)), + Target::Empty => { + let (sr, sp) = cx.layout.spread_at(p)?; + (self.pair(cx.doc, None), Some((sr, click_frame_rect(cx.doc, sr, sp)?)), new_frame) + } + } + }; + let mut params = serde_json::Map::new(); + if let Some(f) = pair.0 { + params.insert("from".into(), json!(f.0)); + } + if let Some(t) = pair.1 { + params.insert("to".into(), json!(t.0)); + } + if let Some((sr, r)) = new_rect { + params.insert("rect".into(), rect_json(r)); + params.insert("spread".into(), spread_json(sr)); + } + if mods.shift { + params.insert("autoflow".into(), json!(true)); + } + Some(Clicked { id: "text.thread", params: Value::Object(params), joins: Some(joins) }) + } +} + +/// Double-click on a port: break the link it shows, if it has one. +pub(crate) fn unthread_at_port(doc: &Document, frame: ItemId, out: bool) -> Option<(&'static str, Value)> { + let linked = if out { doc.next_frame(frame).is_some() } else { doc.prev_frame(frame).is_some() }; + linked.then(|| ("text.unthread", json!({"frame": frame.0, "side": if out { "after" } else { "before" }}))) +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 077ae29..b2ce317 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -139,6 +139,17 @@ pub fn current_slot(app: &DesignApp, layout: &CanvasLayout) -> Option { .map(|(i, _)| i) } +/// The spread nearest the view centre and the centre in that spread's coordinates. +pub fn view_centre(app: &DesignApp) -> Option<(SpreadRef, Point)> { + let st = app.session.active()?; + let layout = CanvasLayout::new(&st.doc, st.editing_parents); + let slot = layout.slots.get(current_slot(app, &layout)?)?; + let v = app.view()?; + let r = app.canvas_rect?; + let c = Point::new(v.origin.x + r.width() as f64 / 2.0 / v.zoom, v.origin.y + r.height() as f64 / 2.0 / v.zoom); + Some((slot.spread, slot.to_spread(c))) +} + /// Absolute page index under the view centre. pub fn current_page(app: &DesignApp) -> Option { let st = app.session.active()?; @@ -338,6 +349,36 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { app.session.cursor(xf.to_canvas(p), ui.input(|i| mods(i, false)), app.view_info()) }; ui.ctx().set_cursor_icon(cursor_icon(c)); + draw_cursor_badge(&painter, p, c, &t); + } +} + +/// The loaded text cursor's badge beside the system cursor: lines of text, a closed chain +/// (a click threads) or a broken one (a click unthreads). Drawn in code. +fn draw_cursor_badge(painter: &egui::Painter, p: Pos2, c: Cursor, t: &Tokens) { + if !matches!(c, Cursor::LoadedText | Cursor::ThreadLink | Cursor::Unthread) { + return; + } + let r = Rect::from_min_size(p + vec2(12.0, 12.0), vec2(20.0, 16.0)); + painter.rect_filled(r, 3.0, t.measure_bg); + let ink = Stroke::new(1.5, t.measure_text); + let c0 = r.center(); + match c { + Cursor::LoadedText => { + for (dy, w) in [(-4.0, 12.0), (0.0, 12.0), (4.0, 8.0)] { + painter.line_segment([pos2(c0.x - 6.0, c0.y + dy), pos2(c0.x - 6.0 + w, c0.y + dy)], ink); + } + } + _ => { + let gap = if c == Cursor::Unthread { 2.5 } else { -2.0 }; + for side in [-1.0, 1.0] { + let link = Rect::from_center_size(pos2(c0.x + side * (3.5 + gap / 2.0), c0.y), vec2(9.0, 6.0)); + painter.rect_stroke(link, 3.0, ink, StrokeKind::Middle); + } + if c == Cursor::Unthread { + painter.line_segment([pos2(c0.x + 2.0, c0.y - 6.0), pos2(c0.x - 2.0, c0.y + 6.0)], ink); + } + } } } @@ -361,6 +402,8 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon { Cursor::ZoomOut => C::ZoomOut, Cursor::Eyedropper => C::Crosshair, Cursor::LoadedText | Cursor::LoadedGraphic => C::Copy, + Cursor::ThreadLink => C::Alias, + Cursor::Unthread => C::NoDrop, Cursor::NotAllowed => C::NotAllowed, } } @@ -810,7 +853,7 @@ fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Docum union = Some(union.map_or(b, |u| u.union(b))); // Text frame ports. if let Content::Text(tf) = &it.content { - draw_ports(app, painter, xf, doc, layout, it, tf.story, a, color); + draw_ports(app, painter, xf, doc, layout, it, tf.story, color); } if let Content::Graphic(g) = &it.content && sel.content @@ -903,7 +946,6 @@ fn draw_ports( layout: &CanvasLayout, it: &Item, sid: designcraft_doc::StoryId, - a: Affine, color: Color32, ) { let Some(story) = doc.story(sid) else { return }; @@ -911,14 +953,11 @@ fn draw_ports( let is_last = pos + 1 == story.frames.len(); let cs = app.session.cache.get(doc, sid, None); let overset = is_last && cs.is_overset(); - let s = 8.5; - let _ = layout; - let r = it.inner_bounds(); - let m = a * it.xf; + let s = designcraft_tools::thread::PORT_SIZE_PX as f32; // In port on the left edge below the top-left corner; out port on the right edge above the - // bottom-right corner (screen-space offsets). - let inp = xf.to_screen(m * Point::new(r.x0, r.y0)) + vec2(0.0, 14.5); - let outp = xf.to_screen(m * Point::new(r.x1, r.y1)) - vec2(0.0, 12.0); + // bottom-right corner. The Selection tool hit-tests the same places. + let port = |out: bool| designcraft_tools::thread::port_center(doc, layout, it.id, out, xf.zoom).map(|c| xf.to_screen(c)); + let (Some(inp), Some(outp)) = (port(false), port(true)) else { return }; for (c, has_link, is_out) in [(inp, pos > 0, false), (outp, !is_last, true)] { let pr = Rect::from_center_size(c, vec2(s, s)); painter.rect_filled(pr, 0.0, Color32::WHITE); @@ -1011,6 +1050,15 @@ fn draw_text_selection( } let past = e == l.range.end && range.end > l.range.end; quads.extend(compose::highlight_quads(l, s, e, past)); + // A selected drop cap is highlighted down to its baseline. + if let Some(dc) = l.drop_cap { + let sel = l.glyphs.iter().filter(|g| g.len > 0 && g.byte < dc.end && g.byte >= s && g.byte < e); + let (a, b) = sel.fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), g| (a.min(g.x), b.max(g.x + g.adv))); + if a < b { + let r = dc.rect; + quads.push([Point::new(a, r.y0), Point::new(b, r.y0), Point::new(b, r.y1), Point::new(a, r.y1)]); + } + } for (gi, g) in l.glyphs.iter().enumerate() { if g.visible && g.len > 0 && g.byte >= s && g.byte < e { glyphs.push((li, gi)); @@ -1366,19 +1414,6 @@ fn fmt_tick(v: f64) -> String { if (v - r).abs() < 1e-6 { format!("{}", r as i64) } else { format!("{v:.1}") } } -/// Presses in a row, this one included: each within egui's double-click delay and distance of the -/// one before. Counted on the press, as text selection wants (egui counts releases, up to three). -fn count_click(ui: &egui::Ui, id: egui::Id, at: Pos2, now: f64) -> u8 { - let (delay, dist) = ui.ctx().options(|o| (o.input_options.max_double_click_delay, o.input_options.max_click_dist)); - let last: Option<(f64, Pos2, u8)> = ui.data(|d| d.get_temp(id)); - let n = match last { - Some((t, p, n)) if now - t < delay && p.distance(at) < dist => n.saturating_add(1), - _ => 1, - }; - ui.data_mut(|d| d.insert_temp(id, (now, at, n))); - n -} - fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rect: Rect) { let Some(v) = app.view().copied() else { return }; let xf = Xf::new(rect, &v); @@ -1421,15 +1456,19 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r let mut events: Vec = Vec::new(); let down_id = egui::Id::new(("canvas_pointer_down", app.pane)); let mut down: bool = ui.data(|d| d.get_temp(down_id)).unwrap_or(false); + let clicks_id = egui::Id::new(("canvas_clicks", app.pane)); let (pressed, released, origin, latest, now) = ui.input(|i| (i.pointer.primary_pressed(), i.pointer.primary_released(), i.pointer.press_origin(), i.pointer.latest_pos(), i.time)); if pressed && !space && let Some(o) = origin.filter(|o| (if xf.rot == 0 { rect.contains(*o) } else { resp.rect.contains(*o) }) && resp.hovered()) { - let clicks = count_click(ui, egui::Id::new(("canvas_clicks", app.pane)), o, now); - let kind = if clicks == 2 { PointerKind::DoubleClick } else { PointerKind::Down }; - events.push(PointerEvent { kind, pos: pos(o), mods: m, clicks }); + // Count presses in a row here: egui reports double and triple clicks only on release and + // stops at three, but a press must know (word, line, paragraph, story). + let (delay, dist) = ui.ctx().options(|o| (o.input_options.max_double_click_delay, o.input_options.max_click_dist)); + let n = press_count(ui.data(|d| d.get_temp(clicks_id)), o, now, delay, dist); + ui.data_mut(|d| d.insert_temp(clicks_id, Clicks { at: f64::NEG_INFINITY, pos: o, n })); + events.push(PointerEvent { kind: PointerKind::Down, pos: pos(o), mods: m, clicks: n }); down = true; } if down @@ -1437,7 +1476,7 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r && resp.drag_delta() != egui::Vec2::ZERO && let Some(p) = latest { - events.push(PointerEvent { kind: PointerKind::Drag, pos: pos(p), mods: m, clicks: 0 }); + events.push(PointerEvent { kind: PointerKind::Drag, pos: pos(p), mods: m, clicks: 1 }); } // Power Zoom: holding the Hand tool still for half a second turns the press into one. let hold_id = egui::Id::new(("canvas_hold", app.pane)); @@ -1447,7 +1486,7 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r let still = origin_pos.zip(latest).is_some_and(|(o, l)| (o - l).length() < 3.0); if !fired && still && now - start >= 0.5 { let p = pos(latest.unwrap_or(rect.center())); - events.push(PointerEvent { kind: PointerKind::Up, pos: p, mods: m, clicks: 0 }); + events.push(PointerEvent { kind: PointerKind::Up, pos: p, mods: m, clicks: 1 }); events.push(PointerEvent { kind: PointerKind::Down, pos: p, mods: Mods { alt: true, ..m }, clicks: 1 }); ui.data_mut(|d| d.insert_temp(hold_id, (start, true))); } else { @@ -1460,11 +1499,15 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r ui.data_mut(|d| d.remove::<(f64, bool)>(hold_id)); } if down && released { + if let Some(c) = ui.data(|d| d.get_temp::(clicks_id)) { + let dist = ui.ctx().options(|o| o.input_options.max_click_dist); + ui.data_mut(|d| d.insert_temp(clicks_id, release_clicks(c, latest.unwrap_or(c.pos), now, dist))); + } let p = latest.unwrap_or(rect.center()); - events.push(PointerEvent { kind: PointerKind::Up, pos: pos(p), mods: m, clicks: 0 }); + events.push(PointerEvent { kind: PointerKind::Up, pos: pos(p), mods: m, clicks: 1 }); down = false; } else if !down && let Some(p) = resp.hover_pos() { - events.push(PointerEvent { kind: PointerKind::Move, pos: pos(p), mods: m, clicks: 0 }); + events.push(PointerEvent { kind: PointerKind::Move, pos: pos(p), mods: m, clicks: 1 }); } ui.data_mut(|d| d.insert_temp(down_id, down)); for e in events { @@ -1494,11 +1537,20 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r let _ = app.run(id, json!({"text": t})); } egui::Event::Copy | egui::Event::Cut if wants_text => { + // `run` puts the copied text on the system clipboard. let id = if matches!(e, egui::Event::Cut) { "edit.cut" } else { "edit.copy" }; - if let Ok(r) = app.run(id, json!({})) - && let Some(t) = r.get("text").and_then(serde_json::Value::as_str) - { - ui.ctx().copy_text(t.to_string()); + let _ = app.run(id, json!({})); + } + // Without a native menu bar the copy and paste keys arrive only as these events. + egui::Event::Copy | egui::Event::Cut | egui::Event::Paste(_) if !app.native_menu => { + let (id, params) = match e { + egui::Event::Copy => ("edit.copy", serde_json::Value::Null), + egui::Event::Cut => ("edit.cut", serde_json::Value::Null), + egui::Event::Paste(t) => ("edit.paste", json!({"text": t})), + _ => continue, + }; + if crate::menus::enabled(app, id) { + crate::menus::activate(app, id, ¶ms); } } egui::Event::Key { key, pressed: true, modifiers, .. } => { @@ -1771,6 +1823,31 @@ fn guide_drag(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rec true } +/// Presses in a row on the canvas: the last press's count, and where and when it was released +/// (`at` is −∞ while the button is down). +#[derive(Clone, Copy, Debug)] +struct Clicks { + at: f64, + pos: Pos2, + n: u8, +} + +/// The count of a press at `pos` at time `now`: one more than the last press when it comes within +/// the double-click delay of the last release and near where that release was, else 1. +fn press_count(last: Option, pos: Pos2, now: f64, delay: f64, dist: f32) -> u8 { + match last { + Some(c) if now - c.at <= delay && (pos - c.pos).length() <= dist => c.n.saturating_add(1), + _ => 1, + } +} + +/// The record a release at `pos` leaves: a release away from its press ended a drag, which is +/// not a click, so the next press counts from one. +fn release_clicks(press: Clicks, pos: Pos2, now: f64, dist: f32) -> Clicks { + let n = if (pos - press.pos).length() <= dist { press.n } else { 0 }; + Clicks { at: now, pos, n } +} + #[cfg(test)] mod tests { use designcraft_geom::Unit; @@ -1778,6 +1855,23 @@ mod tests { use super::*; + #[test] + fn a_press_after_a_drag_is_not_a_double_click() { + let (delay, dist) = (0.3, 6.0); + let press = |last, x: f32, now| press_count(last, pos2(x, 0.0), now, delay, dist); + // Click, click: a double click. + let n = press(None, 0.0, 0.0); + let up = release_clicks(Clicks { at: f64::NEG_INFINITY, pos: pos2(0.0, 0.0), n }, pos2(0.0, 0.0), 0.1, dist); + assert_eq!(press(Some(up), 1.0, 0.2), 2); + // A drag from 0 to 100, then a fast press where it started or where it ended: a single click. + let up = release_clicks(Clicks { at: f64::NEG_INFINITY, pos: pos2(0.0, 0.0), n: 1 }, pos2(100.0, 0.0), 0.1, dist); + assert_eq!(press(Some(up), 0.0, 0.2), 1); + assert_eq!(press(Some(up), 100.0, 0.2), 1); + // Too slow: a single click. + let up = release_clicks(Clicks { at: f64::NEG_INFINITY, pos: pos2(0.0, 0.0), n: 1 }, pos2(0.0, 0.0), 0.1, dist); + assert_eq!(press(Some(up), 0.0, 1.0), 1); + } + fn harness(app: DesignApp) -> Harness<'static, DesignApp> { let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state( |ui, app: &mut DesignApp| { @@ -1881,10 +1975,10 @@ mod tests { } #[test] - fn clicks_in_a_row_select_word_sentence_paragraph_and_story() { + fn clicks_in_a_row_select_word_line_paragraph_with_return_and_story() { let text = "One two. Three four! Five six.\nSecond para here."; let (mut h, id) = typing(text); - // Inside "Three", the second sentence: its start is not the paragraph's. + // Inside "Three": double-click selects the word, then line and paragraph with return. let p = screen_at(&mut h, id, 10..14); h.hover_at(p); h.step(); @@ -1894,7 +1988,7 @@ mod tests { let r = selected(&h); got.push(text[r].to_string()); } - assert_eq!(got[1..], ["Three", "Three four! ", "One two. Three four! Five six.", text], "{got:?}"); + assert_eq!(got[1..], ["Three", "One two. Three four! Five six.", "One two. Three four! Five six.\n", text], "{got:?}"); assert!(got[0].is_empty(), "one click places the caret: {got:?}"); } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 5a4de00..15da79d 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -100,7 +100,16 @@ fn key_from(name: &str) -> Option { }) } +/// Handle one control request. Commands it runs leave the user's system clipboard alone. pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { + app.install_clipboard_guard(ctx); + let was = std::mem::replace(&mut app.in_control, true); + let out = handle_request(app, ctx, req); + app.in_control = was; + out +} + +fn handle_request(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { let p = &req.params; let s = |k: &str| p.get(k).and_then(Value::as_str); match req.method.as_str() { @@ -127,6 +136,7 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> let base_mods: Mods = p.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or_default(); let view = app.view_info(); let xf = app.canvas_rect.zip(app.view().copied()).map(|(rect, v)| Xf::new(rect, &v)); + let mut last = None; for e in events { let kind = match e.get("kind").and_then(Value::as_str).unwrap_or("") { "down" => PointerKind::Down, @@ -147,13 +157,19 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> designcraft_geom::Point::new(x, y) }; let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base_mods); - if let Err(e) = app.session.pointer(&PointerEvent { kind, pos, mods, clicks: 0 }, view) { + let clicks = e.get("clicks").and_then(Value::as_u64).map_or(1, |n| n.clamp(1, 255) as u8); + if let Err(e) = app.session.pointer(&PointerEvent { kind, pos, mods, clicks }, view) { return err(e); } app.after_engine(); + last = Some((pos, mods)); } ctx.request_repaint(); - wrap(app.run("document.inspect", json!({})).map(|d| json!({"selection": d["selection"], "tool": app.session.tool_id()}))) + // The tool's cursor where the last event was. + let cursor = last.map(|(pos, mods)| app.session.cursor(pos, mods, view)); + wrap( + app.run("document.inspect", json!({})).map(|d| json!({"selection": d["selection"], "tool": app.session.tool_id(), "cursor": cursor})), + ) } "ui.key" => { let Some(k) = s("key").and_then(key_from) else { return err("unknown or missing `key`") }; @@ -165,21 +181,28 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> mac_cmd: b("cmd") && cfg!(target_os = "macos"), command: b("cmd"), }; - app.synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: m }); - app.synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: m }); + app.control_synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: m }); + app.control_synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: m }); if let Some(t) = s("text") { - app.synthetic.push(egui::Event::Text(t.to_string())); + app.control_synthetic.push(egui::Event::Text(t.to_string())); } ctx.request_repaint(); ok(Value::Null) } "ui.move" => { let pos = egui::pos2(p.get("x").and_then(Value::as_f64).unwrap_or(0.0) as f32, p.get("y").and_then(Value::as_f64).unwrap_or(0.0) as f32); - app.synthetic.push(egui::Event::PointerMoved(pos)); + app.control_synthetic.push(egui::Event::PointerMoved(pos)); ctx.request_repaint(); ok(Value::Null) } "ui.click" | "ui.drag" => { + let count = match p.get("count") { + None => 1, + Some(v) => match v.as_u64().filter(|n| (1..=255).contains(n)) { + Some(n) => n, + None => return err("`count` must be an integer from 1 through 255"), + }, + }; // Screen-space (egui points) pointer input through egui itself: reaches every widget. let f = |k: &str| p.get(k).and_then(Value::as_f64).unwrap_or(0.0) as f32; let button = match s("button") { @@ -197,21 +220,20 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> }; let a = egui::pos2(f("x"), f("y")); let end = if req.method == "ui.drag" { egui::pos2(f("toX"), f("toY")) } else { a }; - app.synthetic.push(egui::Event::PointerMoved(a)); - app.synthetic.push(egui::Event::PointerButton { pos: a, button, pressed: true, modifiers }); + app.control_synthetic.push(egui::Event::PointerMoved(a)); + app.control_synthetic.push(egui::Event::PointerButton { pos: a, button, pressed: true, modifiers }); if req.method == "ui.drag" { // From the caller: a huge count would queue events until memory runs out. let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).clamp(1, 10_000); for i in 1..=steps { let t = i as f32 / steps as f32; - app.synthetic.push(egui::Event::PointerMoved(a + (end - a) * t)); + app.control_synthetic.push(egui::Event::PointerMoved(a + (end - a) * t)); } } - app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: false, modifiers }); - let count = p.get("count").and_then(Value::as_u64).unwrap_or(1); + app.control_synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: false, modifiers }); for _ in 1..count { - app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: true, modifiers }); - app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: false, modifiers }); + app.control_synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: true, modifiers }); + app.control_synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: false, modifiers }); } ctx.request_repaint(); ok(Value::Null) @@ -221,7 +243,7 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> // Focused widgets retain the synthetic keyboard route. Document automation uses // the explicit caret independently of the active tool, unlike ordinary typing. if ctx.text_edit_focused() || app.ui.dialog.is_some() || app.ui.palette.is_some() { - app.synthetic.push(egui::Event::Text(text.to_string())); + app.control_synthetic.push(egui::Event::Text(text.to_string())); ctx.request_repaint(); ok(Value::Null) } else { @@ -439,7 +461,7 @@ mod automation_contract_tests { } assert!(ctx.text_edit_focused()); assert_eq!(call(&mut app, &ctx, "ui.text", json!({"text":"field"}))["ok"], true); - let events = std::mem::take(&mut app.synthetic); + let events = std::mem::take(&mut app.control_synthetic); frame(&mut app, &ctx, events); assert_eq!(app.ui.palette.as_deref(), Some("field")); assert_eq!(app.session.execute("story.get", &json!({"story":f["story"]})).unwrap()["text"], "Hello"); @@ -476,3 +498,117 @@ mod automation_contract_tests { assert!(writes.lock().unwrap().is_empty()); } } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + + #[test] + fn control_paste_never_reads_the_system_clipboard() { + let calls = Arc::new(AtomicUsize::new(0)); + let seen = calls.clone(); + let services = crate::Services { + clipboard_text: Some(Box::new(move || { + seen.fetch_add(1, Ordering::SeqCst); + Some("SECRET".into()) + })), + ..Default::default() + }; + let mut app = DesignApp::new(designcraft_engine::Session::new(), services); + app.run("file.new", json!({})).unwrap(); + let r = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "words"})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + assert!(app.session.active().unwrap().selection.text.is_some(), "the new frame is being typed in"); + let text = |app: &DesignApp| app.session.active().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().text.clone(); + let ctx = egui::Context::default(); + let (req, _rx) = ControlRequest::new("engine.execute", json!({"command": "edit.paste"})); + let _ = handle(&mut app, &ctx, &req); + assert_eq!(calls.load(Ordering::SeqCst), 0, "the control channel must not read the system clipboard"); + assert!(!text(&app).contains("SECRET")); + assert!(!app.in_control); + // The same command chosen by the user does paste the system clipboard. + let _ = app.run("edit.paste", json!({})); + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert!(text(&app).contains("SECRET")); + } +} + +#[cfg(test)] +mod deferred_clipboard_tests { + use super::*; + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + + #[test] + fn synthetic_paste_keeps_mock_clipboard_private() { + let reads = Arc::new(AtomicUsize::new(0)); + let observed = reads.clone(); + let mut app = DesignApp::new( + designcraft_engine::Session::new(), + crate::Services { + clipboard_text: Some(Box::new(move || { + observed.fetch_add(1, Ordering::SeqCst); + Some("SYNTHETIC_CLIPBOARD_SENTINEL".to_string()) + })), + ..Default::default() + }, + ); + app.run("file.new", json!({})).unwrap(); + let frame = app.run("frame.create", json!({"content":"text", "text":"Original", "rect":[72,72,200,100], "caret":false})).unwrap(); + app.run("selection.set", json!({"ids":[frame["id"]]})).unwrap(); + assert!(!app.session.wants_text()); + let ctx = egui::Context::default(); + let (req, _) = ControlRequest::new("ui.key", json!({"key":"V", "cmd":true})); + assert!(matches!(handle(&mut app, &ctx, &req), Outcome::Done(v) if v["ok"]==true)); + assert!(!app.in_control); + for step in 0..3 { + let mut raw = egui::RawInput { time: Some(step as f64 * 0.05), ..Default::default() }; + app.raw_input_hook(&mut raw); + ctx.run_ui(raw, |ui| menus::shortcuts(&mut app, ui.ctx())).textures_delta.clear(); + } + let texts: Vec = app.session.active().unwrap().doc.stories.values().map(|story| story.text.clone()).collect(); + assert_eq!(reads.load(Ordering::SeqCst), 0, "synthetic command read mocked system clipboard; stories={texts:?}"); + assert!(!texts.iter().any(|text| text.contains("SYNTHETIC_CLIPBOARD_SENTINEL"))); + // Once control input is drained, an ordinary user paste still reads its clipboard. + let mut raw = egui::RawInput::default(); + app.raw_input_hook(&mut raw); + app.run("edit.paste", json!({})).unwrap(); + assert_eq!(reads.load(Ordering::SeqCst), 1); + } + #[test] + fn deferred_control_widget_copy_leaves_native_clipboard_output_alone() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + let (req, _) = ControlRequest::new("ui.move", json!({"x":10,"y":10})); + let _ = handle(&mut app, &ctx, &req); + let mut raw = egui::RawInput::default(); + app.raw_input_hook(&mut raw); + let mut private = ctx.run_ui(raw, |ui| ui.ctx().copy_text("control field".into())); + assert!(!private.platform_output.commands.iter().any(|cmd| matches!(cmd, egui::OutputCommand::CopyText(_)))); + private.textures_delta.clear(); + let mut raw = egui::RawInput::default(); + app.raw_input_hook(&mut raw); + let mut native = ctx.run_ui(raw, |ui| ui.ctx().copy_text("native field".into())); + assert!(native.platform_output.commands.iter().any(|cmd| matches!(cmd, egui::OutputCommand::CopyText(text) if text=="native field"))); + native.textures_delta.clear(); + } + + #[test] + fn control_click_rejects_unbounded_counts_before_queuing_input() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + for count in [json!(u64::MAX), json!(256), json!(0), json!(-1), json!(1.5)] { + let (req, _) = ControlRequest::new("ui.click", json!({"x":10,"y":10,"count":count})); + assert!(matches!(handle(&mut app, &ctx, &req), Outcome::Done(v) if v["ok"]==false)); + assert!(app.control_synthetic.is_empty()); + assert!(app.synthetic.is_empty()); + assert!(!app.in_control); + } + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 695a66a..17a68e7 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1908,7 +1908,7 @@ pub fn confirm(app: &mut DesignApp) -> Result { } "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(); @@ -2312,6 +2312,41 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo }); } "nested" => { + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Drop Caps")).font(semibold(12.0))); + egui::Grid::new("psdc").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + for (label, key, max) in [ + ("Lines:", "dropCapLines", designcraft_compose::MAX_DROP_CAP_LINES), + ("Characters:", "dropCapChars", designcraft_compose::MAX_DROP_CAP_CHARS), + ] { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, label)); + let v = cur(d, &format!("p.{key}"), &pv[key]).as_f64(); + if let Some(n) = crate::widgets::number(ui, &format!("ps{key}"), v, "", 60.0, 0) { + d.fields.insert(format!("p.{key}"), json!((n.max(0.0) as u64).min(max as u64))); + } + ui.end_row(); + } + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Character Style:")); + let style = cur(d, "p.dropCapStyle", &pv["dropCapStyle"]).as_str().unwrap_or("").to_string(); + let style = if style.is_empty() { designcraft_doc::NO_CHAR_STYLE.to_string() } else { style }; + egui::ComboBox::from_id_salt("ps_dropcap_style") + .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &style))) + .width(160.0) + .show_ui(ui, |ui| { + for c in &cnames { + if ui.selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, c))).clicked() { + d.fields.insert("p.dropCapStyle".into(), json!(c)); + } + } + }); + ui.end_row(); + }); + for (label, key) in [("Align Left Edge", "dropCapAlignLeft"), ("Scale for Descenders", "dropCapScaleDescenders")] { + let mut on = cur(d, &format!("p.{key}"), &pv[key]).as_bool().unwrap_or(false); + if ui.checkbox(&mut on, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).changed() { + d.fields.insert(format!("p.{key}"), json!(on)); + } + } + ui.add_space(8.0); crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Nested Styles")).font(semibold(12.0))); let mut list: Vec = cur(d, "p.nestedStyles", &pv["nestedStyles"]).as_array().cloned().unwrap_or_default(); let mut changed = false; @@ -2367,9 +2402,19 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo )) .width(100.0) .show_ui(ui, |ui| { - for k in - ["sentences", "words", "characters", "letters", "digits", "tab", "forcedLineBreak", "emSpace", "enSpace", "chars"] - { + for k in [ + "sentences", + "words", + "characters", + "letters", + "digits", + "tab", + "forcedLineBreak", + "emSpace", + "enSpace", + "dropcap", + "chars", + ] { if ui.selectable_label(k == kind, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, k))).clicked() { ns["until"] = if k == "chars" { json!({"kind": "chars", "chars": ":"}) } else { json!({"kind": k}) }; changed = true; @@ -3352,7 +3397,14 @@ fn footnote_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { ui, d, "firstBaseline", - &[("ascent", "Ascent"), ("capHeight", "Cap Height"), ("leading", "Leading"), ("xHeight", "x Height"), ("fixed", "Fixed")], + &[ + ("ascent", "Ascent"), + ("capHeight", "Cap Height"), + ("leading", "Leading"), + ("xHeight", "x Height"), + ("emboxHeight", "Em Box Height"), + ("fixed", "Fixed"), + ], ); ui.end_row(); crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Min:")); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 5252c38..6d6503a 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -1783,13 +1783,13 @@ const TABLE: &[(&str, [&str; 5])] = &[ ], ), ( - "Select an object to color its fill or stroke.", + "Select an object or text to color its fill or stroke.", [ - "Select an object to color its fill or stroke.", - "Select an object to color its fill or stroke.", - "Select an object to color its fill or stroke.", - "Select an object to color its fill or stroke.", - "选择一个对象以设置其填色或描边颜色。", + "Select an object or text to color its fill or stroke.", + "Select an object or text to color its fill or stroke.", + "Select an object or text to color its fill or stroke.", + "Select an object or text to color its fill or stroke.", + "选择一个对象或文本以设置其填色或描边颜色。", ], ), ( @@ -1969,6 +1969,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Cap Height", ["Cap Height", "Cap Height", "Cap Height", "Cap Height", "大写字母高度"]), ("Leading", ["Leading", "Leading", "Leading", "Leading", "行距"]), ("x Height", ["x Height", "x Height", "x Height", "x Height", "x 字高"]), + ("Em Box Height", ["Em Box Height", "Em Box Height", "Em Box Height", "Em Box Height", "全角字框高度"]), ("Fixed", ["Fixed", "Fixed", "Fixed", "Fixed", "固定"]), ("Never (continuous)", ["Never (continuous)", "Never (continuous)", "Never (continuous)", "Never (continuous)", "从不(连续编号)"]), ("Page", ["Page", "Page", "Page", "Page", "页面"]), @@ -1994,6 +1995,23 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("forcedLineBreak", ["forcedLineBreak", "forcedLineBreak", "forcedLineBreak", "forcedLineBreak", "强制换行符"]), ("emSpace", ["emSpace", "emSpace", "emSpace", "emSpace", "全角空格"]), ("enSpace", ["enSpace", "enSpace", "enSpace", "enSpace", "半角空格"]), + ("dropcap", ["Initiale", "lettrine", "capitular", "ドロップキャップ", "首字下沉"]), + ("Drop Caps", ["Initialen", "Lettrines", "Capitulares", "ドロップキャップ", "首字下沉"]), + ("Lines:", ["Zeilen:", "Lignes :", "Líneas:", "行数:", "行数:"]), + ("Characters:", ["Zeichen:", "Caractères :", "Caracteres:", "文字数:", "字符数:"]), + ("Drop Lines", ["Initialzeilen", "Lignes lettrine", "Líneas capitular", "ドロップ行数", "下沉行数"]), + ("Drop Chars", ["Initialzeichen", "Car. lettrine", "Car. capitular", "ドロップ文字数", "下沉字符数"]), + ("Align Left Edge", ["Linke Kante ausrichten", "Aligner le bord gauche", "Alinear borde izquierdo", "左端揃え", "左边缘对齐"]), + ( + "Scale for Descenders", + [ + "Für Unterlängen skalieren", + "Mise à l'échelle pour les jambages", + "Escalar para trazos descendentes", + "ディセンダーに合わせて拡大・縮小", + "为下伸部分缩放", + ], + ), ("chars", ["chars", "chars", "chars", "chars", "指定字符"]), ("[Automatic]", ["[Automatic]", "[Automatic]", "[Automatic]", "[Automatic]", "[自动]"]), ("Tracking:", ["Tracking:", "Tracking:", "Tracking:", "Tracking:", "字距调整:"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index a182d09..7695119 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -299,6 +299,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Ascent", "الصعود"), ("Cap Height", "ارتفاع الأحرف الكبيرة"), ("x Height", "ارتفاع x"), + ("Em Box Height", "ارتفاع مربع em"), ("Fixed", "ثابت"), ("Never (continuous)", "أبدًا (ترقيم متصل)"), ("Spread", "صفحتان متقابلتان"), @@ -323,6 +324,14 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("forcedLineBreak", "فاصل سطر إجباري"), ("emSpace", "مسافة em"), ("enSpace", "مسافة en"), + ("dropcap", "الحرف الاستهلالي"), + ("Drop Caps", "الأحرف الاستهلالية"), + ("Lines:", "الأسطر:"), + ("Characters:", "الأحرف:"), + ("Drop Lines", "أسطر الاستهلال"), + ("Drop Chars", "أحرف الاستهلال"), + ("Align Left Edge", "محاذاة الحافة اليسرى"), + ("Scale for Descenders", "تحجيم للأحرف الهابطة"), ("chars", "محارف محددة"), ("[Automatic]", "[تلقائي]"), ("Size:", "الحجم:"), @@ -971,7 +980,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ "Select a placed video or sound (File › Place a .mp4, .mov, .mp3, .wav …).", "حدد فيديو أو صوتًا مدرجًا (ملف › إدراج ملف .mp4 أو .mov أو .mp3 أو .wav …).", ), - ("Select an object to color its fill or stroke.", "حدد كائنًا لتلوين تعبئته أو حده."), + ("Select an object or text to color its fill or stroke.", "حدد كائنًا أو نصًا لتلوين تعبئته أو حده."), ("Select an object to give its fill a gradient.", "حدد كائنًا لإضافة تدرج إلى تعبئته."), ("Select an object to make it a button.", "حدد كائنًا لتحويله إلى زر."), ("Select an object.", "حدد كائنًا."), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 75ece03..747511a 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -919,7 +919,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ "Select a placed video or sound (File › Place a .mp4, .mov, .mp3, .wav …).", "Seleziona un video o un audio inserito (File › Inserisci un .mp4, .mov, .mp3, .wav …).", ), - ("Select an object to color its fill or stroke.", "Seleziona un oggetto per colorarne il riempimento o la traccia."), + ("Select an object or text to color its fill or stroke.", "Seleziona un oggetto o del testo per colorarne il riempimento o la traccia."), ("Select an object to give its fill a gradient.", "Seleziona un oggetto per applicare una sfumatura al suo riempimento."), ("Select an object to make it a button.", "Seleziona un oggetto per trasformarlo in pulsante."), ( @@ -980,6 +980,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cap Height", "Altezza maiuscole"), ("Leading", "Interlinea"), ("x Height", "Altezza x"), + ("Em Box Height", "Altezza riquadro em"), ("Fixed", "Fisso"), ("Never (continuous)", "Mai (continua)"), ("Page", "Pagina"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 642b4a4..263d422 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -915,7 +915,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ "Select a placed video or sound (File › Place a .mp4, .mov, .mp3, .wav …).", "配置した動画または音声を選択します (ファイル › 配置で .mp4、.mov、.mp3、.wav などを配置)。", ), - ("Select an object to color its fill or stroke.", "塗りや線にカラーを設定するオブジェクトを選択してください。"), + ("Select an object or text to color its fill or stroke.", "塗りや線にカラーを設定するオブジェクトまたはテキストを選択してください。"), ("Select an object to give its fill a gradient.", "塗りにグラデーションを適用するオブジェクトを選択してください。"), ("Select an object to make it a button.", "ボタンにするオブジェクトを選択します。"), ( @@ -976,6 +976,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cap Height", "大文字の高さ"), ("Leading", "行送り"), ("x Height", "x の高さ"), + ("Em Box Height", "仮想ボディの高さ"), ("Fixed", "固定"), ("Never (continuous)", "振り直しなし (通し番号)"), ("Page", "ページ"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 982dfb2..3f955f6 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -300,6 +300,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Ascent", "Ascendente"), ("Cap Height", "Altura da caixa alta"), ("x Height", "Altura do x"), + ("Em Box Height", "Altura da caixa eme"), ("Fixed", "Fixo"), ("Never (continuous)", "Nunca (contínuo)"), ("Spread", "Página dupla"), @@ -975,7 +976,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Save recovery data every:", "Salvar dados de recuperação a cada:"), ("Saves which bars show and where the panels are.", "Salva quais barras são exibidas e onde ficam os painéis."), ("Search:", "Pesquisar:"), - ("Select an object to color its fill or stroke.", "Selecione um objeto para colorir seu preenchimento ou traçado."), + ("Select an object or text to color its fill or stroke.", "Selecione um objeto ou texto para colorir seu preenchimento ou traçado."), ("Select an object to give its fill a gradient.", "Selecione um objeto para dar um gradiente ao seu preenchimento."), ("Select an object to make it a button.", "Selecione um objeto para transformá-lo em botão."), ("Select an object.", "Selecione um objeto."), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index f3bc8fb..efccfc1 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -916,7 +916,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ "Select a placed video or sound (File › Place a .mp4, .mov, .mp3, .wav …).", "Виділіть розміщене відео або звук («Файл» › «Розмістити» файл .mp4, .mov, .mp3, .wav …).", ), - ("Select an object to color its fill or stroke.", "Виділіть об’єкт, щоб змінити колір заливки або обведення."), + ("Select an object or text to color its fill or stroke.", "Виділіть об’єкт або текст, щоб змінити колір заливки або обведення."), ("Select an object to give its fill a gradient.", "Виділіть об’єкт, щоб застосувати градієнт до заливки."), ("Select an object to make it a button.", "Виділіть об’єкт, щоб перетворити його на кнопку."), ( @@ -977,6 +977,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cap Height", "Висота великих літер"), ("Leading", "Міжрядковий інтервал"), ("x Height", "Висота малих літер"), + ("Em Box Height", "Висота em-квадрата"), ("Fixed", "Фіксований"), ("Never (continuous)", "Ніколи (наскрізна нумерація)"), ("Page", "Сторінка"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fc7a3e6..1db2f59 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -53,6 +53,7 @@ pub struct ImportedFile { } pub type OpenAsyncFn = Box; pub type DownloadFn = Box; +pub type ClipboardFn = Box Option>; /// Files delivered asynchronously with their original operation and document identity. pub type Inbox = std::sync::Arc>>; @@ -73,6 +74,9 @@ pub struct Services { pub download: Option, /// Files delivered asynchronously, drained every frame with their captured request context. 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)] @@ -264,6 +268,9 @@ pub struct UiState { pub smart_spacing: bool, #[serde(default = "default_zone")] pub snap_zone: f64, + /// Tools panel: Formatting Affects Text (J): with text frames selected, colour edits go to + /// their text instead of the frames. + pub formatting_affects_text: bool, /// Window > Contextual Task Bar. pub task_bar: bool, /// Where the Contextual Task Bar is pinned: its top-left, in points from the canvas's top-left @@ -356,6 +363,7 @@ impl Default for UiState { smart_dimensions: true, smart_spacing: true, snap_zone: 4.0, + formatting_affects_text: false, task_bar: true, task_bar_pin: None, task_bar_at: None, @@ -496,6 +504,10 @@ pub struct DesignApp { pub color_applied: bool, pub perf: Perf, pub synthetic: Vec, + /// Control input stays separate from native-menu input until its frame is processed. + pub(crate) control_synthetic: Vec, + control_input_frame: std::sync::Arc, + clipboard_guard_installed: bool, /// Story open in the Story Editor. pub story_editor: Option, control_rx: Option>, @@ -515,6 +527,15 @@ pub struct DesignApp { last_time: f64, /// When recovery data was last written (seconds, egui time). pub last_recovery: f64, + /// The egui context (set by the first frame): copied text goes to the system clipboard through it. + egui_ctx: Option, + /// A control-channel request is being handled: commands it runs never read or write the + /// user's system clipboard (a control client must not see or replace it). + pub(crate) in_control: bool, + /// The system clipboard's text that stands for the last object copy: what that copy put there + /// (no native menu: without it the paste keys produce no paste event), or what it held then + /// (native menu). It is never pasted as text: Paste with it pastes the copied objects. + object_clip_text: Option, /// Quit was chosen: close the window on the next frame, as its close button does. quit_requested: bool, /// Close the window without asking about unsaved changes (the control channel's `app.quit`). @@ -550,6 +571,9 @@ impl DesignApp { color_applied: false, perf: Perf::default(), synthetic: vec![], + control_synthetic: vec![], + control_input_frame: Default::default(), + clipboard_guard_installed: false, story_editor: None, control_rx: None, pending_shots: vec![], @@ -564,6 +588,9 @@ impl DesignApp { native_shortcuts: Default::default(), last_time: 0.0, last_recovery: 0.0, + egui_ctx: None, + in_control: false, + object_clip_text: None, quit_requested: false, quit_without_asking: false, quitting: None, @@ -610,14 +637,102 @@ impl DesignApp { if let Some(r) = menus::run_ui(self, id, ¶ms) { return r; } + // Only user-initiated runs touch the system clipboard, never the control channel's. + let system_clipboard = !self.in_control && !self.control_input_frame.load(std::sync::atomic::Ordering::Relaxed); + let params = if system_clipboard { self.with_system_clipboard(id, params) } else { params }; let r = self.session.execute(id, ¶ms).map_err(|e| e.to_string()); self.after_engine(); - if let Err(e) = &r { - self.status(e.clone()); + match &r { + Err(e) => self.status(e.clone()), + // Copied text goes to the system clipboard too, whichever way Copy was chosen. + Ok(v) if system_clipboard && matches!(id, "edit.copy" | "edit.cut") => { + let text = match v.get("text").and_then(Value::as_str) { + Some(t) => { + self.object_clip_text = None; + Some(t.to_string()) + } + None if !self.native_menu => { + self.object_clip_text = Some(self.copied_objects_text()); + self.object_clip_text.clone() + } + None => { + self.object_clip_text = self.services.clipboard_text.as_mut().and_then(|f| f()); + None + } + }; + if let (Some(t), Some(ctx)) = (text, &self.egui_ctx) { + ctx.copy_text(t); + ctx.request_repaint(); + } + } + Ok(_) => {} } r } + /// Paste without the clipboard's text (a menu, not the paste keys): read it here. With no + /// insertion point, text that doesn't stand for copied objects becomes a new text frame + /// centred in the view. + fn with_system_clipboard(&mut self, id: &str, mut params: Value) -> Value { + let typing = self.session.active().is_some_and(|d| d.selection.text.is_some()); + if !matches!(id, "edit.paste" | "edit.pasteWithoutFormatting") || !(typing || id == "edit.paste") { + return params; + } + if params.get("text").is_none() + && let Some(text) = self.services.clipboard_text.as_mut().and_then(|f| f()) + && let Some(o) = params.as_object_mut() + { + o.insert("text".into(), Value::String(text)); + } + // Still what an object copy put there: paste those objects, not the placeholder text. + let norm = |t: &str| t.replace("\r\n", "\n"); + if let Some(copied) = &self.object_clip_text + && params.get("text").and_then(Value::as_str).is_some_and(|t| norm(t) == norm(copied)) + && let Some(o) = params.as_object_mut() + { + o.remove("text"); + } + if !typing + && params.get("text").and_then(Value::as_str).is_some_and(|t| !t.is_empty()) + && let Some((spread, at)) = canvas::view_centre(self) + && let Ok(spread) = serde_json::to_value(spread) + && let Some(o) = params.as_object_mut() + { + o.entry("spread").or_insert(spread); + o.entry("at").or_insert(json!([at.x, at.y])); + } + params + } + + /// Plain text for the system clipboard after copying objects: the copied text frames' stories, + /// or U+FFFC (object replacement character) when none has text. + fn copied_objects_text(&self) -> String { + let mut stories = Vec::new(); + if let Some(clip) = &self.session.clipboard + && let Some(sp) = clip.spreads.first() + { + for it in &sp.items { + it.walk(&mut |i| { + if let Some(tf) = i.text_frame() + && !stories.contains(&tf.story) + { + stories.push(tf.story); + } + }); + } + } + let text = stories + .iter() + .filter_map(|id| self.session.clipboard.as_ref()?.story(*id)) + .map(|st| { + st.text.chars().filter(|c| !('\u{E000}'..='\u{E1FF}').contains(c)).map(|c| if c == '\u{2028}' { '\n' } else { c }).collect::() + }) + .filter(|t| !t.is_empty()) + .collect::>() + .join("\n"); + if text.is_empty() { '\u{FFFC}'.to_string() } else { text } + } + /// Handle requests produced by tools/commands (dialogs, view changes, file pickers). pub fn after_engine(&mut self) { let reqs = std::mem::take(&mut self.session.ui_requests); @@ -761,6 +876,9 @@ impl DesignApp { /// Per-frame logic before layout. pub fn logic(&mut self, ctx: &egui::Context) { + if self.egui_ctx.is_none() { + self.egui_ctx = Some(ctx.clone()); + } if !self.color_applied { self.color_applied = true; if let Some(cs) = self.ui.color_settings.clone() { @@ -807,7 +925,7 @@ impl DesignApp { if self.fonts_ready { self.drain_inbox(); } - if !self.synthetic.is_empty() { + if !self.synthetic.is_empty() || !self.control_synthetic.is_empty() { ctx.request_repaint(); } self.collect_screenshots(ctx); @@ -1005,19 +1123,39 @@ impl DesignApp { r } - /// Inject synthetic events (one pointer event per frame). - pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) { - if self.synthetic.is_empty() { + /// Keep widget clipboard output private while a queued control event is running. + pub(crate) fn install_clipboard_guard(&mut self, ctx: &egui::Context) { + if self.clipboard_guard_installed { return; } - let n = match self.synthetic[0] { + let private = self.control_input_frame.clone(); + ctx.on_end_pass( + "control clipboard", + std::sync::Arc::new(move |ui| { + if private.load(std::sync::atomic::Ordering::Relaxed) { + ui.ctx().output_mut(|o| { + o.commands.retain(|cmd| !matches!(cmd, egui::OutputCommand::CopyText(_) | egui::OutputCommand::CopyImage(_))) + }); + } + }), + ); + self.clipboard_guard_installed = true; + } + + /// Inject synthetic events (one pointer event per frame), retaining control provenance. + pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) { + let private = !self.control_synthetic.is_empty(); + self.control_input_frame.store(private, std::sync::atomic::Ordering::Relaxed); + let events = if private { &mut self.control_synthetic } else { &mut self.synthetic }; + let Some(first) = events.first() else { return }; + let n = match first { egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } => 1, - _ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1), + _ => events.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(events.len(), |i| i + 1), }; - if let Some(egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. }) = self.synthetic.first() { + if let egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. } = first { raw.events.push(egui::Event::PointerMoved(*p)); } - raw.events.extend(self.synthetic.drain(..n)); + raw.events.extend(events.drain(..n)); } /// Lay out the whole window. diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 872cb81..dc8b88b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -86,6 +86,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("view.zoom", "Zoom To", None, "{zoom: 1.0 = 100%}"), ("view.screenMode", "Screen Mode", None, "{mode: normal|preview|bleed|slug|presentation}"), ("view.togglePreview", "Toggle Normal/Preview", Some("W"), "{}"), + ( + "app.formattingAffectsText", + "Formatting Affects Text", + Some("J"), + "{on?: bool} — with text frames selected, colours go to their text (on) or the frames (off); toggles without `on`", + ), ( "color.defaultFillStroke", "Default Fill and Stroke", @@ -1320,6 +1326,10 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option { + app.ui.formatting_affects_text = p.get("on").and_then(Value::as_bool).unwrap_or(!app.ui.formatting_affects_text); + Ok(json!({"on": app.ui.formatting_affects_text})) + } "window.richBlack" => { app.ui.rich_black = p.get("on").and_then(Value::as_bool).unwrap_or(!app.ui.rich_black); app.canvas.shown = None; @@ -2482,6 +2492,35 @@ mod tests { assert!(!egui::Popup::is_any_open(&ctx), "choosing a command dismisses the menu"); } + #[test] + fn formatting_affects_text_sends_swatches_to_the_text() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + for (name, color) in [("Cyan", "#00ffff"), ("Magenta", "#ff00ff")] { + let created = app.run("swatch.create", json!({"name": name, "color": color})).unwrap(); + assert_eq!(created["name"], name); + } + let r = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "words", "caret": false})).unwrap(); + let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap()); + app.run("selection.set", json!({"ids": [fid]})).unwrap(); + let text_fill = |app: &crate::DesignApp| { + let st = app.session.active().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().clone(); + st.char_format_at(2).over.fill.clone() + }; + let frame_fill = |app: &crate::DesignApp| app.session.active().unwrap().doc.item(designcraft_doc::ItemId(fid)).unwrap().fill.swatch.clone(); + assert!(!crate::panels::colors_affect_text(&app)); + crate::panels::apply_swatch(&mut app, false, "Cyan", None).unwrap(); + assert_eq!(frame_fill(&app), "Cyan"); + assert_eq!(text_fill(&app), None); + let r = run_ui(&mut app, "app.formattingAffectsText", &json!({})).unwrap().unwrap(); + assert_eq!(r["on"], true, "toggles on"); + assert!(crate::panels::colors_affect_text(&app)); + crate::panels::apply_swatch(&mut app, false, "Magenta", Some(0.5)).unwrap(); + assert_eq!(frame_fill(&app), "Cyan", "the frame is left alone"); + assert_eq!(text_fill(&app).as_deref(), Some("Magenta")); + assert_eq!(crate::panels::color_target(&mut app).map(|t| (t.0, t.1)), Some(("Magenta".to_string(), 0.5))); + } + #[test] fn transform_menu_items_open_their_dialogs() { for id in ["transform.move", "transform.scale", "transform.rotate", "transform.shear"] { @@ -3127,6 +3166,117 @@ mod tests { assert!((a - b).abs() < 0.5, "{:?} at x {a}, {:?} at x {b}", plain.label, with_sc.label); } + #[test] + fn copy_and_paste_keys_work_for_objects_without_a_native_menu() { + // Without a native menu the paste key arrives only as a paste event, which needs text on + // the system clipboard (#164, #185). + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({})).unwrap(); + let ctx = egui::Context::default(); + let frame = |app: &mut crate::DesignApp, events: Vec| { + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }); + out.textures_delta.clear(); + out.platform_output.commands + }; + let count = |app: &crate::DesignApp| app.session.active().unwrap().doc.spreads[0].items.len(); + frame(&mut app, vec![]); + frame(&mut app, vec![]); + let id = app.session.execute("frame.create", &json!({"rect": [72, 72, 200, 160]})).unwrap()["id"].clone(); + app.session.execute("selection.set", &json!({"ids": [id]})).unwrap(); + let n = count(&app); + let copied = frame(&mut app, vec![egui::Event::Copy]) + .into_iter() + .find_map(|c| match c { + egui::OutputCommand::CopyText(t) => Some(t), + _ => None, + }) + .expect("copying objects puts text on the system clipboard"); + assert!(!copied.is_empty()); + frame(&mut app, vec![egui::Event::Paste(copied.clone())]); + assert_eq!(count(&app), n + 1, "the paste key pastes the copied rectangle"); + + // Pasting into text anchors the copied object; the clipboard placeholder isn't typed. + let r = app.session.execute("frame.create", &json!({"rect": [72, 300, 400, 400], "content": "text", "text": "Hello"})).unwrap(); + let story = r["story"].as_u64().unwrap(); + app.session.execute("tool.select", &json!({"tool": "type"})).unwrap(); + app.session.execute("text.select", &json!({"story": story, "anchor": 5, "focus": 5})).unwrap(); + frame(&mut app, vec![egui::Event::Paste(copied.clone())]); + // Shift pastes without formatting. + frame(&mut app, vec![egui::Event::ModifiersChanged(egui::Modifiers::SHIFT), egui::Event::Paste(copied)]); + let text = app.session.active().unwrap().doc.story(designcraft_doc::StoryId(story)).unwrap().text.clone(); + assert!(text.starts_with("Hello") && !text.contains('\u{FFFC}'), "{text:?}"); + assert!(text.contains(designcraft_doc::OBJECT_MARK), "{text:?}"); + } + + #[test] + fn paste_with_no_insertion_point_makes_a_text_frame_from_other_apps_text() { + // #518: text copied in another app pastes as a new, selected text frame; the text an + // object copy stands for still pastes the objects. One paste each way, with and without + // a native menu. + let selected_text = |app: &crate::DesignApp| { + let d = app.session.active().unwrap(); + let it = d.doc.item(*d.selection.items.first()?)?; + Some(d.doc.story(it.text_frame()?.story)?.text.clone()) + }; + let ctx = egui::Context::default(); + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({})).unwrap(); + native_frame(&mut app, &ctx, vec![], None); + native_frame(&mut app, &ctx, vec![], None); + // The paste keys without a native menu: a paste event. + native_frame(&mut app, &ctx, vec![egui::Event::Paste("From elsewhere".into())], None); + assert_eq!(document_items(&app), (1, 1), "pasted once"); + assert_eq!(selected_text(&app).as_deref(), Some("From elsewhere")); + let id = app.session.execute("frame.create", &json!({"rect": [72, 72, 200, 160]})).unwrap()["id"].clone(); + app.session.execute("selection.set", &json!({"ids": [id]})).unwrap(); + let copied = native_frame(&mut app, &ctx, vec![egui::Event::Copy], None) + .platform_output + .commands + .into_iter() + .find_map(|c| match c { + egui::OutputCommand::CopyText(t) => Some(t), + _ => None, + }) + .expect("copying objects puts text on the system clipboard"); + native_frame(&mut app, &ctx, vec![egui::Event::Paste(copied)], None); + assert_eq!(document_items(&app), (3, 1)); + assert_eq!(selected_text(&app), None, "the copied rectangle, not a text frame"); + + // A native menu: Paste reads the system clipboard. + let clip = std::rc::Rc::new(std::cell::RefCell::new(Some("Menu text".to_string()))); + let read = clip.clone(); + let services = crate::Services { clipboard_text: Some(Box::new(move || read.borrow().clone())), ..Default::default() }; + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), services); + app.native_menu = true; + app.session.execute("file.new", &json!({})).unwrap(); + native_frame(&mut app, &ctx, vec![], None); + native_frame(&mut app, &ctx, vec![], None); + native_frame(&mut app, &ctx, vec![], Some("edit.paste")); + native_frame(&mut app, &ctx, vec![], None); + assert_eq!(document_items(&app), (1, 1), "pasted once"); + assert_eq!(selected_text(&app).as_deref(), Some("Menu text")); + // Objects copied since that text was copied paste as objects. + let id = app.session.execute("frame.create", &json!({"rect": [72, 72, 200, 160]})).unwrap()["id"].clone(); + app.session.execute("selection.set", &json!({"ids": [id]})).unwrap(); + native_frame(&mut app, &ctx, vec![], Some("edit.copy")); + native_frame(&mut app, &ctx, vec![], Some("edit.paste")); + assert_eq!(document_items(&app), (3, 1)); + assert_eq!(selected_text(&app), None, "the copied rectangle, not a text frame"); + // Newer text from another app wins. + *clip.borrow_mut() = Some("Newer".into()); + native_frame(&mut app, &ctx, vec![], Some("edit.paste")); + assert_eq!(document_items(&app), (4, 1)); + assert_eq!(selected_text(&app).as_deref(), Some("Newer")); + } + #[test] fn sample_scripts_run() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 581f674..baf9a12 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -46,6 +46,44 @@ pub struct SelInfo { pub locked: bool, } +/// Colour edits go to text: text is selected, or text frames are selected with Formatting +/// Affects Text on. +pub fn colors_affect_text(app: &DesignApp) -> bool { + let Some(st) = app.session.active() else { return false }; + st.selection.text.is_some() + || (app.ui.formatting_affects_text && st.selection.items.iter().any(|id| st.doc.item(*id).is_some_and(|i| i.is_text_frame()))) +} + +/// Fill and stroke swatches and tints of what colour edits apply to: the text (see +/// [`colors_affect_text`]) or the first selected object. +pub fn color_target(app: &mut DesignApp) -> Option<(String, f32, String, f32)> { + if colors_affect_text(app) { + let a = text_attrs(app)?; + let c = &a["chars"]; + let tint = |k: &str| c[k].as_f64().unwrap_or(1.0) as f32; + return Some((c["fill"].as_str()?.to_string(), tint("fillTint"), c["stroke"].as_str()?.to_string(), tint("strokeTint"))); + } + let i = sel_info(app)?; + Some((i.fill, sel_tint(app, false), i.stroke, sel_tint(app, true))) +} + +/// Apply a swatch (and tint) to the fill or stroke of what colour edits apply to. +pub fn apply_swatch(app: &mut DesignApp, stroke: bool, swatch: &str, tint: Option) -> 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 d9c74e4..4e207c4 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1347,6 +1347,34 @@ pub fn character_panel(app: &mut DesignApp, ui: &mut egui::Ui) { crate::rtl::label(ui, c["fill"].as_str().unwrap_or("")); }); ui.horizontal(|ui| { + // Bold and Italic pick the family's own styles (style linking). + let (weight, italic) = super::fonts(app).traits_of(c["fontFamily"].as_str().unwrap_or_default(), c["fontStyle"].as_str().unwrap_or_default()); + let tip = |app: &DesignApp, name: &str, id: &str| { + let name = crate::i18n::tr(&app.ui.language, name); + match crate::menus::shortcut_of(app, id) { + Some(sc) => format!("{name} ({})", crate::menus::shortcut_text(&sc)), + None => name.to_string(), + } + }; + let (bold_tip, italic_tip) = (tip(app, "Bold", "type.bold"), tip(app, "Italic", "type.italic")); + if ui + .selectable_label(weight >= 650.0, egui::RichText::new("B").strong()) + .on_hover_ui(|ui| { + crate::rtl::label(ui, bold_tip); + }) + .clicked() + { + let _ = app.run("type.bold", json!({})); + } + if ui + .selectable_label(italic, egui::RichText::new("I").italics()) + .on_hover_ui(|ui| { + crate::rtl::label(ui, italic_tip); + }) + .clicked() + { + let _ = app.run("type.italic", json!({})); + } let caps = c["capitalization"].as_str() == Some("allCaps"); if ui .selectable_label(caps, "TT") @@ -1523,6 +1551,17 @@ pub fn paragraph_panel(app: &mut DesignApp, ui: &mut egui::Ui) { } ui.end_row(); } + // Drop cap: number of lines and characters. + for (label, key, max, id) in [ + ("Drop Lines", "dropCapLines", designcraft_compose::MAX_DROP_CAP_LINES, "ppdl"), + ("Drop Chars", "dropCapChars", designcraft_compose::MAX_DROP_CAP_CHARS, "ppdc"), + ] { + caption(ui, crate::i18n::tr(&app.ui.language, label)); + if let Some(v) = number(ui, id, p[key].as_f64(), "", 64.0, 0) { + let _ = app.run("type.para", json!({"attrs": {key: (v.max(0.0) as u64).min(max as u64)}})); + } + } + ui.end_row(); }); ui.horizontal(|ui| { let mut h = p["hyphenate"].as_bool().unwrap_or(true); diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 7544347..967ff38 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -11,8 +11,8 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let Some(st) = app.session.active() else { return }; let doc = st.doc.clone(); - let text = st.selection.text.is_some(); - let fill_cur = super::sel_info(app).map(|i| i.fill); + let text = super::colors_affect_text(app); + let fill_cur = super::color_target(app).map(|t| t.0); ui.horizontal(|ui| { crate::rtl::label( ui, @@ -25,7 +25,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { // colour groups are listed under their folder. let grouped: Vec<&str> = doc.color_groups.iter().flat_map(|g| g.swatches.iter().map(String::as_str)).collect(); for sw in doc.swatches.iter().filter(|w| !w.hidden && !grouped.contains(&w.name.as_str())) { - swatch_row(app, ui, &doc, sw, 0.0, fill_cur.as_deref(), text); + swatch_row(app, ui, &doc, sw, 0.0, fill_cur.as_deref()); } for g in &doc.color_groups { let open_id = egui::Id::new(("color_group_open", &g.name)); @@ -58,7 +58,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { if open { for n in &g.swatches { if let Some(sw) = doc.swatches.iter().find(|w| w.name == *n) { - swatch_row(app, ui, &doc, sw, 16.0, fill_cur.as_deref(), text); + swatch_row(app, ui, &doc, sw, 16.0, fill_cur.as_deref()); } } } @@ -114,8 +114,8 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { }); } -/// One Swatches panel row: chip, name, type; click applies to fill (or text), the context menu -/// to stroke, colour groups and delete. +/// One Swatches panel row: chip, name, type; click applies to the fill (of the frames or their +/// text, see [`super::colors_affect_text`]), the context menu to the stroke, colour groups and delete. fn swatch_row( app: &mut DesignApp, ui: &mut egui::Ui, @@ -123,7 +123,6 @@ fn swatch_row( sw: &designcraft_color::swatch::Swatch, indent: f32, fill_cur: Option<&str>, - text: bool, ) { let t = Tokens::get(ui.ctx()); let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); @@ -155,12 +154,11 @@ fn swatch_row( } ui.painter().text(row.right_center() - vec2(6.0, 0.0), egui::Align2::RIGHT_CENTER, kind, egui::FontId::proportional(10.5), t.text_dim); if resp.clicked() { - let r = if text { app.run("type.char", json!({"attrs": {"fill": sw.name}})) } else { app.run("object.fill", json!({"swatch": sw.name})) }; - let _ = r; + let _ = super::apply_swatch(app, false, &sw.name, None); } resp.context_menu(|ui| { if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Apply to Stroke"))).clicked() { - let _ = app.run("object.stroke", json!({"swatch": sw.name})); + let _ = super::apply_swatch(app, true, &sw.name, None); ui.close(); } if !sw.locked { @@ -201,24 +199,25 @@ pub enum ColorMode { /// swatch), the spectrum ramp, Add to Swatches. Edits apply unnamed colours (`object.color`). pub fn color_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - let Some(i) = super::sel_info(app) else { + let Some((fill, fill_tint, stroke_sw, stroke_tint)) = super::color_target(app) else { ui.label(crate::rtl::widget( ui, - egui::RichText::new(crate::i18n::tr(&app.ui.language, "Select an object to color its fill or stroke.")).color(t.text_dim), + egui::RichText::new(crate::i18n::tr(&app.ui.language, "Select an object or text to color its fill or stroke.")).color(t.text_dim), )); return; }; + let text = super::colors_affect_text(app); let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return }; let mode_id = egui::Id::new("color_panel_mode"); let mut stroke = app.ui.stroke_front; let default_mode = if doc.settings.intent == designcraft_doc::Intent::Print { ColorMode::Cmyk } else { ColorMode::Rgb }; let mut mode: ColorMode = ui.data(|d| d.get_temp(mode_id)).unwrap_or(default_mode); let target = if stroke { "stroke" } else { "fill" }; - let name = if stroke { i.stroke.clone() } else { i.fill.clone() }; + let name = if stroke { stroke_sw.clone() } else { fill.clone() }; // Proxy: fill (front) and stroke chips; click to choose, double-click for the Color Picker. ui.horizontal(|ui| { for (k, label) in [(false, "Fill"), (true, "Stroke")] { - let n = if k { &i.stroke } else { &i.fill }; + let n = if k { &stroke_sw } else { &fill }; let (c, g) = crate::widgets::swatch_colors(&doc, n, 1.0); let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); crate::widgets::paint_chip(ui.painter(), r.shrink(2.0), c, g); @@ -233,7 +232,8 @@ pub fn color_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let rgb = doc.resolve_color(n, 1.0).map(|c| c.to_rgb()).unwrap_or([0.0; 3]); let hex = format!("#{:02x}{:02x}{:02x}", (rgb[0] * 255.0).round() as u8, (rgb[1] * 255.0).round() as u8, (rgb[2] * 255.0).round() as u8); - app.ui.dialog = Some(crate::dialogs::Dialog::new("colorPicker", json!({"target": if k { "stroke" } else { "fill" }, "hex": hex}))); + app.ui.dialog = + Some(crate::dialogs::Dialog::new("colorPicker", json!({"target": if k { "stroke" } else { "fill" }, "hex": hex, "text": text}))); } } ui.label(egui::RichText::new(&name).size(11.0).color(t.text_dim)); @@ -265,13 +265,12 @@ pub fn color_panel(app: &mut DesignApp, ui: &mut egui::Ui) { }; // A named swatch shows its tint; mixing its values makes an unnamed colour. if let Some(sw) = sw.as_ref().filter(|w| !w.hidden && !w.locked) { - let cur = if stroke { super::sel_tint(app, true) } else { super::sel_tint(app, false) }; + let cur = if stroke { stroke_tint } else { fill_tint }; let mut tint = cur * 100.0; ui.horizontal(|ui| { ui.label("T"); if ui.add(egui::Slider::new(&mut tint, 0.0..=100.0).suffix("%").integer()).drag_stopped() { - let cmd = if stroke { "object.stroke" } else { "object.fill" }; - let _ = app.run(cmd, json!({"swatch": sw.name, "tint": tint / 100.0})); + let _ = super::apply_swatch(app, stroke, &sw.name, Some(tint / 100.0)); } }); } @@ -307,13 +306,13 @@ pub fn color_panel(app: &mut DesignApp, ui: &mut egui::Ui) { } } if let Some(color) = send { - let _ = app.run("object.color", json!({"color": color, "target": target})); + let _ = app.run("object.color", json!({"color": color, "target": target, "text": text})); } spectrum(app, ui, target); ui.horizontal(|ui| { let unnamed = sw.as_ref().is_some_and(|w| w.hidden); if ui.add_enabled(unnamed, egui::Button::new(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Add to Swatches")))).clicked() { - let _ = app.run("swatch.addToSwatches", json!({"target": target})); + let _ = app.run("swatch.addToSwatches", json!({"swatch": name, "target": target})); } }); } @@ -383,7 +382,11 @@ fn spectrum(app: &mut DesignApp, ui: &mut egui::Ui, target: &str) { && let Some(p) = resp.interact_pointer_pos() { let c = at(((p.x - r.min.x) / r.width()).clamp(0.0, 1.0), ((p.y - r.min.y) / r.height()).clamp(0.0, 1.0)); - let _ = app.run("object.color", json!({"color": [(c[0] * 255.0).round(), (c[1] * 255.0).round(), (c[2] * 255.0).round()], "target": target})); + let text = super::colors_affect_text(app); + let _ = app.run( + "object.color", + json!({"color": [(c[0] * 255.0).round(), (c[1] * 255.0).round(), (c[2] * 255.0).round()], "target": target, "text": text}), + ); } } diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 20f4947..6e7d984 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -194,13 +194,12 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { /// utility button and the Screen Mode well. fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) { let t = Tokens::get(ui.ctx()); - let info = crate::panels::sel_info(app); + let target = crate::panels::color_target(app); let doc = app.session.active().map(|d| d.doc.clone()); - let text_id = egui::Id::new("proxy_text"); let stroke_front = app.ui.stroke_front; - let text_mode: bool = ui.data(|d| d.get_temp(text_id)).unwrap_or(false); - let (fill_sw, stroke_sw) = match &info { - Some(i) => (i.fill.clone(), i.stroke.clone()), + let text_mode = crate::panels::colors_affect_text(app) || app.ui.formatting_affects_text; + let (fill_sw, stroke_sw) = match &target { + Some((fill, _, stroke, _)) => (fill.clone(), stroke.clone()), None => ("[None]".to_string(), "[Black]".to_string()), }; let x0 = ui.max_rect().min.x + (width - 30.0) / 2.0; @@ -293,7 +292,10 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) { let rgb = doc.as_ref().and_then(|d| d.resolve_color(¤t, 1.0)).map_or([0.0; 3], |c| c.to_rgb()); let byte = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; let hex = format!("#{:02x}{:02x}{:02x}", byte(rgb[0]), byte(rgb[1]), byte(rgb[2])); - app.ui.dialog = Some(crate::dialogs::Dialog::new("colorPicker", json!({"target": if stroke { "stroke" } else { "fill" }, "hex": hex}))); + app.ui.dialog = Some(crate::dialogs::Dialog::new( + "colorPicker", + json!({"target": if stroke { "stroke" } else { "fill" }, "hex": hex, "text": crate::panels::colors_affect_text(app)}), + )); } ui.add_space(3.0); // Formatting affects container / text. @@ -314,7 +316,7 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) { }) .clicked() { - ui.data_mut(|d| d.insert_temp(text_id, k == 1)); + let _ = app.run("app.formattingAffectsText", json!({"on": k == 1})); } } ui.add_space(5.0); @@ -328,14 +330,13 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) { let chip = egui::Rect::from_center_size(well.center(), vec2(14.0, 14.0)); crate::widgets::paint_chip(ui.painter(), chip, None, None); flyout_triangle(ui.painter(), well, t.icon); - let target = if stroke_front { "object.stroke" } else { "object.fill" }; if resp.clicked() { let _ = app.run("color.applyNone", json!({})); } egui::Popup::context_menu(&resp).show(|ui| { for (label, sw) in [("Apply Color", "[Black]"), ("Apply None", "[None]")] { if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() { - let _ = app.run(target, json!({"swatch": sw})); + let _ = crate::panels::apply_swatch(app, stroke_front, sw, None); ui.close(); } } @@ -425,12 +426,16 @@ fn tool_button(ui: &mut egui::Ui, icon: &str, active: bool, tip: &str) -> egui:: /// Default Fill and Stroke (D): no fill, a black stroke. pub(crate) fn default_fill_stroke(app: &mut DesignApp) -> Result { - app.run("object.defaultFillStroke", json!({})) + if crate::panels::colors_affect_text(app) { + app.run("type.char", json!({"fill": "[Black]", "fillTint": 1.0, "stroke": "[None]", "strokeTint": 1.0})) + } else { + app.run("object.defaultFillStroke", json!({})) + } } /// Swap Fill and Stroke (Shift+X): each selected object's fill and stroke trade places. pub(crate) fn swap_fill_stroke(app: &mut DesignApp) -> Result { - app.run("object.swapFillStroke", json!({})) + if crate::panels::colors_affect_text(app) { app.run("type.swapFillStroke", json!({})) } else { app.run("object.swapFillStroke", json!({})) } } /// Fill/Stroke toggle (X): brings the other of fill and stroke to the front of the proxy. @@ -441,8 +446,7 @@ pub(crate) fn toggle_fill_stroke(app: &mut DesignApp) -> Result { /// Apply None (/): to the fill or the stroke, whichever is in front of the proxy. pub(crate) fn apply_none(app: &mut DesignApp) -> Result { - let target = if app.ui.stroke_front { "object.stroke" } else { "object.fill" }; - app.run(target, json!({"swatch": "[None]"})) + crate::panels::apply_swatch(app, app.ui.stroke_front, "[None]", None) } #[cfg(test)] @@ -585,3 +589,83 @@ mod tests { assert!(app.ui.stroke_front, "X while typing leaves the proxy alone"); } } + +#[cfg(test)] +mod text_color_tests { + use super::*; + use designcraft_doc::{ItemId, StoryId}; + + #[test] + fn integration_color_swap_preserves_each_run_and_frame() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let mut frames = vec![]; + let mut stories = vec![]; + for text in ["abcd", "efgh"] { + let f = app.run("frame.create", json!({"rect":[36,36,300,200],"content":"text","text":text})).unwrap(); + frames.push(f["id"].clone()); + stories.push(f["story"].as_u64().unwrap()); + } + let pairs = [("[Paper]", "[Black]", 0.2, 0.8), ("[Black]", "[Paper]", 0.4, 0.6)]; + for (i, sid) in stories.iter().enumerate() { + for j in 0..2 { + let (fill, stroke, ft, st) = pairs[(i + j) % 2]; + app.run("text.select", json!({"story":sid,"anchor":j*2,"focus":j*2+2})).unwrap(); + app.run("type.char", json!({"fill":fill,"stroke":stroke,"fillTint":ft,"strokeTint":st})).unwrap(); + } + } + app.run("selection.set", json!({"ids":frames})).unwrap(); + app.run("app.formattingAffectsText", json!({"on":true})).unwrap(); + let before = serde_json::to_value(app.session.doc().unwrap().doc.as_ref()).unwrap(); + let n = app.session.doc().unwrap().history.undo.len(); + swap_fill_stroke(&mut app).unwrap(); + assert_eq!(app.session.doc().unwrap().history.undo.len(), n + 1); + for (i, sid) in stories.iter().enumerate() { + let story = app.session.doc().unwrap().doc.story(StoryId(*sid)).unwrap(); + for j in 0..2 { + let (fill, stroke, ft, st) = pairs[(i + j) % 2]; + let a = &story.format_after(j * 2).over; + assert_eq!((a.fill.as_deref(), a.stroke.as_deref(), a.fill_tint, a.stroke_tint), (Some(stroke), Some(fill), Some(st), Some(ft))); + } + } + let swapped = serde_json::to_value(app.session.doc().unwrap().doc.as_ref()).unwrap(); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(serde_json::to_value(app.session.doc().unwrap().doc.as_ref()).unwrap(), before); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(serde_json::to_value(app.session.doc().unwrap().doc.as_ref()).unwrap(), swapped); + } + + #[test] + fn integration_color_toolbar_picker_targets_selected_frame_text() { + use egui_kittest::kittest::Queryable; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect":[36,36,300,200],"content":"text","text":"hello"})).unwrap(); + app.run("selection.set", json!({"ids":[f["id"]]})).unwrap(); + app.run("app.formattingAffectsText", json!({"on":true})).unwrap(); + let before = app.session.doc().unwrap().doc.item(ItemId(f["id"].as_u64().unwrap())).unwrap().fill.clone(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(0.001).build_ui_state( + |ui, app: &mut DesignApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(6); + let at = h.get_by_label("Fill (X)").rect().center(); + h.hover_at(at); + h.step(); + for pressed in [true, false, true, false] { + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + h.step(); + } + h.run_steps(3); + let d = h.state().ui.dialog.clone().unwrap(); + assert_eq!(d.id, "colorPicker"); + assert_eq!(d.fields["text"], true); + h.state_mut().run("object.color", json!({"color":"#123456","target":"fill","text":d.fields["text"]})).unwrap(); + let doc = &h.state().session.doc().unwrap().doc; + assert_eq!(doc.item(ItemId(f["id"].as_u64().unwrap())).unwrap().fill, before); + assert!(doc.story(StoryId(f["story"].as_u64().unwrap())).unwrap().format_after(0).over.fill.is_some()); + } +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 9e51456..4399872 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -37,7 +37,7 @@ so only enable it while you use it. Transport: `apps/designcraft/src/control_ser | `ui.inspect` | — | Tool, UI state, view (zoom/origin), canvas rect, perf | | `ui.menu.list` / `ui.tool.list` | — | Menu tree / Tools panel groups | | `ui.tool.select` | `{tool}` | Select a tool (`selection`, `type`, `rectangleFrame`, …) | -| `ui.pointer` | `{events:[{kind: down\|drag\|up\|move\|doubleclick, x, y, space?: "screen"\|"canvas"}], mods?}` | Drive the active tool through the same code path as the mouse | +| `ui.pointer` | `{events:[{kind: down\|drag\|up\|move\|doubleclick, x, y, space?: "screen"\|"canvas", clicks?}], mods?}` | Drive the active tool through the same code path as the mouse; `clicks` on a `down` is the press count (3 = triple click); returns the selection, the tool and its cursor at the last event (e.g. `LoadedText`, `ThreadLink`, `Unthread`) | | `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Synthetic keyboard input (typing into a text frame) | | `ui.move` / `ui.click` / `ui.drag` | screen points, `button?: left\|right\|middle` | Real egui pointer input — reaches every widget, menu and panel | | `ui.set` | `{brightness?, panel?, rulers?, guides?, frameEdges?, baselineGrid?, textThreads?, screenMode?, zoom?, page?, fit?}` | UI state | diff --git a/docs/gaps.md b/docs/gaps.md index ebd2d7d..41a0af1 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DesignCraft falls short of InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (book chapter update safety) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (gap 0 shrunk: text threads by hand; threading gaps added) · **Target:** Adobe InDesign 2026 (21.6) Every known shortfall against InDesign 2026, one entry each, ranked by how much it stops an InDesign professional from doing real work. This is the work list: agents pick from the top. @@ -14,20 +14,14 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · ## Alpha blocker (the core-workflow gate in [roadmap.md](roadmap.md#alpha-gate)) -### 0. Text can't be threaded by hand (F, U) · **ALPHA BLOCKER** -- **Missing:** threading a story from one frame to another. The canvas draws in and out ports - (`crates/ui-egui/src/canvas.rs`), but clicking the out port does nothing: no loaded text - cursor, no click-a-frame or draw-a-frame to continue the story, no unthread by double-clicking - a port, and no `frame.thread` / `frame.unthread` command for the control channel or MCP - (`Document::thread` exists in `crates/doc/src/edit.rs` but only autoflow, IDML import and the - sample call it). Long text flows only through autoflow on Place and Primary Text Frame, which - users report adding pages they can't delete (#165). -- **Evidence:** #352, #194, #165, #140 (Windows and unspecified platforms; the code shows it's - missing everywhere). -- **Impact:** a story can't run from page 2 to page 7, or from one column frame to the next - that the designer drew. That is InDesign's core text workflow. -- **Estimate:** 15–25 h (commands with undo and tests 4–6, out-port loaded cursor and - click/draw/unthread 6–12, Primary Text Frame page add/delete fixes 5–7). +### 0. Primary Text Frame autoflow adds pages users can't delete (F) · **ALPHA BLOCKER** +- **Missing:** fixes to Smart Text Reflow on the Primary Text Frame, which users report adding + pages they can't delete (#165). Threading by hand works (ports, loaded text cursor, `text.thread` / `text.unthread` / + `text.removeFromThread`). +- **Evidence:** #165. +- **Impact:** a long story in a Primary Text Frame document can leave pages the designer can't + remove. +- **Estimate:** 5–7 h. - **Doc:** [ui-parity.md](ui-parity.md) ## Blocking (beta can't be declared while these are open) @@ -35,7 +29,7 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · ### 1. Basic editing fails for many users (U, S) - **Missing:** object copy/paste on Windows and Linux (#351, #348, #312, #185, #164, #201, text RTF paste #212); Paste in Place onto the selected page (#348); right-click context menus that don't - open (#238, #187, #160, #312); threading (gap 0); double-click word selection in fields + open (#238, #187, #160, #312); double-click word selection in fields (#189); character formatting set at a caret is discarded (#289). - **Evidence:** 16 issues from 2026-10-05 to 2026-10-10, mostly against the 0.4/0.5 release builds. - **Impact:** a layout artist can't finish the simplest job. This dominates the @@ -192,6 +186,19 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · parallel story composition. - **Estimate:** 40–80 h. +### 26. Autoflow fills the following pages before it adds pages (F) +- **Missing:** Shift-click autoflow (and autoflow on Place) always adds new pages after the + story's last frame, one margin-sized frame each. InDesign first threads a frame per column on + the existing pages that follow, then adds pages at the end of the document. +- **Impact:** autoflowing into a document that already has pages inserts new pages in the + middle of it instead of using the empty pages. +- **Estimate:** 4–8 h. + +### 27. Loaded text cursor can't thread into a frame inside a group (F, U) +- **Missing:** a click with the loaded text cursor on a text frame inside a group hits the group, + so the cursor shows it can't thread there. +- **Estimate:** 2–4 h. + ## Lower ### 21. Localization breadth (L) @@ -227,5 +234,6 @@ Cue. These are excluded from every percentage. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Gap 0 shrunk to the Primary Text Frame page fixes (#165): text threads by hand; gaps added for autoflow onto existing pages and threading into grouped frames | | 2026-10-10 | minor | Gap 0 added and marked alpha blocker: manual text threading is missing (stage re-normalized to pre-alpha) | | 2026-10-10 | major | Created: 25 ranked gaps from the full re-measure against InDesign 2026 21.6 and issues #1–#352 | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index d2d1ac1..b8b7154 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -16,12 +16,12 @@ of in-scope menu items (75% against the documented menu tree) (see [target-app-p bound. -**Weighted breadth parity: 99%** over 275 features. +**Weighted breadth parity: 99%** over 276 features. | Priority | Features | Done | Partial | Missing | Parity | |---|---|---|---|---|---| | P0 | 102 | 100 | 2 | 0 | 99% | -| P1 | 97 | 97 | 0 | 0 | 100% | +| P1 | 98 | 98 | 0 | 0 | 100% | | P2 | 76 | 72 | 4 | 0 | 97% | | Area | Parity | @@ -235,6 +235,7 @@ Open P0 items: Vector/page formats (PDF/AI/SVG/EPS place), Export to PDF (print) | Typography | Single-line Composer | P0 | D | | Typography | Tabs, leaders, right-indent tab | P0 | D | | Typography | Nested / line / GREP styles | P1 | D | +| Typography | Drop caps | P1 | D | | Typography | Paragraph rules | P1 | D | | Typography | Paragraph borders & shading | P1 | D | | Typography | Balance ragged lines | P1 | D | diff --git a/docs/roadmap.md b/docs/roadmap.md index d80fe7a..8542413 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # DesignCraft roadmap (detail) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (stage re-normalized to pre-alpha by the core-workflow gate; earlier today: major, milestones re-assessed; beta plan from the full re-measure) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text threads by hand; the gate waits on the Primary Text Frame page fixes) · **Target:** Adobe InDesign 2026 (21.6) Forward-looking plan. The one-page summary is [ROADMAP.md](../ROADMAP.md); numbers are in [target-app-parity.md](target-app-parity.md); the work list is [gaps.md](gaps.md). Hours are @@ -18,7 +18,7 @@ but are next in line. | Core workflow | Works end to end? | Evidence | Hours to pass | |---|---|---|---| | Set up a document (size, margins, columns, parent pages, page numbers, sections); save and reopen | yes | New Document, Document Setup, parent pages and overrides, Numbering & Section Options; `.designcraft` round-trip tests; recovery | 0 | -| Flow a long story across frames and pages: thread, unthread, autoflow | **no** | out/in ports drawn but no out-port click, loaded text cursor or `thread`/`unthread` command; only autoflow on Place and Primary Text Frame, which adds pages users can't delete (#165, #140, #194, #352) | 15–25 | +| Flow a long story across frames and pages: thread, unthread, autoflow | **no** | threading by hand works (ports, loaded text cursor, `text.thread`/`text.unthread`/`text.removeFromThread`); Primary Text Frame autoflow adds pages users can't delete (#165) | 5–7 | | Format text with paragraph and character styles | partial (not blocking) | Paragraph/Character Style Options, Quick Apply, based-on, nested/GREP styles work; style edit/delete from the panel reported (#169); formatting set at a caret is discarded (#289) | 6–12 | | Place, crop and fit images; wrap text around them | yes | place gun, frame fitting and Fitting Options, content grabber, text wrap, Links panel | 0 | | Arrange and edit objects: select, move, transform, duplicate, copy/paste, align, layers | partial (not blocking on macOS) | all present and working on macOS; Paste in Place lands on page 1 (#348); right-click menus fail for some macOS users (#160, #238); copy/paste and context menus fail on Windows/Linux (#351, #185, #164) | 10–20 | @@ -27,16 +27,15 @@ but are next in line. Exchanging files with InDesign users (open INDD, round-trip IDML) is a beta requirement, not part of the alpha gate: IDML works both ways today and INDD is gap 2. -**Distance to alpha:** ~15–25 h (the threading row). Ready for real work (~47%) is already above +**Distance to alpha:** ~5–7 h (the threading row: Primary Text Frame page fixes). Ready for real work (~47%) is already above the ~40% alpha bar. ## Current focus In order. Each closes a beta blocker from [gaps.md](gaps.md). -0. **Alpha gate: manual text threading** (gap 0): `frame.thread` / `frame.unthread` commands, - out-port click with a loaded text cursor (click a frame or draw one), unthread by - double-clicking a port, Primary Text Frame page fixes (#165). 15–25 h. +0. **Alpha gate: Primary Text Frame page fixes** (gap 0): autoflow adds pages users can't + delete (#165). 5–7 h. 1. **Basic editing works on every platform** (gap 1): object copy/paste, Paste in Place, context menus, caret formatting, reproduced and pinned with control-channel tests on macOS, Windows and Linux. 30–50 h. 2. **CI on pull requests** (gap 7): `cargo xtask ci` on every PR, at least macOS and Linux; @@ -58,7 +57,7 @@ set exists; depth gaps are in gaps.md. |---|---|---| | M0 | Skeleton + vertical slice | ✅ | | M1 | Selection, transform, layers, pages, MCP | ✅ core; handle and snapping issues (#186, #188, #337) | -| M2 | Type I (Type tool, threading, Character/Paragraph, composer) | partial: Type tool and composer ✅; **manual threading missing** (alpha blocker); caret formatting (#289); no IME (#322) | +| M2 | Type I (Type tool, threading, Character/Paragraph, composer) | partial: Type tool, composer and threading ✅; Primary Text Frame pages (#165, alpha blocker); caret formatting (#289); no IME (#322) | | M3 | Styles (nested/GREP, bullets, keeps, span columns) | ✅; style editing reliability (#169) | | M4 | Color & effects | ✅ core; colour management depth | | M5 | Graphics & links | ✅ core; EPS preview only | @@ -71,7 +70,7 @@ set exists; depth gaps are in gaps.md. | M12 | Interactive & digital (EPUB, HTML, interactive PDF) | present; HTML basic (#314), fixed-layout EPUB as images | | M13 | Automation (scripts, batch) | ✅ own command scripts, CLI, MCP; ExtendScript/UXP compatibility ✗ | | M14 | 1.0 polish & packaging | packaging ✅ (macOS, Windows x64/ARM64, Linux deb/AppImage/Flatpak, FreeBSD, web); polish not started | -| **A1** | **Alpha** (core-workflow gate) | blocked by manual text threading; see Alpha gate | +| **A1** | **Alpha** (core-workflow gate) | blocked by the Primary Text Frame page fixes (#165); see Alpha gate | | **B1** | **Beta** (ready ≥ 75%, INDD/IDML reliable) | not started as a milestone; see below | ## To beta @@ -110,6 +109,7 @@ output intent · EPS place") is done except Books depth and EPS (preview only). | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Threading row: threading by hand works; the gate waits on the Primary Text Frame page fixes (#165), 5–7 h | | 2026-10-10 | trivial | Ready figure 42% → 47% (method aligned with the standard); beta distance 28 points | | 2026-10-10 | minor | Alpha gate table added (6 core workflows); gate fails on manual threading, stage pre-alpha; A1 milestone and focus item 0 | | 2026-10-10 | major | Created: milestones moved from ROADMAP.md and re-assessed; Current focus and the beta plan from the full re-measure | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index eb7d7e5..daaa3f4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # DesignCraft parity with Adobe InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (cancelable macOS Dock Quit) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text threads by hand) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) The authoritative assessment of how close DesignCraft is to Adobe InDesign. [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) lists every known shortfall one at a time; the feature-by-feature @@ -16,7 +16,7 @@ presence checklist is [parity-checklist.md](parity-checklist.md) (`cargo xtask p | Essentials user (flyers, newsletters: the core only) | **~54%** | 135–250 h to ~95%; estimated, [method below](#essentials-user-54) | | Remaining effort to **beta** (~75% ready, main format reliable; 28 points away) | **420–750 Opus 5.5 agent-hours** | estimated | | Remaining effort to **full parity** | **1,150–1,950 Opus 5.5 agent-hours** | estimated | -| Stage | **pre-alpha** (fails the core-workflow gate: no manual text threading); ~15–25 h to alpha | see [roadmap.md](roadmap.md#alpha-gate) | +| Stage | **pre-alpha** (fails the core-workflow gate: Primary Text Frame adds pages users can't delete, #165); ~5–7 h to alpha | see [roadmap.md](roadmap.md#alpha-gate) | ## How breadth was measured @@ -77,7 +77,7 @@ standard: 25×55 + 15×45 + 20×40 + 15×45 + 10×45 + 5×65 + 3×45 + 3×55 + 2 today applied a further ×0.9 for cross-cutting core-path failures and reported 42%. That multiplicative step was dropped to match the standard (discounts belong only to the mainstream and essentials numbers). Those failures are already in the UI/UX (45%) and Stability (45%) rows, -and the missing manual threading is what holds the stage at pre-alpha through the gate. +and the Primary Text Frame page fixes (#165) are what hold the stage at pre-alpha through the gate. ## Mainstream practitioner and essentials user @@ -108,7 +108,7 @@ twice. | Area | Weight | Depth | Evidence | |---|---|---|---| | Document setup: pages, margins, columns, parents, sections | 10% | 65% | features area table | -| Text frames: type, select, edit, thread, autoflow | 15% | 45% | manual threading missing; autoflow and Primary Text Frame only | +| Text frames: type, select, edit, thread, autoflow | 15% | 45% | threading by hand, autoflow; Primary Text Frame adds pages users can't delete (#165) | | Typography: composer, H&J, OpenType, tabs, lists, keeps | 15% | 65% | typography-parity.md: 18 ✓ / 16 ~; Tabs panel missing; InDesign-identical breaks counted under exchange | | Paragraph, character and object styles | 10% | 65% | dialogs, based-on, nested/GREP, Quick Apply | | Frames, shapes, transform, arrange, align | 10% | 70% | full tool set; copy/paste problems counted under interaction | @@ -211,7 +211,7 @@ table is the one summed. | Fill, stroke, colour | 100% | 60% | 20–35 | hex entry (#323); Assign Profiles / Convert to Profile; color-managed proofing depth; Adobe Color Themes OOS | | Effects & transparency | 100% | 50% | 20–30 | effects fidelity vs InDesign unmeasured; Clear Effects / Clear All Transparency; flattener depth | | Placing & links | 93% | 55% | 20–35 | EPS is preview-only (no PostScript interpreter); Edit Original; placed PDF layer visibility; INDD page place | -| Type & text frames | 99% | 45% | 40–60 | **no manual threading** (ports drawn, no out-port click, no thread/unthread command: alpha blocker; #352, #194, #165, #140); caret formatting discarded (#289); canvas IME (#322); Notes/Track Changes menus; frame grids | +| Type & text frames | 99% | 45% | 40–60 | Primary Text Frame pages users can't delete (alpha blocker, #165); autoflow doesn't fill existing pages; no threading into grouped frames with the loaded cursor; caret formatting discarded (#289); canvas IME (#322); Notes/Track Changes menus; frame grids | | Typography | 100% | 45% | (typography dimension) | see [typography-parity.md](typography-parity.md) | | Styles | 100% | 60% | 15–25 | Paragraph Styles edit/delete reported failing (#169); style options depth | | Tables | 100% | 55% | 25–40 | anchored objects in cells not drawn (#163); Table ▸ Table Options submenus, Diagonal Lines, Convert Rows, Edit Header/Footer | @@ -265,14 +265,14 @@ The inventory below was the "Working today" list of ROADMAP.md (moved here 2026- It is evidence of breadth: each item exists. It is not a claim of InDesign-level depth. - Document model: facing/non-facing spreads, parent pages with page-number markers, sections, layers, frames (text / graphic / unassigned), threaded stories, paragraph/character/object styles with based-on, swatches (process/spot/tints/gradients), corner options, text wrap, drop shadow, opacity/blend. -- Text engine: Knuth–Plass paragraph composer and single-line composer, dictionary hyphenation (public-domain Moby list + our own trained Liang patterns), justification with word/letter/glyph-scaling ranges, keep options (keep with next, keep lines together, widows/orphans), balance ragged lines, optical margin alignment, hyphenation zone/limit, columns, threading across frames and spreads, tabs, bullets/numbering, rules, shading, baseline grid, first-baseline options, vertical justification, wrap exclusions, overset detection, caret/hit testing. +- Text engine: Knuth–Plass paragraph composer and single-line composer, dictionary hyphenation (public-domain Moby list + our own trained Liang patterns), justification with word/letter/glyph-scaling ranges, keep options (keep with next, keep lines together — at start/end of paragraph by default, as in InDesign — widows/orphans), balance ragged lines, optical margin alignment, hyphenation zone/limit, columns, threading across frames and spreads, tabs, bullets/numbering, rules, shading, baseline grid, first-baseline options, vertical justification, wrap exclusions, overset detection, caret/hit testing. - FID2: IDML underline/strikethrough Text Color resets survive style inheritance and roundtrip. Raster and PDF paint underlines behind glyphs, retain strikethrough above, and center rules on their offsets; synthetic overlap tests cover both backends. - Rendering: vello_cpu (SIMD, multithreaded) — pages, items, images with mip levels, gradients, composed text. - UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts and the Fill/Stroke proxy (D, X, Shift+X, / as `color.*` commands), document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches, Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document and Preferences dialogs, Quick Apply (⌘Return), 4 brightness themes. - Pen tool, Direct Selection anchor/handle editing, rotate from corners, Rotate/Scale/Shear tools, Eyedropper, snapping & smart guides, Align/Distribute, Find/Change (text + GREP), Story Editor, Paragraph Style Options, Effects panel, hidden characters, native macOS menu bar, measured InDesign 2026 look (Medium Dark): Properties sections per selection state, 21 pt spinner fields with arithmetic, Contextual Task Bar, inverse (black) text selection, Pages panel with drag-and-drop reorder / parent apply, Layers panel with per-object rows. - Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection, Type (draw frame, click caret, select, type), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom. - PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`. File › Export PDF… now opens an "Export PDF" options dialog (General / Compression / Marks and Bleeds / Advanced) and remembers the last-used settings across exports and sessions. -- IDML interchange (`designcraft-idml`): export and import of swatches, styles, fonts, preferences, parent spreads, spreads/pages, frames, groups, images (embedded or linked), formatted threaded stories — opens in InDesign 2026 and round-trips InDesign-exported files (`file.exportIdml`, `file.openIdml`, File → Export IDML…, CLI `--in x.idml` / `--export x.idml`). +- IDML interchange (`designcraft-idml`): export and import of swatches, styles, fonts, preferences, parent spreads, spreads/pages, frames, groups, images (embedded or linked), formatted threaded stories; attributes a file leaves unset take InDesign's defaults (Keep Lines Together at start/end of paragraph, 12 pt corners, paragraph rules in Text Color, auto-sizing from the centre, numbered lists restarting in each story, alternating table fills Black 20 % / None; a tint of −1 is the colour's own; frames take the fill, stroke, corners and Text Frame Options they don't write from their object style chain, then [None]) — opens in InDesign 2026 and round-trips InDesign-exported files (`file.exportIdml`, `file.openIdml`, File → Export IDML…, CLI `--in x.idml` / `--export x.idml`). - Tables (M8): tables anchored in stories (header/footer/body rows, merged cells, per-edge strokes, fills + alternating fills, insets, vertical justification, at-least/exact row heights); composed into the text column (columns scale to fit, rows break across columns/frames with repeating headers/footers, overset); rendered, exported to PDF (real text) and IDML (export + import); Table menu, Table panel, Create Table dialog, caret/typing/Tab navigation and cell selection in cells; `table.*` commands. - Hyperlinks (text or frames → URL / e-mail / page) and bookmarks, exported as PDF link annotations and outline. - EPUB 3 (reflowable) export: stories in reading order, CSS from paragraph/character styles, images, navigation (`file.exportEpub`, CLI `--export x.epub`). @@ -295,6 +295,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Graphics backend chosen before the window exists (DirectX 12 on Windows, Vulkan on Linux, Metal on macOS, OpenGL as the fallback; `WGPU_BACKEND` overrides) with a start-up fallback: a backend whose start never showed a frame is skipped at the next start and the status bar says so (`gpu.json`). Found on an AMD hybrid-graphics laptop whose Vulkan driver faulted at the first present. - Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool). - Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`. +- Threading: click a selected text frame's in or out port (overset or not) to load the text cursor, then click a frame to thread it (stories merge), click empty space for a column-wide frame down to the bottom margin, or drag a frame; Shift-click autoflows, Alt-click stays loaded; click the linked frame or double-click a port to break the thread; Esc unloads. Link and unthread cursors show where a click threads or unthreads. `text.thread`, `text.unthread`, `text.removeFromThread`. - Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow adds pages and threaded frames until the text fits; Remove Styles option (`designcraft-textimport`). - Gradient Feather (⇧G tool, `object.gradientFeather`): objects fade along a linear or radial opacity gradient, on screen and as a soft mask in PDF. - Script Label (`object.label`, `object.findByLabel`), alt text (`object.altText`), Isolate Blending / Knockout Group (`object.transparencyGroup`; on screen, PDF isolates), Table ▸ Sort (`table.sortRows`, numeric-aware, header/footer fixed). @@ -323,6 +324,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Appearance of Black complete: Printing/Exporting rich black for RGB output (PNG), Overprint [Black] Swatch at 100% (document, IDML OverprintBlack). - Hyperlinks and Bookmarks panels (Window ▸ Interactive): new from the selection, edit (`hyperlink.edit`), go to source (`hyperlink.goToSource`), rename bookmarks (`bookmark.rename`), go to page, delete. - Nested line styles (`nestedLineStyles` paragraph attribute, Paragraph Style Options › Drop Caps and Nested Styles, IDML AllNestedLineStyles): the first lines restyle until the line breaks settle. +- Drop caps (`type.para {dropCapLines, dropCapChars, dropCapStyle, dropCapAlignLeft, dropCapScaleDescenders}`, Paragraph panel, Paragraph Style Options › Drop Caps and Nested Styles; IDML DropCapLines/DropCapCharacters/DropcapDetail, the drop cap style as the leading "through 1 Dropcap" nested style): the first characters (code points, as InDesign counts them, never splitting a letter from its combining marks; up to 150) are enlarged from the first line's cap height down N − 1 leadings of the text after them (up to 25 lines; grid steps on a baseline grid), the lines beside them are indented by their advance (on the right in right-to-left paragraphs), spaces right after them are set at their size without widening the indent, baselines don't move; a one-line drop cap keeps a smaller size of its own and sets its tabs at the paragraph's tab stops; Align Left Edge (on by default) moves the drop cap left by its first letter's side bearing so its ink starts at the indent; Scale for Descenders is kept but doesn't change the layout yet; a drop cap character style, and nested styles through the drop cap (Dropcap delimiter). Drawn on screen and in PDF (text in order), with caret, hit testing and selection over the enlarged letters. Vertical text sets no drop caps. - User dictionary hyphenation exceptions (`hyphenation.addException/removeException/list`, Edit ▸ Spelling ▸ User Dictionary): `ex~am~ple` breaks only at `~`, a plain word never hyphenates. - Frame Fitting Options (`object.fittingOptions`, Object ▸ Fitting ▸ Frame Fitting Options…): Auto-Fit refits on resize, fitting, Align From reference point, crop amounts; IDML FrameFittingOption. - Paragraph borders (per-side weights, colour, tint, offsets) and shading offsets, drawn per column part of split paragraphs (also when overset); Paragraph panel controls; IDML ParagraphBorder*/ParagraphShading*Offset. @@ -365,7 +367,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Split Window / New Window (`window.split`, `window.newWindow`, Window ▸ Arrange): two side-by-side views of the document with their own zoom, scroll and render cache. New Window puts the second view in its own OS window (an egui viewport; a floating window on the web). - Transparency flattener (`file.exportPdf {flatten: high|medium|low|ppi}`, Edit ▸ Transparency Flattener Presets): each object involving transparency becomes an opaque image of its area over what's beneath it, so everything else stays vector (spreads with transparent parent items are rasterised whole). - Video and sound (place .mp4/.mov/.webm/.mp3/.wav…, `media.options/get`, Media panel): media frames show a poster or placeholder on screen, in print and in PDF, and export as HTML5 `