From 4ca58d67e1e8086278098ef58b80dc00633c8d3f Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 11 Oct 2026 21:53:47 +0200 Subject: [PATCH 01/89] Draw: Lasso Select, Add Pen, Ink to Shape and Ink Replay (#395) (#408) * Draw: Lasso Select, Add Pen, Ink to Shape and Ink Replay (#395) - draw.lasso: with no points picks up the lasso; with a loop on a page it selects the strokes mostly inside it and drawings whose centre is inside. draw.lassoMove / draw.lassoColor / draw.lassoDelete move, recolour and delete the selection (one undo step each); it lasts until the document changes otherwise. In the app: drag a loop, drag the selection to move it, Delete deletes, Esc lets go, the Color menu recolours it. - draw.addPen / draw.removePen: a pen gallery (kind, colour, width; capped, validated) shown after the built-in pens, kept in ui.json; damaged entries are dropped on reading. Right-click a gallery pen to remove it. - draw.inkToShape: toggles Ink to Shape (pen and pencil strokes that look like a line, rectangle/square, ellipse/circle, triangle or polygon of up to eight corners become that shape, in the same undo step); with ink lasso-selected it converts the selection (draw.convertInk). Our own recogniser (cmd/ink_shape.rs): chord test for lines, closure test, ellipse RMS fit, closed RDP simplification with corner merging. - draw.replay: Ink Replay plays the strokes back in drawing order with a Rewind / Play-Pause / Forward / Close bar; pages are drawn without ink meanwhile. Each new stroke records its drawing order (Freeform::order), written to DOCX in the drawing name (`Ink Pen 3 #12`) and read back. Catalog 402/434 -> 406/434 (93.5%). New strings in all nine catalogs. Co-Authored-By: Claude Opus 5.5 * Regenerate parity checklist after rebase (#395) --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +- crates/doc/src/freeform.rs | 10 +- crates/docx/src/read/freeform.rs | 8 +- crates/docx/src/read/story.rs | 4 +- crates/docx/src/write/story.rs | 12 +- crates/docx/tests/roundtrip.rs | 11 +- crates/engine/src/cmd/draw.rs | 657 ++++++++++++++++++++++++++-- crates/engine/src/cmd/ink_shape.rs | 325 ++++++++++++++ crates/engine/src/cmd/mod.rs | 1 + crates/ui-egui/src/canvas.rs | 10 +- crates/ui-egui/src/i18n/es.tsv | 13 + crates/ui-egui/src/i18n/et.tsv | 13 + crates/ui-egui/src/i18n/ja.tsv | 13 + crates/ui-egui/src/i18n/pt-br.tsv | 13 + crates/ui-egui/src/i18n/ru.tsv | 13 + crates/ui-egui/src/i18n/sr-latn.tsv | 13 + crates/ui-egui/src/i18n/sr.tsv | 13 + crates/ui-egui/src/i18n/tr.tsv | 13 + crates/ui-egui/src/i18n/uk.tsv | 13 + crates/ui-egui/src/i18n/zh-hans.tsv | 13 + crates/ui-egui/src/i18n/zh-hant.tsv | 13 + crates/ui-egui/src/icons.rs | 21 + crates/ui-egui/src/ink.rs | 349 ++++++++++++++- crates/ui-egui/src/keys.rs | 4 + crates/ui-egui/src/keytips.rs | 1 + crates/ui-egui/src/lib.rs | 7 + crates/ui-egui/src/ribbon.rs | 109 ++++- crates/ui-egui/src/widgets.rs | 2 +- docs/gaps.md | 3 +- docs/parity-checklist.md | 8 +- docs/roadmap.md | 5 +- docs/target-app-parity.md | 5 +- docs/ui-parity.md | 3 +- 33 files changed, 1612 insertions(+), 90 deletions(-) create mode 100644 crates/engine/src/cmd/ink_shape.rs diff --git a/ROADMAP.md b/ROADMAP.md index ea18713785..f44e6f0789 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -14,7 +14,7 @@ agent control. This page is the summary; the assessment is | Number | Value | Kind | |---|---|---| -| Ribbon/menu catalog coverage | **429 / 451 (95.1%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) | +| Ribbon/menu catalog coverage | **433 / 451 (96.0%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) | | **Feature breadth** (weighted, incl. dialog options, styles, languages beyond the ribbon) | **~80%** | estimated | | **Ready for real work** (full target) | **~60%** (55–63%) | estimated, additive weighted sum over the dimensions | | **Mainstream practitioner** | **~55%** | estimated: weekly-work depth × discounts for interaction (×0.93), stability (×0.90), file exchange (×0.90) | @@ -147,6 +147,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-11 | Citations and bibliography in all 12 of Word's styles: Harvard, ISO 690 (author-date and numerical), Turabian, GB/T 7714, GOST (name and title sort) and SIST02 join APA, MLA, Chicago and IEEE; numbered styles count in order of first citation (#383) | | 2026-10-11 | Interface size setting (#475): File › Options › General scales the interface 80–200 % on top of the system display scale, kept between runs; `ui.interfaceSize` for agents | | 2026-10-11 | Formatting revisions (#41): `w:rPrChange`/`w:pPrChange`, table/row/cell, section and numbering changes kept in DOCX; formatting recorded as revisions while tracking; accept/reject, Reviewing Pane descriptions, "Formatted: …" balloons and change bars | +| 2026-10-11 | Draw: Lasso Select (move, recolour, delete the strokes inside a loop), Add Pen gallery kept between runs, Ink to Shape (own recogniser: line, rectangle/square, ellipse/circle, triangle, polygon), Ink Replay in drawing order with play/pause/rewind/forward; drawing order kept in DOCX (#395); catalog 406/434 (93.5%) | | 2026-10-10 | Estonian (`et`) interface (#294): 1,161 translated entries, locale and saved-preference tests, bundled-font checks; no Estonian proofing resources | | 2026-10-10 | Draw tab: pen, pencil and highlighter ink, stroke eraser, Select, Review › Hide Ink; ink saved to DOCX as DrawingML freeforms (#307); catalog 399/431 (92.6%) | | 2026-10-10 | Draw Table and Eraser (#303): a pen draws one-cell tables and splits cells along drawn lines; the eraser merges the cells beside a border. Catalog 393/431 (91.2%) | @@ -162,6 +163,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Progress log: Draw lasso, Add Pen, Ink to Shape, Ink Replay (#395); catalog 406/434 | | 2026-10-11 | trivial | Progress log: document grid (#391) | | 2026-10-11 | trivial | Progress log: text direction, Align Text and linked text boxes (#369) | | 2026-10-10 | trivial | Progress: Drawing Canvas (#344): insert, shapes/pictures/text boxes inside, Fit/Expand/Scale, DOCX `wpc:wpc`, turns and flips as a whole; catalog 404/434 | diff --git a/crates/doc/src/freeform.rs b/crates/doc/src/freeform.rs index 06d80c0bcb..6b46d3b0f4 100644 --- a/crates/doc/src/freeform.rs +++ b/crates/doc/src/freeform.rs @@ -88,18 +88,22 @@ pub struct Freeform { /// by the eraser. #[serde(skip_serializing_if = "Option::is_none")] pub ink: Option, + /// Ink: its place in the order the strokes were drawn (1, 2, 3…), for Draw › Ink Replay. + /// `None` for ink from files that don't record it (replayed first, in document order). + #[serde(skip_serializing_if = "Option::is_none")] + pub order: Option, } impl Default for Freeform { fn default() -> Self { - Freeform { w: 0.0, h: 0.0, paths: Vec::new(), alpha: 1.0, ink: None } + Freeform { w: 0.0, h: 0.0, paths: Vec::new(), alpha: 1.0, ink: None, order: None } } } impl Freeform { /// An ink stroke through `pts` (shape-local points, already in the `w` × `h` frame). pub fn ink(tool: InkTool, w: f32, h: f32, pts: Vec<[f32; 2]>) -> Freeform { - Freeform { w, h, paths: vec![FreePath { pts, closed: false }], alpha: tool.alpha(), ink: Some(tool) }.sanitized() + Freeform { w, h, paths: vec![FreePath { pts, closed: false }], alpha: tool.alpha(), ink: Some(tool), order: None }.sanitized() } /// The same geometry with non-finite points dropped, coordinates clamped, the counts capped @@ -183,7 +187,7 @@ mod tests { fn hostile_geometry_is_sanitized() { let mut pts = vec![[f32::NAN, 1.0], [2.0, f32::INFINITY], [1.0, 2.0], [1e30, -1e30]]; pts.extend(std::iter::repeat_n([3.0, 3.0], MAX_POINTS * 2)); - let f = Freeform { w: f32::NAN, h: -4.0, paths: vec![FreePath { pts, closed: false }], alpha: 7.0, ink: None }.sanitized(); + let f = Freeform { w: f32::NAN, h: -4.0, paths: vec![FreePath { pts, closed: false }], alpha: 7.0, ink: None, order: None }.sanitized(); assert_eq!((f.w, f.h, f.alpha), (0.0, 0.0, 1.0)); let p = &f.paths[0].pts; assert_eq!(p.len(), MAX_POINTS); diff --git a/crates/docx/src/read/freeform.rs b/crates/docx/src/read/freeform.rs index 50a6c775fd..6d2525f4d2 100644 --- a/crates/docx/src/read/freeform.rs +++ b/crates/docx/src/read/freeform.rs @@ -71,7 +71,7 @@ pub fn cust_geom(sppr: &El, w: f32, h: f32) -> Option { paths.push(FreePath { pts: pts.into_iter().take(MAX_POINTS).map(scale).collect(), closed: closed && !fill_none }); } } - let f = Freeform { w, h, paths, alpha: 1.0, ink: None }.sanitized(); + let f = Freeform { w, h, paths, alpha: 1.0, ink: None, order: None }.sanitized(); (!f.paths.is_empty()).then_some(f) } @@ -85,6 +85,12 @@ pub fn ink_tool(name: &str) -> Option { words.next().and_then(InkTool::parse) } +/// The drawing order of an ink stroke written by WordCraft, from its drawing's name +/// (`Ink Pen 3 #12`: the twelfth stroke drawn), for Ink Replay. +pub fn ink_order(name: &str) -> Option { + name.split_whitespace().skip(3).find_map(|w| w.strip_prefix('#')?.parse().ok()) +} + /// The outline opacity of `ln` (`a:ln`): its colour's `a:alpha`, 0–1. pub fn line_alpha(ln: Option<&El>) -> f32 { ln.and_then(|l| l.child("a:solidFill")) diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index b17f219e2a..e72b91496a 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -816,7 +816,9 @@ impl Reader<'_> { && let Some(mut f) = super::freeform::cust_geom(sppr, w, h) { f.alpha = super::freeform::line_alpha(sppr.child("a:ln")); - f.ink = c.child("wp:docPr").and_then(|p| p.attr("name")).and_then(super::freeform::ink_tool); + let name = c.child("wp:docPr").and_then(|p| p.attr("name")); + f.ink = name.and_then(super::freeform::ink_tool); + f.order = name.filter(|_| f.ink.is_some()).and_then(super::freeform::ink_order); *kind = ShapeKind::Freeform; *freeform = Some(Arc::new(f)); } diff --git a/crates/docx/src/write/story.rs b/crates/docx/src/write/story.rs index 903765885b..e95602d3ea 100644 --- a/crates/docx/src/write/story.rs +++ b/crates/docx/src/write/story.rs @@ -1191,11 +1191,15 @@ fn sectpr_body(w: &mut W, s: &SectionProps) { } } -/// A shape's `cNvPr`/`docPr` name: ink is "Ink ", which reading looks for. +/// A shape's `cNvPr`/`docPr` name: ink is "Ink " (and "#" when its drawing +/// order is known), which reading looks for. fn shape_name(kind: ShapeKind, freeform: Option<&wordcraft_doc::freeform::Freeform>, id: &str) -> String { - match freeform.filter(|_| kind == ShapeKind::Freeform).and_then(|f| f.ink) { - Some(tool) => format!("Ink {} {id}", ink_name(tool)), - None => format!("Shape {id}"), + let f = freeform.filter(|_| kind == ShapeKind::Freeform); + match (f.and_then(|f| f.ink), f.and_then(|f| f.order)) { + // The drawing order rides along for Ink Replay: `Ink Pen 3 #12`. + (Some(tool), Some(n)) => format!("Ink {} {id} #{n}", ink_name(tool)), + (Some(tool), None) => format!("Ink {} {id}", ink_name(tool)), + (None, _) => format!("Shape {id}"), } } diff --git a/crates/docx/tests/roundtrip.rs b/crates/docx/tests/roundtrip.rs index 612fbdb477..da13e30c68 100644 --- a/crates/docx/tests/roundtrip.rs +++ b/crates/docx/tests/roundtrip.rs @@ -1373,7 +1373,8 @@ fn list_level_overrides_round_trip() { } /// Ink strokes and freeform shapes are written as DrawingML custom geometry (`a:custGeom`, -/// `a:moveTo`/`a:lnTo`) in floating drawings and read back with their points, pen and opacity. +/// `a:moveTo`/`a:lnTo`) in floating drawings and read back with their points, pen, opacity and +/// drawing order. #[test] fn ink_and_freeforms_round_trip_as_custom_geometry() { use wordcraft_doc::freeform::{FreePath, Freeform, InkTool}; @@ -1389,7 +1390,13 @@ fn ink_and_freeforms_round_trip_as_custom_geometry() { freeform: Some(Arc::new(f)), effects: Default::default(), }; - let pen = shape(None, 2.0, Wrap::InFrontOfText, Freeform::ink(InkTool::Pen, 60.0, 20.0, vec![[1.0, 1.0], [30.0, 19.0], [59.0, 4.0]])); + // The pen stroke was the seventh drawn: Ink Replay's order comes back too. + let pen = shape( + None, + 2.0, + Wrap::InFrontOfText, + Freeform { order: Some(7), ..Freeform::ink(InkTool::Pen, 60.0, 20.0, vec![[1.0, 1.0], [30.0, 19.0], [59.0, 4.0]]) }, + ); let marker = shape(None, 12.0, Wrap::BehindText, Freeform::ink(InkTool::Highlighter, 80.0, 12.0, vec![[6.0, 6.0], [74.0, 6.0]])); let triangle = Freeform { w: 40.0, diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs index 38f4121ce3..c00724df13 100644 --- a/crates/engine/src/cmd/draw.rs +++ b/crates/engine/src/cmd/draw.rs @@ -1,20 +1,28 @@ -//! Draw tab and Review › Ink: pens, the eraser and ink strokes. +//! Draw tab and Review › Ink: pens, the eraser, the lasso and ink strokes. //! //! An ink stroke is a floating freeform shape ([`Freeform`] with its pen set) anchored to the //! paragraph under where it starts, in front of the text (a highlighter's behind it). The pen //! commands only choose what dragging on the page does (session view state); `draw.stroke` and //! `draw.erase` change the document. +//! +//! Draw › Lasso Select picks the strokes (and drawings) inside a loop drawn on a page; the +//! selection can then be moved, recoloured, deleted or turned into shapes. Ink to Shape replaces +//! strokes that look like a line, rectangle, ellipse, triangle or polygon with that shape +//! ([`ink_shape`](super::ink_shape)). Ink Replay plays the strokes back in the order they were +//! drawn (each stroke keeps its place in that order). Add Pen keeps custom pens in a gallery. use std::sync::Arc; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use serde_json::{Value, json}; -use wordcraft_doc::freeform::{Freeform, InkTool, MAX_INK_PER_PAGE, MAX_POINTS}; +use wordcraft_doc::freeform::{FreePath, Freeform, InkTool, MAX_INK_PER_PAGE, MAX_POINTS}; use wordcraft_doc::para::{Anchor, Float, InlineObject, OBJ, ShapeKind, Wrap}; use wordcraft_doc::props::Rgb; use wordcraft_doc::{CharProps, Pos, StoryRef}; +use wordcraft_geom::Rect; use wordcraft_layout::{DocLayout, Placed}; +use super::ink_shape::{InkShape, recognise}; use super::pos_json; use crate::{CmdError, CmdResult, CommandSpec, Session, p}; @@ -25,6 +33,8 @@ pub enum DrawMode { /// Ordinary selection of text and drawings. #[default] Select, + /// Dragging draws a loop that selects the ink inside it; dragging the selection moves it. + Lasso, /// Dragging over ink strokes deletes them. Eraser, /// Dragging draws ink with this pen. @@ -56,7 +66,69 @@ impl PenSettings { } } -/// The Draw tab's state: the tool in use and each pen's settings. +/// A pen added to the pen gallery (Draw › Add Pen): its kind, colour and thickness. Saved as +/// `{"kind": "pen", "color": "RRGGBB", "width": 1.5}`. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(try_from = "Value", into = "Value")] +pub struct CustomPen { + pub kind: InkTool, + pub color: Rgb, + /// Points. + pub width: f32, +} + +impl TryFrom for CustomPen { + type Error = CmdError; + fn try_from(v: Value) -> Result { + custom_pen(&v) + } +} + +impl From for Value { + fn from(p: CustomPen) -> Value { + json!({"kind": p.kind.name(), "color": p.color.hex(), "width": p.width}) + } +} + +/// Most pens the gallery keeps. +pub const MAX_PENS: usize = 24; + +/// A pen from `{"kind"?, "color"?, "width"?}` (the kind's defaults for what's left out; the +/// width clamped). Anything but an object, an unknown kind or a malformed colour is refused. +pub fn custom_pen(v: &Value) -> Result { + if !v.is_object() { + return Err(CmdError::Params("a pen is {\"kind\", \"color\", \"width\"}".into())); + } + let kind = match p::str(v, "kind") { + Some(k) => InkTool::parse(k).ok_or_else(|| CmdError::Params("`kind` is pen, pencil or highlighter".into()))?, + None => InkTool::Pen, + }; + let color = match p::str(v, "color") { + Some(c) => Rgb::parse(c).ok_or_else(|| CmdError::Params("`color` is RRGGBB".into()))?, + None => kind.default_color(), + }; + let width = p::f32(v, "width").filter(|w| w.is_finite()).unwrap_or(kind.default_width()).clamp(MIN_WIDTH, MAX_WIDTH); + Ok(CustomPen { kind, color, width }) +} + +/// Read a saved pen gallery, dropping entries that aren't pens (a hand-edited or damaged +/// settings file loses those pens, never the rest of the settings). +pub fn lenient_pens<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + let v = Value::deserialize(d)?; + Ok(v.as_array().map(|a| a.iter().filter_map(|x| custom_pen(x).ok()).take(MAX_PENS).collect()).unwrap_or_default()) +} + +/// Strokes and drawings picked with the lasso on `page`. Good while the document is as it was +/// when they were picked (or last moved, recoloured or converted through the lasso). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct LassoSelection { + pub page: usize, + pub items: Vec, + rev: u64, +} + +/// The Draw tab's state: the tool in use, each pen's settings, the pen gallery, Ink to Shape, +/// the lasso selection and whether ink is being replayed. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct DrawState { @@ -64,6 +136,16 @@ pub struct DrawState { pub pen: PenSettings, pub pencil: PenSettings, pub highlighter: PenSettings, + /// Pens added with Add Pen (kept between runs by the app). + #[serde(deserialize_with = "lenient_pens")] + pub pens: Vec, + /// Ink to Shape is on: strokes drawn from now on that look like a shape become that shape. + pub ink_to_shape: bool, + #[serde(skip)] + pub lasso: Option, + /// Ink Replay is showing (the app plays the strokes back and turns this off when done). + #[serde(skip)] + pub replay: bool, } impl Default for DrawState { @@ -73,6 +155,10 @@ impl Default for DrawState { pen: PenSettings::of(InkTool::Pen), pencil: PenSettings::of(InkTool::Pencil), highlighter: PenSettings::of(InkTool::Highlighter), + pens: Vec::new(), + ink_to_shape: false, + lasso: None, + replay: false, } } } @@ -99,6 +185,12 @@ const MIN_WIDTH: f32 = 0.25; const MAX_WIDTH: f32 = 72.0; /// How far from a stroke (beyond its half width) the eraser still takes it, points. const ERASE_SLOP: f32 = 3.0; +/// Most points of a lasso loop that are kept (a longer loop is thinned out). +const MAX_LASSO_POINTS: usize = 512; +/// Points of a stroke tested against the lasso (spread along it). +const LASSO_SAMPLES: usize = 64; +/// Farthest the lasso moves its selection in one step, points. +const MAX_MOVE: f32 = 10_000.0; pub fn specs() -> Vec { vec![ @@ -107,6 +199,12 @@ pub fn specs() -> Vec { state(s) }) .pure(), + CommandSpec::new("draw.lasso", "Lasso Select", "Draw › Tools", lasso) + .params(r#"{"page"?: n, "points"?: [[x, y], …], "clear"?: bool} (no points: use the lasso; points: select the ink inside that loop; clear: drop the selection)"#) + .pure(), + CommandSpec::new("draw.lassoMove", "Move Selected Ink", "Draw › Tools", lasso_move).params(r#"{"dx": pt, "dy": pt}"#), + CommandSpec::new("draw.lassoDelete", "Delete Selected Ink", "Draw › Tools", lasso_delete), + CommandSpec::new("draw.lassoColor", "Recolor Selected Ink", "Draw › Tools", lasso_color).params(r#"{"color": "RRGGBB"}"#), CommandSpec::new("draw.eraser", "Eraser", "Draw › Tools", |s, v| { // With a point, erase there (as a script would); without, pick up the eraser. if v.get("page").is_some() || v.get("x").is_some() || v.get("y").is_some() { @@ -120,10 +218,42 @@ pub fn specs() -> Vec { CommandSpec::new("draw.pen", "Pen", "Draw › Pens", |s, v| pen(s, v, InkTool::Pen)).params(PEN_PARAMS).pure(), CommandSpec::new("draw.pencil", "Pencil", "Draw › Pens", |s, v| pen(s, v, InkTool::Pencil)).params(PEN_PARAMS).pure(), CommandSpec::new("draw.highlighter", "Highlighter", "Draw › Pens", |s, v| pen(s, v, InkTool::Highlighter)).params(PEN_PARAMS).pure(), + CommandSpec::new("draw.addPen", "Add Pen", "Draw › Pens", add_pen) + .params(r#"{"kind"?: "pen|pencil|highlighter", "color"?: "RRGGBB", "width"?: pt} (adds the pen to the gallery and picks it up)"#) + .pure(), + CommandSpec::new("draw.removePen", "Remove Pen", "Draw › Pens", |s, v| { + let i = p::u64(v, "index").and_then(|i| usize::try_from(i).ok()).ok_or_else(|| CmdError::Params("`index` (0-based) is required".into()))?; + if i >= s.view.draw.pens.len() { + return Err(CmdError::Params(format!("no pen {i}"))); + } + let pen = s.view.draw.pens.remove(i); + Ok(json!({"removed": Value::from(pen), "pens": s.view.draw.pens.len()})) + }) + .params(r#"{"index": n}"#) + .pure(), CommandSpec::new("draw.stroke", "Draw Ink", "Draw › Pens", stroke).params( r#"{"page": n, "points": [[x, y], …] (page points, at most 5000), "tool"?: "pen|pencil|highlighter", "color"?: "RRGGBB", "width"?: pt}"#, ), CommandSpec::new("draw.erase", "Erase Ink", "Draw › Tools", erase).params(r#"{"page": n, "x": pt, "y": pt}"#), + CommandSpec::new("draw.inkToShape", "Ink to Shape", "Draw › Convert", |s, v| { + if let Some(on) = p::bool(v, "value") { + s.view.draw.ink_to_shape = on; + return Ok(json!({"value": on})); + } + // With strokes lasso-selected it converts them; otherwise it turns the mode on or off. + let ink_selected = lasso_selection(s).is_some_and(|l| l.items.iter().any(|pos| s.doc.para_at(pos).and_then(|p| p.object_at(pos.off)).and_then(InlineObject::ink).is_some())); + if ink_selected { + return s.run("draw.convertInk", &json!({})); + } + s.view.draw.ink_to_shape = !s.view.draw.ink_to_shape; + Ok(json!({"value": s.view.draw.ink_to_shape})) + }) + .params(r#"{"value"?: bool} (no value: convert the lasso-selected strokes, or turn Ink to Shape on or off)"#) + .pure(), + CommandSpec::new("draw.convertInk", "Convert to Shapes", "Draw › Convert", convert_selection), + CommandSpec::new("draw.replay", "Ink Replay", "Draw › Replay", replay) + .params(r#"{"value"?: bool} (starts or stops Ink Replay; returns the strokes in the order they were drawn)"#) + .pure(), CommandSpec::new("review.hideInk", "Hide Ink", "Review › Ink", |s, v| { s.view.hide_ink = p::bool(v, "value").unwrap_or(!s.view.hide_ink); Ok(json!({"value": s.view.hide_ink})) @@ -146,15 +276,54 @@ fn pen(s: &mut Session, v: &Value, tool: InkTool) -> CmdResult { if let Some(c) = color { set.color = c; } - if let Some(w) = p::f32(v, "width") { + if let Some(w) = p::f32(v, "width").filter(|w| w.is_finite()) { set.width = w.clamp(MIN_WIDTH, MAX_WIDTH); } s.view.draw.mode = DrawMode::Pen(tool); state(s) } -/// Ink strokes laid out on `page`: their position, unturned frame, turn and geometry on the page. -fn ink_on(s: &Session, layout: &DocLayout, page: usize) -> Vec<(Pos, wordcraft_geom::Rect, wordcraft_geom::Spin, Arc, f32)> { +/// Add a pen to the gallery and pick it up. +fn add_pen(s: &mut Session, v: &Value) -> CmdResult { + let pen = custom_pen(v)?; + if s.view.draw.pens.len() >= MAX_PENS { + return Err(CmdError::Failed(format!("the pen gallery holds at most {MAX_PENS} pens"))); + } + s.view.draw.pens.push(pen); + *s.view.draw.settings_mut(pen.kind) = PenSettings { color: pen.color, width: pen.width }; + s.view.draw.mode = DrawMode::Pen(pen.kind); + Ok(json!({"pen": Value::from(pen), "index": s.view.draw.pens.len() - 1, "pens": s.view.draw.pens.len()})) +} + +/// An ink stroke laid out on a page. +#[derive(Clone, Debug)] +pub struct PlacedInk { + pub pos: Pos, + /// Its unturned frame on the page. + pub rect: Rect, + /// Its turn about the frame's centre. + pub spin: wordcraft_geom::Spin, + pub freeform: Arc, + pub color: Option, + pub width: f32, +} + +impl PlacedInk { + /// Its paths in page points, turned as it is drawn. + pub fn paths(&self) -> Vec> { + let r = self.rect; + let paths = self.freeform.placed(r.x, r.y, r.w, r.h).into_iter().map(|(pts, _)| pts); + if self.spin.is_identity() { + return paths.collect(); + } + let (cx, cy) = (r.x + r.w / 2.0, r.y + r.h / 2.0); + paths.map(|pts| pts.into_iter().map(|[x, y]| <[f32; 2]>::from(self.spin.apply(cx, cy, x, y))).collect()).collect() + } +} + +/// Ink strokes laid out on `page`, in the page's drawing order: their position, unturned frame, +/// turn and geometry on the page. +pub fn ink_on(s: &Session, layout: &DocLayout, page: usize) -> Vec { let Some(pg) = layout.pages.get(page) else { return Vec::new() }; pg.items .iter() @@ -162,8 +331,8 @@ fn ink_on(s: &Session, layout: &DocLayout, page: usize) -> Vec<(Pos, wordcraft_g Placed::Object { rect, spin, story, path, off, .. } => { let pos = Pos { story: *story, path: path.clone(), off: *off }; match s.doc.para_at(&pos).and_then(|p| p.object_at(*off)) { - Some(InlineObject::Shape { freeform: Some(f), stroke_width, .. }) if f.is_ink() => { - Some((pos, *rect, *spin, f.clone(), *stroke_width)) + Some(InlineObject::Shape { freeform: Some(f), stroke_width, stroke, .. }) if f.is_ink() => { + Some(PlacedInk { pos, rect: *rect, spin: *spin, freeform: f.clone(), color: *stroke, width: *stroke_width }) } _ => None, } @@ -179,12 +348,13 @@ pub fn ink_at(s: &mut Session, page: usize, x: f32, y: f32) -> Option { return None; } let layout = s.layout(); - ink_on(s, &layout, page).into_iter().rev().find_map(|(pos, r, spin, f, sw)| { - let reach = (if sw.is_finite() { sw.clamp(0.0, MAX_WIDTH) } else { 1.0 }) / 2.0 + ERASE_SLOP; + ink_on(s, &layout, page).into_iter().rev().find_map(|k| { + let reach = (if k.width.is_finite() { k.width.clamp(0.0, MAX_WIDTH) } else { 1.0 }) / 2.0 + ERASE_SLOP; // A turned stroke is drawn turned about its frame's centre: test the point turned back // into the stroke's own frame (a turn keeps distances, so the reach holds). - let (u, v) = spin.unapply(r.x + r.w / 2.0, r.y + r.h / 2.0, x, y); - f.distance(r.x, r.y, r.w, r.h, u, v).filter(|d| *d <= reach).map(|_| pos) + let r = k.rect; + let (u, v) = k.spin.unapply(r.x + r.w / 2.0, r.y + r.h / 2.0, x, y); + k.freeform.distance(r.x, r.y, r.w, r.h, u, v).filter(|d| *d <= reach).map(|_| k.pos) }) } @@ -195,19 +365,55 @@ fn page_param(s: &mut Session, v: &Value) -> Result<(usize, Arc), Cmd Ok((page, layout)) } +/// `points` as page points: at most `max`, NaN, infinities and malformed entries dropped, each +/// clamped to the `pw` × `ph` page. +fn points_param(v: &Value, max: usize, pw: f32, ph: f32) -> Result, CmdError> { + Ok(v.get("points") + .and_then(Value::as_array) + .ok_or_else(|| CmdError::Params("`points` ([[x, y], …]) is required".into()))? + .iter() + .take(max) + .filter_map(|pt| { + let a = pt.as_array()?; + let (x, y) = (a.first()?.as_f64()? as f32, a.get(1)?.as_f64()? as f32); + (x.is_finite() && y.is_finite()).then(|| [x.clamp(0.0, pw), y.clamp(0.0, ph)]) + }) + .collect()) +} + /// Delete the ink stroke under a point. fn erase(s: &mut Session, v: &Value) -> CmdResult { let (page, _) = page_param(s, v)?; let (x, y) = (p::req_f32(v, "x")?, p::req_f32(v, "y")?); let pos = ink_at(s, page, x, y).ok_or_else(|| CmdError::Failed("no ink stroke there".into()))?; + delete_object(s, &pos)?; + Ok(json!({"erased": pos_json(&pos)})) +} + +/// Delete the object at `pos`, keeping the text selection on the same text. +fn delete_object(s: &mut Session, pos: &Pos) -> Result<(), CmdError> { let len = OBJ.len_utf8(); - s.doc.delete_range(&pos, &Pos { off: pos.off + len, ..pos.clone() })?; + s.doc.delete_range(pos, &Pos { off: pos.off + len, ..pos.clone() })?; for at in [&mut s.sel.anchor, &mut s.sel.focus] { if at.story == pos.story && at.path == pos.path && at.off > pos.off { at.off = at.off.saturating_sub(len).max(pos.off); } } - Ok(json!({"erased": pos_json(&pos)})) + Ok(()) +} + +/// The next stroke's place in the drawing order: one after the latest drawn. +fn next_order(s: &Session) -> u32 { + let latest = s + .doc + .para_paths(StoryRef::Body) + .iter() + .filter_map(|path| s.doc.para(StoryRef::Body, path)) + .flat_map(|p| p.objects.iter()) + .filter_map(|o| o.ink().and_then(|f| f.order)) + .max() + .unwrap_or(0); + latest.saturating_add(1) } /// Draw an ink stroke through page points. @@ -222,22 +428,10 @@ fn stroke(s: &mut Session, v: &Value) -> CmdResult { Some(c) => Rgb::parse(c).ok_or_else(|| CmdError::Params("`color` is RRGGBB".into()))?, None => set.color, }; - let width = p::f32(v, "width").unwrap_or(set.width).clamp(MIN_WIDTH, MAX_WIDTH); + let width = p::f32(v, "width").filter(|w| w.is_finite()).unwrap_or(set.width).clamp(MIN_WIDTH, MAX_WIDTH); let pg = layout.pages.get(page).ok_or_else(|| CmdError::Params(format!("no page {page}")))?; let (pw, ph) = (pg.w.max(1.0), pg.h.clamp(1.0, 1e5)); - // Points on the page; NaN, infinities and malformed entries are dropped. - let pts: Vec<[f32; 2]> = v - .get("points") - .and_then(Value::as_array) - .ok_or_else(|| CmdError::Params("`points` ([[x, y], …]) is required".into()))? - .iter() - .take(MAX_POINTS) - .filter_map(|pt| { - let a = pt.as_array()?; - let (x, y) = (a.first()?.as_f64()? as f32, a.get(1)?.as_f64()? as f32); - (x.is_finite() && y.is_finite()).then(|| [x.clamp(0.0, pw), y.clamp(0.0, ph)]) - }) - .collect(); + let pts = points_param(v, MAX_POINTS, pw, ph)?; let first = *pts.first().ok_or_else(|| CmdError::Params("`points` has no usable point".into()))?; if ink_on(s, &layout, page).len() >= MAX_INK_PER_PAGE { return Err(CmdError::Failed(format!("page {page} already has {MAX_INK_PER_PAGE} ink strokes"))); @@ -252,6 +446,7 @@ fn stroke(s: &mut Session, v: &Value) -> CmdResult { let (fw, fh) = ((x1 - x0 + width).max(1.0), (y1 - y0 + width).max(1.0)); let local: Vec<[f32; 2]> = pts.iter().map(|[x, y]| [x - fx, y - fy]).collect(); let wrap = if tool == InkTool::Highlighter { Wrap::BehindText } else { Wrap::InFrontOfText }; + let order = next_order(s); let obj = InlineObject::Shape { kind: ShapeKind::Freeform, w: fw, @@ -261,11 +456,13 @@ fn stroke(s: &mut Session, v: &Value) -> CmdResult { stroke_width: width, float: Float { wrap, ..Default::default() }, story: None, - freeform: Some(Arc::new(Freeform::ink(tool, fw, fh, local))), + freeform: Some(Arc::new(Freeform { order: Some(order), ..Freeform::ink(tool, fw, fh, local) })), effects: Default::default(), }; let at = place(s, &layout, obj, page, (fx, fy), first[1])?; - Ok(json!({"pos": pos_json(&at), "page": page, "rect": [fx, fy, fw, fh]})) + // Ink to Shape: a stroke that looks like a shape becomes it (in the same undo step). + let shape = if s.view.draw.ink_to_shape && tool != InkTool::Highlighter { convert_at(s, &at)? } else { None }; + Ok(json!({"pos": pos_json(&at), "page": page, "rect": [fx, fy, fw, fh], "shape": shape})) } /// Insert floating `obj` with its top-left at `(x, y)` on `page`, anchored to the body paragraph @@ -303,12 +500,271 @@ fn place(s: &mut Session, layout: &DocLayout, obj: InlineObject, page: usize, (x Ok(at) } +// --- Lasso ------------------------------------------------------------------------------- + +/// The current lasso selection, if the document hasn't changed under it. +pub fn lasso_selection(s: &Session) -> Option<&LassoSelection> { + s.view.draw.lasso.as_ref().filter(|l| l.rev == s.rev() && !l.items.is_empty()) +} + +/// Keep `sel` selected after the mutating command running now (which bumps the revision once). +fn keep_lasso(s: &mut Session, sel: LassoSelection) { + s.view.draw.lasso = Some(LassoSelection { rev: s.rev().wrapping_add(1), ..sel }); +} + +/// Whether (`x`, `y`) is inside the closed loop `poly` (even–odd rule). +fn inside(poly: &[[f32; 2]], x: f32, y: f32) -> bool { + let n = poly.len(); + let mut odd = false; + for i in 0..n { + let (Some(a), Some(b)) = (poly.get(i), poly.get((i + n - 1) % n)) else { continue }; + if (a[1] > y) != (b[1] > y) && x < (b[0] - a[0]) * (y - a[1]) / (b[1] - a[1]) + a[0] { + odd = !odd; + } + } + odd +} + +/// Select the ink inside a loop (most of a stroke's points inside it) and the drawings whose +/// centre is inside it; without a loop, pick up the lasso. +fn lasso(s: &mut Session, v: &Value) -> CmdResult { + if p::bool(v, "clear") == Some(true) { + s.view.draw.lasso = None; + return Ok(json!({"selected": [], "count": 0})); + } + if v.get("points").is_none() { + s.view.draw.mode = DrawMode::Lasso; + return state(s); + } + let (page, layout) = page_param(s, v)?; + let pg = layout.pages.get(page).ok_or_else(|| CmdError::Params(format!("no page {page}")))?; + let mut poly = points_param(v, MAX_POINTS, pg.w.max(1.0), pg.h.clamp(1.0, 1e5))?; + if poly.len() > MAX_LASSO_POINTS { + let step = poly.len().div_ceil(MAX_LASSO_POINTS); + poly = poly.into_iter().step_by(step).collect(); + } + if poly.len() < 3 { + return Err(CmdError::Params("a lasso loop needs at least three points".into())); + } + let mut items = Vec::new(); + for it in &pg.items { + let Placed::Object { rect, spin, story, path, off, .. } = it else { continue }; + let pos = Pos { story: *story, path: path.clone(), off: *off }; + let Some(obj) = s.doc.para_at(&pos).and_then(|p| p.object_at(*off)) else { continue }; + let picked = match obj.ink() { + Some(_) if s.view.hide_ink => false, + Some(f) => { + // Where the stroke is drawn: turned about its frame's centre. + let (cx, cy) = (rect.x + rect.w / 2.0, rect.y + rect.h / 2.0); + let pts: Vec<[f32; 2]> = f + .placed(rect.x, rect.y, rect.w, rect.h) + .into_iter() + .flat_map(|(p, _)| p) + .map(|[x, y]| <[f32; 2]>::from(spin.apply(cx, cy, x, y))) + .collect(); + let step = pts.len().div_ceil(LASSO_SAMPLES).max(1); + let (n, hits) = pts.iter().step_by(step).fold((0usize, 0usize), |(n, h), [x, y]| (n + 1, h + usize::from(inside(&poly, *x, *y)))); + n > 0 && hits * 10 >= n * 6 + } + None => obj.frame().is_some() && inside(&poly, rect.x + rect.w / 2.0, rect.y + rect.h / 2.0), + }; + if picked && !items.contains(&pos) { + items.push(pos); + } + } + let selected: Vec = items.iter().map(pos_json).collect(); + s.view.draw.lasso = (!items.is_empty()).then(|| LassoSelection { page, items, rev: s.rev() }); + Ok(json!({"page": page, "count": selected.len(), "selected": selected})) +} + +fn lasso_required(s: &Session) -> Result { + lasso_selection(s).cloned().ok_or_else(|| CmdError::Failed("nothing is selected with the lasso".into())) +} + +/// The lasso selection's bounds on its page in `layout` (the document's current layout), for +/// the app to draw and drag. +pub fn lasso_bounds(s: &Session, layout: &DocLayout) -> Option<(usize, Rect)> { + let sel = lasso_selection(s)?; + let mut r: Option = None; + for pos in &sel.items { + if let Some(hit) = layout.object(pos, sel.page) { + r = Some(r.map_or(hit.rect, |u| u.union(&hit.rect))); + } + } + r.map(|r| (sel.page, r)) +} + +/// Move the lasso-selected drawings by (`dx`, `dy`) points (one undo step). +fn lasso_move(s: &mut Session, v: &Value) -> CmdResult { + let sel = lasso_required(s)?; + let d = |k: &str| p::req_f32(v, k).map(|d| if d.is_finite() { d.clamp(-MAX_MOVE, MAX_MOVE) } else { 0.0 }); + let (dx, dy) = (d("dx")?, d("dy")?); + let mut moved = 0; + for pos in &sel.items { + let para = s.doc.para_mut(pos.story, &pos.path)?; + let mut hit = false; + if let Some(o) = para.object_at_mut(pos.off) + && o.is_floating() + && let Some(f) = o.float_mut() + { + if f.h_align.is_none() { + f.x += dx; + } + if f.v_align.is_none() { + f.y += dy; + } + hit = true; + } + if hit { + para.touch(); + moved += 1; + } + } + if moved == 0 { + return Err(CmdError::Failed("nothing selected can be moved".into())); + } + keep_lasso(s, sel); + Ok(json!({"moved": moved})) +} + +/// Delete the lasso-selected strokes and drawings (one undo step). +fn lasso_delete(s: &mut Session, _: &Value) -> CmdResult { + let mut items = lasso_required(s)?.items; + // Last first, so the earlier ones keep their place. + items.sort(); + items.dedup(); + let mut deleted = 0; + for pos in items.iter().rev() { + if s.doc.para_at(pos).and_then(|p| p.object_at(pos.off)).is_some() { + delete_object(s, pos)?; + deleted += 1; + } + } + s.view.draw.lasso = None; + if deleted == 0 { + return Err(CmdError::Failed("nothing selected to delete".into())); + } + Ok(json!({"deleted": deleted})) +} + +/// Give the lasso-selected strokes and shapes this outline colour (one undo step). +fn lasso_color(s: &mut Session, v: &Value) -> CmdResult { + let sel = lasso_required(s)?; + let color = Rgb::parse(p::req_str(v, "color")?).ok_or_else(|| CmdError::Params("`color` is RRGGBB".into()))?; + let mut changed = 0; + for pos in &sel.items { + let para = s.doc.para_mut(pos.story, &pos.path)?; + let mut hit = false; + if let Some(InlineObject::Shape { stroke, .. }) = para.object_at_mut(pos.off) { + *stroke = Some(color); + hit = true; + } + if hit { + para.touch(); + changed += 1; + } + } + if changed == 0 { + return Err(CmdError::Failed("nothing selected has an outline to recolour".into())); + } + keep_lasso(s, sel); + Ok(json!({"recolored": changed, "color": color.hex()})) +} + +// --- Ink to Shape -------------------------------------------------------------------------- + +/// Turn the lasso-selected strokes that look like shapes into those shapes (one undo step). +fn convert_selection(s: &mut Session, _: &Value) -> CmdResult { + let sel = lasso_required(s)?; + let mut converted = Vec::new(); + for pos in &sel.items { + if let Some(name) = convert_at(s, pos)? { + converted.push(json!({"pos": pos_json(pos), "shape": name})); + } + } + if converted.is_empty() { + return Err(CmdError::Failed("none of the selected strokes looks like a shape".into())); + } + keep_lasso(s, sel); + Ok(json!({"count": converted.len(), "converted": converted})) +} + +/// Replace the ink stroke at `pos` by the shape it looks like, in its colour and thickness and +/// where it was drawn. `None` (nothing changed) when it isn't a pen or pencil stroke, it is +/// turned or flipped, or it doesn't look like a shape. +fn convert_at(s: &mut Session, pos: &Pos) -> Result, CmdError> { + let para = s.doc.para_mut(pos.story, &pos.path)?; + let Some(InlineObject::Shape { kind, w, h, fill, float, freeform, .. }) = para.object_at_mut(pos.off) else { return Ok(None) }; + let Some(f) = freeform.as_deref().filter(|f| matches!(f.ink, Some(InkTool::Pen | InkTool::Pencil)) && f.paths.len() == 1) else { + return Ok(None); + }; + if float.rot != 0.0 || float.flip_h || float.flip_v { + return Ok(None); + } + // The stroke in its frame's own points (the frame may have been resized since). + let Some((pts, _)) = f.placed(0.0, 0.0, *w, *h).into_iter().next() else { return Ok(None) }; + let Some(found) = recognise(&pts) else { return Ok(None) }; + let [bx, by, bw, bh] = found.bounds(); + let (bw, bh) = (bw.max(1.0), bh.max(1.0)); + let outline = |pts: Vec<[f32; 2]>, closed: bool| { + Some(Arc::new(Freeform { w: bw, h: bh, paths: vec![FreePath { pts, closed }], ..Default::default() }.sanitized())) + }; + let local = |c: &[[f32; 2]]| c.iter().map(|[x, y]| [x - bx, y - by]).collect::>(); + let (new_kind, geometry) = match &found { + InkShape::Rectangle { .. } => (ShapeKind::Rectangle, None), + InkShape::Ellipse { .. } => (ShapeKind::Ellipse, None), + InkShape::Line { a, b } => (ShapeKind::Freeform, outline(local(&[*a, *b]), false)), + InkShape::Polygon(c) => (ShapeKind::Freeform, outline(local(c), true)), + }; + *kind = new_kind; + *freeform = geometry; + *fill = None; + *w = bw; + *h = bh; + float.x += bx; + float.y += by; + para.touch(); + Ok(Some(found.name())) +} + +// --- Ink Replay ---------------------------------------------------------------------------- + +/// Every ink stroke in the document with its page, in the order it was drawn: strokes that +/// don't record their order (from other files) first, in document order, then the rest by order. +pub fn strokes_in_drawing_order(s: &mut Session) -> Vec<(usize, PlacedInk)> { + if s.view.hide_ink { + return Vec::new(); + } + let layout = s.layout(); + let mut all: Vec<(usize, PlacedInk)> = + (0..layout.pages.len()).flat_map(|page| ink_on(s, &layout, page).into_iter().map(move |k| (page, k))).collect(); + all.sort_by_key(|(_, k)| k.freeform.order.unwrap_or(0)); + all +} + +/// Start or stop Ink Replay; the strokes in the order they'll be replayed. +fn replay(s: &mut Session, v: &Value) -> CmdResult { + let on = p::bool(v, "value").unwrap_or(!s.view.draw.replay); + let strokes = if on { strokes_in_drawing_order(s) } else { Vec::new() }; + if on && strokes.is_empty() { + return Err(CmdError::Failed("there is no ink to replay".into())); + } + s.view.draw.replay = on; + let list: Vec = strokes + .iter() + .map(|(page, k)| { + json!({"page": page, "pos": pos_json(&k.pos), "order": k.freeform.order, "tool": k.freeform.ink.map(InkTool::name), "points": k.freeform.paths.iter().map(|p| p.pts.len()).sum::()}) + }) + .collect(); + Ok(json!({"replaying": on, "strokes": list})) +} + #[cfg(test)] mod tests { use serde_json::json; use wordcraft_doc::StoryRef; use wordcraft_doc::freeform::InkTool; - use wordcraft_doc::para::{InlineObject, Wrap}; + use wordcraft_doc::para::{InlineObject, ShapeKind, Wrap}; use crate::Session; @@ -404,6 +860,20 @@ mod tests { assert!(s.run("draw.erase", &json!({"page": 0, "x": 150, "y": 60})).is_err()); } + /// The lasso, like the eraser, finds a turned stroke where it is drawn. + #[test] + fn the_lasso_selects_a_rotated_stroke_where_it_is_drawn() { + let mut s = session(); + s.run("draw.stroke", &json!({"page": 0, "points": [[100, 100], [200, 100]], "width": 2})).unwrap(); + s.run("select.objects", &json!({"index": 0})).unwrap(); + s.run("arrange.rotation", &json!({"degrees": 90})).unwrap(); + // A loop around its old horizontal path misses it; one around the vertical stroke takes it. + let r = s.run("draw.lasso", &json!({"page": 0, "points": [[90, 95], [210, 95], [210, 105], [90, 105]]})).unwrap(); + assert_eq!(r["count"], 0); + let r = s.run("draw.lasso", &json!({"page": 0, "points": [[140, 40], [160, 40], [160, 160], [140, 160]]})).unwrap(); + assert_eq!(r["count"], 1); + } + /// `draw.eraser` is a view command, but with a point it erases through `draw.erase`, which /// changes the document: that must be one undo step and mark the document changed. #[test] @@ -421,4 +891,127 @@ mod tests { s.run("edit.redo", &json!({})).unwrap(); assert!(ink(&s).is_empty()); } + + /// A closed rectangle drawn as page points, `per` points to a side. + fn rect_stroke(x: f32, y: f32, w: f32, h: f32) -> Vec<[f32; 2]> { + let c = [[x, y], [x + w, y], [x + w, y + h], [x, y + h]]; + let mut pts = Vec::new(); + for i in 0..4 { + let (a, b) = (c[i], c[(i + 1) % 4]); + for k in 0..10 { + let t = k as f32 / 10.0; + pts.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]); + } + } + pts.push([x, y]); + pts + } + + #[test] + fn the_lasso_selects_the_ink_inside_and_moves_recolours_and_deletes_it() { + let mut s = session(); + s.run("draw.stroke", &json!({"page": 0, "points": [[100, 100], [140, 110]]})).unwrap(); + s.run("draw.stroke", &json!({"page": 0, "points": [[300, 300], [340, 310]]})).unwrap(); + // Without points it picks up the lasso; a loop around the first stroke selects only it. + s.run("draw.lasso", &json!({})).unwrap(); + assert_eq!(s.view.draw.mode, super::DrawMode::Lasso); + let r = s.run("draw.lasso", &json!({"page": 0, "points": [[80, 80], [200, 80], [200, 150], [80, 150]]})).unwrap(); + assert_eq!(r["count"], 1); + let before = s.layout().object(&super::lasso_selection(&s).unwrap().items[0], 0).unwrap().rect; + // Moved, recoloured: each one undo step, and the selection stays. + let steps = s.undo_depth(); + s.run("draw.lassoMove", &json!({"dx": 20, "dy": -10})).unwrap(); + s.run("draw.lassoColor", &json!({"color": "0070C0"})).unwrap(); + assert_eq!(s.undo_depth(), steps + 2); + let sel = super::lasso_selection(&s).unwrap().items[0].clone(); + let after = s.layout().object(&sel, 0).unwrap().rect; + assert!((after.x - before.x - 20.0).abs() < 0.01 && (after.y - before.y + 10.0).abs() < 0.01, "{before:?} -> {after:?}"); + let objs = ink(&s); + let blue = objs.iter().filter(|o| matches!(o, InlineObject::Shape { stroke: Some(c), .. } if c.hex() == "0070C0")).count(); + assert_eq!(blue, 1); + // Deleted: only the selected stroke goes, and the selection with it. + s.run("draw.lassoDelete", &json!({})).unwrap(); + assert_eq!(ink(&s).len(), 1); + assert!(super::lasso_selection(&s).is_none()); + assert!(s.run("draw.lassoMove", &json!({"dx": 1, "dy": 1})).is_err(), "nothing selected"); + // Hostile loops are refused or select nothing; they never panic. + assert!(s.run("draw.lasso", &json!({"page": 0, "points": [[1, 1], [2, 2]]})).is_err()); + let huge: Vec<[f64; 2]> = (0..20_000).map(|i| [(i % 600) as f64, (i / 40) as f64]).collect(); + let _ = s.run("draw.lasso", &json!({"page": 0, "points": huge})); + } + + #[test] + fn ink_to_shape_turns_shape_like_strokes_into_shapes_and_leaves_scribbles() { + let mut s = session(); + assert_eq!(s.run("draw.inkToShape", &json!({})).unwrap()["value"], true); + let steps = s.undo_depth(); + let r = s.run("draw.stroke", &json!({"page": 0, "points": rect_stroke(100.0, 100.0, 120.0, 60.0), "color": "C00000", "width": 2})).unwrap(); + assert_eq!(r["shape"], "rectangle"); + assert_eq!(s.undo_depth(), steps + 1, "drawn and converted in one undo step"); + let at: wordcraft_doc::Pos = serde_json::from_value(r["pos"].clone()).unwrap(); + let obj = s.doc.para_at(&at).and_then(|p| p.object_at(at.off)).cloned().unwrap(); + let InlineObject::Shape { kind, w, h, stroke, stroke_width, freeform, .. } = obj else { panic!("{obj:?}") }; + assert_eq!((kind, stroke, stroke_width, freeform.is_none()), (ShapeKind::Rectangle, Some(wordcraft_doc::Rgb(0xC0, 0, 0)), 2.0, true)); + assert!((w - 120.0).abs() < 2.0 && (h - 60.0).abs() < 2.0, "{w} x {h}"); + let rect = s.layout().object(&at, 0).unwrap().rect; + assert!((rect.x - 100.0).abs() < 2.0 && (rect.y - 100.0).abs() < 2.0, "where it was drawn: {rect:?}"); + // A scribble stays ink. + let r = s.run("draw.stroke", &json!({"page": 0, "points": [[300, 300], [320, 340], [340, 300], [360, 340], [380, 300]]})).unwrap(); + assert!(r["shape"].is_null()); + assert_eq!(ink(&s).len(), 1); + // Off again; a drawn ellipse stays ink until the lasso selects it and Ink to Shape converts it. + s.run("draw.inkToShape", &json!({"value": false})).unwrap(); + let ring: Vec<[f32; 2]> = + (0..=48).map(|i| i as f32 / 48.0 * std::f32::consts::TAU).map(|a| [200.0 + 50.0 * a.cos(), 500.0 + 30.0 * a.sin()]).collect(); + s.run("draw.stroke", &json!({"page": 0, "points": ring})).unwrap(); + assert_eq!(ink(&s).len(), 2); + s.run("draw.lasso", &json!({"page": 0, "points": [[130, 450], [270, 450], [270, 550], [130, 550]]})).unwrap(); + let r = s.run("draw.inkToShape", &json!({})).unwrap(); + assert_eq!(r["converted"][0]["shape"], "ellipse"); + assert_eq!(ink(&s).len(), 1); + assert!(!s.view.draw.ink_to_shape, "converting the selection doesn't turn the mode on"); + } + + #[test] + fn ink_replay_plays_strokes_in_the_order_they_were_drawn() { + let mut s = session(); + // Drawn low on the page first, then higher: document order would be the other way round. + s.run("draw.stroke", &json!({"page": 0, "points": [[100, 400], [150, 400]]})).unwrap(); + s.run("draw.stroke", &json!({"page": 0, "points": [[100, 90], [150, 90]]})).unwrap(); + let r = s.run("draw.replay", &json!({})).unwrap(); + assert!(s.view.draw.replay); + let orders: Vec = r["strokes"].as_array().unwrap().iter().map(|k| k["order"].as_u64().unwrap()).collect(); + assert_eq!(orders, vec![1, 2]); + let ys: Vec = super::strokes_in_drawing_order(&mut s).iter().map(|(_, k)| k.rect.y).collect(); + assert!(ys[0] > ys[1], "the lower stroke was drawn first: {ys:?}"); + assert_eq!(s.run("draw.replay", &json!({})).unwrap()["replaying"], false); + // Nothing to replay without ink. + assert!(session().run("draw.replay", &json!({})).is_err()); + } + + #[test] + fn add_pen_validates_and_saved_galleries_drop_junk() { + let mut s = session(); + let r = s.run("draw.addPen", &json!({"kind": "highlighter", "color": "00B050", "width": 1e9})).unwrap(); + assert_eq!(r["pen"], json!({"kind": "highlighter", "color": "00B050", "width": super::MAX_WIDTH})); + assert_eq!(s.view.draw.mode, super::DrawMode::Pen(InkTool::Highlighter), "the new pen is picked up"); + for bad in [json!({"kind": "crayon"}), json!({"color": "blue"}), json!(5), json!(null)] { + assert!(s.run("draw.addPen", &bad).is_err(), "{bad}"); + } + for _ in 0..super::MAX_PENS + 3 { + let _ = s.run("draw.addPen", &json!({})); + } + assert_eq!(s.view.draw.pens.len(), super::MAX_PENS); + s.run("draw.removePen", &json!({"index": 0})).unwrap(); + assert!(s.run("draw.removePen", &json!({"index": 999})).is_err()); + // A saved gallery keeps its pens and drops the junk around them. + let saved = + json!({"pens": [{"kind": "pencil", "color": "7030A0", "width": 3}, "junk", {"kind": "crayon"}, {"width": "x"}], "inkToShape": true}); + let d: super::DrawState = serde_json::from_value(saved).unwrap(); + assert_eq!(d.pens.len(), 2); + assert_eq!((d.pens[0].kind, d.pens[0].color.hex(), d.pens[0].width), (InkTool::Pencil, "7030A0".to_string(), 3.0)); + assert!(d.ink_to_shape); + let back: super::DrawState = serde_json::from_value(serde_json::to_value(&d).unwrap()).unwrap(); + assert_eq!(back.pens, d.pens); + } } diff --git a/crates/engine/src/cmd/ink_shape.rs b/crates/engine/src/cmd/ink_shape.rs new file mode 100644 index 0000000000..b7b3aa3a67 --- /dev/null +++ b/crates/engine/src/cmd/ink_shape.rs @@ -0,0 +1,325 @@ +//! Draw › Ink to Shape: a small geometric recogniser that tells whether a hand-drawn stroke is a +//! straight line, a rectangle (or square), an ellipse (or circle), a triangle or another polygon +//! of up to eight corners. Our own fit: an open stroke is a line when it stays close to the chord +//! between its ends; a closed one is an ellipse when its points sit on the ellipse inscribed in +//! its bounds (low RMS of the normalised radius), else a polygon from its corners (the closed +//! path simplified with Ramer–Douglas–Peucker, near-straight and crowded corners merged) when +//! that polygon stays close to every point. Anything else (scribbles, curves, letters) stays ink. + +use wordcraft_doc::freeform::MAX_POINTS; + +/// A shape recognised in a stroke, in the stroke's own coordinates. +#[derive(Clone, Debug, PartialEq)] +pub enum InkShape { + /// A straight line from `a` to `b`. + Line { a: [f32; 2], b: [f32; 2] }, + /// An upright rectangle: top-left and size (equal sides when drawn nearly square). + Rectangle { x: f32, y: f32, w: f32, h: f32 }, + /// An upright ellipse in this box (a circle when drawn nearly round). + Ellipse { x: f32, y: f32, w: f32, h: f32 }, + /// A closed polygon through its 3–8 corners (a triangle has three). + Polygon(Vec<[f32; 2]>), +} + +impl InkShape { + /// What it is, for messages and command results. + pub fn name(&self) -> &'static str { + match self { + InkShape::Line { .. } => "line", + InkShape::Rectangle { w, h, .. } if w == h => "square", + InkShape::Rectangle { .. } => "rectangle", + InkShape::Ellipse { w, h, .. } if w == h => "circle", + InkShape::Ellipse { .. } => "ellipse", + InkShape::Polygon(c) if c.len() == 3 => "triangle", + InkShape::Polygon(_) => "polygon", + } + } + + /// Its bounds: left, top, width, height. + pub fn bounds(&self) -> [f32; 4] { + match self { + InkShape::Rectangle { x, y, w, h } | InkShape::Ellipse { x, y, w, h } => [*x, *y, *w, *h], + InkShape::Line { a, b } => bbox(&[*a, *b]), + InkShape::Polygon(c) => bbox(c), + } + } +} + +/// Smallest stroke (its longer side, points) that is turned into a shape: a tap or a tick stays ink. +const MIN_SIZE: f32 = 6.0; +/// Most corners a polygon may have. +pub const MAX_CORNERS: usize = 8; + +/// The shape `pts` draws, if it is one. +pub fn recognise(pts: &[[f32; 2]]) -> Option { + // Finite points only, without repeats. + let mut p: Vec<[f32; 2]> = Vec::with_capacity(pts.len().min(MAX_POINTS)); + for q in pts.iter().take(MAX_POINTS).filter(|[x, y]| x.is_finite() && y.is_finite() && x.abs() < 1e6 && y.abs() < 1e6) { + if p.last() != Some(q) { + p.push(*q); + } + } + let (first, last) = (*p.first()?, *p.last()?); + if p.len() < 2 { + return None; + } + let [x0, y0, w, h] = bbox(&p); + let size = w.max(h); + let diag = w.hypot(h); + if size < MIN_SIZE { + return None; + } + let length: f32 = p.windows(2).map(|s| s.first().zip(s.get(1)).map_or(0.0, |(a, b)| dist(*a, *b))).sum(); + let gap = dist(first, last); + // A line: the stroke barely leaves the chord between its ends and doesn't double back. + if gap > 0.85 * length && gap >= MIN_SIZE { + let off = p.iter().map(|q| seg_distance(first, last, *q)).fold(0.0, f32::max); + if off <= (0.06 * gap).max(1.5) { + return Some(InkShape::Line { a: first, b: last }); + } + } + // Other shapes are closed: the stroke ends near where it began and goes around. + if gap > 0.2 * size || length < 2.0 * size { + return None; + } + // Ellipse: the points sit on the ellipse inscribed in their bounds. + let ellipse_rms = ellipse_rms(&p, x0, y0, w, h); + if w > 0.2 * size && h > 0.2 * size && ellipse_rms < 0.06 { + let (x, y, w, h) = snap_square(x0, y0, w, h); + return Some(InkShape::Ellipse { x, y, w, h }); + } + // Polygon: the corners of the closed path, if they describe it. + let corners = corners(&p, diag); + let fits = corners.len() >= 3 && p.iter().all(|q| polygon_distance(&corners, *q) <= 0.08 * diag); + if fits && corners.len() == 4 && upright(&corners) { + let [x, y, w, h] = bbox(&corners); + let (x, y, w, h) = snap_square(x, y, w, h); + return Some(InkShape::Rectangle { x, y, w, h }); + } + if fits && corners.len() <= MAX_CORNERS { + return Some(InkShape::Polygon(corners)); + } + // A wobbly round stroke is still an ellipse. + (w > 0.2 * size && h > 0.2 * size && ellipse_rms < 0.1).then(|| { + let (x, y, w, h) = snap_square(x0, y0, w, h); + InkShape::Ellipse { x, y, w, h } + }) +} + +/// Left, top, width and height of `pts` (zero for none). +fn bbox(pts: &[[f32; 2]]) -> [f32; 4] { + let Some(f) = pts.first() else { return [0.0; 4] }; + let (mut x0, mut y0, mut x1, mut y1) = (f[0], f[1], f[0], f[1]); + for [x, y] in pts { + (x0, y0, x1, y1) = (x0.min(*x), y0.min(*y), x1.max(*x), y1.max(*y)); + } + [x0, y0, x1 - x0, y1 - y0] +} + +fn dist(a: [f32; 2], b: [f32; 2]) -> f32 { + (b[0] - a[0]).hypot(b[1] - a[1]) +} + +/// Distance from `p` to the segment `a`–`b`. +fn seg_distance(a: [f32; 2], b: [f32; 2], p: [f32; 2]) -> f32 { + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + let len2 = dx * dx + dy * dy; + let t = if len2 > 0.0 { (((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2).clamp(0.0, 1.0) } else { 0.0 }; + dist([a[0] + t * dx, a[1] + t * dy], p) +} + +/// Distance from `p` to the closed polygon through `c`. +fn polygon_distance(c: &[[f32; 2]], p: [f32; 2]) -> f32 { + let n = c.len(); + (0..n).filter_map(|i| Some(seg_distance(*c.get(i)?, *c.get((i + 1) % n)?, p))).fold(f32::INFINITY, f32::min) +} + +/// How far the points stray from the ellipse inscribed in the `w` × `h` box at (`x0`, `y0`): the +/// RMS of each point's normalised radius minus one (0 on the ellipse). +fn ellipse_rms(p: &[[f32; 2]], x0: f32, y0: f32, w: f32, h: f32) -> f32 { + let (rx, ry) = (w / 2.0, h / 2.0); + if rx <= 0.0 || ry <= 0.0 || p.is_empty() { + return f32::INFINITY; + } + let (cx, cy) = (x0 + rx, y0 + ry); + let sum: f32 = p.iter().map(|[x, y]| (((x - cx) / rx).hypot((y - cy) / ry) - 1.0).powi(2)).sum(); + (sum / p.len() as f32).sqrt() +} + +/// A box drawn nearly square made square (same centre). +fn snap_square(x: f32, y: f32, w: f32, h: f32) -> (f32, f32, f32, f32) { + if (w - h).abs() <= 0.12 * w.max(h) { + let s = (w + h) / 2.0; + (x + (w - s) / 2.0, y + (h - s) / 2.0, s, s) + } else { + (x, y, w, h) + } +} + +/// The corners of the closed path through `p`: simplified with Ramer–Douglas–Peucker (split +/// at the point farthest from the start), then corners that barely turn or crowd a neighbour +/// merged away. +fn corners(p: &[[f32; 2]], diag: f32) -> Vec<[f32; 2]> { + let eps = 0.05 * diag; + let Some(&start) = p.first() else { return Vec::new() }; + let far = p.iter().enumerate().fold((0, 0.0), |best, (i, q)| if dist(start, *q) > best.1 { (i, dist(start, *q)) } else { best }).0; + let (a, b) = p.split_at(far.min(p.len())); + let mut ring: Vec<[f32; 2]> = rdp(&[a, b.get(..1).unwrap_or(&[])].concat(), eps); + let mut back = rdp(&[b, &[start]].concat(), eps); + // The halves share the far point and the start. + ring.pop(); + back.pop(); + ring.extend(back); + // Merge until every corner turns sharply and none sits on top of the next. + while ring.len() > 3 { + let n = ring.len(); + let turn = |i: usize| -> f32 { + let (Some(a), Some(b), Some(c)) = (ring.get((i + n - 1) % n), ring.get(i), ring.get((i + 1) % n)) else { return 0.0 }; + turning(*a, *b, *c) + }; + let (i, t) = (0..n).map(|i| (i, turn(i))).fold((0, f32::INFINITY), |m, x| if x.1 < m.1 { x } else { m }); + let short = (0..n).find(|&j| ring.get(j).zip(ring.get((j + 1) % n)).is_some_and(|(a, b)| dist(*a, *b) < 0.1 * diag)); + if t < 30.0 { + ring.remove(i); + } else if let Some(j) = short { + // Of the two crowded corners keep the sharper. + let k = if turn(j) < turn((j + 1) % n) { j } else { (j + 1) % n }; + ring.remove(k); + } else { + break; + } + } + ring +} + +/// How far the path turns at `b` coming from `a` and going on to `c`, degrees (0 = straight on). +fn turning(a: [f32; 2], b: [f32; 2], c: [f32; 2]) -> f32 { + let (u, v) = ([b[0] - a[0], b[1] - a[1]], [c[0] - b[0], c[1] - b[1]]); + let (lu, lv) = (u[0].hypot(u[1]), v[0].hypot(v[1])); + if lu <= 0.0 || lv <= 0.0 { + return 0.0; + } + ((u[0] * v[0] + u[1] * v[1]) / (lu * lv)).clamp(-1.0, 1.0).acos().to_degrees() +} + +/// Ramer–Douglas–Peucker simplification of an open path (iterative: no deep recursion on long +/// strokes); keeps both ends. +fn rdp(p: &[[f32; 2]], eps: f32) -> Vec<[f32; 2]> { + let n = p.len(); + if n < 3 { + return p.to_vec(); + } + let mut keep = vec![false; n]; + if let Some(f) = keep.first_mut() { + *f = true; + } + if let Some(l) = keep.last_mut() { + *l = true; + } + let mut stack = vec![(0usize, n - 1)]; + while let Some((a, b)) = stack.pop() { + let (Some(&pa), Some(&pb)) = (p.get(a), p.get(b)) else { continue }; + let far = (a + 1..b).filter_map(|i| Some((i, seg_distance(pa, pb, *p.get(i)?)))).fold((a, 0.0), |m, x| if x.1 > m.1 { x } else { m }); + if far.1 > eps + && let Some(k) = keep.get_mut(far.0) + { + *k = true; + stack.push((a, far.0)); + stack.push((far.0, b)); + } + } + p.iter().zip(keep).filter(|(_, k)| *k).map(|(q, _)| *q).collect() +} + +/// A quadrilateral whose sides run (nearly) across and down the page, alternately. +fn upright(c: &[[f32; 2]]) -> bool { + let n = c.len(); + let dirs: Vec> = (0..n) + .map(|i| { + let (a, b) = (c.get(i)?, c.get((i + 1) % n)?); + let deg = (b[1] - a[1]).atan2(b[0] - a[0]).to_degrees().rem_euclid(180.0); + // true: across, false: down; None when it's slanted. + if deg <= 15.0 || deg >= 165.0 { + Some(true) + } else if (75.0..=105.0).contains(°) { + Some(false) + } else { + None + } + }) + .collect(); + dirs.iter().all(Option::is_some) && (0..n).all(|i| dirs.get(i) != dirs.get((i + 1) % n)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Points along a closed polygon, `per` to a side, with a little deterministic hand wobble. + fn around(corners: &[[f32; 2]], per: usize) -> Vec<[f32; 2]> { + let mut out = Vec::new(); + for (i, a) in corners.iter().enumerate() { + let b = corners[(i + 1) % corners.len()]; + for k in 0..per { + let t = k as f32 / per as f32; + let wob = (out.len() as f32 * 1.7).sin() * 0.6; + out.push([a[0] + (b[0] - a[0]) * t + wob, a[1] + (b[1] - a[1]) * t - wob]); + } + } + out.push(corners[0]); + out + } + + #[test] + fn rectangles_ellipses_lines_and_triangles_are_recognised() { + // A rectangle, started mid-side as people often do. + let rect = around(&[[50.0, 10.0], [110.0, 10.0], [110.0, 70.0 + 0.0], [10.0, 70.0], [10.0, 10.0]], 12); + let Some(InkShape::Rectangle { x, y, w, h }) = recognise(&rect) else { panic!("{:?}", recognise(&rect)) }; + assert!((x - 10.0).abs() < 2.0 && (y - 10.0).abs() < 2.0 && (w - 100.0).abs() < 3.0 && (h - 60.0).abs() < 3.0, "{x} {y} {w} {h}"); + // Nearly square: a square. + assert_eq!(recognise(&around(&[[0.0, 0.0], [60.0, 0.0], [60.0, 57.0], [0.0, 57.0]], 10)).map(|s| s.name()), Some("square")); + // An ellipse and a circle. + let ring = |rx: f32, ry: f32| { + (0..=64).map(|i| (i as f32 / 64.0) * std::f32::consts::TAU).map(|a| [100.0 + rx * a.cos(), 80.0 + ry * a.sin()]).collect::>() + }; + let Some(InkShape::Ellipse { x, w, h, .. }) = recognise(&ring(40.0, 25.0)) else { panic!() }; + assert!((x - 60.0).abs() < 0.5 && (w - 80.0).abs() < 0.5 && (h - 50.0).abs() < 0.5); + assert_eq!(recognise(&ring(30.0, 29.0)).map(|s| s.name()), Some("circle")); + // A slightly shaky line keeps its ends. + let line: Vec<[f32; 2]> = (0..=40).map(|i| i as f32).map(|t| [10.0 + t * 3.0, 20.0 + t + (t * 2.1).sin() * 0.8]).collect(); + assert_eq!(recognise(&line), Some(InkShape::Line { a: line[0], b: line[40] })); + // A triangle and a pentagon. + let tri = around(&[[0.0, 80.0], [45.0, 0.0], [90.0, 80.0]], 15); + let Some(InkShape::Polygon(c)) = recognise(&tri) else { panic!("{:?}", recognise(&tri)) }; + assert_eq!(c.len(), 3); + let penta: Vec<[f32; 2]> = (0..5) + .map(|i| (i as f32 / 5.0) * std::f32::consts::TAU - std::f32::consts::FRAC_PI_2) + .map(|a| [60.0 + 50.0 * a.cos(), 60.0 + 50.0 * a.sin()]) + .collect(); + assert_eq!(recognise(&around(&penta, 12)).map(|s| s.name()), Some("polygon")); + } + + #[test] + fn scribbles_ticks_and_hostile_points_stay_ink() { + // An open zigzag, a hook, a tap and a tiny tick. + let zigzag: Vec<[f32; 2]> = (0..20).map(|i| [i as f32 * 8.0, if i % 2 == 0 { 0.0 } else { 30.0 }]).collect(); + assert_eq!(recognise(&zigzag), None); + assert_eq!(recognise(&[[0.0, 0.0], [50.0, 0.0], [50.0, 50.0]]), None); + assert_eq!(recognise(&[[5.0, 5.0]]), None); + assert_eq!(recognise(&[[0.0, 0.0], [2.0, 3.0]]), None); + // A closed scribble with many spikes: not a polygon of eight or fewer corners, not round. + let star: Vec<[f32; 2]> = (0..=24) + .map(|i| { + let a = i as f32 / 24.0 * std::f32::consts::TAU; + let r = if i % 2 == 0 { 60.0 } else { 20.0 }; + [80.0 + r * a.cos(), 80.0 + r * a.sin()] + }) + .collect(); + assert_eq!(recognise(&star), None); + // NaN, infinities and a flood of points never panic. + let mut junk = vec![[f32::NAN, 1.0], [f32::INFINITY, 2.0], [3.0, f32::NEG_INFINITY]]; + junk.extend(std::iter::repeat_n([1.0, 1.0], MAX_POINTS * 3)); + assert_eq!(recognise(&junk), None); + assert_eq!(recognise(&[]), None); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 758787d10c..fc77351276 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -12,6 +12,7 @@ pub mod fields; pub mod file; pub mod fmt_revisions; pub mod format; +pub mod ink_shape; pub mod insert; pub mod inspector; pub mod lists; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 2a1c288cf1..71ec3da4e6 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -50,6 +50,8 @@ pub struct CanvasState { pub(crate) ink: Option, /// The eraser is down: whether it has erased anything in this drag yet. pub(crate) erasing: Option, + /// The lasso being drawn or dragged, and Ink Replay (Draw tab). + pub(crate) ink_ui: crate::ink::InkUi, /// Wheel/touchpad scrolling (smooth notches, touchpad momentum). pub(crate) wheel: crate::scroll::CanvasScroll, /// The scroll offset and its maximum at the end of last frame. @@ -111,6 +113,7 @@ impl Default for CanvasState { obj_drag: None, ink: None, erasing: None, + ink_ui: Default::default(), context_menu_open: false, mini_anchor: None, pasted: false, @@ -266,7 +269,7 @@ fn page_key(app: &WordApp, page: &Page, scale_px: f32, dim_body: bool) -> u64 { let mut h = std::collections::hash_map::DefaultHasher::new(); scale_px.to_bits().hash(&mut h); let v = &app.session.view; - (v.marks, v.show_markup, v.dark_mode, wordcraft_render::DARK_PAPER, v.hide_ink).hash(&mut h); + (v.marks, v.show_markup, v.dark_mode, wordcraft_render::DARK_PAPER, v.hide_ink, v.draw.replay).hash(&mut h); app.session.prefs.markup.hash(&mut h); dim_body.hash(&mut h); format!("{:?}{:?}", app.session.doc.settings.page_color, app.session.doc.settings.watermark).hash(&mut h); @@ -350,6 +353,7 @@ fn drawing_grid_step(spacing: f32, scale: f32) -> f32 { pub fn show(app: &mut WordApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); + crate::ink::replay_tick(app, ui.ctx()); let layout = app.session.layout(); // View › Switch Modes: dark paper, inverted document colours, a white caret. let dark_page = app.session.view.dark_mode; @@ -460,7 +464,8 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { opts.display.marks = app.session.view.marks; opts.display.placeholders = true; opts.display.markup = app.session.view.show_markup; - opts.display.hide_ink = app.session.view.hide_ink; + // Ink Replay draws the ink itself, stroke by stroke, over pages without it. + opts.display.hide_ink = app.session.view.hide_ink || app.session.view.draw.replay; opts.display.revisions = app.session.prefs.markup.clone(); opts.dark = dark_page; opts.dark_paper = dark_paper; @@ -675,6 +680,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { (resp, rects) }); app.canvas.scroll_offset = out.state.offset; + crate::ink::replay_bar(app, ui, out.inner_rect); app.canvas.scroll_max = (out.content_size - out.inner_rect.size()).max(egui::Vec2::ZERO); let (resp, rects) = out.inner; // A click on the page leaves the selected comment balloon. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 53daa14ef7..66ecb82e7b 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1469,3 +1469,16 @@ Continuous numbering Numeración continua Apply Aplicar Convert Notes Convertir notas + Lasso Select Selección con lazo + Move Selected Ink Mover la tinta seleccionada + Delete Selected Ink Eliminar la tinta seleccionada + Recolor Selected Ink Cambiar el color de la tinta seleccionada + Add Pen Agregar pluma + Remove Pen Quitar pluma + Ink to Shape Tinta a forma + Convert to Shapes Convertir en formas + Ink Replay Reproducir tinta + Add\nPen Agregar\npluma + Rewind Rebobinar + Forward Avanzar + Stroke {n} of {total} Trazo {n} de {total} diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 75fa642100..da627a4184 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -1463,3 +1463,16 @@ Continuous numbering Pidev nummerdus Apply Rakenda Convert Notes Teisenda märkused + Lasso Select Lassoga valimine + Move Selected Ink Teisalda valitud tint + Delete Selected Ink Kustuta valitud tint + Recolor Selected Ink Muuda valitud tindi värvi + Add Pen Lisa pliiats + Remove Pen Eemalda pliiats + Ink to Shape Käsikiri kujundiks + Convert to Shapes Teisenda kujunditeks + Ink Replay Esita käsikiri + Add\nPen Lisa\npliiats + Rewind Keri algusesse + Forward Edasi + Stroke {n} of {total} Joon {n}/{total} diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index f598e09a83..5b7be257d3 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1453,3 +1453,16 @@ Continuous numbering 通し番号 Apply 適用 Convert Notes 注の変換 + Lasso Select なげなわ選択 + Move Selected Ink 選択したインクを移動 + Delete Selected Ink 選択したインクを削除 + Recolor Selected Ink 選択したインクの色を変更 + Add Pen ペンの追加 + Remove Pen ペンの削除 + Ink to Shape インクを図形に変換 + Convert to Shapes 図形に変換 + Ink Replay インクの再生 + Add\nPen ペンの\n追加 + Rewind 巻き戻し + Forward 早送り + Stroke {n} of {total} ストローク {n}/{total} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index df8b9b875d..e4fbc884c3 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1465,3 +1465,16 @@ Continuous numbering Numeração contínua Apply Aplicar Convert Notes Converter notas + Lasso Select Seleção com laço + Move Selected Ink Mover a tinta selecionada + Delete Selected Ink Excluir a tinta selecionada + Recolor Selected Ink Mudar a cor da tinta selecionada + Add Pen Adicionar caneta + Remove Pen Remover caneta + Ink to Shape Tinta para forma + Convert to Shapes Converter em formas + Ink Replay Reproduzir tinta + Add\nPen Adicionar\ncaneta + Rewind Retroceder + Forward Avançar + Stroke {n} of {total} Traço {n} de {total} diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 786a925759..3d430c69e6 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1803,6 +1803,19 @@ preview Title Пример Right-to-Left Section Раздел справа налево Table direction: Направление таблицы: Section direction: Направление раздела: + Lasso Select Лассо + Move Selected Ink Переместить выделенные штрихи + Delete Selected Ink Удалить выделенные штрихи + Recolor Selected Ink Перекрасить выделенные штрихи + Add Pen Добавить перо + Remove Pen Удалить перо + Ink to Shape Рукописный ввод в фигуры + Convert to Shapes Преобразовать в фигуры + Ink Replay Воспроизведение рукописного ввода + Add\nPen Добавить\nперо + Rewind Назад + Forward Вперёд + Stroke {n} of {total} Штрих {n} из {total} Drawing Canvas Полотно для рисования New Drawing Canvas Новое полотно для рисования Fit По размеру diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index d00d9cc2d1..ef47e7105f 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -1463,3 +1463,16 @@ Continuous numbering Neprekidna numeracija Apply Primeni Convert Notes Pretvori napomene + Lasso Select Izbor lasom + Move Selected Ink Premesti izabrani rukopis + Delete Selected Ink Obriši izabrani rukopis + Recolor Selected Ink Promeni boju izabranog rukopisa + Add Pen Dodaj pero + Remove Pen Ukloni pero + Ink to Shape Rukopis u oblik + Convert to Shapes Pretvori u oblike + Ink Replay Rukopis repriza + Add\nPen Dodaj\npero + Rewind Na početak + Forward Napred + Stroke {n} of {total} Potez {n} od {total} diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 4541e8f7d9..84270af129 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -1467,3 +1467,16 @@ Continuous numbering Непрекидна нумерација Apply Примени Convert Notes Претвори напомене + Lasso Select Избор ласом + Move Selected Ink Премести изабрани рукопис + Delete Selected Ink Обриши изабрани рукопис + Recolor Selected Ink Промени боју изабраног рукописа + Add Pen Додај перо + Remove Pen Уклони перо + Ink to Shape Рукопис у облик + Convert to Shapes Претвори у облике + Ink Replay Рукопис реприза + Add\nPen Додај\nперо + Rewind На почетак + Forward Напред + Stroke {n} of {total} Потез {n} од {total} diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index e62c0c6e8d..4f80145d97 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1421,6 +1421,19 @@ Right-to-Left Section Sağdan Sola Bölüm Table direction: Tablo yönü: Section direction: Bölüm yönü: + Lasso Select Kement Seçimi + Move Selected Ink Seçili Mürekkebi Taşı + Delete Selected Ink Seçili Mürekkebi Sil + Recolor Selected Ink Seçili Mürekkebi Yeniden Renklendir + Add Pen Kalem Ekle + Remove Pen Kalemi Kaldır + Ink to Shape Mürekkepten Şekle + Convert to Shapes Şekillere Dönüştür + Ink Replay Mürekkep Yeniden Oynatma + Add\nPen Kalem\nEkle + Rewind Geri Sar + Forward İleri + Stroke {n} of {total} Vuruş {n} / {total} Drawing Canvas Çizim Tuvali New Drawing Canvas Yeni Çizim Tuvali Fit Sığdır diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 6c9244dadd..909d87a305 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1465,3 +1465,16 @@ Continuous numbering Наскрізна нумерація Apply Застосувати Convert Notes Перетворити примітки + Lasso Select Виділення ласо + Move Selected Ink Перемістити виділений рукопис + Delete Selected Ink Видалити виділений рукопис + Recolor Selected Ink Змінити колір виділеного рукопису + Add Pen Додати перо + Remove Pen Вилучити перо + Ink to Shape Рукопис у фігуру + Convert to Shapes Перетворити на фігури + Ink Replay Відтворити рукопис + Add\nPen Додати\nперо + Rewind На початок + Forward Вперед + Stroke {n} of {total} Штрих {n} з {total} diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 6141f47a2b..2465337d77 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1465,3 +1465,16 @@ Continuous numbering 连续编号 Apply 应用 Convert Notes 转换注释 + Lasso Select 套索选择 + Move Selected Ink 移动所选墨迹 + Delete Selected Ink 删除所选墨迹 + Recolor Selected Ink 更改所选墨迹颜色 + Add Pen 添加笔 + Remove Pen 删除笔 + Ink to Shape 墨迹转形状 + Convert to Shapes 转换为形状 + Ink Replay 墨迹重播 + Add\nPen 添加笔 + Rewind 倒回 + Forward 前进 + Stroke {n} of {total} 第 {n} 笔,共 {total} 笔 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 176b8c2d66..0f564e3d9c 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1454,3 +1454,16 @@ Continuous numbering 連續編號 Apply 套用 Convert Notes 轉換附註 + Lasso Select 套索選取 + Move Selected Ink 移動選取的筆跡 + Delete Selected Ink 刪除選取的筆跡 + Recolor Selected Ink 變更選取筆跡的色彩 + Add Pen 新增畫筆 + Remove Pen 移除畫筆 + Ink to Shape 筆跡轉圖形 + Convert to Shapes 轉換為圖案 + Ink Replay 筆跡重播 + Add\nPen 新增畫筆 + Rewind 倒轉 + Forward 快轉 + Stroke {n} of {total} 第 {n} 筆,共 {total} 筆 diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index c8fa350dc7..8bbeca1172 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -498,6 +498,27 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32, accent: Color32) { pen.circle(10.0, 10.0, 6.5, c); pen.fill(&[(8.5, 7.0), (8.5, 13.0), (13.0, 10.0)], a); } + // Add Pen: a pen with a plus. + "addPen" => { + pen.line(&[(3.0, 17.0), (11.0, 9.0), (13.0, 11.0), (5.0, 19.0), (2.5, 19.5), (3.0, 17.0)]); + pen.fill(&[(11.0, 9.0), (12.5, 7.5), (14.5, 9.5), (13.0, 11.0)], a); + pen.line_c(&[(15.0, 2.0), (15.0, 8.0)], a); + pen.line_c(&[(12.0, 5.0), (18.0, 5.0)], a); + } + // Ink Replay's controls. + "replayRewind" => { + pen.frect(4.0, 5.0, 5.5, 15.0, c); + pen.fill(&[(16.0, 5.0), (16.0, 15.0), (6.5, 10.0)], c); + } + "replayPlay" => pen.fill(&[(6.0, 4.0), (6.0, 16.0), (16.0, 10.0)], a), + "replayPause" => { + pen.frect(5.5, 4.5, 8.5, 15.5, a); + pen.frect(11.5, 4.5, 14.5, 15.5, a); + } + "replayForward" => { + pen.fill(&[(4.0, 5.0), (4.0, 15.0), (13.5, 10.0)], c); + pen.frect(14.5, 5.0, 16.0, 15.0, c); + } // Design "themes" => { pen.frect(3.0, 3.0, 10.0, 10.0, a); diff --git a/crates/ui-egui/src/ink.rs b/crates/ui-egui/src/ink.rs index d3ca7c6ef0..a1b2273829 100644 --- a/crates/ui-egui/src/ink.rs +++ b/crates/ui-egui/src/ink.rs @@ -1,9 +1,14 @@ -//! Drawing on the page with the Draw tab's pens and eraser. While a pen is in use, a drag draws a -//! live stroke and its release commits it with `draw.stroke` (one command, one undo step); with -//! the eraser, a click or drag over a stroke deletes it with `draw.erase` (one undo step per -//! drag). Select (or Esc) goes back to ordinary selection. +//! Drawing on the page with the Draw tab's pens, eraser and lasso, and Ink Replay. While a pen +//! is in use, a drag draws a live stroke and its release commits it with `draw.stroke` (one +//! command, one undo step); with the eraser, a click or drag over a stroke deletes it with +//! `draw.erase` (one undo step per drag). With the lasso, a drag draws a loop whose release +//! selects the ink inside (`draw.lasso`); dragging the selection moves it (`draw.lassoMove`), +//! Delete deletes it and Esc lets it go. Select (or Esc) goes back to ordinary selection. +//! +//! Ink Replay (`draw.replay`) hides the ink on the pages and draws the strokes again here, one +//! after another in the order they were drawn, with a bar of controls at the bottom. -use egui::{Color32, Painter, Pos2, Rect, Stroke, Ui}; +use egui::{Align2, Color32, Key, Painter, Pos2, Rect, Stroke, Ui, pos2, vec2}; use serde_json::json; use wordcraft_doc::freeform::{InkTool, MAX_POINTS}; use wordcraft_engine::cmd::draw::DrawMode; @@ -19,13 +24,55 @@ pub struct InkDrag { pts: Vec<[f32; 2]>, } +/// The lasso in use: a loop being drawn, or the selection being dragged (page points). +pub enum LassoDrag { + Loop { page: usize, pts: Vec<[f32; 2]> }, + Move { page: usize, from: [f32; 2], to: [f32; 2] }, +} + +/// Ink Replay in progress: the strokes in drawing order and how far it has got. +pub struct Replay { + strokes: Vec, + /// Seconds into the replay. + t: f32, + playing: bool, +} + +struct ReplayStroke { + page: usize, + paths: Vec>, + color: Color32, + width: f32, + /// When it starts being drawn and how long it takes, seconds. + start: f32, + dur: f32, +} + +/// The Draw tab's pointer state beyond the pens (kept on the canvas). +#[derive(Default)] +pub struct InkUi { + pub(crate) lasso: Option, + pub(crate) replay: Option, +} + /// Smallest move that adds a point to a live stroke, screen points. const MIN_STEP_PX: f32 = 1.5; +/// How fast replayed ink is drawn, page points per second, and the pause between strokes. +const REPLAY_SPEED: f32 = 400.0; +const REPLAY_GAP: f32 = 0.12; +/// How close to the lasso selection's frame a press still grabs it, page points. +const GRAB_SLOP: f32 = 4.0; -/// Handle the pointer while a pen or the eraser is in use; `true` when it did (no text -/// selection or object dragging then). +/// Handle the pointer while a pen, the eraser or the lasso is in use; `true` when it did (no +/// text selection or object dragging then). pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layout: &DocLayout, scale: f32) -> bool { let mode = app.session.view.draw.mode; + if mode != DrawMode::Lasso { + app.canvas.ink_ui.lasso = None; + if app.session.view.draw.lasso.is_some() { + let _ = app.run("draw.lasso", json!({"clear": true})); + } + } if mode == DrawMode::Select { app.canvas.ink = None; app.canvas.erasing = None; @@ -73,27 +120,112 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect] app.canvas.erasing = None; } } + DrawMode::Lasso => lasso_pointer(app, (starts, down, released, at), rects, layout, scale), DrawMode::Select => {} } true } +fn lasso_pointer(app: &mut WordApp, (starts, down, released, at): (bool, bool, bool, Option), rects: &[Rect], layout: &DocLayout, scale: f32) { + if starts && let Some((page, x, y)) = at.and_then(|p| page_at(rects, layout, scale, p)) { + // A press on the selection drags it; anywhere else starts a new loop. + let grab = wordcraft_engine::cmd::draw::lasso_bounds(&app.session, layout).is_some_and(|(bp, r)| { + bp == page && x >= r.x - GRAB_SLOP && x <= r.right() + GRAB_SLOP && y >= r.y - GRAB_SLOP && y <= r.bottom() + GRAB_SLOP + }); + app.canvas.ink_ui.lasso = + Some(if grab { LassoDrag::Move { page, from: [x, y], to: [x, y] } } else { LassoDrag::Loop { page, pts: vec![[x, y]] } }); + } else if down + && let Some(p) = at + && let Some(d) = app.canvas.ink_ui.lasso.as_mut() + { + match d { + LassoDrag::Loop { page, pts } => { + if let Some((x, y)) = on_page(rects, layout, scale, *page, p) { + let far = pts.last().is_none_or(|[lx, ly]| ((x - lx).powi(2) + (y - ly).powi(2)).sqrt() * scale >= MIN_STEP_PX * 2.0); + if far && pts.len() < MAX_POINTS { + pts.push([x, y]); + } + } + } + LassoDrag::Move { page, to, .. } => { + if let Some((x, y)) = on_page(rects, layout, scale, *page, p) { + *to = [x, y]; + } + } + } + } + if released && let Some(d) = app.canvas.ink_ui.lasso.take() { + match d { + LassoDrag::Loop { page, pts } if pts.len() >= 3 => { + let _ = app.run("draw.lasso", json!({"page": page, "points": pts})); + } + // A click without a loop lets the selection go. + LassoDrag::Loop { .. } => { + let _ = app.run("draw.lasso", json!({"clear": true})); + } + LassoDrag::Move { from, to, .. } => { + let (dx, dy) = (to[0] - from[0], to[1] - from[1]); + if dx.abs() + dy.abs() > 0.25 { + let _ = app.run("draw.lassoMove", json!({"dx": dx, "dy": dy})); + } + } + } + } +} + +/// Keys the lasso and Ink Replay take: Delete deletes the lasso selection, Esc lets it go or +/// stops the replay. `true` when the key was used. +pub fn key(app: &mut WordApp, key: Key) -> bool { + if key == Key::Escape && app.session.view.draw.replay { + stop_replay(app); + return true; + } + let selected = wordcraft_engine::cmd::draw::lasso_selection(&app.session).is_some(); + match key { + Key::Delete | Key::Backspace if selected => { + let _ = app.run("draw.lassoDelete", json!({})); + true + } + Key::Escape if selected => { + let _ = app.run("draw.lasso", json!({"clear": true})); + true + } + _ => false, + } +} + /// Page point under screen point `p` on `page` (outside the page too, clamped by the command). fn on_page(rects: &[Rect], layout: &DocLayout, scale: f32, page: usize, p: Pos2) -> Option<(f32, f32)> { let r = rects.get(page)?; - let s = layout.pages.get(page).map_or(scale, |pg| crate::canvas::page_screen_scale(*r, pg, scale)); - (s > 0.0).then(|| ((p.x - r.min.x) / s, (p.y - r.min.y) / s)) + let s = page_scale(rects, layout, scale, page)?; + Some(((p.x - r.min.x) / s, (p.y - r.min.y) / s)) } -/// The live stroke, drawn over the page until it is committed. +/// Screen points per page point on `page`. +fn page_scale(rects: &[Rect], layout: &DocLayout, scale: f32, page: usize) -> Option { + let r = rects.get(page)?; + let s = layout.pages.get(page).map_or(scale, |pg| crate::canvas::page_screen_scale(*r, pg, scale)); + (s > 0.0).then_some(s) +} + +/// Page points to screen points on `page`. +fn to_screen(rects: &[Rect], layout: &DocLayout, scale: f32, page: usize) -> Option Pos2> { + let r = *rects.get(page)?; + let s = page_scale(rects, layout, scale, page)?; + Some(move |[x, y]: [f32; 2]| pos2(r.min.x + x * s, r.min.y + y * s)) +} + +/// The live stroke, the lasso and its selection, and the replayed ink, drawn over the pages. pub fn paint(app: &WordApp, painter: &Painter, rects: &[Rect], layout: &DocLayout, scale: f32) { + paint_replay(app, painter, rects, layout, scale); + paint_lasso(app, painter, rects, layout, scale); let Some(d) = &app.canvas.ink else { return }; - let Some(r) = rects.get(d.page) else { return }; - let s = layout.pages.get(d.page).map_or(scale, |pg| crate::canvas::page_screen_scale(*r, pg, scale)); + let Some(map) = to_screen(rects, layout, scale, d.page) else { return }; + let s = page_scale(rects, layout, scale, d.page).unwrap_or(scale); let set = app.session.view.draw.settings(d.tool); let alpha = (d.tool.alpha() * 255.0).round() as u8; let color = Color32::from_rgba_unmultiplied(set.color.0, set.color.1, set.color.2, alpha); - let pts: Vec = d.pts.iter().map(|[x, y]| egui::pos2(r.min.x + x * s, r.min.y + y * s)).collect(); + let pts: Vec = d.pts.iter().map(|p| map(*p)).collect(); let width = (set.width * s).max(1.0); if let [only] = pts.as_slice() { painter.circle_filled(*only, width / 2.0, color); @@ -102,10 +234,197 @@ pub fn paint(app: &WordApp, painter: &Painter, rects: &[Rect], layout: &DocLayou } } -/// The pointer over the page while a pen or the eraser is in use. +fn paint_lasso(app: &WordApp, painter: &Painter, rects: &[Rect], layout: &DocLayout, scale: f32) { + let t = crate::theme::Tokens::get(painter.ctx()); + let dash = |pts: &[Pos2]| egui::Shape::dashed_line(pts, Stroke::new(1.0, t.accent), 4.0, 3.0); + if let Some(LassoDrag::Loop { page, pts }) = &app.canvas.ink_ui.lasso + && let Some(map) = to_screen(rects, layout, scale, *page) + { + let mut loop_pts: Vec = pts.iter().map(|p| map(*p)).collect(); + if let Some(first) = loop_pts.first().copied() { + loop_pts.push(first); + } + painter.extend(dash(&loop_pts)); + } + let Some((page, r)) = wordcraft_engine::cmd::draw::lasso_bounds(&app.session, layout) else { return }; + let (dx, dy) = match &app.canvas.ink_ui.lasso { + Some(LassoDrag::Move { from, to, .. }) => (to[0] - from[0], to[1] - from[1]), + _ => (0.0, 0.0), + }; + let Some(map) = to_screen(rects, layout, scale, page) else { return }; + let (a, b) = (map([r.x + dx, r.y + dy]), map([r.right() + dx, r.bottom() + dy])); + let sr = Rect::from_min_max(a, b).expand(3.0); + painter.extend(dash(&[sr.left_top(), sr.right_top(), sr.right_bottom(), sr.left_bottom(), sr.left_top()])); +} + +/// The pointer over the page while a pen, the eraser or the lasso is in use. pub fn cursor(app: &WordApp) -> Option { match app.session.view.draw.mode { DrawMode::Select => None, - DrawMode::Eraser | DrawMode::Pen(_) => Some(egui::CursorIcon::Crosshair), + DrawMode::Lasso if matches!(app.canvas.ink_ui.lasso, Some(LassoDrag::Move { .. })) => Some(egui::CursorIcon::Grabbing), + DrawMode::Eraser | DrawMode::Pen(_) | DrawMode::Lasso => Some(egui::CursorIcon::Crosshair), } } + +// --- Ink Replay ---------------------------------------------------------------------------- + +/// Start, advance or end Ink Replay with the session's `draw.replay` switch; call once a frame +/// before the pages are drawn. +pub fn replay_tick(app: &mut WordApp, ctx: &egui::Context) { + if !app.session.view.draw.replay { + app.canvas.ink_ui.replay = None; + return; + } + if app.canvas.ink_ui.replay.is_none() { + let mut start = 0.0; + let strokes: Vec = wordcraft_engine::cmd::draw::strokes_in_drawing_order(&mut app.session) + .into_iter() + .map(|(page, k)| { + let paths = k.paths(); + let len: f32 = paths.iter().map(|p| path_len(p)).sum(); + let dur = (len / REPLAY_SPEED).clamp(0.15, 2.5); + let alpha = (k.freeform.alpha.clamp(0.0, 1.0) * 255.0).round() as u8; + let c = k.color.unwrap_or_default(); + let width = if k.width.is_finite() { k.width.clamp(0.25, 72.0) } else { 1.0 }; + let st = ReplayStroke { page, paths, color: Color32::from_rgba_unmultiplied(c.0, c.1, c.2, alpha), width, start, dur }; + start += dur + REPLAY_GAP; + st + }) + .collect(); + if strokes.is_empty() { + stop_replay(app); + return; + } + app.canvas.ink_ui.replay = Some(Replay { strokes, t: 0.0, playing: true }); + } + let dt = ctx.input(|i| i.stable_dt).clamp(0.0, 0.1); + if let Some(r) = app.canvas.ink_ui.replay.as_mut() + && r.playing + { + r.t = (r.t + dt).min(r.end()); + if r.t >= r.end() { + r.playing = false; + } + ctx.request_repaint(); + } +} + +impl Replay { + fn end(&self) -> f32 { + self.strokes.last().map_or(0.0, |s| s.start + s.dur) + } + /// Strokes started so far. + fn started(&self) -> usize { + self.strokes.iter().filter(|s| s.start <= self.t).count() + } +} + +fn path_len(p: &[[f32; 2]]) -> f32 { + p.windows(2).filter_map(|w| Some((w.first()?, w.get(1)?))).map(|(a, b)| (b[0] - a[0]).hypot(b[1] - a[1])).sum() +} + +/// The replayed ink so far: finished strokes whole, the current one up to where it has got. +fn paint_replay(app: &WordApp, painter: &Painter, rects: &[Rect], layout: &DocLayout, scale: f32) { + let Some(r) = &app.canvas.ink_ui.replay else { return }; + for k in r.strokes.iter().filter(|k| k.start <= r.t) { + let Some(map) = to_screen(rects, layout, scale, k.page) else { continue }; + let s = page_scale(rects, layout, scale, k.page).unwrap_or(scale); + let frac = if k.dur > 0.0 { ((r.t - k.start) / k.dur).clamp(0.0, 1.0) } else { 1.0 }; + let total: f32 = k.paths.iter().map(|p| path_len(p)).sum(); + let mut left = total * frac; + let width = (k.width * s).max(1.0); + for path in &k.paths { + let mut pts: Vec = Vec::new(); + for w in path.windows(2) { + let (Some(a), Some(b)) = (w.first(), w.get(1)) else { continue }; + if pts.is_empty() { + pts.push(map(*a)); + } + let seg = (b[0] - a[0]).hypot(b[1] - a[1]); + if seg <= left { + pts.push(map(*b)); + left -= seg; + } else { + let f = if seg > 0.0 { left / seg } else { 0.0 }; + pts.push(map([a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f])); + left = 0.0; + break; + } + } + match (pts.as_slice(), path.as_slice()) { + (_, [only]) => { + painter.circle_filled(map(*only), width / 2.0, k.color); + } + ([], _) => {} + _ => { + painter.add(egui::Shape::line(pts, Stroke::new(width, k.color))); + } + } + if left <= 0.0 { + break; + } + } + } +} + +fn stop_replay(app: &mut WordApp) { + app.canvas.ink_ui.replay = None; + let _ = app.run("draw.replay", json!({"value": false})); +} + +/// Ink Replay's controls at the bottom of the document area: Rewind, Play/Pause, Forward (to +/// the end of the stroke being drawn), how far it has got, and Close. +pub fn replay_bar(app: &mut WordApp, ui: &Ui, area: Rect) { + let Some(r) = app.canvas.ink_ui.replay.as_ref() else { return }; + let (playing, n, total) = (r.playing, r.started(), r.strokes.len()); + let mut action = None; + egui::Area::new(egui::Id::new("ink_replay_bar")) + .order(egui::Order::Foreground) + .pivot(Align2::CENTER_BOTTOM) + .fixed_pos(pos2(area.center().x, area.max.y - 18.0)) + .show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing = vec2(6.0, 0.0); + if crate::widgets::icon_button(ui, "replayRewind", tl!("Rewind")).clicked() { + action = Some("rewind"); + } + let (icon, tip) = if playing { ("replayPause", tl!("Pause")) } else { ("replayPlay", tl!("Play")) }; + if crate::widgets::icon_button(ui, icon, tip).clicked() { + action = Some("play"); + } + if crate::widgets::icon_button(ui, "replayForward", tl!("Forward")).clicked() { + action = Some("forward"); + } + let label = crate::i18n::fmt(tl!("Stroke {n} of {total}"), &[("n", &n.to_string()), ("total", &total.to_string())]); + ui.label(label); + if crate::widgets::icon_button(ui, "close", tl!("Close")).clicked() { + action = Some("close"); + } + }); + }); + }); + let Some(a) = action else { return }; + if a == "close" { + stop_replay(app); + return; + } + let Some(r) = app.canvas.ink_ui.replay.as_mut() else { return }; + match a { + "rewind" => r.t = 0.0, + "play" if r.playing => r.playing = false, + "play" => { + // Play again from the start once it has finished. + if r.t >= r.end() { + r.t = 0.0; + } + r.playing = true; + } + _ => { + // Forward: finish the stroke being drawn, or the next one between strokes. + let t = r.t; + r.t = r.strokes.iter().map(|s| s.start + s.dur).find(|e| *e > t + 1e-3).unwrap_or(r.end()); + } + } + ui.ctx().request_repaint(); +} diff --git a/crates/ui-egui/src/keys.rs b/crates/ui-egui/src/keys.rs index 9afef5bd49..dbb444e759 100644 --- a/crates/ui-egui/src/keys.rs +++ b/crates/ui-egui/src/keys.rs @@ -258,6 +258,10 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) { app.session.painter = None; continue; } + // The lasso selection and Ink Replay take Delete and Escape first. + if crate::ink::key(app, key) { + continue; + } // Escape puts the pen or eraser down (back to selecting). if key == Key::Escape && app.session.view.draw.mode != wordcraft_engine::cmd::draw::DrawMode::Select { app.canvas.ink = None; diff --git a/crates/ui-egui/src/keytips.rs b/crates/ui-egui/src/keytips.rs index ce037d6e4d..9a0f4550c4 100644 --- a/crates/ui-egui/src/keytips.rs +++ b/crates/ui-egui/src/keytips.rs @@ -207,6 +207,7 @@ fn controls(tab: &str) -> Vec { c("Pen", "draw.pen"), c("Pencil", "draw.pencil"), c("Highlighter", "draw.highlighter"), + m("Add Pen", "draw.addPen"), c("Ink to Shape", "draw.inkToShape"), c("Ink to Math", "draw.inkToMath"), c("Drawing Canvas", "insert.canvas"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f6751bcf27..092de66829 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -135,6 +135,10 @@ pub struct UiState { /// Custom ribbon tabs and groups, hidden tabs and the Quick Access Toolbar (Customize Ribbon, /// #379); the session owns them while running. pub ribbon: wordcraft_engine::RibbonLayout, + /// Draw › Add Pen: the pens added to the pen gallery; the session owns them while running. + /// Entries that aren't pens are dropped on reading. + #[serde(deserialize_with = "wordcraft_engine::cmd::draw::lenient_pens")] + pub ink_pens: Vec, } impl Default for UiState { @@ -162,6 +166,7 @@ impl Default for UiState { dark_page: false, keyboard: wordcraft_engine::KeyMap::default(), ribbon: wordcraft_engine::RibbonLayout::default(), + ink_pens: Vec::new(), } } } @@ -305,6 +310,7 @@ impl WordApp { ui.dark_page = self.session.view.dark_mode; ui.keyboard = self.session.keymap.clone(); ui.ribbon = self.session.ribbon.clone(); + ui.ink_pens = self.session.view.draw.pens.clone(); ui } @@ -332,6 +338,7 @@ impl WordApp { // So are ribbon and toolbar commands. self.session.ribbon = std::mem::take(&mut self.ui.ribbon); self.session.ribbon.retain_known(®); + self.session.view.draw.pens = std::mem::take(&mut self.ui.ink_pens); } /// Run a command the user asked for (ribbon, shortcut, Backstage, file drop). New, Open, diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 0699914418..749a1cc87f 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -717,29 +717,46 @@ fn insert(app: &mut WordApp, ui: &mut Ui) { fn draw(app: &mut WordApp, ui: &mut Ui) { use wordcraft_doc::freeform::InkTool; - use wordcraft_engine::cmd::draw::DrawMode; + use wordcraft_engine::cmd::draw::{DrawMode, lasso_selection}; let mode = app.session.view.draw.mode; // The pen the colour and thickness menus change: the one in use, else the pen. let tool = mode.pen().unwrap_or(InkTool::Pen); - let pen_id = |t: InkTool| match t { - InkTool::Pen => "draw.pen", - InkTool::Pencil => "draw.pencil", - InkTool::Highlighter => "draw.highlighter", - }; + let set = app.session.view.draw.settings(tool); + // The gallery pen in use, if any: changing the colour or thickness changes it too. + let custom = + app.session.view.draw.pens.iter().position(|p| mode == DrawMode::Pen(p.kind) && p.color == set.color && (p.width - set.width).abs() < 0.01); + let lasso_on = lasso_selection(&app.session).is_some(); group(ui, "Drawing Tools", None, app, |ui, app| { big_toggle(ui, app, "select", "Select", "draw.select", mode == DrawMode::Select); - big(ui, app, "lasso", "Lasso", "draw.lasso", json!({}), false); + big_toggle(ui, app, "lasso", "Lasso", "draw.lasso", mode == DrawMode::Lasso); big_toggle(ui, app, "eraser", "Eraser", "draw.eraser", mode == DrawMode::Eraser); - big_toggle(ui, app, "pen", "Pen", "draw.pen", mode == DrawMode::Pen(InkTool::Pen)); - big_toggle(ui, app, "pencil", "Pencil", "draw.pencil", mode == DrawMode::Pen(InkTool::Pencil)); - big_toggle(ui, app, "highlight", "Highlighter", "draw.highlighter", mode == DrawMode::Pen(InkTool::Highlighter)); - let set = app.session.view.draw.settings(tool); + big_toggle(ui, app, "pen", "Pen", "draw.pen", mode == DrawMode::Pen(InkTool::Pen) && custom.is_none()); + big_toggle(ui, app, "pencil", "Pencil", "draw.pencil", mode == DrawMode::Pen(InkTool::Pencil) && custom.is_none()); + big_toggle(ui, app, "highlight", "Highlighter", "draw.highlighter", mode == DrawMode::Pen(InkTool::Highlighter) && custom.is_none()); + // Pens added with Add Pen; right-click one to remove it. + let pens = app.session.view.draw.pens.clone(); + for (i, pen) in pens.iter().enumerate() { + gallery_pen(ui, app, i, *pen, custom == Some(i)); + } + menu_button(ui, app, "addPen", Some("Add\nPen"), "Add Pen", true, |ui, app| { + for (label, kind) in [("Pen", InkTool::Pen), ("Pencil", InkTool::Pencil), ("Highlighter", InkTool::Highlighter)] { + // A new pen starts as that kind's current colour and thickness. + let cur = app.session.view.draw.settings(kind); + mi(ui, app, label, "draw.addPen", json!({"kind": kind.name(), "color": cur.color.hex(), "width": cur.width})); + } + }); stack(ui, |ui| { let sw = Some(crate::theme::c32(set.color)); - split(ui, app, "fontcolor", "Color", pen_id(tool), json!({}), false, sw, |ui, app| { + split(ui, app, "fontcolor", "Color", pen_command(tool), json!({}), false, sw, |ui, app| { let theme = app.session.doc.settings.theme_colors.clone(); if let Some(hex) = color_grid(ui, &theme) { - let _ = app.run(pen_id(tool), json!({"color": hex})); + // With ink lasso-selected the colour goes to the selection, else to the pen. + if lasso_on { + let _ = app.run("draw.lassoColor", json!({"color": hex})); + } else { + let _ = app.run(pen_command(tool), json!({"color": hex})); + update_gallery_pen(app, custom, tool); + } ui.close(); } }); @@ -747,20 +764,80 @@ fn draw(app: &mut WordApp, ui: &mut Ui) { let widths: &[f32] = if tool == InkTool::Highlighter { &[4.0, 8.0, 12.0, 18.0, 24.0] } else { &[0.5, 1.0, 1.5, 2.5, 3.5, 5.0] }; for w in widths { let label = crate::i18n::fmt(tl!("{n} pt"), &[("n", &w.to_string())]); - mi_check(ui, app, &label, (set.width - w).abs() < 0.01, pen_id(tool), json!({"width": w})); + mi_check(ui, app, &label, (set.width - w).abs() < 0.01, pen_command(tool), json!({"width": w})); } + update_gallery_pen(app, custom, tool); }); }); }); group(ui, "Convert", None, app, |ui, app| { - big(ui, app, "inkToShape", "Ink to\nShape", "draw.inkToShape", json!({}), false); + let on = app.session.view.draw.ink_to_shape; + big_toggle(ui, app, "inkToShape", "Ink to\nShape", "draw.inkToShape", on); big(ui, app, "inkToMath", "Ink to\nMath", "draw.inkToMath", json!({}), false); }); group(ui, "Insert", None, app, |ui, app| { big(ui, app, "canvas", "Drawing\nCanvas", "insert.canvas", json!({}), false); }); group(ui, "Replay", None, app, |ui, app| { - big(ui, app, "replay", "Ink\nReplay", "draw.replay", json!({}), false); + let on = app.session.view.draw.replay; + big_toggle(ui, app, "replay", "Ink\nReplay", "draw.replay", on); + }); +} + +/// The command that picks up `tool`. +fn pen_command(tool: wordcraft_doc::freeform::InkTool) -> &'static str { + use wordcraft_doc::freeform::InkTool; + match tool { + InkTool::Pen => "draw.pen", + InkTool::Pencil => "draw.pencil", + InkTool::Highlighter => "draw.highlighter", + } +} + +/// After the colour or thickness menu changed `tool`'s settings, the gallery pen that was in use +/// (`custom`) takes them too. +fn update_gallery_pen(app: &mut WordApp, custom: Option, tool: wordcraft_doc::freeform::InkTool) { + let set = app.session.view.draw.settings(tool); + if let Some(p) = custom.and_then(|i| app.session.view.draw.pens.get_mut(i)) + && p.kind == tool + { + p.color = set.color; + p.width = set.width; + } +} + +/// A pen of the gallery: its kind's icon over a bar of its colour and thickness; a click picks it +/// up, right-click offers Remove Pen. +fn gallery_pen(ui: &mut Ui, app: &mut WordApp, i: usize, pen: wordcraft_engine::cmd::draw::CustomPen, active: bool) { + use wordcraft_doc::freeform::InkTool; + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(34.0, CONTENT_H), Sense::click()); + crate::widgets::bg(ui, r, &resp, active, &t); + let icon = match pen.kind { + InkTool::Pen => "pen", + InkTool::Pencil => "pencil", + InkTool::Highlighter => "highlight", + }; + let color = crate::theme::c32(pen.color); + icons::paint(ui.painter(), Rect::from_center_size(pos2(r.center().x, r.min.y + 20.0), vec2(28.0, 28.0)), icon, t.icon, color); + let bar = (pen.width * 0.6).clamp(1.5, 8.0); + let y = r.min.y + 46.0; + ui.painter().line_segment([pos2(r.min.x + 6.0, y), pos2(r.max.x - 6.0, y)], Stroke::new(bar, color)); + let kind = tl!(match pen.kind { + InkTool::Pen => "Pen", + InkTool::Pencil => "Pencil", + InkTool::Highlighter => "Highlighter", + }); + let size = crate::i18n::fmt(tl!("{n} pt"), &[("n", &pen.width.to_string())]); + let resp = resp.on_hover_text(format!("{kind}: {size}, #{}", pen.color.hex())); + if resp.clicked() { + let _ = app.run(pen_command(pen.kind), json!({"color": pen.color.hex(), "width": pen.width})); + } + resp.context_menu(|ui| { + if ui.button(tl!("Remove Pen")).clicked() { + let _ = app.run("draw.removePen", json!({"index": i})); + ui.close(); + } }); } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 3602e5acd5..d40d5e8397 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -89,7 +89,7 @@ pub fn enabled(app: &WordApp, id: &str) -> bool { } } -fn bg(ui: &Ui, r: Rect, resp: &Response, checked: bool, t: &Tokens) { +pub(crate) fn bg(ui: &Ui, r: Rect, resp: &Response, checked: bool, t: &Tokens) { let fill = if resp.is_pointer_button_down_on() { t.pressed } else if checked { diff --git a/docs/gaps.md b/docs/gaps.md index b96d95c616..0b7d477fb5 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -22,7 +22,7 @@ first (`gh pr list`), and update this file and the parity doc when a gap closes. | 9 | **Password-protected files** can't be opened or saved | | #55; `docbin` detects and refuses RC4/XOR | Corporate and legal users | 6–10 | [file-format-parity.md](file-format-parity.md) | | 10 | **Objects**: no group (#267 open), text in a freely rotated text box (free rotation and flips landed, #332; text direction, alignment and linked text boxes landed, #369; contour wrap and wrap side landed, #354), shape effects, WordArt, connectors, dragging members inside a Drawing Canvas (the canvas landed, #344) | | [parity-checklist.md](parity-checklist.md) (Shape Format 6/6, Layout › Group) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) | | 11 | **Interface languages**: 8 of the 12 key languages missing (hi, ar, fr, id, de, ko, vi, pt-PT); no mirrored RTL interface; ~5% of strings still English in the six translated catalogs | | [localization-parity.md](localization-parity.md) | Most of the world's writers | 50–80 | [localization-parity.md](localization-parity.md) | -| 12 | **Draw tab / ink**: pens, pencil, highlighter, eraser, Select and Hide Ink landed (#307); missing: Lasso Select, Add Pen, Ink to Shape, Ink to Math, Ink Replay, pressure, Word's own ink (`w14:contentPart`/InkML) | | [parity-checklist.md](parity-checklist.md) (Draw 5/11) | Pen and tablet users | 8–14 | [hardware-parity.md](hardware-parity.md) | +| 12 | **Draw tab / ink**: pens, pencil, highlighter, eraser, Select and Hide Ink landed (#307); Lasso Select, Add Pen, Ink to Shape and Ink Replay landed (#395); missing: Ink to Math, arrows in Ink to Shape, pressure, Word's own ink (`w14:contentPart`/InkML) | | [parity-checklist.md](parity-checklist.md) (Draw 9/11) | Pen and tablet users | 4–8 | [hardware-parity.md](hardware-parity.md) | | 13 | **Charts and SmartArt editing** (own renderer, chart data editing, SmartArt layouts) | | `insert.chart`, `insert.smartArt` missing | Reports and business documents | 45–70 | [target-app-parity.md](target-app-parity.md) | | 14 | **`.doc` can't be written; metafile pictures and Word 6/95 files refused** | | `crates/docbin` is read-only | Users exchanging files with old Word or legacy systems | 25–40 | [file-format-parity.md](file-format-parity.md) | | 15 | **References depth**: sources not saved in DOCX (#169 open); no EndNote/Mendeley desktop integration | | Sources live only in the session (#169 open) | Students and researchers | 6–10 | [target-app-parity.md](target-app-parity.md) | @@ -79,6 +79,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | 2026-10-11 | trivial | Collapsed duplicate rows left by parallel merges | | 2026-10-11 | trivial | All 12 bibliography styles landed (#383); removed from References depth | | 2026-10-11 | trivial | Formatting revisions landed (#41); gap #5 narrowed to move tracking and content controls, 8–12 h | +| 2026-10-11 | trivial | #12 narrowed: Lasso Select, Add Pen, Ink to Shape and Ink Replay landed (#395) | | 2026-10-10 | trivial | Draw Table and Eraser landed (#303); removed from the feature-gap list | | 2026-10-10 | trivial | Tabs and Borders and Shading dialogs landed (#320); removed from the dialog-depth list | | 2026-10-10 | trivial | Columns, Symbol and Field dialogs landed (#321); removed from the dialog-depth list | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index b0932f6bd1..f8a06af45c 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -2,7 +2,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry. -**429 of 451 catalog features have commands (95.1%).** +**433 of 451 catalog features have commands (96.0%).** | Tab | Live | Total | |---|---|---| @@ -10,7 +10,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word- | Quick Access | 4 | 4 | | Home | 101 | 102 | | Insert | 45 | 53 | -| Draw | 6 | 11 | +| Draw | 10 | 11 | | Design | 10 | 10 | | Layout | 21 | 21 | | References | 24 | 24 | @@ -45,11 +45,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word- ### Draw -- Tools › Lasso Select (`draw.lasso`) -- Pens › Add Pen (`draw.addPen`) -- Convert › Ink to Shape (`draw.inkToShape`) - Convert › Ink to Math (`draw.inkToMath`) -- Replay › Ink Replay (`draw.replay`) ### Review diff --git a/docs/roadmap.md b/docs/roadmap.md index b5089be659..b9fd642728 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # WordCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (M7: formatting revisions landed, #41; previously trivial: M5: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: alpha gate checked: six core workflows pass; previously major: milestones moved here from ROADMAP.md; Current focus set from the re-measured gaps) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Draw: Lasso Select, Add Pen, Ink to Shape and Ink Replay landed, #395; previously trivial: M7: formatting revisions landed, #41; previously trivial: M5: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: alpha gate checked: six core workflows pass; previously major: milestones moved here from ROADMAP.md; Current focus set from the re-measured gaps) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Forward-looking plan. The summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md); the numbers are in [`target-app-parity.md`](target-app-parity.md). Hours are @@ -55,7 +55,7 @@ partial rows are fidelity gaps (beta work), not broken workflows. WordCraft stay | M8 | View | mostly done; window commands missing | 3–5 h | | M9 | Mailings | done (first version); Excel/Outlook data sources and email merge missing | 6–10 h | | M10 | File/Backstage | mostly done; native printing, Options depth missing | 12–20 h | -| M11 | Draw and objects | text wrap, text boxes, canvas handles, rotation (#332), ink pens and eraser (#307), contour wrap (#354) done; lasso and ink conversion, grouping missing | 35–55 h | +| M11 | Draw and objects | text wrap, text boxes, canvas handles, rotation (#332), ink pens and eraser (#307), contour wrap (#354), lasso, Add Pen, Ink to Shape and Ink Replay (#395) done; Ink to Math, grouping missing | 35–55 h | | M12 | Formats breadth (PDF, ODT, RTF, HTML, MD, TXT, LaTeX, `.doc` import) | done (first versions); `.doc` write, encryption, minor formats missing | 60–100 h | | M13 | Performance budgets | on track (1.4 ms relayout); large real-world documents unmeasured | 10–20 h | | M14 | 1.0 polish, packaging, signing | releases v0.1.0–v0.4.0 published (signed macOS, Windows MSI x64/x86/arm64, Linux, FreeBSD, web) | — | @@ -81,3 +81,4 @@ languages, screen-reader access, ecosystem decisions (VBA, add-ins, cloud), an i | 2026-10-10 | minor | Alpha gate table added (core-workflow gate from the progress-docs standard): all six workflows pass, stage stays alpha | | 2026-10-10 | major | Created: milestones moved from ROADMAP.md with remaining hours; Current focus from gaps.md; M15–M16 added | | 2026-10-10 | trivial | M11: ink pens and eraser landed (#307) | +| 2026-10-11 | trivial | M11: lasso, Add Pen, Ink to Shape and Ink Replay landed (#395) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 28353a722f..fc58b630b8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -13,7 +13,7 @@ The generated ribbon checklist is [`parity-checklist.md`](parity-checklist.md). | Number | Value | Kind | |---|---|---| -| Ribbon/menu catalog coverage | **429 / 451 (95.1%)** | **measured**: `cargo xtask parity` (re-derived this pass from `crates/engine/src/catalog.rs` against every `CommandSpec::new` id in the source; identical to the checked-in `parity-checklist.md`) | +| Ribbon/menu catalog coverage | **433 / 451 (96.0%)** | **measured**: `cargo xtask parity` (re-derived this pass from `crates/engine/src/catalog.rs` against every `CommandSpec::new` id in the source; identical to the checked-in `parity-checklist.md`) | | Feature breadth (weighted, beyond the ribbon: dialog options, citation styles, proofing languages, chart types, UI languages) | **~80%** | estimated | | Feature depth (weighted by use, table below) | **~68%** | estimated | | **Ready for real work** (full target) | **~60%** (range 55–63%) | estimated: weighted sum over the dimension table ([By dimension](#by-dimension)) | @@ -70,7 +70,7 @@ full parity for the area. | Mailings | 2 | 70% | 70% | 6–10 | Mail merge from CSV, fields, rules (IF, SKIPIF, NEXT, NEXTIF, evaluated alike in preview and merge), fields in headers/footers (one section per letter), preview, envelopes, labels. Select Recipients takes a typed list, CSV (#247) or a sheet of an Excel (.xlsx/.xlsm) or OpenDocument (.ods) workbook, with Select Table to pick the sheet (#334); no Outlook/Contacts/Access data sources, no email merge. | | Pictures, shapes, text boxes, WordArt | 6 | 60% | 60% | 20–30 | Insert, crop, recolour, styles, floating placement like Word (#136), wrap (square/top-bottom/behind/front), text boxes edit and overflow (#46), VML text boxes (#242), free rotation and flips with a rotation handle (#332), text-box text direction (90°, 270°, stacked), Align Text and linked text boxes (#369), Icons: an original icon library drawn in code with a searchable picker, inserted as pictures and recoloured with Icon Color (#309), Drawing Canvas with Fit/Expand/Scale and DOCX `wpc:wpc` (#344; members edited by command, not yet dragged inside the canvas; the canvas rotates and flips as a whole). Missing: tight/through contour wrap, text in a freely rotated text box, group (#267 open), shape effects, WordArt, connectors, online pictures, screenshot. | | Equations | 1 | 75% | 20% (with Draw) | 4–8 | OMML read/write, OpenType MATH layout, in-place editor with an Equation tab (#191). Missing: line breaking of long display equations, ink equations. | -| Charts, SmartArt, Draw/ink, 3D models, icons | 4 | 3% | (20% with equations) | 70–110 | Icons landed (#309; an original library drawn in code). Draw: pens, pencil, highlighter, stroke eraser and Hide Ink (#307), ink saved as DrawingML freeforms; lasso, ink to shape/math and replay missing; charts and SmartArt are dropped on DOCX read (no `c:chart`/`dgm` handling in `crates/docx`). | +| Charts, SmartArt, Draw/ink, 3D models, icons | 4 | 3% | (20% with equations) | 70–110 | Icons landed (#309; an original library drawn in code). Draw: pens, pencil, highlighter, stroke eraser and Hide Ink (#307), ink saved as DrawingML freeforms; Lasso Select (move, recolour, delete), Add Pen gallery, Ink to Shape (line, rectangle, ellipse, triangle, polygon) and Ink Replay (#395); Ink to Math, pressure and Word's own InkML missing; charts and SmartArt are dropped on DOCX read (no `c:chart`/`dgm` handling in `crates/docx`). | | View (modes, zoom, panes, windows) | 3 | 72% | 70% | 6–10 | Print/web/draft/read/focus, dark page (#194), pages side by side when zoomed out (#251), navigation pane. Missing: View Side by Side, Synchronous Scrolling, Arrange All, Switch Windows, Outline view depth, Immersive Reader depth. | | Backstage, printing, options | 3 | 58% | 55% | 12–20 | New/Home pages (#254), save prompts (#151), web printing via the browser (#209). Desktop printing still goes through PDF; Options has a fraction of Word's panes. | | Right-to-left and complex scripts | 1 | 50% | 55% | 15–25 | UAX #9, Arabic shaping, RTL paragraphs (#207). No RTL sections/tables, kashida justification or mirrored UI; open bug reports (#215, #211, #199, #66, #63, #48, #19) need re-testing against #207. | @@ -263,6 +263,7 @@ can be used without committing Word output. | 2026-10-11 | trivial | References: all 12 of Word's bibliography styles (eight more: Harvard, ISO 690 ×2, Turabian, GB/T 7714, GOST ×2, SIST02) (#383); no percentage change | | 2026-10-11 | trivial | Mail merge fixes (#427–#430): SKIPIF drops matching records, NEXT advances later fields, IF previews, header/footer fields merge; Mailings row updated, no percentage change | | 2026-10-11 | trivial | Formatting revisions (#41): Review row updated; no percentage change | +| 2026-10-11 | trivial | #395: Lasso Select, Add Pen, Ink to Shape and Ink Replay; catalog 423/448 → 427/448 (95.3%) | | 2026-10-10 | trivial | #307: Draw tab pens, eraser and Hide Ink; catalog 384/430 → 390/430 (90.7%) | | 2026-10-10 | trivial | Draw Table and Eraser (#303): catalog 384/430 → 387/430 (90.0%) | | 2026-10-10 | trivial | Pictures, shapes, text boxes: free rotation and flips, rotation handle, rotated wrap bounds, DOCX `a:xfrm` rot/flipH/flipV (#332); no percentage change | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e656748170..99e4731f13 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -10,7 +10,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | Area | WordCraft | Word | Parity | Hours | |---|---|---|---|---| -| Ribbon tabs and groups | All Word tabs including contextual Table Design/Layout, Picture Format, Shape Format, Header & Footer, Equation; ribbon overflow (#43, #129). Draw tab: Select, Eraser, Pen, Pencil, Highlighter with colour and thickness (#307); missing: Lasso, Add Pen, Ink to Shape/Math, Ink Replay | Same plus Draw | 85% | (Draw in features) | +| Ribbon tabs and groups | All Word tabs including contextual Table Design/Layout, Picture Format, Shape Format, Header & Footer, Equation; ribbon overflow (#43, #129). Draw tab: Select, Eraser, Pen, Pencil, Highlighter with colour and thickness (#307); Lasso (drag to move, Delete, recolour), Add Pen gallery (right-click to remove), Ink to Shape, Ink Replay with play/pause/rewind/forward bar (#395); missing: Ink to Math | Same plus Draw | 85% | (Draw in features) | | Keytips (Alt / ⌃⌥ letters) | ✅ (#43) | ✅ | 85% | 1–2 | | Mini-toolbar on selection | ✅ (#43) | ✅ | 80% | 1–2 | | Quick Access Toolbar | AutoSave, then the user's commands (Save, Undo, Redo by default); add, remove and reorder from its … menu (common commands, More Commands…), right-click on any ribbon button, or Customize Ribbon; saved per user (#379, #376). Not shown below the ribbon | customizable | 90% | 0.5 | @@ -58,3 +58,4 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | 2026-10-10 | minor | Merged main: Style Inspector, Column Selection, zoom buttons, inertial scrolling, File Info landed | | 2026-10-10 | major | First version: ribbon, dialog, shortcut and interaction inventory | | 2026-10-10 | trivial | Draw tab tools and Review › Hide Ink (#307) | +| 2026-10-11 | trivial | Draw: Lasso, Add Pen gallery, Ink to Shape and Ink Replay (#395) | From 56f9456f03f508828514c392f34023636c4a59d0 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 11 Oct 2026 21:56:41 +0200 Subject: [PATCH 02/89] References dialogs: Caption, Index, Table of Contents, Create Source (#402) (#410) * References dialogs: Caption, Index, Table of Contents, Create Source (#402) - Caption: labels (built-in plus New/Delete Label, kept with the interface settings), above/below the item, leave the label out, Numbering (format, chapter number from a heading level with a separator) -> SEQ \* \s and STYLEREF n \s fields; Update Field renumbers SEQ with \s restarts, \r, \c, \h. - Index: indented/run-in, columns, right-aligned page numbers with tab leader, letter headings -> INDEX \h \c \e \r; Mark Index Entry (subentry, cross-reference, page-range bookmark, bold/italic page numbers, Mark All) -> XE \t \r \b \i. The index is rebuilt honouring them; columns become a continuous section of their own. Index commands move to cmd/index.rs. - Custom Table of Contents: page numbers, right align, tab leader, hyperlinks, levels, Options (heading styles, other styles with levels, outline levels, TC fields), Modify for TOC 1-9 -> TOC \o \h \z \u \t \f \l \n \p; updating honours them (hyperlinks to _Toc bookmarks). - Source Manager and Create/Edit Source: source types with their fields, show every bibliography field (new Source fields); Insert Citation menu lists the sources and Add New Source. Co-Authored-By: Claude Opus 5.5 * fmt (#402) --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 1 + crates/doc/src/lib.rs | 11 +- crates/engine/src/cmd/citations.rs | 41 +- crates/engine/src/cmd/index.rs | 440 ++++++++++ crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/references.rs | 634 +++++++++++--- crates/layout/src/fields.rs | 2 +- crates/layout/src/lib.rs | 4 +- crates/ui-egui/src/dialogs.rs | 24 +- crates/ui-egui/src/dialogs_refs.rs | 1183 +++++++++++++++++++++++++++ crates/ui-egui/src/i18n/audit.rs | 7 + crates/ui-egui/src/i18n/es.tsv | 98 +++ crates/ui-egui/src/i18n/et.tsv | 98 +++ crates/ui-egui/src/i18n/ja.tsv | 98 +++ crates/ui-egui/src/i18n/pt-br.tsv | 98 +++ crates/ui-egui/src/i18n/ru.tsv | 95 +++ crates/ui-egui/src/i18n/sr-latn.tsv | 98 +++ crates/ui-egui/src/i18n/sr.tsv | 98 +++ crates/ui-egui/src/i18n/tr.tsv | 98 +++ crates/ui-egui/src/i18n/uk.tsv | 98 +++ crates/ui-egui/src/i18n/zh-hans.tsv | 98 +++ crates/ui-egui/src/i18n/zh-hant.tsv | 98 +++ crates/ui-egui/src/lib.rs | 13 + crates/ui-egui/src/ribbon.rs | 15 +- docs/gaps.md | 3 +- docs/target-app-parity.md | 3 +- docs/ui-parity.md | 3 +- 27 files changed, 3332 insertions(+), 129 deletions(-) create mode 100644 crates/engine/src/cmd/index.rs create mode 100644 crates/ui-egui/src/dialogs_refs.rs diff --git a/ROADMAP.md b/ROADMAP.md index f44e6f0789..17a263f993 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| +| 2026-10-11 | References dialogs (#402): Caption (labels, chapter numbering), Index and Mark Entry, Custom Table of Contents, Source Manager and Create/Edit Source, with TOC/INDEX/XE/SEQ switches honoured on update | | 2026-10-11 | Document grid (#391): Page Setup › Document Grid sets lines per page and characters per line (`w:docGrid` round trip); lines snap to the line pitch, East Asian characters to the character pitch; View › Gridlines shows it | | 2026-10-11 | Move tracking (#400): `w:moveFrom`/`w:moveTo` and their named ranges kept in DOCX, a tracked cut and paste of a paragraph or sentence records a move, accept/reject at either end resolves both, green double strikethrough/underline and "Moved from/to" balloons | | 2026-10-11 | Footnote and Endnote dialog from the References › Footnotes launcher: options per section or document (below text, end of section, number format, start at, restart each section or page), custom marks, convert footnotes ↔ endnotes; kept in DOCX (#385) | diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 40ad558c20..ae0c6928a0 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -331,7 +331,7 @@ impl Document { pub struct Source { /// Unique tag cited by CITATION fields. pub tag: String, - /// book, article, website, report, film, other + /// book, bookSection, article (journal), periodical, conference, report, website, film, other pub kind: String, /// "Last, First; Last, First" pub author: String, @@ -343,6 +343,15 @@ pub struct Source { pub volume: String, pub pages: String, pub url: String, + /// More bibliography fields (Create Source › Show All Bibliography Fields). + pub editor: String, + pub edition: String, + pub issue: String, + pub month: String, + pub day: String, + /// Date accessed (websites). + pub accessed: String, + pub doi: String, } /// A custom document property (File › Info › Properties › Custom). Citation managers keep diff --git a/crates/engine/src/cmd/citations.rs b/crates/engine/src/cmd/citations.rs index bdd667b6b3..4b40c93c41 100644 --- a/crates/engine/src/cmd/citations.rs +++ b/crates/engine/src/cmd/citations.rs @@ -319,28 +319,6 @@ pub fn specs() -> Vec { update_generated(s)?; sel_result(s) }), - CommandSpec::new("references.markEntry", "Mark Entry", "References › Index", |s, v| { - let entry = match p::str(v, "entry") { - Some(e) => e.to_string(), - None => s.selected_text().trim().to_string(), - }; - if entry.is_empty() { - return Err(CmdError::Params("select a word or give `entry`".into())); - } - let (_, b) = s.sel.ordered(); - s.doc.insert_object(&b, InlineObject::Field { instr: format!("XE \"{}\"", entry.replace('"', "")), result: String::new(), locked: false, code: false }, &CharProps { hidden: Some(true), ..Default::default() })?; - Ok(json!({"entry": entry})) - }) - .params(r#"{"entry"?: string}"#) - .key(super::non_mac("Alt+Shift+X")), - CommandSpec::new("references.index", "Insert Index", "References › Index", |s, _| { - generated_list(s, "INDEX", "Index")?; - sel_result(s) - }), - CommandSpec::new("references.updateIndex", "Update Index", "References › Index", |s, _| { - update_generated(s)?; - sel_result(s) - }), CommandSpec::new("references.markCitation", "Mark Citation", "References › Table of Authorities", |s, v| { let entry = p::str(v, "entry").map(str::to_string).unwrap_or_else(|| s.selected_text().trim().to_string()); if entry.is_empty() { @@ -547,8 +525,16 @@ fn sources(s: &mut Session, v: &Value) -> CmdResult { .collect(); src.tag = format!("{base}{}", src.year); } - s.doc.sources.retain(|x| x.tag != src.tag); - s.doc.sources.push(src); + // Edit Source keeps the source's place in the list and refreshes its citations. + match s.doc.sources.iter().position(|x| x.tag == src.tag) { + Some(i) => { + if let Some(x) = s.doc.sources.get_mut(i) { + *x = src; + } + update_citations(s)?; + } + None => s.doc.sources.push(src), + } s.touch(); } if let Some(t) = p::str(v, "remove") { @@ -685,7 +671,7 @@ fn refresh_citations(s: &mut Session) -> Result<(), CmdError> { /// Generated lists (BIBLIOGRAPHY, INDEX, table of figures, TOA): a heading paragraph holding /// the field, then entry paragraphs marked with the "GeneratedEntry" style family. -fn generated_list(s: &mut Session, instr: &str, title: &str) -> Result<(), CmdError> { +pub(crate) fn generated_list(s: &mut Session, instr: &str, title: &str) -> Result<(), CmdError> { let at = delete_selection(s)?; if at.story != StoryRef::Body || at.path.depth() > 0 { return Err(CmdError::Failed("lists go in the main text".into())); @@ -734,6 +720,11 @@ pub fn update_generated(s: &mut Session) -> Result<(), CmdError> { i += 1; continue; }; + if kind.starts_with("INDEX") { + super::index::rebuild(s, i, None)?; + i += 1; + continue; + } // Remove old entries. while s.doc.body.get(i + 1).and_then(|b| b.as_para()).is_some_and(|p| p.props.style.as_deref() == Some(ENTRY_STYLE)) { s.doc.remove_block(StoryRef::Body, &Path::top(i + 1))?; diff --git a/crates/engine/src/cmd/index.rs b/crates/engine/src/cmd/index.rs new file mode 100644 index 0000000000..3b1cc4c25b --- /dev/null +++ b/crates/engine/src/cmd/index.rs @@ -0,0 +1,440 @@ +//! References › Index: Mark Entry (XE fields), Insert Index and Update Index (the INDEX field). +//! Field switches follow ECMA-376 §17.16.5.32 (INDEX) and §17.16.5.79 (XE). + +use std::collections::BTreeMap; + +use serde_json::{Value, json}; +use wordcraft_doc::para::InlineObject; +use wordcraft_doc::props::{CharProps, TabAlign, TabLeader, TabStop}; +use wordcraft_doc::section::SectionStart; +use wordcraft_doc::{Block, Paragraph, Path, Pos, StoryRef}; + +use super::references::{FieldCode, leader}; +use super::sel_result; +use crate::{CmdError, CmdResult, CommandSpec, Session, p}; + +/// The paragraph style generated lists use for their entries (see `citations.rs`). +const ENTRY_STYLE: &str = "ListEntry"; + +pub fn specs() -> Vec { + vec![ + CommandSpec::new("references.markEntry", "Mark Entry", "References › Index", mark_entry).params( + r#"{"entry"?: string (the selection), "subentry"?: string, "crossReference"?: "See …", "bookmark"?: page-range bookmark, "bold"?: bool, "italic"?: bool, "all"?: bool (Mark All: every paragraph's first match of "text" or the selection)}"#, + ) + .key(super::non_mac("Alt+Shift+X")), + CommandSpec::new("references.index", "Insert Index", "References › Index", insert_index).params( + r#"{"type"?: "indented|runIn", "columns"?: 1-4, "rightAlign"?: bool, "tabLeader"?: "dot|hyphen|underscore|none", "headings"?: bool (letter headings)} (replaces an existing index's settings)"#, + ), + CommandSpec::new("references.updateIndex", "Update Index", "References › Index", |s, _| { + super::citations::update_generated(s)?; + sel_result(s) + }), + ] +} + +fn clean(t: &str, max: usize) -> String { + t.chars().filter(|c| *c != '"' && !c.is_control()).take(max).collect::().trim().to_string() +} + +/// The XE field for `references.markEntry`'s parameters: `XE "Main:Sub" \t "See X"` or +/// `\r bookmark`, then `\b` and `\i` for a bold or italic page number. +fn xe_instr(s: &Session, v: &Value, main: &str) -> Result { + let sub = p::str(v, "subentry").map(|t| clean(t, 200)).unwrap_or_default(); + let mut instr = if sub.is_empty() { format!("XE \"{main}\"") } else { format!("XE \"{main}:{sub}\"") }; + if let Some(see) = p::str(v, "crossReference").map(|t| clean(t, 200)).filter(|t| !t.is_empty()) { + instr.push_str(&format!(" \\t \"{see}\"")); + } else if let Some(bm) = p::str(v, "bookmark").filter(|b| !b.trim().is_empty()) { + if !s.doc.bookmarks().iter().any(|(n, _)| n == bm) { + return Err(CmdError::Params(format!("no bookmark `{bm}`"))); + } + instr.push_str(&format!(" \\r {}", bm.trim())); + } + if p::bool(v, "bold").unwrap_or(false) { + instr.push_str(" \\b"); + } + if p::bool(v, "italic").unwrap_or(false) { + instr.push_str(" \\i"); + } + Ok(instr) +} + +fn mark_entry(s: &mut Session, v: &Value) -> CmdResult { + let main = clean(&p::str(v, "entry").map(str::to_string).unwrap_or_else(|| s.selected_text()), 200); + if main.is_empty() { + return Err(CmdError::Params("select a word or give `entry`".into())); + } + let instr = xe_instr(s, v, &main)?; + let hidden = CharProps { hidden: Some(true), ..Default::default() }; + let field = || InlineObject::Field { instr: instr.clone(), result: String::new(), locked: false, code: false }; + if !p::bool(v, "all").unwrap_or(false) { + let (_, b) = s.sel.ordered(); + s.doc.insert_object(&b, field(), &hidden)?; + return Ok(json!({"entry": main, "marked": 1})); + } + // Mark All: the first whole-word, same-case match in every paragraph that hasn't this entry. + let text = p::str(v, "text").map(str::to_string).unwrap_or_else(|| { + let t = s.selected_text(); + if t.trim().is_empty() { main.clone() } else { t.trim().to_string() } + }); + if text.is_empty() { + return Err(CmdError::Params("nothing to look for".into())); + } + let mut marked = 0u32; + for path in s.doc.para_paths(StoryRef::Body) { + let Some(para) = s.doc.para(StoryRef::Body, &path) else { continue }; + if para.props.style.as_deref().is_some_and(|st| st == ENTRY_STYLE || st.starts_with("TOC")) { + continue; + } + if para.objects.iter().any(|o| matches!(o, InlineObject::Field { instr: i, .. } if *i == instr)) { + continue; + } + let whole = |a: usize, b: usize| { + let before = para.text.get(..a).and_then(|t| t.chars().next_back()).is_none_or(|c| !c.is_alphanumeric()); + let after = para.text.get(b..).and_then(|t| t.chars().next()).is_none_or(|c| !c.is_alphanumeric()); + before && after + }; + let Some(end) = para.text.match_indices(&text).map(|(a, m)| (a, a + m.len())).find(|(a, b)| whole(*a, *b)).map(|(_, b)| b) else { + continue; + }; + s.doc.insert_object(&Pos { story: StoryRef::Body, path, off: end }, field(), &hidden)?; + marked += 1; + } + s.clamp_selection(); + Ok(json!({"entry": main, "marked": marked})) +} + +/// The INDEX field for `references.index`'s parameters: `\h "A"` letter headings, `\c "n"` +/// columns, `\e` with a tab for right-aligned page numbers, `\r` run-in subentries. +pub fn index_instr(v: &Value) -> String { + let mut s = String::from("INDEX"); + if p::bool(v, "headings").unwrap_or(true) { + s.push_str(" \\h \"A\""); + } + let cols = p::u64(v, "columns").unwrap_or(1).clamp(1, 4); + if cols > 1 { + s.push_str(&format!(" \\c \"{cols}\"")); + } + if p::bool(v, "rightAlign").unwrap_or(false) { + s.push_str(" \\e \"\t\""); + } + if p::str(v, "type") == Some("runIn") { + s.push_str(" \\r"); + } + s +} + +/// The body block holding the INDEX field, and its instruction. +fn index_field(s: &Session) -> Option<(usize, String)> { + s.doc.body.iter().enumerate().find_map(|(i, b)| { + b.as_para()?.objects.iter().find_map(|o| match o { + InlineObject::Field { instr, .. } if FieldCode::parse(instr).name == "INDEX" => Some((i, instr.clone())), + _ => None, + }) + }) +} + +fn insert_index(s: &mut Session, v: &Value) -> CmdResult { + let instr = index_instr(v); + match index_field(s) { + Some((i, _)) => { + // Settings for the index that is there: a new field code, then rebuild it. + let para = s.doc.para_mut(StoryRef::Body, &Path::top(i))?; + for o in para.objects.iter_mut() { + if let InlineObject::Field { instr: old, .. } = o + && FieldCode::parse(old).name == "INDEX" + { + *old = instr.clone(); + } + } + para.touch(); + } + None => super::citations::generated_list(s, &instr, "Index")?, + } + if let Some((i, _)) = index_field(s) { + rebuild(s, i, p::str(v, "tabLeader").map(leader))?; + } + s.clamp_selection(); + sel_result(s) +} + +/// One page reference of an entry. +#[derive(Clone, Debug, PartialEq)] +struct PageRef { + page: String, + bold: bool, + italic: bool, +} + +#[derive(Default)] +struct Node { + /// The entry's levels as written (the key is their lower-case form). + levels: Vec, + pages: Vec, + see: Vec, +} + +/// Every XE entry in the body with its page (or page range), grouped by entry. +fn collect(s: &mut Session) -> BTreeMap, Node> { + let mut marks: Vec<(FieldCode, Pos)> = Vec::new(); + for path in s.doc.para_paths(StoryRef::Body) { + let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue }; + for off in p.object_offsets() { + if let Some(InlineObject::Field { instr, .. }) = p.object_at(off) { + let f = FieldCode::parse(instr); + if f.name == "XE" { + marks.push((f, Pos { story: StoryRef::Body, path: path.clone(), off })); + } + } + } + } + let l = s.layout(); + let page = |pos: &Pos| l.caret(pos).and_then(|c| l.pages.get(c.page)).map(|p| p.number).unwrap_or(1); + let bookmark_end = |name: &str| { + s.doc.para_paths(StoryRef::Body).into_iter().find_map(|path| { + let p = s.doc.para(StoryRef::Body, &path)?; + let off = p.object_offsets().into_iter().find(|o| matches!(p.object_at(*o), Some(InlineObject::BookmarkEnd { name: n }) if n == name))?; + Some(Pos { story: StoryRef::Body, path, off }) + }) + }; + let starts = s.doc.bookmarks(); + let mut tree: BTreeMap, Node> = BTreeMap::new(); + for (f, pos) in marks { + let text = f.args.first().cloned().unwrap_or_default(); + let levels: Vec = text.split(':').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).take(9).collect(); + if levels.is_empty() { + continue; + } + // Every level above gets a line of its own, even without pages. + for k in 1..levels.len() { + let head: Vec = levels.iter().take(k).cloned().collect(); + tree.entry(head.iter().map(|x| x.to_lowercase()).collect()).or_insert_with(|| Node { levels: head, ..Default::default() }); + } + let node = + tree.entry(levels.iter().map(|x| x.to_lowercase()).collect()).or_insert_with(|| Node { levels: levels.clone(), ..Default::default() }); + if let Some(see) = f.arg('t') { + if !node.see.iter().any(|x| x == see) { + node.see.push(see.to_string()); + } + continue; + } + let range = f.arg('r').and_then(|bm| { + let a = starts.iter().find(|(n, _)| n == bm).map(|(_, p)| page(p))?; + let b = bookmark_end(bm).map(|p| page(&p)).unwrap_or(a); + Some(if b > a { format!("{a}\u{2013}{b}") } else { a.to_string() }) + }); + let r = PageRef { page: range.unwrap_or_else(|| page(&pos).to_string()), bold: f.has('b'), italic: f.has('i') }; + match node.pages.iter_mut().find(|x| x.page == r.page) { + Some(x) => { + x.bold |= r.bold; + x.italic |= r.italic; + } + None => node.pages.push(r), + } + } + tree +} + +/// Text runs of one index line. +type Line = Vec<(String, CharProps)>; + +fn refs(line: &mut Line, node: &Node, sep: &str) { + for (k, r) in node.pages.iter().enumerate() { + line.push((if k == 0 { sep.to_string() } else { ", ".into() }, CharProps::default())); + line.push((r.page.clone(), CharProps { bold: r.bold.then_some(true), italic: r.italic.then_some(true), ..Default::default() })); + } + for (k, see) in node.see.iter().enumerate() { + let lead = if k > 0 || !node.pages.is_empty() { "; " } else { ". " }; + line.push((lead.into(), CharProps::default())); + line.push((see.clone(), CharProps { italic: Some(true), ..Default::default() })); + } +} + +/// Rebuild the index whose field is in body block `i`: entries sorted and grouped, indented or +/// run-in (`\r`), letter headings (`\h`), the page separator (`\e`, a tab right-aligns the page +/// numbers with `leader` or the leader the entries had), and `\c` columns as a continuous +/// section of their own. +pub fn rebuild(s: &mut Session, i: usize, leader: Option) -> Result<(), CmdError> { + let Some(instr) = s.doc.body.get(i).and_then(|b| b.as_para()).and_then(|p| { + p.objects.iter().find_map(|o| match o { + InlineObject::Field { instr, .. } if FieldCode::parse(instr).name == "INDEX" => Some(instr.clone()), + _ => None, + }) + }) else { + return Ok(()); + }; + let f = FieldCode::parse(&instr); + // Old entries go; keep their leader and the section their columns had. + let mut old_leader = None; + let mut old_sect = None; + while let Some(p) = s.doc.body.get(i + 1).and_then(|b| b.as_para()).filter(|p| p.props.style.as_deref() == Some(ENTRY_STYLE)) { + old_leader = old_leader.or_else(|| p.props.tabs.as_ref().and_then(|t| t.last()).map(|t| t.leader)); + if p.section.is_some() { + old_sect = p.section.clone(); + } + s.doc.remove_block(StoryRef::Body, &Path::top(i + 1))?; + } + let leader = leader.or(old_leader).unwrap_or(TabLeader::Dot); + let cols = f.arg('c').and_then(|c| c.trim().parse::().ok()).unwrap_or(1).clamp(1, 4); + let sep = f.arg('e').map(str::to_string).unwrap_or_else(|| ", ".into()); + let run_in = f.has('r'); + let headings = f.switches.iter().find(|(c, _)| *c == 'h').map(|(_, a)| a.clone().unwrap_or_default()); + let tree = collect(s); + let mut lines: Vec<(usize, Line)> = Vec::new(); + let mut letter: Option = None; + let mut keys = tree.keys().peekable(); + while let Some(key) = keys.next() { + let Some(node) = tree.get(key) else { continue }; + let Some(main) = node.levels.first() else { continue }; + if let Some(h) = &headings { + let first = main.chars().next().map(|c| c.to_uppercase().to_string()); + if first != letter { + if letter.is_some() || !h.is_empty() { + lines.push((0, vec![(if h.is_empty() { String::new() } else { first.clone().unwrap_or_default() }, CharProps::default())])); + } + letter = first; + } + } + if run_in { + // Subentries follow their main entry on its line: `Main, 3: sub, 4; other, 5`. + if node.levels.len() > 1 { + continue; + } + let mut line: Line = vec![(main.clone(), CharProps::default())]; + refs(&mut line, node, &sep); + let mut first = true; + while let Some(sub) = keys.peek().filter(|k| k.len() > 1 && k.first() == key.first()).and_then(|k| tree.get(*k)) { + keys.next(); + line.push((if first { ": " } else { "; " }.into(), CharProps::default())); + first = false; + line.push((sub.levels.iter().skip(1).cloned().collect::>().join(", "), CharProps::default())); + refs(&mut line, sub, ", "); + } + lines.push((0, line)); + } else { + let mut line: Line = vec![(node.levels.last().cloned().unwrap_or_default(), CharProps::default())]; + refs(&mut line, node, &sep); + lines.push((node.levels.len().saturating_sub(1), line)); + } + } + // Entries are laid out in the index's columns. + let sects = s.doc.sections(); + let sect = sects.get(s.doc.section_index_of(i + 1)).map(|(_, x)| (*x).clone()).unwrap_or_default(); + let space = old_sect.as_ref().map_or(sect.columns.space, |x| x.columns.space); + let col_w = (sect.text_width() - space * (cols as f32 - 1.0)) / cols as f32; + let n = lines.len(); + for (k, (level, runs)) in lines.into_iter().enumerate() { + let mut para = Paragraph::new().styled(ENTRY_STYLE); + for (t, props) in runs { + let at = para.len(); + para.insert_text(at, &t, &props)?; + } + para.props.indent_left = Some(18.0 * (level as f32 + 1.0)); + para.props.indent_first = Some(-18.0); + if para.text.contains('\t') { + para.props.tabs = Some(vec![TabStop { pos: (col_w - 0.5).max(18.0), align: TabAlign::Right, leader }]); + } + s.doc.insert_block(StoryRef::Body, &Path::top(i + 1 + k), Block::Para(para))?; + } + columns(s, i, n, cols, old_sect.map(|b| *b))?; + s.touch(); + Ok(()) +} + +/// Put the `n` entries after the index field's paragraph `i` in a continuous section with `cols` +/// columns (the field's paragraph ends the section before), or merge that section back when the +/// index has one column again. +fn columns(s: &mut Session, i: usize, n: usize, cols: u32, old: Option) -> Result<(), CmdError> { + let last = i + n; + if cols > 1 && n > 0 { + // A paragraph after the index keeps the following section from being empty. + if last + 1 >= s.doc.body.len() { + s.doc.insert_block(StoryRef::Body, &Path::top(last + 1), Block::Para(Paragraph::new()))?; + } + let after = s.doc.sections().get(s.doc.section_index_of(last + 1)).map(|(_, x)| (*x).clone()).unwrap_or_default(); + let head = s.doc.para_mut(StoryRef::Body, &Path::top(i))?; + let new = head.section.is_none(); + if new { + head.section = Some(Box::new(after.clone())); + } + let mut sect = old.unwrap_or_else(|| after.clone()); + sect.columns.count = cols; + sect.columns.widths.clear(); + sect.start = SectionStart::Continuous; + sect.page_num_start = None; + let end = s.doc.para_mut(StoryRef::Body, &Path::top(last))?; + end.section = Some(Box::new(sect)); + if new { + let rest = s.doc.section_mut(last + 1); + rest.start = SectionStart::Continuous; + rest.page_num_start = None; + } + } else if old.is_some() { + // Back to one column: the sections around the index join again, with the first one's start. + let head = s.doc.para_mut(StoryRef::Body, &Path::top(i))?; + if let Some(before) = head.section.take() { + head.touch(); + let rest = s.doc.section_mut(i); + rest.start = before.start; + rest.page_num_start = before.page_num_start; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn index_lines(s: &Session) -> Vec { + s.doc.body.iter().filter_map(|b| b.as_para()).filter(|p| p.props.style.as_deref() == Some(ENTRY_STYLE)).map(|p| p.plain_text()).collect() + } + + /// #402: Mark Entry writes XE switches (subentry, bold page, cross-reference, Mark All) and + /// the INDEX field's switches change the index: run-in subentries, letter headings, a tab + /// before right-aligned page numbers, and two columns in a continuous section. + #[test] + fn index_switches_shape_the_index() { + let mut s = Session::new(wordcraft_doc::Document::from_text("Kilns are hot.\nGlaze the kilns.\nKilns again.\nPresses.")); + s.run("select.text", &json!({"text": "Kilns"})).unwrap(); + s.run("references.markEntry", &json!({"subentry": "firing", "bold": true})).unwrap(); + s.run("select.text", &json!({"text": "Presses"})).unwrap(); + s.run("references.markEntry", &json!({"crossReference": "See Kilns"})).unwrap(); + let all = s.run("references.markEntry", &json!({"entry": "Kilns", "text": "Kilns", "all": true})).unwrap(); + assert_eq!(all["marked"], 2, "the paragraphs with a whole-word, same-case match"); + let instrs: Vec = s + .doc + .body + .iter() + .filter_map(|b| b.as_para()) + .flat_map(|p| p.objects.iter()) + .filter_map(|o| match o { + InlineObject::Field { instr, .. } => Some(instr.clone()), + _ => None, + }) + .collect(); + assert!( + instrs.contains(&"XE \"Kilns:firing\" \\b".to_string()) && instrs.contains(&"XE \"Presses\" \\t \"See Kilns\"".to_string()), + "{instrs:?}" + ); + s.run("caret.docEnd", &json!({})).unwrap(); + s.run("text.newParagraph", &json!({})).unwrap(); + s.run("references.index", &json!({"type": "runIn", "rightAlign": true, "columns": 2, "tabLeader": "hyphen"})).unwrap(); + let (i, instr) = index_field(&s).unwrap(); + assert_eq!(instr, "INDEX \\h \"A\" \\c \"2\" \\e \"\t\" \\r"); + assert_eq!(index_lines(&s), vec!["K", "Kilns\t1: firing, 1", "P", "Presses. See Kilns"]); + let entry = s.doc.body[i + 2].as_para().unwrap(); + assert_eq!(entry.props.tabs.as_ref().unwrap()[0].leader, TabLeader::Hyphen); + assert!(entry.runs.iter().any(|r| r.props.bold == Some(true)), "the \\b page number is bold"); + // The entries sit in a two-column continuous section. + let last = s.doc.body[i + 4].as_para().unwrap(); + let sect = last.section.as_ref().expect("index section"); + assert_eq!((sect.columns.count, sect.start), (2, SectionStart::Continuous)); + // Indented again: subentries get lines of their own; the leader stays on update. + s.run("references.index", &json!({"columns": 1, "rightAlign": true})).unwrap(); + assert_eq!(index_lines(&s), vec!["K", "Kilns\t1", "firing\t1", "P", "Presses. See Kilns"]); + assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.section.is_none()), "one section again"); + s.run("references.updateIndex", &json!({})).unwrap(); + let (i, _) = index_field(&s).unwrap(); + assert_eq!(s.doc.body[i + 2].as_para().unwrap().props.tabs.as_ref().unwrap()[0].leader, TabLeader::Hyphen); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index fc77351276..b31eebfdb8 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -12,6 +12,7 @@ pub mod fields; pub mod file; pub mod fmt_revisions; pub mod format; +pub mod index; pub mod ink_shape; pub mod insert; pub mod inspector; @@ -66,6 +67,7 @@ pub fn registry() -> Registry { v.extend(fields::specs()); v.extend(mailings::specs()); v.extend(citations::specs()); + v.extend(index::specs()); v.extend(objects::specs()); v.extend(draw::specs()); v.extend(canvas::specs()); diff --git a/crates/engine/src/cmd/references.rs b/crates/engine/src/cmd/references.rs index 0ec7a54d01..4c28a5091e 100644 --- a/crates/engine/src/cmd/references.rs +++ b/crates/engine/src/cmd/references.rs @@ -10,8 +10,9 @@ use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; pub fn specs() -> Vec { vec![ - CommandSpec::new("references.toc", "Table of Contents", "References › Table of Contents", insert_toc) - .params(r#"{"levels"?: 1-9, "title"?: string}"#), + CommandSpec::new("references.toc", "Table of Contents", "References › Table of Contents", insert_toc).params( + r#"{"levels"?: 1-9, "title"?: string, "pageNumbers"?: bool, "rightAlign"?: bool, "tabLeader"?: "dot|hyphen|underscore|none", "hyperlinks"?: bool, "headingStyles"?: bool, "outlineLevels"?: bool, "tcFields"?: bool, "styleLevels"?: {style name: 1-9}} (replaces an existing table; {} updates it)"#, + ), CommandSpec::new("references.updateToc", "Update Table", "References › Table of Contents", |s, _| { update_toc(s)?; sel_result(s) @@ -67,7 +68,7 @@ pub fn specs() -> Vec { }) .pure(), CommandSpec::new("references.caption", "Insert Caption", "References › Captions", caption) - .params(r#"{"label"?: "Figure|Table|Equation", "text"?: string}"#), + .params(r#"{"label"?: "Figure|Table|Equation|any label", "text"?: string, "position"?: "below|above", "excludeLabel"?: bool, "format"?: "ARABIC|alphabetic|ALPHABETIC|roman|ROMAN", "chapter"?: heading level 1-9, "separator"?: "-|.|:|—|–"}"#), // F9: update fields; the selected ones that showed their code show their result again. CommandSpec::new("references.updateFields", "Update Field", "References", |s, _| { update_fields(s)?; @@ -78,8 +79,170 @@ pub fn specs() -> Vec { ] } -/// Headings (outline level < levels) in the body: (path, level, text). -fn headings(s: &Session, levels: u8) -> Vec<(Path, u8, String)> { +/// A field instruction split into its name, positional arguments and switches (ECMA-376 +/// §17.16.1): `TOC \o "1-3" \h` → name `TOC`, switches `o` = `1-3` and `h`. Quoted text stays +/// whole (without the quotes; `\"` and `\\` inside it are a quote and a backslash). A switch +/// takes the word after it as its argument unless that word is another switch. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct FieldCode { + pub name: String, + pub args: Vec, + /// Switch letter (lower-cased; `*`, `@`, `#` for the general switches) and argument. + pub switches: Vec<(char, Option)>, +} + +impl FieldCode { + pub fn parse(instr: &str) -> FieldCode { + let mut words: Vec<(String, bool)> = Vec::new(); + let mut chars = instr.chars().peekable(); + while let Some(&c) = chars.peek() { + if c.is_whitespace() { + chars.next(); + } else if c == '"' { + chars.next(); + let mut w = String::new(); + while let Some(c) = chars.next() { + match c { + '"' => break, + '\\' if matches!(chars.peek(), Some('"' | '\\')) => w.extend(chars.next()), + c => w.push(c), + } + } + words.push((w, true)); + } else { + let mut w = String::new(); + while let Some(&c) = chars.peek() { + if c.is_whitespace() || (c == '"' && !w.is_empty()) { + break; + } + w.push(c); + chars.next(); + } + words.push((w, false)); + } + } + let mut words = words.into_iter().peekable(); + let mut f = + FieldCode { name: words.next().map(|(w, _)| w.trim_start_matches('=').to_ascii_uppercase()).unwrap_or_default(), ..Default::default() }; + while let Some((w, quoted)) = words.next() { + let sw = if quoted { None } else { w.strip_prefix('\\').and_then(|r| r.chars().next()) }; + match sw { + Some(c) => { + let arg = match words.peek() { + Some((n, q)) if *q || !n.starts_with('\\') => words.next().map(|(n, _)| n), + _ => None, + }; + f.switches.push((c.to_ascii_lowercase(), arg)); + } + None => f.args.push(w), + } + } + f + } + + pub fn has(&self, c: char) -> bool { + self.switches.iter().any(|(k, _)| *k == c) + } + + /// The argument of switch `c`, if the switch is there with one. + pub fn arg(&self, c: char) -> Option<&str> { + self.switches.iter().find(|(k, _)| *k == c).and_then(|(_, a)| a.as_deref()) + } +} + +/// A level range such as `1-3` (or one level, `2`), clamped to 1–9. +pub fn level_range(s: &str) -> Option<(u8, u8)> { + let mut it = s.split('-').map(|x| x.trim().parse::().ok()); + let a = it.next().flatten()?.clamp(1, 9); + let b = match it.next() { + Some(b) => b?.clamp(1, 9), + None => a, + }; + Some((a.min(b), a.max(b))) +} + +/// The tab leader named in a command parameter. +pub fn leader(name: &str) -> TabLeader { + match name { + "none" => TabLeader::None, + "hyphen" => TabLeader::Hyphen, + "underscore" => TabLeader::Underscore, + "middleDot" => TabLeader::MiddleDot, + _ => TabLeader::Dot, + } +} + +/// The TOC field instruction for `references.toc`'s parameters (ECMA-376 §17.16.5.68): `\o` +/// heading styles, `\h` hyperlinks, `\z` hide tab and page numbers in Web view, `\u` outline +/// levels, `\f`/`\l` TC fields, `\n` without page numbers, `\p` separator, `\t` styles with levels. +pub fn toc_instr(v: &Value) -> String { + let levels = p::u64(v, "levels").unwrap_or(3).clamp(1, 9); + let on = |k: &str, d: bool| p::bool(v, k).unwrap_or(d); + let mut s = String::from("TOC"); + if on("headingStyles", true) { + s.push_str(&format!(" \\o \"1-{levels}\"")); + } + if on("hyperlinks", true) { + s.push_str(" \\h"); + } + s.push_str(" \\z"); + if on("outlineLevels", true) { + s.push_str(" \\u"); + } + if on("tcFields", false) { + s.push_str(&format!(" \\f \\l \"1-{levels}\"")); + } + if !on("pageNumbers", true) { + s.push_str(&format!(" \\n \"1-{levels}\"")); + } else if !on("rightAlign", true) { + s.push_str(" \\p \" \""); + } + let clean = |n: &str| n.chars().filter(|c| !matches!(c, '"' | ',' | '\\')).take(64).collect::().trim().to_string(); + let pairs: Vec = match v.get("styleLevels") { + Some(Value::Object(m)) => m + .iter() + .take(64) + .filter_map(|(name, l)| Some((clean(name), l.as_u64()?.clamp(1, 9)))) + .filter(|(n, _)| !n.is_empty()) + .map(|(n, l)| format!("{n},{l}")) + .collect(), + _ => Vec::new(), + }; + if !pairs.is_empty() { + s.push_str(&format!(" \\t \"{}\"", pairs.join(","))); + } + s +} + +/// A TOC entry: the paragraph it points at, its level (0-based), its text, and the leading page +/// breaks of that paragraph (its page is where the text after them lands). +struct TocEntry { + path: Path, + level: u8, + text: String, + lead: usize, +} + +/// The heading level (1–9) of a built-in heading style. +fn heading_style_level(p: &Paragraph) -> Option { + p.props.style.as_deref()?.strip_prefix("Heading")?.trim().parse::().ok().filter(|l| (1..=9).contains(l)) +} + +/// The entries a TOC field collects from the body, in document order: built-in heading styles +/// in the `\o` range, paragraphs with an outline level (`\u`), styles listed in `\t`, and TC +/// fields (`\f`, levels `\l`). +fn toc_entries(s: &Session, f: &FieldCode) -> Vec { + let o = f.has('o').then(|| f.arg('o').and_then(level_range).unwrap_or((1, 9))); + let max = o.map(|r| r.1).unwrap_or(9); + let styles: Vec<(String, u8)> = f + .arg('t') + .map(|t| { + let parts: Vec<&str> = t.split([',', ';']).map(str::trim).collect(); + parts.chunks(2).filter_map(|c| Some((c.first()?.to_string(), c.get(1)?.parse::().ok()?.clamp(1, 9)))).collect() + }) + .unwrap_or_default(); + let tc = f.has('f').then(|| f.arg('f').unwrap_or("").to_ascii_uppercase()); + let tc_levels = f.arg('l').and_then(level_range).unwrap_or((1, 9)); let mut v = Vec::new(); for path in s.doc.para_paths(StoryRef::Body) { if path.depth() > 0 { @@ -90,26 +253,51 @@ fn headings(s: &Session, levels: u8) -> Vec<(Path, u8, String)> { if style.starts_with("TOC") { continue; } - let rp = s.doc.styles.resolve_para(&p.props); - if let Some(l) = rp.outline_level - && l < levels - { + let lead = p.text.bytes().take_while(|b| *b == 0x0C).count(); + let by_style = + styles.iter().find(|(n, _)| s.doc.styles.get(style).is_some_and(|st| st.name.eq_ignore_ascii_case(n)) || n == style).map(|(_, l)| *l); + // `Some(9)` direct outline level is body text: "Do Not Show in Table of Contents". + let by_outline = f.has('u').then_some(p.props.outline_level).flatten().filter(|l| *l < 9).map(|l| l + 1).filter(|l| *l <= max); + let hidden = f.has('u') && p.props.outline_level == Some(9); + let by_heading = o.and_then(|(a, b)| { + let l = heading_style_level(p).or_else(|| { + s.doc + .styles + .resolve_para(&wordcraft_doc::ParaProps { style: p.props.style.clone(), ..Default::default() }) + .outline_level + .map(|l| l + 1) + })?; + (a..=b).contains(&l).then_some(l) + }); + if let Some(l) = by_style.or(by_outline).or(if hidden { None } else { by_heading }) { let t = p.plain_text().replace(['\u{000C}', '\n', '\t'], " ").trim().to_string(); if !t.is_empty() { - v.push((path, l, t)); + v.push(TocEntry { path: path.clone(), level: l - 1, text: t, lead }); + } + } + if let Some(id) = &tc { + for off in p.object_offsets() { + let Some(InlineObject::Field { instr, .. }) = p.object_at(off) else { continue }; + let c = FieldCode::parse(instr); + if c.name != "TC" { + continue; + } + let kind = c.arg('f').unwrap_or("C").to_ascii_uppercase(); + let l = c.arg('l').and_then(|l| l.trim().parse::().ok()).unwrap_or(1).clamp(1, 9); + let text = c.args.first().map(|t| t.trim().to_string()).unwrap_or_default(); + if (id.is_empty() || *id == kind) && (tc_levels.0..=tc_levels.1).contains(&l) && !text.is_empty() { + v.push(TocEntry { path: path.clone(), level: l - 1, text, lead: off }); + } } } } v } -/// The TOC block range in the body: the paragraph holding the TOC field and the TOC entries after it. +/// The TOC block range in the body: the paragraph holding the TOC field and the TOC entries after +/// it. A TOC with `\c` is a table of figures, kept by the generated lists. fn toc_range(s: &Session) -> Option<(usize, usize)> { - let start = s.doc.body.iter().position(|b| { - b.as_para().is_some_and(|p| { - p.objects.iter().any(|o| matches!(o, InlineObject::Field { instr, .. } if instr.trim_start().to_ascii_uppercase().starts_with("TOC"))) - }) - })?; + let start = s.doc.body.iter().position(|b| b.as_para().is_some_and(|p| toc_field(p).is_some()))?; let mut end = start; while let Some(Block::Para(p)) = s.doc.body.get(end + 1).map(|b| &**b) { if p.props.style.as_deref().is_some_and(|st| st.starts_with("TOC") && st != "TOCHeading") { @@ -121,33 +309,39 @@ fn toc_range(s: &Session) -> Option<(usize, usize)> { Some((start, end)) } -fn toc_levels(s: &Session, start: usize) -> u8 { - s.doc - .body - .get(start) - .and_then(|b| b.as_para()) - .and_then(|p| { - p.objects.iter().find_map(|o| match o { - InlineObject::Field { instr, .. } => instr - .split("\\o") - .nth(1) - .and_then(|r| r.trim().trim_matches('"').split('-').nth(1)) - .and_then(|n| n.trim_matches('"').parse::().ok()), - _ => None, - }) - }) - .unwrap_or(3) - .clamp(1, 9) +/// The TOC field instruction in `p` (not a table of figures). +fn toc_field(p: &Paragraph) -> Option { + p.objects.iter().find_map(|o| match o { + InlineObject::Field { instr, .. } => { + let f = FieldCode::parse(instr); + (f.name == "TOC" && !f.has('c')).then(|| instr.clone()) + } + _ => None, + }) } fn insert_toc(s: &mut Session, v: &Value) -> CmdResult { - if toc_range(s).is_some() { - update_toc(s)?; - return sel_result(s); - } - let levels = p::u64(v, "levels").unwrap_or(3).clamp(1, 9) as u8; + let replace = v.as_object().is_some_and(|m| !m.is_empty()); + // Replacing a table puts the new one where the old one was, in place of an empty paragraph. + let mut placeholder = false; + let at = match toc_range(s) { + // `{}` updates the table there; settings replace it where it is. + Some(_) if !replace => { + update_toc(s)?; + return sel_result(s); + } + Some((start, end)) => { + for i in (start..=end).rev() { + s.doc.remove_block(StoryRef::Body, &Path::top(i))?; + } + s.doc.insert_block(StoryRef::Body, &Path::top(start), Block::Para(Paragraph::new()))?; + placeholder = true; + s.sel = Selection::caret(Pos { story: StoryRef::Body, path: Path::top(start), off: 0 }); + s.sel.focus.clone() + } + None => delete_selection(s)?, + }; let title = p::str(v, "title").unwrap_or("Contents").to_string(); - let at = delete_selection(s)?; if at.story != StoryRef::Body || at.path.depth() > 0 { return Err(CmdError::Failed("a table of contents goes in the main text".into())); } @@ -155,28 +349,95 @@ fn insert_toc(s: &mut Session, v: &Value) -> CmdResult { let mut head = Paragraph::with_text(&title, CharProps::default()).styled("TOCHeading"); head.insert_object( head.len(), - InlineObject::Field { instr: format!("TOC \\o \"1-{levels}\" \\h \\z \\u"), result: String::new(), locked: false, code: false }, + InlineObject::Field { instr: toc_instr(v), result: String::new(), locked: false, code: false }, &CharProps::default(), )?; - let i = if s.doc.para_at(&at).is_some_and(|p| p.is_empty()) { at.path.last() } else { s.doc.split_paragraph(&at)?.path.last() }; + let i = if s.doc.para_at(&at).is_some_and(|p| p.is_empty()) { + if placeholder { + s.doc.remove_block(StoryRef::Body, &at.path)?; + } + at.path.last() + } else { + s.doc.split_paragraph(&at)?.path.last() + }; s.doc.insert_block(StoryRef::Body, &Path::top(i), Block::Para(head))?; - update_toc(s)?; + let leader = leader(p::str(v, "tabLeader").unwrap_or("dot")); + regenerate_toc(s, Some(leader))?; + s.clamp_selection(); sel_result(s) } +/// A hidden `_Toc` bookmark around the heading at body block `i` (after its leading page breaks), +/// reusing one already there; its name. +fn toc_bookmark(s: &mut Session, i: usize, lead: usize) -> Result { + const MARK: usize = wordcraft_doc::para::OBJ.len_utf8(); + let para = s.doc.para(StoryRef::Body, &Path::top(i)).ok_or_else(|| CmdError::Failed("heading is gone".into()))?; + if let Some(InlineObject::BookmarkStart { name }) = para.object_at(lead) + && name.starts_with("_Toc") + { + return Ok(name.clone()); + } + let next = + s.doc.bookmarks().iter().filter_map(|(n, _)| n.strip_prefix("_Toc").and_then(|d| d.parse::().ok())).max().unwrap_or(0).saturating_add(1); + let name = format!("_Toc{next:09}"); + let end = para.len(); + let props = CharProps::default(); + let pos = |off| Pos { story: StoryRef::Body, path: Path::top(i), off }; + s.doc.insert_object(&pos(end), InlineObject::BookmarkEnd { name: name.clone() }, &props)?; + s.doc.insert_object(&pos(lead), InlineObject::BookmarkStart { name: name.clone() }, &props)?; + // The caret in that paragraph stays on the same text. + let shift = |p: &mut Pos| { + if p.story == StoryRef::Body && p.path == Path::top(i) { + if p.off > end { + p.off = p.off.saturating_add(2 * MARK); + } else if p.off >= lead && p.off > 0 { + p.off = p.off.saturating_add(MARK); + } + } + }; + shift(&mut s.sel.anchor); + shift(&mut s.sel.focus); + Ok(name) +} + /// Regenerate the TOC entries with page numbers (two layout passes: entries change pagination). pub fn update_toc(s: &mut Session) -> Result<(), CmdError> { + regenerate_toc(s, None) +} + +/// Rebuild the entries the TOC field's switches ask for. The tab leader is `leader`, or the one +/// the entries had (Word keeps it in the entries' tab stops, not in the field). +fn regenerate_toc(s: &mut Session, leader: Option) -> Result<(), CmdError> { let Some((start, end)) = toc_range(s) else { return Ok(()) }; - let levels = toc_levels(s, start); + let Some(instr) = s.doc.body.get(start).and_then(|b| b.as_para()).and_then(toc_field) else { return Ok(()) }; + let f = FieldCode::parse(&instr); + let leader = leader.unwrap_or_else(|| { + s.doc + .body + .get(start + 1) + .filter(|_| end > start) + .and_then(|b| b.as_para()) + .and_then(|p| p.props.tabs.as_ref()?.last().map(|t| t.leader)) + .unwrap_or(TabLeader::Dot) + }); for i in (start + 1..=end).rev() { s.doc.remove_block(StoryRef::Body, &Path::top(i))?; } let width = super::page::sect(s).text_width(); - let entries = headings(s, levels); - // A heading typed after a manual page break starts with that break; its page is where the - // text after the break lands, so look the page up past any leading breaks. - let leads: Vec = - entries.iter().map(|(path, ..)| s.doc.para(StoryRef::Body, path).map_or(0, |p| p.text.bytes().take_while(|b| *b == 0x0C).count())).collect(); + let entries = toc_entries(s, &f); + let links: Vec> = if f.has('h') { + let mut v = Vec::new(); + for e in &entries { + // TC fields link to their paragraph's start. + let lead = s.doc.para(StoryRef::Body, &e.path).map_or(0, |p| p.text.bytes().take_while(|b| *b == 0x0C).count()); + v.push(Some(format!("#{}", toc_bookmark(s, e.path.last(), lead)?))); + } + v + } else { + vec![None; entries.len()] + }; + let no_pages = f.has('n').then(|| f.arg('n').and_then(level_range).unwrap_or((1, 9))); + let sep = f.arg('p').map(str::to_string).unwrap_or_else(|| "\t".into()); for pass in 0..2 { // Pass 1 lays out with the entries in place, so headings below the TOC sit `entries.len()` // blocks further down and their pages account for the TOC's own length. @@ -189,12 +450,18 @@ pub fn update_toc(s: &mut Session) -> Result<(), CmdError> { } } let shift = if pass == 0 { 0 } else { entries.len() }; - for (k, (path, level, text)) in entries.iter().enumerate() { - let target = Path::top(path.last() + if path.last() > start { shift } else { 0 }); - let off = leads.get(k).copied().unwrap_or(0); - let page = l.caret(&Pos { story: StoryRef::Body, path: target, off }).and_then(|c| l.pages.get(c.page)).map(|pg| pg.number).unwrap_or(1); - let mut para = Paragraph::with_text(&format!("{text}\t{page}"), CharProps::default()).styled(&format!("TOC{}", level + 1)); - para.props.tabs = Some(vec![TabStop { pos: width - 0.5, align: TabAlign::Right, leader: TabLeader::Dot }]); + for (k, e) in entries.iter().enumerate() { + let target = Path::top(e.path.last() + if e.path.last() > start { shift } else { 0 }); + let page = + l.caret(&Pos { story: StoryRef::Body, path: target, off: e.lead }).and_then(|c| l.pages.get(c.page)).map(|pg| pg.number).unwrap_or(1); + let show_page = no_pages.is_none_or(|(a, b)| !(a..=b).contains(&(e.level + 1))); + let text = if show_page { format!("{}{sep}{page}", e.text) } else { e.text.clone() }; + let props = CharProps { link: links.get(k).cloned().flatten(), ..Default::default() }; + let mut para = Paragraph::with_text(&text, props).styled(&format!("TOC{}", e.level + 1)); + para.mark = CharProps::default(); + if show_page && sep.contains('\t') { + para.props.tabs = Some(vec![TabStop { pos: width - 0.5, align: TabAlign::Right, leader }]); + } s.doc.insert_block(StoryRef::Body, &Path::top(start + 1 + k), Block::Para(para))?; } } @@ -337,45 +604,152 @@ fn note_refs(s: &Session) -> Vec { v } -fn caption(s: &mut Session, v: &Value) -> CmdResult { - let label = p::str(v, "label").unwrap_or("Figure").to_string(); - let text = p::str(v, "text").unwrap_or("").to_string(); - // Number = existing captions with this label before the caret + 1. - let caret = s.sel.focus.clone(); - let n = s - .doc - .para_paths(StoryRef::Body) - .into_iter() - .filter(|q| *q < caret.path) - .filter(|q| s.doc.para(StoryRef::Body, q).is_some_and(|p| p.props.style.as_deref() == Some("Caption") && p.text.starts_with(&label))) - .count() - + 1; - let f = s.sel.focus.clone(); - let len = s.doc.para_at(&f).map(|p| p.len()).unwrap_or(0); - let at = Pos { off: len, ..f }; - let new = s.doc.split_paragraph(&at)?; - let para = s.doc.para_mut(new.story, &new.path)?; - para.props = wordcraft_doc::ParaProps { style: Some("Caption".into()), ..Default::default() }; - para.mark = CharProps::default(); - let base = format!("{label} "); - para.insert_text(0, &base, &CharProps::default())?; - para.insert_object( - base.len(), - InlineObject::Field { instr: format!("SEQ {label} \\* ARABIC"), result: n.to_string(), locked: false, code: false }, - &CharProps::default(), - )?; - let end = para.len(); - if !text.is_empty() { - para.insert_text(end, &format!(": {text}"), &CharProps::default())?; +/// The `\*` format switch value for a caption's number format (OOXML names work too). +pub fn seq_format(name: &str) -> &'static str { + match name { + "alphabetic" | "lowerLetter" => "alphabetic", + "ALPHABETIC" | "upperLetter" => "ALPHABETIC", + "roman" | "lowerRoman" => "roman", + "ROMAN" | "upperRoman" => "ROMAN", + _ => "ARABIC", } - let off = para.len(); - s.sel = Selection::caret(Pos { off, ..new }); +} + +/// Separators Word offers between a chapter number and a caption number. +pub const CHAPTER_SEPARATORS: [&str; 5] = ["-", ".", ":", "\u{2014}", "\u{2013}"]; + +/// Insert a caption paragraph below (or above) the caret's paragraph, table or object: the label, +/// a chapter number (`STYLEREF n \s` and a separator) and a `SEQ label \* format \s n` number +/// (ECMA-376 §17.16.5.56, §17.16.5.59). +fn caption(s: &mut Session, v: &Value) -> CmdResult { + let label: String = p::str(v, "label").unwrap_or("Figure").chars().filter(|c| !matches!(c, '"' | '\\') && !c.is_control()).take(40).collect(); + let label = if label.trim().is_empty() { "Figure".to_string() } else { label.trim().to_string() }; + // SEQ identifiers are one word: Word joins a multi-word label with underscores. + let ident = label.split_whitespace().collect::>().join("_"); + let text = p::str(v, "text").unwrap_or("").to_string(); + let above = p::str(v, "position") == Some("above"); + let format = seq_format(p::str(v, "format").unwrap_or("ARABIC")); + let chapter = p::u64(v, "chapter").filter(|l| (1..=9).contains(l)); + let sep = p::str(v, "separator").and_then(|x| CHAPTER_SEPARATORS.iter().find(|s| **s == x)).copied().unwrap_or("-"); + let mut para = Paragraph::new().styled("Caption"); + let field = |instr: String| InlineObject::Field { instr, result: String::new(), locked: false, code: false }; + if !p::bool(v, "excludeLabel").unwrap_or(false) { + para.insert_text(0, &format!("{label} "), &CharProps::default())?; + } + let mut seq = format!("SEQ {ident} \\* {format}"); + if let Some(l) = chapter { + para.insert_object(para.len(), field(format!("STYLEREF {l} \\s")), &CharProps::default())?; + para.insert_text(para.len(), sep, &CharProps::default())?; + seq.push_str(&format!(" \\s {l}")); + } + para.insert_object(para.len(), field(seq), &CharProps::default())?; + if !text.is_empty() { + para.insert_text(para.len(), &format!(": {text}"), &CharProps::default())?; + } + // Next to the caret's top-level block: a paragraph, or the whole table it is in. + let f = s.sel.focus.clone(); + let top = f.path.0.first().copied().unwrap_or(0) as usize; + let i = if above { top } else { top.saturating_add(1) }; + s.doc.insert_block(f.story, &Path::top(i), Block::Para(para))?; + update_seq(s)?; + let off = s.doc.para(f.story, &Path::top(i)).map_or(0, |p| p.len()); + s.sel = Selection::caret(Pos { story: f.story, path: Path::top(i), off }); sel_result(s) } +/// The number part of a heading's list label (`Chapter 2.` → `2`). +fn chapter_number(label: &str) -> String { + let word = label.split_whitespace().last().unwrap_or(""); + word.trim_matches(|c: char| !c.is_alphanumeric()).to_string() +} + +/// Renumber the body's SEQ fields in document order, honouring `\* format`, `\s level` (restart +/// after each heading of that level), `\r n` (reset to n), `\c` (repeat) and `\h` (hide), and +/// fill chapter-number `STYLEREF n \s` fields with the last level-n heading's number (its list +/// label, or its count when the heading isn't numbered). +pub fn update_seq(s: &mut Session) -> Result<(), CmdError> { + let mut counters = wordcraft_doc::numbering::Counters::default(); + let mut headings = [0u32; 9]; + let mut chapter: [String; 9] = Default::default(); + let mut heading_text: [String; 9] = Default::default(); + let mut seq: std::collections::HashMap = Default::default(); + for path in s.doc.para_paths(StoryRef::Body) { + let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue }; + let label = p + .props + .numbering + .or_else(|| s.doc.styles.resolve_para(&p.props).numbering) + .filter(|n| n.num != 0) + .and_then(|n| counters.next_label(&s.doc.numbering, n.num, n.level)) + .map(|(l, _)| l); + if let Some(l) = heading_style_level(p) + && let Some(k) = (l as usize).checked_sub(1) + && let Some(count) = headings.get_mut(k) + { + *count = count.saturating_add(1); + let n = label.map(|l| chapter_number(&l)).filter(|n| !n.is_empty()).unwrap_or_else(|| count.to_string()); + if let Some(c) = chapter.get_mut(k) { + *c = n; + } + if let Some(t) = heading_text.get_mut(k) { + *t = p.plain_text().trim().to_string(); + } + } + let mut updates: Vec<(usize, String)> = Vec::new(); + for (k, o) in p.objects.iter().enumerate() { + let InlineObject::Field { instr, locked: false, .. } = o else { continue }; + let f = FieldCode::parse(instr); + match f.name.as_str() { + "SEQ" => { + let Some(id) = f.args.first() else { continue }; + let restart = + f.arg('s').and_then(|l| l.parse::().ok()).and_then(|l| l.checked_sub(1)).and_then(|l| headings.get(l)).copied(); + let n = seq.entry(id.clone()).or_insert((0, 0)); + if let Some(epoch) = restart + && n.1 != epoch + { + *n = (0, epoch); + } + if let Some(r) = f.arg('r').and_then(|r| r.parse::().ok()) { + n.0 = r; + } else if !f.has('c') { + n.0 = n.0.saturating_add(1); + } + let fmt = wordcraft_layout::fields::format_switch(instr).unwrap_or_default(); + updates.push((k, if f.has('h') { String::new() } else { fmt.format(n.0) })); + } + "STYLEREF" => { + let level = f + .args + .first() + .and_then(|a| a.strip_prefix("Heading").unwrap_or(a).trim().parse::().ok()) + .and_then(|l| l.checked_sub(1)); + let Some(level) = level else { continue }; + let r = if f.has('s') || f.has('n') { chapter.get(level) } else { heading_text.get(level) }; + if let Some(r) = r { + updates.push((k, r.clone())); + } + } + _ => {} + } + } + if updates.is_empty() { + continue; + } + let para = s.doc.para_mut(StoryRef::Body, &path)?; + for (k, r) in updates { + if let Some(InlineObject::Field { result, .. }) = para.objects.get_mut(k) { + *result = r; + } + } + para.touch(); + } + Ok(()) +} + /// Update fields in the body: dates, SEQ numbering, cross-references, TOC. pub fn update_fields(s: &mut Session) -> Result<(), CmdError> { - let mut seq: std::collections::HashMap = Default::default(); + update_seq(s)?; let mut pages: std::collections::HashMap = Default::default(); for (name, pos) in s.doc.bookmarks() { let l = s.layout(); @@ -396,12 +770,6 @@ pub fn update_fields(s: &mut Session) -> Result<(), CmdError> { let pic = date_picture(instr).unwrap_or_else(|| if name == "TIME" { "h:mm am/pm".into() } else { "M/d/yyyy".into() }); updates.push((k, super::insert::format_date(&pic))); } - "SEQ" => { - let id = instr.split_whitespace().nth(1).unwrap_or("").to_string(); - let n = seq.entry(id).or_insert(0); - *n += 1; - updates.push((k, n.to_string())); - } "REF" | "PAGEREF" => { let target = instr.split_whitespace().nth(1).unwrap_or(""); let r = if name == "REF" { bookmark_text(&s.doc, target) } else { pages.get(target).map(u32::to_string) }; @@ -452,3 +820,83 @@ pub fn bookmark_text(doc: &wordcraft_doc::Document, name: &str) -> Option Vec<(String, String)> { + let mut out = Vec::new(); + for path in s.doc.para_paths(StoryRef::Body) { + for o in &s.doc.para(StoryRef::Body, &path).unwrap().objects { + if let InlineObject::Field { instr, result, .. } = o { + out.push((instr.clone(), result.clone())); + } + } + } + out + } + + /// #402: Custom Table of Contents settings become TOC switches, and updating honours them: + /// levels (`\o`), a style with a level (`\t`), no hyperlinks, page numbers after a space + /// (`\p`), or none at all (`\n`). + #[test] + fn toc_switches_shape_the_entries() { + let mut s = Session::new(wordcraft_doc::Document::from_text("Intro\nPart\nDeep\nNote")); + for (text, style) in [("Intro", "Heading 1"), ("Part", "Heading 2"), ("Deep", "Heading 3"), ("Note", "Quote")] { + s.run("select.text", &json!({"text": text})).unwrap(); + s.run("styles.apply", &json!({"style": style})).unwrap(); + } + s.run("caret.docStart", &json!({})).unwrap(); + s.run("references.toc", &json!({"levels": 2, "hyperlinks": false, "rightAlign": false, "styleLevels": {"Quote": 2}})).unwrap(); + assert_eq!(fields(&s)[0].0, "TOC \\o \"1-2\" \\z \\u \\p \" \" \\t \"Quote,2\""); + let entries: Vec<(String, String)> = s + .doc + .body + .iter() + .filter_map(|b| b.as_para()) + .filter(|p| p.props.style.as_deref().is_some_and(|st| st.starts_with("TOC") && st != "TOCHeading")) + .map(|p| (p.props.style.clone().unwrap(), p.plain_text())) + .collect(); + assert_eq!(entries, [("TOC1", "Intro 1"), ("TOC2", "Part 1"), ("TOC2", "Note 1")].map(|(a, b)| (a.to_string(), b.to_string()))); + assert!(s.doc.bookmarks().is_empty(), "no hyperlinks, no _Toc bookmarks"); + // Settings again replace the table in place; hyperlinks bookmark the headings. + s.run("references.toc", &json!({"levels": 3, "pageNumbers": false})).unwrap(); + assert_eq!(s.doc.plain_text(StoryRef::Body).matches("Contents").count(), 1); + let toc: Vec = + s.doc.body.iter().filter_map(|b| b.as_para()).filter(|p| p.props.style.as_deref() == Some("TOC3")).map(|p| p.plain_text()).collect(); + assert_eq!(toc, ["Deep"]); + let first = s.doc.body.iter().filter_map(|b| b.as_para()).find(|p| p.props.style.as_deref() == Some("TOC1")).unwrap(); + let link = first.runs[0].props.link.clone().unwrap(); + assert!(s.doc.bookmarks().iter().any(|(n, _)| format!("#{n}") == link), "{link}"); + } + + /// #402: Caption numbering — a custom label above the paragraph, letters, and chapter + /// numbers that restart after each Heading 1 (`STYLEREF 1 \s`, `SEQ … \s 1`). + #[test] + fn caption_numbering_with_chapters() { + let mut s = Session::new(wordcraft_doc::Document::from_text("One\nPic\nPic two\nTwo\nPic three")); + for t in ["One", "Two"] { + s.run("select.text", &json!({"text": t})).unwrap(); + s.run("styles.apply", &json!({"style": "Heading 1"})).unwrap(); + } + let opts = json!({"label": "Photo Plate", "format": "upperLetter", "chapter": 1, "separator": "."}); + for t in ["Pic three", "Pic two", "Pic"] { + s.run("select.text", &json!({"text": t})).unwrap(); + let mut v = opts.clone(); + v["position"] = json!("above"); + v["text"] = json!(t); + s.run("references.caption", &v).unwrap(); + } + let caps: Vec = + s.doc.body.iter().filter_map(|b| b.as_para()).filter(|p| p.props.style.as_deref() == Some("Caption")).map(|p| p.plain_text()).collect(); + assert_eq!(caps, ["Photo Plate 1.A: Pic", "Photo Plate 1.B: Pic two", "Photo Plate 2.A: Pic three"]); + assert!(fields(&s).contains(&("SEQ Photo_Plate \\* ALPHABETIC \\s 1".to_string(), "A".to_string())), "{:?}", fields(&s)); + // The caption sits above its paragraph; excluding the label leaves the number. + let i = s.doc.body.iter().position(|b| b.as_para().is_some_and(|p| p.plain_text() == "Pic")).unwrap(); + assert_eq!(s.doc.body[i - 1].as_para().unwrap().plain_text(), "Photo Plate 1.A: Pic"); + s.run("caret.docEnd", &json!({})).unwrap(); + s.run("references.caption", &json!({"label": "Table", "excludeLabel": true, "format": "ROMAN"})).unwrap(); + assert_eq!(s.doc.body.last().unwrap().as_para().unwrap().plain_text(), "I"); + } +} diff --git a/crates/layout/src/fields.rs b/crates/layout/src/fields.rs index e703f8dd8c..f202391f50 100644 --- a/crates/layout/src/fields.rs +++ b/crates/layout/src/fields.rs @@ -55,7 +55,7 @@ pub fn field_name(instr: &str) -> String { } /// `\* FORMAT` switch value (Arabic, roman, ROMAN, alphabetic, ALPHABETIC, CardText, Ordinal…). -fn format_switch(instr: &str) -> Option { +pub fn format_switch(instr: &str) -> Option { let mut it = instr.split_whitespace(); while let Some(w) = it.next() { if w == "\\*" { diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 2de0290c88..af8e9e4206 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -2638,6 +2638,6 @@ mod tests; #[cfg(test)] mod tests_bidi; #[cfg(test)] -mod tests_textbox; -#[cfg(test)] mod tests_grid; +#[cfg(test)] +mod tests_textbox; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index ff6f01b3be..071b945be6 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -134,6 +134,9 @@ pub enum Dialog { ModifyStyle { #[serde(skip)] back: bool, + /// The dialog that comes back when this closes (Table of Contents › Modify…). + #[serde(skip)] + resume: Option>, id: String, name: String, font: String, @@ -170,6 +173,9 @@ pub enum Dialog { }, /// Columns, Symbol and Field ([`crate::dialogs_insert`], #321). Insert(Box), + /// Caption, Index, Mark Index Entry, Table of Contents, Source Manager and Create / Edit + /// Source ([`crate::dialogs_refs`], #402). + Refs(Box), /// Insert › Icons: the icon library, searchable and filtered by category (empty for all). Icons { query: String, @@ -705,6 +711,7 @@ impl Dialog { Dialog::TableStyle { .. } => "modifyTableStyle", Dialog::Commands { .. } => "commands", Dialog::Insert(d) => d.name(), + Dialog::Refs(d) => d.name(), Dialog::Icons { .. } => "icons", Dialog::PasteSpecial { .. } => "pasteSpecial", Dialog::About { .. } => "about", @@ -751,6 +758,9 @@ impl Dialog { if let Some(d) = crate::dialogs_insert::open(name, app) { return Some(Dialog::Insert(Box::new(d))); } + if let Some(d) = crate::dialogs_refs::open(name, app) { + return Some(Dialog::Refs(Box::new(d))); + } let st = app.session.run("format.state", &json!({})).unwrap_or_default(); let s = |k: &str| st.get(k).and_then(Value::as_str).unwrap_or("").to_string(); let b = |k: &str| st.get(k).and_then(Value::as_bool).unwrap_or(false); @@ -967,6 +977,7 @@ impl Dialog { let rp = app.session.doc.styles.resolve_para(&wordcraft_doc::ParaProps { style: Some(id.into()), ..Default::default() }); Some(Dialog::ModifyStyle { back: false, + resume: None, id: id.into(), name: st.name.clone(), font: rc.font, @@ -1080,6 +1091,7 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) { Dialog::TableStyle { .. } => "Modify Table Style", Dialog::Commands { .. } => "Search Commands", Dialog::Insert(d) => d.title(), + Dialog::Refs(d) => d.title(), Dialog::Icons { .. } => "Icons", Dialog::PasteSpecial { .. } => "Paste Special", Dialog::About { .. } => "About WordCraft", @@ -1687,7 +1699,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { } ok || cancel } - Dialog::ModifyStyle { back, id, name, font, size, bold, italic, color, before, after } => { + Dialog::ModifyStyle { back, resume, id, name, font, size, bold, italic, color, before, after } => { egui::Grid::new("ms").num_columns(2).show(ui, |ui| { ui.label(tl!("Name:")); ui.text_edit_singleline(name); @@ -1724,6 +1736,11 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { if (ok || cancel) && *back { app.dialog = Some(Dialog::ManageStyles { alphabetical: false, selected: id.clone() }); } + if (ok || cancel) + && let Some(r) = resume.take() + { + app.dialog = Some(*r); + } ok || cancel } Dialog::ManageStyles { alphabetical, selected } => manage_styles(app, ui, alphabetical, selected), @@ -1819,6 +1836,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { ok || cancel } Dialog::Insert(f) => crate::dialogs_insert::body(app, ui, f), + Dialog::Refs(f) => crate::dialogs_refs::body(app, ui, f), Dialog::Commands { query } => { let r = ui.add(egui::TextEdit::singleline(query).hint_text(tl!("Type a command, e.g. \"insert table\"")).desired_width(380.0)); r.request_focus(); @@ -2461,7 +2479,7 @@ fn manage_styles(app: &mut WordApp, ui: &mut Ui, alphabetical: &mut bool, select if let Some(Dialog::ModifyStyle { id, name, font, size, bold, italic, color, before, after, .. }) = Dialog::modify_style(app, selected) { - app.dialog = Some(Dialog::ModifyStyle { back: true, id, name, font, size, bold, italic, color, before, after }); + app.dialog = Some(Dialog::ModifyStyle { back: true, resume: None, id, name, font, size, bold, italic, color, before, after }); } } } @@ -2498,7 +2516,7 @@ fn manage_styles(app: &mut WordApp, ui: &mut Ui, alphabetical: &mut bool, select if ui.add_enabled(ty != "table", egui::Button::new(tl!("Modify…"))).clicked() && let Some(Dialog::ModifyStyle { id, name, font, size, bold, italic, color, before, after, .. }) = Dialog::modify_style(app, selected) { - app.dialog = Some(Dialog::ModifyStyle { back: true, id, name, font, size, bold, italic, color, before, after }); + app.dialog = Some(Dialog::ModifyStyle { back: true, resume: None, id, name, font, size, bold, italic, color, before, after }); } if ui.button(tl!("New Style…")).clicked() { app.dialog = Some(Dialog::NewStyle { name: "Style1".into(), based_on: str_of(&cur, "name"), back: true }); diff --git a/crates/ui-egui/src/dialogs_refs.rs b/crates/ui-egui/src/dialogs_refs.rs new file mode 100644 index 0000000000..b161ae46e1 --- /dev/null +++ b/crates/ui-egui/src/dialogs_refs.rs @@ -0,0 +1,1183 @@ +//! References dialogs (#402): Caption (with New Label and Numbering), Index and Mark Index Entry, +//! Custom Table of Contents (with Options and the TOC styles), and the Source Manager with +//! Create / Edit Source. Each fills a form from the session and ends in a command +//! (`references.caption`, `references.index`, `references.markEntry`, `references.toc`, +//! `references.sources`, `references.citation`), so agents get the same result without the +//! dialog. Shown through [`crate::dialogs::Dialog::Refs`]. + +use egui::{Sense, Ui, vec2}; +use serde::Serialize; +use serde_json::{Value, json}; +use wordcraft_doc::{Block, Source, StyleKind}; +use wordcraft_engine::cmd::references::FieldCode; + +use crate::WordApp; +use crate::dialogs::{Dialog, buttons}; +use crate::theme::{Tokens, semibold}; + +#[derive(Clone, Debug, Serialize)] +#[serde(tag = "refs", rename_all = "camelCase")] +pub enum RefsDialog { + Caption(CaptionForm), + /// Caption › Numbering…; `was` comes back on Cancel. + CaptionNumbering { + form: CaptionForm, + #[serde(skip)] + was: Box, + }, + /// Caption › New Label…. + NewLabel(CaptionForm), + Index(IndexForm), + MarkEntry(MarkForm), + Toc(TocForm), + /// Table of Contents › Options…; `was` comes back on Cancel. + TocOptions { + form: TocForm, + #[serde(skip)] + was: Box, + }, + /// Table of Contents › Modify…: the TOC 1–9 styles. + TocStyles { + form: TocForm, + level: u8, + }, + Sources { + selected: Option, + }, + Source(Box), +} + +impl RefsDialog { + pub fn name(&self) -> &'static str { + match self { + RefsDialog::Caption(_) => "caption", + RefsDialog::CaptionNumbering { .. } => "captionNumbering", + RefsDialog::NewLabel(_) => "newLabel", + RefsDialog::Index(_) => "index", + RefsDialog::MarkEntry(_) => "markEntry", + RefsDialog::Toc(_) => "toc", + RefsDialog::TocOptions { .. } => "tocOptions", + RefsDialog::TocStyles { .. } => "tocStyles", + RefsDialog::Sources { .. } => "sourceManager", + RefsDialog::Source(f) if f.edit => "editSource", + RefsDialog::Source(_) => "createSource", + } + } + + /// The window title (English; the caller translates it). + pub fn title(&self) -> &'static str { + match self { + RefsDialog::Caption(_) => "Caption", + RefsDialog::CaptionNumbering { .. } => "Caption Numbering", + RefsDialog::NewLabel(_) => "New Label", + RefsDialog::Index(_) => "Index", + RefsDialog::MarkEntry(_) => "Mark Index Entry", + RefsDialog::Toc(_) => "Table of Contents", + RefsDialog::TocOptions { .. } => "Table of Contents Options", + RefsDialog::TocStyles { .. } => "Table of Contents Styles", + RefsDialog::Sources { .. } => "Source Manager", + RefsDialog::Source(f) if f.edit => "Edit Source", + RefsDialog::Source(_) => "Create Source", + } + } +} + +/// The dialog `ui.dialog` opens by `name`: `caption`, `index`, `markEntry`, `toc`, +/// `sourceManager` or `createSource` (which inserts a citation of the new source). +pub fn open(name: &str, app: &WordApp) -> Option { + Some(match name { + "caption" => RefsDialog::Caption(CaptionForm::read(app)), + "index" => RefsDialog::Index(IndexForm::read(app)), + "markEntry" => RefsDialog::MarkEntry(MarkForm::read(app)), + "toc" => RefsDialog::Toc(TocForm::read(app)), + "sourceManager" => RefsDialog::Sources { selected: None }, + "createSource" => RefsDialog::Source(Box::new(SourceForm::new(true, false))), + _ => return None, + }) +} + +/// Draws the dialog; returns true to close it. +pub fn body(app: &mut WordApp, ui: &mut Ui, d: &mut RefsDialog) -> bool { + let next = match d { + RefsDialog::Caption(f) => caption_ui(app, ui, f), + RefsDialog::CaptionNumbering { form, was } => numbering_ui(ui, form, was), + RefsDialog::NewLabel(f) => new_label_ui(app, ui, f), + RefsDialog::Index(f) => index_ui(app, ui, f), + RefsDialog::MarkEntry(f) => mark_ui(app, ui, f), + RefsDialog::Toc(f) => toc_ui(app, ui, f), + RefsDialog::TocOptions { form, was } => toc_options_ui(app, ui, form, was), + RefsDialog::TocStyles { form, level } => toc_styles_ui(app, ui, form, level), + RefsDialog::Sources { selected } => sources_ui(app, ui, selected), + RefsDialog::Source(f) => source_ui(app, ui, f), + }; + match next { + Next::Stay => false, + Next::Close => true, + Next::Go(n) => { + *d = *n; + false + } + } +} + +fn go(d: RefsDialog) -> Next { + Next::Go(Box::new(d)) +} + +/// What a dialog does after a frame. +enum Next { + Stay, + Close, + Go(Box), +} + +fn heading(ui: &mut Ui, s: &str) { + ui.add_space(4.0); + ui.label(egui::RichText::new(tl!(s)).font(semibold(12.5))); +} + +/// A choice from `options` ((value, label)) in a drop-down. +fn choice(ui: &mut Ui, id: &str, value: &mut T, options: &[(T, &str)]) { + let label = options.iter().find(|(v, _)| v == value).map(|(_, l)| tl!(l).to_string()).unwrap_or_default(); + egui::ComboBox::from_id_salt(id).selected_text(label).width(150.0).show_ui(ui, |ui| { + for (v, l) in options { + ui.selectable_value(value, v.clone(), tl!(l)); + } + }); +} + +const LEADERS: [(&str, &str); 4] = [("none", "(none)"), ("dot", "........"), ("hyphen", "--------"), ("underscore", "________")]; + +fn leader_char(l: &str) -> Option { + match l { + "dot" => Some('.'), + "hyphen" => Some('-'), + "underscore" => Some('_'), + _ => None, + } +} + +/// Sample lines on a white card: (indent level, text, page or empty), with `leader` filling up +/// to right-aligned page numbers (or the page right after the text). +fn sample(ui: &mut Ui, lines: &[(usize, String, String)], right: bool, leader: Option) { + let t = Tokens::get(ui.ctx()); + let row = 17.0; + let (rect, _) = ui.allocate_exact_size(vec2(260.0, row * lines.len().max(1) as f32 + 10.0), Sense::hover()); + let p = ui.painter_at(rect); + p.rect_filled(rect, 2.0, egui::Color32::WHITE); + p.rect_stroke(rect, 2.0, egui::Stroke::new(1.0, t.border_strong), egui::StrokeKind::Inside); + let font = egui::FontId::proportional(11.0); + let ink = egui::Color32::BLACK; + for (i, (level, text, page)) in lines.iter().enumerate() { + let y = rect.top() + 5.0 + row * i as f32; + let x = rect.left() + 8.0 + 12.0 * *level as f32; + let g = p.layout_no_wrap(text.clone(), font.clone(), ink); + let w = g.size().x; + p.galley(egui::pos2(x, y), g, ink); + if page.is_empty() { + continue; + } + let pg = p.layout_no_wrap(page.clone(), font.clone(), ink); + let pw = pg.size().x; + let px = if right { rect.right() - 8.0 - pw } else { x + w + 4.0 }; + p.galley(egui::pos2(px, y), pg, ink); + if right && let Some(c) = leader { + let dots = p.layout_no_wrap(c.to_string(), font.clone(), egui::Color32::from_gray(90)); + let step = dots.size().x.max(2.0); + let mut dx = x + w + 4.0; + while dx + step < px - 3.0 { + p.galley(egui::pos2(dx, y), dots.clone(), egui::Color32::from_gray(90)); + dx += step; + } + } + } +} + +// --------------------------------------------------------------------------------------------- +// Caption + +/// Labels every document has. +const BUILT_IN_LABELS: [&str; 3] = ["Equation", "Figure", "Table"]; + +const NUMBER_FORMATS: [(&str, &str); 5] = + [("ARABIC", "1, 2, 3, …"), ("alphabetic", "a, b, c, …"), ("ALPHABETIC", "A, B, C, …"), ("roman", "i, ii, iii, …"), ("ROMAN", "I, II, III, …")]; + +const SEPARATORS: [(&str, &str); 5] = + [("-", "- (hyphen)"), (".", ". (period)"), (":", ": (colon)"), ("\u{2014}", "\u{2014} (em dash)"), ("\u{2013}", "\u{2013} (en dash)")]; + +/// The Caption dialog's fields. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CaptionForm { + pub label: String, + /// Built-in labels and the user's own (kept with the interface settings, like Word's + /// Normal template keeps them). + pub labels: Vec, + pub text: String, + /// `below` or `above`. + pub position: String, + pub exclude_label: bool, + /// `\*` format: `ARABIC`, `alphabetic`, `ALPHABETIC`, `roman`, `ROMAN`. + pub format: String, + pub chapter: bool, + /// Heading level that starts a chapter (1–9). + pub chapter_level: u8, + pub separator: String, + /// New Label's text. + pub new_label: String, +} + +impl CaptionForm { + pub fn read(app: &WordApp) -> CaptionForm { + let mut labels: Vec = BUILT_IN_LABELS.iter().map(|l| l.to_string()).collect(); + for l in &app.ui.caption_labels { + if !labels.contains(l) { + labels.push(l.clone()); + } + } + // In a table the caption is for the table. + let in_table = app.session.sel.focus.path.depth() > 0 + && app + .session + .doc + .body + .get(app.session.sel.focus.path.0.first().copied().unwrap_or(0) as usize) + .is_some_and(|b| matches!(&**b, Block::Table(_))); + CaptionForm { + label: if in_table { "Table" } else { "Figure" }.into(), + labels, + text: String::new(), + position: "below".into(), + exclude_label: false, + format: "ARABIC".into(), + chapter: false, + chapter_level: 1, + separator: "-".into(), + new_label: String::new(), + } + } + + /// The number as it will show (`Figure 1-A`). + pub fn preview(&self) -> String { + let n = match self.format.as_str() { + "alphabetic" => "a", + "ALPHABETIC" => "A", + "roman" => "i", + "ROMAN" => "I", + _ => "1", + }; + let chapter = if self.chapter { format!("1{}", self.separator) } else { String::new() }; + let label = if self.exclude_label { String::new() } else { format!("{} ", self.label) }; + format!("{label}{chapter}{n}") + } + + /// `references.caption` parameters. + pub fn params(&self) -> Value { + let mut v = json!({ + "label": self.label, + "text": self.text.trim(), + "position": self.position, + "excludeLabel": self.exclude_label, + "format": self.format, + }); + if self.chapter { + v["chapter"] = json!(self.chapter_level); + v["separator"] = json!(self.separator); + } + v + } +} + +fn caption_ui(app: &mut WordApp, ui: &mut Ui, f: &mut CaptionForm) -> Next { + ui.label(tl!("Caption:")); + ui.horizontal(|ui| { + ui.label(f.preview()); + ui.add(egui::TextEdit::singleline(&mut f.text).desired_width(220.0).hint_text(tl!("Caption text"))); + }); + ui.add_space(6.0); + let mut then = None; + egui::Grid::new("cap_opts").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + ui.label(tl!("Label:")); + let labels: Vec<(String, &str)> = f.labels.iter().map(|l| (l.clone(), l.as_str())).collect(); + egui::ComboBox::from_id_salt("cap_label").selected_text(tl!(&f.label)).width(150.0).show_ui(ui, |ui| { + for (v, l) in &labels { + ui.selectable_value(&mut f.label, v.clone(), tl!(l)); + } + }); + ui.end_row(); + ui.label(tl!("Position:")); + choice(ui, "cap_pos", &mut f.position, &[("below".to_string(), "Below the item"), ("above".to_string(), "Above the item")]); + ui.end_row(); + }); + ui.checkbox(&mut f.exclude_label, tl!("Leave the label out of the caption")); + ui.add_space(4.0); + ui.horizontal(|ui| { + if ui.button(tl!("New Label…")).clicked() { + let mut n = f.clone(); + n.new_label.clear(); + then = Some(RefsDialog::NewLabel(n)); + } + let custom = !BUILT_IN_LABELS.contains(&f.label.as_str()); + if ui.add_enabled(custom, egui::Button::new(tl!("Delete Label"))).clicked() { + app.ui.caption_labels.retain(|l| *l != f.label); + f.labels.retain(|l| *l != f.label); + f.label = "Figure".into(); + } + if ui.button(tl!("Numbering…")).clicked() { + then = Some(RefsDialog::CaptionNumbering { was: Box::new(f.clone()), form: f.clone() }); + } + }); + if let Some(g) = then { + return go(g); + } + let (ok, cancel) = buttons(ui, tl!("OK")); + if ok && let Err(e) = app.run("references.caption", f.params()) { + app.status(e); + } + if ok || cancel { Next::Close } else { Next::Stay } +} + +fn numbering_ui(ui: &mut Ui, f: &mut CaptionForm, was: &CaptionForm) -> Next { + egui::Grid::new("cap_num").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + ui.label(tl!("Format:")); + let opts: Vec<(String, &str)> = NUMBER_FORMATS.iter().map(|(v, l)| (v.to_string(), *l)).collect(); + choice(ui, "cap_fmt", &mut f.format, &opts); + ui.end_row(); + }); + ui.checkbox(&mut f.chapter, tl!("Add the chapter number")); + ui.add_enabled_ui(f.chapter, |ui| { + egui::Grid::new("cap_chap").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + ui.label(tl!("Chapters begin at:")); + let opts: Vec<(u8, String)> = (1..=9u8).map(|l| (l, crate::i18n::fmt(tl!("Heading {n}"), &[("n", &l.to_string())]))).collect(); + let shown = opts.iter().find(|(l, _)| *l == f.chapter_level).map(|(_, s)| s.clone()).unwrap_or_default(); + egui::ComboBox::from_id_salt("cap_level").selected_text(shown).width(150.0).show_ui(ui, |ui| { + for (l, s) in &opts { + ui.selectable_value(&mut f.chapter_level, *l, s); + } + }); + ui.end_row(); + ui.label(tl!("Separator:")); + let opts: Vec<(String, &str)> = SEPARATORS.iter().map(|(v, l)| (v.to_string(), *l)).collect(); + choice(ui, "cap_sep", &mut f.separator, &opts); + ui.end_row(); + }); + }); + ui.add_space(4.0); + ui.label(crate::i18n::fmt(tl!("Example: {sample}"), &[("sample", &f.preview())])); + let (ok, cancel) = buttons(ui, tl!("OK")); + if ok { + return go(RefsDialog::Caption(f.clone())); + } + if cancel { + return go(RefsDialog::Caption(was.clone())); + } + Next::Stay +} + +fn new_label_ui(app: &mut WordApp, ui: &mut Ui, f: &mut CaptionForm) -> Next { + ui.label(tl!("Label:")); + let r = ui.add(egui::TextEdit::singleline(&mut f.new_label).desired_width(260.0)); + r.request_focus(); + let (ok, cancel) = buttons(ui, tl!("OK")); + let name: String = f.new_label.chars().filter(|c| !matches!(c, '"' | '\\') && !c.is_control()).take(40).collect::().trim().to_string(); + if ok && !name.is_empty() { + if !f.labels.contains(&name) { + f.labels.push(name.clone()); + } + if !BUILT_IN_LABELS.contains(&name.as_str()) && !app.ui.caption_labels.contains(&name) { + app.ui.caption_labels.push(name.clone()); + } + f.label = name; + } + if ok || cancel { go(RefsDialog::Caption(f.clone())) } else { Next::Stay } +} + +// --------------------------------------------------------------------------------------------- +// Index and Mark Index Entry + +/// The Index dialog's fields. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct IndexForm { + /// `indented` or `runIn`. + pub kind: String, + pub columns: u32, + pub right_align: bool, + pub leader: String, + pub headings: bool, +} + +/// The first field in the body whose name is `name`. +fn body_field(app: &WordApp, name: &str) -> Option { + app.session.doc.body.iter().filter_map(|b| b.as_para()).flat_map(|p| p.objects.iter()).find_map(|o| match o { + wordcraft_doc::InlineObject::Field { instr, .. } => { + Some(FieldCode::parse(instr)).filter(|f| f.name == name && (name != "TOC" || !f.has('c'))) + } + _ => None, + }) +} + +impl IndexForm { + /// The index in the document, or Word's defaults for a new one (two columns). + pub fn read(app: &WordApp) -> IndexForm { + match body_field(app, "INDEX") { + Some(f) => IndexForm { + kind: if f.has('r') { "runIn" } else { "indented" }.into(), + columns: f.arg('c').and_then(|c| c.trim().parse().ok()).unwrap_or(1).clamp(1, 4), + right_align: f.arg('e').is_some_and(|e| e.contains('\t')), + leader: "dot".into(), + headings: f.has('h'), + }, + None => IndexForm { kind: "indented".into(), columns: 2, right_align: false, leader: "dot".into(), headings: true }, + } + } + + /// `references.index` parameters. + pub fn params(&self) -> Value { + json!({"type": self.kind, "columns": self.columns, "rightAlign": self.right_align, "tabLeader": self.leader, "headings": self.headings}) + } +} + +fn index_ui(app: &mut WordApp, ui: &mut Ui, f: &mut IndexForm) -> Next { + let page = |p: &str| p.to_string(); + let lines: Vec<(usize, String, String)> = { + let mut v = Vec::new(); + if f.headings { + v.push((0, "A".to_string(), String::new())); + } + if f.kind == "runIn" { + v.push((0, format!("{}: {}, 4; {}", tl!("Atmosphere"), tl!("Ionosphere"), tl!("Mesosphere")), page("3"))); + } else { + v.push((0, tl!("Atmosphere").to_string(), page("1"))); + v.push((1, tl!("Ionosphere").to_string(), page("3"))); + v.push((1, tl!("Mesosphere").to_string(), page("4"))); + } + v + }; + sample(ui, &lines, f.right_align, leader_char(&f.leader)); + ui.add_space(6.0); + egui::Grid::new("idx").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + ui.label(tl!("Type:")); + ui.horizontal(|ui| { + ui.radio_value(&mut f.kind, "indented".to_string(), tl!("Indented")); + ui.radio_value(&mut f.kind, "runIn".to_string(), tl!("Run-in")); + }); + ui.end_row(); + ui.label(tl!("Columns:")); + ui.add(egui::DragValue::new(&mut f.columns).range(1..=4)); + ui.end_row(); + ui.label(""); + ui.checkbox(&mut f.right_align, tl!("Page numbers at the right margin")); + ui.end_row(); + ui.label(tl!("Tab leader:")); + ui.add_enabled_ui(f.right_align, |ui| { + let opts: Vec<(String, &str)> = LEADERS.iter().map(|(v, l)| (v.to_string(), *l)).collect(); + choice(ui, "idx_leader", &mut f.leader, &opts); + }); + ui.end_row(); + ui.label(""); + ui.checkbox(&mut f.headings, tl!("Letter headings")); + ui.end_row(); + }); + ui.add_space(4.0); + if ui.button(tl!("Mark Entry…")).clicked() { + return go(RefsDialog::MarkEntry(MarkForm::read(app))); + } + let (ok, cancel) = buttons(ui, tl!("OK")); + if ok && let Err(e) = app.run("references.index", f.params()) { + app.status(e); + } + if ok || cancel { Next::Close } else { Next::Stay } +} + +/// The Mark Index Entry dialog's fields. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MarkForm { + pub entry: String, + pub subentry: String, + /// `page` (current page), `see` (cross-reference) or `range` (page range bookmark). + pub option: String, + pub see: String, + pub bookmark: String, + pub bookmarks: Vec, + pub bold: bool, + pub italic: bool, + pub message: String, +} + +impl MarkForm { + pub fn read(app: &WordApp) -> MarkForm { + let bookmarks: Vec = app.session.doc.bookmarks().into_iter().map(|(n, _)| n).filter(|n| !n.starts_with('_')).collect(); + MarkForm { + entry: app.session.selected_text().trim().to_string(), + subentry: String::new(), + option: "page".into(), + see: format!("{} ", tl!("See")), + bookmark: bookmarks.first().cloned().unwrap_or_default(), + bookmarks, + bold: false, + italic: false, + message: String::new(), + } + } + + /// `references.markEntry` parameters (`all` for Mark All). + pub fn params(&self, all: bool) -> Value { + let mut v = json!({"entry": self.entry.trim(), "subentry": self.subentry.trim(), "bold": self.bold, "italic": self.italic, "all": all}); + match self.option.as_str() { + "see" => v["crossReference"] = json!(self.see.trim()), + "range" => v["bookmark"] = json!(self.bookmark), + _ => {} + } + if all { + v["text"] = json!(self.entry.trim()); + } + v + } +} + +fn mark_ui(app: &mut WordApp, ui: &mut Ui, f: &mut MarkForm) -> Next { + heading(ui, "Index"); + egui::Grid::new("xe").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + ui.label(tl!("Main entry:")); + ui.add(egui::TextEdit::singleline(&mut f.entry).desired_width(220.0)); + ui.end_row(); + ui.label(tl!("Subentry:")); + ui.add(egui::TextEdit::singleline(&mut f.subentry).desired_width(220.0)); + ui.end_row(); + }); + heading(ui, "Options"); + ui.horizontal(|ui| { + ui.radio_value(&mut f.option, "see".to_string(), tl!("Cross-reference:")); + ui.add_enabled(f.option == "see", egui::TextEdit::singleline(&mut f.see).desired_width(160.0)); + }); + ui.radio_value(&mut f.option, "page".to_string(), tl!("This page")); + ui.horizontal(|ui| { + ui.add_enabled_ui(!f.bookmarks.is_empty(), |ui| { + ui.radio_value(&mut f.option, "range".to_string(), tl!("Pages of bookmark:")); + }); + ui.add_enabled_ui(f.option == "range", |ui| { + let opts: Vec<(String, &str)> = f.bookmarks.iter().map(|b| (b.clone(), b.as_str())).collect(); + egui::ComboBox::from_id_salt("xe_bm").selected_text(f.bookmark.clone()).width(140.0).show_ui(ui, |ui| { + for (v, l) in &opts { + ui.selectable_value(&mut f.bookmark, v.clone(), *l); + } + }); + }); + }); + heading(ui, "Page number style"); + ui.horizontal(|ui| { + ui.checkbox(&mut f.bold, tl!("Bold")); + ui.checkbox(&mut f.italic, tl!("Italic")); + }); + if !f.message.is_empty() { + ui.label(egui::RichText::new(&f.message).small()); + } + let mut close = false; + ui.add_space(8.0); + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button(tl!("Close")).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + close = true; + } + let can = !f.entry.trim().is_empty(); + if ui.add_enabled(can, egui::Button::new(tl!("Mark All"))).clicked() { + f.message = match app.run("references.markEntry", f.params(true)) { + Ok(v) => { + crate::i18n::fmt(tl!("{n} entries marked."), &[("n", &v.get("marked").and_then(Value::as_u64).unwrap_or(0).to_string())]) + } + Err(e) => e, + }; + } + if ui + .add_enabled(can, egui::Button::new(egui::RichText::new(tl!("Mark")).color(egui::Color32::WHITE)).fill(crate::theme::APP_COLOR)) + .clicked() + { + f.message = match app.run("references.markEntry", f.params(false)) { + Ok(_) => tl!("Entry marked.").to_string(), + Err(e) => e, + }; + } + }); + }); + if close { Next::Close } else { Next::Stay } +} + +// --------------------------------------------------------------------------------------------- +// Table of Contents + +/// The Custom Table of Contents dialog's fields (with its Options). +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TocForm { + pub page_numbers: bool, + pub right_align: bool, + pub leader: String, + pub hyperlinks: bool, + pub levels: u8, + /// Options: heading styles (`\o`), outline levels (`\u`), TC fields (`\f`). + pub heading_styles: bool, + pub outline_levels: bool, + pub tc_fields: bool, + /// Options: other paragraph styles with a TOC level (0 = not in the table), by style name. + pub style_levels: Vec<(String, u8)>, +} + +impl TocForm { + /// The table in the document, or the defaults for a new one. + pub fn read(app: &WordApp) -> TocForm { + let f = body_field(app, "TOC").unwrap_or_else(|| FieldCode::parse(r#"TOC \o "1-3" \h \z \u"#)); + let levels = f.arg('o').and_then(wordcraft_engine::cmd::references::level_range).map_or(3, |r| r.1); + let listed: Vec<(String, u8)> = f + .arg('t') + .map(|t| { + let parts: Vec<&str> = t.split(',').map(str::trim).collect(); + parts.chunks(2).filter_map(|c| Some((c.first()?.to_string(), c.get(1)?.parse::().ok()?.clamp(1, 9)))).collect() + }) + .unwrap_or_default(); + let style_levels = app + .session + .doc + .styles + .styles + .iter() + .filter(|s| s.kind == StyleKind::Paragraph && !s.hidden && !s.id.starts_with("Heading") && !s.id.starts_with("TOC")) + .map(|s| (s.name.clone(), listed.iter().find(|(n, _)| n.eq_ignore_ascii_case(&s.name)).map_or(0, |(_, l)| *l))) + .collect(); + // Right-aligned page numbers: no `\p` separator, or a tab one. + let leader = app + .session + .doc + .body + .iter() + .filter_map(|b| b.as_para()) + .find(|p| p.props.style.as_deref().is_some_and(|s| s.starts_with("TOC") && s != "TOCHeading")) + .and_then(|p| p.props.tabs.as_ref()?.last().map(|t| t.leader)); + TocForm { + page_numbers: !f.has('n'), + right_align: f.arg('p').is_none_or(|p| p.contains('\t')), + leader: match leader { + Some(wordcraft_doc::props::TabLeader::None) => "none", + Some(wordcraft_doc::props::TabLeader::Hyphen) => "hyphen", + Some(wordcraft_doc::props::TabLeader::Underscore) => "underscore", + _ => "dot", + } + .into(), + hyperlinks: f.has('h'), + levels: levels.clamp(1, 9), + heading_styles: f.has('o'), + outline_levels: f.has('u'), + tc_fields: f.has('f'), + style_levels, + } + } + + /// `references.toc` parameters. + pub fn params(&self) -> Value { + let styles: serde_json::Map = self.style_levels.iter().filter(|(_, l)| *l > 0).map(|(n, l)| (n.clone(), json!(l))).collect(); + json!({ + "levels": self.levels, + "pageNumbers": self.page_numbers, + "rightAlign": self.right_align, + "tabLeader": self.leader, + "hyperlinks": self.hyperlinks, + "headingStyles": self.heading_styles, + "outlineLevels": self.outline_levels, + "tcFields": self.tc_fields, + "styleLevels": styles, + }) + } +} + +fn toc_ui(app: &mut WordApp, ui: &mut Ui, f: &mut TocForm) -> Next { + let lines: Vec<(usize, String, String)> = (1..=f.levels.min(3)) + .map(|l| { + let name = crate::i18n::fmt(tl!("Heading {n}"), &[("n", &l.to_string())]); + (l as usize - 1, name, if f.page_numbers { (l * 2 - 1).to_string() } else { String::new() }) + }) + .collect(); + sample(ui, &lines, f.right_align, leader_char(&f.leader)); + ui.add_space(6.0); + ui.checkbox(&mut f.page_numbers, tl!("Include page numbers")); + ui.add_enabled_ui(f.page_numbers, |ui| { + ui.checkbox(&mut f.right_align, tl!("Page numbers at the right margin")); + ui.horizontal(|ui| { + ui.label(tl!("Tab leader:")); + ui.add_enabled_ui(f.right_align, |ui| { + let opts: Vec<(String, &str)> = LEADERS.iter().map(|(v, l)| (v.to_string(), *l)).collect(); + choice(ui, "toc_leader", &mut f.leader, &opts); + }); + }); + }); + ui.checkbox(&mut f.hyperlinks, tl!("Link entries to their headings")); + ui.horizontal(|ui| { + ui.label(tl!("Levels:")); + ui.add(egui::DragValue::new(&mut f.levels).range(1..=9)); + }); + ui.add_space(4.0); + let mut then = None; + ui.horizontal(|ui| { + if ui.button(tl!("Options…")).clicked() { + then = Some(RefsDialog::TocOptions { was: Box::new(f.clone()), form: f.clone() }); + } + if ui.button(tl!("Modify…")).clicked() { + then = Some(RefsDialog::TocStyles { form: f.clone(), level: 1 }); + } + }); + if let Some(g) = then { + return go(g); + } + let (ok, cancel) = buttons(ui, tl!("OK")); + if ok && let Err(e) = app.run("references.toc", f.params()) { + app.status(e); + } + if ok || cancel { Next::Close } else { Next::Stay } +} + +fn toc_options_ui(_app: &mut WordApp, ui: &mut Ui, f: &mut TocForm, was: &TocForm) -> Next { + heading(ui, "Collect entries from:"); + ui.checkbox(&mut f.heading_styles, crate::i18n::fmt(tl!("Heading styles (levels 1–{n})"), &[("n", &f.levels.to_string())])); + ui.label(tl!("Other styles and their TOC level:")); + egui::ScrollArea::vertical().max_height(180.0).show(ui, |ui| { + egui::Grid::new("toc_styles").num_columns(2).spacing(vec2(10.0, 3.0)).show(ui, |ui| { + for (name, level) in f.style_levels.iter_mut() { + ui.label(name.as_str()); + ui.add(egui::DragValue::new(level).range(0..=9).custom_formatter(|v, _| if v < 0.5 { String::new() } else { format!("{v:.0}") })); + ui.end_row(); + } + }); + }); + ui.checkbox(&mut f.outline_levels, tl!("Outline levels")); + ui.checkbox(&mut f.tc_fields, tl!("TC entry fields")); + ui.add_space(4.0); + if ui.button(tl!("Reset")).clicked() { + f.heading_styles = true; + f.outline_levels = true; + f.tc_fields = false; + for (_, l) in f.style_levels.iter_mut() { + *l = 0; + } + } + let (ok, cancel) = buttons(ui, tl!("OK")); + if ok { + return go(RefsDialog::Toc(f.clone())); + } + if cancel { + return go(RefsDialog::Toc(was.clone())); + } + Next::Stay +} + +/// Modify… in Table of Contents: pick a TOC level style and edit it in Modify Style, which +/// comes back here when it closes. +fn toc_styles_ui(app: &mut WordApp, ui: &mut Ui, f: &mut TocForm, level: &mut u8) -> Next { + ui.label(tl!("Styles:")); + egui::ScrollArea::vertical().max_height(200.0).show(ui, |ui| { + for l in 1..=9u8 { + let name = app.session.doc.styles.get(&format!("TOC{l}")).map(|s| s.name.clone()).unwrap_or_else(|| format!("TOC {l}")); + ui.selectable_value(level, l, name); + } + }); + ui.add_space(6.0); + if ui.button(tl!("Modify…")).clicked() + && let Some(mut d) = Dialog::modify_style(app, &format!("TOC{level}")) + { + if let Dialog::ModifyStyle { resume, .. } = &mut d { + *resume = Some(Box::new(Dialog::Refs(Box::new(RefsDialog::TocStyles { form: f.clone(), level: *level })))); + } + app.dialog = Some(d); + return Next::Close; + } + let (ok, cancel) = buttons(ui, tl!("OK")); + if ok || cancel { go(RefsDialog::Toc(f.clone())) } else { Next::Stay } +} + +// --------------------------------------------------------------------------------------------- +// Sources + +/// Source types with their fields: (kind, label, recommended fields). A field is (key, label). +const SOURCE_TYPES: [(&str, &str, &[(&str, &str)]); 9] = [ + ("book", "Book", &[("author", "Author"), ("title", "Title"), ("year", "Year"), ("city", "City"), ("publisher", "Publisher")]), + ( + "bookSection", + "Chapter in a book", + &[ + ("author", "Author"), + ("title", "Title"), + ("journal", "Book title"), + ("year", "Year"), + ("pages", "Pages"), + ("city", "City"), + ("publisher", "Publisher"), + ], + ), + ( + "article", + "Journal article", + &[("author", "Author"), ("title", "Title"), ("journal", "Journal"), ("year", "Year"), ("volume", "Volume"), ("pages", "Pages")], + ), + ( + "periodical", + "Magazine or newspaper article", + &[ + ("author", "Author"), + ("title", "Title"), + ("journal", "Periodical"), + ("year", "Year"), + ("month", "Month"), + ("day", "Day"), + ("pages", "Pages"), + ], + ), + ( + "conference", + "Conference paper", + &[ + ("author", "Author"), + ("title", "Title"), + ("journal", "Conference"), + ("year", "Year"), + ("pages", "Pages"), + ("city", "City"), + ("publisher", "Publisher"), + ], + ), + ("report", "Report", &[("author", "Author"), ("title", "Title"), ("year", "Year"), ("publisher", "Institution"), ("city", "City")]), + ( + "website", + "Website", + &[ + ("author", "Author"), + ("title", "Page title"), + ("journal", "Site name"), + ("year", "Year"), + ("month", "Month"), + ("day", "Day"), + ("url", "URL"), + ], + ), + ("film", "Film", &[("author", "Author"), ("title", "Title"), ("publisher", "Studio"), ("year", "Year")]), + ("other", "Other", &[("author", "Author"), ("title", "Title"), ("year", "Year"), ("city", "City"), ("publisher", "Publisher")]), +]; + +/// Every field "show all bibliography fields" adds, after the type's own. +const ALL_FIELDS: [(&str, &str); 17] = [ + ("author", "Author"), + ("editor", "Editor"), + ("title", "Title"), + ("journal", "Container Title"), + ("year", "Year"), + ("month", "Month"), + ("day", "Day"), + ("city", "City"), + ("publisher", "Publisher"), + ("edition", "Edition"), + ("volume", "Volume"), + ("issue", "Issue"), + ("pages", "Pages"), + ("url", "URL"), + ("doi", "DOI"), + ("accessed", "Accessed on"), + ("tag", "Tag"), +]; + +fn field_mut<'a>(s: &'a mut Source, key: &str) -> Option<&'a mut String> { + Some(match key { + "author" => &mut s.author, + "editor" => &mut s.editor, + "title" => &mut s.title, + "journal" => &mut s.journal, + "year" => &mut s.year, + "month" => &mut s.month, + "day" => &mut s.day, + "city" => &mut s.city, + "publisher" => &mut s.publisher, + "edition" => &mut s.edition, + "volume" => &mut s.volume, + "issue" => &mut s.issue, + "pages" => &mut s.pages, + "url" => &mut s.url, + "doi" => &mut s.doi, + "accessed" => &mut s.accessed, + "tag" => &mut s.tag, + _ => return None, + }) +} + +/// Create Source / Edit Source. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SourceForm { + pub source: Source, + pub show_all: bool, + /// Editing a source that is there (its tag stays: citations point at it). + pub edit: bool, + /// Insert a citation of the source on OK (Insert Citation › Add New Source…). + pub cite: bool, + /// Opened from the Source Manager, which comes back when this closes. + pub back: bool, +} + +impl SourceForm { + pub fn new(cite: bool, back: bool) -> SourceForm { + SourceForm { source: Source { kind: "book".into(), ..Default::default() }, show_all: false, edit: false, cite, back } + } + + /// The fields shown: the type's own, or every field. + pub fn fields(&self) -> Vec<(&'static str, &'static str)> { + let own = SOURCE_TYPES.iter().find(|(k, ..)| *k == self.source.kind).map(|(_, _, f)| *f).unwrap_or(SOURCE_TYPES[0].2); + let mut v: Vec<(&str, &str)> = own.to_vec(); + if self.show_all { + v.extend(ALL_FIELDS.iter().filter(|(k, _)| !own.iter().any(|(o, _)| o == k) && *k != "tag").copied()); + } + v.push(("tag", "Tag")); + v + } +} + +fn source_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SourceForm) -> Next { + egui::Grid::new("src").num_columns(2).spacing(vec2(10.0, 5.0)).show(ui, |ui| { + ui.label(tl!("Source type:")); + let shown = SOURCE_TYPES.iter().find(|(k, ..)| *k == f.source.kind).map(|(_, l, _)| tl!(l).to_string()).unwrap_or_default(); + egui::ComboBox::from_id_salt("src_kind").selected_text(shown).width(200.0).show_ui(ui, |ui| { + for (k, l, _) in SOURCE_TYPES { + ui.selectable_value(&mut f.source.kind, k.to_string(), tl!(l)); + } + }); + ui.end_row(); + let edit = f.edit; + for (key, label) in f.fields() { + ui.label(format!("{}:", tl!(label))); + if let Some(v) = field_mut(&mut f.source, key) { + let hint = match key { + "author" => tl!("Last, First; Last, First"), + "tag" => tl!("Made from the author and year when empty"), + _ => "", + }; + ui.add_enabled(!(edit && key == "tag"), egui::TextEdit::singleline(v).desired_width(260.0).hint_text(hint)); + } + ui.end_row(); + } + }); + ui.checkbox(&mut f.show_all, tl!("Show every bibliography field")); + let (ok, cancel) = buttons(ui, tl!("OK")); + if ok { + let src = serde_json::to_value(&f.source).unwrap_or(Value::Null); + let r = if f.cite { app.run("references.citation", json!({"source": src})) } else { app.run("references.sources", json!({"add": src})) }; + if let Err(e) = r { + app.status(e); + } + } + if (ok || cancel) && f.back { + return go(RefsDialog::Sources { selected: None }); + } + if ok || cancel { Next::Close } else { Next::Stay } +} + +fn sources_ui(app: &mut WordApp, ui: &mut Ui, selected: &mut Option) -> Next { + let t = Tokens::get(ui.ctx()); + let list: Vec = app + .session + .doc + .sources + .iter() + .map(|s| { + let parts: Vec<&str> = [s.author.as_str(), s.title.as_str(), s.year.as_str()].into_iter().filter(|x| !x.is_empty()).collect(); + format!("{} — {}", s.tag, parts.join("; ")) + }) + .collect(); + ui.label(tl!("Sources in this document:")); + egui::Frame::new().stroke(egui::Stroke::new(1.0, t.border_strong)).inner_margin(4.0).show(ui, |ui| { + ui.set_min_size(vec2(380.0, 160.0)); + egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| { + if list.is_empty() { + ui.label(egui::RichText::new(tl!("No sources yet.")).color(t.text_dim)); + } + for (i, l) in list.iter().enumerate() { + if ui.selectable_label(*selected == Some(i), l).clicked() { + *selected = Some(i); + } + } + }); + }); + let src = selected.and_then(|i| app.session.doc.sources.get(i)).cloned(); + let mut next = Next::Stay; + ui.add_space(6.0); + ui.horizontal(|ui| { + if ui.button(tl!("New…")).clicked() { + next = go(RefsDialog::Source(Box::new(SourceForm::new(false, true)))); + } + if ui.add_enabled(src.is_some(), egui::Button::new(tl!("Edit…"))).clicked() + && let Some(s) = &src + { + next = go(RefsDialog::Source(Box::new(SourceForm { source: s.clone(), show_all: false, edit: true, cite: false, back: true }))); + } + if ui.add_enabled(src.is_some(), egui::Button::new(tl!("Delete"))).clicked() + && let Some(s) = &src + { + if let Err(e) = app.run("references.sources", json!({"remove": s.tag})) { + app.status(e); + } + *selected = None; + } + if ui.add_enabled(src.is_some(), egui::Button::new(tl!("Insert Citation"))).clicked() + && let Some(s) = &src + { + if let Err(e) = app.run("references.citation", json!({"tag": s.tag})) { + app.status(e); + } + next = Next::Close; + } + }); + ui.add_space(8.0); + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button(tl!("Close")).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + next = Next::Close; + } + }); + }); + next +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use wordcraft_doc::{InlineObject, StoryRef}; + use wordcraft_engine::Session; + + use super::*; + use crate::Services; + + fn app(text: &str) -> WordApp { + WordApp::new(Session::new(wordcraft_doc::Document::from_text(text)), Services::default()) + } + + fn form(a: &WordApp) -> RefsDialog { + match &a.dialog { + Some(Dialog::Refs(d)) => (**d).clone(), + d => panic!("no references dialog: {d:?}"), + } + } + + fn instrs(a: &WordApp) -> Vec { + let doc = &a.session.doc; + doc.para_paths(StoryRef::Body) + .iter() + .filter_map(|p| doc.para(StoryRef::Body, p)) + .flat_map(|p| p.objects.iter()) + .filter_map(|o| match o { + InlineObject::Field { instr, .. } => Some(instr.clone()), + _ => None, + }) + .collect() + } + + /// #402: Insert Caption opens the dialog; a new label is kept with the interface settings, + /// and Numbering's chapter options become `STYLEREF` and `SEQ … \s` fields. + #[test] + fn caption_dialog_numbers_with_chapters() { + let mut a = app("Results\nA chart"); + a.run("select.text", json!({"text": "Results"})).unwrap(); + a.run("styles.apply", json!({"style": "Heading 1"})).unwrap(); + a.run("select.text", json!({"text": "A chart"})).unwrap(); + assert_eq!(a.run("references.caption", json!({})).unwrap(), json!({"pending": "caption"})); + let RefsDialog::Caption(mut f) = form(&a) else { panic!() }; + assert_eq!((f.label.as_str(), f.labels.len()), ("Figure", 3)); + // New Label…, then Numbering… with a chapter number. + f.new_label = "Plate".into(); + a.dialog = None; + a.ui.caption_labels.push(f.new_label.clone()); + f.label = "Plate".into(); + f.format = "ALPHABETIC".into(); + f.chapter = true; + f.separator = ".".into(); + f.text = "Growth".into(); + assert_eq!(f.preview(), "Plate 1.A"); + a.run("references.caption", f.params()).unwrap(); + assert!(instrs(&a).contains(&"SEQ Plate \\* ALPHABETIC \\s 1".to_string()), "{:?}", instrs(&a)); + assert!(a.session.doc.plain_text(StoryRef::Body).contains("Plate 1.A: Growth")); + // The label is offered next time and saved with the preferences. + assert!(CaptionForm::read(&a).labels.contains(&"Plate".to_string())); + assert_eq!(a.prefs().caption_labels, vec!["Plate".to_string()]); + } + + /// #402: Mark Entry opens with the selection; Insert Index opens with Word's defaults and + /// reads the index back; both write their switches. + #[test] + fn index_and_mark_entry_dialogs_write_switches() { + let mut a = app("Kilns are hot.\nMore text."); + a.run("select.text", json!({"text": "Kilns"})).unwrap(); + assert_eq!(a.run("references.markEntry", json!({})).unwrap(), json!({"pending": "markEntry"})); + let RefsDialog::MarkEntry(mut m) = form(&a) else { panic!() }; + assert_eq!(m.entry, "Kilns"); + m.subentry = "firing".into(); + m.italic = true; + a.run("references.markEntry", m.params(false)).unwrap(); + assert!(instrs(&a).contains(&"XE \"Kilns:firing\" \\i".to_string()), "{:?}", instrs(&a)); + a.run("caret.docEnd", json!({})).unwrap(); + assert_eq!(a.run("references.index", json!({})).unwrap(), json!({"pending": "index"})); + let RefsDialog::Index(mut f) = form(&a) else { panic!() }; + assert_eq!((f.columns, f.kind.as_str(), f.headings), (2, "indented", true)); + f.kind = "runIn".into(); + f.right_align = true; + a.run("references.index", f.params()).unwrap(); + assert!(instrs(&a).contains(&"INDEX \\h \"A\" \\c \"2\" \\e \"\t\" \\r".to_string()), "{:?}", instrs(&a)); + assert_eq!(IndexForm::read(&a), f, "the dialog shows the index's settings"); + } + + /// #402: Custom Table of Contents and its Options make the TOC switches, and the dialog + /// opens again with them. + #[test] + fn custom_table_of_contents_dialog() { + let mut a = app("Intro\nNote"); + a.run("select.text", json!({"text": "Intro"})).unwrap(); + a.run("styles.apply", json!({"style": "Heading 1"})).unwrap(); + a.run("select.text", json!({"text": "Note"})).unwrap(); + a.run("styles.apply", json!({"style": "Quote"})).unwrap(); + a.run("caret.docStart", json!({})).unwrap(); + a.run("ui.dialog", json!({"name": "toc"})).unwrap(); + let RefsDialog::Toc(mut f) = form(&a) else { panic!() }; + assert_eq!((f.levels, f.hyperlinks, f.right_align, f.heading_styles, f.outline_levels), (3, true, true, true, true)); + f.levels = 2; + f.hyperlinks = false; + f.leader = "hyphen".into(); + // Options…: the Quote style at level 2. + f.style_levels.iter_mut().find(|(n, _)| n == "Quote").unwrap().1 = 2; + a.run("references.toc", f.params()).unwrap(); + assert_eq!(instrs(&a)[0], "TOC \\o \"1-2\" \\z \\u \\t \"Quote,2\""); + let text = a.session.doc.plain_text(StoryRef::Body); + assert!(text.contains("Intro\t1") && text.contains("Note\t1"), "{text}"); + assert_eq!(TocForm::read(&a), f); + } + + /// #402: Insert Citation without a source opens Create Source; the type picks the fields, + /// "show all" adds the rest, and Edit Source from the Source Manager updates the citation. + #[test] + fn create_and_edit_source() { + let mut a = app("Studios matter "); + a.run("caret.docEnd", json!({})).unwrap(); + assert_eq!(a.run("references.citation", json!({})).unwrap(), json!({"pending": "createSource"})); + let RefsDialog::Source(mut f) = form(&a) else { panic!() }; + let f = &mut *f; + f.source.kind = "website".into(); + let shown = f.fields().len(); + assert!(f.fields().iter().any(|(k, l)| *k == "url" && *l == "URL")); + f.show_all = true; + assert!(f.fields().len() > shown && f.fields().iter().any(|(k, _)| *k == "doi")); + for (k, v) in [("author", "Rivera, Alex"), ("title", "Kilns Online"), ("year", "2024"), ("url", "https://example.org"), ("doi", "10.1/x")] { + *field_mut(&mut f.source, k).unwrap() = v.into(); + } + a.run("references.citation", json!({"source": serde_json::to_value(&f.source).unwrap()})).unwrap(); + assert!(a.session.doc.plain_text(StoryRef::Body).contains("(Rivera, 2024)")); + assert_eq!(a.session.doc.sources[0].doi, "10.1/x"); + // Manage Sources › Edit…: a new year shows in the citation. + assert_eq!(a.run("references.sources", json!({})).unwrap(), json!({"pending": "sourceManager"})); + let mut src = a.session.doc.sources[0].clone(); + src.year = "2025".into(); + a.run("references.sources", json!({"add": src})).unwrap(); + assert!(a.session.doc.plain_text(StoryRef::Body).contains("(Rivera, 2025)"), "{}", a.session.doc.plain_text(StoryRef::Body)); + assert_eq!(a.session.doc.sources.len(), 1); + } +} diff --git a/crates/ui-egui/src/i18n/audit.rs b/crates/ui-egui/src/i18n/audit.rs index 714c12c9e2..0749cfb29c 100644 --- a/crates/ui-egui/src/i18n/audit.rs +++ b/crates/ui-egui/src/i18n/audit.rs @@ -142,6 +142,7 @@ impl Exempt { || plain.contains('┬') || plain.contains('\u{2061}') // equation input (UnicodeMath), drawn as math || matches!(plain, "WordCraft" | "WordCraft User" | "Discord" | "https://" | "auto" | "I. II. III." | "i. ii. iii.") || plain == "CONFIDENTIAL" // the watermark dialog's text: document content, kept as typed + || plain == "Figure 1" // the caption dialog's sample: the label goes into the document as is } } @@ -195,6 +196,12 @@ fn untranslated_interface_text() { "models", "insertMergeField", "findRecipient", + "caption", + "index", + "markEntry", + "toc", + "sourceManager", + "createSource", ]; for name in dialogs { let _ = app.run("ui.dialog", json!({"name": name})); diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 66ecb82e7b..8a9b8308bb 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1469,6 +1469,104 @@ Continuous numbering Numeración continua Apply Aplicar Convert Notes Convertir notas + A, B, C, … A, B, C, … + Above the item Encima del elemento + Accessed on Consultado el + Add New Source… Agregar nueva fuente… + Add the chapter number Agregar el número de capítulo + Atmosphere Atmósfera + Below the item Debajo del elemento + Book Libro + Book title Título del libro + Caption Leyenda + Caption Numbering Numeración de leyendas + Caption text Texto de la leyenda + Caption: Leyenda: + Chapter in a book Capítulo de libro + Chapters begin at: Los capítulos empiezan en: + Collect entries from: Recoger entradas de: + Columns: Columnas: + Conference Conferencia + Conference paper Ponencia de conferencia + Container Title Título del contenedor + Create Source Crear fuente + Cross-reference: Referencia cruzada: + Custom Table of Contents… Tabla de contenido personalizada… + DOI DOI + Day Día + Delete Label Eliminar rótulo + Edit Source Editar fuente + Edition Edición + Edit… Editar… + Entry marked. Entrada marcada. + Example: {sample} Ejemplo: {sample} + Figure Figura + Film Película + Heading styles (levels 1–{n}) Estilos de título (niveles 1–{n}) + Heading {n} Título {n} + I, II, III, … I, II, III, … + Include page numbers Incluir números de página + Indented Con sangría + Institution Institución + Ionosphere Ionosfera + Issue Número + Journal Revista + Label: Rótulo: + Last, First; Last, First Apellido, Nombre; Apellido, Nombre + Leave the label out of the caption Omitir el rótulo en la leyenda + Letter headings Encabezados de letra + Levels: Niveles: + Link entries to their headings Vincular las entradas a sus títulos + Made from the author and year when empty Si está vacío, se forma con autor y año + Magazine or newspaper article Artículo de revista o periódico + Main entry: Entrada principal: + Mark Marcar + Mark All Marcar todas + Mark Entry… Marcar entrada… + Mark Index Entry Marcar entrada de índice + Mesosphere Mesosfera + Month Mes + New Label Nuevo rótulo + New Label… Nuevo rótulo… + New… Nueva… + No sources yet. Aún no hay fuentes. + Numbering… Numeración… + Options… Opciones… + Other Otro + Other styles and their TOC level: Otros estilos y su nivel en la tabla: + Outline levels Niveles de esquema + Page number style Estilo del número de página + Page numbers at the right margin Números de página en el margen derecho + Page title Título de la página + Pages of bookmark: Páginas del marcador: + Periodical Publicación periódica + Position: Posición: + Publisher Editorial + Run-in Continuo + See Véase + Separator: Separador: + Show every bibliography field Mostrar todos los campos bibliográficos + Site name Nombre del sitio + Source Manager Administrador de fuentes + Source type: Tipo de fuente: + Sources in this document: Fuentes de este documento: + Styles: Estilos: + Subentry: Subentrada: + TC entry fields Campos de entrada TC + Tab leader: Carácter de relleno: + Table of Contents Options Opciones de tabla de contenido + Table of Contents Styles Estilos de tabla de contenido + Tag Etiqueta + This page Esta página + Title Título + Type: Tipo: + URL URL + Volume Volumen + Website Sitio web + Year Año + a, b, c, … a, b, c, … + i, ii, iii, … i, ii, iii, … + {n} entries marked. Entradas marcadas: {n}. Lasso Select Selección con lazo Move Selected Ink Mover la tinta seleccionada Delete Selected Ink Eliminar la tinta seleccionada diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index da627a4184..92dfd0bc08 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -1463,6 +1463,104 @@ Continuous numbering Pidev nummerdus Apply Rakenda Convert Notes Teisenda märkused + A, B, C, … A, B, C, … + Above the item Objekti kohal + Accessed on Vaadatud + Add New Source… Lisa uus allikas… + Add the chapter number Lisa peatüki number + Atmosphere Atmosfäär + Below the item Objekti all + Book Raamat + Book title Raamatu pealkiri + Caption Pealdis + Caption Numbering Pealdiste nummerdamine + Caption text Pealdise tekst + Caption: Pealdis: + Chapter in a book Peatükk raamatus + Chapters begin at: Peatükid algavad: + Collect entries from: Kirjed võetakse: + Columns: Veerud: + Conference Konverents + Conference paper Konverentsiartikkel + Container Title Kogumiku pealkiri + Create Source Loo allikas + Cross-reference: Ristviide: + Custom Table of Contents… Kohandatud sisukord… + DOI DOI + Day Päev + Delete Label Kustuta silt + Edit Source Muuda allikat + Edition Väljaanne + Edit… Muuda… + Entry marked. Kirje märgitud. + Example: {sample} Näide: {sample} + Figure Joonis + Film Film + Heading styles (levels 1–{n}) Pealkirjalaadid (tasemed 1–{n}) + Heading {n} Pealkiri {n} + I, II, III, … I, II, III, … + Include page numbers Lisa leheküljenumbrid + Indented Taandega + Institution Asutus + Ionosphere Ionosfäär + Issue Number + Journal Ajakiri + Label: Silt: + Last, First; Last, First Perenimi, Eesnimi; Perenimi, Eesnimi + Leave the label out of the caption Jäta silt pealdisest välja + Letter headings Tähepealkirjad + Levels: Tasemed: + Link entries to their headings Lingi kirjed pealkirjadega + Made from the author and year when empty Tühjana tehakse autorist ja aastast + Magazine or newspaper article Ajakirja- või ajaleheartikkel + Main entry: Põhikirje: + Mark Märgi + Mark All Märgi kõik + Mark Entry… Märgi kirje… + Mark Index Entry Märgi registrikirje + Mesosphere Mesosfäär + Month Kuu + New Label Uus silt + New Label… Uus silt… + New… Uus… + No sources yet. Allikaid veel pole. + Numbering… Nummerdamine… + Options… Suvandid… + Other Muu + Other styles and their TOC level: Muud laadid ja nende sisukorratase: + Outline levels Liigendustasemed + Page number style Leheküljenumbri laad + Page numbers at the right margin Leheküljenumbrid paremas servas + Page title Lehe pealkiri + Pages of bookmark: Järjehoidja leheküljed: + Periodical Perioodika + Position: Asukoht: + Publisher Kirjastus + Run-in Jooksev + See Vt + Separator: Eraldaja: + Show every bibliography field Näita kõiki bibliograafiavälju + Site name Saidi nimi + Source Manager Allikahaldur + Source type: Allika tüüp: + Sources in this document: Selle dokumendi allikad: + Styles: Laadid: + Subentry: Alamkirje: + TC entry fields TC-kirjeväljad + Tab leader: Tabelduse täide: + Table of Contents Options Sisukorra suvandid + Table of Contents Styles Sisukorra laadid + Tag Silt + This page See lehekülg + Title Pealkiri + Type: Tüüp: + URL URL + Volume Köide + Website Veebisait + Year Aasta + a, b, c, … a, b, c, … + i, ii, iii, … i, ii, iii, … + {n} entries marked. Märgitud kirjeid: {n}. Lasso Select Lassoga valimine Move Selected Ink Teisalda valitud tint Delete Selected Ink Kustuta valitud tint diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 5b7be257d3..4023c4aff7 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1453,6 +1453,104 @@ Continuous numbering 通し番号 Apply 適用 Convert Notes 注の変換 + A, B, C, … A, B, C, … + Above the item 項目の上 + Accessed on アクセス日 + Add New Source… 新しい資料文献の追加… + Add the chapter number 章番号を付ける + Atmosphere 大気 + Below the item 項目の下 + Book 書籍 + Book title 書籍名 + Caption 図表番号 + Caption Numbering 図表番号の書式 + Caption text 図表番号の文字列 + Caption: 図表番号: + Chapter in a book 書籍の章 + Chapters begin at: 章の開始: + Collect entries from: 項目の取得元: + Columns: 段数: + Conference 会議 + Conference paper 会議論文 + Container Title 掲載元の名前 + Create Source 資料文献の作成 + Cross-reference: 相互参照: + Custom Table of Contents… ユーザー設定の目次… + DOI DOI + Day 日 + Delete Label ラベル削除 + Edit Source 資料文献の編集 + Edition 版 + Edit… 編集… + Entry marked. 項目を登録しました。 + Example: {sample} 例: {sample} + Figure 図 + Film 映画 + Heading styles (levels 1–{n}) 見出しスタイル (レベル 1–{n}) + Heading {n} 見出し {n} + I, II, III, … I, II, III, … + Include page numbers ページ番号を含める + Indented インデント + Institution 機関 + Ionosphere 電離圏 + Issue 号 + Journal 学術誌 + Label: ラベル: + Last, First; Last, First 姓, 名; 姓, 名 + Leave the label out of the caption ラベルを図表番号に含めない + Letter headings 文字見出し + Levels: レベル: + Link entries to their headings 項目を見出しにリンクする + Made from the author and year when empty 空欄なら著者と年から作成 + Magazine or newspaper article 雑誌・新聞の記事 + Main entry: 主項目: + Mark 登録 + Mark All すべて登録 + Mark Entry… 索引登録… + Mark Index Entry 索引登録 + Mesosphere 中間圏 + Month 月 + New Label 新しいラベル + New Label… ラベル名… + New… 新規作成… + No sources yet. 資料文献はまだありません。 + Numbering… 番号付け… + Options… オプション… + Other その他 + Other styles and their TOC level: その他のスタイルと目次レベル: + Outline levels アウトライン レベル + Page number style ページ番号のスタイル + Page numbers at the right margin ページ番号を右端に配置 + Page title ページ名 + Pages of bookmark: ブックマークのページ: + Periodical 定期刊行物 + Position: 位置: + Publisher 出版社 + Run-in 追い込み + See 参照 + Separator: 区切り文字: + Show every bibliography field すべての文献フィールドを表示 + Site name サイト名 + Source Manager 資料文献の管理 + Source type: 資料文献の種類: + Sources in this document: この文書の資料文献: + Styles: スタイル: + Subentry: 副項目: + TC entry fields TC 項目フィールド + Tab leader: タブ リーダー: + Table of Contents Options 目次オプション + Table of Contents Styles 目次スタイル + Tag タグ + This page このページ + Title タイトル + Type: 種類: + URL URL + Volume 巻 + Website Web サイト + Year 年 + a, b, c, … a, b, c, … + i, ii, iii, … i, ii, iii, … + {n} entries marked. {n} 件の項目を登録しました。 Lasso Select なげなわ選択 Move Selected Ink 選択したインクを移動 Delete Selected Ink 選択したインクを削除 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index e4fbc884c3..f175cfa5dd 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1465,6 +1465,104 @@ Continuous numbering Numeração contínua Apply Aplicar Convert Notes Converter notas + A, B, C, … A, B, C, … + Above the item Acima do item + Accessed on Acessado em + Add New Source… Adicionar nova fonte… + Add the chapter number Adicionar o número do capítulo + Atmosphere Atmosfera + Below the item Abaixo do item + Book Livro + Book title Título do livro + Caption Legenda + Caption Numbering Numeração de legendas + Caption text Texto da legenda + Caption: Legenda: + Chapter in a book Capítulo de livro + Chapters begin at: Os capítulos começam em: + Collect entries from: Coletar entradas de: + Columns: Colunas: + Conference Conferência + Conference paper Artigo de conferência + Container Title Título do contêiner + Create Source Criar fonte + Cross-reference: Referência cruzada: + Custom Table of Contents… Sumário personalizado… + DOI DOI + Day Dia + Delete Label Excluir rótulo + Edit Source Editar fonte + Edition Edição + Edit… Editar… + Entry marked. Entrada marcada. + Example: {sample} Exemplo: {sample} + Figure Figura + Film Filme + Heading styles (levels 1–{n}) Estilos de título (níveis 1–{n}) + Heading {n} Título {n} + I, II, III, … I, II, III, … + Include page numbers Incluir números de página + Indented Recuado + Institution Instituição + Ionosphere Ionosfera + Issue Número + Journal Periódico científico + Label: Rótulo: + Last, First; Last, First Sobrenome, Nome; Sobrenome, Nome + Leave the label out of the caption Omitir o rótulo na legenda + Letter headings Cabeçalhos de letra + Levels: Níveis: + Link entries to their headings Vincular as entradas aos títulos + Made from the author and year when empty Se vazio, é formado pelo autor e ano + Magazine or newspaper article Artigo de revista ou jornal + Main entry: Entrada principal: + Mark Marcar + Mark All Marcar todas + Mark Entry… Marcar entrada… + Mark Index Entry Marcar entrada de índice + Mesosphere Mesosfera + Month Mês + New Label Novo rótulo + New Label… Novo rótulo… + New… Nova… + No sources yet. Ainda não há fontes. + Numbering… Numeração… + Options… Opções… + Other Outro + Other styles and their TOC level: Outros estilos e seu nível no sumário: + Outline levels Níveis de estrutura de tópicos + Page number style Estilo do número de página + Page numbers at the right margin Números de página na margem direita + Page title Título da página + Pages of bookmark: Páginas do indicador: + Periodical Periódico + Position: Posição: + Publisher Editora + Run-in Contínuo + See Ver + Separator: Separador: + Show every bibliography field Mostrar todos os campos bibliográficos + Site name Nome do site + Source Manager Gerenciador de fontes + Source type: Tipo de fonte: + Sources in this document: Fontes deste documento: + Styles: Estilos: + Subentry: Subentrada: + TC entry fields Campos de entrada TC + Tab leader: Preenchimento de tabulação: + Table of Contents Options Opções do sumário + Table of Contents Styles Estilos do sumário + Tag Marca + This page Esta página + Title Título + Type: Tipo: + URL URL + Volume Volume + Website Site + Year Ano + a, b, c, … a, b, c, … + i, ii, iii, … i, ii, iii, … + {n} entries marked. Entradas marcadas: {n}. Lasso Select Seleção com laço Move Selected Ink Mover a tinta selecionada Delete Selected Ink Excluir a tinta selecionada diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 3d430c69e6..8903a83381 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1803,6 +1803,101 @@ preview Title Пример Right-to-Left Section Раздел справа налево Table direction: Направление таблицы: Section direction: Направление раздела: + A, B, C, … A, B, C, … + Above the item Над объектом + Accessed on Дата обращения + Add New Source… Добавить новый источник… + Add the chapter number Добавить номер главы + Atmosphere Атмосфера + Below the item Под объектом + Book Книга + Book title Название книги + Caption Numbering Нумерация названий + Caption text Текст названия + Caption: Название: + Chapter in a book Глава книги + Chapters begin at: Главы начинаются с: + Collect entries from: Собирать записи из: + Columns: Столбцы: + Conference Конференция + Conference paper Доклад на конференции + Container Title Название контейнера + Create Source Создать источник + Cross-reference: Перекрёстная ссылка: + Custom Table of Contents… Настраиваемое оглавление… + DOI DOI + Day День + Delete Label Удалить подпись + Edit Source Изменить источник + Edition Издание + Edit… Изменить… + Entry marked. Запись помечена. + Example: {sample} Пример: {sample} + Figure Рисунок + Film Фильм + Heading styles (levels 1–{n}) Стили заголовков (уровни 1–{n}) + I, II, III, … I, II, III, … + Include page numbers Включить номера страниц + Indented Со ступенчатым отступом + Institution Учреждение + Ionosphere Ионосфера + Issue Выпуск + Journal Журнал + Label: Подпись: + Last, First; Last, First Фамилия, Имя; Фамилия, Имя + Leave the label out of the caption Не включать подпись в название + Letter headings Заголовки с буквами + Levels: Уровни: + Link entries to their headings Связать записи с заголовками + Made from the author and year when empty Если пусто, составляется из автора и года + Magazine or newspaper article Статья в журнале или газете + Main entry: Основная запись: + Mark Пометить + Mark All Пометить все + Mark Entry… Пометить запись… + Mark Index Entry Пометка элемента указателя + Mesosphere Мезосфера + Month Месяц + New Label Новая подпись + New Label… Новая подпись… + New… Создать… + No sources yet. Источников пока нет. + Numbering… Нумерация… + Options… Параметры… + Other Другое + Other styles and their TOC level: Другие стили и их уровень в оглавлении: + Outline levels Уровни структуры + Page number style Стиль номеров страниц + Page numbers at the right margin Номера страниц по правому краю + Page title Название страницы + Pages of bookmark: Страницы закладки: + Periodical Периодическое издание + Position: Положение: + Publisher Издатель + Run-in В подбор + See См. + Separator: Разделитель: + Show every bibliography field Показать все поля библиографии + Site name Название сайта + Source Manager Диспетчер источников + Source type: Тип источника: + Sources in this document: Источники в этом документе: + Styles: Стили: + Subentry: Подзапись: + TC entry fields Поля записей TC + Tab leader: Заполнитель табуляции: + Table of Contents Options Параметры оглавления + Table of Contents Styles Стили оглавления + Tag Тег + This page Эта страница + Type: Тип: + URL URL + Volume Том + Website Веб-сайт + Year Год + a, b, c, … a, b, c, … + i, ii, iii, … i, ii, iii, … + {n} entries marked. Помечено записей: {n}. Lasso Select Лассо Move Selected Ink Переместить выделенные штрихи Delete Selected Ink Удалить выделенные штрихи diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index ef47e7105f..8b21b0c252 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -1463,6 +1463,104 @@ Continuous numbering Neprekidna numeracija Apply Primeni Convert Notes Pretvori napomene + A, B, C, … A, B, C, … + Above the item Iznad stavke + Accessed on Pristupljeno + Add New Source… Dodaj novi izvor… + Add the chapter number Dodaj broj poglavlja + Atmosphere Atmosfera + Below the item Ispod stavke + Book Knjiga + Book title Naslov knjige + Caption Natpis + Caption Numbering Numerisanje natpisa + Caption text Tekst natpisa + Caption: Natpis: + Chapter in a book Poglavlje u knjizi + Chapters begin at: Poglavlja počinju na: + Collect entries from: Stavke uzeti iz: + Columns: Kolone: + Conference Konferencija + Conference paper Rad sa konferencije + Container Title Naslov zbirke + Create Source Napravi izvor + Cross-reference: Unakrsna referenca: + Custom Table of Contents… Prilagođeni sadržaj… + DOI DOI + Day Dan + Delete Label Obriši oznaku + Edit Source Uredi izvor + Edition Izdanje + Edit… Uredi… + Entry marked. Stavka je označena. + Example: {sample} Primer: {sample} + Figure Slika + Film Film + Heading styles (levels 1–{n}) Stilovi naslova (nivoi 1–{n}) + Heading {n} Naslov {n} + I, II, III, … I, II, III, … + Include page numbers Uključi brojeve stranica + Indented Uvučeno + Institution Institucija + Ionosphere Jonosfera + Issue Broj + Journal Časopis + Label: Oznaka: + Last, First; Last, First Prezime, Ime; Prezime, Ime + Leave the label out of the caption Izostavi oznaku iz natpisa + Letter headings Naslovi po slovima + Levels: Nivoi: + Link entries to their headings Poveži stavke sa naslovima + Made from the author and year when empty Ako je prazno, pravi se od autora i godine + Magazine or newspaper article Članak u magazinu ili novinama + Main entry: Glavna stavka: + Mark Označi + Mark All Označi sve + Mark Entry… Označi stavku… + Mark Index Entry Označi stavku indeksa + Mesosphere Mezosfera + Month Mesec + New Label Nova oznaka + New Label… Nova oznaka… + New… Novo… + No sources yet. Još nema izvora. + Numbering… Numerisanje… + Options… Opcije… + Other Drugo + Other styles and their TOC level: Ostali stilovi i njihov nivo u sadržaju: + Outline levels Nivoi strukture + Page number style Stil broja stranice + Page numbers at the right margin Brojevi stranica uz desnu marginu + Page title Naslov stranice + Pages of bookmark: Stranice obeleživača: + Periodical Periodika + Position: Položaj: + Publisher Izdavač + Run-in U nizu + See Vidi + Separator: Razdvajač: + Show every bibliography field Prikaži sva polja bibliografije + Site name Naziv sajta + Source Manager Upravljač izvorima + Source type: Vrsta izvora: + Sources in this document: Izvori u ovom dokumentu: + Styles: Stilovi: + Subentry: Podstavka: + TC entry fields Polja stavki TC + Tab leader: Popuna tabulatora: + Table of Contents Options Opcije sadržaja + Table of Contents Styles Stilovi sadržaja + Tag Oznaka + This page Ova stranica + Title Naslov + Type: Vrsta: + URL URL + Volume Tom + Website Veb-sajt + Year Godina + a, b, c, … a, b, c, … + i, ii, iii, … i, ii, iii, … + {n} entries marked. Označeno stavki: {n}. Lasso Select Izbor lasom Move Selected Ink Premesti izabrani rukopis Delete Selected Ink Obriši izabrani rukopis diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 84270af129..4f2d003329 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -1467,6 +1467,104 @@ Continuous numbering Непрекидна нумерација Apply Примени Convert Notes Претвори напомене + A, B, C, … A, B, C, … + Above the item Изнад ставке + Accessed on Приступљено + Add New Source… Додај нови извор… + Add the chapter number Додај број поглавља + Atmosphere Атмосфера + Below the item Испод ставке + Book Књига + Book title Наслов књиге + Caption Натпис + Caption Numbering Нумерисање натписа + Caption text Текст натписа + Caption: Натпис: + Chapter in a book Поглавље у књизи + Chapters begin at: Поглавља почињу на: + Collect entries from: Ставке узети из: + Columns: Колоне: + Conference Конференција + Conference paper Рад са конференције + Container Title Наслов збирке + Create Source Направи извор + Cross-reference: Унакрсна референца: + Custom Table of Contents… Прилагођени садржај… + DOI DOI + Day Дан + Delete Label Обриши ознаку + Edit Source Уреди извор + Edition Издање + Edit… Уреди… + Entry marked. Ставка је означена. + Example: {sample} Пример: {sample} + Figure Слика + Film Филм + Heading styles (levels 1–{n}) Стилови наслова (нивои 1–{n}) + Heading {n} Наслов {n} + I, II, III, … I, II, III, … + Include page numbers Укључи бројеве страница + Indented Увучено + Institution Институција + Ionosphere Јоносфера + Issue Број + Journal Часопис + Label: Ознака: + Last, First; Last, First Презиме, Име; Презиме, Име + Leave the label out of the caption Изостави ознаку из натписа + Letter headings Наслови по словима + Levels: Нивои: + Link entries to their headings Повежи ставке са насловима + Made from the author and year when empty Ако је празно, прави се од аутора и године + Magazine or newspaper article Чланак у магазину или новинама + Main entry: Главна ставка: + Mark Означи + Mark All Означи све + Mark Entry… Означи ставку… + Mark Index Entry Означи ставку индекса + Mesosphere Мезосфера + Month Месец + New Label Нова ознака + New Label… Нова ознака… + New… Ново… + No sources yet. Још нема извора. + Numbering… Нумерисање… + Options… Опције… + Other Друго + Other styles and their TOC level: Остали стилови и њихов ниво у садржају: + Outline levels Нивои структуре + Page number style Стил броја странице + Page numbers at the right margin Бројеви страница уз десну маргину + Page title Наслов странице + Pages of bookmark: Странице обележивача: + Periodical Периодика + Position: Положај: + Publisher Издавач + Run-in У низу + See Види + Separator: Раздвајач: + Show every bibliography field Прикажи сва поља библиографије + Site name Назив сајта + Source Manager Управљач изворима + Source type: Врста извора: + Sources in this document: Извори у овом документу: + Styles: Стилови: + Subentry: Подставка: + TC entry fields Поља ставки TC + Tab leader: Попуна табулатора: + Table of Contents Options Опције садржаја + Table of Contents Styles Стилови садржаја + Tag Ознака + This page Ова страница + Title Наслов + Type: Врста: + URL URL + Volume Том + Website Веб-сајт + Year Година + a, b, c, … a, b, c, … + i, ii, iii, … i, ii, iii, … + {n} entries marked. Означено ставки: {n}. Lasso Select Избор ласом Move Selected Ink Премести изабрани рукопис Delete Selected Ink Обриши изабрани рукопис diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 4f80145d97..fa6a35527a 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1421,6 +1421,104 @@ Right-to-Left Section Sağdan Sola Bölüm Table direction: Tablo yönü: Section direction: Bölüm yönü: + A, B, C, … A, B, C, … + Above the item Öğenin üstünde + Accessed on Erişim tarihi + Add New Source… Yeni Kaynak Ekle… + Add the chapter number Bölüm numarasını ekle + Atmosphere Atmosfer + Below the item Öğenin altında + Book Kitap + Book title Kitap başlığı + Caption Başlık + Caption Numbering Başlık Numaralandırma + Caption text Başlık metni + Caption: Başlık: + Chapter in a book Kitapta bölüm + Chapters begin at: Bölümler şunla başlar: + Collect entries from: Girdileri şuradan topla: + Columns: Sütunlar: + Conference Konferans + Conference paper Konferans bildirisi + Container Title Kapsayıcı Başlığı + Create Source Kaynak Oluştur + Cross-reference: Çapraz başvuru: + Custom Table of Contents… Özel İçindekiler Tablosu… + DOI DOI + Day Gün + Delete Label Etiketi Sil + Edit Source Kaynağı Düzenle + Edition Baskı + Edit… Düzenle… + Entry marked. Girdi işaretlendi. + Example: {sample} Örnek: {sample} + Figure Şekil + Film Film + Heading styles (levels 1–{n}) Başlık stilleri (1–{n}. düzeyler) + Heading {n} Başlık {n} + I, II, III, … I, II, III, … + Include page numbers Sayfa numaralarını ekle + Indented Girintili + Institution Kurum + Ionosphere İyonosfer + Issue Sayı + Journal Dergi + Label: Etiket: + Last, First; Last, First Soyadı, Adı; Soyadı, Adı + Leave the label out of the caption Etiketi başlıktan çıkar + Letter headings Harf başlıkları + Levels: Düzeyler: + Link entries to their headings Girdileri başlıklarına bağla + Made from the author and year when empty Boşsa yazardan ve yıldan oluşturulur + Magazine or newspaper article Dergi veya gazete makalesi + Main entry: Ana girdi: + Mark İşaretle + Mark All Tümünü İşaretle + Mark Entry… Girdiyi İşaretle… + Mark Index Entry Dizin Girdisini İşaretle + Mesosphere Mezosfer + Month Ay + New Label Yeni Etiket + New Label… Yeni Etiket… + New… Yeni… + No sources yet. Henüz kaynak yok. + Numbering… Numaralandırma… + Options… Seçenekler… + Other Diğer + Other styles and their TOC level: Diğer stiller ve İçindekiler düzeyleri: + Outline levels Anahat düzeyleri + Page number style Sayfa numarası stili + Page numbers at the right margin Sayfa numaraları sağ kenar boşluğunda + Page title Sayfa başlığı + Pages of bookmark: Yer işaretinin sayfaları: + Periodical Süreli yayın + Position: Konum: + Publisher Yayıncı + Run-in Yan yana + See Bkz. + Separator: Ayırıcı: + Show every bibliography field Tüm kaynakça alanlarını göster + Site name Site adı + Source Manager Kaynak Yöneticisi + Source type: Kaynak türü: + Sources in this document: Bu belgedeki kaynaklar: + Styles: Stiller: + Subentry: Alt girdi: + TC entry fields TC girdi alanları + Tab leader: Sekme öncülü: + Table of Contents Options İçindekiler Tablosu Seçenekleri + Table of Contents Styles İçindekiler Tablosu Stilleri + Tag Etiket + This page Bu sayfa + Title Başlık + Type: Tür: + URL URL + Volume Cilt + Website Web sitesi + Year Yıl + a, b, c, … a, b, c, … + i, ii, iii, … i, ii, iii, … + {n} entries marked. {n} girdi işaretlendi. Lasso Select Kement Seçimi Move Selected Ink Seçili Mürekkebi Taşı Delete Selected Ink Seçili Mürekkebi Sil diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 909d87a305..3c9d51872a 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1465,6 +1465,104 @@ Continuous numbering Наскрізна нумерація Apply Застосувати Convert Notes Перетворити примітки + A, B, C, … A, B, C, … + Above the item Над елементом + Accessed on Дата звернення + Add New Source… Додати нове джерело… + Add the chapter number Додати номер розділу + Atmosphere Атмосфера + Below the item Під елементом + Book Книга + Book title Назва книги + Caption Підпис + Caption Numbering Нумерація підписів + Caption text Текст підпису + Caption: Підпис: + Chapter in a book Розділ книги + Chapters begin at: Розділи починаються з: + Collect entries from: Брати елементи з: + Columns: Стовпці: + Conference Конференція + Conference paper Доповідь конференції + Container Title Назва видання + Create Source Створити джерело + Cross-reference: Перехресне посилання: + Custom Table of Contents… Настроюваний зміст… + DOI DOI + Day День + Delete Label Видалити мітку + Edit Source Редагувати джерело + Edition Видання + Edit… Редагувати… + Entry marked. Елемент позначено. + Example: {sample} Приклад: {sample} + Figure Рисунок + Film Фільм + Heading styles (levels 1–{n}) Стилі заголовків (рівні 1–{n}) + Heading {n} Заголовок {n} + I, II, III, … I, II, III, … + Include page numbers Додавати номери сторінок + Indented З відступом + Institution Установа + Ionosphere Іоносфера + Issue Випуск + Journal Журнал + Label: Мітка: + Last, First; Last, First Прізвище, Ім’я; Прізвище, Ім’я + Leave the label out of the caption Не включати мітку в підпис + Letter headings Літерні заголовки + Levels: Рівні: + Link entries to their headings Пов’язати елементи із заголовками + Made from the author and year when empty Якщо порожньо, утворюється з автора й року + Magazine or newspaper article Стаття в журналі чи газеті + Main entry: Основний елемент: + Mark Позначити + Mark All Позначити всі + Mark Entry… Позначити елемент… + Mark Index Entry Позначити елемент покажчика + Mesosphere Мезосфера + Month Місяць + New Label Нова мітка + New Label… Нова мітка… + New… Створити… + No sources yet. Джерел ще немає. + Numbering… Нумерація… + Options… Параметри… + Other Інше + Other styles and their TOC level: Інші стилі та їхній рівень у змісті: + Outline levels Рівні структури + Page number style Стиль номера сторінки + Page numbers at the right margin Номери сторінок біля правого поля + Page title Назва сторінки + Pages of bookmark: Сторінки закладки: + Periodical Періодичне видання + Position: Розташування: + Publisher Видавець + Run-in Суцільний + See Див. + Separator: Роздільник: + Show every bibliography field Показати всі поля бібліографії + Site name Назва сайту + Source Manager Диспетчер джерел + Source type: Тип джерела: + Sources in this document: Джерела в цьому документі: + Styles: Стилі: + Subentry: Піделемент: + TC entry fields Поля елементів TC + Tab leader: Заповнювач табуляції: + Table of Contents Options Параметри змісту + Table of Contents Styles Стилі змісту + Tag Тег + This page Ця сторінка + Title Назва + Type: Тип: + URL URL + Volume Том + Website Вебсайт + Year Рік + a, b, c, … a, b, c, … + i, ii, iii, … i, ii, iii, … + {n} entries marked. Позначено елементів: {n}. Lasso Select Виділення ласо Move Selected Ink Перемістити виділений рукопис Delete Selected Ink Видалити виділений рукопис diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 2465337d77..2d735ab2d4 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1465,6 +1465,104 @@ Continuous numbering 连续编号 Apply 应用 Convert Notes 转换注释 + A, B, C, … A, B, C, … + Above the item 项目上方 + Accessed on 访问日期 + Add New Source… 添加新源… + Add the chapter number 添加章节编号 + Atmosphere 大气 + Below the item 项目下方 + Book 书籍 + Book title 书名 + Caption 题注 + Caption Numbering 题注编号 + Caption text 题注文字 + Caption: 题注: + Chapter in a book 书籍章节 + Chapters begin at: 章节起始于: + Collect entries from: 条目来源: + Columns: 栏数: + Conference 会议 + Conference paper 会议论文 + Container Title 载体标题 + Create Source 创建源 + Cross-reference: 交叉引用: + Custom Table of Contents… 自定义目录… + DOI DOI + Day 日 + Delete Label 删除标签 + Edit Source 编辑源 + Edition 版次 + Edit… 编辑… + Entry marked. 已标记条目。 + Example: {sample} 示例:{sample} + Figure 图 + Film 电影 + Heading styles (levels 1–{n}) 标题样式(级别 1–{n}) + Heading {n} 标题 {n} + I, II, III, … I, II, III, … + Include page numbers 包含页码 + Indented 缩进式 + Institution 机构 + Ionosphere 电离层 + Issue 期号 + Journal 期刊 + Label: 标签: + Last, First; Last, First 姓, 名; 姓, 名 + Leave the label out of the caption 题注中不含标签 + Letter headings 字母标题 + Levels: 级别: + Link entries to their headings 将条目链接到标题 + Made from the author and year when empty 留空时由作者和年份生成 + Magazine or newspaper article 杂志或报纸文章 + Main entry: 主索引项: + Mark 标记 + Mark All 全部标记 + Mark Entry… 标记条目… + Mark Index Entry 标记索引项 + Mesosphere 中间层 + Month 月 + New Label 新建标签 + New Label… 新建标签… + New… 新建… + No sources yet. 尚无源。 + Numbering… 编号… + Options… 选项… + Other 其他 + Other styles and their TOC level: 其他样式及其目录级别: + Outline levels 大纲级别 + Page number style 页码样式 + Page numbers at the right margin 页码靠右边距 + Page title 页面标题 + Pages of bookmark: 书签所在页: + Periodical 期刊名 + Position: 位置: + Publisher 出版商 + Run-in 接排式 + See 参见 + Separator: 分隔符: + Show every bibliography field 显示所有书目字段 + Site name 网站名称 + Source Manager 源管理器 + Source type: 源类型: + Sources in this document: 本文档中的源: + Styles: 样式: + Subentry: 次索引项: + TC entry fields TC 条目域 + Tab leader: 制表符前导符: + Table of Contents Options 目录选项 + Table of Contents Styles 目录样式 + Tag 标记名 + This page 当前页 + Title 标题 + Type: 类型: + URL URL + Volume 卷 + Website 网站 + Year 年份 + a, b, c, … a, b, c, … + i, ii, iii, … i, ii, iii, … + {n} entries marked. 已标记 {n} 个条目。 Lasso Select 套索选择 Move Selected Ink 移动所选墨迹 Delete Selected Ink 删除所选墨迹 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 0f564e3d9c..94a8774990 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1454,6 +1454,104 @@ Continuous numbering 連續編號 Apply 套用 Convert Notes 轉換附註 + A, B, C, … A, B, C, … + Above the item 項目上方 + Accessed on 存取日期 + Add New Source… 新增來源… + Add the chapter number 加入章節編號 + Atmosphere 大氣 + Below the item 項目下方 + Book 書籍 + Book title 書名 + Caption 標號 + Caption Numbering 標號編號 + Caption text 標號文字 + Caption: 標號: + Chapter in a book 書籍章節 + Chapters begin at: 章節起始於: + Collect entries from: 項目來源: + Columns: 欄數: + Conference 會議 + Conference paper 會議論文 + Container Title 載體標題 + Create Source 建立來源 + Cross-reference: 交互參照: + Custom Table of Contents… 自訂目錄… + DOI DOI + Day 日 + Delete Label 刪除標籤 + Edit Source 編輯來源 + Edition 版次 + Edit… 編輯… + Entry marked. 已標記項目。 + Example: {sample} 範例:{sample} + Figure 圖 + Film 電影 + Heading styles (levels 1–{n}) 標題樣式(階層 1–{n}) + Heading {n} 標題 {n} + I, II, III, … I, II, III, … + Include page numbers 包含頁碼 + Indented 縮排式 + Institution 機構 + Ionosphere 電離層 + Issue 期號 + Journal 期刊 + Label: 標籤: + Last, First; Last, First 姓, 名; 姓, 名 + Leave the label out of the caption 標號中不含標籤 + Letter headings 字母標題 + Levels: 階層: + Link entries to their headings 將項目連結到標題 + Made from the author and year when empty 留空時由作者和年份產生 + Magazine or newspaper article 雜誌或報紙文章 + Main entry: 主索引項: + Mark 標記 + Mark All 全部標記 + Mark Entry… 標記項目… + Mark Index Entry 標記索引項目 + Mesosphere 中氣層 + Month 月 + New Label 新增標籤 + New Label… 新增標籤… + New… 新增… + No sources yet. 尚無來源。 + Numbering… 編號… + Options… 選項… + Other 其他 + Other styles and their TOC level: 其他樣式及其目錄階層: + Outline levels 大綱階層 + Page number style 頁碼樣式 + Page numbers at the right margin 頁碼靠右邊界 + Page title 頁面標題 + Pages of bookmark: 書籤所在頁: + Periodical 期刊名 + Position: 位置: + Publisher 出版者 + Run-in 接排式 + See 參見 + Separator: 分隔符號: + Show every bibliography field 顯示所有書目欄位 + Site name 網站名稱 + Source Manager 來源管理員 + Source type: 來源類型: + Sources in this document: 此文件中的來源: + Styles: 樣式: + Subentry: 次索引項: + TC entry fields TC 項目功能變數 + Tab leader: 定位點前置字元: + Table of Contents Options 目錄選項 + Table of Contents Styles 目錄樣式 + Tag 標記名 + This page 目前頁面 + Title 標題 + Type: 類型: + URL URL + Volume 卷 + Website 網站 + Year 年份 + a, b, c, … a, b, c, … + i, ii, iii, … i, ii, iii, … + {n} entries marked. 已標記 {n} 個項目。 Lasso Select 套索選取 Move Selected Ink 移動選取的筆跡 Delete Selected Ink 刪除選取的筆跡 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 092de66829..f95a6c01a0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -24,6 +24,7 @@ pub mod dialogs_keyboard; pub mod dialogs_lists; pub mod dialogs_notes; pub mod dialogs_para; +pub mod dialogs_refs; pub mod dialogs_ribbon; pub mod equation_tab; pub mod file_dialogs; @@ -135,6 +136,9 @@ pub struct UiState { /// Custom ribbon tabs and groups, hidden tabs and the Quick Access Toolbar (Customize Ribbon, /// #379); the session owns them while running. pub ribbon: wordcraft_engine::RibbonLayout, + /// Caption labels the user added (References › Insert Caption › New Label), kept between + /// runs like Word keeps them in its Normal template (#402). + pub caption_labels: Vec, /// Draw › Add Pen: the pens added to the pen gallery; the session owns them while running. /// Entries that aren't pens are dropped on reading. #[serde(deserialize_with = "wordcraft_engine::cmd::draw::lenient_pens")] @@ -166,6 +170,7 @@ impl Default for UiState { dark_page: false, keyboard: wordcraft_engine::KeyMap::default(), ribbon: wordcraft_engine::RibbonLayout::default(), + caption_labels: Vec::new(), ink_pens: Vec::new(), } } @@ -1200,6 +1205,14 @@ fn input_dialog(id: &str, params: &Value) -> Option<&'static str> { "format.enclose" if params.get("shape").is_none() => Some("enclose"), // Footnote and Endnote without settings shows its dialog (#385). "references.noteOptions" if params.as_object().is_none_or(|m| m.is_empty()) => Some("noteOptions"), + // References dialogs (#402): Insert Caption, Insert Index, Mark Entry, Manage Sources and + // Insert Citation without a source. + "references.caption" | "references.index" if params.as_object().is_none_or(|m| m.is_empty()) => { + Some(if id == "references.caption" { "caption" } else { "index" }) + } + "references.markEntry" if !has("entry") => Some("markEntry"), + "references.sources" if params.as_object().is_none_or(|m| m.is_empty()) => Some("sourceManager"), + "references.citation" if !has("tag") && !has("source") => Some("createSource"), _ => None, } } diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 749a1cc87f..41e0247906 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1089,6 +1089,8 @@ fn references(app: &mut WordApp, ui: &mut Ui) { menu_button(ui, app, "toc", Some("Table of\nContents"), "Table of Contents", true, |ui, app| { mi(ui, app, "Automatic Table 1 (3 levels)", "references.toc", json!({"levels": 3})); mi(ui, app, "Automatic Table 2 (2 levels)", "references.toc", json!({"levels": 2, "title": "Table of Contents"})); + ui.separator(); + mi(ui, app, "Custom Table of Contents…", "ui.dialog", json!({"name": "toc"})); mi(ui, app, "Remove Table of Contents", "references.removeToc", json!({})); }); stack(ui, |ui| { @@ -1113,7 +1115,18 @@ fn references(app: &mut WordApp, ui: &mut Ui) { big(ui, app, "researcher", "Researcher", "references.researcher", json!({}), false); }); group(ui, "Citations & Bibliography", None, app, |ui, app| { - big(ui, app, "citation", "Insert\nCitation", "references.citation", json!({}), false); + // The document's sources to cite, then a new one (Create Source, #402). + menu_button(ui, app, "citation", Some("Insert\nCitation"), "Insert Citation", true, |ui, app| { + let sources: Vec<(String, String)> = + app.session.doc.sources.iter().map(|s| (s.tag.clone(), format!("{} — {}", s.author, s.title))).collect(); + for (tag, label) in sources { + mi_text(ui, app, &label, "references.citation", json!({"tag": tag})); + } + if !app.session.doc.sources.is_empty() { + ui.separator(); + } + mi(ui, app, "Add New Source…", "ui.dialog", json!({"name": "createSource"})); + }); stack(ui, |ui| { small(ui, app, "sources", Some("Manage Sources"), "Manage Sources", "references.sources", json!({}), false); let current = app.session.bib_style.clone(); diff --git a/docs/gaps.md b/docs/gaps.md index 0b7d477fb5..0ec7a24d74 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -17,7 +17,7 @@ first (`gh pr list`), and update this file and the parity doc when a gap closes. | 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options ignored; no page-by-page comparison harness | B | [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) | | 5 | **Content controls** are lost (`w:sdt` unwrapped). Move tracking landed (#400): `w:moveFrom`/`w:moveTo` with their named ranges round-trip, a tracked cut and paste of a paragraph or sentence records a move, either end accepts/rejects both, green double strike/underline markup and "Moved from/to" balloons; still missing: drag-and-drop moves (no text drag yet) and move options in Track Changes Options. Formatting revisions landed (#41): `w:rPrChange`/`w:pPrChange`/`w:tblPrChange`/`w:trPrChange`/`w:tcPrChange`/`w:sectPrChange`/`w:numberingChange` round-trip, are recorded while tracking, accepted/rejected and shown in balloons; still missing: tracked style-definition changes and table/section changes made in WordCraft | B | Issues #41, #400; source grep | Legal and editorial review workflows; templates with form controls | 5–8 | [file-format-parity.md](file-format-parity.md) | | 6 | **No native printing** on desktop: File › Print opens the PDF in the system viewer, which prints (#286); no print dialog of our own (printer, copies, ranges); web prints through the browser (#209) | B | `ui.print` → `apps/wordcraft/src/print.rs`; issue #15 | Everyone who prints | 5–8 | [hardware-parity.md](hardware-parity.md) | -| 7 | **Dialog depth**: 35 modal dialogs vs Word's ~100 (Caption, Index, TOC options, Citation, Compare, Protect, Language, Hyphenation, Line Numbers, Envelopes, Labels, AutoCorrect, Options panes…) | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs` | Power users reach for dialogs the ribbon alone doesn't cover | 20–30 | [ui-parity.md](ui-parity.md) | +| 7 | **Dialog depth**: 41 modal dialogs vs Word's ~100 (Compare, Protect, Language, Hyphenation, Line Numbers, Envelopes, Labels, AutoCorrect, Options panes…) | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs` | Power users reach for dialogs the ribbon alone doesn't cover | 15–25 | [ui-parity.md](ui-parity.md) | | 8 | **Proofing is English only**; no language per run, no dictionaries for other languages | | `crates/proof` has one dictionary; Word ships 120 proofing tools; issues #25, #40, #100 | Every non-English writer | 25–45 | [localization-parity.md](localization-parity.md) | | 9 | **Password-protected files** can't be opened or saved | | #55; `docbin` detects and refuses RC4/XOR | Corporate and legal users | 6–10 | [file-format-parity.md](file-format-parity.md) | | 10 | **Objects**: no group (#267 open), text in a freely rotated text box (free rotation and flips landed, #332; text direction, alignment and linked text boxes landed, #369; contour wrap and wrap side landed, #354), shape effects, WordArt, connectors, dragging members inside a Drawing Canvas (the canvas landed, #344) | | [parity-checklist.md](parity-checklist.md) (Shape Format 6/6, Layout › Group) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) | @@ -64,6 +64,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Caption, Index, Mark Index Entry, Custom Table of Contents, Source Manager and Create/Edit Source dialogs landed (#402); removed from the dialog-depth list | | 2026-10-11 | trivial | Document grid landed (#391); removed from the East Asian gap | | 2026-10-11 | trivial | Move tracking landed (#400); gap #5 narrowed to content controls, 5–8 h | | 2026-10-11 | trivial | #4: note options per section (below text, end of section, restart each section/page) and the Footnote and Endnote dialog (#385) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index fc58b630b8..18d9cd4a39 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -64,7 +64,7 @@ full parity for the area. | Page layout and sections | 7 | 70% | 72% | 12–20 | Margins, size, columns, breaks, page borders, line numbers, vertical alignment, drop caps, hyphenation. Columns don't balance; no vertical text; RTL sections missing. Detail: [layout-parity.md](layout-parity.md). | | Headers, footers, page numbers, fields | 5 | 70% | 70% | 8–12 | First/even/odd, link to previous, TOC as an updatable field (#52), cross-references to real bookmarks (#227). Field coverage is a subset of Word's ~90 field codes; Field dialog missing. | | Footnotes and endnotes | 3 | 60% | 60% | 3–5 | Placed and editable, numbers from Word files (#103); long notes continue onto the next page under the continuation separator (#352). Footnote and Endnote dialog with options per section: below text, end of section, number format, start at, restart each section or page, custom mark, convert (#385). | -| References (TOC, citations, bibliography, captions, index, TOA) | 5 | 65% | 60% | 12–20 | Working first versions, Zotero integration (#189), Zotero/Mendeley `ADDIN` fields round-trip. All 12 of Word's bibliography styles (APA, MLA, Chicago, IEEE, Harvard, ISO 690 author-date and numerical, Turabian, GB/T 7714, GOST name and title sort, SIST02; #383); source manager depth; bibliography sources not yet in DOCX (#169 open). | +| References (TOC, citations, bibliography, captions, index, TOA) | 5 | 65% | 60% | 12–20 | Working first versions, Zotero integration (#189), Zotero/Mendeley `ADDIN` fields round-trip. Caption, Index, Mark Entry, Custom TOC and Create/Edit Source dialogs; updates honour the TOC (`\o \u \t \f \n \p \h`), INDEX (`\c \e \h \r`), XE (`\b \i \t \r`) and SEQ/STYLEREF chapter switches (#402). All 12 of Word's bibliography styles (APA, MLA, Chicago, IEEE, Harvard, ISO 690 author-date and numerical, Turabian, GB/T 7714, GOST name and title sort, SIST02; #383); bibliography sources not yet in DOCX (#169 open). | | Review (proofing UI, comments, track changes, compare, protect) | 8 | 72% | 75% | 15–25 | Comment balloons, tracked insert/delete incl. paragraph marks (#244, #125), compare, restrict editing. Formatting revisions (#41): kept in DOCX, recorded while tracking, accepted/rejected, "Formatted: …" balloons and change bars. Moves (#400): kept in DOCX, recorded on a tracked cut and paste, accepted/rejected at either end, green markup and "Moved from/to" balloons. Missing: drag-and-drop moves, insert/delete balloons, Translate, Block Authors, modern comment threads/mentions. Lowered: these gaps are confirmed by source (no `rPrChange` anywhere). | | Proofing (spelling, grammar, thesaurus, languages) | 5 | 35% | (in Review) | 25–45 | English only: one dictionary (Moby list plus our supplement of modern words such as "desktop", #520), a rule-based grammar checker. Word ships 120 proofing tools for ~50 languages plus Editor (style refinements, similarity). Issues #25, #40, #100. | | Mailings | 2 | 70% | 70% | 6–10 | Mail merge from CSV, fields, rules (IF, SKIPIF, NEXT, NEXTIF, evaluated alike in preview and merge), fields in headers/footers (one section per letter), preview, envelopes, labels. Select Recipients takes a typed list, CSV (#247) or a sheet of an Excel (.xlsx/.xlsm) or OpenDocument (.ods) workbook, with Select Table to pick the sheet (#334); no Outlook/Contacts/Access data sources, no email merge. | @@ -249,6 +249,7 @@ can be used without committing Word output. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | References: Caption, Index, Mark Entry, Custom Table of Contents and Create/Edit Source dialogs, with the field switches they write honoured on update (#402); no percentage change | | 2026-10-11 | trivial | Move tracking (#400): Review row updated; no percentage change | | 2026-10-11 | trivial | Footnotes and endnotes: Footnote and Endnote dialog, options per section (position, format, start, restart each section/page), convert notes (#385); no percentage change | | 2026-10-11 | trivial | Pictures, shapes, text boxes: text direction, Align Text and linked text boxes (Create Link / Break Link), DOCX `bodyPr` `vert`/`anchor` and `wps:linkedTxbx` (#369); no percentage change | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 99e4731f13..e506318999 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -16,7 +16,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | Quick Access Toolbar | AutoSave, then the user's commands (Save, Undo, Redo by default); add, remove and reorder from its … menu (common commands, More Commands…), right-click on any ribbon button, or Customize Ribbon; saved per user (#379, #376). Not shown below the ribbon | customizable | 90% | 0.5 | | Customize Ribbon / Customize Keyboard | Customize Keyboard (#368): categories, a command's keys, press a combination to see which command uses it, Assign / Remove / Reset All; custom keys override built-ins, removed built-ins do nothing, saved per user; `tools.customizeKeyboard` for agents. Customize Ribbon (#379): show, hide and reorder tabs, custom tabs and custom groups (on any tab) holding any commands, rename, remove, Reset; right-click on the ribbon; saved per user; `tools.customizeRibbon` for agents. Built-in tabs can't be renamed; no import/export file | ✅ | 85% | 1–2 | | Keyboard shortcuts | **125** commands bound in `CommandSpec::key`, 144 key combinations (measured on Linux/Windows); styles, spacing, symbols, fields, views, mail merge (#330); field keys Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, F11, Shift+F11 (#339); Alt+Shift+arrows (move paragraphs, promote/demote), Shift+F5 Go Back and Alt+X (#343). Missing: en dash on the numeric keypad | ~250 default shortcuts | ~70% | 2–4 | -| Modal dialogs | **38** (`Dialog` enum: Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Icons, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors) plus panes and menus for the rest | ~100 (Caption, Index, TOC options, Citation, Compare, Protect, Language, Hyphenation, Line Numbers, Envelopes, Labels, AutoCorrect, Options panes…) | ~40% | 20–30 | +| Modal dialogs | **44** (`Dialog` enum: Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Icons, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors; Caption with New Label and Numbering, Index, Mark Index Entry, Custom Table of Contents with Options and TOC styles, Source Manager, Create/Edit Source (#402)) plus panes and menus for the rest | ~100 (Compare, Protect, Language, Hyphenation, Line Numbers, Envelopes, Labels, AutoCorrect, Options panes…) | ~45% | 15–25 | | Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation, Styles, Comments, Thesaurus, Accessibility, Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 | | Context menus (right-click) | text, spelling suggestions, fields (Update Field, Edit Field…, Toggle Field Codes, #339); objects and tables partial | rich per context | 55% | 3–5 | | Backstage (Home, New, Open, Info, Save As, Print, Share, Export, Options) | Home and New separate (#254), Info properties kept (#268) | | 68% | 4–6 | @@ -37,6 +37,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | References dialogs: Caption (New/Delete Label, Numbering with chapter numbers), Index and Mark Index Entry (Mark All), Custom Table of Contents (Options, TOC styles via Modify Style), Source Manager and Create/Edit Source; modal dialog count 34 (#402) | | 2026-10-11 | trivial | Customize Ribbon (custom tabs and groups, hidden and reordered tabs) and a customizable Quick Access Toolbar with a working … menu (#379, #376) | | 2026-10-11 | trivial | Customize Keyboard dialog (Tools › Customize Keyboard, File › Options › Keyboard shortcuts): custom keys override built-ins and persist (#368) | | 2026-10-11 | minor | Keyboard shortcuts 101 → 125 commands (#330): Reset Paragraph/Character Formatting, symbols, DATE/TIME/PAGE fields, view switches, Word Count, mail merge, F4 Repeat now reachable | From d5bcb9b4902019f078b89ab100b96853251fb623 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 11 Oct 2026 22:01:20 +0200 Subject: [PATCH 03/89] Accessibility: alt text for every object, decorative flag, more checker rules (#386) (#396) - Model: Float carries AltText (title, decorative; the description of shapes and groups); InlineObject::alt_text/set_alt_text/is_decorative; TableProps.description. Capped input. - DOCX: wp:docPr / pic:cNvPr / wps:cNvPr descr, title and the [MS-ODRAWXML] adec:decorative extension read and written for pictures, shapes, text boxes, groups and members, charts; OLE objects get their alt text in the kept XML; w:tblCaption + w:tblDescription. - Commands: object.altText {text?, title?, decorative?} (picture.altText kept as alias), table.altText {title?, text?}, view.accessibilityPane, view.altTextPane. - Checker (cmd/accessibility.rs): alt text on every object and table (decorative passes), heading order, header rows, blank table rows/columns, merged cells, WCAG contrast, floating objects, blank paragraphs; issues carry pos/select and fix actions. - UI: Accessibility pane (grouped, jump-to, fix buttons), Alt Text pane, Picture/Shape Format > Accessibility > Alt Text, right-click > View Alt Text...; strings in all catalogs. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +- crates/doc/src/para.rs | 57 +++++ crates/doc/src/props.rs | 5 +- crates/docx/src/read/props.rs | 5 +- crates/docx/src/read/story.rs | 41 +++- crates/docx/src/write/embed.rs | 23 +- crates/docx/src/write/props.rs | 3 + crates/docx/src/write/story.rs | 39 ++- crates/docx/src/xml.rs | 5 + crates/docx/tests/roundtrip.rs | 74 ++++++ crates/engine/src/catalog.rs | 3 + crates/engine/src/cmd/accessibility.rs | 321 +++++++++++++++++++++++++ crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/cmd/objects.rs | 80 +++++- crates/engine/src/cmd/table.rs | 17 ++ crates/engine/src/cmd/tools.rs | 45 +--- crates/engine/src/cmd/view.rs | 8 + crates/engine/src/session.rs | 8 + crates/ui-egui/src/a11y_pane.rs | 158 ++++++++++++ crates/ui-egui/src/canvas.rs | 4 + crates/ui-egui/src/i18n/audit.rs | 5 + crates/ui-egui/src/i18n/es.tsv | 14 ++ crates/ui-egui/src/i18n/et.tsv | 14 ++ crates/ui-egui/src/i18n/ja.tsv | 14 ++ crates/ui-egui/src/i18n/pt-br.tsv | 14 ++ crates/ui-egui/src/i18n/ru.tsv | 14 ++ crates/ui-egui/src/i18n/sr-latn.tsv | 14 ++ crates/ui-egui/src/i18n/sr.tsv | 14 ++ crates/ui-egui/src/i18n/tr.tsv | 14 ++ crates/ui-egui/src/i18n/uk.tsv | 14 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 14 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 14 ++ crates/ui-egui/src/icons.rs | 8 + crates/ui-egui/src/lib.rs | 5 + crates/ui-egui/src/panes.rs | 19 +- crates/ui-egui/src/ribbon.rs | 9 + docs/file-format-parity.md | 2 + docs/parity-checklist.md | 8 +- docs/target-app-parity.md | 5 +- docs/ui-parity.md | 3 +- 40 files changed, 1031 insertions(+), 88 deletions(-) create mode 100644 crates/engine/src/cmd/accessibility.rs create mode 100644 crates/ui-egui/src/a11y_pane.rs diff --git a/ROADMAP.md b/ROADMAP.md index 17a263f993..cf684ff7e4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -14,7 +14,7 @@ agent control. This page is the summary; the assessment is | Number | Value | Kind | |---|---|---| -| Ribbon/menu catalog coverage | **433 / 451 (96.0%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) | +| Ribbon/menu catalog coverage | **436 / 454 (96.0%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) | | **Feature breadth** (weighted, incl. dialog options, styles, languages beyond the ribbon) | **~80%** | estimated | | **Ready for real work** (full target) | **~60%** (55–63%) | estimated, additive weighted sum over the dimensions | | **Mainstream practitioner** | **~55%** | estimated: weekly-work depth × discounts for interaction (×0.93), stability (×0.90), file exchange (×0.90) | @@ -131,6 +131,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| +| 2026-10-11 | Accessibility (#386): Alt Text pane for every object and table, Mark as decorative, alt text round-trips in DOCX, deeper checker with fix actions | | 2026-10-11 | References dialogs (#402): Caption (labels, chapter numbering), Index and Mark Entry, Custom Table of Contents, Source Manager and Create/Edit Source, with TOC/INDEX/XE/SEQ switches honoured on update | | 2026-10-11 | Document grid (#391): Page Setup › Document Grid sets lines per page and characters per line (`w:docGrid` round trip); lines snap to the line pitch, East Asian characters to the character pitch; View › Gridlines shows it | | 2026-10-11 | Move tracking (#400): `w:moveFrom`/`w:moveTo` and their named ranges kept in DOCX, a tracked cut and paste of a paragraph or sentence records a move, accept/reject at either end resolves both, green double strikethrough/underline and "Moved from/to" balloons | @@ -164,6 +165,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Progress log: Alt Text pane, decorative objects, deeper accessibility checker (#386) | | 2026-10-11 | trivial | Progress log: Draw lasso, Add Pen, Ink to Shape, Ink Replay (#395); catalog 406/434 | | 2026-10-11 | trivial | Progress log: document grid (#391) | | 2026-10-11 | trivial | Progress log: text direction, Align Text and linked text boxes (#369) | diff --git a/crates/doc/src/para.rs b/crates/doc/src/para.rs index 9fd05d432f..2a9ce12991 100644 --- a/crates/doc/src/para.rs +++ b/crates/doc/src/para.rs @@ -208,6 +208,41 @@ pub struct Float { /// (unrotated) frame; `None` = derived from the object (see [`crate::wrap`]). #[serde(skip_serializing_if = "Option::is_none")] pub wrap_polygon: Option>, + /// What the drawing is, for screen readers and exports (`wp:docPr` `@descr`, `@title` and the + /// decorative mark). See [`InlineObject::alt_text`]. + #[serde(skip_serializing_if = "AltText::is_empty")] + pub alt: AltText, +} + +/// Longest alternative text kept (characters): hostile files and commands are capped. +pub const MAX_ALT_TEXT: usize = 10_000; + +/// A drawing's alternative text (Picture/Shape Format › Accessibility › Alt Text). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(default, rename_all = "camelCase")] +pub struct AltText { + /// The description of a shape, text box or group. Pictures and charts keep theirs in their + /// own `alt`; read it with [`InlineObject::alt_text`], which knows where it is. + #[serde(skip_serializing_if = "String::is_empty")] + pub text: String, + /// The short title (`wp:docPr/@title`). + #[serde(skip_serializing_if = "String::is_empty")] + pub title: String, + /// Decorative: it adds nothing to the content, so screen readers skip it and the + /// accessibility checker doesn't ask for a description. + #[serde(skip_serializing_if = "is_false")] + pub decorative: bool, +} + +impl AltText { + pub fn is_empty(&self) -> bool { + self.text.is_empty() && self.title.is_empty() && !self.decorative + } +} + +/// `s` cut to [`MAX_ALT_TEXT`] characters. +pub fn cap_alt(s: &str) -> String { + s.chars().take(MAX_ALT_TEXT).collect() } fn is_zero(v: &f32) -> bool { @@ -607,6 +642,28 @@ impl InlineObject { | InlineObject::FieldEnd ) } + /// A drawing's description (alternative text), wherever it is kept. + pub fn alt_text(&self) -> &str { + match self { + InlineObject::Image { alt, .. } | InlineObject::Graphic { alt, .. } => alt, + InlineObject::Shape { float, .. } | InlineObject::Group { float, .. } => &float.alt.text, + _ => "", + } + } + /// Set a drawing's description (capped at [`MAX_ALT_TEXT`] characters); `false` if this is no drawing. + pub fn set_alt_text(&mut self, text: &str) -> bool { + let text = cap_alt(text); + match self { + InlineObject::Image { alt, .. } | InlineObject::Graphic { alt, .. } => *alt = text, + InlineObject::Shape { float, .. } | InlineObject::Group { float, .. } => float.alt.text = text, + _ => return false, + } + true + } + /// A drawing marked decorative. + pub fn is_decorative(&self) -> bool { + self.frame().is_some_and(|(_, _, f)| f.alt.decorative) + } pub fn is_floating(&self) -> bool { match self { InlineObject::Image { float, .. } diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index c8cdcffb0e..f5407d7075 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -873,8 +873,11 @@ pub struct TableProps { /// column, banded columns. pub look: TableLook, pub shading: Option, - /// Alternative text. + /// Alternative text: the title (`w:tblCaption`). pub caption: Option, + /// Alternative text: the description (`w:tblDescription`). + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, /// Floating placement (`w:tblpPr`); `None` for a table in the text flow. #[serde(skip_serializing_if = "Option::is_none")] pub float: Option, diff --git a/crates/docx/src/read/props.rs b/crates/docx/src/read/props.rs index d423107747..d58b1111c9 100644 --- a/crates/docx/src/read/props.rs +++ b/crates/docx/src/read/props.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; +use wordcraft_doc::para::cap_alt; use wordcraft_doc::props::{ Align, Border, BorderStyle, Borders, CellProps, CharProps, HeightRule, Highlight, LineSpacing, NumRef, ParaProps, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableFloat, TableLook, TableProps, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign, @@ -317,8 +318,8 @@ impl PropCtx { f.overlap = k.attr("w:val") != Some("never"); } } - "w:tblCaption" => t.caption = k.attr("w:val").map(str::to_string), - "w:tblDescription" if t.caption.is_none() => t.caption = k.attr("w:val").map(str::to_string), + "w:tblCaption" => t.caption = k.attr("w:val").map(cap_alt), + "w:tblDescription" => t.description = k.attr("w:val").map(cap_alt), _ => {} } } diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index e72b91496a..aad9b26de4 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use wordcraft_doc::effects::{Glow, Shadow, ShapeEffects}; use wordcraft_doc::graphic::{Embedded, Graphic, GraphicItem, GraphicKind}; -use wordcraft_doc::para::{Anchor, Float, FloatAlign, NoteKind, ShapeKind, Wrap, WrapText}; +use wordcraft_doc::para::{AltText, Anchor, Float, FloatAlign, NoteKind, ShapeKind, Wrap, WrapText, cap_alt}; use wordcraft_doc::props::{CharProps, NumChange, PropChange, Rgb}; use wordcraft_doc::table::{Cell, MAX_COLS, MAX_ROWS, Row, Table}; use wordcraft_doc::{Block, Blocks, InlineObject, Paragraph, PartKind, RevisionKind, Run, para_block}; @@ -771,8 +771,9 @@ impl Reader<'_> { let ext = c.child("wp:extent"); let dim = |n: &str| ext.and_then(|e| e.attr(n)).and_then(|v| measure(v, 12_700.0)).unwrap_or(0.0).clamp(0.0, crate::units::MAX_LEN_PT); let (w, h) = (dim("cx"), dim("cy")); - let alt = c.child("wp:docPr").and_then(|p| p.attr("descr").filter(|s| !s.is_empty()).or_else(|| p.attr("title"))).unwrap_or("").to_string(); + let (alt, meta) = nv_alt(c.child("wp:docPr")); let mut float = if anchored { anchor_float(c) } else { Float::default() }; + float.alt = meta; if let Some(e) = c.child("wp:effectExtent") { for (slot, n) in float.effect.iter_mut().zip(["l", "t", "r", "b"]) { // Negative for a rotated object narrower than its frame; settled below. @@ -806,7 +807,7 @@ impl Reader<'_> { } else if let Some(g) = gd.child("wpg:wgp") { self.read_group(sc, g, rels, w, h, float)? } else if gd.find("a:blip").is_some() { - self.read_pic(gd, rels, w, h, alt, float)? + self.read_pic(gd, rels, w, h, alt.clone(), float)? } else { let wsp = gd.find("wps:wsp")?; let mut obj = self.read_wsp(sc, wsp, rels, w, h, float); @@ -824,6 +825,9 @@ impl Reader<'_> { } obj }; + if !alt.is_empty() { + obj.set_alt_text(&alt); + } // Word's effect extent also covers a rotated object's overhang; the model keeps that // apart (it follows from the angle), so take it back out. if let Some(f) = obj.float_mut() { @@ -973,12 +977,14 @@ impl Reader<'_> { let max = crate::units::MAX_LEN_PT; let fit = |v: f32, lo: f32| wordcraft_geom::finite(v).clamp(lo, max); let (w, h) = (fit(ew * sx, 0.0), fit(eh * sy, 0.0)); - let obj = if e.name == "pic:pic" { - let alt = e.find("pic:cNvPr").and_then(|p| p.attr("descr")).unwrap_or("").to_string(); - self.read_pic(e, rels, w, h, alt, Float::default()) - } else { - Some(self.read_wsp(sc, e, rels, w, h, Float::default())) - }; + let (alt, meta) = nv_alt(if e.name == "pic:pic" { e.find("pic:cNvPr") } else { e.child("wps:cNvPr") }); + let float = Float { alt: meta, ..Float::default() }; + let obj = + if e.name == "pic:pic" { self.read_pic(e, rels, w, h, alt.clone(), float) } else { Some(self.read_wsp(sc, e, rels, w, h, float)) }; + let obj = obj.map(|mut o| { + o.set_alt_text(&alt); + o + }); if let Some(obj) = obj { out.push(wordcraft_doc::para::GroupChild { x: fit(x0 + ox * sx, -max), y: fit(y0 + oy * sy, -max), obj }); } @@ -1195,6 +1201,23 @@ fn on_off_attr(e: &El, name: &str) -> bool { e.attr(name).is_some_and(|v| !matches!(v, "0" | "false" | "off")) } +/// The alternative text of a `wp:docPr`, `pic:cNvPr` or `wps:cNvPr` (ECMA-376 +/// `CT_NonVisualDrawingProps`): the description (the title when there is none, as before), and +/// the title and decorative mark (the `adec:decorative` extension of [MS-ODRAWXML]). +fn nv_alt(p: Option<&El>) -> (String, AltText) { + let Some(p) = p else { return Default::default() }; + let title = cap_alt(p.attr("title").unwrap_or("")); + let descr = p.attr("descr").filter(|s| !s.is_empty()).map(cap_alt).unwrap_or_else(|| title.clone()); + let decorative = p + .child("a:extLst") + .into_iter() + .flat_map(|l| l.children("a:ext")) + .filter(|x| x.attr("uri").is_some_and(|u| u.eq_ignore_ascii_case(crate::xml::DECORATIVE_EXT))) + .flat_map(|x| x.els()) + .any(|d| d.local() == "decorative" && on_off_attr(d, "val")); + (descr, AltText { text: String::new(), title, decorative }) +} + fn anchor_float(c: &El) -> Float { let mut f = Float { wrap: Wrap::Square, ..Float::default() }; let behind = on_off_attr(c, "behindDoc"); diff --git a/crates/docx/src/write/embed.rs b/crates/docx/src/write/embed.rs index d028a30bbb..2c11b326be 100644 --- a/crates/docx/src/write/embed.rs +++ b/crates/docx/src/write/embed.rs @@ -191,7 +191,7 @@ impl super::Writer<'_> { /// story part's) and the parts it needs queued for writing. `resize`, for an OLE object, writes /// the object's current size and position into its own markup (a chart or diagram's frame is /// written by the caller). `None` when it can't be written back (its parts are gone). - pub(super) fn embedded_xml(&mut self, src: &Embedded, rels: &mut PartRels, resize: Option<(f32, f32, &Float)>) -> Option { + pub(super) fn embedded_xml(&mut self, src: &Embedded, rels: &mut PartRels, resize: Option<(f32, f32, &Float, &str)>) -> Option { let Some(left) = self.embeds.markup_bytes.checked_sub(src.xml.len() as u64) else { log::warn!("docx: too much embedded-object markup; the rest is left out"); return None; @@ -243,16 +243,16 @@ impl super::Writer<'_> { } } set_rel_ids(&mut root, &ids, 0); - if let Some((w, h, float)) = resize { - self.resize_object(&mut root, w, h, float, 0); + if let Some((w, h, float, alt)) = resize { + self.resize_object(&mut root, w, h, float, alt, 0); } Some(root.to_xml()) } /// Write an OLE object's size (and position, when floating), rotation and flips into its VML /// shape and DrawingML picture, with the effect extent covering the rotated bounds as for - /// other drawings, and give its drawing a fresh `wp:docPr` id. - fn resize_object(&mut self, e: &mut El, w: f32, h: f32, float: &Float, depth: usize) { + /// other drawings, its alt text into both, and give its drawing a fresh `wp:docPr` id. + fn resize_object(&mut self, e: &mut El, w: f32, h: f32, float: &Float, alt: &str, depth: usize) { if depth > MAX_DEPTH { return; } @@ -261,6 +261,7 @@ impl super::Writer<'_> { if let Some((_, style)) = e.attrs.iter_mut().find(|(k, _)| k == "style") { *style = vml_style_with(style, w, h, float); } + set_attr(e, "alt", alt); } "wp:extent" => set_size_attrs(e, w, h), // A turned object needs an effect extent (filled in below) right after its extent. @@ -299,12 +300,13 @@ impl super::Writer<'_> { if let Some((_, v)) = e.attrs.iter_mut().find(|(k, _)| k == "id") { *v = id; } + set_attr(e, "descr", alt); } _ => {} } for k in e.kids.iter_mut() { if let xml::Node::El(c) = k { - self.resize_object(c, w, h, float, depth + 1); + self.resize_object(c, w, h, float, alt, depth + 1); } } } @@ -321,6 +323,15 @@ fn set_size_attrs(e: &mut El, w: f32, h: f32) { } } +/// Set attribute `k` of `e` to `v` (added when missing; left out when both are empty). +fn set_attr(e: &mut El, k: &str, v: &str) { + match e.attrs.iter_mut().find(|(n, _)| n == k) { + Some((_, old)) => *old = v.to_string(), + None if !v.is_empty() => e.attrs.push((k.to_string(), v.to_string())), + None => {} + } +} + /// Is attribute `k` a relationship id (see `read::embed::rel_ids`)? fn is_rel_attr(k: &str) -> bool { k.starts_with("r:") || k == "o:relid" diff --git a/crates/docx/src/write/props.rs b/crates/docx/src/write/props.rs index 5a90ea9447..71b377aa54 100644 --- a/crates/docx/src/write/props.rs +++ b/crates/docx/src/write/props.rs @@ -395,6 +395,9 @@ fn tblpr_inner(w: &mut W, t: &TableProps) { if let Some(c) = &t.caption { w.val("w:tblCaption", c); } + if let Some(d) = &t.description { + w.val("w:tblDescription", d); + } } /// `w:trPr` (when the row has any), with a tracked change last as for [`tblpr`]. diff --git a/crates/docx/src/write/story.rs b/crates/docx/src/write/story.rs index e95602d3ea..5e46102b15 100644 --- a/crates/docx/src/write/story.rs +++ b/crates/docx/src/write/story.rs @@ -1,7 +1,7 @@ //! Blocks, paragraphs, runs, objects and tables → WordprocessingML. use wordcraft_doc::effects::ShapeEffects; -use wordcraft_doc::para::{Anchor, Float, NoteKind, ShapeKind, Wrap}; +use wordcraft_doc::para::{AltText, Anchor, Float, NoteKind, ShapeKind, Wrap}; use wordcraft_doc::props::{CharProps, PropChange, Rgb}; use wordcraft_doc::section::{LineNumberRestart, NoteProps, SectionProps, SectionStart}; use wordcraft_doc::table::Table; @@ -500,7 +500,7 @@ impl Writer<'_> { InlineObject::Image { media, w: iw, h: ih, alt, float, ole, .. } => { // An OLE object read from a file: the object itself, at its current size. if let Some(src) = ole.as_deref() - && let Some(obj) = self.embedded_xml(src, rels, Some((*iw, *ih, float))) + && let Some(obj) = self.embedded_xml(src, rels, Some((*iw, *ih, float, alt.as_str()))) { self.rev_open(w, props); w.open("w:r", &[]); @@ -549,7 +549,7 @@ impl Writer<'_> { .map(std::sync::Arc::new); } let float = &float; - self.drawing_open(w, float, *sw, *sh, &docpr, &name, ""); + self.drawing_open(w, float, *sw, *sh, &docpr, &name, o.alt_text()); w.empty("wp:cNvGraphicFramePr", &[]); w.open("a:graphic", &[]); w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingShape")]); @@ -568,7 +568,7 @@ impl Writer<'_> { w.open("w:drawing", &[]); let docpr = self.next_docpr(); let name = format!("{} {docpr}", if canvas.is_some() { "Canvas" } else { "Group" }); - self.drawing_open(w, float, *gw, *gh, &docpr, &name, ""); + self.drawing_open(w, float, *gw, *gh, &docpr, &name, o.alt_text()); w.empty("wp:cNvGraphicFramePr", &[]); w.open("a:graphic", &[]); let outer = match canvas { @@ -812,7 +812,7 @@ impl Writer<'_> { self.used_media.insert(media.clone()); w.open("pic:pic", &[]); w.open("pic:nvPicPr", &[]); - w.empty("pic:cNvPr", &[("id", "0"), ("name", file), ("descr", alt)]); + nv_props(w, "pic:cNvPr", "0", file, alt, &float.alt); w.open("pic:cNvPicPr", &[]); w.empty("a:picLocks", &[("noChangeAspect", "1"), ("noChangeArrowheads", "1")]); w.close("pic:cNvPicPr"); @@ -844,7 +844,7 @@ impl Writer<'_> { w.open("wps:wsp", &[]); if let Some(id) = id { let name = shape_name(*kind, freeform.as_deref(), id); - w.empty("wps:cNvPr", &[("id", id), ("name", &name)]); + nv_props(w, "wps:cNvPr", id, &name, &float.alt.text, &float.alt); } if *kind == ShapeKind::TextBox { w.empty("wps:cNvSpPr", &[("txBox", "1")]); @@ -1023,7 +1023,7 @@ impl Writer<'_> { Wrap::TopAndBottom => w.empty("wp:wrapTopAndBottom", &[]), Wrap::BehindText | Wrap::InFrontOfText => w.empty("wp:wrapNone", &[]), } - w.empty("wp:docPr", &[("id", docpr), ("name", name), ("descr", alt)]); + nv_props(w, "wp:docPr", docpr, name, alt, &float.alt); } fn table(&mut self, w: &mut W, t: &Table, rels: &mut PartRels, depth: usize) { @@ -1366,6 +1366,31 @@ fn toc_span(bl: &Blocks) -> Option<(usize, usize)> { (entries > 0).then_some((start, start + entries)) } +/// A drawing's `wp:docPr` (or a group member's `pic:cNvPr`/`wps:cNvPr`, ECMA-376 +/// `CT_NonVisualDrawingProps`) with its alt text `descr`, its title, and the decorative mark as +/// the `adec:decorative` extension ([MS-ODRAWXML]). A description that is only the title (read +/// from a file with no description) is not written twice. +pub(super) fn nv_props(w: &mut W, tag: &str, id: &str, name: &str, descr: &str, alt: &AltText) { + let mut attrs = vec![("id", id), ("name", name)]; + if descr != alt.title || descr.is_empty() { + attrs.push(("descr", descr)); + } + if !alt.title.is_empty() { + attrs.push(("title", alt.title.as_str())); + } + if !alt.decorative { + w.empty(tag, &attrs); + return; + } + w.open(tag, &attrs); + w.open("a:extLst", &[]); + w.open("a:ext", &[("uri", crate::xml::DECORATIVE_EXT)]); + w.empty("adec:decorative", &[("xmlns:adec", crate::xml::DECORATIVE_NS), ("val", "1")]); + w.close("a:ext"); + w.close("a:extLst"); + w.close(tag); +} + /// A DrawingML `a:solidFill` in `c`, or `a:noFill`. fn solid_fill(w: &mut W, c: Option) { match c { diff --git a/crates/docx/src/xml.rs b/crates/docx/src/xml.rs index 5b7e70439b..c0c43c0f7d 100644 --- a/crates/docx/src/xml.rs +++ b/crates/docx/src/xml.rs @@ -39,6 +39,11 @@ pub const NAMESPACES: &[(&str, &str)] = &[ ("w10", "urn:schemas-microsoft-com:office:word"), ]; +/// The `a:ext` URI under which a drawing is marked decorative ([MS-ODRAWXML]), and the +/// namespace of its `adec:decorative` element. +pub const DECORATIVE_EXT: &str = "{C183D7F6-B498-43B3-948B-1728B52AA6E4}"; +pub const DECORATIVE_NS: &str = "http://schemas.microsoft.com/office/drawing/2017/decorative"; + /// Namespaces that only appear in non-body parts. const OTHER_NAMESPACES: &[(&str, &str)] = &[ ("rel", "http://schemas.openxmlformats.org/package/2006/relationships"), diff --git a/crates/docx/tests/roundtrip.rs b/crates/docx/tests/roundtrip.rs index da13e30c68..902cca8462 100644 --- a/crates/docx/tests/roundtrip.rs +++ b/crates/docx/tests/roundtrip.rs @@ -1778,3 +1778,77 @@ fn wrap_text_sides_round_trip() { // A value outside the schema is no side (read as both sides). assert_eq!(WrapText::from_ooxml("middle"), None); } + +#[test] +fn alt_text_title_and_decorative_round_trip_for_every_object() { + use wordcraft_doc::para::AltText; + let mut d = Document::new(); + let key = d.add_media(tiny_png(), "png"); + let pic = InlineObject::Image { + media: key, + w: 100.0, + h: 50.0, + alt: "A kiln at dusk".into(), + float: Float { alt: AltText { title: "Kiln".into(), ..Default::default() }, ..Default::default() }, + crop: [0.0; 4], + ole: None, + }; + let shape = |alt: AltText| InlineObject::Shape { + kind: ShapeKind::Ellipse, + w: 80.0, + h: 40.0, + fill: Some(Rgb(1, 2, 3)), + stroke: None, + stroke_width: 0.0, + float: Float { alt, ..Default::default() }, + story: None, + effects: Default::default(), + freeform: None, + }; + let described = shape(AltText { text: "Process flow & \"steps\"".into(), ..Default::default() }); + let decorative = shape(AltText { decorative: true, ..Default::default() }); + let group = InlineObject::Group { + w: 120.0, + h: 60.0, + float: Float { alt: AltText { text: "Two circles".into(), ..Default::default() }, ..Default::default() }, + ch_w: 120.0, + ch_h: 60.0, + children: vec![wordcraft_doc::para::GroupChild { x: 0.0, y: 0.0, obj: shape(AltText { text: "Left circle".into(), ..Default::default() }) }], + canvas: None, + }; + let mut p = Paragraph::with_text("objects ", CharProps::default()); + for o in [pic, described, decorative, group] { + let end = p.len(); + p.insert_object(end, o, &CharProps::default()).unwrap(); + } + let mut t = Table::new(2, 2, 300.0); + t.props.caption = Some("Prices".into()); + t.props.description = Some("Prices by year".into()); + d.body = vec![para_block(p), Arc::new(Block::Table(t))]; + let bytes = wordcraft_docx::write(&d).expect("write"); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap(); + let mut xml = String::new(); + std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap(); + assert!(xml.contains(r#"{C183D7F6-B498-43B3-948B-1728B52AA6E4}">"#), "{xml}"); + assert!( + xml.contains(r#""#), + "{xml}" + ); + let r = wordcraft_docx::read(&bytes).expect("read"); + let objs = ¶s(&r)[0].objects; + let alts: Vec<_> = + objs.iter().map(|o| (o.alt_text().to_string(), o.frame().map(|f| f.2.alt.title.clone()).unwrap_or_default(), o.is_decorative())).collect(); + assert_eq!( + alts, + [ + ("A kiln at dusk".into(), "Kiln".into(), false), + ("Process flow & \"steps\"".into(), String::new(), false), + (String::new(), String::new(), true), + ("Two circles".into(), String::new(), false), + ] + ); + let InlineObject::Group { children, .. } = &objs[3] else { panic!("{objs:?}") }; + assert_eq!(children[0].obj.alt_text(), "Left circle"); + let Some(Block::Table(t)) = r.body.iter().map(|b| &**b).find(|b| matches!(b, Block::Table(_))) else { panic!() }; + assert_eq!((t.props.caption.as_deref(), t.props.description.as_deref()), (Some("Prices"), Some("Prices by year"))); +} diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 196b65081f..fcfcc3ee08 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -275,6 +275,7 @@ Review|Proofing|Thesaurus|review.thesaurus Review|Proofing|Word Count|review.wordCount Review|Speech|Read Aloud|review.readAloud Review|Accessibility|Check Accessibility|file.accessibility +Review|Accessibility|Alt Text|view.altTextPane Review|Language|Translate|review.translate Review|Language|Language|review.language Review|Comments|New Comment|review.newComment @@ -342,6 +343,7 @@ Table Layout|Table|Select Row|table.selectRow Table Layout|Table|Select Cell|table.selectCell Table Layout|Table|View Gridlines|table.viewGridlines Table Layout|Table|Properties|table.properties +Table Layout|Table|Alt Text|table.altText Table Layout|Table|Table Direction|table.rtl Table Layout|Draw|Draw Table|table.draw Table Layout|Draw|Eraser|table.eraser @@ -401,6 +403,7 @@ Picture Format|Adjust|Reset Picture|picture.reset Picture Format|Picture Styles|Picture Styles|picture.style Picture Format|Picture Styles|Picture Border|picture.border Picture Format|Accessibility|Alt Text|picture.altText +Shape Format|Accessibility|Alt Text|object.altText Picture Format|Size|Crop|picture.crop Picture Format|Size|Size|picture.size Picture Format|Size|Rotation|arrange.rotation diff --git a/crates/engine/src/cmd/accessibility.rs b/crates/engine/src/cmd/accessibility.rs new file mode 100644 index 0000000000..c6044e48af --- /dev/null +++ b/crates/engine/src/cmd/accessibility.rs @@ -0,0 +1,321 @@ +//! The accessibility checker (Review › Check Accessibility): what makes the body hard to use with +//! a screen reader or hard to read, as `{"kind": "error|warning|tip", "rule", "issue", "where", +//! "pos"?, "select"?, "fix"?, "action"?}` items. +//! +//! `where` is the paragraph (or table) path; `pos` a place to put the caret there; `select` the +//! range that selects the object an issue is about; `action` a command (`{"label", "command", +//! "params"}`) that fixes it, run with the caret at `pos` (or the object selected). The rules and +//! their wording are our own; the contrast rule uses the WCAG 2 contrast ratio. + +use serde_json::{Value, json}; +use wordcraft_doc::para::{InlineObject, OBJ}; +use wordcraft_doc::props::{Rgb, TextColor, VMerge}; +use wordcraft_doc::table::Table; +use wordcraft_doc::{Block, Document, Paragraph, Path, Pos, StoryRef}; + +/// Most issues reported (a hostile document can't make the report huge). +const MAX_ISSUES: usize = 500; +/// This many empty paragraphs in a row look like spacing made with Enter. +const BLANK_RUN: usize = 3; + +/// The checker's findings for `doc`, errors first, then warnings, then tips (each in document order). +pub fn check(doc: &Document) -> Vec { + let mut c = Checker { doc, issues: Vec::new() }; + c.run(); + let rank = |v: &Value| match v["kind"].as_str() { + Some("error") => 0, + Some("warning") => 1, + _ => 2, + }; + c.issues.sort_by_key(rank); + c.issues.truncate(MAX_ISSUES); + c.issues +} + +struct Checker<'a> { + doc: &'a Document, + issues: Vec, +} + +fn at(path: &Path, off: usize) -> Pos { + Pos { story: StoryRef::Body, path: path.clone(), off } +} + +impl Checker<'_> { + fn push(&mut self, v: Value) { + if self.issues.len() < MAX_ISSUES * 2 { + self.issues.push(v); + } + } + + fn run(&mut self) { + let doc = self.doc; + let mut last_level: Option = None; + let mut tables: Vec = Vec::new(); + let mut blank: Vec = Vec::new(); + for path in doc.para_paths(StoryRef::Body) { + let Some(p) = doc.para(StoryRef::Body, &path) else { continue }; + if let Some((tp, _, _)) = path.cell() + && !tables.contains(&tp) + { + tables.push(tp); + } + self.objects(&path, p); + // Empty paragraphs in a row (top level, no section break): spacing made with Enter. + let consecutive = blank.last().is_none_or(|b| b.0.len() == 1 && path.0.len() == 1 && b.last() + 1 == path.last()); + if path.0.len() == 1 && p.text.trim().is_empty() && p.section.is_none() && consecutive { + blank.push(path.clone()); + } else { + self.blank_run(&blank); + blank.clear(); + if path.0.len() == 1 && p.text.trim().is_empty() && p.section.is_none() { + blank.push(path.clone()); + } + } + let rp = doc.styles.resolve_para(&p.props); + if let Some(l) = rp.outline_level.filter(|_| !p.plain_text().trim().is_empty()) { + if let Some(prev) = last_level + && l > prev + 1 + { + self.push(json!({"kind": "warning", "rule": "headingOrder", "issue": format!("Heading level skipped (Heading {} after Heading {})", l + 1, prev + 1), "where": path.0, "pos": at(&path, 0), "fix": "Use the next heading level down, so the outline has no gaps"})); + } + last_level = Some(l); + } + if p.runs.iter().any(|r| r.props.link.is_some()) { + let txt = p.plain_text().to_lowercase(); + if txt.contains("click here") { + self.push(json!({"kind": "tip", "rule": "linkText", "issue": "Link text \"click here\" isn't descriptive", "where": path.0, "pos": at(&path, 0)})); + } + } + self.contrast(&path, p, rp.shading); + } + self.blank_run(&blank); + for tp in tables { + if let Some(t) = doc.table(StoryRef::Body, &tp) { + self.table(&tp, t); + } + } + if doc.core.title.trim().is_empty() { + self.push(json!({"kind": "tip", "rule": "docTitle", "issue": "Document has no title", "fix": "file.properties {title}"})); + } + } + + fn blank_run(&mut self, blank: &[Path]) { + if blank.len() >= BLANK_RUN + && let Some(first) = blank.first() + { + self.push(json!({"kind": "tip", "rule": "blankParagraphs", "issue": format!("{} empty paragraphs in a row", blank.len()), "where": first.0, "pos": at(first, 0), "fix": "Use Space Before/After (Layout › Paragraph) or a page break instead of empty paragraphs"})); + } + } + + /// Alt text and reading order of the drawings in paragraph `p`. + fn objects(&mut self, path: &Path, p: &Paragraph) { + for off in p.object_offsets() { + let Some(o) = p.object_at(off).filter(|o| o.is_drawing()) else { continue }; + let pos = at(path, off); + let select = json!({"anchor": pos, "focus": at(path, off + OBJ.len_utf8())}); + // Ink: every stroke is its own object, so asking about each would bury the report. + if o.is_decorative() || o.ink().is_some() { + continue; + } + let titled = o.frame().is_some_and(|(_, _, f)| !f.alt.title.trim().is_empty()); + let described = !o.alt_text().trim().is_empty() || titled; + if !described && !self.speaks_for_itself(o) { + let (rule, issue) = match o { + InlineObject::Graphic { .. } => ("altText", "Chart or diagram has no alternative text"), + _ => ("altText", "Missing alternative text"), + }; + self.push(json!({ + "kind": "error", "rule": rule, "issue": issue, "what": kind_of(o), "where": path.0, "pos": pos, "select": select, + "fix": "Describe it in the Alt Text pane (object.altText), or mark it decorative", + "action": {"label": "Mark as decorative", "command": "object.altText", "params": {"decorative": true}}, + })); + } + if o.is_floating() { + let mut v = json!({"kind": "tip", "rule": "floating", "issue": "Object isn't in line with text, so its reading order is unclear", "what": kind_of(o), "where": path.0, "pos": pos, "select": select, "fix": "Place it in line with text (Layout › Wrap Text)"}); + // Charts and diagrams keep their imported placement (see objects.rs). + if !matches!(o, InlineObject::Graphic { .. }) { + v["action"] = json!({"label": "In Line with Text", "command": "arrange.wrap", "params": {"wrap": "inline"}}); + } + self.push(v); + } + } + } + + /// A text box with text in it is read out as that text. + fn speaks_for_itself(&self, o: &InlineObject) -> bool { + o.text_box().and_then(|id| self.doc.parts.get(&id)).is_some_and(|part| { + part.blocks.iter().any(|b| match &**b { + Block::Para(p) => !p.plain_text().trim().is_empty(), + _ => true, + }) + }) + } + + /// Text whose colour is too close to what it's drawn on (WCAG 2: 4.5:1, 3:1 for large text). + fn contrast(&mut self, path: &Path, p: &Paragraph, para_shading: Option) { + let doc = self.doc; + let cell = path.cell().and_then(|(tp, r, c)| { + let t = doc.table(StoryRef::Body, &tp)?; + t.rows.get(r).and_then(|row| row.cells.get(c)).and_then(|c| c.props.shading).or(t.props.shading) + }); + let page = doc.settings.page_color.unwrap_or(Rgb::WHITE); + let mut start = 0usize; + for r in &p.runs { + let text = p.text.get(start..start.saturating_add(r.len)).unwrap_or(""); + start = start.saturating_add(r.len); + if text.trim().is_empty() || r.props.hidden == Some(true) { + continue; + } + let rc = doc.styles.resolve_char(p.props.style.as_deref(), &r.props); + let TextColor::Rgb(fg) = rc.color else { continue }; + let bg = rc.highlight.or(rc.shading).or(para_shading).or(cell).unwrap_or(page); + let ratio = contrast_ratio(fg, bg); + let large = rc.size >= 18.0 || (rc.bold && rc.size >= 14.0); + let need = if large { 3.0 } else { 4.5 }; + if ratio < need { + let kind = if ratio < 3.0 { "warning" } else { "tip" }; + self.push(json!({"kind": kind, "rule": "contrast", "issue": format!("Text is hard to read: contrast {:.1}:1, {need}:1 needed", ratio), "where": path.0, "pos": at(path, 0), "fix": "Use a darker or lighter text colour, or change the shading behind it"})); + return; + } + } + } + + fn table(&mut self, tp: &Path, t: &Table) { + let cell_pos = |r: usize, c: usize| { + let mut v = tp.0.clone(); + v.extend([r as u32, c as u32, 0]); + at(&Path(v), 0) + }; + if t.props.caption.as_deref().is_none_or(|s| s.trim().is_empty()) && t.props.description.as_deref().is_none_or(|s| s.trim().is_empty()) { + self.push(json!({"kind": "warning", "rule": "tableAltText", "issue": "Table has no alternative text", "where": tp.0, "pos": cell_pos(0, 0), "fix": "Give it a title or description (table.altText)"})); + } + if t.rows.len() >= 2 && !t.rows.first().is_some_and(|r| r.props.header) { + self.push(json!({ + "kind": "warning", "rule": "tableHeader", "issue": "Table has no header row", "where": tp.0, "pos": cell_pos(0, 0), + "fix": "Repeat the first row as a header row (table.repeatHeader)", + "action": {"label": "Repeat Header Rows", "command": "table.repeatHeader", "params": {"value": true}}, + })); + } + let empty = |c: &wordcraft_doc::table::Cell| { + c.blocks.iter().all(|b| match &**b { + Block::Para(p) => p.text.trim().is_empty(), + _ => false, + }) + }; + // A table with nothing in it yet is being filled in, not used for spacing. + let filled = !t.rows.iter().flat_map(|r| &r.cells).all(empty); + if t.rows.len() >= 2 && filled { + for (r, row) in t.rows.iter().enumerate() { + if !row.cells.is_empty() && row.cells.iter().all(empty) { + self.push(json!({"kind": "warning", "rule": "blankRow", "issue": format!("Row {} of a table is empty: it looks like spacing", r + 1), "where": tp.0, "pos": cell_pos(r, 0), "fix": "Delete the row and use cell margins or spacing instead"})); + } + } + } + let cols = t.rows.iter().map(|r| r.cells.len()).max().unwrap_or(0); + if cols >= 2 && t.rows.len() >= 2 && filled { + for c in 0..cols.min(63) { + if t.rows.iter().all(|row| row.cells.get(c).is_none_or(empty)) { + self.push(json!({"kind": "warning", "rule": "blankColumn", "issue": format!("Column {} of a table is empty: it looks like spacing", c + 1), "where": tp.0, "pos": cell_pos(0, c), "fix": "Delete the column and use cell margins instead"})); + } + } + } + if t.rows.iter().flat_map(|r| &r.cells).any(|c| c.span() > 1 || c.props.vmerge != VMerge::None) { + self.push(json!({"kind": "tip", "rule": "mergedCells", "issue": "Table has merged cells, which screen readers can lose track of", "where": tp.0, "pos": cell_pos(0, 0), "fix": "Split the merged cells (Table Layout › Merge)"})); + } + } +} + +fn kind_of(o: &InlineObject) -> &'static str { + match o { + InlineObject::Image { ole: Some(_), .. } => "object", + InlineObject::Image { .. } => "picture", + InlineObject::Graphic { .. } => "chart", + InlineObject::Shape { story: Some(_), .. } => "textBox", + InlineObject::Shape { .. } => "shape", + InlineObject::Group { .. } => "group", + _ => "object", + } +} + +/// WCAG 2 relative luminance of an sRGB colour. +fn luminance(c: Rgb) -> f64 { + let ch = |v: u8| { + let s = v as f64 / 255.0; + if s <= 0.04045 { s / 12.92 } else { ((s + 0.055) / 1.055).powf(2.4) } + }; + 0.2126 * ch(c.0) + 0.7152 * ch(c.1) + 0.0722 * ch(c.2) +} + +/// WCAG 2 contrast ratio of two colours, 1 to 21. +pub fn contrast_ratio(a: Rgb, b: Rgb) -> f64 { + let (la, lb) = (luminance(a), luminance(b)); + let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) }; + (hi + 0.05) / (lo + 0.05) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use wordcraft_doc::Document; + use wordcraft_doc::props::Rgb; + + use crate::Session; + + fn rules(s: &mut Session) -> Vec<(String, String)> { + let v = s.run("file.accessibility", &json!({})).unwrap(); + v["issues"] + .as_array() + .unwrap() + .iter() + .map(|i| (i["kind"].as_str().unwrap().to_string(), i["rule"].as_str().unwrap_or("").to_string())) + .collect() + } + + #[test] + fn contrast_ratio_matches_wcag() { + assert!((super::contrast_ratio(Rgb::BLACK, Rgb::WHITE) - 21.0).abs() < 0.01); + assert!((super::contrast_ratio(Rgb(0x77, 0x77, 0x77), Rgb::WHITE) - 4.48).abs() < 0.01); + } + + #[test] + fn shapes_need_alt_text_unless_decorative_and_the_fix_marks_them() { + let mut s = Session::new(Document::from_text("Title")); + s.doc.core.title = "T".into(); + s.run("insert.shape", &json!({"kind": "ellipse"})).unwrap(); + assert!(rules(&mut s).contains(&("error".into(), "altText".into()))); + // The issue says how to fix it: select the object, run the action. + let v = s.run("file.accessibility", &json!({})).unwrap(); + let i = v["issues"].as_array().unwrap().iter().find(|i| i["rule"] == "altText").unwrap().clone(); + s.run("select.range", &i["select"]).unwrap(); + s.run(i["action"]["command"].as_str().unwrap(), &i["action"]["params"]).unwrap(); + assert!(!rules(&mut s).iter().any(|(_, r)| r == "altText")); + } + + #[test] + fn tables_blank_paragraphs_and_contrast_are_reported() { + let mut s = Session::new(Document::from_text("Intro")); + s.doc.core.title = "T".into(); + // Light grey text on white. + s.run("select.all", &json!({})).unwrap(); + s.run("format.color", &json!({"color": "BBBBBB"})).unwrap(); + s.run("caret.docEnd", &json!({})).unwrap(); + for _ in 0..4 { + s.run("text.newParagraph", &json!({})).unwrap(); + } + s.run("insert.table", &json!({"rows": 3, "cols": 2})).unwrap(); + s.run("text.insert", &json!({"text": "Year"})).unwrap(); + let r = rules(&mut s); + for want in + [("warning", "contrast"), ("tip", "blankParagraphs"), ("warning", "tableHeader"), ("warning", "tableAltText"), ("warning", "blankRow")] + { + assert!(r.contains(&(want.0.into(), want.1.into())), "{want:?} in {r:?}"); + } + // Fix the header row from the issue's action, with the caret where it says. + let v = s.run("file.accessibility", &json!({})).unwrap(); + let i = v["issues"].as_array().unwrap().iter().find(|i| i["rule"] == "tableHeader").unwrap().clone(); + s.run("caret.set", &json!({"pos": i["pos"]})).unwrap(); + s.run(i["action"]["command"].as_str().unwrap(), &i["action"]["params"]).unwrap(); + assert!(!rules(&mut s).iter().any(|(_, r)| r == "tableHeader")); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index b31eebfdb8..f17226650c 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -1,5 +1,6 @@ //! Command implementations, one module per area. Each module exposes `specs()`. +pub mod accessibility; pub mod canvas; pub mod caret; pub mod citations; diff --git a/crates/engine/src/cmd/objects.rs b/crates/engine/src/cmd/objects.rs index bbe4a11a0c..ed7be57fd9 100644 --- a/crates/engine/src/cmd/objects.rs +++ b/crates/engine/src/cmd/objects.rs @@ -75,16 +75,16 @@ pub fn specs() -> Vec { }) .params(r#"{"left"?, "top"?, "right"?, "bottom"? (fractions 0–0.45)}"#) .when(has_picture), + // Kept for scripts written before every object had alt text: `object.altText` on a picture. CommandSpec::new("picture.altText", "Alt Text", "Picture Format › Accessibility", |s, v| { - let t = p::req_str(v, "text")?.to_string(); - with_obj(s, |o| { - if let InlineObject::Image { alt, .. } = o { - *alt = t.clone(); - } - }) + p::req_str(v, "text")?; + alt_text(s, v) }) - .params(r#"{"text": string}"#) + .params(r#"{"text": string, "title"?: string, "decorative"?: bool}"#) .when(has_picture), + CommandSpec::new("object.altText", "Alt Text", "Shape Format › Accessibility", alt_text) + .params(r#"{"text"?: string, "title"?: string, "decorative"?: bool} (the selected picture, shape, text box, group or chart)"#) + .when(has_object), CommandSpec::new("picture.corrections", "Corrections", "Picture Format › Adjust", |s, v| { let b = p::f32(v, "brightness").unwrap_or(0.0).clamp(-100.0, 100.0); let c = p::f32(v, "contrast").unwrap_or(0.0).clamp(-100.0, 100.0); @@ -790,6 +790,27 @@ fn obj_size(o: &InlineObject) -> (f32, f32) { o.frame().map_or((0.0, 0.0), |(w, h, _)| (w, h)) } +/// Alt Text: the selected object's description, title and decorative mark, in one undo step. +fn alt_text(s: &mut Session, v: &Value) -> CmdResult { + let (text, title, decorative) = (p::str(v, "text"), p::str(v, "title"), p::bool(v, "decorative")); + if text.is_none() && title.is_none() && decorative.is_none() { + return Err(CmdError::Params("`text`, `title` or `decorative` is required".into())); + } + with_obj(s, |o| { + if let Some(t) = text { + o.set_alt_text(t); + } + if let Some(f) = o.float_mut() { + if let Some(t) = title { + f.alt.title = wordcraft_doc::para::cap_alt(t); + } + if let Some(d) = decorative { + f.alt.decorative = d; + } + } + }) +} + fn with_obj(s: &mut Session, f: impl Fn(&mut InlineObject)) -> CmdResult { let (pos, _) = selected(s).ok_or_else(|| CmdError::Disabled("no picture or shape selected".into()))?; let o = edit_obj(s, &pos, f)?; @@ -1138,13 +1159,16 @@ fn group(s: &mut Session, _: &Value) -> CmdResult { let mut obj = obj.clone(); obj.set_size(cw, ch); if let Some(f) = obj.float_mut() { - *f = Float::default(); + // Each member keeps its own alt text. + let alt = std::mem::take(&mut f.alt); + *f = Float { alt, ..Float::default() }; f.set_spin(spin); } GroupChild { x: x - x0, y: y - y0, obj } }) .collect(); - let mut float = found.first().and_then(|(o, _)| o.frame()).map(|(_, _, f)| f.clone()).unwrap_or_default(); + // The group takes the first member's placement; its members keep their own alt text. + let mut float = found.first().and_then(|(o, _)| o.frame()).map(|(_, _, f)| Float { alt: Default::default(), ..f.clone() }).unwrap_or_default(); float.set_spin(Spin::default()); let grouped = InlineObject::Group { w, h, float, ch_w: w, ch_h: h, children, canvas: None }; // Take the members out, last first so the earlier positions hold, and put the group where @@ -1195,7 +1219,9 @@ fn ungroup(s: &mut Session, _: &Value) -> CmdResult { // Grown to its smallest size about its centre, so it stays centred where it was. let (x, y) = (x - (mw - cw) / 2.0, y - (mh - ch) / 2.0); if let Some(f) = c.float_mut() { - *f = Float { x: (float.x + x).clamp(-MAX_OFFSET, MAX_OFFSET), y: (float.y + y).clamp(-MAX_OFFSET, MAX_OFFSET), ..float.clone() }; + // Each member keeps its own alt text; the group's placement is shared out. + let alt = std::mem::take(&mut f.alt); + *f = Float { x: (float.x + x).clamp(-MAX_OFFSET, MAX_OFFSET), y: (float.y + y).clamp(-MAX_OFFSET, MAX_OFFSET), alt, ..float.clone() }; f.set_spin(spin); } c @@ -1638,4 +1664,38 @@ mod tests { s.run("edit.undo", &json!({})).unwrap(); assert!(s.doc.text_box_chains().is_empty()); } + + #[test] + fn shape_alt_text_and_decorative_are_one_undo_step_each() { + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("insert.shape", &json!({"kind": "ellipse"})).unwrap(); + s.run("object.altText", &json!({"text": "A green circle", "title": "Logo"})).unwrap(); + let (_, o) = selected(&s).unwrap(); + assert_eq!((o.alt_text(), o.frame().map(|f| f.2.alt.title.as_str()), o.is_decorative()), ("A green circle", Some("Logo"), false)); + s.run("object.altText", &json!({"decorative": true})).unwrap(); + assert!(selected(&s).unwrap().1.is_decorative()); + s.run("edit.undo", &json!({})).unwrap(); + let (_, o) = selected(&s).unwrap(); + assert!(!o.is_decorative() && o.alt_text() == "A green circle"); + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(selected(&s).unwrap().1.alt_text(), ""); + // Hostile input is capped, and nothing to set is an error. + s.run("object.altText", &json!({"text": "x".repeat(50_000)})).unwrap(); + assert_eq!(selected(&s).unwrap().1.alt_text().len(), wordcraft_doc::para::MAX_ALT_TEXT); + assert!(s.run("object.altText", &json!({})).is_err()); + // A picture-only alias stays a picture-only alias. + assert!(s.run("picture.altText", &json!({"text": "no"})).is_err()); + } + + #[test] + fn table_alt_text_sets_title_and_description() { + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("insert.table", &json!({"rows": 2, "cols": 2})).unwrap(); + s.run("table.altText", &json!({"title": "Prices", "text": "Prices by year"})).unwrap(); + let t = s.doc.body.iter().find_map(|b| if let wordcraft_doc::Block::Table(t) = &**b { Some(t.clone()) } else { None }).unwrap(); + assert_eq!((t.props.caption.as_deref(), t.props.description.as_deref()), (Some("Prices"), Some("Prices by year"))); + s.run("table.altText", &json!({"text": ""})).unwrap(); + let t = s.doc.body.iter().find_map(|b| if let wordcraft_doc::Block::Table(t) = &**b { Some(t.clone()) } else { None }).unwrap(); + assert_eq!((t.props.caption.as_deref(), t.props.description.as_deref()), (Some("Prices"), None)); + } } diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 8eace0a4aa..fd7939a0bc 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -144,6 +144,23 @@ pub fn specs() -> Vec { } }) })), + // Table Properties › Alt Text: the title (`w:tblCaption`) and description (`w:tblDescription`). + t(CommandSpec::new("table.altText", "Alt Text", "Table Layout › Table › Properties", |s, v| { + let (title, text) = (p::str(v, "title"), p::str(v, "text")); + if title.is_none() && text.is_none() { + return Err(CmdError::Params("`text` or `title` (string) is required".into())); + } + let set = |x: Option<&str>, cur: &Option| match x { + Some(x) if x.trim().is_empty() => None, + Some(x) => Some(wordcraft_doc::para::cap_alt(x)), + None => cur.clone(), + }; + with_table(s, |t| { + t.props.caption = set(title, &t.props.caption); + t.props.description = set(text, &t.props.description); + }) + }) + .params(r#"{"text"?: string, "title"?: string} (empty clears)"#)), t(CommandSpec::new("table.sort", "Sort", "Table Layout › Data", sort_table) .params(r#"{"column"?: n, "descending"?: bool, "header"?: bool}"#)), t(CommandSpec::new("table.toText", "Convert to Text", "Table Layout › Data", to_text).params(r#"{"separator"?: "tab|comma|paragraph"}"#)), diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index dcbff141e8..111e44dba5 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -750,49 +750,8 @@ fn word_diff(a: &Paragraph, b: &Paragraph, ins: u32, del: u32) -> Paragraph { } fn accessibility(s: &mut Session, _: &Value) -> CmdResult { - let mut issues = Vec::new(); - let mut last_level: Option = None; - for path in s.doc.para_paths(StoryRef::Body) { - let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue }; - for o in &p.objects { - if let InlineObject::Image { alt, .. } = o - && alt.trim().is_empty() - { - issues.push(json!({"kind": "error", "issue": "Missing alternative text", "where": path.0, "fix": "Select the picture and run picture.altText"})); - } - // Charts and diagrams can't be given alt text yet (see objects.rs), so only report them. - if let InlineObject::Graphic { alt, .. } = o - && alt.trim().is_empty() - { - issues.push(json!({"kind": "error", "issue": "Chart or diagram has no alternative text", "where": path.0})); - } - } - if let Some(l) = s.doc.styles.resolve_para(&p.props).outline_level { - if let Some(prev) = last_level - && l > prev + 1 - { - issues.push(json!({"kind": "warning", "issue": format!("Heading level skipped (Heading {} after Heading {})", l + 1, prev + 1), "where": path.0})); - } - last_level = Some(l); - } - if p.runs.iter().any(|r| r.props.link.is_some()) { - let txt = p.plain_text().to_lowercase(); - if txt.contains("click here") { - issues.push(json!({"kind": "tip", "issue": "Link text \"click here\" isn't descriptive", "where": path.0})); - } - } - } - for (i, b) in s.doc.body.iter().enumerate() { - if let Block::Table(t) = &**b - && !t.rows.first().is_some_and(|r| r.props.header) - { - issues.push(json!({"kind": "warning", "issue": "Table has no header row", "where": [i], "fix": "table.repeatHeader on the first row"})); - } - } - if s.doc.core.title.is_empty() { - issues.push(json!({"kind": "tip", "issue": "Document has no title", "fix": "file.properties {title}"})); - } - Ok(json!({"issues": issues, "ok": issues.is_empty()})) + let issues = super::accessibility::check(&s.doc); + Ok(json!({"ok": issues.is_empty(), "issues": issues})) } fn inspect_doc(s: &mut Session, v: &Value) -> CmdResult { diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 42a9ecfbd5..965e98c92f 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -63,6 +63,14 @@ pub fn specs() -> Vec { CommandSpec::new("view.darkMode", "Switch Modes", "View › Dark Mode", |s, v| toggle(s, v, |x| &mut x.dark_mode)).pure(), CommandSpec::new("view.stylesPane", "Styles Pane", "Home › Styles", |s, v| toggle(s, v, |x| &mut x.styles_pane)).pure(), CommandSpec::new("view.commentsPane", "Comments Pane", "Review › Comments", |s, v| toggle(s, v, |x| &mut x.comments_pane)).pure(), + CommandSpec::new("view.accessibilityPane", "Accessibility Pane", "Review › Accessibility", |s, v| { + toggle(s, v, |x| &mut x.accessibility_pane) + }) + .params(r#"{"value"?: bool}"#) + .pure(), + CommandSpec::new("view.altTextPane", "Alt Text Pane", "Review › Accessibility", |s, v| toggle(s, v, |x| &mut x.alt_text_pane)) + .params(r#"{"value"?: bool}"#) + .pure(), CommandSpec::new("view.newWindow", "New Window", "View › Window", |s, _| { s.ui_requests.push(json!({"open": "newWindow"})); sel_result(s) diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index a06a7d522f..95356c4120 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -68,6 +68,12 @@ pub struct ViewState { /// The Clipboard pane (Home › Clipboard): items collected by Copy and Cut. #[serde(default)] pub clipboard_pane: bool, + /// The Accessibility pane (Review › Check Accessibility): the checker's findings. + #[serde(default)] + pub accessibility_pane: bool, + /// The Alt Text pane (Picture/Shape Format › Alt Text): the selected object's or table's alt text. + #[serde(default)] + pub alt_text_pane: bool, pub multi_page: bool, /// Zoom to fit: "pageWidth", "onePage", "multiplePages", or empty. pub fit: String, @@ -108,6 +114,8 @@ impl Default for ViewState { style_inspector: false, comments_pane: false, clipboard_pane: false, + accessibility_pane: false, + alt_text_pane: false, multi_page: false, fit: String::new(), web_width: 800.0, diff --git a/crates/ui-egui/src/a11y_pane.rs b/crates/ui-egui/src/a11y_pane.rs new file mode 100644 index 0000000000..25d2c82c62 --- /dev/null +++ b/crates/ui-egui/src/a11y_pane.rs @@ -0,0 +1,158 @@ +//! The Accessibility pane (the checker's findings, grouped, each a jump and maybe a fix) and the +//! Alt Text pane (the selected object's or table's alternative text). + +use egui::Ui; +use serde_json::{Value, json}; +use wordcraft_doc::Pos; + +use crate::WordApp; +use crate::theme::{regular, semibold}; + +/// Review › Check Accessibility: the findings for the document as it is now. +pub fn accessibility(app: &mut WordApp, ui: &mut Ui) { + if crate::panes::header(ui, "Accessibility") { + let _ = app.run("view.accessibilityPane", json!({"value": false})); + return; + } + let issues = issues(app, ui); + if issues.is_empty() { + ui.label(egui::RichText::new(tl!("No accessibility issues found.")).weak()); + return; + } + let mut picked: Option<(Value, Option)> = None; + egui::ScrollArea::vertical().show(ui, |ui| { + for (kind, title) in [("error", "Errors ({n})"), ("warning", "Warnings ({n})"), ("tip", "Tips ({n})")] { + let group: Vec<&Value> = issues.iter().filter(|i| i["kind"] == kind).collect(); + if group.is_empty() { + continue; + } + let heading = crate::i18n::fmt(tl!(title), &[("n", &group.len().to_string())]); + egui::CollapsingHeader::new(egui::RichText::new(heading).font(semibold(13.0))).id_salt(("a11y", kind)).default_open(true).show( + ui, + |ui| { + for (k, i) in group.into_iter().enumerate() { + ui.push_id(k, |ui| { + let text = i["issue"].as_str().unwrap_or(""); + let r = ui.add(egui::Button::new(egui::RichText::new(text).font(regular(12.5))).frame(false).wrap()); + if let Some(fix) = i["fix"].as_str() { + ui.label(egui::RichText::new(fix).small().weak()); + } + if r.clicked() { + picked = Some((i.clone(), None)); + } + if let Some(a) = i.get("action").filter(|a| a.is_object()) + && let Some(label) = a["label"].as_str() + && ui.small_button(tl!(label)).clicked() + { + picked = Some((i.clone(), Some(a.clone()))); + } + ui.add_space(4.0); + }); + } + }, + ); + } + }); + if let Some((issue, action)) = picked { + go_to(app, &issue); + if let Some(a) = action + && let Some(cmd) = a["command"].as_str() + { + let _ = app.run(cmd, a.get("params").cloned().unwrap_or_else(|| json!({}))); + } + } +} + +/// The checker's findings, kept until the document changes. +fn issues(app: &mut WordApp, ui: &Ui) -> Vec { + let key = egui::Id::new("a11y_issues"); + let stamp = (app.session.document_id(), app.session.rev()); + if let Some((s, v)) = ui.data(|d| d.get_temp::<((u64, u64), Vec)>(key)) + && s == stamp + { + return v; + } + let v = wordcraft_engine::cmd::accessibility::check(&app.session.doc); + ui.data_mut(|d| d.insert_temp(key, (stamp, v.clone()))); + v +} + +/// Select the object an issue is about, or put the caret where it is. +fn go_to(app: &mut WordApp, issue: &Value) { + if let Some(sel) = issue.get("select").filter(|s| s.is_object()) { + let _ = app.run("select.range", sel.clone()); + } else if let Some(pos) = issue.get("pos").filter(|p| p.is_object()) { + let _ = app.run("caret.set", json!({"pos": pos})); + } + app.canvas.want_focus = true; +} + +/// Alt Text: the selected picture's, shape's, chart's or group's description and decorative mark, +/// or the title and description of the table the caret is in. +pub fn alt_text(app: &mut WordApp, ui: &mut Ui) { + if crate::panes::header(ui, "Alt Text") { + let _ = app.run("view.altTextPane", json!({"value": false})); + return; + } + if let Some((pos, obj)) = wordcraft_engine::cmd::objects::selected(&app.session) { + let decorative = obj.is_decorative(); + ui.label(egui::RichText::new(tl!("Describe what this object shows, for people who can't see it.")).weak()); + ui.add_space(4.0); + if let Some(text) = draft_box(ui, ("alt_obj", &pos), obj.alt_text(), !decorative, true) + && still_selected(app, &pos) + { + let _ = app.run("object.altText", json!({"text": text})); + } + ui.add_space(6.0); + let mut d = decorative; + if ui.checkbox(&mut d, tl!("Mark as decorative")).changed() { + let _ = app.run("object.altText", json!({"decorative": d})); + } + ui.label(egui::RichText::new(tl!("Decorative objects only add visual interest: screen readers skip them.")).small().weak()); + return; + } + let focus = app.session.sel.focus.clone(); + let table = focus + .path + .cell() + .and_then(|(tp, _, _)| app.session.doc.table(focus.story, &tp).map(|t| (tp, t.props.caption.clone(), t.props.description.clone()))); + if let Some((tp, caption, description)) = table { + ui.label(egui::RichText::new(tl!("Describe what this table shows, for people who can't see it.")).weak()); + ui.add_space(4.0); + ui.label(tl!("Title:")); + if let Some(t) = draft_box(ui, ("alt_table_title", &tp), caption.as_deref().unwrap_or(""), true, false) { + let _ = app.run("table.altText", json!({"title": t})); + } + ui.add_space(4.0); + ui.label(tl!("Description:")); + if let Some(t) = draft_box(ui, ("alt_table_text", &tp), description.as_deref().unwrap_or(""), true, true) { + let _ = app.run("table.altText", json!({"text": t})); + } + return; + } + ui.label(egui::RichText::new(tl!("Select a picture, shape, chart or table to describe it.")).weak()); +} + +fn still_selected(app: &WordApp, pos: &Pos) -> bool { + wordcraft_engine::cmd::objects::selected(&app.session).is_some_and(|(p, _)| &p == pos) +} + +/// A text box editing `current`; the draft lives in egui memory while it has focus (frame-local +/// buffers lose keystrokes). Returns the new text when editing ends with a change. +fn draft_box(ui: &mut Ui, key: impl std::hash::Hash + std::fmt::Debug, current: &str, enabled: bool, multiline: bool) -> Option { + let key = egui::Id::new(key); + let mut text = ui.data(|d| d.get_temp::(key)).unwrap_or_else(|| current.to_string()); + let edit = if multiline { egui::TextEdit::multiline(&mut text).desired_rows(5) } else { egui::TextEdit::singleline(&mut text) }; + let r = ui.add_enabled(enabled, edit.desired_width(f32::INFINITY)); + if r.changed() { + ui.data_mut(|d| d.insert_temp(key, text.clone())); + } + if r.lost_focus() { + ui.data_mut(|d| d.remove::(key)); + return (text != current).then_some(text); + } + if !r.has_focus() { + ui.data_mut(|d| d.remove::(key)); + } + None +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 71ec3da4e6..4fbc9cc135 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1656,6 +1656,10 @@ fn context_menu(app: &mut WordApp, ui: &mut Ui) { item(ui, app, tl!("Edit Field…"), "fields.setCode", json!({})); item(ui, app, tl!("Toggle Field Codes"), "fields.toggleCode", json!({})); } + let object = wordcraft_engine::cmd::objects::selected(&app.session).is_some(); + if object || app.session.sel.focus.path.cell().is_some() { + item(ui, app, "View Alt Text…", "view.altTextPane", json!({"value": true})); + } if crate::ribbon::has_picture_selected(&app.session) { ui.separator(); item(ui, app, "Change Picture…", "ui.changePicture", json!({})); diff --git a/crates/ui-egui/src/i18n/audit.rs b/crates/ui-egui/src/i18n/audit.rs index 0749cfb29c..980d318fc7 100644 --- a/crates/ui-egui/src/i18n/audit.rs +++ b/crates/ui-egui/src/i18n/audit.rs @@ -112,6 +112,11 @@ impl Exempt { names.extend(app.session.doc.styles.styles.iter().map(|s| s.name.clone())); // Fonts a document asks for, installed or not (Aptos is drawn with a substitute). names.extend([wordcraft_doc::styles::BODY_FONT, wordcraft_doc::styles::HEADING_FONT].map(str::to_string)); + // The accessibility checker's findings: like command messages, they answer scripts and + // MCP too, so they stay English for now. + for i in wordcraft_engine::cmd::accessibility::check(&app.session.doc) { + names.extend(["issue", "fix"].iter().filter_map(|k| i[*k].as_str()).map(str::to_string)); + } for m in crate::credits::MODELS { names.extend([m.company, m.model, m.version].map(str::to_string)); } diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 8a9b8308bb..3793a246a7 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1412,6 +1412,20 @@ New Group Nuevo grupo Rename… Cambiar nombre… Ribbon and Quick Access Toolbar: Cinta de opciones y barra de acceso rápido: + Accessibility Pane Panel de accesibilidad + Alt Text Pane Panel de texto alternativo + Alt\nText Texto\nalternativo + View Alt Text… Ver texto alternativo… + Mark as decorative Marcar como decorativo + Title: Título: + Errors ({n}) Errores ({n}) + Warnings ({n}) Advertencias ({n}) + Tips ({n}) Sugerencias ({n}) + No accessibility issues found. No se encontraron problemas de accesibilidad. + Describe what this object shows, for people who can't see it. Describa lo que muestra este objeto para quienes no pueden verlo. + Decorative objects only add visual interest: screen readers skip them. Los objetos decorativos solo aportan interés visual: los lectores de pantalla los omiten. + Describe what this table shows, for people who can't see it. Describa lo que muestra esta tabla para quienes no pueden verla. + Select a picture, shape, chart or table to describe it. Seleccione una imagen, forma, gráfico o tabla para describirlo. Document Grid Cuadrícula del documento No grid Sin cuadrícula Lines only Solo líneas diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 92dfd0bc08..9fadefd945 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -1444,6 +1444,20 @@ Rotate all text 270° Pööra kogu teksti 270° Rotate all text 90° Pööra kogu teksti 90° Stacked Virnas + Accessibility Pane Ligipääsetavuse paan + Alt Text Pane Asendusteksti paan + Alt\nText Asendus-\ntekst + View Alt Text… Kuva asendustekst… + Mark as decorative Märgi dekoratiivseks + Title: Pealkiri: + Errors ({n}) Vead ({n}) + Warnings ({n}) Hoiatused ({n}) + Tips ({n}) Näpunäited ({n}) + No accessibility issues found. Ligipääsetavuse probleeme ei leitud. + Describe what this object shows, for people who can't see it. Kirjeldage, mida see objekt näitab, neile, kes seda ei näe. + Decorative objects only add visual interest: screen readers skip them. Dekoratiivsed objektid lisavad ainult visuaalset huvi: ekraanilugerid jätavad need vahele. + Describe what this table shows, for people who can't see it. Kirjeldage, mida see tabel näitab, neile, kes seda ei näe. + Select a picture, shape, chart or table to describe it. Valige pilt, kujund, diagramm või tabel, et seda kirjeldada. Document Grid Dokumendi ruudustik No grid Ruudustikuta Lines only Ainult read diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 4023c4aff7..a74dbc6eba 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1396,6 +1396,20 @@ New Group 新しいグループ Rename… 名前の変更… Ribbon and Quick Access Toolbar: リボンとクイック アクセス ツール バー: + Accessibility Pane アクセシビリティ ウィンドウ + Alt Text Pane 代替テキスト ウィンドウ + Alt\nText 代替\nテキスト + View Alt Text… 代替テキストを表示… + Mark as decorative 装飾用にする + Title: タイトル: + Errors ({n}) エラー ({n}) + Warnings ({n}) 警告 ({n}) + Tips ({n}) ヒント ({n}) + No accessibility issues found. アクセシビリティの問題は見つかりませんでした。 + Describe what this object shows, for people who can't see it. このオブジェクトが示す内容を、見ることができない人のために説明してください。 + Decorative objects only add visual interest: screen readers skip them. 装飾用のオブジェクトは見た目を整えるだけなので、スクリーン リーダーは読み上げません。 + Describe what this table shows, for people who can't see it. この表が示す内容を、見ることができない人のために説明してください。 + Select a picture, shape, chart or table to describe it. 説明する画像、図形、グラフ、または表を選択してください。 Document Grid 文書グリッド No grid グリッドなし Lines only 行のみ diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f175cfa5dd..099dbe4a8c 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1408,6 +1408,20 @@ New Group Novo Grupo Rename… Renomear… Ribbon and Quick Access Toolbar: Faixa de Opções e Barra de Acesso Rápido: + Accessibility Pane Painel de acessibilidade + Alt Text Pane Painel de texto alternativo + Alt\nText Texto\nalternativo + View Alt Text… Exibir texto alternativo… + Mark as decorative Marcar como decorativo + Title: Título: + Errors ({n}) Erros ({n}) + Warnings ({n}) Avisos ({n}) + Tips ({n}) Dicas ({n}) + No accessibility issues found. Nenhum problema de acessibilidade encontrado. + Describe what this object shows, for people who can't see it. Descreva o que este objeto mostra, para quem não pode vê-lo. + Decorative objects only add visual interest: screen readers skip them. Objetos decorativos só acrescentam interesse visual: os leitores de tela os ignoram. + Describe what this table shows, for people who can't see it. Descreva o que esta tabela mostra, para quem não pode vê-la. + Select a picture, shape, chart or table to describe it. Selecione uma imagem, forma, gráfico ou tabela para descrevê-lo. Document Grid Grade do documento No grid Sem grade Lines only Somente linhas diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 8903a83381..27018f5c52 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1790,6 +1790,20 @@ preview Title Пример Left Only Только слева Right Only Только справа Largest Only Только по большей стороне + Accessibility Pane Область специальных возможностей + Alt Text Pane Область замещающего текста + Alt\nText Замещающий\nтекст + View Alt Text… Показать замещающий текст… + Mark as decorative Пометить как декоративный + Title: Заголовок: + Errors ({n}) Ошибки ({n}) + Warnings ({n}) Предупреждения ({n}) + Tips ({n}) Советы ({n}) + No accessibility issues found. Проблем со специальными возможностями не найдено. + Describe what this object shows, for people who can't see it. Опишите, что показывает этот объект, для людей, которые его не видят. + Decorative objects only add visual interest: screen readers skip them. Декоративные объекты лишь украшают документ: программы чтения с экрана их пропускают. + Describe what this table shows, for people who can't see it. Опишите, что показывает эта таблица, для людей, которые её не видят. + Select a picture, shape, chart or table to describe it. Выберите рисунок, фигуру, диаграмму или таблицу, чтобы описать её. Document Grid Сетка документа No grid Без сетки Lines only Только строки diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 8b21b0c252..e87c436fcd 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -1406,6 +1406,20 @@ New Group Nova grupa Rename… Preimenuj… Ribbon and Quick Access Toolbar: Traka i traka sa alatkama za brzi pristup: + Accessibility Pane Okno pristupačnosti + Alt Text Pane Okno alternativnog teksta + Alt\nText Alternativni\ntekst + View Alt Text… Prikaži alternativni tekst… + Mark as decorative Označi kao dekorativno + Title: Naslov: + Errors ({n}) Greške ({n}) + Warnings ({n}) Upozorenja ({n}) + Tips ({n}) Saveti ({n}) + No accessibility issues found. Nije pronađen nijedan problem pristupačnosti. + Describe what this object shows, for people who can't see it. Opišite šta ovaj objekat prikazuje, za one koji ne mogu da ga vide. + Decorative objects only add visual interest: screen readers skip them. Dekorativni objekti samo doprinose izgledu: čitači ekrana ih preskaču. + Describe what this table shows, for people who can't see it. Opišite šta ova tabela prikazuje, za one koji ne mogu da je vide. + Select a picture, shape, chart or table to describe it. Izaberite sliku, oblik, grafikon ili tabelu da biste ih opisali. Document Grid Mreža dokumenta No grid Bez mreže Lines only Samo redovi diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 4f2d003329..b1352e4aae 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -1410,6 +1410,20 @@ New Group Нова група Rename… Преименуј… Ribbon and Quick Access Toolbar: Трака и трака са алаткама за брзи приступ: + Accessibility Pane Окно приступачности + Alt Text Pane Окно алтернативног текста + Alt\nText Алтернативни\nтекст + View Alt Text… Прикажи алтернативни текст… + Mark as decorative Означи као декоративно + Title: Наслов: + Errors ({n}) Грешке ({n}) + Warnings ({n}) Упозорења ({n}) + Tips ({n}) Савети ({n}) + No accessibility issues found. Није пронађен ниједан проблем приступачности. + Describe what this object shows, for people who can't see it. Опишите шта овај објекат приказује, за оне који не могу да га виде. + Decorative objects only add visual interest: screen readers skip them. Декоративни објекти само доприносе изгледу: читачи екрана их прескачу. + Describe what this table shows, for people who can't see it. Опишите шта ова табела приказује, за оне који не могу да је виде. + Select a picture, shape, chart or table to describe it. Изаберите слику, облик, графикон или табелу да бисте их описали. Document Grid Мрежа документа No grid Без мреже Lines only Само редови diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index fa6a35527a..289858d589 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1408,6 +1408,20 @@ Left Only Yalnızca Sol Right Only Yalnızca Sağ Largest Only Yalnızca En Geniş + Accessibility Pane Erişilebilirlik Bölmesi + Alt Text Pane Alternatif Metin Bölmesi + Alt\nText Alternatif\nMetin + View Alt Text… Alternatif Metni Görüntüle… + Mark as decorative Dekoratif olarak işaretle + Title: Başlık: + Errors ({n}) Hatalar ({n}) + Warnings ({n}) Uyarılar ({n}) + Tips ({n}) İpuçları ({n}) + No accessibility issues found. Erişilebilirlik sorunu bulunamadı. + Describe what this object shows, for people who can't see it. Bu nesnenin neyi gösterdiğini, göremeyenler için açıklayın. + Decorative objects only add visual interest: screen readers skip them. Dekoratif nesneler yalnızca görsel çekicilik katar: ekran okuyucular bunları atlar. + Describe what this table shows, for people who can't see it. Bu tablonun neyi gösterdiğini, göremeyenler için açıklayın. + Select a picture, shape, chart or table to describe it. Açıklamak için bir resim, şekil, grafik veya tablo seçin. Document Grid Belge Kılavuzu No grid Kılavuz yok Lines only Yalnızca satırlar diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 3c9d51872a..893804b545 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1408,6 +1408,20 @@ New Group Нова група Rename… Перейменувати… Ribbon and Quick Access Toolbar: Стрічка й панель швидкого доступу: + Accessibility Pane Панель доступності + Alt Text Pane Панель альтернативного тексту + Alt\nText Альтернативний\nтекст + View Alt Text… Переглянути альтернативний текст… + Mark as decorative Позначити як декоративний + Title: Заголовок: + Errors ({n}) Помилки ({n}) + Warnings ({n}) Попередження ({n}) + Tips ({n}) Поради ({n}) + No accessibility issues found. Проблем із доступністю не знайдено. + Describe what this object shows, for people who can't see it. Опишіть, що показує цей об’єкт, для тих, хто не може його бачити. + Decorative objects only add visual interest: screen readers skip them. Декоративні об’єкти лише прикрашають документ: засоби читання з екрана їх пропускають. + Describe what this table shows, for people who can't see it. Опишіть, що показує ця таблиця, для тих, хто не може її бачити. + Select a picture, shape, chart or table to describe it. Виберіть зображення, фігуру, діаграму або таблицю, щоб описати її. Document Grid Сітка документа No grid Без сітки Lines only Лише рядки diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 2d735ab2d4..85b59009c4 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1408,6 +1408,20 @@ New Group 新建组 Rename… 重命名… Ribbon and Quick Access Toolbar: 功能区和快速访问工具栏: + Accessibility Pane 辅助功能窗格 + Alt Text Pane 替代文字窗格 + Alt\nText 替代\n文字 + View Alt Text… 查看替代文字… + Mark as decorative 标记为装饰性 + Title: 标题: + Errors ({n}) 错误 ({n}) + Warnings ({n}) 警告 ({n}) + Tips ({n}) 提示 ({n}) + No accessibility issues found. 未发现辅助功能问题。 + Describe what this object shows, for people who can't see it. 请为看不见的人描述此对象显示的内容。 + Decorative objects only add visual interest: screen readers skip them. 装饰性对象仅增添视觉效果:屏幕阅读器会跳过它们。 + Describe what this table shows, for people who can't see it. 请为看不见的人描述此表格显示的内容。 + Select a picture, shape, chart or table to describe it. 选择图片、形状、图表或表格以添加描述。 Document Grid 文档网格 No grid 无网格 Lines only 仅行 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 94a8774990..dda1432430 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1397,6 +1397,20 @@ New Group 新增群組 Rename… 重新命名… Ribbon and Quick Access Toolbar: 功能區和快速存取工具列: + Accessibility Pane 協助工具窗格 + Alt Text Pane 替代文字窗格 + Alt\nText 替代\n文字 + View Alt Text… 檢視替代文字… + Mark as decorative 標示為裝飾用 + Title: 標題: + Errors ({n}) 錯誤 ({n}) + Warnings ({n}) 警告 ({n}) + Tips ({n}) 提示 ({n}) + No accessibility issues found. 找不到協助工具問題。 + Describe what this object shows, for people who can't see it. 請為看不見的人描述此物件顯示的內容。 + Decorative objects only add visual interest: screen readers skip them. 裝飾用物件只增添視覺效果:螢幕助讀程式會略過它們。 + Describe what this table shows, for people who can't see it. 請為看不見的人描述此表格顯示的內容。 + Select a picture, shape, chart or table to describe it. 選取圖片、圖形、圖表或表格以加入描述。 Document Grid 文件格線 No grid 無格線 Lines only 僅行 diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 8bbeca1172..49a7147e28 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -782,6 +782,14 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32, accent: Color32) { pen.line(&[(10.0, 7.0), (10.0, 12.0), (7.0, 17.5)]); pen.line(&[(10.0, 12.0), (13.0, 17.5)]); } + "altText" => { + // A small picture with lines of text beside and below it. + pen.rect(2.5, 3.5, 11.5, 11.0, c); + pen.line_c(&[(3.5, 10.0), (6.0, 6.5), (8.0, 8.5), (9.0, 7.5), (10.5, 10.0)], a); + for (x0, x1, y) in [(13.5, 17.5, 5.0), (13.5, 17.5, 8.0), (2.5, 17.5, 14.0), (2.5, 13.0, 17.0)] { + pen.line_c(&[(x0, y), (x1, y)], c); + } + } "translate" | "language" => { pen.text(6.5, 7.0, 9.0, "A", c, true); pen.text(13.5, 13.5, 9.0, "あ", a, false); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f95a6c01a0..f788740491 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -13,6 +13,7 @@ macro_rules! tl { }; } +pub mod a11y_pane; pub mod backstage; pub mod canvas; pub mod chrome; @@ -372,6 +373,10 @@ impl WordApp { return r; } let r = self.execute_user(id, params); + // Check Accessibility shows its findings in the Accessibility pane, kept up to date there. + if id == "file.accessibility" && r.is_ok() { + self.session.view.accessibility_pane = true; + } // Match Fields and Check for Errors show what they found. if let Ok(v) = &r && let Some(d) = dialogs::Dialog::report(id, v) diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 2c8eff222f..06ecd9aa34 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -1,4 +1,5 @@ -//! Side panes: Navigation (headings, pages, search results), Clipboard, Styles, Style Inspector, Comments. +//! Side panes: Navigation (headings, pages, search results), Clipboard, Styles, Style Inspector, +//! Accessibility and Alt Text (see `a11y_pane`), Comments. use egui::{Stroke, Ui, vec2}; use serde_json::{Value, json}; @@ -37,6 +38,20 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { .frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border))) .show(ui, |ui| inspector(app, ui)); } + if app.session.view.accessibility_pane { + egui::Panel::right("accessibility_pane") + .default_size(280.0) + .resizable(true) + .frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border))) + .show(ui, |ui| crate::a11y_pane::accessibility(app, ui)); + } + if app.session.view.alt_text_pane { + egui::Panel::right("alt_text_pane") + .default_size(260.0) + .resizable(true) + .frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border))) + .show(ui, |ui| crate::a11y_pane::alt_text(app, ui)); + } if app.session.view.comments_pane { egui::Panel::right("comments_pane") .default_size(290.0) @@ -46,7 +61,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { } } -fn header(ui: &mut Ui, title: &str) -> bool { +pub(crate) fn header(ui: &mut Ui, title: &str) -> bool { let mut close = false; ui.horizontal(|ui| { ui.label(egui::RichText::new(tl!(title)).font(semibold(15.0))); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 41e0247906..bf74ed2c14 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1436,6 +1436,7 @@ fn shape_format(app: &mut WordApp, ui: &mut Ui) { }); }); }); + alt_text_group(ui, app); if wordcraft_engine::cmd::canvas::selected_canvas(&app.session).is_some() { group(ui, "Drawing Canvas", None, app, |ui, app| { menu_button(ui, app, "canvas", Some("Drawing\nCanvas"), "Drawing Canvas", true, |ui, app| { @@ -1514,6 +1515,13 @@ fn shape_text_group(app: &mut WordApp, ui: &mut Ui) { }); } +/// Picture/Shape Format › Accessibility: Alt Text opens the Alt Text pane. +fn alt_text_group(ui: &mut Ui, app: &mut WordApp) { + group(ui, "Accessibility", None, app, |ui, app| { + big(ui, app, "altText", "Alt\nText", "view.altTextPane", json!({"value": true}), false); + }); +} + /// Shape Format › Shape Effects: Shadow, Glow and Soft Edges, each with presets and a "No …" entry. fn shape_effects_menu(ui: &mut Ui, app: &mut WordApp, theme: &[wordcraft_doc::Rgb], glow_size: Option) { ui.menu_button(tl!("Shadow"), |ui| { @@ -1602,6 +1610,7 @@ fn picture_format(app: &mut WordApp, ui: &mut Ui) { } }); }); + alt_text_group(ui, app); group(ui, "Arrange", None, app, |ui, app| { big(ui, app, "position", "Position", "arrange.position", json!({}), false); wrap_menu(ui, app); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 53e89e56e4..8cab9c00f3 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -61,6 +61,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Freeform shapes (`a:custGeom`: `a:moveTo`, `a:lnTo`, Bézier curves flattened) and WordCraft ink | ✅ | ✅ | Ink is written as a freeform `wps:wsp` in a `wp:anchor` (round caps, alpha for highlighter) and recognised again by its drawing name (#307). It is saved as a custom-geometry shape, not as Word's own ink (InkML in `w14:contentPart`), so Word shows WordCraft ink as a freeform shape it can move and recolour but not erase with its ink eraser; arcs drawn straight | | Group shapes (`wpg:`), drawing canvas (`wpc:`) | ✅ | ✅ | Groups (#267) and canvases (#344) with pictures, shapes and text boxes; nested groups flattened. The canvas schema has no transform, so a turned or flipped canvas keeps its turn in our own `wpc:extLst` extension (`urn:wordcraft:canvas-xfrm`; Word shows it unturned). Legacy VML canvases (`v:group editas="canvas"`) aren't read as canvases | | VML (`w:pict`, `v:shape`, `v:textbox`) | 🟡 | ❌ | Pictures and text boxes, best effort (#242) | +| Alt text of drawings (`wp:docPr`/`cNvPr` `descr` and `title`, decorative mark `adec:decorative` from [MS-ODRAWXML]) and tables (`w:tblCaption`, `w:tblDescription`) | ✅ | ✅ | Pictures, shapes, text boxes, groups and their members, charts and SmartArt; OLE objects get theirs written into the kept XML (#386) | | Charts (`c:chart`), SmartArt (`dgm`) | ✅ | ✅ | Drawn from the chart's cached data and SmartArt's stored drawing (#292); not editable. Saved back as read, with every part they reach (chart, embedded workbook, colours and style; diagram data, layout, quick style, colours and drawing) and their content types; a moved or resized one keeps its new frame, and a pasted copy gets its own parts (#319) | | OLE objects (`w:object`: embedded or linked files, ActiveX) | 🟡 | ✅ | Shown as their picture (VML or DrawingML); saved back whole with the embedding and picture parts, at the size, rotation and flips set in WordCraft (#319, #332). Can't be opened or edited | | Word ink (`w14:contentPart`, InkML), 3D models | ❌ | ❌ | Dropped on read, not preserved on save | @@ -86,6 +87,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Alt text, titles and the decorative mark for every drawing, table captions and descriptions round-trip (#386) | | 2026-10-11 | trivial | Document grid (`w:docGrid`) and paragraph `w:snapToGrid` read and written (#391) | | 2026-10-11 | trivial | Move tracking (`w:moveFrom`/`w:moveTo` and their ranges) read and written (#400) | | 2026-10-11 | trivial | DOCX text-box `bodyPr` `vert`/`anchor` and linked text boxes (`wps:linkedTxbx`) read and written (#369) | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index f8a06af45c..4e03d849cb 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -2,7 +2,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry. -**433 of 451 catalog features have commands (96.0%).** +**436 of 454 catalog features have commands (96.0%).** | Tab | Live | Total | |---|---|---| @@ -15,13 +15,13 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word- | Layout | 21 | 21 | | References | 24 | 24 | | Mailings | 17 | 17 | -| Review | 28 | 30 | +| Review | 29 | 31 | | View | 25 | 29 | | Table Design | 8 | 8 | -| Table Layout | 31 | 31 | +| Table Layout | 32 | 32 | | Equation | 21 | 22 | | Picture Format | 14 | 14 | -| Shape Format | 6 | 6 | +| Shape Format | 7 | 7 | | Header & Footer | 5 | 5 | | Editing | 38 | 38 | | Tools | 5 | 6 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 18d9cd4a39..fd4b8c8d04 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -13,7 +13,7 @@ The generated ribbon checklist is [`parity-checklist.md`](parity-checklist.md). | Number | Value | Kind | |---|---|---| -| Ribbon/menu catalog coverage | **433 / 451 (96.0%)** | **measured**: `cargo xtask parity` (re-derived this pass from `crates/engine/src/catalog.rs` against every `CommandSpec::new` id in the source; identical to the checked-in `parity-checklist.md`) | +| Ribbon/menu catalog coverage | **436 / 454 (96.0%)** | **measured**: `cargo xtask parity` (re-derived this pass from `crates/engine/src/catalog.rs` against every `CommandSpec::new` id in the source; identical to the checked-in `parity-checklist.md`) | | Feature breadth (weighted, beyond the ribbon: dialog options, citation styles, proofing languages, chart types, UI languages) | **~80%** | estimated | | Feature depth (weighted by use, table below) | **~68%** | estimated | | **Ready for real work** (full target) | **~60%** (range 55–63%) | estimated: weighted sum over the dimension table ([By dimension](#by-dimension)) | @@ -75,7 +75,7 @@ full parity for the area. | Backstage, printing, options | 3 | 58% | 55% | 12–20 | New/Home pages (#254), save prompts (#151), web printing via the browser (#209). Desktop printing still goes through PDF; Options has a fraction of Word's panes. | | Right-to-left and complex scripts | 1 | 50% | 55% | 15–25 | UAX #9, Arabic shaping, RTL paragraphs (#207). No RTL sections/tables, kashida justification or mirrored UI; open bug reports (#215, #211, #199, #66, #63, #48, #19) need re-testing against #207. | | East Asian typography | 1 | 25% | not rated | 13–22 | IME input works (winit fixes pending, #155–#164). Ruby/Phonetic Guide with DOCX round trip (#288); document grid (lines per page, characters per line, #391). No vertical text, `w:eastAsianLayout`, enclose characters, Asian line-break options. | -| Accessibility | 1 | 35% | not rated | 15–25 | AccessKit enabled in eframe; an accessibility checker exists. Screen-reader access to the document canvas is untested; no alt-text workflow polish, no read-order tools. | +| Accessibility | 1 | 50% | not rated | 10–18 | AccessKit enabled in eframe. Alt Text pane for pictures, shapes, text boxes, groups, charts and tables with Mark as decorative, all round-tripping in DOCX (#386). The Accessibility pane groups errors, warnings and tips with jump-to and one-click fixes; rules for alt text, heading order, header rows, blank table rows/columns, merged cells, low contrast (WCAG ratio), floating objects and blank paragraphs. Screen-reader access to the document canvas is still untested (#18); no reading-order tools. | | Agent control (CLI, MCP, control channel, macro record/playback) | 0 | beyond Word | 100%+ | — | Every action is a command id usable from CLI, MCP and a keyed control port. Word has VBA and Office.js instead (see Ecosystem). | Weighted feature depth: **~68%** (Σ weight × parity / 100 over the table). @@ -249,6 +249,7 @@ can be used without committing Word output. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Accessibility 35% → 50%: Alt Text pane for every object and tables, decorative flag, deeper checker with an Accessibility pane (#386); weight 1, headline unchanged | | 2026-10-11 | trivial | References: Caption, Index, Mark Entry, Custom Table of Contents and Create/Edit Source dialogs, with the field switches they write honoured on update (#402); no percentage change | | 2026-10-11 | trivial | Move tracking (#400): Review row updated; no percentage change | | 2026-10-11 | trivial | Footnotes and endnotes: Footnote and Endnote dialog, options per section (position, format, start, restart each section/page), convert notes (#385); no percentage change | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e506318999..9404d24c1d 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -17,7 +17,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | Customize Ribbon / Customize Keyboard | Customize Keyboard (#368): categories, a command's keys, press a combination to see which command uses it, Assign / Remove / Reset All; custom keys override built-ins, removed built-ins do nothing, saved per user; `tools.customizeKeyboard` for agents. Customize Ribbon (#379): show, hide and reorder tabs, custom tabs and custom groups (on any tab) holding any commands, rename, remove, Reset; right-click on the ribbon; saved per user; `tools.customizeRibbon` for agents. Built-in tabs can't be renamed; no import/export file | ✅ | 85% | 1–2 | | Keyboard shortcuts | **125** commands bound in `CommandSpec::key`, 144 key combinations (measured on Linux/Windows); styles, spacing, symbols, fields, views, mail merge (#330); field keys Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, F11, Shift+F11 (#339); Alt+Shift+arrows (move paragraphs, promote/demote), Shift+F5 Go Back and Alt+X (#343). Missing: en dash on the numeric keypad | ~250 default shortcuts | ~70% | 2–4 | | Modal dialogs | **44** (`Dialog` enum: Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Icons, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors; Caption with New Label and Numbering, Index, Mark Index Entry, Custom Table of Contents with Options and TOC styles, Source Manager, Create/Edit Source (#402)) plus panes and menus for the rest | ~100 (Compare, Protect, Language, Hyphenation, Line Numbers, Envelopes, Labels, AutoCorrect, Options panes…) | ~45% | 15–25 | -| Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation, Styles, Comments, Thesaurus, Accessibility, Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 | +| Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation, Styles, Comments, Thesaurus, Accessibility (grouped findings, jump-to, fixes) and Alt Text (#386), Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 | | Context menus (right-click) | text, spelling suggestions, fields (Update Field, Edit Field…, Toggle Field Codes, #339); objects and tables partial | rich per context | 55% | 3–5 | | Backstage (Home, New, Open, Info, Save As, Print, Share, Export, Options) | Home and New separate (#254), Info properties kept (#268) | | 68% | 4–6 | | Rulers: indents, tabs, margins, table columns | indents draggable (one undo, #62); tabs; table column drag pending (#49) | ✅ | 70% | 2–3 | @@ -37,6 +37,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Accessibility pane (errors/warnings/tips, jump-to, fixes) and Alt Text pane, opened from Picture/Shape Format › Accessibility and right-click › View Alt Text… (#386) | | 2026-10-11 | trivial | References dialogs: Caption (New/Delete Label, Numbering with chapter numbers), Index and Mark Index Entry (Mark All), Custom Table of Contents (Options, TOC styles via Modify Style), Source Manager and Create/Edit Source; modal dialog count 34 (#402) | | 2026-10-11 | trivial | Customize Ribbon (custom tabs and groups, hidden and reordered tabs) and a customizable Quick Access Toolbar with a working … menu (#379, #376) | | 2026-10-11 | trivial | Customize Keyboard dialog (Tools › Customize Keyboard, File › Options › Keyboard shortcuts): custom keys override built-ins and persist (#368) | From 1a0db9ec6562162078b0c192fb7cc01bd95582dd Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 11 Oct 2026 22:15:53 +0200 Subject: [PATCH 04/89] Dialogs: Line Numbers, Hyphenation, Language, Envelopes and Labels (#407) (#414) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Dialogs: Line Numbers, Hyphenation, Language, Envelopes and Labels (#407) - Line Numbers (Line Numbering Options…): add numbering, start at, from text (auto or a distance), count by, restart each page/section or continuous; `layout.lineNumbers` takes start/countBy/distance. - Hyphenation Options: automatic, words in CAPS, hyphenation zone, consecutive-hyphen limit; kept in DOCX (w:hyphenationZone, w:doNotHyphenateCaps, w:consecutiveHyphenLimit) and honoured by layout. Manual…: `layout.manualHyphenation` finds words at line starts that would fit hyphenated on the line above; Yes inserts an optional hyphen, No skips. - Language: localized language list, do not check spelling or grammar, detect language automatically (per-user preference), Set As Default (document default language). - Envelopes: delivery/return address, common sizes (#10, #9, Monarch, DL, C4, C5, C6, B5); Add to Document inserts an envelope section before the letter. Labels: common sheets (Letter and A4), full page or a single label at row/column, one per recipient; New Document builds the sheet with gap columns. - Ribbon: Line Numbering Options…, Hyphenation Options…, Manual…; the Language, Envelopes and Labels buttons open their dialogs. Co-Authored-By: Claude Opus 5.5 * Remove duplicate Label: keys in ru and tr (#407) --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 2 + crates/doc/src/lib.rs | 13 + crates/docx/src/read/mod.rs | 11 + crates/docx/src/write/mod.rs | 10 + crates/docx/tests/roundtrip.rs | 4 + crates/engine/src/cmd/mailings.rs | 334 ++++++++++-- crates/engine/src/cmd/page.rs | 208 +++++++- crates/engine/src/cmd/tools.rs | 71 ++- crates/engine/src/session.rs | 4 +- crates/layout/src/lib.rs | 8 +- crates/layout/src/para.rs | 19 +- crates/layout/src/tests.rs | 26 + crates/ui-egui/src/dialogs.rs | 8 + crates/ui-egui/src/dialogs_layout.rs | 744 +++++++++++++++++++++++++++ crates/ui-egui/src/i18n/es.tsv | 63 +++ crates/ui-egui/src/i18n/et.tsv | 63 +++ crates/ui-egui/src/i18n/ja.tsv | 63 +++ crates/ui-egui/src/i18n/pt-br.tsv | 63 +++ crates/ui-egui/src/i18n/ru.tsv | 63 +++ crates/ui-egui/src/i18n/sr-latn.tsv | 63 +++ crates/ui-egui/src/i18n/sr.tsv | 63 +++ crates/ui-egui/src/i18n/tr.tsv | 63 +++ crates/ui-egui/src/i18n/uk.tsv | 63 +++ crates/ui-egui/src/i18n/zh-hans.tsv | 63 +++ crates/ui-egui/src/i18n/zh-hant.tsv | 63 +++ crates/ui-egui/src/lib.rs | 30 +- crates/ui-egui/src/ribbon.rs | 5 + docs/file-format-parity.md | 2 + docs/gaps.md | 3 +- docs/layout-parity.md | 5 +- docs/ui-parity.md | 5 +- 31 files changed, 2121 insertions(+), 84 deletions(-) create mode 100644 crates/ui-egui/src/dialogs_layout.rs diff --git a/ROADMAP.md b/ROADMAP.md index cf684ff7e4..e4465afa90 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| +| 2026-10-11 | Line Numbers, Hyphenation (zone, CAPS, consecutive limit, Manual), Language, Envelopes and Labels dialogs (#407); hyphenation options kept in DOCX; 30 modal dialogs | | 2026-10-11 | Accessibility (#386): Alt Text pane for every object and table, Mark as decorative, alt text round-trips in DOCX, deeper checker with fix actions | | 2026-10-11 | References dialogs (#402): Caption (labels, chapter numbering), Index and Mark Entry, Custom Table of Contents, Source Manager and Create/Edit Source, with TOC/INDEX/XE/SEQ switches honoured on update | | 2026-10-11 | Document grid (#391): Page Setup › Document Grid sets lines per page and characters per line (`w:docGrid` round trip); lines snap to the line pitch, East Asian characters to the character pitch; View › Gridlines shows it | @@ -165,6 +166,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Progress log: Line Numbers, Hyphenation, Language, Envelopes and Labels dialogs (#407) | | 2026-10-11 | trivial | Progress log: Alt Text pane, decorative objects, deeper accessibility checker (#386) | | 2026-10-11 | trivial | Progress log: Draw lasso, Add Pen, Ink to Shape, Ink Replay (#395); catalog 406/434 | | 2026-10-11 | trivial | Progress log: document grid (#391) | diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index ae0c6928a0..8b441b86b3 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -391,6 +391,13 @@ pub struct Settings { pub even_odd_headers: bool, pub mirror_margins: bool, pub auto_hyphenation: bool, + /// Hyphenation zone (`w:hyphenationZone`), points: a line is hyphenated only when breaking + /// it at a word boundary would leave more than this empty at its end. + pub hyphenation_zone: f32, + /// Words in capitals may be hyphenated (`w:doNotHyphenateCaps` turns it off). + pub hyphenate_caps: bool, + /// Most consecutive lines that may end with a hyphen (`w:consecutiveHyphenLimit`); 0 = no limit. + pub consecutive_hyphen_limit: u32, pub page_color: Option, pub watermark: Option, /// Theme fonts (major = headings, minor = body). @@ -426,6 +433,9 @@ pub const LEGACY_COMPAT_MODE: u32 = 12; /// The compatibility mode of documents created by Word 2013 and later, and by WordCraft. pub const COMPAT_MODE_CURRENT: u32 = 15; +/// Word's hyphenation zone when a document doesn't set one (0.25"), points. +pub const DEFAULT_HYPHENATION_ZONE: f32 = 18.0; + /// ECMA-376 §17.15.1.44/45: 180 twentieths of a point when a document doesn't say. pub const DEFAULT_GRID: f32 = 9.0; @@ -469,6 +479,9 @@ impl Default for Settings { even_odd_headers: false, mirror_margins: false, auto_hyphenation: false, + hyphenation_zone: DEFAULT_HYPHENATION_ZONE, + hyphenate_caps: true, + consecutive_hyphen_limit: 0, page_color: None, watermark: None, major_font: styles::HEADING_FONT.into(), diff --git a/crates/docx/src/read/mod.rs b/crates/docx/src/read/mod.rs index 3cd99ecb7b..8de43028e2 100644 --- a/crates/docx/src/read/mod.rs +++ b/crates/docx/src/read/mod.rs @@ -804,6 +804,17 @@ impl Reader<'_> { "m:mathPr" => s.math = Some(math::read_math_pr(k)), "w:mirrorMargins" => s.mirror_margins = on_off(k), "w:autoHyphenation" => s.auto_hyphenation = on_off(k), + "w:doNotHyphenateCaps" => s.hyphenate_caps = !on_off(k), + "w:hyphenationZone" => { + if let Some(v) = tw(k, "w:val").filter(|v| v.is_finite() && *v >= 0.0) { + s.hyphenation_zone = v.min(1584.0); + } + } + "w:consecutiveHyphenLimit" => { + if let Some(v) = k.attr("w:val").and_then(u32_of) { + s.consecutive_hyphen_limit = v.min(32_767); + } + } "w:footnotePr" => { if let Some(f) = k.child_val("w:numFmt") { s.footnote_format = NumFormat::from_ooxml(f); diff --git a/crates/docx/src/write/mod.rs b/crates/docx/src/write/mod.rs index b31fdf9042..65a0e149ba 100644 --- a/crates/docx/src/write/mod.rs +++ b/crates/docx/src/write/mod.rs @@ -852,6 +852,16 @@ fn settings_xml(doc: &Document, footnotes: bool, endnotes: bool) -> Vec { if s.auto_hyphenation { w.empty("w:autoHyphenation", &[]); } + // Schema order: consecutiveHyphenLimit, hyphenationZone, doNotHyphenateCaps. + if s.consecutive_hyphen_limit > 0 { + w.val("w:consecutiveHyphenLimit", &s.consecutive_hyphen_limit.min(32_767).to_string()); + } + if s.hyphenation_zone.is_finite() && (s.hyphenation_zone - wordcraft_doc::DEFAULT_HYPHENATION_ZONE).abs() > 0.01 { + w.val("w:hyphenationZone", &crate::units::twips(s.hyphenation_zone.clamp(0.0, 1584.0))); + } + if !s.hyphenate_caps { + w.empty("w:doNotHyphenateCaps", &[]); + } if s.even_odd_headers { w.empty("w:evenAndOddHeaders", &[]); } diff --git a/crates/docx/tests/roundtrip.rs b/crates/docx/tests/roundtrip.rs index 902cca8462..8a558e2507 100644 --- a/crates/docx/tests/roundtrip.rs +++ b/crates/docx/tests/roundtrip.rs @@ -1116,6 +1116,10 @@ fn settings_core_theme_round_trip() { d.settings.even_odd_headers = true; d.settings.mirror_margins = true; d.settings.auto_hyphenation = true; + // Hyphenation Options (#407): zone, words in capitals, consecutive hyphens. + d.settings.hyphenation_zone = 27.0; + d.settings.hyphenate_caps = false; + d.settings.consecutive_hyphen_limit = 2; d.settings.page_color = Some(Rgb(250, 240, 230)); d.settings.major_font = "Cambria".into(); d.settings.minor_font = "Calibri".into(); diff --git a/crates/engine/src/cmd/mailings.rs b/crates/engine/src/cmd/mailings.rs index 3ff6cb057d..ba66116191 100644 --- a/crates/engine/src/cmd/mailings.rs +++ b/crates/engine/src/cmd/mailings.rs @@ -136,10 +136,12 @@ pub fn specs() -> Vec { .params(r#"{"path"?: string (save the merged document), "from"?: n, "to"?: n}"#) .key(super::non_mac("Alt+Shift+N")) .when(has_recipients), - CommandSpec::new("mailings.envelopes", "Envelopes", "Mailings › Create", envelopes) - .params(r#"{"delivery": string, "return"?: string, "size"?: "Envelope #10|Envelope DL"}"#), - CommandSpec::new("mailings.labels", "Labels", "Mailings › Create", labels) - .params(r#"{"text"?: string, "rows"?: n, "cols"?: n, "fromRecipients"?: bool}"#), + CommandSpec::new("mailings.envelopes", "Envelopes", "Mailings › Create", envelopes).params( + r#"{"delivery": string, "return"?: string, "size"?: "Envelope #10|#9|Monarch|DL|C4|C5|C6|B5", "width"?: pt, "height"?: pt, "add"?: bool (add to this document instead of a new one)}"#, + ), + CommandSpec::new("mailings.labels", "Labels", "Mailings › Create", labels).params( + r#"{"text"?: string, "product"?: "letter-3x10|letter-2x10|letter-2x7|letter-2x5|letter-4x20|a4-3x7|a4-2x7|a4-3x8|a4-2x4", "rows"?: n, "cols"?: n, "fromRecipients"?: bool, "single"?: {"row": n, "col": n}}"#, + ), ] } @@ -739,17 +741,41 @@ fn merge_blocks(s: &Session, blocks: &mut Blocks, cur: &mut usize, end: usize, d true } +/// Common envelope sizes: name, width and height in points (long side first). +pub const ENVELOPE_SIZES: [(&str, f32, f32); 8] = [ + ("Envelope #10", 684.0, 297.0), + ("Envelope #9", 639.0, 279.0), + ("Envelope Monarch", 540.0, 279.0), + ("Envelope DL", 623.6, 311.8), + ("Envelope C4", 918.4, 649.1), + ("Envelope C5", 649.1, 459.2), + ("Envelope C6", 459.2, 323.1), + ("Envelope B5", 708.7, 498.9), +]; + +/// Envelope size by name (`Envelope DL`, or just `DL`, any case), or `width`/`height` in points. +fn envelope_size(v: &Value) -> Result<(f32, f32), CmdError> { + if let (Some(w), Some(h)) = (p::f32(v, "width"), p::f32(v, "height")) { + let (w, h) = (w.clamp(144.0, 1584.0), h.clamp(144.0, 1584.0)); + return Ok((w.max(h), w.min(h))); + } + let name = p::str(v, "size").unwrap_or("Envelope #10").trim(); + ENVELOPE_SIZES + .iter() + .find(|(n, ..)| n.eq_ignore_ascii_case(name) || n.strip_prefix("Envelope ").is_some_and(|short| short.eq_ignore_ascii_case(name))) + .map(|(_, w, h)| (*w, *h)) + .ok_or_else(|| CmdError::Params(format!("unknown envelope size `{name}`"))) +} + +/// Envelopes: a landscape page with the return address at the top left and the delivery address +/// in the middle. As a new document, or (`add`) as a section of its own at the start of this one. fn envelopes(s: &mut Session, v: &Value) -> CmdResult { let delivery = p::str(v, "delivery").map(str::to_string).unwrap_or_else(|| { if s.merge.rows.is_empty() { "Recipient Name\nStreet Address\nCity, ST 00000".into() } else { address_block(s, s.merge.record) } }); let ret = p::str(v, "return").unwrap_or("").to_string(); - let (w, h) = match p::str(v, "size").unwrap_or("Envelope #10") { - "Envelope DL" => (623.6, 311.8), - _ => (684.0, 297.0), - }; - let mut d = Document::new(); - d.last_section = SectionProps { + let (w, h) = envelope_size(v)?; + let sect = SectionProps { page_w: w, page_h: h, landscape: true, @@ -759,55 +785,262 @@ fn envelopes(s: &mut Session, v: &Value) -> CmdResult { margin_bottom: 22.0, ..Default::default() }; + // Addresses are capped: a pasted novel is not an address. + let lines = |t: &str| t.split('\n').take(20).map(str::to_string).collect::>(); let mut blocks = Vec::new(); - for l in ret.split('\n') { + let ret_lines = if ret.is_empty() { Vec::new() } else { lines(&ret) }; + for l in &ret_lines { blocks.push(para_block(Paragraph::with_text(l, CharProps { size: Some(10.0), ..Default::default() }).styled("NoSpacing"))); } - let spacer = (6 - ret.lines().count().min(6)).max(1); + let spacer = (6 - ret_lines.len().min(6)).max(1); for _ in 0..spacer + 2 { blocks.push(para_block(Paragraph::new().styled("NoSpacing"))); } - for l in delivery.split('\n') { - let mut p = Paragraph::with_text(l, CharProps::default()).styled("NoSpacing"); + let mut last = None; + for l in lines(&delivery) { + let mut p = Paragraph::with_text(&l, CharProps::default()).styled("NoSpacing"); p.props.indent_left = Some(w * 0.42); - blocks.push(para_block(p)); + if let Some(prev) = last.replace(p) { + blocks.push(para_block(prev)); + } } + if p::bool(v, "add") == Some(true) { + // The envelope's own section ends with its last address line. + let mut end = last.unwrap_or_else(|| Paragraph::new().styled("NoSpacing")); + end.section = Some(Box::new(sect)); + blocks.push(para_block(end)); + let n = blocks.len(); + s.doc.body.splice(0..0, blocks); + s.sel = Selection::caret(s.doc.start_of(StoryRef::Body)); + return Ok(json!({"width": w, "height": h, "added": n})); + } + blocks.extend(last.map(para_block)); + let mut d = Document::new(); + d.last_section = sect; d.body = blocks; s.set_document(d); s.path = None; Ok(json!({"width": w, "height": h})) } +/// A sheet of labels: the label size, how many across and down, and where the first one sits. +/// Lengths in points; the pitch is from one label's edge to the next one's. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct LabelProduct { + pub id: &'static str, + /// What the labels are for: `address`, `shipping` or `return`. + pub kind: &'static str, + /// Letter or A4. + pub paper: &'static str, + pub cols: usize, + pub rows: usize, + pub width: f32, + pub height: f32, + pub top: f32, + pub side: f32, + pub h_pitch: f32, + pub v_pitch: f32, +} + +const MM: f32 = 72.0 / 25.4; + +/// Common label sheets (the sizes are the industry's; the names are ours). +pub const LABEL_PRODUCTS: [LabelProduct; 9] = [ + LabelProduct { + id: "letter-3x10", + kind: "address", + paper: "Letter", + cols: 3, + rows: 10, + width: 189.0, + height: 72.0, + top: 36.0, + side: 13.5, + h_pitch: 198.0, + v_pitch: 72.0, + }, + LabelProduct { + id: "letter-2x10", + kind: "address", + paper: "Letter", + cols: 2, + rows: 10, + width: 288.0, + height: 72.0, + top: 36.0, + side: 11.25, + h_pitch: 301.5, + v_pitch: 72.0, + }, + LabelProduct { + id: "letter-2x7", + kind: "address", + paper: "Letter", + cols: 2, + rows: 7, + width: 288.0, + height: 96.0, + top: 60.0, + side: 11.25, + h_pitch: 301.5, + v_pitch: 96.0, + }, + LabelProduct { + id: "letter-2x5", + kind: "shipping", + paper: "Letter", + cols: 2, + rows: 5, + width: 288.0, + height: 144.0, + top: 36.0, + side: 11.25, + h_pitch: 301.5, + v_pitch: 144.0, + }, + LabelProduct { + id: "letter-4x20", + kind: "return", + paper: "Letter", + cols: 4, + rows: 20, + width: 126.0, + height: 36.0, + top: 36.0, + side: 21.6, + h_pitch: 147.6, + v_pitch: 36.0, + }, + LabelProduct { + id: "a4-3x7", + kind: "address", + paper: "A4", + cols: 3, + rows: 7, + width: 63.5 * MM, + height: 38.1 * MM, + top: 15.15 * MM, + side: 7.21 * MM, + h_pitch: 66.04 * MM, + v_pitch: 38.1 * MM, + }, + LabelProduct { + id: "a4-2x7", + kind: "address", + paper: "A4", + cols: 2, + rows: 7, + width: 99.1 * MM, + height: 38.1 * MM, + top: 15.15 * MM, + side: 4.65 * MM, + h_pitch: 101.6 * MM, + v_pitch: 38.1 * MM, + }, + LabelProduct { + id: "a4-3x8", + kind: "address", + paper: "A4", + cols: 3, + rows: 8, + width: 63.5 * MM, + height: 33.9 * MM, + top: 12.9 * MM, + side: 7.21 * MM, + h_pitch: 66.04 * MM, + v_pitch: 33.9 * MM, + }, + LabelProduct { + id: "a4-2x4", + kind: "shipping", + paper: "A4", + cols: 2, + rows: 4, + width: 99.1 * MM, + height: 67.7 * MM, + top: 13.1 * MM, + side: 4.65 * MM, + h_pitch: 101.6 * MM, + v_pitch: 67.7 * MM, + }, +]; + +impl LabelProduct { + pub fn by_id(id: &str) -> Option<&'static LabelProduct> { + LABEL_PRODUCTS.iter().find(|p| p.id == id) + } + /// Label size as people say it: inches on Letter, millimetres on A4. + pub fn size_text(&self) -> String { + if self.paper == "A4" { + format!("{:.1} × {:.1} mm", self.width / MM, self.height / MM) + } else { + let i = |pt: f32| format!("{}", ((pt / 72.0) * 1000.0).round() / 1000.0); + format!("{}\" × {}\"", i(self.width), i(self.height)) + } + } +} + +/// Labels: a new document with a table of labels laid out like the sheet (`product`; spacer +/// columns and rows stand for the gaps). A full page of the same `text` (or one recipient per +/// label), or (`single`: `{"row", "col"}`, from 1) one label at that place on the sheet. fn labels(s: &mut Session, v: &Value) -> CmdResult { - // A 3 × 10 sheet of 2.625" × 1" labels on Letter (a common layout). - let rows = p::u64(v, "rows").unwrap_or(10).clamp(1, 40) as usize; - let cols = p::u64(v, "cols").unwrap_or(3).clamp(1, 10) as usize; - let from_rec = p::bool(v, "fromRecipients").unwrap_or(!s.merge.rows.is_empty() && p::str(v, "text").is_none()); + let prod = match p::str(v, "product") { + Some(id) => *LabelProduct::by_id(id).ok_or_else(|| CmdError::Params(format!("unknown label product `{id}`")))?, + None => LABEL_PRODUCTS[0], + }; + let rows = p::u64(v, "rows").map_or(prod.rows, |n| n.clamp(1, 40) as usize); + let cols = p::u64(v, "cols").map_or(prod.cols, |n| n.clamp(1, 10) as usize); + let single = v.get("single").filter(|x| !x.is_null()).map(|x| { + let at = |k: &str| x.get(k).and_then(Value::as_u64).unwrap_or(1).max(1) as usize; + (at("row").min(rows), at("col").min(cols)) + }); + let from_rec = single.is_none() && p::bool(v, "fromRecipients").unwrap_or(!s.merge.rows.is_empty() && p::str(v, "text").is_none()); let text = p::str(v, "text").unwrap_or("Name\nAddress\nCity, ST 00000").to_string(); + let (page_w, page_h) = if prod.paper == "A4" { (595.3, 841.9) } else { (612.0, 792.0) }; + let h_gap = (prod.h_pitch - prod.width).max(0.0); + let v_gap = (prod.v_pitch - prod.height).max(0.0); let mut d = Document::new(); - d.last_section.margin_top = 36.0; + d.last_section.page_w = page_w; + d.last_section.page_h = page_h; + d.last_section.margin_top = prod.top; d.last_section.margin_bottom = 0.0; - d.last_section.margin_left = 13.5; - d.last_section.margin_right = 13.5; - let mut t = Table::new(rows, cols, 189.0 * cols as f32); + d.last_section.margin_left = prod.side; + d.last_section.margin_right = (page_w - prod.side - prod.h_pitch * (cols - 1) as f32 - prod.width).max(0.0); + d.last_section.header = 0.0; + d.last_section.footer = 0.0; + // Grid columns: label, gap, label, …; rows likewise. + let grid_cols: Vec> = (0..cols).flat_map(|c| [Some(c)].into_iter().chain((c + 1 < cols && h_gap > 0.5).then_some(None))).collect(); + let grid_rows: Vec> = (0..rows).flat_map(|r| [Some(r)].into_iter().chain((r + 1 < rows && v_gap > 0.5).then_some(None))).collect(); + let width: f32 = grid_cols.iter().map(|c| if c.is_some() { prod.width } else { h_gap }).sum(); + let mut t = Table::new(grid_rows.len(), grid_cols.len(), width); t.props.style = None; t.props.borders = Some(wordcraft_doc::props::Borders::default()); t.props.fixed = true; + t.grid = grid_cols.iter().map(|c| if c.is_some() { prod.width } else { h_gap }).collect(); let mut k = 0usize; - for r in &mut t.rows { - r.props.height = Some(72.0); - r.props.height_rule = wordcraft_doc::props::HeightRule::Exact; - for c in &mut r.cells { - let content = if from_rec { - let rec = address_block(s, k); - k += 1; - if k > s.merge.rows.len() { String::new() } else { rec } - } else { - text.clone() + let mut made = 0usize; + for (row, r) in t.rows.iter_mut().zip(&grid_rows) { + row.props.height = Some(if r.is_some() { prod.height } else { v_gap }); + row.props.height_rule = wordcraft_doc::props::HeightRule::Exact; + for (cell, c) in row.cells.iter_mut().zip(&grid_cols) { + cell.props.width = Some(if c.is_some() { prod.width } else { h_gap }); + let (Some(r), Some(c)) = (r, c) else { continue }; + let content = match single { + Some((sr, sc)) if (sr, sc) != (r + 1, c + 1) => String::new(), + _ if from_rec => { + let rec = if k < s.merge.rows.len() { address_block(s, k) } else { String::new() }; + k += 1; + rec + } + _ => text.clone(), }; - c.props.valign = wordcraft_doc::props::VAlign::Center; - c.blocks = content + if !content.is_empty() { + made += 1; + } + cell.props.valign = wordcraft_doc::props::VAlign::Center; + cell.blocks = content .split('\n') + .take(20) .map(|l| { let mut p = Paragraph::with_text(l, CharProps::default()).styled("NoSpacing"); p.props.align = Some(Align::Left); @@ -821,7 +1054,7 @@ fn labels(s: &mut Session, v: &Value) -> CmdResult { s.set_document(d); s.path = None; let _ = Pos::body(0, 0); - Ok(json!({"labels": rows * cols})) + Ok(json!({"labels": if single.is_some() { 1 } else { rows * cols }, "filled": made, "product": prod.id})) } #[cfg(test)] @@ -1023,4 +1256,33 @@ mod tests { s.run("mailings.labels", &json!({"text": "Hello"})).unwrap(); assert!(s.doc.plain_text(StoryRef::Body).matches("Hello").count() == 30); } + + /// #407: Envelopes adds an envelope section of the chosen size before the letter; Labels + /// lays out a sheet of the chosen product, or a single label at a row and column. + #[test] + fn envelope_sizes_add_to_document_and_label_products() { + let mut s = Session::new(Document::from_text("Dear Jo,")); + let r = s.run("mailings.envelopes", &json!({"delivery": "Jo Doe\n1 Main St", "return": "Me", "size": "C5", "add": true})).unwrap(); + assert_eq!((r["width"].as_f64().map(|w| w.round()), r["height"].as_f64().map(|h| h.round())), (Some(649.0), Some(459.0))); + let secs = s.doc.sections(); + assert_eq!(secs.len(), 2, "an envelope section of its own"); + assert!(secs[0].1.landscape && (secs[0].1.page_w - 649.1).abs() < 0.1); + let text = s.doc.plain_text(StoryRef::Body); + assert!(text.starts_with("Me") && text.contains("1 Main St") && text.trim_end().ends_with("Dear Jo,"), "{text}"); + assert!(s.run("mailings.envelopes", &json!({"size": "Envelope X"})).is_err()); + + let r = s.run("mailings.labels", &json!({"text": "Ada", "product": "a4-2x7"})).unwrap(); + assert_eq!(r["labels"], 14); + assert!((s.doc.last_section.page_w - 595.3).abs() < 0.1, "A4 sheet"); + let Some(Block::Table(t)) = s.doc.body.first().map(|b| &**b) else { panic!("no table") }; + assert_eq!((t.rows.len(), t.grid.len()), (7, 3), "two label columns and the gap between"); + assert_eq!(s.doc.plain_text(StoryRef::Body).matches("Ada").count(), 14); + let r = s.run("mailings.labels", &json!({"text": "Bo", "product": "letter-3x10", "single": {"row": 2, "col": 3}})).unwrap(); + assert_eq!(r["filled"], 1); + let Some(Block::Table(t)) = s.doc.body.first().map(|b| &**b) else { panic!("no table") }; + // Row 2, column 3 (grid column 5: label, gap, label, gap, label). + let cell = t.rows.get(1).and_then(|r| r.cells.get(4)).and_then(|c| c.blocks.first()); + assert!(matches!(cell.map(|b| &**b), Some(Block::Para(p)) if p.text == "Bo")); + assert!(s.run("mailings.labels", &json!({"product": "nope"})).is_err()); + } } diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs index f7c3388aed..a3311895ec 100644 --- a/crates/engine/src/cmd/page.rs +++ b/crates/engine/src/cmd/page.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use wordcraft_doc::section::{ Columns, DocGrid, DocGridType, LineNumbering, MAX_CHAR_SPACE, MAX_GRID_PITCH, MIN_GRID_PITCH, SectionProps, SectionStart, }; -use wordcraft_doc::{Block, Pos}; +use wordcraft_doc::{Block, Pos, StoryRef}; use super::sel_result; use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; @@ -30,23 +30,14 @@ pub fn specs() -> Vec { ), CommandSpec::new("layout.break", "Breaks", "Layout › Page Setup", breaks) .params(r#"{"kind": "page|column|textWrapping|nextPage|continuous|evenPage|oddPage"}"#), - CommandSpec::new("layout.lineNumbers", "Line Numbers", "Layout › Page Setup", |s, v| { - let mode = p::str(v, "value").unwrap_or("continuous"); - with_sect(s, |x| { - x.line_numbers = match mode { - "none" => None, - "restartPage" => Some(LineNumbering { restart: wordcraft_doc::section::LineNumberRestart::Page, ..Default::default() }), - "restartSection" => Some(LineNumbering { restart: wordcraft_doc::section::LineNumberRestart::Section, ..Default::default() }), - _ => Some(LineNumbering { restart: wordcraft_doc::section::LineNumberRestart::Continuous, ..Default::default() }), - } - }) - }) - .params(r#"{"value": "none|continuous|restartPage|restartSection"}"#), - CommandSpec::new("layout.hyphenation", "Hyphenation", "Layout › Page Setup", |s, v| { - let on = p::bool(v, "value").unwrap_or(!s.doc.settings.auto_hyphenation); - s.doc.settings.auto_hyphenation = on; - Ok(json!({"value": on})) - }), + CommandSpec::new("layout.lineNumbers", "Line Numbers", "Layout › Page Setup", line_numbers).params( + r#"{"value"?: "none|continuous|restartPage|restartSection", "start"?: 1-32767, "countBy"?: 1-100, "distance"?: pt (0 = auto)}"#, + ), + CommandSpec::new("layout.hyphenation", "Hyphenation", "Layout › Page Setup", hyphenation) + .params(r#"{"value"?: bool (automatic; toggles when nothing is given), "zone"?: pt, "caps"?: bool (hyphenate words in capitals), "limit"?: n (consecutive hyphens, 0 = no limit)}"#), + CommandSpec::new("layout.manualHyphenation", "Manual Hyphenation", "Layout › Page Setup › Hyphenation", manual_hyphenation) + .params(r#"{"from"?: Pos (default: the caret)} → the next word at a line end that could be hyphenated: {"word", "pos", "points": [char index], "suggest": char index} or {"done": true}"#) + .pure(), CommandSpec::new("layout.pageSetup", "Page Setup", "Layout › Page Setup", |s, v| { if let Some(props) = v.get("section") { let new: SectionProps = serde_json::from_value(props.clone()).map_err(|e| CmdError::Params(e.to_string()))?; @@ -98,6 +89,130 @@ pub fn specs() -> Vec { ] } +/// Line Numbers: on with a restart mode (`value`), or off (`none`). `start`, `countBy` and +/// `distance` change the numbering's options and turn it on (keeping its restart mode). +fn line_numbers(s: &mut Session, v: &Value) -> CmdResult { + use wordcraft_doc::section::LineNumberRestart as R; + let mode = p::str(v, "value"); + if mode == Some("none") { + return with_sect(s, |x| x.line_numbers = None); + } + let restart = match mode { + Some("restartPage") => Some(R::Page), + Some("restartSection") => Some(R::Section), + Some("continuous") => Some(R::Continuous), + Some(x) => return Err(CmdError::Params(format!("unknown value `{x}`"))), + None => None, + }; + let start = p::u64(v, "start").map(|n| n.clamp(1, 32_767) as u32); + let count_by = p::u64(v, "countBy").map(|n| n.clamp(1, 100) as u32); + let distance = p::f32(v, "distance").map(|d| d.clamp(0.0, 1584.0)); + let options = start.is_some() || count_by.is_some() || distance.is_some(); + with_sect(s, |x| { + // Options alone keep the current numbering's other settings; a new mode alone starts over. + let mut l = match (&x.line_numbers, options) { + (Some(cur), true) => cur.clone(), + (None, true) => LineNumbering { restart: R::Continuous, ..Default::default() }, + _ => LineNumbering::default(), + }; + if let Some(r) = restart.or(if options { None } else { Some(R::Continuous) }) { + l.restart = r; + } + if let Some(n) = start { + l.start = n; + } + if let Some(n) = count_by { + l.count_by = n; + } + if let Some(d) = distance { + l.distance = d; + } + x.line_numbers = Some(l); + }) +} + +/// Hyphenation: automatic on or off, and its options (Hyphenation Options). +fn hyphenation(s: &mut Session, v: &Value) -> CmdResult { + let st = &mut s.doc.settings; + let options = ["zone", "caps", "limit"].iter().any(|k| v.get(*k).is_some_and(|x| !x.is_null())); + match p::bool(v, "value") { + Some(on) => st.auto_hyphenation = on, + None if !options => st.auto_hyphenation = !st.auto_hyphenation, + None => {} + } + if let Some(z) = p::f32(v, "zone") { + st.hyphenation_zone = z.clamp(0.0, 1584.0); + } + if let Some(c) = p::bool(v, "caps") { + st.hyphenate_caps = c; + } + if let Some(n) = p::u64(v, "limit") { + st.consecutive_hyphen_limit = n.min(32_767) as u32; + } + Ok(json!({"value": st.auto_hyphenation, "zone": st.hyphenation_zone, "caps": st.hyphenate_caps, "limit": st.consecutive_hyphen_limit})) +} + +/// Manual hyphenation: the next word after `from` that starts a line after a line wrapped at a +/// space, and could end the line before it if hyphenated. `points` are where a hyphen may go +/// (char indices into `word`); `suggest` is the last one whose first part fits on that line. +/// Accepting is `caret.set` to `pos` plus the chosen point, then `text.optionalHyphen`. +fn manual_hyphenation(s: &mut Session, v: &Value) -> CmdResult { + let from = match v.get("from") { + Some(f) if !f.is_null() => super::parse_pos(f).ok_or_else(|| CmdError::Params("bad `from`".into()))?, + _ => s.sel.ordered().0, + }; + let lim = wordcraft_proof::hyphen::Limits::default(); + let caps = s.doc.settings.hyphenate_caps; + let layout = s.layout(); + let mut best: Option<(Pos, Value)> = None; + for page in &layout.pages { + for it in &page.items { + let wordcraft_layout::Placed::Lines { story, path, para, l0, l1, .. } = it else { continue }; + if *story != StoryRef::Body { + continue; + } + let Some(text) = s.doc.para_at(&Pos { story: *story, path: path.clone(), off: 0 }).map(|p| p.text.clone()) else { continue }; + for li in (*l0).max(1)..*l1 { + let (Some(prev), Some(line)) = (para.lines.get(li - 1), para.lines.get(li)) else { continue }; + if prev.end != wordcraft_layout::LineEnd::Wrap || prev.hyphen.is_some() { + continue; + } + let at = Pos { story: *story, path: path.clone(), off: line.start }; + if at < from || best.as_ref().is_some_and(|(b, _)| *b <= at) { + continue; + } + let Some(rest) = text.get(line.start..) else { continue }; + let len = rest.char_indices().find(|(_, c)| !(c.is_alphabetic() || *c == '\'' || *c == '\u{2019}')).map_or(rest.len(), |(i, _)| i); + let Some(word) = rest.get(..len) else { continue }; + // A word with an optional hyphen already breaks where its author said. + if rest.get(len..).is_some_and(|r| r.starts_with('\u{ad}')) + || word.chars().count() < lim.min_word + || (!caps && word.chars().all(char::is_uppercase)) + { + continue; + } + let points = wordcraft_proof::hyphen::hyphen_points(word, &lim); + // The room left at the end of the line above, and the width of each first part. + let room = prev.right - prev.end_x(); + let hyphen_w = para + .x_of(li, line.start) + .zip(para.x_of(li, line.start + word.chars().next().map_or(0, char::len_utf8))) + .map_or(6.0, |(a, b)| (b - a) * 0.6); + let fits = |pt: usize| { + let bytes = word.char_indices().nth(pt).map_or(word.len(), |(i, _)| i); + match (para.x_of(li, line.start), para.x_of(li, line.start + bytes)) { + (Some(a), Some(b)) => (b - a) + hyphen_w <= room, + _ => false, + } + }; + let Some(suggest) = points.iter().copied().filter(|pt| fits(*pt)).max() else { continue }; + best = Some((at.clone(), json!({"word": word, "pos": super::pos_json(&at), "points": points, "suggest": suggest}))); + } + } + } + Ok(best.map(|(_, v)| v).unwrap_or_else(|| json!({"done": true}))) +} + fn block_of(s: &Session) -> usize { s.sel.focus.path.0.first().copied().unwrap_or(0) as usize } @@ -385,4 +500,61 @@ mod tests { assert_eq!(s.doc.last_section.start, SectionStart::Continuous); assert!(s.run("layout.columns", &json!({"count": 2, "apply": "elsewhere"})).is_err()); } + + /// #407 Line Numbers options: start, count by, distance and restart; options alone keep the + /// restart mode, `none` turns numbering off. + #[test] + fn line_numbering_options() { + use wordcraft_doc::section::LineNumberRestart; + let mut s = Session::new(wordcraft_doc::Document::from_text("One\nTwo")); + s.run("layout.lineNumbers", &json!({"value": "restartSection", "start": 5, "countBy": 2, "distance": 18})).unwrap(); + let l = s.doc.last_section.line_numbers.clone().unwrap(); + assert_eq!((l.start, l.count_by, l.distance, l.restart), (5, 2, 18.0, LineNumberRestart::Section)); + s.run("layout.lineNumbers", &json!({"countBy": 1e9, "start": -3})).unwrap(); + let l = s.doc.last_section.line_numbers.clone().unwrap(); + assert_eq!((l.start, l.count_by, l.restart), (5, 100, LineNumberRestart::Section), "clamped, mode kept"); + s.run("layout.lineNumbers", &json!({"value": "none"})).unwrap(); + assert!(s.doc.last_section.line_numbers.is_none()); + assert!(s.run("layout.lineNumbers", &json!({"value": "sometimes"})).is_err()); + } + + /// #407 Hyphenation Options: options don't toggle automatic hyphenation; Manual finds a + /// word at a line start that would fit hyphenated on the line above, and an optional hyphen + /// there breaks the word. + #[test] + fn hyphenation_options_and_manual_hyphenation() { + let mut s = Session::new(wordcraft_doc::Document::from_text( + &"a extraordinarily be uncharacteristic of responsibilities the considerations ".repeat(12), + )); + let r = s.run("layout.hyphenation", &json!({"zone": 36, "caps": false, "limit": 3})).unwrap(); + assert_eq!(r, json!({"value": false, "zone": 36.0, "caps": false, "limit": 3})); + s.run("layout.hyphenation", &json!({})).unwrap(); + assert!(s.doc.settings.auto_hyphenation, "nothing given toggles"); + s.run("layout.hyphenation", &json!({"value": false})).unwrap(); + // Accept every candidate (which words they are depends on the fonts). + let mut found = 0; + let mut from = json!(null); + while found < 5 { + let c = s.run("layout.manualHyphenation", &json!({"from": from})).unwrap(); + if c["done"] == true { + break; + } + let word = c["word"].as_str().unwrap().to_string(); + let pts = c["points"].as_array().unwrap(); + assert!(!pts.is_empty() && pts.contains(&c["suggest"]), "{c}"); + let at = c["suggest"].as_u64().unwrap() as usize; + let mut pos = c["pos"].clone(); + let off = pos["off"].as_u64().unwrap() as usize + word.char_indices().nth(at).unwrap().0; + pos["off"] = json!(off); + s.run("caret.set", &json!({"pos": pos})).unwrap(); + s.run("text.optionalHyphen", &json!({})).unwrap(); + pos["off"] = json!(off + word.len()); + from = pos; + found += 1; + } + // Some line of thirty or so starts with a long word that fits hyphenated above, whatever the font. + assert!(found > 0, "no candidates"); + assert_eq!(s.doc.plain_text(wordcraft_doc::StoryRef::Body).matches('\u{ad}').count(), found); + assert!(s.run("layout.manualHyphenation", &json!({"from": {"block": 999, "off": 0}})).unwrap()["done"] == true); + } } diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index 111e44dba5..8d1646504f 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -350,17 +350,9 @@ pub fn specs() -> Vec { }) }) .params(r#"{"format": "decimal|upperRoman|lowerLetter…", "text"?: "%1.", "start"?: n}"#), - CommandSpec::new("review.language", "Language", "Review › Language", |s, v| { - let lang = p::str(v, "lang").unwrap_or("en-US").to_string(); - let no_proof = p::bool(v, "noProof"); - super::format::apply(s, &|c| { - c.lang = Some(lang.clone()); - if let Some(np) = no_proof { - c.no_proof = Some(np); - } - }) - }) - .params(r#"{"lang": "en-US|en-GB|fr-FR|…", "noProof"?: bool}"#), + CommandSpec::new("review.language", "Language", "Review › Language", language).params( + r#"{"lang"?: "en-US|en-GB|fr-FR|…", "noProof"?: bool, "detect"?: bool (detect language automatically, a preference), "default"?: bool (make `lang` the document's default language instead)}"#, + ), CommandSpec::new("review.showMarkup", "Show Markup", "Review › Tracking", |s, v| { s.view.show_markup = p::bool(v, "value").unwrap_or(!s.view.show_markup); s.relayout(); @@ -410,6 +402,48 @@ pub fn specs() -> Vec { ] } +/// Set Proofing Language: the selection's language and "do not check spelling or grammar", +/// or (`default`) the document's default language; `detect` is the per-user preference. +/// Nothing at all sets US English, as before. +fn language(s: &mut Session, v: &Value) -> CmdResult { + let lang = match p::str(v, "lang") { + Some(l) if !l.is_empty() && l.len() <= 35 && l.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') => Some(l.to_string()), + Some(l) => return Err(CmdError::Params(format!("`{l}` is not a language tag"))), + None => None, + }; + let no_proof = p::bool(v, "noProof"); + let detect = p::bool(v, "detect"); + if let Some(d) = detect { + s.prefs.detect_language = d; + } + if p::bool(v, "default") == Some(true) { + let l = lang.ok_or_else(|| CmdError::Params("`lang` is required with `default`".into()))?; + s.doc.styles.default_chr.lang = Some(l.clone()); + // A Normal style that names its own language follows the new default. + if let Some(n) = s.doc.styles.styles.iter_mut().find(|x| x.id == "Normal") + && n.chr.lang.is_some() + { + n.chr.lang = Some(l.clone()); + } + return Ok(json!({"default": l, "detect": s.prefs.detect_language})); + } + let lang = match (lang, no_proof, detect) { + (None, None, None) => Some("en-US".to_string()), + (l, ..) => l, + }; + if lang.is_none() && no_proof.is_none() { + return Ok(json!({"detect": s.prefs.detect_language})); + } + super::format::apply(s, &|c| { + if let Some(l) = &lang { + c.lang = Some(l.clone()); + } + if let Some(np) = no_proof { + c.no_proof = Some(np); + } + }) +} + fn macros(s: &mut Session, v: &Value) -> CmdResult { if let Some(d) = v.get("define") { let name = d.get("name").and_then(Value::as_str).unwrap_or("Macro").to_string(); @@ -1048,4 +1082,19 @@ mod tests { s.run("insert.quickParts", &json!({"insert": "m"})).unwrap(); assert!(s.doc.plain_text(StoryRef::Body).ends_with("Membership")); } + + /// #407 Language: the selection's language and "do not check", the default language and + /// the detect preference; a bad tag is refused. + #[test] + fn proofing_language_options() { + let mut s = Session::new(Document::from_text("Bonjour")); + s.run("select.all", &json!({})).unwrap(); + s.run("review.language", &json!({"lang": "fr-FR", "noProof": true, "detect": false})).unwrap(); + let rc = s.doc.para_at(&Pos::body(0, 1)).map(|p| p.props_at(1).clone()).unwrap(); + assert_eq!((rc.lang.as_deref(), rc.no_proof), (Some("fr-FR"), Some(true))); + assert!(!s.prefs.detect_language); + s.run("review.language", &json!({"lang": "de-DE", "default": true})).unwrap(); + assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("de-DE")); + assert!(s.run("review.language", &json!({"lang": "", + "file:///etc/passwd", + "ms-msdt:/id", + "", + ] { + assert!(!is_safe_href(bad), "{bad:?}"); + } + } +} diff --git a/crates/formats/src/html.rs b/crates/formats/src/html.rs index 254b6c7ef5..afbee5ecdf 100644 --- a/crates/formats/src/html.rs +++ b/crates/formats/src/html.rs @@ -1004,9 +1004,7 @@ impl<'r> Builder<'r> { } } "a" => { - if let Some(h) = - attr(attrs, "href").filter(|h| !h.trim().is_empty() && !h.trim_start().to_ascii_lowercase().starts_with("javascript:")) - { + if let Some(h) = attr(attrs, "href").filter(|h| wordcraft_doc::link::is_safe_href(h)) { fmt.link = Some(h.trim().to_string()); } } @@ -1205,7 +1203,8 @@ pub fn inlines_html(inlines: &[Inline]) -> String { Inline::Text(t, f) => { let mut open = String::new(); let mut close: Vec<&str> = Vec::new(); - if let Some(l) = &f.link { + // A script or other non-web target is written as plain text (`is_safe_href`). + if let Some(l) = f.link.as_deref().filter(|l| wordcraft_doc::link::is_safe_href(l)) { open.push_str(&format!("", esc(l))); close.push(""); } diff --git a/crates/formats/src/markdown.rs b/crates/formats/src/markdown.rs index ab3b3730cd..0df3f5a571 100644 --- a/crates/formats/src/markdown.rs +++ b/crates/formats/src/markdown.rs @@ -1125,8 +1125,9 @@ fn inlines_md(inlines: &[Inline]) -> String { } }; for (text, f, raw) in pieces { - // Links are the outermost construct. - if f.link != link || raw.is_some() { + // Links are the outermost construct; a script or other non-web target is plain text. + let f_link = f.link.clone().filter(|l| wordcraft_doc::link::is_safe_href(l)); + if f_link != link || raw.is_some() { close_all(&mut out, &mut stack); if let Some(l) = &link { out.push_str(&format!("]({})", link_target(l))); @@ -1136,9 +1137,9 @@ fn inlines_md(inlines: &[Inline]) -> String { out.push_str(&r); continue; } - if let Some(l) = &f.link { + if let Some(l) = f_link { out.push('['); - link = Some(l.clone()); + link = Some(l); } } let want: Vec = diff --git a/crates/formats/src/tests.rs b/crates/formats/src/tests.rs index b939e7ae1f..018ccc533e 100644 --- a/crates/formats/src/tests.rs +++ b/crates/formats/src/tests.rs @@ -610,3 +610,47 @@ fn bounded_scans_keep_ordinary_markdown_and_entities() { let t = import("html", long.as_bytes()).unwrap().unwrap().plain_text(Default::default()); assert!(t.contains(&"a".repeat(40)), "{t}"); } + +/// A document's hyperlink can be `javascript:` (WordCraft refuses to follow it); the exported +/// HTML and Markdown must not hand it to a browser either. Web, mail, bookmark and relative links +/// still export as links. +#[test] +fn exported_links_keep_only_safe_targets() { + let links = [ + ("script", "javascript:alert(1)"), + ("split", "java\nscript:alert(1)"), + ("vb", "vbscript:MsgBox(1)"), + ("data", "data:text/html,x"), + ("web", "https://ok.example/x"), + ("mail", "mailto:a@b.example"), + ("bookmark", "#top"), + ("relative", "other.html"), + ]; + let mut d = Document::new(); + let mut p = Paragraph::with_text("", CharProps::default()); + for (text, l) in links { + let n = p.len(); + p.insert_text(n, text, &CharProps { link: Some(l.to_string()), ..Default::default() }).unwrap(); + let n = p.len(); + p.insert_text(n, " ", &CharProps::default()).unwrap(); + } + d.body = vec![para_block(p)]; + let html = String::from_utf8(export("html", &d).unwrap().unwrap()).unwrap(); + let md = String::from_utf8(export("md", &d).unwrap().unwrap()).unwrap(); + for bad in ["javascript", "vbscript", "data:"] { + assert!(!html.to_ascii_lowercase().contains(bad), "html: {html}"); + assert!(!md.to_ascii_lowercase().replace('\n', "").contains(bad), "md: {md}"); + } + for (text, _) in links { + assert!(html.contains(text), "html keeps the text {text:?}: {html}"); + assert!(md.contains(text), "md keeps the text {text:?}: {md}"); + } + for (text, l) in &links[4..] { + assert!(html.contains(&format!("{text}")), "html: {html}"); + assert!(md.contains(&format!("[{text}]({l})")), "md: {md}"); + } + for text in ["script", "split", "vb", "data"] { + assert!(!html.contains(&format!(">{text}")), "html: {html}"); + assert!(!md.contains(&format!("[{text}](")), "md: {md}"); + } +} diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 52756749c9..6a3310703a 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -67,3 +67,43 @@ fn resources() { let r = call(&mut s, 2, "resources/read", json!({"uri": "wordcraft://document"})); assert!(r["result"]["contents"][0]["text"].as_str().unwrap().contains("blocks")); } + +/// A backend standing in for a running app: it answers `ui.render` and `ui.screenshot` with a +/// PNG in the reply and records what it was asked. +struct FakeApp { + calls: std::sync::Arc>>, +} + +impl crate::backend::Backend for FakeApp { + fn call(&mut self, method: &str, params: Value) -> Result { + self.calls.lock().unwrap().push((method.to_string(), params.clone())); + match method { + "ui.render" | "ui.screenshot" => Ok(json!({"png": wordcraft_engine::cmd::insert::base64_encode(b"\x89PNG"), "width": 1, "height": 1})), + other => Err(format!("unexpected {other}")), + } + } + fn has_ui(&self) -> bool { + true + } + fn describe(&self) -> String { + "fake app".into() + } +} + +/// With an app attached, pictures travel inside the reply: the bridge never writes a file with a +/// predictable name into the shared temporary folder (another user could plant a link there). +#[test] +fn render_and_screenshot_through_the_app_use_no_temp_file() { + let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let mut s = Server::new(Box::new(FakeApp { calls: calls.clone() })); + for (tool, method) in [("render_page", "ui.render"), ("screenshot", "ui.screenshot")] { + let r = call(&mut s, 10, "tools/call", json!({"name": tool, "arguments": {"page": 1}})); + let content = &r["result"]["content"]; + assert_eq!(r["result"]["isError"], false, "{tool}: {r}"); + assert_eq!(content[0]["type"], "image", "{tool}"); + assert_eq!(content[0]["data"], wordcraft_engine::cmd::insert::base64_encode(b"\x89PNG"), "{tool}"); + let (m, params) = calls.lock().unwrap().pop().expect("one call"); + assert_eq!(m, method); + assert!(params.get("path").is_none(), "{tool} asked for a file: {params}"); + } +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 8ec4dfd850..b01106af6c 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -4,6 +4,14 @@ use serde_json::{Value, json}; use crate::backend::Backend; +/// An image tool result from a reply carrying base64 PNG in `png`. +fn png_result(v: &Value, info: &Value) -> ToolResult { + match v.get("png").and_then(Value::as_str) { + Some(png) if !png.is_empty() => ToolResult::image(png.to_string(), info), + _ => ToolResult::error("the reply carried no picture"), + } +} + #[derive(Clone, Debug, PartialEq)] pub struct ToolResult { pub content: Vec, @@ -244,37 +252,19 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult { "get_text" => wrap(exec(b, "document.text", json!({}))), "inspect_document" => wrap(b.call("document.inspect", json!({}))), "parity" => wrap(b.call("ui.parity", json!({}))), + // Pictures travel inside the reply (`png`), headless or through the app: no file with a + // predictable name in the shared temporary folder. "render_page" => { - if b.has_ui() { - let path = std::env::temp_dir().join("wordcraft-mcp-page.png"); - let path_s = path.to_string_lossy().to_string(); - match b.call("ui.render", json!({"path": path_s, "page": a.get("page").cloned().unwrap_or(json!(1)), "scale": a.get("scale").cloned().unwrap_or(json!(1.0))})) { - Ok(info) => match std::fs::read(&path) { - Ok(bytes) => ToolResult::image(wordcraft_engine::cmd::insert::base64_encode(&bytes), &info), - Err(e) => ToolResult::error(e.to_string()), - }, - Err(e) => ToolResult::error(e), - } - } else { - match b.call("ui.render", a.clone()) { - Ok(v) => { - let png = v.get("png").and_then(Value::as_str).unwrap_or("").to_string(); - ToolResult::image(png, &json!({"width": v["width"], "height": v["height"], "pages": v["pages"]})) - } - Err(e) => ToolResult::error(e), - } - } - } - "screenshot" => { - let path = std::env::temp_dir().join("wordcraft-mcp-shot.png"); - match b.call("ui.screenshot", json!({"path": path.to_string_lossy()})) { - Ok(info) => match std::fs::read(&path) { - Ok(bytes) => ToolResult::image(wordcraft_engine::cmd::insert::base64_encode(&bytes), &info), - Err(e) => ToolResult::error(e.to_string()), - }, + let params = json!({"page": a.get("page").cloned().unwrap_or(json!(1)), "scale": a.get("scale").cloned().unwrap_or(json!(1.0))}); + match b.call("ui.render", params) { + Ok(v) => png_result(&v, &json!({"width": v["width"], "height": v["height"], "pages": v["pages"]})), Err(e) => ToolResult::error(e), } } + "screenshot" => match b.call("ui.screenshot", json!({})) { + Ok(v) => png_result(&v, &json!({"width": v["width"], "height": v["height"]})), + Err(e) => ToolResult::error(e), + }, "click" => wrap(b.call("ui.click", a.clone())), "key" => wrap(b.call("ui.key", a.clone())), "ui_inspect" => wrap(b.call("ui.inspect", json!({}))), diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index fb524e3389..bad0326392 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1867,8 +1867,7 @@ fn link_under(app: &WordApp, layout: &DocLayout, page: usize, x: f32, y: f32, po /// Whether Ctrl/⌘+click may hand a document's hyperlink to the system: web and email links only, /// so a document can't launch `file:` paths, programs or custom-scheme handlers. fn is_followable_link(url: &str) -> bool { - let Some((scheme, _)) = url.split_once(':') else { return false }; - ["http", "https", "mailto"].iter().any(|s| scheme.eq_ignore_ascii_case(s)) + wordcraft_doc::link::is_web_or_mail_link(url) } #[cfg(test)] diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index a9c171b057..c011f68a3a 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -8,7 +8,8 @@ //! real pointer input in screen points (reaches every widget) //! - `ui.clickText {page, x, y}`: click at a page position (points from the page's top-left) //! - `ui.key {key, shift?, alt?, cmd?}`, `ui.text {text}`: keyboard input through egui -//! - `ui.screenshot {path?}`: PNG of the window; `ui.render {path, page?, scale?}`: page PNG +//! - `ui.screenshot {path?}`: PNG of the window; `ui.render {path?, page?, scale?}`: page PNG +//! (without `path`, both reply with the PNG as base64 `png`) //! - `ui.parity`: feature catalog parity use std::sync::mpsc::Sender; @@ -203,11 +204,19 @@ pub fn handle(app: &mut WordApp, ctx: &egui::Context, req: &ControlRequest) -> O } "ui.screenshot" => Outcome::Screenshot { path: s("path").map(str::to_string) }, "ui.render" => { - let path = s("path").unwrap_or("/tmp/wordcraft-page.png"); let page = p.get("page").and_then(Value::as_u64).unwrap_or(1); - match app.session.run("file.exportPng", &json!({"path": path, "page": page, "scale": f("scale").unwrap_or(2.0)})) { - Ok(v) => ok(v), - Err(e) => err(e), + let scale = f("scale").unwrap_or(2.0); + match s("path") { + Some(path) => match app.session.run("file.exportPng", &json!({"path": path, "page": page, "scale": scale})) { + Ok(v) => ok(v), + Err(e) => err(e), + }, + // No path: the PNG travels in the reply. Never a fixed name in the shared + // temporary folder, where another user could have planted a link to a file. + None => match render_png(app, page, scale) { + Ok(v) => ok(v), + Err(e) => err(e), + }, } } "ui.resize" => { @@ -234,12 +243,27 @@ pub fn handle(app: &mut WordApp, ctx: &egui::Context, req: &ControlRequest) -> O } } +/// A page rendered by the engine, as base64 PNG in the reply (the same shape as the headless +/// MCP server's `ui.render`). +fn render_png(app: &WordApp, page: u64, scale: f32) -> Result { + let page = usize::try_from(page.max(1)).unwrap_or(usize::MAX).saturating_sub(1); + let l = app.session.export_layout(); + let pg = l.pages.get(page).ok_or_else(|| format!("no page {}", page + 1))?; + let img = wordcraft_render::render_page(&app.session.doc, pg, scale.clamp(0.1, 8.0), &Default::default()); + let png = img.to_png(); + Ok(json!({"png": wordcraft_engine::cmd::insert::base64_encode(&png), "width": img.width, "height": img.height, "pages": l.pages.len()})) +} + +/// The window screenshot: written to `path` when one was given, else base64 PNG in the reply +/// (never a fixed name in the shared temporary folder). pub fn save_screenshot(image: &egui::ColorImage, path: Option<&str>) -> Value { - let path = path.unwrap_or("/tmp/wordcraft-screenshot.png"); let [w, h] = image.size; let bytes: Vec = image.pixels.iter().flat_map(|c| c.to_array()).collect(); let rendered = wordcraft_render::Rendered { width: w as u32, height: h as u32, pixels: bytes }; let png = rendered.to_png(); + let Some(path) = path else { + return json!({"ok": true, "result": {"png": wordcraft_engine::cmd::insert::base64_encode(&png), "width": w, "height": h}}); + }; #[cfg(target_arch = "wasm32")] let _ = (&png, path); #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6f37e6ee10..47b7c6f1c8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2947,4 +2947,29 @@ mod tests { a.run("format.bold", json!({})).unwrap(); assert!(!a.recipient_list_pending); } + + /// `ui.render` and `ui.screenshot` without a `path` return the PNG in the reply instead of + /// writing a fixed name into the shared temporary folder, where another user could have + /// planted a link to one of this user's files. + #[test] + fn render_and_screenshot_without_a_path_reply_with_the_png() { + let ctx = egui::Context::default(); + let mut a = typed(); + let (req, _reply) = ControlRequest::new("ui.render", json!({"page": 1, "scale": 0.5})); + let control::Outcome::Done(v) = control::handle(&mut a, &ctx, &req) else { panic!("ui.render replies at once") }; + assert_eq!(v["ok"], true, "{v}"); + assert!(v["result"].get("path").is_none(), "no file: {v}"); + let png = wordcraft_engine::cmd::insert::base64_decode(v["result"]["png"].as_str().expect("png")).expect("base64"); + assert_eq!(&png[..4], b"\x89PNG", "{v}"); + assert!(v["result"]["width"].as_u64().unwrap() > 0 && v["result"]["pages"].as_u64().unwrap() >= 1, "{v}"); + + let image = egui::ColorImage::from_rgba_unmultiplied([2, 1], &[255, 0, 0, 255, 0, 0, 255, 255]); + let v = control::save_screenshot(&image, None); + assert_eq!(v["ok"], true, "{v}"); + assert!(v["result"].get("path").is_none(), "no file: {v}"); + let png = wordcraft_engine::cmd::insert::base64_decode(v["result"]["png"].as_str().expect("png")).expect("base64"); + assert_eq!(&png[..4], b"\x89PNG"); + assert_eq!(v["result"]["width"], 2, "{v}"); + assert_eq!(v["result"]["height"], 1, "{v}"); + } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index ff88f147c0..61d1df80a8 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -60,8 +60,8 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key. | `ui.press` / `ui.release` | `x`,`y`, modifiers | primary button down / up there (with `ui.move` between: a drag you can screenshot halfway) | | `ui.key` | `key` (`B`, `Enter`, `Left`…), `shift?`, `alt?`, `cmd?` | key press through egui (shortcuts apply) | | `ui.text` | `text` | typed text through egui | -| `ui.screenshot` | `path?` | PNG of the window | -| `ui.render` | `path`, `page?` (1-based), `scale?` | PNG of a page, rendered by the engine | +| `ui.screenshot` | `path?` | PNG of the window: written to `path`, or without one returned as base64 `png` in the reply | +| `ui.render` | `path?`, `page?` (1-based), `scale?` | PNG of a page, rendered by the engine: written to `path`, or without one returned as base64 `png` in the reply (never a fixed name in the shared temporary folder) | | `ui.parity` | — | feature-catalog parity | | `ui.resize` / `ui.focus` / `app.quit` | | window control | From 0918a52e571d9cae209b6f5e6d12550307c8f426 Mon Sep 17 00:00:00 2001 From: lunar-me Date: Mon, 12 Oct 2026 17:20:52 +1300 Subject: [PATCH 60/89] docs: fix coordination error in ROADMAP.md (#715) Co-authored-by: lunar-me Co-authored-by: Brandon Thomas --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index aafab2dadc..ea331f8d1a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -110,7 +110,7 @@ Weights and evidence per area: [docs/target-app-parity.md](docs/target-app-parit | Korean | ko | none | 0% | | Vietnamese | vi | none | 0% | -Also shipped: Traditional Chinese (`zh-hant`), Ukrainian (`uk`) and Serbian (`sr`, `sr-latn`, #250) and Russian (`ru`), all partial (~95%). Estonian (`et`) and Turkish (`tr`) now have complete 1,307-entry interface catalogs with +Also shipped: Traditional Chinese (`zh-hant`), Ukrainian (`uk`), Serbian (`sr`, `sr-latn`, #250) and Russian (`ru`), all partial (~95%). Estonian (`et`) and Turkish (`tr`) now have complete 1,307-entry interface catalogs with system-locale selection and a saved language choice; Estonian and Turkish proofing are not included. Word ships 30 interface languages and proofing for ~50. Detail: [docs/localization-parity.md](docs/localization-parity.md). From 46e694f5f5b283ef5fa8f59910c7c759fedd0b83 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 00:27:33 -0400 Subject: [PATCH 61/89] Release workflow: pass the Azure signing secrets so Windows builds are signed (#752) Since the release workflow moved to storytold/craft-common-actions, it has passed only the Apple and WINDOWS_CERTIFICATE secrets. The signing job reads values from the release environment, but only for names the caller lists, so the AZURE_* names that Trusted Signing uses came through empty and Windows builds would ship unsigned. Same fix as storytold/craft-launcher#174. Co-authored-by: Claude Opus 5.5 --- .github/workflows/release.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ca4b173184..6a73f61245 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,3 +35,9 @@ jobs: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} + AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} + AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} From 19684cff26792cf1d919daadbc21cb98c0c50167 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 00:29:58 -0400 Subject: [PATCH 62/89] layout: make Myanmar fallback tests deterministic (#807) tests_complex::myanmar_syllables_are_single_clusters and myanmar_in_a_latin_font_falls_back_to_a_regular_myanmar_face failed about 1 run in 4 of the full layout suite (cluster starts [0, 3, 6, 12, 18], "one face for the word") but passed alone. Root cause: a race in FontDb's system font fallback, not the tests. Tests share FontDb::global(), and layout asks for a fallback per character from several threads. A search marks a preferred family ("Padauk" for Myanmar) as tried before loading it; a second search racing it skipped the family as already tried, fell through to the coverage index, and loaded the first cataloged file in path order that maps the character (Padauk-Bold.ttf on its own). Characters of one syllable then fell back to different faces, so the shaper split the syllable and the word's faces differed. Separately, a search's answer was cached under the face count read after the search, so an answer chosen before another thread's load could be cached as current. Fix (crates/fonts): - System fallback searches run one at a time (a `searching` lock). A search that waited re-checks the loaded faces first, so it uses the family the previous search loaded instead of loading another file. - The fallback cache keys an answer by the face count it was chosen from, and doesn't cache an answer from before a concurrent load. Co-authored-by: Claude Opus 5.5 --- crates/fonts/src/fontdb.rs | 57 +++++++++++++++++++++++++++++--------- 1 file changed, 44 insertions(+), 13 deletions(-) diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 2d0573a08d..dea01f87a4 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -306,6 +306,12 @@ pub struct FontDb { /// System fallback state: enabled, and characters no system font covers. #[cfg(not(target_arch = "wasm32"))] sys: Mutex, + /// Held through a system fallback search, so searches run one at a time. Without it a search + /// racing another that was still loading a preferred family ("Padauk" for Myanmar) skipped + /// that family as already tried and loaded whatever the coverage index found first (only + /// Padauk Bold), so which face a character fell back to depended on thread timing. + #[cfg(not(target_arch = "wasm32"))] + searching: Mutex<()>, /// The characters each installed font file maps, read the first time a fallback search /// needs that file. Held through a search, so two searches never read a file twice. #[cfg(not(target_arch = "wasm32"))] @@ -841,6 +847,8 @@ impl FontDb { #[cfg(not(target_arch = "wasm32"))] sys: Mutex::new(SysFallback { enabled: true, ..Default::default() }), #[cfg(not(target_arch = "wasm32"))] + searching: Mutex::new(()), + #[cfg(not(target_arch = "wasm32"))] coverage: Mutex::new(CoverageIndex::default()), #[cfg(not(target_arch = "wasm32"))] catalog_gen: AtomicU32::new(0), @@ -1276,9 +1284,13 @@ impl FontDb { return hit.clone(); } } - let found = self.search_fallback(c, exclude); - // Keyed by the face count after the search (a system fallback may have loaded fonts). - let n = self.read_faces().len(); + let (found, n) = self.search_fallback(c, exclude); + // Keyed by the face count the answer was chosen from (a system fallback may have loaded + // fonts, and another thread may have loaded more since: an answer from before that load + // is returned but not cached under the new count). + if n != self.read_faces().len() { + return found; + } let mut cache = self.fallbacks.write().unwrap_or_else(|e| e.into_inner()); if cache.faces != n || cache.map.len() >= FALLBACK_CACHE_MAX { cache.map.clear(); @@ -1288,17 +1300,36 @@ impl FontDb { found } - fn search_fallback(&self, c: char, exclude: u32) -> Option> { - self.loaded_fallback(c, exclude).or_else(|| { - #[cfg(not(target_arch = "wasm32"))] - if self.system_fallback(c) { - return self.loaded_fallback(c, exclude); - } - None - }) + /// The fallback face for `c`, and the number of loaded faces it was chosen from. + fn search_fallback(&self, c: char, exclude: u32) -> (Option>, usize) { + let (found, n) = self.loaded_fallback(c, exclude); + if found.is_some() { + return (found, n); + } + self.system_search(c, exclude) } - fn loaded_fallback(&self, c: char, exclude: u32) -> Option> { + /// [`Self::search_fallback`] when no loaded face covers `c`: load a system font that does, + /// one search at a time. + #[cfg(not(target_arch = "wasm32"))] + fn system_search(&self, c: char, exclude: u32) -> (Option>, usize) { + let _searching = self.searching.lock().unwrap_or_else(|e| e.into_inner()); + // A search that held the lock first may have loaded a font covering `c`: choose among the + // loaded faces again before loading more. + let (found, n) = self.loaded_fallback(c, exclude); + if found.is_none() && self.system_fallback(c) { + return self.loaded_fallback(c, exclude); + } + (found, n) + } + + #[cfg(target_arch = "wasm32")] + fn system_search(&self, _c: char, _exclude: u32) -> (Option>, usize) { + (None, self.read_faces().len()) + } + + /// The best loaded face covering `c`, and the number of loaded faces it was chosen from. + fn loaded_fallback(&self, c: char, exclude: u32) -> (Option>, usize) { let faces = self.read_faces(); let mut order: Vec<&Arc> = faces.iter().filter(|f| f.id != exclude).collect(); // Right-to-left letters prefer a font made for the script, so a word isn't split across @@ -1315,7 +1346,7 @@ impl FontDb { let complex = crate::needs_layout_tables(c); let shaping = |f: &FontFace| if complex { (!f.has_layout_tables(), crate::fallback_rank(c, &f.family)) } else { (false, 0) }; order.sort_by_key(|f| (rank(f), shaping(f), !f.family.eq_ignore_ascii_case(FALLBACK_FAMILY), f.italic, (f.weight - 400.0).abs() as i32)); - order.into_iter().find(|f| f.covers(c)).cloned() + (order.into_iter().find(|f| f.covers(c)).cloned(), faces.len()) } /// Load a system font covering `c` (preferred fallback families first, then any cataloged From 8bd34270121f1388738fec20e0890ca737646f6e Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Mon, 12 Oct 2026 06:34:30 +0200 Subject: [PATCH 63/89] View: Hide White Space between pages in Print Layout (#645) (#684) Co-authored-by: Claude Sonnet 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 1 + crates/engine/src/cmd/view.rs | 4 + crates/engine/src/session.rs | 6 ++ crates/engine/src/tests.rs | 11 +++ crates/ui-egui/src/canvas.rs | 131 +++++++++++++++++++++++++++- crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/et.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sr-latn.tsv | 1 + crates/ui-egui/src/i18n/sr.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/ribbon.rs | 4 + 17 files changed, 164 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index ea331f8d1a..3dc8c82ce8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -130,6 +130,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log | Date | What landed | +| 2026-10-11 | Hide White Space (#645): `view.hideWhiteSpace` (View › Show checkbox, or double-click the gap between pages) collapses margins, headers and footers between pages to a thin line in Print Layout; screen only, page count unchanged | | 2026-10-11 | Fonts: Palatino Linotype and Book Antiqua fall back to Palatino (macOS) or TeX Gyre Pagella / P052 / URW Palladio (Linux) before generic serif (#670) | |---|---| | 2026-10-11 | Security review fixes: `ui.render`/`ui.screenshot` without a `path` (and the MCP bridge's `render_page`/`screenshot`) return the PNG in the reply instead of writing a fixed name into the shared temporary folder; exported HTML/Markdown write `href` only for web, mail, bookmark and relative links, never `javascript:` and other schemes (one allow-list in `wordcraft_doc::link`) | diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 6647a5cd6b..1e2e0cfcef 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -27,6 +27,10 @@ pub fn specs() -> Vec { // The body as an outline of its headings, with the Outlining tab (`outline.*`). CommandSpec::new("view.outline", "Outline", "View › Views", |s, _| mode(s, ViewMode::Outline, false)).key("Mod+Alt+O").pure(), CommandSpec::new("view.readMode", "Read Mode", "View › Views", |s, _| mode(s, ViewMode::Print, true)).pure(), + // Print Layout: hide the white space (margins, headers, footers) between pages. Screen only. + CommandSpec::new("view.hideWhiteSpace", "Hide White Space", "View › Show", |s, v| toggle(s, v, |x| &mut x.hide_white_space)) + .params(r#"{"value"?: bool}"#) + .pure(), CommandSpec::new("view.focus", "Focus", "View › Immersive", |s, v| toggle(s, v, |x| &mut x.focus_mode)).pure(), CommandSpec::new("view.ruler", "Ruler", "View › Show", |s, v| toggle(s, v, |x| &mut x.ruler)).pure(), // The drawing grid over the page (screen only; never printed or exported). diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 278f7d031e..0a103f8cb6 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -95,6 +95,11 @@ pub struct ViewState { /// Show field codes instead of their results (`view.fieldCodes`, Alt+F9). #[serde(default)] pub field_codes: bool, + /// Print Layout › Hide White Space: pages run together, the top and bottom margins (with + /// headers and footers) between pages collapse to a thin line. On screen only; the layout + /// and page count are unchanged. + #[serde(default)] + pub hide_white_space: bool, /// Outline view: collapsed headings, Show Level, first lines, text formatting. #[serde(default)] pub outline: crate::cmd::outline::OutlineState, @@ -136,6 +141,7 @@ impl Default for ViewState { hide_ink: false, draw: Default::default(), field_codes: false, + hide_white_space: false, outline: Default::default(), design_mode: false, } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6d11930ab3..79af9d0566 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3588,6 +3588,17 @@ fn note_options_per_section_and_convert_notes() { assert!(s.run("references.convertNotes", &json!({"to": "sideways"})).is_err()); } +#[test] +fn hide_white_space_toggles_view_state_only() { + let mut s = s(); + let before = s.doc.body.len(); + assert!(!s.view.hide_white_space); + assert_eq!(run(&mut s, "view.hideWhiteSpace", json!({}))["value"], true); + assert!(s.view.hide_white_space); + assert_eq!(run(&mut s, "view.hideWhiteSpace", json!({"value": false}))["value"], false); + assert_eq!(s.doc.body.len(), before, "a view option never edits the document"); +} + /// The first SmartArt (or other graphic) in the body, selected. fn select_graphic(s: &mut Session) { let (path, off) = (0..s.doc.body.len() as u32) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index bad0326392..67900eec02 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -181,6 +181,62 @@ pub struct Geometry { pub scale: f32, /// Pages per row. pub cols: usize, + /// Per page, the vertical range (pixels from the page rect's top) that is on screen. The + /// whole page, except with Hide White Space, which cuts the margins between pages. + pub bands: Vec<(f32, f32)>, +} + +/// Room kept above the first line and below the last line of a page when white space is hidden (points). +const WHITE_SPACE_PAD: f32 = 6.0; +/// The line drawn between two pages when white space is hidden (pixels). +const SEAM: f32 = 1.0; + +/// Hide White Space: stack single-column pages (`rects`, full pages) so that only each page's +/// text area (`bodies`: top and bottom of the body in points, plus a little room) shows, with a +/// [`SEAM`] between pages. The first page keeps its top and the last its bottom. Rects keep the +/// full page size, so page coordinates still map as `(p - rect.min) / scale`; neighbouring rects +/// overlap in the hidden margins. Returns the moved rects, the visible band of each, and the +/// content height. +fn collapse_white_space(rects: &[Rect], bodies: &[(f32, f32)], scale: f32, avail_h: f32) -> (Vec, Vec<(f32, f32)>, f32) { + let n = rects.len(); + let scale = if scale.is_finite() { scale.clamp(0.01, 10.0) } else { PX_PER_PT }; + let avail_h = if avail_h.is_finite() { avail_h } else { 0.0 }; + let mut out = Vec::with_capacity(n); + let mut bands = Vec::with_capacity(n); + let Some(first) = rects.first() else { return (out, bands, avail_h) }; + let mut cursor = first.min.y; + for (i, r) in rects.iter().enumerate() { + let h = if r.height().is_finite() { r.height().max(0.0) } else { 0.0 }; + let (bt, bb) = bodies.get(i).copied().unwrap_or((0.0, h)); + let fin = |v: f32, d: f32| if v.is_finite() { v } else { d }; + let top = if i == 0 { 0.0 } else { (fin(bt, 0.0) * scale - WHITE_SPACE_PAD * scale).clamp(0.0, h) }; + let bot = if i + 1 == n { h } else { (fin(bb, 0.0) * scale + WHITE_SPACE_PAD * scale).clamp(top, h) }; + let y = cursor - top; + out.push(Rect::from_min_size(pos2(r.min.x, y), r.size())); + bands.push((top, bot)); + cursor = y + bot + SEAM; + } + (out, bands, (cursor - SEAM + GAP).max(avail_h)) +} + +/// True when any page is cut to a band (white space is hidden). +fn white_space_hidden(rects: &[Rect], bands: &[(f32, f32)]) -> bool { + rects.iter().zip(bands).any(|(r, b)| b.0 > 0.5 || b.1 < r.height() - 0.5) +} + +/// True when a double-click at `p` is on the space between two pages (or on the seam when white +/// space is hidden): Word's way to toggle Hide White Space. +fn on_page_gap(rects: &[Rect], bands: &[(f32, f32)], p: Pos2) -> bool { + let hidden = white_space_hidden(rects, bands); + rects.windows(2).enumerate().any(|(i, w)| { + let (a, b) = (w[0], w[1]); + let in_x = p.x >= a.min.x.min(b.min.x) && p.x <= a.max.x.max(b.max.x); + let (y0, y1) = match bands.get(i) { + Some(&(_, bot)) if hidden => (a.min.y + bot - 3.0, a.min.y + bot + SEAM + 3.0), + _ => (a.max.y, b.min.y), + }; + in_x && p.y >= y0 && p.y <= y1 + }) } /// Width of the markup area beside each page for comment balloons (points), or 0. @@ -281,8 +337,16 @@ pub fn geometry(app: &WordApp, l: &DocLayout, avail: egui::Vec2) -> Geometry { }; let cols = if v.read_mode { 2 } else { columns_for(avail.x, maxw * scale, l.pages.len(), min_cols, auto) }; let sizes: Vec<(f32, f32)> = l.pages.iter().map(|p| (p.w, p.h)).collect(); - let (rects, size) = place_pages(&sizes, markup, scale, cols, avail); - Geometry { rects, size, scale, cols } + let (mut rects, mut size) = place_pages(&sizes, markup, scale, cols, avail); + let mut bands: Vec<(f32, f32)> = rects.iter().map(|r| (0.0, r.height())).collect(); + let editing_part = matches!(app.session.sel.focus.story, StoryRef::Part(_)); + if v.hide_white_space && v.mode == wordcraft_layout::ViewMode::Print && !v.read_mode && cols == 1 && !editing_part && rects.len() > 1 { + let bodies: Vec<(f32, f32)> = l.pages.iter().map(|p| (p.body.y, p.body.bottom())).collect(); + let (r, b, h) = collapse_white_space(&rects, &bodies, scale, avail.y); + (rects, bands) = (r, b); + size.y = h; + } + Geometry { rects, size, scale, cols, bands } } /// While a fit mode (Page Width, One Page, Multiple Pages) sizes the page, keep the session's zoom @@ -476,6 +540,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { for (i, pr) in geo.rects.iter().enumerate() { let sr = pr.translate(content_origin.to_vec2()); let Some(page) = layout.pages.get(i) else { continue }; + let (band_top, band_bot) = geo.bands.get(i).copied().unwrap_or((0.0, sr.height())); let scale_px = (geo.scale * ppp).min(max_tex / page.w.max(1.0)).min(max_tex / page.h.clamp(1.0, 1e6)).max(0.05); let key = page_key(app, page, scale_px, dim_body); let fresh = app.canvas.textures.get(&i).is_some_and(|(k, _)| *k == key); @@ -486,9 +551,17 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { sr }; rects.push(visual_sr); - if !sr.intersects(ui.clip_rect().expand(200.0)) { + // Hide White Space shows only a band of the page; everything below paints into it. + let band = + Rect::from_min_max(pos2(visual_sr.min.x - 4.0, visual_sr.min.y + band_top), pos2(visual_sr.max.x + 4.0, visual_sr.min.y + band_bot)); + if !band.intersects(ui.clip_rect().expand(200.0)) { continue; } + let painter = if band_top <= 0.0 && band_bot >= sr.height() { + painter.clone() + } else { + painter.with_clip_rect(painter.clip_rect().intersect(band)) + }; // Shadow and paper. painter.rect_filled(visual_sr.translate(vec2(0.0, 2.0)).expand(1.5), 1.0, t.page_shadow); painter.rect_filled(visual_sr, 0.0, if dark_page { Color32::from_gray(dark_paper) } else { Color32::WHITE }); @@ -581,6 +654,14 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { } } } + // Hide White Space: a thin line where one page ends and the next begins. + for (i, pair) in rects.windows(2).enumerate() { + let Some(&(_, bot)) = geo.bands.get(i) else { continue }; + if white_space_hidden(&geo.rects, &geo.bands) { + let y = pair[0].min.y + bot; + painter.rect_filled(Rect::from_min_max(pos2(pair[0].min.x, y), pos2(pair[0].max.x, y + SEAM)), 0.0, t.border_strong); + } + } if rendered > 0 { app.canvas.render_ms = crate::now_ms() - t0; } @@ -749,6 +830,15 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { app.canvas.focused = resp.has_focus(); // Screen readers: the document under the canvas, with the caret (#488). crate::a11y::expose(app, ui.ctx(), resp.id, &layout, &rects, area); + // Double-click the space between two pages (or the line between them) to hide or show it. + if resp.double_clicked() + && app.session.view.mode == wordcraft_layout::ViewMode::Print + && !app.session.view.read_mode + && let Some(p) = resp.interact_pointer_pos() + && on_page_gap(&rects, &geo.bands, p) + { + let _ = app.run("view.hideWhiteSpace", json!({"value": !app.session.view.hide_white_space})); + } else // Draw Table / Eraser own the mouse while on (no caret moves); else the usual editing. if !crate::table_pen::pointer(app, ui, &resp, &rects, &layout, geo.scale) { mouse(app, ui, &resp, &rects, &layout, geo.scale); @@ -1123,7 +1213,8 @@ pub fn nearest_page(rects: &[Rect], p: Pos2) -> Option { let mut best: Option<(usize, f32)> = None; for (i, r) in rects.iter().enumerate() { let d = if r.contains(p) { - 0.0 + // Pages overlap in the margins Hide White Space cuts: the one whose middle is nearer wins. + 1e-3 * (p.y - r.center().y).abs() / r.height().max(1.0) } else { let dx = (r.min.x - p.x).max(p.x - r.max.x).max(0.0); let dy = (r.min.y - p.y).max(p.y - r.max.y).max(0.0); @@ -2223,4 +2314,36 @@ mod tests { assert_eq!(visual, layout); } + + #[test] + fn hiding_white_space_stacks_page_bands_and_keeps_hit_testing() { + let scale = 1.0; + let sizes = [(612.0, 792.0); 3]; + let (rects, _) = place_pages(&sizes, 0.0, scale, 1, vec2(800.0, 600.0)); + // Body from 72 to 720 pt on every page. + let bodies = [(72.0, 720.0); 3]; + let (moved, bands, h) = collapse_white_space(&rects, &bodies, scale, 600.0); + assert_eq!(bands[0].0, 0.0, "first page keeps its top"); + assert_eq!(bands[2].1, 792.0, "last page keeps its bottom"); + assert_eq!(bands[1], (66.0, 726.0)); + // Bands abut with one seam, so the content is much shorter than three full pages. + assert_eq!(moved[1].min.y + bands[1].0, moved[0].min.y + bands[0].1 + SEAM); + assert!(h < rects[2].bottom() - 2.0 * 66.0 * 2.0); + // A point inside page 2's band maps to that page and the right page coordinates. + let p = pos2(moved[1].center().x, moved[1].min.y + 300.0); + assert_eq!(page_at_index(&moved, p), Some(1)); + // Just above the seam is page 1's text area, just below is page 2's, though the rects overlap. + let seam = moved[0].min.y + bands[0].1; + assert_eq!(nearest_page(&moved, pos2(moved[0].center().x, seam - 2.0)), Some(0)); + assert_eq!(nearest_page(&moved, pos2(moved[0].center().x, seam + SEAM + 2.0)), Some(1)); + assert!(on_page_gap(&moved, &bands, pos2(moved[0].center().x, seam + 0.5))); + assert!(!on_page_gap(&moved, &bands, pos2(moved[0].center().x, seam - 40.0))); + // Hostile numbers never panic. + let _ = collapse_white_space(&rects, &[(f32::NAN, f32::INFINITY), (-1e9, 1e9)], f32::NAN, f32::NAN); + let _ = collapse_white_space(&[], &[], 1.0, 1.0); + } + + fn page_at_index(rects: &[Rect], p: Pos2) -> Option { + nearest_page(rects, p) + } } diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 89d09d8306..8bc4f8b7d9 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1698,6 +1698,7 @@ Continuous numbering Numeración continua Apply Aplicar Convert Notes Convertir notas + Hide White Space Ocultar espacio en blanco All pages Todas las páginas Current page Página actual Selected pages Páginas seleccionadas diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 4b668de033..b1f39d3ec2 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -1659,6 +1659,7 @@ Continuous numbering Pidev nummerdus Apply Rakenda Convert Notes Teisenda märkused + Hide White Space Peida tühi ala lehtede vahel All pages Kõik leheküljed Current page Praegune lehekülg Selected pages Valitud leheküljed diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index ef76573495..2217bde16f 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1682,6 +1682,7 @@ Continuous numbering 通し番号 Apply 適用 Convert Notes 注の変換 + Hide White Space 余白を非表示 All pages すべてのページ Current page 現在のページ Selected pages 選択したページ diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index d71ca865b5..72d356b0e6 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1694,6 +1694,7 @@ Continuous numbering Numeração contínua Apply Aplicar Convert Notes Converter notas + Hide White Space Ocultar espaço em branco All pages Todas as páginas Current page Página atual Selected pages Páginas selecionadas diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 2cd5d3fe38..5bd9516d64 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2127,6 +2127,7 @@ preview Title Пример Continuous numbering Сквозная нумерация Apply Применить Convert Notes Преобразовать сноски + Hide White Space Скрыть пустое место между страницами All pages Все страницы Current page Текущая страница Selected pages Выбранные страницы diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 38ac345946..6567ef753b 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -1692,6 +1692,7 @@ Continuous numbering Neprekidna numeracija Apply Primeni Convert Notes Pretvori napomene + Hide White Space Sakrij prazan prostor All pages Sve stranice Current page Trenutna stranica Selected pages Izabrane stranice diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 4128e8bd11..e20cccd28b 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -1696,6 +1696,7 @@ Continuous numbering Непрекидна нумерација Apply Примени Convert Notes Претвори напомене + Hide White Space Сакриј празан простор All pages Све странице Current page Тренутна страница Selected pages Изабране странице diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 7013716c82..148da1956b 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1749,6 +1749,7 @@ Continuous numbering Sürekli numaralandırma Apply Uygula Convert Notes Notları Dönüştür + Hide White Space Boşluğu gizle All pages Tüm sayfalar Current page Geçerli sayfa Selected pages Seçili sayfalar diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 904fa1d5e1..4bb4979284 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1694,6 +1694,7 @@ Continuous numbering Наскрізна нумерація Apply Застосувати Convert Notes Перетворити примітки + Hide White Space Приховати порожній простір All pages Усі сторінки Current page Поточна сторінка Selected pages Вибрані сторінки diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 9923ded0f7..ff55829b5b 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1694,6 +1694,7 @@ Continuous numbering 连续编号 Apply 应用 Convert Notes 转换注释 + Hide White Space 隐藏空白 All pages 所有页面 Current page 当前页 Selected pages 所选页面 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index e5a7c38259..464268e989 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1683,6 +1683,7 @@ Continuous numbering 連續編號 Apply 套用 Convert Notes 轉換附註 + Hide White Space 隱藏空白 All pages 所有頁面 Current page 目前頁面 Selected pages 所選頁面 diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 78fac2db70..edb8fad80d 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1501,6 +1501,10 @@ fn view(app: &mut WordApp, ui: &mut Ui) { if ui.checkbox(&mut n, tl!("Navigation Pane")).changed() { let _ = app.run("view.navigationPane", json!({"value": n})); } + let mut w = v.hide_white_space; + if ui.checkbox(&mut w, tl!("Hide White Space")).changed() { + let _ = app.run("view.hideWhiteSpace", json!({"value": w})); + } }); }); group(ui, "Zoom", None, app, |ui, app| { From 1b96e04b2c8c8e75c68d26ebd15749f70f2d05fa Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Mon, 12 Oct 2026 06:38:20 +0200 Subject: [PATCH 64/89] =?UTF-8?q?Add=20fonts=20at=20run=20time:=20Add=20Fo?= =?UTF-8?q?nt=E2=80=A6,=20drop=20a=20font=20file,=20font.add=20(#582)=20(#?= =?UTF-8?q?668)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add fonts at run time: Add Font…, drop a font file, font.add (#582) Rebased onto main as one commit (the branch had merge commits from main). Co-Authored-By: Claude Opus 5.5 * #582: font test no longer depends on the system's fallback font The FreeBSD runner has no system fonts, so a missing family fell back to Source Serif 4, the font the test compared against. The test now adds a renamed JetBrains Mono under a name without Serif/Sans/Mono (which picks a fallback of that kind), with narrow-letter sample text: monospaced, it measures unlike any proportional fallback. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- Cargo.lock | 14 + Cargo.toml | 3 + ROADMAP.md | 3 +- apps/wordcraft-web/Cargo.toml | 3 +- apps/wordcraft-web/src/web.rs | 7 + apps/wordcraft-web/src/web/font_store.rs | 116 ++++ apps/wordcraft/Cargo.toml | 2 + apps/wordcraft/src/file_dialogs.rs | 2 + apps/wordcraft/src/main.rs | 14 + crates/engine/src/cmd/font.rs | 107 ++++ crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/lib.rs | 2 + crates/engine/src/tests_fonts.rs | 107 ++++ crates/fonts/Cargo.toml | 1 + crates/fonts/src/fontdb.rs | 11 +- crates/fonts/src/fontdb/user.rs | 699 +++++++++++++++++++++++ crates/fonts/src/lib.rs | 5 +- crates/fonts/src/word.rs | 28 +- crates/ui-egui/src/file_dialogs.rs | 17 +- crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/et.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/sr-latn.tsv | 3 + crates/ui-egui/src/i18n/sr.tsv | 3 + crates/ui-egui/src/i18n/tr.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 3 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + crates/ui-egui/src/lib.rs | 100 ++++ crates/ui-egui/src/mini_toolbar.rs | 8 +- crates/ui-egui/src/previews.rs | 11 + crates/ui-egui/src/ribbon.rs | 8 +- crates/ui-egui/src/widgets.rs | 12 + docs/ui-parity.md | 3 +- 36 files changed, 1300 insertions(+), 18 deletions(-) create mode 100644 apps/wordcraft-web/src/web/font_store.rs create mode 100644 crates/engine/src/cmd/font.rs create mode 100644 crates/engine/src/tests_fonts.rs create mode 100644 crates/fonts/src/fontdb/user.rs diff --git a/Cargo.lock b/Cargo.lock index 8bab53cfbe..853b8a797b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5014,6 +5014,7 @@ dependencies = [ "wordcraft-control-key", "wordcraft-doc", "wordcraft-engine", + "wordcraft-fonts", "wordcraft-ui-egui", ] @@ -5116,6 +5117,7 @@ dependencies = [ "skrifa 0.47.0", "unicode-bidi", "unicode-script", + "wuff", ] [[package]] @@ -5258,6 +5260,7 @@ dependencies = [ "web-sys", "wordcraft-doc", "wordcraft-engine", + "wordcraft-fonts", "wordcraft-ui-egui", ] @@ -5294,6 +5297,17 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +[[package]] +name = "wuff" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200a7b806377b65f1ff9a985874332f15357c87e5dd7855f35f06dade95b8b3d" +dependencies = [ + "brotli-decompressor", + "bytes", + "flate2", +] + [[package]] name = "x11-dl" version = "2.21.0" diff --git a/Cargo.toml b/Cargo.toml index c0b3b4b024..438cbc9ee5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,9 @@ regex-automata = { version = "0.4", default-features = false, features = ["std", # text / rendering / formats skrifa = "0.47" harfrust = "0.13" +# WOFF / WOFF2 web fonts added by the user (#582): pure Rust (MIT); its brotli and zlib decoders +# (brotli-decompressor, flate2) are already in the tree. +wuff = "0.2.9" vello_cpu = "0.2" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] } krilla = { version = "0.8", features = ["pdf"] } diff --git a/ROADMAP.md b/ROADMAP.md index 3dc8c82ce8..1b1dd3de8a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (security review fixes landed; previously trivial: Turkish interface landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (adding fonts at run time, #582; previously trivial: Insert › SmartArt landed, #497: catalog 429/451 → 437/458 (95.4%); previously trivial: Turkish interface landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 WordCraft aims for complete parity with Microsoft Word, then goes further on speed, openness and agent control. This page is the summary; the assessment is @@ -149,6 +149,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-11 | Right-click Wrap Text submenu on pictures, shapes and text boxes: wrapping styles, Move with Text / Fix Position on Page (#513) | | 2026-10-11 | Paragraph dialog options (#512): mirror indents (`w:mirrorIndents`, laid out on even pages), same-style spacing, suppress line numbers, don't hyphenate | | 2026-10-11 | Screen readers can read the document canvas (#488): paragraphs, headings, lists, tables, links, pictures and the caret exposed through AccessKit | +| 2026-10-11 | Add fonts without rebuilding (#582): Home › Font › Add Font… (end of the font list), dropping a TTF/OTF/TTC/WOFF/WOFF2 file, or `font.add`/`font.list`/`font.remove` for agents. Files are checked (50 MB cap, every face must parse and map characters), never replace a bundled or loaded face, and are kept between runs (desktop: `fonts` in the settings folder; web: IndexedDB). Linking a font by URL is still to come | | 2026-10-11 | Print Markup (#589): File › Print/Export checkbox, `file.printMarkup`, `markup` on save/export commands and `wordcraft-cli convert --markup` print comments (with replies) in balloons beside each page, scaled to the paper like Word's "Document showing markup"; HTML, Markdown and text copies end with the comments | | 2026-10-11 | Custom colours (#606): every colour drop-down gains Recent Colors and More Colors…, which opens a Colors dialog with a Standard honeycomb and a Custom hue/saturation field, lightness slider, RGB/HSL numbers and a hex box | | 2026-10-11 | Complex scripts (#604): Myanmar, Khmer and Indic text shaped one script at a time in OpenType fallback fonts (regular weight, not AAT-only fonts), the caret and line breaks never split a syllable, Myanmar wraps between syllables, Backspace removes one sign at a time | diff --git a/apps/wordcraft-web/Cargo.toml b/apps/wordcraft-web/Cargo.toml index befd8b77fb..a3e53cc132 100644 --- a/apps/wordcraft-web/Cargo.toml +++ b/apps/wordcraft-web/Cargo.toml @@ -20,6 +20,7 @@ egui = { workspace = true } log = { workspace = true } serde_json = { workspace = true } wordcraft-doc = { workspace = true } +wordcraft-fonts = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] # `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback. @@ -28,4 +29,4 @@ rfd = { workspace = true } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode"] } diff --git a/apps/wordcraft-web/src/web.rs b/apps/wordcraft-web/src/web.rs index a49a04d83d..87076beafd 100644 --- a/apps/wordcraft-web/src/web.rs +++ b/apps/wordcraft-web/src/web.rs @@ -11,6 +11,7 @@ use wordcraft_ui_egui::{Inbox, Services, WordApp}; const DOC_EXTS: &[&str] = &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json", "xml"]; const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp"]; const RECIPIENT_EXTS: &[&str] = &["csv", "tsv", "txt", "xlsx", "xlsm", "ods"]; +mod font_store; const CANVAS_ID: &str = "wordcraft_canvas"; const LOADING_ID: &str = "wordcraft_loading"; @@ -43,6 +44,8 @@ pub fn start() { if let Some(rs) = &cc.wgpu_render_state { log::info!("wordcraft-web: wgpu backend {:?}", rs.adapter.get_info().backend); } + // Fonts the user added on an earlier visit (#582); the app notices them arrive. + font_store::load_all(cc.egui_ctx.clone()); let inbox: Inbox = Inbox::default(); if let Err(e) = listen_for_pasted_pictures(inbox.clone(), cc.egui_ctx.clone()) { log::error!("pasting pictures is unavailable: {e}"); @@ -158,6 +161,8 @@ fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc>) -> Services let ctx = ctx.clone(); let dialog = if purpose == "picture" { rfd::AsyncFileDialog::new().add_filter("Pictures", IMAGE_EXTS) + } else if purpose == "font" { + rfd::AsyncFileDialog::new().add_filter("Fonts", wordcraft_fonts::FONT_FILE_EXTS) } else if purpose == "recipients" { rfd::AsyncFileDialog::new().add_filter("Recipient lists", RECIPIENT_EXTS) } else { @@ -178,6 +183,8 @@ fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc>) -> Services print: Some(Box::new(print_pdf)), inbox: Some(inbox), on_dirty: Some(Box::new(move |d| dirty.set(d))), + store_font: Some(Box::new(font_store::store)), + forget_font: Some(Box::new(font_store::forget)), ..Default::default() } } diff --git a/apps/wordcraft-web/src/web/font_store.rs b/apps/wordcraft-web/src/web/font_store.rs new file mode 100644 index 0000000000..3a77264066 --- /dev/null +++ b/apps/wordcraft-web/src/web/font_store.rs @@ -0,0 +1,116 @@ +//! Fonts the user added in the browser (#582), kept in IndexedDB (`localStorage` holds about +//! 5 MB, less than one CJK font) so they are there again after a reload. Every failure is logged +//! and skipped: a font that can't be stored still works for the session. + +use wasm_bindgen::JsCast as _; +use wasm_bindgen::JsValue; +use wasm_bindgen::closure::Closure; +use web_sys::{IdbDatabase, IdbRequest, IdbTransactionMode}; + +const DB_NAME: &str = "wordcraft"; +const STORE: &str = "fonts"; + +fn js(e: JsValue) -> String { + format!("{e:?}") +} + +/// Wait for an IndexedDB request; its result. +async fn done(req: &IdbRequest) -> Result { + let promise = js_sys::Promise::new(&mut |resolve: js_sys::Function, reject: js_sys::Function| { + let ok_req = req.clone(); + let ok = Closure::once_into_js(move || { + let _ = resolve.call1(&JsValue::NULL, &ok_req.result().unwrap_or(JsValue::UNDEFINED)); + }); + let err = Closure::once_into_js(move || { + let _ = reject.call1(&JsValue::NULL, &JsValue::from_str("IndexedDB request failed")); + }); + req.set_onsuccess(Some(ok.unchecked_ref())); + req.set_onerror(Some(err.unchecked_ref())); + }); + wasm_bindgen_futures::JsFuture::from(promise).await.map_err(js) +} + +/// The database, with its font store made on first use. +async fn open() -> Result { + let factory = web_sys::window().ok_or("no window")?.indexed_db().map_err(js)?.ok_or("IndexedDB is unavailable")?; + let req = factory.open_with_u32(DB_NAME, 1).map_err(js)?; + let upgrade_req = req.clone(); + let upgrade = Closure::::new(move || { + if let Ok(db) = upgrade_req.result().and_then(|r| r.dyn_into::().map_err(JsValue::from)) + && !db.object_store_names().contains(STORE) + && let Err(e) = db.create_object_store(STORE) + { + log::error!("couldn't make the font store: {e:?}"); + } + }); + req.set_onupgradeneeded(Some(upgrade.as_ref().unchecked_ref())); + let db = done(&req).await; + req.set_onupgradeneeded(None); + drop(upgrade); + db?.dyn_into::().map_err(js) +} + +async fn store_async(name: String, bytes: Vec) -> Result<(), String> { + let db = open().await?; + let store = db.transaction_with_str_and_mode(STORE, IdbTransactionMode::Readwrite).map_err(js)?.object_store(STORE).map_err(js)?; + let req = store.put_with_key(&js_sys::Uint8Array::from(bytes.as_slice()), &JsValue::from_str(&name)).map_err(js)?; + done(&req).await.map(|_| ()) +} + +async fn forget_async(name: String) -> Result<(), String> { + let db = open().await?; + let store = db.transaction_with_str_and_mode(STORE, IdbTransactionMode::Readwrite).map_err(js)?.object_store(STORE).map_err(js)?; + let req = store.delete(&JsValue::from_str(&name)).map_err(js)?; + done(&req).await.map(|_| ()) +} + +/// Keep `bytes` (a font file as the user gave it) under `name` (its stored file name). +pub fn store(name: &str, bytes: &[u8]) { + let (name, bytes) = (name.to_string(), bytes.to_vec()); + wasm_bindgen_futures::spawn_local(async move { + if let Err(e) = store_async(name.clone(), bytes).await { + log::error!("couldn't keep the font {name} for the next visit: {e}"); + } + }); +} + +/// Forget the font stored under `name`. +pub fn forget(name: &str) { + let name = name.to_string(); + wasm_bindgen_futures::spawn_local(async move { + if let Err(e) = forget_async(name.clone()).await { + log::error!("couldn't forget the stored font {name}: {e}"); + } + }); +} + +/// Add every stored font to the font database (each checked like a new one; one that fails is +/// logged and skipped), then repaint: the app lays out again when fonts change. +pub fn load_all(ctx: egui::Context) { + wasm_bindgen_futures::spawn_local(async move { + let r: Result<(), String> = async { + let db = open().await?; + let store = db.transaction_with_str(STORE).map_err(js)?.object_store(STORE).map_err(js)?; + let keys = done(&store.get_all_keys().map_err(js)?).await?; + let values = done(&store.get_all().map_err(js)?).await?; + let (keys, values) = (js_sys::Array::from(&keys), js_sys::Array::from(&values)); + for i in 0..keys.length().min(values.length()) { + let Some(name) = keys.get(i).as_string() else { continue }; + let Ok(bytes) = values.get(i).dyn_into::() else { continue }; + if bytes.length() as usize > wordcraft_fonts::MAX_FONT_FILE_BYTES { + log::warn!("stored font {name} is too large; skipped"); + continue; + } + if let Err(e) = wordcraft_fonts::FontDb::global().install_font(&name, &bytes.to_vec()) { + log::warn!("stored font {name} skipped: {e}"); + } + } + Ok(()) + } + .await; + if let Err(e) = r { + log::warn!("no stored fonts: {e}"); + } + ctx.request_repaint(); + }); +} diff --git a/apps/wordcraft/Cargo.toml b/apps/wordcraft/Cargo.toml index 141b569287..bd587a9757 100644 --- a/apps/wordcraft/Cargo.toml +++ b/apps/wordcraft/Cargo.toml @@ -13,6 +13,8 @@ workspace = true [dependencies] wordcraft-doc = { workspace = true } wordcraft-engine = { workspace = true } +# Fonts the user added (#582) load from the settings folder at start-up. +wordcraft-fonts = { workspace = true } wordcraft-ui-egui = { workspace = true } # The control key file's location (shared with the MCP bridge). wordcraft-control-key = { workspace = true } diff --git a/apps/wordcraft/src/file_dialogs.rs b/apps/wordcraft/src/file_dialogs.rs index a194c9642f..c0c2c843c5 100644 --- a/apps/wordcraft/src/file_dialogs.rs +++ b/apps/wordcraft/src/file_dialogs.rs @@ -72,6 +72,8 @@ impl Launcher { fn open_filters(d: rfd::AsyncFileDialog, purpose: &str) -> rfd::AsyncFileDialog { if purpose == "picture" { d.add_filter("Pictures", &["png", "jpg", "jpeg", "gif", "webp", "bmp"]) + } else if purpose == "font" { + d.add_filter("Fonts", wordcraft_fonts::FONT_FILE_EXTS) } else if purpose == "recipients" { d.add_filter("Recipient lists", &["csv", "tsv", "txt", "xlsx", "xlsm", "ods"]).add_filter("All files", &["*"]) } else { diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index 8b39ae9274..3222d2036c 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -97,6 +97,19 @@ fn load_prefs(app: &mut WordApp) { } } +/// Fonts the user added (#582) live in `fonts` in the settings folder: load them, and store fonts +/// added later there. Runs without preferences neither read nor keep them. +fn load_user_fonts() { + if !prefs_enabled() { + return; + } + let Some(dir) = wordcraft_control_key::settings_dir().map(|d| d.join("fonts")) else { return }; + let (loaded, skipped) = wordcraft_fonts::FontDb::global().load_user_fonts(&dir); + if !loaded.is_empty() || !skipped.is_empty() { + log::info!("user fonts: {} loaded, {} skipped from {}", loaded.len(), skipped.len(), dir.display()); + } +} + /// The saved window geometry, read before the window opens (`load_prefs` runs after). fn saved_window() -> Option { if !prefs_enabled() { @@ -211,6 +224,7 @@ fn main() -> eframe::Result { // DLL of ours (#603; see graphics.rs). #[cfg(target_os = "windows")] graphics::configure(&mut options, eframe::wgpu::Backends::from_env()); + load_user_fonts(); let restored = saved_window(); if let Some(window) = restored { options.viewport = window.apply(options.viewport); diff --git a/crates/engine/src/cmd/font.rs b/crates/engine/src/cmd/font.rs new file mode 100644 index 0000000000..703a868587 --- /dev/null +++ b/crates/engine/src/cmd/font.rs @@ -0,0 +1,107 @@ +//! Fonts the user adds without rebuilding (#582): `font.add`, `font.list`, `font.remove`. +//! +//! An added font joins the process-wide font database on top of the bundled and system fonts +//! (never replacing a face already there), so it shows in the font lists, lays out, renders and +//! embeds in PDFs like any other. Where it is kept between runs is [`FontDb::load_user_fonts`]'s +//! folder (desktop); without one it lasts for the session (the web shell stores it itself). + +use serde_json::{Value, json}; +use wordcraft_fonts::{FontDb, UserFont}; + +use crate::cmd::insert::base64_decode; +use crate::{CmdError, CmdResult, CommandSpec, Session, p}; + +pub fn specs() -> Vec { + vec![ + CommandSpec::new("font.add", "Add Font…", "Home › Font", add) + .params(r#"{"path"?: string, "data"?: base64, "name"?: string (file name for "data")} — a TTF, OTF, TTC, WOFF or WOFF2 file; none: pick a file"#) + .pure(), + CommandSpec::new("font.list", "Added Fonts", "Home › Font", list).params(r#"{"all"?: bool (also every available family)}"#).pure(), + CommandSpec::new("font.remove", "Remove Added Font", "Home › Font", remove).params(r#"{"family": string}"#).pure(), + ] +} + +fn font_json(f: &UserFont) -> Value { + json!({ + "file": f.file, + "families": f.families(), + "faces": f.faces.iter().map(|(family, style)| json!({"family": family, "style": style})).collect::>(), + "path": f.path.as_ref().map(|p| p.to_string_lossy().to_string()), + }) +} + +/// Read a font file named by the user, refusing one larger than the cap before reading it. +#[cfg(not(target_arch = "wasm32"))] +fn read_font_file(path: &str) -> Result, CmdError> { + let meta = std::fs::metadata(path).map_err(|e| CmdError::Failed(format!("{path}: {e}")))?; + if !meta.is_file() { + return Err(CmdError::Failed(format!("{path}: not a file"))); + } + if meta.len() > wordcraft_fonts::MAX_FONT_FILE_BYTES as u64 { + return Err(CmdError::Failed(format!("{path}: the font file is larger than {} MB", wordcraft_fonts::MAX_FONT_FILE_BYTES >> 20))); + } + std::fs::read(path).map_err(|e| CmdError::Failed(format!("{path}: {e}"))) +} + +fn add(s: &mut Session, v: &Value) -> CmdResult { + if p::str(v, "url").is_some() { + return Err(CmdError::Failed( + "adding a font from a link isn't available yet: download the font file and add it with {\"path\"} or {\"data\"}".into(), + )); + } + let (name, bytes) = if let Some(d) = p::str(v, "data") { + // Base64 is a third larger than the file: refuse before decoding an oversized one. + if d.len() / 4 * 3 > wordcraft_fonts::MAX_FONT_FILE_BYTES + 3 { + return Err(CmdError::Failed(format!("the font file is larger than {} MB", wordcraft_fonts::MAX_FONT_FILE_BYTES >> 20))); + } + let bytes = base64_decode(d).ok_or_else(|| CmdError::Params("bad base64 data".into()))?; + (p::str(v, "name").unwrap_or("font").to_string(), bytes) + } else if let Some(path) = p::str(v, "path") { + #[cfg(not(target_arch = "wasm32"))] + { + (path.to_string(), read_font_file(path)?) + } + #[cfg(target_arch = "wasm32")] + { + let _ = path; + return Err(CmdError::Failed("reading files by path isn't available on the web".into())); + } + } else { + // From the ribbon: the UI shows a file picker and runs this again with the file. + s.ui_requests.push(json!({"open": "addFont"})); + return Ok(json!({"pending": true})); + }; + let r = FontDb::global().install_font(&name, &bytes).map_err(|e| CmdError::Failed(e.to_string()))?; + // Text already set in the family (shown in a fallback until now) takes the new font. + s.relayout(); + let families = r.font.families().join(", "); + s.status = match &r.warning { + Some(w) => format!("Added {families} ({w})"), + None => format!("Added {families}"), + }; + let mut out = font_json(&r.font); + if let Some(w) = r.warning { + out["warning"] = json!(w); + } + Ok(out) +} + +fn list(_: &mut Session, v: &Value) -> CmdResult { + let db = FontDb::global(); + let mut out = json!({ + "fonts": db.user_fonts().iter().map(font_json).collect::>(), + "folder": db.user_font_dir().map(|p| p.to_string_lossy().to_string()), + }); + if p::bool(v, "all") == Some(true) { + out["families"] = json!(db.families()); + } + Ok(out) +} + +fn remove(s: &mut Session, v: &Value) -> CmdResult { + let family = p::str(v, "family").filter(|f| !f.trim().is_empty()).ok_or_else(|| CmdError::Params("family is required".into()))?; + let gone = FontDb::global().remove_user_font(family.trim()).map_err(|e| CmdError::Failed(e.to_string()))?; + s.relayout(); + s.status = format!("Removed {}", family.trim()); + Ok(json!({"removed": gone.iter().map(font_json).collect::>()})) +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 62042a29ec..5e8161a83d 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -16,6 +16,7 @@ pub mod fields; pub mod file; pub mod find_pattern; pub mod fmt_revisions; +pub mod font; pub mod format; pub mod index; pub mod ink_shape; @@ -60,6 +61,7 @@ pub fn registry() -> Registry { v.extend(edit::specs()); v.extend(paste::specs()); v.extend(format::specs()); + v.extend(font::specs()); v.extend(phonetic::specs()); v.extend(para::specs()); v.extend(lists::specs()); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 1c4446bea1..2ec7893010 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -165,6 +165,8 @@ mod tests; #[cfg(test)] mod tests_bidi; #[cfg(test)] +mod tests_fonts; +#[cfg(test)] mod tests_typing; #[cfg(test)] mod tests_wordart; diff --git a/crates/engine/src/tests_fonts.rs b/crates/engine/src/tests_fonts.rs new file mode 100644 index 0000000000..fdc54c0307 --- /dev/null +++ b/crates/engine/src/tests_fonts.rs @@ -0,0 +1,107 @@ +//! Fonts added at run time (#582): `font.add`, `font.list`, `font.remove`. + +use serde_json::json; +use wordcraft_doc::Pos; + +use crate::Session; +use crate::cmd::insert::base64_encode; + +/// The bundled face `file` of family `original` renamed `family` (as long as `original`), so it +/// is a font the database doesn't have. +fn renamed(file: &str, original: &str, family: &str) -> Vec { + assert_eq!(family.len(), original.len()); + let mut data = std::fs::read(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts").join(file)).unwrap(); + let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::>(); + for (from, to) in [(utf16(original), utf16(family)), (original.as_bytes().to_vec(), family.as_bytes().to_vec())] { + let mut i = 0; + while let Some(at) = data[i..].windows(from.len()).position(|w| w == from) { + data[i + at..i + at + to.len()].copy_from_slice(&to); + i += at + to.len(); + } + } + data +} + +/// The caret's x after `text` set in `font`. +fn text_end_x(s: &mut Session, font: &str, text: &str) -> f32 { + s.run("document.setText", &json!({"text": text})).unwrap(); + s.run("select.all", &json!({})).unwrap(); + s.run("format.font", &json!({"name": font})).unwrap(); + s.layout().caret_on(&Pos::body(0, text.len()), 0).unwrap().x +} + +#[test] +fn an_added_font_is_listed_and_lays_out_text_already_set_in_it() { + // A monospaced face, so it measures unlike whatever proportional font the system falls back + // to (Source Serif 4 itself where no system fonts are installed, as on the FreeBSD runner). + // The name has no Serif/Sans/Mono in it, which would pick a fallback of that kind. + const FAMILY: &str = "Cmdfont Face 4"; + // Narrow letters: set monospaced they take far more room than in any proportional font. + const TEXT: &str = "illicit little lilies fill tiny jill"; + let mut s = Session::new(wordcraft_doc::Document::new()); + let mono = text_end_x(&mut s, "JetBrains Mono", TEXT); + // Not installed yet: the text falls back to another font. + let missing = text_end_x(&mut s, FAMILY, TEXT); + assert!((missing - mono).abs() > 1.0, "the fallback measures differently: {missing} vs {mono}"); + + let data = base64_encode(&renamed("JetBrainsMono-Regular.ttf", "JetBrains Mono", FAMILY)); + let r = s.run("font.add", &json!({"data": data, "name": "CmdfontFace4-Regular.ttf"})).unwrap(); + assert_eq!(r["families"], json!([FAMILY])); + assert_eq!(r["faces"][0]["style"], "Regular"); + // The text already set in it re-lays out with it, without touching the document. + let x = s.layout().caret_on(&Pos::body(0, TEXT.len()), 0).unwrap().x; + assert!((x - mono).abs() < 0.01, "measured with the added font: {x} vs {mono} (missing {missing})"); + + let list = s.run("font.list", &json!({"all": true})).unwrap(); + assert!(list["fonts"].as_array().unwrap().iter().any(|f| f["families"] == json!([FAMILY]))); + assert!(list["families"].as_array().unwrap().iter().any(|f| f == FAMILY)); + // It exports to PDF like any other font. + assert!(crate::io::save_bytes("out.pdf", &s.doc).unwrap().starts_with(b"%PDF")); + + // Adding it again changes nothing. + let again = s.run("font.add", &json!({"data": data, "name": "again.ttf"})); + assert!(again.unwrap_err().to_string().contains("already available")); + + let removed = s.run("font.remove", &json!({"family": FAMILY})).unwrap(); + assert_eq!(removed["removed"].as_array().unwrap().len(), 1); + let x = s.layout().caret_on(&Pos::body(0, TEXT.len()), 0).unwrap().x; + assert!((x - missing).abs() < 0.01, "falls back again once removed"); + assert!(s.run("font.remove", &json!({"family": FAMILY})).is_err()); +} + +#[test] +fn adding_a_font_never_changes_text_that_does_not_use_it() { + const FAMILY: &str = "Metric Serif 4"; + let mut s = Session::new(crate::sample::sample_document()); + let before = s.layout(); + let positions = |s: &mut Session| { + let l = s.layout(); + (0..s.doc.body.len().min(12)).filter_map(|b| l.caret_on(&Pos::body(b, 0), 0).map(|c| (c.page, c.x, c.top))).collect::>() + }; + let a = positions(&mut s); + s.run("font.add", &json!({"data": base64_encode(&renamed("SourceSerif4-Bold.ttf", "Source Serif 4", FAMILY)), "name": "m.ttf"})).unwrap(); + assert_eq!(positions(&mut s), a); + assert_eq!(s.layout().pages.len(), before.pages.len()); +} + +#[test] +fn bad_font_input_is_an_error() { + let mut s = Session::new(wordcraft_doc::Document::new()); + for p in [ + json!({"data": base64_encode(b"definitely not a font")}), + json!({"data": "%%% not base64"}), + json!({"data": base64_encode(b"wOF2\0\x01\0\0\xff\xff\xff\xff")}), + json!({"path": "/nonexistent/font.ttf"}), + json!({"path": env!("CARGO_MANIFEST_DIR")}), + json!({"path": concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml")}), + json!({"url": "https://fonts.googleapis.com/css2?family=Roboto"}), + ] { + assert!(s.run("font.add", &p).is_err(), "{p}"); + } + assert!(s.run("font.remove", &json!({})).is_err()); + assert!(s.run("font.remove", &json!({"family": "Source Sans 3"})).is_err(), "the bundled fonts stay"); + // No file: the UI is asked for a picker. + let r = s.run("font.add", &json!({})).unwrap(); + assert_eq!(r["pending"], true); + assert!(s.ui_requests.iter().any(|r| r["open"] == "addFont")); +} diff --git a/crates/fonts/Cargo.toml b/crates/fonts/Cargo.toml index b7aafa34a8..9107da44f1 100644 --- a/crates/fonts/Cargo.toml +++ b/crates/fonts/Cargo.toml @@ -12,6 +12,7 @@ workspace = true kurbo = { workspace = true } skrifa = { workspace = true } harfrust = { workspace = true } +wuff = { workspace = true } unicode-bidi = { workspace = true } unicode-script = { workspace = true } log = { workspace = true } diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index dea01f87a4..5b21cb4c04 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -2,7 +2,7 @@ //! time a lookup needs them, native only), outline cache. use std::collections::HashMap; -use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, RwLock}; use kurbo::BezPath; @@ -12,6 +12,9 @@ use skrifa::raw::FileRef; use skrifa::string::StringId; use skrifa::{GlyphId, MetadataProvider}; +mod user; +pub use user::{FONT_FILE_EXTS, FontFormat, Installed, MAX_FONT_FILE_BYTES, UserFont, UserFontError, decode_font_file, is_font_file_name, sniff}; + /// The family used when a requested family is unknown (the UI sans). pub const FALLBACK_FAMILY: &str = "Source Sans 3"; @@ -293,6 +296,10 @@ pub struct FontDb { fallbacks: RwLock, #[cfg(not(target_arch = "wasm32"))] catalog: RwLock>, + /// Fonts the user added at run time, and the folder they are stored in (#582). + user: RwLock, + /// Bumped whenever user fonts are added or removed ([`FontDb::generation`]). + generation: AtomicU64, /// The folders the system font scan reads (the platform's font folders for [`FontDb::global`]). #[cfg_attr(target_arch = "wasm32", allow(dead_code))] font_dirs: Vec, @@ -839,6 +846,8 @@ impl FontDb { fallbacks: RwLock::new(FallbackCache::default()), #[cfg(not(target_arch = "wasm32"))] catalog: RwLock::new(Vec::new()), + user: RwLock::new(user::UserFonts::default()), + generation: AtomicU64::new(0), font_dirs, #[cfg(not(target_arch = "wasm32"))] cataloged: std::sync::OnceLock::new(), diff --git a/crates/fonts/src/fontdb/user.rs b/crates/fonts/src/fontdb/user.rs new file mode 100644 index 0000000000..ff9cee66eb --- /dev/null +++ b/crates/fonts/src/fontdb/user.rs @@ -0,0 +1,699 @@ +//! Fonts an end user adds at run time, without rebuilding (#582). +//! +//! The bundled faces (and the system fonts) stay the baseline: a user font only adds faces, never +//! replaces one (a face whose family and style are already loaded is skipped), so documents that +//! don't name it keep their metrics. A file is checked before anything is added: its size is +//! capped, WOFF and WOFF2 are unwrapped, and every face must parse and map characters. +//! +//! Desktop: [`FontDb::load_user_fonts`] names a folder (the app's `fonts` folder); the fonts there +//! load at start-up and each font added later is copied there (unwrapped from WOFF/WOFF2). Without +//! a folder (the web, tests, `WORDCRAFT_NO_PREFS`) added fonts last for the session; the web +//! shell keeps them in the browser itself. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::Ordering; + +use skrifa::MetadataProvider; +use skrifa::raw::FileRef; + +use super::{FontBytes, FontDb, enumerate_faces, make_face}; + +/// Largest font file accepted (as given: TTF, OTF, TTC, WOFF or WOFF2). +pub const MAX_FONT_FILE_BYTES: usize = 50 << 20; +/// Largest font a WOFF/WOFF2 file may unwrap to. +const MAX_DECODED_BYTES: usize = 128 << 20; +/// Most faces one file may add (collections, variable fonts' named instances). +const MAX_FACES_PER_FILE: usize = 256; +/// Most user fonts at once. +const MAX_USER_FONTS: usize = 1000; +/// File extensions [`FontDb::load_user_fonts`] reads (fonts are stored unwrapped). +const STORED_EXTS: &[&str] = &["ttf", "otf", "ttc", "otc"]; +/// Extensions a font file the user picks or drops may have. +pub const FONT_FILE_EXTS: &[&str] = &["ttf", "otf", "ttc", "otc", "woff", "woff2"]; + +/// Is `name` (a file name or path) named like a font file? +pub fn is_font_file_name(name: &str) -> bool { + let ext = name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()); + ext.is_some_and(|e| FONT_FILE_EXTS.contains(&e.as_str())) +} + +/// Why a font couldn't be added or removed. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum UserFontError { + Empty, + TooLarge { + bytes: usize, + }, + /// Not a font file this reads (TrueType, OpenType, collections, WOFF, WOFF2). + NotAFont, + /// A WOFF/WOFF2 file that couldn't be unwrapped. + BadWebFont(String), + /// A font file that doesn't parse, or has no usable face. + Unusable(String), + /// Every face in the file is already available. + AlreadyAvailable(Vec), + TooMany, + /// The family isn't one the user added (bundled and system fonts can't be removed). + NotUserFont(String), +} + +impl std::fmt::Display for UserFontError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + UserFontError::Empty => write!(f, "the font file is empty"), + UserFontError::TooLarge { bytes } => { + write!(f, "the font file is too large ({} MB; at most {} MB)", bytes.div_ceil(1 << 20), MAX_FONT_FILE_BYTES >> 20) + } + UserFontError::NotAFont => write!(f, "not a font file (TrueType, OpenType, TTC, WOFF or WOFF2)"), + UserFontError::BadWebFont(e) => write!(f, "the web font couldn't be read: {e}"), + UserFontError::Unusable(e) => write!(f, "the font couldn't be read: {e}"), + UserFontError::AlreadyAvailable(families) => write!(f, "already available: {}", families.join(", ")), + UserFontError::TooMany => write!(f, "too many added fonts (at most {MAX_USER_FONTS}); remove some first"), + UserFontError::NotUserFont(family) => write!(f, "{family:?} isn't a font you added"), + } + } +} + +impl std::error::Error for UserFontError {} + +/// A font file the user added. +#[derive(Clone, Debug, PartialEq)] +pub struct UserFont { + /// Its file name in the user font folder (unique among the user fonts), also the key the web + /// shell stores it under. + pub file: String, + /// The faces it added: (typographic family, style). + pub faces: Vec<(String, String)>, + /// Where it is stored (desktop); `None` when it lasts for the session only. + pub path: Option, + face_ids: Vec, +} + +impl UserFont { + /// The families it added (typographic names, in file order, each once). + pub fn families(&self) -> Vec { + let mut v: Vec = Vec::new(); + for (family, _) in &self.faces { + if !v.iter().any(|f| f.eq_ignore_ascii_case(family)) { + v.push(family.clone()); + } + } + v + } +} + +/// What [`FontDb::install_font`] did. +#[derive(Clone, Debug, PartialEq)] +pub struct Installed { + pub font: UserFont, + /// The font was added for this session but couldn't be stored for the next one. + pub warning: Option, +} + +#[derive(Default)] +pub(super) struct UserFonts { + fonts: Vec, + dir: Option, +} + +/// The kind of font file `bytes` holds, from its signature. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FontFormat { + TrueType, + OpenType, + Collection, + Woff, + Woff2, +} + +pub fn sniff(bytes: &[u8]) -> Option { + match bytes.get(..4)? { + [0, 1, 0, 0] | b"true" | b"typ1" => Some(FontFormat::TrueType), + b"OTTO" => Some(FontFormat::OpenType), + b"ttcf" => Some(FontFormat::Collection), + b"wOFF" => Some(FontFormat::Woff), + b"wOF2" => Some(FontFormat::Woff2), + _ => None, + } +} + +/// The font file in `bytes` as plain OpenType (TTF/OTF/TTC) bytes: WOFF and WOFF2 are unwrapped. +/// Checks the size and the signature only; [`FontDb::install_font`] checks the faces. +pub fn decode_font_file(bytes: &[u8]) -> Result, UserFontError> { + if bytes.is_empty() { + return Err(UserFontError::Empty); + } + if bytes.len() > MAX_FONT_FILE_BYTES { + return Err(UserFontError::TooLarge { bytes: bytes.len() }); + } + let decoded = match sniff(bytes).ok_or(UserFontError::NotAFont)? { + FontFormat::TrueType | FontFormat::OpenType | FontFormat::Collection => return Ok(bytes.to_vec()), + FontFormat::Woff => unwrap_web_font(|| wuff::decompress_woff1(bytes))?, + FontFormat::Woff2 => unwrap_web_font(|| wuff::decompress_woff2(bytes))?, + }; + if decoded.len() > MAX_DECODED_BYTES { + return Err(UserFontError::TooLarge { bytes: decoded.len() }); + } + match sniff(&decoded) { + Some(FontFormat::TrueType | FontFormat::OpenType | FontFormat::Collection) => Ok(decoded), + _ => Err(UserFontError::BadWebFont("it doesn't hold a TrueType or OpenType font".into())), + } +} + +/// Run a WOFF/WOFF2 decoder. The decoder checks its input, but it is third-party code reading +/// hostile bytes: a panic in it is caught (native) and reported as a damaged file. +fn unwrap_web_font(decode: impl FnOnce() -> Result, wuff::WuffErr>) -> Result, UserFontError> { + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(decode)) { + Ok(Ok(v)) => Ok(v), + Ok(Err(e)) => Err(UserFontError::BadWebFont(format!("{e:?}"))), + Err(_) => Err(UserFontError::BadWebFont("damaged file".into())), + } +} + +/// A file name for a user font from the name it came with: letters, digits, spaces, `-` and `_` +/// of its stem (others become `_`, at most 64), and the extension of its unwrapped format. +fn stored_name(name: &str, sfnt: &[u8]) -> String { + // The last component, whichever separator the name came with. + let base = name.rsplit(['/', '\\']).next().unwrap_or(name); + let stem = base.rsplit_once('.').map(|(s, _)| s).unwrap_or(base); + let mut clean: String = stem.chars().take(64).map(|c| if c.is_alphanumeric() || matches!(c, ' ' | '-' | '_') { c } else { '_' }).collect(); + clean = clean.trim().to_string(); + if clean.is_empty() || clean.starts_with('-') { + clean = format!("font{clean}"); + } + let ext = match sniff(sfnt) { + Some(FontFormat::OpenType) => "otf", + Some(FontFormat::Collection) => "ttc", + _ => "ttf", + }; + format!("{clean}.{ext}") +} + +impl FontDb { + /// A counter that changes whenever user fonts are added or removed: a UI caching the font + /// list or a layout redraws when it moves. + pub fn generation(&self) -> u64 { + self.generation.load(Ordering::Acquire) + } + + fn read_user(&self) -> std::sync::RwLockReadGuard<'_, UserFonts> { + self.user.read().unwrap_or_else(|e| e.into_inner()) + } + + fn write_user(&self) -> std::sync::RwLockWriteGuard<'_, UserFonts> { + self.user.write().unwrap_or_else(|e| e.into_inner()) + } + + /// The fonts the user added, in the order added. + pub fn user_fonts(&self) -> Vec { + self.read_user().fonts.clone() + } + + /// The folder user fonts are stored in, if any ([`FontDb::load_user_fonts`]). + pub fn user_font_dir(&self) -> Option { + self.read_user().dir.clone() + } + + /// Add the font file `bytes` (TTF, OTF, TTC, WOFF or WOFF2) named `name` (its file name, for + /// the stored copy). Every face is checked first; faces whose family and style are already + /// available are skipped (the bundled baseline is never replaced). With a user font folder the + /// font is stored there too, for the next start. + pub fn install_font(&self, name: &str, bytes: &[u8]) -> Result { + let sfnt = decode_font_file(bytes)?; + let mut font = self.add_user_faces(name, sfnt.clone(), None)?; + let warning = match self.user_font_dir() { + Some(dir) => match write_unique(&dir, &font.file, &sfnt) { + Ok(path) => { + font.file = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(font.file); + font.path = Some(path); + None + } + Err(e) => { + log::warn!("couldn't store the font {name} in {}: {e}", dir.display()); + Some(format!("added for this session only: couldn't store it in {}: {e}", dir.display())) + } + }, + None => None, + }; + if let Some(f) = self.write_user().fonts.iter_mut().find(|f| f.face_ids == font.face_ids) { + f.file.clone_from(&font.file); + f.path.clone_from(&font.path); + } + Ok(Installed { font, warning }) + } + + /// Check the faces in `sfnt` and add the new ones, recording them as a user font. + fn add_user_faces(&self, name: &str, sfnt: Vec, path: Option) -> Result { + if self.read_user().fonts.len() >= MAX_USER_FONTS { + return Err(UserFontError::TooMany); + } + if FileRef::new(&sfnt).is_err() { + return Err(UserFontError::Unusable("not a valid OpenType file".into())); + } + let found = enumerate_faces(&sfnt); + if found.is_empty() { + return Err(UserFontError::Unusable("no face with a family name".into())); + } + if found.len() > MAX_FACES_PER_FILE { + return Err(UserFontError::Unusable(format!("more than {MAX_FACES_PER_FILE} faces"))); + } + let file = stored_name(name, &sfnt); + let data = Arc::new(sfnt); + let mut new = Vec::new(); + for f in found { + let label = format!("{} {}", f.family, f.style); + let face = make_face(FontBytes::Owned(data.clone()), f.index, f.family, f.style, f.legacy, f.coords) + .ok_or_else(|| UserFontError::Unusable(format!("{label} doesn't parse")))?; + if face.skrifa().is_none_or(|s| s.charmap().mappings().next().is_none()) { + return Err(UserFontError::Unusable(format!("{label} maps no characters"))); + } + new.push(Arc::new(face)); + } + let families = |faces: &[Arc]| { + let mut v: Vec = faces.iter().map(|f| f.family.clone()).collect(); + v.dedup(); + v + }; + let mut user = self.write_user(); + // Unique among the user fonts (the folder is checked when writing). + let file = unique_name(&file, |n| user.fonts.iter().any(|f| f.file.eq_ignore_ascii_case(n))); + let added: Vec> = { + let mut faces = self.faces.write().unwrap_or_else(|e| e.into_inner()); + let fresh: Vec<_> = new + .iter() + .filter(|f| !faces.iter().any(|g| g.family.eq_ignore_ascii_case(&f.family) && g.style.eq_ignore_ascii_case(&f.style))) + .cloned() + .collect(); + faces.extend(fresh.iter().cloned()); + fresh + }; + if added.is_empty() { + return Err(UserFontError::AlreadyAvailable(families(&new))); + } + let font = UserFont { + file, + faces: added.iter().map(|f| (f.family.clone(), f.style.clone())).collect(), + path, + face_ids: added.iter().map(|f| f.id).collect(), + }; + user.fonts.push(font.clone()); + drop(user); + self.fonts_changed(); + Ok(font) + } + + /// Forget cached fallback choices and tell watchers ([`FontDb::generation`]). + fn fonts_changed(&self) { + self.fallbacks.write().unwrap_or_else(|e| e.into_inner()).map.clear(); + self.generation.fetch_add(1, Ordering::AcqRel); + } + + /// Remove the user fonts that added `family` (a typographic or legacy family name): their + /// faces leave the font list and their stored files are deleted. Text set in the family falls + /// back as for any font that isn't installed. Bundled and system fonts can't be removed. + pub fn remove_user_font(&self, family: &str) -> Result, UserFontError> { + let mut user = self.write_user(); + let ids: Vec = { + let faces = self.read_faces(); + faces.iter().filter(|f| f.named(family)).map(|f| f.id).collect() + }; + let (gone, kept): (Vec, Vec) = std::mem::take(&mut user.fonts) + .into_iter() + .partition(|u| u.faces.iter().any(|(f, _)| f.eq_ignore_ascii_case(family)) || u.face_ids.iter().any(|i| ids.contains(i))); + user.fonts = kept; + drop(user); + if gone.is_empty() { + return Err(UserFontError::NotUserFont(family.to_string())); + } + let drop_ids: Vec = gone.iter().flat_map(|u| u.face_ids.iter().copied()).collect(); + self.faces.write().unwrap_or_else(|e| e.into_inner()).retain(|f| !drop_ids.contains(&f.id)); + for u in &gone { + if let Some(p) = &u.path + && let Err(e) = std::fs::remove_file(p) + && e.kind() != std::io::ErrorKind::NotFound + { + log::warn!("couldn't delete the font file {}: {e}", p.display()); + } + } + self.fonts_changed(); + Ok(gone) + } + + /// Use `dir` as the user font folder: load the fonts stored there (each checked like an added + /// font; one that fails is logged and skipped) and store fonts added later there. The folder is + /// made when the first font is stored. Returns the fonts loaded and the files skipped (with + /// why). + pub fn load_user_fonts(&self, dir: &Path) -> (Vec, Vec<(String, String)>) { + self.write_user().dir = Some(dir.to_path_buf()); + let mut loaded = Vec::new(); + let mut skipped = Vec::new(); + let Ok(rd) = std::fs::read_dir(dir) else { return (loaded, skipped) }; + let mut paths: Vec = rd + .flatten() + .map(|e| e.path()) + .filter(|p| p.is_file()) + .filter(|p| p.extension().and_then(|e| e.to_str()).is_some_and(|e| STORED_EXTS.contains(&e.to_ascii_lowercase().as_str()))) + .collect(); + paths.sort(); + for p in paths { + let file = p.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + let read = match std::fs::metadata(&p) { + Ok(m) if m.len() > MAX_FONT_FILE_BYTES as u64 => { + Err(UserFontError::TooLarge { bytes: usize::try_from(m.len()).unwrap_or(usize::MAX) }) + } + Ok(_) => std::fs::read(&p).map_err(|e| UserFontError::Unusable(e.to_string())), + Err(e) => Err(UserFontError::Unusable(e.to_string())), + }; + let r = read.and_then(|b| decode_font_file(&b)).and_then(|sfnt| self.add_user_faces(&file, sfnt, Some(p.clone()))); + match r { + Ok(mut f) => { + // Its own file name, so removing it deletes this file. + if let Some(u) = self.write_user().fonts.iter_mut().find(|u| u.face_ids == f.face_ids) { + u.file.clone_from(&file); + } + f.file = file; + loaded.push(f); + } + Err(e) => { + log::warn!("skipped the user font {}: {e}", p.display()); + skipped.push((file, e.to_string())); + } + } + } + (loaded, skipped) + } +} + +/// `name`, or `name` with `-2`, `-3`… before its extension, the first that `taken` refuses. +fn unique_name(name: &str, taken: impl Fn(&str) -> bool) -> String { + if !taken(name) { + return name.to_string(); + } + let (stem, ext) = name.rsplit_once('.').unwrap_or((name, "")); + (2..10_000).map(|n| format!("{stem}-{n}.{ext}")).find(|n| !taken(n)).unwrap_or_else(|| format!("{stem}-x.{ext}")) +} + +/// Write `bytes` to a new file in `dir` named `name` (or a numbered variant when that exists), +/// through a temporary file so a crash never leaves half a font. +fn write_unique(dir: &Path, name: &str, bytes: &[u8]) -> std::io::Result { + std::fs::create_dir_all(dir)?; + let name = unique_name(name, |n| dir.join(n).exists()); + let path = dir.join(&name); + let tmp = dir.join(format!(".{name}.part")); + std::fs::write(&tmp, bytes)?; + if let Err(e) = std::fs::rename(&tmp, &path) { + let _ = std::fs::remove_file(&tmp); + return Err(e); + } + Ok(path) +} + +#[cfg(test)] +mod tests { + use super::*; + + const FAMILY: &str = "Userfont Sans"; + + /// A bundled Source Sans 3 file renamed `family` (as long as the original name). + pub(crate) fn renamed(file: &str, family: &str) -> Vec { + assert_eq!(family.len(), "Source Sans 3".len()); + let mut data = std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts").join(file)).unwrap(); + 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(at) = data[i..].windows(from.len()).position(|w| w == from) { + data[i + at..i + at + to.len()].copy_from_slice(&to); + i += at + to.len(); + } + } + data + } + + fn db() -> FontDb { + FontDb::with_font_dirs(vec![]) + } + + fn temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("wc-userfonts-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + dir + } + + /// The tables of a TrueType/OpenType font: (tag, data), in directory order. + fn tables(font: &[u8]) -> Vec<([u8; 4], Vec)> { + let n = u16::from_be_bytes([font[4], font[5]]) as usize; + (0..n) + .map(|i| { + let r = &font[12 + i * 16..28 + i * 16]; + let off = u32::from_be_bytes(r[8..12].try_into().unwrap()) as usize; + let len = u32::from_be_bytes(r[12..16].try_into().unwrap()) as usize; + (r[..4].try_into().unwrap(), font[off..off + len].to_vec()) + }) + .collect() + } + + /// `font` as a WOFF 1.0 file with every table stored uncompressed (the format allows it). + fn woff1(font: &[u8]) -> Vec { + let mut t = tables(font); + t.sort_by_key(|x| x.0); + let mut out = Vec::new(); + let dir_end = 44 + 20 * t.len(); + let mut body = Vec::new(); + let mut dir = Vec::new(); + let mut sfnt_size = 12 + 16 * t.len(); + for (tag, data) in &t { + let off = dir_end + body.len(); + dir.extend_from_slice(tag); + dir.extend_from_slice(&(off as u32).to_be_bytes()); + dir.extend_from_slice(&(data.len() as u32).to_be_bytes()); + dir.extend_from_slice(&(data.len() as u32).to_be_bytes()); + dir.extend_from_slice(&0u32.to_be_bytes()); + body.extend_from_slice(data); + body.resize(body.len().next_multiple_of(4), 0); + sfnt_size += data.len().next_multiple_of(4); + } + out.extend_from_slice(b"wOFF"); + out.extend_from_slice(&font[..4]); + out.extend_from_slice(&((dir_end + body.len()) as u32).to_be_bytes()); + out.extend_from_slice(&(t.len() as u16).to_be_bytes()); + out.extend_from_slice(&0u16.to_be_bytes()); + out.extend_from_slice(&(sfnt_size as u32).to_be_bytes()); + out.extend_from_slice(&[0, 1, 0, 0]); // version 1.0 + out.extend_from_slice(&[0; 20]); // no metadata, no private data + out.extend(dir); + out.extend(body); + out + } + + /// `data` as a brotli stream of uncompressed meta-blocks (RFC 7932 §9.2). + fn brotli_stored(data: &[u8]) -> Vec { + struct Bits { + out: Vec, + acc: u64, + n: u32, + } + impl Bits { + fn put(&mut self, v: u64, bits: u32) { + self.acc |= v << self.n; + self.n += bits; + while self.n >= 8 { + self.out.push(self.acc as u8); + self.acc >>= 8; + self.n -= 8; + } + } + fn align(&mut self) { + if self.n > 0 { + self.put(0, 8 - self.n); + } + } + } + let mut b = Bits { out: Vec::new(), acc: 0, n: 0 }; + b.put(0, 1); // WBITS = 16 + for chunk in data.chunks(1 << 16) { + b.put(0, 1); // ISLAST = 0 + b.put(0, 2); // MNIBBLES = 4 + b.put(chunk.len() as u64 - 1, 16); + b.put(1, 1); // ISUNCOMPRESSED + b.align(); + b.out.extend_from_slice(chunk); + } + b.put(1, 1); // ISLAST + b.put(1, 1); // ISLASTEMPTY + b.align(); + b.out + } + + /// WOFF2's UIntBase128. + fn base128(mut v: u32, out: &mut Vec) { + let mut bytes = vec![(v & 0x7f) as u8]; + v >>= 7; + while v > 0 { + bytes.push((v & 0x7f) as u8 | 0x80); + v >>= 7; + } + out.extend(bytes.into_iter().rev()); + } + + /// `font` (TrueType) as a WOFF2 file with no table transformed (glyf/loca use the null + /// transform, version 3) and the table data in stored brotli blocks. + fn woff2(font: &[u8]) -> Vec { + let t = tables(font); + let mut dir = Vec::new(); + let mut stream = Vec::new(); + for (tag, data) in &t { + let null = if tag == b"glyf" || tag == b"loca" { 3 << 6 } else { 0 }; + dir.push(63 | null); + dir.extend_from_slice(tag); + base128(data.len() as u32, &mut dir); + stream.extend_from_slice(data); + } + let compressed = brotli_stored(&stream); + let mut out = Vec::new(); + out.extend_from_slice(b"wOF2"); + out.extend_from_slice(&font[..4]); + let total = (48 + dir.len() + compressed.len()).next_multiple_of(4); + out.extend_from_slice(&(total as u32).to_be_bytes()); + out.extend_from_slice(&(t.len() as u16).to_be_bytes()); + out.extend_from_slice(&0u16.to_be_bytes()); + let sfnt: usize = 12 + 16 * t.len() + t.iter().map(|(_, d)| d.len().next_multiple_of(4)).sum::(); + out.extend_from_slice(&(sfnt as u32).to_be_bytes()); + out.extend_from_slice(&(compressed.len() as u32).to_be_bytes()); + out.extend_from_slice(&[0, 1, 0, 0]); + out.extend_from_slice(&[0; 20]); + out.extend(dir); + out.extend(compressed); + out.resize(total, 0); + out + } + + #[test] + fn an_added_font_is_listed_resolved_and_measured() { + let db = db(); + let gen0 = db.generation(); + assert!(!db.has_family(FAMILY)); + let r = db.install_font("Userfont-Regular.ttf", &renamed("SourceSans3-Regular.ttf", FAMILY)).unwrap(); + assert_eq!(r.font.families(), vec![FAMILY.to_string()]); + assert_eq!(r.font.file, "Userfont-Regular.ttf"); + assert!(r.font.path.is_none() && r.warning.is_none(), "no folder: kept for the session only"); + assert!(db.generation() > gen0); + assert!(db.families().iter().any(|f| f == FAMILY)); + let face = db.face(FAMILY, "Regular"); + assert_eq!(face.family, FAMILY); + // Measured with its own glyphs: the copy has Source Sans 3's metrics. + let src = db.face("Source Sans 3", "Regular"); + let g = face.glyph_for('W'); + assert!(g != 0); + assert_eq!(face.advance(g), src.advance(src.glyph_for('W'))); + assert_eq!(db.user_fonts().len(), 1); + } + + #[test] + fn the_bundled_baseline_is_never_replaced() { + let db = db(); + let before = db.face("Source Sans 3", "Regular").id(); + let bytes = std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/SourceSans3-Regular.ttf")).unwrap(); + assert!(matches!(db.install_font("x.ttf", &bytes), Err(UserFontError::AlreadyAvailable(_)))); + assert_eq!(db.face("Source Sans 3", "Regular").id(), before); + assert!(db.user_fonts().is_empty()); + assert!(matches!(db.remove_user_font("Source Sans 3"), Err(UserFontError::NotUserFont(_)))); + assert!(db.has_family("Source Sans 3")); + } + + #[test] + fn junk_is_rejected_without_adding_anything() { + let db = db(); + let n = db.families().len(); + let regular = renamed("SourceSans3-Regular.ttf", FAMILY); + let mut cases: Vec> = vec![ + vec![], + b"not a font at all".to_vec(), + b"\0\x01\0\0".to_vec(), + b"\0\x01\0\0\xff\xff\0\0\0\0\0\0".to_vec(), + b"ttcf\0\x01\0\0\xff\xff\xff\xff".to_vec(), + b"OTTO\0\x01".to_vec(), + b"wOFF\0\x01\0\0\0\0\0\x08".to_vec(), + b"wOF2\0\x01\0\0\xff\xff\xff\xff\0\x05".to_vec(), + regular[..regular.len() / 3].to_vec(), + ]; + // Damaged web fonts: every prefix, and flipped bytes through the headers and directory. + for packed in [woff1(®ular), woff2(®ular)] { + for cut in [8, 20, 44, 47, 48, 60, 100, 300, packed.len() / 2, packed.len() - 1] { + cases.push(packed[..cut.min(packed.len())].to_vec()); + } + for at in 4..200 { + let mut b = packed.clone(); + b[at] ^= 0xA5; + cases.push(b); + } + } + for (i, c) in cases.iter().enumerate() { + let r = db.install_font("junk.ttf", c); + // A flipped byte inside a table can leave a usable font; the cut-off files can't. + assert!(i >= 9 || r.is_err(), "case {i}"); + } + assert_eq!(db.families().len(), n + db.user_fonts().iter().map(|u| u.families().len()).sum::()); + let mut big = regular.clone(); + big.resize(MAX_FONT_FILE_BYTES + 1, 0); + assert!(matches!(db.install_font("big.ttf", &big), Err(UserFontError::TooLarge { .. }))); + } + + #[test] + fn woff_and_woff2_are_unwrapped() { + let regular = renamed("SourceSans3-Regular.ttf", FAMILY); + for (name, packed) in [("a.woff", woff1(®ular)), ("a.woff2", woff2(®ular))] { + assert!(is_font_file_name(name)); + let db = db(); + let r = db.install_font(name, &packed).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(r.font.file, "a.ttf", "stored unwrapped"); + let face = db.face(FAMILY, "Regular"); + assert_eq!(face.family, FAMILY); + assert!(face.glyph_for('g') != 0); + } + } + + #[test] + fn user_fonts_are_stored_loaded_and_removed() { + let dir = temp_dir("store"); + let db = db(); + let (loaded, skipped) = db.load_user_fonts(&dir); + assert!(loaded.is_empty() && skipped.is_empty(), "a missing folder is empty"); + let r = db.install_font("../../evil/Userfont Bold?.woff2", &woff2(&renamed("SourceSans3-Bold.ttf", FAMILY))).unwrap(); + assert!(r.warning.is_none()); + let path = r.font.path.clone().unwrap(); + assert_eq!(path.parent(), Some(dir.as_path()), "a file name can't leave the folder"); + assert_eq!(r.font.file, "Userfont Bold_.ttf"); + assert!(path.exists()); + db.install_font("Userfont-Regular.ttf", &renamed("SourceSans3-Regular.ttf", FAMILY)).unwrap(); + // A damaged file in the folder is skipped, never fatal. + std::fs::write(dir.join("damaged.ttf"), b"ttcf\0\x01\0\0\xff\xff\xff\xff").unwrap(); + std::fs::write(dir.join("notes.txt"), b"hello").unwrap(); + + // The next start loads both from the folder. + let next = super::super::FontDb::with_font_dirs(vec![]); + let (loaded, skipped) = next.load_user_fonts(&dir); + assert_eq!(loaded.len(), 2, "{skipped:?}"); + assert_eq!(skipped.len(), 1); + assert_eq!(next.styles(FAMILY), vec!["Regular".to_string(), "Bold".to_string()]); + assert_eq!(next.face(FAMILY, "Bold").style, "Bold"); + + // Removing the family deletes both files; text in it falls back. + let gone = next.remove_user_font(FAMILY).unwrap(); + assert_eq!(gone.len(), 2); + assert!(!next.has_family(FAMILY)); + assert_eq!(next.face(FAMILY, "Regular").family, super::super::FALLBACK_FAMILY); + assert!(!path.exists()); + assert!(next.user_fonts().is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn stored_names_are_safe_and_unique() { + let ttf = renamed("SourceSans3-Regular.ttf", FAMILY); + assert_eq!(stored_name("C:\\x\\..\\a.b.TTF", &ttf), "a_b.ttf"); + assert_eq!(stored_name("", &ttf), "font.ttf"); + assert_eq!(stored_name("-rf.otf", b"OTTO"), "font-rf.otf"); + assert_eq!(unique_name("a.ttf", |n| n == "a.ttf" || n == "a-2.ttf"), "a-3.ttf"); + } +} diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index e5ac6f9819..15c9c2c8f0 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -13,7 +13,10 @@ mod script; mod ui_fallback; pub mod word; -pub use fontdb::{FALLBACK_FAMILY, FaceRef, FontDb, FontFace, LocalName, base_style, bundled, name_language_matches, system_font_dirs}; +pub use fontdb::{ + FALLBACK_FAMILY, FONT_FILE_EXTS, FaceRef, FontDb, FontFace, FontFormat, Installed, LocalName, MAX_FONT_FILE_BYTES, UserFont, UserFontError, + base_style, bundled, decode_font_file, is_font_file_name, name_language_matches, sniff, system_font_dirs, +}; pub use harfrust::Feature; use harfrust::{Direction, ShapeOptions, Tag, UnicodeBuffer}; pub use kurbo::BezPath; diff --git a/crates/fonts/src/word.rs b/crates/fonts/src/word.rs index 5cb79f5158..cc76077671 100644 --- a/crates/fonts/src/word.rs +++ b/crates/fonts/src/word.rs @@ -174,15 +174,24 @@ fn style_name(bold: bool, italic: bool) -> &'static str { } } -/// Resolve `family` with bold/italic to a face (memoised). +/// Resolve `family` with bold/italic to a face (memoised until user fonts are added or removed, +/// [`FontDb::generation`]). pub fn resolve(family: &str, bold: bool, italic: bool) -> Resolved { - static CACHE: OnceLock>> = OnceLock::new(); - let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new())); - let key = (family.to_string(), bold, italic); - if let Some(r) = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&key) { - return *r; - } + type Memo = (u64, HashMap<(String, bool, bool), Resolved>); + static CACHE: OnceLock> = OnceLock::new(); + let cache = CACHE.get_or_init(|| Mutex::new((0, HashMap::new()))); let db = FontDb::global(); + let generation = db.generation(); + let key = (family.to_string(), bold, italic); + { + let mut c = cache.lock().unwrap_or_else(|e| e.into_inner()); + if c.0 != generation { + *c = (generation, HashMap::new()); + } + if let Some(r) = c.1.get(&key) { + return *r; + } + } let (fam, substituted) = if db.has_family(family) { (family.to_string(), false) } else { @@ -191,7 +200,10 @@ pub fn resolve(family: &str, bold: bool, italic: bool) -> Resolved { }; let face = db.face(&fam, style_name(bold, italic)); let r = Resolved { synth_bold: bold && face.weight < 600.0, synth_italic: italic && !face.italic, substituted, face: FaceRef::of(&face) }; - cache.lock().unwrap_or_else(|e| e.into_inner()).insert(key, r); + let mut c = cache.lock().unwrap_or_else(|e| e.into_inner()); + if c.0 == generation { + c.1.insert(key, r); + } r } diff --git a/crates/ui-egui/src/file_dialogs.rs b/crates/ui-egui/src/file_dialogs.rs index 3e793791d3..939a8fb9a5 100644 --- a/crates/ui-egui/src/file_dialogs.rs +++ b/crates/ui-egui/src/file_dialogs.rs @@ -16,7 +16,7 @@ use crate::WordApp; /// A file dialog the app asks the host to show ([`Services::file_dialog`](crate::Services::file_dialog)). #[derive(Clone, Debug, PartialEq, Eq)] pub enum FileDialogRequest { - /// Pick a file to open; `purpose` says what for (`document`, `picture`, `recipients`), as for + /// Pick a file to open; `purpose` says what for (`document`, `picture`, `recipients`, `font`), as for /// `pick_open`. Open { purpose: String }, /// Pick a path to save to, given a suggested file name. @@ -37,6 +37,8 @@ pub(crate) enum AfterPick { PrintPdf { settings: Value }, /// Load the picked CSV/TSV/text file or spreadsheet as the mail-merge recipients (#240, #334). Recipients, + /// Add the picked font file (#582). + Font, /// Hand the picked path to the open dialog (`WordApp::dialog_pick`): Templates and Add-ins, /// Organizer (#377). ForDialog, @@ -130,6 +132,10 @@ impl WordApp { }; self.insert_or_change_picture(json!({"path": path})) } + AfterPick::Font => { + let Some(path) = picked else { return Ok(json!({"cancelled": true})) }; + self.run("font.add", json!({"path": path})) + } AfterPick::ForDialog => { let picked = picked.filter(|p| !p.is_empty()); let r = json!({"picked": picked}); @@ -226,6 +232,15 @@ impl WordApp { } let _ = self.ask_file(FileDialogRequest::Open { purpose: "picture".into() }, AfterPick::Picture); } + + /// Pick a font file to add (Add Font…; web: it arrives through the inbox). + pub(crate) fn pick_font(&mut self) { + if let Some(f) = &self.services.open_async { + f("font"); + return; + } + let _ = self.ask_file(FileDialogRequest::Open { purpose: "font".into() }, AfterPick::Font); + } } #[cfg(test)] diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 8bc4f8b7d9..12094d3fd5 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1537,6 +1537,9 @@ New Group Nuevo grupo Rename… Cambiar nombre… Ribbon and Quick Access Toolbar: Cinta de opciones y barra de acceso rápido: + Add Font… Agregar fuente… + Added Fonts Fuentes agregadas + Remove Added Font Quitar fuente agregada Recent Colors Colores recientes More Colors… Más colores… Standard Estándar diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index b1f39d3ec2..d6fdc45d8b 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -1523,6 +1523,9 @@ Ribbon Lint Ribbon and Quick Access Toolbar: Lint ja kiirpääsuriba: Ribbon tabs Lindi vahekaardid + Add Font… Lisa font… + Added Fonts Lisatud fondid + Remove Added Font Eemalda lisatud font Recent Colors Viimati kasutatud värvid More Colors… Rohkem värve… Standard Standard diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 2217bde16f..eeb92894f0 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1521,6 +1521,9 @@ New Group 新しいグループ Rename… 名前の変更… Ribbon and Quick Access Toolbar: リボンとクイック アクセス ツール バー: + Add Font… フォントの追加… + Added Fonts 追加したフォント + Remove Added Font 追加したフォントの削除 Recent Colors 最近使用した色 More Colors… その他の色… Standard 標準 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 72d356b0e6..508e078f22 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1533,6 +1533,9 @@ New Group Novo Grupo Rename… Renomear… Ribbon and Quick Access Toolbar: Faixa de Opções e Barra de Acesso Rápido: + Add Font… Adicionar fonte… + Added Fonts Fontes adicionadas + Remove Added Font Remover fonte adicionada Recent Colors Cores recentes More Colors… Mais cores… Standard Padrão diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 5bd9516d64..4db24be1e0 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1790,6 +1790,9 @@ preview Title Пример Left Only Только слева Right Only Только справа Largest Only Только по большей стороне + Add Font… Добавить шрифт… + Added Fonts Добавленные шрифты + Remove Added Font Удалить добавленный шрифт Print Markup Печатать исправления и примечания Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. Примечания рядом с каждой страницей и отслеживаемые изменения в PDF, при печати и в изображениях; список примечаний в конце веб-страниц, Markdown и текста. Recent Colors Последние цвета diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 6567ef753b..810226cd0d 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -1531,6 +1531,9 @@ New Group Nova grupa Rename… Preimenuj… Ribbon and Quick Access Toolbar: Traka i traka sa alatkama za brzi pristup: + Add Font… Dodaj font… + Added Fonts Dodati fontovi + Remove Added Font Ukloni dodati font Recent Colors Nedavne boje More Colors… Još boja… Standard Standardne diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index e20cccd28b..48b3c3ef46 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -1535,6 +1535,9 @@ New Group Нова група Rename… Преименуј… Ribbon and Quick Access Toolbar: Трака и трака са алаткама за брзи приступ: + Add Font… Додај фонт… + Added Fonts Додати фонтови + Remove Added Font Уклони додати фонт Recent Colors Недавне боје More Colors… Још боја… Standard Стандардне diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 148da1956b..bbf6b37516 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1408,6 +1408,9 @@ Left Only Yalnızca Sol Right Only Yalnızca Sağ Largest Only Yalnızca En Geniş + Add Font… Yazı Tipi Ekle… + Added Fonts Eklenen Yazı Tipleri + Remove Added Font Eklenen Yazı Tipini Kaldır Print Markup İşaretlemeyi Yazdır Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. PDF'lerde, baskılarda ve görüntülerde her sayfanın yanında açıklamalar ve izlenen değişiklikler; web sayfalarının, Markdown ve metnin sonunda açıklama listesi. Recent Colors Son Kullanılan Renkler diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 4bb4979284..f0cb28108a 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1533,6 +1533,9 @@ New Group Нова група Rename… Перейменувати… Ribbon and Quick Access Toolbar: Стрічка й панель швидкого доступу: + Add Font… Додати шрифт… + Added Fonts Додані шрифти + Remove Added Font Видалити доданий шрифт Recent Colors Останні кольори More Colors… Інші кольори… Standard Стандартні diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index ff55829b5b..baf7301bc0 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1533,6 +1533,9 @@ New Group 新建组 Rename… 重命名… Ribbon and Quick Access Toolbar: 功能区和快速访问工具栏: + Add Font… 添加字体… + Added Fonts 已添加的字体 + Remove Added Font 删除已添加的字体 Recent Colors 最近使用的颜色 More Colors… 其他颜色… Standard 标准 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 464268e989..dbb70d4b8b 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1522,6 +1522,9 @@ New Group 新增群組 Rename… 重新命名… Ribbon and Quick Access Toolbar: 功能區和快速存取工具列: + Add Font… 新增字型… + Added Fonts 已新增的字型 + Remove Added Font 移除已新增的字型 Recent Colors 最近使用的色彩 More Colors… 其他色彩… Standard 標準 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 47b7c6f1c8..81d54f74ac 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -97,6 +97,11 @@ pub struct Services { /// Desktop: the picture on the system clipboard, if any — egui's paste only carries text /// (#45). Read when Paste finds no text; see [`paste_picture`]. pub clipboard_picture: Option Option>>, + /// Web: keep a font the user added (stored file name, original bytes) for the next visit + /// (#582). Desktop fonts are stored by the font database itself, in the app's font folder. + pub store_font: Option>, + /// Web: forget a stored font (its stored file name) once the user removes it. + pub forget_font: Option>, } /// Files delivered asynchronously. @@ -258,6 +263,9 @@ pub struct WordApp { /// Web: a recipient list was asked for (Select Recipients › Use an Existing List…), so the /// next text file from the picker loads as recipients instead of opening (#240). pub recipient_list_pending: bool, + /// The font database's [`generation`](wordcraft_fonts::FontDb::generation) last seen: when + /// fonts are added or removed (by any route) the font lists and the layout are refreshed. + font_generation: u64, /// A file picked for the open dialog (Templates and Add-ins › Attach…, Organizer › /// Browse…); the dialog takes it on its next frame. pub(crate) dialog_pick: Option, @@ -377,6 +385,7 @@ impl WordApp { autosave_path: None, change_picture_target: None, recipient_list_pending: false, + font_generation: wordcraft_fonts::FontDb::global().generation(), dialog_pick: None, file_dialog: None, info_editing: None, @@ -559,6 +568,9 @@ impl WordApp { { self.autosave_path = self.session.path.clone(); } + if let Ok(v) = &r { + self.keep_web_font(id, ¶ms, v); + } self.after_command(id); if let Err(e) = &r { self.status(e.clone()); @@ -566,6 +578,44 @@ impl WordApp { r } + /// Web: store a font added from bytes, or forget the removed ones ([`Services::store_font`]). + fn keep_web_font(&self, id: &str, params: &Value, result: &Value) { + match id { + "font.add" => { + if let (Some(store), Some(file), Some(data)) = + (&self.services.store_font, result.get("file").and_then(Value::as_str), params.get("data").and_then(Value::as_str)) + && let Some(bytes) = wordcraft_engine::cmd::insert::base64_decode(data) + { + store(file, &bytes); + } + } + "font.remove" => { + if let Some(forget) = &self.services.forget_font { + for f in result.get("removed").and_then(Value::as_array).into_iter().flatten() { + if let Some(file) = f.get("file").and_then(Value::as_str) { + forget(file); + } + } + } + } + _ => {} + } + } + + /// Fonts were added or removed (a command, the web shell loading stored fonts): lay out + /// again and refresh the font lists and their previews. + fn watch_fonts(&mut self, ctx: &egui::Context) { + let db = wordcraft_fonts::FontDb::global(); + let generation = db.generation(); + if generation == self.font_generation { + return; + } + self.font_generation = generation; + self.session.relayout(); + self.previews.fonts_changed(ctx, db.user_fonts().iter().flat_map(|f| f.families())); + ctx.request_repaint(); + } + /// Answer the Save Changes prompt: Save (through Save As for a new document) and carry on, /// carry on without saving, or cancel. A failed or cancelled save cancels too. fn answer_save_changes(&mut self, choice: SaveChoice) -> Result { @@ -673,6 +723,7 @@ impl WordApp { self.save_as_dialog(); } "insertPicture" => self.pick_picture(), + "addFont" => self.pick_font(), "print" => { self.ui.backstage = true; self.ui.backstage_page = "print".into(); @@ -1003,6 +1054,7 @@ impl WordApp { self.clear_stale_change_picture(); let _ = self.poll_file_dialog(); self.drain_inbox(); + self.watch_fonts(ctx); self.autosave_tick(now_ms()); if self.autosaves() && self.session.dirty { ctx.request_repaint_after(std::time::Duration::from_millis(1000)); @@ -1024,6 +1076,8 @@ impl WordApp { } else if sheet { // A spreadsheet isn't a document: it's a recipient list (#334). self.load_recipients(json!({"path": path})) + } else if wordcraft_fonts::is_font_file_name(&lp) { + self.run("font.add", json!({"path": path})) } else { self.run("file.open", json!({"path": path})) }; @@ -1170,6 +1224,8 @@ impl WordApp { self.recipient_list_pending = asked && !list && !sheet; let r = if sheet { self.load_recipients(json!({"path": name, "data": wordcraft_engine::cmd::insert::base64_encode(&bytes)})) + } else if wordcraft_fonts::is_font_file_name(&lower) { + self.run("font.add", json!({"name": name, "data": wordcraft_engine::cmd::insert::base64_encode(&bytes)})) } else if list { self.load_recipients(json!({"csv": wordcraft_engine::io::decode_text(&bytes)})) } else if img { @@ -2911,6 +2967,50 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// Add Font… (#582), web: the picker asks for fonts, the picked file arrives through the + /// inbox and is added, listed in the font menu and kept in the browser; removing it forgets it. + #[test] + fn a_font_picked_on_the_web_is_added_listed_and_kept() { + const FAMILY: &str = "Uifont Sans 3"; + let mut font = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts/SourceSans3-Regular.ttf")).unwrap(); + 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(at) = font[i..].windows(from.len()).position(|w| w == from) { + font[i + at..i + at + to.len()].copy_from_slice(&to); + i += at + to.len(); + } + } + let mut a = typed(); + let asked = std::rc::Rc::new(std::cell::RefCell::new(String::new())); + let seen = asked.clone(); + a.services.open_async = Some(Box::new(move |purpose| *seen.borrow_mut() = purpose.to_string())); + let kept = std::rc::Rc::new(std::cell::RefCell::new(Vec::<(String, usize)>::new())); + let (k1, k2) = (kept.clone(), kept.clone()); + a.services.store_font = Some(Box::new(move |name, bytes| k1.borrow_mut().push((name.to_string(), bytes.len())))); + a.services.forget_font = Some(Box::new(move |name| k2.borrow_mut().retain(|(n, _)| n != name))); + let inbox: Inbox = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + a.services.inbox = Some(inbox.clone()); + let ctx = egui::Context::default(); + assert!(!a.previews.families().iter().any(|f| f == FAMILY)); + a.run("font.add", json!({})).unwrap(); + assert_eq!(*asked.borrow(), "font"); + inbox.lock().unwrap().push(("Uifont-Regular.ttf".into(), font.clone())); + a.drain_inbox(); + a.watch_fonts(&ctx); + assert!(a.previews.families().iter().any(|f| f == FAMILY), "listed in the font menu at once"); + assert_eq!(*kept.borrow(), vec![("Uifont-Regular.ttf".to_string(), font.len())]); + assert!(body_text(&a).contains(UNSAVED), "the document stays open"); + // Junk named like a font is refused, and nothing is kept. + inbox.lock().unwrap().push(("junk.woff2".into(), b"wOF2junk".to_vec())); + a.drain_inbox(); + assert_eq!(kept.borrow().len(), 1); + a.run("font.remove", json!({"family": FAMILY})).unwrap(); + a.watch_fonts(&ctx); + assert!(kept.borrow().is_empty()); + assert!(!a.previews.families().iter().any(|f| f == FAMILY)); + } + /// Web: the picked file arrives through the inbox and loads as recipients, even as .txt; a /// CSV dropped on the page does too. Anything else afterwards opens as before. #[test] diff --git a/crates/ui-egui/src/mini_toolbar.rs b/crates/ui-egui/src/mini_toolbar.rs index cc9a48a519..2aa3524433 100644 --- a/crates/ui-egui/src/mini_toolbar.rs +++ b/crates/ui-egui/src/mini_toolbar.rs @@ -132,8 +132,12 @@ fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens) { let fams = app.previews.families(); let prev = app.previews.font_preview_fn(); if let Some(f) = widgets::font_combo(ui, "mini_font", COMBO_FONT_W, &font, &fams, Some(&*prev)) { - let f = app.previews.font_stored(&f); - let _ = app.run("format.font", json!({"name": f})); + let _ = if f == crate::widgets::ADD_FONT { + app.run("font.add", json!({})) + } else { + let f = app.previews.font_stored(&f); + app.run("format.font", json!({"name": f})) + }; } let size = st.get("size").and_then(Value::as_f64).map(|s| if s.fract() == 0.0 { format!("{s:.0}") } else { s.to_string() }).unwrap_or_default(); diff --git a/crates/ui-egui/src/previews.rs b/crates/ui-egui/src/previews.rs index 5127d10e01..1fc566eb64 100644 --- a/crates/ui-egui/src/previews.rs +++ b/crates/ui-egui/src/previews.rs @@ -84,6 +84,17 @@ impl Previews { self.english_names.get(shown).cloned().unwrap_or_else(|| shown.to_string()) } + /// Fonts were added or removed: list the families again and redraw the entries of + /// `families` (an entry drawn before its font was added shows the fallback face). + pub fn fonts_changed(&mut self, ctx: &egui::Context, families: impl Iterator) { + self.families = None; + ctx.data_mut(|d| { + for f in families { + d.remove::(egui::Id::new(format!("font:{f}"))); + } + }); + } + /// A closure drawing a font-menu entry in its own face. pub fn font_preview_fn(&mut self) -> Box Response> { Box::new(|ui: &mut Ui, name: &str| { diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index edb8fad80d..0b51858b4e 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -414,8 +414,12 @@ fn home(app: &mut WordApp, ui: &mut Ui) { let fams = app.previews.families(); let prev = app.previews.font_preview_fn(); if let Some(f) = font_combo(ui, "font", 150.0, &font, &fams, Some(&*prev)) { - let f = app.previews.font_stored(&f); - let _ = app.run("format.font", json!({"name": f})); + let _ = if f == crate::widgets::ADD_FONT { + app.run("font.add", json!({})) + } else { + let f = app.previews.font_stored(&f); + app.run("format.font", json!({"name": f})) + }; } let size = st .get("size") diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 659d0f3ce9..ac028bb3b6 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -425,7 +425,12 @@ pub fn combo( combo_impl(ui, id, width, current, items, preview, false) } +/// What [`font_combo`] returns when its list's last entry, Add Font…, is chosen (#582): the +/// caller runs `font.add`, which shows a file picker. Not a family name anyone can type. +pub const ADD_FONT: &str = "\u{1}font.add"; + /// The font box: a [`combo`] where typing lists the matching families under the field (#359). +/// Its list ends with Add Font… ([`ADD_FONT`]). /// Up/Down move the highlight, Enter or a click applies the highlighted family ("arial" applies /// "Arial"), Escape restores the current font. Text matching no family is applied as typed, as /// Word allows fonts that are not installed. @@ -566,6 +571,13 @@ fn combo_impl( out = Some(v); ui.close(); } + if search { + ui.separator(); + if ui.button(tl!("Add Font…")).clicked() { + out = Some(ADD_FONT.to_string()); + ui.close(); + } + } }); if s.open && !s.matches.is_empty() { let matches = s.matches.clone(); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 9b941f37c9..616c915486 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (More Colors… and the Colors dialog, #606; previously trivial: Find and Replace special characters and wildcards, #543; previously trivial: Customize Ribbon and the Quick Access Toolbar, #379; previously: major, first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Add Font… ends the font list, #582; previously trivial: Outlining tab and outline keys, #493; previously trivial: Customize Ribbon and the Quick Access Toolbar, #379; previously: major, first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 How WordCraft looks and feels next to Word: ribbon, dialogs, panes, keyboard, mouse and touch. Typing behaviour has its own checklist ([`typing-parity.md`](typing-parity.md)). All assets are our @@ -60,6 +60,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | 2026-10-11 | trivial | Collapsed duplicate rows left by parallel merges | | 2026-10-11 | trivial | Dialogs keep their top edge in place when switching tabs (Paragraph, Font, Borders and Shading…): only the bottom edge moves (#507) | | 2026-10-11 | trivial | Interface size setting in File › Options › General (80–200 %), kept between runs and applied on top of the system's display scale; `ui.interfaceSize` for agents (#475) | +| 2026-10-11 | trivial | Font list ends with Add Font… (TTF, OTF, TTC, WOFF, WOFF2; also by dropping the file); added fonts are listed at once and kept between runs (#582) | | 2026-10-11 | trivial | Font box (ribbon and mini toolbar): typing lists the matching fonts (prefix first, then any part of the name); Up/Down, Enter or a click picks one, Escape cancels (#359) | | 2026-10-11 | trivial | Templates and Add-ins (template path, Attach…, automatically update document styles) and Organizer (copy styles from another file, delete, rename) dialogs (#377); styles update from the template only when the user asks (OK with the box ticked, or Update Styles), never on open; the three Modal dialogs rows merged into one | | 2026-10-11 | trivial | Field codes (#339): View › Field Codes (Alt+F9), Toggle Field Codes (Shift+F9), a field-code popup (double-click, right-click › Edit Field…; Ctrl+F9 opens the Field dialog), unlink/lock/unlock and next/previous field keys; field items in the context menu | From 219b125eeb362ff8947d854039faf707b3c006c6 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Mon, 12 Oct 2026 06:41:03 +0200 Subject: [PATCH 65/89] #661: Table of Authorities buttons labelled, with Update Table (#693) Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/engine/src/catalog.rs | 1 + crates/engine/src/cmd/citations.rs | 22 ++++++++++++++++++++++ crates/ui-egui/src/ribbon.rs | 14 +++++++++++++- docs/ui-parity.md | 3 ++- 4 files changed, 38 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 7efd4d893b..92d2cdf982 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -255,6 +255,7 @@ References|Index|Insert Index|references.index References|Index|Update Index|references.updateIndex References|Table of Authorities|Mark Citation|references.markCitation References|Table of Authorities|Insert Table of Authorities|references.tableOfAuthorities +References|Table of Authorities|Update Table|references.updateAuthorities References|Fields|Update Fields|references.updateFields Mailings|Create|Envelopes|mailings.envelopes Mailings|Create|Labels|mailings.labels diff --git a/crates/engine/src/cmd/citations.rs b/crates/engine/src/cmd/citations.rs index 4b40c93c41..37857ed35f 100644 --- a/crates/engine/src/cmd/citations.rs +++ b/crates/engine/src/cmd/citations.rs @@ -334,6 +334,10 @@ pub fn specs() -> Vec { generated_list(s, "TOA", "Table of Authorities")?; sel_result(s) }), + CommandSpec::new("references.updateAuthorities", "Update Table", "References › Table of Authorities", |s, _| { + update_generated(s)?; + sel_result(s) + }), CommandSpec::new("insert.crossReference", "Cross-reference", "Insert › Links", cross_ref) .params(r#"{"to": "heading|bookmark|figure|table", "target": string (text / name / number), "show"?: "text|page|number|aboveBelow"} or {} to list targets"#), CommandSpec::new("references.noteOptions", "Footnote and Endnote", "References › Footnotes", super::references::note_options).params( @@ -999,6 +1003,24 @@ mod tests { assert!(fields(&s).contains(&(format!("REF {name} \\h"), "Figure 1".to_string())), "{:?}", fields(&s)); } + /// #661: References › Table of Authorities › Update Table picks up citations marked after the table. + #[test] + fn table_of_authorities_updates() { + let mut s = Session::new(wordcraft_doc::Document::from_text("Smith v. Jones held it.\nDoe v. Roe agreed.")); + s.run("select.text", &json!({"text": "Smith v. Jones"})).unwrap(); + s.run("references.markCitation", &json!({})).unwrap(); + s.run("caret.docEnd", &json!({})).unwrap(); + s.run("text.newParagraph", &json!({})).unwrap(); + s.run("references.tableOfAuthorities", &json!({})).unwrap(); + let t = s.doc.plain_text(StoryRef::Body); + assert!(t.contains("Smith v. Jones") && !t.contains("Doe v. Roe, "), "{t}"); + s.run("select.text", &json!({"text": "Doe v. Roe"})).unwrap(); + s.run("references.markCitation", &json!({})).unwrap(); + s.run("references.updateAuthorities", &json!({})).unwrap(); + let after = s.doc.plain_text(StoryRef::Body); + assert!(after.matches("Doe v. Roe").count() > t.matches("Doe v. Roe").count(), "{after}"); + } + /// Field instructions and results in the body, in order. fn fields(s: &Session) -> Vec<(String, String)> { let mut out = Vec::new(); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 0b51858b4e..a6418e374d 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1278,7 +1278,19 @@ fn references(app: &mut WordApp, ui: &mut Ui) { }); group(ui, "Table of Authorities", None, app, |ui, app| { big(ui, app, "markCitation", "Mark\nCitation", "references.markCitation", json!({}), false); - small(ui, app, "tableOfAuthorities", None, "Insert Table of Authorities", "references.tableOfAuthorities", json!({}), false); + stack(ui, |ui| { + small( + ui, + app, + "tableOfAuthorities", + Some("Insert Table of Authorities"), + "Insert Table of Authorities", + "references.tableOfAuthorities", + json!({}), + false, + ); + small(ui, app, "update", Some("Update Table"), "Update Table", "references.updateAuthorities", json!({}), false); + }); }); } diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 616c915486..004ccdf2be 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Add Font… ends the font list, #582; previously trivial: Outlining tab and outline keys, #493; previously trivial: Customize Ribbon and the Quick Access Toolbar, #379; previously: major, first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Table of Authorities buttons labelled, with Update Table, #661; previously trivial: More Colors… and the Colors dialog, #606; previously trivial: Find and Replace special characters and wildcards, #543; previously trivial: Customize Ribbon and the Quick Access Toolbar, #379; previously: major, first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 How WordCraft looks and feels next to Word: ribbon, dialogs, panes, keyboard, mouse and touch. Typing behaviour has its own checklist ([`typing-parity.md`](typing-parity.md)). All assets are our @@ -54,6 +54,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | 2026-10-11 | trivial | Accessibility pane (errors/warnings/tips, jump-to, fixes) and Alt Text pane, opened from Picture/Shape Format › Accessibility and right-click › View Alt Text… (#386) | | 2026-10-11 | trivial | References dialogs: Caption (New/Delete Label, Numbering with chapter numbers), Index and Mark Index Entry (Mark All), Custom Table of Contents (Options, TOC styles via Modify Style), Source Manager and Create/Edit Source; modal dialog count 34 (#402) | | 2026-10-11 | trivial | Font dialog: Asian text font and Latin text font boxes, each with its own list and type-to-find, shown for a Chinese/Japanese interface or East Asian text in the selection; OK sets only the fonts changed; `format.font {"eastAsia"}` (#505) | +| 2026-10-11 | trivial | References › Table of Authorities: Insert Table of Authorities is labelled and joined by Update Table (`references.updateAuthorities`), as in the Index group (#661) | | 2026-10-11 | trivial | Customize Ribbon (custom tabs and groups, hidden and reordered tabs) and a customizable Quick Access Toolbar with a working … menu (#379, #376) | | 2026-10-11 | trivial | Customize Keyboard dialog (Tools › Customize Keyboard, File › Options › Keyboard shortcuts): custom keys override built-ins and persist (#368) | | 2026-10-11 | minor | Keyboard shortcuts 101 → 125 commands (#330): Reset Paragraph/Character Formatting, symbols, DATE/TIME/PAGE fields, view switches, Word Count, mail merge, F4 Repeat now reachable | From b2491a28dd323dd0a8c2eaf6b308478eedcd82a6 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Mon, 12 Oct 2026 06:43:19 +0200 Subject: [PATCH 66/89] #657: input borders reach 3:1 contrast in both themes (#696) Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/ui-egui/src/theme.rs | 28 ++++++++++++++++++++++++++-- docs/ui-parity.md | 3 ++- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index a6d330de74..b4901f67e7 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -194,7 +194,7 @@ impl Tokens { accent_text: APP_INK, on_accent: Color32::WHITE, input: Color32::WHITE, - input_border: Color32::from_rgb(0xC7, 0xC7, 0xC7), + input_border: Color32::from_rgb(0x8A, 0x8A, 0x8A), group_label: Color32::from_rgb(0x70, 0x70, 0x70), ruler: Color32::from_rgb(0xFF, 0xFF, 0xFF), ruler_margin: Color32::from_rgb(0xD4, 0xD4, 0xD4), @@ -234,7 +234,7 @@ impl Tokens { accent_text: Color32::from_rgb(0x9C, 0xB0, 0xF5), on_accent: Color32::WHITE, input: Color32::from_rgb(0x1E, 0x1E, 0x1E), - input_border: Color32::from_rgb(0x58, 0x58, 0x58), + input_border: Color32::from_rgb(0x7A, 0x7A, 0x7A), group_label: Color32::from_rgb(0xA0, 0xA0, 0xA0), ruler: Color32::from_rgb(0x2E, 0x2E, 0x2E), ruler_margin: Color32::from_rgb(0x45, 0x45, 0x45), @@ -555,6 +555,30 @@ pub fn c32(c: wordcraft_doc::Rgb) -> Color32 { mod tests { use std::collections::BTreeSet; + /// WCAG contrast ratio between two opaque colours. + pub(super) fn contrast(a: egui::Color32, b: egui::Color32) -> f32 { + let lum = |c: egui::Color32| { + let ch = |v: u8| { + let v = f32::from(v) / 255.0; + if v <= 0.040_45 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } + }; + 0.2126 * ch(c.r()) + 0.7152 * ch(c.g()) + 0.0722 * ch(c.b()) + }; + let (x, y) = (lum(a), lum(b)); + (x.max(y) + 0.05) / (x.min(y) + 0.05) + } + + /// #657: text field and combo outlines reach 3:1 against the field and everything it sits on. + #[test] + fn input_borders_reach_three_to_one() { + for t in [super::Tokens::light(), super::Tokens::dark()] { + for (name, bg) in [("input", t.input), ("ribbon", t.ribbon), ("panel", t.panel), ("menu", t.menu), ("title bar", t.title_bar)] { + let r = contrast(t.input_border, bg); + assert!(r >= 3.0, "dark={}: input_border on {name} is {r:.2}:1", t.dark); + } + } + } + /// #565: the composition of an input method (Bangla through ibus) drew as boxes because only the /// interface fonts were used. Faces found for the uncovered characters are added as fallbacks, /// once, and not for characters the interface fonts draw. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 004ccdf2be..7ef0126df7 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Table of Authorities buttons labelled, with Update Table, #661; previously trivial: More Colors… and the Colors dialog, #606; previously trivial: Find and Replace special characters and wildcards, #543; previously trivial: Customize Ribbon and the Quick Access Toolbar, #379; previously: major, first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (input borders reach 3:1, #657; previously trivial: Outlining tab and outline keys, #493; previously trivial: Customize Ribbon and the Quick Access Toolbar, #379; previously: major, first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 How WordCraft looks and feels next to Word: ribbon, dialogs, panes, keyboard, mouse and touch. Typing behaviour has its own checklist ([`typing-parity.md`](typing-parity.md)). All assets are our @@ -53,6 +53,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | 2026-10-11 | trivial | Line Numbers, Hyphenation (with Manual Hyphenation), Language, Envelopes and Labels dialogs (#407); the three duplicated modal-dialog rows merged into one | | 2026-10-11 | trivial | Accessibility pane (errors/warnings/tips, jump-to, fixes) and Alt Text pane, opened from Picture/Shape Format › Accessibility and right-click › View Alt Text… (#386) | | 2026-10-11 | trivial | References dialogs: Caption (New/Delete Label, Numbering with chapter numbers), Index and Mark Index Entry (Mark All), Custom Table of Contents (Options, TOC styles via Modify Style), Source Manager and Create/Edit Source; modal dialog count 34 (#402) | +| 2026-10-11 | trivial | Text field and combo outlines reach 3:1 against their fill and surroundings in both themes (light #8A8A8A, dark #7A7A7A); a test pins the ratios (#657) | | 2026-10-11 | trivial | Font dialog: Asian text font and Latin text font boxes, each with its own list and type-to-find, shown for a Chinese/Japanese interface or East Asian text in the selection; OK sets only the fonts changed; `format.font {"eastAsia"}` (#505) | | 2026-10-11 | trivial | References › Table of Authorities: Insert Table of Authorities is labelled and joined by Update Table (`references.updateAuthorities`), as in the Index group (#661) | | 2026-10-11 | trivial | Customize Ribbon (custom tabs and groups, hidden and reordered tabs) and a customizable Quick Access Toolbar with a working … menu (#379, #376) | From 05679497ea2669c8d038ed895fb8909f2c7d321b Mon Sep 17 00:00:00 2001 From: naxvog Date: Mon, 12 Oct 2026 06:45:53 +0200 Subject: [PATCH 67/89] Pictures: WMF drawings fill their frame like Word plays them (#739) Word plays a WMF into the picture's box the way a GDI player does: the placeable header's bounds are only the first window, mapped onto a viewport of the picture's size, and the file's own SETWINDOWORG and SETWINDOWEXT records replace that window. WordCraft mapped the header's bounds through the file's final mapping instead, which broke three pictures in docxide-pdf's fixtures: - bulgarian_farmland_allocation_order: the coat of arms draws in a window centred on the origin, nowhere near the header's bounds, so the lion was blown up into a solid black box. - ukrainian_municipal_heating_resolution: a portrait window replaced the landscape bounds, so the emblem was drawn at a fifth of its size. - A plain WMF in anisotropic mode that sets a window but no viewport kept the 1 x 1 default viewport and shrank to a point; its first window is now its target. Files that set their own viewport, or use the fixed-scale map modes, are read as before. SETROP2 is now read too: with R2_NOP, shapes and paths draw nothing (the coat of arms first draws its bounding rectangle that way, which showed as an outline). The mode is saved and restored with the DC. Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/metafile/src/canvas.rs | 53 ++++++++++++++++++++- crates/metafile/src/emf.rs | 1 + crates/metafile/src/tests.rs | 89 +++++++++++++++++++++++++++++++++++ crates/metafile/src/wmf.rs | 21 ++++++++- 4 files changed, 162 insertions(+), 2 deletions(-) diff --git a/crates/metafile/src/canvas.rs b/crates/metafile/src/canvas.rs index d553c81959..fa8bfdcf9f 100644 --- a/crates/metafile/src/canvas.rs +++ b/crates/metafile/src/canvas.rs @@ -8,6 +8,7 @@ use crate::{MAX_BYTES, MAX_ITEMS, MAX_OBJECTS, MAX_PATH_OPS, MAX_STACK}; pub(crate) const MM_TEXT: u32 = 1; const MM_ISOTROPIC: u32 = 7; +const MM_ANISOTROPIC: u32 = 8; const STOCK: u32 = 0x8000_0000; pub(crate) const PS_NULL: u32 = 5; /// Pen style bits; the rest of the style word holds end-cap, join and type flags. @@ -15,6 +16,8 @@ pub(crate) const PS_STYLE_MASK: u32 = 0x0F; const BS_SOLID: u32 = 0; pub(crate) const BS_NULL: u32 = 1; const WINDING: u32 = 2; +/// SETROP2 mix mode that leaves the destination alone: outlines and fills draw nothing. +const R2_NOP: u32 = 11; const PATCOPY: u32 = 0x00F0_0021; const BLACKNESS: u32 = 0x0000_0042; const WHITENESS: u32 = 0x00FF_0062; @@ -135,6 +138,19 @@ impl Mapping { det * (sx.abs() + sy.abs()) / 2.0 } + /// True when the window-to-viewport extents set the scale (the isotropic and anisotropic modes). + pub fn uses_extents(&self) -> bool { + matches!(self.mode, MM_ISOTROPIC | MM_ANISOTROPIC) + } + + pub fn window_ext(&self) -> P { + self.we + } + + pub fn viewport_ext(&self) -> P { + self.ve + } + /// The window in device units as `[x, y, w, h]` with positive extents, used as the picture frame /// of a plain WMF. pub fn window_rect(&self) -> [f64; 4] { @@ -160,6 +176,7 @@ struct Saved { pen: Pen, brush: Brush, even_odd: bool, + nop: bool, } pub(crate) struct Canvas { @@ -167,6 +184,8 @@ pub(crate) struct Canvas { pub map: Mapping, /// True once SETWINDOWEXT was seen, so a plain WMF has a frame to use. pub window_set: bool, + /// True once the file set the viewport extent itself (not the player, see [`Canvas::play_into`]). + pub viewport_set: bool, saved: Vec, /// Bytes held by the items so far (path ops and pixels), capped at [`MAX_BYTES`]. used: usize, @@ -174,6 +193,8 @@ pub(crate) struct Canvas { pen: Pen, brush: Brush, even_odd: bool, + /// The mix mode is R2_NOP: shapes draw nothing. + nop: bool, pos: P, in_path: bool, path: Vec, @@ -186,12 +207,14 @@ impl Canvas { items: Vec::new(), map: Mapping::identity(), window_set: false, + viewport_set: false, saved: Vec::new(), used: 0, objects: Vec::new(), pen: Pen { style: 0, width: 0.0, color: [0, 0, 0, 255] }, brush: Brush { style: BS_SOLID, color: [255, 255, 255, 255] }, even_odd: true, + nop: false, pos: (0.0, 0.0), in_path: false, path: Vec::new(), @@ -232,6 +255,22 @@ impl Canvas { pub fn set_viewport_ext(&mut self, e: P) { self.map.ve = e; + self.viewport_set = true; + } + + /// Sets up the mapping the way a player does before it plays a WMF into a target: anisotropic, + /// `window` (`[x, y, w, h]` in logical units) onto a viewport of `size` at the origin. The file's + /// own window records then replace the window; the viewport stays unless the file sets one. + /// Without a window (a plain WMF) only the viewport extent is set: the map mode and viewport + /// origin stay the file's. + pub fn play_into(&mut self, window: Option<[f64; 4]>, size: P) { + if let Some([x, y, w, h]) = window { + self.map.mode = MM_ANISOTROPIC; + self.map.wo = (x, y); + self.map.we = (w, h); + self.map.vo = (0.0, 0.0); + } + self.map.ve = size; } pub fn set_map_mode(&mut self, mode: u32) { @@ -249,6 +288,11 @@ impl Canvas { self.even_odd = mode != WINDING; } + /// SETROP2: only R2_NOP changes what is drawn (nothing); the other mix modes draw as copy. + pub fn set_rop2(&mut self, mode: u32) { + self.nop = mode == R2_NOP; + } + /// EMF SETWORLDTRANSFORM (`mode` 4) and MODIFYWORLDTRANSFORM (1 identity, 2 left, 3 right, 4 set). /// An unknown mode leaves the world transform as it was. pub fn modify_world(&mut self, x: Xform, mode: u32) { @@ -265,7 +309,7 @@ impl Canvas { /// could no longer match the file. pub fn save_dc(&mut self) { if self.saved.len() < MAX_STACK { - self.saved.push(Saved { map: self.map, pen: self.pen, brush: self.brush, even_odd: self.even_odd }); + self.saved.push(Saved { map: self.map, pen: self.pen, brush: self.brush, even_odd: self.even_odd, nop: self.nop }); } else { self.full = true; } @@ -281,6 +325,7 @@ impl Canvas { self.pen = s.pen; self.brush = s.brush; self.even_odd = s.even_odd; + self.nop = s.nop; } /// Stores an object in `slot` (EMF index), or in the first free slot (WMF handle table). @@ -381,6 +426,9 @@ impl Canvas { if self.in_path { return self.push_path_ops(dev); } + if self.nop { + return; + } let fill = if fill { self.fill_color() } else { None }; let stroke = if stroke { self.stroke_pen() } else { None }; if (fill.is_none() && stroke.is_none()) || dev.len() < 2 { @@ -460,6 +508,9 @@ impl Canvas { /// FILLPATH, STROKEPATH and STROKEANDFILLPATH: paints the bracketed path and empties it. pub fn paint_path(&mut self, fill: bool, stroke: bool) { let ops = std::mem::take(&mut self.path); + if self.nop { + return; + } let fill = if fill { self.fill_color() } else { None }; let stroke = if stroke { self.stroke_pen() } else { None }; if ops.len() < 2 || (fill.is_none() && stroke.is_none()) { diff --git a/crates/metafile/src/emf.rs b/crates/metafile/src/emf.rs index 6f32486978..027cfae9f9 100644 --- a/crates/metafile/src/emf.rs +++ b/crates/metafile/src/emf.rs @@ -88,6 +88,7 @@ fn record(cv: &mut Canvas, typ: u32, r: Bytes) { 12 => xy32(r, 8).map(|p| cv.set_viewport_org(p)), 17 => r.u32(8).map(|m| cv.set_map_mode(m)), 19 => r.u32(8).map(|m| cv.set_polyfill(m)), + 20 => r.u32(8).map(|m| cv.set_rop2(m)), 33 => { cv.save_dc(); Some(()) diff --git a/crates/metafile/src/tests.rs b/crates/metafile/src/tests.rs index 9fab9bea73..4ee32578ee 100644 --- a/crates/metafile/src/tests.rs +++ b/crates/metafile/src/tests.rs @@ -927,3 +927,92 @@ fn take_pixels_returns_only_drawable_bitmaps() { assert!(pic(1.0, 2.0).has_size()); assert!(!pic(0.0, 2.0).has_size() && !pic(f32::NAN, 2.0).has_size() && !pic(1.0, f32::INFINITY).has_size()); } + +/// The rectangle a WMF RECTANGLE record covers, from its path's corners. +fn rect_extent(item: &Item) -> (f32, f32, f32, f32) { + let (segs, ..) = path_segs(item); + let pts: Vec<(f32, f32)> = segs + .iter() + .filter_map(|s| match s { + Seg::Move(x, y) | Seg::Line(x, y) => Some((*x, *y)), + _ => None, + }) + .collect(); + let (xs, ys): (Vec, Vec) = pts.into_iter().unzip(); + let lo = |v: &[f32]| v.iter().copied().fold(f32::MAX, f32::min); + let hi = |v: &[f32]| v.iter().copied().fold(f32::MIN, f32::max); + (lo(&xs), lo(&ys), hi(&xs), hi(&ys)) +} + +#[test] +fn plain_anisotropic_wmf_without_a_viewport_fills_its_window() { + // Anisotropic, window 200 x 100, no viewport (a logo strip saved without a placeable header): the + // window is the target, not a 1 x 1 viewport that squeezes the picture to a point. + let recs = [ + wrec(0x0103, &h16(8)), + wrec(0x020B, &cat(vec![h16(0), h16(0)])), + wrec(0x020C, &cat(vec![h16(100), h16(200)])), + wrec(0x041B, &cat(vec![h16(100), h16(200), h16(0), h16(0)])), + ]; + let p = parse(&wmf(&recs, None)).unwrap(); + assert_eq!((p.width, p.height), (200.0, 100.0)); + let (x0, y0, x1, y1) = rect_extent(&p.items[0]); + assert!(close(x0, 0.0) && close(y0, 0.0) && close(x1, 200.0) && close(y1, 100.0), "{x0} {y0} {x1} {y1}"); +} + +#[test] +fn placeable_wmf_window_records_replace_the_bounds() { + // The placeable bounds say 1440 square (72 points); the file then draws in a window centred on the + // origin, as a coat of arms saved by a drawing program does. The window maps onto the 72-point + // target, so the drawing fills it instead of being blown up around the bounds. + let recs = [ + wrec(0x020C, &cat(vec![h16(2000), h16(2000)])), + wrec(0x020B, &cat(vec![h16(-1000), h16(-1000)])), + wrec(0x041B, &cat(vec![h16(1000), h16(1000), h16(-1000), h16(-1000)])), + ]; + let p = parse(&wmf(&recs, Some([238, 539, 1678, 1979]))).unwrap(); + assert_eq!((p.width, p.height), (72.0, 72.0)); + let (x0, y0, x1, y1) = rect_extent(&p.items[0]); + assert!(close(x0, 0.0) && close(y0, 0.0) && close(x1, 72.0) && close(y1, 72.0), "{x0} {y0} {x1} {y1}"); +} + +#[test] +fn placeable_wmf_keeps_its_target_when_the_window_changes_shape() { + // Bounds 600 x 300 (30 x 15 points), then a portrait window 100 x 200 for a bitmap-sized drawing: + // the drawing still fills the target (Word stretches it to the picture's box). + let recs = [wrec(0x0103, &h16(8)), wrec(0x020C, &cat(vec![h16(200), h16(100)])), wrec(0x041B, &cat(vec![h16(200), h16(100), h16(0), h16(0)]))]; + let p = parse(&wmf(&recs, Some([0, 0, 600, 300]))).unwrap(); + assert_eq!((p.width, p.height), (30.0, 15.0)); + let (x0, y0, x1, y1) = rect_extent(&p.items[0]); + assert!(close(x0, 0.0) && close(y0, 0.0) && close(x1, 30.0) && close(y1, 15.0), "{x0} {y0} {x1} {y1}"); +} + +#[test] +fn rop2_nop_draws_nothing_until_the_mode_changes() { + // A coat of arms that first draws its bounding rectangle with the R2_NOP mix mode (no output), + // then its shapes with R2_COPYPEN. Restoring a saved state brings R2_NOP back. + let rect = |n: i32| wrec(0x041B, &cat(vec![h16(n), h16(n), h16(0), h16(0)])); + let recs = [wrec(0x0104, &h16(11)), rect(100), wrec(0x001E, &[]), wrec(0x0104, &h16(13)), rect(50), wrec(0x0127, &h16(-1)), rect(20)]; + let p = parse(&wmf(&recs, Some([0, 0, 100, 100]))).unwrap(); + assert_eq!(p.items.len(), 1, "{:?}", p.items); + let (_, _, x1, y1) = rect_extent(&p.items[0]); + // 50 of 100 units in a 5-point picture. + assert!(close(x1, 2.5) && close(y1, 2.5), "{x1} {y1}"); +} + +#[test] +fn plain_wmf_target_comes_from_a_window_that_was_read() { + // A window record too short to read, then the real one: the target is the real window, not the + // 1 x 1 default the short record left in place. A viewport origin set before it is kept. + let recs = [ + wrec(0x0103, &h16(8)), + wrec(0x020D, &cat(vec![h16(0), h16(0)])), + wrec(0x020C, &[]), + wrec(0x020C, &cat(vec![h16(100), h16(200)])), + wrec(0x041B, &cat(vec![h16(100), h16(200), h16(0), h16(0)])), + ]; + let p = parse(&wmf(&recs, None)).unwrap(); + assert_eq!((p.width, p.height), (200.0, 100.0)); + let (x0, y0, x1, y1) = rect_extent(&p.items[0]); + assert!(close(x0, 0.0) && close(y0, 0.0) && close(x1, 200.0) && close(y1, 100.0), "{x0} {y0} {x1} {y1}"); +} diff --git a/crates/metafile/src/wmf.rs b/crates/metafile/src/wmf.rs index e051471d87..d221a7655a 100644 --- a/crates/metafile/src/wmf.rs +++ b/crates/metafile/src/wmf.rs @@ -37,7 +37,13 @@ pub(crate) fn parse(input: Bytes) -> Result<(Canvas, Option), Error> { return Err(Error::BadHeader); } let mut pos = hdr.checked_add(hsize.checked_mul(2).ok_or(Error::BadHeader)?).ok_or(Error::BadHeader)?; + // Played into a target like Word does: the placeable bounds are the first window, the target (the + // picture size) the viewport. A plain WMF's target is its first window. let mut cv = Canvas::new(); + if let Some((c, size)) = bounds { + cv.play_into(Some([c[0], c[1], c[2] - c[0], c[3] - c[1]]), size); + } + let mut player_viewport = bounds.is_some(); let mut parsed = 0usize; while !cv.full() { let (Some(words), Some(func)) = (input.u32(pos), pos.checked_add(4).and_then(|o| input.u16(o))) else { break }; @@ -47,6 +53,11 @@ pub(crate) fn parse(input: Bytes) -> Result<(Canvas, Option), Error> { break; } record(&mut cv, func, Bytes(rec)); + if func == 0x020C && cv.window_set && !player_viewport && !cv.viewport_set { + let we = cv.map.window_ext(); + cv.play_into(None, (we.0.abs(), we.1.abs())); + player_viewport = true; + } parsed += 1; let Some(next) = pos.checked_add(len) else { break }; pos = next; @@ -55,12 +66,19 @@ pub(crate) fn parse(input: Bytes) -> Result<(Canvas, Option), Error> { return Err(Error::NoRecords); } let frame = match bounds { - // The drawing is in device units, so the logical bounds go through the final mapping. + // Played into its target: the frame is the target. + Some((_, size)) if !cv.viewport_set && cv.map.uses_extents() => Some(Frame { rect: [0.0, 0.0, size.0, size.1], size: Some(size) }), + // The file set its own viewport, so the drawing is in its device units and the logical bounds + // go through the final mapping. Some((c, size)) => { let (a, b) = (cv.map.device((c[0], c[1])), cv.map.device((c[2], c[3]))); let rect = [a.0.min(b.0), a.1.min(b.1), (b.0 - a.0).abs(), (b.1 - a.1).abs()]; (rect[2] > 0.0 && rect[3] > 0.0).then_some(Frame { rect, size: Some(size) }) } + None if player_viewport && !cv.viewport_set && cv.map.uses_extents() => { + let ve = cv.map.viewport_ext(); + Some(Frame { rect: [0.0, 0.0, ve.0, ve.1], size: None }) + } None if cv.window_set => Some(Frame { rect: cv.map.window_rect(), size: None }), None => None, }; @@ -74,6 +92,7 @@ fn record(cv: &mut Canvas, func: u16, r: Bytes) { let _ = match func { 0x0103 => r.u16(6).map(|m| cv.set_map_mode(u32::from(m))), 0x0106 => r.u16(6).map(|m| cv.set_polyfill(u32::from(m))), + 0x0104 => r.u16(6).map(|m| cv.set_rop2(u32::from(m))), // Y is stored before X in these records. 0x020B => xy16(r, 6).map(|p| cv.set_window_org(p)), 0x020C => xy16(r, 6).map(|p| cv.set_window_ext(p)), From 4fb9c2cb2ac390d588b164c62d1eca983bd03462 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 06:48:02 +0200 Subject: [PATCH 68/89] Ink: draw the live pen stroke in the dark-page colour while drawing, not only after lifting the pen (#730) (#746) Co-authored-by: Brandon Thomas --- crates/ui-egui/src/ink.rs | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/ink.rs b/crates/ui-egui/src/ink.rs index a1b2273829..2eebb6bf74 100644 --- a/crates/ui-egui/src/ink.rs +++ b/crates/ui-egui/src/ink.rs @@ -215,6 +215,14 @@ fn to_screen(rects: &[Rect], layout: &DocLayout, scale: f32, page: usize) -> Opt Some(move |[x, y]: [f32; 2]| pos2(r.min.x + x * s, r.min.y + y * s)) } +/// The on-screen colour of ink: on a dark page (View > Switch Modes) it is mapped like every other +/// document colour, so a stroke looks the same while it is drawn as after it is saved. +fn screen_ink(dark: bool, c: wordcraft_doc::Rgb, alpha: u8) -> Color32 { + let opts = wordcraft_render::RenderOptions { dark, ..Default::default() }; + let wordcraft_doc::Rgb(r, g, b) = opts.ink(c); + Color32::from_rgba_unmultiplied(r, g, b, alpha) +} + /// The live stroke, the lasso and its selection, and the replayed ink, drawn over the pages. pub fn paint(app: &WordApp, painter: &Painter, rects: &[Rect], layout: &DocLayout, scale: f32) { paint_replay(app, painter, rects, layout, scale); @@ -224,7 +232,7 @@ pub fn paint(app: &WordApp, painter: &Painter, rects: &[Rect], layout: &DocLayou let s = page_scale(rects, layout, scale, d.page).unwrap_or(scale); let set = app.session.view.draw.settings(d.tool); let alpha = (d.tool.alpha() * 255.0).round() as u8; - let color = Color32::from_rgba_unmultiplied(set.color.0, set.color.1, set.color.2, alpha); + let color = screen_ink(app.session.view.dark_mode, set.color, alpha); let pts: Vec = d.pts.iter().map(|p| map(*p)).collect(); let width = (set.width * s).max(1.0); if let [only] = pts.as_slice() { @@ -331,6 +339,8 @@ fn paint_replay(app: &WordApp, painter: &Painter, rects: &[Rect], layout: &DocLa let s = page_scale(rects, layout, scale, k.page).unwrap_or(scale); let frac = if k.dur > 0.0 { ((r.t - k.start) / k.dur).clamp(0.0, 1.0) } else { 1.0 }; let total: f32 = k.paths.iter().map(|p| path_len(p)).sum(); + let [cr, cg, cb, ca] = k.color.to_srgba_unmultiplied(); + let color = screen_ink(app.session.view.dark_mode, wordcraft_doc::Rgb(cr, cg, cb), ca); let mut left = total * frac; let width = (k.width * s).max(1.0); for path in &k.paths { @@ -353,11 +363,11 @@ fn paint_replay(app: &WordApp, painter: &Painter, rects: &[Rect], layout: &DocLa } match (pts.as_slice(), path.as_slice()) { (_, [only]) => { - painter.circle_filled(map(*only), width / 2.0, k.color); + painter.circle_filled(map(*only), width / 2.0, color); } ([], _) => {} _ => { - painter.add(egui::Shape::line(pts, Stroke::new(width, k.color))); + painter.add(egui::Shape::line(pts, Stroke::new(width, color))); } } if left <= 0.0 { @@ -428,3 +438,17 @@ pub fn replay_bar(app: &mut WordApp, ui: &Ui, area: Rect) { } ui.ctx().request_repaint(); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn live_ink_follows_the_dark_page() { + let black = wordcraft_doc::Rgb(0, 0, 0); + assert_eq!(screen_ink(false, black, 255), Color32::from_rgba_unmultiplied(0, 0, 0, 255)); + // Black pen on a dark page turns light, like the saved stroke. + let dark = screen_ink(true, black, 128); + assert_eq!(dark, Color32::from_rgba_unmultiplied(255, 255, 255, 128)); + } +} From cad4138e7ed26da6d9c44bc07b8ebe39a9eb4d8f Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 06:50:14 +0200 Subject: [PATCH 69/89] Backspace still erases while Shift is held (#707) (#747) Co-authored-by: Brandon Thomas --- crates/ui-egui/src/keys.rs | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/keys.rs b/crates/ui-egui/src/keys.rs index 00d9a4bf73..26e78d019c 100644 --- a/crates/ui-egui/src/keys.rs +++ b/crates/ui-egui/src/keys.rs @@ -112,17 +112,29 @@ fn dispatch(app: &mut WordApp, key: Key, m: Modifiers) -> bool { let _ = app.run(spec.id, json!({})); return true; } - // Shift extends caret movement. + // Shift extends caret movement; Shift+Backspace still erases (#707). if m.shift && let Some(spec) = app.session.keymap.resolve(®, &combo(m, name, false)) - && spec.id.starts_with("caret.") + && let Some(params) = shift_variant_params(spec.id) { - let _ = app.run(spec.id, json!({"extend": true})); + let _ = app.run(spec.id, params); return true; } false } +/// How a command bound to a plain key runs when Shift is also held and nothing is bound to the +/// shifted combo: caret moves extend the selection, Backspace erases as usual (#707). +fn shift_variant_params(id: &str) -> Option { + if id.starts_with("caret.") { + Some(json!({"extend": true})) + } else if id == "text.backspace" { + Some(json!({})) + } else { + None + } +} + /// F1–F12: shortcuts on these work without the canvas focused. fn is_function_key(key: Key) -> bool { matches!(key, Key::F1 | Key::F2 | Key::F3 | Key::F4 | Key::F5 | Key::F6 | Key::F7 | Key::F8 | Key::F9 | Key::F10 | Key::F11 | Key::F12) @@ -322,6 +334,13 @@ pub fn global_shortcuts(app: &mut WordApp, ctx: &egui::Context) { mod tests { use super::*; + #[test] + fn shift_backspace_still_erases() { + assert_eq!(shift_variant_params("text.backspace"), Some(json!({}))); + assert_eq!(shift_variant_params("caret.left"), Some(json!({"extend": true}))); + assert_eq!(shift_variant_params("text.delete"), None); + } + #[test] fn only_plain_paste_releases_paste_a_clipboard_picture() { let m = |command, shift, alt| egui::Modifiers { command, shift, alt, ..Default::default() }; From 1490d4e594093846c403bf3a6fdb5165b2db6823 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Mon, 12 Oct 2026 06:51:53 +0200 Subject: [PATCH 70/89] Dark theme: readable text on the accent (#655) (#704) * #657: input borders reach 3:1 contrast in both themes Co-Authored-By: Claude Opus 5.5 * #655: dark-theme accent controls use dark on_accent text (6:1) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/ui-egui/src/chrome.rs | 4 ++-- crates/ui-egui/src/ribbon.rs | 6 +++--- crates/ui-egui/src/theme.rs | 15 ++++++++++++++- docs/ui-parity.md | 3 ++- 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 69c4090cdf..a93a22cb66 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -132,7 +132,7 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) { let (r, resp) = ui.allocate_exact_size(vec2(30.0, 16.0), Sense::click()); let (fill, stroke, knob_fill) = match (&block, on) { (Some(_), _) => (t.input.gamma_multiply(0.5), t.border.gamma_multiply(0.7), t.text_dim.gamma_multiply(0.45)), - (None, true) => (t.accent, t.accent, egui::Color32::WHITE), + (None, true) => (t.accent, t.accent, t.on_accent), (None, false) => (t.input, t.border_strong, t.text_dim), }; ui.painter().rect(r, 8.0, fill, Stroke::new(1.0, stroke), egui::StrokeKind::Inside); @@ -161,7 +161,7 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) { let (r, resp) = ui.allocate_exact_size(vec2(control_h, control_h), Sense::click()); ui.painter().circle_filled(r.center(), control_h / 2.0 - 1.0, t.accent); let initials: String = app.session.user_initials().chars().take(2).collect(); - ui.painter().text(r.center(), Align2::CENTER_CENTER, initials, semibold(10.5), egui::Color32::WHITE); + ui.painter().text(r.center(), Align2::CENTER_CENTER, initials, semibold(10.5), t.on_accent); resp.on_hover_text(format!("{} — set your name in File › Options", app.session.author)); ui.add_space(8.0); if app.ui.show_discord { diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index a6418e374d..374ebf9141 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -163,10 +163,10 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { ui.painter(), Rect::from_center_size(pos2(r.min.x + 14.0, r.center().y), vec2(14.0, 14.0)), "share", - egui::Color32::WHITE, - egui::Color32::WHITE, + t.on_accent, + t.on_accent, ); - ui.painter().text(pos2(r.min.x + 26.0, r.center().y), Align2::LEFT_CENTER, share, medium(12.0), egui::Color32::WHITE); + ui.painter().text(pos2(r.min.x + 26.0, r.center().y), Align2::LEFT_CENTER, share, medium(12.0), t.on_accent); if resp.on_hover_text(tl!("Export a copy to share (PDF, Word document)")).clicked() { let _ = app.run("ui.backstage", json!({"value": true, "page": "export"})); } diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index b4901f67e7..27c18e399d 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -232,7 +232,8 @@ impl Tokens { checked: Color32::from_rgb(0x2E, 0x3A, 0x66), accent: Color32::from_rgb(0x7A, 0x93, 0xF0), accent_text: Color32::from_rgb(0x9C, 0xB0, 0xF5), - on_accent: Color32::WHITE, + // Dark text on the light dark-theme accent: white on #7a93f0 is only 2.9:1 (#655). + on_accent: Color32::from_rgb(0x11, 0x16, 0x2E), input: Color32::from_rgb(0x1E, 0x1E, 0x1E), input_border: Color32::from_rgb(0x7A, 0x7A, 0x7A), group_label: Color32::from_rgb(0xA0, 0xA0, 0xA0), @@ -568,6 +569,18 @@ mod tests { (x.max(y) + 0.05) / (x.min(y) + 0.05) } + /// #655: text and knobs on filled accent controls (Share, avatar, AutoSave, keytips) reach 4.5:1 + /// on the accent and on its hover shade, in both themes. + #[test] + fn on_accent_reads_on_the_accent() { + for t in [super::Tokens::light(), super::Tokens::dark()] { + for (name, bg) in [("accent", t.accent), ("hover", t.accent_text)] { + let r = contrast(t.on_accent, bg); + assert!(r >= 4.5, "dark={}: on_accent on {name} is {r:.2}:1", t.dark); + } + } + } + /// #657: text field and combo outlines reach 3:1 against the field and everything it sits on. #[test] fn input_borders_reach_three_to_one() { diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 7ef0126df7..eda6b7ca2d 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (input borders reach 3:1, #657; previously trivial: Outlining tab and outline keys, #493; previously trivial: Customize Ribbon and the Quick Access Toolbar, #379; previously: major, first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (dark-theme accent controls readable, #655; previously trivial: input borders reach 3:1, #657; previously trivial: Outlining tab and outline keys, #493; previously trivial: Customize Ribbon and the Quick Access Toolbar, #379; previously: major, first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 How WordCraft looks and feels next to Word: ribbon, dialogs, panes, keyboard, mouse and touch. Typing behaviour has its own checklist ([`typing-parity.md`](typing-parity.md)). All assets are our @@ -53,6 +53,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | 2026-10-11 | trivial | Line Numbers, Hyphenation (with Manual Hyphenation), Language, Envelopes and Labels dialogs (#407); the three duplicated modal-dialog rows merged into one | | 2026-10-11 | trivial | Accessibility pane (errors/warnings/tips, jump-to, fixes) and Alt Text pane, opened from Picture/Shape Format › Accessibility and right-click › View Alt Text… (#386) | | 2026-10-11 | trivial | References dialogs: Caption (New/Delete Label, Numbering with chapter numbers), Index and Mark Index Entry (Mark All), Custom Table of Contents (Options, TOC styles via Modify Style), Source Manager and Create/Edit Source; modal dialog count 34 (#402) | +| 2026-10-11 | trivial | Dark theme: Share, the avatar initials, the AutoSave knob and keytips use dark `on_accent` text on the light accent (6.2:1, 8.4:1 on hover; white was 2.9:1) (#655) | | 2026-10-11 | trivial | Text field and combo outlines reach 3:1 against their fill and surroundings in both themes (light #8A8A8A, dark #7A7A7A); a test pins the ratios (#657) | | 2026-10-11 | trivial | Font dialog: Asian text font and Latin text font boxes, each with its own list and type-to-find, shown for a Chinese/Japanese interface or East Asian text in the selection; OK sets only the fonts changed; `format.font {"eastAsia"}` (#505) | | 2026-10-11 | trivial | References › Table of Authorities: Insert Table of Authorities is labelled and joined by Update Table (`references.updateAuthorities`), as in the Index group (#661) | From 92d6165d4cdcc61546888408b2d1e01f6b45e0b4 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 06:54:25 +0200 Subject: [PATCH 71/89] Let dialogs be dragged by their title bar instead of pinning them to the screen centre (#732) (#748) * Let dialogs be dragged by their title bar instead of pinning them to the screen centre (#732) * rustfmt after merging main --------- Co-authored-by: Brandon Thomas --- crates/ui-egui/src/dialogs.rs | 79 +++++++++++++++++++++++++++-------- 1 file changed, 62 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 8f4d340e15..d6654a8849 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1242,35 +1242,41 @@ pub fn table_grid_picker(ui: &mut Ui, app: &mut WordApp) { } } -/// Where the open dialog's top edge sits, as (window id, offset below the top of the screen). +/// The open dialog, as (window id, frames it has been centred for). fn dialog_top_key() -> egui::Id { egui::Id::new("dialog_top") } -/// Anchors a dialog: centred on the frame it opens, then with its top edge held where that put -/// it, so a taller or shorter tab (Paragraph, Font, Borders and Shading…) only moves the bottom -/// edge and the tab strip stays under the mouse (#507). egui keeps it on screen if it grows past -/// the bottom. +/// Frames a dialog stays centred: the first one measures it, the second centres that size. +const DIALOG_CENTRED_FRAMES: u8 = 2; + +/// Places a dialog: centred for its first frames, then left where it is with its top-centre point +/// as the pivot, so a taller or shorter tab (Paragraph, Font, Borders and Shading…) only moves +/// the bottom edge and the tab strip stays under the mouse (#507). It is no longer anchored after +/// that, so dragging the title bar moves it (#732). egui keeps it on screen if it grows past the +/// bottom. fn place_dialog<'a>(ctx: &egui::Context, id: egui::Id, w: egui::Window<'a>) -> egui::Window<'a> { - match ctx.data(|d| d.get_temp::<(egui::Id, f32)>(dialog_top_key())) { - Some((open, top)) if open == id => w.anchor(egui::Align2::CENTER_TOP, vec2(0.0, top)), + let w = w.pivot(egui::Align2::CENTER_TOP); + match ctx.data(|d| d.get_temp::<(egui::Id, u8)>(dialog_top_key())) { + Some((open, frames)) if open == id && frames >= DIALOG_CENTRED_FRAMES => w, _ => w.anchor(egui::Align2::CENTER_CENTER, vec2(0.0, -40.0)), } } -/// After a dialog's first frame: holds its top edge where centring its measured size put it. -fn remember_dialog_top(ctx: &egui::Context, id: egui::Id, rect: egui::Rect) { - if ctx.data(|d| d.get_temp::<(egui::Id, f32)>(dialog_top_key())).is_some_and(|(open, _)| open == id) { - return; +/// After a frame of a dialog: counts the frames it has been centred for. +fn count_dialog_frame(ctx: &egui::Context, id: egui::Id) { + let frames = match ctx.data(|d| d.get_temp::<(egui::Id, u8)>(dialog_top_key())) { + Some((open, frames)) if open == id => frames, + _ => 0, + }; + if frames < DIALOG_CENTRED_FRAMES { + ctx.data_mut(|d| d.insert_temp(dialog_top_key(), (id, frames + 1))); } - let screen = ctx.content_rect(); - let top = (screen.center().y - 40.0 - rect.height() / 2.0 - screen.top()).max(0.0); - ctx.data_mut(|d| d.insert_temp(dialog_top_key(), (id, top))); } pub fn show(app: &mut WordApp, ctx: &egui::Context) { let Some(mut d) = app.dialog.take() else { - ctx.data_mut(|d| d.remove::<(egui::Id, f32)>(dialog_top_key())); + ctx.data_mut(|d| d.remove::<(egui::Id, u8)>(dialog_top_key())); return; }; let mut open = true; @@ -1333,8 +1339,8 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) { ui.set_min_width(340.0); close = body(app, ui, &mut d); }); - if let Some(r) = shown { - remember_dialog_top(ctx, id, r.response.rect); + if shown.is_some() { + count_dialog_frame(ctx, id); } if open && !close { app.dialog = Some(d); @@ -3414,4 +3420,43 @@ mod tests { let c = app.session.doc.para_at(&wordcraft_doc::Pos::body(0, 0)).map(|p| p.props_of_char(0).clone()).unwrap_or_default(); assert_eq!((c.small_caps, c.caps, c.size), (Some(false), Some(true), Some(14.0))); } + + /// A dialog was pinned by its anchor, so dragging its title bar did nothing (#732). + #[test] + fn dragging_a_dialog_title_bar_moves_it() { + let mut app = app_with("Some text"); + app.dialog = Dialog::open("paragraph", &mut app); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + for _ in 0..4 { + h.step(); + } + let id = egui::Id::new(("dialog", "Paragraph")); + let before = h.ctx.memory(|m| m.area_rect(id)).expect("dialog is open"); + let screen = h.ctx.content_rect(); + assert!((before.center().x - screen.center().x).abs() <= 1.0, "opens centred: {before:?} in {screen:?}"); + let grab = egui::pos2(before.center().x, before.top() + 10.0); + let drop = grab + vec2(-200.0, 120.0); + let button = + |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + h.event(egui::Event::PointerMoved(grab)); + h.step(); + h.event(button(grab, true)); + h.step(); + h.event(egui::Event::PointerMoved(drop)); + h.step(); + h.event(button(drop, false)); + for _ in 0..4 { + h.step(); + } + let after = h.ctx.memory(|m| m.area_rect(id)).expect("dialog is still open"); + assert!((after.min.x - (before.min.x - 200.0)).abs() <= 1.0, "moved left: {before:?} -> {after:?}"); + assert!((after.min.y - (before.min.y + 120.0)).abs() <= 1.0, "moved down: {before:?} -> {after:?}"); + } } From 37318985b570527bad2c5d908dd2e718609585fc Mon Sep 17 00:00:00 2001 From: naxvog Date: Mon, 12 Oct 2026 06:58:37 +0200 Subject: [PATCH 72/89] Layout: multiple line spacing adds its leading below the text, like Word (#719) Benchmarked against docxide-pdf's 390 Word-referenced fixtures, the largest systematic gap was the vertical position of text: WordCraft put the baseline one descent above the bottom of the line box, so all the extra leading of 1.15 or double spacing went above the text (1.8pt at 1.15-spaced 12pt). Word keeps the text one ascent below the line top and puts the extra leading below it; below single spacing it shrinks the whole box. The hhea line gap now counts toward the ascent instead of being split half above, half below. Line heights stay the same, but every descent is half a line gap smaller, so text in exact, at-least and grid-snapped lines (which keep the descent below the baseline or centre the glyph box) sits up to half a line gap lower: 0.25pt for 12pt Times New Roman. On the suite that brings those lines closer to Word as well (exact: 5 fixtures better, 1 worse; at-least: 5 better, none worse). On the suite: mean Jaccard 29.2 -> 33.5%, SSIM 49.4 -> 50.3%, median baseline offset from Word +0.68 -> -0.06pt, documents within 1pt 35 -> 51%. Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/fonts/src/word.rs | 30 ++++++++++++++++++++++-------- crates/layout/src/para.rs | 16 ++++++++++++++-- crates/layout/src/tests.rs | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 10 deletions(-) diff --git a/crates/fonts/src/word.rs b/crates/fonts/src/word.rs index cc76077671..66ef6a7ac0 100644 --- a/crates/fonts/src/word.rs +++ b/crates/fonts/src/word.rs @@ -235,9 +235,15 @@ pub fn symbol_font_char(family: &str, c: char) -> Option { Some(u) } +/// A line's (ascent, descent) from hhea values, both positive: the line gap goes above the ascent. +fn hhea_line(ascender: i16, descender: i16, line_gap: i16) -> (f64, f64) { + (f64::from(ascender) + f64::from(line_gap.max(0)), f64::from(descender).abs()) +} + /// Line metrics for a face in font units: (ascent, descent), both positive, the way word -/// processors measure single line spacing (OS/2 usWinAscent/usWinDescent, falling back to the -/// hhea values plus line gap). +/// processors measure single line spacing: the hhea values with the line gap above the ascent +/// (Word puts the first baseline that far below the line top), falling back to OS/2 +/// usWinAscent/usWinDescent. pub fn line_metrics(face: &crate::FontFace) -> (f64, f64) { static CACHE: OnceLock>> = OnceLock::new(); let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new())); @@ -246,12 +252,9 @@ pub fn line_metrics(face: &crate::FontFace) -> (f64, f64) { } let mut v = (face.ascent.max(1.0), face.descent.abs()); if let Some(f) = face.skrifa() { - // hhea ascender + descender + line gap (Word on macOS); OS/2 win metrics only when the - // hhea values are missing, and never more than twice the em. - let hhea = f.hhea().ok().map(|h| { - let gap = h.line_gap().to_i16().max(0) as f64; - (h.ascender().to_i16() as f64 + gap / 2.0, (h.descender().to_i16() as f64).abs() + gap / 2.0) - }); + // hhea ascender + descender + line gap (Word on macOS), the gap above the ascent; OS/2 + // win metrics only when the hhea values are missing, and never more than twice the em. + let hhea = f.hhea().ok().map(|h| hhea_line(h.ascender().to_i16(), h.descender().to_i16(), h.line_gap().to_i16())); let win = f.os2().ok().map(|o| (o.us_win_ascent() as f64, o.us_win_descent() as f64)); let ok = |m: &(f64, f64)| m.0 > 0.0 && m.0 + m.1 <= face.upem * 2.0; if let Some(m) = hhea.filter(ok) { @@ -268,6 +271,17 @@ pub fn line_metrics(face: &crate::FontFace) -> (f64, f64) { mod tests { use super::*; + #[test] + fn the_line_gap_goes_above_the_ascent() { + // Times New Roman (hhea 1825 / -443, gap 87), and a font with a large gap: Word puts the + // first baseline the ascent plus the whole gap below the line top. + assert_eq!(hhea_line(1825, -443, 87), (1912.0, 443.0)); + assert_eq!(hhea_line(1536, -512, 452), (1988.0, 512.0)); + // A negative gap adds nothing; the most negative descender doesn't overflow. + assert_eq!(hhea_line(1000, -300, -50), (1000.0, 300.0)); + assert_eq!(hhea_line(1000, i16::MIN, 0), (1000.0, 32768.0)); + } + #[test] fn unknown_family_substitutes() { let r = resolve("Definitely Not A Font 123", false, false); diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index c2a14486cc..93483ea3a8 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -1711,7 +1711,7 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, level: Optio let up = asc.max(obj_asc); top + ((height - up - desc) / 2.0).max(0.0) + up } else { - top + height - desc + top + baseline_offset(rp.line_spacing, asc, height, desc).max(obj_asc.min(height - desc)) }; // Alignment / justification (trailing spaces hang). @@ -1840,7 +1840,7 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, level: Optio lines.push(Line { top, height: h, - baseline: top + h - desc, + baseline: top + if grid.is_some() { h - desc } else { baseline_offset(rp.line_spacing, asc, h, desc) }, c0: n, c1: n, xs: vec![x0], @@ -1958,6 +1958,18 @@ fn reorder_line(pl: &ParaLayout, line: &mut Line, width: f32) { /// paragraph beside a floating table in a 21.3pt gap but not in an 18.6pt one. const MIN_SPAN: f32 = 20.0; +/// How far below a line's top its baseline sits. Multiple spacing at or above single keeps the +/// text at the top of the line and puts the extra leading below the baseline; below single it +/// shrinks the whole box, ascent included (Word's output for docxide-pdf's fixtures: 1.15-spaced +/// Times New Roman 12pt sits one ascent below the line top, not 1.8pt lower). Exact and at-least +/// spacing keep the descent below the baseline and the rest above it. +fn baseline_offset(spacing: LineSpacing, asc: f32, height: f32, desc: f32) -> f32 { + match spacing { + LineSpacing::Multiple(m) => asc * m.min(1.0), + LineSpacing::AtLeast(_) | LineSpacing::Exactly(_) => height - desc, + } +} + /// The spans of a row at `top` (about `h` tall) between `lo` and `hi` that text can use around /// the exclusions, left to right (Square wrapping uses both sides of an object). `Err(y)`: none /// here, try again at `y` (below what's in the way). diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index bd29cffff5..5714b3ca79 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -2010,6 +2010,40 @@ fn line_spacing_does_not_scale_pictures() { assert!((line.baseline - line.top - 144.0).abs() < 0.01, "the picture stands on the baseline"); } +#[test] +fn multiple_line_spacing_adds_its_leading_below_the_text() { + use wordcraft_doc::props::LineSpacing; + // (spacing, expected baseline below the line top as a share of the ascent) + for (spacing, share) in [(LineSpacing::Multiple(1.15), 1.0), (LineSpacing::Multiple(2.0), 1.0), (LineSpacing::Multiple(0.8), 0.8)] { + let mut d = Document::from_text(&"Several words wrap onto more lines. ".repeat(30)); + if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(0).map(std::sync::Arc::make_mut) { + p.props.line_spacing = Some(spacing); + } + let l = lay(&d); + let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() }; + let asc = para.styles[0].ascent; + assert!(para.lines.len() > 1); + for line in ¶.lines { + // Word's 1.15-spaced Times New Roman 12pt sits one ascent below the line top; the + // 0.15 extra goes below the descent (docxide-pdf's hyphenation fixtures). + assert!( + (line.baseline - line.top - asc * share).abs() < 0.01, + "{spacing:?}: baseline {} below the top, ascent {asc}", + line.baseline - line.top + ); + } + } + // Exact spacing still keeps the descent below the baseline. + let mut d = Document::from_text("Exact"); + if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(0).map(std::sync::Arc::make_mut) { + p.props.line_spacing = Some(LineSpacing::Exactly(30.0)); + } + let l = lay(&d); + let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() }; + let line = ¶.lines[0]; + assert!((line.top + line.height - line.baseline - para.styles[0].descent).abs() < 0.01); +} + #[test] fn body_starts_right_below_a_tall_header() { let mut d = Document::from_text("Body"); From 9a58b5ba331511927def98ef889fa49c8abe6e75 Mon Sep 17 00:00:00 2001 From: Debarghaya Mitra <175645893+GhostMitra@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:34:33 +0530 Subject: [PATCH 73/89] Web: add wordcraft.ico as favicon via Trunk (#717) Registers assets/app-icon/wordcraft.ico in apps/wordcraft-web/index.html using Trunk's rel=icon asset pipeline. Co-authored-by: Brandon Thomas --- apps/wordcraft-web/index.html | 1 + contributors/people.toml | 2 ++ 2 files changed, 3 insertions(+) diff --git a/apps/wordcraft-web/index.html b/apps/wordcraft-web/index.html index 83a02c281e..ca6ad46936 100644 --- a/apps/wordcraft-web/index.html +++ b/apps/wordcraft-web/index.html @@ -4,6 +4,7 @@ WordCraft +