From ad8c26442076e357f5bb88515b25dfbf73c81e2e Mon Sep 17 00:00:00 2001 From: rabis7575 Date: Mon, 12 Oct 2026 08:38:05 +0200 Subject: [PATCH 01/88] Tables: resize by dragging column borders, row borders and the corner handle (#217) (#600) Like Word: the pointer turns into a resize arrow over a column border or a row's bottom border, and a small handle shows past the table's bottom-right corner. Dragging shows a dashed guide; releasing runs the new `table.resizeDrag` command (page coordinates), so a drag is one undo step and the selection is kept. Esc cancels a drag. - Column border: the right neighbour gives or takes the difference, so the table keeps its width; Shift, or the table's right edge, changes only the column on the left. Columns stay at least 6 pt wide. - Row border: sets the row's "at least" height; the margins and border bands the layout adds are measured and taken off, so the row is drawn at the dragged height. - Corner handle: scales every column and the rows on that page. - Widths come from the drawn layout, so autofit and percentage widths don't make the result jump; a merged cell hides the border it covers. - Borders are hit-tested in the existing canvas mouse handler, only within a few pixels of a border, so clicks in cells still place the caret. Engine: crates/engine/src/cmd/table_resize.rs (7 tests). UI: crates/ui-egui/src/table_resize.rs, wired into canvas.rs; kittest tests drive real pointer events through the whole window (one undo step per drag, selection kept, clicks in cells still move the caret, Esc cancels). The command label is translated in all nine interface catalogs. Progress docs updated: gaps, ui-parity, target-app-parity, ROADMAP. `cargo xtask ci` passes (fmt, clippy, test, assets, layers, wasm). Co-authored-by: rabis <340973947+rabis7575@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/table_resize.rs | 542 ++++++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 9 + 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/lib.rs | 1 + crates/ui-egui/src/table_resize.rs | 304 +++++++++++++++ docs/gaps.md | 7 +- docs/target-app-parity.md | 7 +- docs/ui-parity.md | 4 +- 20 files changed, 883 insertions(+), 8 deletions(-) create mode 100644 crates/engine/src/cmd/table_resize.rs create mode 100644 crates/ui-egui/src/table_resize.rs diff --git a/ROADMAP.md b/ROADMAP.md index ad38d570e..39f71505b 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 (Animation effects setting landed; previously trivial: every measurement follows the Options unit, #755; 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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (table borders and the resize handle drag on the page, #217; 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 @@ -133,6 +133,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 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 | Tables resize with the mouse (#217): drag a column border (the neighbour makes up the difference; Shift moves one column), a row's bottom border, or the handle past the bottom-right corner to scale the table; a dashed guide while dragging, one undo step per drag; `table.resizeDrag` for agents | | 2026-10-11 | Animation effects setting: File › Options › Accessibility › Animation effects (Use system setting, On, Off). With the system's animations off (Windows' Animation effects, macOS' Reduce motion, the Linux/BSD desktop's setting, a browser's reduced motion) the page no longer smooth-scrolls: wheel notches and Ctrl+wheel zoom land at once, in the document and in every list and pane, and menus and buttons don't fade; `ui.animations` for agents | | 2026-10-11 | Linux/BSD: Open, Save As, Export and Insert Picture work without a file chooser portal or zenity (labwc, sway…): WordCraft shows its own file browser and says how to get the system dialog; File › Open › Browse in WordCraft… is always there (#576) | | 2026-10-11 | Every measurement follows File › Options › Advanced › Measurement units (#755): the Line Numbers "From text" distance and the Hyphenation zone, still fixed in inches after #485, now convert like every other dialog | @@ -204,6 +205,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Progress log: tables resize with the mouse (#217) | | 2026-10-11 | trivial | Progress log: Animation effects setting | | 2026-10-11 | trivial | Progress log: built-in file browser when the system file dialog is unavailable (#576) | | 2026-10-11 | trivial | Progress log: security review fixes (temp files, exported link schemes) | diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 5e8161a83..ab23672a6 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -39,6 +39,7 @@ pub mod smart_art; pub mod speech; pub mod table; pub mod table_draw; +pub mod table_resize; pub mod table_style; pub mod templates; pub mod text; @@ -74,6 +75,7 @@ pub fn registry() -> Registry { v.extend(table::specs()); v.extend(table_style::specs()); v.extend(table_draw::specs()); + v.extend(table_resize::specs()); v.extend(review::specs()); v.extend(file::specs()); v.extend(options::specs()); diff --git a/crates/engine/src/cmd/table_resize.rs b/crates/engine/src/cmd/table_resize.rs new file mode 100644 index 000000000..f7aab43e8 --- /dev/null +++ b/crates/engine/src/cmd/table_resize.rs @@ -0,0 +1,542 @@ +//! Resizing a table with the mouse, like Word: drag a border between columns (or the table's +//! right edge), a row's bottom border, or the small handle just outside the table's bottom-right +//! corner to scale the whole table. The geometry comes from the layout (what is on the page, in +//! page points), so the result is what the drag showed even when autofit or a percentage width +//! made the drawn columns differ from the stored grid. The app sends each finished drag here, +//! so every drag is one undo step. + +use serde_json::Value; +use wordcraft_doc::props::HeightRule; +use wordcraft_doc::{Document, Path, StoryRef, Table}; +use wordcraft_geom::Rect; +use wordcraft_layout::{DocLayout, Placed}; + +use super::sel_result; +use crate::{CmdError, CmdResult, CommandSpec, Session, p}; + +/// How near (points) a border has to be to the pointer, by default. +pub const REACH: f32 = 3.0; +/// The corner handle's side (points); it sits just outside the table's bottom-right corner. +pub const HANDLE: f32 = 6.0; +/// The narrowest column a drag leaves (points). +pub const MIN_COL: f32 = 6.0; +/// The lowest row a drag leaves (points). +pub const MIN_ROW: f32 = 4.0; +/// The largest column width or row height a drag sets (points; Word's 22 inches). +const MAX_LEN: f32 = 1584.0; + +pub fn specs() -> Vec { + vec![CommandSpec::new("table.resizeDrag", "Resize Table by Dragging", "Table Layout › Cell Size", resize_drag).params( + r#"{"page": n, "x": pt, "y": pt, "toX": pt, "toY": pt, "shift"?: bool, "reach"?: pt} (a mouse drag in page points, 0-based page, starting on a table border or the resize handle: a column border moves (the column to its right gives or takes the difference, so the table keeps its width; with shift, or on the table's right edge, only the column left of it changes); a row's bottom border sets that row's height; the handle just outside the bottom-right corner scales every column and the rows on that page. `reach` (default 3) is how near the start has to be.)"#, + )] +} + +/// What a press on the page would grab. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Edge { + /// The vertical border at grid line `line` (1 = between the first two columns; the last is + /// the table's right edge). + Column(usize), + /// The bottom border of row `row`. + Row(usize), + /// The resize handle at the bottom-right corner. + Corner, +} + +/// A row of the table as drawn on one page. +#[derive(Clone, Debug, PartialEq)] +pub struct DrawnRow { + pub row: usize, + pub top: f32, + pub bottom: f32, + /// Grid lines that are cell borders in this row (a merged cell hides the ones it covers). + edges: Vec, +} + +/// A table border (or the handle) under the pointer, with the table's drawn geometry. +#[derive(Clone, Debug, PartialEq)] +pub struct ResizeTarget { + pub edge: Edge, + pub story: StoryRef, + pub table: Path, + /// Drawn grid lines, page x, left to right (columns + 1 of them). + pub cols: Vec, + /// Drawn rows on this page, top to bottom. + pub rows: Vec, + /// Whether the table's last row is on this page (the handle is shown only then). + pub ends_here: bool, +} + +/// What a drag to a point would make of the table. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Plan { + /// New column widths (points), when the columns change. + pub widths: Option>, + /// New heights (points) of these rows. + pub heights: Vec<(usize, f32)>, +} + +impl ResizeTarget { + pub fn left(&self) -> f32 { + self.cols.first().copied().unwrap_or(0.0) + } + pub fn right(&self) -> f32 { + self.cols.last().copied().unwrap_or(0.0) + } + pub fn top(&self) -> f32 { + self.rows.first().map_or(0.0, |r| r.top) + } + pub fn bottom(&self) -> f32 { + self.rows.last().map_or(0.0, |r| r.bottom) + } + /// The drawn column widths. + pub fn widths(&self) -> Vec { + self.cols.windows(2).map(|w| (w[1] - w[0]).max(0.0)).collect() + } + /// The resize handle's square (page points). + pub fn handle(&self) -> Rect { + Rect::new(self.right() + 1.0, self.bottom() + 1.0, HANDLE, HANDLE) + } + + /// The new widths and heights a drag that moved the pointer by (`dx`, `dy`) points gives. + pub fn plan(&self, dx: f32, dy: f32, shift: bool) -> Plan { + let (dx, dy) = + (if dx.is_finite() { dx.clamp(-MAX_LEN, MAX_LEN) } else { 0.0 }, if dy.is_finite() { dy.clamp(-MAX_LEN, MAX_LEN) } else { 0.0 }); + let mut w = self.widths(); + match self.edge { + Edge::Column(line) => { + let Some(left) = line.checked_sub(1).and_then(|i| w.get(i).copied()) else { return Plan::default() }; + let right = w.get(line).copied(); + let (new_left, new_right) = match right { + // An inner border: the neighbour on the right makes up the difference. + Some(right) if !shift => { + let d = dx.clamp(MIN_COL - left, (right - MIN_COL).max(MIN_COL - left)); + (left + d, Some(right - d)) + } + // The right edge (or Shift): only the column on the left changes. + _ => ((left + dx).clamp(MIN_COL, MAX_LEN), None), + }; + if let Some(x) = w.get_mut(line - 1) { + *x = new_left; + } + if let (Some(r), Some(x)) = (new_right, w.get_mut(line)) { + *x = r; + } + Plan { widths: Some(w), heights: Vec::new() } + } + Edge::Row(row) => { + let Some(r) = self.rows.iter().find(|r| r.row == row) else { return Plan::default() }; + Plan { widths: None, heights: vec![(row, (r.bottom - r.top + dy).clamp(MIN_ROW, MAX_LEN))] } + } + Edge::Corner => { + let (width, height) = (self.right() - self.left(), self.bottom() - self.top()); + let kx = if width > 1.0 { ((width + dx) / width).clamp(0.01, 100.0) } else { 1.0 }; + let ky = if height > 1.0 { ((height + dy) / height).clamp(0.01, 100.0) } else { 1.0 }; + let widths = ((kx - 1.0).abs() > 0.001).then(|| w.iter().map(|x| (x * kx).clamp(MIN_COL, MAX_LEN)).collect()); + let heights = if (ky - 1.0).abs() > 0.001 { + self.rows.iter().map(|r| (r.row, ((r.bottom - r.top) * ky).clamp(MIN_ROW, MAX_LEN))).collect() + } else { + Vec::new() + }; + Plan { widths, heights } + } + } + } +} + +/// A table cell's area on a page. +struct CellBox { + rect: Rect, + table: Path, + story: StoryRef, + row: usize, + cell: usize, +} + +fn cell_boxes(l: &DocLayout, page: usize) -> Vec { + let Some(pg) = l.pages.get(page) else { return Vec::new() }; + pg.items + .iter() + .filter_map(|it| match it { + Placed::Cell { rect, table, row, cell, story, .. } => { + Some(CellBox { rect: *rect, table: table.clone(), story: *story, row: *row, cell: *cell }) + } + _ => None, + }) + .collect() +} + +/// The drawn geometry of one table on a page, from its cells' boxes. +fn geometry(t: &Table, boxes: &[&CellBox]) -> Option<(Vec, Vec)> { + let n = t.cols(); + if n == 0 || n > wordcraft_doc::table::MAX_COLS || boxes.is_empty() { + return None; + } + let mut lines: Vec> = vec![None; n + 1]; + let mut rows: Vec = Vec::new(); + for b in boxes { + let g = t.grid_col(b.row, b.cell); + let span = t.rows.get(b.row).and_then(|r| r.cells.get(b.cell)).map_or(1, wordcraft_doc::Cell::span); + let end = g.saturating_add(span).min(n); + if let Some(x) = lines.get_mut(g) { + x.get_or_insert(b.rect.x); + } + if let Some(x) = lines.get_mut(end) { + x.get_or_insert(b.rect.right()); + } + match rows.iter_mut().find(|r| r.row == b.row) { + Some(r) => { + r.top = r.top.min(b.rect.y); + r.bottom = r.bottom.max(b.rect.bottom()); + r.edges.extend([g, end]); + } + None => rows.push(DrawnRow { row: b.row, top: b.rect.y, bottom: b.rect.bottom(), edges: vec![g, end] }), + } + } + // A line no cell on this page ends at: between its drawn neighbours, as the grid shares it. + let (first, last) = (lines.first().copied().flatten()?, lines.last().copied().flatten()?); + let weight = |i: usize| t.grid.get(i).copied().filter(|w| t.grid.len() == n && w.is_finite() && *w > 0.0).unwrap_or(1.0); + let mut cols = Vec::with_capacity(n + 1); + let mut prev = (0usize, first); + for i in 0..=n { + let x = match lines.get(i).copied().flatten() { + Some(x) => x, + None => { + let next = (i + 1..=n).find_map(|j| lines.get(j).copied().flatten().map(|x| (j, x))).unwrap_or((n, last)); + let span: f32 = (prev.0..next.0).map(weight).sum(); + let part: f32 = (prev.0..i).map(weight).sum(); + prev.1 + (next.1 - prev.1) * if span > 0.0 { part / span } else { 0.0 } + } + }; + let x = x.max(cols.last().copied().unwrap_or(x)); + if lines.get(i).copied().flatten().is_some() { + prev = (i, x); + } + cols.push(x); + } + rows.sort_by_key(|r| r.row); + Some((cols, rows)) +} + +/// Every table drawn on a page, with its geometry there (`edge` is a placeholder). +fn tables_on(l: &DocLayout, doc: &Document, page: usize) -> Vec { + let boxes = cell_boxes(l, page); + let mut tables: Vec<(StoryRef, &Path)> = Vec::new(); + for b in &boxes { + if !tables.iter().any(|(s, p)| *s == b.story && **p == b.table) { + tables.push((b.story, &b.table)); + } + } + tables + .into_iter() + .filter_map(|(story, path)| { + let t = doc.table(story, path)?; + let mine: Vec<&CellBox> = boxes.iter().filter(|b| b.story == story && b.table == *path).collect(); + let (cols, rows) = geometry(t, &mine)?; + let ends_here = rows.last().is_some_and(|r| r.row + 1 == t.rows.len()); + Some(ResizeTarget { edge: Edge::Corner, story, table: path.clone(), cols, rows, ends_here }) + }) + .collect() +} + +fn on_handle(t: &ResizeTarget, x: f32, y: f32, reach: f32) -> bool { + let h = t.handle(); + t.ends_here && x >= h.x - reach / 2.0 && x <= h.right() + reach / 2.0 && y >= h.y - reach / 2.0 && y <= h.bottom() + reach / 2.0 +} + +/// The innermost table under page point (`x`, `y`) whose resize handle is on this page, or whose +/// handle is under the point: the one whose handle the app shows. +pub fn table_under(l: &DocLayout, doc: &Document, page: usize, x: f32, y: f32, reach: f32) -> Option { + if !x.is_finite() || !y.is_finite() { + return None; + } + let reach = if reach.is_finite() { reach.clamp(0.5, 24.0) } else { REACH }; + tables_on(l, doc, page) + .into_iter() + .filter(|t| t.ends_here && ((x >= t.left() && x <= t.right() && y >= t.top() && y <= t.bottom()) || on_handle(t, x, y, reach))) + .max_by_key(|t| t.table.0.len()) +} + +/// The table border or resize handle within `reach` points of page point (`x`, `y`): the +/// nearest, and of nested tables the innermost. +pub fn resize_target(l: &DocLayout, doc: &Document, page: usize, x: f32, y: f32, reach: f32) -> Option { + if !x.is_finite() || !y.is_finite() { + return None; + } + let reach = if reach.is_finite() { reach.clamp(0.5, 24.0) } else { REACH }; + let mut best: Option<(f32, usize, ResizeTarget)> = None; + for mut target in tables_on(l, doc, page) { + let depth = target.table.0.len(); + let mut offer = |d: f32, edge: Edge, target: &mut ResizeTarget| { + if d <= reach && best.as_ref().is_none_or(|(bd, bdepth, _)| d < *bd - 0.01 || (d <= *bd + 0.01 && depth > *bdepth)) { + target.edge = edge; + best = Some((d, depth, target.clone())); + } + }; + if on_handle(&target, x, y, reach) { + offer(0.0, Edge::Corner, &mut target); + } + let (left, right, top, bottom) = (target.left(), target.right(), target.top(), target.bottom()); + if x < left - reach || x > right + reach || y < top - reach || y > bottom + reach { + continue; + } + // Column borders: in the row under the pointer, only lines that are cell borders there. + let row_here = target.rows.iter().find(|r| y >= r.top && y <= r.bottom).cloned(); + if let Some(r) = &row_here { + for line in 1..target.cols.len() { + if r.edges.contains(&line) + && let Some(cx) = target.cols.get(line).copied() + { + offer((x - cx).abs(), Edge::Column(line), &mut target); + } + } + } + // Row borders: the bottom of each drawn row. + if x >= left && x <= right { + for r in target.rows.clone() { + offer((y - r.bottom).abs(), Edge::Row(r.row), &mut target); + } + } + } + best.map(|b| b.2) +} + +fn resize_drag(s: &mut Session, v: &Value) -> CmdResult { + let page = + p::u64(v, "page").map(|n| usize::try_from(n).unwrap_or(usize::MAX)).ok_or_else(|| CmdError::Params("`page` (number) is required".into()))?; + let (x, y) = (p::req_f32(v, "x")?, p::req_f32(v, "y")?); + let (to_x, to_y) = (p::req_f32(v, "toX")?, p::req_f32(v, "toY")?); + let shift = p::bool(v, "shift").unwrap_or(false); + let reach = p::f32(v, "reach").unwrap_or(REACH); + let l = s.layout(); + if l.pages.get(page).is_none() { + return Err(CmdError::Params(format!("no page {page}"))); + } + let target = + resize_target(&l, &s.doc, page, x, y, reach).ok_or_else(|| CmdError::Failed("there's no table border or resize handle there".into()))?; + let plan = target.plan(to_x - x, to_y - y, shift); + if plan.widths.is_none() && plan.heights.is_empty() { + return sel_result(s); + } + let t = s.doc.table_mut(target.story, &target.table)?; + if let Some(widths) = plan.widths + && widths.len() == t.cols() + { + t.grid = widths; + // The drag's widths are the table's now: no preferred table width scales them again. + t.props.width = None; + t.props.width_pct = None; + super::table::sync_cell_widths(t); + } + for (r, h) in &plan.heights { + if let Some(row) = t.rows.get_mut(*r) { + row.props.height = Some(*h); + if row.props.height_rule != HeightRule::Exact { + row.props.height_rule = HeightRule::AtLeast; + } + } + } + touch(s, &target)?; + if plan.heights.is_empty() { + return sel_result(s); + } + // An "at least" height leaves out the cells' margins and border bands, which the layout adds + // (and which depend on the table style): measure what the rows came to and take the extra + // off once. A row its text holds taller keeps the dragged height. + let drawn = drawn_heights(&s.layout(), page, &target); + let mut fixed = Vec::new(); + let t = s.doc.table_mut(target.story, &target.table)?; + for (r, want) in &plan.heights { + let Some(got) = drawn.iter().find(|(i, _)| i == r).map(|(_, h)| *h) else { continue }; + if let Some(row) = t.rows.get_mut(*r) + && row.props.height_rule == HeightRule::AtLeast + && got > want + 0.05 + { + row.props.height = Some((want - (got - want)).max(1.0)); + fixed.push((*r, *want, got)); + } + } + if fixed.is_empty() { + return sel_result(s); + } + touch(s, &target)?; + let again = drawn_heights(&s.layout(), page, &target); + let t = s.doc.table_mut(target.story, &target.table)?; + for (r, want, got) in fixed { + let now = again.iter().find(|(i, _)| *i == r).map_or(got, |(_, h)| *h); + if now >= got - 0.05 + && let Some(row) = t.rows.get_mut(r) + { + row.props.height = Some(want); + } + } + touch(s, &target)?; + sel_result(s) +} + +/// The drawn height of each of the target table's rows on `page`. +fn drawn_heights(l: &DocLayout, page: usize, target: &ResizeTarget) -> Vec<(usize, f32)> { + let mut out: Vec<(usize, f32)> = Vec::new(); + for b in cell_boxes(l, page).iter().filter(|b| b.story == target.story && b.table == target.table) { + match out.iter_mut().find(|(r, _)| *r == b.row) { + Some((_, h)) => *h = h.max(b.rect.h), + None => out.push((b.row, b.rect.h)), + } + } + out +} + +/// Mark the table's paragraphs changed, so the layout redoes them. +fn touch(s: &mut Session, target: &ResizeTarget) -> Result<(), CmdError> { + let story = target.story; + let paths: Vec = + s.doc.para_paths(story).into_iter().filter(|q| q.0.len() > target.table.0.len() && q.0.starts_with(&target.table.0)).collect(); + for q in paths { + s.doc.para_mut(story, &q)?.touch(); + } + // A new document revision: the next `layout()` lays the change out instead of reusing the + // cached pages. + s.touch(); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn first_table(s: &Session) -> Table { + s.doc.body.iter().find_map(|b| b.as_table()).cloned().unwrap() + } + + fn cell_rect(s: &mut Session, row: usize, cell: usize) -> Rect { + s.layout().pages[0] + .items + .iter() + .find_map(|it| match it { + Placed::Cell { rect, row: r, cell: c, .. } if *r == row && *c == cell => Some(*rect), + _ => None, + }) + .unwrap() + } + + fn table_session(rows: usize, cols: usize) -> Session { + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("insert.table", &json!({"rows": rows, "cols": cols})).unwrap(); + s + } + + /// Dragging the border between two columns moves it; the right neighbour gives up the room, + /// so the table keeps its width; one undo puts it back. + #[test] + fn a_column_border_drag_trades_width_with_the_neighbour() { + let mut s = table_session(2, 3); + let start = first_table(&s); + let a = cell_rect(&mut s, 0, 0); + let (b0, total0) = (cell_rect(&mut s, 0, 1), cell_rect(&mut s, 0, 2).right() - a.x); + let y = a.y + a.h / 2.0; + s.run("table.resizeDrag", &json!({"page": 0, "x": a.right() + 1.0, "y": y, "toX": a.right() + 31.0, "toY": y})).unwrap(); + let (a1, b1) = (cell_rect(&mut s, 0, 0), cell_rect(&mut s, 0, 1)); + assert!((a1.w - (a.w + 30.0)).abs() < 1.0, "{a:?} → {a1:?}"); + assert!((b1.w - (b0.w - 30.0)).abs() < 1.0, "{b0:?} → {b1:?}"); + let total1 = cell_rect(&mut s, 0, 2).right() - a1.x; + assert!((total1 - total0).abs() < 1.0, "the table keeps its width"); + // The second row follows: one border for the whole column. + assert!((cell_rect(&mut s, 1, 0).w - a1.w).abs() < 0.5); + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(first_table(&s), start, "one undo step"); + } + + /// With Shift only the left column changes and the table grows; the right edge does the same. + #[test] + fn shift_and_the_right_edge_change_the_table_width() { + let mut s = table_session(1, 2); + let (a, b) = (cell_rect(&mut s, 0, 0), cell_rect(&mut s, 0, 1)); + let y = a.y + a.h / 2.0; + s.run("table.resizeDrag", &json!({"page": 0, "x": a.right(), "y": y, "toX": a.right() - 40.0, "toY": y, "shift": true})).unwrap(); + let (a1, b1) = (cell_rect(&mut s, 0, 0), cell_rect(&mut s, 0, 1)); + assert!((a1.w - (a.w - 40.0)).abs() < 1.0 && (b1.w - b.w).abs() < 1.0, "{a1:?} {b1:?}"); + s.run("table.resizeDrag", &json!({"page": 0, "x": b1.right() - 1.0, "y": y, "toX": b1.right() - 51.0, "toY": y})).unwrap(); + let b2 = cell_rect(&mut s, 0, 1); + assert!((b2.w - (b1.w - 50.0)).abs() < 1.0, "{b1:?} → {b2:?}"); + } + + /// No column gets narrower than the minimum, however far the drag goes. + #[test] + fn column_drags_stop_at_the_minimum_width() { + let mut s = table_session(1, 2); + let (a, b) = (cell_rect(&mut s, 0, 0), cell_rect(&mut s, 0, 1)); + let y = a.y + a.h / 2.0; + s.run("table.resizeDrag", &json!({"page": 0, "x": a.right(), "y": y, "toX": a.right() + 5000.0, "toY": y})).unwrap(); + let t = first_table(&s); + assert!((t.grid[1] - MIN_COL).abs() < 0.01, "{:?}", t.grid); + assert!((t.grid[0] + t.grid[1] - (a.w + b.w)).abs() < 1.0); + } + + /// Dragging a row's bottom border sets its height ("at least"), and the row grows. + #[test] + fn a_row_border_drag_sets_the_row_height() { + let mut s = table_session(2, 2); + let a = cell_rect(&mut s, 0, 0); + let x = a.x + a.w / 2.0; + s.run("table.resizeDrag", &json!({"page": 0, "x": x, "y": a.bottom() + 1.0, "toX": x, "toY": a.bottom() + 41.0})).unwrap(); + let t = first_table(&s); + assert_eq!(t.rows[0].props.height_rule, HeightRule::AtLeast); + // The stored height leaves out the margins and borders; the drawn row is what was dragged. + assert!(t.rows[0].props.height.unwrap() < a.h + 40.0, "{:?}", t.rows[0].props); + let h = cell_rect(&mut s, 0, 0).h; + assert!((h - (a.h + 40.0)).abs() < 0.1, "{} → {h}", a.h); + assert_eq!(t.rows[1].props.height, None, "the other row is untouched"); + } + + /// The handle past the bottom-right corner scales every column and row. + #[test] + fn the_corner_handle_scales_the_table() { + let mut s = table_session(2, 2); + let (a, d) = (cell_rect(&mut s, 0, 0), cell_rect(&mut s, 1, 1)); + let (w, h) = (d.right() - a.x, d.bottom() - a.y); + let (hx, hy) = (d.right() + 1.0 + HANDLE / 2.0, d.bottom() + 1.0 + HANDLE / 2.0); + let l = s.layout(); + let tgt = resize_target(&l, &s.doc, 0, hx, hy, REACH).unwrap(); + assert_eq!(tgt.edge, Edge::Corner); + // Half as wide, twice as tall: the handle moves by that much. + s.run("table.resizeDrag", &json!({"page": 0, "x": hx, "y": hy, "toX": hx - w * 0.5, "toY": hy + h})).unwrap(); + let (a1, d1) = (cell_rect(&mut s, 0, 0), cell_rect(&mut s, 1, 1)); + assert!(((d1.right() - a1.x) - w * 0.5).abs() < 1.5, "{w} → {}", d1.right() - a1.x); + assert!(((d1.bottom() - a1.y) - h * 2.0).abs() < 0.5, "{h} → {}", d1.bottom() - a1.y); + } + + /// A merged cell hides the border it covers: no column border to grab in its middle. + #[test] + fn merged_cells_have_no_border_inside() { + let mut s = table_session(2, 2); + let a = cell_rect(&mut s, 0, 0); + s.run("table.eraser", &json!({"page": 0, "x": a.right(), "y": a.y + a.h / 2.0})).unwrap(); + let l = s.layout(); + let m = cell_rect(&mut s, 0, 0); + let mid = resize_target(&l, &s.doc, 0, a.right(), m.y + m.h / 2.0, REACH); + assert!(mid.as_ref().is_none_or(|t| t.edge != Edge::Column(1)), "{mid:?}"); + // The second row still has it. + let b = cell_rect(&mut s, 1, 0); + let t = resize_target(&l, &s.doc, 0, b.right(), b.y + b.h / 2.0, REACH).unwrap(); + assert_eq!(t.edge, Edge::Column(1)); + } + + /// Away from any border, or with hostile numbers: an error and no change, never a panic. + #[test] + fn misses_and_hostile_params_change_nothing() { + let mut s = table_session(2, 2); + let start = first_table(&s); + let c = cell_rect(&mut s, 0, 0); + let mid = json!({"page": 0, "x": c.x + c.w / 2.0, "y": c.y + c.h / 2.0, "toX": 10, "toY": 10}); + assert!(s.run("table.resizeDrag", &mid).is_err()); + assert!(s.run("table.resizeDrag", &json!({"page": 0, "x": 1e30, "y": -1e30, "toX": 1e30, "toY": 1e30})).is_err()); + assert!(s.run("table.resizeDrag", &json!({"page": 99, "x": 1, "y": 1, "toX": 1, "toY": 1})).is_err()); + assert!(s.run("table.resizeDrag", &json!({})).is_err()); + let y = c.y + c.h / 2.0; + s.run("table.resizeDrag", &json!({"page": 0, "x": c.right(), "y": y, "toX": f32::MAX, "toY": y, "reach": 1e9})).unwrap(); + assert!(first_table(&s).grid.iter().all(|w| w.is_finite() && *w >= MIN_COL)); + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(first_table(&s), start); + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 369ecc94e..9c448da1f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -95,6 +95,8 @@ pub struct CanvasState { pub table_tool: Option, /// The Draw Table stroke (or eraser press) in progress. pub(crate) table_stroke: Option, + /// A table border or the table's resize handle being dragged (`table_resize`). + pub(crate) table_resize: Option, /// The last key ran an Alt shortcut: the text its key sends (it may arrive a frame later) /// isn't typed. The next key press clears it. pub(crate) swallow_text: bool, @@ -155,6 +157,7 @@ impl Default for CanvasState { balloon_rects: Vec::new(), table_tool: None, table_stroke: None, + table_resize: None, link_from: None, a11y: Default::default(), } @@ -856,6 +859,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { crate::objects::paint(app, &painter, &t, &layout, &rects, geo.scale); crate::ink::paint(app, &painter, &rects, &layout, geo.scale); crate::table_pen::paint(app, &painter, &t, &layout, &rects, geo.scale); + crate::table_resize::paint(app, &painter, &t, &layout, &rects, geo.scale); (resp, rects) }); app.canvas.scroll_offset = out.state.offset; @@ -942,6 +946,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { let over_object = ui.input(|i| i.pointer.latest_pos()).and_then(|p| crate::objects::cursor(app, &layout, &rects, geo.scale, p)); ui.ctx().set_cursor_icon(over_object.unwrap_or(egui::CursorIcon::Text)); } + crate::table_resize::cursor(app, ui, &resp, &layout, &rects, geo.scale); crate::table_pen::cursor(app, ui, &resp); // While "Save changes?" is up, keys answer it rather than edit the document behind it. if app.canvas.focused && !matches!(app.dialog, Some(crate::dialogs::Dialog::SaveChanges { .. })) { @@ -1327,6 +1332,10 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo { return; } + // A press on a table border (or the table's resize handle) drags it instead of the caret. + if crate::table_resize::pointer(app, ui, resp, rects, layout, scale) { + return; + } let Some(p) = pointer else { return }; // While drag-selecting, the pointer may sit past the viewport's edge (or the window's): // hit-test where it would be inside the view, so as the edge auto-scroll moves the content the diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index f1f52fa8d..53849cc60 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1419,6 +1419,7 @@ Deleted Eliminado Interface size: Tamaño de la interfaz: Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Agranda o reduce la cinta, los menús y los cuadros de diálogo, además del escalado de pantalla del sistema. El zoom del documento es aparte. + Resize Table by Dragging Cambiar tamaño de tabla arrastrando Print Markup Imprimir marcas Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. Comentarios junto a cada página y cambios marcados en PDF, impresiones e imágenes; una lista de comentarios al final de páginas web, Markdown y texto. diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 82e80d79d..3e71a911b 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -1405,6 +1405,7 @@ Deleted Kustutatud Interface size: Kasutajaliidese suurus: Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Muudab menüülindi, menüüd ja dialoogid suuremaks või väiksemaks, lisaks süsteemi ekraani skaleerimisele. Dokumendi suum on eraldi. + Resize Table by Dragging Tabeli suuruse muutmine lohistades Print Markup Prindi märgistus Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. Kommentaarid iga lehe kõrval ja jälitatud muudatused PDF-ides, väljatrükkides ja piltides; kommentaaride loend veebilehtede, Markdowni ja teksti lõpus. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index da0f4672a..b4292b4ba 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1403,6 +1403,7 @@ Deleted 削除 Interface size: 表示サイズ: Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. リボン、メニュー、ダイアログを、システムの表示倍率に加えて拡大または縮小します。文書のズームは別に設定します。 + Resize Table by Dragging ドラッグで表のサイズを変更 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、印刷、画像では各ページの横にコメントと変更履歴を表示し、Web ページ、Markdown、テキストでは末尾にコメント一覧を付けます。 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index fa4b0ef68..88f87099b 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1415,6 +1415,7 @@ Deleted Excluído Interface size: Tamanho da interface: Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Aumenta ou diminui a faixa de opções, os menus e as caixas de diálogo, além da escala de exibição do sistema. O zoom do documento é separado. + Resize Table by Dragging Redimensionar tabela arrastando Print Markup Imprimir marcação Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. Comentários ao lado de cada página e alterações controladas em PDFs, impressões e imagens; uma lista de comentários no final de páginas da web, Markdown e texto. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index f5338fb6f..c1faccdcd 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1794,6 +1794,7 @@ preview Title Пример Left Only Только слева Right Only Только справа Largest Only Только по большей стороне + Resize Table by Dragging Изменение размера таблицы перетаскиванием Add Font… Добавить шрифт… Added Fonts Добавленные шрифты Remove Added Font Удалить добавленный шрифт diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 57d87244b..b5ad3b780 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -1413,6 +1413,7 @@ Deleted Izbrisano Interface size: Veličina interfejsa: Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Povećava ili smanjuje traku, menije i dijaloge, povrh skaliranja ekrana u sistemu. Zumiranje dokumenta je zasebno. + Resize Table by Dragging Promena veličine tabele prevlačenjem Print Markup Štampaj oznake Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. Komentari pored svake stranice i praćene izmene u PDF-ovima, otiscima i slikama; lista komentara na kraju veb-stranica, Markdown-a i teksta. diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 519efcce1..2ff4eb77d 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -1417,6 +1417,7 @@ Deleted Избрисано Interface size: Величина интерфејса: Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Повећава или смањује траку, меније и дијалоге, поврх скалирања екрана у систему. Зумирање документа је засебно. + Resize Table by Dragging Промена величине табеле превлачењем 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-а и текста. diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 4a9017b82..6a13a1755 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1412,6 +1412,7 @@ Left Only Yalnızca Sol Right Only Yalnızca Sağ Largest Only Yalnızca En Geniş + Resize Table by Dragging Tabloyu Sürükleyerek Yeniden Boyutlandır Add Font… Yazı Tipi Ekle… Added Fonts Eklenen Yazı Tipleri Remove Added Font Eklenen Yazı Tipini Kaldır diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 02212e733..1d52b614c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1415,6 +1415,7 @@ Deleted Видалено Interface size: Розмір інтерфейсу: Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Збільшує або зменшує стрічку, меню й діалогові вікна понад системне масштабування екрана. Масштаб документа налаштовується окремо. + Resize Table by Dragging Змінити розмір таблиці перетягуванням 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 і тексту. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index cbc542930..903a040a7 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1415,6 +1415,7 @@ Deleted 已删除 Interface size: 界面大小: Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. 在系统显示缩放的基础上放大或缩小功能区、菜单和对话框。文档缩放单独设置。 + Resize Table by Dragging 拖动调整表格大小 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 和文本末尾附上批注列表。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index ac5605cfd..c1c784790 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1404,6 +1404,7 @@ Deleted 已刪除 Interface size: 介面大小: Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. 在系統顯示縮放的基礎上放大或縮小功能區、功能表與對話方塊。文件縮放另行設定。 + Resize Table by Dragging 拖曳調整表格大小 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 和文字末尾附上註解清單。 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 29b9a2569..a890f15b1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -59,6 +59,7 @@ pub mod read_aloud; pub mod ribbon; pub mod scroll; pub mod table_pen; +pub mod table_resize; pub mod theme; pub mod widgets; pub mod window_geometry; diff --git a/crates/ui-egui/src/table_resize.rs b/crates/ui-egui/src/table_resize.rs new file mode 100644 index 000000000..81aec8771 --- /dev/null +++ b/crates/ui-egui/src/table_resize.rs @@ -0,0 +1,304 @@ +//! Resizing tables with the mouse, like Word: over a column border the pointer turns into a +//! column-resize arrow, over a row's bottom border a row-resize arrow; over a table a small +//! handle shows past its bottom-right corner. Dragging shows a dashed preview of the new border +//! (or outline); releasing runs `table.resizeDrag` with page coordinates, one undo step. Shift +//! while releasing a column border changes only the column on its left. The geometry decisions +//! are the engine's (`cmd::table_resize`). + +use egui::{Pos2, Rect, Stroke, Ui, pos2}; +use serde_json::json; +use wordcraft_engine::cmd::table_resize::{self, Edge, ResizeTarget}; +use wordcraft_layout::DocLayout; + +use crate::WordApp; +use crate::theme::Tokens; + +/// How near (screen pixels) the pointer has to be to a border to grab it. +const REACH_PX: f32 = 4.0; + +/// A table border (or the handle) being dragged. +#[derive(Clone, Debug)] +pub(crate) struct ResizeDrag { + page: usize, + from: (f32, f32), + to: (f32, f32), + target: ResizeTarget, +} + +/// Screen pixels per page point on `page`. +fn page_scale(rects: &[Rect], layout: &DocLayout, scale: f32, page: usize) -> Option { + let r = rects.get(page)?; + let sc = layout.pages.get(page).filter(|pg| pg.w > 0.0).map_or(scale, |pg| r.width() / pg.w); + (sc > 0.0 && sc.is_finite()).then_some(sc) +} + +fn to_screen(rects: &[Rect], layout: &DocLayout, scale: f32, page: usize, x: f32, y: f32) -> Option { + let r = rects.get(page)?; + let sc = page_scale(rects, layout, scale, page)?; + Some(pos2(r.min.x + x * sc, r.min.y + y * sc)) +} + +/// Whether table resizing is in reach: not while a pen, the eraser, an object drag or a text +/// drag owns the pointer, nor while editing a header or footer from the body (or back). +fn available(app: &WordApp) -> bool { + app.canvas.table_tool.is_none() + && app.canvas.ink.is_none() + && app.canvas.obj_drag.is_none() + && !app.canvas.drag_selecting() + && crate::ink::cursor(app).is_none() +} + +/// The border or handle under screen point `p`, if any. +fn target_at(app: &WordApp, layout: &DocLayout, rects: &[Rect], scale: f32, p: Pos2) -> Option<(usize, f32, f32, ResizeTarget)> { + let (page, x, y) = crate::canvas::page_at(rects, layout, scale, p)?; + let sc = page_scale(rects, layout, scale, page)?; + let t = table_resize::resize_target(layout, &app.session.doc, page, x, y, REACH_PX / sc)?; + Some((page, x, y, t)) +} + +/// Mouse input: a press on a border starts a drag, moving updates it, releasing applies it. +/// `false` (nothing done) when the pointer isn't on a border, so the caret logic runs. +pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layout: &DocLayout, scale: f32) -> bool { + let latest = ui.input(|i| i.pointer.latest_pos()); + if let Some(mut d) = app.canvas.table_resize.take() { + if let Some(p) = latest + && let Some(r) = rects.get(d.page) + && let Some(sc) = page_scale(rects, layout, scale, d.page) + { + d.to = ((p.x - r.min.x) / sc, (p.y - r.min.y) / sc); + } + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + return true; + } + if ui.input(|i| i.pointer.primary_down()) { + app.canvas.table_resize = Some(d); + return true; + } + // A click without a move changes nothing. + if (d.to.0 - d.from.0).abs() < 0.5 && (d.to.1 - d.from.1).abs() < 0.5 { + return true; + } + let shift = ui.input(|i| i.modifiers.shift); + let sc = page_scale(rects, layout, scale, d.page).unwrap_or(1.0); + let params = json!({ + "page": d.page, "x": d.from.0, "y": d.from.1, "toX": d.to.0, "toY": d.to.1, + "shift": shift, "reach": REACH_PX / sc, + }); + if let Err(e) = app.run("table.resizeDrag", params) { + app.status(e); + } + return true; + } + if !available(app) || !(ui.input(|i| i.pointer.primary_pressed()) && resp.contains_pointer()) { + return false; + } + let Some(p) = latest else { return false }; + let Some((page, x, y, target)) = target_at(app, layout, rects, scale, p) else { return false }; + app.canvas.table_resize = Some(ResizeDrag { page, from: (x, y), to: (x, y), target }); + true +} + +/// The resize arrow over a border or the handle, and while dragging. +pub fn cursor(app: &WordApp, ui: &Ui, resp: &egui::Response, layout: &DocLayout, rects: &[Rect], scale: f32) { + let edge = match &app.canvas.table_resize { + Some(d) => Some(d.target.edge), + None if resp.hovered() && available(app) => { + ui.input(|i| i.pointer.latest_pos()).and_then(|p| target_at(app, layout, rects, scale, p)).map(|(_, _, _, t)| t.edge) + } + None => None, + }; + let icon = match edge { + Some(Edge::Column(_)) => egui::CursorIcon::ResizeColumn, + Some(Edge::Row(_)) => egui::CursorIcon::ResizeRow, + Some(Edge::Corner) => egui::CursorIcon::ResizeNwSe, + None => return, + }; + ui.ctx().set_cursor_icon(icon); +} + +/// The handle past the bottom-right corner of the table under the pointer, and while dragging, +/// a dashed preview of the new border or outline. +pub fn paint(app: &WordApp, painter: &egui::Painter, t: &Tokens, layout: &DocLayout, rects: &[Rect], scale: f32) { + let s = |page: usize, x: f32, y: f32| to_screen(rects, layout, scale, page, x, y); + let dash = Stroke::new(1.0, t.text); + if let Some(d) = &app.canvas.table_resize { + let shift = painter.ctx().input(|i| i.modifiers.shift); + let tg = &d.target; + let plan = tg.plan(d.to.0 - d.from.0, d.to.1 - d.from.1, shift); + let (left, top) = (tg.left(), tg.top()); + let right = plan.widths.as_ref().map_or(tg.right(), |w| left + w.iter().sum::()); + let bottom = top + tg.rows.iter().map(|r| plan.heights.iter().find(|(i, _)| *i == r.row).map_or(r.bottom - r.top, |(_, h)| *h)).sum::(); + let line = |a: Option, b: Option| { + if let (Some(a), Some(b)) = (a, b) { + painter.extend(egui::Shape::dashed_line(&[a, b], dash, 3.0, 3.0)); + } + }; + match tg.edge { + Edge::Column(i) => { + let x = left + plan.widths.as_ref().map_or(0.0, |w| w.iter().take(i).sum::()); + line(s(d.page, x, top), s(d.page, x, tg.bottom())); + } + Edge::Row(r) => { + let y = tg.rows.iter().find(|x| x.row == r).map_or(top, |x| x.top) + plan.heights.first().map_or(0.0, |(_, h)| *h); + line(s(d.page, left, y), s(d.page, tg.right(), y)); + } + Edge::Corner => { + if let (Some(a), Some(b)) = (s(d.page, left, top), s(d.page, right, bottom)) { + let r = Rect::from_two_pos(a, b); + let pts = [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.left_top()]; + painter.extend(egui::Shape::dashed_line(&pts, dash, 3.0, 3.0)); + } + } + } + return; + } + if !available(app) { + return; + } + // The handle shows while the pointer is over the table (or on the handle itself). + let Some(p) = painter.ctx().input(|i| i.pointer.hover_pos()) else { return }; + let Some((page, x, y)) = crate::canvas::page_at(rects, layout, scale, p) else { return }; + let Some(sc) = page_scale(rects, layout, scale, page) else { return }; + let Some(tg) = table_resize::table_under(layout, &app.session.doc, page, x, y, REACH_PX / sc) else { return }; + let h = tg.handle(); + if let (Some(a), Some(b)) = (s(page, h.x, h.y), s(page, h.right(), h.bottom())) { + let r = Rect::from_two_pos(a, b); + painter.rect_filled(r, 0.0, egui::Color32::WHITE); + painter.rect_stroke(r, 0.0, Stroke::new(1.0, t.text_dim), egui::StrokeKind::Inside); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Screen ↔ page conversion survives a page list that doesn't match the layout. + #[test] + fn conversions_survive_missing_pages() { + let layout = DocLayout::default(); + assert!(page_scale(&[], &layout, 1.0, 0).is_none()); + assert!(to_screen(&[], &layout, 1.0, 3, 1.0, 1.0).is_none()); + let rects = [Rect::from_min_size(pos2(10.0, 20.0), egui::vec2(100.0, 200.0))]; + assert_eq!(page_scale(&rects, &layout, 2.0, 0), Some(2.0)); + assert_eq!(to_screen(&rects, &layout, 2.0, 0, 5.0, 5.0), Some(pos2(20.0, 30.0))); + assert!(page_scale(&rects, &layout, 0.0, 0).is_none()); + } + + type H = egui_kittest::Harness<'static, WordApp>; + + /// The whole window with a 2×3 table whose first cell's text is selected. + fn table_window() -> H { + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + a.run("insert.table", json!({"rows": 2, "cols": 3})).unwrap(); + a.run("text.insert", json!({"text": "Product"})).unwrap(); + a.run("select.text", json!({"text": "Product"})).unwrap(); + 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); + }, + a, + ); + for _ in 0..6 { + h.step(); + } + h + } + + /// Cell (`row`, `cell`)'s rect in page points. + fn cell(h: &mut H, row: usize, cell: usize) -> wordcraft_geom::Rect { + h.state_mut().session.layout().pages[0] + .items + .iter() + .find_map(|it| match it { + wordcraft_layout::Placed::Cell { rect, row: r, cell: c, .. } if *r == row && *c == cell => Some(*rect), + _ => None, + }) + .unwrap() + } + + fn screen(h: &mut H, x: f32, y: f32) -> Pos2 { + crate::canvas::page_to_screen(h.state_mut(), 0, x, y).unwrap() + } + + fn button(h: &H, at: Pos2, pressed: bool) { + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + } + + fn steps(h: &mut H, n: usize) { + for _ in 0..n { + h.step(); + } + } + + /// A real mouse drag of a column border: the resize arrow on hover, a preview and no change + /// while dragging, then one undo step on release, the selection untouched. + #[test] + fn dragging_a_column_border_with_the_mouse_is_one_undo_step() { + let mut h = table_window(); + let (a, b) = (cell(&mut h, 0, 0), cell(&mut h, 0, 1)); + let sel = h.state().session.sel.clone(); + let depth = h.state().session.undo_depth(); + let start = screen(&mut h, a.right(), a.y + a.h / 2.0); + h.event(egui::Event::PointerMoved(start)); + steps(&mut h, 2); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeColumn, "the resize arrow over the border"); + button(&h, start, true); + h.step(); + // 30 page points to the right, in a few moves. + let end = screen(&mut h, a.right() + 30.0, a.y + a.h / 2.0); + for k in 1..=5 { + h.event(egui::Event::PointerMoved(start + (end - start) * (k as f32 / 5.0))); + h.step(); + } + assert!(h.state().canvas.table_resize.is_some(), "a drag is in progress"); + assert_eq!(h.state().session.undo_depth(), depth, "nothing is written while dragging"); + assert_eq!(cell(&mut h, 0, 0), a, "the table waits for the release"); + button(&h, end, false); + steps(&mut h, 3); + assert!(h.state().canvas.table_resize.is_none()); + assert_eq!(h.state().session.undo_depth(), depth + 1, "one undo step for the drag"); + let (a1, b1) = (cell(&mut h, 0, 0), cell(&mut h, 0, 1)); + assert!((a1.w - (a.w + 30.0)).abs() < 1.0, "{a:?} → {a1:?}"); + assert!((b1.w - (b.w - 30.0)).abs() < 1.0, "{b:?} → {b1:?}"); + assert_eq!(h.state().session.sel, sel, "the selection is kept"); + assert!(h.state_mut().session.undo()); + assert_eq!(cell(&mut h, 0, 0), a); + } + + /// Clicks inside a cell still place the caret (the border doesn't steal them), and Esc drops a + /// drag without changing anything. + #[test] + fn clicks_in_cells_still_move_the_caret_and_esc_cancels_a_drag() { + let mut h = table_window(); + let c = cell(&mut h, 1, 1); + let at = screen(&mut h, c.x + c.w / 2.0, c.y + c.h / 2.0); + h.event(egui::Event::PointerMoved(at)); + h.step(); + button(&h, at, true); + h.step(); + button(&h, at, false); + steps(&mut h, 3); + let focus = &h.state().session.sel.focus; + assert_eq!(focus.path.cell().map(|(_, r, cl)| (r, cl)), Some((1, 1)), "the caret went into the clicked cell"); + let table = h.state().session.doc.body.iter().find_map(|b| b.as_table()).cloned(); + let depth = h.state().session.undo_depth(); + let a = cell(&mut h, 0, 0); + let start = screen(&mut h, a.x + a.w / 2.0, a.bottom()); + h.event(egui::Event::PointerMoved(start)); + h.step(); + button(&h, start, true); + h.step(); + let end = start + egui::vec2(0.0, 40.0); + h.event(egui::Event::PointerMoved(end)); + h.step(); + assert!(h.state().canvas.table_resize.is_some(), "the row border is being dragged"); + h.event(egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }); + h.step(); + button(&h, end, false); + steps(&mut h, 3); + assert_eq!(h.state().session.undo_depth(), depth, "Esc cancelled it"); + assert_eq!(h.state().session.doc.body.iter().find_map(|b| b.as_table()).cloned(), table); + } +} diff --git a/docs/gaps.md b/docs/gaps.md index 021f26c31..51a77327d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Animation effects setting landed, following the system's setting on Windows, macOS, Linux/BSD and the web; previously trivial: Word XML Document and Word 2003 XML landed, #548; previously trivial: #24 narrowed: Customize Keyboard and Customize Ribbon landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 The work list. One entry per shortfall, ranked by how much it stops a Word user from switching (**B** = blocks beta). Each says what's missing, the evidence, who it hurts, an Opus 5.5 agent @@ -55,8 +55,8 @@ The same gaps grouped the way the parity documents are, for agents working in on pane, Manage Styles, Group (open PRs exist for the first three — review and merge before re-implementing). Paste Special, Advanced Find, Column Selection, Style Inspector, custom table styles, and Draw Table and Eraser (#303) landed on 2026-10-10. -- **UI/UX gaps:** #7, #18, #24; crop handles on the canvas (rotation handle landed, #332); table column drag on the - ruler (#49, #217); context menus for objects and tables; Linux title bar theming (#78); File › +- **UI/UX gaps:** #7, #18, #24; crop handles on the canvas (rotation handle landed, #332); table column markers on the + ruler (#49; dragging borders and the resize handle on the page landed, #217); context menus for objects and tables; Linux title bar theming (#78); File › Options › Animation effects › Use system setting reads macOS (Reduce motion) and the Linux/BSD desktops (`apps/wordcraft/src/animations.rs`) but hasn't been tried on a Mac or in a real GNOME or KDE session yet. @@ -69,6 +69,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Table column and row borders and the resize handle drag on the page (#217); UI/UX gap narrowed to the ruler's column markers | | 2026-10-11 | trivial | UI/UX gaps list: the system's animation setting is now read on macOS and the Linux/BSD desktops; trying it on real machines remains | | 2026-10-11 | trivial | Animation effects setting landed (File › Options › Accessibility); UI/UX gaps list: the system's animation setting isn't read on macOS or Linux/BSD yet | | 2026-10-11 | trivial | Word XML Document (Flat OPC) and Word 2003 XML opening landed (#548); gap #26 narrowed to 15–28 h | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c44c3658f..d9fc10677 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # WordCraft parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Show revisions in balloons draws deletion balloons; previously trivial: English spelling knows common modern words, #520; previously trivial: all 12 bibliography styles, #383; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; 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; replaces the parity tables that lived in ROADMAP.md) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (table borders and the resize handle drag on the page, #217; previously trivial: all 12 bibliography styles, #383; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; 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; replaces the parity tables that lived in ROADMAP.md) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 The authoritative assessment of how close WordCraft is to Microsoft Word. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) itemizes every shortfall; the deep checklists are @@ -60,7 +60,7 @@ full parity for the area. | Character and paragraph formatting | 11 | 82% | 85% | 8–12 | Nearly every property, Font/Paragraph dialogs, character border, Phonetic Guide (#288), Enclose Characters (#297). Missing: Asian Typography, kerning/ligature/stylistic-set options in the Font dialog's Advanced tab. | | Styles and themes | 8 | 74% | 75% | 8–12 | Gallery, pane, create/modify, style sets, themes. Style Inspector landed (#236). Missing: Manage Styles (#270 open), style separators, linked-style edge cases; Word ships 11 Quick Style sets plus dozens of themes, we ship our own smaller set. | | Lists | 6 | 78% | 75% | 5–8 | Bullets, numbering, multilevel, restart, `w:lvlOverride` levels (#134), Word's list AutoFormat and Enter/Backspace behaviour. Missing: Define New Multilevel List dialog depth, list styles, legal numbering edge cases. | -| Tables | 8 | 76% | 72% | 10–16 | Merge/split, styles (cell text takes style formatting, #104), custom table styles saved in the .docx (#256), Word 2013 edge, floating tables, rows split across pages, sort, formula, cell gridlines (#243), Draw Table and Eraser (#303). Missing: splitting or erasing around vertically merged cells, nested-table polish, interactive column resize (#49 open, #217). Cell text direction landed (#245). | +| Tables | 8 | 76% | 72% | 10–16 | Merge/split, styles (cell text takes style formatting, #104), custom table styles saved in the .docx (#256), Word 2013 edge, floating tables, rows split across pages, sort, formula, cell gridlines (#243), Draw Table and Eraser (#303). Missing: splitting or erasing around vertically merged cells, nested-table polish, table column markers on the ruler (#49). Dragging column and row borders and the resize handle on the page landed (#217); cell text direction landed (#245). | | 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; RTL sections missing. Vertical sections (tbRl/btLr) landed (#491). 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). | @@ -105,7 +105,7 @@ h), pictures and tables (12–18 h), spelling depth (8–12 h), polish (10–20 | Discount | Mainstream | Essentials | Evidence | |---|---|---|---| -| Interaction fidelity | ×0.93 | — | Typing pinned to Word (`tests_typing.rs`), ribbon, keytips, mini-toolbar; but 16 modal dialogs against Word's ~100, 125 of ~250 shortcuts, no table column drag on the canvas (#217, #49), picture/shape handling complaints (#82, #142), lines wrap differently without an Aptos-metric font | +| Interaction fidelity | ×0.93 | — | Typing pinned to Word (`tests_typing.rs`), ribbon, keytips, mini-toolbar; but 16 modal dialogs against Word's ~100, 125 of ~250 shortcuts, picture/shape handling complaints (#82, #142), lines wrap differently without an Aptos-metric font | | Discoverability and UI clarity | — | ×0.95 | Word-style ribbon a Word user already knows, command search, keytips; some newer pane strings and tooltips terse or untranslated | | Stability on real machines | ×0.90 | ×0.90 (launch and install) | Never-crash standard, hostile-param fuzzing, panic guard; but open startup crash on Intel UHD (#170), freezes (#77, #59, #31), Windows writes fail without admin (#218), installer conflicts with other Crafting Apps (#64, #73, #106, #51); 5 days of field history | | Exchanging files with Word users | ×0.90 | ×0.92 (opening files people send) | Word opens our .docx; `.doc` import; but no real-world corpus test, charts/SmartArt/OLE dropped silently, pagination differs (Aptos, no column balancing or footnote continuation). Simple documents, which are most of what a casual user receives, come through well; double-click open on macOS is fixed on main but not yet released (#223, #279) | @@ -251,6 +251,7 @@ can be used without committing Word output. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Tables: column and row borders and the resize handle drag on the page (#217); Tables row and interaction-fidelity evidence updated, no percentage change | | 2026-10-11 | trivial | Review: Show revisions in balloons takes tracked deletions in the body out of the text into "Deleted: …" balloons (it drew like the comments-and-formatting mode before); Review row updated, no percentage change | | 2026-10-11 | trivial | Insert › SmartArt (#497): charts/SmartArt row updated; catalog 429/451 → 437/458 (95.4%); no percentage change | | 2026-10-11 | trivial | Outline view depth (#493): View row updated; catalog 428/450 → 441/463 (95.2%) with the Outlining tab rows | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 7cf41790b..7125195f3 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 (Animation effects setting; previously trivial: every measurement follows the Options unit, #755; 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 (table borders and the resize handle drag on the page, #217; 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 @@ -24,6 +24,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | Backstage (Home, New, Open, Info, Save As, Print, Share, Export, Options) | Home and New separate (#254), Info properties kept (#268), Options in Word's panes with working settings (#485), Print page with preview, page range and copies (#641) | | 68% | 4–6 | | Rulers: indents, tabs, margins, table columns | indents draggable (one undo, #62); tabs; table column drag pending (#49) | ✅ | 70% | 2–3 | | On-canvas objects: select, drag, resize handles, nudge | ✅ tested (#228) | ✅ | 75% | — | +| Tables on the page: drag column and row borders, resize handle past the bottom-right corner | ✅ resize cursors, dashed guide, one undo step per drag, selection kept; Shift moves one column; Esc cancels (#217). Missing: Ctrl and Alt drags, the left edge, borders of selected cells only, double-click AutoFit | ✅ | 80% | 1–2 | | Rotation handle, crop handles on canvas, alignment guides, smart guides | rotation handle (Shift: 15° steps), turned frames resize along their axes (#332); crop handles and guides missing | ✅ | 25% | 3–5 | | Selection: word/sentence/paragraph clicks, dragging after a double-click/triple-click/Ctrl+click extends by whole words/paragraphs/sentences (#549), Shift extend, F8 extend mode, column (Alt+drag, #237) | ✅ | ✅ | 90% | 1 | | Zoom: Ctrl+scroll, pinch (#179), zoom buttons (#232), pages side by side when zoomed out (#251) | ✅ | ✅ | 85% | — | @@ -42,6 +43,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Tables on the page: column and row borders and the resize handle drag, with a dashed guide and one undo step per drag (#217) | | 2026-10-11 | trivial | Animation effects › Use system setting also reads macOS (Reduce motion) and the Linux/BSD desktops (settings portal: the cross-desktop reduced-motion preference, GNOME's switch, KDE's animation speed; the GTK and KDE settings files where no portal answers), and follows a change without a restart | | 2026-10-11 | trivial | Animation effects setting in File › Options › Accessibility (Use system setting, On, Off), kept between runs: the app eased wheel scrolling, zooming and its fades whatever the system's setting was; off, they land at once. Follows the system's setting on Windows and in browsers; `ui.animations` for agents | | 2026-10-11 | trivial | Built-in file browser when the system file dialog can't be shown on Linux/BSD; `ui.fileBrowser` (#576) | From 4611afba16bcd33be46cf1a7a2dce4310c7001a9 Mon Sep 17 00:00:00 2001 From: Rees <109076338+OPUZZLES@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:29:32 +0200 Subject: [PATCH 02/88] SCRUM-28: preserve imported TOC result ranges (#617) --- ROADMAP.md | 2 + crates/doc/src/para.rs | 4 +- crates/docx/src/read/mod.rs | 48 +++++++- crates/docx/src/read/story.rs | 4 +- crates/docx/src/write/story.rs | 2 +- crates/docx/tests/fixtures.rs | 27 ++++- crates/docx/tests/roundtrip.rs | 71 +++++------ crates/docx/tests/toc_ranges.rs | 177 ++++++++++++++++++++++++++++ crates/engine/src/cmd/references.rs | 39 +++++- crates/engine/src/tests.rs | 105 +++++++++++++++++ docs/file-format-parity.md | 26 +++- docs/gaps.md | 5 +- 12 files changed, 459 insertions(+), 51 deletions(-) create mode 100644 crates/docx/tests/toc_ranges.rs diff --git a/ROADMAP.md b/ROADMAP.md index 39f71505b..812203cb2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -179,6 +179,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-11 | Home › Font › Enclose Characters (#297): circle, square, triangle or diamond around a character, saved as Word's `EQ \o\ac` field | | 2026-10-11 | Phonetic Guide (#288): ruby text over base text, `w:ruby` round trip | | 2026-10-12 | Custom XML backing stores preserved as opaque data and related properties through edited DOCX saves; content-control markup is handled by the existing reader/writer and #532 bibliography I/O remains separate; binding evaluation and readiness unchanged | +| 2026-10-12 | Imported TOCs keep actual result boundaries, nested page fields, formatting and locks through DOCX saves without relying on TOC styles; existing engine-managed styled TOCs remain refreshable | | 2026-10-11 | Customize Ribbon and the Quick Access Toolbar: custom tabs and groups, hide and reorder tabs, add or remove toolbar commands (right-click any button, or the toolbar's … menu); kept between runs (#379, #376) | | 2026-10-11 | Customize Keyboard: assign, remove and reset keyboard shortcuts; custom keys override built-ins and are kept between runs (#368) | | 2026-10-12 | Native saves use exclusively created sibling temporary files, synchronize before replacement and clean up on failure; existing temporary-name files/links and original files on failed saves are protected. Synthetic CLI and focused tests on macOS; no new Word parity or readiness claim. | @@ -232,6 +233,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-11 | trivial | Insert › Icons landed (#309): catalog 402/434 → 403/434 (92.9%) | | 2026-10-11 | trivial | Enclose Characters (#297): catalog 403/434 (92.9%) | | 2026-10-12 | minor | Progress log: opaque custom XML store preservation, with synthetic package regressions and explicit limits | +| 2026-10-12 | minor | Preserve imported TOC ranges and retain #538 refresh/undo/save coverage on current main; readiness unchanged | | 2026-10-11 | trivial | Progress log: Customize Ribbon and the Quick Access Toolbar (#379) | | 2026-10-11 | trivial | Omitted `w:pPrDefault` stays omitted across saves; explicit empty/default properties and later spacing edits remain writable. Synthetic preservation checks only; readiness estimates unchanged | | 2026-10-11 | trivial | Progress log: Customize Keyboard (#368) | diff --git a/crates/doc/src/para.rs b/crates/doc/src/para.rs index 8fec42071..287f12ac3 100644 --- a/crates/doc/src/para.rs +++ b/crates/doc/src/para.rs @@ -447,8 +447,8 @@ pub enum InlineObject { code: bool, }, /// The start of a field whose result is ordinary content — formatted, possibly spanning - /// paragraphs — up to the matching [`InlineObject::FieldEnd`]. Citation managers' `ADDIN` - /// fields (Zotero, Mendeley, EndNote) are kept this way; `instr` is the field code. See + /// paragraphs — up to the matching [`InlineObject::FieldEnd`]. Imported TOCs and citation + /// managers' `ADDIN` fields are kept this way; `instr` is the field code. See /// [`crate::fields`]. FieldStart { instr: String, diff --git a/crates/docx/src/read/mod.rs b/crates/docx/src/read/mod.rs index c86bb4fc7..df8c48b84 100644 --- a/crates/docx/src/read/mod.rs +++ b/crates/docx/src/read/mod.rs @@ -385,8 +385,8 @@ fn keep_glued_ids(doc: &mut Document, glued: &HashSet) { }); } -/// Word starts a TOC field in the first entry; the engine keeps it at the end of the TOC heading -/// (the paragraph holding the field marks where the TOC is). Move it there when one precedes it. +/// Normalize only a complete TOC range that the engine's heading/entry representation can +/// reproduce exactly. Other imported TOCs keep their explicit boundaries, independent of styles. fn toc_field_to_heading(body: &mut Blocks) { for i in 1..body.len() { let heading = body[i - 1].as_para().filter(|p| p.props.style.as_deref() == Some("TOCHeading")); @@ -394,12 +394,23 @@ fn toc_field_to_heading(body: &mut Blocks) { if heading.is_none_or(|h| h.objects.iter().any(has_toc)) { continue; } - let Some((field, props)) = body[i].as_para().and_then(|p| match p.object_at(0) { - Some(f @ InlineObject::Field { result, .. }) if result.is_empty() && has_toc(f) => Some((f.clone(), p.props_of_char(0).clone())), - _ => None, + let Some((field, props, end)) = body[i].as_para().and_then(|p| { + let (field, end) = match p.object_at(0) { + Some(InlineObject::FieldStart { instr, locked }) if is_toc(instr) => { + (InlineObject::Field { instr: instr.clone(), result: String::new(), locked: *locked, code: false }, Some(toc_range_end(body, i)?)) + } + Some(f @ InlineObject::Field { result, .. }) if result.is_empty() && has_toc(f) => (f.clone(), None), + _ => return None, + }; + Some((field, p.props_of_char(0).clone(), end)) }) else { continue; }; + // Delete the end first: the two markers may occupy the same paragraph. + if let Some(Block::Para(p)) = end.and_then(|end| body.get_mut(end)).map(Arc::make_mut) { + let off = p.len().saturating_sub(OBJ.len_utf8()); + let _ = p.delete(off, p.len()); + } if let Some(Block::Para(p)) = body.get_mut(i).map(Arc::make_mut) { let _ = p.delete(0, OBJ.len_utf8()); } @@ -411,7 +422,32 @@ fn toc_field_to_heading(body: &mut Blocks) { } fn is_toc(instr: &str) -> bool { - instr.trim_start().get(..3).is_some_and(|k| k.eq_ignore_ascii_case("TOC")) + instr.split_whitespace().next().is_some_and(|k| k.eq_ignore_ascii_case("TOC")) +} + +/// The actual matching end must also be the end the styled writer would choose: at the end +/// of the last contiguous TOC entry, with no section break or trailing content outside it. +fn toc_range_end(body: &Blocks, start: usize) -> Option { + let entry = + |p: &wordcraft_doc::Paragraph| p.section.is_none() && p.props.style.as_deref().is_some_and(|s| s.starts_with("TOC") && s != "TOCHeading"); + let mut depth = 0_usize; + for (i, b) in body.iter().enumerate().skip(start) { + let p = b.as_para().filter(|p| entry(p))?; + for (off, o) in p.object_offsets().into_iter().zip(&p.objects) { + match o { + InlineObject::FieldStart { .. } => depth = depth.checked_add(1)?, + InlineObject::FieldEnd => { + depth = depth.checked_sub(1)?; + if depth == 0 { + let following_entry = body.get(i + 1).and_then(|b| b.as_para()).is_some_and(entry); + return (off.checked_add(OBJ.len_utf8()) == Some(p.len()) && !following_entry).then_some(i); + } + } + _ => {} + } + } + } + None } /// Built-in style names Word stores in lower case, and how the UI shows them. diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index c6f724ca2..617708e71 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -83,9 +83,9 @@ fn enclosure(f: &FieldState) -> Option { } /// Fields kept as ranges: those whose result another program writes, with formatting and often -/// several paragraphs (citation managers' `ADDIN` fields: Zotero, Mendeley, EndNote). +/// several paragraphs (TOCs and citation managers' `ADDIN` fields). fn is_range_instr(instr: &str) -> bool { - instr.trim_start().get(..5).is_some_and(|k| k.eq_ignore_ascii_case("ADDIN")) + super::is_toc(instr) || instr.trim_start().get(..5).is_some_and(|k| k.eq_ignore_ascii_case("ADDIN")) } /// The last paragraph of a block list: its last block, or inside a final table the last diff --git a/crates/docx/src/write/story.rs b/crates/docx/src/write/story.rs index 508f4fdbd..a7f09976e 100644 --- a/crates/docx/src/write/story.rs +++ b/crates/docx/src/write/story.rs @@ -1488,7 +1488,7 @@ const TOC_SDT_OPEN: &str = r#""#; fn is_toc(instr: &str) -> bool { - instr.trim_start().get(..3).is_some_and(|k| k.eq_ignore_ascii_case("TOC")) + instr.split_whitespace().next().is_some_and(|k| k.eq_ignore_ascii_case("TOC")) } /// The engine's TOC: a paragraph holding an empty `TOC` field, then its TOC-styled entries. diff --git a/crates/docx/tests/fixtures.rs b/crates/docx/tests/fixtures.rs index 912d21397..6d0bc5ca0 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/fixtures.rs @@ -97,6 +97,29 @@ fn paras(d: &Document) -> Vec<&Paragraph> { d.body.iter().filter_map(|b| b.as_para()).collect() } +#[test] +fn single_entry_toc_keeps_formatted_result_and_nested_page_field() { + let d = read_body( + r#" +Contents + + + TOC \o "1-1" \h + + Intro + 1 + +"#, + ); + let p = paras(&d); + assert_eq!(p[0].plain_text(), "Contents"); + assert_eq!(p[0].objects, vec![InlineObject::Field { instr: r#"TOC \o "1-1" \h"#.into(), result: String::new(), locked: true, code: false }]); + assert_eq!(p[1].plain_text(), "Intro\t1"); + assert_eq!(p[1].props_of_char(0).bold, Some(true)); + assert_eq!(p[1].props_of_char(0).link.as_deref(), Some("#_Toc1")); + assert_eq!(p[1].objects, vec![InlineObject::Field { instr: r#"PAGEREF _Toc1 \h"#.into(), result: "1".into(), locked: false, code: false }]); +} + #[test] fn toc_complex_field_spanning_paragraphs() { let body = r#" @@ -112,13 +135,13 @@ fn toc_complex_field_spanning_paragraphs() { let p = paras(&d); assert_eq!(p.len(), 5); assert_eq!(p[0].text, "\u{FFFC}Intro\t\u{FFFC}"); - assert_eq!(p[0].objects[0], InlineObject::Field { instr: r#"TOC \o "1-3" \h \z \u"#.into(), result: String::new(), locked: false, code: false }); + assert_eq!(p[0].objects[0], InlineObject::FieldStart { instr: r#"TOC \o "1-3" \h \z \u"#.into(), locked: false }); assert_eq!(p[0].objects[1], InlineObject::Field { instr: r#"PAGEREF _Toc1 \h"#.into(), result: "1".into(), locked: false, code: false }); assert_eq!(p[0].props_of_char(3).link.as_deref(), Some("#_Toc1")); assert_eq!(p[0].props.style.as_deref(), Some("TOC1")); assert_eq!(p[1].text, "Body\t2"); assert_eq!(p[1].props_of_char(0).link.as_deref(), Some("#_Toc2")); - assert_eq!(p[2].text, ""); + assert_eq!(p[2].objects, vec![InlineObject::FieldEnd]); assert_eq!(p[3].objects, vec![InlineObject::BookmarkStart { name: "_Toc1".into() }, InlineObject::BookmarkEnd { name: "_Toc1".into() }]); assert_eq!(p[4].plain_text(), "Page 7"); assert_eq!(p[4].objects[0], InlineObject::Field { instr: "PAGE".into(), result: "7".into(), locked: false, code: false }); diff --git a/crates/docx/tests/roundtrip.rs b/crates/docx/tests/roundtrip.rs index 4d9759616..9dc5c35c1 100644 --- a/crates/docx/tests/roundtrip.rs +++ b/crates/docx/tests/roundtrip.rs @@ -685,41 +685,46 @@ fn word_note_mark_round_trips_once() { /// or Word shows them as plain text with no Update Table. #[test] fn toc_field_wraps_its_entries() { - let mut head = Paragraph::with_text("Contents", CharProps::default()).styled("TOCHeading"); - let end = head.len(); - head.insert_object( - end, - InlineObject::Field { instr: "TOC \\o \"1-2\" \\h \\z \\u".into(), result: String::new(), locked: false, code: false }, - &CharProps::default(), - ) - .unwrap(); - let entries = [("Intro\t1", "TOC1"), ("Detail\t2", "TOC2")]; - let mut blocks = vec![head]; - blocks.extend(entries.iter().map(|(t, st)| Paragraph::with_text(t, CharProps::default()).styled(st))); - blocks.push(Paragraph::with_text("Body", CharProps::default())); - let d = doc_with(blocks); + for entries in [&[("Intro\t1", "TOC1")][..], &[("Intro\t1", "TOC1"), ("Detail\t2", "TOC2")][..]] { + let mut head = Paragraph::with_text("Contents", CharProps::default()).styled("TOCHeading"); + let end = head.len(); + head.insert_object( + end, + InlineObject::Field { instr: "TOC \\o \"1-2\" \\h \\z \\u".into(), result: String::new(), locked: false, code: false }, + &CharProps::default(), + ) + .unwrap(); + let mut blocks = vec![head]; + blocks.extend(entries.iter().map(|(t, st)| Paragraph::with_text(t, CharProps::default()).styled(st))); + blocks.push(Paragraph::with_text("Body", CharProps::default())); + let d = doc_with(blocks); - 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(); - let sdt = xml.find("").expect("TOC content control"); - assert!(xml.contains(r#""#), "{xml}"); - let (begin, fend) = (xml.find(r#"fldCharType="begin""#).unwrap(), xml.find(r#"fldCharType="end""#).unwrap()); - let last_entry = xml.find("Detail").unwrap(); - assert!(sdt < xml.find("Contents").unwrap() && xml.find("").unwrap() < xml.find(">Body<").unwrap(), "{xml}"); - assert!(begin < xml.find("Intro").unwrap() && fend > last_entry && fend < xml.find("").unwrap(), "field must span the entries: {xml}"); - // Word starts the field in the first entry; started in the heading, LibreOffice splits the heading. - let heading_end = xml[..xml.find("Intro").unwrap()].rfind("").unwrap(); - assert!(begin > heading_end, "field must start in the first entry, not the heading: {xml}"); + 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(); + let sdt = xml.find("").expect("TOC content control"); + assert!(xml.contains(r#""#), "{xml}"); + let (begin, fend) = (xml.find(r#"fldCharType="begin""#).unwrap(), xml.find(r#"fldCharType="end""#).unwrap()); + let last_entry = xml.find(entries.last().unwrap().0.split('\t').next().unwrap()).unwrap(); + assert!(sdt < xml.find("Contents").unwrap() && xml.find("").unwrap() < xml.find(">Body<").unwrap(), "{xml}"); + assert!( + begin < xml.find("Intro").unwrap() && fend > last_entry && fend < xml.find("").unwrap(), + "field must span the entries: {xml}" + ); + // Word starts the field in the first entry; started in the heading, LibreOffice splits the heading. + let heading_end = xml[..xml.find("Intro").unwrap()].rfind("").unwrap(); + assert!(begin > heading_end, "field must start in the first entry, not the heading: {xml}"); - // WordCraft reads its own TOC back unchanged. - let r = wordcraft_docx::read(&bytes).expect("read"); - let ps = paras(&r); - assert_eq!(ps.iter().map(|p| p.plain_text()).collect::>(), ["Contents", "Intro\t1", "Detail\t2", "Body"]); - assert!(matches!(ps[0].objects.first(), Some(InlineObject::Field { instr, .. }) if instr.starts_with("TOC")), "{:?}", ps[0].objects); - assert!(ps[1].objects.is_empty(), "the field goes back on the heading: {:?}", ps[1].objects); - assert_eq!(ps[1].props.style.as_deref(), Some("TOC1")); + // WordCraft reads its own TOC back unchanged. + let r = wordcraft_docx::read(&bytes).expect("read"); + let ps = paras(&r); + let expected: Vec<_> = std::iter::once("Contents").chain(entries.iter().map(|(t, _)| *t)).chain(std::iter::once("Body")).collect(); + assert_eq!(ps.iter().map(|p| p.plain_text()).collect::>(), expected); + assert!(matches!(ps[0].objects.first(), Some(InlineObject::Field { instr, .. }) if instr.starts_with("TOC")), "{:?}", ps[0].objects); + assert!(ps[1].objects.is_empty(), "the field goes back on the heading: {:?}", ps[1].objects); + assert_eq!(ps[1].props.style.as_deref(), Some("TOC1")); + } } /// Table Layout › Text Direction (#226): each direction is written as Word's `w:textDirection` diff --git a/crates/docx/tests/toc_ranges.rs b/crates/docx/tests/toc_ranges.rs new file mode 100644 index 000000000..d8010bd49 --- /dev/null +++ b/crates/docx/tests/toc_ranges.rs @@ -0,0 +1,177 @@ +//! Original field-boundary fixtures from the public OOXML complex-field contract. +use std::io::Write; + +use wordcraft_doc::{InlineObject, Path, StoryRef}; + +fn package(body: &str) -> Vec { + let xml = format!(r#"{body}"#); + let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, value) in [ + ("word/document.xml", xml.as_str()), + ( + "_rels/.rels", + r#""#, + ), + ] { + zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + zip.write_all(value.as_bytes()).unwrap(); + } + zip.finish().unwrap().into_inner() +} + +fn start(code: &str) -> String { + format!( + r#"{code}"# + ) +} + +const END: &str = r#""#; +const PAGE: &str = r#"7"#; +const ENTRY: &str = r#"Scope"#; + +#[test] +fn unstyled_toc_keeps_range_nested_field_and_formatting_through_two_edited_saves() { + for split in [false, true] { + let content = if split { + format!("{ENTRY}{PAGE}Delivery") + } else { + format!("{ENTRY}{PAGE}") + }; + let body = format!( + r#"Before.{}{content}{END}After."#, + start(r#"TOC \o "1-2" \h"#) + ); + let mut doc = wordcraft_docx::read(&package(&body)).unwrap(); + for cycle in 0..=2 { + let ranges = doc.field_ranges(StoryRef::Body); + assert_eq!(ranges.len(), 1); + assert_eq!(ranges[0].instr, r#"TOC \o "1-2" \h"#); + assert!(ranges[0].locked); + assert_eq!(ranges[0].start.path, Path::top(1)); + assert_eq!(ranges[0].start.off, 0); + assert_eq!(ranges[0].end.path, Path::top(if split { 2 } else { 1 })); + let entry = doc.body[1].as_para().unwrap(); + assert_eq!(entry.plain_text(), "Scope\t7"); + let props = entry.props_of_char(wordcraft_doc::para::OBJ.len_utf8()); + assert_eq!(props.bold, Some(true)); + assert_eq!(props.italic, Some(true)); + assert_eq!(props.link.as_deref(), Some("#Original")); + assert!( + entry.objects.iter().any(|o| matches!(o, InlineObject::Field { instr, result, .. } if instr == "PAGEREF Original" && result == "7")) + ); + assert_eq!(doc.body.last().unwrap().as_para().unwrap().plain_text(), "After."); + if cycle == 2 { + break; + } + doc.para_mut(StoryRef::Body, &Path::top(0)).unwrap().insert_text(0, "Independent edit. ", &Default::default()).unwrap(); + doc = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap(); + } + } +} + +#[test] +fn styled_heading_does_not_expand_partial_custom_or_short_ranges() { + let heading = r#"Contents"#; + let extra = r#"Outside entry."#; + for (style, prefix, suffix, following) in + [("TOC1", "Prefix.", "", ""), ("TOC1", "", "Suffix.", ""), ("TOC1", "", "", extra), ("CompanyContents", "", "", "")] + { + let body = format!( + r#"{heading}{prefix}{}Scope{END}{suffix}{following}Body."#, + start("TOC") + ); + let mut doc = wordcraft_docx::read(&package(&body)).unwrap(); + let text = doc.plain_text(StoryRef::Body); + for cycle in 0..=2 { + let ranges = doc.field_ranges(StoryRef::Body); + assert_eq!(ranges.len(), 1, "{style} {prefix} {suffix} {following}"); + assert_eq!(ranges[0].start.path, Path::top(1)); + assert_eq!(ranges[0].end.path, Path::top(1)); + assert_eq!(ranges[0].start.off, prefix.len()); + assert_eq!(ranges[0].end.off, prefix.len() + wordcraft_doc::para::OBJ.len_utf8() + "Scope".len()); + assert_eq!(doc.plain_text(StoryRef::Body), text); + if cycle == 2 { + break; + } + doc = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap(); + } + } +} + +#[test] +fn simple_lowercase_toc_and_multiple_tocs_keep_their_own_ranges() { + let body = format!( + r#"{ENTRY}{PAGE}Between.{}Other result.{END}After."#, + start(r#"TOC \o "1-1""#) + ); + let mut doc = wordcraft_docx::read(&package(&body)).unwrap(); + for cycle in 0..=2 { + let r = doc.field_ranges(StoryRef::Body); + assert_eq!(r.len(), 2); + assert_eq!(r[0].instr, r#"toc \h"#); + assert_eq!(r[1].instr, r#"TOC \o "1-1""#); + assert!(r.iter().all(|r| r.locked)); + assert!(r[0].end.off < r[1].start.off); + assert_eq!(doc.plain_text(StoryRef::Body), "Scope\t7Between.Other result.After."); + if cycle == 2 { + break; + } + doc = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap(); + } +} + +#[test] +fn toc_in_table_cell_does_not_capture_the_next_cell() { + let body = format!( + r#"{}{ENTRY}{PAGE}{END}Other cell."#, + start("TOC") + ); + let mut doc = wordcraft_docx::read(&package(&body)).unwrap(); + for cycle in 0..=2 { + let ranges = doc.field_ranges(StoryRef::Body); + assert_eq!(ranges.len(), 1); + assert_eq!(ranges[0].start.path, wordcraft_doc::Path(vec![0, 0, 0, 0])); + assert_eq!(ranges[0].end.path, ranges[0].start.path); + assert_eq!(doc.body[0].as_table().unwrap().rows[0].cells[1].blocks[0].as_para().unwrap().plain_text(), "Other cell."); + if cycle == 2 { + break; + } + doc = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap(); + } +} + +#[test] +fn missing_end_and_excessive_nesting_stay_bounded_and_save_balanced() { + for (starts, ends) in [(1, 0), (40, 40)] { + let body = format!("{}Visible result.{}", start("TOC").repeat(starts), END.repeat(ends)); + let doc = wordcraft_docx::read(&package(&body)).unwrap(); + let saved = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap(); + assert!(!saved.has_unbalanced_field_ranges()); + assert_eq!(saved.plain_text(StoryRef::Body), "Visible result."); + assert!(saved.field_ranges(StoryRef::Body).len() <= 32); + } +} + +#[test] +fn section_break_keeps_an_explicit_range_and_toc_prefix_is_not_a_toc() { + let body = format!( + r#"Contents{}Scope{END}{}Other.{END}"#, + start("TOC"), + start("TOC_OTHER") + ); + let doc = wordcraft_docx::read(&package(&body)).unwrap(); + for d in [&doc, &wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap()] { + let ranges = d.field_ranges(StoryRef::Body); + assert_eq!(ranges.len(), 1); + assert_eq!(ranges[0].start.path, Path::top(1)); + assert!(d.body[1].as_para().unwrap().section.is_some()); + assert!( + d.body[2] + .as_para() + .unwrap() + .objects + .iter() + .any(|o| matches!(o, InlineObject::Field { instr, result, .. } if instr == "TOC_OTHER" && result == "Other.")) + ); + } +} diff --git a/crates/engine/src/cmd/references.rs b/crates/engine/src/cmd/references.rs index 4c28a5091..c16ded371 100644 --- a/crates/engine/src/cmd/references.rs +++ b/crates/engine/src/cmd/references.rs @@ -1,7 +1,7 @@ //! References tab: table of contents, footnotes/endnotes, captions; field updates. use serde_json::{Value, json}; -use wordcraft_doc::para::{InlineObject, NoteKind}; +use wordcraft_doc::para::{InlineObject, NoteKind, OBJ}; use wordcraft_doc::props::{CharProps, TabAlign, TabLeader, TabStop}; use wordcraft_doc::{Block, Paragraph, PartKind, Path, Pos, StoryRef, para_block}; @@ -309,6 +309,41 @@ fn toc_range(s: &Session) -> Option<(usize, usize)> { Some((start, end)) } +/// A TOC imported as a range field (`FieldStart`…`FieldEnd`: one without a TOC heading, or with +/// content around it the engine's TOC can't reproduce) becomes the engine's TOC before an update, +/// so Update Table refreshes it rather than adding a second one: an empty `TOC` field where the +/// range started, its old result gone, and the regenerated entries after that paragraph (text +/// that followed the range moves below them). Only an unlocked, top-level TOC that isn't inside +/// another range field, and only when the body has no engine TOC already. +fn range_toc_to_field(s: &mut Session) -> Result<(), CmdError> { + if toc_range(s).is_some() { + return Ok(()); + } + let is_toc = |instr: &str| { + let field = FieldCode::parse(instr); + field.name == "TOC" && !field.has('c') + }; + let Some(r) = s + .doc + .field_ranges(StoryRef::Body) + .into_iter() + .find(|r| r.depth == 0 && !r.locked && is_toc(&r.instr) && r.start.path.depth() == 0 && r.end.path.depth() == 0) + else { + return Ok(()); + }; + let Some(after_end) = r.end.off.checked_add(OBJ.len_utf8()) else { return Ok(()) }; + let end = Pos { off: after_end, ..r.end.clone() }; + let props = s.doc.para_at(&r.start).map(|p| p.props_of_char(r.start.off).clone()).unwrap_or_default(); + let at = s.doc.delete_range(&r.start, &end)?; + let field = InlineObject::Field { instr: r.instr.trim().to_string(), result: String::new(), locked: false, code: false }; + let after = s.doc.insert_object(&at, field, &props)?; + if s.doc.para_at(&after).is_some_and(|p| after.off < p.len()) { + s.doc.split_paragraph(&after)?; + } + s.clamp_selection(); + Ok(()) +} + /// 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 { @@ -321,6 +356,7 @@ fn toc_field(p: &Paragraph) -> Option { } fn insert_toc(s: &mut Session, v: &Value) -> CmdResult { + range_toc_to_field(s)?; 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; @@ -408,6 +444,7 @@ pub fn update_toc(s: &mut Session) -> Result<(), CmdError> { /// 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> { + range_toc_to_field(s)?; let Some((start, end)) = toc_range(s) else { return Ok(()) }; 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); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 79af9d056..ffa7664a0 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1422,6 +1422,111 @@ fn toc_and_fields() { assert_eq!(s.doc.plain_text(StoryRef::Body).matches("Summary\t").count(), 1, "one TOC entry after update"); } +#[test] +fn toc_refresh_after_save_and_reopen() { + for extension in ["docx", "json"] { + for (headings, levels) in [(&["Top"][..], 1), (&["Top", "Next"][..], 1), (&["Top"][..], 4)] { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": headings.join("\n")})); + for i in 0..headings.len() { + run(&mut s, "caret.set", json!({"pos": Pos::body(i, 0)})); + run(&mut s, "styles.apply", json!({"style": "Heading 1"})); + } + // A level-two heading must stay out of a depth-one TOC after reopening. + if levels == 1 { + run(&mut s, "caret.docEnd", json!({})); + run(&mut s, "text.newParagraph", json!({})); + run(&mut s, "text.insert", json!({"text": "Detail"})); + run(&mut s, "styles.apply", json!({"style": "Heading 2"})); + } + run(&mut s, "caret.docStart", json!({})); + run(&mut s, "references.toc", json!({"levels": levels})); + run(&mut s, "references.updateToc", json!({})); + let expected = text(&s); + let name = format!("toc.{extension}"); + let bytes = crate::io::save_bytes(&name, &s.doc).unwrap(); + s = Session::new(crate::io::open_bytes(&name, &bytes).unwrap()); + for command in ["references.updateToc", "references.updateToc", "references.updateFields"] { + run(&mut s, command, json!({})); + assert_eq!(text(&s), expected, "{extension}, levels={levels}, {command}"); + } + let heading = s + .doc + .body + .iter() + .position(|b| b.as_para().is_some_and(|p| p.props.style.as_deref() == Some("Heading1") && p.plain_text() == "Top")) + .unwrap(); + // Hyperlinked TOCs bookmark their headings; select the text after those markers. + let off = s.doc.body[heading].as_para().unwrap().text.find("Top").unwrap(); + run(&mut s, "select.range", json!({"anchor": Pos::body(heading, off), "focus": Pos::body(heading, off + 3)})); + run(&mut s, "text.insert", json!({"text": "Revised"})); + let before_refresh = text(&s); + run(&mut s, "references.updateToc", json!({})); + let revised = text(&s); + assert_eq!(revised.matches("Revised\t").count(), 1, "{revised}"); + assert!(!revised.contains("Top"), "{revised}"); + assert_eq!(revised.matches("Next\t").count(), headings.len() - 1, "{revised}"); + assert_eq!(revised.matches("Detail\t").count(), 0, "{revised}"); + assert_eq!(revised.lines().filter(|line| *line == "Detail").count(), usize::from(levels == 1), "{revised}"); + assert!(s.undo()); + assert_eq!(text(&s), before_refresh); + run(&mut s, "references.updateToc", json!({})); + assert_eq!(text(&s), revised); + let bytes = crate::io::save_bytes(&name, &s.doc).unwrap(); + s = Session::new(crate::io::open_bytes(&name, &bytes).unwrap()); + run(&mut s, "references.updateFields", json!({})); + assert_eq!(text(&s), revised, "saved edited TOC, {extension}"); + } + } +} + +/// A TOC imported as a range field (no TOC heading, so it isn't turned back into the engine's +/// TOC on open) is still found by Update Table, Update Field and Table of Contents: it is +/// refreshed in place, not duplicated, and the refresh is one undo step. +#[test] +fn imported_range_toc_is_refreshed_in_place() { + use wordcraft_doc::para::InlineObject; + use wordcraft_doc::props::CharProps; + for locked in [false, true] { + let mut d = wordcraft_doc::Document::from_text("Before.\nOld entry\t9\nAlpha\nBeta"); + { + let p = d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(1)).unwrap(); + let n = p.len(); + p.insert_object(n, InlineObject::FieldEnd, &CharProps::default()).unwrap(); + p.insert_object(0, InlineObject::FieldStart { instr: r#"TOC \o "1-2" \h"#.into(), locked }, &CharProps::default()).unwrap(); + } + let mut s = Session::new(d); + for (i, style) in [(2, "Heading 1"), (3, "Heading 2")] { + run(&mut s, "caret.set", json!({"pos": Pos::body(i, 0)})); + run(&mut s, "styles.apply", json!({"style": style})); + } + // Through DOCX, as an imported file is. + let bytes = wordcraft_docx::write(&s.doc).unwrap(); + s = Session::new(wordcraft_docx::read(&bytes).unwrap()); + assert_eq!(s.doc.field_ranges(StoryRef::Body).len(), 1, "read back as a range field"); + let before = text(&s); + run(&mut s, "references.updateToc", json!({})); + let refreshed = text(&s); + if locked { + // Like Word, a locked field keeps its result. + assert_eq!(refreshed, before); + continue; + } + assert!(!refreshed.contains("Old entry"), "{refreshed}"); + assert!(refreshed.starts_with("Before.\n"), "{refreshed}"); + assert_eq!(refreshed.matches("Alpha\t").count(), 1, "{refreshed}"); + assert_eq!(refreshed.matches("Beta\t").count(), 1, "{refreshed}"); + assert!(s.doc.field_ranges(StoryRef::Body).is_empty(), "now the engine's TOC"); + for command in ["references.updateToc", "references.updateFields", "references.toc"] { + run(&mut s, command, json!({})); + assert_eq!(text(&s), refreshed, "{command} refreshes the same TOC"); + } + while s.undo() {} + assert_eq!(text(&s), before); + assert_eq!(s.doc.field_ranges(StoryRef::Body).len(), 1, "undo brings the imported range back"); + } +} + #[test] fn toc_page_numbers_follow_headings() { let mut s = s(); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 7493c4126..ae4a855ec 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** trivial (native save temporary-file ownership and failure protection; previously trivial: Print Markup: comments in PDF/print/copies, #589; previously trivial: SmartArt written from WordCraft's model, #497; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (imported TOC result boundaries preserved; native save temporary-file ownership and failure protection; previously trivial: Print Markup: comments in PDF/print/copies, #589; previously trivial: SmartArt written from WordCraft's model, #497; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the share of a typical real-world file's content and formatting that survives (read: shows correctly; @@ -54,7 +54,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Tracked insertions and deletions (`w:ins`/`w:del`), paragraph-mark revisions | ✅ | ✅ | #125, #244 | | Formatting revisions (`w:rPrChange`, `w:pPrChange`, `w:sectPrChange`, `w:tblPrChange`, `w:trPrChange`, `w:tcPrChange`, `w:numberingChange`) | ✅ | ✅ | #41: kept on open and save, schema order (change element last); changes inside `styles.xml` are dropped. RTF/ODT ignore them | | Move tracking (`w:moveFrom`/`w:moveTo`, `w:moveFromRangeStart/End`, `w:moveToRangeStart/End`), moved paragraph marks | ✅ | ✅ | #400: both ends linked by the range name; ranges rewritten around each moved stretch (ids renumbered); a move missing an end or a range end opens as a plain deletion/insertion. RTF/ODT/PDF export the result (moved-away text hidden like deletions) | -| Fields (`w:fldChar`, `w:fldSimple`), TOC, cross-references, `ADDIN` citations | ✅ | ✅ | Field codes Word supports but we don't evaluate keep their cached result. `EQ \o` (overstrike, enclosed characters) is drawn; enclosed characters are written as Word stores them, their style in the code's run sizes (#297); a cached picture (`INCLUDEPICTURE`) stays as the content of a range field (#465) | +| Fields (`w:fldChar`, `w:fldSimple`), TOC, cross-references, `ADDIN` citations | ✅ | ✅ | Field codes Word supports but we don't evaluate keep their cached result. Imported TOCs preserve actual result boundaries, formatted entries and nested fields without relying on TOC styles. `EQ \o` (overstrike, enclosed characters) is drawn; enclosed characters are written as Word stores them, their style in the code's run sizes (#297); a cached picture (`INCLUDEPICTURE`) stays as the content of a range field (#465) | | Content controls (`w:sdt`) | ✅ | ✅ | Block, inline, row and cell level, nested; title, tag, id, lock, placeholder, temporary, rich/plain text, `w14:checkbox`, combo box, drop-down list, date, picture, building block gallery, `w15` repeating sections; other `w:sdtPr` children (data binding, appearance) kept verbatim (#381). A TOC's control is rebuilt by the TOC | | Restrict Editing (`w:documentProtection` edit/formatting/enforcement and password hash, `w:locked` styles, `w:permStart`/`w:permEnd` exceptions) | ✅ | ✅ | #412: SHA-512 hash with salt and spin count written; Word's legacy `w:cryptAlgorithmSid`/`w:hash` form (SHA-1/-256/-384/-512, ECMA-376 Part 4 §14.8.1) is kept and verified; MD2/MD4/MD5, `w:algIdExt` and other unsupported forms are refused, never unlocked; `w:colFirst`/`w:colLast` table-column ranges read as plain ranges | | Legacy form fields (`w:ffData`), check boxes, drop-downs | ❌ | ❌ | | @@ -131,6 +131,27 @@ Windows/Linux runtime behavior, ACLs and extended metadata, real disk-full condi 3. Move tracking (`w:moveFrom`/`w:moveTo`) read and written (formatting revisions: #41; content controls: #381). 4. Encrypted `.docx` opens with a password. +## Imported TOC result preservation + +Original synthetic TOCs without TOC paragraph styles lose their outer result range on main +and #538. Main also flattens a nested page field in a single-paragraph result. Imported TOCs +now use the existing range-field markers to retain the actual result boundaries, nested +page-reference fields, formatting, links and locks through edited saves. + +The public [complex-field contract](https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.wordprocessing.fieldchar?view=openxml-3.0.1) +defines begin/separate/end delimiters and permits nested fields. Paragraph styles do not define +those boundaries. A complete range is converted to the engine's styled TOC representation only +when that writer can reproduce its exact extent; partial or custom-styled ranges stay explicit. + +Focused original regressions cover single/multiple paragraphs, partial ranges, adjacent styled +text, multiple/simple fields, table cells, section breaks, missing ends and bounded nesting. +The existing styled TOC write/read path remains supported. Update Table and Update Fields refresh +unlocked top-level body TOC ranges in place through the engine's generator; one undo restores +the imported range. Locked, nested and table-cell ranges remain preserved without regeneration. +Preservation is separate from field evaluation. Native Word reopening and +refresh, full schema validation and company documents remain untested. Synthetic checks do not +raise readiness estimates. No Word-derived files are fixtures. + ## Revision history | Date | Change | Summary | @@ -159,6 +180,7 @@ Windows/Linux runtime behavior, ACLs and extended metadata, real disk-full condi | 2026-10-11 | trivial | Spreadsheets (.xlsx/.xlsm/.ods) read as mail-merge recipient lists (#334) | | 2026-10-12 | minor | Preserve opaque custom XML stores and related properties; bindings and control support remain separate | | 2026-10-11 | trivial | Native saves protect existing temporary-name entries and originals on pre-replacement failure; synthetic macOS evidence, explicit durability/platform limits; readiness unchanged | +| 2026-10-11 | minor | Preserve imported TOC ranges independently of paragraph styles, with explicit evaluation limits | | 2026-10-11 | trivial | Import/export fixes (#433–#440): TXT opens through the encoding-sniffing reader (Windows-1252, unmarked UTF-16) and saves list labels; RTF `\uN` in document info, bookmark names and field instructions; ODT common paragraph styles' text formatting and table-cell background colours; HTML borderless tables export without cell borders and `white-space:pre` keeps edge spaces; LaTeX brace groups scope `\centering`/`\raggedleft`/`\raggedright` | | 2026-10-11 | trivial | `.doc` import fixes per [MS-DOC]: piece Prm0/Prm1 decoding (#458), section-break kinds (#459), explicit orientation (#460), negative `sprmPIlfo` list references (#461), zero-based `istdBase` style inheritance (#462), font-table entries with alternate names (#463) | | 2026-10-11 | trivial | Formatting revisions (`w:rPrChange`, `w:pPrChange`, table, row, cell, section and numbering changes) read and written (#41) | diff --git a/docs/gaps.md b/docs/gaps.md index 51a77327d..a0b05da98 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** trivial (imported TOC boundaries preserved with refresh coverage; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 The work list. One entry per shortfall, ranked by how much it stops a Word user from switching (**B** = blocks beta). Each says what's missing, the evidence, who it hurts, an Opus 5.5 agent @@ -13,7 +13,7 @@ Native save reliability: synthetic cases on macOS reproduced loss through the pr | # | Gap | B | Evidence | Impact | Hours | Doc | |---|---|---|---|---|---|---| -| 1 | **DOCX never tested on real-world files.** No corpus of files from Word, Google Docs, LibreOffice and Pages has been opened, rendered and round-tripped and compared with Word page by page | B | No corpus in the repo; `crates/docx/tests` are synthetic fixtures (including omitted/empty paragraph-default preservation); "DOCX fidelity corpus: not started" since 2026-10-06 | Everyone: the files people receive are the files that break | 30–50 | [file-format-parity.md](file-format-parity.md) | +| 1 | **DOCX never tested on real-world files.** No corpus of files from Word, Google Docs, LibreOffice and Pages has been opened, rendered and round-tripped and compared with Word page by page | B | No corpus in the repo; `crates/docx/tests` are synthetic fixtures (including omitted/empty paragraph defaults and imported TOC range preservation); "DOCX fidelity corpus: not started" since 2026-10-06 | Everyone: the files people receive are the files that break | 30–50 | [file-format-parity.md](file-format-parity.md) | | 2 | **Ink and drawing canvases are dropped on open and lost on save, silently**; OLE objects show only their picture | B | No ink or drawing-canvas (`wpc`) handling in `crates/docx`; group shapes (`wpg`) read and written (#267). Charts and SmartArt are drawn (#292) and, with OLE objects, written back with all their parts on save (#319) | Documents with diagrams drawn in Word or pen notes lose content without warning | 4–8 to preserve ink and drawing canvases; 45–70 to render and edit charts and SmartArt (#13) | [file-format-parity.md](file-format-parity.md) | | 3 | **Crashes and freezes in the field**: startup crash on Intel UHD Graphics (#170) (lag on GPUs without a DirectX 12 driver fixed, #316), freezes (#77, #59 Thai fonts, #31 font selector), no writes without admin on Windows 11 (#218), broken shape tool (#142), images can't be moved (#82) | B | 62 open issues, ~15 of them crashes, hangs or data-path failures | Anyone hit loses work or trust; the never-crash standard covers panics, not hangs or GPU driver faults | 20–30 | [target-app-parity.md](target-app-parity.md) (Stability) | | 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) | @@ -91,6 +91,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | 2026-10-11 | trivial | #21: Excel (.xlsx/.xlsm) and OpenDocument (.ods) spreadsheets as recipient lists (#334); hours 6–10 → 4–8 | | 2026-10-11 | trivial | Insert › Icons landed (#309); removed from the objects gap | | 2026-10-12 | minor | Preserve opaque custom XML data/properties through saves; gap #5 and readiness remain unchanged | +| 2026-10-11 | minor | Imported TOC boundaries preserved in synthetic checks; real-world corpus gap and readiness unchanged | | 2026-10-11 | trivial | #24 narrowed: Customize Ribbon and Quick Access Toolbar customization landed (#379); the default shortcuts remain | | 2026-10-11 | trivial | #24 narrowed: Customize Keyboard landed (#368); Customize Ribbon and the Quick Access Toolbar remain | | 2026-10-11 | trivial | Native saves protect existing temporary-name entries and originals on pre-replacement failure; synthetic macOS evidence, explicit durability/platform limits; readiness unchanged | From 6801d89065fd21fe91b9c9501856465d4006f8a8 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Mon, 12 Oct 2026 09:42:18 +0200 Subject: [PATCH 03/88] .doc import: inline pictures, floating pictures and basic shapes (#551) (#558) * .doc import: inline pictures, floating pictures and basic shapes (#551) Word 97-2003 files kept their text but dropped every picture and drawing. Pictures and drawings are now read from the OfficeArt records ([MS-DOC], [MS-ODRAW]): - Inline pictures: the PICF's OfficeArtInlineSpContainer (MM_SHAPE / MM_SHAPEFILE) and its FBSE/BLIP, with crop and alt text from the shape properties; the older raw-image path stays as a fallback. - Floating pictures and shapes: PlcSpaMom/PlcSpaHdr anchors joined with the shapes of the OfficeArtContent drawings (fcDggInfo), placed by the SPA bounds, bx/by or posrelh/posrelv/posh/posv, wrap type and fBelowText; rotation and flips; pictures from the BLIP store. - Shapes: rectangle, rounded rectangle, ellipse, diamond, triangles, star, block arrows, heart, lines/connectors, with fill/line colour and width; groups flattened into one group space; text boxes linked to their PlcftxbxTxt story parts by spid. - BLIPs: PNG, JPEG, TIFF as-is, DIB converted to PNG, EMF/WMF inflated (the metafile renderer draws them), PICT kept as a sized placeholder. - Hostile input: record lengths clamped to their parent, nesting depth and record counts capped, decompression size-limited, picture bytes capped per document as before. Co-Authored-By: Claude Opus 5.5 * Review fixes for #558: miniz_oxide as a workspace dependency Restores the change @echelon pushed in 04b59287, which a rebase dropped: miniz_oxide is declared once in the root [workspace.dependencies] and docbin uses it with workspace = true. Co-Authored-By: Claude Sonnet 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- Cargo.lock | 2 + Cargo.toml | 2 + ROADMAP.md | 1 + crates/docbin/Cargo.toml | 2 + crates/docbin/src/drawing.rs | 262 ++++++++++++++++ crates/docbin/src/fib.rs | 8 + crates/docbin/src/lib.rs | 51 +++- crates/docbin/src/media.rs | 139 +++++++-- crates/docbin/src/officeart.rs | 463 +++++++++++++++++++++++++++++ crates/docbin/src/tests.rs | 4 +- crates/docbin/src/tests/drawing.rs | 276 +++++++++++++++++ docs/file-format-parity.md | 5 +- docs/gaps.md | 5 +- 13 files changed, 1189 insertions(+), 31 deletions(-) create mode 100644 crates/docbin/src/drawing.rs create mode 100644 crates/docbin/src/officeart.rs create mode 100644 crates/docbin/src/tests/drawing.rs diff --git a/Cargo.lock b/Cargo.lock index b6665923d..0dfdc221f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5056,7 +5056,9 @@ name = "wordcraft-docbin" version = "0.5.0" dependencies = [ "cfb", + "image", "log", + "miniz_oxide 0.8.9", "proptest", "serde_json", "thiserror 2.0.21", diff --git a/Cargo.toml b/Cargo.toml index 438cbc9ee..d30e72560 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,6 +55,8 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg", krilla = { version = "0.8", features = ["pdf"] } quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } +# zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates. +miniz_oxide = "0.8" cfb = "0.15" # Password-protected (agile-encrypted) Word documents: pure-Rust RustCrypto primitives. aes = "0.9" diff --git a/ROADMAP.md b/ROADMAP.md index 812203cb2..3956c29d1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -133,6 +133,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 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 | `.doc` import keeps pictures and drawings (#551): inline and floating pictures (PNG, JPEG, DIB, EMF, WMF), shapes, lines, groups and text boxes with position and wrap, from [MS-ODRAW] | | 2026-10-11 | Tables resize with the mouse (#217): drag a column border (the neighbour makes up the difference; Shift moves one column), a row's bottom border, or the handle past the bottom-right corner to scale the table; a dashed guide while dragging, one undo step per drag; `table.resizeDrag` for agents | | 2026-10-11 | Animation effects setting: File › Options › Accessibility › Animation effects (Use system setting, On, Off). With the system's animations off (Windows' Animation effects, macOS' Reduce motion, the Linux/BSD desktop's setting, a browser's reduced motion) the page no longer smooth-scrolls: wheel notches and Ctrl+wheel zoom land at once, in the document and in every list and pane, and menus and buttons don't fade; `ui.animations` for agents | | 2026-10-11 | Linux/BSD: Open, Save As, Export and Insert Picture work without a file chooser portal or zenity (labwc, sway…): WordCraft shows its own file browser and says how to get the system dialog; File › Open › Browse in WordCraft… is always there (#576) | diff --git a/crates/docbin/Cargo.toml b/crates/docbin/Cargo.toml index 253a0b298..3f9d43662 100644 --- a/crates/docbin/Cargo.toml +++ b/crates/docbin/Cargo.toml @@ -12,6 +12,8 @@ workspace = true wordcraft-doc = { workspace = true } wordcraft-geom = { workspace = true } cfb = { workspace = true } +image = { workspace = true } +miniz_oxide = { workspace = true } thiserror = { workspace = true } log = { workspace = true } diff --git a/crates/docbin/src/drawing.rs b/crates/docbin/src/drawing.rs new file mode 100644 index 000000000..ef403d158 --- /dev/null +++ b/crates/docbin/src/drawing.rs @@ -0,0 +1,262 @@ +//! Floating shapes and pictures: the `PlcfSpa` anchors of the main document and the headers +//! ([MS-DOC] §2.8.27 PlcfSpa, §2.9.253 Spa) joined with the OfficeArt shapes they name, and the +//! text box stories (`PlcftxbxTxt`, §2.8.30) that fill text boxes. + +use std::collections::HashMap; + +use wordcraft_doc::para::{Anchor, Float, FloatAlign, GroupChild, InlineObject, ShapeKind, Wrap}; +use wordcraft_doc::props::Rgb; + +use crate::media::Pictures; +use crate::officeart::{Node, Sp}; + +/// Most anchors (and text box stories) read per PLC. +const MAX_ANCHORS: usize = 10_000; +/// `Spa` and `FTXBXS` record sizes. +const CB_SPA: usize = 26; +const CB_FTXBXS: usize = 22; +/// Largest frame or offset kept (points; 22 inches, Word's own limit for sizes). +const MAX_PT: f32 = 1584.0; +/// Deepest group nesting flattened. +const MAX_GROUP_DEPTH: usize = 8; + +/// The (CP, data) entries of a PLC with `cb`-byte data elements, the trailing CP dropped. +fn plc(table: &[u8], (fc, lcb): (u32, u32), cb: usize) -> Vec<(u32, &[u8])> { + let Some(blob) = table.get(fc as usize..(fc as usize).saturating_add(lcb as usize)) else { return Vec::new() }; + let n = blob.len().saturating_sub(4) / (4 + cb); + let n = n.min(MAX_ANCHORS); + let data_at = (n + 1) * 4; + (0..n) + .filter_map(|i| { + let cp = u32::from_le_bytes(blob.get(i * 4..i * 4 + 4)?.try_into().ok()?); + let at = data_at + i * cb; + Some((cp, blob.get(at..at + cb)?)) + }) + .collect() +} + +/// The text box stories of a `PlcftxbxTxt` (or `PlcfHdrtxbxTxt`): (shape spid, story CP range +/// relative to the text box subdocument). +pub(crate) fn textbox_stories(table: &[u8], pair: Option<(u32, u32)>) -> Vec<(u32, (u32, u32))> { + let Some(pair) = pair else { return Vec::new() }; + let Some(blob) = table.get(pair.0 as usize..(pair.0 as usize).saturating_add(pair.1 as usize)) else { return Vec::new() }; + let mut out = Vec::new(); + for (i, (cp, ftxbxs)) in plc(table, pair, CB_FTXBXS).iter().enumerate() { + // The story runs to the next entry's CP (the PLC's last CP for the last entry). + let Some(end) = blob.get((i + 1) * 4..(i + 1) * 4 + 4).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes) else { continue }; + let Some(lid) = ftxbxs.get(14..18).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes) else { continue }; + if end > *cp && lid != 0 { + out.push((lid, (*cp, end))); + } + } + out +} + +/// What building the floating objects needs. +pub(crate) struct Build<'a> { + /// The BLIP store and the stream holding BLIPs not embedded in it (WordDocument). + pub(crate) bstore: &'a [&'a [u8]], + pub(crate) delay: &'a [u8], + /// Text box part ids by shape spid. + pub(crate) stories: &'a HashMap, +} + +/// The floating objects anchored by `PlcfSpa` (at `pair`), as (absolute anchor CP, object), +/// sorted by CP. `cp_base` turns the PLC's CPs into absolute ones (the header subdocument's +/// start for `PlcSpaHdr`). +pub(crate) fn anchors( + table: &[u8], + pair: Option<(u32, u32)>, + cp_base: u32, + shapes: &HashMap, + b: &Build, + pics: &mut Pictures, +) -> Vec<(u32, InlineObject)> { + let Some(pair) = pair else { return Vec::new() }; + let mut out = Vec::new(); + for (cp, spa) in plc(table, pair, CB_SPA) { + let i32_at = |o: usize| spa.get(o..o + 4).and_then(|b| b.try_into().ok()).map(i32::from_le_bytes).unwrap_or(0); + let spid = i32_at(0) as u32; + let [xl, yt, xr, yb] = [4, 8, 12, 16].map(i32_at); + let flags = spa.get(20..22).map(|b| u16::from_le_bytes([b[0], b[1]])).unwrap_or(0); + let Some(node) = shapes.get(&spid) else { continue }; + let tw = |v: i64| (v as f32 / 20.0).clamp(-2.0 * MAX_PT, 2.0 * MAX_PT); + let (mut x, mut y) = (tw(xl as i64), tw(yt as i64)); + let (mut w, mut h) = (tw(xr as i64 - xl as i64).clamp(0.0, MAX_PT), tw(yb as i64 - yt as i64).clamp(0.0, MAX_PT)); + let sp = node.sp(); + let rot = wordcraft_geom::normalize_degrees(if sp.props.rot.is_finite() { sp.props.rot } else { 0.0 }); + // A shape turned by 45°–135° (or 225°–315°) is anchored by its turned bounds: the + // unrotated frame has the other aspect, about the same centre. + if (45.0..135.0).contains(&rot) || (225.0..315.0).contains(&rot) { + x += (w - h) / 2.0; + y += (h - w) / 2.0; + std::mem::swap(&mut w, &mut h); + } + let mut float = spa_float(flags, &sp.props); + float.x = x; + float.y = y; + float.rot = rot; + float.flip_h = sp.flip_h(); + float.flip_v = sp.flip_v(); + let obj = match node { + Node::Shape(sp) => leaf(sp, w, h, float, b, pics), + Node::Group(sp, members) => group(sp, members, w, h, float, b, pics), + }; + match obj { + Some(obj) => out.push((cp_base.saturating_add(cp), obj)), + None => pics.dropped(cp_base.saturating_add(cp)), + } + } + out.sort_by_key(|(cp, _)| *cp); + out +} + +/// Placement from a `Spa`'s flags (fHdr, bx, by, wr, wrk, fRcaSimple, fBelowText, fAnchorLock) +/// and the shape's position properties, which take precedence when present. +fn spa_float(flags: u16, p: &crate::officeart::Props) -> Float { + let bx = (flags >> 1) & 3; + let by = (flags >> 3) & 3; + let wr = (flags >> 5) & 0xF; + let below = flags & (1 << 14) != 0; + let wrap = match wr { + 1 => Wrap::TopAndBottom, + 3 => { + if below { + Wrap::BehindText + } else { + Wrap::InFrontOfText + } + } + 4 => Wrap::Tight, + 5 => Wrap::Through, + _ => Wrap::Square, + }; + let h_rel = match p.posrelh.unwrap_or(bx as u32) { + 0 => Anchor::Margin, + 1 => Anchor::Page, + 3 => Anchor::Character, + _ => Anchor::Column, + }; + let v_rel = match p.posrelv.unwrap_or(by as u32) { + 0 => Anchor::Margin, + 1 => Anchor::Page, + 3 => Anchor::Line, + _ => Anchor::Paragraph, + }; + let align = |v: Option| match v { + Some(1) => Some(FloatAlign::Start), + Some(2) => Some(FloatAlign::Center), + Some(3) => Some(FloatAlign::End), + Some(4) => Some(FloatAlign::Inside), + Some(5) => Some(FloatAlign::Outside), + _ => None, + }; + // Distances from text, EMUs; Word's defaults are 0.125" at the sides, none above and below. + let emu = |v: Option, def: f32| v.map_or(def, |e| (e as f32 / 12_700.0).clamp(0.0, MAX_PT)); + let [dl, dt, dr, db] = p.wrap_dist; + Float { + wrap, + h_rel, + v_rel, + h_align: align(p.posh), + v_align: align(p.posv), + dist: emu(dl, 9.0).max(emu(dr, 9.0)), + dist_top: emu(dt, 0.0), + dist_bottom: emu(db, 0.0), + ..Float::default() + } +} + +/// One shape as a picture or a drawn shape, `w` × `h` points. +fn leaf(sp: &Sp, w: f32, h: f32, float: Float, b: &Build, pics: &mut Pictures) -> Option { + let p = &sp.props; + if let Some(pib) = p.pib { + let media = pics.bse(pib, b.bstore, b.delay)?; + return Some(InlineObject::Image { media, w, h, alt: p.alt.clone(), float, crop: p.crop, ole: None }); + } + let story = b.stories.get(&sp.spid).copied().filter(|_| sp.textbox || sp.spt == 202); + let mut kind = match sp.spt { + 1 => ShapeKind::Rectangle, + 2 => ShapeKind::RoundedRectangle, + 3 => ShapeKind::Ellipse, + 4 => ShapeKind::Diamond, + 5 | 6 => ShapeKind::Triangle, + 12 | 187 => ShapeKind::Star, + 13 | 66 | 67 | 68 | 69 => ShapeKind::Arrow, + 20 | 32 => ShapeKind::Line, + 74 => ShapeKind::Heart, + 202 => ShapeKind::TextBox, + // A freeform's path is not read: better left out than drawn as a box. + 0 => return None, + _ => ShapeKind::Rectangle, + }; + if story.is_some() && kind == ShapeKind::Rectangle { + kind = ShapeKind::TextBox; + } + let line = kind == ShapeKind::Line; + let fill = if line || p.filled == Some(false) { None } else { Some(p.fill_color.unwrap_or(Rgb::WHITE)) }; + let stroke = if p.line == Some(false) { None } else { Some(p.line_color.unwrap_or(Rgb::BLACK)) }; + // Line widths are EMUs; Word's default is 0.75 pt. + let stroke_width = p.line_width.map_or(0.75, |e| (e as f32 / 12_700.0).clamp(0.0, 100.0)); + Some(InlineObject::Shape { + kind, + w, + h, + fill, + stroke, + stroke_width, + float, + story, + freeform: None, + effects: Default::default(), + extra: Default::default(), + }) +} + +/// A group: its members flattened into the group's own coordinate space (nested groups mapped +/// through their child anchors). +fn group(sp: &Sp, members: &[Node], w: f32, h: f32, float: Float, b: &Build, pics: &mut Pictures) -> Option { + let [l, t, r, btm] = sp.group_space.unwrap_or([0, 0, 0, 0]); + let (cw, ch) = ((r as f64 - l as f64) as f32, (btm as f64 - t as f64) as f32); + let (ch_w, ch_h) = if cw > 0.0 && ch > 0.0 { (cw, ch) } else { (w.max(1.0), h.max(1.0)) }; + let mut children = Vec::new(); + // Map: outer = x0 + inner × s; the top group's space starts at its FSPGR origin. + members_into(members, (-(l as f32), -(t as f32), 1.0, 1.0), 0, b, pics, &mut children); + if children.is_empty() { + return None; + } + Some(InlineObject::Group { w, h, float, ch_w, ch_h, children, canvas: None }) +} + +fn members_into(nodes: &[Node], t: (f32, f32, f32, f32), depth: usize, b: &Build, pics: &mut Pictures, out: &mut Vec) { + let (x0, y0, sx, sy) = t; + let fit = |v: f32| if v.is_finite() { v.clamp(-1e7, 1e7) } else { 0.0 }; + for n in nodes { + if out.len() >= wordcraft_doc::para::MAX_GROUP_CHILDREN { + return; + } + let sp = n.sp(); + let Some([l, t, r, btm]) = sp.child else { continue }; + let (ox, oy) = (l as f32, t as f32); + let (ew, eh) = ((r as f64 - l as f64) as f32, (btm as f64 - t as f64) as f32); + match n { + Node::Group(_, members) => { + if depth >= MAX_GROUP_DEPTH { + continue; + } + let [gl, gt, gr, gb] = sp.group_space.unwrap_or([l, t, r, btm]); + let (gw, gh) = ((gr as f64 - gl as f64) as f32, (gb as f64 - gt as f64) as f32); + let (kx, ky) = if gw > 0.0 && gh > 0.0 { (ew / gw, eh / gh) } else { (1.0, 1.0) }; + let nt = (fit(x0 + (ox - gl as f32 * kx) * sx), fit(y0 + (oy - gt as f32 * ky) * sy), fit(sx * kx), fit(sy * ky)); + members_into(members, nt, depth + 1, b, pics, out); + } + Node::Shape(sp) => { + let float = Float { rot: sp.props.rot, flip_h: sp.flip_h(), flip_v: sp.flip_v(), ..Float::default() }; + let (w, h) = (fit(ew * sx).max(0.0), fit(eh * sy).max(0.0)); + if let Some(obj) = leaf(sp, w, h, float, b, pics) { + out.push(GroupChild { x: fit(x0 + ox * sx), y: fit(y0 + oy * sy), obj }); + } + } + } + } +} diff --git a/crates/docbin/src/fib.rs b/crates/docbin/src/fib.rs index 959240fa3..983e45bca 100644 --- a/crates/docbin/src/fib.rs +++ b/crates/docbin/src/fib.rs @@ -33,8 +33,16 @@ pub(crate) mod pair { pub(crate) const PLCF_BKF: usize = 22; pub(crate) const PLCF_BKL: usize = 23; pub(crate) const CLX: usize = 33; + /// `fcPlcSpaMom` / `fcPlcSpaHdr`: floating shape anchors of the main document / headers. + pub(crate) const PLC_SPA_MOM: usize = 40; + pub(crate) const PLC_SPA_HDR: usize = 41; pub(crate) const PLCF_END_REF: usize = 46; pub(crate) const PLCF_END_TXT: usize = 47; + /// `fcDggInfo`: the OfficeArt drawing content (BLIP store, shapes). + pub(crate) const DGG_INFO: usize = 50; + /// `fcPlcftxbxTxt` / `fcPlcfHdrtxbxTxt`: text box stories of the main document / headers. + pub(crate) const PLCF_TXBX_TXT: usize = 56; + pub(crate) const PLCF_HDR_TXBX_TXT: usize = 58; /// `fcPlfLst`: the list definitions (LSTF array, with the LVLs appended after the blob). pub(crate) const PLF_LST: usize = 73; /// `fcPlfLfo`: the list format overrides (LFO array with LFOData appended). diff --git a/crates/docbin/src/lib.rs b/crates/docbin/src/lib.rs index dde7ebb22..e056f1092 100644 --- a/crates/docbin/src/lib.rs +++ b/crates/docbin/src/lib.rs @@ -8,12 +8,14 @@ //! capped), so hostile input yields an error or a best-effort document, never a panic. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +mod drawing; mod fib; mod fkp; mod fmt; mod list; mod media; mod notes; +mod officeart; mod piece; mod sections; mod sprm; @@ -139,8 +141,35 @@ pub fn read(bytes: &[u8]) -> Result { sheet: &sheet, notes: &[], marks: &[], + drawn: &[], }; + // Drawings: text box stories become parts first, then the floating shapes and pictures + // anchored in the main document and the headers, found by their anchor CP while walking. + let txbx_base = edn_base.saturating_add(fib.ccp.edn); + let hdr_txbx_base = txbx_base.saturating_add(fib.ccp.txbx); + let art = fib + .pair(fib::pair::DGG_INFO) + .and_then(|(f, l)| table.get(f as usize..(f as usize).saturating_add(l as usize))) + .map(officeart::content) + .unwrap_or_default(); + let mut tb_stories = std::collections::HashMap::new(); + for (pair, base, len) in [(fib::pair::PLCF_TXBX_TXT, txbx_base, fib.ccp.txbx), (fib::pair::PLCF_HDR_TXBX_TXT, hdr_txbx_base, fib.ccp.hdr_txbx)] { + for (spid, ab) in drawing::textbox_stories(&table, fib.pair(pair)) { + let Some((a, b)) = story(base, len, ab) else { continue }; + if doc.parts.len() >= MAX_PARTS || tb_stories.contains_key(&spid) { + continue; + } + let blocks = table::assemble(walk(&ctx, a, b, &mut pics)); + tb_stories.insert(spid, doc.add_part(PartKind::TextBox, blocks)); + } + } + let build = drawing::Build { bstore: &art.bstore, delay: &word, stories: &tb_stories }; + let mut drawn = drawing::anchors(&table, fib.pair(fib::pair::PLC_SPA_MOM), 0, &art.main, &build, &mut pics); + drawn.extend(drawing::anchors(&table, fib.pair(fib::pair::PLC_SPA_HDR), hdd_base, &art.header, &build, &mut pics)); + drawn.sort_by_key(|(cp, _)| *cp); + let ctx = WalkCtx { drawn: &drawn, ..ctx }; + // Header and footer parts: skip the six separator stories, then six stories per section // in the order even header, odd (default) header, even footer, odd (default) footer, // first header, first footer. Empty stories inherit from the previous section. @@ -246,6 +275,8 @@ struct WalkCtx<'a> { notes: &'a [(u32, NoteKind, u32)], /// Bookmark boundaries (main-document CPs), sorted for a sequential walk. marks: &'a [(u32, notes::Mark)], + /// Floating shapes and pictures by anchor CP (absolute), sorted. + drawn: &'a [(u32, InlineObject)], } /// State while assembling one paragraph from per-CP formatting. @@ -405,21 +436,29 @@ fn walk(ctx: &WalkCtx, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) - let fc = ctx.pieces.fc_of_cp(cp).unwrap_or(0); let fc_pic = pic_location(ctx.chpx_bins.chpx(ctx.word, fc)); match fc_pic.and_then(|at| pics.get(ctx.data, at)) { - Some((key, w, h)) => { + Some(p) => { pb.push('\u{FFFC}', CharProps::default()); pb.objects.push(InlineObject::Image { - media: key, - w, - h, - alt: String::new(), + media: p.key, + w: p.w, + h: p.h, + alt: p.alt, float: Default::default(), - crop: [0.0; 4], + crop: p.crop, ole: None, }); } None => pics.dropped(cp), } } + '\u{8}' => { + // Drawn object anchor: the floating shape or picture anchored at this CP. + let at = ctx.drawn.partition_point(|(c, _)| *c < cp); + for (_, obj) in ctx.drawn.get(at..).unwrap_or_default().iter().take_while(|(c, _)| *c == cp) { + pb.push('\u{FFFC}', CharProps::default()); + pb.objects.push(obj.clone()); + } + } '\u{2}' => { // Footnote or endnote reference; custom symbols share the same character. let at = ctx.notes.partition_point(|(c, _, _)| *c < cp); diff --git a/crates/docbin/src/media.rs b/crates/docbin/src/media.rs index 82ca60f1b..1cb46e9e2 100644 --- a/crates/docbin/src/media.rs +++ b/crates/docbin/src/media.rs @@ -1,10 +1,13 @@ //! Inline pictures: `sprmCPicLocation` → a `PICF` in the Data stream → embedded image bytes -//! ([MS-DOC] §2.9.190 PICF, §1.3.5 Pictures). +//! ([MS-DOC] §2.9.190 PICF, §1.3.5 Pictures), and the shared store of every picture the +//! document reads (inline and floating). use std::collections::{BTreeMap, HashMap}; use std::ops::Range; use std::sync::Arc; +use crate::officeart; + /// `PICF.cbHeader` — the fixed size of the PICF structure. const CB_HEADER: usize = 0x44; /// Offsets inside PICF: `PICMID.dxaGoal/dyaGoal/mx/my` start at 28. @@ -17,10 +20,13 @@ pub(crate) const MAX_MEDIA_BYTES: usize = 256 << 20; /// The document's pictures, keyed by their location in the Data stream: anchors that share a /// `fc_pic` share one media entry, and the total bytes copied are capped, so a file with -/// thousands of anchors on one huge picture costs one copy, not thousands. +/// thousands of anchors on one huge picture costs one copy, not thousands. Pictures of the +/// drawing layer's BLIP store are shared the same way, by store index. pub(crate) struct Pictures { - /// fc_pic → (media key, width, height) of pictures already materialised. - by_fc: HashMap, + /// fc_pic → the inline picture already materialised. + by_fc: HashMap, + /// BLIP store index → media key (`None`: unreadable, not retried). + by_bse: HashMap>, /// The media entries, merged into the document at the end. pub(crate) media: BTreeMap>>, total: usize, @@ -28,33 +34,89 @@ pub(crate) struct Pictures { pub(crate) limit: usize, /// Anchors whose picture was dropped (only the first few are logged). dropped: usize, + /// Pictures kept as placeholders (formats we can't draw). + placeholders: usize, } impl Default for Pictures { fn default() -> Self { - Pictures { by_fc: HashMap::new(), media: BTreeMap::new(), total: 0, limit: MAX_MEDIA_BYTES, dropped: 0 } + Pictures { + by_fc: HashMap::new(), + by_bse: HashMap::new(), + media: BTreeMap::new(), + total: 0, + limit: MAX_MEDIA_BYTES, + dropped: 0, + placeholders: 0, + } } } +/// An inline picture: its media key, display size (points), crop fractions and alt text. +#[derive(Clone, Debug)] +pub(crate) struct Inline { + pub(crate) key: String, + pub(crate) w: f32, + pub(crate) h: f32, + pub(crate) crop: [f32; 4], + pub(crate) alt: String, +} + impl Pictures { - /// The media key and display size (points) of the picture at `fc_pic`, copying its - /// bytes the first time. `None` when the header is malformed, the payload is a format - /// we do not embed (metafiles), or a picture count/byte cap is reached. - pub(crate) fn get(&mut self, data: &[u8], fc_pic: u32) -> Option<(String, f32, f32)> { + /// The inline picture at `fc_pic` in the Data stream, copying its bytes the first time. + /// `None` when the header is malformed, the picture can't be read, or a picture + /// count/byte cap is reached. + pub(crate) fn get(&mut self, data: &[u8], fc_pic: u32) -> Option { if let Some(hit) = self.by_fc.get(&fc_pic) { return Some(hit.clone()); } - if self.by_fc.len() >= MAX_PICTURES { + if self.by_fc.len().saturating_add(self.by_bse.len()) >= MAX_PICTURES { return None; } - let pic = locate(data, fc_pic)?; - let bytes = data.get(pic.range)?; + let pic = match locate(data, fc_pic)? { + Found::Range(pic) => { + let bytes = data.get(pic.range.clone())?.to_vec(); + let key = self.insert(bytes, pic.ext, false)?; + Inline { key, w: pic.w, h: pic.h, crop: [0.0; 4], alt: String::new() } + } + Found::Shape { w, h, blip, props } => { + let key = self.insert(blip.bytes, blip.ext, blip.placeholder)?; + Inline { key, w, h, crop: props.crop, alt: props.alt } + } + }; + self.by_fc.insert(fc_pic, pic.clone()); + Some(pic) + } + + /// The media key of BLIP store entry `pib` (1-based), decoding it the first time; `delay` + /// is the stream that holds BLIPs not embedded in the store (the WordDocument stream). + pub(crate) fn bse(&mut self, pib: u32, bstore: &[&[u8]], delay: &[u8]) -> Option { + if let Some(hit) = self.by_bse.get(&pib) { + return hit.clone(); + } + if self.by_fc.len().saturating_add(self.by_bse.len()) >= MAX_PICTURES { + return None; + } + let fbse = bstore.get((pib as usize).checked_sub(1)?)?; + let room = self.limit.saturating_sub(self.total); + let key = officeart::fbse_blip(fbse, delay, room).and_then(|b| self.insert(b.bytes, b.ext, b.placeholder)); + self.by_bse.insert(pib, key.clone()); + key + } + + /// Add picture bytes as a new media entry, within the byte cap. + fn insert(&mut self, bytes: Vec, ext: &str, placeholder: bool) -> Option { let total = self.total.checked_add(bytes.len()).filter(|&t| t <= self.limit)?; - let key = format!("image{}.{}", self.by_fc.len() + 1, pic.ext); - self.media.insert(key.clone(), Arc::new(bytes.to_vec())); + let key = format!("image{}.{ext}", self.media.len() + 1); + if placeholder { + if self.placeholders < 10 { + log::warn!("docbin: picture `{key}` is in a format WordCraft can't draw ({ext}); it shows as a placeholder"); + } + self.placeholders = self.placeholders.saturating_add(1); + } + self.media.insert(key.clone(), Arc::new(bytes)); self.total = total; - self.by_fc.insert(fc_pic, (key.clone(), pic.w, pic.h)); - Some((key, pic.w, pic.h)) + Some(key) } /// Note a picture anchor that could not be materialised. @@ -74,27 +136,64 @@ struct Located { ext: &'static str, } -/// Locate the picture at `fc_pic` in the Data stream without copying it. -fn locate(data: &[u8], fc_pic: u32) -> Option { +/// What a `PICFAndOfficeArtData` holds: raw image bytes (older writers), or an +/// `OfficeArtInlineSpContainer` (Word 97 and later) whose BLIP is decoded. +enum Found { + Range(Located), + Shape { w: f32, h: f32, blip: officeart::Blip, props: officeart::Props }, +} + +/// `PICF.mfpf.mm` values of pictures stored as a shape (`MM_SHAPE`) or a shape with a file +/// name (`MM_SHAPEFILE`). +const MM_SHAPE: u16 = 0x0064; +const MM_SHAPEFILE: u16 = 0x0066; + +/// Locate the picture at `fc_pic` in the Data stream ([MS-DOC] §2.9.192 PICFAndOfficeArtData). +fn locate(data: &[u8], fc_pic: u32) -> Option { let base = fc_pic as usize; let pic = data.get(base..)?; let lcb = u32::from_le_bytes(pic.get(0..4)?.try_into().ok()?) as usize; let cb_header = u16::from_le_bytes(pic.get(4..6)?.try_into().ok()?) as usize; let cb_header = if (0x20..=0x100).contains(&cb_header) { cb_header } else { CB_HEADER }; + let mm = u16::from_le_bytes(pic.get(6..8)?.try_into().ok()?); let m = pic.get(OFF_PICMID..OFF_PICMID + 8)?; let dxa_goal = i16::from_le_bytes([m[0], m[1]]) as f32; let dya_goal = i16::from_le_bytes([m[2], m[3]]) as f32; let mx = u16::from_le_bytes([m[4], m[5]]) as f32; let my = u16::from_le_bytes([m[6], m[7]]) as f32; let scale = |r: f32| if r > 0.0 { (r / 1000.0).clamp(0.01, 100.0) } else { 1.0 }; + let end = lcb.min(pic.len()); + if mm == MM_SHAPE || mm == MM_SHAPEFILE { + let mut at = cb_header; + if mm == MM_SHAPEFILE { + at = at.checked_add(1 + *pic.get(at)? as usize)?; + } + let (blip, props) = inline_shape(pic.get(at..end)?, data)?; + // The goal size is before cropping; the crop fractions take their share off it. + let [cl, ct, cr, cb] = props.crop; + let w = (dxa_goal / 20.0 * (1.0 - cl - cr).max(0.01) * scale(mx)).clamp(1.0, 1584.0); + let h = (dya_goal / 20.0 * (1.0 - ct - cb).max(0.01) * scale(my)).clamp(1.0, 1584.0); + return Some(Found::Shape { w, h, blip, props }); + } let w = (dxa_goal / 20.0 * scale(mx)).clamp(1.0, 1584.0); let h = (dya_goal / 20.0 * scale(my)).clamp(1.0, 1584.0); - let end = lcb.min(pic.len()); let body = pic.get(cb_header..end)?; let at = sniff(body)?; let image = body.get(at..)?; let start = base.checked_add(cb_header)?.checked_add(at)?; - Some(Located { w, h, range: start..start.checked_add(image.len())?, ext: ext_of(image) }) + Some(Found::Range(Located { w, h, range: start..start.checked_add(image.len())?, ext: ext_of(image) })) +} + +/// An `OfficeArtInlineSpContainer`: the picture shape, then the `OfficeArtFBSE` records its +/// `pib` indexes (BLIPs embedded, or at `foDelay` in the Data stream). +fn inline_shape(buf: &[u8], data: &[u8]) -> Option<(officeart::Blip, officeart::Props)> { + let mut budget = 10_000usize; + let recs = officeart::children(buf, &mut budget); + let sp = recs.iter().find(|r| r.typ == officeart::rt::SP_CONTAINER).map(|r| officeart::shape(r.body, &mut budget))?; + let fbses: Vec<&[u8]> = recs.iter().filter(|r| r.typ == officeart::rt::FBSE).map(|r| r.body).collect(); + let fbse = sp.props.pib.and_then(|p| fbses.get((p as usize).checked_sub(1)?)).or(fbses.first())?; + let blip = officeart::fbse_blip(fbse, data, MAX_MEDIA_BYTES)?; + Some((blip, sp.props)) } /// Offset of the first embedded raster image inside the payload, skipping the small diff --git a/crates/docbin/src/officeart.rs b/crates/docbin/src/officeart.rs new file mode 100644 index 000000000..188dbd1b0 --- /dev/null +++ b/crates/docbin/src/officeart.rs @@ -0,0 +1,463 @@ +//! OfficeArt (Escher) drawing records, as Word 97-2003 stores them ([MS-ODRAW], public Microsoft +//! Open Specifications): the record header, the BLIP store and its pictures, shape containers with +//! their property tables, and the `OfficeArtContent` of the Table stream ([MS-DOC] §2.9.172). +//! +//! Everything here walks hostile bytes: record lengths are clamped to their parent, nesting depth +//! and record counts are capped, and decompressed metafiles are size-limited. + +use std::collections::HashMap; + +use wordcraft_doc::props::Rgb; + +/// Deepest container nesting walked (groups inside groups…). +const MAX_DEPTH: usize = 12; +/// Most records walked in one drawing tree or BLIP store. +const MAX_RECORDS: usize = 200_000; +/// Most BLIP store entries kept. +const MAX_BSE: usize = 10_000; +/// Most drawings (main document, headers) read from `OfficeArtContent`. +const MAX_DRAWINGS: usize = 8; +/// Largest decompressed metafile (bytes). +const MAX_METAFILE: usize = 64 << 20; +/// Largest DIB converted to PNG, per side (pixels). +const MAX_DIB_SIDE: u32 = 16_384; + +/// Record types ([MS-ODRAW] §2.2, §2.3). +pub(crate) mod rt { + pub(crate) const DGG_CONTAINER: u16 = 0xF000; + pub(crate) const BSTORE_CONTAINER: u16 = 0xF001; + pub(crate) const DG_CONTAINER: u16 = 0xF002; + pub(crate) const SPGR_CONTAINER: u16 = 0xF003; + pub(crate) const SP_CONTAINER: u16 = 0xF004; + pub(crate) const FBSE: u16 = 0xF007; + pub(crate) const FSPGR: u16 = 0xF009; + pub(crate) const FSP: u16 = 0xF00A; + pub(crate) const FOPT: u16 = 0xF00B; + pub(crate) const CLIENT_TEXTBOX: u16 = 0xF00D; + pub(crate) const CHILD_ANCHOR: u16 = 0xF00F; + pub(crate) const SECONDARY_FOPT: u16 = 0xF121; + pub(crate) const TERTIARY_FOPT: u16 = 0xF122; + pub(crate) const BLIP_EMF: u16 = 0xF01A; + pub(crate) const BLIP_WMF: u16 = 0xF01B; + pub(crate) const BLIP_PICT: u16 = 0xF01C; + pub(crate) const BLIP_JPEG: u16 = 0xF01D; + pub(crate) const BLIP_PNG: u16 = 0xF01E; + pub(crate) const BLIP_DIB: u16 = 0xF01F; + pub(crate) const BLIP_TIFF: u16 = 0xF029; + pub(crate) const BLIP_JPEG_CMYK: u16 = 0xF02A; +} + +/// One record: header fields and its body (clamped to what the parent holds). +#[derive(Clone, Copy, Debug)] +pub(crate) struct Rec<'a> { + pub(crate) inst: u16, + pub(crate) typ: u16, + pub(crate) body: &'a [u8], +} + +/// The record at the start of `buf` and the bytes it spans (header included). A body longer +/// than `buf` is cut to what is there. +pub(crate) fn record(buf: &[u8]) -> Option<(Rec<'_>, usize)> { + let h = buf.get(0..8)?; + let ver_inst = u16::from_le_bytes([h[0], h[1]]); + let typ = u16::from_le_bytes([h[2], h[3]]); + let len = u32::from_le_bytes([h[4], h[5], h[6], h[7]]) as usize; + let rest = buf.get(8..)?; + let len = len.min(rest.len()); + let body = rest.get(..len)?; + Some((Rec { inst: ver_inst >> 4, typ, body }, 8 + len)) +} + +/// The sibling records of a container body, at most `budget` of them (the budget is shared +/// across a whole walk, so a deep or wide tree cannot multiply the work). +pub(crate) fn children<'a>(body: &'a [u8], budget: &mut usize) -> Vec> { + let mut out = Vec::new(); + let mut at = 0usize; + while *budget > 0 { + let Some((rec, used)) = body.get(at..).and_then(record) else { break }; + *budget -= 1; + out.push(rec); + at = at.saturating_add(used); + } + out +} + +fn u16_at(b: &[u8], at: usize) -> Option { + Some(u16::from_le_bytes(b.get(at..at.checked_add(2)?)?.try_into().ok()?)) +} + +fn u32_at(b: &[u8], at: usize) -> Option { + Some(u32::from_le_bytes(b.get(at..at.checked_add(4)?)?.try_into().ok()?)) +} + +fn i32_at(b: &[u8], at: usize) -> Option { + u32_at(b, at).map(|v| v as i32) +} + +// --------------------------------------------------------------------------------------------- +// Pictures (BLIPs). + +/// A decoded picture: bytes ready for `Document::media` and their file extension. +pub(crate) struct Blip { + pub(crate) bytes: Vec, + pub(crate) ext: &'static str, + /// The format cannot be drawn (Macintosh PICT): the picture is kept as a placeholder of + /// the right size. + pub(crate) placeholder: bool, +} + +/// The picture of an `OfficeArtFBSE` body: its embedded BLIP, or the BLIP stored at `foDelay` +/// in `delay` (the WordDocument stream for the document's BLIP store, the Data stream for +/// inline pictures). `limit` caps the bytes produced. +pub(crate) fn fbse_blip(fbse: &[u8], delay: &[u8], limit: usize) -> Option { + // btWin32, btMacOS, rgbUid[16], tag, size, cRef, foDelay, unused, cbName, unused×2. + let size = u32_at(fbse, 20)? as usize; + let fo_delay = u32_at(fbse, 28)? as usize; + let cb_name = *fbse.get(33)? as usize; + let at = 36usize.checked_add(cb_name)?; + if let Some((rec, _)) = fbse.get(at..).and_then(record) { + return blip(&rec, limit); + } + // Not embedded: the record sits in the delay stream. + let end = fo_delay.checked_add(size.max(8))?.min(delay.len()); + let (rec, _) = record(delay.get(fo_delay..end)?)?; + blip(&rec, limit) +} + +/// Decode one `OfficeArtBlip*` record ([MS-ODRAW] §2.2.23-§2.2.31). +pub(crate) fn blip(rec: &Rec, limit: usize) -> Option { + // Each type has two instance values; the odd one carries a second 16-byte UID. + let uids = if rec.inst & 1 == 1 { 32 } else { 16 }; + match rec.typ { + rt::BLIP_PNG | rt::BLIP_JPEG | rt::BLIP_JPEG_CMYK | rt::BLIP_DIB | rt::BLIP_TIFF => { + let data = rec.body.get(uids + 1..)?; // + tag byte + if data.is_empty() || data.len() > limit { + return None; + } + match rec.typ { + rt::BLIP_PNG => Some(Blip { bytes: data.to_vec(), ext: "png", placeholder: false }), + rt::BLIP_JPEG | rt::BLIP_JPEG_CMYK => Some(Blip { bytes: data.to_vec(), ext: "jpeg", placeholder: false }), + rt::BLIP_TIFF => Some(Blip { bytes: data.to_vec(), ext: "tiff", placeholder: false }), + _ => dib_to_png(data), + } + } + rt::BLIP_EMF | rt::BLIP_WMF | rt::BLIP_PICT => { + // OfficeArtMetafileHeader: cbSize, rcBounds, ptSize, cbSave, compression, filter. + let hdr = rec.body.get(uids..uids + 34)?; + let cb_size = u32_at(hdr, 0)? as usize; + let cb_save = u32_at(hdr, 28)? as usize; + let compression = *hdr.get(32)?; + let raw = rec.body.get(uids + 34..)?; + let raw = raw.get(..cb_save.min(raw.len()))?; + let cap = limit.min(MAX_METAFILE); + let bytes = if compression == 0 { + let want = if cb_size > 0 { cb_size.min(cap) } else { cap }; + miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(raw, want).ok()? + } else { + if raw.len() > cap { + return None; + } + raw.to_vec() + }; + if bytes.is_empty() { + return None; + } + match rec.typ { + rt::BLIP_EMF => Some(Blip { bytes, ext: "emf", placeholder: false }), + rt::BLIP_WMF => Some(Blip { bytes, ext: "wmf", placeholder: false }), + _ => Some(Blip { bytes, ext: "pict", placeholder: true }), + } + } + _ => None, + } +} + +/// A device-independent bitmap (BITMAPINFOHEADER + palette + pixels) as a PNG: the DIB gets +/// the 14-byte BMP file header it lacks, is decoded with size limits and re-encoded. When the +/// bitmap can't be decoded the BMP bytes are kept (and render as a grey placeholder). +fn dib_to_png(dib: &[u8]) -> Option { + let header = u32_at(dib, 0)? as usize; + if !(12..=124).contains(&header) { + return None; + } + let (bits, used, compression) = + if header == 12 { (u16_at(dib, 10)? as usize, 0, 0) } else { (u16_at(dib, 14)? as usize, u32_at(dib, 32)? as usize, u32_at(dib, 16)?) }; + let entry = if header == 12 { 3 } else { 4 }; + let colors = if used > 0 { + used.min(256) + } else if bits <= 8 { + 1usize << bits + } else { + 0 + }; + // BI_BITFIELDS masks follow a 40-byte header. + let masks = if header == 40 && (compression == 3 || compression == 6) { if compression == 3 { 12 } else { 16 } } else { 0 }; + let off_bits = 14usize.checked_add(header)?.checked_add(colors * entry)?.checked_add(masks)?; + let file_size = 14usize.checked_add(dib.len())?; + let mut bmp = Vec::with_capacity(file_size); + bmp.extend_from_slice(b"BM"); + bmp.extend_from_slice(&u32::try_from(file_size).ok()?.to_le_bytes()); + bmp.extend_from_slice(&[0, 0, 0, 0]); + bmp.extend_from_slice(&u32::try_from(off_bits).ok()?.to_le_bytes()); + bmp.extend_from_slice(dib); + let mut reader = image::ImageReader::new(std::io::Cursor::new(&bmp)); + reader.set_format(image::ImageFormat::Bmp); + let mut limits = image::Limits::default(); + limits.max_image_width = Some(MAX_DIB_SIDE); + limits.max_image_height = Some(MAX_DIB_SIDE); + limits.max_alloc = Some(256 << 20); + reader.limits(limits); + let mut png = Vec::new(); + match reader.decode() { + Ok(img) if img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).is_ok() => { + Some(Blip { bytes: png, ext: "png", placeholder: false }) + } + _ => Some(Blip { bytes: bmp, ext: "bmp", placeholder: true }), + } +} + +// --------------------------------------------------------------------------------------------- +// Shapes. + +/// The shape properties we use, from `OfficeArtFOPT` and its secondary/tertiary tables +/// ([MS-ODRAW] §2.3). +#[derive(Clone, Debug, Default, PartialEq)] +pub(crate) struct Props { + /// `pib`: 1-based index of the picture in the BLIP store. + pub(crate) pib: Option, + /// Rotation, degrees clockwise. + pub(crate) rot: f32, + /// Crop fractions (left, top, right, bottom). + pub(crate) crop: [f32; 4], + pub(crate) fill_color: Option, + pub(crate) filled: Option, + pub(crate) line_color: Option, + /// Line width in EMUs. + pub(crate) line_width: Option, + pub(crate) line: Option, + /// An arrowhead at either end of a line. + pub(crate) arrowhead: bool, + /// `posh`/`posrelh`/`posv`/`posrelv`: alignment and reference frame (Word 2000 and later). + pub(crate) posh: Option, + pub(crate) posrelh: Option, + pub(crate) posv: Option, + pub(crate) posrelv: Option, + /// Distance from wrapped text (left, top, right, bottom), EMUs. + pub(crate) wrap_dist: [Option; 4], + /// `wzDescription`: the alternative text. + pub(crate) alt: String, +} + +impl Props { + /// Apply one property table (`OfficeArtFOPT` or a secondary/tertiary one): `count` 6-byte + /// entries, then the complex data of the entries flagged complex, in order. + pub(crate) fn apply(&mut self, body: &[u8], count: u16) { + let n = (count as usize).min(body.len() / 6); + let mut complex_at = n * 6; + for i in 0..n { + let Some(opid) = u16_at(body, i * 6) else { break }; + let Some(op) = u32_at(body, i * 6 + 2) else { break }; + let pid = opid & 0x3FFF; + if opid & 0x8000 != 0 { + let start = complex_at; + complex_at = complex_at.saturating_add(op as usize); + if pid == 0x0381 { + let end = complex_at.min(body.len()); + if let Some(data) = body.get(start..end) { + let units: Vec = + data.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).take_while(|u| *u != 0).take(4096).collect(); + self.alt = String::from_utf16_lossy(&units); + } + } + continue; + } + let fixed = |v: u32| (v as i32) as f32 / 65_536.0; + match pid { + 0x0004 => self.rot = fixed(op), + 0x0100..=0x0103 => { + // cropFromTop, cropFromBottom, cropFromLeft, cropFromRight. + let slot = [1, 3, 0, 2][(pid - 0x0100) as usize]; + let v = fixed(op); + self.crop[slot] = if v.is_finite() { v.clamp(0.0, 0.99) } else { 0.0 }; + } + 0x0104 => self.pib = Some(op), + 0x0181 => self.fill_color = color(op), + 0x01BF => { + // fFilled (bit 4) is valid when fUsefFilled (bit 20) is set. + if op & (1 << 20) != 0 { + self.filled = Some(op & (1 << 4) != 0); + } + } + 0x01C0 => self.line_color = color(op), + 0x01CB => self.line_width = Some(op), + 0x01D0 | 0x01D1 => self.arrowhead |= op != 0, + 0x01FF => { + // fLine (bit 3) is valid when fUsefLine (bit 19) is set. + if op & (1 << 19) != 0 { + self.line = Some(op & (1 << 3) != 0); + } + } + 0x0384..=0x0387 => self.wrap_dist[(pid - 0x0384) as usize] = Some(op), + 0x038F => self.posh = Some(op), + 0x0390 => self.posrelh = Some(op), + 0x0391 => self.posv = Some(op), + 0x0392 => self.posrelv = Some(op), + _ => {} + } + } + } +} + +/// An `OfficeArtCOLORREF` holding a literal RGB value (palette, scheme and system colour +/// references are not resolved). +fn color(v: u32) -> Option { + let [r, g, b, flags] = v.to_le_bytes(); + // fPaletteIndex, fSchemeIndex, fSysIndex. + if flags & 0x19 != 0 { + return None; + } + Some(Rgb(r, g, b)) +} + +/// One shape (`OfficeArtSpContainer`). +#[derive(Clone, Debug, Default)] +pub(crate) struct Sp { + pub(crate) spid: u32, + /// Shape type (`MSOSPT`), from the FSP's instance. + pub(crate) spt: u16, + /// FSP flags: fGroup, fChild, fPatriarch, fDeleted, fOleShape, fHaveMaster, fFlipH, fFlipV… + pub(crate) flags: u32, + pub(crate) props: Props, + /// `OfficeArtChildAnchor` (left, top, right, bottom) in the parent group's space. + pub(crate) child: Option<[i32; 4]>, + /// `OfficeArtFSPGR`: a group's own coordinate space. + pub(crate) group_space: Option<[i32; 4]>, + /// Has an `OfficeArtClientTextbox` (its text is in the text box story). + pub(crate) textbox: bool, +} + +impl Sp { + pub(crate) fn flip_h(&self) -> bool { + self.flags & (1 << 6) != 0 + } + pub(crate) fn flip_v(&self) -> bool { + self.flags & (1 << 7) != 0 + } + fn deleted(&self) -> bool { + self.flags & (1 << 3) != 0 + } +} + +/// A shape or a group of shapes. +#[derive(Clone, Debug)] +pub(crate) enum Node { + Shape(Sp), + /// A group: its own shape record (FSPGR, anchor) and members. + Group(Sp, Vec), +} + +impl Node { + pub(crate) fn sp(&self) -> &Sp { + match self { + Node::Shape(sp) | Node::Group(sp, _) => sp, + } + } +} + +/// Parse one `OfficeArtSpContainer`. +pub(crate) fn shape(body: &[u8], budget: &mut usize) -> Sp { + let mut sp = Sp::default(); + for rec in children(body, budget) { + match rec.typ { + rt::FSP => { + sp.spt = rec.inst; + sp.spid = u32_at(rec.body, 0).unwrap_or(0); + sp.flags = u32_at(rec.body, 4).unwrap_or(0); + } + rt::FOPT | rt::SECONDARY_FOPT | rt::TERTIARY_FOPT => sp.props.apply(rec.body, rec.inst), + rt::FSPGR | rt::CHILD_ANCHOR => { + let r = [0, 4, 8, 12].map(|o| i32_at(rec.body, o)); + if let [Some(l), Some(t), Some(r), Some(b)] = r { + if rec.typ == rt::FSPGR { + sp.group_space = Some([l, t, r, b]); + } else { + sp.child = Some([l, t, r, b]); + } + } + } + rt::CLIENT_TEXTBOX => sp.textbox = true, + _ => {} + } + } + sp +} + +/// Parse an `OfficeArtSpgrContainer`: the first shape is the group itself, the rest its members. +fn group(body: &[u8], depth: usize, budget: &mut usize) -> Option { + let recs = children(body, budget); + let mut it = recs.iter(); + let head = it.find(|r| r.typ == rt::SP_CONTAINER).map(|r| shape(r.body, budget))?; + let mut members = Vec::new(); + for rec in it { + if let Some(n) = node(rec, depth + 1, budget) { + members.push(n); + } + } + Some(Node::Group(head, members)) +} + +/// A shape or group record as a node (`None` for other records, deleted shapes or past the +/// depth cap). +fn node(rec: &Rec, depth: usize, budget: &mut usize) -> Option { + match rec.typ { + rt::SP_CONTAINER => Some(shape(rec.body, budget)).filter(|s| !s.deleted()).map(Node::Shape), + rt::SPGR_CONTAINER if depth < MAX_DEPTH => group(rec.body, depth, budget), + _ => None, + } +} + +/// The document's drawings: the BLIP store and the top-level shapes of each drawing by spid. +#[derive(Default)] +pub(crate) struct Content<'a> { + /// `OfficeArtFBSE` bodies, in BLIP store order (`pib` 1 = the first). + pub(crate) bstore: Vec<&'a [u8]>, + /// The main document's shapes (`dgglbl` 0) and the headers' (`dgglbl` 1), by spid. + pub(crate) main: HashMap, + pub(crate) header: HashMap, +} + +/// Parse `OfficeArtContent` (the `fcDggInfo` blob of the Table stream): an +/// `OfficeArtDggContainer` followed by (`dgglbl`, `OfficeArtDgContainer`) drawings. +pub(crate) fn content(blob: &[u8]) -> Content<'_> { + let mut out = Content::default(); + let mut budget = MAX_RECORDS; + let Some((dgg, mut at)) = record(blob) else { return out }; + if dgg.typ == rt::DGG_CONTAINER { + for rec in children(dgg.body, &mut budget) { + if rec.typ == rt::BSTORE_CONTAINER { + out.bstore = children(rec.body, &mut budget).into_iter().filter(|r| r.typ == rt::FBSE).map(|r| r.body).take(MAX_BSE).collect(); + } + } + } + for _ in 0..MAX_DRAWINGS { + let Some(&label) = blob.get(at) else { break }; + let Some((dg, used)) = blob.get(at + 1..).and_then(record) else { break }; + at = at.saturating_add(1 + used); + if dg.typ != rt::DG_CONTAINER { + continue; + } + let into = if label == 0 { &mut out.main } else { &mut out.header }; + for rec in children(dg.body, &mut budget) { + if rec.typ != rt::SPGR_CONTAINER { + continue; + } + // The top group: its first shape is the patriarch; the members are the drawing's shapes. + if let Some(Node::Group(_, members)) = group(rec.body, 0, &mut budget) { + for n in members { + into.entry(n.sp().spid).or_insert(n); + } + } + } + } + out +} diff --git a/crates/docbin/src/tests.rs b/crates/docbin/src/tests.rs index 52582193d..facf4d9b2 100644 --- a/crates/docbin/src/tests.rs +++ b/crates/docbin/src/tests.rs @@ -7,6 +7,8 @@ use proptest::prelude::*; use super::read; +mod drawing; + /// Offset in the WordDocument stream where test text pieces are placed (past the FIB). const TEXT_FC: u32 = 0x600; /// Offset in the Table stream where the test Clx is placed. @@ -1628,7 +1630,7 @@ fn picture_bytes_are_capped_per_document() { pics.limit = 1000; let first = pics.get(&data, 0).expect("first fits"); // Same location again: no new bytes. - assert_eq!(pics.get(&data, 0).map(|p| p.0), Some(first.0.clone())); + assert_eq!(pics.get(&data, 0).map(|p| p.key), Some(first.key.clone())); // A second distinct picture would exceed the cap. assert!(pics.get(&data, a.len() as u32).is_none()); assert_eq!(pics.media.len(), 1); diff --git a/crates/docbin/src/tests/drawing.rs b/crates/docbin/src/tests/drawing.rs new file mode 100644 index 000000000..ef3f269ac --- /dev/null +++ b/crates/docbin/src/tests/drawing.rs @@ -0,0 +1,276 @@ +//! Pictures and drawings: OfficeArt records ([MS-ODRAW]) hand-built for these tests from the +//! specification, with tiny pictures generated in code. No file produced by Word is used. + +use proptest::prelude::*; +use wordcraft_doc::para::{Anchor, InlineObject, ShapeKind, Wrap}; +use wordcraft_doc::props::Rgb; + +use super::{StructSpec, grpprl, plc, read, struct_doc, structured_spec}; +use crate::officeart; + +/// One OfficeArt record: header (recVer, recInstance, recType, recLen) + body. +fn rec(ver: u16, inst: u16, typ: u16, body: &[u8]) -> Vec { + let vi = (inst << 4) | (ver & 0xF); + [vi.to_le_bytes().as_slice(), &typ.to_le_bytes(), &(body.len() as u32).to_le_bytes(), body].concat() +} + +fn container(typ: u16, kids: &[Vec]) -> Vec { + rec(0xF, 0, typ, &kids.concat()) +} + +/// `OfficeArtFSP`: shape type, spid, flags. +fn fsp(spt: u16, spid: u32, flags: u32) -> Vec { + rec(2, spt, 0xF00A, &[spid.to_le_bytes(), flags.to_le_bytes()].concat()) +} + +/// `OfficeArtFOPT` with simple properties and complex ones (data appended in order). +fn fopt(simple: &[(u16, u32)], complex: &[(u16, Vec)]) -> Vec { + let mut body = Vec::new(); + for (pid, op) in simple { + body.extend_from_slice(&pid.to_le_bytes()); + body.extend_from_slice(&op.to_le_bytes()); + } + for (pid, data) in complex { + body.extend_from_slice(&(pid | 0x8000).to_le_bytes()); + body.extend_from_slice(&(data.len() as u32).to_le_bytes()); + } + for (_, data) in complex { + body.extend_from_slice(data); + } + rec(3, (simple.len() + complex.len()) as u16, 0xF00B, &body) +} + +/// A shape container with the given records. +fn sp(kids: &[Vec]) -> Vec { + container(0xF004, kids) +} + +/// A real 2×2 PNG. +fn tiny_png() -> Vec { + let img = image::RgbaImage::from_pixel(2, 2, image::Rgba([10, 20, 30, 255])); + let mut out = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out +} + +/// `OfficeArtBlipPNG` (one UID, tag) and the `OfficeArtFBSE` embedding it. +fn fbse_png(png: &[u8]) -> Vec { + let blip = rec(0, 0x6E0, 0xF01E, &[[0u8; 16].as_slice(), &[0xFF], png].concat()); + let mut body = vec![6u8, 6]; + body.extend_from_slice(&[0u8; 16]); // rgbUid + body.extend_from_slice(&0u16.to_le_bytes()); // tag + body.extend_from_slice(&(blip.len() as u32).to_le_bytes()); // size + body.extend_from_slice(&1u32.to_le_bytes()); // cRef + body.extend_from_slice(&0u32.to_le_bytes()); // foDelay + body.extend_from_slice(&[0, 0, 0, 0]); // unused1, cbName, unused2, unused3 + body.extend_from_slice(&blip); + rec(2, 6, 0xF007, &body) +} + +/// UTF-16LE, NUL-terminated (a complex string property). +fn wz(s: &str) -> Vec { + s.encode_utf16().chain([0]).flat_map(|u| u.to_le_bytes()).collect() +} + +/// A `Spa` (26 bytes): spid, bounds in twips, flags, cTxbx. +fn spa(spid: u32, [l, t, r, b]: [i32; 4], flags: u16) -> Vec { + [spid.to_le_bytes().as_slice(), &l.to_le_bytes(), &t.to_le_bytes(), &r.to_le_bytes(), &b.to_le_bytes(), &flags.to_le_bytes(), &0u32.to_le_bytes()] + .concat() +} + +/// `Spa` flags from bx, by, wr and fBelowText. +fn spa_flags(bx: u16, by: u16, wr: u16, below: bool) -> u16 { + (bx << 1) | (by << 3) | (wr << 5) | (u16::from(below) << 14) +} + +/// `OfficeArtContent`: a DggContainer holding a BLIP store, then one main-document drawing. +fn art_content(fbses: &[Vec], shapes: &[Vec]) -> Vec { + let bstore = rec(0xF, fbses.len() as u16, 0xF001, &fbses.concat()); + let dgg = container(0xF000, &[bstore]); + let patriarch = sp(&[fsp(0, 1024, 0b101)]); + let spgr = container(0xF003, &[[patriarch].as_slice(), shapes].concat()); + let dg = container(0xF002, &[rec(0, 1, 0xF008, &[0u8; 8]), spgr]); + [dgg, vec![0u8], dg].concat() +} + +/// A PICF for an inline OfficeArt picture: `MM_SHAPE`, 1 × 0.5 inch goal size at 100 %. +fn picf_shape(rest: &[u8]) -> Vec { + let mut picf = vec![0u8; 0x44]; + picf[0..4].copy_from_slice(&((0x44 + rest.len()) as u32).to_le_bytes()); + picf[4..6].copy_from_slice(&0x44u16.to_le_bytes()); + picf[6..8].copy_from_slice(&0x0064u16.to_le_bytes()); // mfpf.mm = MM_SHAPE + picf[28..30].copy_from_slice(&1440i16.to_le_bytes()); + picf[30..32].copy_from_slice(&720i16.to_le_bytes()); + picf[32..34].copy_from_slice(&1000u16.to_le_bytes()); + picf[34..36].copy_from_slice(&1000u16.to_le_bytes()); + [picf.as_slice(), rest].concat() +} + +#[test] +fn inline_officeart_picture_with_crop_and_alt_text() { + let png = tiny_png(); + // A picture frame whose pib names the first FBSE that follows it, a quarter cropped + // off the left, with alt text. + let shape = sp(&[fsp(75, 1025, 0xA00), fopt(&[(0x0102, 0x4000), (0x0104, 1)], &[(0x0381, wz("Logo"))])]); + let data = picf_shape(&[shape, fbse_png(&png)].concat()); + let text = "Pic\u{1}!\r"; + let ccp = text.chars().count() as u32; + let spec = StructSpec { text, data_stream: data, chpx_runs: vec![(0, grpprl(&[(0x6A03, &0u32.to_le_bytes())]))], ..structured_spec(text, ccp) }; + let doc = read(&struct_doc(&spec)).expect("opens"); + let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para"); + assert_eq!(p0.text, "Pic\u{FFFC}!"); + let Some(InlineObject::Image { media, w, h, alt, crop, float, .. }) = p0.objects.first() else { panic!("no image: {:?}", p0.objects) }; + assert_eq!((*w, *h), (54.0, 36.0)); // 72 pt less a quarter, × 36 pt + assert_eq!(*crop, [0.25, 0.0, 0.0, 0.0]); + assert_eq!(alt, "Logo"); + assert_eq!(float.wrap, Wrap::Inline); + assert_eq!(doc.media.get(media).map(|b| b.as_slice()), Some(png.as_slice())); +} + +#[test] +fn floating_picture_from_the_blip_store_keeps_position_and_wrap() { + let png = tiny_png(); + let pic = sp(&[fsp(75, 1025, 0xA00), fopt(&[(0x0104, 1)], &[])]); + let art = art_content(&[fbse_png(&png)], &[pic]); + // Two inches by one, 1" from the page's left edge and 0.5" below the top margin; square wrap. + let spas = plc(&[1, 3], &spa(1025, [1440, 720, 4320, 2160], spa_flags(1, 0, 2, false))); + let text = "A\u{8}B\r"; + let ccp = text.chars().count() as u32; + let spec = StructSpec { text, extra_pairs: vec![(40, spas), (50, art)], ..structured_spec(text, ccp) }; + let doc = read(&struct_doc(&spec)).expect("opens"); + let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para"); + assert_eq!(p0.text, "A\u{FFFC}B"); + let Some(InlineObject::Image { media, w, h, float, .. }) = p0.objects.first() else { panic!("no image: {:?}", p0.objects) }; + assert_eq!((*w, *h), (144.0, 72.0)); + assert_eq!((float.wrap, float.h_rel, float.v_rel, float.x, float.y), (Wrap::Square, Anchor::Page, Anchor::Margin, 72.0, 36.0)); + assert_eq!(doc.media.get(media).map(|b| b.as_slice()), Some(png.as_slice())); +} + +#[test] +fn shapes_keep_kind_colours_wrap_and_text_box_text() { + // A red ellipse with a 2 pt blue outline behind the text, and an unfilled text box. + let ellipse = sp(&[fsp(3, 1026, 0xA00), fopt(&[(0x0181, 0x0000_00FF), (0x01C0, 0x00FF_0000), (0x01CB, 25_400)], &[])]); + let tb = sp(&[fsp(202, 1027, 0xA00), fopt(&[(0x01BF, 1 << 20)], &[]), rec(0, 0, 0xF00D, &0x0001_0000u32.to_le_bytes())]); + let art = art_content(&[], &[ellipse, tb]); + let spas = + plc(&[1, 2, 4], &[spa(1026, [0, 0, 1440, 1440], spa_flags(2, 2, 3, true)), spa(1027, [0, 0, 2880, 720], spa_flags(0, 0, 1, false))].concat()); + // The text box story: an FTXBXS whose lid is the shape's spid (cTxbx, cReusable, fReusable, + // reserved, lid, txidUndo). + let ftxbxs = + [1i32.to_le_bytes().as_slice(), &0i32.to_le_bytes(), &0i16.to_le_bytes(), &0i32.to_le_bytes(), &1027u32.to_le_bytes(), &0i32.to_le_bytes()] + .concat(); + let txbx = plc(&[0, 7], &ftxbxs); + let text = "A\u{8}\u{8}B\r"; + let ccp = text.chars().count() as u32; + // The text box story is stored as the subdocument right after the main text; the endnote + // slot of the fixture writes it there and the FIB is then patched to call it the text box + // subdocument (ccpEdn = 0, ccpTxbx = 7). + let spec = + StructSpec { text, endnote_stories: vec!["Inside\r"], extra_pairs: vec![(40, spas), (50, art), (56, txbx)], ..structured_spec(text, ccp) }; + let file = patch_word(&struct_doc(&spec), &[(0x60, 0), (0x64, 7)]); + let doc = read(&file).expect("opens"); + let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para"); + assert_eq!(p0.objects.len(), 2, "{:?}", p0.objects); + let Some(InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, .. }) = p0.objects.first() else { panic!("{:?}", p0.objects) }; + assert_eq!((*kind, *w, *h), (ShapeKind::Ellipse, 72.0, 72.0)); + assert_eq!((*fill, *stroke, *stroke_width), (Some(Rgb(255, 0, 0)), Some(Rgb(0, 0, 255)), 2.0)); + assert_eq!((float.wrap, float.h_rel, float.v_rel), (Wrap::BehindText, Anchor::Column, Anchor::Paragraph)); + let Some(InlineObject::Shape { kind, fill, float, story, .. }) = p0.objects.get(1) else { panic!("{:?}", p0.objects) }; + assert_eq!((*kind, *fill, float.wrap), (ShapeKind::TextBox, None, Wrap::TopAndBottom)); + let part = story.and_then(|id| doc.parts.get(&id)).expect("text box story part"); + assert_eq!(part.kind, wordcraft_doc::PartKind::TextBox); + assert_eq!(part.blocks.first().and_then(|b| b.as_para()).map(|p| p.text.as_str()), Some("Inside")); +} + +/// Overwrite little-endian u32s in the WordDocument stream of a compound file. +fn patch_word(file: &[u8], at: &[(usize, u32)]) -> Vec { + use std::io::Read; + let mut comp = cfb::CompoundFile::open(std::io::Cursor::new(file.to_vec())).unwrap(); + let mut streams = Vec::new(); + for name in ["WordDocument", "0Table", "Data"] { + if let Ok(mut s) = comp.open_stream(format!("/{name}")) { + let mut v = Vec::new(); + s.read_to_end(&mut v).unwrap(); + if name == "WordDocument" { + for (o, x) in at { + v[*o..*o + 4].copy_from_slice(&x.to_le_bytes()); + } + } + streams.push((name, v)); + } + } + let refs: Vec<(&str, &[u8])> = streams.iter().map(|(n, v)| (*n, v.as_slice())).collect(); + super::cfb_file(&refs) +} + +#[test] +fn dib_becomes_png_and_compressed_metafiles_inflate() { + // A 2×1 24-bit DIB (BITMAPINFOHEADER, rows padded to 4 bytes). + let mut dib = Vec::new(); + for v in [40u32, 2, 1] { + dib.extend_from_slice(&v.to_le_bytes()); + } + dib.extend_from_slice(&1u16.to_le_bytes()); + dib.extend_from_slice(&24u16.to_le_bytes()); + dib.extend_from_slice(&[0u8; 24]); + dib.extend_from_slice(&[0, 0, 255, 0, 255, 0, 0, 0]); + let r = rec(0, 0x7A8, 0xF01F, &[[0u8; 16].as_slice(), &[0xFF], &dib].concat()); + let (dib_rec, _) = officeart::record(&r).unwrap(); + let blip = officeart::blip(&dib_rec, 1 << 20).expect("DIB decodes"); + assert_eq!(blip.ext, "png"); + let img = image::load_from_memory(&blip.bytes).unwrap().to_rgb8(); + assert_eq!((img.width(), img.height(), img.get_pixel(0, 0).0), (2, 1, [255, 0, 0])); + + // A deflated WMF stays a WMF (the metafile renderer draws it). + let wmf: Vec = [1u16, 9, 0x300].iter().flat_map(|v| v.to_le_bytes()).chain([0u8; 12]).collect(); + let packed = miniz_oxide::deflate::compress_to_vec_zlib(&wmf, 6); + let mut hdr = Vec::new(); + hdr.extend_from_slice(&(wmf.len() as u32).to_le_bytes()); + hdr.extend_from_slice(&[0u8; 24]); // rcBounds, ptSize + hdr.extend_from_slice(&(packed.len() as u32).to_le_bytes()); + hdr.extend_from_slice(&[0x00, 0xFE]); // compression = deflate, filter + let r = rec(0, 0x216, 0xF01B, &[[0u8; 16].as_slice(), &hdr, &packed].concat()); + let (wmf_rec, _) = officeart::record(&r).unwrap(); + let blip = officeart::blip(&wmf_rec, 1 << 20).expect("WMF inflates"); + assert_eq!((blip.ext, blip.bytes), ("wmf", wmf)); +} + +#[test] +fn deeply_nested_groups_and_lying_lengths_are_bounded() { + // 2000 nested group containers: the walk stops at its depth cap (no stack overflow). + let mut g = sp(&[fsp(1, 2000, 0)]); + for _ in 0..2_000 { + g = container(0xF003, &[sp(&[fsp(0, 2001, 1)]), g]); + } + let _ = officeart::content(&art_content(&[], &[g])); + // A metafile claiming 4 GB uncompressed with garbage data, and an FBSE pointing far out. + let mut hdr = vec![0xFFu8; 4]; + hdr.extend_from_slice(&[0u8; 24]); + hdr.extend_from_slice(&u32::MAX.to_le_bytes()); + hdr.extend_from_slice(&[0, 0]); + let r = rec(0, 0x3D4, 0xF01A, &[[0u8; 16].as_slice(), &hdr, &[0x78, 0x9C, 1, 2, 3]].concat()); + let (emf, _) = officeart::record(&r).unwrap(); + assert!(officeart::blip(&emf, 1 << 20).is_none()); + let mut fbse = vec![0u8; 36]; + fbse[28..32].copy_from_slice(&u32::MAX.to_le_bytes()); + assert!(officeart::fbse_blip(&fbse, &[0u8; 64], 1 << 20).is_none()); +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(128))] + + #[test] + fn junk_drawings_never_panic(art in proptest::collection::vec(any::(), 0..2048), spas in proptest::collection::vec(any::(), 0..512), data in proptest::collection::vec(any::(), 0..1024)) { + let text = "A\u{8}\u{1}\u{8}B\r"; + let ccp = text.chars().count() as u32; + let picf = picf_shape(&data); + let spec = StructSpec { + text, + data_stream: picf, + chpx_runs: vec![(0, grpprl(&[(0x6A03, &0u32.to_le_bytes())]))], + extra_pairs: vec![(40, spas), (50, art.clone()), (56, art)], + ..structured_spec(text, ccp) + }; + let _ = read(&struct_doc(&spec)); + } +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ae4a855ec..45266a8e3 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (imported TOC result boundaries preserved; native save temporary-file ownership and failure protection; previously trivial: Print Markup: comments in PDF/print/copies, #589; previously trivial: SmartArt written from WordCraft's model, #497; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (.doc pictures, shapes and text boxes read, #551; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the share of a typical real-world file's content and formatting that survives (read: shows correctly; @@ -22,7 +22,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ | `.docx` Word Document (ECMA-376 Transitional) | 70% | R/W | ✅ | ✅ | ~65% | 70–110 | `crates/docx/tests` (roundtrip 34, fixtures 29, malformed 8, bidi 5, citations 5); synthetic fixtures only | Word opens our files. Untested on a real-world corpus. Details below | | `.docx` Strict Open XML | 1% | R/W | ✅ (namespace mapping in `xml.rs`) | ❌ | ~60% | 2–4 | one fixture | Writing Strict is rare | | `.docm` / `.dotx` / `.dotm` | 3% | R/W | ✅ | ✅ | ~65% | incl. above | `macro_packages.rs` | Macros and signatures kept on save (#172); macros never run | -| `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~55% | 25–40 | `crates/docbin/src/tests.rs` (56) | Spec-based reader ([MS-DOC]): text, formatting, styles, sections, headers/footers, tables, lists, notes, fields, bookmarks, pictures. Metafile (WMF/EMF) pictures, Word 6/95 files and encrypted files are refused or dropped. No writer | +| `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~60% | 25–40 | `crates/docbin/src/tests.rs`, `tests/drawing.rs` (56) | Spec-based reader ([MS-DOC]): text, formatting, styles, sections, headers/footers, tables, lists, notes, fields, bookmarks; inline and floating pictures (PNG, JPEG, DIB, TIFF, EMF, WMF; Macintosh PICT as a sized placeholder) with crop, alt text, position and wrap; basic shapes, lines, groups and text boxes ([MS-ODRAW]). Freeform shapes, z-order and Word 6/95 or encrypted files are dropped or refused. No writer | | PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133), links, outline, tagging. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. No PDF/A option, no "best for printing/online" choice | | PDF open (PDF Reflow, Word for Windows) | 1% | R | ❌ | — | 0% | 30–50 | — | Converting a PDF into an editable document. A sibling app (PdfCraft) may supply the parser | | `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. Thin: comments, notes, revisions, sections, RTL | @@ -156,6 +156,7 @@ raise readiness estimates. No Word-derived files are fixtures. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | `.doc`: inline and floating pictures (BLIP store, PNG/JPEG/DIB/TIFF/EMF/WMF), basic shapes, groups and text boxes read from OfficeArt drawings (#551); fidelity ~55% → ~60% | | 2026-10-11 | trivial | Word XML Document (Flat OPC) read and written, Word 2003 XML read (#548) | | 2026-10-11 | trivial | #534: DOCX indents in characters read and written; `.doc` first-line indents (`sprmPDxaLeft1`, its two-byte operand, and `sprmPDxaLeft1_80`), indents in characters (`sprmPDxc*`) and exact/at-least line spacing (LSPD) read correctly | | 2026-10-11 | trivial | List and page number formats: all ST_NumberFormat values round-trip; Chinese, Japanese and Korean counting, legal and digit formats, stems/branches, kana, hangul, enclosed and full-width numbers drawn; unknown values are decimal; .doc number format codes mapped (#534) | diff --git a/docs/gaps.md b/docs/gaps.md index a0b05da98..83e53140f 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** trivial (imported TOC boundaries preserved with refresh coverage; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (.doc pictures and drawings read, #551; previously trivial: #24 narrowed: Customize Keyboard and Customize Ribbon landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 The work list. One entry per shortfall, ranked by how much it stops a Word user from switching (**B** = blocks beta). Each says what's missing, the evidence, who it hurts, an Opus 5.5 agent @@ -26,7 +26,7 @@ Native save reliability: synthetic cases on macOS reproduced loss through the pr | 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); 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** (editing charts and SmartArt from Word, more chart types and formatting) | | `insert.chart` and Chart Design basics landed (#409): WordCraft's own charts are editable; charts from Word stay read-only (no embedded-workbook editing), no combo/3D/radar/stock charts, axis or series formatting; Insert › SmartArt landed (#497) for graphics WordCraft makes | Reports and business documents | 35–55 | [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) | +| 14 | **`.doc` can't be written; 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) | | 16 | **Right-to-left completeness**: kashida, RTL in HTML/ODT/RTF; eight older RTL bug reports to re-verify after #207 (#215, #211, #199, #66, #63, #48, #19). RTL tables and sections landed (#362) | | [layout-parity.md](layout-parity.md) | Arabic, Persian, Hebrew writers | 7–11 | [layout-parity.md](layout-parity.md) | | 17 | **East Asian typography**: Asian Typography options (Phonetic Guide landed, #288; Enclose Characters landed, #297; document grid landed, #391; vertical text landed, #491) | | Catalog misses `para.asianTypography` | Chinese, Japanese and Korean documents | 11–19 | [layout-parity.md](layout-parity.md) | @@ -69,6 +69,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | `.doc` pictures, shapes and text boxes read (#551); gap #14 narrowed to the writer and Word 6/95 files | | 2026-10-11 | trivial | Table column and row borders and the resize handle drag on the page (#217); UI/UX gap narrowed to the ruler's column markers | | 2026-10-11 | trivial | UI/UX gaps list: the system's animation setting is now read on macOS and the Linux/BSD desktops; trying it on real machines remains | | 2026-10-11 | trivial | Animation effects setting landed (File › Options › Accessibility); UI/UX gaps list: the system's animation setting isn't read on macOS or Linux/BSD yet | From 99fbd1861a94770f19d4e59c2ebea4ed1ce2c521 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Mon, 12 Oct 2026 09:46:44 +0200 Subject: [PATCH 04/88] i18n: add Italian interface (it) (#598) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * i18n: add Italian interface (it) New it.tsv catalog with 1,378 entries: every key of the existing catalogs plus 51 strings that go through tl! but were in no catalog yet (File › Info properties, File › Home greeting, list levels, Equation tab gallery and tooltips, Draw Table, About tabs). Ribbon tabs, groups and command names use the names Italian Word users know; all other text is written from the meaning of the English. Registers it in LANGUAGES (it, it-IT, it-CH resolve here), adds locale, saved-preference, coverage and bundled-font tests, and updates README, control protocol, localization parity and roadmap. Refs #594. * i18n(it): shorter Wrap Text Sides label Lati del testo a capo matches its parent menu, Testo a capo. * i18n(it): translate the 590 strings main added since the rebase Options, accessibility, alt text, content controls, document grid, templates, index, citations and the other labels every catalog gained on main. The Italian coverage test (every key of the other catalogs) passes again. * i18n(it): Issue is Fascicolo in the bibliography source form * i18n(it): translate the 351 strings main added (equation gallery, built-in styles, paper sizes, animation settings, file browser) --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- README.md | 2 +- ROADMAP.md | 7 +- crates/ui-egui/src/i18n/it.tsv | 2421 ++++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/mod.rs | 4 +- crates/ui-egui/src/i18n/tests.rs | 57 + docs/control-protocol.md | 2 +- docs/localization-parity.md | 19 +- 7 files changed, 2500 insertions(+), 12 deletions(-) create mode 100644 crates/ui-egui/src/i18n/it.tsv diff --git a/README.md b/README.md index d273d490a..51a184577 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ Every screenshot below is WordCraft itself, rendered offscreen by its own UI tes | **References** | Table of contents, footnotes and endnotes, citations and bibliography (APA, MLA, Chicago, IEEE), captions, table of figures, cross-references, index, table of authorities | | **Review** | Spelling and grammar with suggestions, thesaurus, word count, comments in margin balloons or a pane, track changes, accept/reject, compare documents, restrict editing, accessibility checker, document inspector | | **Mailings** | Mail merge from CSV, merge fields, address block, greeting line, rules, preview, finish to a document; envelopes and labels | -| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe or Русский (follows the system language by default) | +| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский or Italiano (follows the system language by default) | | **Files** | .docx read/write (opens in Word), Word 97-2003 .doc import, PDF export, .odt, .rtf, .html, .md, .tex, .txt import/export, page images | The honest picture, area by area, is in [ROADMAP.md](ROADMAP.md), the diff --git a/ROADMAP.md b/ROADMAP.md index 3956c29d1..788c97060 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 (table borders and the resize handle drag on the page, #217; 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 (Italian interface, #594; previously trivial: table borders and the resize handle drag on the page, #217; 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 @@ -110,8 +110,8 @@ 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`), 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 +Also shipped: Traditional Chinese (`zh-hant`), Ukrainian (`uk`), Serbian (`sr`, `sr-latn`, #250) and Russian (`ru`), all partial (~95%). Estonian (`et`), Turkish (`tr`) and Italian (`it`, #594) now have complete 1,307-entry interface catalogs with +system-locale selection and a saved language choice (Italian also has 51 strings none of the others had yet); Estonian, Turkish and Italian proofing are not included. Word ships 30 interface languages and proofing for ~50. Detail: [docs/localization-parity.md](docs/localization-parity.md). ## Upcoming @@ -187,6 +187,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-11 | Field codes like Word's field keys (#339): show codes (Alt+F9, Shift+F9), edit, unlink, lock and step through fields | | 2026-10-11 | Word's common keyboard shortcuts (#330): 101 → 125 of ~250 | | 2026-10-11 | Turkish (`tr`) interface: all 1,307 catalog entries, `tr-TR`/`tr-CY` system locales and a saved language choice; command search keeps Turkish dotted and dotless I apart (`italik` finds İtalik); locale, catalog, search and bundled-font tests; no Turkish proofing resources | +| 2026-10-11 | Italian (`it`) interface (#594): 2,399 entries, feature names as Italian Word users know them, locale and saved-preference tests, bundled-font checks; no Italian proofing resources | | 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 | DOCX paragraph defaults: preserve omission separately from explicit empty defaults, with repeated-save and default-edit regressions | diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv new file mode 100644 index 000000000..c72f21b58 --- /dev/null +++ b/crates/ui-egui/src/i18n/it.tsv @@ -0,0 +1,2421 @@ +# Italian (it) interface catalog. Columns, separated by a TAB: +# context English source translation +# context empty (WordCraft uses no contexts yet) +# source the English text exactly as in the code (tl!(...), ribbon labels, command labels) +# escapes \n \t \\ inside a column. `{name}` placeholders must appear in both columns, and a +# trailing "…" in one needs one in the other. +# Lines starting with # and blank lines are ignored. A missing entry shows in English. +# +# Standard Italian. Commands are second-person imperatives (Salva, Copia, Inserisci); second-person +# text uses tu; labels use sentence case; decimals use a comma (1,5); product and format names stay +# as they are (WordCraft, PDF, Markdown, MCP …). +# Feature names (ribbon tabs, groups and command names) use the names Italian word-processor users +# already know, as AGENTS.md allows for feature names. Every other entry (descriptions, tooltips, +# messages, dialog text) was written from the plain meaning of the English text in ordinary +# computing vocabulary; no localisation string table or translated text was copied. + + (no style) (nessuno stile) + (no suggestions) (nessun suggerimento) + 1.5 Line Spacing Interlinea 1,5 + A free, open-source word processor written from scratch in Rust.\nPart of the Crafting Apps from the ArtCraft team. Un elaboratore di testi gratuito e open source, scritto da zero in Rust.\nFa parte delle Crafting Apps del team ArtCraft. + About Informazioni + About WordCraft Informazioni su WordCraft + Accept Accetta + Accept All Changes Accetta tutte le modifiche + Accept This Change Accetta questa modifica + Accessibility Accessibilità + Add Aggiungi + Add Object to Selection Aggiungi oggetto alla selezione + Add Space Before Paragraph Aggiungi spazio prima del paragrafo + Add space between Asian and Latin text Aggiungi spazio tra testo asiatico e latino + Add space between Asian text and numbers Aggiungi spazio tra testo asiatico e numeri + Add tab stop at: Aggiungi tabulazione a: + Add Text Aggiungi testo + Add a Table Aggiungi una tabella + Add a comment Aggiungi un commento + Add to Dictionary Aggiungi al dizionario + Add to Style Gallery Aggiungi alla raccolta stili + Address Block Blocco di indirizzi + Address: Indirizzo: + Address\nBlock Blocco di\nindirizzi + Adjust Regola + Advanced Avanzate + Advanced Find Ricerca avanzata + Advanced Find… Ricerca avanzata… + After: Dopo: + Agents Agenti + Align Allinea + Align Left Allinea a sinistra + Align Right Allinea a destra + Aligned at: Allineato a: + Alignment Allineamento + All Borders Tutti i bordi + All Caps Tutto maiuscole + All Markup Tutte le revisioni + All caps Tutto maiuscole + Allow hanging punctuation Consenti punteggiatura sospesa + Allow Latin text to wrap in the middle of a word Consenti di dividere il testo latino a metà parola + Alt Text Testo alternativo + Apply Asian line-breaking rules (kinsoku) Applica le regole asiatiche di interruzione di riga (kinsoku) + Apply Character Style Applica stile carattere + Apply Style Applica stile + Arrange Disponi + Arrange\nAll Disponi\ntutto + Arrow Freccia + Artistic Effects Effetti artistici + Asian Typography Tipografia asiatica + Author Autore + AutoCorrect Options Opzioni di correzione automatica + AutoFit Adatta + AutoFit Contents Adatta al contenuto + AutoFit Window Adatta alla finestra + AutoSave Salvataggio automatico + AutoSave saves every change to the file (needs a saved document) Il salvataggio automatico salva ogni modifica nel file (richiede un documento salvato) + AutoSave isn't available in the browser: it can't write to your files. Save downloads a copy. Il salvataggio automatico non è disponibile nel browser: non può scrivere nei tuoi file. Salva scarica una copia. + AutoSave only keeps .docx, .odt and .rtf files; {name} can't keep all formatting. Save as .docx to turn on AutoSave. Il salvataggio automatico supporta solo file .docx, .odt e .rtf; {name} non può conservare tutta la formattazione. Salva come .docx per attivare il salvataggio automatico. + Balloons Fumetti + Bullet Punto elenco + By author Per autore + Changed lines: Righe modificate: + Click level to modify: Fai clic sul livello da modificare: + Color only Solo colore + Define New Multilevel List Definisci nuovo elenco a più livelli + Define New Multilevel List… Definisci nuovo elenco a più livelli… + Deletions: Eliminazioni: + Enter formatting for number: Formato del numero: + Follow number with: Dopo il numero: + Formatting Formattazione + Include level number from: Includi numero di livello da: + Ink Penna + Insertions and Deletions Inserimenti ed eliminazioni + Insertions: Inserimenti: + Left border Bordo sinistro + Legal style numbering Numerazione stile legale + Link level to style: Collega livello allo stile: + List Definition Definizione elenco + Nothing Niente + Number alignment: Allineamento numero: + Number format Formato numero + Number style for this level: Stile numero per questo livello: + Outside border Bordo esterno + Restart list after: Riavvia elenco dopo: + Right border Bordo destro + Save the document to turn on AutoSave Salva il documento per attivare il salvataggio automatico + Save the document to turn on AutoSave (a file you have only opened isn't rewritten until you save it) Salva il documento per attivare il salvataggio automatico (un file solo aperto non viene riscritto finché non lo salvi) + AutoText Testo automatico + Automatic Automatico + Automatic ({language}) Automatico ({language}) + Automatic Table 1 (3 levels) Sommario automatico 1 (3 livelli) + Automatic Table 2 (2 levels) Sommario automatico 2 (2 livelli) + Automatic follows your system's language. Menus and commands change; your documents don't. Automatico segue la lingua del sistema. Cambiano menu e comandi; i tuoi documenti no. + Backspace Backspace + Banded Columns Colonne alternate + Banded Rows Righe alternate + Before: Prima: + Bibliography Bibliografia + Binary assets added Risorse binarie aggiunte + Binary assets removed Risorse binarie rimosse + Blank Vuoto + Blank (Three Columns) Vuoto (tre colonne) + Blank Page Pagina vuota + Blank document Documento vuoto + Block\nAuthors Blocca\nautori + Bold Grassetto + Bookmark Segnalibro + Bookmark name: Nome segnalibro: + Border Painter Copia bordi + Border\nPainter Copia\nbordi + Borders Bordi + Tab stop position: Posizione tabulazione: + Default tab stops: Tabulazioni predefinite: + Tab stops to be cleared: Tabulazioni da cancellare: + All Tutte + Decimal Decimale + Bar Barra + Leader Carattere di riempimento + Set Imposta + Page Border Bordo pagina + Apply to: Applica a: + Whole document Tutto il documento + This section Questa sezione + Fill Riempimento + Setting: Impostazione: + 3-D 3D + Custom Personalizzato + Style: Stile: + Dot dash Punto linea + Triple Triplo + Between Interno + Borders and Shading Bordi e sfondo + Tabs… Tabulazioni… + Borders and Shading… Bordi e sfondo… + Bottom Border Bordo inferiore + Bottom Margin Margine inferiore + Bottom of Page Fondo pagina + Bottom: Inferiore: + Box Riquadro + Breaks Interruzioni + Bring Forward Porta avanti + Bullet Library Raccolta elenchi puntati + Bullets Elenchi puntati + Calendar Calendario + Cancel Annulla + Capitalize Each Word Iniziali maiuscole + Captions Didascalie + Cell Margins Margini cella + Cell Size Dimensioni cella + Cell\nMargins Margini\ncella + Center Centra + Change Cambia + Change Case Maiuscole/minuscole + Change List Level Cambia livello elenco + Change Picture Cambia immagine + Change Shape Cambia forma + Changes Modifiche + Character Position Posizione carattere + Character Scale Scala carattere + Character Shading Sfondo carattere + Character Spacing Spaziatura caratteri + Characters Caratteri + Characters (no spaces) Caratteri (spazi esclusi) + Characters (with spaces) Caratteri (spazi inclusi) + Chart Grafico + Check Accessibility Verifica accessibilità + Check Compatibility Verifica compatibilità + Check Spelling as You Type Controlla ortografia durante la digitazione + Check for Errors Controllo errori + Check\nAccessibility Verifica\naccessibilità + Citation Style Stile citazione + Citations & Bibliography Citazioni e bibliografia + Clear Cancella + Clear All Cancella tutto + Clear All Formatting Cancella formattazione + Clear Character Formatting Cancella formattazione carattere + Clear Character Style Cancella stile carattere + Clear Formatting Level Cancella livello di formattazione + Clear Paragraph Formatting Cancella formattazione paragrafo + Click a border between two cells to merge them. Esc stops. Fai clic sul bordo tra due celle per unirle. Esc per uscire. + Clipboard Appunti + Clipboard Pane Riquadro Appunti + Clipboard Items Elementi degli Appunti + Paste Clipboard Item Incolla elemento degli Appunti + Paste All Incolla tutto + Delete Clipboard Item Elimina elemento degli Appunti + Click an item to paste it. Fai clic su un elemento per incollarlo. + Nothing collected yet. Items you copy or cut appear here. Ancora niente. Ciò che copi o tagli comparirà qui. + (picture or object) (immagine o oggetto) + Close Chiudi + Close Header and Footer Chiudi intestazione e piè di pagina + Collapse Selection Comprimi selezione + Color Colore + Color: Colore: + Colors Colori + Column Colonna + Column Break Interruzione di colonna + Columns Colonne + Combine Combina + Commands Comandi + Comment Commento + Comments Commenti + Comments Pane Riquadro Commenti + Community\nDiscord Community\nDiscord + Compact Compatto + Compare Confronta + Compress Pictures Comprimi immagini + Compress punctuation at the start of a line Comprimi la punteggiatura a inizio riga + Continuous Continua + Convert Converti + Convert Text to Table Converti testo in tabella + Convert Text to Table… Converti testo in tabella… + Convert to Text Converti in testo + Copy Copia + Copy Formatting Copia formato + Corrections Correzioni + Count words in text boxes and notes Conta le parole in caselle di testo e note + Cover Page Frontespizio + Create Crea + Create New Style Crea nuovo stile + Create a Style Crea uno stile + Create an interactive outline of your document.\n\nIt's a great way to keep track of where you are or quickly move your content around.\n\nTo get started, go to the Home tab and apply Heading styles to the headings in your document. Crea una struttura interattiva del documento.\n\nÈ un ottimo modo per sapere dove ti trovi o per spostare rapidamente il contenuto.\n\nPer iniziare, vai alla scheda Home e applica gli stili Titolo ai titoli del documento. + Crop Ritaglia + Cross-reference Riferimento incrociato + Current Position Posizione corrente + Current style: {style} Stile corrente: {style} + Custom Margins… Margini personalizzati… + Custom Watermark Filigrana personalizzata + Custom Watermark… Filigrana personalizzata… + Customize Quick Access Toolbar Personalizza barra di accesso rapido + Cut Taglia + Dark Mode Modalità scura + Dashed Tratteggiato + Data Dati + Date & Time Data e ora + Decrease Font Size Riduci dimensione carattere + Decrease Indent Riduci rientro + Default Predefinito + Define New Bullet Definisci nuovo punto elenco + Define New Number Format Definisci nuovo formato numero + Delete Elimina + Delete Cells Elimina celle + Delete Columns Elimina colonne + Delete Comment Elimina commento + Delete Next Word Elimina parola successiva + Delete Previous Word Elimina parola precedente + Delete Rows Elimina righe + Delete Style Elimina stile + Delete Table Elimina tabella + Design Design + Diagonal Diagonale + Diamond Rombo + Dictate Detta + Different First Page Prima pagina diversa + Different Odd & Even Pages Pagine pari e dispari diverse + Direct formatting Formattazione diretta + Discord Discord + Discord community Community Discord + Display Visualizza + Display for Review Visualizza per revisione + Distribute Columns Distribuisci colonne + Distribute Rows Distribuisci righe + Distributed Distribuito + Do Not Show in TOC Non mostrare nel sommario + Document End Fine documento + Document Formatting Formattazione documento + Document Property Proprietà documento + Document Start Inizio documento + Document Text Testo del documento + Document Title Titolo del documento + Documents you open will show up here. I documenti che apri compariranno qui. + Dot-dash Punto linea + Dotted Punteggiato + Double Doppio + Double Spacing Interlinea doppia + Double Strikethrough Barrato doppio + Double Underline Sottolineato doppio + Down Giù + Draft Bozza + Drag a rectangle to draw a table, or a line across cells to split them. Esc stops. Trascina un rettangolo per disegnare una tabella, o una linea sulle celle per dividerle. Esc per uscire. + Draw Disegno + Draw Table Disegna tabella + Drawing Tools Strumenti di disegno + Drawing\nCanvas Area di\ndisegno + Drop Cap Capolettera + Drops all formatting and pastes plain text. Rimuove tutta la formattazione e incolla testo normale. + Edit Comment Modifica commento + Edit Footer Modifica piè di pagina + Edit Header Modifica intestazione + Edit Recipient List Modifica elenco destinatari + Edit\nRecipient List Modifica elenco\ndestinatari + Edited Modificato + Editing Modifica + Editing header/footer Modifica intestazione/piè di pagina + Editing — edit the document directly Modifica — modifica direttamente il documento + Editor Editor + Effects Effetti + Emboss Rilievo + Encrypt with Password Crittografa con password + English (United States) Inglese (Stati Uniti) + Engrave Incisione + Enter page number: Immetti il numero di pagina: + Envelopes Buste + Eraser Gomma + Even Page Pagina pari + Every command is available to scripts and AI agents: run `wordcraft-cli mcp` for an MCP server, or start the app with `--control ` for the JSON control channel. Ogni comando è disponibile per script e agenti IA: esegui `wordcraft-cli mcp` per un server MCP, o avvia l'app con `--control ` per il canale di controllo JSON. + Export Esporta + Export PDF Esporta PDF + Export Page as PNG Esporta pagina come PNG + Export a copy to share (PDF, Word document) Esporta una copia da condividere (PDF, documento Word) + Exported {path} Esportato {path} + Extend Selection Estendi selezione + Field Campo + File File + Find Trova + Find Next Trova successivo + Find Previous Trova precedente + Find Recipient Trova destinatario + Find and Replace Trova e sostituisci + Find what: Trova: + Finish Fine + Finish & Merge Finalizza e unisci + Finish &\nMerge Finalizza e\nunisci + First Column Prima colonna + First commit Primo commit + First line: Prima riga: + Fixed Column Width Larghezza colonna fissa + Focus Concentrazione + Font Carattere + Font Color Colore carattere + Font Dialog Finestra di dialogo Carattere + Font Size Dimensione carattere + Font color: Colore carattere: + Font: Carattere: + Fonts Tipi di carattere + Footer Piè di pagina + Footnote and Endnote Nota a piè di pagina e nota di chiusura + Footnotes Note a piè di pagina + Format Formato + Format Page Numbers Formato numeri di pagina + Format Page Numbers… Formato numeri di pagina… + Format Painter Copia formato + Formatted Text Testo formattato + Formatting at Selection Formattazione nella selezione + Formula Formula + General Generale + Go To Vai a + Go To (⌘⌥G) Vai a (⌘⌥G) + Go To… Vai a… + Go to Vai a + Grab bag Dati aggiuntivi + Greeting Line Formula di apertura + Greeting\nLine Formula di\napertura + Gridlines Griglia + Group Raggruppa + Grow Font 1 Point Aumenta carattere di 1 punto + HTML Format Formato HTML + Hanging Indent Rientro sporgente + Header Intestazione + Header & Footer Intestazione e piè di pagina + Header Row Riga di intestazione + Header/Footer Position Posizione intestazione e piè di pagina + Heading 1 Titolo 1 + Heading 2 Titolo 2 + Heading 3 Titolo 3 + Headings Titoli + Heart Cuore + Help Guida + Hidden Nascosto + Highlight Merge Fields Evidenzia campi unione + Highlight\nMerge Fields Evidenzia\ncampi unione + Highlighter Evidenziatore + Home Home + Horizontal Orizzontale + Horizontal Line Linea orizzontale + Hyphenation Sillabazione + Icons Icone + Ignore All Ignora tutto + Illustrations Illustrazioni + Immersive Immersiva + Immersive Reader Strumento di lettura immersiva + Immersive\nReader Lettore\nimmersivo + Increase Font Size Aumenta dimensione carattere + Increase Indent Aumenta rientro + Indent Rientro + Indentation Rientri + Indents Rientri + Indents and Spacing Rientri e spaziatura + Index Indice analitico + Info Informazioni + Ink to\nMath Input penna\nin formula + Ink to\nShape Input penna\nin forma + Ink\nReplay Riproduci\ninput penna + Insert Inserisci + Insert Above Inserisci sopra + Insert Below Inserisci sotto + Insert Caption Inserisci didascalia + Insert Citation Inserisci citazione + Insert Endnote Inserisci nota di chiusura + Insert Footnote Inserisci nota a piè di pagina + Insert Hyperlink Inserisci collegamento + Insert Index Inserisci indice + Insert Left Inserisci a sinistra + Insert Merge Field Inserisci campo unione + Insert Merge\nField Inserisci\ncampo unione + Insert Right Inserisci a destra + Insert Shapes Inserisci forme + Insert Table Inserisci tabella + Insert Table of Authorities Inserisci indice delle autorità + Insert Table of Figures Inserisci indice delle figure + Insert Table… Inserisci tabella… + Insert\nAbove Inserisci\nsopra + Insert\nCaption Inserisci\ndidascalia + Insert\nCitation Inserisci\ncitazione + Insert\nFootnote Inserisci nota\na piè di pagina + Inside Borders Bordi interni + Inspect Document Ispeziona documento + Inspect Styles at Selection Ispeziona stili nella selezione + Interface language: Lingua dell'interfaccia: + Italic Corsivo + Join the ArtCraft community on Discord Unisciti alla community di ArtCraft su Discord + Join us on Discord: discord.gg/artcraft Unisciti a noi su Discord: discord.gg/artcraft + Justify Giustifica + Keep Lines Together Mantieni righe insieme + Keep Text Only Mantieni solo testo + Keep lines together Mantieni righe insieme + Keep with Next Mantieni con il successivo + Keep with next Mantieni con il successivo + Keeps the fonts and formatting of the copied text. Mantiene i caratteri e la formattazione del testo copiato. + Labels Etichette + Landscape Orizzontale + Language Lingua + Lasso Lazo + Last Column Ultima colonna + Last commit Ultimo commit + Layout Layout + Layout Summary Riepilogo layout + Left Sinistra + Left Border Bordo sinistro + Left Margin Margine sinistro + Left: Sinistra: + Letter Lettera + Line Linea + Line Break Interruzione di riga + Line breaking Divisione delle righe + Line End Fine linea + Line Numbers Numeri di riga + Line Spacing Interlinea + Line Spacing Options… Opzioni interlinea… + Line Start Inizio linea + Line and Page Breaks Interruzioni di riga e di pagina + Line and Paragraph Spacing Interlinea e spaziatura paragrafo + Line delta Differenza righe + Line spacing: Interlinea: + Lines Righe + Lines added Righe aggiunte + Lines deleted Righe eliminate + Link Collegamento + Link to Previous Collega al precedente + Links Collegamenti + Location: {path} Percorso: {path} + MIT OR Apache-2.0. Copyright (c) 2026 ArtCraft Team and the WordCraft contributors. MIT OR Apache-2.0. Copyright (c) 2026 ArtCraft Team e i collaboratori di WordCraft. + Macros Macro + Mailings Mail + Manage Sources Gestione fonti + Many pages Più pagine + Margins Margini + Mark Citation Segna citazione + Mark Entry Segna voce + Mark\nCitation Contrassegna\ncitazione + Mark\nEntry Contrassegna\nvoce + Markdown (*.md) Markdown (*.md) + Match Fields Corrispondenza campi + Match case Maiuscole/minuscole + Matrix Matrice + Merge Unisci + Merge Cells Unisci celle + Merge Formatting Unisci formattazione + Merged PRs PR unite + Mirrored Speculari + Moderate Moderato + Modify Style Modifica stile + Modify… Modifica… + More templates → Altri modelli → + Move or Resize Sposta o ridimensiona + Multilevel List Elenco a più livelli + Multiple Pages Più pagine + Name (A–Z) Nome (A–Z) + Name: Nome: + Narrow 0.5" all Stretto 0,5" ovunque + Navigation Navigazione + Navigation Pane Riquadro di spostamento + New Nuovo + New Comment Nuovo commento + New Paragraph Nuovo paragrafo + New Style… Nuovo stile… + New Window Nuova finestra + New from Template Nuovo da modello + New\nComment Nuovo\ncommento + New\nWindow Nuova\nfinestra + Next Successivo + Next Change Modifica successiva + Next Comment Commento successivo + Next Footnote Nota successiva + Next Page Pagina successiva + Next Record Record successivo + Next Section Sezione successiva + No Border Nessun bordo + No Color Nessun colore + No Markup Nessuna revisione + No Paragraph Space Nessuno spazio tra paragrafi + No contributor data was built into this copy. Questa copia non include dati sui collaboratori. + No model credits were built into this copy. Questa copia non include i crediti dei modelli. + No templates match your search. Nessun modello corrisponde alla ricerca. + Nonbreaking Hyphen Trattino unificatore + Nonbreaking Space Spazio unificatore + None Nessuno + Normal 1" all Normale 1" ovunque + Normal Style Stile normale + Not available right now Non disponibile al momento + Not saved Non salvato + Not saved yet Non ancora salvato + Nudge Sposta leggermente + Number of columns: Numero di colonne: + Number of rows: Numero di righe: + Numbering Numerazione + OK OK + Object Oggetto + Odd Page Pagina dispari + Office 2003 Default Predefinito di Office 2003 + One Una + One Page Una pagina + Open Apri + OpenDocument Text (*.odt) Testo OpenDocument (*.odt) + Optional Hyphen Trattino facoltativo + Options Opzioni + Orientation Orientamento + Outline Struttura + Outline Level Livello struttura + Outside Borders Bordi esterni + Oval Ovale + PDF document (*.pdf) Documento PDF (*.pdf) + Page Pagina + Page Background Sfondo pagina + Page Borders Bordi pagina + Page Break Interruzione di pagina + Page Break Before Interruzione di pagina prima + Page Breaks Interruzioni di pagina + Page Color Colore pagina + Page Down Pagina giù + Page Movement Spostamento pagina + Page Number Numero di pagina + Page Setup Imposta pagina + Page Up Pagina su + Page Width Larghezza pagina + Page X of Y Pagina X di Y + Page break before Interruzione di pagina prima + Page image (*.png) Immagine della pagina (*.png) + Page width Larghezza pagina + Page {page} Pagina {page} + Page {page} of {pages} Pagina {page} di {pages} + Page\nBorders Bordi\npagina + Page\nColor Colore\npagina + Page\nNumber Numero di\npagina + Pages Pagine + Paragraph Paragrafo + Paragraph Details Dettagli paragrafo + Paragraph Down Paragrafo giù + Paragraph Settings Impostazioni paragrafo + Paragraph Spacing Spaziatura paragrafo + Paragraph Up Paragrafo su + Paragraphs Paragrafi + Paste Incolla + Paste (⌘V) Incolla (⌘V) + Paste Formatting Incolla formattazione + Paste Special Incolla speciale + Paste Special… Incolla speciale… + Paste as: Incolla come: + Paste: Keep Text Only Incolla: mantieni solo testo + Paste: Merge Formatting Incolla: unisci formattazione + Pen Penna + Pencil Matita + Picture Border Bordo immagine + Picture Format Formato immagine + Picture Styles Stili immagine + Pictures Immagini + Plain text (*.txt) Testo normale (*.txt) + Portrait Verticale + Position Posizione + Preview Results Anteprima risultati + Preview\nResults Anteprima\nrisultati + Previous Precedente + Previous Change Modifica precedente + Previous Comment Commento precedente + Previous Record Record precedente + Previous Section Sezione precedente + Print Stampa + Print Layout Layout di stampa + Print\nLayout Layout\ndi stampa + Proofing Strumenti di correzione + Proofing Issues Problemi di correzione + Properties Proprietà + Protect Proteggi + Protect Document Proteggi documento + Quick Access Toolbar Barra di accesso rapido + Quick Parts Parti rapide + Quick Tables Tabelle rapide + Read Aloud Leggi ad alta voce + Read Mode Modalità di lettura + Read\nAloud Leggi ad\nalta voce + Read\nMode Modalità\nlettura + Reads the clipboard's rich text and keeps its formatting. Legge il testo RTF degli appunti e ne mantiene la formattazione. + Reads the clipboard's web content and keeps its formatting. Legge il contenuto web degli appunti e ne mantiene la formattazione. + Reading Highlight Evidenziazione lettura + Recent Recenti + Record Macro Registra macro + Recover Unsaved Documents Recupera documenti non salvati + Rectangle Rettangolo + Redo Ripeti + Reduce Hanging Indent Riduci rientro sporgente + References Riferimenti + Reject Rifiuta + Reject All Changes Rifiuta tutte le modifiche + Reject Change Rifiuta modifica + Relaxed Rilassato + Remove Background Rimuovi sfondo + Remove Footer Rimuovi piè di pagina + Remove Header Rimuovi intestazione + Remove Hyperlink Rimuovi collegamento + Remove Space After Paragraph Rimuovi spazio dopo il paragrafo + Remove Table of Contents Rimuovi sommario + Remove Watermark Rimuovi filigrana + Reopen Riapri + Repeat Ripeti + Repeat Header Rows Ripeti righe di intestazione + Replace Sostituisci + Replace All Sostituisci tutto + Replace Document Text Sostituisci testo del documento + Replace with: Sostituisci con: + Replay Riproduci + Reply Rispondi + Report Report + Research Ricerca + Researcher Ricercatore + Reset Picture Reimposta immagine + Reset to Normal Ripristina Normale + Resolve Risolvi + Resolved Risolto + Restart Each Page Ricomincia a ogni pagina + Restart Each Section Ricomincia a ogni sezione + Restart at 1 Ricomincia da 1 + Restrict Editing Limita modifica + Restrict\nEditing Limita\nmodifiche + Result Risultato + Results Risultati + Reverse the order Inverti l'ordine + Reviewing Revisione + Reviewing Pane Riquadro delle revisioni + Reviewing — edits become suggestions Revisione: le modifiche diventano suggerimenti + Rich Text Format (*.rtf) Formato RTF (*.rtf) + Rich Text Format (RTF) Formato Rich Text (RTF) + Right Destra + Right Border Bordo destro + Right Margin Margine destro + Right-to-Left Text Direction Direzione del testo da destra a sinistra + Left-to-Right Text Direction Direzione del testo da sinistra a destra + Right: Destro: + Rotate Ruota + Rounded Rectangle Rettangolo arrotondato + Rows & Columns Righe e colonne + Ruler Righello + Rules Regole + Résumé Curriculum + Save Salva + Save As Salva con nome + Save a copy in another format. Printing goes through a PDF you can print from any viewer. Salva una copia in un altro formato. La stampa passa attraverso un PDF che puoi stampare da qualsiasi visualizzatore. + Save as Template Salva come modello + Saved Salvato + Search Commands Cerca comandi + Search commands and help Cerca comandi e guida + Search document Cerca nel documento + Search for templates Cerca modelli + Section Breaks Interruzioni di sezione + Section Properties Proprietà sezione + Sections Sezioni + Select Seleziona + Select All Seleziona tutto + Select Cell Seleziona cella + Select Line Seleziona linea + Select Objects Seleziona oggetti + Select Paragraph Seleziona paragrafo + Select Range Seleziona intervallo + Select Recipients Seleziona destinatari + Select Row Seleziona riga + Select Sentence Seleziona frase + Select Table Seleziona tabella + Select Text Seleziona testo + Select Text with Similar Formatting Seleziona testo con formattazione simile + Select Word Seleziona parola + Select\nRecipients Seleziona\ndestinatari + Selection Selezione + Selection Pane Riquadro di selezione + Send Backward Porta indietro + Sentence case. Iniziale maiuscola. + Set Caret Imposta cursore + Set Character Formatting Imposta formattazione carattere + Set Numbering Value Imposta valore numerazione + Set Paragraph Formatting Imposta formattazione paragrafo + Set as Default Imposta come predefinito + Shading Sfondo + Shadow Ombra + Shape Fill Riempimento forma + Shape Format Formato forma + Shape Outline Contorno forma + Shape Styles Stili forma + Shapes Forme + Share Condividi + Shift+Tab Maiusc+Tab + Show Mostra + Show all revisions inline Mostra tutte le revisioni nel testo + Show Comments Mostra commenti + Show Markup Mostra revisioni + Show Notes Mostra note + Show all formatting marks Mostra tutti i segni di formattazione + Show only comments and formatting in balloons Mostra solo commenti e formattazione nei fumetti + Show revisions in balloons Mostra revisioni nei fumetti + Show rulers Mostra righelli + Show the community button in the title bar Mostra il pulsante della community nella barra del titolo + Show/Hide ¶ Mostra tutto + Show\nComments Mostra\ncommenti + Shrink Font 1 Point Riduci carattere di 1 punto + Side to Side Pagine affiancate + Side\nto Side Pagine\naffiancate + Signature Line Riga per la firma + Single Singola + Single Spacing Interlinea singola + Size Dimensioni + Size: Dimensioni: + Small Caps Maiuscoletto + Small caps Maiuscoletto + SmartArt SmartArt + Sort Ordina + Source code: github.com/storytold/wordcraft Codice sorgente: github.com/storytold/wordcraft + Space Spazio + Space before/after: Spazio prima/dopo: + Spacing Spaziatura + Spacing (pt): Spaziatura (pt): + Speech Voce + Spelling & Grammar Ortografia e grammatica + Spelling &\nGrammar Ortografia e\ngrammatica + Spelling Suggestions Suggerimenti ortografici + Split Dividi + Split Cells Dividi celle + Split Table Dividi tabella + Spreadsheet Table Tabella di foglio di calcolo + Standard Colors Colori standard + Star Stella + Start at: Inizia da: + Start Mail Merge Inizia stampa unione + Start Mail\nMerge Inizia stampa\nunione + Statistics Statistiche + Status Bar Barra di stato + Strikethrough Barrato + Studio cover Copertina Studio + Studio handbook (sample) Manuale Studio (esempio) + Style Stile + Style Inspector Ispettore stili + Style Set Set di stili + Style based on: Stile basato su: + Style: {style} Stile: {style} + Styles Stili + Styles Pane Riquadro Stili + Subscript Pedice + Superscript Apice + Switch Modes Cambia modalità + Switch\nModes Cambia\nmodalità + Symbol Simbolo + Symbols Simboli + Synonyms Sinonimi + Tab Tabulazione + Tab character Carattere di tabulazione + Table Tabella + Table Column Width Larghezza colonna tabella + Table Design Tabella Design + Table Layout Tabella Layout + Table Properties Proprietà tabella + Table Row Height Altezza riga tabella + Table Style Options Opzioni stile tabella + Table Styles Stili tabella + Table of Authorities Indice delle fonti + Table of Contents Sommario + Table of\nContents Inserisci\nsommario + Tables Tabelle + Tabs Tabulazioni + Tabular List Elenco tabulare + Text Testo + Text Box Casella di testo + Text Direction Direzione testo + Text Effects Effetti di testo e tipografia + Text Effects and Typography Effetti di testo e tipografia + Text Highlight Color Colore evidenziazione testo + Text indent at: Rientro testo a: + Text Wrapping Disposizione testo + Text from File Testo da file + Text to display: Testo da visualizzare: + Text: Testo: + Text\nBox Casella di\ntesto + Text\nDirection Direzione\ntesto + The clipboard is empty. Gli Appunti sono vuoti. + The new style takes the formatting of the current paragraph. Il nuovo stile assume la formattazione del paragrafo corrente. + Theme Colors Colori tema + Theme Fonts Caratteri tema + Theme List Elenco temi + Themes Temi + Thesaurus Sinonimi + Thick Spessa + Three Tre + Tight Ravvicinato + Top Border Bordo superiore + Top Margin Margine superiore + Top of Page Inizio pagina + Top: Superiore: + Total Row Riga totale + Track Changes Revisioni + Track Changes Options Opzioni revisioni + Track Changes: On Revisioni: attive + Track formatting Tieni traccia della formattazione + Tracking Rilevamento + Translate Traduci + Transparency Trasparenza + Triangle Triangolo + Two Due + Type Text Digita testo + Type a command, e.g. "insert table" Digita un comando, ad es. "inserisci tabella" + Ungroup Separa + UPPERCASE MAIUSCOLE + Underline Sottolineato + Underline Words Only Sottolinea solo le parole + Undo Annulla + Undo {action} Annulla {action} + Unformatted Text Testo non formattato + Up Su + Update Field Aggiorna campo + Update Index Aggiorna indice + Update Style to Match Selection Aggiorna lo stile in base alla selezione + Update Table Aggiorna sommario + Update Table of Figures Aggiorna indice delle figure + Update to Match Aggiorna per corrispondere + Update to Match Selection Aggiorna in base alla selezione + User Name Nome utente + User name: Nome utente: + Version History Cronologia versioni + Version {version} ({build}) Versione {version} ({build}) + Vertical Verticale + Vertical Alignment Allineamento verticale + View Visualizza + View Gridlines Visualizza griglia + View State Stato visualizzazione + Views Visualizzazioni + Voice Voce + Watermark Filigrana + Wave Onda + Web Layout Layout Web + Web\nLayout Layout\nWeb + Web page (*.html) Pagina Web (*.html) + Whole page Pagina intera + Whole words Parole intere + Wide Largo + Widow/Orphan Control Controllo righe isolate + Widow/Orphan control Controllo righe isolate + Wildcards (regex) Caratteri jolly (regex) + Window Finestra + Word Count Conteggio parole + Word Left Parola a sinistra + Word Right Parola a destra + Word document (*.docx) Documento Word (*.docx) + WordArt WordArt + WordCraft WordCraft + Words Parole + Words only Solo parole + Wrap Text Testo a capo + Wrap\nText Disponi\ntesto + Write & Insert Fields Composizione e inserimento campi + Write a reply Scrivi una risposta + Zoom Zoom + Zoom In Ingrandisci + Zoom Out Riduci + development build build di sviluppo + lowercase minuscole + {group} settings Impostazioni {group} + {key}+Click to follow link {key}+clic per seguire il collegamento + {selected} of {words} words Parole: {words}; selezionate: {selected} + {words} words Parole: {words} + Browse… Sfoglia… + Equation Equazione + Review Revisione + Track\nChanges Revisioni + Track\nChanges ✓ Revisioni ✓ + Scroll ribbon left Scorri la barra multifunzione a sinistra + Scroll ribbon right Scorri la barra multifunzione a destra + Apply Styles Applica stili + Dark page (white text on black) Pagina scura (testo bianco su nero) + Show documents with their colours inverted, like View › Switch Modes. Saving, printing and PDFs are unchanged. Mostra i documenti con i colori invertiti, come in Visualizza › Cambia modalità. Salvataggio, stampa e PDF non cambiano. + Only web (http, https) and email (mailto) links can be followed Si possono seguire solo collegamenti web (http, https) e di posta elettronica (mailto) + Character Border Bordo carattere + Direction: Direzione: + Right-to-left Da destra a sinistra + Left-to-right Da sinistra a destra + Zotero Zotero + Citations Citazioni + Document Documento + Add/Edit\nCitation Aggiungi/modifica\ncitazione + Add\nNote Aggiungi\nnota + Add/Edit\nBibliography Aggiungi/modifica\nbibliografia + Refresh Aggiorna + Document\nPreferences Preferenze\ndocumento + Unlink\nCitations Scollega\ncitazioni + Waiting for Zotero… In attesa di Zotero… + Zotero: done Zotero: completato + Zotero: cancelled Zotero: annullato + Yes Sì + No No + Move Past Citation Sposta dopo la citazione + Move Past\nCitation Sposta dopo\nla citazione + Next Sentence Frase successiva + Previous Sentence Frase precedente + Next sentence Frase successiva + Previous sentence Frase precedente + Play/Pause Riproduci/pausa + Read Aloud Status Stato lettura ad alta voce + Reading Speed Velocità di lettura + Skip Citations and Bibliography Salta citazioni e bibliografia + Skip citations & bibliography Salta citazioni e bibliografia + Stop Reading Interrompi lettura + Don't read Zotero, Mendeley or EndNote citations and bibliographies Non leggere citazioni e bibliografie di Zotero, Mendeley o EndNote + Play Riproduci + Pause Pausa + Stop Interrompi + Sentence Frase + Paused In pausa + Finished Completato + Stopped Interrotto + Speed Velocità + Normal speed Velocità normale + AutoSave documents you have saved in WordCraft Salva automaticamente i documenti che hai salvato in WordCraft + AutoSave failed: {error}. Save the document to turn AutoSave back on. Salvataggio automatico non riuscito: {error}. Salva il documento per riattivare il salvataggio automatico. + Couldn't download {name}: {error} Impossibile scaricare {name}: {error} + Do you want to save changes to {name}? Vuoi salvare le modifiche a {name}? + Don't Save Non salvare + That format doesn't keep everything, so the document is still open. Save it as a Word document (.docx) to go on. Quel formato non conserva tutto, quindi il documento resta aperto. Salvalo come documento Word (.docx) per continuare. + Your changes will be lost if you don't save them. Se non salvi le modifiche, andranno perse. + Change Structure Cambia struttura + Change to Display Passa a modalità blocco + Change to Inline Passa a modalità in linea + Conversions Conversioni + Delete Back in Equation Elimina indietro nell'equazione + Delete in Equation Elimina nell'equazione + Edit Equation Modifica equazione + Enter in Equation Invio nell'equazione + Equation Gallery Raccolta equazioni + Equation Info Informazioni equazione + Equation Number Numero equazione + Input Format Formato di input + Insert Structure Inserisci struttura + Insert Symbol Inserisci simbolo + Justification Allineamento + Leave Equation Esci dall'equazione + Move in Equation Spostati nell'equazione + Normal Text Testo normale + Set Equation Imposta equazione + Structure Actions Azioni struttura + Structures Strutture + Tools Strumenti + Type in Equation Digita nell'equazione + Inline In linea + Number Numero + Close Equation Chiudi equazione + Insert New Equation Inserisci nuova equazione + Built-In Predefinite + Professional Professionale + Linear Lineare + Fraction Frazione + Script Indice + Radical Radicale + Integral Integrale + Large\nOperator Operatore\ngrande + Bracket Parentesi + Function Funzione + Accent Accento + Limit and\nLog Limite e\nlogaritmo + Operator Operatore + Equation Options Opzioni equazione + All symbol sets Tutti i set di simboli + Send the document straight to the system print dialog, or save a copy in another format below. Invia il documento direttamente alla finestra di dialogo di stampa del sistema, oppure salva una copia in un altro formato qui sotto. + Or save a copy in another format: Oppure salva una copia in un altro formato: + Opening print dialog… Apertura della finestra di stampa… + Print failed: {error} Stampa non riuscita: {error} + Couldn't open {path}: {error} Impossibile aprire {path}: {error} + Column Selection Selezione colonna + New Table Style Nuovo stile tabella + Modify Table Style Modifica stile tabella + New Table Style… Nuovo stile tabella… + Modify Table Style… Modifica stile tabella… + Apply formatting to: Applica formattazione a: + Whole Table Intera tabella + Fill color: Colore riempimento: + {count} items highlighted Elementi evidenziati: {count} + {n} pt {n} pt + Not {name} Non {name} + Level {n} Livello {n} + Body Text Corpo del testo + {n} lines Righe: {n} + At least {n} Almeno {n} + Exactly {n} Esatta {n} + Centered Centrato + Character spacing Spaziatura caratteri + Don't add space between paragraphs of the same style Non aggiungere spazio tra paragrafi dello stesso stile + Don't hyphenate Non sillabare + Double strikethrough Barrato doppio + Drop cap Capolettera + First line Prima riga + Font color Colore carattere + Highlight Evidenziazione + Justified Giustificato + Left indent Rientro sinistro + Line spacing Interlinea + Outline level Livello struttura + Raised/lowered Alzato/abbassato + Right indent Rientro destro + Scale Scala + Space after Spazio dopo + Space before Spazio prima + Suppress line numbers Non numerare le righe + Underline color Colore sottolineatura + Letters Lettere + E-mail Messages Messaggi di posta elettronica + Directory Elenco + Normal Word Document Documento Word normale + If…Then…Else… Se…Allora…Altrimenti… + If…Then…Else Se…Allora…Altrimenti + Merge Record # Record n. + Skip Record If… Ignora record se… + Skip Record If Salta record se + Equals: Uguale a: + Then insert: Allora inserisci: + Otherwise insert: Altrimenti inserisci: + Courtesy Title Titolo di cortesia + First Name Nome + Last Name Cognome + Company Azienda + Address Indirizzo + City Città + State Stato/Provincia + Postal Code CAP + Country Paese + (not matched) (non associato) + Leave a recipient out of the merge when a field has this value. Escludi un destinatario dall'unione quando un campo ha questo valore. + Insert one text or another, depending on a field's value. Inserisci un testo o un altro, in base al valore di un campo. + Select recipients first to match their fields. Seleziona prima i destinatari per associarne i campi. + The recipient-list field that fills each part of an address block and greeting line. Il campo dell'elenco destinatari che compila ogni parte del blocco indirizzo e della riga di saluto. + No errors found: every merge field is in the recipient list. Nessun errore trovato: tutti i campi unione sono nell'elenco destinatari. + These merge fields aren't in the recipient list: Questi campi unione non sono nell'elenco destinatari: + Add at least one field. Aggiungi almeno un campo. + Add Field Aggiungi campo + Every field needs a name. Ogni campo deve avere un nome. + Field name Nome campo + Field names must be different. I nomi dei campi devono essere diversi. + Fields: Campi: + Field: Campo: + Find: Trova: + New Entry Nuova voce + Recipient List Elenco destinatari + Recipients: {count} Destinatari: {count} + Remove field Rimuovi campo + Remove recipient Rimuovi destinatario + Select recipients first to choose from their fields. Seleziona prima i destinatari per scegliere tra i loro campi. + Type a New List… Digita un nuovo elenco… + Type at least one recipient. Digita almeno un destinatario. + Type the recipients, or load them from a file. Digita i destinatari o caricali da un file. + Use an Existing List… Usa un elenco esistente… + Interface theme: Tema dell'interfaccia: + Interface\nTheme Tema\ninterfaccia + Interface Theme Tema dell'interfaccia + Light Chiaro + Dark Scuro + Use system setting Usa impostazione di sistema + A file dialog is already open. È già aperta una finestra di dialogo dei file. + Height: Altezza: + Width: Larghezza: + Row Riga + Cell Cella + Preferred width: Larghezza preferita: + Alignment: Allineamento: + Indent from left: Rientro da sinistra: + Specify height: Specifica altezza: + Exactly Esatta + At least Almeno + Allow row to break across pages Consenti la divisione della riga tra le pagine + Repeat as header row on each page Ripeti come riga di intestazione in ogni pagina + Vertical alignment: Allineamento verticale: + Top In alto + Bottom In basso + Alt text Testo alternativo + Alt: Alt: + W: L: + H: A: + Reset Reimposta + Reset Crop Reimposta ritaglio + Insert a new equation (Alt+=) Inserisci una nuova equazione (Alt+=) + Leave the equation (Esc) Esci dall'equazione (Esc) + Preview Anteprima + getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft + Draw Ink Disegna a penna + Erase Ink Cancella penna + Hide Ink Nascondi penna + Hide\nInk Nascondi\npenna + Pens Penne + Thickness Spessore + (All) (Tutti) + (none) (nessuno) + (normal text) (testo normale) + 1/4 Em Space Spazio 1/4 em + A mail merge field Un campo di stampa unione + A numbered sequence (figures, tables…) Una sequenza numerata (figure, tabelle…) + A table of contents Un sommario + Categories: Categorie: + Character Carattere + Character code: Codice carattere: + Col #: N. col.: + Copyright Copyright + Date and Time Data e ora + Date formats: Formati data: + Description: Descrizione: + Document Information Informazioni documento + Double Closing Quote Virgolette doppie di chiusura + Double Opening Quote Virgolette doppie di apertura + Ellipsis Puntini di sospensione + Em Dash Lineetta em + En Dash Lineetta en + Em Space Spazio em + En Space Spazio en + Equal column width Colonne di uguale larghezza + Field Codes Codici di campo + Field code: Codice di campo: + Field codes: Codici di campo: + Field name: Nome campo: + Field names: Nomi campo: + Field properties Proprietà campo + Field… Campo… + Format: Formato: + Hide Codes Nascondi codici + Index and Tables Indici e tabelle + Line between Linea tra le colonne + Links and References Collegamenti e riferimenti + Mail Merge Stampa unione + More Columns… Altre colonne… + More Symbols… Altri simboli… + No-Width Non Break Unione senza larghezza + No-Width Optional Break Interruzione facoltativa senza larghezza + Paragraph Mark Segno di paragrafo + Pick a field from the list Scegli un campo dall'elenco + Preserve formatting during updates Mantieni la formattazione negli aggiornamenti + Presets Predefiniti + Recently used symbols: Simboli usati di recente: + Registered Registrato + Section Mark Segno di sezione + Sequence name: Nome sequenza: + Shortcut key: Tasto di scelta rapida: + Single Closing Quote Virgoletta singola di chiusura + Single Opening Quote Virgoletta singola di apertura + Spacing: Spaziatura: + Special Characters Caratteri speciali + Subset: Sottoinsieme: + The time right now L'ora attuale + When this document was first created Quando il documento è stato creato + When this document was most recently printed Quando il documento è stato stampato l'ultima volta + When this document was most recently saved Quando il documento è stato salvato l'ultima volta + The document has no bookmarks yet. Il documento non ha ancora segnalibri. + Who the document properties name as author L'autore indicato nelle proprietà del documento + Name of the file this document is saved as Nome del file con cui è salvato il documento + Title from the document properties Titolo dalle proprietà del documento + How many pages the whole document has Numero di pagine dell'intero documento + How many pages this section has Numero di pagine di questa sezione + Number of the page the field sits on Numero della pagina in cui si trova il campo + Number of the section the field sits in Numero della sezione in cui si trova il campo + How many words the document holds Numero di parole del documento + Page on which a bookmarked passage falls Pagina in cui cade un passaggio con segnalibro + Copy of the text a bookmark marks Copia del testo indicato da un segnalibro + This field has no options. Questo campo non ha opzioni. + This font has no characters to show. Questo carattere non ha caratteri da mostrare. + This point forward Da questo punto in poi + The date it is today La data di oggi + Trademark Marchio + Type the field's name and its switches, for example PAGE \\* roman. Digita il nome del campo e le sue opzioni, ad esempio PAGE \\* roman. + Width and spacing Larghezza e spaziatura + from Unicode (hex) da Unicode (esad.) + Shape Effects Effetti forma + Glow Bagliore + Soft Edges Bordi sfumati + No Shadow Nessuna ombreggiatura + No Glow Nessun bagliore + No Soft Edges Nessun bordo sfumato + Glow Colors Colori bagliore + Weight Spessore + Shadow, glow and soft edges Ombreggiatura, bagliore e bordi sfumati + Offset: Bottom Right Offset: in basso a destra + Offset: Bottom Offset: in basso + Offset: Bottom Left Offset: in basso a sinistra + Offset: Right Offset: a destra + Offset: Center Offset: al centro + Offset: Left Offset: a sinistra + Offset: Top Right Offset: in alto a destra + Offset: Top Offset: in alto + Offset: Top Left Offset: in alto a sinistra + Manage Styles Gestisci stili + Show or Hide Style Mostra o nascondi stile + Manage Styles… Gestisci stili… + Sort order: Ordinamento: + As Recommended Come consigliato + Alphabetical Alfabetico + Font: {font} Carattere: {font} + {size} pt {size} pt + Color: {color} Colore: {color} + Space before: {pt} pt Spazio prima: {pt} pt + Space after: {pt} pt Spazio dopo: {pt} pt + Based on: {style} Basato su: {style} + Paragraph style Stile paragrafo + Character style Stile carattere + Linked style (paragraph and character) Stile collegato (paragrafo e carattere) + Table style Stile tabella + Built-in style Stile predefinito + Show in the Styles gallery Mostra nella raccolta Stili + Hide from style lists Nascondi dagli elenchi di stili + Built-in styles can't be deleted. Gli stili predefiniti non possono essere eliminati. + Delete Table Style Elimina stile tabella + Password Password + Password: Password: + Confirm password: Conferma password: + Encrypt with Password… Crittografa con password… + Change Password… Cambia password… + Remove Password Rimuovi password + A password is required to open this document. Per aprire questo documento è necessaria una password. + Anyone can open this document. Encrypt it with a password to keep it private. Chiunque può aprire questo documento. Crittografalo con una password per mantenerlo privato. + Anyone who opens the document will need this password. Chiunque apra il documento avrà bisogno di questa password. + {name} is protected with a password. {name} è protetto da password. + That password isn't right. Check it (passwords are case-sensitive) and try again. La password non è corretta. Controllala (le password distinguono tra maiuscole e minuscole) e riprova. + The document will be saved with a password. Save it to protect it. Il documento verrà salvato con una password. Salvalo per proteggerlo. + The password is removed. Save the document to keep the change. La password è stata rimossa. Salva il documento per mantenere la modifica. + The passwords don't match. Le password non corrispondono. + Keep the password somewhere safe: without it nobody, WordCraft included, can open the document. Leave both boxes empty to remove the password. Conserva la password in un luogo sicuro: senza di essa nessuno, nemmeno WordCraft, può aprire il documento. Lascia vuote entrambe le caselle per rimuovere la password. + Align Top Left Allinea in alto a sinistra + Align Top Center Allinea in alto al centro + Align Top Right Allinea in alto a destra + Align Center Left Allinea al centro a sinistra + Align Center Allinea al centro + Align Center Right Allinea al centro a destra + Align Bottom Left Allinea in basso a sinistra + Align Bottom Center Allinea in basso al centro + Align Bottom Right Allinea in basso a destra + {contributors} contributors · {commits} commits Collaboratori: {contributors} · commit: {commits} + {cols}×{rows} Table Tabella {cols}×{rows} + AaBbYyZz AaBbYyZz ÀàÈé + pt pt + Document1 Documento1 + Username Nome utente + Display name Nome visualizzato + Real name Nome reale + Name Nome + PRs PR + Commits Commit + First Primo + Last Ultimo + Model Modello + Version Versione + % of all commits % di tutti i commit + Lines +/− Righe +/− + Craft Artigianale + Studio Studio + Atelier Atelier + Darkroom Camera oscura + Gallery Galleria + Sketchbook Album da disegno + Change Picture… Cambia immagine… + Font… Carattere… + Paragraph… Paragrafo… + Link… Collegamento… + Size and Crop… Dimensioni e ritaglio… + Insert Columns to the Left Inserisci colonne a sinistra + Insert Columns to the Right Inserisci colonne a destra + Insert Rows Above Inserisci righe sopra + Insert Rows Below Inserisci righe sotto + Mon Lun + Tue Mar + Wed Mer + Sum Somma + North Nord + South Sud + West Ovest + Total Totale + Rotation Rotazione + Rotation: Rotazione: + Rotate Right 90° Ruota a destra di 90° + Rotate Left 90° Ruota a sinistra di 90° + Flip Vertical Capovolgi verticalmente + Flip Horizontal Capovolgi orizzontalmente + Formatted Formattato + Interface size: Dimensioni interfaccia: + Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Ingrandisce o riduce la barra multifunzione, i menu e le finestre di dialogo, in aggiunta al ridimensionamento dello schermo del sistema. Lo zoom del documento è separato. + Title Titolo + Subject Oggetto + Keywords Parole chiave + Category Categoria + Good morning Buongiorno + Good afternoon Buon pomeriggio + Good evening Buonasera + Contributors Collaboratori + Models Modelli + Draw\nTable Disegna\ntabella + Typing Digitazione + Bayes' rule Teorema di Bayes + Binomial expansion of (x+a)ⁿ Sviluppo binomiale di (x+a)ⁿ + Circle area from its radius Area del cerchio dal raggio + Convert between built-up and linear form Converti tra formato professionale e lineare + Derivative as a limit Derivata come limite + Euler's identity Identità di Eulero + Exponential series Serie esponenziale + Integral of e^(−x²) Integrale di e^(−x²) + Justification of a display equation Allineamento di un'equazione su riga propria + Normal probability density Densità di probabilità normale + Number the equation; the number sits at the right margin (or type # then the number) Numera l'equazione; il numero va al margine destro (oppure digita # e poi il numero) + Power series of (1+x)ⁿ Serie di potenze di (1+x)ⁿ + Roots of ax²+bx+c Radici di ax²+bx+c + Show the equation in the line of text Mostra l'equazione nella riga di testo + Show the equation on a line of its own Mostra l'equazione su una riga propria + Sides of a right triangle Lati di un triangolo rettangolo + Sine and cosine series of f(x) Serie di seni e coseni di f(x) + Sum of cosines Somma di coseni + Sum or difference of sines Somma o differenza di seni + Type equations in LaTeX Scrivi le equazioni in LaTeX + Type equations in the linear format (UnicodeMath) Scrivi le equazioni in formato lineare (UnicodeMath) + Type normal (non-math) text in the equation Scrivi testo normale (non matematico) nell'equazione + Change to Level 1 Passa al livello 1 + Change to Level 2 Passa al livello 2 + Change to Level 3 Passa al livello 3 + Change to Level 4 Passa al livello 4 + Change to Level 5 Passa al livello 5 + Change to Level 6 Passa al livello 6 + Change to Level 7 Passa al livello 7 + Change to Level 8 Passa al livello 8 + Change to Level 9 Passa al livello 9 + Level 1 Livello 1 + Level 2 Livello 2 + Level 3 Livello 3 + Level 4 Livello 4 + Level 5 Livello 5 + Level 6 Livello 6 + Level 7 Livello 7 + Level 8 Livello 8 + Level 9 Livello 9 + Copyright Sign Simbolo di copyright + Registered Sign Simbolo di marchio registrato + Trademark Sign Simbolo di marchio + Insert Date Field Inserisci campo data + Insert Page Field Inserisci campo pagina + Insert Time Field Inserisci campo ora + Reset Character Formatting Reimposta formattazione carattere + Reset Paragraph Formatting Reimposta formattazione paragrafo + Toggle Field Codes Attiva/disattiva codici di campo + Edit Field Code Modifica codice di campo + Unlink Fields Scollega campi + Lock Fields Blocca campi + Unlock Fields Sblocca campi + Next Field Campo successivo + Previous Field Campo precedente + Selected Fields Campi selezionati + Edit Field… Modifica campo… + Style: APA Stile: APA + +# Added with the merge of main (Customize Keyboard and Ribbon, wrap sides, paragraph moves). + (Custom) (Personalizzato) + << Remove << Rimuovi + Add >> Aggiungi >> + Add to Quick Access Toolbar Aggiungi alla barra di accesso rapido + All Commands Tutti i comandi + Assign Assegna + Behind Text Dietro al testo + Both Sides Entrambi i lati + Choose a command Scegli un comando + Choose a shortcut Scegli una scelta rapida + Commands: Comandi: + Customize Personalizza + Customize Keyboard Personalizza tastiera + Customize Quick Access Toolbar… Personalizza barra di accesso rapido… + Customize Ribbon Personalizza barra multifunzione + Customize the Ribbon… Personalizza la barra multifunzione… + Customize… Personalizza… + Demote Abbassa di livello + Go Back Torna indietro + In Front of Text Davanti al testo + In Line with Text In linea con il testo + Keyboard shortcuts: Scelte rapide da tastiera: + Keys for this command: Tasti per questo comando: + Largest Only Solo il lato più ampio + Left Only Solo a sinistra + More Commands… Altri comandi… + Move Paragraph Down Sposta paragrafo giù + Move Paragraph Up Sposta paragrafo su + New Group Nuovo gruppo + New Tab Nuova scheda + New shortcut: Nuova scelta rapida: + Not used by any command Non usata da alcun comando + Now used by {command} Ora usata da {command} + Pick a custom group (New Group makes one) Scegli un gruppo personalizzato (Nuovo gruppo ne crea uno) + Press a key combination Premi una combinazione di tasti + Promote Alza di livello + Put the default commands back on the Quick Access Toolbar? Ripristinare i comandi predefiniti sulla barra di accesso rapido? + Remove Rimuovi + Remove every custom shortcut and restore the built-in keys? Rimuovere tutte le scelte rapide personalizzate e ripristinare i tasti predefiniti? + Remove every custom tab and group and show all tabs again? Rimuovere tutte le schede e i gruppi personalizzati e mostrare di nuovo tutte le schede? + Remove from Quick Access Toolbar Rimuovi dalla barra di accesso rapido + Rename… Rinomina… + Reset All… Reimposta tutto… + Reset… Reimposta… + Ribbon Barra multifunzione + Ribbon and Quick Access Toolbar: Barra multifunzione e barra di accesso rapido: + Ribbon tabs Schede della barra multifunzione + Right Only Solo a destra + Square Incorniciato + Through Passante + Toggle Unicode Character Attiva/disattiva carattere Unicode + Top and Bottom Sopra e sotto + Wrap Text Sides Lati del testo a capo + Arrows Frecce + Base text: Testo di base: + Choose the sheet that holds the recipients. Scegli il foglio che contiene i destinatari. + Circle Cerchio + Communication Comunicazione + Distribute letters Distribuisci lettere + Distribute spacing Distribuisci spaziatura + Enclose Characters Racchiudi caratteri + Enclosure Contorno + Enlarge symbol Ingrandisci simbolo + First row of data contains column headers La prima riga di dati contiene le intestazioni di colonna + Icon Color Colore icona + Nature Natura + No icons match your search. Nessuna icona corrisponde alla ricerca. + Office Ufficio + Offset (pt): Scostamento (pt): + People Persone + Phonetic Guide Guida fonetica + Recolor a one-color picture, such as an icon Ricolora un'immagine a un solo colore, ad esempio un'icona + Right (vertical) Destra (verticale) + Ruby text: Testo ruby: + Search icons Cerca icone + Shrink text Riduci testo + Size (pt): Dimensione (pt): + Technology Tecnologia + Transport Trasporti + Weather Meteo + +# Options, accessibility, content controls, document grid, templates, index and other labels added on main + As you type Durante la digitazione + Asian text font: Carattere testo asiatico: + AutoSave writes your changes to the file a moment after you stop typing. Il salvataggio automatico scrive le modifiche nel file poco dopo che smetti di digitare. + Centimeters Centimetri + Check spelling as you type Controlla ortografia durante la digitazione + Click to switch back to inserting Fai clic per tornare all'inserimento + Custom dictionary Dizionario personalizzato + Formatting marks Segni di formattazione + Hidden text Testo nascosto + Ignore web and file addresses Ignora indirizzi web e di file + Ignore words in capitals Ignora parole in MAIUSCOLO + Ignore words with numbers Ignora parole che contengono numeri + Inches Pollici + Formatting exceptions Eccezioni di formattazione + Initials: Iniziali: + Interface Interfaccia + Interface language Lingua dell'interfaccia + Inside: Interno: + Latin text font: Carattere testo latino: + Mark grammar errors as you type Segna gli errori grammaticali durante la digitazione + Measurement units: Unità di misura: + Millimeters Millimetri + New comments and tracked changes are recorded under this name and these initials. I nuovi commenti e le revisioni vengono registrati con questo nome e queste iniziali. + New word Nuova parola + No words yet. Add to Dictionary on a misspelled word puts it here. Ancora nessuna parola. Usa Aggiungi al dizionario su una parola errata per inserirla qui. + Mirror indents Rientri speculari + Overtype Sovrascrittura + Overtype (typing replaces the characters after the caret) Sovrascrittura (il testo digitato sostituisce i caratteri dopo il cursore) + Page display Visualizzazione pagina + Paragraph marks Segni di paragrafo + Personalize Personalizza + Picas Pica + Points Punti + Print the page color Stampa il colore della pagina + Printing Stampa + Proofing language Lingua di correzione + Recent documents to show: Documenti recenti da mostrare: + Outside: Esterno: + Save documents Salvataggio documenti + Show the mini toolbar when text is selected Mostra la barra di formattazione rapida quando selezioni il testo + Spaces Spazi + Spelling Ortografia + Spelling and grammar are checked in English (United States). L'ortografia e la grammatica vengono controllate in inglese (Stati Uniti). + Tab characters Caratteri di tabulazione + These marks show even when Show/Hide ¶ is off. Questi segni vengono mostrati anche quando Mostra tutto è disattivato. + Typing replaces selected text Il testo digitato sostituisce la selezione + Update fields before printing Aggiorna i campi prima della stampa + Use the Insert key to switch overtype on and off Usa il tasto Ins per attivare e disattivare la sovrascrittura + Extend Selection by Units Estendi selezione per unità + Word XML Document (*.xml) Documento Word XML (*.xml) + Deleted Eliminato + Print Markup Stampa revisioni + Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. Commenti accanto a ogni pagina e revisioni in PDF, stampe e immagini; un elenco dei commenti alla fine di pagine web, Markdown e testo. + 1. Formatting restrictions 1. Restrizioni di formattazione + 2. Editing restrictions 2. Restrizioni di modifica + 3. Start enforcement 3. Applica la protezione + << Less << Meno + Another file Un altro file + Apply as you type Applica durante la digitazione + AutoCorrect Correzione automatica + AutoFormat As You Type Formattazione automatica durante la digitazione + Automatic bulleted lists Elenchi puntati automatici + Automatic numbered lists Elenchi numerati automatici + Border lines Linee del bordo + Capitalize first letter of sentences Maiuscola all'inizio delle frasi + Capitalize first letter of table cells Maiuscola all'inizio delle celle di tabella + Capitalize names of days Maiuscola nei nomi dei giorni + Case changes Modifiche maiuscole/minuscole + Character level Livello carattere + Checked styles are allowed: Gli stili selezionati sono consentiti: + Combine Documents Combina documenti + Compare Documents Confronta documenti + Compared: {changes} changes Modifiche trovate: {changes} + Comparing with another file needs the desktop app. Il confronto con un altro file richiede l'app desktop. + Comparison settings Impostazioni di confronto + Don't capitalize after: Non usare la maiuscola dopo: + Editing in this document is restricted. La modifica di questo documento è limitata. + Every change is recorded as a tracked change. Ogni modifica viene registrata come revisione. + Everyone Tutti + Exceptions (optional) Eccezioni (facoltativo) + Exceptions… Eccezioni… + Fields Campi + Filling in forms Compilazione moduli + Find Next Editable Region Trova area modificabile successiva + Footnotes and endnotes Note a piè di pagina e note di chiusura + Formatting, case changes, white space and tables are compared; the other settings are kept for later. Vengono confrontati formattazione, maiuscole/minuscole, spazi vuoti e tabelle; le altre impostazioni restano per un secondo momento. + Fractions (1/2) as fraction characters (½) Frazioni (1/2) con caratteri frazione (½) + Headers and footers Intestazioni e piè di pagina + Hyphens (--) as dashes (—) Trattini (--) con lineette (—) + In equations, typing a name after a backslash replaces it with its symbol. Nelle equazioni, un nome digitato dopo una barra rovesciata viene sostituito dal suo simbolo. + Internet addresses as links Indirizzi Internet con collegamenti + Label changes with: Etichetta le modifiche con: + Limit formatting to a selection of styles Limita la formattazione a una selezione di stili + Math AutoCorrect Correzione automatica formule + More >> Altro >> + Moves Spostamenti + New document Nuovo documento + No changes (read only) Nessuna modifica (sola lettura) + Only allow this kind of editing: Consenti solo questo tipo di modifica: + Only comments can be added, except in regions marked editable. Si possono aggiungere solo commenti, tranne nelle aree contrassegnate come modificabili. + Only form fields can be filled in. Si possono compilare solo i campi modulo. + Only the allowed styles can be applied. Si possono applicare solo gli stili consentiti. + Ordinals (1st) as superscript Ordinali (1st) in apice + Original document Documento originale + This password type isn't supported. Questo tipo di password non è supportato. + Replace as you type Sostituisci durante la digitazione + Replace text as you type Sostituisci testo durante la digitazione + Replace: Sostituisci: + Revised document Documento revisionato + Select text, then tick who can still edit it. Seleziona il testo, poi spunta chi può ancora modificarlo. + Settings… Impostazioni… + Show changes at: Mostra modifiche a livello di: + Show changes in: Mostra modifiche in: + Smart quotes instead of straight quotes Virgolette tipografiche al posto di quelle dritte + Start Enforcing Protection Applica protezione + Stop Protection Interrompi protezione + Text boxes Caselle di testo + The document is read only, except for regions marked editable. Il documento è di sola lettura, tranne le aree contrassegnate come modificabili. + The password is optional; without it anyone can stop the protection. La password è facoltativa; senza, chiunque può interrompere la protezione. + This document Questo documento + Tracked changes Revisioni + Type the password to stop the protection. Digita la password per interrompere la protezione. + White space Spazi vuoti + With: Con: + Word level Livello parola + Add Font… Aggiungi carattere… + Added Fonts Caratteri aggiunti + Remove Added Font Rimuovi carattere aggiunto + Recent Colors Colori recenti + More Colors… Altri colori… + Standard Standard + Current Corrente + Colors: Colori: + Color model: Modello colori: + Red: Rosso: + Green: Verde: + Blue: Blu: + Hue: Tonalità: + Sat: Sat.: + Lum: Lum.: + Hex: Esad.: + Accessibility Pane Riquadro Accessibilità + Alt Text Pane Riquadro Testo alternativo + Alt\nText Testo\nalternativo + View Alt Text… Visualizza testo alternativo… + Mark as decorative Contrassegna come decorativo + Title: Titolo: + Errors ({n}) Errori ({n}) + Warnings ({n}) Avvisi ({n}) + Tips ({n}) Suggerimenti ({n}) + No accessibility issues found. Nessun problema di accessibilità trovato. + Describe what this object shows, for people who can't see it. Descrivi cosa mostra questo oggetto, per chi non può vederlo. + Decorative objects only add visual interest: screen readers skip them. Gli oggetti decorativi aggiungono solo interesse visivo: i lettori di schermo li saltano. + Describe what this table shows, for people who can't see it. Descrivi cosa mostra questa tabella, per chi non può vederla. + Select a picture, shape, chart or table to describe it. Seleziona un'immagine, una forma, un grafico o una tabella da descrivere. + Document Grid Griglia del documento + No grid Nessuna griglia + Lines only Solo righe + Lines and characters Righe e caratteri + Text snaps to characters Testo allineato ai caratteri + Characters per line: Caratteri per riga: + Lines per page: Righe per pagina: + Moved from Spostato da + Moved to Spostato in + Developer Sviluppo + Controls Controlli + Rich Text Content Control Controllo contenuto RTF + Plain Text Content Control Controllo contenuto testo normale + Picture Content Control Controllo contenuto immagine + Building Block Gallery Content Control Controllo contenuto raccolta blocchi predefiniti + Check Box Content Control Controllo contenuto casella di controllo + Combo Box Content Control Controllo contenuto casella combinata + Drop-Down List Content Control Controllo contenuto elenco a discesa + Date Picker Content Control Controllo contenuto selezione data + Repeating Section Content Control Controllo contenuto sezione ripetuta + Design Mode Modalità progettazione + Content Control at Selection Controllo contenuto nella selezione + Toggle Check Box Attiva/disattiva casella di controllo + Choose List Item Scegli elemento elenco + Set Date Imposta data + Remove Content Control Rimuovi controllo contenuto + Content Control Properties Proprietà controllo contenuto + Tag Tag + Placeholder text Testo segnaposto + Can't be deleted Non può essere eliminato + Contents can't be edited Il contenuto non può essere modificato + Remove the control once edited Rimuovi il controllo dopo la modifica + Allow line breaks Consenti ritorni a capo + Date format Formato data + Checked symbol Simbolo selezionato + Unchecked symbol Simbolo non selezionato + List entries Voci elenco + Display text Testo visualizzato + Value Valore + Today Oggi + Put the caret in a content control first. Posiziona prima il cursore in un controllo contenuto. + Right-to-Left Table Tabella da destra a sinistra + Right-to-Left Section Sezione da destra a sinistra + Table direction: Direzione tabella: + Section direction: Direzione sezione: + Drawing Canvas Area di disegno + New Drawing Canvas Nuova area di disegno + Fit Adatta + Expand Espandi + Scale Drawing Ridimensiona disegno + Canvas Member Elemento dell'area di disegno + Templates Modelli + Templates and Add-ins Modelli e aggiunte + Attach Template Collega modello + Detach Template Scollega modello + Automatically Update Document Styles Aggiorna automaticamente gli stili del documento + Update Styles from Template Aggiorna stili dal modello + Organizer Organizer + Copy Styles Copia stili + Rename Style Rinomina stile + Document template: Modello documento: + Attach… Collega… + Automatically update document styles Aggiorna automaticamente gli stili del documento + Organizer… Organizer… + Change Chart Type Cambia tipo di grafico + Chart Design Grafico Design + Type Tipo + Edit Data Modifica dati + Chart Title Titolo del grafico + Chart Layouts Layout grafico + Add Chart Element Aggiungi elemento grafico + Legend Legenda + Data Labels Etichette dati + Above Chart Sopra il grafico + Insert Chart Inserisci grafico + Clustered Column Colonne raggruppate + Stacked Column Colonne in pila + Clustered Bar Barre raggruppate + Stacked Bar Barre in pila + Area Area + Pie Torta + Doughnut Anello + Scatter Dispersione + “{text}” ({row}, {series}) isn't a number. “{text}” ({row}, {series}) non è un numero. + Category {n} Categoria {n} + Series {n} Serie {n} + Type the categories down the side and a column of values per series. Digita le categorie nella colonna laterale e una colonna di valori per ogni serie. + Categories Categorie + Series name Nome serie + Remove series Rimuovi serie + Remove row Rimuovi riga + Add Row Aggiungi riga + Add Series Aggiungi serie + Edit\nData Modifica\ndati + Change\nChart Type Cambia tipo\ndi grafico + Align Text Allinea testo + Create Link Crea collegamento + Break Link Interrompi collegamento + Rotate all text 90° Ruota tutto il testo di 90° + Rotate all text 270° Ruota tutto il testo di 270° + Stacked In pila + Middle Al centro + Click the empty text box the text should continue in (Esc cancels) Fai clic sulla casella di testo vuota in cui deve continuare il testo (Esc per annullare) + Not linked: click a text box Non collegata: fai clic su una casella di testo + Location Posizione + Endnotes Note di chiusura + Below text Sotto il testo + End of section Fine della sezione + End of document Fine del documento + Footnotes to endnotes Note a piè di pagina in note di chiusura + Endnotes to footnotes Note di chiusura in note a piè di pagina + Swap footnotes and endnotes Scambia note a piè di pagina e note di chiusura + Custom mark: Simbolo personalizzato: + Continuous numbering Numerazione continua + Apply Applica + Convert Notes Converti note + Hide White Space Nascondi spazi vuoti + All pages Tutte le pagine + Current page Pagina corrente + Selected pages Pagine selezionate + Custom range Intervallo personalizzato + All pages in the range Tutte le pagine dell'intervallo + Odd pages only Solo pagine dispari + Even pages only Solo pagine pari + Copies Copie + Collate copies Fascicola copie + Pages like 1-3, 5 Pagine, ad es. 1-3, 5 + {count} of {total} pages will print Pagine da stampare: {count} di {total} + Save these pages as PDF… Salva queste pagine come PDF… + The printer, two-sided printing and pages per sheet are chosen in that dialog. La stampante, la stampa fronte/retro e le pagine per foglio si scelgono in quella finestra di dialogo. + Previous Page Pagina precedente + Print to PDF Stampa in PDF + Add Shape Aggiungi forma + Change Colors Cambia colori + Change Layout Cambia layout + Create Graphic Crea elemento grafico + Edit Items Modifica elementi + Layouts Layout + Reset Graphic Reimposta elemento grafico + SmartArt Design SmartArt Design + SmartArt Styles Stili SmartArt + Text Pane Riquadro di testo + List Elenco + Process Processo + Cycle Ciclo + Hierarchy Gerarchia + Relationship Relazione + Pyramid Piramide + Basic List Elenco di base + Basic Process Processo di base + Basic Cycle Ciclo di base + Basic Pyramid Piramide di base + Basic Radial Radiale di base + Basic Matrix Matrice di base + Basic Venn Venn di base + Shows items of equal weight as blocks in rows. Mostra elementi di pari importanza come blocchi disposti in righe. + Shows steps in order, left to right, with arrows between them. Mostra i passaggi in ordine, da sinistra a destra, con frecce tra uno e l'altro. + Shows steps that repeat, around a circle. Mostra passaggi che si ripetono, disposti in cerchio. + Shows a tree: each item below the item it belongs to. Mostra un albero: ogni elemento sta sotto quello a cui appartiene. + Shows parts that build on each other, largest at the bottom. Mostra parti che poggiano l'una sull'altra, la più grande in basso. + Shows items around a central idea: the first item is the centre. Mostra elementi attorno a un'idea centrale: il primo elemento è il centro. + Shows parts of a whole as quadrants. Mostra le parti di un insieme come quadranti. + Shows overlapping ideas as overlapping circles. Mostra idee che si sovrappongono come cerchi sovrapposti. + Accent {n} Colore {n} + Colorful Colorato + Insert SmartArt Inserisci SmartArt + One line per item. Enter adds an item. Una riga per elemento. Invio aggiunge un elemento. + Add\nShape Aggiungi\nforma + Change\nColors Cambia\ncolori + Reset\nGraphic Reimposta\nelemento grafico + Promote to Heading 1 Alza a Titolo 1 + Demote to Body Text Abbassa a Corpo del testo + Move Up Sposta su + Move Down Sposta giù + Collapse Comprimi + Expand or Collapse Heading Espandi o comprimi titolo + Select Heading and Content Seleziona titolo e contenuto + Show Level Mostra livello + Show Level 1 Mostra livello 1 + Show Level 2 Mostra livello 2 + Show Level 3 Mostra livello 3 + Show Level 4 Mostra livello 4 + Show Level 5 Mostra livello 5 + Show Level 6 Mostra livello 6 + Show Level 7 Mostra livello 7 + Show Level 8 Mostra livello 8 + Show Level 9 Mostra livello 9 + Show All Levels Mostra tutti i livelli + All Levels Tutti i livelli + Show First Line Only Mostra solo la prima riga + Show Text Formatting Mostra formattazione testo + Close Outline View Chiudi visualizzazione Struttura + Outline State Stato struttura + Outlining Struttura + Outline Tools Strumenti struttura + Close Outline\nView Chiudi visualizzazione\nStruttura + A, B, C, … A, B, C, … + Above the item Sopra l'elemento + Accessed on Consultato il + Add New Source… Aggiungi nuova fonte… + Add the chapter number Aggiungi il numero di capitolo + Atmosphere Atmosfera + Below the item Sotto l'elemento + Book Libro + Book title Titolo del libro + Caption Didascalia + Caption Numbering Numerazione didascalie + Caption text Testo della didascalia + Caption: Didascalia: + Chapter in a book Capitolo di libro + Chapters begin at: I capitoli iniziano con: + Collect entries from: Raccogli voci da: + Columns: Colonne: + Conference Conferenza + Conference paper Relazione a una conferenza + Container Title Titolo del contenitore + Create Source Crea fonte + Cross-reference: Riferimento incrociato: + Custom Table of Contents… Sommario personalizzato… + DOI DOI + Day Giorno + Delete Label Elimina etichetta + Edit Source Modifica fonte + Edition Edizione + Edit… Modifica… + Entry marked. Voce segnata. + Example: {sample} Esempio: {sample} + Figure Figura + Film Film + Heading styles (levels 1–{n}) Stili titolo (livelli 1–{n}) + Heading {n} Titolo {n} + I, II, III, … I, II, III, … + Include page numbers Includi numeri di pagina + Indented Rientrato + Institution Istituzione + Ionosphere Ionosfera + Issue Fascicolo + Journal Rivista scientifica + Label: Etichetta: + Last, First; Last, First Cognome, Nome; Cognome, Nome + Leave the label out of the caption Escludi l'etichetta dalla didascalia + Letter headings Intestazioni per lettera + Levels: Livelli: + Link entries to their headings Collega le voci ai rispettivi titoli + Made from the author and year when empty Se vuoto, viene composto da autore e anno + Magazine or newspaper article Articolo di rivista o giornale + Main entry: Voce principale: + Mark Segna + Mark All Segna tutto + Mark Entry… Segna voce… + Mark Index Entry Segna voce di indice + Mesosphere Mesosfera + Month Mese + New Label Nuova etichetta + New Label… Nuova etichetta… + New… Nuova… + No sources yet. Ancora nessuna fonte. + Numbering… Numerazione… + Options… Opzioni… + Other Altro + Other styles and their TOC level: Altri stili e il loro livello nel sommario: + Outline levels Livelli struttura + Page number style Stile numero di pagina + Page numbers at the right margin Numeri di pagina al margine destro + Page title Titolo della pagina + Pages of bookmark: Pagine del segnalibro: + Periodical Periodico + Position: Posizione: + Publisher Editore + Run-in Continuo + See Vedi + Separator: Separatore: + Show every bibliography field Mostra tutti i campi della bibliografia + Site name Nome del sito + Source Manager Gestione fonti + Source type: Tipo di fonte: + Sources in this document: Fonti in questo documento: + Styles: Stili: + Subentry: Voce secondaria: + TC entry fields Campi voce TC + Tab leader: Carattere di riempimento: + Table of Contents Options Opzioni sommario + Table of Contents Styles Stili sommario + This page Questa pagina + Type: Tipo: + URL URL + Volume Volume + Website Sito web + Year Anno + a, b, c, … a, b, c, … + i, ii, iii, … i, ii, iii, … + {n} entries marked. Voci segnate: {n}. + Lasso Select Selezione con lazo + Move Selected Ink Sposta input penna selezionato + Delete Selected Ink Elimina input penna selezionato + Recolor Selected Ink Ricolora input penna selezionato + Add Pen Aggiungi penna + Remove Pen Rimuovi penna + Ink to Shape Input penna in forma + Convert to Shapes Converti in forme + Ink Replay Riproduci input penna + Add\nPen Aggiungi\npenna + Rewind Riavvolgi + Forward Avanti + Stroke {n} of {total} Tratto {n} di {total} + Use wildcards Usa caratteri jolly + Special Speciale + Match prefix Confronta prefisso + Match suffix Confronta suffisso + Ignore punctuation characters Ignora segni di punteggiatura + Ignore white-space characters Ignora spazi vuoti + Regular expressions Espressioni regolari + Search Options Opzioni di ricerca + Tab Character Carattere di tabulazione + Any Character Qualsiasi carattere + Any Digit Qualsiasi cifra + Any Letter Qualsiasi lettera + Caret Character Accento circonflesso + Section Character Simbolo di sezione + Paragraph Character Simbolo di paragrafo + Graphic Elemento grafico + Manual Line Break Interruzione di riga manuale + Manual Page Break Interruzione di pagina manuale + Section Break Interruzione di sezione + White Space Spazio vuoto + Clipboard Contents Contenuto degli Appunti + Find What Text Testo da trovare + Find What Expression Espressione da trovare + Character in Range Carattere nell'intervallo + Beginning of Word Inizio parola + End of Word Fine parola + Expression Espressione + Not Non + Num Occurrences Numero di occorrenze + Previous 1 or More Precedente 1 o più volte + 0 or More Characters 0 o più caratteri + Move with Text Sposta con il testo + Fix Position on Page Blocca posizione nella pagina + WordArt Styles Stili WordArt + Text Fill Riempimento testo + Text Outline Contorno testo + Transform Trasforma + No Fill Nessun riempimento + No Outline Nessun contorno + Reflection Riflesso + No Reflection Nessun riflesso + No Transform Nessuna trasformazione + Connect Shapes Connetti forme + Straight Connector Connettore diritto + Straight Arrow Connector Connettore diritto con freccia + Elbow Connector Connettore a gomito + Elbow Arrow Connector Connettore a gomito con freccia + Curved Connector Connettore curvo + Curved Arrow Connector Connettore curvo con freccia + Arch Up Arco in su + Arch Down Arco in giù + Slant Up Inclinato in su + Slant Down Inclinato in giù + Inflate Gonfia + Deflate Sgonfia + Chevron Up Gallone in su + Chevron Down Gallone in giù + Triangle Up Triangolo in su + Triangle Down Triangolo in giù + Solid Accent Colore pieno + Light with Outline Chiaro con contorno + Hollow Vuoto + Accent Gradient Sfumatura colore + Dark with Shadow Scuro con ombra + Soft Glow Bagliore tenue + Accent with Reflection Colore con riflesso + Warm Gradient with Outline Sfumatura calda con contorno + Add line numbering Aggiungi numerazione righe + Add to Document Aggiungi al documento + Address labels Etichette per indirizzi + Arabic Arabo + Auto Auto + Automatically hyphenate document Sillabazione automatica del documento + Chinese (Simplified) Cinese (semplificato) + Chinese (Traditional) Cinese (tradizionale) + Column: Colonna: + Count by: Intervallo: + Czech Ceco + Danish Danese + Delivery address: Indirizzo di consegna: + Detect language automatically Rileva automaticamente la lingua + Do not check spelling or grammar Non controllare ortografia e grammatica + Dutch Olandese + English (United Kingdom) Inglese (Regno Unito) + Envelope size: Dimensioni busta: + Estonian Estone + Finnish Finlandese + French (France) Francese (Francia) + From text: Distanza dal testo: + Full page of the same label Pagina intera con la stessa etichetta + German (Germany) Tedesco (Germania) + Greek Greco + Hebrew Ebraico + Hyphenate at: Sillaba in: + Hyphenate words in CAPS Sillaba parole in MAIUSCOLO + Hyphenation Options… Opzioni sillabazione… + Hyphenation zone: Area di sillabazione: + Italian Italiano + Japanese Giapponese + Korean Coreano + Limit consecutive hyphens to: Limita trattini consecutivi a: + Line Numbering Options… Opzioni numeri di riga… + Manual Hyphenation Sillabazione manuale + Manual hyphenation is complete. Sillabazione manuale completata. + Manual… Manuale… + Mark selected text as: Contrassegna il testo selezionato come: + New Document Nuovo documento + No limit Nessun limite + Norwegian (Bokmål) Norvegese (bokmål) + Numbering: Numerazione: + Omit Ometti + One label per recipient Un'etichetta per destinatario + Polish Polacco + Portuguese (Brazil) Portoghese (Brasile) + Portuguese (Portugal) Portoghese (Portogallo) + Return address labels Etichette mittente + Return address: Indirizzo mittente: + Row: Riga: + Russian Russo + Serbian (Cyrillic) Serbo (cirillico) + Serbian (Latin) Serbo (latino) + Set As Default Imposta come predefinito + Shipping labels Etichette di spedizione + Single label Etichetta singola + Spanish (Mexico) Spagnolo (Messico) + Spanish (Spain) Spagnolo (Spagna) + Swedish Svedese + Turkish Turco + Ukrainian Ucraino + {language} is now the document's default language. {language} è ora la lingua predefinita del documento. + +# Equation gallery, built-in styles, paper sizes, animation settings and the file browser added on main + 1×2 Empty Matrix Matrice vuota 1×2 + 1×3 Empty Matrix Matrice vuota 1×3 + 2×1 Empty Matrix Matrice vuota 2×1 + 2×2 Determinant Determinante 2×2 + 2×2 Empty Matrix Matrice vuota 2×2 + 2×2 Identity Matrix Matrice identità 2×2 + 2×2 Identity Matrix (sparse) Matrice identità 2×2 (sparsa) + 2×2 Matrix in Brackets Matrice 2×2 tra parentesi quadre + 2×2 Matrix in Double Bars Matrice 2×2 tra doppie barre + 2×2 Matrix in Parentheses Matrice 2×2 tra parentesi tonde + 2×3 Empty Matrix Matrice vuota 2×3 + 3×1 Empty Matrix Matrice vuota 3×1 + 3×2 Empty Matrix Matrice vuota 3×2 + 3×3 Empty Matrix Matrice vuota 3×3 + 3×3 Identity Matrix Matrice identità 3×3 + 3×3 Identity Matrix (sparse) Matrice identità 3×3 (sparsa) + A xor B A xor B + ASAP QUANTO PRIMA + AaBbCcDd AaBbCcDd + Absolute value cases Casi del valore assoluto + Accents Accenti + Acute Accento acuto + All - Linear Tutto - Lineare + All - Professional Tutto - Professionale + All files Tutti i file + Angle Brackets Parentesi angolari + Angle Brackets with Separator Parentesi angolari con separatore + Angle Brackets with Two Separators Parentesi angolari con due separatori + Animate the caret (it glides and fades) Anima il cursore (scorre e sfuma) + Animation Animazione + Animation effects: Effetti di animazione: + Annotation Reference Rimando commento + Annotation Subject Oggetto commento + Annotation Text Testo commento + B4 (JIS) 10.12" × 14.33" B4 (JIS) 10,12" × 14,33" + B5 (JIS) 7.17" × 10.12" B5 (JIS) 7,17" × 10,12" + Baseline Dots Puntini sulla linea di base + Basic Math Matematica di base + Binomial Coefficient Coefficiente binomiale + Binomial Coefficient (angle) Coefficiente binomiale (angolare) + Binomial Coefficient with Angle Brackets Coefficiente binomiale con parentesi angolari + Body text in a short paragraph to show spacing. Testo del corpo in un breve paragrafo per mostrare la spaziatura. + Book Title Titolo del libro + Boxed Formula Formula riquadrata + Boxed Formula (example) Formula riquadrata (esempio) + Boxed Formulas Formule riquadrate + Braces Parentesi graffe + Braces with Separator Parentesi graffe con separatore + Brackets Parentesi quadre + Brackets with Separators Parentesi con separatori + Breve Breve + Browse in WordCraft… Sfoglia in WordCraft… + CONFIDENTIAL RISERVATO + Cases (Three Conditions) Casi (tre condizioni) + Cases (Two Conditions) Casi (due condizioni) + Cases and Stacks Casi e pile + Ceiling Parte intera superiore + Centered as Group Centrato come gruppo + Change in y over change in x Variazione di y su variazione di x + Check Pipa + Circled Dot Punto cerchiato + Circled Plus Più cerchiato + Circled Times Per cerchiato + Colon Equal Due punti uguale + Common Accent Objects Oggetti accento comuni + Common Brackets Parentesi comuni + Common Fractions Frazioni comuni + Common Functions Funzioni comuni + Common Large Operators Operatori grandi comuni + Common Operator Structures Strutture di operatori comuni + Common Operators Operatori comuni + Common Radicals Radicali comuni + Common Subscripts and Superscripts Pedici e apici comuni + Contour Integral Integrale di contorno + Contour Integral with Limits Integrale di contorno con limiti + Contour Integral with Stacked Limits Integrale di contorno con limiti sovrapposti + Contour Integrals Integrali di contorno + Coproduct Coprodotto + Coproduct with Limits Coprodotto con limiti + Coproduct with Lower Limit Coprodotto con limite inferiore + Coproduct with Subscript-Superscript Limits Coprodotto con limiti a pedice e apice + Cosecant Function Funzione cosecante + Cosine 2x Coseno di 2x + Cosine Function Funzione coseno + Cotangent Function Funzione cotangente + Cube Root Radice cubica + Current - Linear Corrente - Lineare + Current - Professional Corrente - Professionale + DO NOT COPY NON COPIARE + DRAFT BOZZA + Default Paragraph Font Carattere predefinito del paragrafo + Delta Equal To Delta uguale a + Delta over arrow Delta sopra la freccia + Diagonal Dots Puntini diagonali + Differential Differenziale + Differential theta Differenziale di theta + Differential x Differenziale di x + Differential y Differenziale di y + Differentials Differenziali + Documents Documenti + Dot Punto + Dots Puntini + Double Bar Doppia barra + Double Dot Due punti sopra + Double Integral Integrale doppio + Double Integral with Limits Integrale doppio con limiti + Double Integral with Stacked Limits Integrale doppio con limiti sovrapposti + Double Left Arrow with Text Above Doppia freccia a sinistra con testo sopra + Double Left Arrow with Text Below Doppia freccia a sinistra con testo sotto + Double Left Right Arrow with Text Above Doppia freccia sinistra-destra con testo sopra + Double Left Right Arrow with Text Below Doppia freccia sinistra-destra con testo sotto + Double Right Arrow with Text Above Doppia freccia a destra con testo sopra + Double Right Arrow with Text Below Doppia freccia a destra con testo sotto + Double Square Brackets Doppie parentesi quadre + Double Vertical Bars Doppie barre verticali + Double summation Sommatoria doppia + Emphasis Enfasi + Empty Matrices Matrici vuote + Endnote Reference Rimando nota di chiusura + Endnote Text Testo nota di chiusura + Envelope #10 4.12" × 9.50" Busta n. 10 4,12" × 9,50" + Envelope Address Indirizzo busta + Envelope DL 4.33" × 8.66" Busta DL 4,33" × 8,66" + Envelope Return Mittente busta + Equal Equal Uguale uguale + Equal by Definition Uguale per definizione + Executive 7.25" × 10.50" Executive 7,25" × 10,50" + Exponential Esponenziale + File name: Nome file: + Floor Parte intera inferiore + Footnote Reference Rimando nota a piè di pagina + Footnote Text Testo nota a piè di pagina + Functions Funzioni + Geometry Geometria + Grave Accento grave + Greek Letters Lettere greche + Grid Table 1 Light Tabella griglia 1 chiara + Grid Table 4 – Blue Tabella griglia 4 – Blu + Grid Table 4 – Green Tabella griglia 4 – Verde + Grid Table 4 – Orange Tabella griglia 4 – Arancione + Grid Table 4 – Purple Tabella griglia 4 – Viola + Grouping Character Above Carattere di raggruppamento sopra + Grouping Character Below Carattere di raggruppamento sotto + Hat Cappello + Home folder Cartella home + Hyperbolic Cosecant Function Funzione cosecante iperbolica + Hyperbolic Cosine Function Funzione coseno iperbolico + Hyperbolic Cotangent Function Funzione cotangente iperbolica + Hyperbolic Functions Funzioni iperboliche + Hyperbolic Secant Function Funzione secante iperbolica + Hyperbolic Sine Function Funzione seno iperbolico + Hyperbolic Tangent Function Funzione tangente iperbolica + Hyperlink Collegamento ipertestuale + Hypotenuse Ipotenusa + Identity Matrices Matrici identità + Index Heading Intestazione indice + Index {n} Indice {n} + Integral with Limits Integrale con limiti + Integral with Stacked Limits Integrale con limiti sovrapposti + Integrals Integrali + Intense Emphasis Enfasi intensa + Intense Quote Citazione intensa + Intense Reference Riferimento intenso + Intersection Intersezione + Intersection with Limits Intersezione con limiti + Intersection with Lower Limit Intersezione con limite inferiore + Intersection with Subscript-Superscript Limits Intersezione con limiti a pedice e apice + Inverse Cosecant Function Funzione cosecante inversa + Inverse Cosine Function Funzione coseno inverso + Inverse Cotangent Function Funzione cotangente inversa + Inverse Functions Funzioni inverse + Inverse Hyperbolic Cosecant Function Funzione cosecante iperbolica inversa + Inverse Hyperbolic Cosine Function Funzione coseno iperbolico inverso + Inverse Hyperbolic Cotangent Function Funzione cotangente iperbolica inversa + Inverse Hyperbolic Functions Funzioni iperboliche inverse + Inverse Hyperbolic Secant Function Funzione secante iperbolica inversa + Inverse Hyperbolic Sine Function Funzione seno iperbolico inverso + Inverse Hyperbolic Tangent Function Funzione tangente iperbolica inversa + Inverse Secant Function Funzione secante inversa + Inverse Sine Function Funzione seno inverso + Inverse Tangent Function Funzione tangente inversa + Left Arrow with Text Above Freccia a sinistra con testo sopra + Left Arrow with Text Below Freccia a sinistra con testo sotto + Left Bracket Twice Parentesi quadra sinistra due volte + Left Right Arrow Above Freccia sinistra-destra sopra + Left Right Arrow with Text Above Freccia sinistra-destra con testo sopra + Left Right Arrow with Text Below Freccia sinistra-destra con testo sotto + Left Subscript-Superscript Pedice e apice a sinistra + Leftwards Arrow Above Freccia verso sinistra sopra + Leftwards Harpoon Above Arpione verso sinistra sopra + Legal 8.50" × 14.00" Legale 8,50" × 14,00" + Letter 8.50" × 11.00" Lettera 8,50" × 11,00" + Letter-Like Symbols Simboli simili a lettere + Limit Limite + Limit Example Esempio di limite + Line Number Numero di riga + Linear Fraction Frazione lineare + List Paragraph Paragrafo elenco + List Table 3 – Blue Tabella elenco 3 – Blu + List Table 3 – Green Tabella elenco 3 – Verde + List Table 3 – Orange Tabella elenco 3 – Arancione + List Table 3 – Purple Tabella elenco 3 – Viola + Logarithm Logaritmo + Logarithm with Base Logaritmo con base + Logical AND AND logico + Logical AND with Limits AND logico con limiti + Logical OR OR logico + Logical OR with Limits OR logico con limiti + Matrices with Brackets Matrici tra parentesi + Maximum Massimo + Maximum Example Esempio di massimo + Measured By Misurato da + Midline Dots Puntini centrati + Minimum Minimo + Minus Equal Meno uguale + Natural Logarithm Logaritmo naturale + Negated Relations Relazioni negate + No Spacing Nessuna spaziatura + Normal Normale + Normal Indent Rientro normale + Normal Table Tabella normale + Off Disattivato + On Attivato + On: the caret glides to its new place when you type or use the arrow keys, and its blink fades. Off: it jumps and blinks on and off. Attivato: il cursore scorre nella nuova posizione quando scrivi o usi i tasti freccia, e il lampeggio sfuma. Disattivato: salta e lampeggia acceso e spento. + One half Un mezzo + Open a file with WordCraft's own browser when the system file dialog doesn't appear. Apri un file con il browser di WordCraft quando la finestra di dialogo dei file del sistema non compare. + Operator Structures Strutture di operatori + Operators Operatori + Other Large Operators Altri operatori grandi + Outward Brackets Parentesi rivolte all'esterno + Overbar Barra sopra + Overbars and Underbars Barre sopra e sotto + Overbrace Graffa sopra + Parentheses Parentesi tonde + Parentheses with Separator Parentesi tonde con separatore + Partial Differential Differenziale parziale + Pi Over 2 Pi su 2 + Plain Table 1 Tabella semplice 1 + Plus Equal Più uguale + Product Produttoria + Product of A Produttoria di A + Product with Limits Produttoria con limiti + Product with Lower Limit Produttoria con limite inferiore + Product with Subscript-Superscript Limits Produttoria con limiti a pedice e apice + Products and Coproducts Produttorie e coprodotti + Quadratic Root Radice dell'equazione di secondo grado + Quote Citazione + Radical with Degree Radicale con indice + Radicals Radicali + Recipient lists Elenchi destinatari + Resize Table by Dragging Ridimensiona la tabella trascinando + Right Arrow with Text Above Freccia a destra con testo sopra + Right Arrow with Text Below Freccia a destra con testo sotto + Right Bracket Twice Parentesi quadra destra due volte + Rightwards Arrow Above Freccia verso destra sopra + Rightwards Harpoon Above Arpione verso destra sopra + SAMPLE ESEMPIO + Scripts Indici + Secant Function Funzione secante + Showing the first {count} items. Sono mostrati i primi {count} elementi. + Sine Function Funzione seno + Sine theta Seno di theta + Single Brackets Parentesi singole + Single Left Angle Parentesi angolare sinistra singola + Single Left Bar Barra sinistra singola + Single Left Brace Parentesi graffa sinistra singola + Single Left Bracket Parentesi quadra sinistra singola + Single Left Parenthesis Parentesi tonda sinistra singola + Single Right Brace Parentesi graffa destra singola + Single Right Bracket Parentesi quadra destra singola + Single Right Parenthesis Parentesi tonda destra singola + Skewed Fraction Frazione obliqua + Small change Piccola variazione + Smooth scrolling and zooming, and menus and buttons that fade in. Off shows every change at once. Scorrimento e zoom fluidi, e menu e pulsanti che compaiono in dissolvenza. Disattivato mostra ogni modifica subito. + Sparse Matrices Matrici sparse + Square Root Radice quadrata + Square Root with Degree Radice con indice + Square Union Unione quadrata + Stack Object Oggetto pila + Stack Object (Three) Oggetto pila (tre) + Stacked Fraction Frazione in colonna + Stacked, no bar In colonna, senza linea + Statement 5.50" × 8.50" Statement 5,50" × 8,50" + Strong Forte + Subscript-Superscript Pedice-apice + Subscripts and Superscripts Pedici e apici + Subtitle Sottotitolo + Subtle Emphasis Enfasi delicata + Subtle Reference Riferimento delicato + Summation Sommatoria + Summation from 0 to n Sommatoria da 0 a n + Summation over binomials Sommatoria di binomiali + Summation with Limits Sommatoria con limiti + Summation with Lower Limit Sommatoria con limite inferiore + Summation with Subscript Limit Sommatoria con limite a pedice + Summation with Subscript-Superscript Limits Sommatoria con limiti a pedice e apice + Summations Sommatorie + Surface Integral Integrale di superficie + Surface Integral with Limits Integrale di superficie con limiti + Surface Integral with Stacked Limits Integrale di superficie con limiti sovrapposti + TOA Heading Titolo indice delle fonti + TOC Heading Titolo sommario + TOC {n} Sommario {n} + Table Grid Griglia tabella + Table of Figures Indice delle figure + Tabloid 11.00" × 17.00" Tabloid 11,00" × 17,00" + Tangent Function Funzione tangente + Tangent formula Formula della tangente + Text over Colon Equal Testo sopra due punti uguale + Text over Equal Testo sopra uguale + The system file dialog isn't available, so WordCraft shows its own. To get the system dialog, install a file chooser portal (such as xdg-desktop-portal-gtk) or zenity. La finestra di dialogo dei file del sistema non è disponibile, quindi WordCraft mostra la sua. Per avere quella del sistema, installa un portale di selezione dei file (come xdg-desktop-portal-gtk) o zenity. + This folder can't be read: {error} Impossibile leggere questa cartella: {error} + Tilde Tilde + Trigonometric Functions Funzioni trigonometriche + Triple Dot Tre punti sopra + Triple Integral Integrale triplo + Triple Integral with Limits Integrale triplo con limiti + Triple Integral with Stacked Limits Integrale triplo con limiti sovrapposti + URGENT URGENTE + Underbar Barra sotto + Underbrace Graffa sotto + Union Unione + Union of intersections Unione di intersezioni + Union with Limits Unione con limiti + Union with Lower Limit Unione con limite inferiore + Union with Plus Unione con più + Union with Subscript-Superscript Limits Unione con limiti a pedice e apice + Unions and Intersections Unioni e intersezioni + Up Diagonal Dots Puntini diagonali ascendenti + Up one level Livello superiore + Vector A Vettore A + Vector AB Vettore AB + Vertical Bars Barre verticali + Vertical Dots Puntini verticali + Visual effects Effetti visivi + Volume Integral Integrale di volume + Volume Integral with Limits Integrale di volume con limiti + Volume Integral with Stacked Limits Integrale di volume con limiti sovrapposti + Word document Documento Word + Yields Produce + Your system doesn't report an animation setting, so animation effects stay on. Il sistema non indica un'impostazione per le animazioni, quindi gli effetti di animazione restano attivi. + Your system has animation effects turned off. Nel sistema gli effetti di animazione sono disattivati. + Your system has animation effects turned on. Nel sistema gli effetti di animazione sono attivati. + n×n Identity Matrix Matrice identità n×n + n×n Matrix Matrice n×n + tOGGLE cASE iNVERTI mAIUSCOLE + x bar x segnato + {name} already exists. Choose Save again to replace it. {name} esiste già. Scegli di nuovo Salva per sostituirlo. + {name} wasn't found. {name} non è stato trovato. + ✓ None ✓ Nessuno +preview Title Esempio diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 7a466fecd..def8d7cfe 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -48,7 +48,7 @@ pub struct LangInfo { } /// The registry. English first: it is the fallback and the source language. -pub static LANGUAGES: [LangInfo; 12] = [ +pub static LANGUAGES: [LangInfo; 13] = [ LangInfo { code: "en", name: "English", source: "", prefer_hans: false, catalog: OnceLock::new() }, // Simplified Chinese; `zh`, `zh-CN`, `zh-SG` and `zh-Hans-*` resolve here (see `candidates`). LangInfo { code: "zh-hans", name: "简体中文", source: include_str!("zh-hans.tsv"), prefer_hans: true, catalog: OnceLock::new() }, @@ -71,6 +71,8 @@ pub static LANGUAGES: [LangInfo; 12] = [ // Russian; `ru`, `ru-RU`, `ru_RU.UTF-8` and other regions resolve here. LangInfo { code: "ru", name: "Русский", source: include_str!("ru.tsv"), prefer_hans: false, catalog: OnceLock::new() }, LangInfo { code: "tr", name: "Türkçe", source: include_str!("tr.tsv"), prefer_hans: false, catalog: OnceLock::new() }, + // Italian; `it-IT`, `it-CH` and `it-*` resolve here. + LangInfo { code: "it", name: "Italiano", source: include_str!("it.tsv"), prefer_hans: false, catalog: OnceLock::new() }, ]; impl LangInfo { diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 281807720..e795c8e09 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -646,3 +646,60 @@ fn bundled_interface_fonts_cover_turkish_without_system_fallbacks() { } assert!(!lang("tr").uses_cjk()); } + +#[test] +fn italian_locales_and_saved_preference_keep_document_content() { + let it = lang("it"); + for tag in ["it", "it-IT", "it_IT.UTF-8", "it-CH", "it_IT.UTF-8@euro"] { + assert_eq!(lang_from_tag(tag), Some(it), "{tag}"); + } + assert_eq!(first_supported(["fr-FR", "it-IT", "en-US"]), Some(it)); + assert_eq!(normalize_pref("IT"), Some("it")); + assert_eq!(it.name(), "Italiano"); + let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_engine::sample::sample_document()), Default::default()); + let before = serde_json::to_value(&app.session.doc).unwrap(); + let result = app.run("ui.language", serde_json::json!({"value": "IT"})).unwrap(); + assert_eq!(result["effective"], "it"); + assert_eq!(app.ui.language, "it"); + assert_eq!(serde_json::to_value(&app.session.doc).unwrap(), before); + let saved = serde_json::to_string(&app.ui).unwrap(); + let restored: crate::UiState = serde_json::from_str(&saved).unwrap(); + assert_eq!(Lang::from_pref(&restored.language), it); +} + +#[test] +fn italian_covers_the_entire_existing_interface_catalog_and_keeps_count_labels_neutral() { + use std::collections::HashSet; + let keys = |source| parse_entries(source).0.into_iter().map(|e| e.source).collect::>(); + let it = lang("it"); + for other in ["es", "tr", "et", "zh-hans"] { + assert!(keys(lang(other).0.source).is_subset(&keys(it.0.source)), "{other} has keys Italian lacks"); + } + assert_eq!(tr(it, "Good morning"), "Buongiorno"); + assert_eq!(tr(it, "Save"), "Salva"); + assert_eq!(tr(it, "Insert"), "Inserisci"); + assert_eq!(tr(it, "Spelling & Grammar"), "Ortografia e grammatica"); + assert_eq!(tr(it, "unknown future label"), "unknown future label"); + // The Review tab's group and its toggle keep different names. + assert_ne!(tr(it, "Changes"), tr(it, "Track Changes")); + // Italian nouns agree with the number, so count labels put the noun first and read right at 1. + for count in [0, 1, 2, 11, 21, 101] { + let n = count.to_string(); + assert_eq!(fmt(tr(it, "{words} words"), &[("words", &n)]), format!("Parole: {count}")); + assert_eq!(fmt(tr(it, "{selected} of {words} words"), &[("selected", "1"), ("words", &n)]), format!("Parole: {count}; selezionate: 1")); + assert_eq!(fmt(tr(it, "{n} lines"), &[("n", &n)]), format!("Righe: {count}")); + assert_eq!(fmt(tr(it, "{count} items highlighted"), &[("count", &n)]), format!("Elementi evidenziati: {count}")); + } +} + +#[test] +fn bundled_interface_fonts_cover_italian_letters() { + let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new()); + for (family, style) in [("Inter", "Regular"), ("Inter", "Medium"), ("Inter", "SemiBold"), ("JetBrains Mono", "Regular")] { + let face = db.face(family, style); + assert_eq!(face.family, family, "must use the bundled interface face"); + for ch in "ÀàÈèÉéÌìÒòÙù".chars() { + assert_ne!(face.glyph_for(ch), 0, "{family} {style} lacks {ch}"); + } + } +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index bd46c534f..125f5c5e2 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -50,7 +50,7 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key. | Method | Params | Result | |---|---|---| -| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog`, `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"|"en"|"zh-hans"|"zh-hant"|"ja"|"pt-br"|"es"|"uk"|"sr"|"sr-latn"|"et"|"tr"|"ru"}`), `ui.theme` (`{"value": "system"|"light"|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"percent": 80–200}`, clamped; no value reads it; scales the interface on top of the system display scale, document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"|"on"|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"|"picture"|"recipients"|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.caretAnimation` (`{"value": true|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading)…) | +| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog`, `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"|"en"|"zh-hans"|"zh-hant"|"ja"|"pt-br"|"es"|"uk"|"sr"|"sr-latn"|"et"|"tr"|"ru"|"it"}`), `ui.theme` (`{"value": "system"|"light"|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"percent": 80–200}`, clamped; no value reads it; scales the interface on top of the system display scale, document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"|"on"|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"|"picture"|"recipients"|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.caretAnimation` (`{"value": true|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading)…) | | `engine.commands` | — | every command: id, label, location, shortcut, params, enabled | | `document.inspect` | `text?` | blocks (text, style, runs, lists, tables), parts, sections, selection, pages | | `ui.inspect` | — | UI state, view, dialog, window size, page rects on screen, caret, comment balloons (`balloons`: id + screen rect; `balloon`: the selected one), perf | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 252290d9a..affe8c0ee 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (font list shows fonts by their names in the interface language, #505; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Italian interface, #594; previously minor: font list shows fonts by their names in the interface language, #505; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Interface languages, script support and proofing per language. How translation works (catalog format, adding a language, clean-room rule): `crates/ui-egui/src/i18n/mod.rs`. @@ -18,8 +18,8 @@ other ~18 languages Word ships, plus native-speaker review (human). ## What WordCraft ships (measured) -- **12 interface languages**: English plus `zh-hans`, `zh-hant`, `ja`, `uk`, `es`, `pt-br`, - Serbian `sr`/`sr-latn` (#250), Estonian `et`, Turkish `tr` and Russian `ru`. Catalogs have 1,305–1,450 entries +- **13 interface languages**: English plus `zh-hans`, `zh-hant`, `ja`, `uk`, `es`, `pt-br`, + Serbian `sr`/`sr-latn` (#250), Estonian `et`, Turkish `tr`, Russian `ru` and Italian `it` (#594). Catalogs have 2,021–2,399 entries each. A test (`every_tab_and_command_is_translated`) enforces that every ribbon tab, command label and ribbon location is translated in every language. - Of the 675 English strings a scan extracts from `tl!(…)` calls and command labels/locations, each @@ -52,8 +52,8 @@ shared items below. | Korean | ko | 0 | ❌ | Hangul ✅; macOS Korean IME fixes pending (#163, #164) | no | none | 3–5 | | Vietnamese | vi | 0 | ❌ | Latin with stacked diacritics, untested | no | none | 3–4 | -Other shipped languages: **7** — Traditional Chinese (`zh-hant`), Ukrainian (`uk`), Serbian -Cyrillic and Latin (`sr`, `sr-latn`, #250), Estonian (`et`), Turkish (`tr`) and Russian (`ru`), all partial. +Other shipped languages: **8** — Traditional Chinese (`zh-hant`), Ukrainian (`uk`), Serbian +Cyrillic and Latin (`sr`, `sr-latn`, #250), Estonian (`et`), Turkish (`tr`), Russian (`ru`) and Italian (`it`, #594), all partial. Estonian uses the existing fonts, follows `et-EE` system locales, and is available in Options or through `ui.language` with `{"value":"et"}`. Its 1,307 entries cover the existing interface catalog. Estonian spelling, grammar, dates and document templates are not added by the interface catalog. @@ -62,6 +62,12 @@ entries. Command search folds the Turkish dotted and dotless I (`italik` finds finds Yazı Tipi) while English labels and ASCII command ids keep their usual matching. Turkish proofing is not included; terminology and checks: [`localization-tr.md`](localization-tr.md). +Italian (`it`, #594) follows `it`, `it-IT` and `it-CH` system locales. Its catalog has every key of +the others plus 51 strings that are wrapped in `tl!` but were in no catalog yet (File › Info +properties, the File › Home greeting, list levels, Equation tab gallery and tooltips). Ribbon tabs, +groups and command names use the names Italian Word users know (feature names, as `AGENTS.md` +allows); all other text is written from the meaning of the English. No Italian proofing yet. + ## Shared work (not in the per-language hours) | Item | Hours | Notes | @@ -76,12 +82,13 @@ proofing is not included; terminology and checks: [`localization-tr.md`](localiz | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Italian interface catalog (#594), locale selection, saved preference and font coverage checks; count labels put the noun first so they read right at 1 | | 2026-10-11 | minor | Font dialog sets the Asian text font (`w:eastAsia`) and the Latin text font separately; the Asian list holds the installed Chinese, Japanese and Korean fonts by their interface-language names (#505) | | 2026-10-11 | minor | Font list shows installed fonts by their names in the interface language (宋体 for SimSun in Chinese, Japanese and Korean names likewise) and finds them by either name; a Chinese, Japanese or Korean font is stored as `w:eastAsia` too (#505) | | 2026-10-11 | minor | Chinese/Japanese interface fonts: a test checks every catalog character has a glyph; an installed CJK font follows the embedded faces whenever they miss one; macOS font asset folders searched for PingFang (#486) | | 2026-10-10 | minor | Russian interface (#300; 1,524 entries, built-in style names shown translated; covers everything the interface audit finds) | | 2026-10-11 | minor | Turkish interface catalog (1,307 entries), locale selection, saved preference, Turkish casing in command search and font coverage checks; catalog sizes re-counted | | 2026-10-11 | trivial | English spelling (#520): our supplement `assets/spelling/en-extra.txt` adds ~220 common modern words the Moby list lacks ("desktop", "download", "email"…) | -| 2026-10-10 | minor | Estonian interface catalog, locale selection, saved preference and font coverage checks | +| 2026-10-10 | minor | Estonian interface catalog, locale selection, saved preference and font coverage checks; count labels put the noun first so they read right at 1 | | 2026-10-10 | minor | Serbian (Cyrillic and Latin) interface merged (#250) | | 2026-10-10 | major | First version: twelve-language table, catalog coverage measured, Word's 30 UI languages and 120 proofing tools listed | From 55f7b894c636b811c66538de8bd30879f7d7695c Mon Sep 17 00:00:00 2001 From: arekown Date: Mon, 12 Oct 2026 09:55:05 +0200 Subject: [PATCH 05/88] Add German (de) interface language (#574) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add German (de) interface language New catalog crates/ui-egui/src/i18n/de.tsv (1,307 entries), key set byte-identical to zh-hans.tsv; original translations written from the meaning of the English UI strings in standard German word-processor vocabulary; MIT OR Apache-2.0. - LangInfo for de (Deutsch) registered in i18n::LANGUAGES - Tests: German locale resolution (de, de-DE, de-AT, de-CH, de_DE.UTF-8@euro), saved-preference/document-content isolation, catalog key-set parity with zh-hans, count-neutral wording, bundled-font glyph coverage for ÄäÖöÜüß - Count strings use count-neutral wording (Wörter: {n}), the convention the other catalogs' tests pin - Docs: localization-parity.md, gaps.md, roadmap.md, ROADMAP.md, README.md, control-protocol.md; ATTRIBUTION.md row for de.tsv cargo test -p wordcraft-ui-egui i18n: 28 passed; --lib: 162 passed. * rustfmt after merging main * German: translate interface strings added on main since this PR Adds 636 German entries so de.tsv again has the full zh-hans key set (2,021 entries) and drops the stale "Wildcards (regex)" row; updates the documented entry count. Co-Authored-By: Claude Opus 5.5 * Fourteen interface languages with German and Italian --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ATTRIBUTION.md | 1 + README.md | 2 +- ROADMAP.md | 6 +- crates/ui-egui/src/i18n/de.tsv | 2036 ++++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/mod.rs | 5 +- crates/ui-egui/src/i18n/tests.rs | 59 +- docs/control-protocol.md | 2 +- docs/gaps.md | 2 +- docs/localization-parity.md | 11 +- docs/roadmap.md | 4 +- 10 files changed, 2114 insertions(+), 14 deletions(-) create mode 100644 crates/ui-egui/src/i18n/de.tsv diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index dc3412843..293066cd6 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -64,5 +64,6 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra | `crates/ui-egui/src/i18n/uk.tsv` | @dmatviichuk with Codex | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Ukrainian interface catalog; spelling checked with external VESUM and ULIF dictionaries, neither bundled | | `crates/ui-egui/src/i18n/et.tsv` | @henno with Codex | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Estonian interface catalog | | `crates/ui-egui/src/i18n/pt-br.tsv` | @edurbs with ZCode | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Brazilian Portuguese interface catalog; spelling checked with the external pt_BR Hunspell dictionary, not bundled | +| `crates/ui-egui/src/i18n/de.tsv` | @arekown with Kimi | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | German interface catalog | | `crates/ui-egui/src/i18n/ru.tsv` | Dmitrii Khlapov with Claude | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Russian interface catalog; reviewed by a native speaker | | `crates/ui-egui/src/i18n/tr.tsv` | @PbyLoyth with Codex and Claude Code | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Turkish interface catalog | diff --git a/README.md b/README.md index 51a184577..474b0c6b8 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ Every screenshot below is WordCraft itself, rendered offscreen by its own UI tes | **References** | Table of contents, footnotes and endnotes, citations and bibliography (APA, MLA, Chicago, IEEE), captions, table of figures, cross-references, index, table of authorities | | **Review** | Spelling and grammar with suggestions, thesaurus, word count, comments in margin balloons or a pane, track changes, accept/reject, compare documents, restrict editing, accessibility checker, document inspector | | **Mailings** | Mail merge from CSV, merge fields, address block, greeting line, rules, preview, finish to a document; envelopes and labels | -| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский or Italiano (follows the system language by default) | +| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский, Italiano or Deutsch (follows the system language by default) | | **Files** | .docx read/write (opens in Word), Word 97-2003 .doc import, PDF export, .odt, .rtf, .html, .md, .tex, .txt import/export, page images | The honest picture, area by area, is in [ROADMAP.md](ROADMAP.md), the diff --git a/ROADMAP.md b/ROADMAP.md index 788c97060..06afe38a1 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 (Italian interface, #594; previously trivial: table borders and the resize handle drag on the page, #217; 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 (German interface catalog, #574; 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 @@ -106,7 +106,7 @@ Weights and evidence per area: [docs/target-app-parity.md](docs/target-app-parit | Portuguese | pt-br (pt-PT none) | partial | ~95% | | Indonesian | id | none | 0% | | Japanese | ja | partial | ~95% | -| German | de | none (PR #203 open) | 0% | +| German | de | partial (#574) | ~95% | | Korean | ko | none | 0% | | Vietnamese | vi | none | 0% | @@ -133,6 +133,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 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 | German (`de`) interface (#574): 2,021 translated entries covering the full zh-hans key set, locale and saved-preference tests, bundled-font checks; no German proofing resources | | 2026-10-11 | `.doc` import keeps pictures and drawings (#551): inline and floating pictures (PNG, JPEG, DIB, EMF, WMF), shapes, lines, groups and text boxes with position and wrap, from [MS-ODRAW] | | 2026-10-11 | Tables resize with the mouse (#217): drag a column border (the neighbour makes up the difference; Shift moves one column), a row's bottom border, or the handle past the bottom-right corner to scale the table; a dashed guide while dragging, one undo step per drag; `table.resizeDrag` for agents | | 2026-10-11 | Animation effects setting: File › Options › Accessibility › Animation effects (Use system setting, On, Off). With the system's animations off (Windows' Animation effects, macOS' Reduce motion, the Linux/BSD desktop's setting, a browser's reduced motion) the page no longer smooth-scrolls: wheel notches and Ctrl+wheel zoom land at once, in the document and in every list and pane, and menus and buttons don't fade; `ui.animations` for agents | @@ -208,6 +209,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Progress log: German interface (#574) | | 2026-10-11 | trivial | Progress log: tables resize with the mouse (#217) | | 2026-10-11 | trivial | Progress log: Animation effects setting | | 2026-10-11 | trivial | Progress log: built-in file browser when the system file dialog is unavailable (#576) | diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv new file mode 100644 index 000000000..2d008112d --- /dev/null +++ b/crates/ui-egui/src/i18n/de.tsv @@ -0,0 +1,2036 @@ +# German (de) interface catalog. Columns, separated by a TAB: +# context English source translation +# context empty (WordCraft uses no contexts yet) +# source the English text exactly as in the code (tl!(...), ribbon labels, command labels) +# escapes \n \t \\ inside a column. `{name}` placeholders must appear in both columns, and a +# trailing "…" in one needs one in the other. +# Lines starting with # and blank lines are ignored. A missing entry shows in English. +# +# Clean room: every entry was written from the plain meaning of the English label in ordinary +# computing vocabulary. No Microsoft Word (or other product) localisation, string table or +# translated UI was consulted or copied; add entries the same way. +# Product, format and technology names (WordCraft, PDF, Markdown, MCP …) stay in Latin letters. + + (no style) (keine Formatvorlage) + (no suggestions) (keine Vorschläge) + 1.5 Line Spacing Zeilenabstand 1,5 + A free, open-source word processor written from scratch in Rust.\nPart of the Crafting Apps from the ArtCraft team. Ein kostenloses, quelloffenes Textverarbeitungsprogramm, von Grund auf in Rust geschrieben.\nTeil der Crafting Apps des ArtCraft-Teams. + About Info + About WordCraft Über WordCraft + Accept Annehmen + Accept All Changes Alle Änderungen annehmen + Accept This Change Diese Änderung annehmen + Accessibility Barrierefreiheit + Add Hinzufügen + Add a comment Kommentar hinzufügen + Add a Table Tabelle hinzufügen + Add Object to Selection Objekt zur Auswahl hinzufügen + Add Space Before Paragraph Abstand vor Absatz hinzufügen + Add space between Asian and Latin text Abstand zwischen asiatischem und lateinischem Text hinzufügen + Add space between Asian text and numbers Abstand zwischen asiatischem Text und Zahlen hinzufügen + Add tab stop at: Tabstopp hinzufügen bei: + Add Text Text hinzufügen + Add to Dictionary Zum Wörterbuch hinzufügen + Add to Style Gallery Zum Formatvorlagenkatalog hinzufügen + Address Block Adressblock + Address: Adresse: + Address\nBlock Adressblock + Adjust Anpassen + Advanced Erweitert + After: Nach: + Agents Agenten + Align Ausrichten + Align Left Linksbündig + Align Right Rechtsbündig + Aligned at: Ausgerichtet an: + Alignment Ausrichtung + All Borders Alle Rahmenlinien + All Caps GROSSBUCHSTABEN + All caps Großbuchstaben + All Markup Alle Änderungen + Allow hanging punctuation Hängende Satzzeichen zulassen + Allow Latin text to wrap in the middle of a word Lateinischen Text mitten im Wort umbrechen + Alt Text Alternativtext + Apply Asian line-breaking rules (kinsoku) Asiatische Zeilenumbruchregeln anwenden (Kinsoku) + Apply Character Style Zeichenformatvorlage anwenden + Apply Style Formatvorlage anwenden + Apply Styles Formatvorlagen anwenden + Arrange Anordnen + Arrange\nAll Alle\nanordnen + Arrow Pfeil + Artistic Effects Künstlerische Effekte + Asian Typography Asiatische Typografie + Author Autor + AutoCorrect Options AutoKorrektur-Optionen + AutoFit Automatisch anpassen + AutoFit Contents An Inhalt anpassen + AutoFit Window An Fenster anpassen + Automatic Automatisch + Automatic ({language}) Automatisch ({language}) + Automatic follows your system's language. Menus and commands change; your documents don't. „Automatisch“ folgt der Sprache Ihres Systems. Menüs und Befehle ändern sich, Ihre Dokumente nicht. + Automatic Table 1 (3 levels) Automatisches Inhaltsverzeichnis 1 (3 Ebenen) + Automatic Table 2 (2 levels) Automatisches Inhaltsverzeichnis 2 (2 Ebenen) + AutoSave AutoSpeichern + AutoSave documents you have saved in WordCraft In WordCraft gespeicherte Dokumente automatisch speichern + AutoSave failed: {error}. Save the document to turn AutoSave back on. AutoSpeichern fehlgeschlagen: {error}. Speichern Sie das Dokument, um AutoSpeichern wieder zu aktivieren. + Balloons Sprechblasen + Bottom Margin Unterer Rand + Click a border between two cells to merge them. Esc stops. Klicken Sie auf eine Rahmenlinie zwischen zwei Zellen, um sie zu verbinden. Esc beendet. + Bullet Aufzählungszeichen + By author Nach Autor + Changed lines: Geänderte Zeilen: + Click level to modify: Zu ändernde Ebene anklicken: + Color only Nur Farbe + Compress punctuation at the start of a line Satzzeichen am Zeilenanfang komprimieren + Dark page (white text on black) Dunkle Seite (weißer Text auf Schwarz) + Drag a rectangle to draw a table, or a line across cells to split them. Esc stops. Ziehen Sie ein Rechteck, um eine Tabelle zu zeichnen, oder eine Linie über Zellen, um sie zu teilen. Esc beendet. + Draw Table Tabelle zeichnen + Define New Multilevel List Neue mehrstufige Liste definieren + Define New Multilevel List… Neue mehrstufige Liste definieren… + Deletions: Löschungen: + Edit Comment Kommentar bearbeiten + Enter formatting for number: Formatierung für die Nummer eingeben: + Follow number with: Der Nummer folgt: + Formatting Formatierung + Include level number from: Ebenennummer übernehmen aus: + Ink Freihand + Insertions and Deletions Einfügungen und Löschungen + Insertions: Einfügungen: + Left border Linke Rahmenlinie + Left Margin Linker Rand + Indents and Spacing Einzüge und Abstände + Legal style numbering Nummerierung im juristischen Stil + Line breaking Zeilenumbruch + Link level to style: Ebene mit Formatvorlage verknüpfen: + List Definition Listendefinition + More templates → Weitere Vorlagen → + No templates match your search. Keine Vorlagen entsprechen Ihrer Suche. + Nothing Keine + Number alignment: Nummernausrichtung: + Number format Nummernformat + Number style for this level: Nummernstil für diese Ebene: + Outside border Äußere Rahmenlinie + Reopen Erneut öffnen + Restart list after: Liste neu starten nach: + Right border Rechte Rahmenlinie + Right Margin Rechter Rand + Search for templates Vorlagen suchen + Show all revisions inline Alle Änderungen im Text anzeigen + Show documents with their colours inverted, like View › Switch Modes. Saving, printing and PDFs are unchanged. Zeigt Dokumente mit invertierten Farben an, wie Ansicht › Modus wechseln. Speichern, Drucken und PDFs bleiben unverändert. + AutoSave saves every change to the file (needs a saved document) AutoSpeichern speichert jede Änderung in der Datei (erfordert ein gespeichertes Dokument) + AutoSave isn't available in the browser: it can't write to your files. Save downloads a copy. AutoSpeichern ist im Browser nicht verfügbar: Es kann nicht in Ihre Dateien schreiben. „Speichern“ lädt eine Kopie herunter. + AutoSave only keeps .docx, .odt and .rtf files; {name} can't keep all formatting. Save as .docx to turn on AutoSave. AutoSpeichern behält nur .docx-, .odt- und .rtf-Dateien; {name} kann nicht die gesamte Formatierung bewahren. Speichern Sie als .docx, um AutoSpeichern zu aktivieren. + Save the document to turn on AutoSave Speichern Sie das Dokument, um AutoSpeichern zu aktivieren + Save the document to turn on AutoSave (a file you have only opened isn't rewritten until you save it) Speichern Sie das Dokument, um AutoSpeichern zu aktivieren (eine nur geöffnete Datei wird erst beim Speichern neu geschrieben) + AutoText Textbausteine + Backspace Rücktaste + Banded Columns Gestreifte Spalten + Banded Rows Gestreifte Zeilen + Before: Vor: + Bibliography Literaturverzeichnis + Binary assets added Binäre Assets hinzugefügt + Binary assets removed Binäre Assets entfernt + Blank Leer + Blank (Three Columns) Leer (drei Spalten) + Blank document Leeres Dokument + Blank Page Leere Seite + Block\nAuthors Block-\nAutoren + Bold Fett + Bookmark Textmarke + Bookmark name: Textmarkenname: + Border Painter Rahmenlinien-Maler + Border\nPainter Rahmenlinien-\nMaler + Borders Rahmenlinien + Tab stop position: Tabstoppposition: + Default tab stops: Standardtabstopps: + Tab stops to be cleared: Zu löschende Tabstopps: + All Alle + Decimal Dezimal + Bar Balken + Leader Füllzeichen + Set Festlegen + Page Border Seitenrahmen + Apply to: Übernehmen für: + Whole document Gesamtes Dokument + This section Dieser Abschnitt + Fill Füllung + Setting: Einstellung: + 3-D 3D + Custom Benutzerdefiniert + Style: Art: + Dot dash Punkt-Strich + Triple Dreifach + Between Dazwischen + Borders and Shading Rahmen und Schattierung + Tabs… Tabstopps… + Borders and Shading… Rahmen und Schattierung… + Bottom Border Untere Rahmenlinie + Bottom of Page Seitenende + Bottom: Unten: + Box Rahmen + Breaks Umbrüche + Bring Forward Nach vorne bringen + Bullet Library Aufzählungszeichen-Bibliothek + Bullets Aufzählungszeichen + Calendar Kalender + Cancel Abbrechen + Capitalize Each Word Jedes Wort großschreiben + Captions Beschriftungen + Cell Margins Zellenränder + Cell Size Zellengröße + Cell\nMargins Zellen-\nränder + Center Zentrieren + Change Ändern + Change Case Groß-/Kleinschreibung ändern + Change List Level Listenebene ändern + Change Picture Bild ändern + Change Shape Form ändern + Changes Änderungen + Character Position Zeichenposition + Character Scale Zeichenskalierung + Character Shading Zeichenschattierung + Character Spacing Zeichenabstand + Character Border Zeichenrahmen + Characters (no spaces) Zeichen (ohne Leerzeichen) + Characters (with spaces) Zeichen (mit Leerzeichen) + Chart Diagramm + Check Accessibility Barrierefreiheit prüfen + Check Compatibility Kompatibilität prüfen + Check for Errors Auf Fehler prüfen + Check Spelling as You Type Rechtschreibung während der Eingabe prüfen + Check\nAccessibility Barrierefreiheit\nprüfen + Citation Style Zitierstil + Citations & Bibliography Zitate und Literaturverzeichnis + Clear Löschen + Clear All Formatting Alle Formatierungen löschen + Clipboard Zwischenablage + Clipboard Pane Zwischenablage-Bereich + Clipboard Items Elemente der Zwischenablage + Paste Clipboard Item Element der Zwischenablage einfügen + Paste All Alle einfügen + Delete Clipboard Item Element der Zwischenablage löschen + Clear All Alle löschen + Click an item to paste it. Klicken Sie auf ein Element, um es einzufügen. + Nothing collected yet. Items you copy or cut appear here. Noch nichts gesammelt. Elemente, die Sie kopieren oder ausschneiden, erscheinen hier. + (picture or object) (Bild oder Objekt) + Close Schließen + Close Header and Footer Kopf- und Fußzeile schließen + Collapse Selection Auswahl reduzieren + Color Farbe + Color: Farbe: + Colors Farben + Column Spalte + Column Break Spaltenumbruch + Columns Spalten + Combine Kombinieren + Commands Befehle + Comment Kommentar + Comments Kommentare + Comments Pane Kommentarbereich + Community\nDiscord Community-\nDiscord + Compact Kompakt + Compare Vergleichen + Compress Pictures Bilder komprimieren + Continuous Fortlaufend + Convert Konvertieren + Convert Text to Table Text in Tabelle umwandeln + Convert Text to Table… Text in Tabelle umwandeln… + Convert to Text In Text umwandeln + Copy Kopieren + Copy Formatting Formatierung kopieren + Corrections Korrekturen + Count words in text boxes and notes Wörter in Textfeldern und Notizen mitzählen + Couldn't download {name}: {error} {name} konnte nicht heruntergeladen werden: {error} + Cover Page Deckblatt + Create Erstellen + Create a Style Formatvorlage erstellen + Create an interactive outline of your document.\n\nIt's a great way to keep track of where you are or quickly move your content around.\n\nTo get started, go to the Home tab and apply Heading styles to the headings in your document. Erstellen Sie eine interaktive Gliederung Ihres Dokuments.\n\nSo behalten Sie den Überblick, wo Sie sind, und können Inhalte schnell verschieben.\n\nWechseln Sie zum Start zunächst zur Registerkarte „Start“ und weisen Sie den Überschriften in Ihrem Dokument Überschriften-Formatvorlagen zu. + Create New Style Neue Formatvorlage erstellen + Crop Zuschneiden + Cross-reference Querverweis + Current Position Aktuelle Position + Current style: {style} Aktuelle Formatvorlage: {style} + Custom Margins… Benutzerdefinierte Ränder… + Custom Watermark Benutzerdefiniertes Wasserzeichen + Custom Watermark… Benutzerdefiniertes Wasserzeichen… + Customize Quick Access Toolbar Symbolleiste für den Schnellzugriff anpassen + Cut Ausschneiden + Dark Mode Dunkelmodus + Dashed Gestrichelt + Data Daten + Date & Time Datum und Uhrzeit + Decrease Font Size Schriftgrad verkleinern + Decrease Indent Einzug verkleinern + Default Standard + Define New Bullet Neues Aufzählungszeichen definieren + Define New Number Format Neues Nummernformat definieren + Delete Löschen + Delete Cells Zellen löschen + Delete Columns Spalten löschen + Delete Comment Kommentar löschen + Delete Next Word Nächstes Wort löschen + Delete Previous Word Vorheriges Wort löschen + Delete Rows Zeilen löschen + Delete Style Formatvorlage löschen + Delete Table Tabelle löschen + Design Entwurf + development build Entwicklungsbuild + Diagonal Diagonal + Diamond Raute + Dictate Diktieren + Different First Page Andere erste Seite + Different Odd & Even Pages Andere gerade und ungerade Seiten + Discord Discord + Discord community Discord-Community + Display Anzeige + Display for Review Anzeige zur Überprüfung + Distribute Columns Spalten verteilen + Distribute Rows Zeilen verteilen + Distributed Verteilt + Do Not Show in TOC Nicht im Inhaltsverzeichnis anzeigen + Do you want to save changes to {name}? Möchten Sie die Änderungen an {name} speichern? + Don't Save Nicht speichern + Document End Dokumentende + Document Formatting Dokumentformatierung + Document Property Dokumenteigenschaft + Document Start Dokumentanfang + Document Text Dokumenttext + Document Title Dokumenttitel + Documents you open will show up here. Geöffnete Dokumente werden hier angezeigt. + Dot-dash Punkt-Strich + Dotted Gepunktet + Double Doppelt + Double Spacing Doppelter Zeilenabstand + Double Strikethrough Doppelt durchgestrichen + Double Underline Doppelt unterstrichen + Down Nach unten + Draft Entwurfsansicht + Draw Zeichnen + Drawing Tools Zeichentools + Drawing\nCanvas Zeichen-\nfläche + Drop Cap Initiale + Edit Footer Fußzeile bearbeiten + Edit Header Kopfzeile bearbeiten + Edit Recipient List Empfängerliste bearbeiten + Edit\nRecipient List Empfängerliste\nbearbeiten + Edited Bearbeitet + Editing Bearbeitung + Editing header/footer Kopf-/Fußzeile bearbeiten + Editing — edit the document directly Bearbeitung — das Dokument direkt bearbeiten + Editor Editor + Effects Effekte + Emboss Relief + Encrypt with Password Mit Kennwort verschlüsseln + English (United States) Englisch (USA) + Engrave Gravur + Enter page number: Seitenzahl eingeben: + Envelopes Umschläge + Equation Formel + Eraser Radierer + Even Page Gerade Seite + Every command is available to scripts and AI agents: run `wordcraft-cli mcp` for an MCP server, or start the app with `--control ` for the JSON control channel. Jeder Befehl steht Skripten und KI-Agenten zur Verfügung: Führen Sie `wordcraft-cli mcp` für einen MCP-Server aus, oder starten Sie die App mit `--control ` für den JSON-Steuerkanal. + Export Exportieren + Export a copy to share (PDF, Word document) Eine Kopie zum Teilen exportieren (PDF, Word-Dokument) + Export Page as PNG Seite als PNG exportieren + Export PDF PDF exportieren + Exported {path} Exportiert: {path} + Extend Selection Auswahl erweitern + Field Feld + File Datei + Find Suchen + Find and Replace Suchen und Ersetzen + Find Next Weitersuchen + Find Previous Rückwärts suchen + Find Recipient Empfänger suchen + Find what: Suchen nach: + Finish Fertig stellen + Finish & Merge Fertig stellen und Seriendruck + Finish &\nMerge Fertig stellen und\nSeriendruck + First Column Erste Spalte + First commit Erster Commit + First line: Erste Zeile: + Fixed Column Width Feste Spaltenbreite + Focus Fokus + Font Schriftart + Font Color Schriftfarbe + Font color: Schriftfarbe: + Font Dialog Schriftart-Dialogfeld + Font Size Schriftgrad + Font: Schriftart: + Fonts Schriftarten + Footer Fußzeile + Footnote and Endnote Fußnote und Endnote + Footnotes Fußnoten + Format Format + Format Page Numbers Seitenzahlen formatieren + Format Page Numbers… Seitenzahlen formatieren… + Format Painter Format übertragen + Formatting at Selection Formatierung an der Auswahl + Formula Formel + General Allgemein + Go To Gehe zu + Go to Gehe zu + Go To (⌘⌥G) Gehe zu (⌘⌥G) + Grab bag Überraschungspaket + Greeting Line Anrede + Greeting\nLine Anrede-\nzeile + Gridlines Rasterlinien + Group Gruppieren + Grow Font 1 Point Schrift um 1 Punkt vergrößern + Hanging Indent Hängender Einzug + Header Kopfzeile + Header & Footer Kopf- und Fußzeile + Header Row Kopfzeilenwiederholung + Header/Footer Position Kopf-/Fußzeilenposition + Heading 1 Überschrift 1 + Heading 2 Überschrift 2 + Heading 3 Überschrift 3 + Headings Überschriften + Heart Herz + Help Hilfe + Hidden Ausgeblendet + Highlight Merge Fields Seriendruckfelder hervorheben + Highlight\nMerge Fields Seriendruckfelder\nhervorheben + Highlighter Textmarker + Home Start + Horizontal Horizontal + Horizontal Line Horizontale Linie + Hyphenation Silbentrennung + Icons Symbole + Ignore All Alle ignorieren + Illustrations Illustrationen + Immersive Immersiv + Immersive Reader Immersiver Lesemodus + Immersive\nReader Immersiver\nLesemodus + Increase Font Size Schriftgrad vergrößern + Increase Indent Einzug vergrößern + Indent Einzug + Indentation Einzug + Indents Einzüge + Index Index + Info Info + Ink to\nMath Freihand zu\nFormel + Ink to\nShape Freihand zu\nForm + Ink\nReplay Freihand-\nWiedergabe + Insert Einfügen + Insert Above Oberhalb einfügen + Insert Below Unterhalb einfügen + Insert Caption Beschriftung einfügen + Insert Citation Zitat einfügen + Insert Endnote Endnote einfügen + Insert Footnote Fußnote einfügen + Insert Hyperlink Hyperlink einfügen + Insert Index Index einfügen + Insert Left Links einfügen + Insert Merge Field Seriendruckfeld einfügen + Insert Merge\nField Seriendruckfeld\neinfügen + Insert Right Rechts einfügen + Insert Shapes Formen einfügen + Insert Table Tabelle einfügen + Insert Table of Authorities Rechtsgrundlagenverzeichnis einfügen + Insert Table of Figures Abbildungsverzeichnis einfügen + Insert Table… Tabelle einfügen… + Insert\nAbove Oberhalb\neinfügen + Insert\nCaption Beschriftung\neinfügen + Insert\nCitation Zitat\neinfügen + Insert\nFootnote Fußnote\neinfügen + Inside Borders Innenrahmen + Inspect Document Dokument prüfen + Interface language: Benutzeroberflächensprache: + Italic Kursiv + Join the ArtCraft community on Discord Treten Sie der ArtCraft-Community auf Discord bei + Join us on Discord: discord.gg/artcraft Besuchen Sie uns auf Discord: discord.gg/artcraft + Justify Blocksatz + Keep Lines Together Zeilen nicht trennen + Keep lines together Zeilen nicht trennen + Keep Text Only Nur Text übernehmen + Keep with Next Mit nächstem Absatz zusammenhalten + Keep with next Mit nächstem Absatz zusammenhalten + Labels Etiketten + Landscape Querformat + Language Sprache + Lasso Lasso + Last Column Letzte Spalte + Last commit Letzter Commit + Layout Layout + Layout Summary Layoutübersicht + Left Links + Left Border Linke Rahmenlinie + Left: Links: + Letter Brief + Line Linie + Line and Page Breaks Zeilen- und Seitenumbruch + Line and Paragraph Spacing Zeilen- und Absatzabstand + Line Break Zeilenumbruch + Line delta Zeilenabstand-Delta + Line End Zeilenende + Line Numbers Zeilennummern + Line Spacing Zeilenabstand + Line Spacing Options… Zeilenabstand-Optionen… + Line spacing: Zeilenabstand: + Line Start Zeilenanfang + Lines Linien + Lines added Zeilen hinzugefügt + Lines deleted Zeilen gelöscht + Link Link + Link to Previous Mit vorheriger verknüpfen + Links Links + Location: {path} Speicherort: {path} + lowercase kleinbuchstaben + Macros Makros + Mailings Sendungen + Manage Sources Quellen verwalten + Many pages Viele Seiten + Margins Ränder + Mark Citation Zitat markieren + Mark Entry Eintrag markieren + Mark\nCitation Zitat\nmarkieren + Mark\nEntry Eintrag\nmarkieren + Markdown (*.md) Markdown (*.md) + Match case Groß-/Kleinschreibung beachten + Match Fields Felder abgleichen + Matrix Matrix + Merge Verbinden + Merge Cells Zellen verbinden + Merge Formatting Formatierung zusammenführen + Merged PRs Zusammengeführte PRs + Mirrored Gespiegelt + MIT OR Apache-2.0. Copyright (c) 2026 ArtCraft Team and the WordCraft contributors. MIT OR Apache-2.0. Copyright (c) 2026 ArtCraft Team and the WordCraft contributors. + Moderate Mittel + Modify Style Formatvorlage ändern + Modify… Ändern… + Move or Resize Verschieben oder Größe ändern + Multilevel List Mehrstufige Liste + Multiple Pages Mehrere Seiten + Name (A–Z) Name (A–Z) + Name: Name: + Narrow 0.5" all Schmal 0,5" rundum + Navigation Navigation + Navigation Pane Navigationsbereich + New Neu + New Comment Neuer Kommentar + New from Template Neu aus Vorlage + New Paragraph Neuer Absatz + New Style… Neue Formatvorlage… + New Window Neues Fenster + New\nComment Neuer\nKommentar + New\nWindow Neues\nFenster + Next Weiter + Next Change Nächste Änderung + Next Comment Nächster Kommentar + Next Footnote Nächste Fußnote + Next Page Nächste Seite + Next Record Nächster Datensatz + Next Section Nächster Abschnitt + No Border Keine Rahmenlinie + No Color Keine Farbe + No contributor data was built into this copy. In dieser Version wurden keine Mitwirkenden-Daten eingebaut. + No Markup Keine Änderungen + No model credits were built into this copy. In dieser Version wurden keine Modellnachweise eingebaut. + No Paragraph Space Kein Absatzabstand + Nonbreaking Hyphen Geschützter Bindestrich + Nonbreaking Space Geschütztes Leerzeichen + None Keine + Normal 1" all Normal 1" rundum + Normal Style Formatvorlage „Standard“ + Not available right now Derzeit nicht verfügbar + Not saved Nicht gespeichert + Not saved yet Noch nicht gespeichert + Nudge Feinverschieben + Number of columns: Anzahl der Spalten: + Number of rows: Anzahl der Zeilen: + Numbering Nummerierung + Object Objekt + Odd Page Ungerade Seite + Office 2003 Default Office-2003-Standard + OK OK + One Eins + One Page Eine Seite + Only web (http, https) and email (mailto) links can be followed Nur Web- (http, https) und E-Mail-Links (mailto) können geöffnet werden + Open Öffnen + OpenDocument Text (*.odt) OpenDocument-Text (*.odt) + Optional Hyphen Bedingter Trennstrich + Options Optionen + Orientation Ausrichtung + Outline Gliederung + Outline Level Gliederungsebene + Outside Borders Außenrahmen + Oval Oval + Page Seite + Page Background Seitenhintergrund + Page Borders Seitenrahmen + Page Break Seitenumbruch + Page Break Before Seitenumbruch davor + Page break before Seitenumbruch davor + Page Breaks Seitenumbrüche + Page Color Seitenfarbe + Page Down Bild ab + Page image (*.png) Seitenbild (*.png) + Page Movement Seitennavigation + Page Number Seitenzahl + Page Setup Seite einrichten + Page Up Bild auf + Page Width Seitenbreite + Page width Seitenbreite + Page X of Y Seite X von Y + Page {page} Seite {page} + Page {page} of {pages} Seite {page} von {pages} + Page\nBorders Seiten-\nrahmen + Page\nColor Seiten-\nfarbe + Page\nNumber Seiten-\nzahl + Pages Seiten + Paragraph Absatz + Paragraph Details Absatzdetails + Paragraph Down Absatz nach unten + Paragraph Settings Absatzeinstellungen + Paragraph Spacing Absatzabstand + Paragraph Up Absatz nach oben + Paragraphs Absätze + Paste Einfügen + Paste (⌘V) Einfügen (⌘V) + Paste Formatting Formatierung einfügen + Paste: Keep Text Only Einfügen: Nur Text übernehmen + Paste: Merge Formatting Einfügen: Formatierung zusammenführen + PDF document (*.pdf) PDF-Dokument (*.pdf) + Pen Stift + Pencil Bleistift + Picture Border Bildrahmen + Picture Format Bildformat + Picture Styles Bildformate + Pictures Bilder + Plain text (*.txt) Nur Text (*.txt) + Portrait Hochformat + Position Position + Preview Results Ergebnisvorschau + Preview\nResults Ergebnis-\nvorschau + Previous Zurück + Previous Change Vorherige Änderung + Previous Comment Vorheriger Kommentar + Previous Record Vorheriger Datensatz + Previous Section Vorheriger Abschnitt + Print Drucken + Print Layout Seitenlayout + Print\nLayout Seiten-\nlayout + Proofing Rechtschreibprüfung + Proofing Issues Rechtschreibprobleme + Properties Eigenschaften + Protect Schützen + Protect Document Dokument schützen + Quick Access Toolbar Symbolleiste für den Schnellzugriff + Quick Parts Schnellbausteine + Quick Tables Schnelltabellen + Read Aloud Vorlesen + Read Mode Lesemodus + Read\nAloud Vor-\nlesen + Read\nMode Lese-\nmodus + Recent Zuletzt verwendet + Record Macro Makro aufzeichnen + Recover Unsaved Documents Nicht gespeicherte Dokumente wiederherstellen + Rectangle Rechteck + Redo Wiederholen + Reduce Hanging Indent Hängenden Einzug verkleinern + References Verweise + Reject Ablehnen + Reject All Changes Alle Änderungen ablehnen + Reject Change Änderung ablehnen + Relaxed Locker + Remove Background Hintergrund entfernen + Remove Footer Fußzeile entfernen + Remove Header Kopfzeile entfernen + Remove Hyperlink Hyperlink entfernen + Remove Space After Paragraph Abstand nach Absatz entfernen + Remove Table of Contents Inhaltsverzeichnis entfernen + Remove Watermark Wasserzeichen entfernen + Repeat Wiederholen + Repeat Header Rows Kopfzeilen wiederholen + Replace Ersetzen + Replace All Alle ersetzen + Replace Document Text Dokumenttext ersetzen + Replace with: Ersetzen durch: + Replay Wiedergeben + Reply Antworten + Report Bericht + Research Recherche + Researcher Recherche-Assistent + Reset Picture Bild zurücksetzen + Resolve Erledigen + Resolved Erledigt + Restart at 1 Bei 1 neu beginnen + Restart Each Page Auf jeder Seite neu beginnen + Restart Each Section In jedem Abschnitt neu beginnen + Restrict Editing Bearbeitung einschränken + Restrict\nEditing Bearbeitung\neinschränken + Results Ergebnisse + Reverse the order Die Reihenfolge umkehren + Review Überprüfen + Reviewing Überprüfung + Reviewing Pane Überprüfungsbereich + Reviewing — edits become suggestions Überprüfung — Bearbeitungen werden zu Vorschlägen + Rich Text Format (*.rtf) Rich Text Format (*.rtf) + Right Rechts + Right Border Rechte Rahmenlinie + Right-to-Left Text Direction Textrichtung von rechts nach links + Left-to-Right Text Direction Textrichtung von links nach rechts + Right: Rechts: + Rotate Drehen + Rounded Rectangle Abgerundetes Rechteck + Rows & Columns Zeilen und Spalten + Ruler Lineal + Rules Regeln + Résumé Lebenslauf + Save Speichern + Save a copy in another format. Printing goes through a PDF you can print from any viewer. Eine Kopie in einem anderen Format speichern. Gedruckt wird über ein PDF, das sich aus jedem Betrachter drucken lässt. + Save As Speichern unter + Save as Template Als Vorlage speichern + Saved Gespeichert + Search Commands Befehle suchen + Search commands and help Befehle und Hilfe suchen + Search document Dokument durchsuchen + Section Breaks Abschnittsumbrüche + Section Properties Abschnittseigenschaften + Sections Abschnitte + Select Auswählen + Select All Alles auswählen + Select Cell Zelle auswählen + Select Line Zeile auswählen + Select Objects Objekte auswählen + Select Paragraph Absatz auswählen + Select Range Bereich auswählen + Select Recipients Empfänger auswählen + Select Row Zeile auswählen + Select Sentence Satz auswählen + Select Table Tabelle auswählen + Select Text Text auswählen + Select Text with Similar Formatting Text mit ähnlicher Formatierung auswählen + Select Word Wort auswählen + Select\nRecipients Empfänger\nauswählen + Selection Auswahl + Selection Pane Auswahlbereich + Send Backward Nach hinten setzen + Sentence case. Nur erstes Wort groß. + Set as Default Als Standard festlegen + Set Caret Einfügemarke setzen + Set Character Formatting Zeichenformatierung festlegen + Set Numbering Value Nummerierungswert festlegen + Set Paragraph Formatting Absatzformatierung festlegen + Shading Schattierung + Shadow Schatten + Shape Fill Formfüllung + Shape Format Formformat + Shape Outline Formkontur + Shape Styles Formformate + Shapes Formen + Share Teilen + Shift+Tab Shift+Tab + Show Einblenden + Show all formatting marks Alle Formatierungszeichen einblenden + Show Comments Kommentare einblenden + Show Markup Änderungen einblenden + Show Notes Notizen einblenden + Show only comments and formatting in balloons Nur Kommentare und Formatierung in Sprechblasen anzeigen + Show revisions in balloons Änderungen in Sprechblasen anzeigen + Show rulers Lineale einblenden + Show the community button in the title bar Die Community-Schaltfläche in der Titelleiste anzeigen + Show/Hide ¶ ¶ ein-/ausblenden + Show\nComments Kommentare\neinblenden + Shrink Font 1 Point Schrift um 1 Punkt verkleinern + Side to Side Seite an Seite + Side\nto Side Seite\nan Seite + Signature Line Signaturzeile + Single Einfach + Single Spacing Einfacher Zeilenabstand + Size Größe + Size: Größe: + Small Caps KAPITÄLCHEN + Small caps Kapitälchen + SmartArt SmartArt + Sort Sortieren + Source code: github.com/storytold/wordcraft Quellcode: github.com/storytold/wordcraft + Space Leerzeichen + Space before/after: Abstand vor/nach: + Spacing Abstand + Spacing (pt): Abstand (pt): + Speech Sprache + Spelling & Grammar Rechtschreibung und Grammatik + Spelling &\nGrammar Rechtschreibung und\nGrammatik + Spelling Suggestions Rechtschreibvorschläge + Split Teilen + Split Cells Zellen teilen + Split Table Tabelle teilen + Spreadsheet Table Tabellenkalkulations-Tabelle + Standard Colors Standardfarben + Star Stern + Start at: Beginnen bei: + Start Mail Merge Seriendruck starten + Start Mail\nMerge Seriendruck\nstarten + Statistics Statistik + Status Bar Statusleiste + Strikethrough Durchgestrichen + Studio cover Studio-Titelblatt + Studio handbook (sample) Studio-Handbuch (Beispiel) + Style Formatvorlage + Style based on: Basiert auf: + Style Set Formatvorlagensatz + Style: {style} Formatvorlage: {style} + Styles Formatvorlagen + Styles Pane Formatvorlagenbereich + Subscript Tiefgestellt + Superscript Hochgestellt + Switch Modes Modus wechseln + Switch\nModes Modus\nwechseln + Symbol Symbol + Symbols Symbole + Synonyms Synonyme + Tab Tab + Tab character Tabulatorzeichen + Table Tabelle + Table Column Width Tabellenspaltenbreite + Table Design Tabellenentwurf + Table Layout Tabellenlayout + Table of Authorities Rechtsgrundlagenverzeichnis + Table of Contents Inhaltsverzeichnis + Table of\nContents Inhalts-\nverzeichnis + Table Properties Tabelleneigenschaften + Table Row Height Tabellenzeilenhöhe + Table Style Options Tabellenformat-Optionen + Table Styles Tabellenformate + Tables Tabellen + Tabs Tabstopps + Tabular List Tabellarische Liste + Text Text + Text Box Textfeld + Text Direction Textrichtung + Text Effects Texteffekte + Text Effects and Typography Texteffekte und Typografie + Text from File Text aus Datei + Text Highlight Color Texthervorhebungsfarbe + Text indent at: Texteinzug bei: + Text to display: Anzuzeigender Text: + Text Wrapping Zeilenumbruch + Text: Text: + Text\nBox Text-\nfeld + Text\nDirection Text-\nrichtung + That format doesn't keep everything, so the document is still open. Save it as a Word document (.docx) to go on. Dieses Format bewahrt nicht alles, daher bleibt das Dokument geöffnet. Speichern Sie es als Word-Dokument (.docx), um fortzufahren. + The new style takes the formatting of the current paragraph. Die neue Formatvorlage übernimmt die Formatierung des aktuellen Absatzes. + Theme Colors Designfarben + Theme Fonts Designschriftarten + Theme List Designliste + Themes Designs + Thesaurus Thesaurus + Thick Stark + Three Drei + Tight Eng + Top Border Obere Rahmenlinie + Top Margin Oberer Rand + Top of Page Seitenanfang + Top: Oben: + Total Row Ergebniszeile + Track Changes Änderungsverfolgung + Track Changes Options Optionen für die Änderungsverfolgung + Track Changes: On Änderungsverfolgung: Ein + Track formatting Formatierung verfolgen + Tracking Änderungsverfolgung + Translate Übersetzen + Transparency Transparenz + Triangle Dreieck + Two Zwei + Type a command, e.g. "insert table" Befehl eingeben, z. B. „Tabelle einfügen“ + Type Text Text eingeben + Underline Unterstrichen + Underline Words Only Nur Wörter unterstreichen + Undo Rückgängig + Undo {action} {action} rückgängig + Ungroup Gruppierung aufheben + Up Nach oben + Update Field Feld aktualisieren + Update Index Index aktualisieren + Update Style to Match Selection Formatvorlage an Auswahl anpassen + Update Table Tabelle aktualisieren + Update Table of Figures Abbildungsverzeichnis aktualisieren + Update to Match An Vorlage anpassen + Update to Match Selection An Auswahl anpassen + UPPERCASE GROSSBUCHSTABEN + User Name Benutzername + User name: Benutzername: + Version History Versionsverlauf + Version {version} ({build}) Version {version} ({build}) + Vertical Vertikal + Vertical Alignment Vertikale Ausrichtung + View Ansicht + View Gridlines Rasterlinien anzeigen + View State Ansichtszustand + Views Ansichten + Voice Stimme + Watermark Wasserzeichen + Wave Welle + Web Layout Weblayout + Web page (*.html) Webseite (*.html) + Web\nLayout Web-\nlayout + Whole page Ganze Seite + Whole words Nur ganze Wörter + Wide Breit + Widow/Orphan Control Absatzkontrolle + Widow/Orphan control Absatzkontrolle + Window Fenster + Word Count Wörter zählen + Word document (*.docx) Word-Dokument (*.docx) + Word Left Wort nach links + Word Right Wort nach rechts + WordArt WordArt + WordCraft WordCraft + Words Wörter + Words only Nur Wörter + Wrap Text Text umbrechen + Wrap\nText Text\numbrechen + Write & Insert Fields Felder schreiben und einfügen + Write a reply Antwort schreiben + Zoom Zoom + Zoom In Vergrößern + Zoom Out Verkleinern + {group} settings {group}-Einstellungen + {key}+Click to follow link {key}+Klick, um dem Link zu folgen + Browse… Durchsuchen… + {words} words Wörter: {words} + {selected} of {words} words Wörter: {words}; ausgewählt: {selected} + Direction: Richtung: + Right-to-left Von rechts nach links + Left-to-right Von links nach rechts + Zotero Zotero + Citations Zitate + Document Dokument + Add/Edit\nCitation Zitat hinzufügen/\nbearbeiten + Add\nNote Notiz\nhinzufügen + Add/Edit\nBibliography Literaturverzeichnis hinzufügen/\nbearbeiten + Refresh Aktualisieren + Document\nPreferences Dokument-\neinstellungen + Unlink\nCitations Verknüpfung der\nZitate lösen + Waiting for Zotero… Warten auf Zotero… + Zotero: done Zotero: fertig + Zotero: cancelled Zotero: abgebrochen + Yes Ja + No Nein + Move Past Citation Hinter das Zitat gehen + Move Past\nCitation Hinter das\nZitat gehen + Next Sentence Nächster Satz + Previous Sentence Vorheriger Satz + Next sentence Nächster Satz + Previous sentence Vorheriger Satz + Play/Pause Wiedergabe/Pause + Read Aloud Status Vorlesestatus + Reading Speed Lesegeschwindigkeit + Skip Citations and Bibliography Zitate und Literaturverzeichnis überspringen + Skip citations & bibliography Zitate und Literaturverzeichnis überspringen + Stop Reading Vorlesen beenden + Don't read Zotero, Mendeley or EndNote citations and bibliographies Zitate und Literaturverzeichnisse aus Zotero, Mendeley oder EndNote nicht vorlesen + Play Wiedergabe + Pause Pause + Stop Stopp + Sentence Satz + Paused Pausiert + Finished Fertig + Stopped Beendet + Speed Geschwindigkeit + Normal speed Normale Geschwindigkeit + Your changes will be lost if you don't save them. Ihre Änderungen gehen verloren, wenn Sie sie nicht speichern. + Scroll ribbon left Menüband nach links scrollen + Scroll ribbon right Menüband nach rechts scrollen + Change Structure Struktur ändern + Change to Display In abgesetzte Formel umwandeln + Change to Inline In Inlineformel umwandeln + Conversions Umwandlungen + Delete Back in Equation In der Formel rückwärts löschen + Delete in Equation In der Formel löschen + Edit Equation Formel bearbeiten + Enter in Equation Eingabetaste in der Formel + Equation Gallery Formelkatalog + Equation Info Formelinfo + Equation Number Formelnummer + Input Format Eingabeformat + Insert Structure Struktur einfügen + Insert Symbol Symbol einfügen + Justification Ausrichtung + Leave Equation Formel verlassen + Move in Equation In der Formel bewegen + Normal Text Normaler Text + Set Equation Formel festlegen + Structure Actions Strukturaktionen + Structures Strukturen + Tools Tools + Type in Equation In der Formel eingeben + Inline Inline + Number Nummer + Close Equation Formel schließen + Insert New Equation Neue Formel einfügen + Built-In Integriert + Professional Professionell + Linear Linear + Fraction Bruch + Script Index + Radical Wurzel + Integral Integral + Large\nOperator Großer\nOperator + Bracket Klammer + Function Funktion + Accent Akzent + Limit and\nLog Grenzwert und\nLogarithmus + Operator Operator + Equation Options Formeloptionen + All symbol sets Alle Symbolmengen + Send the document straight to the system print dialog, or save a copy in another format below. Senden Sie das Dokument direkt an das Druckdialogfeld des Systems, oder speichern Sie unten eine Kopie in einem anderen Format. + Or save a copy in another format: Oder speichern Sie eine Kopie in einem anderen Format: + Opening print dialog… Druckdialogfeld wird geöffnet… + Print failed: {error} Drucken fehlgeschlagen: {error} + Couldn't open {path}: {error} {path} konnte nicht geöffnet werden: {error} + Column Selection Spaltenauswahl + Style Inspector Formatvorlagen-Inspektor + Inspect Styles at Selection Formatvorlagen an der Auswahl prüfen + Clear Formatting Level Formatierungsebene löschen + Characters Zeichen + Direct formatting Direkte Formatierung + Reset to Normal Auf Standard zurücksetzen + Clear Paragraph Formatting Absatzformatierung löschen + Clear Character Style Zeichenformatvorlage löschen + Clear Character Formatting Zeichenformatierung löschen + Paste Special Inhalte einfügen + Paste Special… Inhalte einfügen… + Paste as: Einfügen als: + The clipboard is empty. Die Zwischenablage ist leer. + Formatted Text Formatierter Text + Rich Text Format (RTF) Rich Text Format (RTF) + HTML Format HTML-Format + Unformatted Text Unformatierter Text + Result Ergebnis + Keeps the fonts and formatting of the copied text. Behält die Schriftarten und Formatierung des kopierten Textes bei. + Reads the clipboard's rich text and keeps its formatting. Liest den Rich Text der Zwischenablage und behält dessen Formatierung bei. + Reads the clipboard's web content and keeps its formatting. Liest den Webinhalt der Zwischenablage und behält dessen Formatierung bei. + Drops all formatting and pastes plain text. Verwirft jegliche Formatierung und fügt reinen Text ein. + Advanced Find Erweiterte Suche + Advanced Find… Erweiterte Suche… + Go To… Gehe zu… + Reading Highlight Lesehervorhebung + New Table Style Neues Tabellenformat + Modify Table Style Tabellenformat ändern + New Table Style… Neues Tabellenformat… + Modify Table Style… Tabellenformat ändern… + Apply formatting to: Formatierung anwenden auf: + Whole Table Ganze Tabelle + Fill color: Füllfarbe: + {count} items highlighted Hervorgehobene Elemente: {count} + {n} pt {n} pt + Not {name} Nicht {name} + Level {n} Ebene {n} + Body Text Textkörper + {n} lines Zeilen: {n} + At least {n} Mindestens {n} + Exactly {n} Genau {n} + Centered Zentriert + Character spacing Zeichenabstand + Don't add space between paragraphs of the same style Keinen Abstand zwischen Absätzen derselben Formatvorlage hinzufügen + Don't hyphenate Nicht trennen + Double strikethrough Doppelt durchgestrichen + Drop cap Initiale + First line Erste Zeile + Font color Schriftfarbe + Highlight Hervorhebung + Justified Blocksatz + Left indent Linker Einzug + Line spacing Zeilenabstand + Outline level Gliederungsebene + Raised/lowered Angehoben/abgesenkt + Right indent Rechter Einzug + Scale Skalierung + Space after Abstand nach + Space before Abstand vor + Suppress line numbers Zeilennummern unterdrücken + Underline color Unterstreichungsfarbe + Letters Buchstaben + E-mail Messages E-Mail-Nachrichten + Directory Verzeichnis + Normal Word Document Normales Word-Dokument + If…Then…Else… Wenn…Dann…Sonst… + If…Then…Else Wenn…Dann…Sonst + Merge Record # Seriendruck-Datensatz-Nr. + Skip Record If… Datensatz überspringen, wenn… + Skip Record If Datensatz überspringen, wenn + Equals: Ist gleich: + Then insert: Dann einfügen: + Otherwise insert: Andernfalls einfügen: + Courtesy Title Anrede + First Name Vorname + Last Name Nachname + Company Firma + Address Adresse + City Ort + State Bundesland + Postal Code Postleitzahl + Country Land + (not matched) (nicht zugeordnet) + Leave a recipient out of the merge when a field has this value. Lässt einen Empfänger aus dem Seriendruck aus, wenn ein Feld diesen Wert hat. + Insert one text or another, depending on a field's value. Fügt je nach Wert eines Felds den einen oder anderen Text ein. + Select recipients first to match their fields. Wählen Sie zuerst Empfänger aus, um deren Felder abzugleichen. + The recipient-list field that fills each part of an address block and greeting line. Das Empfängerlistenfeld, das jeden Teil eines Adressblocks und einer Anrede füllt. + No errors found: every merge field is in the recipient list. Keine Fehler gefunden: Jedes Seriendruckfeld ist in der Empfängerliste. + These merge fields aren't in the recipient list: Diese Seriendruckfelder fehlen in der Empfängerliste: + Add at least one field. Fügen Sie mindestens ein Feld hinzu. + Add Field Feld hinzufügen + Every field needs a name. Jedes Feld braucht einen Namen. + Field name Feldname + Field names must be different. Feldnamen müssen verschieden sein. + Fields: Felder: + Field: Feld: + Find: Suchen: + New Entry Neuer Eintrag + Recipient List Empfängerliste + Recipients: {count} Empfänger: {count} + Remove field Feld entfernen + Remove recipient Empfänger entfernen + Select recipients first to choose from their fields. Wählen Sie zuerst Empfänger aus, um aus deren Feldern zu wählen. + Type a New List… Neue Liste eingeben… + Type at least one recipient. Geben Sie mindestens einen Empfänger ein. + Type the recipients, or load them from a file. Geben Sie die Empfänger ein, oder laden Sie sie aus einer Datei. + Use an Existing List… Vorhandene Liste verwenden… + Interface theme: Oberflächendesign: + Interface\nTheme Oberflächen-\ndesign + Interface Theme Oberflächendesign + Light Hell + Dark Dunkel + Use system setting Systemeinstellung verwenden + A file dialog is already open. Ein Dateidialog ist bereits geöffnet. + Height: Höhe: + Width: Breite: + Row Zeile + Cell Zelle + Preferred width: Bevorzugte Breite: + Alignment: Ausrichtung: + Indent from left: Einzug von links: + Specify height: Höhe festlegen: + Exactly Genau + At least Mindestens + Allow row to break across pages Zeilenumbruch über Seiten zulassen + Repeat as header row on each page Auf jeder Seite als Kopfzeile wiederholen + Vertical alignment: Vertikale Ausrichtung: + Top Oben + Bottom Unten + Alt text Alternativtext + Alt: Alt: + W: B: + H: H: + Reset Zurücksetzen + Reset Crop Zuschnitt zurücksetzen + Insert a new equation (Alt+=) Eine neue Formel einfügen (Alt+=) + Leave the equation (Esc) Die Formel verlassen (Esc) + Preview Vorschau + getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft + Draw Ink Freihand zeichnen + Erase Ink Freihand radieren + Hide Ink Freihand ausblenden + Hide\nInk Freihand\nausblenden + Pens Stifte + Thickness Stärke + (All) (Alle) + (none) (keine) + (normal text) (normaler Text) + 1/4 Em Space 1/4-Geviert-Leerzeichen + A mail merge field Ein Seriendruckfeld + A numbered sequence (figures, tables…) Eine nummerierte Folge (Abbildungen, Tabellen…) + A table of contents Ein Inhaltsverzeichnis + Categories: Kategorien: + Character Zeichen + Character code: Zeichencode: + Col #: Spalte Nr.: + Copyright Copyright + Date and Time Datum und Uhrzeit + Date formats: Datumsformate: + Description: Beschreibung: + Document Information Dokumentinformationen + Double Closing Quote Doppeltes schließendes Anführungszeichen + Double Opening Quote Doppeltes öffnendes Anführungszeichen + Ellipsis Auslassungspunkte + Em Dash Geviertstrich + En Dash Halbgeviertstrich + Em Space Geviert-Leerzeichen + En Space Halbgeviert-Leerzeichen + Equal column width Gleiche Spaltenbreite + Field Codes Feldfunktionen + Field code: Feldfunktion: + Field codes: Feldfunktionen: + Field name: Feldname: + Field names: Feldnamen: + Field properties Feldeigenschaften + Field… Feld… + Format: Format: + Hide Codes Funktionen ausblenden + Index and Tables Index und Verzeichnisse + Line between Linie dazwischen + Links and References Hyperlinks und Verweise + Mail Merge Seriendruck + More Columns… Weitere Spalten… + More Symbols… Weitere Symbole… + No-Width Non Break Breitenloses geschütztes Leerzeichen + No-Width Optional Break Breitenloser optionaler Umbruch + Paragraph Mark Absatzmarke + Pick a field from the list Ein Feld aus der Liste wählen + Preserve formatting during updates Formatierung bei Aktualisierungen bewahren + Presets Voreinstellungen + Recently used symbols: Zuletzt verwendete Symbole: + Registered Eingetragene Marke + Section Mark Abschnittsmarke + Sequence name: Folgenname: + Shortcut key: Tastenkombination: + Single Closing Quote Einfaches schließendes Anführungszeichen + Single Opening Quote Einfaches öffnendes Anführungszeichen + Spacing: Abstand: + Special Characters Sonderzeichen + Subset: Teilmenge: + The time right now Die Uhrzeit im Moment + When this document was first created Wann dieses Dokument erstmals erstellt wurde + When this document was most recently printed Wann dieses Dokument zuletzt gedruckt wurde + When this document was most recently saved Wann dieses Dokument zuletzt gespeichert wurde + The document has no bookmarks yet. Das Dokument hat noch keine Textmarken. + Who the document properties name as author Wen die Dokumenteigenschaften als Autor nennen + Name of the file this document is saved as Name der Datei, unter der dieses Dokument gespeichert ist + Title from the document properties Titel aus den Dokumenteigenschaften + How many pages the whole document has Wie viele Seiten das gesamte Dokument hat + How many pages this section has Wie viele Seiten dieser Abschnitt hat + Number of the page the field sits on Nummer der Seite, auf der das Feld steht + Number of the section the field sits in Nummer des Abschnitts, in dem das Feld steht + How many words the document holds Wie viele Wörter das Dokument enthält + Page on which a bookmarked passage falls Seite, auf der eine mit Textmarke versehene Passage liegt + Copy of the text a bookmark marks Kopie des Textes, den eine Textmarke markiert + This field has no options. Dieses Feld hat keine Optionen. + This font has no characters to show. Diese Schriftart hat keine Zeichen zum Anzeigen. + This point forward Ab dieser Stelle + The date it is today Das heutige Datum + Trademark Marke + Type the field's name and its switches, for example PAGE \\* roman. Geben Sie den Namen des Felds und seine Schalter ein, zum Beispiel PAGE \\* roman. + Width and spacing Breite und Abstand + from Unicode (hex) von Unicode (hexadezimal) + Shape Effects Formeffekte + Glow Leuchten + Soft Edges Weiche Kanten + No Shadow Kein Schatten + No Glow Kein Leuchten + No Soft Edges Keine weichen Kanten + Glow Colors Leuchtfarben + Weight Linienstärke + Shadow, glow and soft edges Schatten, Leuchten und weiche Kanten + Offset: Bottom Right Versatz: Rechts unten + Offset: Bottom Versatz: Unten + Offset: Bottom Left Versatz: Links unten + Offset: Right Versatz: Rechts + Offset: Center Versatz: Mitte + Offset: Left Versatz: Links + Offset: Top Right Versatz: Rechts oben + Offset: Top Versatz: Oben + Offset: Top Left Versatz: Links oben + Manage Styles Formatvorlagen verwalten + Show or Hide Style Formatvorlage ein- oder ausblenden + Manage Styles… Formatvorlagen verwalten… + Sort order: Sortierreihenfolge: + As Recommended Wie empfohlen + Alphabetical Alphabetisch + Font: {font} Schriftart: {font} + {size} pt {size} pt + Color: {color} Farbe: {color} + Space before: {pt} pt Abstand vor: {pt} pt + Space after: {pt} pt Abstand nach: {pt} pt + Based on: {style} Basiert auf: {style} + Paragraph style Absatzformatvorlage + Character style Zeichenformatvorlage + Linked style (paragraph and character) Verknüpfte Formatvorlage (Absatz und Zeichen) + Table style Tabellenformatvorlage + Built-in style Integrierte Formatvorlage + Show in the Styles gallery Im Formatvorlagenkatalog anzeigen + Hide from style lists Aus Formatvorlagenlisten ausblenden + Built-in styles can't be deleted. Integrierte Formatvorlagen können nicht gelöscht werden. + Delete Table Style Tabellenformatvorlage löschen + Password Kennwort + Password: Kennwort: + Confirm password: Kennwort bestätigen: + Encrypt with Password… Mit Kennwort verschlüsseln… + Change Password… Kennwort ändern… + Remove Password Kennwort entfernen + A password is required to open this document. Zum Öffnen dieses Dokuments ist ein Kennwort erforderlich. + Anyone can open this document. Encrypt it with a password to keep it private. Jeder kann dieses Dokument öffnen. Verschlüsseln Sie es mit einem Kennwort, um es privat zu halten. + Anyone who opens the document will need this password. Jeder, der das Dokument öffnet, braucht dieses Kennwort. + {name} is protected with a password. {name} ist mit einem Kennwort geschützt. + That password isn't right. Check it (passwords are case-sensitive) and try again. Dieses Kennwort stimmt nicht. Prüfen Sie es (Kennwörter unterscheiden Groß- und Kleinschreibung) und versuchen Sie es erneut. + The document will be saved with a password. Save it to protect it. Das Dokument wird mit einem Kennwort gespeichert. Speichern Sie es, um es zu schützen. + The password is removed. Save the document to keep the change. Das Kennwort ist entfernt. Speichern Sie das Dokument, um die Änderung zu behalten. + The passwords don't match. Die Kennwörter stimmen nicht überein. + Keep the password somewhere safe: without it nobody, WordCraft included, can open the document. Leave both boxes empty to remove the password. Bewahren Sie das Kennwort an einem sicheren Ort auf: Ohne es kann niemand das Dokument öffnen, auch WordCraft nicht. Lassen Sie beide Felder leer, um das Kennwort zu entfernen. + Align Top Left Oben links ausrichten + Align Top Center Oben mittig ausrichten + Align Top Right Oben rechts ausrichten + Align Center Left Mittig links ausrichten + Align Center Zentriert ausrichten + Align Center Right Mittig rechts ausrichten + Align Bottom Left Unten links ausrichten + Align Bottom Center Unten mittig ausrichten + Align Bottom Right Unten rechts ausrichten + {contributors} contributors · {commits} commits {contributors} Mitwirkende · {commits} Commits + {cols}×{rows} Table {cols}×{rows}-Tabelle + AaBbYyZz AaBbYyZz + pt pt + Document1 Document1 + Username Benutzername + Display name Anzeigename + Real name Echter Name + Name Name + PRs PRs + Commits Commits + First Erster + Last Letzter + Model Modell + Version Version + % of all commits % aller Commits + Lines +/− Zeilen +/− + Craft Craft + Studio Studio + Atelier Atelier + Darkroom Darkroom + Gallery Gallery + Sketchbook Sketchbook + Change Picture… Bild ändern… + Font… Schriftart… + Paragraph… Absatz… + Link… Link… + Size and Crop… Größe und Zuschnitt… + Insert Columns to the Left Spalten links einfügen + Insert Columns to the Right Spalten rechts einfügen + Insert Rows Above Zeilen oberhalb einfügen + Insert Rows Below Zeilen unterhalb einfügen + Mon Mo + Tue Di + Wed Mi + Sum Summe + North Norden + South Süden + West Westen + Total Gesamt + Rotation Drehung + Rotation: Drehung: + Rotate Right 90° Um 90° nach rechts drehen + Rotate Left 90° Um 90° nach links drehen + Flip Vertical Vertikal spiegeln + Flip Horizontal Horizontal spiegeln + Formatted Formatiert + Interface size: Oberflächengröße: + Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Vergrößert oder verkleinert Menüband, Menüs und Dialoge, zusätzlich zur Anzeigeskalierung Ihres Systems. Die Dokumentansicht ist davon getrennt. + Base text: Basistext: + Distribute letters Buchstaben verteilen + Distribute spacing Abstände verteilen + Offset (pt): Versatz (pt): + Phonetic Guide Aussprachehilfe + Remove Entfernen + Right (vertical) Rechts (vertikal) + Ruby text: Ruby-Text: + Copyright Sign Copyrightzeichen + Insert Date Field Datumsfeld einfügen + Insert Page Field Seitenfeld einfügen + Insert Time Field Uhrzeitfeld einfügen + Registered Sign Zeichen für eingetragene Marke + Reset Character Formatting Zeichenformatierung zurücksetzen + Reset Paragraph Formatting Absatzformatierung zurücksetzen + Trademark Sign Markenzeichen + Size (pt): Größe (pt): + Draw\nTable Tabelle\nzeichnen + Move Paragraph Up Absatz nach oben verschieben + Move Paragraph Down Absatz nach unten verschieben + Promote Höher stufen + Demote Tiefer stufen + Go Back Zurückspringen + Toggle Unicode Character Unicode-Zeichen umschalten + Toggle Field Codes Feldfunktionen ein/aus + Edit Field Code Feldfunktion bearbeiten + Unlink Fields Feldverknüpfungen aufheben + Lock Fields Felder sperren + Unlock Fields Felder entsperren + Next Field Nächstes Feld + Previous Field Vorheriges Feld + Selected Fields Ausgewählte Felder + Edit Field… Feld bearbeiten… + In Line with Text Mit Text in Zeile + Square Quadrat + Through Durchgehend + Top and Bottom Oben und unten + Behind Text Hinter den Text + In Front of Text Vor den Text + Wrap Text Sides Umbruchseiten + Both Sides Beide Seiten + Left Only Nur links + Right Only Nur rechts + Largest Only Nur größte Seite + Customize Keyboard Tastatur anpassen + Choose a command Befehl auswählen + Choose a shortcut Tastenkombination auswählen + All Commands Alle Befehle + Commands: Befehle: + Keys for this command: Tasten für diesen Befehl: + New shortcut: Neue Tastenkombination: + Now used by {command} Derzeit belegt durch {command} + Not used by any command Keinem Befehl zugewiesen + Remove every custom shortcut and restore the built-in keys? Alle eigenen Tastenkombinationen entfernen und die Standardbelegung wiederherstellen? + Assign Zuweisen + Reset All… Alle zurücksetzen… + Press a key combination Tastenkombination drücken + Customize Anpassen + Keyboard shortcuts: Tastenkombinationen: + Customize… Anpassen… + Customize Ribbon Menüband anpassen + Add to Quick Access Toolbar Zur Symbolleiste für den Schnellzugriff hinzufügen + Remove from Quick Access Toolbar Aus der Symbolleiste für den Schnellzugriff entfernen + Customize the Ribbon… Menüband anpassen… + Customize Quick Access Toolbar… Symbolleiste für den Schnellzugriff anpassen… + More Commands… Weitere Befehle… + Ribbon Menüband + (Custom) (Benutzerdefiniert) + Remove every custom tab and group and show all tabs again? Alle eigenen Registerkarten und Gruppen entfernen und wieder alle Registerkarten anzeigen? + Put the default commands back on the Quick Access Toolbar? Die Standardbefehle wieder in die Symbolleiste für den Schnellzugriff übernehmen? + Reset… Zurücksetzen… + Add >> Hinzufügen >> + << Remove << Entfernen + Pick a custom group (New Group makes one) Eigene Gruppe auswählen (mit „Neue Gruppe“ anlegen) + Ribbon tabs Registerkarten des Menübands + New Tab Neue Registerkarte + New Group Neue Gruppe + Rename… Umbenennen… + Ribbon and Quick Access Toolbar: Menüband und Symbolleiste für den Schnellzugriff: + +# Interface strings added on main since this catalog was first written. + As you type Während der Eingabe + Asian text font: Schriftart für asiatischen Text: + AutoSave writes your changes to the file a moment after you stop typing. AutoSpeichern schreibt Ihre Änderungen kurz nach dem Ende der Eingabe in die Datei. + Centimeters Zentimeter + Check spelling as you type Rechtschreibung während der Eingabe prüfen + Click to switch back to inserting Klicken, um wieder zum Einfügen zu wechseln + Custom dictionary Benutzerwörterbuch + Formatting marks Formatierungszeichen + Hidden text Ausgeblendeter Text + Ignore web and file addresses Web- und Dateiadressen ignorieren + Ignore words in capitals Wörter in Großbuchstaben ignorieren + Ignore words with numbers Wörter mit Zahlen ignorieren + Inches Zoll + Formatting exceptions Formatierungsausnahmen + Initials: Initialen: + Interface Oberfläche + Interface language Sprache der Oberfläche + Inside: Innen: + Latin text font: Schriftart für lateinischen Text: + Mark grammar errors as you type Grammatikfehler während der Eingabe markieren + Measurement units: Maßeinheiten: + Millimeters Millimeter + Mirror indents Gespiegelte Einzüge + New comments and tracked changes are recorded under this name and these initials. Neue Kommentare und nachverfolgte Änderungen werden unter diesem Namen und diesen Initialen erfasst. + New word Neues Wort + No words yet. Add to Dictionary on a misspelled word puts it here. Noch keine Wörter. „Zum Wörterbuch hinzufügen“ bei einem falsch geschriebenen Wort legt es hier ab. + Overtype Überschreiben + Overtype (typing replaces the characters after the caret) Überschreiben (die Eingabe ersetzt die Zeichen nach der Einfügemarke) + Page display Seitenanzeige + Paragraph marks Absatzmarken + Personalize Personalisieren + Picas Pica + Points Punkt + Print the page color Seitenfarbe drucken + Printing Drucken + Proofing language Korrektursprache + Recent documents to show: Anzahl angezeigter zuletzt verwendeter Dokumente: + Outside: Außen: + Save documents Dokumente speichern + Show the mini toolbar when text is selected Minisymbolleiste bei Textauswahl anzeigen + Spaces Leerzeichen + Spelling Rechtschreibung + Spelling and grammar are checked in English (United States). Rechtschreibung und Grammatik werden auf Englisch (USA) geprüft. + Tab characters Tabstoppzeichen + These marks show even when Show/Hide ¶ is off. Diese Zeichen werden auch angezeigt, wenn ¶ ein-/ausblenden deaktiviert ist. + Typing replaces selected text Eingabe ersetzt markierten Text + Update fields before printing Felder vor dem Drucken aktualisieren + Use the Insert key to switch overtype on and off Überschreibmodus mit der Einfg-Taste ein- und ausschalten + Extend Selection by Units Auswahl einheitenweise erweitern + Animation Animation + Animate the caret (it glides and fades) Einfügemarke animieren (sie gleitet und blendet aus) + On: the caret glides to its new place when you type or use the arrow keys, and its blink fades. Off: it jumps and blinks on and off. Ein: Die Einfügemarke gleitet beim Tippen oder mit den Pfeiltasten an ihre neue Stelle, und ihr Blinken blendet weich. Aus: Sie springt und blinkt hart an und aus. + Word XML Document (*.xml) Word-XML-Dokument (*.xml) + Choose the sheet that holds the recipients. Wählen Sie das Tabellenblatt mit den Empfängern. + First row of data contains column headers Erste Datenzeile enthält Spaltenüberschriften + Deleted Gelöscht + Resize Table by Dragging Tabellengröße durch Ziehen ändern + Print Markup Markups drucken + Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. Kommentare neben jeder Seite und nachverfolgte Änderungen in PDFs, Ausdrucken und Bildern; eine Kommentarliste am Ende von Webseiten, Markdown und Text. + 1. Formatting restrictions 1. Formatierungseinschränkungen + 2. Editing restrictions 2. Bearbeitungseinschränkungen + 3. Start enforcement 3. Schutz anwenden + << Less << Weniger + Another file Andere Datei + Apply as you type Während der Eingabe anwenden + AutoCorrect AutoKorrektur + AutoFormat As You Type AutoFormat während der Eingabe + Automatic bulleted lists Automatische Aufzählungen + Automatic numbered lists Automatische Nummerierungen + Border lines Rahmenlinien + Capitalize first letter of sentences Jeden Satz mit einem Großbuchstaben beginnen + Capitalize first letter of table cells Jede Tabellenzelle mit einem Großbuchstaben beginnen + Capitalize names of days Wochentage großschreiben + Case changes Groß-/Kleinschreibung + Character level Zeichenebene + Checked styles are allowed: Markierte Formatvorlagen sind zulässig: + Combine Documents Dokumente kombinieren + Compare Documents Dokumente vergleichen + Compared: {changes} changes Verglichen: {changes} Änderungen + Comparing with another file needs the desktop app. Der Vergleich mit einer anderen Datei erfordert die Desktop-App. + Comparison settings Vergleichseinstellungen + Don't capitalize after: Kein Großbuchstabe nach: + Editing in this document is restricted. Die Bearbeitung dieses Dokuments ist eingeschränkt. + Every change is recorded as a tracked change. Jede Änderung wird als nachverfolgte Änderung erfasst. + Everyone Jeder + Exceptions (optional) Ausnahmen (optional) + Exceptions… Ausnahmen… + Fields Felder + Filling in forms Ausfüllen von Formularen + Find Next Editable Region Nächsten bearbeitbaren Bereich suchen + Footnotes and endnotes Fuß- und Endnoten + Formatting, case changes, white space and tables are compared; the other settings are kept for later. Formatierung, Groß-/Kleinschreibung, Leerraum und Tabellen werden verglichen; die übrigen Einstellungen bleiben für später erhalten. + Fractions (1/2) as fraction characters (½) Brüche (1/2) als Bruchzeichen (½) + Headers and footers Kopf- und Fußzeilen + Hyphens (--) as dashes (—) Bindestriche (--) als Gedankenstriche (—) + In equations, typing a name after a backslash replaces it with its symbol. In Formeln wird ein Name nach einem umgekehrten Schrägstrich durch sein Symbol ersetzt. + Internet addresses as links Internetadressen als Links + Label changes with: Änderungen kennzeichnen mit: + Limit formatting to a selection of styles Formatierung auf eine Auswahl von Formatvorlagen beschränken + Math AutoCorrect Math. AutoKorrektur + More >> Mehr >> + Moves Verschiebungen + New document Neues Dokument + No changes (read only) Keine Änderungen (schreibgeschützt) + Only allow this kind of editing: Nur diese Art der Bearbeitung zulassen: + Only comments can be added, except in regions marked editable. Nur Kommentare können hinzugefügt werden, außer in als bearbeitbar markierten Bereichen. + Only form fields can be filled in. Nur Formularfelder können ausgefüllt werden. + Only the allowed styles can be applied. Nur die zulässigen Formatvorlagen können angewendet werden. + Ordinals (1st) as superscript Ordnungszahlen (1st) hochgestellt + Original document Originaldokument + This password type isn't supported. Dieser Kennworttyp wird nicht unterstützt. + Replace as you type Während der Eingabe ersetzen + Replace text as you type Text während der Eingabe ersetzen + Replace: Ersetzen: + Revised document Überarbeitetes Dokument + Select text, then tick who can still edit it. Markieren Sie Text und haken Sie dann an, wer ihn weiterhin bearbeiten darf. + Settings… Einstellungen… + Show changes at: Änderungen anzeigen auf: + Show changes in: Änderungen anzeigen in: + Smart quotes instead of straight quotes Typografische statt gerader Anführungszeichen + Start Enforcing Protection Schutz jetzt anwenden + Stop Protection Schutz aufheben + Text boxes Textfelder + The document is read only, except for regions marked editable. Das Dokument ist schreibgeschützt, außer in als bearbeitbar markierten Bereichen. + The password is optional; without it anyone can stop the protection. Das Kennwort ist optional; ohne Kennwort kann jeder den Schutz aufheben. + This document Dieses Dokument + Tracked changes Nachverfolgte Änderungen + Type the password to stop the protection. Geben Sie das Kennwort ein, um den Schutz aufzuheben. + White space Leerraum + With: Durch: + Word level Wortebene + Add Font… Schriftart hinzufügen… + Added Fonts Hinzugefügte Schriftarten + Remove Added Font Hinzugefügte Schriftart entfernen + Recent Colors Zuletzt verwendete Farben + More Colors… Weitere Farben… + Standard Standard + Current Aktuell + Colors: Farben: + Color model: Farbmodell: + Red: Rot: + Green: Grün: + Blue: Blau: + Hue: Farbton: + Sat: Sätt.: + Lum: Hell.: + Hex: Hex: + Accessibility Pane Bereich „Barrierefreiheit“ + Alt Text Pane Bereich „Alternativtext“ + Alt\nText Alternativ-\ntext + View Alt Text… Alternativtext anzeigen… + Mark as decorative Als dekorativ markieren + Title: Titel: + Errors ({n}) Fehler ({n}) + Warnings ({n}) Warnungen ({n}) + Tips ({n}) Tipps ({n}) + No accessibility issues found. Keine Probleme mit der Barrierefreiheit gefunden. + Describe what this object shows, for people who can't see it. Beschreiben Sie, was dieses Objekt zeigt, für Menschen, die es nicht sehen können. + Decorative objects only add visual interest: screen readers skip them. Dekorative Objekte dienen nur der Optik: Bildschirmleser überspringen sie. + Describe what this table shows, for people who can't see it. Beschreiben Sie, was diese Tabelle zeigt, für Menschen, die sie nicht sehen können. + Select a picture, shape, chart or table to describe it. Wählen Sie ein Bild, eine Form, ein Diagramm oder eine Tabelle aus, um es zu beschreiben. + Document Grid Dokumentraster + No grid Kein Raster + Lines only Nur Zeilen + Lines and characters Zeilen und Zeichen + Text snaps to characters Text am Zeichenraster ausrichten + Characters per line: Zeichen pro Zeile: + Lines per page: Zeilen pro Seite: + Moved from Verschoben von + Moved to Verschoben nach + Developer Entwicklertools + Controls Steuerelemente + Rich Text Content Control Rich-Text-Inhaltssteuerelement + Plain Text Content Control Nur-Text-Inhaltssteuerelement + Picture Content Control Bild-Inhaltssteuerelement + Building Block Gallery Content Control Bausteinkatalog-Inhaltssteuerelement + Check Box Content Control Kontrollkästchen-Inhaltssteuerelement + Combo Box Content Control Kombinationsfeld-Inhaltssteuerelement + Drop-Down List Content Control Dropdownlisten-Inhaltssteuerelement + Date Picker Content Control Datumsauswahl-Inhaltssteuerelement + Repeating Section Content Control Inhaltssteuerelement für wiederholten Abschnitt + Design Mode Entwurfsmodus + Content Control at Selection Inhaltssteuerelement an der Auswahl + Toggle Check Box Kontrollkästchen umschalten + Choose List Item Listeneintrag auswählen + Set Date Datum festlegen + Remove Content Control Inhaltssteuerelement entfernen + Content Control Properties Eigenschaften des Inhaltssteuerelements + Title Titel + Tag Tag + Placeholder text Platzhaltertext + Can't be deleted Kann nicht gelöscht werden + Contents can't be edited Inhalt kann nicht bearbeitet werden + Remove the control once edited Steuerelement nach der Bearbeitung entfernen + Allow line breaks Zeilenumbrüche zulassen + Date format Datumsformat + Checked symbol Symbol für aktiviert + Unchecked symbol Symbol für deaktiviert + List entries Listeneinträge + Display text Anzeigetext + Value Wert + Today Heute + Put the caret in a content control first. Setzen Sie die Einfügemarke zuerst in ein Inhaltssteuerelement. + Right-to-Left Table Tabelle von rechts nach links + Right-to-Left Section Abschnitt von rechts nach links + Table direction: Tabellenrichtung: + Section direction: Abschnittsrichtung: + Drawing Canvas Zeichenfläche + New Drawing Canvas Neue Zeichenfläche + Fit Anpassen + Expand Erweitern + Scale Drawing Zeichnung skalieren + Canvas Member Element der Zeichenfläche + Arrows Pfeile + Communication Kommunikation + Office Büro + People Personen + Nature Natur + Weather Wetter + Technology Technik + Transport Verkehr + Icon Color Symbolfarbe + Search icons Symbole suchen + No icons match your search. Keine Symbole entsprechen Ihrer Suche. + Recolor a one-color picture, such as an icon Ein einfarbiges Bild, z. B. ein Symbol, umfärben + Enclose Characters Zeichen umschließen + Shrink text Text verkleinern + Enlarge symbol Symbol vergrößern + Enclosure Umschließung + Circle Kreis + Templates Vorlagen + Templates and Add-ins Vorlagen und Add-Ins + Attach Template Vorlage anfügen + Detach Template Vorlage trennen + Automatically Update Document Styles Formatvorlagen des Dokuments automatisch aktualisieren + Update Styles from Template Formatvorlagen aus Vorlage aktualisieren + Organizer Organisieren + Copy Styles Formatvorlagen kopieren + Rename Style Formatvorlage umbenennen + Document template: Dokumentvorlage: + Attach… Anfügen… + Automatically update document styles Formatvorlagen des Dokuments automatisch aktualisieren + Organizer… Organisieren… + Change Chart Type Diagrammtyp ändern + Chart Design Diagrammentwurf + Type Typ + Edit Data Daten bearbeiten + Chart Title Diagrammtitel + Chart Layouts Diagrammlayouts + Add Chart Element Diagrammelement hinzufügen + Legend Legende + Data Labels Datenbeschriftungen + Above Chart Über dem Diagramm + Insert Chart Diagramm einfügen + Clustered Column Gruppierte Säulen + Stacked Column Gestapelte Säulen + Clustered Bar Gruppierte Balken + Stacked Bar Gestapelte Balken + Area Fläche + Pie Kreis + Doughnut Ring + Scatter Punkt (XY) + “{text}” ({row}, {series}) isn't a number. „{text}“ ({row}, {series}) ist keine Zahl. + Category {n} Kategorie {n} + Series {n} Datenreihe {n} + Type the categories down the side and a column of values per series. Geben Sie die Kategorien untereinander ein und je Datenreihe eine Spalte mit Werten. + Categories Kategorien + Series name Name der Datenreihe + Remove series Datenreihe entfernen + Remove row Zeile entfernen + Add Row Zeile hinzufügen + Add Series Datenreihe hinzufügen + Edit\nData Daten\nbearbeiten + Change\nChart Type Diagrammtyp\nändern + Align Text Text ausrichten + Create Link Verknüpfung erstellen + Break Link Verknüpfung aufheben + Rotate all text 90° Gesamten Text um 90° drehen + Rotate all text 270° Gesamten Text um 270° drehen + Stacked Gestapelt + Middle Mitte + Click the empty text box the text should continue in (Esc cancels) Klicken Sie auf das leere Textfeld, in dem der Text fortgesetzt werden soll (Esc bricht ab) + Not linked: click a text box Nicht verknüpft: Klicken Sie auf ein Textfeld + Location Position + Endnotes Endnoten + Below text Unterhalb des Textes + End of section Ende des Abschnitts + End of document Ende des Dokuments + Footnotes to endnotes Fußnoten in Endnoten + Endnotes to footnotes Endnoten in Fußnoten + Swap footnotes and endnotes Fuß- und Endnoten vertauschen + Custom mark: Benutzerdefiniertes Zeichen: + Continuous numbering Fortlaufende Nummerierung + Apply Übernehmen + Convert Notes Notizen umwandeln + Documents Dokumente + Word document Word-Dokument + All files Alle Dateien + Recipient lists Empfängerlisten + The system file dialog isn't available, so WordCraft shows its own. To get the system dialog, install a file chooser portal (such as xdg-desktop-portal-gtk) or zenity. Das Dateidialogfeld des Systems ist nicht verfügbar, daher zeigt WordCraft ein eigenes an. Für das Systemdialogfeld installieren Sie ein Dateiauswahl-Portal (etwa xdg-desktop-portal-gtk) oder zenity. + Up one level Eine Ebene höher + Home folder Persönlicher Ordner + {name} wasn't found. {name} wurde nicht gefunden. + {name} already exists. Choose Save again to replace it. {name} ist bereits vorhanden. Wählen Sie erneut „Speichern“, um die Datei zu ersetzen. + This folder can't be read: {error} Dieser Ordner kann nicht gelesen werden: {error} + Showing the first {count} items. Die ersten {count} Elemente werden angezeigt. + File name: Dateiname: + Browse in WordCraft… In WordCraft durchsuchen… + Open a file with WordCraft's own browser when the system file dialog doesn't appear. Öffnet eine Datei mit dem eigenen Browser von WordCraft, wenn das Dateidialogfeld des Systems nicht erscheint. + Hide White Space Leerraum ausblenden + All pages Alle Seiten + Current page Aktuelle Seite + Selected pages Ausgewählte Seiten + Custom range Benutzerdefinierter Bereich + All pages in the range Alle Seiten im Bereich + Odd pages only Nur ungerade Seiten + Even pages only Nur gerade Seiten + Copies Exemplare + Collate copies Exemplare sortieren + Pages like 1-3, 5 Seiten wie 1-3, 5 + {count} of {total} pages will print {count} von {total} Seiten werden gedruckt + Save these pages as PDF… Diese Seiten als PDF speichern… + The printer, two-sided printing and pages per sheet are chosen in that dialog. Drucker, beidseitiger Druck und Seiten pro Blatt werden in diesem Dialogfeld gewählt. + Previous Page Vorherige Seite + Print to PDF Als PDF drucken + Add Shape Form hinzufügen + Change Colors Farben ändern + Change Layout Layout ändern + Create Graphic Grafik erstellen + Edit Items Elemente bearbeiten + Layouts Layouts + Reset Graphic Grafik zurücksetzen + SmartArt Design SmartArt-Entwurf + SmartArt Styles SmartArt-Formatvorlagen + Text Pane Textbereich + List Liste + Process Prozess + Cycle Zyklus + Hierarchy Hierarchie + Relationship Beziehung + Pyramid Pyramide + Basic List Einfache Liste + Basic Process Einfacher Prozess + Basic Cycle Einfacher Zyklus + Basic Pyramid Einfache Pyramide + Basic Radial Einfaches Radial + Basic Matrix Einfache Matrix + Basic Venn Einfaches Venn + Shows items of equal weight as blocks in rows. Zeigt gleichgewichtige Elemente als Blöcke in Reihen. + Shows steps in order, left to right, with arrows between them. Zeigt Schritte der Reihe nach von links nach rechts, mit Pfeilen dazwischen. + Shows steps that repeat, around a circle. Zeigt sich wiederholende Schritte im Kreis. + Shows a tree: each item below the item it belongs to. Zeigt einen Baum: jedes Element unter dem Element, zu dem es gehört. + Shows parts that build on each other, largest at the bottom. Zeigt aufeinander aufbauende Teile, das größte unten. + Shows items around a central idea: the first item is the centre. Zeigt Elemente um eine zentrale Idee: Das erste Element ist die Mitte. + Shows parts of a whole as quadrants. Zeigt Teile eines Ganzen als Quadranten. + Shows overlapping ideas as overlapping circles. Zeigt sich überschneidende Ideen als sich überschneidende Kreise. + Accent {n} Akzent {n} + Colorful Farbig + Insert SmartArt SmartArt einfügen + One line per item. Enter adds an item. Ein Element pro Zeile. Die Eingabetaste fügt ein Element hinzu. + Add\nShape Form\nhinzufügen + Change\nColors Farben\nändern + Reset\nGraphic Grafik\nzurücksetzen + Promote to Heading 1 Zu Überschrift 1 höher stufen + Demote to Body Text Zu Textkörper tiefer stufen + Move Up Nach oben + Move Down Nach unten + Collapse Reduzieren + Expand or Collapse Heading Überschrift erweitern oder reduzieren + Select Heading and Content Überschrift und Inhalt auswählen + Show Level Ebene anzeigen + Show Level 1 Ebene 1 anzeigen + Show Level 2 Ebene 2 anzeigen + Show Level 3 Ebene 3 anzeigen + Show Level 4 Ebene 4 anzeigen + Show Level 5 Ebene 5 anzeigen + Show Level 6 Ebene 6 anzeigen + Show Level 7 Ebene 7 anzeigen + Show Level 8 Ebene 8 anzeigen + Show Level 9 Ebene 9 anzeigen + Show All Levels Alle Ebenen anzeigen + All Levels Alle Ebenen + Show First Line Only Nur erste Zeile anzeigen + Show Text Formatting Textformatierung anzeigen + Close Outline View Gliederungsansicht schließen + Outline State Gliederungsstatus + Outlining Gliederung + Outline Tools Gliederungstools + Close Outline\nView Gliederungsansicht\nschließen + A, B, C, … A, B, C, … + Above the item Über dem Element + Accessed on Abgerufen am + Add New Source… Neue Quelle hinzufügen… + Add the chapter number Kapitelnummer einbeziehen + Atmosphere Atmosphäre + Below the item Unter dem Element + Book Buch + Book title Buchtitel + Caption Beschriftung + Caption Numbering Beschriftungsnummerierung + Caption text Beschriftungstext + Caption: Beschriftung: + Chapter in a book Kapitel in einem Buch + Chapters begin at: Kapitel beginnen mit: + Collect entries from: Einträge sammeln aus: + Columns: Spalten: + Conference Konferenz + Conference paper Konferenzbeitrag + Container Title Titel des übergeordneten Werks + Create Source Quelle erstellen + Cross-reference: Querverweis: + Custom Table of Contents… Benutzerdefiniertes Inhaltsverzeichnis… + DOI DOI + Day Tag + Delete Label Bezeichnung löschen + Edit Source Quelle bearbeiten + Edition Auflage + Edit… Bearbeiten… + Entry marked. Eintrag markiert. + Example: {sample} Beispiel: {sample} + Figure Abbildung + Film Film + Heading styles (levels 1–{n}) Überschrift-Formatvorlagen (Ebenen 1–{n}) + Heading {n} Überschrift {n} + I, II, III, … I, II, III, … + Include page numbers Seitenzahlen einbeziehen + Indented Eingezogen + Institution Institution + Ionosphere Ionosphäre + Issue Ausgabe + Journal Fachzeitschrift + Label: Bezeichnung: + Last, First; Last, First Nachname, Vorname; Nachname, Vorname + Leave the label out of the caption Bezeichnung nicht in die Beschriftung aufnehmen + Letter headings Buchstabenüberschriften + Levels: Ebenen: + Link entries to their headings Einträge mit ihren Überschriften verknüpfen + Made from the author and year when empty Wird bei leerem Feld aus Autor und Jahr gebildet + Magazine or newspaper article Zeitschriften- oder Zeitungsartikel + Main entry: Haupteintrag: + Mark Markieren + Mark All Alle markieren + Mark Entry… Eintrag markieren… + Mark Index Entry Indexeintrag markieren + Mesosphere Mesosphäre + Month Monat + New Label Neue Bezeichnung + New Label… Neue Bezeichnung… + New… Neu… + No sources yet. Noch keine Quellen. + Numbering… Nummerierung… + Options… Optionen… + Other Sonstige + Other styles and their TOC level: Andere Formatvorlagen und ihre Verzeichnisebene: + Outline levels Gliederungsebenen + Page number style Seitenzahlformat + Page numbers at the right margin Seitenzahlen am rechten Rand + Page title Seitentitel + Pages of bookmark: Seiten der Textmarke: + Periodical Periodikum + Position: Position: + Publisher Verlag + Run-in Fortlaufend + See Siehe + Separator: Trennzeichen: + Show every bibliography field Alle Felder des Literaturverzeichnisses anzeigen + Site name Name der Website + Source Manager Quellen-Manager + Source type: Quellentyp: + Sources in this document: Quellen in diesem Dokument: + Styles: Formatvorlagen: + Subentry: Untereintrag: + TC entry fields TC-Eintragsfelder + Tab leader: Füllzeichen: + Table of Contents Options Optionen für das Inhaltsverzeichnis + Table of Contents Styles Formatvorlagen des Inhaltsverzeichnisses + This page Diese Seite + Type: Typ: + URL URL + Volume Band + Website Website + Year Jahr + a, b, c, … a, b, c, … + i, ii, iii, … i, ii, iii, … + {n} entries marked. {n} Einträge markiert. + Lasso Select Lasso-Auswahl + Move Selected Ink Ausgewählte Freihandeingabe verschieben + Delete Selected Ink Ausgewählte Freihandeingabe löschen + Recolor Selected Ink Ausgewählte Freihandeingabe umfärben + Add Pen Stift hinzufügen + Remove Pen Stift entfernen + Ink to Shape Freihand zu Form + Convert to Shapes In Formen umwandeln + Ink Replay Freihand-Wiedergabe + Add\nPen Stift\nhinzufügen + Rewind Zurückspulen + Forward Vorspulen + Stroke {n} of {total} Strich {n} von {total} + Use wildcards Platzhalter verwenden + Special Sonderzeichen + Match prefix Präfix beachten + Match suffix Suffix beachten + Ignore punctuation characters Satzzeichen ignorieren + Ignore white-space characters Leerraumzeichen ignorieren + Regular expressions Reguläre Ausdrücke + Search Options Suchoptionen + Tab Character Tabstoppzeichen + Any Character Beliebiges Zeichen + Any Digit Beliebige Ziffer + Any Letter Beliebiger Buchstabe + Caret Character Zirkumflex-Zeichen + Section Character Paragrafzeichen + Paragraph Character Absatzmarke + Graphic Grafik + Manual Line Break Manueller Zeilenumbruch + Manual Page Break Manueller Seitenumbruch + Section Break Abschnittsumbruch + White Space Leerraum + Clipboard Contents Inhalt der Zwischenablage + Find What Text Suchtext + Find What Expression Suchausdruck + Character in Range Zeichen im Bereich + Beginning of Word Wortanfang + End of Word Wortende + Expression Ausdruck + Not Nicht + Num Occurrences Anzahl Vorkommen + Previous 1 or More Vorheriges 1-mal oder öfter + 0 or More Characters 0 oder mehr Zeichen + Move with Text Mit Text verschieben + Fix Position on Page Position auf der Seite fixieren + WordArt Styles WordArt-Formatvorlagen + Text Fill Textfüllung + Text Outline Textkontur + Transform Transformieren + No Fill Keine Füllung + No Outline Keine Kontur + Reflection Spiegelung + No Reflection Keine Spiegelung + No Transform Keine Transformation + Connect Shapes Formen verbinden + Straight Connector Gerade Verbindung + Straight Arrow Connector Gerade Verbindung mit Pfeil + Elbow Connector Gewinkelte Verbindung + Elbow Arrow Connector Gewinkelte Verbindung mit Pfeil + Curved Connector Gekrümmte Verbindung + Curved Arrow Connector Gekrümmte Verbindung mit Pfeil + Arch Up Bogen nach oben + Arch Down Bogen nach unten + Slant Up Schräg nach oben + Slant Down Schräg nach unten + Inflate Aufblähen + Deflate Zusammenziehen + Chevron Up Winkel nach oben + Chevron Down Winkel nach unten + Triangle Up Dreieck nach oben + Triangle Down Dreieck nach unten + Solid Accent Akzentfarbe, gefüllt + Light with Outline Hell mit Kontur + Hollow Hohl + Accent Gradient Akzentverlauf + Dark with Shadow Dunkel mit Schatten + Soft Glow Weiches Leuchten + Accent with Reflection Akzent mit Spiegelung + Warm Gradient with Outline Warmer Verlauf mit Kontur + Add line numbering Zeilennummerierung hinzufügen + Add to Document Zum Dokument hinzufügen + Address labels Adressetiketten + Arabic Arabisch + Auto Automatisch + Automatically hyphenate document Silbentrennung im Dokument automatisch durchführen + Chinese (Simplified) Chinesisch (vereinfacht) + Chinese (Traditional) Chinesisch (traditionell) + Column: Spalte: + Count by: Zählintervall: + Czech Tschechisch + Danish Dänisch + Delivery address: Empfängeradresse: + Detect language automatically Sprache automatisch erkennen + Do not check spelling or grammar Rechtschreibung und Grammatik nicht prüfen + Dutch Niederländisch + English (United Kingdom) Englisch (Vereinigtes Königreich) + Envelope size: Umschlaggröße: + Estonian Estnisch + Finnish Finnisch + French (France) Französisch (Frankreich) + From text: Abstand zum Text: + Full page of the same label Ganze Seite mit demselben Etikett + German (Germany) Deutsch (Deutschland) + Greek Griechisch + Hebrew Hebräisch + Hyphenate at: Trennen bei: + Hyphenate words in CAPS Wörter in GROSSBUCHSTABEN trennen + Hyphenation Options… Silbentrennungsoptionen… + Hyphenation zone: Silbentrennzone: + Italian Italienisch + Japanese Japanisch + Korean Koreanisch + Limit consecutive hyphens to: Aufeinanderfolgende Trennstriche begrenzen auf: + Line Numbering Options… Zeilennummerierungsoptionen… + Manual Hyphenation Manuelle Silbentrennung + Manual hyphenation is complete. Die manuelle Silbentrennung ist abgeschlossen. + Manual… Manuell… + Mark selected text as: Markierten Text kennzeichnen als: + New Document Neues Dokument + No limit Keine Begrenzung + Norwegian (Bokmål) Norwegisch (Bokmål) + Numbering: Nummerierung: + Omit Weglassen + One label per recipient Ein Etikett pro Empfänger + Polish Polnisch + Portuguese (Brazil) Portugiesisch (Brasilien) + Portuguese (Portugal) Portugiesisch (Portugal) + Return address labels Absenderetiketten + Return address: Absenderadresse: + Row: Zeile: + Russian Russisch + Serbian (Cyrillic) Serbisch (Kyrillisch) + Serbian (Latin) Serbisch (Lateinisch) + Set As Default Als Standard festlegen + Shipping labels Versandetiketten + Single label Einzelnes Etikett + Spanish (Mexico) Spanisch (Mexiko) + Spanish (Spain) Spanisch (Spanien) + Swedish Schwedisch + Turkish Türkisch + Ukrainian Ukrainisch + {language} is now the document's default language. {language} ist jetzt die Standardsprache des Dokuments. + Visual effects Visuelle Effekte + Animation effects: Animationseffekte: + On Ein + Off Aus + Smooth scrolling and zooming, and menus and buttons that fade in. Off shows every change at once. Flüssiges Scrollen und Zoomen sowie Menüs und Schaltflächen, die weich eingeblendet werden. „Aus“ zeigt jede Änderung sofort. + Your system has animation effects turned on. Auf Ihrem System sind Animationseffekte aktiviert. + Your system has animation effects turned off. Auf Ihrem System sind Animationseffekte deaktiviert. + Your system doesn't report an animation setting, so animation effects stay on. Ihr System meldet keine Animationseinstellung, daher bleiben Animationseffekte aktiviert. diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index def8d7cfe..8ff415c5b 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -48,7 +48,7 @@ pub struct LangInfo { } /// The registry. English first: it is the fallback and the source language. -pub static LANGUAGES: [LangInfo; 13] = [ +pub static LANGUAGES: [LangInfo; 14] = [ LangInfo { code: "en", name: "English", source: "", prefer_hans: false, catalog: OnceLock::new() }, // Simplified Chinese; `zh`, `zh-CN`, `zh-SG` and `zh-Hans-*` resolve here (see `candidates`). LangInfo { code: "zh-hans", name: "简体中文", source: include_str!("zh-hans.tsv"), prefer_hans: true, catalog: OnceLock::new() }, @@ -68,6 +68,9 @@ pub static LANGUAGES: [LangInfo; 13] = [ // matching — no special-casing needed, unlike Chinese's script-by-region fallback). LangInfo { code: "sr-latn", name: "Srpski (latinica)", source: include_str!("sr-latn.tsv"), prefer_hans: false, catalog: OnceLock::new() }, LangInfo { code: "et", name: "Eesti", source: include_str!("et.tsv"), prefer_hans: false, catalog: OnceLock::new() }, + // German, neutral across Germany, Austria and Switzerland; `de`, `de-DE`, `de-AT`, `de-CH` … + // resolve here. + LangInfo { code: "de", name: "Deutsch", source: include_str!("de.tsv"), prefer_hans: false, catalog: OnceLock::new() }, // Russian; `ru`, `ru-RU`, `ru_RU.UTF-8` and other regions resolve here. LangInfo { code: "ru", name: "Русский", source: include_str!("ru.tsv"), prefer_hans: false, catalog: OnceLock::new() }, LangInfo { code: "tr", name: "Türkçe", source: include_str!("tr.tsv"), prefer_hans: false, catalog: OnceLock::new() }, diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index e795c8e09..831be00ed 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -30,6 +30,11 @@ fn system_locales_map_to_languages() { assert_eq!(lang_from_tag("sr-Cyrl-RS"), Some(lang("sr"))); assert_eq!(lang_from_tag("sr-Latn-RS"), Some(lang("sr-latn"))); assert_eq!(lang_from_tag("sr-Latn"), Some(lang("sr-latn"))); + assert_eq!(lang_from_tag("de"), Some(lang("de"))); + assert_eq!(lang_from_tag("de-DE"), Some(lang("de"))); + assert_eq!(lang_from_tag("de_AT.UTF-8"), Some(lang("de"))); + assert_eq!(lang_from_tag("de-CH"), Some(lang("de"))); + assert_eq!(lang_from_tag("DE_de.UTF-8@euro"), Some(lang("de"))); } #[test] @@ -37,7 +42,8 @@ fn the_first_supported_preferred_language_wins() { // Windows and macOS list several preferred languages in order. assert_eq!(first_supported(["fr-FR", "zh-Hant-TW", "en-US"]), Some(lang("zh-hant"))); assert_eq!(first_supported(["en-US", "ja-JP"]), Some(Lang::EN)); - assert_eq!(first_supported(["fr-FR", "de-DE"]), None); + assert_eq!(first_supported(["fr-FR", "de-DE"]), Some(lang("de"))); + assert_eq!(first_supported(["fr-FR", "nl-NL"]), None); assert_eq!(first_supported([]), None); } @@ -647,6 +653,57 @@ fn bundled_interface_fonts_cover_turkish_without_system_fallbacks() { assert!(!lang("tr").uses_cjk()); } +#[test] +fn german_locales_and_saved_preference_keep_document_content() { + let de = lang("de"); + for tag in ["de", "de-DE", "de-AT", "de-CH", "DE_de.UTF-8", "de_DE.UTF-8@euro", "de-Latn-DE"] { + assert_eq!(lang_from_tag(tag), Some(de), "{tag}"); + } + assert_eq!(first_supported(["fr-FR", "de-AT", "en-US"]), Some(de)); + assert_eq!(normalize_pref("DE"), Some("de")); + assert_eq!(de.name(), "Deutsch"); + let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_engine::sample::sample_document()), Default::default()); + let before = serde_json::to_value(&app.session.doc).unwrap(); + let result = app.run("ui.language", serde_json::json!({"value": "DE"})).unwrap(); + assert_eq!(result["effective"], "de"); + assert_eq!(app.ui.language, "de"); + assert_eq!(serde_json::to_value(&app.session.doc).unwrap(), before); + let saved = serde_json::to_string(&app.ui).unwrap(); + let restored: crate::UiState = serde_json::from_str(&saved).unwrap(); + assert_eq!(Lang::from_pref(&restored.language), de); +} + +#[test] +fn german_covers_the_interface_catalog_and_keeps_count_labels_neutral() { + use std::collections::HashSet; + let keys = |source| parse_entries(source).0.into_iter().map(|e| e.source).collect::>(); + let de = lang("de"); + assert_eq!(keys(de.0.source), keys(lang("zh-hans").0.source)); + assert_eq!(tr(de, "Home"), "Start"); + assert_eq!(tr(de, "Save"), "Speichern"); + assert_eq!(tr(de, "Spelling & Grammar"), "Rechtschreibung und Grammatik"); + assert_eq!(tr(de, "unknown future label"), "unknown future label"); + assert_eq!(fmt(tr(de, "Exported {path}"), &[("path", "draft-{words}.docx")]), "Exportiert: draft-{words}.docx"); + // Count-neutral wording is grammatical for every count: `Wörter: 21`, `Wörter: 21; ausgewählt: 1`. + for count in [0, 1, 2, 11, 21, 101] { + let words = count.to_string(); + assert_eq!(fmt(tr(de, "{words} words"), &[("words", &words)]), format!("Wörter: {count}")); + assert_eq!(fmt(tr(de, "{selected} of {words} words"), &[("selected", "1"), ("words", &words)]), format!("Wörter: {count}; ausgewählt: 1")); + } +} + +#[test] +fn bundled_interface_fonts_cover_german_letters() { + let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new()); + for (family, style) in [("Inter", "Regular"), ("Inter", "Medium"), ("Inter", "SemiBold"), ("JetBrains Mono", "Regular")] { + let face = db.face(family, style); + assert_eq!(face.family, family, "must use the bundled interface face"); + for ch in "ÄäÖöÜüß".chars() { + assert_ne!(face.glyph_for(ch), 0, "{family} {style} lacks {ch}"); + } + } +} + #[test] fn italian_locales_and_saved_preference_keep_document_content() { let it = lang("it"); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 125f5c5e2..8de723d17 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -50,7 +50,7 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key. | Method | Params | Result | |---|---|---| -| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog`, `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"|"en"|"zh-hans"|"zh-hant"|"ja"|"pt-br"|"es"|"uk"|"sr"|"sr-latn"|"et"|"tr"|"ru"|"it"}`), `ui.theme` (`{"value": "system"|"light"|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"percent": 80–200}`, clamped; no value reads it; scales the interface on top of the system display scale, document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"|"on"|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"|"picture"|"recipients"|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.caretAnimation` (`{"value": true|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading)…) | +| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog`, `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"|"en"|"zh-hans"|"zh-hant"|"ja"|"pt-br"|"es"|"uk"|"sr"|"sr-latn"|"et"|"tr"|"ru"|"de"|"it"}`), `ui.theme` (`{"value": "system"|"light"|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"percent": 80–200}`, clamped; no value reads it; scales the interface on top of the system display scale, document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"|"on"|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"|"picture"|"recipients"|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.caretAnimation` (`{"value": true|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading)…) | | `engine.commands` | — | every command: id, label, location, shortcut, params, enabled | | `document.inspect` | `text?` | blocks (text, style, runs, lists, tables), parts, sections, selection, pages | | `ui.inspect` | — | UI state, view, dialog, window size, page rects on screen, caret, comment balloons (`balloons`: id + screen rect; `balloon`: the selected one), perf | diff --git a/docs/gaps.md b/docs/gaps.md index 83e53140f..3ece41d9b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -23,7 +23,7 @@ Native save reliability: synthetic cases on macOS reproduced loss through the pr | 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 3-D effects and connector routing around shapes (WordArt effects and warps and glued connectors landed, #416), 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) | +| 11 | **Interface languages**: 7 of the 12 key languages missing (hi, ar, fr, id, ko, vi, pt-PT); no mirrored RTL interface; ~5% of strings still English in the 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); 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** (editing charts and SmartArt from Word, more chart types and formatting) | | `insert.chart` and Chart Design basics landed (#409): WordCraft's own charts are editable; charts from Word stay read-only (no embedded-workbook editing), no combo/3D/radar/stock charts, axis or series formatting; Insert › SmartArt landed (#497) for graphics WordCraft makes | Reports and business documents | 35–55 | [target-app-parity.md](target-app-parity.md) | | 14 | **`.doc` can't be written; 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) | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index affe8c0ee..9c80fde6c 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Italian interface, #594; previously minor: font list shows fonts by their names in the interface language, #505; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (German interface catalog, #574; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Interface languages, script support and proofing per language. How translation works (catalog format, adding a language, clean-room rule): `crates/ui-egui/src/i18n/mod.rs`. @@ -18,15 +18,15 @@ other ~18 languages Word ships, plus native-speaker review (human). ## What WordCraft ships (measured) -- **13 interface languages**: English plus `zh-hans`, `zh-hant`, `ja`, `uk`, `es`, `pt-br`, - Serbian `sr`/`sr-latn` (#250), Estonian `et`, Turkish `tr`, Russian `ru` and Italian `it` (#594). Catalogs have 2,021–2,399 entries +- **14 interface languages**: English plus `zh-hans`, `zh-hant`, `ja`, `uk`, `es`, `pt-br`, + Serbian `sr`/`sr-latn` (#250), Estonian `et`, Turkish `tr`, Russian `ru`, Italian `it` (#594) and German `de` (#574). Catalogs have 2,021–2,399 entries each. A test (`every_tab_and_command_is_translated`) enforces that every ribbon tab, command label and ribbon location is translated in every language. - Of the 675 English strings a scan extracts from `tl!(…)` calls and command labels/locations, each catalog covers **661 (98%)**. Some newer dialog and pane strings (Equation tab tooltips, Zotero, Read Aloud, Backstage greetings) aren't wrapped in `tl!` yet, so they show in English in every language: the true share of visible strings is **~93–95%** (estimated). -- Pending PRs: German (#203), Norwegian Bokmål (#208) and Nynorsk (#210), Hebrew with RTL (#20). +- Pending PRs: Norwegian Bokmål (#208) and Nynorsk (#210), Hebrew with RTL (#20). - **Proofing: English only** (one dictionary, rule-based grammar, English hyphenation patterns). - Document scripts: shaping via HarfRust (HarfBuzz port) for every script; bidi (UAX #9) and Arabic shaping tested (#207); IME on all desktop platforms (winit fixes pending, #155–#164). @@ -48,7 +48,7 @@ shared items below. | Portuguese | pt-br (pt-PT none) | ~930; 98% / ~95% (pt-BR) | most | Latin ✅ | no | partial | 2–4 (pt-BR finish + pt-PT) | | Indonesian | id | 0 | ❌ | Latin ✅ | no | none | 3–4 | | Japanese | ja | ~930; 98% / ~95% | most | CJK ✅, IME ✅; no vertical text, ruby or document grid | no | partial | 3–5 | -| German | de | 0 (PR #203 open) | ❌ | Latin ✅ | no | none | 1–2 | +| German | de | 2,021 entries; same key set as zh-hans (#574); 98% of extracted, ~95% of visible | most | Latin ✅ | no | partial | 1–2 | | Korean | ko | 0 | ❌ | Hangul ✅; macOS Korean IME fixes pending (#163, #164) | no | none | 3–5 | | Vietnamese | vi | 0 | ❌ | Latin with stacked diacritics, untested | no | none | 3–4 | @@ -82,6 +82,7 @@ allows); all other text is written from the meaning of the English. No Italian p | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | German interface catalog (#574): 2,021 entries covering the full zh-hans key set; locale, saved-preference and bundled-font tests; no German proofing | | 2026-10-11 | minor | Italian interface catalog (#594), locale selection, saved preference and font coverage checks; count labels put the noun first so they read right at 1 | | 2026-10-11 | minor | Font dialog sets the Asian text font (`w:eastAsia`) and the Latin text font separately; the Asian list holds the installed Chinese, Japanese and Korean fonts by their interface-language names (#505) | | 2026-10-11 | minor | Font list shows installed fonts by their names in the interface language (宋体 for SimSun in Chinese, Japanese and Korean names likewise) and finds them by either name; a Chinese, Japanese or Korean font is stored as `w:eastAsia` too (#505) | diff --git a/docs/roadmap.md b/docs/roadmap.md index 2137bea26..afd2a2e18 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -10,7 +10,7 @@ Opus 5.5 agent wall-clock. 1. **Merge or close the ~45 open PRs** before starting overlapping work: many close catalog gaps (Clipboard pane #264, Manage Styles #270, Group #267), fix field bugs (Hebrew RTL #20, IME - fixes #155–#164) or add languages (de, nb, nn, he). Owner review needed. + fixes #155–#164) or add languages (nb, nn, he). Owner review needed. 2. **DOCX real-world corpus** (gap #1, 30–50 h): collect openly licensed real-world .docx files, open/render/round-trip them, compare with Word locally, fix what breaks. Omitted/empty paragraph defaults now have synthetic save/reopen coverage; the corpus gate remains open. @@ -60,7 +60,7 @@ partial rows are fidelity gaps (beta work), not broken workflows. WordCraft stay | 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) | — | -| M15 | Localization (12 key languages, RTL interface, proofing languages) | 9 UI languages, English proofing | 80–130 h | +| M15 | Localization (12 key languages, RTL interface, proofing languages) | 11 UI languages, English proofing | 80–130 h | | M16 | Right-to-left and East Asian typography | RTL paragraphs done (#207) | 30–45 h | ## Next after beta From 1fa5333b26d23223da7b4dbf1aaaf9c9fb4aea5a Mon Sep 17 00:00:00 2001 From: witkowski01 Date: Mon, 12 Oct 2026 10:04:03 +0200 Subject: [PATCH 06/88] M15: Polish interface, and Polish spelling, grammar and hyphenation (#346) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * M15: Polish interface, and Polish spelling, grammar and hyphenation Interface - Polish is the tenth interface language ("Polski", pl.tsv, 1,045 entries); pl, pl-PL and pl_PL.UTF-8 system locales select it automatically. - Right-click menu labels now go through translation in every language. - Review > Language is a menu (English US/UK, Polski, Do not check spelling or grammar); the status bar shows the language at the caret. - With a Polish interface, new blank documents and opened .txt/.md files default to pl-PL. Proofing - Each run is checked in its own language (w:lang, resolved through styles): squiggles, F7, review.issues, suggestions and hyphenation. Text in languages without proofing tools is no longer flagged as English. - Polish spelling from the SJP.PL Hunspell dictionary (Apache-2.0 option), read by a new Rust affix engine (crates/proof/src/affix.rs); 875 KB asset, ~4.5M word forms. Regenerate with the pl_dictionary example. - Polish suggestions: case, missing diacritics, rz/ż, ó/u, ch/h confusions, missing spaces (napewno -> na pewno), edit distance. - Conservative Polish grammar rules: repeated words, spacing, doubled punctuation, sentence capitals, missing comma before że/który/ponieważ/ aby/żeby/jeśli…, we/ze forms. - Polish hyphenation with the hyph-utf8 hyph-pl.tex patterns (MIT option); pattern keys widened to 20 letters, TeX exceptions parsed; the heuristic no longer treats ł, ż, ś… as vowels. review.language validates and normalizes tags, can set noProof alone and the document default; format.state reports lang and noProof. Tests for every part, docs and ATTRIBUTION.md/NOTICE updated; cargo xtask ci passes (fmt, clippy, test, assets, layers, wasm). Co-Authored-By: Claude Opus 5.5 * Layout: rustfmt the test module order left unformatted by #391 cargo fmt --check failed on main after #391 added mod tests_grid. Co-Authored-By: Claude Opus 5.5 * Review fixes for #346: read Polish spelling from the installed Hunspell dictionary - Polish spelling uses the Hunspell pl_PL dictionary installed on the system (OS packages, Homebrew, LibreOffice, ~/.local/share/hunspell, or WORDCRAFT_DICTIONARIES) instead of a bundled 870 KB SJP.PL file; without one, Polish text gets no spelling marks, like Word without the language's proofing tools. .aff/.dic are size-capped and decoded from UTF-8 or ISO 8859-2 (crates/proof/src/hunspell.rs). - The dictionary can be replaced at runtime (install_dictionary) or re-searched (reload_dictionary); an empty one accepts every word. - Tests use small synthetic dictionaries written for them, so they don't depend on what the machine has installed. - The bundled pl.dic, its ATTRIBUTION/NOTICE entries and the generator example are removed; docs describe the installed dictionary. Co-Authored-By: Claude Opus 5.5 * M15: Polish catalog covers the interface text merged from main The merge of main (98456ac) brought ~575 new interface strings (charts, SmartArt, content controls, Developer tab, AutoCorrect, outline view, protection, compare, print, accessibility, references and Language/ Hyphenation dialogs, Draw additions). pl.tsv lacked them, so polish_covers_every_key_of_every_other_catalog and every_tab_and_command_is_translated failed. All are now translated. cargo xtask ci: all 6 steps pass. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ATTRIBUTION.md | 2 + NOTICE | 3 + README.md | 10 +- ROADMAP.md | 13 +- assets/hyphenation/pl.pat | 4154 +++++++++++++++++++++++++++ crates/engine/src/cmd/file.rs | 18 +- crates/engine/src/cmd/format.rs | 3 + crates/engine/src/cmd/review.rs | 20 +- crates/engine/src/cmd/tools.rs | 13 +- crates/engine/src/session.rs | 25 + crates/engine/src/tests.rs | 121 + crates/layout/src/para.rs | 77 +- crates/layout/src/tests.rs | 58 + crates/proof/Cargo.toml | 2 +- crates/proof/src/affix.rs | 845 ++++++ crates/proof/src/hunspell.rs | 163 ++ crates/proof/src/hyphen.rs | 195 +- crates/proof/src/lang.rs | 131 + crates/proof/src/lib.rs | 276 +- crates/proof/src/patterns.rs | 172 +- crates/proof/src/pl/grammar.rs | 542 ++++ crates/proof/src/pl/mod.rs | 204 ++ crates/proof/src/pl/suggest.rs | 437 +++ crates/proof/src/pl/test_dict.rs | 108 + crates/ui-egui/src/canvas.rs | 3 +- crates/ui-egui/src/chrome.rs | 8 +- crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 5 +- crates/ui-egui/src/i18n/et.tsv | 5 +- crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 5 +- crates/ui-egui/src/i18n/mod.rs | 81 +- crates/ui-egui/src/i18n/pl.tsv | 2055 +++++++++++++ crates/ui-egui/src/i18n/pt-br.tsv | 5 +- crates/ui-egui/src/i18n/ru.tsv | 5 +- crates/ui-egui/src/i18n/sr-latn.tsv | 5 +- crates/ui-egui/src/i18n/sr.tsv | 5 +- crates/ui-egui/src/i18n/tests.rs | 107 + crates/ui-egui/src/i18n/tr.tsv | 5 +- crates/ui-egui/src/i18n/uk.tsv | 5 +- crates/ui-egui/src/i18n/zh-hans.tsv | 5 +- crates/ui-egui/src/i18n/zh-hant.tsv | 5 +- crates/ui-egui/src/lib.rs | 11 + crates/ui-egui/src/ribbon.rs | 42 +- docs/architecture.md | 5 +- docs/control-protocol.md | 2 +- docs/gaps.md | 5 +- docs/localization-parity.md | 43 +- docs/roadmap.md | 5 +- docs/target-app-parity.md | 7 +- docs/ui-parity.md | 5 +- 51 files changed, 9837 insertions(+), 191 deletions(-) create mode 100644 assets/hyphenation/pl.pat create mode 100644 crates/proof/src/affix.rs create mode 100644 crates/proof/src/hunspell.rs create mode 100644 crates/proof/src/lang.rs create mode 100644 crates/proof/src/pl/grammar.rs create mode 100644 crates/proof/src/pl/mod.rs create mode 100644 crates/proof/src/pl/suggest.rs create mode 100644 crates/proof/src/pl/test_dict.rs create mode 100644 crates/ui-egui/src/i18n/pl.tsv diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 293066cd6..0bffecdad 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -11,6 +11,7 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra | `assets/hyphenation/en-us.pat` | DesignCraft contributors (generated) | trained from the Moby list by DesignCraft's patgen | Public Domain (CC0-1.0) | Our own Liang hyphenation patterns (no TeX patterns) | | `assets/hyphenation/en-us.dic` | Grady Ward (Moby Hyphenator II word list); converted by DesignCraft contributors | https://www.gutenberg.org/ebooks/3204 (file `mhyph.txt`, SHA-256 `eeb30474c86b8af3469035ec1a0913e35905325ca885290db9dfed9881e230ac`), via DesignCraft | Public Domain | ~165k words with break points; drives spelling and hyphenation | | `assets/spelling/en-extra.txt` | WordCraft contributors | original word list written for this repository (common modern words the Moby list lacks); no other dictionary copied | MIT OR Apache-2.0 | ~220 lowercase English base forms supplementing spelling (#520) | +| `assets/hyphenation/pl.pat` | Hanna Kołodziejska, Bogusław Jackowski, Marek Ryćko (Polish TeX hyphenation patterns, 1987–1995); hyph-utf8 package | https://github.com/hyphenation/tex-hyphen (`hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-pl.tex`, SHA-256 `83ddbbd21bc06a44bc4f4a4c49c38782e809cff5f026b764c044daa29e818843`), bundled unmodified | MIT (the file is offered under MIT or Knuth's public-domain terms; WordCraft uses MIT, whose notice is in the file's header) | 4,053 Liang patterns and 20 exception words; Polish hyphenation | | `assets/app-icon/wordcraft-1024.png` | WordCraft contributors | original artwork drawn in SVG (`assets/app-icon/wordcraft.svg`), rendered by `packaging/icons.sh` | MIT OR Apache-2.0 | Placeholder app icon until the WordCraft mascot drawing lands | | `packaging/macos/dmg/background.svg` | @XusBadia | original work for this repository, derived from the WordCraft app icon (`assets/app-icon/wordcraft.svg`, referenced via ``, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0, like the code ([`LICENSE-MIT`](LICENSE-MIT), [`LICENSE-APACHE`](LICENSE-APACHE)) | WordCraft macOS DMG window background (2026-10-08); used for the Finder window of the macOS DMG, rendered to `background.tiff` by `packaging/macos/dmg/generate.py` | | `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0, like the code ([`LICENSE-MIT`](LICENSE-MIT), [`LICENSE-APACHE`](LICENSE-APACHE)) | 1x + 2x HiDPI TIFF for the macOS DMG window; includes the app icon (see its row) and LittleCMS's built-in sRGB profile ("No copyright, use freely") | @@ -63,6 +64,7 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra | craft-fonts (optional build input, not files in this repo): BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans CJK SC, Noto Sans Arabic | see https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | embedded only when built with `CRAFT_FONTS_DIR` (all official releases) | OFL-1.1 | Japanese and Chinese UI, Japanese document fallback | | `crates/ui-egui/src/i18n/uk.tsv` | @dmatviichuk with Codex | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Ukrainian interface catalog; spelling checked with external VESUM and ULIF dictionaries, neither bundled | | `crates/ui-egui/src/i18n/et.tsv` | @henno with Codex | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Estonian interface catalog | +| `crates/ui-egui/src/i18n/pl.tsv` | Konrad Witkowski with Claude (AI agent) | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Polish interface catalog; not yet reviewed by a second native speaker | | `crates/ui-egui/src/i18n/pt-br.tsv` | @edurbs with ZCode | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Brazilian Portuguese interface catalog; spelling checked with the external pt_BR Hunspell dictionary, not bundled | | `crates/ui-egui/src/i18n/de.tsv` | @arekown with Kimi | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | German interface catalog | | `crates/ui-egui/src/i18n/ru.tsv` | Dmitrii Khlapov with Claude | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Russian interface catalog; reviewed by a native speaker | diff --git a/NOTICE b/NOTICE index 4bc9002c8..3390e725c 100644 --- a/NOTICE +++ b/NOTICE @@ -23,6 +23,9 @@ list (author, source, license) is in ATTRIBUTION.md. - English word list (assets/hyphenation/en-us.dic) — derived from the Moby Hyphenator II list by Grady Ward, public domain (Project Gutenberg eBook #3204). The hyphenation patterns (assets/hyphenation/en-us.pat) were trained from it and are released as CC0-1.0. +- Polish hyphenation patterns (assets/hyphenation/pl.pat) — hyph-pl.tex from the hyph-utf8 + package, Copyright (C) 1987-1995 Hanna Kołodziejska, Bogusław Jackowski, Marek Ryćko, bundled + unmodified and used under the MIT License; the copyright and permission notice is in the file. - All UI icons are drawn in code (crates/ui-egui/src/icons.rs) and are original work, MIT OR Apache-2.0. Rust dependencies (egui, eframe, vello_cpu, skrifa, harfrust, krilla, quick-xml, zip, image, …) diff --git a/README.md b/README.md index 474b0c6b8..4e6f871f6 100644 --- a/README.md +++ b/README.md @@ -115,9 +115,9 @@ Every screenshot below is WordCraft itself, rendered offscreen by its own UI tes | **Pages** | Margins, orientation, size, columns, page/column/section breaks, headers and footers (first page, odd/even), page numbers, watermark, page borders, line numbers, vertical alignment, drop caps, automatic hyphenation | | **Objects** | Pictures (resize, crop, recolour, brightness/contrast, transparency, background removal, picture styles, rotate), shapes, text boxes, floating position with text wrapping (square, top and bottom, behind or in front of text) | | **References** | Table of contents, footnotes and endnotes, citations and bibliography (APA, MLA, Chicago, IEEE), captions, table of figures, cross-references, index, table of authorities | -| **Review** | Spelling and grammar with suggestions, thesaurus, word count, comments in margin balloons or a pane, track changes, accept/reject, compare documents, restrict editing, accessibility checker, document inspector | +| **Review** | Spelling and grammar with suggestions (English and Polish, by each run's language), thesaurus, word count, comments in margin balloons or a pane, track changes, accept/reject, compare documents, restrict editing, accessibility checker, document inspector | | **Mailings** | Mail merge from CSV, merge fields, address block, greeting line, rules, preview, finish to a document; envelopes and labels | -| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский, Italiano or Deutsch (follows the system language by default) | +| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский, Italiano, Deutsch or Polski (follows the system language by default) | | **Files** | .docx read/write (opens in Word), Word 97-2003 .doc import, PDF export, .odt, .rtf, .html, .md, .tex, .txt import/export, page images | The honest picture, area by area, is in [ROADMAP.md](ROADMAP.md), the @@ -339,8 +339,10 @@ Copyright (c) 2026 ArtCraft Team and the WordCraft contributors. Required notice Bundled fonts, icons, images and other assets keep their own open licenses; each one is listed with its author, source and license in [ATTRIBUTION.md](ATTRIBUTION.md). -The spelling dictionary and hyphenation come from Grady Ward's public-domain Moby Hyphenator II -word list. The sample documents and templates are original text written for WordCraft. +The English spelling dictionary and hyphenation come from Grady Ward's public-domain Moby +Hyphenator II word list. Polish spelling reads the Hunspell `pl_PL` dictionary installed on the system (not bundled) and +Polish hyphenation the TeX patterns by Hanna Kołodziejska, Bogusław Jackowski and Marek Ryćko (MIT +option). The sample documents and templates are original text written for WordCraft. The ArtCraft name, wordmark and logos in [`docs/brand/`](docs/brand/) are trademarks of the ArtCraft Team and are not covered by this license. They may be used only unmodified, and only as diff --git a/ROADMAP.md b/ROADMAP.md index 06afe38a1..90a94484b 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 (German interface catalog, #574; 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:** minor (Polish interface and Polish proofing; 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 @@ -58,7 +58,7 @@ the two partial ones (real-world .docx, printing) complete, roughly. | File formats | 60% | 185–300 | [file-format-parity.md](docs/file-format-parity.md) | | Layout and pagination fidelity | 55% | 90–145 | [layout-parity.md](docs/layout-parity.md) | | Hardware | 40% | 20–40 | [hardware-parity.md](docs/hardware-parity.md) | -| Localization | 25% | 80–130 + native review | [localization-parity.md](docs/localization-parity.md) | +| Localization | 27% | 75–120 + native review | [localization-parity.md](docs/localization-parity.md) | | Performance | 75% | 10–20 | [layout-parity.md](docs/layout-parity.md#performance) | | Stability | 50% | 25–45 | [gaps.md](docs/gaps.md) (#3) | | Platforms | 95% (beyond Word on Linux, BSD, web) | 5–10 | this page | @@ -85,7 +85,7 @@ the two partial ones (real-world .docx, printing) complete, roughly. | Pictures, shapes, text boxes, WordArt | 60% | 20–30 | | Backstage, printing, options | 58% | 12–20 | | Right-to-left and complex scripts | 50% | 15–25 | -| Proofing (spelling, grammar, thesaurus, languages) | 35% | 25–45 | +| Proofing (spelling, grammar, thesaurus, languages) | 40% | 20–40 | | Accessibility | 35% | 15–25 | | East Asian typography | 25% | 15–25 | | Charts, SmartArt, Draw/ink, 3D models, icons | 3% | 70–110 | @@ -110,8 +110,9 @@ 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`), Serbian (`sr`, `sr-latn`, #250) and Russian (`ru`), all partial (~95%). Estonian (`et`), Turkish (`tr`) and Italian (`it`, #594) now have complete 1,307-entry interface catalogs with -system-locale selection and a saved language choice (Italian also has 51 strings none of the others had yet); Estonian, Turkish and Italian proofing are not included. Word ships +Also shipped: Traditional Chinese (`zh-hant`), Ukrainian (`uk`), Serbian (`sr`, `sr-latn`, #250), Russian (`ru`) and Polish (`pl`), all partial (~95%). Estonian (`et`), Turkish (`tr`) and Italian (`it`, #594) now have complete 1,307-entry interface catalogs with +system-locale selection and a saved language choice (Italian also has 51 strings none of the others had yet); Estonian, Turkish and Italian proofing are not included. +Proofing: English and Polish (spelling, grammar, hyphenation), chosen per run from its language. Word ships 30 interface languages and proofing for ~50. Detail: [docs/localization-parity.md](docs/localization-parity.md). ## Upcoming @@ -133,6 +134,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 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 | Polish: interface (`pl`, the thirteenth language, every catalog key) and proofing — spelling against the installed Hunspell `pl_PL` dictionary (read at runtime, not bundled) through a new Hunspell-style affix engine, suggestions tuned for Polish, Polish grammar rules with Polish messages, TeX Polish hyphenation patterns (MIT option); proofing language per run (`w:lang`) for squiggles, F7, suggestions and hyphenation, languages without tools left unmarked; Review › Language menu and the status bar's language; a Polish interface writes new documents in Polish | | 2026-10-11 | German (`de`) interface (#574): 2,021 translated entries covering the full zh-hans key set, locale and saved-preference tests, bundled-font checks; no German proofing resources | | 2026-10-11 | `.doc` import keeps pictures and drawings (#551): inline and floating pictures (PNG, JPEG, DIB, EMF, WMF), shapes, lines, groups and text boxes with position and wrap, from [MS-ODRAW] | | 2026-10-11 | Tables resize with the mouse (#217): drag a column border (the neighbour makes up the difference; Shift moves one column), a row's bottom border, or the handle past the bottom-right corner to scale the table; a dashed guide while dragging, one undo step per drag; `table.resizeDrag` for agents | @@ -254,6 +256,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-10 | trivial | Progress log: Windows OpenGL fallback without a DirectX 12 driver (#316) | | 2026-10-10 | minor | Password-protected .docx (#281): files encrypted with a password (agile encryption) open and save; File › Info › Encrypt with Password | | 2026-10-10 | minor | Relanded clipboard pane, table styles editor, Manage Styles, Asian typography (kinsoku), Group/Ungroup and Shape Effects (#264 #263 #270 #271 #267 #280): catalog 390/431 (90.5%) | +| 2026-10-10 | minor | Polish interface and proofing landed: Localization 25% → 27%, Proofing 35% → 40% | | 2026-10-10 | minor | Merged main: catalog 384/430 (89.3%), 431 commands; Paste Special, Advanced Find, Column Selection, Style Inspector, custom table styles, gridlines, zoom buttons, touchpad scrolling, File Info, Serbian landed; mainstream 54→55% | | 2026-10-10 | minor | Readiness table with hours per audience; full number restored to the additive weighted sum (~47% → ~60%): method aligned with the standard, no new evidence; beta distance back to ~15 points and ~150–230 h | | 2026-10-10 | minor | Mainstream practitioner (~54%) and essentials user (~63%) numbers added; full number recomputed 60% → ~47% with the standard's discounts; beta distance now ~28 points and ~450–700 h; stage stays alpha | diff --git a/assets/hyphenation/pl.pat b/assets/hyphenation/pl.pat new file mode 100644 index 000000000..1a3c6c1d3 --- /dev/null +++ b/assets/hyphenation/pl.pat @@ -0,0 +1,4154 @@ +% title: Hyphenation patterns for Polish +% copyright: Copyright (C) 1987-1995 Hanna Kołodziejska, Bogusław Jackowski, Marek Ryćko +% notice: This file is part of the hyph-utf8 package. +% See http://www.hyphenation.org/tex for more information. +% language: +% name: Polish +% tag: pl +% version: 3.0a, Wednesday, May 17th, 1995 +% authors: +% - +% name: Hanna Kołodziejska +% - +% name: Bogusław Jackowski +% contact: b_jackowski (at) gust.org.pl +% - +% name: Marek Ryćko +% contact: marek (at) do.com.pl +% licence: +% - This file is made available under any of the following licences: +% - +% name: MIT +% url: https://opensource.org/licenses/MIT +% text: > +% Permission is hereby granted, free of charge, to any person +% obtaining a copy of this software and associated documentation +% files (the “Software”), to deal in the Software without +% restriction, including without limitation the rights to use, +% copy, modify, merge, publish, distribute, sublicense, and/or sell +% copies of the Software, and to permit persons to whom the +% Software is furnished to do so, subject to the following +% conditions: +% +% The above copyright notice and this permission notice shall be +% included in all copies or substantial portions of the Software. +% +% THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +% EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +% OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +% NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +% HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +% WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +% FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +% OTHER DEALINGS IN THE SOFTWARE. +% - text: > +% This macro file belongs to the public domain +% under the conditions specified by the author of TeX: +% +% “Macro files like PLAIN.TEX should not be changed in any way, +% except with respect to preloaded fonts, +% unless the changes are authorized by the authors of the macros.” +% +% Donald E. Knuth +% hyphenmins: +% typesetting: +% left: 2 +% right: 2 +% changes: +% - The first version of the patterns was developed +% by Hanna Kołodziejska (1987). +% - The adaptation to the LeX format (see below) and extensive modification +% were done by Bogusław Jackowski & Marek Ryćko (1987--1989). +% - The hyphenation rules were further improved and adapted to the +% TeX 3.x requirements by Hanna Kołodziejska (1991). +% - Lone-standing version (3.0a) of patterns was prepared (under pressure +% from LaTeX users) by Bogusław Jackowski and Marek Ryćko, following +% Mariusz Olko's suggestions, 1995. +% - The LeX format mentioned above was the first version of the adaptation +% of TeX to the Polish language. The next version is called MeX. +% texlive: +% encoding: qx +% babelname: polish +% legacy_patterns: plhyph.tex +% message: Polish hyphenation patterns +% description: |- +% Hyphenation patterns for Polish in QX and UTF-8 encodings. +% These patterns are also used by Polish TeX formats MeX and LaMeX. +% +\patterns{ +.ćć8 +.ćł8 +.ćń8 +.ćś8 +.ćź8 +.ćż8 +.ć8 +.ćb8 +.ćc8 +.ćd8 +.ćf8 +.ćg8 +.ćh8 +.ćj8 +.ćk8 +.ćl8 +.ćm8 +.ćn8 +.ćp8 +.ćr8 +.ćs8 +.ćt8 +.ćv8 +.ćw8 +.ćwier2ć3 +.ćx8 +.ćz8 +.łć8 +.łł8 +.łń8 +.łś8 +.łź8 +.łż8 +.ł8 +.łb8 +.łc8 +.łd8 +.łf8 +.łg8 +.łh8 +.łj8 +.łk8 +.łl8 +.łm8 +.łn8 +.łp8 +.łr8 +.łs8 +.łt8 +.łv8 +.łw8 +.łx8 +.łz8 +.ńć8 +.ńł8 +.ńń8 +.ńś8 +.ńź8 +.ńż8 +.ń8 +.ńb8 +.ńc8 +.ńd8 +.ńf8 +.ńg8 +.ńh8 +.ńj8 +.ńk8 +.ńl8 +.ńm8 +.ńn8 +.ńp8 +.ńr8 +.ńs8 +.ńt8 +.ńv8 +.ńw8 +.ńx8 +.ńz8 +.ść8 +.śł8 +.śń8 +.śś8 +.śź8 +.śż8 +.ś8 +.śb8 +.śc8 +.śd8 +.śf8 +.śg8 +.śh8 +.śj8 +.śk8 +.śl8 +.śm8 +.śn8 +.śp8 +.śró2d5 +.śródr2 +.śr8 +.śs8 +.śt8 +.śv8 +.św8 +.światło3w2 +.śx8 +.śz8 +.źć8 +.źł8 +.źń8 +.źś8 +.źź8 +.źż8 +.ź8 +.źb8 +.źc8 +.źdź8 +.źd8 +.źf8 +.źg8 +.źh8 +.źj8 +.źk8 +.źl8 +.źm8 +.źn8 +.źp8 +.źr8 +.źs8 +.źt8 +.źv8 +.źw8 +.źx8 +.źz8 +.żć8 +.żł8 +.żń8 +.żś8 +.żź8 +.żż8 +.ż8 +.żb8 +.żc8 +.żd8 +.żf8 +.żg8 +.żh8 +.żj8 +.żk8 +.żl8 +.żm8 +.żn8 +.żp8 +.żr8 +.żs8 +.żt8 +.żv8 +.żw8 +.żx8 +.żz8 +.a2b2s3t +.a2d3 +.ad4a +.ad4e +.ad4i +.ad4o +.ad4u +.ad4y +.ad5apt +.ad5iu +.ad5op +.ad5or +.ae3ro +.aeroa2 +.aeroe2 +.aeroi2 +.aeroo2 +.aerou2 +.antya2 +.antye2 +.antyi2 +.antyo2 +.antyu2 +.arcy3ł2 +.arcy3b2 +.arcy3bz2 +.arcy3k2 +.arcy3m2 +.arcya2 +.arcye2 +.arcyi2 +.arcyo2 +.arcyu2 +.au3g2 +.au3k2 +.au3t2 +.auto3ch2 +.autoa2 +.autoe2 +.autoi2 +.autoo2 +.autotran2s3 +.autou2 +.bć8 +.bł8 +.bń8 +.bś8 +.bź8 +.bż8 +.b8 +.bb8 +.bc8 +.bd8 +.be2z3 +.be3z4an +.be3z4ec +.be3z4ik +.bezch2 +.bezm2 +.bezo2 +.bezo2b1j +.bezw2 +.bezzw2 +.bf8 +.bg8 +.bh8 +.bj8 +.bk8 +.bl8 +.bm8 +.bn8 +.bp8 +.br8 +.brz8 +.bs8 +.bt8 +.bv8 +.bw8 +.bx8 +.bz8 +.cć8 +.cł8 +.cń8 +.cś8 +.cź8 +.cż8 +.c8 +.cało3ś2 +.cało3k2 +.cb8 +.cc8 +.cd8 +.cf8 +.cg8 +.ch8 +.chrz8 +.cienko3w2 +.ciepło3kr2 +.cj8 +.ck8 +.cl8 +.cm8 +.cn8 +.cp8 +.cr8 +.cs8 +.ct8 +.cv8 +.cw8 +.cx8 +.cz8 +.czarno3k2 +.czk8 +.cztere2ch3 +.czterechse2t3 +.cztero3ś2 +.czwó2r3 +.czwó3r4ą +.czwó3r4ę +.czwó3r4a +.czwó3r4e +.czwó3r4o +.dć8 +.dł8 +.długo3tr2 +.długo3w2 +.dń8 +.dś8 +.dź8 +.dż8 +.d8 +.daleko3w2 +.db8 +.dc8 +.dd8 +.de2z3 +.de3z4a3bil +.de3z4a3wu +.de3z4el +.de3z4er +.de3z4y +.deza2 +.dezo2 +.df8 +.dg8 +.dh8 +.dj8 +.dk8 +.dl8 +.dm8 +.dn8 +.do3ć2 +.do3ł2 +.do3ś2 +.do3ź2 +.do3ż2 +.do3b2 +.do3c2 +.do3d2 +.do3f2 +.do3g2 +.do3h2 +.do3k2 +.do3l2 +.do3m2 +.do3p2 +.do3r2 +.do3s2 +.do3t2 +.do3w2 +.do3z2 +.do4ł3k +.do4k3t +.do4l3n +.do4m3k +.do4r3s +.do4w3c +.do5m4k2n +.dobr2 +.dobrz2 +.doch2 +.docz2 +.dodź2 +.dodż2 +.dodz2 +.dogrz2 +.dopch2 +.doprz2 +.dorż2 +.dorz2 +.dosch2 +.dosm2 +.dosz2 +.dotk2 +.dotr2 +.dp8 +.dr8 +.drogo3w2 +.drz8 +.ds8 +.dt8 +.dv8 +.dwó2j3 +.dwó3j4ą +.dwó3j4ę +.dwó3j4a +.dwó3j4e +.dwó3j4o +.dw8 +.dx8 +.dy2s3 +.dy2z3 +.dy3s4e +.dy3s4o +.dy3s4ta +.dy3s4y +.dy3sz +.dy3z4e +.dyzu2 +.dz8 +.dziesięcio3ś2 +.dziewięćse2t3 +.dziewię2ć3 +.dziewięcio3ś2 +.e2k2s3 +.e2m3e2s5ze2t +.e2s1e2s1ma +.e2s1ha +.e2s1t +.egoa2 +.egoe2 +.egoi2 +.egoo2 +.egou2 +.eks4y +.elektroa2 +.elektroe2 +.elektroi2 +.elektroo2 +.elektrou2 +.fć8 +.fł8 +.fń8 +.fś8 +.fź8 +.fż8 +.f8 +.fb8 +.fc8 +.fd8 +.ff8 +.fg8 +.fh8 +.fj8 +.fk8 +.fl8 +.fm8 +.fn8 +.fp8 +.fr8 +.fs8 +.ft8 +.fv8 +.fw8 +.fx8 +.fz8 +.gć8 +.gł8 +.gń8 +.gś8 +.gź8 +.gż8 +.g8 +.gb8 +.gc8 +.gd8 +.ge2o3 +.gf8 +.gg8 +.gh8 +.gj8 +.gk8 +.gl8 +.gm8 +.gn8 +.go2u3 +.gp8 +.gr8 +.grubo3w2 +.grz8 +.gs8 +.gt8 +.gv8 +.gw8 +.gx8 +.gz8 +.hć8 +.hł8 +.hń8 +.hś8 +.hź8 +.hż8 +.h8 +.hb8 +.hc8 +.hd8 +.hf8 +.hg8 +.hh8 +.hipe2r3 +.hipe3r4o +.hipera2 +.hipere2 +.hj8 +.hk8 +.hl8 +.hm8 +.hn8 +.hp8 +.hr8 +.hs8 +.ht8 +.hv8 +.hw8 +.hx8 +.hz8 +.i2n3 +.i2s3l +.i3n4ic +.i3n4o +.i3n4u +.i4n5o2k +.in4f3lan +.ino3w2 +.izoa2 +.izoe2 +.izoi2 +.izoo2 +.izou2 +.jć8 +.jł8 +.jń8 +.jś8 +.jź8 +.jż8 +.j8 +.jadło3w2 +.jb8 +.jc8 +.jd8 +.jf8 +.jg8 +.jh8 +.jj8 +.jk8 +.jl8 +.jm8 +.jn8 +.jp8 +.jr8 +.js8 +.jt8 +.jv8 +.jw8 +.jx8 +.jz8 +.kć8 +.kł8 +.kń8 +.kś8 +.kź8 +.kż8 +.k8 +.kb8 +.kc8 +.kd8 +.kf8 +.kg8 +.kh8 +.kilkuse2t3 +.kilkuseto2 +.kj8 +.kk8 +.kl8 +.km8 +.kn8 +.koło3w2 +.kon2t2r3 +.kon3tr4a +.kon3tr4e +.kon3tr4o3l +.kon3tr4o3w +.kon3tr4y +.kon4tr5a2gi +.kon4tr5a2se +.kon4tr5a2sy +.kon4tr5a2ta +.kon4tr5adm +.kon4tr5akc +.kon4tr5alt +.kon4tr5arg +.kontro2 +.kontru2 +.kp8 +.krótko3tr2 +.krótko3w2 +.kr8 +.kro2ć3 +.krz8 +.ks8 +.kt8 +.kv8 +.kw8 +.kx8 +.kz8 +.lć8 +.lł8 +.lń8 +.lś8 +.lź8 +.lż8 +.l8 +.lb8 +.lc8 +.ld8 +.lf8 +.lg8 +.lh8 +.lj8 +.lk8 +.ll8 +.lm8 +.ln8 +.lp8 +.lr8 +.ls8 +.lt8 +.ludo3w2 +.lv8 +.lw8 +.lx8 +.lz8 +.mć8 +.mł8 +.mń8 +.mś8 +.mź8 +.mż8 +.m8 +.mb8 +.mc8 +.md8 +.mf8 +.mg8 +.mh8 +.mili3amp +.mj8 +.mk8 +.ml8 +.mm8 +.mn8 +.możno3w2 +.mp8 +.mr8 +.ms8 +.mt8 +.mv8 +.mw8 +.mx8 +.mz8 +.nć8 +.nł8 +.nń8 +.nś8 +.nź8 +.nż8 +.n8 +.na2d2 +.na2j +.na3ć2 +.na3ł2 +.na3ś2 +.na3ź2 +.na3ż2 +.na3b2 +.na3c2 +.na3dą +.na3dę +.na3dź2 +.na3d4łub +.na3d4ir +.na3d4much +.na3d4ręcz +.na3d4r2w +.na3d4repcz +.na3d4rept +.na3d4ruk +.na3d4rz +.na3d4worn +.na3daj +.na3de +.na3do +.na3dy +.na3dzi +.na3f2 +.na3g2 +.na3h2 +.na3ją +.na3ję +.na3jazd +.na3je +.na3k2 +.na3l2 +.na3m2 +.na3p2 +.na3r2 +.na3s2 +.na3t2 +.na3u2 +.na3w2 +.na3z2 +.na4d3o2b2ł +.na4d3o2bojcz +.na4d3o2bowi +.na4d3o2brot +.na4d3o2drz +.na4d3o2kien +.na4d3olbrz +.na4d5rzą +.na4d5rzę +.na4d5rzecz +.na4d5rzy +.na4d5ziem +.na4f3c +.na4f3t +.na4j3e2f +.na4j3e2g +.na4j3e2k2s +.na4j3e2ko +.na4j3e2n +.na4j3e2r +.na4j3e2s +.na4j3e2w +.na4j3emf +.na4j3eu +.na4r3c +.na4r3d +.na4r3k +.na4r3r +.na4r3t +.nabrz2 +.nach2 +.nacz2 +.nadśrod5ziem +.nad3ć2 +.nad3ł2 +.nad3ś2 +.nad3b2 +.nad3c2 +.nad3d2 +.nad3e2tat +.nad3f2 +.nad3g2 +.nad3h2 +.nad3i2 +.nad3j2 +.nad3k2 +.nad3l2 +.nad3m2 +.nad3n2 +.nad3p2 +.nad3r2 +.nad3s2 +.nad3t2 +.nad3u2 +.nad3w2 +.nad5ż2 +.nad5zó +.nad5z2mys +.nad5zo +.nad5zwycz +.nadch2 +.nadcz2 +.naddź2 +.nade3ć2 +.nade3ł2 +.nade3ś2 +.nade3ź2 +.nade3ż2 +.nade3b2 +.nade3c2 +.nade3d2 +.nade3f2 +.nade3g2 +.nade3h2 +.nade3k2 +.nade3l2 +.nade3m2 +.nade3p2 +.nade3r2 +.nade3s2 +.nade3t2 +.nade3w2 +.nade3z2 +.nade4p3c +.nade4p3n +.nade4p3t +.nadech2 +.nadecz2 +.nadedź2 +.nadedż2 +.nadedz2 +.naderż2 +.naderz2 +.nadesz2 +.nadsz2 +.nadtr2 +.nadz2 +.nagrz2 +.naj3ć2 +.naj3ł2 +.naj3ś2 +.naj3ź2 +.naj3ż2 +.naj3akt +.naj3au +.naj3b2 +.naj3c2 +.naj3d2 +.naj3f2 +.naj3g2 +.naj3h2 +.naj3i2 +.naj3k2 +.naj3l2 +.naj3m2 +.naj3o2 +.naj3o2ć2 +.naj3o2ł2 +.naj3o2ś2 +.naj3o2ź2 +.naj3o2ż2 +.naj3o2b2 +.naj3o2c2 +.naj3o2d2 +.naj3o2f2 +.naj3o2g2 +.naj3o2h2 +.naj3o2k2 +.naj3o2l2 +.naj3o2m2 +.naj3o2p2 +.naj3o2r2 +.naj3o2s2 +.naj3o2t2 +.naj3o2w2 +.naj3o2z2 +.naj3p2 +.naj3r2 +.naj3ro2z3 +.naj3s2 +.naj3t2 +.naj3u2 +.naj3w2 +.naj3z2 +.najbe2z3 +.najbezw2 +.najch2 +.najcz2 +.najdź2 +.najdż2 +.najdo3ć2 +.najdo3ł2 +.najdo3ś2 +.najdo3ź2 +.najdo3ż2 +.najdo3b2 +.najdo3c2 +.najdo3d2 +.najdo3f2 +.najdo3g2 +.najdo3h2 +.najdo3k2 +.najdo3l2 +.najdo3m2 +.najdo3p2 +.najdo3r2 +.najdo3s2 +.najdo3t2 +.najdo3w2 +.najdo3z2 +.najdoch2 +.najdocz2 +.najdodź2 +.najdodż2 +.najdodz2 +.najdorz2 +.najdosz2 +.najdotk2 +.najdz2 +.najkr2 +.najob3ć2 +.najob3ł2 +.najob3ś2 +.najob3ź2 +.najob3ż2 +.najob3c2 +.najob3d2 +.najob3f2 +.najob3g2 +.najob3h2 +.najob3j2 +.najob3k2 +.najob3l2 +.najob3m2 +.najob3n2 +.najob3p2 +.najob3s2 +.najob3t2 +.najob3w2 +.najobch2 +.najobcz2 +.najobdź2 +.najobdż2 +.najobdz2 +.najobrz2 +.najobsz2 +.najoch2 +.najocz2 +.najodź2 +.najod3ć2 +.najod3ś2 +.najod3c2 +.najod3d2 +.najod3f2 +.najod3g2 +.najod3h2 +.najod3j2 +.najod3k2 +.najod3l2 +.najod3m2 +.najod3n2 +.najod3p2 +.najod3s2 +.najod3t2 +.najod3w2 +.najod5ż2 +.najodch2 +.najodcz2 +.najoddź2 +.najoddż2 +.najoddz2 +.najodsz2 +.najodz2 +.najorz2 +.najosz2 +.najro3z4u +.najrz2 +.najsm2 +.najsz2 +.najtk2 +.najtr2 +.najucz2 +.najzw2 +.nakr2 +.napo2d2 +.napo3ć2 +.napo3ł2 +.napo3ś2 +.napo3ź2 +.napo3ż2 +.napo3b2 +.napo3c2 +.napo3f2 +.napo3g2 +.napo3h2 +.napo3k2 +.napo3l2 +.napo3m2 +.napo3p2 +.napo3r2 +.napo3s2 +.napo3t2 +.napo3w2 +.napo3z2 +.napo4m3p +.napoch2 +.napocz2 +.napodź2 +.napodż2 +.napod3d +.napomk2 +.naporz2 +.naposz2 +.naprz2 +.narż2 +.naro2z3 +.narz2 +.nasm2 +.nasz2 +.natch2 +.natk2 +.naz3m2 +.nazw2 +.nb8 +.nc8 +.nd8 +.ne2o3 +.nf8 +.ng8 +.nh8 +.nie3ć2 +.nie3ł2 +.nie3ś2 +.nie3ź2 +.nie3ż2 +.nie3b2 +.nie3c2 +.nie3d2 +.nie3f2 +.nie3g2 +.nie3h2 +.nie3k2 +.nie3l2 +.nie3m2 +.nie3p2 +.nie3r2 +.nie3s2 +.nie3t2 +.nie3u2 +.nie3w2 +.nie3z2 +.nie4c3c +.nie4c3k +.nie4dź3 +.nie4m3c +.nie4m3k +.niech2 +.niecz2 +.niedż2 +.niedo3ć2 +.niedo3ł2 +.niedo3ś2 +.niedo3ź2 +.niedo3ż2 +.niedo3b2 +.niedo3c2 +.niedo3d2 +.niedo3f2 +.niedo3g2 +.niedo3h2 +.niedo3k2 +.niedo3l2 +.niedo3m2 +.niedo3p2 +.niedo3r2 +.niedo3s2 +.niedo3t2 +.niedo3w2 +.niedo3z2 +.niedobrz2 +.niedoch2 +.niedocz2 +.niedodź2 +.niedodż2 +.niedodz2 +.niedokr2 +.niedomk2 +.niedopch2 +.niedorz2 +.niedosz2 +.niedotk2 +.niedz2 +.nieoć2 +.nieoł2 +.nieoś2 +.nieoź2 +.nieoż2 +.nieo2 +.nieob2 +.nieob3ć2 +.nieob3ś2 +.nieob3ź2 +.nieob3ż2 +.nieob3c2 +.nieob3d2 +.nieob3f2 +.nieob3g2 +.nieob3h2 +.nieob3j2 +.nieob3k2 +.nieob3m2 +.nieob3p2 +.nieob3s2 +.nieob3w2 +.nieobch2 +.nieobcz2 +.nieobdź2 +.nieobdż2 +.nieobdz2 +.nieobsz2 +.nieoc2 +.nieoch2 +.nieocz2 +.nieodź2 +.nieod2 +.nieod3ć2 +.nieod3ł2 +.nieod3ś2 +.nieod3c2 +.nieod3d2 +.nieod3f2 +.nieod3g2 +.nieod3h2 +.nieod3j2 +.nieod3k2 +.nieod3l2 +.nieod3n2 +.nieod3p2 +.nieod3s2 +.nieod3t2 +.nieod3wr +.nieod5ż2 +.nieodch2 +.nieodcz2 +.nieoddź2 +.nieoddż2 +.nieoddz2 +.nieodsz2 +.nieodw2 +.nieodz2 +.nieof2 +.nieog2 +.nieoh2 +.nieok2 +.nieol2 +.nieom2 +.nieop2 +.nieor2 +.nieorz2 +.nieos2 +.nieosz2 +.nieot2 +.nieow2 +.nieoz2 +.niepo2d2 +.niepo3ć2 +.niepo3ł2 +.niepo3ś2 +.niepo3ź2 +.niepo3ż2 +.niepo3b2 +.niepo3c2 +.niepo3dź2 +.niepo3d4łu +.niepo3d4much +.niepo3d4ręcz +.niepo3d4raż +.niepo3d4rap +.niepo3d4repcz +.niepo3d4rept +.niepo3d4waj +.niepo3d4woj +.niepo3do +.niepo3du +.niepo3dz2 +.niepo3f2 +.niepo3g2 +.niepo3h2 +.niepo3k2 +.niepo3l2 +.niepo3m2 +.niepo3p2 +.niepo3r2 +.niepo3s2 +.niepo3t2 +.niepo3w2 +.niepo3z2 +.niepo4d3o2choc +.niepo4d3o2strz +.niepoch2 +.niepocz2 +.niepod3ć2 +.niepod3ł2 +.niepod3ś2 +.niepod3b2 +.niepod3c2 +.niepod3d2 +.niepod3f2 +.niepod3g2 +.niepod3h2 +.niepod3j2 +.niepod3k2 +.niepod3l2 +.niepod3m2 +.niepod3n2 +.niepod3p2 +.niepod3r2 +.niepod3s2 +.niepod3t2 +.niepod3w2 +.niepod5ż +.niepodch2 +.niepodcz2 +.niepoddź2 +.niepoddż2 +.niepodsm2 +.niepodsz2 +.nieporz2 +.nieposm2 +.nieposz2 +.nieprzełk2 +.nieprze2d2 +.nieprze3ć2 +.nieprze3ł2 +.nieprze3ś2 +.nieprze3ź2 +.nieprze3ż2 +.nieprze3b2 +.nieprze3brz2 +.nieprze3c2 +.nieprze3dź2 +.nieprze3d4łuż +.nieprze3d4much +.nieprze3d4ramat +.nieprze3d4ruk +.nieprze3d4ryl +.nieprze3d4rz2 +.nieprze3d4um +.nieprze3dy +.nieprze3dz2 +.nieprze3e2k2s3 +.nieprze3f2 +.nieprze3g2 +.nieprze3h2 +.nieprze3k2 +.nieprze3l2 +.nieprze3m2 +.nieprze3n2 +.nieprze3p2 +.nieprze3r2 +.nieprze3s2 +.nieprze3t2 +.nieprze3w2 +.nieprze3z2 +.nieprze4d5łużyc +.nieprze4d5ż2 +.nieprze4d5z2a +.nieprze4d5zg2 +.nieprze4d5zim +.nieprze4d5zj +.nieprze4d5zl +.nieprze4d5zw2r +.nieprze4d5zwoj +.nieprzech2 +.nieprzecz2 +.nieprzed3ć2 +.nieprzed3ł2 +.nieprzed3ś2 +.nieprzed3c2 +.nieprzed3d2 +.nieprzed3f2 +.nieprzed3g2 +.nieprzed3h2 +.nieprzed3i2 +.nieprzed3j2 +.nieprzed3k2 +.nieprzed3l2 +.nieprzed3m2 +.nieprzed3n2 +.nieprzed3p2 +.nieprzed3r2 +.nieprzed3s2 +.nieprzed3sz2 +.nieprzed3t2 +.nieprzed3u2 +.nieprzed3w2 +.nieprzedch2 +.nieprzedcz2 +.nieprzeddź2 +.nieprzeddż2 +.nieprzeddz2 +.nieprzegrz2 +.nieprzekl2 +.nieprzekr2 +.nieprzepch2 +.nieprzerż2 +.nieprzerz2 +.nieprzesch2 +.nieprzesm2 +.nieprzesz2 +.nieprzetk2 +.nieprzetr2 +.niero2z3 +.niero3z4e +.niero3z4u +.nierozś2 +.nierozbrz2 +.nieroze3r2 +.nierozm2 +.nieroztr2 +.nierz2 +.niesu2b3 +.niesu3b4ie +.niesz2 +.nietk2 +.nietr2 +.nieucz2 +.nieuw2 +.niewy3ć2 +.niewy3ł2 +.niewy3ś2 +.niewy3ź2 +.niewy3ż2 +.niewy3b2 +.niewy3c2 +.niewy3d2 +.niewy3f2 +.niewy3g2 +.niewy3h2 +.niewy3k2 +.niewy3l2 +.niewy3m2 +.niewy3p2 +.niewy3r2 +.niewy3s2 +.niewy3t2 +.niewy3w2 +.niewy3z2 +.niewybrz2 +.niewych2 +.niewycz2 +.niewydź2 +.niewydż2 +.niewydz2 +.niewyrz2 +.niewysz2 +.niewytk2 +.niewytr2 +.niezw2 +.nj8 +.nk8 +.nl8 +.nm8 +.nn8 +.np8 +.nr8 +.ns8 +.nt8 +.nv8 +.nw8 +.nx8 +.nz8 +.oć2 +.oś2 +.ośmio3ś2 +.oź2 +.oż2 +.o2b2 +.o2d2 +.o2t3chł +.o3b4łą +.o3b4łę +.o3b4łoc +.o3b4luzg +.o3b4rać +.o3b4raso +.o3b4roń +.o3b4ron +.o3b4ryź +.o3b4ryz +.o3b4rz2 +.o3be +.o3bi +.o3d4iu +.o3d4ręt +.o3d4rap +.o3d4robin +.o3d4rut +.o3d4rwi +.o3d4rzeć +.o3d4rzw +.o3d6zia +.o3d6zie +.o3de +.o3l2śn +.o4b5łocz +.o4b5rzą +.o4b5rzęd +.o4b5rzez +.o4b5rzuc +.o4b5rzut +.o4b5rzyn +.o4d7ziar +.o4d7ziem +.oa3z +.ob3ć2 +.ob3ł2 +.ob3ś2 +.ob3ź2 +.ob3ż2 +.ob3c2 +.ob3d2 +.ob3f2 +.ob3g2 +.ob3h2 +.ob3j2 +.ob3k2 +.ob3l2 +.ob3m2 +.ob3n2 +.ob3o2strz +.ob3p2 +.ob3r +.ob3s2 +.ob3t2 +.ob3u2m2 +.ob3w2 +.obch2 +.obcz2 +.obdź2 +.obdż2 +.obdz2 +.obe3ć2 +.obe3ł2 +.obe3ś2 +.obe3ź2 +.obe3ż2 +.obe3b2 +.obe3c2 +.obe3d2 +.obe3f2 +.obe3g2 +.obe3h2 +.obe3k2 +.obe3l2 +.obe3m2 +.obe3p2 +.obe3r2 +.obe3r3t +.obe3s2 +.obe3t2 +.obe3w2 +.obe3z2 +.obe4c3n +.obe4z3w +.obech2 +.obecz2 +.obedź2 +.obedż2 +.obedz2 +.oberż2 +.ober3m +.oberz2 +.obesch2 +.obesz2 +.obetk2 +.obi3b2 +.obsz2 +.oc2 +.och2 +.ochrz2 +.ocz2 +.odź2 +.od3ć2 +.od3ś2 +.od3au +.od3b2 +.od3c2 +.od3d2 +.od3f2 +.od3g2 +.od3h2 +.od3i2 +.od3i2zo +.od3j2 +.od3k2 +.od3l2 +.od3m2 +.od3n2 +.od3o2s +.od3p2 +.od3r2 +.od3s2 +.od3t2 +.od3u2cz +.od3u2m2 +.od3w2 +.od5ż2 +.od5z2 +.odbe2z3 +.odch2 +.odcz2 +.oddź2 +.oddż2 +.oddz2 +.ode3ć2 +.ode3ł2 +.ode3ś2 +.ode3ź2 +.ode3ż2 +.ode3b2 +.ode3c2 +.ode3d2 +.ode3f2 +.ode3g2 +.ode3h2 +.ode3k2 +.ode3l2 +.ode3m2 +.ode3mk2 +.ode3p2 +.ode3r2 +.ode3s2 +.ode3t2 +.ode3w2 +.ode3z2 +.odech2 +.odecz2 +.odedź2 +.odedż2 +.odedz2 +.odepch2 +.oderż2 +.oderz2 +.odesz2 +.odetch2 +.odetk2 +.odkrz2 +.odrz2 +.odsz2 +.of2 +.ogólno3k2 +.og2 +.ognio3tr2 +.oh2 +.ok2 +.oka3m2 +.okr2 +.ole2o3 +.om2 +.op2 +.opch2 +.or2ż2 +.or2tę +.or2z2 +.os2 +.osie2m3 +.osiemse2t3 +.osz2 +.ot2 +.ow2 +.oz2 +.pć8 +.pł8 +.płasko3w2 +.pń8 +.półk2 +.półkr2 +.półm2 +.póło2 +.półob3r +.półom2d +.półprzy3m2k +.pó2ł3 +.pó3ł4ą +.pó3ł4ę +.pó3ł4ecz +.pó3ł4y +.pś8 +.pź8 +.pż8 +.p8 +.pb8 +.pc8 +.pch8 +.pd8 +.pełno3kr2 +.pe2r3 +.pe3c2k +.pe3r4e +.pe3r4i +.pe3r4o +.pe3r4u +.pe3r4y +.pe4r5i2n +.pee2se2l +.pepee2r +.pepee2s +.peze2t1pee2r +.pf8 +.pg8 +.ph8 +.pięćse2t3 +.pię2ć3 +.pięcio3ś2 +.pierwo3w2 +.piono3w2 +.pj8 +.pk8 +.pl8 +.pm8 +.pn8 +.połk2 +.po2d2 +.po3ć2 +.po3ł2 +.po3ś2 +.po3ź2 +.po3ż2 +.po3b2 +.po3c2 +.po3dą +.po3dę +.po3dź2 +.po3d4łu +.po3d4much +.po3d4naw +.po3d4ręcz +.po3d4rętw +.po3d4róż +.po3d4r2wi +.po3d4raż +.po3d4rap +.po3d4repcz +.po3d4rept +.po3d4roż +.po3d4robó +.po3d4roba +.po3d4robo +.po3d4roby +.po3d4rocz +.po3d4ruzg +.po3d4ryg +.po3d4rze +.po3d4wójn +.po3d4wór +.po3d4waj +.po3d4woi +.po3d4woj +.po3d4worz +.po3da +.po3de +.po3dej +.po3diu +.po3do +.po3du +.po3dy +.po3dz2 +.po3e2k2s3 +.po3f2 +.po3g2 +.po3h2 +.po3k2 +.po3l2 +.po3m2 +.po3p2 +.po3rż +.po3r2 +.po3s2 +.po3t2 +.po3w2 +.po3z2 +.po4ń3c +.po4cz3d +.po4cz3t +.po4d3ów +.po4d3e4k2s3 +.po4d3o2bóz +.po4d3o2biad +.po4d3o2bojcz +.po4d3o2braz +.po4d3o2choc +.po4d3o2dm +.po4d3o2f +.po4d3o2g +.po4d3o2kien +.po4d3o2kn +.po4d3o2kręg +.po4d3o2kres +.po4d3o2piecz +.po4d3o2ryw +.po4d3o2siniak +.po4d3o2strz +.po4d3obsz +.po4d3odd +.po4d3olbrz +.po4d3u2cz +.po4d3u2dz +.po4d3u2pa +.po4d3u2ral +.po4d3u2sta +.po4d3u2szcz +.po4d5ręczn +.po4d5zakr +.po4d5zam +.po4d5zast +.po4d5zbi +.po4d5ze +.po4d5zielenią +.po4d5zielenić +.po4d5zielenię +.po4d5zielenił +.po4d5zielenic +.po4d5zielenien +.po4d5zielenil +.po4d5zielenim +.po4d5zielenio +.po4d5zielenis +.po4d5ziem +.po4d5ziom +.po4d5zw2r +.po4l3s +.po4m3p +.po4r3c +.po4r3f +.po4r3n +.po4r3t +.po4st3d +.po4st3f +.po4st3g +.po4st3h +.po4st3i2 +.po4st3k +.po4st3l +.po4st3m +.po4st3p +.po4st3rom +.po4st3s +.po5d4uszczyn +.po5r4tę +.pobr2 +.pobrz2 +.poch2 +.pochrz2 +.pocz2 +.pod3ć2 +.pod3ł2 +.pod3ś2 +.pod3śró2d5 +.pod3alp +.pod3b2 +.pod3c2 +.pod3d2 +.pod3f2 +.pod3g2 +.pod3h2 +.pod3i2n +.pod3j2 +.pod3k2 +.pod3l2 +.pod3m2 +.pod3n2 +.pod3p2 +.pod3r2 +.pod3s2 +.pod3t2 +.pod3w2 +.pod5ż2 +.podch2 +.podcz2 +.poddź2 +.poddż2 +.pode3ć2 +.pode3ł2 +.pode3ś2 +.pode3ź2 +.pode3ż2 +.pode3b2 +.pode3c2 +.pode3d2 +.pode3f2 +.pode3g2 +.pode3h2 +.pode3k2 +.pode3l2 +.pode3m2 +.pode3p2 +.pode3r2 +.pode3s2 +.pode3t2 +.pode3tk2 +.pode3w2 +.pode3z2 +.podech2 +.podecz2 +.podedź2 +.podedż2 +.podedz2 +.podepch2 +.poderż2 +.poderz2 +.podesch2 +.podesz2 +.podro2z3 +.podsm2 +.podsz2 +.pogrz2 +.pokl2 +.pokr2 +.pom4pk +.pomk2 +.pona2d2 +.pona3ć2 +.pona3ł2 +.pona3ś2 +.pona3ź2 +.pona3ż2 +.pona3b2 +.pona3c2 +.pona3cz2 +.pona3dź2 +.pona3do +.pona3f2 +.pona3g2 +.pona3h2 +.pona3k2 +.pona3l2 +.pona3m2 +.pona3p2 +.pona3r2 +.pona3s2 +.pona3t2 +.pona3w2 +.pona3z2 +.pona4f3t +.ponabrz2 +.ponach2 +.ponad3ć2 +.ponad3ś2 +.ponad3c2 +.ponad3ch2 +.ponad3cz2 +.ponad3dź2 +.ponad3f2 +.ponad3g2 +.ponad3h2 +.ponad3j2 +.ponad3k2 +.ponad3l2 +.ponad3p2 +.ponad3s2 +.ponad3t2 +.ponadz2 +.ponarz2 +.ponasm2 +.ponasz2 +.ponaz3m2 +.ponazw2 +.ponie3k2 +.ponie3w2 +.popch2 +.popo3w2 +.poprz2 +.por4t1w +.por4tf +.por4tm +.poro2z3 +.poro3z4u +.porz2 +.posch2 +.posm2 +.posz2 +.potk2 +.potr2 +.poz4m2 +.poza3u2 +.pozw2 +.pp8 +.pr8 +.pra3s2 +.pra3w2nu +.pra3w2z +.prapra3w2nu +.predy2s3po +.prz8 +.przełk2 +.prze2d2 +.prze3ć2 +.prze3ł2 +.prze3ś2 +.prze3ź2 +.prze3ż2 +.prze3b2 +.prze3c2 +.prze3dą +.prze3dę +.prze3dź2 +.prze3d4łuż +.prze3d4much +.prze3d4o3br +.prze3d4o3st +.prze3d4o3zo +.prze3d4ramat +.prze3d4ruk +.prze3d4ryl +.prze3d4rz2 +.prze3d4um +.prze3dy +.prze3dz2 +.prze3e2k2s3 +.prze3f2 +.prze3g2 +.prze3h2 +.prze3k2 +.prze3l2 +.prze3m2 +.prze3n2 +.prze3p2 +.prze3r2 +.prze3s2 +.prze3t2 +.prze3u2 +.prze3w2 +.prze3z2 +.prze4d5łużyc +.prze4d5ż2 +.prze4d5o4stat +.prze4d5za +.prze4d5zg2 +.prze4d5zim +.prze4d5zj +.prze4d5zl +.prze4d5zw2r +.prze4d5zwoj +.przebr2 +.przebrz2 +.przech2 +.przechrz2 +.przeci2w3 +.przeci3w4ie +.przeciwa2 +.przeciww2 +.przecz2 +.przed3ć2 +.przed3ł2 +.przed3ś2 +.przed3a2gon +.przed3a2kc +.przed3alp +.przed3b2 +.przed3c2 +.przed3d2 +.przed3e2gz +.przed3e2mer +.przed3f2 +.przed3g2 +.przed3h2 +.przed3i2 +.przed3j2 +.przed3k2 +.przed3l2 +.przed3m2 +.przed3n2 +.przed3o2 +.przed3p2 +.przed3r2 +.przed3s2 +.przed3się3w2 +.przed3sz2 +.przed3t2 +.przed3u2 +.przed3w2 +.przedch2 +.przedcz2 +.przeddź2 +.przeddż2 +.przeddz2 +.przedgrz2 +.przedy2s3ku +.przegrz2 +.przekl2 +.przekr2 +.przemk2 +.przepch2 +.przerż2 +.przerz2 +.przesch2 +.przesm2 +.przesz2 +.przetk2 +.przetr2 +.przetran2s3 +.przy3ć2 +.przy3ł2 +.przy3ś2 +.przy3ź2 +.przy3ż2 +.przy3b2 +.przy3c2 +.przy3d2 +.przy3f2 +.przy3g2 +.przy3h2 +.przy3k2 +.przy3l2 +.przy3m2 +.przy3p2 +.przy3r2 +.przy3s2 +.przy3t2 +.przy3w2 +.przy3z2 +.przybr2 +.przych2 +.przycz2 +.przydź2 +.przydż2 +.przydz2 +.przygrz2 +.przymk2 +.przyoz2 +.przypch2 +.przyrż2 +.przyrz2 +.przysch2 +.przysz2 +.przytk2 +.ps8 +.pt8 +.pv8 +.pw8 +.px8 +.pz8 +.rć8 +.rł8 +.rń8 +.rś8 +.rź8 +.rż8 +.r8 +.rb8 +.rc8 +.rd8 +.retran2s3 +.rf8 +.rg8 +.rh8 +.rj8 +.rk8 +.rl8 +.rm8 +.rn8 +.ro2z3 +.ro3z4a +.ro3z4e +.ro3z4e3ć2 +.ro3z4e3ł2 +.ro3z4e3ś2 +.ro3z4e3ź2 +.ro3z4e3ż2 +.ro3z4e3b2 +.ro3z4e3c2 +.ro3z4e3d2 +.ro3z4e3f2 +.ro3z4e3g2 +.ro3z4e3h2 +.ro3z4e3k2 +.ro3z4e3l2 +.ro3z4e3m2 +.ro3z4e3p2 +.ro3z4e3r2 +.ro3z4e3s2 +.ro3z4e3t2 +.ro3z4e3w2 +.ro3z4e3z2 +.ro3z4ej +.ro3z4u +.ro4z5a2gi +.ro4z5a2nie +.ro4z5e2mo +.ro4z5e4g3z +.ro4z5e4n3t +.rozś2 +.rozbrz2 +.rozd2 +.rozech2 +.rozecz2 +.rozedź2 +.rozedż2 +.rozedz2 +.rozepch2 +.rozerż2 +.rozerz2 +.rozesch2 +.rozesz2 +.rozi2 +.rozm2 +.rozo2 +.rozpo3w2 +.rozt2 +.roztr2 +.rozw2 +.rp8 +.rr8 +.rs8 +.rt8 +.rv8 +.rw8 +.rx8 +.rz8 +.sć8 +.sł8 +.sń8 +.sś8 +.sź8 +.sż8 +.s8 +.samo3ch2 +.samo3k2 +.samo3p2 +.samo3w2 +.samoro2z3 +.sb8 +.sc8 +.sch8 +.sd8 +.sf8 +.sg8 +.sh8 +.siede2m3 +.siedemse2t3 +.siedmio3ś2 +.sj8 +.ską2d5że +.sk8 +.skl8 +.skr8 +.sl8 +.sm8 +.sn8 +.sobo3w2 +.spó2ł3 +.sp8 +.spo2d2 +.spo3ć2 +.spo3ł2 +.spo3ś2 +.spo3ź2 +.spo3ż2 +.spo3b2 +.spo3c2 +.spo3dz2 +.spo3f2 +.spo3g2 +.spo3h2 +.spo3k2 +.spo3l2 +.spo3m2 +.spo3p2 +.spo3r2 +.spo3s2 +.spo3t2 +.spo3w2 +.spo3z2 +.spo4r3n +.spo4r3t +.spoch2 +.spocz2 +.spodź2 +.spodż2 +.spod3d +.sporz2 +.sposz2 +.sr8 +.ss8 +.st8 +.stere2o3 +.stereoa2 +.stereoe2 +.stereoi2 +.stereoo2 +.stereou2 +.su2b3 +.su3b4ie +.su3b4otn +.supe2r3 +.supe3r4at +.supe3r4io +.supe4r5a2tr +.super5z2b +.supere2 +.supero2d1rzut +.sv8 +.sw8 +.sx8 +.sz8 +.sześćse2t3 +.sześcio3ś2 +.sze2ś2ć3 +.sze2s3 +.tć8 +.tł8 +.tń8 +.tś8 +.tź8 +.tż8 +.t8 +.ta2o3 +.ta2r7zan +.tb8 +.tc8 +.tch8 +.td8 +.te2o3 +.tf8 +.tg8 +.th8 +.tj8 +.tk8 +.tl8 +.tm8 +.tn8 +.toa3 +.tp8 +.tró2j3 +.tró3j4ą +.tró3j4ę +.tró3j4ecz +.tr8 +.tran2s3 +.tran3s4e +.tran3s4ie +.tran3s4y +.tran3sz +.tran4s5eu +.transa2 +.transo2 +.trz8 +.trze2ch3 +.trzechse2t3 +.ts8 +.tt8 +.tv8 +.tw8 +.tx8 +.tysią2c3 +.tysią3c4a +.tysią3c4e +.tysią3cz +.tysią4c5zł +.tz8 +.uć2 +.uś2 +.u3ł2 +.u3ź2 +.u3ż2 +.u3b2 +.u3c2 +.u3d2 +.u3f2 +.u3g2 +.u3h2 +.u3k2 +.u3l2 +.u3m2 +.u3n2 +.u3p2 +.u3r2 +.u3s2 +.u3t2 +.u3w2 +.u3z2 +.u4d3k +.u4f3n +.u4k3lej +.u4l3s +.u4l3t +.u4m3br +.u4n3c +.u4n3d +.u4p3p2s +.u4r3s +.u4st3n +.u4stc +.u4stk +.u4z3be +.ube2z3 +.ubezw2 +.ubr2 +.uch2 +.ucz2 +.udź2 +.udż2 +.udz2 +.ukr2 +.umk2 +.upch2 +.upo2d2 +.upo3ć2 +.upo3ł2 +.upo3ś2 +.upo3ź2 +.upo3ż2 +.upo3b2 +.upo3c2 +.upo3da +.upo3f2 +.upo3g2 +.upo3h2 +.upo3k2 +.upo3l2 +.upo3m2 +.upo3p2 +.upo3r2 +.upo3s2 +.upo3t2 +.upo3w2 +.upo3z2 +.upoch2 +.upocz2 +.upodź2 +.upodż2 +.upod3d +.uporz2 +.uposz2 +.urż2 +.uro2z3 +.urz2 +.usch2 +.usz2 +.utk2 +.utr2 +.uze3w2 +.vć8 +.vł8 +.vń8 +.vś8 +.vź8 +.vż8 +.v8 +.vb8 +.vc8 +.vd8 +.vf8 +.vg8 +.vh8 +.vj8 +.vk8 +.vl8 +.vm8 +.vn8 +.vp8 +.vr8 +.vs8 +.vt8 +.vv8 +.vw8 +.vx8 +.vz8 +.wć8 +.wł8 +.wń8 +.wś8 +.wź8 +.wż8 +.w8 +.wb8 +.wc8 +.wd8 +.we3ć2 +.we3ł2 +.we3ś2 +.we3ż2 +.we3b2 +.we3c2 +.we3d2 +.we3f2 +.we3g2 +.we3h2 +.we3k2 +.we3l2 +.we3m2 +.we3n2 +.we3p2 +.we3r2 +.we3s2 +.we3t2 +.we3w2 +.we3z2 +.we4ł3n +.we4k3t +.we4l3w +.we4n3d +.we4n3t +.we4r3b +.we4r3d +.we4r3n +.we4r3s +.we4r3t +.we4s3prz +.we4s3tch2 +.we4z3br +.we4z3gł +.wech2 +.wecz2 +.wedź2 +.wedż2 +.wedz2 +.wemk2 +.wepch2 +.werz2 +.wesz2 +.wetk2 +.wewną2trz3 +.wf8 +.wg8 +.wh8 +.wielo3ś2 +.wielo3d2 +.wielo3k2 +.wieluse2t3 +.wilczo3m2 +.wj8 +.wk8 +.wl8 +.wm8 +.wn8 +.wniebo3w2 +.wodo3w2 +.wp8 +.wr8 +.ws8 +.współi2 +.współo2b3w +.współu2 +.współw2 +.wspó2ł3 +.wsze2ch3 +.wszecho2 +.wszechw2 +.wt8 +.wv8 +.ww8 +.wx8 +.wy3ć2 +.wy3ł2 +.wy3ś2 +.wy3ź2 +.wy3ż2 +.wy3b2 +.wy3c2 +.wy3d2 +.wy3f2 +.wy3g2 +.wy3h2 +.wy3k2 +.wy3l2 +.wy3m2 +.wy3o2d3r +.wy3p2 +.wy3r2 +.wy3s2 +.wy3t2 +.wy3w2 +.wy3z2 +.wy4ż3sz +.wy4cz3ha +.wybr2 +.wybrz2 +.wych2 +.wycz2 +.wydź2 +.wydż2 +.wydr2 +.wydz2 +.wye2k2s3 +.wygrz2 +.wyi2zo +.wykl2 +.wykr2 +.wykrz2 +.wymk2 +.wypch2 +.wyprz2 +.wyrż2 +.wyrz2 +.wysch2 +.wysm2 +.wysz2 +.wytch2 +.wytk2 +.wytr2 +.wz8 +.xć8 +.xł8 +.xń8 +.xś8 +.xź8 +.xż8 +.x8 +.xb8 +.xc8 +.xd8 +.xf8 +.xg8 +.xh8 +.xj8 +.xk8 +.xl8 +.xm8 +.xn8 +.xp8 +.xr8 +.xs8 +.xt8 +.xv8 +.xw8 +.xx8 +.xz8 +.zć8 +.zł8 +.zło3w2 +.zń8 +.zś8 +.zź8 +.zż8 +.z8 +.za3ć2 +.za3ł2 +.za3ś2 +.za3ź2 +.za3ż2 +.za3b2 +.za3c2 +.za3d2 +.za3f2 +.za3g2 +.za3h2 +.za3k2 +.za3l2 +.za3m2 +.za3o2b3r +.za3o2b3s +.za3p2 +.za3r2 +.za3s2 +.za3t2 +.za3u2 +.za3w2 +.za3z2 +.za4k3t +.za4l3g +.za4l3k +.za4l3t +.za4m3k +.za4r3ch +.za4uto +.za5m4k2n +.zabr2 +.zabrz2 +.zach2 +.zacz2 +.zadź2 +.zadż2 +.zadośću4 +.zado2ść3 +.zadr2 +.zady2s3po +.zadz2 +.zagrz2 +.zai2n3 +.zai2zo +.zain4ic +.zakl2 +.zakr2 +.zakrz2 +.zanie3d2 +.zarż2 +.zarz2 +.zasch2 +.zasm2 +.zasz2 +.zatk2 +.zatr2 +.zb8 +.zc8 +.zd8 +.zde2z3 +.zde3z4awu +.zde3z4el +.zde3z4er +.zde3z4y +.zdy2s3kont +.zdy2s3kred +.zdy2s3kwal +.ze3ć2 +.ze3ł2 +.ze3ś2 +.ze3ź2 +.ze3ż2 +.ze3b2 +.ze3c2 +.ze3d2 +.ze3f2 +.ze3g2 +.ze3h2 +.ze3k2 +.ze3l2 +.ze3m2 +.ze3p2 +.ze3r2 +.ze3s2 +.ze3t2 +.ze3tk2 +.ze3w2 +.ze3z2 +.ze4r3k +.ze4t3e2m1e2s +.ze4t3e2s1e2l +.ze4t3emp +.ze4t3hap +.zech2 +.zecz2 +.zedź2 +.zedż2 +.zedz2 +.zekl2 +.zepch2 +.zerż2 +.zerz2 +.zesch2 +.zesm4 +.zesz2 +.zf8 +.zg8 +.zh8 +.zimno3kr2 +.zj8 +.zk8 +.zl8 +.zm8 +.zmartwy2ch3 +.zmartwychw2 +.zn8 +.znie3ć2 +.znie3ł2 +.znie3ń2 +.znie3ś2 +.znie3ź2 +.znie3ż2 +.znie3b2 +.znie3c2 +.znie3d2 +.znie3f2 +.znie3g2 +.znie3h2 +.znie3k2 +.znie3l2 +.znie3m2 +.znie3n2 +.znie3p2 +.znie3r2 +.znie3s2 +.znie3t2 +.znie3w2 +.znie3z2 +.znie4dź3 +.znie4m3c +.zniech2 +.zniecz2 +.zniedż2 +.zniedz2 +.znierz2 +.zniesz2 +.zo2o3 +.zp8 +.zr8 +.zro2z3 +.zro3z4u +.zs8 +.zt8 +.zv8 +.zw8 +.zx8 +.zz8 +ą1 +ę1 +ó1 +ó4w3cz +ś1c +ź2dź +1ś2ci +2ć1ń +2ć1ś +2ć1ź +2ć1ż +2ć1b +2ć1c +2ć1d +2ć1f +2ć1g +2ć1k +2ć1m +2ć1n +2ć1p +2ć1s +2ć1t +2ć1z +2ł1ć +2ł1ń +2ł1ś +2ł1ź +2ł1ż +2ł1b +2ł1c +2ł1d +2ł1f +2ł1g +2ł1h +2ł1j +2ł1k +2ł1l +2ł1m +2ł1n +2ł1p +2ł1r +2ł1s +2ł1t +2ł1w +2ł1z +2ń1ć +2ń1ł +2ń1ń +2ń1ś +2ń1ź +2ń1ż +2ń1b +2ń1c +2ń1d +2ń1f +2ń1g +2ń1h +2ń1j +2ń1k +2ń1l +2ń1m +2ń1n +2ń1p +2ń1r +2ń1s +2ń1t +2ń1w +2ń1z +2śćc +2ś1ś +2ś1ź +2ś1ż +2ś1b +2ś1d +2ś1f +2ś1g +2ś1k +2ś1p +2ś1s +2ś1t +2ś1z +2ślm +2śln +2ź1ć +2ź1ś +2ź1ż +2ź1b +2ź1c +2ź1d +2ź1f +2ź1g +2ź1k +2ź1l +2ź1m +2ź1n +2ź1p +2ź1s +2ź1t +2ź1w +2ź1z +2ż1ć +2ż1ł +2ż1ń +2ż1ś +2ż1ź +2ż1b +2ż1c +2ż1d +2ż1f +2ż1g +2ż1j +2ż1k +2ż1l +2ż1m +2ż1n +2ż1p +2ż1r +2ż1s +2ż1t +2ż1w +2ż1z +2błk +2b1ć +2b1ń +2b1ś +2b1ź +2b1ż +2b1c +2b1d +2b1f +2b1g +2b1k +2b1m +2b1n +2b1p +2b1s +2b1t +2b1z +2brn +2c1ć +2c1ń +2c1ś +2c1ź +2c1ż +2c1b +2c1d +2c1f +2c1g +2c1k +2c1l +2c1m +2c1n +2c1p +2c1s +2c1t +2ch1ć +2ch1ń +2ch1ś +2ch1ź +2ch1ż +2ch1b +2ch1c +2ch1d +2ch1f +2ch1g +2ch1k +2ch1m +2ch1n +2ch1p +2ch1s +2ch1t +2ch1z +2cz1ć +2cz1ń +2cz1ś +2cz1ź +2cz1ż +2cz1b +2cz1c +2cz1d +2cz1f +2cz1g +2cz1k +2cz1l +2cz1m +2cz1n +2cz1p +2cz1s +2cz1t +2cz1z +2dłb +2dłsz +2dź1ć +2dź1ń +2dź1ś +2dź1ź +2dź1ż +2dź1b +2dź1c +2dź1d +2dź1f +2dź1g +2dź1k +2dź1m +2dź1n +2dź1p +2dź1s +2dź1t +2dź1z +2dż1ć +2dż1ń +2dż1ś +2dż1ź +2dż1ż +2dż1b +2dż1c +2dż1d +2dż1f +2dż1g +2dż1k +2dż1m +2dż1n +2dż1p +2dż1s +2dż1t +2dż1z +2d1ć +2d1ń +2d1ś +2d1b +2d1c +2d1f +2d1g +2d1k +2d1m +2d1n +2d1p +2d1s +2d1t +2drn +2dz1ć +2dz1ń +2dz1ś +2dz1ź +2dz1ż +2dz1b +2dz1c +2dz1d +2dz1f +2dz1g +2dz1k +2dz1l +2dz1m +2dz1n +2dz1p +2dz1s +2dz1t +2dz1z +2f1c +2f1k +2f1m +2f1n +2głb +2g1ć +2g1ń +2g1ś +2g1ź +2g1ż +2g1b +2g1c +2g1d +2g1f +2g1k +2g1m +2g1p +2g1s +2g1t +2g1z +2h1ć +2h1ł +2h1ń +2h1ś +2h1ź +2h1ż +2h1b +2h1c +2h1d +2h1f +2h1g +2h1j +2h1k +2h1l +2h1m +2h1n +2h1p +2h1r +2h1s +2h1t +2h1w +2h1z +2j1ć +2j1ł +2j1ń +2j1ś +2j1ź +2j1ż +2j1b +2j1c +2j1d +2j1f +2j1g +2j1h +2j1k +2j1l +2j1m +2j1n +2j1p +2j1r +2j1s +2j1t +2j1w +2j1z +2kłb +2k1ć +2k1ń +2k1ś +2k1ź +2k1ż +2k1b +2k1c +2k1d +2k1f +2k1g +2k1m +2k1n +2k1p +2k1s +2k1sz +2k1t +2k1z +2l1ć +2l1ł +2l1ń +2l1ś +2l1ź +2l1ż +2l1b +2l1c +2l1d +2l1f +2l1g +2l1h +2l1j +2l1k +2l1m +2l1n +2l1p +2l1r +2l1s +2l1t +2l1w +2l1z +2m1ć +2m1ł +2m1ń +2m1ś +2m1ź +2m1ż +2m1b +2m1c +2m1d +2m1f +2m1g +2m1h +2m1j +2m1k +2m1l +2m1n +2m1p +2m1r +2m1s +2m1t +2m1w +2m1z +2n1ć +2n1ł +2n1ń +2n1ś +2n1ź +2n1ż +2n1b +2n1c +2n1d +2n1f +2n1g +2n1h +2n1j +2n1k +2n1l +2n1m +2n1p +2n1r +2n1s +2n1t +2n1w +2n1z +2ntn +2p1ć +2p1ń +2p1ś +2p1ź +2p1ż +2p1b +2p1c +2p1d +2p1f +2p1g +2p1k +2p1m +2p1n +2p1s +2p1sz +2p1t +2p1z +2pln +2r1ć +2r1ł +2r1ń +2r1ś +2r1ź +2r1ż +2r1b +2r1c +2r1d +2r1f +2r1g +2r1h +2r1j +2r1k +2r1l +2r1m +2r1n +2r1p +2r1s +2r1t +2r1w +2rz1ć +2rz1ł +2rz1ń +2rz1ś +2rz1ź +2rz1ż +2rz1b +2rz1c +2rz1d +2rz1f +2rz1g +2rz1h +2rz1j +2rz1k +2rz1l +2rz1m +2rz1n +2rz1p +2rz1r +2rz1s +2rz1t +2rz1w +2słb +2s1ź +2s1ż +2s1b +2s1d +2s1f +2s1g +2s1s +2snk +2stk +2stn +2stsz +2sz1ć +2sz1ś +2sz1c +2sz1f +2sz1k +2sz1l +2sz1m +2sz1n +2sz1p +2sz1s +2sz1t +2sz1w +2sz1z +2szln +2t1ć +2t1ń +2t1ś +2t1ź +2t1ż +2t1b +2t1c +2t1d +2t1f +2t1g +2t1k +2t1m +2t1n +2t1p +2t1s +2t1z +2tln +2trk +2trzn +2w1ć +2w1ł +2w1ń +2w1ś +2w1ź +2w1ż +2w1b +2w1c +2w1d +2w1f +2w1g +2w1j +2w1k +2w1l +2w1m +2w1n +2w1p +2w1r +2w1s +2w1t +2w1z +2z1ć +2z1ś +2z1c +2z1d +2z1f +2z1k +2z1p +2z1s +2z1t +2zdk +2zdn +3d2niow +3k2sz2t +3m2k2n +3m2nest +3m2nezj +3m2sk2n +3p2neu +3w2ład +3w2łos +3w2czas +4ć3ć +4ł3ł +4ź3ź +4ż3ż +4b3b +4c3c +4d3d +4f3f +4g3g +4h3h +4j3j +4k3k +4l3l +4m3m +4n3n +4p3p +4r3r +4t3t +4w3w +4z3z +8ć. +8ćć. +8ćł. +8ćń. +8ćś. +8ćź. +8ćż. +8ćb. +8ćc. +8ćd. +8ćf. +8ćg. +8ćh. +8ćj. +8ćk. +8ćl. +8ćm. +8ćn. +8ćp. +8ćr. +8ćs. +8ćt. +8ćv. +8ćw. +8ćx. +8ćz. +8ł. +8łć. +8łł. +8łń. +8łś. +8łź. +8łż. +8łb. +8łc. +8łd. +8łf. +8łg. +8łh. +8łj. +8łk. +8łl. +8łm. +8łn. +8łp. +8łr. +8łs. +8łt. +8łv. +8łw. +8łx. +8łz. +8ń. +8ńć. +8ńł. +8ńń. +8ńś. +8ńź. +8ńż. +8ńb. +8ńc. +8ńd. +8ńf. +8ńg. +8ńh. +8ńj. +8ńk. +8ńl. +8ńm. +8ńn. +8ńp. +8ńr. +8ńs. +8ńt. +8ńv. +8ńw. +8ńx. +8ńz. +8ś. +8ść. +8śł. +8śń. +8śś. +8śź. +8śż. +8śb. +8śc. +8śd. +8śf. +8śg. +8śh. +8śj. +8śk. +8śl. +8śm. +8śn. +8śp. +8śr. +8śs. +8śt. +8śv. +8św. +8śx. +8śz. +8ź. +8źć. +8źł. +8źń. +8źś. +8źź. +8źż. +8źb. +8źc. +8źd. +8źf. +8źg. +8źh. +8źj. +8źk. +8źl. +8źm. +8źn. +8źp. +8źr. +8źs. +8źt. +8źv. +8źw. +8źx. +8źz. +8ż. +8żć. +8żł. +8żń. +8żś. +8żź. +8żż. +8żb. +8żc. +8żd. +8żf. +8żg. +8żh. +8żj. +8żk. +8żl. +8żm. +8żn. +8żp. +8żr. +8żs. +8żt. +8żv. +8żw. +8żx. +8żz. +8b. +8bć. +8bł. +8bń. +8bś. +8bź. +8bż. +8bb. +8bc. +8bd. +8bf. +8bg. +8bh. +8bj. +8bk. +8bl. +8bm. +8bn. +8bp. +8br. +8brz. +8bs. +8bt. +8bv. +8bw. +8bx. +8bz. +8c. +8cć. +8cł. +8cń. +8cś. +8cź. +8cż. +8cb. +8cc. +8cd. +8cf. +8cg. +8ch. +8chł. +8chrz. +8chw. +8cj. +8ck. +8cl. +8cm. +8cn. +8cp. +8cr. +8cs. +8ct. +8cv. +8cw. +8cx. +8cz. +8czt. +8d. +8dć. +8dł. +8dń. +8dś. +8dź. +8dż. +8db. +8dc. +8dd. +8df. +8dg. +8dh. +8dj. +8dk. +8dl. +8dm. +8dn. +8dp. +8dr. +8drz. +8ds. +8dt. +8dv. +8dw. +8dx. +8dz. +8f. +8fć. +8fł. +8fń. +8fś. +8fź. +8fż. +8fb. +8fc. +8fd. +8ff. +8fg. +8fh. +8fj. +8fk. +8fl. +8fm. +8fn. +8fp. +8fr. +8fs. +8ft. +8fv. +8fw. +8fx. +8fz. +8g. +8gć. +8gł. +8gń. +8gś. +8gź. +8gż. +8gb. +8gc. +8gd. +8gf. +8gg. +8gh. +8gj. +8gk. +8gl. +8gm. +8gn. +8gp. +8gr. +8gs. +8gt. +8gv. +8gw. +8gx. +8gz. +8h. +8hć. +8hł. +8hń. +8hś. +8hź. +8hż. +8hb. +8hc. +8hd. +8hf. +8hg. +8hh. +8hj. +8hk. +8hl. +8hm. +8hn. +8hp. +8hr. +8hs. +8ht. +8hv. +8hw. +8hx. +8hz. +8j. +8jć. +8jł. +8jń. +8jś. +8jź. +8jż. +8jb. +8jc. +8jd. +8jf. +8jg. +8jh. +8jj. +8jk. +8jl. +8jm. +8jn. +8jp. +8jr. +8js. +8jt. +8jv. +8jw. +8jx. +8jz. +8k. +8kć. +8kł. +8kń. +8kś. +8kź. +8kż. +8kb. +8kc. +8kd. +8kf. +8kg. +8kh. +8kj. +8kk. +8kl. +8km. +8kn. +8kp. +8kr. +8ks. +8kst. +8kt. +8kv. +8kw. +8kx. +8kz. +8l. +8lć. +8lł. +8lń. +8lś. +8lź. +8lż. +8lb. +8lc. +8ld. +8lf. +8lg. +8lh. +8lj. +8lk. +8ll. +8lm. +8ln. +8lp. +8lr. +8ls. +8lt. +8lv. +8lw. +8lx. +8lz. +8m. +8mć. +8mł. +8mń. +8mś. +8mź. +8mż. +8mb. +8mc. +8md. +8mf. +8mg. +8mh. +8mj. +8mk. +8ml. +8mm. +8mn. +8mp. +8mr. +8ms. +8mst. +8mt. +8mv. +8mw. +8mx. +8mz. +8n. +8nć. +8nł. +8nń. +8nś. +8nź. +8nż. +8nb. +8nc. +8nd. +8nf. +8ng. +8nh. +8nj. +8nk. +8nl. +8nm. +8nn. +8np. +8nr. +8ns. +8nt. +8nv. +8nw. +8nx. +8nz. +8p. +8pć. +8pł. +8pń. +8pś. +8pź. +8pż. +8pb. +8pc. +8pd. +8pf. +8pg. +8ph. +8pj. +8pk. +8pl. +8pm. +8pn. +8pp. +8pr. +8prz. +8ps. +8pt. +8pv. +8pw. +8px. +8pz. +8r. +8rć. +8rł. +8rń. +8rś. +8rź. +8rż. +8rb. +8rc. +8rd. +8rf. +8rg. +8rh. +8rj. +8rk. +8rl. +8rm. +8rn. +8rp. +8rr. +8rs. +8rsz. +8rt. +8rv. +8rw. +8rx. +8rz. +8rzł. +8s. +8sć. +8sł. +8sń. +8sś. +8sź. +8sż. +8sb. +8sc. +8sch. +8sd. +8sf. +8sg. +8sh. +8sj. +8sk. +8skrz. +8sl. +8sm. +8sn. +8sp. +8sr. +8ss. +8st. +8str. +8strz. +8stw. +8sv. +8sw. +8sx. +8sz. +8szcz. +8szczb. +8szk. +8szn. +8szt. +8sztr. +8t. +8tć. +8tł. +8tń. +8tś. +8tź. +8tż. +8tb. +8tc. +8td. +8tf. +8tg. +8th. +8tj. +8tk. +8tl. +8tm. +8tn. +8tp. +8tr. +8trz. +8ts. +8tt. +8tv. +8tw. +8tx. +8tz. +8v. +8vć. +8vł. +8vń. +8vś. +8vź. +8vż. +8vb. +8vc. +8vd. +8vf. +8vg. +8vh. +8vj. +8vk. +8vl. +8vm. +8vn. +8vp. +8vr. +8vs. +8vt. +8vv. +8vw. +8vx. +8vz. +8w. +8wć. +8wł. +8wń. +8wś. +8wź. +8wż. +8wb. +8wc. +8wd. +8wf. +8wg. +8wh. +8wj. +8wk. +8wl. +8wm. +8wn. +8wp. +8wr. +8ws. +8wt. +8wv. +8ww. +8wx. +8wz. +8x. +8xć. +8xł. +8xń. +8xś. +8xź. +8xż. +8xb. +8xc. +8xd. +8xf. +8xg. +8xh. +8xj. +8xk. +8xl. +8xm. +8xn. +8xp. +8xr. +8xs. +8xt. +8xv. +8xw. +8xx. +8xz. +8z. +8zć. +8zł. +8zń. +8zś. +8zź. +8zż. +8zb. +8zc. +8zd. +8zdr. +8zdrz. +8zf. +8zg. +8zh. +8zj. +8zk. +8zl. +8zm. +8zn. +8zp. +8zr. +8zs. +8zt. +8zv. +8zw. +8zx. +8zz. +a1 +a2u +a2y +aa2 +ae2 +ai2 +ao2 +be2eth +be2f3sz2 +be2k1hend +bi2n3o2ku +bi2sz3kop +bi2z3ne2s3m +bi2z3nes +birmin2g1ham +blo2k1hauz +bo2s3ma +br2d +bro2a2d3way +bu2sz3me +buk2sz3pan +busine2ss3m +busines2s +c4h +c4z +cal2d1well +ch2ł +ch2j +ch2l +ch2r +ch2w +chus1t +cu2r7zon +dż2ł +dż2j +dż2l +dż2r +dż2w +dże4z3b +dże4z3m +d4ź +d4ż +d4z +deut4sch3land +drz2w +du2sz3past +e1 +e2r5zac +e2u +e2y +e3u2sz +ea2 +ee2 +ei2 +eo2 +fi2s3harm +fi2sz3bin +fo2k2s3t +fo2r5zac +fol2k1lor +fos2f1a2zot +ga3d2get +gado3p2ta +gol2f3s +golfsz2 +gran2d1ilo +gro4t3r +hi2sz3p +hu2cz1w +hu2x3ley +i1 +i2ą +i2ę +i2ó +i2a +i2e +i2i +i2o +i2u +i2y +in4nsbruck +in4sbruc +j2t1ł +j2t1r +ja4z4z3b +ja4z4z3m +karl2s1kron +karl2s1ruhe +kir2chhoff +kongre2s3m +led1w +lu2ft3waffe +lu2ks1fer +ly2o +ma2r5zł +ma2r5zl +ma2r5zn +mi2sz1masz +mie2r5zł +mie2r5zi +mon2t3real +moza2i3k +mu2r7zasich3l +na4ł3kows +na4r3v +o1 +o2y +oa2 +och3mistrz +oe2 +of2f3set +oi2 +oo2 +ou2 +pa2n3a2mer +pa2s3cal +pa2s3ch +połu3d2ni +po3d4nieprz +po3m2ną +po3m2nę +po3m2ni +po4rt2s3mo2uth +po4rt3land +poli3e2t +poli3u2re +powsze3d2ni +pr2chal +pre2sz3pa +r4z +ro2e3nt2gen +ro2k3rocz +ro2s3to3c2k +s4z +se2t3le +sko2r5zoner +sm2r +sowi3z2 +sy2n3opt +sy2s1tem +sza2sz1ły +sze2z1long +sze4ść +szto2k1holm +szyn2k1was +to3y2o3t +turboo2d3rzut +tygo3d2ni +u1 +u2y +ua2 +ue2 +ui2 +uo2 +uu2 +vo2lk2s3 +we2e2k1end +we4st3f +we4st3m +y1 +ya2 +ye2 +yi2 +yo2 +yu2 +ze4p3p +} +\hyphenation{ +be-zach +be-zami +by-naj-mniej +gdzie-nie-gdzie +ina-czej +na-dal +ni-gdy +ni-gdzie +niech-że +niech-by +ow-szem +pó-łach +pó-łami +pó-łek +pod-ów-czas +przy-naj-mniej +skąd-inąd +tró-jach +tró-jami +tró-jek +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index e1376555b..473cf0b09 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -119,7 +119,14 @@ fn new(s: &mut Session, v: &Value) -> CmdResult { "letter" => crate::sample::letter(), "resume" => crate::sample::resume(), "report" => crate::sample::report(), - _ => Document::new(), + _ => { + // A blank document is written in the editing language (the templates' text is English). + let mut d = Document::new(); + if let Some(lang) = &s.editing_lang { + d.styles.default_chr.lang = Some(lang.clone()); + } + d + } }; s.set_document(doc); s.path = None; @@ -132,12 +139,19 @@ fn open(s: &mut Session, v: &Value) -> CmdResult { return sel_result(s); }; let password = p::str(v, "password"); - let (doc, encrypted) = if let Some(data) = p::str(v, "data") { + let (mut doc, encrypted) = if let Some(data) = p::str(v, "data") { let bytes = super::insert::base64_decode(data).ok_or_else(|| CmdError::Params("bad base64".into()))?; crate::io::open_bytes_with(path, &bytes, password).map_err(CmdError::Failed)? } else { crate::io::open_path_with(std::path::Path::new(path), password).map_err(CmdError::Failed)? }; + // Plain text and Markdown carry no language: they are in the editing language. + let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase(); + if let Some(lang) = &s.editing_lang + && matches!(ext.as_str(), "txt" | "text" | "md" | "markdown") + { + doc.styles.default_chr.lang = Some(lang.clone()); + } s.set_document(doc); s.path = Some(path.into()); // Like Word, a document opened with a password is saved with it again. diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index ef9a353e5..069310090 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -602,6 +602,9 @@ pub fn state(s: &Session) -> Value { "highlight": same(&|r| json!(r.highlight.map(|c| c.hex()))), "style": style, "styleName": style_name, + // The proofing language (a BCP 47 tag; null when mixed or unset) and "Do not check". + "lang": same(&|r| json!(r.lang)), + "noProof": all.iter().all(|r| r.no_proof), }) } diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index b89f0fab6..228f45fa4 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -738,15 +738,13 @@ fn word_count(s: &mut Session, v: &Value) -> CmdResult { })) } -/// Issues (spelling + grammar) in one paragraph as positions. +/// Issues (spelling + grammar) in one paragraph as positions, each in its run's language and +/// skipping text left unchecked — the same issues the layout underlines. fn para_issues(s: &Session, story: StoryRef, path: &wordcraft_doc::Path) -> Vec<(Pos, Pos, wordcraft_proof::Issue)> { let Some(p) = s.doc.para(story, path) else { return Vec::new() }; - let text = wordcraft_layout::para::proof_text(p); - let mut v: Vec = wordcraft_proof::check_spelling_with(&text, &s.prefs.spelling()); - v.extend(wordcraft_proof::check_grammar(&text)); - v.sort_by_key(|i| i.start); - v.into_iter() - .filter(|i| !p.run_ranges().any(|(r, c)| r.start < i.end && i.start < r.end && (c.no_proof == Some(true) || c.link.is_some()))) + let runs = wordcraft_layout::para::proof_runs(&s.doc, p, None); + wordcraft_layout::para::paragraph_issues(p, &runs, &s.prefs.spelling(), true) + .into_iter() .map(|i| (Pos { story, path: path.clone(), off: i.start }, Pos { story, path: path.clone(), off: i.end }, i)) .collect() } @@ -758,8 +756,12 @@ fn issue_at(s: &Session, at: &Pos) -> Option<(Pos, Pos, wordcraft_proof::Issue)> fn issue_json(s: &Session, a: &Pos, b: &Pos, i: &wordcraft_proof::Issue) -> Value { let word = s.doc.para_at(a).and_then(|p| p.text.get(a.off..b.off)).unwrap_or("").to_string(); - let sugg = if i.kind == wordcraft_proof::IssueKind::Spelling { wordcraft_proof::suggest(&word, 6) } else { i.suggestions.clone() }; - json!({"start": pos_json(a), "end": pos_json(b), "text": word, "kind": if i.kind == wordcraft_proof::IssueKind::Spelling { "spelling" } else { "grammar" }, "message": i.message, "suggestions": sugg}) + let sugg = if i.kind == wordcraft_proof::IssueKind::Spelling { wordcraft_proof::suggest_in(&word, 6, i.lang) } else { i.suggestions.clone() }; + json!({ + "start": pos_json(a), "end": pos_json(b), "text": word, + "kind": if i.kind == wordcraft_proof::IssueKind::Spelling { "spelling" } else { "grammar" }, + "message": i.message, "suggestions": sugg, "lang": i.lang.code(), + }) } /// F7: select the next issue after the caret and return it with suggestions. diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index 6ad3fc46c..3e137f86e 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -577,7 +577,7 @@ pub fn specs() -> Vec { }) .params(r#"{"format": "decimal|upperRoman|lowerLetter…", "text"?: "%1.", "start"?: n}"#), 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)}"#, + r#"{"lang"?: "en-US|en-GB|pl-PL|fr-FR|…" (BCP 47; default en-US), "noProof"?: bool, "detect"?: bool (detect language automatically, a preference), "default"?: bool (also the document's default language)}"#, ), CommandSpec::new("review.showMarkup", "Show Markup", "Review › Tracking", |s, v| { s.view.show_markup = p::bool(v, "value").unwrap_or(!s.view.show_markup); @@ -633,8 +633,10 @@ pub fn specs() -> Vec { /// 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"))), + Some(t) => Some( + wordcraft_proof::lang::normalize_tag(t) + .ok_or_else(|| CmdError::Params(format!("`lang` must be a language tag such as pl-PL or en-US, not {t:?}")))?, + ), None => None, }; let no_proof = p::bool(v, "noProof"); @@ -643,15 +645,14 @@ fn language(s: &mut Session, v: &Value) -> CmdResult { 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()))?; + let l = lang.clone().ok_or_else(|| CmdError::Params("`default` needs `lang`".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()); + n.chr.lang = Some(l); } - 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()), diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 3d5d6db78..85f07b674 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -297,6 +297,10 @@ pub struct Session { pub keymap: crate::KeyMap, /// Custom ribbon and Quick Access Toolbar (`tools.customizeRibbon`); the front end saves it too. pub ribbon: crate::RibbonLayout, + /// The language new blank documents (and opened plain text) are written in, a BCP 47 tag; + /// `None` is WordCraft's default, US English. The front end sets it from the interface + /// language with [`Session::set_editing_language`] (a Polish interface writes Polish). + pub editing_lang: Option, /// Editing inside an equation: which one and the caret in it. pub math: Option, /// Equations are typed in LaTeX rather than the linear format. @@ -460,6 +464,7 @@ impl Session { prefs: Prefs::default(), keymap: crate::KeyMap::default(), ribbon: crate::RibbonLayout::default(), + editing_lang: None, read_aloud: Default::default(), password: None, column: None, @@ -611,6 +616,24 @@ impl Session { self.cache.clear(); } + /// Set the language new documents are written in ([`Session::editing_lang`]; `None` or a + /// malformed tag: US English). A document nobody has touched yet — untitled, unchanged, no + /// undo steps, no text — switches too, so the blank document a session starts with follows + /// the interface language. Nothing else changes and no undo step is added. + pub fn set_editing_language(&mut self, tag: Option<&str>) { + let tag = tag.and_then(wordcraft_proof::lang::normalize_tag); + if self.editing_lang == tag { + return; + } + self.editing_lang = tag; + let untouched = self.path.is_none() && !self.dirty && self.history.is_empty() && self.redo.is_empty(); + if untouched && self.doc.plain_text(StoryRef::Body).trim().is_empty() && self.doc.paragraph_count() <= 1 { + self.doc.styles.default_chr.lang = Some(self.editing_lang.clone().unwrap_or_else(|| "en-US".into())); + self.rev = self.rev.wrapping_add(1); + self.relayout(); + } + } + /// Snapshot for undo before a change. pub fn checkpoint(&mut self, label: &str) { if label == "Typing" && self.typing_open { @@ -794,6 +817,8 @@ impl Session { prefs: _, keymap: _, ribbon: _, + // A preference for new documents, like `prefs`: not part of this document. + editing_lang: _, // Equation editing mode, like `view`: not part of the document or its history. math: _, math_latex: _, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ffa7664a0..b21b8ab73 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3693,6 +3693,127 @@ fn note_options_per_section_and_convert_notes() { assert!(s.run("references.convertNotes", &json!({"to": "sideways"})).is_err()); } +/// A tiny synthetic Polish Hunspell dictionary (written for these tests). Real dictionaries are +/// installed on the system, never bundled; every Polish test installs this same one. +fn polish_test_dictionary() { + let aff = "SET UTF-8\nTRY aioeznrwcyszżtkpdmłjlubąęgh\nMAP 2\nMAP oó\nMAP zż\nREP 2\nREP ż rz\nREP rz ż\n"; + let dic = "12\npłynie\nrzeka\na\nto\nwiem\nże\ntak\nw\nogóle\nzażółć\ngęślą\njaźń\n"; + wordcraft_proof::pl::install_dictionary(aff, dic).unwrap(); +} + +/// Review › Language and the proofing commands work in Polish end to end: the language makes +/// the document's words Polish, F7 and the issue list use the Polish dictionary and grammar, +/// suggestions come from it, and a suggestion fixes the text. +#[test] +fn polish_proofing_through_the_review_commands() { + polish_test_dictionary(); + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "Płynie żeka, a wogóle to wiem że tak."})); + // English by default: the Polish words are English misspellings. + assert!(run(&mut s, "review.issues", json!({})).as_array().unwrap().len() >= 5); + run(&mut s, "select.all", json!({})); + run(&mut s, "review.language", json!({"lang": "pl_PL", "default": true})); + assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("pl-PL"), "the tag is normalized"); + assert_eq!(run(&mut s, "format.state", json!({}))["lang"], "pl-PL"); + let issues = run(&mut s, "review.issues", json!({})); + let arr = issues.as_array().unwrap(); + let has = |text: &str, kind: &str, sugg: &str| { + arr.iter() + .any(|i| i["text"] == text && i["kind"] == kind && i["lang"] == "pl" && i["suggestions"].as_array().unwrap().iter().any(|x| x == sugg)) + }; + assert!(has("żeka", "spelling", "rzeka"), "{issues}"); + assert!(has("wogóle", "spelling", "w ogóle"), "{issues}"); + assert!(has(" że", "grammar", ", że"), "{issues}"); + assert_eq!(arr.len(), 3, "{issues}"); + assert!(arr.iter().any(|i| i["message"] == "Brak przecinka przed „że”"), "{issues}"); + // F7 from the start selects the first issue; Change applies a suggestion. + run(&mut s, "caret.docStart", json!({})); + let first = run(&mut s, "review.spelling", json!({})); + assert_eq!(first["text"], "żeka"); + run(&mut s, "review.applySuggestion", json!({"text": "rzeka"})); + assert!(text(&s).starts_with("Płynie rzeka,"), "{}", text(&s)); + // Add to Dictionary works for Polish words too. + run(&mut s, "review.addToDictionary", json!({"word": "wogóle"})); + let left = run(&mut s, "review.issues", json!({})); + assert_eq!(left.as_array().unwrap().len(), 1, "{left}"); +} + +/// `review.language`: tags are validated and normalized, `noProof` alone keeps the language, +/// no parameters keep the old meaning (US English), and `default` needs a language. +#[test] +fn review_language_validates_tags_and_marks_text_unchecked() { + polish_test_dictionary(); + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "Hello wrold"})); + run(&mut s, "select.all", json!({})); + assert!(s.run("review.language", &json!({"lang": "not a tag!"})).is_err()); + assert!(s.run("review.language", &json!({"lang": ""})).is_err()); + assert!(s.run("review.language", &json!({"default": true, "noProof": true})).is_err(), "default needs a language"); + assert_eq!(text(&s), "Hello wrold", "a refused command changes nothing"); + run(&mut s, "review.language", json!({"noProof": true})); + let st = run(&mut s, "format.state", json!({})); + assert_eq!((st["noProof"].clone(), st["lang"].clone()), (json!(true), json!("en-US"))); + assert_eq!(run(&mut s, "review.issues", json!({})), json!([])); + run(&mut s, "review.language", json!({"noProof": false})); + assert_eq!(run(&mut s, "review.issues", json!({})).as_array().unwrap().len(), 1); + run(&mut s, "review.language", json!({"lang": "PL"})); + assert_eq!(run(&mut s, "format.state", json!({}))["lang"], "pl"); + run(&mut s, "review.language", json!({})); + assert_eq!(run(&mut s, "format.state", json!({}))["lang"], "en-US"); + // A language without proofing tools: no marks. + run(&mut s, "review.language", json!({"lang": "fr-FR"})); + assert_eq!(run(&mut s, "review.issues", json!({})), json!([])); +} + +/// The editing language (set by the front end from the interface language): new blank +/// documents and opened plain text are written in it; the untouched first document switches, +/// a document with changes never does, and templates stay English. +#[test] +fn new_documents_follow_the_editing_language() { + polish_test_dictionary(); + let mut s = s(); + s.set_editing_language(Some("pl-PL")); + assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("pl-PL"), "the untouched blank document switches"); + assert!(!s.dirty && !s.can_undo(), "no change to save or undo"); + run(&mut s, "text.insert", json!({"text": "Zażółć gęślą jaźń."})); + assert_eq!(run(&mut s, "review.issues", json!({})), json!([])); + s.set_editing_language(None); + assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("pl-PL"), "a document with changes keeps its language"); + s.set_editing_language(Some("pl")); + run(&mut s, "file.new", json!({})); + assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("pl")); + run(&mut s, "file.new", json!({"template": "letter"})); + assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("en-US"), "templates are English text"); + let data = crate::cmd::insert::base64_encode("Płynie rzeka.".as_bytes()); + run(&mut s, "file.open", json!({"path": "notatka.txt", "data": data})); + assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("pl")); + assert_eq!(run(&mut s, "review.issues", json!({})), json!([])); + // Malformed tags fall back to US English. + s.set_editing_language(Some("not a tag")); + assert_eq!(s.editing_lang, None); +} + +/// A Polish document keeps its languages through .docx: the default (`w:docDefaults`) and an +/// English run (`w:lang`) come back, and so does the proofing. +#[test] +fn proofing_languages_survive_a_docx_round_trip() { + polish_test_dictionary(); + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "Płynie żeka. Hello wrold."})); + run(&mut s, "select.all", json!({})); + run(&mut s, "review.language", json!({"lang": "pl-PL", "default": true})); + run(&mut s, "select.text", json!({"text": "Hello wrold."})); + run(&mut s, "review.language", json!({"lang": "en-GB"})); + let bytes = crate::io::save_bytes("polski.docx", &s.doc).unwrap(); + let doc = crate::io::open_bytes("polski.docx", &bytes).unwrap(); + assert_eq!(doc.styles.default_chr.lang.as_deref(), Some("pl-PL")); + let mut back = Session::new(doc); + let issues = run(&mut back, "review.issues", json!({})); + let found: Vec<(String, String)> = + issues.as_array().unwrap().iter().map(|i| (i["text"].as_str().unwrap().to_string(), i["lang"].as_str().unwrap().to_string())).collect(); + assert_eq!(found, [("żeka".to_string(), "pl".to_string()), ("wrold".to_string(), "en".to_string())], "{issues}"); +} + #[test] fn hide_white_space_toggles_view_state_only() { let mut s = s(); diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 93483ea3a..4ab1dafe0 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -994,8 +994,11 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { let math_props = doc.settings.math.clone().unwrap_or_default(); // The byte ranges of runs left out of the layout, in order, adjacent runs merged. let mut left: Vec> = Vec::new(); + // Each run's proofing language and whether it is checked (spelling, grammar, hyphenation). + let mut runs: Vec = Vec::new(); for (range, props) in p.run_ranges() { let rc = resolve(props); + runs.push(ProofRun::of(range.clone(), &rc)); let Some(text) = p.text.get(range.clone()) else { continue }; if left_out(&rc, b.env.show_hidden, b.env.hide_deleted) { match left.last_mut() { @@ -1297,7 +1300,7 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { text_len: p.text.len(), has_page_fields, notes, - issues: env.proofing.map(|o| proof_issues(p, &o)).unwrap_or_default(), + issues: env.proofing.map(|o| proof_issues(p, &runs, &o)).unwrap_or_default(), drop_cap, hyph_after: Vec::new(), bidi_levels: b.levels, @@ -1309,7 +1312,7 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { enclosures, }; let auto = env.doc.settings.auto_hyphenation && !pl.rp.suppress_hyphens; - pl.hyph_after = hyphenation_points(&text, to_para, &pl, auto, env.doc.settings.hyphenate_caps); + pl.hyph_after = hyphenation_points(&text, to_para, &pl, auto, env.doc.settings.hyphenate_caps, &runs); pl.left_out = left; for k in pl.hyph_after.clone() { if let Some(c) = pl.clusters.get_mut(k as usize) { @@ -2074,10 +2077,11 @@ fn hyphen_glyph(pl: &ParaLayout, style: u16, cache: &mut Vec<(u16, u32, f32)>) - } /// Clusters after which a line may end with a hyphen: soft hyphens always, and dictionary or -/// pattern hyphenation points of each word when automatic hyphenation is on. Both are found in -/// `text`, the paragraph as laid out (without what the layout leaves out, [`left_out`]), and -/// `to_para` maps the end of a char there to the paragraph's text. -fn hyphenation_points(text: &str, to_para: impl Fn(usize) -> Option, pl: &ParaLayout, auto: bool, caps: bool) -> Vec { +/// pattern hyphenation points of each word, in the language of its run ([`ProofRun`]), when +/// automatic hyphenation is on (words in capitals only with `caps`). Both are found in `text`, the paragraph as laid out (without what +/// the layout leaves out, [`left_out`]), and `to_para` maps the end of a char there to the +/// paragraph's text. +fn hyphenation_points(text: &str, to_para: impl Fn(usize) -> Option, pl: &ParaLayout, auto: bool, caps: bool, runs: &[ProofRun]) -> Vec { let mut bytes: Vec = text.char_indices().filter(|(_, c)| *c == SOFT_HYPHEN).map(|(i, c)| i + c.len_utf8()).collect(); if auto { let lim = wordcraft_proof::hyphen::Limits::default(); @@ -2095,8 +2099,11 @@ fn hyphenation_points(text: &str, to_para: impl Fn(usize) -> Option, pl: && !w.chars().any(wordcraft_fonts::is_rtl) && (caps || !all_caps(w)) { + // The word's language: its first letter's run, found by its place in `p.text`. + let first = w.chars().next().map_or(1, char::len_utf8); + let lang = to_para(a + first).map(|e| lang_at(runs, e.saturating_sub(first))).unwrap_or_default(); let offs: Vec = w.char_indices().map(|(o, _)| o).collect(); - for pt in wordcraft_proof::hyphen::hyphen_points(w, &lim) { + for pt in wordcraft_proof::hyphen::hyphen_points_in(w, &lim, lang) { if let Some(o) = offs.get(pt) { bytes.push(a + o); } @@ -2274,24 +2281,54 @@ impl ParaLayout { } } -/// Spelling and grammar issues in a paragraph (skipping "do not check" and hidden runs). -fn proof_issues(p: &Paragraph, opts: &crate::ProofOptions) -> Vec<(usize, usize, bool)> { +/// One run's proofing: its bytes in the paragraph, its language (`w:lang`, resolved through the +/// styles: the document default, paragraph and character styles, direct formatting) and whether +/// it is left unchecked ("Do not check spelling or grammar", hidden text, hyperlinks). +#[derive(Clone, Debug, PartialEq)] +pub struct ProofRun { + pub range: std::ops::Range, + pub lang: wordcraft_proof::ProofLang, + pub skip: bool, +} + +impl ProofRun { + fn of(range: std::ops::Range, rc: &ResolvedChar) -> ProofRun { + ProofRun { range, lang: wordcraft_proof::ProofLang::from_tag(rc.lang.as_deref()), skip: rc.no_proof || rc.hidden || rc.link.is_some() } + } +} + +/// The proofing runs of a paragraph, in order. `table` is the cell's table-style character +/// formatting ([`CellText::chr`]) for a paragraph in a table, as layout resolves it. +pub fn proof_runs(doc: &Document, p: &Paragraph, table: Option<&CharProps>) -> Vec { + let style = p.props.style.as_deref(); + p.run_ranges().map(|(r, c)| ProofRun::of(r, &doc.styles.resolve_char_in(style, table, c))).collect() +} + +/// The language of the run holding byte `off` (English past the last run). +fn lang_at(runs: &[ProofRun], off: usize) -> wordcraft_proof::ProofLang { + let k = runs.partition_point(|r| r.range.end <= off); + runs.get(k).filter(|r| r.range.start <= off).map(|r| r.lang).unwrap_or_default() +} + +/// Spelling and grammar issues in a paragraph, in order: each word in its run's language +/// ([`wordcraft_proof::check_text_with`]: what spelling skips, and grammar only with `grammar`), +/// and none touching a run left unchecked. +pub fn paragraph_issues(p: &Paragraph, runs: &[ProofRun], spelling: &wordcraft_proof::Options, grammar: bool) -> Vec { if p.text.trim().is_empty() || p.text.len() > 100_000 { return Vec::new(); } let text = proof_text(p); - let skip = |a: usize, b: usize| { - p.run_ranges().any(|(r, c)| r.start < b && a < r.end && (c.no_proof == Some(true) || c.hidden == Some(true) || c.link.is_some())) - }; - let mut v: Vec<(usize, usize, bool)> = wordcraft_proof::check_spelling_with(&text, &opts.spelling) + let spans: Vec<(std::ops::Range, wordcraft_proof::ProofLang)> = runs.iter().map(|r| (r.range.clone(), r.lang)).collect(); + let skip = |a: usize, b: usize| runs.iter().any(|r| r.skip && r.range.start < b && a < r.range.end); + wordcraft_proof::check_text_with(&text, &spans, spelling, grammar).into_iter().filter(|i| !skip(i.start, i.end)).collect() +} + +/// Spelling and grammar issues in a paragraph as squiggles: (start, end, is grammar). +fn proof_issues(p: &Paragraph, runs: &[ProofRun], opts: &crate::ProofOptions) -> Vec<(usize, usize, bool)> { + paragraph_issues(p, runs, &opts.spelling, opts.grammar) .into_iter() - .filter(|i| !skip(i.start, i.end)) - .map(|i| (i.start, i.end, false)) - .collect(); - if opts.grammar { - v.extend(wordcraft_proof::check_grammar(&text).into_iter().filter(|i| !skip(i.start, i.end)).map(|i| (i.start, i.end, true))); - } - v + .map(|i| (i.start, i.end, i.kind == wordcraft_proof::IssueKind::Grammar)) + .collect() } /// Text for proofing with the same byte offsets: inline objects and tracked deletions become diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index fd7720502..6f9ef2af8 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -3831,6 +3831,64 @@ fn endnotes_at_section_end_and_footnotes_below_text() { assert!(below > text.top + text.height && below < bottom - 100.0, "{below} {bottom} {text:?}"); } +/// Each run is hyphenated and proofed in its own language (`w:lang`, resolved through the +/// styles): Polish patterns and dictionary for Polish runs, English ones for English runs. +#[test] +fn hyphenation_and_proofing_follow_each_runs_language() { + // A tiny synthetic Polish dictionary (real ones are installed on the system, not bundled). + wordcraft_proof::pl::install_dictionary("SET UTF-8\nSFX B Y 1\nSFX B y ego y\n", "2\nkonstytucyjny/B\npłynie\n").unwrap(); + let text = "Konstytucyjnego żeka typography beautifull"; + let mut d = Document::from_text(text); + d.settings.auto_hyphenation = true; + let eng = text.find("typography").unwrap(); + d.format_range(&Pos::body(0, 0), &Pos::body(0, eng), &|c| c.lang = Some("pl-PL".into())).unwrap(); + d.format_range(&Pos::body(0, eng), &Pos::body(0, text.len()), &|c| c.lang = Some("en-US".into())).unwrap(); + let mut c = LayoutCache::new(); + let l = layout(&d, &mut c, &LayoutOptions { proofing: true, ..LayoutOptions::default() }); + let para = l.pages[0].items.iter().find_map(|i| if let Placed::Lines { para, .. } = i { Some(para.clone()) } else { None }).expect("a paragraph"); + // Byte offsets after which a hyphen may go. + let ends: Vec = para.hyph_after.iter().filter_map(|&k| para.clusters.get(k as usize).map(|c| c.end)).collect(); + for prefix in ["Kon", "Konsty", "Konstytu", "Konstytucyj", "Konstytucyjne"] { + assert!(ends.contains(&prefix.len()), "Polish kon-sty-tu-cyj-ne-go: no break after {prefix:?} in {ends:?}"); + } + for prefix in ["ty", "typog", "typogra"] { + assert!(ends.contains(&(eng + prefix.len())), "English ty-pog-ra-phy: no break after {prefix:?} in {ends:?}"); + } + // Spelling: the Polish misspelling and the English one; Polish words aren't English errors. + let flagged: Vec<&str> = para.issues.iter().filter(|i| !i.2).map(|i| &text[i.0..i.1]).collect(); + assert_eq!(flagged, ["żeka", "beautifull"]); + // The layout and the Review commands report the same issues. + let p = d.para(StoryRef::Body, &Path::top(0)).unwrap(); + let issues = para::paragraph_issues(p, ¶::proof_runs(&d, p, None), &Default::default(), true); + assert_eq!(issues.iter().map(|i| (i.start, i.end, i.kind == wordcraft_proof::IssueKind::Grammar)).collect::>(), para.issues); + assert_eq!(issues[0].lang, wordcraft_proof::ProofLang::Pl); + assert_eq!(issues[1].lang, wordcraft_proof::ProofLang::En); +} + +/// Text in a language WordCraft can't proof gets no spelling marks, and "Do not check spelling +/// or grammar" text none either; the document default language applies to text without its own. +#[test] +fn unproofed_languages_and_no_proof_runs_are_not_marked() { + // A tiny synthetic Polish dictionary (real ones are installed on the system, not bundled). + wordcraft_proof::pl::install_dictionary("SET UTF-8\nSFX B Y 1\nSFX B y ego y\n", "2\nkonstytucyjny/B\npłynie\n").unwrap(); + let opts = LayoutOptions { proofing: true, ..LayoutOptions::default() }; + let issues = |d: &Document| -> usize { + let l = layout(d, &mut LayoutCache::new(), &opts); + l.pages[0].items.iter().map(|i| if let Placed::Lines { para, .. } = i { para.issues.len() } else { 0 }).sum() + }; + let text = "Nous écrivons beaucoup aujourd'hui."; + let mut d = Document::from_text(text); + assert!(issues(&d) > 0, "French words are English misspellings in an English document"); + d.styles.default_chr.lang = Some("fr-FR".into()); + assert_eq!(issues(&d), 0, "no French dictionary: no marks"); + d.styles.default_chr.lang = Some("pl-PL".into()); + let mut pl = Document::from_text("Płynie żeka."); + pl.styles.default_chr.lang = Some("pl-PL".into()); + assert_eq!(issues(&pl), 1); + pl.format_range(&Pos::body(0, 0), &Pos::body(0, "Płynie żeka.".len()), &|c| c.no_proof = Some(true)).unwrap(); + assert_eq!(issues(&pl), 0); +} + /// #451: page totals in the body show the document's page count (the stale cached result is /// ignored), on every page, like the same fields in the footer. #[test] diff --git a/crates/proof/Cargo.toml b/crates/proof/Cargo.toml index ff3d96cc8..abee19074 100644 --- a/crates/proof/Cargo.toml +++ b/crates/proof/Cargo.toml @@ -3,7 +3,7 @@ name = "wordcraft-proof" version.workspace = true edition.workspace = true license.workspace = true -description = "WordCraft proofing: spelling (public-domain word list), suggestions, grammar checks, hyphenation" +description = "WordCraft proofing per language: spelling, suggestions, grammar checks and hyphenation for English (public-domain word list) and Polish (installed Hunspell dictionary, TeX patterns)" [lints] workspace = true diff --git a/crates/proof/src/affix.rs b/crates/proof/src/affix.rs new file mode 100644 index 000000000..eeef432e0 --- /dev/null +++ b/crates/proof/src/affix.rs @@ -0,0 +1,845 @@ +//! Hunspell-style affix dictionaries — the subset the SJP.PL Polish dictionary (`pl_PL`) uses. +//! +//! A word is correct when it is a stem, a stem with one suffix, a stem with one prefix, or a stem +//! with a prefix and a suffix that both allow cross products; each rule applies only to stems +//! carrying its flag and ending (suffixes) or starting (prefixes) as its condition says. Highly +//! inflected languages store millions of forms this way in a few hundred thousand stems: the +//! Polish dictionary's ~350k stems and ~7,500 rules accept ~4.5 million forms. +//! +//! The rules and stems come from a Hunspell `.aff`/`.dic` pair ([`AffixDict::from_hunspell`]), +//! read at runtime from the dictionaries installed on the system ([`crate::hunspell`]). They can +//! also be stored in WordCraft's compact format ([`AffixDict::to_bytes`]): the rules as text +//! lines and the stems front-coded, raw-deflated together (the Polish dictionary is under 1 MB). +//! +//! Supported: one-character flags (at most 64 distinct), `PFX`/`SFX` with strip, append and +//! condition (`.`, letters, `[abc]`, `[^abc]`), cross products, and the suggestion tables `TRY`, +//! `MAP` and `REP`. Rejected: compounding, continuation classes, long or numeric flags, +//! `NEEDAFFIX`, `FORBIDDENWORD` and the other directives that change which words are correct. +//! +//! Lookups never panic and never allocate per probe: stems sit in one string, found through an +//! open-addressing hash table. + +use std::collections::HashMap; +use std::hash::Hasher; + +use crate::patterns::{FxHasher, FxMap}; + +const MAGIC: &str = "WCAFF1"; +/// Longest word (in bytes) looked up; longer input is never a dictionary word. +pub const MAX_WORD_BYTES: usize = 120; +/// Empty slot in the stem hash table. +const EMPTY: u32 = u32::MAX; + +/// Directives that change which words are correct in ways this engine doesn't implement. +const UNSUPPORTED: &[&str] = &[ + "AF", + "AM", + "CHECKSHARPS", + "CIRCUMFIX", + "COMPLEXPREFIXES", + "COMPOUNDBEGIN", + "COMPOUNDEND", + "COMPOUNDFLAG", + "COMPOUNDMIDDLE", + "COMPOUNDMIN", + "COMPOUNDPERMITFLAG", + "COMPOUNDRULE", + "FORBIDDENWORD", + "FULLSTRIP", + "ICONV", + "IGNORE", + "KEEPCASE", + "LEMMA_PRESENT", + "NEEDAFFIX", + "OCONV", + "ONLYINCOMPOUND", + "PSEUDOROOT", + "SUBSTANDARD", +]; + +/// One character class of a rule's condition. +#[derive(Clone, Debug, PartialEq, Eq)] +enum Cond { + Any, + Char(char), + Set { negated: bool, chars: Box<[char]> }, +} + +impl Cond { + fn matches(&self, c: char) -> bool { + match self { + Cond::Any => true, + Cond::Char(x) => *x == c, + Cond::Set { negated, chars } => chars.contains(&c) != *negated, + } + } +} + +/// Parse a Hunspell condition (`.`, `[^i]e`, `[km]anie`…). +fn parse_cond(text: &str) -> Result, String> { + if text == "." { + return Ok(Box::new([])); + } + let mut out = Vec::new(); + let mut chars = text.chars(); + while let Some(c) = chars.next() { + match c { + '.' => out.push(Cond::Any), + '[' => { + let mut set = Vec::new(); + let mut negated = false; + let mut closed = false; + let mut first = true; + for d in chars.by_ref() { + if d == ']' { + closed = true; + break; + } + if first && d == '^' { + negated = true; + } else { + set.push(d); + } + first = false; + } + if !closed { + return Err(format!("unclosed `[` in condition `{text}`")); + } + out.push(Cond::Set { negated, chars: set.into_boxed_slice() }); + } + ']' => return Err(format!("stray `]` in condition `{text}`")), + c => out.push(Cond::Char(c)), + } + } + Ok(out.into_boxed_slice()) +} + +/// One prefix or suffix rule. +#[derive(Clone, Debug)] +struct Rule { + /// Bit of the rule's flag in the stems' flag masks. + bit: u64, + flag: char, + cross: bool, + strip: Box, + add: Box, + cond: Box<[Cond]>, + cond_text: Box, +} + +impl Rule { + /// Does `root` end (suffix rule) or start (prefix rule) as the condition says? + fn cond_matches(&self, root: &str, suffix: bool) -> bool { + if self.cond.is_empty() { + return true; + } + if suffix { + let mut it = root.chars().rev(); + self.cond.iter().rev().all(|c| it.next().is_some_and(|x| c.matches(x))) + } else { + let mut it = root.chars(); + self.cond.iter().all(|c| it.next().is_some_and(|x| c.matches(x))) + } + } +} + +/// Suffix rules sharing an appended string and a stripped one: one stem lookup serves them all. +#[derive(Clone, Debug, Default)] +struct SfxGroup { + strip: Box, + rules: Vec, +} + +/// A stem list with affix rules (see the module docs). +#[derive(Clone, Debug, Default)] +pub struct AffixDict { + /// All stems, concatenated, sorted by bytes. + words: String, + offs: Vec, + /// Per stem: index into `sets`. + flags: Vec, + /// Distinct flag masks. + sets: Vec, + /// Open-addressing hash table of stem indices (`EMPTY` = free). + table: Vec, + /// Flag char of each bit. + flag_chars: Vec, + prefixes: Vec, + suffixes: Vec, + /// Appended string → groups of suffix rules by stripped string. + sfx_index: FxMap, Vec>, + /// Longest appended suffix, in chars. + max_sfx_chars: usize, + try_chars: String, + map: Vec>, + rep: Vec<(String, String)>, + /// Provenance and licence lines, kept through [`AffixDict::to_bytes`]. + comments: Vec, +} + +fn hash_str(s: &str) -> u64 { + let mut h = FxHasher::default(); + h.write(s.as_bytes()); + h.finish() +} + +/// Letter case of a word. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Casing { + /// No capitals (`kot`). + Lower, + /// First letter capital, no other (`Kot`). + Initial, + /// Every cased letter a capital, at least two (`KOT`). + Upper, + /// Anything else (`McDonald`, `kOT`). + Mixed, +} + +/// The casing of `w`. +pub fn casing(w: &str) -> Casing { + let (mut upper, mut lower, mut first_upper) = (0usize, 0usize, false); + for (i, c) in w.chars().enumerate() { + if c.is_uppercase() { + upper += 1; + if i == 0 { + first_upper = true; + } + } else if c.is_lowercase() { + lower += 1; + } + } + if upper == 0 { + Casing::Lower + } else if upper == 1 && first_upper { + Casing::Initial + } else if lower == 0 { + Casing::Upper + } else { + Casing::Mixed + } +} + +/// `w` with its first letter a capital. +pub fn capitalize(w: &str) -> String { + let mut c = w.chars(); + match c.next() { + Some(f) => f.to_uppercase().chain(c).collect(), + None => String::new(), + } +} + +/// Parsed rules and tables, shared by the Hunspell reader and the compact format. +#[derive(Default)] +struct Rules { + prefixes: Vec<(char, bool, String, String, String)>, + suffixes: Vec<(char, bool, String, String, String)>, + try_chars: String, + map: Vec>, + rep: Vec<(String, String)>, +} + +/// `0` means empty in Hunspell's strip and append fields. +fn zero_empty(s: &str) -> String { + if s == "0" { String::new() } else { s.to_string() } +} + +/// Read Hunspell `.aff` text (already decoded to UTF-8). +fn parse_aff(aff: &str) -> Result { + let mut r = Rules::default(); + let mut cross: HashMap<(bool, char), bool> = HashMap::new(); + let mut map_header = false; + let mut rep_header = false; + for (n, line) in aff.lines().enumerate() { + let f: Vec<&str> = line.split_whitespace().collect(); + let Some(&dir) = f.first() else { continue }; + if dir.starts_with('#') { + continue; + } + let at = |k: usize| f.get(k).copied().unwrap_or(""); + match dir { + "SET" | "LANG" | "NAME" | "VERSION" | "HOME" | "WORDCHARS" | "KEY" | "MAXNGRAMSUGS" | "MAXDIFF" | "ONLYMAXDIFF" | "NOSPLITSUGS" + | "SUGSWITHDOTS" | "NOSUGGEST" | "WARN" | "FORBIDWARN" | "MAXCPDSUGS" => {} + "FLAG" => return Err(format!("line {}: FLAG {} is not supported (one-character flags only)", n + 1, at(1))), + "TRY" => r.try_chars = at(1).to_string(), + "MAP" => { + if !map_header && at(1).parse::().is_ok() { + map_header = true; + } else if !at(1).contains('(') { + r.map.push(at(1).chars().collect()); + } + } + "REP" => { + if !rep_header && f.len() == 2 && at(1).parse::().is_ok() { + rep_header = true; + } else if f.len() >= 3 { + r.rep.push((at(1).replace('_', " "), at(2).replace('_', " "))); + } + } + "PFX" | "SFX" => { + let suffix = dir == "SFX"; + let mut flag_chars = at(1).chars(); + let (Some(flag), None) = (flag_chars.next(), flag_chars.next()) else { + return Err(format!("line {}: flag `{}` is not one character", n + 1, at(1))); + }; + let is_header = f.len() == 4 && matches!(at(2), "Y" | "N") && at(3).parse::().is_ok() && !cross.contains_key(&(suffix, flag)); + if is_header { + cross.insert((suffix, flag), at(2) == "Y"); + continue; + } + if f.len() < 4 { + return Err(format!("line {}: short affix rule", n + 1)); + } + let add = at(3); + if add.contains('/') { + return Err(format!("line {}: continuation classes are not supported", n + 1)); + } + let cond = if f.len() >= 5 { at(4) } else { "." }; + parse_cond(cond).map_err(|e| format!("line {}: {e}", n + 1))?; + let c = cross.get(&(suffix, flag)).copied().unwrap_or(false); + let rule = (flag, c, zero_empty(at(2)), zero_empty(add), cond.to_string()); + if suffix { + r.suffixes.push(rule); + } else { + r.prefixes.push(rule); + } + } + d if UNSUPPORTED.contains(&d) => return Err(format!("line {}: {d} is not supported", n + 1)), + _ => {} + } + } + Ok(r) +} + +impl AffixDict { + /// Build from Hunspell `.aff` and `.dic` text (decoded to UTF-8; the `.dic` count line and + /// morphological fields are ignored). `comments` (provenance, licence) are kept with the data. + pub fn from_hunspell(aff: &str, dic: &str, comments: &[&str]) -> Result { + let rules = parse_aff(aff)?; + let mut stems: Vec<(String, String)> = Vec::new(); + for (n, line) in dic.lines().enumerate() { + let Some(tok) = line.split_whitespace().next() else { continue }; + if n == 0 && tok.parse::().is_ok() { + continue; + } + // `\/` is a literal slash in a word; the first plain `/` starts the flags. + let mut word = String::new(); + let mut flags = String::new(); + let mut chars = tok.chars().peekable(); + let mut in_flags = false; + while let Some(c) = chars.next() { + if in_flags { + flags.push(c); + } else if c == '\\' && chars.peek() == Some(&'/') { + word.push('/'); + chars.next(); + } else if c == '/' { + in_flags = true; + } else { + word.push(c); + } + } + if word.is_empty() || word.len() > MAX_WORD_BYTES || word.contains(['\n', '/']) { + continue; + } + stems.push((word, flags)); + } + AffixDict::build(rules, stems, comments.iter().map(|s| s.to_string()).collect()) + } + + fn build(rules: Rules, mut stems: Vec<(String, String)>, comments: Vec) -> Result { + // Flags: every one a rule or a stem uses gets a bit. + let mut flag_chars: Vec = rules.prefixes.iter().chain(&rules.suffixes).map(|r| r.0).collect(); + flag_chars.extend(stems.iter().flat_map(|s| s.1.chars())); + flag_chars.sort_unstable(); + flag_chars.dedup(); + if flag_chars.len() > 64 { + return Err(format!("{} distinct flags; at most 64 are supported", flag_chars.len())); + } + let bit_of = |c: char| flag_chars.iter().position(|&x| x == c).map_or(0, |i| 1u64 << i); + // Stems sorted by bytes; homonyms (the same word twice) are merged. + stems.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes())); + let mut d = AffixDict { + words: String::with_capacity(stems.iter().map(|s| s.0.len()).sum()), + offs: Vec::with_capacity(stems.len() + 1), + flag_chars: flag_chars.clone(), + try_chars: rules.try_chars, + map: rules.map, + rep: rules.rep, + comments, + ..AffixDict::default() + }; + d.offs.push(0); + let mut set_ids: HashMap = HashMap::new(); + let mut i = 0; + while i < stems.len() { + let Some((word, _)) = stems.get(i) else { break }; + let mut mask = 0u64; + let mut j = i; + while let Some((w, f)) = stems.get(j) + && w == word + { + mask |= f.chars().map(bit_of).fold(0, |a, b| a | b); + j += 1; + } + let next_id = u16::try_from(d.sets.len()).map_err(|_| "too many distinct flag sets".to_string())?; + let id = *set_ids.entry(mask).or_insert(next_id); + if usize::from(id) == d.sets.len() { + d.sets.push(mask); + } + d.words.push_str(word); + d.offs.push(u32::try_from(d.words.len()).map_err(|_| "stems too large".to_string())?); + d.flags.push(id); + i = j; + } + let mk = |(flag, cross, strip, add, cond): (char, bool, String, String, String)| -> Result { + Ok(Rule { bit: bit_of(flag), flag, cross, strip: strip.into(), add: add.into(), cond: parse_cond(&cond)?, cond_text: cond.into() }) + }; + d.prefixes = rules.prefixes.into_iter().map(mk).collect::>()?; + d.suffixes = rules.suffixes.into_iter().map(mk).collect::>()?; + d.index(); + Ok(d) + } + + /// Build the suffix index and the stem hash table. + fn index(&mut self) { + let mut idx: FxMap, Vec> = FxMap::default(); + for (i, r) in self.suffixes.iter().enumerate() { + let groups = idx.entry(r.add.clone()).or_default(); + let Ok(i) = u32::try_from(i) else { break }; + match groups.iter_mut().find(|g| g.strip == r.strip) { + Some(g) => g.rules.push(i), + None => groups.push(SfxGroup { strip: r.strip.clone(), rules: vec![i] }), + } + } + self.max_sfx_chars = self.suffixes.iter().map(|r| r.add.chars().count()).max().unwrap_or(0); + self.sfx_index = idx; + let n = self.len(); + let size = (n.max(1) * 2).next_power_of_two(); + let mut table = vec![EMPTY; size]; + let mask = size - 1; + for i in 0..n { + let Some(w) = self.word(i) else { continue }; + let mut slot = hash_str(w) as usize & mask; + while table.get(slot).is_some_and(|&t| t != EMPTY) { + slot = (slot + 1) & mask; + } + if let (Some(t), Ok(i)) = (table.get_mut(slot), u32::try_from(i)) { + *t = i; + } + } + self.table = table; + } + + /// Number of stems. + pub fn len(&self) -> usize { + self.flags.len() + } + + pub fn is_empty(&self) -> bool { + self.flags.is_empty() + } + + /// Number of prefix and suffix rules. + pub fn rule_count(&self) -> usize { + self.prefixes.len() + self.suffixes.len() + } + + fn word(&self, i: usize) -> Option<&str> { + let a = *self.offs.get(i)? as usize; + let b = *self.offs.get(i + 1)? as usize; + self.words.get(a..b) + } + + /// The stems, in byte order. + pub fn stems(&self) -> impl Iterator + '_ { + (0..self.len()).filter_map(|i| self.word(i)) + } + + /// The stems starting with `prefix`, in byte order (a binary search, then a scan). + pub fn stems_with_prefix<'a>(&'a self, prefix: &str) -> impl Iterator + use<'a> { + let prefix = prefix.to_string(); + let first = { + let (mut lo, mut hi) = (0usize, self.len()); + while lo < hi { + let mid = lo + (hi - lo) / 2; + if self.word(mid).is_some_and(|w| w.as_bytes() < prefix.as_bytes()) { + lo = mid + 1; + } else { + hi = mid; + } + } + lo + }; + (first..self.len()).map_while(move |i| self.word(i).filter(|w| w.starts_with(prefix.as_str()))) + } + + /// Flag mask of a stem, if `w` is one. + fn stem_flags(&self, w: &str) -> Option { + if self.table.is_empty() { + return None; + } + let mask = self.table.len() - 1; + let mut slot = hash_str(w) as usize & mask; + for _ in 0..self.table.len() { + let t = *self.table.get(slot)?; + if t == EMPTY { + return None; + } + if self.word(t as usize) == Some(w) { + let id = *self.flags.get(t as usize)?; + return self.sets.get(usize::from(id)).copied(); + } + slot = (slot + 1) & mask; + } + None + } + + /// Is `w` exactly (case and all) a stem or an affixed form of one? + pub fn contains(&self, w: &str) -> bool { + if w.is_empty() || w.len() > MAX_WORD_BYTES { + return false; + } + if self.stem_flags(w).is_some() || self.suffixed(w, 0) { + return true; + } + let mut root = String::with_capacity(w.len() + 8); + for r in &self.prefixes { + let Some(rest) = w.strip_prefix(&*r.add) else { continue }; + if rest.is_empty() { + continue; + } + root.clear(); + root.push_str(&r.strip); + root.push_str(rest); + if !r.cond_matches(&root, false) { + continue; + } + if self.stem_flags(&root).is_some_and(|f| f & r.bit != 0) { + return true; + } + if r.cross && self.suffixed(&root, r.bit) { + return true; + } + } + false + } + + /// Is `w` a stem with one suffix? With `need` set (a prefix was taken off), the stem must + /// carry those flags too and the suffix must allow cross products. + fn suffixed(&self, w: &str, need: u64) -> bool { + let mut root = String::with_capacity(w.len() + 8); + // Each split point from the end: the appended string is `w[b..]`, at most the longest + // suffix, and something must stay before it. + let cuts = std::iter::once(w.len()).chain(w.char_indices().rev().map(|(i, _)| i).filter(|&i| i > 0)); + for b in cuts.take(self.max_sfx_chars + 1) { + let (Some(base), Some(add)) = (w.get(..b), w.get(b..)) else { continue }; + let Some(groups) = self.sfx_index.get(add) else { continue }; + for g in groups { + root.clear(); + root.push_str(base); + root.push_str(&g.strip); + let Some(f) = self.stem_flags(&root) else { continue }; + if f & need != need { + continue; + } + let ok = g + .rules + .iter() + .filter_map(|&i| self.suffixes.get(i as usize)) + .any(|r| f & r.bit != 0 && (need == 0 || r.cross) && r.cond_matches(&root, true)); + if ok { + return true; + } + } + } + false + } + + /// Is `w` correct, Hunspell style: as written, or — for a capitalized word or one in capitals + /// — in lower case (and capitalized), since a sentence may start with any word and headings + /// may be in capitals. Lower-case input never matches a capitalized stem (`warszawa`). + pub fn check(&self, w: &str) -> bool { + if self.contains(w) { + return true; + } + match casing(w) { + Casing::Lower | Casing::Mixed => false, + Casing::Initial => self.contains(&w.to_lowercase()), + Casing::Upper => { + let lower = w.to_lowercase(); + self.contains(&capitalize(&lower)) || self.contains(&lower) + } + } + } + + /// Every form of the stem `stem` (itself included), unsorted, possibly with repeats. + pub fn forms(&self, stem: &str) -> Vec { + let Some(f) = self.stem_flags(stem) else { return Vec::new() }; + let mut out = vec![stem.to_string()]; + let mut cross_sfx = Vec::new(); + for r in &self.suffixes { + if f & r.bit == 0 || !r.cond_matches(stem, true) { + continue; + } + let Some(base) = stem.strip_suffix(&*r.strip) else { continue }; + let form = format!("{base}{}", r.add); + if r.cross { + cross_sfx.push(form.clone()); + } + out.push(form); + } + for r in &self.prefixes { + if f & r.bit == 0 || !r.cond_matches(stem, false) { + continue; + } + let Some(rest) = stem.strip_prefix(&*r.strip) else { continue }; + out.push(format!("{}{rest}", r.add)); + if r.cross { + for s in &cross_sfx { + if let Some(rest) = s.strip_prefix(&*r.strip) { + out.push(format!("{}{rest}", r.add)); + } + } + } + } + out + } + + /// The `TRY` letters, most frequent first (suggestions try these). + pub fn try_chars(&self) -> &str { + &self.try_chars + } + + /// The `MAP` groups: letters often confused with each other (`aą`, `zżź`, `oóu`…). + pub fn map(&self) -> &[Vec] { + &self.map + } + + /// The `REP` table: common misspellings, `(wrong, right)`. + pub fn rep(&self) -> &[(String, String)] { + &self.rep + } + + /// The provenance and licence lines. + pub fn comments(&self) -> &[String] { + &self.comments + } + + /// Serialize to WordCraft's compact format: raw-deflate of `WCAFF1`, the comments, the + /// tables and rules as text lines, a `WORDS` line, then one line per stem, front-coded + /// against the previous one (a byte with the shared prefix length in bytes, the rest of the + /// word, `/` and its flags if any). + pub fn to_bytes(&self) -> Vec { + let mut out = String::new(); + out.push_str(MAGIC); + out.push('\n'); + for c in &self.comments { + out.push_str("# "); + out.push_str(c.trim()); + out.push('\n'); + } + if !self.try_chars.is_empty() { + out.push_str(&format!("TRY {}\n", self.try_chars)); + } + for m in &self.map { + out.push_str(&format!("MAP {}\n", m.iter().collect::())); + } + for (a, b) in &self.rep { + out.push_str(&format!("REP {} {}\n", a.replace(' ', "_"), b.replace(' ', "_"))); + } + let z = |s: &str| if s.is_empty() { "0".to_string() } else { s.to_string() }; + for (kind, rules) in [("PFX", &self.prefixes), ("SFX", &self.suffixes)] { + for r in rules { + let y = if r.cross { "Y" } else { "N" }; + out.push_str(&format!("{kind} {} {y} {} {} {}\n", r.flag, z(&r.strip), z(&r.add), r.cond_text)); + } + } + out.push_str("WORDS\n"); + let mut raw = out.into_bytes(); + let mut prev: &[u8] = &[]; + for i in 0..self.len() { + let Some(w) = self.word(i) else { continue }; + let b = w.as_bytes(); + let p = prev.iter().zip(b).take_while(|(x, y)| x == y).count().min(255); + raw.push(p as u8); + raw.extend_from_slice(b.get(p..).unwrap_or_default()); + let mask = self.flags.get(i).and_then(|&id| self.sets.get(usize::from(id))).copied().unwrap_or(0); + if mask != 0 { + raw.push(b'/'); + for (bit, c) in self.flag_chars.iter().enumerate() { + if mask & (1 << bit) != 0 { + let mut buf = [0u8; 4]; + raw.extend_from_slice(c.encode_utf8(&mut buf).as_bytes()); + } + } + } + raw.push(b'\n'); + prev = b; + } + miniz_oxide::deflate::compress_to_vec(&raw, 10) + } + + /// Read data written by [`AffixDict::to_bytes`]. + pub fn from_bytes(data: &[u8]) -> Result { + let raw = + miniz_oxide::inflate::decompress_to_vec_with_limit(data, 64 << 20).map_err(|e| format!("affix dictionary: inflate failed: {e:?}"))?; + let words_at = raw.windows(7).position(|w| w == b"\nWORDS\n").ok_or("affix dictionary: no WORDS section")?; + let head = std::str::from_utf8(raw.get(..words_at).unwrap_or_default()).map_err(|_| "affix dictionary: bad utf-8 in the rules")?; + let mut lines = head.lines(); + if lines.next() != Some(MAGIC) { + return Err("affix dictionary: bad header".into()); + } + let mut rules = Rules::default(); + let mut comments = Vec::new(); + for line in lines { + if let Some(c) = line.strip_prefix("# ") { + comments.push(c.to_string()); + continue; + } + let f: Vec<&str> = line.split_whitespace().collect(); + let at = |k: usize| f.get(k).copied().unwrap_or(""); + match at(0) { + "TRY" => rules.try_chars = at(1).to_string(), + "MAP" => rules.map.push(at(1).chars().collect()), + "REP" => rules.rep.push((at(1).replace('_', " "), at(2).replace('_', " "))), + kind @ ("PFX" | "SFX") => { + let mut fc = at(1).chars(); + let (Some(flag), None) = (fc.next(), fc.next()) else { return Err(format!("affix dictionary: bad flag in `{line}`")) }; + if f.len() != 6 { + return Err(format!("affix dictionary: bad rule `{line}`")); + } + let rule = (flag, at(2) == "Y", zero_empty(at(3)), zero_empty(at(4)), at(5).to_string()); + if kind == "SFX" { + rules.suffixes.push(rule); + } else { + rules.prefixes.push(rule); + } + } + "" => {} + other => return Err(format!("affix dictionary: unknown line `{other}`")), + } + } + let body = raw.get(words_at + 7..).unwrap_or_default(); + let mut stems: Vec<(String, String)> = Vec::new(); + let mut cur: Vec = Vec::new(); + let mut i = 0; + while let Some(&p) = body.get(i) { + let rest = body.get(i + 1..).unwrap_or_default(); + let len = rest.iter().position(|&b| b == b'\n').ok_or("affix dictionary: truncated")?; + let line = rest.get(..len).unwrap_or_default(); + i += len + 2; + let p = p as usize; + if p > cur.len() { + return Err("affix dictionary: bad prefix".into()); + } + let (tail, flags) = match line.iter().position(|&b| b == b'/') { + Some(k) => (line.get(..k).unwrap_or_default(), line.get(k + 1..).unwrap_or_default()), + None => (line, &[][..]), + }; + cur.truncate(p); + cur.extend_from_slice(tail); + let word = std::str::from_utf8(&cur).map_err(|_| "affix dictionary: bad utf-8 in a stem")?; + let flags = std::str::from_utf8(flags).map_err(|_| "affix dictionary: bad utf-8 in flags")?; + if word.is_empty() || word.len() > MAX_WORD_BYTES { + return Err("affix dictionary: bad stem".into()); + } + stems.push((word.to_string(), flags.to_string())); + } + AffixDict::build(rules, stems, comments) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const AFF: &str = "SET UTF-8\nTRY aeoiułkr\n\nPFX b Y 1\nPFX b 0 nie .\n\nSFX a Y 3\nSFX a 0 y [^y]\nSFX a ek ka ek\nSFX a 0 em .\n\nSFX c N 1\nSFX c a ą a\n\nMAP 2\nMAP aą\nMAP oóu\nREP 2\nREP ż rz\nREP u ó\n"; + const DIC: &str = "4\nkot/ab\npiesek/a\nkrowa/c\nWarszawa/c\n"; + + fn dict() -> AffixDict { + AffixDict::from_hunspell(AFF, DIC, &["test data"]).unwrap() + } + + #[test] + fn stems_suffixes_prefixes_and_cross_products() { + let d = dict(); + assert_eq!(d.len(), 4); + for w in ["kot", "koty", "kotem", "piesek", "pieska", "piesekem", "krowa", "krową", "niekot", "niekoty", "niekotem", "Warszawa", "Warszawą"] + { + assert!(d.contains(w), "{w}"); + } + for w in ["kotą", "pieseka", "pieskem", "niepiesek", "niekrową", "krowy", "warszawa", "", "nie", "y", "ka"] { + assert!(!d.contains(w), "{w}"); + } + } + + #[test] + fn case_variants() { + let d = dict(); + assert!(d.check("Kot") && d.check("KOT") && d.check("Koty") && d.check("WARSZAWĄ") && d.check("Warszawą")); + assert!(!d.check("warszawa") && !d.check("kOT") && !d.check("KoT")); + assert_eq!(casing("Kot"), Casing::Initial); + assert_eq!(casing("KOT"), Casing::Upper); + assert_eq!(casing("K"), Casing::Initial); + assert_eq!(casing("kot"), Casing::Lower); + assert_eq!(casing("McDonald"), Casing::Mixed); + assert_eq!(capitalize("żółw"), "Żółw"); + } + + #[test] + fn forms_are_all_accepted() { + let d = dict(); + let mut forms = d.forms("kot"); + forms.sort(); + forms.dedup(); + assert_eq!(forms, ["kot", "kotem", "koty", "niekot", "niekotem", "niekoty"]); + for stem in d.stems().map(str::to_string).collect::>() { + for f in d.forms(&stem) { + assert!(d.contains(&f), "{stem} → {f}"); + } + } + } + + #[test] + fn compact_format_round_trips() { + let d = dict(); + let back = AffixDict::from_bytes(&d.to_bytes()).unwrap(); + assert_eq!(back.len(), d.len()); + assert_eq!(back.rule_count(), d.rule_count()); + assert_eq!(back.comments(), ["test data"]); + assert_eq!(back.rep(), d.rep()); + assert_eq!(back.map(), d.map()); + assert_eq!(back.try_chars(), "aeoiułkr"); + for w in ["kotem", "niekoty", "pieska", "krową", "Warszawą"] { + assert!(back.contains(w), "{w}"); + } + assert!(!back.contains("kotą")); + } + + #[test] + fn unsupported_and_broken_input_is_an_error() { + assert!(AffixDict::from_hunspell("FLAG long\n", DIC, &[]).is_err()); + assert!(AffixDict::from_hunspell("COMPOUNDFLAG X\n", DIC, &[]).is_err()); + assert!(AffixDict::from_hunspell("SFX a Y 1\nSFX a 0 y/b .\n", DIC, &[]).is_err()); + assert!(AffixDict::from_hunspell("SFX a Y 1\nSFX a 0 y [ab\n", DIC, &[]).is_err()); + assert!(AffixDict::from_bytes(b"not deflate").is_err()); + let ok = dict().to_bytes(); + assert!(AffixDict::from_bytes(&ok[..ok.len() / 2]).is_err()); + let garbage = miniz_oxide::deflate::compress_to_vec(b"WCAFF1\nWORDS\n\x09abc\n", 6); + assert!(AffixDict::from_bytes(&garbage).is_err(), "prefix longer than the previous word"); + let empty = AffixDict::default(); + assert!(!empty.contains("kot") && !empty.check("Kot") && empty.forms("kot").is_empty()); + } + + proptest::proptest! { + #[test] + fn lookups_never_panic(s in "\\PC{0,40}", bytes in proptest::collection::vec(proptest::num::u8::ANY, 0..200)) { + let d = dict(); + let _ = d.check(&s); + let _ = d.forms(&s); + let _ = AffixDict::from_bytes(&bytes); + let _ = AffixDict::from_bytes(&miniz_oxide::deflate::compress_to_vec(&bytes, 1)); + } + } +} diff --git a/crates/proof/src/hunspell.rs b/crates/proof/src/hunspell.rs new file mode 100644 index 000000000..2c09b11da --- /dev/null +++ b/crates/proof/src/hunspell.rs @@ -0,0 +1,163 @@ +//! Hunspell dictionaries installed on the system, read at runtime. +//! +//! WordCraft doesn't bundle large spelling dictionaries. For languages with an affix dictionary +//! (Polish), it reads the standard Hunspell `.aff`/`.dic` pair installed by the operating system, +//! a package manager or LibreOffice, with [`crate::affix::AffixDict::from_hunspell`]. When none is +//! installed the language has no spelling check (no marks, no errors), like Word without the +//! language's proofing tools. +//! +//! Search order: the directory in `WORDCRAFT_DICTIONARIES` (or one set with +//! [`set_dictionary_dir`]), then the usual system locations. On the web there are none. + +use std::path::{Path, PathBuf}; +use std::sync::RwLock; + +use crate::affix::AffixDict; + +/// Largest `.aff` or `.dic` file read (real ones are a few MB). +const MAX_FILE: u64 = 64 << 20; + +/// An extra directory searched first (a setting), set by the front end. +static EXTRA_DIR: RwLock> = RwLock::new(None); + +/// Search `dir` before the system locations (a "dictionaries folder" setting); `None` clears it. +/// Takes effect for dictionaries not loaded yet. +pub fn set_dictionary_dir(dir: Option) { + *EXTRA_DIR.write().unwrap_or_else(|e| e.into_inner()) = dir; +} + +/// Directories that may hold Hunspell dictionaries, most specific first. +pub fn search_dirs() -> Vec { + let mut dirs: Vec = Vec::new(); + if let Some(d) = EXTRA_DIR.read().unwrap_or_else(|e| e.into_inner()).clone() { + dirs.push(d); + } + if cfg!(target_arch = "wasm32") { + return dirs; + } + if let Some(d) = std::env::var_os("WORDCRAFT_DICTIONARIES").filter(|v| !v.is_empty()) { + dirs.push(PathBuf::from(d)); + } + let home = std::env::var_os("HOME").filter(|v| !v.is_empty()).map(PathBuf::from); + if cfg!(windows) { + for var in ["LOCALAPPDATA", "APPDATA"] { + if let Some(base) = std::env::var_os(var).filter(|v| !v.is_empty()).map(PathBuf::from) { + dirs.push(base.join("WordCraft").join("dictionaries")); + dirs.push(base.join("hunspell")); + } + } + for var in ["ProgramFiles", "ProgramFiles(x86)"] { + if let Some(base) = std::env::var_os(var).filter(|v| !v.is_empty()).map(PathBuf::from) { + dirs.push(base.join("LibreOffice").join("share").join("extensions").join("dict-pl")); + } + } + } else if cfg!(target_os = "macos") { + if let Some(h) = &home { + dirs.push(h.join("Library/Spelling")); + } + dirs.push(PathBuf::from("/Library/Spelling")); + dirs.push(PathBuf::from("/opt/homebrew/share/hunspell")); + dirs.push(PathBuf::from("/usr/local/share/hunspell")); + dirs.push(PathBuf::from("/Applications/LibreOffice.app/Contents/Resources/extensions/dict-pl")); + } else { + if let Some(h) = &home { + dirs.push(h.join(".local/share/hunspell")); + } + for d in [ + "/usr/share/hunspell", + "/usr/share/myspell", + "/usr/share/myspell/dicts", + "/usr/local/share/hunspell", + "/usr/local/share/myspell", + "/usr/lib/libreoffice/share/extensions/dict-pl", + "/opt/libreoffice/share/extensions/dict-pl", + ] { + dirs.push(PathBuf::from(d)); + } + } + dirs +} + +/// The first `.aff`/`.dic` pair found for any of `names` (`pl_PL`, `pl`), searching +/// [`search_dirs`]. +pub fn find(names: &[&str]) -> Option<(PathBuf, PathBuf)> { + search_dirs().into_iter().find_map(|dir| find_in(&dir, names)) +} + +/// The first `.aff`/`.dic` pair for any of `names` in `dir`. +pub fn find_in(dir: &Path, names: &[&str]) -> Option<(PathBuf, PathBuf)> { + names.iter().find_map(|n| { + let aff = dir.join(format!("{n}.aff")); + let dic = dir.join(format!("{n}.dic")); + (aff.is_file() && dic.is_file()).then_some((aff, dic)) + }) +} + +/// Read a Hunspell pair from disk (size-capped), decoded per the `.aff`'s `SET` line. +pub fn load(aff: &Path, dic: &Path) -> Result { + let read = |p: &Path| -> Result, String> { + let len = std::fs::metadata(p).map_err(|e| format!("{}: {e}", p.display()))?.len(); + if len > MAX_FILE { + return Err(format!("{}: too large ({len} bytes)", p.display())); + } + std::fs::read(p).map_err(|e| format!("{}: {e}", p.display())) + }; + let (aff_text, dic_text) = decode(&read(aff)?, &read(dic)?)?; + let source = format!("Hunspell dictionary {}", dic.display()); + AffixDict::from_hunspell(&aff_text, &dic_text, &[source.as_str()]) +} + +/// ISO 8859-2 code points 0xA0..=0xFF (0x00..=0x9F map to themselves). +const ISO_8859_2_HIGH: &str = "\u{a0}Ą˘Ł¤ĽŚ§¨ŠŞŤŹ\u{ad}ŽŻ°ą˛ł´ľśˇ¸šşťź˝žżŔÁÂĂÄĹĆÇČÉĘËĚÍÎĎĐŃŇÓÔŐÖ×ŘŮÚŰÜÝŢßŕáâăäĺćçčéęëěíîďđńňóôőö÷řůúűüýţ˙"; + +fn decode_latin2(bytes: &[u8]) -> String { + let high: Vec = ISO_8859_2_HIGH.chars().collect(); + bytes.iter().map(|&b| if b < 0xa0 { char::from(b) } else { high.get(usize::from(b - 0xa0)).copied().unwrap_or('\u{fffd}') }).collect() +} + +/// Decode an `.aff`/`.dic` pair to UTF-8 from the encoding the `.aff`'s `SET` line names +/// (UTF-8 when there is none; ISO 8859-2 is the other one Polish dictionaries use). +pub fn decode(aff: &[u8], dic: &[u8]) -> Result<(String, String), String> { + let head = String::from_utf8_lossy(aff.get(..aff.len().min(8192)).unwrap_or_default()).to_string(); + let set = head.lines().find_map(|l| l.trim_start_matches('\u{feff}').strip_prefix("SET ")).map(str::trim).unwrap_or("UTF-8").to_ascii_uppercase(); + match set.as_str() { + "UTF-8" | "UTF8" => { + let text = |b: &[u8], what: &str| String::from_utf8(b.to_vec()).map_err(|e| format!("{what}: not UTF-8: {e}")); + Ok((text(aff, ".aff")?, text(dic, ".dic")?)) + } + "ISO8859-2" | "ISO-8859-2" => Ok((decode_latin2(aff), decode_latin2(dic))), + other => Err(format!("unsupported encoding {other}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decodes_latin2_and_utf8() { + let aff = b"SET ISO8859-2\nTRY a\n"; + let (a, d) = decode(aff, b"2\n\xbf\xf3\xb3w\n").unwrap(); + assert!(a.contains("TRY a")); + assert_eq!(d, "2\nżółw\n"); + let (_, d) = decode("SET UTF-8\n".as_bytes(), "żółw".as_bytes()).unwrap(); + assert_eq!(d, "żółw"); + assert!(decode(b"SET KOI8-R\n", b"").is_err()); + assert!(decode(b"SET UTF-8\n", b"\xff\xfe").is_err()); + } + + #[test] + fn finds_and_loads_a_pair_in_a_directory() { + let dir = std::env::temp_dir().join(format!("wordcraft-hunspell-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + assert!(find_in(&dir, &["xx_XX"]).is_none(), "no files: nothing found"); + std::fs::write(dir.join("xx_XX.aff"), "SET UTF-8\nSFX A Y 1\nSFX A 0 y .\n").unwrap(); + assert!(find_in(&dir, &["xx_XX"]).is_none(), "an .aff alone isn't a dictionary"); + std::fs::write(dir.join("xx_XX.dic"), "1\nkot/A\n").unwrap(); + let (aff, dic) = find_in(&dir, &["yy", "xx_XX"]).unwrap(); + let d = load(&aff, &dic).unwrap(); + assert!(d.check("kot") && d.check("koty") && !d.check("kotx")); + assert!(load(&dir.join("missing.aff"), &dic).is_err()); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/proof/src/hyphen.rs b/crates/proof/src/hyphen.rs index 358502e9e..6462938bf 100644 --- a/crates/proof/src/hyphen.rs +++ b/crates/proof/src/hyphen.rs @@ -1,6 +1,7 @@ -//! English hyphenation. +//! Hyphenation, per proofing language ([`ProofLang`]). //! -//! Lookup order for each word: +//! **English** (and languages without patterns of their own, as before languages were told +//! apart), lookup order for each word: //! 1. the **dictionary** ([`Dictionary::en_us`]) — the public-domain Moby Hyphenator II word list //! by Grady Ward (~160k words), stored compactly in `assets/hyphenation/en-us.dic`; simple //! inflections (`'s`, `-s`, `-es`, `-ed`, `-d`) inherit the breaks of their stem; @@ -8,6 +9,10 @@ //! set, trained from the same list by DesignCraft's patgen (generated by DesignCraft's `hyphgen` example); //! 3. a small syllable **heuristic** for words outside the pattern alphabet. //! +//! **Polish**: the exception words, then the TeX Polish patterns ([`Patterns::pl`], by Hanna +//! Kołodziejska, Bogusław Jackowski and Marek Ryćko, MIT licence option), then a Polish syllable +//! heuristic for words with letters outside the Polish alphabet (`é`, `ü`…). +//! //! Then the paragraph's limits ([`Limits`]) are applied. pub use crate::dict; @@ -16,11 +21,14 @@ pub use crate::patterns; use std::sync::OnceLock; +pub use crate::lang::ProofLang; pub use dict::Dictionary; pub use patterns::Patterns; static EN_US_DIC: &[u8] = include_bytes!("../../../assets/hyphenation/en-us.dic"); static EN_US_PAT: &str = include_str!("../../../assets/hyphenation/en-us.pat"); +/// hyph-utf8's `hyph-pl.tex`, unmodified (its licence header is in the file). +static PL_PAT: &str = include_str!("../../../assets/hyphenation/pl.pat"); impl Dictionary { /// The bundled US English dictionary (decoded on first use). @@ -44,6 +52,12 @@ impl Patterns { static P: OnceLock = OnceLock::new(); P.get_or_init(|| Patterns::parse(EN_US_PAT)) } + + /// The bundled Polish patterns and exception words (parsed on first use). + pub fn pl() -> &'static Patterns { + static P: OnceLock = OnceLock::new(); + P.get_or_init(|| Patterns::parse(PL_PAT)) + } } /// Hyphenation limits from the paragraph's Hyphenation settings. @@ -79,11 +93,14 @@ fn is_word_char(c: char) -> bool { } /// Unrestricted break points of one lowercase core (letters and `'` only, no leading/trailing `'`). -fn core_breaks(core: &[char]) -> (Vec, Source) { +fn core_breaks(core: &[char], lang: ProofLang) -> (Vec, Source) { let n = core.len(); if n < 2 { return (vec![], Source::None); } + if lang == ProofLang::Pl { + return core_breaks_pl(core); + } let dict = Dictionary::en_us(); let s: String = core.iter().collect(); let from_mask = |m: u64, len: usize| (1..len.min(64)).filter(|&i| m & (1 << i) != 0).collect::>(); @@ -108,10 +125,30 @@ fn core_breaks(core: &[char]) -> (Vec, Source) { (heuristic_points(core), Source::Heuristic) } -/// Unrestricted break points of `word` (char indices; a hyphen goes before that char), handling -/// case, apostrophes (`don't`, `’s`), surrounding punctuation and compounds (each letter run is -/// hyphenated separately). No break is ever placed next to an apostrophe or non-letter. +/// Polish: exception words, then the patterns, then the Polish syllable heuristic. +fn core_breaks_pl(core: &[char]) -> (Vec, Source) { + let pats = Patterns::pl(); + let s: String = core.iter().collect(); + if let Some(p) = pats.exception(&s) { + return (p.to_vec(), Source::Dictionary); + } + if !pats.is_empty() + && let Some(p) = pats.points(core) + { + return (p, Source::Patterns); + } + (heuristic_points_pl(core), Source::Heuristic) +} + +/// Unrestricted break points of an English `word` ([`word_breaks_in`] with [`ProofLang::En`]). pub fn word_breaks(word: &str) -> (Vec, Source) { + word_breaks_in(word, ProofLang::En) +} + +/// Unrestricted break points of `word` in `lang` (char indices; a hyphen goes before that char), +/// handling case, apostrophes (`don't`, `’s`), surrounding punctuation and compounds (each letter +/// run is hyphenated separately). No break is ever placed next to an apostrophe or non-letter. +pub fn word_breaks_in(word: &str, lang: ProofLang) -> (Vec, Source) { let chars: Vec = word.chars().collect(); let mut out = Vec::new(); let mut src = Source::None; @@ -133,7 +170,7 @@ pub fn word_breaks(word: &str) -> (Vec, Source) { b -= 1; } let core: Vec = chars[a..b].iter().map(|&c| if is_apostrophe(c) { '\'' } else { c.to_lowercase().next().unwrap_or(c) }).collect(); - let (pts, s) = core_breaks(&core); + let (pts, s) = core_breaks(&core, lang); if src == Source::None { src = s; } @@ -143,11 +180,16 @@ pub fn word_breaks(word: &str) -> (Vec, Source) { (out, src) } -/// Allowed hyphenation points in `word` as char indices (a hyphen goes before that char), with -/// the paragraph limits applied to each letter run. +/// Allowed hyphenation points in an English `word` ([`hyphen_points_in`] with [`ProofLang::En`]). pub fn hyphen_points(word: &str, lim: &Limits) -> Vec { + hyphen_points_in(word, lim, ProofLang::En) +} + +/// Allowed hyphenation points in `word`, a word in `lang`, as char indices (a hyphen goes before +/// that char), with the paragraph limits applied to each letter run. +pub fn hyphen_points_in(word: &str, lim: &Limits, lang: ProofLang) -> Vec { let chars: Vec = word.chars().collect(); - let (pts, _) = word_breaks(word); + let (pts, _) = word_breaks_in(word, lang); if pts.is_empty() { return pts; } @@ -208,7 +250,12 @@ pub fn hyphen_points(word: &str, lim: &Limits) -> Vec { /// `word` with `-` at every hyphenation point (tests and diagnostics). pub fn hyphenate_word(word: &str, lim: &Limits) -> String { - let pts = hyphen_points(word, lim); + hyphenate_word_in(word, lim, ProofLang::En) +} + +/// `word`, a word in `lang`, with `-` at every hyphenation point (tests and diagnostics). +pub fn hyphenate_word_in(word: &str, lim: &Limits, lang: ProofLang) -> String { + let pts = hyphen_points_in(word, lim, lang); let mut s = String::with_capacity(word.len() + pts.len()); for (i, ch) in word.chars().enumerate() { if pts.contains(&i) { @@ -221,8 +268,14 @@ pub fn hyphenate_word(word: &str, lim: &Limits) -> String { // ---------- heuristic fallback (words outside the pattern alphabet) ---------- +/// Letters with a diacritic that are consonants (`ł`, `ż`, `ç`, `ñ`…); any other non-ASCII letter +/// counts as a vowel (`é`, `ø`, `ą`…), and so do letters of other scripts, which keeps the +/// heuristic from breaking them. +const NON_ASCII_CONSONANTS: &str = "łńśćźżçčďđğģķļľĺňñņřŕšşșťţțžßŋðþ"; + fn is_vowel(c: char) -> bool { - matches!(c.to_ascii_lowercase(), 'a' | 'e' | 'i' | 'o' | 'u' | 'y') || (c.is_alphabetic() && !c.is_ascii()) + let l = c.to_lowercase().next().unwrap_or(c); + if l.is_ascii() { matches!(l, 'a' | 'e' | 'i' | 'o' | 'u' | 'y') } else { l.is_alphabetic() && !NON_ASCII_CONSONANTS.contains(l) } } fn onset_pair(a: char, b: char) -> bool { @@ -286,6 +339,52 @@ fn heuristic_points(c: &[char]) -> Vec { out } +/// Polish digraphs, one sound each: never split. +fn is_digraph_pl(a: char, b: char) -> bool { + matches!((a, b), ('c', 'h' | 'z') | ('d', 'z' | 'ź' | 'ż') | ('r' | 's', 'z')) +} + +/// Polish syllable heuristic, for words with letters outside the Polish pattern alphabet (`é`, +/// `ü`…): one break between each pair of syllable nuclei — after a leading sonorant (`kon-ty`, +/// `Pol-ska`) or inside a doubled consonant (`Pöp-pel`), else before the consonant cluster +/// (`sio-stra`). Digraphs (`ch`, `cz`, `dz`, `dź`, `dż`, `rz`, `sz`) stay whole, and an `i` that +/// only softens the consonant before a vowel (`nia`, `sio`) stays with that consonant. +fn heuristic_points_pl(c: &[char]) -> Vec { + let n = c.len(); + if n < 4 || !c.iter().all(|c| c.is_alphabetic()) { + return vec![]; + } + let v: Vec = c.iter().map(|&x| is_vowel(x)).collect(); + let at = |i: usize| v.get(i).copied().unwrap_or(false); + // A syllable nucleus: a vowel, except a softening `i` (after a consonant, before a vowel). + let nucleus: Vec = (0..n).map(|i| at(i) && !(c.get(i) == Some(&'i') && i > 0 && !at(i - 1) && at(i + 1))).collect(); + let nuclei: Vec = (0..n).filter(|&i| nucleus.get(i).copied().unwrap_or(false)).collect(); + let mut out = vec![]; + for pair in nuclei.windows(2) { + let [a, b] = pair else { continue }; + let (start, end) = (a + 1, *b); + if start >= end { + continue; // adjacent vowels (na-u-ka): no break inside a vowel pair here + } + // Consonant units of the cluster: digraphs and a softening `i` join their consonant. + let mut units: Vec = Vec::new(); + let mut i = start; + while i < end { + units.push(i); + i += if i + 1 < end && c.get(i).zip(c.get(i + 1)).is_some_and(|(x, y)| is_digraph_pl(*x, *y)) { 2 } else { 1 }; + if i < end && c.get(i) == Some(&'i') { + i += 1; + } + } + let sonorant = |i: usize| matches!(c.get(i), Some('l' | 'ł' | 'm' | 'n' | 'ń' | 'r' | 'j')); + let first_is_single = units.get(1).is_some_and(|&second| second == start + 1); + // A doubled consonant splits (`lek-ki`, `Pöp-pel`), as does a cluster after a sonorant. + let doubled = c.get(start).is_some_and(|x| c.get(start + 1) == Some(x)); + out.push(if units.len() >= 2 && first_is_single && (sonorant(start) || doubled) { start + 1 } else { start }); + } + out +} + #[cfg(test)] mod tests { use super::*; @@ -527,4 +626,76 @@ mod tests { let c: Vec = "happen".chars().collect(); assert_eq!(heuristic_points(&c), vec![3]); } + + #[test] + fn heuristic_knows_consonants_with_diacritics() { + // `ł`, `ż`, `ç` are consonants (every non-ASCII letter used to count as a vowel). + assert!(!is_vowel('ł') && !is_vowel('Ż') && !is_vowel('ç') && is_vowel('é') && is_vowel('ą') && is_vowel('ø')); + let c: Vec = "façade".chars().collect(); + assert_eq!(heuristic_points(&c), vec![2]); + } + + fn pl(w: &str) -> String { + hyphenate_word_in(w, &Limits::default(), ProofLang::Pl) + } + + #[test] + fn polish_patterns_load() { + let p = Patterns::pl(); + assert!(p.len() > 4000, "{}", p.len()); + assert_eq!(p.exception_count(), 20); + assert_eq!(p.exception("nigdy"), Some(&[2][..])); + } + + #[test] + fn polish_words_hyphenate_by_polish_rules() { + for (w, want) in [ + ("konstytucja", "kon-sty-tu-cja"), + ("konstytucyjnego", "kon-sty-tu-cyj-ne-go"), + ("Rzeczpospolita", "Rzecz-po-spo-li-ta"), + ("przedsiębiorstwo", "przed-się-bior-stwo"), + ("pięćdziesięciu", "pięć-dzie-się-ciu"), + ("dziewczyna", "dziew-czy-na"), + ("książka", "książ-ka"), + ("Warszawa", "War-sza-wa"), + ("szczęście", "szczę-ście"), + ("rzeczywistość", "rze-czy-wi-stość"), + ("najpiękniejszy", "naj-pięk-niej-szy"), + ("wyobraźnia", "wy-obraź-nia"), + ("gżegżółka", "gżeg-żół-ka"), + // One syllable, or a single letter left over: no break. + ("chrząszcz", "chrząszcz"), + ("źdźbło", "źdźbło"), + ("obiad", "obiad"), + // The pattern file's exception words. + ("nigdy", "ni-gdy"), + ("przynajmniej", "przy-naj-mniej"), + ("skądinąd", "skąd-inąd"), + // Compounds: each part, never next to the hyphen. + ("biało-czerwony", "bia-ło-czer-wo-ny"), + ] { + assert_eq!(pl(w), want, "{w}"); + } + assert_eq!(word_breaks_in("nigdy", ProofLang::Pl).1, Source::Dictionary); + assert_eq!(word_breaks_in("konstytucja", ProofLang::Pl).1, Source::Patterns); + } + + #[test] + fn polish_heuristic_for_foreign_letters() { + // Letters outside the Polish alphabet: the Polish syllable heuristic, digraphs kept whole. + assert_eq!(word_breaks_in("Pöppelmann", ProofLang::Pl).1, Source::Heuristic); + assert_eq!(pl("Pöppelmann"), "Pöp-pel-mann"); + assert_eq!(pl("Müllerowi"), "Mül-le-ro-wi"); + let c: Vec = "kawiarnié".chars().collect(); + assert_eq!(heuristic_points_pl(&c), vec![2, 6], "ka-wiar-nié: `wi` and `ni` soften, `rn` splits after the sonorant"); + let c: Vec = "szczérze".chars().collect(); + assert_eq!(heuristic_points_pl(&c), vec![5], "szczé-rze: `rz` stays whole"); + } + + #[test] + fn languages_without_patterns_keep_the_english_ones() { + for w in ["hyphenation", "typography", "internationalization"] { + assert_eq!(hyphen_points_in(w, &Limits::default(), ProofLang::Other), hyphen_points(w, &Limits::default()), "{w}"); + } + } } diff --git a/crates/proof/src/lang.rs b/crates/proof/src/lang.rs new file mode 100644 index 000000000..bfd0bea3b --- /dev/null +++ b/crates/proof/src/lang.rs @@ -0,0 +1,131 @@ +//! Proofing languages: which dictionary, grammar rules and hyphenation patterns apply to a run of +//! text. A run's language is its BCP 47 tag (`w:lang` in .docx, `CharProps::lang` in the model), +//! resolved with [`ProofLang::from_tag`]. + +/// A language WordCraft can proof, or [`ProofLang::Other`] for one it can't. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum ProofLang { + /// English, any region (`en`, `en-US`, `en-GB`…): the public-domain Moby word list, the + /// English grammar rules and the English hyphenation dictionary and patterns. Also text with + /// no language at all (WordCraft's default editing language is US English, like Word's). + #[default] + En, + /// Polish (`pl`, `pl-PL`): the installed Hunspell dictionary (when there is one), the Polish grammar rules and the Polish + /// hyphenation patterns. + Pl, + /// A language without proofing tools here (`fr-FR`, `ja-JP`, `x-none`…). Like Word without + /// the language's proofing tools: no spelling or grammar marks, so its words are never + /// flagged as English misspellings. Hyphenation keeps using the English patterns, as before + /// languages were told apart. + Other, +} + +impl ProofLang { + /// Every language with a spelling dictionary, in display order. + pub const PROOFED: [ProofLang; 2] = [ProofLang::En, ProofLang::Pl]; + + /// The proofing language of a BCP 47 tag (`pl-PL`, `en_GB`, `PL`…). Only the primary subtag + /// counts. No tag (or an empty one) is English, WordCraft's default editing language. + pub fn from_tag(tag: Option<&str>) -> ProofLang { + let Some(tag) = tag.map(str::trim).filter(|t| !t.is_empty()) else { return ProofLang::En }; + let primary = tag.split(['-', '_']).next().unwrap_or(""); + if primary.eq_ignore_ascii_case("en") || primary.eq_ignore_ascii_case("eng") { + ProofLang::En + } else if primary.eq_ignore_ascii_case("pl") || primary.eq_ignore_ascii_case("pol") { + ProofLang::Pl + } else { + ProofLang::Other + } + } + + /// The primary language subtag (`en`, `pl`); empty for [`ProofLang::Other`]. + pub fn code(self) -> &'static str { + match self { + ProofLang::En => "en", + ProofLang::Pl => "pl", + ProofLang::Other => "", + } + } + + /// Does WordCraft check spelling and grammar in this language? + pub fn is_proofed(self) -> bool { + self != ProofLang::Other + } +} + +/// A BCP 47 tag in canonical case (`pl-PL`, `en-GB`, `zh-Hant-TW`, `x-none`), or `None` when +/// `tag` isn't shaped like one: 1–35 ASCII letters, digits and `-`/`_` separators, starting with +/// a 2–8 letter language (or `x`/`i` for private and grandfathered tags). `_` becomes `-`. +pub fn normalize_tag(tag: &str) -> Option { + let tag = tag.trim(); + if tag.is_empty() || tag.len() > 35 || !tag.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') { + return None; + } + let parts: Vec<&str> = tag.split(['-', '_']).collect(); + if parts.iter().any(|p| p.is_empty() || p.len() > 8) { + return None; + } + let first = parts.first()?; + let language = (2..=8).contains(&first.len()) && first.chars().all(|c| c.is_ascii_alphabetic()); + if !language && !first.eq_ignore_ascii_case("x") && !first.eq_ignore_ascii_case("i") { + return None; + } + let letters = |p: &str| p.chars().all(|c| c.is_ascii_alphabetic()); + // After a singleton (`x-…` private use, `u-…` extensions) subtags are lower case. + let singleton = parts.iter().position(|p| p.len() == 1).unwrap_or(usize::MAX); + let out: Vec = parts + .iter() + .enumerate() + .map(|(i, p)| { + if i >= singleton { + p.to_ascii_lowercase() + } else if i > 0 && p.len() == 2 && letters(p) { + p.to_ascii_uppercase() // region + } else if i > 0 && p.len() == 4 && letters(p) { + let lower = p.to_ascii_lowercase(); + let mut c = lower.chars(); + c.next().map(|f| f.to_ascii_uppercase().to_string() + c.as_str()).unwrap_or_default() // script + } else { + p.to_ascii_lowercase() + } + }) + .collect(); + Some(out.join("-")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tags_normalize() { + assert_eq!(normalize_tag("PL-pl").as_deref(), Some("pl-PL")); + assert_eq!(normalize_tag(" pl_pl ").as_deref(), Some("pl-PL")); + assert_eq!(normalize_tag("pl").as_deref(), Some("pl")); + assert_eq!(normalize_tag("zh-hant-tw").as_deref(), Some("zh-Hant-TW")); + assert_eq!(normalize_tag("es-419").as_deref(), Some("es-419")); + assert_eq!(normalize_tag("x-none").as_deref(), Some("x-none")); + assert_eq!(normalize_tag("de-DE-u-co-PHONEBK").as_deref(), Some("de-DE-u-co-phonebk")); + for bad in ["", " ", "p", "1pl", "pl--PL", "pl PL", "pl-PL;", "polski język", "-pl", "pl-", "abcdefghi", &"a".repeat(40)] { + assert_eq!(normalize_tag(bad), None, "{bad:?}"); + } + } + + #[test] + fn tags_resolve() { + for t in ["pl", "pl-PL", "PL-pl", "pl_PL", "pl_PL.UTF-8", " pl-PL ", "pol"] { + assert_eq!(ProofLang::from_tag(Some(t)), ProofLang::Pl, "{t}"); + } + for t in ["en", "en-US", "en-GB", "EN_au", "en-Latn-US"] { + assert_eq!(ProofLang::from_tag(Some(t)), ProofLang::En, "{t}"); + } + for t in ["fr-FR", "de", "x-none", "ja-JP", "plx", "e", "-"] { + assert_eq!(ProofLang::from_tag(Some(t)), ProofLang::Other, "{t}"); + } + assert_eq!(ProofLang::from_tag(None), ProofLang::En); + assert_eq!(ProofLang::from_tag(Some("")), ProofLang::En); + assert_eq!(ProofLang::from_tag(Some(" ")), ProofLang::En); + assert!(ProofLang::Pl.is_proofed() && !ProofLang::Other.is_proofed()); + assert_eq!(ProofLang::Pl.code(), "pl"); + } +} diff --git a/crates/proof/src/lib.rs b/crates/proof/src/lib.rs index 6651ea670..925ab7ecf 100644 --- a/crates/proof/src/lib.rs +++ b/crates/proof/src/lib.rs @@ -1,20 +1,34 @@ -//! WordCraft proofing. +//! WordCraft proofing, per language. A run's language (its BCP 47 tag) picks the tools through +//! [`ProofLang`]; the `*_in` functions take it, the older English-only ones remain. //! -//! - **Spelling** against the public-domain Moby word list (~160k words, the same list that -//! drives hyphenation) plus our supplement of modern words it lacks -//! (`assets/spelling/en-extra.txt`: `desktop`, `email`, `download`…), with inflections (`-s`, -//! `-es`, `-ed`, `-ing`, `-ly`, `'s`…), a user dictionary and ignore lists; suggestions by edit -//! distance. -//! - **Grammar** checks that are cheap and reliable: repeated words, `a`/`an`, capital letter -//! after a sentence end, spaces before punctuation, doubled spaces. -//! - **Hyphenation** ([`hyphen`]): dictionary, Liang patterns, heuristic. +//! - **English** ([`ProofLang::En`]; also text with no language): spelling against the +//! public-domain Moby word list (~160k words, the same list that drives hyphenation) plus our +//! supplement of modern words it lacks (`assets/spelling/en-extra.txt`: `desktop`, `email`, +//! `download`…), with inflections (`-s`, `-es`, `-ed`, `-ing`, `-ly`, `'s`…), a user dictionary and ignore lists; +//! suggestions by edit distance. Grammar checks that are cheap and reliable: repeated words, +//! `a`/`an`, capital letter after a sentence end, spaces before punctuation, doubled spaces. +//! - **Polish** ([`ProofLang::Pl`], module [`pl`]): spelling against the Hunspell `pl_PL` +//! dictionary installed on the system ([`hunspell`]; not bundled, so without it Polish has no +//! spelling marks), read by the affix engine ([`affix`]), suggestions tuned for Polish +//! (diacritics, `ż`/`rz`, `u`/`ó`, missing spaces), and Polish grammar rules with Polish +//! messages. +//! - **Other languages** ([`ProofLang::Other`]): no spelling or grammar marks, like Word without +//! the language's proofing tools. +//! - **Hyphenation** ([`hyphen`]): dictionary, Liang patterns (English, Polish), heuristic. +//! +//! [`check_text`] checks a paragraph whose runs are in different languages. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub mod affix; pub mod dict; +pub mod hunspell; pub mod hyphen; +pub mod lang; pub mod patterns; +pub mod pl; use std::collections::HashSet; +use std::ops::Range; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{OnceLock, RwLock}; @@ -35,14 +49,20 @@ impl Default for Options { } } +pub use lang::ProofLang; + /// A problem found in text: byte range, kind, message and suggestions. #[derive(Clone, Debug, PartialEq)] pub struct Issue { pub start: usize, pub end: usize, pub kind: IssueKind, + /// In the language of the text it was found in (Polish rules explain in Polish). pub message: String, pub suggestions: Vec, + /// The language the text was checked in: spelling suggestions come from its dictionary + /// ([`suggest_in`]). + pub lang: ProofLang, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -59,7 +79,7 @@ fn user_words() -> &'static RwLock> { /// Bumped whenever the user dictionary changes, so cached proofing results can be dropped. static DICTIONARY_GENERATION: AtomicU64 = AtomicU64::new(0); -/// Add a word to the user dictionary ("Add to Dictionary"). +/// Add a word to the user dictionary ("Add to Dictionary"). It applies to every language. pub fn add_word(w: &str) { if user_words().write().unwrap_or_else(|e| e.into_inner()).insert(w.to_lowercase()) { DICTIONARY_GENERATION.fetch_add(1, Ordering::Relaxed); @@ -80,6 +100,11 @@ pub fn dictionary_generation() -> u64 { DICTIONARY_GENERATION.load(Ordering::Relaxed) } +/// Is `lower` (a lower-case word) in the user dictionary? +pub(crate) fn in_user_dictionary(lower: &str) -> bool { + user_words().read().unwrap_or_else(|e| e.into_inner()).contains(lower) +} + /// Words in the user dictionary. pub fn user_dictionary() -> Vec { let mut v: Vec = user_words().read().unwrap_or_else(|e| e.into_inner()).iter().cloned().collect(); @@ -161,24 +186,28 @@ fn known_core(w: &str) -> bool { false } -/// Is `word` spelled correctly? Numbers, single letters, words in capitals and words with -/// digits are accepted (Word's default [`Options`]). +/// Is `word` spelled correctly? Numbers, single letters, ALL-CAPS acronyms, URLs and words with +/// digits are accepted. pub fn is_correct(word: &str) -> bool { is_correct_with(word, &Options::default()) } +/// The words every language's check accepts under `opts`: single letters, words without +/// letters, words with digits (Ignore words that contain numbers) and words in capitals +/// (Ignore words in UPPERCASE). +fn accepted_by_options(word: &str, opts: &Options) -> bool { + let w = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '\'').trim_matches('\''); + w.chars().count() <= 1 + || !w.chars().any(char::is_alphabetic) + || (opts.ignore_numbers && w.chars().any(|c| c.is_ascii_digit())) + || (opts.ignore_uppercase && w.chars().all(|c| !c.is_lowercase())) +} + /// [`is_correct`] with the File › Options › Proofing choices. pub fn is_correct_with(word: &str, opts: &Options) -> bool { let w = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '\''); let w = w.trim_matches('\''); - if w.chars().count() <= 1 || !w.chars().any(char::is_alphabetic) { - return true; - } - let digits = w.chars().any(|c| c.is_ascii_digit()); - if digits && opts.ignore_numbers { - return true; - } - if opts.ignore_uppercase && w.chars().all(|c| !c.is_lowercase()) { + if accepted_by_options(w, opts) { return true; } if !w.is_ascii() && !w.chars().any(|c| c.is_ascii_alphabetic()) { @@ -299,31 +328,129 @@ pub fn words(text: &str) -> Vec<(usize, usize)> { .collect() } -/// Spelling issues in a paragraph of text (no suggestions; ask [`suggest`] on demand). +/// Spelling issues in a paragraph of English text (no suggestions; ask [`suggest`] on demand). pub fn check_spelling(text: &str) -> Vec { check_spelling_with(text, &Options::default()) } /// [`check_spelling`] with the File › Options › Proofing choices. pub fn check_spelling_with(text: &str, opts: &Options) -> Vec { - let mut v = Vec::new(); - for (a, b) in words(text) { - let Some(w) = text.get(a..b) else { continue }; - // Skip URLs/emails. - let ts = text.get(..a).and_then(|t| t.rfind(char::is_whitespace)).map(|i| i + 1).unwrap_or(0); - let te = text.get(b..).and_then(|t| t.find(char::is_whitespace)).map(|i| b + i).unwrap_or(text.len()); - let token = text.get(ts..te).unwrap_or(""); - if opts.ignore_internet && (token.contains("://") || token.contains('@') || token.starts_with("www.")) { - continue; - } - if !is_correct_with(w, opts) { - v.push(Issue { start: a, end: b, kind: IssueKind::Spelling, message: "Possible spelling mistake".into(), suggestions: Vec::new() }); + words(text).into_iter().filter_map(|(a, b)| spelling_issue(text, a, b, ProofLang::En, opts)).collect() +} + +/// Spelling issues in a paragraph of text in `lang` (none for a language without a dictionary; +/// no suggestions: ask [`suggest_in`] on demand). +pub fn check_spelling_in(text: &str, lang: ProofLang) -> Vec { + if !lang.is_proofed() { + return Vec::new(); + } + words(text).into_iter().filter_map(|(a, b)| spelling_issue(text, a, b, lang, &Options::default())).collect() +} + +/// The spelling issue of the word at `a..b` of `text`, if it is misspelled in `lang`. Words in +/// web and e-mail addresses are skipped (unless `opts` says otherwise). +fn spelling_issue(text: &str, a: usize, b: usize, lang: ProofLang, opts: &Options) -> Option { + let w = text.get(a..b)?; + let ts = text.get(..a).and_then(|t| t.rfind(char::is_whitespace)).map(|i| i + 1).unwrap_or(0); + let te = text.get(b..).and_then(|t| t.find(char::is_whitespace)).map(|i| b + i).unwrap_or(text.len()); + let token = text.get(ts..te).unwrap_or(""); + let internet = token.contains("://") || token.contains('@') || token.starts_with("www."); + if (opts.ignore_internet && internet) || is_correct_in_with(w, lang, opts) { + return None; + } + let message = match lang { + ProofLang::Pl => "Możliwy błąd pisowni", + _ => "Possible spelling mistake", + }; + Some(Issue { start: a, end: b, kind: IssueKind::Spelling, message: message.into(), suggestions: Vec::new(), lang }) +} + +/// Is `word` spelled correctly in `lang`? Always true in a language without a dictionary. +pub fn is_correct_in(word: &str, lang: ProofLang) -> bool { + is_correct_in_with(word, lang, &Options::default()) +} + +/// [`is_correct_in`] with the File › Options › Proofing choices. +pub fn is_correct_in_with(word: &str, lang: ProofLang, opts: &Options) -> bool { + match lang { + ProofLang::En => is_correct_with(word, opts), + ProofLang::Pl => accepted_by_options(word, opts) || pl::is_correct(word), + ProofLang::Other => true, + } +} + +/// Spelling suggestions for a word misspelled in `lang` (best first, at most `max`). +pub fn suggest_in(word: &str, max: usize, lang: ProofLang) -> Vec { + match lang { + ProofLang::En => suggest(word, max), + ProofLang::Pl => pl::suggest(word, max), + ProofLang::Other => Vec::new(), + } +} + +/// Grammar issues in a paragraph of text in `lang`: the English rules, the Polish ones +/// ([`pl::grammar`]), or none for a language WordCraft doesn't proof. +pub fn check_grammar_in(text: &str, lang: ProofLang) -> Vec { + match lang { + ProofLang::En => check_grammar(text), + ProofLang::Pl => pl::grammar::check(text), + ProofLang::Other => Vec::new(), + } +} + +/// Spelling and grammar issues in a paragraph whose text is in several languages, in order. +/// `spans` gives the language of byte ranges of `text` (in order, not overlapping); text outside +/// them is English. Each word is spelled in the language of its first letter. Each language's +/// grammar rules read the whole paragraph (so sentence boundaries are seen) but report only +/// inside that language's spans: an English quotation in a Polish paragraph gets English +/// spelling and is never told a Polish rule. +pub fn check_text(text: &str, spans: &[(Range, ProofLang)]) -> Vec { + check_text_with(text, spans, &Options::default(), true) +} + +/// [`check_text`] with the File › Options › Proofing choices: what spelling skips, and whether +/// grammar is checked at all. +pub fn check_text_with(text: &str, spans: &[(Range, ProofLang)], opts: &Options, grammar: bool) -> Vec { + // Sorted, empty ones dropped, neighbours of one language merged. + let mut sorted: Vec<&(Range, ProofLang)> = spans.iter().filter(|(r, _)| r.start < r.end).collect(); + sorted.sort_by_key(|s| s.0.start); + let mut merged: Vec<(Range, ProofLang)> = Vec::new(); + for (r, l) in sorted { + match merged.last_mut() { + Some((m, ml)) if *ml == *l && m.end == r.start => m.end = r.end, + _ => merged.push((r.clone(), *l)), } } + let lang_at = |i: usize| -> ProofLang { + let k = merged.partition_point(|(r, _)| r.end <= i); + merged.get(k).filter(|(r, _)| r.start <= i).map_or(ProofLang::En, |s| s.1) + }; + let mut v: Vec = words(text).into_iter().filter_map(|(a, b)| spelling_issue(text, a, b, lang_at(a), opts)).collect(); + if !grammar { + v.sort_by_key(|i| (i.start, i.end)); + return v; + } + let mut langs: Vec = merged.iter().map(|s| s.1).collect(); + let mut covered = 0; + for (r, _) in &merged { + if r.start > covered { + break; + } + covered = covered.max(r.end); + } + if covered < text.len() || merged.is_empty() { + langs.push(ProofLang::En); + } + langs.sort(); + langs.dedup(); + for lang in langs { + v.extend(check_grammar_in(text, lang).into_iter().filter(|i| lang_at(i.start) == lang)); + } + v.sort_by_key(|i| (i.start, i.end)); v } -/// Grammar issues in a paragraph of text. +/// Grammar issues in a paragraph of English text. pub fn check_grammar(text: &str) -> Vec { let mut v = Vec::new(); let ws = words(text); @@ -341,6 +468,7 @@ pub fn check_grammar(text: &str) -> Vec { kind: IssueKind::Grammar, message: format!("Repeated word: \"{w1}\""), suggestions: vec![w0.to_string()], + lang: ProofLang::En, }); } if gap == " " && (w0 == "a" || w0 == "A") && w1.chars().next().is_some_and(|c| "aeiouAEIOU".contains(c)) && !starts_consonant_sound(w1) { @@ -350,6 +478,7 @@ pub fn check_grammar(text: &str) -> Vec { kind: IssueKind::Grammar, message: "Use \"an\" before a vowel sound".into(), suggestions: vec![if w0 == "A" { "An" } else { "an" }.into()], + lang: ProofLang::En, }); } if gap == " " @@ -363,6 +492,7 @@ pub fn check_grammar(text: &str) -> Vec { kind: IssueKind::Grammar, message: "Use \"a\" before a consonant sound".into(), suggestions: vec![if w0 == "An" { "A" } else { "a" }.into()], + lang: ProofLang::En, }); } } @@ -377,13 +507,21 @@ pub fn check_grammar(text: &str) -> Vec { kind: IssueKind::Grammar, message: "Remove the space before the punctuation".into(), suggestions: vec![text.get(i + 1..i + 2).unwrap_or("").to_string()], + lang: ProofLang::En, }); } for (i, _) in text.match_indices(" ") { if i > 0 && text.get(..i).is_some_and(|t| t.ends_with(' ')) { continue; } - v.push(Issue { start: i, end: i + 2, kind: IssueKind::Grammar, message: "Extra space".into(), suggestions: vec![" ".into()] }); + v.push(Issue { + start: i, + end: i + 2, + kind: IssueKind::Grammar, + message: "Extra space".into(), + suggestions: vec![" ".into()], + lang: ProofLang::En, + }); } // Sentence start capital. let mut after_end = true; @@ -397,6 +535,7 @@ pub fn check_grammar(text: &str) -> Vec { kind: IssueKind::Grammar, message: "Capitalize the first word of a sentence".into(), suggestions: vec![fixed], + lang: ProofLang::En, }); } let tail = text.get(*b..).unwrap_or(""); @@ -541,6 +680,51 @@ mod tests { assert!(hyphen::hyphenate_word("hyphenation", &l).matches('-').count() >= 2); } + fn flagged(t: &str, v: &[Issue]) -> Vec { + v.iter().filter(|i| i.kind == IssueKind::Spelling).map(|i| t[i.start..i.end].to_string()).collect() + } + + #[test] + fn each_language_uses_its_own_tools() { + pl::test_dict::install(); + let pl = "Zażółć gęślą jaźń, chrząszcz brzmi w trzcinie."; + assert!(check_spelling_in(pl, ProofLang::Pl).is_empty(), "{:?}", check_spelling_in(pl, ProofLang::Pl)); + assert!(check_spelling_in(pl, ProofLang::En).len() >= 3, "Polish words are English misspellings"); + assert!(check_spelling_in("Nous écrivons en français.", ProofLang::Other).is_empty()); + assert!(check_grammar_in("this is is wrong", ProofLang::Other).is_empty()); + assert!(is_correct_in("anything", ProofLang::Other) && suggest_in("xyzzy", 5, ProofLang::Other).is_empty()); + let v = check_spelling_in("Płynie żeka.", ProofLang::Pl); + assert_eq!(flagged("Płynie żeka.", &v), ["żeka"]); + assert_eq!((v[0].lang, v[0].message.as_str()), (ProofLang::Pl, "Możliwy błąd pisowni")); + assert!(suggest_in("żeka", 5, ProofLang::Pl).contains(&"rzeka".to_string())); + assert!(suggest_in("recieve", 5, ProofLang::En).contains(&"receive".to_string())); + // Polish grammar speaks Polish and has no English a/an rule. + let g = check_grammar_in("Wiem że to jest a apple.", ProofLang::Pl); + assert!(g.iter().any(|i| i.message == "Brak przecinka przed „że”"), "{g:?}"); + assert!(!g.iter().any(|i| i.message.contains("\"an\"")), "{g:?}"); + } + + #[test] + fn mixed_language_paragraphs_check_each_run_in_its_language() { + pl::test_dict::install(); + // A Polish paragraph quoting English: English spelling and rules inside the quote only. + let t = "Napisał „the quick brown fox is is here” i poszedł spać, bo żeka wylała."; + let (a, b) = (t.find("the").unwrap(), t.find('”').unwrap()); + let spans = [(0..a, ProofLang::Pl), (a..b, ProofLang::En), (b..t.len(), ProofLang::Pl)]; + let v = check_text(t, &spans); + assert_eq!(flagged(t, &v), ["żeka"], "{v:?}"); + let rep = v.iter().find(|i| i.kind == IssueKind::Grammar).unwrap(); + assert_eq!((rep.lang, rep.message.as_str()), (ProofLang::En, "Repeated word: \"is\""), "{v:?}"); + // The English rules don't run over the Polish text (no "Capitalize" for the lower-case + // Polish words), and the Polish rules don't run over the English quote. + assert!(v.iter().all(|i| i.lang == ProofLang::Pl || (a..b).contains(&i.start)), "{v:?}"); + // Text outside every span is English; no spans at all is English. + assert_eq!(flagged("helo świat", &check_text("helo świat", &[])), ["helo", "świat"]); + assert_eq!(flagged("helo świat", &check_text("helo świat", &[(5..12, ProofLang::Pl)])), ["helo"]); + // A language without tools: nothing flagged there. + assert!(check_text("Bonjour mes amis", &[(0..16, ProofLang::Other)]).is_empty()); + } + proptest::proptest! { #[test] fn never_panics(s in "\\PC{0,80}") { @@ -548,6 +732,28 @@ mod tests { let _ = check_grammar(&s); let _ = suggest(&s, 3); let _ = hyphen::hyphen_points(&s, &hyphen::Limits::default()); + for lang in [ProofLang::En, ProofLang::Pl, ProofLang::Other] { + let _ = check_spelling_in(&s, lang); + let _ = check_grammar_in(&s, lang); + let _ = is_correct_in(&s, lang); + let _ = hyphen::hyphen_points_in(&s, &hyphen::Limits::default(), lang); + } + let _ = lang::normalize_tag(&s); + let _ = ProofLang::from_tag(Some(&s)); + } + + #[test] + fn polish_suggestions_never_panic(s in "[a-ząćęłńóśźżA-ZĄĆĘŁŃÓŚŹŻ'’ -]{0,14}") { + let _ = suggest_in(&s, 4, ProofLang::Pl); + } + + #[test] + fn mixed_spans_never_panic(s in "\\PC{0,60}", cuts in proptest::collection::vec((0usize..80, 0usize..80, 0u8..3), 0..6)) { + let langs = [ProofLang::En, ProofLang::Pl, ProofLang::Other]; + let spans: Vec<(Range, ProofLang)> = cuts.iter().map(|&(a, b, l)| (a.min(b)..a.max(b), langs[l as usize % 3])).collect(); + for i in check_text(&s, &spans) { + proptest::prop_assert!(i.start <= i.end && i.end <= s.len()); + } } } } diff --git a/crates/proof/src/patterns.rs b/crates/proof/src/patterns.rs index d3e406006..50dd74444 100644 --- a/crates/proof/src/patterns.rs +++ b/crates/proof/src/patterns.rs @@ -1,18 +1,22 @@ //! Liang hyphenation patterns (TeX notation, e.g. `.ab1c`, `4t1ion`): parsing and application. //! //! The bundled English set is *ours*: trained from the public-domain Moby Hyphenator word list by -//! DesignCraft's patgen (see `examples/hyphgen.rs`). Patterns are stored in a hash map keyed by the -//! packed letter string, so applying them to a word costs `O(len × max_pattern_len)` lookups. +//! DesignCraft's patgen (see `examples/hyphgen.rs`). The Polish set is the TeX one by Hanna +//! Kołodziejska, Bogusław Jackowski and Marek Ryćko (hyph-utf8 `hyph-pl.tex`, used under its MIT +//! licence option), bundled unmodified: [`Patterns::parse`] reads TeX's `\patterns{…}` and +//! `\hyphenation{…}` blocks directly. Patterns are stored in a hash map keyed by the packed letter +//! string, so applying them to a word costs `O(len × max_pattern_len)` lookups. use std::collections::HashMap; use std::hash::{BuildHasherDefault, Hasher}; /// Word-boundary marker in patterns. pub const DOT: char = '.'; -/// Longest pattern the packed key supports. -pub const MAX_PATTERN_LEN: usize = 9; +/// Longest pattern the packed key supports (6 bits a letter in a `u128`; the Polish set's longest +/// is 14 including the dots). +pub const MAX_PATTERN_LEN: usize = 20; -/// Small, fast multiplicative hasher for packed `u64` keys (FxHash-style). +/// Small, fast multiplicative hasher for packed integer keys and short strings (FxHash-style). #[derive(Default, Clone, Copy)] pub struct FxHasher(u64); @@ -21,13 +25,27 @@ impl Hasher for FxHasher { self.0 } fn write(&mut self, bytes: &[u8]) { - for &b in bytes { + let (chunks, rest) = bytes.as_chunks::<8>(); + for c in chunks { + self.write_u64(u64::from_le_bytes(*c)); + } + for &b in rest { self.write_u64(b as u64); } } + fn write_u8(&mut self, i: u8) { + self.write_u64(i as u64); + } + fn write_usize(&mut self, i: usize) { + self.write_u64(i as u64); + } fn write_u64(&mut self, i: u64) { self.0 = (self.0.rotate_left(5) ^ i).wrapping_mul(0x51_7c_c1_b7_27_22_0a_95); } + fn write_u128(&mut self, i: u128) { + self.write_u64(i as u64); + self.write_u64((i >> 64) as u64); + } } pub type FxMap = HashMap>; @@ -72,36 +90,67 @@ impl Alphabet { } } -/// Packed key of a code string (≤ [`MAX_PATTERN_LEN`] codes). +/// Packed key of a code string (≤ [`MAX_PATTERN_LEN`] codes): 6 bits a code, the length on top. #[inline] -pub fn pack(codes: &[u8]) -> u64 { - let mut k = 0u64; +pub fn pack(codes: &[u8]) -> u128 { + let mut k = 0u128; for &c in codes { - k = (k << 6) | c as u64; + k = (k << 6) | c as u128; } - k | ((codes.len() as u64) << 58) + k | ((codes.len() as u128) << 120) } -/// A compiled pattern set. +/// A compiled pattern set, with its exception words (TeX's `\hyphenation{…}`). #[derive(Clone, Debug, Default)] pub struct Patterns { alpha: Alphabet, /// Packed letters → inter-letter values (`len + 1` entries). - map: FxMap>, + map: FxMap>, max_len: usize, + /// Lowercase word → its break points (char indices), overriding the patterns. + exceptions: HashMap>, +} + +/// Which TeX block [`Patterns::parse`] is reading. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Block { + Patterns, + Exceptions, } impl Patterns { - /// Parse TeX-style patterns: whitespace-separated, `%`/`#` start a comment line. + /// Parse patterns: whitespace-separated, `%` comments to the end of the line (`#` too at the + /// start of a line). A whole TeX pattern file works as is: `\patterns{…}` holds patterns and + /// `\hyphenation{…}` exception words written with their hyphens (`ni-gdy`); outside any + /// block, a token with a hyphen is an exception and anything else a pattern. pub fn parse(text: &str) -> Patterns { let mut p = Patterns::default(); + let mut block = Block::Patterns; for line in text.lines() { let line = line.trim(); - if line.is_empty() || line.starts_with('%') || line.starts_with('#') { + if line.starts_with('#') { continue; } + let line = line.split('%').next().unwrap_or(""); for tok in line.split_whitespace() { - p.insert(tok); + let mut tok = tok; + if let Some(cmd) = tok.strip_prefix('\\') { + let (name, rest) = cmd.split_once('{').unwrap_or((cmd, "")); + block = if name == "hyphenation" { Block::Exceptions } else { Block::Patterns }; + tok = rest; + } + let closes = tok.ends_with('}'); + let tok = tok.trim_end_matches('}'); + if !tok.is_empty() { + if block == Block::Exceptions || tok.contains('-') { + p.insert_exception(tok); + } else { + p.insert(tok); + } + } + if closes { + block = Block::Patterns; + } } } p @@ -128,9 +177,40 @@ impl Patterns { true } + /// Add an exception word written with its hyphens (`ni-gdy`; no hyphen: never broken). + pub fn insert_exception(&mut self, word: &str) { + let mut plain = String::with_capacity(word.len()); + let mut points = Vec::new(); + let mut n = 0usize; + for c in word.chars() { + if c == '-' { + if n > 0 && points.last() != Some(&n) { + points.push(n); + } + } else { + plain.extend(c.to_lowercase()); + n += 1; + } + } + points.retain(|&i| i < n); + if n > 0 { + self.exceptions.insert(plain, points.into_boxed_slice()); + } + } + + /// Break points of an exception word (lowercase), if it is one. + pub fn exception(&self, word: &str) -> Option<&[usize]> { + self.exceptions.get(word).map(|b| &b[..]) + } + + /// Number of exception words. + pub fn exception_count(&self) -> usize { + self.exceptions.len() + } + #[allow(dead_code)] - pub(crate) fn from_parts(alpha: Alphabet, map: FxMap>, max_len: usize) -> Patterns { - Patterns { alpha, map, max_len } + pub(crate) fn from_parts(alpha: Alphabet, map: FxMap>, max_len: usize) -> Patterns { + Patterns { alpha, map, max_len, exceptions: HashMap::new() } } /// Make `letters` part of the alphabet even if no pattern mentions them. @@ -164,13 +244,13 @@ impl Patterns { let mut vals = vec![0u8; codes.len() + 1]; apply(&self.map, self.max_len, &codes, &mut vals); // Padded position p sits before padded char p; word char i is padded char i + 1. - Some(vals[1..codes.len()].to_vec()) + vals.get(1..codes.len()).map(<[u8]>::to_vec) } /// Break points (char indices, a hyphen goes before that char) of a lowercase word. pub fn points(&self, word: &[char]) -> Option> { let v = self.values(word)?; - Some((1..word.len()).filter(|&i| v[i] % 2 == 1).collect()) + Some((1..word.len()).filter(|&i| v.get(i).is_some_and(|x| x % 2 == 1)).collect()) } /// The patterns in TeX notation (sorted by letters), e.g. for writing a pattern file. @@ -183,17 +263,17 @@ impl Patterns { .map .iter() .map(|(&key, vals)| { - let n = (key >> 58) as usize; + let n = ((key >> 120) as usize).min(MAX_PATTERN_LEN); let letters: Vec = (0..n).map(|i| ch(((key >> (6 * (n - 1 - i))) & 63) as u8)).collect(); let mut s = String::new(); for (i, l) in letters.iter().enumerate() { - if vals[i] > 0 { - s.push(char::from(b'0' + vals[i])); + if let Some(&v) = vals.get(i).filter(|v| **v > 0) { + s.push(char::from(b'0' + v.min(9))); } s.push(*l); } - if vals[n] > 0 { - s.push(char::from(b'0' + vals[n])); + if let Some(&v) = vals.get(n).filter(|v| **v > 0) { + s.push(char::from(b'0' + v.min(9))); } (letters.into_iter().collect(), s) }) @@ -203,18 +283,20 @@ impl Patterns { } } -/// Apply `map` to padded `codes`, raising `vals` (len = codes.len() + 1). +/// Apply `map` to padded `codes`, raising `vals` (len = codes.len() + 1; shorter is tolerated). #[inline] -pub fn apply(map: &FxMap>, max_len: usize, codes: &[u8], vals: &mut [u8]) { +pub fn apply(map: &FxMap>, max_len: usize, codes: &[u8], vals: &mut [u8]) { let n = codes.len(); for s in 0..n { - let mut k = 0u64; - for len in 1..=max_len.min(n - s) { - k = (k << 6) | codes[s + len - 1] as u64; - if let Some(v) = map.get(&(k | ((len as u64) << 58))) { + let mut k = 0u128; + for (len, &c) in codes.iter().skip(s).take(max_len.min(MAX_PATTERN_LEN)).enumerate() { + k = (k << 6) | c as u128; + if let Some(v) = map.get(&(k | (((len + 1) as u128) << 120))) { for (j, &x) in v.iter().enumerate() { - if x > vals[s + j] { - vals[s + j] = x; + if let Some(slot) = vals.get_mut(s + j) + && x > *slot + { + *slot = x; } } } @@ -237,4 +319,28 @@ mod tests { assert!(tex.contains(&"hen5at".to_string())); assert!(p.values(&['h', 'é']).is_none()); } + + #[test] + fn tex_files_parse_with_blocks_comments_and_exceptions() { + let p = Patterns::parse( + "% a comment\n\\patterns{\n1na n2at % trailing comment\n.ab4cdefghijklm5n\n}\n\\hyphenation{\nni-gdy\nna-dal\nzawsze\n}\nhy-phen-ation\n", + ); + assert_eq!(p.len(), 3); + assert_eq!(p.exception("nigdy"), Some(&[2][..])); + assert_eq!(p.exception("nadal"), Some(&[2][..])); + assert_eq!(p.exception("zawsze"), Some(&[][..])); + assert_eq!(p.exception("hyphenation"), Some(&[2, 6][..])); + assert_eq!(p.exception_count(), 4); + // A pattern longer than the old 9-letter limit applies. + let w: Vec = "abcdefghijklmn".chars().collect(); + assert_eq!(p.values(&w).unwrap()[13], 5); + assert!(p.to_tex().contains(&".ab4cdefghijklm5n".to_string())); + } + + #[test] + fn apply_tolerates_short_value_buffers() { + let p = Patterns::parse("a1b"); + let mut vals = [0u8; 1]; + apply(&p.map, p.max_len, &[1, 2, 3, 1], &mut vals); + } } diff --git a/crates/proof/src/pl/grammar.rs b/crates/proof/src/pl/grammar.rs new file mode 100644 index 000000000..ec9d67711 --- /dev/null +++ b/crates/proof/src/pl/grammar.rs @@ -0,0 +1,542 @@ +//! Polish grammar and punctuation checks: cheap, rule-based and tuned to stay quiet when unsure +//! (a missed comma costs less than a wrong mark on correct text). Messages are in Polish. +//! +//! - **Repeated word** (`się się`), except interjections and words Polish doubles on purpose +//! (`to to`, `tak tak`, `ha ha`). +//! - **Space before punctuation** (`tak ,`) and **double spaces**; emoticons (` :)`) and +//! ellipses are left alone. +//! - **Capital letter at the start of a sentence**, knowing Polish abbreviations (`np.`, `itd.`, +//! `r.`, `m.in.`), initials, ordinal numbers (`5. miejsce`), ellipses and dialogue dashes +//! (`– Tak? – zapytał`), after which lower case is correct. +//! - **Missing comma** before subordinating conjunctions and relative pronouns that always open a +//! clause: `że`, `ponieważ`, `gdyż`, `aby`, `żeby`, `jeśli`, `jeżeli`, `gdyby`, `lecz` and the +//! forms of `który` — unless a conjunction or particle comes first (`mimo że`, `chyba że`, +//! `tak aby`, `i który`) or, for `który`, a preposition (`w którym`: the comma goes before it). +//! - **`we`/`ze`** where the next word's consonant cluster requires them (`we wtorek`, +//! `ze szkoły`, `ze mną`). +//! - **Doubled punctuation** (`,,` — often a typed opening quote `„` — `..`, `;;`). +//! +//! No English rule (such as `a`/`an`) applies to Polish text. + +use crate::{Issue, IssueKind, ProofLang, words}; + +/// Words Polish repeats on purpose: interjections, onomatopoeia and emphatic doubling. +const ALLOWED_REPEATS: &[&str] = &[ + "to", "tak", "nie", "no", "ha", "he", "hi", "hej", "oj", "aj", "ej", "ach", "och", "ech", "puk", "hop", "bum", "cyk", "tik", "pa", "fiu", "la", + "bardzo", "dawno", "daleko", "długo", +]; + +/// Abbreviations written with a final period after which a sentence goes on in lower case +/// (lower case, without the period). +const ABBREVIATIONS: &[&str] = &[ + "np", "itd", "itp", "tzn", "tj", "tzw", "ok", "ul", "al", "pl", "os", "in", "wg", "zob", "por", "str", "ss", "nr", "pkt", "ust", "art", "par", + "rozdz", "tab", "rys", "il", "przyp", "red", "wyd", "oprac", "tłum", "cyt", "jw", "ds", "im", "ks", "św", "bł", "prof", "dr", "doc", "hab", + "inż", "mgr", "lek", "płk", "gen", "mjr", "kpt", "ppłk", "sierż", "szer", "kmdr", "adm", "abp", "bp", "br", "ub", "bm", "pn", "płd", "płn", + "wsch", "zach", "gł", "godz", "min", "sek", "tys", "mln", "mld", "zł", "gr", "kg", "dag", "mies", "tyg", "pon", "wt", "śr", "czw", "pt", "sob", + "niedz", "sty", "lut", "mar", "kwi", "cze", "lip", "sie", "wrz", "paź", "lis", "gru", "ang", "niem", "franc", "łac", "ros", "wł", "hiszp", "pol", + "lit", "dosł", "przen", "pot", "zdrob", "fot", "ryc", "tel", "kom", "ew", "ob", "obyw", "pp", "zm", "ur", "ca", "vs", "etc", "cdn", "dot", "wz", + "zw", "zał", "cz", "rr", "ww", "jęz", "jun", "sen", "mec", "red", "dyr", "kier", "proc", "poj", "lp", "lm", "zaw", "woj", "pow", "gm", "wsp", + "nast", "poprz", "ang", "fr", +]; + +/// Subordinating conjunctions and relative pronouns that open a clause: a comma comes before +/// them. +const CLAUSE_OPENERS: &[&str] = &[ + "że", + "ponieważ", + "gdyż", + "aby", + "żeby", + "jeśli", + "jeżeli", + "gdyby", + "lecz", + "który", + "która", + "które", + "którego", + "której", + "któremu", + "którą", + "którym", + "których", + "którymi", + "którzy", +]; + +/// Words after which a clause opener takes no comma of its own: conjunctions (`i że`), particles +/// (`tylko jeśli`, `zwłaszcza że`) and the first halves of compound conjunctions (`mimo że`, +/// `chyba że`, `tak aby`, `jak gdyby`, `na wypadek gdyby`, `z tym że`). +const NO_COMMA_AFTER: &[&str] = &[ + "i", + "a", + "oraz", + "lub", + "albo", + "ani", + "czy", + "bądź", + "ale", + "lecz", + "bo", + "że", + "aby", + "żeby", + "by", + "niż", + "jak", + "jako", + "niby", + "tylko", + "nawet", + "właśnie", + "zwłaszcza", + "szczególnie", + "głównie", + "częściowo", + "jedynie", + "wyłącznie", + "chyba", + "mianowicie", + "przynajmniej", + "również", + "także", + "też", + "dopiero", + "jeszcze", + "już", + "zaś", + "więc", + "zatem", + "toteż", + "przecież", + "chociaż", + "choć", + "choćby", + "gdy", + "kiedy", + "jeśli", + "jeżeli", + "gdyby", + "skoro", + "zanim", + "dopóki", + "póki", + "aż", + "co", + "tak", + "mimo", + "pomimo", + "wtedy", + "teraz", + "dziś", + "dzisiaj", + "podczas", + "tym", + "bardziej", + "warunkiem", + "wypadek", + "zamiast", + "byle", + "lada", + "wiadomo", + "obojętnie", + "jedno", + "wszystkim", + "może", + "pewnie", + "prawdopodobnie", + "zapewne", + "dlatego", + "tyle", + "dość", + "ponieważ", + "gdyż", + "lecz", + "albowiem", + "oto", + "no", +]; + +/// Prepositions: a relative pronoun after one takes the comma before the preposition instead +/// (`dom, w którym`). +const PREPOSITIONS: &[&str] = &[ + "w", + "we", + "z", + "ze", + "na", + "do", + "od", + "ode", + "o", + "u", + "po", + "za", + "przed", + "przede", + "przez", + "przeze", + "przy", + "nad", + "nade", + "pod", + "pode", + "dla", + "bez", + "beze", + "ku", + "między", + "pomiędzy", + "wśród", + "spośród", + "spod", + "spoza", + "sprzed", + "znad", + "zza", + "poza", + "ponad", + "poprzez", + "dzięki", + "według", + "wobec", + "oprócz", + "prócz", + "obok", + "koło", + "około", + "wokół", + "dookoła", + "naprzeciw", + "naprzeciwko", + "wzdłuż", + "wewnątrz", + "zewnątrz", + "względem", + "przeciw", + "przeciwko", + "wbrew", + "wskutek", + "śród", + "wedle", + "pośród", + "ponad", + "niedaleko", + "blisko", +]; + +fn issue(start: usize, end: usize, message: String, suggestions: Vec) -> Issue { + Issue { start, end, kind: IssueKind::Grammar, message, suggestions, lang: ProofLang::Pl } +} + +fn capitalize(w: &str) -> String { + crate::affix::capitalize(w) +} + +/// Polish vowels (and the accented vowels of loanwords). +fn is_vowel(c: char) -> bool { + matches!(c.to_lowercase().next().unwrap_or(c), 'a' | 'ą' | 'e' | 'ę' | 'i' | 'o' | 'ó' | 'u' | 'y' | 'é' | 'á' | 'ö' | 'ü' | 'í' | 'ú') +} + +fn is_consonant(c: char) -> bool { + c.is_alphabetic() && !is_vowel(c) +} + +/// Grammar issues in a paragraph of Polish text, in order. +pub fn check(text: &str) -> Vec { + let ws: Vec<(usize, usize)> = words(text); + let mut v = Vec::new(); + repeated_words(text, &ws, &mut v); + spaces(text, &mut v); + sentence_capitals(text, &ws, &mut v); + missing_commas(text, &ws, &mut v); + we_ze(text, &ws, &mut v); + doubled_punctuation(text, &mut v); + v.sort_by_key(|i| (i.start, i.end)); + v +} + +fn repeated_words(text: &str, ws: &[(usize, usize)], v: &mut Vec) { + for pair in ws.windows(2) { + let [(a0, b0), (a1, b1)] = pair else { continue }; + let (Some(w0), Some(w1), Some(gap)) = (text.get(*a0..*b0), text.get(*a1..*b1), text.get(*b0..*a1)) else { continue }; + if !gap.is_empty() + && gap.chars().all(char::is_whitespace) + && w0.chars().all(char::is_alphabetic) + && w0.to_lowercase() == w1.to_lowercase() + && !ALLOWED_REPEATS.contains(&w0.to_lowercase().as_str()) + { + v.push(issue(*a0, *b1, format!("Powtórzone słowo: „{w1}”"), vec![w0.to_string()])); + } + } +} + +fn spaces(text: &str, v: &mut Vec) { + for (i, c) in text.char_indices() { + if c != ' ' { + continue; + } + let rest = text.get(i + 1..).unwrap_or(""); + let mut it = rest.chars(); + let Some(p) = it.next() else { continue }; + let after = it.next(); + let flag = match p { + ',' => true, + // `;)` and `;-)` are emoticons. + ';' => !matches!(after, Some(')' | '(' | '-' | 'P' | 'D')), + // ` ...` is an ellipsis, ` .5` a number. + '.' => !matches!(after, Some('.')) && !after.is_some_and(|a| a.is_ascii_digit()), + // ` :)`, ` :-)`, ` :D` and similar are emoticons; ` ?!` is still a stray space. + ':' | '!' | '?' => after.is_none_or(|a| a.is_whitespace() || matches!(a, '"' | '”' | '»' | '’' | '!' | '?')), + _ => false, + }; + if flag { + v.push(issue(i, i + 1 + p.len_utf8(), "Zbędna spacja przed znakiem interpunkcyjnym".into(), vec![p.to_string()])); + } + } + for (i, _) in text.match_indices(" ") { + if i > 0 && text.get(..i).is_some_and(|t| t.ends_with(' ')) { + continue; + } + v.push(issue(i, i + 2, "Podwójna spacja".into(), vec![" ".into()])); + } +} + +/// Is `prev`, before a period, an abbreviation, an initial, a number or a Roman numeral (after +/// which a lower-case word doesn't start a sentence)? +fn abbreviation_or_number(prev: &str) -> bool { + prev.chars().count() <= 1 + || prev.chars().any(|c| c.is_ascii_digit()) + || (prev.chars().all(|c| matches!(c, 'I' | 'V' | 'X' | 'L' | 'C' | 'D' | 'M'))) + || ABBREVIATIONS.contains(&prev.to_lowercase().as_str()) +} + +const CLOSING: [char; 8] = ['"', '”', '’', '\'', ')', '»', ']', '\u{1}']; +const OPENING: [char; 8] = ['„', '"', '“', '«', '(', '[', '\'', '‚']; + +/// Does the gap between two words end a sentence: a `.`, `!` or `?` (not an ellipsis, not after +/// an abbreviation), then space, with nothing but quotes and brackets around? +fn ends_sentence(gap: &str, prev: &str) -> bool { + let t = gap.trim_start_matches(CLOSING); + let Some(end) = t.chars().next() else { return false }; + if !matches!(end, '.' | '!' | '?') || t.starts_with("..") { + return false; + } + if end == '.' && abbreviation_or_number(prev) { + return false; + } + let rest = t.trim_start_matches(['.', '!', '?']).trim_start_matches(CLOSING); + rest.starts_with(char::is_whitespace) && rest.trim_start().chars().all(|c| OPENING.contains(&c)) +} + +fn sentence_capitals(text: &str, ws: &[(usize, usize)], v: &mut Vec) { + // List items (`– jabłka,`) may start in lower case. + let list_item = text.trim_end().ends_with([',', ';']); + for (k, &(a, b)) in ws.iter().enumerate() { + let Some(w) = text.get(a..b) else { continue }; + let starts = if k == 0 { + let before = text.get(..a).unwrap_or(""); + !list_item && before.chars().all(|c| c.is_whitespace() || OPENING.contains(&c)) + } else { + let Some(&(pa, pb)) = ws.get(k - 1) else { continue }; + ends_sentence(text.get(pb..a).unwrap_or(""), text.get(pa..pb).unwrap_or("")) + }; + if starts && w.chars().next().is_some_and(char::is_lowercase) && w.chars().all(char::is_alphabetic) { + v.push(issue(a, b, "Wielka litera na początku zdania".into(), vec![capitalize(w)])); + } + } +} + +fn missing_commas(text: &str, ws: &[(usize, usize)], v: &mut Vec) { + for k in 1..ws.len() { + let (Some(&(pa, pb)), Some(&(a, b))) = (ws.get(k - 1), ws.get(k)) else { continue }; + let (Some(prev), Some(w), Some(gap)) = (text.get(pa..pb), text.get(a..b), text.get(pb..a)) else { continue }; + if !CLAUSE_OPENERS.contains(&w) || gap.is_empty() || !gap.chars().all(|c| c == ' ' || c == '\u{a0}') { + continue; + } + if !prev.chars().all(char::is_alphabetic) { + continue; + } + let p = prev.to_lowercase(); + let p = p.as_str(); + if NO_COMMA_AFTER.contains(&p) { + continue; + } + if w.starts_with("któr") && (PREPOSITIONS.contains(&p) || p == "to") { + continue; + } + v.push(issue(pb, b, format!("Brak przecinka przed „{w}”"), vec![format!(", {w}")])); + } +} + +fn we_ze(text: &str, ws: &[(usize, usize)], v: &mut Vec) { + for pair in ws.windows(2) { + let [(a0, b0), (a1, b1)] = pair else { continue }; + let (Some(w0), Some(w1), Some(gap)) = (text.get(*a0..*b0), text.get(*a1..*b1), text.get(*b0..*a1)) else { continue }; + if gap != " " && gap != "\u{a0}" { + continue; + } + let next = w1.to_lowercase(); + let c: Vec = next.chars().collect(); + let at = |i: usize| c.get(i).copied(); + let (long, wanted) = match w0 { + "w" | "W" => ("we", (at(0) == Some('w') && at(1).is_some_and(is_consonant)) || next == "mnie"), + "z" | "Z" => { + // The first sound: `sz` is one consonant (`z szafy`, but `ze szkoły`). + let after = if at(0) == Some('s') && at(1) == Some('z') { at(2) } else { at(1) }; + let sibilant = matches!(at(0), Some('s' | 'z' | 'ś' | 'ź' | 'ż')); + ("ze", (sibilant && after.is_some_and(is_consonant)) || next == "mną") + } + _ => continue, + }; + if wanted { + let fixed = if w0.starts_with(char::is_uppercase) { capitalize(long) } else { long.to_string() }; + v.push(issue(*a0, *b0, format!("Przed tym słowem piszemy „{long}”"), vec![fixed])); + } + } +} + +fn doubled_punctuation(text: &str, v: &mut Vec) { + let bytes = text.as_bytes(); + let mut i = 0; + while let Some(&b) = bytes.get(i) { + if !matches!(b, b',' | b'.' | b';') { + i += 1; + continue; + } + let run = bytes.get(i..).map_or(0, |r| r.iter().take_while(|&&x| x == b).count()); + let flag = match b { + b'.' => run == 2, + _ => run >= 2, + }; + if flag { + let end = i + run; + let suggestions: Vec = match b { + // `,,` before a word, after a space: a typed opening quote. + b',' if text.get(end..).and_then(|t| t.chars().next()).is_some_and(char::is_alphabetic) + && (i == 0 || text.get(..i).is_some_and(|t| t.ends_with(char::is_whitespace))) => + { + vec!["„".into()] + } + b'.' => vec![".".into(), "…".into()], + _ => vec![char::from(b).to_string()], + }; + v.push(issue(i, end, "Powtórzony znak interpunkcyjny".into(), suggestions)); + } + i += run.max(1); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn messages(t: &str) -> Vec { + check(t).into_iter().map(|i| format!("{}@{}", i.message, &t[i.start..i.end])).collect() + } + + fn clean(t: &str) { + let m = messages(t); + assert!(m.is_empty(), "{t:?}: {m:?}"); + } + + #[test] + fn repeated_words() { + let v = check("Ala ma ma kota i się się cieszy."); + assert_eq!(v.len(), 2, "{v:?}"); + assert_eq!(v[0].message, "Powtórzone słowo: „ma”"); + assert_eq!(v[0].suggestions, ["ma"]); + assert!(v.iter().all(|i| i.lang == ProofLang::Pl && i.kind == IssueKind::Grammar)); + clean("Tak tak, to to samo. Ha ha!"); + } + + #[test] + fn spaces_and_punctuation() { + let m = messages("Ala ma kota , a kot ma Alę . Czy na pewno ?"); + assert!(m.contains(&"Zbędna spacja przed znakiem interpunkcyjnym@ ,".to_string()), "{m:?}"); + assert!(m.contains(&"Zbędna spacja przed znakiem interpunkcyjnym@ .".to_string()), "{m:?}"); + assert!(m.contains(&"Zbędna spacja przed znakiem interpunkcyjnym@ ?".to_string()), "{m:?}"); + assert!(m.contains(&"Podwójna spacja@ ".to_string()), "{m:?}"); + clean("Fajnie :) Naprawdę :-) Albo :D Wartość .5 i wielokropek ..."); + let m = messages("Powiedział ,,tak” i poszedł.. Potem;; koniec."); + assert_eq!(m.iter().filter(|x| x.starts_with("Powtórzony znak")).count(), 3, "{m:?}"); + assert_eq!(check("Powiedział ,,tak”.").iter().find(|i| i.message.starts_with("Powtórzony")).unwrap().suggestions, ["„"]); + clean("Czekał… i czekał... aż do rana."); + } + + #[test] + fn sentence_start_capitals() { + let m = messages("to jest zdanie. a to drugie! czy trzecie? tak."); + assert_eq!(m.iter().filter(|x| x.starts_with("Wielka litera")).count(), 4, "{m:?}"); + assert_eq!(check("ala ma kota.")[0].suggestions, ["Ala"]); + // Abbreviations, initials, ordinals, dates, ellipses and dialogue dashes. + clean("Kupiłem owoce, np. jabłka, gruszki itp. oraz warzywa."); + clean("W 1990 r. powstała firma, m.in. dzięki dotacji."); + clean("Spotkałem J. Kowalskiego i prof. Nowaka przy ul. Długiej."); + clean("Zajął 5. miejsce w XX w. sporcie."); + clean("Czekałem... aż w końcu przyszedł."); + clean("– Naprawdę? – zapytał. – Tak – odpowiedziała."); + clean("Powiedział: „Wracam”. Potem wyszedł."); + // A list item may start in lower case. + clean("jabłka,"); + } + + #[test] + fn missing_commas() { + let m = messages("Wiem że przyjdzie. Mam kota który śpi. Zostałem bo musiałem. Idę aby zdążyć. Nie przyszedł ponieważ padało."); + for want in [ + "Brak przecinka przed „że”@ że", + "Brak przecinka przed „który”@ który", + "Brak przecinka przed „aby”@ aby", + "Brak przecinka przed „ponieważ”@ ponieważ", + ] { + assert!(m.contains(&want.to_string()), "{want}: {m:?}"); + } + assert_eq!(check("Wiem że tak.")[0].suggestions, [", że"]); + // Compound conjunctions, particles, conjunctions and prepositions take no comma there. + clean("Przyszedł, mimo że padało, chyba że nie."); + clean("Zrobię to, tylko jeśli zdążę, i to tak aby nikt nie widział."); + clean("To dom, w którym mieszkam, i ogród, przez który idę."); + clean("Wiem, że przyjdzie, i że zostanie."); + clean("Myślę, że gdyby przyszedł, byłoby dobrze."); + clean("Zachowywał się, jak gdyby nigdy nic."); + clean("Który z nich? To który wybierasz?"); + clean("Po raz nie wiadomo który."); + clean("Dlatego że padało, zostaliśmy."); + } + + #[test] + fn we_and_ze() { + let m = messages("Spotkamy się w wtorek w Wrocławiu z szkoły z mną."); + assert_eq!(m.iter().filter(|x| x.starts_with("Przed tym słowem")).count(), 4, "{m:?}"); + let v = check("Z szkoły wracam."); + assert_eq!(v[0].suggestions, ["Ze"]); + clean("Byłem w wodzie, w wieku dziesięciu lat, z szafą, z siostrą i z zamkiem."); + clean("Wracam ze szkoły we wtorek ze mną."); + } + + #[test] + fn correct_polish_text_is_clean() { + clean( + "Zażółć gęślą jaźń. Chrząszcz brzmi w trzcinie, a źdźbło trawy się kołysze. \ + Przyjdę jutro, chyba że będzie padać. Książka, którą czytam, jest ciekawa.", + ); + clean("Pięćdziesięciu uczniów przyszło, ponieważ ogłoszono konkurs."); + } + + proptest::proptest! { + #[test] + fn never_panics(s in "\\PC{0,80}") { + let _ = check(&s); + } + + #[test] + fn never_panics_on_polish_like_text(s in "[a-ząćęłńóśźżA-Z ,.;:!?„”…\\-]{0,60}") { + for i in check(&s) { + proptest::prop_assert!(i.start <= i.end && i.end <= s.len()); + proptest::prop_assert!(s.is_char_boundary(i.start) && s.is_char_boundary(i.end)); + } + } + } +} diff --git a/crates/proof/src/pl/mod.rs b/crates/proof/src/pl/mod.rs new file mode 100644 index 000000000..74d8b8d2f --- /dev/null +++ b/crates/proof/src/pl/mod.rs @@ -0,0 +1,204 @@ +//! Polish proofing. +//! +//! - **Spelling** against the SJP.PL Hunspell dictionary installed on the system +//! ([`dictionary`], found by [`crate::hunspell`]: `pl_PL.aff`/`pl_PL.dic` from the OS, a +//! package manager or LibreOffice; it isn't bundled). Without it Polish text gets no spelling +//! marks. Capitalized stems are names (`Warszawa`); a sentence may start with any word and +//! headings may be in capitals. Hyphenated compounds are checked part by part +//! (`biało-czerwony`), foreign names inflected with an apostrophe are accepted +//! (`Kennedy'ego`), and the user dictionary applies as in English. +//! - **Suggestions** tuned for Polish ([`suggest()`]): missing diacritics first (`zolw` → `żółw`), then +//! the common spelling confusions (`ż`/`rz`, `u`/`ó`, `h`/`ch`…), a missing space (`napewno` → +//! `na pewno`), one-letter typos, and a dictionary scan for anything further off. +//! - **Grammar** ([`grammar`]): cheap, reliable rules with Polish messages. + +pub mod grammar; +mod suggest; +#[cfg(test)] +pub(crate) mod test_dict; + +use std::sync::{Arc, RwLock}; + +use crate::affix::AffixDict; + +pub use suggest::suggest; + +/// The Polish dictionary: `None` until first use, then the installed one (or an empty one). +static DICT: RwLock>> = RwLock::new(None); + +/// File names of a Polish Hunspell dictionary. +const NAMES: &[&str] = &["pl_PL", "pl-PL", "pl"]; + +/// The Polish dictionary: the installed Hunspell `pl_PL` (read on first use), one given with +/// [`install_dictionary`], or an empty one when none is available (Polish spelling is off then; +/// see [`has_dictionary`]). +pub fn dictionary() -> Arc { + if let Some(d) = DICT.read().unwrap_or_else(|e| e.into_inner()).as_ref() { + return d.clone(); + } + let loaded = Arc::new(load_installed()); + let mut slot = DICT.write().unwrap_or_else(|e| e.into_inner()); + // Another thread (or an install) may have filled it meanwhile: that one wins. + slot.get_or_insert(loaded).clone() +} + +fn load_installed() -> AffixDict { + let Some((aff, dic)) = crate::hunspell::find(NAMES) else { + log::info!("Polish spelling: no Hunspell pl_PL dictionary installed"); + return AffixDict::default(); + }; + crate::hunspell::load(&aff, &dic).unwrap_or_else(|e| { + log::warn!("Polish spelling: {e}"); + AffixDict::default() + }) +} + +/// Use this Polish dictionary from now on (Hunspell `.aff` and `.dic` text, decoded to UTF-8), +/// for example one the user picked. On error the current dictionary stays. +pub fn install_dictionary(aff: &str, dic: &str) -> Result<(), String> { + let d = AffixDict::from_hunspell(aff, dic, &[])?; + *DICT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(d)); + Ok(()) +} + +/// Forget the loaded dictionary, so the next check looks for an installed one again (after +/// [`crate::hunspell::set_dictionary_dir`] or a dictionary was installed). +pub fn reload_dictionary() { + *DICT.write().unwrap_or_else(|e| e.into_inner()) = None; +} + +/// Is a Polish dictionary available (spelling is checked)? +pub fn has_dictionary() -> bool { + !dictionary().is_empty() +} + +/// Endings Polish adds after an apostrophe to foreign names whose last letter isn't pronounced +/// (`Kennedy'ego`, `Wilde'a`, `Shakespeare'owi`). +const APOSTROPHE_ENDINGS: &[&str] = &["a", "ach", "ami", "e", "ego", "em", "emu", "i", "m", "om", "owi", "owie", "u", "y", "ów", "ą", "ę"]; + +/// Is `c` a letter of the Latin script (the only one the Polish dictionary covers)? +fn is_latin_letter(c: char) -> bool { + c.is_alphabetic() && (c < '\u{0250}' || ('\u{1E00}'..='\u{1EFF}').contains(&c)) +} + +/// Is `word` spelled correctly in Polish? Numbers, single letters, short words in capitals +/// (acronyms: `PKO`, `ZUS`), words with digits and words in other scripts are accepted, and so +/// is every word when no Polish dictionary is installed. +pub fn is_correct(word: &str) -> bool { + is_correct_with(&dictionary(), word) +} + +/// [`is_correct`] against a given dictionary (an empty one accepts everything). +pub fn is_correct_with(d: &AffixDict, word: &str) -> bool { + if d.is_empty() { + return true; // no Polish dictionary installed: no spelling marks + } + let w = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '\'' && c != '’'); + let w = w.trim_matches(['\'', '’']); + let n = w.chars().count(); + if n <= 1 || w.chars().any(|c| c.is_ascii_digit()) || !w.chars().any(char::is_alphabetic) { + return true; + } + if w.chars().all(|c| !c.is_lowercase()) && n <= 6 { + return true; + } + if !w.chars().any(is_latin_letter) { + return true; // other scripts: no dictionary + } + let w = w.replace('’', "'"); + if w.contains('-') { + return w.split('-').all(|p| p.chars().count() <= 1 || known(d, p)); + } + known(d, &w) +} + +/// Is one word (no hyphens) in the dictionary, the user dictionary, or a foreign name with a +/// Polish ending after an apostrophe? +fn known(d: &AffixDict, w: &str) -> bool { + if d.check(w) || crate::in_user_dictionary(&w.to_lowercase()) { + return true; + } + if let Some((stem, ending)) = w.split_once('\'') { + return stem.chars().next().is_some_and(char::is_uppercase) && stem.chars().count() >= 2 && APOSTROPHE_ENDINGS.contains(&ending); + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_dictionary_loads() { + test_dict::install(); + let d = dictionary(); + assert!(has_dictionary()); + assert!(d.len() > 50, "{}", d.len()); + assert_eq!(d.rule_count(), 5, "the test dictionary's affix rules"); + assert!(!d.rep().is_empty() && !d.map().is_empty() && !d.try_chars().is_empty()); + } + + #[test] + fn without_a_dictionary_nothing_is_flagged_and_nothing_suggested() { + let none = AffixDict::default(); + for w in ["żeka", "kturzy", "xqzv", ""] { + assert!(is_correct_with(&none, w), "{w}"); + } + assert!(suggest::suggest_in(&none, "żeka", 5).is_empty()); + assert!(install_dictionary("FLAG long\n", "1\nkot\n").is_err(), "unsupported files are refused"); + } + + #[test] + fn polish_words_are_correct() { + test_dict::install(); + for w in [ + "źdźbło", + "gżegżółka", + "chrząszcz", + "pięćdziesięciu", + "rzeka", + "którzy", + "żółw", + "Zażółć", + "gęślą", + "jaźń", + "konstytucja", + "konstytucyjnego", + "niebieskiego", + "nieładnie", + "przyszliśmy", + "zrobilibyśmy", + "Warszawa", + "Warszawie", + "WARSZAWA", + "Kot", + "biało-czerwony", + "polsko-niemieckiej", + "Kennedy'ego", + "Shakespeare’a", + "PKO", + "2026", + "e-mail", + "w", + "się", + ] { + assert!(is_correct(w), "{w}"); + } + } + + #[test] + fn misspellings_are_flagged() { + test_dict::install(); + for w in ["żeka", "kturzy", "napewno", "wogóle", "zolw", "chuśtawka", "bżoza", "warszawa", "niewiem", "pszyjaciel", "Kennedy'xyz"] { + assert!(!is_correct(w), "{w}"); + } + } + + #[test] + fn user_dictionary_applies() { + test_dict::install(); + assert!(!is_correct("zxqpolskisłowo")); + crate::add_word("Zxqpolskisłowo"); + assert!(is_correct("zxqpolskisłowo")); + } +} diff --git a/crates/proof/src/pl/suggest.rs b/crates/proof/src/pl/suggest.rs new file mode 100644 index 000000000..101952cc0 --- /dev/null +++ b/crates/proof/src/pl/suggest.rs @@ -0,0 +1,437 @@ +//! Polish spelling suggestions, best first. +//! +//! Candidates come in rounds of rising cost, each checked against the dictionary: +//! 1. letter case (`warszawa` → `Warszawa`); +//! 2. missing or wrong diacritics, from the dictionary's `MAP` groups (`zolw` → `żółw`, `kturzy` +//! → `którzy`), up to four letters changed — Polish typed without diacritics is the most +//! common slip, so adding them ranks first, while taking away a diacritic the writer typed +//! ranks after the known confusions; +//! 3. the dictionary's `REP` table of common confusions (`żeka` → `rzeka`, `chuśtawka` → +//! `huśtawka`, `pszyjaciel` → `przyjaciel`); +//! 4. a missing space (`napewno` → `na pewno`, `wogóle` → `w ogóle`, `kochamcię` → `kocham +//! cię`), cheaper when one half is a short function word; +//! 5. one typo: a letter left out, added, swapped with the next or mistyped (`TRY` letters); +//! 6. when that finds fewer than asked for: the stems that start like the word (diacritics +//! folded) and share the most letter pairs with it, whose forms are ranked by an edit distance +//! in which a diacritic slip costs half (two typos at most). +//! +//! The input is bounded (40 letters), and so is every round, so a call costs a few thousand +//! dictionary lookups at most. + +use std::collections::{HashMap, HashSet}; + +use crate::affix::{AffixDict, Casing, capitalize, casing}; + +/// Short words often run into the next one (`na pewno`, `w ogóle`, `nie wiem`, `przede +/// wszystkim`). +const LEADING_WORDS: &[&str] = &[ + "a", "i", "o", "u", "w", "z", "we", "ze", "na", "po", "do", "od", "za", "co", "to", "nie", "jak", "tak", "przy", "bez", "pod", "nad", "dla", + "że", "by", "czy", "już", "ani", "przed", "przez", "przede", "ku", "aż", "bo", "ten", "ta", "te", "tym", "tej", "też", "jest", "ode", "beze", + "nade", +]; +/// Short words often run into the one before (`kocham cię`, `daj mi`, `bałem się`). +const TRAILING_WORDS: &[&str] = + &["się", "cię", "mi", "mu", "ci", "go", "ją", "je", "nam", "wam", "im", "by", "że", "no", "mnie", "tobie", "jej", "ich"]; +const MAX_LETTERS: usize = 40; +const MAX_MAP_CHANGES: usize = 4; +const MAX_MAP_VARIANTS: usize = 4096; +/// Stems the scan of round 6 expands. +const SCAN_STEMS: usize = 48; + +const COST_CASE: u32 = 0; +const COST_ADD_DIACRITIC: u32 = 4; +const COST_OTHER_MAP: u32 = 7; +const COST_DROP_DIACRITIC: u32 = 11; +const COST_REP: u32 = 10; +const COST_SPLIT: u32 = 12; +const COST_SPLIT_RARE: u32 = 18; +const COST_SWAP: u32 = 14; +const COST_DROP_OR_ADD: u32 = 15; +const COST_MISTYPE: u32 = 16; +const COST_SCAN: u32 = 20; + +struct Collector<'a> { + d: &'a AffixDict, + /// The word, lower case. + input: String, + tested: HashSet, + /// Suggestion → (cost, order found). + found: HashMap, + seq: usize, +} + +impl Collector<'_> { + /// Check a lower-case candidate (a word, or two separated by a space) and keep it if every + /// word is in the dictionary, as written or capitalized (names). + fn offer(&mut self, cand: &str, cost: u32) { + if cand.is_empty() || cand == self.input || !self.tested.insert(cand.to_string()) { + return; + } + let mut words = Vec::new(); + let mut cost = cost; + for part in cand.split(' ') { + if self.d.contains(part) { + words.push(part.to_string()); + } else { + let c = capitalize(part); + if !self.d.contains(&c) { + return; + } + words.push(c); + // A name ranks after a common word at the same distance (`moze` → `może`, then + // `Mozę`). + cost += 1; + } + } + self.keep(words.join(" "), cost); + } + + /// Keep a suggestion known to be correct. + fn keep(&mut self, text: String, cost: u32) { + self.seq += 1; + let seq = self.seq; + let e = self.found.entry(text).or_insert((cost, seq)); + if cost < e.0 { + *e = (cost, seq); + } + } + + fn count_within(&self, cost: u32) -> usize { + self.found.values().filter(|(c, _)| *c <= cost).count() + } +} + +/// The MAP group of each letter (its other members are its likely confusions). +fn alternatives(c: char, map: &[Vec]) -> Vec { + map.iter().filter(|g| g.contains(&c)).flat_map(|g| g.iter().copied()).filter(|&x| x != c).collect() +} + +/// The cost of changing letter `from` to its MAP relative `to`. Adding a missing diacritic is the +/// commonest slip (Polish typed on a keyboard without Polish letters: `zolw`); a diacritic the +/// writer did type is likelier right, so taking one away costs more than a known confusion from +/// the REP table (`żeka` → `rzeka` before `zeka`). +fn map_cost(from: char, to: char) -> u32 { + match (from.is_ascii(), to.is_ascii()) { + (true, false) => COST_ADD_DIACRITIC, + (false, true) => COST_DROP_DIACRITIC, + _ => COST_OTHER_MAP, + } +} + +/// Every way of changing up to [`MAX_MAP_CHANGES`] letters to a MAP relative, with its cost. +fn map_variants(chars: &[char], alts: &[Vec], i: usize, changes: usize, cost: u32, buf: &mut Vec, out: &mut Vec<(String, u32)>) { + if out.len() >= MAX_MAP_VARIANTS { + return; + } + let Some(&c) = chars.get(i) else { + if changes > 0 { + out.push((buf.iter().collect(), cost)); + } + return; + }; + buf.push(c); + map_variants(chars, alts, i + 1, changes, cost, buf, out); + buf.pop(); + if changes < MAX_MAP_CHANGES { + for &a in alts.get(i).map(Vec::as_slice).unwrap_or_default() { + buf.push(a); + map_variants(chars, alts, i + 1, changes + 1, cost + map_cost(c, a), buf, out); + buf.pop(); + } + } +} + +/// A letter's MAP representative (the first of its group), lower case: `ż` → `z`, `ó` → `o`. +fn fold(c: char, map: &[Vec]) -> char { + let l = c.to_lowercase().next().unwrap_or(c); + map.iter().find(|g| g.contains(&l)).and_then(|g| g.first().copied()).unwrap_or(l) +} + +/// Optimal string alignment distance where a MAP slip (`a`/`ą`, `u`/`ó`) costs 1 and any other +/// edit 2; `limit + 1` once it is certainly above `limit`. +fn weighted_distance(a: &[char], b: &[char], related: &dyn Fn(char, char) -> bool, limit: u32) -> u32 { + let m = b.len(); + let over = limit + 1; + let mut prev2 = vec![0u32; m + 1]; + let mut prev: Vec = (0..=m as u32).map(|j| j * 2).collect(); + let mut cur = vec![0u32; m + 1]; + for (i, &ai) in a.iter().enumerate() { + if let Some(c) = cur.get_mut(0) { + *c = (i as u32 + 1) * 2; + } + let mut row_min = u32::MAX; + for (j, &bj) in b.iter().enumerate() { + let sub = if ai == bj { + 0 + } else if related(ai, bj) { + 1 + } else { + 2 + }; + let at = |v: &[u32], k: usize| v.get(k).copied().unwrap_or(u32::MAX / 4); + let mut v = (at(&prev, j + 1) + 2).min(at(&cur, j) + 2).min(at(&prev, j) + sub); + if i > 0 && j > 0 && a.get(i - 1) == Some(&bj) && b.get(j - 1) == Some(&ai) { + v = v.min(at(&prev2, j - 1) + 2); + } + if let Some(c) = cur.get_mut(j + 1) { + *c = v; + } + row_min = row_min.min(v); + } + if m > 0 && row_min > limit { + return over; + } + std::mem::swap(&mut prev2, &mut prev); + std::mem::swap(&mut prev, &mut cur); + } + prev.get(m).copied().unwrap_or(over).min(over) +} + +/// Spelling suggestions for a Polish word, best first, at most `max`, in the word's letter case. +pub fn suggest(word: &str, max: usize) -> Vec { + suggest_in(&super::dictionary(), word, max) +} + +/// [`suggest`] against a given dictionary. +pub fn suggest_in(d: &AffixDict, word: &str, max: usize) -> Vec { + let word = word.trim(); + let n = word.chars().count(); + if n == 0 || n > MAX_LETTERS || max == 0 || d.is_empty() || word.contains(char::is_whitespace) { + return Vec::new(); + } + let case = casing(word); + let input = word.to_lowercase(); + let chars: Vec = input.chars().collect(); + let mut col = Collector { d, input: input.clone(), tested: HashSet::new(), found: HashMap::new(), seq: 0 }; + // 1. Letter case. + if d.contains(&input) && word != input { + col.keep(input.clone(), COST_CASE); + } else if d.contains(&capitalize(&input)) && word != capitalize(&input) { + col.keep(capitalize(&input), COST_CASE); + } + // 2. Diacritics and other MAP slips. + let map = d.map(); + let alts: Vec> = chars.iter().map(|&c| alternatives(c, map)).collect(); + let mut variants = Vec::new(); + map_variants(&chars, &alts, 0, 0, 0, &mut Vec::with_capacity(n), &mut variants); + variants.sort_by_key(|v| v.1); + for (v, cost) in &variants { + col.offer(v, *cost); + } + // 3. Common confusions. + for (from, to) in d.rep() { + if from.is_empty() { + continue; + } + for (i, _) in input.match_indices(from.as_str()) { + if let (Some(a), Some(b)) = (input.get(..i), input.get(i + from.len()..)) { + col.offer(&format!("{a}{to}{b}"), COST_REP); + } + } + } + // 4. A missing space: cheap after a short leading word or before a short trailing one, + // dearer between two longer words, never leaving a stray short half (`że ka`). + for (i, _) in input.char_indices().skip(1) { + let (Some(a), Some(b)) = (input.get(..i), input.get(i..)) else { continue }; + let long = |p: &str| p.chars().count() >= 3; + let cost = if (LEADING_WORDS.contains(&a) && long(b)) || (long(a) && TRAILING_WORDS.contains(&b)) { + COST_SPLIT + } else if long(a) && long(b) { + COST_SPLIT_RARE + } else { + continue; + }; + col.offer(&format!("{a} {b}"), cost); + } + // 5. One typo. + let mut letters: Vec = d.try_chars().chars().filter(|c| c.is_lowercase()).collect(); + if letters.is_empty() { + letters = "aioeznrwcysptkmdłuljągbhęśćóżfńźvqx".chars().collect(); + } + let s = |v: &[char]| v.iter().collect::(); + for i in 0..n.saturating_sub(1) { + if chars.get(i) != chars.get(i + 1) { + let mut v = chars.clone(); + v.swap(i, i + 1); + col.offer(&s(&v), COST_SWAP); + } + } + for i in 0..n { + let mut v = chars.clone(); + v.remove(i); + col.offer(&s(&v), COST_DROP_OR_ADD); + } + for i in 0..=n { + for &c in &letters { + let mut v = chars.clone(); + v.insert(i, c); + col.offer(&s(&v), COST_DROP_OR_ADD); + } + } + for i in 0..n { + for &c in &letters { + if chars.get(i) != Some(&c) { + let mut v = chars.clone(); + if let Some(x) = v.get_mut(i) { + *x = c; + } + col.offer(&s(&v), COST_MISTYPE); + } + } + } + // 6. Further off: scan the stems that start like the word. + if col.count_within(COST_MISTYPE) < max { + scan(&mut col, &chars); + } + let mut found: Vec<(String, (u32, usize))> = col.found.into_iter().collect(); + found.sort_by_key(|(_, k)| *k); + let mut out: Vec = Vec::new(); + for (text, _) in found { + let t = match case { + Casing::Initial => capitalize(&text), + Casing::Upper if n > 1 => text.to_uppercase(), + _ => text, + }; + if t != word && !out.contains(&t) { + out.push(t); + } + if out.len() >= max { + break; + } + } + out +} + +/// Round 6: rank the stems starting with the word's first letter (or a MAP relative of it, either +/// case) by shared letter pairs, then keep the forms of the best within two typos. +fn scan(col: &mut Collector, chars: &[char]) { + let d = col.d; + let map = d.map(); + let Some(&first) = chars.first() else { return }; + let folded: Vec = chars.iter().map(|&c| fold(c, map)).collect(); + let pairs: HashSet<(char, char)> = std::iter::once(' ').chain(folded.iter().copied()).zip(folded.iter().copied()).collect(); + let mut firsts: Vec = std::iter::once(first).chain(alternatives(first, map)).collect(); + let upper: Vec = firsts.iter().flat_map(|c| c.to_uppercase()).collect(); + firsts.extend(upper); + firsts.sort_unstable(); + firsts.dedup(); + let n = folded.len(); + let mut best: Vec<(u32, &str)> = Vec::new(); + for f in firsts { + let prefix = f.to_string(); + for stem in d.stems_with_prefix(&prefix) { + let sf: Vec = stem.chars().map(|c| fold(c, map)).collect(); + let shared = std::iter::once(' ').chain(sf.iter().copied()).zip(sf.iter().copied()).filter(|p| pairs.contains(p)).count(); + if shared < 2 { + continue; + } + // Shared pairs, less the stem's letters the word doesn't explain, less a length gap + // beyond what an ending explains. + let extra = sf.len().saturating_sub(shared); + let gap = sf.len().abs_diff(n).saturating_sub(4); + let score = (shared * 4).saturating_sub(extra * 3 + gap * 2) as u32; + if score == 0 { + continue; + } + if best.len() < SCAN_STEMS { + best.push((score, stem)); + } else if let Some(min) = best.iter_mut().min_by_key(|b| b.0) + && score > min.0 + { + *min = (score, stem); + } + } + } + best.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(b.1))); + let related = |a: char, b: char| fold(a, map) == fold(b, map); + let limit = 4; + for (_, stem) in best { + for form in d.forms(stem) { + let lf: Vec = form.to_lowercase().chars().collect(); + if lf.len().abs_diff(chars.len()) > 2 { + continue; + } + let dist = weighted_distance(&lf, chars, &related, limit); + if dist <= limit && lf.as_slice() != chars { + col.keep(form, COST_SCAN + dist * 3); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn has(word: &str, want: &str) { + let s = suggest(word, 8); + assert!(s.iter().any(|x| x == want), "{word} → {s:?}, want {want}"); + } + + #[test] + fn polish_misspellings_get_the_right_suggestion() { + crate::pl::test_dict::install(); + has("żeka", "rzeka"); + has("kturzy", "którzy"); + has("napewno", "na pewno"); + has("wogóle", "w ogóle"); + has("niewiem", "nie wiem"); + has("zolw", "żółw"); + has("gzegzolka", "gżegżółka"); + has("chuśtawka", "huśtawka"); + has("bżoza", "brzoza"); + has("pszyjaciel", "przyjaciel"); + has("warszawa", "Warszawa"); + has("Żeka", "Rzeka"); + has("ZOLW", "ŻÓŁW"); + has("konstytucija", "konstytucja"); + has("wziąść", "wziąć"); + has("dziewczyan", "dziewczyna"); + } + + #[test] + fn the_likeliest_fix_comes_first() { + crate::pl::test_dict::install(); + assert_eq!(suggest("kturzy", 3).first().map(String::as_str), Some("którzy")); + assert_eq!(suggest("zolw", 3).first().map(String::as_str), Some("żółw")); + assert_eq!(suggest("napewno", 3).first().map(String::as_str), Some("na pewno")); + assert_eq!(suggest("żeka", 3).first().map(String::as_str), Some("rzeka")); + } + + #[test] + fn edge_cases_stay_empty_or_bounded() { + crate::pl::test_dict::install(); + assert!(suggest("", 5).is_empty()); + assert!(suggest("kot", 0).is_empty()); + assert!(suggest(&"a".repeat(41), 5).is_empty()); + assert!(suggest("dwa słowa", 5).is_empty()); + assert!(suggest("żółw", 5).len() <= 5); + assert!(!suggest("żółw", 5).contains(&"żółw".to_string())); + } + + #[test] + fn distance_weighs_diacritics_half() { + let map = vec![vec!['a', 'ą'], vec!['o', 'ó', 'u']]; + let related = |a: char, b: char| fold(a, &map) == fold(b, &map); + let c = |s: &str| s.chars().collect::>(); + assert_eq!(weighted_distance(&c("ktury"), &c("który"), &related, 9), 1); + assert_eq!(weighted_distance(&c("kot"), &c("kto"), &related, 9), 2); + assert_eq!(weighted_distance(&c("kot"), &c("kotek"), &related, 9), 4); + assert_eq!(weighted_distance(&c("kot"), &c("pies"), &related, 2), 3); + assert_eq!(weighted_distance(&c(""), &c("ab"), &related, 9), 4); + } + + #[test] + fn suggestions_are_fast_enough() { + crate::pl::test_dict::install(); + // Warm the dictionary, then time a worst case (a long word nothing near): the scan round. + let _ = suggest("kot", 1); + let t = std::time::Instant::now(); + for w in ["xqzvwpolskiegoslowa", "pszyjacielowi", "zażółćgęśląjaźń"] { + let _ = suggest(w, 6); + } + let ms = t.elapsed().as_secs_f64() * 1000.0; + eprintln!("3 suggestion calls: {ms:.0} ms"); + assert!(ms < 20_000.0, "{ms} ms"); + } +} diff --git a/crates/proof/src/pl/test_dict.rs b/crates/proof/src/pl/test_dict.rs new file mode 100644 index 000000000..4bd16d4c8 --- /dev/null +++ b/crates/proof/src/pl/test_dict.rs @@ -0,0 +1,108 @@ +//! A tiny synthetic Polish Hunspell dictionary for tests (written for these tests; not taken from +//! any real dictionary). Real dictionaries are installed on the system, never bundled. + +/// Affix rules: a few suffixes and one prefix, plus the suggestion tables the Polish +/// suggestions use (`TRY`, `MAP` for diacritics, `REP` for common confusions). +pub const AFF: &str = "SET UTF-8 +TRY aioeznrwcysżtkpdmłjlubąęgchśóźćń +MAP 9 +MAP aą +MAP cć +MAP eę +MAP lł +MAP nń +MAP oó +MAP uó +MAP sś +MAP zżź +REP 6 +REP ż rz +REP rz ż +REP ch h +REP h ch +REP sz rz +REP ść ć +SFX A Y 1 +SFX A a ie a +SFX B Y 3 +SFX B y ego y +SFX B i iego i +SFX B i iej i +PFX N Y 1 +PFX N 0 nie . +"; + +/// Stems (the count line first, as in Hunspell files). +pub const DIC: &str = "63 +źdźbło +gżegżółka +chrząszcz +pięćdziesięciu +rzeka +którzy +żółw +zażółć +gęślą +jaźń +konstytucja +konstytucyjny/B +niebieski/B +niemiecki/B +ładnie/N +przyszliśmy +zrobilibyśmy +Warszawa/A +kot +biało +czerwony +polsko +mail +w +się +huśtawka +brzoza +przyjaciel +wziąć +dziewczyna +na +pewno +ogóle +nie +wiem +płynie +a +i +o +to +że +tak +jest +brzmi +trzcina/A +napisał +poszedł +spać +bo +wylała +świat +dom +ma +ala +kota +dzień +dobry +jutro +bardzo +dobrze +mamy +czas +ten +"; + +/// Make [`AFF`]/[`DIC`] the Polish dictionary (every test that needs one calls this; they all +/// install the same one, so tests running in parallel agree). +pub fn install() { + if let Err(e) = super::install_dictionary(AFF, DIC) { + panic!("test dictionary: {e}"); + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9c448da1f..b01f8797f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1984,7 +1984,8 @@ fn context_menu(app: &mut WordApp, ui: &mut Ui) { .and_then(|v| v.as_array()) .map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()) .unwrap_or_default(); - ui.label(egui::RichText::new(issue.get("message").and_then(|m| m.as_str()).unwrap_or("")).small().weak()); + // English rules' messages are catalog keys; Polish rules already explain in Polish. + ui.label(egui::RichText::new(tl!(issue.get("message").and_then(|m| m.as_str()).unwrap_or(""))).small().weak()); if sugg.is_empty() { ui.label(egui::RichText::new(tl!("(no suggestions)")).italics()); } diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index a93a22cb6..f0dd42458 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -291,7 +291,13 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) { if app.session.sel.focus.story != StoryRef::Body && !crate::canvas::in_text_box(app) { st(ui, tl!("Editing header/footer")); } - st(ui, tl!("English (United States)")); + // The language at the caret; a click sets the proofing language (Review › Language). + let lang = crate::ribbon::caret_language(app); + let resp = st(ui, &lang).on_hover_text(tl!("Language")); + egui::Popup::menu(&resp).show(|ui| { + ui.set_min_width(200.0); + crate::ribbon::language_menu(ui, app); + }); if app.session.doc.settings.track_changes { st(ui, tl!("Track Changes: On")); } diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2d008112d..6e74f4921 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2034,3 +2034,4 @@ Your system has animation effects turned on. Auf Ihrem System sind Animationseffekte aktiviert. Your system has animation effects turned off. Auf Ihrem System sind Animationseffekte deaktiviert. Your system doesn't report an animation setting, so animation effects stay on. Ihr System meldet keine Animationseinstellung, daher bleiben Animationseffekte aktiviert. + Do Not Check Spelling or Grammar Rechtschreibung und Grammatik nicht prüfen diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 53849cc60..394322977 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1706,6 +1706,9 @@ Continuous numbering Numeración continua Apply Aplicar Convert Notes Convertir notas + English (United Kingdom) Inglés (Reino Unido) + Polish Polaco + Do Not Check Spelling or Grammar No revisar la ortografía ni la gramática # Built-in file browser when the system file dialog is unavailable (#576) Documents Documentos @@ -1996,7 +1999,6 @@ Detect language automatically Detectar idioma automáticamente Do not check spelling or grammar No revisar la ortografía ni la gramática Dutch Neerlandés - English (United Kingdom) Inglés (Reino Unido) Envelope size: Tamaño del sobre: Estonian Estonio Finnish Finés @@ -2025,7 +2027,6 @@ Numbering: Numeración: Omit Omitir One label per recipient Una etiqueta por destinatario - Polish Polaco Portuguese (Brazil) Portugués (Brasil) Portuguese (Portugal) Portugués (Portugal) Return address labels Etiquetas de remitente diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 3e71a911b..1ff4ce7da 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -1667,6 +1667,9 @@ Continuous numbering Pidev nummerdus Apply Rakenda Convert Notes Teisenda märkused + Do Not Check Spelling or Grammar Ära kontrolli õigekirja ega grammatikat + English (United Kingdom) Inglise (Ühendkuningriik) + Polish Poola # Built-in file browser when the system file dialog is unavailable (#576) Documents Dokumendid @@ -1990,7 +1993,6 @@ Detect language automatically Tuvasta keel automaatselt Do not check spelling or grammar Ära kontrolli õigekirja ega grammatikat Dutch Hollandi - English (United Kingdom) Inglise (Ühendkuningriik) Envelope size: Ümbriku suurus: Estonian Eesti Finnish Soome @@ -2019,7 +2021,6 @@ Numbering: Nummerdus: Omit Jäta välja One label per recipient Üks silt saaja kohta - Polish Poola Portuguese (Brazil) Portugali (Brasiilia) Portuguese (Portugal) Portugali (Portugal) Return address labels Saatja aadressi sildid diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index c72f21b58..90cc34f32 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2419,3 +2419,4 @@ {name} wasn't found. {name} non è stato trovato. ✓ None ✓ Nessuno preview Title Esempio + Do Not Check Spelling or Grammar Non controllare ortografia e grammatica diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b4292b4ba..18bcc7af1 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1690,6 +1690,9 @@ Continuous numbering 通し番号 Apply 適用 Convert Notes 注の変換 + English (United Kingdom) 英語(英国) + Polish ポーランド語 + Do Not Check Spelling or Grammar スペル チェックと文章校正を行わない # Built-in file browser when the system file dialog is unavailable (#576) Documents ドキュメント @@ -1980,7 +1983,6 @@ Detect language automatically 言語を自動的に検出する Do not check spelling or grammar スペルチェックと文章校正を行わない Dutch オランダ語 - English (United Kingdom) 英語(英国) Envelope size: 封筒サイズ: Estonian エストニア語 Finnish フィンランド語 @@ -2009,7 +2011,6 @@ Numbering: 番号の付け方: Omit 省略 One label per recipient 宛先ごとに 1 枚のラベル - Polish ポーランド語 Portuguese (Brazil) ポルトガル語(ブラジル) Portuguese (Portugal) ポルトガル語(ポルトガル) Return address labels 差出人ラベル diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 8ff415c5b..8054ac3c7 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -14,7 +14,8 @@ //! # Adding a language //! 1. Add `xx.tsv` next to `zh-hans.tsv` (copy its header; translate from the *meaning* of the //! English text, clean-room: never from Microsoft Word's or another product's localisation). -//! 2. Add one row to [`LANGUAGES`]. +//! 2. Add one row to [`LANGUAGES`]. Set its `editing_lang` only when `wordcraft-proof` can proof +//! the language (Polish: `pl-PL`): new documents are then written in it. //! //! The Options dropdown, the system-language match and the catalog tests pick it up from there. //! @@ -44,38 +45,81 @@ pub struct LangInfo { pub source: &'static str, /// Which CJK interface face comes first (Chinese text wants the Chinese face). pub prefer_hans: bool, + /// The language (BCP 47) new blank documents are written in with this interface, for + /// languages WordCraft can proof; `None` keeps WordCraft's default, US English. + pub editing_lang: Option<&'static str>, catalog: OnceLock, } /// The registry. English first: it is the fallback and the source language. -pub static LANGUAGES: [LangInfo; 14] = [ - LangInfo { code: "en", name: "English", source: "", prefer_hans: false, catalog: OnceLock::new() }, +pub static LANGUAGES: [LangInfo; 15] = [ + LangInfo { code: "en", name: "English", source: "", prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, // Simplified Chinese; `zh`, `zh-CN`, `zh-SG` and `zh-Hans-*` resolve here (see `candidates`). - LangInfo { code: "zh-hans", name: "简体中文", source: include_str!("zh-hans.tsv"), prefer_hans: true, catalog: OnceLock::new() }, + LangInfo { + code: "zh-hans", + name: "简体中文", + source: include_str!("zh-hans.tsv"), + prefer_hans: true, + editing_lang: None, + catalog: OnceLock::new(), + }, // Traditional Chinese (Taiwan vocabulary); `zh-TW`, `zh-HK`, `zh-MO` and `zh-Hant-*` resolve here. - LangInfo { code: "zh-hant", name: "繁體中文", source: include_str!("zh-hant.tsv"), prefer_hans: true, catalog: OnceLock::new() }, - LangInfo { code: "ja", name: "日本語", source: include_str!("ja.tsv"), prefer_hans: false, catalog: OnceLock::new() }, - LangInfo { code: "uk", name: "Українська", source: include_str!("uk.tsv"), prefer_hans: false, catalog: OnceLock::new() }, + LangInfo { + code: "zh-hant", + name: "繁體中文", + source: include_str!("zh-hant.tsv"), + prefer_hans: true, + editing_lang: None, + catalog: OnceLock::new(), + }, + LangInfo { code: "ja", name: "日本語", source: include_str!("ja.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, + LangInfo { + code: "uk", name: "Українська", source: include_str!("uk.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() + }, // Spanish, neutral across Spain and Latin America; `es-ES`, `es-MX`, `es-419` … resolve here. - LangInfo { code: "es", name: "Español", source: include_str!("es.tsv"), prefer_hans: false, catalog: OnceLock::new() }, + LangInfo { code: "es", name: "Español", source: include_str!("es.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, // Brazilian Portuguese; `pt-BR` and `pt-BR-*` resolve here. Plain `pt` and `pt-PT` have no // catalog yet (the European vocabulary differs), so they stay in English. - LangInfo { code: "pt-br", name: "Português (Brasil)", source: include_str!("pt-br.tsv"), prefer_hans: false, catalog: OnceLock::new() }, + LangInfo { + code: "pt-br", + name: "Português (Brasil)", + source: include_str!("pt-br.tsv"), + prefer_hans: false, + editing_lang: None, + catalog: OnceLock::new(), + }, // Serbian, Cyrillic script (the default per BCP 47 when no script is given); `sr`, `sr-RS`, // `sr-Cyrl-*` resolve here. - LangInfo { code: "sr", name: "Српски", source: include_str!("sr.tsv"), prefer_hans: false, catalog: OnceLock::new() }, + LangInfo { code: "sr", name: "Српски", source: include_str!("sr.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, // Serbian, Latin script; `sr-Latn-*` resolves here (see `candidates`'s generic prefix // matching — no special-casing needed, unlike Chinese's script-by-region fallback). - LangInfo { code: "sr-latn", name: "Srpski (latinica)", source: include_str!("sr-latn.tsv"), prefer_hans: false, catalog: OnceLock::new() }, - LangInfo { code: "et", name: "Eesti", source: include_str!("et.tsv"), prefer_hans: false, catalog: OnceLock::new() }, + LangInfo { + code: "sr-latn", + name: "Srpski (latinica)", + source: include_str!("sr-latn.tsv"), + prefer_hans: false, + editing_lang: None, + catalog: OnceLock::new(), + }, + // Polish; `pl`, `pl-PL` and `pl_PL.UTF-8` resolve here. WordCraft proofs Polish (spelling, + // grammar, hyphenation), so new documents are written in Polish with this interface. + LangInfo { + code: "pl", + name: "Polski", + source: include_str!("pl.tsv"), + prefer_hans: false, + editing_lang: Some("pl-PL"), + catalog: OnceLock::new(), + }, + LangInfo { code: "et", name: "Eesti", source: include_str!("et.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, // German, neutral across Germany, Austria and Switzerland; `de`, `de-DE`, `de-AT`, `de-CH` … // resolve here. - LangInfo { code: "de", name: "Deutsch", source: include_str!("de.tsv"), prefer_hans: false, catalog: OnceLock::new() }, + LangInfo { code: "de", name: "Deutsch", source: include_str!("de.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, // Russian; `ru`, `ru-RU`, `ru_RU.UTF-8` and other regions resolve here. - LangInfo { code: "ru", name: "Русский", source: include_str!("ru.tsv"), prefer_hans: false, catalog: OnceLock::new() }, - LangInfo { code: "tr", name: "Türkçe", source: include_str!("tr.tsv"), prefer_hans: false, catalog: OnceLock::new() }, + LangInfo { code: "ru", name: "Русский", source: include_str!("ru.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, + LangInfo { code: "tr", name: "Türkçe", source: include_str!("tr.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, // Italian; `it-IT`, `it-CH` and `it-*` resolve here. - LangInfo { code: "it", name: "Italiano", source: include_str!("it.tsv"), prefer_hans: false, catalog: OnceLock::new() }, + LangInfo { code: "it", name: "Italiano", source: include_str!("it.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, ]; impl LangInfo { @@ -123,6 +167,11 @@ impl Lang { self.0.prefer_hans } + /// The language new blank documents are written in with this interface ([`LangInfo::editing_lang`]). + pub fn editing_lang(self) -> Option<&'static str> { + self.0.editing_lang + } + /// Is the interface written in CJK script (its own name is), so it needs a CJK face? pub fn uses_cjk(self) -> bool { self.0.name.chars().any(|c| ('\u{2E80}'..='\u{9FFF}').contains(&c) || ('\u{AC00}'..='\u{D7AF}').contains(&c)) diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv new file mode 100644 index 000000000..c8d5481ba --- /dev/null +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -0,0 +1,2055 @@ +# Polish (pl) interface catalog. Columns, separated by a TAB: +# context English source translation +# context empty (WordCraft uses no contexts yet) +# source the English text exactly as in the code (tl!(...), ribbon labels, command labels) +# escapes \n \t \\ inside a column. `{name}` placeholders must appear in both columns, and a +# trailing "…" in one needs one in the other. +# Lines starting with # and blank lines are ignored. A missing entry shows in English. +# +# Clean room: translated from the meaning of the English labels in this repository into ordinary +# Polish computing vocabulary. No Microsoft Word (or other product) localisation, string table or +# translated UI was consulted or copied; add entries the same way. +# Product, format and technology names (WordCraft, PDF, Markdown, MCP …) stay as they are. +# Conventions: commands are imperatives (Zapisz, Wstaw), tabs and groups are nouns; counts use +# a label and a colon ("Słowa: 21") so every number reads grammatically; quotes are „…”. +# Author: Konrad Witkowski with Claude (AI agent); licensed MIT OR Apache-2.0, like WordCraft. +# Not yet reviewed by a second native speaker. + + (no suggestions) (brak propozycji) + 1.5 Line Spacing Interlinia 1,5 + A free, open-source word processor written from scratch in Rust.\nPart of the Crafting Apps from the ArtCraft team. Darmowy edytor tekstu o otwartym kodzie, napisany od podstaw w języku Rust.\nCzęść rodziny Crafting Apps zespołu ArtCraft. + About Informacje + About WordCraft O programie WordCraft + Accept Zaakceptuj + Accept All Changes Zaakceptuj wszystkie zmiany + Accept This Change Zaakceptuj tę zmianę + Accessibility Ułatwienia dostępu + Add Dodaj + Add Space Before Paragraph Dodaj odstęp przed akapitem + Add Text Dodaj tekst + Add a Table Dodaj tabelę + Add a comment Dodaj komentarz + Add to Dictionary Dodaj do słownika + Add to Style Gallery Dodaj do galerii stylów + Address Block Blok adresu + Address: Adres: + Address\nBlock Blok\nadresu + Adjust Dopasowanie + Advanced Zaawansowane + Advanced Find Wyszukiwanie zaawansowane + Advanced Find… Wyszukiwanie zaawansowane… + After: Po: + Agents Agenci + Align Wyrównaj + Align Left Wyrównaj do lewej + Align Right Wyrównaj do prawej + Alignment Wyrównanie + All Borders Wszystkie krawędzie + All Caps Wersaliki + All Markup Wszystkie znaczniki + All caps Wersaliki + Alt Text Tekst alternatywny + Apply Character Style Zastosuj styl znaku + Apply Style Zastosuj styl + Arrange Rozmieszczanie + Arrange\nAll Rozmieść\nwszystkie + Arrow Strzałka + Artistic Effects Efekty artystyczne + Author Autor + AutoCorrect Options Opcje autokorekty + AutoFit Autodopasowanie + AutoFit Contents Dopasuj do zawartości + AutoFit Window Dopasuj do okna + AutoSave Autozapis + AutoSave saves every change to the file (needs a saved document) Autozapis zapisuje każdą zmianę w pliku (dokument musi być już zapisany) + AutoText Autotekst + Automatic Automatyczny + Automatic ({language}) Automatycznie ({language}) + Automatic Table 1 (3 levels) Tabela automatyczna 1 (3 poziomy) + Automatic Table 2 (2 levels) Tabela automatyczna 2 (2 poziomy) + Automatic follows your system's language. Menus and commands change; your documents don't. Ustawienie automatyczne podąża za językiem systemu. Zmieniają się menu i polecenia, a dokumenty pozostają bez zmian. + Backspace Backspace + Banded Columns Kolumny naprzemienne + Banded Rows Wiersze naprzemienne + Before: Przed: + Bibliography Bibliografia + Binary assets added Dodane zasoby binarne + Binary assets removed Usunięte zasoby binarne + Blank Pusty + Blank (Three Columns) Pusty (trzy kolumny) + Blank Page Pusta strona + Blank document Pusty dokument + Block\nAuthors Blokuj\nautorów + Bold Pogrubienie + Bookmark Zakładka + Bookmark name: Nazwa zakładki: + Border Painter Pędzel krawędzi + Border\nPainter Pędzel\nkrawędzi + Borders Krawędzie + Bottom Border Krawędź dolna + Bottom Margin Margines dolny + Bottom of Page Dół strony + Bottom: Dolny: + Box Ramka + Breaks Podziały + Bring Forward Przesuń do przodu + Bullet Library Biblioteka punktorów + Bullets Punktory + Calendar Kalendarz + Cancel Anuluj + Capitalize Each Word Każdy Wyraz Wielką Literą + Captions Podpisy + Cell Margins Marginesy komórki + Cell Size Rozmiar komórki + Cell\nMargins Marginesy\nkomórki + Center Wyśrodkuj + Change Zmień + Change Case Zmień wielkość liter + Change List Level Zmień poziom listy + Change Picture Zmień obraz + Change Shape Zmień kształt + Changes Zmiany + Character Position Położenie znaku + Character Scale Skala znaku + Character Shading Cieniowanie znaku + Character Spacing Odstępy między znakami + Characters Znaki + Characters (no spaces) Znaki (bez spacji) + Characters (with spaces) Znaki (ze spacjami) + Chart Wykres + Check Accessibility Sprawdź ułatwienia dostępu + Check Compatibility Sprawdź zgodność + Check Spelling as You Type Sprawdzaj pisownię podczas pisania + Check for Errors Wyszukaj błędy + Check\nAccessibility Sprawdź ułatwienia\ndostępu + Citation Style Styl cytowania + Citations & Bibliography Cytaty i bibliografia + Clear Wyczyść + Clear All Wyczyść wszystko + Clear All Formatting Wyczyść całe formatowanie + Clear Character Formatting Wyczyść formatowanie znaków + Clear Character Style Wyczyść styl znaku + Clear Formatting Level Wyczyść poziom formatowania + Clear Paragraph Formatting Wyczyść formatowanie akapitu + Clipboard Schowek + Close Zamknij + Close Header and Footer Zamknij nagłówek i stopkę + Collapse Selection Zwiń zaznaczenie + Color Kolor + Color: Kolor: + Colors Kolory + Column Kolumna + Column Break Podział kolumny + Columns Kolumny + Combine Połącz + Commands Polecenia + Comment Komentarz + Comments Komentarze + Comments Pane Okienko komentarzy + Community\nDiscord Społeczność\nDiscord + Compact Zwarte + Compare Porównaj + Compress Pictures Kompresuj obrazy + Continuous Ciągły + Convert Konwertuj + Convert Text to Table Konwertuj tekst na tabelę + Convert Text to Table… Konwertuj tekst na tabelę… + Convert to Text Konwertuj na tekst + Copy Kopiuj + Copy Formatting Kopiuj formatowanie + Corrections Korekty + Count words in text boxes and notes Licz słowa w polach tekstowych i przypisach + Cover Page Strona tytułowa + Create Utwórz + Create New Style Utwórz nowy styl + Create a Style Utwórz styl + Create an interactive outline of your document.\n\nIt's a great way to keep track of where you are or quickly move your content around.\n\nTo get started, go to the Home tab and apply Heading styles to the headings in your document. Utwórz interaktywny konspekt dokumentu.\n\nTo dobry sposób, by wiedzieć, w którym miejscu jesteś, i szybko przenosić fragmenty tekstu.\n\nAby zacząć, przejdź na kartę Start i zastosuj style nagłówków do nagłówków w dokumencie. + Crop Przytnij + Cross-reference Odsyłacz + Current Position Bieżące położenie + Current style: {style} Bieżący styl: {style} + Custom Margins… Marginesy niestandardowe… + Custom Watermark Niestandardowy znak wodny + Custom Watermark… Niestandardowy znak wodny… + Customize Quick Access Toolbar Dostosuj pasek narzędzi szybkiego dostępu + Cut Wytnij + Dark Mode Tryb ciemny + Dashed Kreskowane + Data Dane + Date & Time Data i godzina + Decrease Font Size Zmniejsz rozmiar czcionki + Decrease Indent Zmniejsz wcięcie + Default Domyślne + Define New Bullet Zdefiniuj nowy punktor + Define New Number Format Zdefiniuj nowy format numeracji + Delete Usuń + Delete Cells Usuń komórki + Delete Columns Usuń kolumny + Delete Comment Usuń komentarz + Delete Next Word Usuń następne słowo + Delete Previous Word Usuń poprzednie słowo + Delete Rows Usuń wiersze + Delete Style Usuń styl + Delete Table Usuń tabelę + Design Projektowanie + Diagonal Ukośna + Diamond Romb + Dictate Dyktuj + Different First Page Inna pierwsza strona + Different Odd & Even Pages Inne strony parzyste i nieparzyste + Direct formatting Formatowanie bezpośrednie + Discord Discord + Discord community Społeczność na Discordzie + Display Wyświetlanie + Display for Review Wyświetlanie do recenzji + Distribute Columns Rozłóż kolumny + Distribute Rows Rozłóż wiersze + Distributed Rozłożony równomiernie + Do Not Show in TOC Nie pokazuj w spisie treści + Document End Koniec dokumentu + Document Formatting Formatowanie dokumentu + Document Property Właściwość dokumentu + Document Start Początek dokumentu + Document Text Tekst dokumentu + Document Title Tytuł dokumentu + Documents you open will show up here. Tu pojawią się otwierane dokumenty. + Dot-dash Kreska-kropka + Dotted Kropkowane + Double Podwójne + Double Spacing Interlinia podwójna + Double Strikethrough Przekreślenie podwójne + Double Underline Podkreślenie podwójne + Down W dół + Draft Wersja robocza + Draw Rysowanie + Drawing Tools Narzędzia rysowania + Drawing\nCanvas Kanwa\nrysunku + Drop Cap Inicjał + Drops all formatting and pastes plain text. Pomija całe formatowanie i wkleja zwykły tekst. + Edit Comment Edytuj komentarz + Edit Footer Edytuj stopkę + Edit Header Edytuj nagłówek + Edit Recipient List Edytuj listę adresatów + Edit\nRecipient List Edytuj listę\nadresatów + Edited Zmieniony + Editing Edytowanie + Editing header/footer Edycja nagłówka/stopki + Editing — edit the document directly Edytowanie — zmiany trafiają wprost do dokumentu + Editor Edytor + Effects Efekty + Emboss Wypukłość + Encrypt with Password Zaszyfruj hasłem + English (United States) Angielski (Stany Zjednoczone) + Engrave Wklęsłość + Enter page number: Wpisz numer strony: + Envelopes Koperty + Eraser Gumka + Even Page Strona parzysta + Every command is available to scripts and AI agents: run `wordcraft-cli mcp` for an MCP server, or start the app with `--control ` for the JSON control channel. Każde polecenie jest dostępne dla skryptów i agentów AI: uruchom `wordcraft-cli mcp`, aby włączyć serwer MCP, albo uruchom aplikację z opcją `--control `, aby korzystać z kanału sterowania JSON. + Export Eksportuj + Export PDF Eksportuj do PDF + Export Page as PNG Eksportuj stronę jako PNG + Export a copy to share (PDF, Word document) Wyeksportuj kopię do udostępnienia (PDF, dokument Word) + Exported {path} Wyeksportowano: {path} + Extend Selection Rozszerz zaznaczenie + Field Pole + File Plik + Find Znajdź + Find Next Znajdź następny + Find Previous Znajdź poprzedni + Find Recipient Znajdź adresata + Find and Replace Znajdź i zamień + Find what: Znajdź: + Finish Zakończ + Finish & Merge Zakończ i scal + Finish &\nMerge Zakończ\ni scal + First Column Pierwsza kolumna + First commit Pierwszy commit + First line: Pierwszy wiersz: + Fixed Column Width Stała szerokość kolumn + Focus Skupienie + Font Czcionka + Font Color Kolor czcionki + Font Dialog Okno dialogowe Czcionka + Font Size Rozmiar czcionki + Font color: Kolor czcionki: + Font: Czcionka: + Fonts Czcionki + Footer Stopka + Footnote and Endnote Przypis dolny i końcowy + Footnotes Przypisy dolne + Format Format + Format Page Numbers Formatuj numery stron + Format Page Numbers… Formatuj numery stron… + Format Painter Pędzel formatowania + Formatted Text Tekst sformatowany + Formatting at Selection Formatowanie zaznaczenia + Formula Formuła + General Ogólne + Go To Przejdź do + Go To (⌘⌥G) Przejdź do (⌘⌥G) + Go To… Przejdź do… + Go to Przejdź do + Grab bag Mieszanka + Greeting Line Wiersz powitania + Greeting\nLine Wiersz\npowitania + Gridlines Linie siatki + Group Grupuj + Grow Font 1 Point Powiększ czcionkę o 1 punkt + HTML Format Format HTML + Hanging Indent Wysunięcie + Header Nagłówek + Header & Footer Nagłówek i stopka + Header Row Wiersz nagłówka + Header/Footer Position Położenie nagłówka i stopki + Heading 1 Nagłówek 1 + Heading 2 Nagłówek 2 + Heading 3 Nagłówek 3 + Headings Nagłówki + Heart Serce + Help Pomoc + Hidden Ukryty + Highlight Merge Fields Wyróżnij pola scalania + Highlight\nMerge Fields Wyróżnij\npola scalania + Highlighter Zakreślacz + Home Start + Horizontal Poziomo + Horizontal Line Linia pozioma + Hyphenation Dzielenie wyrazów + Icons Ikony + Ignore All Ignoruj wszystkie + Illustrations Ilustracje + Immersive Czytanie immersyjne + Immersive Reader Czytnik immersyjny + Immersive\nReader Czytnik\nimmersyjny + Increase Font Size Zwiększ rozmiar czcionki + Increase Indent Zwiększ wcięcie + Indent Wcięcie + Indentation Wcięcia + Indents Wcięcia + Index Indeks + Info Informacje + Ink to\nMath Pismo odręczne\nna równanie + Ink to\nShape Pismo odręczne\nna kształt + Ink\nReplay Odtwarzanie\npisma odręcznego + Insert Wstaw + Insert Above Wstaw powyżej + Insert Below Wstaw poniżej + Insert Caption Wstaw podpis + Insert Citation Wstaw cytat + Insert Endnote Wstaw przypis końcowy + Insert Footnote Wstaw przypis dolny + Insert Hyperlink Wstaw hiperłącze + Insert Index Wstaw indeks + Insert Left Wstaw z lewej + Insert Merge Field Wstaw pole scalania + Insert Merge\nField Wstaw pole\nscalania + Insert Right Wstaw z prawej + Insert Shapes Wstaw kształty + Insert Table Wstaw tabelę + Insert Table of Authorities Wstaw wykaz źródeł prawa + Insert Table of Figures Wstaw spis ilustracji + Insert Table… Wstaw tabelę… + Insert\nAbove Wstaw\npowyżej + Insert\nCaption Wstaw\npodpis + Insert\nCitation Wstaw\ncytat + Insert\nFootnote Wstaw\nprzypis dolny + Inside Borders Krawędzie wewnętrzne + Inspect Document Przejrzyj dokument + Inspect Styles at Selection Przejrzyj style zaznaczenia + Interface language: Język interfejsu: + Italic Kursywa + Join the ArtCraft community on Discord Dołącz do społeczności ArtCraft na Discordzie + Join us on Discord: discord.gg/artcraft Dołącz do nas na Discordzie: discord.gg/artcraft + Justify Wyjustuj + Keep Lines Together Zachowaj wiersze razem + Keep Text Only Zachowaj tylko tekst + Keep lines together Zachowaj wiersze razem + Keep with Next Razem z następnym + Keep with next Razem z następnym + Keeps the fonts and formatting of the copied text. Zachowuje czcionki i formatowanie skopiowanego tekstu. + Labels Etykiety + Landscape Pozioma + Language Język + Lasso Lasso + Last Column Ostatnia kolumna + Last commit Ostatni commit + Layout Układ + Layout Summary Podsumowanie układu + Left Do lewej + Left Border Krawędź lewa + Left Margin Margines lewy + Left: Lewe: + Letter List + Line Linia + Line Break Podział wiersza + Line End Koniec wiersza + Line Numbers Numery wierszy + Line Spacing Interlinia + Line Spacing Options… Opcje interlinii… + Line Start Początek wiersza + Line and Page Breaks Podziały wierszy i stron + Line and Paragraph Spacing Interlinia i odstępy między akapitami + Line delta Bilans wierszy + Line spacing: Interlinia: + Lines Wiersze + Lines added Dodane wiersze + Lines deleted Usunięte wiersze + Link Link + Link to Previous Połącz z poprzednim + Links Linki + Location: {path} Lokalizacja: {path} + MIT OR Apache-2.0. Copyright (c) 2026 ArtCraft Team and the WordCraft contributors. MIT lub Apache-2.0. Copyright (c) 2026 ArtCraft Team i współtwórcy WordCraft. + Macros Makra + Mailings Korespondencja + Manage Sources Zarządzaj źródłami + Many pages Wiele stron + Margins Marginesy + Mark Citation Oznacz cytat + Mark Entry Oznacz hasło + Mark\nCitation Oznacz\ncytat + Mark\nEntry Oznacz\nhasło + Markdown (*.md) Markdown (*.md) + Match Fields Dopasuj pola + Match case Uwzględniaj wielkość liter + Matrix Macierz + Merge Scal + Merge Cells Scal komórki + Merge Formatting Scal formatowanie + Merged PRs Scalone PR-y + Mirrored Lustrzane + Moderate Umiarkowane + Modify Style Modyfikuj styl + Modify… Modyfikuj… + More templates → Więcej szablonów → + Move or Resize Przenieś lub zmień rozmiar + Multilevel List Lista wielopoziomowa + Multiple Pages Wiele stron + Name (A–Z) Nazwa (A–Z) + Name: Nazwa: + Narrow 0.5" all Wąskie wszystkie 0,5" + Navigation Nawigacja + Navigation Pane Okienko nawigacji + New Nowy + New Comment Nowy komentarz + New Paragraph Nowy akapit + New Style… Nowy styl… + New Window Nowe okno + New from Template Nowy z szablonu + New\nComment Nowy\nkomentarz + New\nWindow Nowe\nokno + Next Następny + Next Change Następna zmiana + Next Comment Następny komentarz + Next Footnote Następny przypis + Next Page Następna strona + Next Record Następny rekord + Next Section Następna sekcja + No Border Bez krawędzi + No Color Bez koloru + No Markup Bez znaczników + No Paragraph Space Bez odstępów między akapitami + No contributor data was built into this copy. Ta kopia programu nie zawiera danych o współtwórcach. + No model credits were built into this copy. Ta kopia programu nie zawiera informacji o modelach. + No templates match your search. Żaden szablon nie pasuje do wyszukiwania. + Nonbreaking Hyphen Łącznik nierozdzielający + Nonbreaking Space Spacja nierozdzielająca + None Brak + Normal 1" all Normalne wszystkie 1" + Normal Style Styl Normalny + Not available right now W tej chwili niedostępne + Not saved Niezapisany + Not saved yet Jeszcze niezapisany + Nudge Przesuń nieznacznie + Number of columns: Liczba kolumn: + Number of rows: Liczba wierszy: + Numbering Numerowanie + OK OK + Object Obiekt + Odd Page Strona nieparzysta + Office 2003 Default Domyślny pakietu Office 2003 + One Jedna + One Page Jedna strona + Open Otwórz + OpenDocument Text (*.odt) Tekst OpenDocument (*.odt) + Optional Hyphen Łącznik opcjonalny + Options Opcje + Orientation Orientacja + Outline Konspekt + Outline Level Poziom konspektu + Outside Borders Krawędzie zewnętrzne + Oval Owal + PDF document (*.pdf) Dokument PDF (*.pdf) + Page Strona + Page Background Tło strony + Page Borders Obramowanie strony + Page Break Podział strony + Page Break Before Podział strony przed + Page Breaks Podziały stron + Page Color Kolor strony + Page Down Page Down + Page Movement Przewijanie stron + Page Number Numer strony + Page Setup Ustawienia strony + Page Up Page Up + Page Width Szerokość strony + Page X of Y Strona X z Y + Page break before Podział strony przed + Page image (*.png) Obraz strony (*.png) + Page width Szerokość strony + Page {page} Strona {page} + Page {page} of {pages} Strona {page} z {pages} + Page\nBorders Obramowanie\nstrony + Page\nColor Kolor\nstrony + Page\nNumber Numer\nstrony + Pages Strony + Paragraph Akapit + Paragraph Details Szczegóły akapitu + Paragraph Down Akapit w dół + Paragraph Settings Ustawienia akapitu + Paragraph Spacing Odstępy między akapitami + Paragraph Up Akapit w górę + Paragraphs Akapity + Paste Wklej + Paste (⌘V) Wklej (⌘V) + Paste Formatting Wklej formatowanie + Paste Special Wklej specjalnie + Paste Special… Wklej specjalnie… + Paste as: Wklej jako: + Paste: Keep Text Only Wklej: zachowaj tylko tekst + Paste: Merge Formatting Wklej: scal formatowanie + Pen Pióro + Pencil Ołówek + Picture Border Obramowanie obrazu + Picture Format Formatowanie obrazu + Picture Styles Style obrazu + Pictures Obrazy + Plain text (*.txt) Zwykły tekst (*.txt) + Portrait Pionowa + Position Położenie + Preview Results Podgląd wyników + Preview\nResults Podgląd\nwyników + Previous Poprzedni + Previous Change Poprzednia zmiana + Previous Comment Poprzedni komentarz + Previous Record Poprzedni rekord + Previous Section Poprzednia sekcja + Print Drukuj + Print Layout Układ wydruku + Print\nLayout Układ\nwydruku + Proofing Sprawdzanie + Proofing Issues Problemy z pisownią i gramatyką + Properties Właściwości + Protect Ochrona + Protect Document Chroń dokument + Quick Access Toolbar Pasek narzędzi szybkiego dostępu + Quick Parts Szybkie części + Quick Tables Szybkie tabele + Read Aloud Czytaj na głos + Read Mode Tryb czytania + Read\nAloud Czytaj\nna głos + Read\nMode Tryb\nczytania + Reads the clipboard's rich text and keeps its formatting. Odczytuje sformatowany tekst ze schowka i zachowuje jego formatowanie. + Reads the clipboard's web content and keeps its formatting. Odczytuje zawartość internetową ze schowka i zachowuje jej formatowanie. + Reading Highlight Wyróżnienie wyników + Recent Ostatnie + Record Macro Zarejestruj makro + Recover Unsaved Documents Odzyskaj niezapisane dokumenty + Rectangle Prostokąt + Redo Ponów + Reduce Hanging Indent Zmniejsz wysunięcie + References Odwołania + Reject Odrzuć + Reject All Changes Odrzuć wszystkie zmiany + Reject Change Odrzuć zmianę + Relaxed Swobodne + Remove Background Usuń tło + Remove Footer Usuń stopkę + Remove Header Usuń nagłówek + Remove Hyperlink Usuń hiperłącze + Remove Space After Paragraph Usuń odstęp po akapicie + Remove Table of Contents Usuń spis treści + Remove Watermark Usuń znak wodny + Reopen Otwórz ponownie + Repeat Powtórz + Repeat Header Rows Powtarzaj wiersze nagłówka + Replace Zamień + Replace All Zamień wszystko + Replace Document Text Zastąp tekst dokumentu + Replace with: Zamień na: + Replay Odtwórz + Reply Odpowiedz + Report Raport + Research Badania + Researcher Badacz + Reset Picture Resetuj obraz + Reset to Normal Przywróć styl Normalny + Resolve Rozwiąż + Resolved Rozwiązany + Restart Each Page Od nowa na każdej stronie + Restart Each Section Od nowa w każdej sekcji + Restart at 1 Zacznij od 1 + Restrict Editing Ogranicz edytowanie + Restrict\nEditing Ogranicz\nedytowanie + Result Wynik + Results Wyniki + Reverse the order Odwróć kolejność + Reviewing Recenzowanie + Reviewing Pane Okienko recenzji + Reviewing — edits become suggestions Recenzowanie — zmiany stają się propozycjami + Rich Text Format (*.rtf) Format RTF (*.rtf) + Rich Text Format (RTF) Format RTF + Right Do prawej + Right Border Krawędź prawa + Right Margin Margines prawy + Right-to-Left Text Direction Kierunek tekstu od prawej do lewej + Left-to-Right Text Direction Kierunek tekstu od lewej do prawej + Right: Prawe: + Rotate Obróć + Rounded Rectangle Zaokrąglony prostokąt + Rows & Columns Wiersze i kolumny + Ruler Linijka + Rules Reguły + Résumé Życiorys + Save Zapisz + Save As Zapisz jako + Save a copy in another format. Printing goes through a PDF you can print from any viewer. Zapisz kopię w innym formacie. Drukowanie odbywa się przez plik PDF, który można wydrukować w dowolnej przeglądarce. + Save as Template Zapisz jako szablon + Saved Zapisany + Search Commands Wyszukaj polecenia + Search commands and help Wyszukaj polecenia i pomoc + Search document Przeszukaj dokument + Search for templates Wyszukaj szablony + Section Breaks Podziały sekcji + Section Properties Właściwości sekcji + Sections Sekcje + Select Zaznacz + Select All Zaznacz wszystko + Select Cell Zaznacz komórkę + Select Line Zaznacz wiersz + Select Objects Zaznacz obiekty + Select Paragraph Zaznacz akapit + Select Range Zaznacz zakres + Select Recipients Wybierz adresatów + Select Row Zaznacz wiersz tabeli + Select Sentence Zaznacz zdanie + Select Table Zaznacz tabelę + Select Text Zaznacz tekst + Select Text with Similar Formatting Zaznacz tekst o podobnym formatowaniu + Select Word Zaznacz słowo + Select\nRecipients Wybierz\nadresatów + Selection Zaznaczenie + Selection Pane Okienko zaznaczenia + Send Backward Przesuń do tyłu + Sentence case. Jak w zdaniu. + Set Caret Ustaw kursor + Set Character Formatting Ustaw formatowanie znaków + Set Numbering Value Ustaw wartość numeracji + Set Paragraph Formatting Ustaw formatowanie akapitu + Set as Default Ustaw jako domyślne + Shading Cieniowanie + Shadow Cień + Shape Fill Wypełnienie kształtu + Shape Format Formatowanie kształtu + Shape Outline Kontur kształtu + Shape Styles Style kształtu + Shapes Kształty + Share Udostępnij + Shift+Tab Shift+Tab + Show Pokaż + Show Comments Pokaż komentarze + Show Markup Pokaż znaczniki + Show Notes Pokaż przypisy + Show all formatting marks Pokaż wszystkie znaki formatowania + Show rulers Pokaż linijki + Show the community button in the title bar Pokaż przycisk społeczności na pasku tytułu + Show/Hide ¶ Pokaż/ukryj ¶ + Show\nComments Pokaż\nkomentarze + Shrink Font 1 Point Pomniejsz czcionkę o 1 punkt + Side to Side Obok siebie + Side\nto Side Obok\nsiebie + Signature Line Wiersz podpisu + Single Pojedyncze + Single Spacing Interlinia pojedyncza + Size Rozmiar + Size: Rozmiar: + Small Caps Kapitaliki + Small caps Kapitaliki + SmartArt SmartArt + Sort Sortuj + Source code: github.com/storytold/wordcraft Kod źródłowy: github.com/storytold/wordcraft + Space before/after: Odstęp przed/po: + Spacing Odstępy + Spacing (pt): Odstępy (pt): + Speech Mowa + Spelling & Grammar Pisownia i gramatyka + Spelling &\nGrammar Pisownia\ni gramatyka + Spelling Suggestions Propozycje pisowni + Split Podziel + Split Cells Podziel komórki + Split Table Podziel tabelę + Spreadsheet Table Tabela arkusza kalkulacyjnego + Standard Colors Kolory standardowe + Star Gwiazda + Start Mail Merge Rozpocznij korespondencję seryjną + Start Mail\nMerge Rozpocznij korespondencję\nseryjną + Statistics Statystyki + Status Bar Pasek stanu + Strikethrough Przekreślenie + Studio cover Okładka Studio + Studio handbook (sample) Podręcznik Studio (przykład) + Style Styl + Style Inspector Inspektor stylów + Style Set Zestaw stylów + Style based on: Styl bazuje na: + Styles Style + Styles Pane Okienko stylów + Subscript Indeks dolny + Superscript Indeks górny + Switch Modes Przełącz tryb + Switch\nModes Przełącz\ntryb + Symbol Symbol + Symbols Symbole + Synonyms Synonimy + Tab Tab + Table Tabela + Table Column Width Szerokość kolumny tabeli + Table Design Projekt tabeli + Table Layout Układ tabeli + Table Properties Właściwości tabeli + Table Row Height Wysokość wiersza tabeli + Table Style Options Opcje stylu tabeli + Table Styles Style tabeli + Table of Authorities Wykaz źródeł prawa + Table of Contents Spis treści + Table of\nContents Spis\ntreści + Tables Tabele + Tabs Tabulatory + Tabular List Lista tabelaryczna + Text Tekst + Text Box Pole tekstowe + Text Direction Kierunek tekstu + Text Effects Efekty tekstowe + Text Effects and Typography Efekty tekstowe i typografia + Text Highlight Color Kolor wyróżnienia tekstu + Text Wrapping Zawijanie tekstu + Text from File Tekst z pliku + Text to display: Wyświetlany tekst: + Text: Tekst: + Text\nBox Pole\ntekstowe + Text\nDirection Kierunek\ntekstu + The clipboard is empty. Schowek jest pusty. + The new style takes the formatting of the current paragraph. Nowy styl przejmie formatowanie bieżącego akapitu. + Theme Colors Kolory motywu + Theme Fonts Czcionki motywu + Theme List Lista motywów + Themes Motywy + Thesaurus Słownik synonimów + Thick Grube + Three Trzy + Tight Ciasne + Top Border Krawędź górna + Top Margin Margines górny + Top of Page Góra strony + Top: Górny: + Total Row Wiersz sumy + Track Changes Śledzenie zmian + Track Changes: On Śledzenie zmian: włączone + Tracking Śledzenie + Translate Przetłumacz + Transparency Przezroczystość + Triangle Trójkąt + Two Dwie + Type Text Wpisz tekst + Type a command, e.g. "insert table" Wpisz polecenie, np. „wstaw tabelę” + UPPERCASE WIELKIE LITERY + Underline Podkreślenie + Underline Words Only Podkreślaj tylko wyrazy + Undo Cofnij + Undo {action} Cofnij: {action} + Unformatted Text Tekst niesformatowany + Up W górę + Update Field Aktualizuj pole + Update Index Aktualizuj indeks + Update Style to Match Selection Aktualizuj styl, aby pasował do zaznaczenia + Update Table Aktualizuj tabelę + Update Table of Figures Aktualizuj spis ilustracji + Update to Match Aktualizuj, aby pasował + Update to Match Selection Aktualizuj, aby pasował do zaznaczenia + User Name Nazwa użytkownika + User name: Nazwa użytkownika: + Version History Historia wersji + Version {version} ({build}) Wersja {version} ({build}) + Vertical Pionowo + Vertical Alignment Wyrównanie w pionie + View Widok + View Gridlines Pokaż linie siatki + View State Stan widoku + Views Widoki + Voice Głos + Watermark Znak wodny + Wave Falowane + Web Layout Układ strony WWW + Web\nLayout Układ\nstrony WWW + Web page (*.html) Strona internetowa (*.html) + Whole page Cała strona + Whole words Całe wyrazy + Wide Szerokie + Widow/Orphan Control Kontrola wdów i sierot + Widow/Orphan control Kontrola wdów i sierot + Wildcards (regex) Symbole wieloznaczne (wyrażenia regularne) + Window Okno + Word Count Liczba słów + Word Left Słowo w lewo + Word Right Słowo w prawo + Word document (*.docx) Dokument Word (*.docx) + WordArt WordArt + WordCraft WordCraft + Words Słowa + Words only Tylko wyrazy + Wrap Text Zawijaj tekst + Wrap\nText Zawijaj\ntekst + Write & Insert Fields Pisanie i wstawianie pól + Write a reply Napisz odpowiedź + Zoom Powiększenie + Zoom In Powiększ + Zoom Out Pomniejsz + development build wersja rozwojowa + lowercase małe litery + {group} settings Ustawienia: {group} + {key}+Click to follow link {key}+kliknięcie otwiera link + {selected} of {words} words Słowa: {selected} z {words} + {words} words Słowa: {words} + ➕ New comment ➕ Nowy komentarz + 📂 Browse… 📂 Przeglądaj… + Equation Równanie + Review Recenzja + Track\nChanges Śledzenie\nzmian + Track\nChanges ✓ Śledzenie\nzmian ✓ + Scroll ribbon left Przewiń wstążkę w lewo + Scroll ribbon right Przewiń wstążkę w prawo + Apply Styles Zastosuj style + Dark page (white text on black) Ciemna strona (biały tekst na czarnym tle) + Show documents with their colours inverted, like View › Switch Modes. Saving, printing and PDFs are unchanged. Pokazuj dokumenty w odwróconych kolorach, jak Widok › Przełącz tryb. Zapisywanie, drukowanie i pliki PDF pozostają bez zmian. + Only web (http, https) and email (mailto) links can be followed Otwierać można tylko linki internetowe (http, https) i e-mailowe (mailto) + Character Border Obramowanie znaku + Direction: Kierunek: + Right-to-left Od prawej do lewej + Left-to-right Od lewej do prawej + Zotero Zotero + Citations Cytaty + Document Dokument + Add/Edit\nCitation Dodaj/edytuj\ncytat + Add\nNote Dodaj\nnotatkę + Add/Edit\nBibliography Dodaj/edytuj\nbibliografię + Refresh Odśwież + Document\nPreferences Preferencje\ndokumentu + Unlink\nCitations Odłącz\ncytaty + Waiting for Zotero… Oczekiwanie na Zotero… + Zotero: done Zotero: gotowe + Zotero: cancelled Zotero: anulowano + Yes Tak + No Nie + Move Past Citation Przejdź za cytat + Move Past\nCitation Przejdź\nza cytat + Next Sentence Następne zdanie + Previous Sentence Poprzednie zdanie + Next sentence Następne zdanie + Previous sentence Poprzednie zdanie + Play/Pause Odtwórz/wstrzymaj + Read Aloud Status Stan czytania na głos + Reading Speed Szybkość czytania + Skip Citations and Bibliography Pomijaj cytaty i bibliografię + Skip citations & bibliography Pomijaj cytaty i bibliografię + Stop Reading Zatrzymaj czytanie + Don't read Zotero, Mendeley or EndNote citations and bibliographies Nie czytaj cytatów ani bibliografii z Zotero, Mendeley i EndNote + Play Odtwórz + Pause Wstrzymaj + Stop Zatrzymaj + Sentence Zdanie + Paused Wstrzymano + Finished Zakończono + Stopped Zatrzymano + Speed Szybkość + Normal speed Normalna szybkość + AutoSave documents you have saved in WordCraft Zapisuj automatycznie dokumenty zapisane już w programie WordCraft + AutoSave failed: {error}. Save the document to turn AutoSave back on. Autozapis nie powiódł się: {error}. Zapisz dokument, aby ponownie włączyć autozapis. + Couldn't download {name}: {error} Nie udało się pobrać pliku {name}: {error} + Do you want to save changes to {name}? Czy zapisać zmiany w dokumencie {name}? + Don't Save Nie zapisuj + That format doesn't keep everything, so the document is still open. Save it as a Word document (.docx) to go on. Ten format nie zachowuje wszystkiego, więc dokument pozostaje otwarty. Aby kontynuować, zapisz go jako dokument Word (.docx). + Your changes will be lost if you don't save them. Jeśli nie zapiszesz zmian, zostaną utracone. + Change Structure Zmień strukturę + Change to Display Zmień na równanie wyświetlane + Change to Inline Zmień na równanie w tekście + Conversions Konwersje + Delete Back in Equation Usuń wstecz w równaniu + Delete in Equation Usuń w równaniu + Edit Equation Edytuj równanie + Enter in Equation Enter w równaniu + Equation Gallery Galeria równań + Equation Info Informacje o równaniu + Equation Number Numer równania + Input Format Format wprowadzania + Insert Structure Wstaw strukturę + Insert Symbol Wstaw symbol + Justification Wyrównanie + Leave Equation Opuść równanie + Move in Equation Przesuń w równaniu + Normal Text Zwykły tekst + Set Equation Ustaw równanie + Structure Actions Działania na strukturze + Structures Struktury + Tools Narzędzia + Type in Equation Pisz w równaniu + Inline W tekście + Number Numer + Close Equation Zamknij równanie + Insert New Equation Wstaw nowe równanie + Built-In Wbudowane + Professional Profesjonalny + Linear Liniowy + Fraction Ułamek + Script Indeks + Radical Pierwiastek + Integral Całka + Large\nOperator Duży\noperator + Bracket Nawias + Function Funkcja + Accent Akcent + Limit and\nLog Granica\ni logarytm + Operator Operator + Equation Options Opcje równania + All symbol sets Wszystkie zestawy symboli + Send the document straight to the system print dialog, or save a copy in another format below. Wyślij dokument prosto do systemowego okna drukowania albo zapisz kopię w innym formacie poniżej. + Or save a copy in another format: Albo zapisz kopię w innym formacie: + Opening print dialog… Otwieranie okna drukowania… + Print failed: {error} Drukowanie nie powiodło się: {error} + Couldn't open {path}: {error} Nie udało się otworzyć {path}: {error} + Column Selection Zaznaczanie kolumnowe + New Table Style Nowy styl tabeli + Modify Table Style Modyfikuj styl tabeli + New Table Style… Nowy styl tabeli… + Modify Table Style… Modyfikuj styl tabeli… + Apply formatting to: Zastosuj formatowanie do: + Whole Table Cała tabela + Fill color: Kolor wypełnienia: + {count} items highlighted Wyróżnione elementy: {count} + {n} pt {n} pt + Not {name} {name}: nie + Level {n} Poziom {n} + Body Text Tekst podstawowy + {n} lines {n} × wiersz + At least {n} Co najmniej {n} + Exactly {n} Dokładnie {n} + Centered Wyśrodkowany + Character spacing Odstępy między znakami + Don't add space between paragraphs of the same style Nie dodawaj odstępu między akapitami o tym samym stylu + Don't hyphenate Nie dziel wyrazów + Double strikethrough Przekreślenie podwójne + Drop cap Inicjał + First line Pierwszy wiersz + Font color Kolor czcionki + Highlight Wyróżnienie + Justified Wyjustowany + Left indent Wcięcie z lewej + Line spacing Interlinia + Outline level Poziom konspektu + Raised/lowered Podniesienie/obniżenie + Right indent Wcięcie z prawej + Scale Skala + Space after Odstęp po + Space before Odstęp przed + Suppress line numbers Pomiń numery wierszy + Underline color Kolor podkreślenia + Letters Listy + E-mail Messages Wiadomości e-mail + Directory Katalog + Normal Word Document Zwykły dokument + If…Then…Else… Jeżeli…To…W przeciwnym razie… + If…Then…Else Jeżeli…To…W przeciwnym razie + Merge Record # Nr rekordu scalania + Skip Record If… Pomiń rekord, jeżeli… + Skip Record If Pomiń rekord, jeżeli + Equals: Równa się: + Then insert: Wtedy wstaw: + Otherwise insert: W przeciwnym razie wstaw: + Courtesy Title Zwrot grzecznościowy + First Name Imię + Last Name Nazwisko + Company Firma + Address Adres + City Miejscowość + State Województwo/stan + Postal Code Kod pocztowy + Country Kraj + (not matched) (niedopasowane) + Leave a recipient out of the merge when a field has this value. Pomiń adresata podczas scalania, gdy pole ma tę wartość. + Insert one text or another, depending on a field's value. Wstaw jeden lub drugi tekst w zależności od wartości pola. + Select recipients first to match their fields. Najpierw wybierz adresatów, aby dopasować ich pola. + The recipient-list field that fills each part of an address block and greeting line. Pole listy adresatów, które wypełnia poszczególne części bloku adresu i wiersza powitania. + No errors found: every merge field is in the recipient list. Nie znaleziono błędów: każde pole scalania jest na liście adresatów. + These merge fields aren't in the recipient list: Tych pól scalania nie ma na liście adresatów: + Add at least one field. Dodaj co najmniej jedno pole. + Add Field Dodaj pole + Every field needs a name. Każde pole musi mieć nazwę. + Field name Nazwa pola + Field names must be different. Nazwy pól muszą się różnić. + Fields: Pola: + Field: Pole: + Find: Znajdź: + New Entry Nowy wpis + Recipient List Lista adresatów + Recipients: {count} Adresaci: {count} + Remove field Usuń pole + Remove recipient Usuń adresata + Select recipients first to choose from their fields. Najpierw wybierz adresatów, aby wybierać spośród ich pól. + Type a New List… Wpisz nową listę… + Type at least one recipient. Wpisz co najmniej jednego adresata. + Type the recipients, or load them from a file. Wpisz adresatów albo wczytaj ich z pliku. + Use an Existing List… Użyj istniejącej listy… + Interface theme: Motyw interfejsu: + Interface\nTheme Motyw\ninterfejsu + Interface Theme Motyw interfejsu + Light Jasny + Dark Ciemny + Use system setting Użyj ustawienia systemowego + A file dialog is already open. Okno wyboru pliku jest już otwarte. + Height: Wysokość: + Width: Szerokość: + Row Wiersz + Cell Komórka + Preferred width: Preferowana szerokość: + Alignment: Wyrównanie: + Indent from left: Wcięcie od lewej: + Specify height: Określ wysokość: + Exactly Dokładnie + At least Co najmniej + Allow row to break across pages Zezwalaj na dzielenie wiersza między stronami + Repeat as header row on each page Powtarzaj jako wiersz nagłówka na każdej stronie + Vertical alignment: Wyrównanie w pionie: + Top Do góry + Bottom Do dołu + Alt text Tekst alternatywny + Alt: Alt.: + W: Szer.: + H: Wys.: + Reset Resetuj + Reset Crop Resetuj przycięcie + Insert a new equation (Alt+=) Wstaw nowe równanie (Alt+=) + Leave the equation (Esc) Opuść równanie (Esc) + Preview Podgląd + getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft + English (United Kingdom) Angielski (Wielka Brytania) + Polish Polski + Do Not Check Spelling or Grammar Nie sprawdzaj pisowni ani gramatyki + Possible spelling mistake Możliwy błąd pisowni + Extra space Zbędna spacja + Capitalize the first word of a sentence Zacznij zdanie wielką literą + Remove the space before the punctuation Usuń spację przed znakiem interpunkcyjnym + Use "an" before a vowel sound Przed głoską samogłoskową użyj „an” + Use "a" before a consonant sound Przed głoską spółgłoskową użyj „a” + The spelling and grammar check is complete. Sprawdzanie pisowni i gramatyki zostało zakończone. + Font… Czcionka… + Paragraph… Akapit… + Link… Link… + Change Picture… Zmień obraz… + Size and Crop… Rozmiar i przycinanie… + Insert Rows Above Wstaw wiersze powyżej + Insert Rows Below Wstaw wiersze poniżej + Insert Columns to the Left Wstaw kolumny z lewej + Insert Columns to the Right Wstaw kolumny z prawej + (picture or object) (obraz lub obiekt) + A password is required to open this document. Do otwarcia tego dokumentu potrzebne jest hasło. + Add Object to Selection Dodaj obiekt do zaznaczenia + Add space between Asian and Latin text Dodawaj odstęp między tekstem azjatyckim a łacińskim + Add space between Asian text and numbers Dodawaj odstęp między tekstem azjatyckim a liczbami + Allow Latin text to wrap in the middle of a word Zezwalaj na łamanie tekstu łacińskiego w środku wyrazu + Allow hanging punctuation Zezwalaj na wysuniętą interpunkcję + Alphabetical Alfabetycznie + Anyone can open this document. Encrypt it with a password to keep it private. Każdy może otworzyć ten dokument. Zaszyfruj go hasłem, aby chronić jego treść. + Anyone who opens the document will need this password. Każdy, kto otworzy dokument, będzie potrzebować tego hasła. + Apply Asian line-breaking rules (kinsoku) Stosuj azjatyckie reguły łamania wierszy (kinsoku) + As Recommended Według zaleceń + Asian Typography Typografia azjatycka + AutoSave isn't available in the browser: it can't write to your files. Save downloads a copy. Autozapis nie działa w przeglądarce, ponieważ nie może zapisywać Twoich plików. Polecenie Zapisz pobiera kopię. + AutoSave only keeps .docx, .odt and .rtf files; {name} can't keep all formatting. Save as .docx to turn on AutoSave. Autozapis obsługuje tylko pliki .docx, .odt i .rtf; plik {name} nie zachowa całego formatowania. Zapisz jako .docx, aby włączyć Autozapis. + Based on: {style} Na podstawie: {style} + Browse… Przeglądaj… + Built-in style Styl wbudowany + Built-in styles can't be deleted. Nie można usuwać stylów wbudowanych. + Change Password… Zmień hasło… + Character style Styl znakowy + Click an item to paste it. Kliknij element, aby go wkleić. + Clipboard Items Elementy schowka + Clipboard Pane Okienko schowka + Color: {color} Kolor: {color} + Compress punctuation at the start of a line Kompresuj interpunkcję na początku wiersza + Confirm password: Potwierdź hasło: + Delete Clipboard Item Usuń element schowka + Delete Table Style Usuń styl tabeli + Encrypt with Password… Szyfruj hasłem… + Font: {font} Czcionka: {font} + Glow Poświata + Glow Colors Kolory poświaty + Hide from style lists Ukryj na listach stylów + Indents and Spacing Wcięcia i odstępy + Keep the password somewhere safe: without it nobody, WordCraft included, can open the document. Leave both boxes empty to remove the password. Przechowuj hasło w bezpiecznym miejscu: bez niego nikt, również WordCraft, nie otworzy dokumentu. Pozostaw oba pola puste, aby usunąć hasło. + Line breaking Łamanie wierszy + Linked style (paragraph and character) Styl połączony (akapitowy i znakowy) + Manage Styles Zarządzaj stylami + Manage Styles… Zarządzaj stylami… + No Glow Bez poświaty + No Shadow Bez cienia + No Soft Edges Bez zmiękczonych krawędzi + Nothing collected yet. Items you copy or cut appear here. Na razie nic tu nie ma. Pojawią się tu elementy, które skopiujesz lub wytniesz. + Offset: Bottom Przesunięcie: w dół + Offset: Bottom Left Przesunięcie: w dół i w lewo + Offset: Bottom Right Przesunięcie: w dół i w prawo + Offset: Center Przesunięcie: wyśrodkowane + Offset: Left Przesunięcie: w lewo + Offset: Right Przesunięcie: w prawo + Offset: Top Przesunięcie: w górę + Offset: Top Left Przesunięcie: w górę i w lewo + Offset: Top Right Przesunięcie: w górę i w prawo + Paragraph style Styl akapitowy + Password Hasło + Password: Hasło: + Paste All Wklej wszystko + Paste Clipboard Item Wklej element schowka + Remove Password Usuń hasło + Save the document to turn on AutoSave Zapisz dokument, aby włączyć Autozapis + Save the document to turn on AutoSave (a file you have only opened isn't rewritten until you save it) Zapisz dokument, aby włączyć Autozapis (plik, który tylko otwarto, nie jest nadpisywany, dopóki go nie zapiszesz) + Shadow, glow and soft edges Cień, poświata i zmiękczone krawędzie + Shape Effects Efekty kształtów + Show in the Styles gallery Pokaż w galerii stylów + Show or Hide Style Pokaż lub ukryj styl + Soft Edges Zmiękczone krawędzie + Sort order: Kolejność sortowania: + Space after: {pt} pt Odstęp po: {pt} pt + Space before: {pt} pt Odstęp przed: {pt} pt + Table style Styl tabeli + That password isn't right. Check it (passwords are case-sensitive) and try again. Hasło jest nieprawidłowe. Sprawdź je (wielkość liter ma znaczenie) i spróbuj ponownie. + The document will be saved with a password. Save it to protect it. Dokument zostanie zapisany z hasłem. Zapisz go, aby go zabezpieczyć. + The password is removed. Save the document to keep the change. Hasło zostało usunięte. Zapisz dokument, aby zachować tę zmianę. + The passwords don't match. Hasła nie są zgodne. + Ungroup Rozgrupuj + Weight Grubość + {name} is protected with a password. Plik {name} jest chroniony hasłem. + {size} pt {size} pt + +# Interface text added upstream after the first Polish catalog (#294, #303, #307, #313, #327, #332, #335, #342, #475) + % of all commits % wszystkich commitów + (All) (Wszystkie) + (no style) (bez stylu) + (none) (brak) + (normal text) (zwykły tekst) + 1/4 Em Space Spacja 1/4 firetu + 3-D Trójwymiarowe + A mail merge field Pole korespondencji seryjnej + A numbered sequence (figures, tables…) Sekwencja numerowana (rysunki, tabele…) + A table of contents Spis treści + AaBbYyZz AaBbYyZz ĄąŁłŻż + Add tab stop at: Dodaj tabulator w pozycji: + Align Bottom Center Wyrównaj do dołu i do środka + Align Bottom Left Wyrównaj do dołu i do lewej + Align Bottom Right Wyrównaj do dołu i do prawej + Align Center Wyrównaj do środka + Align Center Left Wyrównaj do środka i do lewej + Align Center Right Wyrównaj do środka i do prawej + Align Top Center Wyrównaj do góry i do środka + Align Top Left Wyrównaj do góry i do lewej + Align Top Right Wyrównaj do góry i do prawej + Aligned at: Wyrównanie na: + All Wszystkie + Apply to: Zastosuj do: + Atelier Atelier + Balloons Dymki + Bar Pionowa kreska + Between Między akapitami + Borders and Shading Obramowanie i cieniowanie + Borders and Shading… Obramowanie i cieniowanie… + Bullet Punktor + By author Według autora + Categories: Kategorie: + Changed lines: Zmienione wiersze: + Character Znak + Character code: Kod znaku: + Click a border between two cells to merge them. Esc stops. Kliknij krawędź między dwiema komórkami, aby je scalić. Esc kończy. + Click level to modify: Kliknij poziom do zmiany: + Col #: Kolumna nr: + Color only Tylko kolor + Commits Commity + Copy of the text a bookmark marks Kopia tekstu oznaczonego zakładką + Copyright Prawa autorskie + Craft Rzemiosło + Custom Niestandardowe + Darkroom Ciemnia + Date and Time Data i godzina + Date formats: Formaty daty: + Decimal Dziesiętny + Default tab stops: Domyślne tabulatory: + Define New Multilevel List Definiuj nową listę wielopoziomową + Define New Multilevel List… Definiuj nową listę wielopoziomową… + Deletions: Usunięcia: + Description: Opis: + Display name Nazwa wyświetlana + Document Information Informacje o dokumencie + Document1 Dokument1 + Dot dash Kreska-kropka + Double Closing Quote Podwójny cudzysłów zamykający + Double Opening Quote Podwójny cudzysłów otwierający + Drag a rectangle to draw a table, or a line across cells to split them. Esc stops. Przeciągnij prostokąt, aby narysować tabelę, albo linię przez komórki, aby je podzielić. Esc kończy. + Draw Ink Rysuj odręcznie + Draw Table Rysuj tabelę + Ellipsis Wielokropek + Em Dash Pauza + Em Space Spacja firetowa + En Dash Półpauza + En Space Spacja półfiretowa + Enter formatting for number: Wprowadź formatowanie numeru: + Equal column width Równa szerokość kolumn + Erase Ink Wymaż pismo odręczne + Field Codes Kody pól + Field code: Kod pola: + Field codes: Kody pól: + Field name: Nazwa pola: + Field names: Nazwy pól: + Field properties Właściwości pola + Field… Pole… + Fill Wypełnienie + First Pierwszy + Flip Horizontal Przerzuć w poziomie + Flip Vertical Przerzuć w pionie + Follow number with: Po numerze wstaw: + Format: Format: + Formatted Sformatowano + Formatting Formatowanie + Gallery Galeria + Hide Codes Ukryj kody + Hide Ink Ukryj pismo odręczne + Hide\nInk Ukryj\npismo + How many pages the whole document has Liczba stron całego dokumentu + How many pages this section has Liczba stron tej sekcji + How many words the document holds Liczba wyrazów w dokumencie + Include level number from: Dołącz numer poziomu z: + Index and Tables Indeks i spisy + Ink Pismo odręczne + Insertions and Deletions Wstawienia i usunięcia + Insertions: Wstawienia: + Interface size: Rozmiar interfejsu: + Last Ostatni + Leader Znaki wiodące + Left border Lewa krawędź + Legal style numbering Numeracja w stylu prawniczym + Line between Linia między kolumnami + Lines +/− Wiersze +/− + Link level to style: Połącz poziom ze stylem: + Links and References Łącza i odwołania + List Definition Definicja listy + Mail Merge Korespondencja seryjna + Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Powiększa lub zmniejsza wstążkę, menu i okna dialogowe, niezależnie od skalowania ekranu w systemie. Powiększenie dokumentu ustawia się osobno. + Model Model + Mon Pon + More Columns… Więcej kolumn… + More Symbols… Więcej symboli… + Name Nazwa + Name of the file this document is saved as Nazwa pliku, pod którą zapisano ten dokument + No-Width Non Break Niełamiący znak zerowej szerokości + No-Width Optional Break Opcjonalny podział zerowej szerokości + North Północ + Nothing Nic + Number alignment: Wyrównanie numeru: + Number format Format numeru + Number of the page the field sits on Numer strony, na której jest pole + Number of the section the field sits in Numer sekcji, w której jest pole + Number style for this level: Styl numeru dla tego poziomu: + Outside border Krawędź zewnętrzna + PRs PR-y + Page Border Obramowanie strony + Page on which a bookmarked passage falls Strona, na której znajduje się fragment oznaczony zakładką + Paragraph Mark Znak akapitu + Pens Pióra + Pick a field from the list Wybierz pole z listy + Preserve formatting during updates Zachowaj formatowanie podczas aktualizacji + Presets Ustawienia wstępne + Real name Imię i nazwisko + Recently used symbols: Ostatnio używane symbole: + Registered Zastrzeżony znak towarowy + Restart list after: Zacznij listę od nowa po: + Right border Prawa krawędź + Rotate Left 90° Obróć w lewo o 90° + Rotate Right 90° Obróć w prawo o 90° + Rotation Obrót + Rotation: Obrót: + Section Mark Znak paragrafu + Sequence name: Nazwa sekwencji: + Set Ustaw + Setting: Ustawienie: + Shortcut key: Klawisz skrótu: + Show all revisions inline Pokaż wszystkie poprawki w tekście + Show only comments and formatting in balloons Pokaż w dymkach tylko komentarze i formatowanie + Show revisions in balloons Pokaż poprawki w dymkach + Single Closing Quote Pojedynczy cudzysłów zamykający + Single Opening Quote Pojedynczy cudzysłów otwierający + Sketchbook Szkicownik + South Południe + Space Odstęp + Spacing: Odstępy: + Special Characters Znaki specjalne + Start at: Zacznij od: + Studio Studio + Style: Styl: + Style: {style} Styl: {style} + Subset: Podzbiór: + Sum Suma + Tab character Znak tabulacji + Tab stop position: Pozycja tabulatora: + Tab stops to be cleared: Tabulatory do usunięcia: + Tabs… Tabulatory… + Text indent at: Wcięcie tekstu na: + The date it is today Dzisiejsza data + The document has no bookmarks yet. Dokument nie ma jeszcze zakładek. + The time right now Bieżąca godzina + Thickness Grubość linii + This field has no options. To pole nie ma opcji. + This font has no characters to show. Ta czcionka nie zawiera znaków do pokazania. + This point forward Od tego miejsca + This section Ta sekcja + Title from the document properties Tytuł z właściwości dokumentu + Total Razem + Track Changes Options Opcje śledzenia zmian + Track formatting Śledź formatowanie + Trademark Znak towarowy + Triple Potrójna + Tue Wt + Type the field's name and its switches, for example PAGE \\* roman. Wpisz nazwę pola i jego przełączniki, na przykład PAGE \\* roman. + Username Nazwa użytkownika + Version Wersja + Wed Śr + West Zachód + When this document was first created Kiedy utworzono ten dokument + When this document was most recently printed Kiedy ostatnio wydrukowano ten dokument + When this document was most recently saved Kiedy ostatnio zapisano ten dokument + Who the document properties name as author Autor podany we właściwościach dokumentu + Whole document Cały dokument + Width and spacing Szerokość i odstępy + from Unicode (hex) z Unicode (szesnastkowo) + pt pt + {cols}×{rows} Table Tabela {cols}×{rows} + {contributors} contributors · {commits} commits Współtwórcy: {contributors} · commity: {commits} + Copyright Sign Znak praw autorskich + Edit Field Code Edytuj kod pola + Edit Field… Edytuj pole… + Insert Date Field Wstaw pole daty + Insert Page Field Wstaw pole numeru strony + Insert Time Field Wstaw pole godziny + Lock Fields Zablokuj pola + Next Field Następne pole + Previous Field Poprzednie pole + Registered Sign Znak zastrzeżonego znaku towarowego + Reset Character Formatting Resetuj formatowanie znaków + Reset Paragraph Formatting Resetuj formatowanie akapitu + Selected Fields Zaznaczone pola + Toggle Field Codes Przełącz kody pól + Trademark Sign Znak towarowy (™) + Unlink Fields Odłącz pola + Unlock Fields Odblokuj pola + All Commands Wszystkie polecenia + Assign Przypisz + Choose a command Wybierz polecenie + Choose a shortcut Wybierz skrót + Commands: Polecenia: + Customize Dostosuj + Customize Keyboard Dostosuj klawiaturę + Customize… Dostosuj… + Demote Obniż poziom + Draw\nTable Rysuj\ntabelę + Go Back Wróć + Keyboard shortcuts: Skróty klawiaturowe: + Keys for this command: Klawisze tego polecenia: + Move Paragraph Down Przenieś akapit w dół + Move Paragraph Up Przenieś akapit w górę + New shortcut: Nowy skrót: + Not used by any command Nieużywany przez żadne polecenie + Now used by {command} Obecnie używany przez: {command} + Press a key combination Naciśnij kombinację klawiszy + Promote Podwyższ poziom + Remove Usuń + Remove every custom shortcut and restore the built-in keys? Usunąć wszystkie niestandardowe skróty i przywrócić wbudowane klawisze? + Reset All… Resetuj wszystko… + Toggle Unicode Character Przełącz znak Unicode + (Custom) (Niestandardowe) + << Remove << Usuń + Add >> Dodaj >> + Add to Quick Access Toolbar Dodaj do paska narzędzi Szybki dostęp + Customize Quick Access Toolbar… Dostosuj pasek narzędzi Szybki dostęp… + Customize Ribbon Dostosuj wstążkę + Customize the Ribbon… Dostosuj wstążkę… + More Commands… Więcej poleceń… + New Group Nowa grupa + New Tab Nowa karta + Pick a custom group (New Group makes one) Wybierz grupę niestandardową (utworzysz ją przyciskiem Nowa grupa) + Put the default commands back on the Quick Access Toolbar? Przywrócić domyślne polecenia na pasku narzędzi Szybki dostęp? + Remove every custom tab and group and show all tabs again? Usunąć wszystkie niestandardowe karty i grupy oraz ponownie pokazać wszystkie karty? + Remove from Quick Access Toolbar Usuń z paska narzędzi Szybki dostęp + Rename… Zmień nazwę… + Reset… Resetuj… + Ribbon Wstążka + Ribbon and Quick Access Toolbar: Wstążka i pasek narzędzi Szybki dostęp: + Ribbon tabs Karty wstążki + Align Text Wyrównaj tekst + Apply Zastosuj + Arrows Strzałki + Base text: Tekst podstawowy: + Behind Text Za tekstem + Below text Pod tekstem + Both Sides Obie strony + Break Link Przerwij łącze + Canvas Member Element kanwy + Choose the sheet that holds the recipients. Wybierz arkusz zawierający adresatów. + Circle Okrąg + Click the empty text box the text should continue in (Esc cancels) Kliknij puste pole tekstowe, w którym ma być kontynuowany tekst (Esc anuluje) + Communication Komunikacja + Continuous numbering Numeracja ciągła + Convert Notes Konwertuj przypisy + Create Link Utwórz łącze + Custom mark: Znak niestandardowy: + Distribute letters Rozłóż litery + Distribute spacing Rozłóż odstępy + Drawing Canvas Kanwa rysunku + Enclose Characters Otocz znaki + Enclosure Obramowanie znaku + End of document Koniec dokumentu + End of section Koniec sekcji + Endnotes Przypisy końcowe + Endnotes to footnotes Przypisy końcowe na dolne + Enlarge symbol Powiększ symbol + Expand Rozwiń + First row of data contains column headers Pierwszy wiersz danych zawiera nagłówki kolumn + Fit Dopasuj + Footnotes to endnotes Przypisy dolne na końcowe + Icon Color Kolor ikony + In Front of Text Przed tekstem + In Line with Text Równo z tekstem + Largest Only Tylko największa strona + Left Only Tylko lewa strona + Location Położenie + Middle Środek + Moved from Przeniesiono z + Moved to Przeniesiono do + Nature Przyroda + New Drawing Canvas Nowa kanwa rysunku + No icons match your search. Żadna ikona nie pasuje do wyszukiwania. + Not linked: click a text box Niepołączone: kliknij pole tekstowe + Office Biuro + Offset (pt): Przesunięcie (pt): + People Ludzie + Phonetic Guide Przewodnik fonetyczny + Recolor a one-color picture, such as an icon Zmień kolor jednobarwnego obrazu, na przykład ikony + Right (vertical) Do prawej (pionowo) + Right Only Tylko prawa strona + Right-to-Left Section Sekcja od prawej do lewej + Right-to-Left Table Tabela od prawej do lewej + Rotate all text 270° Obróć cały tekst o 270° + Rotate all text 90° Obróć cały tekst o 90° + Ruby text: Tekst ruby: + Scale Drawing Skaluj rysunek + Search icons Szukaj ikon + Section direction: Kierunek sekcji: + Shrink text Zmniejsz tekst + Size (pt): Rozmiar (pt): + Square Kwadrat + Stacked Ułożone w stos + Swap footnotes and endnotes Zamień przypisy dolne i końcowe + Table direction: Kierunek tabeli: + Technology Technologia + Through Przyległe + Top and Bottom Górne i dolne + Transport Transport + Weather Pogoda + Wrap Text Sides Strony zawijania tekstu + Characters per line: Znaków w wierszu: + Document Grid Siatka dokumentu + Lines and characters Wiersze i znaki + Lines only Tylko wiersze + Lines per page: Wierszy na stronie: + No grid Bez siatki + Text snaps to characters Tekst przyciąga do znaków + +# Interface text added on main since the first Polish catalog + A, B, C, … A, B, C, … + Above the item Nad elementem + Accessed on Data dostępu + Accessibility Pane Okienko ułatwień dostępu + Add New Source… Dodaj nowe źródło… + Add Pen Dodaj pióro + Add line numbering Dodaj numerowanie wierszy + Add the chapter number Dodaj numer rozdziału + Add to Document Dodaj do dokumentu + Add\nPen Dodaj\npióro + Address labels Etykiety adresowe + Alt Text Pane Okienko tekstu alternatywnego + Alt\nText Tekst\nalternatywny + Arabic Arabski + Atmosphere Atmosfera + Auto Automatycznie + Automatically hyphenate document Automatycznie dziel wyrazy w dokumencie + Below the item Pod elementem + Book Książka + Book title Tytuł książki + Caption Podpis + Caption Numbering Numerowanie podpisów + Caption text Tekst podpisu + Caption: Podpis: + Chapter in a book Rozdział w książce + Chapters begin at: Rozdziały zaczynają się od: + Chinese (Simplified) Chiński (uproszczony) + Chinese (Traditional) Chiński (tradycyjny) + Collect entries from: Zbieraj pozycje z: + Column: Kolumna: + Columns: Kolumny: + Conference Konferencja + Conference paper Referat konferencyjny + Container Title Tytuł całości + Convert to Shapes Konwertuj na kształty + Count by: Co ile: + Create Source Utwórz źródło + Cross-reference: Odsyłacz: + Custom Table of Contents… Niestandardowy spis treści… + Czech Czeski + DOI DOI + Danish Duński + Day Dzień + Decorative objects only add visual interest: screen readers skip them. Obiekty dekoracyjne służą tylko ozdobie: czytniki ekranu je pomijają. + Delete Label Usuń etykietę + Delete Selected Ink Usuń zaznaczone pismo odręczne + Delivery address: Adres odbiorcy: + Describe what this object shows, for people who can't see it. Opisz, co przedstawia ten obiekt, dla osób, które go nie widzą. + Describe what this table shows, for people who can't see it. Opisz, co przedstawia ta tabela, dla osób, które jej nie widzą. + Detect language automatically Wykrywaj język automatycznie + Do not check spelling or grammar Nie sprawdzaj pisowni ani gramatyki + Dutch Niderlandzki + Edit Source Edytuj źródło + Edition Wydanie + Edit… Edytuj… + Entry marked. Pozycja oznaczona. + Envelope size: Rozmiar koperty: + Errors ({n}) Błędy ({n}) + Estonian Estoński + Example: {sample} Przykład: {sample} + Figure Rysunek + Film Film + Finnish Fiński + Forward Do przodu + French (France) Francuski (Francja) + From text: Od tekstu: + Full page of the same label Cała strona tej samej etykiety + German (Germany) Niemiecki (Niemcy) + Greek Grecki + Heading styles (levels 1–{n}) Style nagłówków (poziomy 1–{n}) + Heading {n} Nagłówek {n} + Hebrew Hebrajski + Hyphenate at: Dziel w: + Hyphenate words in CAPS Dziel wyrazy pisane WIELKIMI LITERAMI + Hyphenation Options… Opcje dzielenia wyrazów… + Hyphenation zone: Strefa dzielenia wyrazów: + I, II, III, … I, II, III, … + Include page numbers Dołącz numery stron + Indented Z wcięciem + Ink Replay Odtwarzanie pisma odręcznego + Ink to Shape Pismo odręczne na kształt + Institution Instytucja + Ionosphere Jonosfera + Issue Numer + Italian Włoski + Japanese Japoński + Journal Czasopismo naukowe + Korean Koreański + Label: Etykieta: + Lasso Select Zaznaczanie lassem + Last, First; Last, First Nazwisko, Imię; Nazwisko, Imię + Leave the label out of the caption Pomiń etykietę w podpisie + Letter headings Nagłówki literowe + Levels: Poziomy: + Limit consecutive hyphens to: Ogranicz kolejne łączniki do: + Line Numbering Options… Opcje numerowania wierszy… + Link entries to their headings Połącz pozycje z ich nagłówkami + Made from the author and year when empty Gdy puste, tworzone z autora i roku + Magazine or newspaper article Artykuł w czasopiśmie lub gazecie + Main entry: Pozycja główna: + Manual Hyphenation Ręczne dzielenie wyrazów + Manual hyphenation is complete. Ręczne dzielenie wyrazów zostało zakończone. + Manual… Ręcznie… + Mark Oznacz + Mark All Oznacz wszystkie + Mark Entry… Oznacz pozycję… + Mark Index Entry Oznaczanie pozycji indeksu + Mark as decorative Oznacz jako dekoracyjny + Mark selected text as: Oznacz zaznaczony tekst jako: + Mesosphere Mezosfera + Month Miesiąc + Move Selected Ink Przenieś zaznaczone pismo odręczne + New Document Nowy dokument + New Label Nowa etykieta + New Label… Nowa etykieta… + New… Nowe… + No accessibility issues found. Nie znaleziono problemów z ułatwieniami dostępu. + No limit Bez limitu + No sources yet. Nie ma jeszcze źródeł. + Norwegian (Bokmål) Norweski (bokmål) + Numbering: Numerowanie: + Numbering… Numerowanie… + Omit Pomiń + One label per recipient Jedna etykieta na adresata + Options… Opcje… + Other Inne + Other styles and their TOC level: Inne style i ich poziom w spisie treści: + Outline levels Poziomy konspektu + Page number style Styl numeru strony + Page numbers at the right margin Numery stron przy prawym marginesie + Page title Tytuł strony + Pages of bookmark: Strony zakładki: + Periodical Periodyk + Portuguese (Brazil) Portugalski (Brazylia) + Portuguese (Portugal) Portugalski (Portugalia) + Position: Położenie: + Publisher Wydawca + Recolor Selected Ink Zmień kolor zaznaczonego pisma odręcznego + Remove Pen Usuń pióro + Return address labels Etykiety adresu zwrotnego + Return address: Adres zwrotny: + Rewind Przewiń do początku + Row: Wiersz: + Run-in W ciągu tekstu + Russian Rosyjski + See Zobacz + Select a picture, shape, chart or table to describe it. Zaznacz obraz, kształt, wykres lub tabelę, aby je opisać. + Separator: Separator: + Serbian (Cyrillic) Serbski (cyrylica) + Serbian (Latin) Serbski (łacinka) + Set As Default Ustaw jako domyślny + Shipping labels Etykiety wysyłkowe + Show every bibliography field Pokaż wszystkie pola bibliografii + Single label Pojedyncza etykieta + Site name Nazwa witryny + Source Manager Menedżer źródeł + Source type: Typ źródła: + Sources in this document: Źródła w tym dokumencie: + Spanish (Mexico) Hiszpański (Meksyk) + Spanish (Spain) Hiszpański (Hiszpania) + Stroke {n} of {total} Pociągnięcie {n} z {total} + Styles: Style: + Subentry: Pozycja podrzędna: + Swedish Szwedzki + TC entry fields Pola pozycji TC + Tab leader: Znaki wiodące tabulatora: + Table of Contents Options Opcje spisu treści + Table of Contents Styles Style spisu treści + Tag Znacznik + This page Ta strona + Tips ({n}) Wskazówki ({n}) + Title Tytuł + Title: Tytuł: + Turkish Turecki + Type: Typ: + URL URL + Ukrainian Ukraiński + View Alt Text… Wyświetl tekst alternatywny… + Volume Tom + Warnings ({n}) Ostrzeżenia ({n}) + Website Witryna internetowa + Year Rok + a, b, c, … a, b, c, … + i, ii, iii, … i, ii, iii, … + {language} is now the document's default language. {language} to teraz domyślny język dokumentu. + {n} entries marked. Oznaczone pozycje: {n}. + 0 or More Characters 0 lub więcej znaków + 1. Formatting restrictions 1. Ograniczenia formatowania + 2. Editing restrictions 2. Ograniczenia edycji + 3. Start enforcement 3. Włączanie ochrony + << Less << Mniej + Above Chart Nad wykresem + Accent Gradient Gradient akcentu + Accent with Reflection Akcent z odbiciem + Accent {n} Akcent {n} + Add Chart Element Dodaj element wykresu + Add Font… Dodaj czcionkę… + Add Row Dodaj wiersz + Add Series Dodaj serię + Add Shape Dodaj kształt + Add\nShape Dodaj\nkształt + Added Fonts Dodane czcionki + All Levels Wszystkie poziomy + All files Wszystkie pliki + All pages Wszystkie strony + All pages in the range Wszystkie strony z zakresu + Allow line breaks Zezwalaj na podziały wierszy + Animate the caret (it glides and fades) Animuj kursor (płynnie się przesuwa i wygasa) + Animation Animacja + Animation effects: Efekty animacji: + Another file Inny plik + Any Character Dowolny znak + Any Digit Dowolna cyfra + Any Letter Dowolna litera + Apply as you type Stosuj podczas pisania + Arch Down Łuk w dół + Arch Up Łuk w górę + Area Warstwowy + As you type Podczas pisania + Asian text font: Czcionka tekstu azjatyckiego: + Attach Template Dołącz szablon + Attach… Dołącz… + AutoCorrect Autokorekta + AutoFormat As You Type Autoformatowanie podczas pisania + AutoSave writes your changes to the file a moment after you stop typing. Autozapis zapisuje zmiany w pliku chwilę po tym, jak przestaniesz pisać. + Automatic bulleted lists Automatyczne listy punktowane + Automatic numbered lists Automatyczne listy numerowane + Automatically Update Document Styles Automatycznie aktualizuj style dokumentu + Automatically update document styles Automatycznie aktualizuj style dokumentu + Basic Cycle Cykl podstawowy + Basic List Lista podstawowa + Basic Matrix Macierz podstawowa + Basic Process Proces podstawowy + Basic Pyramid Piramida podstawowa + Basic Radial Promieniowy podstawowy + Basic Venn Diagram Venna podstawowy + Beginning of Word Początek wyrazu + Blue: Niebieski: + Border lines Linie obramowania + Browse in WordCraft… Przeglądaj w programie WordCraft… + Building Block Gallery Content Control Formant zawartości galerii bloków konstrukcyjnych + Can't be deleted Nie można usunąć + Capitalize first letter of sentences Zaczynaj zdania wielką literą + Capitalize first letter of table cells Zaczynaj komórki tabeli wielką literą + Capitalize names of days Pisz nazwy dni wielką literą + Caret Character Znak daszka + Case changes Zmiany wielkości liter + Categories Kategorie + Category {n} Kategoria {n} + Centimeters Centymetry + Change Chart Type Zmień typ wykresu + Change Colors Zmień kolory + Change Layout Zmień układ + Change\nChart Type Zmień typ\nwykresu + Change\nColors Zmień\nkolory + Character in Range Znak z zakresu + Character level Poziom znaków + Chart Design Projekt wykresu + Chart Layouts Układy wykresu + Chart Title Tytuł wykresu + Check Box Content Control Formant zawartości pola wyboru + Check spelling as you type Sprawdzaj pisownię podczas pisania + Checked styles are allowed: Dozwolone są zaznaczone style: + Checked symbol Symbol zaznaczenia + Chevron Down Szewron w dół + Chevron Up Szewron w górę + Choose List Item Wybierz element listy + Click to switch back to inserting Kliknij, aby wrócić do wstawiania + Clipboard Contents Zawartość schowka + Close Outline View Zamknij widok konspektu + Close Outline\nView Zamknij widok\nkonspektu + Clustered Bar Słupkowy grupowany + Clustered Column Kolumnowy grupowany + Collapse Zwiń + Collate copies Sortuj kopie + Color model: Model kolorów: + Colorful Kolorowy + Colors: Kolory: + Combine Documents Połącz dokumenty + Combo Box Content Control Formant zawartości pola kombi + Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. Komentarze obok każdej strony i śledzone zmiany w plikach PDF, wydrukach i obrazach; lista komentarzy na końcu stron internetowych, plików Markdown i tekstu. + Compare Documents Porównaj dokumenty + Compared: {changes} changes Porównano; zmiany: {changes} + Comparing with another file needs the desktop app. Porównanie z innym plikiem wymaga aplikacji komputerowej. + Comparison settings Ustawienia porównania + Connect Shapes Połącz kształty + Content Control Properties Właściwości formantu zawartości + Content Control at Selection Formant zawartości w zaznaczeniu + Contents can't be edited Nie można edytować zawartości + Controls Formanty + Copies Kopie + Copy Styles Kopiuj style + Create Graphic Utwórz grafikę + Current Bieżący + Current page Bieżąca strona + Curved Arrow Connector Łącznik zakrzywiony ze strzałką + Curved Connector Łącznik zakrzywiony + Custom dictionary Słownik niestandardowy + Custom range Zakres niestandardowy + Cycle Cykl + Dark with Shadow Ciemny z cieniem + Data Labels Etykiety danych + Date Picker Content Control Formant zawartości selektora dat + Date format Format daty + Deflate Wklęsły + Deleted Usunięto + Demote to Body Text Obniż do tekstu podstawowego + Design Mode Tryb projektowania + Detach Template Odłącz szablon + Developer Deweloper + Display text Tekst do wyświetlenia + Document template: Szablon dokumentu: + Documents Dokumenty + Don't capitalize after: Nie zmieniaj na wielką literę po: + Doughnut Pierścieniowy + Drop-Down List Content Control Formant zawartości listy rozwijanej + Edit Data Edytuj dane + Edit Items Edytuj elementy + Edit\nData Edytuj\ndane + Editing in this document is restricted. Edycja tego dokumentu jest ograniczona. + Elbow Arrow Connector Łącznik łamany ze strzałką + Elbow Connector Łącznik łamany + End of Word Koniec wyrazu + Even pages only Tylko strony parzyste + Every change is recorded as a tracked change. Każda zmiana jest zapisywana jako śledzona zmiana. + Everyone Wszyscy + Exceptions (optional) Wyjątki (opcjonalnie) + Exceptions… Wyjątki… + Expand or Collapse Heading Rozwiń lub zwiń nagłówek + Expression Wyrażenie + Extend Selection by Units Rozszerz zaznaczenie o jednostki + Fields Pola + File name: Nazwa pliku: + Filling in forms Wypełnianie formularzy + Find Next Editable Region Znajdź następny obszar do edycji + Find What Expression Szukane wyrażenie + Find What Text Szukany tekst + Fix Position on Page Ustal położenie na stronie + Footnotes and endnotes Przypisy dolne i końcowe + Formatting exceptions Wyjątki formatowania + Formatting marks Znaki formatowania + Formatting, case changes, white space and tables are compared; the other settings are kept for later. Porównywane są formatowanie, wielkość liter, odstępy i tabele; pozostałe ustawienia są zachowywane na później. + Fractions (1/2) as fraction characters (½) Ułamki (1/2) jako znaki ułamków (½) + Graphic Grafika + Green: Zielony: + Headers and footers Nagłówki i stopki + Hex: Szesnastkowo: + Hidden text Tekst ukryty + Hide White Space Ukryj odstęp + Hierarchy Hierarchia + Hollow Pusty + Home folder Folder domowy + Hue: Odcień: + Hyphens (--) as dashes (—) Łączniki (--) jako pauzy (—) + Ignore punctuation characters Ignoruj znaki interpunkcyjne + Ignore web and file addresses Ignoruj adresy internetowe i ścieżki plików + Ignore white-space characters Ignoruj znaki odstępu + Ignore words in capitals Ignoruj wyrazy pisane wielkimi literami + Ignore words with numbers Ignoruj wyrazy zawierające cyfry + In equations, typing a name after a backslash replaces it with its symbol. W równaniach nazwa wpisana po ukośniku odwrotnym jest zastępowana symbolem. + Inches Cale + Inflate Wypukły + Initials: Inicjały: + Insert Chart Wstaw wykres + Insert SmartArt Wstaw grafikę SmartArt + Inside: Wewnątrz: + Interface Interfejs + Interface language Język interfejsu + Internet addresses as links Adresy internetowe jako łącza + Label changes with: Oznaczaj zmiany jako: + Latin text font: Czcionka tekstu łacińskiego: + Layouts Układy + Legend Legenda + Light with Outline Jasny z konturem + Limit formatting to a selection of styles Ogranicz formatowanie do wybranych stylów + List Lista + List entries Elementy listy + Lum: Jaskrawość: + Manual Line Break Ręczny podział wiersza + Manual Page Break Ręczny podział strony + Mark grammar errors as you type Zaznaczaj błędy gramatyczne podczas pisania + Match prefix Uwzględnij prefiks + Match suffix Uwzględnij sufiks + Math AutoCorrect Autokorekta matematyczna + Measurement units: Jednostki miary: + Millimeters Milimetry + Mirror indents Wcięcia lustrzane + More >> Więcej >> + More Colors… Więcej kolorów… + Move Down Przenieś w dół + Move Up Przenieś w górę + Move with Text Przenoś z tekstem + Moves Przeniesienia + New comments and tracked changes are recorded under this name and these initials. Nowe komentarze i śledzone zmiany są zapisywane pod tą nazwą i tymi inicjałami. + New document Nowy dokument + New word Nowy wyraz + No Fill Brak wypełnienia + No Outline Brak konturu + No Reflection Bez odbicia + No Transform Bez przekształcenia + No changes (read only) Bez zmian (tylko do odczytu) + No words yet. Add to Dictionary on a misspelled word puts it here. Nie ma jeszcze wyrazów. Polecenie Dodaj do słownika użyte na błędnym wyrazie umieszcza go tutaj. + Not Nie + Num Occurrences Liczba wystąpień + Odd pages only Tylko strony nieparzyste + Off Wyłączone + On Włączone + On: the caret glides to its new place when you type or use the arrow keys, and its blink fades. Off: it jumps and blinks on and off. Włączone: kursor płynnie przesuwa się na nowe miejsce podczas pisania lub używania strzałek, a jego miganie łagodnie wygasa. Wyłączone: kursor przeskakuje i miga skokowo. + One line per item. Enter adds an item. Jeden wiersz na element. Enter dodaje element. + Only allow this kind of editing: Zezwalaj tylko na ten rodzaj edycji: + Only comments can be added, except in regions marked editable. Można dodawać tylko komentarze, z wyjątkiem obszarów oznaczonych jako edytowalne. + Only form fields can be filled in. Można wypełniać tylko pola formularza. + Only the allowed styles can be applied. Można stosować tylko dozwolone style. + Open a file with WordCraft's own browser when the system file dialog doesn't appear. Otwórz plik we własnej przeglądarce plików programu WordCraft, gdy systemowe okno wyboru pliku się nie pojawia. + Ordinals (1st) as superscript Liczebniki porządkowe (1st) jako indeks górny + Organizer Organizator + Organizer… Organizator… + Original document Dokument oryginalny + Outline State Stan konspektu + Outline Tools Narzędzia konspektu + Outlining Konspekt + Outside: Na zewnątrz: + Overtype Zastępowanie + Overtype (typing replaces the characters after the caret) Zastępowanie (wpisywany tekst zastępuje znaki za kursorem) + Page display Wyświetlanie strony + Pages like 1-3, 5 Strony, np. 1-3, 5 + Paragraph Character Znak akapitu + Paragraph marks Znaczniki akapitu + Personalize Personalizuj + Picas Pajki + Picture Content Control Formant zawartości obrazu + Pie Kołowy + Placeholder text Tekst zastępczy + Plain Text Content Control Formant zawartości zwykłego tekstu + Points Punkty + Previous 1 or More Poprzedni 1 lub więcej razy + Previous Page Poprzednia strona + Print Markup Drukuj adiustację + Print the page color Drukuj kolor strony + Print to PDF Drukuj do pliku PDF + Printing Drukowanie + Process Proces + Promote to Heading 1 Podwyższ do Nagłówka 1 + Proofing language Język sprawdzania + Put the caret in a content control first. Najpierw umieść kursor w formancie zawartości. + Pyramid Piramida + Recent Colors Ostatnio używane kolory + Recent documents to show: Liczba ostatnich dokumentów do pokazania: + Recipient lists Listy adresatów + Red: Czerwony: + Reflection Odbicie + Regular expressions Wyrażenia regularne + Relationship Relacja + Remove Added Font Usuń dodaną czcionkę + Remove Content Control Usuń formant zawartości + Remove row Usuń wiersz + Remove series Usuń serię + Remove the control once edited Usuń formant po edycji + Rename Style Zmień nazwę stylu + Repeating Section Content Control Formant zawartości sekcji powtarzanej + Replace as you type Zamieniaj podczas pisania + Replace text as you type Zamieniaj tekst podczas pisania + Replace: Zamień: + Reset Graphic Resetuj grafikę + Reset\nGraphic Resetuj\ngrafikę + Resize Table by Dragging Zmień rozmiar tabeli przeciąganiem + Revised document Dokument poprawiony + Rich Text Content Control Formant zawartości tekstu sformatowanego + Sat: Nasycenie: + Save documents Zapisywanie dokumentów + Save these pages as PDF… Zapisz te strony jako PDF… + Scatter Punktowy + Search Options Opcje wyszukiwania + Section Break Podział sekcji + Section Character Znak paragrafu + Select Heading and Content Zaznacz nagłówek i zawartość + Select text, then tick who can still edit it. Zaznacz tekst, a następnie zaznacz, kto nadal może go edytować. + Selected pages Wybrane strony + Series name Nazwa serii + Series {n} Seria {n} + Set Date Ustaw datę + Settings… Ustawienia… + Show All Levels Pokaż wszystkie poziomy + Show First Line Only Pokaż tylko pierwszy wiersz + Show Level Pokaż poziom + Show Level 1 Pokaż poziom 1 + Show Level 2 Pokaż poziom 2 + Show Level 3 Pokaż poziom 3 + Show Level 4 Pokaż poziom 4 + Show Level 5 Pokaż poziom 5 + Show Level 6 Pokaż poziom 6 + Show Level 7 Pokaż poziom 7 + Show Level 8 Pokaż poziom 8 + Show Level 9 Pokaż poziom 9 + Show Text Formatting Pokaż formatowanie tekstu + Show changes at: Pokaż zmiany na poziomie: + Show changes in: Pokaż zmiany w: + Show the mini toolbar when text is selected Pokazuj minipasek narzędzi po zaznaczeniu tekstu + Showing the first {count} items. Pokazano pierwsze elementy: {count}. + Shows a tree: each item below the item it belongs to. Pokazuje drzewo: każdy element pod elementem, do którego należy. + Shows items around a central idea: the first item is the centre. Pokazuje elementy wokół głównej idei: pierwszy element jest w środku. + Shows items of equal weight as blocks in rows. Pokazuje równorzędne elementy jako bloki w wierszach. + Shows overlapping ideas as overlapping circles. Pokazuje nakładające się idee jako nakładające się okręgi. + Shows parts of a whole as quadrants. Pokazuje części całości jako ćwiartki. + Shows parts that build on each other, largest at the bottom. Pokazuje części oparte jedna na drugiej, największą na dole. + Shows steps in order, left to right, with arrows between them. Pokazuje kolejne kroki od lewej do prawej, ze strzałkami między nimi. + Shows steps that repeat, around a circle. Pokazuje powtarzające się kroki na okręgu. + Slant Down Nachylenie w dół + Slant Up Nachylenie w górę + Smart quotes instead of straight quotes Cudzysłowy drukarskie zamiast prostych + SmartArt Design Projekt grafiki SmartArt + SmartArt Styles Style grafiki SmartArt + Smooth scrolling and zooming, and menus and buttons that fade in. Off shows every change at once. Płynne przewijanie i powiększanie oraz łagodnie pojawiające się menu i przyciski. Po wyłączeniu każda zmiana jest widoczna od razu. + Soft Glow Łagodna poświata + Solid Accent Jednolity akcent + Spaces Spacje + Special Specjalne + Spelling Pisownia + Spelling and grammar are checked in English (United States). Pisownia i gramatyka są sprawdzane w języku angielskim (Stany Zjednoczone). + Stacked Bar Słupkowy skumulowany + Stacked Column Kolumnowy skumulowany + Standard Standardowe + Start Enforcing Protection Włącz ochronę + Stop Protection Wyłącz ochronę + Straight Arrow Connector Łącznik prosty ze strzałką + Straight Connector Łącznik prosty + Tab Character Znak tabulacji + Tab characters Znaki tabulacji + Templates Szablony + Templates and Add-ins Szablony i dodatki + Text Fill Wypełnienie tekstu + Text Outline Kontur tekstu + Text Pane Okienko tekstu + Text boxes Pola tekstowe + The document is read only, except for regions marked editable. Dokument jest tylko do odczytu, z wyjątkiem obszarów oznaczonych jako edytowalne. + The password is optional; without it anyone can stop the protection. Hasło jest opcjonalne; bez niego każdy może wyłączyć ochronę. + The printer, two-sided printing and pages per sheet are chosen in that dialog. Drukarkę, druk dwustronny i liczbę stron na arkuszu wybiera się w tym oknie. + The system file dialog isn't available, so WordCraft shows its own. To get the system dialog, install a file chooser portal (such as xdg-desktop-portal-gtk) or zenity. Systemowe okno wyboru pliku jest niedostępne, więc WordCraft pokazuje własne. Aby korzystać z okna systemowego, zainstaluj portal wyboru plików (np. xdg-desktop-portal-gtk) lub zenity. + These marks show even when Show/Hide ¶ is off. Te znaki są widoczne nawet przy wyłączonym poleceniu Pokaż wszystko ¶. + This document Ten dokument + This folder can't be read: {error} Nie można odczytać tego folderu: {error} + This password type isn't supported. Ten typ hasła nie jest obsługiwany. + Today Dzisiaj + Toggle Check Box Przełącz pole wyboru + Tracked changes Śledzone zmiany + Transform Przekształć + Triangle Down Trójkąt w dół + Triangle Up Trójkąt w górę + Type Typ + Type the categories down the side and a column of values per series. Wpisz kategorie w pierwszej kolumnie i po jednej kolumnie wartości dla każdej serii. + Type the password to stop the protection. Wpisz hasło, aby wyłączyć ochronę. + Typing replaces selected text Wpisywany tekst zastępuje zaznaczenie + Unchecked symbol Symbol braku zaznaczenia + Up one level Poziom wyżej + Update Styles from Template Aktualizuj style z szablonu + Update fields before printing Aktualizuj pola przed drukowaniem + Use the Insert key to switch overtype on and off Używaj klawisza Insert do przełączania trybu zastępowania + Use wildcards Użyj symboli wieloznacznych + Value Wartość + Visual effects Efekty wizualne + Warm Gradient with Outline Ciepły gradient z konturem + White Space Odstęp + White space Odstępy + With: Na: + Word XML Document (*.xml) Dokument XML programu Word (*.xml) + Word document Dokument programu Word + Word level Poziom wyrazów + WordArt Styles Style WordArt + Your system doesn't report an animation setting, so animation effects stay on. System nie podaje ustawienia animacji, więc efekty animacji pozostają włączone. + Your system has animation effects turned off. Efekty animacji są wyłączone w systemie. + Your system has animation effects turned on. Efekty animacji są włączone w systemie. + {count} of {total} pages will print Zostanie wydrukowanych {count} z {total} stron + {name} already exists. Choose Save again to replace it. Plik {name} już istnieje. Wybierz ponownie Zapisz, aby go zastąpić. + {name} wasn't found. Nie znaleziono: {name}. + “{text}” ({row}, {series}) isn't a number. „{text}” ({row}, {series}) nie jest liczbą. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 88f87099b..5c6a95f3f 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1702,6 +1702,9 @@ Continuous numbering Numeração contínua Apply Aplicar Convert Notes Converter notas + English (United Kingdom) Inglês (Reino Unido) + Polish Polonês + Do Not Check Spelling or Grammar Não verificar ortografia nem gramática # Built-in file browser when the system file dialog is unavailable (#576) Documents Documentos @@ -1992,7 +1995,6 @@ Detect language automatically Detectar o idioma automaticamente Do not check spelling or grammar Não verificar ortografia nem gramática Dutch Holandês - English (United Kingdom) Inglês (Reino Unido) Envelope size: Tamanho do envelope: Estonian Estoniano Finnish Finlandês @@ -2021,7 +2023,6 @@ Numbering: Numeração: Omit Omitir One label per recipient Uma etiqueta por destinatário - Polish Polonês Portuguese (Brazil) Português (Brasil) Portuguese (Portugal) Português (Portugal) Return address labels Etiquetas de remetente diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index c1faccdcd..e8649be82 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2135,6 +2135,9 @@ preview Title Пример Continuous numbering Сквозная нумерация Apply Применить Convert Notes Преобразовать сноски + Do Not Check Spelling or Grammar Не проверять орфографию и грамматику + English (United Kingdom) Английский (Великобритания) + Polish Польский # Built-in file browser (#576). Documents Документы @@ -2326,7 +2329,6 @@ preview Title Пример Detect language automatically Определять язык автоматически Do not check spelling or grammar Не проверять орфографию и грамматику Dutch Нидерландский - English (United Kingdom) Английский (Великобритания) Envelope size: Размер конверта: Estonian Эстонский Finnish Финский @@ -2355,7 +2357,6 @@ preview Title Пример Numbering: Нумерация: Omit Пропустить One label per recipient Одна наклейка на получателя - Polish Польский Portuguese (Brazil) Португальский (Бразилия) Portuguese (Portugal) Португальский (Португалия) Return address labels Наклейки с обратным адресом diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index b5ad3b780..a8f3d37e1 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -1700,6 +1700,9 @@ Continuous numbering Neprekidna numeracija Apply Primeni Convert Notes Pretvori napomene + English (United Kingdom) Engleski (Velika Britanija) + Polish Poljski + Do Not Check Spelling or Grammar Ne proveravaj pravopis i gramatiku # Built-in file browser when the system file dialog is unavailable (#576) Documents Dokumenti @@ -1990,7 +1993,6 @@ Detect language automatically Automatski prepoznaj jezik Do not check spelling or grammar Ne proveravaj pravopis ni gramatiku Dutch Holandski - English (United Kingdom) Engleski (Ujedinjeno Kraljevstvo) Envelope size: Veličina koverte: Estonian Estonski Finnish Finski @@ -2019,7 +2021,6 @@ Numbering: Numerisanje: Omit Izostavi One label per recipient Jedna nalepnica po primaocu - Polish Poljski Portuguese (Brazil) Portugalski (Brazil) Portuguese (Portugal) Portugalski (Portugalija) Return address labels Nalepnice sa adresom pošiljaoca diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 2ff4eb77d..c89176486 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -1704,6 +1704,9 @@ Continuous numbering Непрекидна нумерација Apply Примени Convert Notes Претвори напомене + English (United Kingdom) Енглески (Велика Британија) + Polish Пољски + Do Not Check Spelling or Grammar Не проверавај правопис и граматику # Built-in file browser when the system file dialog is unavailable (#576) Documents Документи @@ -1994,7 +1997,6 @@ Detect language automatically Аутоматски препознај језик Do not check spelling or grammar Не проверавај правопис ни граматику Dutch Холандски - English (United Kingdom) Енглески (Уједињено Краљевство) Envelope size: Величина коверте: Estonian Естонски Finnish Фински @@ -2023,7 +2025,6 @@ Numbering: Нумерисање: Omit Изостави One label per recipient Једна налепница по примаоцу - Polish Пољски Portuguese (Brazil) Португалски (Бразил) Portuguese (Portugal) Португалски (Португалија) Return address labels Налепнице са адресом пошиљаоца diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 831be00ed..d90053290 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -653,6 +653,113 @@ fn bundled_interface_fonts_cover_turkish_without_system_fallbacks() { assert!(!lang("tr").uses_cjk()); } +#[test] +fn polish_locales_and_saved_preference_work_without_changing_the_document() { + let pl = lang("pl"); + for tag in ["pl", "pl-PL", "pl_PL.UTF-8", "PL-pl", "pl_PL.UTF-8@euro", "pl-Latn-PL"] { + assert_eq!(lang_from_tag(tag), Some(pl), "{tag}"); + } + assert_eq!(first_supported(["fr-FR", "pl-PL", "en-US"]), Some(pl)); + let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_engine::sample::sample_document()), Default::default()); + let before = serde_json::to_value(&app.session.doc).unwrap(); + let result = app.run("ui.language", serde_json::json!({"value": "PL"})).unwrap(); + assert_eq!(result["effective"], "pl"); + assert_eq!(app.ui.language, "pl"); + assert_eq!(serde_json::to_value(&app.session.doc).unwrap(), before); + let saved = serde_json::to_string(&app.ui).unwrap(); + let restored: crate::UiState = serde_json::from_str(&saved).unwrap(); + assert_eq!(Lang::from_pref(&restored.language), pl); + assert_eq!(pl.name(), "Polski"); +} + +#[test] +fn polish_covers_every_key_of_every_other_catalog() { + use std::collections::HashSet; + let keys = |source| parse_entries(source).0.into_iter().map(|e| e.source).collect::>(); + let polish = keys(lang("pl").0.source); + // Russian and Italian also translate the built-in style names and the equation gallery, + // which the others don't yet; Polish matches every other catalog. + for other in Lang::all().filter(|l| *l != Lang::EN && !matches!(l.code(), "pl" | "ru" | "it")) { + let mut missing: Vec = keys(other.0.source).difference(&polish).cloned().collect(); + missing.sort(); + assert!(missing.is_empty(), "pl lacks {}'s {missing:?}", other.code()); + } + let pl = lang("pl"); + assert_eq!(tr(pl, "File"), "Plik"); + assert_eq!(tr(pl, "Home"), "Start"); + assert_eq!(tr(pl, "Insert"), "Wstaw"); + assert_eq!(tr(pl, "Review"), "Recenzja"); + assert_eq!(tr(pl, "Save"), "Zapisz"); + assert_eq!(tr(pl, "Spelling & Grammar"), "Pisownia i gramatyka"); + assert_eq!(tr(pl, "Polish"), "Polski"); + assert_eq!(tr(pl, "unknown future label"), "unknown future label"); + assert_eq!(fmt(tr(pl, "Exported {path}"), &[("path", "draft-{words}.docx")]), "Wyeksportowano: draft-{words}.docx"); + assert_eq!(fmt(tr(pl, "Page {page} of {pages}"), &[("page", "3"), ("pages", "9")]), "Strona 3 z 9"); + // Count-neutral wording reads grammatically for every Polish number form (1, 2–4, 5+, 12–14, 22). + for count in [0, 1, 2, 4, 5, 12, 14, 22, 25, 101, 1000] { + let words = count.to_string(); + assert_eq!(fmt(tr(pl, "{words} words"), &[("words", &words)]), format!("Słowa: {count}")); + assert_eq!(fmt(tr(pl, "{selected} of {words} words"), &[("selected", "1"), ("words", &words)]), format!("Słowa: 1 z {count}")); + } + set_current(pl); + assert_eq!(location("Review › Proofing"), "Recenzja › Sprawdzanie"); + assert_eq!(prefixed("Undo", "Bold"), "Cofnij: Pogrubienie"); + set_current(Lang::EN); +} + +#[test] +fn bundled_interface_fonts_cover_polish_without_system_fallbacks() { + let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new()); + let (entries, errors) = parse_entries(lang("pl").0.source); + assert!(errors.is_empty(), "{errors:?}"); + // Polish letters beyond ASCII, both cases, and the Polish quotation marks. + let polish = "ĄąĆćĘꣳŃńÓóŚśŹźŻż„”"; + let chars: std::collections::HashSet = + entries.iter().flat_map(|e| e.translation.chars()).filter(|c| polish.contains(*c)).chain(polish.chars()).collect(); + for (family, style) in [("Inter", "Regular"), ("Inter", "Medium"), ("Inter", "SemiBold"), ("JetBrains Mono", "Regular")] { + let face = db.face(family, style); + assert_eq!(face.family, family, "must use the bundled interface face"); + for ch in &chars { + assert_ne!(face.glyph_for(*ch), 0, "{family} {style} lacks {ch}"); + } + } +} + +#[test] +fn a_polish_interface_writes_new_documents_in_polish() { + let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + let ctx = egui::Context::default(); + let frame = |app: &mut crate::WordApp| ctx.run_ui(egui::RawInput::default(), |ui| app.logic(ui.ctx())).drop_without_applying_deltas(); + frame(&mut app); + assert_eq!(app.session.editing_lang, None, "English interface: WordCraft's default, US English"); + assert_eq!(app.session.doc.styles.default_chr.lang.as_deref(), Some("en-US")); + app.run("ui.language", serde_json::json!({"value": "pl"})).unwrap(); + frame(&mut app); + assert_eq!(app.session.editing_lang.as_deref(), Some("pl-PL")); + assert_eq!(app.session.doc.styles.default_chr.lang.as_deref(), Some("pl-PL"), "the untouched first document follows"); + assert!(!app.session.dirty); + app.run("file.new", serde_json::json!({})).unwrap(); + assert_eq!(app.session.doc.styles.default_chr.lang.as_deref(), Some("pl-PL")); + // Typing Polish then shows no spelling marks; the status bar names the language. + app.session.run("text.insert", &serde_json::json!({"text": "Zażółć gęślą jaźń"})).unwrap(); + assert_eq!(app.session.run("review.issues", &serde_json::json!({})).unwrap(), serde_json::json!([])); + set_current(lang("pl")); + assert_eq!(crate::ribbon::caret_language(&app), "Polski"); + set_current(Lang::EN); + assert_eq!(crate::ribbon::caret_language(&app), "Polish"); + // Back to English: documents already written keep their language. + app.run("ui.language", serde_json::json!({"value": "en"})).unwrap(); + frame(&mut app); + assert_eq!(app.session.editing_lang, None); + assert_eq!(app.session.doc.styles.default_chr.lang.as_deref(), Some("pl-PL")); +} + +#[test] +fn only_polish_has_an_editing_language() { + let with: Vec<(&str, &str)> = Lang::all().filter_map(|l| l.editing_lang().map(|e| (l.code(), e))).collect(); + assert_eq!(with, [("pl", "pl-PL")]); +} + #[test] fn german_locales_and_saved_preference_keep_document_content() { let de = lang("de"); diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 6a13a1755..26f0d4349 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1331,6 +1331,9 @@ Edit Field Code Alan Kodunu Düzenle Edit Field… Alanı Düzenle… Go Back Geri Git + Do Not Check Spelling or Grammar Yazım veya dilbilgisini denetleme + English (United Kingdom) İngilizce (Birleşik Krallık) + Polish Lehçe Insert Date Field Tarih Alanı Ekle Insert Page Field Sayfa Alanı Ekle Insert Time Field Saat Alanı Ekle @@ -1948,7 +1951,6 @@ Detect language automatically Dili otomatik algıla Do not check spelling or grammar Yazım ve dil bilgisini denetleme Dutch Felemenkçe - English (United Kingdom) İngilizce (Birleşik Krallık) Envelope size: Zarf boyutu: Estonian Estonca Finnish Fince @@ -1977,7 +1979,6 @@ Numbering: Numaralandırma: Omit Atla One label per recipient Alıcı başına bir etiket - Polish Lehçe Portuguese (Brazil) Portekizce (Brezilya) Portuguese (Portugal) Portekizce (Portekiz) Return address labels Gönderen adresi etiketleri diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 1d52b614c..c7efa79df 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1702,6 +1702,9 @@ Continuous numbering Наскрізна нумерація Apply Застосувати Convert Notes Перетворити примітки + English (United Kingdom) Англійська (Велика Британія) + Polish Польська + Do Not Check Spelling or Grammar Не перевіряти правопис і граматику # Built-in file browser when the system file dialog is unavailable (#576) Documents Документи @@ -1992,7 +1995,6 @@ Detect language automatically Визначати мову автоматично Do not check spelling or grammar Не перевіряти правопис і граматику Dutch Нідерландська - English (United Kingdom) Англійська (Велика Британія) Envelope size: Розмір конверта: Estonian Естонська Finnish Фінська @@ -2021,7 +2023,6 @@ Numbering: Нумерація: Omit Пропустити One label per recipient Одна етикетка на одержувача - Polish Польська Portuguese (Brazil) Португальська (Бразилія) Portuguese (Portugal) Португальська (Португалія) Return address labels Етикетки зі зворотною адресою diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 903a040a7..c0678a6a4 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1702,6 +1702,9 @@ Continuous numbering 连续编号 Apply 应用 Convert Notes 转换注释 + English (United Kingdom) 英语(英国) + Polish 波兰语 + Do Not Check Spelling or Grammar 不检查拼写和语法 # Built-in file browser when the system file dialog is unavailable (#576) Documents 文档 @@ -1992,7 +1995,6 @@ Detect language automatically 自动检测语言 Do not check spelling or grammar 不检查拼写或语法 Dutch 荷兰语 - English (United Kingdom) 英语(英国) Envelope size: 信封尺寸: Estonian 爱沙尼亚语 Finnish 芬兰语 @@ -2021,7 +2023,6 @@ Numbering: 编号: Omit 省略 One label per recipient 每位收件人一个标签 - Polish 波兰语 Portuguese (Brazil) 葡萄牙语(巴西) Portuguese (Portugal) 葡萄牙语(葡萄牙) Return address labels 回信地址标签 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index c1c784790..aaea49470 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1691,6 +1691,9 @@ Continuous numbering 連續編號 Apply 套用 Convert Notes 轉換附註 + English (United Kingdom) 英文(英國) + Polish 波蘭文 + Do Not Check Spelling or Grammar 不檢查拼字及文法 # Built-in file browser when the system file dialog is unavailable (#576) Documents 文件 @@ -1981,7 +1984,6 @@ Detect language automatically 自動偵測語言 Do not check spelling or grammar 不要檢查拼字或文法 Dutch 荷蘭文 - English (United Kingdom) 英文(英國) Envelope size: 信封大小: Estonian 愛沙尼亞文 Finnish 芬蘭文 @@ -2010,7 +2012,6 @@ Numbering: 編號: Omit 省略 One label per recipient 每位收件人一個標籤 - Polish 波蘭文 Portuguese (Brazil) 葡萄牙文(巴西) Portuguese (Portugal) 葡萄牙文(葡萄牙) Return address labels 寄件人地址標籤 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a890f15b1..472235b3b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -313,6 +313,8 @@ pub struct WordApp { fonts_frames: u32, /// The interface theme setting last installed in egui ([`theme::apply`]). applied_theme: Option, + /// The interface language whose editing language the session last got ([`i18n::Lang::editing_lang`]). + applied_editing: Option, /// What the system last said about its animation effects ([`Services::animations_enabled`]). system_animations: Option, /// Whether egui's own animations were last switched on or off ([`theme::apply_animations`]). @@ -399,6 +401,7 @@ impl WordApp { want_system_cjk: false, fonts_frames: 0, applied_theme: None, + applied_editing: None, system_animations: None, applied_animations: None, frame_ms: 0.0, @@ -763,7 +766,9 @@ impl WordApp { self.ui.recent.truncate(MAX_RECENT); } if !self.session.status.is_empty() { + // Engine messages are English; the catalog translates the ones it knows. let s = std::mem::take(&mut self.session.status); + let s = tl!(&s).to_string(); self.status(s); } } @@ -1117,6 +1122,12 @@ impl WordApp { pub fn logic(&mut self, ctx: &egui::Context) { let lang = i18n::Lang::from_pref(&self.ui.language); i18n::set_current(lang); + // New documents are written in the interface's language when WordCraft can proof it + // (a Polish interface: Polish spelling, grammar and hyphenation from the first word). + if self.applied_editing != Some(lang) { + self.session.set_editing_language(lang.editing_lang()); + self.applied_editing = Some(lang); + } options::sync(self); // Chinese text wants the Chinese face before the Japanese one (one glyph style per line). // An installed CJK font is read only once CJK text is shown (#241): never for an English diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 374ebf914..b374376bd 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -342,6 +342,46 @@ fn mi_text(ui: &mut Ui, app: &mut WordApp, text: &str, id: &str, params: Value) } } +/// The proofing languages the Language menu offers: tag and English name (translated when drawn). +pub(crate) const PROOFING_LANGUAGES: [(&str, &str); 3] = + [("en-US", "English (United States)"), ("en-GB", "English (United Kingdom)"), ("pl-PL", "Polish")]; + +/// The English name of a proofing language tag, as the status bar shows it (no tag: WordCraft's +/// default, US English; a tag without a name here: the tag). +pub(crate) fn language_name(tag: Option<&str>) -> String { + let tag = tag.unwrap_or("en-US"); + if let Some((_, name)) = PROOFING_LANGUAGES.iter().find(|(t, _)| t.eq_ignore_ascii_case(tag)) { + return (*name).to_string(); + } + match wordcraft_engine::proof::ProofLang::from_tag(Some(tag)) { + wordcraft_engine::proof::ProofLang::Pl => "Polish".into(), + wordcraft_engine::proof::ProofLang::En if tag.eq_ignore_ascii_case("en") => "English (United States)".into(), + _ => tag.to_string(), + } +} + +/// The language at the caret (resolved through the styles), translated, for the status bar. +pub(crate) fn caret_language(app: &WordApp) -> String { + let s = &app.session; + let style = s.doc.para_at(&s.sel.focus).and_then(|p| p.props.style.clone()); + let rc = s.doc.styles.resolve_char(style.as_deref(), &s.typing_props()); + tl!(&language_name(rc.lang.as_deref())).to_string() +} + +/// Review › Language (and the status bar's language): set the selection's proofing language, or +/// leave it unchecked. The current choice has a check mark. +pub(crate) fn language_menu(ui: &mut Ui, app: &mut WordApp) { + let st = app.session.run("format.state", &json!({})).unwrap_or_default(); + let current = st.get("lang").and_then(Value::as_str).unwrap_or("").to_string(); + let no_proof = st.get("noProof").and_then(Value::as_bool).unwrap_or(false); + for (tag, name) in PROOFING_LANGUAGES { + let on = current.eq_ignore_ascii_case(tag) || (tag == "pl-PL" && current.eq_ignore_ascii_case("pl")); + mi_check(ui, app, name, on, "review.language", json!({"lang": tag})); + } + ui.separator(); + mi_check(ui, app, "Do Not Check Spelling or Grammar", no_proof, "review.language", json!({"noProof": !no_proof})); +} + /// A menu item with a check mark when `checked` (the current choice of several). fn mi_check(ui: &mut Ui, app: &mut WordApp, label: &str, checked: bool, id: &str, params: Value) { // An invisible mark keeps the unchecked labels aligned with the checked one. @@ -1387,7 +1427,7 @@ fn review(app: &mut WordApp, ui: &mut Ui) { }); group(ui, "Language", None, app, |ui, app| { big(ui, app, "translate", "Translate", "review.translate", json!({}), false); - big(ui, app, "language", "Language", "review.language", json!({}), false); + menu_button(ui, app, "language", Some("Language"), "Language", true, language_menu); }); group(ui, "Comments", None, app, |ui, app| { big(ui, app, "newComment", "New\nComment", "review.newComment", json!({}), false); diff --git a/docs/architecture.md b/docs/architecture.md index 6f1ef6ebd..f6f9e76ca 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # WordCraft architecture -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first committed version; `plan/architecture.md` is local-only) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (`proof` is per language: English and Polish; previously major: first committed version; `plan/architecture.md` is local-only) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 How WordCraft is built today (origin/main `40c5d51`). The longer design notes in `plan/architecture.md` are gitignored and local to the original author's machine; this file is the @@ -17,7 +17,7 @@ winit or rfd. | L0 | `geom` | 0.2k | Units (points, twips, EMU, inches, cm), rectangles, measurement parsing | | L1 | `doc` | 10.0k | Document model: stories, paragraphs, runs, tables, sections, styles, numbering, fields, notes, comments, revisions, equations (OMML-shaped math tree, linear/LaTeX input); edit primitives; bidi | | L1 | `fonts` | 2.3k | Font database (bundled OFL + system fonts), metrics, outlines, shaping with HarfRust, Word font substitution (`word.rs`: Carlito ↔ Calibri, Caladea ↔ Cambria, Liberation ↔ Arial/Times/Courier), OpenType MATH | -| L1 | `proof` | 1.4k | Spelling (public-domain English word list), suggestions, rule-based grammar, hyphenation patterns | +| L1 | `proof` | 3.8k | Proofing per language (`ProofLang` from a run's `w:lang`): English (public-domain word list) and Polish (installed Hunspell dictionary through a Hunspell-style affix engine) spelling and suggestions, rule-based grammar for both, hyphenation dictionary and Liang patterns (English, Polish) | | L2 | `layout` | 9.6k | Shaping runs, line breaking, tabs, lists, tables (row splitting, floating tables), pagination, headers/footers, notes, floating objects and wrap, math layout, fields, hit testing; produces a display list | | L2 | `docx` | 8.4k | OOXML WordprocessingML reader and writer (Transitional and Strict namespaces, `.docx/.docm/.dotx/.dotm`), OMML, custom properties, VBA parts preserved | | L2 | `docbin` | 4.4k | Word 97-2003 `.doc` reader ([MS-DOC]: FIB, piece table, FKPs, SPRMs, stylesheet, lists, tables, notes, pictures) over a CFB container | @@ -64,4 +64,5 @@ winit or rfd. | Date | Change | Summary | |---|---|---| +| 2026-10-10 | minor | `proof`: proofing languages (English, Polish), affix engine, Polish patterns; layout and engine route each run by its language | | 2026-10-10 | major | First committed architecture doc, from the crates' sources and `AGENTS.md` | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 8de723d17..cb261352d 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -50,7 +50,7 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key. | Method | Params | Result | |---|---|---| -| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog`, `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"|"en"|"zh-hans"|"zh-hant"|"ja"|"pt-br"|"es"|"uk"|"sr"|"sr-latn"|"et"|"tr"|"ru"|"de"|"it"}`), `ui.theme` (`{"value": "system"|"light"|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"percent": 80–200}`, clamped; no value reads it; scales the interface on top of the system display scale, document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"|"on"|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"|"picture"|"recipients"|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.caretAnimation` (`{"value": true|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading)…) | +| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog`, `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"|"en"|"zh-hans"|"zh-hant"|"ja"|"pt-br"|"es"|"uk"|"sr"|"sr-latn"|"et"|"tr"|"ru"|"pl"|"de"|"it"}`), `ui.theme` (`{"value": "system"|"light"|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"percent": 80–200}`, clamped; no value reads it; scales the interface on top of the system display scale, document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"|"on"|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"|"picture"|"recipients"|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.caretAnimation` (`{"value": true|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading)…) | | `engine.commands` | — | every command: id, label, location, shortcut, params, enabled | | `document.inspect` | `text?` | blocks (text, style, runs, lists, tables), parts, sections, selection, pages | | `ui.inspect` | — | UI state, view, dialog, window size, page rects on screen, caret, comment balloons (`balloons`: id + screen rect; `balloon`: the selected one), perf | diff --git a/docs/gaps.md b/docs/gaps.md index 3ece41d9b..e7acc2319 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (.doc pictures and drawings read, #551; previously trivial: #24 narrowed: Customize Keyboard and Customize Ribbon landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Polish interface and Polish proofing; previously trivial: .doc pictures and drawings read, #551; previously trivial: #24 narrowed: Customize Keyboard and Customize Ribbon landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 The work list. One entry per shortfall, ranked by how much it stops a Word user from switching (**B** = blocks beta). Each says what's missing, the evidence, who it hurts, an Opus 5.5 agent @@ -20,7 +20,7 @@ Native save reliability: synthetic cases on macOS reproduced loss through the pr | 5 | **Content controls** landed (#381): `w:sdt` kept, shown and edited (Developer tab, Design Mode, Properties). 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. Opaque custom XML backing stores now survive saves; binding evaluation remains absent. | 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); File › Print has a preview, page range, odd/even, copies and collate (#641); printer choice, duplex and pages per sheet are still the system's; 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**: 54 modal dialogs vs Word's ~100 | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs` | Power users reach for dialogs the ribbon alone doesn't cover | 11–19 | [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) | +| 8 | **Proofing covers English and Polish only**; no dictionaries, grammar or hyphenation for the other ~48 languages Word proofs (text in them is left unmarked) | | `crates/proof`: English and Polish, a language per run (2026-10-10); Word ships 120 proofing tools; issues #25, #40, #100 | Every writer outside English and Polish | 20–40 | [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 3-D effects and connector routing around shapes (WordArt effects and warps and glued connectors landed, #416), 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**: 7 of the 12 key languages missing (hi, ar, fr, id, ko, vi, pt-PT); no mirrored RTL interface; ~5% of strings still English in the translated catalogs | | [localization-parity.md](localization-parity.md) | Most of the world's writers | 50–80 | [localization-parity.md](localization-parity.md) | @@ -118,6 +118,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | 2026-10-10 | trivial | #10: Drawing Canvas landed (#344); in-canvas dragging of members remains | | 2026-10-10 | trivial | #29: long display equations wrap across lines (#326) | | 2026-10-10 | trivial | #3: Windows lag on GPUs without a DirectX 12 driver fixed with an OpenGL fallback (#316) | +| 2026-10-10 | minor | #8: Polish proofing (spelling, grammar, hyphenation) and a proofing language per run landed; the gap is now the other languages (20–40 h) | | 2026-10-10 | trivial | #6: desktop File › Print opens the system PDF viewer (#286) | | 2026-10-10 | trivial | Table Properties dialog landed (#44); removed from the dialog-depth list | | 2026-10-10 | trivial | Merged main: landed features removed from the feature-gap list | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 9c80fde6c..0b06f2028 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,12 +1,13 @@ # Localization parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (German interface catalog, #574; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Interface languages, script support and proofing per language. How translation works (catalog format, adding a language, clean-room rule): `crates/ui-egui/src/i18n/mod.rs`. -**Dimension: ~25% (estimated), 80–130 h to full for the twelve key languages** plus ~50 h for the -other ~18 languages Word ships, plus native-speaker review (human). +**Dimension: ~27% (estimated; 25% before Polish and per-run proofing languages), 75–120 h to full +for the twelve key languages** plus ~45 h for the other ~17 languages Word ships, plus +native-speaker review (human). ## What Word ships (measured by listing the bundle) @@ -18,16 +19,20 @@ other ~18 languages Word ships, plus native-speaker review (human). ## What WordCraft ships (measured) -- **14 interface languages**: English plus `zh-hans`, `zh-hant`, `ja`, `uk`, `es`, `pt-br`, - Serbian `sr`/`sr-latn` (#250), Estonian `et`, Turkish `tr`, Russian `ru`, Italian `it` (#594) and German `de` (#574). Catalogs have 2,021–2,399 entries - each. A test (`every_tab_and_command_is_translated`) enforces that every ribbon tab, command +- **15 interface languages**: English plus `zh-hans`, `zh-hant`, `ja`, `uk`, `es`, `pt-br`, + Serbian `sr`/`sr-latn` (#250), Estonian `et`, Turkish `tr`, Russian `ru`, Italian `it` (#594), German `de` (#574) and Polish `pl`. Catalogs have 2,021–2,399 entries + each; Polish has every key of every other catalog (a test checks it). A test (`every_tab_and_command_is_translated`) enforces that every ribbon tab, command label and ribbon location is translated in every language. + `pl`, `pl-PL` and `pl_PL.UTF-8` system locales pick Polish automatically. - Of the 675 English strings a scan extracts from `tl!(…)` calls and command labels/locations, each catalog covers **661 (98%)**. Some newer dialog and pane strings (Equation tab tooltips, Zotero, Read Aloud, Backstage greetings) aren't wrapped in `tl!` yet, so they show in English in every language: the true share of visible strings is **~93–95%** (estimated). - Pending PRs: Norwegian Bokmål (#208) and Nynorsk (#210), Hebrew with RTL (#20). -- **Proofing: English only** (one dictionary, rule-based grammar, English hyphenation patterns). +- **Proofing: English and Polish**, chosen per run from its language (`w:lang`, resolved through + the styles; Review › Language and the status bar set it). Languages without proofing tools get + no spelling or grammar marks (as in Word without the language's tools) instead of being flagged + as English. Detail in [Proofing per language](#proofing-per-language). - Document scripts: shaping via HarfRust (HarfBuzz port) for every script; bidi (UAX #9) and Arabic shaping tested (#207); IME on all desktop platforms (winit fixes pending, #155–#164). @@ -52,8 +57,8 @@ shared items below. | Korean | ko | 0 | ❌ | Hangul ✅; macOS Korean IME fixes pending (#163, #164) | no | none | 3–5 | | Vietnamese | vi | 0 | ❌ | Latin with stacked diacritics, untested | no | none | 3–4 | -Other shipped languages: **8** — Traditional Chinese (`zh-hant`), Ukrainian (`uk`), Serbian -Cyrillic and Latin (`sr`, `sr-latn`, #250), Estonian (`et`), Turkish (`tr`), Russian (`ru`) and Italian (`it`, #594), all partial. +Other shipped languages: **9** — Traditional Chinese (`zh-hant`), Ukrainian (`uk`), Serbian +Cyrillic and Latin (`sr`, `sr-latn`, #250), Estonian (`et`), Turkish (`tr`), Russian (`ru`), Italian (`it`, #594) and Polish (`pl`), all partial. Estonian uses the existing fonts, follows `et-EE` system locales, and is available in Options or through `ui.language` with `{"value":"et"}`. Its 1,307 entries cover the existing interface catalog. Estonian spelling, grammar, dates and document templates are not added by the interface catalog. @@ -62,12 +67,27 @@ entries. Command search folds the Turkish dotted and dotless I (`italik` finds finds Yazı Tipi) while English labels and ASCII command ids keep their usual matching. Turkish proofing is not included; terminology and checks: [`localization-tr.md`](localization-tr.md). +Polish is the only one besides English with proofing tools, and a Polish interface writes new +documents in Polish (`pl-PL`). + +## Proofing per language + +| Language | Spelling | Suggestions | Grammar | Hyphenation | +|---|---|---|---|---| +| English (`en`, any region; text with no language) | Moby word list (public domain, ~160k words) with inflection rules | edit distance | repeated word, `a`/`an`, sentence capital, space before punctuation, double space | Moby dictionary, our Liang patterns, heuristic | +| Polish (`pl`) | The Hunspell `pl_PL` dictionary installed on the system (OS package, Homebrew, LibreOffice, or the `WORDCRAFT_DICTIONARIES` folder; not bundled, so no Polish spelling marks without it), read at runtime by a Hunspell-style affix engine (`crates/proof/src/affix.rs`); names capitalized, compounds part by part, `Kennedy'ego` | missing diacritics first (`zolw` → `żółw`), the dictionary's confusion table (`żeka` → `rzeka`, `chuśtawka` → `huśtawka`), missing space (`napewno` → `na pewno`), one typo, then a stem scan within two typos | Polish messages: repeated word, space before punctuation, double space, sentence capital (knows `np.`, `r.`, initials, ordinals, dialogue dashes), missing comma before `że`/`który`/`ponieważ`/`gdyż`/`aby`/`żeby`/`jeśli`/`jeżeli`/`gdyby`/`lecz` (not after `mimo`, `chyba`, `tak`, prepositions…), `we`/`ze` (`we wtorek`, `ze szkoły`), doubled punctuation | TeX Polish patterns (Kołodziejska, Jackowski, Ryćko; MIT option) with their 20 exception words; a Polish syllable heuristic for letters outside the alphabet | +| Any other | — (no marks) | — | — | the English patterns, as before | + +Measured with the SJP.PL dictionary: every one of the ~4.5 million forms an independent expansion of its affix file +produces is accepted, and 200,000 one-edit mutations that aren't words are all rejected; a check +takes ~1 µs, a suggestion list 1–35 ms (release build). Not yet: other languages' dictionaries, +a thesaurus for Polish, Word's style and clarity refinements, Polish-specific AutoCorrect. + Italian (`it`, #594) follows `it`, `it-IT` and `it-CH` system locales. Its catalog has every key of the others plus 51 strings that are wrapped in `tl!` but were in no catalog yet (File › Info properties, the File › Home greeting, list levels, Equation tab gallery and tooltips). Ribbon tabs, groups and command names use the names Italian Word users know (feature names, as `AGENTS.md` allows); all other text is written from the meaning of the English. No Italian proofing yet. - ## Shared work (not in the per-language hours) | Item | Hours | Notes | @@ -75,13 +95,14 @@ allows); all other text is written from the meaning of the English. No Italian p | Wrap the remaining English literals in `tl!` and translate them in every catalog | 4–6 | Makes the six partial languages full except script items | | Mirrored (right-to-left) interface for Arabic and Hebrew | 15–25 | Ribbon, panes, dialogs, rulers | | Vertical text, ruby, document grid (Chinese, Japanese, Korean) | counted in [layout-parity.md](layout-parity.md) | | -| Proofing dictionaries, grammar and hyphenation for the 11 non-English key languages | 25–45 | Open dictionaries only (licence check per dictionary); issues #25, #40, #100 | +| Proofing dictionaries, grammar and hyphenation for the 11 non-English key languages | 20–40 | Open dictionaries only (licence check per dictionary); issues #25, #40, #100. Per-run languages, the Hunspell-style affix engine and the TeX pattern reader landed with Polish, so a language with an open Hunspell dictionary and hyphenation patterns is mostly data plus grammar rules | | Native-speaker review of every catalog | human | None reviewed yet | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Polish interface (13 languages) and Polish proofing: SJP.PL spelling with suggestions, Polish grammar rules, TeX Polish hyphenation; proofing language per run; dimension ~25% → ~27% | | 2026-10-11 | minor | German interface catalog (#574): 2,021 entries covering the full zh-hans key set; locale, saved-preference and bundled-font tests; no German proofing | | 2026-10-11 | minor | Italian interface catalog (#594), locale selection, saved preference and font coverage checks; count labels put the noun first so they read right at 1 | | 2026-10-11 | minor | Font dialog sets the Asian text font (`w:eastAsia`) and the Latin text font separately; the Asian list holds the installed Chinese, Japanese and Korean fonts by their interface-language names (#505) | diff --git a/docs/roadmap.md b/docs/roadmap.md index afd2a2e18..f753b35f6 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 (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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Polish interface and Polish proofing; 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 @@ -60,7 +60,7 @@ partial rows are fidelity gaps (beta work), not broken workflows. WordCraft stay | 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) | — | -| M15 | Localization (12 key languages, RTL interface, proofing languages) | 11 UI languages, English proofing | 80–130 h | +| M15 | Localization (12 key languages, RTL interface, proofing languages) | 15 UI languages; English and Polish proofing, a proofing language per run | 75–120 h | | M16 | Right-to-left and East Asian typography | RTL paragraphs done (#207) | 30–45 h | ## Next after beta @@ -72,6 +72,7 @@ languages, screen-reader access, ecosystem decisions (VBA, add-ins, cloud), an i | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | M15: Polish interface and Polish proofing landed | | 2026-10-11 | trivial | M3: icons landed (#309) | | 2026-10-11 | trivial | M11: contour (Tight/Through) wrap done (#354) | | 2026-10-11 | trivial | M6: the eight missing bibliography styles landed (#383) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d9fc10677..79c94d695 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # WordCraft parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (table borders and the resize handle drag on the page, #217; previously trivial: all 12 bibliography styles, #383; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; 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; replaces the parity tables that lived in ROADMAP.md) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Polish interface and Polish proofing; previously trivial: English spelling knows common modern words, #520; previously trivial: all 12 bibliography styles, #383; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; 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; replaces the parity tables that lived in ROADMAP.md) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 The authoritative assessment of how close WordCraft is to Microsoft Word. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) itemizes every shortfall; the deep checklists are @@ -66,7 +66,7 @@ full parity for the area. | 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. 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 / Combine with comparison settings (case, white space, formatting, tables, word or character level, #412), Restrict Editing pane with formatting and editing restrictions, exceptions for everyone and a password (#412). 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. Show revisions in balloons takes tracked deletions in the body out of the text into "Deleted: …" balloons (insertions stay inline, as in Word; deletions in headers, footers, notes and text boxes, and in print and PDF, stay inline). 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. | +| Proofing (spelling, grammar, thesaurus, languages) | 5 | 40% | (in Review) | 20–40 | English and Polish, chosen per run from its language (`w:lang`): English word list (Moby plus our supplement of modern words such as "desktop", #520) and rule-based grammar; Polish against the installed Hunspell dictionary through a Hunspell-style affix engine, Polish grammar rules and TeX hyphenation patterns (2026-10-10). Other languages are left unmarked. 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 or CSV (#247); no Excel/Outlook/Contacts 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), WordArt: text fill (solid, gradient, none), outline, shadow, glow and reflection with our own styles, and Transform warps along arch, circle, wave, slant, inflate/deflate, chevron and triangle (other presets kept and drawn flat), connectors (straight, elbow, curved, arrowheads) glued to shapes' connection sites and re-routed when those move (#416). Missing: tight/through contour wrap, rotated text-box text, group (#267 open), shape effects, WordArt 3-D/bevel and editing text in its bent form, connector routing around shapes, Drawing Canvas, Icons, online pictures, screenshot. | | 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. | @@ -195,7 +195,7 @@ Word. Their weighted sum is the **full ready for real work** number. | UI/UX fidelity | 10% | 65% | 65–100 | [ui-parity.md](ui-parity.md) | Ribbon, keytips, mini-toolbar, Backstage, 125 shortcuts; 16 modal dialogs against Word's ~100; no ribbon/keyboard customization | | Stability | 10% | 50% | 25–45 | [gaps.md](gaps.md) | Never-crash standard and hostile-param fuzzing in place, but open reports of a startup crash on Intel UHD (#170), freezes (#77, #59, #31) and Windows write failure without admin (#218); no soak test; 5 days of field history | | Performance | 5% | 75% | 10–20 | [layout-parity.md](layout-parity.md) | 61 ms cold layout and 1.4 ms relayout on a 188-page document (measured 2026-10-06, not re-run); font picker freezes fixed in #233; no large real-world documents measured | -| Localization | 4% | 25% | 80–130 + native review | [localization-parity.md](localization-parity.md) | 9 UI languages (Word 30); 4 of the 12 key languages partly done; English-only proofing | +| Localization | 4% | 27% | 75–120 + native review | [localization-parity.md](localization-parity.md) | 13 UI languages (Word 30); 4 of the 12 key languages partly done; English and Polish proofing | | Platforms | 3% | 95% | 5–10 | [ROADMAP.md](../ROADMAP.md) | macOS (universal, signed), Windows x64/x86/arm64, Linux (AppImage/deb/rpm/Flatpak), FreeBSD, web. Beyond Word on Linux, BSD and the web; Word's iPad/iPhone/Android apps are out of scope | | Hardware | 1% | 40% | 20–40 | [hardware-parity.md](hardware-parity.md) | No native printing on desktop, no pen/ink, no dictation; GPU drawing and HiDPI fine. Ink hours are in the Features row (Draw tab) | | Ecosystem and plugins | 2% | 15% | 40–80 | [gaps.md](gaps.md) | Zotero built in, macros record/play commands, `.docm` macros preserved (#172). No VBA execution, no Office add-ins, no EndNote/Mendeley plugins, no cloud storage or co-authoring | @@ -251,6 +251,7 @@ can be used without committing Word output. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Polish interface (13 UI languages) and Polish proofing with per-run languages: Proofing 35% → 40% (25–45 h → 20–40 h), Localization 25% → 27%; feature depth and readiness unchanged at this precision | | 2026-10-11 | trivial | Tables: column and row borders and the resize handle drag on the page (#217); Tables row and interaction-fidelity evidence updated, no percentage change | | 2026-10-11 | trivial | Review: Show revisions in balloons takes tracked deletions in the body out of the text into "Deleted: …" balloons (it drew like the comments-and-formatting mode before); Review row updated, no percentage change | | 2026-10-11 | trivial | Insert › SmartArt (#497): charts/SmartArt row updated; catalog 429/451 → 437/458 (95.4%); no percentage change | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 7125195f3..59450e7c3 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 borders and the resize handle drag on the page, #217; 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:** minor (status bar language follows the caret and sets the proofing language; Review › Language is a menu; 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 @@ -29,8 +29,8 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | Selection: word/sentence/paragraph clicks, dragging after a double-click/triple-click/Ctrl+click extends by whole words/paragraphs/sentences (#549), Shift extend, F8 extend mode, column (Alt+drag, #237) | ✅ | ✅ | 90% | 1 | | Zoom: Ctrl+scroll, pinch (#179), zoom buttons (#232), pages side by side when zoomed out (#251) | ✅ | ✅ | 85% | — | | Touchpad smooth/inertial scroll | ✅ (#252) | ✅ | 90% | — | +| Status bar (page x of y, words, language, view buttons, zoom slider) | ✅ (the language is the caret's; a click sets the proofing language) | ✅ | 85% | — | | Caret motion: keys and typing glide the caret to its new place (100 ms, `cubic-bezier(0, 0, 0, 1)`), the blink fades (530 ms on and off, 100 ms fades); clicks and scripted moves don't glide. File › Options › Advanced › Animation (`ui.caretAnimation`) turns it off | ✅ as measured from Word for Windows (`caret_glide.rs`); zoom levels other than 100%, modifier keys and off-screen moves not measured | ✅ on Windows; Word for Mac not measured | 90% | 1–2 | -| Status bar (page x of y, words, language, view buttons, zoom slider) | ✅ | ✅ | 80% | — | | Interface themes (light, dark, system #249, follows OS changes live #311), dark page separate from the interface theme (#194, #312) | ✅ | ✅ | 90% | — | | Interface size (File › Options › General, 80–200 %, on top of the system display scale; `ui.interfaceSize`) (#475) | ✅ | system display scaling only | 100% | — | | Animation effects (File › Options › Accessibility: Use system setting, On, Off; `ui.animations`). Off: a wheel notch scrolls at once; Ctrl+wheel zooms in whole steps, each drawn finished in its own frame (a notch at once; touchpad pinches and high-resolution wheels in steps of about 10% rather than gliding); menus, buttons and the scroll to the caret neither fade nor glide | ✅ "Use system setting" reads Windows (Animation effects), macOS (Reduce motion), the Linux/BSD desktops (settings portal: the cross-desktop reduced-motion preference, GNOME's switch, KDE's animation speed; the GTK and KDE settings files where no portal answers) and browsers (`prefers-reduced-motion`), and follows a change while running. Run on Windows and against a stand-in portal on Linux; not yet tried on a Mac or a real GNOME or KDE session | not compared | 90% | 0.5–1 | @@ -87,6 +87,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | 2026-10-10 | trivial | System interface theme keeps following OS appearance changes (no longer pins the macOS window); Dark page no longer darkens the interface (#311, #312) | | 2026-10-11 | trivial | Insert › Icons picker: search, category chips, icons drawn live from the library's path data; Picture Format › Icon Color (#309) | | 2026-10-10 | trivial | AutoSave switch greyed out with a tooltip saying why where AutoSave can't save; Save As from it (#196, #176) | +| 2026-10-10 | minor | Status bar language: the caret's language, a click opens the proofing-language menu (also Review › Language); context menu items translated | | 2026-10-10 | trivial | Table Properties dialog and Table Layout Height/Width boxes (#44) | | 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 | From 199ac88a2e9a3cff760ea491545c0f59ebc882b6 Mon Sep 17 00:00:00 2001 From: DanielKnourek <48413610+DanielKnourek@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:09:20 +0200 Subject: [PATCH 07/88] i18n: add Czech (cs) interface translation (#387) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * i18n: add Czech (cs) interface translation Translate the 1,246-entry interface catalog clean-room from the English source labels (cross-referenced with PdfCraft, VectorCraft, and PhotoCraft Czech catalogs) and register Czech (Čeština) for system locale detection and persisted selection. Add tests for Czech locale resolution, full catalog key parity, count-neutral word count formatting, and bundled UI font glyph coverage for Czech diacritics. # Conflicts: # ROADMAP.md * Czech: translate interface strings added on main since this PR Adds 221 Czech entries so cs.tsv again has the full zh-hans key set (2,021 entries) and drops the stale "Wildcards (regex)" row; updates the documented entry count. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ATTRIBUTION.md | 1 + README.md | 2 +- ROADMAP.md | 6 +- contributors/people.toml | 5 + crates/ui-egui/src/i18n/cs.tsv | 2043 ++++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/mod.rs | 3 +- crates/ui-egui/src/i18n/tests.rs | 59 + docs/control-protocol.md | 2 +- docs/localization-parity.md | 9 +- 9 files changed, 2121 insertions(+), 9 deletions(-) create mode 100644 crates/ui-egui/src/i18n/cs.tsv diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 0bffecdad..b218db017 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -66,6 +66,7 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra | `crates/ui-egui/src/i18n/et.tsv` | @henno with Codex | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Estonian interface catalog | | `crates/ui-egui/src/i18n/pl.tsv` | Konrad Witkowski with Claude (AI agent) | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Polish interface catalog; not yet reviewed by a second native speaker | | `crates/ui-egui/src/i18n/pt-br.tsv` | @edurbs with ZCode | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Brazilian Portuguese interface catalog; spelling checked with the external pt_BR Hunspell dictionary, not bundled | +| `crates/ui-egui/src/i18n/cs.tsv` | @DanielKnourek with Antigravity for VS Code | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Czech interface catalog; review suggestions by @Losminek | | `crates/ui-egui/src/i18n/de.tsv` | @arekown with Kimi | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | German interface catalog | | `crates/ui-egui/src/i18n/ru.tsv` | Dmitrii Khlapov with Claude | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Russian interface catalog; reviewed by a native speaker | | `crates/ui-egui/src/i18n/tr.tsv` | @PbyLoyth with Codex and Claude Code | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Turkish interface catalog | diff --git a/README.md b/README.md index 4e6f871f6..3df7194f8 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ Every screenshot below is WordCraft itself, rendered offscreen by its own UI tes | **References** | Table of contents, footnotes and endnotes, citations and bibliography (APA, MLA, Chicago, IEEE), captions, table of figures, cross-references, index, table of authorities | | **Review** | Spelling and grammar with suggestions (English and Polish, by each run's language), thesaurus, word count, comments in margin balloons or a pane, track changes, accept/reject, compare documents, restrict editing, accessibility checker, document inspector | | **Mailings** | Mail merge from CSV, merge fields, address block, greeting line, rules, preview, finish to a document; envelopes and labels | -| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский, Italiano, Deutsch or Polski (follows the system language by default) | +| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский, Italiano, Deutsch, Polski or Čeština (follows the system language by default) | | **Files** | .docx read/write (opens in Word), Word 97-2003 .doc import, PDF export, .odt, .rtf, .html, .md, .tex, .txt import/export, page images | The honest picture, area by area, is in [ROADMAP.md](ROADMAP.md), the diff --git a/ROADMAP.md b/ROADMAP.md index 90a94484b..da80ffe8d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -110,8 +110,8 @@ 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`), Serbian (`sr`, `sr-latn`, #250), Russian (`ru`) and Polish (`pl`), all partial (~95%). Estonian (`et`), Turkish (`tr`) and Italian (`it`, #594) now have complete 1,307-entry interface catalogs with -system-locale selection and a saved language choice (Italian also has 51 strings none of the others had yet); Estonian, Turkish and Italian proofing are not included. +Also shipped: Traditional Chinese (`zh-hant`), Ukrainian (`uk`), Serbian (`sr`, `sr-latn`, #250), Russian (`ru`) and Polish (`pl`), all partial (~95%). Estonian (`et`), Turkish (`tr`), Italian (`it`, #594) and Czech (`cs`) now have complete interface catalogs with +system-locale selection and a saved language choice (Italian also has 51 strings none of the others had yet); Estonian, Turkish, Italian and Czech proofing are not included. Proofing: English and Polish (spelling, grammar, hyphenation), chosen per run from its language. Word ships 30 interface languages and proofing for ~50. Detail: [docs/localization-parity.md](docs/localization-parity.md). @@ -134,6 +134,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 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 | Czech (`cs`) interface catalog: 2,021 entries (the full zh-hans key set), Czech locale detection and persisted language choice | | 2026-10-11 | Polish: interface (`pl`, the thirteenth language, every catalog key) and proofing — spelling against the installed Hunspell `pl_PL` dictionary (read at runtime, not bundled) through a new Hunspell-style affix engine, suggestions tuned for Polish, Polish grammar rules with Polish messages, TeX Polish hyphenation patterns (MIT option); proofing language per run (`w:lang`) for squiggles, F7, suggestions and hyphenation, languages without tools left unmarked; Review › Language menu and the status bar's language; a Polish interface writes new documents in Polish | | 2026-10-11 | German (`de`) interface (#574): 2,021 translated entries covering the full zh-hans key set, locale and saved-preference tests, bundled-font checks; no German proofing resources | | 2026-10-11 | `.doc` import keeps pictures and drawings (#551): inline and floating pictures (PNG, JPEG, DIB, EMF, WMF), shapes, lines, groups and text boxes with position and wrap, from [MS-ODRAW] | @@ -211,6 +212,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Czech (`cs`) interface catalog added | | 2026-10-11 | trivial | Progress log: German interface (#574) | | 2026-10-11 | trivial | Progress log: tables resize with the mouse (#217) | | 2026-10-11 | trivial | Progress log: Animation effects setting | diff --git a/contributors/people.toml b/contributors/people.toml index d6a5d204a..2fffb68df 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -38,6 +38,11 @@ real_name = "Dmitrii Khlapov" display_name = "Dima Khlapov" sync_github_name = false +[people.DanielKnourek] +real_name = "Daniel Kňourek" +display_name = "Daniel Kňourek" +sync_github_name = false + [people.arcanii] real_name = "Bryan Mark" display_name = "Arcanii" diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv new file mode 100644 index 000000000..6405833dc --- /dev/null +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -0,0 +1,2043 @@ +# Czech (cs) interface catalog. Columns, separated by a TAB: +# context English source translation +# context empty (WordCraft uses no contexts yet) +# source the English text exactly as in the code (tl!(...), ribbon labels, command labels) +# escapes \n \t \\ inside a column. `{name}` placeholders must appear in both columns, and a +# trailing "…" in one needs one in the other. +# Lines starting with # and blank lines are ignored. A missing entry shows in English. +# +# Clean room: every entry was written from the plain meaning of the English label in ordinary +# Czech computing and typography vocabulary. No Microsoft Word (or other product) localisation, +# string table or translated UI was consulted or copied; add entries the same way. +# Product, format and technology names (WordCraft, PDF, Markdown, MCP …) stay in Latin letters. + + (All) (Vše) + (no style) (žádný styl) + (no suggestions) (žádné návrhy) + (none) (žádné) + (normal text) (normální text) + (not matched) (nepřiřazeno) + (picture or object) (obrázek nebo objekt) + 1.5 Line Spacing Řádkování 1,5 + 1/4 Em Space Čtvrtčtverčíková mezera + 3-D 3D + A file dialog is already open. Dialogové okno souboru je již otevřené. + A free, open-source word processor written from scratch in Rust.\nPart of the Crafting Apps from the ArtCraft team. Bezplatný textový procesor s otevřeným zdrojovým kódem napsaný od základu v jazyce Rust.\nSoučást sady Crafting Apps od týmu ArtCraft. + A mail merge field Pole hromadné korespondence + A numbered sequence (figures, tables…) Číslovaná posloupnost (obrázky, tabulky…) + A password is required to open this document. K otevření tohoto dokumentu je vyžadováno heslo. + A table of contents Obsah + About O aplikaci + About WordCraft O aplikaci WordCraft + Accent Diakritika + Accept Přijmout + Accept All Changes Přijmout všechny změny + Accept This Change Přijmout tuto změnu + Accessibility Přístupnost + Add Přidat + Add a comment Přidat komentář + Add a Table Přidat tabulku + Add at least one field. Přidejte alespoň jedno pole. + Add Field Přidat pole + Add Object to Selection Přidat objekt do výběru + Add Space Before Paragraph Přidat mezeru před odstavec + Add space between Asian and Latin text Přidávat mezery mezi asijským textem a latinkou + Add space between Asian text and numbers Přidávat mezery mezi asijským textem a čísly + Add tab stop at: Přidat zarážku tabulátoru na: + Add Text Přidat text + Add to Dictionary Přidat do slovníku + Add to Style Gallery Přidat do galerie stylů + Add/Edit\nBibliography Přidat/upravit\nbibliografii + Add/Edit\nCitation Přidat/upravit\ncitaci + Add\nNote Přidat\npoznámku + Address Adresa + Address Block Blok adresy + Address: Adresa: + Address\nBlock Blok\nadresy + Adjust Upravit + Advanced Pokročilé + Advanced Find Rozšířené hledání + Advanced Find… Rozšířené hledání… + After: Za: + Agents Agenti + Align Zarovnat + Align Left Zarovnat vlevo + Align Right Zarovnat vpravo + Aligned at: Zarovnat na: + Alignment Zarovnání + Alignment: Zarovnání: + All Vše + All Borders Všechna ohraničení + All Caps Všechna velká + All caps Všechna velká + All Markup Všechny revize + All symbol sets Všechny sady symbolů + Allow hanging punctuation Povolit předsazenou interpunkci + Allow Latin text to wrap in the middle of a word Povolit zalomení latinky uprostřed slova + Allow row to break across pages Povolit zalomení řádku mezi stránkami + Alphabetical Abecedně + Alt Text Alternativní text + Alt text Alternativní text + Alt: Alt. text: + Anyone can open this document. Encrypt it with a password to keep it private. Tento dokument může otevřít kdokoli. Chcete-li jej chránit, zašifrujte jej heslem. + Anyone who opens the document will need this password. Toto heslo bude potřebovat každý, kdo dokument otevře. + Apply Asian line-breaking rules (kinsoku) Používat asijská pravidla pro zalamování řádků (kinsoku) + Apply Character Style Použít znakový styl + Apply formatting to: Použít formátování na: + Apply Style Použít styl + Apply Styles Použít styly + Apply to: Použít na: + Arrange Uspořádat + Arrange\nAll Uspořádat\nvše + Arrow Šipka + Artistic Effects Umělecké efekty + As Recommended Podle doporučení + Asian Typography Asijská typografie + At least Nejméně + At least {n} Nejméně {n} + Author Autor + AutoCorrect Options Možnosti automatických oprav + AutoFit Automaticky přizpůsobit + AutoFit Contents Automaticky přizpůsobit obsahu + AutoFit Window Automaticky přizpůsobit oknu + Automatic Automaticky + Automatic ({language}) Automaticky ({language}) + Automatic follows your system's language. Menus and commands change; your documents don't. Automatické nastavení sleduje jazyk systému. Mění se nabídky a příkazy, nikoli obsah dokumentů. + Automatic Table 1 (3 levels) Automatický obsah 1 (3 úrovně) + Automatic Table 2 (2 levels) Automatický obsah 2 (2 úrovně) + AutoSave Automatické ukládání + AutoSave documents you have saved in WordCraft Automaticky ukládat dokumenty uložené ve WordCraftu + AutoSave failed: {error}. Save the document to turn AutoSave back on. Automatické ukládání selhalo: {error}. Uložením dokumentu automatické ukládání znovu zapnete. + AutoSave isn't available in the browser: it can't write to your files. Save downloads a copy. Automatické ukládání není v prohlížeči dostupné, protože nemůže zapisovat do vašich souborů. Příkaz Uložit stáhne kopii. + AutoSave only keeps .docx, .odt and .rtf files; {name} can't keep all formatting. Save as .docx to turn on AutoSave. Automatické ukládání podporuje pouze soubory .docx, .odt a .rtf; formát {name} nedokáže zachovat veškeré formátování. Pro zapnutí automatického ukládání uložte dokument jako .docx. + AutoSave saves every change to the file (needs a saved document) Automatické ukládání zapisuje každou změnu do souboru (vyžaduje uložený dokument) + AutoText Automatický text + Backspace Backspace + Balloons Bubliny + Banded Columns Pruhované sloupce + Banded Rows Pruhované řádky + Bar Svislá čára + Based on: {style} Založeno na: {style} + Before: Před: + Between Mezi odstavci + Bibliography Bibliografie + Binary assets added Přidané binární soubory + Binary assets removed Odebrané binární soubory + Blank Prázdné + Blank (Three Columns) Prázdné (tři sloupce) + Blank document Prázdný dokument + Blank Page Prázdná stránka + Block\nAuthors Blokovat\nautory + Body Text Základní text + Bold Tučné + Bookmark Záložka + Bookmark name: Název záložky: + Border Painter Štětec ohraničení + Border\nPainter Štětec\nohraničení + Borders Ohraničení + Borders and Shading Ohraničení a stínování + Borders and Shading… Ohraničení a stínování… + Bottom Dole + Bottom Border Dolní ohraničení + Bottom Margin Dolní okraj + Bottom of Page Dolní část stránky + Bottom: Dole: + Box Rámeček + Bracket Závorky + Breaks Konce + Bring Forward Přenést blíž + Browse… Procházet… + Built-In Vestavěné + Built-in style Vestavěný styl + Built-in styles can't be deleted. Vestavěné styly nelze odstranit. + Bullet Odrážka + Bullet Library Knihovna odrážek + Bullets Odrážky + By author Podle autora + Calendar Kalendář + Cancel Zrušit + Capitalize Each Word Velká Počáteční Písmena Slov + Captions Titulky + Categories: Kategorie: + Cell Buňka + Cell Margins Okraje buňky + Cell Size Velikost buňky + Cell\nMargins Okraje\nbuňky + Center Na střed + Centered Na střed + Change Změnit + Change Case Změnit velikost písmen + Change List Level Změnit úroveň seznamu + Change Password… Změnit heslo… + Change Picture Změnit obrázek + Change Shape Změnit tvar + Change Structure Změnit strukturu + Change to Display Změnit na samostatnou rovnici + Change to Inline Změnit na vnořenou rovnici + Changed lines: Změněné řádky: + Changes Změny + Character Znak + Character Border Ohraničení znaků + Character code: Kód znaku: + Character Position Poloha znaků + Character Scale Měřítko znaků + Character Shading Stínování znaků + Character Spacing Proložení znaků + Character spacing Proložení znaků + Character style Znakový styl + Characters Znaky + Characters (no spaces) Znaky (bez mezer) + Characters (with spaces) Znaky (včetně mezer) + Chart Graf + Check Accessibility Kontrola přístupnosti + Check Compatibility Kontrola kompatibility + Check for Errors Vyhledat chyby + Check Spelling as You Type Kontrolovat pravopis při psaní + Check\nAccessibility Kontrola\npřístupnosti + Citation Style Styl citací + Citations Citace + Citations & Bibliography Citace a bibliografie + City Město + Clear Vymazat + Clear All Vymazat vše + Clear All Formatting Vymazat veškeré formátování + Clear Character Formatting Vymazat formátování znaků + Clear Character Style Vymazat znakový styl + Clear Formatting Level Vymazat úroveň formátování + Clear Paragraph Formatting Vymazat formátování odstavce + Click a border between two cells to merge them. Esc stops. Kliknutím na ohraničení mezi dvěma buňkami je sloučíte. Esc akci ukončí. + Click an item to paste it. Kliknutím na položku ji vložíte. + Click level to modify: Klikněte na úroveň, kterou chcete upravit: + Clipboard Schránka + Clipboard Items Položky schránky + Clipboard Pane Podokno schránky + Close Zavřít + Close Equation Zavřít rovnici + Close Header and Footer Zavřít záhlaví a zápatí + Col #: Sloupec č.: + Collapse Selection Sbalit výběr + Color Barva + Color only Pouze barva + Color: Barva: + Color: {color} Barva: {color} + Colors Barvy + Column Sloupec + Column Break Konec sloupce + Column Selection Sloupcový výběr + Columns Sloupce + Combine Zkombinovat + Commands Příkazy + Comment Komentář + Comments Komentáře + Comments Pane Podokno komentářů + Community\nDiscord Komunita\nDiscord + Compact Kompaktní + Company Společnost + Compare Porovnat + Compress Pictures Komprimovat obrázky + Compress punctuation at the start of a line Sloučit interpunkční znaménka na začátku řádku + Confirm password: Potvrdit heslo: + Continuous Navazující + Conversions Převody + Convert Převést + Convert Text to Table Převést text na tabulku + Convert Text to Table… Převést text na tabulku… + Convert to Text Převést na text + Copy Kopírovat + Copy Formatting Kopírovat formátování + Copy of the text a bookmark marks Text označený záložkou + Copyright Znak autorských práv + Corrections Opravy + Couldn't download {name}: {error} Nepodařilo se stáhnout {name}: {error} + Couldn't open {path}: {error} Nepodařilo se otevřít {path}: {error} + Count words in text boxes and notes Počítat slova v textových polích a poznámkách + Country Země + Courtesy Title Oslovení + Cover Page Titulní stránka + Create Vytvořit + Create a Style Vytvořit styl + Create an interactive outline of your document.\n\nIt's a great way to keep track of where you are or quickly move your content around.\n\nTo get started, go to the Home tab and apply Heading styles to the headings in your document. Vytvořte si interaktivní osnovu dokumentu.\n\nSnadno tak udržíte přehled o tom, kde se nacházíte, a rychle přesunete obsah.\n\nChcete-li začít, přejděte na kartu Domů a použijte na nadpisy v dokumentu styly nadpisů. + Create New Style Vytvořit nový styl + Crop Oříznout + Cross-reference Křížový odkaz + Current Position Aktuální pozice + Current style: {style} Aktuální styl: {style} + Custom Vlastní + Custom Margins… Vlastní okraje… + Custom Watermark Vlastní vodoznak + Custom Watermark… Vlastní vodoznak… + Customize Quick Access Toolbar Přizpůsobit panel nástrojů Rychlý přístup + Cut Vyjmout + Dark Tmavý + Dark Mode Tmavý režim + Dark page (white text on black) Tmavá stránka (bílý text na černém pozadí) + Dashed Čárkované + Data Data + Date & Time Datum a čas + Date and Time Datum a čas + Date formats: Formáty data: + Decimal Desetinná čárka + Decrease Font Size Zmenšit velikost písma + Decrease Indent Zmenšit odsazení + Default Výchozí + Default tab stops: Výchozí krok tabulátoru: + Define New Bullet Definovat novou odrážku + Define New Multilevel List Definovat nový víceúrovňový seznam + Define New Multilevel List… Definovat nový víceúrovňový seznam… + Define New Number Format Definovat nový číselný formát + Delete Odstranit + Delete Back in Equation Smazat zpět v rovnici + Delete Cells Odstranit buňky + Delete Clipboard Item Odstranit položku ze schránky + Delete Columns Odstranit sloupce + Delete Comment Odstranit komentář + Delete in Equation Smazat v rovnici + Delete Next Word Odstranit následující slovo + Delete Previous Word Odstranit předchozí slovo + Delete Rows Odstranit řádky + Delete Style Odstranit styl + Delete Table Odstranit tabulku + Delete Table Style Odstranit styl tabulky + Deletions: Odstranění: + Description: Popis: + Design Návrh + development build vývojové sestavení + Diagonal Diagonální + Diamond Kosočtverec + Dictate Diktovat + Different First Page Jiné na první stránce + Different Odd & Even Pages Jiné na lichých a sudých stránkách + Direct formatting Přímé formátování + Direction: Směr: + Directory Katalog + Discord Discord + Discord community Komunita na Discordu + Display Zobrazení + Display for Review Zobrazení k revizi + Distribute Columns Rovnoměrně rozložit sloupce + Distribute Rows Rovnoměrně rozložit řádky + Distributed Rozložit do šířky + Do Not Show in TOC Nezobrazovat v obsahu + Do you want to save changes to {name}? Chcete uložit změny v dokumentu {name}? + Document Dokument + Document End Konec dokumentu + Document Formatting Formátování dokumentu + Document Information Informace o dokumentu + Document Property Vlastnost dokumentu + Document Start Začátek dokumentu + Document Text Text dokumentu + Document Title Název dokumentu + Document\nPreferences Předvolby\ndokumentu + Documents you open will show up here. Zde se zobrazí dokumenty, které otevřete. + Don't add space between paragraphs of the same style Nepřidávat mezeru mezi odstavce stejného stylu + Don't hyphenate Nedělit slova + Don't read Zotero, Mendeley or EndNote citations and bibliographies Nečíst citace a bibliografie Zotero, Mendeley ani EndNote + Don't Save Neukládat + Dot dash Čerchované + Dot-dash Čerchované + Dotted Tečkované + Double Dvojité + Double Closing Quote Koncové dvojité uvozovky + Double Opening Quote Počáteční dvojité uvozovky + Double Spacing Dvojité řádkování + Double Strikethrough Dvojité přeškrtnutí + Double strikethrough Dvojité přeškrtnutí + Double Underline Dvojité podtržení + Down Dolů + Draft Koncept + Drag a rectangle to draw a table, or a line across cells to split them. Esc stops. Tažením obdélníku nakreslíte tabulku, nebo tažením čáry přes buňky je rozdělíte. Esc akci ukončí. + Draw Kreslení + Draw Ink Kreslit rukopis + Draw Table Nakreslit tabulku + Drawing Tools Nástroje kreslení + Drawing\nCanvas Kreslicí\nplátno + Drop Cap Iniciála + Drop cap Iniciála + Drops all formatting and pastes plain text. Odstraní veškeré formátování a vloží prostý text. + E-mail Messages E-mailové zprávy + Edit Comment Upravit komentář + Edit Equation Upravit rovnici + Edit Footer Upravit zápatí + Edit Header Upravit záhlaví + Edit Recipient List Upravit seznam příjemců + Edit\nRecipient List Upravit seznam\npříjemců + Edited Upraveno + Editing Úpravy + Editing header/footer Úprava záhlaví/zápatí + Editing — edit the document directly Úpravy — přímá úprava dokumentu + Editor Editor + Effects Efekty + Ellipsis Výpustka + Em Dash Dlouhá pomlčka + Em Space Čtverčíková mezera + Emboss Reliéf + En Dash Krátká pomlčka + En Space Půlčtverčíková mezera + Encrypt with Password Zašifrovat heslem + Encrypt with Password… Zašifrovat heslem… + English (United States) Angličtina (Spojené státy) + Engrave Rytina + Enter formatting for number: Zadejte formát čísla: + Enter in Equation Enter v rovnici + Enter page number: Zadejte číslo stránky: + Envelopes Obálky + Equal column width Stejná šířka sloupců + Equals: Rovná se: + Equation Rovnice + Equation Gallery Galerie rovnic + Equation Info Informace o rovnici + Equation Number Číslo rovnice + Equation Options Možnosti rovnice + Erase Ink Smazat rukopis + Eraser Guma + Even Page Sudá stránka + Every command is available to scripts and AI agents: run `wordcraft-cli mcp` for an MCP server, or start the app with `--control ` for the JSON control channel. Každý příkaz je dostupný skriptům a AI agentům: spusťte `wordcraft-cli mcp` pro server MCP, nebo spusťte aplikaci s parametrem `--control ` pro řídicí kanál JSON. + Every field needs a name. Každé pole musí mít název. + Exactly Přesně + Exactly {n} Přesně {n} + Export Exportovat + Export a copy to share (PDF, Word document) Exportovat kopii ke sdílení (PDF, dokument Word) + Export Page as PNG Exportovat stránku jako PNG + Export PDF Exportovat PDF + Exported {path} Exportováno: {path} + Extend Selection Rozšířit výběr + Field Pole + Field code: Kód pole: + Field Codes Kódy polí + Field codes: Kódy polí: + Field name Název pole + Field name: Název pole: + Field names must be different. Názvy polí se musí lišit. + Field names: Názvy polí: + Field properties Vlastnosti pole + Field: Pole: + Fields: Pole: + Field… Pole… + File Soubor + Fill Výplň + Fill color: Barva výplně: + Find Najít + Find and Replace Najít a nahradit + Find Next Najít další + Find Previous Najít předchozí + Find Recipient Najít příjemce + Find what: Najít: + Find: Najít: + Finish Dokončit + Finish & Merge Dokončit a sloučit + Finish &\nMerge Dokončit\na sloučit + Finished Dokončeno + First Column První sloupec + First commit První commit + First line První řádek + First line: První řádek: + First Name Jméno + Fixed Column Width Pevná šířka sloupce + Flip Horizontal Převrátit vodorovně + Flip Vertical Převrátit svisle + Focus Soustředění + Follow number with: Znak za číslem: + Font Písmo + Font Color Barva písma + Font color Barva písma + Font color: Barva písma: + Font Dialog Dialogové okno Písmo + Font Size Velikost písma + Font: Písmo: + Font: {font} Písmo: {font} + Fonts Písma + Footer Zápatí + Footnote and Endnote Poznámka pod čarou a vysvětlivka + Footnotes Poznámky pod čarou + Format Formát + Format Page Numbers Formát číslování stránek + Format Page Numbers… Formát číslování stránek… + Format Painter Štětec formátu + Format: Formát: + Formatted Text Formátovaný text + Formatting Formátování + Formatting at Selection Formátování výběru + Formula Vzorec + Fraction Zlomek + from Unicode (hex) z Unicode (hex) + Function Funkce + General Obecné + getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft + Glow Záře + Glow Colors Barvy záře + Go To Přejít na + Go to Přejít na + Go To (⌘⌥G) Přejít na (⌘⌥G) + Go To… Přejít na… + Grab bag Různé + Greeting Line Řádek oslovení + Greeting\nLine Řádek\noslovení + Gridlines Mřížka + Group Seskupit + Grow Font 1 Point Zvětšit písmo o 1 bod + H: V: + Hanging Indent Předsazení + Header Záhlaví + Header & Footer Záhlaví a zápatí + Header Row Řádek záhlaví + Header/Footer Position Umístění záhlaví a zápatí + Heading 1 Nadpis 1 + Heading 2 Nadpis 2 + Heading 3 Nadpis 3 + Headings Nadpisy + Heart Srdce + Height: Výška: + Help Nápověda + Hidden Skryté + Hide Codes Skrýt kódy + Hide from style lists Skrýt v seznamech stylů + Hide Ink Skrýt rukopis + Hide\nInk Skrýt\nrukopis + Highlight Zvýraznění + Highlight Merge Fields Zvýraznit slučovací pole + Highlight\nMerge Fields Zvýraznit\nslučovací pole + Highlighter Zvýrazňovač + Home Domů + Horizontal Vodorovně + Horizontal Line Vodorovná čára + How many pages the whole document has Celkový počet stránek v dokumentu + How many pages this section has Počet stránek v tomto oddílu + How many words the document holds Počet slov v dokumentu + HTML Format Formát HTML + Hyphenation Dělení slov + Icons Ikony + If…Then…Else Pokud…Pak…Jinak + If…Then…Else… Pokud…Pak…Jinak… + Ignore All Ignorovat vše + Illustrations Ilustrace + Immersive Asistivní čtení + Immersive Reader Asistivní čtečka + Immersive\nReader Asistivní\nčtečka + Include level number from: Zahrnout číslo úrovně z: + Increase Font Size Zvětšit velikost písma + Increase Indent Zvětšit odsazení + Indent Odsazení + Indent from left: Odsazení zleva: + Indentation Odsazení + Indents Odsazení + Indents and Spacing Odsazení a mezery + Index Rejstřík + Index and Tables Rejstřík a seznamy + Info Informace + Ink Rukopis + Ink to\nMath Rukopis na\nrovnici + Ink to\nShape Rukopis na\ntvar + Ink\nReplay Přehrát\nrukopis + Inline Vnořená + Input Format Formát vstupu + Insert Vložení + Insert a new equation (Alt+=) Vložit novou rovnici (Alt+=) + Insert Above Vložit nad + Insert Below Vložit pod + Insert Caption Vložit titulek + Insert Citation Vložit citaci + Insert Endnote Vložit vysvětlivku + Insert Footnote Vložit poznámku pod čarou + Insert Hyperlink Vložit hypertextový odkaz + Insert Index Vložit rejstřík + Insert Left Vložit vlevo + Insert Merge Field Vložit slučovací pole + Insert Merge\nField Vložit slučovací\npole + Insert New Equation Vložit novou rovnici + Insert one text or another, depending on a field's value. Vloží jeden nebo druhý text v závislosti na hodnotě pole. + Insert Right Vložit vpravo + Insert Shapes Vložit tvary + Insert Structure Vložit strukturu + Insert Symbol Vložit symbol + Insert Table Vložit tabulku + Insert Table of Authorities Vložit seznam citovaných předpisů + Insert Table of Figures Vložit seznam obrázků + Insert Table… Vložit tabulku… + Insert\nAbove Vložit\nnad + Insert\nCaption Vložit\ntitulek + Insert\nCitation Vložit\ncitaci + Insert\nFootnote Vložit poznámku\npod čarou + Insertions and Deletions Vložení a odstranění + Insertions: Vložení: + Inside Borders Vnitřní ohraničení + Inspect Document Zkontrolovat dokument + Inspect Styles at Selection Zkontrolovat styly výběru + Integral Integrál + Interface language: Jazyk rozhraní: + Interface Theme Motiv rozhraní + Interface theme: Motiv rozhraní: + Interface\nTheme Motiv\nrozhraní + Italic Kurzíva + Join the ArtCraft community on Discord Připojte se ke komunitě ArtCraft na Discordu + Join us on Discord: discord.gg/artcraft Připojte se k nám na Discordu: discord.gg/artcraft + Justification Zarovnání + Justified Do bloku + Justify Do bloku + Keep Lines Together Svázat řádky + Keep lines together Svázat řádky + Keep Text Only Zachovat pouze text + Keep the password somewhere safe: without it nobody, WordCraft included, can open the document. Leave both boxes empty to remove the password. Heslo si bezpečně uložte: bez něj dokument nikdo (včetně aplikace WordCraft) neotevře. Chcete-li heslo odebrat, ponechte obě pole prázdná. + Keep with Next Svázat s následujícím + Keep with next Svázat s následujícím + Keeps the fonts and formatting of the copied text. Zachová písma a formátování zkopírovaného textu. + Labels Štítky + Landscape Na šířku + Language Jazyk + Large\nOperator Velký\noperátor + Lasso Laso + Last Column Poslední sloupec + Last commit Poslední commit + Last Name Příjmení + Layout Rozvržení + Layout Summary Přehled rozvržení + Leader Vodicí znak + Leave a recipient out of the merge when a field has this value. Vynechá příjemce ze slučování, pokud má pole tuto hodnotu. + Leave Equation Opustit rovnici + Leave the equation (Esc) Opustit rovnici (Esc) + Left Vlevo + Left Border Levé ohraničení + Left border Levé ohraničení + Left indent Odsazení zleva + Left Margin Levý okraj + Left-to-right Zleva doprava + Left-to-Right Text Direction Směr textu zleva doprava + Left: Vlevo: + Legal style numbering Právnické číslování + Letter Dopis + Letters Dopisy + Level {n} Úroveň {n} + Light Světlý + Limit and\nLog Limita a\nlogaritmus + Line Čára + Line and Page Breaks Tok textu + Line and Paragraph Spacing Řádkování a mezery mezi odstavci + Line between Čára mezi sloupci + Line Break Zalomení řádku + Line breaking Zalamování řádků + Line delta Změna počtu řádků + Line End Konec řádku + Line Numbers Čísla řádků + Line Spacing Řádkování + Line spacing Řádkování + Line Spacing Options… Možnosti řádkování… + Line spacing: Řádkování: + Line Start Začátek řádku + Linear Lineární + Lines Řádky + Lines added Přidané řádky + Lines deleted Odstraněné řádky + Link Odkaz + Link level to style: Propojit úroveň se stylem: + Link to Previous Navázat na předchozí + Linked style (paragraph and character) Propojený styl (odstavec a znak) + Links Odkazy + Links and References Odkazy a reference + List Definition Definice seznamu + Location: {path} Umístění: {path} + lowercase malá písmena + Macros Makra + Mail Merge Hromadná korespondence + Mailings Korespondence + Manage Sources Spravovat prameny + Manage Styles Spravovat styly + Manage Styles… Spravovat styly… + Many pages Více stránek + Margins Okraje + Mark Citation Označit citaci + Mark Entry Označit položku + Mark\nCitation Označit\ncitaci + Mark\nEntry Označit\npoložku + Markdown (*.md) Markdown (*.md) + Match case Rozlišovat malá a velká písmena + Match Fields Přiřadit pole + Matrix Matice + Merge Sloučit + Merge Cells Sloučit buňky + Merge Formatting Sloučit formátování + Merge Record # Číslo slučovaného záznamu + Merged PRs Sloučené PR + Mirrored Zrcadlové + MIT OR Apache-2.0. Copyright (c) 2026 ArtCraft Team and the WordCraft contributors. MIT OR Apache-2.0. Copyright (c) 2026 ArtCraft Team a přispěvatelé projektu WordCraft. + Moderate Střední + Modify Style Upravit styl + Modify Table Style Upravit styl tabulky + Modify Table Style… Upravit styl tabulky… + Modify… Upravit… + More Columns… Další sloupce… + More Symbols… Další symboly… + More templates → Další šablony → + Move in Equation Pohyb v rovnici + Move or Resize Přesunout nebo změnit velikost + Move Past Citation Přejít za citaci + Move Past\nCitation Přejít za\ncitaci + Multilevel List Víceúrovňový seznam + Multiple Pages Více stránek + Name (A–Z) Jméno (A–Z) + Name of the file this document is saved as Název souboru dokumentu + Name: Název: + Narrow 0.5" all Úzké 0,5" všude + Navigation Navigace + Navigation Pane Navigační podokno + New Nový + New Comment Nový komentář + New Entry Nová položka + New from Template Nový ze šablony + New Paragraph Nový odstavec + New Style… Nový styl… + New Table Style Nový styl tabulky + New Table Style… Nový styl tabulky… + New Window Nové okno + New\nComment Nový\nkomentář + New\nWindow Nové\nokno + Next Další + Next Change Další změna + Next Comment Další komentář + Next Footnote Další poznámka pod čarou + Next Page Další stránka + Next Record Další záznam + Next Section Další oddíl + Next Sentence Další věta + Next sentence Další věta + No Ne + No Border Bez ohraničení + No Color Bez barvy + No contributor data was built into this copy. Tato kopie neobsahuje údaje o přispěvatelích. + No errors found: every merge field is in the recipient list. Nebyly nalezeny žádné chyby: všechna slučovací pole jsou v seznamu příjemců. + No Glow Bez záře + No Markup Bez revizí + No model credits were built into this copy. Tato kopie neobsahuje uvedení modelů. + No Paragraph Space Bez mezer mezi odstavci + No Shadow Bez stínu + No Soft Edges Bez měkkých okrajů + No templates match your search. Hledání neodpovídají žádné šablony. + No-Width Non Break Nezlomitelný znak nulové šířky + No-Width Optional Break Podmíněné zalomení nulové šířky + Nonbreaking Hyphen Nezlomitelný spojovník + Nonbreaking Space Nezlomitelná mezera + None Žádné + Normal 1" all Normální 1" všude + Normal speed Normální rychlost + Normal Style Normální styl + Normal Text Normální text + Normal Word Document Normální dokument Word + Not available right now Momentálně nedostupné + Not saved Neuloženo + Not saved yet Dosud neuloženo + Not {name} Bez: {name} + Nothing Nic + Nothing collected yet. Items you copy or cut appear here. Zatím zde nic není. Položky, které zkopírujete nebo vyjmete, se zobrazí zde. + Nudge Posunout o krok + Number Číslo + Number alignment: Zarovnání čísel: + Number format Formát čísla + Number of columns: Počet sloupců: + Number of rows: Počet řádků: + Number of the page the field sits on Číslo aktuální stránky + Number of the section the field sits in Číslo aktuálního oddílu + Number style for this level: Styl číslování pro tuto úroveň: + Numbering Číslování + Object Objekt + Odd Page Lichá stránka + Office 2003 Default Výchozí Office 2003 + Offset: Bottom Posun: dolů + Offset: Bottom Left Posun: vlevo dolů + Offset: Bottom Right Posun: vpravo dolů + Offset: Center Posun: na střed + Offset: Left Posun: vlevo + Offset: Right Posun: vpravo + Offset: Top Posun: nahoru + Offset: Top Left Posun: vlevo nahoru + Offset: Top Right Posun: vpravo nahoru + OK OK + One Jeden + One Page Jedna stránka + Only web (http, https) and email (mailto) links can be followed Otevřít lze pouze webové (http, https) a e-mailové (mailto) odkazy + Open Otevřít + OpenDocument Text (*.odt) Text OpenDocument (*.odt) + Opening print dialog… Otevírání dialogu tisku… + Operator Operátor + Optional Hyphen Podmíněný spojovník + Options Možnosti + Or save a copy in another format: Nebo uložte kopii v jiném formátu: + Orientation Orientace + Otherwise insert: Jinak vložit: + Outline Osnova + Outline Level Úroveň osnovy + Outline level Úroveň osnovy + Outside border Vnější ohraničení + Outside Borders Vnější ohraničení + Oval Ovál + Page Stránka + Page Background Pozadí stránky + Page Border Ohraničení stránky + Page Borders Ohraničení stránky + Page Break Konec stránky + Page Break Before Vložit konec stránky před + Page break before Vložit konec stránky před + Page Breaks Konce stránek + Page Color Barva stránky + Page Down O stránku dolů + Page image (*.png) Obrázek stránky (*.png) + Page Movement Pohyb stránek + Page Number Číslo stránky + Page on which a bookmarked passage falls Číslo stránky se záložkou + Page Setup Vzhled stránky + Page Up O stránku nahoru + Page Width Šířka stránky + Page width Šířka stránky + Page X of Y Stránka X z Y + Page {page} Stránka {page} + Page {page} of {pages} Stránka {page} z {pages} + Page\nBorders Ohraničení\nstránky + Page\nColor Barva\nstránky + Page\nNumber Číslo\nstránky + Pages Stránky + Paragraph Odstavec + Paragraph Details Podrobnosti odstavce + Paragraph Down O odstavec dolů + Paragraph Mark Značka odstavce + Paragraph Settings Nastavení odstavce + Paragraph Spacing Mezery mezi odstavci + Paragraph style Styl odstavce + Paragraph Up O odstavec nahoru + Paragraphs Odstavce + Password Heslo + Password: Heslo: + Paste Vložit + Paste (⌘V) Vložit (⌘V) + Paste All Vložit vše + Paste as: Vložit jako: + Paste Clipboard Item Vložit položku ze schránky + Paste Formatting Vložit formátování + Paste Special Vložit jinak + Paste Special… Vložit jinak… + Paste: Keep Text Only Vložit: zachovat pouze text + Paste: Merge Formatting Vložit: sloučit formátování + Pause Pozastavit + Paused Pozastaveno + PDF document (*.pdf) Dokument PDF (*.pdf) + Pen Pero + Pencil Tužka + Pens Pera + Pick a field from the list Vyberte pole ze seznamu + Picture Border Ohraničení obrázku + Picture Format Formát obrázku + Picture Styles Styly obrázků + Pictures Obrázky + Plain text (*.txt) Prostý text (*.txt) + Play Přehrát + Play/Pause Přehrát/pozastavit + Portrait Na výšku + Position Pozice + Postal Code PSČ + Preferred width: Upřednostňovaná šířka: + Preserve formatting during updates Zachovat formátování při aktualizaci + Presets Přednastavení + Preview Náhled + Preview Results Náhled výsledků + Preview\nResults Náhled\nvýsledků + Previous Předchozí + Previous Change Předchozí změna + Previous Comment Předchozí komentář + Previous Record Předchozí záznam + Previous Section Předchozí oddíl + Previous Sentence Předchozí věta + Previous sentence Předchozí věta + Print Tisk + Print failed: {error} Tisk selhal: {error} + Print Layout Rozvržení při tisku + Print\nLayout Rozvržení\npři tisku + Professional Profesionální + Proofing Kontrola pravopisu a mluvnice + Proofing Issues Nalezené chyby + Properties Vlastnosti + Protect Zabezpečení + Protect Document Zabezpečit dokument + Quick Access Toolbar Panel nástrojů Rychlý přístup + Quick Parts Rychlé části + Quick Tables Rychlé tabulky + Radical Odmocnina + Raised/lowered Zvýšené/snížené + Read Aloud Číst nahlas + Read Aloud Status Stav čtení nahlas + Read Mode Režim čtení + Read\nAloud Číst\nnahlas + Read\nMode Režim\nčtení + Reading Highlight Zvýraznění při čtení + Reading Speed Rychlost čtení + Reads the clipboard's rich text and keeps its formatting. Načte formátovaný text (RTF) ze schránky a zachová jeho formátování. + Reads the clipboard's web content and keeps its formatting. Načte webový obsah ze schránky a zachová jeho formátování. + Recent Nedávné + Recently used symbols: Naposledy použité symboly: + Recipient List Seznam příjemců + Recipients: {count} Příjemců: {count} + Record Macro Záznam makra + Recover Unsaved Documents Obnovit neuložené dokumenty + Rectangle Obdélník + Redo Znovu + Reduce Hanging Indent Zmenšit předsazení + References Reference + Refresh Obnovit + Registered Registrovaná ochranná známka + Reject Odmítnout + Reject All Changes Odmítnout všechny změny + Reject Change Odmítnout změnu + Relaxed Volné + Remove Background Odstranit pozadí + Remove field Odebrat pole + Remove Footer Odebrat zápatí + Remove Header Odebrat záhlaví + Remove Hyperlink Odebrat hypertextový odkaz + Remove Password Odebrat heslo + Remove recipient Odebrat příjemce + Remove Space After Paragraph Odebrat mezeru za odstavcem + Remove Table of Contents Odebrat obsah + Remove Watermark Odebrat vodoznak + Reopen Znovu otevřít + Repeat Opakovat + Repeat as header row on each page Opakovat jako řádek záhlaví na každé stránce + Repeat Header Rows Opakovat řádky záhlaví + Replace Nahradit + Replace All Nahradit vše + Replace Document Text Nahradit text dokumentu + Replace with: Nahradit čím: + Replay Přehrát + Reply Odpovědět + Report Zpráva + Research Rešerše + Researcher Výzkumník + Reset Obnovit + Reset Crop Zrušit oříznutí + Reset Picture Obnovit obrázek + Reset to Normal Obnovit normální styl + Resolve Vyřešit + Resolved Vyřešeno + Restart at 1 Začít znovu od 1 + Restart Each Page Začít znovu na každé stránce + Restart Each Section Začít znovu v každém oddílu + Restart list after: Začít číslování znovu po: + Restrict Editing Omezit úpravy + Restrict\nEditing Omezit\núpravy + Result Výsledek + Results Výsledky + Reverse the order Obrátit pořadí + Review Revize + Reviewing Revize + Reviewing Pane Podokno revizí + Reviewing — edits become suggestions Revize — úpravy se ukládají jako návrhy + Rich Text Format (*.rtf) Formát RTF (*.rtf) + Rich Text Format (RTF) Formát RTF (Rich Text Format) + Right Vpravo + Right Border Pravé ohraničení + Right border Pravé ohraničení + Right indent Odsazení zprava + Right Margin Pravý okraj + Right-to-left Zprava doleva + Right-to-Left Text Direction Směr textu zprava doleva + Right: Vpravo: + Rotate Otočit + Rotate Left 90° Otočit doleva o 90° + Rotate Right 90° Otočit doprava o 90° + Rotation Otočení + Rotation: Otočení: + Rounded Rectangle Zaoblený obdélník + Row Řádek + Rows & Columns Řádky a sloupce + Ruler Pravítko + Rules Pravidla + Résumé Životopis + Save Uložit + Save a copy in another format. Printing goes through a PDF you can print from any viewer. Uloží kopii v jiném formátu. Tisk probíhá prostřednictvím souboru PDF, který vytisknete v libovolném prohlížeči. + Save As Uložit jako + Save as Template Uložit jako šablonu + Save the document to turn on AutoSave Pro zapnutí automatického ukládání dokument nejprve uložte + Save the document to turn on AutoSave (a file you have only opened isn't rewritten until you save it) Pro zapnutí automatického ukládání dokument uložte (pouze otevřený soubor se nepřepisuje, dokud jej sami neuložíte) + Saved Uloženo + Scale Měřítko + Script Index + Scroll ribbon left Posunout pás karet doleva + Scroll ribbon right Posunout pás karet doprava + Search Commands Hledat příkazy + Search commands and help Hledat příkazy a nápovědu + Search document Hledat v dokumentu + Search for templates Hledat šablony + Section Breaks Konce oddílů + Section Mark Znak paragrafu + Section Properties Vlastnosti oddílu + Sections Oddíly + Select Vybrat + Select All Vybrat vše + Select Cell Vybrat buňku + Select Line Vybrat řádek + Select Objects Vybrat objekty + Select Paragraph Vybrat odstavec + Select Range Vybrat rozsah + Select Recipients Vybrat příjemce + Select recipients first to choose from their fields. Nejprve vyberte příjemce, abyste mohli vybírat z jejich polí. + Select recipients first to match their fields. Nejprve vyberte příjemce, abyste mohli přiřadit jejich pole. + Select Row Vybrat řádek + Select Sentence Vybrat větu + Select Table Vybrat tabulku + Select Text Vybrat text + Select Text with Similar Formatting Vybrat text s podobným formátováním + Select Word Vybrat slovo + Select\nRecipients Vybrat\npříjemce + Selection Výběr + Selection Pane Podokno výběru + Send Backward Přenést dál + Send the document straight to the system print dialog, or save a copy in another format below. Odešle dokument přímo do systémového dialogu tisku, nebo níže uložte kopii v jiném formátu. + Sentence Věta + Sentence case. Velké písmeno na začátku věty. + Sequence name: Název posloupnosti: + Set Nastavit + Set as Default Nastavit jako výchozí + Set Caret Umístit kurzor + Set Character Formatting Nastavit formátování znaků + Set Equation Nastavit rovnici + Set Numbering Value Nastavit hodnotu číslování + Set Paragraph Formatting Nastavit formátování odstavce + Setting: Nastavení: + Shading Stínování + Shadow Stín + Shadow, glow and soft edges Stín, záře a měkké okraje + Shape Effects Efekty tvarů + Shape Fill Výplň tvaru + Shape Format Formát tvaru + Shape Outline Obrys tvaru + Shape Styles Styly tvarů + Shapes Tvary + Share Sdílet + Shift+Tab Shift+Tab + Shortcut key: Klávesová zkratka: + Show Zobrazit + Show all formatting marks Zobrazit všechny netisknutelné znaky + Show all revisions inline Zobrazit všechny revize přímo v textu + Show Comments Zobrazit komentáře + Show documents with their colours inverted, like View › Switch Modes. Saving, printing and PDFs are unchanged. Zobrazí dokumenty s invertovanými barvami, podobně jako Zobrazení › Přepnout režimy. Ukládání, tisk a PDF zůstávají beze změny. + Show in the Styles gallery Zobrazit v galerii stylů + Show Markup Zobrazit revize + Show Notes Zobrazit poznámky + Show only comments and formatting in balloons Zobrazit v bublinách pouze komentáře a formátování + Show or Hide Style Zobrazit nebo skrýt styl + Show revisions in balloons Zobrazit revize v bublinách + Show rulers Zobrazit pravítka + Show the community button in the title bar Zobrazit tlačítko komunity v záhlaví okna + Show/Hide ¶ Zobrazit/skrýt ¶ + Show\nComments Zobrazit\nkomentáře + Shrink Font 1 Point Zmenšit písmo o 1 bod + Side to Side Vedle sebe + Side\nto Side Vedle\nsebe + Signature Line Řádek podpisu + Single Jednoduché + Single Closing Quote Koncová jednoduchá uvozovka + Single Opening Quote Počáteční jednoduchá uvozovka + Single Spacing Jednoduché řádkování + Size Velikost + Size: Velikost: + Skip citations & bibliography Přeskakovat citace a bibliografii + Skip Citations and Bibliography Přeskakovat citace a bibliografii + Skip Record If Přeskočit záznam, pokud + Skip Record If… Přeskočit záznam, pokud… + Small Caps Kapitálky + Small caps Kapitálky + SmartArt SmartArt + Soft Edges Měkké okraje + Sort Seřadit + Sort order: Pořadí řazení: + Source code: github.com/storytold/wordcraft Zdrojový kód: github.com/storytold/wordcraft + Space Mezera + Space after Mezera za + Space after: {pt} pt Mezera za: {pt} b. + Space before Mezera před + Space before/after: Mezery před/za: + Space before: {pt} pt Mezera před: {pt} b. + Spacing Mezery + Spacing (pt): Mezery (b.): + Spacing: Mezera: + Special Characters Speciální znaky + Specify height: Zadat výšku: + Speech Řeč + Speed Rychlost + Spelling & Grammar Pravopis a mluvnice + Spelling &\nGrammar Pravopis\na mluvnice + Spelling Suggestions Návrhy pravopisu + Split Rozdělit + Split Cells Rozdělit buňky + Split Table Rozdělit tabulku + Spreadsheet Table Tabulka sešitu + Standard Colors Standardní barvy + Star Hvězda + Start at: Začít od: + Start Mail Merge Spustit hromadnou korespondenci + Start Mail\nMerge Spustit hromadnou\nkorespondenci + State Stát/kraj + Statistics Statistika + Status Bar Stavový řádek + Stop Zastavit + Stop Reading Zastavit čtení + Stopped Zastaveno + Strikethrough Přeškrtnuté + Structure Actions Akce se strukturou + Structures Struktury + Studio cover Titulní stránka studia + Studio handbook (sample) Příručka studia (ukázka) + Style Styl + Style based on: Styl založený na: + Style Inspector Inspektor stylů + Style Set Sada stylů + Style: Styl: + Styles Styly + Styles Pane Podokno stylů + Subscript Dolní index + Subset: Podmnožina: + Superscript Horní index + Suppress line numbers Potlačit čísla řádků + Switch Modes Přepnout režimy + Switch\nModes Přepnout\nrežimy + Symbol Symbol + Symbols Symboly + Synonyms Synonyma + Tab Tabulátor + Tab character Znak tabulátoru + Tab stop position: Umístění zarážky tabulátoru: + Tab stops to be cleared: Zarážky tabulátoru ke smazání: + Table Tabulka + Table Column Width Šířka sloupce tabulky + Table Design Návrh tabulky + Table Layout Rozvržení tabulky + Table of Authorities Seznam citovaných předpisů + Table of Contents Obsah + Table of\nContents Obsah + Table Properties Vlastnosti tabulky + Table Row Height Výška řádku tabulky + Table style Styl tabulky + Table Style Options Možnosti stylu tabulky + Table Styles Styly tabulek + Tables Tabulky + Tabs Tabulátory + Tabs… Tabulátory… + Tabular List Tabulkový seznam + Text Text + Text Box Textové pole + Text Direction Směr textu + Text Effects Textové efekty + Text Effects and Typography Textové efekty a typografie + Text from File Text ze souboru + Text Highlight Color Barva zvýraznění textu + Text indent at: Odsazení textu na: + Text to display: Zobrazený text: + Text Wrapping Obtékání textu + Text: Text: + Text\nBox Textové\npole + Text\nDirection Směr\ntextu + That format doesn't keep everything, so the document is still open. Save it as a Word document (.docx) to go on. Tento formát nezachová veškerý obsah, proto dokument zůstává otevřený. Chcete-li pokračovat, uložte jej jako dokument Word (.docx). + That password isn't right. Check it (passwords are case-sensitive) and try again. Zadané heslo není správné. Zkontrolujte jej (rozlišují se malá a velká písmena) a zkuste to znovu. + The clipboard is empty. Schránka je prázdná. + The date it is today Dnešní datum + The document has no bookmarks yet. Dokument zatím neobsahuje žádné záložky. + The document will be saved with a password. Save it to protect it. Dokument bude uložen s heslem. Pro zapnutí ochrany dokument uložte. + The new style takes the formatting of the current paragraph. Nový styl převezme formátování aktuálního odstavce. + The password is removed. Save the document to keep the change. Heslo bylo odebráno. Pro zachování změny dokument uložte. + The passwords don't match. Hesla se neshodují. + The recipient-list field that fills each part of an address block and greeting line. Pole seznamu příjemců, které vyplní jednotlivé části bloku adresy a řádku oslovení. + The time right now Aktuální čas + Theme Colors Barvy motivu + Theme Fonts Písma motivu + Theme List Seznam motivů + Themes Motivy + Then insert: Pak vložit: + Thesaurus Tezaurus + These merge fields aren't in the recipient list: Tato slučovací pole nejsou v seznamu příjemců: + Thick Silné + Thickness Tloušťka + This field has no options. Toto pole nemá žádné možnosti. + This font has no characters to show. Toto písmo neobsahuje žádné znaky k zobrazení. + This point forward Od tohoto místa dále + This section Tento oddíl + Three Tři + Tight Těsné + Title from the document properties Název z vlastností dokumentu + Tools Nástroje + Top Nahoře + Top Border Horní ohraničení + Top Margin Horní okraj + Top of Page Horní část stránky + Top: Nahoře: + Total Row Řádek součtů + Track Changes Sledovat změny + Track Changes Options Možnosti sledování změn + Track Changes: On Sledování změn: zapnuto + Track formatting Sledovat formátování + Tracking Sledování + Trademark Ochranná známka + Translate Přeložit + Transparency Průhlednost + Triangle Trojúhelník + Triple Trojité + Two Dva + Type a command, e.g. "insert table" Zadejte příkaz, např. „vložit tabulku“ + Type a New List… Zadat nový seznam… + Type at least one recipient. Zadejte alespoň jednoho příjemce. + Type in Equation Psaní v rovnici + Type Text Zadat text + Type the field's name and its switches, for example PAGE \\* roman. Zadejte název pole a jeho přepínače, například PAGE \\* roman. + Type the recipients, or load them from a file. Zadejte příjemce nebo je načtěte ze souboru. + Underline Podtržené + Underline color Barva podtržení + Underline Words Only Podtrhnout pouze slova + Undo Zpět + Undo {action} Zpět: {action} + Unformatted Text Neformátovaný text + Ungroup Oddělit + Unlink\nCitations Odpojit\ncitace + Up Nahoru + Update Field Aktualizovat pole + Update Index Aktualizovat rejstřík + Update Style to Match Selection Aktualizovat styl podle výběru + Update Table Aktualizovat + Update Table of Figures Aktualizovat seznam obrázků + Update to Match Aktualizovat podle výběru + Update to Match Selection Aktualizovat podle výběru + UPPERCASE VELKÁ PÍSMENA + Use an Existing List… Použít existující seznam… + Use system setting Podle nastavení systému + User Name Uživatelské jméno + User name: Uživatelské jméno: + Version History Historie verzí + Version {version} ({build}) Verze {version} ({build}) + Vertical Svisle + Vertical Alignment Svislé zarovnání + Vertical alignment: Svislé zarovnání: + View Zobrazení + View Gridlines Zobrazit mřížku + View State Stav zobrazení + Views Zobrazení + Voice Hlas + W: Š: + Waiting for Zotero… Čekání na Zotero… + Watermark Vodoznak + Wave Vlnovka + Web Layout Rozvržení webu + Web page (*.html) Webová stránka (*.html) + Web\nLayout Rozvržení\nwebu + Weight Tloušťka + When this document was first created Datum vytvoření tohoto dokumentu + When this document was most recently printed Datum posledního tisku tohoto dokumentu + When this document was most recently saved Datum posledního uložení tohoto dokumentu + Who the document properties name as author Autor uvedený ve vlastnostech dokumentu + Whole document Celý dokument + Whole page Celá stránka + Whole Table Celá tabulka + Whole words Celá slova + Wide Široké + Widow/Orphan Control Kontrola osamocených řádků + Widow/Orphan control Kontrola osamocených řádků + Width and spacing Šířka a mezery + Width: Šířka: + Window Okno + Word Count Počet slov + Word document (*.docx) Dokument Word (*.docx) + Word Left O slovo vlevo + Word Right O slovo vpravo + WordArt WordArt + WordCraft WordCraft + Words Slova + Words only Pouze slova + Wrap Text Obtékání textu + Wrap\nText Obtékání\ntextu + Write & Insert Fields Psaní a vkládání polí + Write a reply Napište odpověď + Yes Ano + Your changes will be lost if you don't save them. Pokud změny neuložíte, budou ztraceny. + Zoom Lupa + Zoom In Přiblížit + Zoom Out Oddálit + Zotero Zotero + Zotero: cancelled Zotero: zrušeno + Zotero: done Zotero: hotovo + {count} items highlighted Počet zvýrazněných položek: {count} + {group} settings Nastavení skupiny {group} + {key}+Click to follow link {key}+kliknutí pro otevření odkazu + {name} is protected with a password. Dokument {name} je chráněn heslem. + {n} lines Řádků: {n} + {n} pt {n} b. + {selected} of {words} words Počet slov: {words}; vybráno: {selected} + {size} pt {size} b. + {words} words Počet slov: {words} + Base text: Základní text: + Distribute letters Rozmístit písmena + Distribute spacing Rozmístit mezery + Offset (pt): Posun (b.): + Phonetic Guide Fonetický průvodce + Remove Odebrat + Right (vertical) Vpravo (svisle) + Ruby text: Text ruby: + Copyright Sign Symbol autorských práv + Insert Date Field Vložit pole data + Insert Page Field Vložit pole čísla stránky + Insert Time Field Vložit pole času + Registered Sign Symbol registrované ochranné známky + Reset Character Formatting Obnovit formátování znaků + Reset Paragraph Formatting Obnovit formátování odstavce + Trademark Sign Symbol ochranné známky + Size (pt): Velikost (b.): + Style: {style} Styl: {style} + Align Top Left Zarovnat nahoru vlevo + Align Top Center Zarovnat nahoru na střed + Align Top Right Zarovnat nahoru vpravo + Align Center Left Zarovnat na střed vlevo + Align Center Zarovnat na střed + Align Center Right Zarovnat na střed vpravo + Align Bottom Left Zarovnat dolů vlevo + Align Bottom Center Zarovnat dolů na střed + Align Bottom Right Zarovnat dolů vpravo + {contributors} contributors · {commits} commits Přispěvatelé: {contributors} · commity: {commits} + {cols}×{rows} Table Tabulka {cols}×{rows} + AaBbYyZz AaBbYyZz ČčŘřŽž + pt b. + Document1 Dokument1 + Username Uživatelské jméno + Display name Zobrazované jméno + Real name Skutečné jméno + Name Jméno + PRs PR + Commits Commity + First První + Last Poslední + Model Model + Version Verze + % of all commits Podíl na commitech (%) + Lines +/− Řádky +/− + Craft Řemeslo + Studio Studio + Atelier Ateliér + Darkroom Temná komora + Gallery Galerie + Sketchbook Skicák + Change Picture… Změnit obrázek… + Font… Písmo… + Paragraph… Odstavec… + Link… Odkaz… + Size and Crop… Velikost a oříznutí… + Insert Columns to the Left Vložit sloupce vlevo + Insert Columns to the Right Vložit sloupce vpravo + Insert Rows Above Vložit řádky výše + Insert Rows Below Vložit řádky níže + Mon Po + Tue Út + Wed St + Sum Součet + North Sever + South Jih + West Západ + Total Celkem + Draw\nTable Nakreslit\ntabulku + Move Paragraph Up Přesunout odstavec nahoru + Move Paragraph Down Přesunout odstavec dolů + Promote Zvýšit úroveň + Demote Snížit úroveň + Go Back Přejít zpět + Toggle Unicode Character Přepnout znak Unicode + Toggle Field Codes Přepnout kódy polí + Edit Field Code Upravit kód pole + Unlink Fields Zrušit propojení polí + Lock Fields Zamknout pole + Unlock Fields Odemknout pole + Next Field Další pole + Previous Field Předchozí pole + Selected Fields Vybraná pole + Edit Field… Upravit pole… + Formatted Formátováno + Interface size: Velikost rozhraní: + Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Zvětší nebo zmenší pás karet, nabídky a dialogová okna nad rámec škálování displeje v systému. Lupa dokumentu se nastavuje zvlášť. + In Line with Text V řádku s textem + Square Čtverec + Through Průchozí + Top and Bottom Nahoře a dole + Behind Text Za textem + In Front of Text Před textem + Wrap Text Sides Strany obtékání textu + Both Sides Obě strany + Left Only Pouze vlevo + Right Only Pouze vpravo + Largest Only Pouze největší strana + Customize Keyboard Přizpůsobit klávesnici + Choose a command Vyberte příkaz + Choose a shortcut Vyberte klávesovou zkratku + All Commands Všechny příkazy + Commands: Příkazy: + Keys for this command: Klávesy pro tento příkaz: + New shortcut: Nová klávesová zkratka: + Now used by {command} Nyní používá: {command} + Not used by any command Nepoužíváno žádným příkazem + Remove every custom shortcut and restore the built-in keys? Odebrat všechny vlastní klávesové zkratky a obnovit výchozí klávesy? + Assign Přiřadit + Reset All… Obnovit vše… + Press a key combination Stiskněte kombinaci kláves + Customize Přizpůsobit + Keyboard shortcuts: Klávesové zkratky: + Customize… Přizpůsobit… + Customize Ribbon Přizpůsobit pás karet + Add to Quick Access Toolbar Přidat na panel nástrojů Rychlý přístup + Remove from Quick Access Toolbar Odebrat z panelu nástrojů Rychlý přístup + Customize the Ribbon… Přizpůsobit pás karet… + Customize Quick Access Toolbar… Přizpůsobit panel nástrojů Rychlý přístup… + More Commands… Další příkazy… + Ribbon Pás karet + (Custom) (Vlastní) + Remove every custom tab and group and show all tabs again? Odebrat všechny vlastní karty a skupiny a znovu zobrazit všechny karty? + Put the default commands back on the Quick Access Toolbar? Vrátit výchozí příkazy na panel nástrojů Rychlý přístup? + Reset… Obnovit… + Add >> Přidat >> + << Remove << Odebrat + Pick a custom group (New Group makes one) Vyberte vlastní skupinu (novou vytvoříte tlačítkem Nová skupina) + Ribbon tabs Karty pásu karet + New Tab Nová karta + New Group Nová skupina + Rename… Přejmenovat… + Ribbon and Quick Access Toolbar: Pás karet a panel nástrojů Rychlý přístup: + Choose the sheet that holds the recipients. Vyberte list, který obsahuje příjemce. + First row of data contains column headers První řádek dat obsahuje záhlaví sloupců + Accessibility Pane Podokno přístupnosti + Alt Text Pane Podokno alternativního textu + Alt\nText Alternativní\ntext + View Alt Text… Zobrazit alternativní text… + Mark as decorative Označit jako dekorativní + Title: Název: + Errors ({n}) Chyby ({n}) + Warnings ({n}) Upozornění ({n}) + Tips ({n}) Tipy ({n}) + No accessibility issues found. Nebyly nalezeny žádné problémy s přístupností. + Describe what this object shows, for people who can't see it. Popište, co tento objekt zobrazuje, pro lidi, kteří jej nevidí. + Decorative objects only add visual interest: screen readers skip them. Dekorativní objekty slouží pouze jako vizuální doplněk: čtečky obrazovky je přeskakují. + Describe what this table shows, for people who can't see it. Popište, co tato tabulka zobrazuje, pro lidi, kteří ji nevidí. + Select a picture, shape, chart or table to describe it. Vyberte obrázek, tvar, graf nebo tabulku, kterou chcete popsat. + Document Grid Mřížka dokumentu + No grid Bez mřížky + Lines only Pouze řádky + Lines and characters Řádky a znaky + Text snaps to characters Text se přichytává ke znakům + Characters per line: Znaků na řádek: + Lines per page: Řádků na stránku: + Moved from Přesunuto odtud + Moved to Přesunuto sem + Right-to-Left Table Směr tabulky zprava doleva + Right-to-Left Section Směr oddílu zprava doleva + Table direction: Směr tabulky: + Section direction: Směr oddílu: + Drawing Canvas Kreslicí plátno + New Drawing Canvas Nové kreslicí plátno + Fit Přizpůsobit + Expand Rozšířit + Scale Drawing Škálovat kresbu + Canvas Member Prvek plátna + Arrows Šipky + Communication Komunikace + Office Kancelář + People Lidé + Nature Příroda + Weather Počasí + Technology Technologie + Transport Doprava + Icon Color Barva ikony + Search icons Hledat ikony + No icons match your search. Vašemu hledání neodpovídají žádné ikony. + Recolor a one-color picture, such as an icon Přebarví jednobarevný obrázek, například ikonu + Enclose Characters Ohraničit znaky + Shrink text Zmenšit text + Enlarge symbol Zvětšit symbol + Enclosure Ohraničení + Circle Kruh + Change Chart Type Změnit typ grafu + Chart Design Návrh grafu + Type Typ + Edit Data Upravit data + Chart Title Název grafu + Chart Layouts Rozvržení grafu + Add Chart Element Přidat prvek grafu + Legend Legenda + Data Labels Popisky dat + Above Chart Nad grafem + Insert Chart Vložit graf + Clustered Column Skupinový sloupcový + Stacked Column Skládaný sloupcový + Clustered Bar Skupinový pruhový + Stacked Bar Skládaný pruhový + Area Plošný + Pie Výsečový + Doughnut Prstencový + Scatter Bodový + “{text}” ({row}, {series}) isn't a number. „{text}“ ({row}, {series}) není číslo. + Category {n} Kategorie {n} + Series {n} Řada {n} + Type the categories down the side and a column of values per series. Zadejte kategorie pod sebe a pro každou řadu sloupec hodnot. + Categories Kategorie + Series name Název řady + Remove series Odebrat řadu + Remove row Odebrat řádek + Add Row Přidat řádek + Add Series Přidat řadu + Edit\nData Upravit\ndata + Change\nChart Type Změnit\ntyp grafu + Align Text Zarovnat text + Create Link Vytvořit propojení + Break Link Zrušit propojení + Rotate all text 90° Otočit všechen text o 90° + Rotate all text 270° Otočit všechen text o 270° + Stacked Pod sebou + Middle Uprostřed + Click the empty text box the text should continue in (Esc cancels) Klikněte na prázdné textové pole, ve kterém má text pokračovat (Esc zruší) + Not linked: click a text box Nepropojeno: klikněte na textové pole + Location Umístění + Endnotes Vysvětlivky + Below text Pod textem + End of section Konec oddílu + End of document Konec dokumentu + Footnotes to endnotes Převést poznámky pod čarou na vysvětlivky + Endnotes to footnotes Převést vysvětlivky na poznámky pod čarou + Swap footnotes and endnotes Zaměnit poznámky pod čarou a vysvětlivky + Custom mark: Vlastní značka: + Continuous numbering Průběžné číslování + Apply Použít + Convert Notes Převést poznámky + A, B, C, … A, B, C, … + Above the item Nad položkou + Accessed on Datum přístupu + Add New Source… Přidat nový pramen… + Add the chapter number Přidat číslo kapitoly + Atmosphere Atmosféra + Below the item Pod položkou + Book Kniha + Book title Název knihy + Caption Titulek + Caption Numbering Číslování titulků + Caption text Text titulku + Caption: Titulek: + Chapter in a book Kapitola v knize + Chapters begin at: Kapitoly začínají stylem: + Collect entries from: Zahrnout do obsahu: + Columns: Sloupce: + Conference Konference + Conference paper Příspěvek na konferenci + Container Title Název souborného díla + Create Source Vytvořit pramen + Cross-reference: Křížový odkaz: + Custom Table of Contents… Vlastní obsah… + DOI DOI + Day Den + Delete Label Odstranit popisek + Edit Source Upravit pramen + Edition Vydání + Edit… Upravit… + Entry marked. Položka byla označena. + Example: {sample} Příklad: {sample} + Figure Obrázek + Film Film + Heading styles (levels 1–{n}) Styly nadpisů (úrovně 1–{n}) + Heading {n} Nadpis {n} + I, II, III, … I, II, III, … + Include page numbers Zahrnout čísla stránek + Indented Odsazený + Institution Instituce + Ionosphere Ionosféra + Issue Číslo + Journal Časopis + Label: Popisek: + Last, First; Last, First Příjmení, Jméno; Příjmení, Jméno + Leave the label out of the caption Vynechat popisek z titulku + Letter headings Abecední nadpisy + Levels: Úrovně: + Link entries to their headings Propojit položky s nadpisy + Made from the author and year when empty Pokud zůstane pole prázdné, vytvoří se z autora a roku + Magazine or newspaper article Článek v časopise nebo novinách + Main entry: Hlavní položka: + Mark Označit + Mark All Označit vše + Mark Entry… Označit položku… + Mark Index Entry Označit položku rejstříku + Mesosphere Mezosféra + Month Měsíc + New Label Nový popisek + New Label… Nový popisek… + New… Nový… + No sources yet. Zatím žádné prameny. + Numbering… Číslování… + Options… Možnosti… + Other Jiné + Other styles and their TOC level: Další styly a jejich úroveň v obsahu: + Outline levels Úrovně osnovy + Page number style Styl čísel stránek + Page numbers at the right margin Čísla stránek u pravého okraje + Page title Název stránky + Pages of bookmark: Stránky záložky: + Periodical Periodikum + Position: Umístění: + Publisher Vydavatel + Run-in V jednom odstavci + See Viz + Separator: Oddělovač: + Show every bibliography field Zobrazit všechna pole bibliografie + Site name Název webu + Source Manager Správce pramenů + Source type: Typ pramene: + Sources in this document: Prameny v tomto dokumentu: + Styles: Styly: + Subentry: Podpoložka: + TC entry fields Pole položek obsahu (TC) + Tab leader: Vodicí znak tabulátoru: + Table of Contents Options Možnosti obsahu + Table of Contents Styles Styly obsahu + Tag Značka + This page Tato stránka + Title Název + Type: Typ: + URL URL + Volume Ročník + Website Webová stránka + Year Rok + a, b, c, … a, b, c, … + i, ii, iii, … i, ii, iii, … + {n} entries marked. Označeno položek: {n}. + Lasso Select Výběr lasem + Move Selected Ink Přesunout vybraný rukopis + Delete Selected Ink Odstranit vybraný rukopis + Recolor Selected Ink Přebarvit vybraný rukopis + Add Pen Přidat pero + Remove Pen Odebrat pero + Ink to Shape Rukopis na tvar + Convert to Shapes Převést na tvary + Ink Replay Přehrát rukopis + Add\nPen Přidat\npero + Rewind Přetočit zpět + Forward Vpřed + Stroke {n} of {total} Tah {n} z {total} + Add line numbering Přidat číslování řádků + Add to Document Přidat do dokumentu + Address labels Adresní štítky + Arabic Arabština + Auto Automaticky + Automatically hyphenate document Automaticky dělit slova v dokumentu + Chinese (Simplified) Čínština (zjednodušená) + Chinese (Traditional) Čínština (tradiční) + Column: Sloupec: + Count by: Číslovat po: + Czech Čeština + Danish Dánština + Delivery address: Dodací adresa: + Detect language automatically Rozpoznat jazyk automaticky + Do not check spelling or grammar Nekontrolovat pravopis ani gramatiku + Dutch Nizozemština + English (United Kingdom) Angličtina (Spojené království) + Envelope size: Velikost obálky: + Estonian Estonština + Finnish Finština + French (France) Francouzština (Francie) + From text: Od textu: + Full page of the same label Celá stránka stejných štítků + German (Germany) Němčina (Německo) + Greek Řečtina + Hebrew Hebrejština + Hyphenate at: Rozdělit slovo v místě: + Hyphenate words in CAPS Dělit slova psaná VELKÝMI PÍSMENY + Hyphenation Options… Možnosti dělení slov… + Hyphenation zone: Pásmo dělení slov: + Italian Italština + Japanese Japonština + Korean Korejština + Limit consecutive hyphens to: Omezit počet po sobě jdoucích rozdělení: + Line Numbering Options… Možnosti číslování řádků… + Manual Hyphenation Ruční dělení slov + Manual hyphenation is complete. Ruční dělení slov je dokončeno. + Manual… Ručně… + Mark selected text as: Označit vybraný text jako: + New Document Nový dokument + No limit Bez omezení + Norwegian (Bokmål) Norština (Bokmål) + Numbering: Číslování: + Omit Vynechat + One label per recipient Jeden štítek na příjemce + Polish Polština + Portuguese (Brazil) Portugalština (Brazílie) + Portuguese (Portugal) Portugalština (Portugalsko) + Return address labels Štítky se zpáteční adresou + Return address: Zpáteční adresa: + Row: Řádek: + Russian Ruština + Serbian (Cyrillic) Srbština (cyrilice) + Serbian (Latin) Srbština (latinka) + Set As Default Nastavit jako výchozí + Shipping labels Přepravní štítky + Single label Jeden štítek + Spanish (Mexico) Španělština (Mexiko) + Spanish (Spain) Španělština (Španělsko) + Swedish Švédština + Turkish Turečtina + Ukrainian Ukrajinština + {language} is now the document's default language. Výchozím jazykem dokumentu je nyní {language}. + +# Compare / Combine, AutoCorrect Options and Restrict Editing (#412) + 1. Formatting restrictions 1. Omezení formátování + 2. Editing restrictions 2. Omezení úprav + 3. Start enforcement 3. Zahájení ochrany + << Less << Méně + Another file Jiný soubor + Apply as you type Používat při psaní + AutoCorrect Automatické opravy + AutoFormat As You Type Automatický formát při psaní + Automatic bulleted lists Automatické seznamy s odrážkami + Automatic numbered lists Automatické číslované seznamy + Border lines Čáry ohraničení + Capitalize first letter of sentences Velké první písmeno ve větách + Capitalize first letter of table cells Velké první písmeno v buňkách tabulky + Capitalize names of days Velké první písmeno v názvech dnů + Case changes Změny velikosti písmen + Character level Úroveň znaků + Checked styles are allowed: Povolené jsou zaškrtnuté styly: + Combine Documents Zkombinovat dokumenty + Compare Documents Porovnat dokumenty + Compared: {changes} changes Porovnáno: počet změn: {changes} + Comparing with another file needs the desktop app. Porovnání s jiným souborem vyžaduje desktopovou aplikaci. + Comparison settings Nastavení porovnání + Don't capitalize after: Nepsat velké písmeno po: + Editing in this document is restricted. Úpravy v tomto dokumentu jsou omezeny. + Every change is recorded as a tracked change. Každá úprava se zaznamená jako sledovaná změna. + Everyone Všichni + Exceptions (optional) Výjimky (volitelné) + Exceptions… Výjimky… + Fields Pole + Filling in forms Vyplňování formulářů + Find Next Editable Region Najít další upravitelnou oblast + Footnotes and endnotes Poznámky pod čarou a vysvětlivky + Formatting, case changes, white space and tables are compared; the other settings are kept for later. Porovnává se formátování, změny velikosti písmen, mezery a tabulky; ostatní nastavení jsou zachována pro pozdější využití. + Fractions (1/2) as fraction characters (½) Zlomky (1/2) jako znaky zlomků (½) + Headers and footers Záhlaví a zápatí + Hyphens (--) as dashes (—) Spojovníky (--) jako pomlčky (—) + In equations, typing a name after a backslash replaces it with its symbol. V rovnicích se název napsaný za zpětným lomítkem nahradí příslušným symbolem. + Internet addresses as links Internetové adresy jako odkazy + Label changes with: Označit změny štítkem: + Limit formatting to a selection of styles Omezit formátování na vybrané styly + Math AutoCorrect Automatické opravy matematiky + More >> Více >> + Moves Přesuny + New document Nový dokument + No changes (read only) Žádné změny (jen pro čtení) + Only allow this kind of editing: Povolit pouze tento typ úprav: + Only comments can be added, except in regions marked editable. Lze přidávat pouze komentáře, kromě oblastí označených jako upravitelné. + Only form fields can be filled in. Lze vyplňovat pouze formulářová pole. + Only the allowed styles can be applied. Lze použít pouze povolené styly. + Ordinals (1st) as superscript Řadové číslovky (1st) s horním indexem + Original document Původní dokument + This password type isn't supported. Tento typ hesla není podporován. + Replace as you type Nahrazovat při psaní + Replace text as you type Nahrazovat text při psaní + Replace: Nahradit: + Revised document Upravený dokument + Select text, then tick who can still edit it. Vyberte text a zaškrtněte, kdo jej může nadále upravovat. + Settings… Nastavení… + Show changes at: Zobrazit změny na úrovni: + Show changes in: Zobrazit změny v: + Smart quotes instead of straight quotes Typografické uvozovky místo rovných + Start Enforcing Protection Zahájit vynucování ochrany + Stop Protection Ukončit ochranu + Text boxes Textová pole + The document is read only, except for regions marked editable. Dokument je určen jen pro čtení, kromě oblastí označených jako upravitelné. + The password is optional; without it anyone can stop the protection. Heslo je volitelné; bez něj může ochranu ukončit kdokoliv. + This document Tento dokument + Tracked changes Sledované změny + Type the password to stop the protection. Zadejte heslo pro ukončení ochrany. + White space Mezery + With: Čím: + Word level Úroveň slov + +# Developer & Content Controls (#381) + Developer Vývojář + Controls Ovládací prvky + Rich Text Content Control Ovládací prvek obsahu s formátovaným textem + Plain Text Content Control Ovládací prvek obsahu s prostým textem + Picture Content Control Ovládací prvek obsahu s obrázkem + Building Block Gallery Content Control Ovládací prvek obsahu galerie stavebních bloků + Check Box Content Control Ovládací prvek obsahu zaškrtávacího políčka + Combo Box Content Control Ovládací prvek obsahu pole se seznamem + Drop-Down List Content Control Ovládací prvek obsahu rozevíracího seznamu + Date Picker Content Control Ovládací prvek obsahu pro výběr data + Repeating Section Content Control Ovládací prvek obsahu opakujícího se oddílu + Design Mode Režim návrhu + Content Control at Selection Ovládací prvek obsahu ve výběru + Toggle Check Box Přepnout zaškrtávací políčko + Choose List Item Vybrat položku seznamu + Set Date Nastavit datum + Remove Content Control Odebrat ovládací prvek obsahu + Content Control Properties Vlastnosti ovládacího prvku obsahu + Placeholder text Zástupný text + Can't be deleted Nelze odstranit + Contents can't be edited Obsah nelze upravovat + Remove the control once edited Po úpravě ovládací prvek odebrat + Allow line breaks Povolit zalomení řádků + Date format Formát data + Checked symbol Symbol zaškrtnutí + Unchecked symbol Symbol nezaškrtnutí + List entries Položky seznamu + Display text Zobrazený text + Value Hodnota + Today Dnes + Put the caret in a content control first. Nejprve umístěte kurzor do ovládacího prvku obsahu. + +# WordArt and connectors (#416) + WordArt Styles Styly WordArt + Text Fill Výplň textu + Text Outline Obrys textu + Transform Transformace + No Fill Bez výplně + No Outline Bez obrysu + Reflection Odraz + No Reflection Bez odrazu + No Transform Bez transformace + Connect Shapes Propojit tvary + Straight Connector Přímá spojnice + Straight Arrow Connector Přímá spojnice se šipkou + Elbow Connector Lomená spojnice + Elbow Arrow Connector Lomená spojnice se šipkou + Curved Connector Zakřivená spojnice + Curved Arrow Connector Zakřivená spojnice se šipkou + Arch Up Oblouk nahoru + Arch Down Oblouk dolů + Slant Up Zkosení nahoru + Slant Down Zkosení dolů + Inflate Nafouknutí + Deflate Vyfouknutí + Chevron Up Stříška nahoru + Chevron Down Stříška dolů + Triangle Up Trojúhelník nahoru + Triangle Down Trojúhelník dolů + Solid Accent Plná barva zvýraznění + Light with Outline Světlý s obrysem + Hollow Obrysový + Accent Gradient Přechod barvy zvýraznění + Dark with Shadow Tmavý se stínem + Soft Glow Jemná záře + Accent with Reflection Barva zvýraznění s odrazem + Warm Gradient with Outline Teplý přechod s obrysem + +# Interface strings added on main since this catalog was first written. + As you type Při psaní + Asian text font: Písmo pro asijský text: + AutoSave writes your changes to the file a moment after you stop typing. Automatické ukládání zapíše změny do souboru chvíli poté, co přestanete psát. + Centimeters Centimetry + Check spelling as you type Kontrolovat pravopis při psaní + Click to switch back to inserting Kliknutím se vrátíte do režimu vkládání + Custom dictionary Vlastní slovník + Formatting marks Formátovací značky + Hidden text Skrytý text + Ignore web and file addresses Ignorovat webové adresy a cesty k souborům + Ignore words in capitals Ignorovat slova psaná velkými písmeny + Ignore words with numbers Ignorovat slova obsahující číslice + Inches Palce + Formatting exceptions Výjimky formátování + Initials: Iniciály: + Interface Rozhraní + Interface language Jazyk rozhraní + Inside: Uvnitř: + Latin text font: Písmo pro latinkový text: + Mark grammar errors as you type Označovat gramatické chyby při psaní + Measurement units: Jednotky měření: + Millimeters Milimetry + Mirror indents Zrcadlové odsazení + New comments and tracked changes are recorded under this name and these initials. Nové komentáře a sledované změny se zaznamenávají pod tímto jménem a iniciálami. + New word Nové slovo + No words yet. Add to Dictionary on a misspelled word puts it here. Zatím žádná slova. Sem se ukládají slova přidaná příkazem Přidat do slovníku u chybně napsaného slova. + Overtype Přepisování + Overtype (typing replaces the characters after the caret) Přepisování (psaný text nahrazuje znaky za kurzorem) + Page display Zobrazení stránky + Paragraph marks Značky odstavců + Personalize Přizpůsobení + Picas Pika + Points Body + Print the page color Tisknout barvu stránky + Printing Tisk + Proofing language Jazyk kontroly pravopisu + Recent documents to show: Počet zobrazených posledních dokumentů: + Outside: Vně: + Save documents Ukládání dokumentů + Show the mini toolbar when text is selected Při výběru textu zobrazit miniaturní panel nástrojů + Spaces Mezery + Spelling Pravopis + Spelling and grammar are checked in English (United States). Pravopis a mluvnice se kontrolují v angličtině (Spojené státy). + Tab characters Znaky tabulátoru + These marks show even when Show/Hide ¶ is off. Tyto značky se zobrazují, i když je vypnuto Zobrazit/skrýt ¶. + Typing replaces selected text Psaný text nahrazuje vybraný text + Update fields before printing Před tiskem aktualizovat pole + Use the Insert key to switch overtype on and off Přepínat režim přepisování klávesou Insert + Extend Selection by Units Rozšířit výběr po jednotkách + Animation Animace + Animate the caret (it glides and fades) Animovat kurzor (plynule se přesouvá a prolíná) + On: the caret glides to its new place when you type or use the arrow keys, and its blink fades. Off: it jumps and blinks on and off. Zapnuto: kurzor se při psaní nebo použití kláves se šipkami plynule přesune na nové místo a jeho blikání se prolíná. Vypnuto: kurzor přeskakuje a blikání se ostře zapíná a vypíná. + Word XML Document (*.xml) Dokument Word XML (*.xml) + Deleted Odstraněno + Resize Table by Dragging Změnit velikost tabulky přetažením + Print Markup Tisknout revize + Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. Komentáře vedle každé stránky a sledované změny v souborech PDF, výtiscích a obrázcích; seznam komentářů na konci webových stránek, Markdownu a textu. + Add Font… Přidat písmo… + Added Fonts Přidaná písma + Remove Added Font Odebrat přidané písmo + Recent Colors Poslední barvy + More Colors… Další barvy… + Standard Standardní + Current Aktuální + Colors: Barvy: + Color model: Barevný model: + Red: Červená: + Green: Zelená: + Blue: Modrá: + Hue: Odstín: + Sat: Sytost: + Lum: Jas: + Hex: Hex: + Templates Šablony + Templates and Add-ins Šablony a doplňky + Attach Template Připojit šablonu + Detach Template Odpojit šablonu + Automatically Update Document Styles Automaticky aktualizovat styly dokumentu + Update Styles from Template Aktualizovat styly ze šablony + Organizer Organizátor + Copy Styles Kopírovat styly + Rename Style Přejmenovat styl + Document template: Šablona dokumentu: + Attach… Připojit… + Automatically update document styles Automaticky aktualizovat styly dokumentu + Organizer… Organizátor… + Documents Dokumenty + Word document Dokument Word + All files Všechny soubory + Recipient lists Seznamy příjemců + The system file dialog isn't available, so WordCraft shows its own. To get the system dialog, install a file chooser portal (such as xdg-desktop-portal-gtk) or zenity. Systémový dialog pro práci se soubory není k dispozici, proto WordCraft zobrazuje vlastní. Chcete-li používat systémový dialog, nainstalujte portál pro výběr souborů (například xdg-desktop-portal-gtk) nebo zenity. + Up one level O úroveň výš + Home folder Domovská složka + {name} wasn't found. {name} nebyl nalezen. + {name} already exists. Choose Save again to replace it. {name} již existuje. Dalším zvolením Uložit jej nahradíte. + This folder can't be read: {error} Tuto složku nelze přečíst: {error} + Showing the first {count} items. Zobrazuje se prvních {count} položek. + File name: Název souboru: + Browse in WordCraft… Procházet ve WordCraftu… + Open a file with WordCraft's own browser when the system file dialog doesn't appear. Otevřít soubor pomocí vlastního prohlížeče souborů WordCraftu, když se systémový dialog pro práci se soubory nezobrazí. + Hide White Space Skrýt prázdné místo + All pages Všechny stránky + Current page Aktuální stránka + Selected pages Vybrané stránky + Custom range Vlastní rozsah + All pages in the range Všechny stránky v rozsahu + Odd pages only Pouze liché stránky + Even pages only Pouze sudé stránky + Copies Kopie + Collate copies Kompletovat kopie + Pages like 1-3, 5 Stránky, např. 1-3, 5 + {count} of {total} pages will print Vytiskne se {count} z {total} stránek + Save these pages as PDF… Uložit tyto stránky jako PDF… + The printer, two-sided printing and pages per sheet are chosen in that dialog. Tiskárna, oboustranný tisk a počet stránek na list se volí v tomto dialogu. + Previous Page Předchozí stránka + Print to PDF Tisk do PDF + Add Shape Přidat tvar + Change Colors Změnit barvy + Change Layout Změnit rozložení + Create Graphic Vytvořit obrázek + Edit Items Upravit položky + Layouts Rozložení + Reset Graphic Obnovit obrázek + SmartArt Design Návrh SmartArt + SmartArt Styles Styly SmartArt + Text Pane Podokno textu + List Seznam + Process Proces + Cycle Cyklus + Hierarchy Hierarchie + Relationship Vztah + Pyramid Pyramida + Basic List Základní seznam + Basic Process Základní proces + Basic Cycle Základní cyklus + Basic Pyramid Základní pyramida + Basic Radial Základní paprskový + Basic Matrix Základní matice + Basic Venn Základní Vennův diagram + Shows items of equal weight as blocks in rows. Zobrazí položky se stejnou vahou jako bloky v řádcích. + Shows steps in order, left to right, with arrows between them. Zobrazí kroky v pořadí zleva doprava se šipkami mezi nimi. + Shows steps that repeat, around a circle. Zobrazí opakující se kroky v kruhu. + Shows a tree: each item below the item it belongs to. Zobrazí strom: každá položka je pod položkou, ke které patří. + Shows parts that build on each other, largest at the bottom. Zobrazí části, které na sobě staví, s největší dole. + Shows items around a central idea: the first item is the centre. Zobrazí položky kolem ústřední myšlenky: první položka je uprostřed. + Shows parts of a whole as quadrants. Zobrazí části celku jako kvadranty. + Shows overlapping ideas as overlapping circles. Zobrazí překrývající se myšlenky jako překrývající se kruhy. + Accent {n} Zvýraznění {n} + Colorful Barevné + Insert SmartArt Vložit SmartArt + One line per item. Enter adds an item. Jeden řádek na položku. Klávesa Enter přidá položku. + Add\nShape Přidat\ntvar + Change\nColors Změnit\nbarvy + Reset\nGraphic Obnovit\nobrázek + Promote to Heading 1 Povýšit na Nadpis 1 + Demote to Body Text Snížit na základní text + Move Up Přesunout nahoru + Move Down Přesunout dolů + Collapse Sbalit + Expand or Collapse Heading Rozbalit nebo sbalit nadpis + Select Heading and Content Vybrat nadpis a obsah + Show Level Zobrazit úroveň + Show Level 1 Zobrazit úroveň 1 + Show Level 2 Zobrazit úroveň 2 + Show Level 3 Zobrazit úroveň 3 + Show Level 4 Zobrazit úroveň 4 + Show Level 5 Zobrazit úroveň 5 + Show Level 6 Zobrazit úroveň 6 + Show Level 7 Zobrazit úroveň 7 + Show Level 8 Zobrazit úroveň 8 + Show Level 9 Zobrazit úroveň 9 + Show All Levels Zobrazit všechny úrovně + All Levels Všechny úrovně + Show First Line Only Zobrazit jen první řádek + Show Text Formatting Zobrazit formátování textu + Close Outline View Zavřít zobrazení osnovy + Outline State Stav osnovy + Outlining Osnova + Outline Tools Nástroje osnovy + Close Outline\nView Zavřít\nzobrazení osnovy + Use wildcards Používat zástupné znaky + Special Speciální + Match prefix Shoda předpony + Match suffix Shoda přípony + Ignore punctuation characters Ignorovat interpunkční znaky + Ignore white-space characters Ignorovat prázdné znaky + Regular expressions Regulární výrazy + Search Options Možnosti hledání + Tab Character Znak tabulátoru + Any Character Libovolný znak + Any Digit Libovolná číslice + Any Letter Libovolné písmeno + Caret Character Znak stříšky + Section Character Znak paragrafu (§) + Paragraph Character Znak odstavce + Graphic Obrázek + Manual Line Break Ruční zalomení řádku + Manual Page Break Ruční konec stránky + Section Break Konec oddílu + White Space Prázdné místo + Clipboard Contents Obsah schránky + Find What Text Text pole Najít + Find What Expression Výraz pole Najít + Character in Range Znak v rozsahu + Beginning of Word Začátek slova + End of Word Konec slova + Expression Výraz + Not Negace + Num Occurrences Počet výskytů + Previous 1 or More Předchozí 1 nebo více + 0 or More Characters 0 nebo více znaků + Move with Text Přesouvat s textem + Fix Position on Page Ukotvit pozici na stránce + Visual effects Vizuální efekty + Animation effects: Efekty animace: + On Zapnuto + Off Vypnuto + Smooth scrolling and zooming, and menus and buttons that fade in. Off shows every change at once. Plynulé posouvání a přibližování a postupně se zobrazující nabídky a tlačítka. Při vypnutí se každá změna zobrazí okamžitě. + Your system has animation effects turned on. Efekty animace jsou v systému zapnuté. + Your system has animation effects turned off. Efekty animace jsou v systému vypnuté. + Your system doesn't report an animation setting, so animation effects stay on. Systém neuvádí žádné nastavení animací, proto efekty animace zůstávají zapnuté. + Do Not Check Spelling or Grammar Nekontrolovat pravopis ani gramatiku diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 8054ac3c7..cb2c0901b 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -52,7 +52,7 @@ pub struct LangInfo { } /// The registry. English first: it is the fallback and the source language. -pub static LANGUAGES: [LangInfo; 15] = [ +pub static LANGUAGES: [LangInfo; 16] = [ LangInfo { code: "en", name: "English", source: "", prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, // Simplified Chinese; `zh`, `zh-CN`, `zh-SG` and `zh-Hans-*` resolve here (see `candidates`). LangInfo { @@ -111,6 +111,7 @@ pub static LANGUAGES: [LangInfo; 15] = [ editing_lang: Some("pl-PL"), catalog: OnceLock::new(), }, + LangInfo { code: "cs", name: "Čeština", source: include_str!("cs.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, LangInfo { code: "et", name: "Eesti", source: include_str!("et.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, // German, neutral across Germany, Austria and Switzerland; `de`, `de-DE`, `de-AT`, `de-CH` … // resolve here. diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index d90053290..7cd37d81a 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -347,6 +347,65 @@ fn bundled_interface_fonts_cover_brazilian_portuguese_without_system_fallbacks() } } +#[test] +fn czech_locales_and_saved_preference_work_without_changing_the_document() { + let cs = lang("cs"); + for tag in ["cs", "cs-CZ", "cs_CZ.UTF-8", "CS", "cs-Latn-CZ", "cs_CZ.UTF-8@euro"] { + assert_eq!(lang_from_tag(tag), Some(cs), "{tag}"); + } + assert_eq!(first_supported(["fr-FR", "cs-CZ", "en-US"]), Some(cs)); + let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_engine::sample::sample_document()), Default::default()); + let before = serde_json::to_value(&app.session.doc).unwrap(); + let result = app.run("ui.language", serde_json::json!({"value": "CS"})).unwrap(); + assert_eq!(result["effective"], "cs"); + assert_eq!(app.ui.language, "cs"); + assert_eq!(serde_json::to_value(&app.session.doc).unwrap(), before); + let saved = serde_json::to_string(&app.ui).unwrap(); + let restored: crate::UiState = serde_json::from_str(&saved).unwrap(); + assert_eq!(Lang::from_pref(&restored.language), cs); + assert_eq!(cs.name(), "Čeština"); +} + +#[test] +fn czech_covers_the_entire_existing_interface_catalog() { + use std::collections::HashSet; + let keys = |source| parse_entries(source).0.into_iter().map(|e| e.source).collect::>(); + assert_eq!(keys(lang("cs").0.source), keys(lang("zh-hans").0.source)); + let cs = lang("cs"); + assert_eq!(tr(cs, "Home"), "Domů"); + assert_eq!(tr(cs, "Font"), "Písmo"); + assert_eq!(tr(cs, "Review"), "Revize"); + assert_eq!(tr(cs, "Save"), "Uložit"); + assert_eq!(tr(cs, "unknown future label"), "unknown future label"); + assert_eq!(fmt(tr(cs, "Exported {path}"), &[("path", "draft-{words}.docx")]), "Exportováno: draft-{words}.docx"); + // Count-neutral wording is grammatical across all Czech plural categories (1, 2–4, 0/5+). + for count in [0, 1, 2, 3, 4, 5, 11, 21, 100, 1000] { + let words = count.to_string(); + assert_eq!(fmt(tr(cs, "{words} words"), &[("words", &words)]), format!("Počet slov: {count}")); + assert_eq!(fmt(tr(cs, "{selected} of {words} words"), &[("selected", "1"), ("words", &words)]), format!("Počet slov: {words}; vybráno: 1")); + } +} + +#[test] +fn bundled_interface_fonts_cover_czech_without_system_fallbacks() { + let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new()); + let (entries, errors) = parse_entries(lang("cs").0.source); + assert!(errors.is_empty()); + let chars: std::collections::HashSet = entries + .iter() + .flat_map(|e| e.translation.chars()) + .filter(|c| ('\u{00C0}'..='\u{017F}').contains(c)) + .chain("ÁáČčĎďÉéĚěÍíŇňÓóŘřŠšŤťÚúŮůÝýŽž".chars()) + .collect(); + for (family, style) in [("Inter", "Regular"), ("Inter", "Medium"), ("Inter", "SemiBold"), ("JetBrains Mono", "Regular")] { + let face = db.face(family, style); + assert_eq!(face.family, family, "must use the bundled interface face"); + for ch in &chars { + assert_ne!(face.glyph_for(*ch), 0, "{family} {style} lacks {ch}"); + } + } +} + #[test] fn chinese_interface_fonts_load_and_cover_simplified_hanzi() { // #241: without an embedded Chinese face the interface adds an installed CJK font; egui must diff --git a/docs/control-protocol.md b/docs/control-protocol.md index cb261352d..ff021c9a4 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -50,7 +50,7 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key. | Method | Params | Result | |---|---|---| -| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog`, `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"|"en"|"zh-hans"|"zh-hant"|"ja"|"pt-br"|"es"|"uk"|"sr"|"sr-latn"|"et"|"tr"|"ru"|"pl"|"de"|"it"}`), `ui.theme` (`{"value": "system"|"light"|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"percent": 80–200}`, clamped; no value reads it; scales the interface on top of the system display scale, document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"|"on"|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"|"picture"|"recipients"|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.caretAnimation` (`{"value": true|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading)…) | +| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog`, `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"|"en"|"zh-hans"|"zh-hant"|"ja"|"pt-br"|"es"|"uk"|"sr"|"sr-latn"|"et"|"tr"|"ru"|"pl"|"de"|"it"|"cs"}`), `ui.theme` (`{"value": "system"|"light"|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"percent": 80–200}`, clamped; no value reads it; scales the interface on top of the system display scale, document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"|"on"|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"|"picture"|"recipients"|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.caretAnimation` (`{"value": true|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading)…) | | `engine.commands` | — | every command: id, label, location, shortcut, params, enabled | | `document.inspect` | `text?` | blocks (text, style, runs, lists, tables), parts, sections, selection, pages | | `ui.inspect` | — | UI state, view, dialog, window size, page rects on screen, caret, comment balloons (`balloons`: id + screen rect; `balloon`: the selected one), perf | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 0b06f2028..288556f3e 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -19,8 +19,8 @@ native-speaker review (human). ## What WordCraft ships (measured) -- **15 interface languages**: English plus `zh-hans`, `zh-hant`, `ja`, `uk`, `es`, `pt-br`, - Serbian `sr`/`sr-latn` (#250), Estonian `et`, Turkish `tr`, Russian `ru`, Italian `it` (#594), German `de` (#574) and Polish `pl`. Catalogs have 2,021–2,399 entries +- **16 interface languages**: English plus `zh-hans`, `zh-hant`, `ja`, `uk`, `es`, `pt-br`, + Serbian `sr`/`sr-latn` (#250), Estonian `et`, Turkish `tr`, Russian `ru`, Italian `it` (#594), German `de` (#574), Polish `pl` and Czech `cs`. Catalogs have 2,021–2,399 entries each; Polish has every key of every other catalog (a test checks it). A test (`every_tab_and_command_is_translated`) enforces that every ribbon tab, command label and ribbon location is translated in every language. `pl`, `pl-PL` and `pl_PL.UTF-8` system locales pick Polish automatically. @@ -57,8 +57,8 @@ shared items below. | Korean | ko | 0 | ❌ | Hangul ✅; macOS Korean IME fixes pending (#163, #164) | no | none | 3–5 | | Vietnamese | vi | 0 | ❌ | Latin with stacked diacritics, untested | no | none | 3–4 | -Other shipped languages: **9** — Traditional Chinese (`zh-hant`), Ukrainian (`uk`), Serbian -Cyrillic and Latin (`sr`, `sr-latn`, #250), Estonian (`et`), Turkish (`tr`), Russian (`ru`), Italian (`it`, #594) and Polish (`pl`), all partial. +Other shipped languages: **10** — Traditional Chinese (`zh-hant`), Ukrainian (`uk`), Serbian +Cyrillic and Latin (`sr`, `sr-latn`, #250), Estonian (`et`), Turkish (`tr`), Russian (`ru`), Italian (`it`, #594), Polish (`pl`) and Czech (`cs`), all partial. Estonian uses the existing fonts, follows `et-EE` system locales, and is available in Options or through `ui.language` with `{"value":"et"}`. Its 1,307 entries cover the existing interface catalog. Estonian spelling, grammar, dates and document templates are not added by the interface catalog. @@ -102,6 +102,7 @@ allows); all other text is written from the meaning of the English. No Italian p | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Czech (`cs`) interface catalog added | | 2026-10-11 | minor | Polish interface (13 languages) and Polish proofing: SJP.PL spelling with suggestions, Polish grammar rules, TeX Polish hyphenation; proofing language per run; dimension ~25% → ~27% | | 2026-10-11 | minor | German interface catalog (#574): 2,021 entries covering the full zh-hans key set; locale, saved-preference and bundled-font tests; no German proofing | | 2026-10-11 | minor | Italian interface catalog (#594), locale selection, saved preference and font coverage checks; count labels put the noun first so they read right at 1 | From d3e2e184c23be1778aa1eaca88110ab18d316b9a Mon Sep 17 00:00:00 2001 From: gyurix Date: Mon, 12 Oct 2026 10:18:29 +0200 Subject: [PATCH 08/88] Linux: the display theme asks the desktop, not winit (#298) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Linux: the display theme asks the desktop, not winit winit reports no system theme on Linux — `ActiveEventLoop::system_theme()` is `None` there on every desktop — so "Use system setting" never saw the desktop's choice and fell back to light. A new `desktop_theme` module asks the XDG Desktop Portal's Settings interface for `org.gnome.desktop.interface color-scheme` on a thread of its own, so no D-Bus call is ever made from the frame loop, and the app follows the answer, including a change made while it runs. The colour scheme is asked for under its GNOME schema name first, which GNOME's portal and XFCE's both answer, then under the older freedesktop one, which KDE answers. A desktop that answers neither leaves the user's own choice alone, exactly as before. macOS and Windows are untouched: winit answers there. * Review fixes for #298: one D-Bus connection; watcher started by the app - The watcher keeps one session-bus connection for its lifetime instead of opening a new one every 4 s; a connection whose calls all fail is dropped and reopened on the next poll. - WordApp::new no longer starts the watcher. The desktop app calls WordApp::watch_desktop_theme at start-up, so unit tests, kittest and ui_shot don't spawn D-Bus pollers and what they draw doesn't depend on the host's desktop theme (test: a_new_app_does_not_watch_the_desktop). - A change wakes the window (request_repaint), so an idle app follows a theme switch. Co-Authored-By: Claude Opus 5.5 * Test: name the empty Vec's type (a new dependency adds a PartialEq impl for char) --------- Co-authored-by: GyuriX Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 1 + apps/wordcraft/src/main.rs | 1 + crates/ui-egui/Cargo.toml | 6 + crates/ui-egui/src/desktop_theme.rs | 171 ++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/tests.rs | 2 +- crates/ui-egui/src/lib.rs | 47 +++++++- 6 files changed, 221 insertions(+), 7 deletions(-) create mode 100644 crates/ui-egui/src/desktop_theme.rs diff --git a/Cargo.lock b/Cargo.lock index 0dfdc221f..36f1a7cec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5249,6 +5249,7 @@ dependencies = [ "wordcraft-layout", "wordcraft-render", "wordcraft-zotero", + "zbus", "zip", ] diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index 6c392787b..c5008e90f 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -288,6 +288,7 @@ fn main() -> eframe::Result { let (file_dialog, dialogs) = file_dialogs::hook(&cc.egui_ctx); let mut app = WordApp::new(Session::new(doc), services(file_dialog, &cc.egui_ctx)); load_prefs(&mut app); + app.watch_desktop_theme(&cc.egui_ctx); app.ui.window = restored; app.integrated_titlebar = cfg!(target_os = "macos"); let mut key_file = None; diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 54924d07a..4f2e02d3f 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -25,6 +25,12 @@ image = { workspace = true } # The system's preferred interface languages (#8). Native: OS APIs; wasm: none (English). sys-locale = "0.3" +# Linux only, where winit reports no system theme: the desktop portal's light/dark answer (see +# `desktop_theme.rs`). Already in the tree, with these features, through egui's own accessibility +# bus; pinned to one version so nothing is built twice. +[target.'cfg(target_os = "linux")'.dependencies] +zbus = "=5.19.0" + [target.'cfg(target_arch = "wasm32")'.dependencies] # The browser clock (`SystemTime::now()`/`Instant::now()` panic on wasm32). js-sys = "0.3" diff --git a/crates/ui-egui/src/desktop_theme.rs b/crates/ui-egui/src/desktop_theme.rs new file mode 100644 index 000000000..7f9ed410c --- /dev/null +++ b/crates/ui-egui/src/desktop_theme.rs @@ -0,0 +1,171 @@ +//! The desktop's own light/dark choice, for an interface setting that follows it. +//! +//! winit reports no system theme on Linux — `ActiveEventLoop::system_theme` is `None` on every +//! desktop there — so "Use system setting" has to ask the desktop itself. The XDG Desktop Portal's +//! Settings interface answers on GNOME, KDE and XFCE alike, over X11 and Wayland alike. + +use std::sync::mpsc::{Receiver, TryRecvError, channel}; +use std::time::Duration; + +/// How often [`DesktopTheme::start`] re-reads the desktop's answer. The portal also publishes a +/// change signal, but subscribing needs a connection kept alive for the life of every window; one +/// round trip every few seconds is cheap and reacts soon enough after a theme switch. +const POLL: Duration = Duration::from_secs(4); + +/// Whether the desktop asks applications to draw dark. `None` when it says nothing, and when +/// there is no session bus or no portal to ask (a bare X server, a container), so the caller +/// keeps whatever it chose itself. +pub fn prefers_dark() -> Option { + read(&mut Bus::default()) +} + +/// Watches the desktop and reports its choice when it changes. [`prefers_dark`] covers the +/// answer at start-up; this covers the user switching theme while the app runs. +#[derive(Default)] +pub struct DesktopTheme { + /// `None` when no watcher thread could be started; the one-shot read still works. + changes: Option>>, +} + +impl DesktopTheme { + /// Start watching. Nothing runs before this is called, and calling it once is enough. The + /// first answer is sent straight away, so the caller does not have to ask for it separately. + /// `changed` runs on the watcher's thread after each answer is sent (the app asks for a + /// repaint there, so an idle window picks the change up). The app binary starts this; tests + /// and headless screenshots don't, so they never depend on the machine's desktop theme. + pub fn start(changed: impl Fn() + Send + 'static) -> Self { + let (tx, rx) = channel(); + let started = std::thread::Builder::new().name("wordcraft-desktop-theme".into()).spawn(move || { + // One session-bus connection for the life of the watcher, opened again only after + // it fails. + let mut bus = Bus::default(); + let mut last = read(&mut bus); + if tx.send(last).is_err() { + return; + } + changed(); + loop { + std::thread::sleep(POLL); + let now = read(&mut bus); + if now != last { + last = now; + if tx.send(now).is_err() { + return; + } + changed(); + } + } + }); + Self { changes: started.ok().map(|_| rx) } + } + + /// Whether a watcher thread is running. + pub fn watching(&self) -> bool { + self.changes.is_some() + } + + /// The desktop's new answer if it changed since the last call, to be polled once a frame. + /// Several changes in one frame collapse to the newest, and a desktop that stops answering + /// leaves the last answer in place. + pub fn take_change(&mut self) -> Option { + let Some(changes) = &self.changes else { return None }; + let mut newest = None; + loop { + match changes.try_recv() { + Ok(changed) => newest = changed, + Err(TryRecvError::Empty) | Err(TryRecvError::Disconnected) => return newest, + } + } + } +} + +/// The watcher's session-bus connection, kept between polls. +#[cfg(target_os = "linux")] +#[derive(Default)] +struct Bus(Option); + +#[cfg(not(target_os = "linux"))] +#[derive(Default)] +struct Bus; + +/// Ask the portal once, over `bus`'s connection (opened when there is none). D-Bus calls +/// block, so this belongs off the UI thread. A connection whose calls all fail is dropped, so +/// the next poll opens a fresh one (the bus or portal may have restarted). +#[cfg(target_os = "linux")] +fn read(bus: &mut Bus) -> Option { + const PORTAL: &str = "org.freedesktop.portal.Desktop"; + const SETTINGS: &str = "org.freedesktop.portal.Settings"; + const SCHEMAS: [(&str, &str); 2] = [("org.gnome.desktop.interface", "color-scheme"), ("org.freedesktop.interface.color-scheme", "color-scheme")]; + if bus.0.is_none() { + bus.0 = zbus::blocking::Connection::session().ok(); + } + let connection = bus.0.as_ref()?; + let mut answered = false; + for (schema, key) in SCHEMAS { + let Ok(reply) = connection.call_method(Some(PORTAL), "/org/freedesktop/portal/desktop", Some(SETTINGS), "ReadOne", &(schema, key)) else { + continue; + }; + answered = true; + let Ok((answer,)) = reply.body().deserialize::<(zbus::zvariant::OwnedValue,)>() else { continue }; + if let Ok(scheme) = String::try_from(&*answer) { + return parse(&scheme); + } + } + if !answered { + bus.0 = None; + } + None +} + +/// The portal's `color-scheme` answer. `default` means the desktop states no preference, so +/// neither does the application. +#[cfg(target_os = "linux")] +fn parse(scheme: &str) -> Option { + match scheme { + "prefer-dark" => Some(true), + "prefer-light" => Some(false), + _ => None, + } +} + +/// macOS and Windows answer through winit, which the caller already has. +#[cfg(not(target_os = "linux"))] +fn read(_bus: &mut Bus) -> Option { + None +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The portal answers with one of three strings; only the two preferences are a choice. + #[test] + #[cfg(target_os = "linux")] + fn the_portal_answer_reads_as_a_choice_or_as_none() { + assert_eq!(parse("prefer-dark"), Some(true)); + assert_eq!(parse("prefer-light"), Some(false)); + assert_eq!(parse("default"), None); + assert_eq!(parse(""), None); + } + + /// Off Linux the caller already has winit's answer, so this adds nothing. + #[test] + #[cfg(not(target_os = "linux"))] + fn other_platforms_answer_through_winit() { + assert_eq!(prefers_dark(), None); + } + + /// A watcher that was never started is inert rather than a panic. + #[test] + fn a_watcher_without_a_thread_is_inert() { + assert_eq!(DesktopTheme::default().take_change(), None); + } + + /// A new app doesn't watch the desktop (the app binary starts the watcher), so tests and + /// headless screenshots never depend on the host's theme or spawn D-Bus pollers. + #[test] + fn a_new_app_does_not_watch_the_desktop() { + let app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + assert!(!app.desktop_theme.watching()); + } +} diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 7cd37d81a..7384efc77 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -448,7 +448,7 @@ fn cjk_catalogs_are_covered_by_the_embedded_interface_faces() { assert!(missing.is_empty(), "{code}: no glyph in {font:?} for {missing}"); } // So the interface reads no installed CJK font for these languages. - assert_eq!(wordcraft_fonts::ui_uncovered_cjk(&[l.0.source, &names], &wordcraft_fonts::ui_cjk_fonts(l.prefers_hans())), []); + assert_eq!(wordcraft_fonts::ui_uncovered_cjk(&[l.0.source, &names], &wordcraft_fonts::ui_cjk_fonts(l.prefers_hans())), Vec::::new()); } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 472235b3b..d29e1b809 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -5,6 +5,13 @@ //! the menus, ribbon, shortcuts, control channel and MCP all reach the same behaviour. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +/// What the operating system says about light and dark. macOS and Windows answer through winit; +/// winit reports nothing on Linux, where the desktop portal does ([`desktop_theme`]). `None` means +/// nobody answered, not a preference. +fn system_theme(ctx: &egui::Context, desktop_dark: Option) -> Option { + ctx.system_theme().or_else(|| desktop_dark.map(|dark| if dark { egui::Theme::Dark } else { egui::Theme::Light })) +} + /// An English UI string in the current interface language ([`i18n::t`]). #[macro_export] macro_rules! tl { @@ -23,6 +30,7 @@ pub mod colors; pub mod content_controls; pub mod control; pub mod credits; +pub mod desktop_theme; pub mod dialogs; pub mod dialogs_chart; pub mod dialogs_controls; @@ -311,8 +319,12 @@ pub struct WordApp { /// File ▸ Options: load the installed CJK fallback font if no embedded face covers it. pub(crate) want_system_cjk: bool, fonts_frames: u32, - /// The interface theme setting last installed in egui ([`theme::apply`]). + /// The effective interface theme last installed in egui ([`theme::apply`]). applied_theme: Option, + /// The desktop's light/dark choice, watched while the app runs (Linux has no winit answer). + desktop_theme: desktop_theme::DesktopTheme, + /// The last answer [`desktop_theme`] gave, so a frame that asks costs nothing. + desktop_dark: Option, /// The interface language whose editing language the session last got ([`i18n::Lang::editing_lang`]). applied_editing: Option, /// What the system last said about its animation effects ([`Services::animations_enabled`]). @@ -401,6 +413,9 @@ impl WordApp { want_system_cjk: false, fonts_frames: 0, applied_theme: None, + // Inert until the app binary calls `watch_desktop_theme`. + desktop_theme: desktop_theme::DesktopTheme::default(), + desktop_dark: None, applied_editing: None, system_animations: None, applied_animations: None, @@ -803,9 +818,17 @@ impl WordApp { } } + /// Follow the desktop's light/dark choice while the app runs (Linux, where winit reports no + /// system theme; see [`desktop_theme`]). The desktop app calls this once at start-up; unit + /// tests and `ui_shot` don't, so what they draw doesn't depend on the host's desktop theme. + pub fn watch_desktop_theme(&mut self, ctx: &egui::Context) { + let ctx = ctx.clone(); + self.desktop_theme = desktop_theme::DesktopTheme::start(move || ctx.request_repaint()); + } + /// Whether the interface theme setting currently resolves to dark (`System` asks the OS). pub fn ui_is_dark(&self) -> bool { - self.ui.theme.is_dark(self.ctx.as_ref().and_then(egui::Context::system_theme)) + self.ui.theme.is_dark(self.ctx.as_ref().and_then(|ctx| system_theme(ctx, self.desktop_dark))) } /// Whether animation effects are on: the Animation effects setting, where `System` follows @@ -1142,12 +1165,24 @@ impl WordApp { ctx.options_mut(|o| o.zoom_with_keyboard = false); self.styled = true; } + if let Some(answer) = self.desktop_theme.take_change() { + self.desktop_dark = Some(answer); + } // Both palettes are installed and egui picks one: with `System` it follows an OS appearance - // change live (it reports it and repaints, #311). The interface follows the Interface theme + // change live (winit reports it and repaints, #311). Where winit is silent (Linux) the + // desktop portal's answer fills in instead. The interface follows the Interface theme // setting alone; Dark page only changes how pages are drawn (#312). - if self.applied_theme != Some(self.ui.theme) { - theme::apply(ctx, self.ui.theme); - self.applied_theme = Some(self.ui.theme); + let effective = match self.ui.theme { + theme::Appearance::System if ctx.system_theme().is_none() => match self.desktop_dark { + Some(true) => theme::Appearance::Dark, + Some(false) => theme::Appearance::Light, + None => theme::Appearance::System, + }, + other => other, + }; + if self.applied_theme != Some(effective) { + theme::apply(ctx, effective); + self.applied_theme = Some(effective); } // Interface size (#475): egui's zoom factor multiplies the display scale the system reports // (native pixels per point), so it adds to it rather than replacing it. Keyboard zoom is From f4dfc8eb85b8087d6f1b0bb4488b0705863aff4b Mon Sep 17 00:00:00 2001 From: jeleesky Date: Mon, 12 Oct 2026 16:24:03 +0800 Subject: [PATCH 09/88] Fix CJK fallback mixing serif/sans faces within one run (#567) * Fix CJK fallback mixing serif/sans faces within one run Per-character fallback picked the first covering face in load order, so a run without an East Asian font could draw JIS-covered ideographs in a bundled Japanese Mincho and simplified-only ones in the SC sans, mixing serif and sans glyphs even inside one word. Pick one face covering all of the run's East Asian characters when available, keeping the per-character fallback when none does. * Review fixes for #567: cache the run-level CJK fallback - fallback_for_east_asian is cached per (character set, excluded face, bold, italic) and dropped when the loaded faces change or the system fallback is switched, like the per-character cache, so relayout doesn't redo the search; the cache is bounded in entries and in key size. - loaded_fallback and loaded_covering share one fallback_order helper. - Test for the cache. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/fonts/src/fontdb.rs | 139 +++++++++++++++++++++++++++++++++++++ crates/fonts/src/lib.rs | 24 +++++++ crates/layout/src/para.rs | 18 +++++ 3 files changed, 181 insertions(+) diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 5b21cb4c0..02e3be10c 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -294,6 +294,7 @@ pub struct FontDb { /// search sorts every loaded face, so without this text in a script the requested font lacks /// (Thai, #59) got slower with every font loaded. fallbacks: RwLock, + ea_fallbacks: RwLock, #[cfg(not(target_arch = "wasm32"))] catalog: RwLock>, /// Fonts the user added at run time, and the folder they are stored in (#582). @@ -506,6 +507,33 @@ struct FallbackCache { map: HashMap<(char, u32), Option>>, } +/// [`FontDb::fallback_for_east_asian`] results: (sorted, deduplicated chars, excluded face, bold, +/// italic) → face, valid while the loaded face count is `faces`. +#[derive(Default)] +struct EaFallbackCache { + faces: usize, + /// Chars held in the keys (bounds memory: hostile runs can be long). + chars: usize, + #[allow(clippy::type_complexity)] + map: HashMap<(Vec, u32, bool, bool), Option>>, +} + +/// Most cached run-level East Asian fallbacks, and most chars in their keys, before the cache is +/// reset. +const EA_FALLBACK_CACHE_MAX: usize = 4096; +const EA_FALLBACK_CACHE_CHARS: usize = 1 << 20; + +/// Loaded faces other than `exclude`, in fallback order: for right-to-left text the fonts made +/// for the script first (so a word isn't split across fonts, which breaks Arabic joining), then +/// the fallback family, upright, nearest to regular weight. +fn fallback_order(faces: &[Arc], exclude: u32, rtl: bool) -> Vec<&Arc> { + let mut order: Vec<&Arc> = faces.iter().filter(|f| f.id != exclude).collect(); + let rank = + |f: &FontFace| if rtl { RTL_FALLBACKS.iter().position(|n| f.family.eq_ignore_ascii_case(n)).unwrap_or(RTL_FALLBACKS.len()) } else { 0 }; + order.sort_by_key(|f| (rank(f), !f.family.eq_ignore_ascii_case(FALLBACK_FAMILY), f.italic, (f.weight - 400.0).abs() as i32)); + order +} + const FALLBACK_CACHE_MAX: usize = 20_000; static NEXT_ID: AtomicU32 = AtomicU32::new(1); @@ -844,6 +872,7 @@ impl FontDb { faces: RwLock::new(faces), outlines: Mutex::new(HashMap::new()), fallbacks: RwLock::new(FallbackCache::default()), + ea_fallbacks: RwLock::new(EaFallbackCache::default()), #[cfg(not(target_arch = "wasm32"))] catalog: RwLock::new(Vec::new()), user: RwLock::new(user::UserFonts::default()), @@ -873,6 +902,7 @@ impl FontDb { } // Misses remembered while it was off may be found now. self.fallbacks.write().unwrap_or_else(|e| e.into_inner()).map.clear(); + *self.ea_fallbacks.write().unwrap_or_else(|e| e.into_inner()) = EaFallbackCache::default(); #[cfg(target_arch = "wasm32")] let _ = on; } @@ -1358,6 +1388,88 @@ impl FontDb { (order.into_iter().find(|f| f.covers(c)).cloned(), faces.len()) } + /// One fallback face for a run's East Asian characters: the first face in the fallback order + /// that covers **every** given character, so a whole run draws in a single CJK face instead of + /// mixing faces character by character (a bundled Japanese Mincho for JIS-covered ideographs + /// beside a Simplified-Chinese sans for the rest). Like [`Self::fallback_for`], a system font + /// is loaded lazily the first time no loaded face covers a character. `None` when no single + /// face covers them all, and the caller keeps the per-character fallback. + /// + /// Cached per (character set, excluded face, bold, italic) and dropped when the loaded faces + /// change, like [`Self::fallback_for`], so relayout doesn't repeat the search. + pub fn fallback_for_east_asian(&self, chars: &[char], exclude: u32, bold: bool, italic: bool) -> Option> { + if chars.is_empty() { + return None; + } + let mut set: Vec = chars.to_vec(); + set.sort_unstable(); + set.dedup(); + let key = (set, exclude, bold, italic); + let n = self.read_faces().len(); + { + let cache = self.ea_fallbacks.read().unwrap_or_else(|e| e.into_inner()); + if cache.faces == n + && let Some(hit) = cache.map.get(&key) + { + return hit.clone(); + } + } + let found = self.search_east_asian(&key.0, exclude, bold, italic); + // Keyed by the face count after the search (a system fallback may have loaded fonts). + let n = self.read_faces().len(); + let mut cache = self.ea_fallbacks.write().unwrap_or_else(|e| e.into_inner()); + if cache.faces != n || cache.map.len() >= EA_FALLBACK_CACHE_MAX || cache.chars.saturating_add(key.0.len()) > EA_FALLBACK_CACHE_CHARS { + cache.map.clear(); + cache.faces = n; + cache.chars = 0; + } + cache.chars = cache.chars.saturating_add(key.0.len()); + cache.map.insert(key, found.clone()); + found + } + + fn search_east_asian(&self, chars: &[char], exclude: u32, bold: bool, italic: bool) -> Option> { + let mut base = self.loaded_covering(chars, exclude); + #[cfg(not(target_arch = "wasm32"))] + if base.is_none() { + // A character no loaded face covers: load a system font for it, then retry (the run's + // other characters may already be covered by loaded faces). + for c in chars { + if self.read_faces().iter().any(|f| f.id != exclude && f.covers(*c)) { + continue; + } + if self.system_fallback(*c) + && let Some(f) = self.loaded_covering(chars, exclude) + { + base = Some(f); + break; + } + } + } + let base = base?; + if !bold && !italic { + return Some(base); + } + let style = match (bold, italic) { + (true, true) => "Bold Italic", + (true, false) => "Bold", + _ => "Italic", + }; + let styled = self.face(&base.family, style); + Some(if styled.family.eq_ignore_ascii_case(&base.family) && chars.iter().all(|c| styled.covers(*c)) && styled.id != exclude { + styled + } else { + base + }) + } + + /// The first face in the fallback order that covers every char. + fn loaded_covering(&self, chars: &[char], exclude: u32) -> Option> { + let faces = self.read_faces(); + let rtl = chars.iter().any(|c| crate::is_rtl(*c) || arabic_block(*c)); + fallback_order(&faces, exclude, rtl).into_iter().find(|f| chars.iter().all(|c| f.covers(*c))).cloned() + } + /// Load a system font covering `c` (preferred fallback families first, then any cataloged /// file under 40 MB, found through the coverage index). Returns true if one was loaded. /// Misses are remembered. @@ -1501,6 +1613,33 @@ mod tests_sysfonts; mod tests { use super::*; + #[test] + fn east_asian_run_fallback_is_cached_per_char_set() { + let db = FontDb::with_font_dirs(Vec::new()); + db.set_system_fallback(false); + let latin = db.face(FALLBACK_FAMILY, "Regular"); + let a = db.fallback_for_east_asian(&['分', '类', '分'], latin.id(), false, false).map(|f| f.id()); + // The same set in another order (and without the repeat) is the same entry. + assert_eq!(db.fallback_for_east_asian(&['类', '分'], latin.id(), false, false).map(|f| f.id()), a); + { + let cache = db.ea_fallbacks.read().unwrap(); + assert_eq!(cache.faces, db.read_faces().len()); + assert_eq!(cache.map.len(), 1); + assert!(cache.map.contains_key(&(vec!['分', '类'], latin.id(), false, false))); + } + // Long hostile runs keep the cache bounded. + let many: Vec = (0x4E00u32..0x4E00 + 50_000).filter_map(char::from_u32).collect(); + for i in 0..30 { + let _ = db.fallback_for_east_asian(many.get(i..).unwrap_or(&[]), latin.id(), false, false); + } + let cache = db.ea_fallbacks.read().unwrap(); + assert!(cache.chars <= EA_FALLBACK_CACHE_CHARS, "{}", cache.chars); + // Turning the system fallback on or off forgets remembered misses. + drop(cache); + db.set_system_fallback(false); + assert!(db.ea_fallbacks.read().unwrap().map.is_empty()); + } + #[test] fn legacy_family_keeps_the_style_words_that_are_not_regular_bold_or_italic() { for (family, style, legacy) in [ diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 15c9c2c8f..0d04ac7aa 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -360,6 +360,30 @@ mod tests { assert!(fb.family.contains("Mincho"), "{fb:?}"); } + #[test] + fn east_asian_run_uses_one_face_covering_the_whole_run() { + let db = FontDb::with_font_dirs(Vec::new()); + db.set_system_fallback(false); + let latin = db.face(DEFAULT_FAMILY, "Regular"); + if CRAFT_FONTS.is_empty() { + // No bundled CJK font and no system fallback: nothing can draw the run. + assert!(db.fallback_for_east_asian(&['分', '类'], latin.id(), false, false).is_none()); + return; + } + // JIS-covered ideographs alone would fall back to a Mincho and simplified-only ones to the + // SC sans: the run-level pick must cover every character in one face, or a run mixes + // serif and sans glyphs (the per-character fallback still applies when it returns None). + let run: Vec = "分类监督学习".chars().collect(); + let f = db.fallback_for_east_asian(&run, latin.id(), false, false).expect("a CJK face covers the run"); + for c in &run { + assert!(f.covers(*c), "{f:?} covers {c}"); + } + // Pure Japanese still gets the document serif (Mincho first). + let jp: Vec = "日本語".chars().collect(); + let f2 = db.fallback_for_east_asian(&jp, latin.id(), false, false).expect("a CJK face"); + assert!(f2.family.contains("Mincho"), "{f2:?}"); + } + #[test] fn works_without_craft_fonts() { // Always true without CRAFT_FONTS_DIR; with it, the bundled fonts must still all load. diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 4ab1dafe0..85e8e0c00 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -595,6 +595,22 @@ impl<'a> Builder<'a> { .filter(|r| !r.substituted) .map(|r| r.face); let use_levels = para_text && !self.levels.is_empty(); + // One fallback face for the run's East Asian characters: the first face in fallback order + // covering every character the primary font lacks, so the run draws in a single CJK face + // instead of mixing faces per character (e.g. a bundled Japanese Mincho for JIS-covered + // ideographs beside a Simplified-Chinese sans for the rest). Whitespace and characters the + // primary covers don't constrain the choice. `None` keeps the per-character fallback. + let ea_face: Option = { + let ea: Vec = text + .chars() + .filter(|c| crate::kinsoku::is_east_asian(*c) && !primary.covers(*c) && !c.is_whitespace() && !c.is_control() && *c != SOFT_HYPHEN) + .collect(); + if ea.is_empty() { + None + } else { + wordcraft_fonts::FontDb::global().fallback_for_east_asian(&ea, primary.id(), rc.bold, rc.italic).map(|f| FaceRef::of(&f)) + } + }; // Split by font coverage (fallback faces), small caps case, script formatting and direction. let mut seg_start = 0; let mut cur: Option = None; @@ -612,6 +628,8 @@ impl<'a> Builder<'a> { _ if prim.covers(c) || c.is_whitespace() || c.is_control() || c == SOFT_HYPHEN => None, // Stay in the fallback face the word started in while it covers the letters. Some(SegKey { face: Some(f), .. }) if f.covers(c) => Some(f), + // The run's unified East Asian face, when one face covers the whole run. + _ if !complex && ea_face.is_some() && crate::kinsoku::is_east_asian(c) => ea_face, _ => { let (bold, italic) = if complex { (rc.bold_cs, rc.italic_cs) } else { (rc.bold, rc.italic) }; wordcraft_fonts::FontDb::global().fallback_styled(c, prim.id(), bold, italic).map(|f| FaceRef::of(&f)) From ba9bbb48ddb4a9bb245565a1d208099e09b1a2ef Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Mon, 12 Oct 2026 10:30:57 +0200 Subject: [PATCH 10/88] Open Word documents with WordCraft by default (file associations + Options button) (#304) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Open Word documents with WordCraft from the system (#295) Register WordCraft for Word, RTF and ODT files on Windows and Linux without taking over anyone's defaults, and add File > Options > General > "Make WordCraft the default for Word documents..." where the system allows it. - Windows MSI: a ProgId per type (.docx .docm .dotx .dotm .doc .dot .rtf .odt, plus .md and .txt as Open With candidates only) with the app icon and `"wordcraft.exe" "%1"`, OpenWithProgids, Applications\wordcraft.exe SupportedTypes, and Capabilities + RegisteredApplications so WordCraft shows in Settings > Default apps. New component with its own GUID. - Linux: the desktop entry declares the macro-enabled, template and legacy Word types and text/rtf; AppStream lists the media types. deb, rpm, AppImage and both Flatpak manifests use this same file. - macOS: Info.plist also lists legacy .doc. - The Options button runs `ui.makeDefaultApp` through a new Services::make_default_app hook: Windows opens ms-settings:defaultapps on WordCraft's page; Linux and BSD run `xdg-mime default ai.storyteller.wordcraft.desktop ` (refused inside Flatpak or without an installed desktop entry); macOS shows the Finder Get Info hint; the web shows nothing. The status bar reports the outcome. Strings translated in every catalog. Co-Authored-By: Claude Opus 5.5 * Review fixes for #304: keep Make default out of programmatic dispatch ui.makeDefaultApp is refused when run programmatically (control channel, MCP, macros, scripts); only a click on File > Options > General > Make WordCraft the default… reaches the host's hook, so no agent can change the user's system default apps or open system settings. Test: programmatic_calls_cannot_change_the_default_apps. Co-Authored-By: Claude Opus 5.5 * Default-app strings in the newer catalogs; the button's test looks for a General-pane label --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 2 +- apps/wordcraft/src/default_apps.rs | 181 ++++++++++++++++++ apps/wordcraft/src/main.rs | 7 +- crates/ui-egui/src/i18n/cs.tsv | 7 + crates/ui-egui/src/i18n/de.tsv | 7 + crates/ui-egui/src/i18n/es.tsv | 7 + crates/ui-egui/src/i18n/et.tsv | 7 + crates/ui-egui/src/i18n/it.tsv | 7 + crates/ui-egui/src/i18n/ja.tsv | 7 + crates/ui-egui/src/i18n/pl.tsv | 7 + crates/ui-egui/src/i18n/pt-br.tsv | 7 + crates/ui-egui/src/i18n/ru.tsv | 7 + crates/ui-egui/src/i18n/sr-latn.tsv | 7 + crates/ui-egui/src/i18n/sr.tsv | 7 + crates/ui-egui/src/i18n/tr.tsv | 7 + crates/ui-egui/src/i18n/uk.tsv | 7 + crates/ui-egui/src/i18n/zh-hans.tsv | 7 + crates/ui-egui/src/i18n/zh-hant.tsv | 7 + crates/ui-egui/src/lib.rs | 131 +++++++++++++ crates/ui-egui/src/options.rs | 24 +++ docs/file-format-parity.md | 22 ++- .../linux/ai.storyteller.wordcraft.desktop | 2 +- .../ai.storyteller.wordcraft.metainfo.xml.in | 9 + packaging/macos/Info.plist.in | 1 + packaging/windows/wordcraft.wxs | 74 +++++++ 25 files changed, 546 insertions(+), 12 deletions(-) create mode 100644 apps/wordcraft/src/default_apps.rs diff --git a/ROADMAP.md b/ROADMAP.md index da80ffe8d..cba044024 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -201,7 +201,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 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%) | | 2026-10-10 | DOCX charts, SmartArt and OLE objects survive open and save with their parts (#319) | -| 2026-10-10 | Full re-measure against Word for Mac 16.113.4 and progress docs to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `roadmap.md`, `architecture.md`, format/layout/UI/hardware/localization parity; `docs/parity.md` → `docs/parity-checklist.md`). Landed the same day (~80 PRs): equations with OMML and an Equation tab (#191); Word 97-2003 `.doc` import (#145); right-to-left and Persian text (#207); Zotero integration (#189); Read Aloud player (#190); text boxes and floating objects editable (#46) and placed like Word (#136); floating tables (#137); track-changes fixes (#125, #244); typing parity with Word (#204); hidden text (#173); save prompts and AutoSave rules (#151); LaTeX import/export (#11); keytips and mini-toolbar (#43); Spanish, Ukrainian and Brazilian Portuguese interfaces; system theme (#249); Linux file dialogs no longer freeze (#246); Chinese UI font on Windows (#248); every installed font weight (#239); cell text direction (#245); Paste Special (#235), Advanced Find (#234), Column Selection (#237), Style Inspector (#236), custom table styles (#256), View gridlines (#243), zoom buttons (#232), inertial touchpad scrolling (#252), File Info fields kept (#268), ¶ in mixed-direction paragraphs (#278), Serbian interface (#250), table Height/Width boxes, AutoFit Contents and Table Properties (#44), AutoSave switch greyed out with the reason where it can't save (.md/.txt/.html, unsaved, browser) (#196, #176), OpenGL fallback on Windows PCs without a DirectX 12 driver (#316), Columns, Symbol and Field dialogs (#321), Tabs and Borders and Shading dialogs (#320); free rotation and flips for pictures, shapes and text boxes with a rotation handle (#332); v0.4.0 released | +| 2026-10-10 | Full re-measure against Word for Mac 16.113.4 and progress docs to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `roadmap.md`, `architecture.md`, format/layout/UI/hardware/localization parity; `docs/parity.md` → `docs/parity-checklist.md`). Landed the same day (~80 PRs): equations with OMML and an Equation tab (#191); Word 97-2003 `.doc` import (#145); right-to-left and Persian text (#207); Zotero integration (#189); Read Aloud player (#190); text boxes and floating objects editable (#46) and placed like Word (#136); floating tables (#137); track-changes fixes (#125, #244); typing parity with Word (#204); hidden text (#173); save prompts and AutoSave rules (#151); LaTeX import/export (#11); keytips and mini-toolbar (#43); Spanish, Ukrainian and Brazilian Portuguese interfaces; system theme (#249); Linux file dialogs no longer freeze (#246); Chinese UI font on Windows (#248); every installed font weight (#239); cell text direction (#245); Paste Special (#235), Advanced Find (#234), Column Selection (#237), Style Inspector (#236), custom table styles (#256), View gridlines (#243), zoom buttons (#232), inertial touchpad scrolling (#252), File Info fields kept (#268), ¶ in mixed-direction paragraphs (#278), Serbian interface (#250), table Height/Width boxes, AutoFit Contents and Table Properties (#44), AutoSave switch greyed out with the reason where it can't save (.md/.txt/.html, unsaved, browser) (#196, #176), OpenGL fallback on Windows PCs without a DirectX 12 driver (#316), Columns, Symbol and Field dialogs (#321), Tabs and Borders and Shading dialogs (#320); free rotation and flips for pictures, shapes and text boxes with a rotation handle (#332); v0.4.0 released; file associations on Windows and Linux with a Make-default button in Options (#295) | | 2026-10-09 | Interface languages follow the system or Options (#12; zh-hans, zh-hant, ja); TOC page numbers as an updatable field (#52); rotating log file (#17); window geometry remembered (#38) | | 2026-10-08 | v0.2.0 and v0.3.0 released; Flatpak and AppImage auto-update (#22) | | 2026-10-07 | v0.1.0 released; contributor credits in About (#6) | diff --git a/apps/wordcraft/src/default_apps.rs b/apps/wordcraft/src/default_apps.rs new file mode 100644 index 000000000..8f07b2170 --- /dev/null +++ b/apps/wordcraft/src/default_apps.rs @@ -0,0 +1,181 @@ +//! File › Options › General › Make WordCraft the default for Word documents (#295). +//! +//! The installers register WordCraft for Word, RTF and ODT files without taking over the +//! defaults (`packaging/windows/wordcraft.wxs`, the `.desktop` file's `MimeType=`, `Info.plist`). +//! This is the button that makes it the default when the user asks: +//! - **Windows** doesn't let apps set defaults: it opens Settings › Default apps on WordCraft's +//! page (`ms-settings:defaultapps?registeredAppMachine=WordCraft`; Windows 10 ignores the query +//! and shows the Default apps list), where the user picks WordCraft per type. +//! - **Linux and BSD**: `xdg-mime default ai.storyteller.wordcraft.desktop ` for +//! [`WORD_MIME_TYPES`], which writes the user's `mimeapps.list`. Only when the desktop entry is +//! installed (deb, rpm, Flatpak; a bare AppImage has none), and not from inside the Flatpak +//! sandbox, which can't change the host's defaults. +//! - **macOS** has no API for it here: the Options page explains Finder's Get Info › Change All. +//! +//! [`WORD_MIME_TYPES`]: self::WORD_MIME_TYPES + +use wordcraft_ui_egui::DefaultApp; + +/// The `Services::make_default_app` hook. +pub type Hook = Box Result>; + +/// The hook for this platform; `None` where there is no button (macOS). +pub fn hook() -> Option { + #[cfg(target_os = "windows")] + let hook: Option = Some(Box::new(windows::open_default_apps)); + #[cfg(all(unix, not(target_os = "macos")))] + let hook: Option = Some(Box::new(xdg::make_default)); + #[cfg(not(any(target_os = "windows", all(unix, not(target_os = "macos")))))] + let hook: Option = None; + hook +} + +/// The desktop entry the Linux packages install, and the Flatpak's app id. +#[cfg(any(all(unix, not(target_os = "macos")), test))] +const DESKTOP_ID: &str = "ai.storyteller.wordcraft.desktop"; + +/// The types the button makes WordCraft the default for on Linux and BSD: Word documents and +/// templates (also macro-enabled and legacy), RTF and ODT. The same set the Windows installer +/// lists under WordCraft's Capabilities; Markdown and plain text are only offered under Open With. +/// Every one is in the `.desktop` file's `MimeType=` (a test checks). +#[cfg(any(all(unix, not(target_os = "macos")), test))] +const WORD_MIME_TYPES: &[&str] = &[ + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", // .docx + "application/vnd.ms-word.document.macroEnabled.12", // .docm + "application/vnd.openxmlformats-officedocument.wordprocessingml.template", // .dotx + "application/vnd.ms-word.template.macroEnabled.12", // .dotm + "application/msword", // .doc + "application/msword-template", // .dot + "application/rtf", // .rtf + "text/rtf", // .rtf (older name) + "application/vnd.oasis.opendocument.text", // .odt +]; + +/// The arguments for `xdg-mime` that make `desktop_id` the default for [`WORD_MIME_TYPES`]: one +/// call sets them all. +#[cfg(any(all(unix, not(target_os = "macos")), test))] +fn xdg_mime_args(desktop_id: &str) -> Vec { + ["default", desktop_id].into_iter().chain(WORD_MIME_TYPES.iter().copied()).map(str::to_string).collect() +} + +/// The `applications` folders the desktop looks for entries in, from `XDG_DATA_HOME` (default +/// `~/.local/share`) and `XDG_DATA_DIRS` (default `/usr/local/share:/usr/share`). Relative paths +/// are ignored, as the XDG Base Directory spec says. +#[cfg(any(all(unix, not(target_os = "macos")), test))] +fn application_dirs(data_home: Option<&str>, data_dirs: Option<&str>, home: Option<&str>) -> Vec { + use std::path::{Path, PathBuf}; + let absolute = |s: &&str| Path::new(s).is_absolute(); + let home_dir = data_home + .filter(|s| !s.is_empty()) + .filter(absolute) + .map(PathBuf::from) + .or_else(|| home.filter(|s| !s.is_empty()).filter(absolute).map(|h| Path::new(h).join(".local/share"))); + let system = data_dirs.filter(|s| !s.is_empty()).unwrap_or("/usr/local/share:/usr/share"); + home_dir + .into_iter() + .chain(system.split(':').filter(|s| !s.is_empty()).filter(absolute).map(PathBuf::from)) + .map(|d| d.join("applications")) + .collect() +} + +#[cfg(target_os = "windows")] +mod windows { + use wordcraft_ui_egui::DefaultApp; + + /// Settings › Default apps, on the page of the app registered as `WordCraft` under + /// `HKLM\Software\RegisteredApplications` (the MSI writes it). + const SETTINGS_URI: &str = "ms-settings:defaultapps?registeredAppMachine=WordCraft"; + + pub(super) fn open_default_apps() -> Result { + open::that_detached(SETTINGS_URI).map_err(|e| format!("couldn't open Settings: {e}"))?; + Ok(DefaultApp::SettingsOpened) + } +} + +#[cfg(all(unix, not(target_os = "macos")))] +mod xdg { + use std::process::Command; + + use wordcraft_ui_egui::DefaultApp; + + use super::{DESKTOP_ID, application_dirs, xdg_mime_args}; + + /// The longest error text from `xdg-mime` shown in the status bar. + const MAX_ERROR: usize = 200; + + pub(super) fn make_default() -> Result { + if std::env::var_os("FLATPAK_ID").is_some() || std::path::Path::new("/.flatpak-info").exists() { + return Err( + "the Flatpak sandbox can't change default apps; choose WordCraft under Open With in your file manager or system settings".into() + ); + } + let var = |k: &str| std::env::var(k).ok(); + let dirs = application_dirs(var("XDG_DATA_HOME").as_deref(), var("XDG_DATA_DIRS").as_deref(), var("HOME").as_deref()); + if !dirs.iter().any(|d| d.join(DESKTOP_ID).is_file()) { + return Err(format!("{DESKTOP_ID} isn't installed (an AppImage needs desktop integration first)")); + } + let out = Command::new("xdg-mime").args(xdg_mime_args(DESKTOP_ID)).output().map_err(|e| match e.kind() { + std::io::ErrorKind::NotFound => "xdg-mime isn't installed (it comes with xdg-utils)".to_string(), + _ => format!("couldn't run xdg-mime: {e}"), + })?; + if out.status.success() { + return Ok(DefaultApp::MadeDefault); + } + let stderr = String::from_utf8_lossy(&out.stderr); + let first = stderr.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or(""); + let message: String = first.chars().take(MAX_ERROR).collect(); + Err(if message.is_empty() { format!("xdg-mime failed ({})", out.status) } else { format!("xdg-mime failed: {message}") }) + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + + #[test] + fn xdg_mime_sets_every_word_type_in_one_call() { + let args = xdg_mime_args(DESKTOP_ID); + assert_eq!(args.first().map(String::as_str), Some("default")); + assert_eq!(args.get(1).map(String::as_str), Some("ai.storyteller.wordcraft.desktop")); + assert_eq!(&args[2..], WORD_MIME_TYPES); + for t in [ + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/msword", + "application/rtf", + "application/vnd.oasis.opendocument.text", + ] { + assert!(args.iter().any(|a| a == t), "{t}"); + } + assert!(!args.iter().any(|a| a == "text/plain" || a == "text/markdown"), "plain text and Markdown are never claimed"); + } + + /// Every type the button claims is one the desktop entry declares, or desktops ignore it. + #[test] + fn the_desktop_entry_declares_every_claimed_type() { + let entry = include_str!("../../../packaging/linux/ai.storyteller.wordcraft.desktop"); + let declared: Vec<&str> = + entry.lines().find_map(|l| l.strip_prefix("MimeType=")).map(|v| v.split(';').filter(|s| !s.is_empty()).collect()).unwrap_or_default(); + for t in WORD_MIME_TYPES { + assert!(declared.contains(t), "{t} missing from MimeType="); + } + assert!(entry.lines().any(|l| l == "Exec=wordcraft %F"), "files are passed on the command line"); + } + + #[test] + fn application_dirs_follow_the_xdg_spec() { + let p = |s: &str| PathBuf::from(s); + assert_eq!( + application_dirs(None, None, Some("/home/a")), + [p("/home/a/.local/share/applications"), p("/usr/local/share/applications"), p("/usr/share/applications")] + ); + assert_eq!( + application_dirs(Some("/data"), Some("/opt/share::relative:/usr/share"), Some("/home/a")), + [p("/data/applications"), p("/opt/share/applications"), p("/usr/share/applications")] + ); + // Empty or relative values fall back to the defaults; no home at all is fine. + assert_eq!(application_dirs(Some(""), Some(""), None), [p("/usr/local/share/applications"), p("/usr/share/applications")]); + assert_eq!(application_dirs(Some("rel"), None, Some("also-rel")), [p("/usr/local/share/applications"), p("/usr/share/applications")]); + } +} diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index c5008e90f..a4c1a5d80 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -13,6 +13,7 @@ mod animations; #[cfg(target_os = "macos")] mod apple_events; mod control_server; +mod default_apps; mod file_dialogs; #[cfg(any(target_os = "windows", test))] mod graphics; @@ -176,13 +177,15 @@ fn save_prefs(app: &WordApp) { } /// Native file dialogs, shown without blocking the window (`file_dialogs`, #94), File › Print -/// through the system's PDF viewer (`print`, #15), pictures on the system clipboard (#45), and -/// the system's animation setting (`animations`). +/// through the system's PDF viewer (`print`, #15), pictures on the system clipboard (#45), the +/// system's animation setting (`animations`), and making WordCraft the default app for Word +/// documents (`default_apps`, #295). fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services { Services { file_dialog: Some(file_dialog), print: Some(print::hook()), clipboard_picture: Some(Box::new(clipboard_picture)), + make_default_app: default_apps::hook(), animations_enabled: Some(animations::hook(ctx)), clipboard_text: Some(Box::new(|| arboard::Clipboard::new().ok()?.get_text().ok())), ..Default::default() diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 6405833dc..786462613 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2041,3 +2041,10 @@ Your system has animation effects turned off. Efekty animace jsou v systému vypnuté. Your system doesn't report an animation setting, so animation effects stay on. Systém neuvádí žádné nastavení animací, proto efekty animace zůstávají zapnuté. Do Not Check Spelling or Grammar Nekontrolovat pravopis ani gramatiku + Couldn't make WordCraft the default: {error} WordCraft se nepodařilo nastavit jako výchozí: {error} + In Default apps, choose WordCraft for the document types you want it to open. Ve Výchozích aplikacích vyberte WordCraft pro typy dokumentů, které má otevírat. + Make WordCraft the default for Word documents… Nastavit WordCraft jako výchozí pro dokumenty Word… + Opens .docx, .doc, .rtf and .odt files and Word templates with WordCraft from now on. Soubory .docx, .doc, .rtf a .odt a šablony Wordu se od teď otevírají ve WordCraftu. + Opens Settings › Default apps, where you choose WordCraft for .docx and the other document types. Otevře Nastavení › Výchozí aplikace, kde zvolíte WordCraft pro .docx a další typy dokumentů. + To open Word documents with WordCraft by default: select one in Finder, choose File › Get Info, pick WordCraft under Open with, then click Change All. Chcete-li dokumenty Word otevírat ve WordCraftu jako výchozí: vyberte jeden ve Finderu, zvolte Soubor › Informace, v části „Otevřít v aplikaci“ vyberte WordCraft a klikněte na „Změnit vše“. + WordCraft is now the default app for Word documents. WordCraft je nyní výchozí aplikací pro dokumenty Word. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 6e74f4921..354d07f13 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2035,3 +2035,10 @@ Your system has animation effects turned off. Auf Ihrem System sind Animationseffekte deaktiviert. Your system doesn't report an animation setting, so animation effects stay on. Ihr System meldet keine Animationseinstellung, daher bleiben Animationseffekte aktiviert. Do Not Check Spelling or Grammar Rechtschreibung und Grammatik nicht prüfen + Couldn't make WordCraft the default: {error} WordCraft konnte nicht als Standard festgelegt werden: {error} + In Default apps, choose WordCraft for the document types you want it to open. Wählen Sie unter „Standard-Apps“ WordCraft für die Dokumenttypen, die es öffnen soll. + Make WordCraft the default for Word documents… WordCraft als Standard für Word-Dokumente festlegen… + Opens .docx, .doc, .rtf and .odt files and Word templates with WordCraft from now on. Öffnet .docx-, .doc-, .rtf- und .odt-Dateien sowie Word-Vorlagen ab jetzt mit WordCraft. + Opens Settings › Default apps, where you choose WordCraft for .docx and the other document types. Öffnet Einstellungen › Standard-Apps, wo Sie WordCraft für .docx und die anderen Dokumenttypen wählen. + To open Word documents with WordCraft by default: select one in Finder, choose File › Get Info, pick WordCraft under Open with, then click Change All. Um Word-Dokumente standardmäßig mit WordCraft zu öffnen: Wählen Sie eines im Finder aus, wählen Sie Ablage › Informationen, wählen Sie WordCraft unter „Öffnen mit“ und klicken Sie auf „Alle ändern“. + WordCraft is now the default app for Word documents. WordCraft ist jetzt die Standard-App für Word-Dokumente. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 394322977..eb39d48a6 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -280,6 +280,7 @@ Copy Copiar Copy Formatting Copiar formato Corrections Correcciones + Couldn't make WordCraft the default: {error} No se pudo hacer de WordCraft la aplicación predeterminada: {error} Count words in text boxes and notes Contar las palabras de los cuadros de texto y las notas Cover Page Portada Create Crear @@ -449,6 +450,7 @@ Immersive Inmersivo Immersive Reader Lector inmersivo Immersive\nReader Lector\ninmersivo + In Default apps, choose WordCraft for the document types you want it to open. En Aplicaciones predeterminadas, elige WordCraft para los tipos de documento que quieras que abra. Increase Font Size Aumentar tamaño de fuente Increase Indent Aumentar sangría Indent Sangría @@ -531,6 +533,7 @@ MIT OR Apache-2.0. Copyright (c) 2026 ArtCraft Team and the WordCraft contributors. MIT o Apache-2.0. Copyright (c) 2026 ArtCraft Team y los colaboradores de WordCraft. Macros Macros Mailings Correspondencia + Make WordCraft the default for Word documents… Hacer de WordCraft la aplicación predeterminada para documentos de Word… Manage Sources Administrar fuentes Many pages Varias páginas Margins Márgenes @@ -602,6 +605,8 @@ One Page Una página Open Abrir OpenDocument Text (*.odt) Texto de OpenDocument (*.odt) + Opens .docx, .doc, .rtf and .odt files and Word templates with WordCraft from now on. A partir de ahora, los archivos .docx, .doc, .rtf y .odt y las plantillas de Word se abren con WordCraft. + Opens Settings › Default apps, where you choose WordCraft for .docx and the other document types. Abre Configuración › Aplicaciones predeterminadas, donde eliges WordCraft para .docx y los demás tipos de documento. Optional Hyphen Guion opcional Copyright Sign Signo de copyright Ellipsis Puntos suspensivos @@ -903,6 +908,7 @@ Thick Gruesa Three Tres Tight Ajustado + To open Word documents with WordCraft by default: select one in Finder, choose File › Get Info, pick WordCraft under Open with, then click Change All. Para abrir los documentos de Word con WordCraft de forma predeterminada: selecciona uno en el Finder, elige Archivo › Obtener información, selecciona WordCraft en «Abrir con» y haz clic en «Cambiar todo». Top Border Borde superior Top Margin Margen superior Top of Page Parte superior de la página @@ -963,6 +969,7 @@ Word XML Document (*.xml) Documento XML de Word (*.xml) WordArt WordArt WordCraft WordCraft + WordCraft is now the default app for Word documents. WordCraft es ahora la aplicación predeterminada para los documentos de Word. Words Palabras Words only Solo palabras Wrap Text Ajustar texto diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 1ff4ce7da..cbafdc326 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -1579,6 +1579,13 @@ Right (vertical) Paremal (vertikaalne) Ruby text: Ruby-tekst: Size (pt): Suurus (pt): + Couldn't make WordCraft the default: {error} WordCrafti ei õnnestunud vaikerakenduseks määrata: {error} + In Default apps, choose WordCraft for the document types you want it to open. Vali jaotises „Vaikerakendused“ WordCraft nende dokumenditüüpide jaoks, mida see peaks avama. + Make WordCraft the default for Word documents… Määra WordCraft Wordi dokumentide vaikerakenduseks… + Opens .docx, .doc, .rtf and .odt files and Word templates with WordCraft from now on. Edaspidi avatakse .docx-, .doc-, .rtf- ja .odt-failid ning Wordi mallid WordCraftiga. + Opens Settings › Default apps, where you choose WordCraft for .docx and the other document types. Avab Sätted › Vaikerakendused, kus saad valida WordCrafti .docx-i ja teiste dokumenditüüpide jaoks. + To open Word documents with WordCraft by default: select one in Finder, choose File › Get Info, pick WordCraft under Open with, then click Change All. Wordi dokumentide vaikimisi avamiseks WordCraftiga vali Finderis üks dokument, vali Fail › Hangi teave, vali jaotises „Ava rakendusega“ WordCraft ja klõpsa „Muuda kõiki“. + WordCraft is now the default app for Word documents. WordCraft on nüüd Wordi dokumentide vaikerakendus. Templates Mallid Templates and Add-ins Mallid ja lisandmoodulid Attach Template Manusta mall diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 90cc34f32..c11bba484 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2420,3 +2420,10 @@ ✓ None ✓ Nessuno preview Title Esempio Do Not Check Spelling or Grammar Non controllare ortografia e grammatica + Couldn't make WordCraft the default: {error} Impossibile rendere WordCraft l'app predefinita: {error} + In Default apps, choose WordCraft for the document types you want it to open. In App predefinite, scegli WordCraft per i tipi di documento che deve aprire. + Make WordCraft the default for Word documents… Rendi WordCraft l'app predefinita per i documenti Word… + Opens .docx, .doc, .rtf and .odt files and Word templates with WordCraft from now on. D'ora in poi i file .docx, .doc, .rtf e .odt e i modelli di Word si aprono con WordCraft. + Opens Settings › Default apps, where you choose WordCraft for .docx and the other document types. Apre Impostazioni › App predefinite, dove scegli WordCraft per .docx e gli altri tipi di documento. + To open Word documents with WordCraft by default: select one in Finder, choose File › Get Info, pick WordCraft under Open with, then click Change All. Per aprire i documenti Word con WordCraft per impostazione predefinita: selezionane uno nel Finder, scegli Archivio › Ottieni informazioni, scegli WordCraft in «Apri con», quindi fai clic su «Modifica tutto». + WordCraft is now the default app for Word documents. WordCraft è ora l'app predefinita per i documenti Word. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 18bcc7af1..5637bcb6d 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -77,6 +77,7 @@ Click to switch back to inserting クリックすると挿入モードに戻ります Color only 色のみ Compress punctuation at the start of a line 行頭の約物を詰める + Couldn't make WordCraft the default: {error} WordCraft を既定のアプリにできませんでした: {error} Custom dictionary ユーザー辞書 Dark page (white text on black) ダーク ページ (黒地に白い文字) Drag a rectangle to draw a table, or a line across cells to split them. Esc stops. 四角形をドラッグして表を描くか、セルを横切る線を引いて分割します。Esc で終了します。 @@ -105,6 +106,7 @@ Inside: 内側: Latin text font: ラテン文字のフォント: Left border 左罫線 + In Default apps, choose WordCraft for the document types you want it to open. 既定のアプリで、WordCraft で開きたい文書の種類ごとに WordCraft を選んでください。 Left Margin 左余白 Indents and Spacing インデントと間隔 Legal style numbering 正規形式で番号付け @@ -125,6 +127,9 @@ Number format 番号書式 Number style for this level: このレベルの番号の種類: Outside border 外側の罫線 + Make WordCraft the default for Word documents… WordCraft を Word 文書の既定のアプリにする… + Opens .docx, .doc, .rtf and .odt files and Word templates with WordCraft from now on. 今後、.docx、.doc、.rtf、.odt ファイルと Word テンプレートを WordCraft で開きます。 + Opens Settings › Default apps, where you choose WordCraft for .docx and the other document types. 設定 › 既定のアプリ を開きます。そこで .docx などの文書の種類ごとに WordCraft を選びます。 Overtype 上書き Overtype (typing replaces the characters after the caret) 上書き (入力した文字がカーソルの後ろの文字を置き換えます) Page display ページの表示 @@ -319,6 +324,8 @@ Delete Style スタイルの削除 Delete Table 表の削除 Design デザイン + To open Word documents with WordCraft by default: select one in Finder, choose File › Get Info, pick WordCraft under Open with, then click Change All. Word 文書を既定で WordCraft で開くには: Finder で文書を選び、ファイル › 情報を見る を選んで、「このアプリケーションで開く」で WordCraft を選び、「すべてを変更」をクリックします。 + WordCraft is now the default app for Word documents. WordCraft が Word 文書の既定のアプリになりました。 development build 開発ビルド Diagonal 対角線 Diamond ひし形 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index c8d5481ba..aca3d2e30 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2053,3 +2053,10 @@ {name} already exists. Choose Save again to replace it. Plik {name} już istnieje. Wybierz ponownie Zapisz, aby go zastąpić. {name} wasn't found. Nie znaleziono: {name}. “{text}” ({row}, {series}) isn't a number. „{text}” ({row}, {series}) nie jest liczbą. + Couldn't make WordCraft the default: {error} Nie udało się ustawić WordCraft jako domyślnego: {error} + In Default apps, choose WordCraft for the document types you want it to open. W Aplikacjach domyślnych wybierz WordCraft dla typów dokumentów, które ma otwierać. + Make WordCraft the default for Word documents… Ustaw WordCraft jako domyślny dla dokumentów Word… + Opens .docx, .doc, .rtf and .odt files and Word templates with WordCraft from now on. Od teraz pliki .docx, .doc, .rtf i .odt oraz szablony Word otwierają się w WordCraft. + Opens Settings › Default apps, where you choose WordCraft for .docx and the other document types. Otwiera Ustawienia › Aplikacje domyślne, gdzie wybierasz WordCraft dla .docx i innych typów dokumentów. + To open Word documents with WordCraft by default: select one in Finder, choose File › Get Info, pick WordCraft under Open with, then click Change All. Aby otwierać dokumenty Word domyślnie w WordCraft: zaznacz jeden w Finderze, wybierz Plik › Pokaż info, wybierz WordCraft w „Otwórz za pomocą”, a następnie kliknij „Zmień wszystkie”. + WordCraft is now the default app for Word documents. WordCraft jest teraz domyślną aplikacją dla dokumentów Word. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 5c6a95f3f..6d36b89c1 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -271,6 +271,7 @@ Copy Copiar Copy Formatting Copiar formatação Corrections Correções + Couldn't make WordCraft the default: {error} Não foi possível tornar o WordCraft o aplicativo padrão: {error} Count words in text boxes and notes Contar palavras em caixas de texto e notas Cover Page Capa Create Criar @@ -308,11 +309,15 @@ Drag a rectangle to draw a table, or a line across cells to split them. Esc stops. Arraste um retângulo para desenhar uma tabela, ou uma linha sobre as células para dividi-las. Esc encerra. Draw Table Desenhar tabela Edit Comment Editar comentário + In Default apps, choose WordCraft for the document types you want it to open. Em Aplicativos padrão, escolha o WordCraft para os tipos de documento que você quer que ele abra. Left Margin Margem esquerda Indents and Spacing Recuos e espaçamento Line breaking Quebra de linhas + Make WordCraft the default for Word documents… Tornar o WordCraft o aplicativo padrão para documentos do Word… More templates → Mais modelos → No templates match your search. Nenhum modelo corresponde à pesquisa. + Opens .docx, .doc, .rtf and .odt files and Word templates with WordCraft from now on. A partir de agora, arquivos .docx, .doc, .rtf e .odt e modelos do Word abrem no WordCraft. + Opens Settings › Default apps, where you choose WordCraft for .docx and the other document types. Abre Configurações › Aplicativos padrão, onde você escolhe o WordCraft para .docx e os outros tipos de documento. Reopen Reabrir Right Margin Margem direita Distribute letters Distribuir letras @@ -322,6 +327,8 @@ Right (vertical) Direita (vertical) Ruby text: Texto ruby: Search for templates Pesquisar modelos + To open Word documents with WordCraft by default: select one in Finder, choose File › Get Info, pick WordCraft under Open with, then click Change All. Para abrir documentos do Word com o WordCraft por padrão: selecione um no Finder, escolha Arquivo › Obter Informações, selecione o WordCraft em “Abrir com” e clique em “Alterar Tudo”. + WordCraft is now the default app for Word documents. O WordCraft agora é o aplicativo padrão para documentos do Word. development build versão de desenvolvimento Diagonal Diagonal Diamond Losango diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e8649be82..f0a879940 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1998,6 +1998,13 @@ preview Title Пример Search icons Поиск значков No icons match your search. Нет значков, соответствующих поиску. Recolor a one-color picture, such as an icon Перекрасить одноцветный рисунок, например значок + Couldn't make WordCraft the default: {error} Не удалось сделать WordCraft приложением по умолчанию: {error} + In Default apps, choose WordCraft for the document types you want it to open. В разделе «Приложения по умолчанию» выберите WordCraft для нужных типов документов. + Make WordCraft the default for Word documents… Сделать WordCraft приложением по умолчанию для документов Word… + Opens .docx, .doc, .rtf and .odt files and Word templates with WordCraft from now on. Отныне файлы .docx, .doc, .rtf и .odt и шаблоны Word будут открываться в WordCraft. + Opens Settings › Default apps, where you choose WordCraft for .docx and the other document types. Открывает «Параметры» › «Приложения по умолчанию», где можно выбрать WordCraft для .docx и других типов документов. + To open Word documents with WordCraft by default: select one in Finder, choose File › Get Info, pick WordCraft under Open with, then click Change All. Чтобы открывать документы Word в WordCraft по умолчанию: выберите документ в Finder, выберите «Файл» › «Свойства», укажите WordCraft в разделе «Открывать в программе» и нажмите «Изменить все». + WordCraft is now the default app for Word documents. WordCraft теперь приложение по умолчанию для документов Word. Templates Шаблоны Templates and Add-ins Шаблоны и надстройки Attach Template Присоединить шаблон diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index a8f3d37e1..ff2ddf278 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -104,6 +104,7 @@ Color: {color} Boja: {color} Compress punctuation at the start of a line Sažmi interpunkciju na početku reda Confirm password: Potvrdite lozinku: + Couldn't make WordCraft the default: {error} WordCraft nije mogao da postane podrazumevana aplikacija: {error} Custom dictionary Prilagođeni rečnik Dark page (white text on black) Tamna stranica (beo tekst na crnom) Drag a rectangle to draw a table, or a line across cells to split them. Esc stops. Prevucite pravougaonik da nacrtate tabelu ili liniju preko ćelija da ih podelite. Esc završava. @@ -148,6 +149,10 @@ Number format Format broja Number style for this level: Stil broja za ovaj nivo: Outside border Spoljna ivica + In Default apps, choose WordCraft for the document types you want it to open. U podrazumevanim aplikacijama izaberite WordCraft za vrste dokumenata koje želite da otvara. + Make WordCraft the default for Word documents… Postavi WordCraft kao podrazumevanu aplikaciju za Word dokumente… + Opens .docx, .doc, .rtf and .odt files and Word templates with WordCraft from now on. Od sada će se .docx, .doc, .rtf i .odt datoteke i Word šabloni otvarati u WordCraft-u. + Opens Settings › Default apps, where you choose WordCraft for .docx and the other document types. Otvara Podešavanja › Podrazumevane aplikacije, gde birate WordCraft za .docx i druge vrste dokumenata. Overtype Prepisivanje Overtype (typing replaces the characters after the caret) Prepisivanje (otkucano zamenjuje znakove posle kursora) Page display Prikaz strane @@ -367,6 +372,8 @@ Delete Style Obriši stil Delete Table Obriši tabelu Design Dizajn + To open Word documents with WordCraft by default: select one in Finder, choose File › Get Info, pick WordCraft under Open with, then click Change All. Da bi se Word dokumenti podrazumevano otvarali u WordCraft-u: izaberite jedan u Finder-u, izaberite Datoteka › Prikaži informacije, pod „Otvori pomoću” izaberite WordCraft, pa kliknite „Promeni sve”. + WordCraft is now the default app for Word documents. WordCraft je sada podrazumevana aplikacija za Word dokumente. development build razvojna verzija Diagonal Dijagonalno Diamond Dijamant diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index c89176486..f79d5532d 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -108,6 +108,7 @@ Color: {color} Боја: {color} Compress punctuation at the start of a line Сажми интерпункцију на почетку реда Confirm password: Потврдите лозинку: + Couldn't make WordCraft the default: {error} WordCraft није могао да постане подразумевана апликација: {error} Custom dictionary Прилагођени речник Dark page (white text on black) Тамна страница (бео текст на црном) Drag a rectangle to draw a table, or a line across cells to split them. Esc stops. Превуците правоугаоник да нацртате табелу или линију преко ћелија да их поделите. Esc завршава. @@ -152,6 +153,10 @@ Number format Формат броја Number style for this level: Стил броја за овај ниво: Outside border Спољна ивица + In Default apps, choose WordCraft for the document types you want it to open. У подразумеваним апликацијама изаберите WordCraft за врсте докумената које желите да отвара. + Make WordCraft the default for Word documents… Постави WordCraft као подразумевану апликацију за Word документе… + Opens .docx, .doc, .rtf and .odt files and Word templates with WordCraft from now on. Од сада ће се .docx, .doc, .rtf и .odt датотеке и Word шаблони отварати у WordCraft-у. + Opens Settings › Default apps, where you choose WordCraft for .docx and the other document types. Отвара Подешавања › Подразумеване апликације, где бирате WordCraft за .docx и друге врсте докумената. Overtype Преписивање Overtype (typing replaces the characters after the caret) Преписивање (откуцано замењује знакове после курсора) Page display Приказ стране @@ -371,6 +376,8 @@ Delete Style Обриши стил Delete Table Обриши табелу Design Дизајн + To open Word documents with WordCraft by default: select one in Finder, choose File › Get Info, pick WordCraft under Open with, then click Change All. Да би се Word документи подразумевано отварали у WordCraft-у: изаберите један у Finder-у, изаберите Датотека › Прикажи информације, под „Отвори помоћу” изаберите WordCraft, па кликните „Промени све”. + WordCraft is now the default app for Word documents. WordCraft је сада подразумевана апликација за Word документе. development build развојна верзија Diagonal Дијагонално Diamond Дијамант diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 26f0d4349..bb878d5d7 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1623,6 +1623,13 @@ Search icons Simgelerde ara No icons match your search. Aramanızla eşleşen simge yok. Recolor a one-color picture, such as an icon Simge gibi tek renkli bir resmi yeniden renklendir + Couldn't make WordCraft the default: {error} WordCraft varsayılan yapılamadı: {error} + In Default apps, choose WordCraft for the document types you want it to open. Varsayılan uygulamalar bölümünde, WordCraft'ın açmasını istediğiniz belge türlerini seçin. + Make WordCraft the default for Word documents… WordCraft'ı Word belgeleri için varsayılan yap… + Opens .docx, .doc, .rtf and .odt files and Word templates with WordCraft from now on. Bundan sonra .docx, .doc, .rtf ve .odt dosyalarını ve Word şablonlarını WordCraft ile açar. + Opens Settings › Default apps, where you choose WordCraft for .docx and the other document types. Ayarlar › Varsayılan uygulamalar bölümünü açar; burada .docx ve diğer belge türleri için WordCraft'ı seçersiniz. + To open Word documents with WordCraft by default: select one in Finder, choose File › Get Info, pick WordCraft under Open with, then click Change All. Word belgelerini varsayılan olarak WordCraft ile açmak için: Finder'da bir belge seçin, Dosya › Bilgi Al'ı seçin, Birlikte aç altında WordCraft'ı seçin ve Tümünü Değiştir'e tıklayın. + WordCraft is now the default app for Word documents. WordCraft artık Word belgeleri için varsayılan uygulama. Templates Şablonlar Templates and Add-ins Şablonlar ve Eklentiler Attach Template Şablonu Ekle diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index c7efa79df..f314bda22 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -278,6 +278,7 @@ Copy Копіювати Copy Formatting Копіювати форматування Corrections Виправлення + Couldn't make WordCraft the default: {error} Не вдалося зробити WordCraft типовою програмою: {error} Count words in text boxes and notes Рахувати слова в текстових полях і виносках Cover Page Титульна сторінка Create Створити @@ -315,11 +316,15 @@ Drag a rectangle to draw a table, or a line across cells to split them. Esc stops. Перетягніть прямокутник, щоб намалювати таблицю, або лінію через клітинки, щоб їх розділити. Esc завершує. Draw Table Намалювати таблицю Edit Comment Редагувати коментар + In Default apps, choose WordCraft for the document types you want it to open. У програмах за замовчуванням виберіть WordCraft для типів документів, які він має відкривати. Left Margin Ліве поле Indents and Spacing Відступи й інтервали Line breaking Розрив рядків + Make WordCraft the default for Word documents… Зробити WordCraft типовою програмою для документів Word… More templates → Більше шаблонів → No templates match your search. Немає шаблонів, що відповідають пошуку. + Opens .docx, .doc, .rtf and .odt files and Word templates with WordCraft from now on. Відтепер файли .docx, .doc, .rtf, .odt і шаблони Word відкриватимуться у WordCraft. + Opens Settings › Default apps, where you choose WordCraft for .docx and the other document types. Відкриває Параметри › Програми за замовчуванням, де ви вибираєте WordCraft для .docx та інших типів документів. Reopen Відкрити знову Right Margin Праве поле Distribute letters Розподілити літери @@ -329,6 +334,8 @@ Right (vertical) Праворуч (вертикально) Ruby text: Текст рубі: Search for templates Пошук шаблонів + To open Word documents with WordCraft by default: select one in Finder, choose File › Get Info, pick WordCraft under Open with, then click Change All. Щоб документи Word типово відкривалися у WordCraft: виберіть документ у Finder, оберіть Файл › Отримати інфо, виберіть WordCraft у розділі «Відкривати за допомогою» і натисніть «Змінити все». + WordCraft is now the default app for Word documents. WordCraft тепер типова програма для документів Word. development build Збірка для розробки Diagonal Діагональ Diamond Ромб diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index c0678a6a4..132eaa569 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -89,6 +89,7 @@ Click to switch back to inserting 单击以切换回插入模式 Color only 仅颜色 Compress punctuation at the start of a line 压缩行首标点 + Couldn't make WordCraft the default: {error} 无法将 WordCraft 设为默认应用:{error} Custom dictionary 自定义词典 Dark page (white text on black) 深色页面(黑底白字) Drag a rectangle to draw a table, or a line across cells to split them. Esc stops. 拖出矩形以绘制表格,或拖出穿过单元格的线条以拆分单元格。按 Esc 结束。 @@ -117,6 +118,7 @@ Inside: 内侧: Latin text font: 拉丁文字字体: Left border 左框线 + In Default apps, choose WordCraft for the document types you want it to open. 请在“默认应用”中,为希望由 WordCraft 打开的文档类型选择 WordCraft。 Left Margin 左边距 Indents and Spacing 缩进和间距 Legal style numbering 正规形式编号 @@ -137,6 +139,9 @@ Number format 编号格式 Number style for this level: 此级别的编号样式: Outside border 外侧框线 + Make WordCraft the default for Word documents… 将 WordCraft 设为 Word 文档的默认应用… + Opens .docx, .doc, .rtf and .odt files and Word templates with WordCraft from now on. 今后用 WordCraft 打开 .docx、.doc、.rtf、.odt 文件和 Word 模板。 + Opens Settings › Default apps, where you choose WordCraft for .docx and the other document types. 打开“设置 › 默认应用”,在其中为 .docx 及其他文档类型选择 WordCraft。 Overtype 改写 Overtype (typing replaces the characters after the caret) 改写(键入的内容替换插入点后的字符) Page display 页面显示 @@ -331,6 +336,8 @@ Delete Style 删除样式 Delete Table 删除表格 Design 设计 + To open Word documents with WordCraft by default: select one in Finder, choose File › Get Info, pick WordCraft under Open with, then click Change All. 要默认用 WordCraft 打开 Word 文档:在访达中选中一个文档,选择“文件 › 显示简介”,在“打开方式”中选择 WordCraft,然后点按“全部更改”。 + WordCraft is now the default app for Word documents. WordCraft 现在是 Word 文档的默认应用。 development build 开发版本 Diagonal 斜式 Diamond 菱形 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index aaea49470..c584d5a75 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -78,6 +78,7 @@ Click to switch back to inserting 按一下以切換回插入模式 Color only 僅色彩 Compress punctuation at the start of a line 壓縮行首標點 + Couldn't make WordCraft the default: {error} 無法將 WordCraft 設為預設應用程式:{error} Custom dictionary 自訂字典 Dark page (white text on black) 深色頁面(黑底白字) Drag a rectangle to draw a table, or a line across cells to split them. Esc stops. 拖出矩形以繪製表格,或拖出穿過儲存格的線條以分割儲存格。按 Esc 結束。 @@ -106,6 +107,7 @@ Inside: 內側: Latin text font: 拉丁文字字型: Left border 左框線 + In Default apps, choose WordCraft for the document types you want it to open. 請在「預設應用程式」中,為要以 WordCraft 開啟的文件類型選擇 WordCraft。 Left Margin 左邊界 Indents and Spacing 縮排和間距 Legal style numbering 正規形式編號 @@ -126,6 +128,9 @@ Number format 編號格式 Number style for this level: 此層級的編號樣式: Outside border 外側框線 + Make WordCraft the default for Word documents… 將 WordCraft 設為 Word 文件的預設應用程式… + Opens .docx, .doc, .rtf and .odt files and Word templates with WordCraft from now on. 今後以 WordCraft 開啟 .docx、.doc、.rtf、.odt 檔案和 Word 範本。 + Opens Settings › Default apps, where you choose WordCraft for .docx and the other document types. 開啟「設定 › 預設應用程式」,在其中為 .docx 及其他文件類型選擇 WordCraft。 Overtype 取代 Overtype (typing replaces the characters after the caret) 取代(輸入的內容會取代插入點後的字元) Page display 頁面顯示 @@ -320,6 +325,8 @@ Delete Style 刪除樣式 Delete Table 刪除表格 Design 設計 + To open Word documents with WordCraft by default: select one in Finder, choose File › Get Info, pick WordCraft under Open with, then click Change All. 若要預設以 WordCraft 開啟 Word 文件:在 Finder 中選取一份文件,選擇「檔案 › 取得資訊」,在「打開檔案的應用程式」中選擇 WordCraft,然後按一下「全部更改」。 + WordCraft is now the default app for Word documents. WordCraft 現在是 Word 文件的預設應用程式。 development build 開發版本 Diagonal 斜向 Diamond 菱形 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d29e1b809..f043923de 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -125,6 +125,21 @@ pub struct Services { pub store_font: Option>, /// Web: forget a stored font (its stored file name) once the user removes it. pub forget_font: Option>, + /// Desktop: File › Options › General › Make WordCraft the default for Word documents (#295), + /// only from that button (never a programmatic command). Windows opens the Settings app's + /// Default apps page (apps can't set defaults themselves); Linux and BSD set it with + /// `xdg-mime`. `None` (web, macOS) hides the button; macOS shows how to do it in Finder + /// instead. An error means nothing changed. + pub make_default_app: Option Result>>, +} + +/// What [`Services::make_default_app`] did. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DefaultApp { + /// The system's default-apps settings opened; the user picks WordCraft there (Windows). + SettingsOpened, + /// WordCraft is now the default app for Word documents (Linux, BSD). + MadeDefault, } /// Files delivered asynchronously. @@ -991,6 +1006,16 @@ impl WordApp { // page; this is the button on it, and the one programmatic call that opens system UI // here, like `ui.openFileDialog` — only when the host can print. "ui.print" => return Some(self.print_to_system(p)), + // File › Options › General › Make WordCraft the default… changes the user's system + // default apps (Linux) or opens system settings (Windows), so only a click on that + // button does it ([`WordApp::make_default_app`]); scripts, the control channel and + // MCP get an error. + "ui.makeDefaultApp" => { + return Some(Err( + "making WordCraft the default app needs a click in File › Options › General; scripts and agents can't change system defaults" + .into(), + )); + } "ui.discord" => { self.canvas.open_url = Some("https://discord.gg/artcraft".into()); json!({}) @@ -1052,6 +1077,32 @@ impl WordApp { } } + /// File › Options › General › Make WordCraft the default…: hand over to the host's + /// [`Services::make_default_app`] and say in the status bar what happened. An error when the + /// host has no hook (web, macOS). Only the button calls this: it is deliberately not a + /// command, so nothing programmatic (control channel, MCP, macros) can change system defaults. + pub(crate) fn make_default_app(&mut self) -> Result { + let Some(hook) = self.services.make_default_app.as_ref() else { + return Err("WordCraft can't change the default apps here; use your system's settings".into()); + }; + match hook() { + Ok(DefaultApp::SettingsOpened) => { + self.status(tl!("In Default apps, choose WordCraft for the document types you want it to open.")); + Ok(json!({"settingsOpened": true})) + } + Ok(DefaultApp::MadeDefault) => { + self.status(tl!("WordCraft is now the default app for Word documents.")); + Ok(json!({"default": true})) + } + Err(e) => { + log::warn!("make default app: {e}"); + let msg = i18n::fmt(tl!("Couldn't make WordCraft the default: {error}"), &[("error", &e)]); + self.status(msg.clone()); + Err(msg) + } + } + } + pub fn status(&mut self, s: impl Into) { self.status_msg = Some((s.into(), now_ms())); } @@ -2391,6 +2442,50 @@ mod tests { assert!(a.status_msg.as_ref().is_some_and(|(m, _)| m.contains("blocked by the browser")), "the status bar says why"); } + /// Make WordCraft the default… (#295) goes through the host's hook and reports the outcome in + /// the status bar; without a hook (web, macOS) it's an error and nothing runs. + #[test] + fn make_default_app_needs_the_hosts_hook_and_reports_the_outcome() { + let mut a = app(); + assert!(a.make_default_app().is_err(), "no hook: an error"); + + let calls = std::rc::Rc::new(std::cell::Cell::new(0)); + let seen = calls.clone(); + a.services.make_default_app = Some(Box::new(move || { + seen.set(seen.get() + 1); + Ok(DefaultApp::MadeDefault) + })); + assert_eq!(a.make_default_app().unwrap()["default"], true); + assert_eq!(calls.get(), 1); + assert!(a.status_msg.as_ref().is_some_and(|(m, _)| m.contains("now the default")), "{:?}", a.status_msg); + + a.services.make_default_app = Some(Box::new(|| Ok(DefaultApp::SettingsOpened))); + assert_eq!(a.make_default_app().unwrap()["settingsOpened"], true); + assert!(a.status_msg.as_ref().is_some_and(|(m, _)| m.contains("Default apps")), "{:?}", a.status_msg); + + a.services.make_default_app = Some(Box::new(|| Err("xdg-mime isn't installed".into()))); + let e = a.make_default_app().unwrap_err(); + assert!(e.contains("xdg-mime isn't installed"), "{e}"); + assert!(a.status_msg.as_ref().is_some_and(|(m, _)| m.contains("xdg-mime isn't installed")), "the status bar says why"); + assert!(!a.session.dirty, "the document is untouched"); + } + + /// Changing the system's default apps needs the user's click: `ui.makeDefaultApp` through + /// `run` (what the control channel, MCP and macros use) is refused and never reaches the hook. + #[test] + fn programmatic_calls_cannot_change_the_default_apps() { + let mut a = app(); + let calls = std::rc::Rc::new(std::cell::Cell::new(0)); + let seen = calls.clone(); + a.services.make_default_app = Some(Box::new(move || { + seen.set(seen.get() + 1); + Ok(DefaultApp::MadeDefault) + })); + let e = a.run("ui.makeDefaultApp", json!({})).unwrap_err(); + assert!(e.contains("click"), "{e}"); + assert_eq!(calls.get(), 0, "the hook never ran"); + } + /// The print settings reach the hook: bad ones print nothing, `copies` repeats the pages. #[test] fn ui_print_honours_the_print_settings() { @@ -2448,6 +2543,42 @@ mod tests { } } + /// File › Options shows Make WordCraft the default… only when the host can do it (#295), and + /// the button runs the host's hook. + #[test] + fn the_default_app_button_shows_only_with_the_hosts_hook() { + use egui_kittest::kittest::Queryable; + const LABEL: &str = "Make WordCraft the default for Word documents…"; + for hook in [false, true] { + let calls = std::rc::Rc::new(std::cell::Cell::new(0)); + let mut a = app(); + if hook { + let seen = calls.clone(); + a.services.make_default_app = Some(Box::new(move || { + seen.set(seen.get() + 1); + Ok(DefaultApp::MadeDefault) + })); + } + a.run("ui.backstage", json!({"value": true, "page": "options"})).unwrap(); + 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); + }, + a, + ); + h.run_steps(4); + assert!(h.query_by_label("Show the community button in the title bar").is_some(), "hook {hook}: the Options page rendered"); + assert_eq!(h.query_by_label(LABEL).is_some(), hook, "hook {hook}: the button"); + if hook { + h.get_by_label(LABEL).click(); + h.run_steps(2); + assert_eq!(calls.get(), 1, "the button ran the hook"); + } + } + } + /// A prompt about document A outlived an agent replacing A with B, and Don't Save then ran /// the pending New against B, throwing away B's edits under a question naming A. #[test] diff --git a/crates/ui-egui/src/options.rs b/crates/ui-egui/src/options.rs index 918536ff6..9f3550460 100644 --- a/crates/ui-egui/src/options.rs +++ b/crates/ui-egui/src/options.rs @@ -140,6 +140,7 @@ fn general(app: &mut WordApp, ui: &mut Ui) { let _ = app.run("view.darkMode", json!({"value": dark_page})); } ui.checkbox(&mut app.ui.show_discord, tl!("Show the community button in the title bar")); + default_app_button(app, ui); ui.add_space(8.0); section(ui, "Personalize"); egui::Grid::new("options_user").num_columns(2).spacing(vec2(8.0, 6.0)).show(ui, |ui| { @@ -480,6 +481,29 @@ fn overlay(root: &mut Value, path: &mut Vec, input: } } +/// File ▸ Options ▸ General: make WordCraft the default app for Word documents (#295). Only where +/// the host can (`Services::make_default_app`: Windows opens Default apps, Linux and BSD use +/// `xdg-mime`); macOS shows how to do it in Finder, the web shows nothing. +fn default_app_button(app: &mut WordApp, ui: &mut Ui) { + if app.services.make_default_app.is_some() { + let hint = if cfg!(target_os = "windows") { + tl!("Opens Settings › Default apps, where you choose WordCraft for .docx and the other document types.") + } else { + tl!("Opens .docx, .doc, .rtf and .odt files and Word templates with WordCraft from now on.") + }; + if ui.button(tl!("Make WordCraft the default for Word documents…")).on_hover_text(hint).clicked() { + // Straight to the hook, not through `run`: only a click may change system defaults. + let _ = app.make_default_app(); + } + } else if cfg!(target_os = "macos") { + ui.label( + egui::RichText::new(tl!("To open Word documents with WordCraft by default: select one in Finder, choose File › Get Info, pick WordCraft under Open with, then click Change All.")) + .small() + .weak(), + ); + } +} + #[cfg(test)] mod tests { use egui_kittest::kittest::Queryable; diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 45266a8e3..a0ff0fcb8 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (.doc pictures, shapes and text boxes read, #551; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (file associations and the default-app button, #295; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the share of a typical real-world file's content and formatting that survives (read: shows correctly; @@ -79,8 +79,19 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Hostile input | ✅ | — | `malformed.rs`, capped allocations, never-crash standard | | Document paragraph defaults (`w:pPrDefault`) | ✅ | ✅ | Omission and explicit empty defaults remain distinct across repeated saves; nonempty defaults and later default-spacing edits are written | -## Paragraph-default preservation +## Opening files from the system +Double-clicking a document, Open With, and making WordCraft the default app (#295). The installers +register WordCraft for these types but never take over a default; File › Options › General has +**Make WordCraft the default for Word documents…** where the system allows it. + +| Platform | Registered types | Default app | Notes | +|---|---|---|---| +| Windows (MSI) | `.docx` `.docm` `.dotx` `.dotm` `.doc` `.dot` `.rtf` `.odt`; `.md` `.txt` under Open With only | Options button opens Settings › Default apps on WordCraft's page | One ProgId per type, `OpenWithProgids`, Capabilities + `RegisteredApplications` (`packaging/windows/wordcraft.wxs`). Not yet tried on a real install | +| Linux, BSD (deb, rpm, Flatpak, AppImage) | Word, ODT, RTF, Markdown, LaTeX, HTML, text (`MimeType=` in the desktop entry, AppStream `mediatype`) | Options button runs `xdg-mime default` for the Word, RTF and ODT types | Not from inside Flatpak (the sandbox can't change host defaults); a bare AppImage needs desktop integration first | +| macOS | `CFBundleDocumentTypes` as Alternate handler (Word, ODT, RTF, Markdown, HTML, text) | Finder › Get Info › Open with › Change All (the Options page says so) | Apple events open the files (#27) | + +## Paragraph-default preservation The styles reader records whether `w:pPrDefault` exists. The writer keeps an omitted container omitted when its modeled properties are still empty. An explicitly empty container stays present, and editing document-default spacing writes the new values. @@ -94,30 +105,24 @@ identical text, glyph/image geometry, page sizes and embedded font-program hashe two WordCraft save/reopen cycles. All fifteen variants open without repair warnings. Word PDFs and measurements remain local under ignored `plan/word/`; they are not fixtures. These checks cover preservation on Word reopen, not WordCraft pagination parity. - The [public OOXML reference](https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.wordprocessing.paragraphpropertiesdefault?view=openxml-3.0.1) describes omission as leaving paragraph defaults application-defined. Real-world corpus coverage and WordCraft layout matching remain separate gaps. - ## Custom XML store preservation - A reduced original bound-control DOCX loses its backing data, item properties and relationship on main, both without editing and after unrelated edits. Main's content-control support (#381) preserves control metadata, including binding attributes, while this custom XML path keeps the backing store through the bounded opaque part graph: payload bytes, content types and related-part IDs survive two edited save/reopen cycles. - The public [dataBinding contract](https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.wordprocessing.databinding?view=openxml-3.0.1) maps a control to a custom XML data part; its [item properties](https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.customxmldataproperties.datastoreitem?view=openxml-3.0.1) identify the store. Keeping displayed text alone does not preserve that mapping. This change preserves stores without evaluating XPath or synchronizing control edits into XML. - Focused original tests cover package/main roots, multiple/shared stores, cycles, external schema references, missing parts, UTF-16 payloads, strict relationship types and reserved filenames. The bibliography names used by #532 are reserved; bibliography already loaded into the source model belongs to its writer. Word-derived files are not fixtures. No readiness estimate rises from these synthetic package checks, and native Word rendering/binding behavior remains untested. - ## Native save-path guarantees Native `io::save_path_with` serializes (and, for password-protected DOCX, encrypts) before creating an exclusively owned sibling temporary file. It writes and synchronizes that file, closes it, then replaces the destination. Failed preparation or replacement keeps the original bytes; cleanup removes only the save's temporary file, on a best-effort basis. Existing `.tmp` files, directories and links are untouched. A new document gets the default mode (`0666` less the umask). When replacing an existing regular file, the Unix temporary file starts with mode `0600` and then takes the existing file's basic permissions. Serialization and format support are unchanged. Synthetic CLI cases and `engine/tests/safe_save.rs` cover occupied names, links, blocked replacement, missing directories, Save As session state, password-protected saves and original-file protection on macOS. Unit tests inject preparation errors and interleave two prepared saves. Independent saves use separate files; the last successful replacement wins. This is not document locking or conflict detection. @@ -156,6 +161,7 @@ raise readiness estimates. No Word-derived files are fixtures. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Opening files from the system: file associations and the default-app button (#295) | | 2026-10-11 | trivial | `.doc`: inline and floating pictures (BLIP store, PNG/JPEG/DIB/TIFF/EMF/WMF), basic shapes, groups and text boxes read from OfficeArt drawings (#551); fidelity ~55% → ~60% | | 2026-10-11 | trivial | Word XML Document (Flat OPC) read and written, Word 2003 XML read (#548) | | 2026-10-11 | trivial | #534: DOCX indents in characters read and written; `.doc` first-line indents (`sprmPDxaLeft1`, its two-byte operand, and `sprmPDxaLeft1_80`), indents in characters (`sprmPDxc*`) and exact/at-least line spacing (LSPD) read correctly | diff --git a/packaging/linux/ai.storyteller.wordcraft.desktop b/packaging/linux/ai.storyteller.wordcraft.desktop index eed42d0c7..e72588364 100644 --- a/packaging/linux/ai.storyteller.wordcraft.desktop +++ b/packaging/linux/ai.storyteller.wordcraft.desktop @@ -11,4 +11,4 @@ StartupNotify=true StartupWMClass=ai.storyteller.wordcraft Categories=Office;WordProcessor; Keywords=word;processor;document;docx;writer;text;letter;report; -MimeType=application/vnd.openxmlformats-officedocument.wordprocessingml.document;application/vnd.oasis.opendocument.text;application/rtf;text/markdown;text/x-tex;text/html;text/plain; +MimeType=application/vnd.openxmlformats-officedocument.wordprocessingml.document;application/vnd.ms-word.document.macroEnabled.12;application/vnd.openxmlformats-officedocument.wordprocessingml.template;application/vnd.ms-word.template.macroEnabled.12;application/msword;application/msword-template;application/vnd.oasis.opendocument.text;application/rtf;text/rtf;text/markdown;text/x-tex;text/html;text/plain; diff --git a/packaging/linux/ai.storyteller.wordcraft.metainfo.xml.in b/packaging/linux/ai.storyteller.wordcraft.metainfo.xml.in index b28437e53..be11038a6 100644 --- a/packaging/linux/ai.storyteller.wordcraft.metainfo.xml.in +++ b/packaging/linux/ai.storyteller.wordcraft.metainfo.xml.in @@ -50,8 +50,17 @@ pointing tablet + wordcraft + application/vnd.openxmlformats-officedocument.wordprocessingml.document + application/vnd.ms-word.document.macroEnabled.12 + application/vnd.openxmlformats-officedocument.wordprocessingml.template + application/vnd.ms-word.template.macroEnabled.12 + application/msword + application/msword-template + application/vnd.oasis.opendocument.text + application/rtf diff --git a/packaging/macos/Info.plist.in b/packaging/macos/Info.plist.in index 13024e5d8..1dce256c4 100644 --- a/packaging/macos/Info.plist.in +++ b/packaging/macos/Info.plist.in @@ -59,6 +59,7 @@ org.openxmlformats.wordprocessingml.document org.openxmlformats.wordprocessingml.template + com.microsoft.word.doc org.oasis-open.opendocument.text public.rtf net.daringfireball.markdown diff --git a/packaging/windows/wordcraft.wxs b/packaging/windows/wordcraft.wxs index 620f28943..c0fa1f0aa 100644 --- a/packaging/windows/wordcraft.wxs +++ b/packaging/windows/wordcraft.wxs @@ -76,6 +76,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 715f85c6121cae55e7b547100015ab40173459e5 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 04:37:03 -0400 Subject: [PATCH 11/88] Tests: linear-time checks measure CPU time, not wall clock under load (#850) The five tests that assert linear layout/export/hyphenation by comparing the cost of an n-sized and a larger input (layout: unequal formats, hidden separators with auto hyphenation, hidden float anchors; pdf: fragmented hidden headings; proof: hyphenating a long word) timed both with wall clock, fastest of three. On a loaded machine (CI, shared dev boxes, the test harness running other tests in parallel) a preempted big run, timer resolution on the small run, or a tight 4x/8.0 limit (hyphenation measured ~7.2 when linear) made them fail now and then. Each now uses a shared cost_ratio helper (test-only, no production change): - CPU time of the test thread from /proc/thread-self/schedstat on Linux, so time spent waiting for a CPU is left out (wall clock elsewhere); - the small input runs `scale` times per timing so both timings are long against the clock's resolution; - small and big runs alternate, five rounds after a warm-up, cheapest kept; - 8x the input everywhere: linear ~8x, quadratic ~64x, limit 24x. Measured under load (load avg ~15-35 on 32 cores): 60 linear ratios in 7.7-8.8. With the old quadratic code injected locally each test fails: unequal formats (uncapped style bucket scan) ~90-104x, hidden separators (hyphenator rescan per point) ~36x, float anchors (run walk restarted per anchor) ~188x, PDF headings (left-out range scan per char) ~37x, hyphenation 50-65x. Co-authored-by: Claude Opus 5.5 --- crates/layout/src/tests.rs | 62 ++++++++++++++++++++++++++---------- crates/pdf/src/tests.rs | 65 +++++++++++++++++++++++++++----------- crates/proof/src/hyphen.rs | 52 ++++++++++++++++++++---------- 3 files changed, 128 insertions(+), 51 deletions(-) diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 6f9ef2af8..a65af6dc0 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -166,29 +166,57 @@ fn hidden_text_adds_no_line_break_opportunity_when_not_shown() { } } -/// How much longer laying out `doc(4n)` takes than `doc(n)` (fastest of three each, after a -/// warm-up): ~4 when linear, ~16 when quadratic, whatever the machine. -/// How much bigger the second document is in the linear-time tests. At 8x a linear layout takes -/// about 8x as long and a quadratic one about 64x, so a limit of 24x stays far from both, however -/// busy the machine running the tests is. +/// How much bigger the second document is in the linear-time tests. At 8x a linear layout costs +/// about 8x as much and a quadratic one about 64x, so a limit of 24x stays far from both. const SCALE: usize = 8; -fn layout_time_ratio(doc: impl Fn(usize) -> Document, n: usize, opts: &LayoutOptions) -> (f64, f64, f64) { - let fastest = |d: &Document| { - (0..3) - .map(|_| { - let t = std::time::Instant::now(); - layout(d, &mut LayoutCache::new(), opts); - t.elapsed().as_secs_f64() - }) - .fold(f64::INFINITY, f64::min) +/// The CPU time `f` takes on this thread, in seconds. On Linux it is the scheduler's count of the +/// time the thread actually ran, so the time it waits while other work on a busy machine has the +/// CPU is left out; elsewhere, wall-clock time. +fn cpu_cost(f: &mut impl FnMut()) -> f64 { + let ran = || { + let s = std::fs::read_to_string("/proc/thread-self/schedstat").ok()?; + s.split_whitespace().next()?.parse::().ok() }; - let (small, big) = (doc(n), doc(SCALE * n)); - layout(&small, &mut LayoutCache::new(), opts); - let (t1, tk) = (fastest(&small), fastest(&big)); + let (t, before) = (std::time::Instant::now(), ran()); + f(); + match (before, ran()) { + (Some(a), Some(b)) => b.saturating_sub(a) as f64 * 1e-9, + _ => t.elapsed().as_secs_f64(), + } +} + +/// How much more `big` costs than `small`, for `big` an input `scale` times the size of `small`'s: +/// (small, big, big / small), ~`scale` when linear and ~`scale`² when quadratic. Each is the +/// cheapest of several runs after a warm-up, the runs alternating so a busy spell slows both sizes +/// alike; `small` is timed `scale` times over per run, so both timings are long against the clock's +/// resolution. +fn cost_ratio(scale: usize, mut small: impl FnMut(), mut big: impl FnMut()) -> (f64, f64, f64) { + small(); + big(); + let mut small_scaled = || (0..scale).for_each(|_| small()); + let (mut t1, mut tk) = (f64::INFINITY, f64::INFINITY); + for _ in 0..5 { + t1 = t1.min(cpu_cost(&mut small_scaled) / scale as f64); + tk = tk.min(cpu_cost(&mut big)); + } (t1, tk, tk / t1.max(1e-9)) } +/// [`cost_ratio`] of laying out `doc(SCALE * n)` against `doc(n)`. +fn layout_time_ratio(doc: impl Fn(usize) -> Document, n: usize, opts: &LayoutOptions) -> (f64, f64, f64) { + let (small, big) = (doc(n), doc(SCALE * n)); + cost_ratio( + SCALE, + || { + layout(&small, &mut LayoutCache::new(), opts); + }, + || { + layout(&big, &mut LayoutCache::new(), opts); + }, + ) +} + #[test] fn hidden_separators_with_automatic_hyphenation_lay_out_in_linear_time() { // "hyphenation " n times with every space hidden: one joined word of 11n letters reaches the diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 5683e11b3..2fd44dc7d 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -300,11 +300,44 @@ fn hidden_heading_text_stays_out_of_bookmarks_and_tags() { } } +/// The CPU time `f` takes on this thread, in seconds. On Linux it is the scheduler's count of the +/// time the thread actually ran, so the time it waits while other work on a busy machine has the +/// CPU is left out; elsewhere, wall-clock time. +fn cpu_cost(f: &mut impl FnMut()) -> f64 { + let ran = || { + let s = std::fs::read_to_string("/proc/thread-self/schedstat").ok()?; + s.split_whitespace().next()?.parse::().ok() + }; + let (t, before) = (std::time::Instant::now(), ran()); + f(); + match (before, ran()) { + (Some(a), Some(b)) => b.saturating_sub(a) as f64 * 1e-9, + _ => t.elapsed().as_secs_f64(), + } +} + +/// How much more `big` costs than `small`, for `big` an input `scale` times the size of `small`'s: +/// (small, big, big / small), ~`scale` when linear and ~`scale`² when quadratic. Each is the +/// cheapest of several runs after a warm-up, the runs alternating so a busy spell slows both sizes +/// alike; `small` is timed `scale` times over per run, so both timings are long against the clock's +/// resolution. +fn cost_ratio(scale: usize, mut small: impl FnMut(), mut big: impl FnMut()) -> (f64, f64, f64) { + small(); + big(); + let mut small_scaled = || (0..scale).for_each(|_| small()); + let (mut t1, mut tk) = (f64::INFINITY, f64::INFINITY); + for _ in 0..5 { + t1 = t1.min(cpu_cost(&mut small_scaled) / scale as f64); + tk = tk.min(cpu_cost(&mut big)); + } + (t1, tk, tk / t1.max(1e-9)) +} + #[test] fn long_fragmented_hidden_headings_export_in_linear_time() { // Headings of n chars in one-char runs: all hidden with bold alternating, and hidden and visible // alternating. Layout and the bookmark/tag titles once scanned every left-out range for every - // char, quadratic in n. Machine-independent: the time for 4n against n, linear ~4, quadratic ~16. + // char, quadratic in n. Machine-independent: the cost for 8n against n, linear ~8, quadratic ~64. let doc = |n: usize| { let heading = |visible_every_other: bool| { let mut h = Paragraph::with_text(&"abcdefg ".repeat(n / 8), CharProps::default()).styled("Heading1"); @@ -321,23 +354,19 @@ fn long_fragmented_hidden_headings_export_in_linear_time() { d }; let opts = PdfOptions { compress: false, ..Default::default() }; - // The fastest of a few runs, after a warm-up, so one slow run on a busy machine counts less. - let fastest = |d: &Document| { - (0..3) - .map(|_| { - let t = std::time::Instant::now(); - export(d, &opts).unwrap(); - t.elapsed().as_secs_f64() - }) - .fold(f64::INFINITY, f64::min) - }; - let n = 25_000; - let (small, big) = (doc(n), doc(4 * n)); - export(&small, &opts).unwrap(); - let (t1, t4) = (fastest(&small), fastest(&big)); - let ratio = t4 / t1.max(1e-9); - eprintln!("t(n) {t1:.3} s, t(4n) {t4:.3} s, ratio {ratio:.1}"); - assert!(ratio < 8.0, "4x the text took {ratio:.1}x the time ({t1:.3} s → {t4:.3} s): not linear"); + let n = 12_500; + let (small, big) = (doc(n), doc(8 * n)); + let (t1, t8, ratio) = cost_ratio( + 8, + || { + export(&small, &opts).unwrap(); + }, + || { + export(&big, &opts).unwrap(); + }, + ); + eprintln!("t(n) {t1:.3} s, t(8n) {t8:.3} s, ratio {ratio:.1}"); + assert!(ratio < 24.0, "8x the text took {ratio:.1}x the time ({t1:.3} s → {t8:.3} s): not linear"); let pdf = String::from_utf8_lossy(&export(&big, &opts).unwrap()).into_owned(); // The all-hidden heading has no bookmark; the other is titled with its visible chars only. assert!(!pdf.contains("abcd"), "hidden heading text in a title"); diff --git a/crates/proof/src/hyphen.rs b/crates/proof/src/hyphen.rs index 6462938bf..c244455aa 100644 --- a/crates/proof/src/hyphen.rs +++ b/crates/proof/src/hyphen.rs @@ -455,16 +455,37 @@ mod tests { assert!(hyphen_points("typography", &lim).is_empty()); } - /// The fastest of three timings of `f`, after a warm-up. - fn fastest(f: impl Fn()) -> f64 { + /// The CPU time `f` takes on this thread, in seconds. On Linux it is the scheduler's count of the + /// time the thread actually ran, so the time it waits while other work on a busy machine has the + /// CPU is left out; elsewhere, wall-clock time. + fn cpu_cost(f: &mut impl FnMut()) -> f64 { + let ran = || { + let s = std::fs::read_to_string("/proc/thread-self/schedstat").ok()?; + s.split_whitespace().next()?.parse::().ok() + }; + let (t, before) = (std::time::Instant::now(), ran()); f(); - (0..3) - .map(|_| { - let t = std::time::Instant::now(); - f(); - t.elapsed().as_secs_f64() - }) - .fold(f64::INFINITY, f64::min) + match (before, ran()) { + (Some(a), Some(b)) => b.saturating_sub(a) as f64 * 1e-9, + _ => t.elapsed().as_secs_f64(), + } + } + + /// How much more `big` costs than `small`, for `big` an input `scale` times the size of `small`'s: + /// (small, big, big / small), ~`scale` when linear and ~`scale`² when quadratic. Each is the + /// cheapest of several runs after a warm-up, the runs alternating so a busy spell slows both sizes + /// alike; `small` is timed `scale` times over per run, so both timings are long against the clock's + /// resolution. + fn cost_ratio(scale: usize, mut small: impl FnMut(), mut big: impl FnMut()) -> (f64, f64, f64) { + small(); + big(); + let mut small_scaled = || (0..scale).for_each(|_| small()); + let (mut t1, mut tk) = (f64::INFINITY, f64::INFINITY); + for _ in 0..5 { + t1 = t1.min(cpu_cost(&mut small_scaled) / scale as f64); + tk = tk.min(cpu_cost(&mut big)); + } + (t1, tk, tk / t1.max(1e-9)) } /// `hyphen_points`' limit checks as first written (a rescan of the letter run per point): the @@ -598,7 +619,7 @@ mod tests { #[test] fn hyphenating_a_long_word_is_linear() { - // A word of n letters against one of 4n: linear ~4x the time, the old rescans ~16x. One in + // A word of n letters against one of 8n: linear ~8x the cost, the old rescans ~64x. One in // the pattern alphabet, one outside it (the syllable heuristic). let pattern = |k: usize| "hyphenation".repeat(k); let heuristic = |k: usize| "smørrebrød".repeat(k); @@ -607,17 +628,16 @@ mod tests { let ratios: Vec<(String, f64)> = [(pattern as fn(usize) -> String, 20), (heuristic, 40)] .into_iter() .map(|(word, repeat)| { - let (small, big) = (word(400), word(1600)); - let time = |w: &str| fastest(|| (0..repeat).for_each(|_| drop(hyphen_points(w, &Limits::default())))); - let (t1, t4) = (time(&small), time(&big)); - let ratio = t4 / t1.max(1e-9); - let what = format!("{}…: t(n) {t1:.4} s, t(4n) {t4:.4} s, ratio {ratio:.1}", word(1).chars().take(10).collect::()); + let (small, big) = (word(400), word(8 * 400)); + let time = |w: &str| (0..repeat).for_each(|_| drop(hyphen_points(w, &Limits::default()))); + let (t1, t8, ratio) = cost_ratio(8, || time(&small), || time(&big)); + let what = format!("{}…: t(n) {t1:.4} s, t(8n) {t8:.4} s, ratio {ratio:.1}", word(1).chars().take(10).collect::()); eprintln!("{what}"); (what, ratio) }) .collect(); for (what, ratio) in ratios { - assert!(ratio < 8.0, "4x the letters, not linear: {what}"); + assert!(ratio < 24.0, "8x the letters, not linear: {what}"); } } From 326d6953353c0044eca1227260ce326c924d2d06 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 12 Oct 2026 04:49:37 -0400 Subject: [PATCH 12/88] Linux: default to XWayland for file drag-and-drop on Wayland desktops (#220) Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 1 + README.md | 12 +++++++++--- apps/wordcraft/Cargo.toml | 1 + apps/wordcraft/src/main.rs | 10 ++++++++++ 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 36f1a7cec..69ccefe16 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5014,6 +5014,7 @@ dependencies = [ "rfd", "serde_json", "windows", + "winit", "winresource", "wordcraft-control-key", "wordcraft-doc", diff --git a/README.md b/README.md index 3df7194f8..d7a4bf720 100644 --- a/README.md +++ b/README.md @@ -267,17 +267,23 @@ instead, without setting anything. The log file records the choice (lines starti RISC-V (riscv64): `wordcraft--linux-riscv64.tar.gz`, a tarball cross-compiled for glibc 2.39+ (Ubuntu 24.04, Debian 13 and newer). +**Wayland:** on a Wayland desktop the app runs through XWayland when it is available, because +winit (the windowing library) supports dragging files into the window only on X11. To use the +native Wayland backend instead (for example for sharper text with fractional scaling, at the cost +of file drag-and-drop), start it with `WORDCRAFT_WAYLAND=1`: + +```sh +WORDCRAFT_WAYLAND=1 wordcraft +``` + **Arch Linux (AUR).** Community members maintain three AUR packages; the project doesn't publish to the AUR itself. Install one of them: - | Package | What it installs | Choose it when | |---|---|---| | [`wordcraft-bin`](https://aur.archlinux.org/packages/wordcraft-bin) | The release tarball above, prebuilt (x86_64, aarch64) | You want the latest release without compiling (quickest) | | [`wordcraft`](https://aur.archlinux.org/packages/wordcraft) | The latest release, built from source with `cargo` | You prefer to build releases yourself | | [`wordcraft-git`](https://aur.archlinux.org/packages/wordcraft-git) | The current `main` branch, built from source | You want unreleased changes and accept an occasional broken build | - For example `yay -S wordcraft-bin` or `paru -S wordcraft-bin`. - ### FreeBSD | Build | File | diff --git a/apps/wordcraft/Cargo.toml b/apps/wordcraft/Cargo.toml index d41277f43..7e02415bd 100644 --- a/apps/wordcraft/Cargo.toml +++ b/apps/wordcraft/Cargo.toml @@ -43,6 +43,7 @@ objc2 = "0.6" dispatch2 = "0.3" [target.'cfg(all(unix, not(target_os = "macos")))'.dependencies] +winit = { version = "0.30", default-features = false, features = ["x11"] } # The desktop's animation setting on Linux and the BSDs, read from the XDG settings portal over # D-Bus. Already in the build through AccessKit, at this version; this adds only its blocking API. zbus = { version = "5", default-features = false, features = ["async-io", "blocking-api"] } diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index a4c1a5d80..38ff25eaf 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -277,6 +277,16 @@ fn main() -> eframe::Result { if let Some(window) = restored { options.viewport = window.apply(options.viewport); } + #[cfg(all(unix, not(target_os = "macos")))] + // winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file + // manager showed a "no" cursor. Run through XWayland when it's there; WORDCRAFT_WAYLAND=1 + // keeps the native Wayland backend. + if std::env::var_os("DISPLAY").is_some() && std::env::var_os("WORDCRAFT_WAYLAND").is_none() { + options.event_loop_builder = Some(Box::new(|b| { + use winit::platform::x11::EventLoopBuilderExtX11; + b.with_x11(); + })); + } // Registered before the event loop starts, so it catches the Finder event that launched us as // well as later ones. Lives until the event loop returns; the app creator only borrows it. #[cfg(target_os = "macos")] From 1c188ba140073fae5a119dd9df755b07adc5fcd3 Mon Sep 17 00:00:00 2001 From: shirizhaki Date: Mon, 12 Oct 2026 11:55:44 +0300 Subject: [PATCH 13/88] M1: preserve independent complex-script DOCX formatting (#366) Keep inherited complex-script size, bold and italic independent from Latin properties and retain explicit values during DOCX round trips. Preserve ordinary formatting-command behavior and add regression coverage. Resolve eight existing Clippy warnings without changing behavior so all six required CI gates pass. Co-authored-by: ChatGPT Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 4 +- crates/doc/src/props.rs | 20 +-- crates/docx/src/read/math.rs | 8 +- crates/docx/src/read/props.rs | 11 -- crates/docx/src/write/embed.rs | 6 +- crates/docx/src/write/props.rs | 10 +- crates/docx/tests/bidi.rs | 4 +- .../docx/tests/complex_script_inheritance.rs | 135 ++++++++++++++++++ crates/docx/tests/fixtures.rs | 3 +- crates/engine/src/cmd/format.rs | 20 +-- crates/engine/src/cmd/review.rs | 2 +- crates/engine/src/tests.rs | 5 +- crates/engine/src/tests_bidi.rs | 42 ++++++ crates/formats/src/odt.rs | 6 +- crates/formats/src/rtf.rs | 6 +- crates/ui-egui/src/dialogs_insert.rs | 2 +- docs/file-format-parity.md | 5 +- docs/gaps.md | 3 +- xtask/src/layers.rs | 10 +- 19 files changed, 228 insertions(+), 74 deletions(-) create mode 100644 crates/docx/tests/complex_script_inheritance.rs diff --git a/ROADMAP.md b/ROADMAP.md index cba044024..3c88b3fd5 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:** minor (Polish interface and Polish proofing; 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-11 · **Last updated:** 2026-10-11 · **Change:** trivial (independent complex-script formatting preserved across DOCX open/save; 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 @@ -134,6 +134,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 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 | DOCX open/save keeps inherited complex-script size, bold and italic independent of Latin formatting; explicit values are retained, and font commands still format both scripts. Covered by synthetic round trips and a local GUI save/reopen check. | | 2026-10-11 | Czech (`cs`) interface catalog: 2,021 entries (the full zh-hans key set), Czech locale detection and persisted language choice | | 2026-10-11 | Polish: interface (`pl`, the thirteenth language, every catalog key) and proofing — spelling against the installed Hunspell `pl_PL` dictionary (read at runtime, not bundled) through a new Hunspell-style affix engine, suggestions tuned for Polish, Polish grammar rules with Polish messages, TeX Polish hyphenation patterns (MIT option); proofing language per run (`w:lang`) for squiggles, F7, suggestions and hyphenation, languages without tools left unmarked; Review › Language menu and the status bar's language; a Polish interface writes new documents in Polish | | 2026-10-11 | German (`de`) interface (#574): 2,021 translated entries covering the full zh-hans key set, locale and saved-preference tests, bundled-font checks; no German proofing resources | @@ -212,6 +213,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | DOCX complex-script formatting inheritance fix and round-trip coverage | | 2026-10-11 | minor | Czech (`cs`) interface catalog added | | 2026-10-11 | trivial | Progress log: German interface (#574) | | 2026-10-11 | trivial | Progress log: tables resize with the mouse (#217) | diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index 4405d4d9b..6487e72ee 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -303,10 +303,10 @@ pub struct CharProps { /// inherit, then `font`. #[serde(skip_serializing_if = "Option::is_none")] pub font_ea: Option, - /// Complex-script size, points (`w:szCs`). `None` = the same as `size` (see [`CharProps::overlay`]). + /// Complex-script size, points (`w:szCs`). `None` leaves the inherited value unchanged. #[serde(skip_serializing_if = "Option::is_none")] pub size_cs: Option, - /// Complex-script bold / italic (`w:bCs`, `w:iCs`). `None` = the same as `bold` / `italic`. + /// Complex-script bold / italic (`w:bCs`, `w:iCs`). `None` leaves the inherited value unchanged. #[serde(skip_serializing_if = "Option::is_none")] pub bold_cs: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -390,11 +390,8 @@ impl CharProps { /// Apply every `Some` field of `patch` on top of `self`. /// - /// Size, bold and italic come in pairs with their complex-script values (Word's own buttons - /// set both): a patch that sets the plain value and leaves the complex-script one `None` sets - /// both, so a run's size or bold isn't overridden for its Persian/Arabic characters by an - /// inherited `w:szCs` / `w:bCs`. The complex-script font inherits on its own (Word often - /// sets only the Latin font and lets complex-script text keep the style's). + /// Plain and complex-script properties inherit independently. Commands that intend to + /// format both scripts must set both values explicitly. pub fn overlay(&mut self, patch: &CharProps) { overlay_fields!(self, patch; style, font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight, shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl, @@ -402,15 +399,6 @@ impl CharProps { if let Some(p) = &patch.text_effects { self.text_effects.get_or_insert_with(Default::default).overlay(p); } - if patch.size.is_some() && patch.size_cs.is_none() { - self.size_cs = None; - } - if patch.bold.is_some() && patch.bold_cs.is_none() { - self.bold_cs = None; - } - if patch.italic.is_some() && patch.italic_cs.is_none() { - self.italic_cs = None; - } } pub fn overlaid(mut self, patch: &CharProps) -> CharProps { self.overlay(patch); diff --git a/crates/docx/src/read/math.rs b/crates/docx/src/read/math.rs index d5d12c776..c6a8adc36 100644 --- a/crates/docx/src/read/math.rs +++ b/crates/docx/src/read/math.rs @@ -219,11 +219,9 @@ fn node(k: &El, depth: usize, out: &mut Arg) { }); } // Tracked insertions show; deletions don't. - "w:ins" | "w:moveTo" | "w:smartTag" | "w:customXml" | "m:oMath" => { - if depth <= MAX_DEPTH { - for c in k.els() { - node(c, d, out); - } + "w:ins" | "w:moveTo" | "w:smartTag" | "w:customXml" | "m:oMath" if depth <= MAX_DEPTH => { + for c in k.els() { + node(c, d, out); } } "w:sdt" => { diff --git a/crates/docx/src/read/props.rs b/crates/docx/src/read/props.rs index 7a307db3a..3cbab1bac 100644 --- a/crates/docx/src/read/props.rs +++ b/crates/docx/src/read/props.rs @@ -189,17 +189,6 @@ impl PropCtx { _ => {} } } - // A complex-script value equal to its plain one is the same as none (see - // `CharProps::overlay`), which keeps documents round-tripping exactly. - if c.size_cs.is_some() && c.size_cs == c.size { - c.size_cs = None; - } - if c.bold_cs.is_some() && c.bold_cs == c.bold { - c.bold_cs = None; - } - if c.italic_cs.is_some() && c.italic_cs == c.italic { - c.italic_cs = None; - } c } diff --git a/crates/docx/src/write/embed.rs b/crates/docx/src/write/embed.rs index 5f66f0b4e..8d43ec616 100644 --- a/crates/docx/src/write/embed.rs +++ b/crates/docx/src/write/embed.rs @@ -527,9 +527,9 @@ fn vml_style_with(style: &str, w: f32, h: f32, float: &Float) -> String { .split(';') .filter(|d| { let key = d.split_once(':').map_or("", |(k, _)| k).trim().to_ascii_lowercase(); - !d.trim().is_empty() - && !matches!(key.as_str(), "width" | "height" | "rotation" | "flip") - && !(absolute && matches!(key.as_str(), "left" | "top" | "margin-left" | "margin-top")) + !(d.trim().is_empty() + || matches!(key.as_str(), "width" | "height" | "rotation" | "flip") + || (absolute && matches!(key.as_str(), "left" | "top" | "margin-left" | "margin-top"))) }) .map(|d| d.trim().to_string()) .collect(); diff --git a/crates/docx/src/write/props.rs b/crates/docx/src/write/props.rs index b9e083793..2cfe983cb 100644 --- a/crates/docx/src/write/props.rs +++ b/crates/docx/src/write/props.rs @@ -26,9 +26,7 @@ pub fn rpr_inner(w: &mut W, c: &CharProps) { if let Some(s) = &c.style { w.val("w:rStyle", s); } - // The complex-script font is written only when set: without it, Persian/Arabic text keeps - // the style's. Complex-script bold, italic and size default to the plain ones (they come in - // pairs, see `CharProps::overlay`), so text formatted here looks the same in Word. + // Emit only explicit properties so absent values keep inheriting from the style chain. let mut fonts: Vec<(&str, &str)> = Vec::new(); if let Some(f) = &c.font { fonts.extend([("w:ascii", f.as_str()), ("w:hAnsi", f.as_str())]); @@ -43,9 +41,9 @@ pub fn rpr_inner(w: &mut W, c: &CharProps) { w.empty("w:rFonts", &fonts); } toggle(w, "w:b", c.bold); - toggle(w, "w:bCs", c.bold_cs.or(c.bold)); + toggle(w, "w:bCs", c.bold_cs); toggle(w, "w:i", c.italic); - toggle(w, "w:iCs", c.italic_cs.or(c.italic)); + toggle(w, "w:iCs", c.italic_cs); toggle(w, "w:caps", c.caps); toggle(w, "w:smallCaps", c.small_caps); toggle(w, "w:strike", c.strike); @@ -77,7 +75,7 @@ pub fn rpr_inner(w: &mut W, c: &CharProps) { if let Some(s) = c.size { w.val("w:sz", &half(s)); } - if let Some(s) = c.size_cs.or(c.size) { + if let Some(s) = c.size_cs { w.val("w:szCs", &half(s)); } if let Some(h) = c.highlight { diff --git a/crates/docx/tests/bidi.rs b/crates/docx/tests/bidi.rs index ede391b8d..68b008b49 100644 --- a/crates/docx/tests/bidi.rs +++ b/crates/docx/tests/bidi.rs @@ -114,8 +114,8 @@ fn reads_word_style_bidi_markup() { assert_eq!(fa.font.as_deref(), Some("Times New Roman")); assert_eq!(fa.font_cs.as_deref(), Some("B Lotus")); assert_eq!((fa.size, fa.size_cs), (Some(12.0), Some(16.0))); - // b + bCs alike is stored once: an unset complex-script bold is the same as `bold`. - assert_eq!((fa.bold, fa.bold_cs, fa.rtl), (Some(true), None, Some(true))); + // Both explicit values survive, even when they happen to be equal. + assert_eq!((fa.bold, fa.bold_cs, fa.rtl), (Some(true), Some(true), Some(true))); assert_eq!((fa.lang.as_deref(), fa.lang_bidi.as_deref()), (Some("en-US"), Some("fa-IR"))); assert_eq!(ps[1].props.bidi, Some(false)); let x = &ps[1].runs[0].props; diff --git a/crates/docx/tests/complex_script_inheritance.rs b/crates/docx/tests/complex_script_inheritance.rs new file mode 100644 index 000000000..92e11ea3c --- /dev/null +++ b/crates/docx/tests/complex_script_inheritance.rs @@ -0,0 +1,135 @@ +//! Independent complex-script formatting through style inheritance and DOCX round trips. +//! Synthetic packages, written here; no external documents or installed fonts are needed. + +use std::io::Write; +use wordcraft_doc::{Document, Paragraph}; + +fn package(run: &str, defaults: &str, styles: &str, paragraph_style: &str) -> Vec { + let ns = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#; + let root = r#""#; + let rels = r#""#; + let document = format!( + r#"{paragraph_style}{run}שָׁלוֹם ABC 123"# + ); + let styles = format!("{defaults}{styles}"); + let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, data) in [ + ("_rels/.rels", root), + ("word/_rels/document.xml.rels", rels), + ("word/document.xml", document.as_str()), + ("word/styles.xml", styles.as_str()), + ] { + z.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + z.write_all(data.as_bytes()).unwrap(); + } + z.finish().unwrap().into_inner() +} + +fn paragraph(d: &Document) -> &Paragraph { + d.body.iter().find_map(|b| b.as_para()).unwrap() +} + +fn check_generations(bytes: &[u8], check: impl Fn(&Document)) { + let mut d = wordcraft_docx::read(bytes).unwrap(); + for _ in 0..3 { + check(&d); + let next = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap(); + assert_eq!(paragraph(&next).text, paragraph(&d).text); + assert_eq!(paragraph(&next).runs, paragraph(&d).runs); + assert_eq!(paragraph(&next).props.bidi, Some(true)); + d = next; + } +} + +#[test] +fn separate_hebrew_font_size_language_and_flags_survive() { + let run = r#""#; + check_generations(&package(run, "", "", ""), |d| { + let c = ¶graph(d).runs[0].props; + assert_eq!((c.font.as_deref(), c.font_cs.as_deref()), (Some("Source Serif 4"), Some("Arial Hebrew"))); + assert_eq!((c.size, c.size_cs), (Some(12.0), Some(24.0))); + assert_eq!((c.bold, c.bold_cs, c.italic, c.italic_cs), (Some(false), Some(true), Some(false), Some(true))); + assert_eq!((c.rtl, c.cs), (Some(true), Some(true))); + assert_eq!((c.lang.as_deref(), c.lang_bidi.as_deref()), (Some("en-US"), Some("he-IL"))); + }); +} + +#[test] +fn latin_direct_properties_leave_complex_script_defaults_inherited() { + let defaults = r#""#; + let run = r#""#; + check_generations(&package(run, defaults, "", ""), |d| { + let p = paragraph(d); + let c = &p.runs[0].props; + assert_eq!((c.size_cs, c.bold_cs, c.italic_cs), (None, None, None), "Absent direct values must remain absent when saved"); + let resolved = d.styles.resolve_char(p.props.style.as_deref(), c); + assert_eq!((resolved.size, resolved.bold, resolved.italic), (10.0, false, false)); + let cs = resolved.complex(); + assert_eq!((cs.size, cs.bold, cs.italic), (18.0, true, true)); + }); +} + +#[test] +fn latin_style_properties_leave_complex_script_style_chain_inherited() { + let styles = r#" + + +"#; + let run = r#""#; + check_generations(&package(run, "", styles, r#""#), |d| { + let p = paragraph(d); + let c = d.styles.resolve_char(p.props.style.as_deref(), &p.runs[0].props); + assert_eq!((c.font.as_str(), c.size, c.bold, c.italic), ("Source Serif 4", 10.0, false, false)); + let cs = c.complex(); + assert_eq!((cs.font.as_str(), cs.size, cs.bold, cs.italic), ("Arial Hebrew", 18.0, true, true)); + assert_eq!(d.styles.get("HebrewBase").unwrap().chr.lang_bidi.as_deref(), Some("he-IL")); + }); +} + +#[test] +fn equal_explicit_complex_script_values_are_not_discarded() { + let run = r#""#; + let defaults = r#""#; + check_generations(&package(run, defaults, "", ""), |d| { + let p = paragraph(d); + let c = &p.runs[0].props; + assert_eq!((c.size_cs, c.bold_cs, c.italic_cs), (Some(10.0), Some(false), Some(false))); + let cs = d.styles.resolve_char(p.props.style.as_deref(), c).complex(); + assert_eq!((cs.size, cs.bold, cs.italic), (10.0, false, false)); + }); +} + +#[test] +fn complex_script_direct_properties_leave_latin_defaults_inherited() { + let run = r#""#; + let defaults = r#""#; + check_generations(&package(run, defaults, "", ""), |d| { + let p = paragraph(d); + let c = &p.runs[0].props; + assert_eq!((c.size, c.bold, c.italic), (None, None, None)); + assert_eq!((c.rtl, c.cs), (Some(false), Some(false))); + let resolved = d.styles.resolve_char(p.props.style.as_deref(), c); + assert_eq!((resolved.size, resolved.bold, resolved.italic), (18.0, true, true)); + let cs = resolved.complex(); + assert_eq!((cs.size, cs.bold, cs.italic), (10.0, false, false)); + }); +} + +#[test] +fn tracked_formatting_keeps_independent_complex_script_values() { + let defaults = r#""#; + let run = r#""#; + check_generations(&package(run, defaults, "", ""), |d| { + let p = paragraph(d); + let c = &p.runs[0].props; + let current = d.styles.resolve_char(None, c).complex(); + assert_eq!((current.size, current.bold, current.italic), (18.0, true, true)); + let change = c.fmt_change.as_ref().unwrap(); + assert_eq!((change.old.size_cs, change.old.bold_cs, change.old.italic_cs), (Some(10.0), Some(false), Some(false))); + let old = d.styles.resolve_char(None, &change.old).complex(); + assert_eq!((old.size, old.bold, old.italic), (10.0, false, false)); + let mut overlaid = wordcraft_doc::props::CharProps::default(); + overlaid.overlay(c); + assert_eq!(overlaid.fmt_change, c.fmt_change); + }); +} diff --git a/crates/docx/tests/fixtures.rs b/crates/docx/tests/fixtures.rs index 6d0bc5ca0..9742806aa 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/fixtures.rs @@ -1146,7 +1146,8 @@ fn formatting_revisions_round_trip() { assert!(xml.contains(parent), "{tag} last: {xml}"); } assert!(xml.contains(r#""#), "{xml}"); - assert!(xml.contains(r#" Vec { vec![ CommandSpec::new("format.bold", "Bold", "Home › Font", |s, v| { - // Like Word's button, for complex-script (Persian, Arabic) text too: an unset `bold_cs` - // follows `bold`. + // The button formats both plain and complex-script text explicitly. toggle( s, v, |r| r.bold, |c, on| { c.bold = Some(on); - c.bold_cs = None; + c.bold_cs = Some(on); }, ) }) @@ -36,7 +35,7 @@ pub fn specs() -> Vec { |r| r.italic, |c, on| { c.italic = Some(on); - c.italic_cs = None; + c.italic_cs = Some(on); }, ) }) @@ -319,7 +318,7 @@ fn size(s: &mut Session, v: &Value) -> CmdResult { let sz = (sz * 2.0).round() / 2.0; apply(s, &|c| { c.size = Some(sz); - c.size_cs = None; + c.size_cs = Some(sz); }) } @@ -338,7 +337,7 @@ fn step_size(s: &mut Session, dir: i32) -> CmdResult { // Each run steps from its own size when the selection mixes sizes. apply(s, &|c| { c.size = Some(next); - c.size_cs = None; + c.size_cs = Some(next); }) } @@ -346,7 +345,7 @@ fn nudge_size(s: &mut Session, d: f32) -> CmdResult { let next = (current_size(s) + d).clamp(1.0, 1638.0); apply(s, &|c| { c.size = Some(next); - c.size_cs = None; + c.size_cs = Some(next); }) } @@ -568,7 +567,12 @@ fn char_style(s: &mut Session, v: &Value) -> CmdResult { } fn set(s: &mut Session, v: &Value) -> CmdResult { - let props: CharProps = serde_json::from_value(v.get("props").cloned().unwrap_or(Value::Null)).map_err(|e| CmdError::Params(e.to_string()))?; + let mut props: CharProps = serde_json::from_value(v.get("props").cloned().unwrap_or(Value::Null)).map_err(|e| CmdError::Params(e.to_string()))?; + // The font dialog and callers supplying one value format both scripts. Explicit + // complex-script values still take precedence; importing a DOCX never uses this command. + props.size_cs = props.size_cs.or(props.size); + props.bold_cs = props.bold_cs.or(props.bold); + props.italic_cs = props.italic_cs.or(props.italic); apply(s, &|c| c.overlay(&props)) } diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index 228f45fa4..2ce9d4aa1 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -1200,7 +1200,7 @@ mod tests { let list = s.run("review.changes", &json!({})).unwrap(); let desc: Vec<&str> = list.as_array().unwrap().iter().filter(|c| c["kind"] == "format").filter_map(|c| c["description"].as_str()).collect(); - assert_eq!(desc, ["Formatted: Align: center", "Formatted: Bold, Size: 14"], "{list:#}"); + assert_eq!(desc, ["Formatted: Align: center", "Formatted: Bold, Bold cs, Size: 14, Size cs: 14"], "{list:#}"); let (d, body_x) = draws(&mut s); let bars = lines(&d).into_iter().filter(|(x0, _, x1, _)| x0 == x1 && *x0 < body_x).count(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b21b8ab73..59613102c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2428,12 +2428,13 @@ fn style_inspector_reports_and_clears_levels() { assert!(pdirect.iter().any(|d| d["prop"] == "align" && d["value"] == "center"), "{pdirect:?}"); assert!(r["character"]["style"].is_null()); let cdirect = r["character"]["direct"].as_array().unwrap(); - assert_eq!(cdirect, &vec![json!({"prop": "bold", "value": true})]); + assert_eq!(cdirect, &vec![json!({"prop": "bold", "value": true}), json!({"prop": "boldCs", "value": true})]); // Formatting that matches the style isn't a difference (Heading 1 is 20 pt). run(&mut s, "format.size", json!({"size": 20})); let r = run(&mut s, "styles.inspect", json!({})); - assert_eq!(r["character"]["direct"].as_array().unwrap().len(), 1, "{r}"); + let cdirect = r["character"]["direct"].as_array().unwrap(); + assert_eq!(cdirect, &vec![json!({"prop": "bold", "value": true}), json!({"prop": "boldCs", "value": true})]); // Character style level. run(&mut s, "format.charStyle", json!({"style": "Emphasis"})); diff --git a/crates/engine/src/tests_bidi.rs b/crates/engine/src/tests_bidi.rs index e7151a5fa..8f6224f5e 100644 --- a/crates/engine/src/tests_bidi.rs +++ b/crates/engine/src/tests_bidi.rs @@ -89,6 +89,48 @@ fn font_commands_reach_persian_text() { assert_eq!(d.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.bidi, Some(true)); } +#[test] +fn font_commands_override_inherited_complex_script_properties_explicitly() { + use wordcraft_doc::props::CharProps; + + let mut s = session("שלום ABC", true); + s.doc.styles.default_chr.size_cs = Some(30.0); + s.doc.styles.default_chr.bold_cs = Some(true); + s.doc.styles.default_chr.italic_cs = Some(true); + run(&mut s, "select.all", json!({})); + run(&mut s, "format.bold", json!({"value": false})); + run(&mut s, "format.italic", json!({"value": false})); + run(&mut s, "format.size", json!({"size": 18})); + for (command, expected_size) in [ + (None, 18.0), + (Some("format.growFont"), 20.0), + (Some("format.shrinkFont"), 18.0), + (Some("format.growFont1"), 19.0), + (Some("format.shrinkFont1"), 18.0), + ] { + if let Some(command) = command { + run(&mut s, command, json!({})); + } + let p = s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap(); + let c = p.props_of_char(0); + assert_eq!((c.size_cs, c.bold_cs, c.italic_cs), (Some(expected_size), Some(false), Some(false))); + let d = crate::io::open_bytes("x.docx", &crate::io::save_bytes("x.docx", &s.doc).unwrap()).unwrap(); + let c = d.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props_of_char(0); + let cs = d.styles.resolve_char(None, c).complex(); + assert_eq!((cs.size, cs.bold, cs.italic), (expected_size, false, false)); + } + // The font dialog's command keeps formatting both scripts when given one value. + run(&mut s, "format.set", json!({"props": {"size": 10, "bold": true, "italic": true}})); + let p = s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap(); + let c: &CharProps = p.props_of_char(0); + assert_eq!((c.size_cs, c.bold_cs, c.italic_cs), (Some(10.0), Some(true), Some(true))); + // Callers can still supply distinct values explicitly, including false. + run(&mut s, "format.set", json!({"props": {"size": 12, "sizeCs": 18, "bold": true, "boldCs": false, "italic": true, "italicCs": false}})); + let p = s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap(); + let c = p.props_of_char(0); + assert_eq!((c.size, c.size_cs, c.bold_cs, c.italic_cs), (Some(12.0), Some(18.0), Some(false), Some(false))); +} + #[test] fn arrow_keys_move_visually_in_rtl_text() { let fa = "سلام دنیا"; diff --git a/crates/formats/src/odt.rs b/crates/formats/src/odt.rs index 4f98fda7a..e2266f7ed 100644 --- a/crates/formats/src/odt.rs +++ b/crates/formats/src/odt.rs @@ -1222,10 +1222,8 @@ impl Body<'_> { } match local(q) { "p" | "h" if !self.in_desc => self.end_para(), - "span" | "a" => { - if self.fmt.len() > usize::from(self.para.is_some()) { - self.fmt.pop(); - } + "span" | "a" if self.fmt.len() > usize::from(self.para.is_some()) => { + self.fmt.pop(); } "list" => { self.end_para(); diff --git a/crates/formats/src/rtf.rs b/crates/formats/src/rtf.rs index 24ac30fea..0e57fa543 100644 --- a/crates/formats/src/rtf.rs +++ b/crates/formats/src/rtf.rs @@ -1003,10 +1003,8 @@ impl Reader { self.list_cur.clear(); self.list_cur_id = 0; } - "listlevel" => { - if self.list_cur.len() < 9 { - self.list_cur.push(true); - } + "listlevel" if self.list_cur.len() < 9 => { + self.list_cur.push(true); } "levelnfc" | "levelnfcn" => { if let Some(l) = self.list_cur.last_mut() { diff --git a/crates/ui-egui/src/dialogs_insert.rs b/crates/ui-egui/src/dialogs_insert.rs index 2454aca65..5fe756320 100644 --- a/crates/ui-egui/src/dialogs_insert.rs +++ b/crates/ui-egui/src/dialogs_insert.rs @@ -623,7 +623,7 @@ impl SymbolForm { self.chars = face.chars().into_iter().map(|(c, _)| c).filter(|c| !c.is_control() && !(0xD800..=0xDFFF).contains(&(*c as u32))).collect(); self.chars_of = Some(family); // Keep the selection when the new font has it; otherwise its first character. - if !self.selected.is_some_and(|c| self.chars.binary_search(&c).is_ok()) { + if self.selected.is_none_or(|c| self.chars.binary_search(&c).is_err()) { self.selected = self.chars.iter().copied().find(|c| *c > ' ').or(self.chars.first().copied()); } self.sync_code(); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index a0ff0fcb8..c5da6ca15 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (file associations and the default-app button, #295; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** trivial (independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the share of a typical real-world file's content and formatting that survives (read: shows correctly; @@ -46,7 +46,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Feature | Read | Write | Notes | |---|---|---|---| -| Text, runs, paragraph and character properties, styles, numbering, sections | ✅ | ✅ | Including complex-script props (`w:rtl`, `w:cs`, `w:szCs`, `w:bCs`…), East Asian fonts kept apart from Latin (#111), indents in characters (`w:firstLineChars`, `w:hangingChars`, `w:leftChars`, `w:rightChars`, #534); every ST_NumberFormat value of `w:numFmt` is kept on save, Chinese/Japanese/Korean, enclosed and full-width formats are drawn, the rest (Hebrew, Arabic, Hindi, Thai…) show decimal (#534). `w:beforeLines`/`w:afterLines` not kept (the points are) | +| Text, runs, paragraph and character properties, styles, numbering, sections | ✅ | ✅ | Including complex-script props (`w:rtl`, `w:cs`, `w:szCs`, `w:bCs`…), East Asian fonts kept apart from Latin (#111); independent complex-script formatting inherited through defaults/styles and preserved over repeated saves (`complex_script_inheritance.rs`), indents in characters (`w:firstLineChars`, `w:hangingChars`, `w:leftChars`, `w:rightChars`, #534); every ST_NumberFormat value of `w:numFmt` is kept on save, Chinese/Japanese/Korean, enclosed and full-width formats are drawn, the rest (Hebrew, Arabic, Hindi, Thai…) show decimal (#534). `w:beforeLines`/`w:afterLines` not kept (the points are) | | Tables incl. floating (`w:tblpPr`), table styles and conditional formatting | ✅ | ✅ | Custom table styles round-trip (#256); right-to-left tables (`w:bidiVisual`) and sections (`w:bidi`) round-trip (#362) | | Headers/footers (first, even/odd), page borders, line numbers, gutter, mirror margins | ✅ | ✅ | | | Hyphenation settings (`w:autoHyphenation`, `w:hyphenationZone`, `w:doNotHyphenateCaps`, `w:consecutiveHyphenLimit`) | ✅ | ✅ | Zone, capitals and limit kept and honoured by layout (#407) | @@ -161,6 +161,7 @@ raise readiness estimates. No Word-derived files are fixtures. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Independent complex-script size, bold and italic inherit correctly through defaults and styles; explicit equal values survive; six synthetic multi-generation round-trip tests, including tracked formatting, added | | 2026-10-11 | minor | Opening files from the system: file associations and the default-app button (#295) | | 2026-10-11 | trivial | `.doc`: inline and floating pictures (BLIP store, PNG/JPEG/DIB/TIFF/EMF/WMF), basic shapes, groups and text boxes read from OfficeArt drawings (#551); fidelity ~55% → ~60% | | 2026-10-11 | trivial | Word XML Document (Flat OPC) read and written, Word 2003 XML read (#548) | diff --git a/docs/gaps.md b/docs/gaps.md index e7acc2319..578532f53 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Polish interface and Polish proofing; previously trivial: .doc pictures and drawings read, #551; previously trivial: #24 narrowed: Customize Keyboard and Customize Ribbon landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** trivial (DOCX complex-script formatting inheritance fixed; broader RTL work remains; previously trivial: #24 narrowed: Customize Keyboard and Customize Ribbon landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 The work list. One entry per shortfall, ranked by how much it stops a Word user from switching (**B** = blocks beta). Each says what's missing, the evidence, who it hurts, an Opus 5.5 agent @@ -69,6 +69,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | #16: DOCX open/save preserves independent complex-script formatting inheritance; RTL sections, tables and typography remain open | | 2026-10-11 | trivial | `.doc` pictures, shapes and text boxes read (#551); gap #14 narrowed to the writer and Word 6/95 files | | 2026-10-11 | trivial | Table column and row borders and the resize handle drag on the page (#217); UI/UX gap narrowed to the ruler's column markers | | 2026-10-11 | trivial | UI/UX gaps list: the system's animation setting is now read on macOS and the Linux/BSD desktops; trying it on real machines remains | diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 9341e2cdf..a1f43d86f 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -169,13 +169,11 @@ pub fn check(crates: &[Crate]) -> Vec { continue; } match classify(&d.name) { - // Unregistered deps are reported on their own entry. - None => {} - Some(Class::Testkit) => { - if d.kind != DepKind::Dev { - out.push(Violation::TestkitAsNormalDep { krate: c.name.clone() }); - } + Some(Class::Testkit) if d.kind != DepKind::Dev => { + out.push(Violation::TestkitAsNormalDep { krate: c.name.clone() }); } + // Unregistered deps are reported on their own entry; testkit dev deps are allowed. + None | Some(Class::Testkit) => {} Some(dc) => { let to = dc.layer().unwrap_or(u8::MAX); if to >= layer && !(to == layer && intra_layer_allowed(&c.name, &d.name)) { From 2f58d441b6abe05aa60f3c8ce7f33080e0bc565a Mon Sep 17 00:00:00 2001 From: abdo4312 <149546333+abdo4312@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:04:43 +0300 Subject: [PATCH 14/88] Numeral display modes: Arabic, Hindi, Context and System digits (view.numeral) (#501) * Numeral display modes: Arabic, Hindi, Context and System digits Display-only digit substitution (Word File > Options > Advanced > Numeral): ASCII and Arabic-Indic digits are substituted before shaping while clusters keep indexing the original text, so find, spelling, copy/paste and saved files see the typed digits. - layout: NumeralMode (arabic|hindi|context|system) with System resolved from LC_ALL/LANG, threaded through LayoutOptions/ParaEnv with cache invalidation; Context follows the surrounding text - engine: view.numeral command + catalog row - tests: layout tests_numeral, engine command test * Options page: Numerals picker in File > Options > Display ComboBox for the view.numeral mode (Arabic/Hindi/Context/System), mirroring Word's File > Options > Advanced > Numeral. Display-only, session-scoped like the other Display options. * fmt: collapse numeral direction branch * #501: translate the Numerals option label in every catalog Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: abdo4312 Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/engine/src/catalog.rs | 1 + crates/engine/src/cmd/view.rs | 18 ++- crates/engine/src/lib.rs | 2 +- crates/engine/src/session.rs | 27 ++++- crates/engine/src/tests.rs | 24 ++++ crates/layout/src/lib.rs | 91 ++++++++++++++ crates/layout/src/para.rs | 118 ++++++++++++++++-- crates/layout/src/table.rs | 2 + crates/layout/src/tests_numeral.rs | 178 ++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/cs.tsv | 5 + crates/ui-egui/src/i18n/de.tsv | 5 + crates/ui-egui/src/i18n/es.tsv | 7 +- crates/ui-egui/src/i18n/et.tsv | 7 +- crates/ui-egui/src/i18n/it.tsv | 5 + crates/ui-egui/src/i18n/ja.tsv | 7 +- crates/ui-egui/src/i18n/pl.tsv | 5 + crates/ui-egui/src/i18n/pt-br.tsv | 7 +- crates/ui-egui/src/i18n/ru.tsv | 7 +- crates/ui-egui/src/i18n/sr-latn.tsv | 7 +- crates/ui-egui/src/i18n/sr.tsv | 7 +- crates/ui-egui/src/i18n/tr.tsv | 7 +- crates/ui-egui/src/i18n/uk.tsv | 7 +- crates/ui-egui/src/i18n/zh-hans.tsv | 7 +- crates/ui-egui/src/i18n/zh-hant.tsv | 7 +- crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/options.rs | 19 +++ 26 files changed, 550 insertions(+), 31 deletions(-) create mode 100644 crates/layout/src/tests_numeral.rs diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 92d2cdf98..47cbfc521 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -20,6 +20,7 @@ File|Backstage|Properties|file.properties File|Backstage|Export PDF|file.exportPdf File|Backstage|Export Image|file.exportPng File|Backstage|Options|file.options +File|Options|Numerals|view.numeral File|Backstage|Share|file.share File|Backstage|Protect Document|file.protect File|Backstage|Inspect Document|file.inspect diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 1e2e0cfce..b1b6fece1 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -1,7 +1,7 @@ //! View tab and status bar: views, zoom, show/hide, panes. use serde_json::{Value, json}; -use wordcraft_layout::ViewMode; +use wordcraft_layout::{NumeralMode, ViewMode}; use super::sel_result; use crate::{CmdError, CmdResult, CommandSpec, Session, p}; @@ -83,6 +83,22 @@ pub fn specs() -> Vec { .pure(), CommandSpec::new("view.state", "View State", "View", |s, _| serde_json::to_value(&s.view).map_err(|e| CmdError::Failed(e.to_string()))) .pure(), + // Word › File › Options › Advanced › Numeral: how digits look on screen (display + // only; the document text keeps the typed codepoints). + CommandSpec::new("view.numeral", "Numerals", "File › Options › Advanced", |s, v| match v.get("value") { + None => Ok(json!({"value": s.view.numeral})), + Some(Value::String(name)) => { + let Some(mode) = NumeralMode::parse(name) else { + return Err(CmdError::Params("numeral mode: arabic|hindi|context|system".into())); + }; + s.view.numeral = mode; + s.relayout(); + Ok(json!({"value": s.view.numeral})) + } + Some(_) => Err(CmdError::Params("numeral mode: arabic|hindi|context|system".into())), + }) + .params(r#"{"value"?: "arabic|hindi|context|system"}"#) + .pure(), ] } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2ec789301..927d42614 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -25,7 +25,7 @@ use serde_json::Value; pub use io::Password; pub use keymap::KeyMap; pub use ribbon::RibbonLayout; -pub use session::{ColumnBlock, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, ViewState}; +pub use session::{ColumnBlock, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, ViewState, set_system_locale}; pub use wordcraft_doc as doc; pub use wordcraft_layout as layout; pub use wordcraft_proof as proof; diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 85f07b674..3c01c43c9 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -1,13 +1,22 @@ //! The editing session: document, selection, undo/redo, layout, view state. -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; + +/// The system locale (BCP 47 tag) the shell found at startup, for Numeral › System. +static SYSTEM_LOCALE: OnceLock = OnceLock::new(); + +/// Tell the engine the system locale (the shell calls this once at startup; layout never reads +/// the environment). Headless sessions without it show Western digits for Numeral › System. +pub fn set_system_locale(tag: &str) { + let _ = SYSTEM_LOCALE.set(tag.to_string()); +} use serde::{Deserialize, Serialize}; use serde_json::Value; use wordcraft_doc::edit::Fragment; use wordcraft_doc::props::CharProps; use wordcraft_doc::{Document, Path, Pos, StoryRef}; -use wordcraft_layout::{DocLayout, LayoutCache, LayoutOptions, ViewMode}; +use wordcraft_layout::{DocLayout, LayoutCache, LayoutOptions, NumeralMode, ViewMode}; use crate::{CmdError, Registry}; @@ -86,6 +95,9 @@ pub struct ViewState { pub track_changes_pane: bool, /// Check spelling and grammar as you type. pub proofing: bool, + /// How digits look on screen (Word › File › Options › Advanced › Numeral). + #[serde(default)] + pub numeral: NumeralMode, /// Review › Hide Ink: ink strokes aren't shown on screen (they stay in the document). #[serde(default)] pub hide_ink: bool, @@ -138,6 +150,7 @@ impl Default for ViewState { show_markup: true, track_changes_pane: false, proofing: true, + numeral: NumeralMode::default(), hide_ink: false, draw: Default::default(), field_codes: false, @@ -269,8 +282,8 @@ pub struct Session { document_id: u64, cache: LayoutCache, /// (revision, web width, view, layout, proofing, outline, (deletions left out, body - /// deletions in balloons)). - layout: Option<(u64, f32, ViewMode, Arc, bool, Option, (bool, bool))>, + /// deletions in balloons), numeral display). + layout: Option<(u64, f32, ViewMode, Arc, bool, Option, (bool, bool), NumeralMode)>, /// Picture edits: edited media key → original media key (Reset Picture). pub originals: std::collections::HashMap, /// Last mutating command (Repeat). @@ -572,11 +585,12 @@ impl Session { let hide_deleted = !self.view.show_markup || self.prefs.markup.hides_deletions(); let deleted = (hide_deleted, self.revisions_in_balloons()); let outline = (self.view.mode == ViewMode::Outline).then(|| self.view.outline.view()); - if let Some((r, w, m, l, pf, o, hd)) = &self.layout + if let Some((r, w, m, l, pf, o, hd, n)) = &self.layout && *r == self.rev && (*w == ww || self.view.mode == ViewMode::Print) && *m == self.view.mode && *pf == self.view.proofing + && *n == self.view.numeral && *hd == deleted && *o == outline { @@ -589,13 +603,14 @@ impl Session { hide_deleted, deletions_in_balloons: deleted.1, proofing: self.view.proofing, + numeral: self.view.numeral.resolve(SYSTEM_LOCALE.get().map(String::as_str)), comment_balloons: false, field_codes: self.view.field_codes, outline: outline.clone(), proof: wordcraft_layout::ProofOptions { grammar: self.prefs.mark_grammar, spelling: self.prefs.spelling() }, }; let l = Arc::new(wordcraft_layout::layout(&self.doc, &mut self.cache, &opts)); - self.layout = Some((self.rev, ww, self.view.mode, l.clone(), self.view.proofing, outline, deleted)); + self.layout = Some((self.rev, ww, self.view.mode, l.clone(), self.view.proofing, outline, deleted, self.view.numeral)); l } /// A layout for output (PDF, images, print): no proofing marks, print view. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 59613102c..e292a8f60 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2615,6 +2615,30 @@ fn page_and_table_gridlines_toggle_independently() { assert_eq!(state["tableGridlines"], false); } +/// File › Options › Advanced › Numeral: the display mode for digits (display only). +#[test] +fn numeral_mode_sets_gets_and_rejects_junk() { + use wordcraft_layout::NumeralMode; + let mut s = s(); + assert_eq!(s.view.numeral, NumeralMode::Arabic, "Western digits by default"); + let r = run(&mut s, "view.numeral", json!({"value": "hindi"})); + assert_eq!(r["value"], "hindi"); + assert_eq!(s.view.numeral, NumeralMode::Hindi); + let r = run(&mut s, "view.numeral", json!({})); + assert_eq!(r["value"], "hindi", "no params reads the current mode"); + run(&mut s, "view.numeral", json!({"value": "Context"})); + assert_eq!(s.view.numeral, NumeralMode::Context, "mode names ignore case"); + assert!(s.run("view.numeral", &json!({"value": "western"})).is_err()); + assert!(s.run("view.numeral", &json!({"value": 3})).is_err()); + assert!(s.run("view.numeral", &json!({"value": ["hindi"]})).is_err()); + assert_eq!(s.view.numeral, NumeralMode::Context, "rejected values keep the mode"); + // Switching modes relayouts without disturbing the text. + s.doc = wordcraft_doc::Document::from_text("Year 2024"); + run(&mut s, "view.numeral", json!({"value": "hindi"})); + let _l = s.layout(); + assert_eq!(text(&s), "Year 2024"); +} + /// Issue #67: View › Zoom steps. Zoom In/Out leave a fit mode and step 10% from the current zoom /// (the UI keeps `view.zoom` equal to the shown zoom while a fit mode is on), and never get stuck /// short of the 10%–500% limits. diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 3b1344540..9e3f0adae 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -52,6 +52,88 @@ pub enum ViewMode { Outline, } +/// How digits look on screen (Word › File › Options › Advanced › Numeral). +/// +/// Display only: the document text keeps the typed codepoints, so find, spelling, copying and +/// the saved file all see the original digits. Extended Arabic-Indic digits (U+06F0–U+06F9, +/// Persian/Urdu ۰۱۲۳…) always show as typed; only ASCII and Arabic-Indic (U+0660–U+0669) digits +/// are substituted. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum NumeralMode { + /// Western digits everywhere (`0123456789`); the default, today's behaviour. + #[default] + Arabic, + /// Eastern Arabic-Indic digits everywhere (`٠١٢٣٤٥٦٧٨٩`). + Hindi, + /// Hindi digits in and around right-to-left text, Western digits elsewhere (numbers take + /// an even bidi level, so the surrounding text decides). + Context, + /// Hindi digits when the system locale is an Arabic-script language, else Western digits. + /// Call [`NumeralMode::resolve`] before laying out. + System, +} + +impl NumeralMode { + /// Short UI label (Word's option names). + pub fn label(self) -> &'static str { + match self { + NumeralMode::Arabic => "Arabic", + NumeralMode::Hindi => "Hindi", + NumeralMode::Context => "Context", + NumeralMode::System => "System", + } + } + + /// Parse a command value (`"arabic" | "hindi" | "context" | "system"`, any case). + pub fn parse(s: &str) -> Option { + match s.to_lowercase().as_str() { + "arabic" => Some(NumeralMode::Arabic), + "hindi" => Some(NumeralMode::Hindi), + "context" => Some(NumeralMode::Context), + "system" => Some(NumeralMode::System), + _ => None, + } + } + + /// Resolve `System` against the system locale (a BCP 47 or POSIX tag such as `ar-EG` or + /// `fa_IR.UTF-8`, found by the shell: layout never reads the environment). Hindi digits for + /// Arabic-script languages, else Western; unknown locale = Western. Other modes pass through. + pub fn resolve(self, system_locale: Option<&str>) -> NumeralMode { + if self != NumeralMode::System { + return self; + } + if system_locale.is_some_and(hindi_locale) { NumeralMode::Hindi } else { NumeralMode::Arabic } + } + + /// The displayed form of `c` in a run shaped with direction `rtl`. + /// + /// One character maps to exactly one character, so shaped text stays codepoint-aligned + /// with the document text (only byte offsets shift). A `System` mode that was not + /// resolved falls back to Western digits. + pub fn map(self, c: char, rtl: bool) -> char { + let hindi = match self { + NumeralMode::Arabic => false, + NumeralMode::Hindi => true, + NumeralMode::Context => rtl, + NumeralMode::System => false, + }; + if hindi { + if c.is_ascii_digit() { char::from_u32(0x660 + (c as u32 - '0' as u32)).unwrap_or(c) } else { c } + } else if ('\u{660}'..='\u{669}').contains(&c) { + (b'0' + (c as u32 - 0x660) as u8) as char + } else { + c + } + } +} + +/// Does locale `tag` use Hindi digits (Arabic, Persian, Urdu, Pashto, Sindhi, Kurdish)? +fn hindi_locale(tag: &str) -> bool { + let lang = tag.split(['_', '-', '.', '@']).next().unwrap_or_default().to_ascii_lowercase(); + matches!(lang.as_str(), "ar" | "fa" | "ur" | "ps" | "sd" | "ku" | "ckb") +} + #[derive(Clone, Debug, Default)] pub struct LayoutOptions { pub view: ViewMode, @@ -66,6 +148,9 @@ pub struct LayoutOptions { pub deletions_in_balloons: bool, /// Check spelling as you type (squiggles). pub proofing: bool, + /// How digits look on screen (display only; the text is untouched). Callers resolve + /// `System` ([`NumeralMode::resolve`]); an unresolved `System` shows Western digits. + pub numeral: NumeralMode, /// Print Markup (PDF, print, page images): widen each page by [`balloons::MARKUP_AREA`] /// and show the comments there in balloons joined to their anchors, as Word prints them. /// The editor draws its own interactive balloons and leaves this off. @@ -409,6 +494,7 @@ fn env_hash(doc: &Document, opts: &LayoutOptions) -> u64 { opts.hide_deleted, opts.deletions_in_balloons, opts.proofing, + opts.numeral, opts.field_codes, opts.proof, wordcraft_proof::dictionary_generation(), @@ -549,6 +635,7 @@ impl Ctx<'_> { hide_deleted: false, table: None, proofing: None, + numeral: self.opts.numeral, exclusions: &[], eq_number: 0, grid: para::Grid::default(), @@ -618,6 +705,7 @@ impl Ctx<'_> { hide_deleted: self.hide_deleted, table, proofing: self.opts.proofing.then_some(self.opts.proof), + numeral: self.opts.numeral, exclusions, eq_number, grid: self.grid, @@ -2715,6 +2803,7 @@ fn next_first_line(ctx: &mut Ctx, block: usize, width: f32) -> f32 { hide_deleted: ctx.opts.hides_deleted(StoryRef::Body), table: None, proofing: None, + numeral: ctx.opts.numeral, exclusions: &[], eq_number: ctx.eq_count, grid: ctx.grid, @@ -3038,6 +3127,8 @@ mod tests_complex; #[cfg(test)] mod tests_grid; #[cfg(test)] +mod tests_numeral; +#[cfg(test)] mod tests_pages; #[cfg(test)] mod tests_textbox; diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 85e8e0c00..e896016cc 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -12,6 +12,7 @@ use wordcraft_doc::resolve::{ResolvedChar, ResolvedPara}; use wordcraft_doc::{Document, Paragraph}; use wordcraft_fonts::FaceRef; +use crate::NumeralMode; use crate::fields::{FIELD_SHADING, FieldCtx, field_code_text, field_text}; /// A face at a size with the formatting needed to draw it. @@ -334,6 +335,8 @@ pub struct ParaEnv<'a> { pub table: Option<&'a CellText>, /// Check spelling (and grammar, as the options say) for squiggles; `None` = no squiggles. pub proofing: Option, + /// How digits look on screen (an unresolved `System` shows Western digits). + pub numeral: NumeralMode, /// Areas text must flow around (floating objects), relative to the paragraph: x from the /// column's left edge, y from the top of the first line. pub exclusions: &'a [Exclusion], @@ -467,6 +470,64 @@ fn is_mark(c: char) -> bool { matches!(unicode_bidi::bidi_class(c), NSM | BN) && !c.is_control() } +/// A digit Word's Numeral modes substitute: ASCII or Arabic-Indic (U+0660–U+0669). +fn is_numeral_digit(c: char) -> bool { + c.is_ascii_digit() || ('\u{660}'..='\u{669}').contains(&c) +} + +/// Characters skipped when looking for the text around a number: digits, spaces and the +/// Arabic decimal/thousands separators (٫ ٬). +fn is_number_inside(c: char) -> bool { + is_numeral_digit(c) || ('\u{6f0}'..='\u{6f9}').contains(&c) || c == ' ' || c == '\t' || c == '\u{66b}' || c == '\u{66c}' +} + +/// Which digits of run `text` sit in right-to-left surroundings (Word › Numeral › Context), by +/// byte offset. European numbers take an even bidi level, so a digit's own level says little; +/// the nearest neighbour on either side that isn't part of the number (digits, spaces, +/// separators) decides. One pass each way, so long runs of digits and spaces stay linear. +/// Offsets past `levels` count as left to right. +fn context_digit_dirs(text: &str, levels: &[u8], base: usize) -> Vec { + let odd = |o: usize| levels.get(base.saturating_add(o)).is_some_and(|l| l % 2 == 1); + let mut out = vec![false; text.len()]; + let mut left_rtl = false; + for (o, c) in text.char_indices() { + if !is_number_inside(c) { + left_rtl = odd(o); + } else if is_numeral_digit(c) + && let Some(v) = out.get_mut(o) + { + *v = odd(o) || left_rtl; + } + } + let mut right_rtl = false; + for (o, c) in text.char_indices().rev() { + if !is_number_inside(c) { + right_rtl = odd(o); + } else if right_rtl + && is_numeral_digit(c) + && let Some(v) = out.get_mut(o) + { + *v = true; + } + } + out +} + +/// Map a byte range of numeral-substituted display text back to the original text. +/// +/// `map_starts[i]` is the display byte offset of the `i`th char and `orig_of[i]` its original +/// byte offset (`orig_of` ends with the original length as a sentinel). Substitution is +/// 1 char → 1 char, so both ends land on char boundaries; anything else falls back to the +/// end of the original text instead of panicking or slicing mid-character. +pub(crate) fn remap_range(map_starts: &[usize], orig_of: &[usize], orig_len: usize, ms: usize, me: usize) -> (usize, usize) { + let n = map_starts.len(); + let i = map_starts.partition_point(|&b| b < ms).min(n); + let j = map_starts.partition_point(|&b| b < me).min(n); + let s = orig_of.get(i).copied().unwrap_or(orig_len); + let e = orig_of.get(j).copied().unwrap_or(orig_len); + (s.min(e), s.max(e)) +} + /// Bidi embedding levels per byte of `text` (UAX #9 rules up to I2), or empty when the /// paragraph is left to right and has no right-to-left characters. pub fn bidi_levels(text: &str, rtl_para: bool) -> Vec { @@ -517,6 +578,15 @@ fn east_asian(c: char) -> bool { } impl<'a> Builder<'a> { + /// Displayed form of `c` at run-relative byte `at` (Word › Numeral): one char in, one + /// char out. `rtl` is the shaping direction; in Context mode `context` (from + /// [`context_digit_dirs`], empty otherwise) gives each digit's surroundings, since European + /// numbers take an even bidi level. + fn numeral_char(&self, context: &[bool], at: usize, c: char, rtl: bool) -> char { + let dir = if !context.is_empty() && is_numeral_digit(c) { context.get(at).copied().unwrap_or(false) } else { rtl }; + self.env.numeral.map(c, dir) + } + /// Style index for a resolved char in `face` (caps-scaled `size` override for small caps). fn style(&mut self, rc: &Arc, face_override: Option, small: bool) -> u16 { let r = wordcraft_fonts::word::resolve(&rc.font, rc.bold, rc.italic); @@ -595,6 +665,7 @@ impl<'a> Builder<'a> { .filter(|r| !r.substituted) .map(|r| r.face); let use_levels = para_text && !self.levels.is_empty(); + let context = if self.env.numeral == NumeralMode::Context && use_levels { context_digit_dirs(text, &self.levels, base) } else { Vec::new() }; // One fallback face for the run's East Asian characters: the first face in fallback order // covering every character the primary font lacks, so the run draws in a single CJK face // instead of mixing faces per character (e.g. a bundled Japanese Mincho for JIS-covered @@ -616,27 +687,31 @@ impl<'a> Builder<'a> { let mut cur: Option = None; let mut segs: Vec<(usize, usize, SegKey)> = Vec::new(); for (i, c) in text.char_indices() { + let rtl = use_levels && self.levels.get(base + i).is_some_and(|l| l % 2 == 1); + // Displayed form (Word › Numeral): one char in, one char out. Coverage and the + // fallback face are picked for the displayed char, so Hindi digits land in an + // Arabic-capable face and Western digits in a Latin one. + let dc = self.numeral_char(&context, i, c, rtl); let mark = is_mark(c); let complex = match cur { Some(k) if mark => k.complex, - _ => rc_cs.is_some() && rc.uses_complex(c), + _ => rc_cs.is_some() && rc.uses_complex(dc), }; let prim = if complex { primary_cs.unwrap_or(primary) } else { primary }; let face = match cur { Some(k) if mark => k.face, _ if !complex && east_asian(c) && primary_ea.is_some_and(|f| f.covers(c)) => primary_ea, - _ if prim.covers(c) || c.is_whitespace() || c.is_control() || c == SOFT_HYPHEN => None, + _ if prim.covers(dc) || c.is_whitespace() || c.is_control() || c == SOFT_HYPHEN => None, // Stay in the fallback face the word started in while it covers the letters. - Some(SegKey { face: Some(f), .. }) if f.covers(c) => Some(f), + Some(SegKey { face: Some(f), .. }) if f.covers(dc) => Some(f), // The run's unified East Asian face, when one face covers the whole run. _ if !complex && ea_face.is_some() && crate::kinsoku::is_east_asian(c) => ea_face, _ => { let (bold, italic) = if complex { (rc.bold_cs, rc.italic_cs) } else { (rc.bold, rc.italic) }; - wordcraft_fonts::FontDb::global().fallback_styled(c, prim.id(), bold, italic).map(|f| FaceRef::of(&f)) + wordcraft_fonts::FontDb::global().fallback_styled(dc, prim.id(), bold, italic).map(|f| FaceRef::of(&f)) } }; let small = rc.small_caps && !rc.caps && c.is_lowercase(); - let rtl = use_levels && self.levels.get(base + i).is_some_and(|l| l % 2 == 1); let k = SegKey { face, small, complex, rtl }; match cur { Some(p) if p.same(&k) => {} @@ -662,21 +737,38 @@ impl<'a> Builder<'a> { let si = self.style(rc, key.face, small); let Some(st) = self.styles.get(si as usize).cloned() else { continue }; let upper = rc.caps || small; + // Displayed text: numeral substitution is 1 char → 1 char, so codepoints stay + // aligned with the document and only byte offsets shift; `orig_of` maps each + // displayed char back to its original byte offset (+ a `sub.len()` sentinel). + let substituted = sub.char_indices().any(|(i, c)| self.numeral_char(&context, a + i, c, key.rtl) != c); + let mut mapped = String::new(); + let mut map_starts: Vec = Vec::new(); + let mut orig_of: Vec = Vec::new(); + if substituted { + mapped = String::with_capacity(sub.len()); + for (i, c) in sub.char_indices() { + map_starts.push(mapped.len()); + orig_of.push(i); + mapped.push(self.numeral_char(&context, a + i, c, key.rtl)); + } + orig_of.push(sub.len()); + } + let shape_text = if substituted { mapped.as_str() } else { sub }; let map = |c: char| if upper { c.to_uppercase().next().unwrap_or(c) } else { c }; let shaped = if use_levels { - wordcraft_fonts::shape_run(&st.face, sub, &[], map, key.rtl) + wordcraft_fonts::shape_run(&st.face, shape_text, &[], map, key.rtl) } else { - wordcraft_fonts::shape(&st.face, sub, &[], map) + wordcraft_fonts::shape(&st.face, shape_text, &[], map) }; let k = st.size / st.face.upem.max(1.0) as f32; let hscale = rc.scale / 100.0; // Group glyphs by cluster byte offset; graphemes may span several shaper clusters. // Complex-script syllables the graphemes split (Myanmar vowel signs, stacked // consonants) stay one cluster: the caret never stops inside them. - let bounds: Vec = wordcraft_doc::cluster::cluster_starts(sub); + let bounds: Vec = wordcraft_doc::cluster::cluster_starts(shape_text); let mut gi = 0usize; for (bi, &gs) in bounds.iter().enumerate() { - let ge = bounds.get(bi + 1).copied().unwrap_or(sub.len()); + let ge = bounds.get(bi + 1).copied().unwrap_or(shape_text.len()); let g0 = self.glyphs.len() as u32; let mut adv = 0.0f32; while let Some(g) = shaped.get(gi) { @@ -688,7 +780,7 @@ impl<'a> Builder<'a> { gi += 1; } let g1 = self.glyphs.len() as u32; - let s = sub.get(gs..ge).unwrap_or(""); + let s = shape_text.get(gs..ge).unwrap_or(""); let ch = s.chars().next().unwrap_or(' '); let kind = kind_override.unwrap_or(if ch == ' ' || ch == '\u{3000}' { ClKind::Space @@ -698,9 +790,11 @@ impl<'a> Builder<'a> { ClKind::Text }); let adv = if kind == ClKind::Marker { 0.0 } else { adv + rc.spacing }; + // Clusters index the document text, never the displayed text. + let (cs, ce) = if substituted { remap_range(&map_starts, &orig_of, sub.len(), gs, ge) } else { (gs, ge) }; self.clusters.push(Cluster { - start: base + a + gs, - end: base + a + ge, + start: base + a + cs, + end: base + a + ce, adv, kind, style: si, diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index c305674be..c0e80fa25 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -168,6 +168,8 @@ pub fn measure_table_columns(doc: &wordcraft_doc::Document, t: &Table) -> Vec<(f hide_deleted: false, table: text.as_ref(), proofing: None, + // Measurement uses a fixed environment (like show_hidden/proofing above). + numeral: crate::NumeralMode::default(), exclusions: &[], eq_number: 0, grid: crate::para::Grid::default(), diff --git a/crates/layout/src/tests_numeral.rs b/crates/layout/src/tests_numeral.rs new file mode 100644 index 000000000..9de3e8ee8 --- /dev/null +++ b/crates/layout/src/tests_numeral.rs @@ -0,0 +1,178 @@ +//! Numeral display modes (Word › File › Options › Advanced › Numeral): Western digits, +//! Hindi digits, or context-following digits — display only, the text is untouched. + +use super::*; +use wordcraft_doc::props::ParaProps; +use wordcraft_doc::{Pos, StoryRef}; + +fn lay_opts(text: &str, rtl_para: bool, numeral: NumeralMode) -> DocLayout { + let mut d = Document::from_text(text); + if rtl_para { + let at = Pos::body(0, 0); + d.format_paragraphs(&at, &at, &|p: &mut ParaProps| p.bidi = Some(true)).unwrap(); + } + let mut c = LayoutCache::new(); + layout(&d, &mut c, &LayoutOptions { numeral, ..LayoutOptions::default() }) +} + +/// Glyph ids of the first line's clusters covering `text`, in cluster order. +fn line_gids(l: &DocLayout, text: &str) -> Vec> { + for p in &l.pages { + for it in &p.items { + if let Placed::Lines { story: StoryRef::Body, para, .. } = it { + let line = ¶.lines[0]; + let mut out = Vec::new(); + for k in line.c0..line.c1 { + let cl = ¶.clusters[k]; + assert!(cl.start <= cl.end && cl.end <= text.len(), "cluster indexes the original text"); + let gids: Vec = para.glyphs[cl.g0 as usize..cl.g1 as usize].iter().map(|g| g.gid).collect(); + out.push(gids); + } + return out; + } + } + } + panic!("body paragraph not laid out"); +} + +#[test] +fn parse_accepts_the_four_modes_any_case() { + assert_eq!(NumeralMode::parse("arabic"), Some(NumeralMode::Arabic)); + assert_eq!(NumeralMode::parse("hindi"), Some(NumeralMode::Hindi)); + assert_eq!(NumeralMode::parse("context"), Some(NumeralMode::Context)); + assert_eq!(NumeralMode::parse("system"), Some(NumeralMode::System)); + assert_eq!(NumeralMode::parse("Hindi"), Some(NumeralMode::Hindi)); + assert_eq!(NumeralMode::parse("CONTEXT"), Some(NumeralMode::Context)); + assert_eq!(NumeralMode::parse("western"), None); + assert_eq!(NumeralMode::parse(""), None); + assert_eq!(NumeralMode::parse("hindi "), None); +} + +#[test] +fn map_substitutes_digits_both_ways() { + for d in '0'..='9' { + let hindi = char::from_u32(0x660 + (d as u32 - '0' as u32)).unwrap(); + assert_eq!(NumeralMode::Hindi.map(d, false), hindi); + assert_eq!(NumeralMode::Hindi.map(d, true), hindi, "Hindi mode ignores direction"); + assert_eq!(NumeralMode::Arabic.map(hindi, false), d); + assert_eq!(NumeralMode::Arabic.map(hindi, true), d, "Arabic mode converts back both ways"); + assert_eq!(NumeralMode::Arabic.map(d, false), d); + assert_eq!(NumeralMode::Context.map(d, true), hindi); + assert_eq!(NumeralMode::Context.map(d, false), d); + assert_eq!(NumeralMode::Context.map(hindi, true), hindi); + assert_eq!(NumeralMode::Context.map(hindi, false), d); + } + // Letters, punctuation and Persian digits are never touched. + for c in ['A', 'ب', '.', ' ', '(', '۰', '۹'] { + for m in [NumeralMode::Arabic, NumeralMode::Hindi, NumeralMode::Context] { + assert_eq!(m.map(c, false), c, "{m:?} leaves {c:?} alone (ltr)"); + assert_eq!(m.map(c, true), c, "{m:?} leaves {c:?} alone (rtl)"); + } + } +} + +#[test] +fn resolve_uses_the_given_system_locale() { + for m in [NumeralMode::Arabic, NumeralMode::Hindi, NumeralMode::Context] { + assert_eq!(m.resolve(Some("ar-EG")), m, "only System is resolved"); + } + for tag in ["ar", "ar-EG", "fa_IR.UTF-8", "ur-PK", "ckb-IQ", "AR-sa"] { + assert_eq!(NumeralMode::System.resolve(Some(tag)), NumeralMode::Hindi, "{tag}"); + } + for tag in ["en-US", "C", "", "tr_TR", "ary"] { + assert_eq!(NumeralMode::System.resolve(Some(tag)), NumeralMode::Arabic, "{tag}"); + } + assert_eq!(NumeralMode::System.resolve(None), NumeralMode::Arabic, "unknown locale: Western digits"); +} + +#[test] +fn context_mode_on_a_very_long_digit_run_stays_linear() { + // Every digit used to scan back over all the digits and spaces before it (O(n²)). + let text = format!("سنة {}", "1 ".repeat(100_000)); + let start = std::time::Instant::now(); + let l = lay_opts(&text, false, NumeralMode::Context); + assert!(!l.pages.is_empty()); + assert!(start.elapsed() < std::time::Duration::from_secs(60), "took {:?}", start.elapsed()); + // The digits follow the Arabic word before them. + let short = "سنة 1 2 3"; + assert_eq!(line_gids(&lay_opts(short, false, NumeralMode::Context), short), line_gids(&lay_opts(short, false, NumeralMode::Hindi), short)); +} + +#[test] +fn hindi_mode_reshapes_digits_but_keeps_offsets() { + let text = "Year 2024!"; + let latin = lay_opts(text, false, NumeralMode::Arabic); + let hindi = lay_opts(text, false, NumeralMode::Hindi); + let a = line_gids(&latin, text); + let h = line_gids(&hindi, text); + assert_eq!(a.len(), h.len(), "same clusters in both modes"); + assert!(!a.is_empty()); + // Letter clusters draw identical glyphs; digit clusters draw different ones. + for (n, (ga, gh)) in a.iter().zip(h.iter()).enumerate() { + let ch = text.chars().nth(n).unwrap(); + if ch.is_ascii_digit() { + assert_ne!(ga, gh, "digit {ch:?} reshaped in Hindi mode"); + assert!(!gh.is_empty(), "Hindi digit draws something"); + } else { + assert_eq!(ga, gh, "{ch:?} untouched by Hindi mode"); + } + } +} + +#[test] +fn arabic_mode_converts_hindi_digits_back() { + let text = "عام ٢٠٢٤"; + let hindi = lay_opts(text, true, NumeralMode::Hindi); + let arabic = lay_opts(text, true, NumeralMode::Arabic); + let h = line_gids(&hindi, text); + let a = line_gids(&arabic, text); + assert_eq!(h.len(), a.len()); + let mut changed = 0; + for (n, (gh, ga)) in h.iter().zip(a.iter()).enumerate() { + if text.chars().nth(n).unwrap() == '٢' { + assert_ne!(gh, ga); + changed += 1; + } + } + assert!(changed > 0, "typed Hindi digits display Western in Arabic mode"); +} + +#[test] +fn context_mode_follows_run_direction() { + // Right-to-left paragraph: digits behave like Hindi mode. + let rtl = "سنة 2024"; + let ctx = line_gids(&lay_opts(rtl, true, NumeralMode::Context), rtl); + let hin = line_gids(&lay_opts(rtl, true, NumeralMode::Hindi), rtl); + assert_eq!(ctx, hin, "digits in RTL text show Hindi in Context mode"); + // Left-to-right paragraph: digits behave like Arabic mode. + let ltr = "Year 2024"; + let ctx = line_gids(&lay_opts(ltr, false, NumeralMode::Context), ltr); + let ara = line_gids(&lay_opts(ltr, false, NumeralMode::Arabic), ltr); + assert_eq!(ctx, ara, "digits in LTR text show Western in Context mode"); + // Mixed paragraph: digits next to Arabic show Hindi even in a left-to-right paragraph. + let mixed = "Year سنة 2024"; + let ctx = line_gids(&lay_opts(mixed, false, NumeralMode::Context), mixed); + let hin = line_gids(&lay_opts(mixed, false, NumeralMode::Hindi), mixed); + assert_eq!(ctx, hin, "digits by Arabic text show Hindi in Context mode"); +} + +#[test] +fn remap_range_maps_display_bytes_to_original() { + use crate::para::remap_range; + // "a5ب": '5' (1 byte) displays as '٥' (2 bytes), so display offsets shift after it. + let orig = "a5ب"; + let disp = "a٥ب"; + let map_starts: Vec = disp.char_indices().map(|(i, _)| i).collect(); + let orig_of: Vec = orig.char_indices().map(|(i, _)| i).collect(); + let mut orig_of = orig_of; + orig_of.push(orig.len()); + // Display "a" [0,1) → original [0,1); "٥" [1,3) → original [1,2); "ب" [3,5) → [2,4). + assert_eq!(remap_range(&map_starts, &orig_of, orig.len(), 0, 1), (0, 1)); + assert_eq!(remap_range(&map_starts, &orig_of, orig.len(), 1, 3), (1, 2)); + assert_eq!(remap_range(&map_starts, &orig_of, orig.len(), 3, 5), (2, 4)); + assert_eq!(remap_range(&map_starts, &orig_of, orig.len(), 0, 5), (0, 4)); + // Hostile inputs never panic and stay in range. + assert_eq!(remap_range(&[], &[], 0, 0, 0), (0, 0)); + assert_eq!(remap_range(&map_starts, &orig_of, orig.len(), 99, 200), (4, 4)); + assert_eq!(remap_range(&map_starts, &orig_of, orig.len(), 3, 1), (1, 2)); +} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 786462613..3569ce21d 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2048,3 +2048,8 @@ Opens Settings › Default apps, where you choose WordCraft for .docx and the other document types. Otevře Nastavení › Výchozí aplikace, kde zvolíte WordCraft pro .docx a další typy dokumentů. To open Word documents with WordCraft by default: select one in Finder, choose File › Get Info, pick WordCraft under Open with, then click Change All. Chcete-li dokumenty Word otevírat ve WordCraftu jako výchozí: vyberte jeden ve Finderu, zvolte Soubor › Informace, v části „Otevřít v aplikaci“ vyberte WordCraft a klikněte na „Změnit vše“. WordCraft is now the default app for Word documents. WordCraft je nyní výchozí aplikací pro dokumenty Word. + Numerals: Číslice: + Numerals Číslice + Hindi Hindské + Context Podle kontextu + System Systémové diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 354d07f13..e2b62dca5 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2042,3 +2042,8 @@ Opens Settings › Default apps, where you choose WordCraft for .docx and the other document types. Öffnet Einstellungen › Standard-Apps, wo Sie WordCraft für .docx und die anderen Dokumenttypen wählen. To open Word documents with WordCraft by default: select one in Finder, choose File › Get Info, pick WordCraft under Open with, then click Change All. Um Word-Dokumente standardmäßig mit WordCraft zu öffnen: Wählen Sie eines im Finder aus, wählen Sie Ablage › Informationen, wählen Sie WordCraft unter „Öffnen mit“ und klicken Sie auf „Alle ändern“. WordCraft is now the default app for Word documents. WordCraft ist jetzt die Standard-App für Word-Dokumente. + Numerals: Ziffern: + Numerals Ziffern + Hindi Hindi + Context Kontext + System System diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index eb39d48a6..e2bff070e 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1648,6 +1648,12 @@ Enlarge symbol Agrandar símbolo Enclosure Contorno Circle Círculo + Numerals: Números: + Numerals Números + Arabic Árabe + Hindi Hindi + Context Contexto + System Sistema Templates Plantillas Templates and Add-ins Plantillas y complementos Attach Template Adjuntar plantilla @@ -1993,7 +1999,6 @@ Add line numbering Agregar numeración de líneas Add to Document Agregar al documento Address labels Etiquetas de dirección - Arabic Árabe Auto Auto Automatically hyphenate document Separar palabras automáticamente en el documento Chinese (Simplified) Chino (simplificado) diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index cbafdc326..5d0da6a22 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -1579,6 +1579,12 @@ Right (vertical) Paremal (vertikaalne) Ruby text: Ruby-tekst: Size (pt): Suurus (pt): + Numerals: Numbrid: + Numerals Numbrid + Arabic Araabia + Hindi Hindi + Context Kontekst + System Süsteem Couldn't make WordCraft the default: {error} WordCrafti ei õnnestunud vaikerakenduseks määrata: {error} In Default apps, choose WordCraft for the document types you want it to open. Vali jaotises „Vaikerakendused“ WordCraft nende dokumenditüüpide jaoks, mida see peaks avama. Make WordCraft the default for Word documents… Määra WordCraft Wordi dokumentide vaikerakenduseks… @@ -1987,7 +1993,6 @@ Add line numbering Lisa reanummerdus Add to Document Lisa dokumenti Address labels Aadressisildid - Arabic Araabia Auto Automaatne Automatically hyphenate document Poolita dokumenti automaatselt Chinese (Simplified) Hiina (lihtsustatud) diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index c11bba484..661f6494b 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2427,3 +2427,8 @@ preview Title Esempio Opens Settings › Default apps, where you choose WordCraft for .docx and the other document types. Apre Impostazioni › App predefinite, dove scegli WordCraft per .docx e gli altri tipi di documento. To open Word documents with WordCraft by default: select one in Finder, choose File › Get Info, pick WordCraft under Open with, then click Change All. Per aprire i documenti Word con WordCraft per impostazione predefinita: selezionane uno nel Finder, scegli Archivio › Ottieni informazioni, scegli WordCraft in «Apri con», quindi fai clic su «Modifica tutto». WordCraft is now the default app for Word documents. WordCraft è ora l'app predefinita per i documenti Word. + Numerals: Cifre: + Numerals Cifre + Hindi Hindi + Context Contesto + System Sistema diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 5637bcb6d..bc0f918fe 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1632,6 +1632,12 @@ Enlarge symbol 記号を拡大 Enclosure 囲い Circle 円 + Numerals: 数字: + Numerals 数字 + Arabic アラビア + Hindi ヒンディー + Context コンテキスト + System システム Templates テンプレート Templates and Add-ins テンプレートとアドイン Attach Template テンプレートの添付 @@ -1977,7 +1983,6 @@ Add line numbering 行番号を追加する Add to Document 文書に追加 Address labels 宛名ラベル - Arabic アラビア語 Auto 自動 Automatically hyphenate document 文書を自動的にハイフネーションする Chinese (Simplified) 中国語(簡体字) diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index aca3d2e30..41fd289a3 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2060,3 +2060,8 @@ Opens Settings › Default apps, where you choose WordCraft for .docx and the other document types. Otwiera Ustawienia › Aplikacje domyślne, gdzie wybierasz WordCraft dla .docx i innych typów dokumentów. To open Word documents with WordCraft by default: select one in Finder, choose File › Get Info, pick WordCraft under Open with, then click Change All. Aby otwierać dokumenty Word domyślnie w WordCraft: zaznacz jeden w Finderze, wybierz Plik › Pokaż info, wybierz WordCraft w „Otwórz za pomocą”, a następnie kliknij „Zmień wszystkie”. WordCraft is now the default app for Word documents. WordCraft jest teraz domyślną aplikacją dla dokumentów Word. + Numerals: Cyfry: + Numerals Cyfry + Hindi Hindi + Context Kontekstowe + System Systemowe diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 6d36b89c1..fdf4dc3e5 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1644,6 +1644,12 @@ Enlarge symbol Ampliar símbolo Enclosure Contorno Circle Círculo + Numerals: Numerais: + Numerals Numerais + Arabic Arábico + Hindi Hindi + Context Contexto + System Sistema Templates Modelos Templates and Add-ins Modelos e suplementos Attach Template Anexar modelo @@ -1989,7 +1995,6 @@ Add line numbering Adicionar numeração de linhas Add to Document Adicionar ao documento Address labels Etiquetas de endereço - Arabic Árabe Auto Auto Automatically hyphenate document Hifenizar o documento automaticamente Chinese (Simplified) Chinês (simplificado) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index f0a879940..cdf839295 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1998,6 +1998,12 @@ preview Title Пример Search icons Поиск значков No icons match your search. Нет значков, соответствующих поиску. Recolor a one-color picture, such as an icon Перекрасить одноцветный рисунок, например значок + Numerals: Цифры: + Numerals Цифры + Arabic Арабские + Hindi Хинди + Context Контекст + System Системные Couldn't make WordCraft the default: {error} Не удалось сделать WordCraft приложением по умолчанию: {error} In Default apps, choose WordCraft for the document types you want it to open. В разделе «Приложения по умолчанию» выберите WordCraft для нужных типов документов. Make WordCraft the default for Word documents… Сделать WordCraft приложением по умолчанию для документов Word… @@ -2323,7 +2329,6 @@ preview Title Пример Add line numbering Добавить нумерацию строк Add to Document Добавить в документ Address labels Адресные наклейки - Arabic Арабский Auto Авто Automatically hyphenate document Автоматически расставлять переносы в документе Chinese (Simplified) Китайский (упрощённый) diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index ff2ddf278..386a00fdb 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -1642,6 +1642,12 @@ Enlarge symbol Uvećaj simbol Enclosure Okvir Circle Krug + Numerals: Brojevi: + Numerals Brojevi + Arabic Arapski + Hindi Hindi + Context Kontekst + System Sistem Templates Šabloni Templates and Add-ins Šabloni i dodaci Attach Template Priloži šablon @@ -1987,7 +1993,6 @@ Add line numbering Dodaj brojeve redova Add to Document Dodaj u dokument Address labels Adresne nalepnice - Arabic Arapski Auto Automatski Automatically hyphenate document Automatski deli reči u dokumentu Chinese (Simplified) Kineski (pojednostavljeni) diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index f79d5532d..458f39877 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -1646,6 +1646,12 @@ Enlarge symbol Увећај симбол Enclosure Оквир Circle Круг + Numerals: Бројеви: + Numerals Бројеви + Arabic Арапски + Hindi Хинди + Context Контекст + System Систем Templates Шаблони Templates and Add-ins Шаблони и додаци Attach Template Приложи шаблон @@ -1991,7 +1997,6 @@ Add line numbering Додај бројеве редова Add to Document Додај у документ Address labels Адресне налепнице - Arabic Арапски Auto Аутоматски Automatically hyphenate document Аутоматски дели речи у документу Chinese (Simplified) Кинески (поједностављени) diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index bb878d5d7..1dab1298e 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1623,6 +1623,12 @@ Search icons Simgelerde ara No icons match your search. Aramanızla eşleşen simge yok. Recolor a one-color picture, such as an icon Simge gibi tek renkli bir resmi yeniden renklendir + Numerals: Rakamlar: + Numerals Rakamlar + Arabic Arapça + Hindi Hintçe + Context Bağlam + System Sistem Couldn't make WordCraft the default: {error} WordCraft varsayılan yapılamadı: {error} In Default apps, choose WordCraft for the document types you want it to open. Varsayılan uygulamalar bölümünde, WordCraft'ın açmasını istediğiniz belge türlerini seçin. Make WordCraft the default for Word documents… WordCraft'ı Word belgeleri için varsayılan yap… @@ -1945,7 +1951,6 @@ Add line numbering Satır numaralandırma ekle Add to Document Belgeye Ekle Address labels Adres etiketleri - Arabic Arapça Auto Otomatik Automatically hyphenate document Belgede otomatik heceleme yap Chinese (Simplified) Çince (Basitleştirilmiş) diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index f314bda22..ddd6b55fe 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1644,6 +1644,12 @@ Enlarge symbol Збільшити символ Enclosure Обведення Circle Коло + Numerals: Цифри: + Numerals Цифри + Arabic Арабські + Hindi Гінді + Context Контекст + System Системні Templates Шаблони Templates and Add-ins Шаблони й надбудови Attach Template Приєднати шаблон @@ -1989,7 +1995,6 @@ Add line numbering Додати нумерацію рядків Add to Document Додати до документа Address labels Адресні етикетки - Arabic Арабська Auto Авто Automatically hyphenate document Автоматично переносити слова в документі Chinese (Simplified) Китайська (спрощена) diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 132eaa569..e52228b7d 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1644,6 +1644,12 @@ Enlarge symbol 增大圈号 Enclosure 圈号 Circle 圆形 + Numerals: 数字: + Numerals 数字 + Arabic 阿拉伯 + Hindi 印地 + Context 上下文 + System 系统 Templates 模板 Templates and Add-ins 模板和加载项 Attach Template 附加模板 @@ -1989,7 +1995,6 @@ Add line numbering 添加行号 Add to Document 添加到文档 Address labels 地址标签 - Arabic 阿拉伯语 Auto 自动 Automatically hyphenate document 自动断字 Chinese (Simplified) 中文(简体) diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index c584d5a75..89bbe5a33 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1633,6 +1633,12 @@ Enlarge symbol 放大符號 Enclosure 圍繞符號 Circle 圓形 + Numerals: 數字: + Numerals 數字 + Arabic 阿拉伯 + Hindi 印地 + Context 上下文 + System 系統 Templates 範本 Templates and Add-ins 範本與增益集 Attach Template 附加範本 @@ -1978,7 +1984,6 @@ Add line numbering 新增行號 Add to Document 新增至文件 Address labels 地址標籤 - Arabic 阿拉伯文 Auto 自動 Automatically hyphenate document 自動斷字 Chinese (Simplified) 中文(簡體) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f043923de..90e228c6b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -404,6 +404,10 @@ impl SaveChoice { impl WordApp { pub fn new(session: Session, services: Services) -> Self { + // Numeral › System follows the system locale, found here rather than in layout. + if let Some(tag) = sys_locale::get_locale() { + wordcraft_engine::set_system_locale(&tag); + } WordApp { session, ui: UiState::default(), diff --git a/crates/ui-egui/src/options.rs b/crates/ui-egui/src/options.rs index 9f3550460..ab27e63db 100644 --- a/crates/ui-egui/src/options.rs +++ b/crates/ui-egui/src/options.rs @@ -191,6 +191,7 @@ fn display(app: &mut WordApp, ui: &mut Ui) { if ui.checkbox(&mut ruler, tl!("Show rulers")).changed() { let _ = app.run("view.ruler", json!({"value": ruler})); } + numeral_picker(app, ui); ui.add_space(8.0); section(ui, "Printing"); ui.checkbox(&mut app.ui.print_backgrounds, tl!("Print the page color")); @@ -504,6 +505,24 @@ fn default_app_button(app: &mut WordApp, ui: &mut Ui) { } } +/// File ▸ Options ▸ Numerals: how digits look on screen (display only, like Word's +/// File › Options › Advanced › Numeral). +fn numeral_picker(app: &mut WordApp, ui: &mut Ui) { + use wordcraft_layout::NumeralMode; + ui.horizontal(|ui| { + ui.label(tl!("Numerals:")); + let mut sel = app.session.view.numeral; + egui::ComboBox::from_id_salt("numerals").selected_text(tl!(sel.label())).width(220.0).show_ui(ui, |ui| { + for m in [NumeralMode::Arabic, NumeralMode::Hindi, NumeralMode::Context, NumeralMode::System] { + ui.selectable_value(&mut sel, m, tl!(m.label())); + } + }); + if sel != app.session.view.numeral { + let _ = app.run("view.numeral", json!({ "value": sel })); + } + }); +} + #[cfg(test)] mod tests { use egui_kittest::kittest::Queryable; From 6cf0aec0ffa61c73e124f540797dfdee582e916f Mon Sep 17 00:00:00 2001 From: Cyberverse-cent0 Date: Mon, 12 Oct 2026 09:08:11 +0000 Subject: [PATCH 15/88] Fix bulleted list toolbar button active state (#651) * Fix bulleted list toolbar button active state (#642) Add para.state command to return paragraph formatting state (bullets, numbering, multilevel) and update ribbon to use it for list button active states. Previously, the bullets, numbering, and multilevel list buttons never showed as active even when the cursor was inside a list item. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * Review fixes for #651: list buttons from the paragraph's own level - The Bullets/Numbering state reads the paragraph's list level (ilvl), not level 0, and Multilevel is on when the list numbers its levels together (a label showing another level's number, or legal numbering); the old level-0 '%2.' check was never true. - Computed from the paragraph props the ribbon already resolves (cmd::para::list_buttons) instead of a new para.state command. - Engine test. Co-Authored-By: Claude Opus 5.5 * #651: list-button test starts legal numbering from plain text Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/para.rs | 26 ++++++++++++++++++++++++++ crates/engine/src/tests.rs | 32 ++++++++++++++++++++++++++++++++ crates/ui-egui/src/ribbon.rs | 7 ++++--- 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/para.rs b/crates/engine/src/cmd/para.rs index 1bb48312f..bc2a1bd10 100644 --- a/crates/engine/src/cmd/para.rs +++ b/crates/engine/src/cmd/para.rs @@ -1146,3 +1146,29 @@ fn own_format(st: &Style) -> Value { pub fn touch_all(s: &mut Session) { s.dirty = true; } + +/// Which Home › Paragraph list buttons show as on. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct ListButtons { + pub bullets: bool, + pub numbering: bool, + pub multilevel: bool, +} + +/// The list buttons for a paragraph with resolved `numbering` (the ribbon passes the paragraph +/// props it already resolved). The paragraph's own level decides Bullets (a bulleted level) or +/// Numbering (a numbered one); Multilevel is on too when the list numbers its levels together: +/// some level's label shows another level's number (`1.1.`) or uses legal numbering. +pub fn list_buttons(doc: &wordcraft_doc::Document, numbering: Option) -> ListButtons { + let Some(n) = numbering.filter(|n| n.num != 0) else { return ListButtons::default() }; + let Some(level) = doc.numbering.level(n.num, n.level) else { return ListButtons::default() }; + let bullets = level.format == wordcraft_doc::section::NumFormat::Bullet; + let joined = |i: u8| { + doc.numbering.level(n.num, i).is_some_and(|l| { + let own = u32::from(i) + 1; + l.legal || l.text.split('%').skip(1).filter_map(|t| t.chars().next()?.to_digit(10)).any(|k| k != own) + }) + }; + let multilevel = (0..9u8).any(joined); + ListButtons { bullets, numbering: !bullets, multilevel } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e292a8f60..09b6dbf3a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3597,6 +3597,38 @@ fn enclose_characters_make_an_eq_field_and_come_back_out() { assert_eq!(field(&s), Some(("eq \\o\\ac(△,A)".to_string(), "A".to_string()))); } +#[test] +fn list_buttons_follow_the_paragraphs_own_level() { + use crate::cmd::para::{ListButtons, list_buttons}; + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "item"})); + let state = |s: &Session| { + let rp = s.doc.para_at(&s.sel.focus).map(|p| s.doc.styles.resolve_para(&p.props)); + list_buttons(&s.doc, rp.and_then(|r| r.numbering)) + }; + assert_eq!(state(&s), ListButtons::default(), "plain text: no list button on"); + run(&mut s, "para.bullets", json!({})); + assert_eq!(state(&s), ListButtons { bullets: true, numbering: false, multilevel: false }); + // A bulleted list whose second level is numbered: at level 2 the paragraph is numbered. + let num = s.doc.para_at(&s.sel.focus).unwrap().props.numbering.unwrap().num; + let numbered = wordcraft_doc::numbering::Level { text: "%2.".into(), ..Default::default() }; + s.doc.numbering.nums.iter_mut().find(|n| n.id == num).unwrap().level_overrides.push((1, numbered)); + run(&mut s, "para.listLevel", json!({"level": 1})); + assert_eq!(state(&s), ListButtons { bullets: false, numbering: true, multilevel: false }); + run(&mut s, "para.numbering", json!({})); + assert_eq!(state(&s), ListButtons { bullets: false, numbering: true, multilevel: false }, "1. a. i. is not multilevel"); + // Legal numbering (1. 1.1. 1.1.1.) numbers levels together, even on its first level. (Its + // first level reads like "1." numbering, which the button would toggle off, so start plain.) + run(&mut s, "para.numbering", json!({"off": true})); + run(&mut s, "para.multilevel", json!({"kind": "legal"})); + run(&mut s, "para.listLevel", json!({"level": 0})); + assert_eq!(state(&s), ListButtons { bullets: false, numbering: true, multilevel: true }); + run(&mut s, "para.listLevel", json!({"level": 2})); + assert!(state(&s).multilevel); + run(&mut s, "para.numbering", json!({"off": true})); + assert_eq!(state(&s), ListButtons::default()); +} + #[test] fn replace_all_splits_paragraphs_at_a_paragraph_mark_in_the_replacement() { // Issue #423: untracked Replace All left `\r` in the paragraph instead of splitting it. diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index b374376bd..50b8f8c98 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -560,9 +560,10 @@ fn home(app: &mut WordApp, ui: &mut Ui) { // The buttons show alignment as seen on the page (Align Right is a right-to-left paragraph's start). let align = rp.as_ref().map(|r| r.align.visual(r.bidi)).unwrap_or_default(); let rtl = rp.as_ref().is_some_and(|r| r.bidi); + let lists = wordcraft_engine::cmd::para::list_buttons(&app.session.doc, rp.as_ref().and_then(|r| r.numbering)); stack(ui, |ui| { crate::widgets::row(ui, |ui| { - split(ui, app, "bullets", "Bullets", "para.bullets", json!({}), false, None, |ui, app| { + split(ui, app, "bullets", "Bullets", "para.bullets", json!({}), lists.bullets, None, |ui, app| { ui.label(egui::RichText::new(tl!("Bullet Library")).small().weak()); ui.horizontal(|ui| { for c in ["•", "○", "▪", "◆", "➢", "✓", "–"] { @@ -583,7 +584,7 @@ fn home(app: &mut WordApp, ui: &mut Ui) { }); mi(ui, app, "None", "para.bullets", json!({"off": true})); }); - split(ui, app, "numbering", "Numbering", "para.numbering", json!({}), false, None, |ui, app| { + split(ui, app, "numbering", "Numbering", "para.numbering", json!({}), lists.numbering, None, |ui, app| { for (l, k) in [ ("1. 2. 3.", "numbered"), ("1) 2) 3)", "numberedParen"), @@ -598,7 +599,7 @@ fn home(app: &mut WordApp, ui: &mut Ui) { mi(ui, app, "Restart at 1", "para.restartNumbering", json!({})); mi(ui, app, "None", "para.numbering", json!({"off": true})); }); - split(ui, app, "multilevel", "Multilevel List", "para.multilevel", json!({}), false, None, |ui, app| { + split(ui, app, "multilevel", "Multilevel List", "para.multilevel", json!({}), lists.multilevel, None, |ui, app| { mi(ui, app, "1. 1.1. 1.1.1.", "para.multilevel", json!({"kind": "legal"})); mi(ui, app, "I. A. 1. a.", "para.multilevel", json!({"kind": "outline"})); ui.separator(); From 59b04d764472b7c195f88cae7e15736b18a96885 Mon Sep 17 00:00:00 2001 From: Debarghaya Mitra <175645893+GhostMitra@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:41:28 +0530 Subject: [PATCH 16/88] web: use File System Access API showSaveFilePicker with download fallback (#728) * web: use File System Access API showSaveFilePicker with download fallback * Review fixes for #728: mark the document saved only after the picker writes the file A cancelled or failed save in the browser's save picker marks the document unsaved again, and the beforeunload guard keeps warning while a picker save is in flight. The picker's file types use our own neutral format names. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- apps/wordcraft-web/src/web.rs | 189 +++++++++++++++++++++++++++++++--- contributors/people.toml | 2 - crates/ui-egui/src/lib.rs | 2 +- 3 files changed, 176 insertions(+), 17 deletions(-) diff --git a/apps/wordcraft-web/src/web.rs b/apps/wordcraft-web/src/web.rs index bc5e47987..fc250a071 100644 --- a/apps/wordcraft-web/src/web.rs +++ b/apps/wordcraft-web/src/web.rs @@ -1,7 +1,7 @@ //! The browser shell: web `Services`, drag-and-drop, the unsaved-changes guard and the eframe //! web runner. -use std::cell::Cell; +use std::cell::{Cell, RefCell}; use std::rc::Rc; use wasm_bindgen::JsCast as _; @@ -27,7 +27,8 @@ pub fn start() { return; }; let dirty = Rc::new(Cell::new(false)); - if let Err(e) = guard_unload(dirty.clone()) { + let saves = Rc::new(PickerSaves::default()); + if let Err(e) = guard_unload(dirty.clone(), saves.clone()) { log::error!("no unsaved-changes guard: {e}"); } let mut options = eframe::WebOptions::default(); @@ -51,9 +52,9 @@ pub fn start() { log::error!("pasting pictures is unavailable: {e}"); } let doc = if query().contains("sample") { wordcraft_engine::sample::sample_document() } else { wordcraft_doc::Document::new() }; - let mut app = WordApp::new(Session::new(doc), services(inbox.clone(), cc.egui_ctx.clone(), dirty)); + let mut app = WordApp::new(Session::new(doc), services(inbox.clone(), cc.egui_ctx.clone(), dirty, saves.clone())); app.autosave = false; - Ok(Box::new(WebShell { app, inbox })) + Ok(Box::new(WebShell { app, inbox, saves })) }), ) .await; @@ -75,6 +76,38 @@ fn query() -> String { struct WebShell { app: WordApp, inbox: Inbox, + saves: Rc, +} + +/// Saves the browser's save picker finishes after `download` has returned (#728). The app marks +/// the document saved as soon as `download` returns, so a cancelled or failed picker save must +/// mark it unsaved again, and the `beforeunload` guard keeps warning while one is in flight. +#[derive(Default)] +struct PickerSaves { + /// Picker saves started and not yet written and closed. + pending: Cell, + /// Picker saves that didn't write the file: (name, error, or `None` when cancelled). + unsaved: RefCell)>>, +} + +impl WebShell { + /// A picker save that didn't happen leaves the document with unsaved changes. + fn settle_picker_saves(&mut self) { + let unsaved = std::mem::take(&mut *self.saves.unsaved.borrow_mut()); + for (name, error) in unsaved { + // Only a save in a format that keeps everything marked the document saved. + if wordcraft_ui_egui::keeps_everything(&name) { + self.app.session.dirty = true; + } + if let Some(error) = error { + let msg = wordcraft_ui_egui::i18n::fmt( + wordcraft_ui_egui::i18n::t("Couldn't download {name}: {error}"), + &[("name", &name), ("error", &error)], + ); + self.app.status(msg); + } + } + } } impl eframe::App for WebShell { @@ -94,6 +127,7 @@ impl eframe::App for WebShell { } }); } + self.settle_picker_saves(); self.app.logic(ctx); } @@ -153,8 +187,9 @@ fn listen_for_pasted_pictures(inbox: Inbox, ctx: egui::Context) -> Result<(), St } /// `dirty` is the flag the `beforeunload` guard reads ([`guard_unload`]). -fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc>) -> Services { +fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc>, saves: Rc) -> Services { let open_inbox = inbox.clone(); + let save_ctx = ctx.clone(); Services { open_async: Some(Box::new(move |purpose: &str| { let inbox = open_inbox.clone(); @@ -179,7 +214,7 @@ fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc>) -> Services })), // Exports ask for a name; the browser decides where the download goes. pick_save: Some(Box::new(|name: &str| Some(name.to_string()))), - download: Some(Box::new(download)), + download: Some(Box::new(move |path: &str, bytes: &[u8]| download(&saves, &save_ctx, path, bytes))), print: Some(Box::new(print_pdf)), inbox: Some(inbox), on_dirty: Some(Box::new(move |d| dirty.set(d))), @@ -200,10 +235,11 @@ fn animations_enabled() -> Box Option> { /// Closing, reloading or leaving the tab with unsaved changes asks first. Browsers show their own /// confirmation (a page can only ask for it), so there is no Save button: Cancel, then File › Save. -fn guard_unload(dirty: Rc>) -> Result<(), String> { +fn guard_unload(dirty: Rc>, saves: Rc) -> Result<(), String> { let window = web_sys::window().ok_or("no window")?; let on_unload = wasm_bindgen::closure::Closure::::new(move |e: web_sys::BeforeUnloadEvent| { - if dirty.get() { + // A save still in the browser's picker hasn't written anything yet. + if dirty.get() || saves.pending.get() > 0 { e.prevent_default(); // Older browsers ask only when `returnValue` is set. e.set_return_value("unsaved changes"); @@ -215,20 +251,145 @@ fn guard_unload(dirty: Rc>) -> Result<(), String> { Ok(()) } -/// Trigger a browser download of `bytes` named after the last component of `path`. -fn download(path: &str, bytes: &[u8]) -> Result<(), String> { - let js = |e: wasm_bindgen::JsValue| format!("{e:?}"); +/// Trigger a browser save dialog or download of `bytes` named after the last component of `path`. +fn download(saves: &Rc, ctx: &egui::Context, path: &str, bytes: &[u8]) -> Result<(), String> { let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "document".into()); let window = web_sys::window().ok_or("no window")?; + + // On browsers supporting the File System Access API (Chromium: Chrome, Edge, etc.), + // `showSaveFilePicker` lets the user choose the save location and filename natively. + if let Ok(picker_val) = js_sys::Reflect::get(&window, &wasm_bindgen::JsValue::from_str("showSaveFilePicker")) + && let Ok(picker_fn) = picker_val.dyn_into::() + { + match trigger_save_file_picker(saves, ctx, &window, &picker_fn, &name, bytes) { + Ok(()) => return Ok(()), + Err(e) => { + log::warn!("showSaveFilePicker failed: {e}; falling back to anchor download"); + } + } + } + + // Fallback for browsers without showSaveFilePicker (e.g. Firefox, Safari): + trigger_anchor_download(&window, &name, bytes) +} + +fn trigger_save_file_picker( + saves: &Rc, + ctx: &egui::Context, + window: &web_sys::Window, + picker_fn: &js_sys::Function, + name: &str, + bytes: &[u8], +) -> Result<(), String> { + let js = |e: wasm_bindgen::JsValue| format!("{e:?}"); + let ext = std::path::Path::new(name).extension().and_then(|e| e.to_str()).unwrap_or("docx").to_ascii_lowercase(); + let dot_ext = format!(".{ext}"); + let mime = mime_for(name); + + let accept_obj = js_sys::Object::new(); + let exts_arr = js_sys::Array::new(); + exts_arr.push(&wasm_bindgen::JsValue::from_str(&dot_ext)); + let _ = js_sys::Reflect::set(&accept_obj, &wasm_bindgen::JsValue::from_str(mime), &exts_arr); + + // Our own neutral description: the format's name, as its extension says it. + let type_desc = match ext.as_str() { + "htm" => "HTML".to_string(), + "txt" => "Text".to_string(), + e if !e.is_empty() && e.len() <= 8 && e.chars().all(|c| c.is_ascii_alphanumeric()) => e.to_ascii_uppercase(), + _ => "Document".to_string(), + }; + + let type_obj = js_sys::Object::new(); + let _ = js_sys::Reflect::set(&type_obj, &wasm_bindgen::JsValue::from_str("description"), &wasm_bindgen::JsValue::from_str(&type_desc)); + let _ = js_sys::Reflect::set(&type_obj, &wasm_bindgen::JsValue::from_str("accept"), &accept_obj); + + let types_arr = js_sys::Array::new(); + types_arr.push(&type_obj); + + let picker_opts = js_sys::Object::new(); + let _ = js_sys::Reflect::set(&picker_opts, &wasm_bindgen::JsValue::from_str("suggestedName"), &wasm_bindgen::JsValue::from_str(name)); + let _ = js_sys::Reflect::set(&picker_opts, &wasm_bindgen::JsValue::from_str("types"), &types_arr); + + // Call showSaveFilePicker synchronously while transient user activation is active: + let prom_val = picker_fn.call1(window, &picker_opts).map_err(js)?; + let prom: js_sys::Promise = prom_val.dyn_into().map_err(|_| "not a promise".to_string())?; + + let bytes_vec = bytes.to_vec(); + let saves = saves.clone(); + let ctx = ctx.clone(); + let name = name.to_string(); + saves.pending.set(saves.pending.get().saturating_add(1)); + wasm_bindgen_futures::spawn_local(async move { + // The document counts as saved only once the file is written and closed. + let unsaved = match wasm_bindgen_futures::JsFuture::from(prom).await { + Ok(handle_val) => match write_file_handle(&handle_val, &bytes_vec).await { + Ok(()) => { + log::info!("saved {name} through the save picker"); + None + } + Err(e) => { + log::error!("couldn't write {name}: {e}"); + Some(Some(e)) + } + }, + Err(e) => { + let err_name = + js_sys::Reflect::get(&e, &wasm_bindgen::JsValue::from_str("name")).ok().and_then(|v| v.as_string()).unwrap_or_default(); + if err_name == "AbortError" { + log::debug!("save picker cancelled"); + Some(None) + } else { + log::error!("showSaveFilePicker error: {e:?}"); + Some(Some(format!("{e:?}"))) + } + } + }; + if let Some(error) = unsaved { + saves.unsaved.borrow_mut().push((name, error)); + } + saves.pending.set(saves.pending.get().saturating_sub(1)); + ctx.request_repaint(); + }); + Ok(()) +} + +async fn write_file_handle(handle: &wasm_bindgen::JsValue, bytes: &[u8]) -> Result<(), String> { + let js = |e: wasm_bindgen::JsValue| format!("{e:?}"); + let create_fn: js_sys::Function = js_sys::Reflect::get(handle, &wasm_bindgen::JsValue::from_str("createWritable")) + .map_err(js)? + .dyn_into() + .map_err(|_| "createWritable is not a function".to_string())?; + let stream_prom: js_sys::Promise = create_fn.call0(handle).map_err(js)?.dyn_into().map_err(|_| "not a promise".to_string())?; + let stream = wasm_bindgen_futures::JsFuture::from(stream_prom).await.map_err(js)?; + + let write_fn: js_sys::Function = js_sys::Reflect::get(&stream, &wasm_bindgen::JsValue::from_str("write")) + .map_err(js)? + .dyn_into() + .map_err(|_| "write is not a function".to_string())?; + let uint8 = js_sys::Uint8Array::from(bytes); + let write_prom: js_sys::Promise = write_fn.call1(&stream, &uint8).map_err(js)?.dyn_into().map_err(|_| "not a promise".to_string())?; + wasm_bindgen_futures::JsFuture::from(write_prom).await.map_err(js)?; + + let close_fn: js_sys::Function = js_sys::Reflect::get(&stream, &wasm_bindgen::JsValue::from_str("close")) + .map_err(js)? + .dyn_into() + .map_err(|_| "close is not a function".to_string())?; + let close_prom: js_sys::Promise = close_fn.call0(&stream).map_err(js)?.dyn_into().map_err(|_| "not a promise".to_string())?; + wasm_bindgen_futures::JsFuture::from(close_prom).await.map_err(js)?; + Ok(()) +} + +fn trigger_anchor_download(window: &web_sys::Window, name: &str, bytes: &[u8]) -> Result<(), String> { + let js = |e: wasm_bindgen::JsValue| format!("{e:?}"); let document = window.document().ok_or("no document")?; let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes)); let opts = web_sys::BlobPropertyBag::new(); - opts.set_type(mime_for(&name)); + opts.set_type(mime_for(name)); let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?; let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?; - let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(js)?.dyn_into().map_err(|_| "not an anchor")?; + let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(js)?.dyn_into().map_err(|_| "not an anchor".to_string())?; a.set_href(&url); - a.set_download(&name); + a.set_download(name); a.style().set_property("display", "none").map_err(js)?; let body = document.body().ok_or("no body")?; body.append_child(&a).map_err(js)?; diff --git a/contributors/people.toml b/contributors/people.toml index 2fffb68df..08212c6cb 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -59,6 +59,4 @@ display_name = "Gleyson Carvalho" sync_github_name = false [people.alexanderalber] real_name = "Alexander Alber" - [people.edurbs] -sync_github_name = true diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 90e228c6b..19295c700 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1565,7 +1565,7 @@ impl AutoSaveBlock { /// Whether saving to `name` keeps the whole document (the formats `file.save` treats as the /// document's own file, including macro-enabled documents and templates); anything else is a /// copy that leaves it unsaved. AutoSave writes only these formats. -fn keeps_everything(name: &str) -> bool { +pub fn keeps_everything(name: &str) -> bool { let ext = std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); matches!(ext.as_str(), "docx" | "docm" | "dotx" | "dotm" | "odt" | "rtf" | "json" | "xml") } From 439cfd2fe977612456c6563e1cf72628374bfae0 Mon Sep 17 00:00:00 2001 From: Debarghaya Mitra <175645893+GhostMitra@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:56:06 +0530 Subject: [PATCH 17/88] Backstage: add Save As page and close on successful save (#727) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Backstage: add Save As page and close on successful save * Review fixes for #727: Save As page without the open list; unnamed Save opens no native dialog The Save As page reused the Open page's recent list, so clicking a document there opened it. It now has only Browse…. Save on a document without a file goes to the Save As page instead of running file.save, which also opened the native Save As dialog. Test for both. Co-Authored-By: Claude Opus 5.5 * Print-page test clicks the page's Print button (the backstage list now has a Print entry too) --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/backstage.rs | 142 ++++++++++++++++++++++++++++- crates/ui-egui/src/file_dialogs.rs | 7 +- crates/ui-egui/src/lib.rs | 4 +- 3 files changed, 146 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 0e2970ce8..4156006f8 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -59,6 +59,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { ui.add_space(if mac { 12.0 } else { 6.0 }); for (id, label) in PAGES { let (r, resp) = ui.allocate_exact_size(vec2(200.0, 38.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!(label))); let active = app.ui.backstage_page == id; if active { ui.painter().rect_filled(r, 0.0, egui::Color32::from_white_alpha(46)); @@ -74,14 +75,23 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { ); if resp.clicked() { match id { + // A document without a file goes to the Save As page (as in Word) instead + // of `file.save`, which would also open the native Save As dialog. + // In the browser a save is a download, so it saves right away. + "save" if app.session.path.is_none() && app.services.download.is_none() => { + app.ui.backstage_page = "saveAs".into(); + } "save" => { - let _ = app.run("file.save", json!({})); + let r = app.run("file.save", json!({})); + if r.is_ok_and(|v| v.get("saved").and_then(serde_json::Value::as_bool) == Some(true) || v.get("downloaded").is_some()) { + app.ui.backstage = false; + app.canvas.want_focus = true; + } else { + app.ui.backstage_page = "saveAs".into(); + } } "saveAs" => { - app.save_as_dialog(); - } - "open" => { - app.ui.backstage_page = id.into(); + app.ui.backstage_page = "saveAs".into(); } _ => app.ui.backstage_page = id.into(), } @@ -90,6 +100,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| { for (label, id) in [("About", "about"), ("Discord community", "discord")] { let (r, resp) = ui.allocate_exact_size(vec2(200.0, 32.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!(label))); if resp.hovered() { ui.painter().rect_filled(r, 0.0, egui::Color32::from_white_alpha(26)); } @@ -109,6 +120,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { "home" => home_page(app, ui), "new" => new_page(app, ui), "open" => open_page(app, ui), + "saveAs" => save_as_page(app, ui), "info" => info_page(app, ui), "print" | "export" => export_page(app, ui), "options" => crate::options::page(app, ui), @@ -277,6 +289,15 @@ fn open_page(app: &mut WordApp, ui: &mut Ui) { open_list(app, ui); } +/// Save As: Browse… asks where to save. It has no recent-documents list: clicking one of those +/// opens it, which is not what Save As is for. +fn save_as_page(app: &mut WordApp, ui: &mut Ui) { + heading(ui, "Save As"); + if ui.button(egui::RichText::new(tl!("Browse…")).font(medium(14.0))).clicked() { + app.save_as_dialog(); + } +} + fn info_page(app: &mut WordApp, ui: &mut Ui) { heading(ui, "Info"); let info = app.session.run("file.info", &json!({})).unwrap_or_default(); @@ -469,4 +490,115 @@ mod tests { a.session.run("edit.undo", &json!({})).unwrap(); assert_eq!(a.session.doc.core.title, ""); } + + #[test] + fn save_as_page_shows_browse_and_no_open_list() { + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Services::default()); + a.run("ui.backstage", json!({"value": true, "page": "saveAs"})).unwrap(); + 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); + }, + a, + ); + h.run_steps(4); + assert!(h.query_by_label("Browse…").is_some(), "Save As has Browse button"); + assert!(h.query_by_label("Recent").is_none(), "Save As has no list that opens documents"); + } + + #[test] + fn backstage_save_navigates_to_save_as_when_unnamed() { + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Services::default()); + a.run("ui.backstage", json!({"value": true, "page": "home"})).unwrap(); + 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); + }, + a, + ); + h.run_steps(4); + h.get_by_label("Save").click(); + h.run_steps(4); + assert_eq!(h.state().ui.backstage_page, "saveAs", "unnamed Save opens Save As page"); + assert!(h.state().ui.backstage, "backstage remains open"); + } + + /// Save on a document without a file shows the Save As page only: no native dialog as well. + #[test] + fn backstage_save_when_unnamed_opens_no_file_dialog() { + let shown = std::rc::Rc::new(std::cell::Cell::new(0)); + let count = shown.clone(); + let services = Services { + file_dialog: Some(Box::new(move |_| { + count.set(count.get() + 1); + std::sync::mpsc::channel::().1 + })), + ..Default::default() + }; + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), services); + a.run("ui.backstage", json!({"value": true, "page": "home"})).unwrap(); + 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); + }, + a, + ); + h.run_steps(4); + h.get_by_label("Save").click(); + h.run_steps(4); + assert_eq!(h.state().ui.backstage_page, "saveAs"); + assert_eq!(shown.get(), 0, "no native Save As dialog"); + assert!(!h.state().file_dialog_open()); + } + + #[test] + fn backstage_save_closes_when_saved() { + let got: std::rc::Rc)>>> = Default::default(); + let sink = got.clone(); + let services = Services { + download: Some(Box::new(move |n: &str, b: &[u8]| { + sink.borrow_mut().push((n.to_string(), b.to_vec())); + Ok(()) + })), + ..Default::default() + }; + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), services); + a.run("ui.backstage", json!({"value": true, "page": "home"})).unwrap(); + 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); + }, + a, + ); + h.run_steps(4); + h.get_by_label("Save").click(); + h.run_steps(4); + assert!(!h.state().ui.backstage, "successful save closes backstage"); + } + + #[test] + fn backstage_save_as_sidebar_opens_save_as_page() { + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Services::default()); + a.run("ui.backstage", json!({"value": true, "page": "home"})).unwrap(); + 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); + }, + a, + ); + h.run_steps(4); + h.get_by_label("Save As").click(); + h.run_steps(4); + assert_eq!(h.state().ui.backstage_page, "saveAs"); + } } diff --git a/crates/ui-egui/src/file_dialogs.rs b/crates/ui-egui/src/file_dialogs.rs index 788f81b67..5ee9b420a 100644 --- a/crates/ui-egui/src/file_dialogs.rs +++ b/crates/ui-egui/src/file_dialogs.rs @@ -233,8 +233,13 @@ impl WordApp { } AfterPick::SaveAs(after) => { let saved = picked.is_some_and(|path| { - self.run("file.save", json!({"path": path})).is_ok_and(|v| v.get("saved").and_then(Value::as_bool) == Some(true)) + self.run("file.save", json!({"path": path})) + .is_ok_and(|v| v.get("saved").and_then(Value::as_bool) == Some(true) || v.get("downloaded").is_some()) }); + if saved { + self.ui.backstage = false; + self.canvas.want_focus = true; + } match after { AfterSave::Nothing => Ok(json!({"saved": saved})), AfterSave::AutoSave => { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 19295c700..5bb7524f0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2539,7 +2539,9 @@ mod tests { assert_eq!(h.query_by_label(BLURB).is_some(), hook, "hook {hook}: the Print button's blurb"); assert!(h.query_by_label("PDF document (*.pdf)").is_some(), "hook {hook}: the page rendered its export choices"); if hook { - h.get_by_role_and_label(egui::accesskit::Role::Button, "Print").click(); + // The page's own Print button (the backstage list has a Print entry too): the last. + let buttons: Vec<_> = h.query_all(egui_kittest::kittest::By::new().role(egui::accesskit::Role::Button).label("Print")).collect(); + buttons.last().expect("a Print button").click(); h.step(); h.step(); assert_eq!(printed.get(), 1, "the button printed"); From 25048c8b9c4ca80c302125e09ae327b6d2edccdb Mon Sep 17 00:00:00 2001 From: ripdog Date: Mon, 12 Oct 2026 22:29:57 +1300 Subject: [PATCH 18/88] M7: ignore ellipses in sentence capitalization proofing (#742) Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/proof/src/lib.rs | 30 ++++++++++++++++++++++++++++-- docs/typing-parity.md | 3 ++- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/crates/proof/src/lib.rs b/crates/proof/src/lib.rs index 925ab7ecf..71a0e4cb4 100644 --- a/crates/proof/src/lib.rs +++ b/crates/proof/src/lib.rs @@ -539,8 +539,12 @@ pub fn check_grammar(text: &str) -> Vec { }); } let tail = text.get(*b..).unwrap_or(""); - let next_non_space = tail.trim_start_matches(['"', '”', ')', '\'']).chars().next(); - after_end = matches!(next_non_space, Some('.' | '!' | '?')) && tail.trim_start_matches(['.', '!', '?', '"', '”', ')']).starts_with(' '); + let punctuation = tail.trim_start_matches(['"', '”', ')', '\'']); + // Two or more dots are an ellipsis, not a full stop ending a sentence. + // A Unicode ellipsis (U+2026) does not match sentence-ending punctuation either. + after_end = matches!(punctuation.chars().next(), Some('.' | '!' | '?')) + && !punctuation.starts_with("..") + && punctuation.trim_start_matches(['.', '!', '?', '"', '”', ')']).starts_with(' '); } v.sort_by_key(|i| i.start); v @@ -655,6 +659,28 @@ mod tests { assert!(check_grammar("A university is an hour away.").is_empty(), "{:?}", check_grammar("A university is an hour away.")); } + #[test] + fn ellipses_do_not_start_sentences() { + for text in [ + "I saw... a figure right outside.", + "I saw… a figure right outside.", + "I saw.... a figure right outside.", + "I saw... \"a figure\" right outside.", + "I saw… “a figure” right outside.", + ] { + let capitals: Vec<_> = + check_grammar(text).into_iter().filter(|issue| issue.message == "Capitalize the first word of a sentence").collect(); + assert!(capitals.is_empty(), "{text}: {capitals:?}"); + } + + for text in ["I saw. a figure.", "I saw! a figure.", "I saw? a figure.", "I saw.\" a figure."] { + let capitals: Vec<_> = + check_grammar(text).into_iter().filter(|issue| issue.message == "Capitalize the first word of a sentence").collect(); + assert_eq!(capitals.len(), 1, "{text}: {capitals:?}"); + assert_eq!(capitals[0].suggestions, ["A"], "{text}"); + } + } + #[test] fn user_dictionary_works() { assert!(!is_correct("zxqwordcrafty")); diff --git a/docs/typing-parity.md b/docs/typing-parity.md index 41f37dcdd..5d67de23d 100644 --- a/docs/typing-parity.md +++ b/docs/typing-parity.md @@ -1,6 +1,6 @@ # Typing parity with Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** trivial (complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343; previously trivial: outline keys in Outline view, #493) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** trivial (grammar proofing: ellipses are not sentence boundaries; previously minor: pasted paragraph breaks tracked under Track Changes, kept as text in lists, #417, #422) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 What Word does when you type, observed black-box (same keystrokes typed into Word and WordCraft, results read back paragraph by paragraph) and pinned by tests in @@ -135,6 +135,7 @@ Word. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Grammar proofing (not AutoCorrect) no longer suggests capitalising a sentence after `...` or `…` (`proof::tests::ellipses_do_not_start_sentences`) | | 2026-10-11 | minor | AutoFormat tables from `+---+`, arrow bullets, smileys inside words, undo after a border line (#546) | | 2026-10-11 | trivial | Outline view keys: expand/collapse, Show Level, moving and promoting with collapsed content (#493) | | 2026-10-11 | minor | Editing options: typing replaces selection, overtype and the Insert key (#485) | From de8a3daf31f2ff99dcd4bae431243180460bf6e1 Mon Sep 17 00:00:00 2001 From: osaym <68712890+Osaym@users.noreply.github.com> Date: Mon, 12 Oct 2026 05:32:58 -0400 Subject: [PATCH 19/88] Fix paragraph alignment focus (#690) * Fix paragraph alignment focus Fixes #686 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Review fixes for #690: every ribbon command button returns focus to the document Instead of four hard-coded alignment ids, big, small and split buttons and ribbon menu items hand keyboard focus back to the canvas after running any engine command (ui.* commands open dialogs and panes, which take focus themselves). The test also clicks Bold. The ui-parity.md note moves into the change log. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 41 +++++++++++++++++++++++++++++++++++ crates/ui-egui/src/ribbon.rs | 4 ++-- crates/ui-egui/src/widgets.rs | 17 ++++++++++++--- docs/ui-parity.md | 3 ++- 4 files changed, 59 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5bb7524f0..c0e7d809c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1676,6 +1676,7 @@ pub fn now_ms() -> f64 { #[cfg(test)] mod tests { use super::*; + use egui_kittest::kittest::Queryable; fn mod_key(key: egui::Key) -> egui::Event { egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND } @@ -2641,6 +2642,46 @@ mod tests { assert_eq!(reported.get(), Some(true), "the host was told before the frame ended"); } + #[test] + fn paragraph_alignment_keeps_the_canvas_focused_for_typing() { + use wordcraft_doc::Align; + + 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); + }, + typed(), + ); + for _ in 0..6 { + h.step(); + } + + for (label, alignment, text) in + [("Align Left", Align::Left, " left"), ("Center", Align::Center, " center"), ("Align Right", Align::Right, " right")] + { + h.get_by_role_and_label(egui::accesskit::Role::Button, label).click(); + h.step(); + + assert!(h.state().canvas.focused, "{label}: the canvas keeps keyboard focus"); + let paragraph = h.state().session.doc.para_at(&h.state().session.sel.focus).expect("the caret remains in its paragraph"); + assert_eq!(h.state().session.doc.styles.resolve_para(¶graph.props).align, alignment); + + h.event(egui::Event::Text(text.into())); + h.step(); + assert!(body_text(h.state()).ends_with(text), "{label}: typing continues in the document"); + } + + // Every command button does the same, not only alignment: Bold, then type. + h.get_by_role_and_label(egui::accesskit::Role::Button, "Bold").click(); + h.step(); + assert!(h.state().canvas.focused, "Bold: the canvas keeps keyboard focus"); + h.event(egui::Event::Text(" bold".into())); + h.step(); + assert!(body_text(h.state()).ends_with(" bold"), "Bold: typing continues in the document"); + } + /// #330: Word's shortcuts work from the keyboard — Ctrl+Alt+1 (⌘⌥1) applies Heading 1, and /// an Alt shortcut doesn't also type the character its key would produce. #[test] diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 50b8f8c98..43bfc4506 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -337,7 +337,7 @@ fn mi_text(ui: &mut Ui, app: &mut WordApp, text: &str, id: &str, params: Value) let enabled = crate::widgets::enabled(app, id); let resp = ui.add_enabled(enabled, egui::Button::new(text).shortcut_text(sc).min_size(vec2(200.0, 0.0))); if resp.clicked() { - let _ = app.run(id, params); + crate::widgets::run_from_ribbon(app, id, params); ui.close(); } } @@ -390,7 +390,7 @@ fn mi_check(ui: &mut Ui, app: &mut WordApp, label: &str, checked: bool, id: &str let enabled = crate::widgets::enabled(app, id); let resp = ui.add_enabled(enabled, egui::Button::new((mark, tl!(label))).selected(checked).min_size(vec2(200.0, 0.0))); if resp.clicked() { - let _ = app.run(id, params); + crate::widgets::run_from_ribbon(app, id, params); ui.close(); } } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 4df392c8c..37dab5a59 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -105,6 +105,16 @@ pub(crate) fn bg(ui: &Ui, r: Rect, resp: &Response, checked: bool, t: &Tokens) { } } +/// Runs a ribbon button's command, then hands keyboard focus back to the document so typing +/// continues without another click, as in Word (#686). Interface commands (`ui.*`) open dialogs, +/// panes and tabs that take focus themselves, so they keep it. +pub(crate) fn run_from_ribbon(app: &mut WordApp, id: &str, params: Value) { + let _ = app.run(id, params); + if !id.starts_with("ui.") { + app.canvas.want_focus = true; + } +} + /// A large ribbon button: 32 px icon over a (possibly two-line) label. pub fn big(ui: &mut Ui, app: &mut WordApp, icon: &str, label: &str, id: &str, params: Value, menu: bool) -> Response { big_button(ui, app, icon, label, id, params, menu, false) @@ -139,7 +149,7 @@ fn big_button(ui: &mut Ui, app: &mut WordApp, icon: &str, label: &str, id: &str, let resp = tooltip(app, resp, &label.replace('\n', " "), id); command_menu(&resp, app, id); if resp.clicked() && on && !menu { - let _ = app.run(id, params); + run_from_ribbon(app, id, params); } resp } @@ -162,8 +172,9 @@ pub fn small(ui: &mut Ui, app: &mut WordApp, icon: &str, label: Option<&str>, ti let resp = tooltip(app, resp, tip, id); command_menu(&resp, app, id); if resp.clicked() && on { - let _ = app.run(id, params); + run_from_ribbon(app, id, params); } + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, on, tip)); resp } @@ -195,7 +206,7 @@ pub fn split( let resp = tooltip(app, resp, tip, id); command_menu(&resp, app, id); if resp.clicked() { - let _ = app.run(id, params); + run_from_ribbon(app, id, params); } egui::Popup::menu(&aresp).show(|ui| { ui.set_min_width(180.0); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 59450e7c3..bc85f06c2 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:** minor (status bar language follows the caret and sets the proofing language; Review › Language is a menu; 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 (ribbon buttons return keyboard focus to the document, #686; previously trivial: screen readers can read the document canvas, #488; 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 @@ -43,6 +43,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Ribbon buttons that run a command (alignment, Bold, styles…) hand keyboard focus back to the document, so typing continues without another click; they also report their name and enabled state to screen readers (#686) | | 2026-10-11 | trivial | Tables on the page: column and row borders and the resize handle drag, with a dashed guide and one undo step per drag (#217) | | 2026-10-11 | trivial | Animation effects › Use system setting also reads macOS (Reduce motion) and the Linux/BSD desktops (settings portal: the cross-desktop reduced-motion preference, GNOME's switch, KDE's animation speed; the GTK and KDE settings files where no portal answers), and follows a change without a restart | | 2026-10-11 | trivial | Animation effects setting in File › Options › Accessibility (Use system setting, On, Off), kept between runs: the app eased wheel scrolling, zooming and its fades whatever the system's setting was; off, they land at once. Follows the system's setting on Windows and in browsers; `ui.animations` for agents | From 956963e9d9911807144380567e68638c3c1e22fb Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 05:36:02 -0400 Subject: [PATCH 20/88] ui-egui: fix Windows clippy in desktop_theme (#872) Bus is a unit struct off Linux, so Bus::default() tripped clippy::default_constructed_unit_structs on Windows and macOS. Each platform's Bus now has a plain constructor. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/desktop_theme.rs | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/desktop_theme.rs b/crates/ui-egui/src/desktop_theme.rs index 7f9ed410c..b49bbbc02 100644 --- a/crates/ui-egui/src/desktop_theme.rs +++ b/crates/ui-egui/src/desktop_theme.rs @@ -16,7 +16,7 @@ const POLL: Duration = Duration::from_secs(4); /// there is no session bus or no portal to ask (a bare X server, a container), so the caller /// keeps whatever it chose itself. pub fn prefers_dark() -> Option { - read(&mut Bus::default()) + read(&mut Bus::new()) } /// Watches the desktop and reports its choice when it changes. [`prefers_dark`] covers the @@ -38,7 +38,7 @@ impl DesktopTheme { let started = std::thread::Builder::new().name("wordcraft-desktop-theme".into()).spawn(move || { // One session-bus connection for the life of the watcher, opened again only after // it fails. - let mut bus = Bus::default(); + let mut bus = Bus::new(); let mut last = read(&mut bus); if tx.send(last).is_err() { return; @@ -81,13 +81,27 @@ impl DesktopTheme { /// The watcher's session-bus connection, kept between polls. #[cfg(target_os = "linux")] -#[derive(Default)] struct Bus(Option); +#[cfg(target_os = "linux")] +impl Bus { + /// No connection yet: the first read opens one. + fn new() -> Self { + Bus(None) + } +} + +/// Elsewhere there is no session bus to keep. #[cfg(not(target_os = "linux"))] -#[derive(Default)] struct Bus; +#[cfg(not(target_os = "linux"))] +impl Bus { + fn new() -> Self { + Bus + } +} + /// Ask the portal once, over `bus`'s connection (opened when there is none). D-Bus calls /// block, so this belongs off the UI thread. A connection whose calls all fail is dropped, so /// the next poll opens a fresh one (the bus or portal may have restarted). From d510d9a41ae00061601d30e5b205c5f603839fde Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Mon, 12 Oct 2026 11:39:06 +0200 Subject: [PATCH 21/88] #656: dialog buttons, Discord pill, eraser and search highlight use theme tokens (#705) 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 | 18 ++++-------------- crates/ui-egui/src/colors.rs | 2 +- crates/ui-egui/src/dialogs.rs | 6 +++--- crates/ui-egui/src/dialogs_insert.rs | 2 +- crates/ui-egui/src/dialogs_layout.rs | 13 ++++--------- crates/ui-egui/src/dialogs_notes.rs | 3 +-- crates/ui-egui/src/dialogs_refs.rs | 5 +---- crates/ui-egui/src/dialogs_review.rs | 5 ++--- crates/ui-egui/src/dialogs_templates.rs | 2 +- crates/ui-egui/src/panes.rs | 4 +++- crates/ui-egui/src/table_pen.rs | 4 ++-- crates/ui-egui/src/theme.rs | 19 +++++++++++++++++++ crates/ui-egui/src/widgets.rs | 6 ++++++ docs/ui-parity.md | 5 ++++- 14 files changed, 52 insertions(+), 42 deletions(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index f0dd42458..f3ad653e4 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -166,25 +166,15 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) { ui.add_space(8.0); if app.ui.show_discord { let (r, resp) = ui.allocate_exact_size(vec2(84.0, 22.0), Sense::click()); - ui.painter().rect_filled( - r, - 11.0, - if resp.hovered() { egui::Color32::from_rgb(0x47, 0x52, 0xC4) } else { egui::Color32::from_rgb(0x58, 0x65, 0xF2) }, - ); + ui.painter().rect_filled(r, 11.0, if resp.hovered() { t.accent_text } else { t.accent }); icons::paint( ui.painter(), Rect::from_center_size(pos2(r.min.x + 13.0, r.center().y), vec2(14.0, 14.0)), "discord", - egui::Color32::WHITE, - egui::Color32::WHITE, - ); - ui.painter().text( - pos2(r.min.x + 24.0, r.center().y), - Align2::LEFT_CENTER, - tl!("Discord"), - medium(11.5), - egui::Color32::WHITE, + t.on_accent, + t.on_accent, ); + ui.painter().text(pos2(r.min.x + 24.0, r.center().y), Align2::LEFT_CENTER, tl!("Discord"), medium(11.5), t.on_accent); if resp.on_hover_text(tl!("Join the ArtCraft community on Discord")).clicked() { let _ = app.run("ui.discord", json!({})); } diff --git a/crates/ui-egui/src/colors.rs b/crates/ui-egui/src/colors.rs index e82bd920c..8880a97c3 100644 --- a/crates/ui-egui/src/colors.rs +++ b/crates/ui-egui/src/colors.rs @@ -513,7 +513,7 @@ fn body(ui: &mut Ui, d: &mut ColorsDialog) -> Option { if ui.button(tl!("Cancel")).clicked() { out = Some(false); } - let okb = ui.add(egui::Button::new(egui::RichText::new(tl!("OK")).color(Color32::WHITE)).fill(crate::theme::APP_COLOR)); + let okb = ui.add(crate::widgets::primary_button(ui, tl!("OK"))); let typing = ui.ctx().memory(|m| m.focused().is_some()); if okb.clicked() || (!typing && ui.input(|i| i.key_pressed(egui::Key::Enter))) { out = Some(true); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d6654a884..64f162431 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1359,7 +1359,7 @@ pub(crate) fn buttons(ui: &mut Ui, ok: &str) -> (bool, bool) { if ui.button(tl!("Cancel")).clicked() || (keys && ui.input(|i| i.key_pressed(egui::Key::Escape))) { r.1 = true; } - let okb = ui.add(egui::Button::new(egui::RichText::new(ok).color(egui::Color32::WHITE)).fill(crate::theme::APP_COLOR)); + let okb = ui.add(crate::widgets::primary_button(ui, ok)); if okb.clicked() || (keys && ui.input(|i| i.key_pressed(egui::Key::Enter))) { r.0 = true; } @@ -2321,7 +2321,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let cancel = ui.button(tl!("Cancel")); let dont_save = ui.button(tl!("Don't Save")); - let save = ui.add(egui::Button::new(egui::RichText::new(tl!("Save")).color(egui::Color32::WHITE)).fill(crate::theme::APP_COLOR)); + let save = ui.add(crate::widgets::primary_button(ui, tl!("Save"))); [(cancel, "cancel"), (dont_save, "dontSave"), (save, "save")] }) .inner @@ -2939,7 +2939,7 @@ fn close_button(ui: &mut Ui) -> bool { ui.add_space(8.0); let mut close = ui.input(|i| i.key_pressed(egui::Key::Escape)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - close |= ui.add(egui::Button::new(egui::RichText::new(tl!("Close")).color(egui::Color32::WHITE)).fill(crate::theme::APP_COLOR)).clicked(); + close |= ui.add(crate::widgets::primary_button(ui, tl!("Close"))).clicked(); }); close } diff --git a/crates/ui-egui/src/dialogs_insert.rs b/crates/ui-egui/src/dialogs_insert.rs index 5fe756320..f681f0262 100644 --- a/crates/ui-egui/src/dialogs_insert.rs +++ b/crates/ui-egui/src/dialogs_insert.rs @@ -86,7 +86,7 @@ pub(crate) fn buttons(ui: &mut Ui, ok: &str, cancel: &str, ok_enabled: bool) -> if ui.button(tl!(cancel)).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) { r.1 = true; } - let okb = ui.add_enabled(ok_enabled, egui::Button::new(egui::RichText::new(tl!(ok)).color(Color32::WHITE)).fill(crate::theme::APP_COLOR)); + let okb = ui.add_enabled(ok_enabled, crate::widgets::primary_button(ui, tl!(ok))); if okb.clicked() || (ok_enabled && ui.input(|i| i.key_pressed(egui::Key::Enter))) { r.0 = true; } diff --git a/crates/ui-egui/src/dialogs_layout.rs b/crates/ui-egui/src/dialogs_layout.rs index 73c5de7ca..da903c567 100644 --- a/crates/ui-egui/src/dialogs_layout.rs +++ b/crates/ui-egui/src/dialogs_layout.rs @@ -6,7 +6,7 @@ //! and agents get the same result without the dialog. Shown through //! [`crate::dialogs::Dialog::Layout`]. -use egui::{Color32, Ui, vec2}; +use egui::{Ui, vec2}; use serde::Serialize; use serde_json::{Value, json}; @@ -86,7 +86,7 @@ fn buttons(ui: &mut Ui, ok: &str, ok_enabled: bool) -> (bool, bool) { if ui.button(tl!("Cancel")).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) { r.1 = true; } - let okb = ui.add_enabled(ok_enabled, egui::Button::new(egui::RichText::new(tl!(ok)).color(Color32::WHITE)).fill(crate::theme::APP_COLOR)); + let okb = ui.add_enabled(ok_enabled, crate::widgets::primary_button(ui, tl!(ok))); if okb.clicked() { r.0 = true; } @@ -362,7 +362,7 @@ fn manual_ui(app: &mut WordApp, ui: &mut Ui, f: &mut ManualForm) -> bool { if ui.button(tl!("No")).clicked() { f.skip(app); } - if ui.add(egui::Button::new(egui::RichText::new(tl!("Yes")).color(Color32::WHITE)).fill(crate::theme::APP_COLOR)).clicked() { + if ui.add(crate::widgets::primary_button(ui, tl!("Yes"))).clicked() { f.accept(app); } }); @@ -520,12 +520,7 @@ fn envelopes_ui(app: &mut WordApp, ui: &mut Ui, f: &mut EnvelopeForm) -> bool { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { cancel = ui.button(tl!("Cancel")).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)); new = ui.add_enabled(ready, egui::Button::new(tl!("New Document"))).clicked(); - add = ui - .add_enabled( - ready, - egui::Button::new(egui::RichText::new(tl!("Add to Document")).color(Color32::WHITE)).fill(crate::theme::APP_COLOR), - ) - .clicked(); + add = ui.add_enabled(ready, crate::widgets::primary_button(ui, tl!("Add to Document"))).clicked(); }); }); if add || new { diff --git a/crates/ui-egui/src/dialogs_notes.rs b/crates/ui-egui/src/dialogs_notes.rs index ed2ec5347..ae69d5592 100644 --- a/crates/ui-egui/src/dialogs_notes.rs +++ b/crates/ui-egui/src/dialogs_notes.rs @@ -150,8 +150,7 @@ pub fn note_options(app: &mut WordApp, ui: &mut Ui, f: &mut NoteForm) -> bool { cancel = ui.button(tl!("Cancel")).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)); apply = ui.button(tl!("Apply")).clicked(); let label = if f.endnote { "Insert Endnote" } else { "Insert Footnote" }; - insert = ui.add(egui::Button::new(egui::RichText::new(tl!(label)).color(egui::Color32::WHITE)).fill(crate::theme::APP_COLOR)).clicked() - || ui.input(|i| i.key_pressed(egui::Key::Enter)); + insert = ui.add(crate::widgets::primary_button(ui, tl!(label))).clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)); }); }); if insert || apply { diff --git a/crates/ui-egui/src/dialogs_refs.rs b/crates/ui-egui/src/dialogs_refs.rs index b161ae46e..6c8803f25 100644 --- a/crates/ui-egui/src/dialogs_refs.rs +++ b/crates/ui-egui/src/dialogs_refs.rs @@ -590,10 +590,7 @@ fn mark_ui(app: &mut WordApp, ui: &mut Ui, f: &mut MarkForm) -> Next { 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() - { + if ui.add_enabled(can, crate::widgets::primary_button(ui, tl!("Mark"))).clicked() { f.message = match app.run("references.markEntry", f.params(false)) { Ok(_) => tl!("Entry marked.").to_string(), Err(e) => e, diff --git a/crates/ui-egui/src/dialogs_review.rs b/crates/ui-egui/src/dialogs_review.rs index 500a1cabc..f74a4136d 100644 --- a/crates/ui-egui/src/dialogs_review.rs +++ b/crates/ui-egui/src/dialogs_review.rs @@ -5,7 +5,7 @@ //! agents get the same result without them. Shown through [`crate::dialogs::Dialog::Review`] and //! [`crate::panes`]. -use egui::{Color32, Ui, vec2}; +use egui::{Ui, vec2}; use serde::Serialize; use serde_json::{Value, json}; use wordcraft_engine::Password; @@ -71,8 +71,7 @@ fn buttons(ui: &mut Ui, ok_enabled: bool) -> (bool, bool) { if ui.button(tl!("Cancel")).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) { r.1 = true; } - let okb = - ui.add_enabled(ok_enabled, egui::Button::new(egui::RichText::new(tl!("OK")).color(Color32::WHITE)).fill(crate::theme::APP_COLOR)); + let okb = ui.add_enabled(ok_enabled, crate::widgets::primary_button(ui, tl!("OK"))); if okb.clicked() { r.0 = true; } diff --git a/crates/ui-egui/src/dialogs_templates.rs b/crates/ui-egui/src/dialogs_templates.rs index 474dc5a84..1d94ba3db 100644 --- a/crates/ui-egui/src/dialogs_templates.rs +++ b/crates/ui-egui/src/dialogs_templates.rs @@ -255,7 +255,7 @@ pub fn organizer(app: &mut WordApp, ui: &mut Ui, f: &mut OrganizerForm) -> bool let mut close = ui.input(|i| i.key_pressed(egui::Key::Escape)); ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - close |= ui.add(egui::Button::new(egui::RichText::new(tl!("Close")).color(egui::Color32::WHITE)).fill(crate::theme::APP_COLOR)).clicked(); + close |= ui.add(crate::widgets::primary_button(ui, tl!("Close"))).clicked(); }); }); if close { diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index c795d918e..e225b19a2 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -152,7 +152,9 @@ fn nav(app: &mut WordApp, ui: &mut Ui) { let mut job = egui::text::LayoutJob::default(); let fmt = |c| egui::TextFormat { font_id: regular(12.0), color: c, ..Default::default() }; job.append(pre, 0.0, fmt(ui.visuals().text_color())); - job.append(hit, 0.0, egui::TextFormat { font_id: semibold(12.0), background: egui::Color32::from_rgb(0xFF, 0xF1, 0x76), color: egui::Color32::BLACK, ..Default::default() }); + // The match is marked the way Find marks it on the page, per theme (#656). + let mark = Tokens::get(ui.ctx()).find_highlight; + job.append(hit, 0.0, egui::TextFormat { font_id: semibold(12.0), background: mark, color: ui.visuals().text_color(), ..Default::default() }); job.append(post, 0.0, fmt(ui.visuals().text_color())); job.wrap.max_width = ui.available_width(); if ui.add(egui::Button::new(job).frame(false)).clicked() { diff --git a/crates/ui-egui/src/table_pen.rs b/crates/ui-egui/src/table_pen.rs index f3de15fea..af0fb2e2e 100644 --- a/crates/ui-egui/src/table_pen.rs +++ b/crates/ui-egui/src/table_pen.rs @@ -4,7 +4,7 @@ //! `table.draw` / `table.eraser` with page coordinates (one undo step each). Esc or the button //! again turns it off. The geometry decisions are the engine's (`cmd::table_draw`). -use egui::{Color32, Pos2, Rect, Stroke, Ui, pos2, vec2}; +use egui::{Pos2, Rect, Stroke, Ui, pos2, vec2}; use serde_json::{Value, json}; use wordcraft_engine::cmd::table_draw::{self, Stroke as Kind}; use wordcraft_layout::DocLayout; @@ -171,7 +171,7 @@ pub fn paint(app: &WordApp, painter: &egui::Painter, t: &Tokens, layout: &DocLay && let Some(e) = table_draw::erase_target(layout, page, x, y) && let (Some(a), Some(b)) = (to_screen(page, e.from.0, e.from.1), to_screen(page, e.to.0, e.to.1)) { - painter.line_segment([a, b], Stroke::new(3.0, Color32::from_rgb(0xD1, 0x3B, 0x3B))); + painter.line_segment([a, b], Stroke::new(3.0, t.red)); } } _ => {} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index bc23ff56b..6f3563062 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -689,6 +689,25 @@ mod tests { assert!(defs.families[&egui::FontFamily::Proportional].last().is_some_and(|n| n.starts_with("fallback "))); } + /// #656: dialog primary buttons, the Discord pill, the table eraser and the Navigation search + /// highlight take their colours from the theme, not fixed light-theme values. + #[test] + fn interface_colours_come_from_the_theme() { + let banned = ["fill(crate::theme::APP_COLOR)", "0x58, 0x65, 0xF2", "0x47, 0x52, 0xC4", "0xFF, 0xF1, 0x76", "0xD1, 0x3B, 0x3B"]; + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + for entry in std::fs::read_dir(&dir).unwrap() { + let path = entry.unwrap().path(); + let name = path.file_name().unwrap().to_string_lossy().to_string(); + if !name.ends_with(".rs") || name == "theme.rs" || name == "icons.rs" { + continue; + } + let src = std::fs::read_to_string(&path).unwrap(); + for b in banned { + assert!(!src.contains(b), "{name} hard-codes {b}; use theme::Tokens"); + } + } + } + /// Symbols written as text in the interface code must exist in the interface fonts (Inter and /// egui's emoji fallbacks); a missing glyph draws as an empty box, as the panes' "✕" close /// button once did. Letters are the catalogs' business (see the i18n font tests). diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 37dab5a59..58954d431 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -733,6 +733,12 @@ pub fn drawn_state(ui: &Ui, resp: &Response, framed: bool) -> DrawnState { } } +/// A dialog's primary button (OK, Save, Close): the accent fill with `on_accent` text, per theme. +pub fn primary_button(ui: &Ui, text: impl Into) -> egui::Button<'static> { + let t = Tokens::get(ui.ctx()); + egui::Button::new(egui::RichText::new(text.into()).color(t.on_accent)).fill(t.accent) +} + /// A frameless 22 px icon button (pane close buttons): ribbon-style hover and press feedback. pub fn icon_button(ui: &mut Ui, icon: &str, tip: &str) -> Response { let t = Tokens::get(ui.ctx()); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index bc85f06c2..16572461d 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 (ribbon buttons return keyboard focus to the document, #686; previously trivial: screen readers can read the document canvas, #488; 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 (hard-coded interface colours moved to the theme, #656; previously minor: status bar language follows the caret and sets the proofing language; Review › Language is a menu; 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 @@ -69,6 +69,9 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | 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 | Dialog primary buttons, the Discord pill, the table eraser line and the Navigation search highlight take their colours from the theme in both themes (`widgets::primary_button`); a test bans the old fixed values (#656) | +| 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 | 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 dd66d5171bb813d7b691f26a6181f10de6a3a5d5 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Mon, 12 Oct 2026 11:42:58 +0200 Subject: [PATCH 22/88] Dark theme: gallery previews no longer bright white (#663) (#706) * #656: dialog buttons, Discord pill, eraser and search highlight use theme tokens Co-Authored-By: Claude Opus 5.5 * #663: gallery previews dimmed in the dark theme; dark page under Switch Modes 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/previews.rs | 47 ++++++++++++++++++++++++++-------- docs/ui-parity.md | 3 ++- 2 files changed, 38 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/previews.rs b/crates/ui-egui/src/previews.rs index 1fc566eb6..5c1545bfb 100644 --- a/crates/ui-egui/src/previews.rs +++ b/crates/ui-egui/src/previews.rs @@ -256,7 +256,18 @@ pub fn ink_mask(img: &mut egui::ColorImage) { /// Lay out `para` (with `base`'s styles) in a `w`×`h` pt box and rasterise it. pub fn snippet(base: &Document, para: Paragraph, w: f32, h: f32, ppp: f32, margin: f32) -> Option { - snippet_blocks(base, vec![Block::Para(para)], w, h, ppp, margin, None) + snippet_blocks(base, vec![Block::Para(para)], w, h, ppp, margin, None, false) +} + +/// [`snippet`] on the dark page of View › Switch Modes when `dark` (the Styles gallery, #663). +pub fn style_snippet(base: &Document, para: Paragraph, w: f32, h: f32, ppp: f32, margin: f32, dark: bool) -> Option { + snippet_blocks(base, vec![Block::Para(para)], w, h, ppp, margin, None, dark) +} + +/// How a gallery draws a page preview: as rendered on a light interface or on Switch Modes' dark +/// page, and dimmed in the dark theme so a white page isn't the brightest thing on screen (#663). +pub fn preview_tint(t: &Tokens, dark_page: bool) -> egui::Color32 { + if t.dark && !dark_page { egui::Color32::from_gray(0xC8) } else { egui::Color32::WHITE } } fn snippet_blocks( @@ -267,6 +278,7 @@ fn snippet_blocks( ppp: f32, margin: f32, paper: Option, + dark: bool, ) -> Option { let mut d = Document::new(); d.styles = base.styles.clone(); @@ -284,6 +296,7 @@ fn snippet_blocks( if let Some(p) = paper { opts.paper = p; } + opts.dark = dark; let img = wordcraft_render::render_area(&d, page, 0.0, 0.0, w + 2.0 * margin, h + margin, ppp, &opts); let px = img.to_straight(); Some(egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &px)) @@ -294,7 +307,7 @@ fn snippet_blocks( pub fn style_preview(app: &mut WordApp, ui: &mut Ui, id: &str, ty: &str, size: egui::Vec2) { let t = Tokens::get(ui.ctx()); let (r, _) = ui.allocate_exact_size(size, Sense::hover()); - ui.painter().rect(r, 3.0, egui::Color32::WHITE, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + ui.painter().rect(r, 3.0, preview_tint(&t, false), Stroke::new(1.0, t.border), egui::StrokeKind::Inside); let rev = app.session.rev(); let skey = app.previews.styles_key(&app.session.doc, rev); let ppp = ui.ctx().pixels_per_point(); @@ -328,13 +341,13 @@ pub fn style_preview(app: &mut WordApp, ui: &mut Ui, id: &str, ty: &str, size: e Block::Para(p) } }; - snippet_blocks(doc, vec![block], w, h, ppp, 4.0, None) + snippet_blocks(doc, vec![block], w, h, ppp, 4.0, None, false) }); if let Some(hd) = tex { let sz = hd.size_vec2() / ppp; let ir = Rect::from_min_size(pos2(r.min.x + 2.0, r.min.y + 2.0), sz.min(r.size() - vec2(4.0, 4.0))); let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2((ir.width() / sz.x).min(1.0), (ir.height() / sz.y).min(1.0))); - ui.painter().with_clip_rect(r).image(hd.id(), ir, uv, egui::Color32::WHITE); + ui.painter().with_clip_rect(r).image(hd.id(), ir, uv, preview_tint(&t, false)); } } @@ -360,7 +373,8 @@ pub fn style_gallery(app: &mut WordApp, ui: &mut Ui, state: &Value) { ui.painter().rect_filled(r, 3.0, t.hover); } let lang = crate::i18n::current().code(); - let key = format!("style:{id}:{skey}:{ppp}:{lang}"); + let dark_page = app.session.view.dark_mode; + let key = format!("style:{id}:{skey}:{ppp}:{lang}:{dark_page}"); let doc = &app.session.doc; let tex = app.previews.get_or(ui.ctx(), &key, || { // Sample letters in the interface's script (`АаБбВвГг` in Russian). @@ -372,13 +386,13 @@ pub fn style_gallery(app: &mut WordApp, ui: &mut Ui, state: &Value) { p.props.indent_left = Some(if first.is_finite() { (-first).max(0.0) } else { 0.0 }); p.props.borders = None; p.props.shading = None; - snippet(doc, p, 220.0, 30.0, ppp, 1.0) + style_snippet(doc, p, 220.0, 30.0, ppp, 1.0, dark_page) }); if let Some(h) = tex { let sz = h.size_vec2() / ppp; let ir = Rect::from_min_size(pos2(r.min.x + 3.0, r.min.y + 4.0), sz.min(vec2(68.0, 32.0))); let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2((ir.width() / sz.x).min(1.0), (ir.height() / sz.y).min(1.0))); - ui.painter().with_clip_rect(r).image(h.id(), ir, uv, egui::Color32::WHITE); + ui.painter().with_clip_rect(r).image(h.id(), ir, uv, preview_tint(&t, dark_page)); } let mut job = egui::text::LayoutJob::single_section(name.clone(), egui::TextFormat::simple(regular(10.5), t.text)); job.wrap = egui::text::TextWrapping::truncate_at_width(r.width() - 6.0); @@ -446,14 +460,14 @@ pub fn style_set_gallery(app: &mut WordApp, ui: &mut Ui) { Block::Para(Paragraph::with_text(tl!("Heading 1"), CharProps::default()).styled("Heading1")), Block::Para(Paragraph::with_text(tl!("Body text in a short paragraph to show spacing."), CharProps::default())), ]; - snippet_blocks(&s.doc, blocks, 120.0, 110.0, ppp * 0.42, 6.0, None) + snippet_blocks(&s.doc, blocks, 120.0, 110.0, ppp * 0.42, 6.0, None, false) }); if let Some(h) = tex { let sz = h.size_vec2() / ppp; let ir = Rect::from_min_size(pos2(r.min.x + 3.0, r.min.y + 3.0), sz.min(vec2(50.0, 60.0))); ui.painter().rect_stroke(ir, 0.0, Stroke::new(1.0, t.border), egui::StrokeKind::Outside); let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2((ir.width() / sz.x).min(1.0), (ir.height() / sz.y).min(1.0))); - ui.painter().image(h.id(), ir, uv, egui::Color32::WHITE); + ui.painter().image(h.id(), ir, uv, preview_tint(&t, false)); } if resp.on_hover_text(format!("Style set: {name}")).clicked() { let _ = app.run("design.styleSet", json!({"name": name})); @@ -481,13 +495,13 @@ pub fn table_style_tile(ui: &mut Ui, app: &mut WordApp, style: &str) -> Response c.blocks = vec![para_block(Paragraph::with_text(" ", CharProps { size: Some(5.0), ..Default::default() }))]; } } - snippet_blocks(doc, vec![Block::Table(tb), Block::Para(Paragraph::new())], 104.0, 70.0, ppp * 0.48, 4.0, None) + snippet_blocks(doc, vec![Block::Table(tb), Block::Para(Paragraph::new())], 104.0, 70.0, ppp * 0.48, 4.0, None, false) }); if let Some(h) = tex { let sz = h.size_vec2() / ppp; let ir = Rect::from_center_size(r.center(), sz.min(vec2(54.0, 42.0))); let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2((ir.width() / sz.x).min(1.0), (ir.height() / sz.y).min(1.0))); - ui.painter().image(h.id(), ir, uv, egui::Color32::WHITE); + ui.painter().image(h.id(), ir, uv, preview_tint(&t, false)); } resp } @@ -496,6 +510,17 @@ pub fn table_style_tile(ui: &mut Ui, app: &mut WordApp, style: &str) -> Response mod tests { use super::*; + /// #663: gallery previews are dimmed in the dark theme, unless they show Switch Modes' dark + /// page; the light theme keeps a white page. + #[test] + fn previews_are_dimmed_in_the_dark_theme() { + let (light, dark) = (crate::theme::Tokens::light(), crate::theme::Tokens::dark()); + assert_eq!(super::preview_tint(&light, false), egui::Color32::WHITE); + assert_eq!(super::preview_tint(&dark, true), egui::Color32::WHITE, "the dark page is already dark"); + let dim = super::preview_tint(&dark, false); + assert!(dim.r() < 0xE0 && dim.r() > 0xA0, "{dim:?}"); + } + #[test] fn font_menu_previews_render_defer_and_reuse_without_deadlocking() { // A nested Context lock used to hang the first uncached menu entry. Bound the wait so diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 16572461d..49b6ffdbd 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 (hard-coded interface colours moved to the theme, #656; previously minor: status bar language follows the caret and sets the proofing language; Review › Language is a menu; 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 (gallery previews dimmed in the dark theme, #663; previously trivial: hard-coded interface colours moved to the theme, #656; previously minor: status bar language follows the caret and sets the proofing language; Review › Language is a menu; 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 @@ -69,6 +69,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | 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 | Dark theme: Styles, style-set and table-style previews sit on a dimmer page tone; with View › Switch Modes on, the Styles gallery previews the dark page (#663) | | 2026-10-11 | trivial | Dialog primary buttons, the Discord pill, the table eraser line and the Navigation search highlight take their colours from the theme in both themes (`widgets::primary_button`); a test bans the old fixed values (#656) | | 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) | From b7aa7639a995d7d8e60edc98246b82e8524399fb Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 05:44:42 -0400 Subject: [PATCH 23/88] Release prep: 0.6.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 40 ++++++++++++++++++++-------------------- Cargo.toml | 2 +- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 69ccefe16..d93a9c05d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4998,7 +4998,7 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "wordcraft" -version = "0.5.0" +version = "0.6.0" dependencies = [ "arboard", "dispatch2", @@ -5026,7 +5026,7 @@ dependencies = [ [[package]] name = "wordcraft-cli" -version = "0.5.0" +version = "0.6.0" dependencies = [ "serde_json", "wordcraft-doc", @@ -5038,11 +5038,11 @@ dependencies = [ [[package]] name = "wordcraft-control-key" -version = "0.5.0" +version = "0.6.0" [[package]] name = "wordcraft-doc" -version = "0.5.0" +version = "0.6.0" dependencies = [ "proptest", "serde", @@ -5054,7 +5054,7 @@ dependencies = [ [[package]] name = "wordcraft-docbin" -version = "0.5.0" +version = "0.6.0" dependencies = [ "cfb", "image", @@ -5069,7 +5069,7 @@ dependencies = [ [[package]] name = "wordcraft-docx" -version = "0.5.0" +version = "0.6.0" dependencies = [ "aes", "base64", @@ -5091,7 +5091,7 @@ dependencies = [ [[package]] name = "wordcraft-engine" -version = "0.5.0" +version = "0.6.0" dependencies = [ "image", "js-sys", @@ -5117,7 +5117,7 @@ dependencies = [ [[package]] name = "wordcraft-fonts" -version = "0.5.0" +version = "0.6.0" dependencies = [ "harfrust 0.13.3", "kurbo", @@ -5130,7 +5130,7 @@ dependencies = [ [[package]] name = "wordcraft-formats" -version = "0.5.0" +version = "0.6.0" dependencies = [ "image", "log", @@ -5143,14 +5143,14 @@ dependencies = [ [[package]] name = "wordcraft-geom" -version = "0.5.0" +version = "0.6.0" dependencies = [ "serde", ] [[package]] name = "wordcraft-layout" -version = "0.5.0" +version = "0.6.0" dependencies = [ "image", "js-sys", @@ -5169,7 +5169,7 @@ dependencies = [ [[package]] name = "wordcraft-mcp" -version = "0.5.0" +version = "0.6.0" dependencies = [ "log", "serde_json", @@ -5181,7 +5181,7 @@ dependencies = [ [[package]] name = "wordcraft-metafile" -version = "0.5.0" +version = "0.6.0" dependencies = [ "kurbo", "thiserror 2.0.21", @@ -5189,7 +5189,7 @@ dependencies = [ [[package]] name = "wordcraft-pdf" -version = "0.5.0" +version = "0.6.0" dependencies = [ "hayro-cmap", "hayro-interpret", @@ -5209,7 +5209,7 @@ dependencies = [ [[package]] name = "wordcraft-proof" -version = "0.5.0" +version = "0.6.0" dependencies = [ "log", "miniz_oxide 0.8.9", @@ -5218,7 +5218,7 @@ dependencies = [ [[package]] name = "wordcraft-render" -version = "0.5.0" +version = "0.6.0" dependencies = [ "image", "log", @@ -5232,7 +5232,7 @@ dependencies = [ [[package]] name = "wordcraft-ui-egui" -version = "0.5.0" +version = "0.6.0" dependencies = [ "egui", "egui_kittest", @@ -5256,7 +5256,7 @@ dependencies = [ [[package]] name = "wordcraft-web" -version = "0.5.0" +version = "0.6.0" dependencies = [ "eframe", "egui", @@ -5275,7 +5275,7 @@ dependencies = [ [[package]] name = "wordcraft-zotero" -version = "0.5.0" +version = "0.6.0" dependencies = [ "log", "proptest", @@ -5388,7 +5388,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.5.0" +version = "0.6.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index d30e72560..2de37ac07 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"] [workspace.package] -version = "0.5.0" +version = "0.6.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" From d4d28e01f457b057ed5a3c5fbdcedbfd2dbce3bc Mon Sep 17 00:00:00 2001 From: RegalDragoon200 <56407824+regalf@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:55:40 +0200 Subject: [PATCH 24/88] Skip non-English runs in proofing instead of flagging them (#100) (#741) Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/engine/src/tests.rs | 21 ++++++++++++++++++ crates/proof/src/lib.rs | 44 ++++++++++++++++++++++++++++++++++++++ docs/gaps.md | 1 + docs/target-app-parity.md | 1 + 4 files changed, 67 insertions(+) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 09b6dbf3a..f1f23489e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -125,6 +125,27 @@ fn reset_shortcuts_remove_direct_formatting_but_keep_styles() { assert_eq!(text(&s), "Hello world—"); } +#[test] +fn non_english_runs_are_not_proofed() { + // #100: runs tagged with a proofing language that has no dictionary are skipped + // by both proofing paths; English still is proofed. + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "ąžuolas labas"})); + let before = run(&mut s, "review.issues", json!({})); + assert!(!before.as_array().map(|a| a.is_empty()).unwrap_or(true), "default language flags the words: {before}"); + + run(&mut s, "select.all", json!({})); + run(&mut s, "review.language", json!({"lang": "lt-LT"})); + let after = run(&mut s, "review.issues", json!({})); + assert_eq!(after, json!([]), "Lithuanian runs are skipped: {after}"); + + // English typos are still caught. + let mut e = Session::new(wordcraft_doc::Document::new()); + run(&mut e, "text.insert", json!({"text": "Hello helo world"})); + let en = run(&mut e, "review.issues", json!({})); + assert_eq!(en.as_array().map(|a| a.len()).unwrap_or(0), 1, "English typo still flagged: {en}"); +} + #[test] fn typing_enter_undo() { let mut s = s(); diff --git a/crates/proof/src/lib.rs b/crates/proof/src/lib.rs index 71a0e4cb4..34693d700 100644 --- a/crates/proof/src/lib.rs +++ b/crates/proof/src/lib.rs @@ -186,6 +186,19 @@ fn known_core(w: &str) -> bool { false } +/// Is there a proofing dictionary for this BCP 47 language tag? `None` (and empty tags) mean the +/// document default, which is en-US. Runs in a language without one are skipped (#100) instead +/// of flagging every word against another language's dictionary; see [`ProofLang::from_tag`]. +pub fn has_dictionary(lang: Option<&str>) -> bool { + ProofLang::from_tag(lang) != ProofLang::Other +} + +/// Is `word` spelled correctly in the run's proofing language? A language without a dictionary +/// can't be checked, so every word counts as correct (no red underlines for it). +pub fn is_correct_for(word: &str, lang: Option<&str>) -> bool { + is_correct_in(word, ProofLang::from_tag(lang)) +} + /// Is `word` spelled correctly? Numbers, single letters, ALL-CAPS acronyms, URLs and words with /// digits are accepted. pub fn is_correct(word: &str) -> bool { @@ -688,6 +701,35 @@ mod tests { assert!(is_correct("zxqwordcrafty")); } + /// #100: runs are proofed when their language has a dictionary (the default language too) + /// and skipped otherwise. + #[test] + fn dictionary_lang_tags() { + for l in [None, Some(""), Some(" "), Some("en"), Some("en-US"), Some("en-GB"), Some("EN-us"), Some("en_US")] { + assert!(has_dictionary(l), "{l:?}"); + } + for l in [Some("lt-LT"), Some("fr-FR"), Some("de"), Some("uk"), Some("es"), Some("-US"), Some("e")] { + assert!(!has_dictionary(l), "{l:?}"); + } + // The gate follows the dictionaries, not English: every proofed language is proofed. + for d in ProofLang::PROOFED.map(ProofLang::code) { + assert!(has_dictionary(Some(d)), "{d}"); + assert!(has_dictionary(Some(&format!("{}-XX", d.to_ascii_uppercase()))), "{d}"); + } + } + + /// #100: runs in a language without a dictionary are never flagged; English still is. + #[test] + fn non_english_words_are_not_flagged() { + for w in ["ąžuolas", "élève", "über", "labas", "bonjour"] { + assert!(is_correct_for(w, Some("lt-LT")), "{w}"); + assert!(is_correct_for(w, Some("fr-FR")), "{w}"); + } + assert!(!is_correct_for("helo", Some("en-US")), "English typos still flagged"); + assert!(!is_correct_for("helo", None), "default language still proofed"); + assert!(is_correct_for("hello", Some("en-US"))); + } + /// File › Options › Proofing (#485): each "Ignore" choice turned off makes those words count. #[test] fn ignore_options_decide_what_is_checked() { @@ -757,6 +799,8 @@ mod tests { let _ = check_spelling(&s); let _ = check_grammar(&s); let _ = suggest(&s, 3); + let _ = has_dictionary(Some(&s)); + let _ = is_correct_for(&s, Some(&s)); let _ = hyphen::hyphen_points(&s, &hyphen::Limits::default()); for lang in [ProofLang::En, ProofLang::Pl, ProofLang::Other] { let _ = check_spelling_in(&s, lang); diff --git a/docs/gaps.md b/docs/gaps.md index 578532f53..a0d76361b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -69,6 +69,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | #8 narrowed: runs in a proofing language without a dictionary are skipped instead of flagged against the English dictionary (#100); dictionaries still English-only | | 2026-10-11 | trivial | #16: DOCX open/save preserves independent complex-script formatting inheritance; RTL sections, tables and typography remain open | | 2026-10-11 | trivial | `.doc` pictures, shapes and text boxes read (#551); gap #14 narrowed to the writer and Word 6/95 files | | 2026-10-11 | trivial | Table column and row borders and the resize handle drag on the page (#217); UI/UX gap narrowed to the ruler's column markers | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 79c94d695..63265d568 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -251,6 +251,7 @@ can be used without committing Word output. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Proofing: runs in a language without a dictionary are skipped instead of flagged against the English dictionary (#100); no percentage change | | 2026-10-11 | minor | Polish interface (13 UI languages) and Polish proofing with per-run languages: Proofing 35% → 40% (25–45 h → 20–40 h), Localization 25% → 27%; feature depth and readiness unchanged at this precision | | 2026-10-11 | trivial | Tables: column and row borders and the resize handle drag on the page (#217); Tables row and interaction-fidelity evidence updated, no percentage change | | 2026-10-11 | trivial | Review: Show revisions in balloons takes tracked deletions in the body out of the text into "Deleted: …" balloons (it drew like the comments-and-formatting mode before); Review row updated, no percentage change | From c19a2aebefadb70b9a4fb3fed990ea447167650c Mon Sep 17 00:00:00 2001 From: SammyL-eo Date: Mon, 12 Oct 2026 05:01:26 -0500 Subject: [PATCH 25/88] Added some fixes to the spanish "Info" Page on Wordcraft (#695) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Added some fixes to the spanish "Info" Page on Wordcraft * Added more Spanish fixes * Review fixes for #695: test and catalog agree; drop rows main already has Intense Quote is "Cita intensa" in both; "Palabras clave", "Categoría". Caption, Heading {n} and Title were duplicates of main's rows (main's "Título" kept). TOC, Keywords, List Paragraph and the Subtle rows moved into alphabetical order. Turkish and Estonian get the same new keys, which their coverage tests require. Co-Authored-By: Claude Opus 5.5 * Polish: the style and document-property names the Spanish Info page now translates --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/i18n/es.tsv | 24 ++++++++++++++++++++++ crates/ui-egui/src/i18n/et.tsv | 24 ++++++++++++++++++++++ crates/ui-egui/src/i18n/pl.tsv | 24 ++++++++++++++++++++++ crates/ui-egui/src/i18n/tests.rs | 34 ++++++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/tr.tsv | 24 ++++++++++++++++++++++ 5 files changed, 130 insertions(+) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index e2bff070e..e6148430f 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -163,6 +163,7 @@ Bold Negrita Bookmark Marcador Bookmark name: Nombre del marcador: + Book Title Título de libro Border Painter Pincel de bordes Border\nPainter Pincel de\nbordes Borders Bordes @@ -211,6 +212,7 @@ Cancel Cancelar Capitalize Each Word Mayúscula inicial en cada palabra Captions Leyendas + Category Categoría Cell Margins Márgenes de celda Cell Size Tamaño de celda Cell\nMargins Márgenes de\ncelda @@ -273,6 +275,7 @@ Compress Pictures Comprimir imágenes Compress punctuation at the start of a line Comprimir la puntuación al inicio de la línea Continuous Continuo + Contributors Colaboradores Convert Convertir Convert Text to Table Convertir texto en tabla Convert Text to Table… Convertir texto en tabla… @@ -365,6 +368,9 @@ Editor Editor Effects Efectos Emboss Relieve + Emphasis Énfasis + Endnote Reference Referencia de nota al final + Endnote Text Texto de nota al final Encrypt with Password Cifrar con contraseña English (United States) Inglés (Estados Unidos) Engrave Grabado @@ -405,6 +411,8 @@ Fonts Fuentes Footer Pie de página Footnote and Endnote Nota al pie y nota final + Footnote Reference Referencia de nota al pie + Footnote Text Texto de nota al pie Footnotes Notas al pie Format Formato Format Page Numbers Formato de números de página @@ -443,6 +451,7 @@ Home Inicio Horizontal Horizontal Horizontal Line Línea horizontal + Hyperlink Hipervínculo Hyphenation Separación de palabras Icons Iconos Ignore All Omitir todo @@ -484,6 +493,9 @@ Insert\nCaption Insertar\nleyenda Insert\nCitation Insertar\ncita Insert\nFootnote Insertar nota\nal pie + Intense Emphasis Énfasis intenso + Intense Quote Cita intensa + Intense Reference Referencia intensa Inside Borders Bordes interiores Inspect Document Inspeccionar documento Inspect Styles at Selection Inspeccionar estilos de la selección @@ -498,6 +510,7 @@ Keep with Next Mantener con el siguiente Keep with next Mantener con el siguiente Keeps the fonts and formatting of the copied text. Conserva las fuentes y el formato del texto copiado. + Keywords Palabras clave Labels Etiquetas Landscape Horizontal Language Idioma @@ -581,6 +594,7 @@ No Color Sin color No Markup Sin marcas No Paragraph Space Sin espacio entre párrafos + No Spacing Sin espaciado No contributor data was built into this copy. Esta copia no incluye datos de colaboradores. No model credits were built into this copy. Esta copia no incluye créditos de modelos. No templates match your search. Ninguna plantilla coincide con la búsqueda. @@ -691,6 +705,7 @@ Quick Access Toolbar Barra de herramientas de acceso rápido Quick Parts Elementos rápidos Quick Tables Tablas rápidas + Quote Cita Read Aloud Leer en voz alta Read Mode Modo lectura Read\nAloud Leer en\nvoz alta @@ -853,6 +868,7 @@ Statistics Estadísticas Status Bar Barra de estado Strikethrough Tachado + Strong Fuerte Studio cover Portada de Studio Studio handbook (sample) Manual de Studio (ejemplo) Style Estilo @@ -862,7 +878,11 @@ Style: {style} Estilo: {style} Styles Estilos Styles Pane Panel de estilos + Subject Asunto Subscript Subíndice + Subtitle Subtítulo + Subtle Emphasis Énfasis sutil + Subtle Reference Referencia sutil Superscript Superíndice Switch Modes Cambiar de modo Switch\nModes Cambiar\nde modo @@ -908,6 +928,8 @@ Thick Gruesa Three Tres Tight Ajustado + TOC Heading Título de tabla de contenido + TOC {n} Tabla de contenido {n} To open Word documents with WordCraft by default: select one in Finder, choose File › Get Info, pick WordCraft under Open with, then click Change All. Para abrir los documentos de Word con WordCraft de forma predeterminada: selecciona uno en el Finder, elige Archivo › Obtener información, selecciona WordCraft en «Abrir con» y haz clic en «Cambiar todo». Top Border Borde superior Top Margin Margen superior @@ -1251,6 +1273,7 @@ Index and Tables Índices y tablas Line between Línea entre columnas Links and References Vínculos y referencias + List Paragraph Párrafo de lista Mail Merge Combinar correspondencia More Columns… Más columnas… More Symbols… Más símbolos… @@ -1372,6 +1395,7 @@ First Primero Last Último Model Modelo + Models Modelos Version Versión % of all commits % de todos los commits Lines +/− Líneas +/− diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 5d0da6a22..25850b5d9 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -80,6 +80,7 @@ Bold Paks Bookmark Järjehoidja Bookmark name: Järjehoidja nimi: + Book Title Raamatu pealkiri Border Painter Äärisepintsel Border\nPainter Äärise-\npintsel Borders Äärised @@ -95,6 +96,7 @@ Cancel Loobu Capitalize Each Word Iga sõna suure algustähega Captions Pealdised + Category Kategooria Cell Margins Lahtri veerised Cell Size Lahtri suurus Cell\nMargins Lahtri\nveerised @@ -150,6 +152,7 @@ Compare Võrdle Compress Pictures Tihenda pildid Continuous Pidev + Contributors Panustajad Convert Teisenda Convert Text to Table Teisenda tekst tabeliks Convert Text to Table… Teisenda tekst tabeliks… @@ -237,6 +240,9 @@ Editor Toimetaja Effects Efektid Emboss Reljeef + Emphasis Rõhutus + Endnote Reference Lõpumärkuse viide + Endnote Text Lõpumärkuse tekst Encrypt with Password Krüpti parooliga English (United States) Inglise (Ameerika Ühendriigid) Engrave Süvend @@ -277,6 +283,8 @@ Fonts Fondid Footer Jalus Footnote and Endnote Allmärkus ja lõpumärkus + Footnote Reference Allmärkuse viide + Footnote Text Allmärkuse tekst Footnotes Allmärkused Format Vorming Format Page Numbers Vorminda leheküljenumbrid @@ -318,6 +326,7 @@ Home Avaleht Horizontal Rõhtne Horizontal Line Rõhtjoon + Hyperlink Hüperlink Hyphenation Poolitus Icons Ikoonid Ignore All Eira kõiki @@ -362,6 +371,9 @@ Insert\nCaption Lisa\npealdis Insert\nCitation Lisa\nviide Insert\nFootnote Lisa\nallmärkus + Intense Emphasis Intensiivne rõhutus + Intense Quote Intensiivne tsitaat + Intense Reference Intensiivne viide Inside Borders Siseäärised Inside: Sisemine: Inspect Document Kontrolli dokumenti @@ -379,6 +391,7 @@ Keep with Next Hoia järgmisega koos Keep with next Hoia järgmisega koos Keeps the fonts and formatting of the copied text. Säilitab kopeeritud teksti fondid ja vorminduse. + Keywords Märksõnad Labels Sildid Landscape Rõhtpaigutus Language Keel @@ -466,6 +479,7 @@ No Color Värvita No Markup Märgistuseta No Paragraph Space Lõiguvaheta + No Spacing Vahedeta No contributor data was built into this copy. Selles versioonis pole kaasautorite andmeid. No model credits were built into this copy. Selles versioonis pole mudelite autoriteavet. No templates match your search. Otsingule vastavaid malle pole. @@ -574,6 +588,7 @@ Quick Access Toolbar Kiirpääsuriba Quick Parts Kiirplokid Quick Tables Kiirtabelid + Quote Tsitaat Read Aloud Loe ette Read Mode Lugemisrežiim Read\nAloud Loe\nette @@ -733,6 +748,7 @@ Statistics Statistika Status Bar Olekuriba Strikethrough Läbikriipsutus + Strong Tugev Studio cover Stuudio esileht Studio handbook (sample) Stuudio käsiraamat (näidis) Style Stiil @@ -742,7 +758,11 @@ Style: {style} Stiil: {style} Styles Stiilid Styles Pane Stiilipaan + Subject Teema Subscript Allindeks + Subtitle Alapealkiri + Subtle Emphasis Tagasihoidlik rõhutus + Subtle Reference Tagasihoidlik viide Superscript Ülaindeks Switch Modes Vaheta režiimi Switch\nModes Vaheta\nrežiimi @@ -788,6 +808,8 @@ Thick Paks Three Kolm Tight Tihe + TOC Heading Sisukorra pealkiri + TOC {n} Sisukord {n} Top Border Ülemine ääris Top of Page Lehe ülaserv Top: Ülal: @@ -1199,6 +1221,7 @@ First Esimene Last Viimane Model Mudel + Models Mudelid Version Versioon % of all commits % kõigist commitidest Lines +/− Read +/− @@ -1334,6 +1357,7 @@ Index and Tables Register ja tabelid Line between Joon vahel Links and References Lingid ja viited + List Paragraph Loendilõik Mail Merge Kirjakooste More Columns… Rohkem veerge… More Symbols… Rohkem sümboleid… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 41fd289a3..ae3e54713 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2065,3 +2065,27 @@ Hindi Hindi Context Kontekstowe System Systemowe + Book Title Tytuł książki + Category Kategoria + Contributors Współtwórcy + Emphasis Wyróżnienie + Endnote Reference Odwołanie przypisu końcowego + Endnote Text Tekst przypisu końcowego + Footnote Reference Odwołanie przypisu dolnego + Footnote Text Tekst przypisu dolnego + Hyperlink Hiperłącze + Intense Emphasis Wyróżnienie intensywne + Intense Quote Cytat intensywny + Intense Reference Odwołanie intensywne + Keywords Słowa kluczowe + No Spacing Bez odstępów + Quote Cytat + Strong Pogrubienie + Subject Temat + Subtitle Podtytuł + Subtle Emphasis Wyróżnienie delikatne + Subtle Reference Odwołanie delikatne + TOC Heading Nagłówek spisu treści + TOC {n} Spis treści {n} + List Paragraph Akapit z listą + Models Modele diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 7384efc77..97f74cf40 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -561,6 +561,40 @@ fn built_in_style_names_are_translated_for_display_and_user_styles_are_not() { assert_eq!(sheet.get("Normal").unwrap().name, "Normal"); } +#[test] +fn spanish_built_in_style_names_are_translated() { + set_current(lang("es")); + for (source, expected) in [ + ("Book Title", "Título de libro"), + ("Caption", "Leyenda"), + ("Emphasis", "Énfasis"), + ("Endnote Reference", "Referencia de nota al final"), + ("Endnote Text", "Texto de nota al final"), + ("Footnote Reference", "Referencia de nota al pie"), + ("Footnote Text", "Texto de nota al pie"), + ("Hyperlink", "Hipervínculo"), + ("Intense Emphasis", "Énfasis intenso"), + ("Intense Quote", "Cita intensa"), + ("Intense Reference", "Referencia intensa"), + ("List Paragraph", "Párrafo de lista"), + ("Quote", "Cita"), + ("Strong", "Fuerte"), + ("Subtitle", "Subtítulo"), + ("Subtle Emphasis", "Énfasis sutil"), + ("Subtle Reference", "Referencia sutil"), + ("TOC Heading", "Título de tabla de contenido"), + ] { + assert_eq!(builtin_style_name(source), expected, "{source}"); + } + for level in 4..=9 { + assert_eq!(builtin_style_name(&format!("Heading {level}")), format!("Título {level}")); + } + for level in 1..=9 { + assert_eq!(builtin_style_name(&format!("TOC {level}")), format!("Tabla de contenido {level}")); + } + set_current(Lang::EN); +} + #[test] fn context_entries_override_only_their_context_and_unknown_contexts_are_rejected() { let (c, errors) = Catalog::parse("\tTitle\tЗаголовок\npreview\tTitle\tТитул\n\tFont\tШрифт\nnope\tFont\tX\n"); diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 1dab1298e..bfef58b8e 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -83,6 +83,7 @@ Bold Kalın Bookmark Yer İşareti Bookmark name: Yer işareti adı: + Book Title Kitap Adı Border Painter Kenarlık Fırçası Border\nPainter Kenarlık\nFırçası Borders Kenarlıklar @@ -99,6 +100,7 @@ Cancel İptal Capitalize Each Word Her Sözcüğün İlk Harfini Büyüt Captions Resim Yazıları + Category Kategori Cell Margins Hücre Kenar Boşlukları Cell Size Hücre Boyutu Cell\nMargins Hücre Kenar\nBoşlukları @@ -151,6 +153,7 @@ Compare Karşılaştır Compress Pictures Resimleri Sıkıştır Continuous Sürekli + Contributors Katkıda Bulunanlar Convert Dönüştür Convert Text to Table Metni Tabloya Dönüştür Convert Text to Table… Metni Tabloya Dönüştür… @@ -238,6 +241,9 @@ Editor Düzenleyici Effects Efektler Emboss Kabartma + Emphasis Vurgu + Endnote Reference Sonnot Başvurusu + Endnote Text Sonnot Metni Encrypt with Password Parolayla Şifrele English (United States) İngilizce (Amerika Birleşik Devletleri) Engrave Oyma @@ -278,6 +284,8 @@ Fonts Yazı Tipleri Footer Altbilgi Footnote and Endnote Dipnot ve Sonnot + Footnote Reference Dipnot Başvurusu + Footnote Text Dipnot Metni Footnotes Dipnotlar Format Biçim Format Page Numbers Sayfa Numaralarını Biçimlendir @@ -316,6 +324,7 @@ Home Giriş Horizontal Yatay Horizontal Line Yatay Çizgi + Hyperlink Köprü Hyphenation Heceleme Icons Simgeler Ignore All Tümünü Yoksay @@ -355,6 +364,9 @@ Insert\nCaption Resim Yazısı\nEkle Insert\nCitation Atıf\nEkle Insert\nFootnote Dipnot\nEkle + Intense Emphasis Yoğun Vurgu + Intense Quote Yoğun Alıntı + Intense Reference Yoğun Başvuru Inside Borders İç Kenarlıklar Inspect Document Belgeyi İncele Inspect Styles at Selection Seçimdeki Stilleri İncele @@ -369,6 +381,7 @@ Keep with Next Sonrakiyle Birlikte Tut Keep with next Sonrakiyle birlikte tut Keeps the fonts and formatting of the copied text. Kopyalanan metnin yazı tiplerini ve biçimlendirmesini korur. + Keywords Anahtar Sözcükler Labels Etiketler Landscape Yatay Language Dil @@ -450,6 +463,7 @@ No Color Renk Yok No Markup İşaret Yok No Paragraph Space Paragraf Boşluğu Yok + No Spacing Aralık Yok No contributor data was built into this copy. Bu kopyaya katkıda bulunan verisi eklenmemiş. No model credits were built into this copy. Bu kopyaya model katkıları eklenmemiş. No templates match your search. Aramanızla eşleşen şablon yok. @@ -546,6 +560,7 @@ Quick Access Toolbar Hızlı Erişim Araç Çubuğu Quick Parts Hızlı Parçalar Quick Tables Hızlı Tablolar + Quote Alıntı Read Aloud Sesli Oku Read Mode Okuma Modu Read\nAloud Sesli\nOku @@ -700,6 +715,7 @@ Statistics İstatistikler Status Bar Durum Çubuğu Strikethrough Üstü Çizili + Strong Güçlü Studio cover Stüdyo kapağı Studio handbook (sample) Stüdyo el kitabı (örnek) Style Stil @@ -709,7 +725,11 @@ Style: {style} Stil: {style} Styles Stiller Styles Pane Stiller Bölmesi + Subject Konu Subscript Alt Simge + Subtitle Alt Başlık + Subtle Emphasis Hafif Vurgu + Subtle Reference Hafif Başvuru Superscript Üst Simge Switch Modes Mod Değiştir Switch\nModes Mod\nDeğiştir @@ -753,6 +773,8 @@ Thick Kalın Three Üç Tight Sıkı + TOC Heading İçindekiler Başlığı + TOC {n} İçindekiler {n} Top Border Üst Kenarlık Top Margin Üst Kenar Boşluğu Top of Page Sayfa Üstü @@ -1173,6 +1195,7 @@ Index and Tables Dizin ve Tablolar Line between Sütunlar arasında çizgi Links and References Bağlantılar ve Başvurular + List Paragraph Liste Paragrafı Mail Merge Posta Birleştirme More Columns… Diğer Sütunlar… More Symbols… Diğer Semboller… @@ -1292,6 +1315,7 @@ First İlk Last Son Model Model + Models Modeller Version Sürüm % of all commits Commit payı (%) Lines +/− Satır +/− From c1e1c06297f8bf033900becbd01e237a3cd2a1c0 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Mon, 12 Oct 2026 12:05:30 +0200 Subject: [PATCH 26/88] #659: one drop-down marker (the dropdown icon) on every ribbon button (#697) 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/equation_tab.rs | 5 ++- crates/ui-egui/src/ribbon.rs | 8 ++--- crates/ui-egui/src/widgets.rs | 52 ++++++++++++++++++++++++------ docs/ui-parity.md | 3 +- 4 files changed, 48 insertions(+), 20 deletions(-) diff --git a/crates/ui-egui/src/equation_tab.rs b/crates/ui-egui/src/equation_tab.rs index 0720afeda..840a50e47 100644 --- a/crates/ui-egui/src/equation_tab.rs +++ b/crates/ui-egui/src/equation_tab.rs @@ -62,8 +62,7 @@ fn math_menu_button(ui: &mut Ui, app: &mut WordApp, picture: &str, label: &str, let lines: Vec<&str> = label.split('\n').collect(); let mut y = r.min.y + 43.0; for (i, l) in lines.iter().enumerate() { - let txt = if i + 1 == lines.len() { format!("{l} ▾") } else { (*l).to_string() }; - ui.painter().text(pos2(r.center().x, y), Align2::CENTER_TOP, txt, regular(11.5), t.text); + crate::widgets::label_line(ui, r.center().x, y, l, regular(11.5), t.text, i + 1 == lines.len()); y += 13.0; } let resp = resp.on_hover_text(label.replace('\n', " ")); @@ -163,7 +162,7 @@ pub fn insert_button(ui: &mut Ui, app: &mut WordApp) { } } crate::icons::paint(ui.painter(), Rect::from_center_size(pos2(r.center().x, r.min.y + 20.0), vec2(32.0, 32.0)), "equation", t.icon, t.accent); - ui.painter().text(pos2(r.center().x, r.min.y + 43.0), Align2::CENTER_TOP, format!("{label} ▾"), regular(11.5), t.text); + crate::widgets::label_line(ui, r.center().x, r.min.y + 43.0, label, regular(11.5), t.text, true); if rt.on_hover_text(tl!("Insert a new equation (Alt+=)")).clicked() { let _ = app.run("insert.equation", json!({})); app.canvas.want_focus = true; diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 43bfc4506..1b9ca6428 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -172,12 +172,8 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { } ui.add_space(6.0); let track = app.session.doc.settings.track_changes; - let mode = crate::widgets::icon_text_button( - ui, - "pencil", - &format!("{} ▾", if track { tl!("Reviewing") } else { tl!("Editing") }), - regular(12.0), - ); + let mode = + crate::widgets::icon_text_menu_button(ui, "pencil", if track { tl!("Reviewing") } else { tl!("Editing") }, regular(12.0)); egui::Popup::menu(&mode).show(|ui| { if ui.selectable_label(!track, tl!(tl!("Editing — edit the document directly"))).clicked() { let _ = app.run("review.trackChanges", json!({"value": false})); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 58954d431..8881adc49 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -125,6 +125,24 @@ pub fn big_toggle(ui: &mut Ui, app: &mut WordApp, icon: &str, label: &str, id: & big_button(ui, app, icon, label, id, Value::Object(Default::default()), false, checked) } +/// Size of the drop-down marker drawn after a label or beside a split button (#659: one marker, +/// the `dropdown` icon, everywhere; no text glyph). +pub const DROPDOWN: f32 = 10.0; + +/// One centred label line (top at `y`); `marker` draws the drop-down icon after the text, in the +/// text's colour, and the pair is centred together. +pub fn label_line(ui: &Ui, cx: f32, y: f32, text: &str, font: egui::FontId, color: Color32, marker: bool) { + let galley = ui.painter().layout_no_wrap(text.to_string(), font, color); + let extra = if marker { DROPDOWN + 3.0 } else { 0.0 }; + let size = galley.size(); + let x = cx - (size.x + extra) / 2.0; + ui.painter().galley(pos2(x, y), galley, color); + if marker { + let c = pos2(x + size.x + 3.0 + DROPDOWN / 2.0, y + size.y / 2.0); + icons::paint(ui.painter(), Rect::from_center_size(c, vec2(DROPDOWN, DROPDOWN)), "dropdown", color, color); + } +} + #[allow(clippy::too_many_arguments)] fn big_button(ui: &mut Ui, app: &mut WordApp, icon: &str, label: &str, id: &str, params: Value, menu: bool, checked: bool) -> Response { let label = tl!(label); @@ -142,8 +160,7 @@ fn big_button(ui: &mut Ui, app: &mut WordApp, icon: &str, label: &str, id: &str, let mut y = r.min.y + 43.0; let lines: Vec<&str> = label.split('\n').collect(); for (i, l) in lines.iter().enumerate() { - let txt = if menu && i + 1 == lines.len() { format!("{l} ▾") } else { (*l).to_string() }; - ui.painter().text(pos2(r.center().x, y), Align2::CENTER_TOP, txt, regular(11.5), if on { t.text } else { t.text_disabled }); + label_line(ui, r.center().x, y, l, regular(11.5), if on { t.text } else { t.text_disabled }, menu && i + 1 == lines.len()); y += 13.0; } let resp = tooltip(app, resp, &label.replace('\n', " "), id); @@ -202,7 +219,7 @@ pub fn split( if let Some(sw) = swatch { ui.painter().rect_filled(Rect::from_min_max(pos2(r.min.x + 4.0, r.max.y - 5.0), pos2(r.max.x - 4.0, r.max.y - 2.0)), 0.0, sw); } - icons::paint(ui.painter(), Rect::from_center_size(ar.center(), vec2(10.0, 10.0)), "dropdown", t.icon, t.accent); + icons::paint(ui.painter(), Rect::from_center_size(ar.center(), vec2(DROPDOWN, DROPDOWN)), "dropdown", t.icon, t.accent); let resp = tooltip(app, resp, tip, id); command_menu(&resp, app, id); if resp.clicked() { @@ -214,7 +231,7 @@ pub fn split( }); } -/// A button that only opens a menu (icon + label + ▾). +/// A button that only opens a menu (icon + label + drop-down marker). pub fn menu_button( ui: &mut Ui, app: &mut WordApp, @@ -240,8 +257,7 @@ pub fn menu_button( let lines: Vec<&str> = label.unwrap_or("").split('\n').collect(); let mut y = r.min.y + 43.0; for (i, l) in lines.iter().enumerate() { - let txt = if i + 1 == lines.len() { format!("{l} ▾") } else { (*l).to_string() }; - ui.painter().text(pos2(r.center().x, y), Align2::CENTER_TOP, txt, regular(11.5), t.text); + label_line(ui, r.center().x, y, l, regular(11.5), t.text, i + 1 == lines.len()); y += 13.0; } resp @@ -254,7 +270,7 @@ pub fn menu_button( if let Some(l) = label { ui.painter().text(pos2(r.min.x + 24.0, r.center().y), Align2::LEFT_CENTER, l, regular(11.5), t.text); } - icons::paint(ui.painter(), Rect::from_center_size(pos2(r.max.x - 6.0, r.center().y), vec2(9.0, 9.0)), "dropdown", t.icon, t.accent); + icons::paint(ui.painter(), Rect::from_center_size(pos2(r.max.x - 6.0, r.center().y), vec2(DROPDOWN, DROPDOWN)), "dropdown", t.icon, t.accent); resp }; let resp = resp.on_hover_text(tip); @@ -575,7 +591,7 @@ fn combo_impl( } let (ar, aresp) = ui.allocate_exact_size(vec2(16.0, resp.rect.height()), Sense::click()); ui.painter().rect(ar, 0.0, if aresp.hovered() { t.hover } else { t.input }, Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside); - icons::paint(ui.painter(), Rect::from_center_size(ar.center(), vec2(10.0, 10.0)), "dropdown", t.icon, t.accent); + icons::paint(ui.painter(), Rect::from_center_size(ar.center(), vec2(DROPDOWN, DROPDOWN)), "dropdown", t.icon, t.accent); // The list drops from the box's left edge, as in Word, not from the arrow (#535). egui::Popup::menu(&aresp).anchor(resp.rect.union(ar)).show(|ui| { if let Some(v) = combo_list(ui, width, current, items, preview) { @@ -753,11 +769,21 @@ pub fn icon_button(ui: &mut Ui, icon: &str, tip: &str) -> Response { /// fonts and would draw as empty boxes, so icons are painted (`icons.rs`) rather than typed. /// It looks and reacts like `ui.button` (see [`drawn_state`]). pub fn icon_text_button(ui: &mut Ui, icon: &str, text: &str, font: egui::FontId) -> Response { + icon_text_button_with(ui, icon, text, font, false) +} + +/// [`icon_text_button`] that opens a menu: the drop-down marker follows the text. +pub fn icon_text_menu_button(ui: &mut Ui, icon: &str, text: &str, font: egui::FontId) -> Response { + icon_text_button_with(ui, icon, text, font, true) +} + +fn icon_text_button_with(ui: &mut Ui, icon: &str, text: &str, font: egui::FontId, marker: bool) -> Response { let t = Tokens::get(ui.ctx()); let galley = ui.painter().layout_no_wrap(text.to_string(), font, Color32::PLACEHOLDER); let pad = ui.spacing().button_padding; let icon_w = 16.0; - let size = vec2(pad.x + icon_w + 4.0 + galley.size().x + pad.x, galley.size().y.max(icon_w) + 2.0 * pad.y); + let marker_w = if marker { DROPDOWN + 4.0 } else { 0.0 }; + let size = vec2(pad.x + icon_w + 4.0 + galley.size().x + marker_w + pad.x, galley.size().y.max(icon_w) + 2.0 * pad.y); let (r, resp) = ui.allocate_exact_size(size, Sense::click()); let st = drawn_state(ui, &resp, true); let r = r.expand(st.expansion); @@ -769,7 +795,13 @@ pub fn icon_text_button(ui: &mut Ui, icon: &str, text: &str, font: egui::FontId) t.icon, t.accent, ); - ui.painter().galley(pos2(r.min.x + pad.x + icon_w + 4.0, r.center().y - galley.size().y / 2.0), galley, st.text); + let text_x = r.min.x + pad.x + icon_w + 4.0; + let text_w = galley.size().x; + ui.painter().galley(pos2(text_x, r.center().y - galley.size().y / 2.0), galley, st.text); + if marker { + let c = pos2(text_x + text_w + 4.0 + DROPDOWN / 2.0, r.center().y); + icons::paint(ui.painter(), Rect::from_center_size(c, vec2(DROPDOWN, DROPDOWN)), "dropdown", st.text, st.text); + } resp } diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 49b6ffdbd..51669378a 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 (gallery previews dimmed in the dark theme, #663; previously trivial: hard-coded interface colours moved to the theme, #656; previously minor: status bar language follows the caret and sets the proofing language; Review › Language is a menu; 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 (one drop-down marker, #659; previously minor: status bar language follows the caret and sets the proofing language; Review › Language is a menu; 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 @@ -69,6 +69,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | 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 | One drop-down marker everywhere: large ribbon buttons, Equation and the Editing/Reviewing button draw the `dropdown` icon after their label, like split and menu buttons, instead of a text glyph (#659) | | 2026-10-11 | trivial | Dark theme: Styles, style-set and table-style previews sit on a dimmer page tone; with View › Switch Modes on, the Styles gallery previews the dark page (#663) | | 2026-10-11 | trivial | Dialog primary buttons, the Discord pill, the table eraser line and the Navigation search highlight take their colours from the theme in both themes (`widgets::primary_button`); a test bans the old fixed values (#656) | | 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) | From 3c8840556aa6587c117e7bb565513e8157bc5723 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Mon, 12 Oct 2026 12:09:16 +0200 Subject: [PATCH 27/88] Menu buttons grey out while their command is disabled (#658) (#698) * #659: one drop-down marker (the dropdown icon) on every ribbon button Co-Authored-By: Claude Opus 5.5 * #658: menu buttons grey out while their command is disabled 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/equation_tab.rs | 58 +++++++---- crates/ui-egui/src/ribbon.rs | 158 ++++++++++++++++------------- crates/ui-egui/src/widgets.rs | 23 ++++- docs/ui-parity.md | 3 +- 4 files changed, 146 insertions(+), 96 deletions(-) diff --git a/crates/ui-egui/src/equation_tab.rs b/crates/ui-egui/src/equation_tab.rs index 840a50e47..0367e9060 100644 --- a/crates/ui-egui/src/equation_tab.rs +++ b/crates/ui-egui/src/equation_tab.rs @@ -272,20 +272,29 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { !latex, ); small(ui, app, "formula", Some("LaTeX"), "Type equations in LaTeX", "equation.inputFormat", json!({"format": "latex"}), latex); - crate::widgets::menu_button(ui, app, "replace", Some("Convert"), "Convert between built-up and linear form", false, |ui, app| { - for (label, to, all) in [ - ("Current - Professional", "professional", false), - ("Current - Linear", "linear", false), - ("All - Professional", "professional", true), - ("All - Linear", "linear", true), - ] { - if ui.button(tl!(label)).clicked() { - let _ = app.run("equation.convert", json!({"to": to, "all": all})); - app.canvas.want_focus = true; - ui.close(); + crate::widgets::menu_button( + ui, + app, + "replace", + Some("Convert"), + "Convert between built-up and linear form", + false, + "equation.convert", + |ui, app| { + for (label, to, all) in [ + ("Current - Professional", "professional", false), + ("Current - Linear", "linear", false), + ("All - Professional", "professional", true), + ("All - Linear", "linear", true), + ] { + if ui.button(tl!(label)).clicked() { + let _ = app.run("equation.convert", json!({"to": to, "all": all})); + app.canvas.want_focus = true; + ui.close(); + } } - } - }); + }, + ); }); ui.vertical(|ui| { ui.spacing_mut().item_spacing = vec2(1.0, 0.0); @@ -339,14 +348,23 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { }); ui.vertical(|ui| { ui.spacing_mut().item_spacing = vec2(1.0, 0.0); - crate::widgets::menu_button(ui, app, "justify", Some("Justification"), "Justification of a display equation", false, |ui, app| { - for (label, jc) in [("Left", "left"), ("Right", "right"), ("Centered", "center"), ("Centered as Group", "centerGroup")] { - if ui.button(tl!(label)).clicked() { - let _ = app.run("equation.justify", json!({"jc": jc})); - ui.close(); + crate::widgets::menu_button( + ui, + app, + "justify", + Some("Justification"), + "Justification of a display equation", + false, + "equation.justify", + |ui, app| { + for (label, jc) in [("Left", "left"), ("Right", "right"), ("Centered", "center"), ("Centered as Group", "centerGroup")] { + if ui.button(tl!(label)).clicked() { + let _ = app.run("equation.justify", json!({"jc": jc})); + ui.close(); + } } - } - }); + }, + ); if ui.button(tl!("Close Equation")).on_hover_text(tl!("Leave the equation (Esc)")).clicked() { let _ = app.run("equation.exit", json!({})); app.canvas.want_focus = true; diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 1b9ca6428..099400ae8 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -426,7 +426,7 @@ fn home(app: &mut WordApp, ui: &mut Ui) { let st = app.session.run("format.state", &json!({})).unwrap_or_default(); let flag = |k: &str| st.get(k).and_then(Value::as_bool).unwrap_or(false); group(ui, "Clipboard", Some("edit.clipboardPane"), app, |ui, app| { - menu_button(ui, app, "paste", Some("Paste"), "Paste (⌘V)", true, |ui, app| { + menu_button(ui, app, "paste", Some("Paste"), "Paste (⌘V)", true, "edit.paste", |ui, app| { mi(ui, app, "Paste", "edit.paste", json!({})); mi(ui, app, "Keep Text Only", "edit.pasteText", json!({})); mi(ui, app, "Merge Formatting", "edit.pasteMerge", json!({})); @@ -472,7 +472,7 @@ fn home(app: &mut WordApp, ui: &mut Ui) { ui.add_space(3.0); small(ui, app, "grow", None, "Increase Font Size", "format.growFont", json!({}), false); small(ui, app, "shrink", None, "Decrease Font Size", "format.shrinkFont", json!({}), false); - menu_button(ui, app, "case", None, "Change Case", false, |ui, app| { + menu_button(ui, app, "case", None, "Change Case", false, "format.changeCase", |ui, app| { mi(ui, app, "Sentence case.", "format.changeCase", json!({"mode": "sentence"})); mi(ui, app, "lowercase", "format.changeCase", json!({"mode": "lower"})); mi(ui, app, "UPPERCASE", "format.changeCase", json!({"mode": "upper"})); @@ -506,7 +506,7 @@ fn home(app: &mut WordApp, ui: &mut Ui) { small(ui, app, "charborder", None, "Character Border", "format.border", json!({}), flag("border")); small(ui, app, "enclose", None, "Enclose Characters", "format.enclose", json!({}), false); ui.add_space(4.0); - menu_button(ui, app, "effects", None, "Text Effects and Typography", false, |ui, app| { + menu_button(ui, app, "effects", None, "Text Effects and Typography", false, "", |ui, app| { let theme = app.session.doc.settings.theme_colors.clone(); crate::wordart_menus::style_gallery(ui, app, "format.textEffects"); ui.separator(); @@ -624,7 +624,7 @@ fn home(app: &mut WordApp, ui: &mut Ui) { small(ui, app, "textLtr", None, "Left-to-Right Text Direction", "para.ltr", json!({}), !rtl); small(ui, app, "textRtl", None, "Right-to-Left Text Direction", "para.rtl", json!({}), rtl); ui.add_space(4.0); - menu_button(ui, app, "lineSpacing", None, "Line and Paragraph Spacing", false, |ui, app| { + menu_button(ui, app, "lineSpacing", None, "Line and Paragraph Spacing", false, "para.lineSpacing", |ui, app| { for v in [1.0, 1.15, 1.5, 2.0, 2.5, 3.0] { mi(ui, app, &format!("{v}"), "para.lineSpacing", json!({"value": v})); } @@ -663,13 +663,13 @@ fn home(app: &mut WordApp, ui: &mut Ui) { }); group(ui, "Editing", None, app, |ui, app| { stack(ui, |ui| { - menu_button(ui, app, "find", Some("Find"), "Find", false, |ui, app| { + menu_button(ui, app, "find", Some("Find"), "Find", false, "edit.find", |ui, app| { mi(ui, app, "Find", "ui.dialog", json!({"name": "find"})); mi(ui, app, "Advanced Find…", "edit.advancedFind", json!({})); mi(ui, app, "Go To…", "ui.dialog", json!({"name": "goto"})); }); small(ui, app, "replace", Some("Replace"), "Replace", "ui.dialog", json!({"name": "replace"}), false); - menu_button(ui, app, "select", Some("Select"), "Select", false, |ui, app| { + menu_button(ui, app, "select", Some("Select"), "Select", false, "select.all", |ui, app| { mi(ui, app, "Select All", "select.all", json!({})); mi(ui, app, "Select Paragraph", "select.paragraph", json!({})); mi(ui, app, "Select Sentence", "select.sentence", json!({})); @@ -687,7 +687,7 @@ fn home(app: &mut WordApp, ui: &mut Ui) { fn insert(app: &mut WordApp, ui: &mut Ui) { group(ui, "Pages", None, app, |ui, app| { stack(ui, |ui| { - menu_button(ui, app, "coverPage", Some("Cover Page"), "Cover Page", false, |ui, app| { + menu_button(ui, app, "coverPage", Some("Cover Page"), "Cover Page", false, "insert.coverPage", |ui, app| { mi(ui, app, "Studio cover", "insert.coverPage", json!({})); }); small(ui, app, "blankPage", Some("Blank Page"), "Blank Page", "insert.blankPage", json!({}), false); @@ -695,7 +695,7 @@ fn insert(app: &mut WordApp, ui: &mut Ui) { }); }); group(ui, "Tables", None, app, |ui, app| { - menu_button(ui, app, "table", Some("Table"), "Add a Table", true, |ui, app| { + menu_button(ui, app, "table", Some("Table"), "Add a Table", true, "insert.table", |ui, app| { crate::dialogs::table_grid_picker(ui, app); ui.separator(); mi(ui, app, "Insert Table…", "ui.dialog", json!({"name": "insertTable"})); @@ -711,7 +711,7 @@ fn insert(app: &mut WordApp, ui: &mut Ui) { }); group(ui, "Illustrations", None, app, |ui, app| { big(ui, app, "picture", "Pictures", "insert.picture", json!({}), false); - menu_button(ui, app, "shapes", Some("Shapes"), "Shapes", true, |ui, app| { + menu_button(ui, app, "shapes", Some("Shapes"), "Shapes", true, "insert.shape", |ui, app| { for (l, k) in [ ("Rectangle", "rectangle"), ("Rounded Rectangle", "roundedRectangle"), @@ -756,7 +756,7 @@ fn insert(app: &mut WordApp, ui: &mut Ui) { big(ui, app, "newComment", "Comment", "review.newComment", json!({}), false); }); group(ui, "Header & Footer", None, app, |ui, app| { - menu_button(ui, app, "header", Some("Header"), "Header", true, |ui, app| { + menu_button(ui, app, "header", Some("Header"), "Header", true, "insert.header", |ui, app| { mi(ui, app, "Blank", "insert.header", json!({"preset": "blank"})); mi(ui, app, "Blank (Three Columns)", "insert.header", json!({"preset": "blankThree"})); mi(ui, app, "Document Title", "insert.header", json!({"preset": "title"})); @@ -764,7 +764,7 @@ fn insert(app: &mut WordApp, ui: &mut Ui) { mi(ui, app, "Edit Header", "insert.editHeader", json!({})); mi(ui, app, "Remove Header", "insert.removeHeader", json!({})); }); - menu_button(ui, app, "footer", Some("Footer"), "Footer", true, |ui, app| { + menu_button(ui, app, "footer", Some("Footer"), "Footer", true, "insert.footer", |ui, app| { mi(ui, app, "Blank", "insert.footer", json!({"preset": "blank"})); mi(ui, app, "Blank (Three Columns)", "insert.footer", json!({"preset": "blankThree"})); mi(ui, app, "Page Number", "insert.footer", json!({"preset": "pageNumber"})); @@ -772,7 +772,7 @@ fn insert(app: &mut WordApp, ui: &mut Ui) { mi(ui, app, "Edit Footer", "insert.editFooter", json!({})); mi(ui, app, "Remove Footer", "insert.removeFooter", json!({})); }); - menu_button(ui, app, "pageNumber", Some("Page\nNumber"), "Page Number", true, |ui, app| { + menu_button(ui, app, "pageNumber", Some("Page\nNumber"), "Page Number", true, "insert.pageNumber", |ui, app| { mi(ui, app, "Top of Page", "insert.pageNumber", json!({"position": "top", "align": "right"})); mi(ui, app, "Bottom of Page", "insert.pageNumber", json!({"position": "bottom", "align": "center"})); mi(ui, app, "Page X of Y", "insert.pageNumber", json!({"position": "bottom", "format": "x of y"})); @@ -783,7 +783,7 @@ fn insert(app: &mut WordApp, ui: &mut Ui) { group(ui, "Text", None, app, |ui, app| { big(ui, app, "textBox", "Text\nBox", "insert.textBox", json!({}), false); stack(ui, |ui| { - menu_button(ui, app, "quickParts", Some("Quick Parts"), "Quick Parts", false, |ui, app| { + menu_button(ui, app, "quickParts", Some("Quick Parts"), "Quick Parts", false, "insert.quickParts", |ui, app| { let parts: Vec = app.session.building_blocks.keys().cloned().collect(); for name in parts { if ui.button(&name).clicked() { @@ -793,7 +793,7 @@ fn insert(app: &mut WordApp, ui: &mut Ui) { } mi(ui, app, "Field…", "ui.dialog", json!({"name": "field"})); }); - menu_button(ui, app, "wordArt", Some("WordArt"), "WordArt", false, |ui, app| { + menu_button(ui, app, "wordArt", Some("WordArt"), "WordArt", false, "", |ui, app| { crate::wordart_menus::style_gallery(ui, app, "insert.wordArt") }); small(ui, app, "dropCap", Some("Drop Cap"), "Drop Cap", "insert.dropCap", json!({}), false); @@ -806,7 +806,7 @@ fn insert(app: &mut WordApp, ui: &mut Ui) { }); group(ui, "Symbols", None, app, |ui, app| { crate::equation_tab::insert_button(ui, app); - menu_button(ui, app, "symbol", Some("Symbol"), "Symbol", true, |ui, app| { + menu_button(ui, app, "symbol", Some("Symbol"), "Symbol", true, "insert.symbol", |ui, app| { // Recently used symbols first (their font on hover), then common ones. let mut items: Vec<(String, String)> = app.ui.recent_symbols.iter().map(|r| (r.ch.clone(), r.font.clone())).collect(); for c in [ @@ -863,7 +863,7 @@ fn draw(app: &mut WordApp, ui: &mut Ui) { 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| { + 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); @@ -881,7 +881,7 @@ fn draw(app: &mut WordApp, ui: &mut Ui) { update_gallery_pen(app, custom, tool); } }); - menu_button(ui, app, "thickness", None, "Thickness", false, |ui, app| { + menu_button(ui, app, "thickness", None, "Thickness", false, "draw.pen", |ui, app| { 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())]); @@ -978,7 +978,7 @@ fn style_set_box(app: &mut WordApp, ui: &mut Ui) -> (Rect, Rect) { fn design(app: &mut WordApp, ui: &mut Ui) { group(ui, "Themes", None, app, |ui, app| { - menu_button(ui, app, "themes", Some("Themes"), "Themes", true, |ui, app| { + menu_button(ui, app, "themes", Some("Themes"), "Themes", true, "design.themes", |ui, app| { for (name, ..) in wordcraft_engine::cmd::design::THEMES { mi(ui, app, name, "design.theme", json!({"name": name})); } @@ -987,19 +987,19 @@ fn design(app: &mut WordApp, ui: &mut Ui) { group(ui, "Document Formatting", None, app, |ui, app| { style_set_box(app, ui); stack(ui, |ui| { - menu_button(ui, app, "colors", Some("Colors"), "Theme Colors", false, |ui, app| { + menu_button(ui, app, "colors", Some("Colors"), "Theme Colors", false, "design.themeColors", |ui, app| { for (name, ..) in wordcraft_engine::cmd::design::THEMES { mi(ui, app, name, "design.themeColors", json!({"name": name})); } }); - menu_button(ui, app, "fonts", Some("Fonts"), "Theme Fonts", false, |ui, app| { + menu_button(ui, app, "fonts", Some("Fonts"), "Theme Fonts", false, "design.themeFonts", |ui, app| { for (name, h, b, _) in wordcraft_engine::cmd::design::THEMES { mi_text(ui, app, &format!("{}: {h} / {b}", tl!(name)), "design.themeFonts", json!({"heading": h, "body": b})); } }); }); stack(ui, |ui| { - menu_button(ui, app, "paraSpacing", Some("Paragraph Spacing"), "Paragraph Spacing", false, |ui, app| { + menu_button(ui, app, "paraSpacing", Some("Paragraph Spacing"), "Paragraph Spacing", false, "design.paragraphSpacing", |ui, app| { for (l, v) in [ ("Default", "default"), ("No Paragraph Space", "none"), @@ -1017,7 +1017,7 @@ fn design(app: &mut WordApp, ui: &mut Ui) { }); }); group(ui, "Page Background", None, app, |ui, app| { - menu_button(ui, app, "watermark", Some("Watermark"), "Watermark", true, |ui, app| { + menu_button(ui, app, "watermark", Some("Watermark"), "Watermark", true, "design.watermark", |ui, app| { for w in ["DRAFT", "CONFIDENTIAL", "DO NOT COPY", "SAMPLE", "ASAP", "URGENT"] { mi(ui, app, w, "design.watermark", json!({"text": w})); } @@ -1025,7 +1025,7 @@ fn design(app: &mut WordApp, ui: &mut Ui) { mi(ui, app, "Custom Watermark…", "ui.dialog", json!({"name": "watermark"})); mi(ui, app, "Remove Watermark", "design.watermark", json!({"remove": true})); }); - menu_button(ui, app, "pageColor", Some("Page\nColor"), "Page Color", true, |ui, app| { + menu_button(ui, app, "pageColor", Some("Page\nColor"), "Page Color", true, "design.pageColor", |ui, app| { mi(ui, app, "No Color", "design.pageColor", json!({"color": null})); let theme = app.session.doc.settings.theme_colors.clone(); crate::colors::menu(ui, app, &theme, Target::cmd("design.pageColor")); @@ -1038,7 +1038,7 @@ fn layout(app: &mut WordApp, ui: &mut Ui) { group(ui, "Page Setup", Some("layout.pageSetup"), app, |ui, app| { // Text Direction works on what the caret is in: table cells, a text box, else the section. let dir = wordcraft_engine::cmd::page::text_direction_at(&app.session); - menu_button(ui, app, "textDirection", Some("Text\nDirection"), "Text Direction", true, |ui, app| { + menu_button(ui, app, "textDirection", Some("Text\nDirection"), "Text Direction", true, "layout.textDirection", |ui, app| { use wordcraft_doc::props::TextDirection; for (label, d) in [("Horizontal", TextDirection::Horizontal), ("Rotate all text 90°", TextDirection::Down), ("Rotate all text 270°", TextDirection::Up)] @@ -1046,7 +1046,7 @@ fn layout(app: &mut WordApp, ui: &mut Ui) { mi_check(ui, app, label, dir == d, "layout.textDirection", json!({"value": d.ooxml()})); } }); - menu_button(ui, app, "margins", Some("Margins"), "Margins", true, |ui, app| { + menu_button(ui, app, "margins", Some("Margins"), "Margins", true, "layout.margins", |ui, app| { for (l, k) in [ ("Normal 1\" all", "normal"), ("Narrow 0.5\" all", "narrow"), @@ -1059,16 +1059,16 @@ fn layout(app: &mut WordApp, ui: &mut Ui) { } mi(ui, app, "Custom Margins…", "ui.dialog", json!({"name": "pageSetup"})); }); - menu_button(ui, app, "orientation", Some("Orientation"), "Orientation", true, |ui, app| { + menu_button(ui, app, "orientation", Some("Orientation"), "Orientation", true, "layout.orientation", |ui, app| { mi(ui, app, "Portrait", "layout.orientation", json!({"value": "portrait"})); mi(ui, app, "Landscape", "layout.orientation", json!({"value": "landscape"})); }); - menu_button(ui, app, "size", Some("Size"), "Size", true, |ui, app| { + menu_button(ui, app, "size", Some("Size"), "Size", true, "layout.size", |ui, app| { for (n, w, h) in wordcraft_geom::PAPER_SIZES { mi(ui, app, &format!("{n} {:.2}\" × {:.2}\"", w / 72.0, h / 72.0), "layout.size", json!({"name": n})); } }); - menu_button(ui, app, "columns", Some("Columns"), "Columns", true, |ui, app| { + menu_button(ui, app, "columns", Some("Columns"), "Columns", true, "layout.columns", |ui, app| { mi(ui, app, "One", "layout.columns", json!({"count": 1})); mi(ui, app, "Two", "layout.columns", json!({"count": 2})); mi(ui, app, "Three", "layout.columns", json!({"count": 3})); @@ -1078,7 +1078,7 @@ fn layout(app: &mut WordApp, ui: &mut Ui) { mi(ui, app, "More Columns…", "ui.dialog", json!({"name": "columns"})); }); stack(ui, |ui| { - menu_button(ui, app, "breaks", Some("Breaks"), "Breaks", false, |ui, app| { + menu_button(ui, app, "breaks", Some("Breaks"), "Breaks", false, "layout.break", |ui, app| { ui.label(egui::RichText::new(tl!("Page Breaks")).strong()); mi(ui, app, "Page", "layout.break", json!({"kind": "page"})); mi(ui, app, "Column", "layout.break", json!({"kind": "column"})); @@ -1089,7 +1089,7 @@ fn layout(app: &mut WordApp, ui: &mut Ui) { mi(ui, app, "Even Page", "layout.break", json!({"kind": "evenPage"})); mi(ui, app, "Odd Page", "layout.break", json!({"kind": "oddPage"})); }); - menu_button(ui, app, "lineNumbers", Some("Line Numbers"), "Line Numbers", false, |ui, app| { + menu_button(ui, app, "lineNumbers", Some("Line Numbers"), "Line Numbers", false, "layout.lineNumbers", |ui, app| { for (l, v) in [("None", "none"), ("Continuous", "continuous"), ("Restart Each Page", "restartPage"), ("Restart Each Section", "restartSection")] { @@ -1099,7 +1099,7 @@ fn layout(app: &mut WordApp, ui: &mut Ui) { mi(ui, app, "Line Numbering Options…", "ui.dialog", json!({"name": "lineNumbers"})); }); let hy = app.session.doc.settings.auto_hyphenation; - menu_button(ui, app, "hyphenation", Some("Hyphenation"), "Hyphenation", false, |ui, app| { + menu_button(ui, app, "hyphenation", Some("Hyphenation"), "Hyphenation", false, "layout.hyphenation", |ui, app| { mi(ui, app, if hy { "✓ Automatic" } else { "Automatic" }, "layout.hyphenation", json!({"value": true})); mi(ui, app, if hy { "None" } else { "✓ None" }, "layout.hyphenation", json!({"value": false})); mi(ui, app, "Manual…", "ui.dialog", json!({"name": "manualHyphenation"})); @@ -1176,7 +1176,7 @@ fn wrap_menu(ui: &mut Ui, app: &mut WordApp) { let (wrap, side) = wordcraft_engine::cmd::objects::selected(&app.session) .and_then(|(_, o)| o.frame().map(|(_, _, f)| (f.wrap, f.wrap_text))) .unwrap_or_default(); - menu_button(ui, app, "wrapText", Some("Wrap\nText"), "Wrap Text", true, |ui, app| { + menu_button(ui, app, "wrapText", Some("Wrap\nText"), "Wrap Text", true, "arrange.wrapText", |ui, app| { use wordcraft_doc::para::{Wrap, WrapText}; for (l, w, v) in [ ("In Line with Text", Wrap::Inline, "inline"), @@ -1204,7 +1204,7 @@ fn wrap_menu(ui: &mut Ui, app: &mut WordApp) { } fn rotate_menu(ui: &mut Ui, app: &mut WordApp, label: Option<&str>) { - menu_button(ui, app, "rotate", label, "Rotate", false, |ui, app| { + menu_button(ui, app, "rotate", label, "Rotate", false, "arrange.rotate", |ui, app| { mi(ui, app, "Rotate Right 90°", "arrange.rotate", json!({"direction": "right"})); mi(ui, app, "Rotate Left 90°", "arrange.rotate", json!({"direction": "left"})); mi(ui, app, "Flip Vertical", "arrange.rotate", json!({"direction": "flipVertical"})); @@ -1228,7 +1228,7 @@ fn rotation_field(ui: &mut Ui, app: &mut WordApp) { /// Arrange › Group: Group (Shift+click objects to select several) and Ungroup. fn group_menu(ui: &mut Ui, app: &mut WordApp, label: Option<&str>) { - menu_button(ui, app, "group", label, "Group", false, |ui, app| { + menu_button(ui, app, "group", label, "Group", false, "arrange.group", |ui, app| { mi(ui, app, "Group", "arrange.group", json!({})); mi(ui, app, "Ungroup", "arrange.ungroup", json!({})); }); @@ -1236,7 +1236,7 @@ fn group_menu(ui: &mut Ui, app: &mut WordApp, label: Option<&str>) { fn references(app: &mut WordApp, ui: &mut Ui) { group(ui, "Table of Contents", None, app, |ui, app| { - menu_button(ui, app, "toc", Some("Table of\nContents"), "Table of Contents", true, |ui, app| { + menu_button(ui, app, "toc", Some("Table of\nContents"), "Table of Contents", true, "references.toc", |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(); @@ -1244,7 +1244,7 @@ fn references(app: &mut WordApp, ui: &mut Ui) { mi(ui, app, "Remove Table of Contents", "references.removeToc", json!({})); }); stack(ui, |ui| { - menu_button(ui, app, "addText", Some("Add Text"), "Add Text", false, |ui, app| { + menu_button(ui, app, "addText", Some("Add Text"), "Add Text", false, "references.addText", |ui, app| { mi(ui, app, "Do Not Show in TOC", "references.addText", json!({"level": 0})); for l in 1..=3u64 { mi(ui, app, &format!("Level {l}"), "references.addText", json!({"level": l})); @@ -1266,7 +1266,7 @@ fn references(app: &mut WordApp, ui: &mut Ui) { }); group(ui, "Citations & Bibliography", None, app, |ui, app| { // 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| { + 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 { @@ -1281,7 +1281,7 @@ fn references(app: &mut WordApp, ui: &mut Ui) { small(ui, app, "sources", Some("Manage Sources"), "Manage Sources", "references.sources", json!({}), false); let current = app.session.bib_style.clone(); let label = crate::i18n::fmt(tl!("Style: {style}"), &[("style", ¤t)]); - menu_button(ui, app, "styles", Some(&label), "Citation Style", false, |ui, app| { + menu_button(ui, app, "styles", Some(&label), "Citation Style", false, "references.citationStyle", |ui, app| { for st in &wordcraft_engine::cmd::citations::STYLES { mi_check(ui, app, st.name, st.name == current, "references.citationStyle", json!({"style": st.name})); } @@ -1338,7 +1338,7 @@ fn mailings(app: &mut WordApp, ui: &mut Ui) { }); group(ui, "Start Mail Merge", None, app, |ui, app| { // The kinds of merge document the engine makes, the current one checked. - menu_button(ui, app, "mailMerge", Some("Start Mail\nMerge"), "Start Mail Merge", true, |ui, app| { + menu_button(ui, app, "mailMerge", Some("Start Mail\nMerge"), "Start Mail Merge", true, "mailings.start", |ui, app| { let kind = app.session.merge.kind.clone(); for (label, k) in [ ("Letters", "letters"), @@ -1353,7 +1353,7 @@ fn mailings(app: &mut WordApp, ui: &mut Ui) { } }); // The command needs data, so the button offers the two ways to give it (#240). - menu_button(ui, app, "recipients", Some("Select\nRecipients"), "Select Recipients", true, |ui, app| { + menu_button(ui, app, "recipients", Some("Select\nRecipients"), "Select Recipients", true, "mailings.recipients", |ui, app| { mi(ui, app, "Type a New List…", "ui.dialog", json!({"name": "newRecipientList"})); mi(ui, app, "Use an Existing List…", "ui.openRecipientList", json!({})); }); @@ -1366,7 +1366,7 @@ fn mailings(app: &mut WordApp, ui: &mut Ui) { big(ui, app, "mergeField", "Insert Merge\nField", "mailings.insertField", json!({}), false); stack(ui, |ui| { // The merge rules the engine knows; If and Skip Record If ask for their condition. - menu_button(ui, app, "rules", Some("Rules"), "Rules", false, |ui, app| { + menu_button(ui, app, "rules", Some("Rules"), "Rules", false, "mailings.rules", |ui, app| { mi(ui, app, "If…Then…Else…", "mailings.rules", json!({"rule": "IF"})); mi(ui, app, "Merge Record #", "mailings.rules", json!({"rule": "MERGEREC"})); mi(ui, app, "Next Record", "mailings.rules", json!({"rule": "NEXT"})); @@ -1424,7 +1424,7 @@ fn review(app: &mut WordApp, ui: &mut Ui) { }); group(ui, "Language", None, app, |ui, app| { big(ui, app, "translate", "Translate", "review.translate", json!({}), false); - menu_button(ui, app, "language", Some("Language"), "Language", true, language_menu); + menu_button(ui, app, "language", Some("Language"), "Language", true, "", language_menu); }); group(ui, "Comments", None, app, |ui, app| { big(ui, app, "newComment", "New\nComment", "review.newComment", json!({}), false); @@ -1448,6 +1448,7 @@ fn review(app: &mut WordApp, ui: &mut Ui) { Some(if app.session.view.show_markup { "All Markup" } else { "No Markup" }), "Display for Review", false, + "review.markup", |ui, app| { mi(ui, app, "All Markup", "review.markup", json!({"value": "all"})); mi(ui, app, "No Markup", "review.markup", json!({"value": "noMarkup"})); @@ -1457,11 +1458,11 @@ fn review(app: &mut WordApp, ui: &mut Ui) { }); }); group(ui, "Changes", None, app, |ui, app| { - menu_button(ui, app, "accept", Some("Accept"), "Accept", true, |ui, app| { + menu_button(ui, app, "accept", Some("Accept"), "Accept", true, "review.accept", |ui, app| { mi(ui, app, "Accept This Change", "review.accept", json!({})); mi(ui, app, "Accept All Changes", "review.acceptAll", json!({})); }); - menu_button(ui, app, "reject", Some("Reject"), "Reject", true, |ui, app| { + menu_button(ui, app, "reject", Some("Reject"), "Reject", true, "review.reject", |ui, app| { mi(ui, app, "Reject Change", "review.reject", json!({})); mi(ui, app, "Reject All Changes", "review.rejectAll", json!({})); }); @@ -1576,7 +1577,7 @@ fn view(app: &mut WordApp, ui: &mut Ui) { }); group(ui, "Dark Mode", None, app, |ui, app| { big(ui, app, "darkMode", "Switch\nModes", "view.darkMode", json!({}), false); - menu_button(ui, app, "interfaceTheme", Some("Interface\nTheme"), "Interface Theme", true, |ui, app| { + menu_button(ui, app, "interfaceTheme", Some("Interface\nTheme"), "Interface Theme", true, "", |ui, app| { for a in crate::theme::Appearance::ALL { if ui.add(egui::Button::selectable(app.ui.theme == a, tl!(a.label())).min_size(vec2(200.0, 0.0))).clicked() { let _ = app.run("ui.theme", json!({"value": a.code()})); @@ -1708,7 +1709,7 @@ fn smart_art_design(app: &mut WordApp, ui: &mut Ui) { }); }); group(ui, "SmartArt Styles", None, app, |ui, app| { - menu_button(ui, app, "smartArtColors", Some("Change\nColors"), "Change Colors", true, |ui, app| { + menu_button(ui, app, "smartArtColors", Some("Change\nColors"), "Change Colors", true, "", |ui, app| { for colors in SmartArtColors::ALL { let on = g.colors == colors; let resp = ui.add(egui::Button::new(crate::dialogs_smart_art::colors_label(colors)).selected(on).min_size(vec2(200.0, 24.0))); @@ -1731,12 +1732,12 @@ fn smart_art_design(app: &mut WordApp, ui: &mut Ui) { fn chart_design(app: &mut WordApp, ui: &mut Ui) { let Some(chart) = wordcraft_engine::cmd::chart::selected_chart(&app.session) else { return }; group(ui, "Chart Layouts", None, app, |ui, app| { - menu_button(ui, app, "chartTitle", Some("Chart Title"), "Chart Title", true, |ui, app| { + menu_button(ui, app, "chartTitle", Some("Chart Title"), "Chart Title", true, "", |ui, app| { mi_check(ui, app, "None", chart.title.is_none(), "chart.title", json!({"text": null})); let text = chart.title.clone().unwrap_or_else(|| tl!("Chart Title").to_string()); mi_check(ui, app, "Above Chart", chart.title.is_some(), "chart.title", json!({"text": text})); }); - menu_button(ui, app, "legend", Some("Legend"), "Legend", true, |ui, app| { + menu_button(ui, app, "legend", Some("Legend"), "Legend", true, "", |ui, app| { mi_check(ui, app, "None", chart.legend.is_none(), "chart.legend", json!({"pos": "none"})); for (label, pos) in [ ("Right", wordcraft_doc::chart::LegendPos::Right), @@ -1747,7 +1748,7 @@ fn chart_design(app: &mut WordApp, ui: &mut Ui) { mi_check(ui, app, label, chart.legend == Some(pos), "chart.legend", json!({"pos": pos.id()})); } }); - menu_button(ui, app, "dataLabels", Some("Data Labels"), "Data Labels", true, |ui, app| { + menu_button(ui, app, "dataLabels", Some("Data Labels"), "Data Labels", true, "", |ui, app| { mi_check(ui, app, "None", !chart.data_labels, "chart.dataLabels", json!({"on": false})); mi_check(ui, app, "Show", chart.data_labels, "chart.dataLabels", json!({"on": true})); }); @@ -1788,11 +1789,11 @@ fn shape_format(app: &mut WordApp, ui: &mut Ui) { let theme = app.session.doc.settings.theme_colors.clone(); group(ui, "Shape Styles", None, app, |ui, app| { stack(ui, |ui| { - menu_button(ui, app, "shading", Some("Shape Fill"), "Shape Fill", false, |ui, app| { + menu_button(ui, app, "shading", Some("Shape Fill"), "Shape Fill", false, "shape.fill", |ui, app| { mi(ui, app, "No Color", "shape.fill", json!({"color": null})); crate::colors::menu(ui, app, &theme, Target::cmd("shape.fill")); }); - menu_button(ui, app, "pen", Some("Shape Outline"), "Shape Outline", false, |ui, app| { + menu_button(ui, app, "pen", Some("Shape Outline"), "Shape Outline", false, "shape.outline", |ui, app| { mi(ui, app, "No Color", "shape.outline", json!({"color": null})); crate::colors::menu(ui, app, &theme, Target::cmd("shape.outline")); ui.separator(); @@ -1803,7 +1804,7 @@ fn shape_format(app: &mut WordApp, ui: &mut Ui) { } }); }); - menu_button(ui, app, "shapeEffects", Some("Shape Effects"), "Shadow, glow and soft edges", false, |ui, app| { + menu_button(ui, app, "shapeEffects", Some("Shape Effects"), "Shadow, glow and soft edges", false, "shape.effects", |ui, app| { shape_effects_menu(ui, app, &theme, glow_size) }); }); @@ -1811,7 +1812,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| { + menu_button(ui, app, "canvas", Some("Drawing\nCanvas"), "Drawing Canvas", true, "", |ui, app| { mi(ui, app, "Fit", "canvas.fit", json!({})); mi(ui, app, "Expand", "canvas.expand", json!({})); ui.menu_button(tl!("Scale Drawing"), |ui| { @@ -1823,16 +1824,16 @@ fn shape_format(app: &mut WordApp, ui: &mut Ui) { }); } group(ui, "WordArt Styles", None, app, |ui, app| { - menu_button(ui, app, "wordArt", Some("Styles"), "WordArt Styles", true, |ui, app| { + menu_button(ui, app, "wordArt", Some("Styles"), "WordArt Styles", true, "", |ui, app| { crate::wordart_menus::style_gallery(ui, app, "wordArt.style") }); stack(ui, |ui| { - menu_button(ui, app, "textFill", Some("Text Fill"), "Text Fill", false, |ui, app| crate::wordart_menus::fill_menu(ui, app, &theme)); + menu_button(ui, app, "textFill", Some("Text Fill"), "Text Fill", false, "", |ui, app| crate::wordart_menus::fill_menu(ui, app, &theme)); let outline = crate::wordart_menus::current_outline(app); - menu_button(ui, app, "textOutline", Some("Text Outline"), "Text Outline", false, |ui, app| { + menu_button(ui, app, "textOutline", Some("Text Outline"), "Text Outline", false, "", |ui, app| { crate::wordart_menus::outline_menu(ui, app, &theme, outline) }); - menu_button(ui, app, "transform", Some("Text Effects"), "Text Effects", false, |ui, app| { + menu_button(ui, app, "transform", Some("Text Effects"), "Text Effects", false, "", |ui, app| { crate::wordart_menus::effects_menu(ui, app, &theme, true) }); }); @@ -1877,7 +1878,7 @@ fn shape_text_group(app: &mut WordApp, ui: &mut Ui) { let body = target.and_then(|(_, id)| app.session.doc.parts.get(&id).map(|p| p.body)).unwrap_or_default(); group(ui, "Text", None, app, |ui, app| { stack(ui, |ui| { - menu_button(ui, app, "textDirection", Some("Text Direction"), "Text Direction", false, |ui, app| { + menu_button(ui, app, "textDirection", Some("Text Direction"), "Text Direction", false, "", |ui, app| { for (label, vert) in [ ("Horizontal", TextVert::Horz), ("Rotate all text 90°", TextVert::Vert), @@ -1887,7 +1888,7 @@ fn shape_text_group(app: &mut WordApp, ui: &mut Ui) { mi_check(ui, app, label, body.vert == vert, "shape.textDirection", json!({"dir": vert.ooxml()})); } }); - menu_button(ui, app, "alignText", Some("Align Text"), "Align Text", false, |ui, app| { + menu_button(ui, app, "alignText", Some("Align Text"), "Align Text", false, "", |ui, app| { for (label, anchor) in [("Top", VAlign::Top), ("Middle", VAlign::Center), ("Bottom", VAlign::Bottom)] { mi_check(ui, app, label, body.anchor == anchor, "shape.alignText", json!({"anchor": wordcraft_doc::props::anchor_ooxml(anchor)})); } @@ -1941,7 +1942,7 @@ fn shape_effects_menu(ui: &mut Ui, app: &mut WordApp, theme: &[wordcraft_doc::Rg fn picture_format(app: &mut WordApp, ui: &mut Ui) { group(ui, "Adjust", None, app, |ui, app| { - menu_button(ui, app, "picture", Some("Corrections"), "Brightness, contrast and sharpness", true, |ui, app| { + menu_button(ui, app, "picture", Some("Corrections"), "Brightness, contrast and sharpness", true, "picture.corrections", |ui, app| { for (l, b, c) in [("Brighter +20%", 20.0, 0.0), ("Darker −20%", -20.0, 0.0), ("More Contrast +20%", 0.0, 20.0), ("Less Contrast −20%", 0.0, -20.0)] { @@ -1951,7 +1952,7 @@ fn picture_format(app: &mut WordApp, ui: &mut Ui) { mi(ui, app, "Sharpen", "picture.corrections", json!({"sharpen": 40.0})); mi(ui, app, "Soften", "picture.corrections", json!({"sharpen": -40.0})); }); - menu_button(ui, app, "picture", Some("Color"), "Saturation and recolor", true, |ui, app| { + menu_button(ui, app, "picture", Some("Color"), "Saturation and recolor", true, "picture.color", |ui, app| { for (l, v) in [("Full Saturation 100%", 100.0), ("Muted 50%", 50.0), ("Gray 0%", 0.0), ("Vivid 200%", 200.0)] { mi(ui, app, l, "picture.color", json!({"mode": "saturation", "saturation": v})); } @@ -1960,11 +1961,11 @@ fn picture_format(app: &mut WordApp, ui: &mut Ui) { mi(ui, app, "Sepia", "picture.color", json!({"mode": "sepia"})); mi(ui, app, "Washout", "picture.color", json!({"mode": "washout"})); }); - menu_button(ui, app, "icons", Some("Icon Color"), "Recolor a one-color picture, such as an icon", true, |ui, app| { + menu_button(ui, app, "icons", Some("Icon Color"), "Recolor a one-color picture, such as an icon", true, "picture.iconColor", |ui, app| { let theme = app.session.doc.settings.theme_colors.clone(); crate::colors::menu(ui, app, &theme, Target::cmd("picture.iconColor")); }); - menu_button(ui, app, "picture", Some("Transparency"), "Picture transparency", true, |ui, app| { + menu_button(ui, app, "picture", Some("Transparency"), "Picture transparency", true, "picture.transparency", |ui, app| { for v in [0.0, 15.0, 30.0, 50.0, 65.0, 80.0, 95.0] { mi(ui, app, &format!("{v:.0}%"), "picture.transparency", json!({"percent": v})); } @@ -1975,7 +1976,7 @@ fn picture_format(app: &mut WordApp, ui: &mut Ui) { }); }); group(ui, "Picture Styles", None, app, |ui, app| { - menu_button(ui, app, "picture", Some("Styles"), "Frames and effects", true, |ui, app| { + menu_button(ui, app, "picture", Some("Styles"), "Frames and effects", true, "picture.style", |ui, app| { for (l, s) in [ ("Simple Frame", "simpleFrame"), ("Thick Frame", "thickFrame"), @@ -1986,7 +1987,7 @@ fn picture_format(app: &mut WordApp, ui: &mut Ui) { mi(ui, app, l, "picture.style", json!({"style": s})); } }); - menu_button(ui, app, "picture", Some("Border"), "Picture border", true, |ui, app| { + menu_button(ui, app, "picture", Some("Border"), "Picture border", true, "picture.border", |ui, app| { for (l, w) in [("Thin", 2), ("Medium", 4), ("Thick", 8)] { mi(ui, app, l, "picture.border", json!({"width": w})); } @@ -2132,7 +2133,7 @@ fn table_design(app: &mut WordApp, ui: &mut Ui) { let can_modify = current.is_some(); // Built-in table styles can't be deleted. let can_delete = current == Some(false); - menu_button(ui, app, "styles", Some("Styles"), "Table Styles", false, |ui, app| { + menu_button(ui, app, "styles", Some("Styles"), "Table Styles", false, "table.style", |ui, app| { mi(ui, app, "New Table Style…", "ui.dialog", json!({"name": "newTableStyle"})); if ui.add_enabled(can_modify, egui::Button::new(tl!("Modify Table Style…")).min_size(vec2(200.0, 0.0))).clicked() { let _ = app.run("ui.dialog", json!({"name": "modifyTableStyle"})); @@ -2153,7 +2154,7 @@ fn table_design(app: &mut WordApp, ui: &mut Ui) { }); }); group(ui, "Borders", None, app, |ui, app| { - menu_button(ui, app, "borders", Some("Borders"), "Borders", true, |ui, app| { + menu_button(ui, app, "borders", Some("Borders"), "Borders", true, "table.borders", |ui, app| { for (l, k) in [ ("All Borders", "all"), ("Outside Borders", "outside"), @@ -2235,7 +2236,7 @@ fn cell_size_boxes(ui: &mut Ui, app: &mut WordApp) { fn table_layout(app: &mut WordApp, ui: &mut Ui) { group(ui, "Table", None, app, |ui, app| { stack(ui, |ui| { - menu_button(ui, app, "selectTable", Some("Select"), "Select", false, |ui, app| { + menu_button(ui, app, "selectTable", Some("Select"), "Select", false, "table.selectTable", |ui, app| { mi(ui, app, "Select Cell", "table.selectCell", json!({})); mi(ui, app, "Select Row", "table.selectRow", json!({})); mi(ui, app, "Select Table", "table.selectTable", json!({})); @@ -2252,7 +2253,7 @@ fn table_layout(app: &mut WordApp, ui: &mut Ui) { crate::widgets::big_toggle(ui, app, "eraser", "Eraser", "table.eraser", tool == Some(TableTool::Erase)); }); group(ui, "Rows & Columns", None, app, |ui, app| { - menu_button(ui, app, "deleteTable", Some("Delete"), "Delete", true, |ui, app| { + menu_button(ui, app, "deleteTable", Some("Delete"), "Delete", true, "table.deleteTable", |ui, app| { mi(ui, app, "Delete Cells", "table.deleteCells", json!({})); mi(ui, app, "Delete Columns", "table.deleteColumn", json!({})); mi(ui, app, "Delete Rows", "table.deleteRow", json!({})); @@ -2273,7 +2274,7 @@ fn table_layout(app: &mut WordApp, ui: &mut Ui) { }); }); group(ui, "Cell Size", None, app, |ui, app| { - menu_button(ui, app, "autofit", Some("AutoFit"), "AutoFit", true, |ui, app| { + menu_button(ui, app, "autofit", Some("AutoFit"), "AutoFit", true, "table.autofit", |ui, app| { mi(ui, app, "AutoFit Contents", "table.autofit", json!({"mode": "contents"})); mi(ui, app, "AutoFit Window", "table.autofit", json!({"mode": "window"})); mi(ui, app, "Fixed Column Width", "table.autofit", json!({"mode": "fixed"})); @@ -2344,6 +2345,23 @@ mod tests { assert!(next_x >= gallery.right() + 8.0 - 0.01, "{next_x} {gallery:?}"); } + /// #658: menu buttons grey out (and don't open) while the command their menu is about is + /// disabled: the Table Layout menus outside a table; menus with no command stay on. + #[test] + fn menu_buttons_follow_their_command() { + use crate::widgets::menu_enabled; + let mut a = crate::WordApp::new(Session::new(wordcraft_doc::Document::from_text("Text")), crate::Services::default()); + for id in ["table.selectTable", "table.deleteTable", "table.autofit"] { + assert!(!menu_enabled(&a, id), "{id} is off outside a table"); + } + assert!(menu_enabled(&a, ""), "a menu without a command is always on"); + assert!(menu_enabled(&a, "insert.header")); + a.run("insert.table", json!({"rows": 2, "cols": 2})).unwrap(); + for id in ["table.selectTable", "table.deleteTable", "table.autofit"] { + assert!(menu_enabled(&a, id), "{id} is on inside a table"); + } + } + #[test] fn picture_format_tab_appears_when_picture_selected() { let mut s = Session::new(wordcraft_doc::Document::new()); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 8881adc49..1046465bb 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -231,6 +231,11 @@ pub fn split( }); } +/// Whether a menu button whose menu is about command `id` is on ("" = always). +pub fn menu_enabled(app: &WordApp, id: &str) -> bool { + id.is_empty() || enabled(app, id) +} + /// A button that only opens a menu (icon + label + drop-down marker). pub fn menu_button( ui: &mut Ui, @@ -239,10 +244,15 @@ pub fn menu_button( label: Option<&str>, tip: &str, big_btn: bool, + id: &str, menu: impl FnOnce(&mut Ui, &mut WordApp), ) { let (label, tip) = (label.map(|l| tl!(l)), tl!(tip)); let t = Tokens::get(ui.ctx()); + // `id` names the command the menu is about (Header for the Header menu…); while it is + // disabled the button greys out and doesn't open, like the other ribbon buttons. "" = always on. + let on = menu_enabled(app, id); + let (c, a, text) = if on { (t.icon, t.accent, t.text) } else { (t.text_disabled, t.text_disabled, t.text_disabled) }; let resp = if big_btn { let galley_w = label .unwrap_or("") @@ -253,11 +263,11 @@ pub fn menu_button( let (r, resp) = ui.allocate_exact_size(vec2(w, CONTENT_H), Sense::click()); keytip_badge(ui, r, "menu", tip); bg(ui, r, &resp, false, &t); - icons::paint(ui.painter(), Rect::from_center_size(pos2(r.center().x, r.min.y + 20.0), vec2(32.0, 32.0)), icon, t.icon, t.accent); + icons::paint(ui.painter(), Rect::from_center_size(pos2(r.center().x, r.min.y + 20.0), vec2(32.0, 32.0)), icon, c, a); let lines: Vec<&str> = label.unwrap_or("").split('\n').collect(); let mut y = r.min.y + 43.0; for (i, l) in lines.iter().enumerate() { - label_line(ui, r.center().x, y, l, regular(11.5), t.text, i + 1 == lines.len()); + label_line(ui, r.center().x, y, l, regular(11.5), text, i + 1 == lines.len()); y += 13.0; } resp @@ -266,14 +276,17 @@ pub fn menu_button( let (r, resp) = ui.allocate_exact_size(vec2(24.0 + text_w + 10.0, 22.0), Sense::click()); keytip_badge(ui, r, "menu", tip); bg(ui, r, &resp, false, &t); - icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(17.0, 17.0)), icon, t.icon, t.accent); + icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(17.0, 17.0)), icon, c, a); if let Some(l) = label { - ui.painter().text(pos2(r.min.x + 24.0, r.center().y), Align2::LEFT_CENTER, l, regular(11.5), t.text); + ui.painter().text(pos2(r.min.x + 24.0, r.center().y), Align2::LEFT_CENTER, l, regular(11.5), text); } - icons::paint(ui.painter(), Rect::from_center_size(pos2(r.max.x - 6.0, r.center().y), vec2(DROPDOWN, DROPDOWN)), "dropdown", t.icon, t.accent); + icons::paint(ui.painter(), Rect::from_center_size(pos2(r.max.x - 6.0, r.center().y), vec2(DROPDOWN, DROPDOWN)), "dropdown", c, c); resp }; let resp = resp.on_hover_text(tip); + if !on { + return; + } egui::Popup::menu(&resp).show(|ui| { ui.set_min_width(200.0); menu(ui, app); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 51669378a..b7b39a2c4 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 (one drop-down marker, #659; previously minor: status bar language follows the caret and sets the proofing language; Review › Language is a menu; 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 (menu buttons grey out, #658; previously trivial: one drop-down marker, #659; previously minor: status bar language follows the caret and sets the proofing language; Review › Language is a menu; 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 @@ -69,6 +69,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | 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 | Menu buttons (Header, Page Number, Breaks, Wrap Text, table Select/Delete/AutoFit…) grey out and don't open while the command their menu is about is disabled, like the other ribbon buttons (#658) | | 2026-10-11 | trivial | One drop-down marker everywhere: large ribbon buttons, Equation and the Editing/Reviewing button draw the `dropdown` icon after their label, like split and menu buttons, instead of a text glyph (#659) | | 2026-10-11 | trivial | Dark theme: Styles, style-set and table-style previews sit on a dimmer page tone; with View › Switch Modes on, the Styles gallery previews the dark page (#663) | | 2026-10-11 | trivial | Dialog primary buttons, the Discord pill, the table eraser line and the Navigation search highlight take their colours from the theme in both themes (`widgets::primary_button`); a test bans the old fixed values (#656) | From 4a1acb6c3f2e27fa26e6f0be5aef1fed60dcf184 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Mon, 12 Oct 2026 12:13:17 +0200 Subject: [PATCH 28/88] Ribbon: large-button labels end above the group titles (#653) (#699) * #659: one drop-down marker (the dropdown icon) on every ribbon button Co-Authored-By: Claude Opus 5.5 * #658: menu buttons grey out while their command is disabled Co-Authored-By: Claude Opus 5.5 * #653: large-button labels end above the group title row 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/equation_tab.rs | 22 ++++++++++------ crates/ui-egui/src/widgets.rs | 40 +++++++++++++++++++++++------- docs/ui-parity.md | 3 ++- 3 files changed, 47 insertions(+), 18 deletions(-) diff --git a/crates/ui-egui/src/equation_tab.rs b/crates/ui-egui/src/equation_tab.rs index 0367e9060..4a01475a3 100644 --- a/crates/ui-egui/src/equation_tab.rs +++ b/crates/ui-egui/src/equation_tab.rs @@ -11,7 +11,7 @@ use wordcraft_engine::math_gallery::{BUILT_INS, STRUCTURES, SYMBOL_SETS, Templat use crate::WordApp; use crate::theme::{Tokens, regular, semibold}; -use crate::widgets::{CONTENT_H, group, small}; +use crate::widgets::{BIG_ICON_Y, BIG_LABEL_Y, CONTENT_H, LABEL_PITCH, group, small}; /// What each structure button shows. fn button_picture(id: &str) -> &'static str { @@ -56,14 +56,14 @@ fn math_menu_button(ui: &mut Ui, app: &mut WordApp, picture: &str, label: &str, if resp.hovered() { ui.painter().rect_filled(r, 4.0, t.hover); } - let pic = Rect::from_center_size(pos2(r.center().x, r.min.y + 20.0), vec2(40.0, 34.0)); + let pic = Rect::from_center_size(pos2(r.center().x, r.min.y + BIG_ICON_Y), vec2(40.0, 32.0)); ui.painter().rect_filled(pic, 3.0, egui::Color32::WHITE); paint_math(app, ui, pic, &format!("btn:{picture}"), picture, None, false, 12.0); let lines: Vec<&str> = label.split('\n').collect(); - let mut y = r.min.y + 43.0; + let mut y = r.min.y + BIG_LABEL_Y; for (i, l) in lines.iter().enumerate() { crate::widgets::label_line(ui, r.center().x, y, l, regular(11.5), t.text, i + 1 == lines.len()); - y += 13.0; + y += LABEL_PITCH; } let resp = resp.on_hover_text(label.replace('\n', " ")); egui::Popup::menu(&resp).show(|ui| { @@ -152,8 +152,8 @@ pub fn insert_button(ui: &mut Ui, app: &mut WordApp) { let label = tl!("Equation"); let w = (ui.ctx().fonts_mut(|f| f.layout_no_wrap(label.to_string(), regular(11.5), t.text).size().x) + 22.0).max(48.0); let (r, _) = ui.allocate_exact_size(vec2(w, CONTENT_H), Sense::hover()); - let top = Rect::from_min_max(r.min, pos2(r.max.x, r.min.y + 40.0)); - let bottom = Rect::from_min_max(pos2(r.min.x, r.min.y + 40.0), r.max); + let top = Rect::from_min_max(r.min, pos2(r.max.x, r.min.y + BIG_LABEL_Y - 2.0)); + let bottom = Rect::from_min_max(pos2(r.min.x, r.min.y + BIG_LABEL_Y - 2.0), r.max); let rt = ui.interact(top, ui.id().with("eq_insert_top"), Sense::click()); let rb = ui.interact(bottom, ui.id().with("eq_insert_bottom"), Sense::click()); for (rr, resp) in [(top, &rt), (bottom, &rb)] { @@ -161,8 +161,14 @@ pub fn insert_button(ui: &mut Ui, app: &mut WordApp) { ui.painter().rect_filled(rr, 4.0, t.hover); } } - crate::icons::paint(ui.painter(), Rect::from_center_size(pos2(r.center().x, r.min.y + 20.0), vec2(32.0, 32.0)), "equation", t.icon, t.accent); - crate::widgets::label_line(ui, r.center().x, r.min.y + 43.0, label, regular(11.5), t.text, true); + crate::icons::paint( + ui.painter(), + Rect::from_center_size(pos2(r.center().x, r.min.y + BIG_ICON_Y), vec2(32.0, 32.0)), + "equation", + t.icon, + t.accent, + ); + crate::widgets::label_line(ui, r.center().x, r.min.y + BIG_LABEL_Y, label, regular(11.5), t.text, true); if rt.on_hover_text(tl!("Insert a new equation (Alt+=)")).clicked() { let _ = app.run("insert.equation", json!({})); app.canvas.want_focus = true; diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 1046465bb..3c3ff0a66 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -8,7 +8,17 @@ use crate::{WordApp, icons}; /// Height of the ribbon content area (without group labels). pub const CONTENT_H: f32 = 66.0; -pub const LABEL_H: f32 = 16.0; +/// Height of the group title row under the content. +pub const LABEL_H: f32 = 18.0; +/// Large buttons: centre of the 32 px icon, top of the first label line, and the pitch and +/// height of a label line (`regular(11.5)`; a test checks the font's real line height). +pub const BIG_ICON_Y: f32 = 18.0; +pub const BIG_LABEL_Y: f32 = 36.0; +pub const LABEL_PITCH: f32 = 13.0; +pub const LABEL_LINE_H: f32 = 14.0; +// #653: a two-line label ends inside the content, above the group title row. +const _: () = assert!(BIG_LABEL_Y + LABEL_PITCH + LABEL_LINE_H <= CONTENT_H); +const _: () = assert!(BIG_ICON_Y + 16.0 <= BIG_LABEL_Y); /// Shortcut text for a command (`⌘B` on macOS, `Ctrl+B` elsewhere): its first key in effect, /// custom keys included (Customize Keyboard). @@ -154,14 +164,14 @@ fn big_button(ui: &mut Ui, app: &mut WordApp, icon: &str, label: &str, id: &str, keytip_badge(ui, r, id, &label.replace('\n', " ")); let on = enabled(app, id); bg(ui, r, &resp, checked, &t); - let ic = Rect::from_center_size(pos2(r.center().x, r.min.y + 20.0), vec2(32.0, 32.0)); + let ic = Rect::from_center_size(pos2(r.center().x, r.min.y + BIG_ICON_Y), vec2(32.0, 32.0)); let (c, a) = if on { (t.icon, t.accent) } else { (t.text_disabled, t.text_disabled) }; icons::paint(ui.painter(), ic, icon, c, a); - let mut y = r.min.y + 43.0; + let mut y = r.min.y + BIG_LABEL_Y; let lines: Vec<&str> = label.split('\n').collect(); for (i, l) in lines.iter().enumerate() { label_line(ui, r.center().x, y, l, regular(11.5), if on { t.text } else { t.text_disabled }, menu && i + 1 == lines.len()); - y += 13.0; + y += LABEL_PITCH; } let resp = tooltip(app, resp, &label.replace('\n', " "), id); command_menu(&resp, app, id); @@ -263,12 +273,12 @@ pub fn menu_button( let (r, resp) = ui.allocate_exact_size(vec2(w, CONTENT_H), Sense::click()); keytip_badge(ui, r, "menu", tip); bg(ui, r, &resp, false, &t); - icons::paint(ui.painter(), Rect::from_center_size(pos2(r.center().x, r.min.y + 20.0), vec2(32.0, 32.0)), icon, c, a); + icons::paint(ui.painter(), Rect::from_center_size(pos2(r.center().x, r.min.y + BIG_ICON_Y), vec2(32.0, 32.0)), icon, c, a); let lines: Vec<&str> = label.unwrap_or("").split('\n').collect(); - let mut y = r.min.y + 43.0; + let mut y = r.min.y + BIG_LABEL_Y; for (i, l) in lines.iter().enumerate() { label_line(ui, r.center().x, y, l, regular(11.5), text, i + 1 == lines.len()); - y += 13.0; + y += LABEL_PITCH; } resp } else { @@ -313,9 +323,10 @@ pub fn group_titled(ui: &mut Ui, id_title: &str, title: &str, launcher: Option<& }); let r = inner.response.rect; let r = Rect::from_min_max(start, pos2(r.max.x, start.y + CONTENT_H + LABEL_H)); - ui.painter().text(pos2(r.center().x, r.max.y - 8.0), Align2::CENTER_CENTER, title, regular(11.0), t.group_label); + let title_y = r.max.y - LABEL_H / 2.0; + ui.painter().text(pos2(r.center().x, title_y), Align2::CENTER_CENTER, title, regular(11.0), t.group_label); if let Some(cmd) = launcher { - let lr = Rect::from_center_size(pos2(r.max.x - 6.0, r.max.y - 8.0), vec2(11.0, 11.0)); + let lr = Rect::from_center_size(pos2(r.max.x - 6.0, title_y), vec2(11.0, 11.0)); let resp = ui.interact(lr, ui.id().with(("launch", id_title)), Sense::click()); if resp.hovered() { ui.painter().rect_filled(lr.expand(2.0), 2.0, t.hover); @@ -822,6 +833,17 @@ fn icon_text_button_with(ui: &mut Ui, icon: &str, text: &str, font: egui::FontId mod tests { use super::*; + /// #653: a large button's label line is no taller than `LABEL_LINE_H` in the interface font, so + /// two lines (checked at compile time) end above the group title row. + #[test] + fn big_button_label_lines_fit_their_slot() { + let ctx = egui::Context::default(); + ctx.set_fonts(crate::theme::font_definitions(false, false)); + ctx.run_ui(egui::RawInput::default(), |_| {}).drop_without_applying_deltas(); + let h = ctx.fonts_mut(|f| f.layout_no_wrap("Page Number Mail Merge gjpqy".into(), crate::theme::regular(11.5), Color32::WHITE).size().y); + assert!(h <= LABEL_LINE_H, "label line is {h} px tall"); + } + #[test] fn drawn_buttons_fade_in_on_hover_and_darken_while_pressed() { // #305's drawn replacements for ✕, 💬, ✎ and ➕ must react like egui's buttons. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index b7b39a2c4..eb48300d1 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 (menu buttons grey out, #658; previously trivial: one drop-down marker, #659; previously minor: status bar language follows the caret and sets the proofing language; Review › Language is a menu; 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 (ribbon labels clear the group titles, #653; previously trivial: menu buttons grey out, #658; previously trivial: one drop-down marker, #659; previously minor: status bar language follows the caret and sets the proofing language; Review › Language is a menu; 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 @@ -69,6 +69,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | 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 | Two-line labels on large ribbon buttons end above the group title row (icon and label moved up, title row 18 px; shared constants with a compile-time check) (#653) | | 2026-10-11 | trivial | Menu buttons (Header, Page Number, Breaks, Wrap Text, table Select/Delete/AutoFit…) grey out and don't open while the command their menu is about is disabled, like the other ribbon buttons (#658) | | 2026-10-11 | trivial | One drop-down marker everywhere: large ribbon buttons, Equation and the Editing/Reviewing button draw the `dropdown` icon after their label, like split and menu buttons, instead of a text glyph (#659) | | 2026-10-11 | trivial | Dark theme: Styles, style-set and table-style previews sit on a dimmer page tone; with View › Switch Modes on, the Styles gallery previews the dark page (#663) | From af622ac843b684c0fb4d6d280d1537cae765a53e Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Mon, 12 Oct 2026 12:16:15 +0200 Subject: [PATCH 29/88] =?UTF-8?q?Layout=20=E2=80=BA=20Paragraph:=20fields?= =?UTF-8?q?=20fit=20above=20the=20title=20and=20line=20up=20(#654)=20(#703?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * #659: one drop-down marker (the dropdown icon) on every ribbon button Co-Authored-By: Claude Opus 5.5 * #658: menu buttons grey out while their command is disabled Co-Authored-By: Claude Opus 5.5 * #653: large-button labels end above the group title row Co-Authored-By: Claude Opus 5.5 * #654: Layout › Paragraph fields fit above the title and line up 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/ribbon.rs | 57 +++++++++++++++++++----------------- docs/ui-parity.md | 3 +- 2 files changed, 32 insertions(+), 28 deletions(-) diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 099400ae8..d812ade86 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1111,9 +1111,14 @@ fn layout(app: &mut WordApp, ui: &mut Ui) { group(ui, "Paragraph", Some("para.dialog"), app, |ui, app| { let rp = app.session.doc.para_at(&app.session.sel.focus).map(|p| app.session.doc.styles.resolve_para(&p.props)); let Some(rp) = rp else { return }; - egui::Grid::new("layout_para").spacing(vec2(6.0, 4.0)).show(ui, |ui| { + // #654: header + two rows of 20 px fields fit the content height above the group title; + // labels and fields sit on shared columns, every field the same width. + const FIELD: egui::Vec2 = egui::Vec2::new(64.0, 20.0); + ui.spacing_mut().interact_size.y = FIELD.y; + egui::Grid::new("layout_para").spacing(vec2(4.0, 3.0)).min_row_height(FIELD.y).show(ui, |ui| { ui.label(egui::RichText::new(tl!("Indent")).small().strong()); ui.label(""); + ui.add_space(8.0); ui.label(egui::RichText::new(tl!("Spacing")).small().strong()); ui.end_row(); // Indents in the File › Options unit. @@ -1121,35 +1126,33 @@ fn layout(app: &mut WordApp, ui: &mut Ui) { let k = u.pt_per_unit(); let mut l = rp.indent_left / k; let mut b = rp.space_before; - ui.horizontal(|ui| { - ui.label(egui::RichText::new(tl!("Left:")).small()); - if ui.add(egui::DragValue::new(&mut l).speed(3.6 / k).range(-792.0 / k..=1584.0 / k).suffix(u.suffix()).max_decimals(2)).changed() { - let _ = app.run("para.indents", json!({"left": l * k})); - } - }); - ui.label(""); - ui.horizontal(|ui| { - ui.label(egui::RichText::new(tl!("Before:")).small()); - if ui.add(egui::DragValue::new(&mut b).speed(1.0).range(0.0..=1584.0).suffix(format!(" {}", tl!("pt")))).changed() { - let _ = app.run("para.spacing", json!({"before": b})); - } - }); + ui.label(egui::RichText::new(tl!("Left:")).small()); + if ui + .add_sized(FIELD, egui::DragValue::new(&mut l).speed(3.6 / k).range(-792.0 / k..=1584.0 / k).suffix(u.suffix()).max_decimals(2)) + .changed() + { + let _ = app.run("para.indents", json!({"left": l * k})); + } + ui.add_space(8.0); + ui.label(egui::RichText::new(tl!("Before:")).small()); + if ui.add_sized(FIELD, egui::DragValue::new(&mut b).speed(1.0).range(0.0..=1584.0).suffix(format!(" {}", tl!("pt")))).changed() { + let _ = app.run("para.spacing", json!({"before": b})); + } ui.end_row(); let mut r = rp.indent_right / k; let mut a = rp.space_after; - ui.horizontal(|ui| { - ui.label(egui::RichText::new(tl!("Right:")).small()); - if ui.add(egui::DragValue::new(&mut r).speed(3.6 / k).range(-792.0 / k..=1584.0 / k).suffix(u.suffix()).max_decimals(2)).changed() { - let _ = app.run("para.indents", json!({"right": r * k})); - } - }); - ui.label(""); - ui.horizontal(|ui| { - ui.label(egui::RichText::new(tl!("After:")).small()); - if ui.add(egui::DragValue::new(&mut a).speed(1.0).range(0.0..=1584.0).suffix(format!(" {}", tl!("pt")))).changed() { - let _ = app.run("para.spacing", json!({"after": a})); - } - }); + ui.label(egui::RichText::new(tl!("Right:")).small()); + if ui + .add_sized(FIELD, egui::DragValue::new(&mut r).speed(3.6 / k).range(-792.0 / k..=1584.0 / k).suffix(u.suffix()).max_decimals(2)) + .changed() + { + let _ = app.run("para.indents", json!({"right": r * k})); + } + ui.add_space(8.0); + ui.label(egui::RichText::new(tl!("After:")).small()); + if ui.add_sized(FIELD, egui::DragValue::new(&mut a).speed(1.0).range(0.0..=1584.0).suffix(format!(" {}", tl!("pt")))).changed() { + let _ = app.run("para.spacing", json!({"after": a})); + } ui.end_row(); }); }); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index eb48300d1..8e3a9e1f5 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 (ribbon labels clear the group titles, #653; previously trivial: menu buttons grey out, #658; previously trivial: one drop-down marker, #659; previously minor: status bar language follows the caret and sets the proofing language; Review › Language is a menu; 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 (Layout › Paragraph fields fit and align, #654; previously trivial: ribbon labels clear the group titles, #653; previously trivial: menu buttons grey out, #658; previously trivial: one drop-down marker, #659; previously minor: status bar language follows the caret and sets the proofing language; Review › Language is a menu; 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 @@ -69,6 +69,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | 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 | Layout › Paragraph: Indent and Spacing fields fit above the group title, on shared label and field columns, all the same width (#654) | | 2026-10-11 | trivial | Two-line labels on large ribbon buttons end above the group title row (icon and label moved up, title row 18 px; shared constants with a compile-time check) (#653) | | 2026-10-11 | trivial | Menu buttons (Header, Page Number, Breaks, Wrap Text, table Select/Delete/AutoFit…) grey out and don't open while the command their menu is about is disabled, like the other ribbon buttons (#658) | | 2026-10-11 | trivial | One drop-down marker everywhere: large ribbon buttons, Equation and the Editing/Reviewing button draw the `dropdown` icon after their label, like split and menu buttons, instead of a text glyph (#659) | From 7cccdf0f38e42989508813bf0d8c2a09fb581541 Mon Sep 17 00:00:00 2001 From: zuzazzo <100868689+m1ssha@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:24:36 +0300 Subject: [PATCH 30/88] KeyTips: Quick Access Toolbar badges, saved option and native Alt fix (#620) (#709) * M8.1: Quick Access Toolbar KeyTips (#620) * M8.1: Handle native Alt events for KeyTips (#620) --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 6 +- crates/ui-egui/src/chrome.rs | 29 ++- crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/et.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/pl.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/keytips.rs | 339 ++++++++++++++++++++++++++-- crates/ui-egui/src/lib.rs | 22 ++ crates/ui-egui/src/options.rs | 4 + crates/ui-egui/src/ribbon.rs | 2 +- docs/ui-parity.md | 11 +- 22 files changed, 392 insertions(+), 36 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 3c88b3fd5..d6f509e5f 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-11 · **Last updated:** 2026-10-11 · **Change:** trivial (independent complex-script formatting preserved across DOCX open/save; 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 (Quick Access Toolbar KeyTips and a saved enable/disable option, #620; 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 @@ -14,7 +14,7 @@ agent control. This page is the summary; the assessment is | Number | Value | Kind | |---|---|---| -| Ribbon/menu catalog coverage | **483 / 498 (97.0%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) | +| Ribbon/menu catalog coverage | **484 / 499 (97.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) | @@ -180,6 +180,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 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) | | 2026-10-11 | Shape Format › Text: text direction, Align Text and linked text boxes (Create Link / Break Link), read and written in DOCX (#369) | +| 2026-10-11 | Quick Access Toolbar KeyTips in the customized button order (Alt/F10 then a number), disabled-command checks and a saved File › Options switch (`ui.keytips`), #620 | | 2026-10-11 | Mail-merge recipients from Excel (.xlsx/.xlsm) and OpenDocument (.ods) spreadsheets, with Select Table to pick the sheet (#334) | | 2026-10-11 | Insert › Icons (#309): a library of 61 original icons drawn in code, a searchable picker and Icon Color | | 2026-10-11 | Home › Font › Enclose Characters (#297): circle, square, triangle or diamond around a character, saved as Word's `EQ \o\ac` field | @@ -239,6 +240,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 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 | +| 2026-10-11 | trivial | Quick Access Toolbar KeyTips and persisted enable/disable setting (#620) | | 2026-10-11 | trivial | Progress log: mail-merge recipients from .xlsx/.xlsm/.ods (#334) | | 2026-10-11 | trivial | Insert › Icons landed (#309): catalog 402/434 → 403/434 (92.9%) | | 2026-10-11 | trivial | Enclose Characters (#297): catalog 403/434 (92.9%) | diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index f3ad653e4..41348a82b 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -27,19 +27,36 @@ fn qat_button(ui: &mut Ui, app: &mut WordApp, icon: &str, tip: &str, id: &str, e const QAT_MENU: [&str; 10] = ["file.new", "file.open", "file.save", "file.print", "edit.undo", "edit.redo", "edit.repeat", "review.spelling", "view.readMode", "insert.table"]; +/// Match the toolbar's enabled state for keyboard activation too. +pub(crate) fn qat_enabled(app: &WordApp, id: &str) -> bool { + match id { + "edit.undo" => app.session.can_undo(), + "edit.redo" => app.session.can_redo(), + "file.save" => true, + _ => crate::widgets::enabled(app, id), + } +} + /// The Quick Access Toolbar (#379): the user's commands (Save, Undo, Redo until customized), each /// with a right-click menu, then the menu button that customizes it (#376). fn quick_access(ui: &mut Ui, app: &mut WordApp) { let ids: Vec = app.session.ribbon.qat().into_iter().map(str::to_string).collect(); - for id in &ids { + for (index, id) in ids.iter().enumerate() { let id = id.as_str(); - let (tip, enabled) = match id { - "edit.undo" => (crate::i18n::prefixed("Undo", app.session.undo_label().unwrap_or("")), app.session.can_undo()), - "edit.redo" => (tl!("Redo").to_string(), app.session.can_redo()), - "file.save" => (tl!("Save").to_string(), true), - _ => (app.session.registry.get(id).map(|s| tl!(s.label).to_string()).unwrap_or_default(), crate::widgets::enabled(app, id)), + let tip = match id { + "edit.undo" => crate::i18n::prefixed("Undo", app.session.undo_label().unwrap_or("")), + "edit.redo" => tl!("Redo").to_string(), + "file.save" => tl!("Save").to_string(), + _ => app.session.registry.get(id).map(|s| tl!(s.label).to_string()).unwrap_or_default(), }; + let enabled = qat_enabled(app, id); let resp = qat_button(ui, app, crate::widgets::command_icon(id), &tip, id, enabled); + if app.ui.keytips == crate::keytips::Phase::Tabs && ui.is_rect_visible(resp.rect) { + let label = crate::keytips::qat_label(index, ids.len()); + if label.starts_with(&app.ui.keytip_prefix) { + app.keytip_rects.push((resp.rect, label)); + } + } crate::widgets::command_menu(&resp, app, id); } let t = Tokens::get(ui.ctx()); diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 3569ce21d..fdcdf9c10 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2053,3 +2053,4 @@ Hindi Hindské Context Podle kontextu System Systémové + Show KeyTips (Alt or F10) Zobrazit klávesové tipy (Alt nebo F10) diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index e2b62dca5..5fb5a986a 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2047,3 +2047,4 @@ Hindi Hindi Context Kontext System System + Show KeyTips (Alt or F10) Zugriffstasten anzeigen (Alt oder F10) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index e6148430f..1efb41567 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1731,6 +1731,7 @@ Middle En medio Click the empty text box the text should continue in (Esc cancels) Haga clic en el cuadro de texto vacío donde debe continuar el texto (Esc cancela) Not linked: click a text box Sin vincular: haga clic en un cuadro de texto + Show KeyTips (Alt or F10) Mostrar sugerencias de teclas (Alt o F10) Location Ubicación Endnotes Notas finales Below text Debajo del texto diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 25850b5d9..abc4ea1e7 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -1671,6 +1671,7 @@ Rotate all text 270° Pööra kogu teksti 270° Rotate all text 90° Pööra kogu teksti 90° Stacked Virnas + Show KeyTips (Alt or F10) Näita klahvivihjeid (Alt või F10) Accessibility Pane Ligipääsetavuse paan Alt Text Pane Asendusteksti paan Alt\nText Asendus-\ntekst diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 661f6494b..b40ba3507 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2432,3 +2432,4 @@ preview Title Esempio Hindi Hindi Context Contesto System Sistema + Show KeyTips (Alt or F10) Mostra i suggerimenti tasti (Alt o F10) diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index bc0f918fe..4aade67cc 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1691,6 +1691,7 @@ Middle 中央 Click the empty text box the text should continue in (Esc cancels) テキストの続きを入れる空のテキスト ボックスをクリックしてください (Esc でキャンセル) Not linked: click a text box リンクされていません: テキスト ボックスをクリックしてください + Show KeyTips (Alt or F10) キー操作のヒントを表示(Alt または F10) Location 場所 Endnotes 文末脚注 Below text 本文の直後 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index ae3e54713..7921ea282 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2089,3 +2089,4 @@ TOC {n} Spis treści {n} List Paragraph Akapit z listą Models Modele + Show KeyTips (Alt or F10) Pokaż etykietki klawiszy (Alt lub F10) diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index fdf4dc3e5..08818c7d1 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1703,6 +1703,7 @@ Middle Meio Click the empty text box the text should continue in (Esc cancels) Clique na caixa de texto vazia em que o texto deve continuar (Esc cancela) Not linked: click a text box Não vinculado: clique em uma caixa de texto + Show KeyTips (Alt or F10) Mostrar dicas de teclas (Alt ou F10) Location Local Endnotes Notas de fim Below text Abaixo do texto diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index cdf839295..09cde4915 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2136,6 +2136,7 @@ preview Title Пример Middle По центру Click the empty text box the text should continue in (Esc cancels) Щёлкните пустую надпись, в которой должен продолжиться текст (Esc — отмена) Not linked: click a text box Не связано: щёлкните надпись + Show KeyTips (Alt or F10) Показывать подсказки клавиш (Alt или F10) Location Расположение Endnotes Концевые сноски Below text Под текстом diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 386a00fdb..5cfb62972 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -1701,6 +1701,7 @@ Middle Sredina Click the empty text box the text should continue in (Esc cancels) Kliknite prazno polje za tekst u kom tekst treba da se nastavi (Esc otkazuje) Not linked: click a text box Nije povezano: kliknite polje za tekst + Show KeyTips (Alt or F10) Prikaži savete za tastere (Alt ili F10) Location Lokacija Endnotes Endnote Below text Ispod teksta diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 458f39877..b6586b557 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -1705,6 +1705,7 @@ Middle Средина Click the empty text box the text should continue in (Esc cancels) Кликните празно поље за текст у ком текст треба да се настави (Esc отказује) Not linked: click a text box Није повезано: кликните поље за текст + Show KeyTips (Alt or F10) Прикажи савете за тастере (Alt или F10) Location Локација Endnotes Ендноте Below text Испод текста diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index bfef58b8e..ff171b5f6 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1785,6 +1785,7 @@ Middle Orta Click the empty text box the text should continue in (Esc cancels) Metnin devam edeceği boş metin kutusuna tıklayın (Esc iptal eder) Not linked: click a text box Bağlı değil: bir metin kutusuna tıklayın + Show KeyTips (Alt or F10) Tuş ipuçlarını göster (Alt veya F10) Location Konum Endnotes Son notlar Below text Metnin altında diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index ddd6b55fe..4166547fc 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1703,6 +1703,7 @@ Middle Посередині Click the empty text box the text should continue in (Esc cancels) Клацніть порожнє текстове поле, у якому має продовжитися текст (Esc — скасувати) Not linked: click a text box Не зв'язано: клацніть текстове поле + Show KeyTips (Alt or F10) Показувати підказки клавіш (Alt або F10) Location Розташування Endnotes Кінцеві виноски Below text Під текстом diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index e52228b7d..894eb34cb 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1703,6 +1703,7 @@ Middle 中部 Click the empty text box the text should continue in (Esc cancels) 单击要接续文字的空文本框(按 Esc 取消) Not linked: click a text box 未链接:请单击文本框 + Show KeyTips (Alt or F10) 显示快捷键提示(Alt 或 F10) Location 位置 Endnotes 尾注 Below text 文字下方 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 89bbe5a33..bbfae71b0 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1692,6 +1692,7 @@ Middle 中間 Click the empty text box the text should continue in (Esc cancels) 按一下要接續文字的空白文字方塊(按 Esc 取消) Not linked: click a text box 未連結:請按一下文字方塊 + Show KeyTips (Alt or F10) 顯示按鍵提示(Alt 或 F10) Location 位置 Endnotes 章節附註 Below text 文字下方 diff --git a/crates/ui-egui/src/keytips.rs b/crates/ui-egui/src/keytips.rs index 9f3e830c5..6f1b7cb2e 100644 --- a/crates/ui-egui/src/keytips.rs +++ b/crates/ui-egui/src/keytips.rs @@ -1,5 +1,6 @@ //! Word-style keytips: Alt (or F10) shows letter badges over ribbon tabs, then over a tab's -//! controls; the badge's command runs on press. Typing never reaches the document while active. +//! controls; numbered badges also reach the customized Quick Access Toolbar. Typing never +//! reaches the document while active. use egui::{Align2, CornerRadius, Rect, Stroke, Ui, Vec2}; @@ -465,17 +466,20 @@ pub fn controls_public(tab: &str) -> Vec { .collect() } -/// A keytip badge: one letter in a small rounded square, high contrast in both themes. +/// A keytip badge: letters or toolbar numbers, high contrast in both themes. pub fn badge(ui: &Ui, rect: Rect, letter: &str) { - if letter.is_empty() || letter.chars().count() > 1 { + let count = letter.chars().count(); + if count == 0 || count > 4 { return; } let t = Tokens::get(ui.ctx()); - let r = Rect::from_min_size(rect.min + Vec2::new(2.0, 1.0), Vec2::splat(15.0)); + let font = semibold(if count > 3 { 8.5 } else { 10.0 }); + let width = ui.ctx().fonts_mut(|f| f.layout_no_wrap(letter.to_string(), font.clone(), t.on_accent).size().x); + let r = Rect::from_min_size(rect.min + Vec2::new(2.0, 1.0), Vec2::new((width + 4.0).max(15.0), 15.0)); let p = ui.painter(); p.rect_filled(r, CornerRadius::same(3), t.accent); p.rect_stroke(r, CornerRadius::same(3), Stroke::new(1.0, t.menu), egui::StrokeKind::Inside); - p.text(r.center(), Align2::CENTER_CENTER, letter, semibold(10.0), t.on_accent); + p.text(r.center(), Align2::CENTER_CENTER, letter, font, t.on_accent); } /// Record a tab badge over a tab's real rect, called as each tab is laid out. @@ -514,10 +518,15 @@ pub fn handle_key(app: &mut WordApp, key: egui::Key) -> bool { } k => { let Some(name) = keys::key_name(k).map(|n| n.to_ascii_uppercase()) else { - // A key with no keytip (digits, punctuation, arrows) cancels, as in Word. + // A key with no keytip cancels the mode. app.ui.keytips = Phase::Off; + app.ui.keytip_prefix.clear(); return true; }; + if app.ui.keytips == Phase::Tabs && (name.chars().all(|c| c.is_ascii_digit()) || !app.ui.keytip_prefix.is_empty()) { + qat_key(app, &name); + return true; + } match app.ui.keytips { Phase::Tabs => { let mut hit: Option<&'static str> = None; @@ -555,14 +564,51 @@ pub fn handle_key(app: &mut WordApp, key: egui::Key) -> bool { } } } + app.ui.keytip_prefix.clear(); return true; } false } +/// The first nine buttons use one digit. Further buttons use a leading zero and equally wide +/// numbers, so no complete badge is the prefix of another, even for the maximum-size toolbar. +pub(crate) fn qat_label(index: usize, count: usize) -> String { + if index < 9 { + (index + 1).to_string() + } else { + let width = count.saturating_sub(9).to_string().len(); + format!("0{:0width$}", index.saturating_sub(8)) + } +} + +fn qat_key(app: &mut WordApp, name: &str) { + app.ui.keytip_prefix.push_str(name); + let ids = app.session.ribbon.qat(); + let mut hit = None; + let mut pending = false; + for (index, id) in ids.iter().enumerate() { + let label = qat_label(index, ids.len()); + if label == app.ui.keytip_prefix { + hit = Some((*id).to_string()); + break; + } + pending |= label.starts_with(&app.ui.keytip_prefix); + } + if hit.is_some() || !pending { + app.ui.keytips = Phase::Off; + app.ui.keytip_prefix.clear(); + if let Some(id) = hit + && crate::chrome::qat_enabled(app, &id) + { + let _ = app.run(&id, serde_json::json!({})); + } + } +} + /// Alt (or F10) turns keytips on: the first press shows the tab badges. pub fn activate(app: &mut WordApp) { - if app.ui.keytips == Phase::Off { + if app.ui.keytips_enabled && app.ui.keytips == Phase::Off { + app.ui.keytip_prefix.clear(); app.ui.keytips = Phase::Tabs; } } @@ -573,7 +619,24 @@ use crate::keys; /// (not the canvas) has focus; the canvas lets Alt open keytips and `canvas_events` swallows /// typing while they show. pub fn suppressed(app: &WordApp, ctx: &egui::Context) -> bool { - app.ui.backstage || app.dialog.is_some() || (ctx.egui_wants_keyboard_input() && !app.canvas.focused) + !app.ui.keytips_enabled || app.ui.backstage || app.dialog.is_some() || (ctx.egui_wants_keyboard_input() && !app.canvas.focused) +} + +/// Native backends report Alt through both key and modifier events, in either order. Track +/// the transition once; the key-up event can still carry the old `alt: true` modifier state. +fn alt_transition(app: &mut WordApp, pressed: bool, modifiers: egui::Modifiers) -> bool { + let other_modifier = modifiers.ctrl || modifiers.shift || modifiers.command || modifiers.mac_cmd; + if pressed { + if !app.ui.alt_pressed { + app.ui.alt_chord_used = false; + } + app.ui.alt_pressed = true; + app.ui.alt_chord_used |= other_modifier; + false + } else { + let released = std::mem::take(&mut app.ui.alt_pressed); + released && !std::mem::take(&mut app.ui.alt_chord_used) && !other_modifier + } } /// Read keytip input every frame, before layout, so the frame paints the new state. Suppression @@ -584,8 +647,16 @@ pub fn suppressed(app: &WordApp, ctx: &egui::Context) -> bool { /// uses. On macOS they never arm from Alt at all, because Option is how you type accented /// characters there and hijacking it would swallow the next letter. pub fn logic(app: &mut WordApp, ctx: &egui::Context) { - if suppressed(app, ctx) { + if suppressed(app, ctx) || !ctx.input(|i| i.focused) { app.ui.keytips = Phase::Off; + app.ui.keytip_prefix.clear(); + app.ui.alt_pressed = false; + app.ui.alt_chord_used = false; + app.keytip_rects.clear(); + ctx.data_mut(|d| { + d.insert_temp(phase_id(), Phase::Off); + d.remove::>(rects_id()); + }); return; } // On macOS Option+key types accents, so Alt must never arm keytips there. @@ -596,22 +667,9 @@ pub fn logic(app: &mut WordApp, ctx: &egui::Context) { let mut f10 = false; for e in &events { match e { - egui::Event::Key { key, pressed, repeat, modifiers, .. } => { + egui::Event::Key { key, pressed, modifiers, .. } => { if is_alt(*key) { - if *pressed { - // A new Alt press starts a fresh chord (auto-repeat keeps the current one). - if !*repeat { - app.ui.alt_chord_used = false; - } - if modifiers.any() { - // Alt combined with another modifier is a shortcut, not a keytip chord. - other_key = true; - } - } else if !modifiers.any() { - // Alt+click and Alt+drag (column selection), or Alt+key held across frames, - // end with a bare Alt release too; only an untouched Alt tap counts. - alt_released = !std::mem::take(&mut app.ui.alt_chord_used); - } + alt_released |= alt_transition(app, *pressed, *modifiers); } else if *pressed { other_key = true; app.ui.alt_chord_used = true; @@ -620,6 +678,9 @@ pub fn logic(app: &mut WordApp, ctx: &egui::Context) { } } } + egui::Event::ModifiersChanged(modifiers) => { + alt_released |= alt_transition(app, modifiers.alt, *modifiers); + } egui::Event::PointerButton { pressed: true, .. } => { other_key = true; app.ui.alt_chord_used = true; @@ -628,20 +689,37 @@ pub fn logic(app: &mut WordApp, ctx: &egui::Context) { } } // Route letter/navigation keys to the keytips while they are showing. + let mut consumed = Vec::new(); for e in &events { if let egui::Event::Key { key, pressed: true, modifiers, .. } = e && app.ui.keytips != Phase::Off && !is_alt(*key) && (*key != egui::Key::F10 || modifiers.any()) + && handle_key(app, *key) { - handle_key(app, *key); + consumed.push(*key); } } + // A badge can turn the mode off before canvas_events runs. Consume both the key and its + // text event now, or the digit/letter would also be typed into the document (#620). + if !consumed.is_empty() { + ctx.input_mut(|i| { + i.events.retain(|e| match e { + egui::Event::Key { key, pressed: true, .. } => !consumed.contains(key), + egui::Event::Text(_) => false, + _ => true, + }); + }); + } if ALT_ARMS && alt_released && !other_key { app.ui.keytips = if app.ui.keytips == Phase::Tabs { Phase::Off } else { Phase::Tabs }; + app.ui.keytip_prefix.clear(); } else if f10 { activate(app); } + if app.ui.keytips == Phase::Off { + app.ui.keytip_prefix.clear(); + } // Publish the state the ribbon widgets read to place their badges, and clear last frame's. ctx.data_mut(|d| { d.insert_temp(phase_id(), app.ui.keytips); @@ -679,6 +757,88 @@ mod tests { WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default()) } + fn key_event(key: egui::Key, pressed: bool, modifiers: egui::Modifiers) -> egui::Event { + egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers } + } + + fn input_frame(ctx: &egui::Context, app: &mut WordApp, events: Vec) { + ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| logic(app, ui.ctx())).drop_without_applying_deltas(); + } + + #[test] + fn native_alt_taps_toggle_once_in_either_event_order() { + let alt = egui::Modifiers { alt: true, ..Default::default() }; + // Key-up can precede ModifiersChanged and still carry alt=true. Some backends send + // only modifier events; a fast tap can also fit entirely in a single frame. + for key in [None, Some(egui::Key::AltLeft), Some(egui::Key::AltRight)] { + for modifiers_first in [false, true] { + for same_frame in [false, true] { + let ctx = egui::Context::default(); + let mut a = app(); + let mut press = vec![egui::Event::ModifiersChanged(alt)]; + let mut release = vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE)]; + if let Some(key) = key { + if modifiers_first { + press.push(key_event(key, true, alt)); + release.push(key_event(key, false, egui::Modifiers::NONE)); + } else { + press.insert(0, key_event(key, true, egui::Modifiers::NONE)); + release.insert(0, key_event(key, false, alt)); + } + } + for expected in [if cfg!(target_os = "macos") { Phase::Off } else { Phase::Tabs }, Phase::Off] { + if same_frame { + input_frame(&ctx, &mut a, press.iter().chain(&release).cloned().collect()); + } else { + input_frame(&ctx, &mut a, press.clone()); + input_frame(&ctx, &mut a, release.clone()); + } + assert_eq!(a.ui.keytips, expected, "key={key:?}, modifiers_first={modifiers_first}, same_frame={same_frame}"); + assert!(!a.ui.alt_pressed); + } + } + } + } + } + + #[test] + fn native_alt_chords_and_interrupted_taps_do_not_activate() { + let ctx = egui::Context::default(); + let mut a = app(); + let alt = egui::Modifiers { alt: true, ..Default::default() }; + for modifier in [egui::Modifiers::CTRL, egui::Modifiers::SHIFT, egui::Modifiers::MAC_CMD] { + input_frame(&ctx, &mut a, vec![egui::Event::ModifiersChanged(modifier | alt)]); + // Releasing the other modifier first must not turn this into a bare Alt tap. + input_frame(&ctx, &mut a, vec![egui::Event::ModifiersChanged(alt)]); + input_frame(&ctx, &mut a, vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE)]); + assert_eq!(a.ui.keytips, Phase::Off); + } + for event in [ + key_event(egui::Key::ArrowDown, true, alt), + egui::Event::PointerButton { pos: egui::pos2(400.0, 300.0), button: egui::PointerButton::Primary, pressed: true, modifiers: alt }, + ] { + input_frame(&ctx, &mut a, vec![egui::Event::ModifiersChanged(alt)]); + input_frame(&ctx, &mut a, vec![event]); + // A repeated key-down must not forget the key/mouse chord. + input_frame(&ctx, &mut a, vec![key_event(egui::Key::AltLeft, true, alt)]); + input_frame(&ctx, &mut a, vec![key_event(egui::Key::AltLeft, false, alt), egui::Event::ModifiersChanged(egui::Modifiers::NONE)]); + assert_eq!(a.ui.keytips, Phase::Off); + } + input_frame(&ctx, &mut a, vec![egui::Event::ModifiersChanged(alt)]); + ctx.run_ui(egui::RawInput { focused: false, events: vec![egui::Event::WindowFocused(false)], ..Default::default() }, |ui| { + logic(&mut a, ui.ctx()) + }) + .drop_without_applying_deltas(); + input_frame(&ctx, &mut a, vec![key_event(egui::Key::AltLeft, false, alt), egui::Event::ModifiersChanged(egui::Modifiers::NONE)]); + assert_eq!(a.ui.keytips, Phase::Off, "returning after Alt+Tab must not arm keytips"); + input_frame(&ctx, &mut a, vec![egui::Event::ModifiersChanged(alt)]); + a.ui.backstage = true; + input_frame(&ctx, &mut a, vec![]); + a.ui.backstage = false; + input_frame(&ctx, &mut a, vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE)]); + assert_eq!(a.ui.keytips, Phase::Off, "suppression cancels the pending Alt tap"); + } + #[test] fn tab_letters_match_word() { assert_eq!(tab_letter("Home"), "H"); @@ -811,4 +971,133 @@ mod tests { assert!(handle_key(&mut a, egui::Key::F)); assert_eq!(a.ui.keytips, Phase::Off); } + + #[test] + fn qat_keytips_follow_custom_order_and_skip_disabled_commands() { + let mut a = app(); + a.run("tools.customizeRibbon", serde_json::json!({"qatAdd": "format.bold"})).unwrap(); + a.run("tools.customizeRibbon", serde_json::json!({"qatAdd": "format.italic"})).unwrap(); + a.run("tools.customizeRibbon", serde_json::json!({"qatMove": {"command": "format.bold", "by": -4}})).unwrap(); + let bold_index = a.session.ribbon.qat().iter().position(|id| *id == "format.bold").unwrap(); + assert_eq!(bold_index, 0); + activate(&mut a); + handle_key(&mut a, egui::Key::Num1); + assert_eq!(a.session.run("format.state", &serde_json::json!({})).unwrap()["bold"], true); + assert_eq!(a.ui.keytips, Phase::Off); + + let mut b = app(); + let before = b.session.doc.clone(); + activate(&mut b); + handle_key(&mut b, egui::Key::Num2); // Undo is disabled in an untouched document. + assert_eq!(b.session.doc, before); + assert!(!b.session.can_redo()); + assert_eq!(b.ui.keytips, Phase::Off); + } + + #[test] + fn long_qat_badges_wait_for_the_complete_number_and_escape_cancels() { + let mut a = app(); + let mut commands: Vec = + a.session.registry.all().iter().filter(|s| s.id != "format.bold").take(19).map(|s| s.id.to_string()).collect(); + commands.insert(9, "format.bold".into()); + a.session.ribbon.qat = Some(commands); + activate(&mut a); + handle_key(&mut a, egui::Key::Num0); + handle_key(&mut a, egui::Key::Num0); + assert_eq!(a.ui.keytips, Phase::Tabs); + assert_eq!(a.session.run("format.state", &serde_json::json!({})).unwrap()["bold"], false); + handle_key(&mut a, egui::Key::Num1); // 001: first button after the first nine. + assert_eq!(a.session.run("format.state", &serde_json::json!({})).unwrap()["bold"], true); + assert_eq!(a.ui.keytips, Phase::Off); + activate(&mut a); + handle_key(&mut a, egui::Key::Num0); + handle_key(&mut a, egui::Key::Escape); + assert!(a.ui.keytip_prefix.is_empty()); + assert_eq!(a.ui.keytips, Phase::Off); + + for count in [9, 10, 18, 19, 100, 200] { + let labels: Vec<_> = (0..count).map(|i| qat_label(i, count)).collect(); + for (i, label) in labels.iter().enumerate() { + assert!( + !labels.iter().enumerate().any(|(j, other)| i != j && other.starts_with(label)), + "ambiguous label {label} in {count} buttons" + ); + } + } + } + + #[test] + fn qat_digit_runs_once_and_never_types_into_the_document() { + let mut a = app(); + a.session.ribbon.qat = Some(vec!["text.insert".into(), "format.bold".into()]); + a.canvas.focused = true; + a.canvas.want_focus = true; + 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); + }, + a, + ); + h.run_steps(4); + let key = |key| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + h.input_mut().events.push(key(egui::Key::F10)); + h.step(); + assert_eq!(h.state().ui.keytips, Phase::Tabs); + h.input_mut().events.extend([key(egui::Key::Num2), egui::Event::Text("2".into())]); + h.step(); + assert_eq!(h.state().ui.keytips, Phase::Off); + assert_eq!(h.state_mut().session.run("format.state", &serde_json::json!({})).unwrap()["bold"], true); + assert!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body).is_empty()); + // Normal typing resumes immediately after the command. + h.input_mut().events.push(egui::Event::Text("hello".into())); + h.step(); + assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body).trim(), "hello"); + if !cfg!(target_os = "macos") { + let alt = egui::Modifiers { alt: true, ..Default::default() }; + h.input_mut().events.extend([key_event(egui::Key::AltLeft, true, egui::Modifiers::NONE), egui::Event::ModifiersChanged(alt)]); + h.step(); + h.input_mut().events.extend([key_event(egui::Key::AltLeft, false, alt), egui::Event::ModifiersChanged(egui::Modifiers::NONE)]); + h.step(); + assert_eq!(h.state().ui.keytips, Phase::Tabs); + h.input_mut().events.extend([key(egui::Key::Num2), egui::Event::Text("2".into())]); + h.step(); + assert_eq!(h.state_mut().session.run("format.state", &serde_json::json!({})).unwrap()["bold"], false); + assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body).trim(), "hello"); + } + } + + #[test] + fn keytips_option_persists_and_disabled_keytips_leave_f10_alone() { + let mut a = app(); + activate(&mut a); + a.run("ui.keytips", serde_json::json!({"value": false})).unwrap(); + assert_eq!(a.ui.keytips, Phase::Off); + assert!(a.run("ui.keytips", serde_json::json!({"value": "false"})).is_err()); + let saved = serde_json::to_vec(&a.prefs()).unwrap(); + let mut b = app(); + b.apply_prefs(serde_json::from_slice(&saved).unwrap()); + activate(&mut b); + assert_eq!(b.ui.keytips, Phase::Off); + assert_eq!(b.run("ui.keytips", serde_json::json!({})).unwrap()["enabled"], false); + let ctx = egui::Context::default(); + let mut output = ctx.run_ui( + egui::RawInput { + events: vec![egui::Event::Key { + key: egui::Key::F10, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }], + ..Default::default() + }, + |ui| logic(&mut b, ui.ctx()), + ); + output.textures_delta.clear(); + assert_eq!(b.ui.keytips, Phase::Off); + b.apply_prefs(serde_json::from_str("{}").unwrap()); + assert!(b.ui.keytips_enabled, "older preferences retain enabled KeyTips"); + } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c0e7d809c..2f3672ab8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -185,9 +185,17 @@ pub struct UiState { /// Read Aloud speed (1 = normal) and whether it skips citations and bibliographies. pub read_aloud_rate: f32, pub read_aloud_skip_citations: bool, + /// Show ribbon and Quick Access Toolbar KeyTips on Alt/F10 (#620). + pub keytips_enabled: bool, + /// Digits entered for a multi-key Quick Access Toolbar badge; never persisted. + #[serde(skip)] + pub keytip_prefix: String, /// Keytips (Alt) state; not persisted, it resets each session. #[serde(skip)] pub keytips: crate::keytips::Phase, + /// Alt is held according to key or modifier events; never persisted. + #[serde(skip)] + pub alt_pressed: bool, /// Since the last Alt press, another key or a mouse button was pressed: the Alt release /// that follows ends a chord (Alt+click, Alt+drag column selection), not a keytip tap. #[serde(skip)] @@ -256,7 +264,10 @@ impl Default for UiState { editing: wordcraft_engine::Prefs::default(), read_aloud_rate: 1.0, read_aloud_skip_citations: true, + keytips_enabled: true, + keytip_prefix: String::new(), keytips: crate::keytips::Phase::Off, + alt_pressed: false, alt_chord_used: false, dark_page: false, caret_animation: true, @@ -923,6 +934,17 @@ impl WordApp { self.ui.ribbon_collapsed = !self.ui.ribbon_collapsed; json!({"collapsed": self.ui.ribbon_collapsed}) } + "ui.keytips" => { + if let Some(v) = p.get("value") { + let Some(enabled) = v.as_bool() else { return Some(Err("`value` must be a boolean".into())) }; + self.ui.keytips_enabled = enabled; + if !enabled { + self.ui.keytips = keytips::Phase::Off; + self.ui.keytip_prefix.clear(); + } + } + json!({"enabled": self.ui.keytips_enabled}) + } "ui.dark" => { // A manual Light/Dark switch (toggles what is shown when no value is given). let dark = p.get("value").and_then(Value::as_bool).unwrap_or(!self.ui_is_dark()); diff --git a/crates/ui-egui/src/options.rs b/crates/ui-egui/src/options.rs index ab27e63db..513586308 100644 --- a/crates/ui-egui/src/options.rs +++ b/crates/ui-egui/src/options.rs @@ -320,6 +320,10 @@ fn advanced(app: &mut WordApp, ui: &mut Ui) { }); ui.end_row(); }); + let mut keytips = app.ui.keytips_enabled; + if ui.checkbox(&mut keytips, tl!("Show KeyTips (Alt or F10)")).changed() { + let _ = app.run("ui.keytips", json!({"value": keytips})); + } ui.add_space(8.0); section(ui, "Animation"); let mut caret = app.ui.caret_animation; diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index d812ade86..a57bd7bb2 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -132,7 +132,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { let shown = if custom { tab } else { tl!(tab) }; let w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(shown.to_string(), medium(12.5), t.text).size().x) + 18.0; let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click()); - if app.ui.keytips == crate::keytips::Phase::Tabs { + if app.ui.keytips == crate::keytips::Phase::Tabs && app.ui.keytip_prefix.is_empty() { crate::keytips::record_tab(r, tab, &mut app.keytip_rects); } let active = app.ui.tab == tab && !app.ui.backstage; diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 8e3a9e1f5..4810911f3 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 (Layout › Paragraph fields fit and align, #654; previously trivial: ribbon labels clear the group titles, #653; previously trivial: menu buttons grey out, #658; previously trivial: one drop-down marker, #659; previously minor: status bar language follows the caret and sets the proofing language; Review › Language is a menu; 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 (Quick Access Toolbar KeyTips, native Alt handling and a saved KeyTips switch, #620; previously trivial: File › Options panes, #485; 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 @@ -11,7 +11,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, Outlining (Outline view, #493); 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 | +| Keytips (Alt / F10) | Ribbon badges (#43) and numbered Quick Access Toolbar badges in the user's button order (#620); File › Options › Advanced › Display switch saved per user (`ui.keytips`); Alt leaves macOS Option typing alone | ✅ | 85% | 1–2 | | Mini-toolbar on selection | ✅ (#43, two rows #535) | ✅ | 80% | 1–2 | | Mini-toolbar on selection | ✅ (#43) | ✅ | 80% | 1–2 | | Colour drop-downs: theme/standard colours, Recent Colors, More Colors… → Colors dialog (Standard honeycomb, Custom hue/saturation field with lightness, RGB/HSL and hex) | ✅ Font Color (ribbon, mini toolbar, Font dialog), Shading, Page Color, Shape Fill/Outline/Glow, pen colours, dialog colour fields (#606); no Gradient or eyedropper | ✅ | 90% | 1 | @@ -43,6 +43,8 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Alt activation handles native key/modifier events in either order, including key-up with the old Alt flag; taps toggle once, chords and focus loss do not activate KeyTips | +| 2026-10-11 | trivial | Quick Access Toolbar KeyTips (#620): 1–9, zero-prefixed numbers for longer toolbars, disabled commands stay disabled, Escape cancels, badge keystrokes never type into the document; persisted File › Options switch | | 2026-10-11 | trivial | Ribbon buttons that run a command (alignment, Bold, styles…) hand keyboard focus back to the document, so typing continues without another click; they also report their name and enabled state to screen readers (#686) | | 2026-10-11 | trivial | Tables on the page: column and row borders and the resize handle drag, with a dashed guide and one undo step per drag (#217) | | 2026-10-11 | trivial | Animation effects › Use system setting also reads macOS (Reduce motion) and the Linux/BSD desktops (settings portal: the cross-desktop reduced-motion preference, GNOME's switch, KDE's animation speed; the GTK and KDE settings files where no portal answers), and follows a change without a restart | @@ -101,4 +103,9 @@ 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) | + +Quick Access Toolbar labels are generated from the current button order. For more than nine +buttons, the remaining labels start with zero and use the same number of digits (for example +`01`, `02` on a ten-button toolbar; `001` through `011` on a twenty-button toolbar). This keeps +labels unambiguous. Those extra-number assignments have not been compared with Word. | 2026-10-11 | trivial | Draw: Lasso, Add Pen gallery, Ink to Shape and Ink Replay (#395) | From 241877d666401ab3972a43a6573e7f9df7ea741f Mon Sep 17 00:00:00 2001 From: ripdog Date: Mon, 12 Oct 2026 23:33:19 +1300 Subject: [PATCH 31/88] Add paginated Reading Mode with smooth column navigation (#740) * M8: reflow reading mode into viewport-sized pages Co-Authored-By: ChatGPT (GPT-6) * M8: navigate reading columns with wheel, keys and page controls Co-Authored-By: ChatGPT (GPT-6) * M8: keep Outline in current view and add transient reading layout controls Co-Authored-By: ChatGPT (GPT-6) * M8: replace reading ribbon and align navigation to columns Co-Authored-By: ChatGPT (GPT-6) * M8: animate reading column turns and retain visible text through resize Co-Authored-By: ChatGPT (GPT-6) * M8: name reading navigation Outline and complete its translations Co-Authored-By: ChatGPT (GPT-6) * M8: consolidate Reading Mode progress notes Co-Authored-By: ChatGPT (GPT-6) * M8: keep reading clicks from scrolling the selected column Co-Authored-By: ChatGPT (GPT-6) * M8: reveal reading caret only when outside the viewport Co-Authored-By: ChatGPT (GPT-6) * M8: hide floating formatting toolbar in Reading Mode Co-Authored-By: ChatGPT (GPT-6) --------- Co-authored-by: ChatGPT (GPT-6) Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 4 +- crates/engine/src/cmd/view.rs | 50 ++++ crates/engine/src/session.rs | 71 +++++- crates/engine/src/tests.rs | 64 +++++ crates/layout/src/lib.rs | 42 +++- crates/layout/src/para.rs | 4 + crates/layout/src/table.rs | 1 + crates/layout/src/tests.rs | 24 ++ crates/ui-egui/src/canvas.rs | 362 ++++++++++++++++++++++++++-- crates/ui-egui/src/chrome.rs | 26 +- crates/ui-egui/src/i18n/cs.tsv | 4 + crates/ui-egui/src/i18n/de.tsv | 4 + crates/ui-egui/src/i18n/es.tsv | 4 + crates/ui-egui/src/i18n/et.tsv | 4 + crates/ui-egui/src/i18n/it.tsv | 4 + crates/ui-egui/src/i18n/ja.tsv | 4 + crates/ui-egui/src/i18n/pl.tsv | 4 + crates/ui-egui/src/i18n/pt-br.tsv | 4 + crates/ui-egui/src/i18n/ru.tsv | 4 + crates/ui-egui/src/i18n/sr-latn.tsv | 4 + crates/ui-egui/src/i18n/sr.tsv | 4 + crates/ui-egui/src/i18n/tr.tsv | 4 + crates/ui-egui/src/i18n/uk.tsv | 4 + crates/ui-egui/src/i18n/zh-hans.tsv | 4 + crates/ui-egui/src/i18n/zh-hant.tsv | 4 + crates/ui-egui/src/keys.rs | 26 ++ crates/ui-egui/src/lib.rs | 141 +++++++++++ crates/ui-egui/src/mini_toolbar.rs | 20 ++ crates/ui-egui/src/panes.rs | 4 + crates/ui-egui/src/ribbon.rs | 70 ++++++ docs/ui-parity.md | 4 +- 31 files changed, 921 insertions(+), 52 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d6f509e5f..f4eb8cb53 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 (Quick Access Toolbar KeyTips and a saved enable/disable option, #620; 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 (Reading Mode completed; 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 @@ -134,6 +134,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 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 | Reading Mode: existing View ribbon button now opens a read-only, viewport-column layout with animated horizontal paging, transient display controls, heading navigation (Navigation pane) and resize anchoring; document layout and export unchanged | | 2026-10-11 | DOCX open/save keeps inherited complex-script size, bold and italic independent of Latin formatting; explicit values are retained, and font commands still format both scripts. Covered by synthetic round trips and a local GUI save/reopen check. | | 2026-10-11 | Czech (`cs`) interface catalog: 2,021 entries (the full zh-hans key set), Czech locale detection and persisted language choice | | 2026-10-11 | Polish: interface (`pl`, the thirteenth language, every catalog key) and proofing — spelling against the installed Hunspell `pl_PL` dictionary (read at runtime, not bundled) through a new Hunspell-style affix engine, suggestions tuned for Polish, Polish grammar rules with Polish messages, TeX Polish hyphenation patterns (MIT option); proofing language per run (`w:lang`) for squiggles, F7, suggestions and hyphenation, languages without tools left unmarked; Review › Language menu and the status bar's language; a Polish interface writes new documents in Polish | @@ -214,6 +215,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Progress log: Reading Mode implementation | | 2026-10-11 | trivial | DOCX complex-script formatting inheritance fix and round-trip coverage | | 2026-10-11 | minor | Czech (`cs`) interface catalog added | | 2026-10-11 | trivial | Progress log: German interface (#574) | diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index b1b6fece1..824e4dbc2 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -27,6 +27,15 @@ 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(), + CommandSpec::new("view.readColumnWidth", "Reading Column Width", "Reading › Layout", read_column_width) + .params(r#"{"value": "auto" | "narrow" | "medium" | "wide" | "page"}"#) + .pure(), + CommandSpec::new("view.readTextSize", "Reading Text Size", "Reading › Layout", read_text_size) + .params(r#"{"value": percent (75–150)}"#) + .pure(), + CommandSpec::new("view.readLineSpacing", "Reading Line Spacing", "Reading › Layout", read_line_spacing) + .params(r#"{"value": "document" | 1.0 | 1.25 | 1.5 | 2.0}"#) + .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}"#) @@ -103,12 +112,53 @@ pub fn specs() -> Vec { } fn mode(s: &mut Session, m: ViewMode, read: bool) -> CmdResult { + if read && !s.view.read_mode { + // Reading controls are session-only, and reset on each entry to the view. + s.view.read_column_width = None; + s.view.read_zoom = 1.0; + s.view.read_line_spacing = None; + } s.view.mode = m; s.view.read_mode = read; s.relayout(); Ok(json!({"mode": m, "readMode": read})) } +fn read_column_width(s: &mut Session, v: &Value) -> CmdResult { + let width = match v.get("value").and_then(Value::as_str) { + Some("auto") => None, + Some("narrow") => Some(180.0), + Some("medium") => Some(240.0), + Some("wide") => Some(350.0), + Some("page") => Some(612.0), + _ => return Err(CmdError::Params("column width must be auto, narrow, medium, wide, or page".into())), + }; + s.view.read_column_width = width; + Ok(json!({"width": width})) +} + +fn read_text_size(s: &mut Session, v: &Value) -> CmdResult { + let Some(pct) = v.get("value").and_then(Value::as_f64) else { + return Err(CmdError::Params("reading text size must be a percentage".into())); + }; + if !pct.is_finite() || !(75.0..=150.0).contains(&pct) { + return Err(CmdError::Params("reading text size must be 75–150%".into())); + } + s.view.read_zoom = pct as f32 / 100.0; + Ok(json!({"percent": pct})) +} + +fn read_line_spacing(s: &mut Session, v: &Value) -> CmdResult { + let value = v.get("value"); + let spacing = match value { + Some(Value::String(s)) if s == "document" => None, + Some(Value::Number(n)) if matches!(n.as_f64(), Some(1.0 | 1.25 | 1.5 | 2.0)) => n.as_f64().map(|n| n as f32), + _ => return Err(CmdError::Params("reading line spacing must be document, 1, 1.25, 1.5, or 2".into())), + }; + s.view.read_line_spacing = spacing; + Ok(json!({"spacing": spacing})) +} + fn zoom(s: &mut Session, v: &Value) -> CmdResult { match v.get("value") { Some(Value::String(f)) if ["pageWidth", "onePage", "multiplePages", "textWidth"].contains(&f.as_str()) => { diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 3c01c43c9..101cc45ed 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -90,6 +90,19 @@ pub struct ViewState { /// Zoom to fit: "pageWidth", "onePage", "multiplePages", or empty. pub fit: String, pub web_width: f32, + /// Transient viewport metrics for reading pagination; never saved in a document or preferences. + #[serde(skip, default = "default_read_width")] + pub read_width: f32, + #[serde(skip, default = "default_read_height")] + pub read_height: f32, + /// Reader-only layout controls; they are neither document formatting nor saved UI prefs. + /// `None` chooses responsive column width and keeps the document's line spacing. + #[serde(skip)] + pub read_column_width: Option, + #[serde(skip, default = "default_read_zoom")] + pub read_zoom: f32, + #[serde(skip)] + pub read_line_spacing: Option, pub dark_mode: bool, pub show_markup: bool, pub track_changes_pane: bool, @@ -124,6 +137,18 @@ fn on() -> bool { true } +fn default_read_width() -> f32 { + 300.0 +} + +fn default_read_height() -> f32 { + 500.0 +} + +fn default_read_zoom() -> f32 { + 1.0 +} + impl Default for ViewState { fn default() -> Self { ViewState { @@ -146,6 +171,11 @@ impl Default for ViewState { multi_page: false, fit: String::new(), web_width: 800.0, + read_width: default_read_width(), + read_height: default_read_height(), + read_column_width: None, + read_zoom: default_read_zoom(), + read_line_spacing: None, dark_mode: false, show_markup: true, track_changes_pane: false, @@ -281,9 +311,21 @@ pub struct Session { /// mail merge, recover), never on an edit. document_id: u64, cache: LayoutCache, - /// (revision, web width, view, layout, proofing, outline, (deletions left out, body - /// deletions in balloons), numeral display). - layout: Option<(u64, f32, ViewMode, Arc, bool, Option, (bool, bool), NumeralMode)>, + /// (revision, width, read height, view, read line spacing, layout, proofing, outline, + /// (deletions left out, body deletions in balloons), numeral display). + #[allow(clippy::type_complexity)] + layout: Option<( + u64, + f32, + f32, + ViewMode, + Option, + Arc, + bool, + Option, + (bool, bool), + NumeralMode, + )>, /// Picture edits: edited media key → original media key (Reset Picture). pub originals: std::collections::HashMap, /// Last mutating command (Repeat). @@ -579,16 +621,21 @@ impl Session { /// The current layout (recomputed when the document or view changed). pub fn layout(&mut self) -> Arc { - let ww = self.view.web_width; + let mode = if self.view.read_mode { ViewMode::Read } else { self.view.mode }; + let width = if self.view.read_mode { self.view.read_width } else { self.view.web_width }; + let height = if self.view.read_mode { self.view.read_height } else { 0.0 }; + let spacing = if self.view.read_mode { self.view.read_line_spacing.map(f32::to_bits) } else { None }; // Deletions in balloons depend on the view (the Comments pane, Read Mode, side by side), so // a view change that moves them in or out of the text lays it out again. let hide_deleted = !self.view.show_markup || self.prefs.markup.hides_deletions(); let deleted = (hide_deleted, self.revisions_in_balloons()); - let outline = (self.view.mode == ViewMode::Outline).then(|| self.view.outline.view()); - if let Some((r, w, m, l, pf, o, hd, n)) = &self.layout + let outline = (mode == ViewMode::Outline).then(|| self.view.outline.view()); + if let Some((r, w, h, m, sp, l, pf, o, hd, n)) = &self.layout && *r == self.rev - && (*w == ww || self.view.mode == ViewMode::Print) - && *m == self.view.mode + && (*w == width || mode == ViewMode::Print) + && *h == height + && *m == mode + && *sp == spacing && *pf == self.view.proofing && *n == self.view.numeral && *hd == deleted @@ -597,8 +644,10 @@ impl Session { return l.clone(); } let opts = LayoutOptions { - view: self.view.mode, - web_width: ww, + view: mode, + web_width: width, + read_height: height, + read_line_spacing: spacing.map(f32::from_bits), show_hidden: self.view.marks || self.prefs.marks.hidden, hide_deleted, deletions_in_balloons: deleted.1, @@ -610,7 +659,7 @@ impl Session { proof: wordcraft_layout::ProofOptions { grammar: self.prefs.mark_grammar, spelling: self.prefs.spelling() }, }; let l = Arc::new(wordcraft_layout::layout(&self.doc, &mut self.cache, &opts)); - self.layout = Some((self.rev, ww, self.view.mode, l.clone(), self.view.proofing, outline, deleted, self.view.numeral)); + self.layout = Some((self.rev, width, height, mode, spacing, l.clone(), self.view.proofing, outline, deleted, self.view.numeral)); l } /// A layout for output (PDF, images, print): no proofing marks, print view. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f1f23489e..0cdf402d0 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2636,6 +2636,70 @@ fn page_and_table_gridlines_toggle_independently() { assert_eq!(state["tableGridlines"], false); } +#[test] +fn navigation_pane_opens_without_changing_view_mode() { + // Reading Mode's heading navigation is the Navigation pane; Outline is an editing view (#493). + let mut s = s(); + for command in ["view.printLayout", "view.webLayout", "view.readMode"] { + run(&mut s, command, json!({})); + let before = (s.view.mode, s.view.read_mode); + s.view.nav_pane = false; + run(&mut s, "view.navigationPane", json!({"value": true})); + assert!(s.view.nav_pane); + assert_eq!((s.view.mode, s.view.read_mode), before, "{command}"); + } +} + +#[test] +fn reading_mode_reflows_per_viewport_without_changing_document_or_export() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "A long paragraph for reading. ".repeat(200)})); + let original = s.doc.clone(); + let print = s.export_layout(); + let dirty = s.dirty; + run(&mut s, "view.readMode", json!({})); + assert!(s.view.read_mode); + s.view.read_width = 240.0; + s.view.read_height = 420.0; + let columns = s.layout(); + assert!(columns.pages.len() > print.pages.len()); + assert!(columns.pages.iter().all(|p| p.w == 240.0 && p.h == 420.0)); + s.view.read_height = 260.0; + assert!(s.layout().pages.len() > columns.pages.len(), "height is part of the layout cache key"); + s.view.read_width = 300.0; + assert_eq!(s.layout().pages[0].w, 300.0, "width is part of the layout cache key"); + let serialized = serde_json::to_value(&s.view).unwrap(); + assert!(serialized.get("readWidth").is_none() && serialized.get("readHeight").is_none()); + let restored: crate::ViewState = serde_json::from_value(serialized).unwrap(); + assert_eq!((restored.read_width, restored.read_height), (300.0, 500.0)); + run(&mut s, "view.readColumnWidth", json!({"value": "page"})); + run(&mut s, "view.readTextSize", json!({"value": 125})); + let default_spacing_pages = s.layout().pages.len(); + run(&mut s, "view.readLineSpacing", json!({"value": 2.0})); + assert_eq!(s.view.read_column_width, Some(612.0)); + assert_eq!(s.view.read_zoom, 1.25); + assert_eq!(s.view.read_line_spacing, Some(2.0)); + let spaced = s.layout(); + assert!(spaced.pages.len() > default_spacing_pages, "line spacing must invalidate cached pagination"); + assert!(s.run("view.readTextSize", &json!({"value": -10})).is_err()); + assert!(s.run("view.readLineSpacing", &json!({"value": 0.01})).is_err()); + assert!(s.run("view.readColumnWidth", &json!({"value": "giant"})).is_err()); + let serialized = serde_json::to_value(&s.view).unwrap(); + for key in ["readColumnWidth", "readZoom", "readLineSpacing"] { + assert!(serialized.get(key).is_none()); + } + run(&mut s, "view.printLayout", json!({})); + assert!(!s.view.read_mode); + assert_eq!(s.layout().pages[0].w, print.pages[0].w); + assert_eq!(s.export_layout().pages.len(), print.pages.len()); + assert_eq!(s.doc, original); + assert_eq!(s.dirty, dirty); + run(&mut s, "view.readMode", json!({})); + assert_eq!(s.view.read_column_width, None); + assert_eq!(s.view.read_zoom, 1.0); + assert_eq!(s.view.read_line_spacing, None); +} + /// File › Options › Advanced › Numeral: the display mode for digits (display only). #[test] fn numeral_mode_sets_gets_and_rejects_junk() { diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 9e3f0adae..3bff87dc9 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -48,6 +48,8 @@ pub enum ViewMode { Web, /// Draft: like web, page breaks shown as rules. Draft, + /// Reading: reflow into viewport-height narrow pages, navigated horizontally. + Read, /// Outline: like draft, the body as an outline of its headings ([`outline`]). Outline, } @@ -139,6 +141,10 @@ pub struct LayoutOptions { pub view: ViewMode, /// Width of the window in Web/Draft views, points. pub web_width: f32, + /// Page height in Reading Mode (document points). + pub read_height: f32, + /// Transient read-only line-spacing override; ignored by print/export views. + pub read_line_spacing: Option, pub show_hidden: bool, /// Final text ("No Markup"): tracked deletions take no space and draw nothing. pub hide_deleted: bool, @@ -445,6 +451,7 @@ struct Key { notes: u64, excl: u64, eq: u32, + read_spacing: Option, grid: (u32, u32, u32, bool), hide_deleted: bool, } @@ -638,6 +645,7 @@ impl Ctx<'_> { numeral: self.opts.numeral, exclusions: &[], eq_number: 0, + read_line_spacing: None, grid: para::Grid::default(), }; let pl = Arc::new(para::layout_para(&p, &env)); @@ -686,6 +694,7 @@ impl Ctx<'_> { notes: if p.objects.iter().any(|o| matches!(o, InlineObject::NoteRef { .. })) { self.notes_hash } else { 0 }, excl: if exclusions.is_empty() { 0 } else { hash_of(&format!("{exclusions:?}")) }, eq: if eq_here > 0 { eq_number } else { 0 }, + read_spacing: if self.opts.view == ViewMode::Read { self.opts.read_line_spacing.map(f32::to_bits) } else { None }, grid: self.grid.key(), hide_deleted: self.hide_deleted, }; @@ -708,6 +717,7 @@ impl Ctx<'_> { numeral: self.opts.numeral, exclusions, eq_number, + read_line_spacing: if self.opts.view == ViewMode::Read { self.opts.read_line_spacing } else { None }, grid: self.grid, }; let pl = Arc::new(para::layout_para(p, &env)); @@ -1671,7 +1681,9 @@ fn layout_pass( track_totals: true, }; let sections = doc.sections(); - let web = opts.view != ViewMode::Print; + let web = matches!(opts.view, ViewMode::Web | ViewMode::Draft | ViewMode::Outline); + let read = opts.view == ViewMode::Read; + let reflow = web || read; let outline = (opts.view == ViewMode::Outline).then(|| OutlinePass::new(doc, opts.outline.clone().unwrap_or_default())); let default_sect = SectionProps::default(); let mut web_sect; @@ -1679,14 +1691,21 @@ fn layout_pass( Some((_, s)) => *s, None => &default_sect, }; - let sect_ref = if web { + let sect_ref = if reflow { web_sect = first_sect.clone(); - web_sect.page_w = opts.web_width.max(144.0); + web_sect.page_w = if opts.web_width.is_finite() { opts.web_width.clamp(144.0, 20_000.0) } else { 300.0 }; + if read { + web_sect.page_h = if opts.read_height.is_finite() { opts.read_height.clamp(144.0, 20_000.0) } else { 500.0 }; + web_sect.margin_bottom = 18.0; + web_sect.page_borders = None; + web_sect.line_numbers = None; + } web_sect.margin_left = 18.0; web_sect.margin_right = 18.0; web_sect.margin_top = 18.0; web_sect.gutter = 0.0; web_sect.columns = Default::default(); + web_sect.valign = wordcraft_doc::props::VAlign::Top; &web_sect } else { first_sect @@ -1748,10 +1767,10 @@ fn layout_pass( .collect(); let mut prev_turn = TextDirection::Horizontal; for (si, (end, sect)) in sections.iter().enumerate() { - let turn = if web { TextDirection::Horizontal } else { sect.text_direction }; + let turn = if reflow { TextDirection::Horizontal } else { sect.text_direction }; let sect: &SectionProps = match turned.get(si) { Some(Some(t)) => t, - _ if web => sect_ref, + _ if reflow => sect_ref, _ => sect, }; pb.sect = sect; @@ -1766,10 +1785,10 @@ fn layout_pass( ctx.fields.section_pages = totals.section(si); // Headers and footers left out are linked to the previous section's, and take its room. let (heads, feet) = (linked(§ions, si, |s| s.headers), linked(§ions, si, |s| s.footers)); - let body_top = if web || turn.is_turned() { sect.margin_top } else { body_top_for(&mut ctx, sect, heads.default) }; - let first_top = if web || turn.is_turned() || !sect.title_page { body_top } else { body_top_for(&mut ctx, sect, heads.first) }; - let foot_top = if web || turn.is_turned() { f32::MAX } else { foot_top_for(&mut ctx, sect, feet.default) }; - let first_foot = if web || turn.is_turned() || !sect.title_page { foot_top } else { foot_top_for(&mut ctx, sect, feet.first) }; + let body_top = if reflow || turn.is_turned() { sect.margin_top } else { body_top_for(&mut ctx, sect, heads.default) }; + let first_top = if reflow || turn.is_turned() || !sect.title_page { body_top } else { body_top_for(&mut ctx, sect, heads.first) }; + let foot_top = if reflow || turn.is_turned() { f32::MAX } else { foot_top_for(&mut ctx, sect, feet.default) }; + let first_foot = if reflow || turn.is_turned() || !sect.title_page { foot_top } else { foot_top_for(&mut ctx, sect, feet.first) }; ctx.grid = para::Grid::of(doc, sect); let restart = sect.page_num_start; if sect.line_numbers.as_ref().is_some_and(|l| l.restart == wordcraft_doc::section::LineNumberRestart::Section) { @@ -1878,13 +1897,13 @@ fn layout_pass( p.h = bottom + 36.0; } ctx.grid = para::Grid::default(); - if !web && ctx.uses_totals && may_repeat { + if !reflow && ctx.uses_totals && may_repeat { let found = Totals::of(&pages, sections.len()); if found != *totals { return Pass::Again(found); } } - if !web { + if !reflow { ctx.track_totals = false; headers_footers(&mut ctx, &mut pages, §ions); if doc.settings.mirror_margins { @@ -2806,6 +2825,7 @@ fn next_first_line(ctx: &mut Ctx, block: usize, width: f32) -> f32 { numeral: ctx.opts.numeral, exclusions: &[], eq_number: ctx.eq_count, + read_line_spacing: if ctx.opts.view == ViewMode::Read { ctx.opts.read_line_spacing } else { None }, grid: ctx.grid, }; let _ = rp; diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index e896016cc..2b3c1db07 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -342,6 +342,7 @@ pub struct ParaEnv<'a> { pub exclusions: &'a [Exclusion], /// Automatic equation numbers used before this paragraph. pub eq_number: u32, + pub read_line_spacing: Option, /// The section's document grid (`Grid::default()`: none). pub grid: Grid, } @@ -1066,6 +1067,9 @@ pub(crate) fn left_out(rc: &ResolvedChar, show_hidden: bool, hide_deleted: bool) pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { let doc = env.doc; let mut rp = doc.styles.resolve_para_in(&p.props, env.table.map(|t| &t.para)); + if let Some(spacing) = env.read_line_spacing.filter(|s| s.is_finite() && (1.0..=2.0).contains(s)) { + rp.line_spacing = wordcraft_doc::props::LineSpacing::Multiple(spacing); + } let para_style = p.props.style.as_deref(); let table_chr = env.table.map(|t| &t.chr); // Indents in characters are measured in the paragraph mark's font size. diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index c0e80fa25..56373a63b 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -172,6 +172,7 @@ pub fn measure_table_columns(doc: &wordcraft_doc::Document, t: &Table) -> Vec<(f numeral: crate::NumeralMode::default(), exclusions: &[], eq_number: 0, + read_line_spacing: None, grid: crate::para::Grid::default(), }; let (mut min, mut max) = (EMPTY_TEXT, EMPTY_TEXT); diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index a65af6dc0..8e9b3f3a3 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -44,6 +44,30 @@ fn long_paragraph_wraps_and_paginates() { } } +#[test] +fn reading_layout_reflows_into_viewport_height_pages_without_changing_print_layout() { + let d = Document::from_text(&"A moderately long sentence that should wrap across reading columns. ".repeat(250)); + let opts = LayoutOptions { view: ViewMode::Read, web_width: 230.0, read_height: 390.0, ..Default::default() }; + let read = layout(&d, &mut LayoutCache::new(), &opts); + assert!(read.pages.len() > 5, "reading column count: {}", read.pages.len()); + assert!(read.pages.iter().all(|p| p.w == 230.0 && p.h == 390.0)); + assert!(read.pages.iter().all(|p| p.body.w < p.w && p.body.h < p.h)); + assert_eq!( + lines_of(&read), + lines_of(&layout(&d, &mut LayoutCache::new(), &LayoutOptions { view: ViewMode::Web, web_width: 230.0, ..Default::default() })) + ); + let print = lay(&d); + assert_eq!(print.pages[0].w, d.sections()[0].1.page_w); + assert_eq!(print.pages[0].h, d.sections()[0].1.page_h); + // Window height changes page breaks, not the text, and a malformed dimension stays bounded. + let short = layout(&d, &mut LayoutCache::new(), &LayoutOptions { read_height: 260.0, ..opts.clone() }); + assert!(short.pages.len() > read.pages.len()); + let spaced = layout(&d, &mut LayoutCache::new(), &LayoutOptions { read_line_spacing: Some(2.0), ..opts.clone() }); + assert!(spaced.pages.len() > read.pages.len(), "reader-only line spacing repaginates text"); + let invalid = layout(&d, &mut LayoutCache::new(), &LayoutOptions { web_width: f32::NAN, read_height: f32::INFINITY, ..opts }); + assert!(invalid.pages.iter().all(|p| p.w.is_finite() && p.h.is_finite())); +} + #[test] fn hit_and_caret_agree() { let d = Document::from_text("Hello world, this is WordCraft.\nSecond paragraph here."); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index b01f8797f..0b9bd5933 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -30,6 +30,20 @@ const EDGE_MAX_DT: f32 = 0.1; pub struct CanvasState { textures: HashMap, pub scroll_to_caret: bool, + /// A heading navigation click aligns its page against the navigation pane, not just visible. + pub(crate) nav_to_caret: bool, + /// Touchpad travel not yet converted to an entire reading-column turn. + read_wheel_accum: f32, + /// Last actual viewport seen during a resize, distinct from the committed reading page size. + read_viewport: Option, + read_resized_at: f64, + /// Reading controls (as opposed to window resizes) must reflow without debounce. + pub(crate) read_force_reflow: bool, + /// First text position on the column currently leading the reading viewport, captured from + /// last frame's layout. Survives changing page sizes, even when the caret stays elsewhere. + pub(crate) read_first_visible: Option, + /// An animated column-turn destination; intermediate offsets are allowed during the motion. + read_target_page: Option, pub caret_visible_since: f64, dragging: bool, /// Alt+drag: where the column (block) selection started (page, x, y) and its last end. @@ -74,6 +88,8 @@ pub struct CanvasState { /// The scroll offset and its maximum at the end of last frame. pub(crate) scroll_offset: egui::Vec2, scroll_max: egui::Vec2, + /// Explicit page turn to apply on the next frame (reading navigation buttons/keys). + scroll_request: Option, /// The viewport (the content's visible rect on screen) at the end of last frame; the /// drag-select edge auto-scroll decides this frame's scroll from it (#688). pub(crate) viewport: Option, @@ -118,6 +134,13 @@ impl Default for CanvasState { CanvasState { textures: HashMap::new(), scroll_to_caret: true, + nav_to_caret: false, + read_wheel_accum: 0.0, + read_viewport: None, + read_resized_at: 0.0, + read_force_reflow: false, + read_first_visible: None, + read_target_page: None, caret_visible_since: 0.0, dragging: false, column_drag: None, @@ -148,6 +171,7 @@ impl Default for CanvasState { wheel: Default::default(), scroll_offset: egui::Vec2::ZERO, scroll_max: egui::Vec2::ZERO, + scroll_request: None, viewport: None, cols: 1, balloon: None, @@ -316,6 +340,116 @@ fn place_pages(sizes: &[(f32, f32)], markup: f32, scale: f32, cols: usize, avail (rects, vec2(content_w, y.max(avail.y))) } +/// Reading columns use readable text at the user's chosen zoom. The window holds roughly 3–5 +/// narrow, full-height columns, and a resize reflows the text, not just the paper scale. +fn reading_size(avail: egui::Vec2, scale: f32, requested: Option) -> (f32, f32) { + let width = if avail.x.is_finite() { avail.x.max(0.0) } else { 0.0 }; + let height = if avail.y.is_finite() { avail.y.max(0.0) } else { 0.0 }; + let px = if let Some(pt) = requested.filter(|v| v.is_finite()) { + // Keep a selected full-page column usable on a narrow window, not clipped offscreen. + (pt.clamp(144.0, 612.0) * scale).min((width - 2.0 * GAP).max(144.0)) + } else { + let count = (width / 295.0).round().clamp(1.0, 5.0); + ((width - (count + 1.0) * GAP) / count).max(192.0) + }; + (px / scale, (height - 2.0 * GAP).max(192.0) / scale) +} + +/// A single horizontal row of reflowed reading pages; no wrapping at the viewport edge. +fn place_read_pages(sizes: &[(f32, f32)], scale: f32, avail: egui::Vec2) -> (Vec, egui::Vec2) { + let mut x = GAP; + let mut rects = Vec::with_capacity(sizes.len()); + for &(w, h) in sizes { + let w = sane_dim(w, 20_000.0) * scale; + let h = sane_dim(h, 20_000.0) * scale; + rects.push(Rect::from_min_size(pos2(x, GAP), vec2(w, h))); + x += w + GAP; + } + // Extra trailing canvas allows even the last column to sit directly beside the nav pane. + let end = rects.last().map_or(avail.x, |p| p.left() + avail.x - GAP); + (rects, vec2(x.max(end).max(avail.x), avail.y.max(0.0))) +} + +/// Text at the start of a reading page. Unlike the editing caret, this records what the reader +/// can currently see, even when the caret is elsewhere in the document. +fn reading_page_start(layout: &DocLayout, page: usize) -> Option { + let p = layout.pages.get(page)?; + p.items + .iter() + .find_map(|item| match item { + Placed::Lines { story: StoryRef::Body, path, para, l0, .. } => Some(Pos::new(StoryRef::Body, path.clone(), para.lines.get(*l0)?.start)), + _ => None, + }) + .or_else(|| Some(Pos::body(p.first_block, 0))) +} + +/// Resolve a reading anchor after page breaks change. Preserves the first visible text instead +/// of jumping to the editing caret (which may be thousands of columns away). +fn reading_anchor_page(layout: &DocLayout, anchor: &Pos) -> Option { + layout.caret_on(anchor, 0).map(|c| c.page) +} + +/// Turn one reading column left/right. Multiple ticks during an animation queue additional +/// columns, rather than rounding the partially animated position and losing a tick. +pub(crate) fn turn_read_page(app: &mut WordApp, direction: f32) { + let step = app.session.view.read_width * app.canvas.scale + GAP; + if step.is_finite() && step > 0.0 { + let current = app.canvas.read_target_page.unwrap_or_else(|| (app.canvas.scroll_offset.x / step).round().max(0.0) as usize); + let next = if direction < 0.0 { current.saturating_sub(1) } else { current.saturating_add(1) }; + app.canvas.read_target_page = Some(next.min(app.canvas.cols.saturating_sub(1))); + } +} + +/// Ease toward an exact column boundary; a late frame never overshoots. The final offset +/// is snapped exactly, so stopping leaves no partially visible column. +fn reading_animation_step(current: f32, target: f32, dt: f32) -> f32 { + if !current.is_finite() || !target.is_finite() { + return target.max(0.0); + } + let t = 1.0 - (-18.0 * dt.clamp(0.0, 0.1)).exp(); + let next = current + (target - current) * t; + if (target - next).abs() <= 0.5 { target } else { next } +} + +/// One mechanical wheel notch turns one column; precise touchpads accumulate to a turn. +/// Do not feed reading input into the editor's animated pixel scroller. +fn read_wheel_turns(events: &[egui::Event], hovered: bool, accum: &mut f32) -> i32 { + if !hovered { + return 0; + } + let mut turns = 0i32; + for event in events { + if let egui::Event::MouseWheel { unit, delta, modifiers, .. } = event { + if modifiers.command || modifiers.ctrl || !delta.is_finite() { + continue; + } + let d = delta.x + delta.y; + match unit { + egui::MouseWheelUnit::Line => { + // Browsers typically emit three lines for one physical notch. + let n = if cfg!(target_arch = "wasm32") { (d / 3.0).round() } else { d.round() }; + turns = turns.saturating_sub(n.clamp(-5.0, 5.0) as i32); + *accum = 0.0; + } + egui::MouseWheelUnit::Page => { + turns -= d.signum() as i32; + *accum = 0.0; + } + egui::MouseWheelUnit::Point => { + *accum = (*accum + d).clamp(-300.0, 300.0); + if accum.abs() >= 55.0 { + turns -= accum.signum() as i32; + // Discard excess pixels: a large wheel tick must not turn twice on + // the next event, while small touchpad deltas still add up to a turn. + *accum = 0.0; + } + } + } + } + } + turns.clamp(-5, 5) +} + pub fn geometry(app: &WordApp, l: &DocLayout, avail: egui::Vec2) -> Geometry { let v = &app.session.view; let markup = markup_width(app); @@ -323,33 +457,36 @@ pub fn geometry(app: &WordApp, l: &DocLayout, avail: egui::Vec2) -> Geometry { let maxh = l.pages.iter().map(|p| sane_dim(p.h, 20_000.0)).fold(0.0f32, f32::max).max(72.0); let web = v.mode != wordcraft_layout::ViewMode::Print; let mut scale = v.zoom.clamp(0.1, 5.0) * PX_PER_PT; + if v.read_mode { + scale = v.read_zoom.clamp(0.75, 1.5) * PX_PER_PT; + let sizes: Vec<(f32, f32)> = l.pages.iter().map(|p| (p.w, p.h)).collect(); + let (rects, size) = place_read_pages(&sizes, scale, avail); + // Reading columns show whole pages (Hide White Space doesn't apply). + let bands = rects.iter().map(|r| (0.0, r.height())).collect(); + return Geometry { rects, size, scale, cols: sizes.len().max(1), bands }; + } match v.fit.as_str() { "pageWidth" => scale = ((avail.x - 2.0 * GAP - 20.0) / maxw).clamp(0.1, 6.0), "onePage" => scale = ((avail.y - 2.0 * GAP) / maxh).min((avail.x - 2.0 * GAP) / maxw).clamp(0.05, 6.0), "multiplePages" => scale = ((avail.y - 2.0 * GAP) / maxh).min((avail.x - 3.0 * GAP) / (2.0 * maxw)).clamp(0.05, 6.0), _ => {} } - if v.read_mode { - scale = ((avail.y - 2.0 * GAP) / maxh).min((avail.x - 3.0 * GAP) / (2.0 * maxw)).clamp(0.05, 6.0); - } if web { scale = PX_PER_PT * v.zoom.clamp(0.1, 5.0); } if !scale.is_finite() { scale = PX_PER_PT; } - // Pages flow side by side in Print Layout whenever more than one fits across (Read Mode is a - // two-page spread; One Page and Page Width show one page per row; Web Layout has no pages). - let (min_cols, auto) = if v.read_mode { - (2, false) - } else if web || v.fit == "onePage" || v.fit == "pageWidth" { + // Pages flow side by side in Print Layout whenever more than one fits across; One Page and + // Page Width show one per row, Web Layout has no pages. Reading Mode is handled above. + let (min_cols, auto) = if web || v.fit == "onePage" || v.fit == "pageWidth" { (1, false) } else if v.multi_page { (2, true) } else { (1, true) }; - let cols = if v.read_mode { 2 } else { columns_for(avail.x, maxw * scale, l.pages.len(), min_cols, auto) }; + let cols = 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 (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(); @@ -464,13 +601,56 @@ fn drawing_grid_step(spacing: f32, scale: f32) -> f32 { pub fn show(app: &mut WordApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); + let full = ui.available_rect_before_wrap(); + // Full pagination is expensive. During a resize keep the already laid-out columns and + // reflow once the viewport has been stable briefly; ordinary reading controls update now. + const RESIZE_SETTLE: f64 = 0.16; + let mut reflow_anchor: Option = None; + if app.session.view.read_mode { + let now = ui.input(|i| i.time); + let resized = app.canvas.read_viewport.is_none_or(|v| (v - full.size()).length_sq() > 0.5); + let entering = app.canvas.read_viewport.is_none(); + if resized { + app.canvas.read_viewport = Some(full.size()); + app.canvas.read_resized_at = now; + } + let scale = app.session.view.read_zoom.clamp(0.75, 1.5) * PX_PER_PT; + let (w, h) = reading_size(full.size(), scale, app.session.view.read_column_width); + let changed = (app.session.view.read_width - w).abs() > 0.5 || (app.session.view.read_height - h).abs() > 0.5; + let forced = std::mem::take(&mut app.canvas.read_force_reflow) || entering; + if changed && (forced || now - app.canvas.read_resized_at >= RESIZE_SETTLE) { + if !entering { + reflow_anchor = app.canvas.read_first_visible.clone(); + } + app.session.view.read_width = w; + app.session.view.read_height = h; + app.canvas.scroll_to_caret = entering; + } else if changed { + ui.ctx().request_repaint_after(std::time::Duration::from_millis(170)); + } + if forced && !entering && reflow_anchor.is_none() { + // A spacing change can repaginate without changing physical page dimensions. + reflow_anchor = app.canvas.read_first_visible.clone(); + } + } else { + app.canvas.read_viewport = None; + app.canvas.read_force_reflow = false; + app.canvas.read_first_visible = None; + app.canvas.read_target_page = None; + } crate::ink::replay_tick(app, ui.ctx()); let layout = app.session.layout(); + if let Some(anchor) = reflow_anchor + && let Some(page) = reading_anchor_page(&layout, &anchor) + { + let step = app.session.view.read_width * app.session.view.read_zoom.clamp(0.75, 1.5) * PX_PER_PT + GAP; + app.canvas.read_target_page = None; + app.canvas.scroll_request = Some(vec2(page as f32 * step, 0.0)); + } // View › Switch Modes: dark paper, inverted document colours, a white caret. let dark_page = app.session.view.dark_mode; let caret_color = if dark_page { Color32::WHITE } else { t.caret }; let dark_paper = wordcraft_render::DARK_PAPER; - let full = ui.available_rect_before_wrap(); let show_ruler = app.session.view.ruler && app.session.view.mode == wordcraft_layout::ViewMode::Print && !app.session.view.read_mode; let (hruler, vruler, area) = if show_ruler { let h = Rect::from_min_max(pos2(full.min.x + RULER, full.min.y), pos2(full.max.x, full.min.y + RULER)); @@ -487,10 +667,18 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { let opts = ui.ctx().options(|o| o.input_options); let zoom_delta = ui.input(|i| app.canvas.wheel.zoom(i, &opts, animate)); if (zoom_delta - 1.0).abs() > 1e-3 && ui.rect_contains_pointer(area) { - let pct = (app.canvas.scale / PX_PER_PT * zoom_delta * 100.0).clamp(10.0, 500.0); - let _ = app.run("view.zoom", json!({"value": pct})); + let reading = app.session.view.read_mode; + let pct = (app.canvas.scale / PX_PER_PT * zoom_delta * 100.0).clamp(if reading { 75.0 } else { 10.0 }, if reading { 150.0 } else { 500.0 }); + let id = if reading { "view.readTextSize" } else { "view.zoom" }; + let _ = app.run(id, json!({"value": pct})); + } + let avail = if app.session.view.read_mode { area.size() } else { area.size() - vec2(14.0, 0.0) }; + let geo = geometry(app, &layout, avail); + // A pane or toolbar resize changes the reachable last column immediately. Navigation must + // clamp against this frame's geometry, not the previous frame's scroll maximum. + if app.session.view.read_mode { + app.canvas.scroll_max = (geo.size - area.size()).max(egui::Vec2::ZERO); } - let geo = geometry(app, &layout, area.size() - vec2(14.0, 0.0)); // The status bar's zoom readout and slider were laid out before this, from the last frame's // scale: after a zoom change draw again at once, so they follow in the next frame rather than // whenever something else next repaints (the caret's blink). @@ -503,7 +691,9 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { // moved, so bring it back into view rather than leave the reader somewhere else. if geo.cols != app.canvas.cols { app.canvas.cols = geo.cols; - app.canvas.scroll_to_caret = true; + if !app.session.view.read_mode { + app.canvas.scroll_to_caret = true; + } } let caret = layout.caret_on(&app.session.sel.focus, app.session.page_hint); // Editing a header/footer (or a note) dims the body; once per frame, for every page. @@ -512,7 +702,30 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { app.session.page_hint = c.page; } let mut scroll_target: Option = None; - if app.canvas.scroll_to_caret + if app.canvas.nav_to_caret { + app.canvas.read_target_page = None; + if let Some(c) = caret + && let Some(pr) = geo.rects.get(c.page) + { + let x = (pr.left() - GAP).max(0.0); + let y = if app.session.view.read_mode { 0.0 } else { (pr.top() + c.top * geo.scale - 40.0).max(0.0) }; + app.canvas.scroll_request = Some(vec2(x, y)); + } + app.canvas.nav_to_caret = false; + } else if app.canvas.scroll_to_caret && app.session.view.read_mode && app.canvas.scroll_request.is_none() { + if let Some(c) = caret + && let Some(pr) = geo.rects.get(c.page) + { + // Ordinary caret-follow only reveals an offscreen position, as in Print Layout. + // A click in a visible column must not align that whole column to the left. + let x = pr.left() + c.x * geo.scale; + let left = app.canvas.scroll_offset.x; + if x < left || x > left + area.width() { + app.canvas.read_target_page = None; + app.canvas.scroll_request = Some(vec2((pr.left() - GAP).max(0.0), 0.0)); + } + } + } else if app.canvas.scroll_to_caret && let Some(c) = caret && let Some(pr) = geo.rects.get(c.page) { @@ -527,14 +740,39 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { let mut ui_area = ui.new_child(egui::UiBuilder::new().max_rect(area)); // The canvas scrolls itself on wheel/touchpad input (crate::scroll): touchpads 1:1 with // momentum, wheel notches eased in (or all at once with animation effects off). Applied - // before drawing, so it shows this frame. + // before drawing, so it shows this frame. In reading mode, wheel and touchpad events turn + // whole columns instead. let hovered = ui.rect_contains_pointer(area) && ui.ctx().dragged_id().is_none(); let notch = crate::scroll::notch_px(app.canvas.scale / PX_PER_PT); - let wheel_delta = ui.input(|i| app.canvas.wheel.frame(i, &opts, hovered, notch, area.height(), animate)); + let wheel_delta = if app.session.view.read_mode { + let turns = ui.input(|i| read_wheel_turns(&i.events, hovered, &mut app.canvas.read_wheel_accum)); + for _ in 0..turns.unsigned_abs() { + turn_read_page(app, turns.signum() as f32); + } + if let Some(page) = app.canvas.read_target_page { + let step = app.session.view.read_width * geo.scale + GAP; + let x = (page as f32 * step).min(app.canvas.scroll_max.x); + let dt = ui.input(|i| i.stable_dt); + let next = if animate { reading_animation_step(app.canvas.scroll_offset.x, x, dt) } else { x }; + app.canvas.scroll_request = Some(vec2(next, 0.0)); + if next == x { + app.canvas.read_target_page = None; + } else { + ui.ctx().request_repaint(); + } + } + // Discard any pixel-scroll inertia left from editing mode. + app.canvas.wheel = Default::default(); + egui::Vec2::ZERO + } else { + app.canvas.read_wheel_accum = 0.0; + ui.input(|i| app.canvas.wheel.frame(i, &opts, hovered, notch, area.height(), animate)) + }; // Dragging a selection near the viewport's edge scrolls on (#688): from a crawl at the margin // to full speed at the edge, through the same offset the wheel writes. let (down, pointer, dt) = ui.input(|i| (i.pointer.primary_down(), i.pointer.latest_pos(), i.stable_dt)); - let auto_delta = if app.canvas.drag_selecting() + let auto_delta = if !app.session.view.read_mode + && app.canvas.drag_selecting() && down && let Some(vp) = app.canvas.viewport && let Some(p) = pointer @@ -550,9 +788,18 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { // Bringing the caret into view glides there, or jumps with animation effects off. .animated(animate) .scroll_source(egui::scroll_area::ScrollSource { mouse_wheel: false, ..Default::default() }); - if delta != egui::Vec2::ZERO { + if app.session.view.read_mode { + scroll_area = scroll_area.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden); + } + if delta != egui::Vec2::ZERO || app.canvas.scroll_request.is_some() || app.session.view.read_mode { let before = app.canvas.scroll_offset; - let after = clamped_scroll(before, delta, app.canvas.scroll_max); + let step = app.session.view.read_width * geo.scale + GAP; + let snapped = if step.is_finite() && step > 0.0 { vec2((before.x / step).round() * step, 0.0) } else { egui::Vec2::ZERO }; + let after = match app.canvas.scroll_request.take() { + Some(request) => request.clamp(egui::Vec2::ZERO, app.canvas.scroll_max), + None if app.session.view.read_mode => snapped.clamp(egui::Vec2::ZERO, app.canvas.scroll_max), + None => clamped_scroll(before, delta, app.canvas.scroll_max), + }; if after == before && wheel_delta != egui::Vec2::ZERO { app.canvas.wheel.hit_edge(); } @@ -584,6 +831,12 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { 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); + // Most reading documents have many columns; skip expensive page hashing for offscreen + // pages. We still emit their rectangles for hit testing and the control channel. + if !sr.intersects(ui.clip_rect().expand(200.0)) { + rects.push(sr); + continue; + } let key = page_key(app, page, scale_px, dim_body); let mut fresh = app.canvas.textures.get(&i).is_some_and(|(k, _)| *k == key); // Hide White Space shows only a band of the page; everything below paints into it. @@ -865,6 +1118,13 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { 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); + if app.session.view.read_mode { + let step = app.session.view.read_width * geo.scale + GAP; + if step.is_finite() && step > 0.0 { + let page = (app.canvas.scroll_offset.x / step).round().max(0.0) as usize; + app.canvas.read_first_visible = reading_page_start(&layout, page.min(layout.pages.len().saturating_sub(1))); + } + } // The viewport barely moves between frames; the auto-scroll decides this frame's scroll before // the scroll area is built, so it reads last frame's viewport. app.canvas.viewport = Some(out.inner_rect); @@ -961,6 +1221,33 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { { crate::mini_toolbar::show(app, ui.ctx(), sel, area); } + if app.session.view.read_mode { + // Fixed navigation chrome, independent of the horizontally moving columns. + let step = app.session.view.read_width * app.canvas.scale + GAP; + let current = if step > 0.0 { (app.canvas.scroll_offset.x / step).round() as usize + 1 } else { 1 }; + let total = layout.pages.len(); + let footer = Rect::from_min_size(pos2(area.max.x - 260.0, area.max.y - 38.0), vec2(250.0, 32.0)); + ui.scope_builder(egui::UiBuilder::new().max_rect(footer), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.horizontal(|ui| { + if ui.add_enabled(app.canvas.scroll_offset.x > 0.5, egui::Button::new("‹")).on_hover_text(tl!("Previous Page")).clicked() { + turn_read_page(app, -1.0); + } + ui.label(format!("{} / {}", current.min(total.max(1)), total)); + if ui + .add_enabled(app.canvas.scroll_offset.x + 0.5 < app.canvas.scroll_max.x, egui::Button::new("›")) + .on_hover_text(tl!("Next Page")) + .clicked() + { + turn_read_page(app, 1.0); + } + if ui.button(tl!("Edit Document")).clicked() { + let _ = app.run("view.printLayout", json!({})); + } + }); + }); + }); + } } /// Comment balloons in the markup area right of each page, joined to their anchors, with each @@ -2116,6 +2403,41 @@ fn is_followable_link(url: &str) -> bool { mod tests { use super::*; + #[test] + fn reading_animation_eases_then_lands_exactly_on_column_start() { + let goal = 330.0; + let one = reading_animation_step(0.0, goal, 1.0 / 60.0); + assert!(one > 0.0 && one < goal); + let mut pos = one; + for _ in 0..60 { + let next = reading_animation_step(pos, goal, 1.0 / 60.0); + assert!((pos..=goal).contains(&next)); + pos = next; + } + assert_eq!(pos, goal); + assert!(reading_animation_step(500.0, goal, 0.1).is_finite()); + } + + #[test] + fn reading_wheel_snaps_one_column_per_tick_and_respects_touchpads() { + let ev = |unit, y| egui::Event::MouseWheel { unit, delta: vec2(0.0, y), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE }; + let mut partial = 0.0; + for _ in 0..6 { + assert_eq!(read_wheel_turns(&[ev(egui::MouseWheelUnit::Point, -65.0)], true, &mut partial), 1); + assert_eq!(partial, 0.0, "a large wheel tick cannot spill into the next column"); + } + assert_eq!(read_wheel_turns(&[ev(egui::MouseWheelUnit::Line, -1.0)], true, &mut partial), 1); + assert_eq!(read_wheel_turns(&[ev(egui::MouseWheelUnit::Line, 1.0)], true, &mut partial), -1); + assert_eq!(read_wheel_turns(&[ev(egui::MouseWheelUnit::Point, -20.0)], true, &mut partial), 0); + assert_eq!(read_wheel_turns(&[ev(egui::MouseWheelUnit::Point, -20.0)], true, &mut partial), 0); + assert_eq!(read_wheel_turns(&[ev(egui::MouseWheelUnit::Point, -20.0)], true, &mut partial), 1); + assert_eq!(read_wheel_turns(&[ev(egui::MouseWheelUnit::Line, -1.0)], false, &mut partial), 0); + let (rects, size) = place_read_pages(&[(240.0, 500.0); 10], 1.0, vec2(1100.0, 600.0)); + let last_left = rects.last().unwrap().left(); + assert!((size.x - 1100.0 - (last_left - GAP)).abs() < 1.0, "last column aligns against the pane"); + assert!(reading_size(vec2(1440.0, 750.0), PX_PER_PT, Some(612.0)).0 <= 612.0); + } + /// #167: dragging a ruler's margin edge sets that margin, snapped, never negative, and /// always leaving an inch of text. #[test] diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 41348a82b..9b88ad570 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -321,28 +321,38 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) { } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.spacing_mut().item_spacing = vec2(4.0, 0.0); - let z = (app.canvas.scale / crate::canvas::PX_PER_PT * 100.0).round(); + let reading = app.session.view.read_mode; + let z = (if reading { app.session.view.read_zoom } else { app.canvas.scale / crate::canvas::PX_PER_PT } * 100.0).round(); let ztxt = format!("{z}%"); let zfont = TypeRung::Caption.regular(); let ztrack = TypeRung::Caption.tracking(); let zw = text_width(ui.painter(), &ztxt, &zfont, ztrack, t.text_dim); let (zr, zresp) = ui.allocate_exact_size(vec2(zw + 1.0, TypeRung::Caption.line_height()), Sense::click()); paint_text(ui.painter(), pos2(zr.min.x, zr.min.y), Align2::LEFT_TOP, &ztxt, &zfont, ztrack, t.text_dim); - if zresp.clicked() { + if zresp.clicked() && !reading { let _ = app.run("ui.dialog", json!({ "name": "zoom" })); } if small_icon(ui, "plus", tl!("Zoom In")) { - let _ = app.run("view.zoomIn", json!({})); + if reading { + let _ = app.run("view.readTextSize", json!({"value": (z + 10.0).min(150.0)})); + } else { + let _ = app.run("view.zoomIn", json!({})); + } } - // Zoom slider (10%–500%, 100% in the middle). - let mut v = app.canvas.scale / crate::canvas::PX_PER_PT; - let slider = egui::Slider::new(&mut v, 0.1..=5.0).logarithmic(true).show_value(false); + // Reading zoom changes only reading presentation, never the normal view zoom. + let mut v = if reading { app.session.view.read_zoom } else { app.canvas.scale / crate::canvas::PX_PER_PT }; + let slider = egui::Slider::new(&mut v, if reading { 0.75..=1.5 } else { 0.1..=5.0 }).logarithmic(!reading).show_value(false); ui.spacing_mut().slider_width = 110.0; if ui.add(slider).changed() { - let _ = app.run("view.zoom", json!({"value": (v * 100.0).round()})); + let command = if reading { "view.readTextSize" } else { "view.zoom" }; + let _ = app.run(command, json!({"value": (v * 100.0).round()})); } if small_icon(ui, "minus", tl!("Zoom Out")) { - let _ = app.run("view.zoomOut", json!({})); + if reading { + let _ = app.run("view.readTextSize", json!({"value": (z - 10.0).max(75.0)})); + } else { + let _ = app.run("view.zoomOut", json!({})); + } } ui.add_space(10.0); let mode = app.session.view.mode; diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index fdcdf9c10..996f6f9f1 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2054,3 +2054,7 @@ Context Podle kontextu System Systémové Show KeyTips (Alt or F10) Zobrazit klávesové tipy (Alt nebo F10) + Reading Čtení + Reading Column Width Šířka sloupce pro čtení + Reading Line Spacing Řádkování pro čtení + Reading Text Size Velikost textu pro čtení diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 5fb5a986a..915d2783f 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2048,3 +2048,7 @@ Context Kontext System System Show KeyTips (Alt or F10) Zugriffstasten anzeigen (Alt oder F10) + Reading Lesen + Reading Column Width Spaltenbreite beim Lesen + Reading Line Spacing Zeilenabstand beim Lesen + Reading Text Size Textgröße beim Lesen diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 1efb41567..4034d2b4b 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1744,6 +1744,10 @@ Continuous numbering Numeración continua Apply Aplicar Convert Notes Convertir notas + Reading Lectura + Reading Column Width Ancho de columna de lectura + Reading Line Spacing Interlineado de lectura + Reading Text Size Tamaño del texto de lectura English (United Kingdom) Inglés (Reino Unido) Polish Polaco Do Not Check Spelling or Grammar No revisar la ortografía ni la gramática diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index abc4ea1e7..fd1fb1644 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -1705,6 +1705,10 @@ Continuous numbering Pidev nummerdus Apply Rakenda Convert Notes Teisenda märkused + Reading Lugemine + Reading Column Width Lugemisveeru laius + Reading Line Spacing Lugemise reavahe + Reading Text Size Lugemisteksti suurus Do Not Check Spelling or Grammar Ära kontrolli õigekirja ega grammatikat English (United Kingdom) Inglise (Ühendkuningriik) Polish Poola diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index b40ba3507..84bc2af16 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2433,3 +2433,7 @@ preview Title Esempio Context Contesto System Sistema Show KeyTips (Alt or F10) Mostra i suggerimenti tasti (Alt o F10) + Reading Lettura + Reading Column Width Larghezza colonna di lettura + Reading Line Spacing Interlinea di lettura + Reading Text Size Dimensione testo di lettura diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 4aade67cc..037ee2422 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1704,6 +1704,10 @@ Continuous numbering 通し番号 Apply 適用 Convert Notes 注の変換 + Reading 閲覧 + Reading Column Width 閲覧時の列幅 + Reading Line Spacing 閲覧時の行間 + Reading Text Size 閲覧時の文字サイズ English (United Kingdom) 英語(英国) Polish ポーランド語 Do Not Check Spelling or Grammar スペル チェックと文章校正を行わない diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 7921ea282..43249f460 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2090,3 +2090,7 @@ List Paragraph Akapit z listą Models Modele Show KeyTips (Alt or F10) Pokaż etykietki klawiszy (Alt lub F10) + Reading Czytanie + Reading Column Width Szerokość kolumny do czytania + Reading Line Spacing Interlinia do czytania + Reading Text Size Rozmiar tekstu do czytania diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 08818c7d1..392c3f204 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1716,6 +1716,10 @@ Continuous numbering Numeração contínua Apply Aplicar Convert Notes Converter notas + Reading Leitura + Reading Column Width Largura da coluna de leitura + Reading Line Spacing Espaçamento entre linhas na leitura + Reading Text Size Tamanho do texto na leitura English (United Kingdom) Inglês (Reino Unido) Polish Polonês Do Not Check Spelling or Grammar Não verificar ortografia nem gramática diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 09cde4915..ab441c678 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2149,6 +2149,10 @@ preview Title Пример Continuous numbering Сквозная нумерация Apply Применить Convert Notes Преобразовать сноски + Reading Чтение + Reading Column Width Ширина столбца в режиме чтения + Reading Line Spacing Межстрочный интервал в режиме чтения + Reading Text Size Размер текста в режиме чтения Do Not Check Spelling or Grammar Не проверять орфографию и грамматику English (United Kingdom) Английский (Великобритания) Polish Польский diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 5cfb62972..1aab2b39b 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -1714,6 +1714,10 @@ Continuous numbering Neprekidna numeracija Apply Primeni Convert Notes Pretvori napomene + Reading Čitanje + Reading Column Width Širina kolone za čitanje + Reading Line Spacing Proređenost pri čitanju + Reading Text Size Veličina teksta za čitanje English (United Kingdom) Engleski (Velika Britanija) Polish Poljski Do Not Check Spelling or Grammar Ne proveravaj pravopis i gramatiku diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index b6586b557..2f5fb999c 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -1718,6 +1718,10 @@ Continuous numbering Непрекидна нумерација Apply Примени Convert Notes Претвори напомене + Reading Читање + Reading Column Width Ширина колоне за читање + Reading Line Spacing Проређеност при читању + Reading Text Size Величина текста за читање English (United Kingdom) Енглески (Велика Британија) Polish Пољски Do Not Check Spelling or Grammar Не проверавај правопис и граматику diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index ff171b5f6..398a89526 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1798,6 +1798,10 @@ Continuous numbering Sürekli numaralandırma Apply Uygula Convert Notes Notları Dönüştür + Reading Okuma + Reading Column Width Okuma Sütunu Genişliği + Reading Line Spacing Okuma Satır Aralığı + Reading Text Size Okuma Metin Boyutu # Built-in file browser (#576). Documents Belgeler diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 4166547fc..f4829bc13 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1716,6 +1716,10 @@ Continuous numbering Наскрізна нумерація Apply Застосувати Convert Notes Перетворити примітки + Reading Читання + Reading Column Width Ширина стовпця для читання + Reading Line Spacing Міжрядковий інтервал для читання + Reading Text Size Розмір тексту для читання English (United Kingdom) Англійська (Велика Британія) Polish Польська Do Not Check Spelling or Grammar Не перевіряти правопис і граматику diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 894eb34cb..86a1d776d 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1716,6 +1716,10 @@ Continuous numbering 连续编号 Apply 应用 Convert Notes 转换注释 + Reading 阅读 + Reading Column Width 阅读栏宽 + Reading Line Spacing 阅读行距 + Reading Text Size 阅读字号 English (United Kingdom) 英语(英国) Polish 波兰语 Do Not Check Spelling or Grammar 不检查拼写和语法 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index bbfae71b0..0138e3425 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1705,6 +1705,10 @@ Continuous numbering 連續編號 Apply 套用 Convert Notes 轉換附註 + Reading 閱讀 + Reading Column Width 閱讀欄寬 + Reading Line Spacing 閱讀行距 + Reading Text Size 閱讀字級 English (United Kingdom) 英文(英國) Polish 波蘭文 Do Not Check Spelling or Grammar 不檢查拼字及文法 diff --git a/crates/ui-egui/src/keys.rs b/crates/ui-egui/src/keys.rs index 26e78d019..78e638416 100644 --- a/crates/ui-egui/src/keys.rs +++ b/crates/ui-egui/src/keys.rs @@ -177,6 +177,32 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) { return; } let events = ctx.input(|i| i.events.clone()); + if app.session.view.read_mode { + // Read Mode is for navigation and selection, not typing into the document. The ribbon's + // Edit Document button or Escape returns to Print Layout before editing. + for e in &events { + match e { + egui::Event::Key { key: Key::Escape, pressed: true, .. } => { + let _ = app.run("view.printLayout", json!({})); + break; + } + egui::Event::Key { key: Key::PageDown | Key::ArrowRight, pressed: true, modifiers, .. } if modifiers.is_none() => { + crate::canvas::turn_read_page(app, 1.0) + } + egui::Event::Key { key: Key::PageUp | Key::ArrowLeft, pressed: true, modifiers, .. } if modifiers.is_none() => { + crate::canvas::turn_read_page(app, -1.0) + } + egui::Event::Copy => { + let selected = app.session.selected_text(); + if !selected.is_empty() { + ctx.copy_text(selected); + } + } + _ => {} + } + } + return; + } for e in events { // An Alt shortcut (Alt+Shift+D, Alt+F7…) may also send its key's text; it isn't typed. match &e { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2f3672ab8..aaa605441 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -797,6 +797,15 @@ impl WordApp { fn after_command(&mut self, id: &str) { options::sync(self); self.canvas.caret_visible_since = now_ms(); + if id == "view.outline" { + self.ui.nav_tab = "headings".into(); + } + if matches!(id, "view.readColumnWidth" | "view.readTextSize" | "view.readLineSpacing") { + self.canvas.read_force_reflow = true; + } + if id == "view.readMode" { + self.canvas.read_first_visible = None; + } if !id.starts_with("view.") && !id.starts_with("document.") && !id.starts_with("format.state") { self.canvas.scroll_to_caret = true; } @@ -1961,6 +1970,138 @@ mod tests { assert!((a.canvas.scroll_offset.y - at - notch).abs() < 0.5, "a notch scrolls {notch}: {}", a.canvas.scroll_offset.y - at); } + /// Reading Mode: reflowed viewport-height columns, horizontal wheel/navigation, exit restores + /// Print Layout without editing or changing the document's page setup. + #[test] + fn reading_mode_columns_wheel_turns_and_navigation() { + let ctx = egui::Context::default(); + let mut a = app(); + a.run("text.insert", json!({"text": "One readable paragraph in many narrow columns. ".repeat(300)})).unwrap(); + a.run("caret.set", json!({"pos": wordcraft_doc::Pos::body(0, 0)})).unwrap(); + let original = a.session.doc.settings.clone(); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 740.0)); + let mut t = 0.0; + let mut frame = |a: &mut WordApp, events: Vec, screen| { + t += 1.0 / 60.0; + let input = egui::RawInput { events, time: Some(t), screen_rect: Some(screen), ..Default::default() }; + ctx.run_ui(input, |ui| { + a.logic(ui.ctx()); + a.ui(ui); + }) + .drop_without_applying_deltas(); + }; + for _ in 0..3 { + frame(&mut a, vec![], screen); + } + a.run("view.readMode", json!({})).unwrap(); + for _ in 0..3 { + frame(&mut a, vec![], screen); + } + let rects = &a.canvas.page_rects; + assert!(rects.len() > 5, "reading should reflow: {}", rects.len()); + assert_eq!(a.canvas.cols, rects.len(), "one unwrapped row"); + assert!((rects[0].top() - rects[1].top()).abs() < 0.1); + assert!(rects[1].left() > rects[0].right()); + assert!((rects[0].height() - (a.canvas.canvas_rect.unwrap().height() - 36.0)).abs() < 2.0); + let initial_width = a.session.view.read_width; + // A simple mouse click in a later visible column must neither pull that column + // to the leading edge nor select text under the stationary, held pointer. + let click = a.canvas.page_rects[2].center(); + assert!(a.canvas.canvas_rect.unwrap().contains(click)); + let initial_scroll = a.canvas.scroll_offset.x; + let button = + |pressed| egui::Event::PointerButton { pos: click, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + frame(&mut a, vec![egui::Event::PointerMoved(click), button(true)], screen); + assert_eq!(a.session.layout().caret_on(&a.session.sel.focus, a.session.page_hint).unwrap().page, 2); + frame(&mut a, vec![], screen); // mouse is still held at the same coordinates + assert!((a.canvas.scroll_offset.x - initial_scroll).abs() < 0.5, "click must not scroll a later column to the left"); + assert!(a.session.sel.is_collapsed(), "stationary click must not create a selection"); + frame(&mut a, vec![button(false)], screen); + frame(&mut a, vec![], screen); + assert!((a.canvas.scroll_offset.x - initial_scroll).abs() < 0.5); + assert!(a.session.sel.is_collapsed()); + // An explicit caret move outside the visible columns must still reveal its destination. + a.run("caret.set", json!({"pos": wordcraft_doc::Pos::body(0, 5000)})).unwrap(); + frame(&mut a, vec![], screen); + assert!(a.canvas.scroll_offset.x > initial_scroll + a.session.view.read_width * a.canvas.scale); + a.run("caret.set", json!({"pos": wordcraft_doc::Pos::body(0, 0)})).unwrap(); + frame(&mut a, vec![], screen); + assert!((a.canvas.scroll_offset.x - initial_scroll).abs() < 0.5); + let over = a.canvas.canvas_rect.unwrap().center(); + frame(&mut a, vec![egui::Event::PointerMoved(over)], screen); + frame( + &mut a, + vec![egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -65.0), + phase: egui::TouchPhase::Start, + modifiers: egui::Modifiers::NONE, + }], + screen, + ); + let step = a.session.view.read_width * a.canvas.scale + 18.0; + assert!(a.canvas.scroll_offset.x > 0.0 && a.canvas.scroll_offset.x < step, "a wheel turn animates instead of jumping"); + let first = a.canvas.scroll_offset.x; + frame(&mut a, vec![], screen); + assert!(a.canvas.scroll_offset.x > first, "smooth motion continues after the wheel event"); + for _ in 0..23 { + frame(&mut a, vec![], screen); + } + assert!((a.canvas.scroll_offset.x - step).abs() < 1.0, "animation settles exactly on column boundary"); + assert_eq!(a.canvas.scroll_offset.y, 0.0); + canvas::turn_read_page(&mut a, 1.0); + for _ in 0..25 { + frame(&mut a, vec![], screen); + } + assert!((a.canvas.scroll_offset.x - 2.0 * step).abs() < 1.0); + let x = a.canvas.scroll_offset.x; + // A heading navigation request must position its column at the leading edge, + // not wherever egui's generic scroll_to_rect decides the caret is visible. + let target = wordcraft_doc::Pos::body(0, 5000); + a.run("view.navigationPane", json!({"value": true})).unwrap(); + a.run("caret.set", json!({"pos": target})).unwrap(); + a.canvas.nav_to_caret = true; + a.canvas.scroll_to_caret = false; + frame(&mut a, vec![], screen); + let page = a.session.layout().caret_on(&a.session.sel.focus, a.session.page_hint).unwrap().page; + let step = a.session.view.read_width * a.canvas.scale + 18.0; + assert!((a.canvas.scroll_offset.x - (page as f32 * step)).abs() < 1.0, "heading must align its column beside the navigation pane"); + assert!(a.canvas.scroll_offset.x != x); + // Resizing changes the content width (reflow), not the zoom or the print paper width. + let narrow = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(850.0, 630.0)); + let before_resize = a.canvas.scroll_offset.x; + let before_layout = a.session.layout(); + let anchor = a.canvas.read_first_visible.clone().unwrap(); + frame(&mut a, vec![], narrow); + assert!(std::sync::Arc::ptr_eq(&before_layout, &a.session.layout()), "resize frames reuse cached pagination"); + assert!((a.canvas.scroll_offset.x - before_resize).abs() < 1.0, "no jump while resize is in progress"); + assert_eq!(a.session.view.read_width, initial_width, "expensive repagination is deferred during resizing"); + for _ in 0..15 { + frame(&mut a, vec![], narrow); + } + assert_ne!(a.session.view.read_width, initial_width); + assert!(!std::sync::Arc::ptr_eq(&before_layout, &a.session.layout()), "stable viewport commits one repagination"); + let target_page = a.session.layout().caret_on(&anchor, 0).unwrap().page; + let new_step = a.session.view.read_width * a.canvas.scale + 18.0; + assert!( + (a.canvas.scroll_offset.x - target_page as f32 * new_step).abs() < 1.0, + "resize must preserve visible text anchor, not jump to editing caret" + ); + assert!((a.canvas.scale - canvas::PX_PER_PT).abs() < 0.01); + a.run("view.readColumnWidth", json!({"value": "page"})).unwrap(); + frame(&mut a, vec![], narrow); + assert!(a.session.view.read_width <= 612.0); + a.run("view.readTextSize", json!({"value": 125})).unwrap(); + frame(&mut a, vec![], narrow); + assert!((a.canvas.scale / canvas::PX_PER_PT - 1.25).abs() < 0.01); + // The Edit Document button works even when the navigation pane owns focus. + a.run("view.printLayout", json!({})).unwrap(); + frame(&mut a, vec![], narrow); + assert!(!a.session.view.read_mode); + assert_eq!(a.session.view.mode, wordcraft_layout::ViewMode::Print); + assert_eq!(a.session.doc.settings, original); + } + /// An app whose host reports the system's animation setting as `system` holds it. fn app_with_system_animations(system: &std::rc::Rc>>) -> WordApp { let system = system.clone(); diff --git a/crates/ui-egui/src/mini_toolbar.rs b/crates/ui-egui/src/mini_toolbar.rs index 2aa352443..5782be907 100644 --- a/crates/ui-egui/src/mini_toolbar.rs +++ b/crates/ui-egui/src/mini_toolbar.rs @@ -64,6 +64,10 @@ pub fn shown_id() -> egui::Id { /// Whether the bar may be shown for the current app state. pub fn visible(app: &WordApp) -> bool { + // Reading Mode permits text selection and copying, but never formatting controls. + if app.session.view.read_mode { + return false; + } // Never over a modal dialog or the Backstage. if app.dialog.is_some() || app.colors.is_some() || app.ui.backstage { return false; @@ -231,6 +235,22 @@ mod tests { Rect::from_min_size(pos2(100.0, 50.0), Vec2::new(1200.0, 700.0)) } + #[test] + fn reading_mode_hides_formatting_even_with_an_existing_selection() { + let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + app.run("text.insert", json!({"text": "Selectable text"})).unwrap(); + app.run("select.all", json!({})).unwrap(); + assert!(!app.session.sel.is_collapsed()); + assert!(visible(&app), "selection shows the formatting bar in editing mode"); + + app.run("view.readMode", json!({})).unwrap(); + assert!(!app.session.sel.is_collapsed(), "entering Reading Mode must preserve the selection"); + assert!(!visible(&app), "formatting bar must not be accessible in Reading Mode"); + + app.run("view.printLayout", json!({})).unwrap(); + assert!(visible(&app), "returning to editing restores the formatting bar"); + } + #[test] fn sits_above_the_selection_when_there_is_room() { let left = vp().center().x; diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index e225b19a2..2d265befd 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -190,6 +190,10 @@ fn nav(app: &mut WordApp, ui: &mut Ui) { let r = ui.selectable_label(current == Some(i), egui::RichText::new(txt).font(if lv == 0 { semibold(12.5) } else { regular(12.5) })); if r.clicked() { let _ = app.run("caret.set", json!({"pos": Pos { story: StoryRef::Body, path, off: 0 }})); + // Align the chosen heading's page immediately against this pane, rather + // than the indeterminate minimum movement from scroll_to_rect. + app.canvas.nav_to_caret = true; + app.canvas.scroll_to_caret = false; app.canvas.want_focus = true; } }); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index a57bd7bb2..967da0eef 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -23,6 +23,72 @@ pub fn has_shape_selected(s: &wordcraft_engine::Session) -> bool { } } +/// Reading controls replace editing tabs entirely. All settings are transient view controls. +fn reading_toolbar(app: &mut WordApp, ui: &mut Ui, t: Tokens) { + egui::Panel::top("reading_toolbar") + .exact_size(46.0) + .frame(egui::Frame::NONE.fill(t.ribbon).inner_margin(egui::Margin::symmetric(12, 6)).stroke(Stroke::new(1.0, t.border))) + .show(ui, |ui| { + ui.horizontal_centered(|ui| { + ui.label(egui::RichText::new(tl!("Reading Mode")).font(semibold(14.0))); + ui.separator(); + let w = app.session.view.read_column_width; + let active = match w { + None => "Auto", + Some(v) if v <= 180.0 => "Narrow", + Some(v) if v <= 240.0 => "Medium", + Some(v) if v <= 350.0 => "Wide", + _ => "Page", + }; + ui.menu_button(format!("{}: {}", tl!("Column width"), tl!(active)), |ui| { + for (name, value) in [("Auto", "auto"), ("Narrow", "narrow"), ("Medium", "medium"), ("Wide", "wide"), ("Page", "page")] { + if ui.selectable_label(active == name, tl!(name)).clicked() { + let _ = app.run("view.readColumnWidth", json!({"value": value})); + ui.close(); + } + } + }); + ui.separator(); + let pct = (app.session.view.read_zoom * 100.0).round() as u32; + ui.menu_button(format!("{}: {pct}%", tl!("Text size")), |ui| { + for percent in [75, 90, 100, 110, 125, 150] { + if ui.selectable_label(pct == percent, format!("{percent}%")).clicked() { + let _ = app.run("view.readTextSize", json!({"value": percent})); + ui.close(); + } + } + }); + ui.separator(); + let spacing = app.session.view.read_line_spacing; + let label = spacing.map_or_else(|| tl!("Document").to_owned(), |v| format!("{v}×")); + ui.menu_button(format!("{}: {}", tl!("Line spacing"), label), |ui| { + if ui.selectable_label(spacing.is_none(), tl!("Document")).clicked() { + let _ = app.run("view.readLineSpacing", json!({"value": "document"})); + ui.close(); + } + for value in [1.0, 1.25, 1.5, 2.0] { + if ui.selectable_label(spacing == Some(value), format!("{value}×")).clicked() { + let _ = app.run("view.readLineSpacing", json!({"value": value})); + ui.close(); + } + } + }); + ui.separator(); + // Headings to jump to, in the Navigation pane beside the columns (Outline view + // is an editing layout, so it isn't offered here). + if ui.button(tl!("Navigation Pane")).clicked() { + let _ = app.run("view.navigationPane", json!({"value": true})); + app.ui.nav_tab = "headings".into(); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button(tl!("Edit Document")).clicked() { + let _ = app.run("view.printLayout", json!({})); + } + }); + }); + }); +} + /// Whether the selection is a SmartArt graphic WordCraft can edit (so SmartArt Design is shown). pub fn has_smart_art_selected(s: &wordcraft_engine::Session) -> bool { wordcraft_engine::cmd::smart_art::selected_smart_art(s).is_some() @@ -75,6 +141,10 @@ pub fn in_table_public(app: &WordApp) -> bool { pub fn show(app: &mut WordApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); + if app.session.view.read_mode { + reading_toolbar(app, ui, t); + return; + } // Editing an equation brings up the Equation tab; leaving it goes back. if app.session.math.is_some() { if app.equation_prev_tab.is_none() { diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 4810911f3..2698cc517 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 (Quick Access Toolbar KeyTips, native Alt handling and a saved KeyTips switch, #620; previously trivial: File › Options panes, #485; 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:** minor (Reading Mode implemented; 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 @@ -29,6 +29,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | Selection: word/sentence/paragraph clicks, dragging after a double-click/triple-click/Ctrl+click extends by whole words/paragraphs/sentences (#549), Shift extend, F8 extend mode, column (Alt+drag, #237) | ✅ | ✅ | 90% | 1 | | Zoom: Ctrl+scroll, pinch (#179), zoom buttons (#232), pages side by side when zoomed out (#251) | ✅ | ✅ | 85% | — | | Touchpad smooth/inertial scroll | ✅ (#252) | ✅ | 90% | — | +| Reading Mode | Existing Read Mode ribbon button opens a read-only, viewport-column layout with animated horizontal paging, transient width/text/spacing controls, heading navigation (Navigation pane) and resize anchoring; print/export layout unchanged | reading-optimized view and navigation | 75% | 1–2 | | Status bar (page x of y, words, language, view buttons, zoom slider) | ✅ (the language is the caret's; a click sets the proofing language) | ✅ | 85% | — | | Caret motion: keys and typing glide the caret to its new place (100 ms, `cubic-bezier(0, 0, 0, 1)`), the blink fades (530 ms on and off, 100 ms fades); clicks and scripted moves don't glide. File › Options › Advanced › Animation (`ui.caretAnimation`) turns it off | ✅ as measured from Word for Windows (`caret_glide.rs`); zoom levels other than 100%, modifier keys and off-screen moves not measured | ✅ on Windows; Word for Mac not measured | 90% | 1–2 | | Interface themes (light, dark, system #249, follows OS changes live #311), dark page separate from the interface theme (#194, #312) | ✅ | ✅ | 90% | — | @@ -43,6 +44,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Reading Mode: viewport-height horizontal columns, animated paging, transient display controls, heading navigation and responsive anchored resizing | | 2026-10-11 | trivial | Alt activation handles native key/modifier events in either order, including key-up with the old Alt flag; taps toggle once, chords and focus loss do not activate KeyTips | | 2026-10-11 | trivial | Quick Access Toolbar KeyTips (#620): 1–9, zero-prefixed numbers for longer toolbars, disabled commands stay disabled, Escape cancels, badge keystrokes never type into the document; persisted File › Options switch | | 2026-10-11 | trivial | Ribbon buttons that run a command (alignment, Bold, styles…) hand keyboard focus back to the document, so typing continues without another click; they also report their name and enabled state to screen readers (#686) | From feaff0f22b41515e85c14f9ac79f9009d4091f4e Mon Sep 17 00:00:00 2001 From: pechnikov2026-star Date: Mon, 12 Oct 2026 07:38:53 -0300 Subject: [PATCH 32/88] i18n: add French (fr) interface (#729) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * i18n: add French (fr) interface A complete 1813-entry catalog with the same key set and order as ru.tsv (the most complete reference), written from the meaning of the English source. Registered in LANGUAGES after Turkish. The locale-resolution tests that used fr-FR as the example of a language without a catalog now assert the French mapping and use Italian, which has no catalog, to keep testing the unsupported path. * i18n: complete the French catalog and apply review fixes Rebase onto main (1805 uk.tsv keys, up from 1812 rows at the original PR): 357 new translations for the charts, Draw, content controls, Caption/Index/TOC dialogs and formatting features that landed after the PR was opened. Also applied the reviewer's five fixes: Bring Forward → Avancer d'un niveau, Print Layout → Mode Page, Not used by any command → N'est utilisé par aucune commande, Through → Au travers, and consistent U+2019 apostrophes throughout. * Review fixes for #729: attribution, French tests, language lists - ATTRIBUTION row for fr.tsv, in the form the other catalogs use. - French locale/saved-preference test and bundled-font coverage test (accents, œ, « », ’, and the no-break spaces in Inter). - Tests that need a language without a catalog use a private-use tag (qaa-QM, qtz) instead of it-IT/de-DE, so they keep passing as real languages land. - 11 French rows for commands that landed on main since (templates, styles organizer, Overtype, Move with Text), which every_tab_and_command_is_translated requires. - fr in docs/control-protocol.md, README and localization-parity. Co-Authored-By: Claude Opus 5.5 * Review fixes for #729: French rows for Outline view and SmartArt 34 rows for the Outlining and SmartArt Design tabs and their commands, which landed on main meanwhile and every_tab_and_command_is_translated requires; 1,850 entries. Co-Authored-By: Claude Opus 5.5 * French: translate interface strings added on main since this PR Adds 174 French entries (fr.tsv now covers every key in the other catalogs, 2,023 entries) and drops the stale "Wildcards (regex)" row; updates the documented entry count. Co-Authored-By: Claude Opus 5.5 * French: translate interface strings added on main since this PR Adds the 10 strings main gained with Polish proofing (spelling and grammar messages, Do Not Check Spelling or Grammar, comment/browse buttons): 2,033 entries; documented count updated. Co-Authored-By: Claude Opus 5.5 * French: strings main added since the last merge (default app, numerals, KeyTips, Reading Mode) --------- Co-authored-by: sanches Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ATTRIBUTION.md | 1 + README.md | 2 +- crates/ui-egui/src/i18n/fr.tsv | 2063 ++++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/mod.rs | 4 +- crates/ui-egui/src/i18n/tests.rs | 91 +- docs/control-protocol.md | 2 +- docs/localization-parity.md | 9 +- 7 files changed, 2151 insertions(+), 21 deletions(-) create mode 100644 crates/ui-egui/src/i18n/fr.tsv diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index b218db017..8ba915605 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -70,3 +70,4 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra | `crates/ui-egui/src/i18n/de.tsv` | @arekown with Kimi | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | German interface catalog | | `crates/ui-egui/src/i18n/ru.tsv` | Dmitrii Khlapov with Claude | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Russian interface catalog; reviewed by a native speaker | | `crates/ui-egui/src/i18n/tr.tsv` | @PbyLoyth with Codex and Claude Code | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Turkish interface catalog | +| `crates/ui-egui/src/i18n/fr.tsv` | @pechnikov2026-star | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | French interface catalog | diff --git a/README.md b/README.md index d7a4bf720..706786935 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ Every screenshot below is WordCraft itself, rendered offscreen by its own UI tes | **References** | Table of contents, footnotes and endnotes, citations and bibliography (APA, MLA, Chicago, IEEE), captions, table of figures, cross-references, index, table of authorities | | **Review** | Spelling and grammar with suggestions (English and Polish, by each run's language), thesaurus, word count, comments in margin balloons or a pane, track changes, accept/reject, compare documents, restrict editing, accessibility checker, document inspector | | **Mailings** | Mail merge from CSV, merge fields, address block, greeting line, rules, preview, finish to a document; envelopes and labels | -| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский, Italiano, Deutsch, Polski or Čeština (follows the system language by default) | +| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский, Italiano, Deutsch, Polski, Čeština or Français (follows the system language by default) | | **Files** | .docx read/write (opens in Word), Word 97-2003 .doc import, PDF export, .odt, .rtf, .html, .md, .tex, .txt import/export, page images | The honest picture, area by area, is in [ROADMAP.md](ROADMAP.md), the diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv new file mode 100644 index 000000000..9e5f797d2 --- /dev/null +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -0,0 +1,2063 @@ +# French (fr) interface catalog. Columns, separated by a TAB: +# context English source translation +# context usually empty. `preview` words sample text inside rendered previews (the Design +# tab's style-set tiles), which must fit a small tile; it falls back to the plain entry. +# source the English text exactly as in the code (tl!(...), ribbon labels, command labels) +# escapes \n \t \\ inside a column. `{name}` placeholders must appear in both columns, and a +# trailing "…" in one needs one in the other. +# Lines starting with # and blank lines are ignored. A missing entry shows in English. +# +# French translations, written from the English source's meaning (clean-room, not copied +# from any product's localization). License: MIT OR Apache-2.0. + (no style) (Aucun style) + (no suggestions) (aucune suggestion) + 1.5 Line Spacing Interligne 1,5 + A free, open-source word processor written from scratch in Rust.\nPart of the Crafting Apps from the ArtCraft team. Un traitement de texte gratuit et open source, écrit de zéro en Rust.\nPartie de la suite Crafting Apps de l’équipe ArtCraft. + About À propos + About WordCraft À propos de WordCraft + Accept Accepter + Accept All Changes Accepter toutes les modifications + Accept This Change Accepter cette modification + Accessibility Accessibilité + Add Ajouter + Add a comment Ajouter un commentaire + Add a Table Ajouter un tableau + Add Object to Selection Ajouter l’objet à la sélection + Add Space Before Paragraph Ajouter un espace avant le paragraphe + Add space between Asian and Latin text Ajouter un espace entre le texte asiatique et le texte latin + Add space between Asian text and numbers Ajouter un espace entre le texte asiatique et les chiffres + Add tab stop at: Ajouter un taquet de tabulation à : + Add Text Ajouter du texte + Add to Dictionary Ajouter au dictionnaire + Add to Style Gallery Ajouter à la galerie de styles + Address Block Bloc d’adresse + Address: Adresse : + Address\nBlock Bloc\nd’adresse + Adjust Ajuster + Advanced Avancé + Advanced Find Recherche avancée + Advanced Find… Recherche avancée… + After: Après : + Agents Agents + Align Aligner + Align Left Aligner à gauche + Align Right Aligner à droite + Aligned at: Aligné à : + Alignment Alignement + All Borders Toutes les bordures + All Caps Majuscules + All caps Majuscules + All Markup Toutes les marques + Allow hanging punctuation Autoriser la ponctuation suspendue + Allow Latin text to wrap in the middle of a word Autoriser la coupure du texte latin au milieu d’un mot + Alt Text Texte de remplacement + Apply Asian line-breaking rules (kinsoku) Appliquer les règles de coupure de lignes asiatiques (kinsoku) + Apply Character Style Appliquer le style de caractère + Apply Style Appliquer le style + Arrange Organiser + Arrange\nAll Organiser\ntout + Arrow Flèche + Artistic Effects Effets artistiques + Asian Typography Typographie asiatique + Author Auteur + AutoCorrect Options Options de correction automatique + AutoFit Ajustement automatique + AutoFit Contents Ajustement automatique au contenu + AutoFit Window Ajustement automatique à la fenêtre + Automatic Automatique + Automatic ({language}) Automatique ({language}) + Automatic follows your system's language. Menus and commands change; your documents don't. Le mode Automatique suit la langue de votre système. Les menus et les commandes changent, mais pas vos documents. + Automatic Table 1 (3 levels) Table automatique 1 (3 niveaux) + Automatic Table 2 (2 levels) Table automatique 2 (2 niveaux) + AutoSave Enregistrement automatique + AutoSave saves every change to the file (needs a saved document) L’enregistrement automatique enregistre chaque modification dans le fichier (nécessite un document enregistré) + AutoSave isn't available in the browser: it can't write to your files. Save downloads a copy. L’enregistrement automatique n’est pas disponible dans le navigateur : il ne peut pas écrire dans vos fichiers. La commande Enregistrer télécharge une copie. + AutoSave only keeps .docx, .odt and .rtf files; {name} can't keep all formatting. Save as .docx to turn on AutoSave. L’enregistrement automatique ne prend en charge que les fichiers .docx, .odt et .rtf ; {name} ne peut pas conserver toute la mise en forme. Enregistrez au format .docx pour activer l’enregistrement automatique. + Balloons Bulles + Bullet Puce + By author Par auteur + Changed lines: Lignes modifiées : + Click level to modify: Cliquez sur le niveau à modifier : + Color only Couleur uniquement + Define New Multilevel List Définir une nouvelle liste à plusieurs niveaux + Define New Multilevel List… Définir une nouvelle liste à plusieurs niveaux… + Deletions: Suppressions : + Enter formatting for number: Entrez la mise en forme des numéros : + Follow number with: Faire suivre le numéro de : + Formatting Mise en forme + Formatting exceptions Exceptions de mise en forme + Include level number from: Inclure le numéro de niveau de : + Ink Encre + Insertions and Deletions Insertions et suppressions + Insertions: Insertions : + Inside: Intérieur : + Left border Bordure gauche + Legal style numbering Numérotation de style juridique + Link level to style: Lier le niveau au style : + List Definition Définition de liste + Mirror indents Retraits en miroir + Nothing Rien + Number alignment: Alignement des numéros : + Number format Format des numéros + Number style for this level: Style de numéros pour ce niveau : + Outside border Bordure extérieure + Overtype Remplacement + Outside: Extérieur : + Restart list after: Redémarrer la liste après : + Right border Bordure droite + Save the document to turn on AutoSave Enregistrez le document pour activer l’enregistrement automatique + Save the document to turn on AutoSave (a file you have only opened isn't rewritten until you save it) Enregistrez le document pour activer l’enregistrement automatique (un fichier que vous avez simplement ouvert n’est pas réécrit tant que vous ne l’enregistrez pas) + AutoText Texte automatique + Backspace Retour arrière + Banded Columns Colonnes à bandes + Banded Rows Lignes à bandes + Base text: Texte de base : + Before: Avant : + Bibliography Bibliographie + Binary assets added Ressources binaires ajoutées + Binary assets removed Ressources binaires supprimées + Blank Vide + Blank (Three Columns) Vide (trois colonnes) + Blank document Document vierge + Blank Page Page vierge + Block\nAuthors Bloquer\nles auteurs + Bold Gras + Bookmark Signet + Bookmark name: Nom du signet : + Border Painter Pinceau de bordure + Border\nPainter Pinceau\nde bordure + Borders Bordures + Tab stop position: Position du taquet de tabulation : + Default tab stops: Taquets de tabulation par défaut : + Tab stops to be cleared: Taquets de tabulation à supprimer : + All Tout + Decimal Décimal + Bar Barre + Leader Point de suite + Set Définir + Page Border Bordure de page + Apply to: Appliquer à : + Whole document Tout le document + This section Cette section + Fill Remplissage + Setting: Type : + 3-D 3-D + Custom Personnalisé + Style: Style : + Dot dash Tiret-point + Triple Triple + Between Entre + Borders and Shading Bordure et trame + Tabs… Tabulations… + Borders and Shading… Bordure et trame… + Bottom Border Bordure basse + Bottom Margin Marge basse + Bottom of Page Bas de page + Bottom: Bas : + Box Encadré + Breaks Sauts + Bring Forward Avancer d’un niveau + Bullet Library Galerie de puces + Bullets Puces + Calendar Calendrier + Cancel Annuler + Capitalize Each Word Première lettre des mots en majuscule + Captions Légendes + Cell Margins Marges de cellule + Cell Size Taille de cellule + Cell\nMargins Marges\nde cellule + Center Centrer + Change Modifier + Change Case Modifier la casse + Change List Level Modifier le niveau de liste + Change Picture Modifier l’image + Change Shape Modifier la forme + Changes Modifications + Character Position Position des caractères + Character Scale Échelle des caractères + Character Shading Trame de fond des caractères + Character Spacing Espacement des caractères + Characters Caractères + Characters (no spaces) Caractères (sans espaces) + Characters (with spaces) Caractères (avec espaces) + Chart Graphique + Check Accessibility Vérifier l’accessibilité + Check Compatibility Vérifier la compatibilité + Check for Errors Vérifier les erreurs + Check Spelling as You Type Vérifier l’orthographe pendant la saisie + Check\nAccessibility Vérifier\nl’accessibilité + Citation Style Style de citation + Citations & Bibliography Citations et bibliographie + Clear Effacer + Clear All Effacer tout + Clear All Formatting Effacer toute la mise en forme + Clear Character Formatting Effacer la mise en forme des caractères + Clear Character Style Effacer le style de caractère + Clear Formatting Level Effacer le niveau de mise en forme + Clear Paragraph Formatting Effacer la mise en forme de paragraphe + Click a border between two cells to merge them. Esc stops. Cliquez sur une bordure entre deux cellules pour les fusionner. Appuyez sur Échap pour arrêter. + Clipboard Presse-papiers + Clipboard Pane Volet Presse-papiers + Clipboard Items Éléments du Presse-papiers + Paste Clipboard Item Coller l’élément du Presse-papiers + Paste All Coller tout + Delete Clipboard Item Supprimer l’élément du Presse-papiers + Click an item to paste it. Cliquez sur un élément pour le coller. + Nothing collected yet. Items you copy or cut appear here. Aucun élément collecté pour l’instant. Les éléments que vous copiez ou coupez apparaissent ici. + (picture or object) (image ou objet) + Close Fermer + Close Header and Footer Fermer l’en-tête et le pied de page + Collapse Selection Réduire la sélection + Color Couleur + Color: Couleur : + Colors Couleurs + Column Colonne + Column Break Saut de colonne + Columns Colonnes + Combine Combiner + Commands Commandes + Comment Commentaire + Comments Commentaires + Comments Pane Volet Commentaires + Community\nDiscord Communauté\nDiscord + Compact Compact + Compare Comparer + Compress Pictures Compresser les images + Compress punctuation at the start of a line Compresser la ponctuation en début de ligne + Continuous Continu + Convert Convertir + Convert Text to Table Convertir le texte en tableau + Convert Text to Table… Convertir le texte en tableau… + Convert to Text Convertir en texte + Copy Copier + Copy Formatting Copier la mise en forme + Corrections Corrections + Count words in text boxes and notes Compter les mots dans les zones de texte et les notes + Cover Page Page de garde + Create Créer + Create a Style Créer un style + Create an interactive outline of your document.\n\nIt's a great way to keep track of where you are or quickly move your content around.\n\nTo get started, go to the Home tab and apply Heading styles to the headings in your document. Créez un plan interactif de votre document.\n\nC’est un excellent moyen de savoir où vous en êtes ou de déplacer rapidement votre contenu.\n\nPour commencer, accédez à l’onglet Accueil et appliquez les styles Titre aux titres de votre document. + Create New Style Créer un nouveau style + Crop Rogner + Cross-reference Renvoi + Current Position Position actuelle + Current style: {style} Style actuel : {style} + Custom Margins… Marges personnalisées… + Custom Watermark Filigrane personnalisé + Custom Watermark… Filigrane personnalisé… + Customize Quick Access Toolbar Personnaliser la barre d’outils Accès rapide + Cut Couper + Dark Mode Mode sombre + Dashed Tirets + Data Données + Date & Time Date et heure + Decrease Font Size Réduire la taille de la police + Decrease Indent Diminuer le retrait + Default Par défaut + Define New Bullet Définir une nouvelle puce + Define New Number Format Définir un nouveau format de numérotation + Delete Supprimer + Delete Cells Supprimer les cellules + Delete Columns Supprimer les colonnes + Delete Comment Supprimer le commentaire + Delete Next Word Supprimer le mot suivant + Delete Previous Word Supprimer le mot précédent + Delete Rows Supprimer les lignes + Delete Style Supprimer le style + Delete Table Supprimer le tableau + Design Création + Drag a rectangle to draw a table, or a line across cells to split them. Esc stops. Faites glisser un rectangle pour dessiner un tableau, ou une ligne à travers les cellules pour les fractionner. Appuyez sur Échap pour arrêter. + Draw Table Dessiner un tableau + Edit Comment Modifier le commentaire + Left Margin Marge gauche + Indents and Spacing Retraits et espacement + Line breaking Coupure de lignes + More templates → Plus de modèles → + No templates match your search. Aucun modèle ne correspond à votre recherche. + Reopen Rouvrir + Right Margin Marge droite + Distribute letters Répartir les lettres + Distribute spacing Répartir l’espacement + Offset (pt): Décalage (pt) : + Phonetic Guide Guide phonétique + Remove Supprimer + Right (vertical) Droite (vertical) + Ruby text: Texte ruby : + Search for templates Rechercher des modèles + development build version de développement + Diagonal Diagonale + Diamond Losange + Dictate Dicter + Different First Page Première page différente + Different Odd & Even Pages Pages paires et impaires différentes + Direct formatting Mise en forme directe + Discord Discord + Discord community Communauté Discord + Display Affichage + Display for Review Affichage pour la révision + Distribute Columns Répartir les colonnes + Distribute Rows Répartir les lignes + Distributed Distribué + Do Not Show in TOC Ne pas afficher dans la table des matières + Document End Fin du document + Document Formatting Mise en forme du document + Document Property Propriété du document + Document Start Début du document + Document Text Texte du document + Document Title Titre du document + Documents you open will show up here. Les documents que vous ouvrez s’affichent ici. + Dot-dash Tiret-point + Dotted Pointillé + Double Double + Double Spacing Interligne double + Double Strikethrough Double barré + Double Underline Double soulignement + Down Vers le bas + Draft Brouillon + Draw Dessin + Drawing Tools Outils de dessin + Drawing\nCanvas Canevas\nde dessin + Drop Cap Lettrine + Drops all formatting and pastes plain text. Supprime toute la mise en forme et colle du texte brut. + Edit Footer Modifier le pied de page + Edit Header Modifier l’en-tête + Edit Recipient List Modifier la liste des destinataires + Edit\nRecipient List Modifier\nla liste des destinataires + Edited Modifié + Editing Modification + Editing header/footer Modification de l’en-tête/pied de page + Editing — edit the document directly Modification — modifier directement le document + Editor Éditeur + Effects Effets + Emboss Relief + Encrypt with Password Chiffrer avec un mot de passe + English (United States) Anglais (États-Unis) + Engrave Gravure + Enter page number: Entrez le numéro de page : + Envelopes Enveloppes + Equation Équation + Eraser Gomme + Even Page Page paire + Every command is available to scripts and AI agents: run `wordcraft-cli mcp` for an MCP server, or start the app with `--control ` for the JSON control channel. Chaque commande est disponible pour les scripts et les agents IA : exécutez `wordcraft-cli mcp` pour un serveur MCP, ou démarrez l’application avec `--control ` pour le canal de contrôle JSON. + Export Exporter + Export a copy to share (PDF, Word document) Exporter une copie à partager (PDF, document Word) + Export Page as PNG Exporter la page en PNG + Export PDF Exporter en PDF + Exported {path} {path} exporté + Extend Selection Étendre la sélection + Field Champ + File Fichier + Find Rechercher + Find and Replace Rechercher et remplacer + Find Next Suivant + Find Previous Précédent + Find Recipient Rechercher un destinataire + Find what: Rechercher : + Finish Terminer + Finish & Merge Terminer et fusionner + Finish &\nMerge Terminer et\nfusionner + First Column Première colonne + First commit Premier commit + First line: Première ligne : + Fixed Column Width Largeur de colonne fixe + Focus Focus + Font Police + Font Color Couleur de police + Font color: Couleur de police : + Font Dialog Boîte de dialogue Police + Font Size Taille de police + Font: Police : + Fonts Polices + Footer Pied de page + Footnote and Endnote Notes de bas de page et de fin + Footnotes Notes de bas de page + Format Format + Format Page Numbers Format numéros de page + Format Page Numbers… Format numéros de page… + Format Painter Reproduire la mise en forme + Formatted Text Texte mis en forme + Formatting at Selection Mise en forme de la sélection + Formula Formule + General Général + Go To Atteindre + Go to Atteindre + Go To (⌘⌥G) Atteindre (⌘⌥G) + Go To… Atteindre… + Grab bag Divers + Greeting Line Ligne de salutation + Greeting\nLine Ligne de\nsalutation + Gridlines Quadrillage + Group Grouper + Grow Font 1 Point Agrandir la police de 1 point + Hanging Indent Retrait suspendu + Header En-tête + Header & Footer En-tête et pied de page + Header Row Ligne d’en-tête + Header/Footer Position Position en-tête/pied de page + Heading 1 Titre 1 + Heading 2 Titre 2 + Heading 3 Titre 3 + Headings Titres + Heart Cœur + Help Aide + Hidden Masqué + Highlight Merge Fields Mettre en surbrillance les champs de fusion + Highlight\nMerge Fields Mettre en surbrillance\nles champs de fusion + Highlighter Surligneur + Home Accueil + Horizontal Horizontale + Horizontal Line Ligne horizontale + HTML Format Format HTML + Hyphenation Césure + Icons Icônes + Ignore All Ignorer tout + Illustrations Illustrations + Immersive Immersif + Immersive Reader Lecteur immersif + Immersive\nReader Lecteur\nimmersif + Increase Font Size Agrandir la police + Increase Indent Augmenter le retrait + Indent Retrait + Indentation Retrait + Indents Retraits + Index Index + Info Informations + Ink to\nMath Entrée manuscrite\nen équation + Ink to\nShape Entrée manuscrite\nen forme + Ink\nReplay Relecture\nmanuscrite + Insert Insertion + Insert Above Insérer au-dessus + Insert Below Insérer en dessous + Insert Caption Insérer une légende + Insert Citation Insérer une citation + Insert Endnote Insérer une note de fin + Insert Footnote Insérer une note de bas de page + Insert Hyperlink Insérer un lien hypertexte + Insert Index Insérer un index + Insert Left Insérer à gauche + Insert Merge Field Insérer un champ de fusion + Insert Merge\nField Insérer un champ\nde fusion + Insert Right Insérer à droite + Insert Shapes Insérer des formes + Insert Table Insérer un tableau + Insert Table of Authorities Insérer une table des autorités + Insert Table of Figures Insérer une table des illustrations + Insert Table… Insérer un tableau… + Insert\nAbove Insérer\nau-dessus + Insert\nCaption Insérer\nune légende + Insert\nCitation Insérer\nune citation + Insert\nFootnote Insérer une\nnote de bas de page + Inside Borders Bordures intérieures + Inspect Document Vérifier le document + Inspect Styles at Selection Inspecter les styles de la sélection + Interface language: Langue de l’interface : + Italic Italique + Join the ArtCraft community on Discord Rejoignez la communauté ArtCraft sur Discord + Join us on Discord: discord.gg/artcraft Rejoignez-nous sur Discord : discord.gg/artcraft + Justify Justifier + Keep Lines Together Lignes solidaires + Keep lines together Lignes solidaires + Keep Text Only Conserver le texte uniquement + Keep with Next Paragraphes solidaires + Keep with next Paragraphes solidaires + Keeps the fonts and formatting of the copied text. Conserve les polices et la mise en forme du texte copié. + Labels Étiquettes + Landscape Paysage + Language Langue + Lasso Lasso + Last Column Dernière colonne + Last commit Dernier commit + Layout Mise en page + Layout Summary Résumé de la mise en page + Left Gauche + Left Border Bordure gauche + Left: Gauche : + Letter Lettre + Line Ligne + Line and Page Breaks Enchaînements + Line and Paragraph Spacing Espacement des lignes et des paragraphes + Line Break Saut de ligne + Line delta Écart de lignes + Line End Fin de ligne + Line Numbers Numéros de ligne + Line Spacing Interligne + Line Spacing Options… Options d’interligne… + Line spacing: Interligne : + Line Start Début de ligne + Lines Lignes + Lines added Lignes ajoutées + Lines deleted Lignes supprimées + Link Lien + Link to Previous Identique au précédent + Links Liens + Location: {path} Emplacement : {path} + lowercase minuscules + Macros Macros + Mailings Publipostage + Manage Sources Gérer les sources + Many pages Nombreuses pages + Margins Marges + Mark Citation Marquer une citation + Mark Entry Marquer l’entrée + Mark\nCitation Marquer une\ncitation + Mark\nEntry Marquer\nl’entrée + Markdown (*.md) Markdown (*.md) + Match case Respecter la casse + Match Fields Faire correspondre les champs + Matrix Matrice + Merge Fusionner + Merge Cells Fusionner les cellules + Merge Formatting Fusionner la mise en forme + Merged PRs PR fusionnées + Mirrored Miroir + MIT OR Apache-2.0. Copyright (c) 2026 ArtCraft Team and the WordCraft contributors. MIT OR Apache-2.0. Copyright (c) 2026 ArtCraft Team et les contributeurs de WordCraft. + Moderate Modérées + Modify Style Modifier le style + Modify… Modifier… + Move or Resize Déplacer ou redimensionner + Multilevel List Liste à plusieurs niveaux + Multiple Pages Plusieurs pages + Name (A–Z) Nom (A–Z) + Name: Nom : + Narrow 0.5" all Étroites — 0,5" partout + Navigation Navigation + Navigation Pane Volet de navigation + New Nouveau + New Comment Nouveau commentaire + New from Template Nouveau à partir d’un modèle + New Paragraph Nouveau paragraphe + New Style… Nouveau style… + New Window Nouvelle fenêtre + New\nComment Nouveau\ncommentaire + New\nWindow Nouvelle\nfenêtre + Next Suivant + Next Change Modification suivante + Next Comment Commentaire suivant + Next Footnote Note de bas de page suivante + Next Page Page suivante + Next Record Enregistrement suivant + Next Section Section suivante + No Border Sans bordure + No Color Aucune couleur + No contributor data was built into this copy. Aucune donnée de contributeur n’a été intégrée à cette copie. + No Markup Aucune marque + No model credits were built into this copy. Aucune mention de modèles n’a été intégrée à cette copie. + No Paragraph Space Sans espacement entre les paragraphes + Nonbreaking Hyphen Tiret insécable + Nonbreaking Space Espace insécable + None Aucun + Normal 1" all Normales — 1" partout + Normal Style Style Normal + Not available right now Indisponible pour le moment + Not saved Non enregistré + Not saved yet Pas encore enregistré + Nudge Déplacer légèrement + Number of columns: Nombre de colonnes : + Number of rows: Nombre de lignes : + Numbering Numérotation + Object Objet + Odd Page Page impaire + Office 2003 Default Par défaut Office 2003 + OK OK + One Un + One Page Une page + Open Ouvrir + OpenDocument Text (*.odt) Texte OpenDocument (*.odt) + Optional Hyphen Tiret conditionnel + Copyright Sign Symbole de copyright + Ellipsis Points de suspension + Em Dash Tiret cadratin + Insert Date Field Insérer un champ de date + Insert Page Field Insérer un champ de numéro de page + Insert Time Field Insérer un champ d’heure + Registered Sign Signe de marque déposée + Reset Character Formatting Réinitialiser la mise en forme des caractères + Reset Paragraph Formatting Réinitialiser la mise en forme des paragraphes + Trademark Sign Signe de marque commerciale + Options Options + Orientation Orientation + Outline Plan + Outline Level Niveau de plan + Outside Borders Bordures extérieures + Oval Ovale + Page Page + Page Background Arrière-plan de page + Page Borders Bordures de page + Page Break Saut de page + Page Break Before Saut de page avant + Page break before Saut de page avant + Page Breaks Sauts de page + Page Color Couleur de page + Page Down Une page vers le bas + Page image (*.png) Image de page (*.png) + Page Movement Déplacement par page + Page Number Numéro de page + Page Setup Mise en page + Page Up Une page vers le haut + Page Width Largeur de page + Page width Largeur de page + Page X of Y Page X sur Y + Page {page} Page {page} + Page {page} of {pages} Page {page} sur {pages} + Page\nBorders Bordures\nde page + Page\nColor Couleur\nde page + Page\nNumber Numéro\nde page + Pages Pages + Paragraph Paragraphe + Paragraph Details Détails du paragraphe + Paragraph Down Un paragraphe vers le bas + Paragraph Settings Paramètres de paragraphe + Paragraph Spacing Espacement des paragraphes + Paragraph Up Un paragraphe vers le haut + Paragraphs Paragraphes + Paste Coller + Paste (⌘V) Coller (⌘V) + Paste as: Coller en tant que : + Paste Formatting Coller la mise en forme + Paste Special Collage spécial + Paste Special… Collage spécial… + Paste: Keep Text Only Collage : conserver le texte uniquement + Paste: Merge Formatting Collage : fusionner la mise en forme + PDF document (*.pdf) Document PDF (*.pdf) + Pen Stylo + Pencil Crayon + Picture Border Bordure de l’image + Picture Format Format de l’image + Picture Styles Styles d’image + Pictures Images + Plain text (*.txt) Texte brut (*.txt) + Portrait Portrait + Position Position + Preview Results Aperçu des résultats + Preview\nResults Aperçu des\nrésultats + Previous Précédent + Previous Change Modification précédente + Previous Comment Commentaire précédent + Previous Record Enregistrement précédent + Previous Section Section précédente + Print Imprimer + Print Layout Mode Page + Print\nLayout Mise\nen page + Proofing Vérification + Proofing Issues Problèmes de vérification + Properties Propriétés + Protect Protection + Protect Document Protéger le document + Quick Access Toolbar Barre d’outils Accès rapide + Quick Parts Briques de contenu + Quick Tables Tableaux rapides + Read Aloud Lire à voix haute + Read Mode Mode Lecture + Read\nAloud Lire\nà voix haute + Read\nMode Mode\nLecture + Reads the clipboard's rich text and keeps its formatting. Lit le texte enrichi du presse-papiers et conserve sa mise en forme. + Reads the clipboard's web content and keeps its formatting. Lit le contenu web du presse-papiers et conserve sa mise en forme. + Reading Highlight Surbrillance de lecture + Recent Récents + Record Macro Enregistrer une macro + Recover Unsaved Documents Récupérer les documents non enregistrés + Rectangle Rectangle + Redo Rétablir + Reduce Hanging Indent Réduire le retrait suspendu + References Références + Reject Refuser + Reject All Changes Refuser toutes les modifications + Reject Change Refuser la modification + Relaxed Détendu + Remove Background Supprimer l’arrière-plan + Remove Footer Supprimer le pied de page + Remove Header Supprimer l’en-tête + Remove Hyperlink Supprimer le lien hypertexte + Remove Space After Paragraph Supprimer l’espace après le paragraphe + Remove Table of Contents Supprimer la table des matières + Remove Watermark Supprimer le filigrane + Repeat Répéter + Repeat Header Rows Répéter les lignes d’en-tête + Replace Remplacer + Replace All Remplacer tout + Replace Document Text Remplacer le texte du document + Replace with: Remplacer par : + Replay Rejouer + Reply Répondre + Report Rapport + Research Recherche + Researcher Chercheur + Reset Picture Réinitialiser l’image + Reset to Normal Réinitialiser à la normale + Resolve Résoudre + Resolved Résolu + Restart at 1 Recommencer à 1 + Restart Each Page Recommencer à chaque page + Restart Each Section Recommencer à chaque section + Restrict Editing Restreindre la modification + Restrict\nEditing Restreindre\nla modification + Result Résultat + Results Résultats + Reverse the order Inverser l’ordre + Review Révision + Reviewing Révision + Reviewing Pane Volet Révision + Reviewing — edits become suggestions Révision — les modifications deviennent des suggestions + Rich Text Format (*.rtf) Format RTF (*.rtf) + Rich Text Format (RTF) Format RTF (Rich Text Format) + Right Droite + Right Border Bordure droite + Right-to-Left Text Direction Sens du texte de droite à gauche + Left-to-Right Text Direction Sens du texte de gauche à droite + Right: Droite : + Rotate Faire pivoter + Rounded Rectangle Rectangle arrondi + Rows & Columns Lignes et colonnes + Ruler Règle + Rules Règles + Résumé CV + Save Enregistrer + Save a copy in another format. Printing goes through a PDF you can print from any viewer. Enregistrez une copie dans un autre format. L’impression passe par un PDF que vous pouvez imprimer depuis n’importe quelle visionneuse. + Save As Enregistrer sous + Save as Template Enregistrer comme modèle + Saved Enregistré + Search Commands Rechercher des commandes + Search commands and help Rechercher des commandes et de l’aide + Search document Rechercher dans le document + Section Breaks Sauts de section + Section Properties Propriétés de la section + Sections Sections + Select Sélectionner + Select All Sélectionner tout + Select Cell Sélectionner la cellule + Select Line Sélectionner la ligne + Select Objects Sélectionner les objets + Select Paragraph Sélectionner le paragraphe + Select Range Sélectionner la plage + Select Recipients Sélectionner les destinataires + Select Row Sélectionner la ligne de tableau + Select Sentence Sélectionner la phrase + Select Table Sélectionner le tableau + Select Text Sélectionner le texte + Select Text with Similar Formatting Sélectionner le texte avec une mise en forme similaire + Select Word Sélectionner le mot + Select\nRecipients Sélectionner\nles destinataires + Selection Sélection + Selection Pane Volet Sélection + Send Backward Reculer d’un niveau + Sentence case. Comme une phrase. + Set as Default Définir par défaut + Set Caret Placer le point d’insertion + Set Character Formatting Définir la mise en forme des caractères + Set Numbering Value Définir la valeur de la numérotation + Set Paragraph Formatting Définir la mise en forme des paragraphes + Shading Trame de fond + Shadow Ombre + Shape Fill Remplissage de forme + Shape Format Format de la forme + Shape Outline Contour de forme + Shape Styles Styles de formes + Shapes Formes + Share Partager + Shift+Tab Shift+Tab + Show Afficher + Show all formatting marks Afficher toutes les marques de mise en forme + Show all revisions inline Afficher toutes les révisions dans le texte + Show Comments Afficher les commentaires + Show Markup Afficher les marques + Show Notes Afficher les notes + Show only comments and formatting in balloons Afficher uniquement les commentaires et la mise en forme dans les bulles + Show revisions in balloons Afficher les révisions dans les bulles + Show rulers Afficher les règles + Show the community button in the title bar Afficher le bouton Communauté dans la barre de titre + Show/Hide ¶ Afficher/Masquer ¶ + Show\nComments Afficher\nles commentaires + Shrink Font 1 Point Réduire la police d’1 point + Side to Side Côte à côte + Side\nto Side Côte\nà côte + Signature Line Ligne de signature + Single Simple + Single Spacing Interligne simple + Size Taille + Size (pt): Taille (pt) : + Size: Taille : + Small Caps Petites majuscules + Small caps Petites majuscules + SmartArt SmartArt + Sort Trier + Source code: github.com/storytold/wordcraft Code source : github.com/storytold/wordcraft + Space Espace + Space before/after: Espacement avant/après : + Spacing Espacement + Spacing (pt): Espacement (pt) : + Speech Parole + Spelling & Grammar Orthographe et grammaire + Spelling &\nGrammar Orthographe et\ngrammaire + Spelling Suggestions Suggestions orthographiques + Split Fractionner + Split Cells Fractionner les cellules + Split Table Fractionner le tableau + Spreadsheet Table Tableau de feuille de calcul + Standard Colors Couleurs standard + Star Étoile + Start at: Commencer à : + Start Mail Merge Démarrer la fusion et le publipostage + Start Mail\nMerge Démarrer la fusion et\nle publipostage + Statistics Statistiques + Status Bar Barre d’état + Strikethrough Barré + Studio cover Couverture du studio + Studio handbook (sample) Manuel du studio (exemple) + Style Style + Style based on: Style basé sur : + Style Inspector Inspecteur de styles + Style Set Jeu de styles + Style: {style} Style : {style} + Styles Styles + Styles Pane Volet Styles + Subscript Indice + Superscript Exposant + Switch Modes Changer de mode + Switch\nModes Changer\nde mode + Symbol Symbole + Symbols Symboles + Synonyms Synonymes + Tab Tabulation + Tab character Caractère de tabulation + Table Tableau + Table Column Width Largeur de colonne du tableau + Table Design Création de tableau + Table Layout Disposition du tableau + Table of Authorities Table des autorités + Table of Contents Table des matières + Table of\nContents Table des\nmatières + Table Properties Propriétés du tableau + Table Row Height Hauteur de ligne du tableau + Table Style Options Options de style de tableau + Table Styles Styles de tableau + Tables Tableaux + Tabs Tabulations + Tabular List Liste tabulaire + Text Texte + Text Box Zone de texte + Text Direction Sens du texte + Text Effects Effets de texte + Text Effects and Typography Effets de texte et typographie + Text from File Texte à partir d’un fichier + Text Highlight Color Couleur de surbrillance du texte + Text indent at: Retrait du texte : + Text to display: Texte à afficher : + Text Wrapping Habillage du texte + Text: Texte : + Text\nBox Zone de\ntexte + Text\nDirection Sens du\ntexte + The clipboard is empty. Le Presse-papiers est vide. + The new style takes the formatting of the current paragraph. Le nouveau style reprend la mise en forme du paragraphe actuel. + Theme Colors Couleurs du thème + Theme Fonts Polices du thème + Theme List Liste des thèmes + Themes Thèmes + Thesaurus Thésaurus + Thick Épaisse + Three Trois + Tight Rapproché + Top Border Bordure haute + Top Margin Marge supérieure + Top of Page Haut de page + Top: Haut : + Total Row Ligne de totaux + Track Changes Suivi des modifications + Track Changes Options Options de suivi des modifications + Track Changes: On Suivi des modifications : activé + Track formatting Suivre la mise en forme + Tracking Suivi + Translate Traduire + Transparency Transparence + Triangle Triangle + Two Deux + Type a command, e.g. "insert table" Tapez une commande, par exemple « insérer un tableau » + Type Text Saisir du texte + Underline Souligné + Underline Words Only Souligner uniquement les mots + Undo Annuler + Undo {action} Annuler {action} + Ungroup Dissocier + Unformatted Text Texte non mis en forme + Up Vers le haut + Update Field Mettre à jour le champ + Update Index Mettre à jour l’index + Update Style to Match Selection Mettre à jour le style pour qu’il corresponde à la sélection + Update Table Mettre à jour la table + Update Table of Figures Mettre à jour la table des illustrations + Update to Match Mettre à jour pour correspondre + Update to Match Selection Mettre à jour en fonction de la sélection + UPPERCASE MAJUSCULES + User Name Nom d’utilisateur + User name: Nom d’utilisateur : + Version History Historique des versions + Version {version} ({build}) Version {version} ({build}) + Vertical Vertical + Vertical Alignment Alignement vertical + View Affichage + View Gridlines Afficher les quadrillages + View State État de l’affichage + Views Affichages + Voice Voix + Watermark Filigrane + Wave Vague + Web Layout Mode Web + Web page (*.html) Page Web (*.html) + Web\nLayout Mode\nWeb + Whole page Page entière + Whole words Mots entiers + Wide Large + Widow/Orphan Control Contrôle des veuves et orphelines + Widow/Orphan control Contrôle des veuves et orphelines + Window Fenêtre + Word Count Nombre de mots + Word document (*.docx) Document Word (*.docx) + Word Left Mot vers la gauche + Word Right Mot vers la droite + WordArt WordArt + WordCraft WordCraft + Words Mots + Words only Mots uniquement + Wrap Text Habillage du texte + Wrap\nText Habillage\ndu texte + Write & Insert Fields Écriture et insertion de champs + Write a reply Écrire une réponse + Zoom Zoom + Zoom In Zoom avant + Zoom Out Zoom arrière + {group} settings Paramètres de {group} + {key}+Click to follow link {key}+clic pour suivre le lien + Browse… Parcourir… + {words} words {words} mots + {selected} of {words} words {selected} mots sur {words} + Apply Styles Appliquer les styles + Dark page (white text on black) Page sombre (texte blanc sur noir) + Show documents with their colours inverted, like View › Switch Modes. Saving, printing and PDFs are unchanged. Affiche les documents avec les couleurs inversées, comme dans Affichage › Changer de mode. L’enregistrement, l’impression et les PDF restent inchangés. + Only web (http, https) and email (mailto) links can be followed Seuls les liens Web (http, https) et les liens de messagerie (mailto) peuvent être suivis + Scroll ribbon left Faire défiler le ruban vers la gauche + Scroll ribbon right Faire défiler le ruban vers la droite + Character Border Bordure de caractère + Direction: Sens : + Right-to-left De droite à gauche + Left-to-right De gauche à droite + Zotero Zotero + Citations Citations + Document Document + Add/Edit\nCitation Ajouter/Modifier\nune citation + Add\nNote Ajouter\nune note + Add/Edit\nBibliography Ajouter/Modifier\nune bibliographie + Refresh Actualiser + Document\nPreferences Préférences\ndu document + Unlink\nCitations Dissocier\nles citations + Waiting for Zotero… En attente de Zotero… + Zotero: done Zotero : terminé + Zotero: cancelled Zotero : annulé + Yes Oui + No Non + Move Past Citation Passer la citation + Move Past\nCitation Passer\nla citation + Next Sentence Phrase suivante + Previous Sentence Phrase précédente + Next sentence Phrase suivante + Previous sentence Phrase précédente + Play/Pause Lecture/Pause + Read Aloud Status État de la lecture à voix haute + Reading Speed Vitesse de lecture + Skip Citations and Bibliography Ignorer les citations et la bibliographie + Skip citations & bibliography Ignorer les citations et la bibliographie + Stop Reading Arrêter la lecture + Don't read Zotero, Mendeley or EndNote citations and bibliographies Ne pas lire les citations et les bibliographies Zotero, Mendeley ou EndNote + Play Lecture + Pause Pause + Stop Arrêter + Sentence Phrase + Paused En pause + Finished Terminé + Stopped Arrêté + Speed Vitesse + Normal speed Vitesse normale + AutoSave documents you have saved in WordCraft Enregistrement automatique des documents que vous avez enregistrés dans WordCraft + AutoSave failed: {error}. Save the document to turn AutoSave back on. Échec de l’enregistrement automatique : {error}. Enregistrez le document pour réactiver l’enregistrement automatique. + Couldn't download {name}: {error} Impossible de télécharger {name} : {error} + Do you want to save changes to {name}? Voulez-vous enregistrer les modifications apportées à {name} ? + Don't Save Ne pas enregistrer + That format doesn't keep everything, so the document is still open. Save it as a Word document (.docx) to go on. Ce format ne conserve pas tout, le document reste donc ouvert. Enregistrez-le en tant que document Word (.docx) pour continuer. + Your changes will be lost if you don't save them. Vos modifications seront perdues si vous ne les enregistrez pas. + Change Structure Modifier la structure + Change to Display Passer en mode hors texte + Change to Inline Passer en mode en ligne + Conversions Conversions + Delete Back in Equation Supprimer en arrière dans l’équation + Delete in Equation Supprimer dans l’équation + Edit Equation Modifier l’équation + Enter in Equation Enter dans l’équation + Equation Gallery Galerie d’équations + Equation Info Informations sur l’équation + Equation Number Numéro d’équation + Input Format Format d’entrée + Insert Structure Insérer une structure + Insert Symbol Insérer un symbole + Justification Justification + Leave Equation Quitter l’équation + Move in Equation Déplacement dans l’équation + Normal Text Texte normal + Set Equation Définir l’équation + Structure Actions Actions de structure + Structures Structures + Tools Outils + Type in Equation Saisie dans l’équation + Inline En ligne + Number Numéro + Close Equation Fermer l’équation + Insert New Equation Insérer une nouvelle équation + Built-In Intégrées + Professional Professionnel + Linear Linéaire + Fraction Fraction + Script Script + Radical Radical + Integral Intégrale + Large\nOperator Grand\nopérateur + Bracket Crochets + Function Fonction + Accent Accent + Limit and\nLog Limite et\nlog + Operator Opérateur + Equation Options Options d’équation + All symbol sets Tous les jeux de symboles + Send the document straight to the system print dialog, or save a copy in another format below. Envoyez le document directement à la boîte de dialogue d’impression du système, ou enregistrez une copie dans un autre format ci-dessous. + Or save a copy in another format: Ou enregistrez une copie dans un autre format : + Opening print dialog… Ouverture de la boîte de dialogue d’impression… + Print failed: {error} Échec de l’impression : {error} + Couldn't open {path}: {error} Impossible d’ouvrir {path} : {error} + Column Selection Sélection de colonne + New Table Style Nouveau style de tableau + Modify Table Style Modifier le style de tableau + New Table Style… Nouveau style de tableau… + Modify Table Style… Modifier le style de tableau… + Apply formatting to: Appliquer la mise en forme à : + Whole Table Tableau entier + Fill color: Couleur de remplissage : + {count} items highlighted {count} éléments en surbrillance + {n} pt {n} pt + Not {name} Sans {name} + Level {n} Niveau {n} + Body Text Corps de texte + {n} lines {n} lignes + At least {n} Au moins {n} + Exactly {n} Exactement {n} + Centered Centré + Character spacing Espacement des caractères + Don't add space between paragraphs of the same style Ne pas ajouter d’espace entre les paragraphes de même style + Don't hyphenate Ne pas couper les mots + Double strikethrough Double barré + Drop cap Lettrine + First line Première ligne + Font color Couleur de police + Highlight Surlignage + Justified Justifié + Left indent Retrait gauche + Line spacing Interligne + Outline level Niveau de plan + Raised/lowered Élevé/abaissé + Right indent Retrait droit + Scale Échelle + Space after Espace après + Space before Espace avant + Suppress line numbers Ne pas afficher les numéros de ligne + Underline color Couleur de soulignement + Letters Lettres + E-mail Messages Messages électroniques + Directory Répertoire + Normal Word Document Document Word normal + If…Then…Else… Si…Alors…Sinon… + If…Then…Else Si…Alors…Sinon + Merge Record # N° d’enregistrement de fusion + Skip Record If… Ignorer l’enregistrement si… + Skip Record If Ignorer l’enregistrement si + Equals: Égal à : + Then insert: Alors insérer : + Otherwise insert: Sinon insérer : + Courtesy Title Civilité + First Name Prénom + Last Name Nom + Company Société + Address Adresse + City Ville + State État + Postal Code Code postal + Country Pays + (not matched) (aucune correspondance) + Leave a recipient out of the merge when a field has this value. Exclure un destinataire de la fusion lorsqu’un champ a cette valeur. + Insert one text or another, depending on a field's value. Insérer un texte ou l’autre selon la valeur d’un champ. + Select recipients first to match their fields. Sélectionnez d’abord les destinataires pour faire correspondre leurs champs. + The recipient-list field that fills each part of an address block and greeting line. Le champ de la liste des destinataires qui remplit chaque partie d’un bloc d’adresse et d’une ligne de salutation. + No errors found: every merge field is in the recipient list. Aucune erreur détectée : chaque champ de fusion se trouve dans la liste des destinataires. + These merge fields aren't in the recipient list: Ces champs de fusion ne figurent pas dans la liste des destinataires : + Add at least one field. Ajoutez au moins un champ. + Add Field Ajouter un champ + Every field needs a name. Chaque champ doit avoir un nom. + Field name Nom de champ + Field names must be different. Les noms de champs doivent être différents. + Fields: Champs : + Field: Champ : + Find: Rechercher : + New Entry Nouvelle entrée + Recipient List Liste des destinataires + Recipients: {count} Destinataires : {count} + Remove field Supprimer le champ + Remove recipient Supprimer le destinataire + Select recipients first to choose from their fields. Sélectionnez d’abord les destinataires pour choisir parmi leurs champs. + Type a New List… Entrer une nouvelle liste… + Type at least one recipient. Saisissez au moins un destinataire. + Type the recipients, or load them from a file. Saisissez les destinataires ou chargez-les à partir d’un fichier. + Use an Existing List… Utiliser une liste existante… + Choose the sheet that holds the recipients. Choisissez la feuille contenant les destinataires. + First row of data contains column headers La première ligne de données contient les en-têtes de colonnes + Interface theme: Thème de l’interface : + Interface\nTheme Thème de\nl’interface + Interface Theme Thème de l’interface + Light Clair + Dark Sombre + Use system setting Utiliser le paramètre système + A file dialog is already open. Une boîte de dialogue de fichier est déjà ouverte. + Height: Hauteur : + Width: Largeur : + Row Ligne + Cell Cellule + Preferred width: Largeur préférée : + Alignment: Alignement : + Indent from left: Retrait à gauche : + Specify height: Spécifier la hauteur : + Exactly Exactement + At least Au moins + Allow row to break across pages Autoriser le fractionnement des lignes sur plusieurs pages + Repeat as header row on each page Répéter en tant que ligne d’en-tête sur chaque page + Vertical alignment: Alignement vertical : + Top Haut + Bottom Bas + Alt text Texte de remplacement + Alt: Alt : + W: L : + H: H : + Reset Réinitialiser + Reset Crop Réinitialiser le rognage + Insert a new equation (Alt+=) Insérer une nouvelle équation (Alt+=) + Leave the equation (Esc) Quitter l’équation (Esc) + Preview Aperçu + getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft + Draw Ink Dessiner à l’encre + Erase Ink Effacer l’encre + Hide Ink Masquer l’encre + Hide\nInk Masquer\nl’encre + Pens Stylos + Thickness Épaisseur + (All) (Tous) + (none) (aucun) + (normal text) (texte normal) + 1/4 Em Space Espace quart de cadratin + A mail merge field Un champ de fusion + A numbered sequence (figures, tables…) Une séquence numérotée (figures, tableaux…) + A table of contents Une table des matières + Categories: Catégories : + Character Caractère + Character code: Code de caractère : + Col #: Colonne : + Copyright Copyright + Date and Time Date et heure + Date formats: Formats de date : + Description: Description : + Document Information Informations sur le document + Double Closing Quote Guillemet double fermant + Double Opening Quote Guillemet double ouvrant + En Dash Tiret demi-cadratin + Em Space Espace cadratin + En Space Espace demi-cadratin + Equal column width Colonnes de largeur égale + Field Codes Codes de champ + Field code: Code de champ : + Field codes: Codes de champ : + Field name: Nom de champ : + Field names: Noms de champs : + Field properties Propriétés du champ + Field… Champ… + Format: Format : + Hide Codes Masquer les codes + Index and Tables Index et tables + Line between Ligne entre les colonnes + Links and References Liens et références + Mail Merge Publipostage + More Columns… Autres colonnes… + More Symbols… Autres symboles… + No-Width Non Break Césure insécable sans chasse + No-Width Optional Break Césure facultative sans chasse + Paragraph Mark Marque de paragraphe + Pick a field from the list Choisissez un champ dans la liste + Preserve formatting during updates Conserver la mise en forme lors des mises à jour + Presets Préréglages + Recently used symbols: Symboles récemment utilisés : + Registered Marque déposée + Section Mark Marque de section + Sequence name: Nom de la séquence : + Shortcut key: Touche de raccourci : + Single Closing Quote Guillemet simple fermant + Single Opening Quote Guillemet simple ouvrant + Spacing: Espacement : + Special Characters Caractères spéciaux + Subset: Sous-ensemble : + The time right now L’heure actuelle + When this document was first created Quand ce document a été créé pour la première fois + When this document was most recently printed Quand ce document a été imprimé pour la dernière fois + When this document was most recently saved Quand ce document a été enregistré pour la dernière fois + The document has no bookmarks yet. Le document ne contient pas encore de signets. + Who the document properties name as author L’auteur désigné dans les propriétés du document + Name of the file this document is saved as Nom du fichier sous lequel ce document est enregistré + Title from the document properties Titre indiqué dans les propriétés du document + How many pages the whole document has Nombre de pages du document entier + How many pages this section has Nombre de pages de cette section + Number of the page the field sits on Numéro de la page où se trouve le champ + Number of the section the field sits in Numéro de la section où se trouve le champ + How many words the document holds Nombre de mots du document + Page on which a bookmarked passage falls Page où se trouve le passage marqué d’un signet + Copy of the text a bookmark marks Copie du texte marqué par un signet + This field has no options. Ce champ n’a pas d’options. + This font has no characters to show. Cette police ne contient aucun caractère à afficher. + This point forward À partir de ce point + The date it is today La date d’aujourd’hui + Trademark Marque + Type the field's name and its switches, for example PAGE \\* roman. Saisissez le nom du champ et ses commutateurs, par exemple PAGE \\* roman. + Width and spacing Largeur et espacement + from Unicode (hex) depuis Unicode (hex) + Shape Effects Effets de forme + Glow Lueur + Soft Edges Bords adoucis + No Shadow Sans ombre + No Glow Sans lueur + No Soft Edges Sans bords adoucis + Glow Colors Couleurs de lueur + Weight Épaisseur + Shadow, glow and soft edges Ombre, lueur et bords adoucis + Offset: Bottom Right Décalage : en bas à droite + Offset: Bottom Décalage : en bas + Offset: Bottom Left Décalage : en bas à gauche + Offset: Right Décalage : à droite + Offset: Center Décalage : au centre + Offset: Left Décalage : à gauche + Offset: Top Right Décalage : en haut à droite + Offset: Top Décalage : en haut + Offset: Top Left Décalage : en haut à gauche + Manage Styles Gérer les styles + Show or Hide Style Afficher ou masquer le style + Manage Styles… Gérer les styles… + Sort order: Ordre de tri : + As Recommended Selon les recommandations + Alphabetical Alphabétique + Font: {font} Police : {font} + {size} pt {size} pt + Color: {color} Couleur : {color} + Space before: {pt} pt Espace avant : {pt} pt + Space after: {pt} pt Espace après : {pt} pt + Based on: {style} Basé sur : {style} + Paragraph style Style de paragraphe + Character style Style de caractère + Linked style (paragraph and character) Style lié (paragraphe et caractère) + Table style Style de tableau + Built-in style Style intégré + Show in the Styles gallery Afficher dans la galerie de styles + Hide from style lists Masquer dans les listes de styles + Built-in styles can't be deleted. Les styles intégrés ne peuvent pas être supprimés. + Delete Table Style Supprimer le style de tableau + Password Mot de passe + Password: Mot de passe : + Confirm password: Confirmer le mot de passe : + Encrypt with Password… Chiffrer avec un mot de passe… + Change Password… Modifier le mot de passe… + Remove Password Supprimer le mot de passe + A password is required to open this document. Un mot de passe est requis pour ouvrir ce document. + Anyone can open this document. Encrypt it with a password to keep it private. N’importe qui peut ouvrir ce document. Chiffrez-le avec un mot de passe pour le garder confidentiel. + Anyone who opens the document will need this password. Quiconque ouvre le document devra saisir ce mot de passe. + {name} is protected with a password. {name} est protégé par un mot de passe. + That password isn't right. Check it (passwords are case-sensitive) and try again. Ce mot de passe est incorrect. Vérifiez-le (les mots de passe respectent la casse) et réessayez. + The document will be saved with a password. Save it to protect it. Le document sera enregistré avec un mot de passe. Enregistrez-le pour le protéger. + The password is removed. Save the document to keep the change. Le mot de passe est supprimé. Enregistrez le document pour conserver cette modification. + The passwords don't match. Les mots de passe ne correspondent pas. + Keep the password somewhere safe: without it nobody, WordCraft included, can open the document. Leave both boxes empty to remove the password. Conservez le mot de passe en lieu sûr : sans lui, personne, WordCraft inclus, ne peut ouvrir le document. Laissez les deux zones vides pour supprimer le mot de passe. + Align Top Left Aligner en haut à gauche + Align Top Center Aligner en haut au centre + Align Top Right Aligner en haut à droite + Align Center Left Aligner au centre à gauche + Align Center Aligner au centre + Align Center Right Aligner au centre à droite + Align Bottom Left Aligner en bas à gauche + Align Bottom Center Aligner en bas au centre + Align Bottom Right Aligner en bas à droite + {contributors} contributors · {commits} commits {contributors} contributeurs · {commits} commits + {cols}×{rows} Table Tableau {cols}×{rows} + AaBbYyZz AaBbYyZz + pt pt + Document1 Document1 + Username Nom d’utilisateur + Display name Nom d’affichage + Real name Nom réel + Name Nom + PRs PR + Commits Commits + First Premier + Last Dernier + Model Modèle + Version Version + % of all commits % de tous les commits + Lines +/− Lignes +/− + Craft Artisanat + Studio Studio + Atelier Atelier + Darkroom Chambre noire + Gallery Galerie + Sketchbook Carnet de croquis + Change Picture… Modifier l’image… + Font… Police… + Paragraph… Paragraphe… + Link… Lien… + Size and Crop… Taille et rognage… + Insert Columns to the Left Insérer des colonnes à gauche + Insert Columns to the Right Insérer des colonnes à droite + Insert Rows Above Insérer des lignes au-dessus + Insert Rows Below Insérer des lignes en dessous + Mon lun. + Tue mar. + Wed mer. + Sum Somme + North Nord + South Sud + West Ouest + Total Total + Draw\nTable Dessiner\nun tableau + Rotation Rotation + Rotation: Rotation : + Rotate Right 90° Faire pivoter à droite de 90° + Rotate Left 90° Faire pivoter à gauche de 90° + Flip Vertical Retourner verticalement + Flip Horizontal Retourner horizontalement + Move Paragraph Up Déplacer le paragraphe vers le haut + Move Paragraph Down Déplacer le paragraphe vers le bas + Promote Élever d’un niveau + Demote Abaisser le niveau + Go Back Revenir en arrière + Toggle Unicode Character Basculer le caractère Unicode + Toggle Field Codes Basculer les codes de champ + Edit Field Code Modifier le code de champ + Unlink Fields Dissocier les champs + Lock Fields Verrouiller les champs + Unlock Fields Déverrouiller les champs + Next Field Champ suivant + Previous Field Champ précédent + Selected Fields Champs sélectionnés + Edit Field… Modifier le champ… + Formatted Mis en forme + Interface size: Taille de l’interface : + Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Agrandit ou réduit le ruban, les menus et les boîtes de dialogue en plus de la mise à l’échelle d’affichage de votre système. Le zoom du document est distinct. + 1. Formatting restrictions 1. Restrictions de mise en forme + 2. Editing restrictions 2. Restrictions de modification + 3. Start enforcement 3. Démarrer la protection + << Less << Moins + Another file Autre fichier + Apply as you type Appliquer pendant la frappe + AutoCorrect Correction automatique + AutoFormat As You Type Mise en forme automatique pendant la frappe + Automatic bulleted lists Listes à puces automatiques + Automatic numbered lists Listes numérotées automatiques + Border lines Lignes de bordure + Capitalize first letter of sentences Majuscule en début de phrase + Capitalize first letter of table cells Majuscule en début de cellule de tableau + Capitalize names of days Majuscule aux noms de jours + Case changes Modifications de casse + Character level Niveau du caractère + Checked styles are allowed: Les styles cochés sont autorisés : + Combine Documents Combiner les documents + Compare Documents Comparer les documents + Compared: {changes} changes Comparaison : {changes} modifications + Comparing with another file needs the desktop app. La comparaison avec un autre fichier nécessite l’application de bureau. + Comparison settings Options de comparaison + Don't capitalize after: Ne pas mettre de majuscule après : + Editing in this document is restricted. La modification de ce document est restreinte. + Every change is recorded as a tracked change. Chaque modification est enregistrée comme modification suivie. + Everyone Tout le monde + Exceptions (optional) Exceptions (facultatives) + Exceptions… Exceptions… + Fields Champs + Filling in forms Remplissage de formulaires + Find Next Editable Region Rechercher la zone modifiable suivante + Footnotes and endnotes Notes de bas de page et notes de fin + Formatting, case changes, white space and tables are compared; the other settings are kept for later. La mise en forme, les modifications de casse, les espaces et les tableaux sont comparés ; les autres paramètres sont conservés pour une utilisation ultérieure. + Fractions (1/2) as fraction characters (½) Fractions (1/2) en caractères de fraction (½) + Headers and footers En-têtes et pieds de page + Hyphens (--) as dashes (—) Traits d’union (--) en tirets (—) + In equations, typing a name after a backslash replaces it with its symbol. Dans les équations, un nom saisi après une barre oblique inverse est remplacé par son symbole. + Internet addresses as links Adresses Internet en liens + Label changes with: Étiqueter les modifications avec : + Limit formatting to a selection of styles Limiter la mise en forme à une sélection de styles + Math AutoCorrect Correction automatique mathématique + More >> Plus >> + Moves Déplacements + New document Nouveau document + No changes (read only) Aucune modification (lecture seule) + Only allow this kind of editing: Autoriser uniquement ce type de modification : + Only comments can be added, except in regions marked editable. Seuls les commentaires peuvent être ajoutés, à l’exception des zones marquées comme modifiables. + Only form fields can be filled in. Seuls les champs de formulaire peuvent être remplis. + Only the allowed styles can be applied. Seuls les styles autorisés peuvent être appliqués. + Ordinals (1st) as superscript Nombres ordinaux (1er) en exposant + Original document Document d’origine + This password type isn't supported. Ce type de mot de passe n’est pas pris en charge. + Replace as you type Remplacer pendant la frappe + Replace text as you type Remplacer le texte pendant la frappe + Replace: Remplacer : + Revised document Document révisé + Select text, then tick who can still edit it. Sélectionnez le texte, puis cochez qui peut encore le modifier. + Settings… Options… + Show changes at: Afficher les modifications au niveau : + Show changes in: Afficher les modifications dans : + Smart quotes instead of straight quotes Guillemets courbes au lieu de guillemets droits + Start Enforcing Protection Démarrer la protection + Stop Protection Arrêter la protection + Text boxes Zones de texte + The document is read only, except for regions marked editable. Le document est en lecture seule, à l’exception des zones marquées comme modifiables. + The password is optional; without it anyone can stop the protection. Le mot de passe est facultatif ; sans lui, n’importe qui peut arrêter la protection. + This document Ce document + Tracked changes Modifications suivies + Type the password to stop the protection. Tapez le mot de passe pour arrêter la protection. + White space Espaces + With: Avec : + Word level Niveau du mot + In Line with Text Dans le texte + Square Carré + Through Au travers + Top and Bottom Haut et bas + Behind Text Derrière le texte + In Front of Text Devant le texte + Wrap Text Sides Côtés de l’habillage + Both Sides Des deux côtés + Left Only À gauche uniquement + Right Only À droite uniquement + Largest Only Le plus grand côté uniquement + Customize Keyboard Personnaliser le clavier + Choose a command Choisir une commande + Choose a shortcut Choisir un raccourci + All Commands Toutes les commandes + Commands: Commandes : + Keys for this command: Touches pour cette commande : + New shortcut: Nouveau raccourci : + Now used by {command} Actuellement utilisé par {command} + Not used by any command N’est utilisé par aucune commande + Remove every custom shortcut and restore the built-in keys? Supprimer tous les raccourcis personnalisés et rétablir les touches d’origine ? + Assign Attribuer + Reset All… Tout réinitialiser… + Press a key combination Appuyez sur une combinaison de touches + Customize Personnaliser + Keyboard shortcuts: Raccourcis clavier : + Customize… Personnaliser… + Customize Ribbon Personnaliser le ruban + Add to Quick Access Toolbar Ajouter à la barre d’outils Accès rapide + Remove from Quick Access Toolbar Supprimer de la barre d’outils Accès rapide + Customize the Ribbon… Personnaliser le ruban… + Customize Quick Access Toolbar… Personnaliser la barre d’outils Accès rapide… + More Commands… Plus de commandes… + Ribbon Ruban + (Custom) (Personnalisé) + Remove every custom tab and group and show all tabs again? Supprimer tous les onglets et groupes personnalisés et réafficher tous les onglets ? + Put the default commands back on the Quick Access Toolbar? Rétablir les commandes par défaut sur la barre d’outils Accès rapide ? + Reset… Réinitialiser… + Add >> Ajouter >> + << Remove << Supprimer + Pick a custom group (New Group makes one) Choisir un groupe personnalisé (« Nouveau groupe » en crée un) + Ribbon tabs Onglets du ruban + New Tab Nouvel onglet + New Group Nouveau groupe + Rename… Renommer… + Ribbon and Quick Access Toolbar: Ruban et barre d’outils Accès rapide : + Accessibility Pane Volet Accessibilité + Alt Text Pane Volet Texte de remplacement + Alt\nText Texte\nde remplacement + View Alt Text… Afficher le texte de remplacement… + Mark as decorative Marquer comme décoratif + Title: Titre : + Errors ({n}) Erreurs ({n}) + Warnings ({n}) Avertissements ({n}) + Tips ({n}) Conseils ({n}) + No accessibility issues found. Aucun problème d’accessibilité détecté. + Describe what this object shows, for people who can't see it. Décrivez ce que montre cet objet, pour les personnes qui ne peuvent pas le voir. + Decorative objects only add visual interest: screen readers skip them. Les objets décoratifs n’ont qu’un rôle visuel : les lecteurs d’écran les ignorent. + Describe what this table shows, for people who can't see it. Décrivez ce que montre ce tableau, pour les personnes qui ne peuvent pas le voir. + Select a picture, shape, chart or table to describe it. Sélectionnez une image, une forme, un graphique ou un tableau pour le décrire. + Document Grid Grille du document + No grid Sans grille + Lines only Lignes uniquement + Lines and characters Lignes et caractères + Text snaps to characters Le texte s’aligne sur les caractères + Characters per line: Caractères par ligne : + Lines per page: Lignes par page : + Moved from Déplacé depuis + Moved to Déplacé vers + Developer Développeur + Controls Contrôles + Rich Text Content Control Contrôle de contenu Texte enrichi + Plain Text Content Control Contrôle de contenu Texte brut + Picture Content Control Contrôle de contenu Image + Building Block Gallery Content Control Contrôle de contenu Galerie de blocs de construction + Check Box Content Control Contrôle de contenu Case à cocher + Combo Box Content Control Contrôle de contenu Zone de liste déroulante + Drop-Down List Content Control Contrôle de contenu Liste déroulante + Date Picker Content Control Contrôle de contenu Sélecteur de date + Repeating Section Content Control Contrôle de contenu Section répétable + Design Mode Mode Création + Content Control at Selection Contrôle de contenu dans la sélection + Toggle Check Box Basculer la case à cocher + Choose List Item Choisir un élément de liste + Set Date Définir la date + Remove Content Control Supprimer le contrôle de contenu + Content Control Properties Propriétés du contrôle de contenu + Title Titre + Tag Balise + Placeholder text Texte de l’espace réservé + Can't be deleted Suppression impossible + Contents can't be edited Contenu non modifiable + Remove the control once edited Supprimer le contrôle une fois le contenu modifié + Allow line breaks Autoriser les sauts de ligne + Date format Format de date + Checked symbol Symbole de l’état coché + Unchecked symbol Symbole de l’état décoché + List entries Entrées de liste + Display text Texte affiché + Value Valeur + Today Aujourd’hui + Put the caret in a content control first. Placez d’abord le curseur dans un contrôle de contenu. + Right-to-Left Table Tableau de droite à gauche + Right-to-Left Section Section de droite à gauche + Table direction: Direction du tableau : + Section direction: Direction de la section : + Drawing Canvas Zone de dessin + New Drawing Canvas Nouvelle zone de dessin + Fit Ajuster + Expand Étendre + Scale Drawing Mettre le dessin à l’échelle + Canvas Member Élément de la zone de dessin + Arrows Flèches + Communication Communication + Office Bureau + People Personnes + Nature Nature + Weather Météo + Technology Technologie + Transport Transports + Icon Color Couleur de l’icône + Search icons Rechercher des icônes + No icons match your search. Aucune icône ne correspond à votre recherche. + Recolor a one-color picture, such as an icon Recolorier une image monochrome, par exemple une icône + Enclose Characters Encadrer les caractères + Shrink text Réduire le texte + Enlarge symbol Agrandir le symbole + Enclosure Encadrement + Circle Cercle + Templates Modèles + Templates and Add-ins Modèles et compléments + Attach Template Attacher un modèle + Detach Template Détacher le modèle + Automatically Update Document Styles Mettre à jour automatiquement les styles du document + Update Styles from Template Mettre à jour les styles depuis le modèle + Organizer Organiseur + Copy Styles Copier les styles + Rename Style Renommer le style + Change Chart Type Modifier le type de graphique + Chart Design Création de graphique + Type Type + Edit Data Modifier les données + Chart Title Titre du graphique + Chart Layouts Dispositions de graphique + Add Chart Element Ajouter un élément de graphique + Legend Légende + Data Labels Étiquettes de données + Above Chart Au-dessus du graphique + Insert Chart Insérer un graphique + Clustered Column Colonnes groupées + Stacked Column Colonnes empilées + Clustered Bar Barres groupées + Stacked Bar Barres empilées + Area Aires + Pie Secteurs + Doughnut Anneau + Scatter Nuage de points + “{text}” ({row}, {series}) isn't a number. « {text} » ({row}, {series}) n’est pas un nombre. + Category {n} Catégorie {n} + Series {n} Série {n} + Type the categories down the side and a column of values per series. Saisissez les catégories sur le côté et une colonne de valeurs pour chaque série. + Categories Catégories + Series name Nom de la série + Remove series Supprimer la série + Remove row Supprimer la ligne + Add Row Ajouter une ligne + Add Series Ajouter une série + Edit\nData Modifier\nles données + Change\nChart Type Modifier\nle type de graphique + Align Text Aligner le texte + Create Link Créer un lien + Break Link Rompre le lien + Rotate all text 90° Faire pivoter tout le texte de 90° + Rotate all text 270° Faire pivoter tout le texte de 270° + Stacked Empilé + Middle Au centre + Click the empty text box the text should continue in (Esc cancels) Cliquez sur la zone de texte vide dans laquelle le texte doit se poursuivre (Échap pour annuler) + Not linked: click a text box Pas de liaison : cliquez sur une zone de texte + Location Emplacement + Endnotes Notes de fin + Below text Sous le texte + End of section Fin de section + End of document Fin du document + Footnotes to endnotes Notes de bas de page en notes de fin + Endnotes to footnotes Notes de fin en notes de bas de page + Swap footnotes and endnotes Échanger les notes de bas de page et les notes de fin + Custom mark: Marque personnalisée : + Continuous numbering Numérotation continue + Apply Appliquer + Convert Notes Convertir les notes + Add Shape Ajouter une forme + Change Colors Modifier les couleurs + Change Layout Modifier la disposition + Create Graphic Créer un graphique + Edit Items Modifier les éléments + Layouts Dispositions + Reset Graphic Rétablir le graphique + SmartArt Design Création SmartArt + SmartArt Styles Styles SmartArt + Text Pane Volet Texte + Promote to Heading 1 Promouvoir en Titre 1 + Demote to Body Text Abaisser en corps de texte + Move Up Monter + Move Down Descendre + Collapse Réduire + Expand or Collapse Heading Développer ou réduire le titre + Select Heading and Content Sélectionner le titre et son contenu + Show Level Afficher le niveau + Show Level 1 Afficher le niveau 1 + Show Level 2 Afficher le niveau 2 + Show Level 3 Afficher le niveau 3 + Show Level 4 Afficher le niveau 4 + Show Level 5 Afficher le niveau 5 + Show Level 6 Afficher le niveau 6 + Show Level 7 Afficher le niveau 7 + Show Level 8 Afficher le niveau 8 + Show Level 9 Afficher le niveau 9 + Show All Levels Afficher tous les niveaux + Show First Line Only Afficher seulement la première ligne + Show Text Formatting Afficher la mise en forme du texte + Close Outline View Fermer le mode Plan + Outline State État du plan + Outlining Plan + Outline Tools Outils Plan + A, B, C, … A, B, C, … + Above the item Au-dessus de l’élément + Accessed on Consulté le + Add New Source… Ajouter une nouvelle source… + Add the chapter number Ajouter le numéro de chapitre + Atmosphere Atmosphère + Below the item En dessous de l’élément + Book Livre + Book title Titre du livre + Caption Légende + Caption Numbering Numérotation des légendes + Caption text Texte de la légende + Caption: Légende : + Chapter in a book Chapitre de livre + Chapters begin at: Les chapitres commencent à : + Collect entries from: Rassembler les entrées à partir de : + Columns: Colonnes : + Conference Conférence + Conference paper Article de conférence + Container Title Titre de la publication + Create Source Créer la source + Cross-reference: Référence croisée : + Custom Table of Contents… Table des matières personnalisée… + DOI DOI + Day Jour + Delete Label Supprimer l’étiquette + Edit Source Modifier la source + Edition Édition + Edit… Modifier… + Entry marked. Entrée marquée. + Example: {sample} Exemple : {sample} + Figure Figure + Film Film + Heading styles (levels 1–{n}) Styles de titres (niveaux 1–{n}) + Heading {n} Titre {n} + I, II, III, … I, II, III, … + Include page numbers Inclure les numéros de page + Indented Avec retrait + Institution Institution + Ionosphere Ionosphère + Issue Numéro + Journal Revue + Label: Étiquette : + Last, First; Last, First Nom, Prénom ; Nom, Prénom + Leave the label out of the caption Ne pas inclure l’étiquette dans la légende + Letter headings En-têtes alphabétiques + Levels: Niveaux : + Link entries to their headings Lier les entrées à leurs titres + Made from the author and year when empty Généré à partir de l’auteur et de l’année si le champ est vide + Magazine or newspaper article Article de magazine ou de journal + Main entry: Entrée principale : + Mark Marquer + Mark All Tout marquer + Mark Entry… Marquer l’entrée… + Mark Index Entry Marquer l’entrée d’index + Mesosphere Mésosphère + Month Mois + New Label Nouvelle étiquette + New Label… Nouvelle étiquette… + New… Nouvelle… + No sources yet. Aucune source pour le moment. + Numbering… Numérotation… + Options… Options… + Other Autre + Other styles and their TOC level: Autres styles et leur niveau dans la table des matières : + Outline levels Niveaux hiérarchiques + Page number style Style des numéros de page + Page numbers at the right margin Numéros de page à la marge de droite + Page title Titre de la page + Pages of bookmark: Pages du signet : + Periodical Périodique + Position: Position : + Publisher Éditeur + Run-in À la suite + See Voir + Separator: Séparateur : + Show every bibliography field Afficher tous les champs bibliographiques + Site name Nom du site + Source Manager Gestionnaire de sources + Source type: Type de source : + Sources in this document: Sources dans ce document : + Styles: Styles : + Subentry: Sous-entrée : + TC entry fields Champs d’entrée TC + Tab leader: Remplissage de tabulation : + Table of Contents Options Options de la table des matières + Table of Contents Styles Styles de la table des matières + This page Cette page + Type: Type : + URL URL + Volume Volume + Website Site web + Year Année + a, b, c, … a, b, c, … + i, ii, iii, … i, ii, iii, … + {n} entries marked. {n} entrées sélectionnées. + Lasso Select Sélection au lasso + Move Selected Ink Déplacer l’encre sélectionnée + Delete Selected Ink Supprimer l’encre sélectionnée + Recolor Selected Ink Recolorer l’encre sélectionnée + Add Pen Ajouter un stylo + Remove Pen Supprimer le stylo + Ink to Shape Convertir l’encre en forme + Convert to Shapes Convertir en formes + Ink Replay Rejouer l’encre + Add\nPen Ajouter\nun stylo + Rewind Rembobiner + Forward Avancer + Stroke {n} of {total} Trait {n} sur {total} + Move with Text Déplacer avec le texte + WordArt Styles Styles WordArt + Text Fill Remplissage du texte + Text Outline Contour du texte + Transform Transformation + No Fill Aucun remplissage + No Outline Aucun contour + Reflection Reflet + No Reflection Aucun reflet + No Transform Aucune transformation + Connect Shapes Connecter les formes + Straight Connector Connecteur droit + Straight Arrow Connector Connecteur droit avec flèche + Elbow Connector Connecteur coudé + Elbow Arrow Connector Connecteur coudé avec flèche + Curved Connector Connecteur incurvé + Curved Arrow Connector Connecteur incurvé avec flèche + Arch Up Arc vers le haut + Arch Down Arc vers le bas + Slant Up Inclinaison vers le haut + Slant Down Inclinaison vers le bas + Inflate Gonflement + Deflate Dégonflement + Chevron Up Chevron vers le haut + Chevron Down Chevron vers le bas + Triangle Up Triangle vers le haut + Triangle Down Triangle vers le bas + Solid Accent Accent uni + Light with Outline Clair avec contour + Hollow Creux + Accent Gradient Dégradé d’accent + Dark with Shadow Sombre avec ombre + Soft Glow Lueur douce + Accent with Reflection Accent avec reflet + Warm Gradient with Outline Dégradé chaud avec contour + Add line numbering Ajouter la numérotation des lignes + Add to Document Ajouter au document + Address labels Étiquettes d’adresse + Arabic Arabe + Auto Auto + Automatically hyphenate document Césure automatique du document + Chinese (Simplified) Chinois (simplifié) + Chinese (Traditional) Chinois (traditionnel) + Column: Colonne : + Count by: Incrémenter de : + Czech Tchèque + Danish Danois + Delivery address: Adresse de livraison : + Detect language automatically Détecter automatiquement la langue + Do not check spelling or grammar Ne pas vérifier l’orthographe ni la grammaire + Dutch Néerlandais + English (United Kingdom) Anglais (Royaume-Uni) + Envelope size: Taille de l’enveloppe : + Estonian Estonien + Finnish Finnois + French (France) Français (France) + From text: Distance du texte : + Full page of the same label Page entière d’étiquettes identiques + German (Germany) Allemand (Allemagne) + Greek Grec + Hebrew Hébreu + Hyphenate at: Couper à : + Hyphenate words in CAPS Couper les mots en MAJUSCULES + Hyphenation Options… Options de césure… + Hyphenation zone: Zone de césure : + Italian Italien + Japanese Japonais + Korean Coréen + Limit consecutive hyphens to: Limiter les césures consécutives à : + Line Numbering Options… Options de numérotation des lignes… + Manual Hyphenation Césure manuelle + Manual hyphenation is complete. La césure manuelle est terminée. + Manual… Manuelle… + Mark selected text as: Marquer le texte sélectionné comme : + New Document Nouveau document + No limit Sans limite + Norwegian (Bokmål) Norvégien (bokmål) + Numbering: Numérotation : + Omit Omettre + One label per recipient Une étiquette par destinataire + Polish Polonais + Portuguese (Brazil) Portugais (Brésil) + Portuguese (Portugal) Portugais (Portugal) + Return address labels Étiquettes d’adresse de retour + Return address: Adresse de retour : + Row: Ligne : + Russian Russe + Serbian (Cyrillic) Serbe (cyrillique) + Serbian (Latin) Serbe (latin) + Set As Default Définir par défaut + Shipping labels Étiquettes d’expédition + Single label Étiquette unique + Spanish (Mexico) Espagnol (Mexique) + Spanish (Spain) Espagnol (Espagne) + Swedish Suédois + Turkish Turc + Ukrainian Ukrainien + {language} is now the document's default language. {language} est désormais la langue par défaut du document. + +# Interface strings added on main since this catalog was first written. + As you type Pendant la frappe + Asian text font: Police du texte asiatique : + AutoSave writes your changes to the file a moment after you stop typing. L’enregistrement automatique écrit vos modifications dans le fichier peu après que vous arrêtez de taper. + Centimeters Centimètres + Check spelling as you type Vérifier l’orthographe pendant la frappe + Click to switch back to inserting Cliquez pour revenir au mode insertion + Custom dictionary Dictionnaire personnel + Formatting marks Marques de mise en forme + Hidden text Texte masqué + Ignore web and file addresses Ignorer les adresses web et les chemins de fichiers + Ignore words in capitals Ignorer les mots en majuscules + Ignore words with numbers Ignorer les mots contenant des chiffres + Inches Pouces + Initials: Initiales : + Interface Interface + Interface language Langue de l’interface + Latin text font: Police du texte latin : + Mark grammar errors as you type Signaler les fautes de grammaire pendant la frappe + Measurement units: Unités de mesure : + Millimeters Millimètres + New comments and tracked changes are recorded under this name and these initials. Les nouveaux commentaires et les modifications suivies sont enregistrés sous ce nom et ces initiales. + New word Nouveau mot + No words yet. Add to Dictionary on a misspelled word puts it here. Aucun mot pour l’instant. Les mots ajoutés avec Ajouter au dictionnaire sur un mot mal orthographié apparaissent ici. + Overtype (typing replaces the characters after the caret) Remplacement (la frappe remplace les caractères situés après le curseur) + Page display Affichage de la page + Paragraph marks Marques de paragraphe + Personalize Personnaliser + Picas Picas + Points Points + Print the page color Imprimer la couleur de la page + Printing Impression + Proofing language Langue de vérification + Recent documents to show: Nombre de documents récents à afficher : + Save documents Enregistrer les documents + Show the mini toolbar when text is selected Afficher la mini barre d’outils lors de la sélection de texte + Spaces Espaces + Spelling Orthographe + Spelling and grammar are checked in English (United States). L’orthographe et la grammaire sont vérifiées en anglais (États-Unis). + Tab characters Caractères de tabulation + These marks show even when Show/Hide ¶ is off. Ces marques s’affichent même lorsque Afficher/Masquer ¶ est désactivé. + Typing replaces selected text La frappe remplace le texte sélectionné + Update fields before printing Mettre à jour les champs avant l’impression + Use the Insert key to switch overtype on and off Utiliser la touche Inser pour activer ou désactiver le mode Remplacement + Extend Selection by Units Étendre la sélection par unités + Animation Animation + Animate the caret (it glides and fades) Animer le curseur (il glisse et s’estompe) + On: the caret glides to its new place when you type or use the arrow keys, and its blink fades. Off: it jumps and blinks on and off. Activé : le curseur glisse jusqu’à sa nouvelle position lorsque vous tapez ou utilisez les touches fléchées, et son clignotement s’estompe en douceur. Désactivé : il saute et clignote de façon nette. + Word XML Document (*.xml) Document XML Word (*.xml) + Deleted Supprimé + Resize Table by Dragging Redimensionner le tableau en le faisant glisser + Print Markup Imprimer les marques de révision + Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. Commentaires à côté de chaque page et modifications suivies dans les PDF, les impressions et les images ; liste des commentaires à la fin des pages web, du Markdown et du texte. + Add Font… Ajouter une police… + Added Fonts Polices ajoutées + Remove Added Font Supprimer la police ajoutée + Recent Colors Couleurs récentes + More Colors… Autres couleurs… + Standard Standard + Current Actuelle + Colors: Couleurs : + Color model: Modèle de couleur : + Red: Rouge : + Green: Vert : + Blue: Bleu : + Hue: Teinte : + Sat: Sat. : + Lum: Lum. : + Hex: Hex : + Document template: Modèle de document : + Attach… Joindre… + Automatically update document styles Mettre à jour automatiquement les styles du document + Organizer… Organiseur… + Documents Documents + Word document Document Word + All files Tous les fichiers + Recipient lists Listes de destinataires + The system file dialog isn't available, so WordCraft shows its own. To get the system dialog, install a file chooser portal (such as xdg-desktop-portal-gtk) or zenity. La boîte de dialogue de fichiers du système n’est pas disponible, WordCraft affiche donc la sienne. Pour utiliser celle du système, installez un portail de sélection de fichiers (comme xdg-desktop-portal-gtk) ou zenity. + Up one level Dossier parent + Home folder Dossier personnel + {name} wasn't found. {name} est introuvable. + {name} already exists. Choose Save again to replace it. {name} existe déjà. Choisissez à nouveau Enregistrer pour le remplacer. + This folder can't be read: {error} Impossible de lire ce dossier : {error} + Showing the first {count} items. Affichage des {count} premiers éléments. + File name: Nom du fichier : + Browse in WordCraft… Parcourir dans WordCraft… + Open a file with WordCraft's own browser when the system file dialog doesn't appear. Ouvrir un fichier avec le navigateur de fichiers intégré à WordCraft lorsque la boîte de dialogue de fichiers du système n’apparaît pas. + Hide White Space Masquer les espaces blancs + All pages Toutes les pages + Current page Page actuelle + Selected pages Pages sélectionnées + Custom range Plage personnalisée + All pages in the range Toutes les pages de la plage + Odd pages only Pages impaires uniquement + Even pages only Pages paires uniquement + Copies Copies + Collate copies Assembler les copies + Pages like 1-3, 5 Pages, par exemple 1-3, 5 + {count} of {total} pages will print {count} pages sur {total} seront imprimées + Save these pages as PDF… Enregistrer ces pages au format PDF… + The printer, two-sided printing and pages per sheet are chosen in that dialog. L’imprimante, l’impression recto verso et le nombre de pages par feuille se choisissent dans cette boîte de dialogue. + Previous Page Page précédente + Print to PDF Imprimer en PDF + List Liste + Process Processus + Cycle Cycle + Hierarchy Hiérarchie + Relationship Relation + Pyramid Pyramide + Basic List Liste simple + Basic Process Processus simple + Basic Cycle Cycle simple + Basic Pyramid Pyramide simple + Basic Radial Radial simple + Basic Matrix Matrice simple + Basic Venn Venn simple + Shows items of equal weight as blocks in rows. Affiche des éléments de même importance sous forme de blocs en lignes. + Shows steps in order, left to right, with arrows between them. Affiche des étapes dans l’ordre, de gauche à droite, reliées par des flèches. + Shows steps that repeat, around a circle. Affiche des étapes qui se répètent, disposées en cercle. + Shows a tree: each item below the item it belongs to. Affiche une arborescence : chaque élément sous celui auquel il appartient. + Shows parts that build on each other, largest at the bottom. Affiche des parties qui s’appuient les unes sur les autres, la plus grande en bas. + Shows items around a central idea: the first item is the centre. Affiche des éléments autour d’une idée centrale : le premier élément est au centre. + Shows parts of a whole as quadrants. Affiche les parties d’un tout sous forme de quadrants. + Shows overlapping ideas as overlapping circles. Affiche des idées qui se chevauchent sous forme de cercles superposés. + Accent {n} Accentuation {n} + Colorful Coloré + Insert SmartArt Insérer un SmartArt + One line per item. Enter adds an item. Une ligne par élément. Entrée ajoute un élément. + Add\nShape Ajouter\nune forme + Change\nColors Modifier les\ncouleurs + Reset\nGraphic Rétablir le\ngraphique + All Levels Tous les niveaux + Close Outline\nView Fermer le\nmode Plan + Use wildcards Utiliser les caractères génériques + Special Spécial + Match prefix Respecter le préfixe + Match suffix Respecter le suffixe + Ignore punctuation characters Ignorer la ponctuation + Ignore white-space characters Ignorer les espaces + Regular expressions Expressions régulières + Search Options Options de recherche + Tab Character Caractère de tabulation + Any Character N’importe quel caractère + Any Digit N’importe quel chiffre + Any Letter N’importe quelle lettre + Caret Character Caractère accent circonflexe + Section Character Caractère de section (§) + Paragraph Character Caractère de paragraphe + Graphic Graphique + Manual Line Break Saut de ligne manuel + Manual Page Break Saut de page manuel + Section Break Saut de section + White Space Espace blanc + Clipboard Contents Contenu du Presse-papiers + Find What Text Texte recherché + Find What Expression Expression recherchée + Character in Range Caractère dans la plage + Beginning of Word Début de mot + End of Word Fin de mot + Expression Expression + Not Non + Num Occurrences Nombre d’occurrences + Previous 1 or More 1 ou plusieurs du précédent + 0 or More Characters 0 caractère ou plus + Fix Position on Page Fixer la position sur la page + Visual effects Effets visuels + Animation effects: Effets d’animation : + On Activés + Off Désactivés + Smooth scrolling and zooming, and menus and buttons that fade in. Off shows every change at once. Défilement et zoom fluides, et menus et boutons qui apparaissent en fondu. Avec Désactivés, chaque changement s’affiche immédiatement. + Your system has animation effects turned on. Les effets d’animation sont activés sur votre système. + Your system has animation effects turned off. Les effets d’animation sont désactivés sur votre système. + Your system doesn't report an animation setting, so animation effects stay on. Votre système n’indique aucun réglage d’animation, les effets d’animation restent donc activés. + Track\nChanges Suivi des\nmodifications + Track\nChanges ✓ Suivi des\nmodifications ✓ + Do Not Check Spelling or Grammar Ne pas vérifier l’orthographe ni la grammaire + ➕ New comment ➕ Nouveau commentaire + 📂 Browse… 📂 Parcourir… + Possible spelling mistake Faute d’orthographe possible + Extra space Espace en trop + Capitalize the first word of a sentence Mettre une majuscule au premier mot de la phrase + Remove the space before the punctuation Supprimer l’espace avant la ponctuation + Use "an" before a vowel sound Utiliser « an » devant un son voyelle + Use "a" before a consonant sound Utiliser « a » devant un son consonne + The spelling and grammar check is complete. La vérification de l’orthographe et de la grammaire est terminée. + Couldn't make WordCraft the default: {error} Impossible de faire de WordCraft l’application par défaut : {error} + In Default apps, choose WordCraft for the document types you want it to open. Dans Applications par défaut, choisissez WordCraft pour les types de documents qu’il doit ouvrir. + Make WordCraft the default for Word documents… Faire de WordCraft l’application par défaut pour les documents Word… + Opens .docx, .doc, .rtf and .odt files and Word templates with WordCraft from now on. Ouvre désormais les fichiers .docx, .doc, .rtf et .odt ainsi que les modèles Word avec WordCraft. + Opens Settings › Default apps, where you choose WordCraft for .docx and the other document types. Ouvre Paramètres › Applications par défaut, où vous choisissez WordCraft pour .docx et les autres types de documents. + To open Word documents with WordCraft by default: select one in Finder, choose File › Get Info, pick WordCraft under Open with, then click Change All. Pour ouvrir les documents Word avec WordCraft par défaut : sélectionnez-en un dans le Finder, choisissez Fichier › Lire les informations, choisissez WordCraft sous « Ouvrir avec », puis cliquez sur « Tout modifier ». + WordCraft is now the default app for Word documents. WordCraft est désormais l’application par défaut pour les documents Word. + Numerals: Chiffres : + Numerals Chiffres + Hindi Hindi + Context Contexte + System Système + Show KeyTips (Alt or F10) Afficher les info-touches (Alt ou F10) + Reading Lecture + Reading Column Width Largeur de colonne de lecture + Reading Line Spacing Interligne de lecture + Reading Text Size Taille du texte de lecture diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index cb2c0901b..832b5caec 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -52,7 +52,7 @@ pub struct LangInfo { } /// The registry. English first: it is the fallback and the source language. -pub static LANGUAGES: [LangInfo; 16] = [ +pub static LANGUAGES: [LangInfo; 17] = [ LangInfo { code: "en", name: "English", source: "", prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, // Simplified Chinese; `zh`, `zh-CN`, `zh-SG` and `zh-Hans-*` resolve here (see `candidates`). LangInfo { @@ -121,6 +121,8 @@ pub static LANGUAGES: [LangInfo; 16] = [ LangInfo { code: "tr", name: "Türkçe", source: include_str!("tr.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, // Italian; `it-IT`, `it-CH` and `it-*` resolve here. LangInfo { code: "it", name: "Italiano", source: include_str!("it.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, + // French; `fr`, `fr-FR`, `fr_CA` and other regions resolve here. + LangInfo { code: "fr", name: "Français", source: include_str!("fr.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, ]; impl LangInfo { diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 97f74cf40..87a9837d8 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -22,7 +22,8 @@ fn system_locales_map_to_languages() { assert_eq!(lang_from_tag("en-GB"), Some(Lang::EN)); assert_eq!(lang_from_tag("C"), Some(Lang::EN)); assert_eq!(lang_from_tag("POSIX"), Some(Lang::EN)); - assert_eq!(lang_from_tag("fr-FR"), None); + assert_eq!(lang_from_tag("fr-FR"), Some(lang("fr"))); + assert_eq!(lang_from_tag(UNSUPPORTED), None); assert_eq!(lang_from_tag(""), None); assert_eq!(lang_from_tag("_"), None); assert_eq!(lang_from_tag("sr"), Some(lang("sr")), "Cyrillic is the default script"); @@ -37,13 +38,19 @@ fn system_locales_map_to_languages() { assert_eq!(lang_from_tag("DE_de.UTF-8@euro"), Some(lang("de"))); } +/// A language tag no catalog will ever have: `qaa`–`qtz` are reserved for local use (ISO 639-2), +/// so these tests keep working as real languages land. +const UNSUPPORTED: &str = "qaa-QM"; + #[test] fn the_first_supported_preferred_language_wins() { // Windows and macOS list several preferred languages in order. - assert_eq!(first_supported(["fr-FR", "zh-Hant-TW", "en-US"]), Some(lang("zh-hant"))); + assert_eq!(first_supported([UNSUPPORTED, "zh-Hant-TW", "en-US"]), Some(lang("zh-hant"))); assert_eq!(first_supported(["en-US", "ja-JP"]), Some(Lang::EN)); - assert_eq!(first_supported(["fr-FR", "de-DE"]), Some(lang("de"))); - assert_eq!(first_supported(["fr-FR", "nl-NL"]), None); + assert_eq!(first_supported([UNSUPPORTED, "qtz"]), None); + assert_eq!(first_supported(["fr-FR", "de-DE"]), Some(lang("fr"))); + assert_eq!(first_supported(["nl-NL", "de-DE"]), Some(lang("de"))); + assert_eq!(first_supported(["nl-NL", UNSUPPORTED]), None); assert_eq!(first_supported([]), None); } @@ -171,7 +178,7 @@ fn ukrainian_locales_and_saved_preference_work_without_changing_the_document() { for tag in ["uk", "uk-UA", "UK_ua.UTF-8", "uk-Cyrl-UA", "uk_UA.UTF-8@euro"] { assert_eq!(lang_from_tag(tag), Some(uk), "{tag}"); } - assert_eq!(first_supported(["fr-FR", "uk-UA", "en-US"]), Some(uk)); + assert_eq!(first_supported([UNSUPPORTED, "uk-UA", "en-US"]), Some(uk)); let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_engine::sample::sample_document()), Default::default()); let before = serde_json::to_value(&app.session.doc).unwrap(); let result = app.run("ui.language", serde_json::json!({"value": "UK"})).unwrap(); @@ -278,7 +285,7 @@ fn serbian_locales_resolve_by_script() { for tag in ["sr-Latn", "sr-Latn-RS", "sr-latn-me"] { assert_eq!(lang_from_tag(tag), Some(lat), "{tag}"); } - assert_eq!(first_supported(["fr-FR", "sr-Latn-RS", "en-US"]), Some(lat)); + assert_eq!(first_supported([UNSUPPORTED, "sr-Latn-RS", "en-US"]), Some(lat)); assert_eq!(tr(cyr, "File"), "Фајл"); assert_eq!(tr(lat, "File"), "Fajl"); } @@ -292,7 +299,7 @@ fn brazilian_portuguese_locales_and_saved_preference_work_without_changing_the_d // European Portuguese and a bare `pt` have no catalog yet, so they follow the fallback. assert_eq!(lang_from_tag("pt-PT"), None); assert_eq!(lang_from_tag("pt"), None); - assert_eq!(first_supported(["fr-FR", "pt-BR", "en-US"]), Some(pt)); + assert_eq!(first_supported([UNSUPPORTED, "pt-BR", "en-US"]), Some(pt)); let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); let before = serde_json::to_value(&app.session.doc).unwrap(); let result = app.run("ui.language", serde_json::json!({"value": "PT-BR"})).unwrap(); @@ -353,7 +360,7 @@ fn czech_locales_and_saved_preference_work_without_changing_the_document() { for tag in ["cs", "cs-CZ", "cs_CZ.UTF-8", "CS", "cs-Latn-CZ", "cs_CZ.UTF-8@euro"] { assert_eq!(lang_from_tag(tag), Some(cs), "{tag}"); } - assert_eq!(first_supported(["fr-FR", "cs-CZ", "en-US"]), Some(cs)); + assert_eq!(first_supported([UNSUPPORTED, "cs-CZ", "en-US"]), Some(cs)); let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_engine::sample::sample_document()), Default::default()); let before = serde_json::to_value(&app.session.doc).unwrap(); let result = app.run("ui.language", serde_json::json!({"value": "CS"})).unwrap(); @@ -465,7 +472,7 @@ fn russian_locales_and_saved_preference_work_without_changing_the_document() { for tag in ["ru", "ru-RU", "ru_RU.UTF-8", "RU-ru", "ru_UA.UTF-8", "ru-Cyrl-RU"] { assert_eq!(lang_from_tag(tag), Some(ru), "{tag}"); } - assert_eq!(first_supported(["fr-FR", "ru-RU", "en-US"]), Some(ru)); + assert_eq!(first_supported([UNSUPPORTED, "ru-RU", "en-US"]), Some(ru)); let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); let before = serde_json::to_value(&app.session.doc).unwrap(); let result = app.run("ui.language", serde_json::json!({"value": "RU"})).unwrap(); @@ -618,7 +625,7 @@ fn estonian_locales_and_saved_preference_keep_document_content() { for tag in ["et", "et-EE", "ET_ee.UTF-8", "et-Latn-EE", "et_EE.UTF-8@euro"] { assert_eq!(lang_from_tag(tag), Some(et), "{tag}"); } - assert_eq!(first_supported(["fr-FR", "et-EE", "en-US"]), Some(et)); + assert_eq!(first_supported([UNSUPPORTED, "et-EE", "en-US"]), Some(et)); assert_eq!(normalize_pref("ET"), Some("et")); assert_eq!(et.name(), "Eesti"); let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_engine::sample::sample_document()), Default::default()); @@ -669,7 +676,7 @@ fn turkish_locales_and_saved_preference_work_without_changing_the_document() { for tag in ["tr", "tr-TR", "TR_tr.UTF-8", "tr-CY", "tr-Latn-TR", "tr_TR.UTF-8@euro"] { assert_eq!(lang_from_tag(tag), Some(tr), "{tag}"); } - assert_eq!(first_supported(["fr-FR", "tr-TR", "en-US"]), Some(tr)); + assert_eq!(first_supported([UNSUPPORTED, "tr-TR", "en-US"]), Some(tr)); assert_eq!(normalize_pref("TR"), Some("tr")); let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_engine::sample::sample_document()), Default::default()); let before = serde_json::to_value(&app.session.doc).unwrap(); @@ -746,13 +753,69 @@ fn bundled_interface_fonts_cover_turkish_without_system_fallbacks() { assert!(!lang("tr").uses_cjk()); } +#[test] +fn french_locales_and_saved_preference_work_without_changing_the_document() { + let fr = lang("fr"); + for tag in ["fr", "fr-FR", "FR_fr.UTF-8", "fr-CA", "fr_BE.UTF-8@euro", "fr-CH", "fr-Latn-FR"] { + assert_eq!(lang_from_tag(tag), Some(fr), "{tag}"); + } + assert_eq!(first_supported([UNSUPPORTED, "fr-CA", "en-US"]), Some(fr)); + assert_eq!(normalize_pref("FR"), Some("fr")); + assert_eq!(fr.name(), "Français"); + assert_eq!(tr(fr, "Home"), "Accueil"); + assert_eq!(tr(fr, "Save"), "Enregistrer"); + assert_eq!(fmt(tr(fr, "Page {page} of {pages}"), &[("page", "3"), ("pages", "9")]), "Page 3 sur 9"); + assert_eq!(tr(fr, "unknown future label"), "unknown future label"); + let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_engine::sample::sample_document()), Default::default()); + let before = serde_json::to_value(&app.session.doc).unwrap(); + let result = app.run("ui.language", serde_json::json!({"value": "FR"})).unwrap(); + assert_eq!(result["effective"], "fr"); + assert_eq!(app.ui.language, "fr"); + assert!(result["available"].as_array().unwrap().iter().any(|l| l["code"] == "fr" && l["name"] == "Français")); + assert_eq!(serde_json::to_value(&app.session.doc).unwrap(), before); + let saved = serde_json::to_string(&app.ui).unwrap(); + let restored: crate::UiState = serde_json::from_str(&saved).unwrap(); + assert_eq!(Lang::from_pref(&restored.language), fr); + assert!(app.run("ui.language", serde_json::json!({"value": "fr-unknown"})).is_err()); + assert_eq!(app.ui.language, "fr", "invalid preferences must keep the saved language"); + app.run("ui.language", serde_json::json!({"value": "auto"})).unwrap(); + assert_eq!(app.ui.language, AUTO); +} + +#[test] +fn bundled_interface_fonts_cover_french_without_system_fallbacks() { + let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new()); + let (entries, errors) = parse_entries(lang("fr").0.source); + assert!(errors.is_empty()); + // French letters and punctuation: accents, ligatures, guillemets and the typographic apostrophe. + let extra = "ÀàÂâÆæÇçÉéÈèÊêËëÎîÏïÔôŒœÙùÛûÜüŸÿ«»’"; + let chars: std::collections::HashSet = + entries.iter().flat_map(|e| e.translation.chars()).filter(|c| ('\u{00C0}'..='\u{017F}').contains(c)).chain(extra.chars()).collect(); + for (family, style) in [("Inter", "Regular"), ("Inter", "Medium"), ("Inter", "SemiBold"), ("JetBrains Mono", "Regular")] { + let face = db.face(family, style); + assert_eq!(face.family, family, "must use the bundled interface face"); + for ch in &chars { + assert_ne!(face.glyph_for(*ch), 0, "{family} {style} lacks {ch:?}"); + } + } + // The no-break spaces French puts before ; : ! ? and inside « » (the catalog uses plain + // spaces today) are in the interface faces too. + for style in ["Regular", "Medium", "SemiBold"] { + let face = db.face("Inter", style); + for ch in ['\u{00A0}', '\u{202F}'] { + assert_ne!(face.glyph_for(ch), 0, "Inter {style} lacks {ch:?}"); + } + } + assert!(!lang("fr").uses_cjk()); +} + #[test] fn polish_locales_and_saved_preference_work_without_changing_the_document() { let pl = lang("pl"); for tag in ["pl", "pl-PL", "pl_PL.UTF-8", "PL-pl", "pl_PL.UTF-8@euro", "pl-Latn-PL"] { assert_eq!(lang_from_tag(tag), Some(pl), "{tag}"); } - assert_eq!(first_supported(["fr-FR", "pl-PL", "en-US"]), Some(pl)); + assert_eq!(first_supported([UNSUPPORTED, "pl-PL", "en-US"]), Some(pl)); let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_engine::sample::sample_document()), Default::default()); let before = serde_json::to_value(&app.session.doc).unwrap(); let result = app.run("ui.language", serde_json::json!({"value": "PL"})).unwrap(); @@ -859,7 +922,7 @@ fn german_locales_and_saved_preference_keep_document_content() { for tag in ["de", "de-DE", "de-AT", "de-CH", "DE_de.UTF-8", "de_DE.UTF-8@euro", "de-Latn-DE"] { assert_eq!(lang_from_tag(tag), Some(de), "{tag}"); } - assert_eq!(first_supported(["fr-FR", "de-AT", "en-US"]), Some(de)); + assert_eq!(first_supported([UNSUPPORTED, "de-AT", "en-US"]), Some(de)); assert_eq!(normalize_pref("DE"), Some("de")); assert_eq!(de.name(), "Deutsch"); let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_engine::sample::sample_document()), Default::default()); @@ -910,7 +973,7 @@ fn italian_locales_and_saved_preference_keep_document_content() { for tag in ["it", "it-IT", "it_IT.UTF-8", "it-CH", "it_IT.UTF-8@euro"] { assert_eq!(lang_from_tag(tag), Some(it), "{tag}"); } - assert_eq!(first_supported(["fr-FR", "it-IT", "en-US"]), Some(it)); + assert_eq!(first_supported([UNSUPPORTED, "it-IT", "en-US"]), Some(it)); assert_eq!(normalize_pref("IT"), Some("it")); assert_eq!(it.name(), "Italiano"); let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_engine::sample::sample_document()), Default::default()); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index ff021c9a4..935576e3a 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -50,7 +50,7 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key. | Method | Params | Result | |---|---|---| -| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog`, `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"|"en"|"zh-hans"|"zh-hant"|"ja"|"pt-br"|"es"|"uk"|"sr"|"sr-latn"|"et"|"tr"|"ru"|"pl"|"de"|"it"|"cs"}`), `ui.theme` (`{"value": "system"|"light"|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"percent": 80–200}`, clamped; no value reads it; scales the interface on top of the system display scale, document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"|"on"|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"|"picture"|"recipients"|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.caretAnimation` (`{"value": true|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading)…) | +| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog`, `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"|"en"|"zh-hans"|"zh-hant"|"ja"|"pt-br"|"es"|"uk"|"sr"|"sr-latn"|"et"|"tr"|"ru"|"pl"|"de"|"it"|"cs"|"fr"}`), `ui.theme` (`{"value": "system"|"light"|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"percent": 80–200}`, clamped; no value reads it; scales the interface on top of the system display scale, document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"|"on"|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"|"picture"|"recipients"|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.caretAnimation` (`{"value": true|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading)…) | | `engine.commands` | — | every command: id, label, location, shortcut, params, enabled | | `document.inspect` | `text?` | blocks (text, style, runs, lists, tables), parts, sections, selection, pages | | `ui.inspect` | — | UI state, view, dialog, window size, page rects on screen, caret, comment balloons (`balloons`: id + screen rect; `balloon`: the selected one), perf | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 288556f3e..aabbbf8dc 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (French interface added; previously minor: Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Interface languages, script support and proofing per language. How translation works (catalog format, adding a language, clean-room rule): `crates/ui-egui/src/i18n/mod.rs`. @@ -19,8 +19,8 @@ native-speaker review (human). ## What WordCraft ships (measured) -- **16 interface languages**: English plus `zh-hans`, `zh-hant`, `ja`, `uk`, `es`, `pt-br`, - Serbian `sr`/`sr-latn` (#250), Estonian `et`, Turkish `tr`, Russian `ru`, Italian `it` (#594), German `de` (#574), Polish `pl` and Czech `cs`. Catalogs have 2,021–2,399 entries +- **17 interface languages**: English plus `zh-hans`, `zh-hant`, `ja`, `uk`, `es`, `pt-br`, + Serbian `sr`/`sr-latn` (#250), Estonian `et`, Turkish `tr`, Russian `ru`, Italian `it` (#594), German `de` (#574), Polish `pl`, Czech `cs` and French `fr`. Catalogs have 2,021–2,399 entries each; Polish has every key of every other catalog (a test checks it). A test (`every_tab_and_command_is_translated`) enforces that every ribbon tab, command label and ribbon location is translated in every language. `pl`, `pl-PL` and `pl_PL.UTF-8` system locales pick Polish automatically. @@ -49,7 +49,7 @@ shared items below. | Spanish | es | ~930; 98% / ~95% | most | Latin ✅ | no | partial | 1–2 | | Hindi | hi | 0 | ❌ | Devanagari shaping via HarfRust, untested; no hyphenation | no | none | 4–6 | | Arabic | ar | 0 | ❌ | shaping, joining, lam-alef, bidi ✅ (#207); no kashida; **no mirrored UI** | no | none | 6–8 | -| French | fr | 0 | ❌ | Latin ✅ | no | none | 3–4 | +| French | fr | 2,033 entries | most; newer dialogs partly English | Latin ✅ | no | partial | 1–2 | | Portuguese | pt-br (pt-PT none) | ~930; 98% / ~95% (pt-BR) | most | Latin ✅ | no | partial | 2–4 (pt-BR finish + pt-PT) | | Indonesian | id | 0 | ❌ | Latin ✅ | no | none | 3–4 | | Japanese | ja | ~930; 98% / ~95% | most | CJK ✅, IME ✅; no vertical text, ruby or document grid | no | partial | 3–5 | @@ -107,6 +107,7 @@ allows); all other text is written from the meaning of the English. No Italian p | 2026-10-11 | minor | German interface catalog (#574): 2,021 entries covering the full zh-hans key set; locale, saved-preference and bundled-font tests; no German proofing | | 2026-10-11 | minor | Italian interface catalog (#594), locale selection, saved preference and font coverage checks; count labels put the noun first so they read right at 1 | | 2026-10-11 | minor | Font dialog sets the Asian text font (`w:eastAsia`) and the Latin text font separately; the Asian list holds the installed Chinese, Japanese and Korean fonts by their interface-language names (#505) | +| 2026-10-11 | minor | French interface catalog (2,033 entries, @pechnikov2026-star), `fr-*` system locales, saved preference and font coverage checks; no French proofing | | 2026-10-11 | minor | Font list shows installed fonts by their names in the interface language (宋体 for SimSun in Chinese, Japanese and Korean names likewise) and finds them by either name; a Chinese, Japanese or Korean font is stored as `w:eastAsia` too (#505) | | 2026-10-11 | minor | Chinese/Japanese interface fonts: a test checks every catalog character has a glyph; an installed CJK font follows the embedded faces whenever they miss one; macOS font asset folders searched for PingFang (#486) | | 2026-10-10 | minor | Russian interface (#300; 1,524 entries, built-in style names shown translated; covers everything the interface audit finds) | From e22ce1d05e0288bf13bde4add06e85e03640d699 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bihacsy=20L=C3=A1szl=C3=B3?= <115165402+Skaywalker@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:45:30 +0200 Subject: [PATCH 33/88] i18n(hu): complete the Hungarian catalog again (#842) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * i18n: add Hungarian (hu) interface translation - crates/ui-egui/src/i18n/hu.tsv: complete catalog, all 1,572 keys of es.tsv in the same order, written clean-room from the plain meaning of the English labels; placeholders, escapes and trailing ellipses kept. Commands are nouns, messages use the informal second person, and only the first word of a label is capitalised. A noun after a number stays singular in Hungarian, so the context-free format needs no plural forms. - LANGUAGES gains `hu` (Magyar); `hu`, `hu-HU` and `hu_HU.UTF-8` resolve to it. - The Font dialog's preview sample is "AaBbYyZz ŐőŰű", so All Caps shows the double acute capitals. - Tests: locale resolution and saved preference, key parity with the Spanish catalog, sample translations, count wording, the sample's capitals and Hungarian letters in the bundled interface fonts. - Docs: new localization-hu.md (conventions, terminology, scope); README, ROADMAP, localization-parity and control-protocol list the new language; ATTRIBUTION row for hu.tsv. * i18n(hu): built-in style names, equation gallery and citation styles - hu.tsv now carries every key of the Russian catalog: built-in style names (Normal, No Spacing, Subtitle, TOC {n}…), the equation gallery and structure labels, paper sizes, watermarks and the Backstage greetings, plus the citation style names (1,946 entries). - The interface audit (untranslated_interface_text) checks Hungarian alongside Russian and reports every language's gaps in one run. - Tests: Hungarian keys cover the Russian catalog; built-in style names and numbered style templates show in Hungarian. - Docs: entry counts and scope in localization-hu, localization-parity and ROADMAP. * Review fixes for #731: Hungarian stays a partial catalog - The i18n audit requires completeness from Russian only again: hu.tsv lacks ~280 strings of the dialogs that landed since (Compare Documents, Math AutoCorrect, …), so it can't claim to be complete. The catalog test no longer asserts it holds every Russian/Spanish key. - 43 Hungarian rows for tab and command labels that reached main since (charts, content controls, Developer tab, templates and the styles organizer, WordArt), which every_tab_and_command_is_translated requires. - Docs say partial, with the entry count (1,989); localization-parity's header keeps its quote marker and the #505 note. Co-Authored-By: Claude Opus 5.5 * Review fixes for #731: Hungarian rows for the Outlining tab 24 rows for Outline view's tab and commands, which landed on main meanwhile; 2,013 entries. Co-Authored-By: Claude Opus 5.5 * Review fixes for #731: ROADMAP header keeps main's SmartArt note Co-Authored-By: Claude Opus 5.5 * i18n(hu): complete the Hungarian catalog again - hu.tsv: 374 new rows for the strings that reached main while #731 was in review (Compare Documents, Restrict Editing, Math AutoCorrect, charts, SmartArt, the Options sections, the print page and more). The catalog now holds every key of ru.tsv again: 2,397 entries. - The completeness checks are back: the interface audit requires Hungarian alongside Russian, and the catalog test asserts hu.tsv holds every key of ru.tsv and es.tsv. The docs describe a complete catalog. - Seven review rows follow the catalog's existing patterns: Text Pane (Szöveg panel, like the other panes), Design Mode (Tervezőmód, one compound), Picture and Date Picker Content Control (clearer compounds), Promote to Heading 1 / Demote to Body Text (style name after a colon, no case ending) and Reset Graphic (Ábra visszaállítása, shorter). * Hungarian: translate interface strings added on main since this PR Adds the 10 strings main gained with Polish proofing (spelling and grammar messages, Do Not Check Spelling or Grammar, comment/browse buttons), so hu.tsv again covers every key of the other catalogs (2,419 entries), and drops the stale "Wildcards (regex)" row; updates the documented counts. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ATTRIBUTION.md | 1 + README.md | 2 +- ROADMAP.md | 9 +- crates/ui-egui/src/i18n/audit.rs | 23 +- crates/ui-egui/src/i18n/hu.tsv | 2453 ++++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/mod.rs | 4 +- crates/ui-egui/src/i18n/tests.rs | 85 +- docs/control-protocol.md | 2 +- docs/localization-hu.md | 75 + docs/localization-parity.md | 16 +- 10 files changed, 2648 insertions(+), 22 deletions(-) create mode 100644 crates/ui-egui/src/i18n/hu.tsv create mode 100644 docs/localization-hu.md diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 8ba915605..5856d7352 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -70,4 +70,5 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra | `crates/ui-egui/src/i18n/de.tsv` | @arekown with Kimi | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | German interface catalog | | `crates/ui-egui/src/i18n/ru.tsv` | Dmitrii Khlapov with Claude | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Russian interface catalog; reviewed by a native speaker | | `crates/ui-egui/src/i18n/tr.tsv` | @PbyLoyth with Codex and Claude Code | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Turkish interface catalog | +| `crates/ui-egui/src/i18n/hu.tsv` | @Skaywalker | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Hungarian interface catalog | | `crates/ui-egui/src/i18n/fr.tsv` | @pechnikov2026-star | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | French interface catalog | diff --git a/README.md b/README.md index 706786935..0833ed4c7 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ Every screenshot below is WordCraft itself, rendered offscreen by its own UI tes | **References** | Table of contents, footnotes and endnotes, citations and bibliography (APA, MLA, Chicago, IEEE), captions, table of figures, cross-references, index, table of authorities | | **Review** | Spelling and grammar with suggestions (English and Polish, by each run's language), thesaurus, word count, comments in margin balloons or a pane, track changes, accept/reject, compare documents, restrict editing, accessibility checker, document inspector | | **Mailings** | Mail merge from CSV, merge fields, address block, greeting line, rules, preview, finish to a document; envelopes and labels | -| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский, Italiano, Deutsch, Polski, Čeština or Français (follows the system language by default) | +| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский, Italiano, Deutsch, Polski, Čeština, Magyar or Français (follows the system language by default) | | **Files** | .docx read/write (opens in Word), Word 97-2003 .doc import, PDF export, .odt, .rtf, .html, .md, .tex, .txt import/export, page images | The honest picture, area by area, is in [ROADMAP.md](ROADMAP.md), the diff --git a/ROADMAP.md b/ROADMAP.md index f4eb8cb53..fe5abf14d 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 (Reading Mode completed; 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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Hungarian interface landed; previously minor: Polish interface and Polish proofing; previously trivial: table borders and the resize handle drag on the page, #217; 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 @@ -102,7 +102,7 @@ Weights and evidence per area: [docs/target-app-parity.md](docs/target-app-parit | Spanish | es | partial | ~95% | | Hindi | hi | none | 0% | | Arabic | ar | none (document shaping and bidi work; no mirrored UI) | 0% | -| French | fr | none | 0% | +| French | fr | partial (interface) | ~95% | | Portuguese | pt-br (pt-PT none) | partial | ~95% | | Indonesian | id | none | 0% | | Japanese | ja | partial | ~95% | @@ -111,7 +111,8 @@ Weights and evidence per area: [docs/target-app-parity.md](docs/target-app-parit | Vietnamese | vi | none | 0% | Also shipped: Traditional Chinese (`zh-hant`), Ukrainian (`uk`), Serbian (`sr`, `sr-latn`, #250), Russian (`ru`) and Polish (`pl`), all partial (~95%). Estonian (`et`), Turkish (`tr`), Italian (`it`, #594) and Czech (`cs`) now have complete interface catalogs with -system-locale selection and a saved language choice (Italian also has 51 strings none of the others had yet); Estonian, Turkish, Italian and Czech proofing are not included. +system-locale selection and a saved language choice (Italian also has 51 strings none of the others had yet); Estonian, Turkish, Italian and Czech proofing are not included. Hungarian (`hu`) has a +complete 2,419-entry catalog, also without proofing. Proofing: English and Polish (spelling, grammar, hyphenation), chosen per run from its language. Word ships 30 interface languages and proofing for ~50. Detail: [docs/localization-parity.md](docs/localization-parity.md). @@ -193,6 +194,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-12 | Native saves use exclusively created sibling temporary files, synchronize before replacement and clean up on failure; existing temporary-name files/links and original files on failed saves are protected. Synthetic CLI and focused tests on macOS; no new Word parity or readiness claim. | | 2026-10-11 | Field codes like Word's field keys (#339): show codes (Alt+F9, Shift+F9), edit, unlink, lock and step through fields | | 2026-10-11 | Word's common keyboard shortcuts (#330): 101 → 125 of ~250 | +| 2026-10-11 | Hungarian (`hu`) interface: all 2,419 catalog entries, built-in style names translated, `hu-HU` system locales and a saved language choice; locale, catalog and bundled-font tests; no Hungarian proofing resources | | 2026-10-11 | Turkish (`tr`) interface: all 1,307 catalog entries, `tr-TR`/`tr-CY` system locales and a saved language choice; command search keeps Turkish dotted and dotless I apart (`italik` finds İtalik); locale, catalog, search and bundled-font tests; no Turkish proofing resources | | 2026-10-11 | Italian (`it`) interface (#594): 2,399 entries, feature names as Italian Word users know them, locale and saved-preference tests, bundled-font checks; no Italian proofing resources | | 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) | @@ -254,6 +256,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-12 | trivial | Native saves protect existing temporary-name entries and originals on pre-replacement failure; synthetic macOS evidence, explicit durability/platform limits; readiness unchanged | | 2026-10-11 | trivial | Word's common keyboard shortcuts (#330): 125 of ~250 | | 2026-10-11 | trivial | Collapsed duplicate rows left by parallel merges | +| 2026-10-11 | trivial | Hungarian interface (`hu`): progress log and shipped languages | | 2026-10-11 | trivial | Turkish interface (`tr`): progress log and shipped languages | | 2026-10-11 | trivial | Progress log: eight more bibliography styles (#383) | | 2026-10-10 | trivial | Progress log: Draw tab ink (#307) | diff --git a/crates/ui-egui/src/i18n/audit.rs b/crates/ui-egui/src/i18n/audit.rs index bb13f0970..51d03a12f 100644 --- a/crates/ui-egui/src/i18n/audit.rs +++ b/crates/ui-egui/src/i18n/audit.rs @@ -311,12 +311,13 @@ fn untranslated_interface_text() { report.push_str(&format!("{:?} [{}]\n", pseudo::unmarked(text), states.join(", "))); } assert!(found.is_empty(), "{} interface texts skip translation (wrap them in tl!):\n{report}", found.len()); - russian_has_every_string_the_interface_looked_up(pseudo::looked_up()); + complete_catalogs_have_every_string_the_interface_looked_up(pseudo::looked_up()); } -/// Russian covers everything the audit saw translated, plus the equation gallery's labels (shown -/// in menus the audit doesn't open one by one). Numbered style names go through a template. -fn russian_has_every_string_the_interface_looked_up(mut keys: std::collections::BTreeSet) { +/// Russian and Hungarian cover everything the audit saw translated, plus the equation gallery's +/// labels (shown in menus the audit doesn't open one by one). Numbered style names go through a +/// template. +fn complete_catalogs_have_every_string_the_interface_looked_up(mut keys: std::collections::BTreeSet) { use wordcraft_engine::math_gallery::{BUILT_INS, STRUCTURES, SYMBOL_SETS}; keys.extend(BUILT_INS.iter().map(|(_, label, _)| label.to_string())); keys.extend(SYMBOL_SETS.iter().map(|(name, _)| name.to_string())); @@ -327,10 +328,16 @@ fn russian_has_every_string_the_interface_looked_up(mut keys: std::collections:: keys.extend(templates.iter().map(|t| t.label.to_string())); } } - let ru = super::Lang::from_code("ru").unwrap_or(super::Lang::EN); let numbered = |s: &str| ["Heading ", "TOC "].iter().any(|p| s.strip_prefix(p).is_some_and(|n| n.chars().all(|c| c.is_ascii_digit()))); - // Names that read the same in Russian. + // Names that read the same in these languages. let same = |s: &str| matches!(s, "LaTeX" | "LaTeX (*.tex)" | "Unicode" | "I. II. III." | "i. ii. iii."); - let missing: Vec = keys.into_iter().filter(|s| english_words(s) && !super::has(ru, s) && !numbered(s) && !same(s)).collect(); - assert!(missing.is_empty(), "{} strings have no Russian entry:\n{missing:#?}", missing.len()); + let mut report = String::new(); + for code in ["ru", "hu"] { + let lang = super::Lang::from_code(code).unwrap_or(super::Lang::EN); + let missing: Vec<&String> = keys.iter().filter(|s| english_words(s) && !super::has(lang, s) && !numbered(s) && !same(s)).collect(); + if !missing.is_empty() { + report.push_str(&format!("{} strings have no {code} entry:\n{missing:#?}\n", missing.len())); + } + } + assert!(report.is_empty(), "{report}"); } diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv new file mode 100644 index 000000000..82e742942 --- /dev/null +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -0,0 +1,2453 @@ +# Hungarian (hu) interface catalog. Columns, separated by a TAB: +# context English source translation +# context empty (WordCraft uses no contexts yet) +# source the English text exactly as in the code (tl!(...), ribbon labels, command labels) +# escapes \n \t \\ inside a column. `{name}` placeholders must appear in both columns, and a +# trailing "…" in one needs one in the other. +# Lines starting with # and blank lines are ignored. A missing entry shows in English. +# +# Commands are nouns (Mentés, Másolás); second-person text uses the informal te form; labels +# capitalise only the first word, as Hungarian spelling does; product and format names stay as +# they are (WordCraft, PDF, Markdown, MCP …). A noun after a number stays singular (3 oldal), so +# no plural forms are needed. Placeholders never take a case ending: the wording puts them after +# a colon or in parentheses instead. +# Clean room: every entry was written from the plain meaning of the English label in ordinary +# computing vocabulary. No Microsoft Word (or other product) localisation, string table or +# translated UI was consulted or copied; add entries the same way. + + (no suggestions) (nincs javaslat) + 1.5 Line Spacing 1,5-es sorköz + A free, open-source word processor written from scratch in Rust.\nPart of the Crafting Apps from the ArtCraft team. Ingyenes, nyílt forráskódú szövegszerkesztő, az alapoktól Rustban írva.\nA Crafting Apps családba tartozik, az ArtCraft csapatától. + AaBbCcDd AaÁáBbCc + About Névjegy + About WordCraft A WordCraft névjegye + Accept Elfogadás + Accept All Changes Összes módosítás elfogadása + Accept This Change E módosítás elfogadása + Accessibility Akadálymentesség + Add Hozzáadás + Add a comment Megjegyzés hozzáadása + Add a Table Táblázat hozzáadása + Add Space Before Paragraph Térköz a bekezdés előtt + Add Text Szöveg hozzáadása + Add to Dictionary Felvétel a szótárba + Add to Style Gallery Felvétel a stílusgalériába + Address Block Címblokk + Address: Cím: + Address\nBlock Címblokk + Adjust Beállítás + Advanced Speciális + After: Utána: + Agents Ágensek + Align Igazítás + Align Left Balra igazítás + Align Right Jobbra igazítás + Alignment Igazítás + All Borders Minden szegély + All Caps Csupa nagybetű + All caps Csupa nagybetű + All Markup Minden jelölés + Alt Text Helyettesítő szöveg + Annotation Reference Jegyzethivatkozás + Annotation Subject Jegyzet tárgya + Annotation Text Jegyzetszöveg + Apply Character Style Karakterstílus alkalmazása + Apply Style Stílus alkalmazása + Arrange Elhelyezés + Arrange\nAll Ablakok\nrendezése + Arrow Nyíl + Artistic Effects Művészi effektusok + Author Szerző + AutoCorrect Options Automatikus javítás beállításai + AutoFit Automatikus méretezés + AutoFit Contents Igazítás a tartalomhoz + AutoFit Window Igazítás az ablakhoz + Automatic Automatikus + Automatic ({language}) Automatikus ({language}) + Automatic follows your system's language. Menus and commands change; your documents don't. Az Automatikus a rendszer nyelvét követi. A menük és a parancsok nyelve változik, a dokumentumaidé nem. + Automatic Table 1 (3 levels) Automatikus tartalomjegyzék 1 (3 szint) + Automatic Table 2 (2 levels) Automatikus tartalomjegyzék 2 (2 szint) + AutoSave Automatikus mentés + AutoSave saves every change to the file (needs a saved document) Az automatikus mentés minden módosítást a fájlba ír (ehhez mentett dokumentum kell) + AutoText Autoszöveg + Backspace Backspace + Banded Columns Sávos oszlopok + Banded Rows Sávos sorok + Before: Előtte: + Bibliography Irodalomjegyzék + Binary assets added Hozzáadott bináris elemek + Binary assets removed Eltávolított bináris elemek + Blank Üres + Blank (Three Columns) Üres (három oszlop) + Blank document Üres dokumentum + Blank Page Üres oldal + Block\nAuthors Szerzők\nletiltása + Body text in a short paragraph to show spacing. Szövegtörzs egy rövid bekezdésben, hogy látszódjon a térköz. + Bold Félkövér + Book Title Könyvcím + Bookmark Könyvjelző + Bookmark name: Könyvjelző neve: + Border Painter Szegélyfestő + Border\nPainter Szegély-\nfestő + Borders Szegélyek + Bottom Border Alsó szegély + Bottom of Page Oldal alja + Bottom: Alul: + Box Keret + Breaks Törések + Bring Forward Előrébb hozás + Bullet Library Felsorolásjel-tár + Bullets Felsorolásjelek + Calendar Naptár + Cancel Mégse + Capitalize Each Word Minden Szó Nagy Kezdőbetűs + Caption Felirat + Captions Feliratok + Category Kategória + Cell Margins Cellamargók + Cell Size Cellaméret + Cell\nMargins Cellamargók + Center Középre igazítás + Change Módosítás + Change Case Kis- és nagybetűk + Change List Level Listaszint módosítása + Change Picture Kép cseréje + Change Shape Alakzat cseréje + Changes Módosítások + Character Position Karakter helyzete + Character Scale Karakterméretezés + Character Shading Karakterárnyékolás + Character Spacing Betűköz + Characters (no spaces) Karakterek (szóközök nélkül) + Characters (with spaces) Karakterek (szóközökkel) + Chart Diagram + Check Accessibility Akadálymentesség ellenőrzése + Check Compatibility Kompatibilitás ellenőrzése + Check for Errors Hibák keresése + Check Spelling as You Type Helyesírás ellenőrzése gépelés közben + Check\nAccessibility Akadálymentesség\nellenőrzése + Citation Style Hivatkozási stílus + Citations & Bibliography Forráshivatkozások és irodalomjegyzék + Clear Törlés + Clear All Formatting Minden formázás törlése + Clipboard Vágólap + Close Bezárás + Close Header and Footer Élőfej és élőláb bezárása + Collapse Selection Kijelölés összehúzása + Color Szín + Color: Szín: + Colors Színek + Column Oszlop + Column Break Hasábtörés + Columns Hasábok + Combine Kombinálás + Commands Parancsok + Comment Megjegyzés + Comments Megjegyzések + Comments Pane Megjegyzések panel + Community\nDiscord Közösségi\nDiscord + Compact Tömör + Compare Összehasonlítás + Compress Pictures Képek tömörítése + Continuous Folyamatos + Convert Átalakítás + Convert Text to Table Szöveg átalakítása táblázattá + Convert Text to Table… Szöveg átalakítása táblázattá… + Convert to Text Átalakítás szöveggé + Copy Másolás + Copy Formatting Formázás másolása + Corrections Korrekciók + Cover Page Címlap + Create Létrehozás + Create a Style Stílus létrehozása + Create an interactive outline of your document.\n\nIt's a great way to keep track of where you are or quickly move your content around.\n\nTo get started, go to the Home tab and apply Heading styles to the headings in your document. Készíts interaktív vázlatot a dokumentumodról.\n\nÍgy mindig látod, hol tartasz, és gyorsan átrendezheted a tartalmat.\n\nKezdésként a Kezdőlap lapon alkalmazz címsorstílusokat a dokumentum címeire. + Create New Style Új stílus létrehozása + Crop Körülvágás + Cross-reference Kereszthivatkozás + Current Position Jelenlegi pozíció + Current style: {style} Jelenlegi stílus: {style} + Custom Margins… Egyéni margók… + Custom Watermark Egyéni vízjel + Custom Watermark… Egyéni vízjel… + Customize Quick Access Toolbar Gyorselérési eszköztár testreszabása + Cut Kivágás + Dark Mode Sötét mód + Dashed Szaggatott + Data Adatok + Date & Time Dátum és idő + Decrease Font Size Betűméret csökkentése + Decrease Indent Behúzás csökkentése + Default Alapértelmezett + Default Paragraph Font Alapértelmezett bekezdésbetűtípus + Define New Bullet Új felsorolásjel megadása + Define New Number Format Új számformátum megadása + Delete Törlés + Delete Cells Cellák törlése + Delete Columns Oszlopok törlése + Delete Comment Megjegyzés törlése + Delete Next Word Következő szó törlése + Delete Previous Word Előző szó törlése + Delete Rows Sorok törlése + Delete Style Stílus törlése + Delete Table Táblázat törlése + Design Tervezés + development build fejlesztői kiadás + Diagonal Átlós + Diamond Rombusz + Dictate Diktálás + Different First Page Eltérő első oldal + Different Odd & Even Pages Eltérő páros és páratlan oldalak + Discord Discord + Discord community Discord-közösség + Display Megjelenítés + Display for Review Megjelenítés véleményezéshez + Distribute Columns Oszlopok egyenletes elosztása + Distribute Rows Sorok egyenletes elosztása + Distributed Egyenletesen elosztott + Do Not Show in TOC Ne jelenjen meg a tartalomjegyzékben + Document End Dokumentum vége + Document Formatting Dokumentumformázás + Document Property Dokumentumtulajdonság + Document Start Dokumentum eleje + Document Text Dokumentum szövege + Document Title Dokumentum címe + Documents you open will show up here. A megnyitott dokumentumaid itt jelennek meg. + Dot-dash Pont-vonal + Dotted Pontozott + Double Dupla + Double Spacing Dupla sorköz + Double Strikethrough Dupla áthúzás + Double Underline Dupla aláhúzás + Down Le + Draft Piszkozat + Draw Rajzolás + Drawing Tools Rajzeszközök + Drawing\nCanvas Rajzvászon + Drop Cap Iniciálé + Edit Footer Élőláb szerkesztése + Edit Header Élőfej szerkesztése + Edit Recipient List Címzettlista szerkesztése + Edit\nRecipient List Címzettlista\nszerkesztése + Edited Szerkesztve + Editing Szerkesztés + Editing header/footer Élőfej és élőláb szerkesztése + Editing — edit the document directly Szerkesztés – a dokumentum közvetlen módosítása + Editor Szerkesztő + Effects Effektusok + Emboss Domború + Emphasis Hangsúlyos + Encrypt with Password Titkosítás jelszóval + Endnote Reference Végjegyzet-hivatkozás + Endnote Text Végjegyzetszöveg + English (United States) Angol (Egyesült Államok) + Engrave Vésett + Enter page number: Add meg az oldalszámot: + Envelope Address Borítékcím + Envelope Return Feladó címe + Envelopes Borítékok + Equation Egyenlet + Eraser Radír + Even Page Páros oldal + Every command is available to scripts and AI agents: run `wordcraft-cli mcp` for an MCP server, or start the app with `--control ` for the JSON control channel. Minden parancs elérhető szkriptekből és MI-ágensekből is: MCP-szerverhez futtasd a `wordcraft-cli mcp` parancsot, a JSON-vezérlőcsatornához pedig indítsd az alkalmazást a `--control ` kapcsolóval. + Export Exportálás + Export a copy to share (PDF, Word document) Másolat exportálása megosztáshoz (PDF, Word-dokumentum) + Export Page as PNG Oldal exportálása PNG-ként + Export PDF PDF exportálása + Exported {path} Exportálva: {path} + Extend Selection Kijelölés bővítése + Extend Selection by Units Kijelölés bővítése egységenként + Field Mező + File Fájl + Find Keresés + Find and Replace Keresés és csere + Find Next Következő keresése + Find Previous Előző keresése + Find Recipient Címzett keresése + Find what: Keresett szöveg: + Finish Befejezés + Finish & Merge Befejezés és egyesítés + Finish &\nMerge Befejezés és\negyesítés + First Column Első oszlop + First commit Első commit + First line: Első sor: + Fixed Column Width Rögzített oszlopszélesség + Focus Fókusz + Font Betűtípus + Font Color Betűszín + Font color: Betűszín: + Font Dialog Betűtípus párbeszédablak + Font Size Betűméret + Font: Betűtípus: + Fonts Betűtípusok + Footer Élőláb + Footnote and Endnote Lábjegyzet és végjegyzet + Footnote Reference Lábjegyzet-hivatkozás + Footnote Text Lábjegyzetszöveg + Footnotes Lábjegyzetek + Format Formátum + Format Page Numbers Oldalszámok formázása + Format Page Numbers… Oldalszámok formázása… + Format Painter Formátummásoló + Formatting at Selection A kijelölés formázása + Formula Képlet + General Általános + Go To Ugrás + Go to Ugrás + Go To (⌘⌥G) Ugrás (⌘⌥G) + Grab bag Vegyes + Greeting Line Megszólítás + Greeting\nLine Megszólítás + Gridlines Rácsvonalak + Group Csoportosítás + Grow Font 1 Point Betűméret növelése 1 ponttal + Hanging Indent Függő behúzás + Header Élőfej + Header & Footer Élőfej és élőláb + Header Row Fejlécsor + Header/Footer Position Élőfej és élőláb helyzete + Heading 1 Címsor 1 + Heading 2 Címsor 2 + Heading 3 Címsor 3 + Heading {n} Címsor {n} + Headings Címsorok + Heart Szív + Help Súgó + Hidden Rejtett + Highlight Merge Fields Egyesítési mezők kiemelése + Highlight\nMerge Fields Egyesítési mezők\nkiemelése + Highlighter Szövegkiemelő + Home Kezdőlap + Horizontal Vízszintes + Horizontal Line Vízszintes vonal + Hyperlink Link + Hyphenation Elválasztás + Icons Ikonok + Ignore All Összes kihagyása + Illustrations Illusztrációk + Immersive Elmélyült + Immersive Reader Elmélyült olvasás + Immersive\nReader Elmélyült\nolvasás + Increase Font Size Betűméret növelése + Increase Indent Behúzás növelése + Indent Behúzás + Indentation Behúzás + Indents Behúzások + Index Tárgymutató + Index Heading Tárgymutató-címsor + Index {n} Tárgymutató {n} + Info Információ + Ink to\nMath Tintából\nképlet + Ink to\nShape Tintából\nalakzat + Ink\nReplay Tinta\nvisszajátszása + Insert Beszúrás + Insert Above Beszúrás fölé + Insert Below Beszúrás alá + Insert Caption Felirat beszúrása + Insert Citation Forráshivatkozás beszúrása + Insert Endnote Végjegyzet beszúrása + Insert Footnote Lábjegyzet beszúrása + Insert Hyperlink Link beszúrása + Insert Index Tárgymutató beszúrása + Insert Left Beszúrás balra + Insert Merge Field Egyesítési mező beszúrása + Insert Merge\nField Egyesítési mező\nbeszúrása + Insert Right Beszúrás jobbra + Insert Shapes Alakzatok beszúrása + Insert Table Táblázat beszúrása + Insert Table of Authorities Jogforrásjegyzék beszúrása + Insert Table of Figures Ábrajegyzék beszúrása + Insert Table… Táblázat beszúrása… + Insert\nAbove Beszúrás\nfölé + Insert\nCaption Felirat\nbeszúrása + Insert\nCitation Forráshivatkozás\nbeszúrása + Insert\nFootnote Lábjegyzet\nbeszúrása + Inside Borders Belső szegélyek + Inspect Document Dokumentum vizsgálata + Intense Emphasis Erősen hangsúlyos + Intense Quote Kiemelt idézet + Intense Reference Erős hivatkozás + Interface language: Felület nyelve: + Italic Dőlt + Join the ArtCraft community on Discord Csatlakozz az ArtCraft közösséghez a Discordon + Join us on Discord: discord.gg/artcraft Csatlakozz hozzánk a Discordon: discord.gg/artcraft + Justify Sorkizárás + Keep Lines Together Sorok együtt tartása + Keep lines together Sorok együtt tartása + Keep Text Only Csak a szöveg megtartása + Keep with Next Együtt a következővel + Keep with next Együtt a következővel + Keywords Kulcsszavak + Labels Címkék + Landscape Fekvő + Language Nyelv + Lasso Lasszó + Last Column Utolsó oszlop + Last commit Utolsó commit + Layout Elrendezés + Layout Summary Elrendezés összegzése + Left Bal + Left Border Bal szegély + Left: Bal: + Letter Levél + Line Vonal + Line and Page Breaks Sor- és oldaltörés + Line and Paragraph Spacing Sorköz és bekezdéstérköz + Line Break Sortörés + Line delta Sorok változása + Line End Sor vége + Line Number Sorszám + Line Numbers Sorszámozás + Line Spacing Sorköz + Line Spacing Options… Sorköz beállításai… + Line spacing: Sorköz: + Line Start Sor eleje + Lines Sorok + Lines added Hozzáadott sorok + Lines deleted Törölt sorok + Link Link + Link to Previous Csatolás az előzőhöz + Links Linkek + List Paragraph Listabekezdés + Location: {path} Hely: {path} + lowercase kisbetűs + Macros Makrók + Mailings Levelezés + Manage Sources Források kezelése + Many pages Sok oldal + Margins Margók + Mark Citation Hivatkozás megjelölése + Mark Entry Bejegyzés megjelölése + Mark\nCitation Hivatkozás\nmegjelölése + Mark\nEntry Bejegyzés\nmegjelölése + Markdown (*.md) Markdown (*.md) + Match case Kis- és nagybetű megkülönböztetése + Match Fields Mezők megfeleltetése + Matrix Mátrix + Merge Egyesítés + Merge Cells Cellák egyesítése + Merge Formatting Formázás összefésülése + Merged PRs Beolvasztott PR-ek + Mirrored Tükrözött + MIT OR Apache-2.0. Copyright (c) 2026 ArtCraft Team and the WordCraft contributors. MIT vagy Apache-2.0. Copyright (c) 2026 ArtCraft Team és a WordCraft közreműködői. + Moderate Mérsékelt + Modify Style Stílus módosítása + Modify… Módosítás… + Multilevel List Többszintű lista + Multiple Pages Több oldal + Name (A–Z) Név (A–Z) + Name: Név: + Narrow 0.5" all Keskeny 0,5" mindenhol + Navigation Navigáció + Navigation Pane Navigációs panel + New Új + New Comment Új megjegyzés + New from Template Új sablonból + New Paragraph Új bekezdés + New Style… Új stílus… + New Window Új ablak + New\nComment Új\nmegjegyzés + New\nWindow Új\nablak + Next Következő + Next Change Következő módosítás + Next Comment Következő megjegyzés + Next Footnote Következő lábjegyzet + Next Page Következő oldal + Next Record Következő rekord + Next Section Következő szakasz + No Border Nincs szegély + No Color Nincs szín + No contributor data was built into this copy. Ebbe a példányba nem kerültek közreműködői adatok. + No Markup Nincs jelölés + No model credits were built into this copy. Ebbe a példányba nem kerültek modell-közreműködői adatok. + No Paragraph Space Nincs bekezdéstérköz + No Spacing Nincs térköz + Nonbreaking Hyphen Nem törő kötőjel + Nonbreaking Space Nem törő szóköz + None Nincs + Normal Normál + Normal 1" all Normál 1" mindenhol + Normal Indent Normál behúzás + Normal Style Normál stílus + Normal Table Normál táblázat + Not available right now Most nem érhető el + Not saved Nincs mentve + Not saved yet Még nincs mentve + Number of columns: Oszlopok száma: + Number of rows: Sorok száma: + Numbering Számozás + Object Objektum + Odd Page Páratlan oldal + Office 2003 Default Office 2003 alapértelmezés + OK OK + One Egy + One Page Egy oldal + Open Megnyitás + OpenDocument Text (*.odt) OpenDocument szöveg (*.odt) + Optional Hyphen Feltételes kötőjel + Options Beállítások + Orientation Tájolás + Outline Vázlat + Outline Level Vázlatszint + Outside Borders Külső szegélyek + Oval Ovális + Page Oldal + Page Background Oldalháttér + Page Borders Oldalszegély + Page Break Oldaltörés + Page Break Before Oldaltörés előtte + Page break before Oldaltörés előtte + Page Breaks Oldaltörések + Page Color Oldalszín + Page Down Page Down + Page image (*.png) Oldalkép (*.png) + Page Movement Lapozás + Page Number Oldalszám + Page Setup Oldalbeállítás + Page Up Page Up + Page Width Oldalszélesség + Page width Oldalszélesség + Page X of Y Oldal: X/Y + Page {page} {page}. oldal + Page {page} of {pages} Oldal: {page}/{pages} + Page\nBorders Oldal-\nszegély + Page\nColor Oldalszín + Page\nNumber Oldalszám + Pages Oldalak + Paragraph Bekezdés + Paragraph Details Bekezdés részletei + Paragraph Down Bekezdés le + Paragraph Settings Bekezdés beállításai + Paragraph Spacing Bekezdéstérköz + Paragraph Up Bekezdés fel + Paragraphs Bekezdések + Paste Beillesztés + Paste (⌘V) Beillesztés (⌘V) + Paste Formatting Formázás beillesztése + Paste: Keep Text Only Beillesztés: csak a szöveg + Paste: Merge Formatting Beillesztés: formázás összefésülése + PDF document (*.pdf) PDF-dokumentum (*.pdf) + Pen Toll + Pencil Ceruza + Picture Border Kép szegélye + Picture Format Képformátum + Picture Styles Képstílusok + Pictures Képek + Plain text (*.txt) Egyszerű szöveg (*.txt) + Portrait Álló + Position Pozíció + Preview Results Eredmények előnézete + Preview\nResults Eredmények\nelőnézete + Previous Előző + Previous Change Előző módosítás + Previous Comment Előző megjegyzés + Previous Record Előző rekord + Previous Section Előző szakasz + Print Nyomtatás + Print Layout Nyomtatási elrendezés + Print\nLayout Nyomtatási\nelrendezés + Proofing Nyelvi ellenőrzés + Proofing Issues Nyelvi hibák + Properties Tulajdonságok + Protect Védelem + Protect Document Dokumentum védelme + Quick Access Toolbar Gyorselérési eszköztár + Quick Parts Gyorsrészek + Quick Tables Gyorstáblázatok + Quote Idézet + Read Aloud Felolvasás + Read Mode Olvasó mód + Read\nAloud Felolvasás + Read\nMode Olvasó\nmód + Recent Legutóbbiak + Record Macro Makró rögzítése + Recover Unsaved Documents Nem mentett dokumentumok helyreállítása + Rectangle Téglalap + Redo Újra + Reduce Hanging Indent Függő behúzás csökkentése + References Hivatkozások + Reject Elutasítás + Reject All Changes Összes módosítás elutasítása + Reject Change Módosítás elutasítása + Relaxed Laza + Remove Background Háttér eltávolítása + Remove Footer Élőláb eltávolítása + Remove Header Élőfej eltávolítása + Remove Hyperlink Link eltávolítása + Remove Space After Paragraph Térköz eltávolítása a bekezdés után + Remove Table of Contents Tartalomjegyzék eltávolítása + Remove Watermark Vízjel eltávolítása + Repeat Ismétlés + Repeat Header Rows Fejlécsorok ismétlése + Replace Csere + Replace All Összes cseréje + Replace Document Text Dokumentumszöveg cseréje + Replace with: Csere erre: + Replay Visszajátszás + Reply Válasz + Report Jelentés + Research Kutatás + Researcher Kutatássegéd + Reset Picture Kép visszaállítása + Resolve Lezárás + Resolved Lezárva + Restart at 1 Újrakezdés 1-től + Restart Each Page Újrakezdés minden oldalon + Restart Each Section Újrakezdés minden szakaszban + Restrict Editing Szerkesztés korlátozása + Restrict\nEditing Szerkesztés\nkorlátozása + Results Eredmények + Reverse the order Fordított sorrend + Review Véleményezés + Reviewing Véleményezés + Reviewing Pane Véleményezés panel + Reviewing — edits become suggestions Véleményezés – a módosítások javaslatként jelennek meg + Rich Text Format (*.rtf) Rich Text Format (*.rtf) + Right Jobb + Right Border Jobb szegély + Right-to-Left Text Direction Jobbról balra írás + Right: Jobb: + Rotate Forgatás + Rounded Rectangle Lekerekített téglalap + Rows & Columns Sorok és oszlopok + Ruler Vonalzó + Rules Szabályok + Résumé Önéletrajz + Save Mentés + Save a copy in another format. Printing goes through a PDF you can print from any viewer. Másolat mentése más formátumban. A nyomtatás PDF-en keresztül történik, amelyet bármelyik nézegetőből kinyomtathatsz. + Save As Mentés másként + Save as Template Mentés sablonként + Saved Mentve + Search Commands Parancsok keresése + Search commands and help Keresés a parancsokban és a súgóban + Search document Keresés a dokumentumban + Section Breaks Szakasztörések + Section Properties Szakasz tulajdonságai + Sections Szakaszok + Select Kijelölés + Select All Az összes kijelölése + Select Cell Cella kijelölése + Select Line Sor kijelölése + Select Objects Objektumok kijelölése + Select Paragraph Bekezdés kijelölése + Select Range Tartomány kijelölése + Select Recipients Címzettek kiválasztása + Select Row Táblázatsor kijelölése + Select Sentence Mondat kijelölése + Select Table Táblázat kijelölése + Select Text Szöveg kijelölése + Select Text with Similar Formatting Hasonló formázású szöveg kijelölése + Select Word Szó kijelölése + Select\nRecipients Címzettek\nkiválasztása + Selection Kijelölés + Selection Pane Kijelölés panel + Send Backward Hátrébb küldés + Sentence case. Mondatkezdő nagybetű. + Set as Default Beállítás alapértelmezettként + Set Caret Kurzor elhelyezése + Set Character Formatting Karakterformázás beállítása + Set Numbering Value Számozás értékének beállítása + Set Paragraph Formatting Bekezdésformázás beállítása + Shading Árnyékolás + Shadow Árnyék + Shape Fill Alakzat kitöltése + Shape Format Alakzatformátum + Shape Outline Alakzat körvonala + Shape Styles Alakzatstílusok + Shapes Alakzatok + Share Megosztás + Shift+Tab Shift+Tab + Show Megjelenítés + Show all formatting marks Minden formázási jel megjelenítése + Show Comments Megjegyzések megjelenítése + Show Markup Jelölések megjelenítése + Show Notes Jegyzetek megjelenítése + Show rulers Vonalzók megjelenítése + Animate the caret (it glides and fades) Animált kurzor (csúszva mozog, halványulva villog) + Animation Animáció + On: the caret glides to its new place when you type or use the arrow keys, and its blink fades. Off: it jumps and blinks on and off. Be: gépeléskor és a nyílbillentyűk használatakor a kurzor csúszva mozog az új helyére, és halványulva villog. Ki: a kurzor ugrik, és egyszerűen ki-be villog. + Show the community button in the title bar Közösség gomb megjelenítése a címsorban + Show/Hide ¶ Megjelenítés/elrejtés ¶ + Show\nComments Megjegyzések\nmegjelenítése + Shrink Font 1 Point Betűméret csökkentése 1 ponttal + Side to Side Egymás mellett + Side\nto Side Egymás\nmellett + Signature Line Aláírássor + Single Szimpla + Single Spacing Szimpla sorköz + Size Méret + Size: Méret: + Small Caps Kiskapitális + Small caps Kiskapitális + SmartArt SmartArt + Sort Rendezés + Source code: github.com/storytold/wordcraft Forráskód: github.com/storytold/wordcraft + Space before/after: Térköz előtte/utána: + Spacing Térköz + Spacing (pt): Térköz (pt): + Speech Beszéd + Spelling & Grammar Helyesírás és nyelvhelyesség + Spelling &\nGrammar Helyesírás és\nnyelvhelyesség + Spelling Suggestions Helyesírási javaslatok + Split Felosztás + Split Cells Cellák felosztása + Split Table Táblázat felosztása + Spreadsheet Table Számolótábla + Standard Colors Alapszínek + Star Csillag + Start Mail Merge Körlevél indítása + Start Mail\nMerge Körlevél\nindítása + Statistics Statisztika + Status Bar Állapotsor + Strikethrough Áthúzott + Strong Félkövér kiemelés + Studio cover Studio-borító + Studio handbook (sample) Studio-kézikönyv (minta) + Style Stílus + Style based on: Alapstílus: + Style Set Stíluskészlet + Style: {style} Stílus: {style} + Style: APA Stílus: APA + Styles Stílusok + Styles Pane Stílusok panel + Subject Tárgy + Subscript Alsó index + Subtitle Alcím + Subtle Emphasis Finoman hangsúlyos + Subtle Reference Finom hivatkozás + Superscript Felső index + Switch Modes Módváltás + Switch\nModes Módváltás + Symbol Szimbólum + Symbols Szimbólumok + Synonyms Szinonimák + Tab Tabulátor + Table Táblázat + Table Column Width Táblázat oszlopszélessége + Table Design Táblázattervezés + Table Grid Táblázatrács + Table Layout Táblázat-elrendezés + Table of Authorities Jogforrásjegyzék + Table of Contents Tartalomjegyzék + Table of Figures Ábrajegyzék + Table of\nContents Tartalom-\njegyzék + Table Properties Táblázat tulajdonságai + Table Row Height Táblázat sormagassága + Table Style Options Táblázatstílus beállításai + Table Styles Táblázatstílusok + Tables Táblázatok + Tabs Tabulátorok + Tabular List Táblázatos lista + Text Szöveg + Text Box Szövegdoboz + Text Direction Szövegirány + Text Effects Szövegeffektusok + Text Effects and Typography Szövegeffektusok és tipográfia + Text from File Szöveg fájlból + Text Highlight Color Kiemelés színe + Text to display: Megjelenített szöveg: + Text Wrapping Szöveg körbefuttatása + Text: Szöveg: + Text\nBox Szövegdoboz + Text\nDirection Szövegirány + The new style takes the formatting of the current paragraph. Az új stílus az aktuális bekezdés formázását veszi át. + Theme Colors Témaszínek + Theme Fonts Téma betűtípusai + Theme List Témák listája + Themes Témák + Thesaurus Szinonimaszótár + Thick Vastag + Three Három + Tight Szoros + Title Cím + TOA Heading Jogforrásjegyzék címsora + TOC Heading Tartalomjegyzék címsora + TOC {n} Tartalomjegyzék {n} + Top Border Felső szegély + Top of Page Oldal teteje + Top: Felül: + Total Row Összegsor + Track Changes Változáskövetés + Track Changes: On Változáskövetés: be + Tracking Követés + Translate Fordítás + Transparency Átlátszóság + Triangle Háromszög + Two Kettő + Type a command, e.g. "insert table" Írj be egy parancsot, például „táblázat beszúrása” + Type Text Szöveg begépelése + Underline Aláhúzott + Underline Words Only Csak a szavak aláhúzása + Undo Visszavonás + Undo {action} Visszavonás: {action} + Up Fel + Update Field Mező frissítése + Update Index Tárgymutató frissítése + Update Style to Match Selection Stílus frissítése a kijelölés alapján + Update Table Jegyzék frissítése + Update Table of Figures Ábrajegyzék frissítése + Update to Match Frissítés egyeztetéssel + Update to Match Selection Frissítés a kijelölés alapján + UPPERCASE NAGYBETŰS + User Name Felhasználónév + User name: Felhasználónév: + Version History Verzióelőzmények + Version {version} ({build}) Verzió: {version} ({build}) + Vertical Függőleges + Vertical Alignment Függőleges igazítás + View Nézet + View Gridlines Rácsvonalak megjelenítése + View State Nézet állapota + Views Nézetek + Voice Hang + Watermark Vízjel + Wave Hullám + Web Layout Webes elrendezés + Web page (*.html) Weboldal (*.html) + Web\nLayout Webes\nelrendezés + Whole page Teljes oldal + Whole words Csak teljes szavak + Wide Széles + Widow/Orphan Control Fattyú- és árvasorok kezelése + Widow/Orphan control Fattyú- és árvasorok kezelése + Window Ablak + Word Count Szavak száma + Word document (*.docx) Word-dokumentum (*.docx) + Word Left Szó balra + Word Right Szó jobbra + WordArt WordArt + WordCraft WordCraft + Words Szavak + Words only Csak szavak + Wrap Text Szöveg körbefuttatása + Wrap\nText Körbe-\nfuttatás + Write & Insert Fields Mezők írása és beszúrása + Zoom Nagyítás + Zoom In Közelítés + Zoom Out Távolítás + {group} settings {group} – beállítások + {key}+Click to follow link {key}+kattintás: link megnyitása + {selected} of {words} words {selected}/{words} szó + {words} words {words} szó + Dark page (white text on black) Sötét oldal (fehér szöveg feketén) + Show documents with their colours inverted, like View › Switch Modes. Saving, printing and PDFs are unchanged. A dokumentumok fordított színekkel jelennek meg, mint a Nézet › Módváltás esetén. A mentés, a nyomtatás és a PDF változatlan marad. + Only web (http, https) and email (mailto) links can be followed Csak webes (http, https) és e-mail (mailto) link nyitható meg + Apply Styles Stílusok alkalmazása + AutoSave documents you have saved in WordCraft A WordCraftban mentett dokumentumok automatikus mentése + AutoSave failed: {error}. Save the document to turn AutoSave back on. Az automatikus mentés sikertelen: {error}. Az újbóli bekapcsolásához mentsd a dokumentumot. + Bottom Margin Alsó margó + Edit Comment Megjegyzés szerkesztése + Left Margin Bal margó + More templates → További sablonok → + No templates match your search. Egy sablon sem felel meg a keresésnek. + Reopen Újbóli megnyitás + Right Margin Jobb margó + Search for templates Sablonok keresése + Character Border Karakterszegély + Count words in text boxes and notes A szövegdobozok és a jegyzetek szavainak beszámítása + Couldn't download {name}: {error} Nem sikerült letölteni: {name} ({error}) + Do you want to save changes to {name}? Mented a módosításokat? ({name}) + Don't Save Nincs mentés + Move or Resize Áthelyezés vagy átméretezés + Nudge Finom mozgatás + Left-to-Right Text Direction Balról jobbra írás + That format doesn't keep everything, so the document is still open. Save it as a Word document (.docx) to go on. Ez a formátum nem őriz meg mindent, ezért a dokumentum nyitva maradt. A folytatáshoz mentsd Word-dokumentumként (.docx). + Top Margin Felső margó + Write a reply Válasz írása + Direction: Irány: + Right-to-left Jobbról balra + Left-to-right Balról jobbra + Zotero Zotero + Citations Forráshivatkozások + Document Dokumentum + Add/Edit\nCitation Hivatkozás\nhozzáadása/szerkesztése + Add\nNote Jegyzet\nhozzáadása + Add/Edit\nBibliography Irodalomjegyzék\nhozzáadása/szerkesztése + Refresh Frissítés + Document\nPreferences Dokumentum\nbeállításai + Unlink\nCitations Hivatkozások\nleválasztása + Waiting for Zotero… Várakozás a Zoterora… + Zotero: done Zotero: kész + Zotero: cancelled Zotero: megszakítva + Yes Igen + No Nem + Move Past Citation Ugrás a hivatkozás után + Move Past\nCitation Ugrás a\nhivatkozás után + Next Sentence Következő mondat + Previous Sentence Előző mondat + Next sentence Következő mondat + Previous sentence Előző mondat + Play/Pause Lejátszás/szünet + Read Aloud Status Felolvasás állapota + Reading Speed Olvasási sebesség + Skip Citations and Bibliography Hivatkozások és irodalomjegyzék kihagyása + Skip citations & bibliography Hivatkozások és irodalomjegyzék kihagyása + Stop Reading Felolvasás leállítása + Don't read Zotero, Mendeley or EndNote citations and bibliographies A Zotero, a Mendeley és az EndNote hivatkozásainak és irodalomjegyzékeinek kihagyása + Play Lejátszás + Pause Szünet + Stop Leállítás + Sentence Mondat + Paused Szüneteltetve + Finished Befejezve + Stopped Leállítva + Speed Sebesség + Normal speed Normál sebesség + Your changes will be lost if you don't save them. Ha nem mented, a módosítások elvesznek. + Scroll ribbon left Menüszalag görgetése balra + Scroll ribbon right Menüszalag görgetése jobbra + Change Structure Szerkezet módosítása + Change to Display Kiemelt egyenletté alakítás + Change to Inline Szövegközi egyenletté alakítás + Conversions Átalakítások + Delete Back in Equation Visszatörlés az egyenletben + Delete in Equation Törlés az egyenletben + Edit Equation Egyenlet szerkesztése + Enter in Equation Enter az egyenletben + Equation Gallery Egyenletgaléria + Equation Info Egyenlet adatai + Equation Number Egyenlet sorszáma + Input Format Beviteli formátum + Insert Structure Szerkezet beszúrása + Insert Symbol Szimbólum beszúrása + Justification Igazítás + Leave Equation Kilépés az egyenletből + Move in Equation Mozgás az egyenletben + Normal Text Normál szöveg + Set Equation Egyenlet beállítása + Structure Actions Szerkezeti műveletek + Structures Szerkezetek + Tools Eszközök + Type in Equation Gépelés az egyenletbe + Inline Szövegközi + Number Szám + Close Equation Egyenlet bezárása + Insert New Equation Új egyenlet beszúrása + Built-In Beépített + Professional Kétdimenziós + Linear Lineáris + Fraction Tört + Script Index + Radical Gyök + Integral Integrál + Large\nOperator Nagy\noperátor + Bracket Zárójel + Function Függvény + Accent Ékezet + Limit and\nLog Határérték\nés logaritmus + Operator Operátor + Equation Options Beállítások + All symbol sets Összes szimbólumkészlet + Send the document straight to the system print dialog, or save a copy in another format below. Küldd a dokumentumot közvetlenül a rendszer nyomtatási párbeszédablakába, vagy ments egy másolatot más formátumban lent. + Or save a copy in another format: Vagy ments másolatot más formátumban: + Opening print dialog… Nyomtatási párbeszédablak megnyitása… + Print failed: {error} A nyomtatás sikertelen: {error} + Couldn't open {path}: {error} Nem sikerült megnyitni: {path} ({error}) + Column Selection Oszlop kijelölése + Style Inspector Stílusvizsgáló + Inspect Styles at Selection A kijelölés stílusainak vizsgálata + Clear Formatting Level Formázási szint törlése + Characters Karakterek + Direct formatting Közvetlen formázás + Reset to Normal Visszaállítás normálra + Clear Paragraph Formatting Bekezdésformázás törlése + Clear Character Style Karakterstílus törlése + Clear Character Formatting Karakterformázás törlése + Clear All Összes törlése + Paste Special Irányított beillesztés + Paste Special… Irányított beillesztés… + Paste as: Beillesztés mint: + The clipboard is empty. A vágólap üres. + Formatted Text Formázott szöveg + Rich Text Format (RTF) Rich Text Format (RTF) + HTML Format HTML formátum + Unformatted Text Formázatlan szöveg + Result Eredmény + Keeps the fonts and formatting of the copied text. Megtartja a másolt szöveg betűtípusát és formázását. + Reads the clipboard's rich text and keeps its formatting. Beolvassa a vágólap formázott szövegét, és megtartja a formázást. + Reads the clipboard's web content and keeps its formatting. Beolvassa a vágólap webes tartalmát, és megtartja a formázást. + Drops all formatting and pastes plain text. Minden formázást elhagy, és egyszerű szövegként illeszt be. + Advanced Find Részletes keresés + Advanced Find… Részletes keresés… + Go To… Ugrás… + Reading Highlight Olvasási kiemelés + New Table Style Új táblázatstílus + Modify Table Style Táblázatstílus módosítása + New Table Style… Új táblázatstílus… + Modify Table Style… Táblázatstílus módosítása… + Apply formatting to: Formázás alkalmazása erre: + Whole Table Teljes táblázat + Fill color: Kitöltőszín: + {count} items highlighted {count} elem kiemelve + {n} pt {n} pt + Not {name} Nem {name} + Level {n} {n}. szint + Body Text Szövegtörzs + {n} lines {n} sor + At least {n} Legalább {n} + Exactly {n} Pontosan {n} + Centered Középre igazított + Character spacing Betűköz + Don't add space between paragraphs of the same style Nincs térköz az azonos stílusú bekezdések között + Don't hyphenate Nincs elválasztás + Double strikethrough Dupla áthúzás + Drop cap Iniciálé + First line Első sor + Font color Betűszín + Highlight Kiemelés + Justified Sorkizárt + Left indent Bal behúzás + Line spacing Sorköz + Outline level Vázlatszint + Raised/lowered Emelt/süllyesztett + Right indent Jobb behúzás + Scale Méretezés + Space after Térköz utána + Space before Térköz előtte + Suppress line numbers Sorszámozás elhagyása + Underline color Aláhúzás színe + Letters Levelek + E-mail Messages E-mail-üzenetek + Directory Címjegyzék + Normal Word Document Normál Word-dokumentum + If…Then…Else… Ha…Akkor…Különben… + If…Then…Else Ha…Akkor…Különben + Merge Record # Egyesítési rekord sorszáma + Skip Record If… Rekord kihagyása, ha… + Skip Record If Rekord kihagyása, ha + Equals: Értéke: + Then insert: Akkor beszúrandó: + Otherwise insert: Különben beszúrandó: + Courtesy Title Titulus + First Name Utónév + Last Name Vezetéknév + Company Cég + Address Cím + City Település + State Megye vagy állam + Postal Code Irányítószám + Country Ország + (not matched) (nincs megfeleltetve) + Leave a recipient out of the merge when a field has this value. A címzett kimarad az egyesítésből, ha egy mező ezt az értéket veszi fel. + Insert one text or another, depending on a field's value. Egy mező értékétől függően az egyik vagy a másik szöveg kerül be. + Select recipients first to match their fields. A mezők megfeleltetéséhez előbb válaszd ki a címzetteket. + The recipient-list field that fills each part of an address block and greeting line. A címzettlista mezője, amely a címblokk és a megszólítás egyes részeit kitölti. + No errors found: every merge field is in the recipient list. Nincs hiba: minden egyesítési mező szerepel a címzettlistában. + These merge fields aren't in the recipient list: Ezek az egyesítési mezők hiányoznak a címzettlistából: + Add at least one field. Adj hozzá legalább egy mezőt. + Add Field Mező hozzáadása + Every field needs a name. Minden mezőnek kell név. + Field name Mezőnév + Field names must be different. A mezőneveknek különbözniük kell. + Fields: Mezők: + Field: Mező: + Find: Keresés: + New Entry Új bejegyzés + Recipient List Címzettlista + Recipients: {count} Címzettek: {count} + Remove field Mező eltávolítása + Remove recipient Címzett eltávolítása + Select recipients first to choose from their fields. A mezők közüli választáshoz előbb válaszd ki a címzetteket. + Type a New List… Új lista begépelése… + Type at least one recipient. Adj meg legalább egy címzettet. + Type the recipients, or load them from a file. Gépeld be a címzetteket, vagy töltsd be őket fájlból. + Use an Existing List… Meglévő lista használata… + Interface theme: Felület témája: + Interface\nTheme Felület\ntémája + Interface Theme Felület témája + Light Világos + Dark Sötét + Use system setting A rendszerbeállítás követése + A file dialog is already open. Már nyitva van egy fájlválasztó ablak. + Height: Magasság: + Width: Szélesség: + Row Sor + Cell Cella + Preferred width: Kívánt szélesség: + Alignment: Igazítás: + Indent from left: Behúzás balról: + Specify height: Magasság megadása: + Exactly Pontosan + At least Legalább + Allow row to break across pages A sor átnyúlhat a következő oldalra + Repeat as header row on each page Ismétlés fejlécsorként minden oldalon + Vertical alignment: Függőleges igazítás: + Top Fent + Bottom Lent + Alt text Helyettesítő szöveg + Alt: Helyettesítő: + W: Sz: + H: M: + Reset Visszaállítás + Reset Crop Körülvágás visszaállítása + Insert a new equation (Alt+=) Új egyenlet beszúrása (Alt+=) + Leave the equation (Esc) Kilépés az egyenletből (Esc) + Preview Előnézet + getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft + Browse… Tallózás… + AaBbYyZz AaBbYyZz ŐőŰű + Align Top Left Igazítás balra fel + Align Top Center Igazítás középre fel + Align Top Right Igazítás jobbra fel + Align Center Left Igazítás balra középen + Align Center Igazítás középre + Align Center Right Igazítás jobbra középen + Align Bottom Left Igazítás balra le + Align Bottom Center Igazítás középre le + Align Bottom Right Igazítás jobbra le + Commits Commitok + {contributors} contributors · {commits} commits {contributors} közreműködő · {commits} commit + Display name Megjelenített név + Real name Valódi név + Username Felhasználónév + Document1 Dokumentum1 + Font… Betűtípus… + Paragraph… Bekezdés… + Link… Link… + Lines +/− Sorok +/− + Model Modell + Version Verzió + % of all commits Az összes commit %-a + pt pt + Save the document to turn on AutoSave Az automatikus mentéshez mentsd a dokumentumot + Contributors Közreműködők + Models Modellek + Good evening Jó estét + Good morning Jó reggelt + Good afternoon Jó napot + Track\nChanges Változás-\nkövetés + Track\nChanges ✓ Változás-\nkövetés ✓ + tOGGLE cASE kIS-nAGYBETŰ cSERE + Change to Level 1 Áthelyezés az 1. szintre + Change to Level 2 Áthelyezés a 2. szintre + Change to Level 3 Áthelyezés a 3. szintre + Change to Level 4 Áthelyezés a 4. szintre + Change to Level 5 Áthelyezés az 5. szintre + Change to Level 6 Áthelyezés a 6. szintre + Change to Level 7 Áthelyezés a 7. szintre + Change to Level 8 Áthelyezés a 8. szintre + Change to Level 9 Áthelyezés a 9. szintre + Level 1 1. szint + Level 2 2. szint + Level 3 3. szint + Level 4 4. szint + Level 5 5. szint + Level 6 6. szint + Level 7 7. szint + Level 8 8. szint + Level 9 9. szint + ASAP SÜRGŐS + CONFIDENTIAL BIZALMAS + DO NOT COPY NEM MÁSOLHATÓ + DRAFT PISZKOZAT + SAMPLE MINTA + URGENT AZONNALI + Atelier Műterem + Craft Kézműves + Darkroom Sötétkamra + Gallery Galéria + Sketchbook Vázlatfüzet + Studio Stúdió + Letter 8.50" × 11.00" Letter 8,50" × 11,00" + Legal 8.50" × 14.00" Legal 8,50" × 14,00" + Executive 7.25" × 10.50" Executive 7,25" × 10,50" + Statement 5.50" × 8.50" Statement 5,50" × 8,50" + Tabloid 11.00" × 17.00" Tabloid 11,00" × 17,00" + B4 (JIS) 10.12" × 14.33" B4 (JIS) 10,12" × 14,33" + B5 (JIS) 7.17" × 10.12" B5 (JIS) 7,17" × 10,12" + Envelope #10 4.12" × 9.50" Boríték #10 4,12" × 9,50" + Envelope DL 4.33" × 8.66" Boríték DL 4,33" × 8,66" + Plain Table 1 Egyszerű táblázat 1 + Grid Table 1 Light Rácsos táblázat 1, világos + Grid Table 4 – Blue Rácsos táblázat 4 – kék + Grid Table 4 – Green Rácsos táblázat 4 – zöld + Grid Table 4 – Orange Rácsos táblázat 4 – narancs + Grid Table 4 – Purple Rácsos táblázat 4 – lila + List Table 3 – Blue Listatáblázat 3 – kék + List Table 3 – Green Listatáblázat 3 – zöld + List Table 3 – Orange Listatáblázat 3 – narancs + List Table 3 – Purple Listatáblázat 3 – lila + Convert between built-up and linear form Váltás a kétdimenziós és a lineáris alak között + Justification of a display equation A kiemelt egyenlet igazítása + Number the equation; the number sits at the right margin (or type # then the number) Az egyenlet számozása; a szám a jobb margónál áll (vagy írj # jelet, majd a számot) + Show the equation in the line of text Az egyenlet megjelenítése a szövegsorban + Show the equation on a line of its own Az egyenlet megjelenítése külön sorban + Type equations in LaTeX Egyenletek írása LaTeX-ben + Type equations in the linear format (UnicodeMath) Egyenletek írása lineáris formátumban (UnicodeMath) + Type normal (non-math) text in the equation Normál (nem matematikai) szöveg írása az egyenletbe + Sides of a right triangle A derékszögű háromszög oldalai + Circle area from its radius A kör területe a sugárból + Roots of ax²+bx+c Az ax²+bx+c gyökei + Binomial expansion of (x+a)ⁿ Az (x+a)ⁿ binomiális kifejtése + Power series of (1+x)ⁿ Az (1+x)ⁿ hatványsora + Euler's identity Euler-azonosság + Derivative as a limit A derivált mint határérték + Exponential series Exponenciális sor + Integral of e^(−x²) Az e^(−x²) integrálja + Sine and cosine series of f(x) Az f(x) szinusz- és koszinuszsora + Sum or difference of sines Szinuszok összege vagy különbsége + Sum of cosines Koszinuszok összege + Normal probability density Normális eloszlás sűrűségfüggvénye + Bayes' rule Bayes-tétel + Accents Ékezetek + Arrows Nyilak + Basic Math Alapvető matematika + Boxed Formulas Bekeretezett képletek + Cases and Stacks Esetszétválasztás és halmozás + Common Accent Objects Gyakori ékezetes objektumok + Common Brackets Gyakori zárójelek + Common Fractions Gyakori törtek + Common Functions Gyakori függvények + Common Large Operators Gyakori nagy operátorok + Common Operator Structures Gyakori operátorszerkezetek + Common Operators Gyakori operátorok + Common Radicals Gyakori gyökök + Common Subscripts and Superscripts Gyakori alsó és felső indexek + Contour Integrals Görbementi integrálok + Differentials Differenciálok + Dots Pontok + Empty Matrices Üres mátrixok + Functions Függvények + Geometry Geometria + Greek Letters Görög betűk + Hyperbolic Functions Hiperbolikus függvények + Identity Matrices Egységmátrixok + Integrals Integrálok + Inverse Functions Inverz függvények + Inverse Hyperbolic Functions Inverz hiperbolikus függvények + Letter-Like Symbols Betűszerű szimbólumok + Matrices with Brackets Zárójeles mátrixok + Negated Relations Tagadott relációk + Operator Structures Operátorszerkezetek + Operators Operátorok + Other Large Operators Egyéb nagy operátorok + Overbars and Underbars Felülvonások és aláhúzások + Products and Coproducts Szorzatok és koszorzatok + Radicals Gyökök + Scripts Indexek + Single Brackets Egyszeres zárójelek + Sparse Matrices Ritka mátrixok + Subscripts and Superscripts Alsó és felső indexek + Summations Összegzések + Trigonometric Functions Trigonometrikus függvények + Unions and Intersections Uniók és metszetek + 1×2 Empty Matrix 1×2-es üres mátrix + 1×3 Empty Matrix 1×3-as üres mátrix + 2×1 Empty Matrix 2×1-es üres mátrix + 2×2 Empty Matrix 2×2-es üres mátrix + 2×3 Empty Matrix 2×3-as üres mátrix + 3×1 Empty Matrix 3×1-es üres mátrix + 3×2 Empty Matrix 3×2-es üres mátrix + 3×3 Empty Matrix 3×3-as üres mátrix + 2×2 Determinant 2×2-es determináns + 2×2 Identity Matrix 2×2-es egységmátrix + 2×2 Identity Matrix (sparse) 2×2-es egységmátrix (ritka) + 3×3 Identity Matrix 3×3-as egységmátrix + 3×3 Identity Matrix (sparse) 3×3-as egységmátrix (ritka) + 2×2 Matrix in Brackets 2×2-es mátrix szögletes zárójelben + 2×2 Matrix in Double Bars 2×2-es mátrix dupla függőleges vonalak között + 2×2 Matrix in Parentheses 2×2-es mátrix kerek zárójelben + n×n Identity Matrix n×n-es egységmátrix + n×n Matrix n×n-es mátrix + A xor B A kizáró vagy B + Absolute value cases Abszolút érték esetszétválasztással + Acute Éles ékezet + Angle Brackets Csúcsos zárójelek + Angle Brackets with Separator Csúcsos zárójelek elválasztóval + Angle Brackets with Two Separators Csúcsos zárójelek két elválasztóval + Bar Vonal + Baseline Dots Alapvonali pontok + Binomial Coefficient Binomiális együttható + Binomial Coefficient (angle) Binomiális együttható (csúcsos) + Binomial Coefficient with Angle Brackets Binomiális együttható csúcsos zárójelben + Boxed Formula Bekeretezett képlet + Boxed Formula (example) Bekeretezett képlet (példa) + Braces Kapcsos zárójelek + Braces with Separator Kapcsos zárójelek elválasztóval + Brackets Szögletes zárójelek + Brackets with Separators Zárójelek elválasztókkal + Breve Breve + Cases (Three Conditions) Esetszétválasztás (három feltétel) + Cases (Two Conditions) Esetszétválasztás (két feltétel) + Ceiling Felső egészrész + Change in y over change in x Az y változása az x változásához képest + Check Pipa + Circled Dot Bekarikázott pont + Circled Plus Bekarikázott plusz + Circled Times Bekarikázott szorzás + Colon Equal Kettőspont-egyenlő + Contour Integral Görbementi integrál + Contour Integral with Limits Görbementi integrál határokkal + Contour Integral with Stacked Limits Görbementi integrál fölé és alá írt határokkal + Coproduct Koszorzat + Coproduct with Limits Koszorzat határokkal + Coproduct with Lower Limit Koszorzat alsó határral + Coproduct with Subscript-Superscript Limits Koszorzat indexben írt határokkal + Cosecant Function Koszekánsfüggvény + Cosine 2x Koszinusz 2x + Cosine Function Koszinuszfüggvény + Cotangent Function Kotangensfüggvény + Cube Root Köbgyök + Delta Equal To Delta egyenlő + Delta over arrow Delta nyíl fölött + Diagonal Dots Átlós pontok + Differential Differenciál + Differential theta Theta differenciálja + Differential x Az x differenciálja + Differential y Az y differenciálja + Dot Pont + Double Bar Dupla vonal + Double Dot Kettőspont ékezet + Double Integral Kettős integrál + Double Integral with Limits Kettős integrál határokkal + Double Integral with Stacked Limits Kettős integrál fölé és alá írt határokkal + Double Left Arrow with Text Above Dupla balra nyíl, fölötte szöveggel + Double Left Arrow with Text Below Dupla balra nyíl, alatta szöveggel + Double Left Right Arrow with Text Above Dupla kétirányú nyíl, fölötte szöveggel + Double Left Right Arrow with Text Below Dupla kétirányú nyíl, alatta szöveggel + Double Right Arrow with Text Above Dupla jobbra nyíl, fölötte szöveggel + Double Right Arrow with Text Below Dupla jobbra nyíl, alatta szöveggel + Double Square Brackets Dupla szögletes zárójelek + Double Vertical Bars Dupla függőleges vonalak + Double summation Kettős összegzés + Equal Equal Egyenlő-egyenlő + Equal by Definition Definíció szerint egyenlő + Exponential Exponenciális + Floor Alsó egészrész + Grave Tompa ékezet + Grouping Character Above Csoportosító jel fölül + Grouping Character Below Csoportosító jel alul + Hat Kalap + Hyperbolic Cosecant Function Hiperbolikus koszekánsfüggvény + Hyperbolic Cosine Function Hiperbolikus koszinuszfüggvény + Hyperbolic Cotangent Function Hiperbolikus kotangensfüggvény + Hyperbolic Secant Function Hiperbolikus szekánsfüggvény + Hyperbolic Sine Function Hiperbolikus szinuszfüggvény + Hyperbolic Tangent Function Hiperbolikus tangensfüggvény + Hypotenuse Átfogó + Integral with Limits Integrál határokkal + Integral with Stacked Limits Integrál fölé és alá írt határokkal + Intersection Metszet + Intersection with Limits Metszet határokkal + Intersection with Lower Limit Metszet alsó határral + Intersection with Subscript-Superscript Limits Metszet indexben írt határokkal + Inverse Cosecant Function Arkusz koszekánsfüggvény + Inverse Cosine Function Arkusz koszinuszfüggvény + Inverse Cotangent Function Arkusz kotangensfüggvény + Inverse Hyperbolic Cosecant Function Area koszekáns hiperbolikus függvény + Inverse Hyperbolic Cosine Function Area koszinusz hiperbolikus függvény + Inverse Hyperbolic Cotangent Function Area kotangens hiperbolikus függvény + Inverse Hyperbolic Secant Function Area szekáns hiperbolikus függvény + Inverse Hyperbolic Sine Function Area szinusz hiperbolikus függvény + Inverse Hyperbolic Tangent Function Area tangens hiperbolikus függvény + Inverse Secant Function Arkusz szekánsfüggvény + Inverse Sine Function Arkusz szinuszfüggvény + Inverse Tangent Function Arkusz tangensfüggvény + Left Arrow with Text Above Balra nyíl, fölötte szöveggel + Left Arrow with Text Below Balra nyíl, alatta szöveggel + Left Bracket Twice Két bal szögletes zárójel + Left Right Arrow Above Kétirányú nyíl fölül + Left Right Arrow with Text Above Kétirányú nyíl, fölötte szöveggel + Left Right Arrow with Text Below Kétirányú nyíl, alatta szöveggel + Left Subscript-Superscript Bal oldali alsó és felső index + Leftwards Arrow Above Balra mutató nyíl fölül + Leftwards Harpoon Above Balra mutató szigony fölül + Limit Határérték + Limit Example Határérték (példa) + Linear Fraction Lineáris tört + Logarithm Logaritmus + Logarithm with Base Logaritmus alappal + Logical AND Logikai ÉS + Logical AND with Limits Logikai ÉS határokkal + Logical OR Logikai VAGY + Logical OR with Limits Logikai VAGY határokkal + Maximum Maximum + Maximum Example Maximum (példa) + Measured By Mértékegysége + Midline Dots Középvonali pontok + Minimum Minimum + Minus Equal Mínusz-egyenlő + Natural Logarithm Természetes logaritmus + One half Egy ketted + Outward Brackets Kifelé néző szögletes zárójelek + Overbar Felülvonás + Overbrace Felső kapcsos zárójel + Parentheses Kerek zárójelek + Parentheses with Separator Kerek zárójelek elválasztóval + Partial Differential Parciális differenciál + Pi Over 2 Pí per 2 + Plus Equal Plusz-egyenlő + Product Szorzat + Product of A Az A szorzata + Product with Limits Szorzat határokkal + Product with Lower Limit Szorzat alsó határral + Product with Subscript-Superscript Limits Szorzat indexben írt határokkal + Quadratic Root Másodfokú egyenlet gyöke + Radical with Degree Gyök kitevővel + Right Arrow with Text Above Jobbra nyíl, fölötte szöveggel + Right Arrow with Text Below Jobbra nyíl, alatta szöveggel + Right Bracket Twice Két jobb szögletes zárójel + Rightwards Arrow Above Jobbra mutató nyíl fölül + Rightwards Harpoon Above Jobbra mutató szigony fölül + Secant Function Szekánsfüggvény + Sine Function Szinuszfüggvény + Sine theta Szinusz theta + Single Left Angle Egyszeres bal csúcsos zárójel + Single Left Bar Egyszeres bal függőleges vonal + Single Left Brace Egyszeres bal kapcsos zárójel + Single Left Bracket Egyszeres bal szögletes zárójel + Single Left Parenthesis Egyszeres bal kerek zárójel + Single Right Brace Egyszeres jobb kapcsos zárójel + Single Right Bracket Egyszeres jobb szögletes zárójel + Single Right Parenthesis Egyszeres jobb kerek zárójel + Skewed Fraction Ferde tört + Small change Kis változás + Square Négyzetes + Square Root Négyzetgyök + Square Root with Degree Négyzetgyök kitevővel + Square Union Szögletes unió + Stack Object Halmozott objektum + Stack Object (Three) Halmozott objektum (három) + Stacked Fraction Emeletes tört + Stacked, no bar Egymás fölött, törtvonal nélkül + Subscript-Superscript Alsó és felső index + Summation Összegzés + Summation from 0 to n Összegzés 0-tól n-ig + Summation over binomials Binomiális együtthatók összege + Summation with Limits Összegzés határokkal + Summation with Lower Limit Összegzés alsó határral + Summation with Subscript Limit Összegzés indexben írt határral + Summation with Subscript-Superscript Limits Összegzés indexben írt határokkal + Surface Integral Felületi integrál + Surface Integral with Limits Felületi integrál határokkal + Surface Integral with Stacked Limits Felületi integrál fölé és alá írt határokkal + Tangent Function Tangensfüggvény + Tangent formula Tangensképlet + Text over Colon Equal Szöveg a kettőspont-egyenlő fölött + Text over Equal Szöveg az egyenlőségjel fölött + Tilde Hullámvonal + Triple Dot Hárompont ékezet + Triple Integral Hármas integrál + Triple Integral with Limits Hármas integrál határokkal + Triple Integral with Stacked Limits Hármas integrál fölé és alá írt határokkal + Underbar Aláhúzás + Underbrace Alsó kapcsos zárójel + Union Unió + Union of intersections Metszetek uniója + Union with Limits Unió határokkal + Union with Lower Limit Unió alsó határral + Union with Plus Unió plusszal + Union with Subscript-Superscript Limits Unió indexben írt határokkal + Up Diagonal Dots Felfelé mutató átlós pontok + Vector A A vektor + Vector AB AB vektor + Vertical Bars Függőleges vonalak + Vertical Dots Függőleges pontok + Volume Integral Térfogati integrál + Volume Integral with Limits Térfogati integrál határokkal + Volume Integral with Stacked Limits Térfogati integrál fölé és alá írt határokkal + Yields Eredményez + x bar x felülvonással + AutoSave isn't available in the browser: it can't write to your files. Save downloads a copy. A böngészőben nincs automatikus mentés, mert nem tud írni a fájljaidba. A Mentés egy másolatot tölt le. + AutoSave only keeps .docx, .odt and .rtf files; {name} can't keep all formatting. Save as .docx to turn on AutoSave. Az automatikus mentés csak .docx, .odt és .rtf fájlt tart naprakészen; ez a formátum ({name}) nem őriz meg minden formázást. Az automatikus mentéshez mentsd .docx formátumban. + Save the document to turn on AutoSave (a file you have only opened isn't rewritten until you save it) Az automatikus mentéshez mentsd a dokumentumot (a csak megnyitott fájlt a program addig nem írja felül, amíg nem mented) + Add Object to Selection Objektum hozzáadása a kijelöléshez + Add space between Asian and Latin text Térköz az ázsiai és a latin betűs szöveg között + Add space between Asian text and numbers Térköz az ázsiai szöveg és a számok között + Allow hanging punctuation Kilógó írásjelek engedélyezése + Allow Latin text to wrap in the middle of a word A latin betűs szöveg szó közben is törhet + Apply Asian line-breaking rules (kinsoku) Ázsiai sortörési szabályok alkalmazása (kinsoku) + Asian Typography Ázsiai tipográfia + Click a border between two cells to merge them. Esc stops. Két cella egyesítéséhez kattints a köztük lévő szegélyre. Kilépés: Esc. + Compress punctuation at the start of a line Írásjelek tömörítése a sor elején + Drag a rectangle to draw a table, or a line across cells to split them. Esc stops. Táblázat rajzolásához húzz egy téglalapot, a cellák felosztásához húzz vonalat rajtuk keresztül. Kilépés: Esc. + Draw Table Táblázat rajzolása + Indents and Spacing Behúzás és térköz + Line breaking Sortörés + Clipboard Pane Vágólap panel + Clipboard Items Vágólap elemei + Paste Clipboard Item Vágólapelem beillesztése + Paste All Összes beillesztése + Delete Clipboard Item Vágólapelem törlése + Click an item to paste it. Kattints egy elemre a beillesztéséhez. + Nothing collected yet. Items you copy or cut appear here. Még nincs itt semmi. Amit másolsz vagy kivágsz, itt jelenik meg. + (picture or object) (kép vagy objektum) + Ungroup Csoportbontás + Draw Ink Tintarajz + Erase Ink Tinta radírozása + Hide Ink Tinta elrejtése + Hide\nInk Tinta\nelrejtése + Pens Tollak + Ink Tinta + Thickness Vastagság + Shape Effects Alakzateffektusok + Glow Ragyogás + Soft Edges Lágy szélek + No Shadow Nincs árnyék + No Glow Nincs ragyogás + No Soft Edges Nincsenek lágy szélek + Glow Colors Ragyogás színei + Weight Vastagság + Shadow, glow and soft edges Árnyék, ragyogás és lágy szélek + Offset: Bottom Right Eltolás: jobbra le + Offset: Bottom Eltolás: le + Offset: Bottom Left Eltolás: balra le + Offset: Right Eltolás: jobbra + Offset: Center Eltolás: középen + Offset: Left Eltolás: balra + Offset: Top Right Eltolás: jobbra fel + Offset: Top Eltolás: fel + Offset: Top Left Eltolás: balra fel + Manage Styles Stílusok kezelése + Show or Hide Style Stílus megjelenítése vagy elrejtése + Manage Styles… Stílusok kezelése… + Sort order: Rendezés: + As Recommended Ajánlott sorrend + Alphabetical Betűrend + Font: {font} Betűtípus: {font} + {size} pt {size} pt + Color: {color} Szín: {color} + Space before: {pt} pt Térköz előtte: {pt} pt + Space after: {pt} pt Térköz utána: {pt} pt + Based on: {style} Alapstílus: {style} + Paragraph style Bekezdésstílus + Character style Karakterstílus + Linked style (paragraph and character) Csatolt stílus (bekezdés és karakter) + Table style Táblázatstílus + Built-in style Beépített stílus + Show in the Styles gallery Megjelenítés a stílusgalériában + Hide from style lists Elrejtés a stíluslistákból + Built-in styles can't be deleted. A beépített stílusok nem törölhetők. + Delete Table Style Táblázatstílus törlése + Password Jelszó + Password: Jelszó: + Confirm password: Jelszó megerősítése: + Encrypt with Password… Titkosítás jelszóval… + Change Password… Jelszó módosítása… + Remove Password Jelszó eltávolítása + A password is required to open this document. A dokumentum megnyitásához jelszó kell. + Anyone can open this document. Encrypt it with a password to keep it private. Ezt a dokumentumot bárki megnyithatja. Titkosítsd jelszóval, ha bizalmas. + Anyone who opens the document will need this password. Aki megnyitja a dokumentumot, annak szüksége lesz erre a jelszóra. + {name} is protected with a password. Ez a dokumentum jelszóval védett: {name} + That password isn't right. Check it (passwords are case-sensitive) and try again. Hibás jelszó. Ellenőrizd (a kis- és nagybetű számít), és próbáld újra. + The document will be saved with a password. Save it to protect it. A dokumentum jelszóval lesz mentve. A védelemhez mentsd. + The password is removed. Save the document to keep the change. A jelszó eltávolítva. A módosítás megtartásához mentsd a dokumentumot. + The passwords don't match. A két jelszó nem egyezik. + Keep the password somewhere safe: without it nobody, WordCraft included, can open the document. Leave both boxes empty to remove the password. Tartsd biztonságos helyen a jelszót: nélküle senki, még a WordCraft sem tudja megnyitni a dokumentumot. A jelszó eltávolításához hagyd üresen mindkét mezőt. + {cols}×{rows} Table {cols}×{rows} táblázat + Name Név + PRs PR-ek + First Első + Last Utolsó + Change Picture… Kép cseréje… + Size and Crop… Méret és körülvágás… + Insert Columns to the Left Oszlopok beszúrása balra + Insert Columns to the Right Oszlopok beszúrása jobbra + Insert Rows Above Sorok beszúrása fölé + Insert Rows Below Sorok beszúrása alá + Mon H + Tue K + Wed Sze + Sum Összeg + North Észak + South Dél + West Nyugat + Total Összesen + Draw\nTable Táblázat\nrajzolása + (no style) (nincs stílus) + Add tab stop at: Új tabulátor helye: + Aligned at: Igazítás helye: + Balloons Buborékok + Bullet Felsorolásjel + By author Szerző szerint + Changed lines: Módosított sorok: + Click level to modify: Kattints a módosítandó szintre: + Color only Csak szín + Define New Multilevel List Új többszintű lista megadása + Define New Multilevel List… Új többszintű lista megadása… + Deletions: Törlések: + Enter formatting for number: A szám formázása: + Follow number with: A szám után: + Formatting Formázás + Include level number from: Szintszám átvétele innen: + Insertions and Deletions Beszúrások és törlések + Insertions: Beszúrások: + Left border Bal szegély + Legal style numbering Jogi stílusú számozás + Link level to style: A szinthez tartozó stílus: + List Definition Listadefiníció + Nothing Semmi + Number alignment: Számigazítás: + Number format Számformátum + Number style for this level: A szint számstílusa: + Outside border Külső szegély + Overtype Felülírás + Overtype (typing replaces the characters after the caret) Felülírás (a gépelés a kurzor utáni karaktereket cseréli) + Page display Oldalmegjelenítés + Paragraph marks Bekezdésjelek + Personalize Személyre szabás + Picas Pica + Points Pont + Print the page color Oldalszín nyomtatása + Printing Nyomtatás + Proofing language Nyelvi ellenőrzés nyelve + Recent documents to show: Megjelenített legutóbbi dokumentumok: + Save documents Dokumentumok mentése + Show the mini toolbar when text is selected Mini eszköztár megjelenítése szövegkijelöléskor + Spaces Szóközök + Spelling Helyesírás + Spelling and grammar are checked in English (United States). A helyesírás- és nyelvhelyesség-ellenőrzés nyelve: angol (Egyesült Államok). + Tab characters Tabulátorok + These marks show even when Show/Hide ¶ is off. Ezek a jelek akkor is látszanak, ha a Megjelenítés/elrejtés ¶ ki van kapcsolva. + Typing replaces selected text A gépelés felülírja a kijelölt szöveget + Typing Gépelés + Resize Table by Dragging Táblázat átméretezése húzással + Update fields before printing Mezők frissítése nyomtatás előtt + Use the Insert key to switch overtype on and off Az Insert billentyű ki- és bekapcsolja a felülírást + Asian text font: Ázsiai szöveg betűtípusa: + Latin text font: Latin szöveg betűtípusa: + Visual effects Vizuális effektusok + Animation effects: Animációs effektusok: + On Be + Off Ki + Smooth scrolling and zooming, and menus and buttons that fade in. Off shows every change at once. Sima görgetés és nagyítás, fokozatosan megjelenő menük és gombok. Kikapcsolva minden változás azonnal látszik. + Your system has animation effects turned on. A rendszereden be vannak kapcsolva az animációs effektusok. + Your system has animation effects turned off. A rendszereden ki vannak kapcsolva az animációs effektusok. + Your system doesn't report an animation setting, so animation effects stay on. A rendszered nem jelez animációs beállítást, ezért az animációs effektusok bekapcsolva maradnak. + Restart list after: Lista újrakezdése ezután: + Right border Jobb szegély + Show all revisions inline Minden változat a szövegben + Show only comments and formatting in balloons Buborékban csak a megjegyzések és a formázás + Show revisions in balloons Változatok buborékban + Space Szóköz + Start at: Kezdőérték: + Tab character Tabulátorkarakter + Text indent at: Szöveg behúzása: + Track Changes Options Változáskövetés beállításai + Track formatting Formázás követése + Flip Horizontal Vízszintes tükrözés + Flip Vertical Függőleges tükrözés + Rotate Left 90° Forgatás balra 90°-kal + Rotate Right 90° Forgatás jobbra 90°-kal + Rotation Elforgatás + Rotation: Elforgatás: + Tab stop position: Tabulátor helye: + Default tab stops: Alapértelmezett tabulátortávolság: + Tab stops to be cleared: Törlendő tabulátorok: + All Mind + Decimal Decimális + Leader Kitöltő karakter + Set Beállítás + Page Border Oldalszegély + Apply to: Alkalmazás erre: + Whole document Teljes dokumentum + This section Ez a szakasz + Fill Kitöltés + Setting: Beállítás: + 3-D Térhatású + Custom Egyéni + Style: Stílus: + Dot dash Pont-vonal + Triple Hármas + Between Köztes + Borders and Shading Szegélyek és árnyékolás + Tabs… Tabulátorok… + Borders and Shading… Szegélyek és árnyékolás… + (All) (Mind) + (none) (nincs) + (normal text) (normál szöveg) + 1/4 Em Space 1/4 kvirtus szóköz + A mail merge field Körlevél-egyesítési mező + A numbered sequence (figures, tables…) Számozott sorozat (ábrák, táblázatok…) + A table of contents Tartalomjegyzék + Categories: Kategóriák: + Character Karakter + Character code: Karakterkód: + Col #: Oszlop sorszáma: + Copyright Copyright + Date and Time Dátum és idő + Date formats: Dátumformátumok: + Description: Leírás: + Document Information Dokumentum adatai + Double Closing Quote Záró idézőjel + Double Opening Quote Nyitó idézőjel + Ellipsis Három pont + Em Dash Gondolatjel + En Dash Nagykötőjel + Em Space Kvirtus szóköz + En Space Félkvirtus szóköz + Equal column width Egyenlő hasábszélesség + Field Codes Mezőkódok + Field code: Mezőkód: + Field codes: Mezőkódok: + Field name: Mezőnév: + Field names: Mezőnevek: + Field properties Mező tulajdonságai + Field… Mező… + Format: Formátum: + Hide Codes Kódok elrejtése + Index and Tables Tárgymutató és jegyzékek + Line between Elválasztó vonal + Links and References Linkek és hivatkozások + Mail Merge Körlevél + More Columns… További hasábok… + More Symbols… További szimbólumok… + No-Width Non Break Nulla szélességű nem törő jel + No-Width Optional Break Nulla szélességű feltételes törés + Paragraph Mark Bekezdésjel + Pick a field from the list Válassz mezőt a listából + Preserve formatting during updates Formázás megtartása frissítéskor + Presets Előbeállítások + Recently used symbols: Legutóbb használt szimbólumok: + Registered Bejegyzett + Section Mark Paragrafusjel + Sequence name: Sorozat neve: + Shortcut key: Billentyűparancs: + Single Closing Quote Záró szimpla idézőjel + Single Opening Quote Nyitó szimpla idézőjel + Spacing: Térköz: + Special Characters Különleges karakterek + Subset: Részhalmaz: + The time right now A jelenlegi időpont + When this document was first created A dokumentum létrehozásának ideje + When this document was most recently printed A dokumentum legutóbbi nyomtatásának ideje + When this document was most recently saved A dokumentum legutóbbi mentésének ideje + The document has no bookmarks yet. A dokumentumban még nincs könyvjelző. + Who the document properties name as author A dokumentum tulajdonságaiban megadott szerző + Name of the file this document is saved as A dokumentum mentett fájljának neve + Title from the document properties A dokumentum tulajdonságaiban megadott cím + How many pages the whole document has A teljes dokumentum oldalainak száma + How many pages this section has A szakasz oldalainak száma + Number of the page the field sits on A mezőt tartalmazó oldal száma + Number of the section the field sits in A mezőt tartalmazó szakasz száma + How many words the document holds A dokumentum szavainak száma + Page on which a bookmarked passage falls A könyvjelzővel megjelölt rész oldalszáma + Copy of the text a bookmark marks A könyvjelzővel megjelölt szöveg másolata + This field has no options. Ennek a mezőnek nincsenek beállításai. + This font has no characters to show. Ebben a betűtípusban nincs megjeleníthető karakter. + This point forward Innentől kezdve + The date it is today A mai dátum + Trademark Védjegy + Type the field's name and its switches, for example PAGE \\* roman. Írd be a mező nevét és kapcsolóit, például: PAGE \\* roman. + Width and spacing Szélesség és térköz + from Unicode (hex) Unicode (hexa) + All - Linear Mind – lineáris + All - Professional Mind – kétdimenziós + Current - Linear Aktuális – lineáris + Current - Professional Aktuális – kétdimenziós + Centered as Group Középre igazítva csoportként + ✓ None ✓ Nincs + Formatted Formázott + Deleted Törölve + Interface size: Felület mérete: + Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. A menüszalag, a menük és a párbeszédablakok nagyítása vagy kicsinyítése a rendszer kijelzőméretezésén felül. A dokumentum nagyítása ettől független. + Copyright Sign Copyright jel + Demote Lefokozás + Edit Field Code Mezőkód szerkesztése + Edit Field… Mező szerkesztése… + Go Back Vissza + Insert Date Field Dátummező beszúrása + Insert Page Field Oldalszámmező beszúrása + Insert Time Field Időmező beszúrása + Lock Fields Mezők zárolása + Move Paragraph Down Bekezdés mozgatása lefelé + Move Paragraph Up Bekezdés mozgatása felfelé + Next Field Következő mező + Previous Field Előző mező + Promote Előléptetés + Registered Sign Bejegyzett védjegy jele + Reset Character Formatting Karakterformázás visszaállítása + Reset Paragraph Formatting Bekezdésformázás visszaállítása + Selected Fields Kijelölt mezők + Toggle Field Codes Mezőkódok átváltása + Toggle Unicode Character Unicode-karakter átváltása + Trademark Sign Védjegy jele + Unlink Fields Mezők leválasztása + Unlock Fields Mezők feloldása + Customize Keyboard Billentyűzet testreszabása + Choose a command Válassz parancsot + Choose a shortcut Válassz billentyűparancsot + All Commands Összes parancs + Commands: Parancsok: + Keys for this command: A parancs billentyűi: + New shortcut: Új billentyűparancs: + Now used by {command} Jelenleg ehhez tartozik: {command} + Not used by any command Egyik parancs sem használja + Remove every custom shortcut and restore the built-in keys? Törlöd az összes egyéni billentyűparancsot, és visszaállítod a beépítetteket? + Assign Hozzárendelés + Remove Eltávolítás + Reset All… Összes visszaállítása… + Press a key combination Nyomj le egy billentyűkombinációt + Customize Testreszabás + Keyboard shortcuts: Billentyűparancsok: + Customize… Testreszabás… + Customize Ribbon Menüszalag testreszabása + Add to Quick Access Toolbar Felvétel a gyorselérési eszköztárra + Remove from Quick Access Toolbar Eltávolítás a gyorselérési eszköztárról + Customize the Ribbon… Menüszalag testreszabása… + Customize Quick Access Toolbar… Gyorselérési eszköztár testreszabása… + More Commands… További parancsok… + Ribbon Menüszalag + (Custom) (Egyéni) + Remove every custom tab and group and show all tabs again? Törlöd az összes egyéni lapot és csoportot, és újra megjelenítesz minden lapot? + Put the default commands back on the Quick Access Toolbar? Visszaállítod a gyorselérési eszköztár alapértelmezett parancsait? + Reset… Visszaállítás… + Add >> Hozzáadás >> + << Remove << Eltávolítás + Pick a custom group (New Group makes one) Válassz egyéni csoportot (az Új csoport gombbal hozhatsz létre egyet) + Ribbon tabs Menüszalag lapjai + New Tab Új lap + New Group Új csoport + Rename… Átnevezés… + Ribbon and Quick Access Toolbar: Menüszalag és gyorselérési eszköztár: + Enclose Characters Bekeretezett karakterek + Shrink text Szöveg kicsinyítése + Enlarge symbol Szimbólum nagyítása + Enclosure Keret + Circle Kör + Templates Sablonok + Templates and Add-ins Sablonok és bővítmények + Attach Template Sablon csatolása + Detach Template Sablon leválasztása + Automatically Update Document Styles Dokumentumstílusok automatikus frissítése + Update Styles from Template Stílusok frissítése a sablonból + Organizer Rendező + Copy Styles Stílusok másolása + Rename Style Stílus átnevezése + Document template: Dokumentumsablon: + Attach… Csatolás… + Automatically update document styles Dokumentumstílusok automatikus frissítése + Organizer… Rendező… + 1. Formatting restrictions 1. Formázási korlátozások + 2. Editing restrictions 2. Szerkesztési korlátozások + 3. Start enforcement 3. A korlátozások érvényesítése + << Less << Kevesebb + Another file Másik fájl + Apply as you type Alkalmazás gépelés közben + AutoCorrect Automatikus javítás + AutoFormat As You Type Automatikus formázás gépelés közben + Automatic bulleted lists Automatikus felsorolásjeles listák + Automatic numbered lists Automatikus számozott listák + Border lines Szegélyvonalak + Capitalize first letter of sentences Mondatok első betűjének nagybetűsítése + Capitalize first letter of table cells Táblázatcellák első betűjének nagybetűsítése + Capitalize names of days Napnevek nagybetűsítése + Case changes Kis- és nagybetű-változások + Character level Karakterszinten + Checked styles are allowed: A bejelölt stílusok engedélyezettek: + Combine Documents Dokumentumok kombinálása + Compare Documents Dokumentumok összehasonlítása + Compared: {changes} changes Összehasonlítás kész: {changes} módosítás + Comparing with another file needs the desktop app. Másik fájllal való összehasonlításhoz az asztali alkalmazás kell. + Comparison settings Összehasonlítási beállítások + Don't capitalize after: Nincs nagybetű ezek után: + Editing in this document is restricted. A dokumentum szerkesztése korlátozott. + Every change is recorded as a tracked change. Minden módosítás követett módosításként rögzül. + Everyone Mindenki + Exceptions (optional) Kivételek (nem kötelező) + Exceptions… Kivételek… + Fields Mezők + Filling in forms Űrlapok kitöltése + Find Next Editable Region Következő szerkeszthető terület keresése + Footnotes and endnotes Láb- és végjegyzetek + Formatting, case changes, white space and tables are compared; the other settings are kept for later. A formázás, a kis- és nagybetű-változások, az üres karakterek és a táblázatok összevetése működik; a többi beállítás egyelőre csak megőrződik. + Fractions (1/2) as fraction characters (½) Törtek (1/2) törtkarakterként (½) + Headers and footers Élőfejek és élőlábak + Hyphens (--) as dashes (—) Kötőjelek (--) gondolatjelként (—) + In equations, typing a name after a backslash replaces it with its symbol. Egyenletben a fordított perjel után beírt név helyére a hozzá tartozó szimbólum kerül. + Internet addresses as links Internetcímek linkként + Label changes with: Módosítások szerzője: + Limit formatting to a selection of styles Formázás korlátozása a kiválasztott stílusokra + Math AutoCorrect Matematikai automatikus javítás + More >> Több >> + Moves Áthelyezések + New document Új dokumentum + No changes (read only) Nincs módosítás (csak olvasható) + Only allow this kind of editing: Csak ez a fajta szerkesztés engedélyezett: + Only comments can be added, except in regions marked editable. Csak megjegyzés fűzhető a szöveghez, kivéve a szerkeszthetőnek jelölt területeken. + Only form fields can be filled in. Csak az űrlapmezők tölthetők ki. + Only the allowed styles can be applied. Csak az engedélyezett stílusok alkalmazhatók. + Ordinals (1st) as superscript Sorszámnevek (1st) felső indexben + Original document Eredeti dokumentum + This password type isn't supported. Ez a jelszótípus nem támogatott. + Replace as you type Csere gépelés közben + Replace text as you type Szöveg cseréje gépelés közben + Replace: Mit: + Revised document Módosított dokumentum + Select text, then tick who can still edit it. Jelölj ki szöveget, majd pipáld be, ki szerkesztheti továbbra is. + Settings… Beállítások… + Show changes at: Módosítások szintje: + Show changes in: Módosítások megjelenítése itt: + Smart quotes instead of straight quotes Tipográfiai idézőjelek az egyenesek helyett + Start Enforcing Protection Védelem bekapcsolása + Stop Protection Védelem kikapcsolása + Text boxes Szövegdobozok + The document is read only, except for regions marked editable. A dokumentum csak olvasható, kivéve a szerkeszthetőnek jelölt területeket. + The password is optional; without it anyone can stop the protection. A jelszó nem kötelező; nélküle bárki kikapcsolhatja a védelmet. + This document Ez a dokumentum + Tracked changes Követett módosítások + Type the password to stop the protection. A védelem kikapcsolásához add meg a jelszót. + White space Üres karakterek + With: Mire: + Word level Szószinten + Change Chart Type Diagramtípus módosítása + Chart Design Diagramtervezés + Type Típus + Edit Data Adatok szerkesztése + Chart Title Diagramcím + Chart Layouts Diagramelrendezések + Add Chart Element Diagramelem hozzáadása + Legend Jelmagyarázat + Data Labels Adatfeliratok + Above Chart Diagram fölött + Insert Chart Diagram beszúrása + Clustered Column Csoportosított oszlop + Stacked Column Halmozott oszlop + Clustered Bar Csoportosított sáv + Stacked Bar Halmozott sáv + Area Terület + Pie Kör + Doughnut Gyűrű + Scatter Pont (XY) + “{text}” ({row}, {series}) isn't a number. „{text}” ({row}, {series}) nem szám. + Category {n} Kategória {n} + Series {n} Adatsor {n} + Type the categories down the side and a column of values per series. Írd a kategóriákat a bal oldali oszlopba, az értékeket pedig adatsoronként egy-egy oszlopba. + Categories Kategóriák + Series name Adatsor neve + Remove series Adatsor eltávolítása + Remove row Sor eltávolítása + Add Row Sor hozzáadása + Add Series Adatsor hozzáadása + Edit\nData Adatok\nszerkesztése + Change\nChart Type Diagramtípus\nmódosítása + Base text: Alapszöveg: + Distribute letters Betűk egyenletes elosztása + Distribute spacing Térköz egyenletes elosztása + Offset (pt): Eltolás (pt): + Phonetic Guide Kiejtési útmutató + Right (vertical) Jobb (függőleges) + Ruby text: Ruby szöveg: + Size (pt): Méret (pt): + In Line with Text Szöveggel egy sorban + Through Átfutó + Top and Bottom Felül és alul + Behind Text Szöveg mögött + In Front of Text Szöveg előtt + Wrap Text Sides Körbefuttatás oldalai + Both Sides Mindkét oldal + Left Only Csak bal + Right Only Csak jobb + Largest Only Csak a szélesebb oldal + Add Font… Betűtípus hozzáadása… + Added Fonts Hozzáadott betűtípusok + Remove Added Font Hozzáadott betűtípus eltávolítása + Print Markup Jelölések nyomtatása + 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-ben, nyomtatásban és képen: megjegyzések az oldalak mellett és követett módosítások. Weboldal, Markdown és szöveg esetén: megjegyzéslista a végén. + Recent Colors Legutóbbi színek + More Colors… További színek… + Standard Alapszínek + Current Jelenlegi + Colors: Színek: + Color model: Színmodell: + Red: Vörös: + Green: Zöld: + Blue: Kék: + Hue: Árnyalat: + Sat: Telítettség: + Lum: Fényesség: + Hex: Hex: + Accessibility Pane Akadálymentesség panel + Alt Text Pane Helyettesítő szöveg panel + Alt\nText Helyettesítő\nszöveg + View Alt Text… Helyettesítő szöveg megtekintése… + Mark as decorative Megjelölés díszítőelemként + Title: Cím: + Errors ({n}) Hibák ({n}) + Warnings ({n}) Figyelmeztetések ({n}) + Tips ({n}) Tippek ({n}) + No accessibility issues found. Nincs akadálymentességi probléma. + Describe what this object shows, for people who can't see it. Írd le, mit ábrázol ez az objektum, azok számára, akik nem látják. + Decorative objects only add visual interest: screen readers skip them. A díszítőelemek csak a látványt szolgálják, a képernyőolvasók kihagyják őket. + Describe what this table shows, for people who can't see it. Írd le, mit mutat ez a táblázat, azok számára, akik nem látják. + Select a picture, shape, chart or table to describe it. Jelölj ki egy képet, alakzatot, diagramot vagy táblázatot a leírásához. + Document Grid Dokumentumrács + No grid Nincs rács + Lines only Csak sorok + Lines and characters Sorok és karakterek + Text snaps to characters A szöveg a karakterrácshoz igazodik + Characters per line: Karakterek soronként: + Lines per page: Sorok oldalanként: + Moved from Áthelyezve innen + Moved to Áthelyezve ide + Developer Fejlesztőeszközök + Controls Vezérlők + Rich Text Content Control Formázott szöveges tartalomvezérlő + Plain Text Content Control Egyszerű szöveges tartalomvezérlő + Picture Content Control Kép típusú tartalomvezérlő + Building Block Gallery Content Control Építőelemgaléria-tartalomvezérlő + Check Box Content Control Jelölőnégyzet-tartalomvezérlő + Combo Box Content Control Kombinált listás tartalomvezérlő + Drop-Down List Content Control Legördülő listás tartalomvezérlő + Date Picker Content Control Dátumválasztó-tartalomvezérlő + Repeating Section Content Control Ismétlődő szakaszos tartalomvezérlő + Design Mode Tervezőmód + Content Control at Selection Tartalomvezérlő a kijelölésnél + Toggle Check Box Jelölőnégyzet átváltása + Choose List Item Listaelem kiválasztása + Set Date Dátum beállítása + Remove Content Control Tartalomvezérlő eltávolítása + Content Control Properties Tartalomvezérlő tulajdonságai + Right-to-Left Table Jobbról balra író táblázat + Right-to-Left Section Jobbról balra író szakasz + Table direction: Táblázat iránya: + Section direction: Szakasz iránya: + A, B, C, … A, B, C, … + Above the item Az elem fölött + Accessed on Megtekintés dátuma + Add New Source… Új forrás hozzáadása… + Add the chapter number Fejezetszám hozzáadása + Atmosphere Légkör + Below the item Az elem alatt + Book Könyv + Book title Könyv címe + Caption Numbering Feliratszámozás + Caption text Felirat szövege + Caption: Felirat: + Chapter in a book Könyvfejezet + Chapters begin at: Fejezetek kezdete: + Collect entries from: Bejegyzések forrása: + Columns: Oszlopok: + Conference Konferencia + Conference paper Konferencia-előadás + Container Title Befoglaló mű címe + Create Source Forrás létrehozása + Cross-reference: Kereszthivatkozás: + Custom Table of Contents… Egyéni tartalomjegyzék… + DOI DOI + Day Nap + Delete Label Címke törlése + Edit Source Forrás szerkesztése + Edition Kiadás + Edit… Szerkesztés… + Entry marked. Bejegyzés megjelölve. + Example: {sample} Példa: {sample} + Figure Ábra + Film Film + Heading styles (levels 1–{n}) Címsorstílusok (1–{n}. szint) + I, II, III, … I, II, III, … + Include page numbers Oldalszámokkal + Indented Behúzott + Institution Intézmény + Ionosphere Ionoszféra + Issue Szám + Journal Folyóirat + Label: Címke: + Last, First; Last, First Vezetéknév, Utónév; Vezetéknév, Utónév + Leave the label out of the caption A címke elhagyása a feliratból + Letter headings Betűfejlécek + Levels: Szintek: + Link entries to their headings Bejegyzések csatolása a címsorukhoz + Made from the author and year when empty Ha üres, a szerzőből és az évből készül + Magazine or newspaper article Folyóirat- vagy újságcikk + Main entry: Főbejegyzés: + Mark Megjelölés + Mark All Összes megjelölése + Mark Entry… Bejegyzés megjelölése… + Mark Index Entry Tárgymutató-bejegyzés megjelölése + Mesosphere Mezoszféra + Month Hónap + New Label Új címke + New Label… Új címke… + New… Új… + No sources yet. Még nincs forrás. + Numbering… Számozás… + Options… Beállítások… + Other Egyéb + Other styles and their TOC level: Egyéb stílusok és tartalomjegyzék-szintjük: + Outline levels Vázlatszintek + Page number style Oldalszám stílusa + Page numbers at the right margin Oldalszámok a jobb margónál + Page title Oldal címe + Pages of bookmark: A könyvjelző oldalai: + Periodical Időszaki kiadvány + Position: Pozíció: + Publisher Kiadó + Run-in Folyamatos + See Lásd + Separator: Elválasztó: + Show every bibliography field Az összes irodalomjegyzék-mező megjelenítése + Site name Webhely neve + Source Manager Forráskezelő + Source type: Forrás típusa: + Sources in this document: A dokumentum forrásai: + Styles: Stílusok: + Subentry: Albejegyzés: + TC entry fields TC bejegyzésmezők + Tab leader: Kitöltő karakter: + Table of Contents Options Tartalomjegyzék beállításai + Table of Contents Styles Tartalomjegyzék stílusai + Tag Azonosító + Placeholder text Helyőrző szöveg + Can't be deleted Nem törölhető + Contents can't be edited A tartalom nem szerkeszthető + Remove the control once edited A vezérlő eltávolítása szerkesztés után + Allow line breaks Sortörések engedélyezése + Date format Dátumformátum + Checked symbol Bejelölt állapot szimbóluma + Unchecked symbol Bejelöletlen állapot szimbóluma + List entries Listaelemek + Display text Megjelenített szöveg + Value Érték + Today Ma + Put the caret in a content control first. Előbb vidd a kurzort egy tartalomvezérlőbe. + This page Ez az oldal + Type: Típus: + URL URL + Volume Kötet + Website Webhely + Year Év + a, b, c, … a, b, c, … + i, ii, iii, … i, ii, iii, … + {n} entries marked. Megjelölt bejegyzések: {n}. + Lasso Select Lasszós kijelölés + Move Selected Ink Kijelölt tinta áthelyezése + Delete Selected Ink Kijelölt tinta törlése + Recolor Selected Ink Kijelölt tinta átszínezése + Add Pen Toll hozzáadása + Remove Pen Toll eltávolítása + Ink to Shape Tintából alakzat + Convert to Shapes Átalakítás alakzattá + Ink Replay Tinta visszajátszása + Add\nPen Toll\nhozzáadása + Rewind Visszatekerés + Forward Előretekerés + Stroke {n} of {total} Vonás: {n}/{total} + Move with Text Mozgatás a szöveggel + Fix Position on Page Rögzített hely az oldalon + Formatting exceptions Formázási kivételek + Inside: Belső: + Mirror indents Tükrözött behúzások + Outside: Külső: + WordArt Styles WordArt-stílusok + Text Fill Szövegkitöltés + Text Outline Szöveg körvonala + Transform Átalakítás + No Fill Nincs kitöltés + No Outline Nincs körvonal + Reflection Tükröződés + No Reflection Nincs tükröződés + No Transform Nincs átalakítás + Connect Shapes Alakzatok összekötése + Straight Connector Egyenes összekötő + Straight Arrow Connector Egyenes nyilas összekötő + Elbow Connector Derékszögű összekötő + Elbow Arrow Connector Derékszögű nyilas összekötő + Curved Connector Ívelt összekötő + Curved Arrow Connector Ívelt nyilas összekötő + Arch Up Ív felfelé + Arch Down Ív lefelé + Slant Up Dőlés felfelé + Slant Down Dőlés lefelé + Inflate Felfújás + Deflate Leeresztés + Chevron Up Ék felfelé + Chevron Down Ék lefelé + Triangle Up Háromszög felfelé + Triangle Down Háromszög lefelé + Solid Accent Teli kiemelőszín + Light with Outline Világos, körvonallal + Hollow Üreges + Accent Gradient Kiemelőszínes színátmenet + Dark with Shadow Sötét, árnyékkal + Soft Glow Lágy ragyogás + Accent with Reflection Kiemelőszín tükröződéssel + Warm Gradient with Outline Meleg színátmenet körvonallal + Add line numbering Sorszámozás hozzáadása + Add to Document Hozzáadás a dokumentumhoz + Address labels Címcímkék + Arabic Arab + Auto Automatikus + Automatically hyphenate document Automatikus elválasztás a dokumentumban + Chinese (Simplified) Kínai (egyszerűsített) + Chinese (Traditional) Kínai (hagyományos) + Column: Oszlop: + Count by: Lépésköz: + Czech Cseh + Danish Dán + Delivery address: Kézbesítési cím: + Detect language automatically Nyelv automatikus felismerése + Do not check spelling or grammar Nincs helyesírás- és nyelvhelyesség-ellenőrzés + Dutch Holland + English (United Kingdom) Angol (Egyesült Királyság) + Envelope size: Borítékméret: + Estonian Észt + Finnish Finn + French (France) Francia (Franciaország) + From text: Távolság a szövegtől: + Full page of the same label Teljes oldal azonos címkével + German (Germany) Német (Németország) + Greek Görög + Hebrew Héber + Hyphenate at: Elválasztási hely: + Hyphenate words in CAPS Csupa nagybetűs szavak elválasztása + Hyphenation Options… Elválasztási beállítások… + Hyphenation zone: Elválasztási zóna: + Italian Olasz + Japanese Japán + Korean Koreai + Limit consecutive hyphens to: Egymást követő elválasztások legfeljebb: + Line Numbering Options… Sorszámozás beállításai… + Manual Hyphenation Kézi elválasztás + Manual hyphenation is complete. A kézi elválasztás befejeződött. + Manual… Kézi… + Mark selected text as: A kijelölt szöveg nyelve: + New Document Új dokumentum + No limit Nincs korlát + Norwegian (Bokmål) Norvég (bokmål) + Numbering: Számozás: + Omit Elhagyás + One label per recipient Címzettenként egy címke + Polish Lengyel + Portuguese (Brazil) Portugál (Brazília) + Portuguese (Portugal) Portugál (Portugália) + Return address labels Feladócímkék + Return address: Feladó címe: + Row: Sor: + Russian Orosz + Serbian (Cyrillic) Szerb (cirill) + Serbian (Latin) Szerb (latin) + Set As Default Beállítás alapértelmezettként + Shipping labels Szállítási címkék + Single label Egyetlen címke + Spanish (Mexico) Spanyol (Mexikó) + Spanish (Spain) Spanyol (Spanyolország) + Swedish Svéd + Turkish Török + Ukrainian Ukrán + {language} is now the document's default language. A dokumentum alapértelmezett nyelve mostantól: {language} + As you type Gépelés közben + AutoSave writes your changes to the file a moment after you stop typing. Az automatikus mentés néhány pillanattal a gépelés abbahagyása után a fájlba írja a módosításaidat. + Centimeters Centiméter + Check spelling as you type Helyesírás ellenőrzése gépelés közben + Click to switch back to inserting Kattints ide a beszúrásra való visszaváltáshoz + Custom dictionary Egyéni szótár + Formatting marks Formázási jelek + Hidden text Rejtett szöveg + Ignore web and file addresses Web- és fájlcímek kihagyása + Ignore words in capitals Csupa nagybetűs szavak kihagyása + Ignore words with numbers Számot tartalmazó szavak kihagyása + Inches Hüvelyk + Initials: Monogram: + Interface Felület + Interface language Felület nyelve + Mark grammar errors as you type Nyelvhelyességi hibák jelölése gépelés közben + Measurement units: Mértékegység: + Millimeters Milliméter + New comments and tracked changes are recorded under this name and these initials. Az új megjegyzések és követett módosítások ezzel a névvel és monogrammal rögzülnek. + New word Új szó + No words yet. Add to Dictionary on a misspelled word puts it here. Még nincs szó. Ha egy hibás szón a Felvétel a szótárba parancsot választod, ide kerül. + Drawing Canvas Rajzvászon + New Drawing Canvas Új rajzvászon + Fit Illesztés + Expand Kibővítés + Scale Drawing Rajz méretezése + Canvas Member Vászonelem + Choose the sheet that holds the recipients. Válaszd ki a címzetteket tartalmazó munkalapot. + First row of data contains column headers Az adatok első sora oszlopfejléceket tartalmaz + Communication Kommunikáció + Office Iroda + People Emberek + Nature Természet + Weather Időjárás + Technology Technika + Transport Közlekedés + Icon Color Ikon színe + Search icons Ikonok keresése + No icons match your search. Egy ikon sem felel meg a keresésnek. + Recolor a one-color picture, such as an icon Egyszínű kép, például ikon átszínezése + Align Text Szöveg igazítása + Create Link Csatolás létrehozása + Break Link Csatolás megszüntetése + Rotate all text 90° Teljes szöveg elforgatása 90°-kal + Rotate all text 270° Teljes szöveg elforgatása 270°-kal + Stacked Egymás alatt + Middle Középen + Click the empty text box the text should continue in (Esc cancels) Kattints arra az üres szövegdobozra, amelyben a szöveg folytatódjon (megszakítás: Esc) + Not linked: click a text box Nincs csatolva: kattints egy szövegdobozra + Location Hely + Endnotes Végjegyzetek + Below text A szöveg alatt + End of section Szakasz vége + End of document Dokumentum vége + Footnotes to endnotes Lábjegyzetekből végjegyzetek + Endnotes to footnotes Végjegyzetekből lábjegyzetek + Swap footnotes and endnotes Láb- és végjegyzetek felcserélése + Custom mark: Egyéni jel: + Continuous numbering Folyamatos számozás + Apply Alkalmaz + Convert Notes Jegyzetek átalakítása + Documents Dokumentumok + Word document Word-dokumentum + All files Minden fájl + Recipient lists Címzettlisták + The system file dialog isn't available, so WordCraft shows its own. To get the system dialog, install a file chooser portal (such as xdg-desktop-portal-gtk) or zenity. A rendszer fájlpárbeszédablaka nem érhető el, ezért a WordCraft a sajátját mutatja. A rendszer párbeszédablakához telepíts egy fájlválasztó portált (például xdg-desktop-portal-gtk) vagy a zenity programot. + Up one level Egy szinttel feljebb + Home folder Saját mappa + {name} wasn't found. Nem található: {name} + {name} already exists. Choose Save again to replace it. Már létezik: {name}. A felülírásához válaszd újra a Mentést. + This folder can't be read: {error} Ez a mappa nem olvasható: {error} + Showing the first {count} items. Az első {count} elem látható. + File name: Fájlnév: + Browse in WordCraft… Tallózás a WordCraftban… + Open a file with WordCraft's own browser when the system file dialog doesn't appear. Fájl megnyitása a WordCraft saját tallózójával, ha a rendszer fájlpárbeszédablaka nem jelenik meg. + Hide White Space Üres terület elrejtése + All pages Minden oldal + Current page Aktuális oldal + Selected pages Kijelölt oldalak + Custom range Egyéni tartomány + All pages in the range A tartomány minden oldala + Odd pages only Csak a páratlan oldalak + Even pages only Csak a páros oldalak + Copies Példányszám + Collate copies Példányok szétválogatása + Pages like 1-3, 5 Oldalak, például 1-3, 5 + {count} of {total} pages will print Nyomtatandó oldalak: {count}/{total} + Save these pages as PDF… Az oldalak mentése PDF-ként… + The printer, two-sided printing and pages per sheet are chosen in that dialog. A nyomtatót, a kétoldalas nyomtatást és a laponkénti oldalszámot abban a párbeszédablakban választhatod ki. + Previous Page Előző oldal + Print to PDF Nyomtatás PDF-be + Word XML Document (*.xml) Word XML-dokumentum (*.xml) + Use wildcards Helyettesítő karakterek használata + Special Különleges + Match prefix Egyezés a szó elején + Match suffix Egyezés a szó végén + Ignore punctuation characters Írásjelek figyelmen kívül hagyása + Ignore white-space characters Üres karakterek figyelmen kívül hagyása + Regular expressions Reguláris kifejezések + Search Options Keresési beállítások + Tab Character Tabulátor karakter + Any Character Bármely karakter + Any Digit Bármely számjegy + Any Letter Bármely betű + Caret Character Kalap karakter (^) + Section Character Paragrafusjel (§) + Paragraph Character Bekezdésjel karakter (¶) + Graphic Grafika + Manual Line Break Kézi sortörés + Manual Page Break Kézi oldaltörés + Section Break Szakasztörés + White Space Üres karakter + Clipboard Contents Vágólap tartalma + Find What Text A keresett szöveg + Find What Expression A keresett kifejezés + Character in Range Karakter a tartományban + Beginning of Word Szó eleje + End of Word Szó vége + Expression Kifejezés + Not Kivéve + Num Occurrences Előfordulások száma + Previous 1 or More Előző 1 vagy több + 0 or More Characters 0 vagy több karakter + Add Shape Alakzat hozzáadása + Change Colors Színek módosítása + Change Layout Elrendezés módosítása + Create Graphic Ábra létrehozása + Edit Items Elemek szerkesztése + Layouts Elrendezések + Reset Graphic Ábra visszaállítása + SmartArt Design SmartArt-tervezés + SmartArt Styles SmartArt-stílusok + Text Pane Szöveg panel + List Lista + Process Folyamat + Cycle Körforgás + Hierarchy Hierarchia + Relationship Kapcsolat + Pyramid Piramis + Basic List Egyszerű lista + Basic Process Egyszerű folyamat + Basic Cycle Egyszerű körforgás + Basic Pyramid Egyszerű piramis + Basic Radial Egyszerű sugaras + Basic Matrix Egyszerű mátrix + Basic Venn Egyszerű Venn-diagram + Shows items of equal weight as blocks in rows. Az egyenrangú elemeket sorokba rendezett blokkokként mutatja. + Shows steps in order, left to right, with arrows between them. A lépéseket sorrendben, balról jobbra, nyilakkal összekötve ábrázolja. + Shows steps that repeat, around a circle. Az ismétlődő lépések egy kör mentén követik egymást. + Shows a tree: each item below the item it belongs to. Faszerkezet: minden elem az alatt az elem alatt áll, amelyikhez tartozik. + Shows parts that build on each other, largest at the bottom. Egymásra épülő részek, alul a legnagyobb. + Shows items around a central idea: the first item is the centre. Egy központi gondolat köré rendezett elemek: az első elem a középpont. + Shows parts of a whole as quadrants. Az egész részei negyedekre osztva. + Shows overlapping ideas as overlapping circles. Az átfedő gondolatokat egymást metsző körök jelenítik meg. + Accent {n} Kiemelőszín {n} + Colorful Színes + Insert SmartArt SmartArt beszúrása + One line per item. Enter adds an item. Elemenként egy sor. Az Enter új elemet ad hozzá. + Add\nShape Alakzat\nhozzáadása + Change\nColors Színek\nmódosítása + Reset\nGraphic Ábra\nvisszaállítása + Promote to Heading 1 Előléptetés: Címsor 1 + Demote to Body Text Lefokozás: Szövegtörzs + Move Up Mozgatás felfelé + Move Down Mozgatás lefelé + Collapse Összecsukás + Expand or Collapse Heading Címsor kibontása vagy összecsukása + Select Heading and Content Címsor és tartalma kijelölése + Show Level Szint megjelenítése + Show Level 1 1. szint megjelenítése + Show Level 2 2. szint megjelenítése + Show Level 3 3. szint megjelenítése + Show Level 4 4. szint megjelenítése + Show Level 5 5. szint megjelenítése + Show Level 6 6. szint megjelenítése + Show Level 7 7. szint megjelenítése + Show Level 8 8. szint megjelenítése + Show Level 9 9. szint megjelenítése + Show All Levels Összes szint megjelenítése + All Levels Minden szint + Show First Line Only Csak az első sor megjelenítése + Show Text Formatting Szövegformázás megjelenítése + Close Outline View Vázlatnézet bezárása + Outline State Vázlatállapot + Outlining Vázlatkészítés + Outline Tools Vázlateszközök + Close Outline\nView Vázlatnézet\nbezárása + APA APA + Chicago Chicago + GB/T 7714 GB/T 7714 + GOST (name sort) GOST (név szerinti rendezés) + GOST (title sort) GOST (cím szerinti rendezés) + Harvard (Anglia) Harvard (Anglia) + IEEE IEEE + ISO 690 (author-date) ISO 690 (szerző–év) + ISO 690 (numerical) ISO 690 (számozott) + MLA MLA + SIST02 SIST02 + Turabian Turabian + Do Not Check Spelling or Grammar Nincs helyesírás- és nyelvhelyesség-ellenőrzés + ➕ New comment ➕ Új megjegyzés + 📂 Browse… 📂 Tallózás… + Possible spelling mistake Lehetséges helyesírási hiba + Extra space Felesleges szóköz + Capitalize the first word of a sentence A mondat első szava nagybetűvel kezdődjön + Remove the space before the punctuation Töröld az írásjel előtti szóközt + Use "an" before a vowel sound Magánhangzóval kezdődő szó előtt „an” áll + Use "a" before a consonant sound Mássalhangzóval kezdődő szó előtt „a” áll + The spelling and grammar check is complete. A helyesírás- és nyelvhelyesség-ellenőrzés befejeződött. + Couldn't make WordCraft the default: {error} Nem sikerült a WordCraftot alapértelmezetté tenni: {error} + In Default apps, choose WordCraft for the document types you want it to open. Az Alapértelmezett alkalmazások között válassza a WordCraftot azokhoz a dokumentumtípusokhoz, amelyeket meg kell nyitnia. + Make WordCraft the default for Word documents… A WordCraft legyen az alapértelmezett a Word-dokumentumokhoz… + Opens .docx, .doc, .rtf and .odt files and Word templates with WordCraft from now on. Mostantól a .docx, .doc, .rtf és .odt fájlok, valamint a Word-sablonok a WordCrafttal nyílnak meg. + Opens Settings › Default apps, where you choose WordCraft for .docx and the other document types. Megnyitja a Beállítások › Alapértelmezett alkalmazások lapot, ahol a WordCraftot választhatja a .docx és a többi dokumentumtípus számára. + To open Word documents with WordCraft by default: select one in Finder, choose File › Get Info, pick WordCraft under Open with, then click Change All. Ha a Word-dokumentumokat alapértelmezés szerint a WordCrafttal szeretné megnyitni: jelöljön ki egyet a Finderben, válassza a Fájl › Információ parancsot, a „Megnyitás ezzel” alatt válassza a WordCraftot, majd kattintson az „Összes módosítása” gombra. + WordCraft is now the default app for Word documents. Mostantól a WordCraft az alapértelmezett alkalmazás a Word-dokumentumokhoz. + Numerals: Számjegyek: + Numerals Számjegyek + Hindi Hindi + Context Környezetfüggő + System Rendszer + Show KeyTips (Alt or F10) Billentyűtippek megjelenítése (Alt vagy F10) + Reading Olvasás + Reading Column Width Olvasási oszlopszélesség + Reading Line Spacing Olvasási sorköz + Reading Text Size Olvasási szövegméret diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 832b5caec..986fc6ec9 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -52,7 +52,7 @@ pub struct LangInfo { } /// The registry. English first: it is the fallback and the source language. -pub static LANGUAGES: [LangInfo; 17] = [ +pub static LANGUAGES: [LangInfo; 18] = [ LangInfo { code: "en", name: "English", source: "", prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, // Simplified Chinese; `zh`, `zh-CN`, `zh-SG` and `zh-Hans-*` resolve here (see `candidates`). LangInfo { @@ -121,6 +121,8 @@ pub static LANGUAGES: [LangInfo; 17] = [ LangInfo { code: "tr", name: "Türkçe", source: include_str!("tr.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, // Italian; `it-IT`, `it-CH` and `it-*` resolve here. LangInfo { code: "it", name: "Italiano", source: include_str!("it.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, + // Hungarian; `hu`, `hu-HU` and `hu_HU.UTF-8` resolve here. + LangInfo { code: "hu", name: "Magyar", source: include_str!("hu.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, // French; `fr`, `fr-FR`, `fr_CA` and other regions resolve here. LangInfo { code: "fr", name: "Français", source: include_str!("fr.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, ]; diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 87a9837d8..7a6707146 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -753,6 +753,87 @@ fn bundled_interface_fonts_cover_turkish_without_system_fallbacks() { assert!(!lang("tr").uses_cjk()); } +#[test] +fn hungarian_locales_and_saved_preference_work_without_changing_the_document() { + let hu = lang("hu"); + for tag in ["hu", "hu-HU", "hu_HU.UTF-8", "HU_hu.UTF-8@euro", "hu-Latn-HU"] { + assert_eq!(lang_from_tag(tag), Some(hu), "{tag}"); + } + assert_eq!(first_supported(["sv-SE", "hu-HU", "en-US"]), Some(hu)); + assert_eq!(normalize_pref("HU"), Some("hu")); + let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_engine::sample::sample_document()), Default::default()); + let before = serde_json::to_value(&app.session.doc).unwrap(); + let result = app.run("ui.language", serde_json::json!({"value": "hu"})).unwrap(); + assert_eq!(result["effective"], "hu"); + assert!(result["available"].as_array().unwrap().iter().any(|l| l["code"] == "hu" && l["name"] == "Magyar")); + assert_eq!(serde_json::to_value(&app.session.doc).unwrap(), before); + let saved = serde_json::to_string(&app.ui).unwrap(); + let restored: crate::UiState = serde_json::from_str(&saved).unwrap(); + assert_eq!(Lang::from_pref(&restored.language), hu); + app.run("ui.language", serde_json::json!({"value": "auto"})).unwrap(); + assert_eq!(app.ui.language, AUTO); +} + +#[test] +fn hungarian_covers_the_entire_existing_interface_catalog() { + use std::collections::HashSet; + let keys = |source| parse_entries(source).0.into_iter().map(|e| e.source).collect::>(); + // Every key of the most complete catalog: built-in style names, equation gallery labels, paper + // sizes and watermarks included. + assert!(keys(lang("hu").0.source).is_superset(&keys(lang("ru").0.source))); + assert!(keys(lang("hu").0.source).is_superset(&keys(lang("es").0.source))); + let hu = lang("hu"); + for (source, expected) in [ + ("Home", "Kezdőlap"), + ("Font", "Betűtípus"), + ("Review", "Véleményezés"), + ("Header", "Élőfej"), + ("Track Changes", "Változáskövetés"), + ("Save", "Mentés"), + ("Redo", "Újra"), + ("UPPERCASE", "NAGYBETŰS"), + ("WordCraft", "WordCraft"), + ("unknown future label", "unknown future label"), + ] { + assert_eq!(super::tr(hu, source), expected); + } + assert_eq!(fmt(super::tr(hu, "Page {page} of {pages}"), &[("page", "3"), ("pages", "9")]), "Oldal: 3/9"); + // A noun after a number stays singular in Hungarian, whatever the count. + for count in [0, 1, 2, 21, 1000] { + let words = count.to_string(); + assert_eq!(fmt(super::tr(hu, "{words} words"), &[("words", &words)]), format!("{count} szó")); + } + // The Font dialog uppercases its preview sample for All Caps. + assert_eq!(super::tr(hu, "AaBbYyZz").to_uppercase(), "AABBYYZZ ŐŐŰŰ"); + set_current(hu); + // Built-in styles show translated names; numbered families go through their template. + for (style, expected) in [("Normal", "Normál"), ("No Spacing", "Nincs térköz"), ("Heading 2", "Címsor 2"), ("TOC 3", "Tartalomjegyzék 3")] { + assert_eq!(builtin_style_name(style), expected); + } + assert_eq!(builtin_style_name("My Own Style"), "My Own Style"); + assert_eq!(location("Home › Font"), "Kezdőlap › Betűtípus"); + assert_eq!(prefixed("Undo", "Bold"), "Visszavonás: Félkövér"); + set_current(Lang::EN); +} + +#[test] +fn bundled_interface_fonts_cover_hungarian_without_system_fallbacks() { + let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new()); + let (entries, errors) = parse_entries(lang("hu").0.source); + assert!(errors.is_empty()); + let extra = "ÁáÉéÍíÓóÖöŐőÚúÜüŰű"; + let chars: std::collections::HashSet = + entries.iter().flat_map(|e| e.translation.chars()).filter(|c| extra.contains(*c)).chain(extra.chars()).collect(); + for (family, style) in [("Inter", "Regular"), ("Inter", "Medium"), ("Inter", "SemiBold"), ("JetBrains Mono", "Regular")] { + let face = db.face(family, style); + assert_eq!(face.family, family, "must use the bundled interface face"); + for ch in &chars { + assert_ne!(face.glyph_for(*ch), 0, "{family} {style} lacks {ch}"); + } + } + assert!(!lang("hu").uses_cjk()); +} + #[test] fn french_locales_and_saved_preference_work_without_changing_the_document() { let fr = lang("fr"); @@ -833,9 +914,9 @@ fn polish_covers_every_key_of_every_other_catalog() { use std::collections::HashSet; let keys = |source| parse_entries(source).0.into_iter().map(|e| e.source).collect::>(); let polish = keys(lang("pl").0.source); - // Russian and Italian also translate the built-in style names and the equation gallery, + // Russian, Italian and Hungarian also translate the built-in style names and the equation gallery, // which the others don't yet; Polish matches every other catalog. - for other in Lang::all().filter(|l| *l != Lang::EN && !matches!(l.code(), "pl" | "ru" | "it")) { + for other in Lang::all().filter(|l| *l != Lang::EN && !matches!(l.code(), "pl" | "ru" | "it" | "hu")) { let mut missing: Vec = keys(other.0.source).difference(&polish).cloned().collect(); missing.sort(); assert!(missing.is_empty(), "pl lacks {}'s {missing:?}", other.code()); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 935576e3a..e816eb3c9 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -50,7 +50,7 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key. | Method | Params | Result | |---|---|---| -| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog`, `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"|"en"|"zh-hans"|"zh-hant"|"ja"|"pt-br"|"es"|"uk"|"sr"|"sr-latn"|"et"|"tr"|"ru"|"pl"|"de"|"it"|"cs"|"fr"}`), `ui.theme` (`{"value": "system"|"light"|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"percent": 80–200}`, clamped; no value reads it; scales the interface on top of the system display scale, document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"|"on"|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"|"picture"|"recipients"|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.caretAnimation` (`{"value": true|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading)…) | +| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog`, `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"|"en"|"zh-hans"|"zh-hant"|"ja"|"pt-br"|"es"|"uk"|"sr"|"sr-latn"|"et"|"tr"|"ru"|"pl"|"de"|"it"|"cs"|"hu"|"fr"}`), `ui.theme` (`{"value": "system"|"light"|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"percent": 80–200}`, clamped; no value reads it; scales the interface on top of the system display scale, document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"|"on"|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"|"picture"|"recipients"|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.caretAnimation` (`{"value": true|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading)…) | | `engine.commands` | — | every command: id, label, location, shortcut, params, enabled | | `document.inspect` | `text?` | blocks (text, style, runs, lists, tables), parts, sections, selection, pages | | `ui.inspect` | — | UI state, view, dialog, window size, page rects on screen, caret, comment balloons (`balloons`: id + screen rect; `balloon`: the selected one), perf | diff --git a/docs/localization-hu.md b/docs/localization-hu.md new file mode 100644 index 000000000..eb7a71d15 --- /dev/null +++ b/docs/localization-hu.md @@ -0,0 +1,75 @@ +# Hungarian interface + +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (Hungarian interface catalog and regression checks) · **Target:** WordCraft 0.5.0 + +Choose **File › Options › Interface language › Magyar**, or follow a Hungarian system display +language (`hu-HU`, `hu_HU.UTF-8`). The catalog code is `hu`; `ui.language` with `{"value":"hu"}` +selects it explicitly, and `WORDCRAFT_LANGUAGE=hu-HU` does the same for a headless screenshot or a +launch while the preference is `auto`. The choice is saved with the other interface settings. +Document text, command ids, CLI/MCP/control responses and file paths do not change. + +`crates/ui-egui/src/i18n/hu.tsv` has 2,419 entries: every key of the Russian catalog (built-in style +names, equation gallery labels, paper sizes, watermarks and greetings included) plus the citation +style names. It is written from the meaning of the English labels; no proprietary translation resources were consulted. The catalog +format has no plural forms, and Hungarian doesn't need them: a noun after a number stays singular +(`0 szó`, `1 szó`, `21 szó`). Placeholders, `\n` breaks in ribbon labels and dialog ellipses are +kept, and strings added later fall back to English until they are translated. + +## Conventions + +- Commands are nouns (`Mentés`, `Másolás`, `Beillesztés`); messages address the user with the + informal second person (`Mentsd a dokumentumot`). +- Only the first word of a label is capitalised, as Hungarian spelling requires + (`Összes módosítás elfogadása`). +- Decimal comma (`1,5-es sorköz`) and Hungarian quotation marks („…”). +- A placeholder never takes a case ending, because the value is not known when the text is + written. The wording puts it after a colon or in parentheses instead + (`Undo {action}` → `Visszavonás: {action}`, `Couldn't open {path}: {error}` → + `Nem sikerült megnyitni: {path} ({error})`). +- Text columns and table columns are different words: `Hasábok` (Layout › Columns, column break) + and `Oszlopok` (table rows and columns). + +## Terminology + +| Source | Hungarian | +|---|---| +| Home / Insert / Draw / Design / Layout | Kezdőlap / Beszúrás / Rajzolás / Tervezés / Elrendezés | +| References / Mailings / Review / View / Help | Hivatkozások / Levelezés / Véleményezés / Nézet / Súgó | +| Font / Paragraph / Styles | Betűtípus / Bekezdés / Stílusok | +| Header / Footer / Heading | Élőfej / Élőláb / Címsor | +| Footnote / Endnote / Caption | Lábjegyzet / Végjegyzet / Felirat | +| Table of Contents / Index | Tartalomjegyzék / Tárgymutató | +| Citation / Bibliography | Forráshivatkozás / Irodalomjegyzék | +| Link / Hyperlink | Link | +| Track Changes / Accept / Reject | Változáskövetés / Elfogadás / Elutasítás | +| Comment / Markup | Megjegyzés / Jelölések | +| Mail Merge / Recipients / Merge Field | Körlevél / Címzettek / Egyesítési mező | +| Columns (text) / Columns (table) | Hasábok / Oszlopok | +| Section / Section Break | Szakasz / Szakasztörés | +| Undo / Redo / Repeat | Visszavonás / Újra / Ismétlés | +| Undo {action} | Visszavonás: {action} | +| Page {page} of {pages} | Oldal: {page}/{pages} | +| Ribbon / Pane / Quick Access Toolbar | Menüszalag / Panel / Gyorselérési eszköztár | +| Proofing / Word Count | Nyelvi ellenőrzés / Szavak száma | + +Product and format names stay as they are (WordCraft, PDF, DOCX, Markdown, MCP, Zotero). The Font +dialog's preview sample is `AaBbYyZz ŐőŰű`, so the All Caps preview shows the Hungarian double +acute capitals. + +## Verification and remaining scope + +`cargo test -p wordcraft-ui-egui --lib i18n::tests` covers Hungarian locale tags and preference +order, saving and restoring `hu`, unchanged document content, every key of the Russian catalog, +built-in style names, placeholders, count wording, the font sample's capitals, English fallback, +and the glyphs `ÁáÉéÍíÓóÖöŐőÚúÜüŰű` in the bundled Inter (Regular, Medium, SemiBold) and JetBrains Mono. The shared catalog checks +(validation, every tab and command translated) include Hungarian automatically, and the on-demand +interface audit (`untranslated_interface_text`) checks Hungarian alongside Russian. + +Spelling, grammar and hyphenation stay English-only: this is an interface translation, not +Hungarian proofing. A native-speaker review of the catalog is welcome. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-11 | minor | Hungarian catalog (2,419 entries: built-in style names, equation gallery, citation styles), conventions, terminology, scope and regression checks | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index aabbbf8dc..7743cf578 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (French interface added; previously minor: Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Hungarian interface added; previously minor: Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Interface languages, script support and proofing per language. How translation works (catalog format, adding a language, clean-room rule): `crates/ui-egui/src/i18n/mod.rs`. @@ -19,9 +19,9 @@ native-speaker review (human). ## What WordCraft ships (measured) -- **17 interface languages**: English plus `zh-hans`, `zh-hant`, `ja`, `uk`, `es`, `pt-br`, - Serbian `sr`/`sr-latn` (#250), Estonian `et`, Turkish `tr`, Russian `ru`, Italian `it` (#594), German `de` (#574), Polish `pl`, Czech `cs` and French `fr`. Catalogs have 2,021–2,399 entries - each; Polish has every key of every other catalog (a test checks it). A test (`every_tab_and_command_is_translated`) enforces that every ribbon tab, command +- **18 interface languages**: English plus `zh-hans`, `zh-hant`, `ja`, `uk`, `es`, `pt-br`, + Serbian `sr`/`sr-latn` (#250), Estonian `et`, Turkish `tr`, Russian `ru`, Italian `it` (#594), German `de` (#574), Polish `pl`, Czech `cs`, Hungarian `hu` and French `fr`. Catalogs have 2,021–2,419 entries + each; Polish has every key of every other catalog except the style names and equation gallery labels only Russian, Italian and Hungarian translate so far (a test checks it). A test (`every_tab_and_command_is_translated`) enforces that every ribbon tab, command label and ribbon location is translated in every language. `pl`, `pl-PL` and `pl_PL.UTF-8` system locales pick Polish automatically. - Of the 675 English strings a scan extracts from `tl!(…)` calls and command labels/locations, each @@ -57,8 +57,8 @@ shared items below. | Korean | ko | 0 | ❌ | Hangul ✅; macOS Korean IME fixes pending (#163, #164) | no | none | 3–5 | | Vietnamese | vi | 0 | ❌ | Latin with stacked diacritics, untested | no | none | 3–4 | -Other shipped languages: **10** — Traditional Chinese (`zh-hant`), Ukrainian (`uk`), Serbian -Cyrillic and Latin (`sr`, `sr-latn`, #250), Estonian (`et`), Turkish (`tr`), Russian (`ru`), Italian (`it`, #594), Polish (`pl`) and Czech (`cs`), all partial. +Other shipped languages: **12** — Traditional Chinese (`zh-hant`), Ukrainian (`uk`), Serbian +Cyrillic and Latin (`sr`, `sr-latn`, #250), Estonian (`et`), Turkish (`tr`), Russian (`ru`), Italian (`it`, #594), Polish (`pl`), Czech (`cs`), Hungarian (`hu`) and French (`fr`), all partial. Estonian uses the existing fonts, follows `et-EE` system locales, and is available in Options or through `ui.language` with `{"value":"et"}`. Its 1,307 entries cover the existing interface catalog. Estonian spelling, grammar, dates and document templates are not added by the interface catalog. @@ -66,6 +66,9 @@ Turkish works the same way (`tr-TR` and `tr-CY` system locales, `{"value":"tr"}` entries. Command search folds the Turkish dotted and dotless I (`italik` finds İtalik, `YAZI TİPİ` finds Yazı Tipi) while English labels and ASCII command ids keep their usual matching. Turkish proofing is not included; terminology and checks: [`localization-tr.md`](localization-tr.md). +Hungarian follows `hu-HU` system locales and `{"value":"hu"}`, with a complete catalog of 2,419 entries: every key of +the Russian catalog, built-in style names shown translated, plus the citation style names. Hungarian proofing is not included; terminology and conventions: +[`localization-hu.md`](localization-hu.md). Polish is the only one besides English with proofing tools, and a Polish interface writes new documents in Polish (`pl-PL`). @@ -111,6 +114,7 @@ allows); all other text is written from the meaning of the English. No Italian p | 2026-10-11 | minor | Font list shows installed fonts by their names in the interface language (宋体 for SimSun in Chinese, Japanese and Korean names likewise) and finds them by either name; a Chinese, Japanese or Korean font is stored as `w:eastAsia` too (#505) | | 2026-10-11 | minor | Chinese/Japanese interface fonts: a test checks every catalog character has a glyph; an installed CJK font follows the embedded faces whenever they miss one; macOS font asset folders searched for PingFang (#486) | | 2026-10-10 | minor | Russian interface (#300; 1,524 entries, built-in style names shown translated; covers everything the interface audit finds) | +| 2026-10-11 | minor | Hungarian interface catalog (2,419 entries, built-in style names shown translated; covers everything the interface audit finds), locale selection, saved preference and font coverage checks | | 2026-10-11 | minor | Turkish interface catalog (1,307 entries), locale selection, saved preference, Turkish casing in command search and font coverage checks; catalog sizes re-counted | | 2026-10-11 | trivial | English spelling (#520): our supplement `assets/spelling/en-extra.txt` adds ~220 common modern words the Moby list lacks ("desktop", "download", "email"…) | | 2026-10-10 | minor | Estonian interface catalog, locale selection, saved preference and font coverage checks; count labels put the noun first so they read right at 1 | From 62a17b841fcd083fc0c03ac5ce877541e2c3106e Mon Sep 17 00:00:00 2001 From: Hevar Hemin <66733462+Hevarh1@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:51:03 +0300 Subject: [PATCH 34/88] Bidi: auto-detect RTL paragraph direction when typing Kurdish, Arabic and Hebrew (#713) * Bidi: auto-detect RTL paragraph direction and keep typing caret advancing smoothly When typing in Kurdish, Arabic or Hebrew in a new document, default paragraph direction (bidi: None) was resolving to LTR. In an LTR paragraph, visual reordering pinned newly typed characters and the caret to the left margin (x = 72.0), resetting to the start of the line with every keystroke. - Add first_strong_is_rtl() following UAX #9 rules P2/P3. - Auto-resolve unassigned paragraph direction to RTL in layout when the paragraph starts with an RTL script. - Auto-promote paragraph to RTL (bidi = Some(true)) when typing text starting with an RTL character. - Add regression test in wordcraft-engine. * Review fixes for #713: RTL only when typing into an empty paragraph - Drop the layout fallback that drew any paragraph without w:bidi as RTL when its first strong character is Arabic/Hebrew: opened DOCX paragraphs keep their direction, and a style that sets bidi=false is respected. - type_text promotes a paragraph to RTL only when it had no letters before the insert and nothing (paragraph, style chain, document defaults) sets its direction; typing into a paragraph that already has text no longer flips it. - docs/typing-parity.md: Text direction section; test in tests_typing.rs. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/doc/src/bidi.rs | 26 ++++++++++++++++++++++ crates/engine/src/cmd/mod.rs | 22 +++++++++++++++++++ crates/engine/src/tests_bidi.rs | 20 +++++++++++++++++ crates/engine/src/tests_typing.rs | 36 +++++++++++++++++++++++++++++++ docs/typing-parity.md | 12 ++++++++++- 5 files changed, 115 insertions(+), 1 deletion(-) diff --git a/crates/doc/src/bidi.rs b/crates/doc/src/bidi.rs index 5da0dab5c..e4c91835f 100644 --- a/crates/doc/src/bidi.rs +++ b/crates/doc/src/bidi.rs @@ -37,6 +37,26 @@ pub fn has_rtl(text: &str) -> bool { text.chars().any(is_rtl_script) } +/// Does the first strong directional character in `text` belong to a right-to-left script? +/// +/// Follows UAX #9 rules P2 and P3: characters with strong bidirectional class AL or R +/// (Arabic, Hebrew, Syriac, Thaana…) give `true`; strong L characters (Latin, Greek, +/// Cyrillic, CJK…) give `false`. Neutral, weak and whitespace characters are skipped. +pub fn first_strong_is_rtl(text: &str) -> bool { + for c in text.chars() { + if c == '\u{200F}' { + return true; + } + if c == '\u{200E}' { + return false; + } + if c.is_alphabetic() { + return is_rtl_script(c); + } + } + false +} + #[cfg(test)] mod tests { use super::*; @@ -56,5 +76,11 @@ mod tests { assert!("می‌خواهم".chars().all(is_word_char), "ZWNJ is inside the word"); assert!("کِتاب".chars().all(is_word_char), "so are harakat"); assert!(!is_word_char(' ') && !is_word_char('،')); + assert!(first_strong_is_rtl("سلام")); + assert!(first_strong_is_rtl("من ناوم هێڤاره")); + assert!(first_strong_is_rtl(" 123. سلام")); + assert!(!first_strong_is_rtl("Hello سلام")); + assert!(!first_strong_is_rtl("123 Hello")); + assert!(!first_strong_is_rtl("")); } } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index ab23672a6..a6569125c 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -275,7 +275,18 @@ fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<(), CmdError> first = false; let part = part.replace('\u{000B}', "\n"); if !part.is_empty() { + let starts_para = direction_unset(s, &at); at = s.doc.insert_text(&at, &part, &props)?; + // Typing Arabic, Hebrew… as the first letters of a paragraph whose direction nothing + // sets makes it a right-to-left paragraph, so the caret and text start at the right + // margin. Paragraphs that already have letters, or a direction, keep theirs. + if starts_para + && let Ok(p) = s.doc.para_mut(at.story, &at.path) + && wordcraft_doc::bidi::first_strong_is_rtl(&p.text) + { + p.props.bidi = Some(true); + p.touch(); + } } } s.sel = crate::Selection::caret(at); @@ -283,6 +294,17 @@ fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<(), CmdError> Ok(()) } +/// Is the paragraph at `at` still without letters (empty, or only spaces, digits and +/// punctuation), with no direction set on it, its style chain or the document defaults? +fn direction_unset(s: &Session, at: &Pos) -> bool { + let Some(p) = s.doc.para_at(at) else { return false }; + if p.props.bidi.is_some() || p.text.chars().any(char::is_alphabetic) || s.doc.styles.default_para.bidi.is_some() { + return false; + } + let style = p.props.style.as_deref().unwrap_or("Normal"); + !s.doc.styles.chain(style).iter().any(|st| st.para.bidi.is_some()) +} + /// Take a paragraph out of its list like Word does when Enter or Backspace ends a list: a /// "List Paragraph" goes back to Normal; any other style keeps itself with numbering switched off. pub fn leave_list(para: &mut wordcraft_doc::Paragraph) { diff --git a/crates/engine/src/tests_bidi.rs b/crates/engine/src/tests_bidi.rs index 8f6224f5e..745eb5c4a 100644 --- a/crates/engine/src/tests_bidi.rs +++ b/crates/engine/src/tests_bidi.rs @@ -225,3 +225,23 @@ fn explicit_alignment_sets_without_toggling() { } } } + +#[test] +fn typing_kurdish_in_new_document_advances_caret_smoothly() { + let mut s = Session::new(wordcraft_doc::Document::new()); + let mut first_x = None; + let mut last_x = 0.0; + for ch in ["م", "ن", " ", "ن", "ا", "و", "م", " ", "ه", "ێ", "ڤ", "ا", "ر", "ه"] { + run(&mut s, "text.insert", json!({"text": ch})); + let l = s.layout(); + let c = l.caret(&s.sel.focus).unwrap(); + if first_x.is_none() { + first_x = Some(c.x); + } + last_x = c.x; + // The caret must not jump back to the left margin (72.0), but stay near the right margin (540.0). + assert!(c.x > 400.0, "caret should not be at left margin: {}", c.x); + } + assert!(last_x < first_x.unwrap(), "caret should advance leftwards overall"); + assert_eq!(props(&s, 0).bidi, Some(true), "paragraph auto-detected as right-to-left"); +} diff --git a/crates/engine/src/tests_typing.rs b/crates/engine/src/tests_typing.rs index 8a39a608b..48a5d92a2 100644 --- a/crates/engine/src/tests_typing.rs +++ b/crates/engine/src/tests_typing.rs @@ -705,3 +705,39 @@ fn plain_text_paste_keeps_an_empty_paragraph_in_a_list() { typ(&mut s, "A\n\nB"); assert_eq!(paras(&s), vec![para("A", Some(0)), para("B", None)]); } + +/// #713: Arabic or Hebrew typed into an empty paragraph makes it right-to-left; a paragraph that +/// already has letters, or whose style sets a direction, keeps its own. +#[test] +fn typing_rtl_letters_into_an_empty_paragraph_makes_it_rtl() { + let bidi = |s: &Session, i: usize| s.doc.body.iter().filter_map(|b| b.as_para()).nth(i).and_then(|p| p.props.bidi); + // Empty paragraph: the first Arabic letter flips it, the rest keep it. + let s = typed("سلام"); + assert_eq!(bidi(&s, 0), Some(true)); + // Digits first, then Arabic: still no letters before, so it flips. + let s = typed("1. سلام"); + assert_eq!(bidi(&s, 0), Some(true)); + // Latin first: Arabic typed later doesn't flip the paragraph. + let s = typed("Hello سلام"); + assert_eq!(bidi(&s, 0), None); + // A new paragraph after an RTL one starts its own detection. + let s = typed("سلام\nHello"); + assert_eq!(bidi(&s, 1), Some(true), "Enter carries the paragraph's direction on, as in Word"); + // A style that sets left-to-right wins. + let mut doc = Session::new(wordcraft_doc::Document::new()); + if let Some(st) = doc.doc.styles.get_mut("Normal") { + st.para.bidi = Some(false); + } + typ(&mut doc, "سلام"); + assert_eq!(bidi(&doc, 0), None); + // An opened paragraph without w:bidi lays out left-to-right whatever its text: the caret + // after the Arabic word sits near the left margin, not the right one. + let mut doc = typed("سلام"); + let path = doc.sel.focus.path.clone(); + if let Ok(p) = doc.doc.para_mut(StoryRef::Body, &path) { + p.props.bidi = None; + p.touch(); + } + let x = doc.layout().caret(&doc.sel.focus).map(|c| c.x).unwrap_or(f32::MAX); + assert!(x < 300.0, "left-to-right paragraph: caret at {x}"); +} diff --git a/docs/typing-parity.md b/docs/typing-parity.md index 5d67de23d..8e644851c 100644 --- a/docs/typing-parity.md +++ b/docs/typing-parity.md @@ -1,6 +1,6 @@ # Typing parity with Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** trivial (grammar proofing: ellipses are not sentence boundaries; previously minor: pasted paragraph breaks tracked under Track Changes, kept as text in lists, #417, #422) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (right-to-left letters typed into an empty paragraph make it right-to-left, #713; previously minor: editing options: typing replaces selection, overtype, #485; previously minor: Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 What Word does when you type, observed black-box (same keystrokes typed into Word and WordCraft, results read back paragraph by paragraph) and pinned by tests in @@ -97,6 +97,15 @@ Word. | Typing over a selection | replaces it (Word's default); with "Typing replaces selected text" off, the text goes in front of the selection, which stays | `typing_over_a_selection_follows_the_replace_selection_option` | | Overtype on, typing | each character replaces the one after the caret, never the paragraph mark or an object; Insert switches overtype only with the Insert-key option on (off by default, as in Word) | `overtype_replaces_the_characters_after_the_caret` | +## Text direction + +- Typing Arabic, Hebrew or another right-to-left script as the first letters of a paragraph + whose direction nothing sets (not the paragraph, its style or the document defaults) makes it + a right-to-left paragraph, so the caret starts at the right margin. A paragraph that already + has letters keeps its direction, and so do opened documents: a paragraph without `w:bidi` stays + left-to-right whatever its text (`typing_rtl_letters_into_an_empty_paragraph_makes_it_rtl`). + Word takes the direction from the keyboard language instead; not yet compared side by side. + ## Layout - No line break right after a slash (`and/or`, URLs) unless the word can't fit a line @@ -135,6 +144,7 @@ Word. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Text direction: right-to-left letters typed into an empty paragraph make it right-to-left; existing paragraphs keep theirs (#713) | | 2026-10-11 | trivial | Grammar proofing (not AutoCorrect) no longer suggests capitalising a sentence after `...` or `…` (`proof::tests::ellipses_do_not_start_sentences`) | | 2026-10-11 | minor | AutoFormat tables from `+---+`, arrow bullets, smileys inside words, undo after a border line (#546) | | 2026-10-11 | trivial | Outline view keys: expand/collapse, Show Level, moving and promoting with collapsed content (#493) | From e75ff362518ff9858d27857213581b8c111eb3b4 Mon Sep 17 00:00:00 2001 From: naxvog Date: Mon, 12 Oct 2026 12:56:54 +0200 Subject: [PATCH 35/88] Layout: an inline picture wider than the text keeps its size, like Word (#745) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Layout: an inline picture wider than the text keeps its size, like Word WordCraft shrank an inline picture, chart or shape that is wider than its line to the line's width, height with it. Word draws it at its own size: it runs past the margin and the page edge clips it, and the line is as tall as the picture. In docxide-pdf's fixtures that moved the rest of the page up (case179: a 1014 x 100 pt picture became 468 x 46 pt) and shrank a header's 1014 pt logo strip to fit (alfies_arc). All page outputs already clip at the page: the PDF page box, and the page bitmaps the rasteriser and the editor draw. Text boxes don't clip what runs past their sides yet, so a picture wider than a text box is still fitted to the box rather than spilling over the page (ParaEnv::fit_objects, part of the paragraph cache key). On the 390 fixtures: case179 SSIM 70.9 -> 99.9%, alfies_arc 18.4 -> 74.8%, case178 98.0 -> 99.9%, nabl_lab 33.2 -> 36.8%; no fixture got worse. Co-Authored-By: Claude Opus 5.5 * Review fixes for #745: fit imported and pasted pictures to the text width Layout no longer shrinks oversized inline pictures, so pictures that come at their pixel size are fitted when they enter the document, as Insert › Pictures already does: - opening HTML (also from a path), Markdown, ODT, RTF and other formats-crate imports (io::fit_pictures, against the narrowest section text width); - pasting HTML/RTF (paste::adopt, against our text width). Only inline pictures wider than the text are scaled, proportionally. DOCX keeps its sizes. Test: tests::imported_pictures_fit_the_text_width. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/doc/src/lib.rs | 5 +++ crates/engine/src/cmd/paste.rs | 7 +++- crates/engine/src/io.rs | 38 +++++++++++++++++++++ crates/engine/src/io_ext.rs | 8 ++++- crates/engine/src/tests.rs | 42 +++++++++++++++++++++++ crates/layout/src/display.rs | 2 +- crates/layout/src/lib.rs | 5 +++ crates/layout/src/para.rs | 7 +++- crates/layout/src/table.rs | 1 + crates/layout/src/tests.rs | 62 ++++++++++++++++++++++++++++++---- 10 files changed, 167 insertions(+), 10 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index a0f244006..f4b4694ab 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -136,6 +136,11 @@ impl BoxBudget { self.open.pop(); } + /// Whether a text box is being expanded now. + pub fn inside(&self) -> bool { + !self.open.is_empty() + } + /// Boxes expanded so far. pub fn total(&self) -> usize { self.total diff --git a/crates/engine/src/cmd/paste.rs b/crates/engine/src/cmd/paste.rs index dcb3d3699..6044537fb 100644 --- a/crates/engine/src/cmd/paste.rs +++ b/crates/engine/src/cmd/paste.rs @@ -171,7 +171,7 @@ pub fn insert(s: &mut Session, mut frag: Fragment) -> Result<(), CmdError> { } /// Every paragraph in a block, tables included (bounded depth). -fn each_para(b: &mut Block, depth: usize, f: &mut dyn FnMut(&mut Paragraph)) { +pub(crate) fn each_para(b: &mut Block, depth: usize, f: &mut dyn FnMut(&mut Paragraph)) { if depth > MAX_DEPTH { return; } @@ -218,6 +218,11 @@ fn adopt(s: &mut Session, src: Document) -> Fragment { } } let mut blocks: Vec = src.body.iter().map(|b| (**b).clone()).collect(); + // Pasted pictures fit our text width, like Insert › Pictures (layout keeps their size). + let max_w = crate::io::text_width(&s.doc); + for b in &mut blocks { + crate::io::fit_block_pictures(b, max_w); + } for b in &mut blocks { each_para(b, 0, &mut |p| { if let Some(n) = p.props.numbering.as_mut() diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index f3fc25819..defc29dd8 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -126,6 +126,43 @@ pub fn open_bytes_with(name: &str, bytes: &[u8], password: Option<&str>) -> Resu Ok((doc, false)) } +/// The narrowest text width of the document's sections, points: what a picture must fit. +pub(crate) fn text_width(doc: &Document) -> f32 { + doc.sections().iter().map(|(_, sp)| sp.text_width()).fold(f32::INFINITY, f32::min).clamp(36.0, 4000.0) +} + +/// Shrink inline pictures wider than `max_w` to it, keeping their proportions. Layout keeps a +/// picture's size (as Word does), so formats whose pictures come at their pixel size (HTML, +/// Markdown, ODT, RTF) are fitted when they are read, as Insert › Pictures does. +pub(crate) fn fit_block_pictures(b: &mut wordcraft_doc::Block, max_w: f32) { + crate::cmd::paste::each_para(b, 0, &mut |p| { + let mut changed = false; + for o in &mut p.objects { + if let wordcraft_doc::InlineObject::Image { w, h, float, .. } = o + && float.wrap == wordcraft_doc::para::Wrap::Inline + && w.is_finite() + && *w > max_w + { + let k = max_w / *w; + *w = max_w; + *h = (*h * k).max(1.0); + changed = true; + } + } + if changed { + p.touch(); + } + }); +} + +/// [`fit_block_pictures`] for a whole document read from a file, against its own text width. +pub(crate) fn fit_pictures(doc: &mut Document) { + let max_w = text_width(doc); + for b in &mut doc.body { + fit_block_pictures(std::sync::Arc::make_mut(b), max_w); + } +} + /// A file whose extension names no format WordCraft knows (`.m3u`, `.log`, `.ini`, none…), /// opened by what it holds, as Word does: a Word document or RTF file by its signature, any /// other text through the plain-text import with encoding detection (#569). Anything else is @@ -201,6 +238,7 @@ pub fn open_path_with(path: &std::path::Path, password: Option<&str>) -> Result< // Pictures referenced by a relative path load from beside the HTML file. let images = LocalImages::new(path.parent().unwrap_or(std::path::Path::new(""))); let mut doc = wordcraft_formats::html::import_with(&wordcraft_formats::html::decode(&bytes), &|src| images.load(src)); + fit_pictures(&mut doc); doc.ensure_nonempty(); return Ok((doc, false)); } diff --git a/crates/engine/src/io_ext.rs b/crates/engine/src/io_ext.rs index a0693d447..04bd7e484 100644 --- a/crates/engine/src/io_ext.rs +++ b/crates/engine/src/io_ext.rs @@ -9,7 +9,13 @@ pub fn open(ext: &str, bytes: &[u8]) -> Option> { "doc" | "dot" => Some(wordcraft_docbin::read(bytes).map_err(|e| e.to_string())), // Word XML Document (Flat OPC) or Word 2003 XML, told apart by the root element. "xml" => Some(wordcraft_docx::read_xml(bytes).map_err(|e| e.to_string())), - other => wordcraft_formats::import(other, bytes), + // HTML, Markdown, ODT, RTF…: pictures fit the text width (see `io::fit_pictures`). + other => wordcraft_formats::import(other, bytes).map(|r| { + r.map(|mut doc| { + crate::io::fit_pictures(&mut doc); + doc + }) + }), } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 0cdf402d0..f9b496a9c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4211,6 +4211,48 @@ fn outline_collapse_hides_descendants_and_expand_restores() { assert_eq!(s.selected_text(), "One\na\nTwo\nb"); } +/// #745: layout keeps a picture's size, so pictures read from HTML, Markdown, ODT or RTF at +/// their pixel size are fitted to the text width when the file is opened (and when pasted), +/// keeping their proportions; small pictures keep their size. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn imported_pictures_fit_the_text_width() { + let png = |w: u32, h: u32| { + let mut b = Vec::new(); + image::RgbaImage::from_pixel(w, h, image::Rgba([200, 30, 30, 255])) + .write_to(&mut std::io::Cursor::new(&mut b), image::ImageFormat::Png) + .unwrap(); + cmd::insert::base64_encode(&b) + }; + let sizes = |doc: &wordcraft_doc::Document| -> Vec<(f32, f32)> { + doc.body + .iter() + .filter_map(|b| b.as_para()) + .flat_map(|p| p.objects.iter()) + .filter_map(|o| match o { + wordcraft_doc::InlineObject::Image { w, h, .. } => Some((*w, *h)), + _ => None, + }) + .collect() + }; + let md = format!("![wide](data:image/png;base64,{})\n\n![small](data:image/png;base64,{})\n", png(1200, 300), png(40, 20)); + let doc = crate::io::open_bytes("p.md", md.as_bytes()).unwrap(); + let max_w = crate::io::text_width(&doc); + let got = sizes(&doc); + assert_eq!(got.len(), 2, "{got:?}"); + assert!((got[0].0 - max_w).abs() < 0.01, "wide picture fits: {got:?} (text width {max_w})"); + assert!((got[0].1 - max_w / 4.0).abs() < 0.01, "and keeps its proportions: {got:?}"); + assert!((got[1].0 - 30.0).abs() < 0.01 && (got[1].1 - 15.0).abs() < 0.01, "small picture keeps its size: {got:?}"); + + // Pasting HTML fits our text width too. + let mut s = Session::new(wordcraft_doc::Document::new()); + let html = format!("

", png(1200, 300)); + s.run("edit.pasteSpecial", &json!({"as": "html", "html": html})).unwrap(); + let max_w = crate::io::text_width(&s.doc); + let got = sizes(&s.doc); + assert!(got.iter().any(|(w, _)| (w - max_w).abs() < 0.01), "pasted picture fits: {got:?}"); +} + #[test] fn format_font_sets_the_asian_and_latin_fonts_separately() { // #505: Word's Font dialog sets an Asian text font and a Latin text font of their own. diff --git a/crates/layout/src/display.rs b/crates/layout/src/display.rs index 48d1a5098..d9d34930d 100644 --- a/crates/layout/src/display.rs +++ b/crates/layout/src/display.rs @@ -1487,7 +1487,7 @@ pub(crate) fn inline_rect(obj: Option<&InlineObject>, cx: f32, base: f32, adv: f let [l, t, r, b] = frame.map_or([0.0; 4], |(_, _, float)| float.effect_extent()); let room = Rect::new(cx + l, base - obj_h + t, (adv - l - r).max(0.0), (obj_h - t - b).max(0.0)); let Some((w, h, float)) = frame.filter(|(_, _, f)| f.rot != 0.0) else { return room }; - // The room is the rotated bounds of the (possibly scaled-down) frame: undo the rotation. + // The room is the rotated bounds of the frame: undo the rotation. let (w, h) = (wordcraft_geom::finite(w).clamp(1.0, 4000.0), wordcraft_geom::finite(h).clamp(1.0, 4000.0)); let (bw, _) = float.spin().extent(w, h); let k = if bw > 0.0 { room.w / bw } else { 1.0 }; diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 3bff87dc9..017833787 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -453,6 +453,7 @@ struct Key { eq: u32, read_spacing: Option, grid: (u32, u32, u32, bool), + fit: bool, hide_deleted: bool, } @@ -647,6 +648,7 @@ impl Ctx<'_> { eq_number: 0, read_line_spacing: None, grid: para::Grid::default(), + fit_objects: false, }; let pl = Arc::new(para::layout_para(&p, &env)); self.numbers.insert(n, pl.clone()); @@ -696,6 +698,7 @@ impl Ctx<'_> { eq: if eq_here > 0 { eq_number } else { 0 }, read_spacing: if self.opts.view == ViewMode::Read { self.opts.read_line_spacing.map(f32::to_bits) } else { None }, grid: self.grid.key(), + fit: self.boxes.inside(), hide_deleted: self.hide_deleted, }; self.cache.used.insert(key.clone()); @@ -719,6 +722,7 @@ impl Ctx<'_> { eq_number, read_line_spacing: if self.opts.view == ViewMode::Read { self.opts.read_line_spacing } else { None }, grid: self.grid, + fit_objects: self.boxes.inside(), }; let pl = Arc::new(para::layout_para(p, &env)); self.cache.paras.insert(key, pl.clone()); @@ -2827,6 +2831,7 @@ fn next_first_line(ctx: &mut Ctx, block: usize, width: f32) -> f32 { eq_number: ctx.eq_count, read_line_spacing: if ctx.opts.view == ViewMode::Read { ctx.opts.read_line_spacing } else { None }, grid: ctx.grid, + fit_objects: false, }; let _ = rp; let pl = para::layout_para(p, &env); diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 2b3c1db07..11aba54e8 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -345,6 +345,9 @@ pub struct ParaEnv<'a> { pub read_line_spacing: Option, /// The section's document grid (`Grid::default()`: none). pub grid: Grid, + /// Shrink inline drawings wider than the line to fit it: in text boxes, which don't clip what + /// runs past their sides. Elsewhere such a drawing keeps its size and the page edge clips it. + pub fit_objects: bool, } /// The section's document grid as layout applies it (`w:docGrid`, ECMA-376 §17.6.5). @@ -1208,9 +1211,11 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { if let Some((w, h, float)) = o.frame() && float.wrap == wordcraft_doc::para::Wrap::Inline { + // A picture wider than the line keeps its size, as in Word: it runs past + // the margin and the page edge clips it (see `ParaEnv::fit_objects`). let maxw = (env.width - rp.indent_left.max(0.0) - rp.indent_right.max(0.0)).max(18.0); let (w, h) = (w.clamp(1.0, 4000.0), h.clamp(1.0, 4000.0)); - let s = if w > maxw { maxw / w } else { 1.0 }; + let s = if env.fit_objects && w > maxw { maxw / w } else { 1.0 }; // The line makes room for effects (shadows) around the picture too, and // for a rotated one's bounds, as Word does. let [el, et, er, eb] = float.effect_extent(); diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index 56373a63b..add09c6ec 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -174,6 +174,7 @@ pub fn measure_table_columns(doc: &wordcraft_doc::Document, t: &Table) -> Vec<(f eq_number: 0, read_line_spacing: None, grid: crate::para::Grid::default(), + fit_objects: false, }; let (mut min, mut max) = (EMPTY_TEXT, EMPTY_TEXT); if cell.props.vmerge != VMerge::Continue && !cell.props.text_direction.is_turned() { diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 8e9b3f3a3..437758efc 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -2847,10 +2847,10 @@ fn graphic_paths_drop_segments_that_cannot_be_drawn() { } #[test] -fn oversized_graphic_draws_shrunk_with_its_items() { +fn graphic_items_scale_with_their_frame() { use wordcraft_doc::graphic::{Graphic, GraphicItem, GraphicKind}; use wordcraft_doc::props::Rgb; - // A 1000 x 500 pt chart is wider than the text: layout shrinks it, and its items with it. + // A chart drawn for 1000 x 500 pt in a 500 x 250 pt frame: its items and strokes scale by half. let items = vec![GraphicItem::Shape { rect: [0.0, 0.0, 1000.0, 500.0], kind: wordcraft_doc::para::ShapeKind::Rectangle, @@ -2859,7 +2859,7 @@ fn oversized_graphic_draws_shrunk_with_its_items() { stroke_width: 4.0, }]; let graphic = Graphic { kind: GraphicKind::Chart, items, w: 1000.0, h: 500.0, source: None, chart: None, smart_art: None }; - let obj = InlineObject::Graphic { w: 1000.0, h: 500.0, alt: String::new(), float: Default::default(), graphic: Arc::new(graphic) }; + let obj = InlineObject::Graphic { w: 500.0, h: 250.0, alt: String::new(), float: Default::default(), graphic: Arc::new(graphic) }; let mut d = Document::from_text("Chart"); d.insert_object(&Pos::body(0, 0), obj, &Default::default()).unwrap(); let draws = flat(display::page_display(&d, &lay(&d).pages[0], &Default::default())); @@ -2867,9 +2867,59 @@ fn oversized_graphic_draws_shrunk_with_its_items() { .iter() .find_map(|x| if let display::Draw::Shape { rect, stroke_width, .. } = x { Some((*rect, *stroke_width)) } else { None }) .expect("a shape"); - assert!(r.w < 1000.0, "{r:?}"); - assert!((r.h / r.w - 0.5).abs() < 1e-3, "{r:?}"); - assert!((stroke / r.w - 4.0 / 1000.0).abs() < 1e-5, "stroke {stroke} at width {}", r.w); + assert!((r.w - 500.0).abs() < 1e-3 && (r.h - 250.0).abs() < 1e-3, "{r:?}"); + assert!((stroke - 2.0).abs() < 1e-5, "stroke {stroke}"); +} + +#[test] +fn inline_picture_wider_than_a_text_box_is_fitted_to_it() { + // Text boxes don't clip what runs past their sides, so a 400 x 100 pt picture in a 144 pt box + // is fitted to the box's 129.6 pt inside its margins instead of spilling over the page. + let mut d = Document::from_text("Before"); + let mut p = wordcraft_doc::Paragraph::with_text("", Default::default()); + p.insert_object(0, picture(400.0, 100.0, Default::default()), &Default::default()).unwrap(); + let id = d.add_part(wordcraft_doc::PartKind::TextBox, vec![wordcraft_doc::para_block(p)]); + let shape = InlineObject::Shape { + kind: wordcraft_doc::para::ShapeKind::TextBox, + w: 144.0, + h: 72.0, + fill: None, + stroke: None, + stroke_width: 0.75, + float: Default::default(), + story: Some(id), + freeform: None, + effects: Default::default(), + extra: Default::default(), + }; + d.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap(); + let l = lay(&d); + let draws = flat(display::page_display(&d, &l.pages[0], &Default::default())); + let r = draws + .iter() + .find_map(|x| match x { + display::Draw::Shape { rect, kind: wordcraft_doc::para::ShapeKind::Rectangle, .. } => Some(*rect), + _ => None, + }) + .expect("the picture"); + assert!((r.w - 129.6).abs() < 0.1 && (r.h - 32.4).abs() < 0.1, "{r:?}"); +} + +#[test] +fn inline_picture_wider_than_the_text_keeps_its_size() { + // A 1000 x 100 pt picture in 468 pt of text: Word draws it at its size, past the margin, and the + // page edge clips it; the line is as tall as the picture (docxide-pdf's case179). + let mut d = Document::from_text(""); + if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(0).map(std::sync::Arc::make_mut) { + p.insert_object(0, picture(1000.0, 100.0, Default::default()), &Default::default()).unwrap(); + } + 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.baseline - line.top - 100.0).abs() < 0.01, "the picture keeps its height: {line:?}"); + let draws = flat(display::page_display(&d, &l.pages[0], &Default::default())); + let r = draws.iter().find_map(|x| if let display::Draw::Shape { rect, .. } = x { Some(*rect) } else { None }).expect("the picture"); + assert!((r.w - 1000.0).abs() < 0.01 && (r.h - 100.0).abs() < 0.01, "{r:?}"); } /// The paragraph at `path` on page 0: its first line's x, its y and its line count. From 325e5a6157930d076816f41425d89fa37022cdf3 Mon Sep 17 00:00:00 2001 From: ripdog Date: Mon, 12 Oct 2026 23:59:54 +1300 Subject: [PATCH 36/88] M8: Truncate long Navigation headings in resizable pane (#759) Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 4 ++- crates/ui-egui/src/panes.rs | 72 ++++++++++++++++++++++++++++++------- docs/ui-parity.md | 5 +-- 3 files changed, 66 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index fe5abf14d..f6ec76d19 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 (Hungarian interface landed; previously minor: Polish interface and Polish proofing; previously trivial: table borders and the resize handle drag on the page, #217; 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-12 · **Change:** trivial (Navigation heading truncation; 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 @@ -135,6 +135,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 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-12 | Navigation headings truncate within the resizable pane, with an ellipsis for long titles and full text on hover (#759) | | 2026-10-11 | Reading Mode: existing View ribbon button now opens a read-only, viewport-column layout with animated horizontal paging, transient display controls, heading navigation (Navigation pane) and resize anchoring; document layout and export unchanged | | 2026-10-11 | DOCX open/save keeps inherited complex-script size, bold and italic independent of Latin formatting; explicit values are retained, and font commands still format both scripts. Covered by synthetic round trips and a local GUI save/reopen check. | | 2026-10-11 | Czech (`cs`) interface catalog: 2,021 entries (the full zh-hans key set), Czech locale detection and persisted language choice | @@ -217,6 +218,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-12 | trivial | Progress log: Navigation heading truncation (#759) | | 2026-10-11 | trivial | Progress log: Reading Mode implementation | | 2026-10-11 | trivial | DOCX complex-script formatting inheritance fix and round-trip coverage | | 2026-10-11 | minor | Czech (`cs`) interface catalog added | diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 2d265befd..0bbef8876 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -185,23 +185,33 @@ fn nav(app: &mut WordApp, ui: &mut Ui) { ui.label(egui::RichText::new(tl!("Create an interactive outline of your document.\n\nIt's a great way to keep track of where you are or quickly move your content around.\n\nTo get started, go to the Home tab and apply Heading styles to the headings in your document.")).weak()); } for (i, (path, lv, txt)) in items.into_iter().enumerate() { - ui.horizontal(|ui| { - ui.add_space(lv as f32 * 14.0); - let r = ui.selectable_label(current == Some(i), egui::RichText::new(txt).font(if lv == 0 { semibold(12.5) } else { regular(12.5) })); - if r.clicked() { - let _ = app.run("caret.set", json!({"pos": Pos { story: StoryRef::Body, path, off: 0 }})); - // Align the chosen heading's page immediately against this pane, rather - // than the indeterminate minimum movement from scroll_to_rect. - app.canvas.nav_to_caret = true; - app.canvas.scroll_to_caret = false; - app.canvas.want_focus = true; - } - }); + if nav_heading(ui, lv, &txt, current == Some(i)).clicked() { + let _ = app.run("caret.set", json!({"pos": Pos { story: StoryRef::Body, path, off: 0 }})); + // Align the chosen heading's page immediately against this pane, rather + // than the indeterminate minimum movement from scroll_to_rect. + app.canvas.nav_to_caret = true; + app.canvas.scroll_to_caret = false; + app.canvas.want_focus = true; + } } } }); } +/// Constrain each heading to the available pane width so long document text cannot +/// enlarge the panel. The full heading stays accessible on hover. +fn nav_heading(ui: &mut Ui, level: u8, text: &str, selected: bool) -> egui::Response { + ui.horizontal(|ui| { + ui.add_space(level as f32 * 14.0); + ui.add_sized( + vec2(ui.available_width(), ui.spacing().interact_size.y), + egui::Button::selectable(selected, egui::RichText::new(text).font(if level == 0 { semibold(12.5) } else { regular(12.5) })).truncate(), + ) + .on_hover_text(text) + }) + .inner +} + /// Items collected by Copy and Cut, newest first; clicking one pastes it at the caret. fn clipboard(app: &mut WordApp, ui: &mut Ui) { if header(ui, "Clipboard") { @@ -695,3 +705,41 @@ fn camel_words(s: &str) -> String { } out } + +#[cfg(test)] +mod navigation_tests { + use super::*; + + #[test] + fn long_headings_truncate_without_stretching_navigation() { + let text = "An extremely long heading with words that must stay inside the navigation pane ".repeat(20); + for (width, level) in [(260.0, 0), (180.0, 5)] { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(900.0, 600.0)); + let mut panel_rect = egui::Rect::NOTHING; + let mut heading_rect = egui::Rect::NOTHING; + let output = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| { + let result = egui::Panel::left("navigation_test") + .default_size(width) + .resizable(true) + .show(ui, |ui| egui::ScrollArea::vertical().show(ui, |ui| nav_heading(ui, level, &text, true)).inner); + panel_rect = result.response.rect; + heading_rect = result.inner.rect; + }); + + assert!(panel_rect.width() < width + 20.0, "long heading expanded the {width}px pane to {panel_rect:?}"); + assert!(heading_rect.max.x <= panel_rect.max.x, "heading escaped pane: {heading_rect:?} / {panel_rect:?}"); + assert!( + output.shapes.iter().any(|s| match &s.shape { + egui::Shape::Text(t) => { + t.galley.elided && t.galley.rows.last().is_some_and(|r| r.row.glyphs.last().is_some_and(|g| g.chr == '…')) + } + _ => false, + }), + "long heading must render a trailing ellipsis at {width}px" + ); + output.drop_without_applying_deltas(); + } + } +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 2698cc517..5218d8645 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:** minor (Reading Mode implemented; 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-12 · **Change:** trivial (Navigation headings truncate in the resizable pane, #759; 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 @@ -19,7 +19,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 | **137** commands bound in `CommandSpec::key`, 156 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); Outline view: Alt+Shift+Plus/Minus expand/collapse, Alt+Shift+1–9 and Alt+Shift+A Show Level (#493). Missing: en dash on the numeric keypad | ~250 default shortcuts | ~70% | 2–4 | | Modal dialogs | **54** (`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, Templates and Add-ins, Organizer, Compare and Combine, AutoCorrect Options, 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); Line Numbers, Hyphenation with Manual Hyphenation, Language, Envelopes, Labels (#407)) plus panes and menus for the rest | ~100 (Options panes…) | ~45% | 11–19 | -| Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation, Styles, Comments, Thesaurus, Restrict Editing, Accessibility (grouped findings, jump-to, fixes) and Alt Text (#386), 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 (long headings truncate with an ellipsis instead of expanding the pane, #759), Styles, Comments, Thesaurus, Restrict Editing, 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); right-click selects a picture, shape or text box and offers Wrap Text (styles, Move with Text / Fix Position on Page, #513); tables partial | rich per context | 60% | 3–5 | | Backstage (Home, New, Open, Info, Save As, Print, Share, Export, Options) | Home and New separate (#254), Info properties kept (#268), Options in Word's panes with working settings (#485), Print page with preview, page range and copies (#641) | | 68% | 4–6 | | Rulers: indents, tabs, margins, table columns | indents draggable (one undo, #62); tabs; table column drag pending (#49) | ✅ | 70% | 2–3 | @@ -44,6 +44,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | Date | Change | Summary | |---|---|---| +| 2026-10-12 | trivial | Navigation headings truncate with an ellipsis within the pane width, preserving resizing and full-text hover (#759) | | 2026-10-11 | minor | Reading Mode: viewport-height horizontal columns, animated paging, transient display controls, heading navigation and responsive anchored resizing | | 2026-10-11 | trivial | Alt activation handles native key/modifier events in either order, including key-up with the old Alt flag; taps toggle once, chords and focus loss do not activate KeyTips | | 2026-10-11 | trivial | Quick Access Toolbar KeyTips (#620): 1–9, zero-prefixed numbers for longer toolbars, disabled commands stay disabled, Escape cancels, badge keystrokes never type into the document; persisted File › Options switch | From 691e887004f9b83a117c7b989350fa7e714a1588 Mon Sep 17 00:00:00 2001 From: Ufuk Efe Date: Mon, 12 Oct 2026 14:04:50 +0300 Subject: [PATCH 37/88] =?UTF-8?q?i18n:=20complete=20Turkish=20interface=20?= =?UTF-8?q?catalog=20with=202,170=20entries=20and=20full=20=E2=80=A6=20(#7?= =?UTF-8?q?74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * i18n: complete Turkish interface catalog with 2,253 entries and full parity * Review fixes for #774: Turkish entry count after merging main (2,325) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 6 +- contributors/people.toml | 6 +- crates/ui-egui/src/dialogs_layout.rs | 5 +- crates/ui-egui/src/i18n/audit.rs | 33 +- crates/ui-egui/src/i18n/tests.rs | 13 +- crates/ui-egui/src/i18n/tr.tsv | 1009 +++++++++++++++++--------- docs/localization-parity.md | 9 +- docs/localization-tr.md | 20 +- 8 files changed, 746 insertions(+), 355 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f6ec76d19..674457930 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-12 · **Change:** trivial (Navigation heading truncation; 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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Turkish interface catalog completed, #774; previously trivial: drag-select by whole units, #549; 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 @@ -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`), Serbian (`sr`, `sr-latn`, #250), Russian (`ru`) and Polish (`pl`), all partial (~95%). Estonian (`et`), Turkish (`tr`), Italian (`it`, #594) and Czech (`cs`) now have complete interface catalogs with +Also shipped: Traditional Chinese (`zh-hant`), Ukrainian (`uk`), Serbian (`sr`, `sr-latn`, #250), Russian (`ru`) and Polish (`pl`), all partial (~95%). Estonian (`et`), Italian (`it`, #594) and Czech (`cs`) now have complete interface catalogs, and Turkish (`tr`) a complete 2,325-entry one (every key of the Russian catalog), all with system-locale selection and a saved language choice (Italian also has 51 strings none of the others had yet); Estonian, Turkish, Italian and Czech proofing are not included. Hungarian (`hu`) has a complete 2,419-entry catalog, also without proofing. Proofing: English and Polish (spelling, grammar, hyphenation), chosen per run from its language. Word ships @@ -135,6 +135,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 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 | Turkish (`tr`) interface completed (#774): 2,325 entries, every key of the Russian catalog (equation gallery, built-in style names, watermarks, paper sizes, preview tiles); the interface audit checks Turkish too | | 2026-10-12 | Navigation headings truncate within the resizable pane, with an ellipsis for long titles and full text on hover (#759) | | 2026-10-11 | Reading Mode: existing View ribbon button now opens a read-only, viewport-column layout with animated horizontal paging, transient display controls, heading navigation (Navigation pane) and resize anchoring; document layout and export unchanged | | 2026-10-11 | DOCX open/save keeps inherited complex-script size, bold and italic independent of Latin formatting; explicit values are retained, and font commands still format both scripts. Covered by synthetic round trips and a local GUI save/reopen check. | @@ -218,6 +219,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Progress log and shipped languages: Turkish interface completed, 2,325 entries (#774) | | 2026-10-12 | trivial | Progress log: Navigation heading truncation (#759) | | 2026-10-11 | trivial | Progress log: Reading Mode implementation | | 2026-10-11 | trivial | DOCX complex-script formatting inheritance fix and round-trip coverage | diff --git a/contributors/people.toml b/contributors/people.toml index 08212c6cb..558b5a5d7 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -51,8 +51,12 @@ sync_github_name = false [people.henno] sync_github_name = true -[people.GhostMitra] +[people.yeniufuklar-com] +real_name = "Ufuk Efe" +display_name = "Ufuk Efe" sync_github_name = true + +[people.GhostMitra] [people.DevCoderMax] real_name = "Gleyson do Nascimento Carvalho" display_name = "Gleyson Carvalho" diff --git a/crates/ui-egui/src/dialogs_layout.rs b/crates/ui-egui/src/dialogs_layout.rs index da903c567..3e69ababe 100644 --- a/crates/ui-egui/src/dialogs_layout.rs +++ b/crates/ui-egui/src/dialogs_layout.rs @@ -530,7 +530,7 @@ fn envelopes_ui(app: &mut WordApp, ui: &mut Ui, f: &mut EnvelopeForm) -> bool { } /// `DL 110 × 220 mm` / `#10 4.13 × 9.5 in`: metric sizes in millimetres, the others in inches. -fn envelope_label(name: &str, w: f32, h: f32) -> String { +pub(crate) fn envelope_label(name: &str, w: f32, h: f32) -> String { let short = name.strip_prefix("Envelope ").unwrap_or(name); if short.starts_with('#') || short == "Monarch" { let i = |pt: f32| ((pt / 72.0) * 100.0).round() / 100.0; @@ -589,7 +589,8 @@ fn product_label(i: usize) -> String { "return" => tl!("Return address labels"), _ => tl!("Address labels"), }; - format!("{kind}, {} × {}, {} ({})", p.cols, p.rows, p.size_text(), p.paper) + let paper = tl!(p.paper); + format!("{kind}, {} × {}, {} ({paper})", p.cols, p.rows, p.size_text()) } fn labels_ui(app: &mut WordApp, ui: &mut Ui, f: &mut LabelsForm) -> bool { diff --git a/crates/ui-egui/src/i18n/audit.rs b/crates/ui-egui/src/i18n/audit.rs index 51d03a12f..872b18ce3 100644 --- a/crates/ui-egui/src/i18n/audit.rs +++ b/crates/ui-egui/src/i18n/audit.rs @@ -314,9 +314,9 @@ fn untranslated_interface_text() { complete_catalogs_have_every_string_the_interface_looked_up(pseudo::looked_up()); } -/// Russian and Hungarian cover everything the audit saw translated, plus the equation gallery's -/// labels (shown in menus the audit doesn't open one by one). Numbered style names go through a -/// template. +/// Russian, Hungarian and Turkish cover everything the audit saw translated, plus the equation +/// gallery's labels (shown in menus the audit doesn't open one by one). Numbered style names go +/// through a template. fn complete_catalogs_have_every_string_the_interface_looked_up(mut keys: std::collections::BTreeSet) { use wordcraft_engine::math_gallery::{BUILT_INS, STRUCTURES, SYMBOL_SETS}; keys.extend(BUILT_INS.iter().map(|(_, label, _)| label.to_string())); @@ -329,10 +329,31 @@ fn complete_catalogs_have_every_string_the_interface_looked_up(mut keys: std::co } } let numbered = |s: &str| ["Heading ", "TOC "].iter().any(|p| s.strip_prefix(p).is_some_and(|n| n.chars().all(|c| c.is_ascii_digit()))); - // Names that read the same in these languages. - let same = |s: &str| matches!(s, "LaTeX" | "LaTeX (*.tex)" | "Unicode" | "I. II. III." | "i. ii. iii."); + // Names that read the same in these languages (citation style names included). + let same = |s: &str| { + matches!( + s, + "LaTeX" + | "LaTeX (*.tex)" + | "Unicode" + | "I. II. III." + | "i. ii. iii." + | "APA" + | "Chicago" + | "GB/T 7714" + | "GOST (name sort)" + | "GOST (title sort)" + | "Harvard (Anglia)" + | "IEEE" + | "ISO 690 (author-date)" + | "ISO 690 (numerical)" + | "MLA" + | "SIST02" + | "Turabian" + ) + }; let mut report = String::new(); - for code in ["ru", "hu"] { + for code in ["ru", "hu", "tr"] { let lang = super::Lang::from_code(code).unwrap_or(super::Lang::EN); let missing: Vec<&String> = keys.iter().filter(|s| english_words(s) && !super::has(lang, s) && !numbered(s) && !same(s)).collect(); if !missing.is_empty() { diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 7a6707146..0f11fa92a 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -699,8 +699,9 @@ fn turkish_locales_and_saved_preference_work_without_changing_the_document() { fn turkish_covers_the_entire_existing_interface_catalog_and_preserves_templates() { use std::collections::HashSet; let keys = |source| parse_entries(source).0.into_iter().map(|e| e.source).collect::>(); - // Spanish carries every key the interface looks up; Chinese lacks only a few newer labels. - assert_eq!(keys(lang("tr").0.source), keys(lang("es").0.source)); + // Full parity with the Russian catalog: covers all UI strings, styles, math gallery, and presets. + assert_eq!(keys(lang("tr").0.source), keys(lang("ru").0.source)); + assert!(keys(lang("es").0.source).is_subset(&keys(lang("tr").0.source))); assert!(keys(lang("zh-hans").0.source).is_subset(&keys(lang("tr").0.source))); let tr = lang("tr"); for (source, expected) in [ @@ -713,6 +714,10 @@ fn turkish_covers_the_entire_existing_interface_catalog_and_preserves_templates( ("Save", "Kaydet"), ("UPPERCASE", "BÜYÜK HARF"), ("lowercase", "küçük harf"), + ("Square Root", "Karekök"), + ("Euler's identity", "Euler Özdeşliği"), + ("Default Paragraph Font", "Varsayılan Paragraf Yazı Tipi"), + ("CONFIDENTIAL", "GİZLİ"), ("WordCraft", "WordCraft"), ("unknown future label", "unknown future label"), ] { @@ -914,9 +919,9 @@ fn polish_covers_every_key_of_every_other_catalog() { use std::collections::HashSet; let keys = |source| parse_entries(source).0.into_iter().map(|e| e.source).collect::>(); let polish = keys(lang("pl").0.source); - // Russian, Italian and Hungarian also translate the built-in style names and the equation gallery, + // Russian, Italian, Hungarian and Turkish also translate the built-in style names and the equation gallery, // which the others don't yet; Polish matches every other catalog. - for other in Lang::all().filter(|l| *l != Lang::EN && !matches!(l.code(), "pl" | "ru" | "it" | "hu")) { + for other in Lang::all().filter(|l| *l != Lang::EN && !matches!(l.code(), "pl" | "ru" | "it" | "hu" | "tr")) { let mut missing: Vec = keys(other.0.source).difference(&polish).cloned().collect(); missing.sort(); assert!(missing.is_empty(), "pl lacks {}'s {missing:?}", other.code()); diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 398a89526..ad2074a18 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1,6 +1,7 @@ # Turkish (tr) interface catalog. Columns, separated by a TAB: # context English source translation -# context empty (WordCraft uses no contexts yet) +# context usually empty. `preview` words sample text inside rendered previews (the Design +# tab's style-set tiles), which must fit a small tile; it falls back to the plain entry. # source exact English lookup key (tl!(...), ribbon and command labels) # escapes \n \t \\ inside a column. Keep all `{name}` placeholders and trailing …. # Lines starting with # and blank lines are ignored. Missing entries show in English. @@ -10,14 +11,11 @@ # was consulted or copied. Product/format names (WordCraft, PDF, Markdown, MCP ...) stay intact. # Commands use imperatives; labels use natural Turkish. After a count the noun stays singular # (1 sözcük, 2 sözcük). This catalog format has no plural contexts or separate one/other forms. -# Turkish capitals (İ/i, I/ı) are written explicitly, never generated from translated labels. -# The font preview sample (AaBbYyZz) has no lowercase i: the Font dialog uppercases it for All Caps. -# Terminology: Font = Yazı Tipi, Paragraph = Paragraf, Review = İncele, References = Başvurular, -# Track Changes = Değişiklikleri İzle, Caption = Resim Yazısı, Citation = Atıf, Mail Merge = Posta -# Birleştirme, Symbol = Sembol (Icons = Simgeler), Page {page} of {pages} = Sayfa {page} / {pages}. + (no suggestions) (öneri yok) 1.5 Line Spacing 1,5 Satır Aralığı A free, open-source word processor written from scratch in Rust.\nPart of the Crafting Apps from the ArtCraft team. Rust ile sıfırdan yazılmış ücretsiz, açık kaynak bir kelime işlemci.\nArtCraft ekibinin Crafting Apps ailesinin bir parçası. + AaBbCcDd AaBbCcÇç About Hakkında About WordCraft WordCraft Hakkında Accept Kabul Et @@ -25,10 +23,10 @@ Accept This Change Bu Değişikliği Kabul Et Accessibility Erişilebilirlik Add Ekle + Add a comment Yorum ekle + Add a Table Tablo Ekle Add Space Before Paragraph Paragraf Öncesine Boşluk Ekle Add Text Metin Ekle - Add a Table Tablo Ekle - Add a comment Yorum ekle Add to Dictionary Sözlüğe Ekle Add to Style Gallery Stil Galerisine Ekle Address Block Adres Bloğu @@ -36,8 +34,6 @@ Address\nBlock Adres\nBloğu Adjust Ayarla Advanced Gelişmiş - Advanced Find Gelişmiş Bul - Advanced Find… Gelişmiş Bul… After: Sonra: Agents Ajanlar Align Hizala @@ -46,9 +42,12 @@ Alignment Hizalama All Borders Tüm Kenarlıklar All Caps Tümü Büyük Harf - All Markup Tüm İşaretler All caps Tümü büyük harf + All Markup Tüm İşaretler Alt Text Alternatif Metin + Annotation Reference Açıklama Başvurusu + Annotation Subject Açıklama Konusu + Annotation Text Açıklama Metni Apply Character Style Karakter Stili Uygula Apply Style Stil Uygula Arrange Yerleştir @@ -60,14 +59,14 @@ AutoFit Otomatik Sığdır AutoFit Contents İçeriğe Sığdır AutoFit Window Pencereye Sığdır + Automatic Otomatik + Automatic ({language}) Otomatik ({language}) + Automatic follows your system's language. Menus and commands change; your documents don't. Otomatik seçeneği sisteminizin dilini kullanır. Menüler ve komutlar değişir; belgeleriniz değişmez. + Automatic Table 1 (3 levels) Otomatik Tablo 1 (3 düzey) + Automatic Table 2 (2 levels) Otomatik Tablo 2 (2 düzey) AutoSave Otomatik Kaydet AutoSave saves every change to the file (needs a saved document) Otomatik Kaydet, her değişikliği dosyaya kaydeder (belgenin önce kaydedilmesi gerekir) AutoText Otomatik Metin - Automatic Otomatik - Automatic ({language}) Otomatik ({language}) - Automatic Table 1 (3 levels) Otomatik Tablo 1 (3 düzey) - Automatic Table 2 (2 levels) Otomatik Tablo 2 (2 düzey) - Automatic follows your system's language. Menus and commands change; your documents don't. Otomatik seçeneği sisteminizin dilini kullanır. Menüler ve komutlar değişir; belgeleriniz değişmez. Backspace Geri Sil Banded Columns Şeritli Sütunlar Banded Rows Şeritli Satırlar @@ -77,18 +76,18 @@ Binary assets removed Kaldırılan ikili varlıklar Blank Boş Blank (Three Columns) Boş (Üç Sütun) - Blank Page Boş Sayfa Blank document Boş belge + Blank Page Boş Sayfa Block\nAuthors Yazarları\nEngelle + Body text in a short paragraph to show spacing. Aralığı göstermek için kısa bir paragrafta gövde metni. Bold Kalın + Book Title Kitap Başlığı Bookmark Yer İşareti Bookmark name: Yer işareti adı: - Book Title Kitap Adı Border Painter Kenarlık Fırçası Border\nPainter Kenarlık\nFırçası Borders Kenarlıklar Bottom Border Alt Kenarlık - Bottom Margin Alt Kenar Boşluğu Bottom of Page Sayfa Altı Bottom: Alt: Box Kutu @@ -99,6 +98,7 @@ Calendar Takvim Cancel İptal Capitalize Each Word Her Sözcüğün İlk Harfini Büyüt + Caption Başlık Captions Resim Yazıları Category Kategori Cell Margins Hücre Kenar Boşlukları @@ -115,24 +115,18 @@ Character Scale Karakter Ölçeği Character Shading Karakter Gölgelendirmesi Character Spacing Karakter Aralığı - Characters Karakterler Characters (no spaces) Karakterler (boşluksuz) Characters (with spaces) Karakterler (boşluklu) Chart Grafik Check Accessibility Erişilebilirliği Denetle Check Compatibility Uyumluluğu Denetle - Check Spelling as You Type Yazarken Yazımı Denetle Check for Errors Hataları Denetle + Check Spelling as You Type Yazarken Yazımı Denetle Check\nAccessibility Erişilebilirliği\nDenetle Citation Style Atıf Stili Citations & Bibliography Atıflar ve Kaynakça Clear Temizle - Clear All Tümünü Temizle Clear All Formatting Tüm Biçimlendirmeyi Temizle - Clear Character Formatting Karakter Biçimlendirmesini Temizle - Clear Character Style Karakter Stilini Temizle - Clear Formatting Level Biçimlendirme Düzeyini Temizle - Clear Paragraph Formatting Paragraf Biçimlendirmesini Temizle Clipboard Pano Close Kapat Close Header and Footer Üstbilgi ve Altbilgiyi Kapat @@ -161,12 +155,11 @@ Copy Kopyala Copy Formatting Biçimlendirmeyi Kopyala Corrections Düzeltmeler - Count words in text boxes and notes Metin kutularındaki ve notlardaki sözcükleri say Cover Page Kapak Sayfası Create Oluştur - Create New Style Yeni Stil Oluştur Create a Style Stil Oluştur Create an interactive outline of your document.\n\nIt's a great way to keep track of where you are or quickly move your content around.\n\nTo get started, go to the Home tab and apply Heading styles to the headings in your document. Belgenizin etkileşimli ana hattını oluşturun.\n\nKonumunuzu takip etmek veya içeriğinizi hızla taşımak için kullanabilirsiniz.\n\nBaşlamak için Giriş sekmesine gidin ve belgenizin başlıklarına Başlık stillerini uygulayın. + Create New Style Yeni Stil Oluştur Crop Kırp Cross-reference Çapraz Başvuru Current Position Geçerli Konum @@ -183,6 +176,7 @@ Decrease Font Size Yazı Tipi Boyutunu Küçült Decrease Indent Girintiyi Azalt Default Varsayılan + Default Paragraph Font Varsayılan Paragraf Yazı Tipi Define New Bullet Yeni Madde İşareti Tanımla Define New Number Format Yeni Numara Biçimi Tanımla Delete Sil @@ -195,12 +189,12 @@ Delete Style Stili Sil Delete Table Tabloyu Sil Design Tasarım + development build geliştirme derlemesi Diagonal Çapraz Diamond Eşkenar Dörtgen Dictate Dikte Et Different First Page İlk Sayfa Farklı Different Odd & Even Pages Tek ve Çift Sayfalar Farklı - Direct formatting Doğrudan biçimlendirme Discord Discord Discord community Discord topluluğu Display Görüntü @@ -228,8 +222,6 @@ Drawing Tools Çizim Araçları Drawing\nCanvas Çizim\nTuvali Drop Cap Büyük Başlangıç Harfi - Drops all formatting and pastes plain text. Tüm biçimlendirmeyi kaldırır ve düz metin yapıştırır. - Edit Comment Yorumu Düzenle Edit Footer Altbilgiyi Düzenle Edit Header Üstbilgiyi Düzenle Edit Recipient List Alıcı Listesini Düzenle @@ -248,24 +240,27 @@ English (United States) İngilizce (Amerika Birleşik Devletleri) Engrave Oyma Enter page number: Sayfa numarası girin: + Envelope Address Zarf Adresi + Envelope Return Zarf İade Adresi Envelopes Zarflar + Equation Denklem Eraser Silgi Even Page Çift Sayfa Every command is available to scripts and AI agents: run `wordcraft-cli mcp` for an MCP server, or start the app with `--control ` for the JSON control channel. Her komut betiklere ve AI ajanlarına açıktır: MCP sunucusu için `wordcraft-cli mcp` çalıştırın veya JSON denetim kanalı için uygulamayı `--control ` ile başlatın. Export Dışa Aktar - Export PDF PDF Olarak Dışa Aktar - Export Page as PNG Sayfayı PNG Olarak Dışa Aktar Export a copy to share (PDF, Word document) Paylaşmak için bir kopya dışa aktarın (PDF, Word belgesi) + Export Page as PNG Sayfayı PNG Olarak Dışa Aktar + Export PDF PDF Olarak Dışa Aktar Exported {path} Dışa aktarıldı: {path} Extend Selection Seçimi Genişlet Extend Selection by Units Seçimi Birimlere Göre Genişlet Field Alan File Dosya Find Bul + Find and Replace Bul ve Değiştir Find Next Sonrakini Bul Find Previous Öncekini Bul Find Recipient Alıcı Bul - Find and Replace Bul ve Değiştir Find what: Aranan: Finish Bitir Finish & Merge Bitir ve Birleştir @@ -277,9 +272,9 @@ Focus Odak Font Yazı Tipi Font Color Yazı Tipi Rengi + Font color: Yazı tipi rengi: Font Dialog Yazı Tipi İletişim Kutusu Font Size Yazı Tipi Boyutu - Font color: Yazı tipi rengi: Font: Yazı tipi: Fonts Yazı Tipleri Footer Altbilgi @@ -291,21 +286,18 @@ Format Page Numbers Sayfa Numaralarını Biçimlendir Format Page Numbers… Sayfa Numaralarını Biçimlendir… Format Painter Biçim Fırçası - Formatted Text Biçimlendirilmiş Metin Formatting at Selection Seçimdeki Biçimlendirme Formula Formül General Genel Go To Git - Go To (⌘⌥G) Git (⌘⌥G) - Go To… Git… Go to Git + Go To (⌘⌥G) Git (⌘⌥G) Grab bag Çeşitli Greeting Line Selamlama Satırı Greeting\nLine Selamlama\nSatırı Gridlines Kılavuz Çizgileri Group Grupla Grow Font 1 Point Yazı Tipini 1 Punto Büyüt - HTML Format HTML Biçimi Hanging Indent Asılı Girinti Header Üstbilgi Header & Footer Üstbilgi ve Altbilgi @@ -314,6 +306,7 @@ Heading 1 Başlık 1 Heading 2 Başlık 2 Heading 3 Başlık 3 + Heading {n} Başlık {n} Headings Başlıklar Heart Kalp Help Yardım @@ -338,6 +331,8 @@ Indentation Girintileme Indents Girintiler Index Dizin + Index Heading Dizin Başlığı + Index {n} Dizin {n} Info Bilgi Ink to\nMath Mürekkebi\nMatematiğe Dönüştür Ink to\nShape Mürekkebi\nŞekle Dönüştür @@ -369,19 +364,18 @@ Intense Reference Yoğun Başvuru Inside Borders İç Kenarlıklar Inspect Document Belgeyi İncele - Inspect Styles at Selection Seçimdeki Stilleri İncele Interface language: Arayüz dili: Italic İtalik Join the ArtCraft community on Discord Discord'da ArtCraft topluluğuna katılın Join us on Discord: discord.gg/artcraft Discord'da bize katılın: discord.gg/artcraft Justify İki Yana Yasla Keep Lines Together Satırları Birlikte Tut - Keep Text Only Yalnızca Metni Koru Keep lines together Satırları birlikte tut + Keep Text Only Yalnızca Metni Koru Keep with Next Sonrakiyle Birlikte Tut Keep with next Sonrakiyle birlikte tut - Keeps the fonts and formatting of the copied text. Kopyalanan metnin yazı tiplerini ve biçimlendirmesini korur. Keywords Anahtar Sözcükler + Keeps the fonts and formatting of the copied text. Kopyalanan metnin yazı tiplerini ve biçimlendirmesini korur. Labels Etiketler Landscape Yatay Language Dil @@ -392,28 +386,29 @@ Layout Summary Düzen Özeti Left Sol Left Border Sol Kenarlık - Left Margin Sol Kenar Boşluğu Left: Sol: Letter Letter Line Satır + Line and Page Breaks Satır ve Sayfa Sonları + Line and Paragraph Spacing Satır ve Paragraf Aralığı Line Break Satır Sonu + Line delta Satır farkı Line End Satırın Sonu + Line Number Satır Numarası Line Numbers Satır Numaraları Line Spacing Satır Aralığı Line Spacing Options… Satır Aralığı Seçenekleri… - Line Start Satırın Başı - Line and Page Breaks Satır ve Sayfa Sonları - Line and Paragraph Spacing Satır ve Paragraf Aralığı - Line delta Satır farkı Line spacing: Satır aralığı: + Line Start Satırın Başı Lines Satırlar Lines added Eklenen satırlar Lines deleted Silinen satırlar Link Bağlantı Link to Previous Öncekine Bağla Links Bağlantılar + List Paragraph Liste Paragrafı Location: {path} Konum: {path} - MIT OR Apache-2.0. Copyright (c) 2026 ArtCraft Team and the WordCraft contributors. MIT OR Apache-2.0. Telif hakkı (c) 2026 ArtCraft ekibi ve WordCraft'a katkıda bulunanlar. + lowercase küçük harf Macros Makrolar Mailings Postalar Manage Sources Kaynakları Yönet @@ -424,19 +419,18 @@ Mark\nCitation Atıfı\nİşaretle Mark\nEntry Girdiyi\nİşaretle Markdown (*.md) Markdown (*.md) - Match Fields Alanları Eşleştir Match case Büyük/küçük harfe duyarlı + Match Fields Alanları Eşleştir Matrix Matris Merge Birleştir Merge Cells Hücreleri Birleştir Merge Formatting Biçimlendirmeyi Birleştir Merged PRs Birleştirilen PR'lar Mirrored Aynalı + MIT OR Apache-2.0. Copyright (c) 2026 ArtCraft Team and the WordCraft contributors. MIT OR Apache-2.0. Telif hakkı (c) 2026 ArtCraft ekibi ve WordCraft'a katkıda bulunanlar. Moderate Orta Modify Style Stili Değiştir Modify… Değiştir… - More templates → Diğer şablonlar → - Move or Resize Taşı veya Boyutlandır Multilevel List Çok Düzeyli Liste Multiple Pages Birden Çok Sayfa Name (A–Z) Ad (A–Z) @@ -446,10 +440,10 @@ Navigation Pane Gezinti Bölmesi New Yeni New Comment Yeni Yorum + New from Template Şablondan Yeni New Paragraph Yeni Paragraf New Style… Yeni Stil… New Window Yeni Pencere - New from Template Şablondan Yeni New\nComment Yeni\nYorum New\nWindow Yeni\nPencere Next Sonraki @@ -466,23 +460,24 @@ No Spacing Aralık Yok No contributor data was built into this copy. Bu kopyaya katkıda bulunan verisi eklenmemiş. No model credits were built into this copy. Bu kopyaya model katkıları eklenmemiş. - No templates match your search. Aramanızla eşleşen şablon yok. Nonbreaking Hyphen Bölünemez Kısa Çizgi Nonbreaking Space Bölünemez Boşluk None Yok + Normal Normal Normal 1" all Normal her kenar 1 inç + Normal Indent Normal Girintili Normal Style Normal Stil + Normal Table Normal Tablo Not available right now Şu anda kullanılamıyor Not saved Kaydedilmedi Not saved yet Henüz kaydedilmedi - Nudge Az Kaydır Number of columns: Sütun sayısı: Number of rows: Satır sayısı: Numbering Numaralandırma - OK Tamam Object Nesne Odd Page Tek Sayfa Office 2003 Default Office 2003 Varsayılanı + OK Tamam One Bir One Page Bir Sayfa Open Aç @@ -494,24 +489,23 @@ Outline Level Ana Hat Düzeyi Outside Borders Dış Kenarlıklar Oval Oval - PDF document (*.pdf) PDF belgesi (*.pdf) Page Sayfa Page Background Sayfa Arka Planı Page Borders Sayfa Kenarlıkları Page Break Sayfa Sonu Page Break Before Öncesinde Sayfa Sonu + Page break before Öncesinde sayfa sonu Page Breaks Sayfa Sonları Page Color Sayfa Rengi Page Down Bir Sayfa Aşağı + Page image (*.png) Sayfa görüntüsü (*.png) Page Movement Sayfa Geçişi Page Number Sayfa Numarası Page Setup Sayfa Ayarları Page Up Bir Sayfa Yukarı Page Width Sayfa Genişliği - Page X of Y Sayfa X / Y - Page break before Öncesinde sayfa sonu - Page image (*.png) Sayfa görüntüsü (*.png) Page width Sayfa genişliği + Page X of Y Sayfa X / Y Page {page} Sayfa {page} Page {page} of {pages} Sayfa {page} / {pages} Page\nBorders Sayfa\nKenarlıkları @@ -528,11 +522,9 @@ Paste Yapıştır Paste (⌘V) Yapıştır (⌘V) Paste Formatting Biçimlendirmeyi Yapıştır - Paste Special Özel Yapıştır - Paste Special… Özel Yapıştır… - Paste as: Yapıştırma biçimi: Paste: Keep Text Only Yapıştır: Yalnızca Metni Koru Paste: Merge Formatting Yapıştır: Biçimlendirmeyi Birleştir + PDF document (*.pdf) PDF belgesi (*.pdf) Pen Kalem Pencil Kurşun Kalem Picture Border Resim Kenarlığı @@ -565,9 +557,6 @@ Read Mode Okuma Modu Read\nAloud Sesli\nOku Read\nMode Okuma\nModu - Reads the clipboard's rich text and keeps its formatting. Panodaki zengin metni okur ve biçimlendirmesini korur. - Reads the clipboard's web content and keeps its formatting. Panodaki web içeriğini okur ve biçimlendirmesini korur. - Reading Highlight Okuma Vurgusu Recent Son Kullanılanlar Record Macro Makro Kaydet Recover Unsaved Documents Kaydedilmemiş Belgeleri Kurtar @@ -586,7 +575,6 @@ Remove Space After Paragraph Paragraf Sonrası Boşluğu Kaldır Remove Table of Contents İçindekiler Tablosunu Kaldır Remove Watermark Filigranı Kaldır - Reopen Yeniden Aç Repeat Tekrarla Repeat Header Rows Başlık Satırlarını Tekrarla Replace Değiştir @@ -599,27 +587,23 @@ Research Araştırma Researcher Araştırmacı Reset Picture Resmi Sıfırla - Reset to Normal Normale Sıfırla Resolve Çöz Resolved Çözüldü + Restart at 1 1'den Yeniden Başlat Restart Each Page Her Sayfada Yeniden Başlat Restart Each Section Her Bölümde Yeniden Başlat - Restart at 1 1'den Yeniden Başlat Restrict Editing Düzenlemeyi Kısıtla Restrict\nEditing Düzenlemeyi\nKısıtla - Result Sonuç Results Sonuçlar Reverse the order Sırayı ters çevir + Review İncele Reviewing İnceleme Reviewing Pane İnceleme Bölmesi Reviewing — edits become suggestions İnceleme — düzenlemeler öneriye dönüşür Rich Text Format (*.rtf) Zengin Metin Biçimi (*.rtf) - Rich Text Format (RTF) Zengin Metin Biçimi (RTF) Right Sağ Right Border Sağ Kenarlık - Right Margin Sağ Kenar Boşluğu Right-to-Left Text Direction Sağdan Sola Metin Yönü - Left-to-Right Text Direction Soldan Sağa Metin Yönü Right: Sağ: Rotate Döndür Rounded Rectangle Yuvarlatılmış Dikdörtgen @@ -628,14 +612,13 @@ Rules Kurallar Résumé Özgeçmiş Save Kaydet - Save As Farklı Kaydet Save a copy in another format. Printing goes through a PDF you can print from any viewer. Başka bir biçimde kopya kaydedin. Yazdırma, herhangi bir görüntüleyiciden yazdırabileceğiniz bir PDF üzerinden yapılır. + Save As Farklı Kaydet Save as Template Şablon Olarak Kaydet Saved Kaydedildi Search Commands Komut Ara Search commands and help Komutlarda ve yardımda ara Search document Belgede ara - Search for templates Şablon ara Section Breaks Bölüm Sonları Section Properties Bölüm Özellikleri Sections Bölümler @@ -658,11 +641,11 @@ Selection Pane Seçim Bölmesi Send Backward Arkaya Gönder Sentence case. Cümle düzeni. + Set as Default Varsayılan Yap Set Caret İmleci Konumlandır Set Character Formatting Karakter Biçimlendirmesini Ayarla Set Numbering Value Numaralandırma Değerini Ayarla Set Paragraph Formatting Paragraf Biçimlendirmesini Ayarla - Set as Default Varsayılan Yap Shading Gölgelendirme Shadow Gölge Shape Fill Şekil Dolgusu @@ -673,10 +656,10 @@ Share Paylaş Shift+Tab Shift+Tab Show Göster + Show all formatting marks Tüm biçimlendirme işaretlerini göster Show Comments Yorumları Göster Show Markup İşaretleri Göster Show Notes Notları Göster - Show all formatting marks Tüm biçimlendirme işaretlerini göster Show rulers Cetvelleri göster Animate the caret (it glides and fades) İmleci canlandır (kayarak ilerler ve solarak yanıp söner) Animation Animasyon @@ -715,13 +698,12 @@ Statistics İstatistikler Status Bar Durum Çubuğu Strikethrough Üstü Çizili - Strong Güçlü + Strong Kalın Vurgu Studio cover Stüdyo kapağı Studio handbook (sample) Stüdyo el kitabı (örnek) Style Stil - Style Inspector Stil Denetçisi - Style Set Stil Kümesi Style based on: Temel alınan stil: + Style Set Stil Kümesi Style: {style} Stil: {style} Styles Stiller Styles Pane Stiller Bölmesi @@ -740,14 +722,16 @@ Table Tablo Table Column Width Tablo Sütun Genişliği Table Design Tablo Tasarımı + Table Grid Tablo Kılavuzu Table Layout Tablo Düzeni + Table of Authorities Hukuki Kaynaklar Tablosu + Table of Contents İçindekiler Tablosu + Table of Figures Şekiller Tablosu + Table of\nContents İçindekiler\nTablosu Table Properties Tablo Özellikleri Table Row Height Tablo Satır Yüksekliği Table Style Options Tablo Stili Seçenekleri Table Styles Tablo Stilleri - Table of Authorities Hukuki Kaynaklar Tablosu - Table of Contents İçindekiler Tablosu - Table of\nContents İçindekiler\nTablosu Tables Tablolar Tabs Sekmeler Tabular List Tablo Biçiminde Liste @@ -756,14 +740,13 @@ Text Direction Metin Yönü Text Effects Metin Efektleri Text Effects and Typography Metin Efektleri ve Tipografi - Text Highlight Color Metin Vurgu Rengi - Text Wrapping Metin Kaydırma Text from File Dosyadan Metin + Text Highlight Color Metin Vurgu Rengi Text to display: Görüntülenecek metin: + Text Wrapping Metin Kaydırma Text: Metin: Text\nBox Metin\nKutusu Text\nDirection Metin\nYönü - The clipboard is empty. Pano boş. The new style takes the formatting of the current paragraph. Yeni stil, geçerli paragrafın biçimlendirmesini alır. Theme Colors Tema Renkleri Theme Fonts Tema Yazı Tipleri @@ -773,10 +756,13 @@ Thick Kalın Three Üç Tight Sıkı + Title Başlık +# The style is «Заголовок»; the Design tab's tiles are too narrow for it, so they show a sample word. +preview Title Örnek + TOA Heading Yetki Tablosu Başlığı TOC Heading İçindekiler Başlığı TOC {n} İçindekiler {n} Top Border Üst Kenarlık - Top Margin Üst Kenar Boşluğu Top of Page Sayfa Üstü Top: Üst: Total Row Toplam Satırı @@ -787,14 +773,12 @@ Transparency Saydamlık Triangle Üçgen Two İki - Type Text Metin Yaz Type a command, e.g. "insert table" Bir komut yazın, ör. "tablo ekle" - UPPERCASE BÜYÜK HARF + Type Text Metin Yaz Underline Altı Çizili Underline Words Only Yalnızca Sözcüklerin Altını Çiz Undo Geri Al Undo {action} Geri Al: {action} - Unformatted Text Biçimlendirilmemiş Metin Up Yukarı Update Field Alanı Güncelle Update Index Dizini Güncelle @@ -803,6 +787,7 @@ Update Table of Figures Şekiller Listesini Güncelle Update to Match Eşleşecek Şekilde Güncelle Update to Match Selection Seçime Göre Güncelle + UPPERCASE BÜYÜK HARF User Name Kullanıcı Adı User name: Kullanıcı adı: Version History Sürüm Geçmişi @@ -817,8 +802,8 @@ Watermark Filigran Wave Dalga Web Layout Web Düzeni - Web\nLayout Web\nDüzeni Web page (*.html) Web sayfası (*.html) + Web\nLayout Web\nDüzeni Whole page Tüm sayfa Whole words Tam sözcükler Wide Geniş @@ -826,9 +811,9 @@ Widow/Orphan control Tek kalan satır denetimi Window Pencere Word Count Sözcük Sayısı + Word document (*.docx) Word belgesi (*.docx) Word Left Önceki Sözcük Word Right Sonraki Sözcük - Word document (*.docx) Word belgesi (*.docx) WordArt WordArt WordCraft WordCraft Words Sözcükler @@ -836,27 +821,39 @@ Wrap Text Metni Kaydır Wrap\nText Metni\nKaydır Write & Insert Fields Alanları Yaz ve Ekle - Write a reply Yanıt yazın Zoom Yakınlaştırma Zoom In Yakınlaştır Zoom Out Uzaklaştır - development build geliştirme derlemesi - lowercase küçük harf {group} settings {group} ayarları {key}+Click to follow link Bağlantıyı açmak için {key} tuşunu basılı tutup tıklayın {selected} of {words} words {words} sözcükten {selected} seçili {words} words {words} sözcük - Equation Denklem - Review İncele - Track\nChanges Değişiklikleri\nİzle - Track\nChanges ✓ Değişiklikleri\nİzle ✓ - Scroll ribbon left Şeridi sola kaydır - Scroll ribbon right Şeridi sağa kaydır - Apply Styles Stilleri Uygula Dark page (white text on black) Koyu sayfa (siyah üzerine beyaz metin) Show documents with their colours inverted, like View › Switch Modes. Saving, printing and PDFs are unchanged. Belgeleri, Görünüm › Mod Değiştir'deki gibi ters renklerle gösterir. Kaydetme, yazdırma ve PDF'ler değişmez. Only web (http, https) and email (mailto) links can be followed Yalnızca web (http, https) ve e-posta (mailto) bağlantıları açılabilir + + Apply Styles Stilleri Uygula + AutoSave documents you have saved in WordCraft WordCraft'ta kaydettiğiniz belgeleri otomatik kaydet + AutoSave failed: {error}. Save the document to turn AutoSave back on. Otomatik Kaydet başarısız oldu: {error}. Otomatik Kaydet'i yeniden açmak için belgeyi kaydedin. + Bottom Margin Alt Kenar Boşluğu + Edit Comment Yorumu Düzenle + Left Margin Sol Kenar Boşluğu + More templates → Diğer şablonlar → + No templates match your search. Aramanızla eşleşen şablon yok. + Reopen Yeniden Aç + Right Margin Sağ Kenar Boşluğu + Search for templates Şablon ara Character Border Karakter Kenarlığı + Count words in text boxes and notes Metin kutularındaki ve notlardaki sözcükleri say + Couldn't download {name}: {error} {name} indirilemedi: {error} + Do you want to save changes to {name}? {name} içindeki değişiklikleri kaydetmek istiyor musunuz? + Don't Save Kaydetme + Move or Resize Taşı veya Boyutlandır + Nudge Az Kaydır + Left-to-Right Text Direction Soldan Sağa Metin Yönü + That format doesn't keep everything, so the document is still open. Save it as a Word document (.docx) to go on. Bu biçim her şeyi korumaz; bu yüzden belge açık kalır. Devam etmek için Word belgesi (.docx) olarak kaydedin. + Top Margin Üst Kenar Boşluğu + Write a reply Yanıt yazın Direction: Yön: Right-to-left Sağdan sola Left-to-right Soldan sağa @@ -896,13 +893,9 @@ Stopped Durduruldu Speed Hız Normal speed Normal hız - AutoSave documents you have saved in WordCraft WordCraft'ta kaydettiğiniz belgeleri otomatik kaydet - AutoSave failed: {error}. Save the document to turn AutoSave back on. Otomatik Kaydet başarısız oldu: {error}. Otomatik Kaydet'i yeniden açmak için belgeyi kaydedin. - Couldn't download {name}: {error} {name} indirilemedi: {error} - Do you want to save changes to {name}? {name} içindeki değişiklikleri kaydetmek istiyor musunuz? - Don't Save Kaydetme - That format doesn't keep everything, so the document is still open. Save it as a Word document (.docx) to go on. Bu biçim her şeyi korumaz; bu yüzden belge açık kalır. Devam etmek için Word belgesi (.docx) olarak kaydedin. Your changes will be lost if you don't save them. Kaydetmezseniz değişiklikleriniz kaybolur. + Scroll ribbon left Şeridi sola kaydır + Scroll ribbon right Şeridi sağa kaydır Change Structure Yapıyı Değiştir Change to Display Ayrı Satıra Dönüştür Change to Inline Satır İçine Dönüştür @@ -951,6 +944,32 @@ Print failed: {error} Yazdırma başarısız: {error} Couldn't open {path}: {error} {path} açılamadı: {error} Column Selection Sütun Seçimi + Style Inspector Stil Denetçisi + Inspect Styles at Selection Seçimdeki Stilleri İncele + Clear Formatting Level Biçimlendirme Düzeyini Temizle + Characters Karakterler + Direct formatting Doğrudan biçimlendirme + Reset to Normal Normale Sıfırla + Clear Paragraph Formatting Paragraf Biçimlendirmesini Temizle + Clear Character Style Karakter Stilini Temizle + Clear Character Formatting Karakter Biçimlendirmesini Temizle + Clear All Tümünü Temizle + Paste Special Özel Yapıştır + Paste Special… Özel Yapıştır… + Paste as: Yapıştırma biçimi: + The clipboard is empty. Pano boş. + Formatted Text Biçimlendirilmiş Metin + Rich Text Format (RTF) Zengin Metin Biçimi (RTF) + HTML Format HTML Biçimi + Unformatted Text Biçimlendirilmemiş Metin + Result Sonuç + Reads the clipboard's rich text and keeps its formatting. Panodaki zengin metni okur ve biçimlendirmesini korur. + Reads the clipboard's web content and keeps its formatting. Panodaki web içeriğini okur ve biçimlendirmesini korur. + Drops all formatting and pastes plain text. Tüm biçimlendirmeyi kaldırır ve düz metin yapıştırır. + Advanced Find Gelişmiş Bul + Advanced Find… Gelişmiş Bul… + Go To… Git… + Reading Highlight Okuma Vurgusu New Table Style Yeni Tablo Stili Modify Table Style Tablo Stilini Değiştir New Table Style… Yeni Tablo Stili… @@ -1039,105 +1058,6 @@ Dark Koyu Use system setting Sistem ayarını kullan A file dialog is already open. Bir dosya iletişim kutusu zaten açık. - Alt text Alternatif metin - Alt: Alt metin: - W: G: - H: Y: - Reset Sıfırla - Reset Crop Kırpmayı Sıfırla - Insert a new equation (Alt+=) Yeni denklem ekle (Alt+=) - Leave the equation (Esc) Denklemden çık (Esc) - Preview Önizleme - getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft - (no style) (stil yok) - Add Object to Selection Nesneyi Seçime Ekle - Add space between Asian and Latin text Asya ve Latin metni arasına boşluk ekle - Add space between Asian text and numbers Asya metni ile sayılar arasına boşluk ekle - Add tab stop at: Sekme durağı ekle: - Aligned at: Hizalama konumu: - Allow hanging punctuation Asılı noktalamaya izin ver - Allow Latin text to wrap in the middle of a word Latin metninin sözcük ortasında alt satıra geçmesine izin ver - Apply Asian line-breaking rules (kinsoku) Asya satır bölme kurallarını uygula (kinsoku) - Asian Typography Asya Tipografisi - AutoSave isn't available in the browser: it can't write to your files. Save downloads a copy. Otomatik Kaydet tarayıcıda kullanılamaz: dosyalarınıza yazamaz. Kaydet komutu bir kopya indirir. - AutoSave only keeps .docx, .odt and .rtf files; {name} can't keep all formatting. Save as .docx to turn on AutoSave. Otomatik Kaydet yalnızca .docx, .odt ve .rtf dosyalarıyla çalışır; {name} tüm biçimlendirmeyi koruyamaz. Otomatik Kaydet'i açmak için .docx olarak kaydedin. - Balloons Balonlar - Bullet Madde İşareti - By author Yazara göre - Changed lines: Değişen satırlar: - Click level to modify: Değiştirilecek düzeye tıklayın: - Color only Yalnızca renk - Define New Multilevel List Yeni Çok Düzeyli Liste Tanımla - Define New Multilevel List… Yeni Çok Düzeyli Liste Tanımla… - Deletions: Silmeler: - Enter formatting for number: Numara biçimini girin: - Follow number with: Numaradan sonra: - Formatting Biçimlendirme - Include level number from: Şu düzeyin numarasını ekle: - Ink Mürekkep - Insertions and Deletions Eklemeler ve Silmeler - Insertions: Eklemeler: - Left border Sol kenarlık - Legal style numbering Hukuki stilde numaralandırma - Link level to style: Düzeyi stile bağla: - List Definition Liste Tanımı - Nothing Hiçbiri - Number alignment: Numara hizalaması: - Number format Numara biçimi - Number style for this level: Bu düzeyin numara stili: - Outside border Dış kenarlık - Restart list after: Listeyi şundan sonra yeniden başlat: - Right border Sağ kenarlık - Save the document to turn on AutoSave Otomatik Kaydet'i açmak için belgeyi kaydedin - Save the document to turn on AutoSave (a file you have only opened isn't rewritten until you save it) Otomatik Kaydet'i açmak için belgeyi kaydedin (yalnızca açtığınız bir dosya, siz kaydedene kadar yeniden yazılmaz) - Tab stop position: Sekme durağı konumu: - Default tab stops: Varsayılan sekme durakları: - Tab stops to be cleared: Temizlenecek sekme durakları: - All Tümü - Decimal Ondalık - Bar Çubuk - Leader Dolgu karakteri - Set Ayarla - Page Border Sayfa Kenarlığı - Apply to: Uygula: - Whole document Tüm belge - This section Bu bölüm - Fill Dolgu - Setting: Ayar: - 3-D 3B - Custom Özel - Style: Stil: - Dot dash Nokta çizgi - Triple Üçlü - Between Paragraflar arası - Borders and Shading Kenarlıklar ve Gölgelendirme - Tabs… Sekmeler… - Borders and Shading… Kenarlıklar ve Gölgelendirme… - Click a border between two cells to merge them. Esc stops. İki hücreyi birleştirmek için aradaki kenarlığa tıklayın. Esc ile bitirin. - Clipboard Pane Pano Bölmesi - Clipboard Items Pano Öğeleri - Paste Clipboard Item Pano Öğesini Yapıştır - Paste All Tümünü Yapıştır - Delete Clipboard Item Pano Öğesini Sil - Click an item to paste it. Yapıştırmak için bir öğeye tıklayın. - Nothing collected yet. Items you copy or cut appear here. Henüz bir şey toplanmadı. Kopyaladığınız veya kestiğiniz öğeler burada görünür. - (picture or object) (resim veya nesne) - Compress punctuation at the start of a line Satır başındaki noktalamayı sıkıştır - Drag a rectangle to draw a table, or a line across cells to split them. Esc stops. Tablo çizmek için bir dikdörtgen, hücreleri bölmek için üzerlerinden bir çizgi sürükleyin. Esc ile bitirin. - Draw Table Tablo Çiz - Indents and Spacing Girintiler ve Aralık - Line breaking Satır bölme - Show all revisions inline Tüm düzeltmeleri satır içinde göster - Show only comments and formatting in balloons Balonlarda yalnızca yorumları ve biçimlendirmeyi göster - Show revisions in balloons Düzeltmeleri balonlarda göster - Space Boşluk - Start at: Başlangıç değeri: - Tab character Sekme karakteri - Text indent at: Metin girintisi: - Track Changes Options Değişiklikleri İzleme Seçenekleri - Track formatting Biçimlendirmeyi izle - Ungroup Grubu Çöz - Browse… Gözat… Height: Yükseklik: Width: Genişlik: Row Satır @@ -1153,91 +1073,406 @@ Vertical alignment: Dikey hizalama: Top Üst Bottom Alt + Alt text Alternatif metin + Alt: Alt metin: + W: G: + H: Y: + Reset Sıfırla + Reset Crop Kırpmayı Sıfırla + Insert a new equation (Alt+=) Yeni denklem ekle (Alt+=) + Leave the equation (Esc) Denklemden çık (Esc) + Preview Önizleme + getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft + Browse… Gözat… + +# Equation gallery, themes, watermarks, paper sizes, table styles and other strings the +# interface audit (`i18n/audit.rs`) found without a Russian entry. + AaBbYyZz AaBbYyZz ĞğİıŞş + Align Top Left Sol Üste Hizala + Align Top Center Üst Ortaya Hizala + Align Top Right Sağ Üste Hizala + Align Center Left Sol Ortaya Hizala + Align Center Ortaya Hizala + Align Center Right Sağ Ortaya Hizala + Align Bottom Left Sol Alta Hizala + Align Bottom Center Alt Ortaya Hizala + Align Bottom Right Sağ Alta Hizala + Commits Commit'ler + {contributors} contributors · {commits} commits {contributors} katkıda bulunan · {commits} commit + Display name Görünen ad + Real name Gerçek ad + Username Kullanıcı adı + Document1 Belge1 + Font… Yazı Tipi… + Paragraph… Paragraf… + Link… Bağlantı… + Lines +/− Satır +/− + Model Model + Version Sürüm + % of all commits Commit payı (%) + pt pt + Save the document to turn on AutoSave Otomatik Kaydet'i açmak için belgeyi kaydedin + Models Modeller + Good evening İyi akşamlar + Good morning Günaydın + Good afternoon Tünaydın + Track\nChanges Değişiklikleri\nİzle + Track\nChanges ✓ Değişiklikleri\nİzle ✓ + tOGGLE cASE bÜYÜK kÜÇÜK dÖNÜŞTÜR + Change to Level 1 Düzey 1 Yap + Change to Level 2 Düzey 2 Yap + Change to Level 3 Düzey 3 Yap + Change to Level 4 Düzey 4 Yap + Change to Level 5 Düzey 5 Yap + Level 1 Düzey 1 + Level 2 Düzey 2 + Level 3 Düzey 3 + ASAP EN KISA SÜREDE + CONFIDENTIAL GİZLİ + DO NOT COPY KOPYALAMAYIN + DRAFT TASLAK + SAMPLE ÖRNEK + URGENT ACİL + Atelier Atölye + Craft Zanaat + Darkroom Karanlık Oda + Gallery Galeri + Sketchbook Eskiz Defteri + Studio Stüdyo + Letter 8.50" × 11.00" Letter 8,50" × 11,00" + Legal 8.50" × 14.00" Legal 8,50" × 14,00" + Executive 7.25" × 10.50" Executive 7,25" × 10,50" + Statement 5.50" × 8.50" Statement 5,50" × 8,50" + Tabloid 11.00" × 17.00" Tabloid 11,00" × 17,00" + B4 (JIS) 10.12" × 14.33" B4 (JIS) 10,12" × 14,33" + B5 (JIS) 7.17" × 10.12" B5 (JIS) 7,17" × 10,12" + Envelope #10 4.12" × 9.50" Zarf No. 10 4,12" × 9,50" + Envelope DL 4.33" × 8.66" Zarf DL 4,33" × 8,66" + Plain Table 1 Düz Tablo 1 + Grid Table 1 Light Kılavuz Tablo 1 Açık + Grid Table 4 – Blue Kılavuz Tablo 4 – Mavi + Grid Table 4 – Green Kılavuz Tablo 4 – Yeşil + Grid Table 4 – Orange Kılavuz Tablo 4 – Turuncu + Grid Table 4 – Purple Kılavuz Tablo 4 – Mor + List Table 3 – Blue Liste Tablosu 3 – Mavi + List Table 3 – Green Liste Tablosu 3 – Yeşil + List Table 3 – Orange Liste Tablosu 3 – Turuncu + List Table 3 – Purple Liste Tablosu 3 – Mor + Convert between built-up and linear form Biçimlendirilmiş ve doğrusal biçim arasında dönüştür + Justification of a display equation Denklemin hizalaması + Number the equation; the number sits at the right margin (or type # then the number) Denklemi numaralandır; numara sağ kenar boşluğunda yer alır (veya # yazıp numarayı girin) + Show the equation in the line of text Denklemi metin satırı içinde göster + Show the equation on a line of its own Denklemi ayrı bir satırda göster + Type equations in LaTeX Denklemleri LaTeX biçiminde yazın + Type equations in the linear format (UnicodeMath) Denklemleri doğrusal biçimde (UnicodeMath) yazın + Type normal (non-math) text in the equation Denklem içinde normal (matematik dışı) metin yazın + Sides of a right triangle Dik Üçgen Kenarları + Circle area from its radius Yarıçaptan Daire Alanı + Roots of ax²+bx+c ax²+bx+c Kökleri + Binomial expansion of (x+a)ⁿ (x+a)ⁿ Binom Açılımı + Power series of (1+x)ⁿ (1+x)ⁿ Kuvvet Serisi + Euler's identity Euler Özdeşliği + Derivative as a limit Limit Olarak Türev + Exponential series Üstel Seri + Integral of e^(−x²) e^(−x²) İntegrali + Sine and cosine series of f(x) f(x) Sinüs ve Kosinüs Serisi + Sum or difference of sines Sinüsler Toplamı veya Farkı + Sum of cosines Kosinüsler Toplamı + Normal probability density Normal Olasılık Yoğunluğu + Bayes' rule Bayes Kuralı + Accents Vurgular + Arrows Oklar + Basic Math Temel Matematik + Boxed Formulas Kutulu Formüller + Cases and Stacks Koşullar ve Yığınlar + Common Accent Objects Sık Kullanılan Vurgulu Nesneler + Common Brackets Sık Kullanılan Parantezler + Common Fractions Sık Kullanılan Kesirler + Common Functions Sık Kullanılan Fonksiyonlar + Common Large Operators Sık Kullanılan Büyük İşleçler + Common Operator Structures Sık Kullanılan İşleç Yapıları + Common Operators Sık Kullanılan İşleçler + Common Radicals Sık Kullanılan Kökler + Common Subscripts and Superscripts Sık Kullanılan Alt ve Üst İndisler + Contour Integrals Çevre İntegralleri + Differentials Diferansiyeller + Dots Noktalar + Empty Matrices Boş Matrisler + Functions Fonksiyonlar + Geometry Geometri + Greek Letters Yunan Harfleri + Hyperbolic Functions Hiperbolik Fonksiyonlar + Identity Matrices Birim Matrisler + Integrals İntegraller + Inverse Functions Ters Fonksiyonlar + Inverse Hyperbolic Functions Ters Hiperbolik Fonksiyonlar + Letter-Like Symbols Harf Benzeri Semboller + Matrices with Brackets Parantezli Matrisler + Negated Relations Olumsuzlanmış Bağıntılar + Operator Structures İşleç Yapıları + Operators İşleçler + Other Large Operators Diğer Büyük İşleçler + Overbars and Underbars Üst ve Alt Çizgiler + Products and Coproducts Çarpımlar ve Eşçarpımlar + Radicals Kökler + Scripts İndisler + Single Brackets Tekli Parantezler + Sparse Matrices Seyrek Matrisler + Subscripts and Superscripts Alt ve Üst İndisler + Summations Toplamlar + Trigonometric Functions Trigonometrik Fonksiyonlar + Unions and Intersections Birleşimler ve Kesişimler + 1×2 Empty Matrix 1×2 Boş Matris + 1×3 Empty Matrix 1×3 Boş Matris + 2×1 Empty Matrix 2×1 Boş Matris + 2×2 Empty Matrix 2×2 Boş Matris + 2×3 Empty Matrix 2×3 Boş Matris + 3×1 Empty Matrix 3×1 Boş Matris + 3×2 Empty Matrix 3×2 Boş Matris + 3×3 Empty Matrix 3×3 Boş Matris + 2×2 Determinant 2×2 Determinant + 2×2 Identity Matrix 2×2 Birim Matris + 2×2 Identity Matrix (sparse) 2×2 Birim Matris (seyrek) + 3×3 Identity Matrix 3×3 Birim Matris + 3×3 Identity Matrix (sparse) 3×3 Birim Matris (seyrek) + 2×2 Matrix in Brackets Köşeli Parantezli 2×2 Matris + 2×2 Matrix in Double Bars Çift Çubuklu 2×2 Matris + 2×2 Matrix in Parentheses Parantezli 2×2 Matris + n×n Identity Matrix n×n Birim Matris + n×n Matrix n×n Matris + A xor B A xor B + Absolute value cases Mutlak Değer Durumları + Acute Tiz Vurgu + Angle Brackets Açılı Parantezler + Angle Brackets with Separator Ayırıcılı Açılı Parantezler + Angle Brackets with Two Separators İki Ayırıcılı Açılı Parantezler + Bar Çubuk + Baseline Dots Taban Çizgisinde Üç Nokta + Binomial Coefficient Binom Katsayısı + Binomial Coefficient (angle) Binom Katsayısı (açılı) + Binomial Coefficient with Angle Brackets Açılı Parantezli Binom Katsayısı + Boxed Formula Kutulu Formül + Boxed Formula (example) Kutulu Formül (örnek) + Braces Küme Parantezleri + Braces with Separator Ayırıcılı Küme Parantezleri + Brackets Köşeli Parantezler + Brackets with Separators Ayırıcılı Köşeli Parantezler + Breve Kısa İşareti + Cases (Three Conditions) Koşul Grubu (Üç Koşul) + Cases (Two Conditions) Koşul Grubu (İki Koşul) + Ceiling Yukarı Yuvarlama + Change in y over change in x y'deki Değişimin x'teki Değişime Oranı + Check İşaret + Circled Dot Daire İçinde Nokta + Circled Plus Daire İçinde Artı + Circled Times Daire İçinde Çarpı + Colon Equal İki Nokta Üst Üste Eşittir + Contour Integral Çevre İntegrali + Contour Integral with Limits Sınırlı Çevre İntegrali + Contour Integral with Stacked Limits Üst Üste Sınırlı Çevre İntegrali + Coproduct Eşçarpım + Coproduct with Limits Sınırlı Eşçarpım + Coproduct with Lower Limit Alt Sınırlı Eşçarpım + Coproduct with Subscript-Superscript Limits Alt ve Üst İndis Sınırlı Eşçarpım + Cosecant Function Kosekant Fonksiyonu + Cosine 2x Kosinüs 2x + Cosine Function Kosinüs Fonksiyonu + Cotangent Function Kotanjant Fonksiyonu + Cube Root Küp Kök + Delta Equal To Delta Eşittir + Delta over arrow Ok Üzerinde Delta + Diagonal Dots Köşegen Üç Nokta + Differential Diferansiyel + Differential theta Teta Diferansiyeli + Differential x x Diferansiyeli + Differential y y Diferansiyeli + Dot Nokta + Double Bar Çift Çubuk + Double Dot İki Nokta + Double Integral İkili İntegral + Double Integral with Limits Sınırlı İkili İntegral + Double Integral with Stacked Limits Üst Üste Sınırlı İkili İntegral + Double Left Arrow with Text Above Üstünde Metin Olan Çift Sola Ok + Double Left Arrow with Text Below Altında Metin Olan Çift Sola Ok + Double Left Right Arrow with Text Above Üstünde Metin Olan Çift Yönlü Çift Ok + Double Left Right Arrow with Text Below Altında Metin Olan Çift Yönlü Çift Ok + Double Right Arrow with Text Above Üstünde Metin Olan Çift Sağa Ok + Double Right Arrow with Text Below Altında Metin Olan Çift Sağa Ok + Double Square Brackets Çift Köşeli Parantez + Double Vertical Bars Çift Dikey Çubuk + Double summation Çift Toplam + Equal Equal Eşittir Eşittir + Equal by Definition Tanım Gereği Eşittir + Exponential Üstel + Floor Aşağı Yuvarlama + Grave Pes Vurgu + Grouping Character Above Üstte Gruplandırma Karakteri + Grouping Character Below Altta Gruplandırma Karakteri + Hat Şapka + Hyperbolic Cosecant Function Hiperbolik Kosekant Fonksiyonu + Hyperbolic Cosine Function Hiperbolik Kosinüs Fonksiyonu + Hyperbolic Cotangent Function Hiperbolik Kotanjant Fonksiyonu + Hyperbolic Secant Function Hiperbolik Sekant Fonksiyonu + Hyperbolic Sine Function Hiperbolik Sinüs Fonksiyonu + Hyperbolic Tangent Function Hiperbolik Tanjant Fonksiyonu + Hypotenuse Hipotenüs + Integral with Limits Sınırlı İntegral + Integral with Stacked Limits Üst Üste Sınırlı İntegral + Intersection Kesişim + Intersection with Limits Sınırlı Kesişim + Intersection with Lower Limit Alt Sınırlı Kesişim + Intersection with Subscript-Superscript Limits Alt ve Üst İndis Sınırlı Kesişim + Inverse Cosecant Function Ters Kosekant Fonksiyonu + Inverse Cosine Function Ters Kosinüs Fonksiyonu + Inverse Cotangent Function Ters Kotanjant Fonksiyonu + Inverse Hyperbolic Cosecant Function Ters Hiperbolik Kosekant Fonksiyonu + Inverse Hyperbolic Cosine Function Ters Hiperbolik Kosinüs Fonksiyonu + Inverse Hyperbolic Cotangent Function Ters Hiperbolik Kotanjant Fonksiyonu + Inverse Hyperbolic Secant Function Ters Hiperbolik Sekant Fonksiyonu + Inverse Hyperbolic Sine Function Ters Hiperbolik Sinüs Fonksiyonu + Inverse Hyperbolic Tangent Function Ters Hiperbolik Tanjant Fonksiyonu + Inverse Secant Function Ters Sekant Fonksiyonu + Inverse Sine Function Ters Sinüs Fonksiyonu + Inverse Tangent Function Ters Tanjant Fonksiyonu + Left Arrow with Text Above Üstünde Metin Olan Sola Ok + Left Arrow with Text Below Altında Metin Olan Sola Ok + Left Bracket Twice İki Sol Köşeli Parantez + Left Right Arrow Above Üstte Çift Yönlü Ok + Left Right Arrow with Text Above Üstünde Metin Olan Çift Yönlü Ok + Left Right Arrow with Text Below Altında Metin Olan Çift Yönlü Ok + Left Subscript-Superscript Sol Alt ve Üst İndis + Leftwards Arrow Above Üstte Sola Ok + Leftwards Harpoon Above Üstte Sola Zıpkın + Limit Limit + Limit Example Limit Örneği + Linear Fraction Doğrusal Kesir + Logarithm Logaritma + Logarithm with Base Tabanlı Logaritma + Logical AND Mantıksal VE + Logical AND with Limits Sınırlı Mantıksal VE + Logical OR Mantıksal VEYA + Logical OR with Limits Sınırlı Mantıksal VEYA + Maximum Maksimum + Maximum Example Maksimum Örneği + Measured By Ölçüm Birimi + Midline Dots Orta Çizgide Üç Nokta + Minimum Minimum + Minus Equal Eksi Eşittir + Natural Logarithm Doğal Logaritma + One half Bir Bölü İki + Outward Brackets Dışa Bakan Köşeli Parantezler + Overbar Üst Çizgi + Overbrace Üst Küme Parantezi + Parentheses Parantezler + Parentheses with Separator Ayırıcılı Parantezler + Partial Differential Kısmi Diferansiyel + Pi Over 2 Pi Bölü 2 + Plus Equal Artı Eşittir + Product Çarpım + Product of A A Çarpımı + Product with Limits Sınırlı Çarpım + Product with Lower Limit Alt Sınırlı Çarpım + Product with Subscript-Superscript Limits Alt ve Üst İndis Sınırlı Çarpım + Quadratic Root İkinci Dereceden Kök + Radical with Degree Dereceli Kök + Right Arrow with Text Above Üstünde Metin Olan Sağa Ok + Right Arrow with Text Below Altında Metin Olan Sağa Ok + Right Bracket Twice İki Sağ Köşeli Parantez + Rightwards Arrow Above Üstte Sağa Ok + Rightwards Harpoon Above Üstte Sağa Zıpkın + Secant Function Sekant Fonksiyonu + Sine Function Sinüs Fonksiyonu + Sine theta Sinüs Teta + Single Left Angle Tekli Sol Açılı Parantez + Single Left Bar Tekli Sol Çubuk + Single Left Brace Tekli Sol Küme Parantezi + Single Left Bracket Tekli Sol Köşeli Parantez + Single Left Parenthesis Tekli Sol Parantez + Single Right Brace Tekli Sağ Küme Parantezi + Single Right Bracket Tekli Sağ Köşeli Parantez + Single Right Parenthesis Tekli Sağ Parantez + Skewed Fraction Eğik Kesir + Small change Küçük Değişim + Square Kare + Square Root Karekök + Square Root with Degree Dereceli Karekök + Square Union Kare Birleşim + Stack Object Yığın Nesnesi + Stack Object (Three) Yığın Nesnesi (Üçlü) + Stacked Fraction Dikey Kesir + Stacked, no bar Çizgisiz Yığın + Subscript-Superscript Alt ve Üst İndis + Summation Toplam + Summation from 0 to n 0'dan n'ye Toplam + Summation over binomials Binom Katsayıları Üzerinden Toplam + Summation with Limits Sınırlı Toplam + Summation with Lower Limit Alt Sınırlı Toplam + Summation with Subscript Limit Alt İndis Sınırlı Toplam + Summation with Subscript-Superscript Limits Alt ve Üst İndis Sınırlı Toplam + Surface Integral Yüzey İntegrali + Surface Integral with Limits Sınırlı Yüzey İntegrali + Surface Integral with Stacked Limits Üst Üste Sınırlı Yüzey İntegrali + Tangent Function Tanjant Fonksiyonu + Tangent formula Tanjant Formülü + Text over Colon Equal İki Nokta Üst Üste Eşittir Üzerinde Metin + Text over Equal Eşittir Üzerinde Metin + Tilde Tilde + Triple Dot Üç Nokta + Triple Integral Üçlü İntegral + Triple Integral with Limits Sınırlı Üçlü İntegral + Triple Integral with Stacked Limits Üst Üste Sınırlı Üçlü İntegral + Underbar Alt Çizgi + Underbrace Alt Küme Parantezi + Union Birleşim + Union of intersections Kesişimlerin Birleşimi + Union with Limits Sınırlı Birleşim + Union with Lower Limit Alt Sınırlı Birleşim + Union with Plus Artılı Birleşim + Union with Subscript-Superscript Limits Alt ve Üst İndis Sınırlı Birleşim + Up Diagonal Dots Yukarı Köşegen Üç Nokta + Vector A A Vektörü + Vector AB AB Vektörü + Vertical Bars Dikey Çubuklar + Vertical Dots Dikey Üç Nokta + Volume Integral Hacim İntegrali + Volume Integral with Limits Sınırlı Hacim İntegrali + Volume Integral with Stacked Limits Üst Üste Sınırlı Hacim İntegrali + Yields Sonuç + x bar x Çizgisi + AutoSave isn't available in the browser: it can't write to your files. Save downloads a copy. Otomatik Kaydet tarayıcıda kullanılamaz: dosyalarınıza yazamaz. Kaydet komutu bir kopya indirir. + AutoSave only keeps .docx, .odt and .rtf files; {name} can't keep all formatting. Save as .docx to turn on AutoSave. Otomatik Kaydet yalnızca .docx, .odt ve .rtf dosyalarıyla çalışır; {name} tüm biçimlendirmeyi koruyamaz. Otomatik Kaydet'i açmak için .docx olarak kaydedin. + Save the document to turn on AutoSave (a file you have only opened isn't rewritten until you save it) Otomatik Kaydet'i açmak için belgeyi kaydedin (yalnızca açtığınız bir dosya, siz kaydedene kadar yeniden yazılmaz) + +# Labels added on main and in this stack after the first Russian catalog (#300 integration) + Add Object to Selection Nesneyi Seçime Ekle + Add space between Asian and Latin text Asya ve Latin metni arasına boşluk ekle + Add space between Asian text and numbers Asya metni ile sayılar arasına boşluk ekle + Allow hanging punctuation Asılı noktalamaya izin ver + Allow Latin text to wrap in the middle of a word Latin metninin sözcük ortasında alt satıra geçmesine izin ver + Apply Asian line-breaking rules (kinsoku) Asya satır bölme kurallarını uygula (kinsoku) + Asian Typography Asya Tipografisi + Click a border between two cells to merge them. Esc stops. İki hücreyi birleştirmek için aradaki kenarlığa tıklayın. Esc ile bitirin. + Compress punctuation at the start of a line Satır başındaki noktalamayı sıkıştır + Drag a rectangle to draw a table, or a line across cells to split them. Esc stops. Tablo çizmek için bir dikdörtgen, hücreleri bölmek için üzerlerinden bir çizgi sürükleyin. Esc ile bitirin. + Draw Table Tablo Çiz + Indents and Spacing Girintiler ve Aralık + Line breaking Satır bölme + Clipboard Pane Pano Bölmesi + Clipboard Items Pano Öğeleri + Paste Clipboard Item Pano Öğesini Yapıştır + Paste All Tümünü Yapıştır + Delete Clipboard Item Pano Öğesini Sil + Click an item to paste it. Yapıştırmak için bir öğeye tıklayın. + Nothing collected yet. Items you copy or cut appear here. Henüz bir şey toplanmadı. Kopyaladığınız veya kestiğiniz öğeler burada görünür. + (picture or object) (resim veya nesne) + Ungroup Grubu Çöz Draw Ink Mürekkeple Çiz Erase Ink Mürekkebi Sil Hide Ink Mürekkebi Gizle Hide\nInk Mürekkebi\nGizle Pens Kalemler + Ink Mürekkep Thickness Kalınlık - (All) (Tümü) - (none) (yok) - (normal text) (normal metin) - 1/4 Em Space 1/4 Em Boşluk - A mail merge field Bir posta birleştirme alanı - A numbered sequence (figures, tables…) Numaralı bir dizi (şekiller, tablolar…) - A table of contents Bir içindekiler tablosu - Categories: Kategoriler: - Character Karakter - Character code: Karakter kodu: - Col #: Sütun no.: - Copyright Telif Hakkı - Date and Time Tarih ve Saat - Date formats: Tarih biçimleri: - Description: Açıklama: - Document Information Belge Bilgileri - Double Closing Quote Çift Kapanış Tırnağı - Double Opening Quote Çift Açılış Tırnağı - Ellipsis Üç Nokta - Em Dash Uzun Tire - En Dash Orta Tire - Em Space Em Boşluk - En Space En Boşluk - Equal column width Eşit sütun genişliği - Field Codes Alan Kodları - Field code: Alan kodu: - Field codes: Alan kodları: - Field name: Alan adı: - Field names: Alan adları: - Field properties Alan özellikleri - Field… Alan… - Format: Biçim: - Hide Codes Kodları Gizle - Index and Tables Dizin ve Tablolar - Line between Sütunlar arasında çizgi - Links and References Bağlantılar ve Başvurular - List Paragraph Liste Paragrafı - Mail Merge Posta Birleştirme - More Columns… Diğer Sütunlar… - More Symbols… Diğer Semboller… - No-Width Non Break Sıfır Genişlikli Bölünmez - No-Width Optional Break Sıfır Genişlikli İsteğe Bağlı Bölme - Paragraph Mark Paragraf İşareti - Pick a field from the list Listeden bir alan seçin - Preserve formatting during updates Güncellemelerde biçimlendirmeyi koru - Presets Hazır ayarlar - Recently used symbols: Son kullanılan semboller: - Registered Tescilli Marka - Section Mark Bölüm İşareti - Sequence name: Dizi adı: - Shortcut key: Kısayol tuşu: - Single Closing Quote Tek Kapanış Tırnağı - Single Opening Quote Tek Açılış Tırnağı - Spacing: Aralık: - Special Characters Özel Karakterler - Subset: Alt küme: - The time right now Şu anki saat - When this document was first created Belgenin ilk oluşturulduğu tarih - When this document was most recently printed Belgenin en son yazdırıldığı tarih - When this document was most recently saved Belgenin en son kaydedildiği tarih - The document has no bookmarks yet. Belgede henüz yer işareti yok. - Who the document properties name as author Belge özelliklerinde yazar olarak geçen kişi - Name of the file this document is saved as Belgenin kaydedildiği dosyanın adı - Title from the document properties Belge özelliklerindeki başlık - How many pages the whole document has Belgenin toplam sayfa sayısı - How many pages this section has Bu bölümün sayfa sayısı - Number of the page the field sits on Alanın bulunduğu sayfanın numarası - Number of the section the field sits in Alanın bulunduğu bölümün numarası - How many words the document holds Belgedeki sözcük sayısı - Page on which a bookmarked passage falls Yer işaretli metnin bulunduğu sayfa - Copy of the text a bookmark marks Yer işaretinin kapsadığı metnin kopyası - This field has no options. Bu alanın seçeneği yok. - This font has no characters to show. Bu yazı tipinde gösterilecek karakter yok. - This point forward Bu noktadan itibaren - The date it is today Bugünün tarihi - Trademark Ticari Marka - Type the field's name and its switches, for example PAGE \\* roman. Alanın adını ve anahtarlarını yazın, örneğin PAGE \\* roman. - Width and spacing Genişlik ve aralık - from Unicode (hex) Unicode'dan (onaltılık) Shape Effects Şekil Efektleri Glow Parlama Soft Edges Yumuşak Kenarlar @@ -1292,43 +1527,12 @@ The password is removed. Save the document to keep the change. Parola kaldırıldı. Değişikliği korumak için belgeyi kaydedin. The passwords don't match. Parolalar eşleşmiyor. Keep the password somewhere safe: without it nobody, WordCraft included, can open the document. Leave both boxes empty to remove the password. Parolayı güvenli bir yerde saklayın: parola olmadan WordCraft dahil hiç kimse belgeyi açamaz. Parolayı kaldırmak için iki kutuyu da boş bırakın. - Align Top Left Sol Üste Hizala - Align Top Center Üst Ortaya Hizala - Align Top Right Sağ Üste Hizala - Align Center Left Sol Ortaya Hizala - Align Center Ortaya Hizala - Align Center Right Sağ Ortaya Hizala - Align Bottom Left Sol Alta Hizala - Align Bottom Center Alt Ortaya Hizala - Align Bottom Right Sağ Alta Hizala - {contributors} contributors · {commits} commits {contributors} katkıda bulunan · {commits} commit {cols}×{rows} Table {cols}×{rows} Tablo - AaBbYyZz AaBbYyZz ĞğİıŞş - pt pt - Document1 Belge1 - Username Kullanıcı adı - Display name Görünen ad - Real name Gerçek ad Name Ad PRs PR'lar - Commits Commit'ler First İlk Last Son - Model Model - Models Modeller - Version Sürüm - % of all commits Commit payı (%) - Lines +/− Satır +/− - Craft Zanaat - Studio Stüdyo - Atelier Atölye - Darkroom Karanlık Oda - Gallery Galeri - Sketchbook Eskiz Defteri Change Picture… Resmi Değiştir… - Font… Yazı Tipi… - Paragraph… Paragraf… - Link… Bağlantı… Size and Crop… Boyut ve Kırpma… Insert Columns to the Left Sola Sütun Ekle Insert Columns to the Right Sağa Sütun Ekle @@ -1342,13 +1546,170 @@ South Güney West Batı Total Toplam + +# Table Layout › Draw (#308) + Draw\nTable Tablo\nÇiz + +# Define New Multilevel List and Track Changes Options (#342) + (no style) (stil yok) + Add tab stop at: Sekme durağı ekle: + Aligned at: Hizalama konumu: + Balloons Balonlar + Bullet Madde İşareti + By author Yazara göre + Changed lines: Değişen satırlar: + Click level to modify: Değiştirilecek düzeye tıklayın: + Color only Yalnızca renk + Define New Multilevel List Yeni Çok Düzeyli Liste Tanımla + Define New Multilevel List… Yeni Çok Düzeyli Liste Tanımla… + Deletions: Silmeler: + Enter formatting for number: Numara biçimini girin: + Follow number with: Numaradan sonra: + Formatting Biçimlendirme + Include level number from: Şu düzeyin numarasını ekle: + Insertions and Deletions Eklemeler ve Silmeler + Insertions: Eklemeler: + Left border Sol kenarlık + Legal style numbering Hukuki stilde numaralandırma + Link level to style: Düzeyi stile bağla: + List Definition Liste Tanımı + Nothing Hiçbiri + Number alignment: Numara hizalaması: + Number format Numara biçimi + Number style for this level: Bu düzeyin numara stili: + Outside border Dış kenarlık + Restart list after: Listeyi şundan sonra yeniden başlat: + Right border Sağ kenarlık + Show all revisions inline Tüm düzeltmeleri satır içinde göster + Show only comments and formatting in balloons Balonlarda yalnızca yorumları ve biçimlendirmeyi göster + Show revisions in balloons Düzeltmeleri balonlarda göster + Space Boşluk + Start at: Başlangıç değeri: + Tab character Sekme karakteri + Text indent at: Metin girintisi: + Track Changes Options Değişiklikleri İzleme Seçenekleri + Track formatting Biçimlendirmeyi izle + +# Rotation and flips (#332) + Flip Horizontal Yatay Çevir + Flip Vertical Dikey Çevir + Rotate Left 90° Sola 90° Döndür + Rotate Right 90° Sağa 90° Döndür Rotation Döndürme Rotation: Döndürme: - Rotate Right 90° Sağa 90° Döndür - Rotate Left 90° Sola 90° Döndür - Flip Vertical Dikey Çevir - Flip Horizontal Yatay Çevir + +# Tabs, Borders and Shading (#327); Columns, Symbol and Field (#335) + Tab stop position: Sekme durağı konumu: + Default tab stops: Varsayılan sekme durakları: + Tab stops to be cleared: Temizlenecek sekme durakları: + All Tümü + Decimal Ondalık + Leader Dolgu karakteri + Set Ayarla + Page Border Sayfa Kenarlığı + Apply to: Uygula: + Whole document Tüm belge + This section Bu bölüm + Fill Dolgu + Setting: Ayar: + 3-D 3B + Custom Özel + Style: Stil: + Dot dash Nokta çizgi + Triple Üçlü + Between Paragraflar arası + Borders and Shading Kenarlıklar ve Gölgelendirme + Tabs… Sekmeler… + Borders and Shading… Kenarlıklar ve Gölgelendirme… + (All) (Tümü) + (none) (yok) + (normal text) (normal metin) + 1/4 Em Space 1/4 Em Boşluk + A mail merge field Bir posta birleştirme alanı + A numbered sequence (figures, tables…) Numaralı bir dizi (şekiller, tablolar…) + A table of contents Bir içindekiler tablosu + Categories: Kategoriler: + Character Karakter + Character code: Karakter kodu: + Col #: Sütun no.: + Copyright Telif Hakkı + Date and Time Tarih ve Saat + Date formats: Tarih biçimleri: + Description: Açıklama: + Document Information Belge Bilgileri + Double Closing Quote Çift Kapanış Tırnağı + Double Opening Quote Çift Açılış Tırnağı + Ellipsis Üç Nokta + Em Dash Uzun Tire + En Dash Orta Tire + Em Space Em Boşluk + En Space En Boşluk + Equal column width Eşit sütun genişliği + Field Codes Alan Kodları + Field code: Alan kodu: + Field codes: Alan kodları: + Field name: Alan adı: + Field names: Alan adları: + Field properties Alan özellikleri + Field… Alan… + Format: Biçim: + Hide Codes Kodları Gizle + Index and Tables Dizin ve Tablolar + Line between Sütunlar arasında çizgi + Links and References Bağlantılar ve Başvurular + Mail Merge Posta Birleştirme + More Columns… Diğer Sütunlar… + More Symbols… Diğer Semboller… + No-Width Non Break Sıfır Genişlikli Bölünmez + No-Width Optional Break Sıfır Genişlikli İsteğe Bağlı Bölme + Paragraph Mark Paragraf İşareti + Pick a field from the list Listeden bir alan seçin + Preserve formatting during updates Güncellemelerde biçimlendirmeyi koru + Presets Hazır ayarlar + Recently used symbols: Son kullanılan semboller: + Registered Tescilli Marka + Section Mark Bölüm İşareti + Sequence name: Dizi adı: + Shortcut key: Kısayol tuşu: + Single Closing Quote Tek Kapanış Tırnağı + Single Opening Quote Tek Açılış Tırnağı + Spacing: Aralık: + Special Characters Özel Karakterler + Subset: Alt küme: + The time right now Şu anki saat + When this document was first created Belgenin ilk oluşturulduğu tarih + When this document was most recently printed Belgenin en son yazdırıldığı tarih + When this document was most recently saved Belgenin en son kaydedildiği tarih + The document has no bookmarks yet. Belgede henüz yer işareti yok. + Who the document properties name as author Belge özelliklerinde yazar olarak geçen kişi + Name of the file this document is saved as Belgenin kaydedildiği dosyanın adı + Title from the document properties Belge özelliklerindeki başlık + How many pages the whole document has Belgenin toplam sayfa sayısı + How many pages this section has Bu bölümün sayfa sayısı + Number of the page the field sits on Alanın bulunduğu sayfanın numarası + Number of the section the field sits in Alanın bulunduğu bölümün numarası + How many words the document holds Belgedeki sözcük sayısı + Page on which a bookmarked passage falls Yer işaretli metnin bulunduğu sayfa + Copy of the text a bookmark marks Yer işaretinin kapsadığı metnin kopyası + This field has no options. Bu alanın seçeneği yok. + This font has no characters to show. Bu yazı tipinde gösterilecek karakter yok. + This point forward Bu noktadan itibaren + The date it is today Bugünün tarihi + Trademark Ticari Marka + Type the field's name and its switches, for example PAGE \\* roman. Alanın adını ve anahtarlarını yazın, örneğin PAGE \\* roman. + Width and spacing Genişlik ve aralık + from Unicode (hex) Unicode'dan (onaltılık) + +# Equation › Convert and Justification, and the hyphenation menu's checked None. + All - Linear Tümü - Doğrusal + All - Professional Tümü - Profesyonel + Current - Linear Geçerli - Doğrusal + Current - Professional Geçerli - Profesyonel + Centered as Group Grup Olarak Ortalanmış + ✓ None ✓ Yok Formatted Biçimlendirildi + Interface size: Arabirim boyutu: + Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Şeridi, menüleri ve iletişim kutularını sisteminizin ekran ölçeklendirmesine ek olarak büyütür veya küçültür. Belge yakınlaştırması ayrıdır. Deleted Silindi Copyright Sign Telif Hakkı İşareti Demote Düzey Düşür @@ -1361,9 +1722,7 @@ Insert Date Field Tarih Alanı Ekle Insert Page Field Sayfa Alanı Ekle Insert Time Field Saat Alanı Ekle - Interface size: Arabirim boyutu: Lock Fields Alanları Kilitle - Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Şeridi, menüleri ve iletişim kutularını sisteminizin ekran ölçeklendirmesine ek olarak büyütür veya küçültür. Belge yakınlaştırması ayrıdır. Move Paragraph Down Paragrafı Aşağı Taşı Move Paragraph Up Paragrafı Yukarı Taşı Next Field Sonraki Alan @@ -1378,7 +1737,6 @@ Trademark Sign Ticari Marka İşareti Unlink Fields Alan Bağlantılarını Kaldır Unlock Fields Alanların Kilidini Aç - Draw\nTable Tablo\nÇiz Customize Keyboard Klavyeyi Özelleştir Choose a command Bir komut seçin Choose a shortcut Bir kısayol seçin @@ -1420,7 +1778,6 @@ Enlarge symbol Simgeyi büyüt Enclosure Çerçeve Circle Daire - Square Kare Base text: Ana metin: Distribute letters Harfleri dağıt Distribute spacing Aralığı dağıt @@ -1499,7 +1856,6 @@ Set Date Tarihi Ayarla Remove Content Control İçerik Denetimini Kaldır Content Control Properties İçerik Denetimi Özellikleri - Title Başlık Tag Etiket Placeholder text Yer tutucu metin Can't be deleted Silinemez @@ -1527,7 +1883,6 @@ 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: @@ -1552,7 +1907,6 @@ 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 @@ -1635,7 +1989,6 @@ Canvas Member Tuval Öğesi Choose the sheet that holds the recipients. Alıcıları içeren sayfayı seçin. First row of data contains column headers Verinin ilk satırı sütun başlıklarını içerir - Arrows Oklar Communication İletişim Office Ofis People İnsanlar diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 7743cf578..46603f75e 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Hungarian interface added; previously minor: Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Turkish interface catalog completed, 2,325 entries, #774; previously minor: font list shows fonts by their names in the interface language, #505; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Interface languages, script support and proofing per language. How translation works (catalog format, adding a language, clean-room rule): `crates/ui-egui/src/i18n/mod.rs`. @@ -21,7 +21,7 @@ native-speaker review (human). - **18 interface languages**: English plus `zh-hans`, `zh-hant`, `ja`, `uk`, `es`, `pt-br`, Serbian `sr`/`sr-latn` (#250), Estonian `et`, Turkish `tr`, Russian `ru`, Italian `it` (#594), German `de` (#574), Polish `pl`, Czech `cs`, Hungarian `hu` and French `fr`. Catalogs have 2,021–2,419 entries - each; Polish has every key of every other catalog except the style names and equation gallery labels only Russian, Italian and Hungarian translate so far (a test checks it). A test (`every_tab_and_command_is_translated`) enforces that every ribbon tab, command + each; Polish has every key of every other catalog except the style names and equation gallery labels only Russian, Italian, Hungarian and Turkish translate so far (a test checks it). A test (`every_tab_and_command_is_translated`) enforces that every ribbon tab, command label and ribbon location is translated in every language. `pl`, `pl-PL` and `pl_PL.UTF-8` system locales pick Polish automatically. - Of the 675 English strings a scan extracts from `tl!(…)` calls and command labels/locations, each @@ -62,8 +62,8 @@ Cyrillic and Latin (`sr`, `sr-latn`, #250), Estonian (`et`), Turkish (`tr`), Rus Estonian uses the existing fonts, follows `et-EE` system locales, and is available in Options or through `ui.language` with `{"value":"et"}`. Its 1,307 entries cover the existing interface catalog. Estonian spelling, grammar, dates and document templates are not added by the interface catalog. -Turkish works the same way (`tr-TR` and `tr-CY` system locales, `{"value":"tr"}`) with all 1,307 -entries. Command search folds the Turkish dotted and dotless I (`italik` finds İtalik, `YAZI TİPİ` +Turkish works the same way (`tr-TR` and `tr-CY` system locales, `{"value":"tr"}`) with all 2,325 +entries, the same keys as the Russian catalog. Command search folds the Turkish dotted and dotless I (`italik` finds İtalik, `YAZI TİPİ` finds Yazı Tipi) while English labels and ASCII command ids keep their usual matching. Turkish proofing is not included; terminology and checks: [`localization-tr.md`](localization-tr.md). Hungarian follows `hu-HU` system locales and `{"value":"hu"}`, with a complete catalog of 2,419 entries: every key of @@ -105,6 +105,7 @@ allows); all other text is written from the meaning of the English. No Italian p | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Turkish interface catalog completed (2,325 entries, the same keys as Russian): equation gallery, built-in style names, watermarks, paper sizes and preview tiles; the interface audit checks Turkish too (#774) | | 2026-10-11 | minor | Czech (`cs`) interface catalog added | | 2026-10-11 | minor | Polish interface (13 languages) and Polish proofing: SJP.PL spelling with suggestions, Polish grammar rules, TeX Polish hyphenation; proofing language per run; dimension ~25% → ~27% | | 2026-10-11 | minor | German interface catalog (#574): 2,021 entries covering the full zh-hans key set; locale, saved-preference and bundled-font tests; no German proofing | diff --git a/docs/localization-tr.md b/docs/localization-tr.md index d4630d8d2..e8b9f4160 100644 --- a/docs/localization-tr.md +++ b/docs/localization-tr.md @@ -1,6 +1,6 @@ # Turkish interface -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (Turkish interface catalog and regression checks) · **Target:** WordCraft 0.5.0 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (Turkish interface catalog completed, 2,325 entries, #774; previously minor: Turkish interface catalog and regression checks) · **Target:** WordCraft 0.5.0 Choose **File › Options › Interface language › Türkçe**, or follow a Turkish system display language (`tr-TR`, `tr-CY`, `tr_TR.UTF-8`). The catalog code is `tr`; `ui.language` with `{"value":"tr"}` @@ -8,7 +8,9 @@ selects it explicitly, and `WORDCRAFT_LANGUAGE=tr-TR` does the same for a headle launch while the preference is `auto`. The choice is saved with the other interface settings. Document text, command ids, CLI/MCP/control responses and file paths do not change. -`crates/ui-egui/src/i18n/tr.tsv` has all 1,307 keys of the Spanish catalog. It is written from the +`crates/ui-egui/src/i18n/tr.tsv` has all 2,325 entries of the Russian catalog (the same +keys). It covers all ribbon commands, dialogs, built-in style names, equation and math gallery +structures, watermark presets, paper sizes, and rendered preview tiles. It is written from the meaning of the English labels; no proprietary translation resources were consulted. The catalog format has no plural forms, and Turkish doesn't need them here: a noun after a number stays singular (`0 sözcük`, `1 sözcük`, `21 sözcük`). Placeholders, `\n` breaks in ribbon labels and dialog @@ -44,12 +46,13 @@ still work. Other interface languages and document search are unchanged. ## Verification and remaining scope `cargo test -p wordcraft-ui-egui --lib i18n::tests` covers Turkish locale tags and preference order, -saving and restoring `tr`, unknown preferences, unchanged document content, key parity with -Spanish, placeholders, count wording, the font sample's capitals, English fallback, and the glyphs -`ÇçĞğİıÖöŞşÜü` in the bundled Inter (Regular, Medium, SemiBold) and JetBrains Mono. A dialog test -renders real command-search results in Turkish and English, including dotted and dotless I and -ASCII command ids. The shared catalog checks (validation, every tab and command translated) include -Turkish automatically. +saving and restoring `tr`, unknown preferences, unchanged document content, full key parity with +the Russian catalog (and superset of Spanish and Chinese), placeholders, count wording, the font +sample's capitals, English fallback, and the glyphs `ÇçĞğİıÖöŞşÜü` in the bundled Inter (Regular, +Medium, SemiBold) and JetBrains Mono. A dialog test renders real command-search results in Turkish +and English, including dotted and dotless I and ASCII command ids. The shared catalog checks +(validation, every tab and command translated, and interface audit strings) include Turkish +automatically. Spelling, grammar and hyphenation stay English-only: this is an interface translation, not Turkish proofing. A native-speaker skim of the catalog is welcome. @@ -58,4 +61,5 @@ proofing. A native-speaker skim of the catalog is welcome. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Turkish catalog completed (2,325 entries, #774), covering all math structures, built-in styles, presets and preview tiles | | 2026-10-11 | minor | Turkish catalog (1,307 entries), terminology, scope and regression checks | From 64319e0039e49a608a257ae0925ff2483695c78a Mon Sep 17 00:00:00 2001 From: Isaac Levine <80065258+isaac-levine@users.noreply.github.com> Date: Mon, 12 Oct 2026 07:18:00 -0400 Subject: [PATCH 38/88] MCP: confine file access with --automation-read-root and --automation-write-root (#769) (#798) * Engine: check automation read and write roots on every path a command reads or writes (#769) * MCP: confine the headless session to the automation roots (#769) * CLI: add --automation-read-root and --automation-write-root to mcp (#769) * Docs: document --automation-read-root and --automation-write-root for mcp (#769) * Review fixes for #798: confine file.printPdf; guard all of engine/src - print.rs::print_pdf_to_file (file.printPdf, added on main) wrote with std::fs::write directly, so a confined agent could print a PDF anywhere. It now goes through io::write_file, which checks the automation write root. - The guard test now walks every file under crates/engine/src (not only cmd/), except io.rs, file_access.rs and the tests*.rs modules, and also flags OpenOptions. - Tests: an engine test that file.printPdf is refused outside the write root, works inside and is unchanged without roots; the MCP integration test covers it too. Co-Authored-By: Claude Opus 5.5 * Review fixes for #798: list file.printPdf among the confined writes in docs/mcp.md Co-Authored-By: Claude Opus 5.5 * Font files added by path go through io, inside the automation read root (#769) --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- README.md | 1 + apps/wordcraft-cli/src/automation_args.rs | 90 ++++ apps/wordcraft-cli/src/main.rs | 103 ++++- crates/engine/src/cmd/file.rs | 3 +- crates/engine/src/cmd/font.rs | 10 +- crates/engine/src/cmd/insert.rs | 10 +- crates/engine/src/cmd/mailings.rs | 8 +- crates/engine/src/cmd/objects.rs | 10 +- crates/engine/src/file_access.rs | 480 ++++++++++++++++++++++ crates/engine/src/io.rs | 45 +- crates/engine/src/lib.rs | 1 + crates/engine/src/print.rs | 30 +- crates/mcp/src/lib.rs | 30 +- crates/mcp/tests/automation_roots.rs | 209 ++++++++++ docs/mcp.md | 54 +++ 15 files changed, 1038 insertions(+), 46 deletions(-) create mode 100644 apps/wordcraft-cli/src/automation_args.rs create mode 100644 crates/engine/src/file_access.rs create mode 100644 crates/mcp/tests/automation_roots.rs diff --git a/README.md b/README.md index 0833ed4c7..77d25a38c 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,7 @@ WordCraft was designed to be driven by people *and* by AI agents. ```sh claude mcp add wordcraft -- wordcraft-cli mcp # headless documents +# confine the agent to folders: wordcraft-cli mcp --automation-read-root DIR --automation-write-root DIR wordcraft --control 7981 & # or drive the running app… claude mcp add wordcraft-app -- wordcraft-cli mcp --connect 127.0.0.1:7981 ``` diff --git a/apps/wordcraft-cli/src/automation_args.rs b/apps/wordcraft-cli/src/automation_args.rs new file mode 100644 index 000000000..60fa0e30a --- /dev/null +++ b/apps/wordcraft-cli/src/automation_args.rs @@ -0,0 +1,90 @@ +//! Parse `--automation-read-root` / `--automation-write-root` before the server starts. A malformed +//! flag must not silently remove a root and leave the server unconfined. + +use std::path::PathBuf; + +pub struct AutomationArgs { + pub read_root: Option, + pub write_root: Option, + read_given: bool, + write_given: bool, +} + +impl AutomationArgs { + pub fn new(read_root: Option, write_root: Option) -> Self { + Self { read_root, write_root, read_given: false, write_given: false } + } + + /// Returns whether this argument is a root flag. A second occurrence of a flag is an error, + /// including when the two spellings (`--flag DIR`, `--flag=DIR`) are mixed. + pub fn consume(&mut self, arg: &str, args: &mut impl Iterator) -> Result { + let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value))); + let (root, given) = match flag { + "--automation-read-root" => (&mut self.read_root, &mut self.read_given), + "--automation-write-root" => (&mut self.write_root, &mut self.write_given), + _ => return Ok(false), + }; + if *given { + return Err(format!("{flag} given twice")); + } + let value = match inline { + Some(value) => value.to_owned(), + None => { + let value = args.next().ok_or_else(|| format!("{flag} needs a folder"))?; + if value.starts_with('-') { + return Err(format!("{flag} needs a folder; use {flag}= for a folder starting with '-'")); + } + value + } + }; + if value.is_empty() { + return Err(format!("{flag} needs a non-empty folder")); + } + *root = Some(value.into()); + *given = true; + Ok(true) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roots_are_taken_once_and_other_arguments_are_left() { + let mut roots = AutomationArgs::new(None, None); + let mut args = ["a folder".to_owned(), "report.docx".to_owned()].into_iter(); + assert!(roots.consume("--automation-read-root", &mut args).unwrap()); + assert!(roots.consume("--automation-write-root=out", &mut args).unwrap()); + assert!(!roots.consume("--author", &mut args).unwrap()); + assert_eq!(roots.read_root, Some("a folder".into())); + assert_eq!(roots.write_root, Some("out".into())); + assert_eq!(args.next().as_deref(), Some("report.docx")); + assert!(roots.consume("--automation-read-root=other", &mut args).is_err()); + assert_eq!(roots.read_root, Some("a folder".into())); + } + + #[test] + fn invalid_operands_leave_roots_unchanged() { + for flag in ["--automation-read-root", "--automation-write-root"] { + for operand in [None, Some(""), Some("--version"), Some("--connect"), Some("-folder")] { + let mut roots = AutomationArgs::new(Some("read".into()), Some("write".into())); + let mut args = operand.map(str::to_owned).into_iter(); + assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}"); + assert_eq!(roots.read_root, Some("read".into())); + assert_eq!(roots.write_root, Some("write".into())); + } + let mut roots = AutomationArgs::new(None, None); + assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err()); + } + } + + #[test] + fn explicit_dash_folder_and_relative_dash_folder_are_valid() { + let mut roots = AutomationArgs::new(None, None); + assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap()); + assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap()); + assert_eq!(roots.read_root, Some("-folder".into())); + assert_eq!(roots.write_root, Some("./-folder".into())); + } +} diff --git a/apps/wordcraft-cli/src/main.rs b/apps/wordcraft-cli/src/main.rs index d3355ec5e..a0a2d5f23 100644 --- a/apps/wordcraft-cli/src/main.rs +++ b/apps/wordcraft-cli/src/main.rs @@ -1,6 +1,8 @@ //! `wordcraft-cli`: WordCraft from the command line. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +mod automation_args; + use std::fmt; use std::io::Write; use std::process::ExitCode; @@ -25,8 +27,14 @@ USAGE: who tracked changes and comments are by) wordcraft-cli commands [--json] list every command wordcraft-cli parity [--markdown] feature-catalog parity - wordcraft-cli mcp [--connect HOST:PORT | --author NAME] - MCP server on stdio (headless, or bridged to the app) + wordcraft-cli mcp [--connect HOST:PORT | --author NAME] [--automation-read-root DIR] [--automation-write-root DIR] + MCP server on stdio (headless, or bridged to the app). + --automation-read-root / --automation-write-root confine + the files the agent's commands read (open, insert text + or picture, mail-merge data, templates…) and write (save, + export, merge output…) to DIR, links followed; a root left + out grants none of its access. They apply to the headless + server and are an error with --connect. wordcraft-cli zotero [--cmd 'id={json}' …] [--save OUT] [--trace] [--port P] run a Zotero command on a document (Zotero must be running): addEditCitation, addEditBibliography, @@ -76,6 +84,26 @@ fn validate_options(command: &str, args: &[String], specs: &[OptionSpec]) -> Res Ok(()) } +/// Take `--automation-read-root` / `--automation-write-root` (each `--flag DIR` or `--flag=DIR`, +/// once) out of `args`, and open the roots, which must be existing folders. Returns the arguments +/// that are left. A malformed root flag is an error, never a root silently left out. +fn automation_roots( + command: &str, + args: &[String], +) -> Result<(Option>, Vec), String> { + let mut found = automation_args::AutomationArgs::new(None, None); + let mut left = Vec::new(); + let mut it = args.iter().cloned(); + while let Some(arg) = it.next() { + if !found.consume(&arg, &mut it).map_err(|e| format!("{command}: {e}"))? { + left.push(arg); + } + } + let roots = wordcraft_engine::file_access::AutomationRoots::new(found.read_root.as_deref(), found.write_root.as_deref()) + .map_err(|e| format!("{command}: {e}"))?; + Ok((roots, left)) +} + const NO_OPTIONS: &[OptionSpec] = &[]; const CONVERT_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--markup", takes_value: false }]; const RENDER_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--page", takes_value: true }, OptionSpec { name: "--scale", takes_value: true }]; @@ -265,9 +293,17 @@ fn run(args: &[String]) -> Result<(), Failure> { } } "mcp" => { + let (roots, rest) = automation_roots("mcp", &rest)?; validate_options("mcp", &rest, MCP_OPTIONS)?; let author = arg_value(&rest, "--author"); - let backend: Box = match arg_value(&rest, "--connect") { + let connect = arg_value(&rest, "--connect"); + if roots.is_some() && connect.is_some() { + return Err( + "mcp: --automation-read-root and --automation-write-root confine the headless server; a running app is not confined by them" + .into(), + ); + } + let backend: Box = match connect { // The app's user name is the person's own setting; don't rewrite it from a flag. Some(_) if author.is_some() => { return Err("mcp: --author applies to the headless server; with --connect, run file.setAuthor".into()); @@ -281,6 +317,13 @@ fn run(args: &[String]) -> Result<(), Failure> { } None => { let mut headless = wordcraft_mcp::Headless::default(); + if let Some(roots) = roots { + // Every thread of this process, not only the server's: nothing reaches past the roots. + wordcraft_engine::file_access::confine_process(roots.clone()); + let show = |root: Option<&std::path::Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string()); + eprintln!("wordcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root())); + headless = headless.with_automation_roots(Some(roots)); + } if let Some(name) = author { set_author(&mut headless.session, &name)?; } @@ -428,4 +471,58 @@ mod tests { // The app's user name is the person's own; a bridge flag must not rewrite it. assert!(run(&args(&["mcp", "--connect", "127.0.0.1:9", "--author", "Claude"])).is_err_and(|e| e.to_string().contains("--author"))); } + + #[test] + fn automation_root_flags_are_taken_out_of_the_mcp_arguments() { + let base = std::env::temp_dir().join(format!("wc-cli-roots-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(base.join("in")).unwrap(); + std::fs::create_dir_all(base.join("out")).unwrap(); + let (read, write) = (base.join("in").to_string_lossy().into_owned(), base.join("out").to_string_lossy().into_owned()); + + let (roots, left) = + automation_roots("mcp", &args(&["--automation-read-root", &read, "--author", "Claude", &format!("--automation-write-root={write}")])) + .unwrap(); + let roots = roots.unwrap(); + assert_eq!(left, args(&["--author", "Claude"])); + assert!(roots.read_root().is_some() && roots.write_root().is_some()); + assert!(validate_options("mcp", &left, MCP_OPTIONS).is_ok()); + + // A root left out grants none of its access. + let (roots, _) = automation_roots("mcp", &args(&["--automation-read-root", &read])).unwrap(); + let roots = roots.unwrap(); + assert!(roots.write_root().is_none()); + assert!( + roots.check(&format!("{write}/x.docx"), wordcraft_engine::file_access::Access::Write).unwrap_err().contains("write authority is absent") + ); + + // No flags: nothing is confined and the arguments pass through. + let (roots, left) = automation_roots("mcp", &args(&["--connect", "127.0.0.1:9"])).unwrap(); + assert!(roots.is_none()); + assert_eq!(left, args(&["--connect", "127.0.0.1:9"])); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn automation_root_flags_reject_bad_operands_and_missing_folders() { + for bad in [ + args(&["--automation-read-root"]), + args(&["--automation-read-root", "--author"]), + args(&["--automation-write-root="]), + args(&["--automation-read-root", "a", "--automation-read-root=b"]), + ] { + assert!(automation_roots("mcp", &bad).is_err(), "{bad:?}"); + } + let e = automation_roots("mcp", &args(&["--automation-read-root", "/definitely/not/a/folder"])).err().unwrap(); + assert!(e.starts_with("mcp: cannot open the automation read root"), "{e}"); + // Through the command line: an error, not a server that quietly runs unconfined. + assert!(run(&args(&["mcp", "--automation-write-root", "/definitely/not/a/folder"])).is_err()); + } + + #[test] + fn automation_roots_are_an_error_with_connect() { + let dir = std::env::temp_dir().to_string_lossy().into_owned(); + let e = run(&args(&["mcp", "--connect", "127.0.0.1:9", "--automation-read-root", &dir])).unwrap_err().to_string(); + assert!(e.contains("--automation-read-root") && e.contains("headless"), "{e}"); + } } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 473cf0b09..e716df5bb 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -233,8 +233,7 @@ fn export_png(s: &mut Session, v: &Value) -> CmdResult { let pg = l.pages.get(page).ok_or_else(|| CmdError::Params(format!("no page {}", page + 1)))?; let img = wordcraft_render::render_page(&s.doc, pg, scale, &Default::default()); let png = img.to_png(); - #[cfg(not(target_arch = "wasm32"))] - std::fs::write(path, &png).map_err(|e| CmdError::Failed(format!("{path}: {e}")))?; + crate::io::write_file(path, &png).map_err(CmdError::Failed)?; Ok(json!({"path": path, "width": img.width, "height": img.height, "bytes": png.len()})) } diff --git a/crates/engine/src/cmd/font.rs b/crates/engine/src/cmd/font.rs index 703a86858..bcdee205d 100644 --- a/crates/engine/src/cmd/font.rs +++ b/crates/engine/src/cmd/font.rs @@ -33,14 +33,8 @@ fn font_json(f: &UserFont) -> Value { /// 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}"))) + // Through `io`, which keeps automation inside its read root (#769). + crate::io::read_file_capped(path, wordcraft_fonts::MAX_FONT_FILE_BYTES as u64, "font file").map_err(CmdError::Failed) } fn add(s: &mut Session, v: &Value) -> CmdResult { diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 9d84e5f81..9a874410b 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -187,15 +187,7 @@ fn picture(s: &mut Session, v: &Value) -> CmdResult { let bytes = if let Some(d) = p::str(v, "data") { base64_decode(d).ok_or_else(|| CmdError::Params("bad base64 data".into()))? } else if let Some(path) = p::str(v, "path") { - #[cfg(not(target_arch = "wasm32"))] - { - std::fs::read(path).map_err(|e| CmdError::Failed(format!("{path}: {e}")))? - } - #[cfg(target_arch = "wasm32")] - { - let _ = path; - return Err(CmdError::Failed("reading files by path isn't available on the web".into())); - } + crate::io::read_file(path).map_err(CmdError::Failed)? } else { s.ui_requests.push(json!({"open": "insertPicture"})); return sel_result(s); diff --git a/crates/engine/src/cmd/mailings.rs b/crates/engine/src/cmd/mailings.rs index ba6611619..d8f7891c6 100644 --- a/crates/engine/src/cmd/mailings.rs +++ b/crates/engine/src/cmd/mailings.rs @@ -320,14 +320,8 @@ fn extension(path: &str) -> Option { std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()) } -#[cfg(not(target_arch = "wasm32"))] fn read_file(path: &str) -> Result, CmdError> { - std::fs::read(path).map_err(|e| CmdError::Failed(format!("{path}: {e}"))) -} - -#[cfg(target_arch = "wasm32")] -fn read_file(_path: &str) -> Result, CmdError> { - Err(CmdError::Failed("paths aren't available on the web; pass `data`".into())) + crate::io::read_file(path).map_err(CmdError::Failed) } /// Field names and records from a table's rows: with `headers`, the first row names the fields diff --git a/crates/engine/src/cmd/objects.rs b/crates/engine/src/cmd/objects.rs index fc658de12..11493302c 100644 --- a/crates/engine/src/cmd/objects.rs +++ b/crates/engine/src/cmd/objects.rs @@ -203,15 +203,7 @@ pub fn specs() -> Vec { let bytes = if let Some(d) = p::str(v, "data") { super::insert::base64_decode(d).ok_or_else(|| CmdError::Params("bad base64".into()))? } else if let Some(path) = p::str(v, "path") { - #[cfg(not(target_arch = "wasm32"))] - { - std::fs::read(path).map_err(|e| CmdError::Failed(format!("{path}: {e}")))? - } - #[cfg(target_arch = "wasm32")] - { - let _ = path; - return Err(CmdError::Failed("paths aren't available on the web".into())); - } + crate::io::read_file(path).map_err(CmdError::Failed)? } else { return Err(CmdError::Params("`path` or `data` required".into())); }; diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs new file mode 100644 index 000000000..6744e7b46 --- /dev/null +++ b/crates/engine/src/file_access.rs @@ -0,0 +1,480 @@ +//! The folders automation may read and write (#769). +//! +//! `wordcraft-cli mcp --automation-read-root --automation-write-root ` confines the +//! files an agent's commands reach. [`AutomationRoots`] holds the two folders. While they are in force, every path a command +//! reads goes through [`check_read`] and every path it writes through [`check_write`]: +//! +//! - The path is made absolute against the working directory, as the file system reads it. Its +//! links are followed: for a file that doesn't exist yet, the links of its nearest existing +//! folder, the rest of the path being plain names (`..` below a missing folder is refused, and so +//! is a link that leads nowhere). +//! - The result must lie inside the root, compared by whole components (`/work2` isn't inside +//! `/work`), without regard to case on Windows. +//! - Read and write access are separate: a root given alone grants only its own kind of access. +//! +//! Roots are in force on a thread inside [`confine`], and in the whole process after +//! [`confine_process`] (`wordcraft-cli mcp`), except inside [`unconfined`]. Without roots +//! nothing is checked. +//! +//! A path is checked, then opened: a process that can change the folders in between (swapping in +//! a link) can still win that race. The roots keep an agent to the files it was given; they don't +//! guard against other programs on the computer. + +use std::cell::RefCell; +use std::ffi::OsString; +use std::path::{Component, Path, PathBuf}; +use std::sync::{Arc, OnceLock}; + +/// Read access or write access. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Access { + Read, + Write, +} + +/// The folders automation may read and write; a root left out grants no access of its kind. +#[derive(Debug)] +pub struct AutomationRoots { + read: Option, + write: Option, +} + +#[derive(Debug)] +struct Root { + /// The folder made absolute, links followed: what paths are compared with. + resolved: PathBuf, + /// The same without Windows' `\\?\` prefix, for people. + shown: PathBuf, +} + +impl Root { + /// The folder at `path`, which must exist. + fn open(path: &Path, access: Access) -> Result { + let kind = match access { + Access::Read => "read", + Access::Write => "write", + }; + let resolved = std::fs::canonicalize(path) + .and_then(|p| if p.is_dir() { Ok(p) } else { Err(std::io::ErrorKind::NotADirectory.into()) }) + .map_err(|e| format!("cannot open the automation {kind} root `{}`: {e}", path.display()))?; + Ok(Self { shown: plain(resolved.clone()), resolved }) + } +} + +impl AutomationRoots { + /// The roots `read` and `write`, which must be existing folders; `None` when neither is given + /// (nothing is confined then). + pub fn new(read: Option<&Path>, write: Option<&Path>) -> Result>, String> { + if read.is_none() && write.is_none() { + return Ok(None); + } + let read = read.map(|p| Root::open(p, Access::Read)).transpose()?; + let write = write.map(|p| Root::open(p, Access::Write)).transpose()?; + Ok(Some(Arc::new(Self { read, write }))) + } + + /// The read root, absolute with its links followed. + pub fn read_root(&self) -> Option<&Path> { + self.read.as_ref().map(|r| r.shown.as_path()) + } + + /// The write root, absolute with its links followed. + pub fn write_root(&self) -> Option<&Path> { + self.write.as_ref().map(|r| r.shown.as_path()) + } + + /// May the file or folder at `path` be read (or written)? `Err` says why not. + pub fn check(&self, path: &str, access: Access) -> Result<(), String> { + let root = match access { + Access::Read => self.read.as_ref(), + Access::Write => self.write.as_ref(), + }; + let Some(Root { resolved: folder, shown }) = root else { + return match access { + Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {path}")), + Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {path}")), + }; + }; + if inside(&resolve(path)?, folder) { + return Ok(()); + } + let root = shown.display(); + match access { + Access::Read => Err(format!("automation path rejected: outside the read root {root}: {path}")), + Access::Write => Err(format!("automation path rejected: outside the write root {root}: {path}")), + } + } +} + +/// Where the roots are in force on a thread ([`confine`], [`unconfined`]). +#[derive(Clone, Debug)] +enum Scope { + Confined(Arc), + Unconfined, +} + +thread_local! { + /// This thread's scope; `None`: the process's roots ([`confine_process`]). + static SCOPE: RefCell> = const { RefCell::new(None) }; +} + +/// The roots in force in every thread outside [`confine`] and [`unconfined`]. +static PROCESS: OnceLock> = OnceLock::new(); + +/// Put `roots` in force in the whole process, for good. `false` when roots were in force already +/// (they stay as they were). +pub fn confine_process(roots: Arc) -> bool { + PROCESS.set(roots).is_ok() +} + +/// Puts the scope it replaced back when dropped (also when `f` panics). +struct Restore(Option); + +impl Drop for Restore { + fn drop(&mut self) { + let previous = self.0.take(); + SCOPE.with(|s| *s.borrow_mut() = previous); + } +} + +fn with_scope(scope: Option, f: impl FnOnce() -> T) -> T { + let _restore = Restore(SCOPE.with(|s| s.replace(scope))); + f() +} + +/// Run `f` with `roots` in force on this thread (`None`: as things stand). +pub fn confine(roots: Option<&Arc>, f: impl FnOnce() -> T) -> T { + match roots { + Some(r) => with_scope(Some(Scope::Confined(r.clone())), f), + None => f(), + } +} + +/// Run `f` with no roots in force on this thread: what the person at the keyboard does, and the +/// app's own folders. +pub fn unconfined(f: impl FnOnce() -> T) -> T { + with_scope(Some(Scope::Unconfined), f) +} + +/// The roots in force on this thread, if any. +pub fn current() -> Option> { + match SCOPE.with(|s| s.borrow().clone()) { + Some(Scope::Confined(r)) => Some(r), + Some(Scope::Unconfined) => None, + None => PROCESS.get().cloned(), + } +} + +/// `path` without Windows' `\\?\` prefix (which `canonicalize` adds), for people to read. +fn plain(path: PathBuf) -> PathBuf { + if !cfg!(windows) { + return path; + } + let text = path.to_string_lossy(); + if let Some(rest) = text.strip_prefix(r"\\?\UNC\") { + return PathBuf::from(format!(r"\\{rest}")); + } + match text.strip_prefix(r"\\?\") { + Some(rest) => PathBuf::from(rest), + None => path, + } +} + +/// May `path` be read here? Always, without roots in force. +pub fn check_read(path: &str) -> Result<(), String> { + current().map_or(Ok(()), |r| r.check(path, Access::Read)) +} + +/// May `path` be written here? Always, without roots in force. +pub fn check_write(path: &str) -> Result<(), String> { + current().map_or(Ok(()), |r| r.check(path, Access::Write)) +} + +/// `path` made absolute with its links followed; for a path that doesn't exist (yet), its nearest +/// existing folder's links followed and the rest appended. +fn resolve(path: &str) -> Result { + if path.is_empty() { + return Err("automation path rejected: path is empty".into()); + } + // Checked as given: making `…\NUL` absolute gives the device path `\\.\NUL` on Windows. + if cfg!(windows) && Path::new(path).components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) { + return Err(format!("automation path rejected: reserved device names are not allowed: {path}")); + } + let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {path}: {e}"))?; + // The names below the nearest existing folder, last first. + let mut missing: Vec = vec![]; + let mut at = absolute.as_path(); + loop { + match std::fs::canonicalize(at) { + Ok(mut found) => { + found.extend(missing.iter().rev()); + return Ok(found); + } + Err(e) => match std::fs::symlink_metadata(at) { + Ok(m) if m.file_type().is_symlink() => return Err(format!("automation path rejected: a link that leads nowhere: {path}")), + // There, yet its real path can't be had (no permission): nothing to compare. + Ok(_) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")), + }, + } + let parent = at.parent(); + match (at.components().next_back(), parent) { + (Some(Component::Normal(name)), Some(parent)) => { + missing.push(name.to_os_string()); + at = parent; + } + (Some(Component::ParentDir), _) => { + return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {path}")); + } + // Down to the root (or drive) and it doesn't exist either. + _ => { + let e = std::io::Error::from(std::io::ErrorKind::NotFound); + return Err(format!("automation path rejected: cannot resolve {path}: {e}")); + } + } + } +} + +/// Is `path` (resolved) `root` or inside it, component by component (without regard to case on +/// Windows, whose file systems ignore it)? +fn inside(path: &Path, root: &Path) -> bool { + let mut path = path.components(); + root.components().all(|r| path.next().is_some_and(|p| same_component(p, r))) +} + +fn same_component(a: Component, b: Component) -> bool { + if a == b { + return true; + } + if !cfg!(windows) { + return false; + } + match (a.as_os_str().to_str(), b.as_os_str().to_str()) { + (Some(a), Some(b)) => a.to_lowercase() == b.to_lowercase(), + _ => false, + } +} + +/// A Windows device name (`CON`, `NUL`, `COM1`… also with an extension), which names no file. +fn is_device_name(component: &str) -> bool { + let stem = component.split('.').next().unwrap_or(component).trim_end_matches(' ').to_ascii_uppercase(); + let numbered = |prefix: &str| stem.strip_prefix(prefix).is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9")); + matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CLOCK$" | "CONIN$" | "CONOUT$") || numbered("COM") || numbered("LPT") +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A fresh folder holding `in` (read root), `out` (write root) and `outside`. + fn folders(name: &str) -> (PathBuf, Arc) { + let base = std::env::temp_dir().join(format!("wc-file-access-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + for d in ["in/sub", "out", "outside", "in2"] { + std::fs::create_dir_all(base.join(d)).unwrap(); + } + std::fs::write(base.join("in/a.docx"), "x").unwrap(); + std::fs::write(base.join("outside/secret.docx"), "x").unwrap(); + let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap().unwrap(); + (base, roots) + } + + fn s(p: PathBuf) -> String { + p.to_string_lossy().into_owned() + } + + #[test] + fn paths_inside_the_roots_pass_and_others_dont() { + let (base, roots) = folders("inside"); + assert_eq!(roots.check(&s(base.join("in/a.docx")), Access::Read), Ok(())); + assert_eq!(roots.check(&s(base.join("in")), Access::Read), Ok(()), "the root itself"); + assert_eq!(roots.check(&s(base.join("in/sub/new.docx")), Access::Read), Ok(()), "missing files are checked by their folder"); + assert_eq!(roots.check(&s(base.join("out/new/deeper/x.png")), Access::Write), Ok(()), "and by their nearest folder"); + let e = roots.check(&s(base.join("outside/secret.docx")), Access::Read).unwrap_err(); + assert!(e.starts_with("automation path rejected: outside the read root"), "{e}"); + // Read and write access are separate. + let e = roots.check(&s(base.join("in/a.docx")), Access::Write).unwrap_err(); + assert!(e.contains("outside the write root"), "{e}"); + assert!(roots.check(&s(base.join("out/x.png")), Access::Read).unwrap_err().contains("outside the read root")); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn a_sibling_with_the_same_prefix_is_outside() { + let (base, roots) = folders("prefix"); + std::fs::write(base.join("in2/b.docx"), "x").unwrap(); + assert!(roots.check(&s(base.join("in2/b.docx")), Access::Read).is_err(), "in2 is not inside in"); + assert!(roots.check(&format!("{}2/c.docx", s(base.join("in"))), Access::Read).is_err()); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn parent_traversal_is_resolved_or_refused() { + let (base, roots) = folders("dotdot"); + let sep = std::path::MAIN_SEPARATOR; + let up = format!("{}{sep}..{sep}outside{sep}secret.docx", s(base.join("in"))); + assert!(roots.check(&up, Access::Read).unwrap_err().contains("outside the read root"), "`..` out of the root"); + let back = format!("{}{sep}..{sep}a.docx", s(base.join("in/sub"))); + assert_eq!(roots.check(&back, Access::Read), Ok(()), "`..` that stays inside"); + let missing = format!("{}{sep}nope{sep}..{sep}..{sep}outside{sep}x.docx", s(base.join("out"))); + let e = roots.check(&missing, Access::Write).unwrap_err(); + // Windows resolves `..` by name first (so does opening the file there): then it's outside. + assert!(e.contains("parent traversal below a folder that doesn't exist") || e.contains("outside the write root"), "{e}"); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn relative_paths_resolve_against_the_working_directory() { + let (base, roots) = folders("relative"); + // The tests' working directory is the crate's folder: not inside the roots. + assert!(roots.check("a.docx", Access::Read).unwrap_err().contains("outside the read root")); + assert!(roots.check("", Access::Read).unwrap_err().contains("path is empty")); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn a_missing_root_is_an_error_and_none_is_no_confinement() { + let (base, _) = folders("missing"); + let e = AutomationRoots::new(Some(&base.join("nope")), None).unwrap_err(); + assert!(e.starts_with("cannot open the automation read root"), "{e}"); + let e = AutomationRoots::new(None, Some(&base.join("in/a.docx"))).unwrap_err(); + assert!( + e.starts_with("cannot open the automation write root") + && e.ends_with(&std::io::Error::from(std::io::ErrorKind::NotADirectory).to_string()), + "{e}" + ); + assert!(AutomationRoots::new(Some(Path::new("")), None).unwrap_err().starts_with("cannot open the automation read root ``")); + assert!(AutomationRoots::new(None, None).unwrap().is_none()); + // One root alone grants only its own access. + let read_only = AutomationRoots::new(Some(&base.join("in")), None).unwrap().unwrap(); + let e = read_only.check(&s(base.join("in/x.docx")), Access::Write).unwrap_err(); + assert!(e.contains("write authority is absent"), "{e}"); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn links_are_followed_before_comparing() { + let (base, roots) = folders("links"); + #[cfg(unix)] + let made = |target: &Path, link: &Path| std::os::unix::fs::symlink(target, link).is_ok(); + #[cfg(windows)] + let made = |target: &Path, link: &Path| { + let made = + if target.is_dir() { std::os::windows::fs::symlink_dir(target, link) } else { std::os::windows::fs::symlink_file(target, link) }; + made.is_ok() + }; + #[cfg(not(any(unix, windows)))] + let made = |_: &Path, _: &Path| false; + // Windows needs Developer Mode or elevation to make links: nothing to test without them. + if !made(&base.join("outside/secret.docx"), &base.join("in/leak.docx")) { + eprintln!("skipped: symbolic links can't be made here"); + let _ = std::fs::remove_dir_all(base); + return; + } + assert!(roots.check(&s(base.join("in/leak.docx")), Access::Read).unwrap_err().contains("outside the read root"), "a file link out"); + assert!(made(&base.join("outside"), &base.join("out/away"))); + let e = roots.check(&s(base.join("out/away/new.png")), Access::Write).unwrap_err(); + assert!(e.contains("outside the write root"), "a folder link out: {e}"); + assert!(made(&base.join("outside/gone.docx"), &base.join("out/dangling.png"))); + let e = roots.check(&s(base.join("out/dangling.png")), Access::Write).unwrap_err(); + assert!(e.contains("a link that leads nowhere"), "writing would create the file outside: {e}"); + // A link to a folder inside the root stays inside. + assert!(made(&base.join("in/sub"), &base.join("in/alias"))); + assert_eq!(roots.check(&s(base.join("in/alias/x.docx")), Access::Read), Ok(())); + // A root given through a link is the folder it leads to. + assert!(made(&base.join("in"), &base.join("in-link"))); + let linked = AutomationRoots::new(Some(&base.join("in-link")), None).unwrap().unwrap(); + assert_eq!(linked.check(&s(base.join("in/a.docx")), Access::Read), Ok(())); + let _ = std::fs::remove_dir_all(base); + } + + #[cfg(windows)] + #[test] + fn windows_ignores_case_and_refuses_devices() { + let (base, roots) = folders("case"); + let upper = s(base.join("IN/A.SVG")); + assert_eq!(roots.check(&upper, Access::Read), Ok(()), "{upper}"); + assert_eq!(roots.check(&s(base.join("In/Sub/New.docx")), Access::Read), Ok(())); + let e = roots.check(&s(base.join("out/NUL")), Access::Write).unwrap_err(); + assert!(e.contains("reserved device names"), "{e}"); + assert!(roots.check(&s(base.join("out/com1.txt")), Access::Write).is_err()); + assert_eq!(roots.check(&s(base.join("out/console.txt")), Access::Write), Ok(())); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn scopes_nest_and_stay_on_their_thread() { + let (base, roots) = folders("scopes"); + let outside = s(base.join("outside/secret.docx")); + assert_eq!(check_read(&outside), Ok(()), "nothing is confined by default"); + confine(Some(&roots), || { + assert!(check_read(&outside).is_err()); + assert_eq!(unconfined(|| check_read(&outside)), Ok(())); + assert!(check_read(&outside).is_err(), "restored after unconfined"); + let o = outside.clone(); + let there = std::thread::spawn(move || check_read(&o).is_ok()).join().unwrap(); + assert!(there, "another thread is not confined by this thread's scope"); + }); + assert_eq!(check_read(&outside), Ok(()), "restored after confine"); + let r = std::panic::catch_unwind(|| confine(Some(&roots), || panic!("boom"))); + assert!(r.is_err()); + assert_eq!(check_read(&outside), Ok(()), "restored after a panic"); + assert_eq!(confine(None, || check_read(&outside)), Ok(())); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn device_names() { + for d in ["CON", "nul", "Com1", "LPT9.txt", "aux.docx", "NUL "] { + assert!(is_device_name(d), "{d}"); + } + for f in ["console", "com10", "COM0", "lpt", "a.docx", "nul-x"] { + assert!(!is_device_name(f), "{f}"); + } + } + + /// Every file a command reads or writes by path goes through `io` (`open_path_with`, + /// `save_path_with`, `read_file`, `write_file`), which check the roots. Engine code that + /// reaches for `std::fs` itself would bypass them, so none may: every file under `src/` + /// (commands, `print.rs`, `session.rs`…) except `io.rs` with its `io/` helpers (`safe_save`, + /// reached only after `io` checked the roots), this module, and the test files. + #[test] + fn commands_do_not_touch_the_file_system_themselves() { + fn walk(dir: &Path, files: &mut Vec) { + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + walk(&path, files); + } else if path.extension().is_some_and(|e| e == "rs") { + files.push(path); + } + } + } + let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut files = vec![]; + walk(&src, &mut files); + assert!(files.iter().any(|f| f.ends_with("print.rs")) && files.iter().any(|f| f.ends_with("cmd/file.rs")), "{files:?}"); + let mut offenders = vec![]; + for path in files { + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + // `io` and this module are the checked gateway; `tests*.rs` are test-only modules. + if path.parent() == Some(src.as_path()) && (name == "io.rs" || name == "file_access.rs" || name.starts_with("tests")) + || path.starts_with(src.join("io")) + { + continue; + } + let text = std::fs::read_to_string(&path).unwrap(); + // Test modules (which build their own fixtures) come last in each file. + let code = text.split("#[cfg(test)]").next().unwrap_or(""); + for (n, line) in code.lines().enumerate() { + let line = line.trim_start(); + let touches = ["std::fs", "File::open", "File::create", "OpenOptions"].iter().any(|w| line.contains(w)); + if !line.starts_with("//") && touches { + offenders.push(format!("{}:{}: {line}", path.display(), n + 1)); + } + } + } + assert!(offenders.is_empty(), "use crate::io::{{read_file, write_file, open_path_with, save_path_with}}: {offenders:#?}"); + } +} diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index defc29dd8..cdc1d63b5 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -228,6 +228,7 @@ pub fn open_path(path: &std::path::Path) -> Result { /// [`open_path`] for a file that may be password-protected (see [`open_bytes_with`]). #[cfg(not(target_arch = "wasm32"))] pub fn open_path_with(path: &std::path::Path, password: Option<&str>) -> Result<(Document, bool), String> { + crate::file_access::check_read(&path.to_string_lossy())?; let meta = std::fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?; if meta.len() > 2 << 30 { return Err(format!("{}: file is larger than 2 GB", path.display())); @@ -247,7 +248,8 @@ pub fn open_path_with(path: &std::path::Path, password: Option<&str>) -> Result< /// The pictures an HTML file names by a path relative to its folder. /// -/// Only regular files inside the folder (after resolving `..` and symlinks) are read. URLs +/// Only regular files inside the folder (after resolving `..` and symlinks) are read. The folder is +/// the HTML file's own, which `open_path_with` has already checked against the automation read root. URLs /// (`http:`, `file:`…) and absolute paths are not followed. Each file is read at most once and /// at most `max_file` bytes of it; every reference, cached or not, counts against a budget for /// the whole document, so one large picture can't be multiplied by referencing it many times. @@ -348,6 +350,46 @@ fn read_regular_file(path: &std::path::Path, limit: u64) -> Option> { Some(out) } +/// The bytes of the file at `path`, for commands that take a path (pictures, mail-merge data). +/// Every such read goes through here so the automation read root applies ([`crate::file_access`]). +#[cfg(not(target_arch = "wasm32"))] +pub fn read_file(path: &str) -> Result, String> { + crate::file_access::check_read(path)?; + std::fs::read(path).map_err(|e| format!("{path}: {e}")) +} + +#[cfg(target_arch = "wasm32")] +pub fn read_file(path: &str) -> Result, String> { + Err(format!("{path}: files are opened through the browser on the web")) +} + +/// [`read_file`] for a file that must be a regular file of at most `max` bytes, checked before +/// anything is read (a font named by the user, which could be any size). +#[cfg(not(target_arch = "wasm32"))] +pub fn read_file_capped(path: &str, max: u64, what: &str) -> Result, String> { + crate::file_access::check_read(path)?; + let meta = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?; + if !meta.is_file() { + return Err(format!("{path}: not a file")); + } + if meta.len() > max { + return Err(format!("{path}: the {what} is larger than {} MB", max >> 20)); + } + std::fs::read(path).map_err(|e| format!("{path}: {e}")) +} + +/// Write `bytes` to the file at `path`, inside the automation write root when there is one. +#[cfg(not(target_arch = "wasm32"))] +pub fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> { + crate::file_access::check_write(path)?; + std::fs::write(path, bytes).map_err(|e| format!("{path}: {e}")) +} + +#[cfg(target_arch = "wasm32")] +pub fn write_file(path: &str, _bytes: &[u8]) -> Result<(), String> { + Err(format!("{path}: files are saved through the browser on the web")) +} + #[cfg(target_arch = "wasm32")] pub fn open_path_with(path: &std::path::Path, _password: Option<&str>) -> Result<(Document, bool), String> { Err(format!("{}: files are opened through the browser on the web", path.display())) @@ -360,6 +402,7 @@ pub fn save_path(path: &std::path::Path, doc: &Document) -> Result<(), String> { /// [`save_path`], encrypted with `password` as [`save_bytes_with`] does. #[cfg(not(target_arch = "wasm32"))] pub fn save_path_with(path: &std::path::Path, doc: &Document, password: Option<&str>, opts: &ExportOptions) -> Result<(), String> { + crate::file_access::check_write(&path.to_string_lossy())?; let bytes = save_bytes_with(&path.to_string_lossy(), doc, password, opts)?; safe_save::write(path, &bytes).map_err(|e| format!("{}: {e}", path.display())) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 927d42614..79b3e7b17 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -8,6 +8,7 @@ pub mod catalog; pub mod cmd; +pub mod file_access; pub mod io; mod io_ext; pub mod keymap; diff --git a/crates/engine/src/print.rs b/crates/engine/src/print.rs index 290dfdb94..9d05060d9 100644 --- a/crates/engine/src/print.rs +++ b/crates/engine/src/print.rs @@ -161,8 +161,9 @@ pub(crate) fn print_pdf_to_file(s: &mut Session, v: &Value) -> Result { let id = params.get("command").and_then(Value::as_str).ok_or("missing `command`")?.to_string(); @@ -55,6 +60,19 @@ impl Backend for Headless { other => self.session.run(other, ¶ms).map_err(|e| e.to_string()), } } +} + +impl Default for Headless { + fn default() -> Self { + Headless { session: Session::new(wordcraft_doc::Document::new()), roots: None } + } +} + +impl Backend for Headless { + fn call(&mut self, method: &str, params: Value) -> Result { + let roots = self.roots.clone(); + file_access::confine(roots.as_ref(), || self.answer(method, params)) + } fn has_ui(&self) -> bool { false } diff --git a/crates/mcp/tests/automation_roots.rs b/crates/mcp/tests/automation_roots.rs new file mode 100644 index 000000000..813af092d --- /dev/null +++ b/crates/mcp/tests/automation_roots.rs @@ -0,0 +1,209 @@ +//! `wordcraft-cli mcp --automation-read-root --automation-write-root ` (#769): through +//! the MCP protocol, files open and load only from inside the read root and are saved and +//! exported only inside the write root; paths that leave a root (`..`, another folder, a sibling +//! with the same prefix, a link) are refused, and nothing is written outside. +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::path::{Path, PathBuf}; + +use serde_json::{Value, json}; +use wordcraft_engine::file_access::AutomationRoots; +use wordcraft_mcp::{Headless, Server}; + +/// A 1x1 PNG, so `insert.picture` has something real to read. +const PNG: &[u8] = &[ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8, 0xcf, 0xc0, 0xf0, + 0x1f, 0x00, 0x05, 0x00, 0x01, 0xff, 0x89, 0x99, 0x3d, 0x1d, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, +]; + +/// A fresh folder holding `in` (the read root, with `a.txt`, `people.csv` and `pic.png`), `out` (the +/// write root), `outside` (with `secret.txt`) and `in2`, and a server confined to `in` and `out` +/// (either may be left out). +fn confined(tag: &str, read: bool, write: bool) -> (Server, PathBuf) { + let base = std::env::temp_dir().join(format!("wc-mcp-roots-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + for d in ["in", "out", "outside", "in2"] { + std::fs::create_dir_all(base.join(d)).unwrap(); + } + std::fs::write(base.join("in/a.txt"), "Inside text").unwrap(); + std::fs::write(base.join("in/people.csv"), "Name\nAda\n").unwrap(); + std::fs::write(base.join("in/pic.png"), PNG).unwrap(); + std::fs::write(base.join("outside/secret.txt"), "TOP SECRET").unwrap(); + std::fs::write(base.join("outside/pic.png"), PNG).unwrap(); + std::fs::write(base.join("outside/t.docx"), "x").unwrap(); + std::fs::write(base.join("in2/c.txt"), "Sibling").unwrap(); + let (in_dir, out_dir) = (base.join("in"), base.join("out")); + let roots = AutomationRoots::new(read.then_some(in_dir.as_path()), write.then_some(out_dir.as_path())).unwrap(); + (Server::new(Box::new(Headless::default().with_automation_roots(roots))), base) +} + +fn s(p: &Path) -> String { + p.to_string_lossy().into_owned() +} + +/// `tools/call` -> (isError, the first text block). +fn call(server: &mut Server, name: &str, args: Value) -> (bool, String) { + let line = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": name, "arguments": args}}).to_string(); + let reply: Value = serde_json::from_str(&server.handle_line(&line).unwrap()).unwrap(); + let result = &reply["result"]; + let text = result["content"].as_array().unwrap().iter().find_map(|c| c["text"].as_str()).unwrap_or_default().to_string(); + (result["isError"] == true, text) +} + +fn ok(server: &mut Server, name: &str, args: Value) -> String { + let (error, text) = call(server, name, args.clone()); + assert!(!error, "{name} {args}: {text}"); + text +} + +fn refused(server: &mut Server, name: &str, args: Value, why: &str) { + let (error, text) = call(server, name, args.clone()); + assert!(error && text.contains(why), "{name} {args} should be refused ({why}): {text}"); +} + +fn run(server: &mut Server, command: &str, params: Value) -> (bool, String) { + call(server, "execute", json!({"command": command, "params": params})) +} + +#[test] +fn files_open_only_from_inside_the_read_root() { + let (mut server, base) = confined("open", true, true); + let sep = std::path::MAIN_SEPARATOR; + ok(&mut server, "open_document", json!({"path": s(&base.join("in/a.txt"))})); + assert!(ok(&mut server, "get_text", json!({})).contains("Inside text")); + + refused(&mut server, "open_document", json!({"path": s(&base.join("outside/secret.txt"))}), "outside the read root"); + refused( + &mut server, + "open_document", + json!({"path": format!("{}{sep}..{sep}outside{sep}secret.txt", s(&base.join("in")))}), + "outside the read root", + ); + refused(&mut server, "open_document", json!({"path": s(&base.join("in2/c.txt"))}), "outside the read root"); + // The refused open left the document it had. + assert!(ok(&mut server, "get_text", json!({})).contains("Inside text")); + // The write root isn't readable: the two kinds of access are separate. + std::fs::write(base.join("out/d.txt"), "d").unwrap(); + refused(&mut server, "open_document", json!({"path": s(&base.join("out/d.txt"))}), "outside the read root"); + // A relative path is the working directory's (the crate's folder here): outside. + refused(&mut server, "open_document", json!({"path": "Cargo.toml"}), "outside the read root"); +} + +#[test] +fn commands_that_read_a_path_are_held_to_the_read_root() { + let (mut server, base) = confined("reads", true, true); + let outside = s(&base.join("outside/secret.txt")); + // The injected-document scenario from the issue: pull a file in from outside, then read it back. + for (command, params) in [ + ("insert.textFromFile", json!({"path": outside})), + ("insert.object", json!({"path": outside})), + ("insert.picture", json!({"path": s(&base.join("outside/pic.png"))})), + ("mailings.recipients", json!({"path": outside})), + ("file.newFromTemplate", json!({"path": outside})), + ("review.compare", json!({"path": outside})), + ("review.compare", json!({"text": "x", "original": outside})), + ("file.open", json!({"path": outside})), + ("styles.organizer", json!({"path": s(&base.join("outside/t.docx"))})), + ("tools.updateStyles", json!({"path": s(&base.join("outside/t.docx"))})), + ("styles.copyFrom", json!({"path": s(&base.join("outside/t.docx")), "styles": ["Heading 1"]})), + ] { + let (error, text) = run(&mut server, command, params); + assert!(error && text.contains("outside the read root"), "{command}: {text}"); + } + assert!(!ok(&mut server, "get_text", json!({})).contains("TOP SECRET")); + + // The same commands work on files inside the root. + let (error, text) = run(&mut server, "insert.textFromFile", json!({"path": s(&base.join("in/a.txt"))})); + assert!(!error, "{text}"); + assert!(ok(&mut server, "get_text", json!({})).contains("Inside text")); + let (error, text) = run(&mut server, "insert.picture", json!({"path": s(&base.join("in/pic.png"))})); + assert!(!error, "{text}"); + let (error, text) = run(&mut server, "mailings.recipients", json!({"path": s(&base.join("in/people.csv"))})); + assert!(!error, "{text}"); +} + +#[test] +fn saves_and_exports_go_only_into_the_write_root() { + let (mut server, base) = confined("write", true, true); + let sep = std::path::MAIN_SEPARATOR; + ok(&mut server, "type_text", json!({"text": "Hello"})); + + ok(&mut server, "save_document", json!({"path": s(&base.join("out/doc.docx"))})); + assert!(base.join("out/doc.docx").is_file()); + ok(&mut server, "save_document", json!({"path": s(&base.join("out/doc.pdf"))})); + ok(&mut server, "save_document", json!({"path": s(&base.join("out/page.png"))})); + assert!(base.join("out/page.png").is_file()); + let (error, text) = run(&mut server, "file.exportPdf", json!({"path": s(&base.join("out/two"))})); + assert!(!error, "{text}"); + // Printing to a PDF (#641) writes a file too. + let (error, text) = run(&mut server, "file.printPdf", json!({"path": s(&base.join("out/printed"))})); + assert!(!error, "{text}"); + assert!(base.join("out/printed.pdf").is_file()); + + refused(&mut server, "save_document", json!({"path": s(&base.join("in/doc.docx"))}), "outside the write root"); + refused(&mut server, "save_document", json!({"path": s(&base.join("outside/doc.html"))}), "outside the write root"); + refused(&mut server, "save_document", json!({"path": s(&base.join("outside/page.png"))}), "outside the write root"); + refused( + &mut server, + "save_document", + json!({"path": format!("{}{sep}..{sep}outside{sep}up.docx", s(&base.join("out")))}), + "outside the write root", + ); + let (error, text) = run(&mut server, "mailings.recipients", json!({"csv": "Name\nAda\n"})); + assert!(!error, "{text}"); + for (command, params) in [ + ("file.saveAs", json!({"path": s(&base.join("outside/x.docx"))})), + ("file.exportPdf", json!({"path": s(&base.join("outside/x.pdf"))})), + ("file.printPdf", json!({"path": s(&base.join("outside/printed.pdf"))})), + ("file.printPdf", json!({"path": format!("{}{sep}..{sep}outside{sep}up", s(&base.join("out")))})), + ("file.exportPng", json!({"path": s(&base.join("outside/x.png"))})), + ("file.saveTemplate", json!({"path": s(&base.join("outside/x.dotx"))})), + ("mailings.finish", json!({"path": s(&base.join("outside/merged.docx"))})), + ] { + let (error, text) = run(&mut server, command, params); + assert!(error && text.contains("outside the write root"), "{command}: {text}"); + } + // The document saved inside the write root saves again in place. + ok(&mut server, "save_document", json!({})); + let mut written: Vec = + std::fs::read_dir(base.join("outside")).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); + written.sort(); + assert_eq!(written, ["pic.png", "secret.txt", "t.docx"], "nothing was written outside the roots"); + assert!(!base.join("in/doc.docx").exists()); +} + +#[test] +fn a_root_left_out_grants_none_of_its_access() { + let (mut server, base) = confined("read-only", true, false); + ok(&mut server, "open_document", json!({"path": s(&base.join("in/a.txt"))})); + refused(&mut server, "save_document", json!({"path": s(&base.join("out/doc.docx"))}), "write authority is absent"); + refused(&mut server, "save_document", json!({"path": s(&base.join("in/doc.docx"))}), "write authority is absent"); + assert!(!base.join("out/doc.docx").exists()); + + let (mut server, base) = confined("write-only", false, true); + refused(&mut server, "open_document", json!({"path": s(&base.join("in/a.txt"))}), "read authority is absent"); + ok(&mut server, "save_document", json!({"path": s(&base.join("out/doc.docx"))})); +} + +#[test] +fn links_out_of_a_root_are_refused() { + let (mut server, base) = confined("links", true, true); + #[cfg(unix)] + let linked = std::os::unix::fs::symlink(base.join("outside"), base.join("out/away")).is_ok() + && std::os::unix::fs::symlink(base.join("outside/secret.txt"), base.join("in/leak.txt")).is_ok(); + #[cfg(windows)] + let linked = std::os::windows::fs::symlink_dir(base.join("outside"), base.join("out/away")).is_ok() + && std::os::windows::fs::symlink_file(base.join("outside/secret.txt"), base.join("in/leak.txt")).is_ok(); + #[cfg(not(any(unix, windows)))] + let linked = false; + // Windows needs Developer Mode or elevation to make links. + if !linked { + eprintln!("skipped: symbolic links can't be made here"); + return; + } + refused(&mut server, "open_document", json!({"path": s(&base.join("in/leak.txt"))}), "outside the read root"); + refused(&mut server, "save_document", json!({"path": s(&base.join("out/away/doc.docx"))}), "outside the write root"); + assert!(!base.join("outside/doc.docx").exists()); +} diff --git a/docs/mcp.md b/docs/mcp.md index 00a9d6edb..d18816089 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -24,6 +24,60 @@ with `wordcraft-cli mcp --author "Claude (copyedit)"`, or run `review.trackChanges {"value": true}`; without `value` it toggles, which turns tracking *off* in a document that was saved with it on. `wordcraft-cli run` takes `--author` too. +## Confining file access + +```sh +wordcraft-cli mcp --automation-read-root /work/project --automation-write-root /work/project/out +``` + +```json +{"mcpServers": {"wordcraft": {"command": "/abs/path/target/release/wordcraft-cli", + "args": ["mcp", "--automation-read-root", "/work/project", "--automation-write-root", "/work/project/out"]}}} +``` + +The two flags (VectorCraft, PhotoCraft and the other Craft apps take the same ones, so one client +config works across the suite) keep the agent to the folders you give it: + +- Every file a command reads must lie inside the **read root**: `open_document` and `file.open`, + `file.newFromTemplate`, `insert.textFromFile` and `insert.object`, `insert.picture` and + `picture.change` with a `path`, `mailings.recipients` with a `path` (csv, tsv, txt, xlsx, xlsm, + ods), `review.compare` (`path` and `original`), the templates and documents `tools.updateStyles`, + `styles.organizer` and `styles.copyFrom` read. The pictures an opened HTML file names are read + from beside that file, so they are inside the read root too (a link that leads out is not followed). +- Every file a command writes must lie inside the **write root**: `save_document`, `file.save`, + `file.saveAs`, `file.exportPdf`, `file.exportPng`, `file.printPdf`, `file.saveTemplate`, and `mailings.finish` with + a `path`. +- Read and write access are separate. Give the same folder twice for both; a root left out grants + none of its access (with only `--automation-read-root`, nothing can be written to disk). Without + either flag nothing changes. +- A path is made absolute against the server's working directory, then its links are followed: a + file (or folder) link that leads out of a root is outside it. For a file that doesn't exist yet, + its nearest existing folder's links are followed and the rest must be plain names, so `..` below a + folder that doesn't exist is refused, as is a link that leads nowhere (writing through it would + create its target). The result is compared with the root by whole folder names: `/work2/x` is not + inside `/work`. On Windows case is ignored, UNC paths (`\\server\share\...`) are resolved like + drive-letter ones, and device names (`CON`, `NUL`, `COM1`...) are refused. +- A refused path is an ordinary tool error the agent can read and correct, for example + `automation path rejected: outside the write root /work/project/out: /etc/x.docx` or + `automation filesystem access is not granted: write authority is absent: /work/project/out/x.docx`. +- The roots must be existing folders; a missing one stops the server with an error. Each flag is + given once (`--flag ` or `--flag=`). + +The flags confine the headless server; `--connect` with them is an error, since the server would +otherwise talk to an app that isn't confined. WordCraft's desktop app has no equivalent flags yet, +so a running app (`wordcraft --control`) is not confined. + +What the roots do not cover: commands that take no path (`text.insert`, `execute` of anything that +works on the open document) can still read and change that document, and `get_text` and +`inspect_document` return it. The Zotero commands talk to a local Zotero over a socket, not to files. +Anything that reads or writes a path goes through one place in the engine (`io::open_path_with`, +`io::save_path_with`, `io::read_file`, `io::write_file`); a test keeps command modules from +touching the file system on their own. + +The checks hold for every thread of the server process. A path is checked, then opened: another +program that swaps a link in between can still win that race, so the roots keep an agent to the +files it was given rather than guarding against other software on the computer. + ## Tools | Tool | What it does | From b30cda15352093710e9346ca1592618403789081 Mon Sep 17 00:00:00 2001 From: RegalDragoon200 <56407824+regalf@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:23:51 +0200 Subject: [PATCH 39/88] Persist the custom dictionary across sessions (#40) (#796) * Persist the custom dictionary across sessions (#40) * Review fixes for #796: session-only Ignore All, desktop-only persistence, capped atomic I/O - review.ignoreAll no longer adds to (and saves) the custom dictionary: ignored words go to a separate session-only list, as Ignore All does in Word. - Only the desktop app wires the dictionary file. Headless MCP and `wordcraft-cli run` keep words in memory, so an agent never reads or changes the person's dictionary (and nothing writes outside an automation write root, #769). - Loading reads through `take(1 MB)`, so a huge file is never read whole; lines that aren't a single word (spaces, control characters, invalid UTF-8, over 64 characters) are skipped. - review.addToDictionary applies the same word rule (proof::custom_word), so a word added is never dropped on the next load. - Saving merges the words on disk first (another process's additions survive; words just removed stay out), creates the folder, and replaces the file atomically (temp + rename). The file I/O lives in the proof crate, so the engine doesn't touch std::fs itself. - Tests for each. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 1 + apps/wordcraft/Cargo.toml | 1 + apps/wordcraft/src/main.rs | 16 +++ crates/engine/src/cmd/options.rs | 3 + crates/engine/src/cmd/review.rs | 13 +- crates/engine/src/session.rs | 22 ++++ crates/engine/src/tests.rs | 39 ++++++ crates/mcp/src/tests.rs | 12 ++ crates/proof/src/lib.rs | 205 ++++++++++++++++++++++++++++++- docs/gaps.md | 1 + docs/target-app-parity.md | 3 +- 11 files changed, 312 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d93a9c05d..1fa2de1d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5020,6 +5020,7 @@ dependencies = [ "wordcraft-doc", "wordcraft-engine", "wordcraft-fonts", + "wordcraft-proof", "wordcraft-ui-egui", "zbus", ] diff --git a/apps/wordcraft/Cargo.toml b/apps/wordcraft/Cargo.toml index 7e02415bd..d878c9305 100644 --- a/apps/wordcraft/Cargo.toml +++ b/apps/wordcraft/Cargo.toml @@ -13,6 +13,7 @@ workspace = true [dependencies] wordcraft-doc = { workspace = true } wordcraft-engine = { workspace = true } +wordcraft-proof = { workspace = true } # Fonts the user added (#582) load from the settings folder at start-up. wordcraft-fonts = { workspace = true } wordcraft-ui-egui = { workspace = true } diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index 38ff25eaf..40cb074c5 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -123,6 +123,21 @@ fn log_dir() -> Option { Some(prefs_path()?.parent()?.join("logs")) } +/// `custom-dictionary.txt` in the settings folder: the words Add to Dictionary kept (#40). +fn user_dict_path() -> Option { + wordcraft_control_key::settings_dir().map(|b| b.join(wordcraft_engine::Session::USER_DICT_FILE)) +} + +fn load_user_dictionary(app: &mut WordApp) { + if !prefs_enabled() { + return; + } + if let Some(p) = user_dict_path() { + wordcraft_proof::load_user_dictionary(&p); + app.session.user_dict_path = Some(p); + } +} + /// Runs without preferences (`WORDCRAFT_NO_PREFS`, agents' test runs) neither read nor write them. fn prefs_enabled() -> bool { std::env::var_os("WORDCRAFT_NO_PREFS").is_none() @@ -301,6 +316,7 @@ fn main() -> eframe::Result { let (file_dialog, dialogs) = file_dialogs::hook(&cc.egui_ctx); let mut app = WordApp::new(Session::new(doc), services(file_dialog, &cc.egui_ctx)); load_prefs(&mut app); + load_user_dictionary(&mut app); app.watch_desktop_theme(&cc.egui_ctx); app.ui.window = restored; app.integrated_titlebar = cfg!(target_os = "macos"); diff --git a/crates/engine/src/cmd/options.rs b/crates/engine/src/cmd/options.rs index c7a8a49c5..91246625c 100644 --- a/crates/engine/src/cmd/options.rs +++ b/crates/engine/src/cmd/options.rs @@ -154,6 +154,9 @@ fn options(s: &mut Session, v: &Value) -> CmdResult { for w in &remove { wordcraft_proof::remove_word(w); } + if !add.is_empty() || !remove.is_empty() { + s.persist_user_dictionary(&remove); + } if setting { // Marks, hidden text and proofing change what is laid out. s.relayout(); diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index 2ce9d4aa1..babb3ca09 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -65,7 +65,13 @@ pub fn specs() -> Vec { if w.is_empty() { return Err(CmdError::Params("no word".into())); } - wordcraft_proof::add_word(&w); + // The same rule as loading the dictionary file, so a word added is never dropped later. + if wordcraft_proof::custom_word(&w).is_none() { + let max = wordcraft_proof::MAX_CUSTOM_WORD_CHARS; + return Err(CmdError::Params(format!("{w:?} isn't a single word of at most {max} characters"))); + } + wordcraft_proof::add_word(w.trim()); + s.persist_user_dictionary(&[]); s.relayout(); Ok(json!({"added": w})) }) @@ -78,7 +84,10 @@ pub fn specs() -> Vec { .map(|(a, b, _)| s.doc.para_at(&a).and_then(|p| p.text.get(a.off..b.off).map(str::to_string)).unwrap_or_default()) .unwrap_or_default(), }; - wordcraft_proof::add_word(&w); + // For this session only, as in Word: not added to (or saved with) the dictionary. + if !w.trim().is_empty() { + wordcraft_proof::ignore_word(w.trim()); + } s.relayout(); Ok(json!({"ignored": w})) }) diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 101cc45ed..09f00ff9d 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -275,6 +275,11 @@ pub struct Session { pub pending: Option, pub path: Option, pub dirty: bool, + /// Where the custom dictionary is kept on disk, if the host wired one up. Only the desktop + /// app does: headless sessions (MCP, `wordcraft-cli run`), tests and the web build leave + /// `None`, so an agent's words stay in memory and never change the person's dictionary. + /// Add to Dictionary and File › Options › Proofing save through it (#40). + pub user_dict_path: Option, pub clipboard: Option, /// A tracked cut the next paste can turn into a move (see `cmd::moves`). pub pending_move: Option, @@ -471,6 +476,20 @@ fn doc_bib_style(doc: &Document) -> String { const MAX_UNDO: usize = 500; impl Session { + /// File name of the persisted custom dictionary inside the settings folder. + pub const USER_DICT_FILE: &str = "custom-dictionary.txt"; + + /// Save the custom dictionary through [`Session::user_dict_path`], if the host wired one up, + /// with `removed` (words just taken out) left out of the merge with the file. A failed save + /// keeps the words in memory and never fails the command (#40). + pub(crate) fn persist_user_dictionary(&self, removed: &[String]) { + if let Some(path) = self.user_dict_path.as_deref() + && let Err(e) = wordcraft_proof::save_user_dictionary(path, removed) + { + log::warn!("custom dictionary: save failed: {e}"); + } + } + pub fn new(doc: Document) -> Self { let start = doc.start_of(StoryRef::Body); let bib_style = doc_bib_style(&doc); @@ -480,6 +499,7 @@ impl Session { view: ViewState::default(), pending: None, path: None, + user_dict_path: None, dirty: false, clipboard: None, pending_move: None, @@ -844,6 +864,8 @@ impl Session { view: _, pending: _, path: _, + // The custom dictionary file, like the file path: host wiring, not an edit. + user_dict_path: _, dirty, clipboard: _, // Like the clipboard: what the next paste can make a move of. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f9b496a9c..26f9ebc22 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -125,6 +125,45 @@ fn reset_shortcuts_remove_direct_formatting_but_keep_styles() { assert_eq!(text(&s), "Hello world—"); } +#[test] +fn custom_dictionary_words_persist_through_their_file() { + // #40: adding a word through a command writes it to the session's file, and loading + // that file makes the word known (only the desktop app wires this up). + let dir = std::env::temp_dir().join(format!("wc-engdict-{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + let path = dir.join("custom-dictionary.txt"); + let _ = std::fs::remove_file(&path); + let mut s = s(); + s.user_dict_path = Some(path.clone()); + run(&mut s, "review.addToDictionary", json!({"word": "zxqengpersist"})); + let saved = std::fs::read_to_string(&path).unwrap_or_default(); + assert!(saved.lines().any(|l| l == "zxqengpersist"), "the word reaches the file: {saved:?}"); + run(&mut s, "file.options", json!({"removeWords": ["zxqengpersist"]})); + let saved = std::fs::read_to_string(&path).unwrap_or_default(); + assert!(!saved.lines().any(|l| l == "zxqengpersist"), "removal reaches the file too: {saved:?}"); + + // Ignore All is for this session only, as in Word: the word is accepted, never saved. + assert!(!wordcraft_proof::is_correct("zxqengignored")); + run(&mut s, "review.ignoreAll", json!({"word": "zxqengignored"})); + assert!(wordcraft_proof::is_correct("zxqengignored")); + assert!(!wordcraft_proof::user_dictionary().contains(&"zxqengignored".to_string())); + let saved = std::fs::read_to_string(&path).unwrap_or_default(); + assert!(!saved.contains("zxqengignored"), "Ignore All doesn't reach the file: {saved:?}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn add_to_dictionary_takes_only_words_the_file_keeps() { + // #40: the same rule as loading the dictionary file, so nothing added is dropped on the next load. + let mut s = s(); + for bad in ["two words", "tab\tword", "bell\u{7}", &"x".repeat(65)] { + assert!(s.run("review.addToDictionary", &json!({"word": bad})).is_err(), "{bad:?}"); + assert!(!wordcraft_proof::user_dictionary().iter().any(|w| w == &bad.to_lowercase())); + } + run(&mut s, "review.addToDictionary", json!({"word": " zxqengtrimmed "})); + assert!(wordcraft_proof::user_dictionary().contains(&"zxqengtrimmed".to_string())); +} + #[test] fn non_english_runs_are_not_proofed() { // #100: runs tagged with a proofing language that has no dictionary are skipped diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 6a3310703..5ea5ad9f5 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -107,3 +107,15 @@ fn render_and_screenshot_through_the_app_use_no_temp_file() { assert!(params.get("path").is_none(), "{tool} asked for a file: {params}"); } } + +/// #40: a headless session never reads or writes the person's custom dictionary file: an agent's +/// Add to Dictionary stays in memory (only the desktop app saves the dictionary). +#[test] +fn headless_sessions_keep_the_dictionary_in_memory() { + let mut h = Headless::default(); + assert!(h.session.user_dict_path.is_none()); + let mut s = Server::new(Box::new(Headless::default())); + tool(&mut s, "execute", json!({"command": "review.addToDictionary", "params": {"word": "zxqmcpword"}})); + h.session.run("review.addToDictionary", &json!({"word": "zxqmcpword2"})).expect("added"); + assert!(h.session.user_dict_path.is_none()); +} diff --git a/crates/proof/src/lib.rs b/crates/proof/src/lib.rs index 34693d700..32d1aa7e1 100644 --- a/crates/proof/src/lib.rs +++ b/crates/proof/src/lib.rs @@ -95,6 +95,19 @@ pub fn remove_word(w: &str) -> bool { removed } +fn ignored_words() -> &'static RwLock> { + static I: OnceLock>> = OnceLock::new(); + I.get_or_init(|| RwLock::new(HashSet::new())) +} + +/// Ignore a word everywhere until the app closes ("Ignore All"). Unlike [`add_word`] it isn't +/// part of the user dictionary: it is never listed, saved or loaded, as in Word. +pub fn ignore_word(w: &str) { + if ignored_words().write().unwrap_or_else(|e| e.into_inner()).insert(w.to_lowercase()) { + DICTIONARY_GENERATION.fetch_add(1, Ordering::Relaxed); + } +} + /// A number that changes whenever a word is added to or removed from the user dictionary. pub fn dictionary_generation() -> u64 { DICTIONARY_GENERATION.load(Ordering::Relaxed) @@ -112,6 +125,109 @@ pub fn user_dictionary() -> Vec { v } +/// Longest custom-dictionary word kept, in characters (mirrors `file.options` validation). +pub const MAX_CUSTOM_WORD_CHARS: usize = 64; +/// Most custom-dictionary words read from one file (caps a hostile file). +const MAX_CUSTOM_WORDS: usize = 100_000; +/// Largest custom-dictionary file read, in bytes (caps a hostile file). +const MAX_CUSTOM_FILE_BYTES: u64 = 1_000_000; + +/// `w` as a custom-dictionary word: trimmed and lower-cased, or `None` when it isn't a single +/// word of at most [`MAX_CUSTOM_WORD_CHARS`] characters (empty, spaces, control characters, or +/// bytes that weren't UTF-8). +/// Adding and loading both use it, so a word that can be added is never dropped on the next load. +pub fn custom_word(w: &str) -> Option { + let w = w.trim(); + if w.is_empty() + || w.chars().count() > MAX_CUSTOM_WORD_CHARS + || w.chars().any(|c| c.is_whitespace() || c.is_control() || c == char::REPLACEMENT_CHARACTER) + { + return None; + } + Some(w.to_lowercase()) +} + +/// The valid words of the custom-dictionary file at `path`, reading at most +/// [`MAX_CUSTOM_FILE_BYTES`] of it and keeping at most [`MAX_CUSTOM_WORDS`]. A missing or +/// unreadable file reads as no words. +fn read_dictionary_file(path: &std::path::Path) -> Vec { + use std::io::Read; + let mut bytes = Vec::new(); + let Ok(file) = std::fs::File::open(path) else { return Vec::new() }; + // `take` bounds the read itself, so a huge file is never read whole. + if file.take(MAX_CUSTOM_FILE_BYTES).read_to_end(&mut bytes).is_err() { + return Vec::new(); + } + // Lossy: a cut multi-byte tail becomes U+FFFD and is skipped by validation below, + // instead of discarding the whole file. + String::from_utf8_lossy(&bytes).lines().filter_map(custom_word).take(MAX_CUSTOM_WORDS).collect() +} + +/// Read custom-dictionary words from a file into the user dictionary (one word per line; +/// blank lines are skipped). A missing file reads as empty; a hostile file is capped, +/// never a panic and never an error that stops startup. Returns the words added. +pub fn load_user_dictionary(path: &std::path::Path) -> Vec { + let words = read_dictionary_file(path); + let mut set = user_words().write().unwrap_or_else(|e| e.into_inner()); + let mut added = Vec::new(); + for w in words { + if set.len() >= MAX_CUSTOM_WORDS { + break; + } + if set.insert(w.clone()) { + added.push(w); + } + } + if !added.is_empty() { + DICTIONARY_GENERATION.fetch_add(1, Ordering::Relaxed); + } + added +} + +/// Write the user dictionary to a file (one word per line, sorted), creating its folder. +/// +/// Words another process (a second window, the MCP bridge) saved to the file since it was loaded +/// are merged in first, so neither loses the other's words; `removed` (words just taken out) stay +/// out. The file is replaced atomically: written beside it, then renamed over it. Errors (a +/// read-only folder, a full disk) go back to the caller; the in-memory words are kept. +pub fn save_user_dictionary(path: &std::path::Path, removed: &[String]) -> std::io::Result<()> { + let removed: HashSet = removed.iter().map(|w| w.to_lowercase()).collect(); + let on_disk = read_dictionary_file(path); + { + let mut set = user_words().write().unwrap_or_else(|e| e.into_inner()); + let mut changed = false; + for w in on_disk { + if set.len() >= MAX_CUSTOM_WORDS { + break; + } + if !removed.contains(&w) && set.insert(w) { + changed = true; + } + } + for w in &removed { + changed |= set.remove(w); + } + if changed { + DICTIONARY_GENERATION.fetch_add(1, Ordering::Relaxed); + } + } + let mut out = String::new(); + for w in user_dictionary() { + out.push_str(&w); + out.push('\n'); + } + if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) { + std::fs::create_dir_all(dir)?; + } + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "custom-dictionary.txt".into()); + let tmp = path.with_file_name(format!(".{name}.{}.tmp", std::process::id())); + let written = std::fs::write(&tmp, out).and_then(|()| std::fs::rename(&tmp, path)); + if written.is_err() { + let _ = std::fs::remove_file(&tmp); + } + written +} + /// Common words the source list lacks (modern vocabulary, contractions). const COMMON: &str = "a an the and or but if of to in on at by for with from as is am are was were be been being it its we us our you your he him his she her they them their i me my mine this that these those there here what which who whom whose when where why how all any both each few more most other some such no nor not only own same so than too very can will just should now do does did done has have had having may might must shall would could up down out over under again further then once off into onto upon about above below between through during before after until while because per via vs etc ok yes yeah oh ah hi hey bye one two three four five six seven eight nine ten first last next new old get got go goes went gone see saw seen say said make made take took come came give gave know knew think thought look want need use used find found tell told ask asked feel felt try left keep let put mean set run show turn move live play pay hear hold bring begin seem help talk start might"; @@ -134,7 +250,7 @@ fn known_core(w: &str) -> bool { if base_known(d, w) || COMMON.split(' ').any(|c| c == w) { return true; } - if user_words().read().unwrap_or_else(|e| e.into_inner()).contains(w) { + if user_words().read().unwrap_or_else(|e| e.into_inner()).contains(w) || ignored_words().read().unwrap_or_else(|e| e.into_inner()).contains(w) { return true; } // Inflections. @@ -701,6 +817,93 @@ mod tests { assert!(is_correct("zxqwordcrafty")); } + /// #40: the user dictionary survives a save and a fresh load. + #[test] + fn user_dictionary_persists_through_a_file() { + let dir = std::env::temp_dir().join(format!("wc-dict-{}-{}", std::process::id(), "persist")); + let _ = std::fs::remove_dir_all(&dir); + // The folder is created on the first save. + let path = dir.join("sub/custom-dictionary.txt"); + add_word("zxqpersistone"); + add_word("zxqpersisttwo"); + save_user_dictionary(&path, &[]).expect("save works"); + let saved = std::fs::read_to_string(&path).unwrap(); + assert!(saved.lines().any(|w| w == "zxqpersistone") && saved.lines().any(|w| w == "zxqpersisttwo"), "{saved}"); + // Written atomically: no temporary file is left beside it. + let names: Vec<_> = std::fs::read_dir(dir.join("sub")).unwrap().map(|e| e.unwrap().file_name()).collect(); + assert_eq!(names, ["custom-dictionary.txt"]); + remove_word("zxqpersistone"); + assert!(!is_correct("zxqpersistone")); + load_user_dictionary(&path); + assert!(is_correct("zxqpersistone"), "loading brings the word back"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// #40: a hostile file is capped: invalid lines are skipped, and only the first megabyte is read. + #[test] + fn hostile_dictionary_files_are_capped() { + let dir = std::env::temp_dir().join(format!("wc-dict-{}-{}", std::process::id(), "hostile")); + let _ = std::fs::create_dir_all(&dir); + let path = dir.join("custom-dictionary.txt"); + let mut hostile = vec![0xff, 0xfe, b'\n']; + hostile.extend(b"zxqhostileok\n zxqhostiletrim \nzxq hostile space\nzxq\x07bell\n"); + hostile.extend("zxq".repeat(30).bytes()); // 90 characters: too long + hostile.push(b'\n'); + // Pad past the 1 MB cap with blank lines; the word after it is never read. + hostile.resize(1_000_100, b'\n'); + hostile.extend(b"zxqhostilepastcap\n"); + std::fs::write(&path, &hostile).unwrap(); + let added = load_user_dictionary(&path); + assert!(added.contains(&"zxqhostileok".to_string()) && added.contains(&"zxqhostiletrim".to_string()), "{added:?}"); + assert!(!added.iter().any(|w| w.contains("space") || w.contains("bell") || w.chars().count() > MAX_CUSTOM_WORD_CHARS), "{added:?}"); + assert!(!is_correct("zxqhostilepastcap"), "the file is read only up to the cap"); + assert_eq!(read_dictionary_file(&path).len(), 2); + let _ = std::fs::remove_dir_all(&dir); + } + + /// #40: saving merges in words another process saved, and leaves out the ones just removed. + #[test] + fn saving_merges_with_the_file() { + let dir = std::env::temp_dir().join(format!("wc-dict-{}-{}", std::process::id(), "merge")); + let _ = std::fs::create_dir_all(&dir); + let path = dir.join("custom-dictionary.txt"); + std::fs::write(&path, "zxqmergeother\nzxqmergegone\n").unwrap(); + add_word("zxqmergemine"); + save_user_dictionary(&path, &["zxqmergegone".to_string()]).unwrap(); + let saved = std::fs::read_to_string(&path).unwrap(); + assert!(saved.lines().any(|w| w == "zxqmergeother"), "the other process's word is kept: {saved}"); + assert!(saved.lines().any(|w| w == "zxqmergemine"), "{saved}"); + assert!(!saved.lines().any(|w| w == "zxqmergegone"), "a removed word stays out: {saved}"); + assert!(is_correct("zxqmergeother") && !is_correct("zxqmergegone")); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Ignore All lasts for the session: the word is accepted but isn't in the dictionary. + #[test] + fn ignored_words_are_not_dictionary_words() { + assert!(!is_correct("zxqignoredword")); + ignore_word("ZxqIgnoredWord"); + assert!(is_correct("zxqignoredword")); + assert!(!user_dictionary().contains(&"zxqignoredword".to_string())); + } + + #[test] + fn custom_words_are_single_short_words() { + assert_eq!(custom_word(" Hello "), Some("hello".into())); + for bad in ["", " ", "two words", "tab\tbed", "bell\u{7}", "bad\u{FFFD}", &"x".repeat(MAX_CUSTOM_WORD_CHARS + 1)] { + assert_eq!(custom_word(bad), None, "{bad:?}"); + } + assert!(custom_word(&"é".repeat(MAX_CUSTOM_WORD_CHARS)).is_some(), "the cap counts characters, not bytes"); + } + + /// #40: a missing file loads as empty, never an error. + #[test] + fn missing_dictionary_file_loads_empty() { + let path = std::env::temp_dir().join(format!("wc-dict-{}-missing.txt", std::process::id())); + let _ = std::fs::remove_file(&path); + assert!(load_user_dictionary(&path).is_empty()); + } + /// #100: runs are proofed when their language has a dictionary (the default language too) /// and skipped otherwise. #[test] diff --git a/docs/gaps.md b/docs/gaps.md index a0d76361b..c7bc6fd59 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -69,6 +69,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | Date | Change | Summary | |---|---|---| +| 2026-10-12 | trivial | Custom dictionary persists across sessions (`custom-dictionary.txt` in the settings folder; #40) | | 2026-10-11 | trivial | #8 narrowed: runs in a proofing language without a dictionary are skipped instead of flagged against the English dictionary (#100); dictionaries still English-only | | 2026-10-11 | trivial | #16: DOCX open/save preserves independent complex-script formatting inheritance; RTL sections, tables and typography remain open | | 2026-10-11 | trivial | `.doc` pictures, shapes and text boxes read (#551); gap #14 narrowed to the writer and Word 6/95 files | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 63265d568..a82ba7f33 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -66,7 +66,7 @@ full parity for the area. | 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. 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 / Combine with comparison settings (case, white space, formatting, tables, word or character level, #412), Restrict Editing pane with formatting and editing restrictions, exceptions for everyone and a password (#412). 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. Show revisions in balloons takes tracked deletions in the body out of the text into "Deleted: …" balloons (insertions stay inline, as in Word; deletions in headers, footers, notes and text boxes, and in print and PDF, stay inline). 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 | 40% | (in Review) | 20–40 | English and Polish, chosen per run from its language (`w:lang`): English word list (Moby plus our supplement of modern words such as "desktop", #520) and rule-based grammar; Polish against the installed Hunspell dictionary through a Hunspell-style affix engine, Polish grammar rules and TeX hyphenation patterns (2026-10-10). Other languages are left unmarked. Word ships 120 proofing tools for ~50 languages plus Editor (style refinements, similarity). Issues #25, #40, #100. | +| Proofing (spelling, grammar, thesaurus, languages) | 5 | 40% | (in Review) | 20–40 | English and Polish, chosen per run from its language (`w:lang`): English word list (Moby plus our supplement of modern words such as "desktop", #520) and rule-based grammar; Polish against the installed Hunspell dictionary through a Hunspell-style affix engine, Polish grammar rules and TeX hyphenation patterns (2026-10-10). Other languages are left unmarked. The custom dictionary persists across sessions (#40). 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 or CSV (#247); no Excel/Outlook/Contacts 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), WordArt: text fill (solid, gradient, none), outline, shadow, glow and reflection with our own styles, and Transform warps along arch, circle, wave, slant, inflate/deflate, chevron and triangle (other presets kept and drawn flat), connectors (straight, elbow, curved, arrowheads) glued to shapes' connection sites and re-routed when those move (#416). Missing: tight/through contour wrap, rotated text-box text, group (#267 open), shape effects, WordArt 3-D/bevel and editing text in its bent form, connector routing around shapes, Drawing Canvas, Icons, online pictures, screenshot. | | 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. | @@ -251,6 +251,7 @@ can be used without committing Word output. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | trivial | Proofing: custom dictionary persists across sessions (#40); no percentage change | | 2026-10-11 | trivial | Proofing: runs in a language without a dictionary are skipped instead of flagged against the English dictionary (#100); no percentage change | | 2026-10-11 | minor | Polish interface (13 UI languages) and Polish proofing with per-run languages: Proofing 35% → 40% (25–45 h → 20–40 h), Localization 25% → 27%; feature depth and readiness unchanged at this precision | | 2026-10-11 | trivial | Tables: column and row borders and the resize handle drag on the page (#217); Tables row and interaction-fidelity evidence updated, no percentage change | From 417896301bd0a4296ba09541c4a96ddd66da232b Mon Sep 17 00:00:00 2001 From: Rees <109076338+OPUZZLES@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:30:49 +0200 Subject: [PATCH 40/88] M1: preserve native Word ink through DOCX saves (#592) Co-authored-by: Brandon Thomas Co-authored-by: Brandon Thomas --- ROADMAP.md | 2 + crates/doc/src/graphic.rs | 12 +- crates/docx/src/read/story.rs | 73 ++++++++++-- crates/docx/src/write/embed.rs | 57 ++++++++- crates/docx/src/write/story.rs | 42 ++++--- crates/docx/src/xml.rs | 51 +++++++- crates/docx/tests/fixtures.rs | 205 +++++++++++++++++++++++++++++++++ docs/file-format-parity.md | 8 +- docs/gaps.md | 5 +- docs/roadmap.md | 6 +- 10 files changed, 424 insertions(+), 37 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 674457930..bef690d2c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -199,6 +199,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-11 | Hungarian (`hu`) interface: all 2,419 catalog entries, built-in style names translated, `hu-HU` system locales and a saved language choice; locale, catalog and bundled-font tests; no Hungarian proofing resources | | 2026-10-11 | Turkish (`tr`) interface: all 1,307 catalog entries, `tr-TR`/`tr-CY` system locales and a saved language choice; command search keeps Turkish dotted and dotless I apart (`italik` finds İtalik); locale, catalog, search and bundled-font tests; no Turkish proofing resources | | 2026-10-11 | Italian (`it`) interface (#594): 2,399 entries, feature names as Italian Word users know them, locale and saved-preference tests, bundled-font checks; no Italian proofing resources | +| 2026-10-12 | Native drawing-level Word ink preserved through DOCX edits and repeated saves, including its InkML part and optional VML picture fallback; native ink rendering and editing remain missing | | 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 | DOCX paragraph defaults: preserve omission separately from explicit empty defaults, with repeated-save and default-edit regressions | @@ -262,6 +263,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-11 | trivial | Collapsed duplicate rows left by parallel merges | | 2026-10-11 | trivial | Hungarian interface (`hu`): progress log and shipped languages | | 2026-10-11 | trivial | Turkish interface (`tr`): progress log and shipped languages | +| 2026-10-12 | minor | Native drawing-level ink and its picture fallback survive DOCX edits and repeated saves; adapted to the current graphic writer; rendering and editing InkML remain missing | | 2026-10-11 | trivial | Progress log: eight more bibliography styles (#383) | | 2026-10-10 | trivial | Progress log: Draw tab ink (#307) | | 2026-10-10 | trivial | Draw Table and Eraser (#303): catalog 393/431 (91.2%) | diff --git a/crates/doc/src/graphic.rs b/crates/doc/src/graphic.rs index 091a0b426..c6e448f67 100644 --- a/crates/doc/src/graphic.rs +++ b/crates/doc/src/graphic.rs @@ -1,4 +1,4 @@ -//! Charts and SmartArt diagrams: drawings kept as neutral items ready to draw. Charts and SmartArt +//! Charts, SmartArt diagrams and native ink: drawings kept as neutral items ready to draw. Charts and SmartArt //! WordCraft can edit also keep their model ([`crate::chart::ChartSpec`], //! [`crate::smart_art::SmartArtSpec`]); the rest are shown, not edited. @@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize}; use crate::para::ShapeKind; use crate::props::Rgb; -/// A chart or a SmartArt diagram, as items ready to draw (and, for an editable chart or SmartArt +/// A chart, SmartArt diagram or native ink preview, as items ready to draw (and, for an editable chart or SmartArt /// graphic, its model). #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -22,7 +22,7 @@ pub struct Graphic { pub w: f32, #[serde(default)] pub h: f32, - /// Where the graphic was read from, so saving writes the chart or diagram itself back (not + /// Where the graphic was read from, so saving writes the original drawing itself back (not /// only these items). `None` for graphics made some other way. #[serde(skip)] pub source: Option>, @@ -44,7 +44,8 @@ pub struct Graphic { #[derive(Clone, Debug, Default, PartialEq)] pub struct Embedded { /// The object's markup as a standalone XML element with its namespace declarations (DOCX: - /// the `a:graphic` of a chart or SmartArt diagram, the `w:object` of an OLE object). + /// the `a:graphic` of a chart, SmartArt diagram or native ink, an ink `mc:AlternateContent`, + /// or the `w:object` of an OLE object). pub xml: String, /// The relationships it uses, by the ids in `xml` (or in its parts, for a SmartArt drawing). pub refs: Vec, @@ -68,6 +69,8 @@ pub enum GraphicKind { #[default] Chart, Diagram, + /// Word's native InkML, kept opaque; displayed as its fallback picture when available. + Ink, } impl GraphicKind { @@ -76,6 +79,7 @@ impl GraphicKind { match self { GraphicKind::Chart => "chart", GraphicKind::Diagram => "diagram", + GraphicKind::Ink => "ink", } } } diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index 617708e71..05ca4eaea 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -14,11 +14,14 @@ use wordcraft_doc::{Block, Blocks, InlineObject, Paragraph, PartKind, RevisionKi use super::Reader; use super::props::{sectpr, tcpr, trpr}; use super::shape_style::{preset_kind, shape_paint, vml_color}; -use crate::package::{Rels, rt}; +use crate::package::{ContentTypes, Rels, rt}; use crate::units::{int, measure, u32_of}; -use crate::xml::El; +use crate::xml::{El, Node}; use wordcraft_doc::wordart::TextWarp; +const INK_URI: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingInk"; +const INK_REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml"; + /// Deepest table nesting we build (deeper tables become plain paragraphs). const MAX_TABLE_DEPTH: usize = 24; /// Deepest field nesting we track. @@ -163,7 +166,7 @@ fn clean_text(s: &str) -> String { fn children_of_choice(ac: &El) -> Option<&El> { // Prefer a Choice whose requirements we understand; else the Fallback; else any Choice. - const KNOWN: &[&str] = &["wps", "wpg", "wpc", "w14", "w15", "wp14", "a14", "w16se", "w16cid", "w16", "w16cex", "w16sdtdh", "v"]; + const KNOWN: &[&str] = &["wps", "wpg", "wpc", "wpi", "w14", "w15", "wp14", "a14", "w16se", "w16cid", "w16", "w16cex", "w16sdtdh", "v"]; let ok = |c: &El| c.attr("Requires").unwrap_or("").split_whitespace().all(|r| KNOWN.contains(&r)); ac.children("mc:Choice").find(|c| ok(c)).or_else(|| ac.child("mc:Fallback")).or_else(|| ac.child("mc:Choice")) } @@ -500,7 +503,10 @@ impl Reader<'_> { } } "mc:AlternateContent" => { - if let Some(c) = children_of_choice(k) { + if let Some(o) = self.read_ink_alternate(sc, k, rels) { + let props = self.run_props_none(ctx); + self.emit_obj(sc, pb, o, &props); + } else if let Some(c) = children_of_choice(k) { self.read_inline_children(sc, pb, c, rels, ctx, depth + 1); } } @@ -661,7 +667,9 @@ impl Reader<'_> { } "w:ruby" => self.read_ruby(sc, pb, k, props), "mc:AlternateContent" => { - if let Some(c) = children_of_choice(k) { + if let Some(o) = self.read_ink_alternate(sc, k, rels) { + self.emit_obj(sc, pb, o, props); + } else if let Some(c) = children_of_choice(k) { for x in c.els() { self.read_run_child(sc, pb, x, props, rels, note, depth + 1); } @@ -881,6 +889,25 @@ impl Reader<'_> { let gd = c.child("a:graphic").and_then(|g| g.child("a:graphicData"))?; // Checked by URI before the blip search: a diagram's data can hold a blip further down. let uri = gd.attr("uri").unwrap_or(""); + if uri == INK_URI { + let content = gd.child("w14:contentPart")?; + let part = super::part_of(rels, content.attr("r:id")?, INK_REL)?; + let pkg = self.pkg; + if self.content_types.get_or_insert_with(|| ContentTypes::read(pkg)).of(&part) != Some("application/inkml+xml") { + return None; + } + let source = self.keep_embedded(c.child("a:graphic")?, rels, &[])?; + float.set_spin(xfrm_spin(content.child("w14:xfrm"))); + let (px, py) = float.spin_pad(w, h); + for (e, p) in float.effect.iter_mut().zip([px, py, px, py]) { + *e = (*e - p).clamp(0.0, 1584.0); + } + // The renderer's existing missing-picture box marks ink without a usable preview. + // InkML stays opaque: no approximation of its strokes or conversion to freeforms. + let items = vec![GraphicItem::Image { rect: [0.0, 0.0, w, h], media: String::new() }]; + let graphic = Arc::new(Graphic { kind: GraphicKind::Ink, items, w, h, source: Some(source), ..Default::default() }); + return Some(InlineObject::Graphic { w, h, alt, float, graphic }); + } let graphic_kind = if uri.ends_with("/chart") { Some(GraphicKind::Chart) } else if uri.ends_with("/diagram") { @@ -892,7 +919,7 @@ impl Reader<'_> { // Kept so saving writes the chart or diagram back: the frame around it is written // from the object's (possibly moved or resized) size and position. let extra: Vec = match kind { - GraphicKind::Chart => Vec::new(), + GraphicKind::Chart | GraphicKind::Ink => Vec::new(), GraphicKind::Diagram => self.diagram_drawing_rel(gd, rels).into_iter().collect(), }; let source = c.child("a:graphic").and_then(|g| self.keep_embedded(g, rels, &extra)); @@ -938,6 +965,34 @@ impl Reader<'_> { Some(obj) } + /// Keep the selected native-ink branch and its picture fallback as one object. Reading only + /// the fallback flattens the ink; reading only the choice loses its preview on the next save. + fn read_ink_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option { + let choice = children_of_choice(ac).filter(|e| e.name == "mc:Choice")?; + let drawing = choice.child("w:drawing")?; + if drawing.find("a:graphicData")?.attr("uri") != Some(INK_URI) { + return None; + } + let fallback = ac.child("mc:Fallback"); + let preview = fallback.and_then(|f| f.child("w:pict")).and_then(|p| self.read_vml(sc, p, rels)); + let Some(mut object) = self.read_drawing(sc, drawing, rels) else { return preview }; + if let InlineObject::Graphic { w, h, graphic, .. } = &mut object { + let graphic = Arc::make_mut(graphic); + if let Some(InlineObject::Image { media, .. }) = preview { + graphic.items = vec![GraphicItem::Image { rect: [0.0, 0.0, *w, *h], media }]; + } + if let Some(fallback) = fallback { + let kept = + El { name: "mc:AlternateContent".into(), attrs: Vec::new(), kids: vec![Node::El(choice.clone()), Node::El(fallback.clone())] }; + if let Some(source) = self.keep_embedded(&kept, rels, &[]) { + graphic.source = Some(source); + } + // A malformed or over-budget fallback must not discard a valid native part. + } + } + Some(object) + } + /// The chart or SmartArt diagram in an `a:graphicData`, as items in points from its top-left /// corner. Charts are drawn from their cached data, SmartArt diagrams from the drawing Word /// stores beside them. Built once per part and size; once the file's graphic budget is spent, @@ -946,6 +1001,7 @@ impl Reader<'_> { let part = match kind { GraphicKind::Chart => gd.child("c:chart").and_then(|c| c.attr("r:id")).and_then(|id| super::part_of(rels, id, rt::CHART)), GraphicKind::Diagram => self.diagram_part(gd, rels), + GraphicKind::Ink => None, }; let Some(path) = part else { return Arc::new(Graphic { kind, items: Vec::new(), w, h, source, chart: None, smart_art: None }) }; let key = (kind, path, w.to_bits(), h.to_bits()); @@ -978,6 +1034,7 @@ impl Reader<'_> { None => Vec::new(), }, GraphicKind::Diagram => self.diagram_drawing(path, w, h), + GraphicKind::Ink => Vec::new(), } } @@ -1649,7 +1706,9 @@ struct Xfrm { /// The rotation and flips of the `a:xfrm` in shape properties `sppr` (ECMA-376 §20.1.7.6: /// `rot` in 60000ths of a degree, clockwise; `flipH`, `flipV`). None when missing or junk. fn xfrm_spin(sppr: Option<&El>) -> wordcraft_geom::Spin { - let Some(x) = sppr.and_then(|s| s.child("a:xfrm")) else { return wordcraft_geom::Spin::default() }; + let Some(x) = sppr.and_then(|s| if matches!(s.name.as_str(), "a:xfrm" | "w14:xfrm") { Some(s) } else { s.child("a:xfrm") }) else { + return wordcraft_geom::Spin::default(); + }; let rot = x.attr("rot").and_then(int).map(|v| v.rem_euclid(21_600_000) as f32 / 60_000.0).unwrap_or(0.0); wordcraft_geom::Spin::new(rot, on_off_attr(x, "flipH"), on_off_attr(x, "flipV")) } diff --git a/crates/docx/src/write/embed.rs b/crates/docx/src/write/embed.rs index 8d43ec616..c76012a60 100644 --- a/crates/docx/src/write/embed.rs +++ b/crates/docx/src/write/embed.rs @@ -1,4 +1,4 @@ -//! Writing back charts, SmartArt diagrams and OLE objects read from a file (see `read::embed`). +//! Writing back charts, SmartArt diagrams, native ink and OLE objects read from a file (see `read::embed`). //! //! Each written object gets its kept markup with fresh relationship ids, pointing at copies of the //! parts it needs. The first object to use a part writes it under its original name (unless that @@ -16,7 +16,7 @@ use super::PartRels; use crate::package::{ CUSTOM_XML_ROOTS, EMBEDDED_PARTS, EmbeddedManifest, MAX_EMBEDDED_PARTS, MAX_EMBEDDED_RELS, VBA_PROJECT_PART, VBA_RELATED, rel_is, relative, rt, }; -use crate::units::emu; +use crate::units::{MAX_LEN_PT, emu, int}; use crate::xml::{self, El, MAX_DEPTH}; /// Most bytes written for copies of parts (objects pasted more than once). @@ -251,6 +251,12 @@ impl super::Writer<'_> { /// 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, &str)>) -> Option { + self.embedded_tree(src, rels, resize).map(|root| root.to_xml()) + } + + /// The same bounded preservation operation, retaining the parsed tree for a caller that + /// must rebuild an ink AlternateContent drawing frame without reparsing unbound prefixes. + pub(super) fn embedded_tree(&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; @@ -305,7 +311,7 @@ impl super::Writer<'_> { if let Some((w, h, float, alt)) = resize { self.resize_object(&mut root, w, h, float, alt, 0); } - Some(root.to_xml()) + Some(root) } /// The `a:graphic` of a SmartArt graphic written from its model, laid out `w` × `h` points: @@ -437,6 +443,51 @@ impl super::Writer<'_> { } } } + + /// Ink's own transform need not fill its outer drawing frame. Scale its original offsets + /// and extents proportionally; an unrelated edit must not stretch the preserved strokes. + pub(super) fn ink_transform(e: &mut El, from: (f32, f32), to: (f32, f32), float: &Float, depth: usize) { + if depth > MAX_DEPTH { + return; + } + if e.name == "w14:xfrm" { + e.attrs.retain(|(k, _)| !matches!(k.as_str(), "rot" | "flipH" | "flipV")); + for (k, v) in super::story::spin_attrs(float.spin()) { + e.attrs.push((k.to_string(), v)); + } + let scale = |old: f32, new: f32| if old.is_finite() && old > 0.0 && new.is_finite() { (new.max(0.0) / old) as f64 } else { 1.0 }; + let (sx, sy) = (scale(from.0, to.0), scale(from.1, to.1)); + let max = MAX_LEN_PT as f64 * 12_700.0; + for c in e.kids.iter_mut().filter_map(|k| match k { + xml::Node::El(c) => Some(c), + _ => None, + }) { + if !matches!(c.name.as_str(), "a:off" | "a:ext") { + continue; + } + for (key, value) in &mut c.attrs { + let factor = match key.as_str() { + "x" | "cx" => sx, + "y" | "cy" => sy, + _ => continue, + }; + if factor != 1.0 + && let Some(n) = int(value) + { + let min = if c.name == "a:ext" { 0.0 } else { -max }; + *value = ((n as f64 * factor).clamp(min, max).round() as i64).to_string(); + } + } + } + return; + } + for c in e.kids.iter_mut().filter_map(|k| match k { + xml::Node::El(c) => Some(c), + _ => None, + }) { + Self::ink_transform(c, from, to, float, depth + 1); + } + } } /// Set an extent's `cx` and `cy` (those it has) to `w` × `h` points. diff --git a/crates/docx/src/write/story.rs b/crates/docx/src/write/story.rs index a7f09976e..830184a37 100644 --- a/crates/docx/src/write/story.rs +++ b/crates/docx/src/write/story.rs @@ -524,38 +524,50 @@ impl Writer<'_> { w.close("w:r"); self.rev_close(w, props); } - // A chart or diagram read from a file: its frame from the object's size and position, - // the graphic inside as read. Ones made some other way have nothing to write, except - // charts and SmartArt with a model (made or edited in WordCraft): their parts are - // written from it. + // Editable charts and SmartArt keep main's model writer; opaque graphics and native + // ink keep their original source and related parts. InlineObject::Graphic { w: gw, h: gh, alt, float, graphic } => { - let inner = match (graphic.chart.as_deref(), graphic.smart_art.as_deref(), graphic.source.as_deref()) { - // A chart WordCraft can edit: a chart part written from its model. - (Some(spec), _, _) => self.chart_graphic(spec, rels), - (None, Some(spec), _) => self.smart_art_graphic(spec, *gw, *gh, rels), - (None, None, Some(src)) => { - let Some(inner) = self.embedded_xml(src, rels, None) else { return }; - inner - } - (None, None, None) => return, + let ink = graphic.kind == wordcraft_doc::graphic::GraphicKind::Ink; + let root = match (graphic.chart.as_deref(), graphic.smart_art.as_deref(), graphic.source.as_deref()) { + (Some(spec), _, _) => crate::xml::parse(self.chart_graphic(spec, rels).as_bytes()).ok(), + (None, Some(spec), _) => crate::xml::parse(self.smart_art_graphic(spec, *gw, *gh, rels).as_bytes()).ok(), + (None, None, Some(src)) => self.embedded_tree(src, rels, ink.then_some((*gw, *gh, float, alt.as_str()))), + (None, None, None) => None, }; - if inner.is_empty() { + let Some(mut root) = root else { return }; + if ink { + Self::ink_transform(&mut root, (graphic.w, graphic.h), (*gw, *gh), float, 0); + } + let alternate = (ink && root.name == "mc:AlternateContent").then_some(&root); + let fallback = alternate.and_then(|e| e.child("mc:Fallback")); + let native = alternate.and_then(|e| e.child("mc:Choice")).and_then(|e| e.find("a:graphic")); + if alternate.is_some() && native.is_none() { return; } self.rev_open(w, props); w.open("w:r", &[]); self.rpr(w, props); + if fallback.is_some() { + w.open("mc:AlternateContent", &[]); + w.open("mc:Choice", &[("Requires", "wpi")]); + } w.open("w:drawing", &[]); let docpr = self.next_docpr(); let name = match graphic.kind { wordcraft_doc::graphic::GraphicKind::Chart => format!("Chart {docpr}"), wordcraft_doc::graphic::GraphicKind::Diagram => format!("Diagram {docpr}"), + wordcraft_doc::graphic::GraphicKind::Ink => format!("Ink {docpr}"), }; self.drawing_open(w, float, *gw, *gh, &docpr, &name, alt); w.empty("wp:cNvGraphicFramePr", &[]); - w.raw(&inner); + w.raw(&native.unwrap_or(&root).to_xml()); w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" }); w.close("w:drawing"); + if let Some(fallback) = fallback { + w.close("mc:Choice"); + w.raw(&fallback.to_xml()); + w.close("mc:AlternateContent"); + } w.close("w:r"); self.rev_close(w, props); } diff --git a/crates/docx/src/xml.rs b/crates/docx/src/xml.rs index 925122c70..2d4747841 100644 --- a/crates/docx/src/xml.rs +++ b/crates/docx/src/xml.rs @@ -6,7 +6,7 @@ use quick_xml::XmlVersion; use quick_xml::events::Event; -use quick_xml::name::ResolveResult; +use quick_xml::name::{QName, ResolveResult}; use quick_xml::reader::NsReader; use crate::DocxError; @@ -26,6 +26,7 @@ pub const NAMESPACES: &[(&str, &str)] = &[ ("pic", "http://schemas.openxmlformats.org/drawingml/2006/picture"), ("mc", "http://schemas.openxmlformats.org/markup-compatibility/2006"), ("w14", "http://schemas.microsoft.com/office/word/2010/wordml"), + ("wpi", "http://schemas.microsoft.com/office/word/2010/wordprocessingInk"), ("w15", "http://schemas.microsoft.com/office/word/2012/wordml"), ("wp14", "http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing"), ("wps", "http://schemas.microsoft.com/office/word/2010/wordprocessingShape"), @@ -81,6 +82,24 @@ fn prefix_for(uri: &[u8], extra: &[(&'static str, &str)]) -> &'static str { "?" } +/// Compatibility feature namespaces whose elements are not otherwise handled by this tree. +/// Keep the reader's existing feature set when canonicalizing `mc:Choice/@Requires` aliases. +fn requirement_prefix(uri: &[u8]) -> &'static str { + let prefix = prefix_for(uri, &[]); + if prefix != "?" { + return prefix; + } + match uri { + b"http://schemas.microsoft.com/office/drawing/2010/main" => "a14", + b"http://schemas.microsoft.com/office/word/2015/wordml/symex" => "w16se", + b"http://schemas.microsoft.com/office/word/2016/wordml/cid" => "w16cid", + b"http://schemas.microsoft.com/office/word/2018/wordml" => "w16", + b"http://schemas.microsoft.com/office/word/2018/wordml/cex" => "w16cex", + b"http://schemas.microsoft.com/office/word/2020/wordml/sdtdatahash" => "w16sdtdh", + _ => "?", + } +} + #[derive(Clone, Debug, PartialEq)] pub enum Node { El(El), @@ -288,6 +307,28 @@ pub fn parse_ns(bytes: &[u8], extra: &[(&'static str, &str)]) -> Result v.into_owned(), Err(_) => String::from_utf8_lossy(&a.value).into_owned(), }; + // Requires contains namespace prefixes, not feature names. Resolve them while + // the namespace scope still exists; declarations are omitted from this tree. + let val = if el.name == "mc:Choice" && aname == "Requires" { + let requirements: Vec<_> = val.split_whitespace().take(65).collect(); + if requirements.len() > 64 || requirements.iter().any(|prefix| prefix.len() > 128) { + "?".to_string() + } else { + requirements + .into_iter() + .map(|prefix| { + let qualified = format!("{prefix}:requirement"); + match r.resolver_mut().resolve_attribute(QName(qualified.as_bytes())).0 { + ResolveResult::Bound(ns) => requirement_prefix(ns.as_ref()), + _ => "?", + } + }) + .collect::>() + .join(" ") + } + } else { + val + }; el.attrs.push((aname, val)); } if matches!(ev, Event::Start(_)) { @@ -489,6 +530,14 @@ mod tests { assert_eq!(t.attr("xml:space"), Some("preserve")); } + #[test] + fn choice_requirements_resolve_namespace_aliases_and_rebindings() { + let x = br#""#; + let e = parse(x).unwrap(); + let values: Vec<_> = e.children("mc:Choice").map(|c| c.attr("Requires")).collect(); + assert_eq!(values, [Some("wpi"), Some("?"), Some("?")]); + } + #[test] fn deep_nesting_is_error() { let mut s = String::new(); diff --git a/crates/docx/tests/fixtures.rs b/crates/docx/tests/fixtures.rs index 9742806aa..e3b95d194 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/fixtures.rs @@ -1513,3 +1513,208 @@ fn vml_lines_and_rectangles() { } assert!(matches!(&objs[1], InlineObject::Shape { kind: ShapeKind::Rectangle, fill: Some(Rgb(255, 0, 0)), stroke: None, w: 30.0, h: 20.0, .. })); } + +// ---- native Word ink: opaque preservation, never re-encoded as WordCraft freeforms ---- + +const INK_NS: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingInk"; +const INK_XML: &str = r##"0 0, 1 1, 2 0"##; +const INK_TYPES: &str = r#""#; + +fn ink_docx(fallback: bool, alias: bool) -> Vec { + let content = r#""#; + let drawing = format!( + r#"{content}"# + ); + let prefix = if alias { "pen" } else { "wpi" }; + let markup = if fallback { + format!( + r##"{drawing}"## + ) + } else { + drawing + }; + let body = format!("Original{markup}"); + let mut relationships = vec![("rInk", "customXml", "ink/ink1.xml")]; + let mut extra = vec![("[Content_Types].xml", INK_TYPES), ("word/ink/ink1.xml", INK_XML)]; + if fallback { + relationships.push(("rPreview", "image", "media/preview.png")); + extra.push(("word/media/preview.png", "original preview bytes")); + } + docx(&body, &relationships, &extra) +} + +fn check_ink(files: &std::collections::BTreeMap>) -> String { + let body = text(files, "word/document.xml"); + let [id] = attr_values(&body, "w14:contentPart", "r:id").try_into().unwrap_or_else(|_| panic!("ink reference lost: {body}")); + let (kind, target) = rel(&text(files, "word/_rels/document.xml.rels"), &id); + assert!(kind.ends_with("/customXml")); + let part = part_at("word", &target); + assert_eq!(text(files, &part), INK_XML); + assert!(text(files, "[Content_Types].xml").contains(&format!(r#"PartName="/{part}" ContentType="application/inkml+xml""#))); + part +} + +#[test] +fn native_ink_survives_unrelated_edit_and_two_saves() { + let mut d = wordcraft_docx::read(&ink_docx(false, false)).unwrap(); + let g = graphics(&d); + assert_eq!(g.len(), 1, "native ink must remain a model object"); + let InlineObject::Graphic { graphic, .. } = &g[0] else { panic!() }; + assert_eq!(graphic.kind.noun(), "ink"); + assert!(graphic.source.is_some()); + assert!(!graphic.items.is_empty(), "a missing preview must not silently leave a blank frame"); + let p = std::sync::Arc::make_mut(&mut d.body[0]); + let Block::Para(p) = p else { panic!() }; + p.delete(0, p.len()).unwrap(); + p.insert_text(0, "Edited", &Default::default()).unwrap(); + for _ in 0..2 { + let out = wordcraft_docx::write(&d).unwrap(); + let files = unzip(&out); + check_ink(&files); + assert!(text(&files, "word/document.xml").contains("Edited")); + d = wordcraft_docx::read(&out).unwrap(); + assert_eq!(graphics(&d).len(), 1); + } +} + +#[test] +fn native_ink_keeps_alternate_content_and_preview_with_renamed_prefixes() { + for alias in [false, true] { + let mut d = wordcraft_docx::read(&ink_docx(true, alias)).unwrap(); + let g = graphics(&d); + assert_eq!(g.len(), 1, "fallback must not replace native ink"); + let InlineObject::Graphic { graphic, .. } = &g[0] else { panic!() }; + assert!( + graphic.items.iter().any(|item| matches!(item, wordcraft_doc::graphic::GraphicItem::Image { media, .. } if d.media.contains_key(media))) + ); + for _ in 0..2 { + let out = wordcraft_docx::write(&d).unwrap(); + let files = unzip(&out); + check_ink(&files); + let body = text(&files, "word/document.xml"); + assert!(body.contains("") && body.contains("")); + let [id] = attr_values(&body, "v:imagedata", "r:id").try_into().unwrap(); + let (kind, target) = rel(&text(&files, "word/_rels/document.xml.rels"), &id); + assert!(kind.ends_with("/image")); + assert_eq!(text(&files, &part_at("word", &target)), "original preview bytes"); + d = wordcraft_docx::read(&out).unwrap(); + assert_eq!(graphics(&d).len(), 1); + } + } +} + +#[test] +fn native_ink_missing_part_uses_available_fallback() { + let bytes = ink_docx(true, true); + let mut files = unzip(&bytes); + files.remove("word/ink/ink1.xml"); + let entries: Vec<_> = files.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect(); + let d = wordcraft_docx::read(&zip(&entries)).unwrap(); + assert!(graphics(&d).is_empty()); + assert!(paras(&d).iter().flat_map(|p| &p.objects).any(|o| matches!(o, InlineObject::Image { .. }))); + let files = unzip(&wordcraft_docx::write(&d).unwrap()); + assert!(!text(&files, "word/document.xml").contains("w14:contentPart")); + assert!(files.values().any(|bytes| bytes == b"original preview bytes")); +} + +#[test] +fn native_ink_missing_optional_preview_keeps_native_part() { + let mut files = unzip(&ink_docx(true, false)); + files.remove("word/media/preview.png"); + let entries: Vec<_> = files.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect(); + let d = wordcraft_docx::read(&zip(&entries)).unwrap(); + assert_eq!(graphics(&d).len(), 1); + let files = unzip(&wordcraft_docx::write(&d).unwrap()); + check_ink(&files); + assert!(!text(&files, "word/document.xml").contains("")); +} + +#[test] +fn native_ink_requires_the_bound_namespace_and_ink_content_type() { + for wrong_namespace in [true, false] { + let mut files = unzip(&ink_docx(true, false)); + let name = if wrong_namespace { "word/document.xml" } else { "[Content_Types].xml" }; + let old = text(&files, name); + let changed = if wrong_namespace { + old.replace(&format!(r#"xmlns:wpi="{INK_NS}""#), r#"xmlns:wpi="urn:future:unsupported""#) + } else { + old.replace("application/inkml+xml", "application/xml") + }; + files.insert(name.into(), changed.into_bytes()); + let entries: Vec<_> = files.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect(); + let d = wordcraft_docx::read(&zip(&entries)).unwrap(); + assert!(graphics(&d).is_empty()); + assert!(paras(&d).iter().flat_map(|p| &p.objects).any(|o| matches!(o, InlineObject::Image { .. }))); + } +} + +#[test] +fn native_ink_copies_have_independent_parts_and_current_frames() { + let mut d = wordcraft_docx::read(&ink_docx(true, false)).unwrap(); + let [mut object] = graphics(&d).try_into().unwrap(); + let InlineObject::Graphic { w, h, float, .. } = &mut object else { panic!() }; + (*w, *h) = (288.0, 144.0); + *float = wordcraft_doc::para::Float { wrap: Wrap::Square, h_rel: Anchor::Page, v_rel: Anchor::Page, x: 72.0, y: 36.0, ..Default::default() }; + let mut p = Paragraph::new(); + p.insert_object(0, object.clone(), &Default::default()).unwrap(); + p.insert_object(p.len(), object, &Default::default()).unwrap(); + d.body = vec![wordcraft_doc::para_block(p)]; + let out = wordcraft_docx::write(&d).unwrap(); + let files = unzip(&out); + let body = text(&files, "word/document.xml"); + let ids = attr_values(&body, "w14:contentPart", "r:id"); + assert_eq!(ids.len(), 2); + let parts: Vec<_> = ids.iter().map(|id| part_at("word", &rel(&text(&files, "word/_rels/document.xml.rels"), id).1)).collect(); + assert_ne!(parts[0], parts[1]); + for part in parts { + assert_eq!(text(&files, &part), INK_XML); + } + assert_eq!(attr_values(&body, "wp:extent", "cx"), ["3657600", "3657600"]); + assert_eq!(attr_values(&body, "a:ext", "cx"), ["3657600", "3657600"]); + assert_eq!(attr_values(&body, "v:shape", "style"), ["width:288pt;height:144pt", "width:288pt;height:144pt"]); + assert_eq!(body.matches("914400").count(), 2); + let back = wordcraft_docx::read(&out).unwrap(); + let g = graphics(&back); + assert_eq!(g.len(), 2); + for object in g { + let InlineObject::Graphic { w, h, float, .. } = object else { panic!() }; + assert_eq!((w, h, float.x, float.y), (288.0, 144.0, 72.0, 36.0)); + } +} + +#[test] +fn native_ink_markup_limit_does_not_write_a_dangling_reference() { + let mut files = unzip(&ink_docx(false, false)); + let body = text(&files, "word/document.xml").replace(" = files.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect(); + let d = wordcraft_docx::read(&zip(&entries)).unwrap(); + let files = unzip(&wordcraft_docx::write(&d).unwrap()); + assert!(!text(&files, "word/document.xml").contains("w14:contentPart")); +} + +#[test] +fn native_ink_preserves_inner_transform_and_rotation_padding_on_unrelated_saves() { + let mut files = unzip(&ink_docx(false, false)); + let body = text(&files, "word/document.xml") + .replace("", r#""#) + .replace(r#""#, r#""#); + files.insert("word/document.xml".into(), body.into_bytes()); + let entries: Vec<_> = files.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect(); + let mut d = wordcraft_docx::read(&zip(&entries)).unwrap(); + for _ in 0..2 { + let [object] = graphics(&d).try_into().unwrap(); + let InlineObject::Graphic { float, .. } = object else { panic!() }; + assert_eq!(float.spin().deg, 45.0); + assert!(float.effect.iter().all(|v| v.abs() < 0.001), "rotation padding is not counted twice: {:?}", float.effect); + let out = wordcraft_docx::write(&d).unwrap(); + let files = unzip(&out); + check_ink(&files); + let body = text(&files, "word/document.xml"); + assert_eq!(attr_values(&body, "a:off", "x"), ["127000"]); + assert_eq!(attr_values(&body, "a:ext", "cx"), ["914400"]); + assert_eq!(attr_values(&body, "w14:xfrm", "rot"), ["2700000"]); + d = wordcraft_docx::read(&out).unwrap(); + } +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index c5da6ca15..0b38c646d 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -67,7 +67,8 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | 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), with data labels (`c:showVal`). Charts made in WordCraft (Insert › Chart, #409) are written as a `c:chartSpace` part with their data as literals (`c:strLit`/`c:numLit`, no embedded workbook, so Word shows them and offers to create a workbook on Edit Data) and read back as editable; a chart part holding anything more (any chart from Word, or one re-saved by Word) stays read-only. 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). SmartArt made in WordCraft (#497) is written from its model: data (`dgm:dataModel` points and connections), our own layout, quick-style and colour definitions under the built-in layout identifiers, and a `dsp:drawing` with every shape placed; it reads back editable only when its data part is exactly what WordCraft writes | | 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 | +| Native Word ink (`w14:contentPart`, InkML) | 🟡 | 🟡 | Drawing-level ink kept opaque with its InkML part, relationship and content type; run-level `mc:AlternateContent` keeps the native branch and VML picture fallback. Shows the preview or a missing-picture box. Repeated saves, copies, resize, rotation and namespace aliases tested with original fixtures. No native stroke rendering, eraser, conversion or replay; direct run-level content parts and ink nested in groups/canvases remain unsupported | +| 3D models | ❌ | ❌ | Dropped on read, not preserved on save | | Equations (OMML `m:oMath`) | ✅ | ✅ | #191; manual breaks (`m:brk`) and the settings' `m:mathPr` wrapping options (#326) | | Themes, font table, settings, compatibility mode | ✅ | ✅ | Embedded fonts (`w:embedRegular`) not read; attached template (`w:attachedTemplate` and its external relationship) and `w:linkStyles` round-trip (#377). The template path comes from the file, so opening never reads it: styles update from it only on an explicit Update Styles | | Custom XML parts, document properties, bibliography sources | 🟡 | 🟡 | Custom XML stores reached from the package/main document preserve opaque data, properties and relationships; binding evaluation remains absent. Custom properties round-trip; bibliography model I/O remains separate (#532 open) | @@ -131,8 +132,8 @@ Windows/Linux runtime behavior, ACLs and extended metadata, real disk-full condi 1. A **real-world DOCX corpus** opened, rendered and round-tripped, compared page by page with Word (local only: Word output can't be committed; keep a manifest and our own renders). -2. **Preserve what we can't render**: ink and drawing canvases survive a round trip - (keep the parts and the run). Charts, SmartArt and OLE objects do (#292, #319). +2. **Preserve what we can't render**: remaining ink forms and unsupported graphics survive a round trip + (keep the parts and the run). Charts, SmartArt and OLE objects do (#292, #319); drawing-level native ink now keeps its InkML and optional picture fallback. 3. Move tracking (`w:moveFrom`/`w:moveTo`) read and written (formatting revisions: #41; content controls: #381). 4. Encrypted `.docx` opens with a password. @@ -191,6 +192,7 @@ raise readiness estimates. No Word-derived files are fixtures. | 2026-10-11 | minor | Preserve imported TOC ranges independently of paragraph styles, with explicit evaluation limits | | 2026-10-11 | trivial | Import/export fixes (#433–#440): TXT opens through the encoding-sniffing reader (Windows-1252, unmarked UTF-16) and saves list labels; RTF `\uN` in document info, bookmark names and field instructions; ODT common paragraph styles' text formatting and table-cell background colours; HTML borderless tables export without cell borders and `white-space:pre` keeps edge spaces; LaTeX brace groups scope `\centering`/`\raggedleft`/`\raggedright` | | 2026-10-11 | trivial | `.doc` import fixes per [MS-DOC]: piece Prm0/Prm1 decoding (#458), section-break kinds (#459), explicit orientation (#460), negative `sprmPIlfo` list references (#461), zero-based `istdBase` style inheritance (#462), font-table entries with alternate names (#463) | +| 2026-10-11 | minor | Native drawing-level ink and its picture fallback survive DOCX edits and repeated saves; rendering and editing InkML remain missing | | 2026-10-11 | trivial | Formatting revisions (`w:rPrChange`, `w:pPrChange`, table, row, cell, section and numbering changes) read and written (#41) | | 2026-10-11 | trivial | Omitted `w:pPrDefault` stays omitted across saves; explicit empty/default properties and later spacing edits remain writable. Synthetic preservation checks only; readiness estimates unchanged | | 2026-10-10 | trivial | OLE objects turned or flipped in WordCraft keep the turn on save (VML `rotation`/`flip`, DrawingML `a:xfrm` with the rotated effect extent); test counts refreshed | diff --git a/docs/gaps.md b/docs/gaps.md index c7bc6fd59..ff66ff864 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -14,7 +14,7 @@ Native save reliability: synthetic cases on macOS reproduced loss through the pr | # | Gap | B | Evidence | Impact | Hours | Doc | |---|---|---|---|---|---|---| | 1 | **DOCX never tested on real-world files.** No corpus of files from Word, Google Docs, LibreOffice and Pages has been opened, rendered and round-tripped and compared with Word page by page | B | No corpus in the repo; `crates/docx/tests` are synthetic fixtures (including omitted/empty paragraph defaults and imported TOC range preservation); "DOCX fidelity corpus: not started" since 2026-10-06 | Everyone: the files people receive are the files that break | 30–50 | [file-format-parity.md](file-format-parity.md) | -| 2 | **Ink and drawing canvases are dropped on open and lost on save, silently**; OLE objects show only their picture | B | No ink or drawing-canvas (`wpc`) handling in `crates/docx`; group shapes (`wpg`) read and written (#267). Charts and SmartArt are drawn (#292) and, with OLE objects, written back with all their parts on save (#319) | Documents with diagrams drawn in Word or pen notes lose content without warning | 4–8 to preserve ink and drawing canvases; 45–70 to render and edit charts and SmartArt (#13) | [file-format-parity.md](file-format-parity.md) | +| 2 | **Remaining native ink forms are dropped on open and lost on save, silently**; OLE objects show only their picture | B | Drawing-level native ink is preserved with its optional picture fallback; direct run-level content parts and ink nested in groups/canvases remain unsupported. Drawing canvases are read and written (#344); group shapes (`wpg`) read and written (#267). Charts and SmartArt are drawn (#292) and, with OLE objects, written back with all their parts on save (#319) | Documents with diagrams drawn in Word or pen notes lose content without warning | 4–8 to preserve remaining native ink forms; 45–70 to render and edit charts and SmartArt (#13) | [file-format-parity.md](file-format-parity.md) | | 3 | **Crashes and freezes in the field**: startup crash on Intel UHD Graphics (#170) (lag on GPUs without a DirectX 12 driver fixed, #316), freezes (#77, #59 Thai fonts, #31 font selector), no writes without admin on Windows 11 (#218), broken shape tool (#142), images can't be moved (#82) | B | 62 open issues, ~15 of them crashes, hangs or data-path failures | Anyone hit loses work or trust; the never-crash standard covers panics, not hangs or GPU driver faults | 20–30 | [target-app-parity.md](target-app-parity.md) (Stability) | | 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** landed (#381): `w:sdt` kept, shown and edited (Developer tab, Design Mode, Properties). 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. Opaque custom XML backing stores now survive saves; binding evaluation remains absent. | 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) | @@ -24,7 +24,7 @@ Native save reliability: synthetic cases on macOS reproduced loss through the pr | 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 3-D effects and connector routing around shapes (WordArt effects and warps and glued connectors landed, #416), 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**: 7 of the 12 key languages missing (hi, ar, fr, id, ko, vi, pt-PT); no mirrored RTL interface; ~5% of strings still English in the 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); 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) | +| 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, rendering/editing Word's own ink (drawing-level `w14:contentPart`/InkML now preserved on DOCX save) | | [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** (editing charts and SmartArt from Word, more chart types and formatting) | | `insert.chart` and Chart Design basics landed (#409): WordCraft's own charts are editable; charts from Word stay read-only (no embedded-workbook editing), no combo/3D/radar/stock charts, axis or series formatting; Insert › SmartArt landed (#497) for graphics WordCraft makes | Reports and business documents | 35–55 | [target-app-parity.md](target-app-parity.md) | | 14 | **`.doc` can't be written; 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) | @@ -102,6 +102,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | 2026-10-11 | trivial | #24: Word's field keys (Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, F11, Shift+F11) (#339) | | 2026-10-10 | trivial | #24: 125 of ~250 keyboard shortcuts after #330 | | 2026-10-11 | trivial | Collapsed duplicate rows left by parallel merges | +| 2026-10-11 | minor | Native drawing-level ink and its picture fallback survive DOCX edits and repeated saves; rendering and editing InkML remain missing | | 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 | Omitted `w:pPrDefault` stays omitted across saves; explicit empty/default properties and later spacing edits remain writable. Synthetic preservation checks only; readiness estimates unchanged | diff --git a/docs/roadmap.md b/docs/roadmap.md index f753b35f6..ef1a7a71b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -14,8 +14,9 @@ Opus 5.5 agent wall-clock. 2. **DOCX real-world corpus** (gap #1, 30–50 h): collect openly licensed real-world .docx files, open/render/round-trip them, compare with Word locally, fix what breaks. Omitted/empty paragraph defaults now have synthetic save/reopen coverage; the corpus gate remains open. -3. **Preserve what we can't render** (gap #2, 10–15 h): charts, SmartArt, OLE, ink, group shapes - survive a round trip and show their fallback pictures. +3. **Preserve what we can't render** (gap #2, 10–15 h): remaining native ink forms and unsupported + graphics survive a round trip. Drawing-level native ink now keeps its InkML and optional picture + fallback alongside chart, SmartArt, OLE and drawing-canvas support. 4. **Stability sweep** (gap #3, 20–30 h): the open crash, hang and write-failure issues, plus an autosave/recovery soak test. 5. **Pagination fidelity** (gap #4, 30–50 h): column balancing, footnote continuation, compat @@ -75,6 +76,7 @@ languages, screen-reader access, ecosystem decisions (VBA, add-ins, cloud), an i | 2026-10-11 | trivial | M15: Polish interface and Polish proofing landed | | 2026-10-11 | trivial | M3: icons landed (#309) | | 2026-10-11 | trivial | M11: contour (Tight/Through) wrap done (#354) | +| 2026-10-11 | minor | Native drawing-level ink and its picture fallback survive DOCX edits and repeated saves; rendering and editing InkML remain missing | | 2026-10-11 | trivial | M6: the eight missing bibliography styles landed (#383) | | 2026-10-11 | trivial | M7: formatting revisions landed (#41) | | 2026-10-11 | trivial | Omitted `w:pPrDefault` stays omitted across saves; explicit empty/default properties and later spacing edits remain writable. Synthetic preservation checks only; readiness estimates unchanged | From 2f9bf4e800ae1696a64f24129177e6a1379401cc Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Mon, 12 Oct 2026 13:35:11 +0200 Subject: [PATCH 41/88] AutoCorrect: TWo INitial CApitals, cAPS LOCK and table-cell capitals (#544) (#545) - Correct TWo INitial CApitals: a finished word of 3+ letters with two leading capitals and the rest lowercase loses its second capital ("WOrd" -> "Word"), before the replacement list ("TEh" -> "The"). Built-in exceptions (IDs, CDs, PCs, TVs, ...) plus user exceptions. - Correct accidental cAPS LOCK: "wORD" -> "Word" (not iOS, mRNA, ...). - Capitalize first letter of table cells, under its own option. - Session::autocorrect_caps (AutoCorrectCaps, all on by default); tools.autocorrect takes twoInitialCaps / capsLock / capCells / addException / removeException. - Each fix is one undo step right after it (the existing AutoFormat snapshot); case changes replace letter by letter, keeping formatting. - Tests in tests_typing.rs; docs/typing-parity.md and ROADMAP updated. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 1 + crates/engine/src/cmd/tools.rs | 100 +++++++++++++++++++++++++-- crates/engine/src/tests_typing.rs | 55 +++++++++++++++ crates/ui-egui/src/dialogs_review.rs | 4 ++ crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/et.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sr-latn.tsv | 2 + crates/ui-egui/src/i18n/sr.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + docs/typing-parity.md | 19 ++++- 22 files changed, 207 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index bef690d2c..6cfce04db 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -135,6 +135,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 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-12 | AutoCorrect capitals (#544): Correct TWo INitial CApitals (with exceptions), accidental cAPS LOCK, first letter of table cells; each undoable and switchable | | 2026-10-11 | Turkish (`tr`) interface completed (#774): 2,325 entries, every key of the Russian catalog (equation gallery, built-in style names, watermarks, paper sizes, preview tiles); the interface audit checks Turkish too | | 2026-10-12 | Navigation headings truncate within the resizable pane, with an ellipsis for long titles and full text on hover (#759) | | 2026-10-11 | Reading Mode: existing View ribbon button now opens a read-only, viewport-column layout with animated horizontal paging, transient display controls, heading navigation (Navigation pane) and resize anchoring; document layout and export unchanged | diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index 3e137f86e..c7f5c9505 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -71,12 +71,16 @@ pub struct AutoCorrectPrefs { pub enabled: bool, /// Replace text as you type (the replacement list). pub replace_text: bool, + /// Correct TWo INitial CApitals: "WOrd" → "Word" (not the exceptions, e.g. "IDs"). + pub two_initial_caps: bool, /// Capitalize the first letter of sentences. pub cap_sentences: bool, /// Capitalize the first letter of table cells. pub cap_cells: bool, /// Capitalize names of days. pub cap_days: bool, + /// Correct accidental usage of cAPS LOCK: "wORD" → "Word". + pub caps_lock: bool, /// AutoFormat As You Type: "straight quotes" with “smart quotes”. pub smart_quotes: bool, /// Fractions (1/2) with fraction characters (½). @@ -101,6 +105,8 @@ pub struct AutoCorrectPrefs { pub exceptions: Vec, /// Built-in abbreviations the user deleted from the exceptions. pub exceptions_removed: Vec, + /// The user's words that keep two initial capitals (besides the built-in ones). + pub initial_caps_exceptions: Vec, } impl Default for AutoCorrectPrefs { @@ -108,9 +114,11 @@ impl Default for AutoCorrectPrefs { AutoCorrectPrefs { enabled: true, replace_text: true, + two_initial_caps: true, cap_sentences: true, cap_cells: true, cap_days: true, + caps_lock: true, smart_quotes: true, fractions: true, ordinals: true, @@ -123,6 +131,7 @@ impl Default for AutoCorrectPrefs { removed: Vec::new(), exceptions: Vec::new(), exceptions_removed: Vec::new(), + initial_caps_exceptions: Vec::new(), } } } @@ -145,7 +154,7 @@ impl AutoCorrectPrefs { let mut seen = std::collections::HashSet::new(); self.entries.retain(|(a, b)| ok(a, MAX_FROM) && b.chars().count() <= MAX_TO && !b.contains(['\n', '\r']) && seen.insert(a.clone())); self.entries.truncate(MAX_ENTRIES); - for list in [&mut self.removed, &mut self.exceptions, &mut self.exceptions_removed] { + for list in [&mut self.removed, &mut self.exceptions, &mut self.exceptions_removed, &mut self.initial_caps_exceptions] { let mut seen = std::collections::HashSet::new(); list.retain(|a| ok(a, MAX_FROM) && seen.insert(a.clone())); list.truncate(MAX_ENTRIES); @@ -216,9 +225,11 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult { for (k, f) in [ ("enabled", &mut ac.enabled as &mut bool), ("replaceText", &mut ac.replace_text), + ("twoInitialCaps", &mut ac.two_initial_caps), ("capSentences", &mut ac.cap_sentences), ("capCells", &mut ac.cap_cells), ("capDays", &mut ac.cap_days), + ("capsLock", &mut ac.caps_lock), ("smartQuotes", &mut ac.smart_quotes), ("fractions", &mut ac.fractions), ("ordinals", &mut ac.ordinals), @@ -273,13 +284,31 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult { ac.exceptions_removed.push(e); } } + if let Some(w) = p::str(v, "addInitialCapsException") { + let w = w.trim(); + if bad(w, MAX_FROM) || w.contains(char::is_whitespace) { + return Err(CmdError::Params("an INitial CAps exception is one word, like \"IDs\"".into())); + } + if !INITIAL_CAPS_EXCEPTIONS.contains(&w) + && !ac.initial_caps_exceptions.iter().any(|e| e == w) + && ac.initial_caps_exceptions.len() < MAX_ENTRIES + { + ac.initial_caps_exceptions.push(w.to_string()); + } + } + if let Some(w) = p::str(v, "deleteInitialCapsException") { + let w = w.trim(); + ac.initial_caps_exceptions.retain(|e| e != w); + } let ac = &s.prefs.autocorrect; Ok(json!({ "enabled": ac.enabled, "replaceText": ac.replace_text, + "twoInitialCaps": ac.two_initial_caps, "capSentences": ac.cap_sentences, "capCells": ac.cap_cells, "capDays": ac.cap_days, + "capsLock": ac.caps_lock, "smartQuotes": ac.smart_quotes, "fractions": ac.fractions, "ordinals": ac.ordinals, @@ -291,6 +320,7 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult { "entries": ac.list().into_iter().map(|(a, b, builtin)| json!({"from": a, "to": b, "builtIn": builtin})).collect::>(), "user": ac.entries, "exceptions": ac.exception_list(), + "initialCapsExceptions": INITIAL_CAPS_EXCEPTIONS.iter().map(|e| e.to_string()).chain(ac.initial_caps_exceptions.iter().cloned()).collect::>(), })) } @@ -321,7 +351,7 @@ pub fn specs() -> Vec { .pure(), CommandSpec::new("tools.autocorrect", "AutoCorrect Options", "File › Options › Proofing", autocorrect_options) .params( - r#"{"enabled"?: bool (all of AutoCorrect), "replaceText"|"capSentences"|"capCells"|"capDays"|"smartQuotes"|"fractions"|"ordinals"|"dashes"|"links"|"bullets"|"numbering"|"borderLines"?: bool, "add"?: {"from": string, "to": string} (adds or replaces an entry), "delete"?: string (an entry's "from", built in or added), "addException"?: string, "deleteException"?: string} → options, entries and exceptions"#, + r#"{"enabled"?: bool (all of AutoCorrect), "replaceText"|"twoInitialCaps"|"capSentences"|"capCells"|"capDays"|"capsLock"|"smartQuotes"|"fractions"|"ordinals"|"dashes"|"links"|"bullets"|"numbering"|"borderLines"?: bool, "add"?: {"from": string, "to": string} (adds or replaces an entry), "delete"?: string (an entry's "from", built in or added), "addException"?: string, "deleteException"?: string, "addInitialCapsException"?: string (keeps TWo INitial CApitals, like "IDs"), "deleteInitialCapsException"?: string} → options, entries and exceptions"#, ) .pure(), CommandSpec::new("tools.customizeKeyboard", "Customize Keyboard", "File › Options", customize_keyboard) @@ -714,7 +744,7 @@ pub fn autocorrect_word(s: &mut Session, on_enter: bool) -> Result<(), CmdError> return Ok(()); } let ac = s.prefs.autocorrect.clone(); - let f = s.sel.focus.clone(); + let mut f = s.sel.focus.clone(); let Some(text) = s.doc.para_at(&f).map(|p| p.text.clone()) else { return Ok(()) }; let Some(before) = text.get(..f.off) else { return Ok(()) }; let Some(last) = before.chars().next_back() else { return Ok(()) }; @@ -724,10 +754,29 @@ pub fn autocorrect_word(s: &mut Session, on_enter: bool) -> Result<(), CmdError> let trigger = if on_enter { 0 } else { last.len_utf8() }; let Some(body) = before.get(..before.len() - trigger) else { return Ok(()) }; let start = body.rfind(char::is_whitespace).map(|i| i + 1).unwrap_or(0); - let Some(word) = body.get(start..).map(str::to_string) else { return Ok(()) }; + let Some(mut word) = body.get(start..).map(str::to_string) else { return Ok(()) }; if word.is_empty() { return Ok(()); } + // TWo INitial CApitals and cAPS LOCK come first, so "TEh" is still a replacement's typo. + if let Some((at, reps)) = case_fix(&ac, &word) { + let para = s.doc.para_mut(f.story, &f.path)?; + let letters = word.get(at..).unwrap_or("").chars().take(reps.len()).collect::(); + let mut end = start + at + letters.len(); + for (c, rep) in letters.chars().rev().zip(reps.iter().rev()) { + let from = end.saturating_sub(c.len_utf8()); + if rep.chars().ne(std::iter::once(c)) { + let props = para.props_of_char(from).clone(); + para.delete(from, end)?; + para.insert_text(from, rep, &props)?; + } + end = from; + } + let fixed: String = reps.concat(); + f.off = (f.off + fixed.len()).saturating_sub(letters.len()); + s.sel = Selection::caret(f.clone()); + word = format!("{}{fixed}{}", word.get(..at).unwrap_or(""), word.get(at + letters.len()..).unwrap_or("")); + } // A whole-word replacement, else "wait..." → "wait…". let found = if ac.replace_text { ac.replacement(&word) } else { None }; let (from, rep) = match found { @@ -804,6 +853,49 @@ pub fn autocorrect_word(s: &mut Session, on_enter: bool) -> Result<(), CmdError> Ok(()) } +/// Built-in words that keep their two initial capitals (plurals of two-letter abbreviations). +pub const INITIAL_CAPS_EXCEPTIONS: &[&str] = &["CDs", "IDs", "PCs", "TVs", "DJs", "GBs", "KBs", "MBs", "TBs", "VMs"]; + +/// Words with a lowercase first letter and the rest in capitals that aren't cAPS LOCK slips. +const CAPS_LOCK_EXCEPTIONS: &[&str] = &["iOS", "mRNA", "tRNA", "rRNA", "cDNA", "eSIM", "eSATA", "iSCSI", "vCPU"]; + +/// The capitalisation fix for a finished word: TWo INitial CApitals ("WOrd" → "Word") or cAPS +/// LOCK ("wORD" → "Word"). Returns the byte offset of the word's letters and each letter's +/// replacement. Only words of three or more letters, optionally in quotes or brackets. +fn case_fix(caps: &AutoCorrectPrefs, word: &str) -> Option<(usize, Vec)> { + if !caps.two_initial_caps && !caps.caps_lock { + return None; + } + let at = word.find(char::is_alphabetic)?; + let rest = word.get(at..)?; + let letters = rest.get(..rest.find(|c: char| !c.is_alphabetic()).unwrap_or(rest.len()))?; + let tail = rest.get(letters.len()..)?; + if !word.get(..at)?.chars().all(|c| "\"'“‘([".contains(c)) || !tail.chars().all(|c| "\"'”’)]".contains(c)) { + return None; + } + let cs: Vec = letters.chars().collect(); + let (&a, &b, more) = match cs.as_slice() { + [a, b, more @ ..] if !more.is_empty() => (a, b, more), + _ => return None, + }; + let one = |c: char| c.to_string(); + let lower = |c: char| c.to_lowercase().collect::(); + if caps.two_initial_caps + && a.is_uppercase() + && b.is_uppercase() + && more.iter().all(|c| c.is_lowercase()) + && !INITIAL_CAPS_EXCEPTIONS.contains(&letters) + && !caps.initial_caps_exceptions.iter().any(|e| e == letters) + { + return Some((at, std::iter::once(one(a)).chain(std::iter::once(lower(b))).chain(more.iter().map(|&c| one(c))).collect())); + } + if caps.caps_lock && a.is_lowercase() && b.is_uppercase() && more.iter().all(|c| c.is_uppercase()) && !CAPS_LOCK_EXCEPTIONS.contains(&letters) { + let up = a.to_uppercase().collect::(); + return Some((at, std::iter::once(up).chain(std::iter::once(lower(b))).chain(more.iter().map(|&c| lower(c))).collect())); + } + None +} + /// Byte length of an ordinal suffix to superscript ("1st" → 2), if `word` is a correct ordinal. fn ordinal_suffix(word: &str) -> Option { let digits = word.find(|c: char| !c.is_ascii_digit())?; diff --git a/crates/engine/src/tests_typing.rs b/crates/engine/src/tests_typing.rs index 48a5d92a2..8e03dd0d4 100644 --- a/crates/engine/src/tests_typing.rs +++ b/crates/engine/src/tests_typing.rs @@ -634,6 +634,61 @@ fn shift_f5_goes_back_through_the_last_edits() { assert!(Session::new(wordcraft_doc::Document::new()).run("edit.goBack", &json!({})).is_err(), "nothing edited yet"); } +#[test] +fn two_initial_capitals_are_corrected() { + let s = typed("the WOrd and THe end, not IDs or CDs. TV is fine "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "The Word and The end, not IDs or CDs. TV is fine "); + // "TEh" loses its second capital, then the replacement list fixes the typo. + assert_eq!(typed("TEh ").doc.plain_text(StoryRef::Body), "The "); + // A user exception. + let mut s = typed(""); + run(&mut s, "tools.autocorrect", json!({"addInitialCapsException": "WCraft"})); + typ(&mut s, "WCraft "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "WCraft "); +} + +#[test] +fn accidental_caps_lock_is_corrected() { + let s = typed("a wORD and hELLO there. iOS stays "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "A Word and Hello there. iOS stays "); +} + +#[test] +fn capitalisation_fixes_undo_and_turn_off() { + let mut s = typed("WOrd"); + typ(&mut s, " "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Word "); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "WOrd "); + let mut s = typed("x wORD"); + typ(&mut s, " "); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "X wORD "); + let mut s = typed(""); + run(&mut s, "tools.autocorrect", json!({"twoInitialCaps": false, "capsLock": false})); + typ(&mut s, "x WOrd wORD "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "X WOrd wORD "); +} + +#[test] +fn first_letter_of_a_table_cell_is_capitalised() { + let cell_text = |s: &Session, c: usize| { + s.doc.body.iter().find_map(|b| b.as_table()).and_then(|t| t.rows.first()?.cells.get(c)?.blocks.first()?.as_para().map(|p| p.text.clone())) + }; + let mut s = typed(""); + run(&mut s, "insert.table", json!({"rows": 1, "cols": 2})); + typ(&mut s, "apples and pears\tplums "); + assert_eq!(cell_text(&s, 0).as_deref(), Some("Apples and pears")); + assert_eq!(cell_text(&s, 1).as_deref(), Some("Plums ")); + run(&mut s, "edit.undo", json!({})); + assert_eq!(cell_text(&s, 1).as_deref(), Some("plums ")); + let mut s = typed(""); + run(&mut s, "tools.autocorrect", json!({"capCells": false})); + run(&mut s, "insert.table", json!({"rows": 1, "cols": 1})); + typ(&mut s, "plums "); + assert_eq!(cell_text(&s, 0).as_deref(), Some("plums ")); +} + /// File › Options › Advanced, "Typing replaces selected text" (#485): on (Word's default) the /// typed text replaces the selection; off, it goes in front of the selection and keeps it. #[test] diff --git a/crates/ui-egui/src/dialogs_review.rs b/crates/ui-egui/src/dialogs_review.rs index f74a4136d..263c9b562 100644 --- a/crates/ui-egui/src/dialogs_review.rs +++ b/crates/ui-egui/src/dialogs_review.rs @@ -336,9 +336,11 @@ impl AutoCorrectForm { let o = &self.options; json!({ "replaceText": o.replace_text, + "twoInitialCaps": o.two_initial_caps, "capSentences": o.cap_sentences, "capCells": o.cap_cells, "capDays": o.cap_days, + "capsLock": o.caps_lock, "smartQuotes": o.smart_quotes, "fractions": o.fractions, "ordinals": o.ordinals, @@ -410,6 +412,7 @@ fn autocorrect_ui(app: &mut WordApp, ui: &mut Ui, f: &mut AutoCorrectForm) -> bo fn autocorrect_tab(app: &mut WordApp, ui: &mut Ui, f: &mut AutoCorrectForm) { let o = &mut f.options; + ui.checkbox(&mut o.two_initial_caps, tl!("Correct TWo INitial CApitals")); ui.checkbox(&mut o.cap_sentences, tl!("Capitalize first letter of sentences")); ui.horizontal(|ui| { ui.add_space(18.0); @@ -423,6 +426,7 @@ fn autocorrect_tab(app: &mut WordApp, ui: &mut Ui, f: &mut AutoCorrectForm) { let o = &mut f.options; ui.checkbox(&mut o.cap_cells, tl!("Capitalize first letter of table cells")); ui.checkbox(&mut o.cap_days, tl!("Capitalize names of days")); + ui.checkbox(&mut o.caps_lock, tl!("Correct accidental usage of cAPS LOCK key")); ui.checkbox(&mut o.replace_text, tl!("Replace text as you type")); egui::Grid::new("ac_boxes").num_columns(2).spacing(vec2(8.0, 2.0)).show(ui, |ui| { ui.label(tl!("Replace:")); diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 996f6f9f1..1e84700e6 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2058,3 +2058,5 @@ Reading Column Width Šířka sloupce pro čtení Reading Line Spacing Řádkování pro čtení Reading Text Size Velikost textu pro čtení + Correct TWo INitial CApitals Opravovat DVě POčáteční VElká písmena + Correct accidental usage of cAPS LOCK key Opravovat náhodné použití klávesy cAPS LOCK diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 915d2783f..35c7f83a9 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2052,3 +2052,5 @@ Reading Column Width Spaltenbreite beim Lesen Reading Line Spacing Zeilenabstand beim Lesen Reading Text Size Textgröße beim Lesen + Correct TWo INitial CApitals ZWei GRoßbuchstaben am WOrtanfang korrigieren + Correct accidental usage of cAPS LOCK key Unbeabsichtigtes Verwenden der fESTSTELLTASTE korrigieren diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4034d2b4b..098589448 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1468,6 +1468,8 @@ Border lines Líneas de borde Capitalize first letter of sentences Poner en mayúscula la primera letra de las oraciones Capitalize first letter of table cells Poner en mayúscula la primera letra de las celdas + Correct TWo INitial CApitals Corregir DOs MAyúsculas SEguidas + Correct accidental usage of cAPS LOCK key Corregir el uso accidental de bLOQ mAYÚS Capitalize names of days Poner en mayúscula los nombres de los días Case changes Cambios de mayúsculas Character level Nivel de carácter diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index fd1fb1644..b6be2424f 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -1447,6 +1447,8 @@ Border lines Äärisjooned Capitalize first letter of sentences Lausete esitäht suureks Capitalize first letter of table cells Tabelilahtrite esitäht suureks + Correct TWo INitial CApitals Paranda KAks SUurt algustähte + Correct accidental usage of cAPS LOCK key Paranda kogemata sisse jäänud cAPS LOCK Capitalize names of days Nädalapäevade nimed suure algustähega Case changes Tähesuuruse muutused Character level Märgi tasandil diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 9e5f797d2..8a7f1e713 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2061,3 +2061,5 @@ Reading Column Width Largeur de colonne de lecture Reading Line Spacing Interligne de lecture Reading Text Size Taille du texte de lecture + Correct TWo INitial CApitals Corriger DEux MAjuscules EN début de mot + Correct accidental usage of cAPS LOCK key Corriger l’utilisation accidentelle de la touche vERR. mAJ diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 82e742942..e076dc0b3 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2451,3 +2451,5 @@ Reading Column Width Olvasási oszlopszélesség Reading Line Spacing Olvasási sorköz Reading Text Size Olvasási szövegméret + Correct TWo INitial CApitals KÉt KEzdő NAgybetű javítása + Correct accidental usage of cAPS LOCK key A cAPS LOCK billentyű véletlen használatának javítása diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 84bc2af16..aa2a09fdf 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2437,3 +2437,5 @@ preview Title Esempio Reading Column Width Larghezza colonna di lettura Reading Line Spacing Interlinea di lettura Reading Text Size Dimensione testo di lettura + Correct TWo INitial CApitals Correggi DUe MAiuscole INiziali + Correct accidental usage of cAPS LOCK key Correggi l'uso accidentale del tasto bLOC mAIUSC diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 037ee2422..1c49d8889 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1428,6 +1428,8 @@ Border lines 罫線 Capitalize first letter of sentences 文の先頭文字を大文字にする Capitalize first letter of table cells 表のセルの先頭文字を大文字にする + Correct TWo INitial CApitals 先頭 2 文字の大文字を修正する + Correct accidental usage of cAPS LOCK key Caps Lock キーの押し間違いを修正する Capitalize names of days 曜日の先頭文字を大文字にする Case changes 大文字/小文字の変更 Character level 文字単位 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 43249f460..91cdbb5a0 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2094,3 +2094,5 @@ Reading Column Width Szerokość kolumny do czytania Reading Line Spacing Interlinia do czytania Reading Text Size Rozmiar tekstu do czytania + Correct TWo INitial CApitals Poprawiaj DWie POczątkowe WIelkie litery + Correct accidental usage of cAPS LOCK key Poprawiaj przypadkowe użycie klawisza cAPS LOCK diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 392c3f204..00b4fa955 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1440,6 +1440,8 @@ Border lines Linhas de borda Capitalize first letter of sentences Primeira letra das frases em maiúscula Capitalize first letter of table cells Primeira letra das células em maiúscula + Correct TWo INitial CApitals Corrigir DUas INiciais MAiúsculas + Correct accidental usage of cAPS LOCK key Corrigir o uso acidental da tecla cAPS LOCK Capitalize names of days Nomes dos dias em maiúscula Case changes Alterações de maiúsculas Character level Nível de caractere diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index ab441c678..f6ac8166d 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2037,6 +2037,8 @@ preview Title Пример Border lines Линии границ Capitalize first letter of sentences Делать первые буквы предложений прописными Capitalize first letter of table cells Делать первые буквы ячеек таблиц прописными + Correct TWo INitial CApitals Исправлять ДВе ПРописные буквы в начале слова + Correct accidental usage of cAPS LOCK key Устранять последствия случайного нажатия cAPS LOCK Capitalize names of days Названия дней с прописной буквы Case changes Изменения регистра Character level На уровне символов diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 1aab2b39b..755e7238d 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -1438,6 +1438,8 @@ Border lines Linije ivica Capitalize first letter of sentences Veliko prvo slovo rečenice Capitalize first letter of table cells Veliko prvo slovo u ćelijama tabele + Correct TWo INitial CApitals Ispravi DVa POčetna velika slova + Correct accidental usage of cAPS LOCK key Ispravi slučajno uključen cAPS LOCK Capitalize names of days Veliko slovo u nazivima dana Case changes Promene veličine slova Character level Nivo znaka diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 2f5fb999c..e52ceaad4 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -1442,6 +1442,8 @@ Border lines Линије ивица Capitalize first letter of sentences Велико прво слово реченице Capitalize first letter of table cells Велико прво слово у ћелијама табеле + Correct TWo INitial CApitals Исправи ДВа ПОчетна велика слова + Correct accidental usage of cAPS LOCK key Исправи случајно укључен cAPS LOCK Capitalize names of days Велико слово у називима дана Case changes Промене величине слова Character level Ниво знака diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index ad2074a18..dd84ba95f 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2039,6 +2039,8 @@ preview Title Örnek Border lines Kenarlık çizgileri Capitalize first letter of sentences Cümlelerin ilk harfini büyük yap Capitalize first letter of table cells Tablo hücrelerinin ilk harfini büyük yap + Correct TWo INitial CApitals İKi BAşharfi büyük olan sözcükleri düzelt + Correct accidental usage of cAPS LOCK key cAPS LOCK tuşunun yanlışlıkla kullanımını düzelt Capitalize names of days Gün adlarını büyük harfle başlat Case changes Büyük/küçük harf değişiklikleri Character level Karakter düzeyi diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index f4829bc13..bc82b66ee 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1440,6 +1440,8 @@ Border lines Лінії меж Capitalize first letter of sentences Перша літера речення велика Capitalize first letter of table cells Перша літера в клітинках таблиці велика + Correct TWo INitial CApitals Виправляти ДВі ВЕликі літери на початку слова + Correct accidental usage of cAPS LOCK key Виправляти випадкове натискання cAPS LOCK Capitalize names of days Назви днів із великої літери Case changes Зміни регістру Character level Рівень символів diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 86a1d776d..ee6bbe421 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1440,6 +1440,8 @@ Border lines 边框线 Capitalize first letter of sentences 句首字母大写 Capitalize first letter of table cells 表格单元格首字母大写 + Correct TWo INitial CApitals 更正前两个字母连续大写 + Correct accidental usage of cAPS LOCK key 更正误按 Caps Lock 键造成的大小写错误 Capitalize names of days 星期名称首字母大写 Case changes 大小写更改 Character level 字符级 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 0138e3425..fafd53e95 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1429,6 +1429,8 @@ Border lines 框線 Capitalize first letter of sentences 句首字母大寫 Capitalize first letter of table cells 表格儲存格首字母大寫 + Correct TWo INitial CApitals 更正前兩個字母連續大寫 + Correct accidental usage of cAPS LOCK key 更正意外使用 Caps Lock 鍵造成的大小寫錯誤 Capitalize names of days 星期名稱首字母大寫 Case changes 大小寫變更 Character level 字元層級 diff --git a/docs/typing-parity.md b/docs/typing-parity.md index 8e644851c..6acbd8bbf 100644 --- a/docs/typing-parity.md +++ b/docs/typing-parity.md @@ -1,6 +1,6 @@ # Typing parity with Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (right-to-left letters typed into an empty paragraph make it right-to-left, #713; previously minor: editing options: typing replaces selection, overtype, #485; previously minor: Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 What Word does when you type, observed black-box (same keystrokes typed into Word and WordCraft, results read back paragraph by paragraph) and pinned by tests in @@ -63,6 +63,18 @@ Applied when a word is finished by a space, punctuation **or Enter** (`enter_fin after abbreviations (`e.g.`, `Dr.`, …) or initials (`J.`), and not for mixed-case words (`iPhone`) or single-letter labels (`a.`). `teh`→`the`, `i`→`I`, `(c)`→`©`, `(tm)`→`™`, `wait...`→`wait…`, smart quotes (`autocorrect_as_you_type`). +- Correct TWo INitial CApitals: a finished word of three or more letters with two capitals and + the rest lowercase loses its second capital (`WOrd`→`Word`, `TEh`→`The`); not the exceptions + (`IDs`, `CDs`, `PCs`, `TVs`, … plus the user's, `tools.autocorrect {"addInitialCapsException"}`) + (`two_initial_capitals_are_corrected`). +- Correct accidental usage of cAPS LOCK: `wORD`→`Word` (three or more letters; not `iOS`, + `mRNA`, …). Word also switches Caps Lock off; we only correct the word + (`accidental_caps_lock_is_corrected`). +- Capitalize first letter of table cells: the first word typed in a cell is capitalised like a + sentence start, under its own option (`first_letter_of_a_table_cell_is_capitalised`). +- Each fix is one undo step right after it, and each has its own switch (`twoInitialCaps`, + `capsLock`, `capCells` on `tools.autocorrect`; on by default) + (`capitalisation_fixes_undo_and_turn_off`). - `1/2 1/4 3/4` → `½ ¼ ¾`; `1st 22nd 13th` get superscript suffixes (`ordinals_become_superscript`). - `word -- word` → en dash, `word - word` → en dash, `word--word` → em dash (`dashes`). - Web addresses become links; following text isn't linked (`web_addresses_become_links`). @@ -128,7 +140,9 @@ Word. - Moving paragraphs with Track Changes on isn't recorded as a tracked move; whole table rows don't move yet (a selection across cells is refused). Go Back's places aren't mapped through later edits (they are clamped to the document). -- Not yet compared: Correct TWo INitial CApitals; the rows marked ¹ above. +- Not yet compared: keystroke-by-keystroke comparison of Correct TWo INitial CApitals, cAPS LOCK + and table-cell capitals (implemented from Word's AutoCorrect options; Word's exception list + not compared); the rows marked ¹ above. ## How to compare @@ -144,6 +158,7 @@ Word. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | AutoCorrect: Correct TWo INitial CApitals, accidental cAPS LOCK, capitalize first letter of table cells (#544) | | 2026-10-11 | minor | Text direction: right-to-left letters typed into an empty paragraph make it right-to-left; existing paragraphs keep theirs (#713) | | 2026-10-11 | trivial | Grammar proofing (not AutoCorrect) no longer suggests capitalising a sentence after `...` or `…` (`proof::tests::ellipses_do_not_start_sentences`) | | 2026-10-11 | minor | AutoFormat tables from `+---+`, arrow bullets, smileys inside words, undo after a border line (#546) | From d8940cb317aee9ee9d8921d878c1c2f2329baf42 Mon Sep 17 00:00:00 2001 From: Eduardo Soares Date: Mon, 12 Oct 2026 08:38:10 -0300 Subject: [PATCH 42/88] Affix engine: FLAG UTF-8, 128 flag bits, continuation classes, FORBIDDENWORD (#100) (#782) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * M15: Polish interface, and Polish spelling, grammar and hyphenation Interface - Polish is the tenth interface language ("Polski", pl.tsv, 1,045 entries); pl, pl-PL and pl_PL.UTF-8 system locales select it automatically. - Right-click menu labels now go through translation in every language. - Review > Language is a menu (English US/UK, Polski, Do not check spelling or grammar); the status bar shows the language at the caret. - With a Polish interface, new blank documents and opened .txt/.md files default to pl-PL. Proofing - Each run is checked in its own language (w:lang, resolved through styles): squiggles, F7, review.issues, suggestions and hyphenation. Text in languages without proofing tools is no longer flagged as English. - Polish spelling from the SJP.PL Hunspell dictionary (Apache-2.0 option), read by a new Rust affix engine (crates/proof/src/affix.rs); 875 KB asset, ~4.5M word forms. Regenerate with the pl_dictionary example. - Polish suggestions: case, missing diacritics, rz/ż, ó/u, ch/h confusions, missing spaces (napewno -> na pewno), edit distance. - Conservative Polish grammar rules: repeated words, spacing, doubled punctuation, sentence capitals, missing comma before że/który/ponieważ/ aby/żeby/jeśli…, we/ze forms. - Polish hyphenation with the hyph-utf8 hyph-pl.tex patterns (MIT option); pattern keys widened to 20 letters, TeX exceptions parsed; the heuristic no longer treats ł, ż, ś… as vowels. review.language validates and normalizes tags, can set noProof alone and the document default; format.state reports lang and noProof. Tests for every part, docs and ATTRIBUTION.md/NOTICE updated; cargo xtask ci passes (fmt, clippy, test, assets, layers, wasm). Co-Authored-By: Claude Opus 5.5 * Layout: rustfmt the test module order left unformatted by #391 cargo fmt --check failed on main after #391 added mod tests_grid. Co-Authored-By: Claude Opus 5.5 * Review fixes for #346: read Polish spelling from the installed Hunspell dictionary - Polish spelling uses the Hunspell pl_PL dictionary installed on the system (OS packages, Homebrew, LibreOffice, ~/.local/share/hunspell, or WORDCRAFT_DICTIONARIES) instead of a bundled 870 KB SJP.PL file; without one, Polish text gets no spelling marks, like Word without the language's proofing tools. .aff/.dic are size-capped and decoded from UTF-8 or ISO 8859-2 (crates/proof/src/hunspell.rs). - The dictionary can be replaced at runtime (install_dictionary) or re-searched (reload_dictionary); an empty one accepts every word. - Tests use small synthetic dictionaries written for them, so they don't depend on what the machine has installed. - The bundled pl.dic, its ATTRIBUTION/NOTICE entries and the generator example are removed; docs describe the installed dictionary. Co-Authored-By: Claude Opus 5.5 * #100: affix — accept FLAG UTF-8 and DEFAULT flag modes Per-character flag modes are what the reader already parses; only the multi-character encodings (long, num) stay errors. VERO pt_BR declares FLAG UTF-8. * #100: affix — widen flag masks to u128 (128 flag capacity) VERO pt_BR needs 106 distinct flags; the u64 bitmask capped at 64. Serialization is unaffected: WCAFF1 stores flag characters and re-derives the masks on load. * #100: affix — support FORBIDDENWORD VERO pt_BR marks spellings it must reject with a forbidden flag (ý). Stems carrying it are listed but never correct and never inflect. * #100: affix — continuation classes in check SFX A 0 able/B: the form derived by A carries flag B, licensing a further suffix of class B (workable -> workables) while the bare stem cannot take one. form_has_flag walks the chain depth-bounded (MAX_CHAIN = 3); with no continuations declared it degenerates to the stem lookup, so existing dictionaries are unaffected. VERO pt_BR uses 26 continuation rules. * #100: affix — continuation classes in forms Enumerate chained suffix forms (workable -> workables) under the same MAX_CHAIN bound the checker walks, so every form the checker accepts is listed and vice versa. * #100: affix — round-trip continuations and FORBIDDENWORD through WCAFF1 The compact format keeps its WCAFF1 magic: continuation classes ride on the rule's appended field (as in the .aff source) and the forbidden flag becomes a FORBIDDENWORD line. Old files read as before. * #100: affix — strip a UTF-8 BOM from .aff/.dic input VERO's files open with a BOM; glued to the count line it parsed as a stem (one garbage entry per load) and to SET it hid the directive. * #782: affix — the module doc still rejected what the code now reads Continuation classes and FORBIDDENWORD are supported; the header said Rejected for both. * Review fixes for #782: two-suffix chain limit, module doc - MAX_CHAIN = 2 counts suffixes in a row, matching Hunspell's two-suffix limit (the old bound of 3 allowed up to four); test pins that a third is rejected. - Module doc: say probes allocate small scratch strings instead of claiming none. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: witkowski01 Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/proof/src/affix.rs | 321 ++++++++++++++++++++++++++++++++------ 1 file changed, 276 insertions(+), 45 deletions(-) diff --git a/crates/proof/src/affix.rs b/crates/proof/src/affix.rs index eeef432e0..fb362c76b 100644 --- a/crates/proof/src/affix.rs +++ b/crates/proof/src/affix.rs @@ -11,13 +11,14 @@ //! also be stored in WordCraft's compact format ([`AffixDict::to_bytes`]): the rules as text //! lines and the stems front-coded, raw-deflated together (the Polish dictionary is under 1 MB). //! -//! Supported: one-character flags (at most 64 distinct), `PFX`/`SFX` with strip, append and -//! condition (`.`, letters, `[abc]`, `[^abc]`), cross products, and the suggestion tables `TRY`, -//! `MAP` and `REP`. Rejected: compounding, continuation classes, long or numeric flags, -//! `NEEDAFFIX`, `FORBIDDENWORD` and the other directives that change which words are correct. +//! Supported: one-character flags (at most 128 distinct, any Unicode character — `FLAG UTF-8`), +//! `PFX`/`SFX` with strip, append and condition (`.`, letters, `[abc]`, `[^abc]`), cross +//! products, continuation classes on suffixes (at most two suffixes in a row, as in Hunspell), +//! `FORBIDDENWORD`, and the suggestion tables `TRY`, `MAP` and `REP`. Rejected: compounding, +//! long or numeric flags, `NEEDAFFIX` and the other directives that change which words are correct. //! -//! Lookups never panic and never allocate per probe: stems sit in one string, found through an -//! open-addressing hash table. +//! Lookups never panic: stems sit in one string, found through an open-addressing hash table, and +//! a probe allocates only small scratch strings for the candidate roots it tries. use std::collections::HashMap; use std::hash::Hasher; @@ -44,7 +45,6 @@ const UNSUPPORTED: &[&str] = &[ "COMPOUNDMIN", "COMPOUNDPERMITFLAG", "COMPOUNDRULE", - "FORBIDDENWORD", "FULLSTRIP", "ICONV", "IGNORE", @@ -114,15 +114,25 @@ fn parse_cond(text: &str) -> Result, String> { Ok(out.into_boxed_slice()) } +/// How many suffixes a word may carry in a row through continuation classes (`work` + +/// `able/B` + `s` → `workables` is two). Hunspell strips at most two suffixes, so a third +/// chained suffix is not a word; the bound also keeps hostile input from walking deep recursions. +const MAX_CHAIN: u8 = 2; +/// How many suffixes may sit under the outermost one: the depth `form_has_flag` walks. +const INNER_CHAIN: u8 = MAX_CHAIN - 1; + /// One prefix or suffix rule. #[derive(Clone, Debug)] struct Rule { /// Bit of the rule's flag in the stems' flag masks. - bit: u64, + bit: u128, flag: char, cross: bool, strip: Box, add: Box, + /// Continuation classes (`SFX A 0 able/B`): flags the derived form carries, licensing + /// further affixes. Chained application is bounded by [`MAX_CHAIN`]. + cont: u128, cond: Box<[Cond]>, cond_text: Box, } @@ -159,7 +169,9 @@ pub struct AffixDict { /// Per stem: index into `sets`. flags: Vec, /// Distinct flag masks. - sets: Vec, + sets: Vec, + /// The `FORBIDDENWORD` flag's bit; stems carrying it are not words (0: none declared). + forbidden: u128, /// Open-addressing hash table of stem indices (`EMPTY` = free). table: Vec, /// Flag char of each bit. @@ -232,13 +244,18 @@ pub fn capitalize(w: &str) -> String { /// Parsed rules and tables, shared by the Hunspell reader and the compact format. #[derive(Default)] struct Rules { - prefixes: Vec<(char, bool, String, String, String)>, - suffixes: Vec<(char, bool, String, String, String)>, + prefixes: Vec, + suffixes: Vec, try_chars: String, map: Vec>, rep: Vec<(String, String)>, + /// The `FORBIDDENWORD` flag: stems carrying it are listed but never correct. + forbidden: Option, } +/// A rule as parsed from text, before flags become bits: `(flag, cross, strip, add, cont, cond)`. +type RawRule = (char, bool, String, String, String, String); + /// `0` means empty in Hunspell's strip and append fields. fn zero_empty(s: &str) -> String { if s == "0" { String::new() } else { s.to_string() } @@ -260,7 +277,20 @@ fn parse_aff(aff: &str) -> Result { match dir { "SET" | "LANG" | "NAME" | "VERSION" | "HOME" | "WORDCHARS" | "KEY" | "MAXNGRAMSUGS" | "MAXDIFF" | "ONLYMAXDIFF" | "NOSPLITSUGS" | "SUGSWITHDOTS" | "NOSUGGEST" | "WARN" | "FORBIDWARN" | "MAXCPDSUGS" => {} - "FLAG" => return Err(format!("line {}: FLAG {} is not supported (one-character flags only)", n + 1, at(1))), + "FLAG" => { + // Per-character flag modes (`FLAG UTF-8`, as VERO's pt_BR dictionary uses) work + // with the one-character parsing here; only the multi-character encodings differ. + if !matches!(at(1), "UTF-8" | "DEFAULT" | "utf-8" | "default" | "UTF8" | "") { + return Err(format!("line {}: FLAG {} is not supported (one-character flags only)", n + 1, at(1))); + } + } + "FORBIDDENWORD" => { + let mut it = at(1).chars(); + match (it.next(), it.next()) { + (Some(c), None) if !c.is_whitespace() => r.forbidden = Some(c), + _ => return Err(format!("line {}: FORBIDDENWORD needs one flag character", n + 1)), + } + } "TRY" => r.try_chars = at(1).to_string(), "MAP" => { if !map_header && at(1).parse::().is_ok() { @@ -290,14 +320,15 @@ fn parse_aff(aff: &str) -> Result { if f.len() < 4 { return Err(format!("line {}: short affix rule", n + 1)); } - let add = at(3); - if add.contains('/') { - return Err(format!("line {}: continuation classes are not supported", n + 1)); - } + // `able/B`: the appended text and its continuation classes. + let (add, cont) = match at(3).split_once('/') { + Some((a, c)) => (a, c), + None => (at(3), ""), + }; let cond = if f.len() >= 5 { at(4) } else { "." }; parse_cond(cond).map_err(|e| format!("line {}: {e}", n + 1))?; let c = cross.get(&(suffix, flag)).copied().unwrap_or(false); - let rule = (flag, c, zero_empty(at(2)), zero_empty(add), cond.to_string()); + let rule: RawRule = (flag, c, zero_empty(at(2)), zero_empty(add), cont.to_string(), cond.to_string()); if suffix { r.suffixes.push(rule); } else { @@ -315,6 +346,10 @@ impl AffixDict { /// Build from Hunspell `.aff` and `.dic` text (decoded to UTF-8; the `.dic` count line and /// morphological fields are ignored). `comments` (provenance, licence) are kept with the data. pub fn from_hunspell(aff: &str, dic: &str, comments: &[&str]) -> Result { + // Some dictionaries open with a UTF-8 BOM; it must not glue itself to the first + // directive or turn the `.dic` count line into a stem. + let aff = aff.strip_prefix('\u{feff}').unwrap_or(aff); + let dic = dic.strip_prefix('\u{feff}').unwrap_or(dic); let rules = parse_aff(aff)?; let mut stems: Vec<(String, String)> = Vec::new(); for (n, line) in dic.lines().enumerate() { @@ -353,16 +388,18 @@ impl AffixDict { flag_chars.extend(stems.iter().flat_map(|s| s.1.chars())); flag_chars.sort_unstable(); flag_chars.dedup(); - if flag_chars.len() > 64 { - return Err(format!("{} distinct flags; at most 64 are supported", flag_chars.len())); + if flag_chars.len() > 128 { + return Err(format!("{} distinct flags; at most 128 are supported", flag_chars.len())); } - let bit_of = |c: char| flag_chars.iter().position(|&x| x == c).map_or(0, |i| 1u64 << i); + let bit_of = |c: char| flag_chars.iter().position(|&x| x == c).map_or(0, |i| 1u128 << i); + let forbidden = rules.forbidden.map_or(0, bit_of); // Stems sorted by bytes; homonyms (the same word twice) are merged. stems.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes())); let mut d = AffixDict { words: String::with_capacity(stems.iter().map(|s| s.0.len()).sum()), offs: Vec::with_capacity(stems.len() + 1), flag_chars: flag_chars.clone(), + forbidden, try_chars: rules.try_chars, map: rules.map, rep: rules.rep, @@ -370,11 +407,11 @@ impl AffixDict { ..AffixDict::default() }; d.offs.push(0); - let mut set_ids: HashMap = HashMap::new(); + let mut set_ids: HashMap = HashMap::new(); let mut i = 0; while i < stems.len() { let Some((word, _)) = stems.get(i) else { break }; - let mut mask = 0u64; + let mut mask = 0u128; let mut j = i; while let Some((w, f)) = stems.get(j) && w == word @@ -392,8 +429,9 @@ impl AffixDict { d.flags.push(id); i = j; } - let mk = |(flag, cross, strip, add, cond): (char, bool, String, String, String)| -> Result { - Ok(Rule { bit: bit_of(flag), flag, cross, strip: strip.into(), add: add.into(), cond: parse_cond(&cond)?, cond_text: cond.into() }) + let mk = |(flag, cross, strip, add, cont, cond): RawRule| -> Result { + let cont = cont.chars().fold(0u128, |m, c| m | bit_of(c)); + Ok(Rule { bit: bit_of(flag), flag, cross, strip: strip.into(), add: add.into(), cont, cond: parse_cond(&cond)?, cond_text: cond.into() }) }; d.prefixes = rules.prefixes.into_iter().map(mk).collect::>()?; d.suffixes = rules.suffixes.into_iter().map(mk).collect::>()?; @@ -451,6 +489,21 @@ impl AffixDict { self.words.get(a..b) } + /// The flag characters of `mask`, in flag order (how the compact format stores flags). + fn flag_chars_for(&self, mask: u128) -> String { + self.flag_chars.iter().enumerate().filter(|&(i, _)| mask & (1u128 << i) != 0).map(|(_, c)| c).collect() + } + + /// The character of `mask` when it holds exactly one flag. + fn flag_char(&self, mask: u128) -> Option { + let s = self.flag_chars_for(mask); + let mut it = s.chars(); + match (it.next(), it.next()) { + (Some(c), None) => Some(c), + _ => None, + } + } + /// The stems, in byte order. pub fn stems(&self) -> impl Iterator + '_ { (0..self.len()).filter_map(|i| self.word(i)) @@ -475,7 +528,7 @@ impl AffixDict { } /// Flag mask of a stem, if `w` is one. - fn stem_flags(&self, w: &str) -> Option { + fn stem_flags(&self, w: &str) -> Option { if self.table.is_empty() { return None; } @@ -488,13 +541,44 @@ impl AffixDict { } if self.word(t as usize) == Some(w) { let id = *self.flags.get(t as usize)?; - return self.sets.get(usize::from(id)).copied(); + let mask = self.sets.get(usize::from(id)).copied(); + // A stem carrying FORBIDDENWORD is listed but is not a word: no direct hit, + // and (this is the only stem lookup) no affixed form is built on it either. + return mask.filter(|m| m & self.forbidden == 0); } slot = (slot + 1) & mask; } None } + /// Does `w` carry flag `bit` — as a stem, or as the form of a suffix rule whose + /// continuation classes include `bit` (chained suffixation, depth-bounded)? + fn form_has_flag(&self, w: &str, bit: u128, depth: u8) -> bool { + if self.stem_flags(w).is_some_and(|f| f & bit != 0) { + return true; + } + if depth == 0 { + return false; + } + let mut root = String::with_capacity(w.len() + 8); + let cuts = std::iter::once(w.len()).chain(w.char_indices().rev().map(|(i, _)| i).filter(|&i| i > 0)); + for b in cuts.take(self.max_sfx_chars + 1) { + let (Some(base), Some(add)) = (w.get(..b), w.get(b..)) else { continue }; + let Some(groups) = self.sfx_index.get(add) else { continue }; + for g in groups { + root.clear(); + root.push_str(base); + root.push_str(&g.strip); + for r in g.rules.iter().filter_map(|&i| self.suffixes.get(i as usize)) { + if r.cont & bit != 0 && r.cond_matches(&root, true) && self.form_has_flag(&root, r.bit, depth - 1) { + return true; + } + } + } + } + false + } + /// Is `w` exactly (case and all) a stem or an affixed form of one? pub fn contains(&self, w: &str) -> bool { if w.is_empty() || w.len() > MAX_WORD_BYTES { @@ -518,6 +602,9 @@ impl AffixDict { if self.stem_flags(&root).is_some_and(|f| f & r.bit != 0) { return true; } + if self.form_has_flag(&root, r.bit, INNER_CHAIN) { + return true; + } if r.cross && self.suffixed(&root, r.bit) { return true; } @@ -527,7 +614,7 @@ impl AffixDict { /// Is `w` a stem with one suffix? With `need` set (a prefix was taken off), the stem must /// carry those flags too and the suffix must allow cross products. - fn suffixed(&self, w: &str, need: u64) -> bool { + fn suffixed(&self, w: &str, need: u128) -> bool { let mut root = String::with_capacity(w.len() + 8); // Each split point from the end: the appended string is `w[b..]`, at most the longest // suffix, and something must stay before it. @@ -539,16 +626,18 @@ impl AffixDict { root.clear(); root.push_str(base); root.push_str(&g.strip); - let Some(f) = self.stem_flags(&root) else { continue }; - if f & need != need { - continue; + let rules = || g.rules.iter().filter_map(|&i| self.suffixes.get(i as usize)); + // The stem path: `root` is a stem carrying the rule's flag (and the prefix's, + // when a cross product is being checked). + if let Some(f) = self.stem_flags(&root) + && f & need == need + && rules().any(|r| f & r.bit != 0 && (need == 0 || r.cross) && r.cond_matches(&root, true)) + { + return true; } - let ok = g - .rules - .iter() - .filter_map(|&i| self.suffixes.get(i as usize)) - .any(|r| f & r.bit != 0 && (need == 0 || r.cross) && r.cond_matches(&root, true)); - if ok { + // Continuation classes: `root` need not be a stem carrying this rule's flag — + // it may be a form that gained the flag from another rule's continuation. + if need == 0 && rules().any(|r| r.cond_matches(&root, true) && self.form_has_flag(&root, r.bit, INNER_CHAIN)) { return true; } } @@ -578,6 +667,8 @@ impl AffixDict { let Some(f) = self.stem_flags(stem) else { return Vec::new() }; let mut out = vec![stem.to_string()]; let mut cross_sfx = Vec::new(); + // Suffix forms queued with the continuation classes they carry, for chained expansion. + let mut chain: Vec<(String, u128)> = Vec::new(); for r in &self.suffixes { if f & r.bit == 0 || !r.cond_matches(stem, true) { continue; @@ -587,8 +678,32 @@ impl AffixDict { if r.cross { cross_sfx.push(form.clone()); } + if r.cont != 0 { + chain.push((form.clone(), r.cont)); + } out.push(form); } + // Continuation classes chain suffixes (`workable/B` → `workables`), up to [`MAX_CHAIN`] + // suffixes in all — the same bound the checker walks. + for _ in 0..INNER_CHAIN { + let level = std::mem::take(&mut chain); + if level.is_empty() { + break; + } + for (form, flags) in level { + for r in &self.suffixes { + if flags & r.bit == 0 || !r.cond_matches(&form, true) { + continue; + } + let Some(base) = form.strip_suffix(&*r.strip) else { continue }; + let next = format!("{base}{}", r.add); + if r.cont != 0 { + chain.push((next.clone(), r.cont)); + } + out.push(next); + } + } + } for r in &self.prefixes { if f & r.bit == 0 || !r.cond_matches(stem, false) { continue; @@ -648,11 +763,23 @@ impl AffixDict { for (a, b) in &self.rep { out.push_str(&format!("REP {} {}\n", a.replace(' ', "_"), b.replace(' ', "_"))); } + if self.forbidden != 0 + && let Some(c) = self.flag_char(self.forbidden) + { + out.push_str(&format!("FORBIDDENWORD {c}\n")); + } let z = |s: &str| if s.is_empty() { "0".to_string() } else { s.to_string() }; for (kind, rules) in [("PFX", &self.prefixes), ("SFX", &self.suffixes)] { for r in rules { let y = if r.cross { "Y" } else { "N" }; - out.push_str(&format!("{kind} {} {y} {} {} {}\n", r.flag, z(&r.strip), z(&r.add), r.cond_text)); + // Continuation classes ride on the appended field, as in the `.aff` source. + let mut add = z(&r.add); + let cont = self.flag_chars_for(r.cont); + if !cont.is_empty() { + add.push('/'); + add.push_str(&cont); + } + out.push_str(&format!("{kind} {} {y} {} {} {}\n", r.flag, z(&r.strip), add, r.cond_text)); } } out.push_str("WORDS\n"); @@ -667,12 +794,7 @@ impl AffixDict { let mask = self.flags.get(i).and_then(|&id| self.sets.get(usize::from(id))).copied().unwrap_or(0); if mask != 0 { raw.push(b'/'); - for (bit, c) in self.flag_chars.iter().enumerate() { - if mask & (1 << bit) != 0 { - let mut buf = [0u8; 4]; - raw.extend_from_slice(c.encode_utf8(&mut buf).as_bytes()); - } - } + raw.extend_from_slice(self.flag_chars_for(mask).as_bytes()); } raw.push(b'\n'); prev = b; @@ -703,13 +825,25 @@ impl AffixDict { "TRY" => rules.try_chars = at(1).to_string(), "MAP" => rules.map.push(at(1).chars().collect()), "REP" => rules.rep.push((at(1).replace('_', " "), at(2).replace('_', " "))), + "FORBIDDENWORD" => { + let mut it = at(1).chars(); + match (it.next(), it.next()) { + (Some(c), None) if !c.is_whitespace() => rules.forbidden = Some(c), + _ => return Err(format!("affix dictionary: bad FORBIDDENWORD in `{line}`")), + } + } kind @ ("PFX" | "SFX") => { let mut fc = at(1).chars(); let (Some(flag), None) = (fc.next(), fc.next()) else { return Err(format!("affix dictionary: bad flag in `{line}`")) }; if f.len() != 6 { return Err(format!("affix dictionary: bad rule `{line}`")); } - let rule = (flag, at(2) == "Y", zero_empty(at(3)), zero_empty(at(4)), at(5).to_string()); + // Continuation classes ride on the appended field, as in the `.aff` source. + let (add, cont) = match at(4).split_once('/') { + Some((a, c)) => (zero_empty(a), c.to_string()), + None => (zero_empty(at(4)), String::new()), + }; + let rule: RawRule = (flag, at(2) == "Y", zero_empty(at(3)), add, cont, at(5).to_string()); if kind == "SFX" { rules.suffixes.push(rule); } else { @@ -817,11 +951,108 @@ mod tests { assert!(!back.contains("kotą")); } + #[test] + fn flag_utf8_and_default_modes_are_accepted() { + // `FLAG UTF-8` (VERO pt_BR) and `FLAG DEFAULT` are per-character flag modes: the same + // mode the reader already assumes. Only `long` and `num` stay unsupported. + for mode in ["UTF-8", "DEFAULT"] { + let aff = format!("SET UTF-8\nFLAG {mode}\nSFX a Y 1\nSFX a 0 y [^y]\n"); + let d = AffixDict::from_hunspell(&aff, "1\nkot/a\n", &[]).unwrap(); + assert!(d.contains("kot") && d.contains("koty"), "{mode}"); + } + assert!(AffixDict::from_hunspell("FLAG UTF-8\n", "0\n", &[]).is_ok(), "FLAG alone is harmless"); + } + + #[test] + fn more_than_64_distinct_flags_load() { + // VERO pt_BR uses 106 distinct flags (FLAG UTF-8). Build one with every basic Latin + // letter, the digits and eight accented letters: 70 flags, 64 < n <= 128. + let mut flags = String::new(); + let mut aff = String::from("SET UTF-8\nFLAG UTF-8\n"); + let chars: Vec = ('a'..='z').chain('A'..='Z').chain('0'..='9').chain(['á', 'é', 'í', 'ó', 'ú', 'ç', 'ã', 'õ']).collect(); + assert_eq!(chars.len(), 70); + for c in chars { + aff.push_str(&format!("SFX {c} N 1\nSFX {c} 0 {c} .\n")); + flags.push(c); + } + let d = AffixDict::from_hunspell(&aff, &format!("1\nverbo/{flags}\n"), &[]).unwrap(); + assert!(d.contains("verbo")); + assert!(d.contains("verboa") && d.contains("verboz") && d.contains("verbo9")); + assert!(d.contains("verboç") && d.contains("verboã"), "multi-byte UTF-8 flags"); + assert!(!d.contains("verboß"), "ß is not a flag and not a stem"); + } + + #[test] + fn forbidden_words_are_rejected() { + // VERO pt_BR marks spellings it must reject (flag ý under FLAG UTF-8) as stems carrying + // FORBIDDENWORD: listed, but never correct and never inflected. + let aff = "SET UTF-8\nFLAG UTF-8\nFORBIDDENWORD ý\nSFX a Y 1\nSFX a 0 y [^y]\n"; + let d = AffixDict::from_hunspell(aff, "2\nkot/a\nimundo/ý\n", &[]).unwrap(); + assert!(d.check("kot") && d.check("koty") && d.check("Kot") && d.check("KOT")); + assert!(!d.check("imundo") && !d.check("Imundo") && !d.check("IMUNDO")); + assert!(!d.contains("imundoy"), "a forbidden stem must not inflect"); + assert!(d.forms("imundo").is_empty()); + } + + #[test] + fn continuation_classes_chain_suffixes() { + // `SFX A 0 able/B`: the form derived by A carries flag B, so a suffix of class B may + // follow (workable → workables). The bare stem has A only, so `works` stays wrong. + let aff = "SET UTF-8\nFLAG UTF-8\nSFX A N 1\nSFX A 0 able/B .\nSFX B N 1\nSFX B 0 s .\n"; + let d = AffixDict::from_hunspell(aff, "1\nwork/A\n", &[]).unwrap(); + assert!(d.contains("work") && d.contains("workable") && d.contains("workables")); + assert!(!d.contains("works"), "the stem has A, not B"); + assert!(!d.contains("workabless"), "B declares no continuation"); + assert!(d.check("Workable") && d.check("WORKABLES")); + } + + #[test] + fn continuation_chains_stop_at_two_suffixes() { + // Hunspell strips at most two suffixes: work + able/B + s/C is a word, a third (C) is not. + let aff = "SET UTF-8\nFLAG UTF-8\nSFX A N 1\nSFX A 0 able/B .\nSFX B N 1\nSFX B 0 s/C .\nSFX C N 1\nSFX C 0 x .\n"; + let d = AffixDict::from_hunspell(aff, "1\nwork/A\n", &[]).unwrap(); + assert!(d.contains("workable") && d.contains("workables")); + assert!(!d.contains("workablesx"), "three chained suffixes exceed the limit"); + assert!(!d.forms("work").iter().any(|f| f == "workablesx")); + } + + #[test] + fn forms_include_continuation_chains() { + let aff = "SET UTF-8\nFLAG UTF-8\nSFX A N 1\nSFX A 0 able/B .\nSFX B N 1\nSFX B 0 s .\n"; + let d = AffixDict::from_hunspell(aff, "1\nwork/A\n", &[]).unwrap(); + let mut f = d.forms("work"); + f.sort(); + f.dedup(); + assert_eq!(f, ["work", "workable", "workables"]); + for form in d.forms("work") { + assert!(d.contains(&form), "every listed form is accepted: {form}"); + } + } + + #[test] + fn compact_format_round_trips_continuations_and_forbidden() { + let aff = "SET UTF-8\nFLAG UTF-8\nFORBIDDENWORD ý\nSFX A N 1\nSFX A 0 able/B .\nSFX B N 1\nSFX B 0 s .\n"; + let d = AffixDict::from_hunspell(aff, "2\nwork/A\nimundo/ý\n", &[]).unwrap(); + let back = AffixDict::from_bytes(&d.to_bytes()).unwrap(); + assert!(back.contains("work") && back.contains("workable") && back.contains("workables")); + assert!(!back.contains("works"), "the stem has A, not B, after a round trip"); + assert!(!back.check("imundo") && !back.contains("imundoy"), "forbidden survives a round trip"); + } + + #[test] + fn dic_bom_glued_to_the_count_line_is_skipped() { + // VERO's .dic opens with a UTF-8 BOM glued to its count line; it must not become a stem. + let aff = "SET UTF-8\nSFX a Y 1\nSFX a 0 y [^y]\n"; + let d = AffixDict::from_hunspell(aff, "\u{feff}1\nkot/a\n", &[]).unwrap(); + assert_eq!(d.len(), 1); + assert!(d.contains("kot") && d.contains("koty")); + } + #[test] fn unsupported_and_broken_input_is_an_error() { assert!(AffixDict::from_hunspell("FLAG long\n", DIC, &[]).is_err()); + assert!(AffixDict::from_hunspell("FLAG num\n", DIC, &[]).is_err()); assert!(AffixDict::from_hunspell("COMPOUNDFLAG X\n", DIC, &[]).is_err()); - assert!(AffixDict::from_hunspell("SFX a Y 1\nSFX a 0 y/b .\n", DIC, &[]).is_err()); assert!(AffixDict::from_hunspell("SFX a Y 1\nSFX a 0 y [ab\n", DIC, &[]).is_err()); assert!(AffixDict::from_bytes(b"not deflate").is_err()); let ok = dict().to_bytes(); From 1e53adf9d62f6721165e0d3f0c8b5d98c115a61b Mon Sep 17 00:00:00 2001 From: Eduardo Soares Date: Mon, 12 Oct 2026 08:43:24 -0300 Subject: [PATCH 43/88] Portuguese proofing: pt-BR spelling, suggestions, grammar and hyphenation (#100) (#797) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * M15: Polish interface, and Polish spelling, grammar and hyphenation Interface - Polish is the tenth interface language ("Polski", pl.tsv, 1,045 entries); pl, pl-PL and pl_PL.UTF-8 system locales select it automatically. - Right-click menu labels now go through translation in every language. - Review > Language is a menu (English US/UK, Polski, Do not check spelling or grammar); the status bar shows the language at the caret. - With a Polish interface, new blank documents and opened .txt/.md files default to pl-PL. Proofing - Each run is checked in its own language (w:lang, resolved through styles): squiggles, F7, review.issues, suggestions and hyphenation. Text in languages without proofing tools is no longer flagged as English. - Polish spelling from the SJP.PL Hunspell dictionary (Apache-2.0 option), read by a new Rust affix engine (crates/proof/src/affix.rs); 875 KB asset, ~4.5M word forms. Regenerate with the pl_dictionary example. - Polish suggestions: case, missing diacritics, rz/ż, ó/u, ch/h confusions, missing spaces (napewno -> na pewno), edit distance. - Conservative Polish grammar rules: repeated words, spacing, doubled punctuation, sentence capitals, missing comma before że/który/ponieważ/ aby/żeby/jeśli…, we/ze forms. - Polish hyphenation with the hyph-utf8 hyph-pl.tex patterns (MIT option); pattern keys widened to 20 letters, TeX exceptions parsed; the heuristic no longer treats ł, ż, ś… as vowels. review.language validates and normalizes tags, can set noProof alone and the document default; format.state reports lang and noProof. Tests for every part, docs and ATTRIBUTION.md/NOTICE updated; cargo xtask ci passes (fmt, clippy, test, assets, layers, wasm). Co-Authored-By: Claude Opus 5.5 * Layout: rustfmt the test module order left unformatted by #391 cargo fmt --check failed on main after #391 added mod tests_grid. Co-Authored-By: Claude Opus 5.5 * Review fixes for #346: read Polish spelling from the installed Hunspell dictionary - Polish spelling uses the Hunspell pl_PL dictionary installed on the system (OS packages, Homebrew, LibreOffice, ~/.local/share/hunspell, or WORDCRAFT_DICTIONARIES) instead of a bundled 870 KB SJP.PL file; without one, Polish text gets no spelling marks, like Word without the language's proofing tools. .aff/.dic are size-capped and decoded from UTF-8 or ISO 8859-2 (crates/proof/src/hunspell.rs). - The dictionary can be replaced at runtime (install_dictionary) or re-searched (reload_dictionary); an empty one accepts every word. - Tests use small synthetic dictionaries written for them, so they don't depend on what the machine has installed. - The bundled pl.dic, its ATTRIBUTION/NOTICE entries and the generator example are removed; docs describe the installed dictionary. Co-Authored-By: Claude Opus 5.5 * #100: affix — accept FLAG UTF-8 and DEFAULT flag modes Per-character flag modes are what the reader already parses; only the multi-character encodings (long, num) stay errors. VERO pt_BR declares FLAG UTF-8. * #100: affix — widen flag masks to u128 (128 flag capacity) VERO pt_BR needs 106 distinct flags; the u64 bitmask capped at 64. Serialization is unaffected: WCAFF1 stores flag characters and re-derives the masks on load. * #100: affix — support FORBIDDENWORD VERO pt_BR marks spellings it must reject with a forbidden flag (ý). Stems carrying it are listed but never correct and never inflect. * #100: affix — continuation classes in check SFX A 0 able/B: the form derived by A carries flag B, licensing a further suffix of class B (workable -> workables) while the bare stem cannot take one. form_has_flag walks the chain depth-bounded (MAX_CHAIN = 3); with no continuations declared it degenerates to the stem lookup, so existing dictionaries are unaffected. VERO pt_BR uses 26 continuation rules. * #100: affix — continuation classes in forms Enumerate chained suffix forms (workable -> workables) under the same MAX_CHAIN bound the checker walks, so every form the checker accepts is listed and vice versa. * #100: affix — round-trip continuations and FORBIDDENWORD through WCAFF1 The compact format keeps its WCAFF1 magic: continuation classes ride on the rule's appended field (as in the .aff source) and the forbidden flag becomes a FORBIDDENWORD line. Old files read as before. * #100: affix — strip a UTF-8 BOM from .aff/.dic input VERO's files open with a BOM; glued to the count line it parsed as a stem (one garbage entry per load) and to SET it hid the directive. * #782: affix — the module doc still rejected what the code now reads Continuation classes and FORBIDDENWORD are supported; the header said Rejected for both. * #100: pt — Portuguese spelling, following the Polish module ProofLang::Pt resolves pt/pt-BR/pt-PT/por tags; the pt module reads the installed Hunspell pt_BR dictionary (VERO) through the affix engine — FLAG UTF-8 flags, continuation classes and FORBIDDENWORD spellings all exercised by the synthetic test dictionary. Hyphenated compounds are checked part by part (guarda-roupa, ex-marido); without a dictionary installed, Portuguese text gets no marks. Spelling message in Portuguese; suggestions and grammar land next. * #100: pt — Portuguese suggestions Rounds of rising cost, driven by the dictionary's own tables: missing accents from MAP (coraçao -> coração, saude -> saúde), the REP confusions (seje -> seja, excessão -> exceção), a missing space after a short function word (nãoé -> não é, dagua -> da água), one typo over TRY, and an accent-folding stem scan — the Polish rounds, retuned for Portuguese. * #100: pt — Portuguese grammar flags Conservative rules with Portuguese messages: repeated words (except the emphatic doubles Portuguese allows), space before punctuation and double spaces, sentence capitals knowing the Portuguese abbreviations and dialogue dashes, and doubled punctuation. No comma rule: que opens a clause only sometimes, so a wrong flag would cost more than a missed one. * #100: pt — Portuguese hyphenation hyph-utf8's hyph-pt.tex bundled unmodified (BSD 3-clause, ATTRIBUTION row with source and hash): 426 Liang patterns and 2 exception words; falls back to the generic heuristic outside their reach. * #100: pt — Review › Language offers Português (Brasil), and a pt-br interface writes pt-BR The menu's fourth entry sets the run language; the status bar names Portuguese; editing_lang is now allowed for pt-br (wordcraft-proof proofs it), so new documents under a Portuguese interface are Portuguese by default. * #100: pt — engine test: Portuguese proofing through the review commands review.language pt-BR normalizes the tag, review.issues reports Portuguese spelling and grammar with the lang field, F7 and Change apply a suggestion, Add to Dictionary works for Portuguese. * #100: pt — Portuguese in the mixed-language proofing tests and docs check_text proofs a Portuguese span with the Portuguese message; the never-panic loops and the module docs cover the third language. * #100: docs — Portuguese proofing in the gap, parity and roadmap docs Gap 8 narrowed (English, Polish and Portuguese), localization parity gains a Portuguese row and a revision entry, target-app-parity's Proofing row moves 40% -> 42%, and the ROADMAP progress log records what landed. * #100: hunspell — LibreOffice extension folders follow the language The search paths hardcoded LibreOffice's dict-pl; a pt_BR user whose only dictionary is LibreOffice's dict-pt-BR extension — on Windows the only realistic on-disk source — got silently no spelling. Both the primary-language folder and the dashed full name are now tried, so Polish keeps dict-pl and Portuguese finds dict-pt-BR. * #100: pt — review fixes Suggestion ties break toward the change nearest the word's start (saúde before saudê, proven against a fixture that has both); the Language menu's label is the English source "Portuguese (Brazil)" with translations in all twelve catalogs (as Polish is); the attribution counts pt.pat's 2 exception words; the pt fixture drops a duplicate stem; docs/roadmap.md catches up with the landing; the throwaway spike is gone. * Review fixes for #782: two-suffix chain limit, module doc - MAX_CHAIN = 2 counts suffixes in a row, matching Hunspell's two-suffix limit (the old bound of 3 allowed up to four); test pins that a third is rejected. - Module doc: say probes allocate small scratch strings instead of claiming none. Co-Authored-By: Claude Opus 5.5 * Review fixes for #797: NOTICE for pt.pat, no indexing in the LibreOffice folder helper - NOTICE reproduces hyph-pt.tex's BSD 3-Clause copyright notice, conditions and disclaimer, which the licence requires for binary redistributions. - hunspell::lo_ext_dirs builds its list without indexing (never-crash rule). - Catalogs: main already translates "Portuguese (Brazil)" in every language, so the duplicate rows the branch added are dropped. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: witkowski01 Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ATTRIBUTION.md | 1 + NOTICE | 26 ++ ROADMAP.md | 5 +- assets/hyphenation/pt.pat | 503 +++++++++++++++++++++++++++++++ crates/engine/src/tests.rs | 44 +++ crates/proof/src/hunspell.rs | 53 +++- crates/proof/src/hyphen.rs | 60 ++++ crates/proof/src/lang.rs | 15 +- crates/proof/src/lib.rs | 33 +- crates/proof/src/pt/grammar.rs | 245 +++++++++++++++ crates/proof/src/pt/mod.rs | 202 +++++++++++++ crates/proof/src/pt/suggest.rs | 444 +++++++++++++++++++++++++++ crates/proof/src/pt/test_dict.rs | 121 ++++++++ crates/ui-egui/src/i18n/mod.rs | 2 +- crates/ui-egui/src/i18n/tests.rs | 4 +- crates/ui-egui/src/ribbon.rs | 17 +- docs/gaps.md | 3 +- docs/localization-parity.md | 8 +- docs/roadmap.md | 5 +- docs/target-app-parity.md | 5 +- 20 files changed, 1758 insertions(+), 38 deletions(-) create mode 100644 assets/hyphenation/pt.pat create mode 100644 crates/proof/src/pt/grammar.rs create mode 100644 crates/proof/src/pt/mod.rs create mode 100644 crates/proof/src/pt/suggest.rs create mode 100644 crates/proof/src/pt/test_dict.rs diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 5856d7352..e8a20adc2 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -12,6 +12,7 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra | `assets/hyphenation/en-us.dic` | Grady Ward (Moby Hyphenator II word list); converted by DesignCraft contributors | https://www.gutenberg.org/ebooks/3204 (file `mhyph.txt`, SHA-256 `eeb30474c86b8af3469035ec1a0913e35905325ca885290db9dfed9881e230ac`), via DesignCraft | Public Domain | ~165k words with break points; drives spelling and hyphenation | | `assets/spelling/en-extra.txt` | WordCraft contributors | original word list written for this repository (common modern words the Moby list lacks); no other dictionary copied | MIT OR Apache-2.0 | ~220 lowercase English base forms supplementing spelling (#520) | | `assets/hyphenation/pl.pat` | Hanna Kołodziejska, Bogusław Jackowski, Marek Ryćko (Polish TeX hyphenation patterns, 1987–1995); hyph-utf8 package | https://github.com/hyphenation/tex-hyphen (`hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-pl.tex`, SHA-256 `83ddbbd21bc06a44bc4f4a4c49c38782e809cff5f026b764c044daa29e818843`), bundled unmodified | MIT (the file is offered under MIT or Knuth's public-domain terms; WordCraft uses MIT, whose notice is in the file's header) | 4,053 Liang patterns and 20 exception words; Polish hyphenation | +| `assets/hyphenation/pt.pat` | Pedro J. de Rezende, J. Joao Dias Almeida, Leonardo Araujo, Aline Benevides (Portuguese TeX hyphenation patterns, 1987–2024); hyph-utf8 package | https://github.com/hyphenation/tex-hyphen (`hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-pt.tex`, SHA-256 `c110a39e501db372d1d755e1c64464321a2efa7d8e9617f66001000389b941f2`), bundled unmodified | BSD 3-clause (the file's licence header names it and carries the notice) | 426 Liang patterns and 2 exception words; Portuguese hyphenation | | `assets/app-icon/wordcraft-1024.png` | WordCraft contributors | original artwork drawn in SVG (`assets/app-icon/wordcraft.svg`), rendered by `packaging/icons.sh` | MIT OR Apache-2.0 | Placeholder app icon until the WordCraft mascot drawing lands | | `packaging/macos/dmg/background.svg` | @XusBadia | original work for this repository, derived from the WordCraft app icon (`assets/app-icon/wordcraft.svg`, referenced via ``, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0, like the code ([`LICENSE-MIT`](LICENSE-MIT), [`LICENSE-APACHE`](LICENSE-APACHE)) | WordCraft macOS DMG window background (2026-10-08); used for the Finder window of the macOS DMG, rendered to `background.tiff` by `packaging/macos/dmg/generate.py` | | `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0, like the code ([`LICENSE-MIT`](LICENSE-MIT), [`LICENSE-APACHE`](LICENSE-APACHE)) | 1x + 2x HiDPI TIFF for the macOS DMG window; includes the app icon (see its row) and LittleCMS's built-in sRGB profile ("No copyright, use freely") | diff --git a/NOTICE b/NOTICE index 3390e725c..8d09e43bd 100644 --- a/NOTICE +++ b/NOTICE @@ -26,6 +26,32 @@ list (author, source, license) is in ATTRIBUTION.md. - Polish hyphenation patterns (assets/hyphenation/pl.pat) — hyph-pl.tex from the hyph-utf8 package, Copyright (C) 1987-1995 Hanna Kołodziejska, Bogusław Jackowski, Marek Ryćko, bundled unmodified and used under the MIT License; the copyright and permission notice is in the file. +- Portuguese hyphenation patterns (assets/hyphenation/pt.pat) — hyph-pt.tex from the hyph-utf8 + package, bundled unmodified and used under the BSD 3-Clause License: + + Copyright (C) 1987, 1994, 1996, 2015 Pedro J. de Rezende, 1996, 2015 J. Joao Dias Almeida, + 2024 Leonardo Araujo and Aline Benevides + + Redistribution and use in source and binary forms, with or without modification, are + permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright notice, this list of + conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright notice, this list of + conditions and the following disclaimer in the documentation and/or other materials + provided with the distribution. + * Neither the name of the University of Campinas, of the University of Minho nor the names + of its contributors may be used to endorse or promote products derived from this software + without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS + OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL PEDRO + J. DE REZENDE OR J.JOAO DIAS ALMEIDA BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE + GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + OF THE POSSIBILITY OF SUCH DAMAGE. - All UI icons are drawn in code (crates/ui-egui/src/icons.rs) and are original work, MIT OR Apache-2.0. Rust dependencies (egui, eframe, vello_cpu, skrifa, harfrust, krilla, quick-xml, zip, image, …) diff --git a/ROADMAP.md b/ROADMAP.md index 6cfce04db..628265775 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 (Turkish interface catalog completed, #774; previously trivial: drag-select by whole units, #549; 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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Portuguese proofing; previously minor: Polish interface and Polish proofing; 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 @@ -113,7 +113,7 @@ Weights and evidence per area: [docs/target-app-parity.md](docs/target-app-parit Also shipped: Traditional Chinese (`zh-hant`), Ukrainian (`uk`), Serbian (`sr`, `sr-latn`, #250), Russian (`ru`) and Polish (`pl`), all partial (~95%). Estonian (`et`), Italian (`it`, #594) and Czech (`cs`) now have complete interface catalogs, and Turkish (`tr`) a complete 2,325-entry one (every key of the Russian catalog), all with system-locale selection and a saved language choice (Italian also has 51 strings none of the others had yet); Estonian, Turkish, Italian and Czech proofing are not included. Hungarian (`hu`) has a complete 2,419-entry catalog, also without proofing. -Proofing: English and Polish (spelling, grammar, hyphenation), chosen per run from its language. Word ships +Proofing: English, Polish and Portuguese (spelling, grammar, hyphenation), chosen per run from its language. Word ships 30 interface languages and proofing for ~50. Detail: [docs/localization-parity.md](docs/localization-parity.md). ## Upcoming @@ -141,6 +141,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-11 | Reading Mode: existing View ribbon button now opens a read-only, viewport-column layout with animated horizontal paging, transient display controls, heading navigation (Navigation pane) and resize anchoring; document layout and export unchanged | | 2026-10-11 | DOCX open/save keeps inherited complex-script size, bold and italic independent of Latin formatting; explicit values are retained, and font commands still format both scripts. Covered by synthetic round trips and a local GUI save/reopen check. | | 2026-10-11 | Czech (`cs`) interface catalog: 2,021 entries (the full zh-hans key set), Czech locale detection and persisted language choice | +| 2026-10-12 | Portuguese proofing: pt-BR spelling against the installed Hunspell `pt_BR` dictionary (VERO; read at runtime, not bundled) — the affix engine grew `FLAG UTF-8`, 128 flag bits, continuation classes and `FORBIDDENWORD` to read it — suggestions tuned for Portuguese (missing accents first), conservative grammar rules with Portuguese messages, TeX Portuguese hyphenation patterns (BSD 3-clause, attributed); Review › Language offers Português (Brasil); a pt-br interface writes new documents in pt-BR | | 2026-10-11 | Polish: interface (`pl`, the thirteenth language, every catalog key) and proofing — spelling against the installed Hunspell `pl_PL` dictionary (read at runtime, not bundled) through a new Hunspell-style affix engine, suggestions tuned for Polish, Polish grammar rules with Polish messages, TeX Polish hyphenation patterns (MIT option); proofing language per run (`w:lang`) for squiggles, F7, suggestions and hyphenation, languages without tools left unmarked; Review › Language menu and the status bar's language; a Polish interface writes new documents in Polish | | 2026-10-11 | German (`de`) interface (#574): 2,021 translated entries covering the full zh-hans key set, locale and saved-preference tests, bundled-font checks; no German proofing resources | | 2026-10-11 | `.doc` import keeps pictures and drawings (#551): inline and floating pictures (PNG, JPEG, DIB, EMF, WMF), shapes, lines, groups and text boxes with position and wrap, from [MS-ODRAW] | diff --git a/assets/hyphenation/pt.pat b/assets/hyphenation/pt.pat new file mode 100644 index 000000000..cb947c43b --- /dev/null +++ b/assets/hyphenation/pt.pat @@ -0,0 +1,503 @@ +% title: Hyphenation patterns for Portuguese +% copyright: Copyright (C) 1987, 1994, 1996, 2015 Pedro J. de Rezende, 1996, 2015 J. Joao Dias Almeida, 2024 Leonardo Araujo and Aline Benevides +% notice: This file is part of the hyph-utf8 package. +% See http://www.hyphenation.org/tex for more information. +% language: +% name: Portuguese +% tag: pt +% version: 1.4 2024-07-13 +% authors: +% - +% name: Pedro J. de Rezende +% contact: rezende (at) ic.unicamp.br +% - +% name: J. Joao Dias Almeida +% contact: jj (at) di.uminho.pt +% - +% name: Leonardo Araujo +% contact: leolca (at) gmail.com +% - +% name: Aline Benevides +% contact: benevides.aline12 (at) gmail.com +% licence: +% name: BSD 3-clause licence +% url: https://opensource.org/licenses/BSD-3-Clause +% text: > +% Redistribution and use in source and binary forms, with or without +% modification, are permitted provided that the following conditions +% are met: +% * Redistributions of source code must retain the above copyright +% notice, this list of conditions and the following disclaimer. +% * Redistributions in binary form must reproduce the above copyright +% notice, this list of conditions and the following disclaimer in the +% documentation and/or other materials provided with the +% distribution. +% * Neither the name of the University of Campinas, of the University +% of Minho nor the names of its contributors may be used to endorse +% or promote products derived from this software without specific +% prior written permission. +% +% THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +% "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +% LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +% A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL PEDRO J. DE +% REZENDE OR J.JOAO DIAS ALMEIDA BE LIABLE FOR ANY DIRECT, INDIRECT, +% INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +% BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS +% OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED +% AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +% LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY +% WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +% POSSIBILITY OF SUCH DAMAGE. +% hyphenmins: +% typesetting: +% left: 2 +% right: 3 +% changes: +% - Version 1.4 Release date: 13/07/2024 Leonardo Araujo and Aline Benevides +% - Version 1.3 Release date: 12/08/2015 Pedro J. de Rezende and J. Joao Dias Almeida +% - Version 1.2 Release date: 07/21/1996 Pedro J. de Rezende and J. Joao Dias Almeida +% - Version 1.1 Release date: 04/12/1994 Pedro J. de Rezende +% - Version 1.0 Release date: 02/13/1987 Pedro J. de Rezende +% texlive: +% synonyms: +% - portuges +% encoding: ec +% babelname: portuguese +% legacy_patterns: pthyph.tex +% message: Portuguese hyphenation patterns +% description: Hyphenation patterns for Portuguese in T1/EC and UTF-8 encodings. +% +\patterns{ +1b2l +1b2r +1ba +1be +1bi +1bo +1bu +1bá +1bâ +1bã +1bé +1bí +1bó +1bú +1bê +1bõ +1c2h +1c2l +1c2r +1ca +1ce +1ci +1co +1cu +1cá +1câ +1cã +1cé +1cí +1có +1cú +1cê +1cõ +1ça +1çe +1çi +1ço +1çu +1çá +1çâ +1çã +1çé +1çí +1çó +1çú +1çê +1çõ +1d2l +1d2r +1da +1de +1di +1do +1du +1dá +1dâ +1dã +1dé +1dí +1dó +1dú +1dê +1dõ +1f2l +1f2r +1fa +1fe +1fi +1fo +1fu +1fá +1fâ +1fã +1fé +1fí +1fó +1fú +1fê +1fõ +1g2l +1g2r +1ga +1ge +1gi +1go +1gu +1gu4a +1gu4e +1gu4i +1gu4o +1gá +1gâ +1gã +1gé +1gí +1gó +1gú +1gê +1gõ +1ja +1je +1ji +1jo +1ju +1já +1jâ +1jã +1jé +1jí +1jó +1jú +1jê +1jõ +1k2l +1k2r +1ka +1ke +1ki +1ko +1ku +1ká +1kâ +1kã +1ké +1kí +1kó +1kú +1kê +1kõ +1l2h +1la +1le +1li +1lo +1lu +1lá +1lâ +1lã +1lé +1lí +1ló +1lú +1lê +1lõ +1ma +1me +1mi +1mo +1mu +1má +1mâ +1mã +1mé +1mí +1mó +1mú +1mê +1mõ +1n2h +1na +1ne +1ni +1no +1nu +1ná +1nâ +1nã +1né +1ní +1nó +1nú +1nê +1nõ +1p2l +1p2r +1pa +1pe +1pi +1po +1pu +1pá +1pâ +1pã +1pé +1pí +1pó +1pú +1pê +1põ +1qu4a +1qu4e +1qu4i +1qu4o +1ra +1re +1ri +1ro +1ru +1rá +1râ +1rã +1ré +1rí +1ró +1rú +1rê +1rõ +1sa +1se +1si +1so +1su +1sá +1sâ +1sã +1sé +1sí +1só +1sú +1sê +1sõ +1t2l +1t2r +1ta +1te +1ti +1to +1tu +1tá +1tâ +1tã +1té +1tí +1tó +1tú +1tê +1tõ +1v2l +1v2r +1va +1ve +1vi +1vo +1vu +1vá +1vâ +1vã +1vé +1ví +1vó +1vú +1vê +1võ +1w2l +1w2r +1xa +1xe +1xi +1xo +1xu +1xá +1xâ +1xã +1xé +1xí +1xó +1xú +1xê +1xõ +1za +1ze +1zi +1zo +1zu +1zá +1zâ +1zã +1zé +1zí +1zó +1zú +1zê +1zõ +a3a +a3e +a3o +c3c +e3a +e3e +e3o +i3a +i3e +i3i +i3o +i3â +i3ê +i3ô +o3a +o3e +o3o +r3r +s3s +u3a +u3e +u3o +u3u +.p2si +.p2sí +.g2no +.g2nó +.g2nô +t2c +1p2neu +.t2m +.p2t +su2b3r +su2b3l +.su3b4li +a1ir. +u1ir. +1qu +1vô +1lô +1cô +1gô +1bô +1tô +1rô +1pô +a1é +a1í +a1ó +a1ú +e1á +e1â +e1ã +e1é +e1ê +e1í +e1ó +é1o +e1ú +i1á +i1ã +í1a +i1é +i1í +i1ó +í1o +i1u +i1ú +o1á +o1é +o1í +o1ó +u1á +u1ã +u1â +u1í +ú1o +1qu2á +1qu2â +1qu2í +1gu2í +a1ind +a1i1nh +e1imp +e1inc +e1inf +e1ing +e1ins +e1int +e1inv +u1iz. +a1iz. +4a. +4e. +4o. +1gu2á +1gu2ã +1qu2ã +.m2n +c2za +.s2 +1p2seu1d +1dô +1fô +1mô +1nô +1sô +1zô +tu1i +tu2it +tu2id +bu1i +nu1i +o1in +u1in +su1i +í1e +ju1i +fu1i +du1i +do1im +au1i +u1i1ç +u1ê +1gu2ê +1qu2ê +1çô +u1é +1gu2é +1qu2é +1xô +a1â +a1ã +a1ô +e1ô +.ne4o +o1ã +o1ê +o1im +o2i1na +pro1i1b +co2ima +.t2 +1- +} +\hyphenation{% Do NOT make any alterations to this list! --- PdR +hard-ware +soft-ware +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 26f9ebc22..a1c6a5490 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3882,6 +3882,50 @@ fn polish_test_dictionary() { wordcraft_proof::pl::install_dictionary(aff, dic).unwrap(); } +/// A tiny synthetic Portuguese Hunspell dictionary (written for these tests). Real dictionaries +/// are installed on the system, never bundled; every Portuguese test installs this same one. +fn portuguese_test_dictionary() { + let aff = "SET UTF-8\nFLAG UTF-8\nFORBIDDENWORD F\nTRY aeiousrntmdlcpvgqbzfhjxçãõáéíóúâêô\nMAP 2\nMAP aáã\nMAP eé\nREP 1\nREP seje seja\n"; + let dic = "11\nsaudade\ncoração\ne\nele\ndisse\nnão\né\nque\nseja\nfrequencia/F\nmuito\n"; + wordcraft_proof::pt::install_dictionary(aff, dic).unwrap(); +} + +/// Review › Language and the proofing commands work in Portuguese end to end: the language makes +/// the document's words Portuguese, F7 and the issue list use the Portuguese dictionary and +/// grammar, suggestions come from it, and a suggestion fixes the text. +#[test] +fn portuguese_proofing_through_the_review_commands() { + portuguese_test_dictionary(); + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "Saudade coraçao, e ele ele disse."})); + // English by default: the Portuguese words are English misspellings. + assert!(run(&mut s, "review.issues", json!({})).as_array().unwrap().len() >= 5); + run(&mut s, "select.all", json!({})); + run(&mut s, "review.language", json!({"lang": "pt_br", "default": true})); + assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("pt-BR"), "the tag is normalized"); + assert_eq!(run(&mut s, "format.state", json!({}))["lang"], "pt-BR"); + let issues = run(&mut s, "review.issues", json!({})); + let arr = issues.as_array().unwrap(); + let has = |text: &str, kind: &str, sugg: &str| { + arr.iter() + .any(|i| i["text"] == text && i["kind"] == kind && i["lang"] == "pt" && i["suggestions"].as_array().unwrap().iter().any(|x| x == sugg)) + }; + assert!(has("coraçao", "spelling", "coração"), "{issues}"); + assert!(has("ele ele", "grammar", "ele"), "{issues}"); + assert_eq!(arr.len(), 2, "{issues}"); + assert!(arr.iter().any(|i| i["message"] == "Palavra repetida: \"ele\""), "{issues}"); + // F7 from the start selects the first issue; Change applies a suggestion. + run(&mut s, "caret.docStart", json!({})); + let first = run(&mut s, "review.spelling", json!({})); + assert_eq!(first["text"], "coraçao"); + run(&mut s, "review.applySuggestion", json!({"text": "coração"})); + assert!(text(&s).starts_with("Saudade coração,"), "{}", text(&s)); + // Add to Dictionary works for Portuguese words too. + run(&mut s, "review.addToDictionary", json!({"word": "coraçao"})); + let left = run(&mut s, "review.issues", json!({})); + assert_eq!(left.as_array().unwrap().len(), 1, "{left}"); +} + /// Review › Language and the proofing commands work in Polish end to end: the language makes /// the document's words Polish, F7 and the issue list use the Polish dictionary and grammar, /// suggestions come from it, and a suggestion fixes the text. diff --git a/crates/proof/src/hunspell.rs b/crates/proof/src/hunspell.rs index 2c09b11da..113420843 100644 --- a/crates/proof/src/hunspell.rs +++ b/crates/proof/src/hunspell.rs @@ -26,8 +26,20 @@ pub fn set_dictionary_dir(dir: Option) { *EXTRA_DIR.write().unwrap_or_else(|e| e.into_inner()) = dir; } -/// Directories that may hold Hunspell dictionaries, most specific first. -pub fn search_dirs() -> Vec { +/// The LibreOffice extension directories a Hunspell dictionary name may live in: LibreOffice's +/// IDs are per dictionary — Polish is `dict-pl`, Brazilian Portuguese `dict-pt-BR` — so both +/// the primary-language folder and the dashed full name are tried. +fn lo_ext_dirs(name: &str) -> Vec { + let primary = name.split(['-', '_']).next().unwrap_or(name); + let dashed = name.replace('_', "-"); + let first = format!("dict-{primary}"); + let full = format!("dict-{dashed}"); + if full == first { vec![first] } else { vec![first, full] } +} + +/// Directories that may hold Hunspell dictionaries, most specific first. `lo_exts`, when a +/// language is being searched for, adds LibreOffice's extension folders for it (`dict-pt-BR`). +pub fn search_dirs(lo_exts: Option<&[String]>) -> Vec { let mut dirs: Vec = Vec::new(); if let Some(d) = EXTRA_DIR.read().unwrap_or_else(|e| e.into_inner()).clone() { dirs.push(d); @@ -38,6 +50,9 @@ pub fn search_dirs() -> Vec { if let Some(d) = std::env::var_os("WORDCRAFT_DICTIONARIES").filter(|v| !v.is_empty()) { dirs.push(PathBuf::from(d)); } + // LibreOffice keeps each language under `…/share/extensions/`. + let lo_dirs = + |prefix: &str| -> Vec { lo_exts.map_or_else(Vec::new, |exts| exts.iter().map(|e| PathBuf::from(prefix).join(e)).collect()) }; let home = std::env::var_os("HOME").filter(|v| !v.is_empty()).map(PathBuf::from); if cfg!(windows) { for var in ["LOCALAPPDATA", "APPDATA"] { @@ -48,7 +63,7 @@ pub fn search_dirs() -> Vec { } for var in ["ProgramFiles", "ProgramFiles(x86)"] { if let Some(base) = std::env::var_os(var).filter(|v| !v.is_empty()).map(PathBuf::from) { - dirs.push(base.join("LibreOffice").join("share").join("extensions").join("dict-pl")); + dirs.extend(lo_dirs(&base.join("LibreOffice").join("share").join("extensions").to_string_lossy())); } } } else if cfg!(target_os = "macos") { @@ -58,30 +73,26 @@ pub fn search_dirs() -> Vec { dirs.push(PathBuf::from("/Library/Spelling")); dirs.push(PathBuf::from("/opt/homebrew/share/hunspell")); dirs.push(PathBuf::from("/usr/local/share/hunspell")); - dirs.push(PathBuf::from("/Applications/LibreOffice.app/Contents/Resources/extensions/dict-pl")); + dirs.extend(lo_dirs("/Applications/LibreOffice.app/Contents/Resources/extensions")); } else { if let Some(h) = &home { dirs.push(h.join(".local/share/hunspell")); } - for d in [ - "/usr/share/hunspell", - "/usr/share/myspell", - "/usr/share/myspell/dicts", - "/usr/local/share/hunspell", - "/usr/local/share/myspell", - "/usr/lib/libreoffice/share/extensions/dict-pl", - "/opt/libreoffice/share/extensions/dict-pl", - ] { + for d in ["/usr/share/hunspell", "/usr/share/myspell", "/usr/share/myspell/dicts", "/usr/local/share/hunspell", "/usr/local/share/myspell"] { dirs.push(PathBuf::from(d)); } + for d in ["/usr/lib/libreoffice/share/extensions", "/opt/libreoffice/share/extensions"] { + dirs.extend(lo_dirs(d)); + } } dirs } /// The first `.aff`/`.dic` pair found for any of `names` (`pl_PL`, `pl`), searching -/// [`search_dirs`]. +/// [`search_dirs`] (including LibreOffice's extension folder for the first name). pub fn find(names: &[&str]) -> Option<(PathBuf, PathBuf)> { - search_dirs().into_iter().find_map(|dir| find_in(&dir, names)) + let lo = names.first().copied().map(lo_ext_dirs); + search_dirs(lo.as_deref()).into_iter().find_map(|dir| find_in(&dir, names)) } /// The first `.aff`/`.dic` pair for any of `names` in `dir`. @@ -146,6 +157,18 @@ mod tests { assert!(decode(b"SET UTF-8\n", b"\xff\xfe").is_err()); } + #[test] + fn libreoffice_extension_dirs_follow_the_language() { + // LibreOffice's IDs are per dictionary: Polish is `dict-pl`, Brazilian Portuguese + // `dict-pt-BR`, so both the primary-language folder and the dashed full name are tried. + assert_eq!(super::lo_ext_dirs("pl_PL"), ["dict-pl", "dict-pl-PL"]); + assert_eq!(super::lo_ext_dirs("pt_BR"), ["dict-pt", "dict-pt-BR"]); + assert_eq!(super::lo_ext_dirs("de-DE"), ["dict-de", "dict-de-DE"]); + let dirs = search_dirs(Some(&super::lo_ext_dirs("pt_BR"))); + assert!(dirs.iter().any(|d| d.ends_with("dict-pt-BR")), "{dirs:?}"); + assert!(search_dirs(None).iter().all(|d| !d.to_string_lossy().contains("dict-")), "no language: no LibreOffice guess"); + } + #[test] fn finds_and_loads_a_pair_in_a_directory() { let dir = std::env::temp_dir().join(format!("wordcraft-hunspell-{}", std::process::id())); diff --git a/crates/proof/src/hyphen.rs b/crates/proof/src/hyphen.rs index c244455aa..9919aa072 100644 --- a/crates/proof/src/hyphen.rs +++ b/crates/proof/src/hyphen.rs @@ -29,6 +29,8 @@ static EN_US_DIC: &[u8] = include_bytes!("../../../assets/hyphenation/en-us.dic" static EN_US_PAT: &str = include_str!("../../../assets/hyphenation/en-us.pat"); /// hyph-utf8's `hyph-pl.tex`, unmodified (its licence header is in the file). static PL_PAT: &str = include_str!("../../../assets/hyphenation/pl.pat"); +/// hyph-utf8's `hyph-pt.tex`, unmodified (its BSD 3-clause header is in the file). +static PT_PAT: &str = include_str!("../../../assets/hyphenation/pt.pat"); impl Dictionary { /// The bundled US English dictionary (decoded on first use). @@ -58,6 +60,12 @@ impl Patterns { static P: OnceLock = OnceLock::new(); P.get_or_init(|| Patterns::parse(PL_PAT)) } + + /// The bundled Portuguese patterns and exception words (parsed on first use). + pub fn pt() -> &'static Patterns { + static P: OnceLock = OnceLock::new(); + P.get_or_init(|| Patterns::parse(PT_PAT)) + } } /// Hyphenation limits from the paragraph's Hyphenation settings. @@ -101,6 +109,9 @@ fn core_breaks(core: &[char], lang: ProofLang) -> (Vec, Source) { if lang == ProofLang::Pl { return core_breaks_pl(core); } + if lang == ProofLang::Pt { + return core_breaks_pt(core); + } let dict = Dictionary::en_us(); let s: String = core.iter().collect(); let from_mask = |m: u64, len: usize| (1..len.min(64)).filter(|&i| m & (1 << i) != 0).collect::>(); @@ -140,6 +151,21 @@ fn core_breaks_pl(core: &[char]) -> (Vec, Source) { (heuristic_points_pl(core), Source::Heuristic) } +/// [`core_breaks_pl`] for Portuguese: the bundled patterns, then the generic heuristic. +fn core_breaks_pt(core: &[char]) -> (Vec, Source) { + let pats = Patterns::pt(); + let s: String = core.iter().collect(); + if let Some(p) = pats.exception(&s) { + return (p.to_vec(), Source::Dictionary); + } + if !pats.is_empty() + && let Some(p) = pats.points(core) + { + return (p, Source::Patterns); + } + (heuristic_points(core), Source::Heuristic) +} + /// Unrestricted break points of an English `word` ([`word_breaks_in`] with [`ProofLang::En`]). pub fn word_breaks(word: &str) -> (Vec, Source) { word_breaks_in(word, ProofLang::En) @@ -667,6 +693,40 @@ mod tests { assert_eq!(p.exception("nigdy"), Some(&[2][..])); } + fn pt(w: &str) -> String { + hyphenate_word_in(w, &Limits::default(), ProofLang::Pt) + } + + #[test] + fn portuguese_patterns_load() { + let p = Patterns::pt(); + assert!(p.len() > 300, "{}", p.len()); + // `hard-ware`, `soft-ware` — and the file's last pattern line, a bare `1-`, which the + // reader counts as a (never-matching) exception because it contains a hyphen. + assert_eq!(p.exception_count(), 3); + assert_eq!(p.exception("hardware"), Some(&[4][..])); + assert_eq!(p.exception("software"), Some(&[4][..])); + } + + #[test] + fn portuguese_words_hyphenate_by_portuguese_rules() { + for (w, want) in [ + ("problema", "pro-ble-ma"), + ("coração", "co-ra-ção"), + ("paralelepípedo", "pa-ra-le-le-pí-pe-do"), + ("computador", "com-pu-ta-dor"), + ("escrever", "es-cre-ver"), + ("saudade", "sau-da-de"), + ("extraordinário", "ex-tra-or-di-ná-rio"), + ("automático", "au-to-má-ti-co"), + // One syllable: no break. + ("flor", "flor"), + ("bem", "bem"), + ] { + assert_eq!(pt(w), want, "{w}"); + } + } + #[test] fn polish_words_hyphenate_by_polish_rules() { for (w, want) in [ diff --git a/crates/proof/src/lang.rs b/crates/proof/src/lang.rs index bfd0bea3b..344fc52e4 100644 --- a/crates/proof/src/lang.rs +++ b/crates/proof/src/lang.rs @@ -13,6 +13,10 @@ pub enum ProofLang { /// Polish (`pl`, `pl-PL`): the installed Hunspell dictionary (when there is one), the Polish grammar rules and the Polish /// hyphenation patterns. Pl, + /// Portuguese (`pt`, `pt-BR`, `pt-PT`): the installed Hunspell dictionary (VERO and + /// friends, when there is one), the Portuguese grammar rules and the Portuguese hyphenation + /// patterns. + Pt, /// A language without proofing tools here (`fr-FR`, `ja-JP`, `x-none`…). Like Word without /// the language's proofing tools: no spelling or grammar marks, so its words are never /// flagged as English misspellings. Hyphenation keeps using the English patterns, as before @@ -22,7 +26,7 @@ pub enum ProofLang { impl ProofLang { /// Every language with a spelling dictionary, in display order. - pub const PROOFED: [ProofLang; 2] = [ProofLang::En, ProofLang::Pl]; + pub const PROOFED: [ProofLang; 3] = [ProofLang::En, ProofLang::Pl, ProofLang::Pt]; /// The proofing language of a BCP 47 tag (`pl-PL`, `en_GB`, `PL`…). Only the primary subtag /// counts. No tag (or an empty one) is English, WordCraft's default editing language. @@ -33,6 +37,8 @@ impl ProofLang { ProofLang::En } else if primary.eq_ignore_ascii_case("pl") || primary.eq_ignore_ascii_case("pol") { ProofLang::Pl + } else if primary.eq_ignore_ascii_case("pt") || primary.eq_ignore_ascii_case("por") { + ProofLang::Pt } else { ProofLang::Other } @@ -43,6 +49,7 @@ impl ProofLang { match self { ProofLang::En => "en", ProofLang::Pl => "pl", + ProofLang::Pt => "pt", ProofLang::Other => "", } } @@ -116,6 +123,9 @@ mod tests { for t in ["pl", "pl-PL", "PL-pl", "pl_PL", "pl_PL.UTF-8", " pl-PL ", "pol"] { assert_eq!(ProofLang::from_tag(Some(t)), ProofLang::Pl, "{t}"); } + for t in ["pt", "pt-BR", "PT_br", "pt-BR.UTF-8", " pt-PT ", "por"] { + assert_eq!(ProofLang::from_tag(Some(t)), ProofLang::Pt, "{t}"); + } for t in ["en", "en-US", "en-GB", "EN_au", "en-Latn-US"] { assert_eq!(ProofLang::from_tag(Some(t)), ProofLang::En, "{t}"); } @@ -126,6 +136,9 @@ mod tests { assert_eq!(ProofLang::from_tag(Some("")), ProofLang::En); assert_eq!(ProofLang::from_tag(Some(" ")), ProofLang::En); assert!(ProofLang::Pl.is_proofed() && !ProofLang::Other.is_proofed()); + assert!(ProofLang::Pt.is_proofed() && !ProofLang::Other.is_proofed()); assert_eq!(ProofLang::Pl.code(), "pl"); + assert_eq!(ProofLang::Pt.code(), "pt"); + assert!(ProofLang::PROOFED.contains(&ProofLang::Pt), "every proofed language is listed"); } } diff --git a/crates/proof/src/lib.rs b/crates/proof/src/lib.rs index 32d1aa7e1..736d2164f 100644 --- a/crates/proof/src/lib.rs +++ b/crates/proof/src/lib.rs @@ -12,9 +12,13 @@ //! spelling marks), read by the affix engine ([`affix`]), suggestions tuned for Polish //! (diacritics, `ż`/`rz`, `u`/`ó`, missing spaces), and Polish grammar rules with Polish //! messages. +//! - **Portuguese** ([`ProofLang::Pt`], module [`pt`]): spelling against the Hunspell `pt_BR` +//! dictionary installed on the system (VERO and friends; not bundled), suggestions tuned for +//! Portuguese (missing accents, `seje`/`seja` confusions, missing spaces), conservative +//! grammar rules with Portuguese messages, and `hyph-utf8`'s Portuguese patterns. //! - **Other languages** ([`ProofLang::Other`]): no spelling or grammar marks, like Word without //! the language's proofing tools. -//! - **Hyphenation** ([`hyphen`]): dictionary, Liang patterns (English, Polish), heuristic. +//! - **Hyphenation** ([`hyphen`]): dictionary, Liang patterns (English, Polish, Portuguese), heuristic. //! //! [`check_text`] checks a paragraph whose runs are in different languages. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -26,6 +30,7 @@ pub mod hyphen; pub mod lang; pub mod patterns; pub mod pl; +pub mod pt; use std::collections::HashSet; use std::ops::Range; @@ -489,6 +494,7 @@ fn spelling_issue(text: &str, a: usize, b: usize, lang: ProofLang, opts: &Option } let message = match lang { ProofLang::Pl => "Możliwy błąd pisowni", + ProofLang::Pt => "Possível erro de ortografia", _ => "Possible spelling mistake", }; Some(Issue { start: a, end: b, kind: IssueKind::Spelling, message: message.into(), suggestions: Vec::new(), lang }) @@ -504,6 +510,7 @@ pub fn is_correct_in_with(word: &str, lang: ProofLang, opts: &Options) -> bool { match lang { ProofLang::En => is_correct_with(word, opts), ProofLang::Pl => accepted_by_options(word, opts) || pl::is_correct(word), + ProofLang::Pt => accepted_by_options(word, opts) || pt::is_correct(word), ProofLang::Other => true, } } @@ -513,16 +520,19 @@ pub fn suggest_in(word: &str, max: usize, lang: ProofLang) -> Vec { match lang { ProofLang::En => suggest(word, max), ProofLang::Pl => pl::suggest(word, max), + ProofLang::Pt => pt::suggest(word, max), ProofLang::Other => Vec::new(), } } /// Grammar issues in a paragraph of text in `lang`: the English rules, the Polish ones -/// ([`pl::grammar`]), or none for a language WordCraft doesn't proof. +/// ([`pl::grammar`]), the Portuguese ones ([`pt::grammar`]), or none for a language WordCraft +/// doesn't proof. pub fn check_grammar_in(text: &str, lang: ProofLang) -> Vec { match lang { ProofLang::En => check_grammar(text), ProofLang::Pl => pl::grammar::check(text), + ProofLang::Pt => pt::grammar::check(text), ProofLang::Other => Vec::new(), } } @@ -994,6 +1004,12 @@ mod tests { assert_eq!(flagged("helo świat", &check_text("helo świat", &[(5..12, ProofLang::Pl)])), ["helo"]); // A language without tools: nothing flagged there. assert!(check_text("Bonjour mes amis", &[(0..16, ProofLang::Other)]).is_empty()); + // Portuguese checks in their span only, with the Portuguese message. + pt::test_dict::install(); + let t = "Ele disse que coraçao e frequencia existem."; + let v = check_text(t, &[(0..t.len(), ProofLang::Pt)]); + assert_eq!(flagged(t, &v), ["coraçao", "frequencia"], "{v:?}"); + assert!(v.iter().all(|i| i.lang == ProofLang::Pt && i.message == "Possível erro de ortografia"), "{v:?}"); } proptest::proptest! { @@ -1005,7 +1021,7 @@ mod tests { let _ = has_dictionary(Some(&s)); let _ = is_correct_for(&s, Some(&s)); let _ = hyphen::hyphen_points(&s, &hyphen::Limits::default()); - for lang in [ProofLang::En, ProofLang::Pl, ProofLang::Other] { + for lang in [ProofLang::En, ProofLang::Pl, ProofLang::Pt, ProofLang::Other] { let _ = check_spelling_in(&s, lang); let _ = check_grammar_in(&s, lang); let _ = is_correct_in(&s, lang); @@ -1021,9 +1037,14 @@ mod tests { } #[test] - fn mixed_spans_never_panic(s in "\\PC{0,60}", cuts in proptest::collection::vec((0usize..80, 0usize..80, 0u8..3), 0..6)) { - let langs = [ProofLang::En, ProofLang::Pl, ProofLang::Other]; - let spans: Vec<(Range, ProofLang)> = cuts.iter().map(|&(a, b, l)| (a.min(b)..a.max(b), langs[l as usize % 3])).collect(); + fn portuguese_suggestions_never_panic(s in "[a-záàâãéêíóôõúüçA-ZÁÀÂÃÉÊÍÓÔÕÚÜÇ'’ -]{0,14}") { + let _ = suggest_in(&s, 4, ProofLang::Pt); + } + + #[test] + fn mixed_spans_never_panic(s in "\\PC{0,60}", cuts in proptest::collection::vec((0usize..80, 0usize..80, 0u8..4), 0..6)) { + let langs = [ProofLang::En, ProofLang::Pl, ProofLang::Pt, ProofLang::Other]; + let spans: Vec<(Range, ProofLang)> = cuts.iter().map(|&(a, b, l)| (a.min(b)..a.max(b), langs[l as usize % 4])).collect(); for i in check_text(&s, &spans) { proptest::prop_assert!(i.start <= i.end && i.end <= s.len()); } diff --git a/crates/proof/src/pt/grammar.rs b/crates/proof/src/pt/grammar.rs new file mode 100644 index 000000000..c5109de72 --- /dev/null +++ b/crates/proof/src/pt/grammar.rs @@ -0,0 +1,245 @@ +//! Portuguese grammar and punctuation checks: cheap, rule-based and tuned to stay quiet when +//! unsure (a missed mistake costs less than a wrong mark on correct text). Messages are in +//! Portuguese. +//! +//! - **Repeated word** (`ele ele`), except interjections and words Portuguese doubles on purpose +//! (`sim sim`, `não não`, `ora ora`). +//! - **Space before punctuation** (`bem ,`) and **double spaces**; emoticons (` :)`) and +//! ellipses are left alone. +//! - **Capital letter at the start of a sentence**, knowing Portuguese abbreviations (`etc.`, +//! `pág.`, `Prof.`, `Dr.`), initials, ordinal numbers (`5. colocado`), ellipses and dialogue +//! dashes (`— Vai — disse`), after which lower case is correct. +//! - **Doubled punctuation** (`,,`, `..`, `;;`). +//! +//! No comma rule (`que` opens clauses only sometimes) and no English rule (such as `a`/`an`) +//! applies to Portuguese text. + +use crate::{Issue, IssueKind, ProofLang, words}; + +/// Words Portuguese repeats on purpose: interjections, onomatopoeia and emphatic doubling. +const ALLOWED_REPEATS: &[&str] = &[ + "sim", "não", "ora", "olha", "já", "bem", "muito", "vai", "lá", "cá", "aí", "ha", "he", "hi", "oh", "ah", "ai", "ui", "uf", "uxa", "oba", "viva", + "psiu", "olá", "tchau", "xau", "adeus", "devagar", +]; + +/// Abbreviations written with a final period after which a sentence goes on in lower case +/// (lower case, without the period). +const ABBREVIATIONS: &[&str] = &[ + "etc", "ex", "pág", "págs", "séc", "sécs", "fl", "av", "vol", "p", "pp", "ed", "tel", "min", "max", "art", "par", "prof", "profa", "dr", "dra", + "sr", "sra", "srta", "eng", "arq", "op", "cit", "cf", "ibid", "id", "vide", "seg", "ter", "qua", "qui", "sex", "sáb", "dom", "jan", "fev", "mar", + "abr", "mai", "jun", "jul", "ago", "set", "out", "nov", "dez", "s.a", "d.c", "a.c", "pg", "ref", "cod", "dept", "univ", "inst", "lat", "ing", + "esp", "fr", "it", "al", "obs", "att", "obs", +]; + +fn issue(start: usize, end: usize, message: String, suggestions: Vec) -> Issue { + Issue { start, end, kind: IssueKind::Grammar, message, suggestions, lang: ProofLang::Pt } +} + +fn capitalize(w: &str) -> String { + crate::affix::capitalize(w) +} + +/// Is `prev`, before a period, an abbreviation, an initial, a number or a Roman numeral (after +/// which a lower-case word doesn't start a sentence)? +fn abbreviation_or_number(prev: &str) -> bool { + prev.chars().count() <= 1 + || prev.chars().any(|c| c.is_ascii_digit()) + || (prev.chars().all(|c| matches!(c, 'I' | 'V' | 'X' | 'L' | 'C' | 'D' | 'M'))) + || ABBREVIATIONS.contains(&prev.to_lowercase().as_str()) +} + +const CLOSING: [char; 8] = ['"', '”', '’', '\'', ')', '»', ']', '\u{1}']; +const OPENING: [char; 8] = ['"', '“', '«', '(', '[', '\'', '‚', '—']; + +/// Does the gap between two words end a sentence: a `.`, `!` or `?` (not an ellipsis, not after +/// an abbreviation), then space, with nothing but quotes and brackets around? +fn ends_sentence(gap: &str, prev: &str) -> bool { + let t = gap.trim_start_matches(CLOSING); + let Some(end) = t.chars().next() else { return false }; + if !matches!(end, '.' | '!' | '?') || t.starts_with("..") { + return false; + } + if end == '.' && abbreviation_or_number(prev) { + return false; + } + let rest = t.trim_start_matches(['.', '!', '?']).trim_start_matches(CLOSING); + rest.starts_with(char::is_whitespace) && rest.trim_start().chars().all(|c| OPENING.contains(&c)) +} + +/// Grammar issues in a paragraph of Portuguese text, in order. +pub fn check(text: &str) -> Vec { + let ws: Vec<(usize, usize)> = words(text); + let mut v = Vec::new(); + repeated_words(text, &ws, &mut v); + spaces(text, &mut v); + sentence_capitals(text, &ws, &mut v); + doubled_punctuation(text, &mut v); + v.sort_by_key(|i| (i.start, i.end)); + v +} + +fn repeated_words(text: &str, ws: &[(usize, usize)], v: &mut Vec) { + for pair in ws.windows(2) { + let [(a0, b0), (a1, b1)] = pair else { continue }; + let (Some(w0), Some(w1), Some(gap)) = (text.get(*a0..*b0), text.get(*a1..*b1), text.get(*b0..*a1)) else { continue }; + if !gap.is_empty() + && gap.chars().all(char::is_whitespace) + && w0.chars().all(char::is_alphabetic) + && w0.to_lowercase() == w1.to_lowercase() + && !ALLOWED_REPEATS.contains(&w0.to_lowercase().as_str()) + { + v.push(issue(*a0, *b1, format!("Palavra repetida: \"{w1}\""), vec![w0.to_string()])); + } + } +} + +fn spaces(text: &str, v: &mut Vec) { + for (i, c) in text.char_indices() { + if c != ' ' { + continue; + } + let rest = text.get(i + 1..).unwrap_or(""); + let mut it = rest.chars(); + let Some(p) = it.next() else { continue }; + let after = it.next(); + let flag = match p { + ',' => true, + // `;)` and `;-)` are emoticons. + ';' => !matches!(after, Some(')' | '(' | '-' | 'P' | 'D')), + // ` ...` is an ellipsis, ` .5` a number. + '.' => !matches!(after, Some('.')) && !after.is_some_and(|a| a.is_ascii_digit()), + // ` :)`, ` :-)`, ` :D` and similar are emoticons; ` ?!` is still a stray space. + ':' | '!' | '?' => after.is_none_or(|a| a.is_whitespace() || matches!(a, '"' | '”' | '’' | '!' | '?')), + _ => false, + }; + if flag { + v.push(issue(i, i + 1 + p.len_utf8(), "Espaço antes da pontuação".into(), vec![p.to_string()])); + } + } + for (i, _) in text.match_indices(" ") { + if i > 0 && text.get(..i).is_some_and(|t| t.ends_with(' ')) { + continue; + } + v.push(issue(i, i + 2, "Espaço duplo".into(), vec![" ".into()])); + } +} + +fn sentence_capitals(text: &str, ws: &[(usize, usize)], v: &mut Vec) { + // List items (`– maçãs,`) may start in lower case. + let list_item = text.trim_end().ends_with([',', ';']); + for (k, &(a, b)) in ws.iter().enumerate() { + let Some(w) = text.get(a..b) else { continue }; + let starts = if k == 0 { + let before = text.get(..a).unwrap_or(""); + !list_item && before.chars().all(|c| c.is_whitespace() || OPENING.contains(&c)) + } else { + let Some(&(pa, pb)) = ws.get(k - 1) else { continue }; + ends_sentence(text.get(pb..a).unwrap_or(""), text.get(pa..pb).unwrap_or("")) + }; + if starts && w.chars().next().is_some_and(char::is_lowercase) && w.chars().all(char::is_alphabetic) { + v.push(issue(a, b, "Letra maiúscula no início da frase".into(), vec![capitalize(w)])); + } + } +} + +fn doubled_punctuation(text: &str, v: &mut Vec) { + let bytes = text.as_bytes(); + let mut i = 0; + while let Some(&b) = bytes.get(i) { + if !matches!(b, b',' | b'.' | b';') { + i += 1; + continue; + } + let run = bytes.get(i..).map_or(0, |r| r.iter().take_while(|&&x| x == b).count()); + let flag = match b { + b'.' => run == 2, + _ => run >= 2, + }; + if flag { + let end = i + run; + let suggestions: Vec = match b { + b'.' => vec![".".into(), "…".into()], + _ => vec![char::from(b).to_string()], + }; + v.push(issue(i, end, "Sinal repetido".into(), suggestions)); + } + i += run.max(1); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn messages(t: &str) -> Vec { + check(t).into_iter().map(|i| format!("{}@{}", i.message, &t[i.start..i.end])).collect() + } + + fn clean(t: &str) { + let m = messages(t); + assert!(m.is_empty(), "{t:?}: {m:?}"); + } + + #[test] + fn repeated_words() { + let v = check("Eu eu vi isso e ele ele saiu."); + assert_eq!(v.len(), 2, "{v:?}"); + assert_eq!(v[0].message, "Palavra repetida: \"eu\""); + assert_eq!(v[0].suggestions, ["Eu"]); + assert!(v.iter().all(|i| i.lang == ProofLang::Pt && i.kind == IssueKind::Grammar)); + clean("Sim, sim! Não, não! Ora ora, que surpresa."); + } + + #[test] + fn spaces_and_punctuation() { + let m = messages("Olá , mundo . Tudo bem ?"); + assert!(m.contains(&"Espaço antes da pontuação@ ,".to_string()), "{m:?}"); + assert!(m.contains(&"Espaço antes da pontuação@ .".to_string()), "{m:?}"); + assert!(m.contains(&"Espaço antes da pontuação@ ?".to_string()), "{m:?}"); + assert!(m.contains(&"Espaço duplo@ ".to_string()), "{m:?}"); + clean("Legal :) Muito bom :-) Ou :D Valor .5 e reticências ..."); + let m = messages("Falou,, e saiu.. depois;; fim."); + assert_eq!(m.iter().filter(|x| x.starts_with("Sinal repetido")).count(), 3, "{m:?}"); + clean("Esperei… e esperei... até ele voltar."); + } + + #[test] + fn sentence_start_capitals() { + let m = messages("isso é um teste. a segunda frase! terceira? ok."); + assert_eq!(m.iter().filter(|x| x.starts_with("Letra maiúscula")).count(), 4, "{m:?}"); + assert_eq!(check("ele tem razão.")[0].suggestions, ["Ele"]); + // Abbreviations, initials, ordinals, ellipses and dialogue dashes. + clean("Comprei frutas, etc. e depois saímos."); + clean("Conheci o Dr. Silva na Av. Paulista."); + clean("Zhou ficou em 5. lugar no séc. XX."); + clean("Esperei... e ele apareceu."); + clean("— Vai — disse ele. — Amanhã eu volto."); + clean("Ele disse: \"Volto amanhã\". E saiu."); + // A list item may start in lower case. + clean("maçãs,"); + } + + #[test] + fn correct_portuguese_text_is_clean() { + clean( + "O rato roeu a roupa do rei de Roma. Quem não tem cão caça com gato, \ + e quem tem paciência colhe o que plantou.", + ); + clean("Brasília, 12 de outubro de 2026 — uma data memorável."); + } + + proptest::proptest! { + #[test] + fn never_panics(s in "\\PC{0,80}") { + let _ = check(&s); + } + + #[test] + fn never_panics_on_portuguese_like_text(s in "[a-záàâãéêíóôõúüçA-Z ,.;:!?\"…\\-—]{0,60}") { + for i in check(&s) { + proptest::prop_assert!(i.start <= i.end && i.end <= s.len()); + proptest::prop_assert!(s.is_char_boundary(i.start) && s.is_char_boundary(i.end)); + } + } + } +} diff --git a/crates/proof/src/pt/mod.rs b/crates/proof/src/pt/mod.rs new file mode 100644 index 000000000..be16c74b8 --- /dev/null +++ b/crates/proof/src/pt/mod.rs @@ -0,0 +1,202 @@ +//! Portuguese proofing. +//! +//! - **Spelling** against the Hunspell `pt_BR` dictionary installed on the system +//! ([`dictionary`], found by [`crate::hunspell`]: `pt_BR.aff`/`pt_BR.dic` — VERO and its +//! derivatives — from the OS, a package manager or LibreOffice; it isn't bundled). Without it +//! Portuguese text gets no spelling marks. Capitalized stems are names (`Brasília`); a sentence +//! may start with any word and headings may be in capitals. Hyphenated compounds are checked +//! part by part (`guarda-roupa`, `ex-marido`), and the user dictionary applies as in English. +//! - **Suggestions** tuned for Portuguese ([`suggest`]): missing accents first (`coraçao` → +//! `coração`, `saude` → `saúde`), then the dictionary's `REP` confusions (`seje` → `seja`), a +//! missing space, one-letter typos, and a dictionary scan for anything further off. +//! - **Grammar** ([`grammar`]): cheap, reliable rules with Portuguese messages. + +pub mod grammar; +mod suggest; +#[cfg(test)] +pub(crate) mod test_dict; + +use std::sync::{Arc, RwLock}; + +use crate::affix::AffixDict; + +pub use suggest::suggest; + +/// The Portuguese dictionary: `None` until first use, then the installed one (or an empty one). +static DICT: RwLock>> = RwLock::new(None); + +/// File names of a Portuguese Hunspell dictionary. +const NAMES: &[&str] = &["pt_BR", "pt-BR", "pt"]; + +/// The Portuguese dictionary: the installed Hunspell `pt_BR` (read on first use), one given with +/// [`install_dictionary`], or an empty one when none is available (Portuguese spelling is off +/// then; see [`has_dictionary`]). +pub fn dictionary() -> Arc { + if let Some(d) = DICT.read().unwrap_or_else(|e| e.into_inner()).as_ref() { + return d.clone(); + } + let loaded = Arc::new(load_installed()); + let mut slot = DICT.write().unwrap_or_else(|e| e.into_inner()); + // Another thread (or an install) may have filled it meanwhile: that one wins. + slot.get_or_insert(loaded).clone() +} + +fn load_installed() -> AffixDict { + let Some((aff, dic)) = crate::hunspell::find(NAMES) else { + log::info!("Portuguese spelling: no Hunspell pt_BR dictionary installed"); + return AffixDict::default(); + }; + crate::hunspell::load(&aff, &dic).unwrap_or_else(|e| { + log::warn!("Portuguese spelling: {e}"); + AffixDict::default() + }) +} + +/// Use this Portuguese dictionary from now on (Hunspell `.aff` and `.dic` text, decoded to +/// UTF-8), for example one the user picked. On error the current dictionary stays. +pub fn install_dictionary(aff: &str, dic: &str) -> Result<(), String> { + let d = AffixDict::from_hunspell(aff, dic, &[])?; + *DICT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(d)); + Ok(()) +} + +/// Forget the loaded dictionary, so the next check looks for an installed one again (after +/// [`crate::hunspell::set_dictionary_dir`] or a dictionary was installed). +pub fn reload_dictionary() { + *DICT.write().unwrap_or_else(|e| e.into_inner()) = None; +} + +/// Is a Portuguese dictionary available (spelling is checked)? +pub fn has_dictionary() -> bool { + !dictionary().is_empty() +} + +/// Is `c` a letter of the Latin script (the only one the Portuguese dictionary covers)? +fn is_latin_letter(c: char) -> bool { + c.is_alphabetic() && (c < '\u{0250}' || ('\u{1E00}'..='\u{1EFF}').contains(&c)) +} + +/// Is `word` spelled correctly in Portuguese? Numbers, single letters, short words in capitals +/// (acronyms: `USP`, `PT`), words with digits and words in other scripts are accepted, and so +/// is every word when no Portuguese dictionary is installed. +pub fn is_correct(word: &str) -> bool { + is_correct_with(&dictionary(), word) +} + +/// [`is_correct`] against a given dictionary (an empty one accepts everything). +pub fn is_correct_with(d: &AffixDict, word: &str) -> bool { + if d.is_empty() { + return true; // no Portuguese dictionary installed: no spelling marks + } + let w = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '\'' && c != '’'); + let w = w.trim_matches(['\'', '’']); + let n = w.chars().count(); + if n <= 1 || w.chars().any(|c| c.is_ascii_digit()) || !w.chars().any(char::is_alphabetic) { + return true; + } + if w.chars().all(|c| !c.is_lowercase()) && n <= 6 { + return true; + } + if !w.chars().any(is_latin_letter) { + return true; // other scripts: no dictionary + } + if w.contains('-') { + return w.split('-').all(|p| p.chars().count() <= 1 || known(d, p)); + } + known(d, w) +} + +/// Is one word (no hyphens) in the dictionary or the user dictionary? +fn known(d: &AffixDict, w: &str) -> bool { + d.check(w) || crate::in_user_dictionary(&w.to_lowercase()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_dictionary_loads() { + test_dict::install(); + let d = dictionary(); + assert!(has_dictionary()); + assert!(d.len() > 50, "{}", d.len()); + assert!(d.rule_count() >= 5, "the test dictionary's affix rules"); + assert!(!d.rep().is_empty() && !d.map().is_empty() && !d.try_chars().is_empty()); + } + + #[test] + fn without_a_dictionary_nothing_is_flagged() { + let none = AffixDict::default(); + for w in ["coraçao", "saude", "xqzv", ""] { + assert!(is_correct_with(&none, w), "{w}"); + } + assert!(install_dictionary("FLAG long\n", "1\nkot\n").is_err(), "unsupported files are refused"); + } + + #[test] + fn portuguese_words_are_correct() { + test_dict::install(); + for w in [ + "coração", + "saudade", + "pássaro", + "saúde", + "árvore", + "escrever", + "computador", + "praia", + "conhecer", + "Brasília", + "São", + "autoestima", + "frequência", + "exceção", + "amanhã", + "vocês", + "coragem", + "parabéns", + "cantava", + "cantando", + "provável", + "prováveis", + "descobrir", + "trabalho", + "CORAGEM", + "USP", + "2026", + "e-mail", + "guarda-roupa", + "ex-marido", + "vice-presidente", + "e", + "não", + ] { + assert!(is_correct(w), "{w}"); + } + } + + #[test] + fn misspellings_are_flagged() { + test_dict::install(); + for w in ["coraçao", "saude", "trbalho", "excrever", "prsidente", "frequencia", "amnha", "cantaando"] { + assert!(!is_correct(w), "{w}"); + } + } + + #[test] + fn forbidden_words_are_rejected() { + test_dict::install(); + // `frequencia/F` is listed with FORBIDDENWORD: the old spelling, never correct. + assert!(!is_correct("frequencia") && !is_correct("Frequencia") && !is_correct("FREQUENCIA")); + assert!(is_correct("frequência")); + } + + #[test] + fn user_dictionary_applies() { + test_dict::install(); + assert!(!is_correct("zxqportuguespalavra")); + crate::add_word("Zxqportuguespalavra"); + assert!(is_correct("zxqportuguespalavra")); + } +} diff --git a/crates/proof/src/pt/suggest.rs b/crates/proof/src/pt/suggest.rs new file mode 100644 index 000000000..314ef39bd --- /dev/null +++ b/crates/proof/src/pt/suggest.rs @@ -0,0 +1,444 @@ +//! Portuguese spelling suggestions, best first. +//! +//! Candidates come in rounds of rising cost, each checked against the dictionary: +//! 1. letter case (`brasília` → `Brasília`); +//! 2. missing or wrong accents, from the dictionary's `MAP` groups (`coraçao` → `coração`, +//! `saude` → `saúde`), up to four letters changed — Portuguese typed without accents is the +//! most common slip, so adding one ranks first, while taking away an accent the writer typed +//! ranks after the known confusions; +//! 3. the dictionary's `REP` table of common confusions (`seje` → `seja`, `excessão` → +//! `exceção`, `previlégio` → `privilégio`); +//! 4. a missing space (`nãoé` → `não é`, `paraela` → `para ela`), cheaper when one half is a +//! short function word; +//! 5. one typo: a letter left out, added, swapped with the next or mistyped (`TRY` letters); +//! 6. when that finds fewer than asked for: the stems that start like the word (accents +//! folded) and share the most letter pairs with it, whose forms are ranked by an edit distance +//! in which an accent slip costs half (two typos at most). +//! +//! The input is bounded (40 letters), and so is every round, so a call costs ten-odd thousand +//! dictionary lookups at most. + +use std::collections::{HashMap, HashSet}; + +use crate::affix::{AffixDict, Casing, capitalize, casing}; + +/// Short words often run into the next one (`não é`, `da água`, `para isso`). +const LEADING_WORDS: &[&str] = &[ + "a", "o", "e", "é", "de", "do", "da", "dos", "das", "em", "no", "na", "nos", "nas", "por", "para", "com", "sem", "sob", "sobre", "ao", "aos", + "à", "às", "que", "se", "não", "como", "mas", "mais", "já", "até", "entre", "contra", "perante", "desde", "após", "ante", "durante", "mediante", + "segundo", "conforme", "salvo", "fora", "onde", "quando", "bem", "mal", "pois", "logo", "portanto", "porém", "todavia", "contudo", "assim", + "tão", "tanto", "tudo", "nada", "algo", "cada", "outro", "mesmo", "próprio", "ainda", "tampouco", "quase", "talvez", "nunca", "sempre", +]; +/// Short words often run into the one before (`amo-te`, `deu-lhe`; in writing without the +/// hyphen: `amote`, `deulhe`). +const TRAILING_WORDS: &[&str] = &[ + "me", "te", "se", "lhe", "nos", "vos", "lhes", "o", "a", "os", "as", "ele", "ela", "eles", "elas", "isso", "isto", "aquilo", "mim", "ti", "si", + "eu", "tu", "que", "de", "em", "é", +]; +const MAX_LETTERS: usize = 40; +const MAX_MAP_CHANGES: usize = 4; +const MAX_MAP_VARIANTS: usize = 4096; +/// Stems the scan of round 6 expands. +const SCAN_STEMS: usize = 48; + +const COST_CASE: u32 = 0; +const COST_ADD_DIACRITIC: u32 = 4; +const COST_OTHER_MAP: u32 = 7; +const COST_DROP_DIACRITIC: u32 = 11; +const COST_REP: u32 = 10; +const COST_SPLIT: u32 = 12; +const COST_SPLIT_RARE: u32 = 18; +const COST_SWAP: u32 = 14; +const COST_DROP_OR_ADD: u32 = 15; +const COST_MISTYPE: u32 = 16; +const COST_SCAN: u32 = 20; + +struct Collector<'a> { + d: &'a AffixDict, + /// The word, lower case. + input: String, + tested: HashSet, + /// Suggestion → (cost, order found). + found: HashMap, + seq: usize, +} + +impl Collector<'_> { + /// Check a lower-case candidate (a word, or two separated by a space) and keep it if every + /// word is in the dictionary, as written or capitalized (names). + fn offer(&mut self, cand: &str, cost: u32) { + if cand.is_empty() || cand == self.input || !self.tested.insert(cand.to_string()) { + return; + } + let mut words = Vec::new(); + let mut cost = cost; + for part in cand.split(' ') { + if self.d.contains(part) { + words.push(part.to_string()); + } else { + let c = capitalize(part); + if !self.d.contains(&c) { + return; + } + words.push(c); + // A name ranks after a common word at the same distance (`moça`, then `Moça`). + cost += 1; + } + } + self.keep(words.join(" "), cost); + } + + /// Keep a suggestion known to be correct. + fn keep(&mut self, text: String, cost: u32) { + self.seq += 1; + let seq = self.seq; + let e = self.found.entry(text).or_insert((cost, seq)); + if cost < e.0 { + *e = (cost, seq); + } + } + + fn count_within(&self, cost: u32) -> usize { + self.found.values().filter(|(c, _)| *c <= cost).count() + } +} + +/// The MAP group of each letter (its other members are its likely confusions). +fn alternatives(c: char, map: &[Vec]) -> Vec { + map.iter().filter(|g| g.contains(&c)).flat_map(|g| g.iter().copied()).filter(|&x| x != c).collect() +} + +/// The cost of changing letter `from` to its MAP relative `to`. Adding a missing accent is the +/// commonest slip (Portuguese typed without accents: `coracao`); an accent the writer did type +/// is likelier right, so taking one away costs more than a known confusion from the REP table +/// (`seje` → `seja` before `sejé`). +fn map_cost(from: char, to: char) -> u32 { + match (from.is_ascii(), to.is_ascii()) { + (true, false) => COST_ADD_DIACRITIC, + (false, true) => COST_DROP_DIACRITIC, + _ => COST_OTHER_MAP, + } +} + +/// Every way of changing up to [`MAX_MAP_CHANGES`] letters to a MAP relative, with its cost. +fn map_variants(chars: &[char], alts: &[Vec], i: usize, changes: usize, cost: u32, buf: &mut Vec, out: &mut Vec<(String, u32)>) { + if out.len() >= MAX_MAP_VARIANTS { + return; + } + let Some(&c) = chars.get(i) else { + if changes > 0 { + out.push((buf.iter().collect(), cost)); + } + return; + }; + buf.push(c); + map_variants(chars, alts, i + 1, changes, cost, buf, out); + buf.pop(); + if changes < MAX_MAP_CHANGES { + for &a in alts.get(i).map(Vec::as_slice).unwrap_or_default() { + buf.push(a); + map_variants(chars, alts, i + 1, changes + 1, cost + map_cost(c, a), buf, out); + buf.pop(); + } + } +} + +/// A letter's MAP representative (the first of its group), lower case: `ã` → `a`, `ç` → `c`. +fn fold(c: char, map: &[Vec]) -> char { + let l = c.to_lowercase().next().unwrap_or(c); + map.iter().find(|g| g.contains(&l)).and_then(|g| g.first().copied()).unwrap_or(l) +} + +/// Optimal string alignment distance where a MAP slip (`a`/`á`, `c`/`ç`) costs 1 and any other +/// edit 2; `limit + 1` once it is certainly above `limit`. +fn weighted_distance(a: &[char], b: &[char], related: &dyn Fn(char, char) -> bool, limit: u32) -> u32 { + let m = b.len(); + let over = limit + 1; + let mut prev2 = vec![0u32; m + 1]; + let mut prev: Vec = (0..=m as u32).map(|j| j * 2).collect(); + let mut cur = vec![0u32; m + 1]; + for (i, &ai) in a.iter().enumerate() { + if let Some(c) = cur.get_mut(0) { + *c = (i as u32 + 1) * 2; + } + let mut row_min = u32::MAX; + for (j, &bj) in b.iter().enumerate() { + let sub = if ai == bj { + 0 + } else if related(ai, bj) { + 1 + } else { + 2 + }; + let at = |v: &[u32], k: usize| v.get(k).copied().unwrap_or(u32::MAX / 4); + let mut v = (at(&prev, j + 1) + 2).min(at(&cur, j) + 2).min(at(&prev, j) + sub); + if i > 0 && j > 0 && a.get(i - 1) == Some(&bj) && b.get(j - 1) == Some(&ai) { + v = v.min(at(&prev2, j - 1) + 2); + } + if let Some(c) = cur.get_mut(j + 1) { + *c = v; + } + row_min = row_min.min(v); + } + if m > 0 && row_min > limit { + return over; + } + std::mem::swap(&mut prev2, &mut prev); + std::mem::swap(&mut prev, &mut cur); + } + prev.get(m).copied().unwrap_or(over).min(over) +} + +/// Spelling suggestions for a Portuguese word, best first, at most `max`, in the word's letter case. +pub fn suggest(word: &str, max: usize) -> Vec { + suggest_in(&super::dictionary(), word, max) +} + +/// [`suggest`] against a given dictionary. +pub fn suggest_in(d: &AffixDict, word: &str, max: usize) -> Vec { + let word = word.trim(); + let n = word.chars().count(); + if n == 0 || n > MAX_LETTERS || max == 0 || d.is_empty() || word.contains(char::is_whitespace) { + return Vec::new(); + } + let case = casing(word); + let input = word.to_lowercase(); + let chars: Vec = input.chars().collect(); + let mut col = Collector { d, input: input.clone(), tested: HashSet::new(), found: HashMap::new(), seq: 0 }; + // 1. Letter case. + if d.contains(&input) && word != input { + col.keep(input.clone(), COST_CASE); + } else if d.contains(&capitalize(&input)) && word != capitalize(&input) { + col.keep(capitalize(&input), COST_CASE); + } + // 2. Accents and other MAP slips. + let map = d.map(); + let alts: Vec> = chars.iter().map(|&c| alternatives(c, map)).collect(); + let mut variants = Vec::new(); + map_variants(&chars, &alts, 0, 0, 0, &mut Vec::with_capacity(n), &mut variants); + variants.sort_by_key(|v| v.1); + for (v, cost) in &variants { + col.offer(v, *cost); + } + // 3. Common confusions. + for (from, to) in d.rep() { + if from.is_empty() { + continue; + } + for (i, _) in input.match_indices(from.as_str()) { + if let (Some(a), Some(b)) = (input.get(..i), input.get(i + from.len()..)) { + col.offer(&format!("{a}{to}{b}"), COST_REP); + } + } + } + // 4. A missing space: cheap after a short leading word or before a short trailing one, + // dearer between two longer words, never leaving a stray short half. + for (i, _) in input.char_indices().skip(1) { + let (Some(a), Some(b)) = (input.get(..i), input.get(i..)) else { continue }; + let long = |p: &str| p.chars().count() >= 3; + let cost = if (LEADING_WORDS.contains(&a) && long(b)) || (long(a) && TRAILING_WORDS.contains(&b)) { + COST_SPLIT + } else if long(a) && long(b) { + COST_SPLIT_RARE + } else { + continue; + }; + col.offer(&format!("{a} {b}"), cost); + } + // 5. One typo. + let mut letters: Vec = d.try_chars().chars().filter(|c| c.is_lowercase()).collect(); + if letters.is_empty() { + letters = "aeiousrntmdlcpvgqbzfhjxçãõáéíóúâêôkw".chars().collect(); + } + let s = |v: &[char]| v.iter().collect::(); + for i in 0..n.saturating_sub(1) { + if chars.get(i) != chars.get(i + 1) { + let mut v = chars.clone(); + v.swap(i, i + 1); + col.offer(&s(&v), COST_SWAP); + } + } + for i in 0..n { + let mut v = chars.clone(); + v.remove(i); + col.offer(&s(&v), COST_DROP_OR_ADD); + } + for i in 0..=n { + for &c in &letters { + let mut v = chars.clone(); + v.insert(i, c); + col.offer(&s(&v), COST_DROP_OR_ADD); + } + } + for i in 0..n { + for &c in &letters { + if chars.get(i) != Some(&c) { + let mut v = chars.clone(); + if let Some(x) = v.get_mut(i) { + *x = c; + } + col.offer(&s(&v), COST_MISTYPE); + } + } + } + // 6. Further off: scan the stems that start like the word. + if col.count_within(COST_MISTYPE) < max { + scan(&mut col, &chars); + } + let mut found: Vec<(String, (u32, usize))> = col.found.into_iter().collect(); + // Among equal costs, the change nearest the word's start is the likelier intent + // (`saúde` before `saudê`: both add an accent, but at the first difference). + let diff_at = |text: &str| chars.iter().zip(text.chars()).position(|(a, b)| *a != b).unwrap_or(n); + found.sort_by_key(|(text, (cost, seq))| (*cost, diff_at(text), *seq)); + let mut out: Vec = Vec::new(); + for (text, _) in found { + let t = match case { + Casing::Initial => capitalize(&text), + Casing::Upper if n > 1 => text.to_uppercase(), + _ => text, + }; + if t != word && !out.contains(&t) { + out.push(t); + } + if out.len() >= max { + break; + } + } + out +} + +/// Round 6: rank the stems starting with the word's first letter (or a MAP relative of it, either +/// case) by shared letter pairs, then keep the forms of the best within two typos. +fn scan(col: &mut Collector, chars: &[char]) { + let d = col.d; + let map = d.map(); + let Some(&first) = chars.first() else { return }; + let folded: Vec = chars.iter().map(|&c| fold(c, map)).collect(); + let pairs: HashSet<(char, char)> = std::iter::once(' ').chain(folded.iter().copied()).zip(folded.iter().copied()).collect(); + let mut firsts: Vec = std::iter::once(first).chain(alternatives(first, map)).collect(); + let upper: Vec = firsts.iter().flat_map(|c| c.to_uppercase()).collect(); + firsts.extend(upper); + firsts.sort_unstable(); + firsts.dedup(); + let n = folded.len(); + let mut best: Vec<(u32, &str)> = Vec::new(); + for f in firsts { + let prefix = f.to_string(); + for stem in d.stems_with_prefix(&prefix) { + let sf: Vec = stem.chars().map(|c| fold(c, map)).collect(); + let shared = std::iter::once(' ').chain(sf.iter().copied()).zip(sf.iter().copied()).filter(|p| pairs.contains(p)).count(); + if shared < 2 { + continue; + } + // Shared pairs, less the stem's letters the word doesn't explain, less a length gap + // beyond what an ending explains. + let extra = sf.len().saturating_sub(shared); + let gap = sf.len().abs_diff(n).saturating_sub(4); + let score = (shared * 4).saturating_sub(extra * 3 + gap * 2) as u32; + if score == 0 { + continue; + } + if best.len() < SCAN_STEMS { + best.push((score, stem)); + } else if let Some(min) = best.iter_mut().min_by_key(|b| b.0) + && score > min.0 + { + *min = (score, stem); + } + } + } + best.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(b.1))); + let related = |a: char, b: char| fold(a, map) == fold(b, map); + let limit = 4; + for (_, stem) in best { + for form in d.forms(stem) { + let lf: Vec = form.to_lowercase().chars().collect(); + if lf.len().abs_diff(chars.len()) > 2 { + continue; + } + let dist = weighted_distance(&lf, chars, &related, limit); + if dist <= limit && lf.as_slice() != chars { + col.keep(form, COST_SCAN + dist * 3); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn has(word: &str, want: &str) { + let s = suggest(word, 8); + assert!(s.iter().any(|x| x == want), "{word} → {s:?}, want {want}"); + } + + #[test] + fn portuguese_misspellings_get_the_right_suggestion() { + crate::pt::test_dict::install(); + has("coraçao", "coração"); + has("saude", "saúde"); + has("seje", "seja"); + has("excessão", "exceção"); + has("previlégio", "privilégio"); + has("prsidente", "presidente"); + has("provavel", "provável"); + has("trbalho", "trabalho"); + has("nãoé", "não é"); + has("paraela", "para ela"); + has("brasília", "Brasília"); + has("Coraçao", "Coração"); + } + + #[test] + fn the_likeliest_fix_comes_first() { + crate::pt::test_dict::install(); + assert_eq!(suggest("coraçao", 3).first().map(String::as_str), Some("coração")); + assert_eq!( + suggest("saude", 3).first().map(String::as_str), + Some("saúde"), + "an accent at the first difference beats one further right (saudê is a word too)" + ); + assert_eq!(suggest("seje", 3).first().map(String::as_str), Some("seja")); + } + + #[test] + fn edge_cases_stay_empty_or_bounded() { + crate::pt::test_dict::install(); + assert!(suggest("", 5).is_empty()); + assert!(suggest("bem", 0).is_empty()); + assert!(suggest(&"a".repeat(41), 5).is_empty()); + assert!(suggest("duas palavras", 5).is_empty()); + assert!(suggest_in(&crate::affix::AffixDict::default(), "coraçao", 5).is_empty()); + assert!(suggest("coração", 5).len() <= 5); + assert!(!suggest("coração", 5).contains(&"coração".to_string())); + } + + #[test] + fn distance_weighs_accents_half() { + let map = vec![vec!['a', 'á', 'à', 'â', 'ã'], vec!['c', 'ç']]; + let related = |a: char, b: char| fold(a, &map) == fold(b, &map); + let c = |s: &str| s.chars().collect::>(); + assert_eq!(weighted_distance(&c("coraçao"), &c("coração"), &related, 9), 1); + assert_eq!(weighted_distance(&c("coracao"), &c("coração"), &related, 9), 2, "ç and ã are two accent slips"); + assert_eq!(weighted_distance(&c("coro"), &c("coro"), &related, 9), 0); + assert_eq!(weighted_distance(&c("bem"), &c("bom"), &related, 9), 2); + assert_eq!(weighted_distance(&c("bem"), &c("bemte"), &related, 9), 4); + assert_eq!(weighted_distance(&c("bem"), &c("ver"), &related, 2), 3); + assert_eq!(weighted_distance(&c(""), &c("ab"), &related, 9), 4); + } + + #[test] + fn suggestions_are_fast_enough() { + crate::pt::test_dict::install(); + // Warm the dictionary, then time a worst case (a long word nothing near): the scan round. + let _ = suggest("bem", 1); + let t = std::time::Instant::now(); + for w in ["xqzvweportuguespalavra", "prsidentes", "paralelepípedo"] { + let _ = suggest(w, 6); + } + let ms = t.elapsed().as_secs_f64() * 1000.0; + eprintln!("3 suggestion calls: {ms:.0} ms"); + assert!(ms < 20_000.0, "{ms} ms"); + } +} diff --git a/crates/proof/src/pt/test_dict.rs b/crates/proof/src/pt/test_dict.rs new file mode 100644 index 000000000..e02be3c16 --- /dev/null +++ b/crates/proof/src/pt/test_dict.rs @@ -0,0 +1,121 @@ +//! A tiny synthetic Portuguese Hunspell dictionary for tests (written for these tests; not taken +//! from any real dictionary). Real dictionaries are installed on the system, never bundled. +//! +//! It exercises what VERO-like dictionaries use: `FLAG UTF-8` (multi-byte flag characters — +//! `F` marks a forbidden spelling), continuation classes (`provável` → `prováveis`), the +//! suggestion tables (`TRY`, `MAP` for accents, `REP` for common confusions) and one prefix. + +pub const AFF: &str = "SET UTF-8 +FLAG UTF-8 +FORBIDDENWORD F +TRY aeiousrntmdlcpvgqbzfhjxçãõáéíóúâêôkw +MAP 6 +MAP aáàâã +MAP eéê +MAP ií +MAP oóôõ +MAP uú +MAP cç +REP 4 +REP seje seja +REP excessão exceção +REP previlégio privilégio +REP analize análise +SFX A Y 2 +SFX A a ava a +SFX A a ando a +SFX B N 1 +SFX B 0 ável/C . +SFX C N 1 +SFX C vel veis vel +PFX X Y 1 +PFX X 0 des . +"; + +/// Stems (the count line first, as in Hunspell files). +pub const DIC: &str = "76 +coração +saudade +saudê +pássaro +saúde +árvore +escrever +computador +praia +conhecer +Brasília +São +Paulo +guarda +roupa +mail +marido +ex +vice +presidente +autoestima +frequência +frequencia/F +exceção +privilégio +análise +seja +trabalho +canta/A +prov/B +cobrir/X +avião +pássaros +amanhã +vocês +coragem +parabéns +está +ele +disse +existem +bem +não +sim +que +com +para +dos +das +pela +pelo +muito +mais +como +porque +aqui +também +já +ser +ter +ir +ver +dar +e +o +é +água +ela +da +do +de +em +no +na +um +uma +"; + +/// Make [`AFF`]/[`DIC`] the Portuguese dictionary (every test that needs one calls this; they all +/// install the same one, so tests running in parallel agree). +pub fn install() { + if let Err(e) = super::install_dictionary(AFF, DIC) { + panic!("test dictionary: {e}"); + } +} diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 986fc6ec9..35b585ade 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -85,7 +85,7 @@ pub static LANGUAGES: [LangInfo; 18] = [ name: "Português (Brasil)", source: include_str!("pt-br.tsv"), prefer_hans: false, - editing_lang: None, + editing_lang: Some("pt-BR"), catalog: OnceLock::new(), }, // Serbian, Cyrillic script (the default per BCP 47 when no script is given); `sr`, `sr-RS`, diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 0f11fa92a..db07c8604 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -997,9 +997,9 @@ fn a_polish_interface_writes_new_documents_in_polish() { } #[test] -fn only_polish_has_an_editing_language() { +fn the_editing_languages_are_the_ones_wordcraft_can_proof() { let with: Vec<(&str, &str)> = Lang::all().filter_map(|l| l.editing_lang().map(|e| (l.code(), e))).collect(); - assert_eq!(with, [("pl", "pl-PL")]); + assert_eq!(with, [("pt-br", "pt-BR"), ("pl", "pl-PL")]); } #[test] diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 967da0eef..56dbf32cb 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -409,8 +409,8 @@ fn mi_text(ui: &mut Ui, app: &mut WordApp, text: &str, id: &str, params: Value) } /// The proofing languages the Language menu offers: tag and English name (translated when drawn). -pub(crate) const PROOFING_LANGUAGES: [(&str, &str); 3] = - [("en-US", "English (United States)"), ("en-GB", "English (United Kingdom)"), ("pl-PL", "Polish")]; +pub(crate) const PROOFING_LANGUAGES: [(&str, &str); 4] = + [("en-US", "English (United States)"), ("en-GB", "English (United Kingdom)"), ("pl-PL", "Polish"), ("pt-BR", "Portuguese (Brazil)")]; /// The English name of a proofing language tag, as the status bar shows it (no tag: WordCraft's /// default, US English; a tag without a name here: the tag). @@ -421,6 +421,7 @@ pub(crate) fn language_name(tag: Option<&str>) -> String { } match wordcraft_engine::proof::ProofLang::from_tag(Some(tag)) { wordcraft_engine::proof::ProofLang::Pl => "Polish".into(), + wordcraft_engine::proof::ProofLang::Pt => "Portuguese (Brazil)".into(), wordcraft_engine::proof::ProofLang::En if tag.eq_ignore_ascii_case("en") => "English (United States)".into(), _ => tag.to_string(), } @@ -441,7 +442,9 @@ pub(crate) fn language_menu(ui: &mut Ui, app: &mut WordApp) { let current = st.get("lang").and_then(Value::as_str).unwrap_or("").to_string(); let no_proof = st.get("noProof").and_then(Value::as_bool).unwrap_or(false); for (tag, name) in PROOFING_LANGUAGES { - let on = current.eq_ignore_ascii_case(tag) || (tag == "pl-PL" && current.eq_ignore_ascii_case("pl")); + let on = current.eq_ignore_ascii_case(tag) + || (tag == "pl-PL" && current.eq_ignore_ascii_case("pl")) + || (tag == "pt-BR" && current.eq_ignore_ascii_case("pt")); mi_check(ui, app, name, on, "review.language", json!({"lang": tag})); } ui.separator(); @@ -2435,6 +2438,14 @@ mod tests { } } + #[test] + fn portuguese_has_a_name_in_the_language_menu() { + assert!(PROOFING_LANGUAGES.iter().any(|(t, _)| *t == "pt-BR")); + assert_eq!(language_name(Some("pt-BR")), "Portuguese (Brazil)"); + assert_eq!(language_name(Some("pt")), "Portuguese (Brazil)"); + assert_eq!(language_name(Some("PT-BR".to_string().as_str())), "Portuguese (Brazil)"); + } + #[test] fn picture_format_tab_appears_when_picture_selected() { let mut s = Session::new(wordcraft_doc::Document::new()); diff --git a/docs/gaps.md b/docs/gaps.md index ff66ff864..49d0f2740 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -20,7 +20,7 @@ Native save reliability: synthetic cases on macOS reproduced loss through the pr | 5 | **Content controls** landed (#381): `w:sdt` kept, shown and edited (Developer tab, Design Mode, Properties). 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. Opaque custom XML backing stores now survive saves; binding evaluation remains absent. | 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); File › Print has a preview, page range, odd/even, copies and collate (#641); printer choice, duplex and pages per sheet are still the system's; 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**: 54 modal dialogs vs Word's ~100 | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs` | Power users reach for dialogs the ribbon alone doesn't cover | 11–19 | [ui-parity.md](ui-parity.md) | -| 8 | **Proofing covers English and Polish only**; no dictionaries, grammar or hyphenation for the other ~48 languages Word proofs (text in them is left unmarked) | | `crates/proof`: English and Polish, a language per run (2026-10-10); Word ships 120 proofing tools; issues #25, #40, #100 | Every writer outside English and Polish | 20–40 | [localization-parity.md](localization-parity.md) | +| 8 | **Proofing covers English, Polish and Portuguese only**; no dictionaries, grammar or hyphenation for the other ~47 languages Word proofs (text in them is left unmarked) | | `crates/proof`: English, Polish and Portuguese (pt-BR via the installed VERO dictionary, 2026-10-12), a language per run (2026-10-10); Word ships 120 proofing tools; issues #25, #40, #100 | Every writer outside English, Polish and Portuguese | 20–40 | [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 3-D effects and connector routing around shapes (WordArt effects and warps and glued connectors landed, #416), 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**: 7 of the 12 key languages missing (hi, ar, fr, id, ko, vi, pt-PT); no mirrored RTL interface; ~5% of strings still English in the translated catalogs | | [localization-parity.md](localization-parity.md) | Most of the world's writers | 50–80 | [localization-parity.md](localization-parity.md) | @@ -86,6 +86,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | 2026-10-11 | trivial | Insert › Chart landed (#409); gap #13 narrowed to editing Word's charts, more types and SmartArt, 35–55 h | | 2026-10-11 | trivial | Line Numbers, Hyphenation, Language, Envelopes and Labels dialogs landed (#407); gap #7 at 30 dialogs, 16–24 h; duplicated #7 rows merged | | 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-12 | minor | #8: Portuguese proofing (spelling, suggestions, grammar, hyphenation) landed; the gap is now the other languages (20–40 h) | | 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/localization-parity.md b/docs/localization-parity.md index 46603f75e..ab4d9f405 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,11 +1,11 @@ # Localization parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Turkish interface catalog completed, 2,325 entries, #774; previously minor: font list shows fonts by their names in the interface language, #505; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Portuguese proofing: pt-BR spelling, suggestions, grammar and hyphenation from the installed VERO dictionary and TeX patterns; a pt-br interface now writes pt-BR documents; previously minor: Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Interface languages, script support and proofing per language. How translation works (catalog format, adding a language, clean-room rule): `crates/ui-egui/src/i18n/mod.rs`. -**Dimension: ~27% (estimated; 25% before Polish and per-run proofing languages), 75–120 h to full +**Dimension: ~28% (estimated; 27% before Portuguese proofing, 25% before Polish and per-run proofing languages), 70–115 h to full for the twelve key languages** plus ~45 h for the other ~17 languages Word ships, plus native-speaker review (human). @@ -29,7 +29,7 @@ native-speaker review (human). Read Aloud, Backstage greetings) aren't wrapped in `tl!` yet, so they show in English in every language: the true share of visible strings is **~93–95%** (estimated). - Pending PRs: Norwegian Bokmål (#208) and Nynorsk (#210), Hebrew with RTL (#20). -- **Proofing: English and Polish**, chosen per run from its language (`w:lang`, resolved through +- **Proofing: English, Polish and Portuguese**, chosen per run from its language (`w:lang`, resolved through the styles; Review › Language and the status bar set it). Languages without proofing tools get no spelling or grammar marks (as in Word without the language's tools) instead of being flagged as English. Detail in [Proofing per language](#proofing-per-language). @@ -79,6 +79,7 @@ documents in Polish (`pl-PL`). |---|---|---|---|---| | English (`en`, any region; text with no language) | Moby word list (public domain, ~160k words) with inflection rules | edit distance | repeated word, `a`/`an`, sentence capital, space before punctuation, double space | Moby dictionary, our Liang patterns, heuristic | | Polish (`pl`) | The Hunspell `pl_PL` dictionary installed on the system (OS package, Homebrew, LibreOffice, or the `WORDCRAFT_DICTIONARIES` folder; not bundled, so no Polish spelling marks without it), read at runtime by a Hunspell-style affix engine (`crates/proof/src/affix.rs`); names capitalized, compounds part by part, `Kennedy'ego` | missing diacritics first (`zolw` → `żółw`), the dictionary's confusion table (`żeka` → `rzeka`, `chuśtawka` → `huśtawka`), missing space (`napewno` → `na pewno`), one typo, then a stem scan within two typos | Polish messages: repeated word, space before punctuation, double space, sentence capital (knows `np.`, `r.`, initials, ordinals, dialogue dashes), missing comma before `że`/`który`/`ponieważ`/`gdyż`/`aby`/`żeby`/`jeśli`/`jeżeli`/`gdyby`/`lecz` (not after `mimo`, `chyba`, `tak`, prepositions…), `we`/`ze` (`we wtorek`, `ze szkoły`), doubled punctuation | TeX Polish patterns (Kołodziejska, Jackowski, Ryćko; MIT option) with their 20 exception words; a Polish syllable heuristic for letters outside the alphabet | +| Portuguese (`pt`, any region) | The Hunspell `pt_BR` dictionary installed on the system — VERO and its derivatives (same places as Polish; not bundled, LGPL/MPL), read at runtime by the same affix engine, which reads its `FLAG UTF-8`, 106 flags, continuation classes and `FORBIDDENWORD` spellings; names capitalized, compounds part by part (`guarda-roupa`, `ex-marido`) | missing accents first (`coraçao` → `coração`, `saude` → `saúde`), the dictionary's confusion table (`seje` → `seja`, `excessão` → `exceção`), missing space (`nãoé` → `não é`), one typo, then an accent-folding stem scan | Portuguese messages: repeated word (the emphatic doubles are allowed), space before punctuation, double space, sentence capital (knows `etc.`, `pág.`, `Dr.`, initials, ordinals, dialogue dashes), doubled punctuation; no comma rule — `que` opens a clause only sometimes | TeX Portuguese patterns (Rezende, Almeida, Araujo, Benevides; BSD 3-clause) with their 2 exception words | | Any other | — (no marks) | — | — | the English patterns, as before | Measured with the SJP.PL dictionary: every one of the ~4.5 million forms an independent expansion of its affix file @@ -107,6 +108,7 @@ allows); all other text is written from the meaning of the English. No Italian p |---|---|---| | 2026-10-11 | minor | Turkish interface catalog completed (2,325 entries, the same keys as Russian): equation gallery, built-in style names, watermarks, paper sizes and preview tiles; the interface audit checks Turkish too (#774) | | 2026-10-11 | minor | Czech (`cs`) interface catalog added | +| 2026-10-12 | minor | Portuguese proofing: pt-BR spelling from the installed VERO dictionary (the affix engine grew `FLAG UTF-8`, 128 flag bits, continuation classes and `FORBIDDENWORD`), accent-first suggestions, conservative grammar with Portuguese messages, TeX Portuguese hyphenation patterns (BSD 3-clause); Review › Language offers Português (Brasil); a pt-br interface writes pt-BR documents; dimension ~27% → ~28% | | 2026-10-11 | minor | Polish interface (13 languages) and Polish proofing: SJP.PL spelling with suggestions, Polish grammar rules, TeX Polish hyphenation; proofing language per run; dimension ~25% → ~27% | | 2026-10-11 | minor | German interface catalog (#574): 2,021 entries covering the full zh-hans key set; locale, saved-preference and bundled-font tests; no German proofing | | 2026-10-11 | minor | Italian interface catalog (#594), locale selection, saved preference and font coverage checks; count labels put the noun first so they read right at 1 | diff --git a/docs/roadmap.md b/docs/roadmap.md index ef1a7a71b..eb3810e70 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:** minor (Polish interface and Polish proofing; 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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Portuguese proofing; previously minor: Polish interface and Polish proofing; 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 @@ -61,7 +61,7 @@ partial rows are fidelity gaps (beta work), not broken workflows. WordCraft stay | 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) | — | -| M15 | Localization (12 key languages, RTL interface, proofing languages) | 15 UI languages; English and Polish proofing, a proofing language per run | 75–120 h | +| M15 | Localization (12 key languages, RTL interface, proofing languages) | 15 UI languages; English, Polish and Portuguese proofing, a proofing language per run | 70–115 h | | M16 | Right-to-left and East Asian typography | RTL paragraphs done (#207) | 30–45 h | ## Next after beta @@ -73,6 +73,7 @@ languages, screen-reader access, ecosystem decisions (VBA, add-ins, cloud), an i | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | M15: Portuguese proofing (pt-BR spelling from the installed VERO dictionary, suggestions, grammar, hyphenation; Review › Language gains Português (Brasil)) | | 2026-10-11 | trivial | M15: Polish interface and Polish proofing landed | | 2026-10-11 | trivial | M3: icons landed (#309) | | 2026-10-11 | trivial | M11: contour (Tight/Through) wrap done (#354) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a82ba7f33..a4d8afc80 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # WordCraft parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Polish interface and Polish proofing; previously trivial: English spelling knows common modern words, #520; previously trivial: all 12 bibliography styles, #383; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; 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; replaces the parity tables that lived in ROADMAP.md) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Portuguese proofing; previously trivial: English spelling knows common modern words, #520; previously trivial: all 12 bibliography styles, #383; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; 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; replaces the parity tables that lived in ROADMAP.md) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 The authoritative assessment of how close WordCraft is to Microsoft Word. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) itemizes every shortfall; the deep checklists are @@ -66,7 +66,7 @@ full parity for the area. | 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. 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 / Combine with comparison settings (case, white space, formatting, tables, word or character level, #412), Restrict Editing pane with formatting and editing restrictions, exceptions for everyone and a password (#412). 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. Show revisions in balloons takes tracked deletions in the body out of the text into "Deleted: …" balloons (insertions stay inline, as in Word; deletions in headers, footers, notes and text boxes, and in print and PDF, stay inline). 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 | 40% | (in Review) | 20–40 | English and Polish, chosen per run from its language (`w:lang`): English word list (Moby plus our supplement of modern words such as "desktop", #520) and rule-based grammar; Polish against the installed Hunspell dictionary through a Hunspell-style affix engine, Polish grammar rules and TeX hyphenation patterns (2026-10-10). Other languages are left unmarked. The custom dictionary persists across sessions (#40). Word ships 120 proofing tools for ~50 languages plus Editor (style refinements, similarity). Issues #25, #40, #100. | +| Proofing (spelling, grammar, thesaurus, languages) | 5 | 42% | (in Review) | 20–40 | English, Polish and Portuguese, chosen per run from its language (`w:lang`): English word list (Moby plus our supplement of modern words such as "desktop", #520) and rule-based grammar; Polish against the installed Hunspell dictionary through a Hunspell-style affix engine, Polish grammar rules and TeX hyphenation patterns (2026-10-10); Portuguese (pt-BR) against the installed VERO Hunspell dictionary — the affix engine reads its `FLAG UTF-8` flags, continuation classes and forbidden spellings — with accent-first suggestions, conservative grammar rules and TeX patterns (2026-10-12). Other languages are left unmarked. The custom dictionary persists across sessions (#40). 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 or CSV (#247); no Excel/Outlook/Contacts 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), WordArt: text fill (solid, gradient, none), outline, shadow, glow and reflection with our own styles, and Transform warps along arch, circle, wave, slant, inflate/deflate, chevron and triangle (other presets kept and drawn flat), connectors (straight, elbow, curved, arrowheads) glued to shapes' connection sites and re-routed when those move (#416). Missing: tight/through contour wrap, rotated text-box text, group (#267 open), shape effects, WordArt 3-D/bevel and editing text in its bent form, connector routing around shapes, Drawing Canvas, Icons, online pictures, screenshot. | | 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. | @@ -251,6 +251,7 @@ can be used without committing Word output. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Portuguese proofing (pt-BR spelling from the installed VERO dictionary, accent-first suggestions, grammar, hyphenation; Review › Language gains Português (Brasil)): Proofing 40% → 42%; other dimensions unchanged at this precision | | 2026-10-12 | trivial | Proofing: custom dictionary persists across sessions (#40); no percentage change | | 2026-10-11 | trivial | Proofing: runs in a language without a dictionary are skipped instead of flagged against the English dictionary (#100); no percentage change | | 2026-10-11 | minor | Polish interface (13 UI languages) and Polish proofing with per-run languages: Proofing 35% → 40% (25–45 h → 20–40 h), Localization 25% → 27%; feature depth and readiness unchanged at this precision | From f7ce977e4a31d9bc47f3a28e2332dc37c2557c96 Mon Sep 17 00:00:00 2001 From: Dimitar Date: Mon, 12 Oct 2026 14:51:05 +0300 Subject: [PATCH 44/88] Hyphenation: Bulgarian text follows the Bulgarian syllable rules (#826) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runs in Bulgarian (`w:lang` bg, bg-BG) are hyphenated by the Bulgarian syllable rules (crates/proof/src/bg.rs, clean-room from the spelling rules; no pattern file or word list): vowel-vowel splits, one consonant goes with the next syllable, a consonant cluster splits after its first consonant except that an obstruent before р/л starts the next syllable (до-бре, та-бли-ца), й stays with its vowel, дж/дз and ьо are never split. - ProofLang::Bg (bg, bul): hyphenation only. It isn't "proofed", so like Other there are no spelling or grammar marks until there is a Bulgarian dictionary. - Latin-script words in a Bulgarian run are hyphenated as English, as in any other language. - The layout already takes each word's language from its run (ProofRun), so nothing changes there. Tests: the rules (срич-ко-пре-на-ся-не, та-бли-ца, шо-фьор, хо-джа, строй-ка), tag resolution, hyphenate_word_in with the paragraph limits (Бъл-га-рия), and a laid-out bg-BG paragraph whose hyphenated lines all break at Bulgarian points. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/layout/src/tests.rs | 35 ++++++++++++ crates/proof/src/bg.rs | 112 +++++++++++++++++++++++++++++++++++++ crates/proof/src/hyphen.rs | 24 ++++++++ crates/proof/src/lang.rs | 19 ++++++- crates/proof/src/lib.rs | 14 +++-- 5 files changed, 196 insertions(+), 8 deletions(-) create mode 100644 crates/proof/src/bg.rs diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 437758efc..cd10dd97f 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -4108,3 +4108,38 @@ fn footer_page_total_needs_one_body_pass() { p.insert_object(n, field(), &Default::default()).unwrap(); assert!(matches!(first(&d), Pass::Again(t) if t.pages == 3)); } + +/// Bulgarian text (`w:lang` `bg-BG`) is hyphenated by the Bulgarian syllable rules; the same +/// Cyrillic text in an English run is not hyphenated (the English data has nothing to say about it). +#[test] +fn bulgarian_text_is_hyphenated_by_its_own_rules() { + let text = "Сричкопренасянето в професионалните документи задължително следва правописните правила, разработени от специалистите. ".repeat(6); + let hyphenated = |d: &Document| -> Vec<(String, String)> { + let mut out = Vec::new(); + for it in &lay(d).pages[0].items { + if let Placed::Lines { para, .. } = it { + for l in para.lines.iter().filter(|x| x.hyphen.is_some()) { + let end = l.stop; + let before = text.get(..end).unwrap_or_default(); + let word_start = before.rfind(' ').map_or(0, |i| i + 1); + let after = text.get(end..).unwrap_or_default(); + let word_end = after.find(|c: char| !c.is_alphabetic()).unwrap_or(after.len()); + out.push((text[word_start..end].to_string(), after[..word_end].to_string())); + } + } + } + out + }; + let mut d = Document::from_text(&text); + d.settings.auto_hyphenation = true; + assert!(hyphenated(&d).is_empty(), "Cyrillic in an English run: {:?}", hyphenated(&d)); + d.format_range(&Pos::body(0, 0), &Pos::body(0, text.len()), &|c| c.lang = Some("bg-BG".into())).unwrap(); + let splits = hyphenated(&d); + assert!(!splits.is_empty(), "no hyphenated lines"); + let lim = wordcraft_proof::hyphen::Limits::default(); + for (head, tail) in &splits { + let word = format!("{head}{tail}"); + let at = head.chars().count(); + assert!(wordcraft_proof::hyphen::hyphen_points_in(&word, &lim, wordcraft_proof::ProofLang::Bg).contains(&at), "{head}-{tail}"); + } +} diff --git a/crates/proof/src/bg.rs b/crates/proof/src/bg.rs new file mode 100644 index 000000000..6b456d8c9 --- /dev/null +++ b/crates/proof/src/bg.rs @@ -0,0 +1,112 @@ +//! Bulgarian hyphenation by rule ([`ProofLang::Bg`](crate::ProofLang::Bg)). +//! +//! Bulgarian spelling splits words at syllables, and its rules are regular enough to apply +//! directly, so no pattern file or word list is needed (and none is borrowed): +//! - two vowels next to each other may be split (`на-у-ка`); +//! - one consonant between vowels goes with the next syllable (`ма-ма`, `ро-за`); +//! - of two or more consonants between vowels, the first stays with the syllable before and the +//! rest go with the next (`сес-тра`, `въз-дух`, `ком-пю-тър`) — the spelling rules allow any +//! split of a consonant group, and this is the usual one — except that a consonant followed by +//! `р` or `л` starts the next syllable together with it (`до-бре`, `ко-пре`, `та-бли-ца`); +//! - `й` before a consonant stays with the vowel before it (`строй-ка`, `май-ка`); +//! - `ьо` is never split, and the consonant before `ь` goes with it (`шо-фьор`); +//! - `дж` and `дз`, one sound each, are never split (`хо-джа`). +//! +//! A single letter left at the end or start of a line is ruled out by the paragraph's +//! [`Limits`](crate::hyphen::Limits) (`Бъл-га-рия`, not `Бъл-га-ри-я`). + +fn is_vowel(c: char) -> bool { + matches!(c, 'а' | 'е' | 'и' | 'о' | 'у' | 'ъ' | 'ю' | 'я' | 'ѝ') +} + +/// Consonants that may start a syllable before `р` or `л` (`бр`, `пл`, `тр`, `гл` …). +fn is_obstruent(c: char) -> bool { + matches!(c, 'б' | 'в' | 'г' | 'д' | 'з' | 'к' | 'п' | 'с' | 'т' | 'ф' | 'х') +} + +/// A lowercase Bulgarian letter (the words [`points`] splits). +pub(crate) fn is_letter(c: char) -> bool { + matches!(c, 'а'..='я' | 'ѝ') +} + +/// Break points of one lowercase Bulgarian word (char indices; a hyphen goes before that char). +/// Empty for anything that is not entirely Bulgarian letters. +pub fn points(word: &[char]) -> Vec { + if word.len() < 2 || !word.iter().all(|&c| is_letter(c)) { + return Vec::new(); + } + let vowels: Vec = (0..word.len()).filter(|&i| is_vowel(word[i])).collect(); + let mut out = Vec::new(); + for pair in vowels.windows(2) { + let (v1, v2) = (pair[0], pair[1]); + let mut pt = match v2 - v1 - 1 { + 0 | 1 => v1 + 1, + _ => v1 + 2, + }; + // A consonant with `р` or `л` after it starts a syllable together with it. + if v2 - v1 > 2 && matches!(word[v2 - 1], 'р' | 'л') && is_obstruent(word[v2 - 2]) { + pt = v2 - 2; + } + // `дж`, `дз`: one sound, so both go to the next syllable. + if pt > v1 + 1 && word[pt - 1] == 'д' && matches!(word[pt], 'ж' | 'з') { + pt -= 1; + } + // `ь` keeps the consonant before it (`фьо`). + if pt < v2 && word[pt] == 'ь' && pt > v1 + 1 { + pt -= 1; + } + if pt > v1 && pt <= v2 && word[pt - 1] != 'ь' { + out.push(pt); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn split(w: &str) -> String { + let chars: Vec = w.chars().collect(); + let pts = points(&chars); + let mut s = String::new(); + for (i, c) in chars.iter().enumerate() { + if pts.contains(&i) { + s.push('-'); + } + s.push(*c); + } + s + } + + #[test] + fn syllables_follow_the_bulgarian_rules() { + for (word, expected) in [ + ("мама", "ма-ма"), + ("наука", "на-у-ка"), + ("сестра", "сес-тра"), + ("въздух", "въз-дух"), + ("компютър", "ком-пю-тър"), + ("стройка", "строй-ка"), + ("майка", "май-ка"), + ("шофьор", "шо-фьор"), + ("ходжа", "хо-джа"), + ("здравей", "здра-вей"), + ("плевен", "пле-вен"), + ("българия", "бъл-га-ри-я"), + ("сричкопренасяне", "срич-ко-пре-на-ся-не"), + ("добре", "до-бре"), + ("таблица", "та-бли-ца"), + ("отговор", "от-го-вор"), + ] { + assert_eq!(split(word), expected, "{word}"); + } + } + + #[test] + fn non_bulgarian_letters_are_left_alone() { + assert!(points(&"word".chars().collect::>()).is_empty()); + assert!(points(&"съобщение1".chars().collect::>()).is_empty()); + assert!(points(&"объём".chars().collect::>()).is_empty(), "Russian ё is not a Bulgarian letter"); + } +} diff --git a/crates/proof/src/hyphen.rs b/crates/proof/src/hyphen.rs index 9919aa072..89ab24004 100644 --- a/crates/proof/src/hyphen.rs +++ b/crates/proof/src/hyphen.rs @@ -13,6 +13,8 @@ //! Kołodziejska, Bogusław Jackowski and Marek Ryćko, MIT licence option), then a Polish syllable //! heuristic for words with letters outside the Polish alphabet (`é`, `ü`…). //! +//! **Bulgarian**: the Bulgarian syllable rules ([`crate::bg`]); no pattern file or word list. +//! //! Then the paragraph's limits ([`Limits`]) are applied. pub use crate::dict; @@ -89,6 +91,8 @@ pub enum Source { Dictionary, Patterns, Heuristic, + /// A language's syllable rules (Bulgarian, [`crate::bg`]). + Rules, None, } @@ -109,6 +113,11 @@ fn core_breaks(core: &[char], lang: ProofLang) -> (Vec, Source) { if lang == ProofLang::Pl { return core_breaks_pl(core); } + // Bulgarian words follow the Bulgarian syllable rules; Latin-script words in a Bulgarian run + // (`WordCraft`, `email`) are hyphenated as English, as in any other language. + if lang == ProofLang::Bg && core.iter().all(|&c| crate::bg::is_letter(c)) { + return (crate::bg::points(core), Source::Rules); + } if lang == ProofLang::Pt { return core_breaks_pt(core); } @@ -778,4 +787,19 @@ mod tests { assert_eq!(hyphen_points_in(w, &Limits::default(), ProofLang::Other), hyphen_points(w, &Limits::default()), "{w}"); } } + + #[test] + fn bulgarian_words_follow_the_bulgarian_rules() { + let bg = |w: &str| hyphenate_word_in(w, &Limits::default(), ProofLang::Bg); + assert_eq!(bg("сричкопренасяне"), "срич-ко-пре-на-ся-не"); + assert_eq!(bg("Таблица"), "Та-бли-ца"); + // A single letter is never left on a line (the paragraph limits): България, not Бъл-га-ри-я. + assert_eq!(bg("България"), "Бъл-га-рия"); + assert_eq!(bg("„шофьор“,"), "„шо-фьор“,"); + assert_eq!(word_breaks_in("таблица", ProofLang::Bg).1, Source::Rules); + // Latin words in a Bulgarian run are hyphenated as English. + for w in ["publication", "WordCraft"] { + assert_eq!(hyphen_points_in(w, &Limits::default(), ProofLang::Bg), hyphen_points(w, &Limits::default()), "{w}"); + } + } } diff --git a/crates/proof/src/lang.rs b/crates/proof/src/lang.rs index 344fc52e4..21d99b9aa 100644 --- a/crates/proof/src/lang.rs +++ b/crates/proof/src/lang.rs @@ -13,6 +13,10 @@ pub enum ProofLang { /// Polish (`pl`, `pl-PL`): the installed Hunspell dictionary (when there is one), the Polish grammar rules and the Polish /// hyphenation patterns. Pl, + /// Bulgarian (`bg`, `bg-BG`): hyphenation by the Bulgarian syllable rules ([`crate::bg`]). + /// There is no Bulgarian dictionary or grammar yet, so, as for [`ProofLang::Other`], no + /// spelling or grammar marks. + Bg, /// Portuguese (`pt`, `pt-BR`, `pt-PT`): the installed Hunspell dictionary (VERO and /// friends, when there is one), the Portuguese grammar rules and the Portuguese hyphenation /// patterns. @@ -37,6 +41,8 @@ impl ProofLang { ProofLang::En } else if primary.eq_ignore_ascii_case("pl") || primary.eq_ignore_ascii_case("pol") { ProofLang::Pl + } else if primary.eq_ignore_ascii_case("bg") || primary.eq_ignore_ascii_case("bul") { + ProofLang::Bg } else if primary.eq_ignore_ascii_case("pt") || primary.eq_ignore_ascii_case("por") { ProofLang::Pt } else { @@ -44,11 +50,12 @@ impl ProofLang { } } - /// The primary language subtag (`en`, `pl`); empty for [`ProofLang::Other`]. + /// The primary language subtag (`en`, `pl`, `bg`); empty for [`ProofLang::Other`]. pub fn code(self) -> &'static str { match self { ProofLang::En => "en", ProofLang::Pl => "pl", + ProofLang::Bg => "bg", ProofLang::Pt => "pt", ProofLang::Other => "", } @@ -56,7 +63,7 @@ impl ProofLang { /// Does WordCraft check spelling and grammar in this language? pub fn is_proofed(self) -> bool { - self != ProofLang::Other + Self::PROOFED.contains(&self) } } @@ -129,13 +136,19 @@ mod tests { for t in ["en", "en-US", "en-GB", "EN_au", "en-Latn-US"] { assert_eq!(ProofLang::from_tag(Some(t)), ProofLang::En, "{t}"); } - for t in ["fr-FR", "de", "x-none", "ja-JP", "plx", "e", "-"] { + for t in ["bg", "bg-BG", "BG_bg", "bg_BG.UTF-8", "bul"] { + assert_eq!(ProofLang::from_tag(Some(t)), ProofLang::Bg, "{t}"); + } + for t in ["fr-FR", "de", "x-none", "ja-JP", "plx", "bgx", "e", "-"] { assert_eq!(ProofLang::from_tag(Some(t)), ProofLang::Other, "{t}"); } assert_eq!(ProofLang::from_tag(None), ProofLang::En); assert_eq!(ProofLang::from_tag(Some("")), ProofLang::En); assert_eq!(ProofLang::from_tag(Some(" ")), ProofLang::En); assert!(ProofLang::Pl.is_proofed() && !ProofLang::Other.is_proofed()); + // Bulgarian has hyphenation only: no spelling or grammar marks. + assert!(!ProofLang::Bg.is_proofed()); + assert_eq!(ProofLang::Bg.code(), "bg"); assert!(ProofLang::Pt.is_proofed() && !ProofLang::Other.is_proofed()); assert_eq!(ProofLang::Pl.code(), "pl"); assert_eq!(ProofLang::Pt.code(), "pt"); diff --git a/crates/proof/src/lib.rs b/crates/proof/src/lib.rs index 736d2164f..075f3b43c 100644 --- a/crates/proof/src/lib.rs +++ b/crates/proof/src/lib.rs @@ -12,18 +12,22 @@ //! spelling marks), read by the affix engine ([`affix`]), suggestions tuned for Polish //! (diacritics, `ż`/`rz`, `u`/`ó`, missing spaces), and Polish grammar rules with Polish //! messages. +//! - **Bulgarian** ([`ProofLang::Bg`], module [`bg`]): hyphenation by the Bulgarian syllable +//! rules; no spelling or grammar marks yet. //! - **Portuguese** ([`ProofLang::Pt`], module [`pt`]): spelling against the Hunspell `pt_BR` //! dictionary installed on the system (VERO and friends; not bundled), suggestions tuned for //! Portuguese (missing accents, `seje`/`seja` confusions, missing spaces), conservative //! grammar rules with Portuguese messages, and `hyph-utf8`'s Portuguese patterns. //! - **Other languages** ([`ProofLang::Other`]): no spelling or grammar marks, like Word without //! the language's proofing tools. -//! - **Hyphenation** ([`hyphen`]): dictionary, Liang patterns (English, Polish, Portuguese), heuristic. +//! - **Hyphenation** ([`hyphen`]): dictionary, Liang patterns (English, Polish, Portuguese), +//! Bulgarian syllable rules, heuristic. //! //! [`check_text`] checks a paragraph whose runs are in different languages. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod affix; +pub mod bg; pub mod dict; pub mod hunspell; pub mod hyphen; @@ -311,7 +315,7 @@ fn known_core(w: &str) -> bool { /// document default, which is en-US. Runs in a language without one are skipped (#100) instead /// of flagging every word against another language's dictionary; see [`ProofLang::from_tag`]. pub fn has_dictionary(lang: Option<&str>) -> bool { - ProofLang::from_tag(lang) != ProofLang::Other + ProofLang::from_tag(lang).is_proofed() } /// Is `word` spelled correctly in the run's proofing language? A language without a dictionary @@ -511,7 +515,7 @@ pub fn is_correct_in_with(word: &str, lang: ProofLang, opts: &Options) -> bool { ProofLang::En => is_correct_with(word, opts), ProofLang::Pl => accepted_by_options(word, opts) || pl::is_correct(word), ProofLang::Pt => accepted_by_options(word, opts) || pt::is_correct(word), - ProofLang::Other => true, + ProofLang::Bg | ProofLang::Other => true, } } @@ -521,7 +525,7 @@ pub fn suggest_in(word: &str, max: usize, lang: ProofLang) -> Vec { ProofLang::En => suggest(word, max), ProofLang::Pl => pl::suggest(word, max), ProofLang::Pt => pt::suggest(word, max), - ProofLang::Other => Vec::new(), + ProofLang::Bg | ProofLang::Other => Vec::new(), } } @@ -533,7 +537,7 @@ pub fn check_grammar_in(text: &str, lang: ProofLang) -> Vec { ProofLang::En => check_grammar(text), ProofLang::Pl => pl::grammar::check(text), ProofLang::Pt => pt::grammar::check(text), - ProofLang::Other => Vec::new(), + ProofLang::Bg | ProofLang::Other => Vec::new(), } } From e619b12cebb0d5025922a7b8c238db0672be6e8c Mon Sep 17 00:00:00 2001 From: Dimitar Date: Mon, 12 Oct 2026 14:56:30 +0300 Subject: [PATCH 45/88] i18n: add Bulgarian (bg) interface translation (#825) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * i18n: add Bulgarian (bg) interface translation A 2,386-entry Bulgarian catalog: every key of the reference catalog, the equation gallery's labels and everything the interface audit finds. Written from the meaning of the English labels in ordinary Bulgarian computing vocabulary; no other product's localisation and no other catalog in this repository was used. Counts use count-neutral wording ("Думи: 21"). - bg, bg-BG, bg_BG.UTF-8 select it automatically; "Български" in Options and ui.language with {"value":"bg"}. - The interface audit checks Bulgarian coverage the way it checks Russian. - Tests: locales and saved preference, catalog coverage and key translations, count wording, and the bundled Inter/JetBrains Mono faces covering the Bulgarian letters. Co-Authored-By: Claude Opus 5.5 * Review fixes for #825: Bulgarian built-in style names Adds the built-in style names Russian translates (Normal Table, Default Paragraph Font, Annotation Text/Reference/Subject, Envelope Address/Return, Index Heading, Index {n}, Line Number, Normal Indent, Table of Figures, TOA Heading, TOC {n}) and Good afternoon; updates the entry count in the docs. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ATTRIBUTION.md | 1 + README.md | 2 +- ROADMAP.md | 4 +- crates/ui-egui/src/i18n/audit.rs | 4 +- crates/ui-egui/src/i18n/bg.tsv | 2439 ++++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/mod.rs | 6 +- crates/ui-egui/src/i18n/tests.rs | 83 +- docs/localization-parity.md | 15 +- 8 files changed, 2541 insertions(+), 13 deletions(-) create mode 100644 crates/ui-egui/src/i18n/bg.tsv diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index e8a20adc2..1bf3fa163 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -71,5 +71,6 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra | `crates/ui-egui/src/i18n/de.tsv` | @arekown with Kimi | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | German interface catalog | | `crates/ui-egui/src/i18n/ru.tsv` | Dmitrii Khlapov with Claude | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Russian interface catalog; reviewed by a native speaker | | `crates/ui-egui/src/i18n/tr.tsv` | @PbyLoyth with Codex and Claude Code | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Turkish interface catalog | +| `crates/ui-egui/src/i18n/bg.tsv` | Dimitar Klaturov (@bulgariamitko) with Claude Code | Original translation from the English interface labels in this repository; no other product localisation or other catalog used | MIT OR Apache-2.0 | Bulgarian interface catalog | | `crates/ui-egui/src/i18n/hu.tsv` | @Skaywalker | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Hungarian interface catalog | | `crates/ui-egui/src/i18n/fr.tsv` | @pechnikov2026-star | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | French interface catalog | diff --git a/README.md b/README.md index 77d25a38c..81180c12f 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ Every screenshot below is WordCraft itself, rendered offscreen by its own UI tes | **References** | Table of contents, footnotes and endnotes, citations and bibliography (APA, MLA, Chicago, IEEE), captions, table of figures, cross-references, index, table of authorities | | **Review** | Spelling and grammar with suggestions (English and Polish, by each run's language), thesaurus, word count, comments in margin balloons or a pane, track changes, accept/reject, compare documents, restrict editing, accessibility checker, document inspector | | **Mailings** | Mail merge from CSV, merge fields, address block, greeting line, rules, preview, finish to a document; envelopes and labels | -| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский, Italiano, Deutsch, Polski, Čeština, Magyar or Français (follows the system language by default) | +| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский, Italiano, Deutsch, Polski, Čeština, Magyar, Français or Български (follows the system language by default) | | **Files** | .docx read/write (opens in Word), Word 97-2003 .doc import, PDF export, .odt, .rtf, .html, .md, .tex, .txt import/export, page images | The honest picture, area by area, is in [ROADMAP.md](ROADMAP.md), the diff --git a/ROADMAP.md b/ROADMAP.md index 628265775..45ff902b8 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`), Serbian (`sr`, `sr-latn`, #250), Russian (`ru`) and Polish (`pl`), all partial (~95%). Estonian (`et`), Italian (`it`, #594) and Czech (`cs`) now have complete interface catalogs, and Turkish (`tr`) a complete 2,325-entry one (every key of the Russian catalog), all with +Also shipped: Traditional Chinese (`zh-hant`), Ukrainian (`uk`), Serbian (`sr`, `sr-latn`, #250), Russian (`ru`) and Polish (`pl`), all partial (~95%). Bulgarian (`bg`) covers everything the interface audit finds (2,419 entries). Estonian (`et`), Italian (`it`, #594) and Czech (`cs`) now have complete interface catalogs, and Turkish (`tr`) a complete 2,325-entry one (every key of the Russian catalog), all with system-locale selection and a saved language choice (Italian also has 51 strings none of the others had yet); Estonian, Turkish, Italian and Czech proofing are not included. Hungarian (`hu`) has a complete 2,419-entry catalog, also without proofing. Proofing: English, Polish and Portuguese (spelling, grammar, hyphenation), chosen per run from its language. Word ships @@ -198,6 +198,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-12 | Native saves use exclusively created sibling temporary files, synchronize before replacement and clean up on failure; existing temporary-name files/links and original files on failed saves are protected. Synthetic CLI and focused tests on macOS; no new Word parity or readiness claim. | | 2026-10-11 | Field codes like Word's field keys (#339): show codes (Alt+F9, Shift+F9), edit, unlink, lock and step through fields | | 2026-10-11 | Word's common keyboard shortcuts (#330): 101 → 125 of ~250 | +| 2026-10-12 | Bulgarian (`bg`) interface: 2,419 catalog entries (every key of the reference catalog, the equation gallery and everything the interface audit finds), `bg-BG` system locales and a saved language choice; locale, catalog and bundled-font tests; no Bulgarian proofing resources | | 2026-10-11 | Hungarian (`hu`) interface: all 2,419 catalog entries, built-in style names translated, `hu-HU` system locales and a saved language choice; locale, catalog and bundled-font tests; no Hungarian proofing resources | | 2026-10-11 | Turkish (`tr`) interface: all 1,307 catalog entries, `tr-TR`/`tr-CY` system locales and a saved language choice; command search keeps Turkish dotted and dotless I apart (`italik` finds İtalik); locale, catalog, search and bundled-font tests; no Turkish proofing resources | | 2026-10-11 | Italian (`it`) interface (#594): 2,399 entries, feature names as Italian Word users know them, locale and saved-preference tests, bundled-font checks; no Italian proofing resources | @@ -263,6 +264,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-12 | trivial | Native saves protect existing temporary-name entries and originals on pre-replacement failure; synthetic macOS evidence, explicit durability/platform limits; readiness unchanged | | 2026-10-11 | trivial | Word's common keyboard shortcuts (#330): 125 of ~250 | | 2026-10-11 | trivial | Collapsed duplicate rows left by parallel merges | +| 2026-10-12 | trivial | Bulgarian interface (`bg`): progress log and shipped languages | | 2026-10-11 | trivial | Hungarian interface (`hu`): progress log and shipped languages | | 2026-10-11 | trivial | Turkish interface (`tr`): progress log and shipped languages | | 2026-10-12 | minor | Native drawing-level ink and its picture fallback survive DOCX edits and repeated saves; adapted to the current graphic writer; rendering and editing InkML remain missing | diff --git a/crates/ui-egui/src/i18n/audit.rs b/crates/ui-egui/src/i18n/audit.rs index 872b18ce3..de25080e9 100644 --- a/crates/ui-egui/src/i18n/audit.rs +++ b/crates/ui-egui/src/i18n/audit.rs @@ -314,7 +314,7 @@ fn untranslated_interface_text() { complete_catalogs_have_every_string_the_interface_looked_up(pseudo::looked_up()); } -/// Russian, Hungarian and Turkish cover everything the audit saw translated, plus the equation +/// Russian, Hungarian, Turkish and Bulgarian cover everything the audit saw translated, plus the equation /// gallery's labels (shown in menus the audit doesn't open one by one). Numbered style names go /// through a template. fn complete_catalogs_have_every_string_the_interface_looked_up(mut keys: std::collections::BTreeSet) { @@ -353,7 +353,7 @@ fn complete_catalogs_have_every_string_the_interface_looked_up(mut keys: std::co ) }; let mut report = String::new(); - for code in ["ru", "hu", "tr"] { + for code in ["ru", "hu", "tr", "bg"] { let lang = super::Lang::from_code(code).unwrap_or(super::Lang::EN); let missing: Vec<&String> = keys.iter().filter(|s| english_words(s) && !super::has(lang, s) && !numbered(s) && !same(s)).collect(); if !missing.is_empty() { diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv new file mode 100644 index 000000000..e796f9e21 --- /dev/null +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -0,0 +1,2439 @@ +# Bulgarian (bg) interface catalog. Columns, separated by a TAB: +# context English source translation +# context usually empty. `preview` words sample text inside rendered previews (the Design +# tab's style-set tiles), which must fit a small tile; it falls back to the plain entry. +# source the English text exactly as in the code (tl!(...), ribbon labels, command labels) +# escapes \n \t \\ inside a column. `{name}` placeholders must appear in both columns, and a +# trailing "…" in one needs one in the other. +# Lines starting with # and blank lines are ignored. A missing entry shows in English. +# +# Clean room: every entry was written from the plain meaning of the English label in ordinary +# Bulgarian computing vocabulary. No Microsoft Word (or other product) localisation, string table +# or translated UI was consulted or copied, and no other catalog in this repository was used; +# add entries the same way. +# Product, format and technology names (WordCraft, PDF, Markdown, MCP …) stay in Latin letters. +# Commands and buttons use verbal nouns (Запазване, Вмъкване на таблица); instructions use the +# polite imperative (Въведете, Изберете). Counts use count-neutral wording (`Думи: 21`), so no +# plural forms are needed. + + (no style) (без стил) + (no suggestions) (няма предложения) + 1.5 Line Spacing Междуредие 1,5 + A free, open-source word processor written from scratch in Rust.\nPart of the Crafting Apps from the ArtCraft team. Безплатен текстообработващ редактор с отворен код, написан от нулата на Rust.\nЧаст от Crafting Apps на екипа на ArtCraft. + About Относно + About WordCraft За WordCraft + Accept Приемане + Accept All Changes Приемане на всички промени + Accept This Change Приемане на тази промяна + Accessibility Достъпност + Add Добавяне + Add Object to Selection Добавяне на обект към селекцията + Add Space Before Paragraph Добавяне на разстояние преди абзаца + Add space between Asian and Latin text Добавяне на интервал между азиатски и латински текст + Add space between Asian text and numbers Добавяне на интервал между азиатски текст и числа + Add tab stop at: Добавяне на позиция на табулатора при: + Add Text Добавяне на текст + Add a Table Добавяне на таблица + Add a comment Добавяне на коментар + Add to Dictionary Добавяне в речника + Add to Style Gallery Добавяне към галерията със стилове + Address Block Адресен блок + Address: Адрес: + Address\nBlock Адресен\nблок + Adjust Коригиране + Advanced Разширени + Advanced Find Разширено търсене + Advanced Find… Разширено търсене… + After: След: + Agents Агенти + Align Подравняване + Align Left Подравняване вляво + Align Right Подравняване вдясно + Aligned at: Подравнено на: + Alignment Подравняване + All Borders Всички граници + All Caps Всички главни + All Markup Всички маркировки + All caps Всички главни + Allow hanging punctuation Разрешаване на висяща пунктуация + Allow Latin text to wrap in the middle of a word Разрешаване на пренасяне на латински текст в средата на дума + Alt Text Алтернативен текст + Apply Asian line-breaking rules (kinsoku) Прилагане на азиатските правила за пренасяне на редове (kinsoku) + Apply Character Style Прилагане на знаков стил + Apply Style Прилагане на стил + Arrange Подреждане + Arrange\nAll Подреждане\nна всички + Arrow Стрелка + Artistic Effects Художествени ефекти + Asian Typography Азиатска типография + Author Автор + AutoCorrect Options Опции за автокорекция + AutoFit Автопобиране + AutoFit Contents Автопобиране по съдържанието + AutoFit Window Автопобиране по прозореца + AutoSave Автоматично запазване + AutoSave saves every change to the file (needs a saved document) Автоматичното запазване записва всяка промяна във файла (изисква запазен документ) + AutoSave isn't available in the browser: it can't write to your files. Save downloads a copy. Автоматичното запазване не е налично в браузъра: то не може да записва във Вашите файлове. „Запазване“ изтегля копие. + AutoSave only keeps .docx, .odt and .rtf files; {name} can't keep all formatting. Save as .docx to turn on AutoSave. Автоматичното запазване поддържа само файлове .docx, .odt и .rtf; {name} не може да запази цялото форматиране. Запазете като .docx, за да включите автоматичното запазване. + Balloons Балончета + Bullet Водещ символ + By author По автор + Changed lines: Променени редове: + Click level to modify: Щракнете върху ниво за промяна: + Color only Само цвят + Define New Multilevel List Дефиниране на нов многостепенен списък + Define New Multilevel List… Дефиниране на нов многостепенен списък… + Deletions: Изтривания: + Enter formatting for number: Въведете форматиране за номера: + Follow number with: След номера: + Formatting Форматиране + Include level number from: Включване на номер на ниво от: + Ink Мастило + Insertions and Deletions Вмъквания и изтривания + Insertions: Вмъквания: + Left border Лява граница + Legal style numbering Номериране в юридически стил + Link level to style: Свързване на нивото със стил: + List Definition Дефиниция на списък + Nothing Нищо + Number alignment: Подравняване на номера: + Number format Формат на номера + Number style for this level: Стил на номериране за това ниво: + Outside border Външна граница + Restart list after: Рестартиране на списъка след: + Right border Дясна граница + Save the document to turn on AutoSave Запазете документа, за да включите автоматичното запазване + Save the document to turn on AutoSave (a file you have only opened isn't rewritten until you save it) Запазете документа, за да включите автоматичното запазване (файл, който само сте отворили, не се презаписва, докато не го запазите) + AutoText Автотекст + Automatic Автоматично + Automatic ({language}) Автоматично ({language}) + Automatic Table 1 (3 levels) Автоматично съдържание 1 (3 нива) + Automatic Table 2 (2 levels) Автоматично съдържание 2 (2 нива) + Automatic follows your system's language. Menus and commands change; your documents don't. „Автоматично“ следва езика на системата. Менютата и командите се променят, а документите Ви – не. + Backspace Backspace + Banded Columns Редуващи се колони + Banded Rows Редуващи се редове + Base text: Основен текст: + Before: Преди: + Bibliography Библиография + Binary assets added Добавени двоични ресурси + Binary assets removed Премахнати двоични ресурси + Blank Празен + Blank (Three Columns) Празен (три колони) + Blank Page Празна страница + Blank document Празен документ + Block\nAuthors Блокиране\nна автори + Bold Получер + Bookmark Отметка + Bookmark name: Име на отметката: + Border Painter Рисуване на граници + Border\nPainter Рисуване\nна граници + Borders Граници + Tab stop position: Позиция на табулатора: + Default tab stops: Позиции на табулатора по подразбиране: + Tab stops to be cleared: Позиции на табулатора за изчистване: + All Всички + Decimal Десетичен + Bar Черта + Leader Водач + Set Задаване + Page Border Рамка на страницата + Apply to: Прилагане към: + Whole document Целия документ + This section Този раздел + Fill Запълване + Setting: Настройка: + 3-D 3-D + Custom Потребителски + Style: Стил: + Dot dash Точка-тире + Triple Тройна + Between Между + Borders and Shading Граници и засенчване + Tabs… Табулатори… + Borders and Shading… Граници и засенчване… + Bottom Border Долна граница + Bottom Margin Долно поле + Bottom of Page Долу на страницата + Bottom: Долу: + Box Каре + Breaks Прекъсвания + Bring Forward Преместване напред + Bullet Library Библиотека с водещи символи + Bullets Водещи символи + Calendar Календар + Cancel Отказ + Capitalize Each Word Всяка дума с главна буква + Captions Надписи + Cell Margins Полета на клетката + Cell Size Размер на клетката + Cell\nMargins Полета на\nклетката + Center Центриране + Change Промяна + Change Case Смяна на регистъра + Change List Level Промяна на нивото на списъка + Change Picture Смяна на изображението + Change Shape Смяна на фигурата + Changes Промени + Character Position Позиция на знаците + Character Scale Мащаб на знаците + Character Shading Засенчване на знаци + Character Spacing Разстояние между знаците + Characters Знаци + Characters (no spaces) Знаци (без интервали) + Characters (with spaces) Знаци (с интервали) + Chart Диаграма + Check Accessibility Проверка на достъпността + Check Compatibility Проверка на съвместимостта + Check Spelling as You Type Проверка на правописа при въвеждане + Check for Errors Проверка за грешки + Check\nAccessibility Проверка на\nдостъпността + Citation Style Стил на цитиране + Citations & Bibliography Цитати и библиография + Clear Изчистване + Clear All Изчистване на всичко + Clear All Formatting Изчистване на цялото форматиране + Clear Character Formatting Изчистване на знаковото форматиране + Clear Character Style Изчистване на знаковия стил + Clear Formatting Level Изчистване на форматирането на нивото + Clear Paragraph Formatting Изчистване на форматирането на абзаца + Click a border between two cells to merge them. Esc stops. Щракнете върху граница между две клетки, за да ги обедините. Esc спира. + Clipboard Клипборд + Clipboard Pane Панел на клипборда + Clipboard Items Елементи в клипборда + Paste Clipboard Item Поставяне на елемент от клипборда + Paste All Поставяне на всичко + Delete Clipboard Item Изтриване на елемент от клипборда + Click an item to paste it. Щракнете върху елемент, за да го поставите. + Nothing collected yet. Items you copy or cut appear here. Все още няма събрани елементи. Елементите, които копирате или изрежете, се показват тук. + (picture or object) (изображение или обект) + Close Затваряне + Close Header and Footer Затваряне на колонтитулите + Collapse Selection Свиване на селекцията + Color Цвят + Color: Цвят: + Colors Цветове + Column Колона + Column Break Прекъсване на колона + Columns Колони + Combine Комбиниране + Commands Команди + Comment Коментар + Comments Коментари + Comments Pane Панел с коментари + Community\nDiscord Общност\nв Discord + Compact Компактно + Compare Сравняване + Compress Pictures Компресиране на изображенията + Compress punctuation at the start of a line Сгъстяване на пунктуацията в началото на реда + Continuous Непрекъснато + Convert Преобразуване + Convert Text to Table Преобразуване на текст в таблица + Convert Text to Table… Преобразуване на текст в таблица… + Convert to Text Преобразуване в текст + Copy Копиране + Copy Formatting Копиране на форматирането + Corrections Корекции + Count words in text boxes and notes Броене на думите в текстови полета и бележки + Cover Page Заглавна страница + Create Създаване + Create New Style Създаване на нов стил + Create a Style Създаване на стил + Create an interactive outline of your document.\n\nIt's a great way to keep track of where you are or quickly move your content around.\n\nTo get started, go to the Home tab and apply Heading styles to the headings in your document. Създайте интерактивна структура на документа си.\n\nТова е чудесен начин да следите къде се намирате или бързо да премествате съдържанието.\n\nЗа да започнете, отидете в раздела „Начало“ и приложете стилове за заглавия към заглавията в документа. + Crop Отрязване + Cross-reference Кръстосана препратка + Current Position Текуща позиция + Current style: {style} Текущ стил: {style} + Custom Margins… Потребителски полета… + Custom Watermark Потребителски воден знак + Custom Watermark… Потребителски воден знак… + Customize Quick Access Toolbar Персонализиране на лентата с инструменти за бърз достъп + Cut Изрязване + Dark Mode Тъмен режим + Dashed Пунктирана + Data Данни + Date & Time Дата и час + Decrease Font Size Намаляване на размера на шрифта + Decrease Indent Намаляване на отстъпа + Default По подразбиране + Define New Bullet Дефиниране на нов водещ символ + Define New Number Format Дефиниране на нов числов формат + Delete Изтриване + Delete Cells Изтриване на клетки + Delete Columns Изтриване на колони + Delete Comment Изтриване на коментар + Delete Next Word Изтриване на следващата дума + Delete Previous Word Изтриване на предишната дума + Delete Rows Изтриване на редове + Delete Style Изтриване на стил + Delete Table Изтриване на таблица + Design Дизайн + Diagonal Диагонал + Diamond Ромб + Dictate Диктуване + Different First Page Различна първа страница + Different Odd & Even Pages Различни четни и нечетни страници + Direct formatting Директно форматиране + Discord Discord + Discord community Общност в Discord + Display Показване + Display for Review Показване за преглед + Distribute Columns Разпределяне на колоните + Distribute letters Разпределяне на буквите + Distribute Rows Разпределяне на редовете + Distribute spacing Разпределяне на разстоянието + Distributed Разпределено + Do Not Show in TOC Без показване в съдържанието + Document End Край на документа + Document Formatting Форматиране на документа + Document Property Свойство на документа + Document Start Начало на документа + Document Text Текст на документа + Document Title Заглавие на документа + Documents you open will show up here. Документите, които отваряте, ще се показват тук. + Dot-dash Точка-тире + Dotted Точкова + Double Двойна + Double Spacing Двойно междуредие + Double Strikethrough Двойно зачертаване + Double Underline Двойно подчертаване + Down Надолу + Draft Чернова + Drag a rectangle to draw a table, or a line across cells to split them. Esc stops. Плъзнете правоъгълник, за да начертаете таблица, или линия през клетки, за да ги разделите. Esc спира. + Draw Рисуване + Draw Table Чертане на таблица + Drawing Tools Инструменти за рисуване + Drawing\nCanvas Платно за\nрисуване + Drop Cap Главна буква в началото + Drops all formatting and pastes plain text. Премахва цялото форматиране и поставя обикновен текст. + Edit Comment Редактиране на коментар + Edit Footer Редактиране на долния колонтитул + Edit Header Редактиране на горния колонтитул + Edit Recipient List Редактиране на списъка с получатели + Edit\nRecipient List Редактиране на\nсписъка с получатели + Edited Редактиран + Editing Редактиране + Editing header/footer Редактиране на горен/долен колонтитул + Editing — edit the document directly Редактиране – директно редактиране на документа + Editor Редактор + Effects Ефекти + Emboss Релеф + Encrypt with Password Шифроване с парола + English (United States) Английски (САЩ) + Engrave Гравиране + Enter page number: Въведете номер на страница: + Envelopes Пликове + Eraser Гума + Even Page Четна страница + Every command is available to scripts and AI agents: run `wordcraft-cli mcp` for an MCP server, or start the app with `--control ` for the JSON control channel. Всяка команда е достъпна за скриптове и AI агенти: изпълнете `wordcraft-cli mcp` за MCP сървър или стартирайте приложението с `--control ` за JSON канала за управление. + Export Експортиране + Export PDF Експортиране в PDF + Export Page as PNG Експортиране на страницата като PNG + Export a copy to share (PDF, Word document) Експортиране на копие за споделяне (PDF, документ на Word) + Exported {path} Експортирано: {path} + Extend Selection Разширяване на селекцията + Field Поле + File Файл + Find Търсене + Find Next Търсене на следващо + Find Previous Търсене на предишно + Find Recipient Търсене на получател + Find and Replace Търсене и замяна + Find what: Търсене на: + Finish Завършване + Finish & Merge Завършване и обединяване + Finish &\nMerge Завършване и\nобединяване + First Column Първа колона + First commit Първо подаване + First line: Първи ред: + Fixed Column Width Фиксирана ширина на колоните + Focus Фокус + Font Шрифт + Font Color Цвят на шрифта + Font Dialog Диалогов прозорец за шрифт + Font Size Размер на шрифта + Font color: Цвят на шрифта: + Font: Шрифт: + Fonts Шрифтове + Footer Долен колонтитул + Footnote and Endnote Бележка под линия и бележка в края + Footnotes Бележки под линия + Format Формат + Format Page Numbers Форматиране на номерата на страниците + Format Page Numbers… Форматиране на номерата на страниците… + Format Painter Копиране на формат + Formatted Text Форматиран текст + Formatting at Selection Форматиране при селекцията + Formula Формула + General Общи + Go To Отиване до + Go To (⌘⌥G) Отиване до (⌘⌥G) + Go To… Отиване до… + Go to Отиване до + Grab bag Смесица + Greeting Line Ред за обръщение + Greeting\nLine Ред за\nобръщение + Gridlines Линии на мрежата + Group Групиране + Grow Font 1 Point Увеличаване на шрифта с 1 пункт + HTML Format HTML формат + Hanging Indent Висящ отстъп + Header Горен колонтитул + Header & Footer Горен и долен колонтитул + Header Row Заглавен ред + Header/Footer Position Позиция на колонтитулите + Heading 1 Заглавие 1 + Heading 2 Заглавие 2 + Heading 3 Заглавие 3 + Headings Заглавия + Heart Сърце + Help Помощ + Hidden Скрит + Highlight Merge Fields Осветяване на полетата за обединяване + Highlight\nMerge Fields Осветяване на\nполетата за обединяване + Highlighter Маркер + Home Начало + Horizontal Хоризонтално + Horizontal Line Хоризонтална линия + Hyphenation Сричкопренасяне + Icons Икони + Ignore All Пропускане на всички + Illustrations Илюстрации + Immersive Потапящ + Immersive Reader Потапящ четец + Immersive\nReader Потапящ\nчетец + Increase Font Size Увеличаване на размера на шрифта + Increase Indent Увеличаване на отстъпа + Indent Отстъп + Indentation Отстъпи + Indents Отстъпи + Indents and Spacing Отстъпи и разстояние + Index Индекс + Info Информация + Ink to\nMath Мастило към\nматематика + Ink to\nShape Мастило към\nфигура + Ink\nReplay Възпроизвеждане\nна мастило + Insert Вмъкване + Insert Above Вмъкване отгоре + Insert Below Вмъкване отдолу + Insert Caption Вмъкване на надпис + Insert Citation Вмъкване на цитиране + Insert Endnote Вмъкване на бележка в края + Insert Footnote Вмъкване на бележка под линия + Insert Hyperlink Вмъкване на хипервръзка + Insert Index Вмъкване на индекс + Insert Left Вмъкване вляво + Insert Merge Field Вмъкване на поле за обединяване + Insert Merge\nField Вмъкване на поле\nза обединяване + Insert Right Вмъкване вдясно + Insert Shapes Вмъкване на фигури + Insert Table Вмъкване на таблица + Insert Table of Authorities Вмъкване на таблица на източниците + Insert Table of Figures Вмъкване на списък на фигурите + Insert Table… Вмъкване на таблица… + Insert\nAbove Вмъкване\nотгоре + Insert\nCaption Вмъкване\nна надпис + Insert\nCitation Вмъкване\nна цитиране + Insert\nFootnote Вмъкване на\nбележка под линия + Inside Borders Вътрешни граници + Inspect Document Проверка на документа + Inspect Styles at Selection Проверка на стиловете при селекцията + Interface language: Език на интерфейса: + Italic Курсив + Join the ArtCraft community on Discord Присъединете се към общността на ArtCraft в Discord + Join us on Discord: discord.gg/artcraft Присъединете се към нас в Discord: discord.gg/artcraft + Justify Двустранно подравняване + Keep Lines Together Редовете заедно + Keep Text Only Запазване само на текста + Keep lines together Редовете заедно + Keep with Next Заедно със следващия + Keep with next Заедно със следващия + Keeps the fonts and formatting of the copied text. Запазва шрифтовете и форматирането на копирания текст. + Labels Етикети + Landscape Пейзаж + Language Език + Lasso Ласо + Last Column Последна колона + Last commit Последно подаване + Layout Оформление + Layout Summary Обобщение на оформлението + Left Ляво + Left Border Лява граница + Left Margin Ляво поле + Left: Ляво: + Letter Писмо + Line Линия + Line Break Нов ред + Line breaking Пренасяне на редове + Line End Край на реда + Line Numbers Номера на редове + Line Spacing Междуредие + Line Spacing Options… Опции за междуредие… + Line Start Начало на реда + Line and Page Breaks Нови редове и страници + Line and Paragraph Spacing Междуредие и разстояние между абзаците + Line delta Разлика в редовете + Line spacing: Междуредие: + Lines Редове + Lines added Добавени редове + Lines deleted Изтрити редове + Link Връзка + Link to Previous Връзка към предишния + Links Връзки + Location: {path} Местоположение: {path} + MIT OR Apache-2.0. Copyright (c) 2026 ArtCraft Team and the WordCraft contributors. MIT OR Apache-2.0. Авторско право (c) 2026 ArtCraft Team и сътрудниците на WordCraft. + Macros Макроси + Mailings Кореспонденция + Manage Sources Управление на източниците + Many pages Много страници + Margins Полета + Mark Citation Маркиране на цитиране + Mark Entry Маркиране на запис + Mark\nCitation Маркиране\nна цитиране + Mark\nEntry Маркиране\nна запис + Markdown (*.md) Markdown (*.md) + Match Fields Съпоставяне на полета + Match case Съвпадение на регистъра + Matrix Матрица + Merge Обединяване + Merge Cells Обединяване на клетки + Merge Formatting Сливане на форматирането + Merged PRs Обединени PR + Mirrored Огледални + Moderate Умерено + Modify Style Промяна на стил + Modify… Промяна… + More templates → Още шаблони → + Move or Resize Преместване или преоразмеряване + Multilevel List Многостепенен списък + Multiple Pages Няколко страници + Name (A–Z) Име (А–Я) + Name: Име: + Narrow 0.5" all Тесни 0.5" навсякъде + Navigation Навигация + Navigation Pane Навигационен панел + New Нов + New Comment Нов коментар + New Paragraph Нов абзац + New Style… Нов стил… + New Window Нов прозорец + New from Template Нов от шаблон + New\nComment Нов\nкоментар + New\nWindow Нов\nпрозорец + Next Следваща + Next Change Следваща промяна + Next Comment Следващ коментар + Next Footnote Следваща бележка под линия + Next Page Следваща страница + Next Record Следващ запис + Next Section Следващ раздел + No Border Без граница + No Color Без цвят + No Markup Без маркировки + No Paragraph Space Без разстояние между абзаците + No contributor data was built into this copy. В това копие няма вградени данни за сътрудниците. + No model credits were built into this copy. В това копие няма вградени сведения за моделите. + No templates match your search. Няма шаблони, отговарящи на търсенето. + Nonbreaking Hyphen Непрекъсваемо тире + Nonbreaking Space Непрекъсваем интервал + None Няма + Normal 1" all Нормални 1" навсякъде + Normal Style Нормален стил + Not available right now В момента не е налично + Not saved Не е запазено + Not saved yet Още не е запазено + Nudge Леко преместване + Number of columns: Брой колони: + Number of rows: Брой редове: + Numbering Номериране + Offset (pt): Отместване (pt): + OK OK + Object Обект + Odd Page Нечетна страница + Office 2003 Default Office 2003 по подразбиране + One Една + One Page Една страница + Open Отваряне + OpenDocument Text (*.odt) OpenDocument текст (*.odt) + Optional Hyphen Условно тире + Copyright Sign Знак за авторско право + Ellipsis Многоточие + Em Dash Дълго тире + Insert Date Field Вмъкване на поле за дата + Insert Page Field Вмъкване на поле за страница + Insert Time Field Вмъкване на поле за час + Registered Sign Знак за регистрирана марка + Reset Character Formatting Нулиране на форматирането на знаците + Reset Paragraph Formatting Нулиране на форматирането на абзаца + Trademark Sign Знак за търговска марка + Options Опции + Orientation Ориентация + Outline Структура + Outline Level Ниво в структурата + Outside Borders Външни граници + Oval Овал + PDF document (*.pdf) PDF документ (*.pdf) + Page Страница + Page Background Фон на страницата + Page Borders Рамки на страницата + Page Break Нова страница + Page Break Before Нова страница преди + Page Breaks Нови страници + Page Color Цвят на страницата + Page Down Page Down + Page Movement Придвижване по страници + Page Number Номер на страница + Page Setup Настройка на страницата + Page Up Page Up + Page Width Ширина на страницата + Page X of Y Страница X от Y + Page break before Нова страница преди + Page image (*.png) Изображение на страница (*.png) + Page width Ширина на страницата + Page {page} Страница {page} + Page {page} of {pages} Страница {page} от {pages} + Page\nBorders Рамки на\nстраницата + Page\nColor Цвят на\nстраницата + Page\nNumber Номер на\nстраница + Pages Страници + Paragraph Абзац + Paragraph Details Подробности за абзаца + Paragraph Down Абзац надолу + Paragraph Settings Настройки на абзаца + Paragraph Spacing Разстояние между абзаците + Paragraph Up Абзац нагоре + Paragraphs Абзаци + Paste Поставяне + Paste (⌘V) Поставяне (⌘V) + Paste Formatting Поставяне на форматирането + Paste Special Специално поставяне + Paste Special… Специално поставяне… + Paste as: Поставяне като: + Paste: Keep Text Only Поставяне: само текст + Paste: Merge Formatting Поставяне: сливане на форматирането + Pen Писалка + Pencil Молив + Phonetic Guide Фонетично ръководство + Picture Border Граница на изображението + Picture Format Формат на изображението + Picture Styles Стилове на изображението + Pictures Изображения + Plain text (*.txt) Обикновен текст (*.txt) + Portrait Портрет + Position Позиция + Preview Results Визуализация на резултатите + Preview\nResults Визуализация\nна резултатите + Previous Предишна + Previous Change Предишна промяна + Previous Comment Предишен коментар + Previous Record Предишен запис + Previous Section Предишен раздел + Print Печат + Print Layout Оформление за печат + Print\nLayout Оформление\nза печат + Proofing Проверка + Proofing Issues Проблеми при проверката + Properties Свойства + Protect Защита + Protect Document Защита на документа + Quick Access Toolbar Лента с инструменти за бърз достъп + Quick Parts Бързи части + Quick Tables Бързи таблици + Read Aloud Четене на глас + Read Mode Режим за четене + Read\nAloud Четене\nна глас + Read\nMode Режим\nза четене + Reads the clipboard's rich text and keeps its formatting. Прочита форматирания текст от клипборда и запазва форматирането му. + Reads the clipboard's web content and keeps its formatting. Прочита уеб съдържанието от клипборда и запазва форматирането му. + Reading Highlight Осветяване при четене + Recent Последни + Record Macro Записване на макрос + Recover Unsaved Documents Възстановяване на незапазени документи + Rectangle Правоъгълник + Redo Повторение + Reduce Hanging Indent Намаляване на висящия отстъп + References Препратки + Reject Отхвърляне + Reject All Changes Отхвърляне на всички промени + Reject Change Отхвърляне на промяната + Relaxed Свободно + Remove Премахване + Remove Background Премахване на фона + Remove Footer Премахване на долния колонтитул + Remove Header Премахване на горния колонтитул + Remove Hyperlink Премахване на хипервръзката + Remove Space After Paragraph Премахване на разстоянието след абзаца + Remove Table of Contents Премахване на съдържанието + Remove Watermark Премахване на водния знак + Reopen Повторно отваряне + Repeat Повтаряне + Repeat Header Rows Повтаряне на заглавните редове + Replace Замяна + Replace All Замяна на всички + Replace Document Text Замяна на текста в документа + Replace with: Замяна с: + Replay Възпроизвеждане + Reply Отговор + Report Отчет + Research Проучване + Researcher Изследовател + Reset Picture Нулиране на изображението + Reset to Normal Връщане към Нормален + Resolve Разрешаване + Resolved Разрешен + Restart Each Page Отначало на всяка страница + Restart Each Section Отначало на всеки раздел + Restart at 1 Отначало от 1 + Restrict Editing Ограничаване на редактирането + Restrict\nEditing Ограничаване на\nредактирането + Result Резултат + Results Резултати + Reverse the order Обръщане на реда + Reviewing Преглед + Reviewing Pane Панел за преглед + Reviewing — edits become suggestions Преглед — редакциите стават предложения + Rich Text Format (*.rtf) Rich Text Format (*.rtf) + Rich Text Format (RTF) Rich Text Format (RTF) + Right Дясно + Right (vertical) Дясно (вертикално) + Right Border Дясна граница + Right Margin Дясно поле + Right-to-Left Text Direction Посока на текста отдясно наляво + Left-to-Right Text Direction Посока на текста отляво надясно + Right: Дясно: + Rotate Завъртане + Rounded Rectangle Заоблен правоъгълник + Rows & Columns Редове и колони + Ruby text: Текст ruby: + Ruler Линийка + Rules Правила + Résumé Автобиография + Save Запазване + Save As Запазване като + Save a copy in another format. Printing goes through a PDF you can print from any viewer. Запазване на копие в друг формат. Отпечатването минава през PDF файл, който можете да отпечатате от всяка програма за преглед. + Save as Template Запазване като шаблон + Saved Запазено + Search Commands Търсене на команди + Search commands and help Търсене на команди и помощ + Search document Търсене в документа + Search for templates Търсене на шаблони + Section Breaks Прекъсвания на раздел + Section Properties Свойства на раздела + Sections Раздели + Select Избор + Select All Избиране на всичко + Select Cell Избиране на клетка + Select Line Избиране на ред + Select Objects Избиране на обекти + Select Paragraph Избиране на абзац + Select Range Избиране на диапазон + Select Recipients Избиране на получатели + Select Row Избиране на ред от таблицата + Select Sentence Избиране на изречение + Select Table Избиране на таблица + Select Text Избиране на текст + Select Text with Similar Formatting Избиране на текст с подобно форматиране + Select Word Избиране на дума + Select\nRecipients Избиране на\nполучатели + Selection Избрано + Selection Pane Панел за избор + Send Backward Преместване назад + Sentence case. Като в изречение. + Set Caret Поставяне на курсора + Set Character Formatting Задаване на форматиране на знаците + Set Numbering Value Задаване на стойност за номериране + Set Paragraph Formatting Задаване на форматиране на абзаца + Set as Default Задаване по подразбиране + Shading Засенчване + Shadow Сянка + Shape Fill Запълване на фигура + Shape Format Формат на фигура + Shape Outline Контур на фигура + Shape Styles Стилове на фигури + Shapes Фигури + Share Споделяне + Shift+Tab Shift+Tab + Show Показване + Show all revisions inline Показване на всички редакции в текста + Show Comments Показване на коментарите + Show Markup Показване на маркировките + Show Notes Показване на бележките + Show all formatting marks Показване на всички знаци за форматиране + Show only comments and formatting in balloons Показване само на коментарите и форматирането в балончета + Show revisions in balloons Показване на редакциите в балончета + Show rulers Показване на линийките + Show the community button in the title bar Показване на бутона за общността в заглавната лента + Show/Hide ¶ Показване/скриване на ¶ + Show\nComments Показване на\nкоментарите + Shrink Font 1 Point Намаляване на шрифта с 1 пункт + Side to Side Една до друга + Side\nto Side Една\nдо друга + Signature Line Ред за подпис + Single Единично + Single Spacing Единично междуредие + Size Размер + Size (pt): Размер (пт): + Size: Размер: + Small Caps Малки главни букви + Small caps Малки главни букви + SmartArt SmartArt + Sort Сортиране + Source code: github.com/storytold/wordcraft Изходен код: github.com/storytold/wordcraft + Space Интервал + Space before/after: Разстояние преди/след: + Spacing Разстояние + Spacing (pt): Разстояние (пт): + Speech Реч + Spelling & Grammar Правопис и граматика + Spelling &\nGrammar Правопис и\nграматика + Spelling Suggestions Предложения за правопис + Split Разделяне + Split Cells Разделяне на клетки + Split Table Разделяне на таблица + Spreadsheet Table Електронна таблица + Standard Colors Стандартни цветове + Star Звезда + Start at: Начало от: + Start Mail Merge Стартиране на обединяване на кореспонденция + Start Mail\nMerge Стартиране на\nобединяването + Statistics Статистика + Status Bar Лента на състоянието + Strikethrough Зачертаване + Studio cover Корица „Студио“ + Studio handbook (sample) Наръчник на студиото (пример) + Style Стил + Style Inspector Инспектор на стилове + Style Set Набор от стилове + Style based on: Стил, базиран на: + Style: {style} Стил: {style} + Styles Стилове + Styles Pane Панел със стилове + Subscript Долен индекс + Superscript Горен индекс + Switch Modes Смяна на режима + Switch\nModes Смяна на\nрежима + Symbol Символ + Symbols Символи + Synonyms Синоними + Tab Tab + Tab character Знак за табулация + Table Таблица + Table Column Width Ширина на колона в таблица + Table Design Дизайн на таблица + Table Layout Оформление на таблица + Table Properties Свойства на таблица + Table Row Height Височина на ред в таблица + Table Style Options Опции за стил на таблица + Table Styles Стилове на таблици + Table of Authorities Списък на правните източници + Table of Contents Съдържание + Table of\nContents Съдържание\nна документа + Tables Таблици + Tabs Табулатори + Tabular List Табличен списък + Text Текст + Text Box Текстово поле + Text Direction Посока на текста + Text Effects Текстови ефекти + Text Effects and Typography Текстови ефекти и типография + Text Highlight Color Цвят за осветяване на текст + Text indent at: Отстъп на текста на: + Text Wrapping Обтичане на текста + Text from File Текст от файл + Text to display: Текст за показване: + Text: Текст: + Text\nBox Текстово\nполе + Text\nDirection Посока на\nтекста + The clipboard is empty. Клипбордът е празен. + The new style takes the formatting of the current paragraph. Новият стил приема форматирането на текущия абзац. + Theme Colors Цветове на темата + Theme Fonts Шрифтове на темата + Theme List Списък с теми + Themes Теми + Thesaurus Синонимен речник + Thick Дебело + Three Три + Tight Стегнато + Top Border Горна граница + Top Margin Горно поле + Top of Page Горна част на страницата + Top: Горе: + Total Row Ред с общ сбор + Track Changes Проследяване на промените + Track Changes Options Опции за проследяване на промените + Track Changes: On Проследяване на промените: включено + Track formatting Проследяване на форматирането + Tracking Разредка + Translate Превод + Transparency Прозрачност + Triangle Триъгълник + Two Две + Type Text Въвеждане на текст + Type a command, e.g. "insert table" Въведете команда, напр. „вмъкване на таблица“ + Ungroup Разгрупиране + UPPERCASE ГЛАВНИ БУКВИ + Underline Подчертаване + Underline Words Only Подчертаване само на думите + Undo Отмяна + Undo {action} Отмяна: {action} + Unformatted Text Неформатиран текст + Up Нагоре + Update Field Актуализиране на полето + Update Index Актуализиране на индекса + Update Style to Match Selection Актуализиране на стила според избраното + Update Table Актуализиране на таблицата + Update Table of Figures Актуализиране на списъка на фигурите + Update to Match Актуализиране за съвпадение + Update to Match Selection Актуализиране според избраното + User Name Потребителско име + User name: Потребителско име: + Version History История на версиите + Version {version} ({build}) Версия {version} ({build}) + Vertical Вертикално + Vertical Alignment Вертикално подравняване + View Изглед + View Gridlines Показване на линиите на мрежата + View State Състояние на изгледа + Views Изгледи + Voice Глас + Watermark Воден знак + Wave Вълна + Web Layout Уеб оформление + Web\nLayout Уеб\nоформление + Web page (*.html) Уеб страница (*.html) + Whole page Цяла страница + Whole words Цели думи + Wide Широко + Widow/Orphan Control Контрол на висящи редове + Widow/Orphan control Контрол на висящи редове + Wildcards (regex) Заместващи символи (regex) + Window Прозорец + Word Count Брой думи + Word Left Дума наляво + Word Right Дума надясно + Word document (*.docx) Документ на Word (*.docx) + WordArt WordArt + WordCraft WordCraft + Words Думи + Words only Само думи + Wrap Text Обтичане на текста + Wrap\nText Обтичане\nна текста + Write & Insert Fields Писане и вмъкване на полета + Write a reply Напишете отговор + Zoom Мащаб + Zoom In Увеличаване + Zoom Out Намаляване + development build версия за разработка + lowercase малки букви + {group} settings Настройки за {group} + {key}+Click to follow link {key}+щракване за отваряне на връзката + {selected} of {words} words Думи: {selected} от {words} + {words} words Думи: {words} + Browse… Преглед… + Equation Уравнение + Review Преглед + Track\nChanges Проследяване\nна промените + Track\nChanges ✓ Проследяване\nна промените ✓ + Scroll ribbon left Превъртане на лентата наляво + Scroll ribbon right Превъртане на лентата надясно + Apply Styles Прилагане на стилове + Dark page (white text on black) Тъмна страница (бял текст на черно) + Show documents with their colours inverted, like View › Switch Modes. Saving, printing and PDFs are unchanged. Документите се показват с обърнати цветове, както при Изглед › Смяна на режима. Запазването, отпечатването и PDF файловете не се променят. + Only web (http, https) and email (mailto) links can be followed Могат да се отварят само уеб (http, https) и имейл (mailto) връзки + Character Border Граница на знак + Direction: Посока: + Right-to-left Отдясно наляво + Left-to-right Отляво надясно + Zotero Zotero + Citations Цитирания + Document Документ + Add/Edit\nCitation Добавяне/редакция\nна цитиране + Add\nNote Добавяне\nна бележка + Add/Edit\nBibliography Добавяне/редакция\nна библиография + Refresh Опресняване + Document\nPreferences Предпочитания\nза документа + Unlink\nCitations Разкачване\nна цитиранията + Waiting for Zotero… Изчакване на Zotero… + Zotero: done Zotero: готово + Zotero: cancelled Zotero: отменено + Yes Да + No Не + Move Past Citation Преминаване след цитирането + Move Past\nCitation Преминаване\nслед цитирането + Next Sentence Следващо изречение + Previous Sentence Предишно изречение + Next sentence Следващо изречение + Previous sentence Предишно изречение + Play/Pause Възпроизвеждане/пауза + Read Aloud Status Състояние на четенето на глас + Reading Speed Скорост на четене + Skip Citations and Bibliography Пропускане на цитиранията и библиографията + Skip citations & bibliography Пропускане на цитиранията и библиографията + Stop Reading Спиране на четенето + Don't read Zotero, Mendeley or EndNote citations and bibliographies Цитиранията и библиографиите от Zotero, Mendeley или EndNote не се четат + Play Възпроизвеждане + Pause Пауза + Stop Спиране + Sentence Изречение + Paused На пауза + Finished Завършено + Stopped Спряно + Speed Скорост + Normal speed Нормална скорост + AutoSave documents you have saved in WordCraft Автоматично запазване на документите, които сте запазили в WordCraft + AutoSave failed: {error}. Save the document to turn AutoSave back on. Автоматичното запазване е неуспешно: {error}. Запазете документа, за да включите отново автоматичното запазване. + Couldn't download {name}: {error} Неуспешно изтегляне на {name}: {error} + Do you want to save changes to {name}? Искате ли да запазите промените в {name}? + Don't Save Без запазване + That format doesn't keep everything, so the document is still open. Save it as a Word document (.docx) to go on. Този формат не запазва всичко, затова документът остава отворен. Запазете го като документ на Word (.docx), за да продължите. + Your changes will be lost if you don't save them. Промените ще бъдат загубени, ако не ги запазите. + Change Structure Смяна на структурата + Change to Display Превключване към отделен ред + Change to Inline Превключване към вграден в реда + Conversions Преобразувания + Delete Back in Equation Изтриване назад в уравнението + Delete in Equation Изтриване в уравнението + Edit Equation Редактиране на уравнение + Enter in Equation Enter в уравнението + Equation Gallery Галерия с уравнения + Equation Info Информация за уравнението + Equation Number Номер на уравнение + Input Format Формат на въвеждане + Insert Structure Вмъкване на структура + Insert Symbol Вмъкване на символ + Justification Подравняване + Leave Equation Излизане от уравнението + Move in Equation Придвижване в уравнението + Normal Text Нормален текст + Set Equation Задаване на уравнение + Structure Actions Действия със структури + Structures Структури + Tools Инструменти + Type in Equation Въвеждане в уравнението + Inline В реда + Number Номер + Close Equation Затваряне на уравнението + Insert New Equation Вмъкване на ново уравнение + Built-In Вградени + Professional Професионален + Linear Линеен + Fraction Дроб + Script Индекс + Radical Корен + Integral Интеграл + Large\nOperator Голям\nоператор + Bracket Скоби + Function Функция + Accent Акцент + Limit and\nLog Граница\nи логаритъм + Operator Оператор + Equation Options Опции за уравнения + All symbol sets Всички набори от символи + Send the document straight to the system print dialog, or save a copy in another format below. Изпращане на документа директно към системния диалогов прозорец за печат или запазване на копие в друг формат по-долу. + Or save a copy in another format: Или запазете копие в друг формат: + Opening print dialog… Отваряне на диалоговия прозорец за печат… + Print failed: {error} Неуспешен печат: {error} + Couldn't open {path}: {error} Неуспешно отваряне на {path}: {error} + Column Selection Избор на колона + New Table Style Нов стил на таблица + Modify Table Style Промяна на стила на таблица + New Table Style… Нов стил на таблица… + Modify Table Style… Промяна на стила на таблица… + Apply formatting to: Прилагане на форматирането към: + Whole Table Цялата таблица + Fill color: Цвят на запълване: + {count} items highlighted Осветени елементи: {count} + {n} pt {n} пт + Not {name} Не {name} + Level {n} Ниво {n} + Body Text Основен текст + {n} lines Редове: {n} + At least {n} Поне {n} + Exactly {n} Точно {n} + Centered Центрирано + Character spacing Разстояние между знаците + Don't add space between paragraphs of the same style Без добавяне на разстояние между абзаци с еднакъв стил + Don't hyphenate Без сричкопренасяне + Double strikethrough Двойно зачертаване + Drop cap Главна буква в началото + First line Първи ред + Font color Цвят на шрифта + Highlight Осветяване + Justified Двустранно подравнен + Left indent Ляв отстъп + Line spacing Междуредие + Outline level Ниво в структурата + Raised/lowered Повдигнат/понижен + Right indent Десен отстъп + Scale Мащаб + Space after Разстояние след + Space before Разстояние преди + Suppress line numbers Без номера на редове + Underline color Цвят на подчертаването + Letters Писма + E-mail Messages Имейл съобщения + Directory Указател + Normal Word Document Нормален документ на Word + If…Then…Else… Ако…Тогава…Иначе… + If…Then…Else Ако…Тогава…Иначе + Merge Record # Номер на записа при обединяване + Skip Record If… Пропускане на запис, ако… + Skip Record If Пропускане на запис, ако + Equals: Е равно на: + Then insert: Тогава да се вмъкне: + Otherwise insert: Иначе да се вмъкне: + Courtesy Title Обръщение + First Name Име + Last Name Фамилия + Company Фирма + Address Адрес + City Град + State Област + Postal Code Пощенски код + Country Държава + (not matched) (няма съответствие) + Leave a recipient out of the merge when a field has this value. Изключване на получател от обединяването, когато поле има тази стойност. + Insert one text or another, depending on a field's value. Вмъкване на един или друг текст в зависимост от стойността на поле. + Select recipients first to match their fields. Първо изберете получатели, за да съпоставите полетата им. + The recipient-list field that fills each part of an address block and greeting line. Полето от списъка с получатели, което попълва всяка част от адресния блок и реда за поздрав. + No errors found: every merge field is in the recipient list. Няма открити грешки: всяко поле за обединяване е в списъка с получатели. + These merge fields aren't in the recipient list: Тези полета за обединяване не са в списъка с получатели: + Add at least one field. Добавете поне едно поле. + Add Field Добавяне на поле + Every field needs a name. Всяко поле трябва да има име. + Field name Име на поле + Field names must be different. Имената на полетата трябва да са различни. + Fields: Полета: + Field: Поле: + Find: Търсене: + New Entry Нов запис + Recipient List Списък с получатели + Recipients: {count} Получатели: {count} + Remove field Премахване на поле + Remove recipient Премахване на получател + Select recipients first to choose from their fields. Първо изберете получатели, за да изберете от полетата им. + Type a New List… Въвеждане на нов списък… + Type at least one recipient. Въведете поне един получател. + Type the recipients, or load them from a file. Въведете получателите или ги заредете от файл. + Use an Existing List… Използване на съществуващ списък… + Choose the sheet that holds the recipients. Изберете листа, който съдържа получателите. + First row of data contains column headers Първият ред с данни съдържа заглавия на колони + Interface theme: Тема на интерфейса: + Interface\nTheme Тема на\nинтерфейса + Interface Theme Тема на интерфейса + Light Светла + Dark Тъмна + Use system setting Според системната настройка + A file dialog is already open. Вече е отворен диалогов прозорец за файл. + Height: Височина: + Width: Ширина: + Row Ред + Cell Клетка + Preferred width: Предпочитана ширина: + Alignment: Подравняване: + Indent from left: Отстъп отляво: + Specify height: Задаване на височина: + Exactly Точно + At least Най-малко + Allow row to break across pages Разрешаване на разделяне на реда между страници + Repeat as header row on each page Повтаряне като заглавен ред на всяка страница + Vertical alignment: Вертикално подравняване: + Top Горе + Bottom Долу + Alt text Алтернативен текст + Alt: Алт. текст: + W: Ш: + H: В: + Reset Нулиране + Reset Crop Нулиране на изрязването + Insert a new equation (Alt+=) Вмъкване на ново уравнение (Alt+=) + Leave the equation (Esc) Излизане от уравнението (Esc) + Preview Визуализация + getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft + Draw Ink Рисуване с мастило + Erase Ink Изтриване на мастило + Hide Ink Скриване на мастилото + Hide\nInk Скриване на\nмастилото + Pens Писалки + Thickness Дебелина + (All) (Всички) + (none) (няма) + (normal text) (нормален текст) + 1/4 Em Space 1/4 Em интервал + A mail merge field Поле за обединяване на кореспонденция + A numbered sequence (figures, tables…) Номерирана последователност (фигури, таблици…) + A table of contents Съдържание + Categories: Категории: + Character Знак + Character code: Код на знака: + Col #: Колона №: + Copyright Авторско право + Date and Time Дата и час + Date formats: Формати на датата: + Description: Описание: + Document Information Информация за документа + Double Closing Quote Двойна затваряща кавичка + Double Opening Quote Двойна отваряща кавичка + En Dash Средно тире + Em Space Em интервал + En Space En интервал + Equal column width Еднаква ширина на колоните + Field Codes Кодове на полета + Field code: Код на полето: + Field codes: Кодове на полета: + Field name: Име на поле: + Field names: Имена на полета: + Field properties Свойства на полето + Field… Поле… + Format: Формат: + Hide Codes Скриване на кодовете + Index and Tables Индекс и таблици + Line between Разделителна линия + Links and References Връзки и препратки + Mail Merge Обединяване на кореспонденция + More Columns… Още колони… + More Symbols… Още символи… + No-Width Non Break Неразделящ знак с нулева ширина + No-Width Optional Break Незадължително прекъсване с нулева ширина + Paragraph Mark Знак за абзац + Pick a field from the list Изберете поле от списъка + Preserve formatting during updates Запазване на форматирането при актуализиране + Presets Готови настройки + Recently used symbols: Последно използвани символи: + Registered Регистрирана марка + Section Mark Знак за раздел + Sequence name: Име на последователността: + Shortcut key: Клавишна комбинация: + Single Closing Quote Единична затваряща кавичка + Single Opening Quote Единична отваряща кавичка + Spacing: Разстояние: + Special Characters Специални знаци + Subset: Подмножество: + The time right now Текущият час + When this document was first created Кога е създаден документът + When this document was most recently printed Кога документът е отпечатан за последно + When this document was most recently saved Кога документът е запазен за последно + The document has no bookmarks yet. Документът все още няма отметки. + Who the document properties name as author Авторът, посочен в свойствата на документа + Name of the file this document is saved as Името на файла, под което е запазен документът + Title from the document properties Заглавието от свойствата на документа + How many pages the whole document has Общ брой страници в документа + How many pages this section has Брой страници в този раздел + Number of the page the field sits on Номер на страницата, на която е полето + Number of the section the field sits in Номер на раздела, в който е полето + How many words the document holds Брой думи в документа + Page on which a bookmarked passage falls Страницата, на която се намира пасажът с отметка + Copy of the text a bookmark marks Копие на текста, отбелязан с отметка + This field has no options. Това поле няма опции. + This font has no characters to show. Този шрифт няма знаци за показване. + This point forward От тази точка нататък + The date it is today Днешната дата + Trademark Търговска марка + Type the field's name and its switches, for example PAGE \\* roman. Въведете името на полето и неговите превключватели, например PAGE \\* roman. + Width and spacing Ширина и разстояние + from Unicode (hex) от Unicode (шестн.) + Shape Effects Ефекти за фигура + Glow Сияние + Soft Edges Меки ръбове + No Shadow Без сянка + No Glow Без сияние + No Soft Edges Без меки ръбове + Glow Colors Цветове на сиянието + Weight Дебелина + Shadow, glow and soft edges Сянка, сияние и меки ръбове + Offset: Bottom Right Отместване: долу вдясно + Offset: Bottom Отместване: долу + Offset: Bottom Left Отместване: долу вляво + Offset: Right Отместване: вдясно + Offset: Center Отместване: център + Offset: Left Отместване: вляво + Offset: Top Right Отместване: горе вдясно + Offset: Top Отместване: горе + Offset: Top Left Отместване: горе вляво + Manage Styles Управление на стиловете + Show or Hide Style Показване или скриване на стил + Manage Styles… Управление на стиловете… + Sort order: Ред на сортиране: + As Recommended Според препоръките + Alphabetical По азбучен ред + Font: {font} Шрифт: {font} + {size} pt {size} пт + Color: {color} Цвят: {color} + Space before: {pt} pt Разстояние преди: {pt} пт + Space after: {pt} pt Разстояние след: {pt} пт + Based on: {style} Базиран на: {style} + Paragraph style Стил на абзац + Character style Стил на знаци + Linked style (paragraph and character) Свързан стил (абзац и знаци) + Table style Стил на таблица + Built-in style Вграден стил + Show in the Styles gallery Показване в галерията със стилове + Hide from style lists Скриване от списъците със стилове + Built-in styles can't be deleted. Вградените стилове не могат да бъдат изтрити. + Delete Table Style Изтриване на стил на таблица + Password Парола + Password: Парола: + Confirm password: Потвърждаване на паролата: + Encrypt with Password… Шифроване с парола… + Change Password… Смяна на паролата… + Remove Password Премахване на паролата + A password is required to open this document. За отваряне на този документ е необходима парола. + Anyone can open this document. Encrypt it with a password to keep it private. Всеки може да отвори този документ. Шифровайте го с парола, за да остане поверителен. + Anyone who opens the document will need this password. Всеки, който отваря документа, ще се нуждае от тази парола. + {name} is protected with a password. {name} е защитен с парола. + That password isn't right. Check it (passwords are case-sensitive) and try again. Паролата не е правилна. Проверете я (паролите са чувствителни към регистъра) и опитайте отново. + The document will be saved with a password. Save it to protect it. Документът ще бъде запазен с парола. Запазете го, за да го защитите. + The password is removed. Save the document to keep the change. Паролата е премахната. Запазете документа, за да запазите промяната. + The passwords don't match. Паролите не съвпадат. + Keep the password somewhere safe: without it nobody, WordCraft included, can open the document. Leave both boxes empty to remove the password. Съхранявайте паролата на сигурно място: без нея никой, включително WordCraft, не може да отвори документа. Оставете двете полета празни, за да премахнете паролата. + Align Top Left Подравняване горе вляво + Align Top Center Подравняване горе в центъра + Align Top Right Подравняване горе вдясно + Align Center Left Подравняване в центъра вляво + Align Center Подравняване в центъра + Align Center Right Подравняване в центъра вдясно + Align Bottom Left Подравняване долу вляво + Align Bottom Center Подравняване долу в центъра + Align Bottom Right Подравняване долу вдясно + {contributors} contributors · {commits} commits Сътрудници: {contributors} · Къмити: {commits} + {cols}×{rows} Table Таблица {cols}×{rows} + AaBbYyZz AaBbYyZz + pt пт + Document1 Документ1 + Username Потребителско име + Display name Показвано име + Real name Истинско име + Name Име + PRs PR-и + Commits Къмити + First Първи + Last Последен + Model Модел + Version Версия + % of all commits % от всички къмити + Lines +/− Редове +/− + Craft Занаят + Studio Студио + Atelier Ателие + Darkroom Тъмна стая + Gallery Галерия + Sketchbook Скицник + Change Picture… Смяна на изображението… + Font… Шрифт… + Paragraph… Абзац… + Link… Връзка… + Size and Crop… Размер и изрязване… + Insert Columns to the Left Вмъкване на колони отляво + Insert Columns to the Right Вмъкване на колони отдясно + Insert Rows Above Вмъкване на редове отгоре + Insert Rows Below Вмъкване на редове отдолу + Mon Пн + Tue Вт + Wed Ср + Sum Сума + North Север + South Юг + West Запад + Total Общо + Draw\nTable Рисуване на\nтаблица + Rotation Завъртане + Rotation: Завъртане: + Rotate Right 90° Завъртане надясно на 90° + Rotate Left 90° Завъртане наляво на 90° + Flip Vertical Обръщане вертикално + Flip Horizontal Обръщане хоризонтално + Move Paragraph Up Преместване на абзаца нагоре + Move Paragraph Down Преместване на абзаца надолу + Promote Повишаване на нивото + Demote Понижаване на нивото + Go Back Връщане назад + Toggle Unicode Character Превключване на Unicode знак + Toggle Field Codes Превключване на кодовете на полетата + Edit Field Code Редактиране на кода на полето + Unlink Fields Прекъсване на връзката на полетата + Lock Fields Заключване на полетата + Unlock Fields Отключване на полетата + Next Field Следващо поле + Previous Field Предишно поле + Selected Fields Избрани полета + Edit Field… Редактиране на поле… + Formatted Форматиран + Interface size: Размер на интерфейса: + Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Уголемява или смалява лентата, менютата и диалоговите прозорци в допълнение към мащабирането на дисплея в системата. Мащабът на документа е отделна настройка. + In Line with Text На един ред с текста + Square Квадрат + Through През + Top and Bottom Отгоре и отдолу + Behind Text Зад текста + In Front of Text Пред текста + Wrap Text Sides Страни за обтичане + Both Sides От двете страни + Left Only Само отляво + Right Only Само отдясно + Largest Only Само от най-голямата страна + Customize Keyboard Персонализиране на клавиатурата + Choose a command Изберете команда + Choose a shortcut Изберете клавишна комбинация + All Commands Всички команди + Commands: Команди: + Keys for this command: Клавиши за тази команда: + New shortcut: Нова клавишна комбинация: + Now used by {command} Използва се от {command} + Not used by any command Не се използва от никоя команда + Remove every custom shortcut and restore the built-in keys? Да се премахнат ли всички потребителски клавишни комбинации и да се възстановят вградените? + Assign Присвояване + Reset All… Нулиране на всички… + Press a key combination Натиснете клавишна комбинация + Customize Персонализиране + Keyboard shortcuts: Клавишни комбинации: + Customize… Персонализиране… + Customize Ribbon Персонализиране на лентата + Add to Quick Access Toolbar Добавяне към лентата с инструменти за бърз достъп + Remove from Quick Access Toolbar Премахване от лентата с инструменти за бърз достъп + Customize the Ribbon… Персонализиране на лентата… + Customize Quick Access Toolbar… Персонализиране на лентата с инструменти за бърз достъп… + More Commands… Още команди… + Ribbon Лента + (Custom) (Потребителски) + Remove every custom tab and group and show all tabs again? Да се премахнат ли всички потребителски раздели и групи и да се покажат отново всички раздели? + Put the default commands back on the Quick Access Toolbar? Да се върнат ли командите по подразбиране в лентата с инструменти за бърз достъп? + Reset… Нулиране… + Add >> Добавяне >> + << Remove << Премахване + Pick a custom group (New Group makes one) Изберете потребителска група (с „Нова група“ се създава такава) + Ribbon tabs Раздели на лентата + New Tab Нов раздел + 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 Само редове + Lines and characters Редове и знаци + Text snaps to characters Прилепване на текста към знаците + Characters per line: Знаци на ред: + Lines per page: Редове на страница: + Moved from Преместено от + Moved to Преместено в + Developer Разработчик + Controls Контроли + Rich Text Content Control Контрола за съдържание с форматиран текст + Plain Text Content Control Контрола за съдържание с обикновен текст + Picture Content Control Контрола за съдържание с изображение + Building Block Gallery Content Control Контрола за съдържание с галерия от градивни блокове + Check Box Content Control Контрола за съдържание с отметка + Combo Box Content Control Контрола за съдържание с комбинирано поле + Drop-Down List Content Control Контрола за съдържание с падащ списък + Date Picker Content Control Контрола за съдържание с избор на дата + Repeating Section Content Control Контрола за съдържание с повтарящ се раздел + Design Mode Режим на проектиране + Content Control at Selection Контрола за съдържание в селекцията + Toggle Check Box Превключване на отметката + Choose List Item Избор на елемент от списъка + Set Date Задаване на дата + Remove Content Control Премахване на контролата за съдържание + Content Control Properties Свойства на контролата за съдържание + Title Заглавие + Tag Таг + Placeholder text Текст-заместител + Can't be deleted Не може да се изтрие + Contents can't be edited Съдържанието не може да се редактира + Remove the control once edited Премахване на контролата след редактиране + Allow line breaks Разрешаване на нови редове + Date format Формат на датата + Checked symbol Символ за отметнато + Unchecked symbol Символ за неотметнато + List entries Елементи на списъка + Display text Показван текст + Value Стойност + Today Днес + Put the caret in a content control first. Първо поставете курсора в контрола за съдържание. + Right-to-Left Table Таблица отдясно наляво + Right-to-Left Section Раздел отдясно наляво + Table direction: Посока на таблицата: + Section direction: Посока на раздела: + Drawing Canvas Платно за рисуване + New Drawing Canvas Ново платно за рисуване + Fit Побиране + Expand Разширяване + Scale Drawing Мащабиране на рисунката + Canvas Member Елемент на платното + Arrows Стрелки + Communication Комуникация + Office Офис + People Хора + Nature Природа + Weather Времето + Technology Технологии + Transport Транспорт + Icon Color Цвят на иконата + Search icons Търсене на икони + No icons match your search. Няма икони, отговарящи на търсенето. + Recolor a one-color picture, such as an icon Прецветяване на едноцветно изображение, например икона + Enclose Characters Ограждане на знаци + Shrink text Смаляване на текста + Enlarge symbol Уголемяване на символа + Enclosure Ограждане + Circle Кръг + Change Chart Type Промяна на типа диаграма + Chart Design Дизайн на диаграма + Type Тип + Edit Data Редактиране на данни + Chart Title Заглавие на диаграмата + Chart Layouts Оформления на диаграмата + Add Chart Element Добавяне на елемент на диаграма + Legend Легенда + Data Labels Етикети на данни + Above Chart Над диаграмата + Insert Chart Вмъкване на диаграма + Clustered Column Групирани колони + Stacked Column Натрупани колони + Clustered Bar Групирани ленти + Stacked Bar Натрупани ленти + Area Площна + Pie Кръгова + Doughnut Пръстеновидна + Scatter Точкова + “{text}” ({row}, {series}) isn't a number. „{text}“ ({row}, {series}) не е число. + Category {n} Категория {n} + Series {n} Серия {n} + Type the categories down the side and a column of values per series. Въведете категориите отстрани и по една колона със стойности за всяка серия. + Categories Категории + Series name Име на серията + Remove series Премахване на серията + Remove row Премахване на реда + Add Row Добавяне на ред + Add Series Добавяне на серия + Edit\nData Редактиране\nна данни + Change\nChart Type Промяна на\nтипа диаграма + Align Text Подравняване на текста + Create Link Създаване на връзка + Break Link Прекъсване на връзката + Rotate all text 90° Завъртане на целия текст на 90° + Rotate all text 270° Завъртане на целия текст на 270° + Stacked Подреден вертикално + Middle По средата + Click the empty text box the text should continue in (Esc cancels) Щракнете върху празното текстово поле, в което да продължи текстът (Esc за отказ) + Not linked: click a text box Няма връзка: щракнете върху текстово поле + Location Местоположение + Endnotes Бележки в края + Below text Под текста + End of section Край на раздела + End of document Край на документа + Footnotes to endnotes Бележки под линия в бележки в края + Endnotes to footnotes Бележки в края в бележки под линия + Swap footnotes and endnotes Размяна на бележките под линия и бележките в края + Custom mark: Потребителски знак: + 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 Стилове на съдържанието + 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 Изтриване на избраното мастило + 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} + WordArt Styles Стилове на WordArt + Text Fill Запълване на текста + Text Outline Контур на текста + Transform Трансформиране + No Fill Без запълване + No Outline Без контур + Reflection Отражение + No Reflection Без отражение + No Transform Без трансформиране + Connect Shapes Свързване на фигури + Straight Connector Прав съединител + Straight Arrow Connector Прав съединител със стрелка + Elbow Connector Начупен съединител + Elbow Arrow Connector Начупен съединител със стрелка + Curved Connector Извит съединител + Curved Arrow Connector Извит съединител със стрелка + Arch Up Дъга нагоре + Arch Down Дъга надолу + Slant Up Наклон нагоре + Slant Down Наклон надолу + Inflate Издуване + Deflate Свиване + Chevron Up Шеврон нагоре + Chevron Down Шеврон надолу + Triangle Up Триъгълник нагоре + Triangle Down Триъгълник надолу + Solid Accent Плътен акцент + Light with Outline Светъл с контур + Hollow Кух + Accent Gradient Акцентен градиент + Dark with Shadow Тъмен със сянка + Soft Glow Меко сияние + Accent with Reflection Акцент с отражение + Warm Gradient with Outline Топъл градиент с контур + Add line numbering Добавяне на номериране на редовете + Add to Document Добавяне към документа + Address labels Адресни етикети + Arabic Арабски + Auto Автоматично + Automatically hyphenate document Автоматично сричкопренасяне в документа + Chinese (Simplified) Китайски (опростен) + Chinese (Traditional) Китайски (традиционен) + Column: Колона: + Count by: Стъпка: + Czech Чешки + Danish Датски + Delivery address: Адрес на получателя: + Detect language automatically Автоматично разпознаване на езика + Do not check spelling or grammar Без проверка на правописа и граматиката + Dutch Нидерландски + English (United Kingdom) Английски (Обединеното кралство) + Envelope size: Размер на плика: + Estonian Естонски + Finnish Финландски + French (France) Френски (Франция) + From text: От текста: + Full page of the same label Цяла страница с един и същ етикет + German (Germany) Немски (Германия) + Greek Гръцки + Hebrew Иврит + Hyphenate at: Сричкопренасяне при: + Hyphenate words in CAPS Пренасяне на думи с ГЛАВНИ БУКВИ + Hyphenation Options… Опции за сричкопренасяне… + Hyphenation zone: Зона на сричкопренасяне: + Italian Италиански + Japanese Японски + Korean Корейски + Limit consecutive hyphens to: Ограничаване на последователните пренасяния до: + Line Numbering Options… Опции за номериране на редовете… + Manual Hyphenation Ръчно сричкопренасяне + Manual hyphenation is complete. Ръчното сричкопренасяне завърши. + Manual… Ръчно… + Mark selected text as: Маркиране на избрания текст като: + New Document Нов документ + No limit Без ограничение + Norwegian (Bokmål) Норвежки (букмол) + Numbering: Номериране: + Omit Пропускане + One label per recipient По един етикет за получател + Polish Полски + Portuguese (Brazil) Португалски (Бразилия) + Portuguese (Portugal) Португалски (Португалия) + Return address labels Етикети с адрес на подателя + Return address: Адрес на подателя: + Row: Ред: + Russian Руски + Serbian (Cyrillic) Сръбски (кирилица) + Serbian (Latin) Сръбски (латиница) + Set As Default Задаване по подразбиране + Shipping labels Етикети за пратки + Single label Единичен етикет + Spanish (Mexico) Испански (Мексико) + Spanish (Spain) Испански (Испания) + Swedish Шведски + Turkish Турски + Ukrainian Украински + {language} is now the document's default language. {language} вече е езикът по подразбиране на документа. + 1×2 Empty Matrix Празна матрица 1×2 + 1×3 Empty Matrix Празна матрица 1×3 + 2×1 Empty Matrix Празна матрица 2×1 + 2×2 Determinant Детерминанта 2×2 + 2×2 Empty Matrix Празна матрица 2×2 + 2×2 Identity Matrix Единична матрица 2×2 + 2×2 Identity Matrix (sparse) Единична матрица 2×2 (разредена) + 2×2 Matrix in Brackets Матрица 2×2 в квадратни скоби + 2×2 Matrix in Double Bars Матрица 2×2 в двойни черти + 2×2 Matrix in Parentheses Матрица 2×2 в кръгли скоби + 2×3 Empty Matrix Празна матрица 2×3 + 3×1 Empty Matrix Празна матрица 3×1 + 3×2 Empty Matrix Празна матрица 3×2 + 3×3 Empty Matrix Празна матрица 3×3 + 3×3 Identity Matrix Единична матрица 3×3 + 3×3 Identity Matrix (sparse) Единична матрица 3×3 (разредена) + A xor B A xor B + Absolute value cases Абсолютна стойност по случаи + Accents Диакритични знаци + Acute Остро ударение + Angle Brackets Ъглови скоби + Angle Brackets with Separator Ъглови скоби с разделител + Angle Brackets with Two Separators Ъглови скоби с два разделителя + Baseline Dots Точки на базовата линия + Basic Math Основна математика + Bayes' rule Формула на Бейс + Binomial Coefficient Биномен коефициент + Binomial Coefficient (angle) Биномен коефициент (ъглови скоби) + Binomial Coefficient with Angle Brackets Биномен коефициент в ъглови скоби + Binomial expansion of (x+a)ⁿ Биномно развитие на (x+a)ⁿ + Boxed Formula Формула в рамка + Boxed Formula (example) Формула в рамка (пример) + Boxed Formulas Формули в рамка + Braces Фигурни скоби + Braces with Separator Фигурни скоби с разделител + Brackets Скоби + Brackets with Separators Скоби с разделители + Breve Бреве + Cases (Three Conditions) Случаи (три условия) + Cases (Two Conditions) Случаи (две условия) + Cases and Stacks Случаи и наслагвания + Ceiling Горна цяла част + Change in y over change in x Изменение на y спрямо изменение на x + Check Хачек + Circle area from its radius Лице на кръг по радиуса му + Circled Dot Точка в кръг + Circled Plus Плюс в кръг + Circled Times Умножение в кръг + Colon Equal Двоеточие и равно + Common Accent Objects Често използвани диакритични знаци + Common Brackets Често използвани скоби + Common Fractions Често използвани дроби + Common Functions Често използвани функции + Common Large Operators Често използвани големи оператори + Common Operator Structures Често използвани операторни структури + Common Operators Често използвани оператори + Common Radicals Често използвани корени + Common Subscripts and Superscripts Често използвани долни и горни индекси + Contour Integral Контурен интеграл + Contour Integral with Limits Контурен интеграл с граници + Contour Integral with Stacked Limits Контурен интеграл с граници отгоре и отдолу + Contour Integrals Контурни интеграли + Coproduct Копроизведение + Coproduct with Limits Копроизведение с граници + Coproduct with Lower Limit Копроизведение с долна граница + Coproduct with Subscript-Superscript Limits Копроизведение с граници като индекси + Cosecant Function Функция косеканс + Cosine 2x Косинус от 2x + Cosine Function Функция косинус + Cotangent Function Функция котангенс + Cube Root Кубичен корен + Delta Equal To Делта равно + Delta over arrow Делта над стрелка + Derivative as a limit Производна като граница + Diagonal Dots Диагонални точки + Differential Диференциал + Differential theta Диференциал на тета + Differential x Диференциал на x + Differential y Диференциал на y + Differentials Диференциали + Dot Точка + Dots Точки + Double Bar Двойна черта + Double Dot Две точки + Double Integral Двоен интеграл + Double Integral with Limits Двоен интеграл с граници + Double Integral with Stacked Limits Двоен интеграл с граници отгоре и отдолу + Double Left Arrow with Text Above Двойна стрелка наляво с текст отгоре + Double Left Arrow with Text Below Двойна стрелка наляво с текст отдолу + Double Left Right Arrow with Text Above Двойна двупосочна стрелка с текст отгоре + Double Left Right Arrow with Text Below Двойна двупосочна стрелка с текст отдолу + Double Right Arrow with Text Above Двойна стрелка надясно с текст отгоре + Double Right Arrow with Text Below Двойна стрелка надясно с текст отдолу + Double Square Brackets Двойни квадратни скоби + Double Vertical Bars Двойни вертикални черти + Double summation Двойна сума + Empty Matrices Празни матрици + Equal Equal Двойно равно + Equal by Definition Равно по дефиниция + Euler's identity Тъждество на Ойлер + Exponential Експонента + Exponential series Експоненциален ред + Floor Долна цяла част + Functions Функции + Geometry Геометрия + Grave Тежко ударение + Greek Letters Гръцки букви + Grouping Character Above Групиращ знак отгоре + Grouping Character Below Групиращ знак отдолу + Hat Шапка + Hyperbolic Cosecant Function Функция хиперболичен косеканс + Hyperbolic Cosine Function Функция хиперболичен косинус + Hyperbolic Cotangent Function Функция хиперболичен котангенс + Hyperbolic Functions Хиперболични функции + Hyperbolic Secant Function Функция хиперболичен секанс + Hyperbolic Sine Function Функция хиперболичен синус + Hyperbolic Tangent Function Функция хиперболичен тангенс + Hypotenuse Хипотенуза + Identity Matrices Единични матрици + Integral of e^(−x²) Интеграл от e^(−x²) + Integral with Limits Интеграл с граници + Integral with Stacked Limits Интеграл с граници отгоре и отдолу + Integrals Интеграли + Intersection Сечение + Intersection with Limits Сечение с граници + Intersection with Lower Limit Сечение с долна граница + Intersection with Subscript-Superscript Limits Сечение с граници като индекси + Inverse Cosecant Function Функция аркускосеканс + Inverse Cosine Function Функция аркускосинус + Inverse Cotangent Function Функция аркускотангенс + Inverse Functions Обратни функции + Inverse Hyperbolic Cosecant Function Функция обратен хиперболичен косеканс + Inverse Hyperbolic Cosine Function Функция обратен хиперболичен косинус + Inverse Hyperbolic Cotangent Function Функция обратен хиперболичен котангенс + Inverse Hyperbolic Functions Обратни хиперболични функции + Inverse Hyperbolic Secant Function Функция обратен хиперболичен секанс + Inverse Hyperbolic Sine Function Функция обратен хиперболичен синус + Inverse Hyperbolic Tangent Function Функция обратен хиперболичен тангенс + Inverse Secant Function Функция аркуссеканс + Inverse Sine Function Функция аркуссинус + Inverse Tangent Function Функция аркустангенс + Large Големи + Left Arrow with Text Above Стрелка наляво с текст отгоре + Left Arrow with Text Below Стрелка наляво с текст отдолу + Left Bracket Twice Двойна лява скоба + Left Right Arrow Above Двупосочна стрелка отгоре + Left Right Arrow with Text Above Двупосочна стрелка с текст отгоре + Left Right Arrow with Text Below Двупосочна стрелка с текст отдолу + Left Subscript-Superscript Ляв долен и горен индекс + Leftwards Arrow Above Стрелка наляво отгоре + Leftwards Harpoon Above Полустрелка наляво отгоре + Letter-Like Symbols Буквоподобни символи + Limit Граница + Limit Example Пример за граница + Limit and Граница и + Log Логаритъм + Linear Fraction Линейна дроб + Logarithm Логаритъм + Logarithm with Base Логаритъм с основа + Logical AND Логическо И + Logical AND with Limits Логическо И с граници + Logical OR Логическо ИЛИ + Logical OR with Limits Логическо ИЛИ с граници + Matrices with Brackets Матрици в скоби + Maximum Максимум + Maximum Example Пример за максимум + Measured By Измерено чрез + Midline Dots Точки на средната линия + Minimum Минимум + Minus Equal Минус равно + Natural Logarithm Натурален логаритъм + Negated Relations Отрицания на релации + Normal probability density Плътност на нормалното разпределение + One half Една втора + Operator Structures Операторни структури + Operators Оператори + Other Large Operators Други големи оператори + Outward Brackets Обърнати скоби + Overbar Черта отгоре + Overbars and Underbars Черти отгоре и отдолу + Overbrace Фигурна скоба отгоре + Parentheses Кръгли скоби + Parentheses with Separator Кръгли скоби с разделител + Partial Differential Частен диференциал + Pi Over 2 Пи върху 2 + Plus Equal Плюс равно + Power series of (1+x)ⁿ Степенен ред на (1+x)ⁿ + Product Произведение + Product of A Произведение на A + Product with Limits Произведение с граници + Product with Lower Limit Произведение с долна граница + Product with Subscript-Superscript Limits Произведение с граници като индекси + Products and Coproducts Произведения и копроизведения + Quadratic Root Корен на квадратно уравнение + Radical with Degree Корен с показател + Radicals Корени + Right Arrow with Text Above Стрелка надясно с текст отгоре + Right Arrow with Text Below Стрелка надясно с текст отдолу + Right Bracket Twice Двойна дясна скоба + Rightwards Arrow Above Стрелка надясно отгоре + Rightwards Harpoon Above Полустрелка надясно отгоре + Roots of ax²+bx+c Корени на ax²+bx+c + Scripts Индекси + Secant Function Функция секанс + Sides of a right triangle Страни на правоъгълен триъгълник + Sine Function Функция синус + Sine and cosine series of f(x) Ред по синуси и косинуси на f(x) + Sine theta Синус от тета + Single Brackets Единични скоби + Single Left Angle Единична лява ъглова скоба + Single Left Bar Единична лява черта + Single Left Brace Единична лява фигурна скоба + Single Left Bracket Единична лява квадратна скоба + Single Left Parenthesis Единична лява кръгла скоба + Single Right Brace Единична дясна фигурна скоба + Single Right Bracket Единична дясна квадратна скоба + Single Right Parenthesis Единична дясна кръгла скоба + Skewed Fraction Наклонена дроб + Small change Малко изменение + Sparse Matrices Разредени матрици + Square Root Квадратен корен + Square Root with Degree Корен с показател + Square Union Квадратно обединение + Stack Object Наслоен обект + Stack Object (Three) Наслоен обект (три реда) + Stacked Fraction Вертикална дроб + Stacked, no bar Една над друга, без черта + Subscript-Superscript Долен и горен индекс + Subscripts and Superscripts Долни и горни индекси + Sum of cosines Сума от косинуси + Sum or difference of sines Сума или разлика от синуси + Summation Сума + Summation from 0 to n Сума от 0 до n + Summation over binomials Сума от биномни коефициенти + Summation with Limits Сума с граници + Summation with Lower Limit Сума с долна граница + Summation with Subscript Limit Сума с граница като индекс + Summation with Subscript-Superscript Limits Сума с граници като индекси + Summations Суми + Surface Integral Повърхнинен интеграл + Surface Integral with Limits Повърхнинен интеграл с граници + Surface Integral with Stacked Limits Повърхнинен интеграл с граници отгоре и отдолу + Tangent Function Функция тангенс + Tangent formula Формула за тангенс + Text over Colon Equal Текст над двоеточие и равно + Text over Equal Текст над равно + Tilde Тилда + Trigonometric Functions Тригонометрични функции + Triple Dot Три точки + Triple Integral Троен интеграл + Triple Integral with Limits Троен интеграл с граници + Triple Integral with Stacked Limits Троен интеграл с граници отгоре и отдолу + Underbar Черта отдолу + Underbrace Фигурна скоба отдолу + Union Обединение + Union of intersections Обединение на сечения + Union with Limits Обединение с граници + Union with Lower Limit Обединение с долна граница + Union with Plus Обединение с плюс + Union with Subscript-Superscript Limits Обединение с граници като индекси + Unions and Intersections Обединения и сечения + Up Diagonal Dots Възходящи диагонални точки + Vector A Вектор A + Vector AB Вектор AB + Vertical Bars Вертикални черти + Vertical Dots Вертикални точки + Volume Integral Обемен интеграл + Volume Integral with Limits Обемен интеграл с граници + Volume Integral with Stacked Limits Обемен интеграл с граници отгоре и отдолу + Yields Води до + n×n Identity Matrix Единична матрица n×n + n×n Matrix Матрица n×n + x bar x с черта + APA APA + ASAP СПЕШНО + AaBbCcDd АаБбВвГг + All - Linear Всички – линеен формат + All - Professional Всички – професионален формат + B4 (JIS) 10.12" × 14.33" B4 (JIS) 10.12" × 14.33" + B5 (JIS) 7.17" × 10.12" B5 (JIS) 7.17" × 10.12" + Body text in a short paragraph to show spacing. Основен текст в кратък абзац, който показва разстоянията. + Book Title Заглавие на книга + CONFIDENTIAL ПОВЕРИТЕЛНО + Category Категория + Centered as Group Центрирано като група + Change to Level 1 Промяна към ниво 1 + Change to Level 2 Промяна към ниво 2 + Change to Level 3 Промяна към ниво 3 + Change to Level 4 Промяна към ниво 4 + Change to Level 5 Промяна към ниво 5 + Chicago Chicago + Contributors Сътрудници + Convert between built-up and linear form Превключване между професионален и линеен формат + Current - Linear Текущо – линеен формат + Current - Professional Текущо – професионален формат + DO NOT COPY НЕ КОПИРАЙТЕ + DRAFT ЧЕРНОВА + Emphasis Акцент + Endnote Reference Препратка към бележка в края + Endnote Text Текст на бележка в края + Envelope #10 4.12" × 9.50" Плик №10 4.12" × 9.50" + Envelope DL 4.33" × 8.66" Плик DL 4.33" × 8.66" + Executive 7.25" × 10.50" Executive 7.25" × 10.50" + Footnote Reference Препратка към бележка под линия + Footnote Text Текст на бележка под линия + GB/T 7714 GB/T 7714 + GOST (name sort) GOST (сортиране по име) + GOST (title sort) GOST (сортиране по заглавие) + Good evening Добър вечер + Grid Table 1 Light Таблица с мрежа 1, светла + Grid Table 4 – Blue Таблица с мрежа 4 – синя + Grid Table 4 – Green Таблица с мрежа 4 – зелена + Grid Table 4 – Orange Таблица с мрежа 4 – оранжева + Grid Table 4 – Purple Таблица с мрежа 4 – лилава + Harvard (Anglia) Harvard (Anglia) + Hyperlink Хипервръзка + IEEE IEEE + ISO 690 (author-date) ISO 690 (автор и дата) + ISO 690 (numerical) ISO 690 (числов) + Intense Emphasis Силен акцент + Intense Quote Изразен цитат + Intense Reference Изразена препратка + Justification of a display equation Подравняване на уравнение на отделен ред + Keywords Ключови думи + Legal 8.50" × 14.00" Legal 8.50" × 14.00" + Letter 8.50" × 11.00" Letter 8.50" × 11.00" + Level 1 Ниво 1 + Level 2 Ниво 2 + Level 3 Ниво 3 + List Paragraph Абзац от списък + List Table 3 – Blue Таблица със списък 3 – синя + List Table 3 – Green Таблица със списък 3 – зелена + List Table 3 – Orange Таблица със списък 3 – оранжева + List Table 3 – Purple Таблица със списък 3 – лилава + MLA MLA + Models Модели + No Spacing Без разредка + Normal Нормален + Number the equation; the number sits at the right margin (or type # then the number) Номериране на уравнението; номерът стои до дясното поле (или въведете # и номера) + Plain Table 1 Обикновена таблица 1 + Quote Цитат + SAMPLE ОБРАЗЕЦ + SIST02 SIST02 + Show the equation in the line of text Показване на уравнението в реда на текста + Show the equation on a line of its own Показване на уравнението на отделен ред + Statement 5.50" × 8.50" Statement 5.50" × 8.50" + Strong Удебелен + Subject Тема + Subtitle Подзаглавие + Subtle Emphasis Лек акцент + Subtle Reference Лека препратка + TOC Heading Заглавие на съдържание + Table Grid Мрежа на таблица + Tabloid 11.00" × 17.00" Tabloid 11.00" × 17.00" + Turabian Turabian + Type equations in LaTeX Въвеждане на уравнения в LaTeX + Type equations in the linear format (UnicodeMath) Въвеждане на уравнения в линеен формат (UnicodeMath) + Type normal (non-math) text in the equation Въвеждане на обикновен (нематематически) текст в уравнението + URGENT НЕОТЛОЖНО + tOGGLE cASE сМЯНА нА рЕГИСТЪРА + ✓ None ✓ Няма + As you type Докато въвеждате + Asian text font: Шрифт за азиатски текст: + AutoSave writes your changes to the file a moment after you stop typing. Автоматичното запазване записва промените във файла малко след като спрете да въвеждате. + Centimeters Сантиметри + Check spelling as you type Проверка на правописа при въвеждане + Click to switch back to inserting Щракнете, за да се върнете към вмъкване + Custom dictionary Потребителски речник + Formatting marks Знаци за форматиране + Hidden text Скрит текст + Ignore web and file addresses Пренебрегване на уеб и файлови адреси + Ignore words in capitals Пренебрегване на думи с главни букви + Ignore words with numbers Пренебрегване на думи с цифри + Inches Инчове + Formatting exceptions Изключения за форматирането + Initials: Инициали: + Interface Интерфейс + Interface language Език на интерфейса + Inside: Вътре: + Latin text font: Шрифт за латински текст: + Mark grammar errors as you type Отбелязване на граматичните грешки при въвеждане + Measurement units: Мерни единици: + Millimeters Милиметри + New comments and tracked changes are recorded under this name and these initials. Новите коментари и проследени промени се записват с това име и тези инициали. + New word Нова дума + No words yet. Add to Dictionary on a misspelled word puts it here. Все още няма думи. „Добавяне в речника“ върху сгрешена дума я добавя тук. + Mirror indents Огледални отстъпи + Overtype Заместване + Overtype (typing replaces the characters after the caret) Заместване (въвежданият текст замества знаците след курсора) + Page display Показване на страницата + Paragraph marks Знаци за абзац + Personalize Персонализиране + Picas Пики + Points Пунктове + Print the page color Отпечатване на цвета на страницата + Printing Печат + Proofing language Език за проверка + Recent documents to show: Брой показвани последни документи: + Outside: Отвън: + Save documents Запазване на документи + Show the mini toolbar when text is selected Показване на мини лентата с инструменти при избран текст + Spaces Интервали + Spelling Правопис + Spelling and grammar are checked in English (United States). Правописът и граматиката се проверяват на английски (САЩ). + Tab characters Знаци за табулация + These marks show even when Show/Hide ¶ is off. Тези знаци се показват дори когато „Показване/скриване на ¶“ е изключено. + Typing replaces selected text Въвежданият текст замества избрания текст + Update fields before printing Актуализиране на полетата преди печат + Use the Insert key to switch overtype on and off Използване на клавиша Insert за включване и изключване на заместването + Extend Selection by Units Разширяване на селекцията по единици + Word XML Document (*.xml) XML документ на Word (*.xml) + Deleted Изтрито + 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 и текст. + 1. Formatting restrictions 1. Ограничения за форматирането + 2. Editing restrictions 2. Ограничения за редактирането + 3. Start enforcement 3. Започване на прилагането + << Less << По-малко + Another file Друг файл + Apply as you type Прилагане при въвеждане + AutoCorrect Автокорекция + AutoFormat As You Type Автоформатиране при въвеждане + Automatic bulleted lists Автоматични списъци с водещи символи + Automatic numbered lists Автоматични номерирани списъци + Border lines Гранични линии + Capitalize first letter of sentences Главна буква в началото на изреченията + Capitalize first letter of table cells Главна буква в началото на клетките на таблици + Capitalize names of days Главни букви в имената на дните + Case changes Промени в регистъра + Character level Ниво знак + Checked styles are allowed: Позволени са отметнатите стилове: + Combine Documents Комбиниране на документи + Compare Documents Сравняване на документи + Compared: {changes} changes Сравнено. Промени: {changes} + Comparing with another file needs the desktop app. Сравняването с друг файл изисква настолното приложение. + Comparison settings Настройки за сравняване + Don't capitalize after: Без главна буква след: + Editing in this document is restricted. Редактирането в този документ е ограничено. + Every change is recorded as a tracked change. Всяка промяна се записва като проследена промяна. + Everyone Всички + Exceptions (optional) Изключения (по избор) + Exceptions… Изключения… + Fields Полета + Filling in forms Попълване на формуляри + Find Next Editable Region Намиране на следващата редактируема област + Footnotes and endnotes Бележки под линия и в края + Formatting, case changes, white space and tables are compared; the other settings are kept for later. Сравняват се форматирането, промените в регистъра, празното пространство и таблиците; другите настройки се запазват за по-късно. + Fractions (1/2) as fraction characters (½) Дроби (1/2) като знаци за дроби (½) + Headers and footers Горни и долни колонтитули + Hyphens (--) as dashes (—) Тирета (--) като дълги тирета (—) + In equations, typing a name after a backslash replaces it with its symbol. В уравненията име, въведено след обратна наклонена черта, се заменя със своя символ. + Internet addresses as links Интернет адреси като връзки + Label changes with: Обозначаване на промените с: + Limit formatting to a selection of styles Ограничаване на форматирането до избрани стилове + Math AutoCorrect Автокорекция на математически изрази + More >> Още >> + Moves Премествания + New document Нов документ + No changes (read only) Без промени (само за четене) + Only allow this kind of editing: Разрешаване само на този вид редактиране: + Only comments can be added, except in regions marked editable. Могат да се добавят само коментари, освен в областите, отбелязани като редактируеми. + Only form fields can be filled in. Могат да се попълват само полета на формуляри. + Only the allowed styles can be applied. Могат да се прилагат само разрешените стилове. + Ordinals (1st) as superscript Поредни числителни (1-ви) като горен индекс + Original document Оригинален документ + This password type isn't supported. Този тип парола не се поддържа. + Replace as you type Замяна при въвеждане + Replace text as you type Замяна на текст при въвеждане + Replace: Замяна: + Revised document Редактиран документ + Select text, then tick who can still edit it. Изберете текст, след което отметнете кой още може да го редактира. + Settings… Настройки… + Show changes at: Показване на промените на ниво: + Show changes in: Показване на промените в: + Smart quotes instead of straight quotes Типографски кавички вместо прави кавички + Start Enforcing Protection Започване на прилагането на защитата + Stop Protection Спиране на защитата + Text boxes Текстови полета + The document is read only, except for regions marked editable. Документът е само за четене, с изключение на областите, отбелязани като редактируеми. + The password is optional; without it anyone can stop the protection. Паролата не е задължителна; без нея всеки може да спре защитата. + This document Този документ + Tracked changes Проследени промени + Type the password to stop the protection. Въведете паролата, за да спрете защитата. + White space Празно пространство + With: С: + Word level Ниво дума + Add Font… Добавяне на шрифт… + Added Fonts Добавени шрифтове + Remove Added Font Премахване на добавен шрифт + Recent Colors Последни цветове + More Colors… Още цветове… + Standard Стандартни + Current Текущ + Colors: Цветове: + Color model: Цветови модел: + Red: Червено: + Green: Зелено: + Blue: Синьо: + Hue: Нюанс: + Sat: Насит.: + Lum: Ярк.: + Hex: Hex: + Templates Шаблони + Templates and Add-ins Шаблони и добавки + Attach Template Прикачване на шаблон + Detach Template Откачване на шаблон + Automatically Update Document Styles Автоматично актуализиране на стиловете на документа + Update Styles from Template Актуализиране на стиловете от шаблона + Organizer Организатор + Copy Styles Копиране на стилове + Rename Style Преименуване на стил + Document template: Шаблон на документа: + Attach… Прикачване… + Automatically update document styles Автоматично актуализиране на стиловете на документа + Organizer… Организатор… + Hide White Space Скриване на празното пространство + All pages Всички страници + Current page Текущата страница + Selected pages Избраните страници + Custom range Потребителски диапазон + All pages in the range Всички страници в диапазона + Odd pages only Само нечетните страници + Even pages only Само четните страници + Copies Копия + Collate copies Подреждане на копията + Pages like 1-3, 5 Страници, напр. 1-3, 5 + {count} of {total} pages will print Ще се отпечатат страници: {count} от {total} + Save these pages as PDF… Запазване на тези страници като PDF… + The printer, two-sided printing and pages per sheet are chosen in that dialog. Принтерът, двустранният печат и броят страници на лист се избират в този диалогов прозорец. + Previous Page Предишна страница + Print to PDF Печат в PDF + Add Shape Добавяне на фигура + Change Colors Смяна на цветовете + Change Layout Смяна на оформлението + Create Graphic Създаване на графика + Edit Items Редактиране на елементите + Layouts Оформления + Reset Graphic Нулиране на графиката + SmartArt Design Дизайн на SmartArt + SmartArt Styles Стилове на SmartArt + Text Pane Текстов панел + List Списък + Process Процес + Cycle Цикъл + Hierarchy Йерархия + Relationship Връзка + Pyramid Пирамида + Basic List Основен списък + Basic Process Основен процес + Basic Cycle Основен цикъл + Basic Pyramid Основна пирамида + Basic Radial Основна радиална диаграма + Basic Matrix Основна матрица + Basic Venn Основна диаграма на Вен + Shows items of equal weight as blocks in rows. Показва елементи с еднаква тежест като блокове в редове. + Shows steps in order, left to right, with arrows between them. Показва стъпки в ред отляво надясно със стрелки между тях. + Shows steps that repeat, around a circle. Показва повтарящи се стъпки в кръг. + Shows a tree: each item below the item it belongs to. Показва дърво: всеки елемент е под елемента, към който принадлежи. + Shows parts that build on each other, largest at the bottom. Показва части, които надграждат една върху друга, като най-голямата е долу. + Shows items around a central idea: the first item is the centre. Показва елементи около централна идея: първият елемент е центърът. + Shows parts of a whole as quadrants. Показва части от цяло като квадранти. + Shows overlapping ideas as overlapping circles. Показва застъпващи се идеи като застъпващи се кръгове. + Accent {n} Акцент {n} + Colorful Цветни + Insert SmartArt Вмъкване на SmartArt + One line per item. Enter adds an item. По един ред за елемент. Enter добавя елемент. + Add\nShape Добавяне\nна фигура + Change\nColors Смяна\nна цветовете + Reset\nGraphic Нулиране\nна графиката + Promote to Heading 1 Повишаване до Заглавие 1 + Demote to Body Text Понижаване до основен текст + Move Up Преместване нагоре + Move Down Преместване надолу + Collapse Свиване + Expand or Collapse Heading Разгъване или свиване на заглавието + Select Heading and Content Избиране на заглавието и съдържанието + Show Level Показване на ниво + Show Level 1 Показване на ниво 1 + Show Level 2 Показване на ниво 2 + Show Level 3 Показване на ниво 3 + Show Level 4 Показване на ниво 4 + Show Level 5 Показване на ниво 5 + Show Level 6 Показване на ниво 6 + Show Level 7 Показване на ниво 7 + Show Level 8 Показване на ниво 8 + Show Level 9 Показване на ниво 9 + Show All Levels Показване на всички нива + All Levels Всички нива + Show First Line Only Показване само на първия ред + Show Text Formatting Показване на форматирането на текста + Close Outline View Затваряне на изгледа на структурата + Outline State Състояние на структурата + Outlining Структуриране + Outline Tools Инструменти за структура + Close Outline\nView Затваряне на изгледа\nна структурата + Use wildcards Използване на заместващи символи + Special Специални + Match prefix Съвпадение на представката + Match suffix Съвпадение на наставката + Ignore punctuation characters Пренебрегване на пунктуационните знаци + Ignore white-space characters Пренебрегване на празните знаци + Regular expressions Регулярни изрази + Search Options Опции за търсене + Tab Character Знак за табулация + Any Character Произволен знак + Any Digit Произволна цифра + Any Letter Произволна буква + Caret Character Знак ^ + Section Character Знак за раздел + Paragraph Character Знак за абзац + Graphic Графика + Manual Line Break Ръчен нов ред + Manual Page Break Ръчна нова страница + Section Break Прекъсване на раздел + White Space Празно пространство + Clipboard Contents Съдържание на клипборда + Find What Text Текст за търсене + Find What Expression Израз за търсене + Character in Range Знак от диапазон + Beginning of Word Начало на дума + End of Word Край на дума + Expression Израз + Not Не + Num Occurrences Брой повторения + Previous 1 or More Предходният 1 или повече пъти + 0 or More Characters 0 или повече знака + Move with Text Преместване с текста + Fix Position on Page Фиксирана позиция на страницата + Good morning Добро утро + Animation Анимация + Animate the caret (it glides and fades) Анимиране на курсора (плъзга се и избледнява) + On: the caret glides to its new place when you type or use the arrow keys, and its blink fades. Off: it jumps and blinks on and off. Включено: курсорът се плъзга до новото си място, когато пишете или използвате стрелките, а мигането му избледнява. Изключено: курсорът прескача и мига рязко. + Documents Документи + Word document Документ на Word + All files Всички файлове + Recipient lists Списъци с получатели + The system file dialog isn't available, so WordCraft shows its own. To get the system dialog, install a file chooser portal (such as xdg-desktop-portal-gtk) or zenity. Системният диалогов прозорец за файлове не е наличен, затова WordCraft показва своя. За да получите системния, инсталирайте портал за избор на файлове (например xdg-desktop-portal-gtk) или zenity. + Up one level Едно ниво нагоре + Home folder Домашна папка + {name} wasn't found. {name} не е намерен. + {name} already exists. Choose Save again to replace it. {name} вече съществува. Изберете „Запазване“ отново, за да го замените. + This folder can't be read: {error} Тази папка не може да бъде прочетена: {error} + Showing the first {count} items. Показани са първите {count} елемента. + File name: Име на файла: + Browse in WordCraft… Разглеждане в WordCraft… + Open a file with WordCraft's own browser when the system file dialog doesn't appear. Отваряне на файл чрез собствения браузър на WordCraft, когато системният диалогов прозорец за файлове не се появява. + Resize Table by Dragging Преоразмеряване на таблица чрез плъзгане + Visual effects Визуални ефекти + Animation effects: Ефекти на анимация: + On Включено + Off Изключено + Smooth scrolling and zooming, and menus and buttons that fade in. Off shows every change at once. Плавно превъртане и мащабиране, и менюта и бутони, които се появяват постепенно. При „Изключено“ всяка промяна се показва веднага. + Your system has animation effects turned on. Ефектите на анимация в системата са включени. + Your system has animation effects turned off. Ефектите на анимация в системата са изключени. + Your system doesn't report an animation setting, so animation effects stay on. Системата не съобщава настройка за анимацията, затова ефектите остават включени. + Annotation Reference Препратка към коментар + Annotation Subject Тема на коментар + Annotation Text Текст на коментар + Default Paragraph Font Шрифт на абзаца по подразбиране + Envelope Address Адрес на получателя върху плик + Envelope Return Адрес на подателя върху плик + Index Heading Заглавие на индекс + Index {n} Индекс {n} + Line Number Номер на ред + Normal Indent Нормален с отстъп + Normal Table Нормална таблица + Table of Figures Списък на фигурите + TOA Heading Заглавие на списъка на правните източници + TOC {n} Съдържание {n} + Good afternoon Добър ден + Context Според контекста + Couldn't make WordCraft the default: {error} WordCraft не можа да стане приложение по подразбиране: {error} + Do Not Check Spelling or Grammar Без проверка на правописа и граматиката + Hindi Хинди + In Default apps, choose WordCraft for the document types you want it to open. В „Приложения по подразбиране“ изберете WordCraft за типовете документи, които искате да отваря. + Make WordCraft the default for Word documents… WordCraft по подразбиране за документи на Word… + Numerals Цифри + Numerals: Цифри: + Opens .docx, .doc, .rtf and .odt files and Word templates with WordCraft from now on. Отсега нататък файловете .docx, .doc, .rtf и .odt и шаблоните на Word ще се отварят с WordCraft. + Opens Settings › Default apps, where you choose WordCraft for .docx and the other document types. Отваря „Настройки › Приложения по подразбиране“, където избирате WordCraft за .docx и другите типове документи. + Reading Четене + Reading Column Width Ширина на колоната при четене + Reading Line Spacing Междуредие при четене + Reading Text Size Размер на текста при четене + Show KeyTips (Alt or F10) Показване на клавишните подсказки (Alt или F10) + System Според системата + To open Word documents with WordCraft by default: select one in Finder, choose File › Get Info, pick WordCraft under Open with, then click Change All. За да се отварят документите на Word с WordCraft по подразбиране: изберете документ във Finder, изберете File › Get Info, изберете WordCraft под Open with и щракнете Change All. + WordCraft is now the default app for Word documents. WordCraft вече е приложението по подразбиране за документи на Word. + Correct TWo INitial CApitals Коригиране на ДВе НАчални ГЛавни букви + Correct accidental usage of cAPS LOCK key Коригиране на случайно използване на клавиша cAPS LOCK diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 35b585ade..902a1cf55 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -52,7 +52,7 @@ pub struct LangInfo { } /// The registry. English first: it is the fallback and the source language. -pub static LANGUAGES: [LangInfo; 18] = [ +pub static LANGUAGES: [LangInfo; 19] = [ LangInfo { code: "en", name: "English", source: "", prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, // Simplified Chinese; `zh`, `zh-CN`, `zh-SG` and `zh-Hans-*` resolve here (see `candidates`). LangInfo { @@ -125,6 +125,10 @@ pub static LANGUAGES: [LangInfo; 18] = [ LangInfo { code: "hu", name: "Magyar", source: include_str!("hu.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, // French; `fr`, `fr-FR`, `fr_CA` and other regions resolve here. LangInfo { code: "fr", name: "Français", source: include_str!("fr.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, + // Bulgarian; `bg`, `bg-BG` and `bg_BG.UTF-8` resolve here. + LangInfo { + code: "bg", name: "Български", source: include_str!("bg.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() + }, ]; impl LangInfo { diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index db07c8604..a33d62bb7 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -919,9 +919,9 @@ fn polish_covers_every_key_of_every_other_catalog() { use std::collections::HashSet; let keys = |source| parse_entries(source).0.into_iter().map(|e| e.source).collect::>(); let polish = keys(lang("pl").0.source); - // Russian, Italian, Hungarian and Turkish also translate the built-in style names and the equation gallery, - // which the others don't yet; Polish matches every other catalog. - for other in Lang::all().filter(|l| *l != Lang::EN && !matches!(l.code(), "pl" | "ru" | "it" | "hu" | "tr")) { + // Russian, Italian, Hungarian, Turkish and Bulgarian also translate the built-in style names + // and the equation gallery, which the others don't yet; Polish matches every other catalog. + for other in Lang::all().filter(|l| *l != Lang::EN && !matches!(l.code(), "pl" | "ru" | "it" | "hu" | "tr" | "bg")) { let mut missing: Vec = keys(other.0.source).difference(&polish).cloned().collect(); missing.sort(); assert!(missing.is_empty(), "pl lacks {}'s {missing:?}", other.code()); @@ -1109,3 +1109,80 @@ fn bundled_interface_fonts_cover_italian_letters() { } } } + +#[test] +fn bulgarian_locales_and_saved_preference_work_without_changing_the_document() { + let bg = lang("bg"); + for tag in ["bg", "bg-BG", "bg_BG.UTF-8", "BG-bg", "bg-Cyrl-BG"] { + assert_eq!(lang_from_tag(tag), Some(bg), "{tag}"); + } + assert_eq!(first_supported(["ko-KR", "bg-BG", "en-US"]), Some(bg)); + let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + let before = serde_json::to_value(&app.session.doc).unwrap(); + let result = app.run("ui.language", serde_json::json!({"value": "BG"})).unwrap(); + assert_eq!(result["effective"], "bg"); + assert_eq!(app.ui.language, "bg"); + assert!(result["available"].as_array().unwrap().iter().any(|l| l["code"] == "bg" && l["name"] == "Български")); + assert_eq!(serde_json::to_value(&app.session.doc).unwrap(), before); + let saved = serde_json::to_string(&app.ui).unwrap(); + let restored: crate::UiState = serde_json::from_str(&saved).unwrap(); + assert_eq!(Lang::from_pref(&restored.language), bg); + assert_eq!(bg.name(), "Български"); +} + +#[test] +fn bulgarian_covers_the_entire_existing_interface_catalog() { + use std::collections::HashSet; + let keys = |source| parse_entries(source).0.into_iter().map(|e| e.source).collect::>(); + // Every key the interface looks up (Spanish carries them all), and no catalog errors. + assert!(keys(lang("bg").0.source).is_superset(&keys(lang("es").0.source))); + assert!(parse_entries(lang("bg").0.source).1.is_empty()); + let bg = lang("bg"); + for (source, expected) in [ + ("Home", "Начало"), + ("Insert", "Вмъкване"), + ("Font", "Шрифт"), + ("Paragraph", "Абзац"), + ("Review", "Преглед"), + ("Save", "Запазване"), + ("Heading 1", "Заглавие 1"), + ("Hyphenation", "Сричкопренасяне"), + ("Track Changes", "Проследяване на промените"), + ("WordCraft", "WordCraft"), + ("unknown future label", "unknown future label"), + ] { + assert_eq!(tr(bg, source), expected, "{source}"); + } + // The search hint's example must find the command it names. + assert_eq!(tr(bg, "Insert Table"), "Вмъкване на таблица"); + assert!(tr(bg, "Type a command, e.g. \"insert table\"").contains("вмъкване на таблица")); + assert_eq!(fmt(tr(bg, "Page {page} of {pages}"), &[("page", "3"), ("pages", "9")]), "Страница 3 от 9"); + // Count-neutral wording is grammatical for every count: `Думи: 21`, `Думи: 1 от 21`. + for count in [0, 1, 2, 5, 21, 100, 1000] { + let words = count.to_string(); + assert_eq!(fmt(tr(bg, "{words} words"), &[("words", &words)]), format!("Думи: {count}")); + assert_eq!(fmt(tr(bg, "{selected} of {words} words"), &[("selected", "1"), ("words", &words)]), format!("Думи: 1 от {count}")); + } +} + +#[test] +fn bundled_interface_fonts_cover_bulgarian_without_system_fallbacks() { + let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new()); + let (entries, errors) = parse_entries(lang("bg").0.source); + assert!(errors.is_empty()); + let chars: std::collections::HashSet = entries + .iter() + .flat_map(|e| e.translation.chars()) + // Cyrillic letters plus the Bulgarian quotes and dash; symbols come from the English sources. + .filter(|c| ('\u{0400}'..='\u{04FF}').contains(c) || matches!(c, '„' | '“' | '—' | '–')) + .chain("АБВГДЕЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЬЮЯабвгдежзийклмнопрстуфхцчшщъьюя".chars()) + .collect(); + for (family, style) in [("Inter", "Regular"), ("Inter", "Medium"), ("Inter", "SemiBold"), ("JetBrains Mono", "Regular")] { + let face = db.face(family, style); + assert_eq!(face.family, family, "must use the bundled interface face"); + for ch in &chars { + assert_ne!(face.glyph_for(*ch), 0, "{family} {style} lacks {ch}"); + } + } + assert!(!lang("bg").uses_cjk()); +} diff --git a/docs/localization-parity.md b/docs/localization-parity.md index ab4d9f405..a3a64330b 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -19,9 +19,9 @@ native-speaker review (human). ## What WordCraft ships (measured) -- **18 interface languages**: English plus `zh-hans`, `zh-hant`, `ja`, `uk`, `es`, `pt-br`, - Serbian `sr`/`sr-latn` (#250), Estonian `et`, Turkish `tr`, Russian `ru`, Italian `it` (#594), German `de` (#574), Polish `pl`, Czech `cs`, Hungarian `hu` and French `fr`. Catalogs have 2,021–2,419 entries - each; Polish has every key of every other catalog except the style names and equation gallery labels only Russian, Italian, Hungarian and Turkish translate so far (a test checks it). A test (`every_tab_and_command_is_translated`) enforces that every ribbon tab, command +- **19 interface languages**: English plus `zh-hans`, `zh-hant`, `ja`, `uk`, `es`, `pt-br`, + Serbian `sr`/`sr-latn` (#250), Estonian `et`, Turkish `tr`, Russian `ru`, Italian `it` (#594), German `de` (#574), Polish `pl`, Czech `cs`, Hungarian `hu`, French `fr` and Bulgarian `bg`. Catalogs have 2,021–2,419 entries + each; Polish has every key of every other catalog except the style names and equation gallery labels only Russian, Italian, Hungarian, Turkish and Bulgarian translate so far (a test checks it). A test (`every_tab_and_command_is_translated`) enforces that every ribbon tab, command label and ribbon location is translated in every language. `pl`, `pl-PL` and `pl_PL.UTF-8` system locales pick Polish automatically. - Of the 675 English strings a scan extracts from `tl!(…)` calls and command labels/locations, each @@ -57,8 +57,8 @@ shared items below. | Korean | ko | 0 | ❌ | Hangul ✅; macOS Korean IME fixes pending (#163, #164) | no | none | 3–5 | | Vietnamese | vi | 0 | ❌ | Latin with stacked diacritics, untested | no | none | 3–4 | -Other shipped languages: **12** — Traditional Chinese (`zh-hant`), Ukrainian (`uk`), Serbian -Cyrillic and Latin (`sr`, `sr-latn`, #250), Estonian (`et`), Turkish (`tr`), Russian (`ru`), Italian (`it`, #594), Polish (`pl`), Czech (`cs`), Hungarian (`hu`) and French (`fr`), all partial. +Other shipped languages: **13** — Traditional Chinese (`zh-hant`), Ukrainian (`uk`), Serbian +Cyrillic and Latin (`sr`, `sr-latn`, #250), Estonian (`et`), Turkish (`tr`), Russian (`ru`), Italian (`it`, #594), Polish (`pl`), Czech (`cs`), Hungarian (`hu`), French (`fr`) and Bulgarian (`bg`), all partial. Estonian uses the existing fonts, follows `et-EE` system locales, and is available in Options or through `ui.language` with `{"value":"et"}`. Its 1,307 entries cover the existing interface catalog. Estonian spelling, grammar, dates and document templates are not added by the interface catalog. @@ -66,6 +66,10 @@ Turkish works the same way (`tr-TR` and `tr-CY` system locales, `{"value":"tr"}` entries, the same keys as the Russian catalog. Command search folds the Turkish dotted and dotless I (`italik` finds İtalik, `YAZI TİPİ` finds Yazı Tipi) while English labels and ASCII command ids keep their usual matching. Turkish proofing is not included; terminology and checks: [`localization-tr.md`](localization-tr.md). +Bulgarian (`bg-BG` system locales, `{"value":"bg"}`) has 2,419 entries: every key of the reference +catalog, the equation gallery's labels and everything the interface audit finds (the audit checks +Bulgarian as it does Russian). Counts use count-neutral wording (`Думи: 21`). Bulgarian text is +hyphenated by the Bulgarian syllable rules (#826); Bulgarian spelling and grammar are not included. Hungarian follows `hu-HU` system locales and `{"value":"hu"}`, with a complete catalog of 2,419 entries: every key of the Russian catalog, built-in style names shown translated, plus the citation style names. Hungarian proofing is not included; terminology and conventions: [`localization-hu.md`](localization-hu.md). @@ -117,6 +121,7 @@ allows); all other text is written from the meaning of the English. No Italian p | 2026-10-11 | minor | Font list shows installed fonts by their names in the interface language (宋体 for SimSun in Chinese, Japanese and Korean names likewise) and finds them by either name; a Chinese, Japanese or Korean font is stored as `w:eastAsia` too (#505) | | 2026-10-11 | minor | Chinese/Japanese interface fonts: a test checks every catalog character has a glyph; an installed CJK font follows the embedded faces whenever they miss one; macOS font asset folders searched for PingFang (#486) | | 2026-10-10 | minor | Russian interface (#300; 1,524 entries, built-in style names shown translated; covers everything the interface audit finds) | +| 2026-10-12 | minor | Bulgarian interface catalog (2,419 entries), locale selection, saved preference, audit coverage and font checks | | 2026-10-11 | minor | Hungarian interface catalog (2,419 entries, built-in style names shown translated; covers everything the interface audit finds), locale selection, saved preference and font coverage checks | | 2026-10-11 | minor | Turkish interface catalog (1,307 entries), locale selection, saved preference, Turkish casing in command search and font coverage checks; catalog sizes re-counted | | 2026-10-11 | trivial | English spelling (#520): our supplement `assets/spelling/en-extra.txt` adds ~220 common modern words the Moby list lacks ("desktop", "download", "email"…) | From 18e25d0883a89511482a2e5de1db07f01b4b6da9 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:01:25 +0200 Subject: [PATCH 46/88] ODT import reads page size, orientation and margins from styles.xml (#810) (#829) * ODT import reads page size, orientation and margins from styles.xml (#810) * Review fixes for #829: clamp ODT page size and margins Page width/height from styles.xml are kept to 36..=1584 pt (the DOCX reader's floor, Word's 22-inch maximum) and each margin is capped at the page's longer side, like w:pgMar. Adds a hostile-values test and collapses a nested if for clippy. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/formats/src/odt.rs | 119 ++++++++++++++++++++++++++++++++++++- docs/file-format-parity.md | 5 +- 2 files changed, 121 insertions(+), 3 deletions(-) diff --git a/crates/formats/src/odt.rs b/crates/formats/src/odt.rs index e2266f7ed..ab288fc30 100644 --- a/crates/formats/src/odt.rs +++ b/crates/formats/src/odt.rs @@ -1404,7 +1404,63 @@ pub fn parse(bytes: &[u8]) -> Result { /// Parse an `.odt` package into a document. pub fn import(bytes: &[u8]) -> Result { - Ok(model::to_doc(&parse(bytes)?)) + let mut doc = model::to_doc(&parse(bytes)?); + if let Ok(mut z) = zip::ZipArchive::new(Cursor::new(bytes)) + && let Some(s) = read_entry(&mut z, "styles.xml") + { + apply_page_layout(&s, &mut doc.last_section); + } + Ok(doc) +} + +/// Read the page size, orientation and margins of the first master page in `styles.xml` into `sec`. +fn apply_page_layout(xml: &[u8], sec: &mut wordcraft_doc::SectionProps) { + let mut layouts: HashMap = HashMap::new(); + let mut cur: Option = None; + let mut master: Option = None; + events(xml, |ev| match ev { + Ev::Start(q, a) => match local(&q) { + "page-layout" if q.starts_with("style:") => cur = get(&a, "style:name").map(String::from), + "page-layout-properties" => { + if let Some(n) = &cur { + layouts.insert(n.clone(), a); + } + } + "master-page" if master.is_none() => master = get(&a, "style:page-layout-name").map(String::from), + _ => {} + }, + Ev::End(q) => { + if local(&q) == "page-layout" { + cur = None; + } + } + Ev::Text(_) => {} + }); + let Some(a) = master.and_then(|m| layouts.remove(&m)) else { return }; + let len = |k: &str| get(&a, k).and_then(length).filter(|v| *v >= 0.0); + // Like the DOCX reader: a page is at least half an inch; at most 22 inches (Word's limit), so a hostile + // `1e30pt` can't reach layout. + let page = |k: &str| len(k).filter(|v| *v >= 36.0).map(|v| v.min(1584.0)); + if let (Some(w), Some(h)) = (page("fo:page-width"), page("fo:page-height")) { + sec.page_w = w; + sec.page_h = h; + } + sec.landscape = match get(&a, "style:print-orientation") { + Some("landscape") => true, + Some("portrait") => false, + _ => sec.page_w > sec.page_h, + }; + for (k, slot) in [ + ("fo:margin-top", &mut sec.margin_top), + ("fo:margin-bottom", &mut sec.margin_bottom), + ("fo:margin-left", &mut sec.margin_left), + ("fo:margin-right", &mut sec.margin_right), + ] { + if let Some(v) = len(k) { + // No margin is larger than the page's longer side (as `w:pgMar` in the DOCX reader). + *slot = v.min(sec.page_w.max(sec.page_h)); + } + } } #[cfg(test)] @@ -1465,6 +1521,67 @@ mod tests { } } + #[test] + fn page_size_orientation_and_margins_round_trip() { + // #810: the page setup written on export is read back on import. + let mut doc = Document::new(); + doc.last_section.page_w = 841.0; + doc.last_section.page_h = 595.0; + doc.last_section.landscape = true; + doc.last_section.margin_left = 36.0; + doc.last_section.margin_top = 54.0; + let back = import(&export(&doc).unwrap()).unwrap(); + let s = &back.last_section; + assert!(s.landscape); + assert_eq!((s.page_w, s.page_h), (841.0, 595.0)); + assert_eq!((s.margin_left, s.margin_top), (36.0, 54.0)); + } + + #[test] + fn page_layout_follows_the_first_master_page() { + let xml = br#" + + + + + + + + "#; + let mut sec = wordcraft_doc::SectionProps::default(); + apply_page_layout(xml, &mut sec); + assert!(sec.landscape); + assert_eq!((sec.page_w, sec.page_h, sec.margin_left, sec.margin_top), (720.0, 360.0, 72.0, 72.0)); + } + + #[test] + fn hostile_page_layout_values_are_clamped() { + let styles = |w: &str, h: &str, m: &str| { + format!( + r#" + + + + "# + ) + }; + // Huge values: the page is capped at 22 inches and margins at the page's longer side. + let mut sec = wordcraft_doc::SectionProps::default(); + apply_page_layout(styles("1e30pt", "1e30in", "1e30pt").as_bytes(), &mut sec); + assert_eq!((sec.page_w, sec.page_h), (1584.0, 1584.0)); + assert_eq!((sec.margin_top, sec.margin_left), (1584.0, 1584.0)); + // Negative, zero, tiny and non-numeric values leave the defaults alone. + let def = wordcraft_doc::SectionProps::default(); + for (w, h, m) in [("-10in", "-5in", "-1in"), ("0pt", "0pt", "NaNpt"), ("1pt", "2pt", "infpt"), ("NaN", "inf", "x")] { + let mut sec = wordcraft_doc::SectionProps::default(); + apply_page_layout(styles(w, h, m).as_bytes(), &mut sec); + assert_eq!((sec.page_w, sec.page_h), (def.page_w, def.page_h), "{w} {h}"); + assert_eq!((sec.margin_top, sec.margin_right), (def.margin_top, def.margin_right), "{m}"); + assert!(sec.page_w.is_finite() && sec.margin_left.is_finite()); + } + } + #[test] fn table_cell_style_background_is_shading() { // #437: `fo:background-color` on `style:table-cell-properties` is the cell's shading. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 0b38c646d..6d8763c90 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** trivial (independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** trivial (ODT page size, orientation and margins read on open, #810; previously trivial: native save temporary-file ownership and failure protection; previously trivial: Print Markup: comments in PDF/print/copies, #589; previously trivial: SmartArt written from WordCraft's model, #497; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the share of a typical real-world file's content and formatting that survives (read: shows correctly; @@ -26,7 +26,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ | PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133), links, outline, tagging. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. No PDF/A option, no "best for printing/online" choice | | PDF open (PDF Reflow, Word for Windows) | 1% | R | ❌ | — | 0% | 30–50 | — | Converting a PDF into an editable document. A sibling app (PdfCraft) may supply the parser | | `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. Thin: comments, notes, revisions, sections, RTL | -| `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs` | Styles, lists (#87), tables, pictures, headers/footers, page breaks (#86). No footnotes, comments, tracked changes, fields or RTL | +| `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs` | Styles, lists (#87), tables, pictures, headers/footers, page breaks (#86), page size, orientation and margins (#810). No footnotes, comments, tracked changes, fields or RTL | | `.txt` Plain text | 2% | R/W | ✅ | ✅ | ~95% | 1–2 | unit tests | Encoding detection. Files with other extensions (`.m3u`/`.m3u8` playlists, logs, config files, no extension) open as text when they are text, Word and RTF files by their signature; binary files are refused (#569). Word's encoding choice dialog and line-ending options missing; such files save only through Save As | | `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~60% | 6–10 | `crates/formats/src/tests.rs` | Tables (#102), relative pictures embedded (#113), missing `` tolerated (#154). No Word "Web Page" (with `_files` folder) round-trip | | `.mht/.mhtml` Single File Web Page | <1% | R/W | ❌ | ❌ | 0% | 3–5 | — | | @@ -162,6 +162,7 @@ raise readiness estimates. No Word-derived files are fixtures. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | trivial | ODT page size, orientation and margins read back from `styles.xml` on open, so they survive a save and reopen (#810) | | 2026-10-11 | trivial | Independent complex-script size, bold and italic inherit correctly through defaults and styles; explicit equal values survive; six synthetic multi-generation round-trip tests, including tracked formatting, added | | 2026-10-11 | minor | Opening files from the system: file associations and the default-app button (#295) | | 2026-10-11 | trivial | `.doc`: inline and floating pictures (BLIP store, PNG/JPEG/DIB/TIFF/EMF/WMF), basic shapes, groups and text boxes read from OfficeArt drawings (#551); fidelity ~55% → ~60% | From 411ce31de716e3fadf460839f2e89c899a43ae4d Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Mon, 12 Oct 2026 14:18:25 +0200 Subject: [PATCH 47/88] Paste keeps other programs' formatting; clipboard pictures on Wayland (#687, #833) (#847) * #687, #833: paste keeps other programs' formatting; Wayland clipboard pictures - Paste (Ctrl+V, Home > Paste) reads the system clipboard's HTML, else RTF, and keeps its formatting; our own copy still wins while the clipboard holds it. Windows CF_HTML headers are understood; oversized or empty payloads fall back to the text. - Merge Formatting inserts the content in the destination's font and paragraph, keeping bold, italic, underline and strikethrough (it did nothing before). - Paste Special lists what the system clipboard holds (HTML, RTF, picture, text); Mod+Alt+V opens it even with only a picture there. - Services gain clipboard_html, clipboard_rtf and clipboard_formats hooks; the host reads RTF on Windows (clipboard-win) and Wayland (wl-clipboard-rs). - Linux: arboard's wayland-data-control feature, so pictures copied in a browser or Spectacle reach a native Wayland window (KWin only syncs to Xwayland while an X11 window has focus); other picture types are read from Wayland directly. A paste that finds nothing usable names the formats on the clipboard, or why its picture couldn't be read, instead of "empty". Co-Authored-By: Claude Opus 5.5 * Review fixes for #847: translate the Paste Special picture strings; Windows/macOS unused import - `Picture` and `Pastes the clipboard's picture.` translated in all 15 interface catalogs. - clipboard.rs: `MAX_FILE_BYTES` is used only by the Linux/Wayland module, so it is named there; the top-level import was unused (a warning, and a clippy -D warnings error) on Windows and macOS. - Merged main (ROADMAP.md, docs/gaps.md, docs/file-format-parity.md, main.rs services: keeps both the clipboard hooks and the default-app hook from #295). Cargo.lock adds only the wl-clipboard-rs set. Co-Authored-By: Claude Opus 5.5 * i18n: Hungarian and French strings for this change 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 | 91 +++++++++- Cargo.toml | 2 +- ROADMAP.md | 3 +- apps/wordcraft/Cargo.toml | 7 + apps/wordcraft/src/clipboard.rs | 164 ++++++++++++++++++ apps/wordcraft/src/main.rs | 28 +--- crates/engine/src/cmd/edit.rs | 26 ++- crates/engine/src/cmd/insert.rs | 2 +- crates/engine/src/cmd/paste.rs | 133 ++++++++++++++- crates/engine/src/tests.rs | 86 ++++++++++ crates/ui-egui/src/dialogs.rs | 20 ++- crates/ui-egui/src/i18n/bg.tsv | 2 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/et.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sr-latn.tsv | 2 + crates/ui-egui/src/i18n/sr.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 8 +- crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/keys.rs | 12 ++ crates/ui-egui/src/lib.rs | 31 ++-- crates/ui-egui/src/paste_picture.rs | 35 ++++ crates/ui-egui/src/system_clipboard.rs | 224 +++++++++++++++++++++++++ docs/file-format-parity.md | 7 +- docs/gaps.md | 7 +- 35 files changed, 848 insertions(+), 72 deletions(-) create mode 100644 apps/wordcraft/src/clipboard.rs create mode 100644 crates/ui-egui/src/system_clipboard.rs diff --git a/Cargo.lock b/Cargo.lock index 1fa2de1d4..2743b3dd2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -241,6 +241,7 @@ dependencies = [ "parking_lot", "percent-encoding", "windows-sys 0.60.2", + "wl-clipboard-rs", "x11rb", ] @@ -1315,6 +1316,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.10" @@ -1352,6 +1359,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -1539,7 +1552,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" dependencies = [ "bytemuck", - "foldhash", + "foldhash 0.2.0", "hashbrown 0.17.1", "log", "peniko", @@ -1555,7 +1568,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be" dependencies = [ "bytemuck", - "foldhash", + "foldhash 0.2.0", "hashbrown 0.17.1", "log", "peniko", @@ -1703,6 +1716,15 @@ dependencies = [ "smallvec", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -1711,7 +1733,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -1720,7 +1742,7 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -2399,6 +2421,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2796,6 +2827,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "parking" version = "2.2.1" @@ -2856,6 +2897,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + [[package]] name = "phf" version = "0.13.1" @@ -3996,6 +4048,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom", + "petgraph", +] + [[package]] name = "ttf-parser" version = "0.25.1" @@ -4996,11 +5059,30 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wl-clipboard-rs" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.5", + "thiserror 2.0.21", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "wordcraft" version = "0.6.0" dependencies = [ "arboard", + "clipboard-win", "dispatch2", "eframe", "egui", @@ -5016,6 +5098,7 @@ dependencies = [ "windows", "winit", "winresource", + "wl-clipboard-rs", "wordcraft-control-key", "wordcraft-doc", "wordcraft-engine", diff --git a/Cargo.toml b/Cargo.toml index 2de37ac07..3ca7c3cb1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,7 +73,7 @@ egui_extras = { version = "0.36", default-features = false } eframe = { version = "0.36", default-features = false } egui_kittest = "0.36" rfd = "0.17" -arboard = { version = "3.6", default-features = false } +arboard = { version = "3.6", default-features = false, features = ["wayland-data-control"] } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/ROADMAP.md b/ROADMAP.md index 45ff902b8..520ac7c38 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-12 · **Change:** minor (Portuguese proofing; previously minor: Polish interface and Polish proofing; 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-11 · **Last updated:** 2026-10-12 · **Change:** trivial (Turkish interface catalog completed, #774; previously trivial: Navigation heading truncation; previously trivial: Hungarian interface landed; previously trivial: formatted paste from other programs, Wayland clipboard pictures, #687, #833; previously trivial: independent complex-script formatting preserved across DOCX open/save; 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 @@ -132,6 +132,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log | Date | What landed | +| 2026-10-12 | Paste from other programs keeps their formatting (#687): Ctrl+V and Home › Paste read the system clipboard's HTML (and RTF on Windows and Wayland); Merge Formatting takes the destination's font and paragraph and keeps bold, italic, underline and strikethrough; Paste Special lists what the system clipboard holds (HTML, RTF, picture, text). Linux: pictures copied in a browser or Spectacle paste on KDE and other Wayland desktops (data-control clipboard), and a paste that finds nothing usable names what the clipboard holds instead of "empty" (#833) | | 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) | |---|---| diff --git a/apps/wordcraft/Cargo.toml b/apps/wordcraft/Cargo.toml index d878c9305..0e92f3888 100644 --- a/apps/wordcraft/Cargo.toml +++ b/apps/wordcraft/Cargo.toml @@ -48,6 +48,10 @@ winit = { version = "0.30", default-features = false, features = ["x11"] } # The desktop's animation setting on Linux and the BSDs, read from the XDG settings portal over # D-Bus. Already in the build through AccessKit, at this version; this adds only its blocking API. zbus = { version = "5", default-features = false, features = ["async-io", "blocking-api"] } +# Wayland's clipboard through the data-control protocol (#687, #833): the RTF and other picture +# types that arboard doesn't read, and the formats offered. Already in the build through arboard's +# `wayland-data-control` feature, at this version. +wl-clipboard-rs = "0.9" [target.'cfg(target_os = "windows")'.dependencies] # The system's Animation effects setting (Settings › Accessibility › Visual effects), read through @@ -55,6 +59,9 @@ zbus = { version = "5", default-features = false, features = ["async-io", "block # workspace forbids. Already in the build through wgpu and AccessKit, at this version; this adds # only the `UI_ViewManagement` bindings. windows = { version = "0.62", features = ["UI_ViewManagement"] } +# The clipboard's "Rich Text Format" and the names of its formats (#687, #833), which arboard +# doesn't read. Already in the build through arboard, at this version. +clipboard-win = { version = "5.4", features = ["std"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rfd = { workspace = true } diff --git a/apps/wordcraft/src/clipboard.rs b/apps/wordcraft/src/clipboard.rs new file mode 100644 index 000000000..ecd284679 --- /dev/null +++ b/apps/wordcraft/src/clipboard.rs @@ -0,0 +1,164 @@ +//! The system clipboard beyond egui's text (#45, #584, #687, #833): text for the ribbon's Paste, +//! another program's formatted copy (HTML, and RTF where the platform offers it), pictures and +//! copied files, and the names of the formats there for a paste that finds nothing it can use. +//! +//! On Linux, arboard talks to Wayland compositors through the data-control protocol (KDE Plasma, +//! wlroots compositors); without it, a native Wayland window only sees what the compositor copies +//! over to Xwayland, which KWin does only while an X11 window has focus — so pictures copied in a +//! browser or Spectacle never arrived (#833). Compositors without the protocol fall back to X11. +//! +//! Everything read here comes from other programs: sizes are capped as it is read. + +use wordcraft_engine::cmd::paste::MAX_PASTE_BYTES; +use wordcraft_ui_egui::paste_picture::ClipboardPicture; + +fn open() -> Option { + arboard::Clipboard::new().map_err(|e| log::warn!("clipboard: {e}")).ok() +} + +/// The clipboard's text. +pub fn text() -> Option { + open()?.get_text().ok() +} + +/// The clipboard's HTML (Windows: the fragment of its "HTML Format"), if not too large. +pub fn html() -> Option { + open()?.get().html().ok().filter(|h| h.len() <= MAX_PASTE_BYTES) +} + +/// The clipboard's RTF: Windows ("Rich Text Format") and Wayland (`text/rtf`); not offered by +/// arboard, so read here. +pub fn rtf() -> Option { + let bytes = platform::rtf()?; + if bytes.len() > MAX_PASTE_BYTES { + return None; + } + // RTF is 7-bit by the spec; stray 8-bit bytes become replacement characters. + let s = String::from_utf8_lossy(&bytes).trim_end_matches('\0').to_string(); + s.trim_start().starts_with("{\\rtf").then_some(s) +} + +/// The names of the formats on the clipboard, where the platform lists them (Wayland, Windows). +pub fn formats() -> Vec { + platform::formats() +} + +/// The picture on the clipboard: copied files, else image pixels, else (Wayland) an encoded +/// picture in a format arboard doesn't decode. A picture that is there but can't be read says +/// why (#833). The UI checks the sizes before inserting anything. +pub fn picture() -> Option { + let mut clipboard = match arboard::Clipboard::new() { + Ok(c) => c, + Err(e) => { + log::warn!("clipboard: {e}"); + return Some(ClipboardPicture::Unreadable(format!("the system clipboard couldn't be opened ({e})"))); + } + }; + if let Ok(files) = clipboard.get().file_list() + && !files.is_empty() + { + return Some(ClipboardPicture::Files(files)); + } + match clipboard.get_image() { + Ok(image) => Some(ClipboardPicture::Pixels { width: image.width, height: image.height, rgba: image.bytes.into_owned() }), + Err(arboard::Error::ContentNotAvailable) => platform::encoded_picture().map(ClipboardPicture::Encoded), + Err(arboard::Error::ConversionFailure) => Some( + platform::encoded_picture() + .map(ClipboardPicture::Encoded) + .unwrap_or_else(|| ClipboardPicture::Unreadable("WordCraft can't decode its format".into())), + ), + Err(e) => { + log::warn!("clipboard picture: {e}"); + platform::encoded_picture().map(ClipboardPicture::Encoded).or(Some(ClipboardPicture::Unreadable(e.to_string()))) + } + } +} + +/// Picture types read straight from Wayland when arboard (PNG only) finds none, best first. +#[cfg(all(unix, not(any(target_os = "macos", target_os = "android", target_os = "emscripten"))))] +const PICTURE_MIMES: [&str; 6] = ["image/png", "image/jpeg", "image/gif", "image/webp", "image/bmp", "image/x-bmp"]; + +#[cfg(all(unix, not(any(target_os = "macos", target_os = "android", target_os = "emscripten"))))] +mod platform { + use std::io::Read; + + use wl_clipboard_rs::paste::{ClipboardType, MimeType, Seat, get_contents, get_mime_types_ordered}; + + fn wayland() -> bool { + std::env::var_os("WAYLAND_DISPLAY").is_some() + } + + /// One MIME type's bytes, at most `cap`; `None` when absent or larger. + fn read(mime: &str, cap: u64) -> Option> { + let (pipe, _) = get_contents(ClipboardType::Regular, Seat::Unspecified, MimeType::Specific(mime)).ok()?; + let mut out = Vec::new(); + pipe.take(cap.saturating_add(1)).read_to_end(&mut out).ok()?; + (out.len() as u64 <= cap).then_some(out) + } + + pub fn formats() -> Vec { + if !wayland() { + return Vec::new(); + } + get_mime_types_ordered(ClipboardType::Regular, Seat::Unspecified).unwrap_or_default() + } + + pub fn rtf() -> Option> { + if !wayland() { + return None; + } + let offered = formats(); + let mime = ["text/rtf", "application/rtf", "text/richtext"].into_iter().find(|m| offered.iter().any(|o| o == m))?; + read(mime, super::MAX_PASTE_BYTES as u64) + } + + pub fn encoded_picture() -> Option> { + if !wayland() { + return None; + } + let offered = formats(); + let mime = super::PICTURE_MIMES.into_iter().find(|m| offered.iter().any(|o| o == m))?; + read(mime, wordcraft_ui_egui::paste_picture::MAX_FILE_BYTES) + } +} + +#[cfg(target_os = "windows")] +mod platform { + use clipboard_win::{Clipboard, EnumFormats, raw}; + + pub fn formats() -> Vec { + let Ok(_open) = Clipboard::new_attempts(10) else { return Vec::new() }; + // Standard formats are named too (CF_BITMAP, CF_UNICODETEXT…). + EnumFormats::new().take(64).filter_map(raw::format_name_big).collect() + } + + pub fn rtf() -> Option> { + let format = raw::register_format("Rich Text Format")?; + let _open = Clipboard::new_attempts(10).ok()?; + if raw::size(format.get()).is_none_or(|n| n.get() > super::MAX_PASTE_BYTES) { + return None; + } + let mut out = Vec::new(); + raw::get_vec(format.get(), &mut out).ok()?; + Some(out) + } + + pub fn encoded_picture() -> Option> { + None + } +} + +#[cfg(not(any(target_os = "windows", all(unix, not(any(target_os = "macos", target_os = "android", target_os = "emscripten"))))))] +mod platform { + pub fn formats() -> Vec { + Vec::new() + } + + pub fn rtf() -> Option> { + None + } + + pub fn encoded_picture() -> Option> { + None + } +} diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index 40cb074c5..2a327a3a6 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -12,6 +12,7 @@ mod animations; #[cfg(target_os = "macos")] mod apple_events; +mod clipboard; mod control_server; mod default_apps; mod file_dialogs; @@ -23,7 +24,6 @@ mod print; use wordcraft_engine::Session; use wordcraft_ui_egui::{ Services, UiState, WordApp, - paste_picture::ClipboardPicture, window_geometry::{WindowGeometry, take_rescue}, }; @@ -192,34 +192,24 @@ fn save_prefs(app: &WordApp) { } /// Native file dialogs, shown without blocking the window (`file_dialogs`, #94), File › Print -/// through the system's PDF viewer (`print`, #15), pictures on the system clipboard (#45), the -/// system's animation setting (`animations`), and making WordCraft the default app for Word -/// documents (`default_apps`, #295). +/// through the system's PDF viewer (`print`, #15), the system clipboard's text, formatted copies, +/// pictures and format names (`clipboard`, #45, #584, #687, #833), the system's animation setting +/// (`animations`), and making WordCraft the default app for Word documents (`default_apps`, #295). fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services { Services { file_dialog: Some(file_dialog), print: Some(print::hook()), - clipboard_picture: Some(Box::new(clipboard_picture)), + clipboard_picture: Some(Box::new(clipboard::picture)), make_default_app: default_apps::hook(), animations_enabled: Some(animations::hook(ctx)), - clipboard_text: Some(Box::new(|| arboard::Clipboard::new().ok()?.get_text().ok())), + clipboard_text: Some(Box::new(clipboard::text)), + clipboard_html: Some(Box::new(clipboard::html)), + clipboard_rtf: Some(Box::new(clipboard::rtf)), + clipboard_formats: Some(Box::new(clipboard::formats)), ..Default::default() } } -/// The picture on the system clipboard (#45): copied files, else image pixels. The UI checks the -/// sizes before inserting anything. -fn clipboard_picture() -> Option { - let mut clipboard = arboard::Clipboard::new().map_err(|e| log::warn!("clipboard: {e}")).ok()?; - if let Ok(files) = clipboard.get().file_list() - && !files.is_empty() - { - return Some(ClipboardPicture::Files(files)); - } - let image = clipboard.get_image().ok()?; - Some(ClipboardPicture::Pixels { width: image.width, height: image.height, rgba: image.bytes.into_owned() }) -} - /// Window, Dock and taskbar icon. fn app_icon() -> Option { #[cfg(target_os = "macos")] diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index e7243a33f..eed8113e8 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -33,11 +33,12 @@ pub fn specs() -> Vec { .pure(), CommandSpec::new("edit.cut", "Cut", "Home › Clipboard", cut).key("Mod+X").when(has_sel), CommandSpec::new("edit.copy", "Copy", "Home › Clipboard", copy).key("Mod+C").pure(), - CommandSpec::new("edit.paste", "Paste", "Home › Clipboard", paste).key("Mod+V").params(r#"{"text"?: string}"#), + CommandSpec::new("edit.paste", "Paste", "Home › Clipboard", paste).key("Mod+V").params(r#"{"text"?: string, "html"?: string, "rtf"?: string}"#), CommandSpec::new("edit.pasteText", "Paste: Keep Text Only", "Home › Clipboard › Paste", paste_text) .params(r#"{"text"?: string}"#) .key("Mod+Shift+Alt+V"), - CommandSpec::new("edit.pasteMerge", "Paste: Merge Formatting", "Home › Clipboard › Paste", paste_text).params(r#"{"text"?: string}"#), + CommandSpec::new("edit.pasteMerge", "Paste: Merge Formatting", "Home › Clipboard › Paste", paste_merge) + .params(r#"{"text"?: string, "html"?: string, "rtf"?: string}"#), CommandSpec::new("edit.clipboardPane", "Clipboard Pane", "Home › Clipboard", |s, v| { let on = p::bool(v, "value").unwrap_or(!s.view.clipboard_pane); s.view.clipboard_pane = on; @@ -133,14 +134,18 @@ fn cut(s: &mut Session, v: &Value) -> CmdResult { Ok(r) } +/// Paste keeps the source formatting: our own copy while the system clipboard still holds it, +/// else another program's HTML or RTF (#687), else the system clipboard's text. fn paste(s: &mut Session, v: &Value) -> CmdResult { - // An explicit text param (from the system clipboard) wins unless it matches our own copy. - let frag = match (p::str(v, "text"), &s.clipboard) { - (Some(t), Some(f)) if f.plain_text() == t.replace("\r\n", "\n") => f.clone(), - (Some(t), _) => Fragment::from_text(&t.replace("\r\n", "\n")), - (None, Some(f)) => f.clone(), - (None, None) => return Err(CmdError::Failed("the clipboard is empty".into())), - }; + let frag = super::paste::paste_fragment(s, v)?; + super::paste::insert(s, frag)?; + sel_result(s) +} + +/// Merge Formatting: the pasted text takes the destination's formatting, keeping its emphasis. +fn paste_merge(s: &mut Session, v: &Value) -> CmdResult { + let mut frag = super::paste::paste_fragment(s, v)?; + super::paste::merge_formatting(s, &mut frag); super::paste::insert(s, frag)?; sel_result(s) } @@ -284,6 +289,9 @@ fn paste_text(s: &mut Session, v: &Value) -> CmdResult { Some(t) => t.to_string(), None => s.clipboard.as_ref().map(Fragment::plain_text).unwrap_or_default(), }; + if text.is_empty() { + return Err(CmdError::Failed("the clipboard is empty".into())); + } super::paste_text(s, &text.replace("\r\n", "\r").replace('\n', "\r"))?; sel_result(s) } diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 9a874410b..c541c3661 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -183,7 +183,7 @@ pub fn base64_encode(b: &[u8]) -> String { out } -fn picture(s: &mut Session, v: &Value) -> CmdResult { +pub(crate) fn picture(s: &mut Session, v: &Value) -> CmdResult { let bytes = if let Some(d) = p::str(v, "data") { base64_decode(d).ok_or_else(|| CmdError::Params("bad base64 data".into()))? } else if let Some(path) = p::str(v, "path") { diff --git a/crates/engine/src/cmd/paste.rs b/crates/engine/src/cmd/paste.rs index 6044537fb..4e23ae4fb 100644 --- a/crates/engine/src/cmd/paste.rs +++ b/crates/engine/src/cmd/paste.rs @@ -1,9 +1,11 @@ -//! Paste Special: paste the clipboard in a chosen format (formatted, RTF, HTML, unformatted). +//! Paste Special: paste the clipboard in a chosen format (formatted, RTF, HTML, picture, +//! unformatted), and the formatted content Paste and Merge Formatting take from other programs. //! //! The clipboard can hold several representations of the same content. WordCraft's own copy keeps //! a rich fragment; the system clipboard gives text, and callers (agents, the CLI, a front end -//! with a richer clipboard) may pass `html` or `rtf` too. Text that is itself RTF or HTML source -//! can also be pasted as that format. +//! with a richer clipboard) may pass `html`, `rtf` or a `picture` too. Text that is itself RTF or +//! HTML source can also be pasted as that format. Paste keeps the source formatting of another +//! program's HTML or RTF; Merge Formatting keeps only its emphasis (#687). use std::collections::BTreeMap; use std::sync::Arc; @@ -17,18 +19,25 @@ use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; /// Largest clipboard payload Paste Special accepts, in bytes. pub const MAX_PASTE_BYTES: usize = 16 << 20; +/// Largest clipboard picture Paste Special accepts (base64), in bytes. +pub const MAX_PICTURE_BYTES: usize = 96 << 20; /// Nesting depth we follow when adjusting pasted tables. const MAX_DEPTH: usize = 64; /// The formats, in the order the dialog lists them: (id, label). -pub const FORMATS: [(&str, &str); 4] = - [("formatted", "Formatted Text"), ("rtf", "Rich Text Format (RTF)"), ("html", "HTML Format"), ("text", "Unformatted Text")]; +pub const FORMATS: [(&str, &str); 5] = [ + ("formatted", "Formatted Text"), + ("rtf", "Rich Text Format (RTF)"), + ("html", "HTML Format"), + ("picture", "Picture"), + ("text", "Unformatted Text"), +]; pub fn specs() -> Vec { vec![ CommandSpec::new("edit.pasteSpecial", "Paste Special", "Home › Clipboard › Paste", paste_special) .key("Mod+Alt+V") - .params(r#"{"as"?: "formatted"|"rtf"|"html"|"text", "text"?: string, "html"?: string, "rtf"?: string}"#), + .params(r#"{"as"?: "formatted"|"rtf"|"html"|"picture"|"text", "text"?: string, "html"?: string, "rtf"?: string, "picture"?: base64}"#), ] } @@ -63,6 +72,7 @@ fn source(s: &Session, v: &Value, fmt: &str) -> Option { } "rtf" => p::str(v, "rtf").map(str::to_string).or_else(|| clip_text(s, v).filter(|t| looks_like_rtf(t))).and_then(non_empty), "html" => p::str(v, "html").map(str::to_string).or_else(|| clip_text(s, v).filter(|t| looks_like_html(t))).and_then(non_empty), + "picture" => p::str(v, "picture").map(str::to_string).and_then(non_empty), "text" => clip_text(s, v).and_then(non_empty), _ => None, } @@ -83,11 +93,14 @@ fn paste_special(s: &mut Session, v: &Value) -> CmdResult { return Err(CmdError::Params(format!("`{k}` is larger than {} MB", MAX_PASTE_BYTES >> 20))); } } + if p::str(v, "picture").is_some_and(|t| t.len() > MAX_PICTURE_BYTES) { + return Err(CmdError::Params(format!("`picture` is larger than {} MB", MAX_PICTURE_BYTES >> 20))); + } let ids = available(s, v); let Some(fmt) = p::str(v, "as") else { // No format chosen: list them and ask a front end (if any) to show the dialog. let mut req = json!({"open": "pasteSpecial", "formats": ids}); - for k in ["text", "html", "rtf"] { + for k in ["text", "html", "rtf", "picture"] { if let Some(t) = p::str(v, k) { req[k] = json!(t); } @@ -99,8 +112,9 @@ fn paste_special(s: &mut Session, v: &Value) -> CmdResult { "formatted" | "keep" | "wordcraft" => "formatted", "rtf" => "rtf", "html" => "html", + "picture" | "png" | "image" => "picture", "text" | "unformatted" | "plain" => "text", - other => return Err(CmdError::Params(format!("unknown format `{other}` (formatted, rtf, html or text)"))), + other => return Err(CmdError::Params(format!("unknown format `{other}` (formatted, rtf, html, picture or text)"))), }; let Some(src) = source(s, v, fmt) else { return Err(CmdError::Failed(if ids.is_empty() { @@ -116,8 +130,13 @@ fn paste_special(s: &mut Session, v: &Value) -> CmdResult { r["pastedAs"] = json!(fmt); return Ok(r); } + "picture" => { + let mut r = super::insert::picture(s, &json!({"data": src}))?; + r["pastedAs"] = json!(fmt); + return Ok(r); + } "formatted" => s.clipboard.clone().ok_or_else(|| CmdError::Failed("the clipboard is empty".into()))?, - "html" => adopt(s, wordcraft_formats::html::import(&src)), + "html" => adopt(s, wordcraft_formats::html::import(html_fragment(&src))), _ => adopt(s, wordcraft_formats::rtf::import(src.as_bytes()).map_err(|e| CmdError::Failed(format!("bad RTF: {e}")))?), }; insert(s, frag)?; @@ -126,6 +145,102 @@ fn paste_special(s: &mut Session, v: &Value) -> CmdResult { Ok(r) } +/// The HTML itself from a Windows "HTML Format" (CF_HTML) clipboard payload — a `Version:` header +/// giving byte offsets — or the text unchanged. Hostile offsets fall back to the text after the +/// header. +pub fn html_fragment(html: &str) -> &str { + if !html.starts_with("Version:") { + return html; + } + let offset = |key: &str| -> Option { + let at = html.find(key)?.checked_add(key.len())?; + let digits: &str = html.get(at..)?.split(['\r', '\n']).next()?; + digits.trim().parse().ok() + }; + if let (Some(a), Some(b)) = (offset("StartFragment:"), offset("EndFragment:")) + && let Some(frag) = html.get(a..b) + { + return frag; + } + html.find('<').and_then(|i| html.get(i..)).unwrap_or("") +} + +/// Whether an imported document has anything to paste: text, a table, or a picture it carries. +fn has_content(d: &Document) -> bool { + d.body.iter().any(|b| match &**b { + Block::Para(p) => !p.text.trim().is_empty() || (!p.objects.is_empty() && !d.media.is_empty()), + Block::Table(_) => true, + }) +} + +/// Another program's formatted copy, from the system clipboard's HTML, else its RTF (#687), as a +/// fragment of this document. `None` when neither is given, either is larger than +/// [`MAX_PASTE_BYTES`], or it holds nothing to paste (the caller pastes the text then). +pub fn rich_fragment(s: &mut Session, v: &Value) -> Option { + let fits = |k: &str| p::str(v, k).filter(|t| !t.trim().is_empty() && t.len() <= MAX_PASTE_BYTES); + let doc = fits("html").map(|html| wordcraft_formats::html::import(html_fragment(html))); + let doc = match doc.filter(has_content) { + Some(d) => d, + None => { + let rtf = fits("rtf").filter(|t| looks_like_rtf(t))?; + wordcraft_formats::rtf::import(rtf.as_bytes()).ok().filter(has_content)? + } + }; + Some(adopt(s, doc)) +} + +/// What Paste inserts: WordCraft's own copy while the system clipboard still holds it, else +/// another program's formatted copy (HTML, RTF), else the system clipboard's text. +pub fn paste_fragment(s: &mut Session, v: &Value) -> Result { + let text = p::str(v, "text").map(|t| t.replace("\r\n", "\n")); + if let Some(f) = &s.clipboard + && text.as_ref().is_none_or(|t| f.plain_text() == *t) + && (text.is_some() || (p::str(v, "html").is_none() && p::str(v, "rtf").is_none())) + { + return Ok(f.clone()); + } + if let Some(f) = rich_fragment(s, v) { + return Ok(f); + } + match text { + Some(t) if !t.is_empty() => Ok(Fragment::from_text(&t)), + _ => Err(CmdError::Failed("the clipboard is empty".into())), + } +} + +/// Merge Formatting (#687): the pasted text takes the formatting where it lands — the paragraph's +/// and the caret's — keeping only its emphasis (bold, italic, underline, strikethrough). +pub fn merge_formatting(s: &Session, frag: &mut Fragment) { + let mut base = s.typing_props(); + base.ins = None; + base.del = None; + base.fmt_change = None; + let (start, _) = s.sel.ordered(); + let para = s.doc.para_at(&start).map(|p| p.props.clone()).unwrap_or_default(); + for b in &mut frag.blocks { + each_para(b, 0, &mut |p| { + p.props = para.clone(); + for run in &mut p.runs { + let src = std::mem::replace(&mut run.props, base.clone()); + if src.bold == Some(true) { + run.props.bold = Some(true); + } + if src.italic == Some(true) { + run.props.italic = Some(true); + } + if src.underline.is_some_and(|u| u != wordcraft_doc::props::Underline::None) { + run.props.underline = src.underline; + } + if src.strike == Some(true) { + run.props.strike = Some(true); + } + run.props.link = src.link; + } + p.touch(); + }); + } +} + /// Replace the selection with `frag`, marking it as an insertion under Track Changes. pub fn insert(s: &mut Session, mut frag: Fragment) -> Result<(), CmdError> { super::controls::prepare_edit(s)?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a1c6a5490..1086a547c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -829,6 +829,92 @@ fn paste_special_respects_track_changes_and_brings_lists() { assert!(text(&s).starts_with("one\ntwo")); } +/// Character formatting at a body offset of the first paragraph. +fn props_at(s: &Session, off: usize) -> wordcraft_doc::CharProps { + let pos = Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(0), off }; + s.doc.para_at(&pos).map(|p| p.props_of_char(off).clone()).unwrap_or_default() +} + +/// Paste of another program's formatted copy keeps its formatting (#687); Merge Formatting keeps +/// only its emphasis, in the destination's font; Keep Text Only drops it all. +#[test] +fn paste_keeps_source_formatting_from_other_programs() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "x"})); + run(&mut s, "caret.docEnd", json!({})); + let html = "

red bold

"; + let clip = json!({"text": "red bold", "html": html}); + run(&mut s, "edit.paste", clip.clone()); + assert_eq!(text(&s), "xred bold"); + assert_eq!(props_at(&s, 1).font.as_deref(), Some("Courier New")); + assert!(props_at(&s, 1).color.is_some()); + assert!(!bold_at(&s, 0, 1) && bold_at(&s, 0, 5)); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s), "x"); + run(&mut s, "edit.pasteMerge", clip.clone()); + assert_eq!(text(&s), "xred bold"); + assert_eq!(props_at(&s, 1).font, None, "Merge Formatting takes the destination's font"); + assert_eq!(props_at(&s, 1).color, None); + assert!(bold_at(&s, 0, 5), "and keeps the emphasis"); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.pasteText", clip); + assert_eq!(text(&s), "xred bold"); + assert!(!bold_at(&s, 0, 5)); + + // Windows' "HTML Format" carries a header with the fragment's byte offsets. + run(&mut s, "document.setText", json!({"text": ""})); + let body = "win"; + let head = "Version:0.9\r\nStartHTML:0000000000\r\nEndHTML:0000000000\r\nStartFragment:SSSSSSSSSS\r\nEndFragment:EEEEEEEEEE\r\n"; + let start = head.len() + body.find("").unwrap(); + let end = head.len() + body.find("