From 3370fd1ba265dd56fc940133618abb7e222dbc49 Mon Sep 17 00:00:00 2001 From: Justin John <34035011+justinjohn0306@users.noreply.github.com> Date: Thu, 15 Oct 2026 05:01:14 +0530 Subject: [PATCH 001/101] JSON open repairs hostile run lengths (#1337) (#1412) Paragraph::normalize now uses saturating sums, moves a run end that splits a character to the next boundary, and makes the runs cover the text exactly. Opening a .json document normalizes every paragraph in every story. Co-authored-by: justinjohn0306 Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/doc/src/lib.rs | 12 ++++++++++++ crates/doc/src/para.rs | 36 ++++++++++++++++++------------------ crates/engine/src/io.rs | 6 +++++- crates/engine/src/tests.rs | 21 +++++++++++++++++++++ 4 files changed, 56 insertions(+), 19 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index f7415b5aa..c8d88f369 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -1207,6 +1207,18 @@ impl Document { } } + /// Repair every paragraph's runs (see [`Paragraph::normalize`]); for models from untrusted + /// JSON, whose run lengths may overflow, fall short or split a character. + pub fn normalize_runs(&mut self) { + let mut stories: Vec<&mut Blocks> = vec![&mut self.body]; + stories.extend(self.parts.values_mut().map(|p| &mut p.blocks)); + for blocks in stories { + for b in blocks.iter_mut() { + edit::each_para_mut(Arc::make_mut(b), 0, &mut Paragraph::normalize); + } + } + } + /// Number of paragraphs in the body (all depths). pub fn paragraph_count(&self) -> usize { self.para_paths(StoryRef::Body).len() diff --git a/crates/doc/src/para.rs b/crates/doc/src/para.rs index a6b08857b..f1379bd35 100644 --- a/crates/doc/src/para.rs +++ b/crates/doc/src/para.rs @@ -1033,32 +1033,32 @@ impl Paragraph { /// Merge adjacent equal runs and drop empty ones. pub fn normalize(&mut self) { + // Runs must end on character boundaries and cover the text exactly (never expected, but + // files lie): clamp each end to the text, push it past a split character, pad the tail. + let n = self.text.len(); + let mut end = 0usize; let mut out: Vec = Vec::with_capacity(self.runs.len()); for r in self.runs.drain(..) { - if r.len == 0 { + let mut e = end.saturating_add(r.len).min(n); + while !self.text.is_char_boundary(e) { + e += 1; + } + if e == end { continue; } match out.last_mut() { - Some(l) if l.props == r.props => l.len += r.len, - _ => out.push(r), + Some(l) if l.props == r.props => l.len += e - end, + _ => out.push(Run { len: e - end, props: r.props }), + } + end = e; + } + if end < n { + match out.last_mut() { + Some(l) => l.len += n - end, + None => out.push(Run { len: n, props: self.mark.clone() }), } } self.runs = out; - // Repair a run list that doesn't cover the text exactly (never expected, but files lie). - let total: usize = self.runs.iter().map(|r| r.len).sum(); - if total != self.text.len() { - if total < self.text.len() { - let p = self.runs.last().map(|r| r.props.clone()).unwrap_or_else(|| self.mark.clone()); - self.runs.push(Run { len: self.text.len() - total, props: p }); - } else { - let mut keep = self.text.len(); - for r in &mut self.runs { - r.len = r.len.min(keep); - keep -= r.len; - } - self.runs.retain(|r| r.len > 0); - } - } } /// Delete `a..b` (bytes). Objects inside are removed. diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 7bd35f19c..d96b4ebe4 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -188,7 +188,11 @@ fn read_bytes(name: &str, bytes: &[u8], password: Option<&str>, page: &wordcraft } // Plain text goes through the formats importer: BOMs, unmarked UTF-16, Windows-1252. "txt" | "text" => wordcraft_formats::txt::import_on(bytes, page), - "json" => serde_json::from_slice::(bytes).map_err(|e| format!("{name}: {e}"))?, + "json" => { + let mut d = serde_json::from_slice::(bytes).map_err(|e| format!("{name}: {e}"))?; + d.normalize_runs(); + d + } other => match crate::io_ext::open(other, bytes, page) { Some(r) => r?, None => return open_by_content(name, other, bytes, password, page), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b69d4ff1e..34d7bda84 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5807,6 +5807,27 @@ fn insert_picture_takes_a_windows_metafile() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn json_open_repairs_hostile_run_lengths() { + use wordcraft_doc::{Block, Document}; + // "héllo": é is two bytes (1..3); a run ending at 2 splits it, usize::MAX overflows a sum. + for lens in [vec![2, 4], vec![usize::MAX, usize::MAX], vec![1], vec![], vec![0, 99]] { + let mut d = Document::from_text("héllo"); + if let Some(Block::Para(p)) = d.body.first_mut().map(std::sync::Arc::make_mut) { + p.runs = lens.iter().map(|&len| wordcraft_doc::para::Run { len, props: Default::default() }).collect(); + } + let bytes = serde_json::to_vec(&d).unwrap(); + let (doc, _) = crate::io::open_bytes_with("x.json", &bytes, None).unwrap(); + let Some(Block::Para(p)) = doc.body.first().map(|b| &**b) else { panic!("no paragraph") }; + let mut end = 0; + for (r, _) in p.run_ranges() { + assert!(p.text.is_char_boundary(r.end) && r.end > r.start, "{lens:?}"); + end = r.end; + } + assert_eq!(end, p.text.len(), "{lens:?}"); + } +} + #[test] fn outline_aliases_add_one_undo_step() { let mut s = s(); From e4e389cdb3a8ac868a587553415ad46f65c036e1 Mon Sep 17 00:00:00 2001 From: rabis7575 <340973947+rabis7575@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:31:25 +0200 Subject: [PATCH 002/101] Free part, list and comment ids after a file's largest ones (#1338) (#1428) New parts, lists and comments took "largest id + 1": after an id of u32::MAX (a .json model or a .docx can carry one) that overflowed, a panic in debug builds and a wrap to 0 in release, and list.define's saturating_add reused the taken id. free_id gives one past the largest, or the lowest free id once that would overflow; try_add_part, try_add_list and try_add_levels report ids running out and add nothing, and the commands that add parts and lists use them. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 2 ++ crates/doc/src/edit.rs | 2 +- crates/doc/src/lib.rs | 34 +++++++++++++++++++++--- crates/doc/src/numbering.rs | 33 ++++++++++++++++++------ crates/doc/src/tests.rs | 40 +++++++++++++++++++++++++++++ crates/engine/src/cmd/insert.rs | 2 +- crates/engine/src/cmd/lists.rs | 10 ++------ crates/engine/src/cmd/para.rs | 7 +++-- crates/engine/src/cmd/references.rs | 2 +- crates/engine/src/cmd/review.rs | 14 +++++++--- crates/engine/src/cmd/text.rs | 5 +++- crates/engine/src/cmd/tools.rs | 2 +- crates/engine/src/cmd/wordart.rs | 2 +- crates/engine/src/tests.rs | 35 +++++++++++++++++++++++++ 14 files changed, 159 insertions(+), 31 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 789842411..1c46be8b1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -244,6 +244,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-12 | An empty paragraph that only carries a continuous section break takes no room, as in Word (10 cases measured in Word for Windows): text after it follows right on and balanced columns don't count it | | 2026-10-12 | Floating objects positioned relative to their paragraph start above its space before, as in Word (10 cases measured in Word for Windows) | | 2026-10-12 | Unequal columns: text after a column break is broken for the column it lands in (NIH's SARP form had a paragraph a word or two wide) | +| 2026-10-13 | New comments, notes, text boxes and lists in a document whose file used the largest ids (#1338): they take a free id instead of overflowing (a crash) or reusing one already taken (a new list could pick up another's definition); `Document::try_add_part` and `Numbering::try_add_list` report ids running out instead | | 2026-10-12 | Space before at the top of a page like Word after page breaks (32 cases measured): a paragraph ending with a page break keeps its mark with it, the next paragraph gets no space before; page break before drops it in Word 2013 mode | | 2026-10-12 | Automatic paragraph spacing drops between list items, as in Word (16 cases measured in Word for Windows): bulleted items that ask for automatic space before and after now touch, as in NIH's monitoring plan | | 2026-10-12 | Rendering like Word for Windows, found by comparing real .gov documents page by page: text is kerned only where Kerning for fonts asks for it (line breaks now match Word's), and automatic paragraph spacing (`w:beforeAutospacing`/`w:afterAutospacing`, common in pasted web text) is 14 pt, dropped at the document's and cells' edges | @@ -478,6 +479,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-12 | trivial | Progress log: selection bar in the left margin (#856) | | 2026-10-11 | trivial | Progress log: content controls (#381) | | 2026-10-11 | trivial | Progress log: Insert › Chart (#409); catalog 408/439 (92.9%) | +| 2026-10-13 | trivial | Progress log: free part, list and comment ids after a file's largest ones (#1338) | | 2026-10-11 | trivial | Progress log: Line Numbers, Hyphenation, Language, Envelopes and Labels dialogs (#407) | | 2026-10-11 | trivial | Progress log: Alt Text pane, decorative objects, deeper accessibility checker (#386) | | 2026-10-11 | trivial | Progress log: Draw lasso, Add Pen, Ink to Shape, Ink Replay (#395); catalog 406/434 | diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index 107344240..1894ce6fd 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -584,7 +584,7 @@ impl Document { let body = crate::TextBody { next: None, ..src.body }; *budget -= 1; self.adopt_text_boxes(&mut blocks, parts, depth + 1, budget); - let id = self.add_part(PartKind::TextBox, blocks.into_iter().map(Arc::new).collect()); + let id = self.try_add_part(PartKind::TextBox, blocks.into_iter().map(Arc::new).collect()).ok()?; if let Some(p) = self.parts.get_mut(&id) { p.body = body; } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index c8d88f369..d6acc31d6 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -71,6 +71,27 @@ pub enum DocError { pub type Result = std::result::Result; +/// A free id, `min` or more, for something new (a part, a list, a comment): one past `largest`, the +/// largest id in use, so ids keep growing; once that would pass `u32::MAX` (a file can use any id), +/// the lowest one `used` (every id in use) doesn't hold. `None` when every id from `min` up is taken. +pub fn free_id(largest: Option, used: impl Iterator, min: u32) -> Option { + let Some(largest) = largest else { return Some(min) }; + if let Some(next) = largest.checked_add(1) { + return Some(next.max(min)); + } + let mut ids: Vec = used.filter(|&i| i >= min).collect(); + ids.sort_unstable(); + ids.dedup(); + let mut want = min; + for i in ids { + if i > want { + break; + } + want = want.checked_add(1)?; + } + Some(want) +} + /// A block in a story. (Blocks are always behind an `Arc`, so the size difference is fine.) #[allow(clippy::large_enum_variant)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -942,15 +963,22 @@ impl Document { &mut self.last_section } - /// Allocate a new part id and insert the part. + /// Allocate a new part id and insert the part. Part ids run out only past `u32::MAX` parts, more + /// than memory holds; then nothing is added and the id is 0, which [`free_id`] never gives a part. pub fn add_part(&mut self, kind: PartKind, blocks: Blocks) -> u32 { - let id = self.parts.keys().next_back().map(|k| k + 1).unwrap_or(1); + self.try_add_part(kind, blocks).unwrap_or(0) + } + + /// [`Document::add_part`], or an error, adding nothing, when every part id is taken. + pub fn try_add_part(&mut self, kind: PartKind, blocks: Blocks) -> Result { + let id = free_id(self.parts.keys().next_back().copied(), self.parts.keys().copied(), 1) + .ok_or_else(|| DocError::Invalid("no free part id".into()))?; let mut part = Part { kind, blocks, body: TextBody::default() }; if part.blocks.is_empty() { part.blocks.push(para_block(Paragraph::new())); } self.parts.insert(id, part); - id + Ok(id) } /// Count words like Word's status bar (whitespace-separated tokens containing a letter or digit, diff --git a/crates/doc/src/numbering.rs b/crates/doc/src/numbering.rs index ffdea365c..ac04c0a02 100644 --- a/crates/doc/src/numbering.rs +++ b/crates/doc/src/numbering.rs @@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize}; use crate::props::{Align, CharProps}; use crate::section::NumFormat; +use crate::{DocError, free_id}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] @@ -230,20 +231,36 @@ impl Numbering { } self.abstract_of(num)?.levels.get(level as usize) } - /// Create a new list of `kind`; returns the `num` id. + /// Create a new list of `kind`; returns the `num` id. List ids run out only past `u32::MAX` + /// lists, more than memory holds; then nothing is added and the id is 0, "no list". pub fn add_list(&mut self, kind: ListKind) -> u32 { - let aid = self.abstracts.iter().map(|a| a.id + 1).max().unwrap_or(0); - self.abstracts.push(AbstractNum { id: aid, name: None, levels: levels_for(kind), ..Default::default() }); - let nid = self.nums.iter().map(|n| n.id + 1).max().unwrap_or(1).max(1); - self.nums.push(Num { id: nid, abstract_id: aid, ..Default::default() }); - nid + self.try_add_list(kind).unwrap_or(0) } - /// A new `num` that restarts numbering of an existing list. + /// [`Numbering::add_list`], or an error, adding nothing, when list ids are all taken. + pub fn try_add_list(&mut self, kind: ListKind) -> crate::Result { + self.try_add_levels(None, levels_for(kind)) + } + /// A new list definition (`name`, `levels`) and a `num` for it; returns the `num` id. Both ids + /// are [`free_id`]s, so the largest ids a file can hold don't make them collide with its lists; + /// an error, adding nothing, when they are all taken. + pub fn try_add_levels(&mut self, name: Option, levels: Vec) -> crate::Result { + let aid = free_id(self.abstracts.iter().map(|a| a.id).max(), self.abstracts.iter().map(|a| a.id), 0); + let (Some(aid), Some(nid)) = (aid, self.free_num_id()) else { return Err(DocError::Invalid("no free list id".into())) }; + self.abstracts.push(AbstractNum { id: aid, name, levels, ..Default::default() }); + self.nums.push(Num { id: nid, abstract_id: aid, ..Default::default() }); + Ok(nid) + } + /// A `num` id no list has (`num` 0 means "no list"), `None` when they are all taken. + fn free_num_id(&self) -> Option { + free_id(self.nums.iter().map(|n| n.id).max(), self.nums.iter().map(|n| n.id), 1) + } + /// A new `num` that restarts numbering of an existing list (`None`: no such list, or list ids + /// are all taken). pub fn restart(&mut self, num: u32) -> Option { let source = self.num(num)?; let a = source.abstract_id; let level_overrides = source.level_overrides.clone(); - let nid = self.nums.iter().map(|n| n.id + 1).max().unwrap_or(1).max(1); + let nid = self.free_num_id()?; // Every level starts again at its own effective start, so a level this list defines itself keeps its start. let levels = self.abstracts.iter().find(|x| x.id == a).map_or(0, |x| x.levels.len()); let start_overrides = (0..levels).filter_map(|i| self.level(num, i as u8).map(|l| (i as u8, l.start))).collect(); diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 83b9aa886..3a895ef2b 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -90,6 +90,46 @@ fn parts_and_media() { assert_ne!(k1, k3); } +#[test] +fn free_ids_skip_taken_ones_instead_of_overflowing() { + assert_eq!(free_id(None, std::iter::empty(), 1), Some(1)); + assert_eq!(free_id(Some(7), [3, 7].into_iter(), 1), Some(8)); + assert_eq!(free_id(Some(0), [0].into_iter(), 1), Some(1)); + // A file used the largest id: the lowest free one, not a wrapped 0 or a taken id. + assert_eq!(free_id(Some(u32::MAX), [1, 2, 4, u32::MAX].into_iter(), 1), Some(3)); + assert_eq!(free_id(Some(u32::MAX), [u32::MAX, 1, 0, 1].into_iter(), 0), Some(2)); + // Every id from `min` up taken: none, rather than a duplicate. + assert_eq!(free_id(Some(u32::MAX), [u32::MAX, u32::MAX - 1, u32::MAX - 2].into_iter(), u32::MAX - 2), None); +} + +#[test] +fn parts_and_lists_after_the_largest_ids_take_free_ones() { + // A file can carry any ids, 0 and u32::MAX included; new ones must not overflow or replace them. + let mut d = Document::new(); + for id in [0, 1, u32::MAX] { + let blocks = vec![para_block(Paragraph::with_text(&id.to_string(), CharProps::default()))]; + d.parts.insert(id, Part { kind: PartKind::Header, blocks, ..Default::default() }); + } + let parts = d.parts.clone(); + assert_eq!(d.try_add_part(PartKind::Comment, Vec::new()), Ok(2)); + assert_eq!(d.add_part(PartKind::Footer, Vec::new()), 3); + assert!(parts.iter().all(|(k, p)| d.parts.get(k) == Some(p))); + + let n = &mut d.numbering; + n.add_list(ListKind::Bullet); + n.add_list(ListKind::Bullet); + n.abstracts[1].id = u32::MAX; + n.nums[1].id = u32::MAX; + n.nums[1].abstract_id = u32::MAX; + let lists = n.clone(); + let num = n.try_add_list(ListKind::Numbered).unwrap(); + assert_eq!((num, n.num(num).map(|x| x.abstract_id)), (2, Some(1))); + assert_eq!(n.abstract_of(num).map(|a| &a.levels), Some(&numbering::levels_for(ListKind::Numbered))); + assert_eq!(n.restart(num), Some(3)); + assert_eq!(n.num(3).map(|x| x.abstract_id), Some(1)); + assert_eq!((&n.abstracts[..2], &n.nums[..2]), (&lists.abstracts[..], &lists.nums[..])); +} + #[test] fn clone_is_shallow() { let d = Document::from_text("a\nb"); diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 6623f7e98..b95327646 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -345,7 +345,7 @@ fn text_box(s: &mut Session, v: &Value) -> CmdResult { let text = p::str(v, "text").unwrap_or("").to_string(); let w = p::f32(v, "width").unwrap_or(144.0).clamp(18.0, 2000.0); let h = p::f32(v, "height").unwrap_or(72.0).clamp(18.0, 2000.0); - let id = s.doc.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text(&text, CharProps::default()))]); + let id = s.doc.try_add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text(&text, CharProps::default()))])?; let obj = InlineObject::Shape { kind: ShapeKind::TextBox, w, diff --git a/crates/engine/src/cmd/lists.rs b/crates/engine/src/cmd/lists.rs index 7f192fcb5..80ee95e58 100644 --- a/crates/engine/src/cmd/lists.rs +++ b/crates/engine/src/cmd/lists.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use wordcraft_doc::StoryRef; -use wordcraft_doc::numbering::{AbstractNum, Level, LevelSuffix, ListKind, Num, levels_for}; +use wordcraft_doc::numbering::{Level, LevelSuffix, ListKind, levels_for}; use wordcraft_doc::props::{Align, NumRef, Rgb, TextColor}; use wordcraft_doc::section::NumFormat; @@ -193,13 +193,7 @@ fn define(s: &mut Session, v: &Value) -> CmdResult { touch_lists(s, &sharing)?; n } - None => { - let aid = s.doc.numbering.abstracts.iter().map(|a| a.id.saturating_add(1)).max().unwrap_or(0); - s.doc.numbering.abstracts.push(AbstractNum { id: aid, name, levels: levels.clone(), ..Default::default() }); - let nid = s.doc.numbering.nums.iter().map(|n| n.id.saturating_add(1)).max().unwrap_or(1).max(1); - s.doc.numbering.nums.push(Num { id: nid, abstract_id: aid, ..Default::default() }); - nid - } + None => s.doc.numbering.try_add_levels(name, levels.clone())?, }; // Levels linked to a paragraph style number every paragraph in that style. let mut linked = Vec::new(); diff --git a/crates/engine/src/cmd/para.rs b/crates/engine/src/cmd/para.rs index c4215efb0..8ed5ac847 100644 --- a/crates/engine/src/cmd/para.rs +++ b/crates/engine/src/cmd/para.rs @@ -536,8 +536,11 @@ fn list(s: &mut Session, v: &Value, default: ListKind) -> CmdResult { let num = match prev_num { Some(n) => n.num, None => match kind { - ListKind::Bullet | ListKind::BulletChar(_) => s.doc.numbering.find_kind(kind).unwrap_or_else(|| s.doc.numbering.add_list(kind)), - _ => s.doc.numbering.add_list(kind), + ListKind::Bullet | ListKind::BulletChar(_) => match s.doc.numbering.find_kind(kind) { + Some(n) => n, + None => s.doc.numbering.try_add_list(kind)?, + }, + _ => s.doc.numbering.try_add_list(kind)?, }, }; fmt(s, &|p| { diff --git a/crates/engine/src/cmd/references.rs b/crates/engine/src/cmd/references.rs index 7b3991fae..87f5463cc 100644 --- a/crates/engine/src/cmd/references.rs +++ b/crates/engine/src/cmd/references.rs @@ -547,7 +547,7 @@ fn note(s: &mut Session, v: &Value, kind: NoteKind) -> CmdResult { NoteKind::Endnote => (PartKind::Endnote, "EndnoteText", "EndnoteReference"), }; let mut para = Paragraph::new().styled(style); - let id = s.doc.add_part(pk, Vec::new()); + let id = s.doc.try_add_part(pk, Vec::new())?; para.insert_object( 0, InlineObject::NoteRef { kind, id, custom: custom.clone() }, diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index 3ab67141e..66e2f5ec4 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -127,8 +127,8 @@ fn new_comment(s: &mut Session, v: &Value) -> CmdResult { } else { (a, b) }; - let part = s.doc.add_part(PartKind::Comment, vec![para_block(Paragraph::with_text(&text, Default::default()))]); - let id = s.doc.comments.keys().next_back().map(|k| k + 1).unwrap_or(0); + let part = s.doc.try_add_part(PartKind::Comment, vec![para_block(Paragraph::with_text(&text, Default::default()))])?; + let id = comment_id(s)?; let initials = s.user_initials(); s.doc .comments @@ -146,8 +146,8 @@ fn reply(s: &mut Session, v: &Value) -> CmdResult { return Err(CmdError::Params("no such comment".into())); } let text = p::req_str(v, "text")?; - let part = s.doc.add_part(PartKind::Comment, vec![para_block(Paragraph::with_text(text, Default::default()))]); - let id = s.doc.comments.keys().next_back().map(|k| k + 1).unwrap_or(0); + let part = s.doc.try_add_part(PartKind::Comment, vec![para_block(Paragraph::with_text(text, Default::default()))])?; + let id = comment_id(s)?; let initials = s.user_initials(); s.doc.comments.insert( id, @@ -156,6 +156,12 @@ fn reply(s: &mut Session, v: &Value) -> CmdResult { Ok(json!({"id": id})) } +/// An id no comment has: one past the largest, or the lowest free one once a file used `u32::MAX`. +fn comment_id(s: &Session) -> Result { + let ids = &s.doc.comments; + wordcraft_doc::free_id(ids.keys().next_back().copied(), ids.keys().copied(), 0).ok_or_else(|| CmdError::Failed("no free comment id".into())) +} + /// Replace a comment's (or reply's) text; each line becomes a paragraph. fn edit_comment(s: &mut Session, v: &Value) -> CmdResult { let id = p::u64(v, "id").and_then(|x| u32::try_from(x).ok()).ok_or_else(|| CmdError::Params("`id` required".into()))?; diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index a4fb4c7b4..e234faac8 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -152,7 +152,10 @@ fn list_autoformat(s: &mut Session) -> Result { // Bullets join an existing bullet list; typing "1. " always starts numbering again at 1, // as in Word. let found = if kind.is_bullet() { s.doc.numbering.find_kind(kind) } else { None }; - let num = found.unwrap_or_else(|| s.doc.numbering.add_list(kind)); + let num = match found { + Some(n) => n, + None => s.doc.numbering.try_add_list(kind)?, + }; let para = s.doc.para_mut(f.story, &f.path)?; let len = para.len(); para.delete(0, len)?; diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index 3ae815f89..c5416437a 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -606,7 +606,7 @@ pub fn specs() -> Vec { let fmt = wordcraft_doc::section::NumFormat::from_ooxml(p::str(v, "format").unwrap_or("decimal")); let text = p::str(v, "text").unwrap_or("%1.").to_string(); let start = p::u64(v, "start").unwrap_or(1).min(100_000) as u32; - let id = s.doc.numbering.add_list(wordcraft_doc::ListKind::Numbered); + let id = s.doc.numbering.try_add_list(wordcraft_doc::ListKind::Numbered)?; if let Some(a) = s.doc.numbering.abstract_of(id).map(|a| a.id) && let Some(abs) = s.doc.numbering.abstracts.iter_mut().find(|x| x.id == a) && let Some(l) = abs.levels.first_mut() diff --git a/crates/engine/src/cmd/wordart.rs b/crates/engine/src/cmd/wordart.rs index fe3883b5d..6270d85df 100644 --- a/crates/engine/src/cmd/wordart.rs +++ b/crates/engine/src/cmd/wordart.rs @@ -285,7 +285,7 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult { _ => h * 1.6, }; } - let id = s.doc.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text(&text, props))]); + let id = s.doc.try_add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text(&text, props))])?; if let Some(p) = s.doc.parts.get_mut(&id) && let Some(b) = p.blocks.first_mut() && let wordcraft_doc::Block::Para(para) = std::sync::Arc::make_mut(b) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 34d7bda84..b1156d725 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2466,6 +2466,41 @@ fn text_box_insert_puts_the_caret_inside() { assert_eq!(s.sel.focus, Pos { story: StoryRef::Part(id), path: wordcraft_doc::Path::top(0), off: 4 }); } +#[test] +fn commands_after_the_largest_ids_take_free_ones() { + // A file can carry any ids, 0 and u32::MAX included: comments, notes, boxes and lists the + // commands add take free ids, without overflowing or replacing what has the id they wrap to. + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Some words here"})); + for id in [0, u32::MAX] { + let blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::new())]; + s.doc.parts.insert(id, wordcraft_doc::Part { kind: wordcraft_doc::PartKind::Comment, blocks, ..Default::default() }); + s.doc.comments.insert(id, wordcraft_doc::Comment { part: id, ..Default::default() }); + } + s.doc.numbering.add_list(wordcraft_doc::ListKind::Bullet); + s.doc.numbering.abstracts[0].id = u32::MAX; + s.doc.numbering.nums[0].id = u32::MAX; + s.doc.numbering.nums[0].abstract_id = u32::MAX; + let before = s.doc.clone(); + assert_eq!(run(&mut s, "review.newComment", json!({"text": "new"})), json!({"id": 1, "story": 1})); + assert_eq!(run(&mut s, "review.reply", json!({"id": 0, "text": "re"})), json!({"id": 2})); + run(&mut s, "para.numbering", json!({})); + assert_eq!(run(&mut s, "list.define", json!({"levels": []}))["num"], 2); + assert_eq!(run(&mut s, "references.footnote", json!({"text": "note"}))["id"], 3); + for id in ["insert.wordArt", "insert.textBox"] { + run(&mut s, "caret.set", json!({"pos": Pos::body(0, 0)})); + run(&mut s, id, json!({"text": "Box"})); + } + assert_eq!((s.doc.parts.len(), s.doc.comments.len()), (before.parts.len() + 5, 4)); + assert!(before.parts.iter().all(|(k, p)| s.doc.parts.get(k) == Some(p))); + assert!(before.comments.iter().all(|(k, c)| s.doc.comments.get(k) == Some(c))); + let n = &s.doc.numbering; + assert!(before.numbering.nums.iter().all(|x| n.nums.contains(x)) && before.numbering.abstracts.iter().all(|a| n.abstracts.contains(a))); + let unique = |ids: Vec| ids.iter().collect::>().len() == ids.len(); + assert!(unique(n.nums.iter().map(|x| x.id).collect()) && unique(n.abstracts.iter().map(|a| a.id).collect()), "{n:?}"); + assert_eq!(n.nums.len(), 3); +} + #[test] fn text_box_in_a_table_cell_shows_its_text() { let mut s = s(); From 9c57b950b9d68bc4226cddf8b161571a1f450fbc Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Thu, 15 Oct 2026 01:31:37 +0200 Subject: [PATCH 003/101] Page Setup: Justified vertical alignment (#1320) (#1435) * #1320: justified vertical page alignment, in DOCX, layout.verticalAlign and Page Setup Co-Authored-By: Claude Opus 5.5 * #1320: a section's last page takes its own vertical alignment; pin space-after and multi-column cases Co-Authored-By: Claude Opus 5.5 * Docs after rebasing on main 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 --- ROADMAP.md | 4 +- crates/doc/src/section.rs | 35 +++++++- crates/docx/src/read/props.rs | 9 +- crates/docx/src/write/story.rs | 7 +- crates/docx/tests/roundtrip.rs | 4 +- crates/docx/tests/section_valign.rs | 52 ++++++++++++ crates/engine/src/cmd/page.rs | 9 +- crates/engine/src/tests.rs | 36 ++++++++ crates/layout/src/lib.rs | 64 +++++++++++++-- crates/layout/src/tests.rs | 122 ++++++++++++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 67 ++++++++++++++- docs/file-format-parity.md | 4 +- docs/layout-parity.md | 5 +- docs/ui-parity.md | 3 +- 14 files changed, 390 insertions(+), 31 deletions(-) create mode 100644 crates/docx/tests/section_valign.rs diff --git a/ROADMAP.md b/ROADMAP.md index 1c46be8b1..1bd51adc1 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-14 · **Change:** trivial (Behind Text pictures and shapes can be dragged across overlaid text, in the body and headers, without taking ordinary text clicks; previously trivial: progress log: crop handles on the canvas; previously trivial: progress log: Insert › Bookmark deletes and goes to bookmarks, #1236; previously trivial: progress log: an option to show only the selected text's comment, #1055; previously trivial: #778 mail-merge recipient filter/sort, .xls and legacy encodings; previously trivial: progress log: clicking in a comment's text moves its caret, #1013; previously trivial: progress log: a press on the ribbon no longer scrolls the document, #1019; previously trivial: title rename and account settings; previously trivial: progress log: Down and Up no longer skip a table, #1007; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; 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-14 · **Change:** trivial (progress log: justified vertical page alignment, #1320; previously trivial: Behind Text pictures and shapes can be dragged across overlaid text, in the body and headers, without taking ordinary text clicks; previously trivial: progress log: crop handles on the canvas; previously trivial: progress log: Insert › Bookmark deletes and goes to bookmarks, #1236; previously trivial: progress log: an option to show only the selected text's comment, #1055; previously trivial: #778 mail-merge recipient filter/sort, .xls and legacy encodings; previously trivial: progress log: clicking in a comment's text moves its caret, #1013; previously trivial: progress log: a press on the ribbon no longer scrolls the document, #1019; previously trivial: title rename and account settings; previously trivial: progress log: Down and Up no longer skip a table, #1007; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; 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 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -136,6 +136,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| +| 2026-10-13 | Justified vertical page alignment (#1320): a section set to Justified spreads each page's paragraphs and tables from the top margin to the bottom one, as Word does, including the last page and pages ending in a manual page break. DOCX `w:vAlign="both"` opens as Justified (it showed centred) and saves back; `layout.verticalAlign` takes `justify`, and Page Setup has a Vertical alignment box | | 2026-10-13 | A picture (or shape) set to Behind Text can be dragged over the text it overlaps, whether or not it was selected first, in the body and in a header/footer. The press becomes an object drag only after the pointer moves, so an ordinary click still places the text caret in the overlapping text. The UI regression tests cover In Front of Text too | | 2026-10-13 | Saving to DOCX keeps the watermark: it is written as Word's own text-watermark shape in every header, and a document without headers gains one to hold it (#1290) | | 2026-10-13 | DOCX files with Word's text watermark (Design › Watermark, a VML text shape in the headers) show it on every page on screen and in the PDF (#1290) | @@ -367,6 +368,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-13 | trivial | Progress log: justified vertical page alignment (#1320) | | 2026-10-13 | trivial | Behind Text pictures and shapes drag over overlapping text in the body and headers, selected or not, while ordinary clicks keep editing text; In Front of Text drag covered too | | 2026-10-13 | trivial | Progress log: the watermark written to DOCX (#1290) | | 2026-10-13 | trivial | Progress log: Word's text watermark read from DOCX (#1290) | diff --git a/crates/doc/src/section.rs b/crates/doc/src/section.rs index 60f712394..4f41f752b 100644 --- a/crates/doc/src/section.rs +++ b/crates/doc/src/section.rs @@ -2,7 +2,36 @@ use serde::{Deserialize, Serialize}; -use crate::props::{Borders, VAlign}; +use crate::props::Borders; + +/// How a section's text sits on its pages, top to bottom (`w:vAlign`, ECMA-376 §17.6.23, +/// ST_VerticalJc). Table cells and text boxes use [`crate::props::VAlign`], which has no +/// Justified. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum PageVAlign { + #[default] + Top, + Center, + /// Justified (`both`): the room left on each page is shared out equally between the page's + /// paragraphs and tables, so the text runs from the top margin to the bottom one. + #[serde(alias = "both", alias = "justified")] + Justify, + Bottom, +} + +impl PageVAlign { + /// From a command or file value: `top|center|justify|bottom` (also `both`, `justified`, + /// `middle`); anything else is Top. + pub fn parse(v: &str) -> PageVAlign { + match v { + "center" | "middle" => PageVAlign::Center, + "justify" | "justified" | "both" => PageVAlign::Justify, + "bottom" => PageVAlign::Bottom, + _ => PageVAlign::Top, + } + } +} #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] @@ -677,7 +706,7 @@ pub struct SectionProps { pub page_num_start: Option, pub page_num_format: NumFormat, pub line_numbers: Option, - pub valign: VAlign, + pub valign: PageVAlign, pub page_borders: Option, /// Text direction / bidi section. pub rtl: bool, @@ -719,7 +748,7 @@ impl Default for SectionProps { page_num_start: None, page_num_format: NumFormat::Decimal, line_numbers: None, - valign: VAlign::Top, + valign: PageVAlign::Top, page_borders: None, rtl: false, text_direction: crate::props::TextDirection::Horizontal, diff --git a/crates/docx/src/read/props.rs b/crates/docx/src/read/props.rs index 706ef7cd9..08eb948a5 100644 --- a/crates/docx/src/read/props.rs +++ b/crates/docx/src/read/props.rs @@ -9,7 +9,7 @@ use wordcraft_doc::props::{ }; use wordcraft_doc::section::{ Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, MAX_CHAR_SPACE, MAX_GRID_PITCH, NotePos, NoteProps, NoteRestart, NumFormat, - SectionProps, SectionStart, + PageVAlign, SectionProps, SectionStart, }; use super::drawing_color::adjust_lum; @@ -668,9 +668,10 @@ pub fn sectpr(e: &El) -> (SectionProps, Vec) { } "w:vAlign" => { s.valign = match k.attr("w:val") { - Some("center") | Some("both") => VAlign::Center, - Some("bottom") => VAlign::Bottom, - _ => VAlign::Top, + Some("center") => PageVAlign::Center, + Some("both") => PageVAlign::Justify, + Some("bottom") => PageVAlign::Bottom, + _ => PageVAlign::Top, } } "w:pgBorders" => s.page_borders = Some(borders(k)), diff --git a/crates/docx/src/write/story.rs b/crates/docx/src/write/story.rs index 621f69be6..bd3dbfd3d 100644 --- a/crates/docx/src/write/story.rs +++ b/crates/docx/src/write/story.rs @@ -1389,9 +1389,10 @@ fn sectpr_body(w: &mut W, s: &SectionProps) { w.empty("w:cols", &refs); } match s.valign { - wordcraft_doc::props::VAlign::Top => {} - wordcraft_doc::props::VAlign::Center => w.val("w:vAlign", "center"), - wordcraft_doc::props::VAlign::Bottom => w.val("w:vAlign", "bottom"), + wordcraft_doc::section::PageVAlign::Top => {} + wordcraft_doc::section::PageVAlign::Center => w.val("w:vAlign", "center"), + wordcraft_doc::section::PageVAlign::Justify => w.val("w:vAlign", "both"), + wordcraft_doc::section::PageVAlign::Bottom => w.val("w:vAlign", "bottom"), } if s.title_page { w.empty("w:titlePg", &[]); diff --git a/crates/docx/tests/roundtrip.rs b/crates/docx/tests/roundtrip.rs index a7c713355..5274e22ad 100644 --- a/crates/docx/tests/roundtrip.rs +++ b/crates/docx/tests/roundtrip.rs @@ -8,7 +8,7 @@ use wordcraft_doc::props::{ Align, Border, BorderStyle, Borders, CharProps, HeightRule, Highlight, LineSpacing, NumRef, ParaProps, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableLook, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign, }; -use wordcraft_doc::section::{Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, NumFormat, SectionProps, SectionStart}; +use wordcraft_doc::section::{Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, NumFormat, PageVAlign, SectionProps, SectionStart}; use wordcraft_doc::styles::{Style, StyleKind}; use wordcraft_doc::table::{Cell, Table}; use wordcraft_doc::{Block, Blocks, Comment, Document, InlineObject, Paragraph, PartKind, Revision, RevisionKind, Source, Watermark, para_block}; @@ -338,7 +338,7 @@ fn sections_headers_footers_round_trip() { title_page: true, page_num_start: Some(3), page_num_format: NumFormat::UpperRoman, - valign: VAlign::Center, + valign: PageVAlign::Center, page_borders: Some(Borders::box_(Border::single(1.0))), line_numbers: Some(LineNumbering { count_by: 5, start: 1, distance: 18.0, restart: LineNumberRestart::Section }), gutter: 18.0, diff --git a/crates/docx/tests/section_valign.rs b/crates/docx/tests/section_valign.rs new file mode 100644 index 000000000..6e000dcc0 --- /dev/null +++ b/crates/docx/tests/section_valign.rs @@ -0,0 +1,52 @@ +//! Section vertical alignment (`w:vAlign` in `w:sectPr`, ECMA-376 Part 1 §17.6.23, values from +//! ST_VerticalJc). Fixtures are hand-written from the spec. + +use std::io::{Read, Write}; + +use wordcraft_doc::section::PageVAlign; + +fn docx(body: &str) -> Vec { + let root = r#""#; + let doc = format!( + r#"{body}"# + ); + let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, bytes) in [("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())] { + zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + zw.write_all(bytes).unwrap(); + } + zw.finish().unwrap().into_inner() +} + +fn document_xml(bytes: &[u8]) -> String { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut s = String::new(); + z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap(); + s +} + +/// #1320: "both" (Justified) reads as Justify and writes back as "both"; the others keep theirs. +#[test] +fn section_vertical_alignment_round_trips_with_justified() { + let sect = |v: &str| format!(r#""#); + let body = format!( + r#"{}A{}B{}C{}DE{}"#, + sect("both"), + sect("center"), + sect("bottom"), + sect("top"), + sect("sideways") + ); + let d = wordcraft_docx::read(&docx(&body)).unwrap(); + let got: Vec = d.sections().iter().map(|(_, s)| s.valign).collect(); + let want = vec![PageVAlign::Justify, PageVAlign::Center, PageVAlign::Bottom, PageVAlign::Top, PageVAlign::Top]; + assert_eq!(got, want); + let out = wordcraft_docx::write(&d).unwrap(); + let xml = document_xml(&out); + assert_eq!(xml.matches(r#""#).count(), 1, "{xml}"); + assert_eq!(xml.matches(r#""#).count(), 1, "{xml}"); + assert_eq!(xml.matches(r#""#).count(), 1, "{xml}"); + assert_eq!(xml.matches("w:vAlign").count(), 3, "top writes nothing: {xml}"); + let back = wordcraft_docx::read(&out).unwrap(); + assert_eq!(back.sections().iter().map(|(_, s)| s.valign).collect::>(), want); +} diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs index 0553d4c0f..66a38fa60 100644 --- a/crates/engine/src/cmd/page.rs +++ b/crates/engine/src/cmd/page.rs @@ -48,13 +48,10 @@ pub fn specs() -> Vec { }) .params(r#"{"section"?: SectionProps}"#), CommandSpec::new("layout.verticalAlign", "Vertical Alignment", "Layout › Page Setup › Layout", |s, v| { - let va = match p::str(v, "value") { - Some("center") => wordcraft_doc::props::VAlign::Center, - Some("bottom") => wordcraft_doc::props::VAlign::Bottom, - _ => wordcraft_doc::props::VAlign::Top, - }; + let va = wordcraft_doc::section::PageVAlign::parse(p::str(v, "value").unwrap_or_default()); with_sect(s, |x| x.valign = va) - }), + }) + .params(r#"{"value": "top|center|justify|bottom"} (also both|justified; anything else is top). Justified shares each page's spare room between its paragraphs and tables"#), CommandSpec::new("layout.sectionRtl", "Right-to-Left Section", "Layout › Page Setup", |s, v| { let on = p::bool(v, "value").unwrap_or(!sect(s).rtl); with_sect(s, |x| x.rtl = on) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b1156d725..49af87d4b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6552,3 +6552,39 @@ fn odt_deleted_mark_inside_an_insertion_survives_reject_all() { assert_eq!(text(&s), "One \n two"); } } + +/// #1320: `layout.verticalAlign` sets the section's vertical alignment, Justified included +/// ("justify", or "both"/"justified"); a value it doesn't know is Top, as before. Section JSON +/// for `layout.pageSetup` keeps its names. +#[test] +fn vertical_align_command_sets_justified() { + use wordcraft_doc::section::PageVAlign; + let mut s = s(); + let va = |s: &Session| crate::cmd::page::sect(s).valign; + for (value, want) in [ + ("justify", PageVAlign::Justify), + ("center", PageVAlign::Center), + ("both", PageVAlign::Justify), + ("bottom", PageVAlign::Bottom), + ("justified", PageVAlign::Justify), + ("top", PageVAlign::Top), + ("sideways", PageVAlign::Top), + ] { + run(&mut s, "layout.verticalAlign", json!({"value": value})); + assert_eq!(va(&s), want, "{value}"); + } + run(&mut s, "layout.verticalAlign", json!({"value": "justify"})); + run(&mut s, "edit.undo", json!({})); + assert_eq!(va(&s), PageVAlign::Top, "one Undo step"); + let spec = s.registry.get("layout.verticalAlign").unwrap(); + assert!(spec.params.contains("justify"), "documents its values: {}", spec.params); + // Section JSON: today's names still read, and Justified is "justify". + let mut props = serde_json::to_value(crate::cmd::page::sect(&s)).unwrap(); + assert_eq!(props["valign"], json!("top")); + for (name, want) in [("center", PageVAlign::Center), ("bottom", PageVAlign::Bottom), ("justify", PageVAlign::Justify), ("top", PageVAlign::Top)] { + props["valign"] = json!(name); + run(&mut s, "layout.pageSetup", json!({"section": props.clone()})); + assert_eq!(va(&s), want, "{name}"); + assert_eq!(serde_json::to_value(crate::cmd::page::sect(&s)).unwrap()["valign"], json!(name)); + } +} diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 1a6f8efed..ca284cde0 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -1408,6 +1408,13 @@ struct PageBuilder<'a> { line_no: u32, /// Index of the first body item of the current page (vertical alignment shifts from here). page_items_start: usize, + /// Where each top-level block after the first starts on the current page: its first item's + /// index counted after the items behind the text (justified vertical alignment spreads the + /// blocks apart there). + block_marks: Vec, + /// The vertical alignment of the section the current page started in: a page finished when + /// the next section starts is aligned as its own section asks, not the next one. + page_valign: wordcraft_doc::section::PageVAlign, /// The document's own footnote separators, laid out for the section's text width. seps: NoteSeps, /// Per page: how many items were inserted at the front of its list (behind the text), so a @@ -1742,12 +1749,50 @@ impl PageBuilder<'_> { } } } - /// Section vertical alignment: move the page's body down (centre/bottom). + /// A top-level block starts here (see `block_marks`). + fn mark_block(&mut self) { + if let Some(pg) = self.pages.last() { + let behind = self.behind.last().copied().unwrap_or(0); + self.block_marks.push(pg.items.len().saturating_sub(behind)); + } + } + /// Justified vertical alignment: share the room left below the page's text out equally + /// between the gaps of its blocks, so the last line sits on the bottom margin (above the + /// footnotes). A page with one block, or several columns, stays at the top. + fn justify_page(&mut self) { + if self.web || self.cols.len() > 1 { + return; + } + let behind = self.behind.last().copied().unwrap_or(0); + let first = self.page_items_start; + // The room is measured below the last paragraph's space after: Word for Mac 16.113.4 keeps + // it inside the justified height (72 pt after leaves the last line 72 pt above the margin). + let space = (self.orig_bottom - self.notes_h() - self.y).max(0.0); + let Some(pg) = self.pages.last_mut() else { return }; + let front = pg.items.len().saturating_sub(behind); + // Blocks that moved on to the next page whole leave a mark at the end: not a gap here. + let mut cuts: Vec = self.block_marks.iter().copied().filter(|&m| m > first && m < front).collect(); + cuts.sort_unstable(); + cuts.dedup(); + if cuts.is_empty() || space <= 0.0 { + return; + } + let gap = space / cuts.len() as f32; + for (i, it) in pg.items.iter_mut().enumerate().skip(behind.saturating_add(first)) { + let k = cuts.partition_point(|&c| c <= i.saturating_sub(behind)); + if k > 0 { + it.translate(0.0, gap * k as f32); + } + } + } + /// Section vertical alignment: move the page's body down (centre/bottom), or spread its + /// blocks out (justified). fn apply_valign(&mut self) { - let k = match self.sect.valign { - wordcraft_doc::props::VAlign::Top => return, - wordcraft_doc::props::VAlign::Center => 0.5, - wordcraft_doc::props::VAlign::Bottom => 1.0, + let k = match self.page_valign { + wordcraft_doc::section::PageVAlign::Top => return, + wordcraft_doc::section::PageVAlign::Justify => return self.justify_page(), + wordcraft_doc::section::PageVAlign::Center => 0.5, + wordcraft_doc::section::PageVAlign::Bottom => 1.0, }; if self.web { return; @@ -1793,6 +1838,8 @@ impl PageBuilder<'_> { } } self.page_items_start = decor.len(); + self.block_marks.clear(); + self.page_valign = s.valign; self.pages.push(Page { w, h, section: self.sect_idx, number: self.number, body, first_block, items: decor, ..Default::default() }); self.behind.push(0); self.col = 0; @@ -1977,7 +2024,7 @@ fn layout_pass( 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.valign = wordcraft_doc::section::PageVAlign::Top; &web_sect } else { first_sect @@ -2002,6 +2049,8 @@ fn layout_pass( float_tables: Vec::new(), line_no: 0, page_items_start: 0, + block_marks: Vec::new(), + page_valign: wordcraft_doc::section::PageVAlign::Top, seps: NoteSeps::default(), behind: Vec::new(), max_y: 0.0, @@ -2110,13 +2159,14 @@ fn layout_pass( // start of the block that reaches it, with shorter columns (see `balance_columns`). let balance = !web && pb.cols.len() > 1 - && sect.valign == wordcraft_doc::props::VAlign::Top + && sect.valign == wordcraft_doc::section::PageVAlign::Top && sections.get(si + 1).is_some_and(|(_, s)| s.start == SectionStart::Continuous); pb.col_break_page = None; let mut from = balance.then(|| (block, Snap::take(&mut pb, &ctx))); while block <= last { let Some(b) = doc.body.get(block) else { break }; let before = balance.then(|| (pb.pages.len(), Snap::take(&mut pb, &ctx))); + pb.mark_block(); match (&**b, &outline) { (Block::Para(p), Some(o)) => place_outline_para(&mut ctx, &mut pb, p, block, o), (Block::Table(_), Some(o)) if o.hidden.get(block).copied().unwrap_or(false) => {} diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index f6a4ac2c0..d0bc8ba4a 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -6470,3 +6470,125 @@ fn paragraph_own_float_is_not_replaced_when_its_rest_wraps_in_the_next_column() assert_contiguous(&ranges, d.para(StoryRef::Body, &Path::top(1)).map(|p| p.len()).unwrap_or(0)); assert_clear_of(&line_boxes(&l, 0, 1, col2), r, "column 2"); } + +/// #1320: a section's vertical alignment (`w:vAlign`, ECMA-376 §17.6.23). Justified shares the +/// room left on each page out equally between the page's blocks, so the first line stays at the +/// top margin and the last one sits on the bottom margin (Word for Mac 16.113.4): a partly filled +/// last page too, and a page ending in a manual page break, where the paragraph holding the break +/// is the page's last block. A page with one block keeps it at the top. +#[test] +fn section_vertical_alignment_top_center_justify_bottom() { + use wordcraft_doc::section::PageVAlign; + let para = |text: &str| { + let mut p = wordcraft_doc::Paragraph::with_text(text, Default::default()); + p.props = ParaProps { space_before: Some(0.0), space_after: Some(0.0), ..Default::default() }; + wordcraft_doc::para_block(p) + }; + let laid = |texts: &[&str], va: PageVAlign| { + let mut d = Document::new(); + d.body = texts.iter().map(|t| para(t)).collect(); + d.last_section.valign = va; + lay(&d) + }; + let three = ["One", "Two", "Three"]; + let top = laid(&three, PageVAlign::Top); + let body = top.pages[0].body; + let (body_top, body_bottom) = (body.y, body.y + body.h); + let t = para_boxes(&top); + let h: f32 = t[2].3 - t[0].2; + assert!((t[0].2 - body_top).abs() < 0.5, "top: at the top margin {t:?} {body:?}"); + let c = para_boxes(&laid(&three, PageVAlign::Center)); + let room = body_bottom - body_top - h; + assert!((c[0].2 - (body_top + room / 2.0)).abs() < 0.5, "center: {c:?}"); + assert!((c[1].2 - c[0].3).abs() < 0.05 && (c[2].2 - c[1].3).abs() < 0.05, "center keeps the blocks together: {c:?}"); + let b = para_boxes(&laid(&three, PageVAlign::Bottom)); + assert!((b[2].3 - body_bottom).abs() < 0.5, "bottom: {b:?} {body:?}"); + // Justified: first at the top, last on the bottom margin, equal gaps. + let j = para_boxes(&laid(&three, PageVAlign::Justify)); + assert!((j[0].2 - body_top).abs() < 0.5, "justify: first line at the top margin {j:?}"); + assert!((j[2].3 - body_bottom).abs() < 0.5, "justify: last line on the bottom margin {j:?} {body:?}"); + let (g1, g2) = (j[1].2 - j[0].3, j[2].2 - j[1].3); + assert!(g1 > 100.0 && (g1 - g2).abs() < 0.05, "justify: equal gaps {g1} {g2}"); + // One block on the page stays at the top. + let one = para_boxes(&laid(&["Only"], PageVAlign::Justify)); + assert!((one[0].2 - body_top).abs() < 0.5, "a single block stays at the top: {one:?}"); + // A manual page break: its paragraph is page 1's last block; "Four" alone on page 2 stays up. + let brk = wordcraft_doc::para::PAGE_BREAK.to_string(); + let p = para_boxes(&laid(&["One", "Two", "Three", &brk, "Four"], PageVAlign::Justify)); + assert_eq!(p.iter().map(|e| e.0).collect::>(), vec![0, 0, 0, 0, 1], "{p:?}"); + assert!((p[0].2 - body_top).abs() < 0.5, "{p:?}"); + assert!((p[3].3 - body_bottom).abs() < 0.5, "the break's paragraph sits on the bottom margin: {p:?}"); + let gaps: Vec = (1..4).map(|i| p[i].2 - p[i - 1].3).collect(); + assert!(gaps.iter().all(|g| (g - gaps[0]).abs() < 0.05 && *g > 50.0), "{gaps:?}"); + assert!((p[4].2 - body_top).abs() < 0.5, "Four keeps to the top of page 2: {p:?}"); +} + +/// #1320 review: a section's last page takes that section's vertical alignment, not the next +/// section's (Justified then Top: spread; Top then Justified: stays at the top). +#[test] +fn a_sections_last_page_keeps_its_own_vertical_alignment() { + use wordcraft_doc::section::{PageVAlign, SectionProps}; + let para = |text: &str| { + let mut p = wordcraft_doc::Paragraph::with_text(text, Default::default()); + p.props = ParaProps { space_before: Some(0.0), space_after: Some(0.0), ..Default::default() }; + wordcraft_doc::para_block(p) + }; + let laid = |first: PageVAlign, second: PageVAlign| { + let mut d = Document::new(); + d.body = ["One", "Two", "Three", "Next section"].iter().map(|t| para(t)).collect(); + d.para_mut(StoryRef::Body, &Path::top(2)).unwrap().section = Some(Box::new(SectionProps { valign: first, ..Default::default() })); + d.last_section.valign = second; + lay(&d) + }; + let l = laid(PageVAlign::Justify, PageVAlign::Top); + let body = l.pages[0].body; + let b = para_boxes(&l); + assert_eq!(b.iter().map(|e| e.0).collect::>(), vec![0, 0, 0, 1]); + assert!((b[0].2 - body.y).abs() < 0.5 && (b[2].3 - (body.y + body.h)).abs() < 0.5, "section 1 justified: {b:?} {body:?}"); + assert!((b[3].2 - body.y).abs() < 0.5, "section 2 at the top: {b:?}"); + let b = para_boxes(&laid(PageVAlign::Top, PageVAlign::Justify)); + assert!((b[1].2 - b[0].3).abs() < 0.05 && (b[2].2 - b[1].3).abs() < 0.05, "section 1 stays at the top: {b:?}"); +} + +/// Word for Mac 16.113.4 (user-checked, #1320): a justified page keeps its last paragraph's space +/// after inside the text area, so "Three" with 72 pt after sits 72 pt above the bottom margin. +#[test] +fn justified_page_keeps_the_last_paragraphs_space_after() { + let mut d = Document::new(); + d.body = ["One", "Two", "Three"] + .iter() + .enumerate() + .map(|(i, t)| { + let mut p = wordcraft_doc::Paragraph::with_text(t, Default::default()); + p.props = ParaProps { space_before: Some(0.0), space_after: Some(if i == 2 { 72.0 } else { 0.0 }), ..Default::default() }; + wordcraft_doc::para_block(p) + }) + .collect(); + d.last_section.valign = wordcraft_doc::section::PageVAlign::Justify; + let l = lay(&d); + let body = l.pages[0].body; + let b = para_boxes(&l); + assert!((b[0].2 - body.y).abs() < 0.5, "{b:?}"); + assert!((body.y + body.h - b[2].3 - 72.0).abs() < 0.5, "72 pt left under Three: {b:?} {body:?}"); + assert!((b[1].2 - b[0].3 - (b[2].2 - b[1].3)).abs() < 0.05, "equal gaps: {b:?}"); +} + +/// #1320: a justified page with several newspaper columns stays at the top (not spread). +#[test] +fn justified_multi_column_pages_stay_at_the_top() { + let laid = |va| { + let mut d = two_column_section(wordcraft_doc::section::SectionStart::NextPage, Vec::new()); + d.para_mut(StoryRef::Body, &Path::top(9)).unwrap().section.as_mut().unwrap().valign = va; + para_boxes(&lay(&d)) + }; + use wordcraft_doc::section::PageVAlign; + assert_eq!(laid(PageVAlign::Justify), laid(PageVAlign::Top)); + // The document's last section in two columns. + let last = |va| { + let mut d = Document::from_text("One\nTwo\nThree\nFour"); + d.last_section.columns.count = 2; + d.last_section.valign = va; + para_boxes(&lay(&d)) + }; + assert_eq!(last(PageVAlign::Justify), last(PageVAlign::Top)); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7c6069cf8..c03a2d5d7 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -263,6 +263,8 @@ pub enum Dialog { paper_custom: bool, /// Why OK didn't apply (the margins don't fit the paper). message: String, + /// Vertical alignment (`layout.verticalAlign`, applied only if changed). + valign: wordcraft_doc::section::PageVAlign, }, Link { url: String, @@ -1183,6 +1185,7 @@ impl Dialog { grid, grid_was: grid, rtl: sp.rtl, + valign: sp.valign, } } "link" => Dialog::Link { url: "https://".into(), text: app.session.selected_text(), place: false, targets: None, pick: None }, @@ -2105,7 +2108,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { } ok || cancel } - Dialog::PageSetup { top, bottom, left, right, landscape, mirror, rtl, grid, grid_was, paper, paper_was, paper_custom, message } => { + Dialog::PageSetup { top, bottom, left, right, landscape, mirror, rtl, grid, grid_was, paper, paper_was, paper_custom, message, valign } => { ui.label(egui::RichText::new(tl!("Margins")).font(semibold(12.5))); egui::Grid::new("ps").num_columns(4).show(ui, |ui| { ui.label(tl!("Top:")); @@ -2151,6 +2154,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { if !message.is_empty() { ui.label(egui::RichText::new(message.as_str()).small().color(ui.visuals().error_fg_color)); } + page_valign_combo(ui, valign); let (mut ok, cancel) = buttons(ui, tl!("OK")); if ok { // Everything OK changes is one undo step; a refused command applies nothing. @@ -2175,6 +2179,9 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { if *rtl != wordcraft_engine::cmd::page::sect(&app.session).rtl { commands.push(("layout.sectionRtl", json!({"value": *rtl}))); } + if *valign != wordcraft_engine::cmd::page::sect(&app.session).valign { + commands.push(("layout.verticalAlign", json!({"value": page_valign_value(*valign)}))); + } run_as_one_step(app, commands) }); if let Err(e) = result { @@ -3892,6 +3899,33 @@ fn enclose_preview(ui: &mut Ui, style: &str, text: &str, shape: &str) { p.text(c + vec2(0.0, dy), egui::Align2::CENTER_CENTER, text, egui::FontId::proportional(px), ink); } +/// Page Setup's "Vertical alignment:" box (Word's Layout tab). +fn page_valign_combo(ui: &mut egui::Ui, valign: &mut wordcraft_doc::section::PageVAlign) { + use wordcraft_doc::section::PageVAlign; + const CHOICES: [(PageVAlign, &str); 4] = + [(PageVAlign::Top, "Top"), (PageVAlign::Center, "Center"), (PageVAlign::Justify, "Justified"), (PageVAlign::Bottom, "Bottom")]; + ui.horizontal(|ui| { + ui.label(tl!("Vertical alignment:")); + let shown = CHOICES.iter().find(|(v, _)| v == valign).map_or("", |(_, l)| tl!(l)); + egui::ComboBox::from_id_salt("ps_valign").selected_text(shown).show_ui(ui, |ui| { + for (v, l) in CHOICES { + ui.selectable_value(valign, v, tl!(l)); + } + }); + }); +} + +/// `layout.verticalAlign`'s name for a value. +fn page_valign_value(v: wordcraft_doc::section::PageVAlign) -> &'static str { + use wordcraft_doc::section::PageVAlign; + match v { + PageVAlign::Top => "top", + PageVAlign::Center => "center", + PageVAlign::Justify => "justify", + PageVAlign::Bottom => "bottom", + } +} + #[cfg(test)] mod tests { use super::*; @@ -4704,4 +4738,35 @@ mod tests { h.run_steps(2); assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("pageSetup")); } + + /// #1320: Page Setup shows the section's vertical alignment, and OK applies a change. + #[test] + fn page_setup_dialog_sets_vertical_alignment() { + use egui_kittest::kittest::Queryable; + use wordcraft_doc::section::PageVAlign; + let mut app = app_with("Some text"); + let _ = app.run("layout.verticalAlign", json!({"value": "bottom"})); + app.dialog = Dialog::open("pageSetup", &mut app); + assert!(matches!(app.dialog, Some(Dialog::PageSetup { valign: PageVAlign::Bottom, .. })), "shows the section's value"); + if let Some(Dialog::PageSetup { valign, .. }) = &mut app.dialog { + *valign = PageVAlign::Justify; + } + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + for _ in 0..4 { + h.step(); + } + assert!(h.query_by_label("Vertical alignment:").is_some(), "the dialog has the control"); + h.get_by_role_and_label(egui::accesskit::Role::Button, "OK").click(); + for _ in 0..4 { + h.step(); + } + assert_eq!(wordcraft_engine::cmd::page::sect(&h.state().session).valign, PageVAlign::Justify); + } } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 7f5fe9f66..7357e1bca 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-14 · **Change:** trivial (Word's text watermark written back to DOCX headers, #1290; previously trivial: VML preset shapes drawn, #1377; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: equations as MathML in HTML export, copy and paste, #816; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously 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-11 · **Last updated:** 2026-10-14 · **Change:** trivial (section `w:vAlign` "both" round-trips as Justified, #1320; previously trivial: Word's text watermark written back to DOCX headers, #1290; previously trivial: VML preset shapes drawn, #1377; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: equations as MathML in HTML export, copy and paste, #816; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously 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; @@ -79,6 +79,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Themes, font table, settings, compatibility mode | ✅ | ✅ | `w:allowSpaceOfSameStyleInTable` read and written in schema order; absent/false default off, enabled flag retained in modes 11/12/14/15. 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 properties round-trip; bibliography sources pending (#169) | | Section text direction (`w:textDirection` in `w:sectPr`, §17.6.20) | ✅ | ✅ | `tbRl`, `btLr` (and `tbRlV`/`tbLrV`, read as `tbRl`); `lrTbV` and unknown values read as horizontal (#491) | +| Section vertical alignment (`w:vAlign` in `w:sectPr`, §17.6.23) | ✅ | ✅ | `top`, `center`, `bottom` and `both` (Justified, read as Center before #1320); unknown values read as top, top writes nothing (`crates/docx/tests/section_valign.rs`). ODT/RTF don't carry it | | Ruby (`w:ruby`: `w:rubyPr`, `w:rt`, `w:rubyBase`) | ✅ | ✅ | #288; base and ruby text with their run formatting, alignment, sizes, raise, language | | Document grid (`w:docGrid`: type, line pitch, `w:charSpace`), paragraph `w:snapToGrid` | ✅ | ✅ | #391; run-level `w:snapToGrid` not read | | `w:eastAsianLayout`, `w:fitText` | ❌ | ❌ | East Asian layout | @@ -194,6 +195,7 @@ attributes inside stories and style or list definitions; a regenerated theme. Te | Date | Change | Summary | |---|---|---| +| 2026-10-13 | trivial | Section `w:vAlign="both"` reads as Justified and writes back as `both` (#1320) | | 2026-10-13 | trivial | DOCX: the document's watermark is written as a VML text shape in every header, adding a header where pages had none (#1290) | | 2026-10-13 | trivial | DOCX: Word's VML text watermark in headers is read as the document's watermark (#1290) | | 2026-10-13 | trivial | Rounded rectangles keep their own corner radius: `roundRect` `adj` read, written back and drawn (#1476) | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 2a6636553..33214efcc 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -1,6 +1,6 @@ # Layout and pagination parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (floats aligned inside/outside or placed in the inside/outside margin swap sides on even pages; page-relative floats no longer move with mirrored text, #1181; previously trivial: a word too wide for the strip beside a floating object is no longer split across it, #1376; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (justified vertical page alignment, #1320; previously trivial: floats aligned inside/outside or placed in the inside/outside margin swap sides on even pages; page-relative floats no longer move with mirrored text, #1181; previously trivial: a word too wide for the strip beside a floating object is no longer split across it, #1376; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 A document only "opens correctly" if its pages break where Word breaks them: the same lines, the same page count, footnotes and pictures on the same pages. This checklist covers line breaking, @@ -52,7 +52,7 @@ font can't match Word's metrics. | Rotated objects and text | 🟡 | #332: pictures, shapes, charts and groups drawn turned and flipped about their centre (screen and PDF); square wrap and inline lines keep clear of the rotated bounds; hit testing on the turned shape. Text in a rotated text box stays upright | 2–3 | | Page borders (from page edge or text), page colour, watermark | ✅ | | — | | Line numbers (restart per page/section, count by) | ✅ | | — | -| Vertical page alignment | ✅ | | — | +| Vertical page alignment | ✅ | Top, Center, Bottom and Justified (#1320): justified shares each page's spare room equally between its paragraphs and tables, first line on the top margin and last on the bottom one (above the footnotes), the last page and a page ending in a manual page break too; a page with one block stays at the top; the last paragraph's space after stays inside the justified height (Word for Mac, user-checked). A section's last page takes its own section's alignment (it took the next section's before). Multi-column justified pages stay at the top (not compared with Word) | — | | Gutter, mirror margins, book fold, 2 pages per sheet | 🟡 | gutter and mirror margins (even pages put the inside margin and gutter on the right, #452; page-relative floats stay put, and inside/outside alignment and margins follow the page side, #1181); book fold missing | 2–3 | | Vertical text (`tbRl`) in sections and text boxes; cell text direction | ✅ | cell text direction (#245); text-box text direction (#369); vertical sections (`tbRl`/`btLr`, #491): the page's text area turned, lines top to bottom stacking right to left, ideographs, kana and Hangul upright, Latin on its side, caret and clicks through the turn. Missing: OpenType `vert` alternates (punctuation is moved, not substituted), arrow keys along vertical lines, upright pictures take turned room | 2–4 | | Gutter, mirror margins, book fold, 2 pages per sheet | 🟡 | gutter and mirror margins; book fold missing | 2–3 | @@ -158,6 +158,7 @@ No overall parity estimate changes from these probes. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | trivial | Justified vertical page alignment (#1320): the page's spare room goes equally between its blocks; a section's last page takes its own section's vertical alignment, not the next one's | | 2026-10-13 | trivial | Floats and floating tables aligned inside/outside, or placed in the inside/outside margin, swap sides on even pages; with mirrored margins, page-relative floats keep their place and left/right margin areas are each page's own (#1181) | | 2026-10-13 | trivial | PAGEREF in headers and footers shows its bookmark's page (#1477) | | 2026-10-14 | trivial | A word too wide for the strip beside a square-wrapped object moves past it whole instead of being split across the strip (#1376) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e277639c5..bbb376f9a 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-14 · **Change:** trivial (selected Behind Text pictures drag over overlapping text while ordinary clicks still put the caret there; previously trivial: crop handles on the canvas; previously trivial: Bookmark dialog with Delete and Go To, #1236; previously trivial: the table context menu's Delete offers Delete Table with the caret in a cell, #1198; previously trivial: Update Field on a table of contents entry, #1197; previously trivial: Track Changes Options › Comments: show only the selected text's comment, beyond Word, #1055; previously trivial: Recipient List dialog gains Filter and Sort, #778; previously trivial: Ctrl+drag copies a picture, shape or text box, #1014; previously trivial: the mini toolbar scrolls under the ribbon, not over it; previously trivial: the mini toolbar steps aside for the ribbon; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously 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-14 · **Change:** trivial (Page Setup vertical alignment, #1320; previously trivial: selected Behind Text pictures drag over overlapping text while ordinary clicks still put the caret there; previously trivial: crop handles on the canvas; previously trivial: Bookmark dialog with Delete and Go To, #1236; previously trivial: the table context menu's Delete offers Delete Table with the caret in a cell, #1198; previously trivial: Update Field on a table of contents entry, #1197; previously trivial: Track Changes Options › Comments: show only the selected text's comment, beyond Word, #1055; previously trivial: Recipient List dialog gains Filter and Sort, #778; previously trivial: Ctrl+drag copies a picture, shape or text box, #1014; previously trivial: the mini toolbar scrolls under the ribbon, not over it; previously trivial: the mini toolbar steps aside for the ribbon; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously 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 @@ -51,6 +51,7 @@ Document title opens Rename Document; the account badge opens Options › Genera | Date | Change | Summary | |---|---|---| +| 2026-10-13 | trivial | Page Setup: a Vertical alignment box (Top, Center, Justified, Bottom), applied on OK only if changed (#1320) | | 2026-10-13 | trivial | On-canvas objects: regression-tested dragging Behind Text pictures and shapes across overlapping text (body and header/footer), selected or not, without stealing ordinary text clicks; In Front of Text covered too | | 2026-10-13 | trivial | Insert Hyperlink: Link to › Place in This Document (#1234) | | 2026-10-13 | trivial | Insert › Bookmark as in Word (#1236): the name box over the list, Add / Delete / Go To beside them, Sort by Name or Location, Hidden bookmarks; it opens on the bookmark around the caret, Add (Enter) closes it, Delete and Go To keep it open (Cancel then reads Close), and Go To selects the bookmark's text, as Find and Replace › Go To does (Ctrl+click on a link to a bookmark only puts the caret at its start, as in Word). Not yet: with the caret in no bookmark Word offers the one added last; here the name box starts empty | From 81b5602bf0919e8d5f3cad80300805d01f3565bf Mon Sep 17 00:00:00 2001 From: Nathanael Date: Thu, 15 Oct 2026 06:31:55 +0700 Subject: [PATCH 004/101] insert.picture and insert.icon leave the caret after the picture (#965) (#1437) * contributors: add Nael-Nathanael Co-Authored-By: Claude Opus 5.5 (1M context) * insert.picture and insert.icon leave the caret after the picture (#965) A script that typed right after inserting a picture replaced it, so the picture was missing from the saved file. Commands now leave the caret after the new picture; Picture Format commands still act on it. The app still selects a picture the user inserts, as in Word. Fixes #965 Co-Authored-By: Claude Opus 5.5 (1M context) * Select a pasted picture again, keep pictures inserted in a row (#965) Desktop paste inserts through execute, so after the caret change it left a pasted picture unselected. WordApp::select_inserted_picture selects the picture just before the caret; run and the paste path call it, paste once after the last picture so a multi-file paste keeps them all. Tests for paste selection and for two inserts in a row. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +- .../tests/picture_then_typing.rs | 31 ++++++++++++++ contributors/people.toml | 3 +- crates/engine/src/cmd/insert.rs | 6 ++- crates/engine/src/cmd/objects.rs | 41 +++++++++++++++++++ crates/ui-egui/src/lib.rs | 36 ++++++++++++++++ crates/ui-egui/src/paste_picture.rs | 23 +++++++++++ docs/mcp.md | 3 ++ 8 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 apps/wordcraft-cli/tests/picture_then_typing.rs diff --git a/ROADMAP.md b/ROADMAP.md index 1bd51adc1..2e48a8a90 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-14 · **Change:** trivial (progress log: justified vertical page alignment, #1320; previously trivial: Behind Text pictures and shapes can be dragged across overlaid text, in the body and headers, without taking ordinary text clicks; previously trivial: progress log: crop handles on the canvas; previously trivial: progress log: Insert › Bookmark deletes and goes to bookmarks, #1236; previously trivial: progress log: an option to show only the selected text's comment, #1055; previously trivial: #778 mail-merge recipient filter/sort, .xls and legacy encodings; previously trivial: progress log: clicking in a comment's text moves its caret, #1013; previously trivial: progress log: a press on the ribbon no longer scrolls the document, #1019; previously trivial: title rename and account settings; previously trivial: progress log: Down and Up no longer skip a table, #1007; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; 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-14 · **Change:** trivial (progress log: justified vertical page alignment, #1320; previously trivial: progress log: a picture inserted by a command survives the next typing, #965; previously trivial: Behind Text pictures and shapes can be dragged across overlaid text, in the body and headers, without taking ordinary text clicks; previously trivial: progress log: crop handles on the canvas; previously trivial: progress log: Insert › Bookmark deletes and goes to bookmarks, #1236; previously trivial: progress log: an option to show only the selected text's comment, #1055; previously trivial: #778 mail-merge recipient filter/sort, .xls and legacy encodings; previously trivial: progress log: clicking in a comment's text moves its caret, #1013; previously trivial: progress log: a press on the ribbon no longer scrolls the document, #1019; previously trivial: title rename and account settings; previously trivial: progress log: Down and Up no longer skip a table, #1007; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; 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 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -268,6 +268,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-12 | Save Changes prompt answers from the keyboard: Save has focus when it opens; arrow keys and Tab move between Save, Don't Save and Cancel; Enter answers (#861) | | 2026-10-12 | Builds with craft-fonts draw simplified-Chinese characters in documents from the bundled Noto Sans CJK SC (it was only used by the interface), so a CJK run gets one face covering it; fixes the FreeBSD job's `east_asian_run_uses_one_face_covering_the_whole_run` failure after #567 | | 2026-10-12 | Insert › Picture, drag-and-drop and paste take Windows Metafiles (`.wmf`) and Enhanced Metafiles (`.emf`) at their own frame size (#802) | +| 2026-10-14 | A picture or icon inserted by a command (`wordcraft-cli run`, MCP, control channel) leaves the caret after it, so the next `text.newParagraph` or `text.insert` no longer deletes it, and two inserted in a row are both kept; Picture Format commands still act on it, and a picture the user inserts or pastes in the app is still selected (#965) | | 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) | @@ -449,6 +450,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-12 | trivial | Progress log: symbol-font text drawn as Unicode symbols (#776) | | 2026-10-13 | trivial | Progress log: the Paragraph dialog leaves mixed formatting alone (#1346) | | 2026-10-12 | trivial | Progress log: WMF and EMF pictures can be inserted (#802) | +| 2026-10-14 | trivial | Progress log: a picture inserted by a command survives the next typing (#965) | | 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 | diff --git a/apps/wordcraft-cli/tests/picture_then_typing.rs b/apps/wordcraft-cli/tests/picture_then_typing.rs new file mode 100644 index 000000000..4857213e0 --- /dev/null +++ b/apps/wordcraft-cli/tests/picture_then_typing.rs @@ -0,0 +1,31 @@ +//! `wordcraft-cli run`: a picture inserted by `--cmd insert.picture` stays in the saved file when +//! the next commands type (#965). + +use std::process::Command; + +/// A 1x1 PNG. +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, +]; + +#[test] +fn typing_after_insert_picture_keeps_it_in_the_saved_file() { + let dir = std::env::temp_dir().join(format!("wordcraft-cli-965-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (pic, out) = (dir.join("pic.png"), dir.join("p.docx")); + std::fs::write(&pic, PNG).unwrap(); + let picture = format!(r#"insert.picture={{"path":{},"width":250}}"#, serde_json::json!(pic.to_string_lossy())); + let cmds = [r#"text.insert={"text":"x"}"#, "text.newParagraph", &picture, "text.newParagraph", r#"text.insert={"text":"cap"}"#]; + let mut cli = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")); + cli.args(["run", "--template", "blank"]); + for c in cmds { + cli.args(["--cmd", c]); + } + let run = cli.arg("--save").arg(&out).output().unwrap(); + let docx = std::fs::read(&out); + let _ = std::fs::remove_dir_all(&dir); + assert!(run.status.success(), "stderr: {}", String::from_utf8_lossy(&run.stderr)); + assert!(docx.unwrap().windows(11).any(|w| w == b"word/media/"), "the saved file keeps the picture"); +} diff --git a/contributors/people.toml b/contributors/people.toml index b1fe21397..de2aa3f85 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -84,10 +84,11 @@ display_name = "cyril265" [people.webdevtodayjason] display_name = "Jason Brashear" sync_github_name = false +[people.Nael-Nathanael] +sync_github_name = true [people.Losminek] -sync_github_name = true [people.benandez] real_name = "Martin Ball" display_name = "Martin Ball" diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index b95327646..f90f56291 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -223,7 +223,9 @@ pub(crate) fn picture(s: &mut Session, v: &Value) -> CmdResult { } /// Insert an encoded image at the selection as an inline picture, `width` points wide (default: -/// its natural size at 96 ppi), never wider than the text column. +/// its natural size at 96 ppi), never wider than the text column. The caret ends after it, so a +/// script's next typing doesn't replace it (#965); Picture Format commands still act on it. The +/// app selects a picture the person inserts (`WordApp::run`). fn place_picture(s: &mut Session, bytes: Vec, width: Option, alt: &str) -> CmdResult { if bytes.len() > 200 << 20 { return Err(CmdError::Failed("image is larger than 200 MB".into())); @@ -265,7 +267,7 @@ fn place_picture(s: &mut Session, bytes: Vec, width: Option, alt: &str) let props = s.typing_props(); let at = delete_selection(s)?; let end = s.doc.insert_object(&at, obj, &props)?; - s.sel = Selection { anchor: at, focus: end }; + s.sel = Selection::caret(end); Ok(json!({"media": key, "width": w, "height": h})) } diff --git a/crates/engine/src/cmd/objects.rs b/crates/engine/src/cmd/objects.rs index 163e4f75a..c0552d18d 100644 --- a/crates/engine/src/cmd/objects.rs +++ b/crates/engine/src/cmd/objects.rs @@ -1553,6 +1553,47 @@ mod tests { click_resize_and_drag("insert.picture", json!({"data": data, "width": 100})); } + /// #965: a picture or icon inserted by a command leaves the caret after it, so a script's next + /// Enter or typing keeps it (and the saved file has it); Picture Format commands still reach it. + #[test] + fn typing_after_an_inserted_picture_keeps_it() { + let data = super::super::insert::base64_encode(&png(20, 10)); + for (id, params) in [("insert.picture", json!({"data": data, "width": 100})), ("insert.icon", json!({"id": "sun"}))] { + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("text.insert", &json!({"text": "x"})).unwrap(); + s.run("text.newParagraph", &json!({})).unwrap(); + s.run(id, ¶ms).unwrap(); + assert!(s.sel.is_collapsed(), "{id}: caret, not a selection"); + assert!(selected(&s).is_some(), "{id}: still the object Picture Format acts on"); + s.run("text.newParagraph", &json!({})).unwrap(); + s.run("text.insert", &json!({"text": "cap"})).unwrap(); + assert_eq!(s.run("arrange.selectionPane", &json!({})).unwrap().as_array().map(Vec::len), Some(1), "{id}"); + let paras: Vec = s + .doc + .body + .iter() + .filter_map(|b| match &**b { + wordcraft_doc::Block::Para(p) => Some(p.text.replace(wordcraft_doc::para::OBJ, "#")), + _ => None, + }) + .collect(); + assert_eq!(paras, ["X", "#", "cap"], "{id}"); + let docx = crate::io::save_bytes("p.docx", &s.doc).unwrap(); + assert!(docx.windows(11).any(|w| w == b"word/media/"), "{id}: the saved file keeps the picture"); + } + } + + /// #965: two pictures inserted by commands in a row are both kept; the second doesn't + /// replace the first. + #[test] + fn two_inserted_pictures_in_a_row_are_both_kept() { + let data = super::super::insert::base64_encode(&png(20, 10)); + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("insert.picture", &json!({"data": data})).unwrap(); + s.run("insert.picture", &json!({"data": data})).unwrap(); + assert_eq!(s.run("arrange.selectionPane", &json!({})).unwrap().as_array().map(Vec::len), Some(2)); + } + #[test] fn picture_pipeline() { let mut s = Session::new(wordcraft_doc::Document::new()); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a4b9da8c6..743f78d1f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -712,6 +712,21 @@ impl WordApp { self.session.view.draw.pens = std::mem::take(&mut self.ui.ink_pens); } + /// Select the picture or icon the person just inserted or pasted, so it shows its handles. + /// `insert.picture` leaves the caret after it, which scripts and agents keep so their next + /// typing doesn't replace it (#965). + pub(crate) fn select_inserted_picture(&mut self, r: &Result) { + if !r.as_ref().is_ok_and(|v| v.get("media").is_some() && v.get("canvasMember").is_none()) || !self.session.sel.is_collapsed() { + return; + } + let end = self.session.sel.focus.clone(); + let Some(off) = end.off.checked_sub(wordcraft_doc::para::OBJ.len_utf8()) else { return }; + let start = wordcraft_doc::Pos { off, ..end.clone() }; + if self.session.doc.para_at(&start).and_then(|p| p.object_at(off)).is_some_and(|o| o.is_drawing()) { + self.session.sel = wordcraft_engine::Selection { anchor: start, focus: end }; + } + } + /// Run a command the user asked for (ribbon, shortcut, Backstage, file drop). New, Open, /// Close, Envelopes, Labels and Finish & Merge on a document with unsaved changes first ask /// Save / Don't Save / Cancel, and the command runs once that is answered (`ui.saveChanges`). @@ -754,6 +769,9 @@ impl WordApp { return r; } let r = self.execute_user(id, params); + if matches!(id, "insert.picture" | "insert.icon") { + self.select_inserted_picture(&r); + } let r = system_clipboard::explain(self, id, r); // Check Accessibility shows its findings in the Accessibility pane, kept up to date there. if id == "file.accessibility" && r.is_ok() { @@ -5829,6 +5847,24 @@ mod tests { a.session.run("arrange.selectionPane", &json!({})).unwrap().as_array().map(|x| x.len()).unwrap_or(0) } + /// #965: a picture or icon the person inserts is selected, with handles, as in Word; one a + /// script inserts (control channel, MCP bridge) leaves the caret after it, and typing keeps it. + #[test] + fn inserted_picture_is_selected_for_the_user_not_for_scripts() { + let data = wordcraft_engine::cmd::insert::base64_encode(&png_bytes([200, 30, 30, 255])); + for (id, params) in [("insert.picture", json!({"data": data})), ("insert.icon", json!({"id": "sun"}))] { + let mut a = app(); + a.run(id, params.clone()).unwrap(); + assert!(crate::objects::selected(&a).is_some(), "{id}: the person's picture is selected"); + let mut a = app(); + a.execute(id, params).unwrap(); + assert!(a.session.sel.is_collapsed() && crate::objects::selected(&a).is_none(), "{id}: a script gets the caret after it"); + a.execute("text.newParagraph", json!({})).unwrap(); + a.execute("text.insert", json!({"text": "cap"})).unwrap(); + assert_eq!(object_count(&mut a), 1, "{id}"); + } + } + /// A picture pasted in the browser lands only when the page is the edit target (#1349); /// dropped or opened files always land. #[test] diff --git a/crates/ui-egui/src/paste_picture.rs b/crates/ui-egui/src/paste_picture.rs index 30e44d405..ef223e1bb 100644 --- a/crates/ui-egui/src/paste_picture.rs +++ b/crates/ui-egui/src/paste_picture.rs @@ -121,6 +121,8 @@ pub(crate) fn paste(app: &mut WordApp) -> Option> { r } }; + // Selected once, after the last picture: a selected picture is replaced by the next insert. + app.select_inserted_picture(&r); Some(r) } @@ -239,4 +241,25 @@ mod tests { let focus = a.session.sel.focus.clone(); assert_eq!(a.session.doc.para_at(&focus).map(|p| p.text.contains("wc-ribbon-paste")), Some(false)); } + + /// A pasted picture is selected, with handles, as one inserted from Insert › Pictures is + /// (#965); pasting several copied picture files keeps them all and selects the last. + #[test] + fn pasted_pictures_are_selected() { + let mut a = app_with(|| Some(ClipboardPicture::Pixels { width: 4, height: 4, rgba: vec![200; 4 * 4 * 4] })); + a.run("edit.paste", json!({})).unwrap(); + assert!(!a.session.sel.is_collapsed() && crate::objects::selected(&a).is_some(), "pixels: the pasted picture is selected"); + let dir = std::env::temp_dir().join(format!("wc-paste-965-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let files: Vec = (0..2).map(|i| dir.join(format!("{i}.png"))).collect(); + for f in &files { + std::fs::write(f, png_from_rgba(4, 4, vec![200; 4 * 4 * 4]).unwrap()).unwrap(); + } + let mut a = app_with(move || Some(ClipboardPicture::Files(files.clone()))); + let r = a.run("edit.paste", json!({})); + let _ = std::fs::remove_dir_all(&dir); + r.unwrap(); + assert_eq!(pictures(&mut a), 2, "files: both pictures are kept"); + assert!(!a.session.sel.is_collapsed() && crate::objects::selected(&a).is_some(), "files: the last picture is selected"); + } } diff --git a/docs/mcp.md b/docs/mcp.md index 56161eb11..50f8e07b5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -94,6 +94,9 @@ files it was given rather than guarding against other software on the computer. | `parity` | feature coverage | | `screenshot`, `click`, `key`, `ui_inspect` | app only (`--connect`) | +`insert.picture` and `insert.icon` leave the caret after the new picture, so typing next doesn't +replace it; Picture Format commands (`picture.*`, `arrange.*`) still act on it. + Resources: `wordcraft://document` (inspect) and `wordcraft://commands`. The acceptance test `crates/mcp/src/tests.rs` writes a formatted document using MCP only. From aec3f505823872022c497370a6f3e80fe2688044 Mon Sep 17 00:00:00 2001 From: spiritbearlabs <101562169+spiritbearlabs@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:32:13 -0400 Subject: [PATCH 005/101] macOS: Word for Mac's keys for Save As, Insert Endnote, New Comment, Replace and the ribbon (gap #24) (#1438) On macOS five of Word for Mac's default shortcuts were missing or unreachable: - Cmd+Shift+S is Save As (it opened Apply Styles, which keeps Ctrl+Shift+S on Windows and Linux). - Cmd+Option+E inserts an endnote. macOS takes Cmd+Option+D to show and hide the Dock, so the old key never reached the app. - Cmd+Option+A adds a comment (Cmd+Option+M still works). - Control+H opens Find and Replace (Cmd+H still works). - Cmd+Option+R collapses or expands the ribbon (ui.collapseRibbon). Keys on Windows, Linux and the web are unchanged. Tests pin the bindings on each platform. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 1 + crates/engine/src/cmd/edit.rs | 3 ++- crates/engine/src/cmd/file.rs | 2 +- crates/engine/src/cmd/para.rs | 3 ++- crates/engine/src/cmd/references.rs | 3 ++- crates/engine/src/cmd/review.rs | 2 +- crates/engine/src/tests.rs | 12 ++++++++++++ crates/ui-egui/src/keys.rs | 14 +++++++++++++- docs/ui-parity.md | 5 +++-- 9 files changed, 37 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 2e48a8a90..0134cc895 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -137,6 +137,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| | 2026-10-13 | Justified vertical page alignment (#1320): a section set to Justified spreads each page's paragraphs and tables from the top margin to the bottom one, as Word does, including the last page and pages ending in a manual page break. DOCX `w:vAlign="both"` opens as Justified (it showed centred) and saves back; `layout.verticalAlign` takes `justify`, and Page Setup has a Vertical alignment box | +| 2026-10-13 | macOS shortcuts as in Word for Mac: ⌘⇧S Save As, ⌘⌥E Insert Endnote, ⌘⌥A New Comment, ⌃H Replace, ⌘⌥R collapse the ribbon | | 2026-10-13 | A picture (or shape) set to Behind Text can be dragged over the text it overlaps, whether or not it was selected first, in the body and in a header/footer. The press becomes an object drag only after the pointer moves, so an ordinary click still places the text caret in the overlapping text. The UI regression tests cover In Front of Text too | | 2026-10-13 | Saving to DOCX keeps the watermark: it is written as Word's own text-watermark shape in every header, and a document without headers gains one to hold it (#1290) | | 2026-10-13 | DOCX files with Word's text watermark (Design › Watermark, a VML text shape in the headers) show it on every page on screen and in the PDF (#1290) | diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index f21dc0549..78c1edba2 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -125,7 +125,8 @@ pub fn specs() -> Vec { s.ui_requests.push(json!({"open": tab})); Ok(json!({"open": tab})) }) - .key("Mod+H") + // Word for Mac's own key is ⌃H. + .key(super::mac_or("Mod+H / Ctrl+H", "Mod+H")) .params(r#"{"tab"?: "find|replace|goto" (default replace)}"#) .pure(), CommandSpec::new("edit.replaceAll", "Replace All", "Home › Editing › Replace", replace_all) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 63cebd481..eafca9c3f 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -73,7 +73,7 @@ pub fn specs() -> Vec { .key("Mod+S / Shift+F12") .params(r#"{"path"?: string, "password"?: string (encrypt this and later saves with it, as file.encrypt does), "markup"?: bool (Print Markup for a PDF, PNG, HTML, Markdown or text copy: comments shown; default file.printMarkup), "format"?: "wordml2003" (an .xml file as a Word 2003 XML Document) | "flatopc" (as a Word XML Document, the default; saving over a Word 2003 XML file keeps that format)}"#) .pure(), - CommandSpec::new("file.saveAs", "Save As", "File", save_as).key("F12").params(r#"{"path": string, "password"?: string, "markup"?: bool, "format"?: string (as file.save)}"#).pure(), + CommandSpec::new("file.saveAs", "Save As", "File", save_as).key(super::mac_or("F12 / Mod+Shift+S", "F12")).params(r#"{"path": string, "password"?: string, "markup"?: bool, "format"?: string (as file.save)}"#).pure(), // Pure: the password must not land in Repeat or a recorded macro, and it isn't an edit // to undo; it marks the document as changed itself. CommandSpec::new("file.encrypt", "Encrypt with Password", "File › Info › Protect Document", encrypt) diff --git a/crates/engine/src/cmd/para.rs b/crates/engine/src/cmd/para.rs index 8ed5ac847..9b7885132 100644 --- a/crates/engine/src/cmd/para.rs +++ b/crates/engine/src/cmd/para.rs @@ -226,7 +226,8 @@ pub fn specs() -> Vec { .pure(), CommandSpec::new("styles.apply", "Apply Styles", "Home › Styles", apply_styles) .params(r#"{"style"?: string (name or id)}"#) - .key("Mod+Shift+S") + // On macOS ⌘⇧S is Save As, as in Word for Mac. + .key(super::non_mac("Mod+Shift+S")) .pure(), CommandSpec::new("styles.addToGallery", "Add to Style Gallery", "Home › Styles", |s, v| { let id = style_id(s, p::req_str(v, "style")?)?; diff --git a/crates/engine/src/cmd/references.rs b/crates/engine/src/cmd/references.rs index 87f5463cc..897b9c45f 100644 --- a/crates/engine/src/cmd/references.rs +++ b/crates/engine/src/cmd/references.rs @@ -37,7 +37,8 @@ pub fn specs() -> Vec { .key("Mod+Alt+F") .params(r#"{"text"?: string, "mark"?: string (a custom mark instead of the number)}"#), CommandSpec::new("references.endnote", "Insert Endnote", "References › Footnotes", |s, v| note(s, v, NoteKind::Endnote)) - .key("Mod+Alt+D") + // macOS takes ⌘⌥D to show and hide the Dock; Word for Mac uses ⌘⌥E. + .key(super::mac_or("Mod+Alt+E", "Mod+Alt+D")) .params(r#"{"text"?: string, "mark"?: string (a custom mark instead of the number)}"#), CommandSpec::new("references.convertNotes", "Convert Notes", "References › Footnotes", |s, v| { let (foot, end) = match p::req_str(v, "to")? { diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index 66e2f5ec4..49ea34048 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -9,7 +9,7 @@ use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; pub fn specs() -> Vec { vec![ - CommandSpec::new("review.newComment", "New Comment", "Review › Comments", new_comment).key("Mod+Alt+M").params(r#"{"text": string}"#), + CommandSpec::new("review.newComment", "New Comment", "Review › Comments", new_comment).key(super::mac_or("Mod+Alt+A / Mod+Alt+M", "Mod+Alt+M")).params(r#"{"text": string}"#), CommandSpec::new("review.reply", "Reply", "Review › Comments", reply).params(r#"{"id": n, "text": string}"#), CommandSpec::new("review.editComment", "Edit Comment", "Review › Comments", edit_comment).params(r#"{"id": n, "text": string}"#), CommandSpec::new("review.deleteComment", "Delete", "Review › Comments", delete_comment).params(r#"{"id"?: n, "all"?: bool}"#), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 49af87d4b..811da96b6 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -119,12 +119,24 @@ fn word_default_shortcuts_resolve() { assert_eq!(id(cmd::mac_or("Ctrl+Shift+D", "Alt+Shift+D")), Some("insert.dateField")); if cfg!(target_os = "macos") { assert_eq!(id("Mod+Shift+G"), Some("edit.findPrevious")); + // Word for Mac's own keys: ⌘⇧S Save As, ⌘⌥E endnote (⌘⌥D hides the Dock), ⌘⌥A comment, ⌃H Replace. + assert_eq!(id("Mod+Shift+S"), Some("file.saveAs")); + assert_eq!(id("Mod+Alt+E"), Some("references.endnote")); + assert_eq!(id("Mod+Alt+D"), None); + assert_eq!(id("Mod+Alt+A"), Some("review.newComment")); + assert_eq!(id("Ctrl+H"), Some("edit.findReplace")); } else { + assert_eq!(id("Mod+Shift+S"), Some("styles.apply")); + assert_eq!(id("Mod+Alt+D"), Some("references.endnote")); + assert_eq!(id("Mod+Alt+A"), None); assert_eq!(id("Mod+Shift+G"), Some("review.wordCount")); assert_eq!(id("Mod+Alt+C"), Some("text.copyright")); assert_eq!(id("Mod+Alt+H"), Some("format.highlight")); } assert_eq!(id("Mod+PageUp"), Some("edit.findPrevious")); + assert_eq!(id("F12"), Some("file.saveAs")); + assert_eq!(id("Mod+Alt+M"), Some("review.newComment")); + assert_eq!(id("Mod+H"), Some("edit.findReplace")); } #[test] diff --git a/crates/ui-egui/src/keys.rs b/crates/ui-egui/src/keys.rs index b0406cd32..872a20bb5 100644 --- a/crates/ui-egui/src/keys.rs +++ b/crates/ui-egui/src/keys.rs @@ -106,11 +106,13 @@ pub fn combo(m: Modifiers, key: &str, with_shift: bool) -> String { /// Shortcuts for interface commands (`ui.*`), which aren't in the engine's keymap: Alt+Q moves /// to "Tell me" command search, as in Word for Windows (#819). Not on macOS, where Option+Q types -/// a character. +/// a character. On macOS ⌘⌥R collapses or expands the ribbon, as in Word for Mac. fn ui_shortcut(key: Key, m: Modifiers) -> Option<(&'static str, Value)> { let alt_only = m.alt && !m.command && !m.ctrl && !m.mac_cmd && !m.shift; + let mac_cmd_alt = cfg!(target_os = "macos") && m.mac_cmd && m.alt && !m.ctrl && !m.shift; match key { Key::Q if alt_only && !cfg!(target_os = "macos") => Some(("ui.dialog", json!({"name": "commands"}))), + Key::R if mac_cmd_alt => Some(("ui.collapseRibbon", json!({}))), _ => None, } } @@ -592,6 +594,16 @@ mod tests { assert_eq!(ui_shortcut(Key::W, m(true, false, false)), None); } + #[test] + fn cmd_option_r_collapses_the_ribbon_on_macos() { + let m = |mac_cmd, alt, shift| egui::Modifiers { mac_cmd, command: mac_cmd, alt, shift, ..Default::default() }; + let collapse = Some(("ui.collapseRibbon", json!({}))); + assert_eq!(ui_shortcut(Key::R, m(true, true, false)), if cfg!(target_os = "macos") { collapse } else { None }); + assert_eq!(ui_shortcut(Key::R, m(true, false, false)), None, "Mod+R aligns right"); + assert_eq!(ui_shortcut(Key::R, m(false, true, false)), None, "Option+R types a character"); + assert_eq!(ui_shortcut(Key::R, m(true, true, true)), None, "Mod+Alt+Shift+R"); + } + #[test] fn only_plain_paste_releases_paste_a_clipboard_picture() { let m = |command, shift, alt| egui::Modifiers { command, shift, alt, ..Default::default() }; diff --git a/docs/ui-parity.md b/docs/ui-parity.md index bbb376f9a..3d09f7e85 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-14 · **Change:** trivial (Page Setup vertical alignment, #1320; previously trivial: selected Behind Text pictures drag over overlapping text while ordinary clicks still put the caret there; previously trivial: crop handles on the canvas; previously trivial: Bookmark dialog with Delete and Go To, #1236; previously trivial: the table context menu's Delete offers Delete Table with the caret in a cell, #1198; previously trivial: Update Field on a table of contents entry, #1197; previously trivial: Track Changes Options › Comments: show only the selected text's comment, beyond Word, #1055; previously trivial: Recipient List dialog gains Filter and Sort, #778; previously trivial: Ctrl+drag copies a picture, shape or text box, #1014; previously trivial: the mini toolbar scrolls under the ribbon, not over it; previously trivial: the mini toolbar steps aside for the ribbon; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously 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-14 · **Change:** trivial (Page Setup vertical alignment, #1320; previously trivial: macOS shortcuts as in Word for Mac; previously trivial: selected Behind Text pictures drag over overlapping text while ordinary clicks still put the caret there; previously trivial: crop handles on the canvas; previously trivial: Bookmark dialog with Delete and Go To, #1236; previously trivial: the table context menu's Delete offers Delete Table with the caret in a cell, #1198; previously trivial: Update Field on a table of contents entry, #1197; previously trivial: Track Changes Options › Comments: show only the selected text's comment, beyond Word, #1055; previously trivial: Recipient List dialog gains Filter and Sort, #778; previously trivial: Ctrl+drag copies a picture, shape or text box, #1014; previously trivial: the mini toolbar scrolls under the ribbon, not over it; previously trivial: the mini toolbar steps aside for the ribbon; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously 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 @@ -18,8 +18,8 @@ Document title opens Rename Document; the account badge opens Options › Genera | 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 | | Quick Access Toolbar | AutoSave, then the user's commands (Save, Undo, Redo by default); add, remove and reorder from its … menu (common commands, More Commands…), right-click on any ribbon button, or Customize Ribbon; saved per user (#379, #376). Undo's ▾ list: recent steps newest first, hover highlights the steps a click undoes ("Undo 3 Actions"), one click undoes them all (#701; `edit.undoTo`, `edit.undoHistory`). Not shown below the ribbon | customizable | 90% | 0.5 | | Customize Ribbon / Customize Keyboard | Customize Keyboard (#368): categories, a command's keys, press a combination to see which command uses it, Assign / Remove / Reset All; custom keys override built-ins, removed built-ins do nothing, saved per user; `tools.customizeKeyboard` for agents. Customize Ribbon (#379): show, hide and reorder tabs, custom tabs and custom groups (on any tab) holding any commands, rename, remove, Reset; right-click on the ribbon; saved per user; `tools.customizeRibbon` for agents. Built-in tabs can't be renamed; no import/export file | ✅ | 85% | 1–2 | +| Keyboard shortcuts | **152** commands bound in `CommandSpec::key`, 172 key combinations (counted from the registry on Linux, 2026-10-13); styles, spacing, symbols, fields, views, mail merge (#330); field keys Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, Shift+F11 (#339), Next Field on Alt+F1 and Alt+Shift+F1 (Word's other keys for it) since F11 went to full screen (#980; GNOME and KDE may keep Alt+F1 for their own overview or application menu, and then Next Field has no key there; Previous Field keeps Shift+F11); 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); Alt+Q to "Tell me" command search (#819, not on macOS); on macOS, Word for Mac's own keys: ⌘⇧S Save As, ⌘⌥E Insert Endnote, ⌘⌥A New Comment, ⌃H Replace, ⌘⌥R collapse the ribbon. Missing: en dash on the numeric keypad Insert Hyperlink has Word's Link to: Existing File or Web Page / Place in This Document (top of the document, headings, bookmarks) (#1234). | ~250 default shortcuts | ~70% | 2–4 | | Modal dialogs | **56** (`Dialog` enum: Discard Recovery, 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); Cross-reference (#916); Line Numbers, Hyphenation with Manual Hyphenation, Language, Envelopes, Labels (#407); Page Setup has a Paper size list and custom width and height, opened from Layout › Size › More Paper Sizes… (#1433)) plus panes and menus for the rest | ~100 (Options panes…) | ~45% | 11–19 | -| Keyboard shortcuts | **152** commands bound in `CommandSpec::key`, 172 key combinations (counted from the registry on Linux, 2026-10-13); styles, spacing, symbols, fields, views, mail merge (#330); field keys Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, Shift+F11 (#339), Next Field on Alt+F1 and Alt+Shift+F1 (Word's other keys for it) since F11 went to full screen (#980; GNOME and KDE may keep Alt+F1 for their own overview or application menu, and then Next Field has no key there; Previous Field keeps Shift+F11); 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); Alt+Q to "Tell me" command search (#819, not on macOS). Missing: en dash on the numeric keypad Insert Hyperlink has Word's Link to: Existing File or Web Page / Place in This Document (top of the document, headings, bookmarks) (#1234). | ~250 default shortcuts | ~70% | 2–4 | | 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; Update Field on a table of contents entry updates the table, #1197); right-click selects a picture, shape or text box and offers Wrap Text (styles, Move with Text / Fix Position on Page, #513); tables, the items following the selection as in Word (Insert, Delete › Cells, Columns, Rows or Table whatever is selected (#1198), Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…; no Border Styles yet) | rich per context | 65% | 2–4 | | 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); launched with nothing to open, the app starts on Home (the Start screen: Blank document, templates, Recent; no document is open until one is picked or opened, so Esc or Back shows an empty window with the ribbon greyed out but for Help, #1129), with File › Options › General › Start-up to turn it off (#766); Info › Inspect Document finds and removes hidden text by its effective formatting (direct, character or paragraph style) in every story: body, headers, footers, notes, comments and text boxes, and lists what it couldn't reach (#1344) | | 68% | 4–6 | @@ -52,6 +52,7 @@ Document title opens Rename Document; the account badge opens Options › Genera | Date | Change | Summary | |---|---|---| | 2026-10-13 | trivial | Page Setup: a Vertical alignment box (Top, Center, Justified, Bottom), applied on OK only if changed (#1320) | +| 2026-10-13 | trivial | macOS shortcuts as in Word for Mac: ⌘⇧S Save As (Apply Styles keeps Ctrl+Shift+S elsewhere), ⌘⌥E Insert Endnote (macOS takes ⌘⌥D for the Dock), ⌘⌥A New Comment, ⌃H Replace, ⌘⌥R collapse the ribbon | | 2026-10-13 | trivial | On-canvas objects: regression-tested dragging Behind Text pictures and shapes across overlapping text (body and header/footer), selected or not, without stealing ordinary text clicks; In Front of Text covered too | | 2026-10-13 | trivial | Insert Hyperlink: Link to › Place in This Document (#1234) | | 2026-10-13 | trivial | Insert › Bookmark as in Word (#1236): the name box over the list, Add / Delete / Go To beside them, Sort by Name or Location, Hidden bookmarks; it opens on the bookmark around the caret, Add (Enter) closes it, Delete and Go To keep it open (Cancel then reads Close), and Go To selects the bookmark's text, as Find and Replace › Go To does (Ctrl+click on a link to a bookmark only puts the caret at its start, as in Word). Not yet: with the caret in no bookmark Word offers the one added last; here the name box starts empty | From bbdcc7c62f0f5a74bfc8792f51ea2044c27f93b8 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Thu, 15 Oct 2026 01:32:30 +0200 Subject: [PATCH 006/101] Proofing: status bar flags a language whose dictionary isn't installed (#1167) (#1441) * Proofing: status bar flags a language whose dictionary isn't installed (#1167) Co-Authored-By: Claude Sonnet 5.5 * vi: missing-dictionary status strings Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Sonnet 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 1 + crates/proof/src/lib.rs | 39 +++++++++++++++++++++++++++++ crates/ui-egui/src/chrome.rs | 11 +++++++- 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 | 2 ++ crates/ui-egui/src/i18n/uk.tsv | 2 ++ crates/ui-egui/src/i18n/vi.tsv | 4 +++ crates/ui-egui/src/i18n/zh-hans.tsv | 2 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 2 ++ crates/ui-egui/src/ribbon.rs | 5 ++++ 23 files changed, 95 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0134cc895..69827c208 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -138,6 +138,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm |---|---| | 2026-10-13 | Justified vertical page alignment (#1320): a section set to Justified spreads each page's paragraphs and tables from the top margin to the bottom one, as Word does, including the last page and pages ending in a manual page break. DOCX `w:vAlign="both"` opens as Justified (it showed centred) and saves back; `layout.verticalAlign` takes `justify`, and Page Setup has a Vertical alignment box | | 2026-10-13 | macOS shortcuts as in Word for Mac: ⌘⇧S Save As, ⌘⌥E Insert Endnote, ⌘⌥A New Comment, ⌃H Replace, ⌘⌥R collapse the ribbon | +| 2026-10-13 | The status bar flags a proofing language whose Hunspell dictionary isn't installed (Polish, Portuguese): "Polish (no dictionary)", with a tooltip naming the dictionary and where it is looked up (#1167) | | 2026-10-13 | A picture (or shape) set to Behind Text can be dragged over the text it overlaps, whether or not it was selected first, in the body and in a header/footer. The press becomes an object drag only after the pointer moves, so an ordinary click still places the text caret in the overlapping text. The UI regression tests cover In Front of Text too | | 2026-10-13 | Saving to DOCX keeps the watermark: it is written as Word's own text-watermark shape in every header, and a document without headers gains one to hold it (#1290) | | 2026-10-13 | DOCX files with Word's text watermark (Design › Watermark, a VML text shape in the headers) show it on every page on screen and in the PDF (#1290) | diff --git a/crates/proof/src/lib.rs b/crates/proof/src/lib.rs index 655beef1c..17a96bb9b 100644 --- a/crates/proof/src/lib.rs +++ b/crates/proof/src/lib.rs @@ -359,6 +359,28 @@ pub fn has_dictionary(lang: Option<&str>) -> bool { ProofLang::from_tag(lang).is_proofed() } +/// The Hunspell dictionary name (`pl_PL`, `pt_BR`) a proofing language needs but doesn't have +/// installed, for a status-bar hint (#1167). `None` when the language's dictionary is available, +/// or when it needs none (English has a built-in word list; unproofed languages aren't offered +/// one). Reads the dictionary once (it is cached afterwards). +pub fn missing_dictionary(lang: Option<&str>) -> Option<&'static str> { + match ProofLang::from_tag(lang) { + ProofLang::Pl => dictionary_needed(ProofLang::Pl, pl::has_dictionary()), + ProofLang::Pt => dictionary_needed(ProofLang::Pt, pt::has_dictionary()), + _ => None, + } +} + +/// The Hunspell name `lang` needs when `installed` is false; only Polish and Portuguese use one. +fn dictionary_needed(lang: ProofLang, installed: bool) -> Option<&'static str> { + match lang { + _ if installed => None, + ProofLang::Pl => Some("pl_PL"), + ProofLang::Pt => Some("pt_BR"), + _ => None, + } +} + /// 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 { @@ -782,6 +804,23 @@ fn starts_consonant_sound(w: &str) -> bool { #[cfg(test)] mod tests { + #[test] + fn missing_dictionary_is_named_only_for_hunspell_languages_without_one() { + assert_eq!(dictionary_needed(ProofLang::Pl, false), Some("pl_PL")); + assert_eq!(dictionary_needed(ProofLang::Pt, false), Some("pt_BR")); + assert_eq!(dictionary_needed(ProofLang::Pl, true), None); + assert_eq!(dictionary_needed(ProofLang::Pt, true), None); + for l in [ProofLang::En, ProofLang::Bg, ProofLang::Other] { + assert_eq!(dictionary_needed(l, false), None); + } + // Installed dictionaries and the built-in English list: no hint. + pl::test_dict::install(); + pt::test_dict::install(); + for tag in [Some("pl-PL"), Some("pt-BR"), Some("en-US"), Some("fr-FR"), None] { + assert_eq!(missing_dictionary(tag), None, "{tag:?}"); + } + } + use super::*; /// #1063: the dictionaries folder is searched before the system locations; the dictionaries diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index cf187a016..c057992ee 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -448,7 +448,16 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) { } // 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")); + let missing = crate::ribbon::caret_missing_dictionary(app); + let shown = if missing.is_some() { crate::i18n::fmt(tl!("{language} (no dictionary)"), &[("language", &lang)]) } else { lang.clone() }; + let tip = match missing { + Some(name) => crate::i18n::fmt( + tl!("No spelling dictionary for {language} is installed, so it isn't checked. WordCraft looks for the Hunspell dictionary {name} in the WORDCRAFT_DICTIONARIES folder and in the usual system locations."), + &[("language", &lang), ("name", name)], + ), + None => tl!("Language").to_string(), + }; + let resp = st(ui, &shown).on_hover_text(tip); egui::Popup::menu(&resp).show(|ui| { ui.set_min_width(200.0); crate::ribbon::language_menu(ui, app); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index f6eb762ee..d5716a4de 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -458,6 +458,8 @@ Labels Етикети Landscape Пейзаж Language Език + {language} (no dictionary) {language} (няма речник) + No spelling dictionary for {language} is installed, so it isn't checked. WordCraft looks for the Hunspell dictionary {name} in the WORDCRAFT_DICTIONARIES folder and in the usual system locations. Не е инсталиран правописен речник за {language}, затова текстът не се проверява. WordCraft търси речника Hunspell {name} в папката WORDCRAFT_DICTIONARIES и на обичайните места в системата. Lasso Ласо Last Column Последна колона Last commit Последно подаване diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 7dc96cccb..52544718f 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -619,6 +619,8 @@ Labels Štítky Landscape Na šířku Language Jazyk + {language} (no dictionary) {language} (bez slovníku) + No spelling dictionary for {language} is installed, so it isn't checked. WordCraft looks for the Hunspell dictionary {name} in the WORDCRAFT_DICTIONARIES folder and in the usual system locations. Pro jazyk {language} není nainstalován žádný pravopisný slovník, proto se nekontroluje. WordCraft hledá slovník Hunspell {name} ve složce WORDCRAFT_DICTIONARIES a na obvyklých místech v systému. Large\nOperator Velký\noperátor Lasso Laso Last Column Poslední sloupec diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 0ec7b28fe..1d68a971e 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -456,6 +456,8 @@ Labels Etiketten Landscape Querformat Language Sprache + {language} (no dictionary) {language} (kein Wörterbuch) + No spelling dictionary for {language} is installed, so it isn't checked. WordCraft looks for the Hunspell dictionary {name} in the WORDCRAFT_DICTIONARIES folder and in the usual system locations. Für {language} ist kein Rechtschreibwörterbuch installiert, daher wird die Sprache nicht geprüft. WordCraft sucht das Hunspell-Wörterbuch {name} im Ordner WORDCRAFT_DICTIONARIES und an den üblichen Systemorten. Lasso Lasso Last Column Letzte Spalte Last commit Letzter Commit diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 7c38faeee..7949ddca9 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -525,6 +525,8 @@ Labels Etiquetas Landscape Horizontal Language Idioma + {language} (no dictionary) {language} (sin diccionario) + No spelling dictionary for {language} is installed, so it isn't checked. WordCraft looks for the Hunspell dictionary {name} in the WORDCRAFT_DICTIONARIES folder and in the usual system locations. No hay un diccionario ortográfico instalado para {language}, así que no se revisa. WordCraft busca el diccionario Hunspell {name} en la carpeta WORDCRAFT_DICTIONARIES y en las ubicaciones habituales del sistema. Lasso Lazo Last Column Última columna Last commit Último commit diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 31ec87cc6..15398bd9f 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -400,6 +400,8 @@ Labels Sildid Landscape Rõhtpaigutus Language Keel + {language} (no dictionary) {language} (sõnastik puudub) + No spelling dictionary for {language} is installed, so it isn't checked. WordCraft looks for the Hunspell dictionary {name} in the WORDCRAFT_DICTIONARIES folder and in the usual system locations. Keele {language} õigekirjasõnastikku pole paigaldatud, seega seda ei kontrollita. WordCraft otsib Hunspelli sõnastikku {name} kaustast WORDCRAFT_DICTIONARIES ja tavalistest süsteemikohtadest. Lasso Lasso Last Column Viimane veerg Last commit Viimane commit diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ac2eae730..f01867b9a 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -470,6 +470,8 @@ Labels Étiquettes Landscape Paysage Language Langue + {language} (no dictionary) {language} (pas de dictionnaire) + No spelling dictionary for {language} is installed, so it isn't checked. WordCraft looks for the Hunspell dictionary {name} in the WORDCRAFT_DICTIONARIES folder and in the usual system locations. Aucun dictionnaire orthographique pour {language} n'est installé, la langue n'est donc pas vérifiée. WordCraft cherche le dictionnaire Hunspell {name} dans le dossier WORDCRAFT_DICTIONARIES et aux emplacements système habituels. Lasso Lasso Last Column Dernière colonne Last commit Dernier commit diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 469323e12..2b5553246 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -385,6 +385,8 @@ Labels Címkék Landscape Fekvő Language Nyelv + {language} (no dictionary) {language} (nincs szótár) + No spelling dictionary for {language} is installed, so it isn't checked. WordCraft looks for the Hunspell dictionary {name} in the WORDCRAFT_DICTIONARIES folder and in the usual system locations. A(z) {language} nyelvhez nincs telepítve helyesírási szótár, ezért nem ellenőrzi. A WordCraft a(z) {name} Hunspell-szótárat a WORDCRAFT_DICTIONARIES mappában és a szokásos rendszerhelyeken keresi. Lasso Lasszó Last Column Utolsó oszlop Last commit Utolsó commit diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 17a17e802..8dc27b47d 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -454,6 +454,8 @@ Labels Etichette Landscape Orizzontale Language Lingua + {language} (no dictionary) {language} (nessun dizionario) + No spelling dictionary for {language} is installed, so it isn't checked. WordCraft looks for the Hunspell dictionary {name} in the WORDCRAFT_DICTIONARIES folder and in the usual system locations. Non è installato alcun dizionario ortografico per {language}, quindi non viene controllata. WordCraft cerca il dizionario Hunspell {name} nella cartella WORDCRAFT_DICTIONARIES e nelle posizioni di sistema abituali. Lasso Lazo Last Column Ultima colonna Last commit Ultimo commit diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 096eeb508..57c7101e2 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -513,6 +513,8 @@ Labels ラベル Landscape 横 Language 言語 + {language} (no dictionary) {language}(辞書なし) + No spelling dictionary for {language} is installed, so it isn't checked. WordCraft looks for the Hunspell dictionary {name} in the WORDCRAFT_DICTIONARIES folder and in the usual system locations. {language}のスペルチェック辞書がインストールされていないため、チェックされません。WordCraft は Hunspell 辞書 {name} を WORDCRAFT_DICTIONARIES フォルダーと通常のシステムの場所から探します。 Lasso なげなわ Last Column 最後の列 Last commit 最後のコミット diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 83e887f0d..9cc35de8d 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -376,6 +376,8 @@ Labels Etykiety Landscape Pozioma Language Język + {language} (no dictionary) {language} (brak słownika) + No spelling dictionary for {language} is installed, so it isn't checked. WordCraft looks for the Hunspell dictionary {name} in the WORDCRAFT_DICTIONARIES folder and in the usual system locations. Nie zainstalowano słownika pisowni dla języka: {language}, więc tekst nie jest sprawdzany. WordCraft szuka słownika Hunspell {name} w folderze WORDCRAFT_DICTIONARIES i w typowych lokalizacjach systemowych. Lasso Lasso Last Column Ostatnia kolumna Last commit Ostatni commit diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 24d150f16..45b6c75d3 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -514,6 +514,8 @@ Labels Etiquetas Landscape Paisagem Language Idioma + {language} (no dictionary) {language} (sem dicionário) + No spelling dictionary for {language} is installed, so it isn't checked. WordCraft looks for the Hunspell dictionary {name} in the WORDCRAFT_DICTIONARIES folder and in the usual system locations. Não há dicionário ortográfico instalado para {language}, então o texto não é verificado. O WordCraft procura o dicionário Hunspell {name} na pasta WORDCRAFT_DICTIONARIES e nos locais habituais do sistema. Lasso Laço Last Column Última coluna Last commit Último commit diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 12a1a3458..2e736fcbb 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -387,6 +387,8 @@ Labels Наклейки Landscape Альбомная Language Язык + {language} (no dictionary) {language} (нет словаря) + No spelling dictionary for {language} is installed, so it isn't checked. WordCraft looks for the Hunspell dictionary {name} in the WORDCRAFT_DICTIONARIES folder and in the usual system locations. Орфографический словарь для языка «{language}» не установлен, поэтому текст не проверяется. WordCraft ищет словарь Hunspell {name} в папке WORDCRAFT_DICTIONARIES и в обычных системных расположениях. Lasso Лассо Last Column Последний столбец Last commit Последний коммит diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index d35caa79e..9bbef566a 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -561,6 +561,8 @@ Labels Nalepnice Landscape Položeno Language Jezik + {language} (no dictionary) {language} (nema rečnika) + No spelling dictionary for {language} is installed, so it isn't checked. WordCraft looks for the Hunspell dictionary {name} in the WORDCRAFT_DICTIONARIES folder and in the usual system locations. Pravopisni rečnik za {language} nije instaliran, pa se tekst ne proverava. WordCraft traži Hunspell rečnik {name} u fascikli WORDCRAFT_DICTIONARIES i na uobičajenim sistemskim lokacijama. Lasso Laso Last Column Zadnja kolona Last commit Zadnji commit diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 4edfc6b4a..1b247b5bc 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -565,6 +565,8 @@ Labels Налепнице Landscape Положено Language Језик + {language} (no dictionary) {language} (нема речника) + No spelling dictionary for {language} is installed, so it isn't checked. WordCraft looks for the Hunspell dictionary {name} in the WORDCRAFT_DICTIONARIES folder and in the usual system locations. Правописни речник за {language} није инсталиран, па се текст не проверава. WordCraft тражи Hunspell речник {name} у фасцикли WORDCRAFT_DICTIONARIES и на уобичајеним системским локацијама. Lasso Ласо Last Column Задња колона Last commit Задњи commit diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 40dacff93..2c51a44e1 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -385,6 +385,8 @@ Labels Etiketler Landscape Yatay Language Dil + {language} (no dictionary) {language} (sözlük yok) + No spelling dictionary for {language} is installed, so it isn't checked. WordCraft looks for the Hunspell dictionary {name} in the WORDCRAFT_DICTIONARIES folder and in the usual system locations. {language} için yüklü bir yazım sözlüğü yok, bu yüzden denetlenmiyor. WordCraft, {name} Hunspell sözlüğünü WORDCRAFT_DICTIONARIES klasöründe ve olağan sistem konumlarında arar. Lasso Kement Last Column Son Sütun Last commit Son commit diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index e869a299a..8c305e2e1 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -528,6 +528,8 @@ Labels Етикетки Landscape Альбомна Language Мова + {language} (no dictionary) {language} (немає словника) + No spelling dictionary for {language} is installed, so it isn't checked. WordCraft looks for the Hunspell dictionary {name} in the WORDCRAFT_DICTIONARIES folder and in the usual system locations. Орфографічний словник для мови «{language}» не встановлено, тому текст не перевіряється. WordCraft шукає словник Hunspell {name} у теці WORDCRAFT_DICTIONARIES та в звичайних системних розташуваннях. Lasso Ласо Last Column Останній стовпець Last commit Останній коміт diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 9aaeddc39..8b3cbf4f2 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2614,3 +2614,7 @@ Existing File or Web Page Tệp hoặc trang web hiện có Link to: Liên kết tới: Place in This Document Vị trí trong tài liệu này + +# Missing dictionary on the status bar (#1441) + No spelling dictionary for {language} is installed, so it isn't checked. WordCraft looks for the Hunspell dictionary {name} in the WORDCRAFT_DICTIONARIES folder and in the usual system locations. Chưa cài từ điển chính tả cho {language}, nên ngôn ngữ này không được kiểm tra. WordCraft tìm từ điển Hunspell {name} trong thư mục WORDCRAFT_DICTIONARIES và ở các vị trí thông thường của hệ thống. + {language} (no dictionary) {language} (không có từ điển) diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 2d94a6779..1bc64bfdf 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -525,6 +525,8 @@ Labels 标签 Landscape 横向 Language 语言 + {language} (no dictionary) {language}(无词典) + No spelling dictionary for {language} is installed, so it isn't checked. WordCraft looks for the Hunspell dictionary {name} in the WORDCRAFT_DICTIONARIES folder and in the usual system locations. 未安装{language}的拼写词典,因此不会检查该语言。WordCraft 会在 WORDCRAFT_DICTIONARIES 文件夹和常见的系统位置查找 Hunspell 词典 {name}。 Lasso 套索 Last Column 最后一列 Last commit 最近提交 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 1248a60a1..483de8d54 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -514,6 +514,8 @@ Labels 標籤 Landscape 橫向 Language 語言 + {language} (no dictionary) {language}(無字典) + No spelling dictionary for {language} is installed, so it isn't checked. WordCraft looks for the Hunspell dictionary {name} in the WORDCRAFT_DICTIONARIES folder and in the usual system locations. 未安裝{language}的拼字字典,因此不會檢查該語言。WordCraft 會在 WORDCRAFT_DICTIONARIES 資料夾和常見的系統位置尋找 Hunspell 字典 {name}。 Lasso 套索 Last Column 最後一欄 Last commit 最近提交 diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 1b79fe859..7be3ff317 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -635,6 +635,11 @@ fn caret_lang_tag(app: &WordApp) -> Option { s.doc.styles.resolve_char(style.as_deref(), &s.typing_props()).lang } +/// The Hunspell dictionary the caret's language needs but doesn't have (`pl_PL`), if any (#1167). +pub(crate) fn caret_missing_dictionary(app: &WordApp) -> Option<&'static str> { + wordcraft_engine::proof::missing_dictionary(caret_lang_tag(app).as_deref()) +} + /// 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; a language outside the short list /// (French, German…) shows checked above it. Set Proofing Language… opens the Language dialog From 3cc0c16302c6f13cbaa9d5ea948694a6b65e036f Mon Sep 17 00:00:00 2001 From: Nathanael Date: Thu, 15 Oct 2026 06:32:48 +0700 Subject: [PATCH 007/101] A file name that isn't valid Unicode no longer crashes wordcraft-cli or the app (#1440) (#1444) * A file name that isn't valid Unicode no longer crashes wordcraft-cli or the app (#1440) Both binaries read std::env::args(), which panics on a non-Unicode argument such as a Latin-1 file name. The CLI now reads args_os(): the file a command opens keeps its real name and opens (convert, info, text, inspect, render, zotero, run --file), and any other such argument is an error naming it, exit 1. The app leaves such a file out with a warning, as it does a file that fails to open, since file.open takes its path as text; a --control port that isn't valid Unicode exits 2. Co-Authored-By: Claude Opus 5.5 (1M context) * The app opens a file whose name isn't valid Unicode too (#1440) file.open takes its path as text but also accepts the file's bytes under a name, as dropped files use. The app reads such a file, opens its bytes under the lossy name and keeps the real path on the session, so Save writes back to it. A unit test covers the open and the save. The CLI test now checks that every command in OPENS_FILE reaches its open with the file, and no longer pins `pos` counting a `--page` value. ROADMAP says which saves go back to the file. Co-Authored-By: Claude Opus 5.5 (1M context) * #1440: skip non-UTF-8 file name tests on macOS APFS refuses file names that aren't valid UTF-8, so the fixtures fail with "Illegal byte sequence" there, and the app-start test keeps running because macOS opens a window without a display variable. The unit test for the CLI's argument checks and the control-port test still run. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +- apps/wordcraft-cli/src/main.rs | 72 +++++++++++++++++++-- apps/wordcraft-cli/tests/non_utf8_args.rs | 76 +++++++++++++++++++++++ apps/wordcraft/src/main.rs | 76 ++++++++++++++++++++++- apps/wordcraft/tests/non_utf8_args.rs | 53 ++++++++++++++++ 5 files changed, 273 insertions(+), 8 deletions(-) create mode 100644 apps/wordcraft-cli/tests/non_utf8_args.rs create mode 100644 apps/wordcraft/tests/non_utf8_args.rs diff --git a/ROADMAP.md b/ROADMAP.md index 69827c208..1c79e56bc 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-14 · **Change:** trivial (progress log: justified vertical page alignment, #1320; previously trivial: progress log: a picture inserted by a command survives the next typing, #965; previously trivial: Behind Text pictures and shapes can be dragged across overlaid text, in the body and headers, without taking ordinary text clicks; previously trivial: progress log: crop handles on the canvas; previously trivial: progress log: Insert › Bookmark deletes and goes to bookmarks, #1236; previously trivial: progress log: an option to show only the selected text's comment, #1055; previously trivial: #778 mail-merge recipient filter/sort, .xls and legacy encodings; previously trivial: progress log: clicking in a comment's text moves its caret, #1013; previously trivial: progress log: a press on the ribbon no longer scrolls the document, #1019; previously trivial: title rename and account settings; previously trivial: progress log: Down and Up no longer skip a table, #1007; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; 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-14 · **Change:** trivial (progress log: justified vertical page alignment, #1320; previously trivial: progress log: file names that aren't valid Unicode, #1440; previously trivial: Behind Text pictures and shapes can be dragged across overlaid text, in the body and headers, without taking ordinary text clicks; previously trivial: progress log: crop handles on the canvas; previously trivial: progress log: Insert › Bookmark deletes and goes to bookmarks, #1236; previously trivial: progress log: an option to show only the selected text's comment, #1055; previously trivial: #778 mail-merge recipient filter/sort, .xls and legacy encodings; previously trivial: progress log: clicking in a comment's text moves its caret, #1013; previously trivial: progress log: a press on the ribbon no longer scrolls the document, #1019; previously trivial: title rename and account settings; previously trivial: progress log: Down and Up no longer skip a table, #1007; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; 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 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -139,6 +139,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | Justified vertical page alignment (#1320): a section set to Justified spreads each page's paragraphs and tables from the top margin to the bottom one, as Word does, including the last page and pages ending in a manual page break. DOCX `w:vAlign="both"` opens as Justified (it showed centred) and saves back; `layout.verticalAlign` takes `justify`, and Page Setup has a Vertical alignment box | | 2026-10-13 | macOS shortcuts as in Word for Mac: ⌘⇧S Save As, ⌘⌥E Insert Endnote, ⌘⌥A New Comment, ⌃H Replace, ⌘⌥R collapse the ribbon | | 2026-10-13 | The status bar flags a proofing language whose Hunspell dictionary isn't installed (Polish, Portuguese): "Polish (no dictionary)", with a tooltip naming the dictionary and where it is looked up (#1167) | +| 2026-10-14 | A file name that isn't valid Unicode, such as a Latin-1 `café.docx`, no longer crashes `wordcraft-cli` or the app (#1440). The app and the CLI's `convert`, `info`, `text`, `inspect`, `render`, `zotero` and `run --file` open such a file, and saving it without a new name (`run --file … --cmd file.save`, or Save in the app) writes back to it. Any other such argument is an error naming it, an output name included; the app exits with status 2 on a `--control` port that isn't valid Unicode | | 2026-10-13 | A picture (or shape) set to Behind Text can be dragged over the text it overlaps, whether or not it was selected first, in the body and in a header/footer. The press becomes an object drag only after the pointer moves, so an ordinary click still places the text caret in the overlapping text. The UI regression tests cover In Front of Text too | | 2026-10-13 | Saving to DOCX keeps the watermark: it is written as Word's own text-watermark shape in every header, and a document without headers gains one to hold it (#1290) | | 2026-10-13 | DOCX files with Word's text watermark (Design › Watermark, a VML text shape in the headers) show it on every page on screen and in the PDF (#1290) | @@ -372,6 +373,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| | 2026-10-13 | trivial | Progress log: justified vertical page alignment (#1320) | +| 2026-10-14 | trivial | Progress log: file names that aren't valid Unicode (#1440) | | 2026-10-13 | trivial | Behind Text pictures and shapes drag over overlapping text in the body and headers, selected or not, while ordinary clicks keep editing text; In Front of Text drag covered too | | 2026-10-13 | trivial | Progress log: the watermark written to DOCX (#1290) | | 2026-10-13 | trivial | Progress log: Word's text watermark read from DOCX (#1290) | diff --git a/apps/wordcraft-cli/src/main.rs b/apps/wordcraft-cli/src/main.rs index e9b86da24..4230645d5 100644 --- a/apps/wordcraft-cli/src/main.rs +++ b/apps/wordcraft-cli/src/main.rs @@ -3,8 +3,10 @@ mod automation_args; +use std::ffi::{OsStr, OsString}; use std::fmt; use std::io::Write; +use std::path::{Path, PathBuf}; use std::process::ExitCode; use serde_json::{Value, json}; @@ -58,16 +60,43 @@ fn wants_help(cmd: &str, rest: &[String]) -> bool { SUBCOMMANDS.contains(&cmd) && rest.iter().any(|a| a == "--help" || a == "-h") } -fn open(path: &str) -> Result { +fn open(path: &Path) -> Result { // A file that gives no page size (HTML, Markdown, plain text) opens on the system region's // paper, as in the app: A4 in Italy, Letter in the US (#1381). let region = system_region(); - let (doc, _) = wordcraft_engine::io::open_path_in(std::path::Path::new(path), None, ®ion)?; + let (doc, _) = wordcraft_engine::io::open_path_in(path, None, ®ion)?; let mut s = session(doc); s.path = Some(path.into()); Ok(s) } +/// The commands that open a file, and which of their arguments not starting with `--` it is (as +/// `pos` counts). `run` opens the value of `--file`. +const OPENS_FILE: &[(&str, usize)] = &[("convert", 0), ("info", 0), ("text", 0), ("inspect", 0), ("render", 0), ("zotero", 1)]; + +/// The arguments as text. Only the file the command opens may have a name that isn't valid Unicode, +/// such as a Latin-1 one (#1440): it comes back as the path to open, and lossy among the arguments. +/// Any other such argument is an error naming it. +fn text_args>(raw: &[S]) -> Result<(Vec, Option), String> { + let args: Vec = raw.iter().map(|a| a.as_ref().to_string_lossy().into_owned()).collect(); + let cmd = args.first().map(String::as_str).unwrap_or(""); + let file_at = match OPENS_FILE.iter().find(|(c, _)| *c == cmd) { + Some((_, n)) => args.iter().enumerate().skip(1).filter(|(_, a)| !a.starts_with("--")).nth(*n).map(|(i, _)| i), + None if cmd == "run" => args.iter().position(|a| a == "--file").map(|i| i + 1), + None => None, + }; + let mut file = None; + for (i, a) in raw.iter().map(AsRef::as_ref).enumerate() { + if a.to_str().is_none() { + if Some(i) != file_at { + return Err(format!("argument {} is not valid Unicode: {}", i + 1, a.to_string_lossy())); + } + file = Some(PathBuf::from(a)); + } + } + Ok((args, file)) +} + /// A headless session in the system region: every way the CLI starts one gives `file.new` the /// same paper (#1381). fn session(doc: wordcraft_doc::Document) -> Session { @@ -262,7 +291,10 @@ fn write_stdout(write: impl FnOnce(&mut std::io::StdoutLock<'static>) -> std::io Ok(()) } -fn run(args: &[String]) -> Result<(), Failure> { +fn run>(args: &[S]) -> Result<(), Failure> { + let (args, file) = text_args(args)?; + // Opens the file by its real name, which its text in `args` may have lost. + let open = |text: &str| open(file.as_deref().unwrap_or(Path::new(text))); let cmd = args.first().map(String::as_str).unwrap_or(""); let rest: Vec = args.iter().skip(1).cloned().collect(); // Before options are checked or files and folders opened. @@ -524,7 +556,7 @@ fn parity_markdown(p: &Value) -> String { } fn main() -> ExitCode { - let args: Vec = std::env::args().skip(1).collect(); + let args: Vec = std::env::args_os().skip(1).collect(); match run(&args) { Ok(()) | Err(Failure::StdoutClosed) => ExitCode::SUCCESS, Err(Failure::Error(e)) => { @@ -654,6 +686,38 @@ mod tests { assert!(run(&args(&["mcp", "--automation-write-root", "/definitely/not/a/folder"])).is_err()); } + #[cfg(unix)] + #[test] + fn only_the_opened_file_may_be_other_than_unicode() { + use std::os::unix::ffi::OsStringExt; + let latin1 = || OsString::from_vec(b"caf\xe9.docx".to_vec()); + let ok = |raw: Vec| text_args(&raw).unwrap().1; + assert_eq!(ok(vec!["zotero".into(), "refresh".into(), latin1()]), Some(PathBuf::from(latin1()))); + assert_eq!(ok(vec!["render".into(), latin1(), "out.png".into(), "--page".into(), "2".into()]), Some(PathBuf::from(latin1()))); + assert_eq!(ok(vec!["run".into(), "--print".into(), "--file".into(), latin1()]), Some(PathBuf::from(latin1()))); + assert_eq!(ok(vec!["info".into(), "in.docx".into()]), None); + // Every command that opens a file reaches the open with it (no such file here), so + // `OPENS_FILE` and the commands' own arguments agree. + let missing = || OsString::from_vec(b"/definitely/not/caf\xe9.docx".to_vec()); + let mut opening: Vec> = OPENS_FILE + .iter() + .map(|(cmd, n)| [vec![(*cmd).into()], vec![OsString::from("refresh"); *n], vec![missing(), "out.png".into()]].concat()) + .collect(); + opening.push(vec!["run".into(), "--file".into(), missing()]); + for raw in opening { + let e = run(&raw).unwrap_err().to_string(); + assert!(e.contains("No such file"), "{raw:?}: {e}"); + } + for (raw, n) in [ + (vec!["zotero".into(), latin1(), "in.docx".into()], 2), + (vec!["render".into(), "in.docx".into(), latin1()], 3), + (vec!["run".into(), "--save".into(), latin1()], 3), + (vec!["commands".into(), latin1()], 2), + ] { + assert_eq!(text_args(&raw).unwrap_err(), format!("argument {n} is not valid Unicode: caf\u{FFFD}.docx")); + } + } + #[test] fn automation_roots_are_an_error_with_connect() { let dir = std::env::temp_dir().to_string_lossy().into_owned(); diff --git a/apps/wordcraft-cli/tests/non_utf8_args.rs b/apps/wordcraft-cli/tests/non_utf8_args.rs new file mode 100644 index 000000000..0258f52a9 --- /dev/null +++ b/apps/wordcraft-cli/tests/non_utf8_args.rs @@ -0,0 +1,76 @@ +//! A file name that isn't valid Unicode (`caf\xe9.txt`, Latin-1) never panics the CLI (#1440): the +//! file a command opens opens, and any other such argument is an error naming it. Not on macOS: +//! APFS refuses file names that aren't valid UTF-8, so the fixture can't be made there. +#![cfg(all(unix, not(target_os = "macos")))] + +use std::ffi::OsString; +use std::os::unix::ffi::OsStringExt; +use std::path::PathBuf; +use std::process::{Command, Output}; + +fn cli(args: &[OsString]) -> Output { + Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).args(args).output().unwrap() +} + +/// A temp folder holding `caf\xe9.txt`, whose text is "Bonjour". +fn folder(name: &str) -> (PathBuf, OsString) { + let dir = std::env::temp_dir().join(format!("wc-cli-non-utf8-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join(OsString::from_vec(b"caf\xe9.txt".to_vec())); + std::fs::write(&file, "Bonjour\n").unwrap(); + (dir, file.into_os_string()) +} + +fn ok(out: &Output) -> String { + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(out.status.success(), "status {:?}, stderr: {stderr}", out.status); + String::from_utf8_lossy(&out.stdout).into_owned() +} + +#[test] +fn the_file_a_command_opens_may_have_any_name() { + let (dir, file) = folder("open"); + assert_eq!(ok(&cli(&["text".into(), file.clone()])).trim(), "Bonjour"); + assert!(ok(&cli(&["info".into(), file.clone()])).contains("\"words\"")); + assert!(ok(&cli(&["inspect".into(), file.clone()])).contains("Bonjour")); + + let md = dir.join("out.md"); + ok(&cli(&["convert".into(), file.clone(), md.clone().into()])); + assert!(std::fs::read_to_string(&md).unwrap().contains("Bonjour")); + + let saved = dir.join("run.txt"); + ok(&cli(&["run".into(), "--file".into(), file.clone(), "--save".into(), saved.clone().into()])); + assert!(std::fs::read_to_string(&saved).unwrap().contains("Bonjour")); + + // Saving without a path writes back to the file under its real name, not a lossy one. + let mut args: Vec = vec!["run".into(), "--file".into(), file.clone()]; + for cmd in ["caret.docEnd", "text.insert={\"text\":\" encore\"}", "file.save"] { + args.extend(["--cmd".into(), cmd.into()]); + } + ok(&cli(&args)); + assert!(std::fs::read_to_string(&file).unwrap().contains("encore")); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 3, "a second file was written"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn any_other_such_argument_is_an_error_naming_it() { + let (dir, file) = folder("error"); + let out_name = dir.join(OsString::from_vec(b"caf\xe9.md".to_vec())).into_os_string(); + let cmd = OsString::from_vec(b"text.insert={\"text\":\"\xe9\"}".to_vec()); + for (args, n) in [ + (vec!["convert".into(), file.clone(), out_name.clone()], 3), + (vec!["run".into(), "--file".into(), file.clone(), "--cmd".into(), cmd], 5), + (vec![OsString::from_vec(b"t\xe9xt".to_vec()), file.clone()], 1), + ] { + let out = cli(&args); + let stderr = String::from_utf8_lossy(&out.stderr); + let shown = args[n - 1].to_string_lossy(); + assert!(shown.contains('\u{FFFD}')); + assert_eq!(out.status.code(), Some(1), "{args:?}: stderr: {stderr}"); + assert_eq!(stderr, format!("wordcraft-cli: argument {n} is not valid Unicode: {shown}\n"), "{args:?}"); + } + assert!(!std::path::Path::new(&out_name).exists()); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index e03b12465..e7b533015 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -282,6 +282,40 @@ fn build_sha() -> &'static str { option_env!("WORDCRAFT_BUILD_SHA").map(|s| s.get(..8).unwrap_or(s)).unwrap_or("dev") } +/// The arguments as text, and apart the files whose names aren't valid Unicode, such as a Latin-1 +/// one (#1440). A `--control` port that isn't is an error. +fn text_args() -> (Vec, Vec) { + let (mut args, mut files) = (Vec::new(), Vec::new()); + for (i, a) in std::env::args_os().skip(1).enumerate() { + match a.into_string() { + Ok(a) => args.push(a), + Err(a) if args.last().is_some_and(|p| p == "--control") => { + // Unlike `eprintln!`, never panics. + let (n, lossy) = (i + 1, a.to_string_lossy()); + let _ = std::io::Write::write_fmt(&mut std::io::stderr(), format_args!("wordcraft: argument {n} is not valid Unicode: {lossy}\n")); + std::process::exit(2); + } + Err(a) => files.push(a.into()), + } + } + (args, files) +} + +/// Open a file whose name isn't valid Unicode (#1440). `file.open` takes its path as text, so it +/// gets the file's bytes under the lossy name, and the document then keeps the real path to save to. +fn open_non_unicode(app: &mut WordApp, path: &std::path::Path) { + let name = path.to_string_lossy(); + let opened = std::fs::read(path) + .map_err(|e| e.to_string()) + .and_then(|bytes| app.run("file.open", serde_json::json!({"path": name, "data": wordcraft_engine::cmd::insert::base64_encode(&bytes)}))); + match opened { + // Not when it waits for a password: that open names the document itself. + Ok(r) if r.get("pending").is_none() => app.session.path = Some(path.into()), + Ok(_) => {} + Err(e) => log::warn!("{name}: {e}"), + } +} + fn main() -> eframe::Result { // First, so the panic hook and every start-up warning are recorded (`logging`). let logger = logging::install(); @@ -289,8 +323,9 @@ fn main() -> eframe::Result { let mut control_port: Option = std::env::var("WORDCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()); let mut files = Vec::new(); let mut sample = false; + let (args, other_files) = text_args(); let mut bad_control: Option> = None; - let mut args = std::env::args().skip(1).peekable(); + let mut args = args.into_iter().peekable(); while let Some(a) = args.next() { match a.as_str() { // A flag after `--control` stays a flag; a bad or missing port is an error (#1426). @@ -421,7 +456,7 @@ fn main() -> eframe::Result { // Nothing to open (no files, no sample, no control channel driving the editor): start // on File › Home, as Word does (#766), unless the Start screen option is off. Files // macOS hands over later open on top of it and close it (`apple_events`). - if files.is_empty() && !sample && control_port.is_none() { + if files.is_empty() && other_files.is_empty() && !sample && control_port.is_none() { app.start_screen(); } for f in files { @@ -429,6 +464,9 @@ fn main() -> eframe::Result { log::warn!("{f}: {e}"); } } + for f in other_files { + open_non_unicode(&mut app, &f); + } Ok(Box::new(App( app, // With nothing restored, the window the system placed is checked all the same. @@ -442,8 +480,40 @@ fn main() -> eframe::Result { ) } -#[cfg(test)] +// Not on macOS: APFS refuses file names that aren't valid UTF-8, so no such file can exist there. +#[cfg(all(test, unix, not(target_os = "macos")))] mod tests { + use super::*; + use std::os::unix::ffi::OsStringExt; + + #[test] + fn a_file_whose_name_isnt_unicode_opens_and_saves_back_to_itself() { + let dir = std::env::temp_dir().join(format!("wc-app-non-utf8-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(std::ffi::OsString::from_vec(b"caf\xe9.txt".to_vec())); + std::fs::write(&path, "Bonjour").unwrap(); + + let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()); + open_non_unicode(&mut app, &path); + assert_eq!(app.session.doc.plain_text(wordcraft_doc::StoryRef::Body).trim(), "Bonjour"); + assert_eq!(app.session.path.as_deref(), Some(path.as_path())); + + app.run("caret.docEnd", serde_json::json!({})).unwrap(); + app.run("text.insert", serde_json::json!({"text": " encore"})).unwrap(); + app.run("file.save", serde_json::json!({})).unwrap(); + assert!(std::fs::read_to_string(&path).unwrap().contains("Bonjour encore")); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "a second file was written"); + + // A file that can't be read is a warning, as one that fails to open by name is. + open_non_unicode(&mut app, &dir.join(std::ffi::OsString::from_vec(b"gon\xe9.txt".to_vec()))); + assert_eq!(app.session.path.as_deref(), Some(path.as_path())); + let _ = std::fs::remove_dir_all(dir); + } +} + +#[cfg(test)] +mod prefs_tests { use super::write_prefs; #[test] diff --git a/apps/wordcraft/tests/non_utf8_args.rs b/apps/wordcraft/tests/non_utf8_args.rs new file mode 100644 index 000000000..7a86c72d5 --- /dev/null +++ b/apps/wordcraft/tests/non_utf8_args.rs @@ -0,0 +1,53 @@ +//! An argument that isn't valid Unicode, such as a Latin-1 file name, never panics the app (#1440). +#![cfg(unix)] + +use std::ffi::OsString; +use std::os::unix::ffi::OsStringExt; +use std::process::{Command, Output, Stdio}; +use std::time::{Duration, Instant}; + +fn app(args: &[OsString]) -> Output { + let mut child = Command::new(env!("CARGO_BIN_EXE_wordcraft")) + .args(args) + // With no display, an app that tries to open a window fails at once instead. + .env_remove("DISPLAY") + .env_remove("WAYLAND_DISPLAY") + .env_remove("WAYLAND_SOCKET") + .env_remove("WORDCRAFT_CONTROL_PORT") + .env("WORDCRAFT_NO_PREFS", "1") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(30); + while child.try_wait().unwrap().is_none() { + if Instant::now() > deadline { + let _ = child.kill(); + panic!("wordcraft {args:?} was still running after 30 s: it started the app"); + } + std::thread::sleep(Duration::from_millis(50)); + } + child.wait_with_output().unwrap() +} + +/// The file opens once the window is up (`open_non_unicode`, unit-tested in `main.rs`). Not on +/// macOS, where the app opens its window without a display variable and so keeps running. +#[cfg(not(target_os = "macos"))] +#[test] +fn a_file_with_such_a_name_starts_the_app() { + let out = app(&["--sample".into(), OsString::from_vec(b"caf\xe9.docx".to_vec())]); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(!stderr.contains("panic"), "stderr: {stderr}"); + // It went on to open the window, which fails without a display. + assert!(stderr.contains("WinitEventLoop"), "stderr: {stderr}"); + assert_eq!(out.status.code(), Some(1), "stderr: {stderr}"); +} + +#[test] +fn a_control_port_that_isnt_unicode_is_an_error_naming_it() { + let out = app(&["--sample".into(), "--control".into(), OsString::from_vec(b"79\xe981".to_vec())]); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!(out.status.code(), Some(2), "stderr: {stderr}"); + assert_eq!(stderr, "wordcraft: argument 3 is not valid Unicode: 79\u{FFFD}81\n"); +} From 8ba285cdef48b3e64ab2ce8f0b7601bf0d5d48b0 Mon Sep 17 00:00:00 2001 From: divyanshu2074 <43274510+divyanshu2074@users.noreply.github.com> Date: Thu, 15 Oct 2026 05:06:36 +0530 Subject: [PATCH 008/101] fix(engine): handle multi-byte UTF-8 characters before em-dash in dash_autoformat (#1570) --- crates/engine/src/cmd/text.rs | 2 +- crates/engine/src/tests_typing.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index e234faac8..02749ce1f 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -192,7 +192,7 @@ fn dash_autoformat(s: &mut Session) -> Result<(), CmdError> { } else if before.ends_with(' ') && let Some(i) = before.trim_end().rfind("--") && i > 0 - && before.trim_end().get(i - 1..i).is_some_and(|c| c != " ") + && before.trim_end()[..i].chars().next_back().is_some_and(|c| c != ' ') { let p = s.doc.para_mut(f.story, &f.path)?; let props = p.props_at(i).clone(); diff --git a/crates/engine/src/tests_typing.rs b/crates/engine/src/tests_typing.rs index e89b0a5b2..4788df900 100644 --- a/crates/engine/src/tests_typing.rs +++ b/crates/engine/src/tests_typing.rs @@ -357,8 +357,8 @@ fn web_addresses_become_links() { #[test] fn dashes() { - let s = typed("a -- b and c - d and e--f "); - assert_eq!(s.doc.plain_text(StoryRef::Body), "A – b and c – d and e—f "); + let s = typed("a -- b and c - d and e--f and café--g "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "A – b and c – d and e—f and café—g "); } #[test] From 21f2b938e2e55250a7a80837c7827450720b790a Mon Sep 17 00:00:00 2001 From: divyanshu2074 <43274510+divyanshu2074@users.noreply.github.com> Date: Thu, 15 Oct 2026 05:06:47 +0530 Subject: [PATCH 009/101] fix(engine): trim arguments when parsing sample text formulas (#1571) --- crates/engine/src/cmd/text.rs | 2 +- crates/engine/src/tests_typing.rs | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 02749ce1f..1cf7444fc 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -527,7 +527,7 @@ fn sample_text(s: &Session) -> Option { if args.contains(['(', ')']) { return None; } - let count = |arg: &str| arg.parse::().ok().filter(|n| (1..=200).contains(n)); + let count = |arg: &str| arg.trim().parse::().ok().filter(|n| (1..=200).contains(n)); let counts = if args.is_empty() { Some((5, 3)) } else if let Some((p, s)) = args.split_once(',') { diff --git a/crates/engine/src/tests_typing.rs b/crates/engine/src/tests_typing.rs index 4788df900..dd69c5cb5 100644 --- a/crates/engine/src/tests_typing.rs +++ b/crates/engine/src/tests_typing.rs @@ -52,7 +52,17 @@ fn typed(keys: &str) -> Session { #[test] fn sample_text_formulas_expand_on_enter() { for name in ["rand", "lorem"] { - for (args, paragraphs, sentences) in [("", 5, 3), ("2", 2, 3), ("2,4", 2, 4), ("1,1", 1, 1), ("200,1", 200, 1), ("1,200", 1, 200)] { + for (args, paragraphs, sentences) in [ + ("", 5, 3), + ("2", 2, 3), + (" 2 ", 2, 3), + ("2,4", 2, 4), + ("2, 4", 2, 4), + (" 2 , 4 ", 2, 4), + ("1,1", 1, 1), + ("200,1", 200, 1), + ("1,200", 1, 200), + ] { let mut s = typed(&format!("={name}({args})")); let depth = s.undo_depth(); typ(&mut s, "\n"); From 1b05f9a6681145233eaf51d9d396e161a99f72d8 Mon Sep 17 00:00:00 2001 From: rabis7575 <340973947+rabis7575@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:36:59 +0200 Subject: [PATCH 010/101] .doc import: notes, headers and text boxes read their text once (#1319) (#1572) The story tables of footnotes and endnotes (PlcffndTxt, PlcfendTxt), headers and footers (Plcfhdd) and text boxes (PlcftxbxTxt) were read one range at a time, and each range only had to run forwards: a table stepping back to an earlier CP after every story pointed many notes, header slots or text boxes at the same text, and each was read into a part of its own (2,000 note references on one story made 1,001 copies of it). The CPs of a well-formed PLC ascend, so a story that starts before the end of the last one kept is now dropped (a header or footer slot is left empty, which links it to the previous section's): every subdocument CP becomes at most one story, and the stories together never hold more than the file's text. Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 2 + crates/docbin/src/drawing.rs | 8 ++- crates/docbin/src/notes.rs | 12 ++++- crates/docbin/src/sections.rs | 10 +++- crates/docbin/src/tests.rs | 86 ++++++++++++++++++++++++++++++ crates/docbin/src/tests/drawing.rs | 30 +++++++++++ 6 files changed, 144 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 1c79e56bc..58ec4a1d8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -282,6 +282,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 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-12 | Sorting and table numbers follow the text's language (#836, #837): Table › Sort, Home › Sort, the index (with its letter headings), the bibliography and the Table of Authorities use the language's alphabet (Polish `Łódź` between `Kraków` and `Zakopane`, Swedish `å ä ö` after `z`, German `ä` with `a`); table sort and formulas read `1,5` and `1 250,00` as Polish decimals and `=SUM` writes `5,75` | +| 2026-10-14 | `.doc` notes, headers and text boxes read their text once (#1319): a story table that steps back no longer has many footnotes, endnotes, headers or text boxes copy the same text into a part each; a story starting inside an earlier one is dropped (a header or footer left empty is linked to the previous section's), so the stories together never hold more than the file's text | | 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-14 | ODT and `.doc` pictures stay within their byte budget (#1334): an ODT picture entry is inflated once however many frames point at it, and only when the size it declares fits in what is left (one inflating past that uses the budget up); a `.doc` inline picture that can't be read or doesn't fit is no longer decoded and copied again for every anchor on it, and is decoded only within what is left | | 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 | @@ -511,6 +512,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 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-14 | trivial | Progress log: `.doc` notes, headers and text boxes read their text once (#1319) | | 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-12 | minor | List-label automatic/AtLeast line-height inflation corrected; gap #4 remains open and readiness unchanged | | 2026-10-12 | minor | Local desktop recovery: unsaved drafts and edits are snapshotted in the profile; File › Recover restores an unsaved copy or confirms discard. Full model storage includes media and opaque parts. Password sessions skip plaintext snapshots. See [recovery.md](docs/recovery.md); stability estimates remain unchanged. | diff --git a/crates/docbin/src/drawing.rs b/crates/docbin/src/drawing.rs index ef403d158..c7b913be8 100644 --- a/crates/docbin/src/drawing.rs +++ b/crates/docbin/src/drawing.rs @@ -37,16 +37,22 @@ fn plc(table: &[u8], (fc, lcb): (u32, u32), cb: usize) -> Vec<(u32, &[u8])> { /// The text box stories of a `PlcftxbxTxt` (or `PlcfHdrtxbxTxt`): (shape spid, story CP range /// relative to the text box subdocument). +/// +/// The CPs of a well-formed PLC ascend, so the stories are disjoint. A story starting before +/// the end of the last one kept (a hostile table pointing many text boxes at the same text, +/// each read into its own part) is dropped: every subdocument CP becomes at most one story. 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(); + let mut kept_to = 0u32; 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 { + if end > *cp && *cp >= kept_to && lid != 0 { out.push((lid, (*cp, end))); + kept_to = end; } } out diff --git a/crates/docbin/src/notes.rs b/crates/docbin/src/notes.rs index e49c86e75..89b1db052 100644 --- a/crates/docbin/src/notes.rs +++ b/crates/docbin/src/notes.rs @@ -7,6 +7,11 @@ const MAX: usize = 10_000; /// Parse the note reference/story PLCs. `ref_pair`/`txt_pair` are the FIB pair indices /// (`(2, 3)` for footnotes, `(46, 47)` for endnotes); story ranges are relative to the /// note subdocument. +/// +/// The story CPs of a well-formed PLC ascend, so the stories are disjoint. A hostile table +/// that steps back would make many notes read the same text again (each one materialised +/// as its own part), so a story starting before the end of the last one kept is dropped: +/// every subdocument CP becomes at most one note. pub(crate) fn parse_notes(table: &[u8], fib: &crate::fib::Fib, ref_pair: usize, txt_pair: usize) -> Vec<(u32, (u32, u32))> { let refs = fib.pair(ref_pair).and_then(|(fc, lcb)| plc_of(table, fc, lcb, 2)).unwrap_or_default(); let txts = fib.pair(txt_pair).and_then(|(fc, lcb)| plc_of(table, fc, lcb, 0)).unwrap_or_default(); @@ -14,12 +19,15 @@ pub(crate) fn parse_notes(table: &[u8], fib: &crate::fib::Fib, ref_pair: usize, return Vec::new(); } let mut out = Vec::new(); + let mut kept_to = 0u32; // The reference PLC's aFtnIdx order matches the story order; pair them by index. for (i, &cp) in refs.iter().enumerate() { - if let (Some(a), Some(b)) = (txts.get(i), txts.get(i + 1)) + if let (Some(&a), Some(&b)) = (txts.get(i), txts.get(i + 1)) && b > a + && a >= kept_to { - out.push((cp, (*a, *b))); + out.push((cp, (a, b))); + kept_to = b; } } out diff --git a/crates/docbin/src/sections.rs b/crates/docbin/src/sections.rs index b5548e228..209fa5841 100644 --- a/crates/docbin/src/sections.rs +++ b/crates/docbin/src/sections.rs @@ -171,6 +171,11 @@ fn apply_section(s: &mut SectionProps, prl: &Prl) { /// `[cps[i], cps[i+1])`. Stories 0-5 are footnote/endnote separators; every section then owns /// six stories in the order even header, odd (default) header, even footer, odd (default) /// footer, first header, first footer. The CPs are relative to the header subdocument. +/// +/// The CPs of a well-formed PLC ascend, so the stories are disjoint. A story starting before +/// the end of an earlier one (a hostile table pointing many sections at the same text, each +/// read into its own part) is left empty, like a slot linked to the previous section's: +/// every subdocument CP becomes at most one story. pub(crate) fn header_stories(table: &[u8], fib: &Fib) -> Vec<(u32, u32)> { let Some((fc, lcb)) = fib.pair(pair::PLCF_HDD) else { return Vec::new() }; let Some(plc) = table.get(fc as usize..(fc as usize).saturating_add(lcb as usize)) else { return Vec::new() }; @@ -178,9 +183,12 @@ pub(crate) fn header_stories(table: &[u8], fib: &Fib) -> Vec<(u32, u32)> { let n = (lcb as usize / 4).min(6 + 6 * MAX_SECTIONS + 1); let cp_at = |i: usize| -> Option { plc.get(i * 4..i * 4 + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])) }; let mut stories = Vec::with_capacity(n); + let mut kept_to = 0u32; for i in 0..n.saturating_sub(1) { if let (Some(a), Some(b)) = (cp_at(i), cp_at(i + 1)) { - stories.push((a, b.max(a))); + let b = if a < kept_to { a } else { b.max(a) }; + kept_to = kept_to.max(b); + stories.push((a, b)); } } stories diff --git a/crates/docbin/src/tests.rs b/crates/docbin/src/tests.rs index 55f66aaa2..9733b6980 100644 --- a/crates/docbin/src/tests.rs +++ b/crates/docbin/src/tests.rs @@ -1501,6 +1501,92 @@ fn huge_story_ranges_are_clamped_to_their_subdocument() { assert!(!doc.parts.values().any(|p| p.kind == wordcraft_doc::PartKind::Endnote)); } +/// The text of each note a paragraph references, in order. +fn note_texts(doc: &wordcraft_doc::Document, p: &wordcraft_doc::Paragraph) -> Vec { + p.objects + .iter() + .filter_map(|o| match o { + wordcraft_doc::para::InlineObject::NoteRef { id, .. } => doc.parts.get(id), + _ => None, + }) + .map(|part| part.blocks.iter().filter_map(|b| b.as_para()).map(|para| para.text.as_str()).collect::>().join("\n")) + .collect() +} + +#[test] +fn note_stories_pointing_at_the_same_text_are_read_once() { + // 2000 footnote references whose PlcffndTxt steps back to CP 0 after every story + // ([0, 10, 0, 10, …]): every other one was read into its own part, a copy of the whole + // footnote subdocument each (1001 parts here; a large story times thousands of + // references in a hostile file). + let n = 2000u32; + let text: &'static str = Box::leak(format!("A{}\r", "\u{2}".repeat(n as usize)).into_boxed_str()); + let ccp = text.chars().count() as u32; + let refs: Vec = (1..=n).chain([ccp]).collect(); + let spec = StructSpec { + text, + note_stories: vec!["Note body\r"], + extra_pairs: vec![(2, plc(&refs, &1u16.to_le_bytes().repeat(n as usize))), (3, plc(&[0u32, 10].repeat(n as usize / 2 + 1), &[]))], + ..structured_spec(text, ccp) + }; + let doc = read(&struct_doc(&spec)).expect("opens"); + assert_eq!(doc.parts.values().filter(|p| p.kind == wordcraft_doc::PartKind::Footnote).count(), 1); + let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para"); + assert_eq!(note_texts(&doc, p0), vec!["Note body".to_string()]); +} + +#[test] +fn note_stories_stepping_back_are_dropped_and_ordered_ones_kept() { + // Story CPs [0, 5, 10, 3, 10] for four footnotes and four endnotes: the first two + // stories are in order and read (the control); the third runs backwards and the fourth + // starts inside the first, so the third and fourth references get no note. + let text = "a\u{2}b\u{2}c\u{2}d\u{2}e\u{2}f\u{2}g\u{2}h\u{2}\r"; + let ccp = text.chars().count() as u32; + let stories = plc(&[0, 5, 10, 3, 10], &[]); + let spec = StructSpec { + text, + note_stories: vec!["Aaaa\rBbbb\r"], + endnote_stories: vec!["Cccc\rDddd\r"], + extra_pairs: vec![ + (2, plc(&[1, 3, 5, 7, ccp], &1u16.to_le_bytes().repeat(4))), + (3, stories.clone()), + (46, plc(&[9, 11, 13, 15, ccp], &1u16.to_le_bytes().repeat(4))), + (47, stories), + ], + ..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!(note_texts(&doc, p0), ["Aaaa", "Bbbb", "Cccc", "Dddd"].map(String::from)); + assert_eq!(doc.parts.len(), 4); +} + +#[test] +fn header_stories_pointing_at_the_same_text_are_read_once() { + // Two sections whose odd headers both claim the header subdocument's [0, 5) (Plcfhdd + // steps back to CP 0 inside the second section's slots): the second used to become a + // second copy of the text. Its slot is now empty, which links it to the first section's. + let text = "a\rb\r"; + let ccp = text.chars().count() as u32; + // Six empty separator stories, then each section's six slots; the odd header is the + // second slot. + let hdd = plc(&[0, 0, 0, 0, 0, 0, 0, 0, 5, 5, 5, 5, 5, 0, 5, 5, 5, 5, 5], &[]); + let spec = StructSpec { + text, + header_stories: vec!["Head\r"], + sections: vec![(2, Vec::new()), (ccp, Vec::new())], + papx_entries: vec![(0, 0, Vec::new()), (2, 0, Vec::new())], + extra_pairs: vec![(11, hdd)], + ..structured_spec(text, ccp) + }; + let doc = read(&struct_doc(&spec)).expect("opens"); + assert_eq!(doc.parts.values().filter(|p| p.kind == wordcraft_doc::PartKind::Header).count(), 1); + let first = doc.body.first().and_then(|b| b.as_para()).and_then(|p| p.section.as_ref()).expect("first section"); + let header = first.headers.default.and_then(|id| doc.parts.get(&id)).expect("first section's header"); + assert!(header.blocks.iter().any(|b| b.as_para().is_some_and(|p| p.text == "Head")), "header: {:?}", header.blocks); + assert_eq!(doc.last_section.headers.default, None); +} + /// A document whose piece table is given raw (cps + one Pcd per piece), with `text` bytes /// at TEXT_FC and the FIB claiming `ccp_text` main-document characters. fn raw_piece_doc(cps: &[u32], fcs: &[u32], text: &[u8], ccp_text: u32) -> Vec { diff --git a/crates/docbin/src/tests/drawing.rs b/crates/docbin/src/tests/drawing.rs index ef3f269ac..ef5f7f99f 100644 --- a/crates/docbin/src/tests/drawing.rs +++ b/crates/docbin/src/tests/drawing.rs @@ -182,6 +182,36 @@ fn shapes_keep_kind_colours_wrap_and_text_box_text() { assert_eq!(part.blocks.first().and_then(|b| b.as_para()).map(|p| p.text.as_str()), Some("Inside")); } +#[test] +fn text_boxes_pointing_at_the_same_story_text_read_it_once() { + // Two text boxes whose PlcftxbxTxt stories are both [0, 7) (the table steps back to CP 0 + // in between): each was read into its own part, so a hostile table could copy one story + // thousands of times. The second story is dropped and its box stays empty. + let tb = |spid: u32| sp(&[fsp(202, spid, 0xA00), fopt(&[(0x01BF, 1 << 20)], &[]), rec(0, 0, 0xF00D, &0x0001_0000u32.to_le_bytes())]); + let art = art_content(&[], &[tb(1027), tb(1028)]); + let flags = spa_flags(0, 0, 1, false); + let spas = plc(&[1, 2, 4], &[spa(1027, [0, 0, 2880, 720], flags), spa(1028, [0, 0, 2880, 720], flags)].concat()); + // FTXBXS: cTxbx, cReusable, fReusable, reserved, lid, txidUndo. + let ftxbxs = |lid: u32| { + [1i32.to_le_bytes().as_slice(), &0i32.to_le_bytes(), &0i16.to_le_bytes(), &0i32.to_le_bytes(), &lid.to_le_bytes(), &0i32.to_le_bytes()] + .concat() + }; + let txbx = plc(&[0, 7, 0, 7], &[ftxbxs(1027), ftxbxs(1), ftxbxs(1028)].concat()); + let text = "A\u{8}\u{8}B\r"; + let ccp = text.chars().count() as u32; + // As above: the endnote slot holds the text box subdocument, then the FIB is patched. + let spec = + StructSpec { text, endnote_stories: vec!["Inside\r"], extra_pairs: vec![(40, spas), (50, art), (56, txbx)], ..structured_spec(text, ccp) }; + let doc = read(&patch_word(&struct_doc(&spec), &[(0x60, 0), (0x64, 7)])).expect("opens"); + let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para"); + let stories: Vec> = + p0.objects.iter().filter_map(|o| if let InlineObject::Shape { story, .. } = o { Some(*story) } else { None }).collect(); + let [Some(first), None] = stories[..] else { panic!("stories: {stories:?}") }; + let part = doc.parts.get(&first).expect("text box story part"); + assert_eq!(part.blocks.first().and_then(|b| b.as_para()).map(|p| p.text.as_str()), Some("Inside")); + assert_eq!(doc.parts.len(), 1); +} + /// 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; From 8fd4951d76f181301f73c70e0e6dae841261e5b2 Mon Sep 17 00:00:00 2001 From: przemek-arch Date: Thu, 15 Oct 2026 01:37:18 +0200 Subject: [PATCH 011/101] Layout: widow control moves a three-line paragraph whole (#1575) With room for two of its three lines at the bottom of a page, a paragraph was split two and one, leaving a widow; taking a line along would leave an orphan, so Word moves the paragraph whole. A paragraph continued from the page before may still take one line along from two (no orphan there). Co-authored-by: przemek-arch <263159043+przemek-arch@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/layout/src/lib.rs | 6 +++++- crates/layout/src/tests_pages.rs | 32 ++++++++++++++++++++++++++++++++ docs/layout-parity.md | 5 +++-- 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index ca284cde0..dcfce9cf3 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -3239,8 +3239,12 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: if forced_break.is_none() && l1 < n && pl.rp.widow_control && n >= 2 { if l1 - l0 == 1 && l0 == 0 && !pb.at_top() { l1 = l0; // orphan: move the first line to the next page - } else if n - l1 == 1 && l1 - l0 >= 3 { + } else if n - l1 == 1 && (l1 - l0 >= 3 || (l0 > 0 && l1 - l0 >= 2)) { l1 -= 1; // widow: take one more line along + } else if n - l1 == 1 && l0 == 0 && l1 - l0 == 2 && !pb.at_top() { + // Neither split of a three-line paragraph is allowed (two and a widow, or an + // orphan and two): it moves whole, as in Word. + l1 = l0; } } // A row (text both sides of a floating object) stays together. diff --git a/crates/layout/src/tests_pages.rs b/crates/layout/src/tests_pages.rs index e59a5b8d8..828cc31cb 100644 --- a/crates/layout/src/tests_pages.rs +++ b/crates/layout/src/tests_pages.rs @@ -463,3 +463,35 @@ fn page_references_in_footer_text_boxes_are_resolved() { assert!(box_text(&l, footer_box).iter().all(|t| t == "2") && !box_text(&l, footer_box).is_empty(), "{:?}", box_text(&l, footer_box)); assert_eq!(box_text(&l, body_box), ["9"]); } + +#[test] +fn widow_control_moves_a_three_line_paragraph_whole() { + // A three-line paragraph with room for two at the bottom of a page can't leave a widow (two + // and one) nor an orphan (one and two): Word moves it whole. A four-line one splits two and + // two. Sweep the paragraph down the page a point at a time and check every split. + let mut split4 = false; + for (want, words) in [(3usize, 45usize), (4, 55)] { + for (fill, shift) in (30usize..60).flat_map(|f| (0..15).step_by(2).map(move |s| (f, s))) { + let filler = "Filler line.\n".repeat(fill); + let mut d = Document::from_text(&format!("{filler}{}\nAfter.", "word ".repeat(words))); + d.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 0), &|p| p.space_before = Some(shift as f32)).unwrap(); + let l = lay(&d); + let pieces: Vec<(usize, usize)> = l + .pages + .iter() + .filter_map(|p| { + p.items.iter().find_map(|it| match it { + Placed::Lines { story: StoryRef::Body, path, para, l0, l1, .. } if *path == Path::top(fill) => { + Some((l1 - l0, para.lines.len())) + } + _ => None, + }) + }) + .collect(); + assert!(pieces.iter().all(|&(_, n)| n == want), "{words} words: {pieces:?}"); + assert!(pieces.iter().all(|&(c, _)| c >= 2), "shift {shift}, {want} lines split as {pieces:?}"); + split4 |= want == 4 && pieces.len() == 2; + } + } + assert!(split4, "the sweep never took the paragraph across a page break"); +} diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 33214efcc..985735c36 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -1,6 +1,6 @@ # Layout and pagination parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (justified vertical page alignment, #1320; previously trivial: floats aligned inside/outside or placed in the inside/outside margin swap sides on even pages; page-relative floats no longer move with mirrored text, #1181; previously trivial: a word too wide for the strip beside a floating object is no longer split across it, #1376; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (justified vertical page alignment, #1320; previously trivial: widow control moves a three-line paragraph whole; previously trivial: STYLEREF running headers per page, #775; previously trivial: PAGEREF in headers and footers shows its bookmark's page, #1477; previously trivial: a paragraph continued into the next column or page wraps around that column's shapes, #1374; previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 A document only "opens correctly" if its pages break where Word breaks them: the same lines, the same page count, footnotes and pictures on the same pages. This checklist covers line breaking, @@ -30,7 +30,7 @@ font can't match Word's metrics. | Line spacing (single, 1.5, double, at least, exactly, multiple), spacing before/after, contextual spacing | 🟡 | Contextual spacing measured in body paragraphs and headers (below), with a four-paragraph table-cell regression; list-label ascent overflow is unscaled by multiple spacing and uses descent for automatic and at-least spacing (#601); other line-spacing combinations remain (#108, #135 open) | 3–5 | | Kerning only where Kerning for fonts asks for it (`w:kern`, at or above its size) | ✅ | Measured in Word for Windows: no `w:kern` or `w:kern="0"` is unkerned; WordCraft kerned every run, so its lines held more text than Word's. `layout::tests::kerning_follows_kerning_for_fonts_like_word` | — | | Automatic (HTML-style) paragraph spacing (`w:beforeAutospacing`, `w:afterAutospacing`) | ✅ | Measured in Word for Windows: 14 pt at any size, none at the top of the document or a cell or after a cell's last paragraph, the larger of two neighbouring spaces. `layout::tests::automatic_paragraph_spacing_like_word`, `…_drops_at_cell_edges` | — | -| Widow/orphan control, keep with next, keep lines together, page break before | ✅ | `lib.rs` keep_next/keep_lines; a line holding only a page break stays at the bottom of a full page instead of leaving a blank page (#534); a paragraph ending with a page break keeps its space after on the old page, so the next page starts at the top (compatibility modes 12 to 15, `layout::tests::space_before_at_the_top_of_a_page_like_word`) | — | +| Widow/orphan control, keep with next, keep lines together, page break before | ✅ | `lib.rs` keep_next/keep_lines; a line holding only a page break stays at the bottom of a full page instead of leaving a blank page (#534); a paragraph ending with a page break keeps its space after on the old page, so the next page starts at the top (compatibility modes 12 to 15, `layout::tests::space_before_at_the_top_of_a_page_like_word`); a three-line paragraph with room for two moves whole (neither a widow nor an orphan is allowed), as in Word | — | | Tabs (left, center, right, decimal, bar), leaders | 🟡 | Bar tabs untested | 1–2 | | Hyphenation (automatic, zone from the document, words in capitals, soft hyphens, consecutive limit, Manual Hyphenation, #407) | 🟡 | English patterns only; other languages hyphenate wrongly or not at all | in localization | | Drop caps | ✅ | 2026-10-06 | — | @@ -160,6 +160,7 @@ No overall parity estimate changes from these probes. |---|---|---| | 2026-10-13 | trivial | Justified vertical page alignment (#1320): the page's spare room goes equally between its blocks; a section's last page takes its own section's vertical alignment, not the next one's | | 2026-10-13 | trivial | Floats and floating tables aligned inside/outside, or placed in the inside/outside margin, swap sides on even pages; with mirrored margins, page-relative floats keep their place and left/right margin areas are each page's own (#1181) | +| 2026-10-14 | trivial | Widow control: a three-line paragraph with room for two of its lines at the bottom of a page moves whole, as in Word (no two-and-one split leaving a widow); a paragraph continued from the previous page may still take one line along from two | | 2026-10-13 | trivial | PAGEREF in headers and footers shows its bookmark's page (#1477) | | 2026-10-14 | trivial | A word too wide for the strip beside a square-wrapped object moves past it whole instead of being split across the strip (#1376) | | 2026-10-13 | trivial | A shape or picture dragged into another column anchors there, so the text in that column wraps around it (#884) | From 2a1cdbb17fddc77ed3f695076b08c828fb6a2b9c Mon Sep 17 00:00:00 2001 From: Alik <86338858+alik654zx@users.noreply.github.com> Date: Thu, 15 Oct 2026 04:37:37 +0500 Subject: [PATCH 012/101] =?UTF-8?q?Lists:=20Russian=20letter=20numbering?= =?UTF-8?q?=20draws=20=D0=B0)=20=D0=B1)=20=D0=B2)=20instead=20of=20digits?= =?UTF-8?q?=20(#1577)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit russianLower/russianUpper showed 1), 2), 3). They now follow Word's 29-letter sequence (no ё, й, ъ or ь; ы kept), then repeat the letter (аа, бб, ааа), as observed black-box in Word. Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +++- crates/doc/src/section.rs | 35 ++++++++++++++++++++++++++++++++--- crates/layout/src/tests.rs | 31 +++++++++++++++++++++++++++++++ docs/layout-parity.md | 5 +++-- 4 files changed, 69 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 58ec4a1d8..eea2e98ad 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-14 · **Change:** trivial (progress log: justified vertical page alignment, #1320; previously trivial: progress log: file names that aren't valid Unicode, #1440; previously trivial: Behind Text pictures and shapes can be dragged across overlaid text, in the body and headers, without taking ordinary text clicks; previously trivial: progress log: crop handles on the canvas; previously trivial: progress log: Insert › Bookmark deletes and goes to bookmarks, #1236; previously trivial: progress log: an option to show only the selected text's comment, #1055; previously trivial: #778 mail-merge recipient filter/sort, .xls and legacy encodings; previously trivial: progress log: clicking in a comment's text moves its caret, #1013; previously trivial: progress log: a press on the ribbon no longer scrolls the document, #1019; previously trivial: title rename and account settings; previously trivial: progress log: Down and Up no longer skip a table, #1007; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; 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-14 · **Change:** trivial (progress log: justified vertical page alignment, #1320; previously trivial: progress log: Russian letter list labels; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; 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 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -161,6 +161,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | `wordcraft --control` checks its port (#1426): a missing port, one outside 1–65535 or one that isn't a number prints an error naming it and exits with status 2, instead of starting without the control channel (and dropping `WORDCRAFT_CONTROL_PORT`); a flag right after it, such as `--sample` or `--version`, is read as a flag rather than as the port | | 2026-10-14 | Text wrap (#1376): in the narrow strip beside a square-wrapped picture, a word that doesn't fit moves on whole to the next row; it used to be split across the strip ("docu" / "mentazione") | | 2026-10-14 | DOCX files with legacy VML preset shapes show them (#1377): Word's right arrow (`_x0000_t13`) and the other presets appear with their fill, outline and size, drawn as WordCraft draws the same DrawingML preset (the nearest shape when it has no exact one), and saving keeps them as DrawingML shapes; before, they were dropped | +| 2026-10-14 | Lists numbered with Russian letters (`russianLower`, `russianUpper`) show а), б), в) … я), then аа), бб), as in Word, instead of 1), 2), 3); the 29-letter sequence (no ё, й, ъ or ь) was observed in Word | | 2026-10-12 | Text boxes from older Word files (VML) keep their internal margins, Resize shape to fit text and vertical alignment, so letterhead address blocks and form headers sit where Word puts them instead of 7 pt in and 4 pt down. Hidden drawings stay hidden (they were drawn as empty boxes) and are kept on save | | 2026-10-13 | Word's privacy options are kept and honoured when saving .docx (#1062): with Remove personal information, comments and tracked changes are saved by "Author" and the file has no creator or last-modified-by; with Remove date and time, they are saved without dates. The open document keeps the real names and dates; `file.privacyOptions` sets both | | 2026-10-13 | A selected comment balloon's Delete, Reply and Resolve buttons work again (#1085): pressing them no longer counts as a click on the page that closes the balloon first, so a new empty comment, or any other, can be deleted from its balloon | @@ -393,6 +394,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | trivial | Progress log: `--control` checks its port (#1426) | | 2026-10-14 | trivial | Progress log: words beside a floating picture are no longer split (#1376) | | 2026-10-14 | trivial | Progress log: VML preset shapes (#1377) | +| 2026-10-14 | trivial | Progress log: Russian letter list labels | | 2026-10-12 | trivial | Progress log: VML text box margins, fit and alignment; hidden drawings | | 2026-10-13 | trivial | Progress log: comment balloon buttons work, empty comments can be deleted (#1085) | | 2026-10-12 | trivial | Progress log: sample-text formulas on Enter (#1067) | diff --git a/crates/doc/src/section.rs b/crates/doc/src/section.rs index 4f41f752b..846505119 100644 --- a/crates/doc/src/section.rs +++ b/crates/doc/src/section.rs @@ -256,7 +256,7 @@ impl NumFormat { NumFormat::ALL.into_iter().find(|f| f.ooxml() == s).unwrap_or(NumFormat::Decimal) } /// Format `n` (1-based). Formats WordCraft doesn't draw yet (Hebrew, Arabic, Hindi, Thai, - /// Vietnamese, Russian, text amounts) show decimal, as do values past a sequence's end + /// Vietnamese, text amounts) show decimal, as do values past a sequence's end /// (the 21st item of ①…⑳). pub fn format(self, n: u32) -> String { use crate::cjk_numbers as cjk; @@ -266,6 +266,8 @@ impl NumFormat { NumFormat::LowerRoman => roman(n), NumFormat::UpperLetter => letters(n).to_uppercase(), NumFormat::LowerLetter => letters(n), + NumFormat::RussianUpper => russian_letters(n).to_uppercase(), + NumFormat::RussianLower => russian_letters(n), NumFormat::Ordinal => format!("{n}{}", ordinal_suffix(n)), NumFormat::CardinalText => cardinal_text(n), NumFormat::OrdinalText => ordinal_text(n), @@ -304,8 +306,6 @@ impl NumFormat { | NumFormat::DecimalHalfWidth | NumFormat::Hex | NumFormat::VietnameseCounting - | NumFormat::RussianLower - | NumFormat::RussianUpper | NumFormat::Hebrew1 | NumFormat::Hebrew2 | NumFormat::ArabicAlpha @@ -383,6 +383,22 @@ pub fn letters(n: u32) -> String { std::iter::repeat_n(c, k as usize).collect() } +/// The 29 letters Word counts Russian lists with: а..я without ё, й, ъ and ь (ы stays). +const RUSSIAN_LETTERS: [char; 29] = + ['а', 'б', 'в', 'г', 'д', 'е', 'ж', 'з', 'и', 'к', 'л', 'м', 'н', 'о', 'п', 'р', 'с', 'т', 'у', 'ф', 'х', 'ц', 'ч', 'ш', 'щ', 'ы', 'э', 'ю', 'я']; + +/// Word-style Russian letters: а..я, аа..яя, ааа.. (repeated letter, as [`letters`]). +pub fn russian_letters(n: u32) -> String { + if n == 0 { + return String::new(); + } + let len = RUSSIAN_LETTERS.len() as u32; + let n = n.min(len * 30); + let k = (n - 1) / len + 1; + let c = RUSSIAN_LETTERS.get(((n - 1) % len) as usize).copied().unwrap_or('а'); + std::iter::repeat_n(c, k as usize).collect() +} + const ONES: [&str; 20] = [ "zero", "one", @@ -860,6 +876,8 @@ mod tests { assert_eq!(NumFormat::ChineseCountingThousand.format(101), "一百零一"); assert_eq!(NumFormat::DecimalEnclosedCircle.format(3), "③"); assert_eq!(NumFormat::Hebrew1.format(3), "3", "formats not drawn yet show decimal"); + assert_eq!(NumFormat::RussianLower.format(0), ""); + assert_eq!(NumFormat::RussianUpper.format(u32::MAX).chars().count(), 30); for f in NumFormat::ALL { assert_eq!(NumFormat::from_ooxml(f.ooxml()), f, "{f:?} round-trips"); assert!(f == NumFormat::Bullet || f == NumFormat::None || !f.format(u32::MAX).is_empty(), "{f:?}"); @@ -867,6 +885,17 @@ mod tests { assert_eq!(NumFormat::from_ooxml("klingon"), NumFormat::Decimal, "unknown formats are decimal"); } + #[test] + fn russian_letters_follow_word() { + // Word's own labels for a russianLower list: no ё, й, ъ or ь, then doubled letters. + let labels: Vec = (1..=31).map(|n| NumFormat::RussianLower.format(n)).collect(); + assert_eq!(labels.join(" "), "а б в г д е ж з и к л м н о п р с т у ф х ц ч ш щ ы э ю я аа бб"); + assert_eq!(NumFormat::RussianLower.format(59), "ааа"); + assert_eq!(NumFormat::RussianLower.format(100), "нннн"); + assert_eq!(NumFormat::RussianUpper.format(26), "Ы"); + assert_eq!(NumFormat::RussianUpper.format(36), "ЖЖ"); + } + #[test] fn landscape_swaps() { let mut s = SectionProps { margin_left: 50.0, ..Default::default() }; diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index d0bc8ba4a..b669a5a93 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -738,6 +738,37 @@ fn lists_get_labels_and_indent() { assert!((x - 108.0).abs() < 0.5, "{x}"); } +#[test] +fn russian_letter_list_labels_draw_cyrillic() { + // russianLower labelled items 1, 2, 3 instead of а), б), в). + use wordcraft_doc::section::NumFormat; + let mut d = Document::from_text("один\nдва\nтри"); + let id = d.numbering.add_list(wordcraft_doc::ListKind::Numbered); + for a in &mut d.numbering.abstracts { + if let Some(l) = a.levels.first_mut() { + l.format = NumFormat::RussianLower; + l.text = "%1)".into(); + } + } + d.format_paragraphs(&Pos::body(0, 0), &Pos::body(2, 3), &|p| p.numbering = Some(wordcraft_doc::props::NumRef { num: id, level: 0 })).unwrap(); + let l = lay(&d); + let labels: Vec<(String, bool)> = l.pages[0] + .items + .iter() + .filter_map(|it| if let Placed::Lines { para, .. } = it { Some(para) } else { None }) + .filter_map(|pl| { + let lab = pl.label.as_ref()?; + let drawn = lab.runs.iter().all(|r| { + let face = &pl.styles[r.style as usize].face; + let gids: Vec = lab.glyphs[r.glyphs.clone()].iter().map(|g| g.gid).collect(); + r.text.chars().all(|c| gids.contains(&face.glyph_for(c)) && face.glyph_for(c) != 0) + }); + Some((lab.text.clone(), drawn)) + }) + .collect(); + assert_eq!(labels, [("а)".to_string(), true), ("б)".to_string(), true), ("в)".to_string(), true)]); +} + #[test] fn chinese_counting_list_labels_and_their_font_runs() { // Issue #534: a "一、" list showed digits, drawn with glyph ids of another font. diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 985735c36..35c993737 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -1,6 +1,6 @@ # Layout and pagination parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (justified vertical page alignment, #1320; previously trivial: widow control moves a three-line paragraph whole; previously trivial: STYLEREF running headers per page, #775; previously trivial: PAGEREF in headers and footers shows its bookmark's page, #1477; previously trivial: a paragraph continued into the next column or page wraps around that column's shapes, #1374; previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (justified vertical page alignment, #1320; previously trivial: Russian letter list labels, а) б) в); previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 A document only "opens correctly" if its pages break where Word breaks them: the same lines, the same page count, footnotes and pictures on the same pages. This checklist covers line breaking, @@ -36,7 +36,7 @@ font can't match Word's metrics. | Drop caps | ✅ | 2026-10-06 | — | | Columns, column breaks, separators, balancing | ✅ | Columns balance on a section's last page when the next section starts continuous (also at the document end, which Word only balances when the document ends with a continuous break); not after next-page/even/odd breaks or on a page with a column break; keep-together, keep-with-next, widow/orphan control and unsplittable rows can leave them slightly uneven (#350) | — | | Section breaks (next page, continuous, even, odd), different first page, odd/even headers | ✅ | Section breaks don't show in Draft view (#42). A break inserted with text selected goes before the selection and keeps it, as in Word (#852) | 1–2 | -| List labels: number formats (decimal, letters, roman, ordinal text, Chinese/Japanese/Korean counting and legal, 一〇一 digits, 甲乙丙, ①…⑳, full-width), `%n` text such as `第%1章` / `(%1)`, legal numbering | ✅ | `doc::cjk_numbers` tests, `layout::tests::chinese_counting_list_labels_and_their_font_runs`; a label mixing faces (digit + CJK comma) is drawn run by run (#534). Hebrew, Arabic, Hindi, Thai, Vietnamese and Russian formats show decimal The label stays attached to the start of the text when the paragraph is centred or aligned to the end, as in Word (#1159). | — | +| List labels: number formats (decimal, letters, Russian letters, roman, ordinal text, Chinese/Japanese/Korean counting and legal, 一〇一 digits, 甲乙丙, ①…⑳, full-width), `%n` text such as `第%1章` / `(%1)`, legal numbering | ✅ | `doc::cjk_numbers` tests, `doc::section::tests::russian_letters_follow_word`, `layout::tests::chinese_counting_list_labels_and_their_font_runs`, `layout::tests::russian_letter_list_labels_draw_cyrillic`; a label mixing faces (digit + CJK comma) is drawn run by run (#534); Russian letters skip ё, й, ъ and ь and then double (аа, бб), as observed in Word. Hebrew, Arabic, Hindi, Thai and Vietnamese formats show decimal The label stays attached to the start of the text when the paragraph is centred or aligned to the end, as in Word (#1159). | — | | Body top below a tall header, footer pushes body up | ✅ | #138; a footer taller than the bottom margin moves the body bottom up (#453), and a header or footer linked to the previous section takes its room too (#454) | — | | Footnotes at the page bottom, endnotes at the end, note numbering | 🟡 | A long note continues onto the next page at a line boundary (its first line stays with the reference), first in that page's note area under a full-width continuation separator (#352); the document's own separator, continuation separator and continuation notice are drawn (the notice under a note that continues); on a page with footnotes a paragraph ending there keeps its space after above the separator, or its last lines go to the next page with the note (#1116); a long endnote continues on the next column or page at a line boundary (#456), and the caret finds note text on its continuation page (#457); per-section restart partial | 1–3 | | Body top below a tall header, footer pushes body up | ✅ | #138 | — | @@ -163,6 +163,7 @@ No overall parity estimate changes from these probes. | 2026-10-14 | trivial | Widow control: a three-line paragraph with room for two of its lines at the bottom of a page moves whole, as in Word (no two-and-one split leaving a widow); a paragraph continued from the previous page may still take one line along from two | | 2026-10-13 | trivial | PAGEREF in headers and footers shows its bookmark's page (#1477) | | 2026-10-14 | trivial | A word too wide for the strip beside a square-wrapped object moves past it whole instead of being split across the strip (#1376) | +| 2026-10-14 | trivial | Russian letter list formats (`russianLower`, `russianUpper`) draw а) б) в) … аа) бб) as Word does, instead of digits | | 2026-10-13 | trivial | A shape or picture dragged into another column anchors there, so the text in that column wraps around it (#884) | | 2026-10-13 | trivial | On a page with footnotes, a paragraph that ends there keeps its space after above the footnote separator; if it doesn't fit, its last line (and, with widow control, one more) goes to the next page with the note. Pages without notes are unchanged (#1116) | | 2026-10-12 | trivial | STYLEREF fields in headers and footers show each page's chapter (or other styled text); Insert › Quick Parts › Field offers STYLEREF (#775) | From 663de991dfc51b095bf214ba8c5499cea1967ad5 Mon Sep 17 00:00:00 2001 From: rabis7575 <340973947+rabis7575@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:37:48 +0200 Subject: [PATCH 013/101] Flat OPC: reading bounded everywhere in the file, CDATA base64 read (#1353) (#1580) The Word XML Document (Flat OPC) reader counted only the parts it kept: a repeated or nameless part, a child of the package that isn't a part and anything nested inside one were read without being counted, nesting had no cap, and a part's inline XML was copied before its size was checked. Every child of the package now counts against the part limit and every part's data against the part and package size limits, kept or not; nesting and the elements of each child are capped as the XML tree reader caps a part's; and data is checked before it is copied. Base64 in pkg:binaryData was taken from plain text only, so CDATA and character references were dropped and a picture written that way lost bytes. Both now read as the same text, and an entity there is an invalid base64 error. Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 2 + crates/docx/src/flat.rs | 119 +++++++++++++++++++++++++++++----- crates/docx/tests/word_xml.rs | 47 ++++++++++++++ 3 files changed, 150 insertions(+), 18 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index eea2e98ad..7161a14ce 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -307,6 +307,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-11 | Outline view (#493): Outlining tab, outline symbols and indents, collapse/expand, Show Level, first line only, plain text; catalog 428/450 → 441/463 (95.2%) | | 2026-10-11 | Vertical sections (#491): Layout › Text Direction turns the section (or the cell or text box the caret is in); lines run top to bottom, right to left, ideographs upright; `w:textDirection` in `w:sectPr` read and written | | 2026-10-11 | File › Options in Word's panes (#485): General, Display, Proofing, Save, Language, Accessibility, Advanced, Agents; initials, individual formatting marks, spelling ignore options, custom dictionary, overtype, typing replaces selection, measurement units, recent-documents count, print options; `file.options` reads and sets them headlessly | +| 2026-10-14 | Word XML Document (Flat OPC) reading is bounded everywhere in the file (#1353): nesting and elements are capped as the XML tree reader caps a part's, every child of the package and every part's data count against the part count and size limits (a repeated part name too), and data is checked before it's copied; base64 written as CDATA or with character references reads as the same bytes, and an entity in it is an error instead of bytes gone missing | | 2026-10-11 | Right-click Wrap Text submenu on pictures, shapes and text boxes: wrapping styles, Move with Text / Fix Position on Page (#513) | | 2026-10-11 | Caret animation: keys and typing glide the caret to its new place in 100 ms along the curve measured from Word for Windows, and the blink fades; clicks and scripted moves don't glide. File › Options › Advanced › Animation turns it off, kept between runs; `ui.caretAnimation` for agents | | 2026-10-11 | Paragraph dialog options (#512): mirror indents (`w:mirrorIndents`, laid out on even pages), same-style spacing, suppress line numbers, don't hyphenate | @@ -528,6 +529,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-10 | trivial | Progress log: contour wrap (#354) | | 2026-10-10 | trivial | Progress log: free rotation and flips (#332) | | 2026-10-10 | trivial | Progress log: Windows OpenGL fallback without a DirectX 12 driver (#316) | +| 2026-10-14 | trivial | Progress log: Flat OPC reading bounded everywhere in the file, CDATA base64 read (#1353) | | 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% | diff --git a/crates/docx/src/flat.rs b/crates/docx/src/flat.rs index f0949a200..fca444c22 100644 --- a/crates/docx/src/flat.rs +++ b/crates/docx/src/flat.rs @@ -5,7 +5,9 @@ //! //! Reading unpacks the parts into an in-memory [`Package`] and hands it to the DOCX reader; //! writing runs the DOCX writer and lays its parts out in one file. Bounded like the zip reader: -//! part count, part size, total size and base64 length are capped. +//! every part counts against the part count, part size and total size limits whether it's kept +//! or not (a repeated name), data is checked before it's copied, and nesting and elements are +//! capped as the XML tree reader caps a part's. use std::collections::BTreeMap; @@ -92,8 +94,24 @@ struct Part { data: Option, } -/// Longest base64 text of one part (the size limit, encoded; whitespace isn't kept). -const MAX_BASE64: u64 = (MAX_PART / 3 + 1) * 4; +/// Deepest element nesting read: a part's inline XML starts below the package, part and data +/// elements and may nest as deep as the XML tree reader takes ([`xml::MAX_DEPTH`] open elements +/// and an empty one inside the deepest). +const MAX_NESTING: usize = 3 + xml::MAX_DEPTH + 1; + +/// Most elements in one child of the package: a part, its data element and as many as the XML +/// tree reader takes in a part ([`xml::MAX_ELEMENTS`]). +const MAX_PART_ELEMENTS: usize = 2 + xml::MAX_ELEMENTS; + +/// Longest base64 text of a part that decodes to at most `bytes` (whitespace isn't kept). +fn max_base64(bytes: u64) -> u64 { + (bytes / 3).saturating_add(1).saturating_mul(4) +} + +/// Bytes the next part may have: its own size limit, or what's left of the package's. +fn room(total: u64) -> u64 { + MAX_PART.min(MAX_TOTAL.saturating_sub(total)) +} /// Unpack a Flat OPC file into its parts, with a `[Content_Types].xml` made from the parts' /// content types (unless the file carries one). @@ -103,6 +121,9 @@ pub(crate) fn unpack(bytes: &[u8]) -> Result { let mut r = NsReader::from_reader(src); r.config_mut().check_end_names = true; let mut depth = 0usize; + // Children of the package (parts or anything else), and elements in the current one. + let mut entries = 0usize; + let mut elements = 0usize; let mut files: BTreeMap> = BTreeMap::new(); let mut types: Vec<(String, String)> = Vec::new(); let mut total: u64 = 0; @@ -118,15 +139,26 @@ pub(crate) fn unpack(bytes: &[u8]) -> Result { Event::Start(ref e) | Event::Empty(ref e) => { let empty = matches!(ev, Event::Empty(_)); depth += 1; + if depth > MAX_NESTING { + return Err(DocxError::Limit("XML nesting too deep".into())); + } + if depth == 2 { + entries += 1; + elements = 0; + if entries > MAX_ENTRIES { + return Err(DocxError::Limit(format!("more than {MAX_ENTRIES} parts"))); + } + } + elements += 1; + if elements > MAX_PART_ELEMENTS { + return Err(DocxError::Limit("too many XML elements".into())); + } let local = e.local_name().as_ref().to_vec(); match depth { 1 if !(in_pkg && local == b"package") => { return Err(DocxError::NotWord("the root element isn't a Flat OPC pkg:package".into())); } 2 if in_pkg && local == b"part" => { - if files.len() + usize::from(part.is_some()) >= MAX_ENTRIES { - return Err(DocxError::Limit(format!("more than {MAX_ENTRIES} parts"))); - } let name = attr(e, b"name").unwrap_or_default(); let content_type = attr(e, b"contentType").unwrap_or_default(); part = Some(Part { name, content_type, data: None }); @@ -167,7 +199,12 @@ pub(crate) fn unpack(bytes: &[u8]) -> Result { && let Some(Data::Xml(start)) = p.data { p.data = None; - let xml = with_scope(src.get(start..before).unwrap_or_default(), &scope); + let frag = src.get(start..before).unwrap_or_default(); + // Refused before it's copied when it can't fit. + if frag.len() as u64 > room(total) { + return Err(DocxError::Limit(format!("part {} is larger than the size limit", p.name))); + } + let xml = with_scope(frag, &scope); store(&p.name, &p.content_type, xml, &mut files, &mut types, &mut total)?; } if depth == 2 @@ -183,15 +220,18 @@ pub(crate) fn unpack(bytes: &[u8]) -> Result { break; } } - Event::Text(ref t) if depth == 3 => { - if let Some(Part { data: Some(Data::Binary(b64)), .. }) = part.as_mut() { - let s = std::str::from_utf8(t.as_ref()).map_err(|e| DocxError::Xml(e.to_string()))?; - b64.extend(s.chars().filter(|c| !c.is_ascii_whitespace())); - if b64.len() as u64 > MAX_BASE64 { - return Err(DocxError::Limit("a binary part is larger than the size limit".into())); + Event::Text(ref t) if depth == 3 => add_base64(&mut part, t, total)?, + Event::CData(ref t) if depth == 3 => add_base64(&mut part, t, total)?, + Event::GeneralRef(ref g) if depth == 3 => match g.resolve_char_ref() { + // A character reference stands for its character (`A` is an `A`). + Ok(Some(c)) => add_base64(&mut part, c.encode_utf8(&mut [0; 4]).as_bytes(), total)?, + // An entity isn't base64: binary data holding one is broken, not shorter. + _ => { + if let Some(Part { name, data: Some(Data::Binary(_)), .. }) = &part { + return Err(DocxError::Xml(format!("part {name} has invalid base64 data"))); } } - } + }, Event::Eof => break, _ => {} } @@ -225,7 +265,21 @@ fn finish_part(p: Part, files: &mut BTreeMap>, types: &mut Vec<( } } -/// Add a part, within the size limits; a repeated or empty name is ignored. +/// Text (or CDATA) inside a part's `pkg:binaryData`: its base64 without whitespace, checked +/// against the size limits before it's copied. +fn add_base64(part: &mut Option, text: &[u8], total: u64) -> Result<(), DocxError> { + let Some(Part { data: Some(Data::Binary(b64)), .. }) = part else { return Ok(()) }; + let s = std::str::from_utf8(text).map_err(|e| DocxError::Xml(e.to_string()))?; + let more = s.bytes().filter(|b| !b.is_ascii_whitespace()).count(); + if b64.len().saturating_add(more) as u64 > max_base64(room(total)) { + return Err(DocxError::Limit("a binary part is larger than the size limit".into())); + } + b64.extend(s.chars().filter(|c| !c.is_ascii_whitespace())); + Ok(()) +} + +/// Add a part, within the size limits; a repeated or empty name isn't stored but counts against +/// them all the same (as a repeated zip entry does). fn store( name: &str, content_type: &str, @@ -235,9 +289,6 @@ fn store( total: &mut u64, ) -> Result<(), DocxError> { let name = name.trim_start_matches('/'); - if name.is_empty() || files.contains_key(name) { - return Ok(()); - } let len = bytes.len() as u64; if len > MAX_PART { return Err(DocxError::Limit(format!("part {name} is larger than the size limit"))); @@ -246,6 +297,9 @@ fn store( if *total > MAX_TOTAL { return Err(DocxError::Limit("the package is larger than the size limit".into())); } + if name.is_empty() || files.contains_key(name) { + return Ok(()); + } if !content_type.is_empty() && !content_type.contains(['\r', '\n']) { types.push((name.to_string(), content_type.to_string())); } @@ -408,4 +462,33 @@ mod tests { assert_eq!(with_scope(own, &scope), own.to_vec()); assert_eq!(with_scope(b"no element", &scope), b"no element".to_vec()); } + + #[test] + fn nesting_is_capped_where_the_xml_tree_reader_stops() { + let inline = |levels: usize| format!("{}{}", "".repeat(levels), "".repeat(levels)); + let package = |levels: usize| { + format!( + "{}", + inline(levels) + ) + }; + assert!(xml::parse(inline(xml::MAX_DEPTH).as_bytes()).is_ok()); + assert!(unpack(package(xml::MAX_DEPTH).as_bytes()).is_ok()); + assert!(matches!(xml::parse(inline(xml::MAX_DEPTH + 1).as_bytes()), Err(DocxError::Limit(_)))); + assert!(matches!(unpack(package(xml::MAX_DEPTH + 1).as_bytes()), Err(DocxError::Limit(_)))); + } + + #[test] + fn repeated_and_nameless_parts_count_against_the_size_limits() { + let (mut files, mut types, mut total) = (BTreeMap::new(), Vec::new(), 0u64); + for name in ["/a.bin", "/a.bin", ""] { + store(name, "", vec![0; 10], &mut files, &mut types, &mut total).unwrap(); + } + assert_eq!((files.len(), total), (1, 30)); + // Near the package limit, what's left of it is all the next part may have. + total = MAX_TOTAL - 5; + assert_eq!(room(total), 5); + assert_eq!(max_base64(5), 8); + assert!(matches!(store("/b.bin", "", vec![0; 6], &mut files, &mut types, &mut total), Err(DocxError::Limit(_)))); + } } diff --git a/crates/docx/tests/word_xml.rs b/crates/docx/tests/word_xml.rs index 1a4287b80..ca2dacacc 100644 --- a/crates/docx/tests/word_xml.rs +++ b/crates/docx/tests/word_xml.rs @@ -213,3 +213,50 @@ fn hostile_word_xml_is_an_error_not_a_panic() { many.push_str(""); assert!(matches!(read_xml(many.as_bytes()), Err(DocxError::Limit(_)))); } + +/// A Flat OPC package of one paragraph with a picture, whose `pkg:binaryData` holds `data` as +/// written, and `extra` after the parts. +fn picture_package(data: &str, extra: &str) -> String { + format!( + r#" + + + Seed + {data}{extra} +"# + ) +} + +/// A binary part's base64 reads the same as plain text, as CDATA or with character references; +/// an entity in it is an error rather than bytes gone missing (#1353). +#[test] +fn flat_opc_binary_data_reads_the_same_in_cdata_and_character_references() { + let png = tiny_png_b64(); + let media = |data: &str| read_xml(picture_package(data, "").as_bytes()).map(|d| d.media.values().cloned().collect::>()); + let plain = media(&png).unwrap(); + assert_eq!(plain.len(), 1); + let (head, tail) = png.split_at(png.len() / 2); + let first = png.chars().next().unwrap(); + for other in + [format!(""), format!("{head}"), format!("&#{};{}", u32::from(first), &png[1..]).replace('\n', " ")] + { + assert_eq!(media(&other).unwrap(), plain, "{other}"); + } + assert!(media(&format!("&{png}")).is_err()); +} + +/// Work anywhere in a Flat OPC file is bounded, not only in the parts that are kept: nesting +/// and elements no part reader looks at, and repeated part names, count too (#1353). +#[test] +fn flat_opc_limits_count_what_is_not_kept() { + let png = tiny_png_b64(); + let limited = |extra: &str| matches!(read_xml(picture_package(&png, extra).as_bytes()), Err(DocxError::Limit(_))); + assert!(read_xml(picture_package(&png, "").as_bytes()).is_ok()); + // Deep nesting outside the parts. + assert!(limited(&format!("{}{}", "".repeat(1000), "".repeat(1000)))); + // One part name over and over, or other elements, past the part count. + assert!(limited(&r#"AA=="#.repeat(20_000))); + assert!(limited(&"".repeat(20_000))); + // A part nothing reads, one element over what the XML tree reader takes in a part (8 000 000). + assert!(limited(&format!(r#"{}"#, "".repeat(8_000_000)))); +} From 4175d68be5fcbaddd27f551086f5079656d402b2 Mon Sep 17 00:00:00 2001 From: rabis7575 <340973947+rabis7575@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:37:59 +0200 Subject: [PATCH 014/101] Text boxes in tables nested deeper than 16 survive edits (#1339) (#1591) The model's paragraph walk (each_para / each_para_mut) stopped 16 tables down, while a .docx opens with tables nested up to 24 deep and pasting can nest them further. Text box pruning, which runs after every edit, took a box shown only from deeper for one shown nowhere and dropped its story, so an unrelated edit lost the box's text. The same walk decides which boxes a copy takes along and which pasted shapes get stories of their own, and serves reading order, the next drawing id and note conversion. The walk now keeps its own stack instead of recursing, so it reaches every paragraph however deep tables nest, in the same document order and with no call stack to run out of; a paragraph on its own still needs no allocation. The signature is unchanged (the depth argument is now unused), so every caller builds as before. Tests: a box 17 tables down in an opened .docx survives an unrelated edit and a save, while a box shown nowhere still goes; boxes 17 and 40 tables down, in a box and in a header are kept, counted in reading order, and new drawing ids go above the deep shape's; a copied deep table pastes into another document showing its own box's text. Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 2 + crates/doc/src/edit.rs | 49 ++++++++++++++---------- crates/doc/src/tests.rs | 78 ++++++++++++++++++++++++++++++++++++++ crates/engine/src/tests.rs | 44 +++++++++++++++++++++ 4 files changed, 153 insertions(+), 20 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 7161a14ce..8a4a17990 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -309,6 +309,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-11 | File › Options in Word's panes (#485): General, Display, Proofing, Save, Language, Accessibility, Advanced, Agents; initials, individual formatting marks, spelling ignore options, custom dictionary, overtype, typing replaces selection, measurement units, recent-documents count, print options; `file.options` reads and sets them headlessly | | 2026-10-14 | Word XML Document (Flat OPC) reading is bounded everywhere in the file (#1353): nesting and elements are capped as the XML tree reader caps a part's, every child of the package and every part's data count against the part count and size limits (a repeated part name too), and data is checked before it's copied; base64 written as CDATA or with character references reads as the same bytes, and an entity in it is an error instead of bytes gone missing | | 2026-10-11 | Right-click Wrap Text submenu on pictures, shapes and text boxes: wrapping styles, Move with Text / Fix Position on Page (#513) | +| 2026-10-14 | Text boxes in tables nested deeper than 16 survive edits (#1339): the paragraph walk behind text box pruning, copy and paste, reading order, drawing ids and note conversion goes through tables however deep they nest instead of stopping 16 tables down, so an unrelated edit no longer drops the text of a box shown only from deeper, and a copied deep table takes its boxes along | | 2026-10-11 | Caret animation: keys and typing glide the caret to its new place in 100 ms along the curve measured from Word for Windows, and the blink fades; clicks and scripted moves don't glide. File › Options › Advanced › Animation turns it off, kept between runs; `ui.caretAnimation` for agents | | 2026-10-11 | Paragraph dialog options (#512): mirror indents (`w:mirrorIndents`, laid out on even pages), same-style spacing, suppress line numbers, don't hyphenate | | 2026-10-11 | Screen readers can read the document canvas (#488): paragraphs, headings, lists, tables, links, pictures and the caret exposed through AccessKit | @@ -527,6 +528,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-10 | trivial | Draw Table and Eraser (#303): catalog 393/431 (91.2%) | | 2026-10-10 | trivial | Progress log: charts, SmartArt and OLE objects kept on DOCX save (#319) | | 2026-10-10 | trivial | Progress log: contour wrap (#354) | +| 2026-10-14 | trivial | Progress log: text boxes in deeply nested tables kept through edits (#1339) | | 2026-10-10 | trivial | Progress log: free rotation and flips (#332) | | 2026-10-10 | trivial | Progress log: Windows OpenGL fallback without a DirectX 12 driver (#316) | | 2026-10-14 | trivial | Progress log: Flat OPC reading bounded everywhere in the file, CDATA base64 read (#1353) | diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index 1894ce6fd..4420b6e31 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -63,37 +63,46 @@ const MAX_BOX_DEPTH: usize = 8; /// Most text box stories one paste may create (hostile documents can fan out). const MAX_PASTED_BOXES: usize = 4096; -/// Call `f` on every paragraph of a block (tables descended, depth-limited). -pub(crate) fn each_para<'a>(b: &'a Block, depth: usize, f: &mut dyn FnMut(&'a Paragraph)) { - match b { - Block::Para(p) => f(p), - Block::Table(t) if depth < 16 => { - for cell in t.rows.iter().flat_map(|r| r.cells.iter()) { - for cb in &cell.blocks { - each_para(cb, depth + 1, f); - } +/// Call `f` on every paragraph of a block, in document order, however deep its tables nest. The +/// walk keeps its own stack instead of recursing, so it is complete: a text box it doesn't reach +/// is shown nowhere, which pruning relies on (#1339). `_depth` is unused (callers keep passing it). +pub(crate) fn each_para<'a>(b: &'a Block, _depth: usize, f: &mut dyn FnMut(&'a Paragraph)) { + // Most blocks are paragraphs, which need no stack. + if let Block::Para(p) = b { + return f(p); + } + let mut todo = vec![b]; + while let Some(b) = todo.pop() { + match b { + Block::Para(p) => f(p), + Block::Table(t) => { + // Last first, so they come off the stack in document order. + let cells = t.rows.iter().rev().flat_map(|r| r.cells.iter().rev()); + todo.extend(cells.flat_map(|c| c.blocks.iter().rev()).map(|cb| &**cb)); } } - Block::Table(_) => {} } } -pub(crate) fn each_para_mut(b: &mut Block, depth: usize, f: &mut dyn FnMut(&mut Paragraph)) { - match b { - Block::Para(p) => f(p), - Block::Table(t) if depth < 16 => { - for cell in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) { - for cb in &mut cell.blocks { - each_para_mut(Arc::make_mut(cb), depth + 1, f); - } +/// [`each_para`], changing the paragraphs. +pub(crate) fn each_para_mut(b: &mut Block, _depth: usize, f: &mut dyn FnMut(&mut Paragraph)) { + if let Block::Para(p) = b { + return f(p); + } + let mut todo = vec![b]; + while let Some(b) = todo.pop() { + match b { + Block::Para(p) => f(p), + Block::Table(t) => { + let cells = t.rows.iter_mut().rev().flat_map(|r| r.cells.iter_mut().rev()); + todo.extend(cells.flat_map(|c| c.blocks.iter_mut().rev()).map(Arc::make_mut)); } } - Block::Table(_) => {} } } /// Whether a paragraph anywhere in `b` (also inside tables) uses paragraph or character style -/// `id` (depth-limited like `each_para`). Table styles are the engine's `table.deleteStyle`'s job. +/// `id` (tables to 16 levels deep). Table styles are the engine's `table.deleteStyle`'s job. fn uses_style(b: &Block, id: &str, depth: usize) -> bool { let is = |s: &Option| s.as_deref() == Some(id); match b { diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 3a895ef2b..5dc89468d 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -281,6 +281,84 @@ fn prune_text_boxes_drops_only_unshown_ones() { assert_eq!(d.prune_text_boxes(), 0); } +/// `blocks` in the only cell of `levels` tables, each nested in the next. +fn nested_tables(levels: usize, blocks: Blocks) -> Blocks { + (0..levels).fold(blocks, |inner, _| { + let mut t = Table::new(1, 1, 100.0); + t.rows[0].cells[0].blocks = inner; + vec![Arc::new(Block::Table(t))] + }) +} + +/// A paragraph showing text box story `story` (drawing id `id`). +fn box_para(story: u32, id: u32) -> Blocks { + let shape = InlineObject::Shape { + kind: para::ShapeKind::TextBox, + w: 50.0, + h: 20.0, + fill: None, + stroke: None, + stroke_width: 0.0, + float: Default::default(), + story: Some(story), + freeform: None, + effects: Default::default(), + extra: connector::ShapeExtra { id, ..Default::default() }, + }; + let mut p = Paragraph::with_text("cell", CharProps::default()); + p.insert_object(0, shape, &CharProps::default()).unwrap(); + vec![para_block(p)] +} + +#[test] +fn text_boxes_shown_from_deeply_nested_tables_are_kept() { + // #1339: the paragraph walk stopped 16 tables down, so a box shown only from deeper was taken + // for unshown and the next edit pruned its story. + let para_of = |t: &str| vec![para_block(Paragraph::with_text(t, CharProps::default()))]; + let mut d = Document::from_text("body"); + let at_17 = d.add_part(PartKind::TextBox, para_of("17 tables down")); + let at_40 = d.add_part(PartKind::TextBox, para_of("40 tables down")); + let inner = d.add_part(PartKind::TextBox, para_of("deep in a box")); + let outer = d.add_part(PartKind::TextBox, nested_tables(18, box_para(inner, 0))); + let in_header = d.add_part(PartKind::TextBox, para_of("deep in a header")); + let hdr = d.add_part(PartKind::Header, nested_tables(20, box_para(in_header, 0))); + let unshown = d.add_part(PartKind::TextBox, para_of("shown nowhere")); + d.body.extend(nested_tables(17, box_para(at_17, 0))); + d.body.extend(nested_tables(40, box_para(at_40, 7))); + d.body.extend(box_para(outer, 0)); + assert_eq!(d.prune_text_boxes(), 1); + assert!(!d.parts.contains_key(&unshown)); + let kept: Vec = d.parts.keys().copied().collect(); + assert_eq!(kept, vec![at_17, at_40, inner, outer, in_header, hdr]); + assert_eq!(d.prune_text_boxes(), 0); + // Counted in reading order, the nested box after its own; new drawing ids above the deep one. + let part = StoryRef::Part; + assert_eq!(d.counted_stories(), vec![StoryRef::Body, part(at_17), part(at_40), part(outer), part(inner)]); + assert_eq!(d.next_shape_id(), 8); +} + +#[test] +fn a_deeply_nested_table_is_copied_with_its_text_boxes() { + // A copy takes the box 17 tables down along, so pasting it into another document shows its + // text, not the other document's box that happens to have the same id. + let para_of = |t: &str| vec![para_block(Paragraph::with_text(t, CharProps::default()))]; + let mut d = Document::from_text("body"); + let deep = d.add_part(PartKind::TextBox, para_of("deep box")); + d.body.extend(nested_tables(17, box_para(deep, 0))); + let frag = d.body_fragment(); + assert_eq!(frag.parts.keys().copied().collect::>(), vec![deep]); + let mut e = Document::new(); + let own = e.add_part(PartKind::TextBox, para_of("its own box")); + assert_eq!(own, deep, "the same id in both documents"); + e.body = box_para(own, 0); + let end = e.end_of(StoryRef::Body); + e.insert_fragment(&end, &frag).unwrap(); + let mut shown = Vec::new(); + e.objects_in_reading_order(&mut |o| shown.extend(o.text_box())); + let texts: Vec = shown.iter().map(|id| e.plain_text(StoryRef::Part(*id))).collect(); + assert_eq!(texts, vec!["its own box", "deep box"]); +} + #[test] fn notes_in_text_box_follow_nested_boxes_once() { let shape = |story| InlineObject::Shape { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 811da96b6..1eef4a492 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2766,6 +2766,50 @@ fn deleted_text_box_takes_its_text_with_it_until_undo() { assert_eq!(s.doc.word_count_including_notes(), 2); } +#[test] +fn text_box_in_a_table_nested_seventeen_deep_survives_an_edit_and_a_save() { + // #1339: opened from a .docx, a box shown only from a table nested deeper than 16 was taken + // for unshown, so the next unrelated edit pruned its story and saving dropped its text. + use wordcraft_doc::{Block, Document, InlineObject, Paragraph, PartKind, Path, Table, para_block}; + let mut d = Document::from_text("Intro"); + let id = d.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text("Deep box", Default::default()))]); + let shape = InlineObject::Shape { + kind: wordcraft_doc::para::ShapeKind::TextBox, + w: 72.0, + h: 36.0, + fill: None, + stroke: None, + stroke_width: 0.75, + float: Default::default(), + story: Some(id), + freeform: None, + effects: Default::default(), + extra: Default::default(), + }; + let mut p = Paragraph::with_text("Cell", Default::default()); + p.insert_object(0, shape, &Default::default()).unwrap(); + d.body.extend((0..17).fold(vec![para_block(p)], |inner, _| { + let mut t = Table::new(1, 1, 300.0); + t.rows[0].cells[0].blocks = inner; + vec![std::sync::Arc::new(Block::Table(t))] + })); + // The innermost cell's paragraph: the table after "Intro", then row, cell and block 0 down. + let path = Path([vec![1], vec![0; 17 * 3]].concat()); + let deep_box = |doc: &Document| { + let id = doc.para(StoryRef::Body, &path)?.objects.iter().find_map(InlineObject::text_box)?; + doc.parts.get(&id).filter(|p| p.kind == PartKind::TextBox).map(|_| doc.plain_text(StoryRef::Part(id))) + }; + let mut s = Session::new(wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap()); + assert_eq!(deep_box(&s.doc).as_deref(), Some("Deep box"), "opened"); + let unshown = s.doc.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text("Shown nowhere", Default::default()))]); + run(&mut s, "text.insert", json!({"text": "Edited. "})); + assert_eq!(deep_box(&s.doc).as_deref(), Some("Deep box"), "kept through the edit"); + assert!(!s.doc.parts.contains_key(&unshown), "a box shown nowhere still goes"); + let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap(); + assert_eq!(deep_box(&back).as_deref(), Some("Deep box"), "saved"); + assert!(back.plain_text(StoryRef::Body).starts_with("Edited. Intro")); +} + #[test] fn word_count_with_an_object_selected_counts_the_document() { let mut s = s(); From e9e8af517cb0d8a93d73c3d708e48cb6be1429e9 Mon Sep 17 00:00:00 2001 From: Fin Warman Date: Thu, 15 Oct 2026 00:38:18 +0100 Subject: [PATCH 015/101] Layout: a table's percentage width past 100% is kept (#1598) * Layout: a table's percentage width past 100% is kept Word keeps a preferred width over 100% of the text column (w:tblW w:type="pct" above 5000). A cover-page footer table at 131% with the left margin as a negative indent spans the page edge to edge in Word; WordCraft capped it at 100%, so it ended three-quarters of the way across. Co-Authored-By: Claude Opus 5.5 * Contributors: add Fin Warman --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- contributors/people.toml | 3 +++ crates/layout/src/table.rs | 6 +++++- crates/layout/src/tests.rs | 17 +++++++++++++++++ docs/layout-parity.md | 5 +++-- 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index de2aa3f85..8f358dca8 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -107,3 +107,6 @@ display_name = "The Legend Of MTS Dev" [people.dotCooCoo] real_name = "Robert Lee" + +[people.finwarman] +real_name = "Fin Warman" diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index 3eb05ccc6..854d3b059 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -11,6 +11,9 @@ use wordcraft_geom::Rect; use crate::para::{CellText, ClKind, ParaLayout}; use crate::{BoxContext, BoxLayout, Ctx, Placed, layout_box}; +/// Largest preferred table width, as a share of the text column, that a file can ask for. +const MAX_WIDTH_SHARE: f32 = 10.0; + pub struct RowLayout { pub height: f32, /// Items relative to the table's top-left (x) and the row's top (y). @@ -341,7 +344,8 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava }; let mut total: f32 = grid.iter().sum(); let target = match (t.props.width_pct, t.props.width) { - (Some(p), _) if p > 0.0 => Some(avail * (p / 100.0).min(1.0)), + // Word keeps a percentage past 100%; the cap only bounds hostile input. + (Some(p), _) if p > 0.0 => Some(avail * (p / 100.0).min(MAX_WIDTH_SHARE)), (_, Some(w)) if w > 0.0 => Some(w), _ => None, }; diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index b669a5a93..046e74254 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -4053,6 +4053,23 @@ fn autofit_handles_spanning_cells_long_words_and_empty_tables() { assert!(x > 72.0 + 200.0, "the second 50% column starts at {x}"); } +#[test] +fn a_percentage_width_past_the_text_column_is_kept() { + // `w:tblW w:type="pct" w:w="6564"` (131.28%) with the left margin as a negative indent: a + // cover-page footer table that spans the page edge to edge. + let mut t = Table::new(1, 2, 234.0); + t.props.width_pct = Some(131.28); + t.props.indent = Some(-72.0); + for (c, s) in t.rows[0].cells.iter_mut().zip(["Left", "Right"]) { + c.blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(s, Default::default()))]; + } + let mut d = Document::from_text("After"); + d.insert_block(StoryRef::Body, &Path::top(0), wordcraft_doc::Block::Table(t)).unwrap(); + let [(x, _, _)] = para_at(&lay(&d), &[0, 0, 1, 0])[..] else { panic!("second cell missing") }; + // Two equal columns of 614.4 pt from x = 0: the second starts past the middle of the page. + assert!(x > 307.0, "the second column starts at {x}"); +} + #[test] fn a_floating_table_in_the_footer_leaves_the_footer_where_it_is() { use wordcraft_doc::para::Anchor; diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 35c993737..6a443d35e 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -1,6 +1,6 @@ # Layout and pagination parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (justified vertical page alignment, #1320; previously trivial: Russian letter list labels, а) б) в); previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (justified vertical page alignment, #1320; previously trivial: a table's percentage width past 100% is kept; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 A document only "opens correctly" if its pages break where Word breaks them: the same lines, the same page count, footnotes and pictures on the same pages. This checklist covers line breaking, @@ -42,7 +42,7 @@ font can't match Word's metrics. | Body top below a tall header, footer pushes body up | ✅ | #138 | — | | Footnotes at the page bottom, endnotes at the end, note numbering | 🟡 | A long note continues onto the next page at a line boundary (its first line stays with the reference), first in that page's note area under a full-width continuation separator (#352); the document's own separator, continuation separator and continuation notice are drawn (the notice under a note that continues; endnotes use the document's separator but don't continue onto a page of their own); each section's note options: footnotes below the text, endnotes at the end of each section, start at, numbering restarted each section or each page (counted from where the notes land, at most three passes) (#385) | 1–2 | | Tables: row heights (at least/exact), rows split across pages, header rows repeat, Can't Split | ✅ | 2026-10-06, #138; every cell in a row gets the row's largest top and bottom margin; an exact height covers the top border, top margin and text, the bottom margin comes on top, and an exact row never splits; a row split across pages keeps its cells' bottom margin, border and last space after on the first page, widow/orphan control applies in its cells, each cell continues at the top of the next page and an at-least row keeps its minimum (`layout::tests::a_row_shares_its_largest_top_and_bottom_margins`, `…_a_split_row_keeps_its_bottom_margin_and_widow_control`, `…_split_row_cells_each_continue_at_the_top`) | — | -| Tables: autofit to contents/window, fixed widths, Word 2013 edge | 🟡 | #137; #44: AutoFit Contents measures each column's narrowest and widest text and shares the width like Word, once (not live as you type); cell preferred widths and spans still differ | 3–5 | +| Tables: autofit to contents/window, fixed widths, Word 2013 edge | 🟡 | #137; #44: AutoFit Contents measures each column's narrowest and widest text and shares the width like Word, once (not live as you type); a percentage width past 100% is kept, as Word does; cell preferred widths and spans still differ | 3–5 | | Floating tables (`w:tblpPr`) | 🟡 | #137; one placed on the page or margin that would cover the text before it and doesn't fit below it moves to the next page (#534); overlap rules untested | 2–3 | | Nested tables | 🟡 | render; polish missing | 2–4 | | Floating pictures/shapes placement (relative to page/margin/column/paragraph/line/character) | ✅ | #136 | — | @@ -164,6 +164,7 @@ No overall parity estimate changes from these probes. | 2026-10-13 | trivial | PAGEREF in headers and footers shows its bookmark's page (#1477) | | 2026-10-14 | trivial | A word too wide for the strip beside a square-wrapped object moves past it whole instead of being split across the strip (#1376) | | 2026-10-14 | trivial | Russian letter list formats (`russianLower`, `russianUpper`) draw а) б) в) … аа) бб) as Word does, instead of digits | +| 2026-10-14 | trivial | A table's percentage width past 100% is kept, so a table pulled into the margins spans them as in Word | | 2026-10-13 | trivial | A shape or picture dragged into another column anchors there, so the text in that column wraps around it (#884) | | 2026-10-13 | trivial | On a page with footnotes, a paragraph that ends there keeps its space after above the footnote separator; if it doesn't fit, its last line (and, with widow control, one more) goes to the next page with the note. Pages without notes are unchanged (#1116) | | 2026-10-12 | trivial | STYLEREF fields in headers and footers show each page's chapter (or other styled text); Insert › Quick Parts › Field offers STYLEREF (#775) | From ba1e28382dd18eddcf6d05def421eed7e2640591 Mon Sep 17 00:00:00 2001 From: Fin Warman Date: Thu, 15 Oct 2026 00:38:36 +0100 Subject: [PATCH 016/101] Layout: page numbers can restart at 0 (#1601) w:pgNumType w:start="0" was read but laid out as 1: the page counter was set to one less than the restart and could not go below zero. A report numbering its cover 0 read "Page 2" on the page Word calls 1. The restart is now the next page's number itself. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/layout/src/lib.rs | 10 +++++++--- crates/layout/src/tests.rs | 9 +++++++++ docs/layout-parity.md | 3 ++- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index dcfce9cf3..6d2db80f8 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -1400,6 +1400,9 @@ struct PageBuilder<'a> { /// The previous paragraph ended with a page break (its mark stayed with the break): the next /// paragraph, first on the new page, gets no space before (Word). after_break_mark: bool, + /// The number the next page takes instead of counting on: a section restarting its + /// numbering, possibly at 0 (a cover page numbered 0). + restart_at: Option, /// Wrap areas of floating objects on this page (see [`wrap_area`]). excl: Vec, /// Wrap areas of the floating tables on this page, which others may not overlap. @@ -1815,7 +1818,7 @@ impl PageBuilder<'_> { self.line_no = 0; } let s = self.sect; - self.number = self.number.saturating_add(1); + self.number = self.restart_at.take().unwrap_or_else(|| self.number.saturating_add(1)); let (w, h) = if self.web { (s.page_w, f32::MAX / 4.0) } else { (s.page_w, s.page_h) }; // A footer taller than the bottom margin pushes the body up (Word: no gap), leaving it at // least a little room. @@ -2045,6 +2048,7 @@ fn layout_pass( orig_bottom: 0.0, prev: None, after_break_mark: false, + restart_at: None, excl: Vec::new(), float_tables: Vec::new(), line_no: 0, @@ -2137,8 +2141,8 @@ fn layout_pass( pb.sect = sect; pb.sect_idx = si; } - if let Some(n) = restart { - pb.number = n.saturating_sub(1); + if restart.is_some() { + pb.restart_at = restart; } pb.foot_top = first_foot; pb.new_page(block, first_top); diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 046e74254..d851f817f 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -688,6 +688,15 @@ fn page_break_char_starts_new_page() { assert_eq!(l.pages.len(), 2); } +#[test] +fn page_numbers_can_restart_at_zero() { + // `w:pgNumType w:start="0"`: a cover page numbered 0, so the page after it reads 1. + let mut d = Document::from_text("Cover\u{000C}Contents"); + d.last_section.page_num_start = Some(0); + let numbers: Vec = lay(&d).pages.iter().map(|p| p.number).collect(); + assert_eq!(numbers, [0, 1]); +} + #[test] fn center_and_right_alignment() { let mut d = Document::from_text("abc\nabc\nabc"); diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 6a443d35e..901287712 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -1,6 +1,6 @@ # Layout and pagination parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (justified vertical page alignment, #1320; previously trivial: a table's percentage width past 100% is kept; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 A document only "opens correctly" if its pages break where Word breaks them: the same lines, the same page count, footnotes and pictures on the same pages. This checklist covers line breaking, @@ -165,6 +165,7 @@ No overall parity estimate changes from these probes. | 2026-10-14 | trivial | A word too wide for the strip beside a square-wrapped object moves past it whole instead of being split across the strip (#1376) | | 2026-10-14 | trivial | Russian letter list formats (`russianLower`, `russianUpper`) draw а) б) в) … аа) бб) as Word does, instead of digits | | 2026-10-14 | trivial | A table's percentage width past 100% is kept, so a table pulled into the margins spans them as in Word | +| 2026-10-14 | trivial | A section can restart its page numbers at 0 (`w:pgNumType w:start="0"`), as a cover page numbered 0 does; the restart was read but laid out from 1 | | 2026-10-13 | trivial | A shape or picture dragged into another column anchors there, so the text in that column wraps around it (#884) | | 2026-10-13 | trivial | On a page with footnotes, a paragraph that ends there keeps its space after above the footnote separator; if it doesn't fit, its last line (and, with widow control, one more) goes to the next page with the note. Pages without notes are unchanged (#1116) | | 2026-10-12 | trivial | STYLEREF fields in headers and footers show each page's chapter (or other styled text); Insert › Quick Parts › Field offers STYLEREF (#775) | From b70ab1a6e7c415a602081b2a0cd8d72f8c78404a Mon Sep 17 00:00:00 2001 From: Alik <86338858+alik654zx@users.noreply.github.com> Date: Thu, 15 Oct 2026 04:38:54 +0500 Subject: [PATCH 017/101] Save Changes prompt takes the keyboard from the page (#1597) (#1602) Save got focus only when nothing else had it, but the page keeps focus while typing and claims Tab and the arrow keys, so the prompt opened without a focused button and the keys moved the caret behind it. Save now takes the focus whenever none of the prompt's buttons has it. Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 ++- crates/ui-egui/src/dialogs.rs | 6 +++-- crates/ui-egui/src/lib.rs | 47 +++++++++++++++++++++++++++++++++++ docs/ui-parity.md | 3 ++- 4 files changed, 56 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a4a17990..1b9705a9f 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-14 · **Change:** trivial (progress log: justified vertical page alignment, #1320; previously trivial: progress log: Russian letter list labels; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; 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-14 · **Change:** trivial (progress log: justified vertical page alignment, #1320; previously trivial: progress log: the save prompt takes the keyboard from the page, #1597; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; 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 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -162,6 +162,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-14 | Text wrap (#1376): in the narrow strip beside a square-wrapped picture, a word that doesn't fit moves on whole to the next row; it used to be split across the strip ("docu" / "mentazione") | | 2026-10-14 | DOCX files with legacy VML preset shapes show them (#1377): Word's right arrow (`_x0000_t13`) and the other presets appear with their fill, outline and size, drawn as WordCraft draws the same DrawingML preset (the nearest shape when it has no exact one), and saving keeps them as DrawingML shapes; before, they were dropped | | 2026-10-14 | Lists numbered with Russian letters (`russianLower`, `russianUpper`) show а), б), в) … я), then аа), бб), as in Word, instead of 1), 2), 3); the 29-letter sequence (no ё, й, ъ or ь) was observed in Word | +| 2026-10-14 | "Do you want to save changes?" answers from the keyboard after typing too (#1597): Save takes focus from the page when the prompt opens, so the arrow keys and Tab move between Save, Don't Save and Cancel instead of the caret behind it, and Enter answers | | 2026-10-12 | Text boxes from older Word files (VML) keep their internal margins, Resize shape to fit text and vertical alignment, so letterhead address blocks and form headers sit where Word puts them instead of 7 pt in and 4 pt down. Hidden drawings stay hidden (they were drawn as empty boxes) and are kept on save | | 2026-10-13 | Word's privacy options are kept and honoured when saving .docx (#1062): with Remove personal information, comments and tracked changes are saved by "Author" and the file has no creator or last-modified-by; with Remove date and time, they are saved without dates. The open document keeps the real names and dates; `file.privacyOptions` sets both | | 2026-10-13 | A selected comment balloon's Delete, Reply and Resolve buttons work again (#1085): pressing them no longer counts as a click on the page that closes the balloon first, so a new empty comment, or any other, can be deleted from its balloon | @@ -397,6 +398,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-14 | trivial | Progress log: words beside a floating picture are no longer split (#1376) | | 2026-10-14 | trivial | Progress log: VML preset shapes (#1377) | | 2026-10-14 | trivial | Progress log: Russian letter list labels | +| 2026-10-14 | trivial | Progress log: the save prompt takes the keyboard from the page (#1597) | | 2026-10-12 | trivial | Progress log: VML text box margins, fit and alignment; hidden drawings | | 2026-10-13 | trivial | Progress log: comment balloon buttons work, empty comments can be deleted (#1085) | | 2026-10-12 | trivial | Progress log: sample-text formulas on Enter (#1067) | diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index c03a2d5d7..a5a686bba 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2717,8 +2717,10 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { let buttons = buttons.inner; // The keyboard alone answers, as in Word (#861): Save has focus when the prompt opens, // so Enter saves, and the arrow keys (egui's focus navigation) or Tab move between the - // buttons, shown as Save, Don't Save, Cancel. - if ui.memory(|m| m.focused().is_none()) + // buttons, shown as Save, Don't Save, Cancel. The focus is taken from whatever held it, + // not only from nothing: the page keeps it while typing and claims Tab and the arrows + // (#1597). + if !buttons.iter().any(|(b, _)| b.has_focus()) && let Some((save, _)) = buttons.get(2) { save.request_focus(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 743f78d1f..1048fc916 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -4167,6 +4167,53 @@ mod tests { } } + /// #1597: the prompt takes the keyboard from the page. The canvas, focused while typing, keeps + /// Tab and the arrows for itself; Save still has focus when the prompt opens over it, and the + /// arrows move between the prompt's buttons instead of the caret. + #[test] + fn save_prompt_takes_focus_from_the_page() { + use egui_kittest::kittest::Queryable; + let dir = scratch("prompt-over-page"); + let path = docx_on_disk(&dir, "Draft"); + let before = std::fs::read(&path).unwrap(); + let mut a = app(); + a.run("file.open", json!({"path": path.to_string_lossy()})).unwrap(); + a.run("text.insert", json!({"text": "Revised "})).unwrap(); + 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, + ); + for _ in 0..6 { + h.step(); + } + assert!(h.state().canvas.focused, "the page has keyboard focus"); + h.state_mut().run("file.close", json!({})).unwrap(); + for _ in 0..3 { + h.step(); + } + assert_eq!(prompt(h.state()), Some("saveChanges")); + let focused = |h: &egui_kittest::Harness, label: &str| h.query_all_by_label(label).any(|n| n.is_focused()); + assert!(focused(&h, "Save"), "Save has focus when the prompt opens"); + let caret = h.state().session.sel.focus.clone(); + h.key_press(egui::Key::ArrowRight); + h.step(); + assert!(focused(&h, "Don't Save"), "the arrow moved to Don't Save"); + assert_eq!(h.state().session.sel.focus, caret, "the caret stayed where it was"); + h.key_press(egui::Key::Enter); + for _ in 0..3 { + h.step(); + } + assert_eq!(prompt(h.state()), None, "answered"); + assert!(h.state().quit_requested, "Don't Save closes"); + assert_eq!(std::fs::read(&path).unwrap(), before, "nothing was saved"); + let _ = std::fs::remove_dir_all(&dir); + } + /// `ui.print` hands the document's PDF to the host's print hook, and is an error (with /// nothing printed) when the host has none. #[test] diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 3d09f7e85..0c8a12111 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-14 · **Change:** trivial (Page Setup vertical alignment, #1320; previously trivial: macOS shortcuts as in Word for Mac; previously trivial: selected Behind Text pictures drag over overlapping text while ordinary clicks still put the caret there; previously trivial: crop handles on the canvas; previously trivial: Bookmark dialog with Delete and Go To, #1236; previously trivial: the table context menu's Delete offers Delete Table with the caret in a cell, #1198; previously trivial: Update Field on a table of contents entry, #1197; previously trivial: Track Changes Options › Comments: show only the selected text's comment, beyond Word, #1055; previously trivial: Recipient List dialog gains Filter and Sort, #778; previously trivial: Ctrl+drag copies a picture, shape or text box, #1014; previously trivial: the mini toolbar scrolls under the ribbon, not over it; previously trivial: the mini toolbar steps aside for the ribbon; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously 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-14 · **Change:** trivial (Page Setup vertical alignment, #1320; previously trivial: the Save Changes prompt takes the keyboard from the page, #1597; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously 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 @@ -62,6 +62,7 @@ Document title opens Rename Document; the account badge opens Options › Genera | 2026-10-12 | trivial | Cross-reference dialog: reference type (heading, bookmark, equation, figure, table), what to insert and the item; the ribbon button opened nothing before (#916); modal dialog count 55 | | 2026-10-12 | trivial | Document title opens Rename Document; the account badge opens Options › General for user name and initials. | | 2026-10-13 | trivial | Magnifiers: a line's end mark (paragraph mark or line break) takes room in what the canvas tells assistive technology, so Windows Magnifier, which places the caret by the character after it, follows the caret onto an empty line after Enter or Shift+Enter instead of staying on the line above (#1119) | +| 2026-10-14 | trivial | Save Changes prompt: Save takes focus from the page too, so after typing the arrow keys, Tab and Enter answer it instead of moving the caret behind it (#1597) | | 2026-10-13 | trivial | The caret is black or white by contrast with the paper, page colour, dark page, shading or highlight under it (#299) | | 2026-10-13 | trivial | Clicks on ribbon and status-bar chrome that run no command button (Styles and style-set gallery tiles, tab names, empty ribbon space, View check boxes, the status bar's view buttons and language menu) no longer take keyboard focus from the page: the caret stays and typing continues, as in Word; text fields such as the font size box still take focus. Tabs, gallery tiles and the status bar's view buttons report their name and state to screen readers (#1093) | | 2026-10-13 | trivial | Interface size: a chosen size is absolute ("100%" is one screen pixel per point, even where the system reports 200 % under a bare window manager) and the new default, Auto, keeps the display scale and shows the size it draws at; 75–300 %; an old saved size becomes the size it drew at; on the web a chosen size cancels browser zoom, Auto follows it (#822) | From da0370c30ba40075861a9082c9388e76152dc4bf Mon Sep 17 00:00:00 2001 From: Luis Vaca Date: Wed, 14 Oct 2026 17:50:04 -0600 Subject: [PATCH 018/101] Comments: deleting a comment leaves no dangling anchors or replies (#1345) (#1565) review.deleteComment removed anchors from the body only, so a comment anchored in a header, footer, note or text box left its CommentStart and CommentEnd behind, pointing at a deleted definition. It also collected only direct replies, so a reply to a reply survived with its parent gone. Anchors are now removed from every story, and the deleted set is the whole thread below the comment. The thread is collected from a parent-to-replies map built once, with a seen set, so a parent cycle from a hostile file ends and a long reply chain doesn't take quadratic time. Undo restores everything. Regression tests: a header comment (anchors checked in the header story), a three-level reply chain (with its text parts and Undo), a parent cycle, and a 50,000-reply chain. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +- crates/engine/src/cmd/review.rs | 136 ++++++++++++++++++++++++++++++-- 2 files changed, 132 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 1b9705a9f..c3bb3276d 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-14 · **Change:** trivial (progress log: justified vertical page alignment, #1320; previously trivial: progress log: the save prompt takes the keyboard from the page, #1597; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; 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-14 · **Change:** trivial (progress log: comment deletion leaves no dangling anchors or replies, #1345; previously trivial: progress log: justified vertical page alignment, #1320; previously trivial: progress log: the save prompt takes the keyboard from the page, #1597; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; 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 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -136,6 +136,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| +| 2026-10-14 | Deleting a comment removes its anchors from headers, footers, notes and text boxes too, not just the body, and deletes the whole thread below it, replies to replies included; no anchor or reply is left pointing at a deleted comment (#1345) | | 2026-10-13 | Justified vertical page alignment (#1320): a section set to Justified spreads each page's paragraphs and tables from the top margin to the bottom one, as Word does, including the last page and pages ending in a manual page break. DOCX `w:vAlign="both"` opens as Justified (it showed centred) and saves back; `layout.verticalAlign` takes `justify`, and Page Setup has a Vertical alignment box | | 2026-10-13 | macOS shortcuts as in Word for Mac: ⌘⇧S Save As, ⌘⌥E Insert Endnote, ⌘⌥A New Comment, ⌃H Replace, ⌘⌥R collapse the ribbon | | 2026-10-13 | The status bar flags a proofing language whose Hunspell dictionary isn't installed (Polish, Portuguese): "Polish (no dictionary)", with a tooltip naming the dictionary and where it is looked up (#1167) | @@ -377,6 +378,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Progress log: deleting a comment leaves no dangling anchors or replies (#1345) | | 2026-10-13 | trivial | Progress log: justified vertical page alignment (#1320) | | 2026-10-14 | trivial | Progress log: file names that aren't valid Unicode (#1440) | | 2026-10-13 | trivial | Behind Text pictures and shapes drag over overlapping text in the body and headers, selected or not, while ordinary clicks keep editing text; In Front of Text drag covered too | diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index 49ea34048..9bda63c02 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -1,5 +1,7 @@ //! Review tab: comments, track changes, word count, spelling (via the proofing hook). +use std::collections::{BTreeMap, BTreeSet}; + use serde_json::{Value, json}; use wordcraft_doc::para::InlineObject; use wordcraft_doc::{Comment, Paragraph, PartKind, Pos, StoryRef, para_block}; @@ -202,8 +204,11 @@ fn comment_at_caret(s: &Session) -> Option { }) } -fn remove_anchors(s: &mut Session, ids: &[u32]) -> Result<(), CmdError> { - for story in [StoryRef::Body] { +/// Removes the comments' anchors from every story: the body, headers, footers, notes and text +/// boxes (#1345). +fn remove_anchors(s: &mut Session, ids: &BTreeSet) -> Result<(), CmdError> { + let stories: Vec = std::iter::once(StoryRef::Body).chain(s.doc.parts.keys().map(|k| StoryRef::Part(*k))).collect(); + for story in stories { for path in s.doc.para_paths(story) { let offs: Vec = s .doc @@ -227,17 +232,37 @@ fn remove_anchors(s: &mut Session, ids: &[u32]) -> Result<(), CmdError> { Ok(()) } +/// The comment and every reply below it, replies to replies included (#1345). A parent cycle +/// from a hostile file ends the walk instead of looping. +fn thread_of(s: &Session, id: u32) -> Vec { + let mut children: BTreeMap> = BTreeMap::new(); + for (k, c) in &s.doc.comments { + if let Some(parent) = c.parent { + children.entry(parent).or_default().push(*k); + } + } + let mut seen = BTreeSet::from([id]); + let mut ids = vec![id]; + let mut i = 0; + while let Some(&at) = ids.get(i) { + for k in children.get(&at).into_iter().flatten() { + if seen.insert(*k) { + ids.push(*k); + } + } + i += 1; + } + ids +} + fn delete_comment(s: &mut Session, v: &Value) -> CmdResult { let ids: Vec = if p::bool(v, "all").unwrap_or(false) { s.doc.comments.keys().copied().collect() } else { let id = p::u64(v, "id").map(|x| x as u32).or_else(|| comment_at_caret(s)).ok_or_else(|| CmdError::Params("no comment here".into()))?; - // The comment and its replies. - let mut v = vec![id]; - v.extend(s.doc.comments.iter().filter(|(_, c)| c.parent == Some(id)).map(|(k, _)| *k)); - v + thread_of(s, id) }; - remove_anchors(s, &ids)?; + remove_anchors(s, &ids.iter().copied().collect())?; for id in &ids { if let Some(c) = s.doc.comments.remove(id) { s.doc.parts.remove(&c.part); @@ -1357,6 +1382,103 @@ mod tests { assert!(s.run("review.changes", &json!({})).unwrap().as_array().unwrap().is_empty()); } + /// Comment anchors left in any story (the body, headers, footers, notes and text boxes), with + /// the story each sits in. + fn anchors(s: &Session) -> Vec<(wordcraft_doc::StoryRef, u32)> { + use wordcraft_doc::StoryRef; + use wordcraft_doc::para::InlineObject; + let stories = std::iter::once(StoryRef::Body).chain(s.doc.parts.keys().map(|k| StoryRef::Part(*k))); + let mut ids = Vec::new(); + for story in stories { + for path in s.doc.para_paths(story) { + let p = s.doc.para(story, &path).unwrap(); + for o in p.object_offsets() { + if let Some(InlineObject::CommentStart { id } | InlineObject::CommentEnd { id }) = p.object_at(o) { + ids.push((story, *id)); + } + } + } + } + ids + } + + /// #1345: deleting a comment anchored in a header removes its anchors there, not just in the + /// body; Undo brings the comment and its anchors back. + #[test] + fn deleting_a_header_comment_removes_its_anchors() { + let mut s = Session::new(Document::new()); + s.view.proofing = false; + s.run("document.setText", &json!({"text": "Body text"})).unwrap(); + let hdr = s.run("insert.header", &json!({"text": "Header text"})).unwrap()["story"].as_u64().unwrap() as u32; + s.run("select.all", &json!({})).unwrap(); + let id = s.run("review.newComment", &json!({"text": "in the header"})).unwrap()["id"].as_u64().unwrap() as u32; + let in_header = vec![(wordcraft_doc::StoryRef::Part(hdr), id); 2]; + assert_eq!(anchors(&s), in_header, "both anchors sit in the header"); + s.run("review.deleteComment", &json!({"id": id})).unwrap(); + assert!(anchors(&s).is_empty(), "no anchor points at the deleted comment"); + assert!(s.doc.comments.is_empty()); + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(anchors(&s), in_header); + assert!(s.doc.comments.contains_key(&id)); + } + + /// #1345: deleting a comment deletes the whole thread below it, replies to replies included, + /// with their text parts; Undo restores all of them. + #[test] + fn deleting_a_comment_deletes_replies_to_replies() { + let mut s = Session::new(Document::new()); + s.view.proofing = false; + s.run("document.setText", &json!({"text": "hello world"})).unwrap(); + s.run("select.range", &json!({"anchor": {"block": 0, "off": 0}, "focus": {"block": 0, "off": 5}})).unwrap(); + let root = s.run("review.newComment", &json!({"text": "root"})).unwrap()["id"].as_u64().unwrap(); + let reply = s.run("review.reply", &json!({"id": root, "text": "reply"})).unwrap()["id"].as_u64().unwrap(); + let nested = s.run("review.reply", &json!({"id": reply, "text": "reply to reply"})).unwrap()["id"].as_u64().unwrap(); + let parts = |s: &Session| s.doc.comments.values().map(|c| c.part).collect::>(); + let before = parts(&s); + let deleted = s.run("review.deleteComment", &json!({"id": root})).unwrap(); + assert_eq!(deleted["deleted"], json!([root, reply, nested])); + assert!(s.doc.comments.is_empty(), "no reply is left without its parent: {:?}", s.doc.comments); + assert!(before.iter().all(|p| !s.doc.parts.contains_key(p)), "the comments' text parts are gone"); + assert!(anchors(&s).is_empty()); + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc.comments.keys().map(|k| u64::from(*k)).collect::>(), vec![root, reply, nested]); + assert_eq!(parts(&s), before); + assert_eq!(anchors(&s).len(), 2); + } + + /// A parent cycle (possible in a hostile file) doesn't hang Delete; both comments go. + #[test] + fn deleting_a_comment_in_a_parent_cycle_ends() { + let mut s = Session::new(Document::new()); + s.view.proofing = false; + s.run("document.setText", &json!({"text": "hello"})).unwrap(); + s.run("select.all", &json!({})).unwrap(); + let a = s.run("review.newComment", &json!({"text": "a"})).unwrap()["id"].as_u64().unwrap() as u32; + let b = s.run("review.reply", &json!({"id": a, "text": "b"})).unwrap()["id"].as_u64().unwrap() as u32; + s.doc.comments.get_mut(&a).unwrap().parent = Some(b); + s.run("review.deleteComment", &json!({"id": a})).unwrap(); + assert!(s.doc.comments.is_empty()); + } + + /// A very long reply chain (as a hostile file could hold) is deleted in one pass over the + /// comments, not one pass per reply: 50,000 replies would otherwise freeze the app. + #[test] + fn deleting_a_long_reply_chain_stays_fast() { + let mut s = Session::new(Document::new()); + s.view.proofing = false; + s.run("document.setText", &json!({"text": "hello"})).unwrap(); + s.run("select.all", &json!({})).unwrap(); + let root = s.run("review.newComment", &json!({"text": "root"})).unwrap()["id"].as_u64().unwrap() as u32; + let template = s.doc.comments.get(&root).unwrap().clone(); + for k in 1..=50_000u32 { + s.doc.comments.insert(root + k, wordcraft_doc::Comment { parent: Some(root + k - 1), ..template.clone() }); + } + let started = std::time::Instant::now(); + s.run("review.deleteComment", &json!({"id": root})).unwrap(); + assert!(s.doc.comments.is_empty()); + assert!(started.elapsed() < std::time::Duration::from_secs(5), "took {:?}", started.elapsed()); + } + #[test] fn rtf_tracked_changes_accept_and_reject_after_opening_and_round_trip() { // Gap 19: an RTF file's tracked insertions and deletions open as tracked changes, also From 2c5249d13ec3ba31c900b5c47d8acb5b29e2cf02 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Thu, 15 Oct 2026 01:50:23 +0200 Subject: [PATCH 019/101] =?UTF-8?q?All=20Caps=20draws=20=C3=9F=20as=20SS,?= =?UTF-8?q?=20as=20Word=20does=20(#1581)=20(#1583)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * #1581: WIP * #1581: All Caps and Small Caps draw ß as SS, one character for the caret Co-Authored-By: Claude Opus 5.5 * #1581: exact caret tests, small-caps scaling, symbol runs never expand Co-Authored-By: Claude Opus 5.5 * Docs after rebasing on main 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 --- ROADMAP.md | 4 +- crates/layout/src/display.rs | 6 ++- crates/layout/src/para.rs | 43 +++++++++++++++---- crates/layout/src/tests.rs | 82 ++++++++++++++++++++++++++++++++++++ crates/pdf/src/tests.rs | 5 +-- docs/layout-parity.md | 4 +- 6 files changed, 129 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c3bb3276d..eee60f234 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-14 · **Change:** trivial (progress log: comment deletion leaves no dangling anchors or replies, #1345; previously trivial: progress log: justified vertical page alignment, #1320; previously trivial: progress log: the save prompt takes the keyboard from the page, #1597; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; 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-14 · **Change:** trivial (progress log: comment deletion leaves no dangling anchors or replies, #1345; previously trivial: progress log: All Caps draws ß as SS, #1581; previously trivial: progress log: justified vertical page alignment, #1320; previously trivial: progress log: the save prompt takes the keyboard from the page, #1597; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; 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 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -157,6 +157,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | Rounded rectangles (#1476): a `roundRect` keeps the corner radius its file gives it (`adj`), on screen, in PDF and when saved; all were drawn with a 16% corner | | 2026-10-13 | PAGEREF in headers and footers (#1477): "Page X of Y" with Y a page reference to a bookmark at each letter's end shows each letter's own total instead of the result stored in the file | | 2026-10-13 | Replace All respects locked content controls with Track Changes on or off; it refuses the entire batch before editing when any match would change locked content or remove a control locked against deletion | +| 2026-10-14 | All Caps and Small Caps draw ß as SS, as Word does (#1581): "Straße" in All Caps reads STRASSE, not STRASE, and other letters whose capital is two characters (ff, ʼn) draw in full. The ß is still one character: the caret never stops between the two S, and a click on them lands before or after it | | 2026-10-13 | Windows Magnifier follows the caret onto a new line: after Enter or Shift+Enter the view moves to the empty line at once, instead of staying on the line above until two more letters are typed (#1119) | | 2026-10-13 | A window too big for its screen shrinks to fit when it opens (#1215): on a laptop screen the first window reached under the taskbar or past the screen edge, with the end of the scroll bar, so dragging the scroll bar stopped where the pointer met the edge. A window saved that size is fitted on its next launch too | | 2026-10-13 | `wordcraft --control` checks its port (#1426): a missing port, one outside 1–65535 or one that isn't a number prints an error naming it and exits with status 2, instead of starting without the control channel (and dropping `WORDCRAFT_CONTROL_PORT`); a flag right after it, such as `--sample` or `--version`, is read as a flag rather than as the port | @@ -394,6 +395,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | trivial | Progress log: rounded rectangle corners (#1476) | | 2026-10-13 | trivial | Progress log: PAGEREF in headers and footers (#1477) | | 2026-10-13 | trivial | Replace All checks content-control locks before editing; a refused batch leaves the document and undo history unchanged; existing deletion-lock rules reused, with tracked/untracked regression coverage | +| 2026-10-14 | trivial | Progress log: All Caps and Small Caps draw ß as SS (#1581) | | 2026-10-13 | trivial | Progress log: Windows Magnifier follows the caret onto a new line (#1119) | | 2026-10-13 | trivial | Progress log: a window too big for its screen shrinks to fit (#1215) | | 2026-10-13 | trivial | Progress log: `--control` checks its port (#1426) | diff --git a/crates/layout/src/display.rs b/crates/layout/src/display.rs index 70843cf0e..e271c7fab 100644 --- a/crates/layout/src/display.rs +++ b/crates/layout/src/display.rs @@ -750,8 +750,10 @@ fn lines( text.push_str(&wordcraft_doc::symbol_fonts::map_text(&st.rc.font, t)); } else if st.rc.caps || st.rc.small_caps { // All Caps and Small Caps read as the capitals drawn (#1473), mapped - // as the shaper maps them, so a glyph never stands for two cases. - text.extend(t.chars().map(|ch| crate::para::caps_char(&st.rc, ch))); + // as drawn, so a glyph never stands for two cases and ß reads as SS (#1581). + for ch in t.chars() { + crate::para::push_caps(&st.rc, ch, &mut text); + } } else { text.push_str(t); } diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index e2e9845f6..1d2b0df07 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -527,7 +527,8 @@ impl SegKey { } } -/// The capital All Caps and Small Caps draw for `c`: one character in, one out (ß draws as S). +/// The first character of `c`'s capital (ß → S): the shaper's one-to-one mapping, and the +/// text read back for labels and field results, whose byte ranges can't grow. fn upper_char(c: char) -> char { c.to_uppercase().next().unwrap_or(c) } @@ -539,6 +540,18 @@ pub(crate) fn caps_char(rc: &ResolvedChar, c: char) -> char { if rc.caps || (rc.small_caps && c.is_lowercase()) { upper_char(c) } else { c } } +/// Appends the text drawn for `c` in a run formatted `rc`: like [`caps_char`], but with a +/// letter's full capital (ß → SS), as body text draws it (#1581). +pub(crate) fn push_caps(rc: &ResolvedChar, c: char, out: &mut String) { + if rc.caps || (rc.small_caps && c.is_lowercase()) { out.extend(c.to_uppercase()) } else { out.push(c) } +} + +/// Whether `c`'s capital is more than one character (ß → SS), so All Caps draws more +/// characters than the document holds. +fn caps_expands(c: char) -> bool { + c.to_uppercase().nth(1).is_some() +} + /// Combining marks and joiners (bidi classes NSM, BN: harakat, ZWNJ, ZWJ) belong to the /// character before them: same font, same shaping run, so Arabic joining isn't broken. fn is_mark(c: char) -> bool { @@ -822,19 +835,28 @@ 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); + // All Caps and Small Caps draw a letter's full capital, which can be several + // characters (ß → SS, ff → FF, ʼn → ʼN), as Word does (#1581). A symbol-font run + // draws its codes as symbols, never as expanded capitals. + let expands = sym.is_none() && upper && sub.chars().any(caps_expands); + // Displayed text: numeral substitution is 1 char → 1 char and a capital may be + // several; `map_starts` holds where each document char's displayed form starts and + // `orig_of` maps it back to the char's original byte offset (+ a `sub.len()` sentinel). + let substituted = expands || 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()); + mapped = String::with_capacity(sub.len().saturating_add(8)); 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)); + let d = self.numeral_char(&context, a + i, c, key.rtl); + if expands { + mapped.extend(d.to_uppercase()); + } else { + mapped.push(d); + } } orig_of.push(sub.len()); } @@ -861,7 +883,12 @@ impl<'a> Builder<'a> { // 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(shape_text); + let mut bounds: Vec = wordcraft_doc::cluster::cluster_starts(shape_text); + if expands { + // The capitals drawn for one character (the two S of ß) are one cluster: the + // caret, selection and line breaking never stop inside the character. + bounds.retain(|b| map_starts.binary_search(b).is_ok()); + } let mut gi = 0usize; for (bi, &gs) in bounds.iter().enumerate() { let ge = bounds.get(bi + 1).copied().unwrap_or(shape_text.len()); diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index d851f817f..3b7abd4ba 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -6528,6 +6528,88 @@ fn paragraph_own_float_is_not_replaced_when_its_rest_wraps_in_the_next_column() assert_clear_of(&line_boxes(&l, 0, 1, col2), r, "column 2"); } +/// "Straße" laid out with `f` applied to all of it: its first line's layout. +fn strasse_with(f: &dyn Fn(&mut wordcraft_doc::props::CharProps)) -> ParaLayout { + let mut d = Document::from_text("Straße"); + d.format_range(&Pos::body(0, 0), &Pos::body(0, "Straße".len()), f).unwrap(); + let l = lay(&d); + l.pages[0].items.iter().find_map(|it| if let Placed::Lines { para, .. } = it { Some((**para).clone()) } else { None }).expect("a laid-out line") +} + +/// Text clusters of the first line: (start, end, glyph ids, left x, advance). +fn text_clusters(pl: &ParaLayout) -> Vec<(usize, usize, Vec, f32, f32)> { + let line = &pl.lines[0]; + (line.c0..line.c1) + .filter(|&k| pl.clusters[k].kind == para::ClKind::Text) + .map(|k| { + let c = &pl.clusters[k]; + let gids = pl.glyphs[c.g0 as usize..c.g1 as usize].iter().map(|g| g.gid).collect(); + (c.start, c.end, gids, line.cl_left(k).unwrap(), c.adv) + }) + .collect() +} + +/// All Caps and Small Caps draw ß as SS, as Word does, and the ß stays one character for the +/// caret, selection and hit testing (#1581). +#[test] +fn caps_draw_sharp_s_as_two_capitals_in_one_cluster() { + let width = |pl: &ParaLayout| text_clusters(pl).iter().map(|c| c.4).sum::(); + let plain_strase = { + let d = Document::from_text("STRASE"); + let l = lay(&d); + let pl = l.pages[0].items.iter().find_map(|it| if let Placed::Lines { para, .. } = it { Some((**para).clone()) } else { None }).unwrap(); + width(&pl) + }; + let caps: &dyn Fn(&mut wordcraft_doc::props::CharProps) = &|c| c.caps = Some(true); + let small: &dyn Fn(&mut wordcraft_doc::props::CharProps) = &|c| c.small_caps = Some(true); + for (name, f) in [("All Caps", caps), ("Small Caps", small)] { + let pl = strasse_with(f); + let cl = text_clusters(&pl); + let glyphs: usize = cl.iter().map(|c| c.2.len()).sum(); + assert_eq!(glyphs, 7, "{name}: S T R A S S E is seven glyphs: {cl:?}"); + assert_eq!(cl.len(), 6, "{name}: six source characters, six clusters: {cl:?}"); + let ss = cl.iter().find(|c| c.0 == 4).expect("a cluster for ß"); + assert_eq!(ss.1, 6, "{name}: the ß cluster covers exactly the ß"); + // Both glyphs drawn for ß are the S that starts the word. + let s_gid = cl.first().and_then(|c| c.2.first().copied()); + assert_eq!(ss.2.len(), 2, "{name}: ß draws two glyphs"); + assert!(ss.2.iter().all(|g| Some(*g) == s_gid), "{name}: both glyphs are S"); + if name == "All Caps" { + assert!(width(&pl) > plain_strase + 1.0, "All Caps: STRASSE is wider than STRASE: {} vs {plain_strase}", width(&pl)); + } + // Caret stops: one before each source character, and the end; none inside ß. + assert_eq!(pl.line_offsets(0), vec![0, 1, 2, 3, 4, 6, 7], "{name}"); + // A click on the first S lands before the ß, on the second S after it. + assert_eq!(pl.off_at_x(0, ss.3 + ss.4 * 0.25), 4, "{name}: click on the first S"); + assert_eq!(pl.off_at_x(0, ss.3 + ss.4 * 0.75), 6, "{name}: click on the second S"); + if name == "Small Caps" { + // The ß is a lowercase letter: drawn at the small-caps size, like the "t", not + // at the size of the capital S that starts the word. + let style = |off: usize| pl.clusters.iter().find(|c| c.start == off).map(|c| c.style); + assert_eq!(style(4), style(1), "Small Caps: ß is scaled like t"); + assert_ne!(style(4), style(0), "Small Caps: ß is not the size of the first S"); + } + } + // Without caps, ß is one glyph of its own. + let pl = strasse_with(&|c| c.bold = Some(false)); + let cl = text_clusters(&pl); + assert_eq!(cl.len(), 6); + assert_eq!(cl.iter().map(|c| c.2.len()).sum::(), 6, "{cl:?}"); +} + +/// A caps run in a symbol font draws each code as its symbol: ß never becomes two glyphs (#1581). +#[test] +fn caps_in_a_symbol_font_never_expand_characters() { + let pl = strasse_with(&|c| { + c.caps = Some(true); + c.font = Some("Wingdings".into()); + }); + let cl = text_clusters(&pl); + assert_eq!(cl.len(), 6, "{cl:?}"); + let ss = cl.iter().find(|c| c.0 == 4).expect("a cluster for the ß code"); + assert_eq!((ss.1, ss.2.len()), (6, 1), "the ß code is one symbol: {cl:?}"); +} + /// #1320: a section's vertical alignment (`w:vAlign`, ECMA-376 §17.6.23). Justified shares the /// room left on each page out equally between the page's blocks, so the first line stays at the /// top margin and the last one sits on the bottom margin (Word for Mac 16.113.4): a partly filled diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 705334970..8e01a0f15 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -1108,9 +1108,8 @@ fn all_caps_and_small_caps_text_extracts_as_displayed() { ] { assert!(text.contains(line), "missing {line:?} in {text:?}"); } - // An All Caps ß is drawn today as one S glyph, so it reads as that S: the text layer follows - // the glyph drawn. Drawing it as "SS", as Word does, is a separate display issue. - assert!(text.contains("STRASE"), "{text:?}"); + // An All Caps ß is drawn as SS, as Word does (#1581), and reads as the SS drawn. + assert!(text.contains("STRASSE"), "{text:?}"); } #[test] diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 901287712..750b3a3de 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -1,6 +1,6 @@ # Layout and pagination parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 A document only "opens correctly" if its pages break where Word breaks them: the same lines, the same page count, footnotes and pictures on the same pages. This checklist covers line breaking, @@ -34,6 +34,7 @@ font can't match Word's metrics. | Tabs (left, center, right, decimal, bar), leaders | 🟡 | Bar tabs untested | 1–2 | | Hyphenation (automatic, zone from the document, words in capitals, soft hyphens, consecutive limit, Manual Hyphenation, #407) | 🟡 | English patterns only; other languages hyphenate wrongly or not at all | in localization | | Drop caps | ✅ | 2026-10-06 | — | +| All Caps and Small Caps: the full capital of each letter, so ß draws as SS (ff as FF); the capitals drawn for one character are one cluster, so the caret, selection, hit testing and line breaking treat ß as one character | ✅ | `layout::tests::caps_draw_sharp_s_as_two_capitals_in_one_cluster` (#1581). The PDF text layer still reads the document's lowercase letters until #1473 | — | | Columns, column breaks, separators, balancing | ✅ | Columns balance on a section's last page when the next section starts continuous (also at the document end, which Word only balances when the document ends with a continuous break); not after next-page/even/odd breaks or on a page with a column break; keep-together, keep-with-next, widow/orphan control and unsplittable rows can leave them slightly uneven (#350) | — | | Section breaks (next page, continuous, even, odd), different first page, odd/even headers | ✅ | Section breaks don't show in Draft view (#42). A break inserted with text selected goes before the selection and keeps it, as in Word (#852) | 1–2 | | List labels: number formats (decimal, letters, Russian letters, roman, ordinal text, Chinese/Japanese/Korean counting and legal, 一〇一 digits, 甲乙丙, ①…⑳, full-width), `%n` text such as `第%1章` / `(%1)`, legal numbering | ✅ | `doc::cjk_numbers` tests, `doc::section::tests::russian_letters_follow_word`, `layout::tests::chinese_counting_list_labels_and_their_font_runs`, `layout::tests::russian_letter_list_labels_draw_cyrillic`; a label mixing faces (digit + CJK comma) is drawn run by run (#534); Russian letters skip ё, й, ъ and ь and then double (аа, бб), as observed in Word. Hebrew, Arabic, Hindi, Thai and Vietnamese formats show decimal The label stays attached to the start of the text when the paragraph is centred or aligned to the end, as in Word (#1159). | — | @@ -162,6 +163,7 @@ No overall parity estimate changes from these probes. | 2026-10-13 | trivial | Floats and floating tables aligned inside/outside, or placed in the inside/outside margin, swap sides on even pages; with mirrored margins, page-relative floats keep their place and left/right margin areas are each page's own (#1181) | | 2026-10-14 | trivial | Widow control: a three-line paragraph with room for two of its lines at the bottom of a page moves whole, as in Word (no two-and-one split leaving a widow); a paragraph continued from the previous page may still take one line along from two | | 2026-10-13 | trivial | PAGEREF in headers and footers shows its bookmark's page (#1477) | +| 2026-10-14 | trivial | All Caps and Small Caps draw ß as SS, as Word does, kept one character for the caret and selection (#1581) | | 2026-10-14 | trivial | A word too wide for the strip beside a square-wrapped object moves past it whole instead of being split across the strip (#1376) | | 2026-10-14 | trivial | Russian letter list formats (`russianLower`, `russianUpper`) draw а) б) в) … аа) бб) as Word does, instead of digits | | 2026-10-14 | trivial | A table's percentage width past 100% is kept, so a table pulled into the margins spans them as in Word | From a3ec498f2a2631f3ecbb48dab64a871a7d5f5bae Mon Sep 17 00:00:00 2001 From: Luigi Pantuosco Date: Thu, 15 Oct 2026 01:50:35 +0200 Subject: [PATCH 020/101] =?UTF-8?q?Italian=20proofing:=20po',=20qual=20?= =?UTF-8?q?=C3=A8,=20=C3=88=20and=20un/un'=20grammar=20rules;=20more=20eli?= =?UTF-8?q?sions=20in=20spelling=20(#1585)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Italian proofing: po', qual è, È and un/un' grammar rules; more elisions in spelling - grammar (works without a dictionary): un pò / un po -> un po'; qual'è -> qual è; E' -> È; un'altro -> un altro and un altra -> un'altra (also nessun, ciascun, qualcun, alcun), gender from the ending with masculine -ista/-ma/-cida... left alone; more emphatic doubles allowed (pian piano, poco poco, così così...) - spelling: more elisions (dov'è, cos'è, anch'io, senz'altro, nessun'altra, n'ha...), truncations po'/mo'/be' accepted, pò and qual'è left to grammar (no double marks) - tests: rule tests, grammar proptests, Italian suggestion proptest, It in mixed-span proptest Co-Authored-By: Claude Opus 5.5 * Contributors: credit lupant by their GitHub profile name Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- contributors/people.toml | 4 +- crates/proof/src/it/grammar.rs | 212 ++++++++++++++++++++++++++++++- crates/proof/src/it/mod.rs | 49 ++++++- crates/proof/src/it/test_dict.rs | 3 +- crates/proof/src/lib.rs | 9 +- docs/localization-parity.md | 7 +- 6 files changed, 269 insertions(+), 15 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 8f358dca8..816997012 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -84,8 +84,10 @@ display_name = "cyril265" [people.webdevtodayjason] display_name = "Jason Brashear" sync_github_name = false -[people.Nael-Nathanael] + +[people.lupant] sync_github_name = true +[people.Nael-Nathanael] [people.Losminek] diff --git a/crates/proof/src/it/grammar.rs b/crates/proof/src/it/grammar.rs index 112b0c2a0..b6b983fc4 100644 --- a/crates/proof/src/it/grammar.rs +++ b/crates/proof/src/it/grammar.rs @@ -9,6 +9,14 @@ //! - **Capital letter at the start of a sentence**, knowing Italian abbreviations (`ecc.`, //! `dott.`, `prof.`, `sig.`), initials, ordinal numbers (`1. posto`) and ellipses. //! - **Doubled punctuation** (`,,`, `..`, `;;`). +//! - **`po'`**: `un pò` and `un po` → `un po'` (a truncation of `poco`: apostrophe, no accent). +//! - **`qual è`**: `qual'è`, `qual'era` → `qual è`, `qual era` (a truncation, never elided). +//! - **`È`**: `E'` written for the verb → `È`. +//! - **`un` / `un'`**: the apostrophe only before a feminine word. `un'altro`, `un'anno` → +//! `un altro`, `un anno` (a masculine word in `-o`; `auto` and `eco` are feminine), and +//! `un altra`, `un amica` → `un'altra`, `un'amica` (a word in `-a`, except the masculine and +//! common-gender ones: `-ista`, `-ita`, `-ota`, `-cida`, `-ma`, `-eta`, `atleta`, `acrobata`…). +//! The same for `nessun`, `ciascun`, `qualcun` and `alcun`. //! //! No comma rule and no English rule (such as `a`/`an`) applies to Italian text. @@ -17,9 +25,22 @@ use crate::{Issue, IssueKind, ProofLang, words}; /// Words Italian repeats on purpose: emphatic doubling, interjections, onomatopoeia. const ALLOWED_REPEATS: &[&str] = &[ "sì", "no", "piano", "bene", "male", "ecco", "oh", "ah", "eh", "mh", "bah", "ehi", "urrà", "evviva", "ciao", "addio", "adagio", "forte", "lento", - "chiaro", + "chiaro", "pian", "poco", "quasi", "presto", "subito", "zitto", "via", "così", "tanto", "molto", "pure", "dai", "su", "avanti", "basta", "aiuto", ]; +/// Indefinite articles (and the like) that elide to `un'` before a feminine word only. +const UN_FORMS: &[&str] = &["un", "nessun", "ciascun", "qualcun", "alcun"]; + +/// Feminine words in `-o` that start with a vowel (`un'auto`, `un'eco`). +const FEMININE_IN_O: &[&str] = &["auto", "autoradio", "eco", "ecografo"]; + +/// Endings of masculine or common-gender words in `-a` (`un artista`, `un idiota`, `un enigma`). +const MASCULINE_A_ENDINGS: &[&str] = &["ista", "ita", "ota", "cida", "ma", "eta", "nauta", "iatra", "arca"]; + +/// Words in `-a` after `un` that aren't feminine nouns: masculine or common-gender ones that fit +/// no ending above, and verbs and pronouns (`un ha`, `un una`). +const NOT_FEMININE_IN_A: &[&str] = &["acrobata", "autodidatta", "alfa", "omega", "ha", "una"]; + /// Abbreviations written with a final period after which a sentence goes on in lower case /// (lower case, without the period). const ABBREVIATIONS: &[&str] = &[ @@ -210,6 +231,100 @@ fn doubled_punctuation(text: &str, v: &mut Vec) { } } +/// Is `c` an apostrophe (straight or typographic)? +fn is_apostrophe(c: char) -> bool { + matches!(c, '\'' | '’') +} + +/// `pò` (never accented) and `po` without its apostrophe → `po'`. `Po` (the river) is left alone. +fn po(text: &str, ws: &[(usize, usize)], v: &mut Vec) { + for (a, b) in ws { + let Some(w) = text.get(*a..*b) else { continue }; + let apostrophe = text.get(*b..).and_then(|r| r.chars().next()).is_some_and(is_apostrophe); + let fix = match w { + "pò" => "po'", + "Pò" => "Po'", + "po" if !apostrophe => "po'", + _ => continue, + }; + v.push(issue(*a, *b, "Si scrive «po'», con l'apostrofo e senza accento".into(), vec![fix.into()])); + } +} + +/// `qual'è` → `qual è` (any case): the fix when `word` is `qual` + apostrophe + `è`/`era`. +pub(crate) fn qual_elided(word: &str) -> Option { + let head_end = word.char_indices().take(4).last().map(|(i, c)| i + c.len_utf8())?; + let (head, rest) = (word.get(..head_end)?, word.get(head_end..)?); + let tail = rest.strip_prefix(is_apostrophe)?; + let tail_ok = matches!(tail.to_lowercase().as_str(), "è" | "era"); + (head.eq_ignore_ascii_case("qual") && tail_ok).then(|| format!("{head} {tail}")) +} + +fn qual_e(text: &str, ws: &[(usize, usize)], v: &mut Vec) { + for (a, b) in ws { + if let Some(fix) = text.get(*a..*b).and_then(qual_elided) { + v.push(issue(*a, *b, "«Qual è» si scrive senza apostrofo".into(), vec![fix])); + } + } +} + +/// `E'` for the verb → `È`. A quoted letter (`la lettera 'E'`) is left alone. +fn e_apostrophe(text: &str, ws: &[(usize, usize)], v: &mut Vec) { + for (a, b) in ws { + if text.get(*a..*b) != Some("E") { + continue; + } + let mut after = text.get(*b..).unwrap_or("").chars(); + let Some(ap) = after.next().filter(|&c| is_apostrophe(c)) else { continue }; + let ends = after.next().is_none_or(|c| c.is_whitespace()); + let quoted = text.get(..*a).and_then(|t| t.chars().next_back()).is_some_and(|c| is_apostrophe(c) || c == '‘'); + if ends && !quoted { + v.push(issue(*a, *b + ap.len_utf8(), "«È» si scrive con l'accento, non con l'apostrofo".into(), vec!["È".into()])); + } + } +} + +/// Does `w` (lower case) start with a vowel or `h`, as the words `un'` elides before? +fn starts_with_vowel(w: &str) -> bool { + w.chars().next().is_some_and(|c| "aeiouhàèéìòóù".contains(c)) +} + +/// Is `w` (lower case) a feminine word in `-a` (`altra`, `amica`, `ora`), as far as the ending +/// tells? +fn feminine_in_a(w: &str) -> bool { + w.chars().count() >= 2 && w.ends_with('a') && !MASCULINE_A_ENDINGS.iter().any(|e| w.ends_with(e)) && !NOT_FEMININE_IN_A.contains(&w) +} + +/// `un'` before a masculine word (`un'altro` → `un altro`), and `un` without the apostrophe +/// before a feminine one (`un altra` → `un'altra`). Only lower-case words: a name's gender isn't +/// in its ending. +fn un_apostrophe(text: &str, ws: &[(usize, usize)], v: &mut Vec) { + for (a, b) in ws { + let Some(w) = text.get(*a..*b) else { continue }; + let Some((head, tail)) = w.split_once(is_apostrophe) else { continue }; + if UN_FORMS.contains(&head.to_lowercase().as_str()) + && tail.chars().all(|c| c.is_alphabetic() && c.is_lowercase()) + && starts_with_vowel(tail) + && tail.ends_with('o') + && !FEMININE_IN_O.contains(&tail) + { + v.push(issue(*a, *b, format!("Davanti a un nome maschile «{head}» non vuole l'apostrofo"), vec![format!("{head} {tail}")])); + } + } + 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 == " " + && UN_FORMS.contains(&w0.to_lowercase().as_str()) + && w1.chars().all(|c| c.is_alphabetic() && c.is_lowercase()) + && starts_with_vowel(w1) + && feminine_in_a(w1) + { + v.push(issue(*a0, *b1, format!("Davanti a un nome femminile si scrive «{w0}'», con l'apostrofo"), vec![format!("{w0}'{w1}")])); + } + } +} + /// Grammar issues in a paragraph of Italian text, in order. pub fn check(text: &str) -> Vec { let ws: Vec<(usize, usize)> = words(text); @@ -218,6 +333,10 @@ pub fn check(text: &str) -> Vec { spaces(text, &mut v); sentence_capitals(text, &ws, &mut v); doubled_punctuation(text, &mut v); + po(text, &ws, &mut v); + qual_e(text, &ws, &mut v); + e_apostrophe(text, &ws, &mut v); + un_apostrophe(text, &ws, &mut v); v.sort_by_key(|i| (i.start, i.end)); v } @@ -250,6 +369,97 @@ mod tests { assert!(check("Sì sì, bene bene :)").is_empty()); } + /// The fixes the rules offer for `t`, in order. + fn fixes(t: &str) -> Vec<(String, String)> { + check(t).into_iter().map(|i| (t.get(i.start..i.end).unwrap_or("").to_string(), i.suggestions.join("|"))).collect() + } + + fn pair(a: &str, b: &str) -> (String, String) { + (a.to_string(), b.to_string()) + } + + #[test] + fn po_takes_an_apostrophe() { + assert_eq!(fixes("Dammi un pò di pane"), [pair("pò", "po'")]); + assert_eq!(fixes("Dammi un po di pane"), [pair("po", "po'")]); + assert_eq!(fixes("Pò di più"), [pair("Pò", "Po'")]); + assert!(fixes("Dammi un po' di pane, un po’ d'acqua").is_empty()); + assert!(fixes("Il Po scorre a Torino").is_empty(), "the river"); + } + + #[test] + fn qual_e_has_no_apostrophe() { + assert_eq!(fixes("Dimmi qual'è il problema"), [pair("qual'è", "qual è")]); + assert_eq!(fixes("Qual’era la strada?"), [pair("Qual’era", "Qual era")]); + assert!(fixes("Qual è il problema? Quale era?").is_empty()); + assert!(fixes("Quale'è").is_empty(), "not the truncation"); + assert_eq!(qual_elided("qual'è").as_deref(), Some("qual è")); + assert_eq!(qual_elided("qua"), None); + assert_eq!(qual_elided(""), None); + } + + #[test] + fn e_apostrophe_becomes_e_grave() { + assert_eq!(fixes("E' vero"), [pair("E'", "È")]); + assert_eq!(fixes("Lo so. E’ tardi"), [pair("E’", "È")]); + assert!(fixes("È vero. E poi?").is_empty()); + assert!(fixes("La lettera 'E' è una vocale").is_empty(), "a quoted letter"); + } + + #[test] + fn un_apostrophe_follows_the_gender() { + assert_eq!(fixes("Ho visto un'altro amico"), [pair("un'altro", "un altro")]); + assert_eq!(fixes("È passato un’anno"), [pair("un’anno", "un anno")]); + assert_eq!(fixes("Prendo un altra strada"), [pair("un altra", "un'altra")]); + assert_eq!(fixes("Ho un amica a Roma"), [pair("un amica", "un'amica")]); + assert_eq!(fixes("Non c'è nessun altra via"), [pair("nessun altra", "nessun'altra")]); + for ok in [ + "Un altro amico", + "Un'altra amica", + "Un'auto nuova", + "Un'eco lontana", + "Un artista", + "Un atleta", + "Un idiota", + "Un ipocrita", + "Un enigma", + "Un omicida", + "Un astronauta", + "Un acrobata", + "Un'ora", + "Un'idea", + "Un uomo", + "Un'Alfa", + "Un Andrea", + "Un ha", + ] { + assert!(fixes(ok).is_empty(), "{ok}: {:?}", fixes(ok)); + } + } + + #[test] + fn more_emphatic_doubles_are_allowed() { + for ok in ["Pian piano", "Poco poco", "Zitto zitto", "Via via", "Così così", "Dai dai"] { + assert!(fixes(ok).is_empty(), "{ok}: {:?}", fixes(ok)); + } + } + + proptest::proptest! { + #[test] + fn never_panics(s in "\\PC{0,80}") { + for i in check(&s) { + proptest::prop_assert!(i.start <= i.end && i.end <= s.len()); + } + } + + #[test] + fn never_panics_on_italian_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() && s.is_char_boundary(i.start) && s.is_char_boundary(i.end)); + } + } + } + fn test_dict_install() { crate::it::test_dict::install(); } diff --git a/crates/proof/src/it/mod.rs b/crates/proof/src/it/mod.rs index 7e1635599..c8e33f14c 100644 --- a/crates/proof/src/it/mod.rs +++ b/crates/proof/src/it/mod.rs @@ -4,12 +4,14 @@ //! [`crate::hunspell`]: `it_IT.aff`/`it_IT.dic` from the OS, a package manager or //! LibreOffice; it isn't bundled — the upstream file is GPL-licensed). Without it Italian //! text gets no spelling marks. Elided forms check the word after the apostrophe -//! (`l'amico`, `un'idea`, `c'è`); hyphenated compounds are checked part by part; the user -//! dictionary applies as in English. +//! (`l'amico`, `un'idea`, `c'è`, `dov'è`, `senz'altro`); truncations keep their apostrophe +//! (`po'`, `mo'`); hyphenated compounds are checked part by part; the user dictionary applies +//! as in English. Forms a grammar rule explains better (`pò`, `qual'è`) are left to it. //! - **Suggestions** tuned for Italian ([`suggest`]): missing accents first (`caffe` → `caffè`, //! `perche` → `perché`), then the dictionary's `REP` confusions, a missing space, one-letter //! typos, and a dictionary scan for anything further off. -//! - **Grammar** ([`grammar`]): cheap, reliable rules with Italian messages. +//! - **Grammar** ([`grammar`]): cheap, reliable rules with Italian messages, which work with +//! no dictionary installed. pub mod grammar; mod suggest; @@ -84,12 +86,23 @@ fn is_latin_letter(c: char) -> bool { } /// Proclitics (and verb clippings) that elide before a vowel or `h`: the word after the -/// apostrophe is what gets checked (`l'amico`, `un'idea`, `c'è`, `m'hai`). +/// apostrophe is what gets checked (`l'amico`, `un'idea`, `c'è`, `m'hai`, `dov'è`, `anch'io`). const PROCLITICS: &[&str] = &[ - "l", "un", "una", "uno", "all", "dall", "dell", "nell", "sull", "coll", "pell", "c", "m", "t", "s", "v", "d", "j", "quest", "quell", "bell", - "sant", "grand", "poc", + "l", "un", "una", "uno", "all", "dall", "dell", "nell", "sull", "coll", "pell", "c", "m", "t", "s", "v", "d", "j", "n", "ch", "gl", "quest", + "quell", "bell", "buon", "sant", "grand", "poc", "nessun", "ciascun", "qualcun", "alcun", "dov", "cos", "com", "quand", "anch", "senz", "tutt", + "nient", "mezz", "sott", "tant", "quant", "dev", ]; +/// Truncated words written with an apostrophe (`un po'`, `a mo' di`, `be'`): [`crate::words`] +/// trims the apostrophe, so spelling sees the bare word; [`grammar`] checks the apostrophe. +const TRUNCATIONS: &[&str] = &["po", "mo", "be"]; + +/// Misspellings a grammar rule reports with a clearer message and fix (`pò` → `po'`, `qual'è` +/// → `qual è`), so spelling leaves them alone rather than marking the word twice. +fn left_to_grammar(lower: &str) -> bool { + lower == "pò" || grammar::qual_elided(lower).is_some() +} + /// Is `word` spelled correctly in Italian? Numbers, single letters, short words in capitals /// (acronyms: `RAI`, `TG`), words with digits and words in other scripts are accepted, and so /// is every word when no Italian dictionary is installed. @@ -114,6 +127,10 @@ pub fn is_correct_with(d: &AffixDict, word: &str) -> bool { if !w.chars().any(is_latin_letter) { return true; // other scripts: no dictionary } + let lower = w.to_lowercase(); + if TRUNCATIONS.contains(&lower.as_str()) || left_to_grammar(&lower) { + return true; + } if w.contains('-') { return w.split('-').all(|p| p.chars().count() <= 1 || known(d, p)); } @@ -178,14 +195,32 @@ mod tests { } } + #[test] + fn elisions_and_truncations_are_correct() { + test_dict::install(); + for w in ["dov'è", "Com'è", "cos'è", "anch'io", "senz'acqua", "tutt'uno", "quand'è", "nessun'amica", "n'ha", "po'", "po", "mo'"] { + assert!(is_correct(w), "{w}"); + } + } + #[test] fn misspellings_are_flagged() { test_dict::install(); - for w in ["caffé", "perche", "lbiro", "parlre", "amicaa", "citta", "quelo"] { + for w in ["caffé", "perche", "lbiro", "parlre", "amicaa", "citta", "quelo", "dov'lbiro", "xyz'casa"] { assert!(!is_correct(w), "{w}"); } } + #[test] + fn grammar_owned_forms_are_not_marked_twice() { + test_dict::install(); + // `pò` and `qual'è` are reported by the grammar rules, with the fix; spelling stays quiet. + for w in ["pò", "Pò", "qual'è", "Qual’era"] { + assert!(is_correct(w), "{w}"); + assert!(!grammar::check(w).is_empty(), "{w}: grammar reports it"); + } + } + #[test] fn user_dictionary_applies() { test_dict::install(); diff --git a/crates/proof/src/it/test_dict.rs b/crates/proof/src/it/test_dict.rs index 16fd8f649..5d594d2fd 100644 --- a/crates/proof/src/it/test_dict.rs +++ b/crates/proof/src/it/test_dict.rs @@ -30,7 +30,7 @@ PFX R 0 ri . "; /// Stems (the count line first, as in Hunspell files). -pub const DIC: &str = "96 +pub const DIC: &str = "97 città caffè perché @@ -127,6 +127,7 @@ a per me quello +io "; /// Make [`AFF`]/[`DIC`] the Italian dictionary (every test that needs one calls this; they all diff --git a/crates/proof/src/lib.rs b/crates/proof/src/lib.rs index 17a96bb9b..369025527 100644 --- a/crates/proof/src/lib.rs +++ b/crates/proof/src/lib.rs @@ -1298,14 +1298,19 @@ mod tests { let _ = suggest_in(&s, 4, ProofLang::Pt); } + #[test] + fn italian_suggestions_never_panic(s in "[a-zàèéìòóùA-ZÀÈÉÌÒÓÙ'’ -]{0,14}") { + let _ = suggest_in(&s, 4, ProofLang::It); + } + #[test] fn turkish_suggestions_never_panic(s in "[a-zçğıöşüA-ZÇĞİÖŞÜ'’ -]{0,14}") { let _ = suggest_in(&s, 4, ProofLang::Tr); } #[test] - fn mixed_spans_never_panic(s in "\\PC{0,60}", cuts in proptest::collection::vec((0usize..80, 0usize..80, 0u8..6), 0..6)) { - let langs = [ProofLang::En, ProofLang::Pl, ProofLang::Pt, ProofLang::Tr, ProofLang::Ru, ProofLang::Other]; + fn mixed_spans_never_panic(s in "\\PC{0,60}", cuts in proptest::collection::vec((0usize..80, 0usize..80, 0u8..7), 0..6)) { + let langs = [ProofLang::En, ProofLang::Pl, ProofLang::Pt, ProofLang::It, ProofLang::Tr, ProofLang::Ru, ProofLang::Other]; let spans: Vec<(Range, ProofLang)> = cuts.iter().map(|&(a, b, l)| (a.min(b)..a.max(b), langs[l as usize % langs.len()])).collect(); for i in check_text(&s, &spans) { proptest::prop_assert!(i.start <= i.end && i.end <= s.len()); diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 9909ea42d..2e4cbcdf8 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-13 · **Change:** trivial (Czech interface catalog completed, 2,447 entries; previously trivial: interface audit runs in CI again: size labels translated, 20 strings in every catalog, Read Aloud error race fixed, #1128; previously trivial: Review › Language reaches every language again — Set Proofing Language… and Set As Default, #860; previously minor: Italian proofing: it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar with Italian messages; previously minor: Portuguese proofing; previously trivial: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; 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-14 · **Change:** trivial (Italian grammar: `po'`, `qual è`, `È`, `un`/`un'` rules; more elisions and truncations in spelling; previously trivial: Czech interface catalog completed, 2,447 entries; previously trivial: interface audit runs in CI again: size labels translated, 20 strings in every catalog, Read Aloud error race fixed, #1128; previously trivial: Review › Language reaches every language again — Set Proofing Language… and Set As Default, #860; previously minor: Italian proofing: it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar with Italian messages; previously minor: Portuguese proofing; previously trivial: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; 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`. @@ -102,7 +102,7 @@ other than envelopes (Letter, Legal, A4, B5 (JIS)) stay as they are (#1128). | Russian (`ru`) | A Russian Hunspell dictionary installed on the system (`ru_RU` from the OS, a package manager, LibreOffice's `dict-ru` or the `WORDCRAFT_DICTIONARIES` folder; UTF-8, KOI8-R or Windows-1251; not bundled, so no Russian spelling marks without it), read by the same affix engine; `ё` optional (`елка`/`ёлка`, `еще`/`ещё`); hyphenated words whole, then by parts (`кто-то`, `по-русски`, `кое-где`, `северо-западный`, `ИИ-консультант`); solid compounds (`высокоэластичный`), `не` with adjectives, participles and verbal nouns (not verbs), productive prefixes (`ультра-`, `эко-`) and prefixed verbs; abbreviations with endings (`ГИПа`) and camel case left alone; our supplement of ~200 modern words with their declensions (`assets/spelling/ru-extra.txt`: `маркетплейс`, `промокод`, `интеграция`, `колеровать`) | `ё`/`е` first, then slips within letter groups (unstressed vowels, voiced and voiceless pairs, `ь`/`ъ`), the common confusions (`жы`, `чя`, `тся`/`ться`, doubled consonants, unpronounced consonants: `здраствуй` → `здравствуй`), a missing space (`незнаю` → `не знаю`; not a preposition before a verb), one typo, then a stem scan | Russian messages: repeated word, space before punctuation, double space, sentence capital (knows `т. е.`, `г.`, `руб.`, `ул.`, initials, numbers, dialogue dashes), possibly missing comma before `что`/`чтобы`/`если`/`хотя`/`поскольку`/`который` and before `потому что` (not after conjunctions and particles such as `и`, `только`, `даже`, `так`, `для того`; not after a preposition; not in `хотя бы`, `что-то`), doubled punctuation | The rules of Russian word division as code (no pattern file): a vowel on each side, two letters at least, `ь`/`ъ`/`й` kept with the letter before, a break after `ъ`, a consonant cluster divided after its first consonant | | Polish (`pl`) | The Hunspell `pl_PL` dictionary installed on the system (OS package, Homebrew, LibreOffice, the folder set in File › Options › Proofing › Dictionaries folder, or `WORDCRAFT_DICTIONARIES`; not bundled, so no Polish spelling marks without it; the same pane shows which file each language found), 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 | -| Italian (`it`, any region) | The Hunspell `it_IT` dictionary installed on the system (same places as Polish and Portuguese; not bundled — the upstream file is GPL-licensed), read at runtime by the same affix engine; elided forms check the word after the apostrophe (`l'amico`, `un'idea`, `c'è`), hyphenated compounds part by part | missing accents first (`caffe` → `caffè`, `perche` → `perché`), the dictionary's confusion table, missing space (`perme` → `per me`), one typo, then an accent-folding stem scan | Italian messages: repeated word (emphatic doubles like `piano piano` are allowed), space before punctuation, double space, sentence capital (knows `ecc.`, `dott.`, `prof.`, initials, ordinals), doubled punctuation; no comma rule | TeX Italian patterns (Beccari; MIT option), no exception words | +| Italian (`it`, any region) | The Hunspell `it_IT` dictionary installed on the system (same places as Polish and Portuguese; not bundled — the upstream file is GPL-licensed), read at runtime by the same affix engine; elided forms check the word after the apostrophe (`l'amico`, `un'idea`, `c'è`, `dov'è`, `senz'altro`), truncations keep their apostrophe (`po'`, `mo'`), hyphenated compounds part by part | missing accents first (`caffe` → `caffè`, `perche` → `perché`), the dictionary's confusion table, missing space (`perme` → `per me`), one typo, then an accent-folding stem scan | Italian messages: repeated word (emphatic doubles like `piano piano` are allowed), space before punctuation, double space, sentence capital (knows `ecc.`, `dott.`, `prof.`, initials, ordinals), doubled punctuation, `un pò` → `un po'`, `qual'è` → `qual è`, `E'` → `È`, `un'altro` → `un altro` and `un altra` → `un'altra` (gender from the ending, masculine `-ista`/`-ma`/`-cida`… left alone); these work without a dictionary; no comma rule | TeX Italian patterns (Beccari; MIT option), no exception words | | Turkish (`tr`, any region) | The Hunspell `tr_TR` dictionary installed on the system (same places as Polish; not bundled), read at runtime; proper nouns capitalized, apostrophe suffixes (`Ankara'da`, `WordCraft'ta`) and hyphenated compounds | missing diacritics / ASCII restoration first (`turkce` → `Türkçe`, `isik` → `ışık`), common confusion table (`yalnış` → `yanlış`, `herşey` → `her şey`), detached suffixes (`bende` → `ben de`, `geldinmi` → `geldi mi`), one typo | Turkish messages: repeated word (idiomatic reduplications allowed: `yavaş yavaş`), space before punctuation, double space, sentence capital (knows `vb.`, `vs.`, `prof.`, `dr.`), no comma before/after conjunctions (`ve`, `veya`, `de`), doubled punctuation | Turkish syllable rules (TDK rules); no external pattern file or word list needed | | Any other | — (no marks) | — | — | the English patterns, as before | @@ -121,7 +121,7 @@ Italian (`it`, #594) follows `it`, `it-IT` and `it-CH` system locales. Its catal 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. +allows); all other text is written from the meaning of the English. Italian proofing: see the table above. ## Sorting and numbers in the text's language Sorts follow the alphabet of the text's language (`w:lang`, else the document default): @@ -184,6 +184,7 @@ WordCraft keeps 2,54 cm). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Italian grammar rules that work without a dictionary: `un pò`/`un po` → `un po'`, `qual'è` → `qual è`, `E'` → `È`, `un'` only before feminine words (`un'altro` → `un altro`, `un altra` → `un'altra`, also `nessun`/`ciascun`/`qualcun`/`alcun`); spelling accepts more elisions (`dov'è`, `anch'io`, `senz'altro`, `nessun'altra`) and truncations (`po'`, `mo'`) and leaves `pò`/`qual'è` to grammar so they aren't marked twice; more emphatic doubles allowed | | 2026-10-12 | minor | File › Options › Proofing › Dictionaries folder (#1063): a saved folder of Hunspell dictionaries searched first, so an app started from Finder or the Dock finds them without `WORDCRAFT_DICTIONARIES`; the pane lists the dictionary file each language found — Polish, Portuguese, Italian, Croatian, Turkish (`file.options` `dictionariesFolder`) | | 2026-10-12 | minor | Czech interface catalog completed: 386 entries added (2,447 total), including built-in style names, numbered families, equation gallery, watermarks, paper sizes and preview tiles; coverage test requires every Russian and Spanish key and retains Chinese coverage | | 2026-10-12 | minor | Italian proofing: it-IT spelling from the installed Hunspell dictionary (system `it_IT`, never bundled — upstream is GPL), accent-first suggestions, conservative grammar with Italian messages, TeX Italian hyphenation patterns (MIT option); Review › Language already offered Italian; an `it` interface writes it-IT documents | From 952eb57688285f267b9e78940f97fa4afdb69f5c Mon Sep 17 00:00:00 2001 From: Ikki Nishihara Date: Thu, 15 Oct 2026 08:50:54 +0900 Subject: [PATCH 021/101] Respect the image byte limit during DOC DIB conversion (#1590) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Engine: the Word 2003 format check reads through io, so commands still never touch files themselves is_word_2003_file (#1081) opened the file with std::fs, which the file-access guard test (commands_do_not_touch_the_file_system_themselves) forbids outside io.rs; main failed it. A new io::read_file_head reads the first bytes inside the read root. Co-Authored-By: Claude Opus 5.5 * UI: a chrome click counts the page as focused at once, so the next keystroke isn't dropped; the ribbon-focus test passes again Co-Authored-By: Claude Opus 5.5 * fix: DOCのDIB画像変換でバイト上限を守る * Drop the duplicate read_file_head left by the merge with main 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: haranishi <223786623+haranishi@users.noreply.github.com> Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 ++- crates/docbin/src/officeart.rs | 43 +++++++++++++++++++++++++----- crates/docbin/src/tests/drawing.rs | 27 +++++++++++++++++++ crates/engine/src/io.rs | 24 ++++++++--------- docs/file-format-parity.md | 5 ++-- 5 files changed, 81 insertions(+), 22 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index eee60f234..ca52d1370 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-14 · **Change:** trivial (progress log: comment deletion leaves no dangling anchors or replies, #1345; previously trivial: progress log: All Caps draws ß as SS, #1581; previously trivial: progress log: justified vertical page alignment, #1320; previously trivial: progress log: the save prompt takes the keyboard from the page, #1597; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; 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-14 · **Change:** trivial (progress log: comment deletion leaves no dangling anchors or replies, #1345; previously trivial: legacy DOC DIB conversion honours the BLIP byte limit; related #1334 and #1554; previously trivial: progress log: justified vertical page alignment, #1320; previously trivial: progress log: the save prompt takes the keyboard from the page, #1597; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; 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 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -137,6 +137,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| | 2026-10-14 | Deleting a comment removes its anchors from headers, footers, notes and text boxes too, not just the body, and deletes the whole thread below it, replies to replies included; no anchor or reply is left pointing at a deleted comment (#1345) | +| 2026-10-14 | Legacy DOC DIB conversion checks the BMP header and copy before allocation, uses the caller's decode allowance, and caps PNG output while encoding; a BMP fallback is kept when it fits. A generated 1-pixel regression covers 57/58/512-byte limits. Resource-exhaustion consequences remain unmeasured. Related: #1334 and #1554 | | 2026-10-13 | Justified vertical page alignment (#1320): a section set to Justified spreads each page's paragraphs and tables from the top margin to the bottom one, as Word does, including the last page and pages ending in a manual page break. DOCX `w:vAlign="both"` opens as Justified (it showed centred) and saves back; `layout.verticalAlign` takes `justify`, and Page Setup has a Vertical alignment box | | 2026-10-13 | macOS shortcuts as in Word for Mac: ⌘⇧S Save As, ⌘⌥E Insert Endnote, ⌘⌥A New Comment, ⌃H Replace, ⌘⌥R collapse the ribbon | | 2026-10-13 | The status bar flags a proofing language whose Hunspell dictionary isn't installed (Polish, Portuguese): "Polish (no dictionary)", with a tooltip naming the dictionary and where it is looked up (#1167) | @@ -380,6 +381,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| | 2026-10-14 | trivial | Progress log: deleting a comment leaves no dangling anchors or replies (#1345) | +| 2026-10-14 | minor | Legacy DOC DIB conversion byte limits only; related #1334/#1554, stage and readiness estimates unchanged | | 2026-10-13 | trivial | Progress log: justified vertical page alignment (#1320) | | 2026-10-14 | trivial | Progress log: file names that aren't valid Unicode (#1440) | | 2026-10-13 | trivial | Behind Text pictures and shapes drag over overlapping text in the body and headers, selected or not, while ordinary clicks keep editing text; In Front of Text drag covered too | diff --git a/crates/docbin/src/officeart.rs b/crates/docbin/src/officeart.rs index 188dbd1b0..12d4a7d2e 100644 --- a/crates/docbin/src/officeart.rs +++ b/crates/docbin/src/officeart.rs @@ -6,6 +6,9 @@ //! and record counts are capped, and decompressed metafiles are size-limited. use std::collections::HashMap; +use std::io::Write; + +use image::ImageEncoder; use wordcraft_doc::props::Rgb; @@ -138,7 +141,7 @@ pub(crate) fn blip(rec: &Rec, limit: usize) -> Option { 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), + _ => dib_to_png(data, limit), } } rt::BLIP_EMF | rt::BLIP_WMF | rt::BLIP_PICT => { @@ -175,7 +178,7 @@ pub(crate) fn blip(rec: &Rec, limit: usize) -> Option { /// 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 { +fn dib_to_png(dib: &[u8], limit: usize) -> Option { let header = u32_at(dib, 0)? as usize; if !(12..=124).contains(&header) { return None; @@ -193,7 +196,7 @@ fn dib_to_png(dib: &[u8]) -> Option { // 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 file_size = 14usize.checked_add(dib.len()).filter(|&size| size <= limit)?; 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()); @@ -205,17 +208,43 @@ fn dib_to_png(dib: &[u8]) -> Option { 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); + limits.max_alloc = Some((256 << 20).min(limit as u64)); reader.limits(limits); - let mut png = Vec::new(); + let mut png = PictureWriter { bytes: Vec::new(), limit }; 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 }) + Ok(img) + if image::codecs::png::PngEncoder::new(&mut png).write_image(img.as_bytes(), img.width(), img.height(), img.color().into()).is_ok() => + { + Some(Blip { bytes: png.bytes, ext: "png", placeholder: false }) } _ => Some(Blip { bytes: bmp, ext: "bmp", placeholder: true }), } } +/// Cap encoded bytes while the PNG encoder writes, before the buffer can grow. +struct PictureWriter { + bytes: Vec, + limit: usize, +} + +impl Write for PictureWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + let Some(end) = self.bytes.len().checked_add(buf.len()).filter(|&end| end <= self.limit) else { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "picture exceeds remaining media budget")); + }; + if end > self.bytes.capacity() { + let new_capacity = self.bytes.capacity().saturating_mul(2).max(end).min(self.limit); + self.bytes.try_reserve_exact(new_capacity - self.bytes.len()).map_err(std::io::Error::other)?; + } + self.bytes.extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + // --------------------------------------------------------------------------------------------- // Shapes. diff --git a/crates/docbin/src/tests/drawing.rs b/crates/docbin/src/tests/drawing.rs index ef5f7f99f..fd9c34aa7 100644 --- a/crates/docbin/src/tests/drawing.rs +++ b/crates/docbin/src/tests/drawing.rs @@ -304,3 +304,30 @@ proptest! { let _ = read(&struct_doc(&spec)); } } + +// A 1×1 24-bit DIB, including its padded four-byte row. +fn one_pixel_dib() -> Vec { + let mut dib = [40u32, 1, 1].iter().flat_map(|v| v.to_le_bytes()).collect::>(); + 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(&[30, 60, 90, 0]); + dib +} + +#[test] +fn dib_conversion_caps_header_and_encoded_bytes() { + let dib = one_pixel_dib(); + let record = rec(0, 0x7A8, officeart::rt::BLIP_DIB, &[[0u8; 16].as_slice(), &[0xFF], &dib].concat()); + let (record, _) = officeart::record(&record).unwrap(); + let bmp_size = 14 + dib.len(); + assert!(officeart::blip(&record, bmp_size - 1).is_none(), "the BMP header also needs room"); + let fallback = officeart::blip(&record, bmp_size).expect("the BMP fallback fits exactly"); + assert_eq!((fallback.ext, fallback.placeholder, fallback.bytes.len()), ("bmp", true, bmp_size)); + let img = image::load_from_memory(&fallback.bytes).unwrap().to_rgb8(); + assert_eq!((img.width(), img.height(), img.get_pixel(0, 0).0), (1, 1, [90, 60, 30])); + let normal = officeart::blip(&record, 512).expect("normal conversion fits"); + assert_eq!((normal.ext, normal.placeholder), ("png", false)); + assert!(normal.bytes.len() > bmp_size && normal.bytes.len() <= 512); + assert_eq!(image::load_from_memory(&normal.bytes).unwrap().width(), 1); +} diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index d96b4ebe4..b7997f915 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -489,6 +489,18 @@ pub fn read_file_capped(path: &str, max: u64, what: &str) -> Result, Str std::fs::read(path).map_err(|e| format!("{path}: {e}")) } +/// The first `max` bytes of the file at `path` (fewer when it is shorter), inside the automation +/// read root when there is one: enough to tell a file's format by its start. +#[cfg(not(target_arch = "wasm32"))] +pub fn read_file_head(path: &str, max: u64) -> Result, String> { + use std::io::Read; + crate::file_access::check_read(path)?; + let f = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?; + let mut head = Vec::new(); + f.take(max).read_to_end(&mut head).map_err(|e| format!("{path}: {e}"))?; + Ok(head) +} + /// Rename without replacing another file, respecting both automation access roots. #[cfg(not(target_arch = "wasm32"))] pub fn rename_file(source: &std::path::Path, destination: &std::path::Path) -> Result<(), String> { @@ -523,18 +535,6 @@ fn only_case_differs(source: &std::path::Path, destination: &std::path::Path) -> std::fs::read_dir(dir).is_ok_and(|entries| entries.filter_map(Result::ok).all(|entry| entry.file_name() != to)) } -/// The first `max` bytes of the file at `path` (fewer when it is shorter), inside the automation -/// read root when there is one: enough to tell a file's format by its start. -#[cfg(not(target_arch = "wasm32"))] -pub fn read_file_head(path: &str, max: u64) -> Result, String> { - use std::io::Read; - crate::file_access::check_read(path)?; - let f = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?; - let mut head = Vec::new(); - f.take(max).read_to_end(&mut head).map_err(|e| format!("{path}: {e}"))?; - Ok(head) -} - /// 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> { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 7357e1bca..7c227b811 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-14 · **Change:** trivial (section `w:vAlign` "both" round-trips as Justified, #1320; previously trivial: Word's text watermark written back to DOCX headers, #1290; previously trivial: VML preset shapes drawn, #1377; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: equations as MathML in HTML export, copy and paste, #816; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously 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-11 · **Last updated:** 2026-10-14 · **Change:** trivial (legacy DOC DIB conversion honours the BLIP byte limit; related #1334 and #1554; previously trivial: section `w:vAlign` "both" round-trips as Justified, #1320; previously trivial: Word's text watermark written back to DOCX headers, #1290; previously trivial: VML preset shapes drawn, #1377; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: equations as MathML in HTML export, copy and paste, #816; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously 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; @@ -23,7 +23,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. Tracked changes inside a field result stay revisions (the field is kept as a range, #1037). 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 | 🟡 | ❌ | ~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]). Table cell shading from every SHD record (`sprmTDefTableShd`, `sprmTDefTableShdRaw` and their 2nd/3rd ranges, with the Word 97 `Shd80` as fallback; a clear pattern shows its background colour); the row-end mark is not a cell, and each table has its own grid and properties (#1380). Horizontally merged rows stored as fewer, wider cells come with #1123. Freeform shapes, z-order and Word 6/95 files are dropped or refused. Password-protected files (XOR obfuscation, RC4, RC4 CryptoAPI) open after a password prompt, from the public specs; not yet tried on a file Word wrote (AES-encrypted ones are refused). No writer | +| `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~60% | 25–40 | `crates/docbin/src/tests.rs`, `tests/drawing.rs` | 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]). Table cell shading from every SHD record (`sprmTDefTableShd`, `sprmTDefTableShdRaw` and their 2nd/3rd ranges, with the Word 97 `Shd80` as fallback; a clear pattern shows its background colour); the row-end mark is not a cell, and each table has its own grid and properties (#1380). Horizontally merged rows stored as fewer, wider cells come with #1123. Freeform shapes, z-order and Word 6/95 files are dropped or refused. Password-protected files (XOR obfuscation, RC4, RC4 CryptoAPI) open after a password prompt, from the public specs; not yet tried on a file Word wrote (AES-encrypted ones are refused). No writer. DIB conversion caps BMP bytes (header included) and encoded PNG at the supplied BLIP byte limit, and uses it as the decoder's allocation limit; a BMP fallback is kept if it fits (related #1334 and #1554). | | PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133); Hebrew and Arabic copy and search as whole words (right-to-left glyphs written in visual order, #1383); All Caps and Small Caps text extracts as the capitals shown, as from Word's PDFs, and never changes the case of other text (#1473), 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. Horizontal and vertical lines inside groups are drawn (#1474). 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. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only). Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Tracked insertions and deletions read and written (`\revtbl`, `\revised`/`\deleted`, authors, `\revdttm` dates), so Accept/Reject All work after opening or a round trip; paragraph-mark and formatting revisions not yet. Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export still writes one section. Thin: comments, notes, section export, RTL | @@ -195,6 +195,7 @@ attributes inside stories and style or list definitions; a regenerated theme. Te | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Legacy DOC DIB helper honours the supplied byte limit for its BMP header/copy, decoder and PNG output, with a bounded BMP fallback. Generated 1-pixel regression only; resource-exhaustion consequences unmeasured, fidelity/readiness estimates unchanged. Related: #1334 and #1554 | | 2026-10-13 | trivial | Section `w:vAlign="both"` reads as Justified and writes back as `both` (#1320) | | 2026-10-13 | trivial | DOCX: the document's watermark is written as a VML text shape in every header, adding a header where pages had none (#1290) | | 2026-10-13 | trivial | DOCX: Word's VML text watermark in headers is read as the document's watermark (#1290) | From 90d8844e44c01eff3a5cdbd20fb60815149ca48e Mon Sep 17 00:00:00 2001 From: Fin Warman Date: Thu, 15 Oct 2026 00:51:13 +0100 Subject: [PATCH 022/101] Shapes: a right triangle keeps its right angle (#1599) DrawingML's rtTriangle was read as an isosceles triangle, so a cover page's white corner cut-outs pointed up into a peak instead of cutting a V into the picture above them. It is now its own shape kind, drawn with the right angle at the bottom left, wrapped and glued along that outline, and saved back as rtTriangle. The .doc reader maps msosptRightTriangle (6) to it too. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/doc/src/connector.rs | 2 ++ crates/doc/src/para.rs | 2 ++ crates/doc/src/wrap.rs | 2 ++ crates/docbin/src/drawing.rs | 3 ++- crates/docx/src/read/shape_style.rs | 3 ++- crates/docx/src/write/story.rs | 1 + crates/docx/tests/fixtures.rs | 15 +++++++++++++++ crates/pdf/src/lib.rs | 1 + crates/render/src/lib.rs | 1 + docs/file-format-parity.md | 3 ++- 10 files changed, 30 insertions(+), 3 deletions(-) diff --git a/crates/doc/src/connector.rs b/crates/doc/src/connector.rs index 2cfa5b277..e3ce8368f 100644 --- a/crates/doc/src/connector.rs +++ b/crates/doc/src/connector.rs @@ -100,6 +100,8 @@ pub fn sites(kind: ShapeKind, w: f32, h: f32) -> Vec<(f32, f32)> { vec![(cx, 0.0), (cx - dx, cy - dy), (0.0, cy), (cx - dx, cy + dy), (cx, h), (cx + dx, cy + dy), (w, cy), (cx + dx, cy - dy)] } ShapeKind::Triangle => vec![(cx, 0.0), (cx / 2.0, cy), (0.0, h), (cx, h), (w, h), (cx + cx / 2.0, cy)], + // The corners, the legs' midpoints and the hypotenuse's. + ShapeKind::RightTriangle => vec![(0.0, 0.0), (0.0, cy), (0.0, h), (cx, h), (w, h), (cx, cy)], ShapeKind::Line | ShapeKind::StraightConnector | ShapeKind::ElbowConnector | ShapeKind::CurvedConnector => vec![(0.0, 0.0), (w, h)], _ => vec![(cx, 0.0), (0.0, cy), (cx, h), (w, cy)], } diff --git a/crates/doc/src/para.rs b/crates/doc/src/para.rs index f1379bd35..aa6d84d32 100644 --- a/crates/doc/src/para.rs +++ b/crates/doc/src/para.rs @@ -289,6 +289,8 @@ pub enum ShapeKind { RoundedRectangle, Ellipse, Triangle, + /// A right triangle (DrawingML's `rtTriangle`): the right angle at the bottom left. + RightTriangle, Diamond, Line, Arrow, diff --git a/crates/doc/src/wrap.rs b/crates/doc/src/wrap.rs index 598286496..75fe8f7fb 100644 --- a/crates/doc/src/wrap.rs +++ b/crates/doc/src/wrap.rs @@ -88,6 +88,7 @@ pub fn shape_outline(kind: ShapeKind, w: f32, h: f32, corner: Option) -> Op pts } ShapeKind::Triangle => vec![(0.5, 0.0), (1.0, 1.0), (0.0, 1.0)], + ShapeKind::RightTriangle => vec![(0.0, 0.0), (1.0, 1.0), (0.0, 1.0)], ShapeKind::Diamond => vec![(0.5, 0.0), (1.0, 0.5), (0.5, 1.0), (0.0, 0.5)], ShapeKind::Arrow => { // As drawn: the head is half the height long, the shaft 40% of the height thick. @@ -217,6 +218,7 @@ mod tests { ShapeKind::Ellipse, ShapeKind::RoundedRectangle, ShapeKind::Triangle, + ShapeKind::RightTriangle, ShapeKind::Diamond, ShapeKind::Arrow, ShapeKind::Star, diff --git a/crates/docbin/src/drawing.rs b/crates/docbin/src/drawing.rs index c7b913be8..f2ecdb9a4 100644 --- a/crates/docbin/src/drawing.rs +++ b/crates/docbin/src/drawing.rs @@ -186,7 +186,8 @@ fn leaf(sp: &Sp, w: f32, h: f32, float: Float, b: &Build, pics: &mut Pictures) - 2 => ShapeKind::RoundedRectangle, 3 => ShapeKind::Ellipse, 4 => ShapeKind::Diamond, - 5 | 6 => ShapeKind::Triangle, + 5 => ShapeKind::Triangle, + 6 => ShapeKind::RightTriangle, 12 | 187 => ShapeKind::Star, 13 | 66 | 67 | 68 | 69 => ShapeKind::Arrow, 20 | 32 => ShapeKind::Line, diff --git a/crates/docx/src/read/shape_style.rs b/crates/docx/src/read/shape_style.rs index 09f47e93f..bb68c23fe 100644 --- a/crates/docx/src/read/shape_style.rs +++ b/crates/docx/src/read/shape_style.rs @@ -29,7 +29,8 @@ pub(crate) fn preset_kind(prst: &str) -> ShapeKind { "ellipse" | "wedgeEllipseCallout" | "cloudCallout" | "cloud" | "flowChartConnector" | "donut" | "flowChartOnlineStorage" => { ShapeKind::Ellipse } - "triangle" | "rtTriangle" | "flowChartExtract" | "flowChartMerge" => ShapeKind::Triangle, + "triangle" | "flowChartExtract" | "flowChartMerge" => ShapeKind::Triangle, + "rtTriangle" => ShapeKind::RightTriangle, "diamond" | "flowChartDecision" => ShapeKind::Diamond, "line" | "lineInv" => ShapeKind::Line, "straightConnector1" => ShapeKind::StraightConnector, diff --git a/crates/docx/src/write/story.rs b/crates/docx/src/write/story.rs index bd3dbfd3d..b9015edbf 100644 --- a/crates/docx/src/write/story.rs +++ b/crates/docx/src/write/story.rs @@ -986,6 +986,7 @@ impl Writer<'_> { ShapeKind::RoundedRectangle => "roundRect", ShapeKind::Ellipse => "ellipse", ShapeKind::Triangle => "triangle", + ShapeKind::RightTriangle => "rtTriangle", ShapeKind::Diamond => "diamond", ShapeKind::Line => "line", ShapeKind::StraightConnector => "straightConnector1", diff --git a/crates/docx/tests/fixtures.rs b/crates/docx/tests/fixtures.rs index b762041a7..2cbb81360 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/fixtures.rs @@ -2567,6 +2567,21 @@ fn text_frames_read_and_round_trip() { assert!(q.iter().any(|x| x.props.drop_cap == Some(2) && x.props.frame.is_none())); } +/// A right triangle (`rtTriangle`) has its right angle at the bottom left: a cover page's white +/// corner cut-outs drawn as isosceles triangles pointed the wrong way. +#[test] +fn a_right_triangle_keeps_its_right_angle() { + let body = anchored_wsp("Corner", 1270000, 635000, r#""#, "", ""); + let d = read_body(&format!("{body}")); + let kind = |d: &Document| match ¶s(d)[0].objects[0] { + InlineObject::Shape { kind, .. } => *kind, + o => panic!("{o:?}"), + }; + assert_eq!(kind(&d), ShapeKind::RightTriangle); + let saved = wordcraft_docx::write(&d).unwrap(); + assert_eq!(kind(&wordcraft_docx::read(&saved).unwrap()), ShapeKind::RightTriangle); +} + #[test] fn hyperlink_with_empty_target_is_no_link() { // LibreOffice writes `Target=""` for the Hyperlink character style without an address (#1005). diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index e73ccb8e5..7f8ec1302 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -558,6 +558,7 @@ fn shape_path(kind: ShapeKind, r: &Rect, corner: Option) -> Option { } ShapeKind::Ellipse => ellipse(&mut pb, cx, cy, r.w / 2.0, r.h / 2.0), ShapeKind::Triangle => poly(&mut pb, &[(cx, y0), (x1, y1), (x0, y1)]), + ShapeKind::RightTriangle => poly(&mut pb, &[(x0, y0), (x1, y1), (x0, y1)]), ShapeKind::Diamond => poly(&mut pb, &[(cx, y0), (x1, cy), (cx, y1), (x0, cy)]), // DrawingML's `line`: top-left to bottom-right (flips give the other directions). ShapeKind::Line => { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 972c3c638..ac79f73ae 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1039,6 +1039,7 @@ pub fn shape_path(kind: ShapeKind, r: kurbo::Rect, corner: Option) -> BezPa } ShapeKind::Ellipse => kurbo::Ellipse::from_rect(r).to_path(0.1), ShapeKind::Triangle => poly(&[(cx, r.y0), (r.x1, r.y1), (r.x0, r.y1)]), + ShapeKind::RightTriangle => poly(&[(r.x0, r.y0), (r.x1, r.y1), (r.x0, r.y1)]), ShapeKind::Diamond => poly(&[(cx, r.y0), (r.x1, cy), (cx, r.y1), (r.x0, cy)]), // DrawingML's `line`: top-left to bottom-right (flips give the other directions). ShapeKind::Line => { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 7c227b811..143502d09 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-14 · **Change:** trivial (legacy DOC DIB conversion honours the BLIP byte limit; related #1334 and #1554; previously trivial: section `w:vAlign` "both" round-trips as Justified, #1320; previously trivial: Word's text watermark written back to DOCX headers, #1290; previously trivial: VML preset shapes drawn, #1377; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: equations as MathML in HTML export, copy and paste, #816; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously 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-11 · **Last updated:** 2026-10-14 · **Change:** trivial (legacy DOC DIB conversion honours the BLIP byte limit; related #1334 and #1554; previously trivial: right triangles keep their right angle; previously trivial: section `w:vAlign` "both" round-trips as Justified, #1320; previously trivial: Word's text watermark written back to DOCX headers, #1290; previously trivial: VML preset shapes drawn, #1377; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: equations as MathML in HTML export, copy and paste, #816; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously 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; @@ -196,6 +196,7 @@ attributes inside stories and style or list definitions; a regenerated theme. Te | Date | Change | Summary | |---|---|---| | 2026-10-14 | minor | Legacy DOC DIB helper honours the supplied byte limit for its BMP header/copy, decoder and PNG output, with a bounded BMP fallback. Generated 1-pixel regression only; resource-exhaustion consequences unmeasured, fidelity/readiness estimates unchanged. Related: #1334 and #1554 | +| 2026-10-14 | trivial | Right triangles (`rtTriangle`) keep their right angle at the bottom left instead of becoming isosceles triangles, and save back as `rtTriangle` | | 2026-10-13 | trivial | Section `w:vAlign="both"` reads as Justified and writes back as `both` (#1320) | | 2026-10-13 | trivial | DOCX: the document's watermark is written as a VML text shape in every header, adding a header where pages had none (#1290) | | 2026-10-13 | trivial | DOCX: Word's VML text watermark in headers is read as the document's watermark (#1290) | From 39af5b17cab52ca66b0facd539ccdaa2d81e62ff Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:51:24 +0200 Subject: [PATCH 023/101] Equations: a multi-letter subscript stays whole after build-up (#1623) --- crates/doc/src/math.rs | 56 ++++++++++++++++++++++++++++++++----- crates/doc/src/math_edit.rs | 15 ++++++++++ 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/crates/doc/src/math.rs b/crates/doc/src/math.rs index 470ef46fb..ce4fc5b44 100644 --- a/crates/doc/src/math.rs +++ b/crates/doc/src/math.rs @@ -499,6 +499,21 @@ fn lin_operand(a: &[MNode], s: &mut String, depth: usize) { } } +/// A script or limit operand. The parser reads a script as one letter or a number (`x_10`), so a +/// longer run is grouped: `V_(dd)`, not `V_dd` (which reads back as V_d then d). +fn lin_script(a: &[MNode], s: &mut String, depth: usize) { + if let [MNode::Run(r)] = a + && r.text.chars().nth(1).is_some() + && !(r.text.starts_with(|c: char| c.is_ascii_digit()) && r.text.chars().all(|c| c.is_ascii_digit() || c == '.')) + { + s.push('('); + s.push_str(&r.text); + s.push(')'); + } else { + lin_operand(a, s, depth); + } +} + fn lin_node(n: &MNode, s: &mut String, depth: usize) { match n { MNode::Run(r) => s.push_str(&r.text), @@ -515,9 +530,9 @@ fn lin_node(n: &MNode, s: &mut String, depth: usize) { MNode::Script { kind, base, sub, sup } => { if *kind == ScriptKind::Pre { s.push('_'); - lin_operand(sub, s, depth); + lin_script(sub, s, depth); s.push('^'); - lin_operand(sup, s, depth); + lin_script(sup, s, depth); s.push('▒'); lin_operand(base, s, depth); return; @@ -525,11 +540,11 @@ fn lin_node(n: &MNode, s: &mut String, depth: usize) { lin_operand(base, s, depth); if matches!(kind, ScriptKind::Sub | ScriptKind::SubSup) { s.push('_'); - lin_operand(sub, s, depth); + lin_script(sub, s, depth); } if matches!(kind, ScriptKind::Sup | ScriptKind::SubSup) { s.push('^'); - lin_operand(sup, s, depth); + lin_script(sup, s, depth); } } MNode::Rad { deg, deg_hide, e } => { @@ -548,11 +563,11 @@ fn lin_node(n: &MNode, s: &mut String, depth: usize) { s.push(*chr); if !sub_hide && !sub.is_empty() { s.push('_'); - lin_operand(sub, s, depth); + lin_script(sub, s, depth); } if !sup_hide && !sup.is_empty() { s.push('^'); - lin_operand(sup, s, depth); + lin_script(sup, s, depth); } s.push('▒'); lin_operand(e, s, depth); @@ -592,7 +607,7 @@ fn lin_node(n: &MNode, s: &mut String, depth: usize) { MNode::Lim { upper, e, lim } => { lin_operand(e, s, depth); s.push(if *upper { '┴' } else { '┬' }); - lin_operand(lim, s, depth); + lin_script(lim, s, depth); } MNode::Acc { chr, e } => { lin_operand(e, s, depth); @@ -945,6 +960,33 @@ mod tests { assert!(matches!(&n[2], MNode::Lim { e, lim, .. } if runs(e) == 1 && runs(lim) == 1), "{n:#?}"); } + #[test] + fn multi_letter_scripts_round_trip() { + // A script reads one letter or a number, so `V_dd` is V_d then d: longer scripts are grouped. + let script = |kind, sub: &str, sup: &str| MNode::Script { kind, base: vec![run("V")], sub: parse_linear(sub), sup: parse_linear(sup) }; + for n in [ + vec![script(ScriptKind::Sub, "dd", "")], + vec![script(ScriptKind::Sup, "", "ab")], + vec![script(ScriptKind::SubSup, "in", "2")], + vec![MNode::Nary { + chr: '∑', + lim_loc: None, + grow: false, + sub_hide: false, + sup_hide: false, + sub: vec![run("ij")], + sup: vec![run("n")], + e: vec![run("x")], + }], + vec![MNode::Lim { upper: false, e: vec![run("⟶")], lim: vec![run("yields")] }], + ] { + assert_eq!(parse_linear(&to_linear(&n)), n, "{}", to_linear(&n)); + } + // Numbers and single letters stay bare. + assert_eq!(to_linear(&[script(ScriptKind::SubSup, "10", "2.5")]), "V_10^2.5"); + assert_eq!(to_linear(&[script(ScriptKind::Sub, "d", "")]), "V_d"); + } + #[test] fn hostile_linear_never_panics() { for s in ["", "/", "^", "_", "√", "∑", "((((", "))))", "■(", "█(@@&&", "a/", "/b", "^^^__", "√(&)", "∫_^▒", "lim_", "sin"] { diff --git a/crates/doc/src/math_edit.rs b/crates/doc/src/math_edit.rs index 8290b8176..bc64d43cc 100644 --- a/crates/doc/src/math_edit.rs +++ b/crates/doc/src/math_edit.rs @@ -1199,6 +1199,21 @@ mod tests { assert!(matches!(eq[0], MNode::Frac { .. })); } + #[test] + fn build_up_keeps_a_multi_letter_subscript() { + // #1609: `dd` typed in a subscript box split into V_d and d on the next space. + let mut eq: Arg = Vec::new(); + let p = insert_nodes(&mut eq, &MathPos::default(), parse_linear("⬚_⬚")); + let p = insert_text(&mut eq, &p, "V", TypeStyle::default()); + let p = move_right(&eq, &p).unwrap(); + let p = insert_text(&mut eq, &p, "dd", TypeStyle::default()); + let p = move_right(&eq, &p).unwrap(); + assert_eq!(p, MathPos::new(vec![], 1)); + let built = eq.clone(); + assert_eq!(build_up(&mut eq, &p, false), None); + assert_eq!(eq, built, "{}", lin(&eq)); + } + #[test] fn backspace_dissolves_and_removes() { let mut eq = parse_linear("√x"); From 64e430354cef6230695922bf6a8473136b9c8186 Mon Sep 17 00:00:00 2001 From: Morzilla Date: Thu, 15 Oct 2026 01:51:35 +0200 Subject: [PATCH 024/101] Tabs left of the margin stay on a default stop they already sit on (Word) (#1631) Left of the left margin Word's next default tab stop is the first multiple of the interval *at or after* the current position, not after it: its arithmetic truncates toward zero. So a tab that already sits on a stop, a second tab in a row or a leading tab on a line that starts at -72 pt, doesn't advance; a tab preceded by text or a space, or a line starting between stops, moves to the next multiple as usual, and right of the margin nothing changes. Measured on Word with synthetic documents (40 positions): "a\t\tX" at -60 pt puts X at -36 pt where the old rule gave 0; "\tX" starting at -72 pt leaves X at -72, at -60 moves it to -36; "\t \tX" at -60 reaches 0. Letterheads that line a footer up with spaces and tabs under a negative indent rely on it: one had its registration number drawn 72 pt too far right, over a vertical rule. It now lands within 0.2 pt of Word. Co-authored-by: Daniele Mori Co-authored-by: Claude Fable 5.1 --- crates/layout/src/para.rs | 7 ++++++- crates/layout/src/tests.rs | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 1d2b0df07..ffc1093ae 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -1588,7 +1588,12 @@ fn next_tab(x: f32, tabs: &[TabStop], default_tab: f32, hanging_at: Option) } best.unwrap_or_else(|| { let d = if default_tab > 1.0 { default_tab } else { 36.0 }; - let n = ((x + 0.01) / d).floor() + 1.0; + // Left of the margin Word's arithmetic truncates toward zero: the next default stop is + // the first multiple of the interval *at or after* `x`, so a tab that already sits on + // one (a second tab in a row, or a line starting at -72 pt) stays put. Measured on Word + // 2016+: a line starting at -72 pt with a leading tab stays at -72, at -60 goes to -36; + // "a\t\tX" at -60 puts X at -36, not 0. Letterheads with negative indents rely on it. + let n = if x < -0.01 { ((x - 0.01) / d).ceil() } else { ((x + 0.01) / d).floor() + 1.0 }; TabStop { pos: (n * d).min(1e6), align: TabAlign::Left, leader: TabLeader::None } }) } diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 3b7abd4ba..9bedfead5 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -719,6 +719,25 @@ fn tabs_land_on_default_stops() { assert!((xb - (72.0 + 36.0)).abs() < 0.5, "{xb}"); } +/// Left of the margin Word's next default tab stop is the first multiple of the interval at or +/// after the position (its arithmetic truncates toward zero), so a tab already on a stop stays +/// put. Measured on Word: "a\t\tX" at -60 pt puts X at -36 pt (not 0), "\tX" starting at -72 pt +/// leaves X at -72, at -60 moves it to -36; right of the margin the stops are as usual. +/// Letterheads that line up a footer with tabs under a negative indent rely on it. +#[test] +fn tabs_left_of_the_margin_stay_on_a_default_stop() { + let x_of = |text: &str, indent: f32, off: usize| { + let mut d = Document::from_text(text); + d.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 0), &|p| p.indent_left = Some(indent)).unwrap(); + lay(&d).caret(&Pos::body(0, off)).unwrap().x - 72.0 + }; + assert!((x_of("a\t\tX", -60.0, 3) - -36.0).abs() < 0.5, "second tab from a stop left of the margin: {}", x_of("a\t\tX", -60.0, 3)); + assert!((x_of("\tX", -72.0, 1) - -72.0).abs() < 0.5, "leading tab on a stop: {}", x_of("\tX", -72.0, 1)); + assert!((x_of("\tX", -60.0, 1) - -36.0).abs() < 0.5, "leading tab between stops: {}", x_of("\tX", -60.0, 1)); + assert!((x_of("\t \tX", -60.0, 3) - 0.0).abs() < 0.5, "a space between the tabs: {}", x_of("\t \tX", -60.0, 3)); + assert!((x_of("a\t\tX", 0.0, 3) - 72.0).abs() < 0.5, "right of the margin: {}", x_of("a\t\tX", 0.0, 3)); +} + #[test] fn right_tab_aligns_text_end() { let mut d = Document::from_text("left\tright"); From 45709520696db6ae6287e16f42f5c15fd4249c39 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 19:54:31 -0400 Subject: [PATCH 025/101] CI: VML shape type 6 is a right triangle in the preset-mapping test #1599 made VML spt 6 (rtTriangle) a RightTriangle; #1442's mapping test still expected the isosceles Triangle, so it failed on main. Co-authored-by: Claude Opus 5.5 --- crates/docx/src/read/shape_style.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/docx/src/read/shape_style.rs b/crates/docx/src/read/shape_style.rs index bb68c23fe..9e5f3b8e1 100644 --- a/crates/docx/src/read/shape_style.rs +++ b/crates/docx/src/read/shape_style.rs @@ -235,7 +235,7 @@ mod tests { // The nearest shape, as `preset_kind` gives it: left arrow, right triangle, four-point // star, rounded callout, elbow connector, a bevel (its box). assert_eq!(vml_preset_kind(66), Some(ShapeKind::Arrow)); - assert_eq!(vml_preset_kind(6), Some(ShapeKind::Triangle)); + assert_eq!(vml_preset_kind(6), Some(ShapeKind::RightTriangle)); assert_eq!(vml_preset_kind(187), Some(ShapeKind::Star)); assert_eq!(vml_preset_kind(62), Some(ShapeKind::RoundedRectangle)); assert_eq!(vml_preset_kind(34), Some(ShapeKind::ElbowConnector)); From 3b16face4987039a6618612d8d06cff8021dca46 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 20:00:19 -0400 Subject: [PATCH 026/101] CI: the rtTriangle VML fixture draws a right triangle Follow-up to #1900: the fixture test vml_preset_shapes_are_drawn also expected the isosceles triangle for VML shape type 6 (#1599). Co-authored-by: Claude Opus 5.5 --- crates/docx/tests/fixtures.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/docx/tests/fixtures.rs b/crates/docx/tests/fixtures.rs index 2cbb81360..b4189c8aa 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/fixtures.rs @@ -1924,7 +1924,7 @@ fn vml_preset_shapes_are_drawn() { assert_eq!(shape(3), Some((ShapeKind::Triangle, 20.0, 10.0, None, black, 0.75, true, false))); // A preset holding text keeps its geometry around the text. assert_eq!(shape(4), Some((ShapeKind::Diamond, 50.0, 50.0, Some(Rgb(0xFF, 0xFF, 0)), black, 0.75, true, true))); - assert_eq!(shape(5), Some((ShapeKind::Triangle, 20.0, 20.0, Some(Rgb(0xFF, 0, 0)), black, 0.75, true, false))); + assert_eq!(shape(5), Some((ShapeKind::RightTriangle, 20.0, 20.0, Some(Rgb(0xFF, 0, 0)), black, 0.75, true, false))); assert_eq!(shape(6), None); assert_eq!(shape(7), Some((ShapeKind::Line, 40.0, 10.0, None, black, 0.75, true, false))); assert_eq!(shape(8), Some((ShapeKind::Line, 30.0, 15.0, None, Some(Rgb(0xFF, 0, 0)), 0.75, false, false))); From c2872f23c3979685358a752897ea60c892317476 Mon Sep 17 00:00:00 2001 From: Fin Warman Date: Thu, 15 Oct 2026 01:04:14 +0100 Subject: [PATCH 027/101] Layout: a page break followed by another page start makes one page (#1611) A paragraph holding only a page break keeps its mark on the page before (#932), but its space after still went onto the new page, so a heading with page break before no longer stood at the top and broke again: a blank page. The space after now stays with the mark. A next-page section break carried by such a paragraph also started a second page; the empty page the break started is now used instead. Found on a 60-page inspection report: two blank pages Word doesn't have. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/layout/src/lib.rs | 23 +++++++++++++++++++++++ crates/layout/src/tests.rs | 22 ++++++++++++++++++++++ docs/layout-parity.md | 3 ++- 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 6d2db80f8..e20023aab 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -1808,6 +1808,26 @@ impl PageBuilder<'_> { } } } + /// Takes back the page a page break has just started while nothing is on it, so a page start + /// that follows (a next-page section) doesn't leave it blank: Word keeps the break's mark on + /// the page before, and starts one page. + fn take_back_break_page(&mut self) -> bool { + let empty = self.after_break_mark + && self.at_top() + && self.notes.is_empty() + && self.pages.len() > 1 + && self.pages.last().is_some_and(|p| p.items.len() == self.page_items_start); + if !empty { + return false; + } + self.pages.pop(); + self.behind.pop(); + self.number = self.number.saturating_sub(1); + self.after_break_mark = false; + // The page before was aligned when it was left: nothing on it moves again. + self.page_items_start = self.pages.last().map_or(0, |p| p.items.len()); + true + } fn new_page(&mut self, first_block: usize, body_top: f32) { self.apply_valign(); self.flush_notes(); @@ -2125,6 +2145,9 @@ fn layout_pass( // one long page } else if pb.pages.is_empty() || start != SectionStart::Continuous || turn != prev_turn { // A page holds text running one way: a section turning the other way starts a page. + if !web { + pb.take_back_break_page(); + } let s = &doc.settings; let (filler, restart) = section_side(start, restart, pb.number, pb.pages.len(), s.even_odd_headers, s.mirror_margins); // The blank page that puts the section on the right side ends the previous section. diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 9bedfead5..b8f5f88aa 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -688,6 +688,28 @@ fn page_break_char_starts_new_page() { assert_eq!(l.pages.len(), 2); } +#[test] +fn a_page_break_then_a_new_page_makes_one_page() { + use wordcraft_doc::section::SectionProps; + // "Before", a paragraph of only a page break, then "After" starting a page by itself: a page + // break before, or a next-page section break carried by the break's own paragraph. Word's + // output for a report: the break's mark stays on the old page, and "After" starts the next. + let doc = |section_on_break: bool| { + let mut d = Document::from_text("Before\n\u{000C}\nAfter"); + if section_on_break { + d.para_mut(StoryRef::Body, &Path::top(1)).unwrap().section = Some(Box::new(SectionProps::default())); + } else { + d.para_mut(StoryRef::Body, &Path::top(2)).unwrap().props.page_break_before = Some(true); + } + d + }; + for section_on_break in [false, true] { + let l = lay(&doc(section_on_break)); + let firsts: Vec = l.pages.iter().map(|p| p.first_block).collect(); + assert_eq!(l.pages.len(), 2, "section break on the page break: {section_on_break}; pages start at blocks {firsts:?}"); + } +} + #[test] fn page_numbers_can_restart_at_zero() { // `w:pgNumType w:start="0"`: a cover page numbered 0, so the page after it reads 1. diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 750b3a3de..7350d9070 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -1,6 +1,6 @@ # Layout and pagination parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 A document only "opens correctly" if its pages break where Word breaks them: the same lines, the same page count, footnotes and pictures on the same pages. This checklist covers line breaking, @@ -159,6 +159,7 @@ No overall parity estimate changes from these probes. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | A paragraph of only a page break followed by a page break before, or carrying a next-page section break, starts one page, not two: the break's mark and its space after stay on the page before, as in Word | | 2026-10-13 | trivial | Justified vertical page alignment (#1320): the page's spare room goes equally between its blocks; a section's last page takes its own section's vertical alignment, not the next one's | | 2026-10-13 | trivial | Floats and floating tables aligned inside/outside, or placed in the inside/outside margin, swap sides on even pages; with mirrored margins, page-relative floats keep their place and left/right margin areas are each page's own (#1181) | | 2026-10-14 | trivial | Widow control: a three-line paragraph with room for two of its lines at the bottom of a page moves whole, as in Word (no two-and-one split leaving a widow); a paragraph continued from the previous page may still take one line along from two | From 241743b4ab4329c575ae47647d58ffa2f0fbaac5 Mon Sep 17 00:00:00 2001 From: Fin Warman Date: Thu, 15 Oct 2026 01:08:02 +0100 Subject: [PATCH 028/101] Layout: a bookmark after a page break takes no line (#1615) Word writes the next heading's TOC bookmark start into the paragraph that ends with a page break. The line after the break then held that marker, so it wasn't the empty mark kept with the break (#932): it went onto the next page with the paragraph's spacing and pushed the heading 17 pt down. Lines holding only zero-width markers (InlineObject::is_marker) now count as empty there. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/layout/src/lib.rs | 9 ++++++++- crates/layout/src/tests.rs | 22 ++++++++++++++++++++++ docs/layout-parity.md | 3 ++- 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index e20023aab..09c167117 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -3252,7 +3252,7 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: l1 += 1; if matches!(l.end, LineEnd::PageBreak | LineEnd::ColumnBreak) && !pb.web { forced_break = Some(l.end); - if l.end == LineEnd::PageBreak && l1 + 1 == n && pl.lines.get(l1).is_some_and(|m| m.start >= m.stop) { + if l.end == LineEnd::PageBreak && l1 + 1 == n && pl.lines.get(l1).is_some_and(|m| only_markers(p, m.start..m.stop)) { l1 += 1; mark_with_break = true; } @@ -3418,6 +3418,13 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: pb.prev = Some((pl.rp.style.clone(), pl.rp.contextual_spacing, space_after, pl.rp.after_auto, label.is_some())); } +/// Whether `range` of `p`'s text holds nothing but zero-width markers (bookmarks, field and +/// control boundaries…), or nothing at all: a line of them takes no room after a page break. +fn only_markers(p: &Paragraph, range: std::ops::Range) -> bool { + let Some(text) = p.text.get(range.clone()) else { return false }; + text.char_indices().all(|(i, c)| c == wordcraft_doc::para::OBJ && p.object_at(range.start + i).is_some_and(InlineObject::is_marker)) +} + /// The outline of the body for an Outline view pass. struct OutlinePass { view: outline::OutlineView, diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index b8f5f88aa..fb8a78579 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -688,6 +688,28 @@ fn page_break_char_starts_new_page() { assert_eq!(l.pages.len(), 2); } +#[test] +fn a_bookmark_after_a_page_break_takes_no_line() { + // "Before", a page break, then a bookmark start (Word writes the next heading's TOC bookmark + // there): the bookmark takes no room, so "After" starts at the top of page 2, as in Word. + let top_of_after = |bookmark: bool| { + let mut d = Document::from_text("Before\nAfter"); + d.insert_text(&Pos::body(0, 6), "\u{000C}", &Default::default()).unwrap(); + if bookmark { + let mark = InlineObject::BookmarkStart { name: "_Toc1".into() }; + d.insert_object(&Pos::body(0, 7), mark, &Default::default()).unwrap(); + } + let l = lay(&d); + let page = l.pages.last().unwrap(); + let after = page.items.iter().find_map(|i| match i { + Placed::Lines { path, y, .. } if path.0 == [1] => Some(*y), + _ => None, + }); + (l.pages.len(), after.map(|y| y - page.body.y)) + }; + assert_eq!(top_of_after(true), top_of_after(false)); +} + #[test] fn a_page_break_then_a_new_page_makes_one_page() { use wordcraft_doc::section::SectionProps; diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 7350d9070..76a4e6478 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -1,6 +1,6 @@ # Layout and pagination parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 A document only "opens correctly" if its pages break where Word breaks them: the same lines, the same page count, footnotes and pictures on the same pages. This checklist covers line breaking, @@ -159,6 +159,7 @@ No overall parity estimate changes from these probes. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | A bookmark (or other zero-width marker) after a paragraph's closing page break stays with the break instead of taking a line at the top of the next page | | 2026-10-14 | trivial | A paragraph of only a page break followed by a page break before, or carrying a next-page section break, starts one page, not two: the break's mark and its space after stay on the page before, as in Word | | 2026-10-13 | trivial | Justified vertical page alignment (#1320): the page's spare room goes equally between its blocks; a section's last page takes its own section's vertical alignment, not the next one's | | 2026-10-13 | trivial | Floats and floating tables aligned inside/outside, or placed in the inside/outside margin, swap sides on even pages; with mirrored margins, page-relative floats keep their place and left/right margin areas are each page's own (#1181) | From 2444e64a25deee82c87d188ceb5a8bcb5480955c Mon Sep 17 00:00:00 2001 From: Fin Warman Date: Thu, 15 Oct 2026 01:11:50 +0100 Subject: [PATCH 029/101] Layout: a numbered paragraph opening with a page break takes its number along (#1616) A heading whose text opens with a page break had its list number drawn on the line holding the break, so the number stood alone at the bottom of one page and the heading text started the next. Word draws the number with the text. The label now goes on the first line after a leading break. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/layout/src/display.rs | 2 +- crates/layout/src/para.rs | 10 ++++++++++ crates/layout/src/tests.rs | 20 ++++++++++++++++++++ docs/layout-parity.md | 3 ++- 4 files changed, 33 insertions(+), 2 deletions(-) diff --git a/crates/layout/src/display.rs b/crates/layout/src/display.rs index e271c7fab..c86b08b3e 100644 --- a/crates/layout/src/display.rs +++ b/crates/layout/src/display.rs @@ -668,7 +668,7 @@ fn lines( char_box(&ob, x0, x1, top, bottom, alpha, out); } // Label. - if li == 0 + if li == pl.label_line() && let Some(lab) = &pl.label { let gx = x + lab.x; diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index ffc1093ae..7d119b177 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -2484,6 +2484,16 @@ fn resolve_tab(pl: &mut ParaLayout, pending: &mut Option<(usize, TabStop, f32)>, } impl ParaLayout { + /// The line the list label is drawn on: the first, unless the paragraph opens with a page + /// break, when Word draws it with the text on the next page. + pub fn label_line(&self) -> usize { + let opens_with_break = self.lines.len() > 1 + && self.lines.first().is_some_and(|l| { + l.end == LineEnd::PageBreak + && self.clusters.get(l.c0..l.c1).is_some_and(|cs| cs.iter().all(|c| matches!(c.kind, ClKind::PageBreak | ClKind::Marker))) + }); + usize::from(opens_with_break) + } /// x of byte offset `off` within line `li` (clamped to the line). pub fn x_of(&self, li: usize, off: usize) -> Option { let l = self.lines.get(li)?; diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index fb8a78579..b64d8e83f 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -810,6 +810,26 @@ fn lists_get_labels_and_indent() { assert!((x - 108.0).abs() < 0.5, "{x}"); } +#[test] +fn a_numbered_heading_opening_with_a_page_break_takes_its_number_along() { + // "Before", then a numbered paragraph whose text opens with a page break: Word draws its + // number on the next page with the text, not alone at the bottom of the first. + let mut d = Document::from_text("Before\n\u{000C}Heading"); + let id = d.numbering.add_list(wordcraft_doc::ListKind::Numbered); + d.format_paragraphs(&Pos::body(1, 0), &Pos::body(1, 1), &|p| p.numbering = Some(wordcraft_doc::props::NumRef { num: id, level: 0 })).unwrap(); + let l = lay(&d); + let label_pages: Vec = l + .pages + .iter() + .enumerate() + .filter(|(_, pg)| { + flat(display::page_display(&d, pg, &Default::default())).iter().any(|x| matches!(x, display::Draw::Glyphs { text, .. } if text == "1.")) + }) + .map(|(i, _)| i) + .collect(); + assert_eq!(label_pages, [1]); +} + #[test] fn russian_letter_list_labels_draw_cyrillic() { // russianLower labelled items 1, 2, 3 instead of а), б), в). diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 76a4e6478..ce416673d 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -1,6 +1,6 @@ # Layout and pagination parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 A document only "opens correctly" if its pages break where Word breaks them: the same lines, the same page count, footnotes and pictures on the same pages. This checklist covers line breaking, @@ -159,6 +159,7 @@ No overall parity estimate changes from these probes. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | A numbered paragraph whose text opens with a page break draws its number on the next page with the text, as Word does | | 2026-10-14 | trivial | A bookmark (or other zero-width marker) after a paragraph's closing page break stays with the break instead of taking a line at the top of the next page | | 2026-10-14 | trivial | A paragraph of only a page break followed by a page break before, or carrying a next-page section break, starts one page, not two: the break's mark and its space after stay on the page before, as in Word | | 2026-10-13 | trivial | Justified vertical page alignment (#1320): the page's spare room goes equally between its blocks; a section's last page takes its own section's vertical alignment, not the next one's | From fee9b984eb902171874afb9701cc55e48beaab30 Mon Sep 17 00:00:00 2001 From: Fin Warman Date: Thu, 15 Oct 2026 01:15:39 +0100 Subject: [PATCH 030/101] Tables: text running past an exact-height row is cut off (#1617) * Tables: text running past an exact-height row is cut off A row with an exact height (w:trHeight w:hRule="exact") keeps that height whatever its cells hold. Word cuts the text that doesn't fit at the cell's edge; WordCraft drew it over the rows below, so a certificate's identification block came out as overlapping lines. The cell is marked as clipped in layout (Placed::Cell clip), and display draws its lines inside that cell's clip, on screen and in PDF. Hit testing is unchanged: the lines stay where they are in the layout. Co-Authored-By: Claude Opus 5.5 * Tables: keep row clipping compatible with Rust 1.90 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/engine/src/cmd/table_mouse.rs | 2 +- crates/layout/src/display.rs | 30 ++++++++++++++++++++++++++++ crates/layout/src/hit.rs | 2 +- crates/layout/src/lib.rs | 3 +++ crates/layout/src/table.rs | 7 +++++-- crates/layout/src/tests.rs | 26 ++++++++++++++++++++++++ crates/pdf/src/lib.rs | 10 ++++++++-- docs/layout-parity.md | 3 ++- 8 files changed, 76 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/table_mouse.rs b/crates/engine/src/cmd/table_mouse.rs index abc600b77..38ac1409d 100644 --- a/crates/engine/src/cmd/table_mouse.rs +++ b/crates/engine/src/cmd/table_mouse.rs @@ -241,7 +241,7 @@ pub fn cell_rects(l: &DocLayout, doc: &Document, range: &CellRange) -> Vec<(usiz for it in &pg.items { // A vertically merged cell's box spans its rows (#640); its continuation rows' // `covered` boxes would shade it twice. - if let Placed::Cell { rect, table, row, cell, story, covered: false } = it + if let Placed::Cell { rect, table, row, cell, story, covered: false, .. } = it && *story == range.story && *table == range.table && picked.contains(&(*row, *cell)) diff --git a/crates/layout/src/display.rs b/crates/layout/src/display.rs index c86b08b3e..3fc994cab 100644 --- a/crates/layout/src/display.rs +++ b/crates/layout/src/display.rs @@ -348,8 +348,17 @@ pub fn page_display(doc: &Document, page: &Page, opts: &DisplayOptions) -> Vec = Vec::new(); for it in items.iter() { + if let Placed::Lines { story, path, .. } = it + && let Some(rect) = cell_clip(&clips, *story, &path.0) + { + let mut inner = Vec::new(); + item(doc, it, opts, a, &mut inner); + out.push(Draw::Clip { rect, items: inner }); + continue; + } match it { Placed::Lines { story: StoryRef::Part(id), .. } if warps.iter().any(|(w, ..)| w == id) => { // A warped text box's lines are drawn together, bent, where its first one is. @@ -390,6 +399,27 @@ pub fn object_display(doc: &Document, page: &Page, o: &InlineObject, rect: Rect, out } +/// The cells among `items` whose content is cut off at their edge (see [`Placed::Cell`]'s +/// `clip`): their story, the path of their content (table, row, cell) and their area. +fn clipped_cells(items: &[Placed]) -> Vec<(StoryRef, Vec, Rect)> { + items + .iter() + .filter_map(|it| match it { + Placed::Cell { rect, table, row, cell, story, clip: true, .. } => { + let mut prefix = table.0.clone(); + prefix.extend([u32::try_from(*row).ok()?, u32::try_from(*cell).ok()?]); + Some((*story, prefix, *rect)) + } + _ => None, + }) + .collect() +} + +/// The area to clip a line at `path` of `story` to: its cell's, when that cell is clipped. +fn cell_clip(clips: &[(StoryRef, Vec, Rect)], story: StoryRef, path: &[u32]) -> Option { + clips.iter().find(|(s, prefix, _)| *s == story && path.starts_with(prefix)).map(|(_, _, r)| *r) +} + /// The text boxes among `items` whose text is warped (Text Effects › Transform, drawn bent): their /// story, frame and warp. Empty, without a walk of the document, when none is. fn warped_boxes(doc: &Document, items: &[Placed]) -> Vec<(u32, Rect, wordcraft_doc::wordart::TextWarp)> { diff --git a/crates/layout/src/hit.rs b/crates/layout/src/hit.rs index 47cd5cc02..6084745b7 100644 --- a/crates/layout/src/hit.rs +++ b/crates/layout/src/hit.rs @@ -754,7 +754,7 @@ impl DocLayout { pub fn cell_at(&self, page: usize, x: f32, y: f32) -> Option<(StoryRef, Path, usize, usize)> { let p = self.pages.get(page)?; p.items.iter().rev().find_map(|it| match it { - Placed::Cell { rect, table, row, cell, story, covered: false } if rect.contains(wordcraft_geom::Point::new(x, y)) => { + Placed::Cell { rect, table, row, cell, story, covered: false, .. } if rect.contains(wordcraft_geom::Point::new(x, y)) => { Some((*story, table.clone(), *row, *cell)) } _ => None, diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 09c167117..2f1bfa39e 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -270,6 +270,9 @@ pub enum Placed { row: usize, cell: usize, story: StoryRef, + /// Its row is exactly as tall as it says and the content is taller: what runs past the + /// cell is cut off when drawn, as in Word. + clip: bool, }, /// The area of a picture, shape or text box, for hit testing, selection handles and dragging /// (drawn by `Image`/`Shape`/`Lines`). The object is the U+FFFC at byte `off` of paragraph diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index 854d3b059..9462ae7c8 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -646,6 +646,7 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava let mut out = Vec::with_capacity(rows.len()); for (ri, cells) in rows.into_iter().enumerate() { let rh = heights.get(ri).copied().unwrap_or(0.0); + let exact = t.rows.get(ri).is_some_and(|r| r.props.height_rule == HeightRule::Exact && r.props.height.is_some_and(|h| h > 0.0)); let mut items = Vec::new(); let mut parts = Vec::with_capacity(cells.len()); for (ci, c) in cells.into_iter().enumerate() { @@ -737,6 +738,7 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava row: ri, cell: ci, story, + clip: exact && c.h > span_h + 0.01, }); parts.push(CellPart { items: first..items.len(), end }); } @@ -914,9 +916,10 @@ pub fn split_row(row: &RowLayout, cut: f32) -> Option<(RowLayout, RowLayout)> { a.extend(above.map(|rect| Placed::Fill { rect, color: *color })); b.extend(below.map(|rect| Placed::Fill { rect, color: *color })); } - Placed::Cell { rect, covered, table, row: r, cell, story } => { + Placed::Cell { rect, covered, table, row: r, cell, story, clip } => { let (above, below) = split_rect(*rect, cut, geo_shift(rect.bottom(), shift)); - let cell_at = |rect| Placed::Cell { rect, covered: *covered, table: table.clone(), row: *r, cell: *cell, story: *story }; + let cell_at = + |rect| Placed::Cell { rect, covered: *covered, table: table.clone(), row: *r, cell: *cell, story: *story, clip: *clip }; a.extend(above.map(cell_at)); b.extend(below.map(cell_at)); } diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index b64d8e83f..7054e96e8 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -4145,6 +4145,32 @@ fn autofit_handles_spanning_cells_long_words_and_empty_tables() { assert!(x > 72.0 + 200.0, "the second 50% column starts at {x}"); } +#[test] +fn text_overflowing_an_exact_row_is_clipped_to_it() { + use wordcraft_doc::props::HeightRule; + // An exactly 12 pt row holding three lines: Word shows what fits and cuts the rest at the row's + // edge, rather than drawing it over the rows below. + let mut t = Table::new(2, 1, 200.0); + t.rows[0].props.height = Some(12.0); + t.rows[0].props.height_rule = HeightRule::Exact; + t.rows[0].cells[0].blocks = + ["one", "two", "three"].iter().map(|s| wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(s, Default::default()))).collect(); + t.rows[1].cells[0].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("below", Default::default()))]; + let mut d = Document::from_text("After"); + d.insert_block(StoryRef::Body, &Path::top(0), wordcraft_doc::Block::Table(t)).unwrap(); + let draws = display::page_display(&d, &lay(&d).pages[0], &Default::default()); + let clipped = |want: &str| { + draws.iter().any(|x| match x { + display::Draw::Clip { items, .. } => { + flat(items.clone()).iter().any(|i| matches!(i, display::Draw::Glyphs { text, .. } if text.contains(want))) + } + _ => false, + }) + }; + assert!(clipped("three"), "the overflowing row's text is drawn inside a clip"); + assert!(!clipped("below"), "a row that fits isn't clipped"); +} + #[test] fn a_percentage_width_past_the_text_column_is_kept() { // `w:tblW w:type="pct" w:w="6564"` (131.28%) with the left margin as a negative indent: a diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 7f8ec1302..5b302b13a 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -837,8 +837,14 @@ impl Exporter<'_> { /// Display items for one placed item. fn draws(&self, page: &Page, it: &Placed, dopts: &DisplayOptions) -> Vec { - let one = - Page { w: page.w, h: page.h, section: page.section, number: page.number, items: vec![it.clone()], body: page.body, ..Default::default() }; + let mut items = vec![it.clone()]; + // Lines in a cell cut off at its edge are drawn inside its clip: `page_display` finds the + // cell among the items (cells themselves draw nothing). + if matches!(it, Placed::Lines { .. }) { + let clipped = page.header.iter().chain(&page.footer).chain(&page.items).filter(|c| matches!(c, Placed::Cell { clip: true, .. })); + items.extend(clipped.cloned()); + } + let one = Page { w: page.w, h: page.h, section: page.section, number: page.number, items, body: page.body, ..Default::default() }; page_display(self.doc, &one, dopts) } diff --git a/docs/layout-parity.md b/docs/layout-parity.md index ce416673d..28590010a 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -1,6 +1,6 @@ # Layout and pagination parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 A document only "opens correctly" if its pages break where Word breaks them: the same lines, the same page count, footnotes and pictures on the same pages. This checklist covers line breaking, @@ -159,6 +159,7 @@ No overall parity estimate changes from these probes. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Text that doesn't fit an exact-height table row is cut off at the cell's edge, as in Word, instead of drawn over the rows below | | 2026-10-14 | trivial | A numbered paragraph whose text opens with a page break draws its number on the next page with the text, as Word does | | 2026-10-14 | trivial | A bookmark (or other zero-width marker) after a paragraph's closing page break stays with the break instead of taking a line at the top of the next page | | 2026-10-14 | trivial | A paragraph of only a page break followed by a page break before, or carrying a next-page section break, starts one page, not two: the break's mark and its space after stay on the page before, as in Word | From 1b607731962c2f2854345f50e8aac1d899232575 Mon Sep 17 00:00:00 2001 From: Fin Warman Date: Thu, 15 Oct 2026 01:19:27 +0100 Subject: [PATCH 031/101] Tables: a row with less room than its minimum height moves on whole (#1618) * Tables: a row with less room than its minimum height moves on whole Measured in Word with about 49 pt left on a page: a table row of eight lines splits there, but the same row with an at-least height of 63.6 pt goes to the next page whole, and so does one holding a nested table. Without the height both split, as before. WordCraft split them all, so a report's title row (a logo beside a small nested table) started at the foot of its cover page. Co-Authored-By: Claude Opus 5.5 * Tables: preserve splitting for oversized minimum-height rows --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/layout/src/lib.rs | 10 +++++++++- crates/layout/src/tests.rs | 30 ++++++++++++++++++++++++++++-- docs/layout-parity.md | 3 ++- 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 2f1bfa39e..c5cfbadf8 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -3630,7 +3630,15 @@ fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, bl if pb.y + cur.height <= pb.bottom + 0.01 { break; } - let split = if splittable { table::split_row(cur, pb.bottom - pb.y) } else { None }; + // Measured in Word: a row with an at-least height doesn't split where less than that + // height is left; it starts the next page whole. + let room = pb.bottom - pb.y; + let short_of_min = rest.is_none() + && t.rows.get(ri).is_some_and(|r| { + r.props.height_rule == wordcraft_doc::props::HeightRule::AtLeast + && r.props.height.is_some_and(|h| h > room + 0.01 && h <= pb.bottom - body_top + 0.01) + }); + let split = if splittable && !short_of_min { table::split_row(cur, room) } else { None }; match split { Some((a, b)) => { place(pb, &a); diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 7054e96e8..db6097daa 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -2160,7 +2160,8 @@ fn split_row_cells_each_continue_at_the_top() { } /// Word never splits a row of an exact height: one the page bottom cuts through, its text -/// included, moves to the next page whole (an "at least" row of that height splits). +/// included, moves to the next page whole. An at-least row also moves when the remaining +/// room is less than its minimum height. #[test] fn exact_rows_move_on_whole() { let text = "Dotted leader lines to fill in by hand later on. ".repeat(14); @@ -2193,7 +2194,8 @@ fn exact_rows_move_on_whole() { assert_eq!(exact.len(), 2); assert_eq!(exact[0], 0, "the exact row moves on whole: {exact:?}"); let at_least = lay_row(wordcraft_doc::props::HeightRule::AtLeast); - assert!(at_least[0] > 0 && at_least.get(1).is_some_and(|n| *n > 0), "an at-least row splits: {at_least:?}"); + assert_eq!(at_least[0], 0, "less than the minimum height remains: {at_least:?}"); + assert!(at_least.get(1).is_some_and(|n| *n > 0), "the at-least row moves: {at_least:?}"); } /// A nested table's cell the page bottom cuts through goes on at the top of the next page with @@ -4145,6 +4147,30 @@ fn autofit_handles_spanning_cells_long_words_and_empty_tables() { assert!(x > 72.0 + 200.0, "the second 50% column starts at {x}"); } +#[test] +fn a_row_with_less_room_than_its_minimum_height_moves_on_whole() { + use wordcraft_doc::props::HeightRule; + // Measured in Word: with 49 pt left on the page, a row of eight lines splits, but the same row + // with an at-least height of 63.6 pt goes to the next page whole. + let first_row_line_page = |min: Option| { + let lines: Vec = (1..=200).map(|i| format!("Filler {i}")).collect(); + let one_page = lay(&Document::from_text(&lines.join("\n"))).pages[0].items.iter().filter(|i| matches!(i, Placed::Lines { .. })).count(); + let mut t = Table::new(1, 1, 400.0); + t.rows[0].props.height = min; + t.rows[0].props.height_rule = HeightRule::AtLeast; + t.rows[0].cells[0].blocks = + (1..=8).map(|k| wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&format!("Row line {k}"), Default::default()))).collect(); + // Fill the page, leaving two filler paragraphs' room (less than 63.6 pt) for the row. + let mut d = Document::from_text(&lines[..one_page - 2].join("\n")); + let end = d.body.len(); + d.insert_block(StoryRef::Body, &Path::top(end), wordcraft_doc::Block::Table(t)).unwrap(); + let l = lay(&d); + l.pages.iter().position(|p| p.items.iter().any(|i| matches!(i, Placed::Lines { path, .. } if path.0.first() == Some(&(end as u32))))) + }; + assert_eq!(first_row_line_page(None), Some(0), "a row without a minimum height splits"); + assert_eq!(first_row_line_page(Some(63.6)), Some(1), "a row with a minimum height that doesn't fit moves on"); +} + #[test] fn text_overflowing_an_exact_row_is_clipped_to_it() { use wordcraft_doc::props::HeightRule; diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 28590010a..f68dcf125 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -1,6 +1,6 @@ # Layout and pagination parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 A document only "opens correctly" if its pages break where Word breaks them: the same lines, the same page count, footnotes and pictures on the same pages. This checklist covers line breaking, @@ -159,6 +159,7 @@ No overall parity estimate changes from these probes. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | A table row with an at-least height doesn't split where less than that height is left on the page; it starts the next page whole, as measured in Word | | 2026-10-14 | trivial | Text that doesn't fit an exact-height table row is cut off at the cell's edge, as in Word, instead of drawn over the rows below | | 2026-10-14 | trivial | A numbered paragraph whose text opens with a page break draws its number on the next page with the text, as Word does | | 2026-10-14 | trivial | A bookmark (or other zero-width marker) after a paragraph's closing page break stays with the break instead of taking a line at the top of the next page | From 6d5cc9ef84a428b65b32a997164f05f591ae78ad Mon Sep 17 00:00:00 2001 From: Fin Warman Date: Thu, 15 Oct 2026 01:23:15 +0100 Subject: [PATCH 032/101] Layout: an empty section mark stays on a full page (#1614) * Layout: an empty section mark stays on a full page An empty paragraph that carries a section break, when the next section starts on a new page, needs no room of its own in Word. Measured: 50 lines filling a page, then that paragraph, then the next section makes two pages in Word; WordCraft made three, the middle one blank. A plain empty paragraph in the same place still moves on, in both. Found after a landscape drawing page in a 60-page report. Co-Authored-By: Claude Opus 5.5 * Layout: drop an unneeded mut in the section mark test Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/layout/src/lib.rs | 10 +++++++++- crates/layout/src/tests.rs | 23 +++++++++++++++++++++++ docs/layout-parity.md | 3 ++- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index c5cfbadf8..41ca30891 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -1403,6 +1403,9 @@ struct PageBuilder<'a> { /// The previous paragraph ended with a page break (its mark stayed with the break): the next /// paragraph, first on the new page, gets no space before (Word). after_break_mark: bool, + /// The next section starts on a new page: this section's closing mark, when empty, needs no + /// room (Word leaves it on a full page rather than starting a page for it). + next_starts_page: bool, /// The number the next page takes instead of counting on: a section restarting its /// numbering, possibly at 0 (a cover page numbered 0). restart_at: Option, @@ -2071,6 +2074,7 @@ fn layout_pass( orig_bottom: 0.0, prev: None, after_break_mark: false, + next_starts_page: false, restart_at: None, excl: Vec::new(), float_tables: Vec::new(), @@ -2192,6 +2196,7 @@ fn layout_pass( && sect.valign == wordcraft_doc::section::PageVAlign::Top && sections.get(si + 1).is_some_and(|(_, s)| s.start == SectionStart::Continuous); pb.col_break_page = None; + pb.next_starts_page = !web && sections.get(si + 1).is_some_and(|(_, s)| s.start != SectionStart::Continuous); let mut from = balance.then(|| (block, Snap::take(&mut pb, &ctx))); while block <= last { let Some(b) = doc.body.get(block) else { break }; @@ -3248,7 +3253,10 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: && p.text .get(l.start..l.stop) .is_some_and(|t| t.chars().all(|c| matches!(c, wordcraft_doc::para::PAGE_BREAK | wordcraft_doc::para::COLUMN_BREAK))); - if over && l1 == l0 && !pb.at_top() && !split && !only_break { + // An empty paragraph closing a section before a new page: measured in Word, it stays + // on a full page instead of starting one of its own. + let closing_mark = pb.next_starts_page && p.section.is_some() && p.text.is_empty(); + if over && l1 == l0 && !pb.at_top() && !split && !only_break && !closing_mark { break; } new_notes.extend(line_notes); diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index db6097daa..cc0926307 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -688,6 +688,29 @@ fn page_break_char_starts_new_page() { assert_eq!(l.pages.len(), 2); } +#[test] +fn an_empty_section_mark_stays_on_a_full_page() { + use wordcraft_doc::section::SectionProps; + // Measured in Word: a page filled to the bottom, then an empty paragraph carrying a next-page + // section break, makes no page for the mark; the next section starts on page 2. A plain empty + // paragraph moves on as usual. + let pages = |carries_section: bool| { + let lines: Vec = (1..=200).map(|i| format!("Line {i}")).collect(); + let d = Document::from_text(&lines.join("\n")); + let one_page = lay(&d).pages[0].items.iter().filter(|i| matches!(i, Placed::Lines { .. })).count(); + let mut d = Document::from_text(&format!("{}\n\nNext", lines[..one_page].join("\n"))); + let mark = d.para_mut(StoryRef::Body, &Path::top(one_page)).unwrap(); + if carries_section { + mark.section = Some(Box::new(SectionProps::default())); + } else { + d.para_mut(StoryRef::Body, &Path::top(one_page + 1)).unwrap().props.page_break_before = Some(true); + } + lay(&d).pages.len() + }; + assert_eq!(pages(true), 2); + assert_eq!(pages(false), 3); +} + #[test] fn a_bookmark_after_a_page_break_takes_no_line() { // "Before", a page break, then a bookmark start (Word writes the next heading's TOC bookmark diff --git a/docs/layout-parity.md b/docs/layout-parity.md index f68dcf125..3332d4009 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -1,6 +1,6 @@ # Layout and pagination parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 A document only "opens correctly" if its pages break where Word breaks them: the same lines, the same page count, footnotes and pictures on the same pages. This checklist covers line breaking, @@ -159,6 +159,7 @@ No overall parity estimate changes from these probes. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | An empty paragraph closing a section before a new page stays on a full page instead of starting a page of its own, as measured in Word | | 2026-10-14 | trivial | A table row with an at-least height doesn't split where less than that height is left on the page; it starts the next page whole, as measured in Word | | 2026-10-14 | trivial | Text that doesn't fit an exact-height table row is cut off at the cell's edge, as in Word, instead of drawn over the rows below | | 2026-10-14 | trivial | A numbered paragraph whose text opens with a page break draws its number on the next page with the text, as Word does | From f3f110b442a0943f31acc5cb0f04abb5b0e3126b Mon Sep 17 00:00:00 2001 From: Fin Warman Date: Thu, 15 Oct 2026 01:25:49 +0100 Subject: [PATCH 033/101] Layout: an inherited footer sizes its percentage tables by its own section (#1620) A header or footer a section inherits (Same as Previous) keeps the percentage widths of its tables relative to the section that defines it. Measured in Word: a 100%-wide footer table defined by a portrait section stays 451 pt wide, centred, on landscape pages that inherit it, and spans the landscape width only when that section names the footer itself. Paragraph alignment in the same footer follows the landscape page. Found on a report whose landscape calculation pages drew their footer table full width where Word draws it at the portrait width. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/layout/src/lib.rs | 59 +++++++++++++++++++++++++++++++++----- crates/layout/src/table.rs | 5 +++- crates/layout/src/tests.rs | 31 ++++++++++++++++++++ docs/layout-parity.md | 3 +- 4 files changed, 89 insertions(+), 9 deletions(-) diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 41ca30891..aba60a6dd 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -650,6 +650,9 @@ struct Ctx<'a> { uses_totals: bool, /// Whether to note `uses_totals` now (off while laying out headers and footers). track_totals: bool, + /// The width a top-level table's percentage width is a share of, when it isn't the box's: + /// an inherited header or footer's tables follow the section that defines it. + pct_base: Option, /// Body blocks that are empty paragraphs carrying only a section break before a continuous /// section, in order: they take no room (see [`place_para`]). quiet_marks: Vec, @@ -2017,6 +2020,7 @@ fn layout_pass( grid: para::Grid::default(), uses_totals: false, track_totals: true, + pct_base: None, quiet_marks: Vec::new(), mirror: false, table_keys: Vec::new(), @@ -2138,10 +2142,25 @@ 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 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) }; + let hb = |i: Option| defining_width(§ions, si, |s| s.headers, i); + let fb = |i: Option| defining_width(§ions, si, |s| s.footers, i); + let body_top = if reflow || turn.is_turned() { + sect.margin_top + } else { + with_pct_base(&mut ctx, hb(heads.default), |c| body_top_for(c, sect, heads.default)) + }; + let first_top = if reflow || turn.is_turned() || !sect.title_page { + body_top + } else { + with_pct_base(&mut ctx, hb(heads.first), |c| body_top_for(c, sect, heads.first)) + }; + let foot_top = + if reflow || turn.is_turned() { f32::MAX } else { with_pct_base(&mut ctx, fb(feet.default), |c| foot_top_for(c, sect, feet.default)) }; + let first_foot = if reflow || turn.is_turned() || !sect.title_page { + foot_top + } else { + with_pct_base(&mut ctx, fb(feet.first), |c| foot_top_for(c, 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) { @@ -2565,6 +2584,30 @@ fn section_side(start: SectionStart, restart: Option, last: u32, pages: usi } } +/// The text width of the section that defines header or footer `id`, when section `si` inherits +/// it rather than naming it (`None` when it names it). Measured in Word: an inherited part's +/// percentage-width tables are sized by that section, though it's laid out on `si`'s pages. +fn defining_width(sections: &[(usize, &SectionProps)], si: usize, get: fn(&SectionProps) -> HeaderSet, id: Option) -> Option { + let id = id?; + let names = |s: &SectionProps| { + let h = get(s); + [h.default, h.first, h.even].contains(&Some(id)) + }; + let own = sections.get(si)?.1; + if names(own) { + return None; + } + sections.iter().take(si).rev().find(|(_, s)| names(s)).map(|(_, s)| s.text_width()).filter(|w| (w - own.text_width()).abs() > 0.01) +} + +/// Lay out with percentage-width tables sized by `base` (see [`defining_width`]). +fn with_pct_base(ctx: &mut Ctx, base: Option, f: impl FnOnce(&mut Ctx) -> T) -> T { + let before = std::mem::replace(&mut ctx.pct_base, base); + let out = f(ctx); + ctx.pct_base = before; + out +} + /// Where body text starts: the top margin, or below the header if it's taller. fn body_top_for(ctx: &mut Ctx, sect: &SectionProps, header: Option) -> f32 { let Some(id) = header else { return sect.margin_top }; @@ -3946,7 +3989,8 @@ fn headers_footers(ctx: &mut Ctx, pages: &mut [Page], sections: &[(usize, &Secti { let blocks = part.blocks.clone(); let frame = PageFrame { sect, origin: (x, sect.header), number: page.number, mirror: ctx.mirror }; - let mut items = layout_box(ctx, StoryRef::Part(id), &blocks, &[], w, BoxContext::Other, 0, Some(frame)).items; + let base = defining_width(sections, page.section, |s| s.headers, hid); + let mut items = with_pct_base(ctx, base, |c| layout_box(c, StoryRef::Part(id), &blocks, &[], w, BoxContext::Other, 0, Some(frame)).items); for it in &mut items { it.translate(x, sect.header); } @@ -3957,10 +4001,11 @@ fn headers_footers(ctx: &mut Ctx, pages: &mut [Page], sections: &[(usize, &Secti && let Some(part) = ctx.doc.parts.get(&id) { let blocks = part.blocks.clone(); + let base = defining_width(sections, page.section, |s| s.footers, fid); // The footer's height fixes where it sits, which page-relative floats need. - let h = layout_box(ctx, StoryRef::Part(id), &blocks, &[], w, BoxContext::Other, 0, None).height; + let h = with_pct_base(ctx, base, |c| layout_box(c, StoryRef::Part(id), &blocks, &[], w, BoxContext::Other, 0, None).height); let frame = PageFrame { sect, origin: (x, sect.page_h - sect.footer - h), number: page.number, mirror: ctx.mirror }; - let mut items = layout_box(ctx, StoryRef::Part(id), &blocks, &[], w, BoxContext::Other, 0, Some(frame)).items; + let mut items = with_pct_base(ctx, base, |c| layout_box(c, StoryRef::Part(id), &blocks, &[], w, BoxContext::Other, 0, Some(frame)).items); for it in &mut items { it.translate(x, sect.page_h - sect.footer - h); } diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index 9462ae7c8..438c339f5 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -343,9 +343,12 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava vec![avail / ncols as f32; ncols] }; let mut total: f32 = grid.iter().sum(); + // A top-level table in an inherited header or footer takes its share of the defining + // section's width (`Ctx::pct_base`). + let pct_base = if depth <= 1 { ctx.pct_base.unwrap_or(avail) } else { avail }; let target = match (t.props.width_pct, t.props.width) { // Word keeps a percentage past 100%; the cap only bounds hostile input. - (Some(p), _) if p > 0.0 => Some(avail * (p / 100.0).min(MAX_WIDTH_SHARE)), + (Some(p), _) if p > 0.0 => Some(pct_base * (p / 100.0).min(MAX_WIDTH_SHARE)), (_, Some(w)) if w > 0.0 => Some(w), _ => None, }; diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index cc0926307..b91ce61e6 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -4170,6 +4170,37 @@ fn autofit_handles_spanning_cells_long_words_and_empty_tables() { assert!(x > 72.0 + 200.0, "the second 50% column starts at {x}"); } +#[test] +fn an_inherited_footer_sizes_its_percentage_table_by_its_own_section() { + use wordcraft_doc::section::SectionProps; + // Measured in Word: a 100%-wide footer table defined by a portrait section keeps that width on + // a landscape page that inherits the footer, centred there; a landscape section naming the + // same footer itself gets the landscape width. + let table_width = |landscape_names_it: bool| { + let mut d = Document::from_text("Portrait\nLandscape"); + let mut t = Table::new(1, 2, 100.0); + t.props.width_pct = Some(100.0); + let footer = d.add_part(wordcraft_doc::PartKind::Footer, vec![std::sync::Arc::new(wordcraft_doc::Block::Table(t))]); + let mut portrait = SectionProps::default(); + portrait.footers.default = Some(footer); + d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().section = Some(Box::new(portrait.clone())); + let mut landscape = portrait.clone(); + (landscape.page_w, landscape.page_h) = (portrait.page_h, portrait.page_w); + if !landscape_names_it { + landscape.footers.default = None; + } + d.last_section = landscape; + let l = lay(&d); + let cells: Vec = l.pages[1].footer.iter().filter_map(|i| if let Placed::Cell { rect, .. } = i { Some(*rect) } else { None }).collect(); + let (x0, x1) = cells.iter().fold((f32::MAX, f32::MIN), |(a, b), r| (a.min(r.x), b.max(r.right()))); + (x1 - x0, portrait.text_width(), d.last_section.text_width()) + }; + let (inherited, portrait_w, _) = table_width(false); + assert!((inherited - portrait_w).abs() < 1.0, "inherited: {inherited} wide, portrait text {portrait_w}"); + let (own, _, landscape_w) = table_width(true); + assert!((own - landscape_w).abs() < 1.0, "own footer: {own} wide, landscape text {landscape_w}"); +} + #[test] fn a_row_with_less_room_than_its_minimum_height_moves_on_whole() { use wordcraft_doc::props::HeightRule; diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 3332d4009..093ddd4ab 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -1,6 +1,6 @@ # Layout and pagination parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 A document only "opens correctly" if its pages break where Word breaks them: the same lines, the same page count, footnotes and pictures on the same pages. This checklist covers line breaking, @@ -159,6 +159,7 @@ No overall parity estimate changes from these probes. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | An inherited header or footer sizes its percentage-width tables by the section that defines it, as measured in Word (a portrait footer table keeps its width on inheriting landscape pages) | | 2026-10-14 | trivial | An empty paragraph closing a section before a new page stays on a full page instead of starting a page of its own, as measured in Word | | 2026-10-14 | trivial | A table row with an at-least height doesn't split where less than that height is left on the page; it starts the next page whole, as measured in Word | | 2026-10-14 | trivial | Text that doesn't fit an exact-height table row is cut off at the cell's edge, as in Word, instead of drawn over the rows below | From fe9d560b49f6495ec01f3d06dfda16beb8e9f3de Mon Sep 17 00:00:00 2001 From: Fin Warman Date: Thu, 15 Oct 2026 01:29:43 +0100 Subject: [PATCH 034/101] Layout: paragraph spacing adds up when HTML auto spacing is off (#1612) space after and the second's space before, as Word does by default. Word adds them instead when the document's compatibility options include w:doNotUseHTMLParagraphAutoSpacing. Measured in Word with five paragraphs of 12 pt before and after: 24 pt between them with the option, 12 pt without, the same in compatibility modes 14 and 15. A 60-page report with the option lost 6 pt per paragraph, about 60 pt on its cover. The option is read, laid out in the body and in boxes, and saved back. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/doc/src/lib.rs | 5 +++++ crates/docx/src/read/mod.rs | 1 + crates/docx/src/write/mod.rs | 4 ++++ crates/docx/tests/fixtures.rs | 14 ++++++++++++++ crates/layout/src/lib.rs | 4 ++-- crates/layout/src/tests.rs | 33 +++++++++++++++++++++++++++++++++ docs/layout-parity.md | 3 ++- 7 files changed, 61 insertions(+), 3 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index d6acc31d6..d2246ee6b 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -494,6 +494,10 @@ pub struct Settings { /// 2013 and later, which places a table's border at the margin rather than its text and lets /// justified lines shrink their spaces to fit more text. pub compat_mode: u32, + /// `w:doNotUseHTMLParagraphAutoSpacing`: the space between two paragraphs is the first's space + /// after plus the second's space before, not the larger of the two. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub sum_paragraph_spacing: bool, /// Keep contextual paragraph spacing inside legacy table cells (`allowSpaceOfSameStyleInTable`). /// Word ignores the layout override in compatibility mode 15, but the flag still round-trips. pub allow_space_of_same_style_in_table: bool, @@ -628,6 +632,7 @@ impl Default for Settings { protect_formatting: false, protect_hash: Vec::new(), compat_mode: COMPAT_MODE_CURRENT, + sum_paragraph_spacing: false, allow_space_of_same_style_in_table: false, grid_h: DEFAULT_GRID, grid_v: DEFAULT_GRID, diff --git a/crates/docx/src/read/mod.rs b/crates/docx/src/read/mod.rs index fa8af6f63..35db2cb93 100644 --- a/crates/docx/src/read/mod.rs +++ b/crates/docx/src/read/mod.rs @@ -1069,6 +1069,7 @@ impl Reader<'_> { if let Some(m) = mode.and_then(u32_of) { s.compat_mode = m.clamp(11, 99); } + s.sum_paragraph_spacing = k.children("w:doNotUseHTMLParagraphAutoSpacing").any(on_off); } "w:trackRevisions" => s.track_changes = on_off(k), "w:defaultTabStop" => { diff --git a/crates/docx/src/write/mod.rs b/crates/docx/src/write/mod.rs index 04ec182fb..a031a231e 100644 --- a/crates/docx/src/write/mod.rs +++ b/crates/docx/src/write/mod.rs @@ -1049,6 +1049,10 @@ fn settings_xml(doc: &Document, footnotes: bool, endnotes: bool, template_rel: O w.close(tag); } w.open("w:compat", &[]); + // CT_Compat: the older switches come before the `w:compatSetting`s. + if doc.settings.sum_paragraph_spacing { + w.empty("w:doNotUseHTMLParagraphAutoSpacing", &[]); + } if s.allow_space_of_same_style_in_table { w.empty("w:allowSpaceOfSameStyleInTable", &[]); } diff --git a/crates/docx/tests/fixtures.rs b/crates/docx/tests/fixtures.rs index b4189c8aa..2b1a6f4e2 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/fixtures.rs @@ -2567,6 +2567,20 @@ fn text_frames_read_and_round_trip() { assert!(q.iter().any(|x| x.props.drop_cap == Some(2) && x.props.frame.is_none())); } +/// `w:doNotUseHTMLParagraphAutoSpacing` in the settings' `w:compat`: paragraph spacing adds up. +#[test] +fn html_auto_spacing_off_is_read_and_kept() { + let settings = |compat: &str| { + let s = format!(r#"{compat}"#); + let rels = [("rId9", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings", "settings.xml")]; + let d = wordcraft_docx::read(&docx("", &rels, &[("word/settings.xml", &s)])).unwrap(); + let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap(); + (d.settings.sum_paragraph_spacing, again.settings.sum_paragraph_spacing) + }; + assert_eq!(settings(""), (true, true)); + assert_eq!(settings(""), (false, false)); +} + /// A right triangle (`rtTriangle`) has its right angle at the bottom left: a cover page's white /// corner cut-outs drawn as isosceles triangles pointed the wrong way. #[test] diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index aba60a6dd..6103badc4 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -1083,7 +1083,7 @@ fn layout_box( // space after the paragraph above (the larger one counts). if pl.rp.before_auto && i == 0 { before = 0.0; - } else if i > 0 && before > 0.0 && prev_after > 0.0 { + } else if i > 0 && before > 0.0 && prev_after > 0.0 && !ctx.doc.settings.sum_paragraph_spacing { before = (before - prev_after).max(0.0); } y += if i == 0 { before } else { before.max(0.0) }; @@ -3139,7 +3139,7 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: } // The space between two paragraphs is the larger of the first's space after and the // second's space before, not their sum. - if before > 0.0 && after > 0.0 { + if before > 0.0 && after > 0.0 && !ctx.doc.settings.sum_paragraph_spacing { before = (before - after).max(0.0); } } diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index b91ce61e6..7bed4c865 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -1392,6 +1392,39 @@ fn space_between_paragraphs_is_the_larger_of_after_and_before() { assert!(gap(6.0, 12.0, true).abs() < 0.01); } +#[test] +fn space_between_paragraphs_is_their_sum_when_html_auto_spacing_is_off() { + // Measured in Word (compatibility modes 14 and 15): with `w:doNotUseHTMLParagraphAutoSpacing`, + // 12 pt after and 12 pt before leave 24 pt; without it, 12 pt. + let gaps = |sum: bool| -> (f32, f32) { + let mut d = Document::from_text("One\nTwo"); + d.settings.sum_paragraph_spacing = sum; + for i in 0..2 { + let p = d.para_mut(StoryRef::Body, &Path::top(i)).unwrap(); + (p.props.space_after, p.props.space_before) = (Some(12.0), Some(12.0)); + } + let mut t = Table::new(1, 1, 468.0); + t.rows[0].cells[0].blocks = d.body.clone(); + d.insert_block(StoryRef::Body, &Path::top(2), wordcraft_doc::Block::Table(t)).unwrap(); + let l = lay(&d); + let tops = |cell: bool| -> Vec<(f32, f32)> { + l.pages[0] + .items + .iter() + .filter_map(|it| match it { + Placed::Lines { path, y, para, .. } if (path.0.len() == 4) == cell => Some((*y, para.height)), + _ => None, + }) + .collect() + }; + let gap = |ys: Vec<(f32, f32)>| ys[1].0 - (ys[0].0 + ys[0].1); + (gap(tops(false)), gap(tops(true))) + }; + let near = |(a, b): (f32, f32), want: f32| (a - want).abs() < 0.01 && (b - want).abs() < 0.01; + assert!(near(gaps(true), 24.0), "{:?}", gaps(true)); + assert!(near(gaps(false), 12.0), "{:?}", gaps(false)); +} + #[test] fn space_between_paragraphs_in_a_cell_follows_the_body_rule() { // Three paragraphs in a cell, each 6pt before and 6pt after -> the gaps between them. diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 093ddd4ab..74ae363f8 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -1,6 +1,6 @@ # Layout and pagination parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 A document only "opens correctly" if its pages break where Word breaks them: the same lines, the same page count, footnotes and pictures on the same pages. This checklist covers line breaking, @@ -159,6 +159,7 @@ No overall parity estimate changes from these probes. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | With `w:doNotUseHTMLParagraphAutoSpacing` the space between two paragraphs is the first's space after plus the second's space before, as measured in Word; without it, still the larger (#733) | | 2026-10-14 | trivial | An inherited header or footer sizes its percentage-width tables by the section that defines it, as measured in Word (a portrait footer table keeps its width on inheriting landscape pages) | | 2026-10-14 | trivial | An empty paragraph closing a section before a new page stays on a full page instead of starting a page of its own, as measured in Word | | 2026-10-14 | trivial | A table row with an at-least height doesn't split where less than that height is left on the page; it starts the next page whole, as measured in Word | From 74a279d5f211c935e4cea67f8b72c37e419fc230 Mon Sep 17 00:00:00 2001 From: Fin Warman Date: Thu, 15 Oct 2026 01:31:42 +0100 Subject: [PATCH 035/101] Fonts: a missing roman font is drawn in Cambria, as in Word (#1613) WordCraft chose a substitute for a font that isn't installed from its name alone, so an unknown brand font always came out sans. Word reads the font table instead: measured with the same missing font, it draws it in Cambria when w:family is roman and in Calibri when it is swiss or auto. A report whose font table calls its brand font roman got a sans footer where Word draws serif. The font table's roman fonts are read into the settings, and layout looks a missing one up as Cambria (Caladea where Cambria isn't installed). The other classes keep the existing substitutes, which already start with Calibri's metric clone. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/doc/src/lib.rs | 5 +++++ crates/docx/src/read/mod.rs | 18 ++++++++++++++++++ crates/docx/tests/fixtures.rs | 9 +++++++++ crates/layout/src/para.rs | 17 ++++++++++++++--- crates/layout/src/tests.rs | 21 +++++++++++++++++++++ docs/file-format-parity.md | 3 ++- 6 files changed, 69 insertions(+), 4 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index d2246ee6b..775c05284 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -494,6 +494,10 @@ pub struct Settings { /// 2013 and later, which places a table's border at the margin rather than its text and lets /// justified lines shrink their spaces to fit more text. pub compat_mode: u32, + /// Fonts the document's font table classes as roman (serif, `w:family w:val="roman"`): Word + /// draws one that isn't installed in a serif face. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub roman_fonts: Vec, /// `w:doNotUseHTMLParagraphAutoSpacing`: the space between two paragraphs is the first's space /// after plus the second's space before, not the larger of the two. #[serde(default, skip_serializing_if = "std::ops::Not::not")] @@ -632,6 +636,7 @@ impl Default for Settings { protect_formatting: false, protect_hash: Vec::new(), compat_mode: COMPAT_MODE_CURRENT, + roman_fonts: Vec::new(), sum_paragraph_spacing: false, allow_space_of_same_style_in_table: false, grid_h: DEFAULT_GRID, diff --git a/crates/docx/src/read/mod.rs b/crates/docx/src/read/mod.rs index 35db2cb93..0843164a6 100644 --- a/crates/docx/src/read/mod.rs +++ b/crates/docx/src/read/mod.rs @@ -33,6 +33,10 @@ use crate::xml::El; pub(crate) use props::PropCtx; use story::StoryCtx; +/// Most fonts read from a font table, and the longest name (hostile files). +const MAX_TABLE_FONTS: usize = 2000; +const MAX_FONT_NAME: usize = 256; + /// Most parts (headers, notes, comments, text boxes) we create from one file. const MAX_PARTS: usize = 50_000; /// Deepest chain of headers/footers whose own sections reference further headers/footers. @@ -171,6 +175,7 @@ pub(crate) fn read_opened(pkg: &Package) -> Result { // The font table and the fonts embedded through it ride along as opaque parts. if let Some(p) = part(rt::FONT_TABLE) { r.keep_part_tree(&p); + r.read_font_families(&p); } if let Some(p) = part(rt::THEME) { r.read_theme(&p); @@ -581,6 +586,19 @@ impl Reader<'_> { } } + /// The fonts the font table classes as roman (`w:font/w:family w:val="roman"`). + fn read_font_families(&mut self, path: &str) { + let Ok(Some(table)) = self.xml(path) else { return }; + self.doc.settings.roman_fonts = table + .children("w:font") + .filter(|f| f.child("w:family").and_then(|v| v.attr("w:val")) == Some("roman")) + .filter_map(|f| f.attr("w:name")) + .filter(|n| !n.is_empty() && n.len() <= MAX_FONT_NAME) + .take(MAX_TABLE_FONTS) + .map(str::to_string) + .collect(); + } + fn xml(&self, path: &str) -> Result, DocxError> { self.pkg.xml(path) } diff --git a/crates/docx/tests/fixtures.rs b/crates/docx/tests/fixtures.rs index 2b1a6f4e2..d61fbcb4a 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/fixtures.rs @@ -2567,6 +2567,15 @@ fn text_frames_read_and_round_trip() { assert!(q.iter().any(|x| x.props.drop_cap == Some(2) && x.props.frame.is_none())); } +/// The font table's family classes: Word draws a missing roman font in a serif face. +#[test] +fn roman_fonts_are_read_from_the_font_table() { + let fonts = r#""#; + let rels = [("rId9", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable", "fontTable.xml")]; + let d = wordcraft_docx::read(&docx("", &rels, &[("word/fontTable.xml", fonts)])).unwrap(); + assert_eq!(d.settings.roman_fonts, ["Brand Serif"]); +} + /// `w:doNotUseHTMLParagraphAutoSpacing` in the settings' `w:compat`: paragraph spacing adds up. #[test] fn html_auto_spacing_off_is_read_and_kept() { diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 7d119b177..f9efa11b9 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -666,6 +666,17 @@ fn east_asian(c: char) -> bool { crate::kinsoku::is_east_asian(c) || matches!(u32::from(c), 0x1100..=0x11FF | 0x3130..=0x318F | 0xAC00..=0xD7AF) } +/// Word draws a font that isn't installed in Cambria when the document's font table classes it +/// as roman (measured in Word; Calibri otherwise, which our own substitutes already match). +const MISSING_ROMAN_FONT: &str = "Cambria"; + +/// The family to look `font` up as: [`MISSING_ROMAN_FONT`] for a missing roman font, else `font`. +fn lookup_family<'f>(doc: &Document, font: &'f str) -> &'f str { + let db = wordcraft_fonts::FontDb::global(); + let roman = doc.settings.roman_fonts.iter().any(|f| f.eq_ignore_ascii_case(font)); + if roman && !db.has_family(font) && !db.has_family(wordcraft_fonts::word::charset_base_family(font)) { MISSING_ROMAN_FONT } else { font } +} + 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 @@ -678,7 +689,7 @@ impl<'a> Builder<'a> { /// 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) -> u32 { - let r = wordcraft_fonts::word::resolve(&rc.font, rc.bold, rc.italic); + let r = wordcraft_fonts::word::resolve(lookup_family(self.env.doc, &rc.font), rc.bold, rc.italic); let face = face_override.unwrap_or(r.face); // The display list reads every run's format (link target, strike kind, underline colour, // baseline shift…) from its StyleRun, so runs share one only when their formats are equal. @@ -744,12 +755,12 @@ impl<'a> Builder<'a> { // Complex-script characters (Persian, Arabic…) use the run's complex-script font, size, // bold and italic. let rc_cs: Option> = text.chars().any(|c| rc.uses_complex(c)).then(|| Arc::new(rc.complex())); - let resolved = wordcraft_fonts::word::resolve(&rc.font, rc.bold, rc.italic); + let resolved = wordcraft_fonts::word::resolve(lookup_family(self.env.doc, &rc.font), rc.bold, rc.italic); let primary = resolved.face; // Symbol-font characters are laid out as their Unicode equivalents. let sym = SymbolRun::of(&rc.font, &resolved); let show = |c: char| sym.map_or(c, |m| m.show(c)); - let primary_cs = rc_cs.as_ref().map(|r| wordcraft_fonts::word::resolve(&r.font, r.bold, r.italic).face); + let primary_cs = rc_cs.as_ref().map(|r| wordcraft_fonts::word::resolve(lookup_family(self.env.doc, &r.font), r.bold, r.italic).face); // Chinese, Japanese and Korean characters use the run's East Asian font (`w:eastAsia`) when // it is installed and has them. let primary_ea = rc diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 7bed4c865..0616e41af 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -688,6 +688,27 @@ fn page_break_char_starts_new_page() { assert_eq!(l.pages.len(), 2); } +#[test] +fn a_missing_roman_font_is_drawn_as_word_draws_it() { + // Measured in Word: a font that isn't installed is drawn in Cambria when the font table calls + // it roman, in Calibri otherwise. + let face = |roman: bool| { + let mut d = Document::from_text("Serif"); + if roman { + d.settings.roman_fonts = vec!["No Such Brand Font".into()]; + } + let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap(); + p.runs[0].props.font = Some("No Such Brand Font".into()); + let l = lay(&d); + let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!("no text") }; + let first = para.clusters.first().expect("a cluster"); + para.styles[first.style as usize].face + }; + let cambria = wordcraft_fonts::word::resolve("Cambria", false, false).face; + assert_eq!(face(true), cambria); + assert_ne!(face(false), cambria); +} + #[test] fn an_empty_section_mark_stays_on_a_full_page() { use wordcraft_doc::section::SectionProps; diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 143502d09..347b1be2c 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-14 · **Change:** trivial (legacy DOC DIB conversion honours the BLIP byte limit; related #1334 and #1554; previously trivial: right triangles keep their right angle; previously trivial: section `w:vAlign` "both" round-trips as Justified, #1320; previously trivial: Word's text watermark written back to DOCX headers, #1290; previously trivial: VML preset shapes drawn, #1377; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: equations as MathML in HTML export, copy and paste, #816; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously 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-11 · **Last updated:** 2026-10-14 · **Change:** trivial (missing roman fonts drawn in Cambria; previously trivial: legacy DOC DIB conversion honours the BLIP byte limit; related #1334 and #1554; previously trivial: right triangles keep their right angle; previously trivial: section `w:vAlign` "both" round-trips as Justified, #1320; previously trivial: Word's text watermark written back to DOCX headers, #1290; previously trivial: VML preset shapes drawn, #1377; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: equations as MathML in HTML export, copy and paste, #816; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously 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; @@ -195,6 +195,7 @@ attributes inside stories and style or list definitions; a regenerated theme. Te | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | A missing font the font table classes as roman is drawn in Cambria, as measured in Word (Calibri for the other classes, as before) | | 2026-10-14 | minor | Legacy DOC DIB helper honours the supplied byte limit for its BMP header/copy, decoder and PNG output, with a bounded BMP fallback. Generated 1-pixel regression only; resource-exhaustion consequences unmeasured, fidelity/readiness estimates unchanged. Related: #1334 and #1554 | | 2026-10-14 | trivial | Right triangles (`rtTriangle`) keep their right angle at the bottom left instead of becoming isosceles triangles, and save back as `rtTriangle` | | 2026-10-13 | trivial | Section `w:vAlign="both"` reads as Justified and writes back as `both` (#1320) | From 63ed4daab2b7cc08ef2c8792f202bffa61a4f95e Mon Sep 17 00:00:00 2001 From: Fin Warman Date: Thu, 15 Oct 2026 01:35:37 +0100 Subject: [PATCH 036/101] Fonts: a Bidi theme slot names the theme's complex-script font (#1619) w:asciiTheme="minorBidi" (and majorBidi, and w:cstheme) read as the theme's Latin font, so a template's titles came out in Calibri where Word draws Arial. Measured in Word: the slot names the theme's a:cs font, and an empty a:cs names nothing, so the font inherited from the style applies. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/docx/src/read/mod.rs | 3 +++ crates/docx/src/read/props.rs | 8 +++++++- crates/docx/tests/fixtures.rs | 17 +++++++++++++++++ docs/file-format-parity.md | 3 ++- 4 files changed, 29 insertions(+), 2 deletions(-) diff --git a/crates/docx/src/read/mod.rs b/crates/docx/src/read/mod.rs index 0843164a6..9eb421498 100644 --- a/crates/docx/src/read/mod.rs +++ b/crates/docx/src/read/mod.rs @@ -726,6 +726,9 @@ impl Reader<'_> { if let Some(f) = face("a:minorFont") { self.doc.settings.minor_font = f; } + let cs = |n: &str| fs.child(n).and_then(|f| f.child("a:cs")).and_then(|c| c.attr("typeface")).unwrap_or_default().to_string(); + self.pc.major_cs = cs("a:majorFont"); + self.pc.minor_cs = cs("a:minorFont"); } self.pc.major_font = self.doc.settings.major_font.clone(); self.pc.minor_font = self.doc.settings.minor_font.clone(); diff --git a/crates/docx/src/read/props.rs b/crates/docx/src/read/props.rs index 08eb948a5..0b8144dc6 100644 --- a/crates/docx/src/read/props.rs +++ b/crates/docx/src/read/props.rs @@ -43,6 +43,9 @@ use crate::xml::El; pub struct PropCtx { pub major_font: String, pub minor_font: String, + /// The theme's complex-script fonts (`a:cs`), which the `…Bidi` theme slots name. + pub major_cs: String, + pub minor_cs: String, /// The theme's colour scheme (dk1, lt1, dk2, lt2, accent1..6, hlink, folHlink); empty when the /// package has none, and then `w:themeColor` falls back to the fixed colour. pub theme_colors: Vec, @@ -82,7 +85,10 @@ impl PropCtx { } fn theme_font(&self, t: &str) -> Option { - let f = if t.starts_with("major") { &self.major_font } else { &self.minor_font }; + let (latin, cs) = if t.starts_with("major") { (&self.major_font, &self.major_cs) } else { (&self.minor_font, &self.minor_cs) }; + // Measured in Word: `minorBidi`/`majorBidi` name the complex-script font, and an empty one + // names nothing, so the font beside it or the inherited one applies. + let f = if t.ends_with("Bidi") { cs } else { latin }; (!f.is_empty()).then(|| f.clone()) } diff --git a/crates/docx/tests/fixtures.rs b/crates/docx/tests/fixtures.rs index d61fbcb4a..ceee89285 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/fixtures.rs @@ -2567,6 +2567,23 @@ fn text_frames_read_and_round_trip() { assert!(q.iter().any(|x| x.props.drop_cap == Some(2) && x.props.frame.is_none())); } +/// Measured in Word: `w:asciiTheme="minorBidi"` names the theme's complex-script font (`a:cs`); +/// when that is empty it names nothing, and the run keeps the font it inherits. +#[test] +fn bidi_theme_slots_name_the_complex_script_font() { + let font = |cs: &str| { + let theme = format!( + r#""# + ); + let rels = [("rId9", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme", "theme/theme1.xml")]; + let body = r#"x"#; + let d = wordcraft_docx::read(&docx(body, &rels, &[("word/theme/theme1.xml", &theme)])).unwrap(); + paras(&d)[0].runs[0].props.font.clone() + }; + assert_eq!(font(""), None); + assert_eq!(font("Times New Roman").as_deref(), Some("Times New Roman")); +} + /// The font table's family classes: Word draws a missing roman font in a serif face. #[test] fn roman_fonts_are_read_from_the_font_table() { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 347b1be2c..95d4fb2a8 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-14 · **Change:** trivial (missing roman fonts drawn in Cambria; previously trivial: legacy DOC DIB conversion honours the BLIP byte limit; related #1334 and #1554; previously trivial: right triangles keep their right angle; previously trivial: section `w:vAlign` "both" round-trips as Justified, #1320; previously trivial: Word's text watermark written back to DOCX headers, #1290; previously trivial: VML preset shapes drawn, #1377; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: equations as MathML in HTML export, copy and paste, #816; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously 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-11 · **Last updated:** 2026-10-14 · **Change:** trivial (Bidi theme font slots; previously trivial: missing roman fonts drawn in Cambria; previously trivial: legacy DOC DIB conversion honours the BLIP byte limit; related #1334 and #1554; previously trivial: right triangles keep their right angle; previously trivial: section `w:vAlign` "both" round-trips as Justified, #1320; previously trivial: Word's text watermark written back to DOCX headers, #1290; previously trivial: VML preset shapes drawn, #1377; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: equations as MathML in HTML export, copy and paste, #816; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously 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; @@ -195,6 +195,7 @@ attributes inside stories and style or list definitions; a regenerated theme. Te | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | `minorBidi`/`majorBidi` theme fonts name the theme's complex-script font (`a:cs`); an empty one names nothing, so the inherited font applies, as measured in Word | | 2026-10-14 | trivial | A missing font the font table classes as roman is drawn in Cambria, as measured in Word (Calibri for the other classes, as before) | | 2026-10-14 | minor | Legacy DOC DIB helper honours the supplied byte limit for its BMP header/copy, decoder and PNG output, with a bounded BMP fallback. Generated 1-pixel regression only; resource-exhaustion consequences unmeasured, fidelity/readiness estimates unchanged. Related: #1334 and #1554 | | 2026-10-14 | trivial | Right triangles (`rtTriangle`) keep their right angle at the bottom left instead of becoming isosceles triangles, and save back as `rtTriangle` | From 4ad625eef18e50337bf7232ec9b7deef434a34b6 Mon Sep 17 00:00:00 2001 From: Yasas Sri <164534752+Yasas-Sri@users.noreply.github.com> Date: Thu, 15 Oct 2026 06:09:24 +0530 Subject: [PATCH 037/101] Escape selects a text box or WordArt, so Delete removes it (#1538) (#1604) Co-authored-by: Brandon Thomas --- crates/ui-egui/src/keys.rs | 10 ++++++---- crates/ui-egui/src/lib.rs | 27 +++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/keys.rs b/crates/ui-egui/src/keys.rs index 872a20bb5..49b90eb5a 100644 --- a/crates/ui-egui/src/keys.rs +++ b/crates/ui-egui/src/keys.rs @@ -369,14 +369,16 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) { app.canvas.context_menu_from_keys = true; continue; } - // Escape leaves a text box to just after it in the body. + // Escape in a text box (or WordArt) selects the box itself, so Delete then removes + // it (#1538). The anchor position is just after the box's object character. if key == Key::Escape && crate::canvas::in_text_box(app) - && app.session.sel.is_collapsed() && let wordcraft_doc::StoryRef::Part(id) = app.session.sel.focus.story - && let Some(pos) = app.session.doc.text_box_anchor(id) + && let Some(end) = app.session.doc.text_box_anchor(id) + && let Some(off) = end.off.checked_sub(wordcraft_doc::para::OBJ.len_utf8()) { - let _ = app.run("caret.set", json!({"pos": pos})); + let start = wordcraft_doc::Pos { off, ..end.clone() }; + let _ = app.run("select.range", json!({"anchor": start, "focus": end})); continue; } if key == Key::Escape && matches!(app.session.sel.focus.story, wordcraft_doc::StoryRef::Part(_)) && app.session.sel.is_collapsed() { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1048fc916..5666750b8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -4658,6 +4658,33 @@ mod tests { assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body), "Kilns"); } + /// #1538: inserting WordArt leaves its text selected, so Delete erased only the text and the + /// empty box stayed. Escape now selects the box itself, Delete removes it, and a second Escape + /// is back to a caret in the body. + #[test] + fn escape_selects_a_wordart_box_and_delete_removes_it() { + let mut a = app(); + let id = a.run("insert.wordArt", json!({})).unwrap()["story"].as_u64().unwrap() as u32; + assert!(canvas::in_text_box(&a), "the WordArt's text is selected after inserting it"); + let mut h = harness(a); + h.run_steps(4); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert!(wordcraft_engine::cmd::objects::object_selection(&h.state().session).is_some(), "Escape selects the box"); + h.key_press(egui::Key::Delete); + h.run_steps(2); + assert_eq!(h.state().session.doc.text_box_anchor(id), None, "Delete removed the WordArt"); + assert!(h.state_mut().session.undo(), "one undo brings it back"); + assert!(h.state().session.doc.text_box_anchor(id).is_some()); + h.run_steps(2); + h.key_press(egui::Key::Escape); + h.run_steps(2); + h.key_press(egui::Key::Escape); + h.run_steps(2); + let s = &h.state().session; + assert!(s.sel.is_collapsed() && s.sel.focus.story == wordcraft_doc::StoryRef::Body, "a second Escape leaves a caret in the body"); + } + /// #1218: Ctrl+Q resets the paragraph formatting and nothing else; it also closed the window /// (asking to save) on Windows and Linux. ⌘Q still quits on the Mac. #[test] From a8c1c228d79d6a11745f4147f21e4fa78dc79b26 Mon Sep 17 00:00:00 2001 From: Diego Guerini <106814760+D13GOOOO@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:43:13 +0200 Subject: [PATCH 038/101] i18n(it): the interface audit checks Italian; Privacy Settings wording; envelope label test (#1627) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Envelopes: the size list was drawn as English text (`#10 4.13 × 9.5 in`) with a decimal point in every region. The label is now a catalog template (`{name} {height} × {width} in` / `mm`) filled with the region's decimal separator; ru, hu, tr, bg, pl and it carry it. Regression test included. - Italian: the 18 strings the interface audit looks up that it.tsv lacked (Reading Mode bar, Go To, command search, status and command messages); `Privacy Settings` no longer says "current". - The audit's catalog-completeness check covers Italian too. - docs/localization-parity.md: status line, Italian note, revision history. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/ui-egui/src/dialogs_layout.rs | 18 ++++++++++++++++++ crates/ui-egui/src/i18n/audit.rs | 4 ++-- crates/ui-egui/src/i18n/it.tsv | 2 +- docs/localization-parity.md | 5 ++++- 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/dialogs_layout.rs b/crates/ui-egui/src/dialogs_layout.rs index bb44db833..a0388463d 100644 --- a/crates/ui-egui/src/dialogs_layout.rs +++ b/crates/ui-egui/src/dialogs_layout.rs @@ -761,6 +761,24 @@ mod tests { assert!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("Thanks.")); } + /// The envelope sizes are in the interface language with the region's decimal separator + /// (#1128; the untranslated-text audit found `#10 4.13 × 9.5 in` drawn in English). + #[test] + fn envelope_sizes_follow_the_interface_language_and_region() { + let size = |name: &str| ENVELOPE_SIZES.iter().find(|s| s.0 == name).map(|(n, w, h)| envelope_label(n, *w, *h)).unwrap(); + assert_eq!(size("Envelope #10"), "#10 4.13 × 9.5 in"); + assert_eq!(size("Envelope DL"), "DL 110 × 220 mm"); + let mut a = app(); + a.run("file.options", json!({"region": "it-IT"})).unwrap(); + crate::options::sync(&a); + crate::i18n::set_current(crate::i18n::Lang::from_code("it").unwrap()); + assert_eq!(size("Envelope #10"), "#10 4,13 × 9,5 pollici"); + assert_eq!(size("Envelope DL"), "DL 110 × 220 mm"); + crate::i18n::set_current(crate::i18n::Lang::EN); + a.run("file.options", json!({"region": "en-US"})).unwrap(); + crate::options::sync(&a); + } + /// #407: Labels builds a new document of the chosen sheet, or a single label. #[test] fn labels_dialog_builds_a_sheet() { diff --git a/crates/ui-egui/src/i18n/audit.rs b/crates/ui-egui/src/i18n/audit.rs index bde2dab12..c81367573 100644 --- a/crates/ui-egui/src/i18n/audit.rs +++ b/crates/ui-egui/src/i18n/audit.rs @@ -333,7 +333,7 @@ fn untranslated_interface_text() { complete_catalogs_have_every_string_the_interface_looked_up(pseudo::looked_up()); } -/// Russian, Hungarian, Turkish and Bulgarian cover everything the audit saw translated, plus the equation +/// Russian, Hungarian, Turkish, Bulgarian and Italian 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) { @@ -379,7 +379,7 @@ fn complete_catalogs_have_every_string_the_interface_looked_up(mut keys: std::co // translate: its placeholder names aren't words the interface shows. let words = |s: &str| english_words(&s.split('{').map(|p| p.split_once('}').map_or(p, |(_, rest)| rest)).collect::()); let mut report = String::new(); - for code in ["ru", "hu", "tr", "bg"] { + for code in ["ru", "hu", "tr", "bg", "it"] { let lang = super::Lang::from_code(code).unwrap_or(super::Lang::EN); let missing: Vec<&String> = keys.iter().filter(|s| words(s) && !super::has(lang, s) && !numbered(s) && !same(s) && !script_error(s)).collect(); diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 8dc27b47d..ac0b47157 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2510,7 +2510,7 @@ preview Title Esempio Remove my name from comments, changes and file properties when saving Rimuovi il mio nome da commenti, modifiche e proprietà del file al salvataggio Leave out the date and time of comments and tracked changes when saving Ometti data e ora di commenti e revisioni al salvataggio The document you are editing keeps the names and dates; only the saved file leaves them out. Il documento che stai modificando conserva nomi e date; solo il file salvato li omette. - Privacy Settings Impostazioni di privacy attuali + Privacy Settings Impostazioni di privacy Waiting for the save to finish… In attesa del termine del salvataggio… Saving {name} was cancelled, so it is still open and nothing was lost. Il salvataggio di {name} è stato annullato: il documento è ancora aperto e non è andato perso nulla. Paper Carta diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 2e4cbcdf8..6de054cf3 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-14 · **Change:** trivial (Italian grammar: `po'`, `qual è`, `È`, `un`/`un'` rules; more elisions and truncations in spelling; previously trivial: Czech interface catalog completed, 2,447 entries; previously trivial: interface audit runs in CI again: size labels translated, 20 strings in every catalog, Read Aloud error race fixed, #1128; previously trivial: Review › Language reaches every language again — Set Proofing Language… and Set As Default, #860; previously minor: Italian proofing: it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar with Italian messages; previously minor: Portuguese proofing; previously trivial: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; 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-14 · **Change:** trivial (Italian: the interface audit checks the Italian catalog, `Privacy Settings` wording; previously trivial: Italian grammar: `po'`, `qual è`, `È`, `un`/`un'` rules; more elisions and truncations in spelling; previously trivial: Czech interface catalog completed, 2,447 entries; previously trivial: interface audit runs in CI again: size labels translated, 20 strings in every catalog, Read Aloud error race fixed, #1128; previously trivial: Review › Language reaches every language again — Set Proofing Language… and Set As Default, #860; previously minor: Italian proofing: it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar with Italian messages; previously minor: Portuguese proofing; previously trivial: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; 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`. @@ -121,6 +121,8 @@ Italian (`it`, #594) follows `it`, `it-IT` and `it-CH` system locales. Its catal 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. The interface audit also +checks the Italian catalog (2026-10-14). Italian proofing is in the proofing table above. allows); all other text is written from the meaning of the English. Italian proofing: see the table above. ## Sorting and numbers in the text's language @@ -184,6 +186,7 @@ WordCraft keeps 2,54 cm). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Italian: the interface audit's catalog check covers Italian too, as it does Russian, Hungarian, Turkish and Bulgarian; `Privacy Settings` no longer says "current" ("attuali"); a test pins the envelope size labels #1128 made translatable (Italian inches, the region's decimal comma) | | 2026-10-14 | minor | Italian grammar rules that work without a dictionary: `un pò`/`un po` → `un po'`, `qual'è` → `qual è`, `E'` → `È`, `un'` only before feminine words (`un'altro` → `un altro`, `un altra` → `un'altra`, also `nessun`/`ciascun`/`qualcun`/`alcun`); spelling accepts more elisions (`dov'è`, `anch'io`, `senz'altro`, `nessun'altra`) and truncations (`po'`, `mo'`) and leaves `pò`/`qual'è` to grammar so they aren't marked twice; more emphatic doubles allowed | | 2026-10-12 | minor | File › Options › Proofing › Dictionaries folder (#1063): a saved folder of Hunspell dictionaries searched first, so an app started from Finder or the Dock finds them without `WORDCRAFT_DICTIONARIES`; the pane lists the dictionary file each language found — Polish, Portuguese, Italian, Croatian, Turkish (`file.options` `dictionariesFolder`) | | 2026-10-12 | minor | Czech interface catalog completed: 386 entries added (2,447 total), including built-in style names, numbered families, equation gallery, watermarks, paper sizes and preview tiles; coverage test requires every Russian and Spanish key and retains Chinese coverage | From 6eeb6d10db50597d20c7232a55c7351ceb0ebae0 Mon Sep 17 00:00:00 2001 From: Morzilla Date: Thu, 15 Oct 2026 03:03:41 +0200 Subject: [PATCH 039/101] A floating object anchored after a page break stays on the page its paragraph starts on (#1632) Word's cover pages are one paragraph holding a page break and then the page-anchored text box with the title and the approval table. Word draws a floating object on the page its anchor paragraph starts on; WordCraft drew it with the line holding the anchor, which after the break is the first line of page two, so the cover was blank and the title box sat over the body text of page two. `place_objects` now places a paragraph's floating objects from its first piece, whichever of its lines holds the anchor, and none from later pieces; inline objects still go with their own line. The wrap areas were already registered from the paragraph's first page, so nothing changes for text flowing around the object. Co-authored-by: Daniele Mori Co-authored-by: Claude Fable 5.1 --- crates/layout/src/lib.rs | 15 ++++++++++++++- crates/layout/src/tests.rs | 15 +++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 6103badc4..498843dc3 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -2914,7 +2914,17 @@ fn place_objects( let Some(fl) = pl.lines.get(l0) else { return (behind, front) }; // Objects left out of the layout (hidden, or deleted in the final text) aren't placed. let left_out = left_out_objects(ctx.doc, p, at.table_chr, ctx.opts.show_hidden, ctx.hide_deleted); - for li in l0..l1 { + // A floating object belongs to its paragraph, not to its line: Word puts it on the page the + // paragraph starts on (a cover page is a paragraph holding a page break and then the text + // box with the title; the box stays on the cover). So the paragraph's first piece places + // every floating object, whichever of its lines holds the anchor, and later pieces none; + // inline objects go with their own line. + let first = l0 == 0; + for li in 0..pl.lines.len() { + let in_piece = (l0..l1).contains(&li); + if !in_piece && !first { + continue; + } let Some(line) = pl.lines.get(li) else { continue }; for k in line.c0..line.c1 { let Some(c) = pl.clusters.get(k) else { continue }; @@ -2925,6 +2935,9 @@ fn place_objects( let Some(obj) = p.objects.get(oi) else { continue }; let Some((w, h, float)) = obj.frame() else { continue }; let floating = float.wrap != Wrap::Inline; + if if floating { !first } else { !in_piece } { + continue; + } let rect = if floating { floats.get(&oi).copied().unwrap_or_else(|| float_rect(at.page, at.col, at.para_y, w, h, float)) } else { diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 0616e41af..0171516f6 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -3438,6 +3438,21 @@ fn float_follows_its_paragraph_to_the_next_page() { assert!(para.lines[0].left >= 144.0, "text wraps beside it: {}", para.lines[0].left); } +/// A floating object belongs to its paragraph, not to the line holding its anchor: Word draws +/// it on the page the paragraph starts on. A Word cover page is one paragraph holding a page +/// break and then the page-anchored text box with the title; the box stays on the cover. +#[test] +fn a_float_anchored_after_a_page_break_stays_on_its_paragraph_s_first_page() { + let mut d = Document::from_text(&format!("{}\nAfter the cover", wordcraft_doc::para::PAGE_BREAK)); + d.insert_object(&Pos::body(0, 1), rect_shape(200.0, 100.0, page_float(100.0, 300.0)), &Default::default()).unwrap(); + let l = lay(&d); + assert_eq!(l.pages.len(), 2); + let on_cover = shapes(&l.pages[0].items); + assert_eq!(on_cover.len(), 1, "the shape is drawn on the cover: {on_cover:?}"); + assert!((on_cover[0].y - 300.0).abs() < 0.5, "{:?}", on_cover[0]); + assert!(shapes(&l.pages[1].items).is_empty(), "and not again on page two"); +} + #[test] fn floats_in_headers_and_table_cells_are_drawn() { use wordcraft_doc::para::{Anchor, Float, Wrap}; From 116e7697a1b1226ef1a862cf6f10dd39f017a695 Mon Sep 17 00:00:00 2001 From: Martin Ball <98639962+benandez@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:03:53 +0100 Subject: [PATCH 040/101] View Gridlines outlines header and footer tables (#1628) (#1633) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * View Gridlines outlines header and footer tables (#1628) Table Layout › View Gridlines drew only the body's table cells; a table in a header or footer showed no gridlines. Walk the page's header and footer items too. Fixes #1628 Co-Authored-By: Claude Opus 5.5 (1M context) * Test: Placed::Cell names its clip flag (#1617) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Brandon Thomas --- crates/ui-egui/src/canvas.rs | 44 +++++++++++++++++++++++++++++------- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index cf95692d9..12ab843e4 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -717,6 +717,15 @@ fn drawing_grid(body: wordcraft_geom::Rect, step: (f32, f32)) -> (Vec, Vec< (axis(body.x, body.w, step.0), axis(body.y, body.h, step.1)) } +/// The cells Table Layout › View Gridlines outlines on a page: the body's tables and the +/// header's and footer's (#1628), skipping cells covered by a merge. +fn gridline_cells(page: &Page) -> impl Iterator + '_ { + page.items.iter().chain(&page.header).chain(&page.footer).filter_map(|it| match it { + Placed::Cell { rect, covered: false, .. } => Some(*rect), + _ => None, + }) +} + /// The drawing grid's spacing in points at a screen scale: the document's spacing (`w:drawingGrid*Spacing`; /// ECMA-376's 1/8 inch when it doesn't say), doubled until the lines are at least /// [`DRAWING_GRID_MIN_PX`] apart on screen. @@ -1105,14 +1114,12 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { } // Table Layout › View Gridlines: table cell outlines (on screen only). if app.session.view.table_gridlines { - for it in &page.items { - if let Placed::Cell { rect, covered: false, .. } = it { - let r = Rect::from_min_size( - pos2(visual_sr.min.x + rect.x * page_scale, visual_sr.min.y + rect.y * page_scale), - vec2(rect.w * page_scale, rect.h * page_scale), - ); - painter.rect_stroke(r, 0.0, Stroke::new(0.5, t.blue.linear_multiply(0.6)), egui::StrokeKind::Middle); - } + for rect in gridline_cells(page) { + let r = Rect::from_min_size( + pos2(visual_sr.min.x + rect.x * page_scale, visual_sr.min.y + rect.y * page_scale), + vec2(rect.w * page_scale, rect.h * page_scale), + ); + painter.rect_stroke(r, 0.0, Stroke::new(0.5, t.blue.linear_multiply(0.6)), egui::StrokeKind::Middle); } } } @@ -4048,6 +4055,27 @@ mod tests { /// 0.5 cm in points. const HALF_CM: f32 = 72.0 * 0.5 / 2.54; + #[test] + fn table_gridlines_include_header_and_footer_tables() { + let cell = |y: f32, covered: bool, story: StoryRef| Placed::Cell { + rect: wordcraft_geom::Rect::new(0.0, y, 10.0, 10.0), + covered, + table: Default::default(), + row: 0, + cell: 0, + story, + clip: false, + }; + let page = Page { + items: vec![cell(100.0, false, StoryRef::Body), cell(110.0, true, StoryRef::Body)], + header: vec![cell(10.0, false, StoryRef::Part(1))], + footer: vec![cell(200.0, false, StoryRef::Part(2))], + ..Default::default() + }; + let ys: Vec = gridline_cells(&page).map(|r| r.y).collect(); + assert_eq!(ys, [100.0, 10.0, 200.0]); + } + #[test] fn drawing_grid_starts_at_the_margins_and_stays_inside_them() { // Letter page with 1" margins: the text area is 468 x 648 pt from (72, 72). From 98cda1ea50f98a05814fb3c62f1d15d5b46a45ab Mon Sep 17 00:00:00 2001 From: Larry Hymes Date: Wed, 14 Oct 2026 21:04:04 -0400 Subject: [PATCH 041/101] DOCX: keep a bare w:pgNumType when saving (#1634) Co-authored-by: Larry Hymes <19394645+lhymes@users.noreply.github.com> --- crates/doc/src/section.rs | 5 ++ crates/docx/src/read/props.rs | 1 + crates/docx/src/write/story.rs | 2 +- crates/docx/tests/section_page_number_type.rs | 48 +++++++++++++++++++ 4 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 crates/docx/tests/section_page_number_type.rs diff --git a/crates/doc/src/section.rs b/crates/doc/src/section.rs index 846505119..bb609e458 100644 --- a/crates/doc/src/section.rs +++ b/crates/doc/src/section.rs @@ -721,6 +721,10 @@ pub struct SectionProps { pub title_page: bool, pub page_num_start: Option, pub page_num_format: NumFormat, + /// The file carries a `w:pgNumType` element that sets nothing else (no start, decimal + /// format), so saving writes it back rather than dropping it. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub page_num_set: bool, pub line_numbers: Option, pub valign: PageVAlign, pub page_borders: Option, @@ -763,6 +767,7 @@ impl Default for SectionProps { title_page: false, page_num_start: None, page_num_format: NumFormat::Decimal, + page_num_set: false, line_numbers: None, valign: PageVAlign::Top, page_borders: None, diff --git a/crates/docx/src/read/props.rs b/crates/docx/src/read/props.rs index 0b8144dc6..7bf0dde37 100644 --- a/crates/docx/src/read/props.rs +++ b/crates/docx/src/read/props.rs @@ -655,6 +655,7 @@ pub fn sectpr(e: &El) -> (SectionProps, Vec) { if let Some(f) = k.attr("w:fmt") { s.page_num_format = NumFormat::from_ooxml(f); } + s.page_num_set = s.page_num_start.is_none() && s.page_num_format == NumFormat::Decimal; } "w:lnNumType" => { let mut l = LineNumbering::default(); diff --git a/crates/docx/src/write/story.rs b/crates/docx/src/write/story.rs index b9015edbf..7aa76cdb5 100644 --- a/crates/docx/src/write/story.rs +++ b/crates/docx/src/write/story.rs @@ -1362,7 +1362,7 @@ fn sectpr_body(w: &mut W, s: &SectionProps) { let refs: Vec<(&str, &str)> = a.iter().map(|(k, v)| (*k, v.as_str())).collect(); w.empty("w:lnNumType", &refs); } - if s.page_num_start.is_some() || s.page_num_format != wordcraft_doc::section::NumFormat::Decimal { + if s.page_num_set || s.page_num_start.is_some() || s.page_num_format != wordcraft_doc::section::NumFormat::Decimal { let mut a = vec![("w:fmt", s.page_num_format.ooxml().to_string())]; if let Some(st) = s.page_num_start { a.push(("w:start", st.to_string())); diff --git a/crates/docx/tests/section_page_number_type.rs b/crates/docx/tests/section_page_number_type.rs new file mode 100644 index 000000000..3dcf5c31b --- /dev/null +++ b/crates/docx/tests/section_page_number_type.rs @@ -0,0 +1,48 @@ +//! Section page-number settings (`w:pgNumType` in `w:sectPr`, ECMA-376 Part 1 §17.6.12). +//! Fixtures are hand-written from the spec. + +use std::io::{Read, Write}; + +fn docx(body: &str) -> Vec { + let root = r#""#; + let doc = format!( + r#"{body}"# + ); + let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, bytes) in [("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())] { + zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + zw.write_all(bytes).unwrap(); + } + zw.finish().unwrap().into_inner() +} + +fn document_xml(bytes: &[u8]) -> String { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut s = String::new(); + z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap(); + s +} + +#[test] +fn page_number_type_is_kept_even_when_it_only_repeats_the_defaults() { + // A decimal format with no start changes nothing, but the file said it, so saving says it too. + let body = r#"One"#; + let d = wordcraft_docx::read(&docx(body)).unwrap(); + let xml = document_xml(&wordcraft_docx::write(&d).unwrap()); + assert!(xml.contains(r#""#), "{xml}"); +} + +#[test] +fn page_number_type_with_a_start_and_format_still_round_trips() { + let body = r#"One"#; + let d = wordcraft_docx::read(&docx(body)).unwrap(); + let xml = document_xml(&wordcraft_docx::write(&d).unwrap()); + assert!(xml.contains(r#"w:fmt="upperRoman""#) && xml.contains(r#"w:start="3""#), "{xml}"); +} + +#[test] +fn a_section_without_page_number_type_writes_none() { + let body = r#"One"#; + let d = wordcraft_docx::read(&docx(body)).unwrap(); + assert!(!document_xml(&wordcraft_docx::write(&d).unwrap()).contains("pgNumType")); +} From ed980e4df7615489541f69cc55418421d148a65b Mon Sep 17 00:00:00 2001 From: Larry Hymes Date: Wed, 14 Oct 2026 21:04:15 -0400 Subject: [PATCH 042/101] DOCX: keep an explicit horizontal w:textDirection when saving (#1635) Co-authored-by: Larry Hymes <19394645+lhymes@users.noreply.github.com> --- crates/doc/src/props.rs | 3 +++ crates/doc/src/section.rs | 4 ++++ crates/docx/src/read/props.rs | 10 ++++++++-- crates/docx/src/write/props.rs | 2 +- crates/docx/src/write/story.rs | 2 +- crates/docx/tests/section_text_direction.rs | 11 +++++++++++ 6 files changed, 28 insertions(+), 4 deletions(-) diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index b04ddc9de..adf1a63a7 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -1124,6 +1124,9 @@ pub struct CellProps { pub margins: Option<[f32; 4]>, /// Which way the cell's text runs. pub text_direction: TextDirection, + /// The file states the horizontal direction (`lrTb`) outright; saving writes it back. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub text_direction_set: bool, pub no_wrap: bool, /// The cell's last paragraph takes no room when it is empty (`w:hideMark`): a row of such /// empty cells shrinks to its borders. diff --git a/crates/doc/src/section.rs b/crates/doc/src/section.rs index bb609e458..a16371eda 100644 --- a/crates/doc/src/section.rs +++ b/crates/doc/src/section.rs @@ -734,6 +734,9 @@ pub struct SectionProps { /// sections turn the page's text area (headers and footers stay horizontal). #[serde(skip_serializing_if = "is_horizontal")] pub text_direction: crate::props::TextDirection, + /// The file states the horizontal direction (`lrTb`) outright; saving writes it back. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub text_direction_set: bool, /// Tracked change of the section's properties (`w:sectPrChange`). #[serde(skip_serializing_if = "Option::is_none")] pub fmt_change: Option>>, @@ -773,6 +776,7 @@ impl Default for SectionProps { page_borders: None, rtl: false, text_direction: crate::props::TextDirection::Horizontal, + text_direction_set: false, fmt_change: None, doc_grid: None, footnote_pr: NoteProps::default(), diff --git a/crates/docx/src/read/props.rs b/crates/docx/src/read/props.rs index 7bf0dde37..05ce31954 100644 --- a/crates/docx/src/read/props.rs +++ b/crates/docx/src/read/props.rs @@ -558,7 +558,10 @@ pub fn tcpr(pc: &PropCtx, e: &El) -> (CellProps, bool) { "w:noWrap" => c.no_wrap = on_off(k), "w:hideMark" => c.hide_mark = on_off(k), "w:tcMar" => c.margins = Some(margins(k)), - "w:textDirection" => c.text_direction = TextDirection::from_ooxml(k.attr("w:val").unwrap_or("")), + "w:textDirection" => { + c.text_direction = TextDirection::from_ooxml(k.attr("w:val").unwrap_or("")); + c.text_direction_set = k.attr("w:val") == Some("lrTb"); + } "w:vAlign" => { c.valign = match k.attr("w:val") { Some("center") => VAlign::Center, @@ -649,7 +652,10 @@ pub fn sectpr(e: &El) -> (SectionProps, Vec) { "w:endnotePr" => s.endnote_pr = note_pr(k, true), "w:titlePg" => s.title_page = on_off(k), "w:bidi" => s.rtl = on_off(k), - "w:textDirection" => s.text_direction = wordcraft_doc::props::TextDirection::from_ooxml(k.attr("w:val").unwrap_or("")), + "w:textDirection" => { + s.text_direction = wordcraft_doc::props::TextDirection::from_ooxml(k.attr("w:val").unwrap_or("")); + s.text_direction_set = k.attr("w:val") == Some("lrTb"); + } "w:pgNumType" => { s.page_num_start = k.attr("w:start").and_then(u32_of); if let Some(f) = k.attr("w:fmt") { diff --git a/crates/docx/src/write/props.rs b/crates/docx/src/write/props.rs index 8dd325bd2..53bced13d 100644 --- a/crates/docx/src/write/props.rs +++ b/crates/docx/src/write/props.rs @@ -542,7 +542,7 @@ fn tcpr_inner(w: &mut W, c: &CellProps) { if let Some(m) = &c.margins { margins(w, "w:tcMar", m); } - if c.text_direction.is_turned() { + if c.text_direction.is_turned() || c.text_direction_set { w.val("w:textDirection", c.text_direction.ooxml()); } match c.valign { diff --git a/crates/docx/src/write/story.rs b/crates/docx/src/write/story.rs index 7aa76cdb5..5b29e91af 100644 --- a/crates/docx/src/write/story.rs +++ b/crates/docx/src/write/story.rs @@ -1398,7 +1398,7 @@ fn sectpr_body(w: &mut W, s: &SectionProps) { if s.title_page { w.empty("w:titlePg", &[]); } - if s.text_direction.is_turned() { + if s.text_direction.is_turned() || s.text_direction_set { w.val("w:textDirection", s.text_direction.ooxml()); } if s.rtl { diff --git a/crates/docx/tests/section_text_direction.rs b/crates/docx/tests/section_text_direction.rs index 77efbaf56..c17656f40 100644 --- a/crates/docx/tests/section_text_direction.rs +++ b/crates/docx/tests/section_text_direction.rs @@ -48,3 +48,14 @@ fn section_text_direction_round_trips_in_schema_order() { let again: Vec = back.sections().iter().map(|(_, s)| s.text_direction).collect(); assert_eq!(again, dirs); } + +#[test] +fn explicit_horizontal_direction_is_kept_in_sections_and_cells() { + let body = r#"CellBody"#; + let d = wordcraft_docx::read(&docx(body)).unwrap(); + let xml = document_xml(&wordcraft_docx::write(&d).unwrap()); + assert_eq!(xml.matches(r#""#).count(), 2, "{xml}"); + // A horizontal section or cell that never said so still writes nothing. + let plain = wordcraft_docx::read(&docx(r#"Body"#)).unwrap(); + assert!(!document_xml(&wordcraft_docx::write(&plain).unwrap()).contains("textDirection")); +} From 2c26fa58a8ca5131108563670af763637e81e803 Mon Sep 17 00:00:00 2001 From: Fin Warman Date: Thu, 15 Oct 2026 02:04:33 +0100 Subject: [PATCH 043/101] Draw arrowheads on open freeform paths (#1645) Co-authored-by: Brandon Thomas --- ROADMAP.md | 3 ++- contributors/people.toml | 1 + crates/layout/src/display.rs | 33 ++++++++++++++++++++++++++ crates/layout/src/tests.rs | 45 ++++++++++++++++++++++++++++++++++++ docs/file-format-parity.md | 5 ++-- docs/gaps.md | 1 + 6 files changed, 85 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index ca52d1370..37e860361 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-14 · **Change:** trivial (progress log: comment deletion leaves no dangling anchors or replies, #1345; previously trivial: legacy DOC DIB conversion honours the BLIP byte limit; related #1334 and #1554; previously trivial: progress log: justified vertical page alignment, #1320; previously trivial: progress log: the save prompt takes the keyboard from the page, #1597; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; 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-14 · **Change:** trivial (progress log: comment deletion leaves no dangling anchors or replies, #1345; previously trivial: progress log: open DrawingML freeform paths draw their end arrowhead; previously trivial: progress log: full screen, #980; previously trivial: progress log: PAGE fields on the pages a paragraph runs onto, #1352; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; 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 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -300,6 +300,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-11 | File › Print: page preview, page range (all, current, selected, custom 1-3,5), odd/even, copies and collate go into the printed PDF (`file.printPdf`, `ui.print` params); printer, duplex and pages per sheet are still the system dialog's (#641, stage 1) | | 2026-10-11 | A vertically merged table cell (eraser on a horizontal border) lays out, shows and clicks as one cell spanning its rows (#640) | | 2026-10-11 | Word XML files (#548): Word XML Document (Flat OPC `.xml`) opens and saves through the DOCX reader and writer; Word 2003 XML opens (styles, lists, tables, sections, notes, comments, pictures, hyperlinks) | +| 2026-10-14 | Open DrawingML freeform paths draw `a:tailEnd` at the final path segment | | 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | | 2026-10-11 | Font fallback indexes which characters each installed font maps, reading each font file once (#605): text in a script no installed font covers no longer re-reads every system font per distinct character (200 such characters: 13 s → 0.2 s) | | 2026-10-13 | Markdown import of text full of backtick runs that never close is linear (#1336): every code span and link bracket over them searched the rest of the text for a closing run (2 MB in brackets: 17.8 s → 0.06 s) | diff --git a/contributors/people.toml b/contributors/people.toml index 816997012..543a14862 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -112,3 +112,4 @@ real_name = "Robert Lee" [people.finwarman] real_name = "Fin Warman" + diff --git a/crates/layout/src/display.rs b/crates/layout/src/display.rs index 3fc994cab..9851af73e 100644 --- a/crates/layout/src/display.rs +++ b/crates/layout/src/display.rs @@ -1367,6 +1367,12 @@ fn shape_draws( if closed && let Some(first) = pts.first().copied() { pts.push(first); } + if !closed + && extra.arrow_end + && let Some(arrow) = path_arrowhead(&pts, color, width) + { + out.push(arrow); + } out.push(Draw::Ink { pts, color, width: width.max(0.25), alpha: f.alpha }); } continue; @@ -1379,9 +1385,36 @@ fn shape_draws( segs.push(PathSeg::Close); } out.push(Draw::Path { segs, fill: if closed { fill } else { None }, stroke, stroke_width: width }); + if !closed + && extra.arrow_end + && let Some(color) = stroke + { + let points: Vec<(f32, f32)> = pts.iter().map(|[x, y]| (*x, *y)).collect(); + if let Some(arrow) = path_arrowhead(&points, color, width) { + out.push(arrow); + } + } } } +fn path_arrowhead(points: &[(f32, f32)], color: Rgb, stroke_width: f32) -> Option { + let tip = *points.last()?; + let (prev_x, prev_y) = points.iter().rev().skip(1).copied().find(|p| *p != tip)?; + let dx = tip.0 - prev_x; + let dy = tip.1 - prev_y; + let length = dx.hypot(dy); + if !length.is_finite() || length <= f32::EPSILON { + return None; + } + let (dx, dy) = (dx / length, dy / length); + let width = if stroke_width.is_finite() { stroke_width.clamp(0.25, 200.0) } else { 0.75 }; + let len = (width * 3.5).max(7.0); + let (bx, by) = (tip.0 - dx * len, tip.1 - dy * len); + let (nx, ny) = (-dy * len * 0.45, dx * len * 0.45); + let segs = vec![PathSeg::Move(tip.0, tip.1), PathSeg::Line(bx + nx, by + ny), PathSeg::Line(bx - nx, by - ny), PathSeg::Close]; + Some(Draw::Path { segs, fill: Some(color), stroke: None, stroke_width: 0.0 }) +} + /// A line or connector at `rect` (its unflipped frame: flips are the frame's turn), with its /// arrowheads. fn line_draws( diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 0171516f6..b7d1556eb 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -1836,6 +1836,51 @@ fn text_wraps_around_square_float() { assert!(l.pages[0].items.iter().any(|i| matches!(i, Placed::Shape { .. }))); } +#[test] +fn freeform_arrow_end_draws_at_the_last_point() { + use std::sync::Arc; + use wordcraft_doc::connector::ShapeExtra; + use wordcraft_doc::freeform::{FreePath, Freeform}; + use wordcraft_doc::graphic::PathSeg; + use wordcraft_doc::para::{Float, ShapeKind}; + use wordcraft_doc::props::Rgb; + + let shape = InlineObject::Shape { + kind: ShapeKind::Freeform, + w: 60.0, + h: 40.0, + fill: None, + stroke: Some(Rgb::BLACK), + stroke_width: 1.0, + float: Float::default(), + story: None, + freeform: Some(Arc::new(Freeform { + w: 60.0, + h: 40.0, + paths: vec![FreePath { pts: vec![[0.0, 40.0], [60.0, 0.0]], closed: false }], + ..Default::default() + })), + effects: Default::default(), + extra: ShapeExtra { arrow_end: true, ..Default::default() }, + }; + let mut doc = Document::from_text("Scan"); + doc.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap(); + + let draws = flat(display::page_display(&doc, &lay(&doc).pages[0], &Default::default())); + let endpoint = draws.iter().find_map(|draw| match draw { + display::Draw::Ink { pts, color: Rgb::BLACK, .. } => pts.last().copied(), + _ => None, + }); + let arrow_tip = draws.iter().find_map(|draw| match draw { + display::Draw::Path { fill: Some(Rgb::BLACK), segs, .. } if segs.len() == 4 => match segs.first() { + Some(PathSeg::Move(x, y)) => Some((*x, *y)), + _ => None, + }, + _ => None, + }); + assert_eq!(arrow_tip, endpoint, "the arrowhead tip meets the custom path's final point"); +} + #[test] fn hidden_drawings_are_neither_drawn_nor_take_room() { // A hidden floating shape (VML `visibility:hidden`, `wp:docPr/@hidden`): no wrap area, nothing diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 95d4fb2a8..f1f844dc0 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-14 · **Change:** trivial (Bidi theme font slots; previously trivial: missing roman fonts drawn in Cambria; previously trivial: legacy DOC DIB conversion honours the BLIP byte limit; related #1334 and #1554; previously trivial: right triangles keep their right angle; previously trivial: section `w:vAlign` "both" round-trips as Justified, #1320; previously trivial: Word's text watermark written back to DOCX headers, #1290; previously trivial: VML preset shapes drawn, #1377; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: equations as MathML in HTML export, copy and paste, #816; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously 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-11 · **Last updated:** 2026-10-14 · **Change:** trivial (Bidi theme font slots; previously trivial: open DrawingML freeform paths draw their arrowheads; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously 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; @@ -64,7 +64,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Legacy form fields (`w:ffData`), check boxes, drop-downs | ❌ | ❌ | | | DrawingML pictures inline and anchored (`wp:anchor`), wrap square/tight/through/top-bottom | ✅ | ✅ | Tight/through read and written with their `wp:wrapPolygon` (and `edited`); without one, a shape's outline is written (a picture's rectangle). `wrapText` (both sides, left, right, largest) read and written for square, tight and through | | WordArt text effects (`w14:textFill`, `w14:textOutline`, `w14:shadow`, `w14:glow`, `w14:reflection` in `w:rPr`) | ✅ | ✅ | Solid, gradient (linear) and no fill, outline colour/width, shadow, glow, reflection (#416); `w14:scene3d`/`w14:props3d` dropped | -| DrawingML shapes and text boxes (`wps:`) | ✅ | ✅ | Preset geometries subset (callouts and flowchart boxes drawn as the nearest shape, #611); rounded rectangles keep their own corner (`roundRect` `adj`: read, written back, drawn on screen and in PDF, #1476); fill and outline from the shape or from the theme through its style references (`a:fillRef`/`a:lnRef`, #611), scheme, preset, system, scRGB and HSL colours; rotation and flips (`a:xfrm` `rot`/`flipH`/`flipV`, pictures and groups too, #332), the effect extent covering the rotated bounds; `wps:bodyPr` `vert` and `anchor`, internal margins (`lIns`/`tIns`/`rIns`/`bIns`) and Resize shape to fit text (`a:spAutoFit`: the text is never cut, #743), linked text boxes (`wps:txbx/@id`, `wps:linkedTxbx` `id`/`seq`, #369); text warps (`a:prstTxWarp` with adjust values, all 41 presets kept; unknown ones dropped), connectors (`straightConnector1`, `bentConnector2`–`5`, `curvedConnector2`–`5` read; 3-segment forms written) with glued ends (`wps:cNvCnPr` `a:stCxn`/`a:endCxn`; the glued shapes keep their drawing ids) and arrowheads (`a:headEnd`/`a:tailEnd`, drawn as triangles) (#416). Hidden drawings (`wp:docPr/@hidden`, a group member's `cNvPr/@hidden`) are kept and saved but neither drawn nor laid out, nor exported to other formats, as in Word for Windows; `.doc` and RTF shapes don't read their hidden flag yet | +| DrawingML shapes and text boxes (`wps:`) | ✅ | ✅ | Preset geometries subset (callouts and flowchart boxes drawn as the nearest shape, #611); rounded rectangles keep their own corner (`roundRect` `adj`: read, written back, drawn on screen and in PDF, #1476); fill and outline from the shape or from the theme through its style references (`a:fillRef`/`a:lnRef`, #611), scheme, preset, system, scRGB and HSL colours; rotation and flips (`a:xfrm` `rot`/`flipH`/`flipV`, pictures and groups too, #332), the effect extent covering the rotated bounds; `wps:bodyPr` `vert` and `anchor`, internal margins (`lIns`/`tIns`/`rIns`/`bIns`) and Resize shape to fit text (`a:spAutoFit`: the text is never cut, #743), linked text boxes (`wps:txbx/@id`, `wps:linkedTxbx` `id`/`seq`, #369); text warps (`a:prstTxWarp` with adjust values, all 41 presets kept; unknown ones dropped), connectors (`straightConnector1`, `bentConnector2`–`5`, `curvedConnector2`–`5` read; 3-segment forms written) with glued ends (`wps:cNvCnPr` `a:stCxn`/`a:endCxn`; the glued shapes keep their drawing ids) and arrowheads (`a:headEnd`/`a:tailEnd`, drawn as triangles), including custom-geometry paths (#416). Hidden drawings (`wp:docPr/@hidden`, a group member's `cNvPr/@hidden`) are kept and saved but neither drawn nor laid out, nor exported to other formats, as in Word for Windows; `.doc` and RTF shapes don't read their hidden flag yet | | Freeform shapes (`a:custGeom`: `a:moveTo`, `a:lnTo`, Bézier curves flattened) and WordCraft ink | ✅ | ✅ | Ink is written as a freeform `wps:wsp` in a `wp:anchor` (round caps, alpha for highlighter) and recognised again by its drawing name (#307). It is saved as a custom-geometry shape, not as Word's own ink (InkML in `w14:contentPart`), so Word shows WordCraft ink as a freeform shape it can move and recolour but not erase with its ink eraser; arcs drawn straight | | Group shapes (`wpg:`), drawing canvas (`wpc:`) | ✅ | ✅ | Groups (#267) and canvases (#344) with pictures, shapes, freeforms (custom geometry, #743; a path drawn back to its start without `a:close` is filled, as in Word) and text boxes; nested groups flattened. The canvas schema has no transform, so a turned or flipped canvas keeps its turn in our own `wpc:extLst` extension (`urn:wordcraft:canvas-xfrm`; Word shows it unturned). Legacy VML canvases (`v:group editas="canvas"`) aren't read as canvases | | VML (`w:pict`, `v:shape`, `v:textbox`) | 🟡 | 🟡 | Pictures, text boxes, lines (with arrowheads), rectangles and ovals, best effort (#242, #611). Preset shapes (`v:shape` whose `o:spt`, own or from its `v:shapetype`, or Word's shapetype id `_x0000_tNN`, names a preset; [MS-ODRAW] §2.4.24 numbers) are drawn and saved like the DrawingML preset of the same shape: exactly for rectangle, rounded rectangle, ellipse, diamond, isosceles triangle, five-point star, right arrow, line and straight connector, heart, text box, and the flowchart process, decision and connector; the others as their nearest shape, as for DrawingML (left/up/down arrows as the arrow, right triangle as the triangle, callouts as their box). WordArt text shapes and picture frames without a picture aren't drawn. Fill, outline and size come from the shape, else its shapetype; text inside keeps the shape around it (#1377). Adjust values (`adj`) aren't read: presets use WordCraft's proportions. Text boxes keep their internal margins (`inset`, unless `o:insetmode="auto"`), Resize shape to fit text (`mso-fit-shape-to-text`) and vertical alignment (`v-text-anchor`). Hidden shapes (`visibility:hidden`, like DrawingML's `wp:docPr/@hidden`) are kept and saved but neither drawn nor laid out, as in Word for Windows (Word for Mac draws them). Not read: vertical text (`layout-flow`) Word's text watermark (a `v:textpath` shape in a header or footer, recognised by Word's `PowerPlusWaterMarkObject` id or by being centred on the page both ways) becomes the document's watermark: text, font, fill colour, semitransparent (`v:fill` `opacity`) and diagonal (any turn); drawn with WordCraft's own size and angle, on every page even when only some sections' headers (or only the first-page header) have it (#1290). Saving writes the watermark back the same way: a `v:textpath` shape centred on the margins, behind the text, at the start of every header's first paragraph; a section whose pages would show no header gets a header holding only the watermark (default, and first-page or even-page ones when those are on), while sections linked to an earlier header stay linked. Other VML is written only for OLE object pictures. Other VML WordArt (`v:textpath` in the body, or a header logo) is still dropped | @@ -250,6 +250,7 @@ attributes inside stories and style or list definitions; a regenerated theme. Te | 2026-10-13 | trivial | Word 365 cover page (#743): a position wrapped in `mc:AlternateContent` (`wp14:pctPosVOffset`) takes the Fallback's offset; a freeform path drawn back to its start is closed | 2026-10-12 | trivial | Word 365 cover page (#743): text-box internal margins and `a:spAutoFit` read and written; freeforms inside groups keep their custom geometry | | 2026-10-11 | trivial | DOCX diagrams (#611): shapes and connectors styled by the theme (`wps:style`) show their outline and fill; preset/system colours; VML lines and rectangles; lines run top-left to bottom-right as in DrawingML | +| 2026-10-14 | trivial | Open DrawingML freeform paths draw `a:tailEnd` arrowheads from their final segment | | 2026-10-11 | trivial | WordArt text effects (`w14:` run properties), text warps (`a:prstTxWarp`) and connectors with glued ends read and written (#416) | | 2026-10-11 | trivial | Content controls read and written with their properties and nesting (#381) | | 2026-10-11 | trivial | Charts made in WordCraft written as chart parts and read back as editable (#409) | diff --git a/docs/gaps.md b/docs/gaps.md index c698251db..696b16b04 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -127,6 +127,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | 2026-10-11 | trivial | File › Options panes landed (#485); removed from the dialog-depth list | | 2026-10-11 | trivial | #18: the document canvas describes its text, structure and caret to screen readers through AccessKit (#488); real screen-reader testing remains | | 2026-10-11 | trivial | Compare / Combine Documents and AutoCorrect Options dialogs and the Restrict Editing pane landed (#412); removed from the dialog-depth list; duplicate #7 rows merged | +| 2026-10-14 | trivial | Open DrawingML freeform paths draw their `a:tailEnd` arrowhead | | 2026-10-11 | trivial | #10: WordArt text effects and Transform warps, connectors glued to shapes (#416) | | 2026-10-11 | trivial | #5 narrowed: content controls kept, shown and edited (#381); move tracking remains | | 2026-10-11 | trivial | Insert › Chart landed (#409); gap #13 narrowed to editing Word's charts, more types and SmartArt, 35–55 h | From 87410b4e3f9bee7ca16d35933772c9550b1adc17 Mon Sep 17 00:00:00 2001 From: Larry Hymes Date: Wed, 14 Oct 2026 21:21:11 -0400 Subject: [PATCH 044/101] DOCX: keep w:formProt when saving (#1636) Co-authored-by: Larry Hymes <19394645+lhymes@users.noreply.github.com> Co-authored-by: Brandon Thomas --- crates/doc/src/section.rs | 5 +++ crates/docx/src/read/props.rs | 1 + crates/docx/src/write/story.rs | 3 ++ crates/docx/tests/section_form_protection.rs | 45 ++++++++++++++++++++ 4 files changed, 54 insertions(+) create mode 100644 crates/docx/tests/section_form_protection.rs diff --git a/crates/doc/src/section.rs b/crates/doc/src/section.rs index a16371eda..21591fa42 100644 --- a/crates/doc/src/section.rs +++ b/crates/doc/src/section.rs @@ -727,6 +727,10 @@ pub struct SectionProps { pub page_num_set: bool, pub line_numbers: Option, pub valign: PageVAlign, + /// Form protection of the section (`w:formProt`, ECMA-376 §17.6.6): `Some` when the file + /// states it. Kept so saving writes it back; editing does not act on it. + #[serde(skip_serializing_if = "Option::is_none")] + pub form_prot: Option, pub page_borders: Option, /// Text direction / bidi section. pub rtl: bool, @@ -773,6 +777,7 @@ impl Default for SectionProps { page_num_set: false, line_numbers: None, valign: PageVAlign::Top, + form_prot: None, page_borders: None, rtl: false, text_direction: crate::props::TextDirection::Horizontal, diff --git a/crates/docx/src/read/props.rs b/crates/docx/src/read/props.rs index 05ce31954..6717d2637 100644 --- a/crates/docx/src/read/props.rs +++ b/crates/docx/src/read/props.rs @@ -650,6 +650,7 @@ pub fn sectpr(e: &El) -> (SectionProps, Vec) { } "w:footnotePr" => s.footnote_pr = note_pr(k, true), "w:endnotePr" => s.endnote_pr = note_pr(k, true), + "w:formProt" => s.form_prot = Some(on_off(k)), "w:titlePg" => s.title_page = on_off(k), "w:bidi" => s.rtl = on_off(k), "w:textDirection" => { diff --git a/crates/docx/src/write/story.rs b/crates/docx/src/write/story.rs index 5b29e91af..8151a7316 100644 --- a/crates/docx/src/write/story.rs +++ b/crates/docx/src/write/story.rs @@ -1389,6 +1389,9 @@ fn sectpr_body(w: &mut W, s: &SectionProps) { let refs: Vec<(&str, &str)> = ca.iter().map(|(k, v)| (*k, v.as_str())).collect(); w.empty("w:cols", &refs); } + if let Some(on) = s.form_prot { + w.empty("w:formProt", if on { &[] } else { &[("w:val", "0")] }); + } match s.valign { wordcraft_doc::section::PageVAlign::Top => {} wordcraft_doc::section::PageVAlign::Center => w.val("w:vAlign", "center"), diff --git a/crates/docx/tests/section_form_protection.rs b/crates/docx/tests/section_form_protection.rs new file mode 100644 index 000000000..e1bdbdb96 --- /dev/null +++ b/crates/docx/tests/section_form_protection.rs @@ -0,0 +1,45 @@ +//! Section form protection (`w:formProt` in `w:sectPr`, ECMA-376 Part 1 §17.6.6). Fixtures are +//! hand-written from the spec. + +use std::io::{Read, Write}; + +fn docx(body: &str) -> Vec { + let root = r#""#; + let doc = format!( + r#"{body}"# + ); + let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, bytes) in [("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())] { + zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + zw.write_all(bytes).unwrap(); + } + zw.finish().unwrap().into_inner() +} + +fn document_xml(bytes: &[u8]) -> String { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut s = String::new(); + z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap(); + s +} + +#[test] +fn form_protection_round_trips_in_schema_order() { + // Three sections: protected, explicitly unprotected, and one that says nothing. + let body = concat!( + r#"A"#, + r#"B"#, + r#"C"# + ); + let d = wordcraft_docx::read(&docx(body)).unwrap(); + let flags: Vec> = d.sections().iter().map(|(_, s)| s.form_prot).collect(); + assert_eq!(flags, vec![Some(true), Some(false), None]); + let out = wordcraft_docx::write(&d).unwrap(); + let xml = document_xml(&out); + // Before w:titlePg in CT_SectPr's sequence; a section that never said it writes none. + assert!(xml.contains(r#""#), "{xml}"); + assert!(xml.contains(r#""#), "{xml}"); + assert_eq!(xml.matches("w:formProt").count(), 2); + let again: Vec> = wordcraft_docx::read(&out).unwrap().sections().iter().map(|(_, s)| s.form_prot).collect(); + assert_eq!(again, flags); +} From 718eb02b8910e25814c28867a9f091be93bf7a98 Mon Sep 17 00:00:00 2001 From: Larry Hymes Date: Wed, 14 Oct 2026 21:21:23 -0400 Subject: [PATCH 045/101] DOCX: keep row-level w:tblPrEx when saving (#1637) Co-authored-by: Larry Hymes <19394645+lhymes@users.noreply.github.com> Co-authored-by: Brandon Thomas --- crates/doc/src/props.rs | 4 ++ crates/docx/src/read/story.rs | 1 + crates/docx/src/write/props.rs | 32 +++++++++++ crates/docx/tests/row_property_exceptions.rs | 56 ++++++++++++++++++++ 4 files changed, 93 insertions(+) create mode 100644 crates/docx/tests/row_property_exceptions.rs diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index adf1a63a7..31960a7df 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -1074,6 +1074,10 @@ pub struct RowProps { pub header: bool, /// Don't let the row break across pages. pub cant_split: bool, + /// Table properties this row overrides (`w:tblPrEx`, ECMA-376 §17.4.61): width, alignment, + /// indent, borders, shading, layout and cell margins. `Some` when the file has the element. + #[serde(skip_serializing_if = "Option::is_none")] + pub exceptions: Option>, /// Grid columns skipped before the row's first cell (`w:gridBefore`), and the preferred /// width of that gap in points (`w:wBefore`). #[serde(skip_serializing_if = "is_zero")] diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index 3e34b1ce2..7287bde45 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -1532,6 +1532,7 @@ impl Reader<'_> { collect(t, "w:tr", &mut rows, &mut Vec::new(), 0); for (tr, row_controls) in rows.into_iter().take(MAX_ROWS) { let mut row = Row { props: tr.child("w:trPr").map(trpr).unwrap_or_default(), cells: Vec::new(), controls: row_controls }; + row.props.exceptions = tr.child("w:tblPrEx").map(|e| Box::new(self.pc.tblpr(e))); if let Some(ch) = tr.child("w:trPr").and_then(|p| p.child("w:trPrChange")) { let old = ch.child("w:trPr").map(trpr).unwrap_or_default(); row.props.fmt_change = PropChange::boxed(self.revision(RevisionKind::Format, ch), old); diff --git a/crates/docx/src/write/props.rs b/crates/docx/src/write/props.rs index 53bced13d..9606e36bc 100644 --- a/crates/docx/src/write/props.rs +++ b/crates/docx/src/write/props.rs @@ -464,6 +464,9 @@ fn tblpr_inner(w: &mut W, t: &TableProps) { /// `w:trPr` (when the row has any), with a tracked change last as for [`tblpr`]. pub fn trpr(w: &mut W, r: &RowProps, change: Option<&ChangeAttrs>) { + if let Some(x) = &r.exceptions { + tblprex(w, x); + } let change = change.zip(r.fmt_change.as_ref()); let gaps = r.grid_before > 0 || r.grid_after > 0 || r.w_before.is_some() || r.w_after.is_some(); if r.height.is_none() && !r.header && !r.cant_split && !gaps && change.is_none() { @@ -477,6 +480,35 @@ pub fn trpr(w: &mut W, r: &RowProps, change: Option<&ChangeAttrs>) { w.close("w:trPr"); } +/// `w:tblPrEx`: only the properties the row overrides (it takes no style, float or look). +fn tblprex(w: &mut W, t: &TableProps) { + w.open("w:tblPrEx", &[]); + if let Some(p) = t.width_pct { + w.empty("w:tblW", &[("w:w", &n(round(p.clamp(0.0, 1000.0) * 50.0))), ("w:type", "pct")]); + } else if let Some(v) = t.width { + w.empty("w:tblW", &[("w:w", &twips(v.max(0.0))), ("w:type", "dxa")]); + } + if let Some(a) = t.align { + w.val("w:jc", align_val(if a == Align::Justify || a == Align::Distribute { Align::Left } else { a })); + } + if let Some(i) = t.indent { + w.empty("w:tblInd", &[("w:w", &twips(i)), ("w:type", "dxa")]); + } + if let Some(b) = &t.borders { + borders(w, "w:tblBorders", b, Some("w:insideH"), &[]); + } + if let Some(s) = t.shading { + w.empty("w:shd", &[("w:val", "clear"), ("w:color", "auto"), ("w:fill", &s.hex())]); + } + if t.fixed { + w.empty("w:tblLayout", &[("w:type", "fixed")]); + } + if let Some(m) = &t.cell_margins { + margins(w, "w:tblCellMar", m); + } + w.close("w:tblPrEx"); +} + fn trpr_inner(w: &mut W, r: &RowProps) { // The grid gaps go first, in the schema's order (as Word writes them). if r.grid_before > 0 { diff --git a/crates/docx/tests/row_property_exceptions.rs b/crates/docx/tests/row_property_exceptions.rs new file mode 100644 index 000000000..ce962c2d8 --- /dev/null +++ b/crates/docx/tests/row_property_exceptions.rs @@ -0,0 +1,56 @@ +//! Row-level table property exceptions (`w:tblPrEx` in `w:tr`, ECMA-376 Part 1 §17.4.61). +//! Fixtures are hand-written from the spec. + +use std::io::{Read, Write}; + +fn docx(body: &str) -> Vec { + let root = r#""#; + let doc = format!( + r#"{body}"# + ); + let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, bytes) in [("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())] { + zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + zw.write_all(bytes).unwrap(); + } + zw.finish().unwrap().into_inner() +} + +fn document_xml(bytes: &[u8]) -> String { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut s = String::new(); + z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap(); + s +} + +fn row(ex: &str, text: &str) -> String { + format!( + r#"{ex}{text}"# + ) +} + +#[test] +fn row_property_exceptions_round_trip_before_the_row_properties() { + let ex = r#""#; + let body = format!(r#"{}{}"#, row(ex, "A"), row("", "B")); + let d = wordcraft_docx::read(&docx(&body)).unwrap(); + let out = wordcraft_docx::write(&d).unwrap(); + let xml = document_xml(&out); + assert_eq!(xml.matches("").count(), 1, "{xml}"); + assert!(xml.contains(r#""#), "{xml}"); + // Reading the output again gives the same exception on the first row only. + let back = wordcraft_docx::read(&out).unwrap(); + let t = back.body.iter().find_map(|b| b.as_table()).unwrap(); + let x = t.rows[0].props.exceptions.as_ref().expect("first row keeps its exceptions"); + assert_eq!(x.width, Some(150.0)); + assert!(x.fixed); + assert!(t.rows[1].props.exceptions.is_none()); +} + +#[test] +fn an_empty_row_exception_element_is_kept() { + let body = format!(r#"{}"#, row("", "A")); + let d = wordcraft_docx::read(&docx(&body)).unwrap(); + let xml = document_xml(&wordcraft_docx::write(&d).unwrap()); + assert!(xml.contains("") || xml.contains(""), "{xml}"); +} From 8b0194bc232cd535e3f819125ed7d73557a7f156 Mon Sep 17 00:00:00 2001 From: "Sir Clangington III." Date: Thu, 15 Oct 2026 03:21:35 +0200 Subject: [PATCH 046/101] M12: preserve document properties when importing legacy DOC and DOT (#1640) * M12: preserve document properties when importing legacy DOC and DOT M12: preserve document properties when importing legacy DOC and DOT Integrate current upstream while retaining import behavior and embedded-font loading. * docbin: as_chunks for fixed-size records (clippy::chunks_exact_to_as_chunks on Rust 1.99); rustfmt Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: LabDennis <341377186+LabDennis@users.noreply.github.com> Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/docbin/src/lib.rs | 4 + crates/docbin/src/metadata.rs | 114 ++++++++++++++++++++++++++++ crates/docbin/src/tests.rs | 1 + crates/docbin/src/tests/metadata.rs | 98 ++++++++++++++++++++++++ docs/file-format-parity.md | 15 ++++ 5 files changed, 232 insertions(+) create mode 100644 crates/docbin/src/metadata.rs create mode 100644 crates/docbin/src/tests/metadata.rs diff --git a/crates/docbin/src/lib.rs b/crates/docbin/src/lib.rs index e076ec2be..2c8beaaeb 100644 --- a/crates/docbin/src/lib.rs +++ b/crates/docbin/src/lib.rs @@ -15,6 +15,7 @@ mod fkp; mod fmt; mod list; mod media; +mod metadata; mod notes; mod officeart; mod piece; @@ -148,6 +149,9 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result(comp: &mut cfb::CompoundFile) -> Option { + let stream = comp.open_stream("/\u{5}SummaryInformation").ok()?; + if stream.len() > MAX_STREAM { + return None; + } + let mut bytes = Vec::new(); + stream.take(MAX_STREAM + 1).read_to_end(&mut bytes).ok()?; + if bytes.len() as u64 > MAX_STREAM { + return None; + } + parse(&bytes) +} + +fn u32_at(bytes: &[u8], offset: usize) -> Option { + Some(u32::from_le_bytes(bytes.get(offset..offset.checked_add(4)?)?.try_into().ok()?)) +} + +fn parse(bytes: &[u8]) -> Option { + if bytes.get(..2)? != [0xfe, 0xff] || bytes.get(28..44)? != SUMMARY_FMTID || u32_at(bytes, 24)? != 1 { + return None; + } + if u16::from_le_bytes(bytes.get(2..4)?.try_into().ok()?) > 1 { + return None; + } + let offset = u32_at(bytes, 44)? as usize; + if offset < 48 || !offset.is_multiple_of(4) { + return None; + } + let size = u32_at(bytes, offset)? as usize; + let set = bytes.get(offset..offset.checked_add(size)?)?; + let count = u32_at(set, 4)? as usize; + if count > 4096 { + return None; + } + let table_end = 8 + count * 8; + let table = set.get(8..table_end)?; + // Offsets are relative to the property set. Bound each value by the next property so a + // forged string length cannot read another property's bytes as part of the title. + let property = |id: u32| -> Option<&[u8]> { + let entry = table.as_chunks::<8>().0.iter().find(|entry| u32_at(entry.as_slice(), 0) == Some(id))?.as_slice(); + let start = u32_at(entry, 4)? as usize; + if start < table_end || !start.is_multiple_of(4) { + return None; + } + let end = table + .as_chunks::<8>() + .0 + .iter() + .filter_map(|e| u32_at(e, 4)) + .map(|n| n as usize) + .filter(|n| *n > start) + .min() + .unwrap_or(set.len()) + .min(set.len()); + set.get(start..end) + }; + let cp = property(1)?; + if u32_at(cp, 0)? != 2 { + return None; + } // VT_I2 + let codepage = u16::from_le_bytes(cp.get(4..6)?.try_into().ok()?); + let get = |id| property(id).and_then(|value| text(value, codepage)).unwrap_or_default(); + Some(CoreProps { + title: get(2), + subject: get(3), + creator: get(4), + keywords: get(5), + description: get(6), + last_modified_by: get(8), + revision: get(9).parse().unwrap_or_default(), + ..Default::default() + }) +} + +fn text(value: &[u8], codepage: u16) -> Option { + let kind = u32_at(value, 0)?; + let count = u32_at(value, 4)? as usize; + let size = match kind { + 30 => count, // CodePageString length is in bytes, even for code page 1200. + 31 => count.checked_mul(2)?, // UnicodeString length is in UTF-16 units. + _ => return None, + }; + if size > MAX_STRING { + return None; + } + let bytes = value.get(8..8usize.checked_add(size)?)?; + if kind == 31 || codepage == 1200 { + if !bytes.len().is_multiple_of(2) { + return None; + } + let units = bytes.as_chunks::<2>().0.iter().map(|b| u16::from_le_bytes([b[0], b[1]])).take_while(|u| *u != 0); + Some(char::decode_utf16(units).map(|c| c.unwrap_or(char::REPLACEMENT_CHARACTER)).collect()) + } else { + let bytes = bytes.split(|b| *b == 0).next().unwrap_or_default(); + if codepage == 65001 { + Some(String::from_utf8_lossy(bytes).into_owned()) + } else if wordcraft_doc::encoding::SINGLE_BYTE_CODE_PAGES.contains(&codepage) { + Some(bytes.iter().map(|b| wordcraft_doc::encoding::windows_byte(codepage, *b)).collect()) + } else { + None + } + } +} diff --git a/crates/docbin/src/tests.rs b/crates/docbin/src/tests.rs index 9733b6980..9e2559ffc 100644 --- a/crates/docbin/src/tests.rs +++ b/crates/docbin/src/tests.rs @@ -9,6 +9,7 @@ use super::read; mod drawing; mod encrypted; +mod metadata; /// Offset in the WordDocument stream where test text pieces are placed (past the FIB). const TEXT_FC: u32 = 0x600; diff --git a/crates/docbin/src/tests/metadata.rs b/crates/docbin/src/tests/metadata.rs new file mode 100644 index 000000000..f36b93df9 --- /dev/null +++ b/crates/docbin/src/tests/metadata.rs @@ -0,0 +1,98 @@ +//! OLE property sets constructed from MS-OLEPS, exercised through the public DOC reader. +use super::{Piece, body_texts, text_doc}; +use std::io::{Cursor, Seek, SeekFrom, Write}; + +fn summary(cp: u16, values: &[(u32, u32, Vec)]) -> Vec { + let mut props = vec![(1, [2u32.to_le_bytes().as_slice(), &cp.to_le_bytes(), &[0, 0]].concat())]; + for (id, kind, bytes) in values { + let size = if *kind == 31 { bytes.len() / 2 } else { bytes.len() } as u32; + let mut value = [kind.to_le_bytes().as_slice(), &size.to_le_bytes(), bytes].concat(); + while !value.len().is_multiple_of(4) { + value.push(0); + } + props.push((*id, value)); + } + let mut section = vec![0; 8 + 8 * props.len()]; + section[4..8].copy_from_slice(&(props.len() as u32).to_le_bytes()); + for (i, (id, value)) in props.iter().enumerate() { + section[8 + i * 8..12 + i * 8].copy_from_slice(&id.to_le_bytes()); + let offset = section.len() as u32; + section[12 + i * 8..16 + i * 8].copy_from_slice(&offset.to_le_bytes()); + section.extend_from_slice(value); + } + let size = section.len() as u32; + section[..4].copy_from_slice(&size.to_le_bytes()); + let mut header = vec![0; 48]; + header[..2].copy_from_slice(&[0xfe, 0xff]); + header[24..28].copy_from_slice(&1u32.to_le_bytes()); + header[28..44].copy_from_slice(&[0xe0, 0x85, 0x9f, 0xf2, 0xf9, 0x4f, 0x68, 0x10, 0xab, 0x91, 0x08, 0, 0x2b, 0x27, 0xb3, 0xd9]); + header[44..48].copy_from_slice(&48u32.to_le_bytes()); + header.extend(section); + header +} + +fn open_with_metadata(bytes: &[u8], template: bool) -> wordcraft_doc::Document { + let file = text_doc(&[Piece { compressed: false, text: "Body stays readable\r".into() }], &[], 0); + let mut cfb = cfb::CompoundFile::open(Cursor::new(file)).unwrap(); + cfb.create_stream("/\u{5}SummaryInformation").unwrap().write_all(bytes).unwrap(); + if template { + let mut stream = cfb.open_stream("/WordDocument").unwrap(); + stream.seek(SeekFrom::Start(0x0a)).unwrap(); + stream.write_all(&0x1001u16.to_le_bytes()).unwrap(); + } + let doc = crate::read(&cfb.into_inner().into_inner()).unwrap(); + assert_eq!(body_texts(&doc), ["Body stays readable"]); + doc +} + +#[test] +fn doc_and_dot_import_summary_text_properties() { + let data = summary( + 1252, + &[ + (2, 30, b"R\xe9sum\xe9\0".to_vec()), + (3, 30, b"Subject\0".to_vec()), + (4, 30, b"Writer\0".to_vec()), + (5, 30, b"one;two\0".to_vec()), + (6, 30, b"Description\0".to_vec()), + (8, 30, b"Editor\0".to_vec()), + (9, 30, b"12\0".to_vec()), + ], + ); + for template in [false, true] { + let c = open_with_metadata(&data, template).core; + assert_eq!(c.title, "Résumé"); + assert_eq!(c.subject, "Subject"); + assert_eq!(c.creator, "Writer"); + assert_eq!(c.keywords, "one;two"); + assert_eq!(c.description, "Description"); + assert_eq!(c.last_modified_by, "Editor"); + assert_eq!(c.revision, 12); + } +} + +#[test] +fn summary_handles_codepages_and_utf16_length_units() { + let wide: Vec = "Unicode ä 文 😀\0".encode_utf16().flat_map(u16::to_le_bytes).collect(); + for kind in [30, 31] { + assert_eq!(open_with_metadata(&summary(1200, &[(2, kind, wide.clone())]), false).core.title, "Unicode ä 文 😀"); + } + assert_eq!(open_with_metadata(&summary(65001, &[(2, 30, "UTF-8 ä\0".as_bytes().to_vec())]), false).core.title, "UTF-8 ä"); + assert_eq!(open_with_metadata(&summary(1251, &[(2, 30, vec![0xcf, 0xf0, 0xe8, 0xe2, 0xe5, 0xf2, 0])]), false).core.title, "Привет"); +} + +#[test] +fn malformed_or_oversized_summary_does_not_break_doc_open() { + let valid = summary(1252, &[(2, 30, b"Title\0".to_vec())]); + for n in [0, 2, 24, 44, 47, 55, valid.len() - 5] { + assert!(open_with_metadata(&valid[..n], false).core.title.is_empty()); + } + for (at, value) in [(44, u32::MAX), (48, u32::MAX), (52, u32::MAX), (68, 4), (84, u32::MAX)] { + let mut bad = valid.clone(); + bad[at..at + 4].copy_from_slice(&value.to_le_bytes()); + assert!(open_with_metadata(&bad, false).core.title.is_empty(), "offset {at}"); + } + let huge = summary(1252, &[(2, 30, vec![b'x'; (64 << 10) + 1])]); + assert!(open_with_metadata(&huge, false).core.title.is_empty()); + assert!(open_with_metadata(&vec![0; (2 << 20) + 1], false).core.title.is_empty()); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index f1f844dc0..254a5ff9a 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -12,6 +12,21 @@ gives ~64%; we discount it to ~60% because DOCX fidelity has never been checked files. DOCX dominates the weight: a Word user sends and receives .docx; everything else is occasional. +## Legacy DOC/DOT document properties + +The binary Word reader imports SummaryInformation text properties: title, subject, author, +keywords, description, last editor and revision. Code-page strings use their declared +Windows single-byte encoding, UTF-8 or UTF-16LE; UnicodeString lengths are counted in UTF-16 +units. Metadata streams are capped at 2 MiB and individual values at 64 KiB. Invalid optional +metadata is skipped without losing the document body. Creation/modification timestamps and +DocumentSummaryInformation/custom properties are not imported by this change; DOC/DOT +remain read-only formats and encrypted legacy documents remain unsupported. + +Tests create OLE property sets in Rust and exercise the public document reader for normal +DOCs, templates, accented/Cyrillic/Unicode properties and malformed lengths/offsets. A local +LibreOffice-generated DOC and DOT containing an original synthetic document reproduced the +missing-title bug before this change and retain their title after it. + ## Formats Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ yes, 🟡 partial, From 6528f972dbcfa110bdf0a4fcd1917360b9babd66 Mon Sep 17 00:00:00 2001 From: Fin Warman Date: Thu, 15 Oct 2026 02:21:49 +0100 Subject: [PATCH 047/101] Shapes: draw flowchart OR spokes (#1644) Co-authored-by: Brandon Thomas --- ROADMAP.md | 3 ++- crates/doc/src/para.rs | 2 ++ crates/doc/src/wrap.rs | 4 +++- crates/docx/src/read/shape_style.rs | 6 ++++++ crates/docx/src/write/story.rs | 1 + crates/pdf/src/lib.rs | 7 +++++++ crates/pdf/src/tests.rs | 1 + crates/render/src/lib.rs | 25 ++++++++++++++++++++++++- docs/file-format-parity.md | 5 +++-- docs/gaps.md | 3 ++- 10 files changed, 51 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 37e860361..f826cd730 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-14 · **Change:** trivial (progress log: comment deletion leaves no dangling anchors or replies, #1345; previously trivial: progress log: open DrawingML freeform paths draw their end arrowhead; previously trivial: progress log: full screen, #980; previously trivial: progress log: PAGE fields on the pages a paragraph runs onto, #1352; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; 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-14 · **Change:** trivial (progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: comment deletion leaves no dangling anchors or replies, #1345; previously trivial: progress log: open DrawingML freeform paths draw their end arrowhead; previously trivial: progress log: full screen, #980; previously trivial: progress log: PAGE fields on the pages a paragraph runs onto, #1352; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; 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 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -136,6 +136,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| +| 2026-10-14 | Grouped `flowChartOr` shapes draw their circle and crossing spokes, as in Word | | 2026-10-14 | Deleting a comment removes its anchors from headers, footers, notes and text boxes too, not just the body, and deletes the whole thread below it, replies to replies included; no anchor or reply is left pointing at a deleted comment (#1345) | | 2026-10-14 | Legacy DOC DIB conversion checks the BMP header and copy before allocation, uses the caller's decode allowance, and caps PNG output while encoding; a BMP fallback is kept when it fits. A generated 1-pixel regression covers 57/58/512-byte limits. Resource-exhaustion consequences remain unmeasured. Related: #1334 and #1554 | | 2026-10-13 | Justified vertical page alignment (#1320): a section set to Justified spreads each page's paragraphs and tables from the top margin to the bottom one, as Word does, including the last page and pages ending in a manual page break. DOCX `w:vAlign="both"` opens as Justified (it showed centred) and saves back; `layout.verticalAlign` takes `justify`, and Page Setup has a Vertical alignment box | diff --git a/crates/doc/src/para.rs b/crates/doc/src/para.rs index aa6d84d32..542cd4f2c 100644 --- a/crates/doc/src/para.rs +++ b/crates/doc/src/para.rs @@ -288,6 +288,8 @@ pub enum ShapeKind { Rectangle, RoundedRectangle, Ellipse, + /// Flowchart OR preset: an ellipse with horizontal and vertical spokes. + FlowchartOr, Triangle, /// A right triangle (DrawingML's `rtTriangle`): the right angle at the bottom left. RightTriangle, diff --git a/crates/doc/src/wrap.rs b/crates/doc/src/wrap.rs index 75fe8f7fb..add3b5d32 100644 --- a/crates/doc/src/wrap.rs +++ b/crates/doc/src/wrap.rs @@ -73,7 +73,9 @@ pub fn shape_outline(kind: ShapeKind, w: f32, h: f32, corner: Option) -> Op | ShapeKind::StraightConnector | ShapeKind::ElbowConnector | ShapeKind::CurvedConnector => return None, - ShapeKind::Ellipse => (0..48).map(|i| (i as f32) * TAU / 48.0).map(|a| (0.5 + 0.5 * a.cos(), 0.5 + 0.5 * a.sin())).collect(), + ShapeKind::Ellipse | ShapeKind::FlowchartOr => { + (0..48).map(|i| (i as f32) * TAU / 48.0).map(|a| (0.5 + 0.5 * a.cos(), 0.5 + 0.5 * a.sin())).collect() + } ShapeKind::RoundedRectangle => { // Corner radius as drawn. let r = crate::connector::corner_radius(corner, w, h); diff --git a/crates/docx/src/read/shape_style.rs b/crates/docx/src/read/shape_style.rs index 9e5f3b8e1..e2a0d5ddb 100644 --- a/crates/docx/src/read/shape_style.rs +++ b/crates/docx/src/read/shape_style.rs @@ -29,6 +29,7 @@ pub(crate) fn preset_kind(prst: &str) -> ShapeKind { "ellipse" | "wedgeEllipseCallout" | "cloudCallout" | "cloud" | "flowChartConnector" | "donut" | "flowChartOnlineStorage" => { ShapeKind::Ellipse } + "flowChartOr" => ShapeKind::FlowchartOr, "triangle" | "flowChartExtract" | "flowChartMerge" => ShapeKind::Triangle, "rtTriangle" => ShapeKind::RightTriangle, "diamond" | "flowChartDecision" => ShapeKind::Diamond, @@ -182,6 +183,11 @@ mod tests { assert_eq!(p.fill, None); } + #[test] + fn flowchart_or_keeps_its_spokes() { + assert_eq!(preset_kind("flowChartOr"), ShapeKind::FlowchartOr); + } + #[test] fn explicit_paint_wins_over_the_style() { let sp = el(r#""#); diff --git a/crates/docx/src/write/story.rs b/crates/docx/src/write/story.rs index 8151a7316..bcdd460f1 100644 --- a/crates/docx/src/write/story.rs +++ b/crates/docx/src/write/story.rs @@ -985,6 +985,7 @@ impl Writer<'_> { ShapeKind::Rectangle | ShapeKind::TextBox | ShapeKind::Freeform => "rect", ShapeKind::RoundedRectangle => "roundRect", ShapeKind::Ellipse => "ellipse", + ShapeKind::FlowchartOr => "flowChartOr", ShapeKind::Triangle => "triangle", ShapeKind::RightTriangle => "rtTriangle", ShapeKind::Diamond => "diamond", diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 5b302b13a..6b7456449 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -557,6 +557,13 @@ fn shape_path(kind: ShapeKind, r: &Rect, corner: Option) -> Option { pb.close(); } ShapeKind::Ellipse => ellipse(&mut pb, cx, cy, r.w / 2.0, r.h / 2.0), + ShapeKind::FlowchartOr => { + ellipse(&mut pb, cx, cy, r.w / 2.0, r.h / 2.0); + pb.move_to(x0, cy); + pb.line_to(x1, cy); + pb.move_to(cx, y0); + pb.line_to(cx, y1); + } ShapeKind::Triangle => poly(&mut pb, &[(cx, y0), (x1, y1), (x0, y1)]), ShapeKind::RightTriangle => poly(&mut pb, &[(x0, y0), (x1, y1), (x0, y1)]), ShapeKind::Diamond => poly(&mut pb, &[(cx, y0), (x1, cy), (cx, y1), (x0, cy)]), diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 8e01a0f15..886172bbf 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -422,6 +422,7 @@ fn shapes_have_paths() { ShapeKind::Rectangle, ShapeKind::RoundedRectangle, ShapeKind::Ellipse, + ShapeKind::FlowchartOr, ShapeKind::Star, ShapeKind::Heart, ShapeKind::Arrow, diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index ac79f73ae..1941ba3c1 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1038,6 +1038,14 @@ pub fn shape_path(kind: ShapeKind, r: kurbo::Rect, corner: Option) -> BezPa kurbo::RoundedRect::from_rect(r, f64::from(radius)).to_path(0.1) } ShapeKind::Ellipse => kurbo::Ellipse::from_rect(r).to_path(0.1), + ShapeKind::FlowchartOr => { + let mut p = kurbo::Ellipse::from_rect(r).to_path(0.1); + p.move_to((r.x0, cy)); + p.line_to((r.x1, cy)); + p.move_to((cx, r.y0)); + p.line_to((cx, r.y1)); + p + } ShapeKind::Triangle => poly(&[(cx, r.y0), (r.x1, r.y1), (r.x0, r.y1)]), ShapeKind::RightTriangle => poly(&[(r.x0, r.y0), (r.x1, r.y1), (r.x0, r.y1)]), ShapeKind::Diamond => poly(&[(cx, r.y0), (r.x1, cy), (cx, r.y1), (r.x0, cy)]), @@ -1615,11 +1623,26 @@ mod tests { #[test] fn shapes_have_paths() { let r = kurbo::Rect::new(0.0, 0.0, 10.0, 10.0); - for k in [ShapeKind::Rectangle, ShapeKind::Ellipse, ShapeKind::Star, ShapeKind::Heart, ShapeKind::Arrow, ShapeKind::Line] { + for k in + [ShapeKind::Rectangle, ShapeKind::Ellipse, ShapeKind::FlowchartOr, ShapeKind::Star, ShapeKind::Heart, ShapeKind::Arrow, ShapeKind::Line] + { assert!(!shape_path(k, r, None).elements().is_empty()); } } + #[test] + fn flowchart_or_spokes() { + let r = kurbo::Rect::new(0.0, 0.0, 10.0, 10.0); + let path = shape_path(ShapeKind::FlowchartOr, r, None); + let elements = path.elements(); + for pair in [ + [kurbo::PathEl::MoveTo((0.0, 5.0).into()), kurbo::PathEl::LineTo((10.0, 5.0).into())], + [kurbo::PathEl::MoveTo((5.0, 0.0).into()), kurbo::PathEl::LineTo((5.0, 10.0).into())], + ] { + assert!(elements.windows(2).any(|w| w == pair), "missing spoke {pair:?}"); + } + } + /// A rounded rectangle laid out and rendered: its corner pixel is painted when the corner is /// square, left white with the default corner; turned a quarter, the same. #[test] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 254a5ff9a..e22709ac3 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-14 · **Change:** trivial (Bidi theme font slots; previously trivial: open DrawingML freeform paths draw their arrowheads; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously 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-11 · **Last updated:** 2026-10-14 · **Change:** trivial (`flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: Bidi theme font slots; previously trivial: open DrawingML freeform paths draw their arrowheads; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously 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; @@ -79,7 +79,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Legacy form fields (`w:ffData`), check boxes, drop-downs | ❌ | ❌ | | | DrawingML pictures inline and anchored (`wp:anchor`), wrap square/tight/through/top-bottom | ✅ | ✅ | Tight/through read and written with their `wp:wrapPolygon` (and `edited`); without one, a shape's outline is written (a picture's rectangle). `wrapText` (both sides, left, right, largest) read and written for square, tight and through | | WordArt text effects (`w14:textFill`, `w14:textOutline`, `w14:shadow`, `w14:glow`, `w14:reflection` in `w:rPr`) | ✅ | ✅ | Solid, gradient (linear) and no fill, outline colour/width, shadow, glow, reflection (#416); `w14:scene3d`/`w14:props3d` dropped | -| DrawingML shapes and text boxes (`wps:`) | ✅ | ✅ | Preset geometries subset (callouts and flowchart boxes drawn as the nearest shape, #611); rounded rectangles keep their own corner (`roundRect` `adj`: read, written back, drawn on screen and in PDF, #1476); fill and outline from the shape or from the theme through its style references (`a:fillRef`/`a:lnRef`, #611), scheme, preset, system, scRGB and HSL colours; rotation and flips (`a:xfrm` `rot`/`flipH`/`flipV`, pictures and groups too, #332), the effect extent covering the rotated bounds; `wps:bodyPr` `vert` and `anchor`, internal margins (`lIns`/`tIns`/`rIns`/`bIns`) and Resize shape to fit text (`a:spAutoFit`: the text is never cut, #743), linked text boxes (`wps:txbx/@id`, `wps:linkedTxbx` `id`/`seq`, #369); text warps (`a:prstTxWarp` with adjust values, all 41 presets kept; unknown ones dropped), connectors (`straightConnector1`, `bentConnector2`–`5`, `curvedConnector2`–`5` read; 3-segment forms written) with glued ends (`wps:cNvCnPr` `a:stCxn`/`a:endCxn`; the glued shapes keep their drawing ids) and arrowheads (`a:headEnd`/`a:tailEnd`, drawn as triangles), including custom-geometry paths (#416). Hidden drawings (`wp:docPr/@hidden`, a group member's `cNvPr/@hidden`) are kept and saved but neither drawn nor laid out, nor exported to other formats, as in Word for Windows; `.doc` and RTF shapes don't read their hidden flag yet | +| DrawingML shapes and text boxes (`wps:`) | ✅ | ✅ | Preset geometries subset (callouts and flowchart boxes drawn as the nearest shape, #611); `flowChartOr` draws a circle with horizontal and vertical spokes; rounded rectangles keep their own corner (`roundRect` `adj`: read, written back, drawn on screen and in PDF, #1476); fill and outline from the shape or from the theme through its style references (`a:fillRef`/`a:lnRef`, #611), scheme, preset, system, scRGB and HSL colours; rotation and flips (`a:xfrm` `rot`/`flipH`/`flipV`, pictures and groups too, #332), the effect extent covering the rotated bounds; `wps:bodyPr` `vert` and `anchor`, internal margins (`lIns`/`tIns`/`rIns`/`bIns`) and Resize shape to fit text (`a:spAutoFit`: the text is never cut, #743), linked text boxes (`wps:txbx/@id`, `wps:linkedTxbx` `id`/`seq`, #369); text warps (`a:prstTxWarp` with adjust values, all 41 presets kept; unknown ones dropped), connectors (`straightConnector1`, `bentConnector2`–`5`, `curvedConnector2`–`5` read; 3-segment forms written) with glued ends (`wps:cNvCnPr` `a:stCxn`/`a:endCxn`; the glued shapes keep their drawing ids) and arrowheads (`a:headEnd`/`a:tailEnd`, drawn as triangles), including custom-geometry paths (#416). Hidden drawings (`wp:docPr/@hidden`, a group member's `cNvPr/@hidden`) are kept and saved but neither drawn nor laid out, nor exported to other formats, as in Word for Windows; `.doc` and RTF shapes don't read their hidden flag yet | | Freeform shapes (`a:custGeom`: `a:moveTo`, `a:lnTo`, Bézier curves flattened) and WordCraft ink | ✅ | ✅ | Ink is written as a freeform `wps:wsp` in a `wp:anchor` (round caps, alpha for highlighter) and recognised again by its drawing name (#307). It is saved as a custom-geometry shape, not as Word's own ink (InkML in `w14:contentPart`), so Word shows WordCraft ink as a freeform shape it can move and recolour but not erase with its ink eraser; arcs drawn straight | | Group shapes (`wpg:`), drawing canvas (`wpc:`) | ✅ | ✅ | Groups (#267) and canvases (#344) with pictures, shapes, freeforms (custom geometry, #743; a path drawn back to its start without `a:close` is filled, as in Word) and text boxes; nested groups flattened. The canvas schema has no transform, so a turned or flipped canvas keeps its turn in our own `wpc:extLst` extension (`urn:wordcraft:canvas-xfrm`; Word shows it unturned). Legacy VML canvases (`v:group editas="canvas"`) aren't read as canvases | | VML (`w:pict`, `v:shape`, `v:textbox`) | 🟡 | 🟡 | Pictures, text boxes, lines (with arrowheads), rectangles and ovals, best effort (#242, #611). Preset shapes (`v:shape` whose `o:spt`, own or from its `v:shapetype`, or Word's shapetype id `_x0000_tNN`, names a preset; [MS-ODRAW] §2.4.24 numbers) are drawn and saved like the DrawingML preset of the same shape: exactly for rectangle, rounded rectangle, ellipse, diamond, isosceles triangle, five-point star, right arrow, line and straight connector, heart, text box, and the flowchart process, decision and connector; the others as their nearest shape, as for DrawingML (left/up/down arrows as the arrow, right triangle as the triangle, callouts as their box). WordArt text shapes and picture frames without a picture aren't drawn. Fill, outline and size come from the shape, else its shapetype; text inside keeps the shape around it (#1377). Adjust values (`adj`) aren't read: presets use WordCraft's proportions. Text boxes keep their internal margins (`inset`, unless `o:insetmode="auto"`), Resize shape to fit text (`mso-fit-shape-to-text`) and vertical alignment (`v-text-anchor`). Hidden shapes (`visibility:hidden`, like DrawingML's `wp:docPr/@hidden`) are kept and saved but neither drawn nor laid out, as in Word for Windows (Word for Mac draws them). Not read: vertical text (`layout-flow`) Word's text watermark (a `v:textpath` shape in a header or footer, recognised by Word's `PowerPlusWaterMarkObject` id or by being centred on the page both ways) becomes the document's watermark: text, font, fill colour, semitransparent (`v:fill` `opacity`) and diagonal (any turn); drawn with WordCraft's own size and angle, on every page even when only some sections' headers (or only the first-page header) have it (#1290). Saving writes the watermark back the same way: a `v:textpath` shape centred on the margins, behind the text, at the start of every header's first paragraph; a section whose pages would show no header gets a header holding only the watermark (default, and first-page or even-page ones when those are on), while sections linked to an earlier header stay linked. Other VML is written only for OLE object pictures. Other VML WordArt (`v:textpath` in the body, or a header logo) is still dropped | @@ -210,6 +210,7 @@ attributes inside stories and style or list definitions; a regenerated theme. Te | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | DrawingML `flowChartOr` preserves its circle and crossing spokes, including when grouped or rotated | | 2026-10-14 | trivial | `minorBidi`/`majorBidi` theme fonts name the theme's complex-script font (`a:cs`); an empty one names nothing, so the inherited font applies, as measured in Word | | 2026-10-14 | trivial | A missing font the font table classes as roman is drawn in Cambria, as measured in Word (Calibri for the other classes, as before) | | 2026-10-14 | minor | Legacy DOC DIB helper honours the supplied byte limit for its BMP header/copy, decoder and PNG output, with a bounded BMP fallback. Generated 1-pixel regression only; resource-exhaustion consequences unmeasured, fidelity/readiness estimates unchanged. Related: #1334 and #1554 | diff --git a/docs/gaps.md b/docs/gaps.md index 696b16b04..4633f819d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -21,9 +21,9 @@ Document renaming: desktop renaming requires a filesystem that supports hard lin | # | 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. Field reports fixed one by one: a cover page's theme-coloured shapes (#611), freeforms inside groups and text-box margins and Resize to fit (#743), cover-page positions relative to the page and freeforms drawn back to their start (#743), Word's text watermark in headers, read and written (#1290), and `flowChartOr` drawn with its spokes (#1644) | Everyone: the files people receive are the files that break | 30–50 | [file-format-parity.md](file-format-parity.md) | | 3 | **Crashes and freezes in the field**: startup crash on Intel UHD Graphics (#170, open), a crash on macOS 12.7.6 Intel (#979, open) (lag on GPUs without a DirectX 12 driver fixed, #316; 32-bit Windows exited at launch because DirectX 12 graphics pipeline creation failed, now OpenGL), freezes (#59 Thai fonts and #31 font selector, both open; #77 closed), broken shape tool (#142, open); the Windows 11 no-write-without-admin (#218) and unmovable images (#82) reports are closed (pictures and shapes in Behind Text drag through the text over them, #1313); memory growth on every font change fixed (#848: the font list no longer loads each family it previews) | B | 197 open issues (2026-10-12), among them the crash and freeze reports #170, #979, #59 and #31 | 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 only partially covered; no page-by-page comparison harness | B | Legacy same-style cell spacing preserved for measured modes 11/12/14, with mode 15 and non-cell controls; Contextual spacing corrected in body/header probes and repeated table-cell runs; [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) | -| 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. Field reports fixed one by one: a cover page's theme-coloured shapes (#611), freeforms inside groups and text-box margins and Resize to fit (#743), cover-page positions relative to the page and freeforms drawn back to their start (#743), Word's text watermark in headers, read and written (#1290) | Everyone: the files people receive are the files that break | 30–50 | [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, its optional picture fallback, and direct run-level content parts are preserved; ink nested in groups/canvases remains 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) | | 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. Replace All now checks content-control locks before editing any match; a refused batch preserves the document and undo history. | B | List-label pitch corrected in synthetic metric controls; 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 | **Print dialog depth**: desktop printer choice, copies, collation, duplex and grayscale submit through craft-print; File › Print retains preview, page ranges and odd/even (#641); driver tray/options and pages per sheet remain; 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) | @@ -87,6 +87,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Field report: grouped `flowChartOr` omitted its crossing spokes; the preset now draws them; wider DOCX corpus work remains open | | 2026-10-13 | trivial | Gap #3: Behind Text pictures and shapes drag over text in the body and headers, selected or not; a click without movement still edits the text | | 2026-10-13 | trivial | Row 1: the watermark is saved to DOCX headers (#1290) | | 2026-10-13 | trivial | Row 1: Word's text watermark in DOCX headers is drawn (#1290) | From 47907a820b451f891a3b9667161e33c4aadb7616 Mon Sep 17 00:00:00 2001 From: Martin Ball <98639962+benandez@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:22:00 +0100 Subject: [PATCH 048/101] ODT import: text after a drawing shape stays in its paragraph (#1654) (#1657) A shape anchored in a text:p (draw:custom-shape, draw:rect, ...) had its own text:p read as a body paragraph. That closed the paragraph the shape sits in, and the text after the shape was dropped. LibreOffice's MadeByLO7.odt lost three of its seven body paragraphs. Drawing shapes are now skipped with their text, as text boxes already are. Groups are still walked, so the pictures inside them are read. Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Brandon Thomas --- crates/formats/src/odt.rs | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/formats/src/odt.rs b/crates/formats/src/odt.rs index 4e315010d..da438604b 100644 --- a/crates/formats/src/odt.rs +++ b/crates/formats/src/odt.rs @@ -8,7 +8,7 @@ //! Quotations, Preformatted Text, page layout) and `meta.xml`. //! //! Import reads the same subset back from `content.xml` / `styles.xml` (style inheritance -//! resolved, both automatic and common styles), skipping notes, annotations and text boxes. +//! resolved, both automatic and common styles), skipping notes, annotations, text boxes and drawing shapes. //! //! Tracked changes (ODF 1.3 Part 3 §5.5) go both ways: `text:tracked-changes` holds one //! `text:changed-region` per change; an insertion is the body text between @@ -1642,6 +1642,14 @@ impl Body<'_> { | "table-of-content-source" => { self.skip = 1; } + // Drawing shapes (ODF 1.3 §10.3), with their text, until shapes are read (#1654). + // Groups are walked, so the pictures in them still are. + "rect" | "line" | "polyline" | "polygon" | "regular-polygon" | "path" | "circle" | "ellipse" | "connector" | "caption" | "measure" + | "custom-shape" + if q.starts_with("draw:") => + { + self.skip = 1; + } "p" | "h" if !self.in_desc => { let h = if local(q) == "h" { Some(get(a, "text:outline-level").and_then(|v| v.parse::().ok()).unwrap_or(1)) } else { None }; self.start_para(get(a, "text:style-name"), h); @@ -2481,6 +2489,24 @@ mod tests { } } + #[test] + fn text_after_a_drawing_shape_stays_in_its_paragraph() { + // #1654: a shape's own text:p closed the paragraph it is anchored in, and the text after + // the shape was dropped. Shapes are skipped like text boxes; the paragraph keeps its text. + let draw = + r#"xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0""#; + let content = format!( + r#"In the shapeBefore middle In the groupafterNext"# + ); + let styles = format!( + r#""# + ); + let f = parse(&package(&content, Some(&styles))).unwrap(); + let ps = paras(&f); + assert_eq!(ps.iter().map(|p| p.text()).collect::>(), ["Before middle after", "Next"]); + assert!(ps[0].inlines.iter().all(|i| !matches!(i, Inline::Text(_, f) if !f.bold)), "{:?}", ps[0].inlines); + } + #[test] fn page_size_orientation_and_margins_round_trip() { // #810: the page setup written on export is read back on import. From faa621104cfba19563e674c6e44e6bdba423ecdd Mon Sep 17 00:00:00 2001 From: Fin Warman Date: Thu, 15 Oct 2026 02:22:19 +0100 Subject: [PATCH 049/101] Layout: empty line breaks do not move floating tables (#1658) Co-authored-by: Brandon Thomas --- ROADMAP.md | 3 ++- contributors/people.toml | 5 +++-- crates/layout/src/lib.rs | 36 +++++++++++++++++++++++++++++++++++- crates/layout/src/tests.rs | 31 +++++++++++++++++++++++++++++++ docs/gaps.md | 3 ++- docs/layout-parity.md | 5 +++-- 6 files changed, 76 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f826cd730..259104e59 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-14 · **Change:** trivial (progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: comment deletion leaves no dangling anchors or replies, #1345; previously trivial: progress log: open DrawingML freeform paths draw their end arrowhead; previously trivial: progress log: full screen, #980; previously trivial: progress log: PAGE fields on the pages a paragraph runs onto, #1352; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; 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-14 · **Change:** trivial (progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: floating tables ignore empty line-break lines; previously trivial: progress log: comment deletion leaves no dangling anchors or replies, #1345; previously trivial: progress log: open DrawingML freeform paths draw their end arrowhead; previously trivial: progress log: full screen, #980; previously trivial: progress log: PAGE fields on the pages a paragraph runs onto, #1352; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; 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 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -382,6 +382,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Floating-table overlap checks use visible line content; empty line-break lines no longer move a table | | 2026-10-14 | trivial | Progress log: deleting a comment leaves no dangling anchors or replies (#1345) | | 2026-10-14 | minor | Legacy DOC DIB conversion byte limits only; related #1334/#1554, stage and readiness estimates unchanged | | 2026-10-13 | trivial | Progress log: justified vertical page alignment (#1320) | diff --git a/contributors/people.toml b/contributors/people.toml index 543a14862..7e9ac0665 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -97,6 +97,9 @@ display_name = "Martin Ball" [people.Nghiathan13] +[people.finwarman] +real_name = "Fin Warman" + [people.albayazid] real_name = "Sazid Al Bayazid" @@ -110,6 +113,4 @@ display_name = "The Legend Of MTS Dev" [people.dotCooCoo] real_name = "Robert Lee" -[people.finwarman] -real_name = "Fin Warman" diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 498843dc3..596d5e7b3 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -3785,7 +3785,7 @@ fn place_floating_table(pb: &mut PageBuilder, tl: &table::TableLayout, f: &Table let moves = if f.v_rel == Anchor::Paragraph { r.bottom() > pb.bottom + 0.01 } else { - let covers_text = r.y < pb.y - 0.01 && r.bottom() > pb.top + 0.01 && r.x < pb.col_x() + pb.col_w() && r.right() > pb.col_x(); + let covers_text = pb.page().is_some_and(|page| page.items.iter().any(|item| placed_intersects(item, &r))); covers_text && pb.y + h > pb.bottom + 0.01 }; if attempt == 0 && moves && !pb.at_top() { @@ -3810,6 +3810,40 @@ fn place_floating_table(pb: &mut PageBuilder, tl: &table::TableLayout, f: &Table } } +fn placed_intersects(item: &Placed, rect: &Rect) -> bool { + match item { + Placed::Lines { para, l0, l1, x, y, .. } => para.lines.get(*l0..*l1).is_some_and(|lines| { + lines.iter().enumerate().any(|(offset, line)| { + if para.label.is_some() && *l0 + offset == 0 { + return Rect::new(x + line.left, y + line.top, line.right - line.left, line.height).intersects(rect); + } + para.clusters.get(line.c0..line.c1).is_some_and(|clusters| { + clusters.iter().enumerate().any(|(index, cluster)| { + if !matches!( + cluster.kind, + crate::para::ClKind::Text | crate::para::ClKind::Space | crate::para::ClKind::Tab | crate::para::ClKind::Object(_) + ) { + return false; + } + let ci = line.c0 + index; + let Some(left) = line.cl_left(ci) else { return false }; + let Some(right) = line.cl_right(ci) else { return false }; + Rect::new(x + left, y + line.top, right - left, line.height).intersects(rect) + }) + }) + }) + }), + Placed::Fill { rect: bounds, .. } + | Placed::Image { rect: bounds, .. } + | Placed::Shape { rect: bounds, .. } + | Placed::Graphic { rect: bounds, .. } + | Placed::Cell { rect: bounds, .. } + | Placed::Object { rect: bounds, .. } + | Placed::Clip { rect: bounds, .. } => bounds.intersects(rect), + Placed::Rule { x0, y0, x1, y1, .. } => Rect::new(x0.min(*x1), y0.min(*y1), (x1 - x0).abs(), (y1 - y0).abs()).intersects(rect), + } +} + /// The page `pb` is filling, for placing floating objects on it (`mirror`: [`Ctx::mirror`]). fn page_frame<'a>(pb: &PageBuilder<'a>, mirror: bool) -> PageFrame<'a> { PageFrame { sect: pb.sect, origin: (0.0, 0.0), number: pb.number, mirror } diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index b7d1556eb..2670952c8 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -3728,6 +3728,37 @@ fn floating_tables_take_no_room_and_text_wraps_beside_them() { assert!(bx + bleft > nx + 200.0, "body text at {}, right of the narrow table", bx + bleft); } +#[test] +fn a_floating_table_ignores_a_paragraphs_empty_lines() { + use wordcraft_doc::para::Anchor; + use wordcraft_doc::props::TableFloat; + + let mut doc = Document::from_text(&[""; 9].join("\n")); + doc.body.push(wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("Visible\n\n\n", Default::default()))); + doc.body.extend((0..14).map(|_| wordcraft_doc::para_block(wordcraft_doc::Paragraph::new()))); + let mut table = Table::new(12, 1, 300.0); + for (i, row) in table.rows.iter_mut().enumerate() { + row.cells[0].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&format!("Approval {i}"), Default::default()))]; + } + table.props.float = Some(TableFloat { h_rel: Anchor::Margin, v_rel: Anchor::Margin, x: 100.0, y: 50.0, ..Default::default() }); + let at = doc.body.len(); + doc.insert_block(StoryRef::Body, &Path::top(at), wordcraft_doc::Block::Table(table)).unwrap(); + + let layout = lay(&doc); + let table_pages: Vec = layout + .pages + .iter() + .enumerate() + .flat_map(|(page, p)| { + p.items.iter().filter_map(move |item| match item { + Placed::Lines { story: StoryRef::Body, path, .. } if path.0.len() > 1 => Some(page), + _ => None, + }) + }) + .collect(); + assert!(!table_pages.is_empty() && table_pages.iter().all(|page| *page == 0), "table rows on pages {table_pages:?}"); +} + #[test] fn a_floating_table_taller_than_a_page_runs_across_pages() { use wordcraft_doc::para::Anchor; diff --git a/docs/gaps.md b/docs/gaps.md index 4633f819d..6a80a1f1e 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-14 · **Change:** trivial (gap #3: dragging a Behind Text picture or shape over text now works, in the body and headers, and plain clicks still reach the text; previously trivial: crop handles on the canvas landed; previously trivial: re-audited every row against main: stale PR and issue states, counts and landed items corrected; previously trivial: Windows Magnifier follows the caret onto a new line, #1119; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; 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-14 · **Change:** trivial (floating-table overlap ignores empty line-break lines; previously trivial: gap #3: dragging a Behind Text picture or shape over text now works, in the body and headers, and plain clicks still reach the text; previously trivial: crop handles on the canvas landed; previously trivial: re-audited every row against main: stale PR and issue states, counts and landed items corrected; previously trivial: Windows Magnifier follows the caret onto a new line, #1119; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; 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 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -88,6 +88,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | Date | Change | Summary | |---|---|---| | 2026-10-14 | trivial | Field report: grouped `flowChartOr` omitted its crossing spokes; the preset now draws them; wider DOCX corpus work remains open | +| 2026-10-14 | trivial | Floating-table overlap checks use visible line content; empty line-break lines no longer move a table | | 2026-10-13 | trivial | Gap #3: Behind Text pictures and shapes drag over text in the body and headers, selected or not; a click without movement still edits the text | | 2026-10-13 | trivial | Row 1: the watermark is saved to DOCX headers (#1290) | | 2026-10-13 | trivial | Row 1: Word's text watermark in DOCX headers is drawn (#1290) | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 74ae363f8..cd357335a 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -1,6 +1,6 @@ # Layout and pagination parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 A document only "opens correctly" if its pages break where Word breaks them: the same lines, the same page count, footnotes and pictures on the same pages. This checklist covers line breaking, @@ -43,8 +43,8 @@ font can't match Word's metrics. | Body top below a tall header, footer pushes body up | ✅ | #138 | — | | Footnotes at the page bottom, endnotes at the end, note numbering | 🟡 | A long note continues onto the next page at a line boundary (its first line stays with the reference), first in that page's note area under a full-width continuation separator (#352); the document's own separator, continuation separator and continuation notice are drawn (the notice under a note that continues; endnotes use the document's separator but don't continue onto a page of their own); each section's note options: footnotes below the text, endnotes at the end of each section, start at, numbering restarted each section or each page (counted from where the notes land, at most three passes) (#385) | 1–2 | | Tables: row heights (at least/exact), rows split across pages, header rows repeat, Can't Split | ✅ | 2026-10-06, #138; every cell in a row gets the row's largest top and bottom margin; an exact height covers the top border, top margin and text, the bottom margin comes on top, and an exact row never splits; a row split across pages keeps its cells' bottom margin, border and last space after on the first page, widow/orphan control applies in its cells, each cell continues at the top of the next page and an at-least row keeps its minimum (`layout::tests::a_row_shares_its_largest_top_and_bottom_margins`, `…_a_split_row_keeps_its_bottom_margin_and_widow_control`, `…_split_row_cells_each_continue_at_the_top`) | — | +| Floating tables (`w:tblpPr`) | 🟡 | #137; one placed on the page or margin that would cover visible text before it and does not fit below it moves to the next page (#534); empty line-break lines do not count as text; other overlap rules untested | 2–3 | | Tables: autofit to contents/window, fixed widths, Word 2013 edge | 🟡 | #137; #44: AutoFit Contents measures each column's narrowest and widest text and shares the width like Word, once (not live as you type); a percentage width past 100% is kept, as Word does; cell preferred widths and spans still differ | 3–5 | -| Floating tables (`w:tblpPr`) | 🟡 | #137; one placed on the page or margin that would cover the text before it and doesn't fit below it moves to the next page (#534); overlap rules untested | 2–3 | | Nested tables | 🟡 | render; polish missing | 2–4 | | Floating pictures/shapes placement (relative to page/margin/column/paragraph/line/character) | ✅ | #136 | — | | Text wrap: square, top-and-bottom, behind, in front | ✅ | 2026-10-06; beside an object, a line's first word that doesn't fit the strip goes on whole to the row's next strip or the next row, instead of being split (#1376, `layout::tests::a_word_too_wide_for_the_strip_beside_a_float_moves_on_whole`; inferred from a reporter's comparison, not yet measured in Word) a paragraph running on into the next column or page is broken again there around that column's shapes, its own floating objects left where they are (#1374). Text in an earlier column doesn't yet wrap around a shape anchored in a later one (#1158) | #884: an object dragged on a multi-column page anchors to a paragraph in the column it lands in, so that column's text wraps around it. Text laid out before the anchor (an earlier column) doesn't yet wrap around an object anchored later on the page, nor do lines a paragraph continues into the next column (broken once, in the column it starts in) | @@ -202,6 +202,7 @@ No overall parity estimate changes from these probes. | 2026-10-12 | trivial | A section break inserted over a selection keeps the selected text: the first section no longer comes out empty (#852) | | 2026-10-12 | trivial | Large-table scrolling (#811): per-frame page fingerprinting fixed; measurement added | | 2026-10-12 | trivial | Symbol-font text (Wingdings, Wingdings 2/3, Webdings, Symbol) drawn as Unicode symbols (#776) | +| 2026-10-14 | trivial | Floating-table overlap checks use visible line content; empty line-break lines no longer move a table | | 2026-10-11 | trivial | Chinese documents (#534): indents in characters, a page-anchored floating table after a full page moves to the next page, no blank page from a page break at the bottom of a full page | | 2026-10-11 | trivial | East Asian list number formats (一、二、三, 第一章, ①); list labels whose characters come from two fonts draw each part with its own font (the digits of "1、" no longer show as other letters); a list level without indents leaves the label at the paragraph edge (#534) | | 2026-10-11 | trivial | Vertical sections: `w:textDirection` in `w:sectPr`, upright East Asian glyphs (#491) | From 29320804f2d72006d13ba30451f212a4e03a4aca Mon Sep 17 00:00:00 2001 From: Diego Guerini <106814760+D13GOOOO@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:29:21 +0200 Subject: [PATCH 050/101] RTF import: Mac OS Roman and DOS code pages (`\mac`, `\pc`, `\pca`) (#1653) (#1661) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * RTF import: Mac OS Roman text (`\mac`, `\fcharset77`) (#1653) A Macintosh RTF document (`{\rtf1\mac …}`) had its `\'xx` bytes read as Windows-1252: LibreOffice's fdo85889-mac.rtf showed "ñòó" instead of "ÒÚÛ". Mac OS Roman (code page 10000, with the euro sign at 0xDB) joins the single-byte code pages, and the reader uses it for `\mac` documents, fonts with the Mac charset (`\fcharset77`) and `\cpg` / `\ansicpg` 10000. Tests: the code page table (accents, euro, quotes) and two RTF cases, the issue's file and a Mac-charset font in a Windows document. Co-Authored-By: Claude Opus 5.5 * RTF import: DOS code pages (`\pc`, `\pca`, OEM `\cpg`) (#1653) With the OEM tables from #1146 now on main, the RTF reader decodes DOS text too: `\pc` documents as code page 437, `\pca` as 850, a font's `\cpg` 437/737/850/852/857/866 and `\fcharset254` (PC 437). `encoding::code_page_byte` decodes a byte of document text in any code page RTF names (Windows, OEM or Mac OS Roman; ASCII below 0x80 stays ASCII), and `is_byte_code_page` says which those are. `SINGLE_BYTE_CODE_PAGES` stays Windows-only, so `text.typeCode`'s `ansi` pages are unchanged. A `\cpg` / `\ansicpg` number out of the u16 range is now ignored; before, `pv as u16` truncated it to another code page (65973 read as 437). Tests: document text in DOS, Mac and Windows pages; `\pc`, `\pca` with a `\cpg437` font, and an out-of-range `\ansicpg`. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/doc/src/encoding.rs | 59 ++++++++++++++++++++++++++++++++++---- crates/formats/src/rtf.rs | 37 +++++++++++++++++++----- docs/file-format-parity.md | 5 ++-- 3 files changed, 87 insertions(+), 14 deletions(-) diff --git a/crates/doc/src/encoding.rs b/crates/doc/src/encoding.rs index 127812df3..a1a1e1892 100644 --- a/crates/doc/src/encoding.rs +++ b/crates/doc/src/encoding.rs @@ -1,6 +1,7 @@ //! Windows-1252 (the byte encoding legacy Word text pieces use) and the other single-byte Windows //! code pages RTF files name with `\ansicpg` or a font's `\fcharset` (Cyrillic 1251, Central -//! European 1250, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai). +//! European 1250, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai), and Mac OS Roman +//! (10000, RTF `\mac`). /// Windows-1252 code points for 0x80..=0x9F. const CP1252: [u16; 32] = [ @@ -125,6 +126,18 @@ const CP1258: [u16; 128] = [ 0x20AB, 0x00FF, ]; +/// Mac OS Roman (code page 10000, RTF `\mac`) code points for 0x80..=0xFF, with the euro sign at +/// 0xDB (Mac OS 8.5 and later). +const MAC_ROMAN: [u16; 128] = [ + 0x00C4, 0x00C5, 0x00C7, 0x00C9, 0x00D1, 0x00D6, 0x00DC, 0x00E1, 0x00E0, 0x00E2, 0x00E4, 0x00E3, 0x00E5, 0x00E7, 0x00E9, 0x00E8, 0x00EA, 0x00EB, + 0x00ED, 0x00EC, 0x00EE, 0x00EF, 0x00F1, 0x00F3, 0x00F2, 0x00F4, 0x00F6, 0x00F5, 0x00FA, 0x00F9, 0x00FB, 0x00FC, 0x2020, 0x00B0, 0x00A2, 0x00A3, + 0x00A7, 0x2022, 0x00B6, 0x00DF, 0x00AE, 0x00A9, 0x2122, 0x00B4, 0x00A8, 0x2260, 0x00C6, 0x00D8, 0x221E, 0x00B1, 0x2264, 0x2265, 0x00A5, 0x00B5, + 0x2202, 0x2211, 0x220F, 0x03C0, 0x222B, 0x00AA, 0x00BA, 0x03A9, 0x00E6, 0x00F8, 0x00BF, 0x00A1, 0x00AC, 0x221A, 0x0192, 0x2248, 0x2206, 0x00AB, + 0x00BB, 0x2026, 0x00A0, 0x00C0, 0x00C3, 0x00D5, 0x0152, 0x0153, 0x2013, 0x2014, 0x201C, 0x201D, 0x2018, 0x2019, 0x00F7, 0x25CA, 0x00FF, 0x0178, + 0x2044, 0x20AC, 0x2039, 0x203A, 0xFB01, 0xFB02, 0x2021, 0x00B7, 0x201A, 0x201E, 0x2030, 0x00C2, 0x00CA, 0x00C1, 0x00CB, 0x00C8, 0x00CD, 0x00CE, + 0x00CF, 0x00CC, 0x00D3, 0x00D4, 0xF8FF, 0x00D2, 0x00DA, 0x00DB, 0x00D9, 0x0131, 0x02C6, 0x02DC, 0x00AF, 0x02D8, 0x02D9, 0x02DA, 0x00B8, 0x02DD, + 0x02DB, 0x02C7, +]; /// IBM PC (OEM) code page 437 code points for 0x80..=0xFF (the Unicode Consortium's CP437 /// mapping file). const CP437: [u16; 128] = [ @@ -236,8 +249,11 @@ pub fn oem_byte(code_page: u16, b: u8) -> char { /// The single-byte Windows code pages [`windows_byte`] decodes (1252 included). pub const SINGLE_BYTE_CODE_PAGES: [u16; 10] = [874, 1250, 1251, 1252, 1253, 1254, 1255, 1256, 1257, 1258]; -/// One byte of a single-byte Windows code page as a char; a code page outside -/// [`SINGLE_BYTE_CODE_PAGES`] reads as Windows-1252. +/// Mac OS Roman, RTF's `\mac` character set. +pub const MAC_ROMAN_CODE_PAGE: u16 = 10000; + +/// One byte of a single-byte code page as a char: a Windows one in [`SINGLE_BYTE_CODE_PAGES`] or +/// Mac OS Roman ([`MAC_ROMAN_CODE_PAGE`]); any other code page reads as Windows-1252. pub fn windows_byte(code_page: u16, b: u8) -> char { let table: &[u16; 128] = match code_page { 874 => &CP874, @@ -249,6 +265,7 @@ pub fn windows_byte(code_page: u16, b: u8) -> char { 1256 => &CP1256, 1257 => &CP1257, 1258 => &CP1258, + MAC_ROMAN_CODE_PAGE => &MAC_ROMAN, _ => return cp1252(b), }; if b < 0x80 { @@ -257,11 +274,25 @@ pub fn windows_byte(code_page: u16, b: u8) -> char { table.get(usize::from(b - 0x80)).and_then(|u| char::from_u32(u32::from(*u))).unwrap_or('\u{FFFD}') } +/// Whether [`code_page_byte`] decodes `code_page`: a Windows one ([`SINGLE_BYTE_CODE_PAGES`]), an +/// OEM (DOS) one ([`OEM_CODE_PAGES`]) or Mac OS Roman. Document formats (RTF) name all three. +pub fn is_byte_code_page(code_page: u16) -> bool { + SINGLE_BYTE_CODE_PAGES.contains(&code_page) || OEM_CODE_PAGES.contains(&code_page) || code_page == MAC_ROMAN_CODE_PAGE +} + +/// A byte of 8-bit document text in `code_page` (see [`is_byte_code_page`]) as a char. Below 0x80 +/// it is ASCII in every code page (text, so an OEM page's glyphs for control codes don't apply). +pub fn code_page_byte(code_page: u16, b: u8) -> char { + if b >= 0x80 && OEM_CODE_PAGES.contains(&code_page) { oem_byte(code_page, b) } else { windows_byte(code_page, b) } +} + /// The code page of an RTF or Word font charset (`\fcharset`): 204 Russian → 1251, 238 Eastern -/// European → 1250… `None` for the ANSI and default charsets (the document's code page applies), -/// symbol fonts and the double-byte East Asian charsets. +/// European → 1250, 77 Mac → 10000, 254 PC → 437… `None` for the ANSI and default charsets (the +/// document's code page applies), symbol fonts and the double-byte East Asian charsets. pub fn charset_code_page(charset: i32) -> Option { Some(match charset { + 77 => MAC_ROMAN_CODE_PAGE, + 254 => 437, 161 => 1253, 162 => 1254, 163 => 1258, @@ -288,6 +319,10 @@ mod tests { assert_eq!(decode(1253, &[0xC1, 0xE2]), "Αβ"); assert_eq!(decode(1252, &[0x80, 0xE9]), "€é"); assert_eq!(decode(9999, &[0xE9]), "é"); + // Mac OS Roman (#1653): accents, the euro sign at 0xDB, typographic quotes. + assert_eq!(decode(10000, &[0x80, 0x8E, 0xDB, 0xF1, 0xF2, 0xF3, 0xD2, 0xD3, 0x41]), "Äé€ÒÚÛ“”A"); + assert_eq!(charset_code_page(77), Some(10000)); + assert_eq!(charset_code_page(254), Some(437)); assert_eq!(charset_code_page(204), Some(1251)); assert_eq!(charset_code_page(0), None); for cp in SINGLE_BYTE_CODE_PAGES { @@ -297,6 +332,20 @@ mod tests { } } + /// #1653: one decoder for document text in any code page RTF names: Windows, DOS or Mac. + #[test] + fn document_text_in_windows_dos_and_mac_code_pages() { + let decode = |cp: u16, bytes: &[u8]| bytes.iter().map(|&b| code_page_byte(cp, b)).collect::(); + assert_eq!(decode(437, &[0x82, 0xA0, 0x41, 0x01]), "éáA\u{1}", "DOS text; ASCII stays ASCII"); + assert_eq!(decode(850, &[0x9D, 0xE9]), "ØÚ"); + assert_eq!(decode(10000, &[0x8E]), "é"); + assert_eq!(decode(1251, &[0xC4]), "Д"); + for cp in [437, 850, 866, 1250, 1252, 10000] { + assert!(is_byte_code_page(cp), "{cp}"); + } + assert!(!is_byte_code_page(932) && !is_byte_code_page(0) && !SINGLE_BYTE_CODE_PAGES.contains(&10000)); + } + #[test] fn oem_code_pages() { let decode = |cp: u16, bytes: &[u8]| bytes.iter().map(|&b| oem_byte(cp, b)).collect::(); diff --git a/crates/formats/src/rtf.rs b/crates/formats/src/rtf.rs index 7a34aea0e..c8b002a20 100644 --- a/crates/formats/src/rtf.rs +++ b/crates/formats/src/rtf.rs @@ -9,9 +9,9 @@ //! //! Import: a tolerant reader for groups and destinations (unknown `\*` destinations, headers, //! footers, notes and objects are skipped), `\par \line \tab \page`, `\uN` with `\ucN` -//! fallback skipping, `\'hh` and 8-bit text in the document's code page (`\ansicpg`) or the -//! current font's (`\fcharset`, `\cpg`: Cyrillic, Central European, Greek…; Windows-1252 when -//! none is named), the formatting words above, stylesheet-based +//! fallback skipping, `\'hh` and 8-bit text in the document's code page (`\ansicpg`, `\mac`, `\pc`, `\pca`) or +//! the current font's (`\fcharset`, `\cpg`: Cyrillic, Central European, Greek, Mac OS Roman…; +//! Windows-1252 when none is named), the formatting words above, stylesheet-based //! headings and `\outlinelevel`, lists through `\ls`/`\ilvl`, fields, bookmarks, pictures and //! tables. Group nesting and picture sizes are capped. @@ -1087,7 +1087,7 @@ impl Reader { /// default font's, else the document's. fn byte_char(&self, b: u8) -> char { let cp = self.st.cp.or_else(|| self.font_cps.get(&self.deff).copied()).unwrap_or(if self.ansicpg == 0 { 1252 } else { self.ansicpg }); - wordcraft_doc::encoding::windows_byte(cp, b) + wordcraft_doc::encoding::code_page_byte(cp, b) } fn commit_style(&mut self) { @@ -1491,7 +1491,11 @@ impl Reader { } "fcharset" => self.font_cp_cur = wordcraft_doc::encoding::charset_code_page(pv), // A font's explicit code page (`\cpg1251`) wins over its charset. - "cpg" if pv > 0 && wordcraft_doc::encoding::SINGLE_BYTE_CODE_PAGES.contains(&(pv as u16)) => self.font_cp_cur = Some(pv as u16), + "cpg" => { + if let Some(cp) = u16::try_from(pv).ok().filter(|cp| wordcraft_doc::encoding::is_byte_code_page(*cp)) { + self.font_cp_cur = Some(cp); + } + } _ => {} } return; @@ -1657,10 +1661,15 @@ impl Reader { } "fs" => self.st.fmt.size = if pv > 0 { Some((pv as f32 / 2.0).clamp(1.0, 1638.0)) } else { None }, "ansicpg" => { - if pv > 0 && wordcraft_doc::encoding::SINGLE_BYTE_CODE_PAGES.contains(&(pv as u16)) { - self.ansicpg = pv as u16; + if let Some(cp) = u16::try_from(pv).ok().filter(|cp| wordcraft_doc::encoding::is_byte_code_page(*cp)) { + self.ansicpg = cp; } } + // The Macintosh and IBM PC character sets: 8-bit text is Mac OS Roman, or DOS code + // page 437 (`\pc`) or 850 (`\pca`) (#1653). + "mac" if self.ansicpg == 0 => self.ansicpg = wordcraft_doc::encoding::MAC_ROMAN_CODE_PAGE, + "pc" if self.ansicpg == 0 => self.ansicpg = 437, + "pca" if self.ansicpg == 0 => self.ansicpg = 850, "deff" => self.deff = pv, "f" => { self.st.cp = self.font_cps.get(&pv).copied(); @@ -2356,6 +2365,20 @@ mod tests { // Raw 8-bit text (not escaped) follows the same code page. let f = parse(b"{\\rtf1\\ansi\\ansicpg1251 \xc4\xee\xec\\par}").unwrap(); assert_eq!(texts(&f), ["Normal:Дом"]); + // #1653: a Macintosh document (LibreOffice's fdo85889-mac.rtf) is Mac OS Roman, and so is + // a font with the Mac charset in a Windows document. + let f = parse(br"{\rtf1\mac \deff0{\fonttbl{\f0\fmodern Helvetica;}}\pard\f0\fs20 \'f1\'f2\'f3\par}").unwrap(); + assert_eq!(texts(&f), ["Normal:ÒÚÛ"]); + let f = parse(br"{\rtf1\ansi\deff0{\fonttbl{\f0 Arial;}{\f1\fcharset77 Geneva;}}caf\'e9 {\f1 caf\'8e \'d2ok\'d3}\par}").unwrap(); + assert_eq!(texts(&f), ["Normal:café café “ok”"]); + // DOS documents: `\pc` is code page 437, `\pca` 850; a font can name one with `\cpg`. + let f = parse(br"{\rtf1\pc\deff0{\fonttbl{\f0 Courier;}}caf\'82 \'a0 la carte\par}").unwrap(); + assert_eq!(texts(&f), ["Normal:café á la carte"]); + let f = parse(br"{\rtf1\pca\deff0{\fonttbl{\f0 Courier;}{\f1\cpg437 Courier;}}\'9d {\f1 \'9d}\par}").unwrap(); + assert_eq!(texts(&f), ["Normal:Ø ¥"]); + // A code page number out of range is ignored, not truncated to another one. + let f = parse(br"{\rtf1\ansi\ansicpg65973 caf\'e9\par}").unwrap(); + assert_eq!(texts(&f), ["Normal:café"]); } #[test] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index e22709ac3..293cbd2c9 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-14 · **Change:** trivial (`flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: Bidi theme font slots; previously trivial: open DrawingML freeform paths draw their arrowheads; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously 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-11 · **Last updated:** 2026-10-14 · **Change:** trivial (`flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously 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; @@ -41,7 +41,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ | `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~60% | 25–40 | `crates/docbin/src/tests.rs`, `tests/drawing.rs` | 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]). Table cell shading from every SHD record (`sprmTDefTableShd`, `sprmTDefTableShdRaw` and their 2nd/3rd ranges, with the Word 97 `Shd80` as fallback; a clear pattern shows its background colour); the row-end mark is not a cell, and each table has its own grid and properties (#1380). Horizontally merged rows stored as fewer, wider cells come with #1123. Freeform shapes, z-order and Word 6/95 files are dropped or refused. Password-protected files (XOR obfuscation, RC4, RC4 CryptoAPI) open after a password prompt, from the public specs; not yet tried on a file Word wrote (AES-encrypted ones are refused). No writer. DIB conversion caps BMP bytes (header included) and encoded PNG at the supplied BLIP byte limit, and uses it as the decoder's allocation limit; a BMP fallback is kept if it fits (related #1334 and #1554). | | PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133); Hebrew and Arabic copy and search as whole words (right-to-left glyphs written in visual order, #1383); All Caps and Small Caps text extracts as the capitals shown, as from Word's PDFs, and never changes the case of other text (#1473), 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. Horizontal and vertical lines inside groups are drawn (#1474). 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. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only). Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Tracked insertions and deletions read and written (`\revtbl`, `\revised`/`\deleted`, authors, `\revdttm` dates), so Accept/Reject All work after opening or a round trip; paragraph-mark and formatting revisions not yet. Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export still writes one section. Thin: comments, notes, section export, RTL | +| `.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. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only), Mac OS Roman (`\mac`, `\fcharset77`) and DOS code pages (`\pc` 437, `\pca` 850, `\cpg` 437/737/850/852/857/866, `\fcharset254`) (#1653). Double-byte East Asian code pages (Japanese `\fcharset128`, Chinese, Korean) not decoded yet. Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Tracked insertions and deletions read and written (`\revtbl`, `\revised`/`\deleted`, authors, `\revdttm` dates), so Accept/Reject All work after opening or a round trip; paragraph-mark and formatting revisions not yet. Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export still writes one section. Thin: comments, notes, section export, RTL | | `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs`, `crates/formats/src/odt.rs` unit tests, `crates/engine/tests/odt_sections.rs` | Styles, lists (#87), tables, pictures, headers/footers, page breaks (#86), page size, orientation and margins (#810), sections: one per master-page change on a paragraph or table (next page, with its own page size, orientation, margins, columns and page-numbering restart) and per columned `text:section` (continuous, with its columns and separator line) (#1379); tracked insertions and deletions with author and date, inserted and deleted paragraph marks and text inserted then deleted included (ODF change tracking; format changes read as none; pictures and tables inside a tracked deletion are dropped). No footnotes, comments, 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). A Word or RTF extension that contradicts the content also opens by signature, as in Word: RTF saved as `.doc` (hh.ru résumés, accounting exports), a `.docx` renamed `.doc`, a Word 97-2003 file renamed `.docx`. 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). Header cells (``) bold unless their CSS says otherwise; `text-align` on a cell, or inherited from its row, row group or table, aligns the cell's paragraphs (#1381). HTML gives no page size, so it opens on the region's paper (A4 or Letter), as Markdown and plain text do; `@page` isn't read (#1381). No Word "Web Page" (with `_files` folder) round-trip. The same reader takes HTML from the system clipboard: Paste keeps another program's formatting (a Word document, a web page; Windows' "HTML Format" header understood), Merge Formatting keeps its emphasis (#687). Equations are written as MathML (with the linear format as a UnicodeMath annotation) and read back from MathML: our annotation, else the presentation elements, else a TeX annotation (#816) | @@ -221,6 +221,7 @@ attributes inside stories and style or list definitions; a regenerated theme. Te | 2026-10-13 | trivial | Rounded rectangles keep their own corner radius: `roundRect` `adj` read, written back and drawn (#1476) | | 2026-10-13 | trivial | Replace All checks content-control locks before editing; a refused batch leaves the document and undo history unchanged; existing deletion-lock rules reused, with tracked/untracked regression coverage | | 2026-10-14 | trivial | VML preset shapes (`o:spt`, `_x0000_tNN` shapetypes) drawn like the DrawingML preset of the same shape (the nearest shape when WordCraft has none) and kept on save, e.g. Word's right arrow `_x0000_t13` (#1377) | +| 2026-10-14 | trivial | RTF import (#1653): Mac OS Roman (code page 10000) for `\mac` documents, `\fcharset77` fonts and `\cpg`/`\ansicpg` 10000, and DOS text: `\pc` (437), `\pca` (850), the OEM pages through `\cpg` and `\fcharset254` (was read as Windows-1252); a code page number out of range is ignored instead of truncated to another; East Asian double-byte code pages still to do | | 2026-10-12 | trivial | VML text boxes keep their internal margins (`inset`), fit to their text (`mso-fit-shape-to-text`) and vertical alignment (`v-text-anchor`); hidden drawings (`visibility:hidden`, `wp:docPr/@hidden`) are kept but not shown | | 2026-10-13 | trivial | Remove personal information / Remove date and time settings kept and honoured on save: anonymous annotation authors, no creator or last-modified-by, no annotation dates (#1062) | | 2026-10-12 | trivial | Direct run-level `w:contentPart` InkML keeps its relationship, content type and bytes through DOCX edits and repeated saves; nested group/canvas ink and native rendering remain missing | From b53796d412e487ff72ab4c92d240599b180c8ea8 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:29:37 +0200 Subject: [PATCH 051/101] Dropping several images inserts all of them (#1592) (#1664) --- crates/ui-egui/src/lib.rs | 57 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5666750b8..9dbd40529 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1560,6 +1560,20 @@ impl WordApp { } } + /// Inserting a picture selects it, and the next insert would replace it. When several images + /// arrive in one drop, put the caret after the picture placed by the previous file (#1592). + fn caret_after_placed_picture(&mut self, placed: bool) { + if placed { + self.session.sel.anchor = self.session.sel.focus.clone(); + } + } + + /// Whether `r` placed a picture in the text (and selected it). One taken into a selected + /// Drawing Canvas leaves the canvas selected, so the next file of the drop joins it too. + fn placed_in_text(r: &Result) -> bool { + r.as_ref().is_ok_and(|v| v.get("canvasMember").is_none()) + } + /// A picked image replaces the pending Change Picture target, else it is inserted. fn insert_or_change_picture(&mut self, params: Value) -> Result { let target = std::mem::take(&mut self.change_picture_target); @@ -1771,6 +1785,8 @@ impl WordApp { ctx.request_repaint(); } #[cfg(not(target_arch = "wasm32"))] + let mut placed_picture = false; + #[cfg(not(target_arch = "wasm32"))] for f in ctx.input(|i| i.raw.dropped_files.clone()) { { let path = f.path().to_string_lossy().to_string(); @@ -1778,7 +1794,10 @@ impl WordApp { let img = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".wmf", ".emf"].iter().any(|e| lp.ends_with(e)); let sheet = wordcraft_engine::cmd::mailings::is_spreadsheet_name(&lp); let _ = if img { - self.run("insert.picture", json!({"path": path})) + self.caret_after_placed_picture(placed_picture); + let r = self.run("insert.picture", json!({"path": path})); + placed_picture = Self::placed_in_text(&r); + r } else if sheet { // A spreadsheet isn't a document: it's a recipient list (#334). self.load_recipients(json!({"path": path})) @@ -2025,6 +2044,7 @@ impl WordApp { fn drain_inbox(&mut self) { let Some(inbox) = self.services.inbox.clone() else { return }; let files = std::mem::take(&mut *inbox.lock().unwrap_or_else(|e| e.into_inner())); + let mut placed_picture = false; for (name, bytes) in files { let lower = name.to_ascii_lowercase(); let img = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".wmf", ".emf"].iter().any(|e| lower.ends_with(e)); @@ -2040,7 +2060,10 @@ impl WordApp { } else if list { self.load_recipients(json!({"csv": wordcraft_engine::io::decode_text(&bytes)})) } else if img { - self.insert_or_change_picture(json!({"data": wordcraft_engine::cmd::insert::base64_encode(&bytes)})) + self.caret_after_placed_picture(placed_picture); + let r = self.insert_or_change_picture(json!({"data": wordcraft_engine::cmd::insert::base64_encode(&bytes)})); + placed_picture = Self::placed_in_text(&r); + r } else { self.run("file.open", json!({"path": name, "data": wordcraft_engine::cmd::insert::base64_encode(&bytes)})) }; @@ -6070,6 +6093,36 @@ mod tests { assert!(a.change_picture_target.is_none()); } + /// Several images dropped together all stay in the document (#1592). + #[test] + fn several_dropped_images_are_all_inserted() { + let mut a = app(); + a.services.inbox = Some(std::sync::Arc::new(std::sync::Mutex::new(Vec::new()))); + { + let mut q = a.services.inbox.as_ref().unwrap().lock().unwrap(); + q.push(("a.png".into(), png_bytes([200, 30, 30, 255]))); + q.push(("b.png".into(), png_bytes([30, 200, 30, 255]))); + } + a.drain_inbox(); + assert_eq!(object_count(&mut a), 2); + } + + /// With a Drawing Canvas selected, every image of the drop goes into it (#1592). + #[test] + fn several_dropped_images_all_join_a_selected_canvas() { + let mut a = app(); + a.services.inbox = Some(std::sync::Arc::new(std::sync::Mutex::new(Vec::new()))); + a.session.run("insert.canvas", &json!({})).unwrap(); + { + let mut q = a.services.inbox.as_ref().unwrap().lock().unwrap(); + q.push(("a.png".into(), png_bytes([200, 30, 30, 255]))); + q.push(("b.png".into(), png_bytes([30, 200, 30, 255]))); + } + a.drain_inbox(); + let members = a.session.run("canvas.member", &json!({})).unwrap(); + assert_eq!(members["members"].as_array().map(|m| m.len()), Some(2)); + } + /// Without a pending change, inbox images insert; stale targets clear on selection change. #[test] fn insert_without_pending_adds_and_stale_target_clears() { From 1c814e0269b5c710d062d42fc95dde5c4f815a2d Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:29:56 +0200 Subject: [PATCH 052/101] Copy puts the formatted text on the system clipboard as HTML, not only copies with equations (#1523) (#1667) Co-authored-by: Brandon Thomas --- ROADMAP.md | 3 ++- apps/wordcraft/src/clipboard.rs | 4 ++-- crates/engine/src/cmd/edit.rs | 8 ++++---- crates/engine/src/session.rs | 4 ++-- crates/engine/src/tests.rs | 17 +++++++++++++++-- crates/ui-egui/src/system_clipboard.rs | 10 ++++++---- docs/gaps.md | 13 +++++++------ docs/ui-parity.md | 3 ++- 8 files changed, 40 insertions(+), 22 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 259104e59..4d8d22e0d 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-14 · **Change:** trivial (progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: floating tables ignore empty line-break lines; previously trivial: progress log: comment deletion leaves no dangling anchors or replies, #1345; previously trivial: progress log: open DrawingML freeform paths draw their end arrowhead; previously trivial: progress log: full screen, #980; previously trivial: progress log: PAGE fields on the pages a paragraph runs onto, #1352; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; 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-14 · **Change:** trivial (progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; 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 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -178,6 +178,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | Dragging a Square-wrapped shape or picture on a multi-column page wraps the text of the column it lands in around it, and its old place closes up (#884) | | 2026-10-13 | Pagination with footnotes follows Word (#1116): a paragraph ending on a page with footnotes keeps its space after above the separator, otherwise its last lines move to the next page with the note; pages without notes are unchanged | | 2026-10-13 | Interface size means what it says (#822): a chosen size is absolute, as in PhotoCraft and PdfCraft, so "100%" is one screen pixel per point even where the system reports 200 % (a bare window manager on X11, where winit guesses the scale from the monitor's DPI). The new default, Auto, keeps the system's display scale and its menu entry shows the size it draws at; sizes run 75–300 %. A saved size from before stays the same if it was the default (now Auto) and becomes absolute otherwise | +| 2026-10-14 | Copied text keeps its formatting in other programs (#1523): Copy and Cut put HTML beside the plain text on the system clipboard for every selection, not only one holding an equation, so a browser, mail client or another word processor pastes bold, italic, fonts and sizes. RTF on the clipboard is not written yet | | 2026-10-13 | Copying an equation also puts MathML in HTML on the clipboard beside the linear text like `y=2x_s` (#816; Word/LibreOffice paste not yet verified; OMML is #1297). HTML export writes equations as MathML too, and opening or pasting HTML with MathML gives equations back | | 2026-10-13 | The Equation tab's galleries, structure buttons and symbols keep the theme's colours on Windows: their pictures share a few textures written in place (no wipe of pictures already on screen, room for the built-ins at high display scales), instead of hundreds of small textures freed in bulk, which on DirectX 12 showed inverted, red or teal tiles (#1002) | | 2026-10-13 | `file.close` over the control channel or MCP bridge no longer quits and discards unsaved changes (#1268): it returns an error unless the document is saved or `{"force": true}` is passed; the user's Save / Don't Save / Cancel prompt is unchanged | diff --git a/apps/wordcraft/src/clipboard.rs b/apps/wordcraft/src/clipboard.rs index 1776d8fd0..6917ecc97 100644 --- a/apps/wordcraft/src/clipboard.rs +++ b/apps/wordcraft/src/clipboard.rs @@ -9,8 +9,8 @@ //! //! Everything read here comes from other programs: sizes are capped as it is read. //! -//! Written here: a copy holding an equation puts HTML (the equation as MathML) beside its text -//! (#816), for programs that read MathML (pasting into Word and LibreOffice not yet verified). arboard sets HTML with a +//! Written here: a copy puts HTML beside its text, so other programs keep its formatting (#1523), +//! with equations as MathML (#816; pasting into Word and LibreOffice not yet verified). arboard sets HTML with a //! text alternative on every desktop platform; formats of other names (Word's "Office Open XML" //! with OMML, #1297) it can't, so they aren't offered. diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 78c1edba2..c7af00d18 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -166,12 +166,12 @@ fn copy(s: &mut Session, _: &Value) -> CmdResult { Ok(copy_result(s)) } -/// What Copy and Cut return: the clipboard's text, and HTML with the equations as MathML when -/// the copy holds any (#816), for the host to put on the system clipboard too. +/// What Copy and Cut return: the clipboard's text, and the copy as HTML (equations as MathML, +/// #816) for the host to put on the system clipboard too, so other programs keep the formatting. pub(crate) fn copy_result(s: &mut Session) -> Value { s.clipboard_html = match &s.clipboard { - Some(f) if f.has_equation() => wordcraft_formats::html::export_fragment(&f.to_document()), - _ => String::new(), + Some(f) => wordcraft_formats::html::export_fragment(&f.to_document()), + None => String::new(), }; if s.clipboard_html.is_empty() { json!({"text": s.clipboard_text}) } else { json!({"text": s.clipboard_text, "html": s.clipboard_html}) } } diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index fc1f4af47..c96ceae03 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -351,8 +351,8 @@ pub struct Session { pub pending_move: Option, /// Plain text mirror of the clipboard (for the system clipboard). pub clipboard_text: String, - /// HTML for the system clipboard beside [`Self::clipboard_text`] when the copy holds an - /// equation (as MathML, #816), for programs that read MathML; empty otherwise. + /// HTML for the system clipboard beside [`Self::clipboard_text`]: the copy's formatting for + /// programs that read HTML, equations as MathML (#816); empty when nothing was copied. pub clipboard_html: String, /// Items collected by Copy and Cut this session, for the Clipboard pane. pub clip_history: crate::cmd::edit::ClipHistory, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 1eef4a492..c67b6f2d3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -894,10 +894,23 @@ fn copying_an_equation_puts_mathml_on_the_clipboard() { run(&mut s, "edit.paste", json!({"text": clip, "html": html})); assert_eq!(equations(&s), vec!["y=2x_s".to_string(), "y=2x_s".to_string()]); - // A copy without an equation carries no HTML. + // A copy without an equation carries HTML too (#1523). run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": 0}, "focus": {"block": 0, "off": 5}})); let r = run(&mut s, "edit.copy", json!({})); - assert!(r.get("html").is_none() && s.clipboard_html.is_empty(), "{r}"); + assert!(r["html"].as_str().is_some_and(|h| h.contains("Speed") && !h.contains("bold"), "{html}"); + assert_eq!(s.clipboard_html, html); } #[test] diff --git a/crates/ui-egui/src/system_clipboard.rs b/crates/ui-egui/src/system_clipboard.rs index be77f80ff..580c36cdf 100644 --- a/crates/ui-egui/src/system_clipboard.rs +++ b/crates/ui-egui/src/system_clipboard.rs @@ -67,8 +67,8 @@ pub(crate) fn fill(app: &mut WordApp, id: &str, params: &mut Value) -> Option **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (floating-table overlap ignores empty line-break lines; previously trivial: gap #3: dragging a Behind Text picture or shape over text now works, in the body and headers, and plain clicks still reach the text; previously trivial: crop handles on the canvas landed; previously trivial: re-audited every row against main: stale PR and issue states, counts and landed items corrected; previously trivial: Windows Magnifier follows the caret onto a new line, #1119; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; 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-14 · **Change:** trivial (floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; 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 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -52,7 +52,7 @@ Document renaming: desktop renaming requires a filesystem that supports hard lin | 27 | **View windows**: Side by Side, Synchronous Scrolling, Arrange All, Switch Windows | | [parity-checklist.md](parity-checklist.md) (View 25/29); PR #331 open | Comparing documents | 3–5 | [ui-parity.md](ui-parity.md) | | 26 | **Minor formats**: MHT, Works/WordPerfect | | Word XML Document (Flat OPC) opens and saves, Word 2003 XML opens (#548) and saves (#1081, not yet checked in Word) | Rare | 14–26 | [file-format-parity.md](file-format-parity.md) | | 28 | **Dictate** | | `tools.dictate` missing | Dictation users | 8–15 + owner (speech model) | [hardware-parity.md](hardware-parity.md) | -| 29 | **Equations**: ink equations; copied equations as OMML for Word (#816, #1297) | | #191 notes; long display equations wrap at operators (#326); a copy holding an equation puts MathML in HTML on the clipboard (#816; Word/LibreOffice paste not yet verified); only copies containing an equation get HTML, text-only copies stay plain | Maths-heavy documents | 2–3 | [layout-parity.md](layout-parity.md) | +| 29 | **Equations**: ink equations; copied equations as OMML for Word (#816, #1297) | | #191 notes; long display equations wrap at operators (#326); a copy holding an equation puts MathML in HTML on the clipboard (#816; Word/LibreOffice paste not yet verified) | Maths-heavy documents | 2–3 | [layout-parity.md](layout-parity.md) | **Beta needs #1–#7** (~150–230 h with the stability and layout work they imply): they are the blocking gaps, and closing them lifts file formats, layout, stability and UI enough to take the @@ -70,11 +70,11 @@ The same gaps grouped the way the parity documents are, for agents working in on styles, and Draw Table and Eraser (#303) landed on 2026-10-10. Paste from other programs keeps their formatting (system clipboard HTML/RTF), Merge Formatting keeps emphasis, Paste Special lists what the system clipboard holds, and Linux/Wayland clipboard pictures paste (#687, #833); - still missing: the Paste Options button after a paste, and RTF on macOS and X11. Copying an - equation puts MathML in HTML on the clipboard (#816; pasting into Word/LibreOffice not yet - verified; OMML on the clipboard is #1297); only copies containing an equation get HTML, - text-only copies stay plain text. - **UI/UX gaps:** #7, #18, #24; context menus for objects (the table's landed; it still lacks Border Styles); Linux title bar theming (#78); File › + still missing: the Paste Options button after a paste, and RTF on macOS and X11. Copy puts + HTML beside the text on the clipboard, so other programs keep the formatting (#1523), with + equations as MathML (#816; pasting into Word/LibreOffice not yet verified; OMML on the + clipboard is #1297); RTF on the clipboard is not written yet. 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. @@ -174,6 +174,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | 2026-10-10 | trivial | #10: Tight and Through wrapping follow the object's outline (#354) | | 2026-10-10 | trivial | #10: free rotation and flips for pictures, shapes and text boxes, with a rotation handle (#332); text boxes rotate their frame; their text stays upright | | 2026-10-10 | trivial | #10: Drawing Canvas landed (#344); in-canvas dragging of members remains | +| 2026-10-14 | trivial | Feature gaps: every copy puts HTML beside its text on the clipboard, not only copies with an equation (#1523); writing RTF stays open | | 2026-10-13 | trivial | #29: copied equations reach the clipboard as MathML in HTML (#816); OMML for Word stays open (#1297) | | 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) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 0c8a12111..e0d1522da 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-14 · **Change:** trivial (Page Setup vertical alignment, #1320; previously trivial: the Save Changes prompt takes the keyboard from the page, #1597; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously 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-14 · **Change:** trivial (Page Setup vertical alignment, #1320; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously 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 @@ -63,6 +63,7 @@ Document title opens Rename Document; the account badge opens Options › Genera | 2026-10-12 | trivial | Document title opens Rename Document; the account badge opens Options › General for user name and initials. | | 2026-10-13 | trivial | Magnifiers: a line's end mark (paragraph mark or line break) takes room in what the canvas tells assistive technology, so Windows Magnifier, which places the caret by the character after it, follows the caret onto an empty line after Enter or Shift+Enter instead of staying on the line above (#1119) | | 2026-10-14 | trivial | Save Changes prompt: Save takes focus from the page too, so after typing the arrow keys, Tab and Enter answer it instead of moving the caret behind it (#1597) | +| 2026-10-14 | trivial | Copy and Cut put HTML beside the text on the system clipboard for every selection, not only one holding an equation, so pasting into a browser, mail client or another word processor keeps bold, italic, fonts and sizes (desktop, through arboard; Wayland without data-control and the web: text only) (#1523) | | 2026-10-13 | trivial | The caret is black or white by contrast with the paper, page colour, dark page, shading or highlight under it (#299) | | 2026-10-13 | trivial | Clicks on ribbon and status-bar chrome that run no command button (Styles and style-set gallery tiles, tab names, empty ribbon space, View check boxes, the status bar's view buttons and language menu) no longer take keyboard focus from the page: the caret stays and typing continues, as in Word; text fields such as the font size box still take focus. Tabs, gallery tiles and the status bar's view buttons report their name and state to screen readers (#1093) | | 2026-10-13 | trivial | Interface size: a chosen size is absolute ("100%" is one screen pixel per point, even where the system reports 200 % under a bare window manager) and the new default, Auto, keeps the display scale and shows the size it draws at; 75–300 %; an old saved size becomes the size it drew at; on the web a chosen size cancels browser zoom, Auto follows it (#822) | From e5a055a310e21a60d5444a70cfa9f4add482f7a3 Mon Sep 17 00:00:00 2001 From: Martin Ball <98639962+benandez@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:30:09 +0100 Subject: [PATCH 053/101] CLI PDF export: Arabic and Persian fall back to the craft-fonts Arabic face (#1652) (#1668) With no system fonts (a headless CLI), Arabic and Persian text drew as empty boxes: the document font database only loaded the craft-fonts Japanese and Chinese faces. Load every non-Japanese craft-fonts face. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/fonts/src/fontdb.rs | 8 +++++--- crates/fonts/src/lib.rs | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 910e9694b..1b984c94f 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -890,11 +890,13 @@ fn style_italic(style: &str) -> bool { impl FontDb { /// A database holding the bundled fonts, then the craft-fonts Japanese faces (Mincho first, /// so they are the fallback for Japanese text after the requested and bundled fonts), then the - /// craft-fonts Simplified Chinese faces, for the ideographs the Japanese fonts lack (both empty - /// without `CRAFT_FONTS_DIR`), whose system font scan reads `font_dirs`. + /// other craft-fonts faces: Simplified Chinese, for the ideographs the Japanese fonts lack, and + /// Arabic, so Arabic and Persian text draws without system fonts (#1652) (all empty without + /// `CRAFT_FONTS_DIR`), whose system font scan reads `font_dirs`. pub fn with_font_dirs(font_dirs: Vec) -> Self { let mut faces = Vec::new(); - let craft = crate::japanese_document_fonts().into_iter().chain(crate::chinese_fonts()).map(|f| f.bytes); + let others = crate::CRAFT_FONTS.iter().filter(|f| !f.scripts.contains(&"Jpan")); + let craft = crate::japanese_document_fonts().into_iter().chain(others).map(|f| f.bytes); for data in BUNDLED.iter().copied().chain(craft) { for f in enumerate_faces(data) { if let Some(mut face) = make_face(FontBytes::Static(data), f.index, f.family, f.style, f.legacy, f.coords) { diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index cddcdad4b..5e5ff26af 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -385,6 +385,22 @@ mod tests { assert!(f2.family.contains("Mincho"), "{f2:?}"); } + #[test] + fn arabic_and_persian_fall_back_to_the_craft_fonts_arabic_face() { + // #1652: with no system fonts (a headless CLI), Arabic and Persian drew as empty boxes. + let Some(arabic) = CRAFT_FONTS.iter().find(|f| f.scripts.contains(&"Arab")) else { + eprintln!("skipped: no Arabic face in the optional craft-fonts input"); + return; + }; + let db = FontDb::with_font_dirs(Vec::new()); + db.set_system_fallback(false); + let latin = db.face(DEFAULT_FAMILY, "Regular"); + for c in "مرحبا پچژگ۱۲۳".chars().filter(|c| !c.is_whitespace()) { + let fb = db.fallback_for(c, latin.id()).unwrap_or_else(|| panic!("no fallback for {c}")); + assert_eq!(fb.family, arabic.family, "{c}"); + } + } + #[test] fn works_without_craft_fonts() { // Always true without CRAFT_FONTS_DIR; with it, the bundled fonts must still all load. From 67b04286573dcc4c00e1d819241ff179aff3ff49 Mon Sep 17 00:00:00 2001 From: dfgodvan Date: Thu, 15 Oct 2026 04:30:21 +0300 Subject: [PATCH 054/101] TOC page numbers reach the right margin of the TOC's section, and no blank line above imported entries (#1686) Update Table on a TOC read from a file set the page-number tab at the text width of the caret's section (after Select All, the last one: a landscape appendix pushed level-one leaders past the margin), kept the style's number tab stops so a short entry or the last line of a wrapped one stopped there without its leader, and left the paragraph that held the field's start empty under the TOC heading. The width now comes from the TOC's own section, entries without a tab of their own clear the style's left/centre/decimal stops before the page-number stop, and a range that filled its own paragraphs under a TOC heading puts the field at the end of that heading, where Table of Contents puts it. Co-authored-by: dfgodvan <180286157+dfgodvan@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/references.rs | 43 +++++++++++++++-- crates/engine/src/tests.rs | 73 +++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/references.rs b/crates/engine/src/cmd/references.rs index 897b9c45f..10ce79bdd 100644 --- a/crates/engine/src/cmd/references.rs +++ b/crates/engine/src/cmd/references.rs @@ -367,7 +367,21 @@ fn range_toc_to_field(s: &mut Session) -> Result<(), CmdError> { 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 mut at = s.doc.delete_range(&r.start, &end)?; + // A range that filled its own paragraphs right under a TOC heading (as Word and other writers + // lay it out) leaves an empty paragraph behind, which would show as a blank line above the + // entries. The field goes at the end of the heading instead, as `insert_toc` places it. + let i = at.path.last(); + if at.path.depth() == 0 + && i > 0 + && s.doc.para_at(&at).is_some_and(|p| p.is_empty()) + && s.doc.body.get(i - 1).and_then(|b| b.as_para()).is_some_and(|p| p.props.style.as_deref() == Some("TOCHeading")) + { + s.doc.remove_block(StoryRef::Body, &at.path)?; + let heading = Path::top(i - 1); + let len = s.doc.para(StoryRef::Body, &heading).map_or(0, |p| p.len()); + at = Pos { story: StoryRef::Body, path: heading, off: len }; + } let field = InlineObject::Field { instr: r.instr.trim().to_string(), result: String::new(), locked: false, code: false, form: String::new() }; let after = s.doc.insert_object(&at, field, &props)?; if s.doc.para_at(&after).is_some_and(|p| after.off < p.len()) { @@ -493,7 +507,10 @@ fn regenerate_toc(s: &mut Session, leader: Option) -> Result<(), CmdE for i in (start + 1..=end).rev() { s.doc.remove_block(StoryRef::Body, &Path::top(i))?; } - let width = super::page::sect(s).text_width(); + // The text width of the TOC's own section, not the caret's: after Select All the caret sits + // at the end of the document, possibly in a wider (landscape) section. + let section = s.doc.section_index_of(start); + let width = s.doc.sections().get(section).map_or_else(|| super::page::sect(s).text_width(), |(_, x)| x.text_width()); let entries = toc_entries(s, &f); let links: Vec> = if f.has('h') { let mut v = Vec::new(); @@ -530,7 +547,8 @@ fn regenerate_toc(s: &mut Session, leader: Option) -> Result<(), CmdE let mut para = Paragraph::with_text(&text, props).styled(&format!("TOC{}", e.level + 1)); para.mark = CharProps::default(); if show_page && sep.contains('\t') { - para.props.tabs = Some(vec![TabStop { pos: width - 0.5, align: TabAlign::Right, leader }]); + let right = TabStop { pos: width - 0.5, align: TabAlign::Right, leader }; + para.props.tabs = Some(entry_tabs(s, ¶.props, &e.text, right)); } s.doc.insert_block(StoryRef::Body, &Path::top(start + 1 + k), Block::Para(para))?; } @@ -539,6 +557,25 @@ fn regenerate_toc(s: &mut Session, leader: Option) -> Result<(), CmdE Ok(()) } +/// The tab stops of a TOC entry: the right-aligned stop for its page number, and, when the +/// entry text itself has no tab, Clear stops for the left, centre and decimal stops its style +/// sets before that. Those stops are for a number set off by a tab (`1.1Title`); with the +/// number set off by a space, or no number, a short entry's page tab would stop at one of them +/// (without the leader), and so would the tab on the last line of an entry that wraps. +fn entry_tabs(s: &Session, props: &wordcraft_doc::ParaProps, text: &str, right: TabStop) -> Vec { + let mut tabs = Vec::new(); + if !text.contains('\t') { + let style = s.doc.styles.resolve_para(&wordcraft_doc::ParaProps { style: props.style.clone(), ..Default::default() }); + for t in style.tabs { + if t.pos < right.pos - 0.5 && !matches!(t.align, TabAlign::Right | TabAlign::Bar) { + tabs.push(TabStop { pos: t.pos, align: TabAlign::Clear, leader: TabLeader::None }); + } + } + } + tabs.push(right); + tabs +} + fn note(s: &mut Session, v: &Value, kind: NoteKind) -> CmdResult { let text = p::str(v, "text").unwrap_or("").to_string(); // A custom mark: a few characters, as Word's dialog takes. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index c67b6f2d3..936f33608 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2331,6 +2331,79 @@ fn imported_range_toc_is_refreshed_in_place() { } } +/// A TOC as Word and other writers lay it out (a TOC heading, then the field in paragraphs of its +/// own), styles with a number tab stop and a hanging indent, and a wider last section with the +/// caret in it (Select All, Update Field): no blank line between the heading and the entries, +/// and every page number at the TOC section's right margin, after its leader: short entries, +/// entries that wrap, and level-one entries whose style has no stops of its own. +#[test] +fn toc_page_numbers_reach_the_right_margin() { + use wordcraft_doc::para::InlineObject; + use wordcraft_doc::props::{CharProps, TabAlign, TabLeader, TabStop}; + let long = "A deliberately long section title that does not fit on one line of the table of contents and wraps onto a second line"; + let mut d = wordcraft_doc::Document::from_text(&format!("Contents\n\nNo entries.\n\nShort\n{long}\nTop\nAppendix")); + { + let toc2 = d.styles.get_mut("TOC2").unwrap(); + toc2.para.tabs = Some(vec![ + TabStop { pos: 77.0, align: TabAlign::Left, leader: TabLeader::None }, + TabStop { pos: 467.2, align: TabAlign::Right, leader: TabLeader::Dot }, + ]); + toc2.para.indent_left = Some(36.9); + toc2.para.indent_first = Some(-22.7); + let p = d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(1)).unwrap(); + p.insert_object(0, InlineObject::FieldStart { instr: r#"TOC \o "1-3" \h \z \u"#.into(), locked: false }, &CharProps::default()).unwrap(); + let p = d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(3)).unwrap(); + p.insert_object(0, InlineObject::FieldEnd, &CharProps::default()).unwrap(); + } + let mut s = Session::new(d); + run(&mut s, "caret.set", json!({"pos": Pos::body(0, 0)})); + run(&mut s, "styles.apply", json!({"style": "TOC Heading"})); + run(&mut s, "caret.set", json!({"pos": Pos::body(1, 0)})); + run(&mut s, "styles.apply", json!({"style": "TOC 1"})); + for (i, style) in [(4, "Heading 2"), (5, "Heading 2"), (6, "Heading 1"), (7, "Heading 1")] { + run(&mut s, "caret.set", json!({"pos": Pos::body(i, 0)})); + run(&mut s, "styles.apply", json!({"style": style})); + } + run(&mut s, "caret.set", json!({"pos": Pos::body(7, 0)})); + run(&mut s, "layout.break", json!({"kind": "nextPage"})); + run(&mut s, "caret.docEnd", json!({})); + run(&mut s, "layout.orientation", json!({"value": "landscape"})); + // Through DOCX, as an imported file is. + let bytes = wordcraft_docx::write(&s.doc).unwrap(); + s = Session::new(wordcraft_docx::read(&bytes).unwrap()); + run(&mut s, "select.all", json!({})); + run(&mut s, "references.updateFields", json!({})); + + let body: Vec<(String, String)> = + s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| (p.props.style.clone().unwrap_or_default(), p.plain_text())).collect(); + assert_eq!(body[0], ("TOCHeading".into(), "Contents".into()), "{body:?}"); + assert!(body[1].0.starts_with("TOC") && !body[1].1.is_empty(), "the first entry right under the heading: {body:?}"); + let toc_width = s.doc.sections()[0].1.text_width(); + let margin = s.doc.sections()[0].1.margin_left; + let l = s.layout(); + for (i, b) in s.doc.body.iter().enumerate() { + let Some(p) = b.as_para() else { continue }; + if !p.props.style.as_deref().is_some_and(|st| st.starts_with("TOC") && st != "TOCHeading") { + continue; + } + let page = p.text.rfind('\t').unwrap() + 1; + let end = l.caret(&Pos::body(i, p.len())).unwrap(); + let number = l.caret(&Pos::body(i, page)).unwrap(); + assert!( + (end.x - (margin + toc_width)).abs() < 2.0, + "{:?}: page number ends at {} (right margin {})", + p.plain_text(), + end.x, + margin + toc_width + ); + assert!(end.page == number.page && number.x > margin + toc_width - 30.0, "{:?}: page number at {}", p.plain_text(), number.x); + } + // Updating again keeps the same table. + let before = text(&s); + run(&mut s, "references.updateToc", json!({})); + assert_eq!(text(&s), before); +} + #[test] fn toc_page_numbers_follow_headings() { let mut s = s(); From 056771a33a6ae81ee92c2997cd4e46abace39295 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Thu, 15 Oct 2026 04:30:33 +0300 Subject: [PATCH 055/101] engine: store replacement images under their true format extension (#1691) * engine: store replacement images under their true format extension * rustfmt Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/insert.rs | 23 +++++++++++++++-------- crates/engine/src/cmd/objects.rs | 2 +- crates/engine/src/tests.rs | 29 +++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index f90f56291..35bc71023 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -222,6 +222,20 @@ pub(crate) fn picture(s: &mut Session, v: &Value) -> CmdResult { place_picture(s, bytes, p::f32(v, "width"), p::str(v, "alt").unwrap_or("")) } +/// The media extension of an encoded image by its magic bytes, as `image_size` accepted it +/// (TIFF included: the `image` crate decodes it). +pub(crate) fn image_ext(bytes: &[u8]) -> &'static str { + match bytes.get(..4) { + Some([0x89, b'P', b'N', b'G']) => "png", + Some([0xFF, 0xD8, ..]) => "jpeg", + Some([b'G', b'I', b'F', _]) => "gif", + Some([b'R', b'I', b'F', b'F']) => "webp", + Some([b'B', b'M', ..]) => "bmp", + Some([b'I', b'I', 0x2A, 0x00]) | Some([b'M', b'M', 0x00, 0x2A]) => "tiff", + _ => "png", + } +} + /// Insert an encoded image at the selection as an inline picture, `width` points wide (default: /// its natural size at 96 ppi), never wider than the text column. The caret ends after it, so a /// script's next typing doesn't replace it (#965); Picture Format commands still act on it. The @@ -232,14 +246,7 @@ fn place_picture(s: &mut Session, bytes: Vec, width: Option, alt: &str) } // Natural size: pixels at 96 ppi, or a metafile's own frame (WMF, EMF: #802). let (natural_w, natural_h, ext) = if let Some((pw, ph)) = wordcraft_render::image_size(&bytes) { - let ext = match bytes.get(..4) { - Some([0x89, b'P', b'N', b'G']) => "png", - Some([0xFF, 0xD8, ..]) => "jpeg", - Some([b'G', b'I', b'F', _]) => "gif", - Some([b'R', b'I', b'F', b'F']) => "webp", - Some([b'B', b'M', ..]) => "bmp", - _ => "png", - }; + let ext = image_ext(&bytes); (pw as f32 * 0.75, ph as f32 * 0.75, ext) } else if let Some(size) = wordcraft_render::metafile_size(&bytes) { size diff --git a/crates/engine/src/cmd/objects.rs b/crates/engine/src/cmd/objects.rs index c0552d18d..dedcee062 100644 --- a/crates/engine/src/cmd/objects.rs +++ b/crates/engine/src/cmd/objects.rs @@ -220,7 +220,7 @@ pub fn specs() -> Vec { return Err(CmdError::Params("`path` or `data` required".into())); }; wordcraft_render::image_size(&bytes).ok_or_else(|| CmdError::Failed("not a supported image".into()))?; - let key = s.doc.add_media(bytes, "png"); + let key = s.doc.add_media(bytes.clone(), super::insert::image_ext(&bytes)); with_obj(s, |o| { if let InlineObject::Image { media, .. } = o { *media = key.clone(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 936f33608..b96e7f512 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5984,6 +5984,35 @@ fn insert_picture_takes_a_windows_metafile() { let _ = std::fs::remove_dir_all(&dir); } +/// Change Picture must keep the replacement's own format: the part's name and content type +/// come from the extension, so a JPEG stored as `imageN.png` saves declared `image/png` (Word +/// and LibreOffice then show a broken picture). +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn picture_change_keeps_the_replacement_format() { + let dir = scratch_dir("picture-change-format"); + let first = dir.join("first.png"); + std::fs::write(&first, tiny_png()).unwrap(); + let mut jpeg = Vec::new(); + image::RgbImage::from_pixel(2, 2, image::Rgb([30, 30, 200])).write_to(&mut std::io::Cursor::new(&mut jpeg), image::ImageFormat::Jpeg).unwrap(); + let second = dir.join("second.jpg"); + std::fs::write(&second, &jpeg).unwrap(); + + let mut s = Session::new(wordcraft_doc::Document::new()); + run(&mut s, "insert.picture", json!({"path": first.to_string_lossy()})); + run(&mut s, "picture.change", json!({"path": second.to_string_lossy()})); + let mut media = String::new(); + s.doc.objects_in_reading_order(&mut |o| { + if let wordcraft_doc::para::InlineObject::Image { media: m, .. } = o { + media = m.clone(); + } + }); + assert!(media.ends_with(".jpeg") || media.ends_with(".jpg"), "the JPEG keeps its format: {media}"); + let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap(); + assert_eq!(back.media.get(&media).map(|b| b.as_slice()), Some(jpeg.as_slice()), "the saved part is the JPEG"); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn json_open_repairs_hostile_run_lengths() { use wordcraft_doc::{Block, Document}; From babb3315283eabbe39d06aef874b7a24934adaad Mon Sep 17 00:00:00 2001 From: Mat Strange <54028700+matstrange@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:30:55 +0100 Subject: [PATCH 056/101] Open on the Home tab every launch, as in Word (#1699) (#1700) The active ribbon tab was saved to ui.json and restored verbatim at startup, so relaunching resumed whichever tab was last used (Review, Help, Draw). Word always opens on Home. apply_prefs now forces DEFAULT_TAB on load. Going File and back to the editor still returns to the tab that was showing, contextual tabs included; tab is still written to ui.json, so the UiState JSON contract (MCP/control) is unchanged. resolve_tab uses the same DEFAULT_TAB so the startup default and the contextual-tab fallback can't drift. Co-authored-by: Brandon Thomas --- crates/ui-egui/src/dialogs_keyboard.rs | 5 +--- crates/ui-egui/src/dialogs_ribbon.rs | 3 +- crates/ui-egui/src/lib.rs | 41 ++++++++++++++++++++++++-- crates/ui-egui/src/ribbon.rs | 2 +- docs/ui-parity.md | 5 ++-- 5 files changed, 45 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/dialogs_keyboard.rs b/crates/ui-egui/src/dialogs_keyboard.rs index 1c5638594..ddd2cbb0b 100644 --- a/crates/ui-egui/src/dialogs_keyboard.rs +++ b/crates/ui-egui/src/dialogs_keyboard.rs @@ -286,10 +286,7 @@ mod tests { assert_eq!(b.session.keymap.resolve(&b.session.registry, "Mod+B").map(|s| s.id), Some("format.italic")); let mut c = app(); - c.apply_prefs( - serde_json::from_str(r#"{"tab": "Insert", "keyboard": {"assigned": {"Mod+Q": "gone.command", "Bad+": 1}, "removed": 7}}"#).unwrap(), - ); - assert_eq!(c.ui.tab, "Insert"); + c.apply_prefs(serde_json::from_str(r#"{"keyboard": {"assigned": {"Mod+Q": "gone.command", "Bad+": 1}, "removed": 7}}"#).unwrap()); assert!(c.session.keymap.is_empty()); } } diff --git a/crates/ui-egui/src/dialogs_ribbon.rs b/crates/ui-egui/src/dialogs_ribbon.rs index cb73e3718..d25817a68 100644 --- a/crates/ui-egui/src/dialogs_ribbon.rs +++ b/crates/ui-egui/src/dialogs_ribbon.rs @@ -405,8 +405,7 @@ mod tests { assert_eq!(b.session.ribbon.qat(), ["file.save", "edit.undo", "edit.redo", "format.bold"]); let mut c = app(); - c.apply_prefs(serde_json::from_str(r#"{"tab": "Insert", "ribbon": {"tabs": 3, "qat": ["gone.command", "edit.undo"]}}"#).unwrap()); - assert_eq!(c.ui.tab, "Insert"); + c.apply_prefs(serde_json::from_str(r#"{"ribbon": {"tabs": 3, "qat": ["gone.command", "edit.undo"]}}"#).unwrap()); assert!(c.session.ribbon.ribbon_is_default()); assert_eq!(c.session.ribbon.qat(), ["edit.undo"]); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9dbd40529..0c8f3a4fe 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -221,6 +221,8 @@ fn paste_target(canvas_focused: bool, backstage: bool, dialog: bool) -> bool { #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct UiState { + /// The ribbon tab showing. It is written to `ui.json` but not restored at startup: Word opens + /// on Home every launch, and only going File and back to the editor keeps the tab ([`WordApp::apply_prefs`]). pub tab: String, pub backstage: bool, pub backstage_page: String, @@ -319,6 +321,10 @@ pub struct UiState { pub dictionary: Vec, } +/// The ribbon tab the app opens on, and the one it falls back to when the tab that was showing +/// no longer applies (a contextual tab after the selection moved away, a tab hidden in Customize +/// Ribbon). Word opens on Home every launch; see [`UiState::tab`]. +pub const DEFAULT_TAB: &str = "Home"; /// Most recent documents kept (File › Options › Advanced offers 0–50, as Word does). pub const MAX_RECENT: usize = 50; /// Most custom dictionary words read back from `ui.json`. @@ -327,7 +333,7 @@ pub const MAX_DICTIONARY: usize = 10_000; impl Default for UiState { fn default() -> Self { UiState { - tab: "Home".into(), + tab: DEFAULT_TAB.into(), backstage: false, backstage_page: "home".into(), print: print_page::PrintSettings::default(), @@ -673,6 +679,9 @@ impl WordApp { pub fn apply_prefs(&mut self, ui: UiState) { self.ui = ui; self.ui.backstage = false; + // Word opens on Home every launch, so the tab `ui.json` holds is not restored. Going File + // and back to the editor within the run still returns to the tab that was showing. + self.ui.tab = DEFAULT_TAB.into(); // A `ui.json` from before the theme setting: its dark-mode switch picks Dark or Light. if let Some(dark) = self.ui.legacy_dark.take() { self.ui.theme = if dark { theme::Appearance::Dark } else { theme::Appearance::Light }; @@ -5924,10 +5933,38 @@ mod tests { fn prefs_without_editing_keep_its_defaults() { let mut a = app(); a.apply_prefs(serde_json::from_str(r#"{"tab": "Insert", "dark": true}"#).unwrap()); - assert_eq!((a.ui.tab.as_str(), a.ui.theme), ("Insert", theme::Appearance::Dark)); + // The tab is not restored (Word opens on Home), but forcing it costs nothing else. + assert_eq!((a.ui.tab.as_str(), a.ui.theme), (DEFAULT_TAB, theme::Appearance::Dark)); assert!(a.session.prefs.count_notes, "text boxes and notes count by default"); } + /// Word opens on Home every launch, so the tab `ui.json` holds is not restored; the rest of the + /// preferences come back as saved. + #[test] + fn startup_opens_on_home_not_the_saved_tab() { + let mut a = app(); + a.ui.tab = "Review".into(); + a.ui.theme = theme::Appearance::Dark; + let saved = serde_json::to_string(&a.prefs()).unwrap(); + assert!(saved.contains(r#""tab":"Review""#), "{saved}"); + let mut b = app(); + b.apply_prefs(serde_json::from_str(&saved).unwrap()); + assert_eq!(b.ui.tab, DEFAULT_TAB, "the saved tab is not restored"); + assert_eq!(b.ui.theme, theme::Appearance::Dark, "the other preferences are"); + } + + /// Going File and back to the editor returns to the tab that was showing, contextual tabs + /// included (Word behaves the same way). + #[test] + fn going_to_file_and_back_keeps_the_tab() { + let mut a = app(); + a.ui_command("ui.tab", &json!({"tab": "Table Design"})).unwrap().unwrap(); + a.ui_command("ui.tab", &json!({"tab": "File"})).unwrap().unwrap(); + assert!(a.ui.backstage, "File opens Backstage, leaving the tab alone"); + a.ui_command("ui.tab", &json!({"tab": "Table Design"})).unwrap().unwrap(); + assert!(!a.ui.backstage && a.ui.tab == "Table Design"); + } + fn png_bytes(c: [u8; 4]) -> Vec { let img = image::RgbaImage::from_fn(20, 10, |_, _| image::Rgba(c)); let mut b = Vec::new(); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 7be3ff317..30a55b4a7 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -132,7 +132,7 @@ pub fn contextual_tabs(s: &wordcraft_engine::Session) -> Vec<&'static str> { /// Tab to show when the stored tab is no longer applicable (pure, tested). pub fn resolve_tab<'a>(current: &'a str, available: &[&str]) -> &'a str { - if available.contains(¤t) { current } else { "Home" } + if available.contains(¤t) { current } else { crate::DEFAULT_TAB } } /// Width of the » button that holds the tabs the strip has no room for. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e0d1522da..e98dfff71 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-14 · **Change:** trivial (Page Setup vertical alignment, #1320; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously 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-14 · **Change:** trivial (Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously 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 @@ -12,7 +12,7 @@ Document title opens Rename Document; the account badge opens Options › Genera | 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 (lines, arrows, rectangles, ellipses, polygons), Ink Replay with play/pause/rewind/forward bar (#395); missing: Ink to Math | Same plus Draw | 85% | (Draw in features) | +| Ribbon tabs and groups | All Word tabs including contextual Table Design/Layout, Picture Format, Shape Format, Header & Footer, Equation, 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 (lines, arrows, rectangles, ellipses, polygons), Ink Replay with play/pause/rewind/forward bar (#395); missing: Ink to Math. The app opens on Home every launch, as in Word: the active tab is not restored between runs, but going File and back to the editor returns to the tab that was showing, contextual tabs included | Same plus Draw | 85% | (Draw in features) | | 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 | | 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 | @@ -64,6 +64,7 @@ Document title opens Rename Document; the account badge opens Options › Genera | 2026-10-13 | trivial | Magnifiers: a line's end mark (paragraph mark or line break) takes room in what the canvas tells assistive technology, so Windows Magnifier, which places the caret by the character after it, follows the caret onto an empty line after Enter or Shift+Enter instead of staying on the line above (#1119) | | 2026-10-14 | trivial | Save Changes prompt: Save takes focus from the page too, so after typing the arrow keys, Tab and Enter answer it instead of moving the caret behind it (#1597) | | 2026-10-14 | trivial | Copy and Cut put HTML beside the text on the system clipboard for every selection, not only one holding an equation, so pasting into a browser, mail client or another word processor keeps bold, italic, fonts and sizes (desktop, through arboard; Wayland without data-control and the web: text only) (#1523) | +| 2026-10-14 | trivial | The app opens on the Home tab every launch, as in Word: the active ribbon tab is no longer restored from `ui.json` between runs. Going File and back to the editor still returns to the tab that was showing, contextual tabs included | | 2026-10-13 | trivial | The caret is black or white by contrast with the paper, page colour, dark page, shading or highlight under it (#299) | | 2026-10-13 | trivial | Clicks on ribbon and status-bar chrome that run no command button (Styles and style-set gallery tiles, tab names, empty ribbon space, View check boxes, the status bar's view buttons and language menu) no longer take keyboard focus from the page: the caret stays and typing continues, as in Word; text fields such as the font size box still take focus. Tabs, gallery tiles and the status bar's view buttons report their name and state to screen readers (#1093) | | 2026-10-13 | trivial | Interface size: a chosen size is absolute ("100%" is one screen pixel per point, even where the system reports 200 % under a bare window manager) and the new default, Auto, keeps the display scale and shows the size it draws at; 75–300 %; an old saved size becomes the size it drew at; on the web a chosen size cancels browser zoom, Auto follows it (#822) | From daf8caada33da7c033289e3a5e69a4a35948a862 Mon Sep 17 00:00:00 2001 From: dfgodvan Date: Thu, 15 Oct 2026 04:31:10 +0300 Subject: [PATCH 057/101] Tables: repeated header rows move to the next page with the first row (#1704) (#1705) A table with repeated header rows that started near the bottom of a page left the header rows there alone when the first row after them didn't fit, then repeated them on the next page above that row. Word starts such a table on the next page. place_table now checks, before the first row, whether the row after the header rows can start under them (whole, or split when it may split). If it can't and the page already holds something, the table starts on the next page. A table at the top of a page keeps its header there as before. Co-authored-by: dfgodvan <180286157+dfgodvan@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/layout/src/lib.rs | 13 +++++++++++++ crates/layout/src/tests.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 596d5e7b3..a7decb564 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -3678,6 +3678,19 @@ fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, bl pb.y += row.height; pb.content_bottom = pb.content_bottom.max(pb.y); }; + // Like Word, repeated header rows don't stay alone at the bottom of a page: when the first row + // after them can't start under them there, the table starts on the next page. + let first = header_rows.len(); + if !header_rows.is_empty() + && !pb.at_top() + && let Some(row) = tl.rows.get(first) + { + let room = pb.bottom - pb.y - tl.rows.iter().take(first).map(|r| r.height).sum::(); + let splittable = !t.rows.get(first).is_some_and(|r| r.props.cant_split); + if row.height > room + 0.01 && !(splittable && table::split_row(row, room).is_some()) { + pb.advance(block, body_top); + } + } for (ri, row) in tl.rows.iter().enumerate() { // Word never splits a row of an exact height: it moves on whole. let splittable = !header_rows.contains(&ri) diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 2670952c8..fad17f1ed 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -2474,6 +2474,32 @@ fn repeated_header_is_followed_by_the_next_row_even_when_it_overflows() { assert!(pages.iter().all(|p| p.first() == Some(&0)), "{pages:?}"); } +#[test] +fn repeated_header_moves_with_the_first_row_that_does_not_fit() { + // A paragraph that leaves `room` points of the letter page's 648 pt body, then a table with a + // 20 pt header row and 40 pt one-line rows. Returns the table rows on each page. + let rows_per_page = |room: f32| -> Vec> { + let mut d = Document::from_text("Text above the table"); + d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().props.space_after = Some(648.0 - room - 14.0); + let mut t = Table::new(3, 1, 468.0); + t.rows[0].props.header = true; + for (row, h) in t.rows.iter_mut().zip([20.0, 40.0, 40.0]) { + row.props.height = Some(h); + row.props.height_rule = wordcraft_doc::props::HeightRule::AtLeast; + } + d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap(); + lay(&d) + .pages + .iter() + .map(|p| p.items.iter().filter_map(|i| if let Placed::Cell { row, cell: 0, .. } = i { Some(*row) } else { None }).collect()) + .collect() + }; + // Header and first row fit: the table starts on the first page. + assert_eq!(rows_per_page(80.0)[0], vec![0, 1]); + // Only the header fits: it goes to the next page with the first row instead of staying alone. + assert_eq!(rows_per_page(40.0), vec![vec![], vec![0, 1, 2]]); +} + /// A drop-cap paragraph in a font larger than 1000 pt used to panic in layout (`clamp` with min > max). #[test] fn drop_cap_with_a_huge_font_lays_out() { From 4b33e7b4ab8edf082be5c8b7adfe2a00d01fb884 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:59:29 +0200 Subject: [PATCH 058/101] Text typed after a footnote or endnote number is no longer superscript (#1593) (#1663) Co-authored-by: Brandon Thomas --- crates/engine/src/session.rs | 7 ++++++- crates/engine/src/tests.rs | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index c96ceae03..4f16aea9a 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -1321,7 +1321,12 @@ impl Session { // As in Word, text typed at either end of a hyperlink isn't part of it; only typing // inside the link extends it. let inside_link = props.link.is_some() && f.off > 0 && f.off < p.len() && p.props_of_char(f.off).link == props.link; - if props.link.is_some() && !inside_link { props.unlinked() } else { props.clone() } + let mut out = if props.link.is_some() && !inside_link { props.unlinked() } else { props.clone() }; + // A footnote or endnote number is raised by its character style; text typed after it is body text. + if matches!(out.style.as_deref(), Some("FootnoteReference" | "EndnoteReference")) { + out.style = None; + } + out } /// Words in the document, as the status bar shows them (see [`Prefs::count_notes`]). diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b96e7f512..72e112823 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6638,6 +6638,22 @@ fn exported_page_images_draw_headers_at_full_strength() { let _ = std::fs::remove_dir_all(&dir); } +/// Text typed right after a footnote or endnote number is plain body text, not another raised number. +#[test] +fn typing_after_a_note_number_is_not_superscript() { + for (cmd_id, style) in [("references.footnote", "FootnoteReference"), ("references.endnote", "EndnoteReference")] { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "One"})); + run(&mut s, cmd_id, json!({"text": "Note"})); + s.sel = crate::Selection::caret(s.doc.end_of(StoryRef::Body)); + assert_eq!(s.doc.para_at(&s.sel.focus).and_then(|p| p.props_at(p.len()).style.clone()).as_deref(), Some(style)); + assert_eq!(s.typing_props().style, None, "{cmd_id}"); + run(&mut s, "text.insert", json!({"text": " after"})); + let end = s.doc.end_of(StoryRef::Body); + assert_eq!(s.doc.para_at(&end).map(|p| p.props_of_char(p.len() - 1).style.clone()), Some(None), "{cmd_id}"); + } +} + /// Gap 19, review: text inserted by one author and deleted by another keeps both marks through /// an ODT save, so Reject All (undo the deletion, then the insertion) and Accept All both remove it. #[test] From 13070ca85e0d71d89020be77e7effcd01cfd7c2d Mon Sep 17 00:00:00 2001 From: Fin Warman Date: Thu, 15 Oct 2026 02:59:41 +0100 Subject: [PATCH 059/101] Layout: field instructions do not add blank paragraphs (#1665) Co-authored-by: Codex Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +++- contributors/people.toml | 1 + crates/docx/src/read/story.rs | 14 +++++++++++- crates/docx/tests/fixtures.rs | 35 ++++++++++++++++++++++++++++++ crates/layout/src/lib.rs | 14 ++++++++++-- crates/layout/src/tests.rs | 41 +++++++++++++++++++++++++++++++++++ docs/file-format-parity.md | 5 +++-- docs/gaps.md | 5 +++-- docs/layout-parity.md | 4 +++- 9 files changed, 114 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4d8d22e0d..2fdf36b4c 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-14 · **Change:** trivial (progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; 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-14 · **Change:** trivial (multi-paragraph field instructions no longer add blank lines; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; 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 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -136,6 +136,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| +| 2026-10-14 | Field instructions spanning paragraphs no longer add blank lines during pagination; empty result paragraphs still take a line. | | 2026-10-14 | Grouped `flowChartOr` shapes draw their circle and crossing spokes, as in Word | | 2026-10-14 | Deleting a comment removes its anchors from headers, footers, notes and text boxes too, not just the body, and deletes the whole thread below it, replies to replies included; no anchor or reply is left pointing at a deleted comment (#1345) | | 2026-10-14 | Legacy DOC DIB conversion checks the BMP header and copy before allocation, uses the caller's decode allowance, and caps PNG output while encoding; a BMP fallback is kept when it fits. A generated 1-pixel regression covers 57/58/512-byte limits. Resource-exhaustion consequences remain unmeasured. Related: #1334 and #1554 | @@ -383,6 +384,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Field instructions spanning paragraphs no longer add blank lines during pagination; empty result paragraphs still take a line. | | 2026-10-14 | trivial | Floating-table overlap checks use visible line content; empty line-break lines no longer move a table | | 2026-10-14 | trivial | Progress log: deleting a comment leaves no dangling anchors or replies (#1345) | | 2026-10-14 | minor | Legacy DOC DIB conversion byte limits only; related #1334/#1554, stage and readiness estimates unchanged | diff --git a/contributors/people.toml b/contributors/people.toml index 7e9ac0665..2c96a260f 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -100,6 +100,7 @@ display_name = "Martin Ball" [people.finwarman] real_name = "Fin Warman" + [people.albayazid] real_name = "Sazid Al Bayazid" diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index 7287bde45..e2575d2d4 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -397,6 +397,11 @@ impl Reader<'_> { para.text = pb.text; para.runs = pb.runs; para.objects = pb.objects; + // Paragraph boundaries inside an instruction are hidden with the field code. + // The result paragraph still takes a line, even when its cached text is empty. + if sc.fields.iter().any(|f| f.phase == Phase::Instr) && para.plain_text().is_empty() { + para.mark.hidden = Some(true); + } para.normalize(); para } @@ -613,6 +618,9 @@ impl Reader<'_> { } "w:tab" | "w:ptab" => self.emit_text(sc, pb, "\t", props), "w:br" => { + if sc.fields.iter().any(|f| f.phase == Phase::Instr) { + return; + } let c = match k.attr("w:type") { Some("page") => "\u{000C}", Some("column") => "\u{000E}", @@ -620,7 +628,11 @@ impl Reader<'_> { }; self.emit_text(sc, pb, c, props); } - "w:cr" => self.emit_text(sc, pb, "\n", props), + "w:cr" => { + if !sc.fields.iter().any(|f| f.phase == Phase::Instr) { + self.emit_text(sc, pb, "\n", props); + } + } "w:noBreakHyphen" => self.emit_text(sc, pb, "\u{2011}", props), "w:softHyphen" => self.emit_text(sc, pb, "\u{00AD}", props), "w:sym" => { diff --git a/crates/docx/tests/fixtures.rs b/crates/docx/tests/fixtures.rs index ceee89285..f4c736237 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/fixtures.rs @@ -665,6 +665,41 @@ fn unbalanced_fields_and_markers() { assert_eq!(p[2].plain_text(), "after"); } +#[test] +fn field_code_spans_paragraphs() { + let body = r#" +IF + MERGEFIELD x = "y" + +Visible"#; + let doc = read_body(body); + let p = paras(&doc); + assert_eq!(p.len(), 4); + for code in &p[..2] { + assert!(code.plain_text().is_empty()); + assert_eq!(code.mark.hidden, Some(true)); + } + assert_eq!(p[2].mark.hidden, None); + assert_eq!(p[3].plain_text(), "Visible"); + assert_eq!(p[3].mark.hidden, None); +} + +#[test] +fn empty_field_results_keep_line() { + let body = r#"PAGE +IF + + x = yResult"#; + let doc = read_body(body); + let p = paras(&doc); + assert_eq!(p[0].mark.hidden, None); + assert!(matches!(&p[0].objects[0], InlineObject::Field { instr, .. } if instr == "PAGE")); + assert_eq!(p[1].mark.hidden, Some(true)); + assert_eq!(p[2].mark.hidden, Some(true)); + assert_eq!(p[3].mark.hidden, None); + assert_eq!(p[3].plain_text(), "Result"); +} + #[test] fn header_self_reference_and_escaping_targets() { let hdr = format!( diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index a7decb564..3ff648436 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -653,8 +653,8 @@ struct Ctx<'a> { /// The width a top-level table's percentage width is a share of, when it isn't the box's: /// an inherited header or footer's tables follow the section that defines it. pct_base: Option, - /// Body blocks that are empty paragraphs carrying only a section break before a continuous - /// section, in order: they take no room (see [`place_para`]). + /// Empty body paragraphs with hidden marks or continuous section breaks, in order: + /// they take no room (see [`place_para`]). quiet_marks: Vec, /// Whether even pages are mirrored (mirrored margins, on pages): see [`mirror_even_pages`]. mirror: bool, @@ -2036,6 +2036,16 @@ fn layout_pass( .map(|w| w[0].0) .filter(|end| doc.body.get(*end).and_then(|b| b.as_para()).is_some_and(|p| p.text.is_empty())) .collect(); + if !opts.show_hidden { + ctx.quiet_marks.extend( + doc.body + .iter() + .enumerate() + .filter_map(|(i, b)| b.as_para().filter(|p| p.mark.hidden == Some(true) && p.plain_text().is_empty()).map(|_| i)), + ); + } + ctx.quiet_marks.sort_unstable(); + ctx.quiet_marks.dedup(); 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; diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index fad17f1ed..6f00716dc 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -265,6 +265,47 @@ fn empty_doc_has_one_page() { assert!(c.top >= 72.0 && c.height > 8.0); } +#[test] +fn hidden_marks_take_no_space() { + let mut d = Document::new(); + d.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("Before", Default::default()))]; + for _ in 0..20 { + let mut p = wordcraft_doc::Paragraph::new(); + p.mark.hidden = Some(true); + d.body.push(wordcraft_doc::para_block(p)); + } + d.body.push(wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("After", Default::default()))); + let l = lay(&d); + let y = |block: usize| { + l.pages + .iter() + .flat_map(|p| &p.items) + .find_map(|item| match item { + Placed::Lines { path, y, .. } if *path == Path::top(block) => Some(*y), + _ => None, + }) + .unwrap() + }; + let mut plain = Document::new(); + plain.body = vec![ + wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("Before", Default::default())), + wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("After", Default::default())), + ]; + let plain_layout = lay(&plain); + let plain_after = plain_layout.pages[0] + .items + .iter() + .find_map(|item| match item { + Placed::Lines { path, y, .. } if *path == Path::top(1) => Some(*y), + _ => None, + }) + .unwrap(); + assert!((y(21) - plain_after).abs() < 0.1, "hidden paragraphs moved the following text to {} vs {plain_after}", y(21)); + let shown = layout(&d, &mut LayoutCache::new(), &LayoutOptions { show_hidden: true, ..Default::default() }); + let after = shown.caret(&Pos::body(21, 0)).unwrap(); + assert!(after.top > plain_after + 100.0, "showing hidden marks must restore their lines"); +} + #[test] fn long_paragraph_wraps_and_paginates() { let text = "The quick brown fox jumps over the lazy dog. ".repeat(400); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 293cbd2c9..b649943bd 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-14 · **Change:** trivial (`flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously 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-11 · **Last updated:** 2026-10-14 · **Change:** trivial (multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously 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; @@ -72,8 +72,8 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Footnotes, endnotes, comments | ✅ | ✅ | The document's own note separators and continuation notice (`w:type` `separator`, `continuationSeparator`, `continuationNotice`) round-trip as ids -1, 0 and 1 (#352); note options (`w:pos`, `w:numFmt`, `w:numStart`, `w:numRestart`) round-trip in the settings and in each `w:sectPr` (#385); `commentsExtended` written; `commentsIds` (durable ids) and `commentsExtensible` (UTC dates) read and regenerated consistently with the written paragraph ids; `people.xml` carried verbatim | | 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 | +| Fields (`w:fldChar`, `w:fldSimple`), TOC, cross-references, `ADDIN` citations | ✅ | ✅ | Field codes Word supports but we don't evaluate keep their cached result. Instruction-only paragraphs carry hidden marks instead of blank lines; an empty result paragraph keeps its line. 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) | | 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. ODT/PDF export the result (moved-away text hidden like deletions); RTF writes a move as a plain tracked deletion and insertion | -| 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. Replace All respects content-edit and control-deletion locks and refuses a batch before making any edit | | 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; forms mode (`w:edit="forms"`) lets edits inside fillable content controls (`w:lock` content-editable, innermost wins) run, everything else rolls back; row-wrapped controls and control deletion remain refused; `review.restrict` reports `formFields`; Find Next Region covers form fields (the pane button shows for forms documents); under active protection, `developer.properties` refuses `lockEdit`/`lockDelete` changes — as Word blocks property edits until Stop Protection — while other properties (title, tag, placeholder…) stay editable | | Legacy form fields (`w:ffData`), check boxes, drop-downs | ❌ | ❌ | | @@ -210,6 +210,7 @@ attributes inside stories and style or list definitions; a regenerated theme. Te | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | DOCX paragraphs inside complex-field instructions carry a hidden paragraph mark; cached result paragraphs remain visible. | | 2026-10-14 | trivial | DrawingML `flowChartOr` preserves its circle and crossing spokes, including when grouped or rotated | | 2026-10-14 | trivial | `minorBidi`/`majorBidi` theme fonts name the theme's complex-script font (`a:cs`); an empty one names nothing, so the inherited font applies, as measured in Word | | 2026-10-14 | trivial | A missing font the font table classes as roman is drawn in Cambria, as measured in Word (Calibri for the other classes, as before) | diff --git a/docs/gaps.md b/docs/gaps.md index f2f15e0a7..2eb61c637 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-14 · **Change:** trivial (floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; 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-14 · **Change:** trivial (multi-paragraph field instructions no longer add blank lines; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; 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 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -23,7 +23,7 @@ Document renaming: desktop renaming requires a filesystem that supports hard lin |---|---|---|---|---|---|---| | 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. Field reports fixed one by one: a cover page's theme-coloured shapes (#611), freeforms inside groups and text-box margins and Resize to fit (#743), cover-page positions relative to the page and freeforms drawn back to their start (#743), Word's text watermark in headers, read and written (#1290), and `flowChartOr` drawn with its spokes (#1644) | Everyone: the files people receive are the files that break | 30–50 | [file-format-parity.md](file-format-parity.md) | | 3 | **Crashes and freezes in the field**: startup crash on Intel UHD Graphics (#170, open), a crash on macOS 12.7.6 Intel (#979, open) (lag on GPUs without a DirectX 12 driver fixed, #316; 32-bit Windows exited at launch because DirectX 12 graphics pipeline creation failed, now OpenGL), freezes (#59 Thai fonts and #31 font selector, both open; #77 closed), broken shape tool (#142, open); the Windows 11 no-write-without-admin (#218) and unmovable images (#82) reports are closed (pictures and shapes in Behind Text drag through the text over them, #1313); memory growth on every font change fixed (#848: the font list no longer loads each family it previews) | B | 197 open issues (2026-10-12), among them the crash and freeze reports #170, #979, #59 and #31 | 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 only partially covered; no page-by-page comparison harness | B | Legacy same-style cell spacing preserved for measured modes 11/12/14, with mode 15 and non-cell controls; Contextual spacing corrected in body/header probes and repeated table-cell runs; [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) | +| 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options only partially covered; no page-by-page comparison harness | B | Legacy same-style cell spacing preserved for measured modes 11/12/14, with mode 15 and non-cell controls; Contextual spacing corrected in body/header probes and repeated table-cell runs; [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps; multi-paragraph field-code blank lines fixed | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-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, its optional picture fallback, and direct run-level content parts are preserved; ink nested in groups/canvases remains 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) | | 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. Replace All now checks content-control locks before editing any match; a refused batch preserves the document and undo history. | B | List-label pitch corrected in synthetic metric controls; 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 | **Print dialog depth**: desktop printer choice, copies, collation, duplex and grayscale submit through craft-print; File › Print retains preview, page ranges and odd/even (#641); driver tray/options and pages per sheet remain; 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) | @@ -87,6 +87,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Pagination gap #4 narrowed: paragraph boundaries inside complex-field instructions add no flow height. Floating-table placement and other paragraph metrics remain open. | | 2026-10-14 | trivial | Field report: grouped `flowChartOr` omitted its crossing spokes; the preset now draws them; wider DOCX corpus work remains open | | 2026-10-14 | trivial | Floating-table overlap checks use visible line content; empty line-break lines no longer move a table | | 2026-10-13 | trivial | Gap #3: Behind Text pictures and shapes drag over text in the body and headers, selected or not; a click without movement still edits the text | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index cd357335a..0f3bb8cb9 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -1,6 +1,6 @@ # Layout and pagination parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (multi-paragraph field instructions no longer add blank lines; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 A document only "opens correctly" if its pages break where Word breaks them: the same lines, the same page count, footnotes and pictures on the same pages. This checklist covers line breaking, @@ -30,6 +30,7 @@ font can't match Word's metrics. | Line spacing (single, 1.5, double, at least, exactly, multiple), spacing before/after, contextual spacing | 🟡 | Contextual spacing measured in body paragraphs and headers (below), with a four-paragraph table-cell regression; list-label ascent overflow is unscaled by multiple spacing and uses descent for automatic and at-least spacing (#601); other line-spacing combinations remain (#108, #135 open) | 3–5 | | Kerning only where Kerning for fonts asks for it (`w:kern`, at or above its size) | ✅ | Measured in Word for Windows: no `w:kern` or `w:kern="0"` is unkerned; WordCraft kerned every run, so its lines held more text than Word's. `layout::tests::kerning_follows_kerning_for_fonts_like_word` | — | | Automatic (HTML-style) paragraph spacing (`w:beforeAutospacing`, `w:afterAutospacing`) | ✅ | Measured in Word for Windows: 14 pt at any size, none at the top of the document or a cell or after a cell's last paragraph, the larger of two neighbouring spaces. `layout::tests::automatic_paragraph_spacing_like_word`, `…_drops_at_cell_edges` | — | +| Paragraph boundaries inside field instructions | ✅ | Synthetic DOCX observed in Word: instruction-only paragraphs add no height; the empty result keeps a line. `docx::fixtures::field_code_spans_paragraphs`, `layout::tests::hidden_marks_take_no_space`. Other field-code display modes remain unverified | — | | Widow/orphan control, keep with next, keep lines together, page break before | ✅ | `lib.rs` keep_next/keep_lines; a line holding only a page break stays at the bottom of a full page instead of leaving a blank page (#534); a paragraph ending with a page break keeps its space after on the old page, so the next page starts at the top (compatibility modes 12 to 15, `layout::tests::space_before_at_the_top_of_a_page_like_word`); a three-line paragraph with room for two moves whole (neither a widow nor an orphan is allowed), as in Word | — | | Tabs (left, center, right, decimal, bar), leaders | 🟡 | Bar tabs untested | 1–2 | | Hyphenation (automatic, zone from the document, words in capitals, soft hyphens, consecutive limit, Manual Hyphenation, #407) | 🟡 | English patterns only; other languages hyphenate wrongly or not at all | in localization | @@ -159,6 +160,7 @@ No overall parity estimate changes from these probes. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Paragraphs inside complex-field instructions take no flow height; empty result paragraphs keep their line. | | 2026-10-14 | trivial | With `w:doNotUseHTMLParagraphAutoSpacing` the space between two paragraphs is the first's space after plus the second's space before, as measured in Word; without it, still the larger (#733) | | 2026-10-14 | trivial | An inherited header or footer sizes its percentage-width tables by the section that defines it, as measured in Word (a portrait footer table keeps its width on inheriting landscape pages) | | 2026-10-14 | trivial | An empty paragraph closing a section before a new page stays on a full page instead of starting a page of its own, as measured in Word | From 35e24aa43684cac65720546ac19d087352f28ce1 Mon Sep 17 00:00:00 2001 From: Fin Warman Date: Thu, 15 Oct 2026 02:59:59 +0100 Subject: [PATCH 060/101] Layout: hide labels on page-break-only list paragraphs (#1673) Co-authored-by: Codex Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +++- crates/layout/src/para.rs | 7 +++++-- crates/layout/src/tests.rs | 30 ++++++++++++++++++++++++++++++ docs/gaps.md | 5 +++-- docs/layout-parity.md | 4 +++- 5 files changed, 44 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 2fdf36b4c..672527e8b 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-14 · **Change:** trivial (multi-paragraph field instructions no longer add blank lines; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; 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-14 · **Change:** trivial (multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; 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 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -137,6 +137,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| | 2026-10-14 | Field instructions spanning paragraphs no longer add blank lines during pagination; empty result paragraphs still take a line. | +| 2026-10-14 | Page-break-only list paragraphs no longer print stray bullets or numbers. The next item keeps its number, matching the reference export. | | 2026-10-14 | Grouped `flowChartOr` shapes draw their circle and crossing spokes, as in Word | | 2026-10-14 | Deleting a comment removes its anchors from headers, footers, notes and text boxes too, not just the body, and deletes the whole thread below it, replies to replies included; no anchor or reply is left pointing at a deleted comment (#1345) | | 2026-10-14 | Legacy DOC DIB conversion checks the BMP header and copy before allocation, uses the caller's decode allowance, and caps PNG output while encoding; a BMP fallback is kept when it fits. A generated 1-pixel regression covers 57/58/512-byte limits. Resource-exhaustion consequences remain unmeasured. Related: #1334 and #1554 | @@ -385,6 +386,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| | 2026-10-14 | trivial | Field instructions spanning paragraphs no longer add blank lines during pagination; empty result paragraphs still take a line. | +| 2026-10-14 | trivial | A list paragraph containing only a page break has no visible label; it still advances the list counter. Ordinary empty list items keep their label. | | 2026-10-14 | trivial | Floating-table overlap checks use visible line content; empty line-break lines no longer move a table | | 2026-10-14 | trivial | Progress log: deleting a comment leaves no dangling anchors or replies (#1345) | | 2026-10-14 | minor | Legacy DOC DIB conversion byte limits only; related #1334/#1554, stage and readiness estimates unchanged | diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index f9efa11b9..e23b61749 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -1504,8 +1504,11 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { c.break_after = opps.contains(&c.end) || matches!(c.kind, ClKind::Object(_) | ClKind::Tab); } - // List label. - let label = env.label.as_ref().map(|(text, lvl)| { + // Word hides a list label on a paragraph containing only a page break. The list counter + // has already advanced, so the following item keeps its number. + let break_only = + b.clusters.iter().any(|c| c.kind == ClKind::PageBreak) && b.clusters.iter().all(|c| matches!(c.kind, ClKind::PageBreak | ClKind::Marker)); + let label = env.label.as_ref().filter(|_| !break_only).map(|(text, lvl)| { let lc = p.mark.clone().overlaid(&lvl.chr); let rc = resolve(&lc); // A Symbol/Wingdings bullet (U+F0B7…) is drawn as its Unicode equivalent when the font diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 6f00716dc..c0c1175d5 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -895,6 +895,36 @@ fn lists_get_labels_and_indent() { assert!((x - 108.0).abs() < 0.5, "{x}"); } +#[test] +fn break_only_list_has_no_label() { + use wordcraft_doc::ListKind; + for kind in [ListKind::Numbered, ListKind::Bullet] { + let mut d = Document::from_text("First\n\u{000C}\nLast\n\nAfter"); + let id = d.numbering.add_list(kind); + d.format_paragraphs(&Pos::body(0, 0), &Pos::body(4, 5), &|p| { + p.numbering = Some(wordcraft_doc::props::NumRef { num: id, level: 0 }); + }) + .unwrap(); + let l = lay(&d); + let labels: Vec<_> = l + .pages + .iter() + .flat_map(|p| &p.items) + .filter_map(|it| { + let Placed::Lines { path, para, .. } = it else { return None }; + Some((path.clone(), para.label.as_ref().map(|label| label.text.clone()))) + }) + .collect(); + let label = |n| Some(if kind == ListKind::Numbered { format!("{n}.") } else { "•".to_string() }); + // Word hides the break's label but counts it; a plain empty item still has a label. + assert_eq!( + labels, + vec![(Path::top(0), label(1)), (Path::top(1), None), (Path::top(2), label(3)), (Path::top(3), label(4)), (Path::top(4), label(5)),], + "{kind:?}" + ); + } +} + #[test] fn a_numbered_heading_opening_with_a_page_break_takes_its_number_along() { // "Before", then a numbered paragraph whose text opens with a page break: Word draws its diff --git a/docs/gaps.md b/docs/gaps.md index 2eb61c637..e7fb3d829 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-14 · **Change:** trivial (multi-paragraph field instructions no longer add blank lines; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; 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-14 · **Change:** trivial (multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; 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 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -23,7 +23,7 @@ Document renaming: desktop renaming requires a filesystem that supports hard lin |---|---|---|---|---|---|---| | 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. Field reports fixed one by one: a cover page's theme-coloured shapes (#611), freeforms inside groups and text-box margins and Resize to fit (#743), cover-page positions relative to the page and freeforms drawn back to their start (#743), Word's text watermark in headers, read and written (#1290), and `flowChartOr` drawn with its spokes (#1644) | Everyone: the files people receive are the files that break | 30–50 | [file-format-parity.md](file-format-parity.md) | | 3 | **Crashes and freezes in the field**: startup crash on Intel UHD Graphics (#170, open), a crash on macOS 12.7.6 Intel (#979, open) (lag on GPUs without a DirectX 12 driver fixed, #316; 32-bit Windows exited at launch because DirectX 12 graphics pipeline creation failed, now OpenGL), freezes (#59 Thai fonts and #31 font selector, both open; #77 closed), broken shape tool (#142, open); the Windows 11 no-write-without-admin (#218) and unmovable images (#82) reports are closed (pictures and shapes in Behind Text drag through the text over them, #1313); memory growth on every font change fixed (#848: the font list no longer loads each family it previews) | B | 197 open issues (2026-10-12), among them the crash and freeze reports #170, #979, #59 and #31 | 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 only partially covered; no page-by-page comparison harness | B | Legacy same-style cell spacing preserved for measured modes 11/12/14, with mode 15 and non-cell controls; Contextual spacing corrected in body/header probes and repeated table-cell runs; [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps; multi-paragraph field-code blank lines fixed | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) | +| 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options only partially covered; no page-by-page comparison harness | B | Legacy same-style cell spacing preserved for measured modes 11/12/14, with mode 15 and non-cell controls; Contextual spacing corrected in body/header probes and repeated table-cell runs; [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps; stray labels on page-break-only list paragraphs fixed multi-paragraph field-code blank lines fixed | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-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, its optional picture fallback, and direct run-level content parts are preserved; ink nested in groups/canvases remains 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) | | 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. Replace All now checks content-control locks before editing any match; a refused batch preserves the document and undo history. | B | List-label pitch corrected in synthetic metric controls; 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 | **Print dialog depth**: desktop printer choice, copies, collation, duplex and grayscale submit through craft-print; File › Print retains preview, page ranges and odd/even (#641); driver tray/options and pages per sheet remain; 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) | @@ -88,6 +88,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | Date | Change | Summary | |---|---|---| | 2026-10-14 | trivial | Pagination gap #4 narrowed: paragraph boundaries inside complex-field instructions add no flow height. Floating-table placement and other paragraph metrics remain open. | +| 2026-10-14 | trivial | A list paragraph containing only a page break has no visible label; it still advances the list counter. Ordinary empty list items keep their label. | | 2026-10-14 | trivial | Field report: grouped `flowChartOr` omitted its crossing spokes; the preset now draws them; wider DOCX corpus work remains open | | 2026-10-14 | trivial | Floating-table overlap checks use visible line content; empty line-break lines no longer move a table | | 2026-10-13 | trivial | Gap #3: Behind Text pictures and shapes drag over text in the body and headers, selected or not; a click without movement still edits the text | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 0f3bb8cb9..4b2298090 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -1,6 +1,6 @@ # Layout and pagination parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (multi-paragraph field instructions no longer add blank lines; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 A document only "opens correctly" if its pages break where Word breaks them: the same lines, the same page count, footnotes and pictures on the same pages. This checklist covers line breaking, @@ -38,6 +38,7 @@ font can't match Word's metrics. | All Caps and Small Caps: the full capital of each letter, so ß draws as SS (ff as FF); the capitals drawn for one character are one cluster, so the caret, selection, hit testing and line breaking treat ß as one character | ✅ | `layout::tests::caps_draw_sharp_s_as_two_capitals_in_one_cluster` (#1581). The PDF text layer still reads the document's lowercase letters until #1473 | — | | Columns, column breaks, separators, balancing | ✅ | Columns balance on a section's last page when the next section starts continuous (also at the document end, which Word only balances when the document ends with a continuous break); not after next-page/even/odd breaks or on a page with a column break; keep-together, keep-with-next, widow/orphan control and unsplittable rows can leave them slightly uneven (#350) | — | | Section breaks (next page, continuous, even, odd), different first page, odd/even headers | ✅ | Section breaks don't show in Draft view (#42). A break inserted with text selected goes before the selection and keeps it, as in Word (#852) | 1–2 | +| Labels on page-break-only list paragraphs | ✅ | `layout::tests::break_only_list_has_no_label`: no bullet or number on the break, but the next number is still 3 after item 1 and a break. Plain empty items keep their labels. Synthetic DOCX observed through Adobe PDF conversion | — | | List labels: number formats (decimal, letters, Russian letters, roman, ordinal text, Chinese/Japanese/Korean counting and legal, 一〇一 digits, 甲乙丙, ①…⑳, full-width), `%n` text such as `第%1章` / `(%1)`, legal numbering | ✅ | `doc::cjk_numbers` tests, `doc::section::tests::russian_letters_follow_word`, `layout::tests::chinese_counting_list_labels_and_their_font_runs`, `layout::tests::russian_letter_list_labels_draw_cyrillic`; a label mixing faces (digit + CJK comma) is drawn run by run (#534); Russian letters skip ё, й, ъ and ь and then double (аа, бб), as observed in Word. Hebrew, Arabic, Hindi, Thai and Vietnamese formats show decimal The label stays attached to the start of the text when the paragraph is centred or aligned to the end, as in Word (#1159). | — | | Body top below a tall header, footer pushes body up | ✅ | #138; a footer taller than the bottom margin moves the body bottom up (#453), and a header or footer linked to the previous section takes its room too (#454) | — | | Footnotes at the page bottom, endnotes at the end, note numbering | 🟡 | A long note continues onto the next page at a line boundary (its first line stays with the reference), first in that page's note area under a full-width continuation separator (#352); the document's own separator, continuation separator and continuation notice are drawn (the notice under a note that continues); on a page with footnotes a paragraph ending there keeps its space after above the separator, or its last lines go to the next page with the note (#1116); a long endnote continues on the next column or page at a line boundary (#456), and the caret finds note text on its continuation page (#457); per-section restart partial | 1–3 | @@ -161,6 +162,7 @@ No overall parity estimate changes from these probes. | Date | Change | Summary | |---|---|---| | 2026-10-14 | trivial | Paragraphs inside complex-field instructions take no flow height; empty result paragraphs keep their line. | +| 2026-10-14 | trivial | A list paragraph containing only a page break has no visible label; it still advances the list counter. Ordinary empty list items keep their label. | | 2026-10-14 | trivial | With `w:doNotUseHTMLParagraphAutoSpacing` the space between two paragraphs is the first's space after plus the second's space before, as measured in Word; without it, still the larger (#733) | | 2026-10-14 | trivial | An inherited header or footer sizes its percentage-width tables by the section that defines it, as measured in Word (a portrait footer table keeps its width on inheriting landscape pages) | | 2026-10-14 | trivial | An empty paragraph closing a section before a new page stays on a full page instead of starting a page of its own, as measured in Word | From 2c7629fa7a19fc2cc55c7dc9605164c2cc405b22 Mon Sep 17 00:00:00 2001 From: Diego Guerini <106814760+D13GOOOO@users.noreply.github.com> Date: Thu, 15 Oct 2026 04:00:11 +0200 Subject: [PATCH 061/101] Shift+Delete cuts the selection on macOS and Linux too (#1669) (#1676) Word for Windows (checked by hand): Shift+Backspace erases like Backspace, Shift+Delete cuts the selection, and Shift+Delete with nothing selected does nothing. On Windows egui-winit already turns Shift+Delete into a Cut event, so WordCraft matched. On macOS and Linux it arrives as the Delete key with Shift, no shortcut was bound to it, and it did nothing, even with text selected. The canvas now runs Cut for Shift+Delete when there is a selection, through the same clipboard path as Mod+X, and does nothing otherwise. Shift+Backspace already erased (#707). Tests: Shift+Backspace erases, Shift+Delete without a selection changes nothing, Shift+Delete on a selection cuts it to the clipboard (fails without the change); the key check accepts Shift alone. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/ui-egui/src/keys.rs | 25 +++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 27 +++++++++++++++++++++++++++ docs/typing-parity.md | 4 +++- 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/keys.rs b/crates/ui-egui/src/keys.rs index 49b90eb5a..8723b5a9e 100644 --- a/crates/ui-egui/src/keys.rs +++ b/crates/ui-egui/src/keys.rs @@ -152,6 +152,13 @@ fn shift_variant_params(id: &str) -> Option { } } +/// Shift+Delete, which cuts the selection and does nothing without one, as in Word for Windows +/// (#1669). On Windows the windowing layer already sends it as a Cut; elsewhere it arrives as the +/// key, and no shortcut was bound to it. +fn is_shift_delete(key: Key, m: Modifiers) -> bool { + key == Key::Delete && m.shift && !m.command && !m.ctrl && !m.alt && !m.mac_cmd +} + /// Shift+F10 opens the context menu, as the Menu key does (#821). (The Menu key itself doesn't /// reach the app: the windowing layer drops it.) fn is_context_menu_key(key: Key, m: Modifiers) -> bool { @@ -385,6 +392,14 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) { let _ = app.run("insert.closeHeader", json!({})); continue; } + if is_shift_delete(key, modifiers) { + if !app.session.sel.is_collapsed() + && let Ok(r) = app.run("edit.cut", json!({})) + { + crate::system_clipboard::put_result(app, ctx, &r); + } + continue; + } if leave_full_screen(app, key, modifiers, true) { continue; } @@ -618,6 +633,16 @@ mod tests { assert!(!is_paste_special_release(Key::V, m(true, true, true)) && !is_paste_special_release(Key::V, m(true, false, false))); } + #[test] + fn shift_delete_is_only_shift_and_delete() { + let m = |shift, ctrl, alt| egui::Modifiers { shift, ctrl, command: ctrl, alt, ..Default::default() }; + assert!(is_shift_delete(Key::Delete, m(true, false, false))); + assert!(!is_shift_delete(Key::Delete, m(false, false, false)), "Delete alone erases"); + assert!(!is_shift_delete(Key::Delete, m(true, true, false)), "Ctrl+Shift+Delete"); + assert!(!is_shift_delete(Key::Delete, m(true, false, true)), "Alt+Shift+Delete"); + assert!(!is_shift_delete(Key::Backspace, m(true, false, false)), "Shift+Backspace erases (#707)"); + } + #[test] fn shift_f10_is_the_context_menu_key() { let m = |shift, ctrl, alt| egui::Modifiers { shift, ctrl, command: ctrl, alt, ..Default::default() }; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0c8f3a4fe..f6a76f40c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -4690,6 +4690,33 @@ mod tests { assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body), "Kilns"); } + /// #1669: with Shift held, Backspace still erases (#707) and Delete cuts the selection, doing + /// nothing without one, as in Word for Windows. (Windows sends Shift+Delete as a Cut; macOS and + /// Linux send the key, which reached no command.) + #[test] + fn shift_delete_cuts_the_selection_and_shift_backspace_erases() { + let mut h = harness(app()); + for _ in 0..6 { + h.step(); + } + let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + let body = |h: &egui_kittest::Harness| h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body); + h.event(egui::Event::Text("Kilns fire clay".into())); + h.step(); + h.event(key(egui::Key::Backspace, egui::Modifiers::SHIFT)); + h.step(); + assert_eq!(body(&h), "Kilns fire cla"); + h.event(key(egui::Key::Home, egui::Modifiers::NONE)); + h.event(key(egui::Key::Delete, egui::Modifiers::SHIFT)); + h.step(); + assert_eq!(body(&h), "Kilns fire cla", "no selection: nothing to cut"); + h.state_mut().run("select.text", json!({"text": "fire "})).unwrap(); + h.event(key(egui::Key::Delete, egui::Modifiers::SHIFT)); + h.step(); + assert_eq!(body(&h), "Kilns cla"); + assert_eq!(h.state().session.clipboard_text, "fire ", "cut, not just deleted"); + } + /// #1538: inserting WordArt leaves its text selected, so Delete erased only the text and the /// empty box stayed. Escape now selects the box itself, Delete removes it, and a second Escape /// is back to a caret in the body. diff --git a/docs/typing-parity.md b/docs/typing-parity.md index f3a5e4ad1..7abd05d4d 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-14 · **Change:** trivial (Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously 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 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** trivial (Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously 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 @@ -76,6 +76,7 @@ Windows/Linux keys are given first, macOS second. | Outline view: Alt+Shift+Plus / Minus, Alt+Shift+1–9, Alt+Shift+A | Expand / Collapse the heading (one level at a time); Show Level 1–9; show all levels. The caret never stays in hidden text: it moves on to the next shown paragraph | `outline_show_level_filters_paragraphs` | | Shift+F5 | Go Back: the caret cycles through the last four places edited, most recent first | `shift_f5_goes_back_through_the_last_edits` | | Alt+X (no macOS default) | the hex code before the caret (up to six digits, `U+` optional) or the selected code becomes its character; otherwise the character before the caret becomes its code (`é` ↔ `00E9`); one undo step | `alt_x_toggles_between_a_character_and_its_code` | +| Shift+Backspace / Shift+Delete | Shift+Backspace erases like Backspace (#707). Shift+Delete cuts the selection to the clipboard and does nothing without one (Word for Windows, observed 2026-10-14). Windows delivers Shift+Delete as a Cut; macOS and Linux deliver the key, which did nothing (#1669) | `shift_delete_cuts_the_selection_and_shift_backspace_erases` | | Alt+numpad code (Windows and Linux; not macOS) | hold Alt, type a decimal code, release Alt: the character appears at the caret as typed text (one undo step). With a leading 0 the code is the ANSI code page — Windows-1252 by default (`0233` → é, `0128` → €); without one, 1–255 is the OEM code page — 437 by default and in US English (`233` → Θ, `1` → ☺, `7` → •), 850 for Western European locales and English outside the US (`155` → ø; `233` is Ú there, per the CP850 mapping file — é is `130` in both). On Windows the system locale picks both pages (`text.typeCode` `ansi`/`oem`): Central European 1250/852 (Polish `0185` and `165` → ą), Cyrillic 1251/866, Greek 1253/737, Turkish 1254/857, and for codes up to 255 the character Windows composes itself replaces ours (it knows every code page, double-byte ones too); Linux keeps 1252 and 437/850. Above 255, with or without the 0, the Unicode character with that decimal value (`8364` → €). Control characters, 0, unassigned bytes and values past Unicode type nothing; auto-repeat of a held digit adds nothing, and switching windows drops a half-typed code. Word counts only keypad digits; egui reports keypad and top-row digits alike, so top-row digits with Alt alone compose codes too. An Alt code never opens KeyTips | `alt_numpad_codes_map_like_word_on_windows`, `alt_numpad_code_types_one_character`, ui-egui `alt_numpad_code_types_its_character_without_keytips` | | Shift+Right (⇧→) at the end of a cell's text | the whole cell is selected first (its end-of-cell mark); the next Shift+Right selects the next cell with it. In an empty cell the first press selects the cell | `shift_right_at_the_end_of_a_cell_selects_the_cell_first` | @@ -194,6 +195,7 @@ Word. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Shift+Delete cuts the selection, and does nothing without one, on macOS and Linux too, as in Word for Windows (#1669); Shift+Backspace already erased (#707) | | 2026-10-13 | trivial | Select All then Delete or Backspace removes a table the document starts with (#1198) | | 2026-10-12 | trivial | Editing keys: Down and Up from the start of a line beside a table go into it instead of skipping it (#1007) | | 2026-10-12 | trivial | Sample-text formulas on Enter (#1067), exact matching, bounded parameters, original English and one-step undo | From 097d81a41b40b2b1ceeec48c31695c13b728a45f Mon Sep 17 00:00:00 2001 From: Wesley Luiz <34388434+leoncral@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:00:30 -0500 Subject: [PATCH 062/101] M15.1: print-area strings translated in every interface catalog (#1702) * M15.1: print-area strings translated in every interface catalog * Add leoncral to contributors (sync GitHub profile name) * Docs: progress log for the print-area translations --------- Co-authored-by: Brandon Thomas --- ROADMAP.md | 6 ++++-- contributors/people.toml | 9 +++------ crates/ui-egui/src/i18n/bg.tsv | 18 ++++++++++++++++++ crates/ui-egui/src/i18n/cs.tsv | 18 ++++++++++++++++++ crates/ui-egui/src/i18n/de.tsv | 18 ++++++++++++++++++ crates/ui-egui/src/i18n/es.tsv | 18 ++++++++++++++++++ crates/ui-egui/src/i18n/et.tsv | 18 ++++++++++++++++++ crates/ui-egui/src/i18n/fr.tsv | 18 ++++++++++++++++++ crates/ui-egui/src/i18n/hu.tsv | 18 ++++++++++++++++++ crates/ui-egui/src/i18n/it.tsv | 18 ++++++++++++++++++ crates/ui-egui/src/i18n/ja.tsv | 18 ++++++++++++++++++ crates/ui-egui/src/i18n/pl.tsv | 18 ++++++++++++++++++ crates/ui-egui/src/i18n/pt-br.tsv | 18 ++++++++++++++++++ crates/ui-egui/src/i18n/ru.tsv | 18 ++++++++++++++++++ crates/ui-egui/src/i18n/sr-latn.tsv | 18 ++++++++++++++++++ crates/ui-egui/src/i18n/sr.tsv | 18 ++++++++++++++++++ crates/ui-egui/src/i18n/tr.tsv | 18 ++++++++++++++++++ crates/ui-egui/src/i18n/uk.tsv | 18 ++++++++++++++++++ crates/ui-egui/src/i18n/vi.tsv | 18 ++++++++++++++++++ crates/ui-egui/src/i18n/zh-hans.tsv | 18 ++++++++++++++++++ crates/ui-egui/src/i18n/zh-hant.tsv | 18 ++++++++++++++++++ docs/localization-parity.md | 3 ++- 22 files changed, 351 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 672527e8b..4d2e6489d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -3,7 +3,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-14 · **Change:** trivial (multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; 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-14 · **Change:** trivial (progress log: print-area strings translated in every catalog; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; 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 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. WordCraft aims for complete parity with Microsoft Word, then goes further on speed, openness and @@ -135,9 +135,9 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log | Date | What landed | -|---|---| | 2026-10-14 | Field instructions spanning paragraphs no longer add blank lines during pagination; empty result paragraphs still take a line. | | 2026-10-14 | Page-break-only list paragraphs no longer print stray bullets or numbers. The next item keeps its number, matching the reference export. | +|---|---|---| | 2026-10-14 | Grouped `flowChartOr` shapes draw their circle and crossing spokes, as in Word | | 2026-10-14 | Deleting a comment removes its anchors from headers, footers, notes and text boxes too, not just the body, and deletes the whole thread below it, replies to replies included; no anchor or reply is left pointing at a deleted comment (#1345) | | 2026-10-14 | Legacy DOC DIB conversion checks the BMP header and copy before allocation, uses the caller's decode allowance, and caps PNG output while encoding; a BMP fallback is kept when it fits. A generated 1-pixel regression covers 57/58/512-byte limits. Resource-exhaustion consequences remain unmeasured. Related: #1334 and #1554 | @@ -161,6 +161,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | Rounded rectangles (#1476): a `roundRect` keeps the corner radius its file gives it (`adj`), on screen, in PDF and when saved; all were drawn with a 16% corner | | 2026-10-13 | PAGEREF in headers and footers (#1477): "Page X of Y" with Y a page reference to a bookmark at each letter's end shows each letter's own total instead of the result stored in the file | | 2026-10-13 | Replace All respects locked content controls with Track Changes on or off; it refuses the entire batch before editing when any match would change locked content or remove a control locked against deletion | +| 2026-10-14 | File › Print translated in all 18 interface languages — printer list, duplex, grayscale, discovery/submission feedback and print commands (18 strings that shipped English-only with the craft-print integration; written from the English meaning, pending native-speaker review like the rest of the catalogs) | | 2026-10-14 | All Caps and Small Caps draw ß as SS, as Word does (#1581): "Straße" in All Caps reads STRASSE, not STRASE, and other letters whose capital is two characters (ff, ʼn) draw in full. The ß is still one character: the caret never stops between the two S, and a click on them lands before or after it | | 2026-10-13 | Windows Magnifier follows the caret onto a new line: after Enter or Shift+Enter the view moves to the empty line at once, instead of staying on the line above until two more letters are typed (#1119) | | 2026-10-13 | A window too big for its screen shrinks to fit when it opens (#1215): on a laptop screen the first window reached under the taskbar or past the screen edge, with the end of the scroll bar, so dragging the scroll bar stopped where the pointer met the edge. A window saved that size is fitted on its next launch too | @@ -405,6 +406,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-13 | trivial | Progress log: rounded rectangle corners (#1476) | | 2026-10-13 | trivial | Progress log: PAGEREF in headers and footers (#1477) | | 2026-10-13 | trivial | Replace All checks content-control locks before editing; a refused batch leaves the document and undo history unchanged; existing deletion-lock rules reused, with tracked/untracked regression coverage | +| 2026-10-14 | trivial | Progress log: print-area strings translated in every catalog | | 2026-10-14 | trivial | Progress log: All Caps and Small Caps draw ß as SS (#1581) | | 2026-10-13 | trivial | Progress log: Windows Magnifier follows the caret onto a new line (#1119) | | 2026-10-13 | trivial | Progress log: a window too big for its screen shrinks to fit (#1215) | diff --git a/contributors/people.toml b/contributors/people.toml index 2c96a260f..be7e7a798 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -88,15 +88,12 @@ sync_github_name = false [people.lupant] sync_github_name = true [people.Nael-Nathanael] - - [people.Losminek] -[people.benandez] -real_name = "Martin Ball" -display_name = "Martin Ball" - [people.Nghiathan13] +[people.leoncral] +sync_github_name = true + [people.finwarman] real_name = "Fin Warman" diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index d5716a4de..ff0521e4e 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2550,6 +2550,24 @@ Suggested Предложения Set Proofing Language… Задаване на езика за проверка… Language Preferences… Езикови предпочитания… + Printer Принтер + Select a printer Изберете принтер + Refresh printers Опресняване на принтерите + Two-sided printing Двустранен печат + Printer default По подразбиране на принтера + One-sided Едностранен + Two-sided: long edge Двустранен: дълъг ръб + Two-sided: short edge Двустранен: къс ръб + Grayscale Нюанси на сивото + Finding printers… Търсене на принтери… + No printers found. Configure a system printer or export a PDF. Не са намерени принтери. Настройте системен принтер или експортирайте PDF. + Printer discovery failed. Търсенето на принтери е неуспешно. + Submitting print job… Изпращане на задание за печат… + Print job submitted. Заданието за печат е изпратено. + Print job submitted: {receipt} Заданието за печат е изпратено: {receipt} + Print failed: the printing worker stopped. Печатът е неуспешен: процесът на печат спря. + Print settings Настройки за печат + Submit print job Изпращане на задание за печат # Cross-reference dialog (#1003) For which item: За кой елемент: diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 52544718f..e1b7405de 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2554,6 +2554,24 @@ preview Title Ukázka Suggested Navrhované Set Proofing Language… Nastavit jazyk kontroly pravopisu… Language Preferences… Předvolby jazyka… + Printer Tiskárna + Select a printer Vyberte tiskárnu + Refresh printers Obnovit tiskárny + Two-sided printing Oboustranný tisk + Printer default Výchozí tiskárny + One-sided Jednostranný + Two-sided: long edge Oboustranný: dlouhý okraj + Two-sided: short edge Oboustranný: krátký okraj + Grayscale Stupně šedi + Finding printers… Hledání tiskáren… + No printers found. Configure a system printer or export a PDF. Nebyly nalezeny žádné tiskárny. Nastavte systémovou tiskárnu nebo exportujte PDF. + Printer discovery failed. Hledání tiskáren se nezdařilo. + Submitting print job… Odesílání tiskové úlohy… + Print job submitted. Tisková úloha odeslána. + Print job submitted: {receipt} Tisková úloha odeslána: {receipt} + Print failed: the printing worker stopped. Tisk se nezdařil: proces tisku se zastavil. + Print settings Nastavení tisku + Submit print job Odeslat tiskovou úlohu # Cross-reference dialog (#1003) For which item: Pro kterou položku: diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 1d68a971e..20bdbdfd0 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2169,6 +2169,24 @@ Suggested Vorschläge Set Proofing Language… Sprache für die Korrekturhilfen festlegen… Language Preferences… Spracheinstellungen… + Printer Drucker + Select a printer Drucker auswählen + Refresh printers Drucker aktualisieren + Two-sided printing Beidseitiges Drucken + Printer default Druckerstandard + One-sided Einseitig + Two-sided: long edge Beidseitig: lange Kante + Two-sided: short edge Beidseitig: kurze Kante + Grayscale Graustufen + Finding printers… Drucker werden gesucht… + No printers found. Configure a system printer or export a PDF. Keine Drucker gefunden. Richten Sie einen Systemdrucker ein oder exportieren Sie ein PDF. + Printer discovery failed. Druckersuche fehlgeschlagen. + Submitting print job… Druckauftrag wird gesendet… + Print job submitted. Druckauftrag gesendet. + Print job submitted: {receipt} Druckauftrag gesendet: {receipt} + Print failed: the printing worker stopped. Drucken fehlgeschlagen: Der Druckvorgang wurde angehalten. + Print settings Druckeinstellungen + Submit print job Druckauftrag senden # Dictionaries folder (#1063) Dictionaries Wörterbücher diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 7949ddca9..bd19a2484 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2536,6 +2536,24 @@ preview Title Título Suggested Sugerencias Set Proofing Language… Establecer idioma de revisión… Language Preferences… Preferencias de idioma… + Printer Impresora + Select a printer Selecciona una impresora + Refresh printers Actualizar impresoras + Two-sided printing Impresión a doble cara + Printer default Valor predeterminado de la impresora + One-sided Una cara + Two-sided: long edge Doble cara: borde largo + Two-sided: short edge Doble cara: borde corto + Grayscale Escala de grises + Finding printers… Buscando impresoras… + No printers found. Configure a system printer or export a PDF. No se encontraron impresoras. Configura una impresora en el sistema o exporta un PDF. + Printer discovery failed. No se pudieron encontrar impresoras. + Submitting print job… Enviando trabajo de impresión… + Print job submitted. Trabajo de impresión enviado. + Print job submitted: {receipt} Trabajo de impresión enviado: {receipt} + Print failed: the printing worker stopped. La impresión falló: el proceso de impresión se detuvo. + Print settings Configuración de impresión + Submit print job Enviar trabajo de impresión # Dictionaries folder (#1063) Dictionaries Diccionarios diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 15398bd9f..7de039d67 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -2204,6 +2204,24 @@ Suggested Soovitused Set Proofing Language… Määra õigekeelsuskontrolli keel… Language Preferences… Keele-eelistused… + Printer Printer + Select a printer Vali printer + Refresh printers Värskenda printereid + Two-sided printing Kahepoolne printimine + Printer default Printeri vaikesäte + One-sided Ühepoolne + Two-sided: long edge Kahepoolne: pikk serv + Two-sided: short edge Kahepoolne: lühike serv + Grayscale Halltoonid + Finding printers… Printerite otsimine… + No printers found. Configure a system printer or export a PDF. Printereid ei leitud. Seadista süsteemis printer või ekspordi PDF. + Printer discovery failed. Printerite otsimine nurjus. + Submitting print job… Prinditöö saatmine… + Print job submitted. Prinditöö saadetud. + Print job submitted: {receipt} Prinditöö saadetud: {receipt} + Print failed: the printing worker stopped. Printimine nurjus: printimisprotsess peatus. + Print settings Printimisseaded + Submit print job Saada prinditöö # Dictionaries folder (#1063) Dictionaries Sõnastikud diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index f01867b9a..ce091d437 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2177,6 +2177,24 @@ Suggested Suggestions Set Proofing Language… Définir la langue de vérification… Language Preferences… Préférences linguistiques… + Printer Imprimante + Select a printer Sélectionnez une imprimante + Refresh printers Actualiser les imprimantes + Two-sided printing Impression recto verso + Printer default Valeur par défaut de l'imprimante + One-sided Recto seul + Two-sided: long edge Recto verso : bord long + Two-sided: short edge Recto verso : bord court + Grayscale Niveaux de gris + Finding printers… Recherche d'imprimantes… + No printers found. Configure a system printer or export a PDF. Aucune imprimante trouvée. Configurez une imprimante système ou exportez un PDF. + Printer discovery failed. La recherche d'imprimantes a échoué. + Submitting print job… Envoi du travail d'impression… + Print job submitted. Travail d'impression envoyé. + Print job submitted: {receipt} Travail d'impression envoyé : {receipt} + Print failed: the printing worker stopped. L'impression a échoué : le processus d'impression s'est arrêté. + Print settings Paramètres d'impression + Submit print job Envoyer le travail d'impression # Dictionaries folder (#1063) Dictionaries Dictionnaires diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 2b5553246..26b8ede95 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2558,6 +2558,24 @@ Set Proofing Language… Nyelvi ellenőrzés nyelvének beállítása… Language Preferences… Nyelvi beállítások… Title Bar Címsáv + Printer Nyomtató + Select a printer Válasszon nyomtatót + Refresh printers Nyomtatók frissítése + Two-sided printing Kétoldalas nyomtatás + Printer default Nyomtató alapértelmezése + One-sided Egyoldalas + Two-sided: long edge Kétoldalas: hosszú él + Two-sided: short edge Kétoldalas: rövid él + Grayscale Szürkeárnyalatos + Finding printers… Nyomtatók keresése… + No printers found. Configure a system printer or export a PDF. Nem található nyomtató. Állítson be egy rendszernyomtatót, vagy exportáljon PDF-et. + Printer discovery failed. A nyomtatók keresése sikertelen. + Submitting print job… Nyomtatási feladat küldése… + Print job submitted. Nyomtatási feladat elküldve. + Print job submitted: {receipt} Nyomtatási feladat elküldve: {receipt} + Print failed: the printing worker stopped. A nyomtatás sikertelen: a nyomtatási folyamat leállt. + Print settings Nyomtatási beállítások + Submit print job Nyomtatási feladat küldése # Cross-reference dialog (#1003) For which item: Melyik elemhez: diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index ac0b47157..6257c3a3d 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2545,6 +2545,24 @@ preview Title Esempio Suggested Suggerimenti Set Proofing Language… Imposta lingua di correzione… Language Preferences… Preferenze lingua… + Printer Stampante + Select a printer Seleziona una stampante + Refresh printers Aggiorna stampanti + Two-sided printing Stampa fronte-retro + Printer default Predefinito della stampante + One-sided Solo fronte + Two-sided: long edge Fronte-retro: lato lungo + Two-sided: short edge Fronte-retro: lato corto + Grayscale Scala di grigi + Finding printers… Ricerca stampanti in corso… + No printers found. Configure a system printer or export a PDF. Nessuna stampante trovata. Configura una stampante di sistema oppure esporta un PDF. + Printer discovery failed. Ricerca stampanti non riuscita. + Submitting print job… Invio del lavoro di stampa in corso… + Print job submitted. Lavoro di stampa inviato. + Print job submitted: {receipt} Lavoro di stampa inviato: {receipt} + Print failed: the printing worker stopped. Stampa non riuscita: il processo di stampa si è interrotto. + Print settings Impostazioni di stampa + Submit print job Invia lavoro di stampa # Cross-reference dialog (#1003) For which item: Per quale elemento: diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 57c7101e2..d0f9c9a9f 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2170,6 +2170,24 @@ Suggested おすすめ Set Proofing Language… 校正言語の設定… Language Preferences… 言語の設定… + Printer プリンター + Select a printer プリンターを選択 + Refresh printers プリンターを更新 + Two-sided printing 両面印刷 + Printer default プリンターの既定値 + One-sided 片面 + Two-sided: long edge 両面:長辺 + Two-sided: short edge 両面:短辺 + Grayscale グレースケール + Finding printers… プリンターを検索しています… + No printers found. Configure a system printer or export a PDF. プリンターが見つかりません。システムにプリンターを設定するか、PDF をエクスポートしてください。 + Printer discovery failed. プリンターの検索に失敗しました。 + Submitting print job… 印刷ジョブを送信しています… + Print job submitted. 印刷ジョブを送信しました。 + Print job submitted: {receipt} 印刷ジョブを送信しました:{receipt} + Print failed: the printing worker stopped. 印刷に失敗しました:印刷ワーカーが停止しました。 + Print settings 印刷設定 + Submit print job 印刷ジョブの送信 # Dictionaries folder (#1063) Dictionaries 辞書 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 9cc35de8d..3f90a93d4 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2554,6 +2554,24 @@ preview Title Tytuł Suggested Sugerowane Set Proofing Language… Ustaw język sprawdzania… Language Preferences… Preferencje językowe… + Printer Drukarka + Select a printer Wybierz drukarkę + Refresh printers Odśwież drukarki + Two-sided printing Drukowanie dwustronne + Printer default Domyślne drukarki + One-sided Jednostronne + Two-sided: long edge Dwustronne: długa krawędź + Two-sided: short edge Dwustronne: krótka krawędź + Grayscale Odcienie szarości + Finding printers… Wyszukiwanie drukarek… + No printers found. Configure a system printer or export a PDF. Nie znaleziono drukarek. Skonfiguruj drukarkę systemową lub wyeksportuj PDF. + Printer discovery failed. Nie udało się znaleźć drukarek. + Submitting print job… Wysyłanie zadania drukowania… + Print job submitted. Zadanie drukowania wysłane. + Print job submitted: {receipt} Zadanie drukowania wysłane: {receipt} + Print failed: the printing worker stopped. Drukowanie nie powiodło się: proces drukowania został zatrzymany. + Print settings Ustawienia drukowania + Submit print job Wyślij zadanie drukowania # Cross-reference dialog (#1003) For which item: Dla którego elementu: diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 45b6c75d3..bc46e2a51 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2182,6 +2182,24 @@ Suggested Sugestões Set Proofing Language… Definir idioma de revisão… Language Preferences… Preferências de idioma… + Printer Impressora + Select a printer Selecione uma impressora + Refresh printers Atualizar impressoras + Two-sided printing Impressão frente e verso + Printer default Padrão da impressora + One-sided Somente frente + Two-sided: long edge Frente e verso: borda longa + Two-sided: short edge Frente e verso: borda curta + Grayscale Tons de cinza + Finding printers… Localizando impressoras… + No printers found. Configure a system printer or export a PDF. Nenhuma impressora encontrada. Configure uma impressora no sistema ou exporte um PDF. + Printer discovery failed. Falha ao localizar impressoras. + Submitting print job… Enviando trabalho de impressão… + Print job submitted. Trabalho de impressão enviado. + Print job submitted: {receipt} Trabalho de impressão enviado: {receipt} + Print failed: the printing worker stopped. A impressão falhou: o processo de impressão parou. + Print settings Configurações de impressão + Submit print job Enviar trabalho de impressão # Dictionaries folder (#1063) Dictionaries Dicionários diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 2e736fcbb..a9651d259 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2560,6 +2560,24 @@ preview Title Пример Suggested Предлагаемые Set Proofing Language… Выбрать язык проверки правописания… Language Preferences… Языковые параметры… + Printer Принтер + Select a printer Выберите принтер + Refresh printers Обновить принтеры + Two-sided printing Двусторонняя печать + Printer default По умолчанию принтера + One-sided Односторонняя + Two-sided: long edge Двусторонняя: длинный край + Two-sided: short edge Двусторонняя: короткий край + Grayscale Оттенки серого + Finding printers… Поиск принтеров… + No printers found. Configure a system printer or export a PDF. Принтеры не найдены. Настройте системный принтер или экспортируйте PDF. + Printer discovery failed. Не удалось найти принтеры. + Submitting print job… Отправка задания на печать… + Print job submitted. Задание на печать отправлено. + Print job submitted: {receipt} Задание на печать отправлено: {receipt} + Print failed: the printing worker stopped. Печать не удалась: процесс печати остановлен. + Print settings Параметры печати + Submit print job Отправить задание на печать # Cross-reference dialog (#1003) For which item: Для какого элемента: diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 9bbef566a..9a0a4f3c7 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -2180,6 +2180,24 @@ Suggested Predlozi Set Proofing Language… Postavi jezik provere… Language Preferences… Jezičke postavke… + Printer Štampač + Select a printer Izaberite štampač + Refresh printers Osveži štampače + Two-sided printing Dvostrano štampanje + Printer default Podrazumevano štampača + One-sided Jednostrano + Two-sided: long edge Dvostrano: duža ivica + Two-sided: short edge Dvostrano: kraća ivica + Grayscale Sivi tonovi + Finding printers… Pronalaženje štampača… + No printers found. Configure a system printer or export a PDF. Nisu pronađeni štampači. Podesite sistemski štampač ili izvezite PDF. + Printer discovery failed. Pronalaženje štampača nije uspelo. + Submitting print job… Slanje zadatka za štampanje… + Print job submitted. Zadatak za štampanje je poslat. + Print job submitted: {receipt} Zadatak za štampanje je poslat: {receipt} + Print failed: the printing worker stopped. Štampanje nije uspelo: proces štampanja je zaustavljen. + Print settings Podešavanja štampanja + Submit print job Pošalji zadatak za štampanje # Dictionaries folder (#1063) Dictionaries Rečnici diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 1b247b5bc..e638e8da1 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2184,6 +2184,24 @@ Suggested Предлози Set Proofing Language… Постави језик провере… Language Preferences… Језичке поставке… + Printer Штампач + Select a printer Изаберите штампач + Refresh printers Освежи штампаче + Two-sided printing Двострано штампање + Printer default Подразумевано штампача + One-sided Једнострано + Two-sided: long edge Двострано: дужа ивица + Two-sided: short edge Двострано: краћа ивица + Grayscale Сиви тонови + Finding printers… Проналажење штампача… + No printers found. Configure a system printer or export a PDF. Нису пронађени штампачи. Подесите системски штампач или извезите PDF. + Printer discovery failed. Проналажење штампача није успело. + Submitting print job… Слање задатка за штампање… + Print job submitted. Задатак за штампање је послат. + Print job submitted: {receipt} Задатак за штампање је послат: {receipt} + Print failed: the printing worker stopped. Штампање није успело: процес штампања је заустављен. + Print settings Подешавања штампања + Submit print job Пошаљи задатак за штампање # Dictionaries folder (#1063) Dictionaries Речници diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 2c51a44e1..950065022 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2559,6 +2559,24 @@ preview Title Örnek Suggested Önerilenler Set Proofing Language… Yazım denetimi dilini ayarla… Language Preferences… Dil tercihleri… + Printer Yazıcı + Select a printer Bir yazıcı seçin + Refresh printers Yazıcıları yenile + Two-sided printing Çift taraflı yazdırma + Printer default Yazıcı varsayılanı + One-sided Tek taraflı + Two-sided: long edge Çift taraflı: uzun kenar + Two-sided: short edge Çift taraflı: kısa kenar + Grayscale Gri tonlama + Finding printers… Yazıcılar aranıyor… + No printers found. Configure a system printer or export a PDF. Yazıcı bulunamadı. Sistemde bir yazıcı yapılandırın veya PDF olarak dışa aktarın. + Printer discovery failed. Yazıcı arama başarısız oldu. + Submitting print job… Yazdırma işi gönderiliyor… + Print job submitted. Yazdırma işi gönderildi. + Print job submitted: {receipt} Yazdırma işi gönderildi: {receipt} + Print failed: the printing worker stopped. Yazdırma başarısız oldu: yazdırma işlemi durdu. + Print settings Yazdırma ayarları + Submit print job Yazdırma işini gönder # Cross-reference dialog (#1003) For which item: Hangi öğe için: diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 8c305e2e1..5a3ff4694 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2182,6 +2182,24 @@ Suggested Пропоновані Set Proofing Language… Вибрати мову перевірки правопису… Language Preferences… Мовні параметри… + Printer Принтер + Select a printer Виберіть принтер + Refresh printers Оновити принтери + Two-sided printing Двосторонній друк + Printer default За замовчуванням принтера + One-sided Односторонній + Two-sided: long edge Двосторонній: довгий край + Two-sided: short edge Двосторонній: короткий край + Grayscale Відтінки сірого + Finding printers… Пошук принтерів… + No printers found. Configure a system printer or export a PDF. Принтерів не знайдено. Налаштуйте системний принтер або експортуйте PDF. + Printer discovery failed. Не вдалося знайти принтери. + Submitting print job… Надсилання завдання на друк… + Print job submitted. Завдання на друк надіслано. + Print job submitted: {receipt} Завдання на друк надіслано: {receipt} + Print failed: the printing worker stopped. Друк не вдався: процес друку зупинився. + Print settings Параметри друку + Submit print job Надіслати завдання на друк # Dictionaries folder (#1063) Dictionaries Словники diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 8b3cbf4f2..b4b2f3362 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2524,6 +2524,24 @@ Paper Giấy Paper size: Khổ giấy: The margins don't fit on a page this size; make the margins smaller first. Lề không vừa với trang có kích thước này; hãy thu nhỏ lề trước. + Printer Máy in + Select a printer Chọn máy in + Refresh printers Làm mới máy in + Two-sided printing In hai mặt + Printer default Mặc định của máy in + One-sided In một mặt + Two-sided: long edge Hai mặt: cạnh dài + Two-sided: short edge Hai mặt: cạnh ngắn + Grayscale Thang độ xám + Finding printers… Đang tìm máy in… + No printers found. Configure a system printer or export a PDF. Không tìm thấy máy in. Hãy thiết lập máy in hệ thống hoặc xuất PDF. + Printer discovery failed. Không tìm được máy in. + Submitting print job… Đang gửi lệnh in… + Print job submitted. Đã gửi lệnh in. + Print job submitted: {receipt} Đã gửi lệnh in: {receipt} + Print failed: the printing worker stopped. In thất bại: tiến trình in đã dừng. + Print settings Cài đặt in + Submit print job Gửi lệnh in # Web Save before New/Open (#1510) Saving {name} was cancelled, so it is still open and nothing was lost. Đã hủy lưu {name}, nên tài liệu vẫn mở và không mất gì. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 1bc64bfdf..090fbc909 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2182,6 +2182,24 @@ Suggested 建议 Set Proofing Language… 设置校对语言… Language Preferences… 语言首选项… + Printer 打印机 + Select a printer 选择打印机 + Refresh printers 刷新打印机 + Two-sided printing 双面打印 + Printer default 打印机默认值 + One-sided 单面 + Two-sided: long edge 双面:长边 + Two-sided: short edge 双面:短边 + Grayscale 灰度 + Finding printers… 正在查找打印机… + No printers found. Configure a system printer or export a PDF. 未找到打印机。请在系统中配置打印机或导出 PDF。 + Printer discovery failed. 查找打印机失败。 + Submitting print job… 正在提交打印作业… + Print job submitted. 打印作业已提交。 + Print job submitted: {receipt} 打印作业已提交:{receipt} + Print failed: the printing worker stopped. 打印失败:打印工作线程已停止。 + Print settings 打印设置 + Submit print job 提交打印作业 # Dictionaries folder (#1063) Dictionaries 词典 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 483de8d54..e67b77ebf 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2171,6 +2171,24 @@ Suggested 建議 Set Proofing Language… 設定校訂語言… Language Preferences… 語言喜好設定… + Printer 印表機 + Select a printer 選取印表機 + Refresh printers 重新整理印表機 + Two-sided printing 雙面列印 + Printer default 印表機預設值 + One-sided 單面 + Two-sided: long edge 雙面:長邊 + Two-sided: short edge 雙面:短邊 + Grayscale 灰階 + Finding printers… 正在尋找印表機… + No printers found. Configure a system printer or export a PDF. 找不到印表機。請在系統中設定印表機或匯出 PDF。 + Printer discovery failed. 尋找印表機失敗。 + Submitting print job… 正在提交列印工作… + Print job submitted. 列印工作已提交。 + Print job submitted: {receipt} 列印工作已提交:{receipt} + Print failed: the printing worker stopped. 列印失敗:列印工作執行緒已停止。 + Print settings 列印設定 + Submit print job 提交列印工作 # Dictionaries folder (#1063) Dictionaries 字典 diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 6de054cf3..b7a6f8c17 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-14 · **Change:** trivial (Italian: the interface audit checks the Italian catalog, `Privacy Settings` wording; previously trivial: Italian grammar: `po'`, `qual è`, `È`, `un`/`un'` rules; more elisions and truncations in spelling; previously trivial: Czech interface catalog completed, 2,447 entries; previously trivial: interface audit runs in CI again: size labels translated, 20 strings in every catalog, Read Aloud error race fixed, #1128; previously trivial: Review › Language reaches every language again — Set Proofing Language… and Set As Default, #860; previously minor: Italian proofing: it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar with Italian messages; previously minor: Portuguese proofing; previously trivial: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; 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-14 · **Change:** trivial (print-area strings translated in every catalog; previously trivial: Italian: the interface audit checks the Italian catalog, `Privacy Settings` wording; previously trivial: Italian grammar: `po'`, `qual è`, `È`, `un`/`un'` rules; more elisions and truncations in spelling; previously trivial: Czech interface catalog completed, 2,447 entries; previously trivial: interface audit runs in CI again: size labels translated, 20 strings in every catalog, Read Aloud error race fixed, #1128; previously trivial: Review › Language reaches every language again — Set Proofing Language… and Set As Default, #860; previously minor: Italian proofing: it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar with Italian messages; previously minor: Portuguese proofing; previously trivial: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; 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`. @@ -186,6 +186,7 @@ WordCraft keeps 2,54 cm). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Print area translated in all 18 interface catalogs (printer list, duplex, grayscale, discovery/submission feedback, print commands) — 18 strings that shipped English-only with the craft-print integration; written from the English meaning, pending native-speaker review like the rest of the catalogs | | 2026-10-14 | trivial | Italian: the interface audit's catalog check covers Italian too, as it does Russian, Hungarian, Turkish and Bulgarian; `Privacy Settings` no longer says "current" ("attuali"); a test pins the envelope size labels #1128 made translatable (Italian inches, the region's decimal comma) | | 2026-10-14 | minor | Italian grammar rules that work without a dictionary: `un pò`/`un po` → `un po'`, `qual'è` → `qual è`, `E'` → `È`, `un'` only before feminine words (`un'altro` → `un altro`, `un altra` → `un'altra`, also `nessun`/`ciascun`/`qualcun`/`alcun`); spelling accepts more elisions (`dov'è`, `anch'io`, `senz'altro`, `nessun'altra`) and truncations (`po'`, `mo'`) and leaves `pò`/`qual'è` to grammar so they aren't marked twice; more emphatic doubles allowed | | 2026-10-12 | minor | File › Options › Proofing › Dictionaries folder (#1063): a saved folder of Hunspell dictionaries searched first, so an app started from Finder or the Dock finds them without `WORDCRAFT_DICTIONARIES`; the pane lists the dictionary file each language found — Polish, Portuguese, Italian, Croatian, Turkish (`file.options` `dictionariesFolder`) | From 20cf5b45d0560f0c92e7b0eddf257268cc8f4ec6 Mon Sep 17 00:00:00 2001 From: Mat Strange <54028700+matstrange@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:00:42 +0100 Subject: [PATCH 063/101] A click on the ribbon's chrome keeps typing in the document (#1093 follow-up) (#1703) Clicks on ribbon chrome that isn't a command button (Styles gallery tiles, tab names, empty ribbon space, View check boxes, status-bar items) left the page without keyboard focus. The click was caught at the end of the frame, after the page had been drawn: want_focus was only picked up next frame and the page's focused flag, which gates the keys, not recomputed until then either, so the keystroke right after the click went nowhere. Claim the keyboard in the same frame a ribbon command button does, using the page's widget id from the frame just drawn. Co-authored-by: Brandon Thomas --- crates/ui-egui/src/canvas.rs | 5 +++++ crates/ui-egui/src/lib.rs | 7 +++++++ docs/ui-parity.md | 3 ++- 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 12ab843e4..38e2b522f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -75,6 +75,9 @@ pub struct CanvasState { pub scale: f32, pub canvas_rect: Option, pub focused: bool, + /// The page's widget id last frame, so a click elsewhere can hand the keyboard straight back to + /// it (`WordApp::refocus_after_chrome_click`). + pub(crate) widget_id: Option, pub render_ms: f64, pub ime_preedit: String, /// Document faces added to the interface fonts so the input method's composition (#565) and a @@ -197,6 +200,7 @@ impl Default for CanvasState { scale: PX_PER_PT, canvas_rect: None, focused: false, + widget_id: None, render_ms: 0.0, ime_preedit: String::new(), ime_faces: Vec::new(), @@ -1353,6 +1357,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { if focus_click || app.canvas.want_focus { app.canvas.want_focus = false; } + app.canvas.widget_id = Some(resp.id); if resp.has_focus() { ui.memory_mut(|m| { m.set_focus_lock_filter(resp.id, egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true }) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f6a76f40c..62d622039 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2041,6 +2041,13 @@ impl WordApp { let on_chrome = ctx.layer_id_at(pos).is_none_or(|l| l.order == egui::Order::Background); if on_chrome && ctx.memory(|m| m.focused().is_none()) && !egui::Popup::is_any_open(ctx) { self.canvas.want_focus = true; + // The page has already been drawn, so it only sees `want_focus` next frame, and its own + // `focused` flag (which gates the keys) isn't recomputed until then either. Claim both + // now, or the click swallows the first keystroke. + self.canvas.focused = true; + if let Some(id) = self.canvas.widget_id { + ctx.memory_mut(|m| m.request_focus(id)); + } // The canvas reads keys before it takes focus back (#1107), so it counts as focused // now: a keystroke in the very next frame reaches the page instead of being dropped. self.canvas.focused = true; diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e98dfff71..bc59368ed 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-14 · **Change:** trivial (Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously 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-14 · **Change:** trivial (a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously 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 @@ -51,6 +51,7 @@ Document title opens Rename Document; the account badge opens Options › Genera | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | A click on the ribbon's chrome that isn't a command button (a Styles gallery tile, a tab name, empty ribbon space, a View check box, a status-bar item) hands the keyboard to the page in the same frame, as a ribbon command button does: typing right after the click reaches the document instead of being swallowed (#1093 follow-up) | | 2026-10-13 | trivial | Page Setup: a Vertical alignment box (Top, Center, Justified, Bottom), applied on OK only if changed (#1320) | | 2026-10-13 | trivial | macOS shortcuts as in Word for Mac: ⌘⇧S Save As (Apply Styles keeps Ctrl+Shift+S elsewhere), ⌘⌥E Insert Endnote (macOS takes ⌘⌥D for the Dock), ⌘⌥A New Comment, ⌃H Replace, ⌘⌥R collapse the ribbon | | 2026-10-13 | trivial | On-canvas objects: regression-tested dragging Behind Text pictures and shapes across overlapping text (body and header/footer), selected or not, without stealing ordinary text clicks; In Front of Text covered too | From 63d5faa9b2af8aa51ebb72f422200518353a50f4 Mon Sep 17 00:00:00 2001 From: Fin Warman Date: Thu, 15 Oct 2026 03:01:19 +0100 Subject: [PATCH 064/101] Tables: honor consistent preferred cell widths (#1709) Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 ++- crates/layout/src/table.rs | 23 ++++++++++++++++ crates/layout/src/tests.rs | 54 ++++++++++++++++++++++++++++++++++++++ docs/gaps.md | 5 +++- docs/layout-parity.md | 5 ++-- 5 files changed, 87 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4d2e6489d..8e391fdde 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-14 · **Change:** trivial (multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; 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-14 · **Change:** trivial (progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; 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-14 · **Change:** trivial (progress log: print-area strings translated in every catalog; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; 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 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -135,6 +135,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log | Date | What landed | +| 2026-10-14 | Consistent preferred cell widths override a stale table grid when both totals agree, for fixed and AutoFit tables. Conflicting widths, spans and percentages remain unchanged. | | 2026-10-14 | Field instructions spanning paragraphs no longer add blank lines during pagination; empty result paragraphs still take a line. | | 2026-10-14 | Page-break-only list paragraphs no longer print stray bullets or numbers. The next item keeps its number, matching the reference export. | |---|---|---| @@ -386,6 +387,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Progress log: consistent preferred cell widths | | 2026-10-14 | trivial | Field instructions spanning paragraphs no longer add blank lines during pagination; empty result paragraphs still take a line. | | 2026-10-14 | trivial | A list paragraph containing only a page break has no visible label; it still advances the list counter. Ordinary empty list items keep their label. | | 2026-10-14 | trivial | Floating-table overlap checks use visible line content; empty line-break lines no longer move a table | diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index 438c339f5..c2544c9a6 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -342,6 +342,7 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava } else { vec![avail / ncols as f32; ncols] }; + apply_cell_widths(t, &mut grid); let mut total: f32 = grid.iter().sum(); // A top-level table in an inherited header or footer takes its share of the defining // section's width (`Ctx::pct_base`). @@ -1174,6 +1175,28 @@ fn is_automatic(t: &Table) -> bool { && t.rows.iter().all(|r| r.cells.iter().all(|c| c.props.width.is_none() && c.props.width_pct.is_none())) } +/// Consistent cell widths replace a stale grid without changing the table's total width. +/// Conflicting widths, spans and percentages need the full shared-grid algorithm. +fn apply_cell_widths(t: &Table, grid: &mut [f32]) { + let Some(first) = t.rows.first() else { return }; + if first.cells.len() != grid.len() { + return; + } + let widths: Option> = first.cells.iter().map(|c| c.props.width.filter(|w| w.is_finite() && *w > 0.0)).collect(); + let Some(widths) = widths else { return }; + let total: f32 = widths.iter().sum(); + if !total.is_finite() || (total - grid.iter().sum::()).abs() > 0.01 { + return; + } + if t.rows.iter().any(|r| { + r.cells.len() != grid.len() + || r.cells.iter().zip(&widths).any(|(c, w)| c.span() != 1 || c.props.width_pct.is_some() || c.props.width != Some(*w)) + }) { + return; + } + grid.copy_from_slice(&widths); +} + /// Word's autofit for a table whose widths may follow its content (`tblLayout` not fixed). /// - With no preferred width anywhere (the table's and every cell's automatic) the columns come /// from their content alone: each its widest line if they all fit in `avail`, else shared out diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index c0c1175d5..1c8542b4b 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -6787,6 +6787,60 @@ fn space_after_stays_clear_before_a_numbered_paragraph_without_a_label() { ); } +#[test] +fn cell_widths_override_grid() { + for fixed in [false, true] { + let mut t = Table::new(2, 3, 450.0); + t.props.fixed = fixed; + t.grid = vec![40.0, 175.0, 235.0]; + for row in &mut t.rows { + for (cell, width) in row.cells.iter_mut().zip([50.0, 60.0, 340.0]) { + cell.props.width = Some(width); + cell.blocks = vec![wordcraft_doc::para_block(Paragraph::with_text("A", Default::default()))]; + } + } + let mut d = Document::from_text("After"); + d.insert_block(StoryRef::Body, &Path::top(0), wordcraft_doc::Block::Table(t)).unwrap(); + let l = lay(&d); + let [(x0, _, _)] = para_at(&l, &[0, 0, 0, 0])[..] else { panic!("cell missing") }; + let [(x1, _, _)] = para_at(&l, &[0, 0, 1, 0])[..] else { panic!("cell missing") }; + let [(x2, _, _)] = para_at(&l, &[0, 0, 2, 0])[..] else { panic!("cell missing") }; + assert!((x1 - x0 - 50.0).abs() < 0.01); + assert!((x2 - x0 - 110.0).abs() < 0.01); + } +} + +#[test] +fn ambiguous_widths_keep_grid() { + let mut t = Table::new(2, 3, 450.0); + t.grid = vec![40.0, 175.0, 235.0]; + for row in &mut t.rows { + for (cell, width) in row.cells.iter_mut().zip([50.0, 60.0, 340.0]) { + cell.props.width = Some(width); + cell.blocks = vec![wordcraft_doc::para_block(Paragraph::with_text("A", Default::default()))]; + } + } + let mut conflicting = t.clone(); + conflicting.rows[1].cells[0].props.width = Some(60.0); + let mut percentage = t.clone(); + percentage.rows[0].cells[0].props.width_pct = Some(10.0); + let mut invalid = t.clone(); + invalid.rows[0].cells[0].props.width = Some(f32::NAN); + let mut different_total = t.clone(); + different_total.rows.iter_mut().for_each(|r| r.cells[0].props.width = Some(60.0)); + let mut spanned = t; + spanned.rows[0].cells.remove(1); + spanned.rows[0].cells[0].props.span = 2; + for table in [conflicting, percentage, invalid, different_total, spanned] { + let mut d = Document::from_text("After"); + d.insert_block(StoryRef::Body, &Path::top(0), wordcraft_doc::Block::Table(table)).unwrap(); + let l = lay(&d); + let [(x0, _, _)] = para_at(&l, &[0, 1, 0, 0])[..] else { panic!("cell missing") }; + let [(x1, _, _)] = para_at(&l, &[0, 1, 1, 0])[..] else { panic!("cell missing") }; + assert!((x1 - x0 - 40.0).abs() < 0.01); + } +} + /// #1374 helpers: a page-anchored, square-wrapped rectangle `w`×`h` at (`x`, `y`) on the page. fn page_shape(x: f32, y: f32, w: f32, h: f32) -> InlineObject { use wordcraft_doc::para::{Anchor, Float, ShapeKind, Wrap}; diff --git a/docs/gaps.md b/docs/gaps.md index e7fb3d829..6c7bfdc86 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-14 · **Change:** trivial (multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; 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-14 · **Change:** trivial (consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; 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 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -11,6 +11,8 @@ first (`gh pr list`), and update this file and the parity doc when a gap closes. Native save reliability: synthetic cases on macOS reproduced loss through the predictable temporary-file name. Saves now use owned, exclusively created siblings, synchronize before replacement and clean up on failure; originals survive pre-replacement errors. This narrows save-path reliability under gap #3; the listed field failures and broader stability work remain open. See [save-path guarantees](file-format-parity.md#native-save-path-guarantees). +Consistent cell widths override stale table grid proportions when their totals agree. Conflicting widths, spans and percentages still need the shared-grid algorithm. + Local desktop recovery now covers unsaved drafts and edits to existing files; see [recovery.md](recovery.md). This narrows gap #3 but does not resolve the crash/hang reports or establish cross-platform reliability. @@ -87,6 +89,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Consistent preferred cell widths | | 2026-10-14 | trivial | Pagination gap #4 narrowed: paragraph boundaries inside complex-field instructions add no flow height. Floating-table placement and other paragraph metrics remain open. | | 2026-10-14 | trivial | A list paragraph containing only a page break has no visible label; it still advances the list counter. Ordinary empty list items keep their label. | | 2026-10-14 | trivial | Field report: grouped `flowChartOr` omitted its crossing spokes; the preset now draws them; wider DOCX corpus work remains open | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 4b2298090..32adbe308 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -1,6 +1,6 @@ # Layout and pagination parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 A document only "opens correctly" if its pages break where Word breaks them: the same lines, the same page count, footnotes and pictures on the same pages. This checklist covers line breaking, @@ -45,8 +45,8 @@ font can't match Word's metrics. | Body top below a tall header, footer pushes body up | ✅ | #138 | — | | Footnotes at the page bottom, endnotes at the end, note numbering | 🟡 | A long note continues onto the next page at a line boundary (its first line stays with the reference), first in that page's note area under a full-width continuation separator (#352); the document's own separator, continuation separator and continuation notice are drawn (the notice under a note that continues; endnotes use the document's separator but don't continue onto a page of their own); each section's note options: footnotes below the text, endnotes at the end of each section, start at, numbering restarted each section or each page (counted from where the notes land, at most three passes) (#385) | 1–2 | | Tables: row heights (at least/exact), rows split across pages, header rows repeat, Can't Split | ✅ | 2026-10-06, #138; every cell in a row gets the row's largest top and bottom margin; an exact height covers the top border, top margin and text, the bottom margin comes on top, and an exact row never splits; a row split across pages keeps its cells' bottom margin, border and last space after on the first page, widow/orphan control applies in its cells, each cell continues at the top of the next page and an at-least row keeps its minimum (`layout::tests::a_row_shares_its_largest_top_and_bottom_margins`, `…_a_split_row_keeps_its_bottom_margin_and_widow_control`, `…_split_row_cells_each_continue_at_the_top`) | — | +| Tables: autofit to contents/window, fixed widths, Word 2013 edge | 🟡 | #137; #44: AutoFit Contents measures each column's narrowest and widest text and shares the width like Word, once (not live as you type); consistent a percentage width past 100% is kept, as Word does; cell widths override stale grid proportions when their totals agree (`cell_widths_override_grid`); conflicting widths and spans still differ | 3–5 | | Floating tables (`w:tblpPr`) | 🟡 | #137; one placed on the page or margin that would cover visible text before it and does not fit below it moves to the next page (#534); empty line-break lines do not count as text; other overlap rules untested | 2–3 | -| Tables: autofit to contents/window, fixed widths, Word 2013 edge | 🟡 | #137; #44: AutoFit Contents measures each column's narrowest and widest text and shares the width like Word, once (not live as you type); a percentage width past 100% is kept, as Word does; cell preferred widths and spans still differ | 3–5 | | Nested tables | 🟡 | render; polish missing | 2–4 | | Floating pictures/shapes placement (relative to page/margin/column/paragraph/line/character) | ✅ | #136 | — | | Text wrap: square, top-and-bottom, behind, in front | ✅ | 2026-10-06; beside an object, a line's first word that doesn't fit the strip goes on whole to the row's next strip or the next row, instead of being split (#1376, `layout::tests::a_word_too_wide_for_the_strip_beside_a_float_moves_on_whole`; inferred from a reporter's comparison, not yet measured in Word) a paragraph running on into the next column or page is broken again there around that column's shapes, its own floating objects left where they are (#1374). Text in an earlier column doesn't yet wrap around a shape anchored in a later one (#1158) | #884: an object dragged on a multi-column page anchors to a paragraph in the column it lands in, so that column's text wraps around it. Text laid out before the anchor (an earlier column) doesn't yet wrap around an object anchored later on the page, nor do lines a paragraph continues into the next column (broken once, in the column it starts in) | @@ -161,6 +161,7 @@ No overall parity estimate changes from these probes. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Consistent cell widths override a stale table grid | | 2026-10-14 | trivial | Paragraphs inside complex-field instructions take no flow height; empty result paragraphs keep their line. | | 2026-10-14 | trivial | A list paragraph containing only a page break has no visible label; it still advances the list counter. Ordinary empty list items keep their label. | | 2026-10-14 | trivial | With `w:doNotUseHTMLParagraphAutoSpacing` the space between two paragraphs is the first's space after plus the second's space before, as measured in Word; without it, still the larger (#733) | From 850bbb5dbce7c3ebc2c19584719d2f7e39682968 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 22:02:53 -0400 Subject: [PATCH 065/101] Docs: merge table rows that conflict resolutions left duplicated While landing community PRs, a few rows in gaps.md, layout-parity.md and file-format-parity.md ended up twice (an older and a newer version). Each pair is now one row: the newer text, plus what only the older one said (section note options #385; allowSpaceOfSameStyleInTable). Co-authored-by: Claude Opus 5.5 --- docs/file-format-parity.md | 4 +--- docs/gaps.md | 5 +---- docs/layout-parity.md | 5 +---- 3 files changed, 3 insertions(+), 11 deletions(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b649943bd..2a82278e7 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -89,10 +89,8 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Native Word ink (`w14:contentPart`, InkML) | 🟡 | 🟡 | Drawing-level ink kept opaque with its InkML part, relationship and content type; direct run-level `w:contentPart` and run-level `mc:AlternateContent` keep the native branch and VML picture fallback. Direct content parts remain direct through repeated save/reopen cycles, without a native rendering. Drawing-level ink shows the preview or a missing-picture box. No native stroke rendering, eraser, conversion or replay; ink nested in groups/canvases remains 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 used for rendering, but the font table, its relationships and the embedded font parts are written back unchanged (#770); attached template (`w:attachedTemplate` and its external relationship) and `w:linkStyles` round-trip (#377). `w:removePersonalInformation` and `w:removeDateAndTime` round-trip and are honoured on save (#1062): comment and revision authors written as "Author" (initials "A"), no `dc:creator`/`cp:lastModifiedBy`, no annotation dates; the open document keeps its values (`file.privacyOptions` sets them, `file.privacy` reads them; File › Options › Save › Privacy for this document). The template path comes from the file, so opening never reads it: styles update from it only on an explicit Update Styles. Settings the model doesn't hold (other `w:compatSetting`s, rsids, document variables, proofing state, colour scheme mapping…) survive a save: the writer merges into the file's own `settings.xml` ([below](#settings-preservation)); `w:latentStyles`, `w:default` on localized default styles and lists' `w:nsid`/`w:tmpl`/`w:multiLevelType` round-trip | +| Themes, font table, settings, compatibility mode | ✅ | ✅ | `w:allowSpaceOfSameStyleInTable` read and written in schema order; absent/false default off, enabled flag retained in modes 11/12/14/15. Embedded fonts (`w:embedRegular`) not used for rendering, but the font table, its relationships and the embedded font parts are written back unchanged (#770); attached template (`w:attachedTemplate` and its external relationship) and `w:linkStyles` round-trip (#377). `w:removePersonalInformation` and `w:removeDateAndTime` round-trip and are honoured on save (#1062): comment and revision authors written as "Author" (initials "A"), no `dc:creator`/`cp:lastModifiedBy`, no annotation dates; the open document keeps its values (`file.privacyOptions` sets them, `file.privacy` reads them; File › Options › Save › Privacy for this document). The template path comes from the file, so opening never reads it: styles update from it only on an explicit Update Styles. Settings the model doesn't hold (other `w:compatSetting`s, rsids, document variables, proofing state, colour scheme mapping…) survive a save: the writer merges into the file's own `settings.xml` ([below](#settings-preservation)); `w:latentStyles`, `w:default` on localized default styles and lists' `w:nsid`/`w:tmpl`/`w:multiLevelType` round-trip | | 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) | -| Themes, font table, settings, compatibility mode | ✅ | ✅ | `w:allowSpaceOfSameStyleInTable` read and written in schema order; absent/false default off, enabled flag retained in modes 11/12/14/15. 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 properties round-trip; bibliography sources pending (#169) | | Section text direction (`w:textDirection` in `w:sectPr`, §17.6.20) | ✅ | ✅ | `tbRl`, `btLr` (and `tbRlV`/`tbLrV`, read as `tbRl`); `lrTbV` and unknown values read as horizontal (#491) | | Section vertical alignment (`w:vAlign` in `w:sectPr`, §17.6.23) | ✅ | ✅ | `top`, `center`, `bottom` and `both` (Justified, read as Center before #1320); unknown values read as top, top writes nothing (`crates/docx/tests/section_valign.rs`). ODT/RTF don't carry it | | Ruby (`w:ruby`: `w:rubyPr`, `w:rt`, `w:rubyBase`) | ✅ | ✅ | #288; base and ruby text with their run formatting, alignment, sizes, raise, language | diff --git a/docs/gaps.md b/docs/gaps.md index 6c7bfdc86..37312ec74 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -42,11 +42,8 @@ Document renaming: desktop renaming requires a filesystem that supports hard lin | 17 | **East Asian typography**: Asian Typography options (Phonetic Guide landed, #288; Enclose Characters landed, #297; document grid landed, #391; vertical text landed, #491) | | `para.asianTypography` is live (Paragraph dialog Asian Typography tab, #323) and no East Asian item is left in the catalog's missing list; the remaining depth is not measured | Chinese, Japanese and Korean documents | 11–19 | [layout-parity.md](layout-parity.md) | | 19 | **RTF and ODT depth**: notes, comments, revisions (RTF and ODT insertions and deletions done), sections (RTF import done, #1378; RTF export and ODT left), RTL. ODT sections landed on import (#1379): page-style changes and columned `text:section`s | | ~2,100 (RTF) and ~1,600 (ODT) lines in `crates/formats`; RTF code pages (#896) and page setup (#902), ODT page setup (#829) landed; open PRs: RTF notes and comments (#1056), ODT notes and comments (#1057), ODT headers and footers (#873) | Users exchanging with LibreOffice or older tools | 18–27 | [file-format-parity.md](file-format-parity.md) | | 18 | **Screen-reader access** to the document canvas: exposed, not yet tried with real screen readers | | The canvas describes paragraphs, headings, lists, tables, links, pictures and the caret to AccessKit (#488), tested headlessly; Windows Magnifier follows the caret, onto a new line too (#1119); no NVDA, VoiceOver or Orca run yet | Blind and low-vision users; public-sector procurement | 3–6 | [ui-parity.md](ui-parity.md) | -| 19 | **RTF and ODT depth**: notes, comments, revisions, sections, RTL | | ~1,500 lines each in `crates/formats` | Users exchanging with LibreOffice or older tools | 18–27 | [file-format-parity.md](file-format-parity.md) | -| 20 | **Performance on large real-world documents** unmeasured (500+ pages, many pictures) | | Only the 188-page sample benchmark | Thesis and book authors | 10–20 | [layout-parity.md](layout-parity.md) | -| 21 | **Mail merge data sources**: no Outlook/Contacts, Access or database sources, no email merge; CSV, typed lists (#247) and Excel/OpenDocument spreadsheets (#334, legacy .xls #778) only | | Select Recipients reads .csv/.tsv/.txt in any common encoding (UTF-8/16, GBK, Big5, Shift_JIS…), .xlsx/.xlsm, .xls and .ods; Edit Recipient List filters and sorts (#778) | Office administrators | 4–8 | [target-app-parity.md](target-app-parity.md) | | 20 | **Performance on large real-world documents** unmeasured (500+ pages, many pictures) | | Only the 188-page sample benchmark; scrolling a 1,096-page one-table document went from ~600 ms to ~2–5 ms a frame (#811), but opening it still lays out for seconds | Thesis and book authors | 10–20 | [layout-parity.md](layout-parity.md) | -| 21 | **Mail merge data sources**: no Outlook/Contacts, Access or database sources, no email merge; CSV, typed lists (#247) and Excel/OpenDocument spreadsheets (#334) only | | Select Recipients reads .csv/.tsv, .xlsx/.xlsm and .ods; open PR #885 adds .xls, legacy encodings, Filter and Sort | Office administrators | 4–8 | [target-app-parity.md](target-app-parity.md) | +| 21 | **Mail merge data sources**: no Outlook/Contacts, Access or database sources, no email merge; CSV, typed lists (#247) and Excel/OpenDocument spreadsheets (#334, legacy .xls #778) only | | Select Recipients reads .csv/.tsv/.txt in any common encoding (UTF-8/16, GBK, Big5, Shift_JIS…), .xlsx/.xlsm, .xls and .ods; Edit Recipient List filters and sorts (#778) | Office administrators | 4–8 | [target-app-parity.md](target-app-parity.md) | | 22 | **Ecosystem**: no VBA execution (macros preserved only), no Office add-ins, no cloud storage, no real-time co-authoring | | `.docm` keeps macros (#172) | Enterprise users with macro templates; teams | 40–80 + owner decisions | [target-app-parity.md](target-app-parity.md) | | 23 | **PDF Reflow** (open a PDF as an editable document, Word for Windows) | | | Users who edit PDFs they receive | 30–50 | [file-format-parity.md](file-format-parity.md) | | 24 | **Default shortcuts**: ~135 of Word's ~250 (#330, #339, #343; Customize Keyboard #368, Customize Ribbon and the Quick Access Toolbar #379 landed) | | [ui-parity.md](ui-parity.md) | Power users | 4–7 | [ui-parity.md](ui-parity.md) | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 32adbe308..a789038b7 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -41,9 +41,7 @@ font can't match Word's metrics. | Labels on page-break-only list paragraphs | ✅ | `layout::tests::break_only_list_has_no_label`: no bullet or number on the break, but the next number is still 3 after item 1 and a break. Plain empty items keep their labels. Synthetic DOCX observed through Adobe PDF conversion | — | | List labels: number formats (decimal, letters, Russian letters, roman, ordinal text, Chinese/Japanese/Korean counting and legal, 一〇一 digits, 甲乙丙, ①…⑳, full-width), `%n` text such as `第%1章` / `(%1)`, legal numbering | ✅ | `doc::cjk_numbers` tests, `doc::section::tests::russian_letters_follow_word`, `layout::tests::chinese_counting_list_labels_and_their_font_runs`, `layout::tests::russian_letter_list_labels_draw_cyrillic`; a label mixing faces (digit + CJK comma) is drawn run by run (#534); Russian letters skip ё, й, ъ and ь and then double (аа, бб), as observed in Word. Hebrew, Arabic, Hindi, Thai and Vietnamese formats show decimal The label stays attached to the start of the text when the paragraph is centred or aligned to the end, as in Word (#1159). | — | | Body top below a tall header, footer pushes body up | ✅ | #138; a footer taller than the bottom margin moves the body bottom up (#453), and a header or footer linked to the previous section takes its room too (#454) | — | -| Footnotes at the page bottom, endnotes at the end, note numbering | 🟡 | A long note continues onto the next page at a line boundary (its first line stays with the reference), first in that page's note area under a full-width continuation separator (#352); the document's own separator, continuation separator and continuation notice are drawn (the notice under a note that continues); on a page with footnotes a paragraph ending there keeps its space after above the separator, or its last lines go to the next page with the note (#1116); a long endnote continues on the next column or page at a line boundary (#456), and the caret finds note text on its continuation page (#457); per-section restart partial | 1–3 | -| Body top below a tall header, footer pushes body up | ✅ | #138 | — | -| Footnotes at the page bottom, endnotes at the end, note numbering | 🟡 | A long note continues onto the next page at a line boundary (its first line stays with the reference), first in that page's note area under a full-width continuation separator (#352); the document's own separator, continuation separator and continuation notice are drawn (the notice under a note that continues; endnotes use the document's separator but don't continue onto a page of their own); each section's note options: footnotes below the text, endnotes at the end of each section, start at, numbering restarted each section or each page (counted from where the notes land, at most three passes) (#385) | 1–2 | +| Footnotes at the page bottom, endnotes at the end, note numbering | 🟡 | A long note continues onto the next page at a line boundary (its first line stays with the reference), first in that page's note area under a full-width continuation separator (#352); the document's own separator, continuation separator and continuation notice are drawn (the notice under a note that continues); on a page with footnotes a paragraph ending there keeps its space after above the separator, or its last lines go to the next page with the note (#1116); a long endnote continues on the next column or page at a line boundary (#456), and the caret finds note text on its continuation page (#457); each section's note options: footnotes below the text, endnotes at the end of each section, start at, numbering restarted each section or each page (counted from where the notes land, at most three passes) (#385) | 1–2 | | Tables: row heights (at least/exact), rows split across pages, header rows repeat, Can't Split | ✅ | 2026-10-06, #138; every cell in a row gets the row's largest top and bottom margin; an exact height covers the top border, top margin and text, the bottom margin comes on top, and an exact row never splits; a row split across pages keeps its cells' bottom margin, border and last space after on the first page, widow/orphan control applies in its cells, each cell continues at the top of the next page and an at-least row keeps its minimum (`layout::tests::a_row_shares_its_largest_top_and_bottom_margins`, `…_a_split_row_keeps_its_bottom_margin_and_widow_control`, `…_split_row_cells_each_continue_at_the_top`) | — | | Tables: autofit to contents/window, fixed widths, Word 2013 edge | 🟡 | #137; #44: AutoFit Contents measures each column's narrowest and widest text and shares the width like Word, once (not live as you type); consistent a percentage width past 100% is kept, as Word does; cell widths override stale grid proportions when their totals agree (`cell_widths_override_grid`); conflicting widths and spans still differ | 3–5 | | Floating tables (`w:tblpPr`) | 🟡 | #137; one placed on the page or margin that would cover visible text before it and does not fit below it moves to the next page (#534); empty line-break lines do not count as text; other overlap rules untested | 2–3 | @@ -58,7 +56,6 @@ font can't match Word's metrics. | Vertical page alignment | ✅ | Top, Center, Bottom and Justified (#1320): justified shares each page's spare room equally between its paragraphs and tables, first line on the top margin and last on the bottom one (above the footnotes), the last page and a page ending in a manual page break too; a page with one block stays at the top; the last paragraph's space after stays inside the justified height (Word for Mac, user-checked). A section's last page takes its own section's alignment (it took the next section's before). Multi-column justified pages stay at the top (not compared with Word) | — | | Gutter, mirror margins, book fold, 2 pages per sheet | 🟡 | gutter and mirror margins (even pages put the inside margin and gutter on the right, #452; page-relative floats stay put, and inside/outside alignment and margins follow the page side, #1181); book fold missing | 2–3 | | Vertical text (`tbRl`) in sections and text boxes; cell text direction | ✅ | cell text direction (#245); text-box text direction (#369); vertical sections (`tbRl`/`btLr`, #491): the page's text area turned, lines top to bottom stacking right to left, ideographs, kana and Hangul upright, Latin on its side, caret and clicks through the turn. Missing: OpenType `vert` alternates (punctuation is moved, not substituted), arrow keys along vertical lines, upright pictures take turned room | 2–4 | -| Gutter, mirror margins, book fold, 2 pages per sheet | 🟡 | gutter and mirror margins; book fold missing | 2–3 | | Document grid (`w:docGrid`, lines per page, characters per line) | 🟡 | #391, #401: line grid snaps line heights to whole pitches (text centred in them; `w:snapToGrid` off opts a paragraph out); character grid puts East Asian characters on the character pitch (`linesAndChars`) or all text (`snapToChars`), Latin text proportional; View › Gridlines draws it. Not yet: run-level `w:snapToGrid`, grid in headers/footers, paragraph spacing in grid lines, Word's exact glyph placement in a cell | 1–2 | | Ruby / phonetic guide, enclose characters, combined characters | 🟡 | ruby (Phonetic Guide) landed (#288): ruby text over its base by alignment, line grows to fit, in PDF; enclose and combined characters missing; ruby not yet compared with Word page by page | 2–4 | | Bidirectional paragraphs, mixed-direction lines; RTL tables and sections | 🟡 | #207; RTL tables (#362): grid columns from the right edge, start alignment and indent from the right margin, cell margins and left/right borders mirrored, merged and turned cells and nested tables included; RTL sections (#362): newspaper columns right to left with the line between mirrored. Right-hand page gutter (`w:rtlGutter`) missing | 1 | From 9d4d2ea01c87e5709f2f3da022e22a372799270b Mon Sep 17 00:00:00 2001 From: mmazzieri Date: Thu, 15 Oct 2026 04:08:59 +0200 Subject: [PATCH 066/101] Legacy VML freeforms (header rule lines) are drawn and kept on save (#1659) * DOCX: read VML freeforms (v:shape with a path) as freeform shapes Legacy VML shapes with their own `path` (ECMA-376 Part 4), such as rule lines in headers, were dropped on open and lost on save. They are now read as freeforms: m/t/l/r/c/v/x/e commands in the coordsize/coordorigin space, nf/ns, v:fill and v:stroke opacity; arcs drawn straight, formula references read as 0. Saved as DrawingML custom geometry. Tests: header rule read and round-tripped; hostile paths and coordinate spaces stay finite and never panic. Co-Authored-By: Claude Opus 5.5 * DOCX: VML freeforms whose path uses formulas aren't drawn Formulas (@n, from v:formulas) aren't evaluated; reading them as 0 drew stray lines. Parity row states the fill-opacity limit (none at 0, otherwise opaque, as for DrawingML fills). Co-Authored-By: Claude Opus 5.5 * DOCX: VML freeforms reuse the watermark's vml_fraction The watermark reader (#1328, now on main) brought its own vml_fraction; the freeform reader had an identical copy, which no longer compiles after the merge. Keep one. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 4 +- crates/docx/src/read/freeform.rs | 226 ++++++++++++++++++++++++++++++- crates/docx/src/read/story.rs | 29 ++++ crates/docx/tests/fixtures.rs | 90 +++++++++++- docs/file-format-parity.md | 5 +- docs/gaps.md | 5 +- 6 files changed, 352 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8e391fdde..ca551d226 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-14 · **Change:** trivial (progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; 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-14 · **Change:** trivial (VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; 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-14 · **Change:** trivial (progress log: print-area strings translated in every catalog; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; 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 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -136,6 +136,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | | 2026-10-14 | Consistent preferred cell widths override a stale table grid when both totals agree, for fixed and AutoFit tables. Conflicting widths, spans and percentages remain unchanged. | +| 2026-10-14 | Legacy VML freeforms (`v:shape` with its own `path`), such as the rule lines in many older headers, are read as freeform shapes: they show on screen and in PDFs and are saved back as DrawingML custom geometry instead of vanishing | | 2026-10-14 | Field instructions spanning paragraphs no longer add blank lines during pagination; empty result paragraphs still take a line. | | 2026-10-14 | Page-break-only list paragraphs no longer print stray bullets or numbers. The next item keeps its number, matching the reference export. | |---|---|---| @@ -387,6 +388,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Progress log: VML freeforms read and kept on save | | 2026-10-14 | trivial | Progress log: consistent preferred cell widths | | 2026-10-14 | trivial | Field instructions spanning paragraphs no longer add blank lines during pagination; empty result paragraphs still take a line. | | 2026-10-14 | trivial | A list paragraph containing only a page break has no visible label; it still advances the list counter. Ordinary empty list items keep their label. | diff --git a/crates/docx/src/read/freeform.rs b/crates/docx/src/read/freeform.rs index d83521f53..4881cf7a3 100644 --- a/crates/docx/src/read/freeform.rs +++ b/crates/docx/src/read/freeform.rs @@ -1,4 +1,5 @@ -//! Freeform shapes (DrawingML custom geometry, `a:custGeom`) and ink strokes drawn with them. +//! Freeform shapes (DrawingML custom geometry, `a:custGeom`; legacy VML paths) and ink strokes +//! drawn with them. use wordcraft_doc::freeform::{FreePath, Freeform, InkTool, MAX_PATHS, MAX_POINTS}; @@ -82,6 +83,229 @@ pub fn cust_geom(sppr: &El, w: f32, h: f32) -> Option { (!f.paths.is_empty()).then_some(f) } +/// Largest VML path coordinate kept (coordinate-space units). +const MAX_VML: f64 = 1.0e12; +/// Characters of a VML `path` attribute read; the rest is ignored. +const MAX_VML_PATH: usize = 1 << 20; +/// Parameters of a VML path kept, all commands together. +const MAX_VML_PARAMS: usize = MAX_POINTS * 8; + +/// A legacy VML freeform (`v:shape` with a `path`, ECMA-376 Part 4 §14.1.2.19). +pub struct VmlPath { + pub freeform: Freeform, + /// `nf` in the path: no fill. + pub no_fill: bool, + /// `ns` in the path: no outline. + pub no_stroke: bool, +} + +/// The VML path commands that are two letters long. +const VML_TWO: [&str; 11] = ["nf", "ns", "ae", "al", "at", "ar", "wa", "wr", "qx", "qy", "qb"]; + +/// The VML `path` of a shape, mapped onto its `w` × `h` frame (points) through its `coordsize` +/// (default `1000,1000`) and `coordorigin` (default `0,0`). Commands: `m`/`t` move (absolute / +/// relative), `l`/`r` line, `c`/`v` cubic Bézier (flattened), `x` close, `e` end, `nf`/`ns` no +/// fill / no outline; arcs (`at`, `ar`, `wa`, `wr`) and quadrants (`qx`, `qy`) are drawn straight +/// to their end, `qb` straight to its last point, and ellipses (`ae`, `al`) are skipped. An empty +/// parameter is 0. `None` when the path has no points, or uses formulas (`@n`, from `v:formulas`, +/// which aren't evaluated): such a shape isn't drawn rather than drawn wrong. +pub fn vml_path(path: &str, coordsize: Option<&str>, coordorigin: Option<&str>, w: f32, h: f32) -> Option { + if path.contains('@') { + return None; + } + let pair = |v: Option<&str>| -> Option<(f64, f64)> { + let v = v?; + let (a, b) = v.split_once(',').unwrap_or((v, "")); + let n = |s: &str| int(s).map(|i| (i as f64).clamp(-MAX_VML, MAX_VML)); + Some((n(a)?, n(b).unwrap_or(0.0))) + }; + let size = |v: f64| if v > 0.0 { v } else { 1000.0 }; + let (cw, ch) = pair(coordsize).map_or((1000.0, 1000.0), |(a, b)| (size(a), size(b))); + let (ox, oy) = pair(coordorigin).unwrap_or((0.0, 0.0)); + let (sx, sy) = (w as f64 / cw, h as f64 / ch); + let scale = |[x, y]: [f64; 2]| -> [f32; 2] { [((x - ox) * sx) as f32, ((y - oy) * sy) as f32] }; + + let mut d = Draw::default(); + let (mut no_fill, mut no_stroke) = (false, false); + for (cmd, params) in vml_commands(path) { + if d.full() { + break; + } + let pts: Vec<[f64; 2]> = params.as_chunks::<2>().0.to_vec(); + match cmd.as_str() { + "m" | "t" => { + for (i, &p) in pts.iter().enumerate() { + let p = if cmd == "t" { d.rel(p) } else { p }; + if i == 0 { + d.move_to(p); + } else { + d.line_to(p); + } + } + } + "l" | "r" | "qx" | "qy" => { + for &p in &pts { + let p = if cmd == "r" { d.rel(p) } else { p }; + d.line_to(p); + } + } + "c" | "v" => { + for c in pts.as_chunks::<3>().0 { + let start = d.at; + let ctrl: Vec<[f64; 2]> = std::iter::once(start).chain(c.iter().map(|&p| if cmd == "v" { d.rel(p) } else { p })).collect(); + for i in 1..=CURVE_STEPS { + d.line_to(bezier(&ctrl, i as f64 / CURVE_STEPS as f64)); + } + } + } + "qb" => { + if let Some(&p) = pts.last() { + d.line_to(p); + } + } + // Bounding box, then start and end: `at`/`wa` draw a line to the start, `ar`/`wr` move there. + "at" | "ar" | "wa" | "wr" => { + for [_, _, s, e] in pts.as_chunks::<4>().0 { + if matches!(cmd.as_str(), "ar" | "wr") { + d.move_to(*s); + } else { + d.line_to(*s); + } + d.line_to(*e); + } + } + "x" => d.end(true), + "e" => d.end(false), + "nf" => no_fill = true, + "ns" => no_stroke = true, + _ => {} + } + } + d.end(false); + let paths = d.done.into_iter().map(|(pts, closed)| FreePath { pts: pts.into_iter().map(scale).collect(), closed }).collect(); + let freeform = Freeform { w, h, paths, alpha: 1.0, ink: None, order: None }.sanitized(); + (!freeform.paths.is_empty()).then_some(VmlPath { freeform, no_fill, no_stroke }) +} + +/// The pen while a VML path is drawn: where it is, where the subpath began, the subpath being +/// drawn and those done (closed or not). +#[derive(Default)] +struct Draw { + at: [f64; 2], + start: [f64; 2], + cur: Vec<[f64; 2]>, + done: Vec<(Vec<[f64; 2]>, bool)>, +} + +impl Draw { + fn full(&self) -> bool { + self.done.len() >= MAX_PATHS + } + + fn rel(&self, [x, y]: [f64; 2]) -> [f64; 2] { + [(self.at[0] + x).clamp(-MAX_VML, MAX_VML), (self.at[1] + y).clamp(-MAX_VML, MAX_VML)] + } + + fn move_to(&mut self, p: [f64; 2]) { + self.end(false); + self.at = p; + self.start = p; + self.cur.push(p); + } + + fn line_to(&mut self, p: [f64; 2]) { + if self.cur.is_empty() { + self.cur.push(self.at); + self.start = self.at; + } + if self.cur.len() < MAX_POINTS { + self.cur.push(p); + } + self.at = p; + } + + /// Finish the subpath being drawn, closed back to its start or not. + fn end(&mut self, closed: bool) { + if !self.cur.is_empty() && !self.full() { + self.done.push((std::mem::take(&mut self.cur), closed)); + } + self.cur.clear(); + if closed { + self.at = self.start; + } + } +} + +/// The commands of a VML path with their parameters: a command is a letter (or one of +/// [`VML_TWO`]) followed by numbers separated by commas or spaces. An empty parameter (nothing +/// before a comma) is 0, and so is a formula reference (`@n`, not expected here: see +/// [`vml_path`]); fractions are dropped. Anything else is skipped. +fn vml_commands(path: &str) -> Vec<(String, Vec)> { + let mut out: Vec<(String, Vec)> = Vec::new(); + let mut chars = path.chars().take(MAX_VML_PATH).peekable(); + // Whether a number was read since the command or the last comma. + let mut has_num = true; + let mut kept = 0usize; + let mut push = |out: &mut Vec<(String, Vec)>, v: f64| { + if kept < MAX_VML_PARAMS + && let Some((_, p)) = out.last_mut() + { + p.push(v); + kept += 1; + } + }; + while let Some(c) = chars.next() { + if c.is_ascii_alphabetic() { + if !has_num { + push(&mut out, 0.0); + } + has_num = false; + let lower = c.to_ascii_lowercase(); + let mut cmd = lower.to_string(); + if let Some(&n) = chars.peek() { + let two: String = [lower, n.to_ascii_lowercase()].iter().collect(); + if VML_TWO.contains(&two.as_str()) { + chars.next(); + cmd = two; + } + } + out.push((cmd, Vec::new())); + } else if c == ',' { + if !has_num { + push(&mut out, 0.0); + } + has_num = false; + } else if matches!(c, '@' | '-' | '+' | '.') || c.is_ascii_digit() { + let mut v = c.to_digit(10).map_or(0.0, f64::from); + let mut frac = c == '.'; + while let Some(&n) = chars.peek() { + if n == '.' { + frac = true; + } else if n.is_ascii_digit() { + if !frac { + v = (v * 10.0 + n.to_digit(10).map_or(0.0, f64::from)).min(MAX_VML); + } + } else { + break; + } + chars.next(); + } + let v = match c { + '@' => 0.0, + '-' => -v, + _ => v, + }; + has_num = true; + push(&mut out, v); + } + // Whitespace and anything else only separate numbers. + } + if !has_num { + push(&mut out, 0.0); + } + out +} + /// The pen of an ink stroke written by WordCraft, from its drawing's name (`Ink Pen 3`, /// `Ink Highlighter 4`). pub fn ink_tool(name: &str) -> Option { diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index e2575d2d4..61b1dc59f 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -1462,6 +1462,35 @@ impl Reader<'_> { extra: Default::default(), }); } + // A freeform: its `path`, in its `coordsize` space; a `v:fill` with no opacity + // or `nf` in the path leaves it unfilled. Not WordArt (`v:textpath`). + if shape.local() == "shape" + && shape.child("v:textpath").is_none() + && let Some(path) = shape.attr("path") + && let Some(vp) = super::freeform::vml_path(path, shape.attr("coordsize"), shape.attr("coordorigin"), w, h) + { + let opacity = |el: &str| shape.child(el).and_then(|e| e.attr("opacity")).and_then(vml_fraction).unwrap_or(1.0); + let fill = fill.filter(|_| !vp.no_fill && opacity("v:fill") > 0.0); + let stroke = stroke.filter(|_| !vp.no_stroke); + if stroke.is_none() && fill.is_none() { + return None; + } + let mut f = vp.freeform; + f.alpha = opacity("v:stroke"); + return Some(InlineObject::Shape { + kind: ShapeKind::Freeform, + w, + h, + fill, + stroke, + stroke_width, + float, + story: None, + freeform: Some(Arc::new(f)), + effects: Default::default(), + extra: Default::default(), + }); + } let kind = kind?; let mut extra = ShapeExtra::default(); if kind == ShapeKind::Line { diff --git a/crates/docx/tests/fixtures.rs b/crates/docx/tests/fixtures.rs index f4c736237..a0c2cfcb9 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/fixtures.rs @@ -1960,7 +1960,8 @@ fn vml_preset_shapes_are_drawn() { // A preset holding text keeps its geometry around the text. assert_eq!(shape(4), Some((ShapeKind::Diamond, 50.0, 50.0, Some(Rgb(0xFF, 0xFF, 0)), black, 0.75, true, true))); assert_eq!(shape(5), Some((ShapeKind::RightTriangle, 20.0, 20.0, Some(Rgb(0xFF, 0, 0)), black, 0.75, true, false))); - assert_eq!(shape(6), None); + // Its own path is drawn as a freeform, not as the shapetype's arrow. + assert_eq!(kind(6), Some((ShapeKind::Freeform, false))); assert_eq!(shape(7), Some((ShapeKind::Line, 40.0, 10.0, None, black, 0.75, true, false))); assert_eq!(shape(8), Some((ShapeKind::Line, 30.0, 15.0, None, Some(Rgb(0xFF, 0, 0)), 0.75, false, false))); assert!(matches!(ps[8].objects.first(), Some(InlineObject::Shape { extra, .. }) if extra.arrow_end && !extra.arrow_start)); @@ -2602,6 +2603,93 @@ fn text_frames_read_and_round_trip() { assert!(q.iter().any(|x| x.props.drop_cap == Some(2) && x.props.frame.is_none())); } +/// A legacy VML freeform (`v:shape` with a `path`, ECMA-376 Part 4): a rule across a header, +/// drawn in a 1000 × 1000 coordinate space, outlined, its fill transparent. It is read as a +/// freeform of the shape's size and kept when saved (as DrawingML custom geometry) and read back. +#[test] +fn vml_freeform_rule_in_header() { + let w10 = r#"xmlns:w10="urn:schemas-microsoft-com:office:word""#; + let rule = r##" +"##; + let header = format!(r#"Header{rule}"#); + let body = r#"Body"#; + let bytes = docx(body, &[("rIdH", "header", "header1.xml")], &[("word/header1.xml", &header)]); + let near = |a: f32, b: f32| (a - b).abs() < 0.05; + let check = |d: &Document| { + let hid = d.last_section.headers.default.unwrap(); + let shapes: Vec<&InlineObject> = d.parts.get(&hid).unwrap().blocks.iter().filter_map(|b| b.as_para()).flat_map(|p| &p.objects).collect(); + let [rule, tri] = shapes.as_slice() else { panic!("{shapes:?}") }; + let InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, freeform: Some(f), .. } = rule else { panic!("{rule:?}") }; + assert_eq!((*kind, *fill, *stroke), (ShapeKind::Freeform, None, Some(Rgb(0x33, 0x66, 0xCC)))); + assert!(near(*w, 360.0) && near(*h, 12.0) && near(*stroke_width, 1.5), "{w} x {h}, {stroke_width}"); + assert_eq!((float.h_rel, float.v_rel, float.wrap), (Anchor::Margin, Anchor::Margin, Wrap::InFrontOfText)); + assert!(near(float.y, 30.0), "{float:?}"); + let [p] = f.paths.as_slice() else { panic!("{f:?}") }; + let pts = f.placed(0.0, 0.0, *w, *h); + let [(pts, _)] = pts.as_slice() else { panic!("{pts:?}") }; + assert!(p.closed && pts.len() == 2, "{pts:?}"); + assert!(near(pts[0][0], 0.0) && near(pts[0][1], 6.0) && near(pts[1][0], 360.0) && near(pts[1][1], 6.0), "{pts:?}"); + // Relative moves and lines from the origin, `nf`: an outlined triangle, half opaque. + let InlineObject::Shape { kind: ShapeKind::Freeform, fill: None, stroke: Some(Rgb(255, 0, 0)), w, h, freeform: Some(f), .. } = tri else { + panic!("{tri:?}") + }; + let placed = f.placed(0.0, 0.0, *w, *h); + let [(pts, true)] = placed.as_slice() else { panic!("{placed:?}") }; + let want = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0]]; + assert!(pts.len() == 3 && pts.iter().zip(want).all(|(a, b)| near(a[0], b[0]) && near(a[1], b[1])), "{pts:?}"); + assert!(near(f.alpha, 0.5), "{}", f.alpha); + }; + let d = wordcraft_docx::read(&bytes).unwrap(); + check(&d); + check(&wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap()); +} + +/// Hostile VML paths and coordinate spaces read without panicking and stay finite. +#[test] +fn hostile_vml_paths_stay_finite() { + let paths = [ + "m,", + "l", + "mxe", + "m99999999999999999999999999,0l-99999999999999999999,1e", + "minf,nanlNaN,-inf", + "m@1,@2l@3,@4nfnse", + "wa1", + "c1", + "m١٢,٣l٤,٥", + "m0,0c", + "m0,0v1,2,3", + "ar0,0,10,10,0,0,10,10at", + "m0,0r1,1,1,1,1,1,1,1,1,1x x x l", + "--..,,@@ ,,, xm1.5.5,2..3l+-+,-e", + "\u{1F600}m1,1l2,2", + "qbqxqyaeal1,2,3,4,5,6", + ]; + let spaces = [("0,0", "0,0"), ("-5,-5", "x,y"), ("1e39,x", "-99999999999999999999,99999999999999999999"), ("", ""), ("1,1", "1e308,1e308")]; + for path in paths { + for (size, origin) in spaces { + let body = format!( + r#""# + ); + let d = read_body(&body); + // A path with formulas isn't drawn (they aren't evaluated). + if path.contains('@') { + assert!(paras(&d)[0].objects.iter().all(|o| !matches!(o, InlineObject::Shape { freeform: Some(_), .. })), "{path}"); + } + for o in ¶s(&d)[0].objects { + if let InlineObject::Shape { w, h, freeform: Some(f), .. } = o { + assert!(w.is_finite() && h.is_finite() && f.alpha.is_finite(), "{path}"); + assert!(f.paths.iter().flat_map(|p| &p.pts).all(|[x, y]| x.is_finite() && y.is_finite()), "{path} {size} {origin}"); + for (pts, _) in f.placed(0.0, 0.0, *w, *h) { + assert!(pts.iter().all(|[x, y]| x.is_finite() && y.is_finite()), "{path}"); + } + } + } + wordcraft_docx::write(&d).unwrap(); + } + } +} + /// Measured in Word: `w:asciiTheme="minorBidi"` names the theme's complex-script font (`a:cs`); /// when that is empty it names nothing, and the run keeps the font it inherits. #[test] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 2a82278e7..36a372739 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-14 · **Change:** trivial (multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously 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-11 · **Last updated:** 2026-10-14 · **Change:** trivial (VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously 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; @@ -82,7 +82,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | DrawingML shapes and text boxes (`wps:`) | ✅ | ✅ | Preset geometries subset (callouts and flowchart boxes drawn as the nearest shape, #611); `flowChartOr` draws a circle with horizontal and vertical spokes; rounded rectangles keep their own corner (`roundRect` `adj`: read, written back, drawn on screen and in PDF, #1476); fill and outline from the shape or from the theme through its style references (`a:fillRef`/`a:lnRef`, #611), scheme, preset, system, scRGB and HSL colours; rotation and flips (`a:xfrm` `rot`/`flipH`/`flipV`, pictures and groups too, #332), the effect extent covering the rotated bounds; `wps:bodyPr` `vert` and `anchor`, internal margins (`lIns`/`tIns`/`rIns`/`bIns`) and Resize shape to fit text (`a:spAutoFit`: the text is never cut, #743), linked text boxes (`wps:txbx/@id`, `wps:linkedTxbx` `id`/`seq`, #369); text warps (`a:prstTxWarp` with adjust values, all 41 presets kept; unknown ones dropped), connectors (`straightConnector1`, `bentConnector2`–`5`, `curvedConnector2`–`5` read; 3-segment forms written) with glued ends (`wps:cNvCnPr` `a:stCxn`/`a:endCxn`; the glued shapes keep their drawing ids) and arrowheads (`a:headEnd`/`a:tailEnd`, drawn as triangles), including custom-geometry paths (#416). Hidden drawings (`wp:docPr/@hidden`, a group member's `cNvPr/@hidden`) are kept and saved but neither drawn nor laid out, nor exported to other formats, as in Word for Windows; `.doc` and RTF shapes don't read their hidden flag yet | | Freeform shapes (`a:custGeom`: `a:moveTo`, `a:lnTo`, Bézier curves flattened) and WordCraft ink | ✅ | ✅ | Ink is written as a freeform `wps:wsp` in a `wp:anchor` (round caps, alpha for highlighter) and recognised again by its drawing name (#307). It is saved as a custom-geometry shape, not as Word's own ink (InkML in `w14:contentPart`), so Word shows WordCraft ink as a freeform shape it can move and recolour but not erase with its ink eraser; arcs drawn straight | | Group shapes (`wpg:`), drawing canvas (`wpc:`) | ✅ | ✅ | Groups (#267) and canvases (#344) with pictures, shapes, freeforms (custom geometry, #743; a path drawn back to its start without `a:close` is filled, as in Word) and text boxes; nested groups flattened. The canvas schema has no transform, so a turned or flipped canvas keeps its turn in our own `wpc:extLst` extension (`urn:wordcraft:canvas-xfrm`; Word shows it unturned). Legacy VML canvases (`v:group editas="canvas"`) aren't read as canvases | -| VML (`w:pict`, `v:shape`, `v:textbox`) | 🟡 | 🟡 | Pictures, text boxes, lines (with arrowheads), rectangles and ovals, best effort (#242, #611). Preset shapes (`v:shape` whose `o:spt`, own or from its `v:shapetype`, or Word's shapetype id `_x0000_tNN`, names a preset; [MS-ODRAW] §2.4.24 numbers) are drawn and saved like the DrawingML preset of the same shape: exactly for rectangle, rounded rectangle, ellipse, diamond, isosceles triangle, five-point star, right arrow, line and straight connector, heart, text box, and the flowchart process, decision and connector; the others as their nearest shape, as for DrawingML (left/up/down arrows as the arrow, right triangle as the triangle, callouts as their box). WordArt text shapes and picture frames without a picture aren't drawn. Fill, outline and size come from the shape, else its shapetype; text inside keeps the shape around it (#1377). Adjust values (`adj`) aren't read: presets use WordCraft's proportions. Text boxes keep their internal margins (`inset`, unless `o:insetmode="auto"`), Resize shape to fit text (`mso-fit-shape-to-text`) and vertical alignment (`v-text-anchor`). Hidden shapes (`visibility:hidden`, like DrawingML's `wp:docPr/@hidden`) are kept and saved but neither drawn nor laid out, as in Word for Windows (Word for Mac draws them). Not read: vertical text (`layout-flow`) Word's text watermark (a `v:textpath` shape in a header or footer, recognised by Word's `PowerPlusWaterMarkObject` id or by being centred on the page both ways) becomes the document's watermark: text, font, fill colour, semitransparent (`v:fill` `opacity`) and diagonal (any turn); drawn with WordCraft's own size and angle, on every page even when only some sections' headers (or only the first-page header) have it (#1290). Saving writes the watermark back the same way: a `v:textpath` shape centred on the margins, behind the text, at the start of every header's first paragraph; a section whose pages would show no header gets a header holding only the watermark (default, and first-page or even-page ones when those are on), while sections linked to an earlier header stay linked. Other VML is written only for OLE object pictures. Other VML WordArt (`v:textpath` in the body, or a header logo) is still dropped | +| VML (`w:pict`, `v:shape`, `v:textbox`) | 🟡 | 🟡 | Pictures, text boxes, lines (with arrowheads), rectangles and ovals, best effort (#242, #611). Preset shapes (`v:shape` whose `o:spt`, own or from its `v:shapetype`, or Word's shapetype id `_x0000_tNN`, names a preset; [MS-ODRAW] §2.4.24 numbers) are drawn and saved like the DrawingML preset of the same shape: exactly for rectangle, rounded rectangle, ellipse, diamond, isosceles triangle, five-point star, right arrow, line and straight connector, heart, text box, and the flowchart process, decision and connector; the others as their nearest shape, as for DrawingML (left/up/down arrows as the arrow, right triangle as the triangle, callouts as their box). WordArt text shapes and picture frames without a picture aren't drawn. Fill, outline and size come from the shape, else its shapetype; text inside keeps the shape around it (#1377). Adjust values (`adj`) aren't read: presets use WordCraft's proportions. Text boxes keep their internal margins (`inset`, unless `o:insetmode="auto"`), Resize shape to fit text (`mso-fit-shape-to-text`) and vertical alignment (`v-text-anchor`). Hidden shapes (`visibility:hidden`, like DrawingML's `wp:docPr/@hidden`) are kept and saved but neither drawn nor laid out, as in Word for Windows (Word for Mac draws them). Not read: vertical text (`layout-flow`) Word's text watermark (a `v:textpath` shape in a header or footer, recognised by Word's `PowerPlusWaterMarkObject` id or by being centred on the page both ways) becomes the document's watermark: text, font, fill colour, semitransparent (`v:fill` `opacity`) and diagonal (any turn); drawn with WordCraft's own size and angle, on every page even when only some sections' headers (or only the first-page header) have it (#1290). Saving writes the watermark back the same way: a `v:textpath` shape centred on the margins, behind the text, at the start of every header's first paragraph; a section whose pages would show no header gets a header holding only the watermark (default, and first-page or even-page ones when those are on), while sections linked to an earlier header stay linked. Freeforms (`v:shape` with its own `path`: moves, lines, Béziers and closes in its `coordsize`/`coordorigin` space, `nf`/`ns`, `v:stroke` opacity) are read as freeforms and saved as DrawingML custom geometry; fill opacity: none at 0, otherwise opaque (as for DrawingML fills); arcs are drawn straight; shapes whose path uses formulas (`@n`) and paths inherited from a `v:shapetype` aren't read. Other VML is written only for OLE object pictures. Other VML WordArt (`v:textpath` in the body, or a header logo) is still dropped | | 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`); a chart of only types not drawn yet (radar, stock, bubble, surface, 3-D, pie of pie) shows a placeholder in its frame with its title and type (#998). 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 | @@ -208,6 +208,7 @@ attributes inside stories and style or list definitions; a regenerated theme. Te | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | VML freeforms (`v:shape` with a `path`, such as rule lines in headers) read as freeforms and kept on save instead of being dropped | | 2026-10-14 | trivial | DOCX paragraphs inside complex-field instructions carry a hidden paragraph mark; cached result paragraphs remain visible. | | 2026-10-14 | trivial | DrawingML `flowChartOr` preserves its circle and crossing spokes, including when grouped or rotated | | 2026-10-14 | trivial | `minorBidi`/`majorBidi` theme fonts name the theme's complex-script font (`a:cs`); an empty one names nothing, so the inherited font applies, as measured in Word | diff --git a/docs/gaps.md b/docs/gaps.md index 37312ec74..7d5e74ab3 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-14 · **Change:** trivial (consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; 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-14 · **Change:** trivial (VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; 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 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -23,7 +23,7 @@ Document renaming: desktop renaming requires a filesystem that supports hard lin | # | 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. Field reports fixed one by one: a cover page's theme-coloured shapes (#611), freeforms inside groups and text-box margins and Resize to fit (#743), cover-page positions relative to the page and freeforms drawn back to their start (#743), Word's text watermark in headers, read and written (#1290), and `flowChartOr` drawn with its spokes (#1644) | 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. Field reports fixed one by one: a cover page's theme-coloured shapes (#611), freeforms inside groups and text-box margins and Resize to fit (#743), cover-page positions relative to the page and freeforms drawn back to their start (#743), Word's text watermark in headers, read and written (#1290), VML freeform rule lines in headers, and `flowChartOr` drawn with its spokes (#1644) | Everyone: the files people receive are the files that break | 30–50 | [file-format-parity.md](file-format-parity.md) | | 3 | **Crashes and freezes in the field**: startup crash on Intel UHD Graphics (#170, open), a crash on macOS 12.7.6 Intel (#979, open) (lag on GPUs without a DirectX 12 driver fixed, #316; 32-bit Windows exited at launch because DirectX 12 graphics pipeline creation failed, now OpenGL), freezes (#59 Thai fonts and #31 font selector, both open; #77 closed), broken shape tool (#142, open); the Windows 11 no-write-without-admin (#218) and unmovable images (#82) reports are closed (pictures and shapes in Behind Text drag through the text over them, #1313); memory growth on every font change fixed (#848: the font list no longer loads each family it previews) | B | 197 open issues (2026-10-12), among them the crash and freeze reports #170, #979, #59 and #31 | 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 only partially covered; no page-by-page comparison harness | B | Legacy same-style cell spacing preserved for measured modes 11/12/14, with mode 15 and non-cell controls; Contextual spacing corrected in body/header probes and repeated table-cell runs; [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps; stray labels on page-break-only list paragraphs fixed multi-paragraph field-code blank lines fixed | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-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, its optional picture fallback, and direct run-level content parts are preserved; ink nested in groups/canvases remains 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) | @@ -86,6 +86,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | #1: VML freeforms (rule lines in headers) read and kept on save | | 2026-10-14 | trivial | Consistent preferred cell widths | | 2026-10-14 | trivial | Pagination gap #4 narrowed: paragraph boundaries inside complex-field instructions add no flow height. Floating-table placement and other paragraph metrics remain open. | | 2026-10-14 | trivial | A list paragraph containing only a page break has no visible label; it still advances the list counter. Ordinary empty list items keep their label. | From 058fd1bacdbf9bbc3505e36d33640def18cb8108 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 22:28:53 -0400 Subject: [PATCH 067/101] CI: both recovery scan-error tests retry a scan a lock race skipped failed_write_keeps_last_valid_generation_and_discovery_falls_back hit the same race as #1849's test (a record locked for a moment by a child another test spawns is skipped, so scan() is Ok). Both now share a helper that reopens until the scan reports the unreadable record. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/recovery/desktop.rs | 32 +++++++++++++-------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/recovery/desktop.rs b/crates/engine/src/recovery/desktop.rs index 6a58ed76d..7e9a7ad54 100644 --- a/crates/engine/src/recovery/desktop.rs +++ b/crates/engine/src/recovery/desktop.rs @@ -558,6 +558,20 @@ mod tests { let _ = fs::remove_dir_all(&self.0); } } + /// A store reopened on `root` whose scan reports an unreadable record. The record's lock can be + /// briefly held by a process another test is starting (a forked child holds the descriptor + /// until it execs), and a record that can't be locked is skipped, not reported: so try again + /// for up to a second rather than read a skipped record as a clean scan. + fn reopen_reporting_unreadable(root: &std::path::Path) -> Store { + for _ in 0..50 { + let mut store = Store::new(root.to_path_buf()).unwrap(); + if store.scan().is_err() { + return store; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + panic!("the scan never reported the unreadable record"); + } fn snapshot(id: &str, text: &str) -> Snapshot { Snapshot { version: 1, @@ -641,8 +655,7 @@ mod tests { let second = store.entries[&id].path.clone(); fs::write(second, b"{truncated").unwrap(); drop(store); - let mut reopened = Store::new(t.0.clone()).unwrap(); - assert!(reopened.scan().is_err()); + let mut reopened = reopen_reporting_unreadable(&t.0); assert_eq!(reopened.load(&id).unwrap().document.plain_text(StoryRef::Body), "First valid copy"); // Never reuse the corrupt generation's name or prune the valid fallback. reopened.save(snapshot(&id, "Third valid copy")).unwrap(); @@ -683,20 +696,7 @@ mod tests { assert!(store.save(snapshot(&id, "New copy")).is_err()); assert_eq!(fs::read(first).unwrap(), bytes); drop(store); - // The record's lock can be briefly held by a process another test is starting (a forked - // child holds the descriptor until it execs), and a record that can't be locked is - // skipped, not reported: try again rather than read that as success. - let mut reopened = Store::new(t.0.clone()).unwrap(); - let mut scanned = reopened.scan(); - for _ in 0..50 { - if scanned.is_err() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(20)); - reopened = Store::new(t.0.clone()).unwrap(); - scanned = reopened.scan(); - } - assert!(scanned.is_err()); + let mut reopened = reopen_reporting_unreadable(&t.0); assert_eq!(reopened.load(&id).unwrap().document.plain_text(StoryRef::Body), "Last valid copy"); } From aa9c31fc69d0bce9bc6eb66387c04d2c9177c6e5 Mon Sep 17 00:00:00 2001 From: ripdog Date: Thu, 15 Oct 2026 15:54:25 +1300 Subject: [PATCH 068/101] Fix blurred text in Web and Draft Layout on long documents (#1152) * M8: keep long Web and Draft pages sharp with raster tiles * M8: document Web Layout blur fix with before-and-after screenshots --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 2 + crates/ui-egui/src/canvas.rs | 133 ++++++++++++++++++++++++++++++++--- docs/gaps.md | 1 + docs/ui-parity.md | 2 + 4 files changed, 127 insertions(+), 11 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index ca551d226..d8d36a1bd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -276,6 +276,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-13 | Web and Draft Layout now render visible full-resolution raster tiles for exceptionally tall pages rather than downsampling the entire page into one texture; verified with a 440-paragraph headless UI reproduction | | 2026-10-12 | Track Changes: Accept All after a tracked deletion from the end of one paragraph into a later one no longer joins the paragraph that follows the range, and Reject All after typing into an empty paragraph keeps that paragraph (#864) | | 2026-10-12 | Ctrl+drag copies an object (#1014): holding Ctrl (⌘ on macOS) while dragging a picture, shape or text box leaves the original and drops a copy where you let go (text boxes get their own text); the copy is selected, the clipboard is untouched, one undo step; `arrange.copyTo` | | 2026-10-11 | Table border drags like Word's (#711): Ctrl shares a column border's change among the columns right of it (the table keeps its width), the table's left edge moves a left-aligned table's indent (a centred or right-aligned table keeps its alignment, as in Word), a border of cells selected across moves only in their rows, and Alt shows the column widths on the ruler | @@ -388,6 +389,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-13 | trivial | Web/Draft long-page blur fixed with visible full-resolution raster tiles | | 2026-10-14 | trivial | Progress log: VML freeforms read and kept on save | | 2026-10-14 | trivial | Progress log: consistent preferred cell widths | | 2026-10-14 | trivial | Field instructions spanning paragraphs no longer add blank lines during pagination; empty result paragraphs still take a line. | diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 38e2b522f..8d2ebb320 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1,8 +1,8 @@ -//! The document canvas: pages on a grey background, rendered by the engine into textures (one -//! per page, re-rendered only when the page's content changes), with the caret, selection, +//! The document canvas: pages on a grey background, rendered into cached page textures (or +//! visible full-resolution tiles for exceptionally tall pages), with the caret, selection, //! rulers and mouse editing drawn by egui on top. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::hash::{Hash, Hasher}; use egui::{Color32, Pos2, Rect, Sense, Stroke, TextureHandle, Ui, pos2, vec2}; @@ -18,6 +18,8 @@ use crate::theme::{Tokens, regular, semibold}; pub const PX_PER_PT: f32 = 96.0 / 72.0; const GAP: f32 = 18.0; const RULER: f32 = 22.0; +/// Maximum device-pixel height of a tile on very tall Web/Draft pages. +const TILE_HEIGHT_PX: usize = 1024; /// Drag-select auto-scroll (#688): the pointer within this many points of the viewport's edge /// starts scrolling, like Word. @@ -33,6 +35,8 @@ const TEXTURE_KEEP_SCREENS: f32 = 3.0; pub struct CanvasState { textures: HashMap, + /// Visible full-resolution tiles of pages too tall for one GPU texture. + tiles: HashMap<(usize, usize), (u64, TextureHandle)>, /// Page content fingerprints of the current layout, for the texture cache (#811). pub(crate) page_keys: PageKeys, /// Whether the document has tracked formatting changes, moves and deletions, by document @@ -168,7 +172,7 @@ impl CanvasState { /// How many pages have a cached rendering. #[cfg(test)] pub(crate) fn texture_count(&self) -> usize { - self.textures.len() + self.textures.len() + self.tiles.len() } } @@ -176,6 +180,7 @@ impl Default for CanvasState { fn default() -> Self { CanvasState { textures: HashMap::new(), + tiles: HashMap::new(), page_keys: PageKeys::default(), markup_facts: std::cell::Cell::new(None), scroll_to_caret: true, @@ -698,6 +703,26 @@ fn texel_aligned_rect(layout: Rect, texture_px: egui::Vec2, ppp: f32) -> Rect { Rect::from_min_size(pos2(snap(layout.min.x), snap(layout.min.y)), texture_px / ppp) } +/// Tile indices intersecting the view, in a fixed grid of raster pixels. The grid follows the +/// document, not the viewport, so scrolling never changes tile contents or their cache keys. +fn visible_tiles(page_rect: Rect, clip: Rect, screen_scale: f32, raster_scale: f32, tile_height_px: usize) -> std::ops::Range { + if !page_rect.intersects(clip) + || !screen_scale.is_finite() + || !raster_scale.is_finite() + || screen_scale <= 0.0 + || raster_scale <= 0.0 + || tile_height_px == 0 + { + return 0..0; + } + let total_px = (page_rect.height() / screen_scale * raster_scale).ceil() as usize; + let count = total_px.div_ceil(tile_height_px); + let at = |y: f32| ((y - page_rect.min.y).clamp(0.0, page_rect.height()) / screen_scale * raster_scale) / tile_height_px as f32; + let first = (at(clip.top()).floor() as usize).min(count); + let last = (at(clip.bottom()).ceil() as usize).min(count); + first..last.max(first) +} + pub(crate) fn page_screen_scale(rect: Rect, page: &Page, fallback: f32) -> f32 { if page.w > 0.0 { rect.width() / page.w } else { fallback } } @@ -1012,7 +1037,8 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { } let painter = ui.painter_at(ui.clip_rect()); let ppp = ui.ctx().pixels_per_point(); - let max_tex = ui.ctx().input(|i| i.max_texture_side) as f32; + let max_tex = ui.ctx().input(|i| i.max_texture_side).min(wordcraft_render::MAX_SIDE as usize).max(1) as f32; + let mut visible_tile_keys = HashSet::new(); let mut rendered = 0; let t0 = crate::now_ms(); let mut rects = Vec::with_capacity(geo.rects.len()); @@ -1022,17 +1048,23 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { let sr = pr.translate(content_origin.to_vec2()); let Some(page) = layout.pages.get(i) else { continue }; let (band_top, band_bot) = geo.bands.get(i).copied().unwrap_or((0.0, sr.height())); - let scale_px = (geo.scale * ppp).min(max_tex / page.w.max(1.0)).min(max_tex / page.h.clamp(1.0, 1e6)).max(0.05); + // A Web/Draft page can be arbitrarily tall. Shrinking the whole page into one GPU + // texture makes the text blurry; split those pages into native-resolution tiles. + let width_scale = (geo.scale * ppp).min(max_tex / page.w.max(1.0)).max(0.05); + let tiled = page.h.clamp(1.0, 1e6) * width_scale > max_tex; + let scale_px = if tiled { width_scale } else { width_scale.min(max_tex / page.h.clamp(1.0, 1e6)).max(0.05) }; + let tile_height_px = TILE_HEIGHT_PX.min(max_tex as usize).max(1); // Hide White Space shows only a band of the page; everything below paints into it. let band_of = |r: Rect| Rect::from_min_max(pos2(r.min.x - 4.0, r.min.y + band_top), pos2(r.max.x + 4.0, r.min.y + band_bot)); let visible = band_of(sr).intersects(ui.clip_rect().expand(200.0)); // Only pages on screen or with a texture need their key (#811). - let cached = app.canvas.textures.contains_key(&i); - let key = if visible || cached { page_key(app, &layout, i, view, scale_px) } else { 0 }; + let cached = !tiled && app.canvas.textures.contains_key(&i); + let key = + if visible || cached { page_key(app, &layout, i, view, scale_px) ^ if tiled { (tile_height_px as u64) << 32 } else { 0 } } else { 0 }; let mut fresh = cached && app.canvas.textures.get(&i).is_some_and(|(k, _)| *k == key); // Render before placing the page: a page re-rendered in this frame (a zoom step, an // edit) then sits on the pixel grid in this frame too, rather than a frame later. - if visible && !fresh && (rendered < 2 || !app.canvas.textures.contains_key(&i) && rendered < 4) { + if visible && !tiled && !fresh && (rendered < 2 || !app.canvas.textures.contains_key(&i) && rendered < 4) { let opts = page_render_options(app, dim_body); let img = wordcraft_render::render_page(&app.session.doc, page, scale_px, &opts); let ci = page_image(&img); @@ -1048,7 +1080,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { } rendered += 1; fresh = true; - } else if visible && !fresh { + } else if visible && !tiled && !fresh { ui.ctx().request_repaint(); } let current_scale = geo.scale * ppp; @@ -1070,7 +1102,59 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { // Shadow and paper. painter.rect_filled(visual_sr.translate(vec2(0.0, 2.0)).expand(1.5), 1.0, t.page_shadow); painter.rect_filled(visual_sr, 0.0, if dark_page { Color32::from_gray(dark_paper) } else { Color32::WHITE }); - if let Some((_, tex)) = app.canvas.textures.get(&i) { + if tiled { + // Render before painting to keep the frame sharp immediately after an edit or + // zoom. Tiles are fixed to raster-pixel boundaries, independent of scrolling. + app.canvas.textures.remove(&i); + let total_px = (page.h.clamp(1.0, 1e6) * scale_px).ceil() as usize; + let screen_per_raster_px = geo.scale / scale_px; + let base = pos2((sr.min.x * ppp).round() / ppp, (sr.min.y * ppp).round() / ppp); + for tile in visible_tiles(sr, ui.clip_rect().expand(200.0).intersect(band), geo.scale, scale_px, tile_height_px) { + let start_px = tile.saturating_mul(tile_height_px); + let height_px = total_px.saturating_sub(start_px).min(tile_height_px); + if height_px == 0 { + continue; + } + let id = (i, tile); + visible_tile_keys.insert(id); + let fresh = app.canvas.tiles.get(&id).is_some_and(|(k, _)| *k == key); + if !fresh && rendered < 4 { + // The tiny subtraction avoids a 1px overshoot from round-tripping f32 + // pixels through page points and render_area's ceil() conversion. + let img = wordcraft_render::render_area( + &app.session.doc, + page, + 0.0, + start_px as f32 / scale_px, + page.w, + (height_px as f32 - 0.01).max(0.01) / scale_px, + scale_px, + &page_render_options(app, dim_body), + ); + let ci = page_image(&img); + match app.canvas.tiles.get_mut(&id) { + Some((k, tex)) => { + tex.set(ci, egui::TextureOptions::LINEAR); + *k = key; + } + None => { + let tex = ui.ctx().load_texture(format!("page{i}-tile{tile}"), ci, egui::TextureOptions::LINEAR); + app.canvas.tiles.insert(id, (key, tex)); + } + } + rendered += 1; + } else if !fresh { + ui.ctx().request_repaint(); + } + if let Some((_, tex)) = app.canvas.tiles.get(&id) { + let rect = Rect::from_min_size( + pos2(base.x, base.y + start_px as f32 * screen_per_raster_px), + vec2(tex.size_vec2().x * screen_per_raster_px, height_px as f32 * screen_per_raster_px), + ); + painter.image(tex.id(), rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + } + } else if let Some((_, tex)) = app.canvas.textures.get(&i) { painter.image(tex.id(), visual_sr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } let page_scale = page_screen_scale(visual_sr, page, geo.scale); @@ -1144,6 +1228,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { let clip = ui.clip_rect(); let near = clip.expand2(vec2(clip.width(), clip.height() * TEXTURE_KEEP_SCREENS)); app.canvas.textures.retain(|k, _| *k < n && geo.rects.get(*k).is_some_and(|r| r.translate(content_origin.to_vec2()).intersects(near))); + app.canvas.tiles.retain(|id, _| visible_tile_keys.contains(id)); app.canvas.page_rects = rects.clone(); let view_rect = ui.clip_rect(); if floating_comments(app) { @@ -4048,6 +4133,32 @@ mod tests { } } + #[test] + fn long_web_page_uses_only_visible_native_resolution_tiles() { + // A 17,843 pt Web Layout page at 100% on a 2x display would need ~47k raster + // rows. One 8192px texture would upscale the page over five times, blurring text. + let screen_scale = PX_PER_PT; + let raster_scale = screen_scale * 2.0; + let page = Rect::from_min_size(pos2(100.0, -10_000.0), vec2(800.0 * screen_scale, 17_843.0 * screen_scale)); + let viewport = Rect::from_min_size(pos2(0.0, 100.0), vec2(1440.0, 700.0)); + assert!(17_843.0 * raster_scale > 8192.0); + assert!(800.0 * raster_scale < 8192.0); + let tiles = visible_tiles(page, viewport, screen_scale, raster_scale, TILE_HEIGHT_PX); + assert!(!tiles.is_empty()); + assert!(tiles.len() <= 3, "only the viewport should be rasterized: {tiles:?}"); + let total_px = (17_843.0 * raster_scale).ceil() as usize; + for tile in tiles { + let start = tile * TILE_HEIGHT_PX; + assert!(start < total_px); + assert!(total_px.saturating_sub(start).min(TILE_HEIGHT_PX) <= TILE_HEIGHT_PX); + } + assert_eq!(visible_tiles(page, Rect::from_min_size(pos2(1500.0, 0.0), vec2(300.0, 500.0)), screen_scale, raster_scale, TILE_HEIGHT_PX), 0..0); + assert_eq!(visible_tiles(page, viewport, 0.0, raster_scale, TILE_HEIGHT_PX), 0..0); + assert_eq!(visible_tiles(page, viewport, screen_scale, f32::NAN, TILE_HEIGHT_PX), 0..0); + assert_eq!(visible_tiles(page, viewport, screen_scale, raster_scale, 0), 0..0); + assert!(visible_tiles(page, viewport, screen_scale, raster_scale, 512).len() <= 5); + } + #[test] fn texel_aligned_rect_snaps_the_origin_and_sizes_from_texture_pixels() { let layout = Rect::from_min_size(pos2(10.25, 20.75), vec2(80.0, 120.0)); diff --git a/docs/gaps.md b/docs/gaps.md index 7d5e74ab3..a9af56718 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -86,6 +86,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | Date | Change | Summary | |---|---|---| +| 2026-10-13 | trivial | Previously untracked long Web/Draft page blur resolved with visible high-resolution raster tiling; no gap remains for this issue | | 2026-10-14 | trivial | #1: VML freeforms (rule lines in headers) read and kept on save | | 2026-10-14 | trivial | Consistent preferred cell widths | | 2026-10-14 | trivial | Pagination gap #4 narrowed: paragraph boundaries inside complex-field instructions add no flow height. Floating-table placement and other paragraph metrics remain open. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index bc59368ed..836e272b2 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -34,6 +34,7 @@ Document title opens Rename Document; the account badge opens Options › Genera | Headers and footers on the page: double-click the top or bottom margin to edit them, the body to leave | ✅ in Print Layout, also on a page with none yet: the header or footer that page shows is created (first page, even page or the default) and the caret put in it; one left empty is taken away again on leaving and is never saved (#960). Making one is an undo step, refused in a protected document. On a later section's page with no header anywhere it goes in the first section and shows on every section linked to it, as Word links to the previous section by default. Web Layout, Draft and Read Mode show no margins and leave it alone | ✅ | 95% | 0.5 | | Zoom: Ctrl+scroll, pinch (#179), zoom buttons (#232), pages side by side when zoomed out (#251) | ✅ | ✅ | 85% | — | | Touchpad smooth/inertial scroll | ✅ (#252) | ✅ | 90% | — | +| Web and Draft Layout rendering | Very long pages render only visible full-resolution raster tiles instead of shrinking one huge texture to the GPU size limit; nearby tiles are cached for scrolling | sharp document text | 95% | — | | 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, by name; a click sets the proofing language, or opens Set Proofing Language… for any other) | ✅ | 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. The caret is black or white by contrast with what lies under it (paper, page colour, dark page, shading, highlight), never by the UI theme (#299) | ✅ 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 | @@ -51,6 +52,7 @@ Document title opens Rename Document; the account badge opens Options › Genera | Date | Change | Summary | |---|---|---| +| 2026-10-13 | trivial | Web/Draft Layout long-page blur fixed using visible full-resolution raster tiles, tested against a 440-paragraph document | | 2026-10-14 | trivial | A click on the ribbon's chrome that isn't a command button (a Styles gallery tile, a tab name, empty ribbon space, a View check box, a status-bar item) hands the keyboard to the page in the same frame, as a ribbon command button does: typing right after the click reaches the document instead of being swallowed (#1093 follow-up) | | 2026-10-13 | trivial | Page Setup: a Vertical alignment box (Top, Center, Justified, Bottom), applied on OK only if changed (#1320) | | 2026-10-13 | trivial | macOS shortcuts as in Word for Mac: ⌘⇧S Save As (Apply Styles keeps Ctrl+Shift+S elsewhere), ⌘⌥E Insert Endnote (macOS takes ⌘⌥D for the Dock), ⌘⌥A New Comment, ⌃H Replace, ⌘⌥R collapse the ribbon | From 4062db0ac2928ab58e3794f8017a81032cd6036f Mon Sep 17 00:00:00 2001 From: Raj Patel Date: Wed, 14 Oct 2026 21:01:17 -0600 Subject: [PATCH 069/101] Shortcuts: more of Word's default keys (Insert-key clipboard, F2 file keys, field and outline keys) (gap #24) (#1166) --- ROADMAP.md | 2 ++ crates/engine/src/cmd/edit.rs | 8 ++++---- crates/engine/src/cmd/file.rs | 8 ++++---- crates/engine/src/cmd/format.rs | 1 + crates/engine/src/cmd/inspector.rs | 1 + crates/engine/src/cmd/outline.rs | 1 + crates/engine/src/cmd/references.rs | 1 + crates/engine/src/cmd/review.rs | 4 +++- crates/engine/src/cmd/tools.rs | 6 +++++- crates/engine/src/tests.rs | 19 +++++++++++++++++++ docs/gaps.md | 3 ++- docs/ui-parity.md | 1 + 12 files changed, 44 insertions(+), 11 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d8d36a1bd..e8be2db0c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -276,6 +276,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 | Word default shortcuts expanded (gap #24): 140 commands bound on macOS (169 combinations), 158 on Linux/Windows (188 combinations). Bound Style Inspector (Shift+F1), Macros (Alt+F8), Strikethrough (Mod+Shift+X), Find Next (Shift+F4), Copy/Paste (Mod+Insert, Shift+Insert), Go Back (Mod+Alt+Z), File Open/Save/Save As/Print (Mod+Alt+F2, Alt+Shift+F2, Alt+F2, Mod+Alt+I), Field navigation (Alt+F1, Alt+Shift+F1), Outline show first line (Alt+Shift+L), Next Footnote (Alt+Shift+>), Link to Previous (Alt+Shift+R), AutoText (Alt+F3), Spelling on Mac (Mod+Alt+L) | | 2026-10-13 | Web and Draft Layout now render visible full-resolution raster tiles for exceptionally tall pages rather than downsampling the entire page into one texture; verified with a 440-paragraph headless UI reproduction | | 2026-10-12 | Track Changes: Accept All after a tracked deletion from the end of one paragraph into a later one no longer joins the paragraph that follows the range, and Reject All after typing into an empty paragraph keeps that paragraph (#864) | | 2026-10-12 | Ctrl+drag copies an object (#1014): holding Ctrl (⌘ on macOS) while dragging a picture, shape or text box leaves the original and drops a copy where you let go (text boxes get their own text); the copy is selected, the clipboard is untouched, one undo step; `arrange.copyTo` | @@ -389,6 +390,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Progress log: Word default shortcuts expanded to 140 commands on macOS (169 combinations) and 158 on Linux/Windows (188 combinations), addressing gap #24 | | 2026-10-13 | trivial | Web/Draft long-page blur fixed with visible full-resolution raster tiles | | 2026-10-14 | trivial | Progress log: VML freeforms read and kept on save | | 2026-10-14 | trivial | Progress log: consistent preferred cell widths | diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index c7af00d18..4213967a6 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -60,8 +60,8 @@ pub fn specs() -> Vec { .key("Mod+Y / Mod+Shift+Z") .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, "html"?: string, "rtf"?: string}"#), + CommandSpec::new("edit.copy", "Copy", "Home › Clipboard", copy).key("Mod+C / Mod+Insert").pure(), + CommandSpec::new("edit.paste", "Paste", "Home › Clipboard", paste).key("Mod+V / Shift+Insert").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"), @@ -103,7 +103,7 @@ pub fn specs() -> Vec { read_opts(s, v); step(s, 1) }) - .key("Mod+G / F3 / Mod+PageDown") + .key("Mod+G / F3 / Shift+F4 / Mod+PageDown") .params(r#"{"text"?: string, "matchCase"?: bool, "wholeWord"?: bool, "regex"?: bool}"#) .pure(), CommandSpec::new("edit.findPrevious", "Find Previous", "Home › Editing › Find", |s, v| { @@ -141,7 +141,7 @@ pub fn specs() -> Vec { s.goal_x = None; sel_result(s) }) - .key("Shift+F5") + .key("Shift+F5 / Mod+Alt+Z") .pure(), CommandSpec::new("edit.formatPainter", "Format Painter", "Home › Clipboard", painter).params(r#"{"sticky"?: bool}"#).pure(), CommandSpec::new("edit.copyFormat", "Copy Formatting", "Home › Clipboard", |s, _| { diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index eafca9c3f..6c5d9fd66 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -66,14 +66,14 @@ pub fn specs() -> Vec { CommandSpec::new("file.rename", "Rename Document", "File › Info", rename).params(r#"{"name": string (document name without its extension)}"#).pure(), CommandSpec::new("file.new", "New", "File", new).key("Mod+N").params(r#"{"template"?: "blank|sample|letter|resume|report"}"#).pure(), CommandSpec::new("file.open", "Open", "File", open) - .key("Mod+O / Mod+F12") + .key("Mod+O / Mod+F12 / Mod+Alt+F2") .params(r#"{"path": string, "data"?: base64 (the file's bytes; `path` then only names it), "password"?: string (for a password-protected Word document)}"#) .pure(), CommandSpec::new("file.save", "Save", "File", save) - .key("Mod+S / Shift+F12") + .key("Mod+S / Shift+F12 / Alt+Shift+F2") .params(r#"{"path"?: string, "password"?: string (encrypt this and later saves with it, as file.encrypt does), "markup"?: bool (Print Markup for a PDF, PNG, HTML, Markdown or text copy: comments shown; default file.printMarkup), "format"?: "wordml2003" (an .xml file as a Word 2003 XML Document) | "flatopc" (as a Word XML Document, the default; saving over a Word 2003 XML file keeps that format)}"#) .pure(), - CommandSpec::new("file.saveAs", "Save As", "File", save_as).key(super::mac_or("F12 / Mod+Shift+S", "F12")).params(r#"{"path": string, "password"?: string, "markup"?: bool, "format"?: string (as file.save)}"#).pure(), + CommandSpec::new("file.saveAs", "Save As", "File", save_as).key(super::mac_or("F12 / Mod+Shift+S", "F12 / Alt+F2")).params(r#"{"path": string, "password"?: string, "markup"?: bool, "format"?: string (as file.save)}"#).pure(), // Pure: the password must not land in Repeat or a recorded macro, and it isn't an edit // to undo; it marks the document as changed itself. CommandSpec::new("file.encrypt", "Encrypt with Password", "File › Info › Protect Document", encrypt) @@ -107,7 +107,7 @@ pub fn specs() -> Vec { s.ui_requests.push(json!({"open": "print"})); sel_result(s) }) - .key("Mod+P / Mod+Shift+F12 / Mod+F2") + .key("Mod+P / Mod+Shift+F12 / Mod+F2 / Mod+Alt+I") .pure(), CommandSpec::new("file.close", "Close", "File", |s, _| { s.ui_requests.push(json!({"close": true})); diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index 3a36c99be..8bbeba508 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -53,6 +53,7 @@ pub fn specs() -> Vec { }) .key("Mod+Shift+W"), CommandSpec::new("format.strikethrough", "Strikethrough", "Home › Font", |s, v| toggle(s, v, |r| r.strike, |c, on| c.strike = Some(on))) + .key("Mod+Shift+X") .params(r#"{"value"?: bool}"#), // Note: off clears the direct property only; a border inherited from a character style stays on. CommandSpec::new("format.border", "Character Border", "Home › Font", |s, v| { diff --git a/crates/engine/src/cmd/inspector.rs b/crates/engine/src/cmd/inspector.rs index 9aae42f49..894e25d9d 100644 --- a/crates/engine/src/cmd/inspector.rs +++ b/crates/engine/src/cmd/inspector.rs @@ -28,6 +28,7 @@ pub fn specs() -> Vec { Ok(out) }) .params(r#"{"value"?: bool (show/hide the pane)}"#) + .key("Shift+F1") .pure(), CommandSpec::new("styles.inspect", "Inspect Styles at Selection", "Home › Styles › Style Inspector", |s, _| Ok(inspect(s))).pure(), CommandSpec::new("styles.inspectorClear", "Clear Formatting Level", "Home › Styles › Style Inspector", clear) diff --git a/crates/engine/src/cmd/outline.rs b/crates/engine/src/cmd/outline.rs index b026eacd6..ea13fc1db 100644 --- a/crates/engine/src/cmd/outline.rs +++ b/crates/engine/src/cmd/outline.rs @@ -157,6 +157,7 @@ pub fn specs() -> Vec { s.view.outline.first_line_only = p::bool(v, "value").unwrap_or(!s.view.outline.first_line_only); Ok(json!({"value": s.view.outline.first_line_only})) }) + .key(super::non_mac("Alt+Shift+L")) .params(r#"{"value"?: bool}"#) .when(in_outline) .pure(), diff --git a/crates/engine/src/cmd/references.rs b/crates/engine/src/cmd/references.rs index 10ce79bdd..656afd894 100644 --- a/crates/engine/src/cmd/references.rs +++ b/crates/engine/src/cmd/references.rs @@ -59,6 +59,7 @@ pub fn specs() -> Vec { } sel_result(s) }) + .key(super::non_mac("Alt+Shift+> / Alt+Shift+.")) .pure(), CommandSpec::new("references.notes", "Show Notes", "References › Footnotes", |s, _| { let mut out = Vec::new(); diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index 9bda63c02..43041df60 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -54,7 +54,9 @@ pub fn specs() -> Vec { .key(super::non_mac("Mod+Shift+G")) .pure(), CommandSpec::new("review.changes", "Reviewing Pane", "Review › Tracking", list_changes).pure(), - CommandSpec::new("review.spelling", "Spelling & Grammar", "Review › Proofing", next_issue).key("F7 / Alt+F7").pure(), + CommandSpec::new("review.spelling", "Spelling & Grammar", "Review › Proofing", next_issue) + .key(super::mac_or("F7 / Alt+F7 / Mod+Alt+L", "F7 / Alt+F7")) + .pure(), CommandSpec::new("review.issues", "Proofing Issues", "Review › Proofing", all_issues).pure(), CommandSpec::new("review.suggestions", "Spelling Suggestions", "Review › Proofing", suggestions).params(r#"{"pos"?: Pos}"#).pure(), CommandSpec::new("review.addToDictionary", "Add to Dictionary", "Review › Proofing", |s, v| { diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index c5416437a..504cc97c3 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -361,6 +361,7 @@ pub fn specs() -> Vec { .params(r#"{"name"?: string} (call again to stop)"#) .pure(), CommandSpec::new("tools.macros", "Macros", "View › Macros", macros) + .key("Alt+F8") .params(r#"{"run"?: name, "define"?: {"name": string, "steps": [{"command", "params"}]}, "delete"?: name}"#) .pure(), CommandSpec::new("tools.autocorrect", "AutoCorrect Options", "File › Options › Proofing", autocorrect_options) @@ -456,7 +457,9 @@ pub fn specs() -> Vec { .pure(), CommandSpec::new("insert.quickParts", "Quick Parts", "Insert › Text", quick_parts) .params(r#"{"save"?: name (from the selection), "insert"?: name, "delete"?: name} → list"#), - CommandSpec::new("insert.autoText", "AutoText", "Insert › Text › Quick Parts", quick_parts).params(r#"{"save"?: name, "insert"?: name}"#), + CommandSpec::new("insert.autoText", "AutoText", "Insert › Text › Quick Parts", quick_parts) + .key(super::non_mac("Alt+F3")) + .params(r#"{"save"?: name, "insert"?: name}"#), CommandSpec::new("insert.docProperty", "Document Property", "Insert › Text › Quick Parts", |s, v| { let name = p::str(v, "name").unwrap_or("Title").to_string(); let c = &s.doc.core; @@ -534,6 +537,7 @@ pub fn specs() -> Vec { } Ok(json!({"linked": true})) }) + .key(super::non_mac("Alt+Shift+R")) .params(r#"{"footer"?: bool}"#), CommandSpec::new("hf.next", "Next Section", "Header & Footer › Navigation", |s, _| hf_nav(s, 1)).pure(), CommandSpec::new("hf.previous", "Previous Section", "Header & Footer › Navigation", |s, _| hf_nav(s, -1)).pure(), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 72e112823..7fd1afd7b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -117,6 +117,17 @@ fn word_default_shortcuts_resolve() { assert_eq!(id(cmd::mac_or("Ctrl+Q", "Mod+Q")), Some("para.reset")); assert_eq!(id(cmd::mac_or("Ctrl+Space", "Mod+Space")), Some("format.resetChar")); assert_eq!(id(cmd::mac_or("Ctrl+Shift+D", "Alt+Shift+D")), Some("insert.dateField")); + assert_eq!(id("Shift+F1"), Some("styles.inspector")); + assert_eq!(id("Alt+F8"), Some("tools.macros")); + assert_eq!(id("Mod+Shift+X"), Some("format.strikethrough")); + assert_eq!(id("Shift+F4"), Some("edit.findNext")); + assert_eq!(id("Mod+Insert"), Some("edit.copy")); + assert_eq!(id("Shift+Insert"), Some("edit.paste")); + assert_eq!(id("Mod+Alt+Z"), Some("edit.goBack")); + assert_eq!(id("Mod+Alt+F2"), Some("file.open")); + assert_eq!(id("Alt+Shift+F2"), Some("file.save")); + assert_eq!(id("Mod+Alt+I"), Some("file.print")); + assert_eq!(id("Alt+Shift+F1"), Some("fields.previous")); if cfg!(target_os = "macos") { assert_eq!(id("Mod+Shift+G"), Some("edit.findPrevious")); // Word for Mac's own keys: ⌘⇧S Save As, ⌘⌥E endnote (⌘⌥D hides the Dock), ⌘⌥A comment, ⌃H Replace. @@ -125,6 +136,7 @@ fn word_default_shortcuts_resolve() { assert_eq!(id("Mod+Alt+D"), None); assert_eq!(id("Mod+Alt+A"), Some("review.newComment")); assert_eq!(id("Ctrl+H"), Some("edit.findReplace")); + assert_eq!(id("Mod+Alt+L"), Some("review.spelling")); } else { assert_eq!(id("Mod+Shift+S"), Some("styles.apply")); assert_eq!(id("Mod+Alt+D"), Some("references.endnote")); @@ -132,6 +144,13 @@ fn word_default_shortcuts_resolve() { assert_eq!(id("Mod+Shift+G"), Some("review.wordCount")); assert_eq!(id("Mod+Alt+C"), Some("text.copyright")); assert_eq!(id("Mod+Alt+H"), Some("format.highlight")); + assert_eq!(id("Alt+Shift+L"), Some("outline.firstLineOnly")); + assert_eq!(id("Alt+Shift+>"), Some("references.nextFootnote")); + assert_eq!(id("Alt+Shift+."), Some("references.nextFootnote")); + assert_eq!(id("Alt+Shift+R"), Some("layout.linkToPrevious")); + assert_eq!(id("Alt+F3"), Some("insert.autoText")); + assert_eq!(id("Alt+F2"), Some("file.saveAs")); + assert_eq!(id("Alt+F1"), Some("fields.next")); } assert_eq!(id("Mod+PageUp"), Some("edit.findPrevious")); assert_eq!(id("F12"), Some("file.saveAs")); diff --git a/docs/gaps.md b/docs/gaps.md index a9af56718..37b5a197b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -46,7 +46,7 @@ Document renaming: desktop renaming requires a filesystem that supports hard lin | 21 | **Mail merge data sources**: no Outlook/Contacts, Access or database sources, no email merge; CSV, typed lists (#247) and Excel/OpenDocument spreadsheets (#334, legacy .xls #778) only | | Select Recipients reads .csv/.tsv/.txt in any common encoding (UTF-8/16, GBK, Big5, Shift_JIS…), .xlsx/.xlsm, .xls and .ods; Edit Recipient List filters and sorts (#778) | Office administrators | 4–8 | [target-app-parity.md](target-app-parity.md) | | 22 | **Ecosystem**: no VBA execution (macros preserved only), no Office add-ins, no cloud storage, no real-time co-authoring | | `.docm` keeps macros (#172) | Enterprise users with macro templates; teams | 40–80 + owner decisions | [target-app-parity.md](target-app-parity.md) | | 23 | **PDF Reflow** (open a PDF as an editable document, Word for Windows) | | | Users who edit PDFs they receive | 30–50 | [file-format-parity.md](file-format-parity.md) | -| 24 | **Default shortcuts**: ~135 of Word's ~250 (#330, #339, #343; Customize Keyboard #368, Customize Ribbon and the Quick Access Toolbar #379 landed) | | [ui-parity.md](ui-parity.md) | Power users | 4–7 | [ui-parity.md](ui-parity.md) | +| 24 | **Default shortcuts**: 140 (macOS) / 158 (Linux/Windows) of Word's ~250 (#330, #339, #343; Customize Keyboard #368, Customize Ribbon and Quick Access Toolbar #379; inspector, macros, strikethrough, file/edit keys) | | [ui-parity.md](ui-parity.md) | Power users | 1–3 | [ui-parity.md](ui-parity.md) | | 25 | **In-app AI assistant** (Copilot-style draft, rewrite, summarize) | | Chat PR #178 and AI assistant PR #815 open | Users expecting Copilot | 15–30 + owner (provider) | [target-app-parity.md](target-app-parity.md) | | 27 | **View windows**: Side by Side, Synchronous Scrolling, Arrange All, Switch Windows | | [parity-checklist.md](parity-checklist.md) (View 25/29); PR #331 open | Comparing documents | 3–5 | [ui-parity.md](ui-parity.md) | | 26 | **Minor formats**: MHT, Works/WordPerfect | | Word XML Document (Flat OPC) opens and saves, Word 2003 XML opens (#548) and saves (#1081, not yet checked in Word) | Rare | 14–26 | [file-format-parity.md](file-format-parity.md) | @@ -86,6 +86,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Gap #24 default shortcuts expanded (140 commands on macOS / 158 on Linux/Windows); stale gaps #9 (DOCX agile encryption already works, only encrypted .doc missing) and #15 (DOCX bibliography sources already saved, only desktop integrations missing) updated | | 2026-10-13 | trivial | Previously untracked long Web/Draft page blur resolved with visible high-resolution raster tiling; no gap remains for this issue | | 2026-10-14 | trivial | #1: VML freeforms (rule lines in headers) read and kept on save | | 2026-10-14 | trivial | Consistent preferred cell widths | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 836e272b2..bbbb25af8 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -52,6 +52,7 @@ Document title opens Rename Document; the account badge opens Options › Genera | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Default keyboard shortcuts expanded to 140 commands on macOS (169 combinations) and 158 on Linux/Windows (188 combinations): Style Inspector (Shift+F1), Macros (Alt+F8), Strikethrough (Mod+Shift+X), Find Next (Shift+F4), Copy/Paste Insert keys (Mod+Insert, Shift+Insert), Go Back (Mod+Alt+Z), File Open/Save/Save As/Print (Mod+Alt+F2, Alt+Shift+F2, Alt+F2, Mod+Alt+I), Field navigation (Alt+F1, Alt+Shift+F1), Outline show first line (Alt+Shift+L), Next Footnote (Alt+Shift+>), Link to Previous (Alt+Shift+R), AutoText (Alt+F3), Spelling on Mac (Mod+Alt+L) | | 2026-10-13 | trivial | Web/Draft Layout long-page blur fixed using visible full-resolution raster tiles, tested against a 440-paragraph document | | 2026-10-14 | trivial | A click on the ribbon's chrome that isn't a command button (a Styles gallery tile, a tab name, empty ribbon space, a View check box, a status-bar item) hands the keyboard to the page in the same frame, as a ribbon command button does: typing right after the click reaches the document instead of being swallowed (#1093 follow-up) | | 2026-10-13 | trivial | Page Setup: a Vertical alignment box (Top, Center, Justified, Bottom), applied on OK only if changed (#1320) | From 3ef4d1fb9af31537146354ab4fc49d557b0ace96 Mon Sep 17 00:00:00 2001 From: emre-clk91 Date: Thu, 15 Oct 2026 06:06:58 +0300 Subject: [PATCH 070/101] feat(print): add orientation support and pass landscape options to spooler (#1389) Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- apps/wordcraft/src/print.rs | 4 +-- crates/ui-egui/src/print_page.rs | 24 +++++++++++++-- crates/ui-egui/src/printing.rs | 24 ++++++++++++--- crates/ui-egui/tests/native_print.rs | 45 ++++++++++++++++++++++++---- 4 files changed, 84 insertions(+), 13 deletions(-) diff --git a/apps/wordcraft/src/print.rs b/apps/wordcraft/src/print.rs index 978bdc450..de52a6360 100644 --- a/apps/wordcraft/src/print.rs +++ b/apps/wordcraft/src/print.rs @@ -26,7 +26,7 @@ pub fn finish_pending_jobs() { } pub fn submit() -> Submit { - Box::new(|bytes, settings: Settings, collate| { + Box::new(|bytes, settings: Settings, collate, options| { let (sender, receiver) = std::sync::mpsc::channel(); let worker = std::thread::Builder::new().name("print-submit".into()).spawn(move || { let job = craft_print::Job { @@ -41,7 +41,7 @@ pub fn submit() -> Submit { grayscale: settings.grayscale, scaling: craft_print::Scaling::ShrinkToFit, title: "WordCraft document".into(), - ..Default::default() + options, }; let _ = sender.send(craft_print::submit(&bytes, &job).map_err(|error| error.to_string())); }); diff --git a/crates/ui-egui/src/print_page.rs b/crates/ui-egui/src/print_page.rs index 4cbd70260..490b73868 100644 --- a/crates/ui-egui/src/print_page.rs +++ b/crates/ui-egui/src/print_page.rs @@ -25,20 +25,37 @@ pub struct PrintSettings { pub collate: bool, /// The page shown in the preview (0-based). pub preview: usize, + /// `auto`, `portrait` or `landscape`. + pub orientation: String, /// The preview's zoom (1.0 is the page's real size), or `None` to fit the whole page. pub zoom: Option, } impl Default for PrintSettings { fn default() -> Self { - PrintSettings { range: "all".into(), pages: String::new(), parity: "all".into(), copies: 1, collate: true, preview: 0, zoom: None } + PrintSettings { + range: "all".into(), + pages: String::new(), + parity: "all".into(), + copies: 1, + collate: true, + preview: 0, + zoom: None, + orientation: "auto".into(), + } } } impl PrintSettings { /// The params of `ui.print` and `file.printPdf` for these settings. pub fn params(&self) -> Value { - let mut v = json!({"range": self.range, "parity": self.parity, "copies": self.copies, "collate": self.collate}); + let mut v = json!({ + "range": self.range, + "parity": self.parity, + "copies": self.copies, + "collate": self.collate, + "orientation": self.orientation, + }); if self.range == "custom" { v["pages"] = json!(self.pages); } @@ -48,6 +65,7 @@ impl PrintSettings { const RANGES: [(&str, &str); 4] = [("all", "All pages"), ("current", "Current page"), ("selection", "Selected pages"), ("custom", "Custom range")]; const PARITIES: [(&str, &str); 3] = [("all", "All pages in the range"), ("odd", "Odd pages only"), ("even", "Even pages only")]; +const ORIENTATIONS: [(&str, &str); 3] = [("auto", "Auto orientation"), ("portrait", "Portrait"), ("landscape", "Landscape")]; /// A choice list over `(id, label)` pairs; true when the choice changed. fn choice(ui: &mut Ui, salt: &str, current: &mut String, items: &[(&str, &str)]) -> bool { @@ -178,6 +196,8 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { ); } choice(ui, "print_parity", &mut app.ui.print.parity, &PARITIES); + ui.label(egui::RichText::new(tl!("Orientation")).font(medium(13.0))); + choice(ui, "print_orientation", &mut app.ui.print.orientation, &ORIENTATIONS); match wordcraft_engine::print::plan(&app.ui.print.params(), total, current) { Ok(plan) => { diff --git a/crates/ui-egui/src/printing.rs b/crates/ui-egui/src/printing.rs index 5fe61f5b6..d9c58803b 100644 --- a/crates/ui-egui/src/printing.rs +++ b/crates/ui-egui/src/printing.rs @@ -2,14 +2,14 @@ use craft_ui::printing::Labels; pub use craft_ui::printing::{Discover, Duplex, Feedback, Printer, Settings, State}; -/// Native submission adds WordCraft's PDF-page collation setting to the shared spooler settings. -pub type Submit = Box, Settings, bool) -> std::sync::mpsc::Receiver>>; +/// Native submission adds WordCraft's PDF-page collation setting and spooler options to the shared spooler settings. +pub type Submit = Box, Settings, bool, Vec<(String, String)>) -> std::sync::mpsc::Receiver>>; /// Metadata for the app-owned commands. Inspecting it never discovers printers or submits. pub fn commands(app: &crate::WordApp) -> Vec { use serde_json::json; let enabled = if app.services.print_native.is_some() { app.printing.ready() } else { app.services.print.is_some() }; - let submit_params = "range?: all|current|selection|custom; pages?: string (1-3,5); parity?: all|odd|even; copies?: integer 1..999; collate?: bool. Native uses the selected printer, duplex and grayscale from ui.printSettings; omitted copies/collation use current settings. Browser printing opens its dialog. Submits only when explicitly executed."; + let submit_params = "range?: all|current|selection|custom; pages?: string (1-3,5); parity?: all|odd|even; copies?: integer 1..999; collate?: bool; orientation?: auto|portrait|landscape. Native uses the selected printer, duplex and grayscale from ui.printSettings; omitted copies/collation use current settings. Browser printing opens its dialog. Submits only when explicitly executed."; vec![ json!({"id":"ui.printSettings", "label":"Print settings", "location":"File › Print", "shortcut":null, "params":"printer?: discovered name; copies?: integer 1..999; duplex?: printerDefault|off|longEdge|shortEdge|null; grayscale?: bool; refresh?: bool. Empty params inspect printer/copies/duplex/grayscale, printers, busy/discovering/ready and status/detail. Updates are atomic; refresh discovers only on native hosts. Never submits or opens a dialog.", @@ -156,7 +156,23 @@ pub fn submit(app: &mut crate::WordApp, params: &serde_json::Value) -> Result p.h) + }; + if is_landscape { + print_options.push(("orientation-requested".into(), "4".into())); + print_options.push(("landscape".into(), "true".into())); + } else { + print_options.push(("orientation-requested".into(), "3".into())); + } + app.printing.begin(submit(bytes, settings, collate, print_options)).map_err(|_| "A print job is already being submitted.".to_owned())?; app.print_feedback_seen = None; } else if let Some(print) = &app.services.print { print(&bytes)?; diff --git a/crates/ui-egui/tests/native_print.rs b/crates/ui-egui/tests/native_print.rs index 7ea822e46..68f9e50c6 100644 --- a/crates/ui-egui/tests/native_print.rs +++ b/crates/ui-egui/tests/native_print.rs @@ -19,7 +19,7 @@ fn control_print_discovery_reports_metadata_without_host_side_effects() { receiver })); let observed = submissions.clone(); - app.services.print_native = Some(Box::new(move |_, _, _| { + app.services.print_native = Some(Box::new(move |_, _, _, _| { observed.set(observed.get() + 1); let (_, receiver) = std::sync::mpsc::channel(); receiver @@ -61,7 +61,7 @@ fn native_print_uses_selected_settings_and_reports_spool_errors() { app.run("text.insert", json!({"text": "A document to print"})).unwrap(); let jobs = Rc::new(RefCell::new(Vec::new())); let observed = jobs.clone(); - app.services.print_native = Some(Box::new(move |bytes, settings, collate| { + app.services.print_native = Some(Box::new(move |bytes, settings, collate, _| { observed.borrow_mut().push((bytes, settings, collate)); let (sender, receiver) = std::sync::mpsc::channel(); sender.send(Err("printer is offline".into())).unwrap(); @@ -99,7 +99,7 @@ fn print_settings_are_visible_in_a_narrow_window() { sender.send(vec![Printer { name: "Test queue".into(), default: true }]).unwrap(); receiver })); - app.services.print_native = Some(Box::new(|_, _, _| { + app.services.print_native = Some(Box::new(|_, _, _, _| { let (sender, receiver) = std::sync::mpsc::channel(); sender.send(Ok("queued".into())).unwrap(); receiver @@ -150,7 +150,7 @@ fn repeated_fast_jobs_each_surface_an_identical_receipt() { let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()); app.printing.printers.push(Printer { name: "Queue".into(), default: true }); app.run("ui.printSettings", json!({"printer":"Queue"})).unwrap(); - app.services.print_native = Some(Box::new(|_, _, _| { + app.services.print_native = Some(Box::new(|_, _, _, _| { let (sender, receiver) = std::sync::mpsc::channel(); sender.send(Ok("same receipt".into())).unwrap(); receiver @@ -186,7 +186,7 @@ fn native_ranges_and_parity_do_not_duplicate_spooler_copies() { app.run("ui.printSettings", json!({"printer":"Queue","copies":3})).unwrap(); let jobs = Rc::new(RefCell::new(Vec::new())); let observed = jobs.clone(); - app.services.print_native = Some(Box::new(move |bytes, settings, collate| { + app.services.print_native = Some(Box::new(move |bytes, settings, collate, _| { observed.borrow_mut().push((bytes, settings, collate)); let (sender, receiver) = std::sync::mpsc::channel(); sender.send(Ok("queued".into())).unwrap(); @@ -207,3 +207,38 @@ fn native_ranges_and_parity_do_not_duplicate_spooler_copies() { assert_eq!(jobs[0].1.copies, 3, "copies go to the spooler, not the PDF"); assert!(!jobs[0].2, "uncollated copies reach the spooler"); } + +#[test] +fn native_print_passes_orientation_options_to_spooler() { + let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()); + app.run("text.insert", json!({"text": "Test orientation"})).unwrap(); + app.printing.printers.push(Printer { name: "Queue".into(), default: true }); + app.run("ui.printSettings", json!({"printer":"Queue"})).unwrap(); + let jobs = Rc::new(RefCell::new(Vec::new())); + let observed = jobs.clone(); + app.services.print_native = Some(Box::new(move |bytes, settings, collate, options| { + observed.borrow_mut().push((bytes, settings, collate, options)); + let (sender, receiver) = std::sync::mpsc::channel(); + sender.send(Ok("queued".into())).unwrap(); + receiver + })); + + // 1. Default (auto for portrait document) sends orientation-requested=3 + app.run("ui.printSubmit", json!({})).unwrap(); + assert_eq!(jobs.borrow().last().unwrap().3, vec![("orientation-requested".to_owned(), "3".to_owned())]); + app.printing.poll(&egui::Context::default()); + app.poll_printing(&egui::Context::default()); + + // 2. Explicit landscape sends orientation-requested=4 and landscape=true + app.run("ui.printSubmit", json!({"orientation": "landscape"})).unwrap(); + assert_eq!( + jobs.borrow().last().unwrap().3, + vec![("orientation-requested".to_owned(), "4".to_owned()), ("landscape".to_owned(), "true".to_owned())] + ); + app.printing.poll(&egui::Context::default()); + app.poll_printing(&egui::Context::default()); + + // 3. Explicit portrait sends orientation-requested=3 + app.run("ui.printSubmit", json!({"orientation": "portrait"})).unwrap(); + assert_eq!(jobs.borrow().last().unwrap().3, vec![("orientation-requested".to_owned(), "3".to_owned())]); +} From cd31095163fb7ee1d313d90f23921b396ee75f8c Mon Sep 17 00:00:00 2001 From: ahutsunshin <118459951+ahutsunshin@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:08:20 +0800 Subject: [PATCH 071/101] perf: lowercase HTML tag names during collection to avoid double String allocation (#1261) * perf: lowercase HTML tag names during collection to avoid double allocation Inline .map(|c| c.to_ascii_lowercase()) into the tag name iterator chain for both open and close tags, then drop the subsequent .to_ascii_lowercase() call. Eliminates a second String allocation and copy per tag in the HTML tokenizer. * rustfmt Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/formats/src/html.rs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/crates/formats/src/html.rs b/crates/formats/src/html.rs index 0c6c975b1..7f29a9bf2 100644 --- a/crates/formats/src/html.rs +++ b/crates/formats/src/html.rs @@ -233,21 +233,27 @@ impl Lexer<'_> { return Some(Tok::Text(String::new())); } if rest.starts_with(""); - return Some(Tok::End(name.to_ascii_lowercase())); + return Some(Tok::End(name)); } if !b.get(1).is_some_and(|c| c.is_ascii_alphabetic()) { self.pos += 1; return Some(Tok::Text("<".into())); } // Start tag. - let name: String = rest[1..].chars().take_while(|c| c.is_ascii_alphanumeric() || *c == ':' || *c == '-').collect(); + let name: String = + rest[1..].chars().take_while(|c| c.is_ascii_alphanumeric() || *c == ':' || *c == '-').map(|c| c.to_ascii_lowercase()).collect(); let mut i = 1 + name.len(); let mut attrs = Vec::new(); let mut self_close = false; @@ -311,7 +317,7 @@ impl Lexer<'_> { } } self.pos += i.min(rest.len()); - Some(Tok::Start { name: name.to_ascii_lowercase(), attrs, self_close }) + Some(Tok::Start { name, attrs, self_close }) } } From 1fc34fc147dac7851a0a472e51385d34dc230747 Mon Sep 17 00:00:00 2001 From: haisondb35-blip Date: Thu, 15 Oct 2026 10:12:46 +0700 Subject: [PATCH 072/101] Proofing: Vietnamese text under the default English language gets no marks (#1553) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A document typed in Vietnamese under the default editing language (en-US) was covered in red underlines, every Vietnamese word checked as English. A paragraph that reads as Vietnamese (words with Vietnamese-only letters: the breve and horn letters, đ, ĩ ũ, the tone-marked vowels of Latin Extended Additional, or stacked combining marks) is now treated like any language without proofing tools: no spelling or grammar marks, as Word's automatic language detection does. A Vietnamese name in English text (Nguyễn) is no longer flagged either; French and Portuguese, which share â ê ô, are unaffected. Automatic hyphenation also leaves Vietnamese paragraphs and words whole (single syllables never take a hyphen); the English patterns happen not to split these words today, and the guard keeps it so. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/layout/src/para.rs | 5 +- crates/layout/src/tests.rs | 17 +++++++ crates/proof/src/lib.rs | 18 ++++++-- crates/proof/src/vi.rs | 93 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 129 insertions(+), 4 deletions(-) create mode 100644 crates/proof/src/vi.rs diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index e23b61749..6b1ea46fd 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -2422,7 +2422,9 @@ fn hyphen_glyph(pl: &ParaLayout, style: u32, cache: &mut Vec<(u32, u32, f32)>) - /// 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 { + // Vietnamese words are single syllables and never take a hyphen: no automatic hyphenation in + // a Vietnamese paragraph, nor in a word written with a Vietnamese-only letter (`Nguyễn`). + if auto && !wordcraft_proof::vi::looks_vietnamese(text) { let lim = wordcraft_proof::hyphen::Limits::default(); let mut start: Option = None; for (i, c) in text.char_indices().chain(std::iter::once((text.len(), ' '))) { @@ -2436,6 +2438,7 @@ fn hyphenation_points(text: &str, to_para: impl Fn(usize) -> Option, pl: if let Some(w) = text.get(a..i) && w.chars().count() >= lim.min_word && !w.chars().any(wordcraft_fonts::is_rtl) + && !wordcraft_proof::vi::is_vietnamese_word(w) && (caps || !all_caps(w)) { // The word's language: its first letter's run, found by its place in `p.text`. diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 1c8542b4b..ef4c182b5 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -6388,6 +6388,23 @@ fn automatic_spacing_drops_between_list_items() { } } +/// Vietnamese is written in single syllables: automatic hyphenation leaves its words whole. (The +/// English patterns happen not to split these accented words today; the Vietnamese guard in +/// `hyphenation_points` keeps it so whatever the patterns become.) +#[test] +fn automatic_hyphenation_leaves_vietnamese_whole() { + let text = + "Những người khuyến khích chuyện trường thường nghiêng nghiêng nhưng thành phố Nguyễn Trường Thượng xuyên suốt chương trình. ".repeat(8); + let mut d = Document::from_text(&text); + d.settings.auto_hyphenation = true; + let hyphens: usize = lay(&d).pages[0] + .items + .iter() + .filter_map(|i| if let Placed::Lines { para, .. } = i { Some(para.lines.iter().filter(|x| x.hyphen.is_some()).count()) } else { None }) + .sum(); + assert_eq!(hyphens, 0); +} + /// #1252: highlight and character-shading fills are marked as text backgrounds (a dark page dims /// them), and so is the text drawn on them; a paragraph's shading is an ordinary fill. #[test] diff --git a/crates/proof/src/lib.rs b/crates/proof/src/lib.rs index 369025527..a0099c664 100644 --- a/crates/proof/src/lib.rs +++ b/crates/proof/src/lib.rs @@ -49,6 +49,7 @@ pub mod pl; pub mod pt; pub mod ru; pub mod tr; +pub mod vi; use std::collections::HashSet; use std::ops::Range; @@ -581,7 +582,8 @@ fn enclosing_token(text: &str, a: usize, b: usize) -> (usize, usize, bool) { /// classification of the word's enclosing token, from [`enclosing_token`]. fn spelling_issue(text: &str, a: usize, b: usize, lang: ProofLang, opts: &Options, internet: bool) -> Option { let w = text.get(a..b)?; - if (opts.ignore_internet && internet) || is_correct_in_with(w, lang, opts) { + // A word with a Vietnamese-only letter (a name like Nguyễn in English text) isn't English. + if (opts.ignore_internet && internet) || (lang == ProofLang::En && vi::is_vietnamese_word(w)) || is_correct_in_with(w, lang, opts) { return None; } let message = match lang { @@ -668,9 +670,19 @@ pub fn check_text_with(text: &str, spans: &[(Range, ProofLang)], opts: &O _ => merged.push((r.clone(), *l)), } } + // A paragraph typed in Vietnamese under the default English language is Vietnamese, which + // has no proofing tools: no marks (Word detects the language the same way). + let default = if vi::looks_vietnamese(text) { + for (_, l) in merged.iter_mut().filter(|(_, l)| *l == ProofLang::En) { + *l = ProofLang::Other; + } + ProofLang::Other + } else { + ProofLang::En + }; 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) + merged.get(k).filter(|(r, _)| r.start <= i).map_or(default, |s| s.1) }; let mut v: Vec = spelling_issues(text, lang_at, opts); if !grammar { @@ -686,7 +698,7 @@ pub fn check_text_with(text: &str, spans: &[(Range, ProofLang)], opts: &O covered = covered.max(r.end); } if covered < text.len() || merged.is_empty() { - langs.push(ProofLang::En); + langs.push(default); } langs.sort(); langs.dedup(); diff --git a/crates/proof/src/vi.rs b/crates/proof/src/vi.rs new file mode 100644 index 000000000..bb24b88e9 --- /dev/null +++ b/crates/proof/src/vi.rs @@ -0,0 +1,93 @@ +//! Telling Vietnamese text apart from English, so a document typed in Vietnamese under the default +//! English proofing language isn't covered in red underlines (as Word's "detect language +//! automatically" does). There are no Vietnamese proofing tools yet, so Vietnamese text is treated +//! like any language without them: no spelling or grammar marks. +//! +//! The test is orthographic. Vietnamese writes most syllables with letters no other Latin-script +//! language shares: the horn letters (`ơ ư`), `ĩ ũ`, and the tone-marked vowels of the Latin +//! Extended Additional block (`ạ ả ấ ầ … ỹ`), precomposed or as combining hook above, dot below +//! and horn. French and Portuguese share `â ê ô à é`, Romanian `ă` and Croatian, Serbian and +//! Bosnian `đ`, so those alone don't count. + +/// Does `c` occur in Vietnamese words but (practically) nowhere else? +fn vietnamese_letter(c: char) -> bool { + matches!(c, 'ơ' | 'Ơ' | 'ư' | 'Ư' | 'ĩ' | 'Ĩ' | 'ũ' | 'Ũ' | '\u{1EA0}'..='\u{1EF9}' | '\u{0309}' | '\u{0323}' | '\u{031B}') +} + +/// Is `word` written with a Vietnamese-only letter (`Việt`, `ơn`)? Such a word is never an +/// English misspelling. +pub fn is_vietnamese_word(word: &str) -> bool { + let combining = |c: char| matches!(c, '\u{0300}'..='\u{036F}'); + // Two marks stacked on one letter (decomposed `ế` = e + circumflex + acute) are Vietnamese too. + word.chars().any(vietnamese_letter) || word.chars().zip(word.chars().skip(1)).any(|(a, b)| combining(a) && combining(b)) +} + +/// Does a paragraph read as Vietnamese? At least two of its words (any, in a heading of up to three +/// words) carry a Vietnamese-only letter, and they are at least a sixth of its words: Vietnamese syllables without +/// such a letter (`anh`, `con`, `ba`) are common, while an English paragraph quoting one +/// Vietnamese name stays English. +pub fn looks_vietnamese(text: &str) -> bool { + let mut total = 0usize; + let mut marked = 0usize; + for w in text.split(|c: char| !(c.is_alphanumeric() || matches!(c, '\u{0300}'..='\u{036F}'))).filter(|w| !w.is_empty()) { + total += 1; + if is_vietnamese_word(w) { + marked += 1; + } + } + marked > 0 && (marked >= 2 || total <= 3) && marked * 6 >= total +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn vietnamese_words() { + for w in ["Việt", "ơn", "những", "Ước", "mũi", "Nghĩa", "tie\u{0302}\u{0301}ng", "nho\u{031B}"] { + assert!(is_vietnamese_word(w), "{w}"); + } + // Shared with French, Portuguese, Spanish: not enough on their own. + // Romanian ă and Croatian đ are Vietnamese letters too, but not Vietnamese-only. + for w in ["café", "être", "côte", "água", "señor", "anh", "the", "mănânc", "acasă", "Đurđa", "đak", "đi"] { + assert!(!is_vietnamese_word(w), "{w}"); + } + } + + #[test] + fn paragraphs() { + assert!(looks_vietnamese("Tôi yêu tiếng Việt và những bài thơ của nó.")); + assert!(looks_vietnamese("Hà Nội")); + assert!(looks_vietnamese("Việt")); + // English quoting one Vietnamese name, and French, stay as they are. + assert!(!looks_vietnamese("The meeting with Mr Nguyễn is on Tuesday at the main office.")); + assert!(!looks_vietnamese("Le café de la côte est très bon.")); + assert!(!looks_vietnamese("")); + assert!(!looks_vietnamese("anh em")); + // Romanian and Croatian paragraphs, full of ă and đ, aren't Vietnamese. + assert!(!looks_vietnamese("Mă duc acasă să mănânc cu fratele meu, că e târziu.")); + assert!(!looks_vietnamese("Đurđa i Đorđe su se vratili kući, a Đakovo je daleko.")); + } +} + +#[cfg(test)] +mod checker_tests { + use crate::{Options, ProofLang, check_text_with}; + + #[test] + fn vietnamese_paragraphs_under_english_get_no_marks() { + let text = "Hôm nay tôi đi học, trời rất đẹp và mát mẻ."; + let spans = [(0..text.len(), ProofLang::En)]; + assert!(check_text_with(text, &spans, &Options::default(), true).is_empty()); + // No language at all (the document default) behaves the same. + assert!(check_text_with(text, &[], &Options::default(), true).is_empty()); + } + + #[test] + fn english_keeps_its_marks_and_skips_vietnamese_names() { + let text = "We met Nguyễn yesterdy at the office."; + let issues = check_text_with(text, &[(0..text.len(), ProofLang::En)], &Options::default(), false); + let flagged: Vec<&str> = issues.iter().filter_map(|i| text.get(i.start..i.end)).collect(); + assert_eq!(flagged, ["yesterdy"]); + } +} From 8d04d14d96e1473ce6e7334b845e26087f23f38d Mon Sep 17 00:00:00 2001 From: divyanshu2074 <43274510+divyanshu2074@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:44:54 +0530 Subject: [PATCH 073/101] fix(formats): preserve leading whitespace in markdown escape_line_start (#1568) Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- contributors/people.toml | 4 ++++ crates/formats/src/markdown.rs | 29 ++++++++++++++++++++++------- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index be7e7a798..4ca087ef8 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -17,6 +17,10 @@ # To remove your names, delete your entry (or set fields empty) in a PR. The next # contributors.py run, and the next build, drop them. +[people.divyanshu2074] +real_name = "Divyanshu Gupta" +sync_github_name = true + [people.lokinmodar] sync_github_name = true diff --git a/crates/formats/src/markdown.rs b/crates/formats/src/markdown.rs index c888b6512..45c805202 100644 --- a/crates/formats/src/markdown.rs +++ b/crates/formats/src/markdown.rs @@ -1071,11 +1071,13 @@ pub(crate) fn escape(s: &str) -> String { out } -/// Escape what would start a block at the beginning of a line. +/// Escape what would start a block at the beginning of a line. Leading spaces are kept, but at +/// most three (a tab counts as four): four would turn the line into an indented code block. fn escape_line_start(s: String) -> String { - let s = s.trim_start().to_string(); - let t = s.as_str(); - let lead = s.len() - t.len(); + let t = s.trim_start_matches([' ', '\t']); + let lead = s.get(..s.len() - t.len()).unwrap_or(""); + let cols: usize = lead.chars().map(|c| if c == '\t' { 4 } else { 1 }).sum(); + let indent = " ".repeat(cols.min(3)); let needs = t.starts_with('#') || t.starts_with('>') || t.starts_with("- ") @@ -1089,12 +1091,12 @@ fn escape_line_start(s: String) -> String { d > 0 && t.get(d..).is_some_and(|r| r.starts_with(". ") || r.starts_with(") ") || r == "." || r == ")") }; if !needs { - return s; + return if cols > 3 || lead.contains('\t') { format!("{indent}{t}") } else { s }; } if let Some(d) = t.find(['.', ')']).filter(|_| t.starts_with(|c: char| c.is_ascii_digit())) { - return format!("{}{}\\{}", &s[..lead], &t[..d], &t[d..]); + return format!("{indent}{}\\{}", &t[..d], &t[d..]); } - format!("{}\\{}", &s[..lead], t) + format!("{indent}\\{t}") } fn code_span(t: &str) -> String { @@ -1476,6 +1478,19 @@ mod tests { assert!(q.list.is_none()); } + #[test] + fn escape_line_start_preserves_indentation() { + assert_eq!(escape_line_start(" indented text".to_string()), " indented text"); + assert_eq!(escape_line_start(" # not a heading".to_string()), " \\# not a heading"); + assert_eq!(escape_line_start(" 1. not a list".to_string()), " 1\\. not a list"); + assert_eq!(escape_line_start(" > quote".to_string()), " \\> quote"); + // Four spaces or a tab would make an indented code block: the indent is capped at three. + assert_eq!(escape_line_start(" deep".to_string()), " deep"); + assert_eq!(escape_line_start("\tTabbed # text".to_string()), " Tabbed # text"); + assert_eq!(escape_line_start(" # not a heading".to_string()), " \\# not a heading"); + assert_eq!(escape_line_start(" \t1. not a list".to_string()), " 1\\. not a list"); + } + #[test] fn single_backtick_code_span_escaped_pipe() { let md = "| Command | Status |\n| --- | --- |\n| `cmd \\| grep` | pass |\n"; From 60b9500347aa581774dd4c5f3a6beba35fa8e244 Mon Sep 17 00:00:00 2001 From: Debarghaya Mitra <175645893+GhostMitra@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:54:19 +0530 Subject: [PATCH 074/101] Web: add option to load system installed fonts in dropdown (#1390) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): add option to load system installed fonts in dropdown Add explicit 'Load System Installed Fonts…' button in font dropdowns for web (Wasm). - Dispatches font.loadSystem via transient user activation on click - Calls window.queryLocalFonts() to enumerate and load installed fonts into FontDb - Invalidates font family cache in FontPreviews upon new font installation - Complete localization across all 18 languages - Credit GhostMitra in contributors/people.toml * fix(ui, engine): canvas focus after chrome click and checked file access in sniff * Revert "fix(ui, engine): canvas focus after chrome click and checked file access in sniff" This reverts commit b0c925bf79f6062ca7534d8df7626b21c178dbda. --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- apps/wordcraft-web/src/web.rs | 118 ++++++++++++++++++++++- apps/wordcraft-web/src/web/font_store.rs | 2 +- contributors/people.toml | 2 + crates/engine/src/cmd/font.rs | 6 ++ crates/engine/src/speech.rs | 1 + crates/engine/src/tests_fonts.rs | 4 + crates/fonts/src/fontdb.rs | 1 + crates/ui-egui/src/dialogs.rs | 16 ++- crates/ui-egui/src/file_dialogs.rs | 27 ++++++ crates/ui-egui/src/i18n/bg.tsv | 5 + crates/ui-egui/src/i18n/cs.tsv | 5 + crates/ui-egui/src/i18n/de.tsv | 5 + crates/ui-egui/src/i18n/es.tsv | 5 + crates/ui-egui/src/i18n/et.tsv | 5 + crates/ui-egui/src/i18n/fr.tsv | 5 + crates/ui-egui/src/i18n/hu.tsv | 5 + crates/ui-egui/src/i18n/it.tsv | 5 + crates/ui-egui/src/i18n/ja.tsv | 5 + crates/ui-egui/src/i18n/pl.tsv | 5 + 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/tr.tsv | 5 + crates/ui-egui/src/i18n/uk.tsv | 5 + crates/ui-egui/src/i18n/vi.tsv | 6 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 5 + crates/ui-egui/src/i18n/zh-hant.tsv | 5 + crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/mini_toolbar.rs | 2 + crates/ui-egui/src/previews.rs | 6 +- crates/ui-egui/src/ribbon.rs | 2 + crates/ui-egui/src/widgets.rs | 9 ++ 33 files changed, 287 insertions(+), 8 deletions(-) diff --git a/apps/wordcraft-web/src/web.rs b/apps/wordcraft-web/src/web.rs index a11266032..0e40099ad 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 _; @@ -60,7 +60,8 @@ pub fn start() { let doc = if sample { wordcraft_engine::sample::sample_document() } else { wordcraft_engine::blank_document(®ion) }; let mut session = Session::new(doc); session.system_region = region; - let mut app = WordApp::new(session, services(inbox.clone(), cc.egui_ctx.clone(), dirty, saves.clone())); + let messages = Rc::new(RefCell::new(Vec::new())); + let mut app = WordApp::new(session, services(inbox.clone(), cc.egui_ctx.clone(), dirty, saves.clone(), messages.clone())); // Pasted pictures wait apart from dropped files: the app inserts one only when the page is the edit target (#1349). app.services.pasted = Some(pasted); app.autosave = false; @@ -68,7 +69,7 @@ pub fn start() { if !sample { app.start_screen(); } - Ok(Box::new(WebShell { app, inbox })) + Ok(Box::new(WebShell { app, inbox, messages })) }), ) .await; @@ -90,6 +91,7 @@ fn query() -> String { struct WebShell { app: WordApp, inbox: Inbox, + messages: Rc>>, } impl eframe::App for WebShell { @@ -109,6 +111,10 @@ impl eframe::App for WebShell { } }); } + let msgs = std::mem::take(&mut *self.messages.borrow_mut()); + for msg in msgs { + self.app.status(msg); + } self.app.logic(ctx); } @@ -169,9 +175,11 @@ 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>, saves: AsyncSaves) -> Services { +fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc>, saves: AsyncSaves, messages: Rc>>) -> Services { let open_inbox = inbox.clone(); let save_ctx = ctx.clone(); + let font_ctx = ctx.clone(); + let font_messages = messages.clone(); Services { open_async: Some(Box::new(move |purpose: &str| { let inbox = open_inbox.clone(); @@ -204,10 +212,112 @@ fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc>, saves: Asyn animations_enabled: Some(animations_enabled()), store_font: Some(Box::new(font_store::store)), forget_font: Some(Box::new(font_store::forget)), + load_system_fonts: Some(Box::new(move || { + load_system_fonts(font_ctx.clone(), font_messages.clone()); + })), ..Default::default() } } +/// Most local fonts one Load System Installed Fonts installs (a designer's machine has thousands). +const MAX_SYSTEM_FONTS: usize = 500; +/// Most font bytes one Load System Installed Fonts reads into memory (wasm memory is small). +const MAX_SYSTEM_FONT_BYTES: usize = 256 << 20; +/// Most local fonts looked at, installed or not (already available, unreadable, too big). +const MAX_SYSTEM_FONTS_TRIED: u32 = 4000; + +/// Query the browser's Local Font Access API (`window.queryLocalFonts()`) and install the faces +/// into the global font database, up to [`MAX_SYSTEM_FONTS`] faces and [`MAX_SYSTEM_FONT_BYTES`]. +fn load_system_fonts(ctx: egui::Context, messages: Rc>>) { + wasm_bindgen_futures::spawn_local(async move { + let Some(window) = web_sys::window() else { + return; + }; + let query_fn = match js_sys::Reflect::get(&window, &wasm_bindgen::JsValue::from_str("queryLocalFonts")) { + Ok(val) if val.is_function() => js_sys::Function::from(val), + _ => { + messages.borrow_mut().push(wordcraft_ui_egui::i18n::t("Local font access is not supported by your browser.").to_string()); + ctx.request_repaint(); + return; + } + }; + let fonts_promise = match query_fn.call0(&window) { + Ok(p) => js_sys::Promise::from(p), + Err(e) => { + log::warn!("queryLocalFonts call failed: {e:?}"); + messages.borrow_mut().push(wordcraft_ui_egui::i18n::t("Local font access was denied.").to_string()); + ctx.request_repaint(); + return; + } + }; + let fonts_val = match wasm_bindgen_futures::JsFuture::from(fonts_promise).await { + Ok(val) => val, + Err(e) => { + log::warn!("queryLocalFonts promise rejected: {e:?}"); + messages.borrow_mut().push(wordcraft_ui_egui::i18n::t("Local font access was denied.").to_string()); + ctx.request_repaint(); + return; + } + }; + let font_list = js_sys::Array::from(&fonts_val); + let len = font_list.length(); + let mut loaded = 0usize; + let mut total_bytes = 0usize; + for i in 0..len.min(MAX_SYSTEM_FONTS_TRIED) { + if loaded >= MAX_SYSTEM_FONTS || total_bytes >= MAX_SYSTEM_FONT_BYTES { + log::warn!("local fonts: stopped after {loaded} fonts ({total_bytes} bytes)"); + break; + } + let font_data = font_list.get(i); + let Ok(blob_fn) = js_sys::Reflect::get(&font_data, &wasm_bindgen::JsValue::from_str("blob")) else { + continue; + }; + if !blob_fn.is_function() { + continue; + } + let Ok(blob_val) = js_sys::Function::from(blob_fn).call0(&font_data) else { + continue; + }; + let Ok(blob_js) = wasm_bindgen_futures::JsFuture::from(js_sys::Promise::from(blob_val)).await else { + continue; + }; + let Ok(blob) = blob_js.dyn_into::() else { + continue; + }; + // Skip a face too big on its own or for what is left of the budget, before reading it. + let size = blob.size().clamp(0.0, usize::MAX as f64) as usize; + if size > wordcraft_fonts::MAX_FONT_FILE_BYTES || total_bytes.saturating_add(size) > MAX_SYSTEM_FONT_BYTES { + continue; + } + let Ok(array_buffer) = wasm_bindgen_futures::JsFuture::from(blob.array_buffer()).await else { + continue; + }; + let bytes = js_sys::Uint8Array::new(&array_buffer).to_vec(); + if bytes.len() > wordcraft_fonts::MAX_FONT_FILE_BYTES { + continue; + } + total_bytes = total_bytes.saturating_add(bytes.len()); + let ps_name = js_sys::Reflect::get(&font_data, &wasm_bindgen::JsValue::from_str("postscriptName")) + .ok() + .and_then(|v| v.as_string()) + .unwrap_or_else(|| format!("local-font-{i}")); + let name = format!("{ps_name}.ttf"); + match wordcraft_fonts::FontDb::global().install_font(&name, &bytes) { + Ok(_) => loaded += 1, + Err(wordcraft_fonts::UserFontError::AlreadyAvailable(_)) => {} + Err(e) => log::warn!("local font {name} skipped: {e}"), + } + } + if loaded > 0 { + let msg = wordcraft_ui_egui::i18n::fmt(wordcraft_ui_egui::i18n::t("Loaded {count} system fonts."), &[("count", &loaded.to_string())]); + messages.borrow_mut().push(msg); + } else { + messages.borrow_mut().push(wordcraft_ui_egui::i18n::t("All system fonts are already available.").to_string()); + } + ctx.request_repaint(); + }); +} + /// Whether the system has animation effects on, as the browser passes it on: off while the /// `prefers-reduced-motion: reduce` media query matches. `None` when the browser can't say. The /// query is made once and asked every frame; it follows the system's setting by itself. diff --git a/apps/wordcraft-web/src/web/font_store.rs b/apps/wordcraft-web/src/web/font_store.rs index 3a7726406..8b577cb62 100644 --- a/apps/wordcraft-web/src/web/font_store.rs +++ b/apps/wordcraft-web/src/web/font_store.rs @@ -36,7 +36,7 @@ async fn open() -> Result { let req = factory.open_with_u32(DB_NAME, 1).map_err(js)?; let upgrade_req = req.clone(); let upgrade = Closure::::new(move || { - if let Ok(db) = upgrade_req.result().and_then(|r| r.dyn_into::().map_err(JsValue::from)) + if let Ok(db) = upgrade_req.result().and_then(|r| r.dyn_into::()) && !db.object_store_names().contains(STORE) && let Err(e) = db.create_object_store(STORE) { diff --git a/contributors/people.toml b/contributors/people.toml index 4ca087ef8..a84454436 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -64,6 +64,8 @@ display_name = "Ufuk Efe" sync_github_name = true [people.GhostMitra] +sync_github_name = true + [people.DevCoderMax] real_name = "Gleyson do Nascimento Carvalho" display_name = "Gleyson Carvalho" diff --git a/crates/engine/src/cmd/font.rs b/crates/engine/src/cmd/font.rs index bcdee205d..0460c2274 100644 --- a/crates/engine/src/cmd/font.rs +++ b/crates/engine/src/cmd/font.rs @@ -18,9 +18,15 @@ pub fn specs() -> Vec { .pure(), CommandSpec::new("font.list", "Added Fonts", "Home › Font", list).params(r#"{"all"?: bool (also every available family)}"#).pure(), CommandSpec::new("font.remove", "Remove Added Font", "Home › Font", remove).params(r#"{"family": string}"#).pure(), + CommandSpec::new("font.loadSystem", "Load System Installed Fonts…", "Home › Font", load_system).pure(), ] } +fn load_system(s: &mut Session, _v: &Value) -> CmdResult { + s.ui_requests.push(json!({"open": "loadSystemFonts"})); + Ok(json!({"pending": true})) +} + fn font_json(f: &UserFont) -> Value { json!({ "file": f.file, diff --git a/crates/engine/src/speech.rs b/crates/engine/src/speech.rs index ce996075e..863c4e45e 100644 --- a/crates/engine/src/speech.rs +++ b/crates/engine/src/speech.rs @@ -256,6 +256,7 @@ struct Shared { generation: u64, error: Option, /// A worker thread is running (cleared under the lock as it exits). + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] running: bool, } diff --git a/crates/engine/src/tests_fonts.rs b/crates/engine/src/tests_fonts.rs index 53dd9b948..83c59cb59 100644 --- a/crates/engine/src/tests_fonts.rs +++ b/crates/engine/src/tests_fonts.rs @@ -104,6 +104,10 @@ fn bad_font_input_is_an_error() { let r = s.run("font.add", &json!({})).unwrap(); assert_eq!(r["pending"], true); assert!(s.ui_requests.iter().any(|r| r["open"] == "addFont")); + + let r = s.run("font.loadSystem", &json!({})).unwrap(); + assert_eq!(r["pending"], true); + assert!(s.ui_requests.iter().any(|r| r["open"] == "loadSystemFonts")); } /// A .docx whose text is set in `family`, embedding `font` through the font table (#1171): as an diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 1b984c94f..5f0729513 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -1531,6 +1531,7 @@ impl FontDb { } fn search_east_asian(&self, chars: &[char], exclude: u32, bold: bool, italic: bool) -> Option> { + #[cfg_attr(target_arch = "wasm32", allow(unused_mut))] let mut base = self.loaded_covering(chars, exclude); #[cfg(not(target_arch = "wasm32"))] if base.is_none() { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index a5a686bba..411272365 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1761,14 +1761,26 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { ui.label(tl!("Asian text font:")); let fams = app.previews.east_asian_families(); if let Some(f) = crate::widgets::font_combo(ui, "fontdlg_asian", 220.0, font_ea, &fams, Some(&*prev)) { - *font_ea = f; + if f == crate::widgets::ADD_FONT { + let _ = app.run("font.add", json!({})); + } else if f == crate::widgets::LOAD_SYSTEM_FONTS { + let _ = app.run("font.loadSystem", json!({})); + } else { + *font_ea = f; + } } ui.end_row(); } ui.label(if *asian { tl!("Latin text font:") } else { tl!("Font:") }); let fams = app.previews.families(); if let Some(f) = crate::widgets::font_combo(ui, "fontdlg_font", 220.0, font, &fams, Some(&*prev)) { - *font = f; + if f == crate::widgets::ADD_FONT { + let _ = app.run("font.add", json!({})); + } else if f == crate::widgets::LOAD_SYSTEM_FONTS { + let _ = app.run("font.loadSystem", json!({})); + } else { + *font = f; + } } ui.end_row(); ui.label(tl!("Size:")); diff --git a/crates/ui-egui/src/file_dialogs.rs b/crates/ui-egui/src/file_dialogs.rs index 1d9903dac..0dc13b1f3 100644 --- a/crates/ui-egui/src/file_dialogs.rs +++ b/crates/ui-egui/src/file_dialogs.rs @@ -317,6 +317,15 @@ impl WordApp { } let _ = self.ask_file(FileDialogRequest::Open { purpose: "font".into() }, AfterPick::Font); } + + /// Request loading local system installed fonts (web: queryLocalFonts). + pub(crate) fn load_system_fonts(&mut self) { + if let Some(f) = &self.services.load_system_fonts { + f(); + } else { + self.status(tl!("All system fonts are already available.")); + } + } } #[cfg(test)] @@ -556,4 +565,22 @@ mod tests { assert_eq!(a.session.merge.rows.len(), 2); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn load_system_fonts_runs_hook_when_present() { + use std::cell::Cell; + let called = Rc::new(Cell::new(false)); + let called2 = called.clone(); + let services = Services { load_system_fonts: Some(Box::new(move || called2.set(true))), ..Default::default() }; + let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), services); + a.run("font.loadSystem", json!({})).unwrap(); + assert!(called.get()); + } + + #[test] + fn load_system_fonts_without_hook_reports_status() { + let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()); + a.run("font.loadSystem", json!({})).unwrap(); + assert_eq!(a.status_msg.as_ref().map(|(s, _)| s.as_str()), Some("All system fonts are already available.")); + } } diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index ff0521e4e..2c334a214 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2241,6 +2241,11 @@ Add Font… Добавяне на шрифт… Added Fonts Добавени шрифтове Remove Added Font Премахване на добавен шрифт + Load System Installed Fonts… Зареждане на инсталирани системни шрифтове… + All system fonts are already available. Всички системни шрифтове вече са налични. + Local font access is not supported by your browser. Достъпът до локални шрифтове не се поддържа от браузъра ви. + Local font access was denied. Достъпът до локални шрифтове беше отказан. + Loaded {count} system fonts. Заредени са {count} системни шрифта. Recent Colors Последни цветове More Colors… Още цветове… Standard Стандартни diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e1b7405de..ac88f6270 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2271,6 +2271,11 @@ preview Title Ukázka Add Font… Přidat písmo… Added Fonts Přidaná písma Remove Added Font Odebrat přidané písmo + Load System Installed Fonts… Načíst nainstalovaná systémová písma… + All system fonts are already available. Všechna systémová písma jsou již k dispozici. + Local font access is not supported by your browser. Váš prohlížeč nepodporuje přístup k místním písmům. + Local font access was denied. Přístup k místním písmům byl odepřen. + Loaded {count} system fonts. Načteno systémových písem: {count}. Recent Colors Poslední barvy More Colors… Další barvy… Standard Standardní diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 20bdbdfd0..6ef5b55ea 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1558,6 +1558,11 @@ Add Font… Schriftart hinzufügen… Added Fonts Hinzugefügte Schriftarten Remove Added Font Hinzugefügte Schriftart entfernen + Load System Installed Fonts… Installierte Systemschriftarten laden… + All system fonts are already available. Alle Systemschriftarten sind bereits verfügbar. + Local font access is not supported by your browser. Lokaler Schriftartenzugriff wird von Ihrem Browser nicht unterstützt. + Local font access was denied. Zugriff auf lokale Schriftarten wurde verweigert. + Loaded {count} system fonts. {count} Systemschriftarten geladen. Recent Colors Zuletzt verwendete Farben More Colors… Weitere Farben… Standard Standard diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index bd19a2484..2b48906e4 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1601,6 +1601,11 @@ Add Font… Agregar fuente… Added Fonts Fuentes agregadas Remove Added Font Quitar fuente agregada + Load System Installed Fonts… Cargar fuentes del sistema instaladas… + All system fonts are already available. Todas las fuentes del sistema ya están disponibles. + Local font access is not supported by your browser. Su navegador no admite el acceso a fuentes locales. + Local font access was denied. Se denegó el acceso a las fuentes locales. + Loaded {count} system fonts. Se cargaron {count} fuentes del sistema. Recent Colors Colores recientes More Colors… Más colores… Standard Estándar diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 7de039d67..f10592d83 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -1580,6 +1580,11 @@ Add Font… Lisa font… Added Fonts Lisatud fondid Remove Added Font Eemalda lisatud font + Load System Installed Fonts… Laadi süsteemi installitud fondid… + All system fonts are already available. Kõik süsteemifondid on juba saadaval. + Local font access is not supported by your browser. Teie brauser ei toeta kohalike fontide juurdepääsu. + Local font access was denied. Juurdepääs kohalikele fontidele keelati. + Loaded {count} system fonts. Laaditi {count} süsteemifonti. Recent Colors Viimati kasutatud värvid More Colors… Rohkem värve… Standard Standard diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ce091d437..e87468d34 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1929,6 +1929,11 @@ Add Font… Ajouter une police… Added Fonts Polices ajoutées Remove Added Font Supprimer la police ajoutée + Load System Installed Fonts… Charger les polices système installées… + All system fonts are already available. Toutes les polices système sont déjà disponibles. + Local font access is not supported by your browser. L'accès aux polices locales n'est pas pris en charge par votre navigateur. + Local font access was denied. L'accès aux polices locales a été refusé. + Loaded {count} system fonts. {count} polices système chargées. Recent Colors Couleurs récentes More Colors… Autres couleurs… Standard Standard diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 26b8ede95..fe96885e2 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1944,6 +1944,11 @@ 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 + Load System Installed Fonts… Telepített rendszer-betűtípusok betöltése… + All system fonts are already available. Minden rendszer-betűtípus elérhető már. + Local font access is not supported by your browser. A böngészője nem támogatja a helyi betűtípusokhoz való hozzáférést. + Local font access was denied. A helyi betűtípusokhoz való hozzáférés megtagadva. + Loaded {count} system fonts. {count} rendszer-betűtípus betöltve. 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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 6257c3a3d..3fefb9a70 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1625,6 +1625,11 @@ Add Font… Aggiungi carattere… Added Fonts Caratteri aggiunti Remove Added Font Rimuovi carattere aggiunto + Load System Installed Fonts… Carica caratteri di sistema installati… + All system fonts are already available. Tutti i caratteri di sistema sono già disponibili. + Local font access is not supported by your browser. L'accesso ai caratteri locali non è supportato dal browser. + Local font access was denied. L'accesso ai caratteri locali è stato negato. + Loaded {count} system fonts. Caricati {count} caratteri di sistema. Recent Colors Colori recenti More Colors… Altri colori… Standard Standard diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index d0f9c9a9f..23683d933 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1561,6 +1561,11 @@ Add Font… フォントの追加… Added Fonts 追加したフォント Remove Added Font 追加したフォントの削除 + Load System Installed Fonts… インストール済みシステムフォントを読み込む… + All system fonts are already available. すべてのシステムフォントは既に使用可能です。 + Local font access is not supported by your browser. お使いのブラウザーはローカルフォントへのアクセスに対応していません。 + Local font access was denied. ローカルフォントへのアクセスが拒否されました。 + Loaded {count} system fonts. {count} 個のシステムフォントを読み込みました。 Recent Colors 最近使用した色 More Colors… その他の色… Standard 標準 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 3f90a93d4..ad6f93a05 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1949,6 +1949,11 @@ Regular expressions Wyrażenia regularne Relationship Relacja Remove Added Font Usuń dodaną czcionkę + Load System Installed Fonts… Wczytaj zainstalowane czcionki systemowe… + All system fonts are already available. Wszystkie czcionki systemowe są już dostępne. + Local font access is not supported by your browser. Twoja przeglądarka nie obsługuje dostępu do czcionek lokalnych. + Local font access was denied. Odmówiono dostępu do czcionek lokalnych. + Loaded {count} system fonts. Wczytano {count} czcionek systemowych. Remove Content Control Usuń formant zawartości Remove row Usuń wiersz Remove series Usuń serię diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index bc46e2a51..4cf2e7130 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1573,6 +1573,11 @@ Add Font… Adicionar fonte… Added Fonts Fontes adicionadas Remove Added Font Remover fonte adicionada + Load System Installed Fonts… Carregar fontes do sistema instaladas… + All system fonts are already available. Todas as fontes do sistema já estão disponíveis. + Local font access is not supported by your browser. O acesso a fontes locais não é compatível com seu navegador. + Local font access was denied. O acesso a fontes locais foi negado. + Loaded {count} system fonts. {count} fontes do sistema carregadas. Recent Colors Cores recentes More Colors… Mais cores… Standard Padrão diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index a9651d259..8df73bb59 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1809,6 +1809,11 @@ preview Title Пример Add Font… Добавить шрифт… Added Fonts Добавленные шрифты Remove Added Font Удалить добавленный шрифт + Load System Installed Fonts… Загрузить установленные системные шрифты… + All system fonts are already available. Все системные шрифты уже доступны. + Local font access is not supported by your browser. Ваш браузер не поддерживает доступ к локальным шрифтам. + Local font access was denied. Доступ к локальным шрифтам отклонен. + Loaded {count} system fonts. Загружено системных шрифтов: {count}. Print Markup Печатать исправления и примечания Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. Примечания рядом с каждой страницей и отслеживаемые изменения в PDF, при печати и в изображениях; список примечаний в конце веб-страниц, Markdown и текста. Recent Colors Последние цвета diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 9a0a4f3c7..8d3d5690b 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -1571,6 +1571,11 @@ Add Font… Dodaj font… Added Fonts Dodati fontovi Remove Added Font Ukloni dodati font + Load System Installed Fonts… Učitaj instalirane sistemske fontove… + All system fonts are already available. Svi sistemski fontovi su već dostupni. + Local font access is not supported by your browser. Vaš pregledač ne podržava pristup lokalnim fontovima. + Local font access was denied. Pristup lokalnim fontovima je odbijen. + Loaded {count} system fonts. Učitano sistemskih fontova: {count}. Recent Colors Nedavne boje More Colors… Još boja… Standard Standardne diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index e638e8da1..e881793d9 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -1575,6 +1575,11 @@ Add Font… Додај фонт… Added Fonts Додати фонтови Remove Added Font Уклони додати фонт + Load System Installed Fonts… Учитај инсталиране системске фонтове… + All system fonts are already available. Сви системски фонтови су већ доступни. + Local font access is not supported by your browser. Ваш прегледач не подржава приступ локалним фонтовима. + Local font access was denied. Приступ локалним фонтовима је одбијен. + Loaded {count} system fonts. Учитано системских фонтова: {count}. Recent Colors Недавне боје More Colors… Још боја… Standard Стандардне diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 950065022..4be49fe67 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1811,6 +1811,11 @@ preview Title Örnek Add Font… Yazı Tipi Ekle… Added Fonts Eklenen Yazı Tipleri Remove Added Font Eklenen Yazı Tipini Kaldır + Load System Installed Fonts… Yüklü Sistem Yazı Tiplerini Yükle… + All system fonts are already available. Tüm sistem yazı tipleri zaten kullanılabilir. + Local font access is not supported by your browser. Tarayıcınız yerel yazı tipi erişimini desteklemiyor. + Local font access was denied. Yerel yazı tipi erişimi reddedildi. + Loaded {count} system fonts. {count} sistem yazı tipi yüklendi. Print Markup İşaretlemeyi Yazdır Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. PDF'lerde, baskılarda ve görüntülerde her sayfanın yanında açıklamalar ve izlenen değişiklikler; web sayfalarının, Markdown ve metnin sonunda açıklama listesi. Recent Colors Son Kullanılan Renkler diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 5a3ff4694..853c89915 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1573,6 +1573,11 @@ Add Font… Додати шрифт… Added Fonts Додані шрифти Remove Added Font Видалити доданий шрифт + Load System Installed Fonts… Завантажити встановлені системні шрифти… + All system fonts are already available. Усі системні шрифти вже доступні. + Local font access is not supported by your browser. Ваш браузер не підтримує доступ до локальних шрифтів. + Local font access was denied. У доступі до локальних шрифтів відмовлено. + Loaded {count} system fonts. Завантажено системних шрифтів: {count}. Recent Colors Останні кольори More Colors… Інші кольори… Standard Стандартні diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index b4b2f3362..952e29ace 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2636,3 +2636,9 @@ # Missing dictionary on the status bar (#1441) No spelling dictionary for {language} is installed, so it isn't checked. WordCraft looks for the Hunspell dictionary {name} in the WORDCRAFT_DICTIONARIES folder and in the usual system locations. Chưa cài từ điển chính tả cho {language}, nên ngôn ngữ này không được kiểm tra. WordCraft tìm từ điển Hunspell {name} trong thư mục WORDCRAFT_DICTIONARIES và ở các vị trí thông thường của hệ thống. {language} (no dictionary) {language} (không có từ điển) +# Load system installed fonts on the web (#1390) + Load System Installed Fonts… Tải phông chữ đã cài trên hệ thống… + All system fonts are already available. Tất cả phông chữ hệ thống đã có sẵn. + Local font access is not supported by your browser. Trình duyệt của bạn không hỗ trợ truy cập phông chữ cục bộ. + Local font access was denied. Quyền truy cập phông chữ cục bộ đã bị từ chối. + Loaded {count} system fonts. Đã tải {count} phông chữ hệ thống. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 090fbc909..412e1dfa3 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1573,6 +1573,11 @@ Add Font… 添加字体… Added Fonts 已添加的字体 Remove Added Font 删除已添加的字体 + Load System Installed Fonts… 加载系统已安装字体… + All system fonts are already available. 所有系统字体已可用。 + Local font access is not supported by your browser. 您的浏览器不支持访问本地字体。 + Local font access was denied. 本地字体访问被拒绝。 + Loaded {count} system fonts. 已加载 {count} 个系统字体。 Recent Colors 最近使用的颜色 More Colors… 其他颜色… Standard 标准 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index e67b77ebf..c7d541da5 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1562,6 +1562,11 @@ Add Font… 新增字型… Added Fonts 已新增的字型 Remove Added Font 移除已新增的字型 + Load System Installed Fonts… 載入系統已安裝字型… + All system fonts are already available. 所有系統字型已可用。 + Local font access is not supported by your browser. 您的瀏覽器不支援存取本機字型。 + Local font access was denied. 本機字型存取遭到拒絕。 + Loaded {count} system fonts. 已載入 {count} 個系統字型。 Recent Colors 最近使用的色彩 More Colors… 其他色彩… Standard 標準 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 62d622039..5c64711c6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -157,6 +157,8 @@ 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>, + /// Web: request browser to query and load local installed fonts (`window.queryLocalFonts()`). + pub load_system_fonts: 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 @@ -1215,6 +1217,7 @@ impl WordApp { } "insertPicture" => self.pick_picture(), "addFont" => self.pick_font(), + "loadSystemFonts" => self.load_system_fonts(), "print" => { self.ui.backstage = true; self.ui.backstage_page = "print".into(); diff --git a/crates/ui-egui/src/mini_toolbar.rs b/crates/ui-egui/src/mini_toolbar.rs index 461ff06cf..ea85eda71 100644 --- a/crates/ui-egui/src/mini_toolbar.rs +++ b/crates/ui-egui/src/mini_toolbar.rs @@ -219,6 +219,8 @@ fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens) { if let Some(f) = widgets::font_combo(ui, "mini_font", COMBO_FONT_W, &font, &fams, Some(&*prev)) { let _ = if f == crate::widgets::ADD_FONT { app.run("font.add", json!({})) + } else if f == crate::widgets::LOAD_SYSTEM_FONTS { + app.run("font.loadSystem", json!({})) } else { let f = app.previews.font_stored(&f); app.run("format.font", json!({"name": f})) diff --git a/crates/ui-egui/src/previews.rs b/crates/ui-egui/src/previews.rs index 528e95f5e..cb9468482 100644 --- a/crates/ui-egui/src/previews.rs +++ b/crates/ui-egui/src/previews.rs @@ -17,6 +17,7 @@ pub struct Previews { tex: HashMap, /// The font list, for the interface language it was made in. families: Option<(&'static str, Vec)>, + font_gen: u64, /// Lowercase English family → its name in that language (simsun → 宋体), and back. local_names: HashMap, english_names: HashMap, @@ -156,7 +157,9 @@ impl Previews { /// one (宋体 for SimSun in Chinese, #505), like Word's. pub fn families(&mut self) -> Vec { let lang = crate::i18n::current().code(); - if self.families.as_ref().is_none_or(|(l, _)| *l != lang) { + let generation = wordcraft_fonts::FontDb::global().generation(); + if self.families.as_ref().is_none_or(|(l, _)| *l != lang) || self.font_gen != generation { + self.font_gen = generation; let db = wordcraft_fonts::FontDb::global(); let mut f: Vec = db.families_in(lang).into_iter().filter(|f| !f.starts_with('.')).collect(); for w in [wordcraft_doc::styles::BODY_FONT, wordcraft_doc::styles::HEADING_FONT] { @@ -211,6 +214,7 @@ impl Previews { /// `families` (an entry drawn before its font was added shows the fallback face). pub fn fonts_changed(&mut self, ctx: &egui::Context, families: impl Iterator) { self.families = None; + self.east_asian = None; ctx.data_mut(|d| { for f in families { d.remove::(egui::Id::new(format!("font:{f}"))); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 30a55b4a7..ead020b4f 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -764,6 +764,8 @@ fn home(app: &mut WordApp, ui: &mut Ui) { if let Some(f) = font_combo(ui, "font", 150.0, &font, &fams, Some(&*prev)) { let _ = if f == crate::widgets::ADD_FONT { app.run("font.add", json!({})) + } else if f == crate::widgets::LOAD_SYSTEM_FONTS { + app.run("font.loadSystem", json!({})) } else { let f = app.previews.font_stored(&f); app.run("format.font", json!({"name": f})) diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 767a95c90..a10495be9 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -499,6 +499,10 @@ pub fn combo( /// caller runs `font.add`, which shows a file picker. Not a family name anyone can type. pub const ADD_FONT: &str = "\u{1}font.add"; +/// What [`font_combo`] returns when "Load System Installed Fonts…" is chosen on web: the +/// caller runs `font.loadSystem`. +pub const LOAD_SYSTEM_FONTS: &str = "\u{1}font.loadSystem"; + /// The font box: a [`combo`] where typing lists the matching families under the field (#359). /// Its list ends with Add Font… ([`ADD_FONT`]). /// Up/Down move the highlight, Enter or a click applies the highlighted family ("arial" applies @@ -666,6 +670,11 @@ fn combo_impl( out = Some(ADD_FONT.to_string()); ui.close(); } + #[cfg(target_arch = "wasm32")] + if ui.button(tl!("Load System Installed Fonts…")).clicked() { + out = Some(LOAD_SYSTEM_FONTS.to_string()); + ui.close(); + } } }); if s.open && !s.matches.is_empty() { From 3f9b6a029fa97883cb47ebd902f67aad9a5ab2a1 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Thu, 15 Oct 2026 05:28:36 +0200 Subject: [PATCH 075/101] =?UTF-8?q?Table=20Layout=20=E2=80=BA=20Sort=20ope?= =?UTF-8?q?ns=20a=20Sort=20dialog:=20column,=20order=20and=20header=20row?= =?UTF-8?q?=20(#1205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/dialogs_layout.rs | 118 ++++++++++++++++++++++++++- crates/ui-egui/src/i18n/bg.tsv | 5 ++ crates/ui-egui/src/i18n/cs.tsv | 5 ++ crates/ui-egui/src/i18n/de.tsv | 5 ++ crates/ui-egui/src/i18n/es.tsv | 5 ++ crates/ui-egui/src/i18n/et.tsv | 5 ++ crates/ui-egui/src/i18n/fr.tsv | 5 ++ crates/ui-egui/src/i18n/hu.tsv | 5 ++ crates/ui-egui/src/i18n/it.tsv | 5 ++ crates/ui-egui/src/i18n/ja.tsv | 5 ++ crates/ui-egui/src/i18n/pl.tsv | 5 ++ 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/tr.tsv | 5 ++ crates/ui-egui/src/i18n/uk.tsv | 5 ++ crates/ui-egui/src/i18n/vi.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 | 2 + 21 files changed, 212 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/dialogs_layout.rs b/crates/ui-egui/src/dialogs_layout.rs index a0388463d..216100ba5 100644 --- a/crates/ui-egui/src/dialogs_layout.rs +++ b/crates/ui-egui/src/dialogs_layout.rs @@ -1,8 +1,9 @@ //! Layout, Review and Mailings dialogs (#407): Line Numbers (Layout › Line Numbers › Line //! Numbering Options…), Hyphenation (Hyphenation Options… and Manual…), Language (Review › -//! Language), and Envelopes and Labels (Mailings › Create). Each ends by running a command -//! (`layout.lineNumbers`, `layout.hyphenation`, `layout.manualHyphenation` with `caret.set` and -//! `text.optionalHyphen`, `review.language`, `mailings.envelopes`, `mailings.labels`), so scripts +//! Language), Envelopes and Labels (Mailings › Create), and Sort (Table Layout › Data, #1200). +//! Each ends by running a command (`layout.lineNumbers`, `layout.hyphenation`, +//! `layout.manualHyphenation` with `caret.set` and `text.optionalHyphen`, `review.language`, +//! `mailings.envelopes`, `mailings.labels`, `table.sort`), so scripts //! and agents get the same result without the dialog. Shown through //! [`crate::dialogs::Dialog::Layout`]. @@ -26,6 +27,7 @@ pub enum LayoutDialog { Language(LanguageForm), Envelopes(EnvelopeForm), Labels(LabelsForm), + Sort(SortForm), } impl LayoutDialog { @@ -37,6 +39,7 @@ impl LayoutDialog { LayoutDialog::Language(_) => "language", LayoutDialog::Envelopes(_) => "envelopes", LayoutDialog::Labels(_) => "labels", + LayoutDialog::Sort(_) => "tableSort", } } @@ -49,6 +52,7 @@ impl LayoutDialog { LayoutDialog::Language(_) => "Language", LayoutDialog::Envelopes(_) => "Envelopes", LayoutDialog::Labels(_) => "Labels", + LayoutDialog::Sort(_) => "Sort", } } } @@ -62,6 +66,7 @@ pub fn open(name: &str, app: &mut WordApp) -> Option { "language" => LayoutDialog::Language(LanguageForm::read(app)), "envelopes" => LayoutDialog::Envelopes(EnvelopeForm::read(app)), "labels" => LayoutDialog::Labels(LabelsForm::read(app)), + "tableSort" => LayoutDialog::Sort(SortForm::read(app)?), _ => return None, }) } @@ -75,6 +80,7 @@ pub fn body(app: &mut WordApp, ui: &mut Ui, d: &mut LayoutDialog) -> bool { LayoutDialog::Language(f) => language_ui(app, ui, f), LayoutDialog::Envelopes(f) => envelopes_ui(app, ui, f), LayoutDialog::Labels(f) => labels_ui(app, ui, f), + LayoutDialog::Sort(f) => sort_ui(app, ui, f), } } @@ -649,6 +655,75 @@ fn labels_ui(app: &mut WordApp, ui: &mut Ui, f: &mut LabelsForm) -> bool { cancel } +// --------------------------------------------------------------------------------------------- +// Sort + +/// The table Sort dialog's fields: the column to sort by, the order and whether the first row is +/// a header (kept in place). `headers` is the first row's text, which names the columns when it +/// is a header. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SortForm { + pub column: usize, + pub descending: bool, + pub header: bool, + pub headers: Vec, +} + +impl SortForm { + /// The caret's table; none outside a table. + pub fn read(app: &WordApp) -> Option { + let s = &app.session; + let (tp, _, _) = s.sel.focus.path.cell()?; + let first = s.doc.table(s.sel.focus.story, &tp)?.rows.first()?; + let headers = first + .cells + .iter() + .map(|c| c.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect::>().join(" ").trim().to_string()) + .collect(); + Some(SortForm { column: 0, descending: false, header: first.props.header, headers }) + } + + /// A column's name in Sort by: its header text, or `Column n`. + pub fn column_label(&self, i: usize) -> String { + match self.headers.get(i).filter(|h| self.header && !h.is_empty()) { + Some(h) => h.clone(), + None => crate::i18n::fmt(tl!("Column {n}"), &[("n", &(i + 1).to_string())]), + } + } + + /// `table.sort` parameters for OK. + pub fn params(&self) -> Value { + json!({"column": self.column, "descending": self.descending, "header": self.header}) + } +} + +fn sort_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SortForm) -> bool { + heading(ui, "Sort by"); + ui.horizontal(|ui| { + egui::ComboBox::from_id_salt("sort_column").selected_text(f.column_label(f.column)).width(180.0).show_ui(ui, |ui| { + for i in 0..f.headers.len() { + let label = f.column_label(i); + ui.selectable_value(&mut f.column, i, label); + } + }); + ui.vertical(|ui| { + ui.radio_value(&mut f.descending, false, tl!("Ascending")); + ui.radio_value(&mut f.descending, true, tl!("Descending")); + }); + }); + heading(ui, "My list has"); + ui.horizontal(|ui| { + ui.radio_value(&mut f.header, true, tl!("Header row")); + ui.radio_value(&mut f.header, false, tl!("No header row")); + }); + let (ok, cancel) = buttons(ui, "OK", true); + if ok { + return run(app, "table.sort", f.params()); + } + cancel +} + #[cfg(test)] mod tests { use serde_json::json; @@ -761,6 +836,43 @@ mod tests { assert!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("Thanks.")); } + /// A table of `cells`, `cols` wide, typed cell by cell; the caret ends in the last cell. + fn table_app(cols: usize, cells: &[&str]) -> WordApp { + let mut a = app(); + a.run("insert.table", json!({"rows": cells.len() / cols, "cols": cols})).unwrap(); + for (k, c) in cells.iter().enumerate() { + if k > 0 { + a.run("text.tab", json!({})).unwrap(); + } + a.run("text.insert", json!({"text": c})).unwrap(); + } + a + } + + fn first_column(a: &WordApp) -> Vec { + let t = a.session.doc.body.iter().find_map(|b| b.as_table()).unwrap(); + t.rows.iter().map(|r| r.cells[0].blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect()).collect() + } + + /// #1200: Table Layout › Sort opens its dialog instead of sorting by the first column at once; + /// with a header row the columns are named by it, and OK sorts the rows under it by the + /// chosen column (as numbers here: 2, 7, 10). + #[test] + fn table_sort_opens_its_dialog() { + let mut a = table_app(2, &["Name", "Score", "Bea", "10", "Al", "2", "Cy", "7"]); + assert_eq!(a.run("table.sort", json!({})).unwrap(), json!({"pending": "tableSort"})); + assert_eq!(first_column(&a), ["Name", "Bea", "Al", "Cy"], "nothing sorted yet"); + let LayoutDialog::Sort(mut f) = form(&mut a) else { panic!("sort") }; + assert!(!f.header && f.column == 0 && !f.descending); + assert_eq!(f.column_label(1), "Column 2"); + f.header = true; + assert_eq!(f.column_label(1), "Score"); + f.column = 1; + a.run("table.sort", f.params()).unwrap(); + assert_eq!(first_column(&a), ["Name", "Al", "Cy", "Bea"]); + assert!(a.dialog.is_none()); + } + /// The envelope sizes are in the interface language with the region's decimal separator /// (#1128; the untranslated-text audit found `#10 4.13 × 9.5 in` drawn in English). #[test] diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 2c334a214..355b77b81 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2599,3 +2599,8 @@ Existing File or Web Page Съществуващ файл или уеб страница Link to: Връзка към: Place in This Document Място в този документ +# Table Layout › Sort dialog (#1205) + Column {n} Колона {n} + Header row Заглавен ред + No header row Без заглавен ред + My list has Списъкът ми има diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index ac88f6270..7915e5939 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2603,3 +2603,8 @@ preview Title Ukázka Existing File or Web Page Existující soubor nebo webová stránka Link to: Odkaz na: Place in This Document Místo v tomto dokumentu +# Table Layout › Sort dialog (#1205) + Column {n} Sloupec {n} + Header row Řádek záhlaví + No header row Bez řádku záhlaví + My list has Můj seznam má diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 6ef5b55ea..8d6e1e990 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2211,3 +2211,8 @@ Existing File or Web Page Datei oder Webseite Link to: Link zu: Place in This Document Aktuelles Dokument +# Table Layout › Sort dialog (#1205) + Column {n} Spalte {n} + Header row Überschriftszeile + No header row Keine Überschriftszeile + My list has Meine Liste hat diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 2b48906e4..d6e207203 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2578,3 +2578,8 @@ preview Title Título Existing File or Web Page Archivo o página web existente Link to: Vincular a: Place in This Document Lugar de este documento +# Table Layout › Sort dialog (#1205) + Column {n} Columna {n} + Header row Fila de encabezado + No header row Sin fila de encabezado + My list has Mi lista tiene diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index f10592d83..642e6dba8 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -2246,3 +2246,8 @@ Existing File or Web Page Olemasolev fail või veebileht Link to: Link: Place in This Document Koht selles dokumendis +# Table Layout › Sort dialog (#1205) + Column {n} Veerg {n} + Header row Päiserida + No header row Päiserida puudub + My list has Minu loendil on diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index e87468d34..6fb1930e0 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2219,3 +2219,8 @@ Existing File or Web Page Fichier ou page web existant(e) Link to: Lier à : Place in This Document Emplacement dans ce document +# Table Layout › Sort dialog (#1205) + Column {n} Colonne {n} + Header row Ligne d’en-tête + No header row Pas de ligne d’en-tête + My list has Ma liste a diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index fe96885e2..7b165aa9f 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2607,3 +2607,8 @@ Existing File or Web Page Létező fájl vagy weblap Link to: Hivatkozás helye: Place in This Document Dokumentumon belüli hely +# Table Layout › Sort dialog (#1205) + Column {n} {n}. oszlop + Header row Fejlécsor + No header row Nincs fejlécsor + My list has A listám tartalmaz diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 3fefb9a70..a59b8264e 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2620,3 +2620,8 @@ preview Title Esempio Existing File or Web Page File o pagina Web esistente Link to: Collega a: Place in This Document Posizione nel documento +# Table Layout › Sort dialog (#1205) + Column {n} Colonna {n} + Header row Riga di intestazione + No header row Nessuna riga di intestazione + My list has L’elenco ha diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 23683d933..9ac109f29 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2212,3 +2212,8 @@ Existing File or Web Page 既存のファイル/Web ページ Link to: リンク先: Place in This Document このドキュメント内 +# Table Layout › Sort dialog (#1205) + Column {n} 列 {n} + Header row 見出し行あり + No header row 見出し行なし + My list has 範囲の先頭行 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index ad6f93a05..03852b97f 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2601,3 +2601,8 @@ preview Title Tytuł Existing File or Web Page Istniejący plik lub strona sieci Web Link to: Połącz z: Place in This Document Miejsce w tym dokumencie +# Table Layout › Sort dialog (#1205) + Column {n} Kolumna {n} + Header row Wiersz nagłówka + No header row Bez wiersza nagłówka + My list has Moja lista ma diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 4cf2e7130..d200ac002 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2224,3 +2224,8 @@ Existing File or Web Page Página da Web ou arquivo existente Link to: Vincular a: Place in This Document Colocar neste documento +# Table Layout › Sort dialog (#1205) + Column {n} Coluna {n} + Header row Linha de cabeçalho + No header row Sem linha de cabeçalho + My list has Minha lista tem diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 8df73bb59..ea21882e0 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2609,3 +2609,8 @@ preview Title Пример Existing File or Web Page Файл или веб-страница Link to: Связать с: Place in This Document Место в документе +# Table Layout › Sort dialog (#1205) + Column {n} Столбец {n} + Header row Строка заголовка + No header row Без строки заголовка + My list has Мой список содержит diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 8d3d5690b..42bc8b4b1 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -2222,3 +2222,8 @@ Existing File or Web Page Postojeća datoteka ili veb stranica Link to: Veza ka: Place in This Document Mesto u ovom dokumentu +# Table Layout › Sort dialog (#1205) + Column {n} Kolona {n} + Header row Red zaglavlja + No header row Bez reda zaglavlja + My list has Moja lista ima diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index e881793d9..8c13194e6 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2226,3 +2226,8 @@ Existing File or Web Page Постојећа датотека или веб страница Link to: Веза ка: Place in This Document Место у овом документу +# Table Layout › Sort dialog (#1205) + Column {n} Колона {n} + Header row Ред заглавља + No header row Без реда заглавља + My list has Моја листа има diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 4be49fe67..55ede8698 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2608,3 +2608,8 @@ preview Title Örnek Existing File or Web Page Varolan Dosya veya Web Sayfası Link to: Bağlantı: Place in This Document Bu Belgede Bir Yer +# Table Layout › Sort dialog (#1205) + Column {n} Sütun {n} + Header row Başlık satırı + No header row Başlık satırı yok + My list has Listemde diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 853c89915..0b89aad19 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2224,3 +2224,8 @@ Existing File or Web Page Файл або веб-сторінка Link to: Зв'язати з: Place in This Document Місце в документі +# Table Layout › Sort dialog (#1205) + Column {n} Стовпець {n} + Header row Рядок заголовка + No header row Без рядка заголовка + My list has Мій список містить diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 952e29ace..f0f29c7c3 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2642,3 +2642,8 @@ Local font access is not supported by your browser. Trình duyệt của bạn không hỗ trợ truy cập phông chữ cục bộ. Local font access was denied. Quyền truy cập phông chữ cục bộ đã bị từ chối. Loaded {count} system fonts. Đã tải {count} phông chữ hệ thống. +# Table Layout › Sort dialog (#1205) + Column {n} Cột {n} + Header row Hàng tiêu đề + No header row Không có hàng tiêu đề + My list has Danh sách của tôi có diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 412e1dfa3..ec70c764e 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2224,3 +2224,8 @@ Existing File or Web Page 现有文件或网页 Link to: 链接到: Place in This Document 本文档中的位置 +# Table Layout › Sort dialog (#1205) + Column {n} 列 {n} + Header row 有标题行 + No header row 无标题行 + My list has 列表包含 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index c7d541da5..04c8284cb 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2213,3 +2213,8 @@ Existing File or Web Page 現有的檔案或網頁 Link to: 連結至: Place in This Document 這份文件中的位置 +# Table Layout › Sort dialog (#1205) + Column {n} 欄 {n} + Header row 有標題列 + No header row 無標題列 + My list has 清單包含 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5c64711c6..057201e39 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2254,6 +2254,8 @@ fn input_dialog(id: &str, params: &Value) -> Option<&'static str> { "chart.editData" if !["data", "categories", "series"].into_iter().any(has) => Some("chartData"), // Table Properties without settings shows the dialog (with settings it applies them). "table.properties" if params.as_object().is_none_or(|m| m.is_empty()) => Some("tableProperties"), + // Sort without settings asks for the column, order and header row (#1200). + "table.sort" if params.as_object().is_none_or(|m| m.is_empty()) => Some("tableSort"), // Tabs, Borders and Shading, and Page Borders without settings show their dialogs (#320). "para.tabs" if params.as_object().is_none_or(|m| m.is_empty()) => Some("tabs"), "para.borders" if params.as_object().is_none_or(|m| m.is_empty()) => Some("borders"), From 3292ba05d3c15c003823be6849e87a1c6e205242 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Thu, 15 Oct 2026 05:34:17 +0200 Subject: [PATCH 076/101] =?UTF-8?q?Table=20Layout=20=E2=80=BA=20Formula=20?= =?UTF-8?q?opens=20a=20Formula=20dialog=20with=20Word's=20suggested=20form?= =?UTF-8?q?ula=20(#1210)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/dialogs_layout.rs | 134 +++++++++++++++++++++++++-- crates/ui-egui/src/i18n/bg.tsv | 3 + crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/et.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/hu.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/sr-latn.tsv | 3 + crates/ui-egui/src/i18n/sr.tsv | 3 + crates/ui-egui/src/i18n/tr.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/vi.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 3 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + crates/ui-egui/src/lib.rs | 2 + 21 files changed, 186 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/dialogs_layout.rs b/crates/ui-egui/src/dialogs_layout.rs index 216100ba5..fb327b9f8 100644 --- a/crates/ui-egui/src/dialogs_layout.rs +++ b/crates/ui-egui/src/dialogs_layout.rs @@ -1,10 +1,10 @@ //! Layout, Review and Mailings dialogs (#407): Line Numbers (Layout › Line Numbers › Line //! Numbering Options…), Hyphenation (Hyphenation Options… and Manual…), Language (Review › -//! Language), Envelopes and Labels (Mailings › Create), and Sort (Table Layout › Data, #1200). -//! Each ends by running a command (`layout.lineNumbers`, `layout.hyphenation`, -//! `layout.manualHyphenation` with `caret.set` and `text.optionalHyphen`, `review.language`, -//! `mailings.envelopes`, `mailings.labels`, `table.sort`), so scripts -//! and agents get the same result without the dialog. Shown through +//! Language), Envelopes and Labels (Mailings › Create), Formula (Table Layout › Data, #1201) +//! and Sort (Table Layout › Data, #1200). Each ends by running a command (`layout.lineNumbers`, +//! `layout.hyphenation`, `layout.manualHyphenation` with `caret.set` and `text.optionalHyphen`, +//! `review.language`, `mailings.envelopes`, `mailings.labels`, `table.formula`, `table.sort`), so +//! scripts and agents get the same result without the dialog. Shown through //! [`crate::dialogs::Dialog::Layout`]. use egui::{Ui, vec2}; @@ -27,6 +27,7 @@ pub enum LayoutDialog { Language(LanguageForm), Envelopes(EnvelopeForm), Labels(LabelsForm), + Formula(FormulaForm), Sort(SortForm), } @@ -39,6 +40,7 @@ impl LayoutDialog { LayoutDialog::Language(_) => "language", LayoutDialog::Envelopes(_) => "envelopes", LayoutDialog::Labels(_) => "labels", + LayoutDialog::Formula(_) => "tableFormula", LayoutDialog::Sort(_) => "tableSort", } } @@ -52,6 +54,7 @@ impl LayoutDialog { LayoutDialog::Language(_) => "Language", LayoutDialog::Envelopes(_) => "Envelopes", LayoutDialog::Labels(_) => "Labels", + LayoutDialog::Formula(_) => "Formula", LayoutDialog::Sort(_) => "Sort", } } @@ -66,6 +69,7 @@ pub fn open(name: &str, app: &mut WordApp) -> Option { "language" => LayoutDialog::Language(LanguageForm::read(app)), "envelopes" => LayoutDialog::Envelopes(EnvelopeForm::read(app)), "labels" => LayoutDialog::Labels(LabelsForm::read(app)), + "tableFormula" => LayoutDialog::Formula(FormulaForm::read(app)?), "tableSort" => LayoutDialog::Sort(SortForm::read(app)?), _ => return None, }) @@ -80,6 +84,7 @@ pub fn body(app: &mut WordApp, ui: &mut Ui, d: &mut LayoutDialog) -> bool { LayoutDialog::Language(f) => language_ui(app, ui, f), LayoutDialog::Envelopes(f) => envelopes_ui(app, ui, f), LayoutDialog::Labels(f) => labels_ui(app, ui, f), + LayoutDialog::Formula(f) => formula_ui(app, ui, f), LayoutDialog::Sort(f) => sort_ui(app, ui, f), } } @@ -655,6 +660,80 @@ fn labels_ui(app: &mut WordApp, ui: &mut Ui, f: &mut LabelsForm) -> bool { cancel } +// --------------------------------------------------------------------------------------------- +// Formula + +/// The functions `table.formula` works out, offered by Paste function. +pub const FORMULA_FUNCTIONS: [&str; 6] = ["AVERAGE", "COUNT", "MAX", "MIN", "PRODUCT", "SUM"]; + +/// The table Formula dialog's field: the formula, suggested as Word does from the numbers next +/// to the caret's cell (`=SUM(ABOVE)`, else `=SUM(LEFT)`, else a bare `=`). +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FormulaForm { + pub formula: String, +} + +impl FormulaForm { + /// The caret's cell; none outside a table. + pub fn read(app: &WordApp) -> Option { + let s = &app.session; + let (tp, r, c) = s.sel.focus.path.cell()?; + let t = s.doc.table(s.sel.focus.story, &tp)?; + let lang = wordcraft_engine::cmd::default_lang(s); + let number = |row: Option, col: Option| { + let cell = t.rows.get(row?)?.cells.get(col?)?; + let text = cell.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect::>().join(" "); + wordcraft_doc::locale_number::parse_number(text.trim(), lang.as_deref()) + }; + let formula = if number(r.checked_sub(1), Some(c)).is_some() { + "=SUM(ABOVE)" + } else if number(Some(r), c.checked_sub(1)).is_some() { + "=SUM(LEFT)" + } else { + "=" + }; + Some(FormulaForm { formula: formula.into() }) + } + + /// Paste function: `NAME()` at the end of the formula. + pub fn paste(&mut self, name: &str) { + self.formula.push_str(name); + self.formula.push_str("()"); + } + + /// `table.formula` parameters for OK. + pub fn params(&self) -> Value { + json!({"formula": self.formula.trim()}) + } +} + +fn formula_ui(app: &mut WordApp, ui: &mut Ui, f: &mut FormulaForm) -> bool { + ui.label(tl!("Formula:")); + ui.add(egui::TextEdit::singleline(&mut f.formula).desired_width(300.0)); + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.label(tl!("Paste function:")); + let mut picked = None; + egui::ComboBox::from_id_salt("formula_paste").selected_text("").width(140.0).show_ui(ui, |ui| { + for name in FORMULA_FUNCTIONS { + if ui.selectable_label(false, name).clicked() { + picked = Some(name); + } + } + }); + if let Some(name) = picked { + f.paste(name); + } + }); + let ready = f.formula.trim().starts_with('=') && f.formula.trim().len() > 1; + let (ok, cancel) = buttons(ui, "OK", ready); + if ok { + return run(app, "table.formula", f.params()); + } + cancel +} + // --------------------------------------------------------------------------------------------- // Sort @@ -836,7 +915,8 @@ mod tests { assert!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("Thanks.")); } - /// A table of `cells`, `cols` wide, typed cell by cell; the caret ends in the last cell. + /// A table `cols` wide, typed cell by cell (empty cells are skipped over); the caret ends in + /// the last cell. fn table_app(cols: usize, cells: &[&str]) -> WordApp { let mut a = app(); a.run("insert.table", json!({"rows": cells.len() / cols, "cols": cols})).unwrap(); @@ -844,11 +924,51 @@ mod tests { if k > 0 { a.run("text.tab", json!({})).unwrap(); } - a.run("text.insert", json!({"text": c})).unwrap(); + if !c.is_empty() { + a.run("text.insert", json!({"text": c})).unwrap(); + } } a } + fn fields(a: &WordApp) -> Vec<(String, String)> { + let mut out = Vec::new(); + a.session.doc.objects_in_reading_order(&mut |o| { + if let wordcraft_doc::InlineObject::Field { instr, result, .. } = o { + out.push((instr.clone(), result.clone())); + } + }); + out + } + + /// #1201: Table Layout › Formula opens its dialog instead of inserting `=SUM(ABOVE)` at once. + /// The formula is suggested from the numbers next to the cell (above, else to the left), Paste + /// function adds a function to it, and OK inserts the field of the formula as edited. + #[test] + fn table_formula_opens_its_dialog() { + let mut a = table_app(2, &["Item", "Cost", "Tea", "3", "Jam", "4", "Total", ""]); + assert_eq!(a.run("table.formula", json!({})).unwrap(), json!({"pending": "tableFormula"})); + assert!(fields(&a).is_empty(), "nothing inserted yet"); + let LayoutDialog::Formula(mut f) = form(&mut a) else { panic!("formula") }; + assert_eq!(f.formula, "=SUM(ABOVE)"); + f.formula = "=".into(); + f.paste("MAX"); + assert_eq!(f.formula, "=MAX()"); + f.formula = "=MAX(ABOVE)".into(); + a.run("table.formula", f.params()).unwrap(); + assert_eq!(fields(&a), [("=MAX(ABOVE)".to_string(), "4".to_string())]); + assert!(a.dialog.is_none()); + + let mut a = table_app(3, &["Tea", "3", "", "Jam", "4", ""]); + a.run("table.formula", json!({})).unwrap(); + let LayoutDialog::Formula(f) = form(&mut a) else { panic!("formula") }; + assert_eq!(f.formula, "=SUM(LEFT)", "nothing above, numbers to the left"); + + let mut a = app(); + a.run("ui.dialog", json!({"name": "tableFormula"})).unwrap(); + assert!(a.dialog.is_none(), "no dialog outside a table"); + } + fn first_column(a: &WordApp) -> Vec { let t = a.session.doc.body.iter().find_map(|b| b.as_table()).unwrap(); t.rows.iter().map(|r| r.cells[0].blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect()).collect() diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 355b77b81..3f228634b 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2604,3 +2604,6 @@ Header row Заглавен ред No header row Без заглавен ред My list has Списъкът ми има +# Table Layout › Formula dialog (#1210) + Formula: Формула: + Paste function: Поставяне на функция: diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 7915e5939..79e09a03b 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2608,3 +2608,6 @@ preview Title Ukázka Header row Řádek záhlaví No header row Bez řádku záhlaví My list has Můj seznam má +# Table Layout › Formula dialog (#1210) + Formula: Vzorec: + Paste function: Vložit funkci: diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 8d6e1e990..f15c032fc 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2216,3 +2216,6 @@ Header row Überschriftszeile No header row Keine Überschriftszeile My list has Meine Liste hat +# Table Layout › Formula dialog (#1210) + Formula: Formel: + Paste function: Funktion einfügen: diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index d6e207203..0e9dfb604 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2583,3 +2583,6 @@ preview Title Título Header row Fila de encabezado No header row Sin fila de encabezado My list has Mi lista tiene +# Table Layout › Formula dialog (#1210) + Formula: Fórmula: + Paste function: Pegar función: diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 642e6dba8..d68f1f917 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -2251,3 +2251,6 @@ Header row Päiserida No header row Päiserida puudub My list has Minu loendil on +# Table Layout › Formula dialog (#1210) + Formula: Valem: + Paste function: Kleebi funktsioon: diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 6fb1930e0..80b8603e1 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2224,3 +2224,6 @@ Header row Ligne d’en-tête No header row Pas de ligne d’en-tête My list has Ma liste a +# Table Layout › Formula dialog (#1210) + Formula: Formule : + Paste function: Coller une fonction : diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 7b165aa9f..0b4287e90 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2612,3 +2612,6 @@ Header row Fejlécsor No header row Nincs fejlécsor My list has A listám tartalmaz +# Table Layout › Formula dialog (#1210) + Formula: Képlet: + Paste function: Függvény beillesztése: diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index a59b8264e..51f35a5c3 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2625,3 +2625,6 @@ preview Title Esempio Header row Riga di intestazione No header row Nessuna riga di intestazione My list has L’elenco ha +# Table Layout › Formula dialog (#1210) + Formula: Formula: + Paste function: Incolla funzione: diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 9ac109f29..916be38ac 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2217,3 +2217,6 @@ Header row 見出し行あり No header row 見出し行なし My list has 範囲の先頭行 +# Table Layout › Formula dialog (#1210) + Formula: 計算式: + Paste function: 関数の貼り付け: diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 03852b97f..c73037abb 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2606,3 +2606,6 @@ preview Title Tytuł Header row Wiersz nagłówka No header row Bez wiersza nagłówka My list has Moja lista ma +# Table Layout › Formula dialog (#1210) + Formula: Formuła: + Paste function: Wklej funkcję: diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index d200ac002..3ab3cb4fa 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2229,3 +2229,6 @@ Header row Linha de cabeçalho No header row Sem linha de cabeçalho My list has Minha lista tem +# Table Layout › Formula dialog (#1210) + Formula: Fórmula: + Paste function: Colar função: diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index ea21882e0..2ed4211c6 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2614,3 +2614,6 @@ preview Title Пример Header row Строка заголовка No header row Без строки заголовка My list has Мой список содержит +# Table Layout › Formula dialog (#1210) + Formula: Формула: + Paste function: Вставить функцию: diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 42bc8b4b1..7112a6448 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -2227,3 +2227,6 @@ Header row Red zaglavlja No header row Bez reda zaglavlja My list has Moja lista ima +# Table Layout › Formula dialog (#1210) + Formula: Formula: + Paste function: Nalepi funkciju: diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 8c13194e6..3177ee59f 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2231,3 +2231,6 @@ Header row Ред заглавља No header row Без реда заглавља My list has Моја листа има +# Table Layout › Formula dialog (#1210) + Formula: Формула: + Paste function: Налепи функцију: diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 55ede8698..749ebcd6a 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2613,3 +2613,6 @@ preview Title Örnek Header row Başlık satırı No header row Başlık satırı yok My list has Listemde +# Table Layout › Formula dialog (#1210) + Formula: Formül: + Paste function: İşlev yapıştır: diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 0b89aad19..d5709fee2 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2229,3 +2229,6 @@ Header row Рядок заголовка No header row Без рядка заголовка My list has Мій список містить +# Table Layout › Formula dialog (#1210) + Formula: Формула: + Paste function: Вставити функцію: diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index f0f29c7c3..9ddf7f8ab 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2647,3 +2647,6 @@ Header row Hàng tiêu đề No header row Không có hàng tiêu đề My list has Danh sách của tôi có +# Table Layout › Formula dialog (#1210) + Formula: Công thức: + Paste function: Dán hàm: diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index ec70c764e..535af920c 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2229,3 +2229,6 @@ Header row 有标题行 No header row 无标题行 My list has 列表包含 +# Table Layout › Formula dialog (#1210) + Formula: 公式: + Paste function: 粘贴函数: diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 04c8284cb..246e63c6a 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2218,3 +2218,6 @@ Header row 有標題列 No header row 無標題列 My list has 清單包含 +# Table Layout › Formula dialog (#1210) + Formula: 公式: + Paste function: 貼上函數: diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 057201e39..5c01d76ad 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2254,6 +2254,8 @@ fn input_dialog(id: &str, params: &Value) -> Option<&'static str> { "chart.editData" if !["data", "categories", "series"].into_iter().any(has) => Some("chartData"), // Table Properties without settings shows the dialog (with settings it applies them). "table.properties" if params.as_object().is_none_or(|m| m.is_empty()) => Some("tableProperties"), + // Formula without one asks for it, suggested from the numbers next to the cell (#1201). + "table.formula" if !has("formula") => Some("tableFormula"), // Sort without settings asks for the column, order and header row (#1200). "table.sort" if params.as_object().is_none_or(|m| m.is_empty()) => Some("tableSort"), // Tabs, Borders and Shading, and Page Borders without settings show their dialogs (#320). From e5d9b3e7cf5f2c02dfeb6be5387c179bded1d978 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 23:44:26 -0400 Subject: [PATCH 077/101] i18n: add Korean UI catalog and font fallback (#1216) Korean interface catalog and Hangul font fallback by @heogun (#1216), merged with main. Co-authored-by: heogun --- ROADMAP.md | 3 +- crates/fonts/src/lib.rs | 29 +- crates/fonts/src/ui_fallback.rs | 67 +- crates/ui-egui/src/canvas.rs | 8 +- crates/ui-egui/src/i18n/ko.tsv | 2476 ++++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/mod.rs | 20 +- crates/ui-egui/src/i18n/tests.rs | 66 +- crates/ui-egui/src/lib.rs | 9 +- crates/ui-egui/src/theme.rs | 71 +- docs/gaps.md | 2 +- docs/localization-parity.md | 4 +- 11 files changed, 2705 insertions(+), 50 deletions(-) create mode 100644 crates/ui-egui/src/i18n/ko.tsv diff --git a/ROADMAP.md b/ROADMAP.md index e8be2db0c..1fc66c047 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -109,7 +109,7 @@ Weights and evidence per area: [docs/target-app-parity.md](docs/target-app-parit | Indonesian | id | none | 0% | | Japanese | ja | partial | ~95% | | German | de | partial (#574) | ~95% | -| Korean | ko | none | 0% | +| Korean | ko | partial (interface catalog; proofing unavailable) | 100% (2,463 entries) | | Vietnamese | vi | partial (#1434) | ~95% | 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 @@ -276,6 +276,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-13 | Korean interface catalog completed: all 2,463 entries translated; the ko-KR system locale and saved language choice select the interface. The UI prefers Korean embedded/system font faces so Hangul renders instead of boxes, and print settings and printer feedback are translated. Korean proofing remains unavailable; macOS Korean IME fixes are still pending (#163, #164) | | 2026-10-12 | Word default shortcuts expanded (gap #24): 140 commands bound on macOS (169 combinations), 158 on Linux/Windows (188 combinations). Bound Style Inspector (Shift+F1), Macros (Alt+F8), Strikethrough (Mod+Shift+X), Find Next (Shift+F4), Copy/Paste (Mod+Insert, Shift+Insert), Go Back (Mod+Alt+Z), File Open/Save/Save As/Print (Mod+Alt+F2, Alt+Shift+F2, Alt+F2, Mod+Alt+I), Field navigation (Alt+F1, Alt+Shift+F1), Outline show first line (Alt+Shift+L), Next Footnote (Alt+Shift+>), Link to Previous (Alt+Shift+R), AutoText (Alt+F3), Spelling on Mac (Mod+Alt+L) | | 2026-10-13 | Web and Draft Layout now render visible full-resolution raster tiles for exceptionally tall pages rather than downsampling the entire page into one texture; verified with a 440-paragraph headless UI reproduction | | 2026-10-12 | Track Changes: Accept All after a tracked deletion from the end of one paragraph into a later one no longer joins the paragraph that follows the range, and Reject All after typing into an empty paragraph keeps that paragraph (#864) | diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 5e5ff26af..994a98f29 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -24,7 +24,7 @@ pub use kurbo::BezPath; pub use script::{fallback_rank, needs_layout_tables, script_fallbacks}; use skrifa::MetadataProvider; use skrifa::instance::Size; -pub use ui_fallback::{SystemUiFont, is_cjk, system_cjk_ui_font, ui_needs_system_cjk, ui_uncovered_cjk}; +pub use ui_fallback::{SystemUiFont, is_cjk, system_cjk_ui_font, system_cjk_ui_font_for, ui_needs_system_cjk, ui_uncovered_cjk}; /// A font from the optional craft-fonts build input (https://github.com/storytold/craft-fonts; /// empty unless built with `CRAFT_FONTS_DIR`, see `build.rs`). @@ -65,15 +65,32 @@ pub fn chinese_fonts() -> impl Iterator { CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&"Hans")) } -/// The CJK interface faces, Regular styles only (the UI fakes no bold): the Japanese UI face and -/// the Chinese face, Chinese first when `prefer_hans` (a Chinese interface), Japanese first -/// otherwise. Mixing the two within a line would mix glyph styles, so the first face should -/// cover the interface language. Empty without craft-fonts. +/// The craft-fonts faces for Korean (`"Kore"`), in manifest order. Empty without craft-fonts. +pub fn korean_fonts() -> impl Iterator { + CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&"Kore")) +} + +/// The CJK interface faces, Regular styles only (the UI fakes no bold), with the Japanese UI, +/// Chinese, and Korean faces ordered for the selected interface language. Mixing the faces within +/// a line would mix glyph styles, so the first face should cover the interface language. Empty +/// without craft-fonts. pub fn ui_cjk_fonts(prefer_hans: bool) -> Vec<&'static CraftFont> { + ui_cjk_fonts_for(prefer_hans, false) +} + +/// The CJK interface faces in the preferred writing system's order. +pub fn ui_cjk_fonts_for(prefer_hans: bool, prefer_korean: bool) -> Vec<&'static CraftFont> { let regular = |f: &&CraftFont| f.style == "Regular"; let ja = japanese_ui_fonts(false).into_iter().filter(regular).take(1); let zh = chinese_fonts().filter(regular).take(1); - if prefer_hans { zh.chain(ja).collect() } else { ja.chain(zh).collect() } + let ko = korean_fonts().filter(regular).take(1); + if prefer_korean { + ko.chain(ja).chain(zh).collect() + } else if prefer_hans { + zh.chain(ja).chain(ko).collect() + } else { + ja.chain(zh).chain(ko).collect() + } } /// The bundled serif used when a document asks for nothing better. diff --git a/crates/fonts/src/ui_fallback.rs b/crates/fonts/src/ui_fallback.rs index 19ea62ae3..8f1956b71 100644 --- a/crates/fonts/src/ui_fallback.rs +++ b/crates/fonts/src/ui_fallback.rs @@ -70,16 +70,25 @@ const MAX_NEEDED: usize = 4096; /// as blank space. const OUTLINE_TABLES: [&[u8; 4]; 4] = [b"glyf", b"CFF ", b"CFF2", b"VARC"]; -/// A character only a font for the language has: a simplified-only hanzi, or kana. -fn probe(hans: bool) -> char { - if hans { '页' } else { 'の' } +/// A character only a font for the language has: a simplified-only hanzi, kana, or Hangul. +fn probe(hans: bool, korean: bool) -> char { + if korean { + '한' + } else if hans { + '页' + } else { + 'の' + } } -/// Font files to look for, relative to a font folder (or absolute), best first. Chinese faces -/// cover kana too, so the Japanese list ends with the Chinese one. -fn candidates(hans: bool) -> Vec<&'static str> { - let (zh, ja): (&[&str], &[&str]) = if cfg!(windows) { - (&["Deng.ttf", "msyh.ttc", "simhei.ttf", "simsun.ttc", "NotoSansSC-VF.ttf"], &["YuGothR.ttc", "meiryo.ttc", "msgothic.ttc"]) +/// Font files to look for, relative to a font folder (or absolute), best first. +fn candidates(hans: bool, korean: bool) -> Vec<&'static str> { + let (zh, ja, ko): (&[&str], &[&str], &[&str]) = if cfg!(windows) { + ( + &["Deng.ttf", "msyh.ttc", "simhei.ttf", "simsun.ttc", "NotoSansSC-VF.ttf"], + &["YuGothR.ttc", "meiryo.ttc", "msgothic.ttc"], + &["malgun.ttf", "malgunsl.ttf", "gulim.ttc", "batang.ttc"], + ) } else if cfg!(target_os = "macos") { ( &[ @@ -92,6 +101,7 @@ fn candidates(hans: bool) -> Vec<&'static str> { "/System/Library/PrivateFrameworks/FontServices.framework/Resources/Reserved/PingFangUI.ttc", ], &["ヒラギノ角ゴシック W3.ttc", "Hiragino Sans W3.ttc"], + &["/System/Library/Fonts/AppleSDGothicNeo.ttc", "/System/Library/Fonts/Supplemental/AppleGothic.ttf"], ) } else { ( @@ -110,18 +120,37 @@ fn candidates(hans: bool) -> Vec<&'static str> { "truetype/droid/DroidSansFallbackFull.ttf", ], &["noto-cjk/NotoSansCJKjp-Regular.otf", "opentype/noto/NotoSansCJKjp-Regular.otf"], + &[ + "noto-cjk/NotoSansCJKkr-Regular.otf", + "opentype/noto/NotoSansCJKkr-Regular.otf", + "google-noto-sans-cjk-fonts/NotoSansCJKkr-Regular.otf", + "adobe-source-han-sans/SourceHanSansK-Regular.otf", + "opentype/adobe-source-han-sans/SourceHanSansK-Regular.otf", + "truetype/nanum/NanumGothic.ttf", + ], ) }; - if hans { zh.to_vec() } else { ja.iter().chain(zh).copied().collect() } + if korean { + ko.iter().chain(ja).chain(zh).copied().collect() + } else if hans { + zh.to_vec() + } else { + ja.iter().chain(zh).copied().collect() + } } /// The installed CJK font for a Chinese (`hans`) or other interface that best covers `needed` /// (the characters the embedded faces lack; empty: a character typical of the language), if one /// is found. Never panics on damaged files. pub fn system_cjk_ui_font(hans: bool, needed: &[char]) -> Option { - let probe = [probe(hans)]; + system_cjk_ui_font_for(hans, false, needed) +} + +/// The installed CJK font for a Chinese, Korean or Japanese interface. +pub fn system_cjk_ui_font_for(hans: bool, korean: bool, needed: &[char]) -> Option { + let probe = [probe(hans, korean)]; let needed = if needed.is_empty() { &probe[..] } else { needed.get(..MAX_NEEDED).unwrap_or(needed) }; - find_ui_font(&with_asset_data(crate::system_font_dirs()), &candidates(hans), needed, hans) + find_ui_font(&with_asset_data(crate::system_font_dirs()), &candidates(hans, korean), needed, hans) } /// `dirs` and, for each macOS font asset folder among them (`com_apple_MobileAsset_Font`), @@ -210,6 +239,22 @@ pub(crate) fn pick_face(data: &[u8], needed: &[char], hans: bool) -> Option<(u32 mod tests { use super::*; + #[test] + fn korean_interface_uses_korean_first_system_font_candidates() { + let names = candidates(false, true); + assert_eq!( + names.first(), + Some(&if cfg!(windows) { + "malgun.ttf" + } else if cfg!(target_os = "macos") { + "/System/Library/Fonts/AppleSDGothicNeo.ttc" + } else { + "noto-cjk/NotoSansCJKkr-Regular.otf" + }) + ); + assert_eq!(probe(false, true), '한'); + } + #[test] fn needs_system_cjk_when_an_embedded_face_lacks_an_interface_character() { // A face without CJK glyphs stands in for a craft-fonts face that lacks some (#486). diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8d2ebb320..e43dbc126 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1389,7 +1389,13 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { ); if !new.is_empty() { app.canvas.ime_faces.extend(new); - crate::theme::install_fonts_extra(ui.ctx(), app.fonts_hans, app.fonts_system_cjk, &app.canvas.ime_faces); + crate::theme::install_fonts_language_extra( + ui.ctx(), + app.fonts_hans, + app.fonts_korean, + app.fonts_system_cjk, + &app.canvas.ime_faces, + ); ui.ctx().request_repaint(); } let caret_color = caret_color(app); diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv new file mode 100644 index 000000000..47c2baa99 --- /dev/null +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -0,0 +1,2476 @@ +# Korean (ko) interface catalog. Same format and clean-room rules as zh-hans.tsv. + + (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로 처음부터 새로 만든 무료 오픈 소스 워드 프로세서입니다.\nArtCraft 팀의 Crafting Apps 제품군에 속합니다. + About 정보 + About WordCraft WordCraft 정보 + Accept 수락 + Accept All Changes 모든 변경 내용 수락 + Accept This Change 이 변경 내용 수락 + Accessibility 접근성 + Add 추가 + Add a comment 댓글 추가 + Add a Table 표 추가 + 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 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 caps 모두 대문자 + All Markup 모든 변경 내용 + Allow hanging punctuation 문장 부호 내어쓰기 허용 + Allow Latin text to wrap in the middle of a word 라틴 문자가 단어 중간에서 줄 바꿈되도록 허용 + Alt Text 대체 텍스트 + Apply Asian line-breaking rules (kinsoku) 아시아 문자 줄 바꿈 규칙(금칙 처리) 적용 + Apply Character Style 문자 스타일 적용 + Apply Style 스타일 적용 + Arrange 정렬 + Arrange\nAll 모두\n정렬 + Arrow 화살표 + Artistic Effects 예술 효과 + As you type 입력할 때 + Asian text font: 아시아 문자 글꼴: + Asian Typography 아시아 문자 조판 + Author 작성자 + AutoCorrect Options 자동 고침 옵션 + AutoFit 자동 맞춤 + AutoFit Contents 내용에 맞게 자동 맞춤 + AutoFit Window 창에 맞게 자동 맞춤 + AutoSave writes your changes to the file a moment after you stop typing. 입력을 멈추면 잠시 후 자동 저장이 변경 내용을 파일에 기록합니다. + Automatic 자동 + Automatic ({language}) 자동({language}) + Automatic follows your system's language. Menus and commands change; your documents don't. 자동은 시스템 언어를 따릅니다. 메뉴와 명령의 언어만 바뀌며 문서 내용은 바뀌지 않습니다. + Automatic Table 1 (3 levels) 자동 목차 1(3수준) + Automatic Table 2 (2 levels) 자동 목차 2(2수준) + 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 작성자별 + Centimeters 센티미터 + Changed lines: 변경된 줄: + Check spelling as you type 입력할 때 맞춤법 검사 + Click level to modify: 수정할 수준을 클릭하세요: + Click to switch back to inserting 클릭하여 삽입 모드로 전환 + Color only 색만 + Custom dictionary 사용자 사전 + Define New Multilevel List 새 다단계 목록 정의 + Define New Multilevel List… 새 다단계 목록 정의… + Deletions: 삭제: + Enter formatting for number: 번호 서식 입력: + Follow number with: 번호 뒤에 입력할 항목: + Formatting 서식 + Formatting marks 서식 기호 + Hidden text 숨겨진 텍스트 + Ignore web and file addresses 웹 주소와 파일 주소 무시 + Ignore words in capitals 대문자로 된 단어 무시 + Ignore words with numbers 숫자가 포함된 단어 무시 + Inches 인치 + Formatting exceptions 서식 예외 + Include level number from: 다음 수준 번호 포함: + Initials: 이니셜: + Ink 잉크 + Insertions and Deletions 삽입 및 삭제 + Insertions: 삽입: + Interface 인터페이스 + Interface language 인터페이스 언어 + Inside: 안쪽: + Latin text font: 라틴 문자 글꼴: + Left border 왼쪽 테두리 + Legal style numbering 법률 형식 번호 매기기 + Link level to style: 수준을 스타일에 연결: + List Definition 목록 정의 + 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 좌우 대칭 들여쓰기 + Nothing 없음 + Number alignment: 번호 맞춤: + Number format 번호 서식 + Number style for this level: 이 수준의 번호 스타일: + Outside border 바깥쪽 테두리 + 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: 표시할 최근 문서 수: + Remove 제거 + Outside: 바깥쪽: + Restart list after: 다음 항목 뒤에서 목록 다시 시작: + Right border 오른쪽 테두리 + Save documents 문서 저장 + 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 상용구 + Backspace 백스페이스 + Banded Columns 줄무늬 열 + Banded Rows 줄무늬 행 + Base text: 기준 텍스트: + Before: 앞쪽: + Bibliography 참고 문헌 + Binary assets added 추가된 바이너리 에셋 + Binary assets removed 삭제된 바이너리 에셋 + Blank 빈 문서 + Blank (Three Columns) 빈 문서(3단) + Blank document 빈 문서 + Blank Page 빈 페이지 + Block\nAuthors 작성자\n차단 + Bold 굵게 + Bookmark 책갈피 + Bookmark name: 책갈피 이름: + Border Painter 테두리 그리기 + Border\nPainter 테두리\n그리기 + Borders 테두리 + Search 검색 + Show the mini toolbar when text is selected 텍스트를 선택할 때 미니 도구 모음 표시 + Spaces 공백 + Spelling 맞춤법 + Spelling and grammar are checked in English (United States). 맞춤법과 문법은 영어(미국)로 검사합니다. + Tab characters 탭 문자 + Tab stop position: 탭 위치: + Default tab stops: 기본 탭 위치: + Tab stops to be cleared: 지울 탭 위치: + All 모두 + Decimal 소수점 + Bar 세로줄 + Leader 채움선 + Set 설정 + Page Border 페이지 테두리 + Apply to: 적용 대상: + These marks show even when Show/Hide ¶ is off. 이 기호는 표시/숨기기 ¶가 꺼져 있어도 표시됩니다. + Title Bar 제목 표시줄 + Typing replaces selected text 입력한 텍스트로 선택한 텍스트 바꾸기 + Update fields before printing 인쇄하기 전에 필드 업데이트 + Use the Insert key to switch overtype on and off Insert 키를 눌러 겹쳐쓰기 켜기/끄기 + Whole document 문서 전체 + This section 이 구역 + Fill 채우기 + Setting: 설정: + 3-D 3차원 + 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 for Errors 오류 검사 + Check Spelling as You Type 입력할 때 맞춤법 검사 + 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 커뮤니티\nDiscord + 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 수정 사항 + Couldn't make WordCraft the default: {error} WordCraft를 기본 앱으로 설정하지 못했습니다: {error} + Count words in text boxes and notes 텍스트 상자와 메모의 단어 수 포함 + Cover Page 표지 + Create 만들기 + 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시작하려면 홈 탭으로 이동해 문서의 제목에 제목 스타일을 적용하세요. + Create New Style 새 스타일 만들기 + 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 디자인 + 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… Word 문서의 기본 앱으로 WordCraft 설정… + 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. 설정 › 기본 앱을 열어 .docx 및 다른 문서 형식의 기본 앱으로 WordCraft를 선택합니다. + Reopen 다시 열기 + Right Margin 오른쪽 여백 + Distribute letters 문자 균등 분할 + Distribute spacing 간격 균등 분할 + Offset (pt): 간격(pt): + Phonetic Guide 윗주 + 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 마름모 + Dictate 받아쓰기 + Different First Page 첫 페이지 다르게 지정 + Different Odd & Even Pages 홀수 페이지와 짝수 페이지 다르게 지정 + Direct formatting 직접 서식 + Discord Discord + Discord community Discord 커뮤니티 + Display 표시 + Display for Review 검토용 표시 + Distribute Columns 열 균등 분할 + Distribute Rows 행 균등 분할 + 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 초안 + Draw 그리기 + Drawing Tools 그리기 도구 + Drawing\nCanvas 그리기\n캔버스 + Drop Cap 문단 첫 글자 장식 + Drops all formatting and pastes plain text. 모든 서식을 제거하고 일반 텍스트로 붙여넣습니다. + 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 봉투 + Equation 수식 + 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 에이전트에서 사용할 수 있습니다. MCP 서버를 실행하려면 `wordcraft-cli mcp`를 실행하거나, JSON 제어 채널을 사용하려면 앱을 `--control `로 시작하세요. + Export 내보내기 + Export a copy to share (PDF, Word document) 공유할 사본 내보내기(PDF, Word 문서) + Export Page as PNG 페이지를 PNG로 내보내기 + Export PDF PDF 내보내기 + Exported {path} {path} 내보냄 + Extend Selection 선택 영역 확장 + Extend Selection by Units 단위별로 선택 영역 확장 + Field 필드 + File 파일 + Find 찾기 + Find and Replace 찾기 및 바꾸기 + Find Next 다음 찾기 + Find Previous 이전 찾기 + Find Recipient 받는 사람 찾기 + Find what: 찾을 내용: + Finish 마침 + Finish & Merge 완료 및 병합 + Finish &\nMerge 완료 및\n병합 + First Column 첫 번째 열 + First commit 첫 번째 커밋 + First line: 첫 줄: + Fixed Column Width 고정 열 너비 + Focus 집중 + Font 글꼴 + Font Color 글꼴 색 + Font color: 글꼴 색: + Font Dialog 글꼴 대화 상자 + Font Size 글꼴 크기 + 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 이동 + Go To (⌘⌥G) 이동(⌘⌥G) + Go To… 이동… + Grab bag 모음 + Greeting Line 인사말 + Greeting\nLine 인사말\n줄 + Gridlines 눈금선 + Group 그룹 + Grow Font 1 Point 글꼴 크기 1포인트 키우기 + 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 가로줄 + HTML Format HTML 형식 + Hyphenation 하이픈 넣기 + Icons 아이콘 + Ignore All 모두 무시 + Illustrations 일러스트레이션 + Immersive 몰입형 + Immersive Reader 몰입형 리더 + Immersive\nReader 몰입형\n리더 + Increase Font Size 글꼴 크기 키우기 + Increase Indent 들여쓰기 늘리기 + Indent 들여쓰기 + Indentation 들여쓰기 + Indents 들여쓰기 + Index 색인 + Info 정보 + Ink to\nMath 잉크를\n수식으로 + Ink to\nShape 잉크를\n도형으로 + Ink\nReplay 잉크\n재생 + Insert 삽입 + Insert Above 위에 삽입 + Insert Below 아래에 삽입 + Insert Caption 캡션 삽입 + 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 Discord에서 ArtCraft 커뮤니티에 참여하세요 + Join us on Discord: discord.gg/artcraft Discord에서 함께하세요: discord.gg/artcraft + Justify 양쪽 맞춤 + Keep Lines Together 현재 단락의 모든 줄을 한 페이지에 유지 + Keep lines together 현재 단락의 모든 줄을 한 페이지에 유지 + Keep Text Only 텍스트만 유지 + 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: 왼쪽: + Letter 레터 + Line 줄 + Line and Page Breaks 줄 및 페이지 나누기 + Line and Paragraph Spacing 줄 및 단락 간격 + Line Break 줄 바꿈 + Line delta 줄 간격 변경량 + Line End 줄 끝 + Line Numbers 줄 번호 + Line Spacing 줄 간격 + Line Spacing Options… 줄 간격 옵션… + Line spacing: 줄 간격: + Line Start 줄 시작 + Lines 줄 + Lines added 추가된 줄 + Lines deleted 삭제된 줄 + Link 링크 + Link to Previous 이전 머리글/바닥글과 연결 + Links 링크 + Location: {path} 위치: {path} + lowercase 소문자 + Macros 매크로 + Mailings 편지 + Manage Sources 출처 관리 + Many pages 여러 페이지 + Margins 여백 + Mark Citation 인용 표시 + Mark Entry 항목 표시 + Mark\nCitation 인용\n표시 + Mark\nEntry 항목\n표시 + Markdown (*.md) Markdown (*.md) + Match case 대/소문자 구분 + Match Fields 필드 일치 + Matrix 행렬 + Merge 병합 + Merge Cells 셀 병합 + Merge Formatting 서식 병합 + Merged PRs 병합된 PR + Mirrored 좌우 대칭 + 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 보통 + Modify Style 스타일 수정 + Modify… 수정… + Move or Resize 이동 또는 크기 조정 + Multilevel List 다단계 목록 + Multiple Pages 여러 페이지 + Name (A–Z) 이름순(A–Z) + Name: 이름: + Narrow 0.5" all 좁게(모든 여백 0.5") + Navigation 탐색 + Navigation Pane 탐색 창 + New 새로 만들기 + New Comment 새 댓글 + New from Template 템플릿에서 새로 만들기 + New Paragraph 새 단락 + New Style… 새 스타일… + New Window 새 창 + New\nComment 새\n댓글 + New\nWindow 새\n창 + Next 다음 + Next Change 다음 변경 내용 + Next Comment 다음 댓글 + Next Footnote 다음 각주 + Next Page 다음 페이지 + Next Record 다음 레코드 + Next Section 다음 구역 + No Border 테두리 없음 + No Color 색 없음 + No contributor data was built into this copy. 이 빌드에는 기여자 정보가 포함되지 않았습니다. + No Markup 변경 내용 표시 안 함 + No model credits were built into this copy. 이 빌드에는 모델 크레딧이 포함되지 않았습니다. + No Paragraph Space 단락 간격 없음 + 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 번호 매기기 + Object 개체 + Odd Page 홀수 페이지 + Office 2003 Default Office 2003 기본값 + OK 확인 + One 하나 + One Page 한 페이지 + Open 열기 + OpenDocument Text (*.odt) OpenDocument Text (*.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 타원 + Page 페이지 + Page Background 페이지 배경 + Page Borders 페이지 테두리 + Page Break 페이지 나누기 + Page Break Before 앞에서 페이지 나누기 + Page break before 앞에서 페이지 나누기 + Page Breaks 페이지 나누기 + Page Color 페이지 색 + Page Down Page Down + Page image (*.png) 페이지 이미지 (*.png) + Page Movement 페이지 이동 + Page Number 페이지 번호 + Page Setup 페이지 설정 + Page Up Page Up + Page Width 페이지 너비 + Page width 페이지 너비 + Page X of Y 전체 X/Y페이지 + Page {page} {page}페이지 + Page {page} of {pages} 전체 {pages}페이지 중 {page}페이지 + 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 as: 다음 형식으로 붙여넣기: + Paste Formatting 서식 붙여넣기 + Paste Special 선택하여 붙여넣기 + Paste Special… 선택하여 붙여넣기… + Paste: Keep Text Only 붙여넣기: 텍스트만 유지 + Paste: Merge Formatting 붙여넣기: 서식 병합 + PDF document (*.pdf) PDF 문서 (*.pdf) + Pen 펜 + Pencil 연필 + 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. 클립보드의 웹 콘텐츠를 읽어 서식을 유지합니다. + Picture 그림 + Pastes the clipboard's picture. 클립보드의 그림을 붙여넣습니다. + Reading Highlight 읽기 강조 표시 + Recent 최근 항목 + Record Macro 매크로 기록 + Recover Unsaved Documents 저장하지 않은 문서 복구 + Rectangle 직사각형 + Redo 다시 실행 + Reduce Hanging Indent 내어쓰기 줄이기 + References 참조 + Reject 거부 + Reject All Changes 모든 변경 내용 거부 + Reject Change 변경 내용 거부 + Relaxed 여유 있게 + Remove Background 배경 제거 + Remove Footer 바닥글 제거 + Remove Header 머리글 제거 + Remove Hyperlink 하이퍼링크 제거 + Remove Space After Paragraph 단락 뒤 간격 제거 + Remove Table of Contents 목차 제거 + Remove Watermark 워터마크 제거 + 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 at 1 1부터 다시 시작 + Restart Each Page 각 페이지에서 다시 시작 + Restart Each Section 각 구역에서 다시 시작 + Restrict Editing 편집 제한 + Restrict\nEditing 편집\n제한 + Result 결과 + Results 결과 + Reverse the order 순서 반전 + Review 검토 + Reviewing 검토 중 + Reviewing Pane 검토 창 + Reviewing — edits become suggestions 검토 중 — 편집 내용이 제안으로 바뀝니다 + Rich Text Format (*.rtf) 서식 있는 텍스트 형식 (*.rtf) + Rich Text Format (RTF) 서식 있는 텍스트 형식 (RTF) + Right 오른쪽 + Right Border 오른쪽 테두리 + Right-to-Left Text Direction 오른쪽에서 왼쪽 텍스트 방향 + Left-to-Right Text Direction 왼쪽에서 오른쪽 텍스트 방향 + Right: 오른쪽: + Rotate 회전 + Rounded Rectangle 둥근 사각형 + Rows & Columns 행 및 열 + Ruler 눈금자 + Rules 규칙 + Résumé 이력서 + Save 저장 + Save a copy in another format. Printing goes through a PDF you can print from any viewer. 다른 형식으로 사본을 저장합니다. 인쇄하려면 PDF로 저장한 뒤 원하는 뷰어에서 인쇄할 수 있습니다. + Save As 다른 이름으로 저장 + Save as Template 템플릿으로 저장 + Saved 저장됨 + Search Commands 명령 검색 + Search commands and help 명령 및 도움말 검색 + Search document 문서 검색 + 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 as Default 기본값으로 설정 + Set Caret 커서 설정 + Set Character Formatting 문자 서식 설정 + Set Numbering Value 번호 값 설정 + Set Paragraph Formatting 단락 서식 설정 + Shading 음영 + Shadow 그림자 + Shape Fill 도형 채우기 + Shape Format 도형 서식 + Shape Outline 도형 윤곽선 + Shape Styles 도형 스타일 + Shapes 도형 + Share 공유 + Shift+Tab Shift+Tab + Show 표시 + Show all formatting marks 모든 서식 기호 표시 + Show all revisions inline 모든 수정 내용을 본문에 표시 + Show Comments 메모 표시 + Show Markup 변경 내용 표시 + Show Notes 메모 표시 + Show only comments and formatting in balloons 말풍선에 메모와 서식만 표시 + Show revisions in balloons 말풍선에 변경 내용 표시 + Show rulers 눈금자 표시 + 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. 사용: 입력하거나 방향키를 누르면 커서가 새 위치로 부드럽게 이동하고 깜박임이 서서히 사라집니다. 사용 안 함: 커서가 바로 이동하고 깜박임이 켜졌다 꺼집니다. + 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): 크기 (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): 간격 (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 based on: 기준 스타일: + Style Inspector 스타일 검사기 + Style Set 스타일 모음 + Style: {style} 스타일: {style} + Styles 스타일 + Styles Pane 스타일 창 + Subscript 아래 첨자 + Superscript 위 첨자 + Switch Modes 모드 전환 + Switch\nModes 모드\n전환 + Symbol 기호 + Symbols 기호 + Synonyms 유의어 + Tab 탭 + Tab character 탭 문자 + Table 표 + Table Column Width 표 열 너비 + Table Design 표 디자인 + Table Layout 표 레이아웃 + Table of Authorities 인용 목차 + Table of Contents 목차 + Table of\nContents 목차 + Table Properties 표 속성 + Table Properties… 표 속성… + Table Row Height 표 행 높이 + Table Style Options 표 스타일 옵션 + Table Styles 표 스타일 + Tables 표 + Tabs 탭 + Tabular List 표 형식 목록 + Text 텍스트 + Text Box 텍스트 상자 + Text Direction 텍스트 방향 + Text Effects 텍스트 효과 + Text Effects and Typography 텍스트 효과 및 타이포그래피 + Text from File 파일에서 텍스트 가져오기 + Text Highlight Color 텍스트 강조 색 + Text indent at: 텍스트 들여쓰기 위치: + Text to display: 표시할 텍스트: + Text Wrapping 텍스트 배치 + 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 2단 + Type a command, e.g. "insert table" 명령 입력 (예: "insert table") + Type Text 텍스트 입력 + Underline 밑줄 + Underline Words Only 단어에만 밑줄 + Undo 실행 취소 + Undo {action} {action} 실행 취소 + Undo Actions 작업 실행 취소 + Undo History 실행 취소 기록 + Undo 1 Action 작업 1개 실행 취소 + Undo {n} Actions 작업 {n}개 실행 취소 + Ungroup 그룹 해제 + 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 선택 영역에 맞게 업데이트 + UPPERCASE 대문자 + 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 page (*.html) 웹 페이지 (*.html) + Web\nLayout 웹\n레이아웃 + Whole page 전체 페이지 + Whole words 단어 단위 + Wide 넓게 + Widow/Orphan Control 고아 줄 및 과부 줄 제어 + Widow/Orphan control 고아 줄 및 과부 줄 제어 + Window 창 + Word Count 단어 수 + Word document (*.docx) Word 문서 (*.docx) + Word XML Document (*.xml) Word XML 문서 (*.xml) + Word Left 이전 단어로 이동 + Word Right 다음 단어로 이동 + WordArt WordArt + WordCraft WordCraft + Words 단어 + Words only 단어만 + Wrap Text 텍스트 줄 바꿈 + Wrap\nText 텍스트 줄\n바꿈 + Write & Insert Fields 필드 작성 및 삽입 + Write a reply 답글 작성 + Zoom 확대/축소 + Zoom In 확대 + Zoom Out 축소 + {group} settings {group} 설정 + {key}+Click to follow link {key}+클릭하여 링크 열기 + Browse… 찾아보기… + {words} words 단어 {words}개 + {selected} of {words} words 전체 {words}개 중 {selected}개 선택 + 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) 링크만 열 수 있습니다. + Scroll ribbon left 리본을 왼쪽으로 스크롤 + Scroll ribbon right 리본을 오른쪽으로 스크롤 + 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} + Finding printers… 프린터 검색 중… + No printers found. Configure a system printer or export a PDF. 프린터를 찾을 수 없습니다. 시스템 프린터를 설정하거나 PDF로 내보내세요. + Printer discovery failed. 프린터 검색에 실패했습니다. + Submitting print job… 인쇄 작업 전송 중… + Print job submitted. 인쇄 작업을 전송했습니다. + Print job submitted: {receipt} 인쇄 작업을 전송했습니다: {receipt} + Print failed: the printing worker stopped. 인쇄 실패: 인쇄 작업자가 중지되었습니다. + 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} pt + 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…)… + 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 전각 공백 + 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 전각 공백 + En Space 반각 공백 + 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 (16진수)에서 + 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} pt + Color: {color} 색: {color} + Space before: {pt} pt 단락 앞 간격: {pt} pt + Space after: {pt} 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 pt + Document1 문서1 + Username 사용자 이름 + Display name 표시 이름 + Real name 실명 + Name 이름 + PRs PR + Commits 커밋 + First 첫 번째 + Last 마지막 + Model 모델 + Version 버전 + % of all commits 전체 커밋 중 % + Lines +/− 줄 +/− + Craft Craft + Studio Studio + Atelier Atelier + Darkroom Darkroom + Gallery Gallery + Sketchbook 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 유니코드 문자 전환 + Toggle Field Codes 필드 코드 전환 + Edit Field Code 필드 코드 편집 + Unlink Fields 필드 연결 해제 + Lock Fields 필드 잠금 + Unlock Fields 필드 잠금 해제 + Next Field 다음 필드 + Previous Field 이전 필드 + Selected Fields 선택한 필드 + Edit Field… 필드 편집… + Formatted 서식 지정됨 + Deleted 삭제됨 + Interface size: 인터페이스 크기: + 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, 텍스트에서는 끝에 주석 목록을 표시합니다. + 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 표 셀의 첫 글자를 대문자로 바꾸기 + Correct TWo INitial CApitals 연속된 두 대문자를 바로잡기 + Correct accidental usage of cAPS LOCK key Caps Lock 키를 실수로 사용한 경우 바로잡기 + 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 서수(1st)를 위 첨자로 바꾸기 + 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 단어 수준 + 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: 리본 및 빠른 실행 도구 모음: + Add Font… 글꼴 추가… + Added Fonts 추가된 글꼴 + Remove Added Font 추가된 글꼴 제거 + Recent Colors 최근 색 + More Colors… 색 더 보기… + Standard 표준 + Current 현재 + Colors: 색: + Color model: 색 모델: + Red: 빨강: + Green: 초록: + Blue: 파랑: + Hue: 색조: + Sat: 채도: + Lum: 명도: + Hex: 16진수: + 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 원 + Numerals: 숫자: + Numerals 숫자 + Arabic 아라비아 숫자 + Hindi 힌디어 숫자 + Context 상황에 맞게 + System 시스템 + 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… 관리자… + 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 연결되지 않음: 텍스트 상자를 클릭하세요 + Show KeyTips (Alt or F10) KeyTips 표시(Alt 또는 F10) + 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 각주/미주 변환 + Reading 읽기 + Reading Column Width 읽기 열 너비 + Reading Line Spacing 읽기 줄 간격 + Reading Text Size 읽기 텍스트 크기 + English (United Kingdom) 영어(영국) + Polish 폴란드어 + Do Not Check Spelling or Grammar 맞춤법 또는 문법 검사 안 함 + 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의 파일 브라우저로 파일을 엽니다. + Hide White Space 공백 숨기기 + All pages 모든 페이지 + Current page 현재 페이지 + Selected pages 선택한 페이지 + Custom range 사용자 지정 범위 + All pages in the range 범위의 모든 페이지 + Odd pages only 홀수 페이지에만 + Even pages only 짝수 페이지에만 + Printer 프린터 + Select a printer 프린터 선택 + Refresh printers 프린터 목록 새로 고침 + Copies 매수 + Collate copies 한 부씩 인쇄 + Two-sided printing 양면 인쇄 + Printer default 프린터 기본값 + One-sided 단면 인쇄 + Two-sided: long edge 양면 인쇄(긴 쪽 넘김) + Two-sided: short edge 양면 인쇄(짧은 쪽 넘김) + Grayscale 회색조 + 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닫기 + 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} + 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 페이지에서 위치 고정 + 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 주소 레이블 + 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 네덜란드어 + 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 받는 사람마다 레이블 하나 + 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}을(를) 문서의 기본 언어로 설정했습니다. + 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. 시스템에서 애니메이션 설정을 확인할 수 없어 애니메이션 효과를 켜 둡니다. + Select Column 열 선택 + Select Table Cells by Clicking 클릭하여 표 셀 선택 + Insert Row or Column by Clicking 클릭하여 행 또는 열 삽입 + Move Table by Dragging 드래그하여 표 이동 + AutoFit Column by Double-Clicking 두 번 클릭하여 열 자동 맞춤 + Region: 지역: + Automatic ({region}) 자동({region}) + Start-up 시작 화면 + Show the Start screen when WordCraft starts WordCraft 시작 시 시작 화면 표시 + On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. 켜기: WordCraft를 실행하면 파일 › 홈에 빈 문서, 서식 파일 및 최근 문서가 표시됩니다. 끄기: 빈 문서가 열립니다. + Wildcards (regex) 와일드카드 (정규식) + ➕ New comment ➕ 새 메모 + 📂 Browse… 📂 찾아보기… + Track\nChanges 변경 내용\n추적 + Track\nChanges ✓ 변경 내용\n추적 ✓ + Possible spelling mistake 맞춤법 오류 가능성 + Extra space 불필요한 공백 + Capitalize the first word of a sentence 문장의 첫 단어를 대문자로 시작하세요 + Remove the space before the punctuation 문장 부호 앞의 공백을 제거하세요 + Use "an" before a vowel sound 모음 소리 앞에는 "an"을 사용하세요 + Use "a" before a consonant sound 자음 소리 앞에는 "a"를 사용하세요 + The spelling and grammar check is complete. 맞춤법 및 문법 검사가 완료되었습니다. + Book Title 책 제목 + Category 범주 + Contributors 기여자 + Emphasis 강조 + Endnote Reference 미주 참조 + Endnote Text 미주 텍스트 + Footnote Reference 각주 참조 + Footnote Text 각주 텍스트 + Hyperlink 하이퍼링크 + Intense Emphasis 강한 강조 + Intense Quote 강한 인용 + Intense Reference 강한 참조 + Keywords 키워드 + No Spacing 간격 없음 + Quote 인용 + Strong 굵게 강조 + Subject 주제 + Subtitle 부제 + Subtle Emphasis 은은한 강조 + Subtle Reference 은은한 참조 + TOC Heading 목차 제목 + TOC {n} 목차 {n} + List Paragraph 목록 단락 + Models 모델 + AaBbCcDd AaBbCcDd + Annotation Reference 메모 참조 + Annotation Subject 메모 주제 + Annotation Text 메모 텍스트 + Body text in a short paragraph to show spacing. 간격을 보여 주기 위한 짧은 단락의 본문 텍스트입니다. + Default Paragraph Font 기본 단락 글꼴 + Envelope Address 봉투 주소 + Envelope Return 봉투 회신 주소 + Index Heading 색인 제목 + Index {n} 색인 {n} + Line Number 줄 번호 + Normal 기본 + Normal Indent 기본 들여쓰기 + Normal Table 기본 표 + Table Grid 표 눈금 + Table of Figures 그림 목차 + TOA Heading 법률 출처 목차 제목 + Good evening 좋은 저녁입니다 + Good morning 좋은 아침입니다 + Good afternoon 좋은 오후입니다 + tOGGLE cASE 대/소문자 전환 + ASAP 신속 처리 + CONFIDENTIAL 기밀 + DO NOT COPY 복사 금지 + DRAFT 초안 + SAMPLE 견본 + URGENT 긴급 + Plain Table 1 일반 표 1 + Grid Table 1 Light 눈금 표 1 밝게 + Convert between built-up and linear form 전문 형식과 선형 형식 간 전환 + Justification of a display equation 별도 행 수식의 맞춤 + Number the equation; the number sits at the right margin (or type # then the number) 수식에 번호를 매깁니다. 번호는 오른쪽 여백에 놓입니다 (또는 #를 입력한 다음 번호를 입력하세요) + Show the equation in the line of text 수식을 텍스트 줄 안에 표시 + Show the equation on a line of its own 수식을 별도 줄에 표시 + Type equations in LaTeX LaTeX로 수식 입력 + Type equations in the linear format (UnicodeMath) 선형 형식(UnicodeMath)으로 수식 입력 + Type normal (non-math) text in the equation 수식 안에 일반(비수식) 텍스트 입력 + Sides of a right triangle 직각삼각형의 변 + Circle area from its radius 반지름으로 구하는 원의 넓이 + Roots of ax²+bx+c ax²+bx+c의 근 + Binomial expansion of (x+a)ⁿ (x+a)ⁿ의 이항 전개 + Power series of (1+x)ⁿ (1+x)ⁿ의 멱급수 + Euler's identity 오일러 항등식 + Derivative as a limit 극한으로 나타낸 도함수 + Exponential series 지수 급수 + Integral of e^(−x²) e^(−x²)의 적분 + Sine and cosine series of f(x) f(x)의 사인·코사인 급수 + Sum or difference of sines 사인의 합 또는 차 + Sum of cosines 코사인의 합 + Normal probability density 정규 확률 밀도 + Bayes' rule 베이즈 정리 + Accents 악센트 + Basic Math 기본 수학 + Boxed Formulas 상자 수식 + Cases and Stacks 경우 및 쌓기 + Common Accent Objects 자주 쓰는 악센트 개체 + Common Brackets 자주 쓰는 괄호 + Common Fractions 자주 쓰는 분수 + Common Functions 자주 쓰는 함수 + Common Large Operators 자주 쓰는 대형 연산자 + Common Operator Structures 자주 쓰는 연산자 구조 + Common Operators 자주 쓰는 연산자 + Common Radicals 자주 쓰는 근호 + Common Subscripts and Superscripts 자주 쓰는 아래·위 첨자 + Contour Integrals 윤곽 적분 + Differentials 미분 + Dots 점 + Empty Matrices 빈 행렬 + Functions 함수 + Geometry 기하학 + Greek Letters 그리스 문자 + Hyperbolic Functions 쌍곡 함수 + Identity Matrices 단위 행렬 + Integrals 적분 + Inverse Functions 역함수 + Inverse Hyperbolic Functions 역쌍곡 함수 + Letter-Like Symbols 문자형 기호 + Matrices with Brackets 괄호 행렬 + Negated Relations 부정 관계 + Operator Structures 연산자 구조 + Operators 연산자 + Other Large Operators 기타 대형 연산자 + Overbars and Underbars 윗줄과 밑줄 + Products and Coproducts 곱과 쌍대곱 + Radicals 근호 + Scripts 첨자 + Single Brackets 단일 괄호 + Sparse Matrices 희소 행렬 + Subscripts and Superscripts 아래·위 첨자 + Summations 합 + Trigonometric Functions 삼각 함수 + Unions and Intersections 합집합과 교집합 + 2×2 Determinant 2×2 행렬식 + 2×2 Identity Matrix 2×2 단위 행렬 + 2×2 Identity Matrix (sparse) 2×2 단위 행렬 (희소) + 3×3 Identity Matrix 3×3 단위 행렬 + 3×3 Identity Matrix (sparse) 3×3 단위 행렬 (희소) + 2×2 Matrix in Brackets 대괄호 2×2 행렬 + 2×2 Matrix in Double Bars 이중 세로선 2×2 행렬 + 2×2 Matrix in Parentheses 소괄호 2×2 행렬 + n×n Identity Matrix n×n 단위 행렬 + n×n Matrix n×n 행렬 + A xor B A xor B + Absolute value cases 절댓값 경우 나누기 + Acute 아큐트(´) + Grave 그레이브(`) + Breve 브레베 + Check 카론(ˇ) + Hat 모자(^) + Tilde 물결표(~) + Dot 점 + Double Dot 이중 점 + Triple Dot 삼중 점 + Double Bar 이중 막대 + Overbar 윗줄 + Underbar 밑줄 + Overbrace 위 중괄호 + Underbrace 아래 중괄호 + Angle Brackets 꺾쇠 괄호 + Angle Brackets with Separator 구분 기호 있는 꺾쇠 괄호 + Angle Brackets with Two Separators 구분 기호 두 개 있는 꺾쇠 괄호 + Baseline Dots 기준선 점 + Binomial Coefficient 이항 계수 + Binomial Coefficient (angle) 이항 계수 (꺾쇠) + Binomial Coefficient with Angle Brackets 꺾쇠 괄호 이항 계수 + Boxed Formula 상자 수식 + Boxed Formula (example) 상자 수식 (예) + Braces 중괄호 + Braces with Separator 구분 기호 있는 중괄호 + Brackets 대괄호 + Brackets with Separators 구분 기호 있는 대괄호 + Cases (Three Conditions) 경우 (조건 3개) + Cases (Two Conditions) 경우 (조건 2개) + Ceiling 올림(천장 함수) + Floor 내림(바닥 함수) + Change in y over change in x x의 변화량에 대한 y의 변화량 + Circled Dot 원 안의 점 + Circled Plus 원 안의 더하기 + Circled Times 원 안의 곱하기 + Colon Equal 콜론 등호 + Cosine 2x 코사인 2x + Cube Root 세제곱근 + Delta Equal To 델타 등호 + Delta over arrow 화살표 위 델타 + Diagonal Dots 대각선 점 + Differential 미분 + Differential theta 미분 세타 + Differential x 미분 x + Differential y 미분 y + 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 이중 합 + Equal Equal 이중 등호 + Equal by Definition 정의에 의한 등호 + Exponential 지수 함수 + Grouping Character Above 위 묶음 문자 + Grouping Character Below 아래 묶음 문자 + Hypotenuse 빗변 + Left Arrow with Text Above 위에 텍스트가 있는 왼쪽 화살표 + Left Arrow with Text Below 아래에 텍스트가 있는 왼쪽 화살표 + Right Arrow with Text Above 위에 텍스트가 있는 오른쪽 화살표 + Right Arrow with Text Below 아래에 텍스트가 있는 오른쪽 화살표 + Left Bracket Twice 왼쪽 대괄호 두 번 + Right 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 위 왼쪽 작살 화살표 + Rightwards Arrow Above 위 오른쪽 화살표 + Rightwards Harpoon Above 위 오른쪽 작살 화살표 + Limit 극한 + Limit Example 극한 예 + Linear Fraction 선형 분수 + Logarithm 로그 + Logarithm with Base 밑이 있는 로그 + Natural Logarithm 자연로그 + Maximum 최댓값 + Maximum Example 최댓값 예 + Minimum 최솟값 + Measured By 측정 기준 + Midline Dots 가운데 점 + Minus Equal 빼기 등호 + Plus Equal 더하기 등호 + One half 2분의 1 + Outward Brackets 바깥쪽 대괄호 + Parentheses 소괄호 + Parentheses with Separator 구분 기호 있는 소괄호 + Partial Differential 편미분 + Pi Over 2 2분의 파이 + Product of A A의 곱 + Quadratic Root 이차 근호 + Square Root 제곱근 + Radical with Degree 차수가 있는 근호 + Square Root with Degree 차수가 있는 제곱근 + Sine theta 사인 세타 + 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 작은 변화량 + Square Union 정사각 합집합 + Stack Object 쌓기 개체 + Stack Object (Three) 쌓기 개체 (3개) + Stacked Fraction 쌓은 분수 + Stacked, no bar 쌓기, 분수선 없음 + Subscript-Superscript 아래·위 첨자 + Summation from 0 to n 0부터 n까지의 합 + Summation over binomials 이항 계수에 대한 합 + Tangent formula 탄젠트 공식 + Text over Colon Equal 콜론 등호 위 텍스트 + Text over Equal 등호 위 텍스트 + Union of intersections 교집합들의 합집합 + Union with Plus 플러스 합집합 + Up Diagonal Dots 오른쪽 위 대각선 점 + Vector A 벡터 A + Vector AB 벡터 AB + Vertical Bars 세로선 + Vertical Dots 세로 점 + Yields 결과 + x bar x 윗줄 + All - Linear 모두 - 선형 + All - Professional 모두 - 전문 + Current - Linear 현재 - 선형 + Current - Professional 현재 - 전문 + Centered as Group 그룹으로 가운데 맞춤 + ✓ None ✓ 없음 + Typing 입력 + Grid Table 4 – Blue 눈금 표 4 – 파랑 + List Table 3 – Blue 목록 표 3 – 파랑 + Grid Table 4 – Green 눈금 표 4 – 녹색 + List Table 3 – Green 목록 표 3 – 녹색 + Grid Table 4 – Orange 눈금 표 4 – 주황 + List Table 3 – Orange 목록 표 3 – 주황 + Grid Table 4 – Purple 눈금 표 4 – 보라 + List Table 3 – Purple 목록 표 3 – 보라 + Level 1 수준 1 + Change to Level 1 수준 1로 변경 + Level 2 수준 2 + Change to Level 2 수준 2로 변경 + Level 3 수준 3 + Change to Level 3 수준 3으로 변경 + Level 4 수준 4 + Change to Level 4 수준 4로 변경 + Level 5 수준 5 + Change to Level 5 수준 5로 변경 + Level 6 수준 6 + Change to Level 6 수준 6으로 변경 + Level 7 수준 7 + Change to Level 7 수준 7로 변경 + Level 8 수준 8 + Change to Level 8 수준 8로 변경 + Level 9 수준 9 + Change to Level 9 수준 9로 변경 + 1×2 Empty Matrix 빈 행렬 1×2 + 1×3 Empty Matrix 빈 행렬 1×3 + 2×1 Empty Matrix 빈 행렬 2×1 + 2×2 Empty Matrix 빈 행렬 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 + Cosecant Function 코시컨트 함수 + Hyperbolic Cosecant Function 쌍곡 코시컨트 함수 + Inverse Cosecant Function 역 코시컨트 함수 + Inverse Hyperbolic Cosecant Function 역쌍곡 코시컨트 함수 + Cosine Function 코사인 함수 + Hyperbolic Cosine Function 쌍곡 코사인 함수 + Inverse Cosine Function 역 코사인 함수 + Inverse Hyperbolic Cosine Function 역쌍곡 코사인 함수 + Cotangent Function 코탄젠트 함수 + Hyperbolic Cotangent Function 쌍곡 코탄젠트 함수 + Inverse Cotangent Function 역 코탄젠트 함수 + Inverse Hyperbolic Cotangent Function 역쌍곡 코탄젠트 함수 + Secant Function 시컨트 함수 + Hyperbolic Secant Function 쌍곡 시컨트 함수 + Inverse Secant Function 역 시컨트 함수 + Inverse Hyperbolic Secant Function 역쌍곡 시컨트 함수 + Sine Function 사인 함수 + Hyperbolic Sine Function 쌍곡 사인 함수 + Inverse Sine Function 역 사인 함수 + Inverse Hyperbolic Sine Function 역쌍곡 사인 함수 + Tangent Function 탄젠트 함수 + Hyperbolic Tangent Function 쌍곡 탄젠트 함수 + Inverse Tangent Function 역 탄젠트 함수 + Inverse Hyperbolic Tangent Function 역쌍곡 탄젠트 함수 + Integral with Limits 적분 (한계 포함) + Integral with Stacked Limits 적분 (위아래 한계 포함) + Contour Integral 윤곽 적분 + Contour Integral with Limits 윤곽 적분 (한계 포함) + Contour Integral with Stacked Limits 윤곽 적분 (위아래 한계 포함) + Double Integral 이중 적분 + Double Integral with Limits 이중 적분 (한계 포함) + Double Integral with Stacked Limits 이중 적분 (위아래 한계 포함) + Triple Integral 삼중 적분 + Triple Integral with Limits 삼중 적분 (한계 포함) + Triple Integral with Stacked Limits 삼중 적분 (위아래 한계 포함) + Surface Integral 면적분 + Surface Integral with Limits 면적분 (한계 포함) + Surface Integral with Stacked Limits 면적분 (위아래 한계 포함) + Volume Integral 체적분 + Volume Integral with Limits 체적분 (한계 포함) + Volume Integral with Stacked Limits 체적분 (위아래 한계 포함) + Coproduct 쌍대곱 + Coproduct with Limits 쌍대곱 (한계 포함) + Coproduct with Lower Limit 쌍대곱 (아래 한계 포함) + Coproduct with Subscript-Superscript Limits 쌍대곱 (아래·위 첨자 한계 포함) + Product 곱 + Product with Limits 곱 (한계 포함) + Product with Lower Limit 곱 (아래 한계 포함) + Product with Subscript-Superscript Limits 곱 (아래·위 첨자 한계 포함) + Intersection 교집합 + Intersection with Limits 교집합 (한계 포함) + Intersection with Lower Limit 교집합 (아래 한계 포함) + Intersection with Subscript-Superscript Limits 교집합 (아래·위 첨자 한계 포함) + Union 합집합 + Union with Limits 합집합 (한계 포함) + Union with Lower Limit 합집합 (아래 한계 포함) + Union with Subscript-Superscript Limits 합집합 (아래·위 첨자 한계 포함) + Summation 합 + Summation with Limits 합 (한계 포함) + Summation with Lower Limit 합 (아래 한계 포함) + Summation with Subscript-Superscript Limits 합 (아래·위 첨자 한계 포함) + Summation with Subscript Limit 합 (아래 첨자 한계 포함) + Logical AND 논리곱 + Logical AND with Limits 논리곱 (한계 포함) + Logical OR 논리합 + Logical OR with Limits 논리합 (한계 포함) +preview Title 제목 +# Added while landing (#1216): labels and dialogs that landed after this catalog + Copy to 다른 위치에 복사 + Delete Bookmark 책갈피 삭제 + Filter Recipients 받는 사람 필터 + Full Screen 전체 화면 + Load System Installed Fonts… 시스템에 설치된 글꼴 불러오기… + Privacy Options 개인 정보 옵션 + Privacy Settings 개인 정보 설정 + Rename Document 문서 이름 바꾸기 + Sort Recipients 받는 사람 정렬 + Type Character Code 문자 코드 입력 + All system fonts are already available. 모든 시스템 글꼴을 이미 사용할 수 있습니다. + Local font access is not supported by your browser. 이 브라우저는 로컬 글꼴 액세스를 지원하지 않습니다. + Local font access was denied. 로컬 글꼴 액세스가 거부되었습니다. + Loaded {count} system fonts. 시스템 글꼴 {count}개를 불러왔습니다. + Column {n} 열 {n} + Header row 머리글 행 있음 + No header row 머리글 행 없음 + My list has 목록 첫 행 + Formula: 수식: + Paste function: 함수 붙여넣기: diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 995c61136..62326265b 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; 20] = [ +pub static LANGUAGES: [LangInfo; 21] = [ 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 { @@ -73,6 +73,14 @@ pub static LANGUAGES: [LangInfo; 20] = [ catalog: OnceLock::new(), }, LangInfo { code: "ja", name: "日本語", source: include_str!("ja.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, + LangInfo { + code: "ko", + name: "한국어", + source: include_str!("ko.tsv"), + prefer_hans: false, // 일본어처럼 CJK지만 한자 우선순위는 필요 없으므로 false + editing_lang: None, // 현재 한국어 맞춤법(proofing) 지원이 없으므로 None + catalog: OnceLock::new(), + }, LangInfo { code: "uk", name: "Українська", source: include_str!("uk.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, @@ -193,6 +201,10 @@ impl Lang { self.0.prefer_hans } + pub fn prefers_korean(self) -> bool { + self.0.code == "ko" + } + /// 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 @@ -211,6 +223,12 @@ impl Lang { (catalogs, Lang::all().map(Lang::name).collect::>().join(" ")) } + /// The CJK interface catalogs using the Korean or Chinese writing-system preference. + pub fn cjk_font_text_for(hans: bool, korean: bool) -> (Vec<&'static str>, String) { + let catalogs = Lang::all().filter(|l| l.uses_cjk() && l.prefers_hans() == hans && l.prefers_korean() == korean).map(|l| l.0.source).collect(); + (catalogs, Lang::all().map(Lang::name).collect::>().join(" ")) + } + /// A language by its exact code (any case). pub fn from_code(code: &str) -> Option { LANGUAGES.iter().find(|l| l.code.eq_ignore_ascii_case(code)).map(Lang) diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index ed7460ec2..e901ab017 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -19,6 +19,8 @@ fn system_locales_map_to_languages() { assert_eq!(lang_from_tag("zh-Hant"), Some(lang("zh-hant"))); assert_eq!(lang_from_tag("ja-JP"), Some(lang("ja"))); assert_eq!(lang_from_tag("ja_JP.eucJP@euro"), Some(lang("ja"))); + assert_eq!(lang_from_tag("ko-KR"), Some(lang("ko"))); + assert_eq!(lang_from_tag("ko_KR.UTF-8"), Some(lang("ko"))); 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)); @@ -150,6 +152,40 @@ fn every_tab_and_command_is_translated() { assert!(missing.is_empty(), "untranslated: {missing:#?}"); } +#[test] +fn korean_print_settings_and_printer_feedback_are_translated() { + let korean = lang("ko"); + for source in [ + "Printer", + "Select a printer", + "Refresh printers", + "Copies", + "Two-sided printing", + "Printer default", + "One-sided", + "Two-sided: long edge", + "Two-sided: short edge", + "Grayscale", + "Finding printers…", + "No printers found. Configure a system printer or export a PDF.", + "Printer discovery failed.", + "Submitting print job…", + "Print job submitted.", + "Print job submitted: {receipt}", + "Print failed: the printing worker stopped.", + "Print failed: {error}", + ] { + assert!(has(korean, source), "missing Korean print translation: {source}"); + } + assert_eq!(korean.catalog().plain("Printer"), Some("프린터")); + assert_eq!(korean.catalog().plain("Copies"), Some("매수")); + assert_eq!(korean.catalog().plain("Two-sided printing"), Some("양면 인쇄")); + assert_eq!( + korean.catalog().plain("No printers found. Configure a system printer or export a PDF."), + Some("프린터를 찾을 수 없습니다. 시스템 프린터를 설정하거나 PDF로 내보내세요.") + ); +} + #[test] fn the_language_setting_is_a_ui_command_and_persists() { let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); @@ -439,9 +475,24 @@ fn chinese_interface_fonts_load_and_cover_simplified_hanzi() { } } +#[test] +fn korean_interface_fonts_load_and_cover_hangul() { + let ctx = egui::Context::default(); + ctx.set_fonts(crate::theme::font_definitions_language(false, true, true)); + ctx.run_ui(Default::default(), |_| {}).textures_delta.clear(); + let covered = ctx.fonts_mut(|f| f.has_glyphs(&egui::FontId::proportional(14.0), "한글")); + let (catalogs, names) = Lang::cjk_font_text_for(false, true); + let mut texts = catalogs; + texts.push(&names); + let embedded = !wordcraft_fonts::ui_needs_system_cjk(&texts, &wordcraft_fonts::ui_cjk_fonts_for(false, true)); + if embedded || wordcraft_fonts::system_cjk_ui_font_for(false, true, &['한']).is_some() { + assert!(covered, "a Korean face is available but the interface lacks Hangul glyphs"); + } +} + #[test] fn cjk_catalogs_are_covered_by_the_embedded_interface_faces() { - // #486: every CJK character of the Chinese and Japanese catalogs and of the language names + // #486: every CJK character of the Chinese, Japanese and Korean catalogs and language names // has a glyph in the interface font chain built from the embedded faces alone, in every // interface weight; `?` and boxes showed where the Japanese face was the only CJK one. Needs // a craft-fonts build (CRAFT_FONTS_DIR); without one there is nothing embedded to check, and @@ -451,10 +502,10 @@ fn cjk_catalogs_are_covered_by_the_embedded_interface_faces() { return; } let names: String = Lang::all().map(Lang::name).collect(); - for code in ["zh-hans", "zh-hant", "ja"] { + for code in ["zh-hans", "zh-hant", "ja", "ko"] { let l = lang(code); let ctx = egui::Context::default(); - ctx.set_fonts(crate::theme::font_definitions(l.prefers_hans(), false)); + ctx.set_fonts(crate::theme::font_definitions_language(l.prefers_hans(), l.prefers_korean(), false)); ctx.run_ui(Default::default(), |_| {}).textures_delta.clear(); let (entries, errors) = parse_entries(l.0.source); assert!(errors.is_empty(), "{code}: {errors:?}"); @@ -466,7 +517,10 @@ 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())), Vec::::new()); + assert_eq!( + wordcraft_fonts::ui_uncovered_cjk(&[l.0.source, &names], &wordcraft_fonts::ui_cjk_fonts_for(l.prefers_hans(), l.prefers_korean())), + Vec::::new() + ); } } @@ -474,7 +528,7 @@ fn cjk_catalogs_are_covered_by_the_embedded_interface_faces() { fn only_cjk_languages_ask_for_an_installed_cjk_font() { // #241 follow-up: an English interface must not read a large system CJK font on startup. let cjk: Vec<&str> = Lang::all().filter(|l| l.uses_cjk()).map(Lang::code).collect(); - assert_eq!(cjk, ["zh-hans", "zh-hant", "ja"]); + assert_eq!(cjk, ["zh-hans", "zh-hant", "ja", "ko"]); } #[test] @@ -1152,7 +1206,7 @@ fn bulgarian_locales_and_saved_preference_work_without_changing_the_document() { 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)); + assert_eq!(first_supported([UNSUPPORTED, "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(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5c01d76ad..464e6d667 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -460,6 +460,8 @@ pub struct WordApp { styled: bool, /// The CJK face order the installed UI fonts use (Chinese first, or Japanese first). fonts_hans: bool, + /// Whether the installed UI fonts use the Korean CJK face order. + fonts_korean: bool, /// Whether the installed UI fonts include an installed CJK fallback font (#241). fonts_system_cjk: bool, /// CJK text is (about to be) on screen in a non-CJK interface, e.g. the language names in @@ -588,6 +590,7 @@ impl WordApp { nav_search_focus: false, styled: false, fonts_hans: false, + fonts_korean: false, fonts_system_cjk: false, want_system_cjk: false, fonts_frames: 0, @@ -1830,9 +1833,11 @@ impl WordApp { /// face has changed since the last install. fn install_ui_fonts(&mut self, ctx: &egui::Context, lang: i18n::Lang) { let system_cjk = self.fonts_system_cjk || self.want_system_cjk || lang.uses_cjk(); - if !self.styled || lang.prefers_hans() != self.fonts_hans || system_cjk != self.fonts_system_cjk { - theme::install_fonts_extra(ctx, lang.prefers_hans(), system_cjk, &self.canvas.ime_faces); + if !self.styled || lang.prefers_hans() != self.fonts_hans || lang.prefers_korean() != self.fonts_korean || system_cjk != self.fonts_system_cjk + { + theme::install_fonts_language_extra(ctx, lang.prefers_hans(), lang.prefers_korean(), system_cjk, &self.canvas.ime_faces); self.fonts_hans = lang.prefers_hans(); + self.fonts_korean = lang.prefers_korean(); self.fonts_system_cjk = system_cjk; // Mod with -, = and 0 are Word shortcuts (optional hyphen, subscript, paragraph spacing); // egui's keyboard zoom would also scale the whole window on them. Zoom is View › Zoom. diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index ce05f0060..d67361ee7 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -376,12 +376,17 @@ pub fn install_fonts(ctx: &egui::Context) { install_fonts_for(ctx, false); } -/// [`install_fonts`] with the CJK fallback order for the interface language: the Chinese face -/// first for Chinese (#8), the Japanese one otherwise. The new fonts apply from the next frame. +/// [`install_fonts`] with the Chinese or Japanese CJK fallback order. The new fonts apply from the +/// next frame. pub fn install_fonts_for(ctx: &egui::Context, prefer_hans: bool) { install_fonts_with(ctx, prefer_hans, false); } +/// [`install_fonts_for`] with the CJK fallback order for a Korean interface. +pub fn install_fonts_language(ctx: &egui::Context, prefer_hans: bool, prefer_korean: bool) { + install_fonts_language_with(ctx, prefer_hans, prefer_korean, false); +} + /// [`install_fonts_for`], also adding an installed CJK font after the embedded faces when /// `system_cjk` is set and the embedded faces leave CJK characters of the interface text without /// a glyph (#241, #486). Reading that font is a large file read, so callers ask for it only when @@ -390,11 +395,27 @@ pub fn install_fonts_with(ctx: &egui::Context, prefer_hans: bool, system_cjk: bo install_fonts_extra(ctx, prefer_hans, system_cjk, &[]); } +/// [`install_fonts_with`] with the CJK font order selected for the interface language. +pub fn install_fonts_language_with(ctx: &egui::Context, prefer_hans: bool, prefer_korean: bool, system_cjk: bool) { + install_fonts_language_extra(ctx, prefer_hans, prefer_korean, system_cjk, &[]); +} + /// [`install_fonts_with`], also adding document-font `faces` as last-resort fallbacks (#565): the /// faces that cover text the interface fonts cannot draw (an input method's composition, or a /// file name in the window caption). pub fn install_fonts_extra(ctx: &egui::Context, prefer_hans: bool, system_cjk: bool, faces: &[Arc]) { - let mut fonts = font_definitions(prefer_hans, system_cjk); + install_fonts_language_extra(ctx, prefer_hans, false, system_cjk, faces); +} + +/// [`install_fonts_extra`] with the CJK font order selected for the interface language. +pub fn install_fonts_language_extra( + ctx: &egui::Context, + prefer_hans: bool, + prefer_korean: bool, + system_cjk: bool, + faces: &[Arc], +) { + let mut fonts = font_definitions_language(prefer_hans, prefer_korean, system_cjk); add_fallback_faces(&mut fonts, faces); ctx.set_fonts(fonts); } @@ -445,6 +466,11 @@ pub fn faces_for_preedit( /// The interface fonts; see [`install_fonts_with`]. pub fn font_definitions(prefer_hans: bool, system_cjk: bool) -> FontDefinitions { + font_definitions_language(prefer_hans, false, system_cjk) +} + +/// The interface fonts, ordered for the preferred CJK writing system. +pub fn font_definitions_language(prefer_hans: bool, prefer_korean: bool, system_cjk: bool) -> FontDefinitions { let mut fonts = FontDefinitions::default(); let add = |fonts: &mut FontDefinitions, name: &str, bytes: &'static [u8]| { fonts.font_data.insert(name.into(), Arc::new(FontData::from_static(bytes))); @@ -471,7 +497,7 @@ pub fn font_definitions(prefer_hans: bool, system_cjk: bool) -> FontDefinitions } } }; - let cjk = wordcraft_fonts::ui_cjk_fonts(prefer_hans); + let cjk = wordcraft_fonts::ui_cjk_fonts_for(prefer_hans, prefer_korean); for f in &cjk { // The same static bytes the document fonts use: one copy in the binary. add_cjk(&mut fonts, format!("{} {}", f.family, f.style), FontData::from_static(f.bytes)); @@ -480,7 +506,7 @@ pub fn font_definitions(prefer_hans: bool, system_cjk: bool) -> FontDefinitions // without its Chinese face, #241, or with faces lacking catalog characters, #486): an // installed CJK font after them, so the menus don't show boxes or `?`. #[cfg(not(target_arch = "wasm32"))] - if system_cjk && let Some(f) = system_cjk_font(prefer_hans) { + if system_cjk && let Some(f) = system_cjk_font(prefer_hans, prefer_korean) { let mut data = FontData::from_static(&f.bytes); data.index = f.index; add_cjk(&mut fonts, format!("system {}", f.family), data); @@ -496,24 +522,31 @@ pub fn font_definitions(prefer_hans: bool, system_cjk: bool) -> FontDefinitions /// ([`cjk_font_text`](crate::i18n::Lang::cjk_font_text)). The font is picked for the catalogs' /// missing characters, or for the language names when only those are missing. #[cfg(not(target_arch = "wasm32"))] -fn system_cjk_font(hans: bool) -> Option<&'static wordcraft_fonts::SystemUiFont> { +fn system_cjk_font(hans: bool, korean: bool) -> Option<&'static wordcraft_fonts::SystemUiFont> { static ZH: std::sync::OnceLock> = std::sync::OnceLock::new(); static OTHER: std::sync::OnceLock> = std::sync::OnceLock::new(); - (if hans { &ZH } else { &OTHER }) - .get_or_init(|| { - let embedded = wordcraft_fonts::ui_cjk_fonts(hans); - let (catalogs, names) = crate::i18n::Lang::cjk_font_text(hans); - let mut needed = wordcraft_fonts::ui_uncovered_cjk(&catalogs, &embedded); + static KO: std::sync::OnceLock> = std::sync::OnceLock::new(); + (if hans { + &ZH + } else if korean { + &KO + } else { + &OTHER + }) + .get_or_init(|| { + let embedded = wordcraft_fonts::ui_cjk_fonts_for(hans, korean); + let (catalogs, names) = crate::i18n::Lang::cjk_font_text_for(hans, korean); + let mut needed = wordcraft_fonts::ui_uncovered_cjk(&catalogs, &embedded); + if needed.is_empty() { + needed = wordcraft_fonts::ui_uncovered_cjk(&[&names], &embedded); if needed.is_empty() { - needed = wordcraft_fonts::ui_uncovered_cjk(&[&names], &embedded); - if needed.is_empty() { - return None; - } + return None; } - log::debug!("interface: {} CJK characters lack an embedded glyph", needed.len()); - wordcraft_fonts::system_cjk_ui_font(hans, &needed) - }) - .as_ref() + } + log::debug!("interface: {} CJK characters lack an embedded glyph", needed.len()); + wordcraft_fonts::system_cjk_ui_font_for(hans, korean, &needed) + }) + .as_ref() } pub fn medium(size: f32) -> FontId { diff --git a/docs/gaps.md b/docs/gaps.md index 37b5a197b..e08b75318 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -33,7 +33,7 @@ Document renaming: desktop renaming requires a filesystem that supports hard lin | 9 | **Password-protected `.doc` files** (AES) can't be opened; password-protected `.docx` opens and saves (agile encryption, #55 closed by #281) | | `crates/docx/src/crypt.rs`; `crates/docbin/src/lib.rs` opens XOR/RC4/RC4 CryptoAPI `.doc` with a password (#1079, untested on Word-written files; AES `.doc` refused) | Corporate and legal users with old files | 3–5 | [file-format-parity.md](file-format-parity.md) | | 8 | **Proofing covers English, Polish, Portuguese, Italian, Croatian, Turkish and Russian only**; no dictionaries, grammar or hyphenation for the other ~43 languages Word proofs (text in them is left unmarked) | | `crates/proof` `ProofLang::PROOFED`: English, Polish, Portuguese (pt-BR via the installed VERO dictionary, #797), Italian (it-IT via the installed Hunspell dictionary), Croatian (hr-HR, 2026-10-13), Turkish (tr_TR spelling, grammar, TDK hyphenation, 2026-10-13) and Russian (the installed `ru_RU` dictionary, 2026-10-12), a language per run; Bulgarian has hyphenation only (#826); open PRs add Danish (#723), Russian (#894) and Czech (#1048); Word ships 120 proofing tools; issues #25, #40, #100 open | Every writer outside English, Polish, Portuguese, Italian, Croatian, Turkish and Russian | 20–40 | [localization-parity.md](localization-parity.md) | | 10 | **Objects**: 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 11/11, Layout 22/22); group and ungroup landed (#267) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) | -| 11 | **Interface languages**: 5 of the 12 key languages missing (hi, ar, id, ko, vi) and pt-PT (French landed, #729; 19 interface languages in `crates/ui-egui/src/i18n`); open PRs for Arabic (#737, #1042, #1032), Indonesian (#572) and pt-PT (#1028); no mirrored RTL interface yet; ~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) | +| 11 | **Interface languages**: 3 of the 12 key languages missing (hi, ar, id) and pt-PT (French landed, #729; Vietnamese, #1434; Korean, #1216; 21 interface languages in `crates/ui-egui/src/i18n`); open PRs for Arabic (#737, #1042, #1032), Indonesian (#572) and pt-PT (#1028); no mirrored RTL interface yet; ~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); Ink to Shape also recognises single-stroke arrows (a line plus a V head drawn in one stroke becomes a straight connector with an arrowhead); missing: Ink to Math, two-stroke arrows and arrows with a closed triangle head, pressure, rendering/editing Word's own ink (drawing-level and direct run-level InkML content parts now survive DOCX save) | | [parity-checklist.md](parity-checklist.md) (Draw 10/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 (charts of those types show a placeholder with their title and type, #998), 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 b7a6f8c17..e0b0a55b0 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -19,7 +19,7 @@ native-speaker review (human). ## What WordCraft ships (measured) -- **20 interface languages**: English plus `zh-hans`, `zh-hant`, `ja`, `uk`, `es`, `pt-br`, +- **21 interface languages**: English plus `zh-hans`, `zh-hant`, `ja`, Korean `ko` (#1216), `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`, Bulgarian `bg` and Vietnamese `vi` (#1434). Catalogs have 2,061–2,463 entries each; Polish has every key of every other catalog, including the built-in style names and the equation gallery, except the citation style names only Hungarian 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. @@ -56,7 +56,7 @@ shared items below. | 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 | 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 | +| Korean | ko | 2,463 entries (all catalog keys and preview context) | most; newer panes English | Hangul ✅ (Korean-first embedded/system UI font fallback); macOS Korean IME fixes pending (#163, #164) | no | partial | 3–5 | | Vietnamese | vi | 2,448 entries; every key of the reference catalogs (#1434); 98% of extracted, ~95% of visible | most | Latin ✅ | no | partial | 0–1 (review) | Other shipped languages: **14** — Traditional Chinese (`zh-hant`), Ukrainian (`uk`), Serbian From fbbf9c04eb8ff0cf8e311f4b5fde174f51079408 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 23:52:28 -0400 Subject: [PATCH 078/101] CI: recovery tests retry a load a lock race refused (#1957) discovery_reclaims_a_crashed_first_write_but_keeps_valid_records failed in a full workspace run: load() found the record locked for a moment by a process another test was starting (the race #1939 handled for scan()). The three tests that reopen a store and load now retry for up to a second. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/recovery/desktop.rs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/recovery/desktop.rs b/crates/engine/src/recovery/desktop.rs index 7e9a7ad54..1480363ae 100644 --- a/crates/engine/src/recovery/desktop.rs +++ b/crates/engine/src/recovery/desktop.rs @@ -572,6 +572,19 @@ mod tests { } panic!("the scan never reported the unreadable record"); } + /// `store.load(id)`, tried again for up to a second while the record can't be locked: a process + /// another test is starting holds the lock for a moment (see [`reopen_reporting_unreadable`]). + fn load_retrying(store: &mut Store, id: &str) -> Snapshot { + let mut last = String::new(); + for _ in 0..50 { + match store.load(id) { + Ok(snapshot) => return snapshot, + Err(e) => last = e, + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + panic!("the record never loaded: {last}"); + } fn snapshot(id: &str, text: &str) -> Snapshot { Snapshot { version: 1, @@ -656,7 +669,7 @@ mod tests { fs::write(second, b"{truncated").unwrap(); drop(store); let mut reopened = reopen_reporting_unreadable(&t.0); - assert_eq!(reopened.load(&id).unwrap().document.plain_text(StoryRef::Body), "First valid copy"); + assert_eq!(load_retrying(&mut reopened, &id).document.plain_text(StoryRef::Body), "First valid copy"); // Never reuse the corrupt generation's name or prune the valid fallback. reopened.save(snapshot(&id, "Third valid copy")).unwrap(); assert_eq!(fs::read(first).unwrap(), bytes); @@ -697,7 +710,7 @@ mod tests { assert_eq!(fs::read(first).unwrap(), bytes); drop(store); let mut reopened = reopen_reporting_unreadable(&t.0); - assert_eq!(reopened.load(&id).unwrap().document.plain_text(StoryRef::Body), "Last valid copy"); + assert_eq!(load_retrying(&mut reopened, &id).document.plain_text(StoryRef::Body), "Last valid copy"); } #[test] @@ -724,7 +737,7 @@ mod tests { let mut reopened = Store::new(t.0.clone()).unwrap(); reopened.scan().unwrap(); assert!(!dir.exists()); - assert_eq!(reopened.load(&id).unwrap().document.plain_text(StoryRef::Body), "Valid synthetic draft"); + assert_eq!(load_retrying(&mut reopened, &id).document.plain_text(StoryRef::Body), "Valid synthetic draft"); } #[test] From 3cbea099f1f6551d22c46060d045b9f4265551d6 Mon Sep 17 00:00:00 2001 From: Erik Holmbom Date: Thu, 15 Oct 2026 06:03:13 +0200 Subject: [PATCH 079/101] i18n: add Swedish (sv) interface translation (#1219) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * i18n: add Swedish (sv) interface translation A 2,504-entry Swedish catalog: every key of the reference catalog, the equation gallery's labels, everything the interface audit finds, and 44 newer strings no catalog had yet (the native print settings among them). Written from the meaning of the English labels in ordinary Swedish computing vocabulary; no other product's localisation and no other catalog was used. An AI-assisted draft whose terminology and running app were reviewed by a native speaker. Counts use count-neutral wording ("21 ord"). - sv, sv-SE, sv_SE.UTF-8 and sv-FI select it automatically; "Svenska" in Options and ui.language with {"value":"sv"}. - The interface audit checks Swedish the way it checks Russian, Hungarian, Turkish and Bulgarian. - Tests: locales and saved preference, catalog coverage and key translations, the search hint's example, count wording, and the bundled Inter/JetBrains Mono faces covering å ä ö é. - native_print: the narrow-window test pins English, since it finds controls by their English labels (it failed on a Swedish machine once those labels had a translation). - The Hungarian locale test used sv-SE as an unsupported language; it now uses fi-FI. - ATTRIBUTION, README, ROADMAP, docs/localization-parity.md and a new docs/localization-sv.md; a contributors/people.toml entry for @holmbom. Co-Authored-By: Claude Opus 5.5 * Swedish rows for the cross-reference dialog 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 +- contributors/people.toml | 5 + crates/ui-egui/src/i18n/audit.rs | 8 +- crates/ui-egui/src/i18n/mod.rs | 4 +- crates/ui-egui/src/i18n/sv.tsv | 2635 ++++++++++++++++++++++++++ crates/ui-egui/src/i18n/tests.rs | 86 +- crates/ui-egui/src/i18n/vi.tsv | 6 + crates/ui-egui/tests/native_print.rs | 2 + docs/localization-parity.md | 17 +- docs/localization-sv.md | 97 + 12 files changed, 2850 insertions(+), 17 deletions(-) create mode 100644 crates/ui-egui/src/i18n/sv.tsv create mode 100644 docs/localization-sv.md diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index f3c8089fb..54afd312d 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -76,3 +76,4 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra | `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 | +| `crates/ui-egui/src/i18n/sv.tsv` | Erik Holmbom (@holmbom) 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 | Swedish interface catalog; AI-assisted, terminology and the running app reviewed by a native speaker | diff --git a/README.md b/README.md index 4adf79d4c..15ebbb05f 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, Français, Български or Tiếng Việt (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, Български, Tiếng Việt, 한국어 or Svenska (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 1fc66c047..9d95d21c0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -112,7 +112,7 @@ Weights and evidence per area: [docs/target-app-parity.md](docs/target-app-parit | Korean | ko | partial (interface catalog; proofing unavailable) | 100% (2,463 entries) | | Vietnamese | vi | partial (#1434) | ~95% | -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 +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); Swedish (`sv`) covers that too, plus 44 newer strings no other catalog has yet (2,504 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. Vietnamese (`vi`, #1434) has a complete 2,448-entry catalog, also without proofing. @@ -360,6 +360,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-13 | Swedish (`sv`) interface: 2,504 catalog entries (every key of the reference catalog, the equation gallery, everything the interface audit finds, and 44 newer strings no catalog had yet, among them the native print settings), `sv-SE`/`sv-FI` system locales and a saved language choice; locale, catalog and bundled-font tests; AI-assisted, terminology and the running app reviewed by a native speaker; no Swedish proofing resources | | 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 | @@ -540,6 +541,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-13 | trivial | Swedish interface (`sv`): progress log and shipped languages | | 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 | diff --git a/contributors/people.toml b/contributors/people.toml index a84454436..74b120f63 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -118,3 +118,8 @@ display_name = "The Legend Of MTS Dev" real_name = "Robert Lee" + +[people.holmbom] +real_name = "Erik Holmbom" +display_name = "Erik Holmbom" +sync_github_name = false diff --git a/crates/ui-egui/src/i18n/audit.rs b/crates/ui-egui/src/i18n/audit.rs index c81367573..88e7063a5 100644 --- a/crates/ui-egui/src/i18n/audit.rs +++ b/crates/ui-egui/src/i18n/audit.rs @@ -333,9 +333,9 @@ fn untranslated_interface_text() { complete_catalogs_have_every_string_the_interface_looked_up(pseudo::looked_up()); } -/// Russian, Hungarian, Turkish, Bulgarian and Italian 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, Turkish, Bulgarian, Italian and Swedish 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())); @@ -379,7 +379,7 @@ fn complete_catalogs_have_every_string_the_interface_looked_up(mut keys: std::co // translate: its placeholder names aren't words the interface shows. let words = |s: &str| english_words(&s.split('{').map(|p| p.split_once('}').map_or(p, |(_, rest)| rest)).collect::()); let mut report = String::new(); - for code in ["ru", "hu", "tr", "bg", "it"] { + for code in ["ru", "hu", "tr", "bg", "it", "sv"] { let lang = super::Lang::from_code(code).unwrap_or(super::Lang::EN); let missing: Vec<&String> = keys.iter().filter(|s| words(s) && !super::has(lang, s) && !numbered(s) && !same(s) && !script_error(s)).collect(); diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 62326265b..10b2c063a 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; 21] = [ +pub static LANGUAGES: [LangInfo; 22] = [ 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 { @@ -154,6 +154,8 @@ pub static LANGUAGES: [LangInfo; 21] = [ }, // Vietnamese; `vi`, `vi-VN` and `vi_VN.UTF-8` resolve here. LangInfo { code: "vi", name: "Tiếng Việt", source: include_str!("vi.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, + // Swedish; sv, sv-SE, sv-FI and sv_SE.UTF-8 resolve here. + LangInfo { code: "sv", name: "Svenska", source: include_str!("sv.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, ]; impl LangInfo { diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv new file mode 100644 index 000000000..b907ececb --- /dev/null +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -0,0 +1,2635 @@ +# Swedish (sv) 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. +# +# Commands are imperatives (Spara, Infoga tabell); text addresses the reader as du; labels use +# sentence case. Counts use count-neutral wording (`{words} ord`), so no plural forms are needed. +# 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 as they are. + + (no style) (ingen stil) + (no suggestions) (inga förslag) + 1.5 Line Spacing Radavstånd 1,5 + A free, open-source word processor written from scratch in Rust.\nPart of the Crafting Apps from the ArtCraft team. En gratis ordbehandlare med öppen källkod, skriven från grunden i Rust.\nEn del av Crafting Apps från ArtCraft-teamet. + About Om + About WordCraft Om WordCraft + Accept Acceptera + Accept All Changes Acceptera alla ändringar + Accept This Change Acceptera den här ändringen + Accessibility Tillgänglighet + Add Lägg till + Add Object to Selection Lägg till objekt i markeringen + Add Space Before Paragraph Lägg till avstånd före stycke + Add space between Asian and Latin text Lägg till mellanrum mellan asiatisk och latinsk text + Add space between Asian text and numbers Lägg till mellanrum mellan asiatisk text och siffror + Add tab stop at: Lägg till tabbstopp vid: + Add Text Lägg till text + Add a Table Lägg till en tabell + Add a comment Lägg till en kommentar + Add to Dictionary Lägg till i ordlistan + Add to Style Gallery Lägg till i stilgalleriet + Address Block Adressblock + Address: Adress: + Address\nBlock Adress-\nblock + Adjust Bildjustering + Advanced Avancerat + Advanced Find Avancerad sökning + Advanced Find… Avancerad sökning… + After: Efter: + Agents Agenter + Align Justera + Align Left Vänsterjustera + Align Right Högerjustera + Aligned at: Justerad vid: + Alignment Justering + All Borders Alla kantlinjer + All Caps Versaler + All Markup All märkning + All caps Versaler + Allow hanging punctuation Tillåt hängande skiljetecken + Allow Latin text to wrap in the middle of a word Tillåt radbrytning mitt i ord i latinsk text + Alt Text Alternativtext + Apply Asian line-breaking rules (kinsoku) Använd asiatiska regler för radbrytning (kinsoku) + Apply Character Style Använd teckenstil + Apply Style Använd stil + Arrange Ordna + Arrange\nAll Ordna\nalla + Arrow Pil + Artistic Effects Konstnärliga effekter + As you type Medan du skriver + Asian text font: Typsnitt för asiatisk text: + Asian Typography Asiatisk typografi + Author Författare + AutoCorrect Options Alternativ för autokorrigering + AutoFit Autoanpassa + AutoFit Contents Autoanpassa efter innehåll + AutoFit Window Autoanpassa efter fönster + AutoSave Autospara + AutoSave saves every change to the file (needs a saved document) Autospara sparar varje ändring i filen (kräver ett sparat dokument) + AutoSave isn't available in the browser: it can't write to your files. Save downloads a copy. Autospara finns inte i webbläsaren, eftersom den inte kan skriva till dina filer. Spara laddar ner en kopia. + AutoSave only keeps .docx, .odt and .rtf files; {name} can't keep all formatting. Save as .docx to turn on AutoSave. Autospara fungerar bara med .docx-, .odt- och .rtf-filer; {name} kan inte behålla all formatering. Spara som .docx för att slå på Autospara. + AutoSave writes your changes to the file a moment after you stop typing. Autospara skriver dina ändringar till filen en liten stund efter att du slutat skriva. + Balloons Pratbubblor + Bullet Punkt + By author Efter författare + Centimeters Centimeter + Changed lines: Ändrade rader: + Click level to modify: Klicka på nivån du vill ändra: + Check spelling as you type Kontrollera stavningen medan du skriver + Click to switch back to inserting Klicka för att växla tillbaka till infogning + Color only Bara färg + Custom dictionary Egen ordlista + Define New Multilevel List Definiera ny lista med flera nivåer + Define New Multilevel List… Definiera ny lista med flera nivåer… + Deletions: Borttagningar: + Enter formatting for number: Ange formatering för numret: + Follow number with: Efter numret: + Formatting Formatering + Formatting marks Formateringstecken + Hidden text Dold text + Ignore web and file addresses Ignorera webb- och filadresser + Ignore words in capitals Ignorera ord med versaler + Ignore words with numbers Ignorera ord med siffror + Inches Tum + Formatting exceptions Formateringsundantag + Include level number from: Ta med nivånummer från: + Initials: Initialer: + Ink Bläck + Insertions and Deletions Infogningar och borttagningar + Insertions: Infogningar: + Interface Gränssnitt + Interface language Gränssnittsspråk + Inside: Inre: + Latin text font: Typsnitt för latinsk text: + Left border Vänster kantlinje + Legal style numbering Numrering i juridisk stil + Link level to style: Koppla nivån till stil: + List Definition Listdefinition + Mark grammar errors as you type Visa grammatikfel medan du skriver + Measurement units: Måttenheter: + Millimeters Millimeter + New comments and tracked changes are recorded under this name and these initials. Nya kommentarer och spårade ändringar sparas med det här namnet och de här initialerna. + New word Nytt ord + No words yet. Add to Dictionary on a misspelled word puts it here. Inga ord än. Välj Lägg till i ordlistan på ett felstavat ord, så hamnar det här. + Mirror indents Spegelvända indrag + Nothing Ingenting + Number alignment: Justering av nummer: + Number format Nummerformat + Number style for this level: Numreringsstil för den här nivån: + Outside border Yttre kantlinje + Overtype Överskrivning + Overtype (typing replaces the characters after the caret) Överskrivning (det du skriver ersätter tecknen efter markören) + Page display Sidvisning + Paragraph marks Stycketecken + Personalize Personligt + Picas Pica + Points Punkter + Print the page color Skriv ut sidfärgen + Printing Utskrift + Proofing language Korrekturspråk + Recent documents to show: Antal senaste dokument som visas: + Remove Ta bort + Outside: Yttre: + Restart list after: Börja om listan efter: + Right border Höger kantlinje + Save the document to turn on AutoSave Spara dokumentet för att slå på Autospara + Save the document to turn on AutoSave (a file you have only opened isn't rewritten until you save it) Spara dokumentet för att slå på Autospara (en fil som du bara har öppnat skrivs inte om förrän du sparar den) + Save documents Spara dokument + AutoText Autotext + Automatic Automatisk + Automatic ({language}) Automatiskt ({language}) + Automatic Table 1 (3 levels) Automatisk innehållsförteckning 1 (3 nivåer) + Automatic Table 2 (2 levels) Automatisk innehållsförteckning 2 (2 nivåer) + Automatic follows your system's language. Menus and commands change; your documents don't. Automatiskt följer systemets språk. Menyer och kommandon ändras, men inte dina dokument. + Backspace Backspace + Banded Columns Randade kolumner + Banded Rows Randade rader + Base text: Bastext: + Before: Före: + Bibliography Källförteckning + Binary assets added Tillagda binärfiler + Binary assets removed Borttagna binärfiler + Blank Tom + Blank (Three Columns) Tom (tre kolumner) + Blank Page Tom sida + Blank document Tomt dokument + Block\nAuthors Blockera\nförfattare + Bold Fet + Bookmark Bokmärke + Bookmark name: Bokmärkesnamn: + Book Title Boktitel + Border Painter Kantlinjepensel + Border\nPainter Kantlinje-\npensel + Borders Kantlinjer + Search Sök + Show the mini toolbar when text is selected Visa miniverktygsfältet när text är markerad + Spaces Blanksteg + Spelling Stavning + Spelling and grammar are checked in English (United States). Stavning och grammatik kontrolleras på engelska (USA). + Tab characters Tabbtecken + Tab stop position: Tabbstoppets position: + Default tab stops: Standardtabbstopp: + Tab stops to be cleared: Tabbstopp som ska rensas: + All Alla + Decimal Decimal + Bar Lodstreck + Leader Utfyllnad + Set Ange + Page Border Sidkantlinje + Apply to: Använd på: + These marks show even when Show/Hide ¶ is off. De här tecknen visas även när Visa/dölj ¶ är avstängt. + Title Bar Namnlist + Typing replaces selected text Det du skriver ersätter markerad text + Update fields before printing Uppdatera fält före utskrift + Use the Insert key to switch overtype on and off Använd Insert-tangenten för att slå på och av överskrivning + Whole document Hela dokumentet + This section Det här avsnittet + Fill Fyllning + Setting: Inställning: + 3-D 3D + Custom Anpassad + Style: Stil: + Dot dash Punkt-streck + Triple Trippel + Between Mellan + Borders and Shading Kantlinjer och skuggning + Tabs… Tabbstopp… + Borders and Shading… Kantlinjer och skuggning… + Bottom Border Nedre kantlinje + Bottom Margin Nedre marginal + Bottom of Page Längst ned på sidan + Bottom: Nederkant: + Box Ram + Breaks Brytningar + Bring Forward Flytta framåt + Bullet Library Punktbibliotek + Bullets Punktlista + Calendar Kalender + Cancel Avbryt + Capitalize Each Word Stor Bokstav I Varje Ord + Captions Bildtexter + Category Kategori + Cell Margins Cellmarginaler + Cell Size Cellstorlek + Cell\nMargins Cell-\nmarginaler + Center Centrera + Change Ändra + Change Case Ändra skiftläge + Change List Level Ändra listnivå + Change Picture Byt bild + Change Shape Byt figur + Changes Ändringar + Character Position Teckenposition + Character Scale Teckenskala + Character Shading Teckenskuggning + Character Spacing Teckenavstånd + Characters Tecken + Characters (no spaces) Tecken (utan blanksteg) + Characters (with spaces) Tecken (med blanksteg) + Chart Diagram + Check Accessibility Kontrollera tillgänglighet + Check Compatibility Kontrollera kompatibilitet + Check Spelling as You Type Kontrollera stavningen medan du skriver + Check for Errors Sök efter fel + Check\nAccessibility Kontrollera\ntillgänglighet + Citation Style Referensstil + Citations & Bibliography Källor och källförteckning + Clear Rensa + Clear All Rensa allt + Clear All Formatting Rensa all formatering + Clear Character Formatting Rensa teckenformatering + Clear Character Style Rensa teckenstil + Clear Formatting Level Rensa formateringsnivå + Clear Paragraph Formatting Rensa styckeformatering + Click a border between two cells to merge them. Esc stops. Klicka på en kantlinje mellan två celler för att sammanfoga dem. Esc avslutar. + Clipboard Urklipp + Clipboard Pane Urklippspanel + Clipboard Items Objekt i Urklipp + Paste Clipboard Item Klistra in objekt från Urklipp + Paste All Klistra in alla + Delete Clipboard Item Ta bort objekt från Urklipp + Click an item to paste it. Klicka på ett objekt för att klistra in det. + Nothing collected yet. Items you copy or cut appear here. Inget insamlat än. Det du kopierar eller klipper ut visas här. + (picture or object) (bild eller objekt) + Close Stäng + Close Header and Footer Stäng sidhuvud och sidfot + Collapse Selection Fäll ihop markeringen + Color Färg + Color: Färg: + Colors Färger + Column Kolumn + Column Break Kolumnbrytning + Columns Kolumner + Combine Kombinera + Commands Kommandon + Comment Kommentar + Comments Kommentarer + Comments Pane Kommentarspanel + Community\nDiscord Discord-\ncommunity + Compact Kompakt + Compare Jämför + Compress Pictures Komprimera bilder + Compress punctuation at the start of a line Komprimera skiljetecken i början av en rad + Continuous Löpande + Contributors Bidragsgivare + Convert Konvertera + Convert Text to Table Konvertera text till tabell + Convert Text to Table… Konvertera text till tabell… + Convert to Text Konvertera till text + Copy Kopiera + Copy Formatting Kopiera formatering + Corrections Korrigeringar + Couldn't make WordCraft the default: {error} Det gick inte att göra WordCraft till standardprogram: {error} + Count words in text boxes and notes Räkna ord i textrutor, fotnoter och slutnoter + Cover Page Försättsblad + Create Skapa + Create New Style Skapa ny stil + Create a Style Skapa en stil + 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. Skapa en interaktiv disposition av dokumentet.\n\nDet är ett bra sätt att hålla reda på var du är, eller att snabbt flytta runt innehållet.\n\nKom igång genom att gå till fliken Start och använda rubrikstilar på rubrikerna i dokumentet. + Crop Beskär + Cross-reference Korsreferens + Current Position Aktuell position + Current style: {style} Aktuell stil: {style} + Custom Margins… Anpassade marginaler… + Custom Watermark Anpassad vattenstämpel + Custom Watermark… Anpassad vattenstämpel… + Customize Quick Access Toolbar Anpassa snabbverktygsfältet + Cut Klipp ut + Dark Mode Mörkt läge + Dashed Streckad + Data Data + Date & Time Datum och tid + Decrease Font Size Minska teckenstorleken + Decrease Indent Minska indraget + Default Standard + Define New Bullet Definiera ny punkt + Define New Number Format Definiera nytt nummerformat + Delete Ta bort + Delete Cells Ta bort celler + Delete Columns Ta bort kolumner + Delete Comment Ta bort kommentar + Delete Next Word Ta bort nästa ord + Delete Previous Word Ta bort föregående ord + Delete Rows Ta bort rader + Delete Style Ta bort stil + Delete Table Ta bort tabell + Design Design + Diagonal Diagonal + Diamond Romb + Dictate Diktera + Different First Page Annan första sida + Different Odd & Even Pages Olika udda och jämna sidor + Direct formatting Direkt formatering + Discord Discord + Discord community Discord-community + Display Visning + Display for Review Visning för granskning + Distribute Columns Fördela kolumner + Distribute letters Fördela bokstäver + Distribute Rows Fördela rader + Distribute spacing Fördela avstånd + Distributed Fördelad + Do Not Show in TOC Visa inte i innehållsförteckningen + Document End Dokumentets slut + Document Formatting Dokumentformatering + Document Property Dokumentegenskap + Document Start Dokumentets början + Document Text Dokumenttext + Document Title Dokumenttitel + Documents you open will show up here. Dokument som du öppnar visas här. + Dot-dash Punkt-streck + Dotted Prickad + Double Dubbel + Double Spacing Dubbelt radavstånd + Double Strikethrough Dubbelt genomstruken + Double Underline Dubbelt understruken + Down Nedåt + Draft Utkast + Drag a rectangle to draw a table, or a line across cells to split them. Esc stops. Dra en rektangel för att rita en tabell, eller en linje över celler för att dela dem. Esc avslutar. + Draw Rita + Draw Table Rita tabell + Drawing Tools Ritverktyg + Drawing\nCanvas Rit-\nyta + Drop Cap Anfang + Drops all formatting and pastes plain text. Tar bort all formatering och klistrar in oformaterad text. + Edit Comment Redigera kommentar + Edit Footer Redigera sidfot + Edit Header Redigera sidhuvud + Edit Recipient List Redigera mottagarlista + Edit\nRecipient List Redigera\nmottagarlista + Edited Ändrat + Editing Redigering + Editing header/footer Redigerar sidhuvud/sidfot + Editing — edit the document directly Redigering – redigera dokumentet direkt + Editor Språkkontroll + Effects Effekter + Emboss Relief + Emphasis Betoning + Endnote Reference Slutnotsreferens + Endnote Text Slutnotstext + Encrypt with Password Kryptera med lösenord + English (United States) Engelska (USA) + Engrave Gravyr + Enter page number: Ange sidnummer: + Envelopes Kuvert + Eraser Suddgummi + Even Page Jämn sida + 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. Alla kommandon är tillgängliga för skript och AI-agenter: kör `wordcraft-cli mcp` för en MCP-server, eller starta appen med `--control ` för JSON-kontrollkanalen. + Export Exportera + Export PDF Exportera PDF + Export Page as PNG Exportera sidan som PNG + Export a copy to share (PDF, Word document) Exportera en kopia att dela (PDF, Word-dokument) + Exported {path} Exporterade {path} + Extend Selection Utöka markeringen + Extend Selection by Units Utöka markeringen med enheter + Field Fält + File Arkiv + Find Sök + Find Next Sök nästa + Find Previous Sök föregående + Find Recipient Sök mottagare + Find and Replace Sök och ersätt + Find what: Sök efter: + Finish Slutför + Finish & Merge Slutför och koppla + Finish &\nMerge Slutför och\nkoppla + First Column Första kolumnen + First commit Första commit + First line: Första raden: + Fixed Column Width Fast kolumnbredd + Focus Fokus + Font Typsnitt + Font Color Teckenfärg + Font Dialog Dialogrutan Typsnitt + Font Size Teckenstorlek + Font color: Teckenfärg: + Font: Typsnitt: + Fonts Typsnitt + Footer Sidfot + Footnote and Endnote Fotnot och slutnot + Footnote Reference Fotnotsreferens + Footnote Text Fotnotstext + Footnotes Fotnoter + Format Format + Format Page Numbers Formatera sidnummer + Format Page Numbers… Formatera sidnummer… + Format Painter Formatpensel + Formatted Text Formaterad text + Formatting at Selection Formatering vid markeringen + Formula Formel + General Allmänt + Go To Gå till + Go To (⌘⌥G) Gå till (⌘⌥G) + Go To… Gå till… + Go to Gå till + Grab bag Blandat + Greeting Line Hälsningsfras + Greeting\nLine Hälsnings-\nfras + Gridlines Stödlinjer + Group Gruppera + Grow Font 1 Point Öka teckenstorleken 1 punkt + HTML Format HTML-format + Hanging Indent Hängande indrag + Header Sidhuvud + Header & Footer Sidhuvud och sidfot + Header Row Rubrikrad + Header/Footer Position Position för sidhuvud/sidfot + Heading 1 Rubrik 1 + Heading 2 Rubrik 2 + Heading 3 Rubrik 3 + Headings Rubriker + Heart Hjärta + Help Hjälp + Hidden Dold + Highlight Merge Fields Framhäv kopplingsfält + Highlight\nMerge Fields Framhäv\nfält + Highlighter Överstrykningspenna + Home Start + Horizontal Vågrät + Horizontal Line Vågrät linje + Hyperlink Hyperlänk + Hyphenation Avstavning + Icons Ikoner + Ignore All Ignorera alla + Illustrations Illustrationer + Immersive Fördjupning + Immersive Reader Fördjupad läsning + Immersive\nReader Fördjupad\nläsning + In Default apps, choose WordCraft for the document types you want it to open. I Standardappar väljer du WordCraft för de dokumenttyper som den ska öppna. + Increase Font Size Öka teckenstorleken + Increase Indent Öka indraget + Indent Indrag + Indentation Indrag + Indents Indrag + Indents and Spacing Indrag och avstånd + Index Register + Info Info + Ink to\nMath Bläck till\nmatematik + Ink to\nShape Bläck till\nfigur + Ink\nReplay Spela upp\nbläck + Insert Infoga + Insert Above Infoga ovanför + Insert Below Infoga nedanför + Insert Caption Infoga bildtext + Insert Caption… Infoga bildtext… + Insert Citation Infoga källhänvisning + Insert Endnote Infoga slutnot + Insert Footnote Infoga fotnot + Insert Hyperlink Infoga hyperlänk + Insert Index Infoga register + Insert Left Infoga till vänster + Insert Merge Field Infoga kopplingsfält + Insert Merge\nField Infoga\nkopplingsfält + Insert Right Infoga till höger + Insert Shapes Infoga figurer + Insert Table Infoga tabell + Insert Table of Authorities Infoga förteckning över rättskällor + Insert Table of Figures Infoga figurförteckning + Insert Table… Infoga tabell… + Insert\nAbove Infoga\novanför + Insert\nCaption Infoga\nbildtext + Insert\nCitation Infoga\nkällhänvisning + Insert\nFootnote Infoga\nfotnot + Intense Emphasis Stark betoning + Intense Quote Starkt citat + Intense Reference Stark referens + Inside Borders Inre kantlinjer + Inspect Document Inspektera dokumentet + Inspect Styles at Selection Inspektera stilar vid markeringen + Interface language: Gränssnittsspråk: + Italic Kursiv + Join the ArtCraft community on Discord Gå med i ArtCrafts community på Discord + Join us on Discord: discord.gg/artcraft Gå med i ArtCrafts community på Discord: discord.gg/artcraft + Justify Marginaljustera + Keep Lines Together Håll ihop raderna + Keep Text Only Behåll bara texten + Keep lines together Håll ihop raderna + Keep with Next Håll ihop med nästa + Keep with next Håll ihop med nästa + Keeps the fonts and formatting of the copied text. Behåller typsnitt och formatering från den kopierade texten. + Keywords Nyckelord + Labels Etiketter + Landscape Liggande + Language Språk + Lasso Lasso + Last Column Sista kolumnen + Last commit Senaste commit + Layout Layout + Layout Summary Layoutsammanfattning + Left Vänster + Left Border Vänster kantlinje + Left Margin Vänstermarginal + Left: Vänster: + Letter Brev + Line Linje + Line Break Radbrytning + Line breaking Radbrytning + Line End Radslut + Line Numbers Radnummer + Line Spacing Radavstånd + Line Spacing Options… Alternativ för radavstånd… + Line Start Radbörjan + Line and Page Breaks Rad- och sidbrytningar + Line and Paragraph Spacing Rad- och styckeavstånd + Line delta Radskillnad + Line spacing: Radavstånd: + Lines Rader + Lines added Tillagda rader + Lines deleted Borttagna rader + Link Länk + Link to Previous Länka till föregående + Links Länkar + Location: {path} Plats: {path} + MIT OR Apache-2.0. Copyright (c) 2026 ArtCraft Team and the WordCraft contributors. MIT OR Apache-2.0. Copyright (c) 2026 ArtCraft-teamet och WordCrafts bidragsgivare. + Macros Makron + Mailings Utskick + Make WordCraft the default for Word documents… Gör WordCraft till standardprogram för Word-dokument… + Manage Sources Hantera källor + Many pages Många sidor + Margins Marginaler + Mark Citation Märk källhänvisning + Mark Entry Märk post + Mark\nCitation Märk\nkällhänvisning + Mark\nEntry Märk\npost + Markdown (*.md) Markdown (*.md) + Match Fields Matcha fält + Match case Matcha skiftläge + Matrix Matris + Merge Sammanfoga + Merge Cells Sammanfoga celler + Merge Formatting Sammanfoga formatering + Merged PRs Sammanslagna PR:er + Mirrored Spegelvända + Moderate Måttliga + Modify Style Ändra stil + Modify… Ändra… + More templates → Fler mallar → + Move or Resize Flytta eller ändra storlek + Multilevel List Lista med flera nivåer + Multiple Pages Flera sidor + Name (A–Z) Namn (A–Ö) + Name: Namn: + Narrow 0.5" all Smala 0,5" överallt + Navigation Navigering + Navigation Pane Navigeringspanel + New Ny + New Comment Ny kommentar + New Paragraph Nytt stycke + New Style… Ny stil… + New Window Nytt fönster + New from Template Ny från mall + New\nComment Ny\nkommentar + New\nWindow Nytt\nfönster + Next Nästa + Next Change Nästa ändring + Next Comment Nästa kommentar + Next Footnote Nästa fotnot + Next Page Nästa sida + Next Record Nästa post + Next Section Nästa avsnitt + No Border Ingen kantlinje + No Color Ingen färg + No Markup Ingen märkning + No Paragraph Space Inget styckeavstånd + No Spacing Inget avstånd + No contributor data was built into this copy. Den här kopian innehåller inga uppgifter om bidragsgivare. + No model credits were built into this copy. Den här kopian innehåller inga uppgifter om medverkande AI-modeller. + No templates match your search. Inga mallar matchar sökningen. + Nonbreaking Hyphen Hårt bindestreck + Nonbreaking Space Hårt blanksteg + None Ingen + Normal 1" all Normala 1" överallt + Normal Style Stilen Normal + Not available right now Inte tillgängligt just nu + Not saved Inte sparat + Not saved yet Inte sparat än + Nudge Knuffa + Number of columns: Antal kolumner: + Number of rows: Antal rader: + Numbering Numrering + Offset (pt): Förskjutning (pt): + OK OK + Object Objekt + Odd Page Udda sida + Office 2003 Default Standard för Office 2003 + One En + One Page En sida + Open Öppna + OpenDocument Text (*.odt) OpenDocument-text (*.odt) + Opens .docx, .doc, .rtf and .odt files and Word templates with WordCraft from now on. Öppnar .docx-, .doc-, .rtf- och .odt-filer och Word-mallar med WordCraft från och med nu. + Opens Settings › Default apps, where you choose WordCraft for .docx and the other document types. Öppnar Inställningar › Standardappar, där du väljer WordCraft för .docx och de andra dokumenttyperna. + Optional Hyphen Mjukt bindestreck + Copyright Sign Copyrighttecken + Ellipsis Ellips + Em Dash Långt tankstreck + Insert Date Field Infoga datumfält + Insert Page Field Infoga sidfält + Insert Time Field Infoga tidsfält + Registered Sign Tecken för registrerat varumärke + Reset Character Formatting Återställ teckenformatering + Reset Paragraph Formatting Återställ styckeformatering + Trademark Sign Varumärkestecken + Options Alternativ + Orientation Orientering + Outline Disposition + Outline Level Dispositionsnivå + Outside Borders Yttre kantlinjer + Oval Oval + PDF document (*.pdf) PDF-dokument (*.pdf) + Page Sida + Page Background Sidbakgrund + Page Borders Sidkantlinjer + Page Break Sidbrytning + Page Break Before Sidbrytning före + Page Breaks Sidbrytningar + Page Color Sidfärg + Page Down Sida nedåt + Page Movement Sidbläddring + Page Number Sidnummer + Page Setup Sidinställningar + Page Up Sida uppåt + Page Width Sidbredd + Page X of Y Sida X av Y + Page break before Sidbrytning före + Page image (*.png) Sidbild (*.png) + Page width Sidbredd + Page {page} Sida {page} + Page {page} of {pages} Sida {page} av {pages} + Page\nBorders Sid-\nkantlinjer + Page\nColor Sid-\nfärg + Page\nNumber Sid-\nnummer + Pages Sidor + Paragraph Stycke + Paragraph Details Styckedetaljer + Paragraph Down Stycke nedåt + Paragraph Settings Styckeinställningar + Paragraph Spacing Styckeavstånd + Paragraph Up Stycke uppåt + Paragraphs Stycken + Paste Klistra in + Paste (⌘V) Klistra in (⌘V) + Paste Formatting Klistra in formatering + Paste Special Klistra in special + Paste Special… Klistra in special… + Paste as: Klistra in som: + Paste: Keep Text Only Klistra in: Behåll bara texten + Paste: Merge Formatting Klistra in: Sammanfoga formatering + Pen Penna + Pencil Blyertspenna + Phonetic Guide Fonetisk guide + Picture Border Bildkantlinje + Picture Format Formatera bild + Picture Styles Bildstilar + Pictures Bilder + Plain text (*.txt) Oformaterad text (*.txt) + Portrait Stående + Position Position + Preview Results Förhandsgranska resultat + Preview\nResults Förhandsgranska\nresultat + Previous Föregående + Previous Change Föregående ändring + Previous Comment Föregående kommentar + Previous Record Föregående post + Previous Section Föregående avsnitt + Print Skriv ut + Print Layout Utskriftslayout + Print\nLayout Utskrifts-\nlayout + Proofing Korrektur + Proofing Issues Korrekturanmärkningar + Properties Egenskaper + Protect Skydda + Protect Document Skydda dokument + Quick Access Toolbar Snabbverktygsfält + Quick Parts Snabbdelar + Quick Tables Snabbtabeller + Quote Citat + Read Aloud Läs upp + Read Mode Läsläge + Read\nAloud Läs\nupp + Read\nMode Läs-\nläge + Reads the clipboard's rich text and keeps its formatting. Läser formaterad text från Urklipp och behåller formateringen. + Reads the clipboard's web content and keeps its formatting. Läser webbinnehåll från Urklipp och behåller formateringen. + Picture Bild + Pastes the clipboard's picture. Klistrar in bilden från Urklipp. + Reading Highlight Framhäv träffar + Recent Senaste + Record Macro Spela in makro + Recover Unsaved Documents Återställ osparade dokument + Rectangle Rektangel + Redo Gör om + Reduce Hanging Indent Minska hängande indrag + References Referenser + Reject Avvisa + Reject All Changes Avvisa alla ändringar + Reject Change Avvisa ändring + Relaxed Luftig + Remove Background Ta bort bakgrund + Remove Footer Ta bort sidfot + Remove Header Ta bort sidhuvud + Remove Hyperlink Ta bort hyperlänk + Remove Space After Paragraph Ta bort avstånd efter stycke + Remove Table of Contents Ta bort innehållsförteckning + Remove Watermark Ta bort vattenstämpel + Reopen Öppna igen + Repeat Upprepa + Repeat Header Rows Upprepa rubrikrader + Replace Ersätt + Replace All Ersätt alla + Replace Document Text Ersätt dokumenttext + Replace with: Ersätt med: + Replay Uppspelning + Reply Svara + Report Rapport + Research Efterforskning + Researcher Efterforska + Reset Picture Återställ bild + Reset to Normal Återställ till Normal + Resolve Lös + Resolved Löst + Restart Each Page Börja om på varje sida + Restart Each Section Börja om i varje avsnitt + Restart at 1 Börja om på 1 + Restrict Editing Begränsa redigering + Restrict\nEditing Begränsa\nredigering + Result Resultat + Results Resultat + Reverse the order Vänd på ordningen + Reviewing Granskning + Reviewing Pane Granskningspanel + Reviewing — edits become suggestions Granskning – ändringar blir förslag + Rich Text Format (*.rtf) Rich Text Format (*.rtf) + Rich Text Format (RTF) Rich Text Format (RTF) + Right Höger + Right (vertical) Höger (lodrät) + Right Border Höger kantlinje + Right Margin Högermarginal + Right-to-Left Text Direction Textriktning höger till vänster + Left-to-Right Text Direction Textriktning vänster till höger + Right: Höger: + Rotate Rotera + Rounded Rectangle Rundad rektangel + Rows & Columns Rader och kolumner + Ruby text: Uttalstext: + Ruler Linjal + Rules Regler + Résumé CV + Save Spara + Save As Spara som + Save a copy in another format. Printing goes through a PDF you can print from any viewer. Spara en kopia i ett annat format. Utskrift går via en PDF som du kan skriva ut från valfritt visningsprogram. + Save as Template Spara som mall + Saved Sparat + Search Commands Sök kommandon + Search commands and help Sök kommandon och hjälp + Search document Sök i dokumentet + Search for templates Sök efter mallar + Section Breaks Avsnittsbrytningar + Section Properties Avsnittsegenskaper + Sections Avsnitt + Select Markera + Select All Markera allt + Select Cell Markera cell + Select Line Markera textrad + Select Objects Markera objekt + Select Paragraph Markera stycke + Select Range Markera område + Select Recipients Välj mottagare + Select Row Markera rad + Select Sentence Markera mening + Select Table Markera tabell + Select Text Markera text + Select Text with Similar Formatting Markera text med liknande formatering + Select Word Markera ord + Select\nRecipients Välj\nmottagare + Selection Markering + Selection Pane Markeringspanel + Send Backward Flytta bakåt + Sentence case. Som i en mening. + Set Caret Placera markören + Set Character Formatting Ange teckenformatering + Set Numbering Value Ange numreringsvärde + Set Paragraph Formatting Ange styckeformatering + Set as Default Ange som standard + Shading Skuggning + Shadow Skugga + Shape Fill Figurfyllning + Shape Format Formatera figur + Shape Outline Figurkontur + Shape Styles Figurstilar + Shapes Figurer + Share Dela + Shift+Tab Shift+Tab + Show Visa + Show all revisions inline Visa alla ändringar i texten + Show Comments Visa kommentarer + Show Markup Visa märkning + Show Notes Visa fot- och slutnoter + Show all formatting marks Visa alla formateringstecken + Show only comments and formatting in balloons Visa bara kommentarer och formatering i pratbubblor + Show revisions in balloons Visa ändringar i pratbubblor + Show rulers Visa linjaler + Animation Animering + Animate the caret (it glides and fades) Animera markören (den glider och tonas) + 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. På: markören glider till sin nya plats när du skriver eller använder piltangenterna, och blinkningen tonas mjukt. Av: den hoppar och blinkar av och på. + Show the community button in the title bar Visa community-knappen i namnlisten + Show/Hide ¶ Visa/dölj ¶ + Show\nComments Visa\nkommentarer + Shrink Font 1 Point Minska teckenstorleken 1 punkt + Side to Side Sida vid sida + Side\nto Side Sida vid\nsida + Signature Line Signaturrad + Single Enkel + Single Spacing Enkelt radavstånd + Size Storlek + Size (pt): Storlek (pt): + Size: Storlek: + Small Caps Kapitäler + Small caps Kapitäler + SmartArt SmartArt + Sort Sortera + Source code: github.com/storytold/wordcraft Källkod: github.com/storytold/wordcraft + Space Blanksteg + Space before/after: Avstånd före/efter: + Spacing Avstånd + Spacing (pt): Avstånd (pt): + Speech Tal + Spelling & Grammar Stavning och grammatik + Spelling &\nGrammar Stavning och\ngrammatik + Spelling Suggestions Stavningsförslag + Split Dela + Split Cells Dela celler + Split Table Dela tabell + Spreadsheet Table Kalkylbladstabell + Standard Colors Standardfärger + Star Stjärna + Start at: Börja på: + Start Mail Merge Starta dokumentkoppling + Start Mail\nMerge Starta\nkoppling + Statistics Statistik + Status Bar Statusfält + Strikethrough Genomstruken + Strong Stark + Studio cover Försättsblad Studio + Studio handbook (sample) Studiohandbok (exempel) + Style Stil + Style Inspector Stilinspektör + Style Set Stiluppsättning + Style based on: Stil baserad på: + Style: {style} Stil: {style} + Styles Stilar + Styles Pane Stilpanel + Subject Ämne + Subscript Nedsänkt + Subtitle Underrubrik + Subtle Emphasis Diskret betoning + Subtle Reference Diskret referens + Superscript Upphöjd + Switch Modes Byt läge + Switch\nModes Byt\nläge + Symbol Symbol + Symbols Symboler + Synonyms Synonymer + Tab Tabb + Tab character Tabbtecken + Table Tabell + Table Column Width Tabellens kolumnbredd + Table Design Tabelldesign + Table Layout Tabellayout + Table Properties Tabellegenskaper + Table Properties… Tabellegenskaper… + Table Row Height Tabellens radhöjd + Table Style Options Alternativ för tabellstil + Table Styles Tabellstilar + Table of Authorities Förteckning över rättskällor + Table of Contents Innehållsförteckning + Table of\nContents Innehålls-\nförteckning + Tables Tabeller + Tabs Tabbstopp + Tabular List Lista i tabellform + Text Text + Text Box Textruta + Text Direction Textriktning + Text Effects Texteffekter + Text Effects and Typography Texteffekter och typografi + Text Highlight Color Överstrykningsfärg + Text indent at: Textindrag vid: + Text Wrapping Textombrytning + Text from File Text från fil + Text to display: Text som visas: + Text: Text: + Text\nBox Text-\nruta + Text\nDirection Text-\nriktning + The clipboard is empty. Urklipp är tomt. + The new style takes the formatting of the current paragraph. Den nya stilen får formateringen från det aktuella stycket. + Theme Colors Temafärger + Theme Fonts Temats typsnitt + Theme List Temalista + Themes Teman + Thesaurus Synonymordlista + Thick Tjock + Three Tre + Tight Tät + TOC Heading Innehållsrubrik + TOC {n} Innehåll {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. Så här öppnar du Word-dokument med WordCraft som standard: markera ett dokument i Finder, välj Arkiv › Hämta info, välj WordCraft under Öppna med och klicka sedan på Ändra alla. + Top Border Övre kantlinje + Top Margin Övre marginal + Top of Page Överst på sidan + Top: Överkant: + Total Row Summarad + Track Changes Spåra ändringar + Track Changes Options Alternativ för Spåra ändringar + Track Changes: On Spåra ändringar: På + Track formatting Spåra formatering + Tracking Spårning + Translate Översätt + Transparency Genomskinlighet + Triangle Triangel + Two Två + Type Text Skriv text + Type a command, e.g. "insert table" Skriv ett kommando, t.ex. ”infoga tabell” + Ungroup Dela upp grupp + UPPERCASE VERSALER + Underline Understruken + Underline Words Only Understryk bara ord + Undo Ångra + Undo {action} Ångra {action} + Undo Actions Ångra åtgärder + Undo History Ångrahistorik + Undo 1 Action Ångra 1 åtgärd + Undo {n} Actions Ångra {n} åtgärder + Unformatted Text Oformaterad text + Up Uppåt + Update Field Uppdatera fält + Update Index Uppdatera register + Update Style to Match Selection Uppdatera stilen så att den matchar markeringen + Update Table Uppdatera förteckning + Update Table of Figures Uppdatera figurförteckning + Update to Match Uppdatera så att den matchar + Update to Match Selection Uppdatera så att den matchar markeringen + User Name Användarnamn + User name: Användarnamn: + Version History Versionshistorik + Version {version} ({build}) Version {version} ({build}) + Vertical Lodrät + Vertical Alignment Lodrät justering + View Visa + View Gridlines Visa stödlinjer + View State Visningstillstånd + Views Vyer + Voice Röst + Watermark Vattenstämpel + Wave Vågig + Web Layout Webblayout + Web\nLayout Webb-\nlayout + Web page (*.html) Webbsida (*.html) + Whole page Hela sidan + Whole words Hela ord + Wide Bred + Widow/Orphan Control Undvik ensamma rader + Widow/Orphan control Undvik ensamma rader + Window Fönster + Word Count Antal ord + Word Left Ord åt vänster + Word Right Ord åt höger + Word document (*.docx) Word-dokument (*.docx) + Word XML Document (*.xml) Word XML-dokument (*.xml) + WordArt WordArt + WordCraft WordCraft + WordCraft is now the default app for Word documents. WordCraft är nu standardprogram för Word-dokument. + Words Ord + Words only Bara ord + Wrap Text Textombrytning + Wrap\nText Text-\nombrytning + Write & Insert Fields Skriv och infoga fält + Write a reply Skriv ett svar + Zoom Zoom + Zoom In Zooma in + Zoom Out Zooma ut + development build utvecklingsversion + lowercase gemener + {group} settings Inställningar för {group} + {key}+Click to follow link {key}+klicka för att följa länken + {selected} of {words} words {selected} av {words} ord + {words} words {words} ord + Browse… Bläddra… + Equation Ekvation + Review Granska + Track\nChanges Spåra\nändringar + Track\nChanges ✓ Spåra\nändringar ✓ + Scroll ribbon left Rulla verktygspanelen åt vänster + Scroll ribbon right Rulla verktygspanelen åt höger + Apply Styles Använd stilar + Dark page (white text on black) Mörk sida (vit text på svart) + Show documents with their colours inverted, like View › Switch Modes. Saving, printing and PDFs are unchanged. Visa dokument med inverterade färger, som Visa › Byt läge. Sparande, utskrift och PDF-filer påverkas inte. + Only web (http, https) and email (mailto) links can be followed Bara webblänkar (http, https) och e-postlänkar (mailto) kan följas + Character Border Teckenkantlinje + Direction: Riktning: + Right-to-left Höger till vänster + Left-to-right Vänster till höger +# Zotero integration + Zotero Zotero + Citations Källhänvisningar + Document Dokument + Add/Edit\nCitation Lägg till/redigera\nkällhänvisning + Add\nNote Lägg till\nanteckning + Add/Edit\nBibliography Lägg till/redigera\nkällförteckning + Refresh Uppdatera + Document\nPreferences Dokument-\ninställningar + Unlink\nCitations Koppla bort\nkällhänvisningar + Waiting for Zotero… Väntar på Zotero… + Zotero: done Zotero: klart + Zotero: cancelled Zotero: avbrutet + Yes Ja + No Nej +# Read Aloud player + Move Past Citation Hoppa förbi källhänvisning + Move Past\nCitation Hoppa förbi\nkällhänvisning + Next Sentence Nästa mening + Previous Sentence Föregående mening + Next sentence Nästa mening + Previous sentence Föregående mening + Play/Pause Spela upp/pausa + Read Aloud Status Status för uppläsning + Reading Speed Läshastighet + Skip Citations and Bibliography Hoppa över källhänvisningar och källförteckning + Skip citations & bibliography Hoppa över källhänvisningar och källförteckning + Stop Reading Sluta läsa + Don't read Zotero, Mendeley or EndNote citations and bibliographies Läs inte upp källhänvisningar och källförteckningar från Zotero, Mendeley eller EndNote + Play Spela upp + Pause Pausa + Stop Stoppa + Sentence Mening + Paused Pausad + Finished Klar + Stopped Stoppad + Speed Hastighet + Normal speed Normal hastighet + AutoSave documents you have saved in WordCraft Autospara dokument som du har sparat i WordCraft + AutoSave failed: {error}. Save the document to turn AutoSave back on. Autospara misslyckades: {error}. Spara dokumentet för att slå på Autospara igen. + Couldn't download {name}: {error} Det gick inte att ladda ner {name}: {error} + Do you want to save changes to {name}? Vill du spara ändringarna i {name}? + Don't Save Spara inte + That format doesn't keep everything, so the document is still open. Save it as a Word document (.docx) to go on. Det formatet behåller inte allt, så dokumentet är fortfarande öppet. Spara det som ett Word-dokument (.docx) för att fortsätta. + Your changes will be lost if you don't save them. Dina ändringar går förlorade om du inte sparar dem. +# Equations. + Change Structure Ändra struktur + Change to Display Visa på egen rad + Change to Inline Visa i texten + Conversions Konverteringar + Delete Back in Equation Ta bort bakåt i ekvation + Delete in Equation Ta bort i ekvation + Edit Equation Redigera ekvation + Enter in Equation Enter i ekvation + Equation Gallery Ekvationsgalleri + Equation Info Ekvationsinformation + Equation Number Ekvationsnummer + Input Format Inmatningsformat + Insert Structure Infoga struktur + Insert Symbol Infoga symbol + Justification Justering + Leave Equation Lämna ekvation + Move in Equation Flytta i ekvation + Normal Text Normal text + Set Equation Ange ekvation + Structure Actions Strukturåtgärder + Structures Strukturer + Tools Verktyg + Type in Equation Skriv i ekvation + Inline I texten + Number Nummer + Close Equation Stäng ekvation + Insert New Equation Infoga ny ekvation + Built-In Inbyggd + Professional Professionell + Linear Linjär + Fraction Bråk + Script Index + Radical Rot + Integral Integral + Large\nOperator Stor\noperator + Bracket Parentes + Function Funktion + Accent Accent + Limit and\nLog Gränsvärde\noch log + Operator Operator + Equation Options Ekvationsalternativ + All symbol sets Alla symboluppsättningar + Send the document straight to the system print dialog, or save a copy in another format below. Skicka dokumentet direkt till systemets utskriftsdialog, eller spara en kopia i ett annat format nedan. + Or save a copy in another format: Eller spara en kopia i ett annat format: + Opening print dialog… Öppnar utskriftsdialogen… + Print failed: {error} Utskriften misslyckades: {error} + Couldn't open {path}: {error} Det gick inte att öppna {path}: {error} + Column Selection Kolumnmarkering + New Table Style Ny tabellstil + Modify Table Style Ändra tabellstil + New Table Style… Ny tabellstil… + Modify Table Style… Ändra tabellstil… + Apply formatting to: Använd formatering på: + Whole Table Hela tabellen + Fill color: Fyllningsfärg: + {count} items highlighted {count} träffar framhävda + {n} pt {n} pt + Not {name} Inte {name} + Level {n} Nivå {n} + Body Text Brödtext + {n} lines {n} rader + At least {n} Minst {n} + Exactly {n} Exakt {n} + Centered Centrerad + Character spacing Teckenavstånd + Don't add space between paragraphs of the same style Lägg inte till avstånd mellan stycken med samma stil + Don't hyphenate Avstava inte + Double strikethrough Dubbelt genomstruken + Drop cap Anfang + First line Första raden + Font color Teckenfärg + Highlight Överstrykning + Justified Marginaljusterad + Left indent Vänsterindrag + Line spacing Radavstånd + Outline level Dispositionsnivå + Raised/lowered Upphöjd/sänkt + Right indent Högerindrag + Scale Skala + Space after Avstånd efter + Space before Avstånd före + Suppress line numbers Utan radnummer + Underline color Understrykningsfärg + Letters Brev + E-mail Messages E-postmeddelanden + Directory Katalog + Normal Word Document Vanligt Word-dokument + If…Then…Else… Om…så…annars… + If…Then…Else Om…så…annars + Merge Record # Kopplingspostens nummer + Skip Record If… Hoppa över post om… + Skip Record If Hoppa över post om + Equals: Är lika med: + Then insert: Infoga då: + Otherwise insert: Infoga annars: + Courtesy Title Tilltalstitel + First Name Förnamn + Last Name Efternamn + Company Företag + Address Adress + City Ort + State Region + Postal Code Postnummer + Country Land + (not matched) (ingen matchning) + Leave a recipient out of the merge when a field has this value. Utelämna en mottagare från kopplingen när ett fält har det här värdet. + Insert one text or another, depending on a field's value. Infoga en text eller en annan, beroende på ett fälts värde. + Select recipients first to match their fields. Välj mottagare först för att matcha deras fält. + The recipient-list field that fills each part of an address block and greeting line. Fältet i mottagarlistan som fyller i varje del av adressblocket och hälsningsfrasen. + No errors found: every merge field is in the recipient list. Inga fel hittades: alla kopplingsfält finns i mottagarlistan. + These merge fields aren't in the recipient list: De här kopplingsfälten finns inte i mottagarlistan: + Add at least one field. Lägg till minst ett fält. + Add Field Lägg till fält + Every field needs a name. Varje fält behöver ett namn. + Field name Fältnamn + Field names must be different. Fältnamnen måste vara olika. + Fields: Fält: + Field: Fält: + Find: Sök: + New Entry Ny post + Recipient List Mottagarlista + Recipients: {count} Mottagare: {count} + Remove field Ta bort fält + Remove recipient Ta bort mottagare + Select recipients first to choose from their fields. Välj mottagare först för att kunna välja bland deras fält. + Type a New List… Skriv en ny lista… + Type at least one recipient. Skriv minst en mottagare. + Type the recipients, or load them from a file. Skriv mottagarna eller läs in dem från en fil. + Use an Existing List… Använd en befintlig lista… + Choose the sheet that holds the recipients. Välj kalkylbladet som innehåller mottagarna. + First row of data contains column headers Första dataraden innehåller kolumnrubriker + Interface theme: Gränssnittstema: + Interface\nTheme Gränssnitts-\ntema + Interface Theme Gränssnittstema + Light Ljust + Dark Mörkt + Use system setting Använd systeminställningen + A file dialog is already open. En fildialogruta är redan öppen. + Height: Höjd: + Width: Bredd: + Row Rad + Cell Cell + Preferred width: Önskad bredd: + Alignment: Justering: + Indent from left: Indrag från vänster: + Specify height: Ange höjd: + Exactly Exakt + At least Minst + Allow row to break across pages Tillåt att raden bryts över sidor + Repeat as header row on each page Upprepa som rubrikrad på varje sida + Vertical alignment: Lodrät justering: + Top Upptill + Bottom Nedtill + Alt text Alternativtext + Alt: Alt-text: + W: B: + H: H: + Reset Återställ + Reset Crop Återställ beskärning + Insert a new equation (Alt+=) Infoga en ny ekvation (Alt+=) + Leave the equation (Esc) Lämna ekvationen (Esc) + Preview Förhandsvisning + getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft + Draw Ink Rita med bläck + Erase Ink Sudda bläck + Hide Ink Dölj bläck + Hide\nInk Dölj\nbläck + Pens Pennor + Thickness Tjocklek + +# Columns, Symbol and Field dialogs (#321). + (All) (Alla) + (none) (ingen) + (normal text) (normal text) + 1/4 Em Space Kvartsfyrkant + A mail merge field Ett kopplingsfält + A numbered sequence (figures, tables…) En numrerad serie (figurer, tabeller…) + A table of contents En innehållsförteckning + Categories: Kategorier: + Character Tecken + Character code: Teckenkod: + Col #: Kol. nr: + Copyright Copyright + Date and Time Datum och tid + Date formats: Datumformat: + Description: Beskrivning: + Document Information Dokumentinformation + Double Closing Quote Dubbelt slutcitattecken + Double Opening Quote Dubbelt startcitattecken + En Dash Tankstreck + Em Space Helfyrkant + En Space Halvfyrkant + Equal column width Lika kolumnbredd + Field Codes Fältkoder + Field code: Fältkod: + Field codes: Fältkoder: + Field name: Fältnamn: + Field names: Fältnamn: + Field properties Fältegenskaper + Field… Fält… + Format: Format: + Hide Codes Dölj koder + Index and Tables Register och förteckningar + Line between Linje mellan + Links and References Länkar och referenser + List Paragraph Liststycke + Mail Merge Dokumentkoppling + More Columns… Fler kolumner… + More Symbols… Fler symboler… + No-Width Non Break Nollbredd, ingen brytning + No-Width Optional Break Nollbredd, valfri brytning + Paragraph Mark Stycketecken + Pick a field from the list Välj ett fält i listan + Preserve formatting during updates Behåll formateringen vid uppdatering + Presets Förinställningar + Recently used symbols: Senast använda symboler: + Registered Registrerat varumärke + Section Mark Paragraftecken + Sequence name: Serienamn: + Shortcut key: Kortkommando: + Single Closing Quote Enkelt slutcitattecken + Single Opening Quote Enkelt startcitattecken + Spacing: Avstånd: + Special Characters Specialtecken + Subset: Delmängd: + The time right now Klockslaget just nu + When this document was first created När dokumentet skapades + When this document was most recently printed När dokumentet senast skrevs ut + When this document was most recently saved När dokumentet senast sparades + The document has no bookmarks yet. Dokumentet har inga bokmärken än. + Who the document properties name as author Den som anges som författare i dokumentegenskaperna + Name of the file this document is saved as Namnet på filen som dokumentet är sparat som + Title from the document properties Titeln från dokumentegenskaperna + How many pages the whole document has Antal sidor i hela dokumentet + How many pages this section has Antal sidor i det här avsnittet + Number of the page the field sits on Numret på sidan där fältet står + Number of the section the field sits in Numret på avsnittet där fältet står + How many words the document holds Antal ord i dokumentet + Page on which a bookmarked passage falls Sidan där den bokmärkta texten står + Copy of the text a bookmark marks En kopia av texten som ett bokmärke anger + This field has no options. Det här fältet har inga alternativ. + This font has no characters to show. Det här typsnittet har inga tecken att visa. + This point forward Härifrån och framåt + The date it is today Dagens datum + Trademark Varumärke + Type the field's name and its switches, for example PAGE \\* roman. Skriv fältets namn och dess växlar, till exempel PAGE \\* roman. + Width and spacing Bredd och avstånd + from Unicode (hex) från Unicode (hex) + Shape Effects Figureffekter + Glow Glöd + Soft Edges Mjuka kanter + No Shadow Ingen skugga + No Glow Ingen glöd + No Soft Edges Inga mjuka kanter + Glow Colors Glödfärger + Weight Tjocklek + Shadow, glow and soft edges Skugga, glöd och mjuka kanter + Offset: Bottom Right Förskjutning: nere till höger + Offset: Bottom Förskjutning: nere + Offset: Bottom Left Förskjutning: nere till vänster + Offset: Right Förskjutning: höger + Offset: Center Förskjutning: mitten + Offset: Left Förskjutning: vänster + Offset: Top Right Förskjutning: uppe till höger + Offset: Top Förskjutning: uppe + Offset: Top Left Förskjutning: uppe till vänster + Manage Styles Hantera stilar + Show or Hide Style Visa eller dölj stil + Manage Styles… Hantera stilar… + Sort order: Sorteringsordning: + As Recommended Som rekommenderat + Alphabetical I bokstavsordning + Font: {font} Typsnitt: {font} + {size} pt {size} pt + Color: {color} Färg: {color} + Space before: {pt} pt Avstånd före: {pt} pt + Space after: {pt} pt Avstånd efter: {pt} pt + Based on: {style} Baserad på: {style} + Paragraph style Styckestil + Character style Teckenstil + Linked style (paragraph and character) Länkad stil (stycke och tecken) + Table style Tabellstil + Built-in style Inbyggd stil + Show in the Styles gallery Visa i stilgalleriet + Hide from style lists Dölj i stillistor + Built-in styles can't be deleted. Inbyggda stilar kan inte tas bort. + Delete Table Style Ta bort tabellstil + Password Lösenord + Password: Lösenord: + Confirm password: Bekräfta lösenordet: + Encrypt with Password… Kryptera med lösenord… + Change Password… Ändra lösenord… + Remove Password Ta bort lösenord + A password is required to open this document. Det krävs ett lösenord för att öppna det här dokumentet. + Anyone can open this document. Encrypt it with a password to keep it private. Vem som helst kan öppna det här dokumentet. Kryptera det med ett lösenord för att hålla det privat. + Anyone who opens the document will need this password. Alla som öppnar dokumentet behöver det här lösenordet. + {name} is protected with a password. {name} är skyddat med lösenord. + That password isn't right. Check it (passwords are case-sensitive) and try again. Lösenordet stämmer inte. Kontrollera det (lösenord skiljer på versaler och gemener) och försök igen. + The document will be saved with a password. Save it to protect it. Dokumentet sparas med lösenord. Spara det för att skydda det. + The password is removed. Save the document to keep the change. Lösenordet är borttaget. Spara dokumentet för att behålla ändringen. + The passwords don't match. Lösenorden stämmer inte överens. + Keep the password somewhere safe: without it nobody, WordCraft included, can open the document. Leave both boxes empty to remove the password. Förvara lösenordet på ett säkert ställe: utan det kan ingen, inte heller WordCraft, öppna dokumentet. Lämna båda rutorna tomma för att ta bort lösenordet. + +# Interface text routed through translation (#313) + Align Top Left Justera uppe till vänster + Align Top Center Justera uppe i mitten + Align Top Right Justera uppe till höger + Align Center Left Justera i mitten till vänster + Align Center Justera i mitten + Align Center Right Justera i mitten till höger + Align Bottom Left Justera nere till vänster + Align Bottom Center Justera nere i mitten + Align Bottom Right Justera nere till höger + {contributors} contributors · {commits} commits Bidragsgivare: {contributors} · Commits: {commits} + {cols}×{rows} Table Tabell {cols}×{rows} + AaBbYyZz AaBbÅåÖö + pt pt + Document1 Dokument1 + Username Användarnamn + Display name Visningsnamn + Real name Riktigt namn + Name Namn + PRs PR:er + Commits Commits + First Första + Last Senaste + Model Modell + Models Modeller + Version Version + % of all commits % av alla commits + Lines +/− Rader +/− + Craft Craft + Studio Studio + Atelier Ateljé + Darkroom Mörkrum + Gallery Galleri + Sketchbook Skissbok + Change Picture… Byt bild… + Font… Typsnitt… + Paragraph… Stycke… + Link… Länk… + Size and Crop… Storlek och beskärning… + Insert Columns to the Left Infoga kolumner till vänster + Insert Columns to the Right Infoga kolumner till höger + Insert Rows Above Infoga rader ovanför + Insert Rows Below Infoga rader nedanför + Mon Mån + Tue Tis + Wed Ons + Sum Summa + North Norr + South Söder + West Väster + Total Totalt + +# Table Layout › Draw (#308) + Draw\nTable Rita\ntabell + Rotation Rotation + Rotation: Rotation: + Rotate Right 90° Rotera 90° åt höger + Rotate Left 90° Rotera 90° åt vänster + Flip Vertical Vänd lodrätt + Flip Horizontal Vänd vågrätt + Move Paragraph Up Flytta stycket uppåt + Move Paragraph Down Flytta stycket nedåt + Promote Höj nivå + Demote Sänk nivå + Go Back Gå tillbaka + Toggle Unicode Character Växla Unicode-tecken + Toggle Field Codes Växla fältkoder + Edit Field Code Redigera fältkod + Unlink Fields Koppla bort fält + Lock Fields Lås fält + Unlock Fields Lås upp fält + Next Field Nästa fält + Previous Field Föregående fält + Selected Fields Markerade fält + Edit Field… Redigera fält… + Formatted Formaterat + Deleted Borttaget + Interface size: Gränssnittets storlek: + Resize Table by Dragging Ändra tabellens storlek genom att dra + Print Markup Skriv ut märkning + Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. Kommentarer bredvid varje sida och spårade ändringar i PDF-filer, utskrifter och bilder; en kommentarslista i slutet av webbsidor, Markdown och text. + +# Compare / Combine, AutoCorrect Options and Restrict Editing (#412) + 1. Formatting restrictions 1. Formateringsbegränsningar + 2. Editing restrictions 2. Redigeringsbegränsningar + 3. Start enforcement 3. Börja tillämpa + << Less << Mindre + Another file En annan fil + Apply as you type Använd medan du skriver + AutoCorrect Autokorrigering + AutoFormat As You Type Autoformat medan du skriver + Automatic bulleted lists Automatiska punktlistor + Automatic numbered lists Automatiska numrerade listor + Border lines Kantlinjer + Capitalize first letter of sentences Versal i början av meningar + Capitalize first letter of table cells Versal i början av tabellceller + Correct TWo INitial CApitals Rätta TVå INledande VErsaler + Correct accidental usage of cAPS LOCK key Rätta oavsiktlig användning av cAPS LOCK + Capitalize names of days Inled veckodagar med versal + Case changes Ändrat skiftläge + Character level Teckennivå + Checked styles are allowed: Ikryssade stilar är tillåtna: + Combine Documents Kombinera dokument + Compare Documents Jämför dokument + Compared: {changes} changes Jämfört. Ändringar: {changes} + Comparing with another file needs the desktop app. Jämförelse med en annan fil kräver skrivbordsappen. + Comparison settings Jämförelseinställningar + Don't capitalize after: Ingen versal efter: + Editing in this document is restricted. Redigeringen av det här dokumentet är begränsad. + Every change is recorded as a tracked change. Varje ändring registreras som en spårad ändring. + Everyone Alla + Exceptions (optional) Undantag (valfritt) + Exceptions… Undantag… + Fields Fält + Filling in forms Ifyllning av formulär + Find Next Editable Region Sök nästa redigerbara område + Footnotes and endnotes Fotnoter och slutnoter + Formatting, case changes, white space and tables are compared; the other settings are kept for later. Formatering, ändrat skiftläge, blanktecken och tabeller jämförs; de andra inställningarna sparas till senare. + Fractions (1/2) as fraction characters (½) Bråk (1/2) som bråktecken (½) + Headers and footers Sidhuvuden och sidfötter + Hyphens (--) as dashes (—) Bindestreck (--) som tankstreck (—) + In equations, typing a name after a backslash replaces it with its symbol. I ekvationer ersätts ett namn som du skriver efter ett omvänt snedstreck med sin symbol. + Internet addresses as links Internetadresser som länkar + Label changes with: Märk ändringar med: + Limit formatting to a selection of styles Begränsa formateringen till ett urval av stilar + Math AutoCorrect Autokorrigering för matematik + More >> Mer >> + Moves Flyttningar + New document Nytt dokument + No changes (read only) Inga ändringar (skrivskyddat) + Only allow this kind of editing: Tillåt bara den här typen av redigering: + Only comments can be added, except in regions marked editable. Bara kommentarer kan läggas till, utom i områden som är märkta som redigerbara. + Only form fields can be filled in. Bara formulärfält kan fyllas i. + Only the allowed styles can be applied. Bara de tillåtna stilarna kan användas. + Ordinals (1st) as superscript Ordningstal (1st) som upphöjd text + Original document Originaldokument + This password type isn't supported. Den här typen av lösenord stöds inte. + Replace as you type Ersätt medan du skriver + Replace text as you type Ersätt text medan du skriver + Replace: Ersätt: + Revised document Reviderat dokument + Select text, then tick who can still edit it. Markera text och kryssa sedan för vem som fortfarande får redigera den. + Settings… Inställningar… + Show changes at: Visa ändringar på: + Show changes in: Visa ändringar i: + Smart quotes instead of straight quotes Typografiska citattecken i stället för raka + Start Enforcing Protection Börja tillämpa skyddet + Stop Protection Avsluta skyddet + Text boxes Textrutor + The document is read only, except for regions marked editable. Dokumentet är skrivskyddat, utom i områden som är märkta som redigerbara. + The password is optional; without it anyone can stop the protection. Lösenordet är valfritt; utan det kan vem som helst avsluta skyddet. + This document Det här dokumentet + Tracked changes Spårade ändringar + Type the password to stop the protection. Skriv lösenordet för att avsluta skyddet. + White space Blanktecken + With: Med: + Word level Ordnivå + In Line with Text I nivå med texten + Square Fyrkant + Through Genom + Top and Bottom Över och under + Behind Text Bakom texten + In Front of Text Framför texten + Wrap Text Sides Sidor för textombrytning + Both Sides Båda sidor + Left Only Bara vänster + Right Only Bara höger + Largest Only Bara största + Customize Keyboard Anpassa tangentbord + Choose a command Välj ett kommando + Choose a shortcut Välj ett kortkommando + All Commands Alla kommandon + Commands: Kommandon: + Keys for this command: Tangenter för det här kommandot: + New shortcut: Nytt kortkommando: + Now used by {command} Används nu av {command} + Not used by any command Används inte av något kommando + Remove every custom shortcut and restore the built-in keys? Vill du ta bort alla egna kortkommandon och återställa de inbyggda? + Assign Tilldela + Reset All… Återställ alla… + Press a key combination Tryck på en tangentkombination + Customize Anpassa + Keyboard shortcuts: Kortkommandon: + Customize… Anpassa… + Customize Ribbon Anpassa verktygspanelen + Add to Quick Access Toolbar Lägg till i snabbverktygsfältet + Remove from Quick Access Toolbar Ta bort från snabbverktygsfältet + Customize the Ribbon… Anpassa verktygspanelen… + Customize Quick Access Toolbar… Anpassa snabbverktygsfältet… + More Commands… Fler kommandon… + Ribbon Verktygspanel + (Custom) (Anpassad) + Remove every custom tab and group and show all tabs again? Vill du ta bort alla egna flikar och grupper och visa alla flikar igen? + Put the default commands back on the Quick Access Toolbar? Vill du lägga tillbaka standardkommandona i snabbverktygsfältet? + Reset… Återställ… + Add >> Lägg till >> + << Remove << Ta bort + Pick a custom group (New Group makes one) Välj en egen grupp (Ny grupp skapar en) + Ribbon tabs Verktygspanelens flikar + New Tab Ny flik + New Group Ny grupp + Rename… Byt namn… + Ribbon and Quick Access Toolbar: Verktygspanel och snabbverktygsfält: + Add Font… Lägg till typsnitt… + Added Fonts Tillagda typsnitt + Remove Added Font Ta bort tillagt typsnitt + Recent Colors Senaste färger + More Colors… Fler färger… + Standard Standard + Current Aktuell + Colors: Färger: + Color model: Färgmodell: + Red: Röd: + Green: Grön: + Blue: Blå: + Hue: Nyans: + Sat: Mättn.: + Lum: Ljush.: + Hex: Hex: + Accessibility Pane Tillgänglighetspanel + Alt Text Pane Alternativtextpanel + Alt\nText Alternativ-\ntext + View Alt Text… Visa alternativtext… + Mark as decorative Märk som dekorativ + Title: Titel: + Errors ({n}) Fel ({n}) + Warnings ({n}) Varningar ({n}) + Tips ({n}) Tips ({n}) + No accessibility issues found. Inga tillgänglighetsproblem hittades. + Describe what this object shows, for people who can't see it. Beskriv vad objektet visar, för den som inte kan se det. + Decorative objects only add visual interest: screen readers skip them. Dekorativa objekt är bara utsmyckning: skärmläsare hoppar över dem. + Describe what this table shows, for people who can't see it. Beskriv vad tabellen visar, för den som inte kan se den. + Select a picture, shape, chart or table to describe it. Markera en bild, figur, ett diagram eller en tabell som du vill beskriva. + Document Grid Dokumentrutnät + No grid Inget rutnät + Lines only Bara rader + Lines and characters Rader och tecken + Text snaps to characters Texten fästs mot tecken + Characters per line: Tecken per rad: + Lines per page: Rader per sida: + Moved from Flyttat från + Moved to Flyttat till + Developer Utvecklare + Controls Kontroller + Rich Text Content Control Innehållskontroll för formaterad text + Plain Text Content Control Innehållskontroll för oformaterad text + Picture Content Control Innehållskontroll för bild + Building Block Gallery Content Control Innehållskontroll för byggblocksgalleri + Check Box Content Control Innehållskontroll för kryssruta + Combo Box Content Control Innehållskontroll för kombinationsruta + Drop-Down List Content Control Innehållskontroll för listruta + Date Picker Content Control Innehållskontroll för datumväljare + Repeating Section Content Control Innehållskontroll för upprepat avsnitt + Design Mode Designläge + Content Control at Selection Innehållskontroll vid markeringen + Toggle Check Box Växla kryssruta + Choose List Item Välj listobjekt + Set Date Ange datum + Remove Content Control Ta bort innehållskontroll + Content Control Properties Egenskaper för innehållskontroll + Title Titel + Tag Tagg + Placeholder text Platshållartext + Can't be deleted Kan inte tas bort + Contents can't be edited Innehållet kan inte redigeras + Remove the control once edited Ta bort kontrollen när den har redigerats + Allow line breaks Tillåt radbrytningar + Date format Datumformat + Checked symbol Symbol för ikryssad + Unchecked symbol Symbol för ej ikryssad + List entries Listposter + Display text Visningstext + Value Värde + Today I dag + Put the caret in a content control first. Placera först markören i en innehållskontroll. + Right-to-Left Table Tabell från höger till vänster + Right-to-Left Section Avsnitt från höger till vänster + Table direction: Tabellriktning: + Section direction: Avsnittsriktning: + Drawing Canvas Rityta + New Drawing Canvas Ny rityta + Fit Anpassa + Expand Expandera + Scale Drawing Skala ritningen + Canvas Member Del av ritytan + Arrows Pilar + Communication Kommunikation + Office Kontor + People Människor + Nature Natur + Weather Väder + Technology Teknik + Transport Transport + Icon Color Ikonfärg + Search icons Sök ikoner + No icons match your search. Inga ikoner matchar sökningen. + Recolor a one-color picture, such as an icon Färga om en enfärgad bild, till exempel en ikon + Enclose Characters Omslut tecken + Shrink text Krymp texten + Enlarge symbol Förstora symbolen + Enclosure Omslutning + Circle Cirkel + Numerals: Siffror: + Numerals Siffror + Arabic Arabiska + Hindi Hindi + Context Efter sammanhang + System Enligt systemet + Templates Mallar + Templates and Add-ins Mallar och tillägg + Attach Template Koppla mall + Detach Template Koppla bort mall + Automatically Update Document Styles Uppdatera dokumentstilar automatiskt + Update Styles from Template Uppdatera stilar från mall + Organizer Organisera + Copy Styles Kopiera stilar + Rename Style Byt namn på stil + Document template: Dokumentmall: + Attach… Koppla… + Automatically update document styles Uppdatera dokumentstilar automatiskt + Organizer… Organisera… + Change Chart Type Ändra diagramtyp + Chart Design Diagramdesign + Type Typ + Edit Data Redigera data + Chart Title Diagramrubrik + Chart Layouts Diagramlayouter + Add Chart Element Lägg till diagramelement + Legend Förklaring + Data Labels Dataetiketter + Above Chart Ovanför diagrammet + Insert Chart Infoga diagram + Clustered Column Grupperad stapel + Stacked Column Staplad stapel + Clustered Bar Grupperad liggande stapel + Stacked Bar Staplad liggande stapel + Area Ytdiagram + Pie Cirkeldiagram + Doughnut Ringdiagram + Scatter Punktdiagram + “{text}” ({row}, {series}) isn't a number. ”{text}” ({row}, {series}) är inget tal. + Category {n} Kategori {n} + Series {n} Serie {n} + Type the categories down the side and a column of values per series. Skriv kategorierna nedåt längs sidan och en kolumn med värden per serie. + Categories Kategorier + Series name Serienamn + Remove series Ta bort serie + Remove row Ta bort rad + Add Row Lägg till rad + Add Series Lägg till serie + Edit\nData Redigera\ndata + Change\nChart Type Ändra\ndiagramtyp + Align Text Justera text + Create Link Skapa länk + Break Link Bryt länk + Rotate all text 90° Rotera all text 90° + Rotate all text 270° Rotera all text 270° + Stacked Staplad + Middle I mitten + Click the empty text box the text should continue in (Esc cancels) Klicka på den tomma textrutan där texten ska fortsätta (Esc avbryter) + Not linked: click a text box Inte länkad: klicka på en textruta + Show KeyTips (Alt or F10) Visa tangenttips (Alt eller F10) + Location Placering + Endnotes Slutnoter + Below text Under texten + End of section Slutet av avsnittet + End of document Slutet av dokumentet + Footnotes to endnotes Fotnoter till slutnoter + Endnotes to footnotes Slutnoter till fotnoter + Swap footnotes and endnotes Byt plats på fotnoter och slutnoter + Custom mark: Eget tecken: + Continuous numbering Löpande numrering + Apply Använd + Convert Notes Konvertera fot- och slutnoter + Reading Läsning + Reading Column Width Kolumnbredd vid läsning + Reading Line Spacing Radavstånd vid läsning + Reading Text Size Textstorlek vid läsning + English (United Kingdom) Engelska (Storbritannien) + Polish Polska + Do Not Check Spelling or Grammar Kontrollera inte stavning eller grammatik + +# Built-in file browser when the system file dialog is unavailable (#576) + Documents Dokument + Word document Word-dokument + All files Alla filer + Recipient lists Mottagarlistor + 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. Systemets fildialogruta är inte tillgänglig, så WordCraft visar sin egen. Installera en filväljarportal (till exempel xdg-desktop-portal-gtk) eller zenity för att få systemets dialogruta. + Up one level Upp en nivå + Home folder Hemmapp + {name} wasn't found. {name} hittades inte. + {name} already exists. Choose Save again to replace it. {name} finns redan. Välj Spara igen för att ersätta den. + This folder can't be read: {error} Den här mappen kan inte läsas: {error} + Showing the first {count} items. Visar de första {count} objekten. + File name: Filnamn: + Browse in WordCraft… Bläddra i WordCraft… + Open a file with WordCraft's own browser when the system file dialog doesn't appear. Öppna en fil med WordCrafts egen filbläddrare när systemets fildialogruta inte visas. + Hide White Space Dölj tomrum + All pages Alla sidor + Current page Aktuell sida + Selected pages Valda sidor + Custom range Anpassat intervall + All pages in the range Alla sidor i intervallet + Odd pages only Bara udda sidor + Even pages only Bara jämna sidor + Copies Kopior + Collate copies Sortera kopior + Pages like 1-3, 5 Sidor som 1-3, 5 + {count} of {total} pages will print Sidor som skrivs ut: {count} av {total} + Save these pages as PDF… Spara de här sidorna som PDF… + The printer, two-sided printing and pages per sheet are chosen in that dialog. Skrivare, dubbelsidig utskrift och sidor per ark väljs i den dialogrutan. + Previous Page Föregående sida + Print to PDF Skriv ut till PDF + Add Shape Lägg till figur + Change Colors Ändra färger + Change Layout Ändra layout + Create Graphic Skapa grafik + Edit Items Redigera objekt + Layouts Layouter + Reset Graphic Återställ grafik + SmartArt Design SmartArt-design + SmartArt Styles SmartArt-stilar + Text Pane Textpanel + List Lista + Process Process + Cycle Cykel + Hierarchy Hierarki + Relationship Relation + Pyramid Pyramid + Basic List Enkel lista + Basic Process Enkel process + Basic Cycle Enkel cykel + Basic Pyramid Enkel pyramid + Basic Radial Enkel radiell + Basic Matrix Enkel matris + Basic Venn Enkelt venndiagram + Shows items of equal weight as blocks in rows. Visar likvärdiga objekt som block i rader. + Shows steps in order, left to right, with arrows between them. Visar steg i ordning, från vänster till höger, med pilar emellan. + Shows steps that repeat, around a circle. Visar steg som upprepas, runt en cirkel. + Shows a tree: each item below the item it belongs to. Visar ett träd: varje objekt under det objekt det hör till. + Shows parts that build on each other, largest at the bottom. Visar delar som bygger på varandra, den största längst ned. + Shows items around a central idea: the first item is the centre. Visar objekt runt en central idé: det första objektet är mitten. + Shows parts of a whole as quadrants. Visar delar av en helhet som kvadranter. + Shows overlapping ideas as overlapping circles. Visar överlappande idéer som överlappande cirklar. + Accent {n} Accent {n} + Colorful Färgglad + Insert SmartArt Infoga SmartArt + One line per item. Enter adds an item. En rad per objekt. Enter lägger till ett objekt. + Add\nShape Lägg till\nfigur + Change\nColors Ändra\nfärger + Reset\nGraphic Återställ\ngrafik + Promote to Heading 1 Höj till Rubrik 1 + Demote to Body Text Sänk till brödtext + Move Up Flytta upp + Move Down Flytta ned + Collapse Komprimera + Expand or Collapse Heading Expandera eller komprimera rubrik + Select Heading and Content Markera rubrik och innehåll + Show Level Visa nivå + Show Level 1 Visa nivå 1 + Show Level 2 Visa nivå 2 + Show Level 3 Visa nivå 3 + Show Level 4 Visa nivå 4 + Show Level 5 Visa nivå 5 + Show Level 6 Visa nivå 6 + Show Level 7 Visa nivå 7 + Show Level 8 Visa nivå 8 + Show Level 9 Visa nivå 9 + Show All Levels Visa alla nivåer + All Levels Alla nivåer + Show First Line Only Visa bara första raden + Show Text Formatting Visa textformatering + Close Outline View Stäng dispositionsvyn + Outline State Dispositionstillstånd + Outlining Disposition + Outline Tools Dispositionsverktyg + Close Outline\nView Stäng\ndispositionsvyn + A, B, C, … A, B, C, … + Above the item Ovanför objektet + Accessed on Hämtad den + Add New Source… Lägg till ny källa… + Add the chapter number Lägg till kapitelnumret + Atmosphere Atmosfär + Below the item Under objektet + Book Bok + Book title Boktitel + Caption Bildtext + Caption Numbering Numrering av bildtexter + Caption text Bildtext + Caption: Bildtext: + Chapter in a book Kapitel i en bok + Chapters begin at: Kapitel börjar vid: + Collect entries from: Hämta poster från: + Columns: Kolumner: + Conference Konferens + Conference paper Konferensbidrag + Container Title Överordnad titel + Create Source Skapa källa + Cross-reference: Korsreferens: + Custom Table of Contents… Anpassad innehållsförteckning… + DOI DOI + Day Dag + Delete Label Ta bort etikett + Edit Source Redigera källa + Edition Upplaga + Edit… Redigera… + Entry marked. Posten är märkt. + Example: {sample} Exempel: {sample} + Figure Figur + Film Film + Heading styles (levels 1–{n}) Rubrikstilar (nivå 1–{n}) + Heading {n} Rubrik {n} + I, II, III, … I, II, III, … + Include page numbers Ta med sidnummer + Indented Indragen + Institution Institution + Ionosphere Jonosfär + Issue Nummer + Journal Tidskrift + Label: Etikett: + Last, First; Last, First Efternamn, Förnamn; Efternamn, Förnamn + Leave the label out of the caption Utelämna etiketten i bildtexten + Letter headings Bokstavsrubriker + Levels: Nivåer: + Link entries to their headings Länka posterna till sina rubriker + Made from the author and year when empty Skapas från författare och år om fältet är tomt + Magazine or newspaper article Tidnings- eller tidskriftsartikel + Main entry: Huvudpost: + Mark Märk + Mark All Märk alla + Mark Entry… Märk post… + Mark Index Entry Märk registerpost + Mesosphere Mesosfär + Month Månad + New Label Ny etikett + New Label… Ny etikett… + New… Ny… + No sources yet. Inga källor än. + Numbering… Numrering… + Options… Alternativ… + Other Annat + Other styles and their TOC level: Andra stilar och deras nivå i innehållsförteckningen: + Outline levels Dispositionsnivåer + Page number style Sidnummerformat + Page numbers at the right margin Sidnummer vid högermarginalen + Page title Sidtitel + Pages of bookmark: Bokmärkets sidor: + Periodical Periodisk publikation + Position: Position: + Publisher Förlag + Run-in Löpande + See Se + Separator: Avgränsare: + Show every bibliography field Visa alla fält för källförteckningen + Site name Webbplatsens namn + Source Manager Källhanterare + Source type: Källtyp: + Sources in this document: Källor i det här dokumentet: + Styles: Stilar: + Subentry: Underpost: + TC entry fields TC-fält (innehållsposter) + Tab leader: Utfyllnad: + Table of Contents Options Alternativ för innehållsförteckning + Table of Contents Styles Stilar för innehållsförteckning + This page Den här sidan + Type: Typ: + URL URL + Volume Volym + Website Webbplats + Year År + a, b, c, … a, b, c, … + i, ii, iii, … i, ii, iii, … + {n} entries marked. Märkta poster: {n}. + Lasso Select Lassomarkering + Move Selected Ink Flytta markerat bläck + Delete Selected Ink Ta bort markerat bläck + Recolor Selected Ink Färga om markerat bläck + Add Pen Lägg till penna + Remove Pen Ta bort penna + Ink to Shape Bläck till figur + Convert to Shapes Konvertera till figurer + Ink Replay Spela upp bläck + Add\nPen Lägg till\npenna + Rewind Spola tillbaka + Forward Spola framåt + Stroke {n} of {total} Penndrag {n} av {total} + Use wildcards Använd jokertecken + Special Special + Match prefix Matcha prefix + Match suffix Matcha suffix + Ignore punctuation characters Ignorera skiljetecken + Ignore white-space characters Ignorera blanktecken + Regular expressions Reguljära uttryck + Search Options Sökalternativ + Tab Character Tabbtecken + Any Character Valfritt tecken + Any Digit Valfri siffra + Any Letter Valfri bokstav + Caret Character Cirkumflex + Section Character Paragraftecken + Paragraph Character Stycketecken + Graphic Grafik + Manual Line Break Manuell radbrytning + Manual Page Break Manuell sidbrytning + Section Break Avsnittsbrytning + White Space Blanktecken + Clipboard Contents Innehållet i Urklipp + Find What Text Söktexten + Find What Expression Sökuttrycket + Character in Range Tecken i intervall + Beginning of Word Början av ord + End of Word Slutet av ord + Expression Uttryck + Not Inte + Num Occurrences Antal förekomster + Previous 1 or More Föregående 1 eller fler + 0 or More Characters 0 eller fler tecken + Move with Text Flytta med texten + Fix Position on Page Fast position på sidan + +# WordArt and connectors (#416). + WordArt Styles WordArt-stilar + Text Fill Textfyllning + Text Outline Textkontur + Transform Omforma + No Fill Ingen fyllning + No Outline Ingen kontur + Reflection Spegling + No Reflection Ingen spegling + No Transform Ingen omformning + Connect Shapes Koppla ihop figurer + Straight Connector Rak koppling + Straight Arrow Connector Rak pilkoppling + Elbow Connector Vinklad koppling + Elbow Arrow Connector Vinklad pilkoppling + Curved Connector Böjd koppling + Curved Arrow Connector Böjd pilkoppling + Arch Up Båge uppåt + Arch Down Båge nedåt + Slant Up Lutning uppåt + Slant Down Lutning nedåt + Inflate Uppblåst + Deflate Ihopsjunken + Chevron Up Vinkel uppåt + Chevron Down Vinkel nedåt + Triangle Up Triangel uppåt + Triangle Down Triangel nedåt + Solid Accent Fylld accent + Light with Outline Ljus med kontur + Hollow Ihålig + Accent Gradient Accenttoning + Dark with Shadow Mörk med skugga + Soft Glow Mjuk glöd + Accent with Reflection Accent med spegling + Warm Gradient with Outline Varm toning med kontur + Add line numbering Lägg till radnumrering + Add to Document Lägg till i dokumentet + Address labels Adressetiketter + Auto Auto + Automatically hyphenate document Avstava dokumentet automatiskt + Chinese (Simplified) Kinesiska (förenklad) + Chinese (Traditional) Kinesiska (traditionell) + Column: Kolumn: + Count by: Intervall: + Czech Tjeckiska + Danish Danska + Delivery address: Leveransadress: + Detect language automatically Identifiera språk automatiskt + Do not check spelling or grammar Kontrollera inte stavning eller grammatik + Dutch Nederländska + Envelope size: Kuvertstorlek: + Estonian Estniska + Finnish Finska + French (France) Franska (Frankrike) + From text: Från texten: + Full page of the same label Hel sida med samma etikett + German (Germany) Tyska (Tyskland) + Greek Grekiska + Hebrew Hebreiska + Hyphenate at: Avstava vid: + Hyphenate words in CAPS Avstava ord med VERSALER + Hyphenation Options… Alternativ för avstavning… + Hyphenation zone: Avstavningszon: + Italian Italienska + Japanese Japanska + Korean Koreanska + Limit consecutive hyphens to: Begränsa antalet avstavningar i följd till: + Line Numbering Options… Alternativ för radnumrering… + Manual Hyphenation Manuell avstavning + Manual hyphenation is complete. Den manuella avstavningen är klar. + Manual… Manuell… + Mark selected text as: Ange språket för den markerade texten: + New Document Nytt dokument + No limit Ingen gräns + Norwegian (Bokmål) Norska (bokmål) + Numbering: Numrering: + Omit Utelämna + One label per recipient En etikett per mottagare + Portuguese (Brazil) Portugisiska (Brasilien) + Portuguese (Portugal) Portugisiska (Portugal) + Return address labels Returadressetiketter + Return address: Returadress: + Row: Rad: + Russian Ryska + Serbian (Cyrillic) Serbiska (kyrilliska) + Serbian (Latin) Serbiska (latinska) + Set As Default Ange som standard + Shipping labels Fraktetiketter + Single label En etikett + Spanish (Mexico) Spanska (Mexiko) + Spanish (Spain) Spanska (Spanien) + Swedish Svenska + Turkish Turkiska + Ukrainian Ukrainska + {language} is now the document's default language. {language} är nu dokumentets standardspråk. + Visual effects Visuella effekter + Animation effects: Animeringseffekter: + On På + Off Av + Smooth scrolling and zooming, and menus and buttons that fade in. Off shows every change at once. Mjuk rullning och zoomning, och menyer och knappar som tonas in. Av visar varje ändring direkt. + Your system has animation effects turned on. Animeringseffekter är påslagna i systemet. + Your system has animation effects turned off. Animeringseffekter är avstängda i systemet. + Your system doesn't report an animation setting, so animation effects stay on. Systemet anger ingen inställning för animering, så animeringseffekterna är fortsatt på. + Select Column Markera kolumn + Select Table Cells by Clicking Markera tabellceller genom att klicka + Insert Row or Column by Clicking Infoga rad eller kolumn genom att klicka + Move Table by Dragging Flytta tabellen genom att dra + AutoFit Column by Double-Clicking Autoanpassa kolumnen genom att dubbelklicka + +# Equation gallery (structures, symbol sets, built-in equations), built-in style names, watermark +# menu labels and other strings that only the Italian and Russian catalogs had. + Good morning God morgon + Good afternoon God eftermiddag + Good evening God kväll + Typing Skrivning + Bayes' rule Bayes sats + Binomial expansion of (x+a)ⁿ Binomialutveckling av (x+a)ⁿ + Circle area from its radius Cirkelns area från radien + Convert between built-up and linear form Konvertera mellan uppställd och linjär form + Derivative as a limit Derivata som gränsvärde + Euler's identity Eulers identitet + Exponential series Exponentialserie + Integral of e^(−x²) Integralen av e^(−x²) + Justification of a display equation Justering av en fristående ekvation + Normal probability density Normalfördelningens täthet + Number the equation; the number sits at the right margin (or type # then the number) Numrera ekvationen; numret står vid högermarginalen (eller skriv # och sedan numret) + Power series of (1+x)ⁿ Potensserie för (1+x)ⁿ + Roots of ax²+bx+c Rötter till ax²+bx+c + Show the equation in the line of text Visa ekvationen på textraden + Show the equation on a line of its own Visa ekvationen på en egen rad + Sides of a right triangle Sidor i en rätvinklig triangel + Sine and cosine series of f(x) Sinus- och cosinusserie för f(x) + Sum of cosines Summa av cosinus + Sum or difference of sines Summa eller differens av sinus + Type equations in LaTeX Skriv ekvationer i LaTeX + Type equations in the linear format (UnicodeMath) Skriv ekvationer i linjärt format (UnicodeMath) + Type normal (non-math) text in the equation Skriv vanlig text (inte matematik) i ekvationen + Level 1 Nivå 1 + Level 2 Nivå 2 + Level 3 Nivå 3 + Level 4 Nivå 4 + Level 5 Nivå 5 + Level 6 Nivå 6 + Level 7 Nivå 7 + Level 8 Nivå 8 + Level 9 Nivå 9 + 1×2 Empty Matrix Tom 1×2-matris + 1×3 Empty Matrix Tom 1×3-matris + 2×1 Empty Matrix Tom 2×1-matris + 2×2 Determinant 2×2-determinant + 2×2 Empty Matrix Tom 2×2-matris + 2×2 Identity Matrix 2×2-enhetsmatris + 2×2 Identity Matrix (sparse) 2×2-enhetsmatris (gles) + 2×2 Matrix in Brackets 2×2-matris inom hakparenteser + 2×2 Matrix in Double Bars 2×2-matris inom dubbla lodstreck + 2×2 Matrix in Parentheses 2×2-matris inom parenteser + 2×3 Empty Matrix Tom 2×3-matris + 3×1 Empty Matrix Tom 3×1-matris + 3×2 Empty Matrix Tom 3×2-matris + 3×3 Empty Matrix Tom 3×3-matris + 3×3 Identity Matrix 3×3-enhetsmatris + 3×3 Identity Matrix (sparse) 3×3-enhetsmatris (gles) + A xor B A xor B + ASAP SNARAST + AaBbCcDd AaBbÅåÖö + Absolute value cases Fall med absolutbelopp + Accents Accenter + Acute Akut accent + All - Linear Alla – linjär + All - Professional Alla – professionell + Angle Brackets Vinkelparenteser + Angle Brackets with Separator Vinkelparenteser med avgränsare + Angle Brackets with Two Separators Vinkelparenteser med två avgränsare + Annotation Reference Kommentarsreferens + Annotation Subject Kommentarsämne + Annotation Text Kommentarstext + Baseline Dots Punkter på baslinjen + Basic Math Grundläggande matematik + Binomial Coefficient Binomialkoefficient + Binomial Coefficient (angle) Binomialkoefficient (vinkel) + Binomial Coefficient with Angle Brackets Binomialkoefficient med vinkelparenteser + Body text in a short paragraph to show spacing. Brödtext i ett kort stycke som visar avståndet. + Boxed Formula Formel i ram + Boxed Formula (example) Formel i ram (exempel) + Boxed Formulas Formler i ram + Braces Klammerparenteser + Braces with Separator Klammerparenteser med avgränsare + Brackets Hakparenteser + Brackets with Separators Hakparenteser med avgränsare + Breve Breve + CONFIDENTIAL KONFIDENTIELLT + Cases (Three Conditions) Fall (tre villkor) + Cases (Two Conditions) Fall (två villkor) + Cases and Stacks Fall och staplar + Ceiling Takfunktion + Centered as Group Centrerad som grupp + Change in y over change in x Förändring i y genom förändring i x + Check Hake + Circled Dot Punkt i cirkel + Circled Plus Plus i cirkel + Circled Times Gånger i cirkel + Colon Equal Kolon lika med + Common Accent Objects Vanliga accentobjekt + Common Brackets Vanliga parenteser + Common Fractions Vanliga bråk + Common Functions Vanliga funktioner + Common Large Operators Vanliga stora operatorer + Common Operator Structures Vanliga operatorstrukturer + Common Operators Vanliga operatorer + Common Radicals Vanliga rötter + Common Subscripts and Superscripts Vanliga index och exponenter + Contour Integral Kurvintegral + Contour Integral with Limits Kurvintegral med gränser + Contour Integral with Stacked Limits Kurvintegral med staplade gränser + Contour Integrals Kurvintegraler + Coproduct Koprodukt + Coproduct with Limits Koprodukt med gränser + Coproduct with Lower Limit Koprodukt med undre gräns + Coproduct with Subscript-Superscript Limits Koprodukt med gränser som index och exponent + Cosecant Function Cosekantfunktion + Cosine 2x Cosinus 2x + Cosine Function Cosinusfunktion + Cotangent Function Cotangensfunktion + Cube Root Kubikrot + Current - Linear Aktuell – linjär + DO NOT COPY KOPIERA INTE + DRAFT UTKAST + Default Paragraph Font Standardtypsnitt för stycke + Delta Equal To Delta lika med + Delta over arrow Delta över pil + Diagonal Dots Diagonala punkter + Differential Differential + Differential theta Differential theta + Differential x Differential x + Differential y Differential y + Differentials Differentialer + Dot Punkt + Dots Punkter + Double Bar Dubbelstreck + Double Dot Dubbelpunkt + Double Integral Dubbelintegral + Double Integral with Limits Dubbelintegral med gränser + Double Integral with Stacked Limits Dubbelintegral med staplade gränser + Double Left Arrow with Text Above Dubbel vänsterpil med text ovanför + Double Left Arrow with Text Below Dubbel vänsterpil med text under + Double Left Right Arrow with Text Above Dubbel vänster-högerpil med text ovanför + Double Left Right Arrow with Text Below Dubbel vänster-högerpil med text under + Double Right Arrow with Text Above Dubbel högerpil med text ovanför + Double Right Arrow with Text Below Dubbel högerpil med text under + Double Square Brackets Dubbla hakparenteser + Double Vertical Bars Dubbla lodstreck + Double summation Dubbelsumma + Empty Matrices Tomma matriser + Envelope Address Kuvertadress + Envelope Return Returadress på kuvert + Equal Equal Dubbelt likhetstecken + Equal by Definition Lika med per definition + Exponential Exponentiell + Floor Golvfunktion + Functions Funktioner + Geometry Geometri + Grave Grav accent + Greek Letters Grekiska bokstäver + Grid Table 1 Light Rutnätstabell 1 ljus + Grouping Character Above Grupperingstecken ovanför + Grouping Character Below Grupperingstecken under + Hat Hatt + Hyperbolic Cosecant Function Hyperbolisk cosekantfunktion + Hyperbolic Cosine Function Hyperbolisk cosinusfunktion + Hyperbolic Cotangent Function Hyperbolisk cotangensfunktion + Hyperbolic Functions Hyperboliska funktioner + Hyperbolic Secant Function Hyperbolisk sekantfunktion + Hyperbolic Sine Function Hyperbolisk sinusfunktion + Hyperbolic Tangent Function Hyperbolisk tangensfunktion + Hypotenuse Hypotenusa + Identity Matrices Enhetsmatriser + Index Heading Registerrubrik + Integral with Limits Integral med gränser + Integral with Stacked Limits Integral med staplade gränser + Integrals Integraler + Intersection Snitt + Intersection with Limits Snitt med gränser + Intersection with Lower Limit Snitt med undre gräns + Intersection with Subscript-Superscript Limits Snitt med gränser som index och exponent + Inverse Cosecant Function Invers cosekantfunktion + Inverse Cosine Function Invers cosinusfunktion + Inverse Cotangent Function Invers cotangensfunktion + Inverse Functions Inversa funktioner + Inverse Hyperbolic Cosecant Function Invers hyperbolisk cosekantfunktion + Inverse Hyperbolic Cosine Function Invers hyperbolisk cosinusfunktion + Inverse Hyperbolic Cotangent Function Invers hyperbolisk cotangensfunktion + Inverse Hyperbolic Functions Inversa hyperboliska funktioner + Inverse Hyperbolic Secant Function Invers hyperbolisk sekantfunktion + Inverse Hyperbolic Sine Function Invers hyperbolisk sinusfunktion + Inverse Hyperbolic Tangent Function Invers hyperbolisk tangensfunktion + Inverse Secant Function Invers sekantfunktion + Inverse Sine Function Invers sinusfunktion + Inverse Tangent Function Invers tangensfunktion + Left Arrow with Text Above Vänsterpil med text ovanför + Left Arrow with Text Below Vänsterpil med text under + Left Bracket Twice Två vänsterhakparenteser + Left Right Arrow Above Vänster-högerpil ovanför + Left Right Arrow with Text Above Vänster-högerpil med text ovanför + Left Right Arrow with Text Below Vänster-högerpil med text under + Left Subscript-Superscript Vänster index och exponent + Leftwards Arrow Above Vänsterpil ovanför + Leftwards Harpoon Above Vänsterharpun ovanför + Letter-Like Symbols Bokstavsliknande symboler + Limit Gränsvärde + Limit Example Exempel på gränsvärde + Line Number Radnummer + Linear Fraction Linjärt bråk + Logarithm Logaritm + Logarithm with Base Logaritm med bas + Logical AND Logiskt OCH + Logical AND with Limits Logiskt OCH med gränser + Logical OR Logiskt ELLER + Logical OR with Limits Logiskt ELLER med gränser + Matrices with Brackets Matriser med parenteser + Maximum Maximum + Maximum Example Exempel på maximum + Measured By Mäts med + Midline Dots Punkter på mittlinjen + Minimum Minimum + Minus Equal Minus lika med + Natural Logarithm Naturlig logaritm + Negated Relations Negerade relationer + Normal Normal + Normal Indent Normalt indrag + Normal Table Normal tabell + One half En halv + Operator Structures Operatorstrukturer + Operators Operatorer + Outward Brackets Utåtvända parenteser + Overbar Överstreck + Overbars and Underbars Över- och understreck + Overbrace Klammer ovanför + Parentheses Parenteser + Parentheses with Separator Parenteser med avgränsare + Partial Differential Partiell differential + Pi Over 2 Pi genom 2 + Plain Table 1 Enkel tabell 1 + Plus Equal Plus lika med + Product Produkt + Product of A Produkt av A + Product with Limits Produkt med gränser + Product with Lower Limit Produkt med undre gräns + Product with Subscript-Superscript Limits Produkt med gränser som index och exponent + Products and Coproducts Produkter och koprodukter + Quadratic Root Andragradsekvationens rot + Radical with Degree Rot med grad + Radicals Rötter + Right Arrow with Text Above Högerpil med text ovanför + Right Arrow with Text Below Högerpil med text under + Right Bracket Twice Två högerhakparenteser + Rightwards Arrow Above Högerpil ovanför + Rightwards Harpoon Above Högerharpun ovanför + SAMPLE EXEMPEL + Scripts Index och exponenter + Secant Function Sekantfunktion + Sine Function Sinusfunktion + Sine theta Sinus theta + Single Brackets Enkla parenteser + Single Left Angle Enkel vänstervinkelparentes + Single Left Bar Enkelt vänsterlodstreck + Single Left Brace Enkel vänsterklammer + Single Left Bracket Enkel vänsterhakparentes + Single Left Parenthesis Enkel vänsterparentes + Single Right Brace Enkel högerklammer + Single Right Bracket Enkel högerhakparentes + Single Right Parenthesis Enkel högerparentes + Skewed Fraction Snett bråk + Small change Liten förändring + Sparse Matrices Glesa matriser + Square Root Kvadratrot + Square Root with Degree Rot med grad + Square Union Kvadratisk union + Stack Object Staplat objekt + Stack Object (Three) Staplat objekt (tre) + Stacked Fraction Staplat bråk + Stacked, no bar Staplat, utan bråkstreck + Subscript-Superscript Index och exponent + Subscripts and Superscripts Index och exponenter + Summation Summa + Summation from 0 to n Summa från 0 till n + Summation over binomials Summa över binomialkoefficienter + Summation with Limits Summa med gränser + Summation with Lower Limit Summa med undre gräns + Summation with Subscript Limit Summa med gräns som index + Summation with Subscript-Superscript Limits Summa med gränser som index och exponent + Summations Summor + Surface Integral Ytintegral + Surface Integral with Limits Ytintegral med gränser + Surface Integral with Stacked Limits Ytintegral med staplade gränser + TOA Heading Rubrik för rättskällor + Table Grid Tabellrutnät + Table of Figures Figurförteckning + Tangent Function Tangensfunktion + Tangent formula Tangensformel + Text over Colon Equal Text över kolon lika med + Text over Equal Text över likhetstecken + Tilde Tilde + Trigonometric Functions Trigonometriska funktioner + Triple Dot Trippelpunkt + Triple Integral Trippelintegral + Triple Integral with Limits Trippelintegral med gränser + Triple Integral with Stacked Limits Trippelintegral med staplade gränser + URGENT BRÅDSKANDE + Underbar Understreck + Underbrace Klammer under + Union Union + Union of intersections Union av snitt + Union with Limits Union med gränser + Union with Lower Limit Union med undre gräns + Union with Plus Union med plus + Union with Subscript-Superscript Limits Union med gränser som index och exponent + Unions and Intersections Unioner och snitt + Up Diagonal Dots Uppåtgående diagonala punkter + Vector A Vektor A + Vector AB Vektor AB + Vertical Bars Lodstreck + Vertical Dots Lodräta punkter + Volume Integral Volymintegral + Volume Integral with Limits Volymintegral med gränser + Volume Integral with Stacked Limits Volymintegral med staplade gränser + Yields Ger + n×n Identity Matrix n×n-enhetsmatris + n×n Matrix n×n-matris + tOGGLE cASE vÄXLA sKIFTLÄGE + x bar x streck + ✓ None ✓ Ingen +preview Title Titel + Current - Professional Aktuell – professionell + Other Large Operators Andra stora operatorer + Region: Region: + Automatic ({region}) Automatisk ({region}) + Start-up Start + Show the Start screen when WordCraft starts Visa startskärmen när WordCraft startar + On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. På: WordCraft öppnas på Arkiv › Start, med ett tomt dokument, mallarna och dina senaste dokument. Av: ett tomt dokument öppnas. + +# Strings the Spanish catalog lacks: paper sizes, citation and table styles, list levels and +# proofing messages. + Wildcards (regex) Jokertecken (regex) + Large Stor + Limit and Gränsvärde och + Log log + APA APA + Change to Level 1 Ändra till nivå 1 + Change to Level 2 Ändra till nivå 2 + Change to Level 3 Ändra till nivå 3 + Change to Level 4 Ändra till nivå 4 + Change to Level 5 Ändra till nivå 5 + Chicago Chicago + GB/T 7714 GB/T 7714 + GOST (name sort) GOST (sortering på namn) + GOST (title sort) GOST (sortering på titel) + Grid Table 4 – Blue Rutnätstabell 4 – blå + Grid Table 4 – Green Rutnätstabell 4 – grön + Grid Table 4 – Orange Rutnätstabell 4 – orange + Grid Table 4 – Purple Rutnätstabell 4 – lila + Harvard (Anglia) Harvard (Anglia) + IEEE IEEE + ISO 690 (author-date) ISO 690 (författare–år) + ISO 690 (numerical) ISO 690 (numerisk) + List Table 3 – Blue Listtabell 3 – blå + List Table 3 – Green Listtabell 3 – grön + List Table 3 – Orange Listtabell 3 – orange + List Table 3 – Purple Listtabell 3 – lila + MLA MLA + SIST02 SIST02 + Turabian Turabian + Index {n} Register {n} + Change to Level 6 Ändra till nivå 6 + Change to Level 7 Ändra till nivå 7 + Change to Level 8 Ändra till nivå 8 + Change to Level 9 Ändra till nivå 9 + Capitalize the first word of a sentence Inled meningen med stor bokstav + Extra space Extra blanksteg + Possible spelling mistake Möjligt stavfel + Remove the space before the punctuation Ta bort blanksteget före skiljetecknet + The spelling and grammar check is complete. Stavnings- och grammatikkontrollen är klar. + Use "a" before a consonant sound Använd ”a” före ett konsonantljud + Use "an" before a vowel sound Använd ”an” före ett vokalljud + +# Strings no catalog had yet, found by the interface audit (untranslated_interface_text). + Column width Kolumnbredd + Edit Document Redigera dokument + Go to what: Gå till: + Reading Mode Läsläge + Researcher needs an online service; WordCraft keeps your documents offline. Efterforska kräver en onlinetjänst; WordCraft håller dina dokument offline. + Suggested Förslag + Text size Textstorlek + These settings will be used for new documents. De här inställningarna används för nya dokument. + Type +2 or -1 to move from the page you are on. Skriv +2 eller -1 för att flytta från sidan du är på. + can't split at the first row kan inte dela vid första raden + select two or more cells to merge markera två eller fler celler att sammanfoga + there is no ink to replay det finns inget bläck att spela upp + there's no citation after the caret det finns ingen källhänvisning efter markören + this item is already at the top level objektet är redan på översta nivån + +# Strings no catalog had yet, sent through translation outside the audit's reach: native print +# settings (printing.rs), Go To and command search. + Printer Skrivare + Select a printer Välj en skrivare + Refresh printers Uppdatera skrivarlistan + Two-sided printing Dubbelsidig utskrift + Printer default Skrivarens standard + One-sided Enkelsidig + Two-sided: long edge Dubbelsidig: vänd längs långsidan + Two-sided: short edge Dubbelsidig: vänd längs kortsidan + Grayscale Gråskala + Finding printers… Söker efter skrivare… + No printers found. Configure a system printer or export a PDF. Inga skrivare hittades. Lägg till en skrivare i systemet eller exportera en PDF. + Printer discovery failed. Det gick inte att söka efter skrivare. + Submitting print job… Skickar utskriften… + Print job submitted. Utskriften har skickats. + Print job submitted: {receipt} Utskriften har skickats: {receipt} + Print failed: the printing worker stopped. Utskriften misslyckades: utskriftsprocessen avbröts. + Wait for the print job to finish submitting before closing. Vänta tills utskriften har skickats innan du stänger. + Find “{text}” in the document Sök efter ”{text}” i dokumentet + Enter bookmark name: Ange bokmärkets namn: + This document has no bookmarks. Det här dokumentet har inga bokmärken. + Type a page number, or +2 / -1. Skriv ett sidnummer, eller +2 / -1. +# Added while landing (#1219): strings that landed after this catalog + In the margin I marginalen + No document is open. Create a document or open one first. Inget dokument är öppet. Skapa eller öppna ett dokument först. + Only for the selected text Endast för den markerade texten + Show alignment guides when moving pictures and shapes Visa justeringslinjer när bilder och former flyttas + Moved objects snap to the page's edges and centre, the margins and other objects. Hold Alt to move freely. Flyttade objekt fäster mot sidans kanter och mitt, marginalerna och andra objekt. Håll ned Alt för att flytta fritt. + Delete Bookmark Ta bort bokmärke + Hidden bookmarks Dolda bokmärken + Sort by: Sortera efter: + {language} (no dictionary) {language} (ingen ordlista) + No spelling dictionary for {language} is installed, so it isn't checked. WordCraft looks for the Hunspell dictionary {name} in the WORDCRAFT_DICTIONARIES folder and in the usual system locations. Ingen stavningsordlista för {language} är installerad, så språket kontrolleras inte. WordCraft letar efter Hunspell-ordlistan {name} i mappen WORDCRAFT_DICTIONARIES och på systemets vanliga platser. + Word 2003 XML Document (*.xml) Word 2003 XML-dokument (*.xml) + Type Character Code Skriv teckenkod + Auto ({percent}%) Automatisk ({percent} %) + Makes the ribbon, menus and dialogs larger or smaller. Auto follows your system's display scaling; 100% is one screen pixel per point. Document zoom is separate. Gör menyfliksområdet, menyerna och dialogrutorna större eller mindre. Automatisk följer systemets skalning av skärmen; 100 % är en skärmpixel per punkt. Dokumentets zoom ställs in separat. + Load System Installed Fonts… Läs in typsnitt installerade i systemet… + All system fonts are already available. Alla systemtypsnitt finns redan tillgängliga. + Local font access is not supported by your browser. Webbläsaren har inte stöd för åtkomst till lokala typsnitt. + Local font access was denied. Åtkomst till lokala typsnitt nekades. + Loaded {count} system fonts. {count} systemtypsnitt har lästs in. + Croatian Kroatiska + Text of the nearest paragraph with a given style (running headers) Text från närmaste stycke med en viss stil (löpande sidhuvuden) + Style name: Stilnamn: + Filter Recipients Filtrera mottagare + Sort Recipients Sortera mottagare + Filter and Sort Filtrera och sortera + And Och + Or Eller + Comparison Jämförelse + Compare to Jämför med + Add Condition Lägg till villkor + Remove condition Ta bort villkor + Sort by Sortera efter + Then by Sedan efter + Ascending Stigande + Descending Fallande + Equal to Lika med + Not equal to Inte lika med + Less than Mindre än + Greater than Större än + Less than or equal Mindre än eller lika med + Greater than or equal Större än eller lika med + Is blank Är tomt + Is not blank Är inte tomt + Contains Innehåller + Does not contain Innehåller inte + {count} of {total} recipients will be merged. {count} av {total} mottagare kopplas. + Take the last match on the page Använd den sista träffen på sidan + Show its list number Visa dess listnummer + Show its number relative to this field Visa dess nummer i förhållande till det här fältet + Show its number with all higher levels Visa dess nummer med alla högre nivåer + Number only, without the label's words Endast nummer, utan etikettens ord + Add whether it is above or below Lägg till om det är ovanför eller nedanför + Copy to Kopiera till + Rename Document Byt namn på dokument + Document name Dokumentnamn + Rename Byt namn + Account Konto + Privacy Options Sekretessalternativ + Trust Center Säkerhetscenter + Privacy for this document Sekretess för det här dokumentet + Remove my name from comments, changes and file properties when saving Ta bort mitt namn från kommentarer, ändringar och filegenskaper när dokumentet sparas + Leave out the date and time of comments and tracked changes when saving Utelämna datum och tid för kommentarer och spårade ändringar när dokumentet sparas + The document you are editing keeps the names and dates; only the saved file leaves them out. Dokumentet du redigerar behåller namnen och datumen; bara den sparade filen utelämnar dem. + Privacy Settings Sekretessinställningar + Waiting for the save to finish… Väntar på att sparandet ska slutföras… + Saving {name} was cancelled, so it is still open and nothing was lost. Sparandet av {name} avbröts, så dokumentet är fortfarande öppet och inget gick förlorat. + Paper Papper + Paper size: Pappersstorlek: + Custom size Anpassad storlek + More Paper Sizes… Fler pappersstorlekar… + The margins don't fit on a page this size; make the margins smaller first. Marginalerna får inte plats på en sida i den här storleken; minska marginalerna först. + Full Screen Helskärm + Full\nScreen Hel-\nskärm + Use the browser's full screen (F11) Använd webbläsarens helskärmsläge (F11) + {name} {height} × {width} in {name} {height} × {width} tum + {name} {height} × {width} mm {name} {height} × {width} mm + {width} × {height} mm {width} × {height} mm + {kind}, {columns} × {rows}, {size} ({paper}) {kind}, {columns} × {rows}, {size} ({paper}) + Envelope #10 Kuvert #10 + Envelope DL Kuvert DL + Set Proofing Language… Ange korrekturspråk… + Language Preferences… Språkinställningar… + Print settings Utskriftsinställningar + Submit print job Skicka utskrift + Dictionaries Ordlistor + Dictionaries folder: Mapp för ordlistor: + Not set: the system's usual places Inte angiven: systemets vanliga platser + Hunspell .aff and .dic files here (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) are used before the system's. Hunspell-filer (.aff och .dic) här (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) används före systemets. + {language}: {file} {language}: {file} + {language}: no dictionary found {language}: ingen ordlista hittades + Recover Återställ + Mirror margins Spegelvända marginaler + Existing File or Web Page Befintlig fil eller webbsida + Link to: Länka till: + Place in This Document Plats i det här dokumentet + Column {n} Kolumn {n} + Header row Rubrikrad + No header row Ingen rubrikrad + My list has Min lista har + Formula: Formel: + Paste function: Klistra in funktion: +# Cross-reference dialog (added while landing #1219) + For which item: För vilket objekt: + Heading Rubrik + Heading text Rubriktext + Insert reference to: Infoga referens till: + Reference type: Referenstyp: diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index e901ab017..90cf9ed9a 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -832,7 +832,7 @@ fn hungarian_locales_and_saved_preference_work_without_changing_the_document() { 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!(first_supported(["fi-FI", "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(); @@ -989,9 +989,9 @@ fn polish_covers_every_key_of_every_other_catalog() { let polish = keys(lang("pl").0.source); // Italian and Hungarian still carry "Style: APA", which the interface no longer shows; // Hungarian and Bulgarian also name the citation styles, and Bulgarian has a few one-line - // pieces of gallery labels. Polish matches every other catalog, including the style names and - // equation gallery Russian translates. - for other in Lang::all().filter(|l| *l != Lang::EN && !matches!(l.code(), "pl" | "it" | "hu" | "bg" | "vi")) { + // pieces of gallery labels, which Swedish carries too. Polish matches every other catalog, + // including the style names and equation gallery Russian translates. + for other in Lang::all().filter(|l| *l != Lang::EN && !matches!(l.code(), "pl" | "it" | "hu" | "bg" | "vi" | "sv")) { let mut missing: Vec = keys(other.0.source).difference(&polish).cloned().collect(); missing.sort(); assert!(missing.is_empty(), "pl lacks {}'s {missing:?}", other.code()); @@ -1277,6 +1277,84 @@ fn bundled_interface_fonts_cover_bulgarian_without_system_fallbacks() { assert!(!lang("bg").uses_cjk()); } +#[test] +fn swedish_locales_and_saved_preference_work_without_changing_the_document() { + let sv = lang("sv"); + for tag in ["sv", "sv-SE", "sv_SE.UTF-8", "SV-se", "sv-FI"] { + assert_eq!(lang_from_tag(tag), Some(sv), "{tag}"); + } + assert_eq!(first_supported([UNSUPPORTED, "sv-SE", "en-US"]), Some(sv)); + 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": "SV"})).unwrap(); + assert_eq!(result["effective"], "sv"); + assert_eq!(app.ui.language, "sv"); + assert!(result["available"].as_array().unwrap().iter().any(|l| l["code"] == "sv" && l["name"] == "Svenska")); + 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), sv); + assert_eq!(sv.name(), "Svenska"); +} + +#[test] +fn swedish_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("sv").0.source).is_superset(&keys(lang("es").0.source))); + assert!(parse_entries(lang("sv").0.source).1.is_empty()); + let sv = lang("sv"); + for (source, expected) in [ + ("File", "Arkiv"), + ("Home", "Start"), + ("Insert", "Infoga"), + ("Font", "Typsnitt"), + ("Paragraph", "Stycke"), + ("Review", "Granska"), + ("Save", "Spara"), + ("Heading 1", "Rubrik 1"), + ("Hyphenation", "Avstavning"), + ("Track Changes", "Spåra ändringar"), + ("WordCraft", "WordCraft"), + ("unknown future label", "unknown future label"), + ] { + assert_eq!(tr(sv, source), expected, "{source}"); + } + // The search hint's example must find the command it names. + assert_eq!(tr(sv, "Insert Table"), "Infoga tabell"); + assert!(tr(sv, "Type a command, e.g. \"insert table\"").contains(&tr(sv, "Insert Table").to_lowercase())); + assert_eq!(fmt(tr(sv, "Page {page} of {pages}"), &[("page", "3"), ("pages", "9")]), "Sida 3 av 9"); + // `ord` is the same in the singular and the plural, so the counts read right for every number. + for count in [0, 1, 2, 5, 21, 100, 1000] { + let words = count.to_string(); + assert_eq!(fmt(tr(sv, "{words} words"), &[("words", &words)]), format!("{count} ord")); + assert_eq!(fmt(tr(sv, "{selected} of {words} words"), &[("selected", "1"), ("words", &words)]), format!("1 av {count} ord")); + } +} + +#[test] +fn bundled_interface_fonts_cover_swedish_letters() { + let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new()); + let (entries, errors) = parse_entries(lang("sv").0.source); + assert!(errors.is_empty()); + let chars: std::collections::HashSet = entries + .iter() + .flat_map(|e| e.translation.chars()) + // Latin letters with diacritics plus the Swedish quote and dash; symbols come from the English sources. + .filter(|c| ('\u{00C0}'..='\u{00FF}').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("sv").uses_cjk()); +} + #[test] fn vietnamese_locales_and_saved_preference_work_without_changing_the_document() { let vi = lang("vi"); diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 9ddf7f8ab..b1263735f 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2650,3 +2650,9 @@ # Table Layout › Formula dialog (#1210) Formula: Công thức: Paste function: Dán hàm: +# Added while landing (#1219): strings that landed after this catalog + Enter bookmark name: Nhập tên dấu trang: + Find “{text}” in the document Tìm “{text}” trong tài liệu + This document has no bookmarks. Tài liệu này không có dấu trang. + Type a page number, or +2 / -1. Nhập số trang, hoặc +2 / -1. + Wait for the print job to finish submitting before closing. Hãy chờ lệnh in được gửi xong rồi mới đóng. diff --git a/crates/ui-egui/tests/native_print.rs b/crates/ui-egui/tests/native_print.rs index 68f9e50c6..9e098be1d 100644 --- a/crates/ui-egui/tests/native_print.rs +++ b/crates/ui-egui/tests/native_print.rs @@ -93,6 +93,8 @@ fn native_print_uses_selected_settings_and_reports_spool_errors() { fn print_settings_are_visible_in_a_narrow_window() { use egui_kittest::kittest::{NodeT, Queryable}; let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()); + // The labels below are English; an integration test otherwise follows the machine's language. + app.ui.language = "en".into(); app.run("text.insert", json!({"text": "A document to print"})).unwrap(); app.services.printers = Some(Box::new(|| { let (sender, receiver) = std::sync::mpsc::channel(); diff --git a/docs/localization-parity.md b/docs/localization-parity.md index e0b0a55b0..bdebdeaef 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -19,9 +19,9 @@ native-speaker review (human). ## What WordCraft ships (measured) -- **21 interface languages**: English plus `zh-hans`, `zh-hant`, `ja`, Korean `ko` (#1216), `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`, Bulgarian `bg` and Vietnamese `vi` (#1434). Catalogs have 2,061–2,463 entries - each; Polish has every key of every other catalog, including the built-in style names and the equation gallery, except the citation style names only Hungarian and Bulgarian translate so far (a test checks it). A test (`every_tab_and_command_is_translated`) enforces that every ribbon tab, command +- **22 interface languages**: English plus `zh-hans`, `zh-hant`, `ja`, Korean `ko` (#1216), `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`, Bulgarian `bg`, Vietnamese `vi` (#1434) and Swedish `sv` (#1219). Catalogs have 2,061–2,504 entries + each; Polish has every key of every other catalog, including the built-in style names and the equation gallery, except the citation style names only Hungarian, Bulgarian and Swedish 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 @@ -59,8 +59,12 @@ shared items below. | Korean | ko | 2,463 entries (all catalog keys and preview context) | most; newer panes English | Hangul ✅ (Korean-first embedded/system UI font fallback); macOS Korean IME fixes pending (#163, #164) | no | partial | 3–5 | | Vietnamese | vi | 2,448 entries; every key of the reference catalogs (#1434); 98% of extracted, ~95% of visible | most | Latin ✅ | no | partial | 0–1 (review) | -Other shipped languages: **14** — 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`), Bulgarian (`bg`) and Vietnamese (`vi`, #1434), all partial. +Other shipped languages: **15** — 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`), Bulgarian (`bg`), Vietnamese (`vi`, #1434) and Swedish (`sv`, #1219), all partial. +Swedish (`sv-SE` and `sv-FI` system locales, `{"value":"sv"}`) has 2,504 entries. Counts use +count-neutral wording (`{words} ord`: the noun is the same in the singular and the plural). The +catalog is an AI-assisted draft whose terminology and running app were reviewed by a native +speaker. Swedish proofing is not included; terminology: [`localization-sv.md`](localization-sv.md). 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. @@ -180,12 +184,13 @@ WordCraft keeps 2,54 cm). | 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 | 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 | +| Native-speaker review of every catalog | human | Swedish: terminology and the running app reviewed (2026-10-13); the others not yet | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Swedish interface catalog (2,504 entries), locale selection, saved preference, audit coverage and font checks (#1219) | | 2026-10-14 | trivial | Print area translated in all 18 interface catalogs (printer list, duplex, grayscale, discovery/submission feedback, print commands) — 18 strings that shipped English-only with the craft-print integration; written from the English meaning, pending native-speaker review like the rest of the catalogs | | 2026-10-14 | trivial | Italian: the interface audit's catalog check covers Italian too, as it does Russian, Hungarian, Turkish and Bulgarian; `Privacy Settings` no longer says "current" ("attuali"); a test pins the envelope size labels #1128 made translatable (Italian inches, the region's decimal comma) | | 2026-10-14 | minor | Italian grammar rules that work without a dictionary: `un pò`/`un po` → `un po'`, `qual'è` → `qual è`, `E'` → `È`, `un'` only before feminine words (`un'altro` → `un altro`, `un altra` → `un'altra`, also `nessun`/`ciascun`/`qualcun`/`alcun`); spelling accepts more elisions (`dov'è`, `anch'io`, `senz'altro`, `nessun'altra`) and truncations (`po'`, `mo'`) and leaves `pò`/`qual'è` to grammar so they aren't marked twice; more emphatic doubles allowed | diff --git a/docs/localization-sv.md b/docs/localization-sv.md new file mode 100644 index 000000000..bfe4222d2 --- /dev/null +++ b/docs/localization-sv.md @@ -0,0 +1,97 @@ +# Swedish interface + +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (Swedish interface catalog and regression checks) · **Target:** WordCraft 0.6.1 + +Choose **File › Options › Interface language › Svenska**, or follow a Swedish system display +language (`sv-SE`, `sv_SE.UTF-8`, and `sv-FI` for Swedish in Finland). The catalog code is `sv`; +`ui.language` with `{"value":"sv"}` selects it explicitly, and `WORDCRAFT_LANGUAGE=sv-SE` 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/sv.tsv` has 2,504 entries: every key of the Spanish reference catalog and +of the Bulgarian one (built-in style names, equation gallery labels, paper sizes, watermarks and +citation styles included), plus 44 newer strings no other catalog has yet: 23 that the interface +audit finds and 21 that the code sends through translation outside the audit's reach (native print +settings, Go To, command search). It is written from the meaning of the English labels; no other +product's Swedish interface, string table or terminology database was consulted, and no other +catalog was used as a source. The catalog format has no plural forms, and Swedish counts don't need +them: `ord` (word) is the same in the singular and the plural (`1 ord`, `21 ord`). Placeholders, +`\n` breaks in ribbon labels and dialog ellipses are kept, and strings added later fall back to +English until they are translated. + +The draft was made with Claude Code. A native speaker approved the terminology and every open +wording choice, and reviewed the running app. + +## Conventions + +- Commands are imperatives (`Spara`, `Infoga tabell`); messages address the user as `du`. When two + words are equally right, the softer everyday one wins (`Använd`, not `Verkställ`). +- Ribbon tabs keep the English word class: `Infoga`, `Rita`, `Granska` and `Visa` are verbs because + the same keys are also buttons and groups. Other tabs and groups are nouns. +- Only the first word of a label is capitalised (`Acceptera alla ändringar`). +- `Markera` means select and `Välj` means choose from a list. Mark, markup and highlight use other + words (`Märk post`, `märkning`, `Framhäv`, `Överstrykning`), so the two never mix. +- Ribbon buttons with a `\n` break are separate keys from the command names and may be shorter + where the group gives the context: the button `Framhäv\nfält`, the command `Framhäv kopplingsfält`. +- Decimal comma in fixed labels (`Radavstånd 1,5`, `8,5" × 11"`), Swedish quotation marks (”…”), + and a spaced en dash where English has an em dash (`Redigering – redigera dokumentet direkt`). +- Names stay as they are: WordCraft, Zotero, SmartArt, WordArt, the Craft theme, paper sizes + (Letter, Legal …) and citation styles (APA, MLA …). Loanwords Swedish speakers use stay too: + `community`, `commit`. + +## Terminology + +| Source | Swedish | +|---|---| +| File / Home / Insert / Draw / Design / Layout | Arkiv / Start / Infoga / Rita / Design / Layout | +| References / Mailings / Review / View / Help | Referenser / Utskick / Granska / Visa / Hjälp | +| Ribbon / Pane / Quick Access Toolbar | Verktygspanel / Panel / Snabbverktygsfält | +| Font / Font Size / Font Color | Typsnitt / Teckenstorlek / Teckenfärg | +| Paragraph / Style / Styles | Stycke / Stil / Stilar | +| Select / Selection / Choose | Markera / Markering / Välj | +| Highlight / Markup / Mark Entry | Överstrykning / Märkning / Märk post | +| Picture / Shape / Caption | Bild / Figur / Bildtext | +| Header / Footer / Heading | Sidhuvud / Sidfot / Rubrik | +| Footnote / Endnote / Notes | Fotnot / Slutnot / Fot- och slutnoter | +| Table of Contents / Index / Bibliography | Innehållsförteckning / Register / Källförteckning | +| Track Changes / Accept / Reject | Spåra ändringar / Acceptera / Avvisa | +| Mail Merge / Recipients / Merge Field | Dokumentkoppling / Mottagare / Kopplingsfält | +| Columns (text and table) | Kolumner | +| Options / Apply | Alternativ / Använd | +| Page {page} of {pages} / {words} words | Sida {page} av {pages} / {words} ord | + +## Known compromises + +The catalog has one translation per English key, so a key used in two senses gets the wording that +fits its most visible place: + +- `Outline` is both the View tab's Outline view and the Font menu's text outline effect: + `Disposition`, which is wrong for the text effect. +- `Display` is both an Options page and the Equation tab's display-equation button: `Visning`; the + button's tooltip explains it. +- `Open` is File › Open and a paragraph-spacing preset: `Öppna`. + +Giving these call sites a translation context (`tc`) would fix them. Some AutoCorrect options +describe English typing (`Capitalize names of days`, `Ordinals (1st) as superscript`, smart +quotes); they are translated as written and still act on English text. + +## Verification and remaining scope + +`cargo test -p wordcraft-ui-egui --lib i18n::tests` covers Swedish locale tags (`sv`, `sv-SE`, +`sv_SE.UTF-8`, `SV-se`, `sv-FI`) and preference order, saving and restoring `sv`, unchanged +document content, every key of the reference catalog, no catalog errors, the search hint's example +(`infoga tabell`), page and count wording, English fallback, and the glyphs `ÅÄÖåäöÉé` plus every +other accented letter the catalog uses in the bundled Inter (Regular, Medium, SemiBold) and +JetBrains Mono. The shared catalog checks (validation, every tab and command translated) include +Swedish automatically, and the on-demand interface audit (`untranslated_interface_text`) checks +Swedish alongside Russian, Hungarian, Turkish and Bulgarian. + +Spelling, grammar and hyphenation stay English-only: this is an interface translation, not +Swedish proofing. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-13 | minor | Swedish catalog (2,504 entries), conventions, terminology, known compromises and regression checks | From 1e0209346212ac4f4522efdd79a329cdefa2223d Mon Sep 17 00:00:00 2001 From: Andrej Skvorc <47588613+andrejskvorc@users.noreply.github.com> Date: Thu, 15 Oct 2026 06:09:07 +0200 Subject: [PATCH 080/101] i18n: add Croatian (hr) interface translation (#1271) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * i18n: add Croatian (hr) interface translation Croatian interface catalog (2,505 entries: every key of the reference catalog, built-in style names, equation gallery, print dialog), registered as `hr` ("Hrvatski"). `hr`, `hr-HR` and `hr_HR.UTF-8` system locales pick it automatically. - Count-neutral wording (`Riječi: 21`), since Croatian numerals take three noun forms. - New `template` catalog context: the "Letter" template can be called "Pismo" while the "Letter" paper size keeps its name. Other languages fall back to their plain entry, so nothing changes. - Locale, saved-preference, catalog-coverage and bundled-font tests; `hr` added to the interface-audit languages; Polish's coverage test lists `hr` with it/hu/bg. - Written from the meaning of the English labels (clean room, no Word localisation consulted), proofread by a second model and reviewed by a native speaker. - ATTRIBUTION row, contributors entry for the author, docs and roadmap. Croatian spelling and grammar proofing is separate (#1114). Co-Authored-By: Claude Sonnet 5.5 * ATTRIBUTION: name the models and the human review for the Croatian catalog Co-Authored-By: Claude Sonnet 5.5 * i18n(hr): native-speaker corrections to math terms and messages Co-Authored-By: Claude Sonnet 5.5 * i18n(hr): envelope names without their size Co-Authored-By: Claude Sonnet 5.5 * docs: Croatian entry count Co-Authored-By: Claude Sonnet 5.5 --------- Co-authored-by: Claude Sonnet 5.5 Co-authored-by: Brandon Thomas --- ATTRIBUTION.md | 1 + ROADMAP.md | 2 + contributors/people.toml | 5 + crates/ui-egui/src/backstage.rs | 6 +- crates/ui-egui/src/i18n/audit.rs | 4 +- crates/ui-egui/src/i18n/catalog.rs | 5 +- crates/ui-egui/src/i18n/hr.tsv | 2620 ++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/mod.rs | 4 +- crates/ui-egui/src/i18n/tests.rs | 89 +- crates/ui-egui/src/i18n/vi.tsv | 2 + docs/localization-parity.md | 10 +- 11 files changed, 2735 insertions(+), 13 deletions(-) create mode 100644 crates/ui-egui/src/i18n/hr.tsv diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 54afd312d..14b58afcd 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -77,3 +77,4 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra | `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 | | `crates/ui-egui/src/i18n/sv.tsv` | Erik Holmbom (@holmbom) 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 | Swedish interface catalog; AI-assisted, terminology and the running app reviewed by a native speaker | +| `crates/ui-egui/src/i18n/hr.tsv` | Andrej Škvorc (@andrejskvorc) with Claude Code (Sonnet 5.5); proofread with Gemini | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Croatian interface catalog; reviewed and corrected in the running interface, menu by menu, by the native-speaker author, who also checked the Gemini suggestions | diff --git a/ROADMAP.md b/ROADMAP.md index 9d95d21c0..68cf33c29 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -362,6 +362,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-11 | Word's common keyboard shortcuts (#330): 101 → 125 of ~250 | | 2026-10-13 | Swedish (`sv`) interface: 2,504 catalog entries (every key of the reference catalog, the equation gallery, everything the interface audit finds, and 44 newer strings no catalog had yet, among them the native print settings), `sv-SE`/`sv-FI` system locales and a saved language choice; locale, catalog and bundled-font tests; AI-assisted, terminology and the running app reviewed by a native speaker; no Swedish proofing resources | | 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-13 | Croatian (`hr`) interface: 2,507 catalog entries (every key of the reference catalog, built-in style names and the equation gallery), `hr-*` system locales and a saved language choice; locale, catalog and bundled-font tests; no Croatian proofing yet | | 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 | @@ -543,6 +544,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-13 | trivial | Swedish interface (`sv`): progress log and shipped languages | | 2026-10-12 | trivial | Bulgarian interface (`bg`): progress log and shipped languages | +| 2026-10-13 | trivial | Croatian interface (`hr`): 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-14 | trivial | Progress log: `.doc` notes, headers and text boxes read their text once (#1319) | diff --git a/contributors/people.toml b/contributors/people.toml index 74b120f63..0f28c5645 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -123,3 +123,8 @@ real_name = "Robert Lee" real_name = "Erik Holmbom" display_name = "Erik Holmbom" sync_github_name = false + +[people.andrejskvorc] +real_name = "Andrej Škvorc" +display_name = "Andrej Škvorc" +sync_github_name = false diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 16991759b..b89665ad3 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -258,7 +258,7 @@ fn template_tile(ui: &mut Ui, app: &mut WordApp, label: &str, template: &str) { ui.painter().image(h.id(), r.shrink(1.0), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), egui::Color32::WHITE); } // The caption opens the template too, as in Word. - let caption = ui.add(egui::Label::new(egui::RichText::new(tl!(label)).font(regular(12.5))).sense(Sense::click())); + let caption = ui.add(egui::Label::new(egui::RichText::new(crate::i18n::tc("template", label)).font(regular(12.5))).sense(Sense::click())); if resp.clicked() || caption.clicked() { let _ = app.run("file.new", json!({"template": template})); app.ui.backstage = false; @@ -280,7 +280,9 @@ fn matching_templates(query: &str) -> Vec<(&'static str, &'static str)> { let q = query.trim().to_lowercase(); TEMPLATES .into_iter() - .filter(|(label, id)| q.is_empty() || id.contains(&q) || label.to_lowercase().contains(&q) || tl!(label).to_lowercase().contains(&q)) + .filter(|(label, id)| { + q.is_empty() || id.contains(&q) || label.to_lowercase().contains(&q) || crate::i18n::tc("template", label).to_lowercase().contains(&q) + }) .collect() } diff --git a/crates/ui-egui/src/i18n/audit.rs b/crates/ui-egui/src/i18n/audit.rs index 88e7063a5..9c326d564 100644 --- a/crates/ui-egui/src/i18n/audit.rs +++ b/crates/ui-egui/src/i18n/audit.rs @@ -333,7 +333,7 @@ fn untranslated_interface_text() { complete_catalogs_have_every_string_the_interface_looked_up(pseudo::looked_up()); } -/// Russian, Hungarian, Turkish, Bulgarian, Italian and Swedish cover everything the audit saw +/// Russian, Hungarian, Turkish, Bulgarian, Italian, Swedish and Croatian 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) { @@ -379,7 +379,7 @@ fn complete_catalogs_have_every_string_the_interface_looked_up(mut keys: std::co // translate: its placeholder names aren't words the interface shows. let words = |s: &str| english_words(&s.split('{').map(|p| p.split_once('}').map_or(p, |(_, rest)| rest)).collect::()); let mut report = String::new(); - for code in ["ru", "hu", "tr", "bg", "it", "sv"] { + for code in ["ru", "hu", "tr", "bg", "it", "sv", "hr"] { let lang = super::Lang::from_code(code).unwrap_or(super::Lang::EN); let missing: Vec<&String> = keys.iter().filter(|s| words(s) && !super::has(lang, s) && !numbered(s) && !same(s) && !script_error(s)).collect(); diff --git a/crates/ui-egui/src/i18n/catalog.rs b/crates/ui-egui/src/i18n/catalog.rs index ac34d14a4..cde9f9d2c 100644 --- a/crates/ui-egui/src/i18n/catalog.rs +++ b/crates/ui-egui/src/i18n/catalog.rs @@ -20,7 +20,10 @@ pub struct Entry { /// The contexts a catalog entry may name, and where each applies. /// - `preview`: sample text inside a rendered preview (the Design tab's style-set tiles), which /// has to fit a small tile; it falls back to the plain entry. -pub const CONTEXTS: &[&str] = &["preview"]; +/// - `template`: the name of a built-in document template (Home and New), which may need other +/// wording than the same English word elsewhere (the "Letter" template and the "Letter" paper +/// size); it falls back to the plain entry. +pub const CONTEXTS: &[&str] = &["preview", "template"]; /// A parsed catalog. Values are owned for the life of the process (catalogs are built once). #[derive(Debug, Default)] diff --git a/crates/ui-egui/src/i18n/hr.tsv b/crates/ui-egui/src/i18n/hr.tsv new file mode 100644 index 000000000..eb07d4f80 --- /dev/null +++ b/crates/ui-egui/src/i18n/hr.tsv @@ -0,0 +1,2620 @@ +# Croatian (hr) 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. +# Counts use count-neutral wording (`Riječi: 21`), since Croatian numerals take three noun forms. + + % of all commits % svih commitova + (All) (Sve) + (Custom) (Prilagođeno) + (no style) (bez stila) + (no suggestions) (nema prijedloga) + (none) (ništa) + (normal text) (normalan tekst) + (not matched) (nema podudaranja) + (picture or object) (slika ili objekt) + 0 or More Characters 0 ili više znakova + 1. Formatting restrictions 1. Ograničenja oblikovanja + 1.5 Line Spacing Razmak redaka 1,5 + 1/4 Em Space Razmak od 1/4 em + 1×2 Empty Matrix Prazna matrica 1×2 + 1×3 Empty Matrix Prazna matrica 1×3 + 2. Editing restrictions 2. Ograničenja uređivanja + 2×1 Empty Matrix Prazna matrica 2×1 + 2×2 Determinant Determinanta 2×2 + 2×2 Empty Matrix Prazna matrica 2×2 + 2×2 Identity Matrix Jedinična matrica 2×2 + 2×2 Identity Matrix (sparse) Jedinična matrica 2×2 (rijetka) + 2×2 Matrix in Brackets Matrica 2×2 u uglatim zagradama + 2×2 Matrix in Double Bars Matrica 2×2 u dvostrukim okomitim crtama + 2×2 Matrix in Parentheses Matrica 2×2 u okruglim zagradama + 2×3 Empty Matrix Prazna matrica 2×3 + 3-D 3D + 3. Start enforcement 3. Pokretanje primjene + 3×1 Empty Matrix Prazna matrica 3×1 + 3×2 Empty Matrix Prazna matrica 3×2 + 3×3 Empty Matrix Prazna matrica 3×3 + 3×3 Identity Matrix Jedinična matrica 3×3 + 3×3 Identity Matrix (sparse) Jedinična matrica 3×3 (rijetka) + << Less << Manje + << Remove << Ukloni + A file dialog is already open. Dijaloški okvir za datoteke već je otvoren. + A free, open-source word processor written from scratch in Rust.\nPart of the Crafting Apps from the ArtCraft team. Besplatan program za obradu teksta otvorenog koda, napisan od nule u Rustu.\nDio paketa Crafting Apps tima ArtCraft. + A mail merge field Polje za spajanje pošte + A numbered sequence (figures, tables…) Numerirani niz (slike, tablice…) + A password is required to open this document. Za otvaranje ovog dokumenta potrebna je lozinka. + A table of contents Sadržaj + A xor B A xor B + A, B, C, … A, B, C, … + APA APA + ASAP ASAP + AaBbCcDd AaBbCcDd + AaBbYyZz AaBbYyZz + About O programu + About WordCraft O programu WordCraft + Above Chart Iznad grafikona + Above the item Iznad stavke + Absolute value cases Slučajevi apsolutne vrijednosti + Accent Naglasak + Accent Gradient Prijelaz naglaska + Accent with Reflection Naglasak s odrazom + Accent {n} Naglasak {n} + Accents Akcenti + Accept Prihvati + Accept All Changes Prihvati sve promjene + Accept This Change Prihvati ovu promjenu + Accessed on Pristupljeno + Accessibility Pristupačnost + Accessibility Pane Okno pristupačnosti + Acute Akut + Add Dodaj + Add >> Dodaj >> + Add Chart Element Dodaj element grafikona + Add Field Dodaj polje + Add Font… Dodaj font… + Add New Source… Dodaj novi izvor… + Add Object to Selection Dodaj objekt u odabir + Add Pen Dodaj olovku + Add Row Dodaj redak + Add Series Dodaj niz + Add Shape Dodaj oblik + Add Space Before Paragraph Dodaj razmak prije odlomka + Add Text Dodaj tekst + Add a Table Dodaj tablicu + Add a comment Dodaj komentar + Add at least one field. Dodajte barem jedno polje. + Add line numbering Dodaj numeriranje redaka + Add space between Asian and Latin text Dodaj razmak između azijskog i latiničnog teksta + Add space between Asian text and numbers Dodaj razmak između azijskog teksta i brojeva + Add tab stop at: Dodaj tabulator na: + Add the chapter number Dodaj broj poglavlja + Add to Dictionary Dodaj u rječnik + Add to Document Dodaj u dokument + Add to Quick Access Toolbar Dodaj na alatnu traku za brzi pristup + Add to Style Gallery Dodaj u galeriju stilova + Add/Edit\nBibliography Dodaj/uredi\nbibliografiju + Add/Edit\nCitation Dodaj/uredi\ncitat + Add\nNote Dodaj\nbilješku + Add\nPen Dodaj\nolovku + Add\nShape Dodaj\noblik + Added Fonts Dodani fontovi + Address Adresa + Address Block Blok adrese + Address labels Naljepnice s adresama + Address: Adresa: + Address\nBlock Blok\nadrese + Adjust Prilagodi + Advanced Napredno + Advanced Find Napredno traženje + Advanced Find… Napredno traženje… + After: Poslije: + Agents Agenti + Align Poravnaj + Align Bottom Center Poravnaj dolje na sredinu + Align Bottom Left Poravnaj dolje lijevo + Align Bottom Right Poravnaj dolje desno + Align Center Poravnaj po sredini + Align Center Left Poravnaj po sredini lijevo + Align Center Right Poravnaj po sredini desno + Align Left Poravnaj lijevo + Align Right Poravnaj desno + Align Text Poravnaj tekst + Align Top Center Poravnaj gore na sredinu + Align Top Left Poravnaj gore lijevo + Align Top Right Poravnaj gore desno + Aligned at: Poravnato na: + Alignment Poravnanje + Alignment: Poravnanje: + All Sve + All - Linear Sve – linearno + All - Professional Sve – profesionalno + All Borders Svi obrubi + All Caps Sva velika slova + All Commands Sve naredbe + All Levels Sve razine + All Markup Sve oznake + All caps Sva velika slova + All files Sve datoteke + All pages Sve stranice + All pages in the range Sve stranice u rasponu + All symbol sets Svi skupovi simbola + Allow Latin text to wrap in the middle of a word Dopusti prelamanje latiničnog teksta usred riječi + Allow hanging punctuation Dopusti viseću interpunkciju + Allow line breaks Dopusti prijelome redaka + Allow row to break across pages Dopusti da se redak podijeli na više stranica + Alphabetical Abecedno + Alt Text Alternativni tekst + Alt Text Pane Okno alternativnog teksta + Alt text Alternativni tekst + Alt: Alt: + Alt\nText Alternativni\ntekst + Angle Brackets Šiljaste zagrade + Angle Brackets with Separator Lomljene zagrade s razdjelnikom + Angle Brackets with Two Separators Lomljene zagrade s dva razdjelnika + Animate the caret (it glides and fades) Animiraj kursor (klizi i blijedi) + Animation Animacija + Animation effects: Animacijski efekti: + Annotation Reference Referenca komentara + Annotation Subject Predmet komentara + Annotation Text Tekst komentara + Another file Druga datoteka + Any Character Bilo koji znak + Any Digit Bilo koja znamenka + Any Letter Bilo koje slovo + Anyone can open this document. Encrypt it with a password to keep it private. Svatko može otvoriti ovaj dokument. Zaštitite dokument lozinkom. + Anyone who opens the document will need this password. Svatko tko otvori dokument trebat će ovu lozinku. + Apply Primijeni + Apply Asian line-breaking rules (kinsoku) Primijeni azijska pravila prijeloma redaka (kinsoku) + Apply Character Style Primijeni stil znakova + Apply Style Primijeni stil + Apply Styles Primijeni stilove + Apply as you type Primijeni tijekom tipkanja + Apply formatting to: Primijeni oblikovanje na: + Apply to: Primijeni na: + Arabic Arapski + Arch Down Luk prema dolje + Arch Up Luk prema gore + Area Površina + Arrange Rasporedi + Arrange\nAll Rasporedi\nsve + Arrow Strelica + Arrows Strelice + Artistic Effects Umjetnički efekti + As Recommended Kako se preporučuje + As you type Tijekom tipkanja + Asian Typography Azijska tipografija + Asian text font: Font azijskog teksta: + Assign Dodijeli + At least Najmanje + At least {n} Najmanje {n} + Atelier Atelijer + Atmosphere Atmosfera + Attach Template Priloži predložak + Attach… Priloži… + Author Autor + Auto Automatski + AutoCorrect Automatski ispravak + AutoCorrect Options Mogućnosti automatskog ispravka + AutoFit Automatsko prilagođavanje + AutoFit Column by Double-Clicking Automatski prilagodi stupac dvostrukim klikom + AutoFit Contents Prilagodi sadržaju + AutoFit Window Prilagodi prozoru + AutoFormat As You Type Automatsko oblikovanje tijekom tipkanja + AutoSave Automatsko spremanje + AutoSave documents you have saved in WordCraft Automatsko spremanje dokumenata koje ste spremili u programu WordCraft + AutoSave failed: {error}. Save the document to turn AutoSave back on. Automatsko spremanje nije uspjelo: {error}. Spremite dokument kako biste ponovno uključili automatsko spremanje. + AutoSave isn't available in the browser: it can't write to your files. Save downloads a copy. Automatsko spremanje nije dostupno u pregledniku: ne može pisati u vaše datoteke. Spremanje preuzima kopiju. + AutoSave only keeps .docx, .odt and .rtf files; {name} can't keep all formatting. Save as .docx to turn on AutoSave. Automatsko spremanje čuva samo datoteke .docx, .odt i .rtf; {name} ne može sačuvati cijelo oblikovanje. Spremite kao .docx kako biste uključili automatsko spremanje. + AutoSave saves every change to the file (needs a saved document) Automatsko spremanje sprema svaku promjenu u datoteku (potreban je spremljen dokument) + AutoSave writes your changes to the file a moment after you stop typing. Automatsko spremanje zapisuje vaše promjene u datoteku trenutak nakon što prestanete tipkati. + AutoText Automatski tekst + Automatic Automatski + Automatic ({language}) Automatski ({language}) + Automatic ({region}) Automatski ({region}) + Automatic Table 1 (3 levels) Automatski sadržaj 1 (3 razine) + Automatic Table 2 (2 levels) Automatski sadržaj 2 (2 razine) + Automatic bulleted lists Automatski popisi s oznakama + Automatic follows your system's language. Menus and commands change; your documents don't. Automatski prati jezik vašeg sustava. Mijenjaju se izbornici i naredbe, a vaši dokumenti ostaju isti. + Automatic numbered lists Automatski numerirani popisi + Automatically Update Document Styles Automatski ažuriraj stilove dokumenta + Automatically hyphenate document Automatski rastavljaj riječi u dokumentu + Automatically update document styles Automatski ažuriraj stilove dokumenta + Backspace Backspace + Balloons Oblačići + Banded Columns Prugasti stupci + Banded Rows Prugasti retci + Bar Traka + Base text: Osnovni tekst: + Based on: {style} Temelji se na: {style} + Baseline Dots Točke na osnovici + Basic Cycle Osnovni ciklus + Basic List Osnovni popis + Basic Math Osnovna matematika + Basic Matrix Osnovna matrica + Basic Process Osnovni proces + Basic Pyramid Osnovna piramida + Basic Radial Osnovni radijalni prikaz + Basic Venn Osnovni Vennov dijagram + Bayes' rule Bayesovo pravilo + Before: Prije: + Beginning of Word Početak riječi + Behind Text Iza teksta + Below text Ispod teksta + Below the item Ispod stavke + Between Između + Bibliography Bibliografija + Binary assets added Dodani binarni resursi + Binary assets removed Uklonjeni binarni resursi + Binomial Coefficient Binomni koeficijent + Binomial Coefficient (angle) Binomni koeficijent (lomljene zagrade) + Binomial Coefficient with Angle Brackets Binomni koeficijent s lomljenim zagradama + Binomial expansion of (x+a)ⁿ Binomni razvoj izraza (x+a)ⁿ + Blank Prazno + Blank (Three Columns) Prazno (tri stupca) + Blank Page Prazna stranica + Blank document Prazan dokument + Block\nAuthors Blok\nautora + Blue: Plava: + Body Text Tijelo teksta + Body text in a short paragraph to show spacing. Tekst u kratkom odlomku radi prikaza razmaka. + Bold Podebljano + Book Knjiga + Book Title Naslov knjige + Book title Naslov knjige + Bookmark Knjižna oznaka + Bookmark name: Naziv knjižne oznake: + Border Painter Kist za obrube + Border lines Linije obruba + Border\nPainter Kist za\nobrube + Borders Obrubi + Borders and Shading Obrubi i sjenčanje + Borders and Shading… Obrubi i sjenčanje… + Both Sides Obje strane + Bottom Dolje + Bottom Border Donji obrub + Bottom Margin Donja margina + Bottom of Page Dno stranice + Bottom: Dolje: + Box Okvir + Boxed Formula Formula u okviru + Boxed Formula (example) Formula u okviru (primjer) + Boxed Formulas Formule u okviru + Braces Vitičaste zagrade + Braces with Separator Vitičaste zagrade s razdjelnikom + Bracket Zagrada + Brackets Zagrade + Brackets with Separators Zagrade s razdjelnicima + Break Link Prekini vezu + Breaks Prijelomi + Breve Breve + Bring Forward Pomakni naprijed + Browse in WordCraft… Pregledaj u programu WordCraft… + Browse… Pregledaj… + Building Block Gallery Content Control Kontrola sadržaja galerije gradivnih blokova + Built-In Ugrađeno + Built-in style Ugrađeni stil + Built-in styles can't be deleted. Ugrađeni stilovi ne mogu se izbrisati. + Bullet Oznaka + Bullet Library Knjižnica oznaka + Bullets Oznake + By author Po autoru + CONFIDENTIAL POVJERLJIVO + Calendar Kalendar + Can't be deleted Ne može se izbrisati + Cancel Odustani + Canvas Member Član platna + Capitalize Each Word Svaka riječ velikim početnim slovom + Capitalize first letter of sentences Veliko početno slovo rečenica + Capitalize first letter of table cells Veliko početno slovo u ćelijama tablice + Capitalize names of days Nazivi dana velikim početnim slovom + Capitalize the first word of a sentence Prva riječ rečenice velikim slovom + Caption Natpis + Caption Numbering Numeriranje natpisa + Caption text Tekst natpisa + Caption: Natpis: + Captions Natpisi + Caret Character Znak ^ + Case changes Promjene veličine slova + Cases (Three Conditions) Slučajevi (tri uvjeta) + Cases (Two Conditions) Slučajevi (dva uvjeta) + Cases and Stacks Slučajevi i nizovi + Categories Kategorije + Categories: Kategorije: + Category Kategorija + Category {n} Kategorija {n} + Ceiling Gornje cijelo + Cell Ćelija + Cell Margins Margine ćelije + Cell Size Veličina ćelije + Cell\nMargins Margine\nćelije + Center Sredina + Centered Centrirano + Centered as Group Centrirano kao grupa + Centimeters Centimetri + Change Promijeni + Change Case Promijeni veličinu slova + Change Chart Type Promijeni vrstu grafikona + Change Colors Promijeni boje + Change Layout Promijeni raspored + Change List Level Promijeni razinu popisa + Change Password… Promijeni lozinku… + Change Picture Promijeni sliku + Change Picture… Promijeni sliku… + Change Shape Promijeni oblik + Change Structure Promijeni strukturu + Change in y over change in x Promjena y kroz promjenu x + Change to Display Promijeni u istaknuti oblik + Change to Inline Promijeni u oblik u retku + Change to Level 1 Promijeni na razinu 1 + Change to Level 2 Promijeni na razinu 2 + Change to Level 3 Promijeni na razinu 3 + Change to Level 4 Promijeni na razinu 4 + Change to Level 5 Promijeni na razinu 5 + Change to Level 6 Promijeni na razinu 6 + Change to Level 7 Promijeni na razinu 7 + Change to Level 8 Promijeni na razinu 8 + Change to Level 9 Promijeni na razinu 9 + Change\nChart Type Promijeni\nvrstu grafikona + Change\nColors Promijeni\nboje + Changed lines: Promijenjeni retci: + Changes Promjene + Chapter in a book Poglavlje u knjizi + Chapters begin at: Poglavlja počinju s: + Character Znak + Character Border Obrub znaka + Character Position Položaj znaka + Character Scale Mjerilo znaka + Character Shading Sjenčanje znaka + Character Spacing Razmak znakova + Character code: Kôd znaka: + Character in Range Znak iz raspona + Character level Razina znaka + Character spacing Razmak znakova + Character style Stil znaka + Characters Znakovi + Characters (no spaces) Znakovi (bez razmaka) + Characters (with spaces) Znakovi (s razmacima) + Characters per line: Znakova po retku: + Chart Grafikon + Chart Design Dizajn grafikona + Chart Layouts Rasporedi grafikona + Chart Title Naslov grafikona + Check Kvačica + Check Accessibility Provjeri pristupačnost + Check Box Content Control Kontrola sadržaja s potvrdnim okvirom + Check Compatibility Provjeri kompatibilnost + Check Spelling as You Type Provjeri pravopis tijekom tipkanja + Check for Errors Provjeri ima li pogrešaka + Check spelling as you type Provjeri pravopis tijekom tipkanja + Check\nAccessibility Provjeri\npristupačnost + Checked styles are allowed: Označeni stilovi su dopušteni: + Checked symbol Simbol označenog okvira + Chevron Down Ševron prema dolje + Chevron Up Ševron prema gore + Chicago Chicago + Chinese (Simplified) Kineski (pojednostavljeni) + Chinese (Traditional) Kineski (tradicionalni) + Choose List Item Odaberite stavku popisa + Choose a command Odaberite naredbu + Choose a shortcut Odaberite prečac + Choose the sheet that holds the recipients. Odaberite list koji sadrži primatelje. + Circle Krug + Circle area from its radius Površina kruga iz polumjera + Circled Dot Točka u krugu + Circled Plus Plus u krugu + Circled Times Množenje u krugu + Citation Style Stil citiranja + Citations Citati + Citations & Bibliography Citati i bibliografija + City Grad + Clear Očisti + Clear All Očisti sve + Clear All Formatting Očisti sve oblikovanje + Clear Character Formatting Očisti oblikovanje znakova + Clear Character Style Očisti stil znaka + Clear Formatting Level Očisti razinu oblikovanja + Clear Paragraph Formatting Očisti oblikovanje odlomka + Click a border between two cells to merge them. Esc stops. Kliknite obrub između dviju ćelija da biste ih spojili. Esc prekida. + Click an item to paste it. Kliknite stavku da biste je zalijepili. + Click level to modify: Kliknite razinu koju želite promijeniti: + Click the empty text box the text should continue in (Esc cancels) Kliknite prazan tekstni okvir u kojem se tekst treba nastaviti (Esc odustaje) + Click to switch back to inserting Kliknite za povratak na umetanje + Clipboard Međuspremnik + Clipboard Contents Sadržaj međuspremnika + Clipboard Items Stavke međuspremnika + Clipboard Pane Okno međuspremnika + Close Zatvori + Close Equation Zatvori jednadžbu + Close Header and Footer Zatvori zaglavlje i podnožje + Close Outline View Zatvori prikaz strukture + Close Outline\nView Zatvori prikaz\nstrukture + Clustered Bar Grupirana traka + Clustered Column Grupirani stupac + Col #: Br. stupca: + Collapse Sažmi + Collapse Selection Sažmi odabir + Collate copies Slaži kopije po redu + Collect entries from: Prikupi unose iz: + Colon Equal Dvotočka jednako + Color Boja + Color model: Model boja: + Color only Samo boja + Color: Boja: + Color: {color} Boja: {color} + Colorful Šareno + Colors Boje + Colors: Boje: + Column Stupac + Column Break Prijelom stupca + Column Selection Odabir stupca + Column width Širina stupca + Column: Stupac: + Columns Stupci + Columns: Stupci: + Combine Kombiniraj + Combine Documents Kombiniraj dokumente + Combo Box Content Control Kontrola sadržaja s kombiniranim okvirom + Commands Naredbe + Commands: Naredbe: + Comment Komentar + Comments Komentari + Comments Pane Okno komentara + 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 pokraj svake stranice i praćene promjene u PDF-ovima, ispisima i slikama; popis komentara na kraju web-stranica, Markdown dokumenata i teksta. + Commits Commitovi + Common Accent Objects Uobičajeni objekti s akcentima + Common Brackets Uobičajene zagrade + Common Fractions Uobičajeni razlomci + Common Functions Uobičajene funkcije + Common Large Operators Uobičajeni veliki operatori + Common Operator Structures Uobičajene strukture operatora + Common Operators Uobičajeni operatori + Common Radicals Uobičajeni korijeni + Common Subscripts and Superscripts Uobičajeni donji i gornji indeksi + Communication Komunikacija + Community\nDiscord Zajednica\nDiscord + Compact Kompaktno + Company Tvrtka + Compare Usporedi + Compare Documents Usporedi dokumente + Compared: {changes} changes Usporedba, promjene: {changes} + Comparing with another file needs the desktop app. Usporedba s drugom datotekom zahtijeva stolnu aplikaciju. + Comparison settings Postavke usporedbe + Compress Pictures Komprimiraj slike + Compress punctuation at the start of a line Sažmi interpunkciju na početku retka + Conference Konferencija + Conference paper Rad s konferencije + Confirm password: Potvrdite lozinku: + Connect Shapes Poveži oblike + Container Title Naslov spremnika + Content Control Properties Svojstva kontrole sadržaja + Content Control at Selection Kontrola sadržaja na odabiru + Contents can't be edited Sadržaj se ne može uređivati + Context Kontekst + Continuous Neprekinuto + Continuous numbering Neprekinuto numeriranje + Contour Integral Konturni integral + Contour Integral with Limits Konturni integral s granicama + Contour Integral with Stacked Limits Konturni integral s granicama jedna ispod druge + Contour Integrals Konturni integrali + Contributors Doprinositelji + Controls Kontrole + Conversions Pretvorbe + Convert Pretvori + Convert Notes Pretvori bilješke + Convert Text to Table Pretvori tekst u tablicu + Convert Text to Table… Pretvori tekst u tablicu… + Convert between built-up and linear form Pretvori između složenog i linearnog oblika + Convert to Shapes Pretvori u oblike + Convert to Text Pretvori u tekst + Copies Kopije + Coproduct Koprodukt + Coproduct with Limits Koprodukt s granicama + Coproduct with Lower Limit Koprodukt s donjom granicom + Coproduct with Subscript-Superscript Limits Koprodukt s granicama u donjem i gornjem indeksu + Copy Kopiraj + Copy Formatting Kopiraj oblikovanje + Copy Styles Kopiraj stilove + Copy of the text a bookmark marks Kopija teksta koji označava knjižna oznaka + Copyright Autorsko pravo + Copyright Sign Znak autorskog prava + Correct TWo INitial CApitals Ispravi DVa POčetna VElika slova + Correct accidental usage of cAPS LOCK key Ispravi slučajnu uporabu tipke cAPS LOCK + Corrections Ispravci + Cosecant Function Funkcija kosekans + Cosine 2x Kosinus 2x + Cosine Function Funkcija kosinus + Cotangent Function Funkcija kotangens + Couldn't download {name}: {error} Nije moguće preuzeti {name}: {error} + Couldn't make WordCraft the default: {error} Nije moguće postaviti WordCraft kao zadani program: {error} + Couldn't open {path}: {error} Nije moguće otvoriti {path}: {error} + Count by: Broji po: + Count words in text boxes and notes Broji riječi u tekstnim okvirima i bilješkama + Country Država + Courtesy Title Oslovljavanje + Cover Page Naslovnica + Craft Craft + Create Izradi + Create Graphic Izradi grafiku + Create Link Izradi poveznicu + Create New Style Izradi novi stil + Create Source Izradi izvor + Create a Style Izradi stil + 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. Izradite interaktivni pregled strukture dokumenta.\n\nTo je odličan način da pratite gdje se nalazite ili da brzo premještate sadržaj.\n\nZa početak idite na karticu Početna i primijenite stilove naslova na naslove u dokumentu. + Crop Obreži + Cross-reference Unakrsna referenca + Cross-reference: Unakrsna referenca: + Cube Root Treći korijen + Current Trenutačno + Current - Linear Trenutačno – linearno + Current - Professional Trenutačno – profesionalno + Current Position Trenutačni položaj + Current page Trenutačna stranica + Current style: {style} Trenutačni stil: {style} + Curved Arrow Connector Zakrivljeni strelasti konektor + Curved Connector Zakrivljeni konektor + Custom Prilagođeno + Custom Margins… Prilagođene margine… + Custom Table of Contents… Prilagođeni sadržaj… + Custom Watermark Prilagođeni vodeni žig + Custom Watermark… Prilagođeni vodeni žig… + Custom dictionary Prilagođeni rječnik + Custom mark: Prilagođena oznaka: + Custom range Prilagođeni raspon + Customize Prilagodi + Customize Keyboard Prilagodi tipkovnicu + Customize Quick Access Toolbar Prilagodi alatnu traku za brzi pristup + Customize Quick Access Toolbar… Prilagodi alatnu traku za brzi pristup… + Customize Ribbon Prilagodi vrpcu + Customize the Ribbon… Prilagodi vrpcu… + Customize… Prilagodi… + Cut Izreži + Cycle Ciklus + Czech Češki + DO NOT COPY NE KOPIRAJ + DOI DOI + DRAFT NACRT + Danish Danski + Dark Tamno + Dark Mode Tamni način rada + Dark page (white text on black) Tamna stranica (bijeli tekst na crnom) + Dark with Shadow Tamno sa sjenom + Darkroom Mračna komora + Dashed Crtkano + Data Podaci + Data Labels Oznake podataka + Date & Time Datum i vrijeme + Date Picker Content Control Kontrola sadržaja za odabir datuma + Date and Time Datum i vrijeme + Date format Format datuma + Date formats: Formati datuma: + Day Dan + Decimal Decimalno + Decorative objects only add visual interest: screen readers skip them. Ukrasni objekti samo daju vizualni dojam: čitači zaslona ih preskaču. + Decrease Font Size Smanji veličinu fonta + Decrease Indent Smanji uvlaku + Default Zadano + Default Paragraph Font Zadani font odlomka + Default tab stops: Zadani tabulatori: + Define New Bullet Definiraj novu oznaku + Define New Multilevel List Definiraj novi višerazinski popis + Define New Multilevel List… Definiraj novi višerazinski popis… + Define New Number Format Definiraj novi format brojeva + Deflate Smanji + Delete Izbriši + Delete Back in Equation Izbriši unatrag u jednadžbi + Delete Cells Izbriši ćelije + Delete Clipboard Item Izbriši stavku međuspremnika + Delete Columns Izbriši stupce + Delete Comment Izbriši komentar + Delete Label Izbriši oznaku + Delete Next Word Izbriši sljedeću riječ + Delete Previous Word Izbriši prethodnu riječ + Delete Rows Izbriši retke + Delete Selected Ink Izbriši odabrani rukopis + Delete Style Izbriši stil + Delete Table Izbriši tablicu + Delete Table Style Izbriši stil tablice + Delete in Equation Izbriši u jednadžbi + Deleted Izbrisano + Deletions: Brisanja: + Delivery address: Adresa za dostavu: + Delta Equal To Delta jednako + Delta over arrow Delta iznad strelice + Demote Spusti razinu + Demote to Body Text Spusti na tijelo teksta + Derivative as a limit Derivacija kao limes + Describe what this object shows, for people who can't see it. Opišite što ovaj objekt prikazuje, za osobe koje ga ne vide. + Describe what this table shows, for people who can't see it. Opišite što ova tablica prikazuje, za osobe koje je ne vide. + Description: Opis: + Design Dizajn + Design Mode Način dizajniranja + Detach Template Odvoji predložak + Detect language automatically Automatski prepoznaj jezik + Developer Razvojni programer + Diagonal Dijagonalno + Diagonal Dots Dijagonalne točke + Diamond Romb + Dictate Diktiraj + Different First Page Drukčija prva stranica + Different Odd & Even Pages Drukčije neparne i parne stranice + Differential Diferencijal + Differential theta Diferencijal theta + Differential x Diferencijal x + Differential y Diferencijal y + Differentials Diferencijali + Direct formatting Izravno oblikovanje + Direction: Smjer: + Directory Katalog + Discord Discord + Discord community Discord zajednica + Display Prikaz + Display for Review Prikaz za pregled + Display name Prikazno ime + Display text Prikazani tekst + Distribute Columns Rasporedi stupce ravnomjerno + Distribute Rows Rasporedi retke ravnomjerno + Distribute letters Rasporedi slova ravnomjerno + Distribute spacing Rasporedi razmake ravnomjerno + Distributed Raspodijeljeno + Do Not Check Spelling or Grammar Ne provjeravaj pravopis ni gramatiku + Do Not Show in TOC Ne prikazuj u sadržaju + Do not check spelling or grammar Ne provjeravaj pravopis ni gramatiku + Do you want to save changes to {name}? Želite li spremiti promjene u {name}? + Document Dokument + Document End Kraj dokumenta + Document Formatting Oblikovanje dokumenta + Document Grid Mreža dokumenta + Document Information Podaci o dokumentu + Document Property Svojstvo dokumenta + Document Start Početak dokumenta + Document Text Tekst dokumenta + Document Title Naslov dokumenta + Document template: Predložak dokumenta: + Document1 Dokument1 + Document\nPreferences Postavke\ndokumenta + Documents Dokumenti + Documents you open will show up here. Ovdje će se prikazati dokumenti koje ste nedavno otvorili. + Don't Save Ne spremaj + Don't add space between paragraphs of the same style Ne dodavati razmak između odlomaka istog stila + Don't capitalize after: Ne piši velikim slovom nakon: + Don't hyphenate Ne rastavljaj riječi + Don't read Zotero, Mendeley or EndNote citations and bibliographies Zanemari citate i bibliografije iz programa Zotero, Mendeley ili EndNote + Dot Točka + Dot dash Točka crtica + Dot-dash Točka-crtica + Dots Točke + Dotted Točkasto + Double Dvostruko + Double Bar Dvostruka okomita crta + Double Closing Quote Dvostruki završni navodnik + Double Dot Dvostruka točka + Double Integral Dvostruki integral + Double Integral with Limits Dvostruki integral s granicama + Double Integral with Stacked Limits Dvostruki integral s granicama jedna ispod druge + Double Left Arrow with Text Above Dvostruka lijeva strelica s tekstom iznad + Double Left Arrow with Text Below Dvostruka lijeva strelica s tekstom ispod + Double Left Right Arrow with Text Above Dvostruka lijevo-desna strelica s tekstom iznad + Double Left Right Arrow with Text Below Dvostruka lijevo-desna strelica s tekstom ispod + Double Opening Quote Dvostruki početni navodnik + Double Right Arrow with Text Above Dvostruka desna strelica s tekstom iznad + Double Right Arrow with Text Below Dvostruka desna strelica s tekstom ispod + Double Spacing Dvostruki razmak + Double Square Brackets Dvostruke uglate zagrade + Double Strikethrough Dvostruko precrtano + Double Underline Dvostruko podcrtano + Double Vertical Bars Dvostruke okomite crte + Double strikethrough Dvostruko precrtano + Double summation Dvostruka suma + Doughnut Prstenasti + Down Dolje + Draft Nacrt + Drag a rectangle to draw a table, or a line across cells to split them. Esc stops. Povucite pravokutnik da biste nacrtali tablicu ili crtu preko ćelija da biste ih podijelili. Esc prekida. + Draw Crtanje + Draw Ink Crtaj rukopisom + Draw Table Nacrtaj tablicu + Draw\nTable Nacrtaj\ntablicu + Drawing Canvas Platno za crtanje + Drawing Tools Alati za crtanje + Drawing\nCanvas Platno za\ncrtanje + Drop Cap Inicijal + Drop cap Inicijal + Drop-Down List Content Control Kontrola sadržaja s padajućim popisom + Drops all formatting and pastes plain text. Odbacuje sve oblikovanje i lijepi običan tekst. + Dutch Nizozemski + E-mail Messages E-poruke + Edit Comment Uredi komentar + Edit Data Uredi podatke + Edit Document Uredi dokument + Edit Equation Uredi jednadžbu + Edit Field Code Uredi kôd polja + Edit Field… Uredi polje… + Edit Footer Uredi podnožje + Edit Header Uredi zaglavlje + Edit Items Uredi stavke + Edit Recipient List Uredi popis primatelja + Edit Source Uredi izvor + Edit\nData Uredi\npodatke + Edit\nRecipient List Uredi\npopis primatelja + Edited Uređeno + Editing Uređivanje + Editing header/footer Uređivanje zaglavlja/podnožja + Editing in this document is restricted. Uređivanje u ovom dokumentu je ograničeno. + Editing — edit the document directly Uređivanje – izravno uređujte dokument + Edition Izdanje + Editor Urednik + Edit… Uredi… + Effects Efekti + Elbow Arrow Connector Koljenasti strelasti konektor + Elbow Connector Koljenasti konektor + Ellipsis Trotočje + Em Dash Crtica em + Em Space Razmak em + Emboss Reljef + Emphasis Isticanje + Empty Matrices Prazne matrice + En Dash Crtica en + En Space Razmak en + Enclose Characters Okruži znakove + Enclosure Prilog + Encrypt with Password Postavi lozinku + Encrypt with Password… Postavi lozinku… + End of Word Kraj riječi + End of document Kraj dokumenta + End of section Kraj odjeljka + Endnote Reference Referenca bilješke na kraju + Endnote Text Tekst bilješke na kraju + Endnotes Bilješke na kraju + Endnotes to footnotes Bilješke na kraju u fusnote + English (United Kingdom) Engleski (Ujedinjeno Kraljevstvo) + English (United States) Engleski (Sjedinjene Američke Države) + Engrave Urez + Enlarge symbol Povećaj simbol + Enter formatting for number: Unesite oblikovanje za broj: + Enter in Equation Enter u jednadžbi + Enter page number: Unesite broj stranice: + Entry marked. Unos je označen. + Envelope #10 Omotnica #10 + Envelope Address Adresa na omotnici + Envelope DL Omotnica DL + Envelope Return Povratna adresa na omotnici + Envelope size: Veličina omotnice: + Envelopes Omotnice + Equal Equal Dvostruko jednako + Equal by Definition Jednako po definiciji + Equal column width Jednaka širina stupaca + Equals: Jednako: + Equation Jednadžba + Equation Gallery Galerija jednadžbi + Equation Info Podaci o jednadžbi + Equation Number Broj jednadžbe + Equation Options Mogućnosti jednadžbi + Erase Ink Izbriši rukopis + Eraser Gumica + Errors ({n}) Pogreške ({n}) + Estonian Estonski + Euler's identity Eulerov identitet + Even Page Parna stranica + Even pages only Samo parne stranice + Every change is recorded as a tracked change. Svaka promjena bilježi se kao praćena promjena. + 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. Svaka naredba dostupna je skriptama i AI agentima: pokrenite `wordcraft-cli mcp` za MCP poslužitelj ili pokrenite aplikaciju s `--control ` za JSON kontrolni kanal. + Every field needs a name. Svako polje treba naziv. + Everyone Svi + Exactly Točno + Exactly {n} Točno {n} + Example: {sample} Primjer: {sample} + Exceptions (optional) Iznimke (neobavezno) + Exceptions… Iznimke… + Expand Proširi + Expand or Collapse Heading Proširi ili sažmi naslov + Exponential Eksponencijalno + Exponential series Eksponencijalni red + Export Izvoz + Export PDF Izvezi PDF + Export Page as PNG Izvezi stranicu kao PNG + Export a copy to share (PDF, Word document) Izvezi kopiju za dijeljenje (PDF, Word dokument) + Exported {path} Izvezeno: {path} + Expression Izraz + Extend Selection Proširi odabir + Extend Selection by Units Proširi odabir po jedinicama + Extra space Dodatni razmak + Field Polje + Field Codes Kodovi polja + Field code: Kôd polja: + Field codes: Kodovi polja: + Field name Naziv polja + Field name: Naziv polja: + Field names must be different. Nazivi polja moraju biti različiti. + Field names: Nazivi polja: + Field properties Svojstva polja + Field: Polje: + Fields Polja + Fields: Polja: + Field… Polje… + Figure Ilustracija + File Datoteka + File name: Naziv datoteke: + Fill Ispuna + Fill color: Boja ispune: + Filling in forms Ispunjavanje obrazaca + Film Film + Find Traži + Find Next Traži sljedeće + Find Next Editable Region Traži sljedeće uredivo područje + Find Previous Traži prethodno + Find Recipient Traži primatelja + Find What Expression Izraz za traženje + Find What Text Tekst za traženje + Find and Replace Traženje i zamjena + Find what: Traži: + Find: Traži: + Finding printers… Traženje pisača… + Finish Završi + Finish & Merge Završi i spoji + Finish &\nMerge Završi i\nspoji + Finished Završeno + Finnish Finski + First Prvi + First Column Prvi stupac + First Name Ime + First commit Prvi commit + First line Prvi redak + First line: Prvi redak: + First row of data contains column headers Prvi redak podataka sadrži zaglavlja stupaca + Fit Prilagodi + Fix Position on Page Učvrsti položaj na stranici + Fixed Column Width Fiksna širina stupca + Flip Horizontal Okreni vodoravno + Flip Vertical Okreni okomito + Floor Donje cijelo + Focus Fokus + Follow number with: Iza broja slijedi: + Font Font + Font Color Boja fonta + Font Dialog Dijaloški okvir fonta + Font Size Veličina fonta + Font color Boja fonta + Font color: Boja fonta: + Font: Font: + Font: {font} Font: {font} + Fonts Fontovi + Font… Font… + Footer Podnožje + Footnote Reference Referenca fusnote + Footnote Text Tekst fusnote + Footnote and Endnote Fusnota i bilješka na kraju + Footnotes Fusnote + Footnotes and endnotes Fusnote i bilješke na kraju + Footnotes to endnotes Fusnote u bilješke na kraju + Format Oblikovanje + Format Page Numbers Oblikuj brojeve stranica + Format Page Numbers… Oblikuj brojeve stranica… + Format Painter Kist za oblikovanje + Format: Oblikovanje: + Formatted Oblikovano + Formatted Text Oblikovani tekst + Formatting Oblikovanje + Formatting at Selection Oblikovanje na odabiru + Formatting exceptions Iznimke oblikovanja + Formatting marks Oznake oblikovanja + Formatting, case changes, white space and tables are compared; the other settings are kept for later. Uspoređuju se oblikovanje, promjene veličine slova, razmaci i tablice; ostale postavke čuvaju se za kasnije. + Formula Formula + Forward Naprijed + Fraction Razlomak + Fractions (1/2) as fraction characters (½) Razlomci (1/2) kao znakovi razlomka (½) + French (France) Francuski (Francuska) + From text: Od teksta: + Full page of the same label Cijela stranica istih naljepnica + Function Funkcija + Functions Funkcije + GB/T 7714 GB/T 7714 + GOST (name sort) GOST (sortiranje po imenu) + GOST (title sort) GOST (sortiranje po naslovu) + Gallery Galerija + General Općenito + Geometry Geometrija + German (Germany) Njemački (Njemačka) + Glow Sjaj + Glow Colors Boje sjaja + Go Back Vrati se + Go To Idi na + Go To (⌘⌥G) Idi na (⌘⌥G) + Go To… Idi na… + Go to Idi na + Go to what: Idi na: + Good afternoon Dobar dan + Good evening Dobra večer + Good morning Dobro jutro + Grab bag Razno + Graphic Grafika + Grave Gravis + Grayscale Sivi tonovi + Greek Grčki + Greek Letters Grčka slova + Green: Zelena: + Greeting Line Redak pozdrava + Greeting\nLine Redak\npozdrava + Grid Table 1 Light Tablica s mrežom 1 svijetla + Grid Table 4 – Blue Tablica s mrežom 4 – plava + Grid Table 4 – Green Tablica s mrežom 4 – zelena + Grid Table 4 – Orange Tablica s mrežom 4 – narančasta + Grid Table 4 – Purple Tablica s mrežom 4 – ljubičasta + Gridlines Crte mreže + Group Grupiraj + Grouping Character Above Znak grupiranja iznad + Grouping Character Below Znak grupiranja ispod + Grow Font 1 Point Povećaj font za 1 točku + H: H: + HTML Format HTML format + Hanging Indent Viseća uvlaka + Harvard (Anglia) Harvard (Anglia) + Hat Cirkumfleks + Header Zaglavlje + Header & Footer Zaglavlje i podnožje + Header Row Redak zaglavlja + Header/Footer Position Položaj zaglavlja/podnožja + Headers and footers Zaglavlja i podnožja + Heading 1 Naslov 1 + Heading 2 Naslov 2 + Heading 3 Naslov 3 + Heading styles (levels 1–{n}) Stilovi naslova (razine 1–{n}) + Heading {n} Naslov {n} + Headings Naslovi + Heart Srce + Hebrew Hebrejski + Height: Visina: + Help Pomoć + Hex: Hex: + Hidden Skriveno + Hidden text Skriveni tekst + Hide Codes Sakrij kodove + Hide Ink Sakrij rukopis + Hide White Space Sakrij prazan prostor + Hide from style lists Sakrij s popisa stilova + Hide\nInk Sakrij\nrukopis + Hierarchy Hijerarhija + Highlight Istakni + Highlight Merge Fields Istakni polja za spajanje + Highlight\nMerge Fields Istakni\npolja za spajanje + Highlighter Marker + Hindi Hindski + Hollow Šuplje + Home Početna + Home folder Početna mapa + Horizontal Vodoravno + Horizontal Line Vodoravna crta + How many pages the whole document has Koliko stranica ima cijeli dokument + How many pages this section has Koliko stranica ima ovaj odjeljak + How many words the document holds Koliko riječi sadrži dokument + Hue: Nijansa: + Hyperbolic Cosecant Function Hiperbolna funkcija kosekans + Hyperbolic Cosine Function Hiperbolna funkcija kosinus + Hyperbolic Cotangent Function Hiperbolna funkcija kotangens + Hyperbolic Functions Hiperbolne funkcije + Hyperbolic Secant Function Hiperbolna funkcija sekans + Hyperbolic Sine Function Hiperbolna funkcija sinus + Hyperbolic Tangent Function Hiperbolna funkcija tangens + Hyperlink Poveznica + Hyphenate at: Rastavljaj na: + Hyphenate words in CAPS Rastavljaj riječi napisane velikim slovima + Hyphenation Rastavljanje riječi + Hyphenation Options… Mogućnosti rastavljanja riječi… + Hyphenation zone: Zona rastavljanja: + Hyphens (--) as dashes (—) Spojnice (--) kao crtice (—) + Hypotenuse Hipotenuza + I, II, III, … I, II, III, … + IEEE IEEE + ISO 690 (author-date) ISO 690 (autor–datum) + ISO 690 (numerical) ISO 690 (numerički) + Icon Color Boja ikone + Icons Ikone + Identity Matrices Jedinične matrice + If…Then…Else Ako…Onda…Inače + If…Then…Else… Ako…Onda…Inače… + Ignore All Zanemari sve + Ignore punctuation characters Zanemari interpunkcijske znakove + Ignore web and file addresses Zanemari web-adrese i adrese datoteka + Ignore white-space characters Zanemari razmake i prazne znakove + Ignore words in capitals Zanemari riječi napisane velikim slovima + Ignore words with numbers Zanemari riječi s brojevima + Illustrations Ilustracije + Immersive Imerzivno + Immersive Reader Imerzivni čitač + Immersive\nReader Imerzivni\nčitač + In Default apps, choose WordCraft for the document types you want it to open. U zadanim aplikacijama odaberite WordCraft za vrste dokumenata koje želite njime otvarati. + In Front of Text Ispred teksta + In Line with Text U retku s tekstom + In equations, typing a name after a backslash replaces it with its symbol. U jednadžbama upisivanje naziva nakon obrnute kose crte zamjenjuje ga njegovim simbolom. + Inches Inči + Include level number from: Uključi broj razine iz: + Include page numbers Uključi brojeve stranica + Increase Font Size Povećaj veličinu fonta + Increase Indent Povećaj uvlaku + Indent Uvlaka + Indent from left: Uvlaka slijeva: + Indentation Uvlačenje + Indented Uvučeno + Indents Uvlake + Indents and Spacing Uvlake i razmaci + Index Kazalo + Index Heading Naslov kazala + Index and Tables Kazalo i tablice + Index {n} Kazalo {n} + Inflate Povećaj + Info Informacije + Initials: Inicijali: + Ink Rukopis + Ink Replay Reprodukcija rukopisa + Ink to Shape Rukopis u oblik + Ink to\nMath Rukopis u\nmatematiku + Ink to\nShape Rukopis u\noblik + Ink\nReplay Reprodukcija\nrukopisa + Inline U retku + Input Format Ulazni format + Insert Umetni + Insert Above Umetni iznad + Insert Below Umetni ispod + Insert Caption Umetni natpis + Insert Caption… Umetni natpis… + Insert Chart Umetni grafikon + Insert Citation Umetni citat + Insert Columns to the Left Umetni stupce lijevo + Insert Columns to the Right Umetni stupce desno + Insert Date Field Umetni polje datuma + Insert Endnote Umetni bilješku na kraju + Insert Footnote Umetni fusnotu + Insert Hyperlink Umetni poveznicu + Insert Index Umetni kazalo + Insert Left Umetni lijevo + Insert Merge Field Umetni polje za spajanje + Insert Merge\nField Umetni polje\nza spajanje + Insert New Equation Umetni novu jednadžbu + Insert Page Field Umetni polje stranice + Insert Right Umetni desno + Insert Row or Column by Clicking Umetni redak ili stupac klikom + Insert Rows Above Umetni retke iznad + Insert Rows Below Umetni retke ispod + Insert Shapes Umetni oblike + Insert SmartArt Umetni SmartArt + Insert Structure Umetni strukturu + Insert Symbol Umetni simbol + Insert Table Umetni tablicu + Insert Table of Authorities Umetni popis pravnih izvora + Insert Table of Figures Umetni popis ilustracija + Insert Table… Umetni tablicu… + Insert Time Field Umetni polje vremena + Insert a new equation (Alt+=) Umetni novu jednadžbu (Alt+=) + Insert one text or another, depending on a field's value. Umetnite jedan ili drugi tekst, ovisno o vrijednosti polja. + Insert\nAbove Umetni\niznad + Insert\nCaption Umetni\nnatpis + Insert\nCitation Umetni\ncitat + Insert\nFootnote Umetni\nfusnotu + Insertions and Deletions Umetanja i brisanja + Insertions: Umetanja: + Inside Borders Unutarnji obrubi + Inside: Unutra: + Inspect Document Pregledaj dokument + Inspect Styles at Selection Pregledaj stilove na odabiru + Institution Ustanova + Integral Integral + Integral of e^(−x²) Integral od e^(−x²) + Integral with Limits Integral s granicama + Integral with Stacked Limits Integral s granicama jedna ispod druge + Integrals Integrali + Intense Emphasis Snažno isticanje + Intense Quote Istaknuti citat + Intense Reference Snažna referenca + Interface Sučelje + Interface Theme Tema sučelja + Interface language Jezik sučelja + Interface language: Jezik sučelja: + Interface size: Veličina sučelja: + Interface theme: Tema sučelja: + Interface\nTheme Tema\nsučelja + Internet addresses as links Internetske adrese kao poveznice + Intersection Presjek + Intersection with Limits Presjek s granicama + Intersection with Lower Limit Presjek s donjom granicom + Intersection with Subscript-Superscript Limits Presjek s granicama u donjem i gornjem indeksu + Inverse Cosecant Function Inverzna funkcija kosekans + Inverse Cosine Function Inverzna funkcija kosinus + Inverse Cotangent Function Inverzna funkcija kotangens + Inverse Functions Inverzne funkcije + Inverse Hyperbolic Cosecant Function Inverzna hiperbolna funkcija kosekans + Inverse Hyperbolic Cosine Function Inverzna hiperbolna funkcija kosinus + Inverse Hyperbolic Cotangent Function Inverzna hiperbolna funkcija kotangens + Inverse Hyperbolic Functions Inverzne hiperbolne funkcije + Inverse Hyperbolic Secant Function Inverzna hiperbolna funkcija sekans + Inverse Hyperbolic Sine Function Inverzna hiperbolna funkcija sinus + Inverse Hyperbolic Tangent Function Inverzna hiperbolna funkcija tangens + Inverse Secant Function Inverzna funkcija sekans + Inverse Sine Function Inverzna funkcija sinus + Inverse Tangent Function Inverzna funkcija tangens + Ionosphere Ionosfera + Issue Broj + Italian Talijanski + Italic Kurziv + Japanese Japanski + Join the ArtCraft community on Discord Pridružite se ArtCraft zajednici na Discordu + Join us on Discord: discord.gg/artcraft Pridružite nam se na Discordu: discord.gg/artcraft + Journal Časopis + Justification Obostrano poravnanje + Justification of a display equation Poravnanje istaknute jednadžbe + Justified Obostrano poravnato + Justify Obostrano poravnaj + Keep Lines Together Zadrži retke zajedno + Keep Text Only Zadrži samo tekst + Keep lines together Zadrži retke zajedno + Keep the password somewhere safe: without it nobody, WordCraft included, can open the document. Leave both boxes empty to remove the password. Lozinku spremite na sigurno mjesto: bez nje nitko, pa ni WordCraft, ne može otvoriti dokument. Ostavite oba polja praznima da biste uklonili lozinku. + Keep with Next Zadrži sa sljedećim + Keep with next Zadrži sa sljedećim + Keeps the fonts and formatting of the copied text. Zadržava fontove i oblikovanje kopiranog teksta. + Keyboard shortcuts: Tipkovnički prečaci: + Keys for this command: Tipke za ovu naredbu: + Keywords Ključne riječi + Korean Korejski + Label changes with: Oznaka se mijenja s: + Label: Oznaka: + Labels Naljepnice + Landscape Položeno + Language Jezik + Large Veliko + Large\nOperator Veliki\noperator + Largest Only Samo najveće + Lasso Laso + Lasso Select Odabir lasom + Last Zadnji + Last Column Zadnji stupac + Last Name Prezime + Last commit Zadnji commit + Last, First; Last, First Prezime, Ime; Prezime, Ime + Latin text font: Font latiničnog teksta: + Layout Raspored + Layout Summary Sažetak rasporeda + Layouts Rasporedi + Leader Vodeća crta + Leave Equation Izađi iz jednadžbe + Leave a recipient out of the merge when a field has this value. Izostavi primatelja iz spajanja kada polje ima ovu vrijednost. + Leave the equation (Esc) Izađi iz jednadžbe (Esc) + Leave the label out of the caption Izostavi oznaku iz natpisa + Left Lijevo + Left Arrow with Text Above Lijeva strelica s tekstom iznad + Left Arrow with Text Below Lijeva strelica s tekstom ispod + Left Border Lijevi obrub + Left Bracket Twice Lijeva zagrada dvaput + Left Margin Lijeva margina + Left Only Samo lijevo + Left Right Arrow Above Lijevo-desna strelica iznad + Left Right Arrow with Text Above Lijevo-desna strelica s tekstom iznad + Left Right Arrow with Text Below Lijevo-desna strelica s tekstom ispod + Left Subscript-Superscript Lijevi donji i gornji indeks + Left border Lijevi obrub + Left indent Lijeva uvlaka + Left-to-Right Text Direction Smjer teksta slijeva nadesno + Left-to-right Slijeva nadesno + Left: Lijevo: + Leftwards Arrow Above Strelica ulijevo iznad + Leftwards Harpoon Above Harpun ulijevo iznad + Legal style numbering Numeriranje u pravnom stilu + Legend Legenda +template Letter Pismo + Letter Letter + Letter headings Naslovi slova + Letter-Like Symbols Simboli nalik slovima + Letters Pisma + Level 1 Razina 1 + Level 2 Razina 2 + Level 3 Razina 3 + Level 4 Razina 4 + Level 5 Razina 5 + Level 6 Razina 6 + Level 7 Razina 7 + Level 8 Razina 8 + Level 9 Razina 9 + Level {n} Razina {n} + Levels: Razine: + Light Svijetlo + Light with Outline Svijetlo s obrisom + Limit Limes + Limit Example Primjer limesa + Limit and Limes i + Limit and\nLog Limes i\nlogaritam + Limit consecutive hyphens to: Ograniči uzastopne spojnice na: + Limit formatting to a selection of styles Ograniči oblikovanje na odabrane stilove + Line Crta + Line Break Prijelom retka + Line End Kraj retka + Line Number Broj retka + Line Numbering Options… Mogućnosti numeriranja redaka… + Line Numbers Brojevi redaka + Line Spacing Razmak redaka + Line Spacing Options… Mogućnosti razmaka redaka… + Line Start Početak retka + Line and Page Breaks Prijelomi redaka i stranica + Line and Paragraph Spacing Razmak redaka i odlomaka + Line between Crta između + Line breaking Prijelom redaka + Line delta Delta redaka + Line spacing Razmak redaka + Line spacing: Razmak redaka: + Linear Linearno + Linear Fraction Jednolinijski razlomak + Lines Crte + Lines +/− Retci +/− + Lines added Dodani retci + Lines and characters Retci i znakovi + Lines deleted Izbrisani retci + Lines only Samo crte + Lines per page: Redaka po stranici: + Link Poveznica + Link entries to their headings Poveži unose s njihovim naslovima + Link level to style: Poveži razinu sa stilom: + Link to Previous Poveži s prethodnim + Linked style (paragraph and character) Povezani stil (odlomak i znak) + Links Poveznice + Links and References Poveznice i reference + Link… Poveznica… + List Popis + List Definition Definicija popisa + List Paragraph Odlomak popisa + List Table 3 – Blue Tablica popisa 3 – plava + List Table 3 – Green Tablica popisa 3 – zelena + List Table 3 – Orange Tablica popisa 3 – narančasta + List Table 3 – Purple Tablica popisa 3 – ljubičasta + List entries Stavke popisa + Location Lokacija + Location: {path} Lokacija: {path} + Lock Fields Zaključaj polja + Log Log + Logarithm Logaritam + Logarithm with Base Logaritam s bazom + Logical AND Logičko I + Logical AND with Limits Logičko I s granicama + Logical OR Logičko ILI + Logical OR with Limits Logičko ILI s granicama + Lum: Svjetl.: + MIT OR Apache-2.0. Copyright (c) 2026 ArtCraft Team and the WordCraft contributors. MIT OR Apache-2.0. Copyright (c) 2026 ArtCraft Team i doprinositelji projekta WordCraft. + MLA MLA + Macros Makronaredbe + Made from the author and year when empty Izrađuje se iz autora i godine kada je prazno + Magazine or newspaper article Članak iz časopisa ili novina + Mail Merge Spajanje pošte + Mailings Pošta + Main entry: Glavni unos: + Make WordCraft the default for Word documents… Postavi WordCraft kao zadani program za Word dokumente… + Manage Sources Upravljanje izvorima + Manage Styles Upravljanje stilovima + Manage Styles… Upravljanje stilovima… + Manual Hyphenation Ručno rastavljanje riječi + Manual Line Break Ručni prijelom retka + Manual Page Break Ručni prijelom stranice + Manual hyphenation is complete. Ručno rastavljanje riječi je dovršeno. + Manual… Ručno… + Many pages Mnogo stranica + Margins Margine + Mark Označi + Mark All Označi sve + Mark Citation Označi citat + Mark Entry Označi unos + Mark Entry… Označi unos… + Mark Index Entry Označi unos kazala + Mark as decorative Označi kao ukrasno + Mark grammar errors as you type Označi gramatičke pogreške tijekom tipkanja + Mark selected text as: Označi odabrani tekst kao: + Mark\nCitation Označi\ncitat + Mark\nEntry Označi\nunos + Markdown (*.md) Markdown (*.md) + Match Fields Uskladi polja + Match case Razlikuj velika i mala slova + Match prefix Podudaranje prefiksa + Match suffix Podudaranje sufiksa + Math AutoCorrect Automatski ispravak matematike + Matrices with Brackets Matrice sa zagradama + Matrix Matrica + Maximum Maksimum + Maximum Example Primjer maksimuma + Measured By Mjereno s + Measurement units: Mjerne jedinice: + Merge Spoji + Merge Cells Spoji ćelije + Merge Formatting Spoji oblikovanje + Merge Record # Spoji zapis br. + Merged PRs Spojeni PR-ovi + Mesosphere Mezosfera + Middle Sredina + Midline Dots Točke na sredini retka + Millimeters Milimetri + Minimum Minimum + Minus Equal Minus jednako + Mirror indents Zrcali uvlake + Mirrored Zrcaljeno + Model Model + Models Modeli + Moderate Umjereno + Modify Style Izmijeni stil + Modify Table Style Izmijeni stil tablice + Modify Table Style… Izmijeni stil tablice… + Modify… Izmijeni… + Mon Pon + Month Mjesec + More >> Više >> + More Colors… Više boja… + More Columns… Više stupaca… + More Commands… Više naredbi… + More Symbols… Više simbola… + More templates → Više predložaka → + Move Down Pomakni dolje + Move Paragraph Down Pomakni odlomak dolje + Move Paragraph Up Pomakni odlomak gore + Move Past Citation Pomakni iza citata + Move Past\nCitation Pomakni iza\ncitata + Move Selected Ink Pomakni odabrani rukopis + Move Table by Dragging Pomakni tablicu povlačenjem + Move Up Pomakni gore + Move in Equation Pomakni u jednadžbi + Move or Resize Pomakni ili promijeni veličinu + Move with Text Pomiče se s tekstom + Moved from Premješteno iz + Moved to Premješteno u + Moves Premještanja + Multilevel List Višerazinski popis + Multiple Pages Više stranica + Name Naziv + Name (A–Z) Naziv (A–Ž) + Name of the file this document is saved as Naziv datoteke u koju je dokument spremljen + Name: Naziv: + Narrow 0.5" all Usko 0,5" sa svih strana + Natural Logarithm Prirodni logaritam + Nature Priroda + Navigation Navigacija + Navigation Pane Okno za navigaciju + Negated Relations Negirane relacije + New Novo + New Comment Novi komentar + New Document Novi dokument + New Drawing Canvas Novo platno za crtanje + New Entry Novi unos + New Group Nova grupa + New Label Nova oznaka + New Label… Nova oznaka… + New Paragraph Novi odlomak + New Style… Novi stil… + New Tab Nova kartica + New Table Style Novi stil tablice + New Table Style… Novi stil tablice… + New Window Novi prozor + New comments and tracked changes are recorded under this name and these initials. Novi komentari i praćene promjene bilježe se pod ovim imenom i ovim inicijalima. + New document Novi dokument + New from Template Novo iz predloška + New shortcut: Novi prečac: + New word Nova riječ + New\nComment Novi\nkomentar + New\nWindow Novi\nprozor + New… Novo… + Next Sljedeće + Next Change Sljedeća promjena + Next Comment Sljedeći komentar + Next Field Sljedeće polje + Next Footnote Sljedeća fusnota + Next Page Sljedeća stranica + Next Record Sljedeći zapis + Next Section Sljedeći odjeljak + Next Sentence Sljedeća rečenica + Next sentence Sljedeća rečenica + No Ne + No Border Bez obruba + No Color Bez boje + No Fill Bez ispune + No Glow Bez sjaja + No Markup Bez oznaka + No Outline Bez obrisa + No Paragraph Space Bez razmaka odlomaka + No Reflection Bez odraza + No Shadow Bez sjene + No Soft Edges Bez mekih rubova + No Spacing Bez proreda + No Transform Bez transformacije + No accessibility issues found. Nisu pronađeni problemi s pristupačnošću. + No changes (read only) Nema promjena (samo za čitanje) + No contributor data was built into this copy. U ovu kopiju nisu ugrađeni podaci o doprinositeljima. + No errors found: every merge field is in the recipient list. Nisu pronađene pogreške: svako polje za spajanje nalazi se na popisu primatelja. + No grid Bez mreže + No icons match your search. Nijedna ikona ne odgovara pretrazi. + No limit Bez ograničenja + No model credits were built into this copy. U ovu kopiju nisu ugrađene zasluge modela. + No printers found. Configure a system printer or export a PDF. Nije pronađen nijedan pisač. Postavite sustavni pisač ili izvezite PDF. + No sources yet. Još nema izvora. + No templates match your search. Nijedan predložak ne odgovara pretrazi. + No words yet. Add to Dictionary on a misspelled word puts it here. Još nema riječi. Naredba Dodaj u rječnik na pogrešno napisanoj riječi stavlja je ovamo. + No-Width Non Break Nulta širina bez prijeloma + No-Width Optional Break Nulta širina s mogućim prijelomom + Nonbreaking Hyphen Spojnica bez prijeloma + Nonbreaking Space Razmak bez prijeloma + None Nema + Normal Uobičajeni + Normal 1" all Normalno 1" sa svih strana + Normal Indent Normalna uvlaka + Normal Style Normalan stil + Normal Table Normalna tablica + Normal Text Normalan tekst + Normal Word Document Normalan Word dokument + Normal probability density Gustoća normalne razdiobe + Normal speed Normalna brzina + North Sjever + Norwegian (Bokmål) Norveški (bokmål) + Not Nije + Not available right now Trenutačno nije dostupno + Not linked: click a text box Nije povezano: kliknite tekstni okvir + Not saved Nije spremljeno + Not saved yet Još nije spremljeno + Not used by any command Ne koristi se ni u jednoj naredbi + Not {name} Nije {name} + Nothing Ništa + Nothing collected yet. Items you copy or cut appear here. Još ništa nije prikupljeno. Ovdje se prikazuju stavke koje kopirate ili izrežete. + Now used by {command} Sada ga koristi {command} + Nudge Pomakni malo + Num Occurrences Broj pojavljivanja + Number Broj + Number alignment: Poravnanje broja: + Number format Format broja + Number of columns: Broj stupaca: + Number of rows: Broj redaka: + Number of the page the field sits on Broj stranice na kojoj se nalazi polje + Number of the section the field sits in Broj odjeljka u kojem se nalazi polje + Number style for this level: Stil brojeva za ovu razinu: + Number the equation; the number sits at the right margin (or type # then the number) Numerirajte jednadžbu; broj se nalazi na desnoj margini (ili upišite # pa broj) + Numbering Numeriranje + Numbering: Numeriranje: + Numbering… Numeriranje… + Numerals Brojke + Numerals: Brojke: + OK U redu + Object Objekt + Odd Page Neparna stranica + Odd pages only Samo neparne stranice + Off Isključeno + Office Ured + Office 2003 Default Office 2003 zadano + Offset (pt): Pomak (pt): + Offset: Bottom Pomak: dolje + Offset: Bottom Left Pomak: dolje lijevo + Offset: Bottom Right Pomak: dolje desno + Offset: Center Pomak: sredina + Offset: Left Pomak: lijevo + Offset: Right Pomak: desno + Offset: Top Pomak: gore + Offset: Top Left Pomak: gore lijevo + Offset: Top Right Pomak: gore desno + Omit Izostavi + On Uključeno + On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. Uključeno: WordCraft se otvara na Datoteka › Početna, s praznim dokumentom, predlošcima i vašim nedavnim dokumentima. Isključeno: otvara prazan dokument. + 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. Uključeno: kursor klizi na novo mjesto kad tipkate ili koristite tipke sa strelicama, a njegovo treptanje postupno nestaje. Isključeno: skače i treperi. + One Jedan + One Page Jedna stranica + One half Jedna polovina + One label per recipient Jedna naljepnica po primatelju + One line per item. Enter adds an item. Jedan redak po stavci. Enter dodaje stavku. + One-sided Jednostrano + Only allow this kind of editing: Dopusti samo ovu vrstu uređivanja: + Only comments can be added, except in regions marked editable. Mogu se dodavati samo komentari, osim u područjima označenima kao urediva. + Only form fields can be filled in. Mogu se ispunjavati samo polja obrasca. + Only the allowed styles can be applied. Mogu se primijeniti samo dopušteni stilovi. + Only web (http, https) and email (mailto) links can be followed Mogu se otvarati samo web-poveznice (http, https) i poveznice e-pošte (mailto) + Open Otvori + Open a file with WordCraft's own browser when the system file dialog doesn't appear. Otvorite datoteku vlastitim preglednikom programa WordCraft kada se sustavni dijaloški okvir za datoteke ne pojavi. + OpenDocument Text (*.odt) OpenDocument Text (*.odt) + Opening print dialog… Otvaranje dijaloškog okvira za ispis… + Opens .docx, .doc, .rtf and .odt files and Word templates with WordCraft from now on. Od sada otvara datoteke .docx, .doc, .rtf i .odt te Word predloške u programu WordCraft. + Opens Settings › Default apps, where you choose WordCraft for .docx and the other document types. Otvara Postavke › Zadane aplikacije, gdje odabirete WordCraft za .docx i ostale vrste dokumenata. + Operator Operator + Operator Structures Strukture operatora + Operators Operatori + Optional Hyphen Neobavezna spojnica + Options Mogućnosti + Options… Mogućnosti… + Or save a copy in another format: Ili spremite kopiju u drugom formatu: + Ordinals (1st) as superscript Redni brojevi (1st) u gornjem indeksu + Organizer Organizator + Organizer… Organizator… + Orientation Orijentacija + Original document Izvorni dokument + Other Ostalo + Other Large Operators Ostali veliki operatori + Other styles and their TOC level: Ostali stilovi i njihova razina u sadržaju: + Otherwise insert: Inače umetni: + Outline Struktura + Outline Level Razina strukture + Outline State Stanje strukture + Outline Tools Alati strukture + Outline level Razina strukture + Outline levels Razine strukture + Outlining Strukturiranje + Outside Borders Vanjski obrubi + Outside border Vanjski obrub + Outside: Vani: + Outward Brackets Zagrade prema van + Oval Ovalno + Overbar Crta iznad + Overbars and Underbars Crte iznad i ispod + Overbrace Vitičasta zagrada iznad + Overtype Prepisivanje + Overtype (typing replaces the characters after the caret) Prepisivanje (tipkanje zamjenjuje znakove iza kursora) + PDF document (*.pdf) PDF dokument (*.pdf) + PRs PR-ovi + Page Stranica + Page Background Pozadina stranice + Page Border Obrub stranice + Page Borders Obrubi stranice + Page Break Prijelom stranice + Page Break Before Prijelom stranice prije + Page Breaks Prijelomi stranica + Page Color Boja stranice + Page Down Stranica dolje + Page Movement Kretanje po stranicama + Page Number Broj stranice + Page Setup Postavljanje stranice + Page Up Stranica gore + Page Width Širina stranice + Page X of Y Stranica X od Y + Page break before Prijelom stranice prije + Page display Prikaz stranice + Page image (*.png) Slika stranice (*.png) + Page number style Stil broja stranice + Page numbers at the right margin Brojevi stranica na desnoj margini + Page on which a bookmarked passage falls Stranica na kojoj se nalazi označeni odlomak + Page title Naslov stranice + Page width Širina stranice + Page {page} Stranica {page} + Page {page} of {pages} Stranica {page} od {pages} + Page\nBorders Obrubi\nstranice + Page\nColor Boja\nstranice + Page\nNumber Broj\nstranice + Pages Stranice + Pages like 1-3, 5 Stranice poput 1-3, 5 + Pages of bookmark: Stranice knjižne oznake: + Paragraph Odlomak + Paragraph Character Znak odlomka + Paragraph Details Pojedinosti odlomka + Paragraph Down Odlomak dolje + Paragraph Mark Oznaka odlomka + Paragraph Settings Postavke odlomka + Paragraph Spacing Razmak odlomaka + Paragraph Up Odlomak gore + Paragraph marks Oznake odlomaka + Paragraph style Stil odlomka + Paragraphs Odlomci + Paragraph… Odlomak… + Parentheses Okrugle zagrade + Parentheses with Separator Okrugle zagrade s razdjelnikom + Partial Differential Parcijalni diferencijal + Password Lozinka + Password: Lozinka: + Paste Zalijepi + Paste (⌘V) Zalijepi (⌘V) + Paste All Zalijepi sve + Paste Clipboard Item Zalijepi stavku međuspremnika + Paste Formatting Zalijepi oblikovanje + Paste Special Zalijepi posebno + Paste Special… Zalijepi posebno… + Paste as: Zalijepi kao: + Paste: Keep Text Only Lijepljenje: zadrži samo tekst + Paste: Merge Formatting Lijepljenje: spoji oblikovanje + Pastes the clipboard's picture. Lijepi sliku iz međuspremnika. + Pause Pauziraj + Paused Pauzirano + Pen Olovka + Pencil Grafitna olovka + Pens Olovke + People Osobe + Periodical Periodika + Personalize Personaliziraj + Phonetic Guide Fonetski vodič + Pi Over 2 Pi kroz 2 + Picas Pike + Pick a custom group (New Group makes one) Odaberite prilagođenu grupu (Nova grupa je izrađuje) + Pick a field from the list Odaberite polje s popisa + Picture Slika + Picture Border Obrub slike + Picture Content Control Kontrola sadržaja sa slikom + Picture Format Oblikovanje slike + Picture Styles Stilovi slike + Pictures Slike + Pie Tortni + Placeholder text Tekst rezerviranog mjesta + Plain Table 1 Obična tablica 1 + Plain Text Content Control Kontrola sadržaja s običnim tekstom + Plain text (*.txt) Običan tekst (*.txt) + Play Reproduciraj + Play/Pause Reproduciraj/pauziraj + Plus Equal Plus jednako + Points Točke + Polish Poljski + Portrait Uspravno + Portuguese (Brazil) Portugalski (Brazil) + Portuguese (Portugal) Portugalski (Portugal) + Position Položaj + Position: Položaj: + Possible spelling mistake Moguća pravopisna pogreška + Postal Code Poštanski broj + Power series of (1+x)ⁿ Red potencija od (1+x)ⁿ + Preferred width: Željena širina: + Preserve formatting during updates Sačuvaj oblikovanje tijekom ažuriranja + Presets Zadane postavke + Press a key combination Pritisnite kombinaciju tipki + Preview Pretpregled + Preview Results Pretpregled rezultata + Preview\nResults Pretpregled\nrezultata + Previous Prethodno + Previous 1 or More Prethodno 1 ili više + Previous Change Prethodna promjena + Previous Comment Prethodni komentar + Previous Field Prethodno polje + Previous Page Prethodna stranica + Previous Record Prethodni zapis + Previous Section Prethodni odjeljak + Previous Sentence Prethodna rečenica + Previous sentence Prethodna rečenica + Print Ispis + Print Layout Izgled ispisa + Print Markup Ispis oznaka + Print failed: the printing worker stopped. Ispis nije uspio: proces ispisa se zaustavio. + Print failed: {error} Ispis nije uspio: {error} + Print job submitted. Zadatak ispisa je poslan. + Print job submitted: {receipt} Zadatak ispisa je poslan: {receipt} + Print the page color Ispiši boju stranice + Print to PDF Ispiši u PDF + Print\nLayout Izgled\nispisa + Printer Pisač + Printer default Zadano za pisač + Printer discovery failed. Traženje pisača nije uspjelo. + Printing Ispis + Process Proces + Product Umnožak + Product of A Umnožak od A + Product with Limits Umnožak s granicama + Product with Lower Limit Umnožak s donjom granicom + Product with Subscript-Superscript Limits Umnožak s granicama u donjem i gornjem indeksu + Products and Coproducts Umnošci i koprodukti + Professional Profesionalno + Promote Podigni razinu + Promote to Heading 1 Podigni na Naslov 1 + Proofing Jezična provjera + Proofing Issues Jezični problemi + Proofing language Jezik provjere + Properties Svojstva + Protect Zaštiti + Protect Document Zaštiti dokument + Publisher Izdavač + Put the caret in a content control first. Prvo postavite kursor u kontrolu sadržaja. + Put the default commands back on the Quick Access Toolbar? Želite li vratiti zadane naredbe na alatnu traku za brzi pristup? + Pyramid Piramida + Quadratic Root Rješenje kvadratne jednadžbe + Quick Access Toolbar Alatna traka za brzi pristup + Quick Parts Brzi dijelovi + Quick Tables Brze tablice + Quote Citat + Radical Korijen + Radical with Degree Korijen sa stupnjem + Radicals Korijeni + Raised/lowered Podignuto/spušteno + Read Aloud Čitaj naglas + Read Aloud Status Status čitanja naglas + Read Mode Čitanje + Read\nAloud Čitaj\nnaglas + Read\nMode Čitanje + Reading Čitanje + Reading Column Width Širina stupca za čitanje + Reading Highlight Isticanje pri čitanju + Reading Line Spacing Razmak redaka pri čitanju + Reading Mode Način čitanja + Reading Speed Brzina čitanja + Reading Text Size Veličina teksta pri čitanju + Reads the clipboard's rich text and keeps its formatting. Čita obogaćeni tekst iz međuspremnika i zadržava njegovo oblikovanje. + Reads the clipboard's web content and keeps its formatting. Čita web-sadržaj iz međuspremnika i zadržava njegovo oblikovanje. + Real name Pravo ime + Recent Nedavno + Recent Colors Nedavne boje + Recent documents to show: Nedavni dokumenti za prikaz: + Recently used symbols: Nedavno korišteni simboli: + Recipient List Popis primatelja + Recipient lists Popisi primatelja + Recipients: {count} Primatelji: {count} + Recolor Selected Ink Promijeni boju odabranog rukopisa + Recolor a one-color picture, such as an icon Promijeni boju jednobojne slike, primjerice ikone + Record Macro Snimi makronaredbu + Recover Unsaved Documents Oporavi nespremljene dokumente + Rectangle Pravokutnik + Red: Crvena: + Redo Ponovi + Reduce Hanging Indent Smanji viseću uvlaku + References Reference + Reflection Odraz + Refresh Osvježi + Refresh printers Osvježi pisače + Region: Regija: + Registered Registrirano + Registered Sign Znak registriranog žiga + Regular expressions Regularni izrazi + Reject Odbij + Reject All Changes Odbij sve promjene + Reject Change Odbij promjenu + Relationship Odnos + Relaxed Opušteno + Remove Ukloni + Remove Added Font Ukloni dodani font + Remove Background Ukloni pozadinu + Remove Content Control Ukloni kontrolu sadržaja + Remove Footer Ukloni podnožje + Remove Header Ukloni zaglavlje + Remove Hyperlink Ukloni poveznicu + Remove Password Ukloni lozinku + Remove Pen Ukloni olovku + Remove Space After Paragraph Ukloni razmak nakon odlomka + Remove Table of Contents Ukloni sadržaj + Remove Watermark Ukloni vodeni žig + Remove every custom shortcut and restore the built-in keys? Želite li ukloniti sve prilagođene prečace i vratiti ugrađene tipke? + Remove every custom tab and group and show all tabs again? Želite li ukloniti sve prilagođene kartice i grupe te ponovno prikazati sve kartice? + Remove field Ukloni polje + Remove from Quick Access Toolbar Ukloni s alatne trake za brzi pristup + Remove recipient Ukloni primatelja + Remove row Ukloni redak + Remove series Ukloni niz + Remove the control once edited Ukloni kontrolu nakon uređivanja + Remove the space before the punctuation Ukloni razmak prije interpunkcije + Rename Style Preimenuj stil + Rename… Preimenuj… + Reopen Ponovno otvori + Repeat Ponovi + Repeat Header Rows Ponovi retke zaglavlja + Repeat as header row on each page Ponovi kao redak zaglavlja na svakoj stranici + Repeating Section Content Control Kontrola sadržaja s ponavljajućim odjeljkom + Replace Zamijeni + Replace All Zamijeni sve + Replace Document Text Zamijeni tekst dokumenta + Replace as you type Zamijeni tijekom tipkanja + Replace text as you type Zamijeni tekst tijekom tipkanja + Replace with: Zamijeni s: + Replace: Zamijeni: + Replay Ponovno reproduciraj + Reply Odgovori + Report Izvješće + Research Istraživanje + Researcher Istraživač + Researcher needs an online service; WordCraft keeps your documents offline. Istraživač zahtijeva mrežnu uslugu; WordCraft vaše dokumente drži izvan mreže. + Reset Vrati na zadano + Reset All… Vrati sve na zadano… + Reset Character Formatting Vrati zadano oblikovanje znakova + Reset Crop Poništi obrezivanje + Reset Graphic Vrati grafiku + Reset Paragraph Formatting Vrati zadano oblikovanje odlomka + Reset Picture Vrati sliku + Reset to Normal Vrati na normalno + Reset\nGraphic Vrati\ngrafiku + Reset… Vrati na zadano… + Resize Table by Dragging Promijeni veličinu tablice povlačenjem + Resolve Riješi + Resolved Riješeno + Restart Each Page Ponovno pokreni na svakoj stranici + Restart Each Section Ponovno pokreni u svakom odjeljku + Restart at 1 Ponovno pokreni od 1 + Restart list after: Ponovno pokreni popis nakon: + Restrict Editing Ograniči uređivanje + Restrict\nEditing Ograniči\nuređivanje + Result Rezultat + Results Rezultati + Return address labels Naljepnice s povratnom adresom + Return address: Povratna adresa: + Reverse the order Obrni redoslijed + Review Pregled + Reviewing Pregledavanje + Reviewing Pane Okno pregleda + Reviewing — edits become suggestions Pregledavanje – izmjene postaju prijedlozi + Revised document Revidirani dokument + Rewind Premotaj unatrag + Ribbon Vrpca + Ribbon and Quick Access Toolbar: Vrpca i alatna traka za brzi pristup: + Ribbon tabs Kartice vrpce + Rich Text Content Control Kontrola sadržaja s obogaćenim tekstom + Rich Text Format (*.rtf) Rich Text Format (*.rtf) + Rich Text Format (RTF) Rich Text Format (RTF) + Right Desno + Right (vertical) Desno (okomito) + Right Arrow with Text Above Desna strelica s tekstom iznad + Right Arrow with Text Below Desna strelica s tekstom ispod + Right Border Desni obrub + Right Bracket Twice Desna zagrada dvaput + Right Margin Desna margina + Right Only Samo desno + Right border Desni obrub + Right indent Desna uvlaka + Right-to-Left Section Odjeljak zdesna nalijevo + Right-to-Left Table Tablica zdesna nalijevo + Right-to-Left Text Direction Smjer teksta zdesna nalijevo + Right-to-left Zdesna nalijevo + Right: Desno: + Rightwards Arrow Above Strelica udesno iznad + Rightwards Harpoon Above Harpun udesno iznad + Roots of ax²+bx+c Korijeni od ax²+bx+c + Rotate Zakreni + Rotate Left 90° Zakreni ulijevo 90° + Rotate Right 90° Zakreni udesno 90° + Rotate all text 270° Zakreni sav tekst 270° + Rotate all text 90° Zakreni sav tekst 90° + Rotation Rotacija + Rotation: Rotacija: + Rounded Rectangle Zaobljeni pravokutnik + Row Redak + Row: Redak: + Rows & Columns Retci i stupci + Ruby text: Ruby tekst: + Ruler Ravnalo + Rules Pravila + Run-in U tekstu + Russian Ruski + Résumé Životopis + SAMPLE UZORAK + SIST02 SIST02 + Sat: Sub: + Save Spremi + Save As Spremi kao + Save a copy in another format. Printing goes through a PDF you can print from any viewer. Spremite kopiju u drugom formatu. Ispis ide preko PDF-a koji možete ispisati iz bilo kojeg PDF preglednika. + Save as Template Spremi kao predložak + Save documents Spremanje dokumenata + Save the document to turn on AutoSave Spremite dokument kako biste uključili automatsko spremanje + Save the document to turn on AutoSave (a file you have only opened isn't rewritten until you save it) Spremite dokument kako biste uključili automatsko spremanje (datoteka koju ste samo otvorili ne prepisuje se dok je ne spremite) + Save these pages as PDF… Spremi ove stranice kao PDF… + Saved Spremljeno + Scale Mjerilo + Scale Drawing Crtež u mjerilu + Scatter Raspršeni + Script Indeksi + Scripts Rukopisna slova + Scroll ribbon left Pomakni vrpcu ulijevo + Scroll ribbon right Pomakni vrpcu udesno + Search Traži + Search Commands Pretraži naredbe + Search Options Mogućnosti pretraživanja + Search commands and help Pretraži naredbe i pomoć + Search document Pretraži dokument + Search for templates Pretraži predloške + Search icons Pretraži ikone + Secant Function Funkcija sekans + Section Break Prijelom odjeljka + Section Breaks Prijelomi odjeljaka + Section Character Znak odjeljka + Section Mark Oznaka odjeljka + Section Properties Svojstva odjeljka + Section direction: Smjer odjeljka: + Sections Odjeljci + See Vidi + Select Odaberi + Select All Odaberi sve + Select Cell Odaberi ćeliju + Select Column Odaberi stupac + Select Heading and Content Odaberi naslov i sadržaj + Select Line Odaberi redak + Select Objects Odaberi objekte + Select Paragraph Odaberi odlomak + Select Range Odaberi raspon + Select Recipients Odaberi primatelje + Select Row Odaberi redak + Select Sentence Odaberi rečenicu + Select Table Odaberi tablicu + Select Table Cells by Clicking Odaberi ćelije tablice klikom + Select Text Odaberi tekst + Select Text with Similar Formatting Odaberi tekst sa sličnim oblikovanjem + Select Word Odaberi riječ + Select a picture, shape, chart or table to describe it. Odaberite sliku, oblik, grafikon ili tablicu da biste ih opisali. + Select a printer Odaberite pisač + Select recipients first to choose from their fields. Prvo odaberite primatelje da biste birali među njihovim poljima. + Select recipients first to match their fields. Prvo odaberite primatelje da biste uskladili njihova polja. + Select text, then tick who can still edit it. Odaberite tekst, zatim označite tko ga i dalje smije uređivati. + Select\nRecipients Odaberi\nprimatelje + Selected Fields Odabrana polja + Selected pages Odabrane stranice + Selection Odabir + Selection Pane Okno odabira + Send Backward Pomakni natrag + Send the document straight to the system print dialog, or save a copy in another format below. Pošaljite dokument izravno u sustavni dijaloški okvir za ispis ili spremite kopiju u drugom formatu u nastavku. + Sentence Rečenica + Sentence case. Veliko početno slovo rečenice. + Separator: Razdjelnik: + Sequence name: Naziv niza: + Serbian (Cyrillic) Srpski (ćirilica) + Serbian (Latin) Srpski (latinica) + Series name Naziv niza + Series {n} Niz {n} + Set Postavi + Set As Default Postavi kao zadano + Set Caret Postavi kursor + Set Character Formatting Postavi oblikovanje znakova + Set Date Postavi datum + Set Equation Postavi jednadžbu + Set Numbering Value Postavi vrijednost numeriranja + Set Paragraph Formatting Postavi oblikovanje odlomka + Set as Default Postavi kao zadano + Setting: Postavka: + Settings… Postavke… + Shading Sjenčanje + Shadow Sjena + Shadow, glow and soft edges Sjena, sjaj i meki rubovi + Shape Effects Efekti oblika + Shape Fill Ispuna oblika + Shape Format Oblikovanje oblika + Shape Outline Obris oblika + Shape Styles Stilovi oblika + Shapes Oblici + Share Podijeli + Shift+Tab Shift+Tab + Shipping labels Naljepnice za otpremu + Shortcut key: Prečac: + Show Prikaži + Show All Levels Prikaži sve razine + Show Comments Prikaži komentare + Show First Line Only Prikaži samo prvi redak + Show KeyTips (Alt or F10) Prikaži savjete za tipke (Alt ili F10) + Show Level Prikaži razinu + Show Level 1 Prikaži razinu 1 + Show Level 2 Prikaži razinu 2 + Show Level 3 Prikaži razinu 3 + Show Level 4 Prikaži razinu 4 + Show Level 5 Prikaži razinu 5 + Show Level 6 Prikaži razinu 6 + Show Level 7 Prikaži razinu 7 + Show Level 8 Prikaži razinu 8 + Show Level 9 Prikaži razinu 9 + Show Markup Prikaži oznake + Show Notes Prikaži bilješke + Show Text Formatting Prikaži oblikovanje teksta + Show all formatting marks Prikaži sve oznake oblikovanja + Show all revisions inline Prikaži sve revizije u retku + Show changes at: Prikaži promjene na: + Show changes in: Prikaži promjene u: + Show documents with their colours inverted, like View › Switch Modes. Saving, printing and PDFs are unchanged. Prikazuje dokumente s invertiranim bojama, kao Prikaz › Promijeni način. Spremanje, ispis i PDF-ovi ostaju nepromijenjeni. + Show every bibliography field Prikaži sva polja bibliografije + Show in the Styles gallery Prikaži u galeriji stilova + Show only comments and formatting in balloons Prikaži samo komentare i oblikovanje u oblačićima + Show or Hide Style Prikaži ili sakrij stil + Show revisions in balloons Prikaži revizije u oblačićima + Show rulers Prikaži ravnala + Show the Start screen when WordCraft starts Prikaži početni zaslon kad se WordCraft pokrene + Show the community button in the title bar Prikaži gumb zajednice u naslovnoj traci + Show the equation in the line of text Prikaži jednadžbu u retku teksta + Show the equation on a line of its own Prikaži jednadžbu u zasebnom retku + Show the mini toolbar when text is selected Prikaži malu alatnu traku kad je tekst odabran + Show/Hide ¶ Prikaži/sakrij ¶ + Show\nComments Prikaži\nkomentare + Showing the first {count} items. Prikazuje se prvih {count} stavki. + Shows a tree: each item below the item it belongs to. Prikazuje stablo: svaka stavka nalazi se ispod stavke kojoj pripada. + Shows items around a central idea: the first item is the centre. Prikazuje stavke oko središnje ideje: prva stavka je središte. + Shows items of equal weight as blocks in rows. Prikazuje stavke jednake težine kao blokove u retcima. + Shows overlapping ideas as overlapping circles. Prikazuje ideje koje se preklapaju kao krugove koji se preklapaju. + Shows parts of a whole as quadrants. Prikazuje dijelove cjeline kao kvadrante. + Shows parts that build on each other, largest at the bottom. Prikazuje dijelove koji se nadograđuju jedan na drugi, najveći je na dnu. + Shows steps in order, left to right, with arrows between them. Prikazuje korake redom, slijeva nadesno, sa strelicama između njih. + Shows steps that repeat, around a circle. Prikazuje korake koji se ponavljaju, u krugu. + Shrink Font 1 Point Smanji font za 1 točku + Shrink text Smanji tekst + Side to Side S jedne strane na drugu + Side\nto Side S jedne\nstrane na drugu + Sides of a right triangle Stranice pravokutnog trokuta + Signature Line Redak za potpis + Sine Function Funkcija sinus + Sine and cosine series of f(x) Redovi sinusa i kosinusa funkcije f(x) + Sine theta Sinus theta + Single Jednostruko + Single Brackets Jednostruke zagrade + Single Closing Quote Jednostruki završni navodnik + Single Left Angle Jedna lijeva lomljena zagrada + Single Left Bar Jedna lijeva okomita crta + Single Left Brace Jedna lijeva vitičasta zagrada + Single Left Bracket Jedna lijeva uglata zagrada + Single Left Parenthesis Jedna lijeva okrugla zagrada + Single Opening Quote Jednostruki početni navodnik + Single Right Brace Jedna desna vitičasta zagrada + Single Right Bracket Jedna desna uglata zagrada + Single Right Parenthesis Jedna desna okrugla zagrada + Single Spacing Jednostruki razmak + Single label Jedna naljepnica + Site name Naziv web-mjesta + Size Veličina + Size (pt): Veličina (pt): + Size and Crop… Veličina i obrezivanje… + Size: Veličina: + Sketchbook Blok za skice + Skewed Fraction Kosi razlomak + Skip Citations and Bibliography Preskoči citate i bibliografiju + Skip Record If Preskoči zapis ako + Skip Record If… Preskoči zapis ako… + Skip citations & bibliography Preskoči citate i bibliografiju + Slant Down Nagib prema dolje + Slant Up Nagib prema gore + Small Caps Kapitelice + Small caps Kapitelice + Small change Mala promjena + Smart quotes instead of straight quotes Pametni navodnici umjesto ravnih + SmartArt SmartArt + SmartArt Design SmartArt dizajn + SmartArt Styles SmartArt stilovi + Smooth scrolling and zooming, and menus and buttons that fade in. Off shows every change at once. Glatko pomicanje i zumiranje te izbornici i gumbi koji se postupno pojavljuju. Kad je isključeno, svaka se promjena prikazuje odmah. + Soft Edges Meki rubovi + Soft Glow Mekani sjaj + Solid Accent Puni naglasak + Sort Sortiraj + Sort order: Redoslijed sortiranja: + Source Manager Upravitelj izvora + Source code: github.com/storytold/wordcraft Izvorni kôd: github.com/storytold/wordcraft + Source type: Vrsta izvora: + Sources in this document: Izvori u ovom dokumentu: + South Jug + Space Razmak + Space after Razmak nakon + Space after: {pt} pt Razmak nakon: {pt} pt + Space before Razmak prije + Space before/after: Razmak prije/nakon: + Space before: {pt} pt Razmak prije: {pt} pt + Spaces Razmaci + Spacing Razmak + Spacing (pt): Razmak (pt): + Spacing: Razmak: + Spanish (Mexico) Španjolski (Meksiko) + Spanish (Spain) Španjolski (Španjolska) + Sparse Matrices Rijetke matrice + Special Posebno + Special Characters Posebni znakovi + Specify height: Odredi visinu: + Speech Govor + Speed Brzina + Spelling Pravopis + Spelling & Grammar Pravopis i gramatika + Spelling &\nGrammar Pravopis i\ngramatika + Spelling Suggestions Pravopisni prijedlozi + Spelling and grammar are checked in English (United States). Pravopis i gramatika provjeravaju se na engleskom (Sjedinjene Američke Države). + Split Podijeli + Split Cells Podijeli ćelije + Split Table Podijeli tablicu + Spreadsheet Table Proračunska tablica + Square Kvadrat + Square Root Kvadratni korijen + Square Root with Degree Kvadratni korijen sa stupnjem + Square Union Uglata unija + Stack Object Složeni objekt + Stack Object (Three) Složeni objekt (tri) + Stacked Složeno + Stacked Bar Složena traka + Stacked Column Složeni stupac + Stacked Fraction Okomiti razlomak + Stacked, no bar Složeno, bez crte + Standard Standardno + Standard Colors Standardne boje + Star Zvijezda + Start Enforcing Protection Pokreni primjenu zaštite + Start Mail Merge Pokreni spajanje pošte + Start Mail\nMerge Pokreni\nspajanje pošte + Start at: Počni od: + Start-up Pokretanje + State Savezna država + Statistics Statistika + Status Bar Statusna traka + Stop Zaustavi + Stop Protection Zaustavi zaštitu + Stop Reading Prestani čitati + Stopped Zaustavljeno + Straight Arrow Connector Ravni strelasti konektor + Straight Connector Ravni konektor + Strikethrough Precrtano + Stroke {n} of {total} Potez {n} od {total} + Strong Jako isticanje + Structure Actions Radnje strukture + Structures Strukture + Studio Studio + Studio cover Naslovnica studija + Studio handbook (sample) Priručnik studija (uzorak) + Style Stil + Style Inspector Inspektor stilova + Style Set Skup stilova + Style based on: Stil temeljen na: + Style: Stil: + Style: APA Stil: APA + Style: {style} Stil: {style} + Styles Stilovi + Styles Pane Okno stilova + Styles: Stilovi: + Subentry: Podunos: + Subject Predmet + Submitting print job… Slanje zadatka ispisa… + Subscript Donji indeks + Subscript-Superscript Donji i gornji indeks + Subscripts and Superscripts Donji i gornji indeksi + Subset: Podskup: + Subtitle Podnaslov + Subtle Emphasis Suptilno isticanje + Subtle Reference Suptilna referenca + Suggested Predloženo + Sum Suma + Sum of cosines Zbroj kosinusa + Sum or difference of sines Zbroj ili razlika sinusa + Summation Sumiranje + Summation from 0 to n Sumiranje od 0 do n + Summation over binomials Sumiranje po binomima + Summation with Limits Sumiranje s granicama + Summation with Lower Limit Sumiranje s donjom granicom + Summation with Subscript Limit Sumiranje s granicom u donjem indeksu + Summation with Subscript-Superscript Limits Sumiranje s granicama u donjem i gornjem indeksu + Summations Sume + Superscript Gornji indeks + Suppress line numbers Potisni brojeve redaka + Surface Integral Površinski integral + Surface Integral with Limits Površinski integral s granicama + Surface Integral with Stacked Limits Površinski integral s granicama jedna ispod druge + Swap footnotes and endnotes Zamijeni fusnote i bilješke na kraju + Swedish Švedski + Switch Modes Promijeni način + Switch\nModes Promijeni\nnačin + Symbol Simbol + Symbols Simboli + Synonyms Sinonimi + System Sustav + TC entry fields Polja stavki TC + TOA Heading Naslov popisa pravnih izvora + TOC Heading Naslov sadržaja + TOC {n} Sadržaj {n} + Tab Tab + Tab Character Znak tabulatora + Tab character Znak tabulatora + Tab characters Znakovi tabulatora + Tab leader: Vodeća crta tabulatora: + Tab stop position: Položaj tabulatora: + Tab stops to be cleared: Tabulatori za brisanje: + Table Tablica + Table Column Width Širina stupca tablice + Table Design Dizajn tablice + Table Grid Mreža tablice + Table Layout Raspored tablice + Table Properties Svojstva tablice + Table Properties… Svojstva tablice… + Table Row Height Visina retka tablice + Table Style Options Mogućnosti stila tablice + Table Styles Stilovi tablice + Table direction: Smjer tablice: + Table of Authorities Popis pravnih izvora + Table of Contents Sadržaj + Table of Contents Options Mogućnosti sadržaja + Table of Contents Styles Stilovi sadržaja + Table of Figures Popis ilustracija + Table of\nContents Sadržaj + Table style Stil tablice + Tables Tablice + Tabs Tabulatori + Tabs… Tabulatori… + Tabular List Tablični popis + Tag Oznaka + Tangent Function Funkcija tangens + Tangent formula Formula tangensa + Technology Tehnologija + Templates Predlošci + Templates and Add-ins Predlošci i dodaci + Text Tekst + Text Box Tekstni okvir + Text Direction Smjer teksta + Text Effects Efekti teksta + Text Effects and Typography Efekti teksta i tipografija + Text Fill Ispuna teksta + Text Highlight Color Boja isticanja teksta + Text Outline Obris teksta + Text Pane Okno teksta + Text Wrapping Obtok teksta + Text boxes Tekstni okviri + Text from File Tekst iz datoteke + Text indent at: Uvlaka teksta na: + Text over Colon Equal Tekst iznad dvotočke i jednako + Text over Equal Tekst iznad jednako + Text size Veličina teksta + Text snaps to characters Tekst se poravnava prema znakovima + Text to display: Tekst za prikaz: + Text: Tekst: + Text\nBox Tekstni\nokvir + Text\nDirection Smjer\nteksta + That format doesn't keep everything, so the document is still open. Save it as a Word document (.docx) to go on. Taj format ne čuva sve, pa je dokument i dalje otvoren. Spremite ga kao Word dokument (.docx) da biste nastavili. + That password isn't right. Check it (passwords are case-sensitive) and try again. Lozinka nije ispravna. Provjerite je (lozinke razlikuju velika i mala slova) i pokušajte ponovno. + The clipboard is empty. Međuspremnik je prazan. + The date it is today Današnji datum + The document has no bookmarks yet. Dokument još nema knjižnih oznaka. + The document is read only, except for regions marked editable. Dokument je samo za čitanje, osim područja označenih kao urediva. + The document will be saved with a password. Save it to protect it. Dokument će biti spremljen s lozinkom. Spremite ga da biste ga zaštitili. + The new style takes the formatting of the current paragraph. Novi stil preuzima oblikovanje trenutačnog odlomka. + The password is optional; without it anyone can stop the protection. Lozinka nije obavezna; bez nje svatko može zaustaviti zaštitu. + The password is removed. Save the document to keep the change. Lozinka je uklonjena. Spremite dokument da biste zadržali promjenu. + The passwords don't match. Lozinke se ne podudaraju. + The printer, two-sided printing and pages per sheet are chosen in that dialog. Pisač, obostrani ispis i broj stranica po listu odabiru se u tom dijaloškom okviru. + The recipient-list field that fills each part of an address block and greeting line. Polje popisa primatelja koje popunjava svaki dio bloka adrese i retka pozdrava. + The spelling and grammar check is complete. Provjera pravopisa i gramatike je dovršena. + 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. Sustavni dijaloški okvir za datoteke nije dostupan, pa WordCraft prikazuje vlastiti. Da biste dobili sustavni dijaloški okvir, instalirajte portal za odabir datoteka (primjerice xdg-desktop-portal-gtk) ili zenity. + The time right now Trenutačno vrijeme + Theme Colors Boje teme + Theme Fonts Fontovi teme + Theme List Popis tema + Themes Teme + Then insert: Zatim umetni: + Thesaurus Tezaurus + These marks show even when Show/Hide ¶ is off. Ove se oznake prikazuju čak i kad je Prikaži/sakrij ¶ isključeno. + These merge fields aren't in the recipient list: Ova polja za spajanje nisu na popisu primatelja: + These settings will be used for new documents. Ove će se postavke koristiti za nove dokumente. + Thick Debelo + Thickness Debljina + This document Ovaj dokument + This field has no options. Ovo polje nema mogućnosti. + This folder can't be read: {error} Ova se mapa ne može pročitati: {error} + This font has no characters to show. Ovaj font nema znakova za prikaz. + This page Ova stranica + This password type isn't supported. Ova vrsta lozinke nije podržana. + This point forward Od ove točke nadalje + This section Ovaj odjeljak + Three Tri + Through Kroz + Tight Usko + Tilde Tilda + Tips ({n}) Savjeti ({n}) + Title Naslov +preview Title Naslov + Title Bar Naslovna traka + Title from the document properties Naslov iz svojstava dokumenta + Title: Naslov: + 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 biste Word dokumente prema zadanim postavkama otvarali u programu WordCraft: odaberite jedan u Finderu, odaberite Datoteka › Dohvati informacije, pod Otvori pomoću odaberite WordCraft, pa kliknite Promijeni sve. + Today Danas + Toggle Check Box Prebaci potvrdni okvir + Toggle Field Codes Prebaci kodove polja + Toggle Unicode Character Prebaci Unicode znak + Tools Alati + Top Gore + Top Border Gornji obrub + Top Margin Gornja margina + Top and Bottom Gore i dolje + Top of Page Vrh stranice + Top: Gore: + Total Ukupno + Total Row Redak ukupnog zbroja + Track Changes Prati promjene + Track Changes Options Mogućnosti praćenja promjena + Track Changes: On Praćenje promjena: uključeno + Track formatting Prati oblikovanje + Track\nChanges Prati\npromjene + Track\nChanges ✓ Prati\npromjene ✓ + Tracked changes Praćene promjene + Tracking Praćenje + Trademark Žig + Trademark Sign Znak žiga + Transform Transformacija + Translate Prevedi + Transparency Prozirnost + Transport Promet + Triangle Trokut + Triangle Down Trokut prema dolje + Triangle Up Trokut prema gore + Trigonometric Functions Trigonometrijske funkcije + Triple Trostruko + Triple Dot Trostruka točka + Triple Integral Trostruki integral + Triple Integral with Limits Trostruki integral s granicama + Triple Integral with Stacked Limits Trostruki integral s granicama jedna ispod druge + Tue Uto + Turabian Turabian + Turkish Turski + Two Dva + Two-sided printing Obostrani ispis + Two-sided: long edge Obostrano: dulji rub + Two-sided: short edge Obostrano: kraći rub + Type Vrsta + Type +2 or -1 to move from the page you are on. Upišite +2 ili -1 za pomak od stranice na kojoj se nalazite. + Type Text Upiši tekst + Type a New List… Upiši novi popis… + Type a command, e.g. "insert table" Upišite naredbu, npr. „umetni tablicu” + Type at least one recipient. Upišite barem jednog primatelja. + Type equations in LaTeX Upisuj jednadžbe u formatu LaTeX + Type equations in the linear format (UnicodeMath) Upisujte jednadžbe u linearnom formatu (UnicodeMath) + Type in Equation Upisivanje u jednadžbi + Type normal (non-math) text in the equation Upiši običan (nematematički) tekst u jednadžbu + Type the categories down the side and a column of values per series. Upišite kategorije niz stranu i po jedan stupac vrijednosti za svaki niz. + Type the field's name and its switches, for example PAGE \\* roman. Upišite naziv polja i njegove prekidače, na primjer PAGE \\* roman. + Type the password to stop the protection. Upišite lozinku da biste zaustavili zaštitu. + Type the recipients, or load them from a file. Upišite primatelje ili ih učitajte iz datoteke. + Type: Vrsta: + Typing Tipkanje + Typing replaces selected text Tipkanje zamjenjuje odabrani tekst + UPPERCASE VELIKA SLOVA + URGENT HITNO + URL URL + Ukrainian Ukrajinski + Unchecked symbol Simbol neoznačenog okvira + Underbar Crta ispod + Underbrace Vitičasta zagrada ispod + Underline Podcrtano + Underline Words Only Podcrtaj samo riječi + Underline color Boja podcrtavanja + Undo Poništi + Undo 1 Action Poništi 1 radnju + Undo Actions Poništi radnje + Undo History Povijest poništavanja + Undo {action} Poništi: {action} + Undo {n} Actions Poništi radnje: {n} + Unformatted Text Neoblikovani tekst + Ungroup Razgrupiraj + Union Unija + Union of intersections Unija presjeka + Union with Limits Unija s granicama + Union with Lower Limit Unija s donjom granicom + Union with Plus Unija s plusom + Union with Subscript-Superscript Limits Unija s granicama u donjem i gornjem indeksu + Unions and Intersections Unije i presjeci + Unlink Fields Prekini vezu polja + Unlink\nCitations Prekini vezu\ncitata + Unlock Fields Otključaj polja + Up Gore + Up Diagonal Dots Dijagonalne točke prema gore + Up one level Jedna razina gore + Update Field Ažuriraj polje + Update Index Ažuriraj kazalo + Update Style to Match Selection Ažuriraj stil prema odabiru + Update Styles from Template Ažuriraj stilove iz predloška + Update Table Ažuriraj tablicu + Update Table of Figures Ažuriraj popis ilustracija + Update fields before printing Ažuriraj polja prije ispisa + Update to Match Ažuriraj prema + Update to Match Selection Ažuriraj prema odabiru + Use "a" before a consonant sound Koristi „a” ispred suglasničkog glasa + Use "an" before a vowel sound Koristi „an” ispred samoglasničkog glasa + Use an Existing List… Koristi postojeći popis… + Use system setting Koristi postavku sustava + Use the Insert key to switch overtype on and off Koristite tipku Umetni (Insert) za uključivanje i isključivanje prepisivanja + Use wildcards Koristi zamjenske znakove + User Name Korisničko ime + User name: Korisničko ime: + Username Korisničko ime + Value Vrijednost + Vector A Vektor A + Vector AB Vektor AB + Version Verzija + Version History Povijest verzija + Version {version} ({build}) Verzija {version} ({build}) + Vertical Okomito + Vertical Alignment Okomito poravnanje + Vertical Bars Okomite crte + Vertical Dots Okomite točke + Vertical alignment: Okomito poravnanje: + View Prikaz + View Alt Text… Prikaži alternativni tekst… + View Gridlines Prikaži crte mreže + View State Stanje prikaza + Views Prikazi + Visual effects Vizualni efekti + Voice Glas + Volume Glasnoća + Volume Integral Volumni integral + Volume Integral with Limits Volumni integral s granicama + Volume Integral with Stacked Limits Volumni integral s granicama jedna ispod druge + W: Š: + Waiting for Zotero… Čekanje na Zotero… + Warm Gradient with Outline Topli prijelaz s obrisom + Warnings ({n}) Upozorenja ({n}) + Watermark Vodeni žig + Wave Val + Weather Vrijeme + Web Layout Web-izgled + Web page (*.html) Web-stranica (*.html) + Web\nLayout Web-\nizgled + Website Web-mjesto + Wed Sri + Weight Težina + West Zapad + When this document was first created Kada je ovaj dokument prvi put izrađen + When this document was most recently printed Kada je ovaj dokument zadnji put ispisan + When this document was most recently saved Kada je ovaj dokument zadnji put spremljen + White Space Prazan prostor + White space Prazan prostor + Who the document properties name as author Tko je u svojstvima dokumenta naveden kao autor + Whole Table Cijela tablica + Whole document Cijeli dokument + Whole page Cijela stranica + Whole words Cijele riječi + Wide Široko + Widow/Orphan Control Kontrola usamljenih redaka + Widow/Orphan control Kontrola usamljenih redaka + Width and spacing Širina i razmak + Width: Širina: + Wildcards (regex) Zamjenski znakovi (regex) + Window Prozor + With: S: + Word Count Broj riječi + Word Left Riječ ulijevo + Word Right Riječ udesno + Word XML Document (*.xml) Word XML dokument (*.xml) + Word document Word dokumenti + Word document (*.docx) Word dokument (*.docx) + Word level Razina riječi + WordArt WordArt + WordArt Styles WordArt stilovi + WordCraft WordCraft + WordCraft is now the default app for Word documents. WordCraft je sada zadana aplikacija za Word dokumente. + Words Riječi + Words only Samo riječi + Wrap Text Obtok teksta + Wrap Text Sides Strane obtoka teksta + Wrap\nText Obtok\nteksta + Write & Insert Fields Napiši i umetni polja + Write a reply Napišite odgovor + Year Godina + Yes Da + Yields Rezultira + Your changes will be lost if you don't save them. Vaše promjene bit će izgubljene ako ih ne spremite. + Your system doesn't report an animation setting, so animation effects stay on. Vaš sustav ne javlja postavku animacije pa animacijski efekti ostaju uključeni. + Your system has animation effects turned off. Vaš sustav ima isključene animacijske efekte. + Your system has animation effects turned on. Vaš sustav ima uključene animacijske efekte. + Zoom Zumiranje + Zoom In Povećaj + Zoom Out Smanji + Zotero Zotero + Zotero: cancelled Zotero: otkazano + Zotero: done Zotero: gotovo + a, b, c, … a, b, c, … + can't split at the first row nije moguće podijeliti na prvom retku + development build razvojna verzija + from Unicode (hex) iz Unicodea (heksadecimalno) + getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft + i, ii, iii, … i, ii, iii, … + lowercase mala slova + n×n Identity Matrix Jedinična matrica n×n + n×n Matrix Matrica n×n + pt pt + select two or more cells to merge odaberite dvije ili više ćelija za spajanje + tOGGLE cASE zAMIJENI vELIČINU sLOVA + there is no ink to replay nema rukopisa za ponovnu reprodukciju + there's no citation after the caret iza kursora nema citata + this item is already at the top level ova je stavka već na najvišoj razini + x bar x s crtom + {cols}×{rows} Table Tablica {cols}×{rows} + {contributors} contributors · {commits} commits Doprinositelji: {contributors} · promjene: {commits} + {count} items highlighted Istaknute stavke: {count} + {count} of {total} pages will print Stranice za ispis: {count} od {total} + {group} settings Postavke: {group} + {key}+Click to follow link {key}+klik za otvaranje poveznice + {language} is now the document's default language. {language} je sada zadani jezik dokumenta. + {name} already exists. Choose Save again to replace it. {name} već postoji. Ponovno odaberite Spremi da biste ga zamijenili. + {name} is protected with a password. {name} je zaštićen lozinkom. + {name} wasn't found. {name} nije pronađen. + {n} entries marked. Označeni unosi: {n} + {n} lines Retci: {n} + {n} pt {n} pt + {selected} of {words} words Riječi: {selected} od {words} + {size} pt {size} pt + {words} words Riječi: {words} + “{text}” ({row}, {series}) isn't a number. „{text}” ({row}, {series}) nije broj. + ✓ None ✓ Nema + ➕ New comment ➕ Novi komentar + 📂 Browse… 📂 Pregledaj… +# Added while landing (#1271): strings that landed after this catalog + Account Račun + Add Condition Dodaj uvjet + Add whether it is above or below Dodaj nalazi li se iznad ili ispod + All system fonts are already available. Svi fontovi sustava već su dostupni. + And I + Ascending Uzlazno + Auto ({percent}%) Automatski ({percent} %) + Column {n} Stupac {n} + Compare to Usporedi s + Comparison Usporedba + Contains Sadrži + Copy to Kopiraj u + Croatian Hrvatski + Custom size Prilagođena veličina + Delete Bookmark Izbriši knjižnu oznaku + Descending Silazno + Dictionaries Rječnici + Dictionaries folder: Mapa rječnika: + Document name Naziv dokumenta + Does not contain Ne sadrži + Enter bookmark name: Unesite naziv knjižne oznake: + Equal to Jednako + Existing File or Web Page Postojeća datoteka ili web-stranica + Filter Recipients Filtriraj primatelje + Filter and Sort Filtriranje i sortiranje + Find “{text}” in the document Pronađi „{text}” u dokumentu + For which item: Za koju stavku: + Formula: Formula: + Full Screen Cijeli zaslon + Full\nScreen Cijeli\nzaslon + Greater than Veće od + Greater than or equal Veće od ili jednako + Header row Redak zaglavlja + Heading Naslov + Heading text Tekst naslova + Hidden bookmarks Skrivene knjižne oznake + Hunspell .aff and .dic files here (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) are used before the system's. Datoteke Hunspell .aff i .dic iz ove mape (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) koriste se prije onih iz sustava. + In the margin Na margini + Insert reference to: Umetni referencu na: + Is blank Prazno je + Is not blank Nije prazno + Language Preferences… Jezične postavke… + Leave out the date and time of comments and tracked changes when saving Pri spremanju izostavi datum i vrijeme komentara i praćenih promjena + Less than Manje od + Less than or equal Manje od ili jednako + Link to: Poveži s: + Load System Installed Fonts… Učitaj fontove instalirane u sustavu… + Loaded {count} system fonts. Učitano fontova sustava: {count}. + Local font access is not supported by your browser. Vaš preglednik ne podržava pristup lokalnim fontovima. + Local font access was denied. Pristup lokalnim fontovima je odbijen. + Makes the ribbon, menus and dialogs larger or smaller. Auto follows your system's display scaling; 100% is one screen pixel per point. Document zoom is separate. Povećava ili smanjuje vrpcu, izbornike i dijaloške okvire. Automatski prati skaliranje zaslona u sustavu; 100 % je jedan piksel zaslona po točki. Zumiranje dokumenta postavlja se zasebno. + Mirror margins Zrcalne margine + More Paper Sizes… Više veličina papira… + Moved objects snap to the page's edges and centre, the margins and other objects. Hold Alt to move freely. Premješteni objekti prianjaju uz rubove i sredinu stranice, margine i druge objekte. Držite Alt za slobodno premještanje. + My list has Moj popis ima + No document is open. Create a document or open one first. Nijedan dokument nije otvoren. Najprije stvorite ili otvorite dokument. + No header row Bez retka zaglavlja + No spelling dictionary for {language} is installed, so it isn't checked. WordCraft looks for the Hunspell dictionary {name} in the WORDCRAFT_DICTIONARIES folder and in the usual system locations. Pravopisni rječnik za {language} nije instaliran pa se taj jezik ne provjerava. WordCraft traži Hunspell rječnik {name} u mapi WORDCRAFT_DICTIONARIES i na uobičajenim mjestima u sustavu. + Not equal to Nije jednako + Not set: the system's usual places Nije postavljeno: uobičajena mjesta u sustavu + Number only, without the label's words Samo broj, bez riječi oznake + Only for the selected text Samo za odabrani tekst + Or Ili + Paper Papir + Paper size: Veličina papira: + Paste function: Zalijepi funkciju: + Place in This Document Mjesto u ovom dokumentu + Print settings Postavke ispisa + Privacy Options Mogućnosti privatnosti + Privacy Settings Postavke privatnosti + Privacy for this document Privatnost ovog dokumenta + Recover Oporavi + Reference type: Vrsta reference: + Remove condition Ukloni uvjet + Remove my name from comments, changes and file properties when saving Pri spremanju ukloni moje ime iz komentara, promjena i svojstava datoteke + Rename Preimenuj + Rename Document Preimenuj dokument + Saving {name} was cancelled, so it is still open and nothing was lost. Spremanje dokumenta {name} je otkazano pa je on i dalje otvoren i ništa nije izgubljeno. + Set Proofing Language… Postavi jezik provjere… + Show alignment guides when moving pictures and shapes Prikaži vodilice poravnanja pri premještanju slika i oblika + Show its list number Prikaži njegov broj na popisu + Show its number relative to this field Prikaži njegov broj u odnosu na ovo polje + Show its number with all higher levels Prikaži njegov broj sa svim višim razinama + Sort Recipients Sortiraj primatelje + Sort by Sortiraj po + Sort by: Sortiraj po: + Style name: Naziv stila: + Submit print job Pošalji zadatak ispisa + Take the last match on the page Uzmi posljednje podudaranje na stranici + Text of the nearest paragraph with a given style (running headers) Tekst najbližeg odlomka zadanog stila (živa zaglavlja) + The document you are editing keeps the names and dates; only the saved file leaves them out. Dokument koji uređujete zadržava imena i datume; izostavlja ih samo spremljena datoteka. + The margins don't fit on a page this size; make the margins smaller first. Margine ne stanu na stranicu ove veličine; najprije smanjite margine. + Then by Zatim po + This document has no bookmarks. Ovaj dokument nema knjižnih oznaka. + Trust Center Centar za pouzdanost + Type Character Code Upiši kôd znaka + Type a page number, or +2 / -1. Upišite broj stranice ili +2 / -1. + Use the browser's full screen (F11) Koristi cijeli zaslon preglednika (F11) + Wait for the print job to finish submitting before closing. Prije zatvaranja pričekajte da se zadatak ispisa pošalje. + Waiting for the save to finish… Čeka se završetak spremanja… + Word 2003 XML Document (*.xml) Word 2003 XML dokument (*.xml) + {count} of {total} recipients will be merged. Spojit će se {count} od {total} primatelja. + {kind}, {columns} × {rows}, {size} ({paper}) {kind}, {columns} × {rows}, {size} ({paper}) + {language} (no dictionary) {language} (nema rječnika) + {language}: no dictionary found {language}: rječnik nije pronađen + {language}: {file} {language}: {file} + {name} {height} × {width} in {name} {height} × {width} in + {name} {height} × {width} mm {name} {height} × {width} mm + {width} × {height} mm {width} × {height} mm +# Paper sizes the other catalogs still carry + 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" Omotnica #10 4.12" × 9.50" + Envelope DL 4.33" × 8.66" Omotnica DL 4.33" × 8.66" + Executive 7.25" × 10.50" Executive 7.25" × 10.50" + Legal 8.50" × 14.00" Legal 8.50" × 14.00" + Letter 8.50" × 11.00" Letter 8.50" × 11.00" + Statement 5.50" × 8.50" Statement 5.50" × 8.50" + Tabloid 11.00" × 17.00" Tabloid 11.00" × 17.00" diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 10b2c063a..664d12056 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; 22] = [ +pub static LANGUAGES: [LangInfo; 23] = [ 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 { @@ -156,6 +156,8 @@ pub static LANGUAGES: [LangInfo; 22] = [ LangInfo { code: "vi", name: "Tiếng Việt", source: include_str!("vi.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, // Swedish; sv, sv-SE, sv-FI and sv_SE.UTF-8 resolve here. LangInfo { code: "sv", name: "Svenska", source: include_str!("sv.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, + // Croatian; `hr`, `hr-HR` and `hr_HR.UTF-8` resolve here. + LangInfo { code: "hr", name: "Hrvatski", source: include_str!("hr.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 90cf9ed9a..3890ee9f5 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -989,9 +989,7 @@ fn polish_covers_every_key_of_every_other_catalog() { let polish = keys(lang("pl").0.source); // Italian and Hungarian still carry "Style: APA", which the interface no longer shows; // Hungarian and Bulgarian also name the citation styles, and Bulgarian has a few one-line - // pieces of gallery labels, which Swedish carries too. Polish matches every other catalog, - // including the style names and equation gallery Russian translates. - for other in Lang::all().filter(|l| *l != Lang::EN && !matches!(l.code(), "pl" | "it" | "hu" | "bg" | "vi" | "sv")) { + for other in Lang::all().filter(|l| *l != Lang::EN && !matches!(l.code(), "pl" | "it" | "hu" | "bg" | "vi" | "sv" | "hr")) { let mut missing: Vec = keys(other.0.source).difference(&polish).cloned().collect(); missing.sort(); assert!(missing.is_empty(), "pl lacks {}'s {missing:?}", other.code()); @@ -1277,6 +1275,91 @@ fn bundled_interface_fonts_cover_bulgarian_without_system_fallbacks() { assert!(!lang("bg").uses_cjk()); } +#[test] +fn croatian_locales_and_saved_preference_work_without_changing_the_document() { + let hr = lang("hr"); + for tag in ["hr", "hr-HR", "hr_HR.UTF-8", "HR-hr", "hr-Latn-HR", "hr-BA"] { + assert_eq!(lang_from_tag(tag), Some(hr), "{tag}"); + } + assert_eq!(first_supported([UNSUPPORTED, "hr-HR", "en-US"]), Some(hr)); + 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": "HR"})).unwrap(); + assert_eq!(result["effective"], "hr"); + assert_eq!(app.ui.language, "hr"); + assert!(result["available"].as_array().unwrap().iter().any(|l| l["code"] == "hr" && l["name"] == "Hrvatski")); + 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), hr); + assert_eq!(hr.name(), "Hrvatski"); +} + +#[test] +fn croatian_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 hr = lang("hr"); + // Every key of every other catalog, and no catalog errors. + for other in Lang::all().filter(|l| *l != Lang::EN && l.code() != "hr") { + let mut missing: Vec = keys(other.0.source).difference(&keys(hr.0.source)).cloned().collect(); + missing.sort(); + // Italian and Hungarian still carry "Style: APA", which the interface no longer shows. + missing.retain(|k| k != "Style: APA" || !matches!(other.code(), "it" | "hu")); + assert!(missing.is_empty(), "hr lacks {}'s {missing:?}", other.code()); + } + assert!(parse_entries(hr.0.source).1.is_empty()); + for (source, expected) in [ + ("File", "Datoteka"), + ("Home", "Početna"), + ("Insert", "Umetni"), + ("Review", "Pregled"), + ("Save", "Spremi"), + ("Heading 1", "Naslov 1"), + ("Spelling & Grammar", "Pravopis i gramatika"), + ("Hyphenation", "Rastavljanje riječi"), + ("WordCraft", "WordCraft"), + ("unknown future label", "unknown future label"), + ] { + assert_eq!(tr(hr, source), expected, "{source}"); + } + assert_eq!(fmt(tr(hr, "Page {page} of {pages}"), &[("page", "3"), ("pages", "9")]), "Stranica 3 od 9"); + // The letter template is "Pismo"; the paper size of the same English name stays "Letter". + assert_eq!(trc(hr, "template", "Letter"), "Pismo"); + assert_eq!(tr(hr, "Letter"), "Letter"); + // Croatian nouns take three forms after numerals (1, 2–4, 5+), so counts read `Riječi: 21`. + for count in [0, 1, 2, 4, 5, 12, 14, 21, 22, 25, 101, 1000] { + let words = count.to_string(); + assert_eq!(fmt(tr(hr, "{words} words"), &[("words", &words)]), format!("Riječi: {count}")); + assert_eq!(fmt(tr(hr, "{selected} of {words} words"), &[("selected", "1"), ("words", &words)]), format!("Riječi: 1 od {count}")); + } + set_current(hr); + assert_eq!(location("Review › Proofing"), "Pregled › Jezična provjera"); + set_current(Lang::EN); +} + +#[test] +fn bundled_interface_fonts_cover_croatian_without_system_fallbacks() { + let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new()); + let (entries, errors) = parse_entries(lang("hr").0.source); + assert!(errors.is_empty()); + let chars: std::collections::HashSet = entries + .iter() + .flat_map(|e| e.translation.chars()) + // Croatian letters plus its quotes and dashes; symbols come from the English sources. + .filter(|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("hr").uses_cjk()); +} + #[test] fn swedish_locales_and_saved_preference_work_without_changing_the_document() { let sv = lang("sv"); diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index b1263735f..8a8b8000a 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2656,3 +2656,5 @@ This document has no bookmarks. Tài liệu này không có dấu trang. Type a page number, or +2 / -1. Nhập số trang, hoặc +2 / -1. Wait for the print job to finish submitting before closing. Hãy chờ lệnh in được gửi xong rồi mới đóng. +# Template names in File › New (#1271) +template Letter Thư diff --git a/docs/localization-parity.md b/docs/localization-parity.md index bdebdeaef..60949a18c 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -19,8 +19,8 @@ native-speaker review (human). ## What WordCraft ships (measured) -- **22 interface languages**: English plus `zh-hans`, `zh-hant`, `ja`, Korean `ko` (#1216), `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`, Bulgarian `bg`, Vietnamese `vi` (#1434) and Swedish `sv` (#1219). Catalogs have 2,061–2,504 entries +- **23 interface languages**: English plus `zh-hans`, `zh-hant`, `ja`, Korean `ko` (#1216), `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`, Bulgarian `bg`, Vietnamese `vi` (#1434), Swedish `sv` (#1219) and Croatian `hr` (#1271). Catalogs have 2,061–2,504 entries each; Polish has every key of every other catalog, including the built-in style names and the equation gallery, except the citation style names only Hungarian, Bulgarian and Swedish 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. @@ -59,12 +59,13 @@ shared items below. | Korean | ko | 2,463 entries (all catalog keys and preview context) | most; newer panes English | Hangul ✅ (Korean-first embedded/system UI font fallback); macOS Korean IME fixes pending (#163, #164) | no | partial | 3–5 | | Vietnamese | vi | 2,448 entries; every key of the reference catalogs (#1434); 98% of extracted, ~95% of visible | most | Latin ✅ | no | partial | 0–1 (review) | -Other shipped languages: **15** — 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`), Bulgarian (`bg`), Vietnamese (`vi`, #1434) and Swedish (`sv`, #1219), all partial. +Other shipped languages: **16** — 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`), Bulgarian (`bg`), Vietnamese (`vi`, #1434), Swedish (`sv`, #1219) and Croatian (`hr`, #1271), all partial. Swedish (`sv-SE` and `sv-FI` system locales, `{"value":"sv"}`) has 2,504 entries. Counts use count-neutral wording (`{words} ord`: the noun is the same in the singular and the plural). The catalog is an AI-assisted draft whose terminology and running app were reviewed by a native speaker. Swedish proofing is not included; terminology: [`localization-sv.md`](localization-sv.md). +Croatian (`hr-HR` system locales, `{"value":"hr"}`) has 2,507 entries: every key of the reference catalog, built-in style names and the equation gallery. Counts use count-neutral wording (`Riječi: 21`) because Croatian numerals take three noun forms. The catalog was written and checked by a native speaker; Croatian proofing is a separate change. 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. @@ -215,6 +216,7 @@ WordCraft keeps 2,54 cm). | 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-13 | minor | Croatian interface catalog (2,507 entries, built-in style names shown translated), `hr-*` system locales, saved preference and font coverage checks; the template name "Letter" has its own `template` context so the paper size keeps its name; no Croatian proofing | | 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 e45c0159e8d20e1685255003fc92b001ef30e2ca Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 15 Oct 2026 00:11:52 -0400 Subject: [PATCH 081/101] Ita Translate (#1040) Italian Reading Mode column widths from #1040 by @Nai-ITA, and the same strings in every catalog. Co-authored-by: Nai-ITA --- crates/ui-egui/src/i18n/bg.tsv | 3 +++ crates/ui-egui/src/i18n/cs.tsv | 3 +++ crates/ui-egui/src/i18n/de.tsv | 3 +++ crates/ui-egui/src/i18n/es.tsv | 3 +++ crates/ui-egui/src/i18n/et.tsv | 3 +++ crates/ui-egui/src/i18n/fr.tsv | 3 +++ crates/ui-egui/src/i18n/hr.tsv | 3 +++ crates/ui-egui/src/i18n/hu.tsv | 3 +++ crates/ui-egui/src/i18n/it.tsv | 3 +++ crates/ui-egui/src/i18n/ja.tsv | 3 +++ crates/ui-egui/src/i18n/ko.tsv | 3 +++ crates/ui-egui/src/i18n/pl.tsv | 3 +++ crates/ui-egui/src/i18n/pt-br.tsv | 3 +++ crates/ui-egui/src/i18n/ru.tsv | 3 +++ crates/ui-egui/src/i18n/sr-latn.tsv | 3 +++ crates/ui-egui/src/i18n/sr.tsv | 3 +++ crates/ui-egui/src/i18n/sv.tsv | 3 +++ crates/ui-egui/src/i18n/tr.tsv | 3 +++ crates/ui-egui/src/i18n/uk.tsv | 3 +++ crates/ui-egui/src/i18n/vi.tsv | 3 +++ crates/ui-egui/src/i18n/zh-hans.tsv | 3 +++ crates/ui-egui/src/i18n/zh-hant.tsv | 3 +++ 22 files changed, 66 insertions(+) diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 3f228634b..44db11ee3 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2607,3 +2607,6 @@ # Table Layout › Formula dialog (#1210) Formula: Формула: Paste function: Поставяне на функция: +# Reading Mode column widths (#1040) + Narrow Тясна + Medium Средна diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 79e09a03b..a188d52e5 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2611,3 +2611,6 @@ preview Title Ukázka # Table Layout › Formula dialog (#1210) Formula: Vzorec: Paste function: Vložit funkci: +# Reading Mode column widths (#1040) + Narrow Úzká + Medium Střední diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index f15c032fc..e8d72f820 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2219,3 +2219,6 @@ # Table Layout › Formula dialog (#1210) Formula: Formel: Paste function: Funktion einfügen: +# Reading Mode column widths (#1040) + Narrow Schmal + Medium Mittel diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 0e9dfb604..b9cd39cba 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2586,3 +2586,6 @@ preview Title Título # Table Layout › Formula dialog (#1210) Formula: Fórmula: Paste function: Pegar función: +# Reading Mode column widths (#1040) + Narrow Estrecha + Medium Media diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index d68f1f917..1ad0dd1d2 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -2254,3 +2254,6 @@ # Table Layout › Formula dialog (#1210) Formula: Valem: Paste function: Kleebi funktsioon: +# Reading Mode column widths (#1040) + Narrow Kitsas + Medium Keskmine diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 80b8603e1..6c7144a8e 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2227,3 +2227,6 @@ # Table Layout › Formula dialog (#1210) Formula: Formule : Paste function: Coller une fonction : +# Reading Mode column widths (#1040) + Narrow Étroite + Medium Moyenne diff --git a/crates/ui-egui/src/i18n/hr.tsv b/crates/ui-egui/src/i18n/hr.tsv index eb07d4f80..85079968f 100644 --- a/crates/ui-egui/src/i18n/hr.tsv +++ b/crates/ui-egui/src/i18n/hr.tsv @@ -2618,3 +2618,6 @@ preview Title Naslov Letter 8.50" × 11.00" Letter 8.50" × 11.00" Statement 5.50" × 8.50" Statement 5.50" × 8.50" Tabloid 11.00" × 17.00" Tabloid 11.00" × 17.00" +# Reading Mode column widths (#1040) + Narrow Usko + Medium Srednje diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 0b4287e90..49cb566cd 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2615,3 +2615,6 @@ # Table Layout › Formula dialog (#1210) Formula: Képlet: Paste function: Függvény beillesztése: +# Reading Mode column widths (#1040) + Narrow Keskeny + Medium Közepes diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 51f35a5c3..490b64702 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2628,3 +2628,6 @@ preview Title Esempio # Table Layout › Formula dialog (#1210) Formula: Formula: Paste function: Incolla funzione: +# Reading Mode column widths (#1040) + Narrow Stretta + Medium Media diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 916be38ac..d601a9c2e 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2220,3 +2220,6 @@ # Table Layout › Formula dialog (#1210) Formula: 計算式: Paste function: 関数の貼り付け: +# Reading Mode column widths (#1040) + Narrow 狭い + Medium 中 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 47c2baa99..099c620a0 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2474,3 +2474,6 @@ preview Title 제목 My list has 목록 첫 행 Formula: 수식: Paste function: 함수 붙여넣기: +# Reading Mode column widths (#1040) + Narrow 좁게 + Medium 보통 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index c73037abb..4c5422b85 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2609,3 +2609,6 @@ preview Title Tytuł # Table Layout › Formula dialog (#1210) Formula: Formuła: Paste function: Wklej funkcję: +# Reading Mode column widths (#1040) + Narrow Wąska + Medium Średnia diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 3ab3cb4fa..7be7ed4c6 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2232,3 +2232,6 @@ # Table Layout › Formula dialog (#1210) Formula: Fórmula: Paste function: Colar função: +# Reading Mode column widths (#1040) + Narrow Estreita + Medium Média diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 2ed4211c6..09d781dd8 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2617,3 +2617,6 @@ preview Title Пример # Table Layout › Formula dialog (#1210) Formula: Формула: Paste function: Вставить функцию: +# Reading Mode column widths (#1040) + Narrow Узкая + Medium Средняя diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 7112a6448..b34068a80 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -2230,3 +2230,6 @@ # Table Layout › Formula dialog (#1210) Formula: Formula: Paste function: Nalepi funkciju: +# Reading Mode column widths (#1040) + Narrow Uska + Medium Srednja diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 3177ee59f..9260518aa 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2234,3 +2234,6 @@ # Table Layout › Formula dialog (#1210) Formula: Формула: Paste function: Налепи функцију: +# Reading Mode column widths (#1040) + Narrow Уска + Medium Средња diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index b907ececb..285a9c5d8 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2633,3 +2633,6 @@ preview Title Titel Heading text Rubriktext Insert reference to: Infoga referens till: Reference type: Referenstyp: +# Reading Mode column widths (#1040) + Narrow Smal + Medium Medel diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 749ebcd6a..4a2d30fd0 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2616,3 +2616,6 @@ preview Title Örnek # Table Layout › Formula dialog (#1210) Formula: Formül: Paste function: İşlev yapıştır: +# Reading Mode column widths (#1040) + Narrow Dar + Medium Orta diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index d5709fee2..ff8cf5d00 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2232,3 +2232,6 @@ # Table Layout › Formula dialog (#1210) Formula: Формула: Paste function: Вставити функцію: +# Reading Mode column widths (#1040) + Narrow Вузька + Medium Середня diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 8a8b8000a..fdabaa106 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2658,3 +2658,6 @@ Wait for the print job to finish submitting before closing. Hãy chờ lệnh in được gửi xong rồi mới đóng. # Template names in File › New (#1271) template Letter Thư +# Reading Mode column widths (#1040) + Narrow Hẹp + Medium Trung bình diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 535af920c..05c1c0ba9 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2232,3 +2232,6 @@ # Table Layout › Formula dialog (#1210) Formula: 公式: Paste function: 粘贴函数: +# Reading Mode column widths (#1040) + Narrow 窄 + Medium 中等 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 246e63c6a..86ee8b872 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2221,3 +2221,6 @@ # Table Layout › Formula dialog (#1210) Formula: 公式: Paste function: 貼上函數: +# Reading Mode column widths (#1040) + Narrow 窄 + Medium 中等 From f43928324f601948aeaca6517b7929554783d1f8 Mon Sep 17 00:00:00 2001 From: Andrej Skvorc <47588613+andrejskvorc@users.noreply.github.com> Date: Thu, 15 Oct 2026 06:15:29 +0200 Subject: [PATCH 082/101] Margins and page sizes: follow the measurement unit and keep translated names (#1274) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Margins gallery: Normal and Narrow follow the measurement unit The two uniform margin presets were written as `Normal 1" all` and `Narrow 0.5" all` whatever unit the user chose, so a centimetre or millimetre setting still showed inches. They now take the size from the measurement unit, like the paper-size menu next to them (`Normal 2,54 cm all`). The catalog keys become `Normal {size} all` and `Narrow {size} all`; every catalog carries the same translation with its number replaced by `{size}`, so no wording changes. Co-Authored-By: Claude Sonnet 5.5 * Paper sizes: translate the name, show the size in the measurement unit The Size menu built each entry as "{name} {w} × {h}" in the measurement unit and then looked that whole text up in the catalog, so with centimetres or millimetres no entry matched and the envelope names stayed English. The name is now looked up on its own (`Envelope #10`, `Envelope DL`); the catalogs that translated the old inch labels carry the same wording without the size. Co-Authored-By: Claude Sonnet 5.5 --------- Co-authored-by: Claude Sonnet 5.5 Co-authored-by: Brandon Thomas --- crates/ui-egui/src/i18n/bg.tsv | 4 ++-- 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/fr.tsv | 4 ++-- crates/ui-egui/src/i18n/hr.tsv | 4 ++-- crates/ui-egui/src/i18n/hu.tsv | 4 ++-- crates/ui-egui/src/i18n/it.tsv | 4 ++-- crates/ui-egui/src/i18n/ja.tsv | 4 ++-- crates/ui-egui/src/i18n/ko.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/sv.tsv | 4 ++-- crates/ui-egui/src/i18n/tr.tsv | 4 ++-- crates/ui-egui/src/i18n/uk.tsv | 4 ++-- crates/ui-egui/src/i18n/vi.tsv | 4 ++-- crates/ui-egui/src/i18n/zh-hans.tsv | 4 ++-- crates/ui-egui/src/i18n/zh-hant.tsv | 4 ++-- crates/ui-egui/src/ribbon.rs | 21 +++++++++++++-------- 23 files changed, 57 insertions(+), 52 deletions(-) diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 44db11ee3..7dcb27f7a 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -517,7 +517,7 @@ Multiple Pages Няколко страници Name (A–Z) Име (А–Я) Name: Име: - Narrow 0.5" all Тесни 0.5" навсякъде + Narrow {size} all Тесни {size} навсякъде Navigation Навигация Navigation Pane Навигационен панел New Нов @@ -545,7 +545,7 @@ Nonbreaking Hyphen Непрекъсваемо тире Nonbreaking Space Непрекъсваем интервал None Няма - Normal 1" all Нормални 1" навсякъде + Normal {size} all Нормални {size} навсякъде Normal Style Нормален стил Not available right now В момента не е налично Not saved Не е запазено diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index a188d52e5..d118ac5e8 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -715,7 +715,7 @@ 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 + Narrow {size} all Úzké {size} všude Navigation Navigace Navigation Pane Navigační podokno New Nový @@ -757,7 +757,7 @@ Nonbreaking Space Nezlomitelná mezera None Žádné Normal Normální - Normal 1" all Normální 1" všude + Normal {size} all Normální {size} všude Normal Indent Normální s odsazením Normal speed Normální rychlost Normal Style Normální styl diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index e8d72f820..2c583170a 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -513,7 +513,7 @@ Multiple Pages Mehrere Seiten Name (A–Z) Name (A–Z) Name: Name: - Narrow 0.5" all Schmal 0,5" rundum + Narrow {size} all Schmal {size} rundum Navigation Navigation Navigation Pane Navigationsbereich New Neu @@ -540,7 +540,7 @@ Nonbreaking Hyphen Geschützter Bindestrich Nonbreaking Space Geschütztes Leerzeichen None Keine - Normal 1" all Normal 1" rundum + Normal {size} all Normal {size} rundum Normal Style Formatvorlage „Standard“ Not available right now Derzeit nicht verfügbar Not saved Nicht gespeichert diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index b9cd39cba..ab187a76e 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -585,7 +585,7 @@ Multiple Pages Varias páginas Name (A–Z) Nombre (A–Z) Name: Nombre: - Narrow 0.5" all Estrecho 0,5" en todos + Narrow {size} all Estrecho {size} en todos Navigation Navegación Navigation Pane Panel de navegación New Nuevo @@ -614,7 +614,7 @@ Nonbreaking Hyphen Guion no separable Nonbreaking Space Espacio de no separación None Ninguno - Normal 1" all Normal 1" en todos + Normal {size} all Normal {size} en todos Normal Style Estilo normal Not available right now No disponible por ahora Not saved Sin guardar diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 1ad0dd1d2..7f0fa3036 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -462,7 +462,7 @@ Multiple Pages Mitu lehte Name (A–Z) Nimi (A–Z) Name: Nimi: - Narrow 0.5" all Kitsas 0,5" kõigil külgedel + Narrow {size} all Kitsas {size} kõigil külgedel Navigation Navigeerimine Navigation Pane Navigeerimispaan New Uus @@ -495,7 +495,7 @@ Nonbreaking Hyphen Poolitamatu sidekriips Nonbreaking Space Poolitamatu tühik None Puudub - Normal 1" all Tavaline 1" kõigil külgedel + Normal {size} all Tavaline {size} kõigil külgedel Normal Style Tavaline stiil Not available right now Praegu pole saadaval Not saved Salvestamata diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 6c7144a8e..396c63f5c 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -527,7 +527,7 @@ Multiple Pages Plusieurs pages Name (A–Z) Nom (A–Z) Name: Nom : - Narrow 0.5" all Étroites — 0,5" partout + Narrow {size} all Étroites — {size} partout Navigation Navigation Navigation Pane Volet de navigation New Nouveau @@ -554,7 +554,7 @@ Nonbreaking Hyphen Tiret insécable Nonbreaking Space Espace insécable None Aucun - Normal 1" all Normales — 1" partout + Normal {size} all Normales — {size} partout Normal Style Style Normal Not available right now Indisponible pour le moment Not saved Non enregistré diff --git a/crates/ui-egui/src/i18n/hr.tsv b/crates/ui-egui/src/i18n/hr.tsv index 85079968f..a15662410 100644 --- a/crates/ui-egui/src/i18n/hr.tsv +++ b/crates/ui-egui/src/i18n/hr.tsv @@ -1370,7 +1370,7 @@ template Letter Pismo Name (A–Z) Naziv (A–Ž) Name of the file this document is saved as Naziv datoteke u koju je dokument spremljen Name: Naziv: - Narrow 0.5" all Usko 0,5" sa svih strana + Narrow {size} all Usko {size} sa svih strana Natural Logarithm Prirodni logaritam Nature Priroda Navigation Navigacija @@ -1439,7 +1439,7 @@ template Letter Pismo Nonbreaking Space Razmak bez prijeloma None Nema Normal Uobičajeni - Normal 1" all Normalno 1" sa svih strana + Normal {size} all Normalno {size} sa svih strana Normal Indent Normalna uvlaka Normal Style Normalan stil Normal Table Normalna tablica diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 49cb566cd..101d08e13 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -443,7 +443,7 @@ Multiple Pages Több oldal Name (A–Z) Név (A–Z) Name: Név: - Narrow 0.5" all Keskeny 0,5" mindenhol + Narrow {size} all Keskeny {size} mindenhol Navigation Navigáció Navigation Pane Navigációs panel New Új @@ -472,7 +472,7 @@ Nonbreaking Space Nem törő szóköz None Nincs Normal Normál - Normal 1" all Normál 1" mindenhol + Normal {size} all Normál {size} mindenhol Normal Indent Normál behúzás Normal Style Normál stílus Normal Table Normál táblázat diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 490b64702..dd516a0ba 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -513,7 +513,7 @@ Multiple Pages Più pagine Name (A–Z) Nome (A–Z) Name: Nome: - Narrow 0.5" all Stretto 0,5" ovunque + Narrow {size} all Stretto {size} ovunque Navigation Navigazione Navigation Pane Riquadro di spostamento New Nuovo @@ -541,7 +541,7 @@ Nonbreaking Hyphen Trattino unificatore Nonbreaking Space Spazio unificatore None Nessuno - Normal 1" all Normale 1" ovunque + Normal {size} all Normale {size} ovunque Normal Style Stile normale Not available right now Non disponibile al momento Not saved Non salvato diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index d601a9c2e..e002a731e 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -570,7 +570,7 @@ Multiple Pages 複数ページ Name (A–Z) 名前(A–Z) Name: 名前: - Narrow 0.5" all 狭い 上下左右 0.5" + Narrow {size} all 狭い 上下左右 {size} Navigation ナビゲーション Navigation Pane ナビゲーション ウィンドウ New 新規 @@ -597,7 +597,7 @@ Nonbreaking Hyphen 改行なしハイフン Nonbreaking Space 改行なしスペース None なし - Normal 1" all 標準 上下左右 1" + Normal {size} all 標準 上下左右 {size} Normal Style 標準スタイル Not available right now 現在は使用できません Not saved 未保存 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 099c620a0..27fdcb428 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -564,7 +564,7 @@ Multiple Pages 여러 페이지 Name (A–Z) 이름순(A–Z) Name: 이름: - Narrow 0.5" all 좁게(모든 여백 0.5") + Narrow {size} all 좁게(모든 여백 {size}) Navigation 탐색 Navigation Pane 탐색 창 New 새로 만들기 @@ -591,7 +591,7 @@ Nonbreaking Hyphen 줄 바꿈하지 않는 하이픈 Nonbreaking Space 줄 바꿈하지 않는 공백 None 없음 - Normal 1" all 보통(모든 여백 1") + Normal {size} all 보통(모든 여백 {size}) Normal Style 표준 스타일 Not available right now 현재 사용할 수 없음 Not saved 저장되지 않음 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 4c5422b85..50ed83ac3 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -434,7 +434,7 @@ Multiple Pages Wiele stron Name (A–Z) Nazwa (A–Z) Name: Nazwa: - Narrow 0.5" all Wąskie wszystkie 0,5" + Narrow {size} all Wąskie wszystkie {size} Navigation Nawigacja Navigation Pane Okienko nawigacji New Nowy @@ -463,7 +463,7 @@ Nonbreaking Hyphen Łącznik nierozdzielający Nonbreaking Space Spacja nierozdzielająca None Brak - Normal 1" all Normalne wszystkie 1" + Normal {size} all Normalne wszystkie {size} Normal Style Styl Normalny Not available right now W tej chwili niedostępne Not saved Niezapisany diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 7be7ed4c6..689d66239 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -571,7 +571,7 @@ Multiple Pages Várias páginas Name (A–Z) Nome (A–Z) Name: Nome: - Narrow 0.5" all Estreita 0,5" em todas as margens + Narrow {size} all Estreita {size} em todas as margens Navigation Navegação Navigation Pane Painel de navegação New Novo @@ -598,7 +598,7 @@ Nonbreaking Hyphen Hífen inseparável Nonbreaking Space Espaço inseparável None Nenhuma - Normal 1" all Normal 1" em todas as margens + Normal {size} all Normal {size} em todas as margens Normal Style Estilo Normal Not available right now Indisponível no momento Not saved Não salvo diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 09d781dd8..9ff70bd02 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -445,7 +445,7 @@ Multiple Pages Несколько страниц Name (A–Z) Имя (А–Я) Name: Имя: - Narrow 0.5" all Узкие 0,5" со всех сторон + Narrow {size} all Узкие {size} со всех сторон Navigation Навигация Navigation Pane Область навигации New Создать @@ -474,7 +474,7 @@ Nonbreaking Space Неразрывный пробел None Нет Normal Обычный - Normal 1" all Обычные 1" со всех сторон + Normal {size} all Обычные {size} со всех сторон Normal Indent Обычный отступ Normal Style Обычный стиль Normal Table Обычная таблица diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index b34068a80..8c8128909 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -618,7 +618,7 @@ Multiple Pages Više strana Name (A–Z) Naziv (A–Š) Name: Naziv: - Narrow 0.5" all Uske 0,5" svuda + Narrow {size} all Uske {size} svuda Navigation Navigacija Navigation Pane Okno za navigaciju New Novo @@ -645,7 +645,7 @@ Nonbreaking Hyphen Nedeljiva crtica Nonbreaking Space Nedeljivi razmak None Nijedno - Normal 1" all Normalne 1" svuda + Normal {size} all Normalne {size} svuda Normal Style Normalan stil Not available right now Trenutno nedostupno Not saved Nije sačuvano diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 9260518aa..64b920410 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -622,7 +622,7 @@ Multiple Pages Више страна Name (A–Z) Назив (А–Ш) Name: Назив: - Narrow 0.5" all Уске 0,5" свуда + Narrow {size} all Уске {size} свуда Navigation Навигација Navigation Pane Окно за навигацију New Ново @@ -649,7 +649,7 @@ Nonbreaking Hyphen Недељива цртица Nonbreaking Space Недељиви размак None Ниједно - Normal 1" all Нормалне 1" свуда + Normal {size} all Нормалне {size} свуда Normal Style Нормалан стил Not available right now Тренутно недоступно Not saved Није сачувано diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 285a9c5d8..fe6f14673 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -576,7 +576,7 @@ Multiple Pages Flera sidor Name (A–Z) Namn (A–Ö) Name: Namn: - Narrow 0.5" all Smala 0,5" överallt + Narrow {size} all Smala {size} överallt Navigation Navigering Navigation Pane Navigeringspanel New Ny @@ -605,7 +605,7 @@ Nonbreaking Hyphen Hårt bindestreck Nonbreaking Space Hårt blanksteg None Ingen - Normal 1" all Normala 1" överallt + Normal {size} all Normala {size} överallt Normal Style Stilen Normal Not available right now Inte tillgängligt just nu Not saved Inte sparat diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 4a2d30fd0..c3fef9852 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -443,7 +443,7 @@ Multiple Pages Birden Çok Sayfa Name (A–Z) Ad (A–Z) Name: Ad: - Narrow 0.5" all Dar her kenar 0,5 inç + Narrow {size} all Dar her kenar {size} Navigation Gezinti Navigation Pane Gezinti Bölmesi New Yeni @@ -472,7 +472,7 @@ Nonbreaking Space Bölünemez Boşluk None Yok Normal Normal - Normal 1" all Normal her kenar 1 inç + Normal {size} all Normal her kenar {size} Normal Indent Normal Girintili Normal Style Normal Stil Normal Table Normal Tablo diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index ff8cf5d00..0f80f53ab 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -585,7 +585,7 @@ Multiple Pages Кілька сторінок Name (A–Z) Назва (А–Я) Name: Назва: - Narrow 0.5" all Вузькі — усі по 0,5″ + Narrow {size} all Вузькі — усі по {size} Navigation Навігація Navigation Pane Панель навігації New Новий @@ -612,7 +612,7 @@ Nonbreaking Hyphen Нерозривний дефіс Nonbreaking Space Нерозривний пробіл None Немає - Normal 1" all Звичайні — усі по 1″ + Normal {size} all Звичайні — усі по {size} Normal Style Звичайний стиль Not available right now Зараз недоступно Not saved Не збережено diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index fdabaa106..0e54b32dd 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -509,7 +509,7 @@ Multiple Pages Nhiều trang Name (A–Z) Tên (A–Z) Name: Tên: - Narrow 0.5" all Hẹp 0,5" mọi cạnh + Narrow {size} all Hẹp {size} mọi cạnh Navigation Điều hướng Navigation Pane Ngăn điều hướng New Mới @@ -537,7 +537,7 @@ Nonbreaking Hyphen Gạch nối không ngắt dòng Nonbreaking Space Khoảng trắng không ngắt dòng None Không có - Normal 1" all Thường 1" mọi cạnh + Normal {size} all Thường {size} mọi cạnh Normal Style Kiểu Thường Not available right now Hiện không khả dụng Not saved Chưa lưu diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 05c1c0ba9..bdc508598 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -582,7 +582,7 @@ Multiple Pages 多页 Name (A–Z) 名称(A–Z) Name: 名称: - Narrow 0.5" all 窄 四周 0.5" + Narrow {size} all 窄 四周 {size} Navigation 导航 Navigation Pane 导航窗格 New 新建 @@ -609,7 +609,7 @@ Nonbreaking Hyphen 不间断连字符 Nonbreaking Space 不间断空格 None 无 - Normal 1" all 常规 四周 1" + Normal {size} all 常规 四周 {size} Normal Style 正文样式 Not available right now 当前不可用 Not saved 未保存 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 86ee8b872..db17f4a83 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -571,7 +571,7 @@ Multiple Pages 多頁 Name (A–Z) 名稱(A–Z) Name: 名稱: - Narrow 0.5" all 窄 四周 0.5" + Narrow {size} all 窄 四周 {size} Navigation 導覽 Navigation Pane 功能窗格 New 新增 @@ -598,7 +598,7 @@ Nonbreaking Hyphen 不分行連字號 Nonbreaking Space 不分行空格 None 無 - Normal 1" all 一般 四周 1" + Normal {size} all 一般 四周 {size} Normal Style 內文樣式 Not available right now 目前無法使用 Not saved 未儲存 diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index ead020b4f..bbcf9c468 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1353,17 +1353,22 @@ fn design(app: &mut WordApp, ui: &mut Ui) { // mark, as Text Direction's does (#1206). fn margins_items(ui: &mut Ui, app: &mut WordApp) { let current = wordcraft_engine::cmd::page::margins_preset_at(&app.session); - for (l, k) in [ - ("Normal 1\" all", "normal"), - ("Narrow 0.5\" all", "narrow"), - ("Moderate", "moderate"), - ("Wide", "wide"), - ("Mirrored", "mirrored"), - ("Office 2003 Default", "office2003"), + // The two uniform presets name their margin in the measurement unit (`2,54 cm`, #838). + for (l, k, uniform) in [ + ("Normal {size} all", "normal", Some(72.0)), + ("Narrow {size} all", "narrow", Some(36.0)), + ("Moderate", "moderate", None), + ("Wide", "wide", None), + ("Mirrored", "mirrored", None), + ("Office 2003 Default", "office2003", None), ] { + let label = match uniform { + Some(pt) => crate::i18n::fmt(tl!(l), &[("size", &crate::options::length(pt))]), + None => l.to_string(), + }; // Mirrored margins come from the document as well as from the preset (#1181). let on = current == Some(k) || (k == "mirrored" && app.session.doc.settings.mirror_margins); - mi_check(ui, app, l, on, "layout.margins", json!({"preset": k})); + mi_check(ui, app, &label, on, "layout.margins", json!({"preset": k})); } mi(ui, app, "Custom Margins…", "ui.dialog", json!({"name": "pageSetup"})); } From a9d3597549f7c9dc8b8f433d09638f0a39c15579 Mon Sep 17 00:00:00 2001 From: Izhaan Intellect <150863240+rbr48@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:17:03 +0600 Subject: [PATCH 083/101] Tables: Backspace after a nested table stays in its cell (#906) Backspace at the start of the paragraph after a table nested in a cell moved the caret into the last cell of the table around it: the search for the table's last paragraph compared only the first index of the path, which a nested table shares with its outer table. It now matches the table's whole path. Regression test: backspace_after_a_nested_table_stays_in_its_cell. Also adds my contributor entry (rbr48, GitHub profile name). Co-authored-by: rbr48 Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 +++- contributors/people.toml | 3 +++ crates/engine/src/cmd/text.rs | 5 +++-- crates/engine/src/tests.rs | 19 +++++++++++++++++++ 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 68cf33c29..662422e90 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -135,11 +135,12 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log | Date | What landed | +|---|---| | 2026-10-14 | Consistent preferred cell widths override a stale table grid when both totals agree, for fixed and AutoFit tables. Conflicting widths, spans and percentages remain unchanged. | | 2026-10-14 | Legacy VML freeforms (`v:shape` with its own `path`), such as the rule lines in many older headers, are read as freeform shapes: they show on screen and in PDFs and are saved back as DrawingML custom geometry instead of vanishing | | 2026-10-14 | Field instructions spanning paragraphs no longer add blank lines during pagination; empty result paragraphs still take a line. | | 2026-10-14 | Page-break-only list paragraphs no longer print stray bullets or numbers. The next item keeps its number, matching the reference export. | -|---|---|---| +| 2026-10-12 | Backspace at the start of the paragraph after a table nested in a cell moves into the nested table's last cell, no longer into the last cell of the table around it | | 2026-10-14 | Grouped `flowChartOr` shapes draw their circle and crossing spokes, as in Word | | 2026-10-14 | Deleting a comment removes its anchors from headers, footers, notes and text boxes too, not just the body, and deletes the whole thread below it, replies to replies included; no anchor or reply is left pointing at a deleted comment (#1345) | | 2026-10-14 | Legacy DOC DIB conversion checks the BMP header and copy before allocation, uses the caller's decode allowance, and caps PNG output while encoding; a BMP fallback is kept when it fits. A generated 1-pixel regression covers 57/58/512-byte limits. Resource-exhaustion consequences remain unmeasured. Related: #1334 and #1554 | @@ -273,6 +274,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-12 | Section breaks (#852): Layout › Breaks with text selected puts the break before the selection and keeps the text, as Word does; it used to delete it, leaving the first section empty | | 2026-10-12 | Find, Replace and Go To like Word (#712): Ctrl+F types into the Navigation pane's search, one tabbed Find and Replace dialog, Go To by page (absolute or relative) and bookmark; `edit.findReplace`, `edit.goto` `pageBy` | | 2026-10-12 | Symbol fonts (#776): text in Wingdings, Wingdings 2, Wingdings 3, Webdings and Symbol (proprietary, never bundled) shows its intended symbols — full code tables from Unicode's published correspondence, both storage forms (`w:sym` private-use codes and plain codes); PDF text, copy and TXT export use the Unicode characters; the saved text is unchanged | +>>>>>>> upstream/main | 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/contributors/people.toml b/contributors/people.toml index 0f28c5645..da86d62d2 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -128,3 +128,6 @@ sync_github_name = false real_name = "Andrej Škvorc" display_name = "Andrej Škvorc" sync_github_name = false + +[people.rbr48] +sync_github_name = true diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 1cf7444fc..62753e9c2 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -727,8 +727,9 @@ fn backspace_inner(s: &mut Session, _: &Value) -> CmdResult { s.sel = Selection::caret(at); } Some(Block::Table(_)) => { - // Move into the table's last cell. - let last = s.doc.para_paths(f.story).into_iter().rfind(|q| q.0.first() == prev_path.0.first() && q.0.len() > 1); + // Move into the table's last cell. The table is matched by its whole path: a table + // nested in a cell shares only its first index with the table around it. + let last = s.doc.para_paths(f.story).into_iter().rfind(|q| q.0.len() > prev_path.0.len() && q.0.starts_with(&prev_path.0)); if let Some(q) = last { let off = s.doc.para(f.story, &q).map(|x| x.len()).unwrap_or(0); if s.doc.para_at(&f).is_some_and(|x| x.is_empty()) && s.doc.container(f.story, &f.path).is_some_and(|c| c.len() > i + 1) { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 7fd1afd7b..98c1a5368 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -561,6 +561,25 @@ fn backspace_and_delete() { assert_eq!(text(&s), ""); } +/// Backspace at the start of the paragraph after a table nested in a cell goes into the nested +/// table's last cell, not into the last cell of the table around it. +#[test] +fn backspace_after_a_nested_table_stays_in_its_cell() { + let mut s = s(); + run(&mut s, "table.quick", json!({"kind": "matrix"})); + run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, 1, 1, 0], "off": 0}})); + run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); + let outer = s.doc.body.get(1).and_then(|b| b.as_table()).expect("the outer table"); + let blocks = &outer.rows[1].cells[1].blocks; + let nested = blocks.iter().position(|b| b.as_table().is_some()).expect("a nested table"); + assert!(nested + 1 < blocks.len(), "a paragraph follows the nested table"); + let after = nested as u32 + 1; + run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, 1, 1, after], "off": 0}})); + run(&mut s, "text.backspace", json!({})); + // The nested table's last cell: row 1, cell 1, its first paragraph. + assert_eq!(s.sel.focus.path.0, vec![1, 1, 1, after - 1, 1, 1, 0]); +} + #[test] fn bold_toggles_selection_and_caret_word() { let mut s = s(); From 5f83a4727aeb8baf7b12dd0b9eb0fea780b30240 Mon Sep 17 00:00:00 2001 From: Arthur M Date: Thu, 15 Oct 2026 06:21:06 +0200 Subject: [PATCH 084/101] get_text and CLI text omit tracked deletions (#866) (#944) Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- apps/wordcraft-cli/tests/text_tracked.rs | 42 ++++++++++++++++++++++++ crates/doc/src/lib.rs | 34 +++++++++++++++---- crates/engine/src/cmd/file.rs | 2 +- crates/engine/src/tests.rs | 12 +++++++ crates/mcp/src/tools.rs | 2 +- 5 files changed, 83 insertions(+), 9 deletions(-) create mode 100644 apps/wordcraft-cli/tests/text_tracked.rs diff --git a/apps/wordcraft-cli/tests/text_tracked.rs b/apps/wordcraft-cli/tests/text_tracked.rs new file mode 100644 index 000000000..6790b763d --- /dev/null +++ b/apps/wordcraft-cli/tests/text_tracked.rs @@ -0,0 +1,42 @@ +//! #866: `wordcraft-cli text` omits tracked deletions (it does not go through `document.text`). + +use std::process::Command; + +fn cli() -> Command { + Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")) +} + +#[test] +fn text_omits_tracked_deletions() { + let dir = std::env::temp_dir().join(format!("wc-cli-866-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("t.docx"); + let path_s = path.to_str().unwrap(); + + let make = cli() + .args([ + "run", + "--save", + path_s, + "--cmd", + r#"text.insert={"text":"Signed by Alice Smith, Partner."}"#, + "--cmd", + r#"review.trackChanges={"value":true}"#, + "--cmd", + r#"select.text={"text":"Alice Smith, Partner"}"#, + "--cmd", + r#"text.insert={"text":"Bob Jones, Director"}"#, + ]) + .output() + .expect("run"); + assert!(make.status.success(), "run failed: {}", String::from_utf8_lossy(&make.stderr)); + + let out = cli().args(["text", path_s]).output().expect("text"); + assert!(out.status.success(), "text failed: {}", String::from_utf8_lossy(&out.stderr)); + let stdout = String::from_utf8_lossy(&out.stdout); + assert_eq!(stdout.trim_end(), "Signed by Bob Jones, Director."); + assert!(!stdout.contains("Alice Smith"), "{stdout}"); + + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 775c05284..797d6c3e4 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -894,19 +894,24 @@ impl Document { /// The whole text of a story, paragraphs separated by `\n` (tables: cells by `\t`). pub fn plain_text(&self, s: StoryRef) -> String { - self.story_text(s, false) + self.story_text(s, TextMode::Plain) } /// [`Self::plain_text`] as it reads on screen: symbol-font characters (Wingdings…) as their /// Unicode equivalents ([`Paragraph::shown_text`]). pub fn shown_text(&self, s: StoryRef) -> String { - self.story_text(s, true) + self.story_text(s, TextMode::Shown) } - fn story_text(&self, s: StoryRef, shown: bool) -> String { + /// [`Self::plain_text`] without tracked-deleted runs. A deleted paragraph mark still yields `\n`. + pub fn final_text(&self, s: StoryRef) -> String { + self.story_text(s, TextMode::Final) + } + + fn story_text(&self, s: StoryRef, mode: TextMode) -> String { let mut out = String::new(); if let Some(bl) = self.story(s) { - text_of(bl, &mut out, 0, shown); + text_of(bl, &mut out, 0, mode); } if out.ends_with('\n') { out.pop(); @@ -1406,14 +1411,29 @@ fn walk(bl: &Blocks, prefix: &mut Vec, out: &mut Vec, depth: usize) { } } -fn text_of(bl: &Blocks, out: &mut String, depth: usize, shown: bool) { +/// Which text of a paragraph [`text_of`] collects. +#[derive(Clone, Copy)] +enum TextMode { + /// Every run, tracked deletions included ([`Paragraph::plain_text`]). + Plain, + /// As shown on screen ([`Paragraph::shown_text`]). + Shown, + /// Without tracked deletions ([`Paragraph::final_text`]). + Final, +} + +fn text_of(bl: &Blocks, out: &mut String, depth: usize, mode: TextMode) { if depth > 16 { return; } for b in bl { match &**b { Block::Para(p) => { - out.push_str(&if shown { p.shown_text() } else { p.plain_text() }); + out.push_str(&match mode { + TextMode::Plain => p.plain_text(), + TextMode::Shown => p.shown_text(), + TextMode::Final => p.final_text(), + }); out.push('\n'); } Block::Table(t) => { @@ -1421,7 +1441,7 @@ fn text_of(bl: &Blocks, out: &mut String, depth: usize, shown: bool) { let mut cells = Vec::new(); for c in &row.cells { let mut s = String::new(); - text_of(&c.blocks, &mut s, depth + 1, shown); + text_of(&c.blocks, &mut s, depth + 1, mode); cells.push(s.trim_end_matches('\n').replace('\n', " ")); } out.push_str(&cells.join("\t")); diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 6c5d9fd66..ecdff7f5f 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -147,7 +147,7 @@ pub fn specs() -> Vec { CommandSpec::new("document.inspect", "Inspect Document", "Agents", inspect).params(r#"{"text"?: bool}"#).pure(), CommandSpec::new("document.text", "Document Text", "Agents", |s, v| { let story = super::story_param(s, v); - Ok(json!({"text": s.doc.plain_text(story)})) + Ok(json!({"text": s.doc.final_text(story)})) }) .pure(), CommandSpec::new("document.paragraph", "Paragraph Details", "Agents", paragraph).params(r#"{"path": [n], "story"?: Story}"#).pure(), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 98c1a5368..a957def7c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1781,6 +1781,18 @@ fn track_changes_and_accept() { assert_eq!(text(&s), "inal added"); } +#[test] +fn document_text_drops_tracked_deletions() { + // #866: document.text (MCP get_text) omits tracked deletions; plain_text still includes them. + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Signed by Alice Smith, Partner."})); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "select.text", json!({"text": "Alice Smith, Partner"})); + run(&mut s, "text.insert", json!({"text": "Bob Jones, Director"})); + assert_eq!(text(&s), "Signed by Bob Jones, DirectorAlice Smith, Partner."); + assert_eq!(run(&mut s, "document.text", json!({}))["text"], "Signed by Bob Jones, Director."); +} + /// The author of the revision `rid` points to. fn author_of(s: &Session, rid: Option) -> Option { rid.and_then(|r| s.doc.revisions.get(r as usize)).map(|r| r.author.clone()) diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 7dd99085b..623e2d5ed 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -113,7 +113,7 @@ pub fn tool_definitions() -> Value { obj(json!({"text": s("Text to find"), "occurrence": n("1-based")}), &["text"]), false ), - tool("get_text", "Document text", "Plain text of the document body.", obj(json!({}), &[]), true), + tool("get_text", "Document text", "Body text with tracked deletions omitted.", obj(json!({}), &[]), true), tool( "inspect_document", "Inspect document", From 7af9748acea971f104992b7680729ba7da8c03a4 Mon Sep 17 00:00:00 2001 From: Eduardo Soares Date: Thu, 15 Oct 2026 01:27:25 -0300 Subject: [PATCH 085/101] M1: fields keep w:dirty through saves; computing a result clears it (#877) (#946) A field begin marker flagged w:dirty by a producer that couldn't compute its result (pandoc-style TOC placeholders) lost the flag on save, so Word no longer refreshed the field on open. Mirror w:fldLock end to end: FieldState, InlineObject::Field/FieldStart, the reader (fldChar and fldSimple) and the writer (buffered, range and TOC-spanning begins). Update Field/Update Table clear it on the fields they compute, like Word's F9; untouched fields keep it. Round-trip and engine regression tests, incl. a pin for #878 (imported TOC placeholder replaced in place, fixed earlier by #617). Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/doc/src/fields.rs | 2 +- crates/doc/src/para.rs | 12 +++- crates/doc/src/tests.rs | 2 +- crates/docbin/src/lib.rs | 2 +- crates/docx/src/read/mod.rs | 11 ++- crates/docx/src/read/story.rs | 21 +++--- crates/docx/src/write/mod.rs | 2 +- crates/docx/src/write/story.rs | 30 ++++---- crates/docx/tests/citations.rs | 4 +- crates/docx/tests/fixtures.rs | 45 ++++++++++-- crates/docx/tests/roundtrip.rs | 108 +++++++++++++++++++++++++--- crates/engine/src/cmd/citations.rs | 13 ++-- crates/engine/src/cmd/format.rs | 9 ++- crates/engine/src/cmd/index.rs | 2 +- crates/engine/src/cmd/insert.rs | 11 +-- crates/engine/src/cmd/mailings.rs | 9 ++- crates/engine/src/cmd/references.rs | 21 ++++-- crates/engine/src/cmd/speech.rs | 5 +- crates/engine/src/cmd/table.rs | 2 +- crates/engine/src/cmd/tools.rs | 2 +- crates/engine/src/sample.rs | 2 +- crates/engine/src/tests.rs | 88 ++++++++++++++++++++++- crates/engine/src/tests_typing.rs | 2 +- crates/layout/src/tests.rs | 18 ++--- crates/layout/src/tests_pages.rs | 15 +++- crates/layout/src/tests_styleref.rs | 2 +- crates/pdf/src/tests.rs | 37 ++++++++-- crates/zotero/src/bridge.rs | 2 +- docs/file-format-parity.md | 1 + 30 files changed, 390 insertions(+), 91 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 662422e90..3e91d26b4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -135,6 +135,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log | Date | What landed | +| 2026-10-12 | Fields keep `w:dirty` through DOCX saves (#877): a begin marker flagged by a generator that couldn't compute a result (pandoc-style TOC placeholders) keeps the flag on open/save — as a buffered field or a range field — so Word still refreshes it on open; computing a result (Update Field, Update Table) clears it, like Word's F9 | |---|---| | 2026-10-14 | Consistent preferred cell widths override a stale table grid when both totals agree, for fixed and AutoFit tables. Conflicting widths, spans and percentages remain unchanged. | | 2026-10-14 | Legacy VML freeforms (`v:shape` with its own `path`), such as the rule lines in many older headers, are read as freeform shapes: they show on screen and in PDFs and are saved back as DrawingML custom geometry instead of vanishing | diff --git a/crates/doc/src/fields.rs b/crates/doc/src/fields.rs index 5f0d8479b..29b050e60 100644 --- a/crates/doc/src/fields.rs +++ b/crates/doc/src/fields.rs @@ -45,7 +45,7 @@ fn scan(story: StoryRef, bl: &Blocks, prefix: &mut Vec, depth: usize, out: let path = Path(prefix.clone()); for off in p.object_offsets() { match p.object_at(off) { - Some(InlineObject::FieldStart { instr, locked }) => { + Some(InlineObject::FieldStart { instr, locked, .. }) => { open.push((instr.clone(), *locked, Pos::new(story, path.clone(), off))); } Some(InlineObject::FieldEnd) => match open.pop() { diff --git a/crates/doc/src/para.rs b/crates/doc/src/para.rs index 542cd4f2c..ab81cc344 100644 --- a/crates/doc/src/para.rs +++ b/crates/doc/src/para.rs @@ -448,6 +448,11 @@ pub enum InlineObject { result: String, #[serde(default)] locked: bool, + /// `w:dirty` (ECMA-376 §17.16.5.35): the cached result is stale — another program that + /// couldn't compute it set the flag so Word refreshes the field on open. Kept through + /// saves; cleared when WordCraft computes a result, like Word's F9. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + dirty: bool, /// Shows its code (`{ PAGE }`) instead of its result, the other way round from the /// document-wide View › Field Codes setting (`fields.toggleCode`, Shift+F9). A view /// toggle: never written to files. @@ -466,6 +471,9 @@ pub enum InlineObject { instr: String, #[serde(default)] locked: bool, + /// `w:dirty` on the begin marker, as [`InlineObject::Field::dirty`]. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + dirty: bool, }, /// The end of the innermost open [`InlineObject::FieldStart`]. FieldEnd, @@ -1355,7 +1363,7 @@ mod tests { let mut p = Paragraph::with_text("añb€c", CharProps::default()); p.insert_object( 3, - InlineObject::Field { instr: "PAGE".into(), result: "7".into(), locked: false, code: false, form: String::new() }, + InlineObject::Field { instr: "PAGE".into(), result: "7".into(), locked: false, dirty: false, code: false, form: String::new() }, &CharProps::default(), ) .unwrap(); @@ -1429,7 +1437,7 @@ mod tests { let mut p = Paragraph::with_text("ab", CharProps::default()); p.insert_object( 1, - InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, code: false, form: String::new() }, + InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() }, &CharProps::default(), ) .unwrap(); diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 5dc89468d..944375629 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -391,7 +391,7 @@ fn notes_in_text_box_follow_nested_boxes_once() { } fn fs(instr: &str) -> InlineObject { - InlineObject::FieldStart { instr: instr.into(), locked: false } + InlineObject::FieldStart { instr: instr.into(), locked: false, dirty: false } } #[test] diff --git a/crates/docbin/src/lib.rs b/crates/docbin/src/lib.rs index 2c8beaaeb..9a40c22f5 100644 --- a/crates/docbin/src/lib.rs +++ b/crates/docbin/src/lib.rs @@ -575,7 +575,7 @@ fn finish_field(pb: &mut ParaBuild, f: FieldBuild) { return; } pb.push('\u{FFFC}', CharProps::default()); - pb.objects.push(InlineObject::Field { instr, result: result_text, locked: false, code: false, form: String::new() }); + pb.objects.push(InlineObject::Field { instr, result: result_text, locked: false, dirty: false, code: false, form: String::new() }); } /// `HYPERLINK "url"` / `HYPERLINK url \l "bookmark"` → the model link target diff --git a/crates/docx/src/read/mod.rs b/crates/docx/src/read/mod.rs index 9eb421498..8944c6bc8 100644 --- a/crates/docx/src/read/mod.rs +++ b/crates/docx/src/read/mod.rs @@ -447,8 +447,15 @@ fn toc_field_to_heading(body: &mut Blocks) { } 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, form: String::new() }, + Some(InlineObject::FieldStart { instr, locked, dirty }) if is_toc(instr) => ( + InlineObject::Field { + instr: instr.clone(), + result: String::new(), + locked: *locked, + dirty: *dirty, + code: false, + form: String::new(), + }, Some(toc_range_end(body, i)?), ), Some(f @ InlineObject::Field { result, .. }) if result.is_empty() && has_toc(f) => (f.clone(), None), diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index 61b1dc59f..9a39f6406 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -63,6 +63,8 @@ struct FieldState { buf: Vec<(Item, CharProps)>, props: CharProps, locked: bool, + /// `w:dirty` from the begin marker: the result is stale, Word refreshes it on open. + dirty: bool, /// Kept as a range field: its result flows into the document as ordinary content between /// `FieldStart` and `FieldEnd` markers instead of being buffered. range: bool, @@ -476,7 +478,7 @@ impl Reader<'_> { "w:fldSimple" => { let instr = k.attr("w:instr").unwrap_or("").to_string(); let props = k.child("w:r").and_then(|r| r.child("w:rPr")).map(|p| self.run_props(p, ctx)).unwrap_or_else(|| self.run_props_none(ctx)); - self.begin_field(sc, props, instr, on_off_attr(k, "w:fldLock"), String::new()); + self.begin_field(sc, props, instr, on_off_attr(k, "w:fldLock"), on_off_attr(k, "w:dirty"), String::new()); self.separate_field(sc, pb); self.read_inline_children(sc, pb, k, rels, ctx, depth + 1); self.end_field(sc, pb); @@ -648,7 +650,7 @@ impl Reader<'_> { "w:fldChar" => match k.attr("w:fldCharType") { Some("begin") => { let form = k.child("w:ffData").map(|f| f.to_xml()).filter(|x| x.len() <= MAX_FORM).unwrap_or_default(); - self.begin_field(sc, props.clone(), String::new(), on_off_attr(k, "w:fldLock"), form) + self.begin_field(sc, props.clone(), String::new(), on_off_attr(k, "w:fldLock"), on_off_attr(k, "w:dirty"), form) } Some("separate") => self.separate_field(sc, pb), Some("end") => self.end_field(sc, pb), @@ -708,7 +710,7 @@ impl Reader<'_> { "w:pgNum" => self.emit_obj( sc, pb, - InlineObject::Field { instr: "PAGE".into(), result: String::new(), locked: false, code: false, form: String::new() }, + InlineObject::Field { instr: "PAGE".into(), result: String::new(), locked: false, dirty: false, code: false, form: String::new() }, props, ), "w:ruby" => self.read_ruby(sc, pb, k, props), @@ -782,7 +784,7 @@ impl Reader<'_> { // ---- fields ---- - fn begin_field(&mut self, sc: &mut StoryCtx, props: CharProps, instr: String, locked: bool, form: String) { + fn begin_field(&mut self, sc: &mut StoryCtx, props: CharProps, instr: String, locked: bool, dirty: bool, form: String) { if sc.fields.len() >= MAX_FIELD_DEPTH || sc.ignored_begins > 0 { sc.ignored_begins += 1; return; @@ -795,6 +797,7 @@ impl Reader<'_> { buf: Vec::new(), props, locked, + dirty, range: false, sizes: Vec::new(), form, @@ -821,7 +824,7 @@ impl Reader<'_> { f.phase = Phase::Result; if !f.spilled && !nested_in_instr && is_range_instr(&f.instr) { f.range = true; - let (obj, props) = (InlineObject::FieldStart { instr: f.instr.trim().to_string(), locked: f.locked }, f.props.clone()); + let (obj, props) = (InlineObject::FieldStart { instr: f.instr.trim().to_string(), locked: f.locked, dirty: f.dirty }, f.props.clone()); self.emit_obj(sc, pb, obj, &props); } } @@ -842,7 +845,7 @@ impl Reader<'_> { } if f.phase == Phase::Instr && !nested_in_instr && is_range_instr(&f.instr) { // A range field with no result yet. - self.emit_obj(sc, pb, InlineObject::FieldStart { instr: f.instr.trim().to_string(), locked: f.locked }, &f.props); + self.emit_obj(sc, pb, InlineObject::FieldStart { instr: f.instr.trim().to_string(), locked: f.locked, dirty: f.dirty }, &f.props); self.emit_obj(sc, pb, InlineObject::FieldEnd, &f.props); return; } @@ -854,7 +857,7 @@ impl Reader<'_> { // each piece stays an ordinary run with its own `w:ins` / `w:del` (#1037). let tracked_inside = f.buf.iter().any(|(_, p)| (p.ins, p.del) != (f.props.ins, f.props.del)); if !nested_in_instr && (tracked_inside || f.buf.iter().any(|(it, _)| matches!(it, Item::Obj(o) if o.frame().is_some()))) { - self.emit_obj(sc, pb, InlineObject::FieldStart { instr: f.instr.trim().to_string(), locked: f.locked }, &f.props); + self.emit_obj(sc, pb, InlineObject::FieldStart { instr: f.instr.trim().to_string(), locked: f.locked, dirty: f.dirty }, &f.props); for (it, p) in f.buf { match it { Item::Text(t) => self.emit_text(sc, pb, &t, &p), @@ -876,7 +879,7 @@ impl Reader<'_> { Some(e) => (e.instr(), if result.is_empty() { e.text } else { result }), None => (f.instr.trim().to_string(), result), }; - let obj = InlineObject::Field { instr, result, locked: f.locked, code: false, form: f.form }; + let obj = InlineObject::Field { instr, result, locked: f.locked, dirty: f.dirty, code: false, form: f.form }; if let Some(parent) = sc.fields.last_mut() && !parent.spilled && parent.phase == Phase::Instr @@ -897,7 +900,7 @@ impl Reader<'_> { f.spilled = true; let form = std::mem::take(&mut f.form); pb.push_obj( - InlineObject::Field { instr: f.instr.trim().to_string(), result: String::new(), locked: f.locked, code: false, form }, + InlineObject::Field { instr: f.instr.trim().to_string(), result: String::new(), locked: f.locked, dirty: f.dirty, code: false, form }, &f.props, ); for (it, p) in f.buf.drain(..) { diff --git a/crates/docx/src/write/mod.rs b/crates/docx/src/write/mod.rs index a031a231e..3cdd865c1 100644 --- a/crates/docx/src/write/mod.rs +++ b/crates/docx/src/write/mod.rs @@ -100,7 +100,7 @@ pub(crate) struct Writer<'d> { /// Writing a TOC heading: its TOC field is held back so the entries become the field's result. toc_hold_end: bool, /// The held TOC field (instruction, locked, props), opened in the first entry as Word does. - toc_field: Option<(String, bool, wordcraft_doc::props::CharProps)>, + toc_field: Option<(String, bool, bool, wordcraft_doc::props::CharProps)>, /// Open the held TOC field at the start of the paragraph being written. toc_begin_here: bool, /// Close the open TOC field at the end of the paragraph being written. diff --git a/crates/docx/src/write/story.rs b/crates/docx/src/write/story.rs index bcdd460f1..07f0795db 100644 --- a/crates/docx/src/write/story.rs +++ b/crates/docx/src/write/story.rs @@ -224,10 +224,10 @@ impl Writer<'_> { } // Taken before the content so a nested story (a text box in an entry) can't claim them. if std::mem::take(&mut self.toc_begin_here) - && let Some((instr, locked, props)) = self.toc_field.take() + && let Some((instr, locked, dirty, props)) = self.toc_field.take() { self.rev_open(w, &props); - self.field_start(w, &instr, locked, "", &props); + self.field_start(w, &instr, locked, dirty, "", &props); self.rev_close(w, &props); } let toc_end = std::mem::take(&mut self.toc_end_here); @@ -496,10 +496,10 @@ impl Writer<'_> { None => super::math::write_equation(w, linear, *display, math, color), } } - InlineObject::Field { instr, result, locked, form, .. } => self.field(w, instr, result, *locked, form, props), - InlineObject::FieldStart { instr, locked } => { + InlineObject::Field { instr, result, locked, dirty, form, .. } => self.field(w, instr, result, *locked, *dirty, form, props), + InlineObject::FieldStart { instr, locked, dirty } => { self.rev_open(w, props); - self.field_start(w, instr, *locked, "", props); + self.field_start(w, instr, *locked, *dirty, "", props); self.rev_close(w, props); } InlineObject::FieldEnd => { @@ -795,11 +795,11 @@ impl Writer<'_> { } } - fn field(&mut self, w: &mut W, instr: &str, result: &str, locked: bool, form: &str, props: &CharProps) { + fn field(&mut self, w: &mut W, instr: &str, result: &str, locked: bool, dirty: bool, form: &str, props: &CharProps) { if self.toc_hold_end && is_toc(instr) { // A TOC heading's field opens in the first entry and closes after the last (see `toc_span`). self.toc_hold_end = false; - self.toc_field = Some((instr.to_string(), locked, props.clone())); + self.toc_field = Some((instr.to_string(), locked, dirty, props.clone())); return; } if let Some(e) = wordcraft_doc::eq::Enclosure::parse(instr) { @@ -816,7 +816,7 @@ impl Writer<'_> { w.close("w:r"); }; self.rev_open(w, props); - self.field_start(w, instr, locked, form, props); + self.field_start(w, instr, locked, dirty, form, props); if !result.is_empty() { run(w, &|w| { let mut buf = String::new(); @@ -896,7 +896,7 @@ impl Writer<'_> { /// A field's begin, instruction and separate runs. /// A field's begin, code and separator; `form` is a legacy form field's `w:ffData`, which /// belongs inside the begin. - fn field_start(&self, w: &mut W, instr: &str, locked: bool, form: &str, props: &CharProps) { + fn field_start(&self, w: &mut W, instr: &str, locked: bool, dirty: bool, form: &str, props: &CharProps) { let del = self.is_del(props); let run = |w: &mut W, f: &dyn Fn(&mut W)| { w.open("w:r", &[]); @@ -905,11 +905,17 @@ impl Writer<'_> { w.close("w:r"); }; run(w, &|w| { - let attrs: &[(&str, &str)] = if locked { &[("w:fldCharType", "begin"), ("w:fldLock", "1")] } else { &[("w:fldCharType", "begin")] }; + let mut attrs: Vec<(&str, &str)> = vec![("w:fldCharType", "begin")]; + if locked { + attrs.push(("w:fldLock", "1")); + } + if dirty { + attrs.push(("w:dirty", "true")); + } if form.is_empty() { - w.empty("w:fldChar", attrs); + w.empty("w:fldChar", &attrs); } else { - w.open("w:fldChar", attrs); + w.open("w:fldChar", &attrs); w.raw(form); w.close("w:fldChar"); } diff --git a/crates/docx/tests/citations.rs b/crates/docx/tests/citations.rs index a29bdf6e6..344ad9f67 100644 --- a/crates/docx/tests/citations.rs +++ b/crates/docx/tests/citations.rs @@ -190,7 +190,7 @@ fn orphan_markers_from_editing_are_dropped_on_save() { d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().insert_object(0, InlineObject::FieldEnd, &c).unwrap(); d.para_mut(StoryRef::Body, &Path::top(1)) .unwrap() - .insert_object(1, InlineObject::FieldStart { instr: "ADDIN X".into(), locked: true }, &c) + .insert_object(1, InlineObject::FieldStart { instr: "ADDIN X".into(), locked: true, dirty: false }, &c) .unwrap(); let xml = document_xml(&write(&d).unwrap()); assert_eq!(count(&xml, "begin"), 0); @@ -204,7 +204,7 @@ fn programmatic_range_field_writes_as_word_field() { let mut d = Document::from_text("ab"); let c = CharProps::default(); let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap(); - p.insert_object(1, InlineObject::FieldStart { instr: "ADDIN ZOTERO_ITEM {}".into(), locked: false }, &c).unwrap(); + p.insert_object(1, InlineObject::FieldStart { instr: "ADDIN ZOTERO_ITEM {}".into(), locked: false, dirty: false }, &c).unwrap(); let end = 1 + OBJ.len_utf8() + 1; p.insert_object(end, InlineObject::FieldEnd, &c).unwrap(); d.set_custom_prop("ZOTERO_PREF_1", ""); diff --git a/crates/docx/tests/fixtures.rs b/crates/docx/tests/fixtures.rs index a0c2cfcb9..635141925 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/fixtures.rs @@ -115,14 +115,28 @@ fn single_entry_toc_keeps_formatted_result_and_nested_page_field() { 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, form: String::new() }] + vec![InlineObject::Field { + instr: r#"TOC \o "1-1" \h"#.into(), + result: String::new(), + locked: true, + dirty: false, + code: false, + form: String::new() + }] ); 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, form: String::new() }] + vec![InlineObject::Field { + instr: r#"PAGEREF _Toc1 \h"#.into(), + result: "1".into(), + locked: false, + dirty: false, + code: false, + form: String::new() + }] ); } @@ -141,10 +155,17 @@ 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::FieldStart { instr: r#"TOC \o "1-3" \h \z \u"#.into(), locked: false }); + assert_eq!(p[0].objects[0], InlineObject::FieldStart { instr: r#"TOC \o "1-3" \h \z \u"#.into(), locked: false, dirty: false }); assert_eq!( p[0].objects[1], - InlineObject::Field { instr: r#"PAGEREF _Toc1 \h"#.into(), result: "1".into(), locked: false, code: false, form: String::new() } + InlineObject::Field { + instr: r#"PAGEREF _Toc1 \h"#.into(), + result: "1".into(), + locked: false, + dirty: false, + code: false, + form: String::new() + } ); assert_eq!(p[0].props_of_char(3).link.as_deref(), Some("#_Toc1")); assert_eq!(p[0].props.style.as_deref(), Some("TOC1")); @@ -153,7 +174,10 @@ fn toc_complex_field_spanning_paragraphs() { 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, form: String::new() }); + assert_eq!( + p[4].objects[0], + InlineObject::Field { instr: "PAGE".into(), result: "7".into(), locked: false, dirty: false, code: false, form: String::new() } + ); // And it writes back out and reads the same. let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap(); assert_eq!(again.body, d.body); @@ -659,7 +683,14 @@ fn unbalanced_fields_and_markers() { assert_eq!(p[0].text, "a"); assert_eq!( p[1].objects, - vec![InlineObject::Field { instr: "IF X = \"y\"".into(), result: String::new(), locked: false, code: false, form: String::new() }] + vec![InlineObject::Field { + instr: "IF X = \"y\"".into(), + result: String::new(), + locked: false, + dirty: false, + code: false, + form: String::new() + }] ); assert_eq!(p[2].objects, vec![InlineObject::BookmarkStart { name: "between".into() }, InlineObject::BookmarkEnd { name: "between".into() }]); assert_eq!(p[2].plain_text(), "after"); @@ -1767,7 +1798,7 @@ fn includepicture_keeps_its_embedded_picture() { let p = paras(d); let objs = &p[0].objects; assert_eq!(objs.len(), 3, "{objs:?}"); - assert_eq!(objs[0], InlineObject::FieldStart { instr: "INCLUDEPICTURE \"picture.png\"".into(), locked }); + assert_eq!(objs[0], InlineObject::FieldStart { instr: "INCLUDEPICTURE \"picture.png\"".into(), locked, dirty: false }); let InlineObject::Image { media, .. } = &objs[1] else { panic!("{objs:?}") }; assert_eq!(d.media.get(media).unwrap().as_slice(), png); assert_eq!(objs[2], InlineObject::FieldEnd); diff --git a/crates/docx/tests/roundtrip.rs b/crates/docx/tests/roundtrip.rs index 5274e22ad..1c128f280 100644 --- a/crates/docx/tests/roundtrip.rs +++ b/crates/docx/tests/roundtrip.rs @@ -324,7 +324,7 @@ fn sections_headers_footers_round_trip() { let mut p = Paragraph::with_text("Page ", CharProps::default()); p.insert_object( 5, - InlineObject::Field { instr: instr.into(), result: "1".into(), locked: false, code: false, form: String::new() }, + InlineObject::Field { instr: instr.into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() }, &CharProps::default(), ) .unwrap(); @@ -374,7 +374,10 @@ fn sections_headers_footers_round_trip() { assert_eq!(part_text(&r, secs[0].1.headers.first), "First page header"); assert_eq!(secs[0].1.headers.default, secs[1].1.headers.default, "shared header part stays shared"); let fp = r.parts.get(&secs[0].1.footers.default.unwrap()).unwrap().blocks[0].as_para().unwrap(); - assert_eq!(fp.objects, vec![InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, code: false, form: String::new() }]); + assert_eq!( + fp.objects, + vec![InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() }] + ); assert_eq!(part_text(&r, secs[1].1.footers.default), "Page 1"); assert_eq!(r.parts.get(&secs[0].1.headers.default.unwrap()).unwrap().kind, PartKind::Header); } @@ -731,6 +734,7 @@ fn toc_field_wraps_its_entries() { instr: "TOC \\o \"1-2\" \\h \\z \\u".into(), result: String::new(), locked: false, + dirty: false, code: false, form: String::new(), }, @@ -818,7 +822,14 @@ fn toc_field_skips_nested_stories() { let end = head.len(); head.insert_object( end, - InlineObject::Field { instr: "TOC \\o \"1-1\" \\h \\z \\u".into(), result: String::new(), locked: false, code: false, form: String::new() }, + InlineObject::Field { + instr: "TOC \\o \"1-1\" \\h \\z \\u".into(), + result: String::new(), + locked: false, + dirty: false, + code: false, + form: String::new(), + }, &CharProps::default(), ) .unwrap(); @@ -857,8 +868,12 @@ fn tracked_changes_round_trip() { let p = para_runs(&[("kept ", CharProps::default()), ("added\t", ins.clone()), ("removed", del.clone()), ("gone", both.clone())]); let mut p = p; let end = p.len(); - p.insert_object(end, InlineObject::Field { instr: "DATE".into(), result: "today".into(), locked: false, code: false, form: String::new() }, &del) - .unwrap(); + p.insert_object( + end, + InlineObject::Field { instr: "DATE".into(), result: "today".into(), locked: false, dirty: false, code: false, form: String::new() }, + &del, + ) + .unwrap(); d.body = vec![para_block(p.clone())]; let r = rt(&d); assert_eq!(r.revisions, d.revisions); @@ -905,9 +920,16 @@ fn tracked_paragraph_marks_round_trip() { fn fields_and_special_chars_round_trip() { let mut p = Paragraph::with_text("a\tb\nc\u{000C}d\u{000E}e\u{2011}f\u{00AD}g spaced ", CharProps::default()); let fields = [ - InlineObject::Field { instr: "PAGE".into(), result: "4".into(), locked: false, code: false, form: String::new() }, - InlineObject::Field { instr: "DATE \\@ \"M/d/yyyy\"".into(), result: "1/2/2026".into(), locked: true, code: false, form: String::new() }, - InlineObject::Field { instr: "NUMPAGES".into(), result: String::new(), locked: false, code: false, form: String::new() }, + InlineObject::Field { instr: "PAGE".into(), result: "4".into(), locked: false, dirty: false, code: false, form: String::new() }, + InlineObject::Field { + instr: "DATE \\@ \"M/d/yyyy\"".into(), + result: "1/2/2026".into(), + locked: true, + dirty: false, + code: false, + form: String::new(), + }, + InlineObject::Field { instr: "NUMPAGES".into(), result: String::new(), locked: false, dirty: false, code: false, form: String::new() }, ]; for f in &fields { let end = p.len(); @@ -920,6 +942,74 @@ fn fields_and_special_chars_round_trip() { assert_eq!(got.runs, p.runs); } +/// The `document.xml` of a written package. +fn document_xml(bytes: &[u8]) -> String { + 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(); + xml +} + +/// #877: a field begin marker's `w:dirty` (the cached result is stale; Word refreshes it on +/// open) survives a round trip, and a field without the flag doesn't gain one. Generators that +/// can't paginate (pandoc-style pipelines, python-docx templates) set the flag instead of a result. +#[test] +fn field_dirty_flag_round_trips() { + let mut p = Paragraph::with_text("", CharProps::default()); + for instr in ["PAGE", "DATE"] { + let end = p.len(); + p.insert_object( + end, + InlineObject::Field { instr: instr.into(), result: "7".into(), locked: false, dirty: false, code: false, form: String::new() }, + &CharProps::default(), + ) + .unwrap(); + } + let bytes = wordcraft_docx::write(&doc_with(vec![p])).expect("write"); + // Another producer marks the first field's begin as dirty. + let xml = document_xml(&bytes); + let marked = xml.replacen(r#""#, r#""#, 1); + assert_ne!(&marked, &xml, "the written begin marker must be bare: {xml}"); + let r = wordcraft_docx::read(&replace_part(&bytes, "word/document.xml", marked.as_bytes())).expect("read"); + let out = document_xml(&wordcraft_docx::write(&r).expect("write")); + assert_eq!(out.matches("w:dirty").count(), 1, "the dirty field keeps its flag, the other gains none: {out}"); + assert!(out.contains(r#""#), "{out}"); +} + +/// #877: a TOC imported as a range field keeps `w:dirty` through a round trip — the flag sits on +/// the begin marker Word rebuilds the field from (#878's fixture shape: a placeholder result). +#[test] +fn range_toc_dirty_flag_round_trips() { + let mut toc = Paragraph::new(); + toc.insert_object(0, InlineObject::FieldStart { instr: r#"TOC \o "1-3" \h \z \u"#.into(), locked: false, dirty: false }, &CharProps::default()) + .unwrap(); + let end = toc.len(); + toc.insert_text(end, "Placeholder: update fields (F9).", &CharProps::default()).unwrap(); + let end = toc.len(); + toc.insert_object(end, InlineObject::FieldEnd, &CharProps::default()).unwrap(); + let mut blocks = vec![toc]; + for (t, style) in [("Alpha", Some("Heading1")), ("Body text.", None), ("Beta", Some("Heading2")), ("Gamma", Some("Heading1"))] { + let mut h = Paragraph::with_text(t, CharProps::default()); + h.props.style = style.map(str::to_string); + blocks.push(h); + } + let bytes = wordcraft_docx::write(&doc_with(blocks)).expect("write"); + let xml = document_xml(&bytes); + let marked = xml.replacen(r#""#, r#""#, 1); + assert_ne!(&marked, &xml, "the written begin marker must be bare: {xml}"); + let marked = replace_part(&bytes, "word/document.xml", marked.as_bytes()); + let r = wordcraft_docx::read(&marked).expect("read"); + // Still the imported shape: a range field holding the placeholder as ordinary content. + assert!(matches!(paras(&r)[0].objects.first(), Some(InlineObject::FieldStart { .. })), "{:?}", paras(&r)[0].objects); + assert!(paras(&r)[0].objects.last().is_some_and(|o| matches!(o, InlineObject::FieldEnd))); + assert_eq!(paras(&r)[0].plain_text(), "Placeholder: update fields (F9)."); + let out = document_xml(&wordcraft_docx::write(&r).expect("write")); + assert_eq!(out.matches("w:dirty").count(), 1, "the TOC keeps its flag: {out}"); + let (begin, placeholder) = (out.find("fldCharType=\"begin\"").unwrap(), out.find("Placeholder").unwrap()); + let (separate, end) = (out.find("fldCharType=\"separate\"").unwrap(), out.find("fldCharType=\"end\"").unwrap()); + assert!(begin < separate && separate < placeholder && placeholder < end, "the placeholder stays the field's result: {out}"); +} + #[test] fn shapes_textboxes_equations_dropcaps_round_trip() { let mut d = Document::new(); @@ -2187,7 +2277,7 @@ fn enclosed_characters_round_trip_in_words_structure() { let off = p.len(); p.insert_object( off, - InlineObject::Field { instr: e.instr(), result: "字".into(), locked: false, code: false, form: String::new() }, + InlineObject::Field { instr: e.instr(), result: "字".into(), locked: false, dirty: false, code: false, form: String::new() }, &CharProps { size, ..Default::default() }, ) .unwrap(); diff --git a/crates/engine/src/cmd/citations.rs b/crates/engine/src/cmd/citations.rs index 2957f2249..da0ed9067 100644 --- a/crates/engine/src/cmd/citations.rs +++ b/crates/engine/src/cmd/citations.rs @@ -328,7 +328,7 @@ pub fn specs() -> Vec { return Err(CmdError::Params("select a citation or give `entry`".into())); } let (_, b) = s.sel.ordered(); - s.doc.insert_object(&b, InlineObject::Field { instr: format!("TA \\l \"{}\"", entry.replace('"', "")), result: String::new(), locked: false, code: false, form: String::new() }, &CharProps { hidden: Some(true), ..Default::default() })?; + s.doc.insert_object(&b, InlineObject::Field { instr: format!("TA \\l \"{}\"", entry.replace('"', "")), result: String::new(), locked: false, dirty: false, code: false, form: String::new() }, &CharProps { hidden: Some(true), ..Default::default() })?; Ok(json!({"entry": entry})) }) .params(r#"{"entry"?: string}"#) @@ -570,7 +570,7 @@ fn citation(s: &mut Session, v: &Value) -> CmdResult { let instr = if pages.is_empty() { format!("CITATION {tag}") } else { format!("CITATION {tag} \\p {pages}") }; let end = s.doc.insert_object( &at, - InlineObject::Field { instr, result: cite(&src, st.name, n, &pages), locked: false, code: false, form: String::new() }, + InlineObject::Field { instr, result: cite(&src, st.name, n, &pages), locked: false, dirty: false, code: false, form: String::new() }, &props, )?; s.sel = Selection::caret(end); @@ -693,7 +693,7 @@ pub(crate) fn generated_list(s: &mut Session, instr: &str, title: &str) -> Resul let end = head.len(); head.insert_object( end, - InlineObject::Field { instr: instr.to_string(), result: String::new(), locked: false, code: false, form: String::new() }, + InlineObject::Field { instr: instr.to_string(), result: String::new(), locked: false, dirty: false, code: false, form: String::new() }, &CharProps::default(), )?; let i = if s.doc.para_at(&at).is_some_and(|p| p.is_empty()) { at.path.last() } else { s.doc.split_paragraph(&at)?.path.last() }; @@ -897,8 +897,11 @@ fn cross_ref(s: &mut Session, v: &Value) -> CmdResult { ref_bookmark(s, &pos, show == "number", at)? }; let instr = format!("{} {name} \\h", if show == "page" { "PAGEREF" } else { "REF" }); - let end = - s.doc.insert_object(&at, InlineObject::Field { instr, result: result.clone(), locked: false, code: false, form: String::new() }, &props)?; + let end = s.doc.insert_object( + &at, + InlineObject::Field { instr, result: result.clone(), locked: false, dirty: false, code: false, form: String::new() }, + &props, + )?; s.sel = Selection::caret(end); Ok(json!({"inserted": result})) } diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index 8bbeba508..4b1fac70e 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -801,7 +801,14 @@ fn enclose(s: &mut Session, v: &Value) -> CmdResult { Some(shape) => { let style = style.or(t.current.as_ref().map(|e| e.style)).unwrap_or_default(); let e = Enclosure { shape, style, text: t.text.clone() }; - let obj = wordcraft_doc::InlineObject::Field { instr: e.instr(), result: t.text, locked: false, code: false, form: String::new() }; + let obj = wordcraft_doc::InlineObject::Field { + instr: e.instr(), + result: t.text, + locked: false, + dirty: false, + code: false, + form: String::new(), + }; s.doc.insert_object(&at, obj, &t.props)? } None => s.doc.insert_text(&at, &t.text, &t.props)?, diff --git a/crates/engine/src/cmd/index.rs b/crates/engine/src/cmd/index.rs index 9c9cdaf5b..d2479bf35 100644 --- a/crates/engine/src/cmd/index.rs +++ b/crates/engine/src/cmd/index.rs @@ -66,7 +66,7 @@ fn mark_entry(s: &mut Session, v: &Value) -> CmdResult { } let instr = xe_instr(s, v, &main)?; let hidden = CharProps { hidden: Some(true), ..Default::default() }; - let field = || InlineObject::Field { instr: instr.clone(), result: String::new(), locked: false, code: false, form: String::new() }; + let field = || InlineObject::Field { instr: instr.clone(), result: String::new(), locked: false, dirty: false, code: false, form: String::new() }; if !p::bool(v, "all").unwrap_or(false) { let (_, b) = s.sel.ordered(); s.doc.insert_object(&b, field(), &hidden)?; diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 35bc71023..2b453df49 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -575,7 +575,7 @@ fn header_footer(s: &mut Session, v: &Value, header: bool) -> CmdResult { para.props.align = Some(Align::Center); para.insert_object( 0, - InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, code: false, form: String::new() }, + InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() }, &CharProps::default(), )?; } @@ -740,7 +740,7 @@ fn page_number(s: &mut Session, v: &Value) -> CmdResult { _ => Align::Center, }; let x_of_y = p::str(v, "format").is_some_and(|f| f.contains("of")); - let field = InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, code: false, form: String::new() }; + let field = InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() }; if position == "current" { let props = s.typing_props(); let at = delete_selection(s)?; @@ -759,7 +759,7 @@ fn page_number(s: &mut Session, v: &Value) -> CmdResult { let end = para.len(); para.insert_object( end, - InlineObject::Field { instr: "NUMPAGES".into(), result: "1".into(), locked: false, code: false, form: String::new() }, + InlineObject::Field { instr: "NUMPAGES".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() }, &CharProps { bold: Some(true), ..Default::default() }, )?; } else { @@ -777,7 +777,7 @@ fn date_time(s: &mut Session, v: &Value) -> CmdResult { let at = delete_selection(s)?; let end = s.doc.insert_object( &at, - InlineObject::Field { instr: format!("DATE \\@ \"{fmt}\""), result: text, locked: false, code: false, form: String::new() }, + InlineObject::Field { instr: format!("DATE \\@ \"{fmt}\""), result: text, locked: false, dirty: false, code: false, form: String::new() }, &props, )?; s.sel = Selection::caret(end); @@ -885,7 +885,8 @@ fn field(s: &mut Session, v: &Value) -> CmdResult { let result = p::str(v, "result").unwrap_or("").to_string(); let props = s.typing_props(); let at = delete_selection(s)?; - let end = s.doc.insert_object(&at, InlineObject::Field { instr, result, locked: false, code: false, form: String::new() }, &props)?; + let end = + s.doc.insert_object(&at, InlineObject::Field { instr, result, locked: false, dirty: false, code: false, form: String::new() }, &props)?; s.sel = Selection::caret(end); let _ = super::references::update_fields(s); sel_result(s) diff --git a/crates/engine/src/cmd/mailings.rs b/crates/engine/src/cmd/mailings.rs index eeff59756..8b91ef5e5 100644 --- a/crates/engine/src/cmd/mailings.rs +++ b/crates/engine/src/cmd/mailings.rs @@ -475,7 +475,14 @@ fn insert_field(s: &mut Session, instr: &str, placeholder: &str) -> CmdResult { let at = delete_selection(s)?; let end = s.doc.insert_object( &at, - InlineObject::Field { instr: instr.to_string(), result: placeholder.to_string(), locked: false, code: false, form: String::new() }, + InlineObject::Field { + instr: instr.to_string(), + result: placeholder.to_string(), + locked: false, + dirty: false, + code: false, + form: String::new(), + }, &props, )?; s.sel = Selection::caret(end); diff --git a/crates/engine/src/cmd/references.rs b/crates/engine/src/cmd/references.rs index 656afd894..cf45e3e1e 100644 --- a/crates/engine/src/cmd/references.rs +++ b/crates/engine/src/cmd/references.rs @@ -383,7 +383,14 @@ fn range_toc_to_field(s: &mut Session) -> Result<(), CmdError> { let len = s.doc.para(StoryRef::Body, &heading).map_or(0, |p| p.len()); at = Pos { story: StoryRef::Body, path: heading, off: len }; } - let field = InlineObject::Field { instr: r.instr.trim().to_string(), result: String::new(), locked: false, code: false, form: String::new() }; + let field = InlineObject::Field { + instr: r.instr.trim().to_string(), + result: String::new(), + locked: false, + dirty: false, + code: false, + form: String::new(), + }; 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)?; @@ -433,7 +440,7 @@ fn insert_toc(s: &mut Session, v: &Value) -> CmdResult { let mut head = Paragraph::with_text(&title, CharProps::default()).styled("TOCHeading"); head.insert_object( head.len(), - InlineObject::Field { instr: toc_instr(v), result: String::new(), locked: false, code: false, form: String::new() }, + InlineObject::Field { instr: toc_instr(v), result: String::new(), locked: false, dirty: false, code: false, form: String::new() }, &CharProps::default(), )?; let i = if s.doc.para_at(&at).is_some_and(|p| p.is_empty()) { @@ -740,7 +747,7 @@ fn caption(s: &mut Session, v: &Value) -> CmdResult { let chapter = p::u64(v, "chapter").filter(|l| (1..=9).contains(l)); let sep = p::str(v, "separator").and_then(|x| CHAPTER_SEPARATORS.iter().find(|s| **s == x)).copied().unwrap_or("-"); let mut para = Paragraph::new().styled("Caption"); - let field = |instr: String| InlineObject::Field { instr, result: String::new(), locked: false, code: false, form: String::new() }; + let field = |instr: String| InlineObject::Field { instr, result: String::new(), locked: false, dirty: false, code: false, form: String::new() }; if !p::bool(v, "excludeLabel").unwrap_or(false) { para.insert_text(0, &format!("{label} "), &CharProps::default())?; } @@ -820,8 +827,10 @@ pub fn update_seq(s: &mut Session) -> Result<(), CmdError> { } let para = s.doc.para_mut(StoryRef::Body, &path)?; for (k, r) in updates { - if let Some(InlineObject::Field { result, .. }) = para.objects.get_mut(k) { + if let Some(InlineObject::Field { result, dirty, .. }) = para.objects.get_mut(k) { + // A computed result is no longer stale: Word's F9 clears `w:dirty` too. *result = r; + *dirty = false; } } para.touch(); @@ -870,8 +879,10 @@ pub fn update_fields(s: &mut Session) -> Result<(), CmdError> { } let para = s.doc.para_mut(StoryRef::Body, &path)?; for (k, r) in updates { - if let Some(InlineObject::Field { result, .. }) = para.objects.get_mut(k) { + if let Some(InlineObject::Field { result, dirty, .. }) = para.objects.get_mut(k) { + // A computed result is no longer stale: Word's F9 clears `w:dirty` too. *result = r; + *dirty = false; } } para.touch(); diff --git a/crates/engine/src/cmd/speech.rs b/crates/engine/src/cmd/speech.rs index ca1ee32cb..651f6d636 100644 --- a/crates/engine/src/cmd/speech.rs +++ b/crates/engine/src/cmd/speech.rs @@ -166,7 +166,8 @@ mod tests { fn cite(d: &mut Document, block: usize, off: usize, text: &str) { let c = CharProps::default(); let p = d.para_mut(StoryRef::Body, &Path::top(block)).unwrap(); - p.insert_object(off, InlineObject::FieldStart { instr: "ADDIN ZOTERO_ITEM CSL_CITATION {}".into(), locked: false }, &c).unwrap(); + p.insert_object(off, InlineObject::FieldStart { instr: "ADDIN ZOTERO_ITEM CSL_CITATION {}".into(), locked: false, dirty: false }, &c) + .unwrap(); let n = p.insert_text(off + 3, text, &c).unwrap(); p.insert_object(off + 3 + n, InlineObject::FieldEnd, &c).unwrap(); } @@ -180,7 +181,7 @@ mod tests { let c = CharProps::default(); d.para_mut(StoryRef::Body, &Path::top(2)) .unwrap() - .insert_object(0, InlineObject::FieldStart { instr: "ADDIN ZOTERO_BIBL {} CSL_BIBLIOGRAPHY".into(), locked: false }, &c) + .insert_object(0, InlineObject::FieldStart { instr: "ADDIN ZOTERO_BIBL {} CSL_BIBLIOGRAPHY".into(), locked: false, dirty: false }, &c) .unwrap(); let p3 = d.para_mut(StoryRef::Body, &Path::top(3)).unwrap(); let end = p3.len(); diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index d4ada2b5e..5616ac0bb 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -859,7 +859,7 @@ fn formula(s: &mut Session, v: &Value) -> CmdResult { let at = s.sel.focus.clone(); let end = s.doc.insert_object( &at, - wordcraft_doc::InlineObject::Field { instr: f.clone(), result: text.clone(), locked: false, code: false, form: String::new() }, + wordcraft_doc::InlineObject::Field { instr: f.clone(), result: text.clone(), locked: false, dirty: false, code: false, form: String::new() }, &props, )?; s.sel = Selection::caret(end); diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index 504cc97c3..1243ba4f2 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -479,7 +479,7 @@ pub fn specs() -> Vec { }; let props = s.typing_props(); let at = delete_selection(s)?; - let end = s.doc.insert_object(&at, InlineObject::Field { instr, result: val, locked: false, code: false, form: String::new() }, &props)?; + let end = s.doc.insert_object(&at, InlineObject::Field { instr, result: val, locked: false, dirty: false, code: false, form: String::new() }, &props)?; s.sel = Selection::caret(end); sel_result(s) }) diff --git a/crates/engine/src/sample.rs b/crates/engine/src/sample.rs index 1ca833b85..fd5ac6437 100644 --- a/crates/engine/src/sample.rs +++ b/crates/engine/src/sample.rs @@ -122,7 +122,7 @@ pub fn sample_document() -> Document { f.props.align = Some(Align::Center); let _ = f.insert_object( 0, - InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, code: false, form: String::new() }, + InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() }, &CharProps::default(), ); let fid = d.add_part(PartKind::Footer, vec![para_block(f)]); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a957def7c..8b103ca9f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2347,7 +2347,7 @@ fn imported_range_toc_is_refreshed_in_place() { 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(); + p.insert_object(0, InlineObject::FieldStart { instr: r#"TOC \o "1-2" \h"#.into(), locked, dirty: false }, &CharProps::default()).unwrap(); } let mut s = Session::new(d); for (i, style) in [(2, "Heading 1"), (3, "Heading 2")] { @@ -2381,6 +2381,89 @@ fn imported_range_toc_is_refreshed_in_place() { } } +/// #877: computing a field's result clears `w:dirty` — the result is no longer stale, like +/// Word's F9. A field WordCraft can't compute keeps the flag so Word refreshes it on open. +#[test] +fn update_fields_clears_dirty() { + let mut s = s(); + s.doc.core.title = "Report".into(); + let mut p = wordcraft_doc::Paragraph::new(); + for instr in ["TITLE", "NUMWORDS", "NOTAFIELD \\x"] { + let end = p.len(); + p.insert_object( + end, + wordcraft_doc::InlineObject::Field { + instr: instr.into(), + result: String::new(), + locked: false, + dirty: true, + code: false, + form: String::new(), + }, + &wordcraft_doc::props::CharProps::default(), + ) + .unwrap(); + } + s.doc.body = vec![wordcraft_doc::para_block(p)]; + run(&mut s, "references.updateFields", json!({})); + let objs = &s.doc.body[0].as_para().unwrap().objects; + let dirty: Vec = objs.iter().map(|o| matches!(o, wordcraft_doc::InlineObject::Field { dirty: true, .. })).collect(); + assert_eq!(dirty, vec![false, false, true], "computed fields clear the flag, unknown ones keep it: {:?}", objs); + assert!(matches!(objs.first(), Some(wordcraft_doc::InlineObject::Field { result, .. }) if result == "Report"), "{objs:?}"); +} + +/// #877/#878: an imported TOC field whose begin marker carries `w:dirty` (a generator wrote a +/// placeholder result and asked Word to refresh it on open) keeps the flag through saves until +/// WordCraft computes the entries: Update Table replaces the placeholder in place (#878) and +/// clears the flag, like Word's F9. Undo brings the dirty imported field back. +#[test] +fn imported_dirty_toc_updates_in_place_and_clears() { + let mut d = wordcraft_doc::Document::from_text("Placeholder: update fields (F9).\nAlpha\nBody text.\nBeta\nGamma"); + { + let p = d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap(); + let n = p.len(); + p.insert_object(n, wordcraft_doc::InlineObject::FieldEnd, &wordcraft_doc::props::CharProps::default()).unwrap(); + p.insert_object( + 0, + wordcraft_doc::InlineObject::FieldStart { instr: r#"TOC \o "1-3" \h \z \u"#.into(), locked: false, dirty: true }, + &wordcraft_doc::props::CharProps::default(), + ) + .unwrap(); + } + let mut s = Session::new(d); + for (i, style) in [(1, "Heading 1"), (3, "Heading 2"), (4, "Heading 1")] { + run(&mut s, "caret.set", json!({"pos": Pos::body(i, 0)})); + run(&mut s, "styles.apply", json!({"style": style})); + } + // As an imported file: the flag survives the round trip, and a plain save keeps it. + s = Session::new(wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap()); + let toc_dirty = |d: &wordcraft_doc::Document| { + d.body.iter().find_map(|b| { + b.as_para()?.objects.iter().find_map(|o| match o { + wordcraft_doc::InlineObject::FieldStart { dirty, .. } => Some(*dirty), + _ => None, + }) + }) + }; + assert_eq!(toc_dirty(&s.doc), Some(true), "the imported TOC reads back dirty"); + let saved = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap(); + assert_eq!(toc_dirty(&saved), Some(true), "a plain save keeps the flag for Word to refresh"); + // Update Table replaces the placeholder in place and clears the flag. + run(&mut s, "references.updateToc", json!({})); + let t = text(&s); + assert!(!t.contains("Placeholder"), "{t}"); + for entry in ["Alpha\t", "Beta\t", "Gamma\t"] { + assert_eq!(t.matches(entry).count(), 1, "{t}"); + } + assert!(s.doc.field_ranges(StoryRef::Body).is_empty(), "now the engine's TOC"); + let updated = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap(); + assert_eq!(toc_dirty(&updated), Some(false), "the computed TOC no longer asks Word to refresh it"); + // Undo brings the dirty imported field back. + while s.undo() {} + assert_eq!(toc_dirty(&s.doc), Some(true)); + assert!(text(&s).contains("Placeholder")); +} + /// A TOC as Word and other writers lay it out (a TOC heading, then the field in paragraphs of its /// own), styles with a number tab stop and a hanging indent, and a wider last section with the /// caret in it (Select All, Update Field): no blank line between the heading and the entries, @@ -2401,7 +2484,8 @@ fn toc_page_numbers_reach_the_right_margin() { toc2.para.indent_left = Some(36.9); toc2.para.indent_first = Some(-22.7); let p = d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(1)).unwrap(); - p.insert_object(0, InlineObject::FieldStart { instr: r#"TOC \o "1-3" \h \z \u"#.into(), locked: false }, &CharProps::default()).unwrap(); + p.insert_object(0, InlineObject::FieldStart { instr: r#"TOC \o "1-3" \h \z \u"#.into(), locked: false, dirty: false }, &CharProps::default()) + .unwrap(); let p = d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(3)).unwrap(); p.insert_object(0, InlineObject::FieldEnd, &CharProps::default()).unwrap(); } diff --git a/crates/engine/src/tests_typing.rs b/crates/engine/src/tests_typing.rs index dd69c5cb5..fbbfd601d 100644 --- a/crates/engine/src/tests_typing.rs +++ b/crates/engine/src/tests_typing.rs @@ -1125,7 +1125,7 @@ fn typing_after_a_citation_goes_outside_its_field() { let c = CharProps::default(); let p = doc.para_mut(StoryRef::Body, &Path::top(0)).unwrap(); let start = p.len(); - p.insert_object(start, InlineObject::FieldStart { instr: "ADDIN ZOTERO_ITEM CSL_CITATION {}".into(), locked: false }, &c).unwrap(); + p.insert_object(start, InlineObject::FieldStart { instr: "ADDIN ZOTERO_ITEM CSL_CITATION {}".into(), locked: false, dirty: false }, &c).unwrap(); let n = p.insert_text(p.len(), "(Smith 2020)", &c).unwrap(); p.insert_object(start + 3 + n, InlineObject::FieldEnd, &c).unwrap(); let mut s = Session::new(doc); diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index ef4c182b5..eb9e073fe 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -1307,7 +1307,7 @@ fn headers_and_page_fields() { let mut hp = wordcraft_doc::Paragraph::new(); hp.insert_object( 0, - InlineObject::Field { instr: "PAGE".into(), result: String::new(), locked: false, code: false, form: String::new() }, + InlineObject::Field { instr: "PAGE".into(), result: String::new(), locked: false, dirty: false, code: false, form: String::new() }, &Default::default(), ) .unwrap(); @@ -4995,7 +4995,7 @@ fn enclosed_characters_draw_a_shape_around_the_text() { let e = Enclosure { shape, style, text: "A".into() }; d.insert_object( &Pos::body(0, 2), - InlineObject::Field { instr: e.instr(), result: "A".into(), locked: false, code: false, form: String::new() }, + InlineObject::Field { instr: e.instr(), result: "A".into(), locked: false, dirty: false, code: false, form: String::new() }, &Default::default(), ) .unwrap(); @@ -5031,7 +5031,7 @@ fn enclosed_characters_draw_a_shape_around_the_text() { let mut d = Document::from_text("x"); d.insert_object( &Pos::body(0, 1), - InlineObject::Field { instr: "eq \\o(=,/)".into(), result: String::new(), locked: false, code: false, form: String::new() }, + InlineObject::Field { instr: "eq \\o(=,/)".into(), result: String::new(), locked: false, dirty: false, code: false, form: String::new() }, &Default::default(), ) .unwrap(); @@ -5485,7 +5485,8 @@ fn unproofed_languages_and_no_proof_runs_are_not_marked() { #[test] fn body_page_totals_count_every_page() { let mut d = Document::from_text("Body-1\nBody-2\nBody-3"); - let field = |instr: &str| InlineObject::Field { instr: instr.into(), result: "9".into(), locked: false, code: false, form: String::new() }; + let field = + |instr: &str| InlineObject::Field { instr: instr.into(), result: "9".into(), locked: false, dirty: false, code: false, form: String::new() }; for i in 0..3 { let p = d.para_mut(StoryRef::Body, &Path::top(i)).unwrap(); p.props.page_break_before = Some(i > 0); @@ -5577,7 +5578,8 @@ fn east_asian_text_takes_the_asian_font_and_latin_text_the_latin_font() { /// the body lay out a second time; one in the body still does. #[test] fn footer_page_total_needs_one_body_pass() { - let field = || InlineObject::Field { instr: "NUMPAGES".into(), result: "9".into(), locked: false, code: false, form: String::new() }; + let field = + || InlineObject::Field { instr: "NUMPAGES".into(), result: "9".into(), locked: false, dirty: false, code: false, form: String::new() }; let mut d = Document::from_text("Body-1\nBody-2\nBody-3"); for i in 1..3 { d.para_mut(StoryRef::Body, &Path::top(i)).unwrap().props.page_break_before = Some(true); @@ -6464,7 +6466,7 @@ fn page_field_doc(before: usize, pieces: &[&str]) -> Document { let n = p.len(); p.insert_object( n, - InlineObject::Field { instr: "PAGE".into(), result: String::new(), locked: false, code: false, form: String::new() }, + InlineObject::Field { instr: "PAGE".into(), result: String::new(), locked: false, dirty: false, code: false, form: String::new() }, &Default::default(), ) .unwrap(); @@ -6581,7 +6583,7 @@ fn footnote_page_field_from_a_split_paragraph_shows_its_page() { let n = note.len(); note.insert_object( n, - InlineObject::Field { instr: "PAGE".into(), result: String::new(), locked: false, code: false, form: String::new() }, + InlineObject::Field { instr: "PAGE".into(), result: String::new(), locked: false, dirty: false, code: false, form: String::new() }, &Default::default(), ) .unwrap(); @@ -6660,7 +6662,7 @@ fn relaid_paragraphs_keep_their_equation_numbers() { let n = p2.len(); p2.insert_object( n, - InlineObject::Field { instr: "PAGE".into(), result: String::new(), locked: false, code: false, form: String::new() }, + InlineObject::Field { instr: "PAGE".into(), result: String::new(), locked: false, dirty: false, code: false, form: String::new() }, &Default::default(), ) .unwrap(); diff --git a/crates/layout/src/tests_pages.rs b/crates/layout/src/tests_pages.rs index 828cc31cb..c19252cb5 100644 --- a/crates/layout/src/tests_pages.rs +++ b/crates/layout/src/tests_pages.rs @@ -264,7 +264,8 @@ fn caret_in_a_continued_footnote_is_found_on_its_page() { /// own pages). The footer shows each letter's last page, not the file's stale result. #[test] fn page_references_in_footers_show_their_bookmarks_page() { - let field = |instr: &str| InlineObject::Field { instr: instr.into(), result: "9".into(), locked: false, code: false, form: String::new() }; + let field = + |instr: &str| InlineObject::Field { instr: instr.into(), result: "9".into(), locked: false, dirty: false, code: false, form: String::new() }; let footer = |d: &mut Document, mark: &str| { let mut p = Paragraph::with_text("Page of ", Default::default()); p.insert_object(5, field("PAGE"), &Default::default()).unwrap(); @@ -355,7 +356,14 @@ fn page_ref_footer(d: &mut Document, marks: &[&str]) -> u32 { let mut p = Paragraph::new(); for mark in marks { let n = p.len(); - let field = InlineObject::Field { instr: format!("PAGEREF {mark}"), result: "9".into(), locked: false, code: false, form: String::new() }; + let field = InlineObject::Field { + instr: format!("PAGEREF {mark}"), + result: "9".into(), + locked: false, + dirty: false, + code: false, + form: String::new(), + }; p.insert_object(n, field, &Default::default()).unwrap(); } let id = d.add_part(PartKind::Footer, vec![para_block(p)]); @@ -418,7 +426,8 @@ fn page_references_in_footer_text_boxes_are_resolved() { use wordcraft_doc::para::{Float, ShapeKind, Wrap}; let text_box = |d: &mut Document| { let mut p = Paragraph::new(); - let field = InlineObject::Field { instr: "PAGEREF End".into(), result: "9".into(), locked: false, code: false, form: String::new() }; + let field = + InlineObject::Field { instr: "PAGEREF End".into(), result: "9".into(), locked: false, dirty: false, code: false, form: String::new() }; p.insert_object(0, field, &Default::default()).unwrap(); let story = d.add_part(PartKind::TextBox, vec![para_block(p)]); InlineObject::Shape { diff --git a/crates/layout/src/tests_styleref.rs b/crates/layout/src/tests_styleref.rs index 4bc34d51b..cba5d9f8c 100644 --- a/crates/layout/src/tests_styleref.rs +++ b/crates/layout/src/tests_styleref.rs @@ -6,7 +6,7 @@ use wordcraft_doc::props::{CharProps, NumRef}; use wordcraft_doc::{ListKind, PartKind, Pos, para_block}; fn field(instr: &str) -> InlineObject { - InlineObject::Field { instr: instr.into(), result: "stale".into(), locked: false, code: false, form: String::new() } + InlineObject::Field { instr: instr.into(), result: "stale".into(), locked: false, dirty: false, code: false, form: String::new() } } /// Body paragraphs: `(style, text, page break before)`; a header with one STYLEREF per code. diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 886172bbf..c7cc44592 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -455,7 +455,14 @@ fn fields_put_their_shown_text_in_the_text_layer() { // characters, are what copying, search and screen readers get. let mut d = Document::new(); let mut f = Paragraph::with_text("Page of ", CharProps::default()); - let field = |instr: &str| InlineObject::Field { instr: instr.into(), result: String::new(), locked: false, code: false, form: String::new() }; + let field = |instr: &str| InlineObject::Field { + instr: instr.into(), + result: String::new(), + locked: false, + dirty: false, + code: false, + form: String::new(), + }; f.insert_object(5, field("PAGE"), &CharProps::default()).unwrap(); let n = f.len(); f.insert_object(n, field("NUMPAGES"), &CharProps::default()).unwrap(); @@ -477,8 +484,14 @@ fn fields_put_their_shown_text_in_the_text_layer() { fn styleref_headers_show_each_page_s_chapter() { let mut d = Document::new(); let mut h = Paragraph::new(); - let field = - InlineObject::Field { instr: r#"STYLEREF "Heading 1""#.into(), result: "Stale".into(), locked: false, code: false, form: String::new() }; + let field = InlineObject::Field { + instr: r#"STYLEREF "Heading 1""#.into(), + result: "Stale".into(), + locked: false, + dirty: false, + code: false, + form: String::new(), + }; h.insert_object(0, field, &CharProps::default()).unwrap(); let id = d.add_part(wordcraft_doc::PartKind::Header, vec![para_block(h)]); d.last_section.headers.default = Some(id); @@ -716,7 +729,14 @@ fn field_results_are_extractable_text() { let mut p = Paragraph::with_text("See for details.", CharProps::default()); p.insert_object( 4, - InlineObject::Field { instr: " REF _RefTarget \\h ".into(), result: "Section 3.01".into(), locked: false, code: false, form: String::new() }, + InlineObject::Field { + instr: " REF _RefTarget \\h ".into(), + result: "Section 3.01".into(), + locked: false, + dirty: false, + code: false, + form: String::new(), + }, &CharProps::default(), ) .unwrap(); @@ -739,7 +759,14 @@ fn field_result_ligatures_extract_all_their_letters() { let mut p = Paragraph::with_text("See here.", font.clone()); p.insert_object( 4, - InlineObject::Field { instr: " REF _RefTarget \\h ".into(), result: result.into(), locked: false, code: false, form: String::new() }, + InlineObject::Field { + instr: " REF _RefTarget \\h ".into(), + result: result.into(), + locked: false, + dirty: false, + code: false, + form: String::new(), + }, &font, ) .unwrap(); diff --git a/crates/zotero/src/bridge.rs b/crates/zotero/src/bridge.rs index c09dd7865..b8dfaedac 100644 --- a/crates/zotero/src/bridge.rs +++ b/crates/zotero/src/bridge.rs @@ -390,7 +390,7 @@ impl Bridge { let start = at.clone(); let mut cur = s .doc - .insert_object(&start, InlineObject::FieldStart { instr: fields::PREFIX.to_string(), locked: false }, &base) + .insert_object(&start, InlineObject::FieldStart { instr: fields::PREFIX.to_string(), locked: false, dirty: false }, &base) .map_err(|e| e.to_string())?; match content { Some(frag) => cur = s.doc.insert_fragment(&cur, &frag).map_err(|e| e.to_string())?, diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 36a372739..c328e3f0a 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -208,6 +208,7 @@ attributes inside stories and style or list definitions; a regenerated theme. Te | Date | Change | Summary | |---|---|---| +| 2026-10-12 | trivial | Fields keep `w:dirty` through DOCX round trips — on buffered fields and range-field begin markers alike — and computing a result (Update Field, Update Table) clears it like Word's F9, so a generator's placeholder TOC still refreshes when Word opens the file until WordCraft replaces the placeholder (#877) | | 2026-10-14 | trivial | VML freeforms (`v:shape` with a `path`, such as rule lines in headers) read as freeforms and kept on save instead of being dropped | | 2026-10-14 | trivial | DOCX paragraphs inside complex-field instructions carry a hidden paragraph mark; cached result paragraphs remain visible. | | 2026-10-14 | trivial | DrawingML `flowChartOr` preserves its circle and crossing spokes, including when grouped or rotated | From 0aab56934ba0fef8ef6855745c463e519585039e Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 15 Oct 2026 00:28:09 -0400 Subject: [PATCH 086/101] Docs: drop a stray merge marker from the ROADMAP progress log (#1962) #906's branch carried a '>>>>>>> upstream/main' line from its own merge. Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 1 - 1 file changed, 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 3e91d26b4..92bad5c75 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -275,7 +275,6 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-12 | Section breaks (#852): Layout › Breaks with text selected puts the break before the selection and keeps the text, as Word does; it used to delete it, leaving the first section empty | | 2026-10-12 | Find, Replace and Go To like Word (#712): Ctrl+F types into the Navigation pane's search, one tabbed Find and Replace dialog, Go To by page (absolute or relative) and bookmark; `edit.findReplace`, `edit.goto` `pageBy` | | 2026-10-12 | Symbol fonts (#776): text in Wingdings, Wingdings 2, Wingdings 3, Webdings and Symbol (proprietary, never bundled) shows its intended symbols — full code tables from Unicode's published correspondence, both storage forms (`w:sym` private-use codes and plain codes); PDF text, copy and TXT export use the Unicode characters; the saved text is unchanged | ->>>>>>> upstream/main | 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) | |---|---| From 5c8dba229f43656dfa3cbc5b1bdfc251e4ade2d8 Mon Sep 17 00:00:00 2001 From: Luis Araya <35828647+Totopo27@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:32:19 -0600 Subject: [PATCH 087/101] fix(references): clear imported TOC field result on update (#878) (#953) Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/references.rs | 13 ++++++ crates/engine/src/tests.rs | 61 +++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/crates/engine/src/cmd/references.rs b/crates/engine/src/cmd/references.rs index cf45e3e1e..5961cc4e4 100644 --- a/crates/engine/src/cmd/references.rs +++ b/crates/engine/src/cmd/references.rs @@ -502,6 +502,19 @@ fn regenerate_toc(s: &mut Session, leader: Option) -> Result<(), CmdE 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(()) }; + // A TOC field imported with an inline placeholder/cached result must have its result cleared so + // that the engine's TOC shape matches `toc_span` and the DOCX writer spans the field across entries. + if let Ok(p) = s.doc.para_mut(StoryRef::Body, &Path::top(start)) { + for o in &mut p.objects { + if let InlineObject::Field { instr, result, .. } = o { + let f = FieldCode::parse(instr); + if f.name == "TOC" && !f.has('c') { + result.clear(); + } + } + } + p.touch(); + } let f = FieldCode::parse(&instr); let leader = leader.unwrap_or_else(|| { s.doc diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 8b103ca9f..cc60e8120 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2381,6 +2381,67 @@ fn imported_range_toc_is_refreshed_in_place() { } } +/// Updating a TOC field that had a non-empty result (placeholder from an external generator, #878) +/// clears the old result so the exported DOCX nests entries inside the field rather than leaving +/// them after the field's end marker. +#[test] +fn update_toc_clears_stale_field_result_for_docx_span() { + use wordcraft_doc::para::InlineObject; + use wordcraft_doc::props::CharProps; + let mut d = wordcraft_doc::Document::from_text("\nAlpha\nBeta"); + { + let p = d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap(); + p.insert_object( + 0, + InlineObject::Field { + instr: r#"TOC \o "1-3" \h \z \u"#.into(), + result: "Placeholder: update fields (F9).".into(), + locked: false, + dirty: false, + code: false, + form: String::new(), + }, + &CharProps::default(), + ) + .unwrap(); + } + let mut s = Session::new(d); + for (i, style) in [(1, "Heading 1"), (2, "Heading 2")] { + run(&mut s, "caret.set", json!({"pos": Pos::body(i, 0)})); + run(&mut s, "styles.apply", json!({"style": style})); + } + // Before update: the field result is present in plain text. + assert!(text(&s).contains("Placeholder: update fields (F9).")); + + run(&mut s, "references.updateToc", json!({})); + + // After update: the placeholder is gone, replaced by regenerated entries. + let refreshed = text(&s); + assert!(!refreshed.contains("Placeholder"), "{refreshed}"); + assert!(refreshed.contains("Alpha\t"), "{refreshed}"); + assert!(refreshed.contains("Beta\t"), "{refreshed}"); + + // DOCX round-trip: entries must be enclosed within the TOC field rather than outside it. + let bytes = wordcraft_docx::write(&s.doc).unwrap(); + let xml = { + let cursor = std::io::Cursor::new(bytes); + let mut archive = zip::ZipArchive::new(cursor).unwrap(); + let mut file = archive.by_name("word/document.xml").unwrap(); + let mut s = String::new(); + std::io::Read::read_to_string(&mut file, &mut s).unwrap(); + s + }; + let b = xml.find(r#"w:fldCharType="begin""#).expect("TOC begin marker present"); + let e = xml.find(r#"w:fldCharType="end""#).expect("TOC end marker present"); + assert!(b < e, "begin marker precedes end marker"); + let inside_field = &xml[b..e]; + let after_field = &xml[e..]; + assert!(inside_field.contains("Alpha"), "Alpha entry is inside TOC field"); + assert!(inside_field.contains("Beta"), "Beta entry is inside TOC field"); + assert!(!inside_field.contains("Placeholder"), "Placeholder is not inside TOC field"); + assert!(!after_field.contains(r#"w:val="TOC1""#), "no TOC1 styles after the field end marker"); +} + /// #877: computing a field's result clears `w:dirty` — the result is no longer stale, like /// Word's F9. A field WordCraft can't compute keeps the flag so Word refreshes it on open. #[test] From cb6418dc6c2ef5521d2f41d0238342dd564eaaf4 Mon Sep 17 00:00:00 2001 From: rabis7575 <340973947+rabis7575@users.noreply.github.com> Date: Thu, 15 Oct 2026 06:35:32 +0200 Subject: [PATCH 088/101] Tables: one selected cell's border drags in its row only (#970) Selecting one cell (Table Layout > Select > Select Cell, or a click along its left edge) and dragging its left or right border moved the border in the whole column: one selected cell wasn't told apart from text selected inside it. `ResizeTarget::limit_to_selection` now takes the selected cells from `Session::cell_range` (#637) when they're in the dragged table, so one selected cell's border moves in its row only, as in Word, like the border of cells selected across (#720). Text selected within a cell still moves the whole column. Tests: engine `a_selected_cells_border_moves_only_in_its_row`; kittest `a_selected_cells_border_drags_in_its_row_only` (a click along the cell's edge, then a real drag). Progress docs: ui-parity, ROADMAP. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 2 + crates/engine/src/cmd/table_resize.rs | 73 ++++++++++++++++++++------- crates/ui-egui/src/table_resize.rs | 22 ++++++++ docs/ui-parity.md | 1 + 4 files changed, 79 insertions(+), 19 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 92bad5c75..5d7fc8e1a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -278,6 +278,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 | Tables: dragging the border of one selected cell (Select Cell, or a click along its left edge) moves it in that cell's row only, as in Word, like the border of cells selected across (#962) | | 2026-10-13 | Korean interface catalog completed: all 2,463 entries translated; the ko-KR system locale and saved language choice select the interface. The UI prefers Korean embedded/system font faces so Hangul renders instead of boxes, and print settings and printer feedback are translated. Korean proofing remains unavailable; macOS Korean IME fixes are still pending (#163, #164) | | 2026-10-12 | Word default shortcuts expanded (gap #24): 140 commands bound on macOS (169 combinations), 158 on Linux/Windows (188 combinations). Bound Style Inspector (Shift+F1), Macros (Alt+F8), Strikethrough (Mod+Shift+X), Find Next (Shift+F4), Copy/Paste (Mod+Insert, Shift+Insert), Go Back (Mod+Alt+Z), File Open/Save/Save As/Print (Mod+Alt+F2, Alt+Shift+F2, Alt+F2, Mod+Alt+I), Field navigation (Alt+F1, Alt+Shift+F1), Outline show first line (Alt+Shift+L), Next Footnote (Alt+Shift+>), Link to Previous (Alt+Shift+R), AutoText (Alt+F3), Spelling on Mac (Mod+Alt+L) | | 2026-10-13 | Web and Draft Layout now render visible full-resolution raster tiles for exceptionally tall pages rather than downsampling the entire page into one texture; verified with a 440-paragraph headless UI reproduction | @@ -395,6 +396,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-12 | trivial | Progress log: one selected cell's border drags in its row only (#962) | | 2026-10-12 | minor | Progress log: Word default shortcuts expanded to 140 commands on macOS (169 combinations) and 158 on Linux/Windows (188 combinations), addressing gap #24 | | 2026-10-13 | trivial | Web/Draft long-page blur fixed with visible full-resolution raster tiles | | 2026-10-14 | trivial | Progress log: VML freeforms read and kept on save | diff --git a/crates/engine/src/cmd/table_resize.rs b/crates/engine/src/cmd/table_resize.rs index 0a39febae..70f04f0c4 100644 --- a/crates/engine/src/cmd/table_resize.rs +++ b/crates/engine/src/cmd/table_resize.rs @@ -210,30 +210,20 @@ impl ResizeTarget { } } - /// When the selection runs across cells of this table (Word selects them whole) and the - /// column border grabbed at page y `y` is one of theirs, in one of their rows, the drag - /// moves it only in those rows (`only`), as Word does with selected cells' borders. Not the - /// table's left edge, which moves the whole table, nor a border of a cell merged down into - /// rows outside the selection. Text selected within one cell isn't a cell selection. + /// When cells of this table are selected whole (one cell too) or the selection runs across + /// cells of it (Word selects them whole), and the column border grabbed at page y `y` is one + /// of theirs, in one of their rows, the drag moves it only in those rows (`only`), as Word + /// does with selected cells' borders. Not the table's left edge, which moves the whole table, + /// nor a border of a cell merged down into rows outside the selection. Text selected within + /// one cell isn't a cell selection. pub fn limit_to_selection(&mut self, s: &Session, y: f32) { self.only.clear(); let Edge::Column(line) = self.edge else { return }; - let (a, b) = s.sel.ordered(); - if line == 0 || s.sel.is_collapsed() || a.story != self.story || b.story != self.story { + if line == 0 { return; } - let (Some(t), Some((ra, ca)), Some((rb, cb))) = - (s.doc.table(self.story, &self.table), cell_in(&self.table, &a.path), cell_in(&self.table, &b.path)) - else { - return; - }; - if (ra, ca) == (rb, cb) { - return; - } - let span = |r: usize, c: usize| t.rows.get(r).and_then(|row| row.cells.get(c)).map_or(1, Cell::span); - let (ga, gb) = (t.grid_col(ra, ca), t.grid_col(rb, cb)); - let (lo, hi) = (ga.min(gb), (ga + span(ra, ca)).max(gb + span(rb, cb))); - let (r0, r1) = (ra.min(rb), ra.max(rb)); + let Some(t) = s.doc.table(self.story, &self.table) else { return }; + let Some(((r0, r1), (lo, hi))) = self.selected_block(s, t) else { return }; let here = self.rows.iter().find(|r| y >= r.top && y <= r.bottom).map(|r| r.row); if line < lo || line > hi || !here.is_some_and(|r| (r0..=r1).contains(&r)) { return; @@ -258,6 +248,30 @@ impl ResizeTarget { } self.only = only; } + + /// The selected cells of this table, as their rows (first, last) and the grid lines of + /// their outer borders (left, right): whole cells (`Session::cell_range`), or a selection + /// running from one of its cells into another. `None` for text within one cell. + fn selected_block(&self, s: &Session, t: &Table) -> Option<((usize, usize), (usize, usize))> { + let lines = |cells: &[(usize, usize)]| -> Option<(usize, usize)> { + let span = |r: usize, c: usize| t.rows.get(r).and_then(|row| row.cells.get(c)).map_or(1, Cell::span); + let lo = cells.iter().map(|&(r, c)| t.grid_col(r, c)).min()?; + let hi = cells.iter().map(|&(r, c)| t.grid_col(r, c).saturating_add(span(r, c))).max()?; + Some((lo, hi)) + }; + if let Some(range) = s.cell_range().filter(|c| c.story == self.story && c.table == self.table) { + return Some((range.rows, lines(&range.cells(t))?)); + } + let (a, b) = s.sel.ordered(); + if s.sel.is_collapsed() || a.story != self.story || b.story != self.story { + return None; + } + let ((ra, ca), (rb, cb)) = (cell_in(&self.table, &a.path)?, cell_in(&self.table, &b.path)?); + if (ra, ca) == (rb, cb) { + return None; + } + Some(((ra.min(rb), ra.max(rb)), lines(&[(ra, ca), (rb, cb)])?)) + } } /// Moves the border right of column `i` of `w` (column widths, left to right) by up to `dx` @@ -964,6 +978,27 @@ mod tests { assert_eq!(first_table(&s), start, "one undo step each"); } + /// One selected cell (Select Cell, unlike its text) is a cell selection too: its border + /// moves in its row only (#962). + #[test] + fn a_selected_cells_border_moves_only_in_its_row() { + let mut s = table_session(3, 3); + let (b, c) = (cell_rect(&mut s, 1, 1), cell_rect(&mut s, 1, 2)); + let y = b.y + b.h / 2.0; + s.run("caret.set", &json!({"page": 0, "x": b.x + b.w / 2.0, "y": y})).unwrap(); + s.run("table.selectCell", &json!({})).unwrap(); + assert!(s.cell_range().is_some()); + let start = first_table(&s); + s.run("table.resizeDrag", &json!({"page": 0, "x": b.right(), "y": y, "toX": b.right() + 30.0, "toY": y})).unwrap(); + let (b1, c1) = (cell_rect(&mut s, 1, 1), cell_rect(&mut s, 1, 2)); + assert!((b1.w - (b.w + 30.0)).abs() < 1.0 && (c1.w - (c.w - 30.0)).abs() < 1.0, "{b1:?} {c1:?}"); + for r in [0, 2] { + assert!((cell_rect(&mut s, r, 1).right() - b.right()).abs() < 0.5, "row {r} keeps its border"); + } + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(first_table(&s), start, "one undo step"); + } + /// A cell merged down past the selection keeps one width: a border of it moves in the whole /// column. #[test] diff --git a/crates/ui-egui/src/table_resize.rs b/crates/ui-egui/src/table_resize.rs index add918d2a..67be6b86a 100644 --- a/crates/ui-egui/src/table_resize.rs +++ b/crates/ui-egui/src/table_resize.rs @@ -565,6 +565,28 @@ pub(crate) mod tests { assert!(widths(&mut h).is_empty(), "gone with the drag"); } + /// A cell selected with a click along its left edge: dragging its right border with the + /// mouse moves it in that cell's row only (#962). + #[test] + fn a_selected_cells_border_drags_in_its_row_only() { + let mut h = table_window(); + let (above, b, c) = (cell(&mut h, 0, 1), cell(&mut h, 1, 1), cell(&mut h, 1, 2)); + let y = b.y + b.h / 2.0; + let at = screen(&mut h, b.x + 4.5, y); + h.event(egui::Event::PointerMoved(at)); + h.step(); + button(&h, at, true); + h.step(); + button(&h, at, false); + steps(&mut h, 2); + assert_eq!(h.state().session.cell_range().map(|r| (r.rows, r.cols)), Some(((1, 1), (1, 1))), "the cell is selected"); + let (start, end) = (screen(&mut h, b.right(), y), screen(&mut h, b.right() + 30.0, y)); + drag(&mut h, start, end); + let (b1, c1) = (cell(&mut h, 1, 1), cell(&mut h, 1, 2)); + assert!((b1.w - (b.w + 30.0)).abs() < 1.0 && (c1.w - (c.w - 30.0)).abs() < 1.0, "{b1:?} {c1:?}"); + assert!((cell(&mut h, 0, 1).right() - above.right()).abs() < 0.5, "the first row keeps its border"); + } + /// Clicks inside a cell still place the caret (the border doesn't steal them), and Esc drops a /// drag without changing anything. #[test] diff --git a/docs/ui-parity.md b/docs/ui-parity.md index bbbb25af8..2b8c4f539 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -52,6 +52,7 @@ Document title opens Rename Document; the account badge opens Options › Genera | Date | Change | Summary | |---|---|---| +| 2026-10-12 | trivial | Tables on the page: one selected cell (Select Cell, or a click along its left edge) is a cell selection when its border is dragged: the border moves in its row only (#962) | | 2026-10-12 | minor | Default keyboard shortcuts expanded to 140 commands on macOS (169 combinations) and 158 on Linux/Windows (188 combinations): Style Inspector (Shift+F1), Macros (Alt+F8), Strikethrough (Mod+Shift+X), Find Next (Shift+F4), Copy/Paste Insert keys (Mod+Insert, Shift+Insert), Go Back (Mod+Alt+Z), File Open/Save/Save As/Print (Mod+Alt+F2, Alt+Shift+F2, Alt+F2, Mod+Alt+I), Field navigation (Alt+F1, Alt+Shift+F1), Outline show first line (Alt+Shift+L), Next Footnote (Alt+Shift+>), Link to Previous (Alt+Shift+R), AutoText (Alt+F3), Spelling on Mac (Mod+Alt+L) | | 2026-10-13 | trivial | Web/Draft Layout long-page blur fixed using visible full-resolution raster tiles, tested against a 440-paragraph document | | 2026-10-14 | trivial | A click on the ribbon's chrome that isn't a command button (a Styles gallery tile, a tab name, empty ribbon space, a View check box, a status-bar item) hands the keyboard to the page in the same frame, as a ribbon command button does: typing right after the click reaches the document instead of being swallowed (#1093 follow-up) | From 94bfc29b0b6920aa62e5f21c9ac578df6c72f91e Mon Sep 17 00:00:00 2001 From: iot-root <140926906+iot-root@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:40:35 -0400 Subject: [PATCH 089/101] Tab stops: edit on the ruler (corner selector, click, drag, drag off) and an inline-edit table in the Tabs dialog (#976) * Ruler tab stops: corner selector, click to set, drag to move, drag off to clear The ruler's markers were read-only (double-click opened the Tabs dialog, click sets (Left, Center, Right, Decimal, Bar; right-click cycles back), clicking the ruler sets one on the half-division grid (Alt places freely), dragging moves it with a dashed guide down the page and a position tooltip, and dragging it off the ruler clears it. Markers draw per kind, mirrored for right-to-left paragraphs. Each gesture runs para.tabs once on release, so it is one undo step and inherited-stop clearing keeps working. Double-click still opens the dialog. Co-Authored-By: Claude Fable 5 * Progress docs: ruler tab stops (ui-parity Rulers row and revision history, ROADMAP progress log) Co-Authored-By: Claude Fable 5 * Tabs dialog: OK applies what the fields show; double-click a stop to edit its position Selecting a stop, changing its alignment or leader and pressing OK now applies the change without Set, and a new position typed while a stop is selected (the dialog preselects the first) is set on OK, so adding a stop never requires leaving the dialog for the ruler. Double-clicking a stop in the list edits its position in place: Enter or clicking away commits (the interface unit by default, any unit spelled out, either decimal separator), Escape cancels, and the commit moves the stop in one clear + set, keeping its alignment and leader. Clear still wins over a re-typed cleared position, and junk input changes nothing. Co-Authored-By: Claude Fable 5 * Tabs dialog: New button, delete from the list, and the list fills its frame New stages a fresh stop - deselects, proposes the next free multiple of the default interval after the last stop (skipping cleared positions), resets alignment and leader, and focuses the position field - and Set or OK adds it. Every row gets a small x that clears its stop, and the Delete key clears the selected one when nothing is being typed; the selection and an in-progress inline edit follow when a row above them goes. The list is a fixed 150 pt wide and the rows and scrollbar fill it, so labels stay left-aligned, the x sits at the frame's edge clear of the scrollbar, and the scrollbar docks at the edge instead of floating mid-list. Co-Authored-By: Claude Fable 5 * Tabs dialog: the stop list is an inline-edit table Each row edits its stop in place: a unit-aware position field (rows resort into position order once the edit ends, never mid-typing; two stops landing on one position merge, the edited row winning), an alignment dropdown and a leader dropdown (the closed box shows just the fill), and the x that clears the row. The double-click editor and move_stop go: the table subsumes them. The selected row keeps an accent outline, its edits sync the fields below, and the frame widens to fit the three columns. Co-Authored-By: Claude Fable 5 * Tabs dialog: the table is the whole editor New adds a row straight into the table at the next free multiple of the default interval, selected and ready to edit in place, and the position field, the Alignment and Leader radio groups and the Set and Clear buttons go: every one of their jobs is a table cell or the row's x. What stays is New, Clear All, the default interval, the cleared list and OK/Cancel, with OK applying the staged diff as one para.tabs call. The staged-field methods (set, clear, on_ok) go with them. Co-Authored-By: Claude Fable 5 * Review fixes: safe ruler gestures, one tab tolerance, dialog cleanups Ruler gestures are primary-button only, a click on an indent marker places nothing (the markers absorb their clicks), Escape cancels a drag like a table-border drag (#217), and a drag is stamped with its paragraph: the caret leaving it mid-drag, or a release the ruler never saw, abandons the gesture instead of applying it elsewhere or late. The dragged marker no longer double-draws on its first frame. One TAB_POS_EPS in the doc layer replaces four hardcoded 0.5s across the engine, the dialog and the ruler. The dialog's normalize is a no-op unless a position edit happened, its combos share the ruler's KINDS and one LEADERS table, dead code and a stale doc comment go, and the ui-parity revision row moves to the table's top. Regression tests cover each gesture rule. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 1 + crates/doc/src/props.rs | 4 + crates/engine/src/cmd/para.rs | 2 +- crates/ui-egui/src/canvas.rs | 234 ++++++++++++++++-- crates/ui-egui/src/dialogs_para.rs | 377 +++++++++++++++++++++-------- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/ruler_tabs.rs | 301 +++++++++++++++++++++++ docs/ui-parity.md | 3 +- 8 files changed, 803 insertions(+), 120 deletions(-) create mode 100644 crates/ui-egui/src/ruler_tabs.rs diff --git a/ROADMAP.md b/ROADMAP.md index 5d7fc8e1a..31ab8bcf3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -135,6 +135,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log | Date | What landed | +| 2026-10-14 | Tab stops edited on the ruler: the corner selector picks Left/Center/Right/Decimal/Bar, a click sets a stop (Alt skips the snap), dragging moves it with a guide line and position tooltip, dragging off the ruler clears it; markers draw per kind and mirror for RTL; one `para.tabs` call and one undo step per gesture | | 2026-10-12 | Fields keep `w:dirty` through DOCX saves (#877): a begin marker flagged by a generator that couldn't compute a result (pandoc-style TOC placeholders) keeps the flag on open/save — as a buffered field or a range field — so Word still refreshes it on open; computing a result (Update Field, Update Table) clears it, like Word's F9 | |---|---| | 2026-10-14 | Consistent preferred cell widths override a stale table grid when both totals agree, for fixed and AutoFit tables. Conflicting widths, spans and percentages remain unchanged. | diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index 31960a7df..f6a5fcb8e 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -554,6 +554,10 @@ impl TabLeader { } } +/// Two tab positions closer than this (points) are the same stop: `para.tabs` replaces at this +/// tolerance, and the Tabs dialog and the ruler merge and detect no-ops with it. +pub const TAB_POS_EPS: f32 = 0.5; + #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] pub struct TabStop { /// Points from the paragraph's left indent origin (the text column's left edge). diff --git a/crates/engine/src/cmd/para.rs b/crates/engine/src/cmd/para.rs index 9b7885132..b20edbef3 100644 --- a/crates/engine/src/cmd/para.rs +++ b/crates/engine/src/cmd/para.rs @@ -619,7 +619,7 @@ fn tabs(s: &mut Session, v: &Value) -> CmdResult { return sel_result(s); } let styles = s.doc.styles.clone(); - let near = |a: f32, b: f32| (a - b).abs() < 0.5; + let near = |a: f32, b: f32| (a - b).abs() < wordcraft_doc::props::TAB_POS_EPS; fmt(s, &|p| { // The stops the paragraph inherits from its style. let inherited = styles.resolve_para(&ParaProps { tabs: None, ..p.clone() }).tabs; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index e43dbc126..ba2557df0 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -159,6 +159,10 @@ pub struct CanvasState { pub(crate) a11y: crate::a11y::A11yCache, /// A press on a table's selection area or move handle, held (`table_mouse`). pub(crate) table_drag: Option, + /// The kind of tab stop a click on the ruler sets (the selector in the rulers' corner). + pub tab_kind: wordcraft_doc::props::TabAlign, + /// A tab stop being placed or dragged on the ruler (`ruler_tabs`). + pub(crate) tab_drag: Option, } impl CanvasState { @@ -245,6 +249,8 @@ impl Default for CanvasState { link_from: None, a11y: Default::default(), table_drag: None, + tab_kind: Default::default(), + tab_drag: None, } } } @@ -2282,9 +2288,11 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo fn rulers(app: &mut WordApp, ui: &mut Ui, h: Rect, v: Rect, rects: &[Rect], layout: &DocLayout, scale: f32) { let t = Tokens::get(ui.ctx()); let p = ui.painter(); - p.rect_filled(Rect::from_min_max(pos2(v.min.x, h.min.y), pos2(v.max.x, h.max.y)), 0.0, t.canvas); + let corner = Rect::from_min_max(pos2(v.min.x, h.min.y), pos2(v.max.x, h.max.y)); + p.rect_filled(corner, 0.0, t.canvas); p.rect_filled(h, 0.0, t.canvas); p.rect_filled(v, 0.0, t.canvas); + crate::ruler_tabs::selector(app, ui, corner); let pi = app.session.page_hint.min(layout.pages.len().saturating_sub(1)); let (Some(page), Some(pr)) = (layout.pages.get(pi), rects.get(pi)) else { return }; let scale = page_screen_scale(*pr, page, scale); @@ -2327,9 +2335,6 @@ fn rulers(app: &mut WordApp, ui: &mut Ui, h: Rect, v: Rect, rects: &[Rect], layo ] { margin_handle(app, ui, edge, zone, |p| (p.x - x0) / scale, §); } - // In a table, its column borders; registered before the indent markers, which keep the - // pointer where they overlap. - crate::table_ruler::columns(app, ui, layout, pi, bar, x0, scale); // Indent markers for the caret's paragraph (relative to its column, or in a table to the // cell's text). let (col_x, w) = crate::table_ruler::text_area(app, layout, pi).unwrap_or((page.body.x, page.body.w)); @@ -2366,23 +2371,18 @@ fn rulers(app: &mut WordApp, ui: &mut Ui, h: Rect, v: Rect, rects: &[Rect], layo t.ruler, Stroke::new(1.0, c), )); - let mut open_tabs = false; - for (i, tab) in rp.tabs.iter().enumerate() { - let tx = px(tab.pos); - let foot = if rtl { -4.0 } else { 4.0 }; - hp.line_segment([pos2(tx, bar.max.y - 6.0), pos2(tx, bar.max.y - 1.0)], Stroke::new(1.5, t.text)); - hp.line_segment([pos2(tx, bar.max.y - 1.0), pos2(tx + foot, bar.max.y - 1.0)], Stroke::new(1.5, t.text)); - // Double-clicking a tab marker opens the Tabs dialog (#320). - let zone = Rect::from_center_size(pos2(tx, bar.max.y - 4.0), vec2(8.0, 9.0)); - open_tabs |= ui.interact(zone, ui.id().with(("ruler_tab", i)), Sense::click()).double_clicked(); - } - if open_tabs { - let _ = app.run("para.tabs", json!({})); - } + // Tab stops: drawn and registered before the indent markers' drags, which win where they overlap. + let step = unit / div as f32 / 2.0; + let ruler = crate::ruler_tabs::Ruler { bar, text, width: w, step, rtl, x_of: &px, dist_at: &dist_at }; + crate::ruler_tabs::stops(app, ui, &hp, &ruler, &rp.tabs); + // In a table, its column borders: registered after the tab stops (whose set-a-stop zone + // spans the text area, so a border marker keeps its drag) and before the indent markers, + // which keep the pointer where they overlap. + crate::table_ruler::columns(app, ui, layout, pi, bar, x0, scale); // Dragging the left-indent marker. let id = ui.id().with("ruler_left"); let mr = Rect::from_center_size(pos2(left, bar.max.y - 3.0), vec2(12.0, 12.0)); - let r = ui.interact(mr, id, Sense::drag()); + let r = ui.interact(mr, id, Sense::click_and_drag()); if r.dragged() && let Some(pp) = r.interact_pointer_pos() { @@ -2394,7 +2394,7 @@ fn rulers(app: &mut WordApp, ui: &mut Ui, h: Rect, v: Rect, rects: &[Rect], layo let _ = app.run("para.indents", json!({"left": snapped})); } let fr = Rect::from_center_size(pos2(first, bar.min.y + 3.0), vec2(12.0, 10.0)); - let r = ui.interact(fr, ui.id().with("ruler_first"), Sense::drag()); + let r = ui.interact(fr, ui.id().with("ruler_first"), Sense::click_and_drag()); if r.dragged() && let Some(pp) = r.interact_pointer_pos() { @@ -2405,7 +2405,7 @@ fn rulers(app: &mut WordApp, ui: &mut Ui, h: Rect, v: Rect, rects: &[Rect], layo let _ = app.run("para.indents", json!({"firstLine": (pt / 4.5).round() * 4.5})); } let rr = Rect::from_center_size(pos2(right, bar.max.y - 3.0), vec2(12.0, 12.0)); - let r = ui.interact(rr, ui.id().with("ruler_right"), Sense::drag()); + let r = ui.interact(rr, ui.id().with("ruler_right"), Sense::click_and_drag()); if r.dragged() && let Some(pp) = r.interact_pointer_pos() { @@ -4353,6 +4353,200 @@ mod tests { assert!((after2 - (2.0 * centre - before2)).abs() < 1.0, "2 mirrors about the page centre: {before2} → {after2}"); } + /// Ruler tab gestures are safe: a click on an indent marker places nothing, only the + /// primary button places or moves stops, Escape cancels a drag, and a drag whose paragraph + /// the caret leaves is abandoned instead of applied there. + #[test] + fn ruler_tab_gestures_are_primary_only_and_cancellable() { + use egui::{Event, Key, PointerButton}; + use wordcraft_doc::props::TabAlign; + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + a.run("text.insert", json!({"text": "Name\tScore\rSecond"})).unwrap(); + a.run("view.ruler", json!({"value": true})).unwrap(); + a.run("view.commentsPane", json!({"value": false})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.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(); + } + let area = h.state().canvas.canvas_rect.expect("the canvas is laid out"); + let ruler_x = |h: &mut egui_kittest::Harness<'_, WordApp>, d: f32| { + let body_x = h.state_mut().session.layout().pages[0].body.x; + page_to_screen(h.state_mut(), 0, body_x + d, 0.0).expect("on screen").x + }; + let on_ruler = |h: &mut egui_kittest::Harness<'_, WordApp>, d: f32| pos2(ruler_x(h, d), area.min.y - 7.0); + let button = |at: Pos2, b: PointerButton, pressed: bool| Event::PointerButton { pos: at, button: b, pressed, modifiers: Default::default() }; + let drag_events = |h: &mut egui_kittest::Harness<'_, WordApp>, from: Pos2, to: Pos2, b: PointerButton| { + h.event(Event::PointerMoved(from)); + h.event(button(from, b, true)); + h.step(); + for k in 1..=4 { + h.event(Event::PointerMoved(from + (to - from) * (k as f32 / 4.0))); + h.step(); + } + h.event(button(to, b, false)); + for _ in 0..3 { + h.step(); + } + }; + let stops_of = |h: &egui_kittest::Harness<'_, WordApp>, block: usize| { + let s = &h.state().session; + let para = s.doc.para_at(&Pos::body(block, 0)).expect("a paragraph"); + s.doc.styles.resolve_para(¶.props).tabs.iter().map(|t| (t.pos, t.align)).collect::>() + }; + + // A plain click on the left-indent marker (at the paragraph's start edge) places nothing. + let marker = pos2(ruler_x(&mut h, 0.0), area.min.y - 6.0); + h.event(Event::PointerMoved(marker)); + h.event(button(marker, PointerButton::Primary, true)); + h.step(); + h.event(button(marker, PointerButton::Primary, false)); + for _ in 0..3 { + h.step(); + } + assert!(stops_of(&h, 1).is_empty(), "an indent-marker click placed a stop: {:?}", stops_of(&h, 1)); + + // A right-button drag across the ruler places nothing. + let (from, to) = (on_ruler(&mut h, 72.0), on_ruler(&mut h, 144.0)); + drag_events(&mut h, from, to, PointerButton::Secondary); + assert!(stops_of(&h, 1).is_empty(), "a right-drag placed a stop: {:?}", stops_of(&h, 1)); + + // Set a stop at 2" the intended way, then prove Escape cancels a move. + let at = on_ruler(&mut h, 144.0); + h.event(Event::PointerMoved(at)); + h.event(button(at, PointerButton::Primary, true)); + h.step(); + h.event(button(at, PointerButton::Primary, false)); + for _ in 0..32 { + h.step(); + } + assert_eq!(stops_of(&h, 1), vec![(144.0, TabAlign::Left)]); + let (from, to) = (on_ruler(&mut h, 144.0), on_ruler(&mut h, 216.0)); + h.event(Event::PointerMoved(from)); + h.event(button(from, PointerButton::Primary, true)); + h.step(); + h.event(Event::PointerMoved(from + (to - from) * 0.5)); + h.step(); + h.event(Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }); + h.step(); + h.event(button(to, PointerButton::Primary, false)); + for _ in 0..32 { + h.step(); + } + assert_eq!(stops_of(&h, 1), vec![(144.0, TabAlign::Left)], "Escape left the stop where it was"); + + // A drag abandoned by moving the caret to another paragraph applies nowhere. + let (from, to) = (on_ruler(&mut h, 144.0), on_ruler(&mut h, 216.0)); + h.event(Event::PointerMoved(from)); + h.event(button(from, PointerButton::Primary, true)); + h.step(); + h.event(Event::PointerMoved(from + (to - from) * 0.5)); + h.step(); + h.state_mut().run("caret.docStart", json!({})).unwrap(); + h.step(); + h.event(button(to, PointerButton::Primary, false)); + for _ in 0..3 { + h.step(); + } + assert_eq!(stops_of(&h, 1), vec![(144.0, TabAlign::Left)], "the old paragraph keeps its stop"); + assert!(stops_of(&h, 0).is_empty(), "the caret's new paragraph gained nothing: {:?}", stops_of(&h, 0)); + } + + /// Tab stops on the ruler: the corner selector picks the kind, a click sets a stop, dragging + /// moves it, dragging it off clears it, and each gesture is one undo step. + #[test] + fn ruler_tab_stops_are_set_moved_and_cleared_with_the_pointer() { + use egui::{Event, PointerButton}; + use wordcraft_doc::props::TabAlign; + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + a.run("text.insert", json!({"text": "Name\tScore"})).unwrap(); + a.run("view.ruler", json!({"value": true})).unwrap(); + a.run("view.commentsPane", json!({"value": false})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.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(); + } + let area = h.state().canvas.canvas_rect.expect("the canvas is laid out"); + // The middle of the ruler's strip, over a point `d` points from the paragraph's start. + let on_ruler = |h: &mut egui_kittest::Harness<'_, WordApp>, d: f32| { + let body_x = h.state_mut().session.layout().pages[0].body.x; + let x = page_to_screen(h.state_mut(), 0, body_x + d, 0.0).expect("on screen").x; + pos2(x, area.min.y - 7.0) + }; + let button = + |at: Pos2, pressed: bool| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + let click = |h: &mut egui_kittest::Harness<'_, WordApp>, at: Pos2| { + h.event(Event::PointerMoved(at)); + h.event(button(at, true)); + h.step(); + h.event(button(at, false)); + for _ in 0..3 { + h.step(); + } + // Let the double-click time pass before the next press. + for _ in 0..30 { + h.step(); + } + }; + let drag = |h: &mut egui_kittest::Harness<'_, WordApp>, from: Pos2, to: Pos2| { + h.event(Event::PointerMoved(from)); + h.event(button(from, true)); + h.step(); + for k in 1..=6 { + h.event(Event::PointerMoved(from + (to - from) * (k as f32 / 6.0))); + h.step(); + } + h.event(button(to, false)); + for _ in 0..3 { + h.step(); + } + }; + let stops = |h: &egui_kittest::Harness<'_, WordApp>| { + let s = &h.state().session; + let para = s.doc.para_at(&s.sel.focus).expect("a paragraph"); + s.doc.styles.resolve_para(¶.props).tabs.iter().map(|t| (t.pos, t.align)).collect::>() + }; + + // The selector starts at Left; a click moves it to Center. + assert_eq!(h.state().canvas.tab_kind, TabAlign::Left); + click(&mut h, pos2(area.min.x - RULER / 2.0, area.min.y - RULER / 2.0)); + assert_eq!(h.state().canvas.tab_kind, TabAlign::Center); + + // A click on the ruler sets a centre stop at 2 inches (on the 1/16 inch grid). + let at = on_ruler(&mut h, 144.0); + click(&mut h, at); + assert_eq!(stops(&h), vec![(144.0, TabAlign::Center)]); + + // Dragging it to 3 inches moves it, keeping its kind. + let (from, to) = (on_ruler(&mut h, 144.0), on_ruler(&mut h, 216.0)); + drag(&mut h, from, to); + assert_eq!(stops(&h), vec![(216.0, TabAlign::Center)]); + + // Dragging it down into the page clears it. + let from = on_ruler(&mut h, 216.0); + drag(&mut h, from, from + vec2(0.0, 80.0)); + assert!(stops(&h).is_empty(), "the stop came off: {:?}", stops(&h)); + + // Each gesture was one step: undo brings back the moved stop, then the first one. + assert!(h.state_mut().session.undo()); + assert_eq!(stops(&h), vec![(216.0, TabAlign::Center)]); + assert!(h.state_mut().session.undo()); + assert_eq!(stops(&h), vec![(144.0, TabAlign::Center)]); + } + #[test] fn hiding_white_space_stacks_page_bands_and_keeps_hit_testing() { let scale = 1.0; diff --git a/crates/ui-egui/src/dialogs_para.rs b/crates/ui-egui/src/dialogs_para.rs index 9a4a81979..32df96f3e 100644 --- a/crates/ui-egui/src/dialogs_para.rs +++ b/crates/ui-egui/src/dialogs_para.rs @@ -13,23 +13,30 @@ use crate::options::MeasureExt; use crate::theme::{Tokens, semibold}; /// Stops closer than this (points) are the same stop. -const SAME_POS: f32 = 0.5; +/// A leader choice: the value, its menu label (translated), and the fill the closed box shows. +const LEADERS: [(TabLeader, &str, &str); 5] = [ + (TabLeader::None, "None", ""), + (TabLeader::Dot, "1 ……", "……"), + (TabLeader::Hyphen, "2 ------", "------"), + (TabLeader::Underscore, "3 ______", "______"), + (TabLeader::MiddleDot, "4 ······", "······"), +]; + +/// [`wordcraft_doc::props::TAB_POS_EPS`]: the engine replaces stops within this tolerance. +const SAME_POS: f32 = wordcraft_doc::props::TAB_POS_EPS; fn unit() -> wordcraft_geom::Unit { crate::options::unit() } -/// The Tabs dialog's fields. Positions in `stops` and `cleared` are points; `pos` and -/// `default_tab` are in the interface unit. +/// The Tabs dialog's fields. Positions in `stops` and `cleared` are points; `default_tab` is in +/// the interface unit. #[derive(Clone, Debug, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct TabsForm { pub stops: Vec, #[serde(skip)] pub basis: Vec, - pub pos: f32, - pub align: TabAlign, - pub leader: TabLeader, pub default_tab: f32, #[serde(skip)] pub basis_default: f32, @@ -37,6 +44,9 @@ pub struct TabsForm { pub cleared: Vec, pub clear_all: bool, pub selected: Option, + /// The row whose position was last edited in place: it wins a merge in [`TabsForm::normalize`]. + #[serde(skip)] + last_edit: Option, } impl TabsForm { @@ -51,39 +61,103 @@ impl TabsForm { Some(TabsForm { basis: stops.clone(), stops, - pos: first.map(|t| t.pos / k).unwrap_or(0.0), - align: first.map(|t| t.align).unwrap_or_default(), - leader: first.map(|t| t.leader).unwrap_or_default(), default_tab, basis_default: default_tab, cleared: Vec::new(), clear_all: false, selected: first.map(|_| 0), + last_edit: None, }) } - /// Set (add or replace) the stop the fields describe. - pub fn set(&mut self) { - let pos = self.pos * unit().pt_per_unit(); - if !pos.is_finite() || self.stops.len() >= 64 { + /// Add a stop at the next free multiple of the default interval after the last stop — + /// left aligned, no leader — selected and ready to edit in place. + pub fn new_stop(&mut self) { + let step = if self.default_tab.is_finite() && self.default_tab > 0.0 { self.default_tab * unit().pt_per_unit() } else { 36.0 }; + let taken = |p: f32| self.stops.iter().any(|t| (t.pos - p).abs() < SAME_POS) || self.cleared.iter().any(|c| (c - p).abs() < SAME_POS); + let last = self.stops.iter().map(|t| t.pos).fold(0.0f32, f32::max); + let mut p = (last / step).floor() * step + step; + while taken(p) && p <= 1584.0 { + p += step; + } + if !p.is_finite() || p > 1584.0 || self.stops.len() >= 64 { return; } - self.stops.retain(|t| (t.pos - pos).abs() >= SAME_POS); - self.cleared.retain(|c| (c - pos).abs() >= SAME_POS); - self.stops.push(TabStop { pos, align: self.align, leader: self.leader }); + self.stops.push(TabStop { pos: p, align: TabAlign::default(), leader: TabLeader::default() }); self.stops.sort_by(|a, b| a.pos.total_cmp(&b.pos)); - self.selected = self.stops.iter().position(|t| (t.pos - pos).abs() < SAME_POS); + self.selected = self.stops.iter().position(|t| (t.pos - p).abs() < SAME_POS); + self.last_edit = None; } - /// Clear the selected stop (or the one at the position field). - pub fn clear(&mut self) { - let pos = self.pos * unit().pt_per_unit(); - let i = self.selected.filter(|i| *i < self.stops.len()).or_else(|| self.stops.iter().position(|t| (t.pos - pos).abs() < SAME_POS)); - if let Some(i) = i { - let t = self.stops.remove(i); - self.cleared.push(t.pos); + /// The list edits a stop's position in place: clamp to the engine's ±1584 bound. The list + /// stays in row order while the field is being edited; [`TabsForm::normalize`] restores + /// position order when the edit ends. + pub fn set_pos(&mut self, i: usize, pos: f32) { + if !pos.is_finite() { + return; } - self.selected = None; + if let Some(t) = self.stops.get_mut(i) { + t.pos = pos.clamp(-1584.0, 1584.0); + let p = t.pos; + self.cleared.retain(|c| (c - p).abs() >= SAME_POS); + self.last_edit = Some(i); + } + } + + /// The list edits a stop's alignment in place; the fields follow when it is the selected row. + pub fn set_align(&mut self, i: usize, align: TabAlign) { + if let Some(t) = self.stops.get_mut(i) { + t.align = align; + } + } + + /// The list edits a stop's leader in place; the fields follow when it is the selected row. + pub fn set_leader(&mut self, i: usize, leader: TabLeader) { + if let Some(t) = self.stops.get_mut(i) { + t.leader = leader; + } + } + + /// Restore position order after in-place edits, merging stops moved onto one another + /// (the edited row wins), and keep the selection on its stop. + pub fn normalize(&mut self) { + // Only an in-place position edit (set_pos) can disorder the list; without one this is + // a no-op, so the dialog may call it every frame. + if self.last_edit.is_none() { + return; + } + let followed = self.selected.and_then(|i| self.stops.get(i)).copied(); + let edited = self.last_edit.take(); + let mut merged: Vec<(usize, TabStop)> = Vec::with_capacity(self.stops.len()); + for (j, t) in self.stops.iter().enumerate() { + match merged.iter().position(|(_, x)| (x.pos - t.pos).abs() < SAME_POS) { + // The edited stop holds its ground; the row that drifted onto it goes. + Some(k) if edited == Some(merged[k].0) => {} + Some(k) => { + merged.remove(k); + merged.push((j, *t)); + } + None => merged.push((j, *t)), + } + } + merged.sort_by(|a, b| a.1.pos.total_cmp(&b.1.pos)); + self.stops = merged.into_iter().map(|(_, t)| t).collect(); + self.selected = followed.and_then(|t| self.stops.iter().position(|x| (x.pos - t.pos).abs() < SAME_POS)); + } + + /// Clear stop `i` (the list's × button and the Delete key). + pub fn remove_stop(&mut self, i: usize) { + if i >= self.stops.len() { + return; + } + let t = self.stops.remove(i); + self.cleared.push(t.pos); + self.last_edit = None; + self.selected = match self.selected { + Some(s) if s == i => None, + Some(s) if s > i => Some(s - 1), + s => s, + }; } pub fn clear_all(&mut self) { @@ -123,30 +197,108 @@ pub fn tabs(app: &mut WordApp, ui: &mut Ui, f: &mut TabsForm) -> bool { let k = u.pt_per_unit(); ui.horizontal(|ui| { ui.vertical(|ui| { - ui.label(tl!("Tab stop position:")); - ui.add(egui::DragValue::new(&mut f.pos).speed(0.05).range(-22.0 * 72.0 / k..=22.0 * 72.0 / k).measure(u).max_decimals(2)); + ui.horizontal(|ui| { + if ui.button(tl!("New")).clicked() { + f.new_stop(); + } + if ui.add_enabled(!f.stops.is_empty(), egui::Button::new(tl!("Clear All"))).clicked() { + f.clear_all(); + } + }); let mut pick = None; egui::Frame::new().stroke(Stroke::new(1.0, Tokens::get(ui.ctx()).input_border)).inner_margin(4.0).show(ui, |ui| { - ui.set_min_size(vec2(150.0, 96.0)); - egui::ScrollArea::vertical().max_height(96.0).show(ui, |ui| { - for (i, t) in f.stops.iter().enumerate() { - if ui.selectable_label(f.selected == Some(i), u.format_with(t.pos, crate::options::decimal())).clicked() { - pick = Some(i); - } + // A fixed width: the scroll area fills it (rows and scrollbar at its edge) + // without growing the dialog. + ui.set_width(278.0); + ui.set_min_height(96.0); + let mut remove = None; + let mut row_active = false; + // The stops as an inline-editable table: position, alignment, clear. Fill the + // frame, so rows span its width and the scrollbar sits at its edge. + egui::ScrollArea::vertical().max_height(96.0).auto_shrink([false, true]).show(ui, |ui| { + for i in 0..f.stops.len() { + let Some(t) = f.stops.get(i).copied() else { continue }; + ui.push_id(i, |ui| { + let row = ui.horizontal(|ui| { + // Position, edited in place: the interface unit by default, any + // unit spelled out, either decimal separator (`.measure`). + let mut pv = t.pos / k; + let r = ui.add_sized( + vec2(58.0, 18.0), + egui::DragValue::new(&mut pv).speed(0.05).range(-1584.0 / k..=1584.0 / k).measure(u).max_decimals(2), + ); + if r.changed() { + f.set_pos(i, pv * k); + } + if r.has_focus() || r.dragged() { + row_active = true; + if f.selected != Some(i) { + pick = Some(i); + } + } + // Alignment, picked in place. + let mut a = t.align; + egui::ComboBox::from_id_salt("tab_align").width(86.0).selected_text(tl!(crate::ruler_tabs::kind_label(a))).show_ui( + ui, + |ui| { + for v in crate::ruler_tabs::KINDS { + ui.selectable_value(&mut a, v, tl!(crate::ruler_tabs::kind_label(v))); + } + }, + ); + if a != t.align { + f.set_align(i, a); + pick = Some(i); + } + // Leader, picked in place; the closed box shows just the fill. + let mut l = t.leader; + let shown = LEADERS + .iter() + .find(|(v, _, _)| *v == l) + .map(|(_, _, fill)| if fill.is_empty() { tl!("None").to_string() } else { (*fill).to_string() }) + .unwrap_or_default(); + egui::ComboBox::from_id_salt("tab_leader").width(64.0).selected_text(shown).show_ui(ui, |ui| { + for (v, label, _) in LEADERS { + ui.selectable_value(&mut l, v, tl!(label)); + } + }); + if l != t.leader { + f.set_leader(i, l); + pick = Some(i); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_space(10.0); // clear of the scrollbar + if ui.add(egui::Button::new("×").small().frame(false)).on_hover_text(tl!("Clear")).clicked() { + remove = Some(i); + } + }); + }); + if f.selected == Some(i) { + let tok = Tokens::get(ui.ctx()); + ui.painter().rect_stroke(row.response.rect.expand(1.0), 2.0, Stroke::new(1.0, tok.accent), egui::StrokeKind::Outside); + } + }); } }); + if let Some(i) = remove { + f.remove_stop(i); + } else if f.selected.is_some() && ui.memory(|m| m.focused().is_none()) && ui.input(|inp| inp.key_pressed(egui::Key::Delete)) { + // Delete clears the selected stop when nothing is being typed. + if let Some(i) = f.selected { + f.remove_stop(i); + } + } else if !row_active { + // Rows go back into position order once no position field is being edited. + f.normalize(); + } }); - if let Some(i) = pick - && let Some(t) = f.stops.get(i) - { + if let Some(i) = pick.filter(|i| *i < f.stops.len()) { f.selected = Some(i); - f.pos = t.pos / k; - f.align = t.align; - f.leader = t.leader; } }); ui.add_space(12.0); ui.vertical(|ui| { + ui.set_min_width(130.0); ui.label(tl!("Default tab stops:")); ui.add(egui::DragValue::new(&mut f.default_tab).speed(0.05).range(1.0 / k..=1584.0 / k).measure(u).max_decimals(2)); ui.add_space(8.0); @@ -163,54 +315,9 @@ pub fn tabs(app: &mut WordApp, ui: &mut Ui, f: &mut TabsForm) -> bool { ui.label(egui::RichText::new(list).weak()); }); }); - ui.separator(); - ui.label(egui::RichText::new(tl!("Alignment")).font(semibold(12.5))); - ui.horizontal_wrapped(|ui| { - for (a, l) in [ - (TabAlign::Left, "Left"), - (TabAlign::Center, "Center"), - (TabAlign::Right, "Right"), - (TabAlign::Decimal, "Decimal"), - (TabAlign::Bar, "Bar"), - ] { - ui.radio_value(&mut f.align, a, tl!(l)); - } - }); - ui.label(egui::RichText::new(tl!("Leader")).font(semibold(12.5))); - ui.horizontal_wrapped(|ui| { - for (l, label) in [ - (TabLeader::None, "None"), - (TabLeader::Dot, "1 ……"), - (TabLeader::Hyphen, "2 ------"), - (TabLeader::Underscore, "3 ______"), - (TabLeader::MiddleDot, "4 ······"), - ] { - ui.radio_value(&mut f.leader, l, tl!(label)); - } - }); - ui.add_space(4.0); - ui.horizontal(|ui| { - if ui.button(tl!("Set")).clicked() { - f.set(); - } - if ui.add_enabled(!f.stops.is_empty(), egui::Button::new(tl!("Clear"))).clicked() { - f.clear(); - } - if ui.add_enabled(!f.stops.is_empty(), egui::Button::new(tl!("Clear All"))).clicked() { - f.clear_all(); - } - }); let (ok, cancel) = buttons(ui, tl!("OK")); - if ok { - // A position typed but not Set is set on OK, as the field shows it. - let pos = f.pos * k; - let typed = f.selected.is_none() && !f.clear_all && f.cleared.iter().all(|c| (c - pos).abs() >= SAME_POS) && pos > 0.0; - if typed && !f.stops.iter().any(|t| (t.pos - pos).abs() < SAME_POS) { - f.set(); - } - if let Some(params) = f.params() { - let _ = app.run("para.tabs", params); - } + if ok && let Some(params) = f.params() { + let _ = app.run("para.tabs", params); } ok || cancel } @@ -691,31 +798,105 @@ mod tests { assert!(f.commands().is_empty()); } - /// #320: the Tabs dialog opens from the Paragraph dialog's command path, and Set / Clear / - /// Clear All become one `para.tabs` call. + /// #320: the Tabs dialog opens from the Paragraph dialog's command path, and the table's + /// edits (New, in-place cells, ×, Clear All) become one `para.tabs` call on OK. #[test] - fn tabs_form_sets_and_clears() { + fn tabs_table_builds_and_clears() { let mut app = app_with("a\tb"); let _ = app.run("para.tabs", json!({})); let Some(Dialog::Tabs { form }) = app.dialog.take() else { panic!("Tabs opens without params") }; let mut f = *form; assert!(f.stops.is_empty()); - let k = unit().pt_per_unit(); - f.pos = 72.0 / k; - f.align = TabAlign::Right; - f.leader = TabLeader::Dot; - f.set(); - f.pos = 144.0 / k; - f.set(); - f.selected = Some(1); - f.clear(); + // New, New: rows at one and two default intervals, each selected as it lands. + f.new_stop(); + f.new_stop(); + assert_eq!((f.stops[0].pos, f.stops[1].pos, f.selected), (36.0, 72.0, Some(1))); + f.set_align(1, TabAlign::Right); + f.set_leader(1, TabLeader::Dot); + f.remove_stop(0); let params = f.params().unwrap_or_default(); let _ = app.run("para.tabs", params); let props = app.session.doc.para_at(&app.session.sel.focus).map(|p| p.props.clone()).unwrap_or_default(); assert_eq!(props.tabs, Some(vec![TabStop { pos: 72.0, align: TabAlign::Right, leader: TabLeader::Dot }])); + // Unchanged, OK runs nothing; Clear All clears. let mut f = TabsForm::read(&app).unwrap_or_else(|| panic!("form")); assert!(f.params().is_none()); f.clear_all(); assert_eq!(f.params(), Some(json!({"clearAll": true}))); } + + /// The list is an inline-edit table: changing a row's position or alignment stages the + /// change (clear + set), rows resort once editing ends, and collisions merge. + #[test] + fn tabs_list_edits_stage_moves_and_alignment() { + let mut app = app_with("a\tb"); + let _ = app.run("para.tabs", json!({"set": [{"pos": 72.0, "align": "right", "leader": "dot"}, {"pos": 144.0}]})); + let mut f = TabsForm::read(&app).unwrap_or_else(|| panic!("form")); + f.set_pos(0, 108.0); + f.normalize(); + assert_eq!(f.params(), Some(json!({"set": [{"pos": 108.0, "align": "right", "leader": "dot"}], "clear": [72.0]}))); + assert_eq!(f.selected, Some(0), "the selection follows its stop"); + + // Moving a row onto another merges them (the edited row wins), and junk input + // changes nothing but clamping. + f.set_pos(0, 144.0); + f.normalize(); + assert_eq!(f.stops.len(), 1); + assert_eq!(f.params(), Some(json!({"set": [{"pos": 144.0, "align": "right", "leader": "dot"}], "clear": [72.0]}))); + f.set_pos(0, f32::NAN); + f.set_pos(7, 72.0); + f.set_pos(0, 99999.0); + f.normalize(); + assert_eq!(f.stops[0].pos, 1584.0, "positions clamp to the engine bound"); + + // Alignment and leader picked on a row stage a set for just that stop. + let mut f = TabsForm::read(&app).unwrap_or_else(|| panic!("form")); + f.set_align(1, TabAlign::Decimal); + f.set_leader(1, TabLeader::Underscore); + assert_eq!(f.params(), Some(json!({"set": [{"pos": 144.0, "align": "decimal", "leader": "underscore"}]}))); + f.set_align(9, TabAlign::Bar); + f.set_leader(9, TabLeader::Dot); + + // What the position field parses: the interface unit by default, any unit spelled out. + assert_eq!(wordcraft_geom::parse_measure("1.5", wordcraft_geom::Unit::Inches), Some(108.0)); + assert_eq!(wordcraft_geom::parse_measure("2,54 cm", wordcraft_geom::Unit::Inches), Some(72.0)); + } + + /// New adds a row at the next free multiple of the default interval — skipping taken and + /// cleared positions — and × / Delete clear rows, keeping the selection sensible. + #[test] + fn tabs_new_adds_and_remove_clears() { + let mut app = app_with("a\tb"); + let _ = app.run("para.tabs", json!({"set": [{"pos": 72.0, "align": "right"}, {"pos": 144.0}]})); + let mut f = TabsForm::read(&app).unwrap_or_else(|| panic!("form")); + assert_eq!(f.default_tab * unit().pt_per_unit(), 36.0, "the document default interval"); + f.new_stop(); + assert_eq!((f.stops.len(), f.selected), (3, Some(2))); + assert_eq!((f.stops[2].pos, f.stops[2].align, f.stops[2].leader), (180.0, TabAlign::Left, TabLeader::None)); + assert_eq!(f.params(), Some(json!({"set": [{"pos": 180.0, "align": "left", "leader": "none"}]}))); + + // Cleared and taken positions are skipped. + let mut f = TabsForm::read(&app).unwrap_or_else(|| panic!("form")); + f.remove_stop(1); + f.cleared.push(108.0); + f.new_stop(); + assert_eq!(f.stops[1].pos, 180.0, "72 holds a stop; 108 and 144 are cleared; 180 is free"); + + // × / Delete: clearing a row keeps the selection sensible. + let mut f = TabsForm::read(&app).unwrap_or_else(|| panic!("form")); + f.selected = Some(1); + f.remove_stop(0); + assert_eq!((f.stops.len(), f.selected, f.cleared.clone()), (1, Some(0), vec![72.0])); + assert_eq!(f.params(), Some(json!({"clear": [72.0]}))); + f.remove_stop(0); + assert_eq!((f.stops.len(), f.selected), (0, None)); + f.remove_stop(7); + assert_eq!(f.cleared, vec![72.0, 144.0], "an index past the end changes nothing"); + + // An empty paragraph starts at one interval. + let app = app_with("plain"); + let mut f = TabsForm::read(&app).unwrap_or_else(|| panic!("form")); + f.new_stop(); + assert_eq!(f.stops[0].pos, 36.0); + } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 464e6d667..81e7b9c9a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -70,6 +70,7 @@ pub mod print_page; pub mod printing; pub mod read_aloud; pub mod ribbon; +pub mod ruler_tabs; pub mod scroll; pub mod system_clipboard; pub mod table_mouse; diff --git a/crates/ui-egui/src/ruler_tabs.rs b/crates/ui-egui/src/ruler_tabs.rs new file mode 100644 index 000000000..d9e26c65c --- /dev/null +++ b/crates/ui-egui/src/ruler_tabs.rs @@ -0,0 +1,301 @@ +//! Tab stops on the horizontal ruler, edited like Word's: the selector in the rulers' corner picks +//! the kind of stop, a click on the ruler sets one there, dragging a stop moves it, dragging it off +//! the ruler clears it, and double-clicking one opens the Tabs dialog. A gesture runs `para.tabs` +//! once when the button is released, so it is one undo step. +use egui::{Painter, Rect, Sense, Shape, Stroke, Ui, pos2, vec2}; +use serde_json::{Value, json}; +use wordcraft_doc::Pos; +use wordcraft_doc::props::{TAB_POS_EPS, TabAlign, TabLeader, TabStop}; + +use crate::WordApp; +use crate::theme::Tokens; + +/// How far (screen points) past the ruler's edge the pointer takes a dragged stop off it. +const DROP_OFF: f32 = 18.0; + +/// The kinds of stop the corner selector cycles through (and the Tabs dialog lists), in Word's order. +pub(crate) const KINDS: [TabAlign; 5] = [TabAlign::Left, TabAlign::Center, TabAlign::Right, TabAlign::Decimal, TabAlign::Bar]; + +/// A tab stop being placed or dragged on the ruler. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct TabDrag { + /// Where the stop was before the drag (points from the paragraph's start edge); `None` for a + /// stop being placed. + pub from: Option, + /// The caret paragraph the gesture started on: it is abandoned if the caret leaves it + /// (the keyboard still works mid-drag), rather than applied to another paragraph. + pub para: Pos, + pub align: TabAlign, + pub leader: TabLeader, + /// Where it lands if the button is released now. + pub at: f32, + /// The pointer has left the ruler: releasing clears the stop. + pub off: bool, +} + +/// The selector's next kind (`back`: the previous one). +pub(crate) fn next_kind(k: TabAlign, back: bool) -> TabAlign { + let n = KINDS.len(); + let i = KINDS.iter().position(|x| *x == k).unwrap_or(0); + let j = if back { (i + n - 1) % n } else { (i + 1) % n }; + KINDS.get(j).copied().unwrap_or(TabAlign::Left) +} + +/// The kind's name, as the Tabs dialog labels it (already translated there). +pub(crate) fn kind_label(k: TabAlign) -> &'static str { + match k { + TabAlign::Left => "Left", + TabAlign::Center => "Center", + TabAlign::Right => "Right", + TabAlign::Decimal => "Decimal", + TabAlign::Bar => "Bar", + TabAlign::Clear => "Clear", + } +} + +/// A ruler position for a stop: snapped to `step` (unless `free`, Alt held), within the column. +pub(crate) fn snap_tab(pt: f32, step: f32, width: f32, free: bool) -> f32 { + let pt = if pt.is_finite() { pt } else { 0.0 }; + let snapped = if free || !step.is_finite() || step <= 0.0 { pt } else { (pt / step).round() * step }; + snapped.clamp(0.0, if width.is_finite() { width.max(0.0) } else { 0.0 }) +} + +/// Whether a pointer at `y` is far enough above or below the ruler `strip` to take a stop off. +pub(crate) fn dropped_off(y: f32, strip: Rect) -> bool { + y > strip.max.y + DROP_OFF || y < strip.min.y - DROP_OFF +} + +/// The finished gesture as `para.tabs` params, or `None` when it changes nothing. +pub(crate) fn release_params(d: &TabDrag) -> Option { + let stop = json!([TabStop { pos: d.at, align: d.align, leader: d.leader }]); + match (d.from, d.off) { + (None, true) => None, + (None, false) => Some(json!({ "set": stop })), + (Some(from), true) => Some(json!({ "clear": [from] })), + (Some(from), false) if (from - d.at).abs() < TAB_POS_EPS => None, + (Some(from), false) => Some(json!({ "clear": [from], "set": stop })), + } +} + +/// Draw a stop's marker standing on `base` at `x`; right-to-left paragraphs mirror its foot. +pub(crate) fn draw_tab_glyph(p: &Painter, x: f32, base: f32, kind: TabAlign, rtl: bool, stroke: Stroke) { + let foot = if rtl { -4.0 } else { 4.0 }; + let stem = |top: f32| { + p.line_segment([pos2(x, top), pos2(x, base)], stroke); + }; + let along = |a: f32, b: f32| { + p.line_segment([pos2(x + a, base), pos2(x + b, base)], stroke); + }; + match kind { + TabAlign::Left => { + stem(base - 5.0); + along(0.0, foot); + } + TabAlign::Right => { + stem(base - 5.0); + along(-foot, 0.0); + } + TabAlign::Center => { + stem(base - 5.0); + along(-4.0, 4.0); + } + TabAlign::Decimal => { + stem(base - 5.0); + along(-4.0, 4.0); + p.circle_filled(pos2(x + foot * 0.7, base - 3.5), 1.1, stroke.color); + } + TabAlign::Bar => { + stem(base - 7.0); + } + TabAlign::Clear => {} + } +} + +/// The tab selector in the corner where the rulers meet: click for the next kind of stop, +/// right-click for the previous one. +pub(crate) fn selector(app: &mut WordApp, ui: &Ui, corner: Rect) { + if corner.width() < 12.0 || corner.height() < 12.0 { + return; + } + let t = Tokens::get(ui.ctx()); + let r = ui.interact(corner, ui.id().with("ruler_tab_kind"), Sense::click()); + if r.clicked() { + app.canvas.tab_kind = next_kind(app.canvas.tab_kind, false); + } else if r.secondary_clicked() { + app.canvas.tab_kind = next_kind(app.canvas.tab_kind, true); + } + let kind = app.canvas.tab_kind; + let p = ui.painter_at(corner); + let b = Rect::from_center_size(corner.center(), vec2(14.0, 14.0)); + p.rect(b, 2.0, if r.hovered() { t.hover } else { t.ruler }, Stroke::new(1.0, t.text_dim), egui::StrokeKind::Inside); + // Centre the glyph on its box: Left's foot runs right of its stem, Right's left. + let nudge = match kind { + TabAlign::Left => -2.0, + TabAlign::Right => 2.0, + _ => 0.0, + }; + draw_tab_glyph(&p, b.center().x + nudge, b.max.y - 4.0, kind, false, Stroke::new(1.5, t.text)); + r.on_hover_text(format!("{}: {}", tl!("Tabs"), tl!(kind_label(kind)))); +} + +/// The ruler geometry the stops are drawn on. +pub(crate) struct Ruler<'a> { + /// The ruler's strip. + pub bar: Rect, + /// The part of the strip over the text column, where a click sets a stop. + pub text: Rect, + /// The column's width (points): stops stay within it. + pub width: f32, + /// Snap step (points). + pub step: f32, + pub rtl: bool, + /// Screen x of a distance (points) from the paragraph's start edge, and back. + pub x_of: &'a dyn Fn(f32) -> f32, + pub dist_at: &'a dyn Fn(f32) -> f32, +} + +/// Draw the caret paragraph's stops and handle placing, moving, clearing and double-clicking them. +/// Register before the indent markers, so those keep the pointer where they overlap a stop. +pub(crate) fn stops(app: &mut WordApp, ui: &Ui, hp: &Painter, g: &Ruler<'_>, tabs: &[TabStop]) { + let t = Tokens::get(ui.ctx()); + let stroke = Stroke::new(1.5, t.text); + let base = g.bar.max.y - 1.0; + let free = ui.input(|i| i.modifiers.alt); + let snap = |x: f32| snap_tab((g.dist_at)(x), g.step, g.width, free); + let units = app.session.units(); + let decimal = crate::options::decimal(); + + // The paragraph the ruler is editing; a drag that outlives it is abandoned. + let para = app.session.sel.focus.clone(); + + // A click on an empty stretch over the text sets a stop of the selector's kind there; pressing + // and dragging places it where the button is released. The edges stay with the margin handles + // and indent markers, and only the primary button places stops. + let add = ui.interact(g.text.shrink2(vec2(4.0, 0.0)), ui.id().with("ruler_tab_add"), Sense::click_and_drag()); + let kind = app.canvas.tab_kind; + let placing = |x: f32| TabDrag { from: None, para: para.clone(), align: kind, leader: TabLeader::None, at: snap(x), off: false }; + if add.clicked() + && let Some(pp) = add.interact_pointer_pos() + && let Some(params) = release_params(&placing(pp.x)) + { + let _ = app.run("para.tabs", params); + } else if add.drag_started_by(egui::PointerButton::Primary) + && let Some(pp) = add.interact_pointer_pos() + { + app.canvas.tab_drag = Some(placing(pp.x)); + } + + let dragging = app.canvas.tab_drag.as_ref().and_then(|d| d.from); + let mut open_dialog = false; + for (i, tab) in tabs.iter().enumerate() { + let x = (g.x_of)(tab.pos); + let r = + ui.interact(Rect::from_center_size(pos2(x, g.bar.max.y - 4.0), vec2(9.0, 10.0)), ui.id().with(("ruler_tab", i)), Sense::click_and_drag()); + let starts = r.drag_started_by(egui::PointerButton::Primary); + if starts { + app.canvas.tab_drag = + Some(TabDrag { from: Some(tab.pos), para: para.clone(), align: tab.align, leader: tab.leader, at: tab.pos, off: false }); + } + // The stop being dragged is drawn where it would land instead (from this frame on). + if !starts && dragging.is_none_or(|from| (from - tab.pos).abs() > TAB_POS_EPS) { + draw_tab_glyph(hp, x, base, tab.align, g.rtl, stroke); + } + open_dialog |= r.double_clicked(); + if !starts && app.canvas.tab_drag.is_none() { + r.on_hover_text(format!("{} {}", tl!(kind_label(tab.align)), units.format_with(tab.pos, decimal))); + } + } + + if let Some(mut d) = app.canvas.tab_drag.clone() { + let (esc, down, released) = ui.input(|i| (i.key_pressed(egui::Key::Escape), i.pointer.primary_down(), i.pointer.primary_released())); + if esc || d.para.story != para.story || d.para.path != para.path { + // Escape cancels (like a table-border drag, #217), and a drag whose paragraph the + // caret has left is abandoned, never applied to the new one. + app.canvas.tab_drag = None; + } else if down { + if let Some(pp) = ui.input(|i| i.pointer.interact_pos()) { + d.at = snap(pp.x); + d.off = dropped_off(pp.y, g.bar); + } + app.canvas.tab_drag = Some(d.clone()); + let label = if d.off { + tl!("Clear").to_string() + } else { + let x = (g.x_of)(d.at); + draw_tab_glyph(hp, x, base, d.align, g.rtl, stroke); + // A guide down the page shows where text will line up. + if let Some(area) = app.canvas.canvas_rect { + let guide = + ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, ui.id().with("ruler_tab_guide"))).with_clip_rect(area); + guide.extend(Shape::dashed_line(&[pos2(x, area.min.y), pos2(x, area.max.y)], Stroke::new(1.0, t.text_dim), 3.0, 3.0)); + } + format!("{} {}", tl!(kind_label(d.align)), units.format_with(d.at, decimal)) + }; + egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), ui.id().with("ruler_tab_tip"), egui::PopupAnchor::Pointer).show(|ui| { + ui.label(label); + }); + } else { + app.canvas.tab_drag = None; + // Only a release seen on this frame applies; one missed while the ruler wasn't + // drawn (a view change mid-drag) is dropped rather than fired late. + if released && let Some(params) = release_params(&d) { + let _ = app.run("para.tabs", params); + } + } + } + if open_dialog { + let _ = app.run("para.tabs", json!({})); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_selector_cycles_through_the_kinds_both_ways() { + let mut k = TabAlign::Left; + let mut seen = Vec::new(); + for _ in 0..5 { + seen.push(k); + k = next_kind(k, false); + } + assert_eq!(seen, KINDS); + assert_eq!(k, TabAlign::Left, "it wraps around"); + assert_eq!(next_kind(TabAlign::Left, true), TabAlign::Bar); + assert_eq!(next_kind(TabAlign::Clear, false), TabAlign::Center, "a stray kind restarts the cycle"); + } + + #[test] + fn stops_snap_and_stay_in_the_column() { + assert_eq!(snap_tab(100.0, 4.5, 468.0, false), 99.0); + assert_eq!(snap_tab(100.0, 4.5, 468.0, true), 100.0, "Alt places freely"); + assert_eq!(snap_tab(-20.0, 4.5, 468.0, false), 0.0); + assert_eq!(snap_tab(900.0, 4.5, 468.0, false), 468.0); + assert_eq!(snap_tab(f32::NAN, 4.5, 468.0, false), 0.0, "junk lands at the start"); + assert_eq!(snap_tab(50.0, 0.0, 468.0, false), 50.0, "no step, no snapping"); + } + + #[test] + fn a_stop_comes_off_only_well_away_from_the_ruler() { + let strip = Rect::from_min_max(pos2(0.0, 100.0), pos2(800.0, 115.0)); + assert!(!dropped_off(110.0, strip)); + assert!(!dropped_off(115.0 + DROP_OFF, strip), "just below still keeps it"); + assert!(dropped_off(116.0 + DROP_OFF, strip)); + assert!(dropped_off(100.0 - DROP_OFF - 1.0, strip)); + } + + #[test] + fn gestures_become_one_para_tabs_call() { + let d = |from, at, off| TabDrag { from, para: Pos::body(0, 0), align: TabAlign::Center, leader: TabLeader::Dot, at, off }; + assert_eq!(release_params(&d(None, 72.0, false)), Some(json!({"set": [{"pos": 72.0, "align": "center", "leader": "dot"}]}))); + assert_eq!(release_params(&d(None, 72.0, true)), None, "a new stop dragged off is never set"); + assert_eq!(release_params(&d(Some(72.0), 72.2, false)), None, "a stop dropped where it was"); + assert_eq!(release_params(&d(Some(72.0), 144.0, true)), Some(json!({"clear": [72.0]}))); + assert_eq!( + release_params(&d(Some(72.0), 144.0, false)), + Some(json!({"clear": [72.0], "set": [{"pos": 144.0, "align": "center", "leader": "dot"}]})), + "moving keeps the stop's kind and leader" + ); + } +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 2b8c4f539..1e36f3c41 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -23,7 +23,7 @@ Document title opens Rename Document; the account badge opens Options › Genera | 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; Update Field on a table of contents entry updates the table, #1197); right-click selects a picture, shape or text box and offers Wrap Text (styles, Move with Text / Fix Position on Page, #513); tables, the items following the selection as in Word (Insert, Delete › Cells, Columns, Rows or Table whatever is selected (#1198), Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…; no Border Styles yet) | rich per context | 65% | 2–4 | | 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); launched with nothing to open, the app starts on Home (the Start screen: Blank document, templates, Recent; no document is open until one is picked or opened, so Esc or Back shows an empty window with the ribbon greyed out but for Help, #1129), with File › Options › General › Start-up to turn it off (#766); Info › Inspect Document finds and removes hidden text by its effective formatting (direct, character or paragraph style) in every story: body, headers, footers, notes, comments and text boxes, and lists what it couldn't reach (#1344) | | 68% | 4–6 | -| Rulers: indents, tabs, margins, table columns | indents draggable (one undo, #62); tabs; table column and row markers drag their borders (one undo) and in a cell the indents are measured from it (#700) | ✅ | 85% | 1–2 | +| Rulers: indents, tabs, margins, table columns | indents draggable (one undo, #62); tab stops: corner selector (Left/Center/Right/Decimal/Bar), click to set, drag to move, drag off to clear, per-kind markers, Alt skips the snap, one undo step per gesture, double-click opens the Tabs dialog (#320); table column and row markers drag their borders (one undo) and in a cell the indents are measured from it (#700) | ✅ | 90% | 1 | | On-canvas objects: select, drag, resize handles, nudge | ✅ tested (#228); a picture or shape behind text can be dragged across the overlapping text (body and header/footer), whether or not it was selected first, while a click without movement still edits that text | ✅ | 75% | — | | On-canvas objects: select, drag, Ctrl+drag to copy, resize handles, nudge | ✅ tested (#228, #1014) | ✅ | 75% | — | | Rotation handle, crop handles on canvas, alignment guides, smart guides | rotation handle (Shift: 15° steps), turned frames resize along their axes (#332); crop handles: Picture Format › Crop puts the picture in crop mode, black crop handles trim a side (a corner, two) keeping the scale and the visible part in place, the cropped-away part shows dimmed, Esc/Enter/a click outside/Crop again keep the crop, one undo step per drag (unturned pictures; no Crop to Shape, Aspect Ratio or Fill/Fit); alignment guides, our own design: a moved picture, shape or text box snaps its left, centre or right (top, middle or bottom) to the page's edges and centre, the margins and the other floating objects on the page within 6 screen points, x and y independently, turned objects by their rotated bounds, with a thin line across the page for each guide while dragging; Alt moves freely; File › Options › Advanced › Editing (`ui.alignmentGuides`) turns them off. Smart guides missing | Word for Mac 16.113.4 shows no guide lines and snaps inconsistently (to the page's left edge, not its top, right or bottom; Word check K); Word for Windows' guides not observed | 75% | 1–2 | @@ -52,6 +52,7 @@ Document title opens Rename Document; the account badge opens Options › Genera | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Tab stops edited on the ruler: corner kind selector, click to set, drag to move or off to clear, per-kind markers mirrored for RTL; each gesture one `para.tabs` call and one undo step; Rulers 85% → 90% | | 2026-10-12 | trivial | Tables on the page: one selected cell (Select Cell, or a click along its left edge) is a cell selection when its border is dragged: the border moves in its row only (#962) | | 2026-10-12 | minor | Default keyboard shortcuts expanded to 140 commands on macOS (169 combinations) and 158 on Linux/Windows (188 combinations): Style Inspector (Shift+F1), Macros (Alt+F8), Strikethrough (Mod+Shift+X), Find Next (Shift+F4), Copy/Paste Insert keys (Mod+Insert, Shift+Insert), Go Back (Mod+Alt+Z), File Open/Save/Save As/Print (Mod+Alt+F2, Alt+Shift+F2, Alt+F2, Mod+Alt+I), Field navigation (Alt+F1, Alt+Shift+F1), Outline show first line (Alt+Shift+L), Next Footnote (Alt+Shift+>), Link to Previous (Alt+Shift+R), AutoText (Alt+F3), Spelling on Mac (Mod+Alt+L) | | 2026-10-13 | trivial | Web/Draft Layout long-page blur fixed using visible full-resolution raster tiles, tested against a 440-paragraph document | From 6e25d482c449b9dbe9f9a368548bd47a181cbac5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Lo=C5=A1=C3=A1k?= Date: Thu, 15 Oct 2026 06:52:43 +0200 Subject: [PATCH 090/101] proof: Czech (cs-CZ) spelling from the installed Hunspell dictionary (#1048) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same pattern as Portuguese (#797) and Polish (#346): the cs_CZ dictionary is read from the system at runtime (e.g. Debian/Ubuntu hunspell-cs), nothing is bundled. Suggestions use the dictionary's own MAP/REP/TRY/KEY; grammar stays generic (repeated word, spacing), with digit ratios like "3 : 1" exempt. The Czech interface now writes new documents (and opened .txt/.md) in cs-CZ. Engine fixes the Czech dictionary needs, also used by pl/pt: - PFX/SFX header lines may carry a trailing comment (cs_CZ has "SFX y Y 80 #…"); rule lines are left literal; - NOSUGGEST stems are accepted when typed but never offered; - KEY is parsed for neighbour-key suggestions. Tests use a hand-written synthetic Czech dictionary only; an end-to-end engine test mirrors the Portuguese one. Co-authored-by: Losminek <301412321+Losminek@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 7 +- crates/engine/src/tests.rs | 41 +++ crates/proof/src/affix.rs | 178 ++++++++++--- crates/proof/src/cs/grammar.rs | 103 ++++++++ crates/proof/src/cs/mod.rs | 212 ++++++++++++++++ crates/proof/src/cs/suggest.rs | 424 +++++++++++++++++++++++++++++++ crates/proof/src/cs/test_dict.rs | 59 +++++ crates/proof/src/hunspell.rs | 1 + crates/proof/src/hyphen.rs | 4 +- crates/proof/src/lang.rs | 17 +- crates/proof/src/lib.rs | 22 +- crates/proof/src/pl/suggest.rs | 12 +- crates/proof/src/pt/suggest.rs | 12 +- crates/ui-egui/src/i18n/mod.rs | 9 +- crates/ui-egui/src/i18n/tests.rs | 29 ++- crates/ui-egui/src/options.rs | 1 + crates/ui-egui/src/ribbon.rs | 13 +- docs/gaps.md | 4 +- docs/localization-parity.md | 11 +- docs/roadmap.md | 6 +- docs/target-app-parity.md | 4 +- 21 files changed, 1113 insertions(+), 56 deletions(-) create mode 100644 crates/proof/src/cs/grammar.rs create mode 100644 crates/proof/src/cs/mod.rs create mode 100644 crates/proof/src/cs/suggest.rs create mode 100644 crates/proof/src/cs/test_dict.rs diff --git a/ROADMAP.md b/ROADMAP.md index 31ab8bcf3..734932a10 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -113,10 +113,10 @@ Weights and evidence per area: [docs/target-app-parity.md](docs/target-app-parit | Vietnamese | vi | partial (#1434) | ~95% | 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); Swedish (`sv`) covers that too, plus 44 newer strings no other catalog has yet (2,504 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 +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. Hungarian (`hu`) has a complete 2,419-entry catalog, also without proofing. Vietnamese (`vi`, #1434) has a complete 2,448-entry catalog, also without proofing. -Proofing: English, Polish, Portuguese and Russian (spelling, grammar, hyphenation), chosen per run from its language. Word ships +Proofing: English, Polish, Portuguese and Russian (spelling, grammar, hyphenation), plus Czech (system Hunspell spelling, generic checks), 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 @@ -279,6 +279,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 | Czech proofing: cs-CZ spelling reads the installed Hunspell dictionary (`hunspell-cs` on Debian/Ubuntu), suggestions use runtime MAP/REP/TRY/KEY tables, generic repeated-word and spacing checks explain in Czech; Review › Language offers Czech/Čeština; the Czech interface writes new documents and opened TXT/Markdown in cs-CZ. Affix headers accept trailing comments. No system dictionary data or Czech hyphenation patterns bundled; parity estimates unchanged | | 2026-10-12 | Tables: dragging the border of one selected cell (Select Cell, or a click along its left edge) moves it in that cell's row only, as in Word, like the border of cells selected across (#962) | | 2026-10-13 | Korean interface catalog completed: all 2,463 entries translated; the ko-KR system locale and saved language choice select the interface. The UI prefers Korean embedded/system font faces so Hangul renders instead of boxes, and print settings and printer feedback are translated. Korean proofing remains unavailable; macOS Korean IME fixes are still pending (#163, #164) | | 2026-10-12 | Word default shortcuts expanded (gap #24): 140 commands bound on macOS (169 combinations), 158 on Linux/Windows (188 combinations). Bound Style Inspector (Shift+F1), Macros (Alt+F8), Strikethrough (Mod+Shift+X), Find Next (Shift+F4), Copy/Paste (Mod+Insert, Shift+Insert), Go Back (Mod+Alt+Z), File Open/Save/Save As/Print (Mod+Alt+F2, Alt+Shift+F2, Alt+F2, Mod+Alt+I), Field navigation (Alt+F1, Alt+Shift+F1), Outline show first line (Alt+Shift+L), Next Footnote (Alt+Shift+>), Link to Previous (Alt+Shift+R), AutoText (Alt+F3), Spelling on Mac (Mod+Alt+L) | @@ -397,6 +398,8 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks | +| 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged | | 2026-10-12 | trivial | Progress log: one selected cell's border drags in its row only (#962) | | 2026-10-12 | minor | Progress log: Word default shortcuts expanded to 140 commands on macOS (169 combinations) and 158 on Linux/Windows (188 combinations), addressing gap #24 | | 2026-10-13 | trivial | Web/Draft long-page blur fixed with visible full-resolution raster tiles | diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index cc60e8120..79df10556 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1,3 +1,8 @@ +// Reuse our hand-written Czech proof fixture; its install helper resolves this parent import. +use wordcraft_proof::cs::install_dictionary; +#[path = "../../proof/src/cs/test_dict.rs"] +mod czech_test_dict; + use serde_json::json; use wordcraft_doc::props::BorderStyle; use wordcraft_doc::{Pos, StoryRef}; @@ -5363,6 +5368,42 @@ fn portuguese_proofing_through_the_review_commands() { assert_eq!(left.as_array().unwrap().len(), 1, "{left}"); } +/// Czech proofing through the same commands as Review, F7, Change and Add to Dictionary. +#[test] +fn czech_proofing_through_the_review_commands() { + czech_test_dict::install(); + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "Město pocitac, škola škola."})); + assert!(run(&mut s, "review.issues", json!({})).as_array().unwrap().len() >= 3); + run(&mut s, "select.all", json!({})); + run(&mut s, "review.language", json!({"lang": "cs_cz", "default": true})); + assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("cs-CZ")); + assert_eq!(run(&mut s, "format.state", json!({}))["lang"], "cs-CZ"); + let issues = run(&mut s, "review.issues", json!({})); + let arr = issues.as_array().unwrap(); + assert_eq!(arr.len(), 2, "{issues}"); + assert!(arr.iter().all(|i| i["lang"] == "cs"), "{issues}"); + assert!( + arr.iter().any(|i| i["text"] == "pocitac" && i["kind"] == "spelling" && i["suggestions"].as_array().unwrap().iter().any(|s| s == "počítač")), + "{issues}" + ); + assert!(arr.iter().any(|i| i["text"] == "škola škola" && i["kind"] == "grammar" && i["message"] == "Opakované slovo: „škola“"), "{issues}"); + run(&mut s, "caret.docStart", json!({})); + assert_eq!(run(&mut s, "review.spelling", json!({}))["text"], "pocitac"); + run(&mut s, "review.applySuggestion", json!({"text": "počítač"})); + assert!(text(&s).starts_with("Město počítač,")); + // Exercise dictionary addition on a misspelling still present in the document. + run(&mut s, "select.text", json!({"text": "počítač"})); + run(&mut s, "text.insert", json!({"text": "zqcsenginevlastni"})); + assert_eq!(run(&mut s, "review.issues", json!({})).as_array().unwrap().len(), 2); + run(&mut s, "review.addToDictionary", json!({"word": "zqcsenginevlastni"})); + let left = run(&mut s, "review.issues", json!({})); + let left = left.as_array().unwrap(); + assert_eq!(left.len(), 1); + assert_eq!(left[0]["kind"], "grammar"); + assert_eq!(left[0]["lang"], "cs"); +} + /// 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/affix.rs b/crates/proof/src/affix.rs index fb362c76b..1cbf3d1df 100644 --- a/crates/proof/src/affix.rs +++ b/crates/proof/src/affix.rs @@ -14,7 +14,7 @@ //! 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, +//! `FORBIDDENWORD`, `NOSUGGEST`, and the suggestion tables `TRY`, `KEY`, `MAP` and `REP`. Rejected: compounding, //! long or numeric flags, `NEEDAFFIX` and the other directives that change which words are correct. //! //! Lookups never panic: stems sit in one string, found through an open-addressing hash table, and @@ -172,6 +172,8 @@ pub struct AffixDict { sets: Vec, /// The `FORBIDDENWORD` flag's bit; stems carrying it are not words (0: none declared). forbidden: u128, + /// Stems with NOSUGGEST are correct, but none of their forms are suggested. + no_suggest: u128, /// Open-addressing hash table of stem indices (`EMPTY` = free). table: Vec, /// Flag char of each bit. @@ -183,6 +185,7 @@ pub struct AffixDict { /// Longest appended suffix, in chars. max_sfx_chars: usize, try_chars: String, + key: String, map: Vec>, rep: Vec<(String, String)>, /// Provenance and licence lines, kept through [`AffixDict::to_bytes`]. @@ -247,10 +250,12 @@ struct Rules { prefixes: Vec, suffixes: Vec, try_chars: String, + key: String, map: Vec>, rep: Vec<(String, String)>, /// The `FORBIDDENWORD` flag: stems carrying it are listed but never correct. forbidden: Option, + no_suggest: Option, } /// A rule as parsed from text, before flags become bits: `(flag, cross, strip, add, cont, cond)`. @@ -275,8 +280,8 @@ fn parse_aff(aff: &str) -> Result { } 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" => {} + "SET" | "LANG" | "NAME" | "VERSION" | "HOME" | "WORDCHARS" | "MAXNGRAMSUGS" | "MAXDIFF" | "ONLYMAXDIFF" | "NOSPLITSUGS" + | "SUGSWITHDOTS" | "WARN" | "FORBIDWARN" | "MAXCPDSUGS" => {} "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. @@ -284,14 +289,21 @@ fn parse_aff(aff: &str) -> Result { return Err(format!("line {}: FLAG {} is not supported (one-character flags only)", n + 1, at(1))); } } - "FORBIDDENWORD" => { + directive @ ("FORBIDDENWORD" | "NOSUGGEST") => { 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)), + (Some(c), None) if !c.is_whitespace() => { + if directive == "FORBIDDENWORD" { + r.forbidden = Some(c); + } else { + r.no_suggest = Some(c); + } + } + _ => return Err(format!("line {}: {directive} needs one flag character", n + 1)), } } "TRY" => r.try_chars = at(1).to_string(), + "KEY" => r.key = at(1).to_string(), "MAP" => { if !map_header && at(1).parse::().is_ok() { map_header = true; @@ -312,7 +324,8 @@ fn parse_aff(aff: &str) -> Result { 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)); + // Only a header ignores trailing fields; a rule condition beginning with # is literal. + 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; @@ -400,7 +413,9 @@ impl AffixDict { offs: Vec::with_capacity(stems.len() + 1), flag_chars: flag_chars.clone(), forbidden, + no_suggest: rules.no_suggest.map_or(0, bit_of), try_chars: rules.try_chars, + key: rules.key, map: rules.map, rep: rules.rep, comments, @@ -529,6 +544,10 @@ impl AffixDict { /// Flag mask of a stem, if `w` is one. fn stem_flags(&self, w: &str) -> Option { + self.stem_flags_excluding(w, self.forbidden) + } + + fn stem_flags_excluding(&self, w: &str, excluded: u128) -> Option { if self.table.is_empty() { return None; } @@ -542,9 +561,8 @@ impl AffixDict { if self.word(t as usize) == Some(w) { let id = *self.flags.get(t as usize)?; 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); + // All derivations use this lookup: exclusions apply to stems and their forms. + return mask.filter(|m| m & excluded == 0); } slot = (slot + 1) & mask; } @@ -553,8 +571,8 @@ impl AffixDict { /// 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) { + fn form_has_flag(&self, w: &str, bit: u128, depth: u8, excluded: u128) -> bool { + if self.stem_flags_excluding(w, excluded).is_some_and(|f| f & bit != 0) { return true; } if depth == 0 { @@ -570,7 +588,7 @@ impl AffixDict { 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) { + if r.cont & bit != 0 && r.cond_matches(&root, true) && self.form_has_flag(&root, r.bit, depth - 1, excluded) { return true; } } @@ -581,10 +599,19 @@ impl AffixDict { /// Is `w` exactly (case and all) a stem or an affixed form of one? pub fn contains(&self, w: &str) -> bool { + self.contains_excluding(w, self.forbidden) + } + + /// Exactly spelled dictionary words eligible for suggestions (NOSUGGEST stems excluded). + pub fn contains_suggestible(&self, w: &str) -> bool { + self.contains_excluding(w, self.forbidden | self.no_suggest) + } + + fn contains_excluding(&self, w: &str, excluded: u128) -> bool { if w.is_empty() || w.len() > MAX_WORD_BYTES { return false; } - if self.stem_flags(w).is_some() || self.suffixed(w, 0) { + if self.stem_flags_excluding(w, excluded).is_some() || self.suffixed(w, 0, excluded) { return true; } let mut root = String::with_capacity(w.len() + 8); @@ -599,13 +626,13 @@ impl AffixDict { if !r.cond_matches(&root, false) { continue; } - if self.stem_flags(&root).is_some_and(|f| f & r.bit != 0) { + if self.stem_flags_excluding(&root, excluded).is_some_and(|f| f & r.bit != 0) { return true; } - if self.form_has_flag(&root, r.bit, INNER_CHAIN) { + if self.form_has_flag(&root, r.bit, INNER_CHAIN, excluded) { return true; } - if r.cross && self.suffixed(&root, r.bit) { + if r.cross && self.suffixed(&root, r.bit, excluded) { return true; } } @@ -614,7 +641,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: u128) -> bool { + fn suffixed(&self, w: &str, need: u128, excluded: 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. @@ -629,7 +656,7 @@ impl AffixDict { 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) + if let Some(f) = self.stem_flags_excluding(&root, excluded) && f & need == need && rules().any(|r| f & r.bit != 0 && (need == 0 || r.cross) && r.cond_matches(&root, true)) { @@ -637,7 +664,7 @@ impl AffixDict { } // 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)) { + if need == 0 && rules().any(|r| r.cond_matches(&root, true) && self.form_has_flag(&root, r.bit, INNER_CHAIN, excluded)) { return true; } } @@ -649,15 +676,24 @@ impl AffixDict { /// — 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) { + self.check_excluding(w, self.forbidden) + } + + /// Whether a suggestion is correct in its displayed case and has an eligible stem. + pub fn can_suggest(&self, w: &str) -> bool { + self.check_excluding(w, self.forbidden | self.no_suggest) + } + + fn check_excluding(&self, w: &str, excluded: u128) -> bool { + if self.contains_excluding(w, excluded) { return true; } match casing(w) { Casing::Lower | Casing::Mixed => false, - Casing::Initial => self.contains(&w.to_lowercase()), + Casing::Initial => self.contains_excluding(&w.to_lowercase(), excluded), Casing::Upper => { let lower = w.to_lowercase(); - self.contains(&capitalize(&lower)) || self.contains(&lower) + self.contains_excluding(&capitalize(&lower), excluded) || self.contains_excluding(&lower, excluded) } } } @@ -726,6 +762,11 @@ impl AffixDict { &self.try_chars } + /// The `KEY` keyboard rows, separated by `|` (adjacent letters are likely mistypes). + pub fn key(&self) -> &str { + &self.key + } + /// The `MAP` groups: letters often confused with each other (`aą`, `zżź`, `oóu`…). pub fn map(&self) -> &[Vec] { &self.map @@ -757,16 +798,21 @@ impl AffixDict { if !self.try_chars.is_empty() { out.push_str(&format!("TRY {}\n", self.try_chars)); } + if !self.key.is_empty() { + out.push_str(&format!("KEY {}\n", self.key)); + } 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(' ', "_"))); } - if self.forbidden != 0 - && let Some(c) = self.flag_char(self.forbidden) - { - out.push_str(&format!("FORBIDDENWORD {c}\n")); + for (directive, mask) in [("FORBIDDENWORD", self.forbidden), ("NOSUGGEST", self.no_suggest)] { + if mask != 0 + && let Some(c) = self.flag_char(mask) + { + out.push_str(&format!("{directive} {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)] { @@ -823,13 +869,20 @@ impl AffixDict { let at = |k: usize| f.get(k).copied().unwrap_or(""); match at(0) { "TRY" => rules.try_chars = at(1).to_string(), + "KEY" => rules.key = at(1).to_string(), "MAP" => rules.map.push(at(1).chars().collect()), "REP" => rules.rep.push((at(1).replace('_', " "), at(2).replace('_', " "))), - "FORBIDDENWORD" => { + directive @ ("FORBIDDENWORD" | "NOSUGGEST") => { 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}`")), + (Some(c), None) if !c.is_whitespace() => { + if directive == "FORBIDDENWORD" { + rules.forbidden = Some(c); + } else { + rules.no_suggest = Some(c); + } + } + _ => return Err(format!("affix dictionary: bad {directive} in `{line}`")), } } kind @ ("PFX" | "SFX") => { @@ -885,7 +938,7 @@ impl AffixDict { } #[cfg(test)] -mod tests { +pub(crate) 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"; @@ -895,6 +948,69 @@ mod tests { AffixDict::from_hunspell(AFF, DIC, &["test data"]).unwrap() } + #[test] + fn affix_headers_allow_comments_but_rule_conditions_stay_literal() { + let aff = "SET UTF-8\nPFX X Y 1 # prefix comment\nPFX X 0 ne . # rule comment\nSFX y Y 1 # suffix comment\nSFX y 0 y . # rule comment\n"; + let d = AffixDict::from_hunspell(aff, "1\nstrom/Xy\n", &[]).unwrap(); + assert_eq!(d.rule_count(), 2, "headers must not become affix rules"); + for w in ["strom", "stromy", "nestrom", "nestromy"] { + assert!(d.contains(w), "{w}"); + } + for w in ["stromY1", "stromN1", "X", "y"] { + assert!(!d.contains(w), "{w}"); + } + let hash = AffixDict::from_hunspell("SFX H Y 1\nSFX H 0 # .\nTRY a#b\nKEY a#b\n", "1\nstrom/H\n", &[]).unwrap(); + assert!(hash.contains("strom#"), "a hash in an affix field is literal"); + let literal = AffixDict::from_hunspell( + "PFX P Y 1 # header\nPFX P 0 pre #\nSFX S Y 1 # header\nSFX S 0 post #\n", + "3\n#stem/P\nstem#/S\nstem/PS\n", + &[], + ) + .unwrap(); + assert!(literal.contains("pre#stem") && literal.contains("stem#post")); + assert!(!literal.contains("prestem") && !literal.contains("stempost"), "# must remain a literal condition"); + + assert_eq!(hash.try_chars(), "a#b"); + assert_eq!(hash.key(), "a#b"); + assert_eq!(AffixDict::from_bytes(&hash.to_bytes()).unwrap().key(), "a#b"); + } + + /// Shared hand-written fixture: collectors must reject every path to NOSUGGEST forms. + pub(crate) fn assert_nosuggest_is_honoured(suggest: impl Fn(&AffixDict, &str, usize) -> Vec) { + let aff = "SET UTF-8\nNOSUGGEST N\nFORBIDDENWORD F\nTRY abeklmnostxy\nMAP 1\nMAP ae\nKEY ak\nREP 2\nREP xy toka\nREP zz normal\nPFX X Y 1\nPFX X 0 pre .\nSFX A Y 1\nSFX A 0 ble/B .\nSFX B Y 1\nSFX B 0 s .\n"; + let d = AffixDict::from_hunspell(aff, "4\ntoka/NXA\nnormal/XA\nTkname/N\nbanned/F\n", &[]).unwrap(); + for word in d.forms("toka").into_iter().chain(["TOKA".into(), "Toka".into(), "Tkname".into(), "TKNAME".into()]) { + assert!(d.check(&word), "NOSUGGEST is still correct: {word}"); + assert!(!d.can_suggest(&word), "NOSUGGEST form: {word}"); + } + for input in ["tokax", "tokablesx", "pretokax", "pretokablesx", "toka", "TOKA", "tkname", "teka", "xy", "normaltoka", "toak", "taak"] { + let results = suggest(&d, input, 32); + assert!(results.iter().all(|word| word.split(' ').all(|part| d.can_suggest(part))), "{input}: {results:?}"); + assert!(results.iter().all(|word| !word.to_lowercase().contains("toka") && word.to_lowercase() != "tkname"), "{input}: {results:?}"); + } + assert!(suggest(&d, "norrmal", 8).contains(&"normal".into())); + assert!(suggest(&d, "zz", 8).contains(&"normal".into())); + // Filtering happens before display casing: ordinary mixed-case stems retain the + // existing all-caps suggestion behaviour, with and without a NOSUGGEST declaration. + for aff in ["", "NOSUGGEST N\n"] { + let ordinary = AffixDict::from_hunspell(aff, "2\nmixCase\nhidden/N\n", &[]).unwrap(); + assert!(suggest(&ordinary, "MIXCASX", 8).contains(&"MIXCASE".into())); + } + } + + #[test] + fn nosuggest_is_correct_but_not_suggestible_and_survives_compact_format() { + let d = AffixDict::from_hunspell("NOSUGGEST N\nSFX S Y 1\nSFX S 0 s .\n", "2\nsecret/NS\nnormal/S\n", &[]).unwrap(); + for dict in [&d, &AffixDict::from_bytes(&d.to_bytes()).unwrap()] { + for word in ["secret", "secrets", "Secret", "SECRETS"] { + assert!(dict.check(word), "{word}"); + assert!(!dict.can_suggest(word), "{word}"); + } + assert!(dict.contains_suggestible("normal") && dict.can_suggest("NORMALS")); + } + assert!(AffixDict::from_hunspell("NOSUGGEST NN\n", "1\nword\n", &[]).is_err()); + } + #[test] fn stems_suffixes_prefixes_and_cross_products() { let d = dict(); diff --git a/crates/proof/src/cs/grammar.rs b/crates/proof/src/cs/grammar.rs new file mode 100644 index 000000000..26a14fcdf --- /dev/null +++ b/crates/proof/src/cs/grammar.rs @@ -0,0 +1,103 @@ +//! Generic repeated-word and spacing checks with Czech messages. +//! No Czech-specific grammar, comma or sentence-capital rules are inferred. + +use crate::{Issue, IssueKind, ProofLang, words}; + +fn issue(start: usize, end: usize, message: String, suggestions: Vec) -> Issue { + Issue { start, end, kind: IssueKind::Grammar, message, suggestions, lang: ProofLang::Cs } +} + +pub fn check(text: &str) -> Vec { + let mut issues = Vec::new(); + repeated_words(text, &words(text), &mut issues); + spaces(text, &mut issues); + issues.sort_by_key(|i| (i.start, i.end)); + issues +} + +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() { + v.push(issue(*a0, *b1, format!("Opakované slovo: „{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(); + // A ratio/score keeps the spaces in digit-space-colon-space-digit. + let ratio = p == ':' + && text.get(..i).and_then(|before| before.chars().next_back()).is_some_and(|c| c.is_ascii_digit()) + && after == Some(' ') + && it.next().is_some_and(|c| c.is_ascii_digit()); + let flag = !ratio + && 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(), "Mezera před interpunkcí".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, "Dvojitá mezera".into(), vec![" ".into()])); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn repeated_words_and_spaces_have_czech_messages() { + let text = "Město město , škola."; + let issues = check(text); + assert_eq!(issues.len(), 3, "{issues:?}"); + assert_eq!(issues[0].message, "Opakované slovo: „město“"); + assert_eq!(issues[0].suggestions, ["Město"]); + assert!(issues.iter().any(|i| i.message == "Mezera před interpunkcí" && &text[i.start..i.end] == " ,")); + assert!(issues.iter().any(|i| i.message == "Dvojitá mezera")); + assert!(issues.iter().all(|i| i.lang == ProofLang::Cs && i.kind == IssueKind::Grammar)); + for text in ["město. škola", "Dr. Novák", "A apple", "Město, město!", "Úsměv :) Číslo .5 a tečky ..."] { + assert!(check(text).is_empty(), "{text}"); + } + } + + #[test] + fn ratios_and_scores_keep_their_spaces() { + for text in ["3 : 1", "1 : 2", "Skóre 12 : 10.", "Poměr 1 : 100."] { + assert!(check(text).is_empty(), "{text}: {:?}", check(text)); + } + for text in ["Slovo : další", "3 : slovo", "slovo : 1", "3 :"] { + assert!(check(text).iter().any(|i| i.message == "Mezera před interpunkcí"), "{text}"); + } + } + + proptest::proptest! { + #[test] + fn never_panics_and_ranges_are_valid(s in "\\PC{0,80}") { + for issue in check(&s) { + proptest::prop_assert!(issue.start <= issue.end && issue.end <= s.len()); + proptest::prop_assert!(s.is_char_boundary(issue.start) && s.is_char_boundary(issue.end)); + } + } + } +} diff --git a/crates/proof/src/cs/mod.rs b/crates/proof/src/cs/mod.rs new file mode 100644 index 000000000..25253d2a7 --- /dev/null +++ b/crates/proof/src/cs/mod.rs @@ -0,0 +1,212 @@ +//! Czech proofing using the installed Hunspell `cs_CZ` dictionary (never bundled). +//! Suggestions use its runtime MAP/REP/TRY/KEY tables. Generic grammar checks have Czech +//! messages; hyphenation keeps the existing fallback, without Czech pattern data. + +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 Czech dictionary: `None` until first use, then the installed one (or an empty one). +static DICT: RwLock>> = RwLock::new(None); + +/// File names of a Czech Hunspell dictionary. +const NAMES: &[&str] = &["cs_CZ", "cs-CZ", "cs"]; + +/// The Czech dictionary: the installed Hunspell `cs_CZ` (read on first use), one given with +/// [`install_dictionary`], or an empty one when none is available (Czech 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!("Czech spelling: no Hunspell cs_CZ dictionary installed"); + return AffixDict::default(); + }; + crate::hunspell::load(&aff, &dic).unwrap_or_else(|e| { + log::warn!("Czech spelling: {e}"); + AffixDict::default() + }) +} + +/// Use this Czech 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 Czech 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 Czech 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 Czech? Numbers, single letters, short words in capitals +/// (acronyms: `ČR`, `EU`), words with digits and words in other scripts are accepted, and so +/// is every word when no Czech 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 Czech 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 dictionary_lookup_accepts_czech_file_names() { + let dir = std::env::temp_dir().join(format!("wordcraft-cs-aliases-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + for name in ["cs_CZ", "cs-CZ", "cs"] { + std::fs::write(dir.join(format!("{name}.aff")), test_dict::AFF).unwrap(); + std::fs::write(dir.join(format!("{name}.dic")), test_dict::DIC).unwrap(); + let (aff, dic) = crate::hunspell::find_in(&dir, NAMES).unwrap(); + let d = crate::hunspell::load(&aff, &dic).unwrap(); + assert!(is_correct_with(&d, "městě")); + std::fs::remove_file(aff).unwrap(); + std::fs::remove_file(dic).unwrap(); + } + std::fs::remove_dir(dir).unwrap(); + } + + #[test] + fn czech_words_affixes_chains_case_and_diacritics() { + test_dict::install(); + let d = dictionary(); + assert!(has_dictionary()); + assert_eq!(d.len(), 20); + assert_eq!(d.rule_count(), 8); + for w in [ + "město", + "města", + "městě", + "neměsta", + "rychle", + "rychlejší", + "učitel", + "učitelé", + "Praha", + "Praze", + "Město", + "POČÍTAČ", + "příliš", + "žluťoučký", + "kůň", + "úpěl", + "ďábelské", + "ódy", + "děkujeme", + "zpráva", + "zprava", + "škola-počítač", + ] { + assert!(is_correct(w), "{w}"); + } + for w in ["městu", "městoě", "rychleejší", "učiteléé", "praha", "mĚsto", "pocitac", "dekujem", "škola-pocitac"] { + assert!(!is_correct(w), "{w}"); + } + for c in "ěščřžýáíéůú".chars() { + assert!(test_dict::DIC.contains(c), "UTF-8 fixture covers {c}"); + } + } + + #[test] + fn forbidden_words_are_rejected_in_every_case() { + test_dict::install(); + for w in ["zqzakázané", "Zqzakázané", "ZQZAKÁZANÉ"] { + assert!(!is_correct(w), "{w}"); + } + } + + #[test] + fn missing_dictionary_and_ignored_inputs_are_quiet() { + let empty = AffixDict::default(); + assert!(is_correct_with(&empty, "pocitac")); + assert!(install_dictionary("FLAG long\n", "1\nstrom\n").is_err()); + let d = AffixDict::from_hunspell(test_dict::AFF, test_dict::DIC, &[]).unwrap(); + for w in ["", "a", "2026", "ČR", "článek2", "日本語"] { + assert!(is_correct_with(&d, w), "{w}"); + } + } + + #[test] + fn user_dictionary_applies() { + test_dict::install(); + assert!(!is_correct("zqčeskévlastníslovo")); + crate::add_word("Zqčeskévlastníslovo"); + assert!(is_correct("zqčeskévlastníslovo")); + } + + #[test] + fn czech_dispatch_messages_options_and_mixed_spans() { + test_dict::install(); + assert!(crate::has_dictionary(Some("cs-CZ"))); + assert!(crate::is_correct_for("městě", Some("cze"))); + let text = "Město pocitac"; + let issues = crate::check_text(text, &[(0..text.len(), crate::ProofLang::Cs)]); + assert_eq!(issues.len(), 1, "{issues:?}"); + assert_eq!(&text[issues[0].start..issues[0].end], "pocitac"); + assert_eq!(issues[0].message, "Možná pravopisná chyba"); + assert_eq!(issues[0].lang, crate::ProofLang::Cs); + assert!(crate::suggest_in("pocitac", 8, crate::ProofLang::Cs).contains(&"počítač".into())); + let text = "Město this is is wrong"; + let issues = crate::check_text(text, &[(0..6, crate::ProofLang::Cs), (7..text.len(), crate::ProofLang::En)]); + assert!(issues.iter().any(|i| i.lang == crate::ProofLang::En && i.message.starts_with("Repeated word"))); + assert!(issues.iter().all(|i| i.start >= 7)); + let opts = crate::Options { ignore_uppercase: false, ..Default::default() }; + assert!(!crate::is_correct_in_with("ZQZAKÁZANÉ", crate::ProofLang::Cs, &opts)); + } +} diff --git a/crates/proof/src/cs/suggest.rs b/crates/proof/src/cs/suggest.rs new file mode 100644 index 000000000..6c6000912 --- /dev/null +++ b/crates/proof/src/cs/suggest.rs @@ -0,0 +1,424 @@ +//! Czech suggestions: bounded MAP variants, REP replacements, KEY neighbours, TRY edits +//! and a stem scan. All linguistic tables are read from the installed dictionary. + +use std::collections::{HashMap, HashSet}; + +use crate::affix::{AffixDict, Casing, capitalize, casing}; + +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 = 17; +const COST_KEY: u32 = 13; +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_suggestible(part) { + words.push(part.to_string()); + } else { + let c = capitalize(part); + if !self.d.contains_suggestible(&c) { + return; + } + words.push(c); + // A name ranks after a common word at the same distance (a capitalized name). + cost += 1; + } + } + self.keep(words.join(" "), cost); + } + + /// Keep a suggestion known to be correct. + fn keep(&mut self, text: String, cost: u32) { + if !text.split(' ').all(|part| self.d.can_suggest(part)) { + return; + } + 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 (typing without diacritics); an accent the writer did type +/// is likelier right, so taking one away costs more than a known confusion from the REP table. +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: as defined by the installed table. +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 (dictionary-defined relatives) 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 Czech 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; both halves must be dictionary words. + for (i, _) in input.char_indices().skip(1) { + let (Some(a), Some(b)) = (input.get(..i), input.get(i..)) else { continue }; + let long = |part: &str| part.chars().count() >= 3; + let cost = if long(a) && long(b) { COST_SPLIT } else { COST_SPLIT_RARE }; + col.offer(&format!("{a} {b}"), cost); + } + // 5. Keyboard neighbours from the installed dictionary's KEY rows. + for row in d.key().split('|').take(32) { + let keys: Vec = row.chars().take(128).collect(); + for pair in keys.windows(2) { + let [a, b] = pair else { continue }; + for (i, &c) in chars.iter().enumerate() { + let replacement = if c == *a { + Some(*b) + } else if c == *b { + Some(*a) + } else { + None + }; + if let Some(replacement) = replacement { + let mut v = chars.clone(); + if let Some(x) = v.get_mut(i) { + *x = replacement; + } + col.offer(&v.iter().collect::(), COST_KEY); + } + } + } + } + // One-letter edits use only the runtime TRY alphabet, with bounded work. + let mut letters: Vec = d.try_chars().chars().filter(|c| c.is_lowercase()).take(128).collect(); + letters.sort_unstable(); + letters.dedup(); + 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 + // (the first changed letter breaks ties). + 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::*; + + #[test] + fn an_extra_trailing_letter_is_dropped_before_a_short_word_split() { + let d = AffixDict::from_hunspell("", "2\npočítač\nz\n", &[]).unwrap(); + let suggestions = suggest_in(&d, "počítačz", 8); + assert_eq!(suggestions.first().map(String::as_str), Some("počítač"), "{suggestions:?}"); + assert!(suggestions.contains(&"počítač z".into()), "{suggestions:?}"); + } + + #[test] + fn suggestions_are_fast_enough() { + crate::cs::test_dict::install(); + let _ = suggest("město", 1); + let t = std::time::Instant::now(); + for word in ["zqčeskévelmidlouhéslovo", "pocitacz", "nejrychllejší"] { + let _ = suggest(word, 6); + } + let ms = t.elapsed().as_secs_f64() * 1000.0; + eprintln!("3 Czech suggestion calls: {ms:.0} ms"); + assert!(ms < 20_000.0, "{ms} ms"); + } + + #[test] + fn nosuggest_stems_and_forms_are_never_offered() { + crate::affix::tests::assert_nosuggest_is_honoured(suggest_in); + } + + #[test] + fn czech_suggestions_use_runtime_tables_and_case() { + crate::cs::test_dict::install(); + for (word, want) in [ + ("mesto", "město"), + ("mňe", "mě"), + ("xůž", "nůž"), + ("škloa", "škola"), + ("pocitac", "počítač"), + ("Mesto", "Město"), + ("praha", "Praha"), + ("toškola", "to škola"), + ] { + assert!(suggest(word, 8).contains(&want.into()), "{word}: {:?}", suggest(word, 8)); + } + // Each table changes the answer without a hard-coded Czech alphabet or confusion list. + for (table, wrong, right) in + [("MAP 1\nMAP cč\n", "caj", "čaj"), ("REP 1\nREP xy město\n", "xy", "město"), ("TRY š\n", "kola", "škola"), ("KEY xn\n", "xůž", "nůž")] + { + let dic = format!("1\n{right}\n"); + let d = AffixDict::from_hunspell(table, &dic, &[]).unwrap(); + assert!(suggest_in(&d, wrong, 4).contains(&right.into()), "{table}"); + let without = AffixDict::from_hunspell("", &dic, &[]).unwrap(); + assert!(!suggest_in(&without, wrong, 4).contains(&right.into()), "must depend on {table}"); + } + } + + #[test] + fn suggestions_stay_bounded_and_skip_forbidden_words() { + crate::cs::test_dict::install(); + for w in ["", "dvě slova", &"ě".repeat(41)] { + assert!(suggest(w, 4).is_empty()); + } + assert!(suggest("mesto", 0).is_empty()); + assert!(suggest_in(&AffixDict::default(), "mesto", 4).is_empty()); + assert!(suggest("město", 4).len() <= 4); + assert!(!suggest("město", 4).contains(&"město".into())); + assert!(!suggest("zqzakázane", 8).contains(&"zqzakázané".into())); + } +} diff --git a/crates/proof/src/cs/test_dict.rs b/crates/proof/src/cs/test_dict.rs new file mode 100644 index 000000000..6cc59759e --- /dev/null +++ b/crates/proof/src/cs/test_dict.rs @@ -0,0 +1,59 @@ +//! Hand-written synthetic Czech test data. No words, rules or suggestion tables were copied +//! from a system dictionary. This is a small fixture, not a redistributable Czech dictionary. + +pub const AFF: &str = "SET UTF-8 +FLAG UTF-8 +FORBIDDENWORD F +TRY aeiouěščřžýáíéůúntklmp +KEY xn|qř +MAP 5 +MAP eěé +MAP cč +MAP rř +MAP uůú +MAP ií +REP 1 +REP mňe mě +SFX A Y 2 # synthetic declension +SFX A o a o +SFX A o ě o +SFX B N 1 +SFX B 0 e/C . +SFX C N 1 +SFX C e ejší e +SFX D N 1 +SFX D 0 itel/E . +SFX E N 1 +SFX E 0 é . +SFX P Y 1 +SFX P ha ze ha +PFX X Y 1 +PFX X 0 ne . +"; + +pub const DIC: &str = "20 +město/AX +rychl/B +uč/D +Praha/P +škola +počítač +příliš +žluťoučký +kůň +úpěl +ďábelské +ódy +zpráva +zprava +děkujeme +mě +nůž +si +to +zqzakázané/F +"; + +pub fn install() { + super::install_dictionary(AFF, DIC).unwrap(); +} diff --git a/crates/proof/src/hunspell.rs b/crates/proof/src/hunspell.rs index 4a42e3ec3..d4b6f07be 100644 --- a/crates/proof/src/hunspell.rs +++ b/crates/proof/src/hunspell.rs @@ -200,6 +200,7 @@ mod tests { 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("cs_CZ"), ["dict-cs", "dict-cs-CZ"]); 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"]); diff --git a/crates/proof/src/hyphen.rs b/crates/proof/src/hyphen.rs index f4096c633..d0eae0c49 100644 --- a/crates/proof/src/hyphen.rs +++ b/crates/proof/src/hyphen.rs @@ -856,7 +856,9 @@ mod tests { #[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}"); + for lang in [ProofLang::Other, ProofLang::Cs] { + assert_eq!(hyphen_points_in(w, &Limits::default(), lang), hyphen_points(w, &Limits::default()), "{w}"); + } } } diff --git a/crates/proof/src/lang.rs b/crates/proof/src/lang.rs index 61dfc7530..e6000c48e 100644 --- a/crates/proof/src/lang.rs +++ b/crates/proof/src/lang.rs @@ -32,6 +32,9 @@ pub enum ProofLang { /// Russian (`ru`, `ru-RU`): the installed Hunspell dictionary (when there is one), the Russian /// grammar rules and the Russian word-division rules. Ru, + /// Czech (`cs`, `cs-CZ`): the installed Hunspell dictionary, dictionary-driven suggestions + /// and generic punctuation checks. Hyphenation keeps the fallback English patterns. + Cs, /// 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 @@ -41,7 +44,7 @@ pub enum ProofLang { impl ProofLang { /// Every language with a spelling dictionary, in display order. - pub const PROOFED: [ProofLang; 7] = [ProofLang::En, ProofLang::Pl, ProofLang::Pt, ProofLang::It, ProofLang::Hr, ProofLang::Tr, ProofLang::Ru]; + pub const PROOFED: [ProofLang; 8] = [ProofLang::En, ProofLang::Pl, ProofLang::Pt, ProofLang::It, ProofLang::Hr, ProofLang::Tr, ProofLang::Ru, ProofLang::Cs]; /// 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. @@ -64,12 +67,14 @@ impl ProofLang { ProofLang::Tr } else if primary.eq_ignore_ascii_case("ru") || primary.eq_ignore_ascii_case("rus") { ProofLang::Ru + } else if primary.eq_ignore_ascii_case("cs") || primary.eq_ignore_ascii_case("ces") || primary.eq_ignore_ascii_case("cze") { + ProofLang::Cs } else { ProofLang::Other } } - /// The primary language subtag (`en`, `pl`, `bg`, `tr`, `ru`); empty for [`ProofLang::Other`]. + /// The primary language subtag (`en`, `pl`, `bg`, `tr`, `ru`, `cs`); empty for [`ProofLang::Other`]. pub fn code(self) -> &'static str { match self { ProofLang::En => "en", @@ -80,6 +85,7 @@ impl ProofLang { ProofLang::Hr => "hr", ProofLang::Tr => "tr", ProofLang::Ru => "ru", + ProofLang::Cs => "cs", ProofLang::Other => "", } } @@ -159,6 +165,11 @@ mod tests { for t in ["it", "it-IT", "IT_it", "it-CH", "it-CH.UTF-8", " it-IT ", "ita"] { assert_eq!(ProofLang::from_tag(Some(t)), ProofLang::It, "{t}"); } + for t in ["cs", "cs-CZ", "CS-cz", "cs_CZ", " ces ", "CES", "cze", "CZE"] { + assert_eq!(ProofLang::from_tag(Some(t)), ProofLang::Cs, "{t}"); + } + assert_eq!(ProofLang::Cs.code(), "cs"); + assert!(ProofLang::Cs.is_proofed() && ProofLang::PROOFED.contains(&ProofLang::Cs)); for t in ["en", "en-US", "en-GB", "EN_au", "en-Latn-US"] { assert_eq!(ProofLang::from_tag(Some(t)), ProofLang::En, "{t}"); } @@ -168,7 +179,7 @@ mod tests { for t in ["tr", "tr-TR", "tr-CY", "TR_tr", "tur"] { assert_eq!(ProofLang::from_tag(Some(t)), ProofLang::Tr, "{t}"); } - for t in ["fr-FR", "de", "x-none", "ja-JP", "plx", "bgx", "trx", "e", "-"] { + for t in ["fr-FR", "de", "x-none", "ja-JP", "plx", "bgx", "trx", "csx", "e", "-"] { assert_eq!(ProofLang::from_tag(Some(t)), ProofLang::Other, "{t}"); } assert_eq!(ProofLang::from_tag(None), ProofLang::En); diff --git a/crates/proof/src/lib.rs b/crates/proof/src/lib.rs index a0099c664..114097d43 100644 --- a/crates/proof/src/lib.rs +++ b/crates/proof/src/lib.rs @@ -28,6 +28,9 @@ //! words (`кто-то`, `по-русски`, `северо-западный`) understood, suggestions tuned for Russian //! (`е`/`ё`, `жи`/`ши`, `тся`/`ться`, voiced and voiceless pairs, missing spaces), Russian //! grammar rules with Russian messages, and Russian word division. +//! - **Czech** ([`ProofLang::Cs`], module [`cs`]): the installed Hunspell `cs_CZ` dictionary +//! (not bundled), dictionary-driven suggestions and generic checks with Czech messages. +//! Hyphenation uses the existing English fallback; no Czech patterns are bundled. //! - **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), @@ -38,6 +41,7 @@ pub mod affix; pub mod bg; +pub mod cs; pub mod dict; pub mod hr; pub mod hunspell; @@ -593,6 +597,7 @@ fn spelling_issue(text: &str, a: usize, b: usize, lang: ProofLang, opts: &Option ProofLang::It => "Possibile errore di ortografia", ProofLang::Tr => "Olası yazım hatası", ProofLang::Ru => "Возможна орфографическая ошибка", + ProofLang::Cs => "Možná pravopisná chyba", _ => "Possible spelling mistake", }; Some(Issue { start: a, end: b, kind: IssueKind::Spelling, message: message.into(), suggestions: Vec::new(), lang }) @@ -613,6 +618,7 @@ pub fn is_correct_in_with(word: &str, lang: ProofLang, opts: &Options) -> bool { ProofLang::It => accepted_by_options(word, opts) || it::is_correct(word), ProofLang::Tr => accepted_by_options(word, opts) || tr::is_correct(word), ProofLang::Ru => accepted_by_options(word, opts) || ru::is_correct(word), + ProofLang::Cs => accepted_by_options(word, opts) || cs::is_correct(word), ProofLang::Bg | ProofLang::Other => true, } } @@ -627,13 +633,14 @@ pub fn suggest_in(word: &str, max: usize, lang: ProofLang) -> Vec { ProofLang::It => it::suggest(word, max), ProofLang::Tr => tr::suggest(word, max), ProofLang::Ru => ru::suggest(word, max), + ProofLang::Cs => cs::suggest(word, max), ProofLang::Bg | ProofLang::Other => Vec::new(), } } /// Grammar issues in a paragraph of text in `lang`: the English rules, the Polish ones /// ([`pl::grammar`]), the Portuguese ones ([`pt::grammar`]), the Italian ones ([`it::grammar`]), -/// the Turkish ones ([`tr::grammar`]), or none for a language WordCraft doesn't proof. +/// the Turkish ones ([`tr::grammar`]), Czech generic checks ([`cs::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), @@ -643,6 +650,7 @@ pub fn check_grammar_in(text: &str, lang: ProofLang) -> Vec { ProofLang::It => it::grammar::check(text), ProofLang::Tr => tr::grammar::check(text), ProofLang::Ru => ru::grammar::check(text), + ProofLang::Cs => cs::grammar::check(text), ProofLang::Bg | ProofLang::Other => Vec::new(), } } @@ -1290,7 +1298,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::Pt, ProofLang::It, ProofLang::Hr, ProofLang::Tr, ProofLang::Ru, ProofLang::Other] { + for lang in [ProofLang::En, ProofLang::Pl, ProofLang::Pt, ProofLang::It, ProofLang::Hr, ProofLang::Tr, ProofLang::Ru, ProofLang::Cs, ProofLang::Other] { let _ = check_spelling_in(&s, lang); let _ = check_grammar_in(&s, lang); let _ = is_correct_in(&s, lang); @@ -1321,8 +1329,14 @@ mod tests { } #[test] - fn mixed_spans_never_panic(s in "\\PC{0,60}", cuts in proptest::collection::vec((0usize..80, 0usize..80, 0u8..7), 0..6)) { - let langs = [ProofLang::En, ProofLang::Pl, ProofLang::Pt, ProofLang::It, ProofLang::Tr, ProofLang::Ru, ProofLang::Other]; + fn czech_suggestions_never_panic(s in "[a-zěščřžýáíéůúĚŠČŘŽÝÁÍÉŮÚ'’ -]{0,14}") { + cs::test_dict::install(); + let _ = suggest_in(&s, 4, ProofLang::Cs); + } + + #[test] + fn mixed_spans_never_panic(s in "\\PC{0,60}", cuts in proptest::collection::vec((0usize..80, 0usize..80, 0u8..8), 0..6)) { + let langs = [ProofLang::En, ProofLang::Pl, ProofLang::Pt, ProofLang::It, ProofLang::Tr, ProofLang::Ru, ProofLang::Cs, ProofLang::Other]; let spans: Vec<(Range, ProofLang)> = cuts.iter().map(|&(a, b, l)| (a.min(b)..a.max(b), langs[l as usize % langs.len()])).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/pl/suggest.rs b/crates/proof/src/pl/suggest.rs index 10cfc0761..0e32a1eaf 100644 --- a/crates/proof/src/pl/suggest.rs +++ b/crates/proof/src/pl/suggest.rs @@ -70,11 +70,11 @@ impl Collector<'_> { let mut words = Vec::new(); let mut cost = cost; for part in cand.split(' ') { - if self.d.contains(part) { + if self.d.contains_suggestible(part) { words.push(part.to_string()); } else { let c = capitalize(part); - if !self.d.contains(&c) { + if !self.d.contains_suggestible(&c) { return; } words.push(c); @@ -88,6 +88,9 @@ impl Collector<'_> { /// Keep a suggestion known to be correct. fn keep(&mut self, text: String, cost: u32) { + if !text.split(' ').all(|part| self.d.can_suggest(part)) { + return; + } self.seq += 1; let seq = self.seq; let e = self.found.entry(text).or_insert((cost, seq)); @@ -371,6 +374,11 @@ mod tests { assert!(s.iter().any(|x| x == want), "{word} → {s:?}, want {want}"); } + #[test] + fn nosuggest_stems_and_forms_are_never_offered() { + crate::affix::tests::assert_nosuggest_is_honoured(suggest_in); + } + #[test] fn polish_misspellings_get_the_right_suggestion() { crate::pl::test_dict::install(); diff --git a/crates/proof/src/pt/suggest.rs b/crates/proof/src/pt/suggest.rs index 314ef39bd..36f1c7b07 100644 --- a/crates/proof/src/pt/suggest.rs +++ b/crates/proof/src/pt/suggest.rs @@ -73,11 +73,11 @@ impl Collector<'_> { let mut words = Vec::new(); let mut cost = cost; for part in cand.split(' ') { - if self.d.contains(part) { + if self.d.contains_suggestible(part) { words.push(part.to_string()); } else { let c = capitalize(part); - if !self.d.contains(&c) { + if !self.d.contains_suggestible(&c) { return; } words.push(c); @@ -90,6 +90,9 @@ impl Collector<'_> { /// Keep a suggestion known to be correct. fn keep(&mut self, text: String, cost: u32) { + if !text.split(' ').all(|part| self.d.can_suggest(part)) { + return; + } self.seq += 1; let seq = self.seq; let e = self.found.entry(text).or_insert((cost, seq)); @@ -373,6 +376,11 @@ mod tests { assert!(s.iter().any(|x| x == want), "{word} → {s:?}, want {want}"); } + #[test] + fn nosuggest_stems_and_forms_are_never_offered() { + crate::affix::tests::assert_nosuggest_is_honoured(suggest_in); + } + #[test] fn portuguese_misspellings_get_the_right_suggestion() { crate::pt::test_dict::install(); diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 664d12056..092e37684 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -119,7 +119,14 @@ pub static LANGUAGES: [LangInfo; 23] = [ 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: "cs", + name: "Čeština", + source: include_str!("cs.tsv"), + prefer_hans: false, + editing_lang: Some("cs-CZ"), + 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 3890ee9f5..e23fa4552 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -1064,6 +1064,33 @@ fn a_polish_interface_writes_new_documents_in_polish() { assert_eq!(app.session.doc.styles.default_chr.lang.as_deref(), Some("pl-PL")); } +#[test] +fn a_czech_interface_writes_new_documents_and_plain_text_in_czech() { + 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(); + app.run("ui.language", serde_json::json!({"value": "cs"})).unwrap(); + frame(&mut app); + assert_eq!(app.session.editing_lang.as_deref(), Some("cs-CZ")); + assert_eq!(app.session.doc.styles.default_chr.lang.as_deref(), Some("cs-CZ")); + assert!(!app.session.dirty); + app.run("file.new", serde_json::json!({})).unwrap(); + assert_eq!(app.session.doc.styles.default_chr.lang.as_deref(), Some("cs-CZ")); + set_current(lang("cs")); + assert_eq!(crate::ribbon::caret_language(&app), "Čeština"); + set_current(Lang::EN); + assert_eq!(crate::ribbon::caret_language(&app), "Czech"); + for path in ["poznamka.txt", "poznamka.md"] { + // Base64 of our synthetic text, "Město"; file.open takes bytes without a dialog. + app.session.run("file.open", &serde_json::json!({"path": path, "data": "TcSbc3Rv"})).unwrap(); + assert_eq!(app.session.doc.styles.default_chr.lang.as_deref(), Some("cs-CZ"), "{path}"); + } + 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("cs-CZ"), "opened content keeps its language"); +} + #[test] fn an_italian_interface_writes_new_documents_in_italian() { let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); @@ -1087,7 +1114,7 @@ fn an_italian_interface_writes_new_documents_in_italian() { #[test] 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, [("pt-br", "pt-BR"), ("pl", "pl-PL"), ("tr", "tr-TR"), ("it", "it-IT")]); + assert_eq!(with, [("pt-br", "pt-BR"), ("pl", "pl-PL"), ("cs", "cs-CZ"), ("tr", "tr-TR"), ("it", "it-IT")]); } #[test] diff --git a/crates/ui-egui/src/options.rs b/crates/ui-egui/src/options.rs index 099734bd6..b39d0fd38 100644 --- a/crates/ui-egui/src/options.rs +++ b/crates/ui-egui/src/options.rs @@ -344,6 +344,7 @@ fn language_name(lang: wordcraft_engine::proof::ProofLang) -> &'static str { ProofLang::Hr => tl!("Croatian"), ProofLang::Tr => tl!("Turkish"), ProofLang::Ru => tl!("Russian"), + ProofLang::Cs => tl!("Czech"), // English uses its bundled word list; Bulgarian has no dictionary yet. ProofLang::En | ProofLang::Bg | ProofLang::Other => "", } diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index bbcf9c468..0dd38891e 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -501,7 +501,7 @@ 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); 8] = [ +pub(crate) const PROOFING_LANGUAGES: [(&str, &str); 9] = [ ("en-US", "English (United States)"), ("en-GB", "English (United Kingdom)"), ("pl-PL", "Polish"), @@ -510,6 +510,7 @@ pub(crate) const PROOFING_LANGUAGES: [(&str, &str); 8] = [ ("hr-HR", "Croatian"), ("tr-TR", "Turkish"), ("ru-RU", "Russian"), + ("cs-CZ", "Czech"), ]; /// The English name of a proofing language tag, as the status bar shows it (no tag: WordCraft's @@ -548,6 +549,7 @@ pub(crate) fn language_name(tag: Option<&str>) -> String { wordcraft_engine::proof::ProofLang::It => "Italian".into(), wordcraft_engine::proof::ProofLang::Tr => "Turkish".into(), wordcraft_engine::proof::ProofLang::Ru => "Russian".into(), + wordcraft_engine::proof::ProofLang::Cs => "Czech".into(), wordcraft_engine::proof::ProofLang::En if tag.eq_ignore_ascii_case("en") => "English (United States)".into(), _ => tag.to_string(), }, @@ -662,6 +664,7 @@ pub(crate) fn language_menu(ui: &mut Ui, app: &mut WordApp) { || (tag == "it-IT" && current.eq_ignore_ascii_case("it")) || (tag == "hr-HR" && current.eq_ignore_ascii_case("hr")) || (tag == "tr-TR" && current.eq_ignore_ascii_case("tr")) + || (tag == "cs-CZ" && wordcraft_engine::proof::ProofLang::from_tag(Some(¤t)) == wordcraft_engine::proof::ProofLang::Cs) || (tag == "en-US" && current.eq_ignore_ascii_case("en")) }; let checks = language_checks(¤t, listed); @@ -2776,6 +2779,14 @@ mod tests { assert_eq!(c.listed, PROOFING_LANGUAGES.map(|(t, _)| t == "pt-BR")); } + #[test] + fn czech_has_a_name_in_the_language_menu() { + assert!(PROOFING_LANGUAGES.contains(&("cs-CZ", "Czech"))); + for tag in ["cs", "cs-CZ", "CS-cz", "ces", "CZE"] { + assert_eq!(language_name(Some(tag)), "Czech", "{tag}"); + } + } + #[test] fn portuguese_has_a_name_in_the_language_menu() { assert!(PROOFING_LANGUAGES.iter().any(|(t, _)| *t == "pt-BR")); diff --git a/docs/gaps.md b/docs/gaps.md index e08b75318..07b088f13 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -31,7 +31,7 @@ Document renaming: desktop renaming requires a filesystem that supports hard lin | 6 | **Print dialog depth**: desktop printer choice, copies, collation, duplex and grayscale submit through craft-print; File › Print retains preview, page ranges and odd/even (#641); driver tray/options and pages per sheet remain; 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) | | 9 | **Password-protected `.doc` files** (AES) can't be opened; password-protected `.docx` opens and saves (agile encryption, #55 closed by #281) | | `crates/docx/src/crypt.rs`; `crates/docbin/src/lib.rs` opens XOR/RC4/RC4 CryptoAPI `.doc` with a password (#1079, untested on Word-written files; AES `.doc` refused) | Corporate and legal users with old files | 3–5 | [file-format-parity.md](file-format-parity.md) | -| 8 | **Proofing covers English, Polish, Portuguese, Italian, Croatian, Turkish and Russian only**; no dictionaries, grammar or hyphenation for the other ~43 languages Word proofs (text in them is left unmarked) | | `crates/proof` `ProofLang::PROOFED`: English, Polish, Portuguese (pt-BR via the installed VERO dictionary, #797), Italian (it-IT via the installed Hunspell dictionary), Croatian (hr-HR, 2026-10-13), Turkish (tr_TR spelling, grammar, TDK hyphenation, 2026-10-13) and Russian (the installed `ru_RU` dictionary, 2026-10-12), a language per run; Bulgarian has hyphenation only (#826); open PRs add Danish (#723), Russian (#894) and Czech (#1048); Word ships 120 proofing tools; issues #25, #40, #100 open | Every writer outside English, Polish, Portuguese, Italian, Croatian, Turkish and Russian | 20–40 | [localization-parity.md](localization-parity.md) | +| 8 | **Proofing covers English, Polish, Portuguese, Italian, Croatian, Turkish, Russian and Czech only**; no dictionaries, grammar or hyphenation for the other ~42 languages Word proofs (text in them is left unmarked) | | `crates/proof` `ProofLang::PROOFED`: English, Polish, Portuguese (pt-BR via the installed VERO dictionary, #797), Italian (it-IT via the installed Hunspell dictionary), Croatian (hr-HR, 2026-10-13), Turkish (tr_TR spelling, grammar, TDK hyphenation, 2026-10-13) and Russian (the installed `ru_RU` dictionary, 2026-10-12), a language per run; Bulgarian has hyphenation only (#826); open PRs add Danish (#723), Russian (#894) and Czech (#1048); Word ships 120 proofing tools; issues #25, #40, #100 open | Every writer outside English, Polish, Portuguese, Italian, Croatian, Turkish and Russian | 20–40 | [localization-parity.md](localization-parity.md) | | 10 | **Objects**: 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 11/11, Layout 22/22); group and ungroup landed (#267) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) | | 11 | **Interface languages**: 3 of the 12 key languages missing (hi, ar, id) and pt-PT (French landed, #729; Vietnamese, #1434; Korean, #1216; 21 interface languages in `crates/ui-egui/src/i18n`); open PRs for Arabic (#737, #1042, #1032), Indonesian (#572) and pt-PT (#1028); no mirrored RTL interface yet; ~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); Ink to Shape also recognises single-stroke arrows (a line plus a V head drawn in one stroke becomes a straight connector with an arrowhead); missing: Ink to Math, two-stroke arrows and arrows with a closed triangle head, pressure, rendering/editing Word's own ink (drawing-level and direct run-level InkML content parts now survive DOCX save) | | [parity-checklist.md](parity-checklist.md) (Draw 10/11) | Pen and tablet users | 4–8 | [hardware-parity.md](hardware-parity.md) | @@ -86,6 +86,8 @@ The same gaps grouped the way the parity documents are, for agents working in on | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks | +| 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged | | 2026-10-12 | minor | Gap #24 default shortcuts expanded (140 commands on macOS / 158 on Linux/Windows); stale gaps #9 (DOCX agile encryption already works, only encrypted .doc missing) and #15 (DOCX bibliography sources already saved, only desktop integrations missing) updated | | 2026-10-13 | trivial | Previously untracked long Web/Draft page blur resolved with visible high-resolution raster tiling; no gap remains for this issue | | 2026-10-14 | trivial | #1: VML freeforms (rule lines in headers) read and kept on save | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 60949a18c..8d1c5e3e8 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -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, Polish, Portuguese, Italian and Russian**, chosen per run from its language (`w:lang`, resolved through +- **Proofing: English, Polish, Portuguese, Italian, Russian and Czech**, chosen per run from its language (`w:lang`, resolved through the styles; Review › Language and the status bar set it: a short list, plus Set Proofing Language… for any of 29 languages and Set As Default for the document). Languages without proofing tools get the styles; Review › Language and the status bar set it). Languages without proofing tools get @@ -87,8 +87,8 @@ gallery, watermarks, paper sizes and preview tiles. The Czech coverage test enforces both reference catalogs and retains the Chinese catalog coverage; counts keep the existing count-neutral wording (`Počet slov: 21`). Czech proofing is not included. -Polish is the only one besides English with proofing tools, and a Polish interface writes new -documents in Polish (`pl-PL`). +Polish, Portuguese and Czech interfaces write new documents and opened TXT/Markdown text +in their proofing languages (`pl-PL`, `pt-BR`, `cs-CZ`). **Interface audit (runs in CI).** `untranslated_interface_text` lays the app out in a pseudo-language (every tab, File page, dialog, pane, the right-click menu, and a hover and a click on every ribbon @@ -107,13 +107,14 @@ other than envelopes (Letter, Legal, A4, B5 (JIS)) stay as they are (#1128). | Russian (`ru`) | A Russian Hunspell dictionary installed on the system (`ru_RU` from the OS, a package manager, LibreOffice's `dict-ru` or the `WORDCRAFT_DICTIONARIES` folder; UTF-8, KOI8-R or Windows-1251; not bundled, so no Russian spelling marks without it), read by the same affix engine; `ё` optional (`елка`/`ёлка`, `еще`/`ещё`); hyphenated words whole, then by parts (`кто-то`, `по-русски`, `кое-где`, `северо-западный`, `ИИ-консультант`); solid compounds (`высокоэластичный`), `не` with adjectives, participles and verbal nouns (not verbs), productive prefixes (`ультра-`, `эко-`) and prefixed verbs; abbreviations with endings (`ГИПа`) and camel case left alone; our supplement of ~200 modern words with their declensions (`assets/spelling/ru-extra.txt`: `маркетплейс`, `промокод`, `интеграция`, `колеровать`) | `ё`/`е` first, then slips within letter groups (unstressed vowels, voiced and voiceless pairs, `ь`/`ъ`), the common confusions (`жы`, `чя`, `тся`/`ться`, doubled consonants, unpronounced consonants: `здраствуй` → `здравствуй`), a missing space (`незнаю` → `не знаю`; not a preposition before a verb), one typo, then a stem scan | Russian messages: repeated word, space before punctuation, double space, sentence capital (knows `т. е.`, `г.`, `руб.`, `ул.`, initials, numbers, dialogue dashes), possibly missing comma before `что`/`чтобы`/`если`/`хотя`/`поскольку`/`который` and before `потому что` (not after conjunctions and particles such as `и`, `только`, `даже`, `так`, `для того`; not after a preposition; not in `хотя бы`, `что-то`), doubled punctuation | The rules of Russian word division as code (no pattern file): a vowel on each side, two letters at least, `ь`/`ъ`/`й` kept with the letter before, a break after `ъ`, a consonant cluster divided after its first consonant | | Polish (`pl`) | The Hunspell `pl_PL` dictionary installed on the system (OS package, Homebrew, LibreOffice, the folder set in File › Options › Proofing › Dictionaries folder, or `WORDCRAFT_DICTIONARIES`; not bundled, so no Polish spelling marks without it; the same pane shows which file each language found), 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 | +| Czech (`cs`, `cs-CZ`, `ces`, `cze`) | The system Hunspell `cs_CZ` dictionary, read at runtime (Debian/Ubuntu: `hunspell-cs`; the same system, LibreOffice and `WORDCRAFT_DICTIONARIES` search paths as Polish). No dictionary data is bundled; without an installed dictionary spelling stays off | suggestions from the loaded dictionary's MAP/REP/TRY/KEY tables, missing spaces and a stem scan; short-word splits rank after deleting an extra letter; NOSUGGEST stems and their derived forms are accepted when typed but excluded from suggestions | Czech messages (including Czech quotation marks) for repeated words, space before punctuation and double spaces only; digit-space-colon-space-digit ratios keep their spaces; no Czech-specific grammar rules | existing English fallback, as for `Other`; no Czech patterns bundled (the TeX/LibreOffice Czech patterns are GPL-2) | | Italian (`it`, any region) | The Hunspell `it_IT` dictionary installed on the system (same places as Polish and Portuguese; not bundled — the upstream file is GPL-licensed), read at runtime by the same affix engine; elided forms check the word after the apostrophe (`l'amico`, `un'idea`, `c'è`, `dov'è`, `senz'altro`), truncations keep their apostrophe (`po'`, `mo'`), hyphenated compounds part by part | missing accents first (`caffe` → `caffè`, `perche` → `perché`), the dictionary's confusion table, missing space (`perme` → `per me`), one typo, then an accent-folding stem scan | Italian messages: repeated word (emphatic doubles like `piano piano` are allowed), space before punctuation, double space, sentence capital (knows `ecc.`, `dott.`, `prof.`, initials, ordinals), doubled punctuation, `un pò` → `un po'`, `qual'è` → `qual è`, `E'` → `È`, `un'altro` → `un altro` and `un altra` → `un'altra` (gender from the ending, masculine `-ista`/`-ma`/`-cida`… left alone); these work without a dictionary; no comma rule | TeX Italian patterns (Beccari; MIT option), no exception words | | Turkish (`tr`, any region) | The Hunspell `tr_TR` dictionary installed on the system (same places as Polish; not bundled), read at runtime; proper nouns capitalized, apostrophe suffixes (`Ankara'da`, `WordCraft'ta`) and hyphenated compounds | missing diacritics / ASCII restoration first (`turkce` → `Türkçe`, `isik` → `ışık`), common confusion table (`yalnış` → `yanlış`, `herşey` → `her şey`), detached suffixes (`bende` → `ben de`, `geldinmi` → `geldi mi`), one typo | Turkish messages: repeated word (idiomatic reduplications allowed: `yavaş yavaş`), space before punctuation, double space, sentence capital (knows `vb.`, `vs.`, `prof.`, `dr.`), no comma before/after conjunctions (`ve`, `veya`, `de`), doubled punctuation | Turkish syllable rules (TDK rules); no external pattern file or word list needed | | 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, +takes ~1 µs, a suggestion list 1–35 ms (release build). Not yet: Czech hyphenation patterns or Czech-specific grammar, other languages' dictionaries, a thesaurus for Polish, Word's style and clarity refinements, Polish-specific AutoCorrect. Measured with the LibreOffice `ru_RU` dictionary on a private corpus of 205 real-world Russian @@ -191,6 +192,8 @@ WordCraft keeps 2,54 cm). | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks | +| 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged | | 2026-10-13 | minor | Swedish interface catalog (2,504 entries), locale selection, saved preference, audit coverage and font checks (#1219) | | 2026-10-14 | trivial | Print area translated in all 18 interface catalogs (printer list, duplex, grayscale, discovery/submission feedback, print commands) — 18 strings that shipped English-only with the craft-print integration; written from the English meaning, pending native-speaker review like the rest of the catalogs | | 2026-10-14 | trivial | Italian: the interface audit's catalog check covers Italian too, as it does Russian, Hungarian, Turkish and Bulgarian; `Privacy Settings` no longer says "current" ("attuali"); a test pins the envelope size labels #1128 made translatable (Italian inches, the region's decimal comma) | diff --git a/docs/roadmap.md b/docs/roadmap.md index fa109655a..ec344c1e1 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-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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Czech proofing review fixes; previously 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 @@ -62,7 +62,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, Polish and Portuguese proofing, a proofing language per run | 70–115 h | +| M15 | Localization (12 key languages, RTL interface, proofing languages) | 15 UI languages; English, Polish and Portuguese proofing plus Czech spelling and generic checks (system Hunspell; English hyphenation fallback), 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 @@ -74,6 +74,8 @@ languages, screen-reader access, ecosystem decisions (VBA, add-ins, cloud), an i | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks | +| 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged | | 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) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d54ae2f65..2699ee237 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 | 44% | (in Review) | 20–40 | English, Polish, Portuguese and Italian, 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); Italian (it-IT) against the installed Hunspell dictionary through the same affix engine, with accent-first suggestions and conservative grammar rules. 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 | 44% | (in Review) | 20–40 | English, Polish, Portuguese, Italian and Czech (system Hunspell, #1048), 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); Italian (it-IT) against the installed Hunspell dictionary through the same affix engine, with accent-first suggestions and conservative grammar rules. 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,8 @@ can be used without committing Word output. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks | +| 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged | | 2026-10-13 | trivial | Windows Magnifier follows the caret onto a new line (#1119): Accessibility row updated; no percentage change | | 2026-10-12 | minor | Italian proofing: it-IT spelling, accent-first suggestions and conservative grammar; Proofing row updated, 42% → 44% | | 2026-10-12 | trivial | The 62 open issues and ~45 open PRs cited as evidence are dated 2026-10-10 (the measurement pass); not re-counted (197 issues were open on 2026-10-12) | From 9a19e09d97b7b16d814c17e9fe2ce554bdbcc0fe Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 15 Oct 2026 00:53:07 -0400 Subject: [PATCH 091/101] CI: rustfmt the proofing language list Co-authored-by: Claude Opus 5.5 --- crates/proof/src/lang.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/proof/src/lang.rs b/crates/proof/src/lang.rs index e6000c48e..ded6f4c39 100644 --- a/crates/proof/src/lang.rs +++ b/crates/proof/src/lang.rs @@ -44,7 +44,8 @@ pub enum ProofLang { impl ProofLang { /// Every language with a spelling dictionary, in display order. - pub const PROOFED: [ProofLang; 8] = [ProofLang::En, ProofLang::Pl, ProofLang::Pt, ProofLang::It, ProofLang::Hr, ProofLang::Tr, ProofLang::Ru, ProofLang::Cs]; + pub const PROOFED: [ProofLang; 8] = + [ProofLang::En, ProofLang::Pl, ProofLang::Pt, ProofLang::It, ProofLang::Hr, ProofLang::Tr, ProofLang::Ru, ProofLang::Cs]; /// 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. From add48514432ffbece30b494d8491c195ae742f8e Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Thu, 15 Oct 2026 06:55:10 +0200 Subject: [PATCH 092/101] layout.pageSetup validates its section values (#1582) (#1584) * #1582: WIP * #1582: layout.pageSetup {section} validates like Size, Margins and Columns Co-Authored-By: Claude Opus 5.5 * #1582: cargo fmt Co-Authored-By: Claude Opus 5.5 * #1582: shared page limits; notes, borders and columns validated too Co-Authored-By: Claude Opus 5.5 * #1582: page limits are pub, as in #1496 Co-Authored-By: Claude Opus 5.5 * #1582: drop page constants #1496 now defines on main Co-Authored-By: Claude Opus 5.5 * #1582: a page smaller than 36 pt passes Page Setup with zero margins 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 --- ROADMAP.md | 2 + crates/engine/src/cmd/page.rs | 230 +++++++++++++++++++++++++++++++++- crates/engine/src/tests.rs | 1 + 3 files changed, 230 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 734932a10..7663dc43c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -170,6 +170,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-14 | All Caps and Small Caps draw ß as SS, as Word does (#1581): "Straße" in All Caps reads STRASSE, not STRASE, and other letters whose capital is two characters (ff, ʼn) draw in full. The ß is still one character: the caret never stops between the two S, and a click on them lands before or after it | | 2026-10-13 | Windows Magnifier follows the caret onto a new line: after Enter or Shift+Enter the view moves to the empty line at once, instead of staying on the line above until two more letters are typed (#1119) | | 2026-10-13 | A window too big for its screen shrinks to fit when it opens (#1215): on a laptop screen the first window reached under the taskbar or past the screen edge, with the end of the scroll bar, so dragging the scroll bar stopped where the pointer met the edge. A window saved that size is fitted on its next launch too | +| 2026-10-14 | `layout.pageSetup` with a `section` checks its values like Margins, Size and Columns (#1582): non-finite numbers and margins that leave no room for text are refused with nothing changed; page sides, column count and gap, header/footer distances and line-numbering values are clamped to the same ranges | | 2026-10-13 | `wordcraft --control` checks its port (#1426): a missing port, one outside 1–65535 or one that isn't a number prints an error naming it and exits with status 2, instead of starting without the control channel (and dropping `WORDCRAFT_CONTROL_PORT`); a flag right after it, such as `--sample` or `--version`, is read as a flag rather than as the port | | 2026-10-14 | Text wrap (#1376): in the narrow strip beside a square-wrapped picture, a word that doesn't fit moves on whole to the next row; it used to be split across the strip ("docu" / "mentazione") | | 2026-10-14 | DOCX files with legacy VML preset shapes show them (#1377): Word's right arrow (`_x0000_t13`) and the other presets appear with their fill, outline and size, drawn as WordCraft draws the same DrawingML preset (the nearest shape when it has no exact one), and saving keeps them as DrawingML shapes; before, they were dropped | @@ -429,6 +430,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-14 | trivial | Progress log: All Caps and Small Caps draw ß as SS (#1581) | | 2026-10-13 | trivial | Progress log: Windows Magnifier follows the caret onto a new line (#1119) | | 2026-10-13 | trivial | Progress log: a window too big for its screen shrinks to fit (#1215) | +| 2026-10-14 | trivial | Progress log: Page Setup sections are validated (#1582) | | 2026-10-13 | trivial | Progress log: `--control` checks its port (#1426) | | 2026-10-14 | trivial | Progress log: words beside a floating picture are no longer split (#1376) | | 2026-10-14 | trivial | Progress log: VML preset shapes (#1377) | diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs index 66a38fa60..4cee3bd96 100644 --- a/crates/engine/src/cmd/page.rs +++ b/crates/engine/src/cmd/page.rs @@ -1,8 +1,9 @@ //! Layout tab: page setup (margins, orientation, size, columns), breaks, line numbers, hyphenation. use serde_json::{Value, json}; +use wordcraft_doc::props::{Border, Borders}; use wordcraft_doc::section::{ - Columns, DocGrid, DocGridType, LineNumbering, MAX_CHAR_SPACE, MAX_GRID_PITCH, MIN_GRID_PITCH, SectionProps, SectionStart, + Columns, DocGrid, DocGridType, LineNumbering, MAX_CHAR_SPACE, MAX_GRID_PITCH, MAX_NOTE_START, MIN_GRID_PITCH, SectionProps, SectionStart, }; use wordcraft_doc::{Block, Pos, StoryRef}; @@ -40,13 +41,14 @@ pub fn specs() -> Vec { .pure(), CommandSpec::new("layout.pageSetup", "Page Setup", "Layout › Page Setup", |s, v| { if let Some(props) = v.get("section") { - let new: SectionProps = serde_json::from_value(props.clone()).map_err(|e| CmdError::Params(e.to_string()))?; + let mut new: SectionProps = serde_json::from_value(props.clone()).map_err(|e| CmdError::Params(e.to_string()))?; + validate_section(&mut new)?; return with_sect(s, |x| *x = new.clone()); } s.ui_requests.push(json!({"open": "pageSetup"})); Ok(serde_json::to_value(sect(s)).unwrap_or(Value::Null)) }) - .params(r#"{"section"?: SectionProps}"#), + .params(r#"{"section"?: SectionProps} (replaces the whole section, headers and footers included; checked like Size, Margins, Columns, Line Numbers, Footnotes and Page Borders: non-finite numbers and margins that leave no room for text are refused; page sides 7.2–1584 pt, 1–12 columns with 0–300 pt gaps, header/footer/gutter 0–1584 pt, page border width 0.25–6 pt and distance 0–31 pt, note start 1–32767, line numbers start 1–32767, countBy 1–100, distance 0–1584 pt are clamped)"#), CommandSpec::new("layout.verticalAlign", "Vertical Alignment", "Layout › Page Setup › Layout", |s, v| { let va = wordcraft_doc::section::PageVAlign::parse(p::str(v, "value").unwrap_or_default()); with_sect(s, |x| x.valign = va) @@ -371,6 +373,90 @@ pub fn columns_preset_at(s: &Session) -> Option<&'static str> { } } +/// Checks a whole section handed to `layout.pageSetup` by the rules of Size, Margins, Columns, +/// Line Numbers, Footnotes and Page Borders: a non-finite number or margins that leave no room +/// for text is an error; the rest is clamped to the ranges those commands use. +fn validate_section(x: &mut SectionProps) -> Result<(), CmdError> { + let bad = |what: &str| CmdError::Params(format!("section: {what} must be a finite number")); + let finite = [x.page_w, x.page_h, x.margin_top, x.margin_bottom, x.margin_left, x.margin_right, x.header, x.footer, x.gutter, x.columns.space]; + if finite.iter().any(|n| !n.is_finite()) { + return Err(bad("sizes, margins, distances and column gaps")); + } + if x.columns.widths.iter().any(|(w, sp)| !w.is_finite() || !sp.is_finite()) { + return Err(bad("column widths")); + } + if x.line_numbers.as_ref().is_some_and(|l| !l.distance.is_finite()) { + return Err(bad("the line number distance")); + } + if x.doc_grid.as_ref().is_some_and(|g| !g.line_pitch.is_finite()) { + return Err(bad("the grid pitch")); + } + if x.page_borders.as_ref().is_some_and(|b| border_sides(b).any(|d| !d.width.is_finite() || !d.space.is_finite())) { + return Err(bad("page border widths and distances")); + } + x.page_w = x.page_w.clamp(MIN_PAGE_SIDE, MAX_PAGE_SIDE); + x.page_h = x.page_h.clamp(MIN_PAGE_SIDE, MAX_PAGE_SIDE); + // Like Size, the orientation follows the sides. + if x.page_w != x.page_h { + x.landscape = x.page_w > x.page_h; + } + x.margin_top = x.margin_top.max(0.0); + x.margin_bottom = x.margin_bottom.max(0.0); + x.margin_left = x.margin_left.max(0.0); + x.margin_right = x.margin_right.max(0.0); + x.header = x.header.clamp(0.0, x.page_h); + x.footer = x.footer.clamp(0.0, x.page_h); + x.gutter = x.gutter.clamp(0.0, MAX_PAGE_SIDE); + // A page narrower than the usual text room (the smallest is 0.1") needs only margins that + // leave the whole of it: zero margins, gutter included. + let room = |side: f32| MIN_TEXT_ROOM.min(side); + if x.margin_left + x.margin_right + x.gutter > x.page_w - room(x.page_w) || x.margin_top + x.margin_bottom > x.page_h - room(x.page_h) { + return Err(CmdError::Params("margins leave no room for text".into())); + } + x.columns.space = x.columns.space.clamp(0.0, 300.0); + x.columns.widths.truncate(12); + for (w, sp) in &mut x.columns.widths { + *w = w.clamp(18.0, MAX_PAGE_SIDE); + *sp = sp.clamp(0.0, 300.0); + } + // As in Columns: custom widths set the count; a single width is no custom layout. + if x.columns.widths.len() > 1 { + x.columns.count = x.columns.widths.len() as u32; + } else { + x.columns.widths.clear(); + x.columns.count = x.columns.count.clamp(1, 12); + } + if let Some(l) = &mut x.line_numbers { + l.start = l.start.clamp(1, 32_767); + l.count_by = l.count_by.clamp(1, 100); + l.distance = l.distance.clamp(0.0, 1584.0); + } + if let Some(g) = &mut x.doc_grid { + g.line_pitch = g.line_pitch.clamp(MIN_GRID_PITCH, MAX_GRID_PITCH); + g.char_space = g.char_space.clamp(-MAX_CHAR_SPACE, MAX_CHAR_SPACE); + } + x.page_num_start = x.page_num_start.map(|n| n.min(100_000)); + for n in [&mut x.footnote_pr, &mut x.endnote_pr] { + n.num_start = n.num_start.map(|v| v.clamp(1, MAX_NOTE_START)); + } + // Page Borders: width 0.25–6 pt, distance from the text 0–31 pt. + if let Some(b) = &mut x.page_borders { + for d in border_sides_mut(b) { + d.width = d.width.clamp(0.25, 6.0); + d.space = d.space.clamp(0.0, 31.0); + } + } + Ok(()) +} + +fn border_sides(b: &Borders) -> impl Iterator { + [&b.top, &b.left, &b.bottom, &b.right, &b.between, &b.inside_v].into_iter().flatten() +} + +fn border_sides_mut(b: &mut Borders) -> impl Iterator { + [&mut b.top, &mut b.left, &mut b.bottom, &mut b.right, &mut b.between, &mut b.inside_v].into_iter().flatten() +} + fn margins(s: &mut Session, v: &Value) -> CmdResult { let preset = p::str(v, "preset"); let (t, b, l, r) = match preset { @@ -600,6 +686,144 @@ mod tests { Session::new(wordcraft_doc::Document::from_text("one\ntwo\nthree")) } + fn with_section(f: impl Fn(&mut Value)) -> Value { + let mut v = serde_json::to_value(SectionProps::default()).unwrap(); + f(&mut v); + json!({"section": v}) + } + + /// `layout.pageSetup {section}` checks the values like the dedicated commands do. + #[test] + fn page_setup_section_is_validated() { + let mut s = doc(); + let before = sect(&s); + // Refused with an error, nothing changes: non-finite (overflowing), margins that leave no text room. + for (what, p) in [ + ("inf width", with_section(|v| v["pageW"] = json!(1e300))), + ("inf margin", with_section(|v| v["marginTop"] = json!(1e300))), + ("inf header", with_section(|v| v["header"] = json!(1e300))), + ("inf column space", with_section(|v| v["columns"]["space"] = json!(1e300))), + ("inf column width", with_section(|v| v["columns"]["widths"] = json!([[1e300, 0.0], [100.0, 0.0]]))), + ("inf line number distance", with_section(|v| v["lineNumbers"] = json!({"distance": 1e300}))), + ("inf grid pitch", with_section(|v| v["docGrid"] = json!({"type": "lines", "linePitch": 1e300, "charSpace": 0}))), + ("inf border space", with_section(|v| v["pageBorders"] = json!({"top": {"style": "single", "width": 1.0, "space": 1e300}}))), + ("inf border width", with_section(|v| v["pageBorders"] = json!({"top": {"style": "single", "width": 1e300, "space": 1.0}}))), + ("margins > page", with_section(|v| v["marginLeft"] = json!(5000.0))), + ("margins eat the height", with_section(|v| v["marginTop"] = json!(700.0))), + ("gutter eats the width", with_section(|v| v["gutter"] = json!(600.0))), + ("negative page", with_section(|v| v["pageW"] = json!(-5.0))), + ("zero page height", with_section(|v| v["pageH"] = json!(0.0))), + ] { + assert!(s.run("layout.pageSetup", &p).is_err(), "{what}"); + assert_eq!(sect(&s), before, "{what}"); + } + // Clamped. + s.run( + "layout.pageSetup", + &with_section(|v| { + v["pageW"] = json!(100.0); + v["pageH"] = json!(100.0); + v["marginTop"] = json!(-10.0); + v["marginBottom"] = json!(0.0); + v["marginLeft"] = json!(0.0); + v["marginRight"] = json!(0.0); + v["header"] = json!(-3.0); + v["footer"] = json!(1e6); + v["columns"]["count"] = json!(1000); + v["columns"]["space"] = json!(-4.0); + v["lineNumbers"] = json!({"countBy": 0, "start": 0, "distance": 1e6, "restart": "page"}); + v["docGrid"] = json!({"type": "lines", "linePitch": 1e9, "charSpace": 2_000_000_000}); + v["pageNumStart"] = json!(4_000_000_000u64); + v["footnotePr"] = json!({"numStart": 4_000_000_000u64}); + v["endnotePr"] = json!({"numStart": 0}); + v["pageBorders"] = + json!({"top": {"style": "single", "width": 1e6, "space": -5.0}, "left": {"style": "single", "width": 0.0, "space": 1e6}}); + }), + ) + .unwrap(); + let x = sect(&s); + assert_eq!((x.page_w, x.page_h), (100.0, 100.0)); + assert_eq!(x.margin_top, 0.0); + assert_eq!(x.header, 0.0); + assert_eq!(x.footer, x.page_h, "a footer distance stays on the page"); + assert_eq!(x.columns.count, 12); + assert_eq!(x.columns.space, 0.0); + let l = x.line_numbers.unwrap(); + assert_eq!((l.count_by, l.start, l.distance), (1, 1, 1584.0)); + let g = x.doc_grid.unwrap(); + assert_eq!((g.line_pitch, g.char_space), (wordcraft_doc::section::MAX_GRID_PITCH, wordcraft_doc::section::MAX_CHAR_SPACE)); + assert_eq!(x.page_num_start, Some(100_000)); + assert_eq!(x.footnote_pr.num_start, Some(wordcraft_doc::section::MAX_NOTE_START)); + assert_eq!(x.endnote_pr.num_start, Some(1)); + let b = x.page_borders.unwrap(); + let (t, lf) = (b.top.unwrap(), b.left.unwrap()); + assert_eq!((t.width, t.space), (6.0, 0.0)); + assert_eq!((lf.width, lf.space), (0.25, 31.0)); + // Huge pages clamp; columns and widths agree; orientation follows the sides. + let mut s = doc(); + s.run( + "layout.pageSetup", + &with_section(|v| { + v["pageW"] = json!(1e5); + v["pageH"] = json!(792.0); + v["landscape"] = json!(false); + v["columns"]["count"] = json!(3); + v["columns"]["widths"] = json!(vec![json!([1.0, 1e5]); 40]); + }), + ) + .unwrap(); + let x = sect(&s); + assert_eq!(x.page_w, 1584.0); + assert!(x.landscape, "wider than tall is landscape"); + assert_eq!(x.columns.widths.len(), 12); + assert_eq!(x.columns.count, 12, "the count follows the widths"); + assert!(x.columns.widths.iter().all(|(w, sp)| *w >= 18.0 && (0.0..=300.0).contains(sp))); + // One width is no custom layout: equal columns. + s.run("layout.pageSetup", &with_section(|v| v["columns"]["widths"] = json!([[200.0, 0.0]]))).unwrap(); + assert!(sect(&s).columns.widths.is_empty()); + } + + /// A valid section still applies as given. + #[test] + fn page_setup_section_applies_a_valid_one() { + let mut s = doc(); + s.run( + "layout.pageSetup", + &with_section(|v| { + v["pageW"] = json!(595.0); + v["pageH"] = json!(842.0); + v["marginLeft"] = json!(50.0); + v["columns"]["count"] = json!(2); + }), + ) + .unwrap(); + let x = sect(&s); + assert_eq!((x.page_w, x.page_h, x.margin_left, x.columns.count), (595.0, 842.0, 50.0, 2)); + } + + /// A page smaller than the usual 36 pt of text room is fine with no margins; any margin or + /// gutter on it is refused. + #[test] + fn page_setup_section_allows_a_tiny_page_without_margins() { + let tiny = |m: f32, g: f32| { + with_section(|v| { + v["pageW"] = json!(7.2); + v["pageH"] = json!(20.0); + for k in ["marginTop", "marginBottom", "marginLeft", "marginRight"] { + v[k] = json!(m); + } + v["gutter"] = json!(g); + }) + }; + let mut s = doc(); + let before = sect(&s); + assert!(s.run("layout.pageSetup", &tiny(1.0, 0.0)).is_err(), "a margin on a tiny page"); + assert!(s.run("layout.pageSetup", &tiny(0.0, 1.0)).is_err(), "a gutter on a tiny page"); + assert_eq!(sect(&s), before); + s.run("layout.pageSetup", &tiny(0.0, 0.0)).unwrap(); + assert_eq!((sect(&s).page_w, sect(&s).page_h), (7.2, 20.0)); + } + /// Layout › Text Direction turns the section, or the table cell the caret is in; one undo step. #[test] fn text_direction_turns_the_section_or_the_cell() { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 79df10556..7e5b9b09d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2692,6 +2692,7 @@ fn hostile_params_never_panic() { json!({"text": 5, "size": "x", "path": [], "pos": {"block": 9999, "off": 99999}}), json!({"value": -1e308, "rows": 1e9}), json!({"width": 1e-9, "height": 1e30, "top": -1e9, "left": 1e9}), + json!({"section": {"pageW": -1e308, "pageH": 0, "marginTop": 1e30, "header": -5, "columns": {"count": 1000000, "space": -1e300, "widths": [[0, 0]]}}}), ]; for spec in reg.all() { // These reach outside the session (files). Read Aloud (`review.readAloud`, `readAloud.*`) From 109956bb90e2b8c55d240d3e5cbe777222a63922 Mon Sep 17 00:00:00 2001 From: Fin Warman Date: Thu, 15 Oct 2026 05:58:59 +0100 Subject: [PATCH 093/101] Tables: preserve row table border exceptions (#1708) * Tables: preserve row table border exceptions * Tests: shorten the row border regression name --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 2 ++ contributors/people.toml | 3 +++ crates/docx/tests/fixtures.rs | 16 ++++++++++++++++ crates/layout/src/table.rs | 25 +++++++++++++++++++++---- crates/layout/src/tests.rs | 28 ++++++++++++++++++++++++++++ docs/file-format-parity.md | 3 ++- docs/gaps.md | 2 ++ docs/layout-parity.md | 2 ++ 8 files changed, 76 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 7663dc43c..452286a27 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -135,6 +135,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log | Date | What landed | +| 2026-10-14 | DOCX row table border exceptions render and survive saving. Explicit cell borders win; a row exception does not change other rows. | | 2026-10-14 | Tab stops edited on the ruler: the corner selector picks Left/Center/Right/Decimal/Bar, a click sets a stop (Alt skips the snap), dragging moves it with a guide line and position tooltip, dragging off the ruler clears it; markers draw per kind and mirror for RTL; one `para.tabs` call and one undo step per gesture | | 2026-10-12 | Fields keep `w:dirty` through DOCX saves (#877): a begin marker flagged by a generator that couldn't compute a result (pandoc-style TOC placeholders) keeps the flag on open/save — as a buffered field or a range field — so Word still refreshes it on open; computing a result (Update Field, Update Table) clears it, like Word's F9 | |---|---| @@ -399,6 +400,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Progress log: row table border exceptions | | 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks | | 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged | | 2026-10-12 | trivial | Progress log: one selected cell's border drags in its row only (#962) | diff --git a/contributors/people.toml b/contributors/people.toml index da86d62d2..2749e0f3f 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -131,3 +131,6 @@ sync_github_name = false [people.rbr48] sync_github_name = true + +[people.finwarman] +real_name = "Fin Warman" diff --git a/crates/docx/tests/fixtures.rs b/crates/docx/tests/fixtures.rs index 635141925..2c36a3bb4 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/fixtures.rs @@ -2814,3 +2814,19 @@ fn hyperlink_with_empty_target_is_no_link() { assert_eq!(p.props_of_char(0).link, None); assert_eq!(p.props_of_char(5).link.as_deref(), Some("mailto:a@example.com")); } + +#[test] +fn row_table_borders_round_trip() { + let d = read_body( + r#" + + +"#, + ); + let saved = wordcraft_docx::write(&d).unwrap(); + let xml = text(&unzip(&saved), "word/document.xml"); + assert!(xml.contains(""), "row table borders were dropped: {xml}"); + assert!(xml.contains(", outer: bool, outer_b: Option, inner_b: Option| own.or(if outer { outer_b } else { inner_b }); let cb = cell.props.borders.unwrap_or_default(); + // A row's table property exceptions (`w:tblPrEx`) override the table's borders for its cells. + let row_borders = row.props.exceptions.as_ref().and_then(|e| e.borders).unwrap_or_default(); let (first_g, last_g) = (g == 0, g + span >= ncols); let rb = parts .map(|p| region_borders(p, (ri, nrows), (first_g, last_g), [is_header, is_total, first_col, last_col, band, col_band])) .unwrap_or_default(); let mut borders = Borders { - top: edge(cb.top.or(rb.top), ri == 0, tb.top, tb.between), - bottom: edge(cb.bottom.or(rb.bottom), ri + 1 == nrows, tb.bottom, tb.between), - left: edge(cb.left.or(if first_g { rb.left } else { rb.inside_v }), first_g, tb.left, tb.inside_v), - right: edge(cb.right.or(if last_g { rb.right } else { rb.inside_v }), last_g, tb.right, tb.inside_v), + top: edge(cb.top.or(if ri == 0 { row_borders.top } else { row_borders.between }).or(rb.top), ri == 0, tb.top, tb.between), + bottom: edge( + cb.bottom.or(if ri + 1 == nrows { row_borders.bottom } else { row_borders.between }).or(rb.bottom), + ri + 1 == nrows, + tb.bottom, + tb.between, + ), + left: edge( + cb.left.or(if first_g { row_borders.left } else { row_borders.inside_v }).or(if first_g { rb.left } else { rb.inside_v }), + first_g, + tb.left, + tb.inside_v, + ), + right: edge( + cb.right.or(if last_g { row_borders.right } else { row_borders.inside_v }).or(if last_g { rb.right } else { rb.inside_v }), + last_g, + tb.right, + tb.inside_v, + ), between: None, inside_v: None, }; diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index eb9e073fe..d6865b0d9 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -7235,3 +7235,31 @@ fn justified_multi_column_pages_stay_at_the_top() { }; assert_eq!(last(PageVAlign::Justify), last(PageVAlign::Top)); } + +#[test] +fn row_borders_stay_on_row() { + let mut d = Document::from_text("before\nafter"); + let mut t = Table::new(2, 2, 200.0); + t.props.style = None; + t.rows[0].props.exceptions = + Some(Box::new(wordcraft_doc::props::TableProps { borders: Some(Borders::all(Border::single(1.0))), ..Default::default() })); + t.rows[0].cells[0].props.borders = Some(Borders { top: Some(Border::single(2.0)), ..Default::default() }); + d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap(); + let rules = rules_on_page0(&lay(&d)); + assert_eq!(rules.len(), 8); + assert_eq!(rules.iter().filter(|w| **w == 2.0).count(), 1); + assert_eq!(rules.iter().filter(|w| **w == 1.0).count(), 7); +} + +#[test] +fn row_nil_overrides_style() { + let mut d = Document::from_text("before\nafter"); + let mut t = Table::new(1, 1, 200.0); + t.rows[0].props.exceptions = Some(Box::new(wordcraft_doc::props::TableProps { + borders: Some(Borders::all(Border { style: BorderStyle::None, width: 0.0, color: None, space: 0.0 })), + ..Default::default() + })); + t.rows[0].cells[0].props.borders = Some(Borders { right: Some(Border::single(2.0)), ..Default::default() }); + d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap(); + assert_eq!(rules_on_page0(&lay(&d)), vec![2.0]); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index c328e3f0a..36c035d96 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -66,7 +66,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 a Styles gallery for files that mark no style `w:qFormat` (LibreOffice: Word's usual quick styles are added, #1238), 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) | +| Tables incl. floating (`w:tblpPr`), table styles and conditional formatting | ✅ | ✅ | Row table border exceptions (`w:tblPrEx/w:tblBorders`) round-trip (`row_table_borders_round_trip`); other row table exceptions are not preserved. 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) | | Footnotes, endnotes, comments | ✅ | ✅ | The document's own note separators and continuation notice (`w:type` `separator`, `continuationSeparator`, `continuationNotice`) round-trip as ids -1, 0 and 1 (#352); note options (`w:pos`, `w:numFmt`, `w:numStart`, `w:numRestart`) round-trip in the settings and in each `w:sectPr` (#385); `commentsExtended` written; `commentsIds` (durable ids) and `commentsExtensible` (UTC dates) read and regenerated consistently with the written paragraph ids; `people.xml` carried verbatim | @@ -208,6 +208,7 @@ attributes inside stories and style or list definitions; a regenerated theme. Te | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Row table border exceptions survive DOCX saves | | 2026-10-12 | trivial | Fields keep `w:dirty` through DOCX round trips — on buffered fields and range-field begin markers alike — and computing a result (Update Field, Update Table) clears it like Word's F9, so a generator's placeholder TOC still refreshes when Word opens the file until WordCraft replaces the placeholder (#877) | | 2026-10-14 | trivial | VML freeforms (`v:shape` with a `path`, such as rule lines in headers) read as freeforms and kept on save instead of being dropped | | 2026-10-14 | trivial | DOCX paragraphs inside complex-field instructions carry a hidden paragraph mark; cached result paragraphs remain visible. | diff --git a/docs/gaps.md b/docs/gaps.md index 07b088f13..3d1d7fdaf 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -10,6 +10,7 @@ wall-clock estimate, and which parity document tracks it. Agents: pick from the first (`gh pr list`), and update this file and the parity doc when a gap closes. Native save reliability: synthetic cases on macOS reproduced loss through the predictable temporary-file name. Saves now use owned, exclusively created siblings, synchronize before replacement and clean up on failure; originals survive pre-replacement errors. This narrows save-path reliability under gap #3; the listed field failures and broader stability work remain open. See [save-path guarantees](file-format-parity.md#native-save-path-guarantees). +Row table border exceptions (`w:tblPrEx/w:tblBorders`) are read, drawn and saved. Other row table exceptions, including widths and cell margins, remain unsupported. Consistent cell widths override stale table grid proportions when their totals agree. Conflicting widths, spans and percentages still need the shared-grid algorithm. @@ -86,6 +87,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Row table border exceptions fixed; other row table exceptions remain unsupported | | 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks | | 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged | | 2026-10-12 | minor | Gap #24 default shortcuts expanded (140 commands on macOS / 158 on Linux/Windows); stale gaps #9 (DOCX agile encryption already works, only encrypted .doc missing) and #15 (DOCX bibliography sources already saved, only desktop integrations missing) updated | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index a789038b7..eb6ffad0a 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -41,6 +41,7 @@ font can't match Word's metrics. | Labels on page-break-only list paragraphs | ✅ | `layout::tests::break_only_list_has_no_label`: no bullet or number on the break, but the next number is still 3 after item 1 and a break. Plain empty items keep their labels. Synthetic DOCX observed through Adobe PDF conversion | — | | List labels: number formats (decimal, letters, Russian letters, roman, ordinal text, Chinese/Japanese/Korean counting and legal, 一〇一 digits, 甲乙丙, ①…⑳, full-width), `%n` text such as `第%1章` / `(%1)`, legal numbering | ✅ | `doc::cjk_numbers` tests, `doc::section::tests::russian_letters_follow_word`, `layout::tests::chinese_counting_list_labels_and_their_font_runs`, `layout::tests::russian_letter_list_labels_draw_cyrillic`; a label mixing faces (digit + CJK comma) is drawn run by run (#534); Russian letters skip ё, й, ъ and ь and then double (аа, бб), as observed in Word. Hebrew, Arabic, Hindi, Thai and Vietnamese formats show decimal The label stays attached to the start of the text when the paragraph is centred or aligned to the end, as in Word (#1159). | — | | Body top below a tall header, footer pushes body up | ✅ | #138; a footer taller than the bottom margin moves the body bottom up (#453), and a header or footer linked to the previous section takes its room too (#454) | — | +| Tables: row border exceptions (`w:tblPrEx/w:tblBorders`) | ✅ | `row_borders_stay_on_row`, `row_nil_overrides_style`: explicit cell borders win; row exceptions precede table styles and table borders | — | | Footnotes at the page bottom, endnotes at the end, note numbering | 🟡 | A long note continues onto the next page at a line boundary (its first line stays with the reference), first in that page's note area under a full-width continuation separator (#352); the document's own separator, continuation separator and continuation notice are drawn (the notice under a note that continues); on a page with footnotes a paragraph ending there keeps its space after above the separator, or its last lines go to the next page with the note (#1116); a long endnote continues on the next column or page at a line boundary (#456), and the caret finds note text on its continuation page (#457); each section's note options: footnotes below the text, endnotes at the end of each section, start at, numbering restarted each section or each page (counted from where the notes land, at most three passes) (#385) | 1–2 | | Tables: row heights (at least/exact), rows split across pages, header rows repeat, Can't Split | ✅ | 2026-10-06, #138; every cell in a row gets the row's largest top and bottom margin; an exact height covers the top border, top margin and text, the bottom margin comes on top, and an exact row never splits; a row split across pages keeps its cells' bottom margin, border and last space after on the first page, widow/orphan control applies in its cells, each cell continues at the top of the next page and an at-least row keeps its minimum (`layout::tests::a_row_shares_its_largest_top_and_bottom_margins`, `…_a_split_row_keeps_its_bottom_margin_and_widow_control`, `…_split_row_cells_each_continue_at_the_top`) | — | | Tables: autofit to contents/window, fixed widths, Word 2013 edge | 🟡 | #137; #44: AutoFit Contents measures each column's narrowest and widest text and shares the width like Word, once (not live as you type); consistent a percentage width past 100% is kept, as Word does; cell widths override stale grid proportions when their totals agree (`cell_widths_override_grid`); conflicting widths and spans still differ | 3–5 | @@ -158,6 +159,7 @@ No overall parity estimate changes from these probes. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Row table border exceptions draw without leaking into other rows | | 2026-10-14 | trivial | Consistent cell widths override a stale table grid | | 2026-10-14 | trivial | Paragraphs inside complex-field instructions take no flow height; empty result paragraphs keep their line. | | 2026-10-14 | trivial | A list paragraph containing only a page break has no visible label; it still advances the list counter. Ordinary empty list items keep their label. | From ffacc4c08c76492d1f7b1e3a582f5d721aaa35d0 Mon Sep 17 00:00:00 2001 From: Hamza Naveed <45464935+Hamza0101@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:07:07 +0500 Subject: [PATCH 094/101] Fields: refresh unlocked DATE and TIME when a document is opened (#1679) A `DATE` or `TIME` field showed whatever was cached the last time the file was saved, so a letter written from a template went out carrying the date the template was saved. Word refreshes those two whenever it shows, prints or exports a document; WordCraft only did so on an explicit Update Field(s), on insert, or when printing with "Update fields before printing" on. Headless `convert` to PDF was the worst of it: nothing hints the date is old. Refresh them where a document is read instead, in a new `io::opened` beside `ensure_nonempty` -- the two things a freshly-read document needs -- so `text`, PDF, rendering and the window all show the same, current value. The model's stored result is the one source they share; patching each output path would have needed a clock in two places and still missed the next one. Narrow on purpose. Only `DATE` and `TIME`: `CREATEDATE`, `SAVEDATE` and `PRINTDATE` record when something happened and are not "now", and `REF`, `TOC` and citations stay on explicit update, as in Word. A field carrying `w:fldLock` keeps its stored result, which is what the lock is for. Headers, footers and text boxes are covered too, not only the body. The field code is untouched, so saving writes the field back as it was, with a current cached result -- as Word writes one. Co-authored-by: Brandon Thomas --- crates/engine/src/cmd/references.rs | 38 ++++++++ crates/engine/src/io.rs | 16 +++- crates/engine/tests/date_fields.rs | 131 ++++++++++++++++++++++++++++ 3 files changed, 181 insertions(+), 4 deletions(-) create mode 100644 crates/engine/tests/date_fields.rs diff --git a/crates/engine/src/cmd/references.rs b/crates/engine/src/cmd/references.rs index 5961cc4e4..7bf52ccee 100644 --- a/crates/engine/src/cmd/references.rs +++ b/crates/engine/src/cmd/references.rs @@ -904,6 +904,44 @@ pub fn update_fields(s: &mut Session) -> Result<(), CmdError> { update_toc(s) } +/// Refresh every unlocked `DATE` and `TIME` field to now, as Word does whenever it shows, +/// prints or exports a document: a letter written from a template carries today's date, not the +/// date the template was last saved (#1672). +/// +/// Only those two. `CREATEDATE`, `SAVEDATE` and `PRINTDATE` record when something happened and +/// are not "now"; `REF`, `PAGEREF`, `TOC` and citations are refreshed on an explicit update +/// (`fields.updateAll`) and not on open, again as Word does. A field carrying `w:fldLock` keeps +/// its stored result, which is what the lock is for. +pub fn refresh_date_fields(doc: &mut wordcraft_doc::Document) { + let stories: Vec = std::iter::once(StoryRef::Body).chain(doc.parts.keys().copied().map(StoryRef::Part)).collect(); + for story in stories { + for path in doc.para_paths(story) { + let Some(p) = doc.para(story, &path) else { continue }; + let mut updates: Vec<(usize, String)> = Vec::new(); + for (k, o) in p.objects.iter().enumerate() { + let InlineObject::Field { instr, locked: false, .. } = o else { continue }; + let name = wordcraft_layout::fields::field_name(instr); + if !matches!(name.as_str(), "DATE" | "TIME") { + continue; + } + // Word's default picture when the code carries no `\@` switch. + let pic = date_picture(instr).unwrap_or_else(|| if name == "TIME" { "h:mm am/pm".into() } else { "M/d/yyyy".into() }); + updates.push((k, super::insert::format_date(&pic))); + } + if updates.is_empty() { + continue; + } + let Ok(para) = doc.para_mut(story, &path) else { continue }; + for (k, r) in updates { + if let Some(InlineObject::Field { result, .. }) = para.objects.get_mut(k) { + *result = r; + } + } + para.touch(); + } + } +} + /// A field's `\@` date picture: the quoted text after it (switches may follow), or one word. pub fn date_picture(instr: &str) -> Option { let rest = instr.split_once("\\@")?.1.trim_start(); diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index b7997f915..725f633c5 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -116,6 +116,14 @@ pub fn can_encrypt(name: &str) -> bool { is_word_package(&ext_of(name)) } +/// Finish a document just read from a file: never leave it empty, and bring its unlocked +/// `DATE`/`TIME` fields up to now the way Word does on open (see +/// [`crate::cmd::references::refresh_date_fields`]). +fn opened(doc: &mut Document) { + doc.ensure_nonempty(); + crate::cmd::references::refresh_date_fields(doc); +} + /// Parse a document from bytes; `name` gives the format by extension. pub fn open_bytes(name: &str, bytes: &[u8]) -> Result { open_bytes_with(name, bytes, None).map(|(doc, _)| doc) @@ -174,7 +182,7 @@ fn read_bytes(name: &str, bytes: &[u8], password: Option<&str>, page: &wordcraft let ext = ext_of(name); if matches!(ext.as_str(), "docx" | "docm" | "dotx" | "dotm" | "doc" | "dot") && wordcraft_docx::is_encrypted(bytes) { let mut doc = wordcraft_docx::read_with_password(bytes, password).map_err(|e| e.to_string())?; - doc.ensure_nonempty(); + opened(&mut doc); return Ok((doc, true)); } if ext.is_empty() || signature_contradicts(&ext, bytes) { @@ -198,7 +206,7 @@ fn read_bytes(name: &str, bytes: &[u8], password: Option<&str>, page: &wordcraft None => return open_by_content(name, other, bytes, password, page), }, }; - doc.ensure_nonempty(); + opened(&mut doc); Ok((doc, false)) } @@ -289,7 +297,7 @@ fn open_by_content( } else { return Err(format!("{name}: unsupported format `.{ext}` (not a document or text file)")); }; - doc.ensure_nonempty(); + opened(&mut doc); Ok((doc, encrypted)) } @@ -351,7 +359,7 @@ pub fn open_path_in(path: &std::path::Path, password: Option<&str>, region: &wor let html = wordcraft_formats::html::decode(&bytes); let mut doc = wordcraft_formats::html::import_on(&html, &|src| images.load(src), ®ion_page(region)); fit_pictures(&mut doc); - doc.ensure_nonempty(); + opened(&mut doc); return Ok((doc, false)); } open_bytes_in(&name, &bytes, password, region) diff --git a/crates/engine/tests/date_fields.rs b/crates/engine/tests/date_fields.rs new file mode 100644 index 000000000..516a3c442 --- /dev/null +++ b/crates/engine/tests/date_fields.rs @@ -0,0 +1,131 @@ +//! Unlocked `DATE` and `TIME` fields are brought up to now when a document is opened, as Word +//! does whenever it shows, prints or exports one (#1672). Packages are hand-written: the writer +//! would never produce the stale cached result this is about. + +use std::io::Write; + +use wordcraft_doc::{InlineObject, StoryRef}; + +fn zip(entries: &[(&str, &str)]) -> Vec { + let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, body) in entries { + zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap(); + zw.write_all(body.as_bytes()).unwrap(); + } + zw.finish().unwrap().into_inner() +} + +/// A field run: its code, its cached result, and whether it carries `w:fldLock`. +fn field(instr: &str, cached: &str, locked: bool) -> String { + let lock = if locked { r#" w:fldLock="1""# } else { "" }; + format!( + concat!( + r#""#, + r#" {} "#, + r#""#, + r#"{}"#, + r#""#, + ), + lock, + instr.replace('"', """), + cached + ) +} + +fn open_body(body: &str) -> wordcraft_doc::Document { + let rels = r#""#; + let doc = format!( + r#"{body}"# + ); + wordcraft_engine::io::open_bytes("d.docx", &zip(&[("_rels/.rels", rels), ("word/document.xml", &doc)])).expect("opens") +} + +/// Every field of the body, as (code, result). +fn fields(d: &wordcraft_doc::Document) -> Vec<(String, String)> { + let mut out = Vec::new(); + for path in d.para_paths(StoryRef::Body) { + let Some(p) = d.para(StoryRef::Body, &path) else { continue }; + for o in &p.objects { + if let InlineObject::Field { instr, result, .. } = o { + out.push((instr.clone(), result.clone())); + } + } + } + out +} + +#[test] +fn unlocked_date_and_time_fields_are_refreshed_on_open() { + // The reported fixture: both cached in January 2020. + let d = open_body(&format!( + "{}{}", + field(r#"DATE \@ "yyyy-MM-dd""#, "2020-01-01", false), + field(r#"TIME \@ "dddd, d MMMM yyyy""#, "Wednesday, 1 January 2020", false), + )); + let got = fields(&d); + assert_eq!(got.len(), 2); + // Compared against the same clock the refresh used, so the test does not age. + assert_eq!(got[0].1, wordcraft_engine::cmd::insert::format_date("yyyy-MM-dd"), "DATE is today"); + assert_eq!(got[1].1, wordcraft_engine::cmd::insert::format_date("dddd, d MMMM yyyy"), "TIME is today"); + assert_ne!(got[0].1, "2020-01-01", "not the stored result"); + // The code itself is untouched, so a save writes the field back as it was. + assert_eq!(got[0].0, r#"DATE \@ "yyyy-MM-dd""#); +} + +#[test] +fn a_date_field_with_no_picture_uses_words_default() { + let d = open_body(&format!("{}{}", field("DATE", "2020-01-01", false), field("TIME", "00:00", false))); + let got = fields(&d); + assert_eq!(got[0].1, wordcraft_engine::cmd::insert::format_date("M/d/yyyy")); + assert_eq!(got[1].1, wordcraft_engine::cmd::insert::format_date("h:mm am/pm")); +} + +#[test] +fn a_locked_date_field_keeps_its_stored_result() { + // `w:fldLock` is exactly the instruction not to refresh. + let d = open_body(&format!("{}", field(r#"DATE \@ "yyyy-MM-dd""#, "2020-01-01", true))); + assert_eq!(fields(&d)[0].1, "2020-01-01"); +} + +#[test] +fn other_date_fields_keep_their_stored_result() { + // These record when something happened; they are not "now", and Word does not refresh them + // on open either. Nor is any other field touched. + let d = open_body(&format!( + "{}{}{}{}", + field(r#"CREATEDATE \@ "yyyy-MM-dd""#, "2019-05-05", false), + field(r#"SAVEDATE \@ "yyyy-MM-dd""#, "2019-06-06", false), + field(r#"PRINTDATE \@ "yyyy-MM-dd""#, "2019-07-07", false), + field("AUTHOR", "Ada Lovelace", false), + )); + let results: Vec = fields(&d).into_iter().map(|(_, r)| r).collect(); + assert_eq!(results, ["2019-05-05", "2019-06-06", "2019-07-07", "Ada Lovelace"]); +} + +#[test] +fn a_date_field_in_a_header_is_refreshed_too() { + // A letter template usually carries its date in the header, which is its own story. + let rels = r#""#; + let drels = r#""#; + let header = format!( + r#"{}"#, + field(r#"DATE \@ "yyyy-MM-dd""#, "2020-01-01", false) + ); + let doc = r#"Body"#; + let d = wordcraft_engine::io::open_bytes( + "d.docx", + &zip(&[("_rels/.rels", rels), ("word/document.xml", doc), ("word/_rels/document.xml.rels", drels), ("word/header1.xml", &header)]), + ) + .expect("opens"); + let today = wordcraft_engine::cmd::insert::format_date("yyyy-MM-dd"); + let found: Vec = d + .parts + .values() + .flat_map(|p| p.blocks.iter().filter_map(|b| b.as_para()).flat_map(|p| p.objects.clone())) + .filter_map(|o| match o { + InlineObject::Field { result, .. } => Some(result), + _ => None, + }) + .collect(); + assert_eq!(found, [today], "the header's field is refreshed as well as the body's"); +} From 5b49d692a54b5e2d496262d9c62dbb068f2a2b26 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Thu, 15 Oct 2026 07:13:56 +0200 Subject: [PATCH 095/101] Table style editor formats every table style part (#714) (#887) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * #714: table style editor formats every table style part Modify/New Table Style: "Apply formatting to" any of the 13 ECMA-376 §17.7.6 parts (whole table, header/total row, first/last column, odd/even row and column bands, four corner cells), each with font, size, bold, italic, underline, colour, fill, borders (line style, width, colour; all/none/outside/inside/each edge) and the 9-way cell alignment, over a live preview. - doc: TablePart, PartFormat, PartSet; TableStyleParts::part/set_part (new parts and per-part jc/vAlign in `regions`) - layout: generic part resolution incl. band2, corner cells, alignment - docx: every tblStylePr type read/written with pPr jc and tcPr vAlign - engine: table.newStyle/modifyStyle take ECMA part names, `part`, `parts`, per-edge `borders`, `underline`, `align`/`vAlign`/`cellAlign` - ui: dialog moved to dialogs_table_style.rs; ui.dialog takes `part` Co-Authored-By: Claude Opus 5.5 * i18n: Hungarian and French strings for this change Co-Authored-By: Claude Opus 5.5 * Review fixes for #887: Bulgarian strings for the table style parts Co-Authored-By: Claude Opus 5.5 * #714: Vietnamese strings for the table style parts (main's new vi catalog) Co-Authored-By: Claude Opus 5.5 (1M context) * hr: match the catalog's word for banded 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 --- ROADMAP.md | 4 +- crates/doc/src/styles.rs | 286 ++++++++- crates/docx/src/read/mod.rs | 77 +-- crates/docx/src/write/mod.rs | 73 +-- crates/docx/tests/roundtrip.rs | 43 ++ crates/engine/src/cmd/table_style.rs | 276 +++++++-- crates/engine/src/tests.rs | 103 ++++ crates/layout/src/table.rs | 205 +++---- crates/ui-egui/src/dialogs.rs | 301 +-------- crates/ui-egui/src/dialogs_table_style.rs | 706 ++++++++++++++++++++++ crates/ui-egui/src/i18n/bg.tsv | 11 + crates/ui-egui/src/i18n/cs.tsv | 10 + crates/ui-egui/src/i18n/de.tsv | 10 + crates/ui-egui/src/i18n/es.tsv | 10 + crates/ui-egui/src/i18n/et.tsv | 10 + crates/ui-egui/src/i18n/fr.tsv | 10 + crates/ui-egui/src/i18n/hr.tsv | 11 + crates/ui-egui/src/i18n/hu.tsv | 10 + crates/ui-egui/src/i18n/it.tsv | 10 + crates/ui-egui/src/i18n/ja.tsv | 10 + crates/ui-egui/src/i18n/ko.tsv | 11 + crates/ui-egui/src/i18n/pl.tsv | 10 + crates/ui-egui/src/i18n/pt-br.tsv | 10 + crates/ui-egui/src/i18n/ru.tsv | 10 + crates/ui-egui/src/i18n/sr-latn.tsv | 10 + crates/ui-egui/src/i18n/sr.tsv | 10 + crates/ui-egui/src/i18n/sv.tsv | 11 + crates/ui-egui/src/i18n/tr.tsv | 10 + crates/ui-egui/src/i18n/uk.tsv | 10 + crates/ui-egui/src/i18n/vi.tsv | 11 + crates/ui-egui/src/i18n/zh-hans.tsv | 10 + crates/ui-egui/src/i18n/zh-hant.tsv | 10 + crates/ui-egui/src/lib.rs | 7 + docs/file-format-parity.md | 5 +- docs/ui-parity.md | 10 +- 35 files changed, 1777 insertions(+), 544 deletions(-) create mode 100644 crates/ui-egui/src/dialogs_table_style.rs diff --git a/ROADMAP.md b/ROADMAP.md index 452286a27..0eb09ef86 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-14 · **Change:** trivial (VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; 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-14 · **Change:** trivial (table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; 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-14 · **Change:** trivial (progress log: print-area strings translated in every catalog; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; 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 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -243,6 +243,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-12 | 32-bit Windows starts on OpenGL. DirectX 12 graphics pipeline creation returns "The parameter is incorrect" and the app exited before the window appeared | | 2026-10-12 | New documents follow the system region (#838): A4 and centimetres outside the US (Letter in the CLDR Letter territories, inches in the US), lengths written with the region's decimal separator (`2,54 cm`) and typed with either; an explicit Measurement units choice wins; `file.options {"region"}` for agents | | 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-12 | Table style editor (#714): New/Modify Table Style edits each of the 13 table style parts (header row, total row, first/last column, odd/even bands, corner cells) with font, colour, fill, borders and cell alignment; every table using the style follows; all parts round-trip in DOCX; `table.modifyStyle` takes `part`/`parts` and per-edge borders | | 2026-10-12 | STYLEREF fields (#775): running headers and footers show each page's chapter title (page's first, or `\l` last, paragraph of the style, else the nearest before), style names, ids, heading levels and character styles, number switches; body STYLEREF updates with the other fields; STYLEREF in the Field dialog; PDF export follows the layout | | 2026-10-12 | Undo history list (#701): a ▾ beside the Quick Access Toolbar's Undo lists recent steps newest first; hovering highlights what a click undoes ("Undo 3 Actions"), and a click undoes them in one go. `edit.undoTo {count}` and `edit.undoHistory` (newest first, up to 100) for CLI and MCP | | 2026-10-12 | Start screen (#766): launched with nothing to open, WordCraft starts on File › Home (Blank document, templates, Recent) like Word, desktop and web; Esc leaves a blank document; File › Options › General › Start-up turns it off | @@ -480,6 +481,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-12 | trivial | Progress log: `--help` for the app and every CLI subcommand (#968) | | 2026-10-12 | trivial | Progress log: Down and Up no longer skip a table (#1007) | | 2026-10-13 | trivial | Progress log: equation pictures keep the theme colours on Windows (#1002) | +| 2026-10-12 | trivial | Progress log: table style editor (#714) | | 2026-10-11 | trivial | Progress log: Ctrl, Alt, left-edge and selected-cells table border drags (#711) | | 2026-10-11 | trivial | Progress log: table markers on the rulers (#700) | | 2026-10-11 | trivial | Progress log: the table context menu | diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index 33627bccd..59416ef9f 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -3,7 +3,12 @@ use serde::{Deserialize, Serialize}; -use crate::props::{Align, Border, BorderStyle, Borders, CharProps, LineSpacing, ParaProps, Rgb, TabAlign, TabLeader, TabStop, TextColor, Underline}; +use std::collections::BTreeMap; + +use crate::props::{ + Align, Border, BorderStyle, Borders, CharProps, LineSpacing, ParaProps, Rgb, TabAlign, TabLeader, TabStop, TableLook, TextColor, Underline, + VAlign, +}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] @@ -88,6 +93,166 @@ pub struct TableStyleParts { pub col_band_fill: Option, pub col_band_chr: CharProps, pub col_band_borders: Option, + /// Per-part formatting the named fields above don't carry: every field of the parts without + /// one (even bands, corner cells), and the cell text alignment of every part. Read and write + /// parts through [`TableStyleParts::part`] / [`TableStyleParts::set_part`]. + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub regions: BTreeMap, +} + +/// A conditional formatting part of a table style (`w:tblStylePr/@w:type`, ECMA-376 §17.7.6). +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TablePart { + WholeTable, + FirstRow, + LastRow, + FirstCol, + LastCol, + Band1Vert, + Band2Vert, + Band1Horz, + Band2Horz, + NeCell, + NwCell, + SeCell, + SwCell, +} + +impl TablePart { + /// Every part, lowest precedence first (ECMA-376 §17.7.6: whole table, bands, first/last + /// column, first/last row, corner cells). + pub const ALL: [TablePart; 13] = [ + TablePart::WholeTable, + TablePart::Band1Vert, + TablePart::Band2Vert, + TablePart::Band1Horz, + TablePart::Band2Horz, + TablePart::FirstCol, + TablePart::LastCol, + TablePart::FirstRow, + TablePart::LastRow, + TablePart::NeCell, + TablePart::NwCell, + TablePart::SeCell, + TablePart::SwCell, + ]; + /// The `w:type` value. + pub fn ooxml(self) -> &'static str { + match self { + TablePart::WholeTable => "wholeTable", + TablePart::FirstRow => "firstRow", + TablePart::LastRow => "lastRow", + TablePart::FirstCol => "firstCol", + TablePart::LastCol => "lastCol", + TablePart::Band1Vert => "band1Vert", + TablePart::Band2Vert => "band2Vert", + TablePart::Band1Horz => "band1Horz", + TablePart::Band2Horz => "band2Horz", + TablePart::NeCell => "neCell", + TablePart::NwCell => "nwCell", + TablePart::SeCell => "seCell", + TablePart::SwCell => "swCell", + } + } + pub fn from_ooxml(s: &str) -> Option { + TablePart::ALL.into_iter().find(|p| p.ooxml() == s) + } + /// A part spanning whole rows (its left/right edges are the table's sides, `inside_v` the + /// edges between its cells). + pub fn is_row(self) -> bool { + matches!(self, TablePart::FirstRow | TablePart::LastRow | TablePart::Band1Horz | TablePart::Band2Horz) + } + /// A part spanning whole columns (its top/bottom edges are the table's, `between` the edges + /// between its cells). + pub fn is_column(self) -> bool { + matches!(self, TablePart::FirstCol | TablePart::LastCol | TablePart::Band1Vert | TablePart::Band2Vert) + } + fn bit(self) -> u16 { + 1 << (TablePart::ALL.iter().position(|p| *p == self).unwrap_or(0) as u16) + } +} + +/// The table style parts one cell is in (always the whole table). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct PartSet(u16); + +impl PartSet { + /// The parts of the cell at row `ri` of `nrows`, grid column `g` spanning `span` of `ncols`, + /// in a table with options `look` and row bands of `band_size` rows. + pub fn of_cell(look: &TableLook, band_size: u32, (ri, nrows): (usize, usize), (g, span, ncols): (usize, usize, usize)) -> PartSet { + let header = look.header_row && ri == 0; + let total = look.total_row && ri + 1 == nrows && nrows > 1; + let first_col = look.first_column && g == 0; + let last_col = look.last_column && g.saturating_add(span) >= ncols && ncols > 1; + let size = band_size.clamp(1, 1000) as usize; + let row_band = look.banded_rows && !header; + let odd_row = (ri.saturating_sub(usize::from(look.header_row)) / size).is_multiple_of(2); + let col_band = look.banded_columns && !first_col; + let odd_col = g.saturating_sub(usize::from(look.first_column)).is_multiple_of(2); + let mut bits = TablePart::WholeTable.bit(); + for (on, p) in [ + (col_band && odd_col, TablePart::Band1Vert), + (col_band && !odd_col, TablePart::Band2Vert), + (row_band && odd_row, TablePart::Band1Horz), + (row_band && !odd_row, TablePart::Band2Horz), + (first_col, TablePart::FirstCol), + (last_col, TablePart::LastCol), + (header, TablePart::FirstRow), + (total, TablePart::LastRow), + (header && last_col, TablePart::NeCell), + (header && first_col, TablePart::NwCell), + (total && last_col, TablePart::SeCell), + (total && first_col, TablePart::SwCell), + ] { + if on { + bits |= p.bit(); + } + } + PartSet(bits) + } + pub fn contains(self, p: TablePart) -> bool { + self.0 & p.bit() != 0 + } + /// The parts, lowest precedence first. + pub fn iter(self) -> impl Iterator { + TablePart::ALL.into_iter().filter(move |p| self.contains(*p)) + } +} + +/// What one table style part gives its cells. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] +#[serde(default, rename_all = "camelCase")] +pub struct PartFormat { + pub chr: CharProps, + /// Cell shading. + pub fill: Option, + /// Cell borders (the whole table's are the table borders). + pub borders: Option, + /// Horizontal alignment of the cells' paragraphs (`w:pPr/w:jc`). + pub align: Option, + /// Vertical alignment in the cells (`w:tcPr/w:vAlign`). + pub v_align: Option, +} + +impl PartFormat { + pub fn is_empty(&self) -> bool { + *self == PartFormat::default() + } + /// What `patch` sets wins; borders merge edge by edge. + pub fn overlay(&mut self, patch: &PartFormat) { + self.chr.overlay(&patch.chr); + self.fill = patch.fill.or(self.fill); + self.borders = match (self.borders, patch.borders) { + (Some(mut b), Some(p)) => { + b.overlay(&p); + Some(b) + } + (b, p) => p.or(b), + }; + self.align = patch.align.or(self.align); + self.v_align = patch.v_align.or(self.v_align); + } } impl TableStyleParts { @@ -124,6 +289,125 @@ impl TableStyleParts { self.col_band_fill = patch.col_band_fill.or(self.col_band_fill); self.col_band_chr.overlay(&patch.col_band_chr); self.col_band_borders = merge(self.col_band_borders, patch.col_band_borders); + for (k, f) in &patch.regions { + self.regions.entry(*k).or_default().overlay(f); + } + } + + /// Part `p`'s formatting. The whole table's text formatting is the style's own + /// ([`Style::table_part`] adds it); its borders are the table borders. + pub fn part(&self, p: TablePart) -> PartFormat { + let extra = self.regions.get(&p).cloned().unwrap_or_default(); + let named = |chr: &CharProps, fill: Option, borders: Option| PartFormat { + chr: chr.clone(), + fill, + borders, + align: extra.align, + v_align: extra.v_align, + }; + match p { + TablePart::WholeTable => { + PartFormat { chr: CharProps::default(), fill: self.fill, borders: self.borders, align: None, v_align: extra.v_align } + } + TablePart::FirstRow => named(&self.header_chr, self.header_fill, self.header_borders), + TablePart::LastRow => { + // The built-in top rule is the region's top edge unless its borders set one. + let borders = match (self.total_borders, self.total_border_top) { + (Some(mut b), top) => { + b.top = b.top.or(top); + Some(b) + } + (None, top) => top.map(|t| Borders { top: Some(t), ..Borders::default() }), + }; + named(&self.total_chr, self.total_fill, borders) + } + TablePart::FirstCol => named(&self.first_col_chr, self.first_col_fill, self.first_col_borders), + TablePart::LastCol => named(&self.last_col_chr, self.last_col_fill, self.last_col_borders), + TablePart::Band1Vert => named(&self.col_band_chr, self.col_band_fill, self.col_band_borders), + TablePart::Band1Horz => named(&self.band_chr, self.band_fill, self.band_borders), + _ => extra, + } + } + + /// Replace part `p`'s formatting (for the whole table only its fill, borders and vertical + /// alignment: [`Style::set_table_part`] stores the rest). + pub fn set_part(&mut self, p: TablePart, f: PartFormat) { + let align_only = PartFormat { align: f.align, v_align: f.v_align, ..PartFormat::default() }; + let PartFormat { chr, fill, borders, .. } = f.clone(); + let rest = match p { + TablePart::WholeTable => { + self.fill = fill; + self.borders = borders; + PartFormat { v_align: f.v_align, ..PartFormat::default() } + } + TablePart::FirstRow => { + (self.header_chr, self.header_fill, self.header_borders) = (chr, fill, borders); + align_only + } + TablePart::LastRow => { + (self.total_chr, self.total_fill) = (chr, fill); + self.total_border_top = borders.and_then(|b| b.top); + self.total_borders = borders.map(|b| Borders { top: None, ..b }).filter(|b| *b != Borders::default()); + align_only + } + TablePart::FirstCol => { + (self.first_col_chr, self.first_col_fill, self.first_col_borders) = (chr, fill, borders); + align_only + } + TablePart::LastCol => { + (self.last_col_chr, self.last_col_fill, self.last_col_borders) = (chr, fill, borders); + align_only + } + TablePart::Band1Vert => { + (self.col_band_chr, self.col_band_fill, self.col_band_borders) = (chr, fill, borders); + align_only + } + TablePart::Band1Horz => { + (self.band_chr, self.band_fill, self.band_borders) = (chr, fill, borders); + align_only + } + _ => f, + }; + if rest.is_empty() { + self.regions.remove(&p); + } else { + self.regions.insert(p, rest); + } + } +} + +impl Style { + /// Table style part `p` as this style alone sets it (the whole table's text formatting and + /// paragraph alignment are the style's own). + pub fn table_part(&self, p: TablePart) -> PartFormat { + let mut f = self.table.as_ref().map(|t| t.part(p)).unwrap_or_default(); + if p == TablePart::WholeTable { + f.chr = self.chr.clone(); + f.align = self.para.align; + } + f + } + /// Replace part `p` of this table style. + pub fn set_table_part(&mut self, p: TablePart, f: PartFormat) { + if p == TablePart::WholeTable { + self.chr = f.chr.clone(); + self.para.align = f.align; + } + let mut t = self.table.take().unwrap_or_default(); + t.set_part(p, f); + self.table = (t != TableStyleParts::default()).then_some(t); + } +} + +impl TableStyleProps { + /// Part `p` with the based-on chain merged (see [`Style::table_part`]). + pub fn part(&self, p: TablePart) -> PartFormat { + let mut f = self.parts.part(p); + if p == TablePart::WholeTable { + f.chr = self.chr.clone(); + f.align = self.para.align; + } + f } } diff --git a/crates/docx/src/read/mod.rs b/crates/docx/src/read/mod.rs index 8944c6bc8..523b50c08 100644 --- a/crates/docx/src/read/mod.rs +++ b/crates/docx/src/read/mod.rs @@ -20,8 +20,9 @@ use wordcraft_doc::numbering::{AbstractNum, Level, LevelSuffix, Num}; use wordcraft_doc::para::NoteKind; use wordcraft_doc::para::OBJ; use wordcraft_doc::props::Rgb; +use wordcraft_doc::props::VAlign; use wordcraft_doc::section::NumFormat; -use wordcraft_doc::styles::{Style, StyleKind, StyleSheet, TableStyleParts}; +use wordcraft_doc::styles::{PartFormat, Style, StyleKind, StyleSheet, TablePart, TableStyleParts}; use wordcraft_doc::{ Block, Blocks, Comment, Document, InlineObject, PagePicture, Paragraph, PartKind, Revision, RevisionKind, Zoom, ZoomFit, para_block, }; @@ -901,56 +902,38 @@ impl Reader<'_> { t.band_size = tp.child_val("w:tblStyleRowBandSize").and_then(u32_of).filter(|n| *n > 0).map(|n| n.min(1000)); } for c in s.children("w:tblStylePr").take(64) { - let fill = c.child("w:tcPr").and_then(|p| p.child("w:shd")).and_then(|e| self.pc.shd_fill(e)); - let cell_borders = c.child("w:tcPr").and_then(|p| p.child("w:tcBorders")).map(props::borders); - let chr = c.child("w:rPr").map(|r| self.pc.rpr(r)).unwrap_or_default(); - match c.attr("w:type") { - Some("wholeTable") => { - if let Some(p) = c.child("w:pPr") { - st.para.overlay(&self.pc.ppr(p).0); - } - st.chr.overlay(&chr); - if fill.is_some() { - t.fill = fill; - } - if let Some(b) = c.child("w:tblPr").and_then(|p| p.child("w:tblBorders")) { - t.borders = Some(props::borders(b)); - } + let Some(part) = c.attr("w:type").and_then(TablePart::from_ooxml) else { continue }; + let tc = c.child("w:tcPr"); + let ppr = c.child("w:pPr").map(|p| self.pc.ppr(p).0); + let f = PartFormat { + chr: c.child("w:rPr").map(|r| self.pc.rpr(r)).unwrap_or_default(), + fill: tc.and_then(|p| p.child("w:shd")).and_then(|e| self.pc.shd_fill(e)), + borders: tc.and_then(|p| p.child("w:tcBorders")).map(props::borders), + align: ppr.as_ref().and_then(|p| p.align), + v_align: tc.and_then(|p| p.child_val("w:vAlign")).map(|v| match v { + "center" => VAlign::Center, + "bottom" => VAlign::Bottom, + _ => VAlign::Top, + }), + }; + if part == TablePart::WholeTable { + // The whole table's paragraph and run properties are the style's own; its + // borders the table borders. + if let Some(p) = ppr { + st.para.overlay(&p); } - Some("firstRow") => { - t.header_fill = fill; - t.header_chr = chr; - t.header_borders = cell_borders; + st.chr.overlay(&f.chr); + if f.fill.is_some() { + t.fill = f.fill; } - Some("band1Horz") => { - t.band_fill = fill; - t.band_chr = chr; - t.band_borders = cell_borders; + if let Some(b) = c.child("w:tblPr").and_then(|p| p.child("w:tblBorders")) { + t.borders = Some(props::borders(b)); } - Some("firstCol") => { - t.first_col_chr = chr; - t.first_col_fill = fill; - t.first_col_borders = cell_borders; + if f.v_align.is_some() { + t.regions.insert(part, PartFormat { v_align: f.v_align, ..PartFormat::default() }); } - Some("lastCol") => { - t.last_col_chr = chr; - t.last_col_fill = fill; - t.last_col_borders = cell_borders; - } - Some("lastRow") => { - t.total_chr = chr; - t.total_fill = fill; - // The top rule is kept on its own (as built-in styles have it); the other edges - // are the region's borders. - t.total_border_top = cell_borders.and_then(|b| b.top); - t.total_borders = cell_borders.map(|b| wordcraft_doc::props::Borders { top: None, ..b }).filter(|b| *b != Default::default()); - } - Some("band1Vert") => { - t.col_band_fill = fill; - t.col_band_chr = chr; - t.col_band_borders = cell_borders; - } - _ => {} + } else { + t.set_part(part, f); } } t diff --git a/crates/docx/src/write/mod.rs b/crates/docx/src/write/mod.rs index 3cdd865c1..f5b09b96f 100644 --- a/crates/docx/src/write/mod.rs +++ b/crates/docx/src/write/mod.rs @@ -12,8 +12,10 @@ mod wordart; use std::collections::{BTreeMap, HashMap}; +use wordcraft_doc::ParaProps; use wordcraft_doc::numbering::{Level, LevelSuffix}; -use wordcraft_doc::styles::{Style, StyleKind}; +use wordcraft_doc::props::VAlign; +use wordcraft_doc::styles::{Style, StyleKind, TablePart}; use wordcraft_doc::{Blocks, Document, PartKind}; use crate::package::{MAX_VBA_RELATED, VBA_PROJECT_PART, VBA_RELATED, rt, zip_entries}; @@ -797,49 +799,52 @@ fn style_xml(w: &mut W, st: &Style, flagged: &[StyleKind]) { } w.close("w:tblPr"); } - // One conditional formatting region (ECMA-376 §17.7.6): run properties, then cell borders - // and shading. - let cond = |w: &mut W, - ty: &str, - chr: &wordcraft_doc::CharProps, - fill: Option, - borders: Option| { - if !props::has_rpr(chr) && fill.is_none() && borders.is_none() { - return; + // The conditional formatting parts (ECMA-376 §17.7.6): paragraph and run properties, + // then cell borders, shading and vertical alignment. The whole table's text formatting + // is the style's own (above). + for part in TablePart::ALL { + let f = t.part(part); + let ppr = ParaProps { align: f.align, ..ParaProps::default() }; + let has_rpr = props::has_rpr(&f.chr); + // The whole table's borders are its table borders (`w:tblPr` above). + let borders = f.borders.filter(|_| part != TablePart::WholeTable); + let has_tc = f.fill.is_some() || borders.is_some() || f.v_align.is_some(); + if !has_rpr && !has_tc && f.align.is_none() { + continue; } - w.open("w:tblStylePr", &[("w:type", ty)]); - if props::has_rpr(chr) { + w.open("w:tblStylePr", &[("w:type", part.ooxml())]); + if f.align.is_some() { + w.open("w:pPr", &[]); + props::ppr_inner(w, &ppr, false, None); + w.close("w:pPr"); + } + if has_rpr { w.open("w:rPr", &[]); - props::rpr_inner(w, chr); + props::rpr_inner(w, &f.chr); w.close("w:rPr"); } - if fill.is_some() || borders.is_some() { + if has_tc { w.open("w:tcPr", &[]); - if let Some(bs) = &borders { - props::borders(w, "w:tcBorders", bs, Some("w:insideH"), &[]); + if let Some(bs) = borders { + props::borders(w, "w:tcBorders", &bs, Some("w:insideH"), &[]); } - if let Some(f) = fill { - w.empty("w:shd", &[("w:val", "clear"), ("w:color", "auto"), ("w:fill", &f.hex())]); + if let Some(c) = f.fill { + w.empty("w:shd", &[("w:val", "clear"), ("w:color", "auto"), ("w:fill", &c.hex())]); + } + if let Some(v) = f.v_align { + w.val( + "w:vAlign", + match v { + VAlign::Top => "top", + VAlign::Center => "center", + VAlign::Bottom => "bottom", + }, + ); } w.close("w:tcPr"); } w.close("w:tblStylePr"); - }; - cond(w, "wholeTable", &wordcraft_doc::CharProps::default(), t.fill, None); - cond(w, "firstRow", &t.header_chr, t.header_fill, t.header_borders); - // The total row's borders, its top edge falling back to the built-in top rule. - let total_borders = match (t.total_borders, t.total_border_top) { - (Some(mut b), top) => { - b.top = b.top.or(top); - Some(b) - } - (None, top) => top.map(|b| wordcraft_doc::props::Borders { top: Some(b), ..Default::default() }), - }; - cond(w, "lastRow", &t.total_chr, t.total_fill, total_borders); - cond(w, "firstCol", &t.first_col_chr, t.first_col_fill, t.first_col_borders); - cond(w, "lastCol", &t.last_col_chr, t.last_col_fill, t.last_col_borders); - cond(w, "band1Vert", &t.col_band_chr, t.col_band_fill, t.col_band_borders); - cond(w, "band1Horz", &t.band_chr, t.band_fill, t.band_borders); + } } w.close("w:style"); } diff --git a/crates/docx/tests/roundtrip.rs b/crates/docx/tests/roundtrip.rs index 1c128f280..e95133549 100644 --- a/crates/docx/tests/roundtrip.rs +++ b/crates/docx/tests/roundtrip.rs @@ -554,6 +554,49 @@ fn table_style_formatting_round_trips() { assert_eq!(got.table.as_ref(), Some(&parts)); } +/// #714: every table style part (ECMA-376 §17.7.6 `w:tblStylePr` types) keeps its run +/// formatting, cell shading, cell borders and horizontal and vertical alignment through a save. +#[test] +fn every_table_style_part_round_trips() { + use wordcraft_doc::styles::{PartFormat, TablePart}; + let mut d = doc_with(vec![Paragraph::with_text("x", CharProps::default())]); + let mut st = Style { id: "Parts".into(), name: "Parts".into(), kind: StyleKind::Table, based_on: Some("TableGrid".into()), ..Default::default() }; + let aligns = [Align::Left, Align::Center, Align::Right]; + let valigns = [VAlign::Top, VAlign::Center, VAlign::Bottom]; + for (i, part) in TablePart::ALL.into_iter().enumerate() { + let n = i as u8; + let f = PartFormat { + chr: CharProps { + bold: Some(i % 2 == 0), + italic: Some(i % 3 == 0), + underline: (i % 4 == 0).then_some(Underline::Single), + size: Some(8.0 + i as f32), + font: Some(format!("Face {i}")), + color: Some(TextColor::Rgb(Rgb(n, 0x20, 0x40))), + ..Default::default() + }, + fill: Some(Rgb(0x10 + n, 0x80, 0xF0 - n)), + borders: Some(Borders { + top: Some(Border { style: BorderStyle::Double, width: 1.5, color: Some(Rgb(n, 0, 0)), space: 0.0 }), + bottom: Some(Border::single(0.5 + i as f32 / 4.0)), + left: Some(Border { style: BorderStyle::None, width: 0.0, color: None, space: 0.0 }), + inside_v: (i % 2 == 1).then(|| Border::single(0.25)), + ..Default::default() + }), + align: aligns.get(i % 3).copied(), + v_align: valigns.get(i % 3).copied(), + }; + st.set_table_part(part, f); + } + d.styles.upsert(st.clone()); + let r = rt(&d); + let got = r.styles.get("Parts").unwrap(); + for part in TablePart::ALL { + assert_eq!(got.table_part(part), st.table_part(part), "{}", part.ooxml()); + } + assert_eq!(got.table, st.table); +} + #[test] fn notes_round_trip() { let mut d = Document::new(); diff --git a/crates/engine/src/cmd/table_style.rs b/crates/engine/src/cmd/table_style.rs index 0b2b58c0e..f1f20bd17 100644 --- a/crates/engine/src/cmd/table_style.rs +++ b/crates/engine/src/cmd/table_style.rs @@ -5,25 +5,54 @@ use std::sync::Arc; use serde_json::{Value, json}; -use wordcraft_doc::props::{Border, BorderStyle, Borders, CharProps, ParaProps, Rgb, TextColor}; -use wordcraft_doc::styles::{Style, StyleKind, TableStyleParts, TableStyleProps}; +use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, ParaProps, Rgb, TextColor, Underline, VAlign}; +use wordcraft_doc::styles::{PartFormat, Style, StyleKind, TablePart, TableStyleParts, TableStyleProps}; use wordcraft_doc::{Block, Table}; use crate::{CmdError, CmdResult, CommandSpec, Session, p}; -/// The regions a table style formats, by param name: the table style options they follow. -pub const REGIONS: [&str; 7] = ["wholeTable", "headerRow", "lastRow", "firstColumn", "lastColumn", "bandedRows", "bandedColumns"]; +/// The parts a table style formats, by param name: the names the table style options follow +/// (`headerRow`, `bandedRows`…) and the ECMA-376 §17.7.6 part names (`firstRow`, `band2Horz`…). +pub const PARTS: [(&str, TablePart); 23] = [ + ("wholeTable", TablePart::WholeTable), + ("headerRow", TablePart::FirstRow), + ("firstRow", TablePart::FirstRow), + ("lastRow", TablePart::LastRow), + ("totalRow", TablePart::LastRow), + ("firstColumn", TablePart::FirstCol), + ("firstCol", TablePart::FirstCol), + ("lastColumn", TablePart::LastCol), + ("lastCol", TablePart::LastCol), + ("bandedRows", TablePart::Band1Horz), + ("band1Horz", TablePart::Band1Horz), + ("band2Horz", TablePart::Band2Horz), + ("bandedColumns", TablePart::Band1Vert), + ("band1Vert", TablePart::Band1Vert), + ("band2Vert", TablePart::Band2Vert), + ("neCell", TablePart::NeCell), + ("nwCell", TablePart::NwCell), + ("seCell", TablePart::SeCell), + ("swCell", TablePart::SwCell), + ("topRightCell", TablePart::NeCell), + ("topLeftCell", TablePart::NwCell), + ("bottomRightCell", TablePart::SeCell), + ("bottomLeftCell", TablePart::SwCell), +]; -/// The formatting params both commands share; each region (`wholeTable`, `headerRow`, `lastRow` -/// (the total row), `firstColumn`, `lastColumn`, `bandedRows`, `bandedColumns`) takes the same -/// shortcuts. +/// The part a param name means. +pub fn part_named(name: &str) -> Option { + PARTS.iter().find(|(n, _)| n.eq_ignore_ascii_case(name)).map(|(_, p)| *p) +} + +/// The formatting params both commands share; each part takes the same Part object. macro_rules! style_params { ($head:literal) => { concat!( "{", $head, - r#", "basedOn"?: string, "wholeTable"?: Region, "headerRow"?: Region, "lastRow"?: Region (total row), "firstColumn"?: Region, "lastColumn"?: Region, "bandedRows"?: Region, "bandedColumns"?: Region, "bandSize"?: n (rows per band), "para"?: ParaProps, "apply"?: bool}"#, - r#" where Region = {"borders"?: bool | null (true: single lines, false: none, null: inherit), "borderColor"?: "RRGGBB", "borderWidth"?: pt, "fill"?: "RRGGBB" | null, "bold"?: bool, "italic"?: bool, "color"?: "RRGGBB" | null, "size"?: pt, "font"?: string}"# + r#", "basedOn"?: string, ""?: Part for each part name (wholeTable, headerRow|firstRow, lastRow|totalRow, firstColumn|firstCol, lastColumn|lastCol, bandedRows|band1Horz, band2Horz, bandedColumns|band1Vert, band2Vert, nwCell, neCell, swCell, seCell), "part"?: part name (the Part fields then sit at the top level), "parts"?: [Part & {"part": name}], "bandSize"?: n (rows per band), "para"?: ParaProps, "apply"?: bool}"#, + r#" where Part = {"fill"?: "RRGGBB" | null, "font"?: string, "size"?: pt, "bold"?: bool, "italic"?: bool, "underline"?: bool | "single" | "double" | "thick" | "words" | "dotted" | "dash" | "wave" | "none", "color"?: "RRGGBB" | null, "align"?: "left" | "center" | "right" | "justify" | null, "vAlign"?: "top" | "center" | "bottom" | null, "cellAlign"?: "topLeft" | "topCenter" | "topRight" | "centerLeft" | "center" | "centerRight" | "bottomLeft" | "bottomCenter" | "bottomRight" | null (both alignments), "borders"?: bool | null (true: single lines on every edge, false: none, null: inherit) | Edges | [Edges], "borderColor"?: "RRGGBB", "borderWidth"?: pt}"#, + r#" and Edges = {"edges"?: "all" | "none" | "outside" | "inside" | "top" | "bottom" | "left" | "right" | "insideH" | "insideV" (default all), "style"?: "single" | "double" | "dotted" | "dashed" | "dotDash" | "thick" | "triple" | "wave" | "none", "width"?: pt, "color"?: "RRGGBB" | null}"# ) }; } @@ -254,30 +283,34 @@ fn apply(s: &mut Session, id: &str) -> Result<(), CmdError> { /// Apply the formatting params to `st`. fn edit(st: &mut Style, v: &Value) -> Result<(), CmdError> { - let mut parts = st.table.take().unwrap_or_default(); - if let Some(r) = v.get("wholeTable") { - region(r, &mut parts.fill, &mut st.chr, &mut parts.borders)?; + for (key, part) in PARTS { + if let Some(r) = v.get(key) { + edit_part(st, part, r)?; + } } - if let Some(r) = v.get("headerRow") { - region(r, &mut parts.header_fill, &mut parts.header_chr, &mut parts.header_borders)?; + match v.get("part") { + None => {} + Some(Value::String(n)) => { + let part = part_named(n).ok_or_else(|| CmdError::Params(format!("no table style part `{n}`")))?; + edit_part(st, part, v)?; + } + Some(_) => return Err(CmdError::Params("`part` is a part name".into())), } - if let Some(r) = v.get("bandedRows") { - region(r, &mut parts.band_fill, &mut parts.band_chr, &mut parts.band_borders)?; - } - if let Some(r) = v.get("lastRow") { - region(r, &mut parts.total_fill, &mut parts.total_chr, &mut parts.total_borders)?; - } - if let Some(r) = v.get("firstColumn") { - region(r, &mut parts.first_col_fill, &mut parts.first_col_chr, &mut parts.first_col_borders)?; - } - if let Some(r) = v.get("lastColumn") { - region(r, &mut parts.last_col_fill, &mut parts.last_col_chr, &mut parts.last_col_borders)?; - } - if let Some(r) = v.get("bandedColumns") { - region(r, &mut parts.col_band_fill, &mut parts.col_band_chr, &mut parts.col_band_borders)?; + match v.get("parts") { + None => {} + Some(Value::Array(list)) => { + for r in list.iter().take(64) { + let n = p::str(r, "part").ok_or_else(|| CmdError::Params("each of `parts` names its `part`".into()))?; + let part = part_named(n).ok_or_else(|| CmdError::Params(format!("no table style part `{n}`")))?; + edit_part(st, part, r)?; + } + } + Some(_) => return Err(CmdError::Params("`parts` is a list".into())), } if let Some(n) = v.get("bandSize") { + let mut parts = st.table.take().unwrap_or_default(); parts.band_size = n.as_u64().map(|n| n.clamp(1, 1000) as u32); + st.table = (parts != TableStyleParts::default()).then_some(parts); } if let Some(pp) = v.get("para") { let pp: ParaProps = serde_json::from_value(pp.clone()).map_err(|e| CmdError::Params(format!("para: {e}")))?; @@ -285,27 +318,132 @@ fn edit(st: &mut Style, v: &Value) -> Result<(), CmdError> { st.para.style = None; st.para.numbering = None; } - st.table = (parts != TableStyleParts::default()).then_some(parts); Ok(()) } -/// One region's shortcuts: borders, fill and character formatting. -fn region(r: &Value, fill: &mut Option, chr: &mut CharProps, borders: &mut Option) -> Result<(), CmdError> { - if !r.is_object() { - return Err(CmdError::Params("a region is an object".into())); +/// Apply one part's params `r` to part `part` of `st`. +fn edit_part(st: &mut Style, part: TablePart, r: &Value) -> Result<(), CmdError> { + let mut f = st.table_part(part); + region(r, &mut f)?; + st.set_table_part(part, f); + Ok(()) +} + +/// A colour param: `"RRGGBB"`, or null for none. +fn color_param(r: &Value, k: &str) -> Result, CmdError> { + match r.get(k) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(h)) => Rgb::parse(h).map(Some).ok_or_else(|| CmdError::Params(format!("`{k}`: bad colour `{h}`"))), + Some(_) => Err(CmdError::Params(format!("`{k}` is \"RRGGBB\" or null"))), } - let color = |k: &str| -> Result, CmdError> { - match r.get(k) { - None | Some(Value::Null) => Ok(None), - Some(Value::String(h)) => Rgb::parse(h).map(Some).ok_or_else(|| CmdError::Params(format!("`{k}`: bad colour `{h}`"))), - Some(_) => Err(CmdError::Params(format!("`{k}` is \"RRGGBB\" or null"))), +} + +/// An explicit "no border" edge (`w:val="nil"`), which hides what a lower part draws. +const NO_LINE: Border = Border { style: BorderStyle::None, width: 0.0, color: None, space: 0.0 }; + +/// The border styles by param name. +const LINE_STYLES: [(&str, BorderStyle); 9] = [ + ("none", BorderStyle::None), + ("single", BorderStyle::Single), + ("double", BorderStyle::Double), + ("dotted", BorderStyle::Dotted), + ("dashed", BorderStyle::Dashed), + ("dotDash", BorderStyle::DotDash), + ("thick", BorderStyle::Thick), + ("triple", BorderStyle::Triple), + ("wave", BorderStyle::Wave), +]; + +/// Set `edges` of `b` to `line`, as the border picker does. +pub fn set_edges(b: &mut Borders, edges: &str, line: Border) -> Result<(), CmdError> { + let all = |b: &mut Borders, l: Border| *b = Borders::all(l); + match edges { + "all" => all(b, line), + "none" => all(b, NO_LINE), + "outside" | "box" => { + (b.top, b.bottom, b.left, b.right) = (Some(line), Some(line), Some(line), Some(line)); } + "inside" => (b.between, b.inside_v) = (Some(line), Some(line)), + "top" => b.top = Some(line), + "bottom" => b.bottom = Some(line), + "left" => b.left = Some(line), + "right" => b.right = Some(line), + "insideH" => b.between = Some(line), + "insideV" => b.inside_v = Some(line), + _ => return Err(CmdError::Params(format!("unknown border edges `{edges}`"))), + } + Ok(()) +} + +/// One `Edges` object of the `borders` param. +fn edges_param(e: &Value, b: &mut Borders) -> Result<(), CmdError> { + if !e.is_object() { + return Err(CmdError::Params("`borders` is true, false, null or {\"edges\", \"style\", \"width\", \"color\"}".into())); + } + let style = match e.get("style") { + None => BorderStyle::Single, + Some(Value::String(n)) => LINE_STYLES + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case(n)) + .map(|(_, s)| *s) + .ok_or_else(|| CmdError::Params(format!("unknown border style `{n}`")))?, + Some(_) => return Err(CmdError::Params("border `style` is a name".into())), }; + let width = p::f32(e, "width").unwrap_or(0.5).clamp(0.25, 6.0); + let line = if style == BorderStyle::None { NO_LINE } else { Border { style, width, color: color_param(e, "color")?, space: 0.0 } }; + let edges = match e.get("edges") { + None => "all", + Some(Value::String(n)) => n.as_str(), + Some(_) => return Err(CmdError::Params("border `edges` is a name".into())), + }; + set_edges(b, edges, line) +} + +/// Horizontal alignment by param name. +fn h_align(n: &str) -> Option { + match n { + "left" | "start" => Some(Align::Left), + "center" => Some(Align::Center), + "right" | "end" => Some(Align::Right), + "justify" | "both" => Some(Align::Justify), + _ => None, + } +} + +/// Vertical alignment by param name. +fn v_align(n: &str) -> Option { + match n { + "top" => Some(VAlign::Top), + "center" | "middle" => Some(VAlign::Center), + "bottom" => Some(VAlign::Bottom), + _ => None, + } +} + +/// The nine cell alignments (`cellAlign`), vertical then horizontal. +pub const CELL_ALIGNS: [(&str, VAlign, Align); 9] = [ + ("topLeft", VAlign::Top, Align::Left), + ("topCenter", VAlign::Top, Align::Center), + ("topRight", VAlign::Top, Align::Right), + ("centerLeft", VAlign::Center, Align::Left), + ("center", VAlign::Center, Align::Center), + ("centerRight", VAlign::Center, Align::Right), + ("bottomLeft", VAlign::Bottom, Align::Left), + ("bottomCenter", VAlign::Bottom, Align::Center), + ("bottomRight", VAlign::Bottom, Align::Right), +]; + +/// One part's params: borders, fill, character formatting and cell alignment. +fn region(r: &Value, f: &mut PartFormat) -> Result<(), CmdError> { + if !r.is_object() { + return Err(CmdError::Params("a part is an object".into())); + } + let chr = &mut f.chr; if r.get("fill").is_some() { - *fill = color("fill")?; + f.fill = color_param(r, "fill")?; } if r.get("color").is_some() { - chr.color = color("color")?.map(TextColor::Rgb); + chr.color = color_param(r, "color")?.map(TextColor::Rgb); } if let Some(b) = p::bool(r, "bold") { chr.bold = Some(b); @@ -315,21 +453,67 @@ fn region(r: &Value, fill: &mut Option, chr: &mut CharProps, borders: &mut chr.italic = Some(b); chr.italic_cs = Some(b); } + match r.get("underline") { + None => {} + Some(Value::Bool(b)) => chr.underline = Some(if *b { Underline::Single } else { Underline::None }), + Some(Value::Null) => chr.underline = None, + Some(u @ Value::String(_)) => { + chr.underline = Some(serde_json::from_value(u.clone()).map_err(|_| CmdError::Params(format!("unknown underline {u}")))?); + } + Some(_) => return Err(CmdError::Params("`underline` is a bool or a style name".into())), + } if let Some(sz) = p::f32(r, "size") { chr.size = Some(sz.clamp(1.0, 1638.0)); chr.size_cs = chr.size; } - if let Some(f) = p::str(r, "font").map(str::trim).filter(|f| !f.is_empty()) { - chr.font = Some(f.to_string()); + if let Some(n) = p::str(r, "font").map(str::trim).filter(|n| !n.is_empty()) { + chr.font = Some(n.chars().take(256).collect()); + } + let name = |k: &str| -> Result>, CmdError> { + match r.get(k) { + None => Ok(None), + Some(Value::Null) => Ok(Some(None)), + Some(Value::String(n)) => Ok(Some(Some(n.as_str()))), + Some(_) => Err(CmdError::Params(format!("`{k}` is a name or null"))), + } + }; + if let Some(a) = name("align")? { + f.align = a.map(|n| h_align(n).ok_or_else(|| CmdError::Params(format!("unknown alignment `{n}`")))).transpose()?; + } + if let Some(a) = name("vAlign")? { + f.v_align = a.map(|n| v_align(n).ok_or_else(|| CmdError::Params(format!("unknown vertical alignment `{n}`")))).transpose()?; + } + if let Some(a) = name("cellAlign")? { + match a { + None => (f.align, f.v_align) = (None, None), + Some(n) => { + let (_, va, ha) = CELL_ALIGNS + .iter() + .find(|(k, _, _)| k.eq_ignore_ascii_case(n)) + .ok_or_else(|| CmdError::Params(format!("unknown cell alignment `{n}`")))?; + (f.align, f.v_align) = (Some(*ha), Some(*va)); + } + } } match r.get("borders") { Some(Value::Bool(true)) => { let width = p::f32(r, "borderWidth").unwrap_or(0.5).clamp(0.25, 6.0); - *borders = Some(Borders::all(Border { style: BorderStyle::Single, width, color: color("borderColor")?, space: 0.0 })); + f.borders = Some(Borders::all(Border { style: BorderStyle::Single, width, color: color_param(r, "borderColor")?, space: 0.0 })); + } + Some(Value::Bool(false)) => f.borders = Some(Borders::all(NO_LINE)), + Some(Value::Null) => f.borders = None, + Some(Value::Array(list)) => { + let mut b = f.borders.unwrap_or_default(); + for e in list.iter().take(64) { + edges_param(e, &mut b)?; + } + f.borders = Some(b); + } + Some(e) => { + let mut b = f.borders.unwrap_or_default(); + edges_param(e, &mut b)?; + f.borders = Some(b); } - Some(Value::Bool(false)) => *borders = Some(Borders::all(Border { style: BorderStyle::None, width: 0.0, color: None, space: 0.0 })), - Some(Value::Null) => *borders = None, - Some(_) => return Err(CmdError::Params("`borders` is true, false or null".into())), None => {} } Ok(()) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 7e5b9b09d..5ee9883b9 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3847,6 +3847,109 @@ fn custom_table_style_applies_modifies_and_saves() { assert_eq!(t.props.style.as_deref(), Some(id.as_str()), "w:tblStyle"); } +/// #714: Modify Table Style edits one part at a time (header row, even bands, a corner cell…) +/// with font, fill, borders and cell alignment, and every table using the style follows. +#[test] +fn table_style_parts_format_every_table_using_the_style() { + use wordcraft_doc::props::{Align, BorderStyle, VAlign}; + use wordcraft_doc::styles::TablePart; + use wordcraft_doc::{Rgb, TextColor}; + let mut s = s(); + let look = json!({"headerRow": true, "bandedRows": true, "firstColumn": true, "totalRow": false}); + run(&mut s, "insert.table", json!({"rows": 4, "cols": 3})); + run(&mut s, "text.insert", json!({"text": "Head"})); + run(&mut s, "table.look", look.clone()); + run(&mut s, "table.newStyle", json!({"name": "Parts", "basedOn": "Table Grid"})); + run(&mut s, "caret.docEnd", json!({})); + run(&mut s, "insert.table", json!({"rows": 4, "cols": 3})); + run(&mut s, "text.insert", json!({"text": "Head"})); + run(&mut s, "table.look", look); + run(&mut s, "table.style", json!({"style": "Parts"})); + let tables: Vec<_> = s.doc.body.iter().filter_map(|b| b.as_table()).collect(); + assert_eq!(tables.len(), 2); + assert!(tables.iter().all(|t| t.props.style.as_deref() == Some("Parts"))); + + run( + &mut s, + "table.modifyStyle", + json!({"style": "Parts", + "firstRow": {"bold": true, "underline": true, "color": "FFFFFF", "font": "Liberation Serif", "cellAlign": "bottomCenter", + "borders": [{"edges": "none"}, {"edges": "bottom", "style": "double", "width": 1.5, "color": "FF0000"}]}, + "band2Horz": {"fill": "DDEEFF"}, + "nwCell": {"fill": "112233"}, + "parts": [{"part": "lastCol", "italic": true}]}), + ); + run(&mut s, "table.modifyStyle", json!({"style": "Parts", "part": "band1Horz", "fill": "AABBCC", "vAlign": "center"})); + let st = s.doc.styles.get("Parts").unwrap().clone(); + let head = st.table_part(TablePart::FirstRow); + assert_eq!((head.chr.bold, head.align, head.v_align), (Some(true), Some(Align::Center), Some(VAlign::Bottom))); + let b = head.borders.unwrap(); + assert_eq!(b.bottom.map(|x| (x.style, x.width, x.color)), Some((BorderStyle::Double, 1.5, Some(Rgb(0xFF, 0, 0))))); + assert_eq!(b.top.map(|x| x.style), Some(BorderStyle::None), "`none` clears the other edges"); + assert_eq!(st.table_part(TablePart::LastCol).chr.italic, Some(true)); + assert_eq!(st.table_part(TablePart::Band1Horz).v_align, Some(VAlign::Center)); + + let fills = |s: &mut Session, c: Rgb| { + s.layout().pages[0].items.iter().filter(|i| matches!(i, crate::layout::Placed::Fill { color, .. } if *color == c)).count() + }; + // Per table: the top-left corner cell; odd bands are rows 1 and 3, the even band row 2. + assert_eq!(fills(&mut s, Rgb(0x11, 0x22, 0x33)), 2, "top left cells"); + assert_eq!(fills(&mut s, Rgb(0xAA, 0xBB, 0xCC)), 12, "odd banded rows"); + assert_eq!(fills(&mut s, Rgb(0xDD, 0xEE, 0xFF)), 6, "even banded rows"); + let rules = s + .layout() + .pages[0] + .items + .iter() + .filter(|i| matches!(i, crate::layout::Placed::Rule { border, .. } if border.style == BorderStyle::Double && border.color == Some(Rgb(0xFF, 0, 0)))) + .count(); + assert!(rules >= 6, "the header rows' bottom edges in both tables: {rules}"); + // The header text: bold, white, underlined and centred, in both tables. + let heads: Vec<_> = s.layout().pages[0] + .items + .iter() + .filter_map(|i| match i { + crate::layout::Placed::Lines { para, .. } if para.text_len == 4 => Some((para.rp.align, para.styles[0].rc.clone())), + _ => None, + }) + .collect(); + assert_eq!(heads.len(), 2, "{heads:?}"); + for (align, rc) in heads { + assert_eq!(align, Align::Center); + assert!(rc.bold && rc.underline != wordcraft_doc::props::Underline::None, "{rc:?}"); + assert_eq!(rc.color, TextColor::Rgb(Rgb::WHITE)); + } + // It saves: the parts come back. + let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap(); + let got = back.styles.get("Parts").unwrap(); + for part in TablePart::ALL { + assert_eq!(got.table_part(part), st.table_part(part), "{}", part.ooxml()); + } + + // Hostile params fail cleanly and change nothing. + for bad in [ + json!({"style": "Parts", "part": 5}), + json!({"style": "Parts", "part": "diagonal"}), + json!({"style": "Parts", "parts": "x"}), + json!({"style": "Parts", "parts": [{"fill": "FF0000"}]}), + json!({"style": "Parts", "firstRow": {"borders": {"edges": "diagonal"}}}), + json!({"style": "Parts", "firstRow": {"borders": {"style": "zigzag"}}}), + json!({"style": "Parts", "firstRow": {"borders": [1, 2]}}), + json!({"style": "Parts", "firstRow": {"cellAlign": 3}}), + json!({"style": "Parts", "firstRow": {"cellAlign": "middleish"}}), + json!({"style": "Parts", "firstRow": {"align": "up"}}), + json!({"style": "Parts", "firstRow": {"underline": "zigzag"}}), + json!({"style": "Parts", "firstRow": "bold"}), + ] { + assert!(s.run("table.modifyStyle", &bad).is_err(), "{bad}"); + } + assert_eq!(s.doc.styles.get("Parts"), Some(&st)); + // Huge and odd numbers are clamped. + run(&mut s, "table.modifyStyle", json!({"style": "Parts", "firstRow": {"size": 1e30, "borders": {"edges": "top", "width": -5}}})); + let head = s.doc.styles.get("Parts").unwrap().table_part(TablePart::FirstRow); + assert_eq!((head.chr.size, head.borders.and_then(|b| b.top).map(|b| b.width)), (Some(1638.0), Some(0.25))); +} + #[test] fn page_and_table_gridlines_toggle_independently() { // #69: View › Gridlines (the page drawing grid) and Table Layout › View Gridlines (table cell diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index 99bf5a056..403d39977 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -4,7 +4,7 @@ use wordcraft_doc::numbering::Counters; use wordcraft_doc::props::{Align, Border, Borders, HeightRule, Rgb, TextDirection, VAlign, VMerge}; -use wordcraft_doc::styles::{TableStyleParts, TableStyleProps}; +use wordcraft_doc::styles::{PartSet, TablePart, TableStyleProps}; use wordcraft_doc::{Block, Blocks, Paragraph, StoryRef, Table}; use wordcraft_geom::Rect; @@ -58,8 +58,7 @@ struct Turned { across: f32, /// List counters and equation count before the cell, to lay it out again the same way. snap: (Counters, u32), - /// Its formatting region (header row, total row, first column, last column, row band, column - /// band) and cell index in the row. + /// Its table style parts and cell index in the row. region: Region, ci: usize, /// Top margin plus top border band. @@ -288,53 +287,69 @@ fn first_cell_left_margin_in(t: &Table, def: [f32; 4]) -> f32 { t.rows.first().and_then(|r| r.cells.first()).and_then(|c| c.props.margins).unwrap_or(def)[1] } -/// A cell's borders from the table style's conditional regions it is in (`regions`: header row, -/// total row, first column, last column, row band, column band), later regions winning. Row -/// regions give their left/right edges at the table's sides and `inside_v` between cells; column -/// regions their top/bottom edges at the table's top and bottom and `between` between rows. -fn region_borders(p: &TableStyleParts, (ri, nrows): (usize, usize), (first_g, last_g): (bool, bool), regions: [bool; 6]) -> Borders { - let [header, total, first_col, last_col, band, col_band] = regions; - let row = |b: Option| { - b.map(|b| Borders { - top: b.top, - bottom: b.bottom, - left: if first_g { b.left } else { b.inside_v }, - right: if last_g { b.right } else { b.inside_v }, - between: None, - inside_v: None, - }) - }; - let col = |b: Option| { - b.map(|b| Borders { - top: if ri == 0 { b.top } else { b.between }, - bottom: if ri + 1 >= nrows { b.bottom } else { b.between }, - left: b.left, - right: b.right, - between: None, - inside_v: None, - }) - }; - let mut out = Borders::default(); - for (on, b) in [ - (col_band, col(p.col_band_borders)), - (band, row(p.band_borders)), - (first_col, col(p.first_col_borders)), - (last_col, col(p.last_col_borders)), - (header, row(p.header_borders)), - (total, row(p.total_borders)), - ] { - if on && let Some(b) = b { - out.overlay(&b); +/// What a table style gives the cells in one set of parts: shading, vertical alignment and each +/// part's cell borders, lowest precedence first. +struct RegionLook { + fill: Option, + v_align: Option, + borders: Vec<(TablePart, Borders)>, +} + +fn region_look(st: &TableStyleProps, region: Region) -> RegionLook { + let mut out = RegionLook { fill: None, v_align: None, borders: Vec::new() }; + for part in region.iter() { + let f = st.part(part); + out.fill = f.fill.or(out.fill); + out.v_align = f.v_align.or(out.v_align); + // The whole table's borders are the table borders, applied on their own. + if part != TablePart::WholeTable + && let Some(b) = f.borders + { + out.borders.push((part, b)); } } out } +/// A cell's borders from the table style's parts it is in, later parts winning. Row parts give +/// their left/right edges at the table's sides and `inside_v` between cells; column parts their +/// top/bottom edges at the table's top and bottom and `between` between rows; corner cells their +/// four edges. +fn region_borders(parts: &[(TablePart, Borders)], (ri, nrows): (usize, usize), (first_g, last_g): (bool, bool)) -> Borders { + let mut out = Borders::default(); + for (part, b) in parts { + let b = *b; + let mapped = if part.is_row() { + Borders { + top: b.top, + bottom: b.bottom, + left: if first_g { b.left } else { b.inside_v }, + right: if last_g { b.right } else { b.inside_v }, + between: None, + inside_v: None, + } + } else if part.is_column() { + Borders { + top: if ri == 0 { b.top } else { b.between }, + bottom: if ri + 1 >= nrows { b.bottom } else { b.between }, + left: b.left, + right: b.right, + between: None, + inside_v: None, + } + } else { + Borders { between: None, inside_v: None, ..b } + }; + out.overlay(&mapped); + } + out +} + pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], avail: f32, depth: usize) -> TableLayout { let style = table_style(ctx, t); - let parts = style.as_ref().map(|s| &s.parts); - // Cell text formatting per region, built once per table. + // Cell text formatting and looks per set of style parts, built once per table. let mut cell_text: Vec<(Region, CellText)> = Vec::new(); + let mut looks: Vec<(Region, RegionLook)> = Vec::new(); let ncols = t.cols().max(1); // Column widths. let mut grid: Vec = if t.grid.len() == ncols { @@ -391,7 +406,7 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava let margins_def = default_margins(t, style.as_ref()); let indent = t.props.indent.unwrap_or(0.0); // The style's borders, overlaid by the table's own side by side. - let mut tborders = parts.and_then(|p| p.borders).unwrap_or_default(); + let mut tborders = style.as_ref().and_then(|s| s.parts.borders).unwrap_or_default(); if let Some(own) = t.props.borders { tborders.overlay(&own); } @@ -418,7 +433,6 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava (_, false) => start_offset, }; let nrows = t.rows.len(); - let header_rows = t.props.look.header_row; // First pass: lay out every cell's content. struct CellBox { items: Vec, @@ -449,9 +463,6 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava let mut row_below = 0.0f32; // Every cell an empty one with a hidden end-of-cell mark: the row is only its borders. let mut collapsed = true; - let is_header = header_rows && ri == 0; - let is_total = t.props.look.total_row && ri + 1 == nrows && nrows > 1; - let band = banded(t, style.as_ref(), ri); // Word gives every cell in a row the row's largest top and bottom margins: a cell without // its own lines up with a neighbour that has 5.65pt ones. let (row_top, row_bottom) = row @@ -469,38 +480,17 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava let m = cell.props.margins.unwrap_or(margins_def); let margins = if rtl { [row_top, m[3], row_bottom, m[1]] } else { [row_top, m[1], row_bottom, m[3]] }; let cw = (x1 - x0 - margins[1] - margins[3]).max(4.0); - let mut fill = cell.props.shading; let region = region_of(t, style.as_ref(), ri, g, span, ncols); - let (_, _, first_col, last_col, _, col_band) = region; - // Conditional formatting, later regions winning (ECMA-376 §17.7.6): whole table, - // column bands, row bands, first/last column, header/total row. - if let Some(p) = parts { - let row_fill = if is_header { - p.header_fill - } else if is_total { - p.total_fill - } else { - None - }; - let col_fill = if first_col { - p.first_col_fill - } else if last_col { - p.last_col_fill - } else { - None - }; - fill = fill - .or(row_fill) - .or(col_fill) - .or(if band { p.band_fill } else { None }) - .or(if col_band { p.col_band_fill } else { None }) - .or(p.fill); - } + // Conditional formatting, later parts winning (ECMA-376 §17.7.6): whole table, column + // bands, row bands, first/last column, header/total row, corner cells. if let Some(st) = &style && !cell_text.iter().any(|(r, _)| *r == region) { cell_text.push((region, region_text(st, region))); + looks.push((region, region_look(st, region))); } + let look = looks.iter().find(|(r, _)| *r == region).map(|(_, l)| l); + let fill = cell.props.shading.or(look.and_then(|l| l.fill)); let text = cell_text.iter().find(|(r, _)| *r == region).map(|(_, c)| c); let mut cpath = path.to_vec(); cpath.push(ri as u32); @@ -513,9 +503,7 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava // A row's table property exceptions (`w:tblPrEx`) override the table's borders for its cells. let row_borders = row.props.exceptions.as_ref().and_then(|e| e.borders).unwrap_or_default(); let (first_g, last_g) = (g == 0, g + span >= ncols); - let rb = parts - .map(|p| region_borders(p, (ri, nrows), (first_g, last_g), [is_header, is_total, first_col, last_col, band, col_band])) - .unwrap_or_default(); + let rb = look.map(|l| region_borders(&l.borders, (ri, nrows), (first_g, last_g))).unwrap_or_default(); let mut borders = Borders { top: edge(cb.top.or(if ri == 0 { row_borders.top } else { row_borders.between }).or(rb.top), ri == 0, tb.top, tb.between), bottom: edge( @@ -539,12 +527,6 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava between: None, inside_v: None, }; - if is_total - && parts.is_none_or(|p| p.total_borders.is_none_or(|b| b.top.is_none())) - && let Some(b) = parts.and_then(|p| p.total_border_top) - { - borders.top = Some(b); - } // The logical start (left) and end (right) borders, drawn mirrored in an RTL table. if rtl { std::mem::swap(&mut borders.left, &mut borders.right); @@ -605,7 +587,8 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava h, x: x0, w: x1 - x0, - valign: cell.props.valign, + // The style's vertical alignment unless the cell has its own. + valign: if cell.props.valign == VAlign::Top { look.and_then(|l| l.v_align).unwrap_or_default() } else { cell.props.valign }, fill, borders, vmerge: cell.props.vmerge, @@ -1028,50 +1011,28 @@ fn split_rect(r: Rect, cut: f32, shift: f32) -> (Option, Option) { (above, below) } -/// A cell's region for table-style conditional formatting: (header row, total row, first column, -/// last column, banded row, banded column). -type Region = (bool, bool, bool, bool, bool, bool); +/// A cell's table style parts. +type Region = PartSet; -/// The region of the cell at grid column `g` spanning `span` of `ncols` columns in row `ri`. +/// The parts of the cell at grid column `g` spanning `span` of `ncols` columns in row `ri`. fn region_of(t: &Table, style: Option<&TableStyleProps>, ri: usize, g: usize, span: usize, ncols: usize) -> Region { - let (look, nrows) = (&t.props.look, t.rows.len()); - let first_col = look.first_column && g == 0; - let last_col = look.last_column && g.saturating_add(span) >= ncols && ncols > 1; - let col_band = look.banded_columns && !first_col && g.saturating_sub(usize::from(look.first_column)).is_multiple_of(2); - (look.header_row && ri == 0, look.total_row && ri + 1 == nrows && nrows > 1, first_col, last_col, banded(t, style, ri), col_band) + let size = style.and_then(|s| s.parts.band_size).unwrap_or(1); + PartSet::of_cell(&t.props.look, size, (ri, t.rows.len()), (g, span, ncols)) } -/// Whether row `ri` is in an odd band of the table style's banded rows (bands of the style's -/// band size, counted below the header row). -fn banded(t: &Table, style: Option<&TableStyleProps>, ri: usize) -> bool { - let look = &t.props.look; - let size = style.and_then(|s| s.parts.band_size).unwrap_or(1).clamp(1, 1000) as usize; - look.banded_rows && !(look.header_row && ri == 0) && (ri.saturating_sub(usize::from(look.header_row)) / size).is_multiple_of(2) -} - -/// The table style's text formatting in `region`: the whole table's, then the column band's, the -/// row band's, the first/last column's and the header/total row's (later regions win, -/// ECMA-376 §17.7.6). -fn region_text(st: &TableStyleProps, (header, total, first_col, last_col, band, col_band): Region) -> CellText { - let mut chr = st.chr.clone(); - if col_band { - chr.overlay(&st.parts.col_band_chr); +/// The table style's text formatting in `region`: each part's character formatting and paragraph +/// alignment over the last, lowest precedence first (ECMA-376 §17.7.6). +fn region_text(st: &TableStyleProps, region: Region) -> CellText { + let mut chr = wordcraft_doc::CharProps::default(); + let mut para = st.para.clone(); + for part in region.iter() { + let f = st.part(part); + chr.overlay(&f.chr); + if f.align.is_some() { + para.align = f.align; + } } - if band { - chr.overlay(&st.parts.band_chr); - } - if first_col { - chr.overlay(&st.parts.first_col_chr); - } - if last_col { - chr.overlay(&st.parts.last_col_chr); - } - if header { - chr.overlay(&st.parts.header_chr); - } else if total { - chr.overlay(&st.parts.total_chr); - } - CellText { para: st.para.clone(), chr } + CellText { para, chr } } /// Each column's narrowest and widest content, cell margins included: its longest word and its diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 411272365..b48a67905 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -335,20 +335,22 @@ pub enum Dialog { alphabetical: bool, selected: String, }, - /// Table Design › New / Modify Table Style: one formatting region at a time. + /// Table Design › New / Modify Table Style: one table style part at a time (#714). TableStyle { /// The style being modified; `None` creates one. id: Option, name: String, /// Style id. based_on: String, - /// Index into `regions` (see `REGION_LABELS`). + /// Index into `regions` (see `dialogs_table_style::PARTS`). region: usize, - regions: Box<[TableRegion; 7]>, - /// What the regions showed when the dialog opened (or the base style changed): only + regions: Box<[crate::dialogs_table_style::TableRegion; 13]>, + /// What the parts showed when the dialog opened (or the base style changed): only /// changes are sent, so everything else stays inherited. #[serde(skip)] - basis: Box<[TableRegion; 7]>, + basis: Box<[crate::dialogs_table_style::TableRegion; 13]>, + /// The line the border choices draw. + pen: crate::dialogs_table_style::BorderPen, }, Commands { query: String, @@ -751,188 +753,6 @@ impl RecipientFilter { } } -/// One table style region in the Table Style dialog: colours are hex, empty for none. -#[derive(Clone, Debug, Default, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct TableRegion { - pub borders: bool, - pub fill: String, - pub bold: bool, - pub color: String, -} - -/// The Table Style dialog's regions, in the order of `table_style::REGIONS` (the param names). -const REGION_LABELS: [&str; 7] = ["Whole Table", "Header Row", "Total Row", "First Column", "Last Column", "Banded Rows", "Banded Columns"]; - -/// The regions of table style `id` as resolved through its based-on chain. -fn table_regions(app: &WordApp, id: &str) -> Box<[TableRegion; 7]> { - let Some(st) = app.session.doc.styles.table_style(id) else { return Default::default() }; - let p = &st.parts; - let hex = |c: Option| c.map(|c| c.hex()).unwrap_or_default(); - let text = |c: &wordcraft_doc::CharProps| match c.color { - Some(wordcraft_doc::TextColor::Rgb(c)) => c.hex(), - _ => String::new(), - }; - let lines = |b: Option| b.is_some_and(|b| b.any_visible()); - // A region's text: the whole table's formatting with the region's over it. - let region = |borders: Option, fill: Option, chr: &wordcraft_doc::CharProps| { - let mut c = st.chr.clone(); - c.overlay(chr); - TableRegion { borders: lines(borders), fill: hex(fill), bold: c.bold.unwrap_or(false), color: text(&c) } - }; - let none = wordcraft_doc::CharProps::default(); - Box::new([ - region(p.borders, p.fill, &none), - region(p.header_borders, p.header_fill, &p.header_chr), - region(p.total_borders, p.total_fill, &p.total_chr), - region(p.first_col_borders, p.first_col_fill, &p.first_col_chr), - region(p.last_col_borders, p.last_col_fill, &p.last_col_chr), - region(p.band_borders, p.band_fill, &p.band_chr), - region(p.col_band_borders, p.col_band_fill, &p.col_band_chr), - ]) -} - -/// The params `table.newStyle` / `table.modifyStyle` take for the Table Style dialog's state: -/// only what changed from `basis`, so the rest stays inherited. -fn table_style_params(id: Option<&str>, name: &str, based_on: &str, regions: &[TableRegion; 7], basis: &[TableRegion; 7]) -> Value { - let mut v = json!({"name": name.trim(), "basedOn": based_on}); - for ((key, r), b) in wordcraft_engine::cmd::table_style::REGIONS.into_iter().zip(regions.iter()).zip(basis.iter()) { - let ch = region_changes(r, b); - if ch.as_object().is_some_and(|o| !o.is_empty()) { - v[key] = ch; - } - } - if let Some(id) = id { - v["style"] = json!(id); - } - v -} - -/// A small sample table drawn with `style` (5 columns, a header, three body rows and a total -/// row), showing the regions `look` turns on: the Table Style dialog's live preview. -fn table_style_preview(ui: &mut Ui, style: Option<&wordcraft_doc::styles::TableStyleProps>, look: wordcraft_doc::props::TableLook) { - use wordcraft_doc::props::{Border, Borders}; - const COLS: usize = 5; - const ROWS: usize = 5; - const TEXT: [[&str; COLS]; ROWS] = [ - ["", "Mon", "Tue", "Wed", "Sum"], - ["North", "4", "7", "2", "13"], - ["South", "6", "1", "5", "12"], - ["West", "3", "8", "4", "15"], - ["Total", "13", "16", "11", "40"], - ]; - let (rect, _) = ui.allocate_exact_size(vec2(300.0, 120.0), Sense::hover()); - let painter = ui.painter_at(rect); - // The page is white whatever the interface theme. - painter.rect_filled(rect, 2.0, egui::Color32::WHITE); - let t = Tokens::get(ui.ctx()); - painter.rect_stroke(rect, 2.0, egui::Stroke::new(1.0, t.border), egui::StrokeKind::Inside); - let Some(st) = style else { return }; - let p = &st.parts; - let table = rect.shrink2(vec2(14.0, 10.0)); - let (cw, rh) = (table.width() / COLS as f32, table.height() / ROWS as f32); - let rgb = |c: wordcraft_doc::Rgb| egui::Color32::from_rgb(c.0, c.1, c.2); - let tb = p.borders.unwrap_or_default(); - for (ri, row) in TEXT.iter().enumerate() { - let header = look.header_row && ri == 0; - let total = look.total_row && ri + 1 == ROWS; - let band = look.banded_rows - && !header - && (ri.saturating_sub(usize::from(look.header_row)) / p.band_size.unwrap_or(1).clamp(1, 1000) as usize).is_multiple_of(2); - for (ci, txt) in row.iter().enumerate() { - // Sample words in the interface language; numbers as they are. - let txt = &if txt.chars().any(char::is_alphabetic) { tl!(txt) } else { *txt }; - let first = look.first_column && ci == 0; - let last = look.last_column && ci + 1 == COLS; - let col_band = look.banded_columns && !first && ci.saturating_sub(usize::from(look.first_column)).is_multiple_of(2); - let cell = egui::Rect::from_min_size(table.min + vec2(ci as f32 * cw, ri as f32 * rh), vec2(cw, rh)); - // Regions from lowest to highest priority, as layout applies them. - let regions = [ - (col_band, p.col_band_fill, &p.col_band_chr, p.col_band_borders), - (band, p.band_fill, &p.band_chr, p.band_borders), - (first, p.first_col_fill, &p.first_col_chr, p.first_col_borders), - (last, p.last_col_fill, &p.last_col_chr, p.last_col_borders), - (header, p.header_fill, &p.header_chr, p.header_borders), - (total, p.total_fill, &p.total_chr, p.total_borders), - ]; - let mut fill = p.fill; - let mut chr = st.chr.clone(); - let mut b = Borders { - top: if ri == 0 { tb.top } else { tb.between }, - bottom: if ri + 1 == ROWS { tb.bottom } else { tb.between }, - left: if ci == 0 { tb.left } else { tb.inside_v }, - right: if ci + 1 == COLS { tb.right } else { tb.inside_v }, - between: None, - inside_v: None, - }; - for (on, f, c, rb) in regions { - if !on { - continue; - } - fill = f.or(fill); - chr.overlay(c); - if let Some(rb) = rb { - b.overlay(&Borders { between: None, inside_v: None, ..rb }); - } - } - if total && p.total_borders.is_none_or(|x| x.top.is_none()) { - b.top = p.total_border_top.or(b.top); - } - if let Some(f) = fill { - painter.rect_filled(cell, 0.0, rgb(f)); - } - let edge = |from: egui::Pos2, to: egui::Pos2, e: Option| { - if let Some(e) = e.filter(Border::is_visible) { - let color = e.color.map_or(egui::Color32::BLACK, rgb); - painter.line_segment([from, to], egui::Stroke::new(e.width.clamp(0.5, 3.0) * 1.3, color)); - } - }; - edge(cell.left_top(), cell.right_top(), b.top); - edge(cell.left_bottom(), cell.right_bottom(), b.bottom); - edge(cell.left_top(), cell.left_bottom(), b.left); - edge(cell.right_top(), cell.right_bottom(), b.right); - let color = match chr.color { - Some(wordcraft_doc::TextColor::Rgb(c)) => rgb(c), - _ => egui::Color32::BLACK, - }; - let font = egui::FontId::proportional(11.0); - let pos = cell.left_center() + vec2(4.0, 0.0); - painter.text(pos, egui::Align2::LEFT_CENTER, tl!(*txt), font.clone(), color); - if chr.bold.unwrap_or(false) { - // A second pass a hair to the right reads as bold at this size. - painter.text(pos + vec2(0.6, 0.0), egui::Align2::LEFT_CENTER, tl!(*txt), font, color); - } - } - } -} - -/// The style of the table at the caret, when it is a table style. -fn current_table_style(app: &WordApp) -> Option { - let s = &app.session; - let (tp, _, _) = s.sel.focus.path.cell()?; - let id = s.doc.table(s.sel.focus.story, &tp)?.props.style.clone()?; - s.doc.styles.get(&id).filter(|st| st.kind == wordcraft_doc::StyleKind::Table).map(|st| st.id.clone()) -} - -/// The params `table.newStyle` / `table.modifyStyle` need for what changed in one region. -fn region_changes(r: &TableRegion, basis: &TableRegion) -> Value { - let color = |c: &str| if c.is_empty() { Value::Null } else { json!(c) }; - let mut v = json!({}); - if r.borders != basis.borders { - v["borders"] = json!(r.borders); - } - if r.fill != basis.fill { - v["fill"] = color(&r.fill); - } - if r.bold != basis.bold { - v["bold"] = json!(r.bold); - } - if r.color != basis.color { - v["color"] = color(&r.color); - } - v -} - /// A colour menu: a swatch with the colour grid and No Color. `value` is hex, empty for none. pub(crate) fn color_menu(ui: &mut Ui, theme: &[wordcraft_doc::Rgb], value: &mut String) { color_field(ui, theme, value, "No Color", ""); @@ -1208,20 +1028,7 @@ impl Dialog { } "newStyle" => Dialog::NewStyle { name: "Style1".into(), based_on: "Normal".into(), back: false }, "manageStyles" => Dialog::ManageStyles { alphabetical: false, selected: s("style") }, - "newTableStyle" => { - let based_on = current_table_style(app).unwrap_or_else(|| "TableGrid".into()); - let styles = &app.session.doc.styles; - let name = (1..1000).map(|i| format!("Table Style {i}")).find(|n| styles.find(n).is_none()).unwrap_or_default(); - let regions = table_regions(app, &based_on); - Dialog::TableStyle { id: None, name, based_on, region: 0, basis: regions.clone(), regions } - } - "modifyTableStyle" => { - let id = current_table_style(app)?; - let st = app.session.doc.styles.get(&id)?; - let (name, based_on) = (st.name.clone(), st.based_on.clone().unwrap_or_default()); - let regions = table_regions(app, &id); - Dialog::TableStyle { id: Some(id), name, based_on, region: 0, basis: regions.clone(), regions } - } + "newTableStyle" | "modifyTableStyle" => crate::dialogs_table_style::open(app, name)?, "commands" => Dialog::Commands { query: String::new(), swallow: true }, "icons" => Dialog::Icons { query: String::new(), category: String::new(), selected: String::new() }, "tableProperties" => { @@ -2477,96 +2284,8 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { ok || cancel } Dialog::ManageStyles { alphabetical, selected } => manage_styles(app, ui, alphabetical, selected), - Dialog::TableStyle { id, name, based_on, region, regions, basis } => { - let styles: Vec<(String, String)> = app - .session - .doc - .styles - .styles - .iter() - .filter(|s| s.kind == wordcraft_doc::StyleKind::Table && Some(&s.id) != id.as_ref()) - .map(|s| (s.id.clone(), s.name.clone())) - .collect(); - let base_name = styles.iter().find(|(i, _)| i == based_on).map(|(_, n)| n.clone()).unwrap_or_else(|| based_on.clone()); - let mut rebased = false; - egui::Grid::new("tstyle").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { - ui.label(tl!("Name:")); - ui.text_edit_singleline(name); - ui.end_row(); - ui.label(tl!("Style based on:")); - egui::ComboBox::from_id_salt("tstyle_base").width(200.0).selected_text(base_name).show_ui(ui, |ui| { - for (sid, sname) in &styles { - if ui.selectable_label(sid == based_on, sname).clicked() && sid != based_on { - *based_on = sid.clone(); - rebased = true; - } - } - }); - ui.end_row(); - ui.label(tl!("Apply formatting to:")); - egui::ComboBox::from_id_salt("tstyle_region") - .width(200.0) - .selected_text(tl!(REGION_LABELS.get(*region).copied().unwrap_or("Whole Table"))) - .show_ui(ui, |ui| { - for (i, n) in REGION_LABELS.iter().enumerate() { - ui.selectable_value(region, i, tl!(n)); - } - }); - ui.end_row(); - }); - // A new style shows its new base's formatting; a modified one keeps its own. - if rebased && id.is_none() { - *regions = table_regions(app, based_on); - *basis = regions.clone(); - } - ui.separator(); - let theme = app.session.doc.settings.theme_colors.clone(); - if let Some(r) = regions.get_mut(*region) { - ui.horizontal(|ui| { - ui.checkbox(&mut r.borders, tl!("Borders")); - ui.checkbox(&mut r.bold, tl!("Bold")); - }); - egui::Grid::new("tstyle_colors").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { - ui.label(tl!("Fill color:")); - color_menu(ui, &theme, &mut r.fill); - ui.end_row(); - ui.label(tl!("Font color:")); - color_menu(ui, &theme, &mut r.color); - ui.end_row(); - }); - } - // Live preview: the style as OK would leave it, on the current table's options with - // the region being edited turned on. - let v = table_style_params(id.as_deref(), name, based_on, regions, basis); - let mut look = app - .session - .sel - .focus - .path - .cell() - .and_then(|(tp, _, _)| app.session.doc.table(app.session.sel.focus.story, &tp).map(|t| t.props.look)) - .unwrap_or_default(); - match *region { - 1 => look.header_row = true, - 2 => look.total_row = true, - 3 => look.first_column = true, - 4 => look.last_column = true, - 5 => look.banded_rows = true, - 6 => look.banded_columns = true, - _ => {} - } - ui.add_space(4.0); - ui.label(egui::RichText::new(tl!("Preview")).small().weak()); - table_style_preview(ui, wordcraft_engine::cmd::table_style::preview(&app.session, &v).as_ref(), look); - let (ok, cancel) = buttons(ui, tl!("OK")); - if ok { - let cmd = if id.is_some() { "table.modifyStyle" } else { "table.newStyle" }; - if let Err(e) = app.run(cmd, v) { - app.status(e); - return false; - } - } - ok || cancel + Dialog::TableStyle { id, name, based_on, region, regions, basis, pen } => { + crate::dialogs_table_style::body(app, ui, crate::dialogs_table_style::Form { id, name, based_on, region, regions, basis, pen }) } Dialog::Insert(f) => crate::dialogs_insert::body(app, ui, f), Dialog::Refs(f) => crate::dialogs_refs::body(app, ui, f), diff --git a/crates/ui-egui/src/dialogs_table_style.rs b/crates/ui-egui/src/dialogs_table_style.rs new file mode 100644 index 000000000..b17d302e2 --- /dev/null +++ b/crates/ui-egui/src/dialogs_table_style.rs @@ -0,0 +1,706 @@ +//! Table Design › New / Modify Table Style (#714): one table style part at a time ("Apply +//! formatting to"), with its font, size, bold, italic, underline and colour, cell shading, borders +//! (line style, width and colour, and which edges) and cell alignment, over a live preview. OK +//! runs `table.newStyle` / `table.modifyStyle` with only what changed. + +use egui::{Rect, Sense, Stroke, Ui, pos2, vec2}; +use serde::Serialize; +use serde_json::{Value, json}; +use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, TableLook, Underline, VAlign}; +use wordcraft_doc::styles::{PartFormat, PartSet, TablePart, TableStyleProps}; +use wordcraft_doc::{CharProps, Rgb, TextColor}; +use wordcraft_engine::cmd::table_style::CELL_ALIGNS; + +use crate::WordApp; +use crate::dialogs::{Dialog, buttons, color_menu}; +use crate::theme::Tokens; + +/// One table style part in the dialog, as it resolves through the based-on chain: colours are +/// hex, empty for none. +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TableRegion { + pub font: String, + /// Points as typed; empty when the style doesn't set one. + pub size: String, + pub bold: bool, + pub italic: bool, + pub underline: bool, + pub color: String, + pub fill: String, + pub borders: Borders, + /// One of the `cellAlign` names (`topLeft`…), empty when the part doesn't set one. + pub align: String, +} + +/// The line the border buttons draw. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BorderPen { + pub style: BorderStyle, + pub width: f32, + /// Hex, empty for automatic. + pub color: String, +} + +impl Default for BorderPen { + fn default() -> Self { + BorderPen { style: BorderStyle::Single, width: 0.5, color: String::new() } + } +} + +impl BorderPen { + fn line(&self) -> Border { + Border { style: self.style, width: self.width, color: Rgb::parse(&self.color), space: 0.0 } + } +} + +/// The parts "Apply formatting to" offers, in its order. +pub const PARTS: [(TablePart, &str); 13] = [ + (TablePart::WholeTable, "Whole Table"), + (TablePart::FirstRow, "Header Row"), + (TablePart::LastRow, "Total Row"), + (TablePart::FirstCol, "First Column"), + (TablePart::LastCol, "Last Column"), + (TablePart::Band1Horz, "Odd Banded Rows"), + (TablePart::Band2Horz, "Even Banded Rows"), + (TablePart::Band1Vert, "Odd Banded Columns"), + (TablePart::Band2Vert, "Even Banded Columns"), + (TablePart::NwCell, "Top Left Cell"), + (TablePart::NeCell, "Top Right Cell"), + (TablePart::SwCell, "Bottom Left Cell"), + (TablePart::SeCell, "Bottom Right Cell"), +]; + +/// The index in [`PARTS`] of `part`. +pub fn part_index(part: TablePart) -> usize { + PARTS.iter().position(|(p, _)| *p == part).unwrap_or(0) +} + +/// The border picker's choices: label and the `edges` param. +const EDGES: [(&str, &str); 10] = [ + ("All Borders", "all"), + ("No Border", "none"), + ("Outside Borders", "outside"), + ("Inside Borders", "inside"), + ("Top Border", "top"), + ("Bottom Border", "bottom"), + ("Left Border", "left"), + ("Right Border", "right"), + ("Inside Horizontal Border", "insideH"), + ("Inside Vertical Border", "insideV"), +]; + +/// The alignment grid's tooltips, in `CELL_ALIGNS` order. +const ALIGN_TIPS: [&str; 9] = [ + "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", +]; + +/// The `cellAlign` name for a part's alignments, empty when it sets neither. +fn align_name(h: Option, v: Option) -> String { + if h.is_none() && v.is_none() { + return String::new(); + } + let (h, v) = (h.unwrap_or(Align::Left), v.unwrap_or(VAlign::Top)); + let h = if matches!(h, Align::Justify | Align::Distribute) { Align::Left } else { h }; + CELL_ALIGNS.iter().find(|(_, va, ha)| *va == v && *ha == h).map(|(n, _, _)| n.to_string()).unwrap_or_default() +} + +/// A size in points as the box shows it. +fn size_text(pt: f32) -> String { + if (pt - pt.round()).abs() < 0.01 { format!("{}", pt.round() as i64) } else { format!("{pt:.1}") } +} + +/// The parts of table style `id` as resolved through its based-on chain, in [`PARTS`] order. A +/// part's text shows the whole table's formatting with its own over it. +pub fn table_regions(app: &WordApp, id: &str) -> Box<[TableRegion; 13]> { + let Some(st) = app.session.doc.styles.table_style(id) else { return Default::default() }; + Box::new(PARTS.map(|(part, _)| region_of(&st, part))) +} + +fn region_of(st: &TableStyleProps, part: TablePart) -> TableRegion { + let f = st.part(part); + let mut c = st.chr.clone(); + c.overlay(&f.chr); + TableRegion { + font: c.font.clone().unwrap_or_default(), + size: c.size.map(size_text).unwrap_or_default(), + bold: c.bold.unwrap_or(false), + italic: c.italic.unwrap_or(false), + underline: c.underline.is_some_and(|u| u != Underline::None), + color: match c.color { + Some(TextColor::Rgb(c)) => c.hex(), + _ => String::new(), + }, + fill: f.fill.map(Rgb::hex).unwrap_or_default(), + borders: f.borders.unwrap_or_default(), + align: align_name(f.align, f.v_align), + } +} + +/// The params `table.newStyle` / `table.modifyStyle` take for the dialog's state: only what +/// changed from `basis`, part by part, so the rest stays inherited. +pub fn table_style_params(id: Option<&str>, name: &str, based_on: &str, regions: &[TableRegion; 13], basis: &[TableRegion; 13]) -> Value { + let mut v = json!({"name": name.trim(), "basedOn": based_on}); + for (((part, _), r), b) in PARTS.iter().zip(regions.iter()).zip(basis.iter()) { + let ch = region_changes(r, b); + if ch.as_object().is_some_and(|o| !o.is_empty()) { + v[part.ooxml()] = ch; + } + } + if let Some(id) = id { + v["style"] = json!(id); + } + v +} + +/// The params for what changed in one part. +pub fn region_changes(r: &TableRegion, basis: &TableRegion) -> Value { + let color = |c: &str| if c.is_empty() { Value::Null } else { json!(c) }; + let mut v = json!({}); + if r.font.trim() != basis.font.trim() && !r.font.trim().is_empty() { + v["font"] = json!(r.font.trim()); + } + if r.size.trim() != basis.size.trim() + && let Ok(pt) = r.size.trim().parse::() + && pt.is_finite() + && pt > 0.0 + { + v["size"] = json!(pt); + } + for (k, a, b) in [("bold", r.bold, basis.bold), ("italic", r.italic, basis.italic), ("underline", r.underline, basis.underline)] { + if a != b { + v[k] = json!(a); + } + } + if r.color != basis.color { + v["color"] = color(&r.color); + } + if r.fill != basis.fill { + v["fill"] = color(&r.fill); + } + if r.align != basis.align { + v["cellAlign"] = if r.align.is_empty() { Value::Null } else { json!(r.align) }; + } + let edges = + |b: &Borders| [("top", b.top), ("bottom", b.bottom), ("left", b.left), ("right", b.right), ("insideH", b.between), ("insideV", b.inside_v)]; + let changed: Vec = edges(&r.borders) + .into_iter() + .zip(edges(&basis.borders)) + .filter(|((_, a), (_, b))| a.filter(Border::is_visible) != b.filter(Border::is_visible)) + .map(|((e, a), _)| match a.filter(Border::is_visible) { + Some(l) => json!({"edges": e, "style": l.style.ooxml(), "width": l.width, "color": l.color.map(Rgb::hex)}), + None => json!({"edges": e, "style": "none"}), + }) + .collect(); + if !changed.is_empty() { + v["borders"] = Value::Array(changed); + } + v +} + +/// Apply the border picker's `edges` choice to `b` with `pen`: All, None, Outside and Inside set +/// their edges; a single edge toggles. +pub fn pick_edges(b: &mut Borders, edges: &str, pen: &BorderPen) { + let none = Border { style: BorderStyle::None, width: 0.0, color: None, space: 0.0 }; + let line = pen.line(); + let toggle = |e: &mut Option| *e = Some(if e.is_some_and(|x| x.is_visible()) { none } else { line }); + match edges { + "all" => *b = Borders::all(line), + "none" => *b = Borders::all(none), + "outside" => (b.top, b.bottom, b.left, b.right) = (Some(line), Some(line), Some(line), Some(line)), + "inside" => (b.between, b.inside_v) = (Some(line), Some(line)), + "top" => toggle(&mut b.top), + "bottom" => toggle(&mut b.bottom), + "left" => toggle(&mut b.left), + "right" => toggle(&mut b.right), + "insideH" => toggle(&mut b.between), + "insideV" => toggle(&mut b.inside_v), + _ => {} + } +} + +/// Whether `edges` are all drawn in `b` (the picker shows those choices checked). +fn edges_on(b: &Borders, edges: &str) -> bool { + let on = |e: Option| e.is_some_and(|x| x.is_visible()); + match edges { + "all" => [b.top, b.bottom, b.left, b.right, b.between, b.inside_v].into_iter().all(on), + "none" => !b.any_visible(), + "outside" => [b.top, b.bottom, b.left, b.right].into_iter().all(on), + "inside" => on(b.between) && on(b.inside_v), + "top" => on(b.top), + "bottom" => on(b.bottom), + "left" => on(b.left), + "right" => on(b.right), + "insideH" => on(b.between), + "insideV" => on(b.inside_v), + _ => false, + } +} + +/// A small square showing which edges a border choice draws: the drawn edges in the text colour, +/// the others as faint dotted guides. +fn edge_glyph(ui: &mut Ui, edges: &str) { + let t = Tokens::get(ui.ctx()); + let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover()); + let p = ui.painter_at(r); + let r = r.shrink(2.0); + let c = r.center(); + let segs = [ + ("top", [r.left_top(), r.right_top()]), + ("bottom", [r.left_bottom(), r.right_bottom()]), + ("left", [r.left_top(), r.left_bottom()]), + ("right", [r.right_top(), r.right_bottom()]), + ("insideH", [pos2(r.left(), c.y), pos2(r.right(), c.y)]), + ("insideV", [pos2(c.x, r.top()), pos2(c.x, r.bottom())]), + ]; + for (e, seg) in segs { + let on = match edges { + "all" => true, + "none" => false, + "outside" => matches!(e, "top" | "bottom" | "left" | "right"), + "inside" => matches!(e, "insideH" | "insideV"), + x => x == e, + }; + if on { + p.line_segment(seg, Stroke::new(1.5, t.icon)); + } else { + p.extend(egui::Shape::dotted_line(&seg, t.text_disabled, 2.5, 0.6)); + } + } +} + +/// One button of the alignment grid: a square with three short bars where the text sits. +fn align_button(ui: &mut Ui, (v, h): (VAlign, Align), on: bool, tip: &str) -> egui::Response { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); + let resp = resp.on_hover_text(tip); + let p = ui.painter_at(r); + let bg = if on { + t.checked + } else if resp.hovered() { + t.hover + } else { + t.input + }; + p.rect(r, 2.0, bg, Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside); + let inner = r.shrink(4.0); + let y0 = match v { + VAlign::Top => inner.top(), + VAlign::Center => inner.center().y - 4.0, + VAlign::Bottom => inner.bottom() - 8.0, + }; + for (i, w) in [10.0f32, 6.0, 10.0].into_iter().enumerate() { + let x0 = match h { + Align::Center => inner.center().x - w / 2.0, + Align::Right => inner.right() - w, + _ => inner.left(), + }; + p.rect_filled(Rect::from_min_size(pos2(x0, y0 + i as f32 * 3.0), vec2(w, 1.6)), 0.0, t.icon); + } + resp +} + +/// The table style options that show `part` in the preview. +fn look_for(mut look: TableLook, part: TablePart) -> TableLook { + match part { + TablePart::FirstRow => look.header_row = true, + TablePart::LastRow => look.total_row = true, + TablePart::FirstCol => look.first_column = true, + TablePart::LastCol => look.last_column = true, + TablePart::Band1Horz | TablePart::Band2Horz => look.banded_rows = true, + TablePart::Band1Vert | TablePart::Band2Vert => look.banded_columns = true, + TablePart::NwCell => (look.header_row, look.first_column) = (true, true), + TablePart::NeCell => (look.header_row, look.last_column) = (true, true), + TablePart::SwCell => (look.total_row, look.first_column) = (true, true), + TablePart::SeCell => (look.total_row, look.last_column) = (true, true), + TablePart::WholeTable => {} + } + look +} + +/// A small sample table drawn with `style` (5 columns, a header, three body rows and a total +/// row), showing the parts `look` turns on: the dialog's live preview. +pub fn table_style_preview(ui: &mut Ui, style: Option<&TableStyleProps>, look: TableLook) { + const COLS: usize = 5; + const ROWS: usize = 5; + const TEXT: [[&str; COLS]; ROWS] = [ + ["", "Mon", "Tue", "Wed", "Sum"], + ["North", "4", "7", "2", "13"], + ["South", "6", "1", "5", "12"], + ["West", "3", "8", "4", "15"], + ["Total", "13", "16", "11", "40"], + ]; + let (rect, _) = ui.allocate_exact_size(vec2(360.0, 130.0), Sense::hover()); + let painter = ui.painter_at(rect); + // The page is white whatever the interface theme. + painter.rect_filled(rect, 2.0, egui::Color32::WHITE); + let t = Tokens::get(ui.ctx()); + painter.rect_stroke(rect, 2.0, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + let Some(st) = style else { return }; + let formats: Vec = TablePart::ALL.iter().map(|p| st.part(*p)).collect(); + let table = rect.shrink2(vec2(14.0, 10.0)); + let (cw, rh) = (table.width() / COLS as f32, table.height() / ROWS as f32); + let rgb = |c: Rgb| egui::Color32::from_rgb(c.0, c.1, c.2); + let tb = st.parts.borders.unwrap_or_default(); + let band = st.parts.band_size.unwrap_or(1); + for (ri, row) in TEXT.iter().enumerate() { + for (ci, txt) in row.iter().enumerate() { + // Sample words in the interface language; numbers as they are. + let txt = if txt.chars().any(char::is_alphabetic) { tl!(*txt) } else { *txt }; + let cell = Rect::from_min_size(table.min + vec2(ci as f32 * cw, ri as f32 * rh), vec2(cw, rh)); + let mut fill = None; + let mut chr = CharProps::default(); + let (mut h, mut v) = (st.para.align, None); + let mut b = Borders { + top: if ri == 0 { tb.top } else { tb.between }, + bottom: if ri + 1 == ROWS { tb.bottom } else { tb.between }, + left: if ci == 0 { tb.left } else { tb.inside_v }, + right: if ci + 1 == COLS { tb.right } else { tb.inside_v }, + between: None, + inside_v: None, + }; + // Parts from lowest to highest precedence, as layout applies them. + for part in PartSet::of_cell(&look, band, (ri, ROWS), (ci, 1, COLS)).iter() { + let Some(f) = TablePart::ALL.iter().position(|p| *p == part).and_then(|i| formats.get(i)) else { continue }; + fill = f.fill.or(fill); + chr.overlay(&f.chr); + h = f.align.or(h); + v = f.v_align.or(v); + if part != TablePart::WholeTable + && let Some(rb) = f.borders + { + let rb = if part.is_row() { + Borders { + left: if ci == 0 { rb.left } else { rb.inside_v }, + right: if ci + 1 == COLS { rb.right } else { rb.inside_v }, + ..rb + } + } else if part.is_column() { + Borders { top: if ri == 0 { rb.top } else { rb.between }, bottom: if ri + 1 == ROWS { rb.bottom } else { rb.between }, ..rb } + } else { + rb + }; + b.overlay(&Borders { between: None, inside_v: None, ..rb }); + } + } + if let Some(f) = fill { + painter.rect_filled(cell, 0.0, rgb(f)); + } + let edge = |from: egui::Pos2, to: egui::Pos2, e: Option| { + if let Some(e) = e.filter(Border::is_visible) { + let color = e.color.map_or(egui::Color32::BLACK, rgb); + let stroke = Stroke::new(e.width.clamp(0.5, 3.0) * 1.3, color); + if matches!(e.style, BorderStyle::Dotted | BorderStyle::Dashed | BorderStyle::DotDash) { + painter.extend(egui::Shape::dashed_line(&[from, to], stroke, 3.0, 2.0)); + } else { + painter.line_segment([from, to], stroke); + } + } + }; + edge(cell.left_top(), cell.right_top(), b.top); + edge(cell.left_bottom(), cell.right_bottom(), b.bottom); + edge(cell.left_top(), cell.left_bottom(), b.left); + edge(cell.right_top(), cell.right_bottom(), b.right); + let color = match chr.color { + Some(TextColor::Rgb(c)) => rgb(c), + _ => egui::Color32::BLACK, + }; + let font = egui::FontId::proportional(11.0); + let (ax, x) = match h { + Some(Align::Center) => (egui::Align::Center, cell.center().x), + Some(Align::Right) => (egui::Align::Max, cell.right() - 4.0), + _ => (egui::Align::Min, cell.left() + 4.0), + }; + let (ay, y) = match v { + Some(VAlign::Center) => (egui::Align::Center, cell.center().y), + Some(VAlign::Bottom) => (egui::Align::Max, cell.bottom() - 2.0), + _ => (egui::Align::Min, cell.top() + 2.0), + }; + let pos = pos2(x, y); + let r = painter.text(pos, egui::Align2([ax, ay]), txt, font.clone(), color); + if chr.bold.unwrap_or(false) { + // A second pass a hair to the right reads as bold at this size. + painter.text(pos + vec2(0.6, 0.0), egui::Align2([ax, ay]), txt, font, color); + } + if chr.underline.is_some_and(|u| u != Underline::None) && !txt.is_empty() { + painter.line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(0.8, color)); + } + } + } +} + +/// The dialog's state. +pub struct Form<'a> { + pub id: &'a Option, + pub name: &'a mut String, + pub based_on: &'a mut String, + pub region: &'a mut usize, + pub regions: &'a mut Box<[TableRegion; 13]>, + pub basis: &'a mut Box<[TableRegion; 13]>, + pub pen: &'a mut BorderPen, +} + +/// The dialog body. Returns true to close. +pub fn body(app: &mut WordApp, ui: &mut Ui, f: Form) -> bool { + let Form { id, name, based_on, region, regions, basis, pen } = f; + let styles: Vec<(String, String)> = app + .session + .doc + .styles + .styles + .iter() + .filter(|s| s.kind == wordcraft_doc::StyleKind::Table && Some(&s.id) != id.as_ref()) + .map(|s| (s.id.clone(), s.name.clone())) + .collect(); + let base_name = styles.iter().find(|(i, _)| i == based_on).map(|(_, n)| n.clone()).unwrap_or_else(|| based_on.clone()); + let mut rebased = false; + egui::Grid::new("tstyle").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + ui.label(tl!("Name:")); + ui.text_edit_singleline(name); + ui.end_row(); + ui.label(tl!("Style based on:")); + egui::ComboBox::from_id_salt("tstyle_base").width(200.0).selected_text(base_name).show_ui(ui, |ui| { + for (sid, sname) in &styles { + if ui.selectable_label(sid == based_on, sname).clicked() && sid != based_on { + *based_on = sid.clone(); + rebased = true; + } + } + }); + ui.end_row(); + ui.label(tl!("Apply formatting to:")); + egui::ComboBox::from_id_salt("tstyle_region").width(200.0).selected_text(tl!(PARTS.get(*region).map_or("Whole Table", |(_, l)| *l))).show_ui( + ui, + |ui| { + for (i, (_, n)) in PARTS.iter().enumerate() { + ui.selectable_value(region, i, tl!(*n)); + } + }, + ); + ui.end_row(); + }); + // A new style shows its new base's formatting; a modified one keeps its own. + if rebased && id.is_none() { + *regions = table_regions(app, based_on); + *basis = regions.clone(); + } + ui.separator(); + let theme = app.session.doc.settings.theme_colors.clone(); + let fams = app.previews.families(); + let preview_fn = app.previews.font_preview_fn(); + if let Some(r) = regions.get_mut(*region) { + ui.label(egui::RichText::new(tl!("Formatting")).font(crate::theme::semibold(12.5))); + // Font, size, bold, italic, underline and text colour. + ui.horizontal(|ui| { + if let Some(f) = crate::widgets::font_combo(ui, "tstyle_font", 170.0, &r.font, &fams, Some(&*preview_fn)) + && f != crate::widgets::ADD_FONT + { + r.font = f; + } + ui.add(egui::TextEdit::singleline(&mut r.size).desired_width(36.0)).on_hover_text(tl!("Size:")); + ui.toggle_value(&mut r.bold, egui::RichText::new("B").strong()).on_hover_text(tl!("Bold")); + ui.toggle_value(&mut r.italic, egui::RichText::new("I").italics()).on_hover_text(tl!("Italic")); + ui.toggle_value(&mut r.underline, egui::RichText::new("U").underline()).on_hover_text(tl!("Underline")); + ui.push_id("tstyle_color", |ui| { + ui.label(tl!("Font color:")); + color_menu(ui, &theme, &mut r.color); + }); + }); + // Borders: the pen (style, width, colour) and which edges; then the cell shading. + ui.horizontal(|ui| { + let current = LINE_STYLES.iter().find(|(s, _)| *s == pen.style).map_or("Single", |(_, n)| *n); + ui.label(tl!("Style:")); + egui::ComboBox::from_id_salt("tstyle_pen_style").selected_text(tl!(current)).width(90.0).show_ui(ui, |ui| { + for (s, n) in LINE_STYLES { + ui.selectable_value(&mut pen.style, s, tl!(n)); + } + }); + ui.label(tl!("Width:")); + egui::ComboBox::from_id_salt("tstyle_pen_width") + .selected_text(crate::i18n::fmt(tl!("{n} pt"), &[("n", &pen.width.to_string())])) + .width(70.0) + .show_ui(ui, |ui| { + for w in WIDTHS { + ui.selectable_value(&mut pen.width, w, crate::i18n::fmt(tl!("{n} pt"), &[("n", &w.to_string())])); + } + }); + ui.push_id("tstyle_pen_color", |ui| { + ui.label(tl!("Color:")); + color_menu(ui, &theme, &mut pen.color); + }); + ui.menu_button(format!("{} ▾", tl!("Borders")), |ui| { + for (label, edges) in EDGES { + ui.horizontal(|ui| { + edge_glyph(ui, edges); + if ui.selectable_label(edges_on(&r.borders, edges), tl!(label)).clicked() { + pick_edges(&mut r.borders, edges, pen); + ui.close(); + } + }); + } + }); + }); + ui.horizontal(|ui| { + ui.push_id("tstyle_fill", |ui| { + ui.label(tl!("Fill color:")); + color_menu(ui, &theme, &mut r.fill); + }); + ui.add_space(12.0); + ui.label(tl!("Alignment:")); + egui::Grid::new("tstyle_align").spacing(vec2(1.0, 1.0)).show(ui, |ui| { + for (i, ((n, v, h), tip)) in CELL_ALIGNS.iter().zip(ALIGN_TIPS).enumerate() { + let on = r.align == *n; + if align_button(ui, (*v, *h), on, tl!(tip)).clicked() { + r.align = if on { String::new() } else { n.to_string() }; + } + if i % 3 == 2 { + ui.end_row(); + } + } + }); + }); + } + // Live preview: the style as OK would leave it, on the current table's options with the + // part being edited turned on. + let v = table_style_params(id.as_deref(), name, based_on, regions, basis); + let look = app + .session + .sel + .focus + .path + .cell() + .and_then(|(tp, _, _)| app.session.doc.table(app.session.sel.focus.story, &tp).map(|t| t.props.look)) + .unwrap_or_default(); + let look = look_for(look, PARTS.get(*region).map_or(TablePart::WholeTable, |(p, _)| *p)); + ui.add_space(4.0); + ui.label(egui::RichText::new(tl!("Preview")).small().weak()); + table_style_preview(ui, wordcraft_engine::cmd::table_style::preview(&app.session, &v).as_ref(), look); + let (ok, cancel) = buttons(ui, tl!("OK")); + if ok { + let cmd = if id.is_some() { "table.modifyStyle" } else { "table.newStyle" }; + if let Err(e) = app.run(cmd, v) { + app.status(e); + return false; + } + } + ok || cancel +} + +/// The border styles the pen offers (the Borders and Shading dialog's list). +const LINE_STYLES: [(BorderStyle, &str); 8] = [ + (BorderStyle::Single, "Single"), + (BorderStyle::Dotted, "Dotted"), + (BorderStyle::Dashed, "Dashed"), + (BorderStyle::DotDash, "Dot dash"), + (BorderStyle::Double, "Double"), + (BorderStyle::Triple, "Triple"), + (BorderStyle::Thick, "Thick"), + (BorderStyle::Wave, "Wave"), +]; + +const WIDTHS: [f32; 9] = [0.25, 0.5, 0.75, 1.0, 1.5, 2.25, 3.0, 4.5, 6.0]; + +/// The dialog for `name` (`newTableStyle` / `modifyTableStyle`), `None` when there's no table +/// style to modify. +pub fn open(app: &WordApp, name: &str) -> Option { + let current = current_table_style(app); + let (id, name, based_on, regions) = if name == "newTableStyle" { + let based_on = current.unwrap_or_else(|| "TableGrid".into()); + let styles = &app.session.doc.styles; + let name = (1..1000).map(|i| format!("Table Style {i}")).find(|n| styles.find(n).is_none()).unwrap_or_default(); + let regions = table_regions(app, &based_on); + (None, name, based_on, regions) + } else { + let id = current?; + let st = app.session.doc.styles.get(&id)?; + let (name, based_on) = (st.name.clone(), st.based_on.clone().unwrap_or_default()); + let regions = table_regions(app, &id); + (Some(id), name, based_on, regions) + }; + Some(Dialog::TableStyle { id, name, based_on, region: 0, basis: regions.clone(), regions, pen: BorderPen::default() }) +} + +/// The style of the table at the caret, when it is a table style. +fn current_table_style(app: &WordApp) -> Option { + let s = &app.session; + let (tp, _, _) = s.sel.focus.path.cell()?; + let id = s.doc.table(s.sel.focus.story, &tp)?.props.style.clone()?; + s.doc.styles.get(&id).filter(|st| st.kind == wordcraft_doc::StyleKind::Table).map(|st| st.id.clone()) +} + +#[cfg(test)] +mod tests { + use super::*; + use egui_kittest::kittest::Queryable; + use wordcraft_engine::Session; + + fn steps(h: &mut egui_kittest::Harness<'_, WordApp>) { + for _ in 0..4 { + h.step(); + } + } + + /// #714: picking a part under "Apply formatting to" and changing it edits that part (here the + /// even banded rows) and no other. + #[test] + fn the_part_picker_edits_the_part_it_shows() { + let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), crate::Services::default()); + app.run("insert.table", json!({"rows": 4, "cols": 3})).unwrap(); + app.run("table.newStyle", json!({"name": "Parts"})).unwrap(); + app.dialog = Dialog::open("modifyTableStyle", &mut app); + assert!(matches!(app.dialog, Some(Dialog::TableStyle { .. }))); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + steps(&mut h); + // The picker is a combo box showing the part being edited. + h.get_by(|n| n.role() == egui::accesskit::Role::ComboBox && n.value().as_deref() == Some("Whole Table")).click(); + steps(&mut h); + h.get_by_label("Even Banded Rows").click(); + steps(&mut h); + assert!(matches!(&h.state().dialog, Some(Dialog::TableStyle { region, .. }) if *region == part_index(TablePart::Band2Horz))); + h.get_by_label("B").click(); + steps(&mut h); + h.get_by_label("OK").click(); + steps(&mut h); + assert!(h.state().dialog.is_none(), "OK closes the dialog"); + let st = h.state().session.doc.styles.get("Parts").cloned().unwrap(); + assert_eq!(st.table_part(TablePart::Band2Horz).chr.bold, Some(true)); + for other in [TablePart::WholeTable, TablePart::Band1Horz, TablePart::FirstRow, TablePart::Band2Vert] { + assert_ne!(st.table_part(other).chr.bold, Some(true), "{}", other.ooxml()); + } + } + + /// The border picker sets and toggles edges with the pen, and only changed edges are sent. + #[test] + fn border_picker_sends_only_changed_edges() { + let pen = BorderPen { style: BorderStyle::Double, width: 1.5, color: "FF0000".into() }; + let basis = TableRegion::default(); + let mut r = basis.clone(); + pick_edges(&mut r.borders, "outside", &pen); + pick_edges(&mut r.borders, "left", &pen); + assert!(edges_on(&r.borders, "top") && !edges_on(&r.borders, "left") && !edges_on(&r.borders, "inside")); + r.align = "bottomRight".into(); + let v = region_changes(&r, &basis); + let edges: Vec<&str> = v["borders"].as_array().unwrap().iter().map(|e| e["edges"].as_str().unwrap()).collect(); + assert_eq!(edges, ["top", "bottom", "right"]); + assert_eq!(v["borders"][0]["style"], "double"); + assert_eq!(v["cellAlign"], "bottomRight"); + assert_eq!(align_name(Some(Align::Center), Some(VAlign::Bottom)), "bottomCenter"); + assert_eq!(align_name(None, None), ""); + } +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 7dcb27f7a..66d02b6cf 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2451,6 +2451,17 @@ Pastes the clipboard's picture. Поставя картината от клипборда. Region: Регион: Automatic ({region}) Автоматично ({region}) +# Table style editor: every table style part (#714) + Odd Banded Rows Нечетни редуващи се редове + Even Banded Rows Четни редуващи се редове + Odd Banded Columns Нечетни редуващи се колони + Even Banded Columns Четни редуващи се колони + Top Left Cell Горна лява клетка + Top Right Cell Горна дясна клетка + Bottom Left Cell Долна лява клетка + Bottom Right Cell Долна дясна клетка + Inside Horizontal Border Вътрешна хоризонтална граница + Inside Vertical Border Вътрешна вертикална граница Text of the nearest paragraph with a given style (running headers) Текст на най-близкия абзац с даден стил (текущи колонтитули) Style name: Име на стил: # Mail merge: filter and sort recipients (#778) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index d118ac5e8..02e380d82 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2467,6 +2467,16 @@ preview Title Ukázka Hindi Hindské Context Podle kontextu System Systémové + Odd Banded Rows Liché pruhované řádky + Even Banded Rows Sudé pruhované řádky + Odd Banded Columns Liché pruhované sloupce + Even Banded Columns Sudé pruhované sloupce + Top Left Cell Levá horní buňka + Top Right Cell Pravá horní buňka + Bottom Left Cell Levá dolní buňka + Bottom Right Cell Pravá dolní buňka + Inside Horizontal Border Vnitřní vodorovné ohraničení + Inside Vertical Border Vnitřní svislé ohraničení # Mail merge: filter and sort recipients (#778) Filter Recipients Filtrovat příjemce Sort Recipients Seřadit příjemce diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2c583170a..6a9f286f8 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2082,6 +2082,16 @@ Hindi Hindi Context Kontext System System + Odd Banded Rows Ungerade gestreifte Zeilen + Even Banded Rows Gerade gestreifte Zeilen + Odd Banded Columns Ungerade gestreifte Spalten + Even Banded Columns Gerade gestreifte Spalten + Top Left Cell Zelle oben links + Top Right Cell Zelle oben rechts + Bottom Left Cell Zelle unten links + Bottom Right Cell Zelle unten rechts + Inside Horizontal Border Innere horizontale Rahmenlinie + Inside Vertical Border Innere vertikale Rahmenlinie # Mail merge: filter and sort recipients (#778) Filter Recipients Empfänger filtern Sort Recipients Empfänger sortieren diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index ab187a76e..794cef7de 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2457,6 +2457,16 @@ preview Title Título Other Large Operators Otros operadores grandes Region: Región: Automatic ({region}) Automático ({region}) + Odd Banded Rows Filas impares con bandas + Even Banded Rows Filas pares con bandas + Odd Banded Columns Columnas impares con bandas + Even Banded Columns Columnas pares con bandas + Top Left Cell Celda superior izquierda + Top Right Cell Celda superior derecha + Bottom Left Cell Celda inferior izquierda + Bottom Right Cell Celda inferior derecha + Inside Horizontal Border Borde horizontal interior + Inside Vertical Border Borde vertical interior Text of the nearest paragraph with a given style (running headers) Texto del párrafo más cercano con un estilo dado (encabezados variables) Style name: Nombre del estilo: # Mail merge: filter and sort recipients (#778) diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 7f0fa3036..b37c9ac9d 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -2125,6 +2125,16 @@ AutoFit Column by Double-Clicking Veeru automaatne sobitus topeltklõpsuga Region: Piirkond: Automatic ({region}) Automaatne ({region}) + Odd Banded Rows Paaritud vahelduvad read + Even Banded Rows Paaris vahelduvad read + Odd Banded Columns Paaritud vahelduvad veerud + Even Banded Columns Paaris vahelduvad veerud + Top Left Cell Ülemine vasak lahter + Top Right Cell Ülemine parem lahter + Bottom Left Cell Alumine vasak lahter + Bottom Right Cell Alumine parem lahter + Inside Horizontal Border Sisemine horisontaalne ääris + Inside Vertical Border Sisemine vertikaalne ääris Text of the nearest paragraph with a given style (running headers) Antud stiiliga lähima lõigu tekst (jooksvad päised) Style name: Stiili nimi: # Mail merge: filter and sort recipients (#778) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 396c63f5c..78f281359 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2080,6 +2080,16 @@ Reading Column Width Largeur de colonne de lecture Reading Line Spacing Interligne de lecture Reading Text Size Taille du texte de lecture + Odd Banded Rows Lignes à bandes impaires + Even Banded Rows Lignes à bandes paires + Odd Banded Columns Colonnes à bandes impaires + Even Banded Columns Colonnes à bandes paires + Top Left Cell Cellule en haut à gauche + Top Right Cell Cellule en haut à droite + Bottom Left Cell Cellule en bas à gauche + Bottom Right Cell Cellule en bas à droite + Inside Horizontal Border Bordure intérieure horizontale + Inside Vertical Border Bordure intérieure verticale Filter Recipients Filtrer les destinataires Sort Recipients Trier les destinataires Filter and Sort Filtrer et trier diff --git a/crates/ui-egui/src/i18n/hr.tsv b/crates/ui-egui/src/i18n/hr.tsv index a15662410..2fda6e192 100644 --- a/crates/ui-egui/src/i18n/hr.tsv +++ b/crates/ui-egui/src/i18n/hr.tsv @@ -2621,3 +2621,14 @@ preview Title Naslov # Reading Mode column widths (#1040) Narrow Usko Medium Srednje +# Table style parts in the Modify Table Style dialog (#714, added while landing #887) + Odd Banded Rows Neparni prugasti retci + Even Banded Rows Parni prugasti retci + Odd Banded Columns Neparni prugasti stupci + Even Banded Columns Parni prugasti stupci + Top Left Cell Gornja lijeva ćelija + Top Right Cell Gornja desna ćelija + Bottom Left Cell Donja lijeva ćelija + Bottom Right Cell Donja desna ćelija + Inside Horizontal Border Unutarnji vodoravni obrub + Inside Vertical Border Unutarnji okomiti obrub diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 101d08e13..73d3f752e 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2461,6 +2461,16 @@ Reading Column Width Olvasási oszlopszélesség Reading Line Spacing Olvasási sorköz Reading Text Size Olvasási szövegméret + Odd Banded Rows Páratlan sávos sorok + Even Banded Rows Páros sávos sorok + Odd Banded Columns Páratlan sávos oszlopok + Even Banded Columns Páros sávos oszlopok + Top Left Cell Bal felső cella + Top Right Cell Jobb felső cella + Bottom Left Cell Bal alsó cella + Bottom Right Cell Jobb alsó cella + Inside Horizontal Border Belső vízszintes szegély + Inside Vertical Border Belső függőleges szegély Filter Recipients Címzettek szűrése Sort Recipients Címzettek rendezése Filter and Sort Szűrés és rendezés diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index dd516a0ba..0edbd298a 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2458,6 +2458,16 @@ preview Title Esempio Hindi Hindi Context Contesto System Sistema + Odd Banded Rows Righe alternate dispari + Even Banded Rows Righe alternate pari + Odd Banded Columns Colonne alternate dispari + Even Banded Columns Colonne alternate pari + Top Left Cell Cella in alto a sinistra + Top Right Cell Cella in alto a destra + Bottom Left Cell Cella in basso a sinistra + Bottom Right Cell Cella in basso a destra + Inside Horizontal Border Bordo orizzontale interno + Inside Vertical Border Bordo verticale interno # Mail merge: filter and sort recipients (#778) Filter Recipients Filtra destinatari Sort Recipients Ordina destinatari diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index e002a731e..9c92f9fcf 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2091,6 +2091,16 @@ AutoFit Column by Double-Clicking ダブルクリックで列の幅を自動調整 Region: 地域: Automatic ({region}) 自動 ({region}) + Odd Banded Rows 奇数の縞模様(行) + Even Banded Rows 偶数の縞模様(行) + Odd Banded Columns 奇数の縞模様(列) + Even Banded Columns 偶数の縞模様(列) + Top Left Cell 左上のセル + Top Right Cell 右上のセル + Bottom Left Cell 左下のセル + Bottom Right Cell 右下のセル + Inside Horizontal Border 内側の横罫線 + Inside Vertical Border 内側の縦罫線 Text of the nearest paragraph with a given style (running headers) 指定したスタイルを持つ最も近い段落の文字列 (柱) Style name: スタイル名: # Mail merge: filter and sort recipients (#778) diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 27fdcb428..96002d803 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2477,3 +2477,14 @@ preview Title 제목 # Reading Mode column widths (#1040) Narrow 좁게 Medium 보통 +# Table style parts in the Modify Table Style dialog (#714, added while landing #887) + Odd Banded Rows 홀수 줄무늬 행 + Even Banded Rows 짝수 줄무늬 행 + Odd Banded Columns 홀수 줄무늬 열 + Even Banded Columns 짝수 줄무늬 열 + Top Left Cell 왼쪽 위 셀 + Top Right Cell 오른쪽 위 셀 + Bottom Left Cell 왼쪽 아래 셀 + Bottom Right Cell 오른쪽 아래 셀 + Inside Horizontal Border 안쪽 가로 테두리 + Inside Vertical Border 안쪽 세로 테두리 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 50ed83ac3..dd9969a3b 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2100,6 +2100,16 @@ Hindi Hindi Context Kontekstowe System Systemowe + Odd Banded Rows Nieparzyste wiersze naprzemienne + Even Banded Rows Parzyste wiersze naprzemienne + Odd Banded Columns Nieparzyste kolumny naprzemienne + Even Banded Columns Parzyste kolumny naprzemienne + Top Left Cell Lewa górna komórka + Top Right Cell Prawa górna komórka + Bottom Left Cell Lewa dolna komórka + Bottom Right Cell Prawa dolna komórka + Inside Horizontal Border Wewnętrzna krawędź pozioma + Inside Vertical Border Wewnętrzna krawędź pionowa # Mail merge: filter and sort recipients (#778) Filter Recipients Filtruj adresatów Sort Recipients Sortuj adresatów diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 689d66239..5e4499591 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2103,6 +2103,16 @@ AutoFit Column by Double-Clicking Autoajustar coluna com clique duplo Region: Região: Automatic ({region}) Automático ({region}) + Odd Banded Rows Linhas listradas ímpares + Even Banded Rows Linhas listradas pares + Odd Banded Columns Colunas listradas ímpares + Even Banded Columns Colunas listradas pares + Top Left Cell Célula superior esquerda + Top Right Cell Célula superior direita + Bottom Left Cell Célula inferior esquerda + Bottom Right Cell Célula inferior direita + Inside Horizontal Border Borda horizontal interna + Inside Vertical Border Borda vertical interna Text of the nearest paragraph with a given style (running headers) Texto do parágrafo mais próximo com um determinado estilo (cabeçalhos dinâmicos) Style name: Nome do estilo: # Mail merge: filter and sort recipients (#778) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 9ff70bd02..a9c41168b 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2474,6 +2474,16 @@ preview Title Пример AutoFit Column by Double-Clicking Автоподбор ширины столбца двойным щелчком Region: Регион: Automatic ({region}) Автоматически ({region}) + Odd Banded Rows Нечётные чередующиеся строки + Even Banded Rows Чётные чередующиеся строки + Odd Banded Columns Нечётные чередующиеся столбцы + Even Banded Columns Чётные чередующиеся столбцы + Top Left Cell Левая верхняя ячейка + Top Right Cell Правая верхняя ячейка + Bottom Left Cell Левая нижняя ячейка + Bottom Right Cell Правая нижняя ячейка + Inside Horizontal Border Внутренняя горизонтальная граница + Inside Vertical Border Внутренняя вертикальная граница Text of the nearest paragraph with a given style (running headers) Текст ближайшего абзаца с заданным стилем (живые колонтитулы) Style name: Имя стиля: # Mail merge: filter and sort recipients (#778) diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 8c8128909..81d9f4f4a 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -2101,6 +2101,16 @@ AutoFit Column by Double-Clicking Automatsko uklapanje kolone dvostrukim klikom Region: Region: Automatic ({region}) Automatski ({region}) + Odd Banded Rows Neparni trakasti redovi + Even Banded Rows Parni trakasti redovi + Odd Banded Columns Neparne trakaste kolone + Even Banded Columns Parne trakaste kolone + Top Left Cell Gornja leva ćelija + Top Right Cell Gornja desna ćelija + Bottom Left Cell Donja leva ćelija + Bottom Right Cell Donja desna ćelija + Inside Horizontal Border Unutrašnja horizontalna ivica + Inside Vertical Border Unutrašnja vertikalna ivica Text of the nearest paragraph with a given style (running headers) Tekst najbližeg pasusa sa zadatim stilom (tekuća zaglavlja) Style name: Naziv stila: # Mail merge: filter and sort recipients (#778) diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 64b920410..42bbeaf7b 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2105,6 +2105,16 @@ AutoFit Column by Double-Clicking Аутоматско уклапање колоне двоструким кликом Region: Регион: Automatic ({region}) Аутоматски ({region}) + Odd Banded Rows Непарни тракасти редови + Even Banded Rows Парни тракасти редови + Odd Banded Columns Непарне тракасте колоне + Even Banded Columns Парне тракасте колоне + Top Left Cell Горња лева ћелија + Top Right Cell Горња десна ћелија + Bottom Left Cell Доња лева ћелија + Bottom Right Cell Доња десна ћелија + Inside Horizontal Border Унутрашња хоризонтална ивица + Inside Vertical Border Унутрашња вертикална ивица Text of the nearest paragraph with a given style (running headers) Текст најближег пасуса са задатим стилом (текућа заглавља) Style name: Назив стила: # Mail merge: filter and sort recipients (#778) diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index fe6f14673..6052c9a23 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2636,3 +2636,14 @@ preview Title Titel # Reading Mode column widths (#1040) Narrow Smal Medium Medel +# Table style parts in the Modify Table Style dialog (#714, added while landing #887) + Odd Banded Rows Udda randade rader + Even Banded Rows Jämna randade rader + Odd Banded Columns Udda randade kolumner + Even Banded Columns Jämna randade kolumner + Top Left Cell Övre vänstra cellen + Top Right Cell Övre högra cellen + Bottom Left Cell Nedre vänstra cellen + Bottom Right Cell Nedre högra cellen + Inside Horizontal Border Inre vågrät kantlinje + Inside Vertical Border Inre lodrät kantlinje diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index c3fef9852..8ace2dd7b 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2473,6 +2473,16 @@ preview Title Örnek AutoFit Column by Double-Clicking Sütunu Çift Tıklayarak Otomatik Sığdır Region: Bölge: Automatic ({region}) Otomatik ({region}) + Odd Banded Rows Tek Şeritli Satırlar + Even Banded Rows Çift Şeritli Satırlar + Odd Banded Columns Tek Şeritli Sütunlar + Even Banded Columns Çift Şeritli Sütunlar + Top Left Cell Sol Üst Hücre + Top Right Cell Sağ Üst Hücre + Bottom Left Cell Sol Alt Hücre + Bottom Right Cell Sağ Alt Hücre + Inside Horizontal Border İç Yatay Kenarlık + Inside Vertical Border İç Dikey Kenarlık Text of the nearest paragraph with a given style (running headers) Belirli bir stile sahip en yakın paragrafın metni (değişken üstbilgiler) Style name: Stil adı: # Mail merge: filter and sort recipients (#778) diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 0f80f53ab..ca2070d95 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2103,6 +2103,16 @@ AutoFit Column by Double-Clicking Добір ширини стовпця подвійним клацанням Region: Регіон: Automatic ({region}) Автоматично ({region}) + Odd Banded Rows Непарні смугасті рядки + Even Banded Rows Парні смугасті рядки + Odd Banded Columns Непарні смугасті стовпці + Even Banded Columns Парні смугасті стовпці + Top Left Cell Ліва верхня клітинка + Top Right Cell Права верхня клітинка + Bottom Left Cell Ліва нижня клітинка + Bottom Right Cell Права нижня клітинка + Inside Horizontal Border Внутрішня горизонтальна межа + Inside Vertical Border Внутрішня вертикальна межа Text of the nearest paragraph with a given style (running headers) Текст найближчого абзацу із заданим стилем (живі колонтитули) Style name: Назва стилю: # Mail merge: filter and sort recipients (#778) diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 0e54b32dd..c1d28add3 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2636,6 +2636,17 @@ # Missing dictionary on the status bar (#1441) No spelling dictionary for {language} is installed, so it isn't checked. WordCraft looks for the Hunspell dictionary {name} in the WORDCRAFT_DICTIONARIES folder and in the usual system locations. Chưa cài từ điển chính tả cho {language}, nên ngôn ngữ này không được kiểm tra. WordCraft tìm từ điển Hunspell {name} trong thư mục WORDCRAFT_DICTIONARIES và ở các vị trí thông thường của hệ thống. {language} (no dictionary) {language} (không có từ điển) +# Table style parts in the Modify Table Style dialog (#714) + Odd Banded Rows Hàng xen kẽ lẻ + Even Banded Rows Hàng xen kẽ chẵn + Odd Banded Columns Cột xen kẽ lẻ + Even Banded Columns Cột xen kẽ chẵn + Top Left Cell Ô trên cùng bên trái + Top Right Cell Ô trên cùng bên phải + Bottom Left Cell Ô dưới cùng bên trái + Bottom Right Cell Ô dưới cùng bên phải + Inside Horizontal Border Viền ngang bên trong + Inside Vertical Border Viền dọc bên trong # Load system installed fonts on the web (#1390) Load System Installed Fonts… Tải phông chữ đã cài trên hệ thống… All system fonts are already available. Tất cả phông chữ hệ thống đã có sẵn. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index bdc508598..07b0861da 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2103,6 +2103,16 @@ AutoFit Column by Double-Clicking 双击自动调整列宽 Region: 区域: Automatic ({region}) 自动({region}) + Odd Banded Rows 奇数镶边行 + Even Banded Rows 偶数镶边行 + Odd Banded Columns 奇数镶边列 + Even Banded Columns 偶数镶边列 + Top Left Cell 左上单元格 + Top Right Cell 右上单元格 + Bottom Left Cell 左下单元格 + Bottom Right Cell 右下单元格 + Inside Horizontal Border 内部横框线 + Inside Vertical Border 内部竖框线 Text of the nearest paragraph with a given style (running headers) 具有指定样式的最近段落的文本(书眉) Style name: 样式名称: # Mail merge: filter and sort recipients (#778) diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index db17f4a83..7b75dc978 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2092,6 +2092,16 @@ AutoFit Column by Double-Clicking 按兩下自動調整欄寬 Region: 地區: Automatic ({region}) 自動({region}) + Odd Banded Rows 奇數帶狀列 + Even Banded Rows 偶數帶狀列 + Odd Banded Columns 奇數帶狀欄 + Even Banded Columns 偶數帶狀欄 + Top Left Cell 左上儲存格 + Top Right Cell 右上儲存格 + Bottom Left Cell 左下儲存格 + Bottom Right Cell 右下儲存格 + Inside Horizontal Border 內部水平框線 + Inside Vertical Border 內部垂直框線 Text of the nearest paragraph with a given style (running headers) 具有指定樣式的最近段落文字(書眉) Style name: 樣式名稱: # Mail merge: filter and sort recipients (#778) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 81e7b9c9a..6fd52cb76 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -47,6 +47,7 @@ pub mod dialogs_refs; pub mod dialogs_review; pub mod dialogs_ribbon; pub mod dialogs_smart_art; +pub mod dialogs_table_style; pub mod dialogs_templates; pub mod equation_tab; /// The built-in file browser, for desktops without a system file dialog (#576). @@ -1309,6 +1310,12 @@ impl WordApp { } "ui.dialog" => { self.dialog = dialogs::Dialog::open(s("name").unwrap_or(""), self); + // New / Modify Table Style can open on a part (`"part": "firstRow"`…). + if let Some(dialogs::Dialog::TableStyle { region, .. }) = &mut self.dialog + && let Some(part) = s("part").and_then(wordcraft_engine::cmd::table_style::part_named) + { + *region = dialogs_table_style::part_index(part); + } json!({"dialog": self.dialog.as_ref().map(|d| d.name())}) } "ui.closeDialog" => { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 36c035d96..409e24005 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-14 · **Change:** trivial (VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously 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-11 · **Last updated:** 2026-10-14 · **Change:** trivial (every table style part round-trips, #714); previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously 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; @@ -65,6 +65,8 @@ 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); 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), every `w:tblStylePr` part (wholeTable, firstRow, lastRow, firstCol, lastCol, band1/2Vert, band1/2Horz, ne/nw/se/swCell) with run formatting, `w:jc`, cell borders, shading and `w:vAlign` (#714); right-to-left tables (`w:bidiVisual`) and sections (`w:bidi`) round-trip (#362) | | Text, runs, paragraph and character properties, styles, numbering, sections | ✅ | ✅ | Including a Styles gallery for files that mark no style `w:qFormat` (LibreOffice: Word's usual quick styles are added, #1238), 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 | ✅ | ✅ | Row table border exceptions (`w:tblPrEx/w:tblBorders`) round-trip (`row_table_borders_round_trip`); other row table exceptions are not preserved. 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 | ✅ | ✅ | | @@ -243,6 +245,7 @@ attributes inside stories and style or list definitions; a regenerated theme. Te | 2026-10-12 | trivial | Restrict Editing: forms mode (`w:edit="forms"`) fills content controls — edits inside fillable controls run and everything else rolls back; `review.restrict` reports `formFields`; Find Next Region covers form fields; a save/reopen keeps `w:edit="forms"`, the filled value and the password guard; under active protection, content-control lock-flag property changes are refused, as Word blocks property edits until Stop Protection (#1077) | | 2026-10-12 | trivial | Paste keeps the formatting of HTML and RTF on the system clipboard; Paste Special lists them and pictures (#687, #833) | | 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-12 | trivial | All 13 table style conditional formatting parts round-trip with alignment (#714) | | 2026-10-13 | trivial | Print to PDF and PNG export replace an existing file only after the new one is fully written, as document saves do (#1355) | | 2026-10-12 | minor | RTF reads 8-bit text in the document's or the font's code page (`\ansicpg`, `\fcharset`, `\cpg`) instead of always Windows-1252; a `.doc`/`.docx`/`.rtf` whose content is another of these formats opens by signature. Found on a private corpus of real Russian documents: 38 of 44 `.doc` files were RTF, all now open with their Cyrillic text | | 2026-10-12 | trivial | `w:sym` and symbol-font runs keep their stored characters on save; TXT export and copy write their Unicode equivalents (#776) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 1e36f3c41..6b9cb0333 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-14 · **Change:** trivial (a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously 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-14 · **Change:** trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously 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 @@ -18,6 +18,13 @@ Document title opens Rename Document; the account badge opens Options › Genera | 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 | | Quick Access Toolbar | AutoSave, then the user's commands (Save, Undo, Redo by default); add, remove and reorder from its … menu (common commands, More Commands…), right-click on any ribbon button, or Customize Ribbon; saved per user (#379, #376). Undo's ▾ list: recent steps newest first, hover highlights the steps a click undoes ("Undo 3 Actions"), one click undoes them all (#701; `edit.undoTo`, `edit.undoHistory`). Not shown below the ribbon | customizable | 90% | 0.5 | | Customize Ribbon / Customize Keyboard | Customize Keyboard (#368): categories, a command's keys, press a combination to see which command uses it, Assign / Remove / Reset All; custom keys override built-ins, removed built-ins do nothing, saved per user; `tools.customizeKeyboard` for agents. Customize Ribbon (#379): show, hide and reorder tabs, custom tabs and custom groups (on any tab) holding any commands, rename, remove, Reset; right-click on the ribbon; saved per user; `tools.customizeRibbon` for agents. Built-in tabs can't be renamed; no import/export file | ✅ | 85% | 1–2 | +| Keyboard shortcuts | **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 (every part: font, borders, shading, cell alignment, #714), 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 | +| 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); Alt+Q to "Tell me" command search (#819, not on macOS). 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 | +| Modal dialogs | **55** (`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); Cross-reference (#916); Line Numbers, Hyphenation with Manual Hyphenation, Language, Envelopes, Labels (#407); Page Setup has a Paper size list and custom width and height, opened from Layout › Size › More Paper Sizes… (#1433)) plus panes and menus for the rest | ~100 (Options panes…) | ~45% | 11–19 | +| Keyboard shortcuts | **152** commands bound in `CommandSpec::key`, 172 key combinations (counted from the registry on Linux, 2026-10-13); styles, spacing, symbols, fields, views, mail merge (#330); field keys Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, Shift+F11 (#339), Next Field on Alt+F1 and Alt+Shift+F1 (Word's other keys for it) since F11 went to full screen (#980; GNOME and KDE may keep Alt+F1 for their own overview or application menu, and then Next Field has no key there; Previous Field keeps Shift+F11); 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); Alt+Q to "Tell me" command search (#819, not on macOS). Missing: en dash on the numeric keypad | ~250 default shortcuts | ~70% | 2–4 | | Keyboard shortcuts | **152** commands bound in `CommandSpec::key`, 172 key combinations (counted from the registry on Linux, 2026-10-13); styles, spacing, symbols, fields, views, mail merge (#330); field keys Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, Shift+F11 (#339), Next Field on Alt+F1 and Alt+Shift+F1 (Word's other keys for it) since F11 went to full screen (#980; GNOME and KDE may keep Alt+F1 for their own overview or application menu, and then Next Field has no key there; Previous Field keeps Shift+F11); 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); Alt+Q to "Tell me" command search (#819, not on macOS); on macOS, Word for Mac's own keys: ⌘⇧S Save As, ⌘⌥E Insert Endnote, ⌘⌥A New Comment, ⌃H Replace, ⌘⌥R collapse the ribbon. Missing: en dash on the numeric keypad Insert Hyperlink has Word's Link to: Existing File or Web Page / Place in This Document (top of the document, headings, bookmarks) (#1234). | ~250 default shortcuts | ~70% | 2–4 | | Modal dialogs | **56** (`Dialog` enum: Discard Recovery, 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); Cross-reference (#916); Line Numbers, Hyphenation with Manual Hyphenation, Language, Envelopes, Labels (#407); Page Setup has a Paper size list and custom width and height, opened from Layout › Size › More Paper Sizes… (#1433)) 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 (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 | @@ -75,6 +82,7 @@ Document title opens Rename Document; the account badge opens Options › Genera | 2026-10-13 | trivial | Clicks on ribbon and status-bar chrome that run no command button (Styles and style-set gallery tiles, tab names, empty ribbon space, View check boxes, the status bar's view buttons and language menu) no longer take keyboard focus from the page: the caret stays and typing continues, as in Word; text fields such as the font size box still take focus. Tabs, gallery tiles and the status bar's view buttons report their name and state to screen readers (#1093) | | 2026-10-13 | trivial | Interface size: a chosen size is absolute ("100%" is one screen pixel per point, even where the system reports 200 % under a bare window manager) and the new default, Auto, keeps the display scale and shows the size it draws at; 75–300 %; an old saved size becomes the size it drew at; on the web a chosen size cancels browser zoom, Auto follows it (#822) | | 2026-10-13 | trivial | Copy and Cut of a selection holding an equation put HTML with the equation as MathML on the system clipboard beside the text (desktop, through arboard), as MathML in HTML on the clipboard (Word/LibreOffice paste not yet verified; OMML is #1297) (#816). Only copies containing an equation get HTML. Web: text only | +| 2026-10-12 | trivial | New/Modify Table Style: Apply formatting to any of the 13 table style parts (whole table, header/total row, first/last column, odd/even row and column bands, four corner cells), each with font, size, bold, italic, underline, colour, fill, borders (line style, width, colour; all/none/outside/inside/each edge) and the 9-way cell alignment, over a live preview (#714) | | 2026-10-14 | trivial | Save Changes prompt on the web: Save before New/Open holds the command back until the browser's save picker has written and closed the file; a cancelled or failed picker save keeps the document and brings the prompt back with the reason (#1315) | | 2026-10-14 | trivial | Inspect Document (#1344): hidden text found by effective formatting (direct, character style, paragraph style) in the body, headers, footers, notes, comments and text boxes; Remove All deletes it all in one undo step (whole hidden paragraphs with their marks, and the notes of hidden references) and reports a `residual` list, including tables whose style hides text | | 2026-10-14 | trivial | Alignment guides while moving pictures, shapes and text boxes: WordCraft's own design (snapping to the page, margins and other objects, thin lines while dragging, Alt moves freely, `ui.alignmentGuides`); 25% → 50% | From e127a080a162eb40d7cc208c73421573399e8f3b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 15 Oct 2026 01:14:53 -0400 Subject: [PATCH 096/101] CI: drop a duplicate contributor entry in people.toml #1708 merged with main added [people.finwarman] a second time; the build then fell back to usernames only. Co-authored-by: Claude Opus 5.5 --- contributors/people.toml | 3 --- 1 file changed, 3 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 2749e0f3f..da86d62d2 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -131,6 +131,3 @@ sync_github_name = false [people.rbr48] sync_github_name = true - -[people.finwarman] -real_name = "Fin Warman" From 18b680c9aa8233389da33bf7b33226922e65ecef Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 15 Oct 2026 01:26:06 -0400 Subject: [PATCH 097/101] Recovery: a record lock held for a moment is waited out, not skipped new_draft_restarts_restores_unsaved_and_explicitly_discards failed in a workspace run: the reopened session found no recovery record, because a process another test was starting held the record's lock for a moment (a forked child shares it until exec) and scan() skips locked records. The same can happen to a user when the app starts a helper process. lock() now tries again up to ten times, 10 ms apart, before treating the record as owned by a live instance. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/recovery/desktop.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/engine/src/recovery/desktop.rs b/crates/engine/src/recovery/desktop.rs index 1480363ae..676a6ed0c 100644 --- a/crates/engine/src/recovery/desktop.rs +++ b/crates/engine/src/recovery/desktop.rs @@ -14,6 +14,8 @@ const MAX_BYTES: u64 = 256 << 20; const MAX_RECORDS: usize = 128; const MAX_FILES: usize = 16; const DISK_BUDGET: u64 = 2 << 30; +/// Times a record lock held for a moment (by a process being started) is tried again. +const LOCK_RETRIES: u32 = 10; #[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -127,11 +129,21 @@ impl Store { paths.sort_by_key(|p| std::cmp::Reverse(generation(p))); Ok(paths) } + /// Locks a record's `owner.lock`, retrying [`LOCK_RETRIES`] times 10 ms apart. fn lock(&self, id: &str) -> Result { if !id_ok(id) { return Err("invalid recovery id".into()); } let file = private_file(&self.root.join(id).join("owner.lock"), false).map_err(|e| e.to_string())?; + // A process another part of the app (or a test) is starting holds the lock for a moment + // (a forked child shares it until it execs): wait that out, but not a live owner. + for _ in 0..LOCK_RETRIES { + match file.try_lock() { + Ok(()) => return Ok(file), + Err(std::fs::TryLockError::WouldBlock) => std::thread::sleep(Duration::from_millis(10)), + Err(e) => return Err(format!("recovery copy is in use or cannot be locked: {e}")), + } + } file.try_lock().map_err(|e| format!("recovery copy is in use or cannot be locked: {e}"))?; Ok(file) } From dac8c6d6c72cadecae75212d726a534103a15405 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Thu, 15 Oct 2026 07:26:42 +0200 Subject: [PATCH 098/101] Table style review follow-ups: No Border on inherited parts, cell Top beats the style (#1288) (#1296) part that inherits its lines sends it, and the picker only shows No Border checked for it. - CellProps::valign is Option: a cell's own Top (Align Top, w:vAlign="top") beats a table style part's vertical alignment, and round-trips through .docx. - tableStyle edit rejects Part fields at the top level without "part". - "font": null or "" makes a part's font inherited; clearing the dialog's font box sends it. - Document ui.dialog's optional "part" in docs/control-protocol.md. Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/doc/src/props.rs | 5 +- crates/docbin/src/table.rs | 2 +- crates/docx/src/read/props.rs | 4 +- crates/docx/src/write/props.rs | 7 +- crates/docx/tests/roundtrip.rs | 2 +- crates/engine/src/cmd/mailings.rs | 2 +- crates/engine/src/cmd/table.rs | 4 +- crates/engine/src/cmd/table_style.rs | 22 ++++-- crates/engine/src/tests.rs | 49 +++++++++++++- crates/layout/src/table.rs | 3 +- crates/ui-egui/src/dialogs.rs | 4 +- crates/ui-egui/src/dialogs_table_style.rs | 82 ++++++++++++++++++++--- docs/control-protocol.md | 4 +- docs/file-format-parity.md | 3 +- docs/ui-parity.md | 1 + 15 files changed, 164 insertions(+), 30 deletions(-) diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index f6a5fcb8e..5bd175258 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -1127,7 +1127,10 @@ pub struct CellProps { pub vmerge: VMerge, pub shading: Option, pub borders: Option, - pub valign: VAlign, + /// Vertical alignment (`w:vAlign`); `None` follows the table style, else top. An explicit + /// `Top` beats the style. + #[serde(skip_serializing_if = "Option::is_none")] + pub valign: Option, /// Cell margins override (top, left, bottom, right), points. pub margins: Option<[f32; 4]>, /// Which way the cell's text runs. diff --git a/crates/docbin/src/table.rs b/crates/docbin/src/table.rs index d79e8812d..b8ac34220 100644 --- a/crates/docbin/src/table.rs +++ b/crates/docbin/src/table.rs @@ -263,7 +263,7 @@ pub(crate) fn assemble(paras: Vec) -> Blocks { c.props.shading = *s; } if let Some((_, v)) = info.valign.iter().find(|(k, _)| *k == i) { - c.props.valign = *v; + c.props.valign = Some(*v); } } for &(a, b) in info.merges.iter().take(MAX_COLS) { diff --git a/crates/docx/src/read/props.rs b/crates/docx/src/read/props.rs index 6717d2637..7547211ed 100644 --- a/crates/docx/src/read/props.rs +++ b/crates/docx/src/read/props.rs @@ -563,11 +563,11 @@ pub fn tcpr(pc: &PropCtx, e: &El) -> (CellProps, bool) { c.text_direction_set = k.attr("w:val") == Some("lrTb"); } "w:vAlign" => { - c.valign = match k.attr("w:val") { + c.valign = Some(match k.attr("w:val") { Some("center") => VAlign::Center, Some("bottom") => VAlign::Bottom, _ => VAlign::Top, - } + }) } _ => {} } diff --git a/crates/docx/src/write/props.rs b/crates/docx/src/write/props.rs index 9606e36bc..416c0e4e4 100644 --- a/crates/docx/src/write/props.rs +++ b/crates/docx/src/write/props.rs @@ -578,9 +578,10 @@ fn tcpr_inner(w: &mut W, c: &CellProps) { w.val("w:textDirection", c.text_direction.ooxml()); } match c.valign { - VAlign::Top => {} - VAlign::Center => w.val("w:vAlign", "center"), - VAlign::Bottom => w.val("w:vAlign", "bottom"), + None => {} + Some(VAlign::Top) => w.val("w:vAlign", "top"), + Some(VAlign::Center) => w.val("w:vAlign", "center"), + Some(VAlign::Bottom) => w.val("w:vAlign", "bottom"), } if c.hide_mark { w.empty("w:hideMark", &[]); diff --git a/crates/docx/tests/roundtrip.rs b/crates/docx/tests/roundtrip.rs index e95133549..6d3a9f872 100644 --- a/crates/docx/tests/roundtrip.rs +++ b/crates/docx/tests/roundtrip.rs @@ -286,7 +286,7 @@ fn tables_with_merges_round_trip() { { let c = &mut t.rows[1].cells[0]; c.props.shading = Some(Rgb(0xFF, 0, 0)); - c.props.valign = VAlign::Bottom; + c.props.valign = Some(VAlign::Bottom); c.props.text_direction = TextDirection::Up; c.props.no_wrap = true; c.props.margins = Some([2.0, 3.0, 4.0, 5.0]); diff --git a/crates/engine/src/cmd/mailings.rs b/crates/engine/src/cmd/mailings.rs index 8b91ef5e5..c64cd6b2e 100644 --- a/crates/engine/src/cmd/mailings.rs +++ b/crates/engine/src/cmd/mailings.rs @@ -1389,7 +1389,7 @@ fn labels(s: &mut Session, v: &Value) -> CmdResult { if !content.is_empty() { made += 1; } - cell.props.valign = wordcraft_doc::props::VAlign::Center; + cell.props.valign = Some(wordcraft_doc::props::VAlign::Center); cell.blocks = content .split('\n') .take(20) diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 5616ac0bb..5bf586af8 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -92,7 +92,7 @@ pub fn specs() -> Vec { x => return Err(CmdError::Params(format!("unknown alignment `{x}`"))), }; with_cells(s, |cl| { - cl.props.valign = va; + cl.props.valign = Some(va); for b in &mut cl.blocks { if let Block::Para(p) = std::sync::Arc::make_mut(b) { p.props.align = Some(h); @@ -396,7 +396,7 @@ fn properties(s: &mut Session, v: &Value) -> CmdResult { cl.props.width = w; } if let Some(va) = valign { - cl.props.valign = va; + cl.props.valign = Some(va); } } } diff --git a/crates/engine/src/cmd/table_style.rs b/crates/engine/src/cmd/table_style.rs index f1f20bd17..d86ba4aa0 100644 --- a/crates/engine/src/cmd/table_style.rs +++ b/crates/engine/src/cmd/table_style.rs @@ -51,7 +51,7 @@ macro_rules! style_params { "{", $head, r#", "basedOn"?: string, ""?: Part for each part name (wholeTable, headerRow|firstRow, lastRow|totalRow, firstColumn|firstCol, lastColumn|lastCol, bandedRows|band1Horz, band2Horz, bandedColumns|band1Vert, band2Vert, nwCell, neCell, swCell, seCell), "part"?: part name (the Part fields then sit at the top level), "parts"?: [Part & {"part": name}], "bandSize"?: n (rows per band), "para"?: ParaProps, "apply"?: bool}"#, - r#" where Part = {"fill"?: "RRGGBB" | null, "font"?: string, "size"?: pt, "bold"?: bool, "italic"?: bool, "underline"?: bool | "single" | "double" | "thick" | "words" | "dotted" | "dash" | "wave" | "none", "color"?: "RRGGBB" | null, "align"?: "left" | "center" | "right" | "justify" | null, "vAlign"?: "top" | "center" | "bottom" | null, "cellAlign"?: "topLeft" | "topCenter" | "topRight" | "centerLeft" | "center" | "centerRight" | "bottomLeft" | "bottomCenter" | "bottomRight" | null (both alignments), "borders"?: bool | null (true: single lines on every edge, false: none, null: inherit) | Edges | [Edges], "borderColor"?: "RRGGBB", "borderWidth"?: pt}"#, + r#" where Part = {"fill"?: "RRGGBB" | null, "font"?: string | null (null or "": inherited), "size"?: pt, "bold"?: bool, "italic"?: bool, "underline"?: bool | "single" | "double" | "thick" | "words" | "dotted" | "dash" | "wave" | "none", "color"?: "RRGGBB" | null, "align"?: "left" | "center" | "right" | "justify" | null, "vAlign"?: "top" | "center" | "bottom" | null, "cellAlign"?: "topLeft" | "topCenter" | "topRight" | "centerLeft" | "center" | "centerRight" | "bottomLeft" | "bottomCenter" | "bottomRight" | null (both alignments), "borders"?: bool | null (true: single lines on every edge, false: none, null: inherit) | Edges | [Edges], "borderColor"?: "RRGGBB", "borderWidth"?: pt}"#, r#" and Edges = {"edges"?: "all" | "none" | "outside" | "inside" | "top" | "bottom" | "left" | "right" | "insideH" | "insideV" (default all), "style"?: "single" | "double" | "dotted" | "dashed" | "dotDash" | "thick" | "triple" | "wave" | "none", "width"?: pt, "color"?: "RRGGBB" | null}"# ) }; @@ -289,7 +289,13 @@ fn edit(st: &mut Style, v: &Value) -> Result<(), CmdError> { } } match v.get("part") { - None => {} + None => { + // Part fields at the top level only mean something with `part`; without it they'd be + // silently dropped. + if let Some(k) = PART_FIELDS.iter().find(|k| v.get(**k).is_some()) { + return Err(CmdError::Params(format!("`{k}` is a part field: give `part`, or put it in a part (`wholeTable`, `headerRow`…)"))); + } + } Some(Value::String(n)) => { let part = part_named(n).ok_or_else(|| CmdError::Params(format!("no table style part `{n}`")))?; edit_part(st, part, v)?; @@ -321,6 +327,10 @@ fn edit(st: &mut Style, v: &Value) -> Result<(), CmdError> { Ok(()) } +/// The fields of a Part object. +const PART_FIELDS: [&str; 13] = + ["fill", "font", "size", "bold", "italic", "underline", "color", "align", "vAlign", "cellAlign", "borders", "borderColor", "borderWidth"]; + /// Apply one part's params `r` to part `part` of `st`. fn edit_part(st: &mut Style, part: TablePart, r: &Value) -> Result<(), CmdError> { let mut f = st.table_part(part); @@ -466,8 +476,12 @@ fn region(r: &Value, f: &mut PartFormat) -> Result<(), CmdError> { chr.size = Some(sz.clamp(1.0, 1638.0)); chr.size_cs = chr.size; } - if let Some(n) = p::str(r, "font").map(str::trim).filter(|n| !n.is_empty()) { - chr.font = Some(n.chars().take(256).collect()); + match r.get("font") { + None => {} + // Null or empty: the part's font is inherited again. + Some(Value::Null) => chr.font = None, + Some(Value::String(n)) => chr.font = Some(n.trim()).filter(|n| !n.is_empty()).map(|n| n.chars().take(256).collect()), + Some(_) => return Err(CmdError::Params("`font` is a name or null".into())), } let name = |k: &str| -> Result>, CmdError> { match r.get(k) { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 5ee9883b9..d1700cb20 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1536,7 +1536,7 @@ fn table_properties_apply_in_one_step() { let row = &t.rows[0]; assert_eq!((row.props.height, row.props.height_rule, row.props.cant_split, row.props.header), (Some(40.0), HeightRule::Exact, true, true)); assert_eq!(t.grid[0], 120.0); - assert_eq!((row.cells[0].props.width, row.cells[0].props.valign), (Some(110.0), VAlign::Bottom)); + assert_eq!((row.cells[0].props.width, row.cells[0].props.valign), (Some(110.0), Some(VAlign::Bottom))); let applied = t.clone(); run(&mut s, "edit.undo", json!({})); assert_eq!(s.doc.body.iter().find_map(|b| b.as_table()).unwrap(), &t0, "one undo step per apply"); @@ -3849,6 +3849,41 @@ fn custom_table_style_applies_modifies_and_saves() { /// #714: Modify Table Style edits one part at a time (header row, even bands, a corner cell…) /// with font, fill, borders and cell alignment, and every table using the style follows. +/// #1288: a cell's own Top alignment beats a table style that centres vertically, and it +/// survives a .docx round trip (`w:vAlign w:val="top"`). +#[test] +fn explicit_cell_top_beats_a_centred_table_style() { + use wordcraft_doc::props::VAlign; + fn text_y(s: &mut Session) -> f32 { + s.layout().pages[0] + .items + .iter() + .find_map(|i| match i { + crate::layout::Placed::Lines { para, y, .. } if para.text_len == 4 => Some(*y), + _ => None, + }) + .unwrap() + } + let mut s = s(); + run(&mut s, "insert.table", json!({"rows": 2, "cols": 2})); + run(&mut s, "text.insert", json!({"text": "Cell"})); + run(&mut s, "table.properties", json!({"rowHeight": 120.0, "rowHeightRule": "exact"})); + let top = text_y(&mut s); + run(&mut s, "table.newStyle", json!({"name": "Mid", "wholeTable": {"vAlign": "center"}})); + let centred = text_y(&mut s); + assert!(centred > top + 20.0, "the style centres the text: {top} {centred}"); + run(&mut s, "table.cellAlign", json!({"value": "topLeft"})); + let t = s.doc.body.iter().find_map(|b| b.as_table()).unwrap(); + assert_eq!(t.rows[0].cells[0].props.valign, Some(VAlign::Top)); + assert!((text_y(&mut s) - top).abs() < 0.5, "the cell's Top wins over the style"); + // It saves and comes back as an explicit Top. + let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap(); + let t = back.body.iter().find_map(|b| b.as_table()).unwrap(); + assert_eq!((t.rows[0].cells[0].props.valign, t.rows[0].cells[1].props.valign), (Some(VAlign::Top), None)); + let mut back = Session::new(back); + assert!((text_y(&mut back) - top).abs() < 0.5); +} + #[test] fn table_style_parts_format_every_table_using_the_style() { use wordcraft_doc::props::{Align, BorderStyle, VAlign}; @@ -3940,6 +3975,11 @@ fn table_style_parts_format_every_table_using_the_style() { json!({"style": "Parts", "firstRow": {"align": "up"}}), json!({"style": "Parts", "firstRow": {"underline": "zigzag"}}), json!({"style": "Parts", "firstRow": "bold"}), + // Part fields without `part` (#1288). + json!({"style": "Parts", "fill": "FF0000"}), + json!({"style": "Parts", "bold": true}), + json!({"style": "Parts", "borders": false}), + json!({"style": "Parts", "firstRow": {"font": 5}}), ] { assert!(s.run("table.modifyStyle", &bad).is_err(), "{bad}"); } @@ -3948,6 +3988,13 @@ fn table_style_parts_format_every_table_using_the_style() { run(&mut s, "table.modifyStyle", json!({"style": "Parts", "firstRow": {"size": 1e30, "borders": {"edges": "top", "width": -5}}})); let head = s.doc.styles.get("Parts").unwrap().table_part(TablePart::FirstRow); assert_eq!((head.chr.size, head.borders.and_then(|b| b.top).map(|b| b.width)), (Some(1638.0), Some(0.25))); + // A null or empty font makes the part's font inherited again (#1288). + assert_eq!(head.chr.font.as_deref(), Some("Liberation Serif")); + run(&mut s, "table.modifyStyle", json!({"style": "Parts", "firstRow": {"font": null}})); + assert_eq!(s.doc.styles.get("Parts").unwrap().table_part(TablePart::FirstRow).chr.font, None); + run(&mut s, "table.modifyStyle", json!({"style": "Parts", "part": "lastCol", "font": "Liberation Mono"})); + run(&mut s, "table.modifyStyle", json!({"style": "Parts", "part": "lastCol", "font": " "})); + assert_eq!(s.doc.styles.get("Parts").unwrap().table_part(TablePart::LastCol).chr.font, None); } #[test] diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index 403d39977..577bd0000 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -588,7 +588,8 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava x: x0, w: x1 - x0, // The style's vertical alignment unless the cell has its own. - valign: if cell.props.valign == VAlign::Top { look.and_then(|l| l.v_align).unwrap_or_default() } else { cell.props.valign }, + // The cell's own alignment, even Top, beats the table style's. + valign: cell.props.valign.or_else(|| look.and_then(|l| l.v_align)).unwrap_or_default(), fill, borders, vmerge: cell.props.vmerge, diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b48a67905..c636f4cde 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -590,8 +590,8 @@ impl TableForm { cell_width_on: cell.width.is_some(), cell_width: fin(cell.width.unwrap_or(72.0)) / k, valign: match cell.valign { - VAlign::Center => "center", - VAlign::Bottom => "bottom", + Some(VAlign::Center) => "center", + Some(VAlign::Bottom) => "bottom", _ => "top", } .into(), diff --git a/crates/ui-egui/src/dialogs_table_style.rs b/crates/ui-egui/src/dialogs_table_style.rs index b17d302e2..2da122204 100644 --- a/crates/ui-egui/src/dialogs_table_style.rs +++ b/crates/ui-egui/src/dialogs_table_style.rs @@ -166,8 +166,9 @@ pub fn table_style_params(id: Option<&str>, name: &str, based_on: &str, regions: pub fn region_changes(r: &TableRegion, basis: &TableRegion) -> Value { let color = |c: &str| if c.is_empty() { Value::Null } else { json!(c) }; let mut v = json!({}); - if r.font.trim() != basis.font.trim() && !r.font.trim().is_empty() { - v["font"] = json!(r.font.trim()); + if r.font.trim() != basis.font.trim() { + // An emptied box makes the part's font inherited again. + v["font"] = if r.font.trim().is_empty() { Value::Null } else { json!(r.font.trim()) }; } if r.size.trim() != basis.size.trim() && let Ok(pt) = r.size.trim().parse::() @@ -195,10 +196,14 @@ pub fn region_changes(r: &TableRegion, basis: &TableRegion) -> Value { let changed: Vec = edges(&r.borders) .into_iter() .zip(edges(&basis.borders)) - .filter(|((_, a), (_, b))| a.filter(Border::is_visible) != b.filter(Border::is_visible)) - .map(|((e, a), _)| match a.filter(Border::is_visible) { - Some(l) => json!({"edges": e, "style": l.style.ooxml(), "width": l.width, "color": l.color.map(Rgb::hex)}), - None => json!({"edges": e, "style": "none"}), + // An explicit "no line" edge differs from an unset one: it hides the lines a lower part + // (or the based-on style) draws. + .filter(|((_, a), (_, b))| a != b) + .filter_map(|((e, a), _)| match a { + Some(l) if l.is_visible() => Some(json!({"edges": e, "style": l.style.ooxml(), "width": l.width, "color": l.color.map(Rgb::hex)})), + Some(_) => Some(json!({"edges": e, "style": "none"})), + // The picker never unsets an edge. + None => None, }) .collect(); if !changed.is_empty() { @@ -233,7 +238,8 @@ fn edges_on(b: &Borders, edges: &str) -> bool { let on = |e: Option| e.is_some_and(|x| x.is_visible()); match edges { "all" => [b.top, b.bottom, b.left, b.right, b.between, b.inside_v].into_iter().all(on), - "none" => !b.any_visible(), + // Only explicit "no line" edges: unset ones still show what the part inherits. + "none" => [b.top, b.bottom, b.left, b.right, b.between, b.inside_v].into_iter().all(|e| e.is_some_and(|x| !x.is_visible())), "outside" => [b.top, b.bottom, b.left, b.right].into_iter().all(on), "inside" => on(b.between) && on(b.inside_v), "top" => on(b.top), @@ -697,10 +703,70 @@ mod tests { r.align = "bottomRight".into(); let v = region_changes(&r, &basis); let edges: Vec<&str> = v["borders"].as_array().unwrap().iter().map(|e| e["edges"].as_str().unwrap()).collect(); - assert_eq!(edges, ["top", "bottom", "right"]); + // Left was turned off again: an explicit "no line" now, not the inherited one. + assert_eq!(edges, ["top", "bottom", "left", "right"]); assert_eq!(v["borders"][0]["style"], "double"); + assert_eq!(v["borders"][2]["style"], "none"); assert_eq!(v["cellAlign"], "bottomRight"); assert_eq!(align_name(Some(Align::Center), Some(VAlign::Bottom)), "bottomCenter"); assert_eq!(align_name(None, None), ""); } + + /// #1288: "No Border" on a part that inherits its lines (the header row of a style based on + /// Table Grid) sends explicit "no line" edges, and the header row draws no lines. + #[test] + fn no_border_hides_inherited_lines() { + use wordcraft_layout::Placed; + let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), crate::Services::default()); + app.run("insert.table", json!({"rows": 2, "cols": 3})).unwrap(); + app.run("table.look", json!({"headerRow": true})).unwrap(); + app.run("table.newStyle", json!({"name": "Parts", "basedOn": "Table Grid"})).unwrap(); + // The table's horizontal rules, by y. + let rules = |app: &mut WordApp| -> Vec { + app.session.layout().pages[0] + .items + .iter() + .filter_map(|i| match i { + Placed::Rule { y0, y1, border, .. } if border.is_visible() && (y0 - y1).abs() < 0.01 => Some(*y0), + _ => None, + }) + .collect() + }; + let before = rules(&mut app); + let top = before.iter().copied().fold(f32::INFINITY, f32::min); + assert!(top.is_finite(), "Table Grid draws the table's top edge"); + app.dialog = Dialog::open("modifyTableStyle", &mut app); + let mut r = table_regions(&app, "Parts")[part_index(TablePart::FirstRow)].clone(); + assert!(!edges_on(&r.borders, "none"), "inherited lines aren't \"No Border\""); + pick_edges(&mut r.borders, "none", &BorderPen::default()); + assert!(edges_on(&r.borders, "none")); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + steps(&mut h); + h.get_by(|n| n.role() == egui::accesskit::Role::ComboBox && n.value().as_deref() == Some("Whole Table")).click(); + steps(&mut h); + h.get_by_label("Header Row").click(); + steps(&mut h); + h.get_by_label("Borders ▾").click(); + steps(&mut h); + h.get_by_label("No Border").click(); + steps(&mut h); + h.get_by_label("OK").click(); + steps(&mut h); + assert!(h.state().dialog.is_none(), "OK closes the dialog"); + let head = h.state().session.doc.styles.get("Parts").unwrap().table_part(TablePart::FirstRow); + let b = head.borders.unwrap(); + for e in [b.top, b.bottom, b.left, b.right, b.between, b.inside_v] { + assert_eq!(e.map(|x| x.style), Some(BorderStyle::None)); + } + let after = rules(h.state_mut()); + assert!(after.iter().all(|y| (y - top).abs() > 0.01), "the header row's top edge is gone: {after:?}"); + assert!(after.len() < before.len()); + } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 9104d17f0..bba3a5267 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -52,8 +52,8 @@ 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"|"hu"|"fr"}`), `ui.theme` (`{"value": "system"|"light"|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; 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), `ui.startScreen` (`{"value": true|false}`; no value reads it; on, the default, the desktop and web apps start on File › Home when launched with nothing to open, #766), `ui.alignmentGuides` (`{"value": true|false}`; no value reads it; on, the default, moving a picture or shape snaps it to the page, the margins and other objects, with guide lines while dragging; Alt moves freely)…) | -| `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` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; 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), `ui.contextMenu` (opens the page's context menu below the caret, as Shift+F10 does)…) | +| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog` (`{"name": dialog, "part"?: table style part}`; `part` opens New / Modify Table Style (`newTableStyle` / `modifyTableStyle`) on that part: `wholeTable`, `headerRow`, `firstColumn`, `bandedRows`, `nwCell`… as `table.modifyStyle` names them), `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` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; 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), `ui.startScreen` (`{"value": true|false}`; no value reads it; on, the default, the desktop and web apps start on File › Home when launched with nothing to open, #766), `ui.alignmentGuides` (`{"value": true|false}`; no value reads it; on, the default, moving a picture or shape snaps it to the page, the margins and other objects, with guide lines while dragging; Alt moves freely)…) | +| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog` (`{"name": dialog, "part"?: table style part}`; `part` opens New / Modify Table Style (`newTableStyle` / `modifyTableStyle`) on that part: `wholeTable`, `headerRow`, `firstColumn`, `bandedRows`, `nwCell`… as `table.modifyStyle` names them), `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` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; 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), `ui.contextMenu` (opens the page's context menu below the caret, as Shift+F10 does)…) | | `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/file-format-parity.md b/docs/file-format-parity.md index 409e24005..38a1638f1 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -66,7 +66,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); 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), every `w:tblStylePr` part (wholeTable, firstRow, lastRow, firstCol, lastCol, band1/2Vert, band1/2Horz, ne/nw/se/swCell) with run formatting, `w:jc`, cell borders, shading and `w:vAlign` (#714); right-to-left tables (`w:bidiVisual`) and sections (`w:bidi`) round-trip (#362) | +| Tables incl. floating (`w:tblpPr`), table styles and conditional formatting | ✅ | ✅ | Custom table styles round-trip (#256), every `w:tblStylePr` part (wholeTable, firstRow, lastRow, firstCol, lastCol, band1/2Vert, band1/2Horz, ne/nw/se/swCell) with run formatting, `w:jc`, cell borders, shading and `w:vAlign` (#714); a cell's own `w:vAlign w:val="top"` is kept and beats the style's (#1288); right-to-left tables (`w:bidiVisual`) and sections (`w:bidi`) round-trip (#362) | | Text, runs, paragraph and character properties, styles, numbering, sections | ✅ | ✅ | Including a Styles gallery for files that mark no style `w:qFormat` (LibreOffice: Word's usual quick styles are added, #1238), 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 | ✅ | ✅ | Row table border exceptions (`w:tblPrEx/w:tblBorders`) round-trip (`row_table_borders_round_trip`); other row table exceptions are not preserved. 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 | ✅ | ✅ | | @@ -210,6 +210,7 @@ attributes inside stories and style or list definitions; a regenerated theme. Te | Date | Change | Summary | |---|---|---| +| 2026-10-13 | trivial | A cell's explicit top vertical alignment (`w:vAlign w:val="top"`) is kept on read and write and beats a table style that centres or bottom-aligns (#1288) | | 2026-10-14 | trivial | Row table border exceptions survive DOCX saves | | 2026-10-12 | trivial | Fields keep `w:dirty` through DOCX round trips — on buffered fields and range-field begin markers alike — and computing a result (Update Field, Update Table) clears it like Word's F9, so a generator's placeholder TOC still refreshes when Word opens the file until WordCraft replaces the placeholder (#877) | | 2026-10-14 | trivial | VML freeforms (`v:shape` with a `path`, such as rule lines in headers) read as freeforms and kept on save instead of being dropped | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 6b9cb0333..ac498d8c5 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -59,6 +59,7 @@ Document title opens Rename Document; the account badge opens Options › Genera | Date | Change | Summary | |---|---|---| +| 2026-10-13 | trivial | New/Modify Table Style: No Border on a part that inherits its lines hides them; emptying the font box makes the part's font inherited again (#1288) | | 2026-10-14 | trivial | Tab stops edited on the ruler: corner kind selector, click to set, drag to move or off to clear, per-kind markers mirrored for RTL; each gesture one `para.tabs` call and one undo step; Rulers 85% → 90% | | 2026-10-12 | trivial | Tables on the page: one selected cell (Select Cell, or a click along its left edge) is a cell selection when its border is dragged: the border moves in its row only (#962) | | 2026-10-12 | minor | Default keyboard shortcuts expanded to 140 commands on macOS (169 combinations) and 158 on Linux/Windows (188 combinations): Style Inspector (Shift+F1), Macros (Alt+F8), Strikethrough (Mod+Shift+X), Find Next (Shift+F4), Copy/Paste Insert keys (Mod+Insert, Shift+Insert), Go Back (Mod+Alt+Z), File Open/Save/Save As/Print (Mod+Alt+F2, Alt+Shift+F2, Alt+F2, Mod+Alt+I), Field navigation (Alt+F1, Alt+Shift+F1), Outline show first line (Alt+Shift+L), Next Footnote (Alt+Shift+>), Link to Previous (Alt+Shift+R), AutoText (Alt+F3), Spelling on Mac (Mod+Alt+L) | From 6a2bad8afaa53a3618531a9e80722a7868e5d935 Mon Sep 17 00:00:00 2001 From: Izhaan Intellect <150863240+rbr48@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:31:18 +0600 Subject: [PATCH 099/101] Tables: formulas with cell references, functions, number formats and F9 (#907) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Tables: formulas with cell references, functions, number formats and F9 Table Layout › Data › Formula was a fixed set of string checks: any unknown function was silently summed, cell references weren't read, and Update Field never recalculated a formula field. Formulas are now parsed and evaluated (crates/engine/src/cmd/table_formula.rs): - cell references and ranges by grid column (=B2+B3, =SUM(B2:C3)), + - * / ^, parentheses and the comparisons = <> < <= > >= - SUM, AVERAGE, COUNT, MAX, MIN, PRODUCT, ABS, INT, SIGN, ROUND, MOD, IF (only the branch taken is worked out), AND, OR, NOT, TRUE, FALSE, DEFINED - ABOVE/LEFT/RIGHT/BELOW sum the first run of numbers next to the cell, as observed in Word; `=SUM()` is a syntax error, as in Word; a bare `=SUM` takes ABOVE or LEFT from the cell - the \# numeric picture (`#,##0.00`, `0%`, `;` sections) - an unknown function or a syntax error is an error and inserts nothing; input is bounded (formula length, nesting depth) Update Field (F9) re-evaluates formula fields from the cells they read. Co-Authored-By: Claude Opus 5.5 * Review fixes for #907: clamp formula ranges to the table, cell budget, depth for ^ and unary + - A range reads only the rows and grid columns the table has, so SUM(A1:BK32767) over a small table no longer walks two million grid positions. - One evaluation reads at most 100,000 cells; past it the formula fails with "Formula too complex" (F9 shows !Formula too complex) instead of hanging on a hostile document. - Each ^ in a chain and each unary + counts toward the nesting limit of 32, like unary -. - Regression tests for all three. Co-Authored-By: Claude Opus 5.5 * M2.1: locale-aware number parsing and formatting in table formulas * contributors: credit rbr48 * Merge main; rustfmt; one contributor entry for rbr48 (#906 had added one) Co-Authored-By: Claude Opus 5.5 * Drop a stray merge marker from ROADMAP Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: rbr48 Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 2 +- contributors/people.toml | 7 +- crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/cmd/references.rs | 14 + crates/engine/src/cmd/table.rs | 90 +-- crates/engine/src/cmd/table_formula.rs | 936 +++++++++++++++++++++++++ crates/engine/src/tests.rs | 65 ++ 7 files changed, 1070 insertions(+), 45 deletions(-) create mode 100644 crates/engine/src/cmd/table_formula.rs diff --git a/ROADMAP.md b/ROADMAP.md index 0eb09ef86..094b504bc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -15,7 +15,7 @@ agent control. This page is the summary; the assessment is ## Headline numbers (2026-10-10) | Number | Value | Kind | -|---|---|---| +| 2026-10-12 | Table formulas: cell references and ranges (`=B2+B3`, `=SUM(B2:C3)`), arithmetic and comparisons, IF, AND, OR, NOT, TRUE, FALSE, DEFINED and the `\#` number format; ABOVE and LEFT sum the run of numbers next to the cell as Word does; an unknown function or a syntax error is an error instead of a sum; Update Field (F9) recalculates formula fields | | Ribbon/menu catalog coverage | **487 / 502 (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 | diff --git a/contributors/people.toml b/contributors/people.toml index da86d62d2..bda2f3bbc 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -117,6 +117,10 @@ display_name = "The Legend Of MTS Dev" [people.dotCooCoo] real_name = "Robert Lee" +[people.rbr48] +real_name = "M S Rana" +display_name = "Izhaan Intellect" + [people.holmbom] @@ -128,6 +132,3 @@ sync_github_name = false real_name = "Andrej Škvorc" display_name = "Andrej Škvorc" sync_github_name = false - -[people.rbr48] -sync_github_name = true diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 7dbedc144..2683dce55 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_formula; pub mod table_mouse; pub mod table_resize; pub mod table_style; diff --git a/crates/engine/src/cmd/references.rs b/crates/engine/src/cmd/references.rs index 7bf52ccee..3e1956d90 100644 --- a/crates/engine/src/cmd/references.rs +++ b/crates/engine/src/cmd/references.rs @@ -868,6 +868,20 @@ pub fn update_fields(s: &mut Session) -> Result<(), CmdError> { let mut updates: Vec<(usize, String)> = Vec::new(); for (k, o) in p.objects.iter().enumerate() { let InlineObject::Field { instr, locked: false, .. } = o else { continue }; + // A table formula reads the cells around its own cell again. + if instr.trim_start().starts_with('=') { + if let Some((tp, r, c)) = path.cell() + && let Some(t) = s.doc.table(StoryRef::Body, &tp) + { + let default_lang = super::default_lang(s); + let text = match super::table_formula::evaluate_field_with(t, Some(&s.doc), default_lang.as_deref(), r, c, instr) { + Ok(text) => text, + Err(e) => format!("!{e}"), + }; + updates.push((k, text)); + } + continue; + } let name = wordcraft_layout::fields::field_name(instr); match name.as_str() { "DATE" | "TIME" | "CREATEDATE" | "SAVEDATE" | "PRINTDATE" => { diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 5bf586af8..349c7b4cf 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -2,7 +2,7 @@ use serde_json::{Value, json}; use wordcraft_doc::collate::{SortKey, sort_key}; -use wordcraft_doc::locale_number::{format_number, parse_number}; +use wordcraft_doc::locale_number::parse_number; use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, HeightRule, Rgb, TextDirection, VAlign}; use wordcraft_doc::{Block, Document, Paragraph, Path, Pos, RevisionKind, StoryRef, Table, para_block}; @@ -745,7 +745,7 @@ fn borders(s: &mut Session, v: &Value) -> CmdResult { }) } -fn cell_text(c: &wordcraft_doc::Cell) -> String { +pub(crate) fn cell_text(c: &wordcraft_doc::Cell) -> String { c.blocks.iter().filter_map(|b| b.as_para().map(|p| p.plain_text())).collect::>().join("\n") } @@ -815,55 +815,63 @@ fn to_text(s: &mut Session, v: &Value) -> CmdResult { sel_result(s) } -/// `=SUM(ABOVE)`, `=SUM(LEFT)`, `=AVERAGE(ABOVE)`, `=COUNT(...)`, `=MAX`, `=MIN`, `=PRODUCT`. +/// Table Layout › Data › Formula: inserts a formula field at the caret, evaluated now and again +/// by Update Field. What a formula can use is described in [`super::table_formula`]. A formula +/// that doesn't evaluate (an unknown function, a syntax error) is an error and inserts nothing. fn formula(s: &mut Session, v: &Value) -> CmdResult { let (tp, r, c) = cell(s)?; - let f = p::str(v, "formula").unwrap_or("=SUM(ABOVE)").to_ascii_uppercase(); - let t = s.doc.table(s.sel.focus.story, &tp).cloned().ok_or_else(|| CmdError::Failed("no table".into()))?; - let story = s.sel.focus.story; - // Each cell's number is read in that cell's language (`3,50` in Polish is three and a half). - let sources: Vec<&wordcraft_doc::Cell> = if f.contains("LEFT") { - t.rows.get(r).map(|row| row.cells.iter().take(c).collect()).unwrap_or_default() - } else { - t.rows.iter().take(r).filter_map(|row| row.cells.get(c)).collect() - }; + let raw = p::str(v, "formula").unwrap_or("=SUM(ABOVE)"); + let t = s.doc.table(s.sel.focus.story, &tp).ok_or_else(|| CmdError::Failed("no table".into()))?; let default_lang = super::default_lang(s); - let nums: Vec = sources - .iter() - .filter_map(|x| parse_number(cell_text(x).trim(), cell_lang(&s.doc, x).or_else(|| default_lang.clone()).as_deref())) - .filter(|x| x.is_finite()) - .collect(); - // The result is written in the language of the formula's cell, else of the numbers it adds. - let out_lang = s - .doc - .para(story, &s.sel.focus.path) - .filter(|p| !p.plain_text().trim().is_empty()) - .and_then(|p| super::para_lang(&s.doc, p)) - .or_else(|| sources.iter().find_map(|x| cell_lang(&s.doc, x))) - .or(default_lang); - let val = if f.starts_with("=AVERAGE") { - if nums.is_empty() { 0.0 } else { nums.iter().sum::() / nums.len() as f64 } - } else if f.starts_with("=COUNT") { - nums.len() as f64 - } else if f.starts_with("=MAX") { - nums.iter().copied().fold(f64::MIN, f64::max) - } else if f.starts_with("=MIN") { - nums.iter().copied().fold(f64::MAX, f64::min) - } else if f.starts_with("=PRODUCT") { - nums.iter().product() - } else { - nums.iter().sum() - }; - let text = format_number(val, out_lang.as_deref()); + let instr = auto_formula_instr(t, r, c, raw, default_lang.as_deref()); + let text = super::table_formula::evaluate_field_with(t, Some(&s.doc), default_lang.as_deref(), r, c, &instr)?; let props = s.typing_props(); let at = s.sel.focus.clone(); let end = s.doc.insert_object( &at, - wordcraft_doc::InlineObject::Field { instr: f.clone(), result: text.clone(), locked: false, dirty: false, code: false, form: String::new() }, + wordcraft_doc::InlineObject::Field { + instr: instr.clone(), + result: text.clone(), + locked: false, + dirty: false, + code: false, + form: String::new(), + }, &props, )?; s.sel = Selection::caret(end); - Ok(json!({"result": text})) + Ok(json!({"result": text, "formula": instr})) +} + +/// A formula as a field stores it: upper case, with one leading `=`. A `\#` numeric picture is +/// kept as typed. +pub(crate) fn formula_instr(raw: &str) -> String { + let body = raw.trim(); + let body = body.strip_prefix('=').unwrap_or(body); + match super::table_formula::split_switch(body) { + (formula, Some(switch)) => format!("={} {switch}", formula.trim().to_ascii_uppercase()), + (formula, None) => format!("={}", formula.trim().to_ascii_uppercase()), + } +} + +/// A bare function name (`=SUM`, no parentheses) takes its direction from the cell: ABOVE when a +/// cell above holds a number, else LEFT. +fn auto_formula_instr(t: &Table, r: usize, c: usize, raw: &str, lang: Option<&str>) -> String { + let instr = formula_instr(raw); + let body = instr.strip_prefix('=').unwrap_or(&instr); + if !body.contains('(') && matches!(body, "SUM" | "AVERAGE" | "COUNT" | "MAX" | "MIN" | "PRODUCT") { + let g = t.grid_col(r, c); + let has_above = (0..r).rev().any(|row| { + t.cell_at_grid(row, g) + .and_then(|ci| t.rows.get(row)?.cells.get(ci)) + .and_then(|cl| super::table_formula::cell_number_in(cl, lang)) + .is_some() + }); + let dir = if has_above { "ABOVE" } else { "LEFT" }; + format!("={body}({dir})") + } else { + instr + } } /// Ctrl+A in a table, as in Word: the cell, then its row, then the table. A nested table that diff --git a/crates/engine/src/cmd/table_formula.rs b/crates/engine/src/cmd/table_formula.rs new file mode 100644 index 000000000..165716d68 --- /dev/null +++ b/crates/engine/src/cmd/table_formula.rs @@ -0,0 +1,936 @@ +//! Table formulas (Table Layout › Data › Formula): `=SUM(ABOVE)`, cell references and ranges +//! (`=SUM(B2:B4)`, `=A1*2`), arithmetic, and the functions Word offers in tables. A formula field +//! is re-evaluated by Update Field (F9), so its result follows the cells it reads. +//! +//! Cell references count rows from 1 at the top and columns from A at the left, by grid column. +//! ABOVE, LEFT, RIGHT and BELOW sum the first run of numbers in that direction, as Word does: the +//! cells next to the formula's cell are skipped while they hold no number (empty or text), then +//! the numbers are summed up to the next cell that holds none. A function needs its arguments: +//! `=SUM()` is a syntax error, as it is in Word. + +use wordcraft_doc::table::{MAX_COLS, MAX_ROWS}; +use wordcraft_doc::{Cell, Table}; + +use crate::CmdError; + +/// The longest formula accepted, in bytes. +pub const MAX_FORMULA: usize = 512; +/// The longest numeric picture (`\#` switch) used, in characters. +const MAX_PICTURE: usize = 64; +/// The deepest nesting of parentheses, function calls, unary signs and `^` chains. +const MAX_DEPTH: usize = 32; +/// The most cells one evaluation reads; past it the formula is too complex to work out, so a +/// hostile document full of huge ranges can't make Update Field hang. +pub const MAX_CELL_VISITS: usize = 100_000; + +/// Evaluate `formula` (with or without its leading `=`) for the cell in row `r`, cell index `c`. +pub fn evaluate(t: &Table, r: usize, c: usize, formula: &str) -> Result { + evaluate_with(t, None, None, r, c, formula) +} + +pub fn evaluate_with( + t: &Table, + doc: Option<&wordcraft_doc::Document>, + default_lang: Option<&str>, + r: usize, + c: usize, + formula: &str, +) -> Result { + if formula.len() > MAX_FORMULA { + return Err(syntax(format!("the formula is longer than {MAX_FORMULA} characters"))); + } + let body = formula.trim(); + let body = body.strip_prefix('=').unwrap_or(body); + let mut parser = Parser { toks: tokenize(body)?, pos: 0, depth: 0 }; + let expr = parser.compare()?; + if parser.pos != parser.toks.len() { + return Err(syntax("unexpected text after the formula".into())); + } + let here = Here { t, doc, default_lang, r, c, g: t.grid_col(r, c), visits: std::cell::Cell::new(0) }; + match eval(&expr, &here)? { + Val::Num(x) => finite(x), + Val::List(_) => Err(syntax("a range or ABOVE/LEFT/RIGHT/BELOW needs a function, such as SUM".into())), + } +} + +/// The text a formula result shows: a whole number as is, otherwise two decimals. +pub fn format_result(v: f64) -> String { + wordcraft_doc::locale_number::format_number(v, None) +} + +/// The text a formula field shows: its result under its `\#` numeric picture when it has one (see +/// [`format_picture`]), otherwise as [`format_result`] shows it. +pub fn evaluate_field(t: &Table, r: usize, c: usize, instr: &str) -> Result { + evaluate_field_with(t, None, None, r, c, instr) +} + +pub fn evaluate_field_with( + t: &Table, + doc: Option<&wordcraft_doc::Document>, + default_lang: Option<&str>, + r: usize, + c: usize, + instr: &str, +) -> Result { + let (formula, switch) = split_switch(instr); + let value = evaluate_with(t, doc, default_lang, r, c, formula)?; + Ok(match switch.and_then(picture_of) { + Some(picture) => format_picture(value, &picture), + None => { + let out_lang = doc + .and_then(|d| { + t.rows.get(r).and_then(|row| row.cells.get(c)).and_then(|cl| { + cl.blocks.iter().filter_map(|b| b.as_para()).find(|p| !p.plain_text().trim().is_empty()).and_then(|p| super::para_lang(d, p)) + }) + }) + .or_else(|| { + doc.and_then(|d| { + t.rows.iter().flat_map(|row| row.cells.iter()).take(100).find_map(|cl| { + cl.blocks + .iter() + .filter_map(|b| b.as_para()) + .find(|p| !p.plain_text().trim().is_empty()) + .and_then(|p| super::para_lang(d, p)) + }) + }) + }) + .or_else(|| default_lang.map(str::to_string)); + wordcraft_doc::locale_number::format_number(value, out_lang.as_deref()) + } + }) +} + +/// Splits a field's `\#` switch off its formula: `=SUM(ABOVE) \# "0.00"` gives the formula and the +/// switch text (from the `\#` on). +pub fn split_switch(instr: &str) -> (&str, Option<&str>) { + match instr.find("\\#") { + Some(at) => (instr[..at].trim_end(), Some(&instr[at..])), + None => (instr, None), + } +} + +/// The picture a `\#` switch names: a quoted one (`"#,##0.00"`) or a single word. +fn picture_of(switch: &str) -> Option { + let rest = switch.strip_prefix("\\#")?.trim_start(); + let picture = match rest.strip_prefix('"') { + Some(quoted) => quoted.split('"').next().unwrap_or_default(), + None => rest.split_whitespace().next().unwrap_or_default(), + }; + let picture: String = picture.chars().take(MAX_PICTURE).collect(); + (!picture.is_empty()).then_some(picture) +} + +/// The text of `value` under a numeric picture, as Word's `\#` pictures read: `0` is a digit that +/// always shows, `#` one that shows when it has a value, `,` groups thousands, `.` marks the +/// decimals, `%` multiplies by 100, and anything else shows as it is. Sections split by `;` give the +/// positive, negative and zero forms; with one section a negative value takes a `-` in front. +pub fn format_picture(value: f64, picture: &str) -> String { + let sections: Vec<&str> = picture.split(';').collect(); + let (section, x, minus) = if value < 0.0 && sections.len() >= 2 { + (sections[1], -value, false) + } else if value == 0.0 && sections.len() >= 3 { + (sections[2], 0.0, false) + } else { + (sections[0], value.abs(), value < 0.0) + }; + let Some(start) = section.find(['#', '0']) else { return section.to_string() }; + let rest = §ion[start..]; + let len = rest.find(|c: char| !matches!(c, '#' | '0' | ',' | '.')).unwrap_or(rest.len()); + let (core, suffix) = rest.split_at(len); + let prefix = §ion[..start]; + let x = if prefix.contains('%') || suffix.contains('%') { x * 100.0 } else { x }; + let (int_pic, frac_pic) = match core.split_once('.') { + Some((int_pic, frac_pic)) => (int_pic, Some(frac_pic)), + None => (core, None), + }; + let min_int = int_pic.matches('0').count().min(MAX_PICTURE); + let max_frac = frac_pic.map_or(0, |f| f.matches(['0', '#']).count()).min(15); + let min_frac = frac_pic.map_or(0, |f| f.matches('0').count()).min(max_frac); + let scale = 10f64.powi(max_frac as i32); + let rounded = (x * scale).round() / scale; + if !rounded.is_finite() { + return format_result(value); + } + let digits = format!("{:.*}", max_frac, rounded); + let (whole, decimals) = match digits.split_once('.') { + Some((whole, decimals)) => (whole.to_string(), decimals.to_string()), + None => (digits.clone(), String::new()), + }; + let whole = if whole == "0" && min_int == 0 { String::new() } else { format!("{whole:0>min_int$}") }; + let whole = if int_pic.contains(',') { group_thousands(&whole) } else { whole }; + let mut decimals = decimals; + while decimals.len() > min_frac && decimals.ends_with('0') { + decimals.pop(); + } + let mut body = whole; + if !decimals.is_empty() { + body.push('.'); + body.push_str(&decimals); + } + let sign = if minus && rounded != 0.0 { "-" } else { "" }; + format!("{sign}{prefix}{body}{suffix}") +} + +/// `1234567` → `1,234,567`. +fn group_thousands(digits: &str) -> String { + let mut out = String::new(); + for (i, ch) in digits.chars().enumerate() { + if i > 0 && (digits.len() - i).is_multiple_of(3) { + out.push(','); + } + out.push(ch); + } + out +} + +/// A cell's number as a formula or a sort key reads it: surrounding spaces, currency signs, +/// thousands separators and `%` are ignored, and accounting negatives in parentheses count as +/// negative. Anything else (letters included) makes the cell text, not a number. +pub fn parse_number(text: &str, lang: Option<&str>) -> Option { + let t = text.trim(); + let (t, negative) = match t.strip_prefix('(').and_then(|x| x.strip_suffix(')')) { + Some(inner) => (inner.trim(), true), + None => (t, false), + }; + if let Some(x) = wordcraft_doc::locale_number::parse_number(t, lang) { + let x = if negative { -x.abs() } else { x }; + return x.is_finite().then_some(x); + } + let cleaned: String = t.chars().filter(|c| !matches!(c, ',' | '$' | '€' | '£' | '¥' | '৳' | '%') && !c.is_whitespace()).collect(); + if cleaned.is_empty() || !cleaned.chars().all(|c| c.is_ascii_digit() || matches!(c, '.' | '-' | '+' | 'e' | 'E')) { + return None; + } + let x = cleaned.parse::().ok()?; + let x = if negative { -x } else { x }; + x.is_finite().then_some(x) +} + +/// The number in a cell, if its text is one. +pub(crate) fn cell_number_in(c: &Cell, lang: Option<&str>) -> Option { + parse_number(&super::table::cell_text(c), lang) +} + +fn syntax(msg: String) -> CmdError { + CmdError::Failed(format!("syntax error: {msg}")) +} + +fn finite(x: f64) -> Result { + if x.is_finite() { Ok(x) } else { Err(CmdError::Failed("the result is not a number".into())) } +} + +#[derive(Clone, Debug, PartialEq)] +enum Tok { + Num(f64), + /// A name: a function, a keyword or a cell reference (letters then digits), upper-cased. + Name(String), + Op(char), + /// A comparison: `=`, `<>`, `<`, `<=`, `>` or `>=`. + Cmp(&'static str), +} + +fn tokenize(s: &str) -> Result, CmdError> { + let mut out = Vec::new(); + let mut chars = s.chars().peekable(); + while let Some(&ch) = chars.peek() { + if ch.is_whitespace() { + chars.next(); + } else if ch.is_ascii_digit() || ch == '.' { + let mut lit = String::new(); + while let Some(&d) = chars.peek() { + if d.is_ascii_digit() || d == '.' { + lit.push(d); + chars.next(); + } else { + break; + } + } + let x = lit.parse::().map_err(|_| syntax(format!("`{lit}` isn't a number")))?; + out.push(Tok::Num(x)); + } else if ch.is_ascii_alphabetic() { + let mut name = String::new(); + while let Some(&d) = chars.peek() { + if d.is_ascii_alphanumeric() { + name.push(d.to_ascii_uppercase()); + chars.next(); + } else { + break; + } + } + out.push(Tok::Name(name)); + } else if matches!(ch, '+' | '-' | '*' | '/' | '^' | '(' | ')' | ',' | ':') { + out.push(Tok::Op(ch)); + chars.next(); + } else if matches!(ch, '<' | '>' | '=') { + chars.next(); + let next = chars.peek().copied(); + let (op, longer) = match (ch, next) { + ('<', Some('=')) => ("<=", true), + ('<', Some('>')) => ("<>", true), + ('>', Some('=')) => (">=", true), + ('<', _) => ("<", false), + ('>', _) => (">", false), + _ => ("=", false), + }; + if longer { + chars.next(); + } + out.push(Tok::Cmp(op)); + } else { + return Err(syntax(format!("`{ch}` isn't part of a formula"))); + } + } + Ok(out) +} + +/// The keywords that name the cells next to the formula's cell. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Side { + Above, + Left, + Right, + Below, +} + +#[derive(Clone, Debug)] +enum Expr { + Num(f64), + /// A cell: (row, grid column). + Cell(usize, usize, String), + /// A rectangle of cells: (row0, grid col0, row1, grid col1), inclusive. + Range(usize, usize, usize, usize), + Side(Side), + Neg(Box), + Bin(char, Box, Box), + /// A comparison: 1 when it holds, 0 when it doesn't. + Cmp(&'static str, Box, Box), + Call(String, Vec), +} + +struct Parser { + toks: Vec, + pos: usize, + depth: usize, +} + +impl Parser { + fn peek_op(&self, c: char) -> bool { + matches!(self.toks.get(self.pos), Some(Tok::Op(x)) if *x == c) + } + + fn eat_op(&mut self, c: char) -> bool { + let hit = self.peek_op(c); + if hit { + self.pos += 1; + } + hit + } + + fn enter(&mut self) -> Result<(), CmdError> { + self.depth += 1; + if self.depth > MAX_DEPTH { Err(syntax("the formula is nested too deeply".into())) } else { Ok(()) } + } + + fn leave(&mut self) { + self.depth = self.depth.saturating_sub(1); + } + + /// The loosest level: one comparison of two sums (comparisons don't chain). + fn compare(&mut self) -> Result { + let lhs = self.expr()?; + let Some(Tok::Cmp(op)) = self.toks.get(self.pos).cloned() else { return Ok(lhs) }; + self.pos += 1; + let rhs = self.expr()?; + Ok(Expr::Cmp(op, Box::new(lhs), Box::new(rhs))) + } + + fn expr(&mut self) -> Result { + let mut lhs = self.term()?; + loop { + let op = if self.eat_op('+') { + '+' + } else if self.eat_op('-') { + '-' + } else { + return Ok(lhs); + }; + let rhs = self.term()?; + lhs = Expr::Bin(op, Box::new(lhs), Box::new(rhs)); + } + } + + fn term(&mut self) -> Result { + let mut lhs = self.power()?; + loop { + let op = if self.eat_op('*') { + '*' + } else if self.eat_op('/') { + '/' + } else { + return Ok(lhs); + }; + let rhs = self.power()?; + lhs = Expr::Bin(op, Box::new(lhs), Box::new(rhs)); + } + } + + fn power(&mut self) -> Result { + let base = self.unary()?; + if self.eat_op('^') { + // `^` is right-associative, so each one in a chain nests one level deeper. + self.enter()?; + let exp = self.power()?; + self.leave(); + return Ok(Expr::Bin('^', Box::new(base), Box::new(exp))); + } + Ok(base) + } + + fn unary(&mut self) -> Result { + if self.eat_op('-') { + self.enter()?; + let inner = self.unary()?; + self.leave(); + return Ok(Expr::Neg(Box::new(inner))); + } + if self.eat_op('+') { + self.enter()?; + let inner = self.unary()?; + self.leave(); + return Ok(inner); + } + self.primary() + } + + fn primary(&mut self) -> Result { + let Some(tok) = self.toks.get(self.pos).cloned() else { + return Err(syntax("the formula ends too soon".into())); + }; + self.pos += 1; + match tok { + Tok::Num(x) => Ok(Expr::Num(x)), + Tok::Op('(') => { + self.enter()?; + let inner = self.compare()?; + if !self.eat_op(')') { + return Err(syntax("a `(` is missing its `)`".into())); + } + self.leave(); + Ok(inner) + } + Tok::Name(name) => { + if self.eat_op('(') { + self.enter()?; + let mut args = Vec::new(); + if !self.eat_op(')') { + loop { + args.push(self.compare()?); + if self.eat_op(')') { + break; + } + if !self.eat_op(',') { + return Err(syntax(format!("`{name}` needs `,` or `)` between its arguments"))); + } + } + } + self.leave(); + return Ok(Expr::Call(name, args)); + } + let side = match name.as_str() { + "ABOVE" => Some(Side::Above), + "LEFT" => Some(Side::Left), + "RIGHT" => Some(Side::Right), + "BELOW" => Some(Side::Below), + _ => None, + }; + if let Some(side) = side { + return Ok(Expr::Side(side)); + } + // TRUE and FALSE need no parentheses. + if matches!(name.as_str(), "TRUE" | "FALSE") { + return Ok(Expr::Call(name, Vec::new())); + } + let (row, col) = cell_ref(&name).ok_or_else(|| syntax(format!("`{name}` isn't a cell reference or a function")))?; + if self.eat_op(':') { + let Some(Tok::Name(other)) = self.toks.get(self.pos).cloned() else { + return Err(syntax("a range ends with a cell reference, such as B4".into())); + }; + self.pos += 1; + let (row2, col2) = cell_ref(&other).ok_or_else(|| syntax(format!("`{other}` isn't a cell reference")))?; + return Ok(Expr::Range(row.min(row2), col.min(col2), row.max(row2), col.max(col2))); + } + Ok(Expr::Cell(row, col, name)) + } + Tok::Op(c) => Err(syntax(format!("`{c}` is in the wrong place"))), + Tok::Cmp(op) => Err(syntax(format!("`{op}` is in the wrong place"))), + } + } +} + +/// `B4` → (row 3, column 1): the row counts from 1 and the column from A. +fn cell_ref(name: &str) -> Option<(usize, usize)> { + let letters: String = name.chars().take_while(|c| c.is_ascii_alphabetic()).collect(); + let digits = name.get(letters.len()..)?; + if letters.is_empty() || digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) { + return None; + } + let col = letters.bytes().try_fold(0usize, |acc, b| acc.checked_mul(26)?.checked_add((b.to_ascii_uppercase() - b'A') as usize + 1))? - 1; + let row = digits.parse::().ok()?.checked_sub(1)?; + (col < MAX_COLS && row < MAX_ROWS).then_some((row, col)) +} + +/// Where the formula's cell sits. +struct Here<'a> { + t: &'a Table, + doc: Option<&'a wordcraft_doc::Document>, + default_lang: Option<&'a str>, + r: usize, + c: usize, + g: usize, + /// Cells read so far in this evaluation (see [`MAX_CELL_VISITS`]). + visits: std::cell::Cell, +} + +impl Here<'_> { + /// Count `n` more cells read, failing once the evaluation has read too many. + fn visit(&self, n: usize) -> Result<(), CmdError> { + let total = self.visits.get().saturating_add(n); + self.visits.set(total); + if total > MAX_CELL_VISITS { Err(CmdError::Failed("Formula too complex".into())) } else { Ok(()) } + } + + fn cell_lang(&self, c: &Cell) -> Option { + let para_lang = self.doc.and_then(|doc| { + c.blocks.iter().filter_map(|b| b.as_para()).find(|p| !p.plain_text().trim().is_empty()).and_then(|p| super::para_lang(doc, p)) + }); + para_lang.or_else(|| self.default_lang.map(str::to_string)) + } + + fn cell_number(&self, c: &Cell) -> Option { + let lang = self.cell_lang(c); + let text = super::table::cell_text(c); + parse_number(&text, lang.as_deref()) + } + + /// The number in the cell covering grid column `g` of row `r`, counted against the budget. + fn number_at(&self, r: usize, g: usize) -> Result, CmdError> { + self.visit(1)?; + let ci = match self.t.cell_at_grid(r, g) { + Some(ci) => ci, + None => return Ok(None), + }; + let c = match self.t.rows.get(r).and_then(|row| row.cells.get(ci)) { + Some(c) => c, + None => return Ok(None), + }; + Ok(self.cell_number(c)) + } +} + +enum Val { + Num(f64), + List(Vec), +} + +fn eval(e: &Expr, here: &Here) -> Result { + Ok(match e { + Expr::Num(x) => Val::Num(*x), + Expr::Cell(row, col, name) => { + let x = here.number_at(*row, *col)?.ok_or_else(|| CmdError::Failed(format!("{name} doesn't hold a number")))?; + Val::Num(x) + } + Expr::Range(r0, g0, r1, g1) => { + // Only the part of the range inside the table holds cells. + let mut v = Vec::new(); + let (r_end, g_end) = (here.t.rows.len().min(r1.saturating_add(1)), here.t.cols().min(g1.saturating_add(1))); + for row in *r0..r_end { + for g in *g0..g_end { + v.extend(here.number_at(row, g)?); + } + } + Val::List(v) + } + Expr::Side(side) => Val::List(side_numbers(here, *side)?), + Expr::Neg(inner) => Val::Num(-scalar(eval(inner, here)?)?), + Expr::Bin(op, a, b) => { + let (x, y) = (scalar(eval(a, here)?)?, scalar(eval(b, here)?)?); + Val::Num(match op { + '+' => x + y, + '-' => x - y, + '*' => x * y, + '/' => { + if y == 0.0 { + return Err(CmdError::Failed("division by zero".into())); + } + x / y + } + _ => { + let p = x.powf(y); + finite(p)? + } + }) + } + Expr::Cmp(op, a, b) => { + let (x, y) = (scalar(eval(a, here)?)?, scalar(eval(b, here)?)?); + let holds = match *op { + "=" => x == y, + "<>" => x != y, + "<" => x < y, + "<=" => x <= y, + ">" => x > y, + _ => x >= y, + }; + Val::Num(truth(holds)) + } + Expr::Call(name, args) => Val::Num(call(name, args, here)?), + }) +} + +/// A truth value as a formula reads it: 1 for true, 0 for false. +fn truth(holds: bool) -> f64 { + if holds { 1.0 } else { 0.0 } +} + +/// A value that must be one number (arithmetic and single-argument functions). +fn scalar(v: Val) -> Result { + match v { + Val::Num(x) => finite(x), + Val::List(_) => Err(syntax("a range can only be used inside a function".into())), + } +} + +/// The first run of numbers next to the formula's cell on one side (see the module note): the +/// cells are skipped while they hold no number, then summed while they do. +fn side_numbers(here: &Here, side: Side) -> Result, CmdError> { + let mut out = Vec::new(); + let mut started = false; + let mut take = |value: Option| -> bool { + match value { + Some(x) => { + out.push(x); + started = true; + true + } + None => !started, + } + }; + match side { + Side::Above => { + for row in (0..here.r).rev() { + if !take(here.number_at(row, here.g)?) { + break; + } + } + } + Side::Below => { + for row in (here.r + 1)..here.t.rows.len() { + if !take(here.number_at(row, here.g)?) { + break; + } + } + } + Side::Left | Side::Right => { + let Some(row) = here.t.rows.get(here.r) else { return Ok(out) }; + here.visit(row.cells.len())?; + let cells: Vec> = row.cells.iter().map(|c| here.cell_number(c)).collect(); + let order: Vec = if side == Side::Left { (0..here.c).rev().collect() } else { ((here.c + 1)..cells.len()).collect() }; + for ci in order { + if !take(cells.get(ci).copied().flatten()) { + break; + } + } + } + } + Ok(out) +} + +/// Gather a function's arguments: numbers and lists (ranges and keywords) become one list. +fn gather(args: &[Expr], here: &Here) -> Result, CmdError> { + let mut all = Vec::new(); + for a in args { + match eval(a, here)? { + Val::Num(x) => all.push(finite(x)?), + Val::List(v) => all.extend(v), + } + } + Ok(all) +} + +/// The one number a single-argument function takes. +fn one(name: &str, args: &[Expr], here: &Here) -> Result { + match args { + [a] => scalar(eval(a, here)?), + _ => Err(CmdError::Failed(format!("{name} takes one argument"))), + } +} + +fn call(name: &str, args: &[Expr], here: &Here) -> Result { + let needs_one = matches!( + name, + "SUM" | "COUNT" | "AVERAGE" | "MAX" | "MIN" | "PRODUCT" | "ABS" | "INT" | "SIGN" | "ROUND" | "MOD" | "AND" | "OR" | "NOT" | "IF" | "DEFINED" + ); + if args.is_empty() && needs_one { + return Err(syntax(format!("{name}() needs an argument"))); + } + match name { + "TRUE" | "FALSE" if args.is_empty() => Ok(truth(name == "TRUE")), + "TRUE" | "FALSE" => Err(CmdError::Failed(format!("{name} takes no arguments"))), + "NOT" => Ok(truth(one(name, args, here)? == 0.0)), + "AND" | "OR" => { + let v = gather(args, here)?; + if v.is_empty() { + return Err(CmdError::Failed(format!("{name} has no numbers to test"))); + } + Ok(truth(if name == "AND" { v.iter().all(|x| *x != 0.0) } else { v.iter().any(|x| *x != 0.0) })) + } + // Only the branch taken is worked out, so an error in the other one doesn't count. + "IF" => match args { + [cond, yes, no] => { + if scalar(eval(cond, here)?)? != 0.0 { + scalar(eval(yes, here)?) + } else { + scalar(eval(no, here)?) + } + } + _ => Err(CmdError::Failed("IF takes three arguments: a condition, the value when it is true, and the value when it is false".into())), + }, + // DEFINED(cell) is 1 when the cell holds a value the formula can read, 0 when it doesn't. + "DEFINED" => match args { + [a] => Ok(truth(eval(a, here).is_ok())), + _ => Err(CmdError::Failed("DEFINED takes one argument".into())), + }, + "SUM" => Ok(gather(args, here)?.iter().sum()), + "COUNT" => Ok(gather(args, here)?.len() as f64), + "AVERAGE" => { + let v = gather(args, here)?; + Ok(if v.is_empty() { 0.0 } else { v.iter().sum::() / v.len() as f64 }) + } + "MAX" => { + let v = gather(args, here)?; + Ok(if v.is_empty() { 0.0 } else { v.iter().copied().fold(f64::NEG_INFINITY, f64::max) }) + } + "MIN" => { + let v = gather(args, here)?; + Ok(if v.is_empty() { 0.0 } else { v.iter().copied().fold(f64::INFINITY, f64::min) }) + } + "PRODUCT" => { + let v = gather(args, here)?; + Ok(if v.is_empty() { 1.0 } else { v.iter().product() }) + } + "ABS" => Ok(one(name, args, here)?.abs()), + "INT" => Ok(one(name, args, here)?.floor()), + "SIGN" => Ok(one(name, args, here)?.signum()), + "ROUND" => match args { + [a, b] => { + let x = scalar(eval(a, here)?)?; + let digits = scalar(eval(b, here)?)?.clamp(-15.0, 15.0).trunc(); + let scale = 10f64.powi(digits as i32); + finite((x * scale).round() / scale) + } + _ => Err(CmdError::Failed("ROUND takes two arguments".into())), + }, + "MOD" => match args { + [a, b] => { + let (x, y) = (scalar(eval(a, here)?)?, scalar(eval(b, here)?)?); + if y == 0.0 { + return Err(CmdError::Failed("division by zero".into())); + } + Ok(x - y * (x / y).floor()) + } + _ => Err(CmdError::Failed("MOD takes two arguments".into())), + }, + _ => Err(CmdError::Failed(format!("unknown formula function `{name}`"))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn table(rows: &[&[&str]]) -> Table { + let cols = rows.iter().map(|r| r.len()).max().unwrap_or(1); + let mut t = Table::new(rows.len(), cols, 468.0); + for (r, row) in rows.iter().enumerate() { + for (c, text) in row.iter().enumerate() { + if let Some(cl) = t.rows.get_mut(r).and_then(|x| x.cells.get_mut(c)) { + cl.blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(text, Default::default()))]; + } + } + } + t + } + + fn ev(t: &Table, r: usize, c: usize, f: &str) -> Result { + evaluate(t, r, c, f) + } + + #[test] + fn cell_references_count_from_one_and_from_a() { + assert_eq!(cell_ref("A1"), Some((0, 0))); + assert_eq!(cell_ref("B4"), Some((3, 1))); + assert_eq!(cell_ref("AA2"), Some((1, 26))); + assert_eq!(cell_ref("A0"), None); + assert_eq!(cell_ref("1A"), None); + } + + #[test] + fn a_run_of_numbers_above_is_summed_after_skipping_cells_without_numbers() { + // Observed in Word: the cells above are skipped while empty or text, then the numbers are + // summed up to the next cell that holds none. + let t = table(&[&["5"], &["x"], &["2"], &[""]]); + assert_eq!(ev(&t, 3, 0, "=SUM(ABOVE)"), Ok(2.0), "text stops the run; the 5 above it is not counted"); + assert_eq!(ev(&t, 2, 0, "=SUM(ABOVE)"), Ok(5.0), "text directly above is skipped, so the run is the 5 above it"); + let t = table(&[&["5"], &[""], &[""], &[""]]); + assert_eq!(ev(&t, 3, 0, "=SUM(ABOVE)"), Ok(5.0), "empty cells are skipped up to the 5"); + let t = table(&[&["5"], &["3"], &[""], &["9"], &[""]]); + assert_eq!(ev(&t, 4, 0, "=SUM(ABOVE)"), Ok(9.0), "the run is the 9 alone: the blank cuts it off"); + let t = table(&[&["x"], &["5"], &[""]]); + assert_eq!(ev(&t, 2, 0, "=SUM(ABOVE)"), Ok(5.0)); + } + + #[test] + fn a_function_needs_its_arguments() { + let t = table(&[&["1"], &[""]]); + assert!(ev(&t, 1, 0, "=SUM()").is_err(), "empty parentheses are a syntax error, as in Word"); + assert!(ev(&t, 1, 0, "=SUM").is_err()); + } + + #[test] + fn unknown_functions_are_errors_not_sums() { + let t = table(&[&["1"], &["4"], &["7"], &[""]]); + let err = ev(&t, 3, 0, "=MEDIAN(ABOVE)").err().map(|e| e.to_string()).unwrap_or_default(); + assert!(err.contains("MEDIAN"), "{err}"); + } + + #[test] + fn ranges_and_references_and_operators() { + let t = table(&[&["1", "2"], &["3", "4"], &["", ""]]); + assert_eq!(ev(&t, 2, 0, "=SUM(A1:B2)"), Ok(10.0)); + assert_eq!(ev(&t, 2, 1, "=A1*B2+1"), Ok(5.0)); + assert_eq!(ev(&t, 2, 0, "=(A1+A2)/2"), Ok(2.0)); + assert_eq!(ev(&t, 2, 0, "=2^3-(-1)"), Ok(9.0)); + assert_eq!(ev(&t, 2, 0, "=AVERAGE(A1:B2)"), Ok(2.5)); + assert_eq!(ev(&t, 2, 0, "=MAX(A1:B2)"), Ok(4.0)); + assert_eq!(ev(&t, 2, 0, "=ROUND(2.5, 0)"), Ok(3.0)); + assert_eq!(ev(&t, 2, 0, "=MOD(7, 3)"), Ok(1.0)); + } + + #[test] + fn division_by_zero_and_bad_syntax_are_errors() { + let t = table(&[&["1"], &["0"]]); + assert!(ev(&t, 1, 0, "=A1/A2").is_err()); + assert!(ev(&t, 1, 0, "=SUM(A1").is_err()); + assert!(ev(&t, 1, 0, "=A1 A1").is_err()); + assert!(ev(&t, 1, 0, "=A1:").is_err()); + assert!(ev(&t, 1, 0, &format!("={}", "(".repeat(64))).is_err(), "deep nesting is refused"); + } + + #[test] + fn numbers_read_currency_and_separators_but_not_letters() { + assert_eq!(parse_number("$1,200", None), Some(1200.0)); + assert_eq!(parse_number(" 12% ", None), Some(12.0)); + assert_eq!(parse_number("(5)", None), Some(-5.0)); + assert_eq!(parse_number("x9", None), None); + assert_eq!(parse_number("1a", None), None); + assert_eq!(parse_number("inf", None), None); + assert_eq!(parse_number("", None), None); + assert_eq!(parse_number("3,50", Some("pl-PL")), Some(3.5)); + } + + #[test] + fn comparisons_give_one_or_zero_and_do_not_chain() { + let t = table(&[&["5"], &["3"]]); + assert_eq!(ev(&t, 1, 0, "=A1>A2"), Ok(1.0)); + assert_eq!(ev(&t, 1, 0, "=A1<=A2"), Ok(0.0)); + assert_eq!(ev(&t, 1, 0, "=A1<>A2"), Ok(1.0)); + assert_eq!(ev(&t, 1, 0, "=A2>=3"), Ok(1.0)); + assert_eq!(ev(&t, 1, 0, "=A1=5"), Ok(1.0)); + assert_eq!(ev(&t, 1, 0, "=A1A2, 10, 20)"), Ok(10.0)); + assert_eq!(ev(&t, 1, 0, "=IF(A1A2, 1, B1+1)"), Ok(1.0), "the branch not taken is not worked out"); + assert!(ev(&t, 1, 0, "=IF(A11, A2>1)"), Ok(1.0)); + assert_eq!(ev(&t, 1, 0, "=AND(A1>1, A2>4)"), Ok(0.0)); + assert_eq!(ev(&t, 1, 0, "=OR(A1<1, A2>1)"), Ok(1.0)); + assert_eq!(ev(&t, 1, 0, "=OR(A1<1, A2<1)"), Ok(0.0)); + assert_eq!(ev(&t, 1, 0, "=NOT(A1<1)"), Ok(1.0)); + assert_eq!(ev(&t, 1, 0, "=TRUE"), Ok(1.0)); + assert_eq!(ev(&t, 1, 0, "=FALSE()"), Ok(0.0)); + assert!(ev(&t, 1, 0, "=TRUE(1)").is_err()); + assert!(ev(&t, 1, 0, "=IF(A1>A2, 1)").is_err(), "IF takes its three arguments"); + assert!(ev(&t, 1, 0, "=AND()").is_err()); + } + + #[test] + fn defined_says_whether_a_cell_holds_a_value() { + let t = table(&[&["5", "x", ""]]); + assert_eq!(ev(&t, 0, 0, "=DEFINED(A1)"), Ok(1.0)); + assert_eq!(ev(&t, 0, 0, "=DEFINED(B1)"), Ok(0.0), "text isn't a value"); + assert_eq!(ev(&t, 0, 0, "=DEFINED(C1)"), Ok(0.0), "an empty cell isn't"); + assert_eq!(ev(&t, 0, 0, "=IF(DEFINED(C1), C1, 7)"), Ok(7.0)); + } + + #[test] + fn a_numeric_picture_formats_the_result() { + assert_eq!(format_picture(1234.5, "#,##0.00"), "1,234.50"); + assert_eq!(format_picture(0.256, "0%"), "26%"); + assert_eq!(format_picture(2.0, "#.##"), "2"); + assert_eq!(format_picture(0.05, "0.0"), "0.1", "halves round away from zero"); + assert_eq!(format_picture(-5.0, "$#,##0"), "-$5", "one section: a minus in front"); + assert_eq!(format_picture(-5.0, "$#,##0.00;($#,##0.00)"), "($5.00)", "the second section is the negative form"); + assert_eq!(format_picture(0.0, "0.00;-;zero"), "zero"); + assert_eq!(format_picture(7.0, "000"), "007"); + assert_eq!(format_picture(-0.001, "0.00"), "0.00", "a rounded zero has no sign"); + } + + #[test] + fn a_field_switch_picks_the_picture_and_the_formula_stays_apart_from_it() { + let t = table(&[&["1234.5"], &[""]]); + assert_eq!(split_switch("=A1 \\# \"0.00\""), ("=A1", Some("\\# \"0.00\""))); + assert_eq!(split_switch("=A1"), ("=A1", None)); + let shown = |f: &str| evaluate_field(&t, 1, 0, f).unwrap_or_else(|e| panic!("{f}: {e}")); + assert_eq!(shown("=A1 \\# \"#,##0.00\""), "1,234.50"); + assert_eq!(shown("=A1"), "1234.50"); + assert_eq!(shown("=A1 \\# 0"), "1235", "a bare picture word"); + assert_eq!(shown("=A1 \\# \"\""), "1234.50", "an empty picture is none"); + } + + #[test] + fn a_huge_range_over_a_small_table_reads_only_the_table() { + let t = table(&[&["1", "2", "3"], &["4", "5", "6"], &["", "", ""]]); + // Forty ranges reaching to the last possible row and column would be about 80 million + // grid positions; clamped to the table they are 6 numbers each, well inside the budget. + let f = format!("=SUM({})", ["A1:BK32767"; 40].join(",")); + assert!(f.len() <= MAX_FORMULA); + assert_eq!(ev(&t, 2, 0, &f).unwrap(), 21.0 * 40.0); + } + + #[test] + fn an_evaluation_past_the_cell_budget_is_an_error() { + let mut t = Table::new(1000, 10, 468.0); + t.rows[0].cells[0] = Cell::with_text("1"); + // Each range reads 10,000 cells, so eleven of them go past the 100,000-cell budget. + let ok = format!("=SUM({})", ["A1:J1000"; 10].join(",")); + assert_eq!(ev(&t, 0, 1, &ok).unwrap(), 10.0); + let too_much = format!("=SUM({})", ["A1:J1000"; 11].join(",")); + let err = ev(&t, 0, 1, &too_much).unwrap_err(); + assert_eq!(err, CmdError::Failed("Formula too complex".into())); + assert_eq!(evaluate_field(&t, 0, 1, &too_much).unwrap_err().to_string(), "Formula too complex"); + } + + #[test] + fn deep_power_and_sign_chains_are_rejected() { + let t = table(&[&["1"]]); + assert_eq!(ev(&t, 0, 0, "=2^3^2").unwrap(), 512.0); + assert_eq!(ev(&t, 0, 0, "=-+-+2").unwrap(), 2.0); + for f in [format!("={}1", "1^".repeat(40)), format!("={}1", "+".repeat(40)), format!("={}1", "-+".repeat(20))] { + let err = ev(&t, 0, 0, &f).unwrap_err(); + assert!(err.to_string().contains("nested too deeply"), "{f}: {err}"); + } + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d1700cb20..321db0c0c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6063,6 +6063,71 @@ fn format_font_sets_the_asian_and_latin_fonts_separately() { assert!(s.run("format.font", &json!({})).is_err() && s.run("format.font", &json!({"eastAsia": " "})).is_err()); } +/// The result of the formula field in cell `c` of row `r` of the first table, if there is one. +fn formula_result(s: &Session, r: usize, c: usize) -> Option { + let t = s.doc.body.iter().find_map(|b| b.as_table())?; + t.rows.get(r)?.cells.get(c)?.blocks.iter().filter_map(|b| b.as_para()).flat_map(|p| p.objects.iter()).find_map(|o| match o { + wordcraft_doc::InlineObject::Field { instr, result, .. } if instr.starts_with('=') => Some(result.clone()), + _ => None, + }) +} + +/// A 4 × 4 matrix (values 1 to 9 under the header) with an empty fifth row; the caret in its +/// second cell. +fn formula_table() -> Session { + let mut s = s(); + run(&mut s, "table.quick", json!({"kind": "matrix"})); + run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, 3, 1, 0], "off": 0}})); + run(&mut s, "table.insertRowBelow", json!({})); + run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, 4, 1, 0], "off": 0}})); + s +} + +#[test] +fn table_formula_reads_cell_references_and_functions() { + let mut s = formula_table(); + let r = run(&mut s, "table.formula", json!({"formula": "=B2+B3*2"})); + assert_eq!(r["result"], "9"); + assert_eq!(formula_result(&s, 4, 1).as_deref(), Some("9")); + let mut s = formula_table(); + assert_eq!(run(&mut s, "table.formula", json!({"formula": "=IF(SUM(ABOVE)>10, 1, 0)"}))["result"], "1"); + let mut s = formula_table(); + assert_eq!(run(&mut s, "table.formula", json!({"formula": "=SUM(B2:C3) \\# \"0.00\""}))["result"], "12.00"); +} + +#[test] +fn table_formula_with_an_unknown_function_is_an_error_and_inserts_nothing() { + let mut s = formula_table(); + let err = s.run("table.formula", &json!({"formula": "=MEDIAN(ABOVE)"})).err().map(|e| e.to_string()).unwrap_or_default(); + assert!(err.contains("MEDIAN"), "{err}"); + assert_eq!(formula_result(&s, 4, 1), None); + assert!(s.run("table.formula", &json!({"formula": "=SUM()"})).is_err(), "empty parentheses are a syntax error, as in Word"); +} + +#[test] +fn table_formula_bare_function_name_takes_its_direction_from_the_cell() { + let mut s = formula_table(); + let r = run(&mut s, "table.formula", json!({"formula": "=SUM"})); + assert_eq!((r["formula"].as_str(), r["result"].as_str()), (Some("=SUM(ABOVE)"), Some("12"))); +} + +/// Update Field (F9) works a formula out again from the cells it reads. +#[test] +fn update_fields_recalculates_a_table_formula() { + let mut s = formula_table(); + run(&mut s, "table.formula", json!({"formula": "=SUM(ABOVE)"})); + assert_eq!(formula_result(&s, 4, 1).as_deref(), Some("12")); + // Row 1's 1 becomes 10. + run( + &mut s, + "select.range", + json!({"anchor": {"story": "body", "path": [1, 1, 1, 0], "off": 0}, "focus": {"story": "body", "path": [1, 1, 1, 0], "off": 1}}), + ); + run(&mut s, "text.insert", json!({"text": "10"})); + run(&mut s, "references.updateFields", json!({})); + assert_eq!(formula_result(&s, 4, 1).as_deref(), Some("21")); +} + /// A one-column table (no header row) holding `cells`, in a document whose default language /// is `lang`; the caret ends in the last cell. fn column_table(lang: &str, cells: &[&str]) -> Session { From b3b6c451e3bda7be32db32c6356d13ffdbd36378 Mon Sep 17 00:00:00 2001 From: Mohamed Youssef <64752958+ms-youssef@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:34:11 +0300 Subject: [PATCH 100/101] fix(ui): draw Arabic and other non-Latin comment text in balloons (#950) * fix(ui): draw Arabic and other non-Latin comment text in balloons Comment balloons and the Comments pane draw comment text and author names with the interface faces (Inter, Source Sans, CJK), which have no Arabic, Hebrew, Thai and so on: every such letter was a box. The app now borrows a document fallback face that covers the comments, as the window caption does (#840), checked once per document revision. egui also lays bidi runs and words out left to right, so a right-to-left comment read backwards. Read-only balloons now wrap the text by word widths, put each row in display order (Unicode bidi runs, words of a right-to-left run reversed, brackets mirrored) and set right-to-left paragraphs against the right edge. Text without right-to-left letters is laid out exactly as before. The editable fields (the selected balloon and the Comments pane) keep logical order. Also adds my contributor entry (sync_github_name). Co-Authored-By: Claude Opus 5.5 * One contributor entry each Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Mohamed Youssef Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- Cargo.lock | 1 + ROADMAP.md | 1 + contributors/people.toml | 3 + crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/canvas.rs | 175 ++++++++++++++++++++++++++++++++++- crates/ui-egui/src/lib.rs | 65 +++++++++++++ docs/ui-parity.md | 1 + 7 files changed, 243 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 60708446f..6f7c8bf02 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5394,6 +5394,7 @@ dependencies = [ "serde_json", "sys-locale", "toml", + "unicode-bidi", "wordcraft-doc", "wordcraft-engine", "wordcraft-fonts", diff --git a/ROADMAP.md b/ROADMAP.md index 094b504bc..6ef6b3370 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -239,6 +239,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-12 | Ribbon groups fit above their labels: View › Show splits its four check boxes into two columns (Hide White Space, #645, had pushed the fourth onto the "Show" label), Developer › Controls loses the gaps that ran into its label, and Layout › Paragraph (#654) no longer trips a debug assertion and sits 2.5 pt higher; a test draws every tab in every language and fails on any group taller than the ribbon | | 2026-10-12 | Down and Up from the start of a line beside a table go into it (#1007): the caret, left of the first cell's text there, used to skip the whole table to the paragraph beyond it; it now lands in the cell under it or the nearest one, as in Word | | 2026-10-12 | Window caption: a non-Latin file name is drawn with a document fallback face (or the CJK UI font), instead of `?` | +| 2026-10-12 | Comments: Arabic, Hebrew and other non-Latin comment text and author names are drawn with a document fallback face (or the CJK UI font) in the balloons and Comments pane, instead of boxes; read-only balloons show right-to-left text in reading order, set against the right edge | | 2026-10-13 | Word 365 cover pages (#743): shapes placed relative to the page's height now sit where Word puts them (they were at the top of the column), and a freeform drawn back to its start without being closed is filled, so the cover's coloured panel and its white title show | | 2026-10-12 | 32-bit Windows starts on OpenGL. DirectX 12 graphics pipeline creation returns "The parameter is incorrect" and the app exited before the window appeared | | 2026-10-12 | New documents follow the system region (#838): A4 and centimetres outside the US (Letter in the CLDR Letter territories, inches in the US), lengths written with the region's decimal separator (`2,54 cm`) and typed with either; an explicit Measurement units choice wins; `file.options {"region"}` for agents | diff --git a/contributors/people.toml b/contributors/people.toml index bda2f3bbc..6a405d053 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -100,6 +100,9 @@ sync_github_name = true [people.leoncral] sync_github_name = true +[people.ms-youssef] +sync_github_name = true + [people.finwarman] real_name = "Fin Warman" diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index f4a0b6645..28a0c979f 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -12,6 +12,7 @@ workspace = true craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" } wordcraft-geom = { workspace = true } wordcraft-doc = { workspace = true } +unicode-bidi = { workspace = true } wordcraft-fonts = { workspace = true } wordcraft-layout = { workspace = true } wordcraft-render = { workspace = true } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ba2557df0..4eca0d304 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1754,14 +1754,14 @@ fn balloons(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, rects: &[Re Rect::from_min_size(pos2(x0, top), vec2(w, app.canvas.balloon_h.max(24.0))) } else { let dim = |r: bool| if r { t.text_dim } else { t.text }; - lines.push((painter.layout(author, semibold(fs), t.text, w - 16.0), 0.0)); + lines.push((balloon_text(painter, author, semibold(fs), t.text, w - 16.0), 0.0)); let body = app.session.doc.plain_text(StoryRef::Part(c.part)); - lines.push((painter.layout(body.trim().to_string(), regular(fs), dim(resolved), w - 16.0), 4.0)); + lines.push((balloon_text(painter, body.trim().to_string(), regular(fs), dim(resolved), w - 16.0), 4.0)); for rid in &thread { let Some(r) = app.session.doc.comments.get(rid) else { continue }; - lines.push((painter.layout(author_name(&r.author), semibold(fs), t.text, w - 16.0), 9.0)); + lines.push((balloon_text(painter, author_name(&r.author), semibold(fs), t.text, w - 16.0), 9.0)); let text = app.session.doc.plain_text(StoryRef::Part(r.part)); - lines.push((painter.layout(text.trim().to_string(), regular(fs), dim(resolved), w - 16.0), 4.0)); + lines.push((balloon_text(painter, text.trim().to_string(), regular(fs), dim(resolved), w - 16.0), 4.0)); } let h = lines.iter().map(|(g, gap)| g.size().y + gap).sum::() + 12.0; Rect::from_min_size(pos2(x0, top), vec2(w, h)) @@ -1840,6 +1840,108 @@ fn card_shapes(painter: &egui::Painter, t: &Tokens, card: Rect, color: Color32, } } +/// Does `text` hold right-to-left letters (Arabic, Hebrew, Syriac, Thaana, N'Ko …)? +fn has_rtl(text: &str) -> bool { + text.chars().any(|c| matches!(unicode_bidi::bidi_class(c), unicode_bidi::BidiClass::R | unicode_bidi::BidiClass::AL)) +} + +/// The row `line` of paragraph `p` of `para` in display order, for egui: the bidi runs left to +/// right as they are shown, and inside a right-to-left run its words reversed. The letters of each +/// word stay in logical order, because egui's shaper already runs an Arabic word right to left. +fn visual_row(info: &unicode_bidi::BidiInfo, p: &unicode_bidi::ParagraphInfo, para: &str, line: std::ops::Range) -> String { + // `visual_runs` indexes the line's first byte: an empty line (an empty last row) would panic. + if line.start >= line.end || line.end > p.range.end { + return String::new(); + } + let (levels, runs) = info.visual_runs(p, line); + let mut out = String::new(); + for run in runs { + let rtl = levels.get(run.start).is_some_and(|l| l.is_rtl()); + // Runs lie on character boundaries; a missing slice is skipped, never a panic. + let Some(piece) = para.get(run) else { continue }; + if rtl { + let mut words: Vec<&str> = piece.split_inclusive(char::is_whitespace).collect(); + words.reverse(); + for w in words { + // The space that followed a word now goes before it. + let word = w.trim_end_matches(char::is_whitespace); + out.push_str(w.get(word.len()..).unwrap_or_default()); + out.extend(word.chars().map(mirrored)); + } + } else { + out.push_str(piece); + } + } + out +} + +/// `c` as drawn in a right-to-left run: brackets face the other way (Unicode's Bidi_Mirrored +/// pairs that interface text uses), since egui draws the character it is given. +fn mirrored(c: char) -> char { + match c { + '(' => ')', + ')' => '(', + '[' => ']', + ']' => '[', + '{' => '}', + '}' => '{', + '<' => '>', + '>' => '<', + '«' => '»', + '»' => '«', + '‹' => '›', + '›' => '‹', + _ => c, + } +} + +/// A read-only balloon's text, wrapped to `width`. egui joins Arabic letters but places them left +/// to right, so a right-to-left comment read its words backwards. Each wrapped row is put into +/// display order (Unicode bidi, per paragraph) and a right-to-left paragraph's rows are set against +/// the right edge. Text without right-to-left letters is laid out exactly as before. +fn balloon_text(painter: &egui::Painter, text: String, font: egui::FontId, color: Color32, width: f32) -> std::sync::Arc { + if !has_rtl(&text) { + return painter.layout(text, font, color, width); + } + let format = egui::TextFormat::simple(font.clone(), color); + let mut job = egui::text::LayoutJob::default(); + let mut rows: Vec<(String, bool)> = Vec::new(); + for para in text.split('\n') { + let info = unicode_bidi::BidiInfo::new(para, None); + if info.paragraphs.is_empty() { + rows.push((String::new(), false)); + } + for p in &info.paragraphs { + let rtl = p.level.is_rtl(); + // Wrap in logical order at spaces, by the words' widths (the order doesn't change a word's + // width), then put each row in display order. egui's own rows can't be used: it draws a + // lam-alef pair as one glyph, so its glyph count isn't the character count. + let mut start = p.range.start; + let mut end = start; + let mut used = 0.0; + for word in para.get(p.range.clone()).unwrap_or_default().split_inclusive(char::is_whitespace) { + let w = painter.layout_no_wrap(word.trim_end().to_string(), font.clone(), color).size().x; + let gap = painter.layout_no_wrap(word.get(word.trim_end().len()..).unwrap_or_default().to_string(), font.clone(), color).size().x; + if end > start && used + w > width { + rows.push((visual_row(&info, p, para, start..end).trim().to_string(), rtl)); + start = end; + used = 0.0; + } + used += w + gap; + end += word.len(); + } + rows.push((visual_row(&info, p, para, start..end).trim().to_string(), rtl)); + } + } + let last = rows.len().saturating_sub(1); + for (i, (row, rtl)) in rows.into_iter().enumerate() { + let lead = if rtl { (width - painter.layout_no_wrap(row.clone(), font.clone(), color).size().x).max(0.0) } else { 0.0 }; + let row = if i < last { row + "\n" } else { row }; + job.append(&row, lead, format.clone()); + } + painter.layout_job(job) +} + /// The selected balloon: the comment and its replies as editable fields (each edit is one undo /// step via `review.editComment`), an optional reply field, and Reply / Resolve / Delete. /// `card` carries last frame's size; returns this frame's. @@ -3455,6 +3557,71 @@ mod tests { /// #167: a comment balloon edits in place like Word's: click it, type, leave it, and the /// comment has the new text as one undo step. + /// Lays `text` out as a read-only balloon does, with a document face that covers Arabic, and + /// returns each glyph's character and left edge. + fn balloon_glyphs(text: &str, width: f32) -> Option> { + let face = wordcraft_fonts::FontDb::global().fallback_for('ش', u32::MAX)?; + let ctx = egui::Context::default(); + let mut fonts = crate::theme::font_definitions(false, false); + crate::theme::add_fallback_faces(&mut fonts, &[face]); + ctx.set_fonts(fonts); + ctx.run_ui(egui::RawInput::default(), |_| {}).drop_without_applying_deltas(); + let mut out = Vec::new(); + ctx.run_ui(egui::RawInput::default(), |ui| { + let g = balloon_text(ui.painter(), text.to_string(), egui::FontId::proportional(14.0), Color32::BLACK, width); + for r in &g.rows { + out.extend(r.row.glyphs.iter().map(|gl| (gl.chr, r.pos.x + gl.pos.x))); + } + }) + .drop_without_applying_deltas(); + Some(out) + } + + /// Where egui draws a right-to-left balloon's words, measured: the first word on the right. + #[test] + fn a_right_to_left_balloon_reads_from_the_right() { + let x = |g: &[(char, f32)], c: char| g.iter().find(|(ch, _)| *ch == c).map(|(_, x)| *x).unwrap(); + let Some(g) = balloon_glyphs("شركة اي", 400.0) else { return }; + assert!(x(&g, 'ش') > x(&g, 'ي'), "the first word is right of the second: {g:?}"); + assert!(x(&g, 'ش') > 300.0, "the line is set against the right edge: {g:?}"); + // A Latin word inside keeps its letters left to right; its brackets face it. + let g = balloon_glyphs("الربط (APIs) هنا", 400.0).unwrap(); + assert!(x(&g, 'A') < x(&g, 's')); + assert!(x(&g, 'ط') > x(&g, 'A') && x(&g, 'A') > x(&g, 'ه'), "{g:?}"); + // The opening bracket, on the right in Arabic, is drawn mirrored: `(APIs)` on screen. + assert!(x(&g, '(') < x(&g, 'A') && x(&g, ')') > x(&g, 's'), "brackets face the word: {g:?}"); + // A paragraph that starts in Latin runs left to right. + let g = balloon_glyphs("@Ahmed برجاء مراجعة", 400.0).unwrap(); + assert!(x(&g, '@') < x(&g, 'ب') && x(&g, '@') < 50.0, "{g:?}"); + assert!(x(&g, 'ب') > x(&g, 'م'), "the Arabic words still read from the right: {g:?}"); + // Wrapped rows stay inside the width. + let long = "بيان الأنظمة المطلوب ربطها ومتطلبات التكامل وواجهات الربط البرمجي وآلية تبادل البيانات"; + let g = balloon_glyphs(long, 200.0).unwrap(); + assert!(g.iter().all(|(_, x)| *x <= 200.0), "{g:?}"); + } + + #[test] + fn balloon_rows_put_right_to_left_words_in_display_order() { + let row = |text: &str| { + let info = unicode_bidi::BidiInfo::new(text, None); + let p = info.paragraphs.first().unwrap(); + visual_row(&info, p, text, p.range.clone()) + }; + // Words swap, each word's letters stay as they are (egui shapes the word). + assert_eq!(row("شركة اي للكهرباء"), "للكهرباء اي شركة"); + // A Latin name and a number inside Arabic keep their own order. + assert_eq!(row("يجب ربط Odoo 19 هنا"), "هنا Odoo 19 ربط يجب"); + assert_eq!(row("Should include Meters system API"), "Should include Meters system API"); + assert!(!has_rtl("Should include Meters system API")); + assert!(has_rtl("נא לבדוק")); + // An empty row at the end of the text (egui's last row after a wrap) is empty, not a panic. + let text = "شركة اي"; + let info = unicode_bidi::BidiInfo::new(text, None); + let p = info.paragraphs.first().unwrap(); + assert_eq!(visual_row(&info, p, text, text.len()..text.len()), ""); + assert_eq!(visual_row(&info, p, text, 0..text.len() + 5), ""); + } + #[test] fn a_balloon_edits_its_comment() { let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6fd52cb76..662516fbd 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -469,6 +469,9 @@ pub struct WordApp { /// CJK text is (about to be) on screen in a non-CJK interface, e.g. the language names in /// File ▸ Options: load the installed CJK fallback font if no embedded face covers it. pub(crate) want_system_cjk: bool, + /// The document (id, revision) whose comment text [`WordApp::ensure_comment_fonts`] last + /// looked at. + comment_fonts_for: Option<(u64, u64)>, fonts_frames: u32, /// The effective interface theme last installed in egui ([`theme::apply`]). applied_theme: Option, @@ -595,6 +598,7 @@ impl WordApp { fonts_korean: false, fonts_system_cjk: false, want_system_cjk: false, + comment_fonts_for: None, fonts_frames: 0, applied_theme: None, native_scale: 1.0, @@ -1678,6 +1682,35 @@ impl WordApp { } } + /// Comment balloons and the Comments pane draw comment text and author names with the + /// interface faces, which have no Arabic, Hebrew, Thai …: each such letter was drawn as a box. + /// Borrow the document fonts' fallback faces for them, as the caption does. Looked at once per + /// document revision; CJK text uses the CJK UI font instead. + fn ensure_comment_fonts(&mut self) { + let key = (self.session.document_id(), self.session.rev()); + if self.comment_fonts_for == Some(key) { + return; + } + self.comment_fonts_for = Some(key); + let doc = &self.session.doc; + let mut chars = std::collections::BTreeSet::new(); + for c in doc.comments.values() { + chars.extend(c.author.chars()); + chars.extend(doc.plain_text(wordcraft_doc::StoryRef::Part(c.part)).chars()); + } + if chars.iter().any(|c| wordcraft_fonts::is_cjk(*c)) { + self.want_system_cjk = true; + } + let text: String = chars.into_iter().collect(); + let new = theme::faces_for_preedit(&text, &self.canvas.ime_faces, theme::caption_char_covered, |c| { + wordcraft_fonts::FontDb::global().fallback_for(c, u32::MAX) + }); + if !new.is_empty() { + self.canvas.ime_faces.extend(new); + self.styled = false; + } + } + /// Document title for the title bar (and the suggested file name). An untitled document is /// named in the interface language, as `Document1` is in English. pub fn title_stem(&self) -> String { @@ -1724,6 +1757,7 @@ impl WordApp { // language names in Options, or a file name the caption is drawing. A file opened later // in this frame (dialog, drop, control channel) is picked up again at the end. self.ensure_caption_fonts(); + self.ensure_comment_fonts(); self.install_ui_fonts(ctx, lang); if let Some(answer) = self.desktop_theme.take_change() { self.desktop_dark = Some(answer); @@ -1833,6 +1867,7 @@ impl WordApp { } // The open above may have changed the file name the caption draws. self.ensure_caption_fonts(); + self.ensure_comment_fonts(); self.install_ui_fonts(ctx, lang); self.report_dirty(); } @@ -2496,6 +2531,36 @@ mod tests { assert!(cjk.canvas.ime_faces.is_empty(), "CJK collections are not copied into the fallback list"); } + /// Comment balloons draw comment text and author names with the interface faces, which have + /// no Arabic, so an Arabic comment was all boxes. A document with one borrows a document + /// fallback face that covers it, once per document revision. + #[test] + fn arabic_comment_text_is_drawn_in_the_balloons() { + let ctx = egui::Context::default(); + let frame = |app: &mut WordApp| { + ctx.run_ui(egui::RawInput::default(), |ui| app.logic(ui.ctx())).drop_without_applying_deltas(); + }; + let mut latin = app(); + latin.run("text.insert", json!({"text": "Scope of works"})).unwrap(); + latin.run("select.text", json!({"text": "Scope"})).unwrap(); + latin.run("review.newComment", json!({"text": "Please check"})).unwrap(); + frame(&mut latin); + assert!(latin.canvas.ime_faces.is_empty(), "a Latin comment needs no extra face"); + + let mut arabic = app(); + arabic.run("text.insert", json!({"text": "Scope of works"})).unwrap(); + arabic.run("select.text", json!({"text": "Scope"})).unwrap(); + arabic.run("review.newComment", json!({"text": "لتحديد نطاق الأعمال"})).unwrap(); + frame(&mut arabic); + let letter = 'ن'; + if wordcraft_fonts::FontDb::global().fallback_for(letter, u32::MAX).is_some_and(|f| f.data().len() <= 16 << 20) { + assert!(arabic.canvas.ime_faces.iter().any(|f| f.covers(letter)), "the balloons borrow a face that covers the comment"); + } + let n = arabic.canvas.ime_faces.len(); + frame(&mut arabic); + assert_eq!(arabic.canvas.ime_faces.len(), n, "the face is added once"); + } + fn app() -> WordApp { WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()) } diff --git a/docs/ui-parity.md b/docs/ui-parity.md index ac498d8c5..b1c503f5c 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -115,6 +115,7 @@ Document title opens Rename Document; the account badge opens Options › Genera | 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-12 | trivial | Keytips use Word for Windows' tab letters, as observed there: File F (opens the Backstage), Design G, References S, Review R, View W, Help Y (were L, R, S, V and none) | +| 2026-10-12 | trivial | Comments: comment text and author names in a script the interface fonts lack (Arabic, Hebrew, and the rest; CJK via the CJK UI font) are drawn with a fallback face in the balloons and Comments pane instead of boxes; read-only balloons show right-to-left text in reading order (words, bidi runs and brackets), set against the right edge | | 2026-10-13 | trivial | Keytips reach the contextual table tabs with Word's two-letter badges: Alt, J, T opens Table Design and Alt, J, L Table Layout; after J only the J badges stay (#1183) | | 2026-10-12 | trivial | Shift+F10 opens the page's context menu below the caret, with the spelling suggestions and synonyms for the word there; `ui.contextMenu` for agents (#821). The Menu key itself isn't passed on by the windowing layer yet | | 2026-10-12 | trivial | Search: Alt+Q (and Alt, Q) opens the Search box from anywhere, as in Word; the key's own "q" isn't typed; every word of a query matches, so "insert table" finds Insert › Tables › Table; `tools.search` lists matches for agents (#819) | From fd0bb2fb1ec081e3eddc4d01917637fef0cafdfe Mon Sep 17 00:00:00 2001 From: Justin John <34035011+justinjohn0306@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:05:42 +0530 Subject: [PATCH 101/101] =?UTF-8?q?#954:=20macOS=20=E2=8C=98=E2=86=90/?= =?UTF-8?q?=E2=8C=98=E2=86=92=20go=20to=20the=20line's=20start=20and=20end?= =?UTF-8?q?=20(#972)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On macOS, Cmd+Left/Right were bound to Word Left/Right (same as Option+arrows). Bind them to Line Start/End there, as in Word for Mac and every Mac text field; Option+arrows still move by word. Windows/Linux keys are unchanged. Co-authored-by: justinjohn0306 Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/engine/src/cmd/caret.rs | 17 ++++++++++++----- crates/engine/src/tests.rs | 5 +++++ docs/typing-parity.md | 2 ++ 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/caret.rs b/crates/engine/src/cmd/caret.rs index 50c1cd019..c4e38a2ba 100644 --- a/crates/engine/src/cmd/caret.rs +++ b/crates/engine/src/cmd/caret.rs @@ -14,17 +14,24 @@ pub fn specs() -> Vec { CommandSpec::new("caret.right", "Right", "Navigation", |s, v| lr(s, v, false)).key("Right").params(EXTEND).pure(), CommandSpec::new("caret.up", "Up", "Navigation", |s, v| vert(s, v, -1)).key("Up").params(EXTEND).pure(), CommandSpec::new("caret.down", "Down", "Navigation", |s, v| vert(s, v, 1)).key("Down").params(EXTEND).pure(), - // In a right-to-left paragraph the word to the left is the next one. + // In a right-to-left paragraph the word to the left is the next one. On macOS ⌘←/⌘→ go + // to the line's start and end, as in Word for Mac and every Mac text field (#954). CommandSpec::new("caret.wordLeft", "Word Left", "Navigation", |s, v| mv(s, v, if rtl_para(s) { word_right } else { word_left })) - .key("Mod+Left / Alt+Left") + .key(super::mac_or("Alt+Left", "Mod+Left / Alt+Left")) .params(EXTEND) .pure(), CommandSpec::new("caret.wordRight", "Word Right", "Navigation", |s, v| mv(s, v, if rtl_para(s) { word_left } else { word_right })) - .key("Mod+Right / Alt+Right") + .key(super::mac_or("Alt+Right", "Mod+Right / Alt+Right")) + .params(EXTEND) + .pure(), + CommandSpec::new("caret.home", "Line Start", "Navigation", |s, v| line(s, v, true)) + .key(super::mac_or("Home / Mod+Left", "Home")) + .params(EXTEND) + .pure(), + CommandSpec::new("caret.end", "Line End", "Navigation", |s, v| line(s, v, false)) + .key(super::mac_or("End / Mod+Right", "End")) .params(EXTEND) .pure(), - CommandSpec::new("caret.home", "Line Start", "Navigation", |s, v| line(s, v, true)).key("Home").params(EXTEND).pure(), - CommandSpec::new("caret.end", "Line End", "Navigation", |s, v| line(s, v, false)).key("End").params(EXTEND).pure(), CommandSpec::new("caret.paraUp", "Paragraph Up", "Navigation", |s, v| mv(s, v, para_up)).key("Mod+Up / Alt+Up").params(EXTEND).pure(), CommandSpec::new("caret.paraDown", "Paragraph Down", "Navigation", |s, v| mv(s, v, para_down)) .key("Mod+Down / Alt+Down") diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 321db0c0c..038786e02 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -122,6 +122,11 @@ fn word_default_shortcuts_resolve() { assert_eq!(id(cmd::mac_or("Ctrl+Q", "Mod+Q")), Some("para.reset")); assert_eq!(id(cmd::mac_or("Ctrl+Space", "Mod+Space")), Some("format.resetChar")); assert_eq!(id(cmd::mac_or("Ctrl+Shift+D", "Alt+Shift+D")), Some("insert.dateField")); + // Option+arrows move by word everywhere; ⌘←/⌘→ go to the line's ends on macOS (#954). + assert_eq!(id("Alt+Left"), Some("caret.wordLeft")); + assert_eq!(id("Alt+Right"), Some("caret.wordRight")); + assert_eq!(id("Mod+Left"), Some(cmd::mac_or("caret.home", "caret.wordLeft"))); + assert_eq!(id("Mod+Right"), Some(cmd::mac_or("caret.end", "caret.wordRight"))); assert_eq!(id("Shift+F1"), Some("styles.inspector")); assert_eq!(id("Alt+F8"), Some("tools.macros")); assert_eq!(id("Mod+Shift+X"), Some("format.strikethrough")); diff --git a/docs/typing-parity.md b/docs/typing-parity.md index 7abd05d4d..f4de6ecab 100644 --- a/docs/typing-parity.md +++ b/docs/typing-parity.md @@ -74,6 +74,7 @@ Windows/Linux keys are given first, macOS second. | Alt+Shift+Left / Right (⌃⇧← / ⌃⇧→) | Promote / Demote: a heading goes up or down one level (Heading 1 and Heading 9 are the ends); promoted body text becomes a heading at the level of the heading before it; a list item changes level like Shift+Tab / Tab | `alt_shift_left_and_right_promote_and_demote_headings_and_list_items` | | Outline view: Alt+Shift+Up / Down, Left / Right | as above, but a collapsed heading takes what is hidden under it along: moving passes whole shown paragraphs with their hidden content; promote/demote shifts the hidden subheadings too | `outline_collapse_hides_descendants_and_expand_restores` | | Outline view: Alt+Shift+Plus / Minus, Alt+Shift+1–9, Alt+Shift+A | Expand / Collapse the heading (one level at a time); Show Level 1–9; show all levels. The caret never stays in hidden text: it moves on to the next shown paragraph | `outline_show_level_filters_paragraphs` | +| Ctrl+Left / Right (⌥← / ⌥→); Home / End (⌘← / ⌘→) | one word left / right; the start / end of the line. On macOS ⌘← / ⌘→ go to the line's ends, as in every Mac text field, and ⌥ moves by word (#954); Shift extends the selection | `word_default_shortcuts_resolve` | | Shift+F5 | Go Back: the caret cycles through the last four places edited, most recent first | `shift_f5_goes_back_through_the_last_edits` | | Alt+X (no macOS default) | the hex code before the caret (up to six digits, `U+` optional) or the selected code becomes its character; otherwise the character before the caret becomes its code (`é` ↔ `00E9`); one undo step | `alt_x_toggles_between_a_character_and_its_code` | | Shift+Backspace / Shift+Delete | Shift+Backspace erases like Backspace (#707). Shift+Delete cuts the selection to the clipboard and does nothing without one (Word for Windows, observed 2026-10-14). Windows delivers Shift+Delete as a Cut; macOS and Linux deliver the key, which did nothing (#1669) | `shift_delete_cuts_the_selection_and_shift_backspace_erases` | @@ -205,6 +206,7 @@ Word. | 2026-10-14 | trivial | Track Changes: empty tables are removed under tracking; Delete Columns leaves merged cells that keep a column; repeated Change Case reads only live text (#1329 review) | | 2026-10-14 | trivial | Track Changes: table deletes mark the cells' text deleted and keep the table; Change Case is a tracked delete+insert (differs from Word's hidden formatting revision); Unlink Fields stays untracked; all three refuse locked content controls (#1329) | | 2026-10-12 | trivial | Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864) | +| 2026-10-12 | trivial | macOS: ⌘← / ⌘→ go to the line's start and end instead of moving by word (#954) | | 2026-10-12 | minor | AutoCorrect: Correct TWo INitial CApitals, accidental cAPS LOCK, capitalize first letter of table cells (#544) | | 2026-10-12 | trivial | Editing keys: Shift+Right at the end of a cell's text selects the cell first, then the next cell with it (#971) | | 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) |