From 9b6e9564a2d276a0a7898c75580bf6708d0a21d8 Mon Sep 17 00:00:00 2001 From: Diego Guerini <106814760+D13GOOOO@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:20:49 +0100 Subject: [PATCH 01/42] Lists: a numFmt none level shows its literal text, so RESPONSE: labels the paragraph (#4985) (#5055) * #4985: a numFmt none list level shows its literal text Word labels a paragraph whose level has numFmt none and lvlText RESPONSE: with RESPONSE:. Counters::next_label returned no label for any none level; now only a none level of placeholders shows nothing. Its own %n stays empty, and it still doesn't count. Numbering::shows_label agrees. Co-Authored-By: Claude Opus 5.5 * #4985: rustfmt Co-Authored-By: Claude Opus 5.5 * #4985: a "none" list level counts, and only its own number is empty Taken from MeHail-02's numbering-literal-none branch (46405e66), checked in Word for Windows: a none level is an ordinary level whose own number reads as nothing. It counts and restarts the levels below it, stays empty in legal numbering, and an empty label still tabs the text to the indent unless it already ends there (the Chapter 1 list's Heading 2 stays at the margin). Co-Authored-By: MeHail Co-Authored-By: Claude Opus 5.5 * #4985: STYLEREF in full context keeps a none-level heading's parent Review: a none level now has a label, so its headings reach the style index, which leaves the none level out of the levels it keeps; \w and \r took the last kept level for the heading's own and dropped the parent. Levels now carry their index, and the levels above are picked by it. Co-Authored-By: Claude Opus 5.5 * #4985: STYLEREF number switches on a none-level heading Review: in full context an empty label of its own added a trailing period (3.), and the suppress switch gave a none level's label (Note), which holds no number, back whole. An empty own part is left out, and a none level's label with no number is suppressed to nothing. Co-Authored-By: Claude Opus 5.5 * #4985: STYLEREF \s and \t on none-level labels Review: \s took a none level's label (Note) for a chapter number; the heading's count stands in, as for an unnumbered heading. \t matched any ancestor's number inside a word (the a of Chapter); only the numbers the label's template shows count now. Co-Authored-By: Claude Opus 5.5 * #4985: STYLEREF takes a label's numbers by their spans Review: \s took the last word of a label (Response of "3 Response") and fell back on the heading count for a none level showing its parent's number (%1); \t kept text attached to a number (Section3). A label's numbers are now the spans of the values its template shows, with their delimiters (a letter number only standing apart from other letters); \s takes the last of them. Co-Authored-By: Claude Opus 5.5 * #4985: tests edit the numbering through Arc::make_mut after merging main Main shares the numbering between undo steps (#2694), so `Document::numbering` is an `Arc`; the tests this PR adds wrote to it directly and no longer built. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: MeHail Co-authored-by: Martin Ball --- ROADMAP.md | 7 ++- crates/doc/src/numbering.rs | 43 +++++++++++--- crates/docx/tests/it/fixtures.rs | 25 ++++++++ crates/engine/src/cmd/lists.rs | 3 +- crates/layout/src/para/lines.rs | 3 + crates/layout/src/styleref.rs | 91 +++++++++++++++++++++++------ crates/layout/src/tests.rs | 24 ++++++++ crates/layout/src/tests/styleref.rs | 43 ++++++++++++++ docs/gaps.md | 4 +- docs/layout-parity.md | 3 +- 10 files changed, 216 insertions(+), 30 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 6549c2d8bf..2418e28c8f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -156,6 +156,10 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-11-05: ODT pictures offered in several formats show and survive a save (gap 19): LibreOffice's letter templates lost theirs. - 2026-11-07: An in-line picture lowered past its own height (`w:position`) lowers its line as far, as in Word; all 156 synthetic lines measured for #3984 match Word (#5796). Exact spacing keeps the line's baseline. - 2026-11-03: Search types in place: a click on the title bar's Search box, or Alt+Q, takes the typing in that box, with the results under it, as in Word, instead of a second box in a dialog (#5398). +- 2026-10-31: List labels: a numbering level with format `none` and literal text, such as `RESPONSE:` with a one-inch hanging indent, shows that text as its label, as Word does; it used to show nothing. As in Word, a `none` level still counts and restarts the levels below it, its own number is empty (in legal numbering too), and an empty label still tabs the text to the indent (#4985). Readiness unchanged. +- 2026-11-01: DOCX tables keep rows and cells wrapped in `mc:AlternateContent`, selecting one supported Choice or Fallback with the existing reader policy (#5219). Original synthetic checks cover namespace aliases, unsupported requirements, nested row/cell content controls and two save/reopen cycles; broader corpus and native Word qualification remain open. + +- 2026-11-01: Shapes: a line's arrowheads are as long and as wide as Word draws them: 2, 3 or 5 times the line's width for small, medium and large (a line under 2 points as a 2-point one). Their kind, width and length are read from DOCX (DrawingML and VML) and saved back; before, every arrowhead was one size and saved as a medium triangle. - 2026-11-06: Windows print submissions run in separate helper processes (#5710). A helper acknowledges cancellation before it stops, so a driver stalled in a cancelled job cannot hold another job’s provider lock. A private temporary PDF and bounded settings messages cross the process boundary; normal shutdown reaps helpers and removes their inputs. The helper erases its plaintext inputs before driver work; a hard app crash during startup can still leave private temporary files. Jobs already sending still complete. Readiness estimates unchanged. - 2026-11-06: File › Print: before sending starts, Cancel printing stops a waiting job, frees the Print page and keeps the settings; a successfully cancelled job is never sent. Cancellation remains available after the last document closes. Quitting cancels waiting jobs and joins jobs already sending (#5710). On Windows, winprint repeats the DEVMODE conversion after sending starts. A stall during that repeat prevents cancellation and can keep shutdown waiting, even before a native job exists. Readiness estimates unchanged. - 2026-11-06: File › Print on Windows no longer waits on printer drivers to list printers (#5710). The list used to wait until each printer's driver said whether it prints on both sides, and a networked printer asleep in power save could keep it empty with Refresh unavailable; now discovery asks no driver (the Windows spooler still supplies the list), and printing still checks the job's options with the driver. The reporter's sleeping printer has not been retested yet. Readiness estimates unchanged. @@ -834,6 +838,8 @@ MATH glyphs that reference the same construction now share its immutable variant | 2026-11-05 | trivial | Page Setup's margin, paper-size and document-grid number boxes scroll long text inside the field instead of enlarging the dialog (#5650). Readiness estimates unchanged. | | 2026-11-07 | trivial | Progress log: An in-line picture lowered past its own height (`w:position`) lowers its line as far, as in Word; all 156 synthetic lines measured for #3984 match Word (#5796). | | 2026-11-03 | trivial | Progress log: the title bar's Search box takes the typing in place (#5398) | +| 2026-10-31 | trivial | Progress log: a numFmt none list level shows its literal text (#4985); readiness unchanged | +| 2026-11-02 | trivial | Progress log: alternative format chunks and MHT open | | 2026-11-06 | trivial | Print helpers remove loaded PDF/settings files before driver work, tolerate prior removal and report cleanup errors; a held-driver regression checks the files are already absent (#5710) | | 2026-11-06 | trivial | Windows print submissions isolated per helper process; cancelled driver work cannot block a retry (#5710) | | 2026-11-06 | trivial | Progress log: print cancellation before sending, including without a document, and the Windows repeated-conversion limitation (#5710) | @@ -883,7 +889,6 @@ MATH glyphs that reference the same construction now share its immutable variant | 2026-11-01 | trivial | Progress log: Language packs install from File › Options › Language and the missing-pack bar once language-packs-1 is published (CP4) | | 2026-11-01 | trivial | Progress log: English spelling reads SCOWL 2026 (CP2) | | 2026-11-01 | trivial | Progress log: arrowheads keep their kind, width and length and are drawn at Word's sizes | -| 2026-11-02 | trivial | Progress log: alternative format chunks and MHT open | | 2026-11-01 | trivial | Progress log: columns for selected text draw their line between (#5178) | | 2026-10-31 | trivial | Progress log: a Word 365 .doc in Aptos opens in Aptos (#5066); readiness unchanged | | 2026-10-31 | trivial | Progress log: a table row doesn't split off a picture that doesn't fit (#4272) | diff --git a/crates/doc/src/numbering.rs b/crates/doc/src/numbering.rs index a5d2ec6e10..5ab94ea877 100644 --- a/crates/doc/src/numbering.rs +++ b/crates/doc/src/numbering.rs @@ -228,7 +228,7 @@ impl Numbering { /// Whether a paragraph in list `num` at `level` gets a label ([`Counters::next_label`] gives /// one), without counting it. pub fn shows_label(&self, num: u32, level: u8) -> bool { - self.abstract_of(num).is_some() && self.level(num, level.min(8)).is_some_and(|d| d.format != NumFormat::None) + self.abstract_of(num).is_some() && self.level(num, level.min(8)).is_some() } pub fn level(&self, num: u32, level: u8) -> Option<&Level> { if let Some((_, l)) = self.num(num)?.level_overrides.iter().find(|(i, _)| *i == level) { @@ -325,12 +325,9 @@ impl Counters { let lv = (level as usize).min(8); // This list's own definition of a level wins over its abstract list's. let level_def = |k: usize| numbering.level(num, k as u8); + // A "none" level (Word's numFmt none) still counts and restarts the levels below it; only + // its number reads as nothing, so its literal text ("RESPONSE:") shows (#4985). let def = level_def(lv)?.clone(); - // A "none" level shows no label at all (Word's numFmt none) — not even its text. - if def.format == NumFormat::None { - self.labels.insert((abs.id, lv as u8), CountedLabel { label: None, levels: Vec::new() }); - return None; - } let st = self.state.entry(abs.id).or_insert([0; 9]); let fl = self.flags.entry(abs.id).or_default(); // Only this level's override, not the num's others: the counters are shared by every @@ -382,7 +379,14 @@ impl Counters { let started = *fl; let numeric = def.format != NumFormat::Bullet; let label = if numeric { render_label(numbering, num, &def, &vals, &started) } else { def.text.clone() }; - self.labels.insert((abs.id, lv as u8), CountedLabel { label: numeric.then(|| label.clone()), levels: template_levels(&def.text) }); + // A "none" level shows its literal text, but has no number of its own for a reference to + // show (#4985): it is kept as one that shows none. + let counted = if def.format == NumFormat::None { + CountedLabel { label: None, levels: Vec::new() } + } else { + CountedLabel { label: numeric.then(|| label.clone()), levels: template_levels(&def.text) } + }; + self.labels.insert((abs.id, lv as u8), counted); Some((label, def)) } /// Which levels of `num`'s list have counted a paragraph yet (a level never used has no @@ -455,9 +459,9 @@ fn render_label(numbering: &Numbering, num: u32, def: &Level, vals: &[u32; 9], s let counted = started.get(k).is_some_and(|f| f.started); let val = if counted { val } else { level_def(k).map(|l| l.start).unwrap_or(1) }; // Legal numbering (w:isLgl) shows every level in Arabic digits, whatever its own - // format (二 of a Chinese outline becomes 2 in "2.1"). + // format (二 of a Chinese outline becomes 2 in "2.1"); a "none" level stays empty. let fmt = match level_def(k).map(|l| l.format).unwrap_or(NumFormat::Decimal) { - NumFormat::DecimalZero => NumFormat::DecimalZero, + f @ (NumFormat::DecimalZero | NumFormat::None) => f, _ if def.legal => NumFormat::Decimal, f => f, }; @@ -556,6 +560,27 @@ mod tests { assert_eq!(n.level(id, 2).map(|l| l.format), Some(NumFormat::DecimalZero)); } + #[test] + fn a_none_level_shows_its_text_and_still_counts() { + // As Word shows them (#4985): a "none" level's literal text is its label, its own number + // reads as nothing (also in legal numbering), and it restarts the levels below it. + let mut n = Numbering::default(); + let id = n.add_list(ListKind::Numbered); + let lvl = |format, text: &str| Level { format, text: text.into(), ..Level::default() }; + let legal = Level { legal: true, ..lvl(NumFormat::Decimal, "%2.%3") }; + if let Some(num) = n.nums.iter_mut().find(|x| x.id == id) { + num.level_overrides = vec![(0, lvl(NumFormat::None, "RESPONSE:")), (1, lvl(NumFormat::None, "[%2]")), (2, legal)]; + } + assert!(n.shows_label(id, 0)); + let mut c = Counters::default(); + assert_eq!(c.next_label(&n, id, 0).unwrap().0, "RESPONSE:"); + assert_eq!(c.next_label(&n, id, 1).unwrap().0, "[]"); + assert_eq!(c.next_label(&n, id, 2).unwrap().0, ".1"); + assert_eq!(c.next_label(&n, id, 2).unwrap().0, ".2"); + assert_eq!(c.next_label(&n, id, 1).unwrap().0, "[]"); + assert_eq!(c.next_label(&n, id, 2).unwrap().0, ".1"); + } + #[test] fn a_start_override_restarts_only_its_own_level_of_a_shared_list() { // One abstract list numbers the headings through two nums: one for levels 1-4, and one diff --git a/crates/docx/tests/it/fixtures.rs b/crates/docx/tests/it/fixtures.rs index 1df2e96f9a..3b4779a248 100644 --- a/crates/docx/tests/it/fixtures.rs +++ b/crates/docx/tests/it/fixtures.rs @@ -2462,6 +2462,31 @@ fn east_asian_number_formats_read_label_and_round_trip() { assert_eq!(formats(&back), want); } +/// #4985: a level with numFmt none and literal text (`RESPONSE:`) labels its paragraph with that +/// text, as in Word, and keeps both through a save. +#[test] +fn a_none_level_s_literal_text_is_its_label() { + use wordcraft_doc::numbering::Counters; + use wordcraft_doc::section::NumFormat; + let numbering = format!( + r#""# + ); + let body = r#"X"#; + let bytes = docx(body, &[("rId1", "numbering", "numbering.xml")], &[("word/numbering.xml", &numbering)]); + let label = |d: &Document| { + let n = paras(d).first().and_then(|p| p.props.numbering)?; + Counters::default().next_label(&d.numbering, n.num, n.level).map(|l| l.0) + }; + let d = wordcraft_docx::read(&bytes).unwrap(); + assert_eq!(label(&d).as_deref(), Some("RESPONSE:")); + let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap(); + assert_eq!( + back.numbering.level(1, 0).map(|l| (l.format, l.text.as_str(), l.indent, l.hanging)), + Some((NumFormat::None, "RESPONSE:", 72.0, 72.0)) + ); + assert_eq!(label(&back).as_deref(), Some("RESPONSE:")); +} + /// #464: a bookmark whose end follows the document's final table keeps its end (it goes into /// the table's last cell) and is saved as a matched pair. #[test] diff --git a/crates/engine/src/cmd/lists.rs b/crates/engine/src/cmd/lists.rs index 5fa185324c..987e9fd537 100644 --- a/crates/engine/src/cmd/lists.rs +++ b/crates/engine/src/cmd/lists.rs @@ -489,7 +489,8 @@ mod tests { ("headings", [Some("1"), Some("1.1"), Some("1.1.1")]), ("article", [Some("Article I."), Some("Section 1.01"), Some("(a)")]), ("outlineHeadings", [Some("I."), Some("A."), Some("1.")]), - ("chapter", [Some("Chapter 1"), None, None]), + // The lower levels are "none" levels with no text: an empty label, as in Word (#4985). + ("chapter", [Some("Chapter 1"), Some(""), Some("")]), ] { let mut s = headings("Heading3"); s.run("list.multilevelPreset", &json!({"preset": preset})).unwrap(); diff --git a/crates/layout/src/para/lines.rs b/crates/layout/src/para/lines.rs index 9edfeb2b7f..f29a7029f9 100644 --- a/crates/layout/src/para/lines.rs +++ b/crates/layout/src/para/lines.rs @@ -297,6 +297,9 @@ impl<'e> Breaker<'e> { }; let end = lab.x + lab.width; match self.level.map(|l| l.suffix).unwrap_or(LevelSuffix::Tab) { + // An empty label (a "none" level with no text) that ends right at the left indent takes + // no tab: the text starts there, as in Word (#4985), and not at the next stop. + LevelSuffix::Tab if lab.width <= 0.01 && (end - self.rp.indent_left).abs() <= 0.01 => end, LevelSuffix::Tab => { let t = next_tab(end, &self.rp.tabs, self.default_tab, self.hanging_at, self.tab_origin).pos; // The level's own tab stop after the number, when it comes first. diff --git a/crates/layout/src/styleref.rs b/crates/layout/src/styleref.rs index 5f2db6045e..8d03623e6f 100644 --- a/crates/layout/src/styleref.rs +++ b/crates/layout/src/styleref.rs @@ -125,9 +125,11 @@ struct Entry { runs: Vec<(String, String)>, /// The list label as shown (`Chapter 2.`). label: Option, - /// Each level's value up to the paragraph's, formatted; and whether the label's own - /// template shows that level. - levels: Vec<(String, bool)>, + /// Each level up to the paragraph's that has a number (not a bullet, nor a "none" level): + /// its index, its value formatted, and whether the label's own template shows it. + levels: Vec<(u8, String, bool)>, + /// The paragraph's own list level. + level: u8, /// How many paragraphs of its style there are up to this one. ordinal: u32, } @@ -180,7 +182,7 @@ impl StyleIndex { *ord = ord.saturating_add(1); let ordinal = *ord; let num = p.props.numbering.or_else(|| doc.styles.resolve_para(&p.props).numbering).filter(|n| n.num != 0); - let (mut label, mut levels) = (None, Vec::new()); + let (mut label, mut levels, mut level) = (None, Vec::new(), 0u8); if let Some(n) = num && let Some((l, def)) = counters.next_label(&doc.numbering, n.num, n.level) && def.format != NumFormat::Bullet @@ -193,9 +195,10 @@ impl StyleIndex { } let v = if *v == 0 { lv.start } else { *v }; let fmt = if def.legal && lv.format != NumFormat::DecimalZero { NumFormat::Decimal } else { lv.format }; - levels.push((fmt.format(v), def.text.contains(&format!("%{}", k + 1)))); + levels.push((k as u8, fmt.format(v), def.text.contains(&format!("%{}", k + 1)))); } label = Some(l); + level = n.level.min(8); } let mut runs: Vec<(String, String)> = Vec::new(); let mut off = 0usize; @@ -221,7 +224,7 @@ impl StyleIndex { list.push(i); } } - entries.push(Entry { path, style, text: clean(&p.plain_text()), runs, label, levels, ordinal }); + entries.push(Entry { path, style, text: clean(&p.plain_text()), runs, label, levels, level, ordinal }); } StyleIndex { entries, by_path, styles, para_entries, char_entries } } @@ -323,18 +326,32 @@ impl StyleIndex { let mut out = match code.number { Number::Text if code.position => String::new(), Number::Text => text, - Number::Chapter => e.label.as_deref().map(chapter_number).filter(|s| !s.is_empty()).unwrap_or_else(|| e.ordinal.to_string()), + // The last number the label shows (`3` of `3 Response`, or of a "none" level's `%1`); + // a label of the paragraph's own number that shows none recognisably, its last word; + // else, as for an unnumbered heading or a "none" level's `Note`, the heading's count. + Number::Chapter => { + let own = e.levels.iter().any(|(k, ..)| *k == e.level); + e.label + .as_deref() + .and_then(|l| { + let shown = l.split_whitespace().rev().find_map(|w| number_span(w, &e.levels)); + shown.map(|n| n.trim_matches(|c: char| !c.is_alphanumeric()).to_string()).or_else(|| own.then(|| chapter_number(l))) + }) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| e.ordinal.to_string()) + } Number::Plain | Number::Full => { let own = match &e.label { - Some(l) if code.suppress => suppress(l, &e.levels), + Some(l) if code.suppress => suppress(l, &e.levels, e.level), Some(l) => l.trim().trim_end_matches('.').to_string(), None => e.ordinal.to_string(), }; if code.number == Number::Full && e.label.is_some() { - // Levels above that the label's own template doesn't show. - let above: Vec<&str> = - e.levels.iter().take(e.levels.len().saturating_sub(1)).filter(|(_, shown)| !shown).map(|(v, _)| v.as_str()).collect(); - if above.is_empty() { own } else { format!("{}.{own}", above.join(".")) } + // Levels above the paragraph's that the label's own template doesn't show. By + // level, not position: a "none" level has no entry, its own (#4985) included. + let above: Vec<&str> = e.levels.iter().filter(|(k, _, shown)| *k < e.level && !shown).map(|(_, v, _)| v.as_str()).collect(); + // An empty label of its own (a "none" level with no text) adds no part: `3`, not `3.`. + above.into_iter().chain(Some(own.as_str()).filter(|o| !o.is_empty())).collect::>().join(".") } else { own } @@ -356,13 +373,46 @@ fn chapter_number(label: &str) -> String { word.trim_matches(|c: char| !c.is_alphanumeric()).to_string() } -/// `\t`: the label without its words that hold no number (`Chapter 2.` → `2`). -fn suppress(label: &str, levels: &[(String, bool)]) -> String { - let kept: Vec<&str> = label.split_whitespace().filter(|w| levels.iter().any(|(v, _)| !v.is_empty() && w.contains(v.as_str()))).collect(); - let s = if kept.is_empty() { label.trim().to_string() } else { kept.join(" ") }; +/// `\t`: the label without its words that hold no number (`Chapter 2.` → `2`). A "none" level's +/// label (`level` has no number of its own) holding none is left with nothing (`Note`, #4985); +/// any other label that holds none is kept whole. +fn suppress(label: &str, levels: &[(u8, String, bool)], level: u8) -> String { + // Each word's numbers, without the text attached to them (`Section3` → `3`). + let kept: Vec<&str> = label.split_whitespace().filter_map(|w| number_span(w, levels)).collect(); + let none = !levels.iter().any(|(k, ..)| *k == level); + let s = match kept.is_empty() { + true if none => String::new(), + true => label.trim().to_string(), + false => kept.join(" "), + }; s.trim_end_matches('.').to_string() } +/// The part of `word` that holds the numbers the label's template shows (`levels` flagged shown), +/// with the delimiters around it (`(3)`, `1.1`, `2.`), without the text attached (`Section3` → +/// `3`); `None` when it holds none. A number written in letters (`a`, `iv`) counts only standing +/// apart from other letters: the `a` of `Chapter` isn't one. +fn number_span<'w>(word: &'w str, levels: &[(u8, String, bool)]) -> Option<&'w str> { + let alpha = |c: Option| c.is_some_and(char::is_alphabetic); + let mut span: Option<(usize, usize)> = None; + for (_, v, _) in levels.iter().filter(|(_, v, shown)| *shown && !v.is_empty()) { + let letters = v.chars().any(char::is_alphabetic); + for (i, _) in word.match_indices(v.as_str()) { + let end = i.saturating_add(v.len()); + let before = word.get(..i).and_then(|s| s.chars().next_back()); + let after = word.get(end..).and_then(|s| s.chars().next()); + if letters && (alpha(before) || alpha(after)) { + continue; + } + span = Some(span.map_or((i, end), |(a, b)| (a.min(i), b.max(end)))); + } + } + let (a, b) = span?; + let lead: usize = word.get(..a)?.chars().rev().take_while(|c| !c.is_alphanumeric()).map(char::len_utf8).sum(); + let trail: usize = word.get(b..)?.chars().take_while(|c| !c.is_alphanumeric()).map(char::len_utf8).sum(); + word.get(a.saturating_sub(lead)..b.saturating_add(trail)) +} + /// Does `p` hold a STYLEREF field? pub fn has_styleref(p: &wordcraft_doc::Paragraph) -> bool { p.objects.iter().any(is_styleref) @@ -441,7 +491,14 @@ mod tests { #[test] fn suppress_keeps_the_number() { - assert_eq!(suppress("Chapter 2.", &[("2".into(), true)]), "2"); + assert_eq!(suppress("Chapter 2.", &[(0, "2".into(), true)], 0), "2"); + assert_eq!(suppress("Chapter", &[(0, "a".into(), false)], 1), "", "an ancestor's number the label doesn't show"); + assert_eq!(suppress("Section3", &[(0, "3".into(), true)], 1), "3", "the text attached to the number"); + assert_eq!(suppress("Chapter a.", &[(0, "a".into(), true)], 0), "a", "a letter number standing apart, not Chapter's a"); + assert_eq!(suppress("(iv)", &[(0, "iv".into(), true)], 0), "(iv)", "delimiters stay"); + assert_eq!(suppress("1.2", &[(0, "1".into(), true), (1, "2".into(), true)], 1), "1.2"); + assert_eq!(suppress("Note", &[(0, "3".into(), false)], 1), "", "a none level's label"); + assert_eq!(suppress("Appendix", &[(0, "3".into(), false)], 0), "Appendix", "kept whole when the level has a number"); assert_eq!(chapter_number("Chapter IV."), "IV"); } } diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index ca0805afc7..fa69a3d93a 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -1820,6 +1820,30 @@ fn lists_get_labels_and_indent() { assert!((x - 108.0).abs() < 0.5, "{x}"); } +#[test] +fn a_none_format_label_shows_its_text_and_tabs_to_the_indent() { + // #4985: Word draws a "none" level's literal text ("RESPONSE:"), and an empty one still tabs + // the text to a hanging indent, as a numbered item's. An empty label already at the left indent + // takes no tab (Word's Chapter 1 list: Heading 2 starts at the margin, not at 0.5"). + use wordcraft_doc::section::NumFormat; + for (text, indent, hanging, want) in [("RESPONSE:", 72.0, 72.0, 144.0), ("", 72.0, 72.0, 144.0), ("", 0.0, 0.0, 72.0), ("", 50.0, 0.0, 122.0)] { + let mut d = Document::from_text("X"); + let id = std::sync::Arc::make_mut(&mut d.numbering).add_list(wordcraft_doc::ListKind::Numbered); + for a in &mut std::sync::Arc::make_mut(&mut d.numbering).abstracts { + if let Some(l) = a.levels.first_mut() { + l.format = NumFormat::None; + l.text = text.into(); + (l.indent, l.hanging) = (indent, hanging); + } + } + d.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 1), &|p| p.numbering = Some(wordcraft_doc::props::NumRef { num: id, level: 0 })).unwrap(); + let l = lay(&d); + assert_eq!(labels(&l), [text]); + let x = l.caret(&Pos::body(0, 0)).unwrap().x; + assert!((x - want).abs() < 0.5, "{text:?} at {indent}/{hanging}: {x}, not {want}"); + } +} + #[test] fn break_only_list_has_no_label() { use wordcraft_doc::ListKind; diff --git a/crates/layout/src/tests/styleref.rs b/crates/layout/src/tests/styleref.rs index e800f51f87..b6e91f6017 100644 --- a/crates/layout/src/tests/styleref.rs +++ b/crates/layout/src/tests/styleref.rs @@ -97,6 +97,49 @@ fn character_styles_and_paragraph_numbers() { assert_eq!(h[3], ["two", "3", "3.a", "3"]); } +/// #4985: a heading on a "none" list level (its label is its text, `Note`) shows its parent's +/// number in full context (`\w`): the parent is not taken for the heading's own level, which +/// has no number to list. +#[test] +fn full_context_keeps_the_parent_of_a_none_level_heading() { + let mut d = doc(CHAPTERS, &[r#"STYLEREF "Heading 2" \w"#, r#"STYLEREF "Heading 2" \r"#]); + let num = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered); + if let Some(n) = Arc::make_mut(&mut d.numbering).nums.iter_mut().find(|n| n.id == num) { + let note = wordcraft_doc::numbering::Level { format: wordcraft_doc::section::NumFormat::None, text: "Note".into(), ..Default::default() }; + n.level_overrides = vec![(1, note)]; + } + for (i, lvl) in [(0, 0u8), (2, 0), (5, 0), (6, 1)] { + d.para_mut(StoryRef::Body, &Path::top(i)).unwrap().props.numbering = Some(NumRef { num, level: lvl }); + } + assert_eq!(headers(&d)[3], ["3.Note", "3.Note"]); +} + +/// #4985 review: `\t` leaves a "none" level's label, which holds no number, with nothing, and an +/// empty label of its own adds no part in full context: `3`, not `3.`. +#[test] +fn a_none_level_heading_s_number_has_no_text_and_no_trailing_period() { + let numbered = |text: &str, codes: &[&str]| { + let mut d = doc(CHAPTERS, codes); + let num = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered); + if let Some(n) = Arc::make_mut(&mut d.numbering).nums.iter_mut().find(|n| n.id == num) { + let level = wordcraft_doc::numbering::Level { format: wordcraft_doc::section::NumFormat::None, text: text.into(), ..Default::default() }; + n.level_overrides = vec![(1, level)]; + } + for (i, lvl) in [(0, 0u8), (2, 0), (5, 0), (6, 1)] { + d.para_mut(StoryRef::Body, &Path::top(i)).unwrap().props.numbering = Some(NumRef { num, level: lvl }); + } + headers(&d)[3].clone() + }; + assert!(numbered("Note", &[r#"STYLEREF "Heading 2" \n \t"#]).is_empty(), "nothing is drawn"); + // `\s` is a chapter number: a "none" label isn't one, so the heading's count stands in; a + // label showing its parent's number (`%1`, `%1 Response`) gives that number. + assert_eq!(numbered("Note", &[r#"STYLEREF "Heading 2" \s"#]), ["1"]); + assert_eq!(numbered("%1", &[r#"STYLEREF "Heading 2" \s"#]), ["3"]); + assert_eq!(numbered("%1 Response", &[r#"STYLEREF "Heading 2" \s"#, r#"STYLEREF "Heading 2" \n \t"#]), ["3", "3"]); + assert_eq!(numbered("Section%1", &[r#"STYLEREF "Heading 2" \n \t"#]), ["3"]); + assert_eq!(numbered("", &[r#"STYLEREF "Heading 2" \w"#, r#"STYLEREF "Heading 2" \r"#]), ["3", "3"]); +} + /// Editing a heading updates the headers that show it, through the paragraph cache. #[test] fn headers_follow_heading_edits() { diff --git a/docs/gaps.md b/docs/gaps.md index 173a8d8db9..66f41b99e7 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -189,6 +189,9 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text | 2026-11-04 | trivial | Field argument import and page-format-aware layout/cache follow-ups; no ranked gap changed | | 2026-11-03 | trivial | Field argument parsing follows Word quoted backslash escapes; body PAGE formatting follows the active section in paragraphs and tables; no ranked gap changed | | 2026-11-07 | trivial | Pagination gap #4: An in-line picture lowered past its own height (`w:position`) lowers its line as far, as in Word; all 156 synthetic lines measured for #3984 match Word (#5796). | +| 2026-10-31 | trivial | A list level with numFmt none shows its literal text as its label and still counts, as in Word (#4985); no ranked gap changed; readiness estimates unchanged | +| 2026-11-02 | trivial | #26 narrowed: MHT opens and `w:altChunk` content is brought in on open; saving MHT remains | +| 2026-11-01 | trivial | Preserve table rows and cells inside selected alternate-content branches (#5219); readiness estimates unchanged. | | 2026-11-06 | trivial | Print helpers remove loaded PDF/settings files before driver work, tolerate prior removal and report cleanup errors; a held-driver regression checks the files are already absent (#5710) | | 2026-11-06 | trivial | Windows print submissions use a separate helper per job, preserving provider serialization inside the helper. An acknowledged cancellation stops that helper, so a stalled cancelled driver cannot block a later job; normal shutdown reaps helpers and removes temporary PDFs. The helper erases its plaintext inputs before driver work; a hard app crash during startup can still leave private temporary files. Startup requires an explicit start grant; after sending begins, jobs complete (#5710) | | 2026-11-06 | trivial | Gap 6: File › Print: before sending starts, Cancel printing (`ui.printCancel`) stops a waiting job and frees the Print page while keeping its settings, including after the last document closes; a successfully cancelled job is never sent. Quitting cancels waiting jobs and joins jobs already sending (#5710). On Windows, winprint repeats the DEVMODE conversion after sending starts. A stall during that repeat prevents cancellation and can keep shutdown waiting, even before a native job exists. No ranked gap changed; readiness estimates unchanged | @@ -223,7 +226,6 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text | 2026-11-02 | trivial | Gap 8's evidence: English spelling reads the SCOWL 2026 dictionaries, one per English (en-US, British-spelling via en-GB, en-CA), with regression tests for #40 (British words in en-GB) and #520 (common modern words); #40 is no longer listed as open (#5205). No ranked gap changed. | | 2026-11-01 | minor | #8: Language packs (CP4.3): install and remove a language's spelling and hyphenation from File › Options › Language, the missing-pack bar, About › Language data, `proof.packs`/`proof.installPack`/`proof.removePack`; Lithuanian [#4707](https://github.com/storytold/wordcraft/issues/4707) and Romanian [#3438](https://github.com/storytold/wordcraft/issues/3438) are available as a Language pack once language-packs-1 is published (with Polish, Portuguese, Italian, Russian, Czech, Spanish, Bulgarian, Bosnian, Slovenian, Ukrainian and the other packs that pass CI); the packs for Dutch [#4145](https://github.com/storytold/wordcraft/issues/4145)/[#2505](https://github.com/storytold/wordcraft/issues/2505) and Catalan [#2895](https://github.com/storytold/wordcraft/issues/2895)/[#4947](https://github.com/storytold/wordcraft/issues/4947) are defined but wait on craft-proof engine support (two-character flags, `FLAG long`), French [#1325](https://github.com/storytold/wordcraft/issues/1325) on `OCONV`, Greek [#2561](https://github.com/storytold/wordcraft/issues/2561) on ISO 8859-7 decoding, Slovak [#3806](https://github.com/storytold/wordcraft/issues/3806) on `CIRCUMFIX`, Serbian [#4968](https://github.com/storytold/wordcraft/issues/4968) on numeric flags (`FLAG num`), and Latvian [#4438](https://github.com/storytold/wordcraft/issues/4438) on `FULLSTRIP` (and `CIRCUMFIX`, `NEEDAFFIX`) | | 2026-11-01 | trivial | Gap 8's evidence names craft-proof (storytold/craft-libs `crates/proof`) and craft-i18n `language-data/` instead of WordCraft's removed `crates/proof` (#5202). No ranked gap changed. | -| 2026-11-02 | trivial | #26 narrowed: MHT opens and `w:altChunk` content is brought in on open; saving MHT remains | | 2026-11-01 | trivial | Preserve table rows and cells inside selected alternate-content branches (#5219); readiness estimates unchanged. || 2026-11-01 | trivial | Arrowheads keep their kind, width and length (DrawingML `a:headEnd`/`a:tailEnd` `type`/`w`/`len`, VML `startarrow`/`endarrow` with `…arrowwidth`/`…arrowlength`), read and written back, and are drawn 2, 3 or 5 times the line's width long and wide (small, medium, large), a line under 2 points as a 2-point one, as measured in Word for Windows; every kind is still drawn as a triangle; no ranked gap changed; readiness estimates unchanged | | 2026-10-31 | trivial | The .doc reader's default font is the stylesheet's standard font, not the font table's first entry (#5066); no ranked gap changed; readiness estimates unchanged | | 2026-11-01 | trivial | A middle or bottom cell of a row split across pages lines up on the first page; no ranked gap changed; readiness estimates unchanged | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index b3bb97b603..a1125c2619 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -58,7 +58,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic | 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). An otherwise empty section at page start retains its line (`empty_page_section_keeps_line`); a closing mark after text stays quiet, but one after another section's mark takes its line while it fits and then stays at the page's bottom, so the text after a long run starts the next page; a run of continuous sections with text goes onto new pages like plain text, and a kept-together paragraph low in a continuous section is measured against the next page's body (its header, footer and carried notes), not the current one's (#3907). | 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 after a leading page break | ✅ | `layout::tests::bidi::list_label_after_leading_page_break_uses_its_text_lines_alignment`: the number/bullet and text retain the no-break horizontal positions for start, centre, end and justified alignment, LTR/RTL, including text with a tab. The issue's synthetic DOCX exports with the number before the heading, matching the reporter's Word/LibreOffice reference (#2964); reference applications were not rerun locally | — | -| 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 | ✅ | a level above that hasn't counted yet takes its first number at the first deeper item, as measured in Word: after 10.1–10.4 the next level-1 item is 11 (`doc::numbering::tests::a_deeper_item_first_counts_the_levels_above_it`); `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, Hindi, Thai and Vietnamese formats show decimal; `arabicAlpha` and `arabicAbjad` draw Arabic letters, then double (أأ, بب) (`doc::section::tests::arabic_letters_count_in_both_orders`). 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 | ✅ | a level above that hasn't counted yet takes its first number at the first deeper item, as measured in Word: after 10.1–10.4 the next level-1 item is 11 (`doc::numbering::tests::a_deeper_item_first_counts_the_levels_above_it`); `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, Hindi, Thai and Vietnamese formats show decimal; `arabicAlpha` and `arabicAbjad` draw Arabic letters, then double (أأ, بب) (`doc::section::tests::arabic_letters_count_in_both_orders`). The label stays attached to the start of the text when the paragraph is centred or aligned to the end, as in Word (#1159). A level with number format `none` is an ordinary level whose own number reads as nothing, as in Word for Windows (Microsoft 365, checked by MeHail-02): its label is its text with that number empty (`RESPONSE:`; `Part` above `%1.%2` gives `.1`), it counts and restarts the levels below it, it stays empty in legal numbering, and an empty label still tabs the text to the indent unless it already ends there (Heading 2 of the Chapter 1 list stays at the margin) (#4985; `doc::numbering::tests::a_none_level_shows_its_text_and_still_counts`, `layout::tests::a_none_format_label_shows_its_text_and_tabs_to_the_indent`). | — | | 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 | @@ -299,6 +299,7 @@ The front-of-text opt-out is retained from the earlier implementation; this patc | 2026-11-04 | trivial | Header table measurements and fitted text-box heights use the page section’s PAGE format; cache reuse follows the field context | | 2026-11-03 | trivial | Body PAGE fields in tables use the active section number format; reflow views measure headers and footers with it before body placement | | 2026-11-06 | trivial | An in-line picture lowered further than its own height (`w:position`) was held with its top on the baseline and its line only as deep as the picture; Word keeps the top there but lowers the line as far as the position says. A 6 pt picture lowered 30 pt now makes a 30 pt line alone and the text's ascent plus 30 pt beside text, as in Word. This closes the gap noted on 2026-11-05: all 156 synthetic lines measured then now match Word within 0.35 pt, heights and picture positions. Exact spacing keeps the line's baseline as before (`tests::lowered_pictures_and_check_boxes_take_words_line_heights`, `tests::an_inline_picture_moves_with_its_runs_position`). | +| 2026-10-31 | trivial | List labels: a level with numFmt none is an ordinary level whose own number reads as nothing: it shows its literal text (`RESPONSE:`), counts and restarts the levels below it, stays empty in legal numbering, and an empty label still tabs the text to the indent, as in Word (#4985) | | 2026-11-05 | trivial | PR #4545: eligible cell floats retain in-bounds offsets and touch the outer cell edge only on overflow, ignoring wrap distances for object placement. Front-of-text, turned-cell and outside-cell opt-outs remain. Synthetic geometry controls; native front/behind-text parity remains unqualified. | | 2026-11-05 | trivial | With multiple spacing a line of only in-line pictures is as tall as they reach, raised or lowered, and never less than its font's height (a list label's font counting too): a picture shorter than the font keeps the font's ascent and descent, as noted for ODT documents below; a taller one ends at the line's bottom with no descent under it. The spacing adds its share of the font's height, not of a lowered picture's. A legacy check box sizes its line as text of its size. Measured in Word with Arial at 10 and 20 pt, single, 1.15, 1.25 and 1.5 spacing, 156 synthetic lines (`tests::lowered_pictures_and_check_boxes_take_words_line_heights`). Known gap: a picture lowered further than its own height is limited to that height where Word's line keeps growing. | | 2026-11-04 | trivial | PR #4609: relative REF keeps context already displayed by the own or a needed ancestor label; six synthetic legal-template cases and separate-list context follow the Word for Windows measurements reported by @pakiat in PR discussion. Existing unknown-switch/cache boundaries and readiness estimates unchanged; no new native Word run. | From 852e67d587c0727b818304af9014f19cf184a284 Mon Sep 17 00:00:00 2001 From: StrajnarFilip <46705237+StrajnarFilip@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:25:17 +0100 Subject: [PATCH 02/42] A template opened from the file manager starts a new document (#3433) (#5698) A Word template (.dotx, .dotm, .dot) the platform hands the app, from a double-click in the file manager, the command line or the Finder, opened as itself: the window called it saved, AutoSave was on, and Ctrl+S wrote over the template. Word's default verb for a template makes a new, untitled document based on it. WordApp::open_from_platform sends a Word template to file.newFromTemplate and opens anything else as File > Open does; main.rs (also for a name that isn't valid Unicode) and the Apple Events handler use it. file.newFromTemplate reads the template as file.open reads a file (password, data, an empty file) and, with replaceBlank, takes an untouched blank document's place, so a launch shows one window. The Password box retries the command that asked for it, so a protected template still starts a new document. File > Open and drops still open a template itself. Claude-Session: https://claude.ai/code/session_01LvG6E1XTfHtZDbfYwfREhR Co-authored-by: Filip Strajnar Co-authored-by: Claude Opus 5.5 Co-authored-by: Martin Ball --- ROADMAP.md | 2 + apps/wordcraft/src/apple_events.rs | 5 +- apps/wordcraft/src/main.rs | 10 +-- crates/engine/src/cmd/file.rs | 70 ++++++++++++++------- crates/engine/src/cmd/tools/specs.rs | 16 ++--- crates/engine/src/cmd/user_templates.rs | 67 ++++++++++++++++++++ crates/ui-egui/src/app/commands.rs | 6 +- crates/ui-egui/src/dialogs.rs | 6 +- crates/ui-egui/src/dialogs/dispatch.rs | 7 ++- crates/ui-egui/src/documents.rs | 31 ++++++++++ crates/ui-egui/src/tests.rs | 82 +++++++++++++++++++++++++ docs/file-format-parity.md | 5 +- 12 files changed, 261 insertions(+), 46 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 2418e28c8f..388ddcc08c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -169,6 +169,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-11-04: RTF import skips the declared Unicode fallback count even when it exceeds eight fallback units, so longer ANSI alternatives do not leak into the document text. Skipping stays bounded by the available input and ends at a group boundary. Readiness estimates unchanged. - 2026-11-05: Linux: a WordCraft started by an AppImage app no longer takes that app's file for its own (#5653). The AppImage runtime's `APPIMAGE`, `APPDIR`, `ARGV0` and `OWD` pass to whatever the app starts, so an installed WordCraft opened through `xdg-open` saw another program's `APPIMAGE`, and its self-update replaced that program's file. The app now starts over without those variables unless its executable is under `APPDIR`, so the update goes through the package or plain file it really is. Should starting over fail, the self-update is off in that window and Help › Check for Updates says why. Readiness estimates unchanged. +- 2026-11-05: A Word template (`.dotx`, `.dotm`, `.dot`) opened from the file manager, the command line or the Finder starts a new, untitled document based on it, as Word's default verb for a template does (#3433). It opened the template itself, so Save (and AutoSave) wrote over it. The new document is attached to the template and Save asks where to write; a protected template asks for its password and still starts a new document. File › Open and a file dropped on the window still open a template itself, to edit it. - 2026-10-28: Ink replay, eraser picking, lasso selection and arrow-candidate discovery reuse the paragraph prefixes they have already scanned (#3614). Page item order, stroke geometry and saved data retain their behavior. This narrows the stability work; ink import fidelity, native pen input and broader corpus qualification remain open. - 2026-10-29: Page drawing finds text-box warps without rescanning the paragraph for every box (#3614). Only requested offsets are indexed, and each paragraph walk stops after its last requested character. Original Linux discovery, complete display-output and CLI/MCP save/reopen controls; native Word comparison untested. Readiness estimates unchanged. - 2026-10-28: Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged. Existing mutation and protection checks retain their costs; this does not close the broader performance gap. @@ -820,6 +821,7 @@ MATH glyphs that reference the same construction now share its immutable variant ## Revision history > 2026-11-05 · Linux self-update ignores another program's AppImage variables (#5653); recorded in the progress log, readiness unchanged. +> 2026-11-05 · A template opened from the file manager starts a new document instead of being edited and saved over (#3433); recorded in the progress log, readiness unchanged. > > 2026-11-03 · Arabic dialogs read right to left (rtl::Table); recorded in the progress log, readiness unchanged. > diff --git a/apps/wordcraft/src/apple_events.rs b/apps/wordcraft/src/apple_events.rs index e79d923a9a..56a284c0cb 100644 --- a/apps/wordcraft/src/apple_events.rs +++ b/apps/wordcraft/src/apple_events.rs @@ -36,14 +36,15 @@ impl AppleEvents { /// Open the documents and act on the quit requests that arrived since the last frame. A document /// opens like File › Open: beside the open ones, in place of an untouched blank one, or, when it -/// is already open, by showing it. Quitting closes the window, which asks about every document +/// is already open, by showing it; a Word template starts a new document based on it, as Word's +/// default verb for one does (#3433). Quitting closes the window, which asks about every document /// with unsaved changes. pub fn poll(inbox: &Inbox, app: &mut WordApp, ctx: &egui::Context) { for e in inbox.drain() { match e { Event::Open(paths) => { for p in paths.iter().filter_map(|p| p.to_str()) { - match app.open_file(p, None) { + match app.open_from_platform(p, None) { // Leave Backstage, as opening from the Open dialog does. Ok(_) => app.ui.backstage = false, Err(e) => { diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index 30cc1082c7..1c29e22899 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -423,14 +423,15 @@ fn without_launch_args(args: Vec) -> Vec { /// 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. -/// The bytes are read as `file.open` reads a path: only a regular file, and at most 2 GB. +/// The bytes are read as `file.open` reads a path: only a regular file, and at most 2 GB. A Word +/// template starts a new document instead ([`WordApp::open_from_platform`]), which has no path. fn open_non_unicode(app: &mut WordApp, path: &std::path::Path) { let name = path.to_string_lossy(); let opened = wordcraft_engine::io::read_document(path) - .and_then(|bytes| app.run("file.open", serde_json::json!({"path": name, "data": wordcraft_engine::cmd::insert::base64_encode(&bytes)}))); + .and_then(|bytes| app.open_from_platform(&name, Some(&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(r) if r.get("pending").is_none() && !wordcraft_ui_egui::documents::is_word_template(&name) => app.session.path = Some(path.into()), Ok(_) => {} Err(e) => { app.report_open_failure(&name, &e); @@ -624,8 +625,9 @@ fn main() -> eframe::Result { if files.is_empty() && other_files.is_empty() && !sample && control_port.is_none() { app.start_screen(); } + // A Word template named here starts a new document based on it (#3433). for f in files { - if let Err(e) = app.run("file.open", serde_json::json!({"path": f})) { + if let Err(e) = app.open_from_platform(&f, None) { app.report_open_failure(&f, &e); } } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 67284a2474..c9d523c5d6 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -253,27 +253,8 @@ fn open(s: &mut Session, v: &Value) -> CmdResult { ); } let password = p::str(v, "password"); - let (mut doc, encrypted) = if let Some(data) = p::str(v, "data") { - let bytes = super::insert::base64_decode(data).ok_or_else(|| CmdError::Params("bad base64".into()))?; - // An empty file (File Explorer's New › Microsoft Word Document makes one) opens as a new - // blank document that saves back to it (#1384). - if bytes.is_empty() { - (normal_document(s), false) - } else { - crate::io::open_bytes_in(path, &bytes, password, &s.region()).map_err(CmdError::Failed)? - } - } else if crate::io::is_empty_file(std::path::Path::new(path)) { - (normal_document(s), false) - } else { - crate::io::open_path_in(std::path::Path::new(path), password, &s.region()).map_err(CmdError::Failed)? - }; - // Plain text and Markdown carry no language: they are in the editing language. - let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase(); - if let Some(lang) = s.prefs.default_language.as_ref().or(s.editing_lang.as_ref()) - && matches!(ext.as_str(), "txt" | "text" | "md" | "markdown") - { - Arc::make_mut(&mut doc.styles).default_chr.lang = Some(lang.clone()); - } + // An empty file opens as a new blank document that saves back to it (#1384). + let (doc, encrypted) = read_document(s, path, v)?; match s.used_because() { None => s.set_document(doc), Some(why) => { @@ -294,6 +275,53 @@ fn open(s: &mut Session, v: &Value) -> CmdResult { ) } +/// The document the file at `path` holds (or its bytes, base64 in `data`, with `path` only +/// naming it), read with `password`, and whether it was encrypted: what `file.open` and +/// `file.newFromTemplate` read. An empty file (File Explorer's New › Microsoft Word Document makes +/// one) is a new blank document (#1384). Plain text and Markdown carry no language, so they are +/// in the editing language. +fn read_document(s: &Session, path: &str, v: &Value) -> Result<(Document, bool), CmdError> { + let password = p::str(v, "password"); + let (mut doc, encrypted) = if let Some(data) = p::str(v, "data") { + let bytes = super::insert::base64_decode(data).ok_or_else(|| CmdError::Params("bad base64".into()))?; + if bytes.is_empty() { + (normal_document(s), false) + } else { + crate::io::open_bytes_in(path, &bytes, password, &s.region()).map_err(CmdError::Failed)? + } + } else if crate::io::is_empty_file(std::path::Path::new(path)) { + (normal_document(s), false) + } else { + crate::io::open_path_in(std::path::Path::new(path), password, &s.region()).map_err(CmdError::Failed)? + }; + let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase(); + if let Some(lang) = s.prefs.default_language.as_ref().or(s.editing_lang.as_ref()) + && matches!(ext.as_str(), "txt" | "text" | "md" | "markdown") + { + Arc::make_mut(&mut doc.styles).default_chr.lang = Some(lang.clone()); + } + Ok((doc, encrypted)) +} + +/// File › New from a template (#2852): a new, untitled document with the template's content, +/// attached to the template as in Word, read as `file.open` reads a file (`data`, `password`). +/// It opens beside the open documents, as `file.new` does. With `replaceBlank` an untouched blank +/// document gives way to it, as to a file `file.open` opens: that is how a template the platform +/// hands the app starts (#3433). The template is only read; the new document has no file, so +/// saving it asks where to write, and a protected template's password doesn't come along. +pub(crate) fn new_from_template(s: &mut Session, v: &Value) -> CmdResult { + let path = p::req_str(v, "path")?; + let (mut doc, _) = read_document(s, path, v)?; + doc.settings.attached_template = Some(path.to_string()); + if p::bool(v, "replaceBlank") == Some(true) && s.used_because().is_none() { + s.set_document(doc); + } else { + s.add_document(doc); + } + s.dirty = true; + sel_result(s) +} + /// Close a document with no window to ask about it (agents, headless sessions). fn close_document(s: &mut Session, v: &Value) -> CmdResult { let id = match v.get("id").filter(|x| !x.is_null()) { diff --git a/crates/engine/src/cmd/tools/specs.rs b/crates/engine/src/cmd/tools/specs.rs index 636faf15b2..f9a9684d5f 100644 --- a/crates/engine/src/cmd/tools/specs.rs +++ b/crates/engine/src/cmd/tools/specs.rs @@ -121,17 +121,11 @@ pub fn specs() -> Vec { CommandSpec::new("file.recover", "Recover Unsaved Documents", "File › Info", |s, v| s.recover(v)) .params(r#"{"restore"?: recoveryId, "discard"?: recoveryId, "refresh"?: true}; desktop only; returns {enabled,busy,documents,error}; restore loads asynchronously without a file path"#) .pure(), - CommandSpec::new("file.newFromTemplate", "New from Template", "File › New", |s, v| { - let path = p::req_str(v, "path")?; - let mut doc = crate::io::open_path(std::path::Path::new(path)).map_err(CmdError::Failed)?; - // As in Word, the new document is attached to the template it came from (#2852). - doc.settings.attached_template = Some(path.to_string()); - s.add_document(doc); - s.dirty = true; - sel_result(s) - }) - .params(r#"{"path": string} → a new, untitled document from the template, attached to it"#) - .pure(), + CommandSpec::new("file.newFromTemplate", "New from Template", "File › New", crate::cmd::file::new_from_template) + .params( + r#"{"path": string, "data"?: base64 (the template's bytes; `path` then only names it), "password"?: string (for a password-protected template), "replaceBlank"?: bool (an untouched blank document gives way to it, as to a file file.open opens)} → a new, untitled document from the template, attached to it"#, + ) + .pure(), CommandSpec::new("file.saveTemplate", "Save as Template", "File › Save As", |s, v| { let path = p::req_str(v, "path")?; // A .dotm keeps its name (#2852); anything else becomes a .dotx. diff --git a/crates/engine/src/cmd/user_templates.rs b/crates/engine/src/cmd/user_templates.rs index 9180a8fe34..ff7b5bca87 100644 --- a/crates/engine/src/cmd/user_templates.rs +++ b/crates/engine/src/cmd/user_templates.rs @@ -337,6 +337,73 @@ mod tests { let _ = std::fs::remove_dir_all(&d); } + /// #3433: a template the platform hands the app takes an untouched blank document's place + /// (`replaceBlank`), as a file `file.open` opens does; otherwise, and without the flag, the + /// new document opens beside the others, as File › New's does. Either way the template on + /// disk is only read: the new document has no path, so a save can't write over it. + #[test] + fn new_from_template_replaces_an_untouched_blank_document_only_when_asked() { + let d = scratch("replace-blank"); + let path = d.join("Letterhead.dotx"); + template(&path, "Letterhead text", ""); + let bytes = std::fs::read(&path).unwrap(); + let from = json!({"path": path.to_string_lossy(), "replaceBlank": true}); + + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("file.newFromTemplate", &from).unwrap(); + assert_eq!(s.documents().len(), 1, "the untouched blank document gave way"); + assert_eq!(s.path, None); + assert!(s.dirty); + assert_eq!(s.doc.settings.attached_template.as_deref(), Some(path.to_string_lossy().as_ref())); + assert!(s.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("Letterhead text")); + let saved = s.run("file.save", &json!({})).unwrap(); + assert_eq!(saved["saved"], json!(false), "no file to save to: Save As asks where"); + assert_eq!(std::fs::read(&path).unwrap(), bytes, "the template is never written over"); + + // The new document has unsaved changes, so a second one opens beside it. + s.run("file.newFromTemplate", &from).unwrap(); + assert_eq!(s.documents().len(), 2); + // Without the flag, even an untouched blank document stays (File › New). + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("file.newFromTemplate", &json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(s.documents().len(), 2); + assert_eq!(std::fs::read(&path).unwrap(), bytes); + let _ = std::fs::remove_dir_all(&d); + } + + /// #3433: a protected template asks for its password like `file.open`, and the document made + /// from it isn't protected; a template's bytes can come in `data` (a file name that isn't + /// valid Unicode, #1440), its path then only naming it. + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn new_from_template_reads_a_protected_template_and_bytes_like_file_open() { + let d = scratch("protected"); + let path = d.join("Locked.dotx"); + let mut t = Session::new(wordcraft_doc::Document::from_text("Locked template")); + t.run("file.save", &json!({"path": path.to_string_lossy(), "password": "sesame"})).unwrap(); + let name = path.to_string_lossy(); + + let mut s = Session::new(wordcraft_doc::Document::new()); + let e = s.run("file.newFromTemplate", &json!({"path": name})).unwrap_err().to_string(); + assert!(crate::io::needs_password(&e), "{e}"); + let e = s.run("file.newFromTemplate", &json!({"path": name, "password": "wrong"})).unwrap_err().to_string(); + assert!(crate::io::wrong_password(&e), "{e}"); + assert_eq!(s.documents().len(), 1, "nothing opened yet"); + s.run("file.newFromTemplate", &json!({"path": name, "password": "sesame", "replaceBlank": true})).unwrap(); + assert!(s.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("Locked template")); + assert_eq!((s.path.as_ref(), s.password.is_some()), (None, false)); + + let plain = d.join("Plain.dotx"); + template(&plain, "From bytes", ""); + let data = crate::cmd::insert::base64_encode(&std::fs::read(&plain).unwrap()); + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("file.newFromTemplate", &json!({"path": "Pl\u{FFFD}in.dotx", "data": data, "replaceBlank": true})).unwrap(); + assert!(s.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("From bytes")); + assert_eq!(s.doc.settings.attached_template.as_deref(), Some("Pl\u{FFFD}in.dotx")); + assert_eq!(s.path, None); + let _ = std::fs::remove_dir_all(&d); + } + #[test] fn the_templates_folder_option_is_saved_and_checked() { let d = scratch("option"); diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 349cd8cc3a..6d3c005642 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -318,7 +318,8 @@ impl WordApp { file_dialogs::Asked::Busy => Ok(json!({"saved": false})), }; } - let retry = (id == "file.open").then(|| params.clone()); + // Opening a file, or a new document from a template, reads a file that may be protected. + let retry = matches!(id, "file.open" | "file.newFromTemplate").then(|| params.clone()); let retry_path = retry.clone(); let r = self.execute(id, params); if let (Err(e), Some(params)) = (&r, retry) @@ -326,7 +327,8 @@ impl WordApp { { let path = params.get("path").and_then(Value::as_str).unwrap_or(""); let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()); - self.dialog = Some(dialogs::Dialog::Password { name, params, password: Default::default(), message: String::new() }); + self.dialog = + Some(dialogs::Dialog::Password { name, then: id.to_string(), params, password: Default::default(), message: String::new() }); return Ok(json!({"pending": "password"})); } // Whoever asked, a failed open names the file and the cause (#1103). diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index ab5efa412f..0caadcbaed 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -416,10 +416,12 @@ pub enum Dialog { #[serde(skip)] basis: Box, }, - /// Opening a password-protected document (#55): the password, then `file.open` with - /// `params` again. The password is never serialised or printed. + /// Opening a password-protected document (#55): the password, then the command that asked + /// for it (`then`: `file.open`, or `file.newFromTemplate` for a protected template, #3433) + /// with `params` again. The password is never serialised or printed. Password { name: String, + then: String, #[serde(skip)] params: Value, #[serde(skip)] diff --git a/crates/ui-egui/src/dialogs/dispatch.rs b/crates/ui-egui/src/dialogs/dispatch.rs index 141061c27b..c2a07d60e8 100644 --- a/crates/ui-egui/src/dialogs/dispatch.rs +++ b/crates/ui-egui/src/dialogs/dispatch.rs @@ -1290,7 +1290,7 @@ fn insert_merge_field_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> } fn password_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { - let Dialog::Password { name, params, password, message } = d else { + let Dialog::Password { name, then, params, password, message } = d else { return false; }; ui.label(crate::i18n::fmt(tl!("{name} is protected with a password."), &[("name", name)])); @@ -1311,8 +1311,9 @@ fn password_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { if let Some(o) = p.as_object_mut() { o.insert("password".into(), json!(password.as_str())); } - // `execute`, not `run`: Save Changes was asked when the open began. - match app.execute("file.open", p) { + // `execute`, not `run`: Save Changes was asked when the open began. The command that + // asked runs again, so a protected template still starts a new document (#3433). + match app.execute(then, p) { Ok(_) => { app.ui.backstage = false; return true; diff --git a/crates/ui-egui/src/documents.rs b/crates/ui-egui/src/documents.rs index e3fc93553a..61fe3e474e 100644 --- a/crates/ui-egui/src/documents.rs +++ b/crates/ui-egui/src/documents.rs @@ -130,6 +130,28 @@ impl WordApp { self.run("file.open", json!({"path": path})) } + /// Open a file the platform hands the app: one named on the command line (a double-click in + /// the file manager, the shell's Open verb) or sent by the Finder. A Word template (`.dotx`, + /// `.dotm`, `.dot`) starts a new, untitled document based on it, as Word's default verb for a + /// template does (#3433): saving it asks where to write, and the template is never written + /// over. File › Open still opens a template itself, to edit it, as in Word. Anything else + /// opens as [`WordApp::open_file`] opens it. `data` holds the file's bytes (base64) when its + /// name can't be passed as text (#1440); `path` then only names it. + pub fn open_from_platform(&mut self, path: &str, data: Option<&str>) -> Result { + let mut params = json!({"path": path}); + if let Some(data) = data { + params["data"] = json!(data); + } + if is_word_template(path) { + params["replaceBlank"] = json!(true); + return self.run("file.newFromTemplate", params); + } + match data { + Some(_) => self.run("file.open", params), + None => self.open_file(path, None), + } + } + /// Show open document `id` in the window (View › Window › Switch Windows, quitting). pub(crate) fn show_document(&mut self, id: u64) -> Result<(), String> { self.session.activate(id).map_err(|e| e.to_string())?; @@ -197,6 +219,15 @@ impl WordApp { } } +/// Is `path` a Word template by its extension (`.dotx`, `.dotm`, `.dot`, in any case)? These are +/// the template types WordCraft registers with the platform. +pub fn is_word_template(path: &str) -> bool { + std::path::Path::new(path) + .extension() + .and_then(std::ffi::OsStr::to_str) + .is_some_and(|e| ["dotx", "dotm", "dot"].iter().any(|t| e.eq_ignore_ascii_case(t))) +} + /// The name an open document goes by in the window: the engine's title, with an untitled /// document's `Document2` in the interface language. pub fn display_title(d: &DocInfo) -> String { diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index 2ab58a925f..7e24875be4 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -2917,6 +2917,88 @@ fn a_command_line_file_still_asks_for_its_password_after_the_startup_document_is assert!(body_text(&a).contains("Locked away"), "{}", body_text(&a)); } +/// A template WordCraft can be handed, saved by the engine with `text` (and `password`). +fn template_file(path: &std::path::Path, text: &str, password: Option<&str>) -> Vec { + let mut s = Session::new(wordcraft_doc::Document::from_text(text)); + s.run("file.save", &json!({"path": path.to_string_lossy(), "password": password})).unwrap(); + std::fs::read(path).unwrap() +} + +/// #3433: a Word template the platform hands the app (the command line, a double-click in the +/// file manager, the Finder) starts a new, untitled document based on it, in place of the +/// untouched blank document, as Word does; saving can't write over the template. A document is +/// still opened as itself, and File › Open still opens a template itself, to edit it. +#[test] +fn a_template_the_platform_hands_the_app_starts_a_new_document() { + let dir = scratch("platform-template"); + let path = dir.join("Letterhead.DOTX"); + let bytes = template_file(&path, "Letterhead text", None); + let name = path.to_string_lossy().to_string(); + + let mut a = app(); + let r = a.open_from_platform(&name, None).unwrap(); + assert_eq!(r.get("pending"), None, "{r}"); + assert_eq!(a.session.documents().len(), 1, "the blank startup document gave way"); + assert_eq!(a.session.path, None); + assert_eq!(a.session.doc.settings.attached_template.as_deref(), Some(name.as_str())); + assert!(body_text(&a).contains("Letterhead text")); + assert_eq!(a.title_stem(), "Document1"); + a.run("text.insert", json!({"text": "Dear reader, "})).unwrap(); + let _ = a.run("file.save", json!({})); + assert_eq!(std::fs::read(&path).unwrap(), bytes, "Save never writes over the template"); + assert_eq!(a.session.path, None); + + // A document opens as itself, beside the new one. + let doc = dir.join("Report.docx"); + template_file(&doc, "Report text", None); + a.open_from_platform(&doc.to_string_lossy(), None).unwrap(); + assert_eq!(a.session.path.as_deref(), Some(doc.as_path())); + // File › Open edits the template itself. + let mut b = app(); + b.open_file(&name, None).unwrap(); + assert_eq!(b.session.path.as_deref(), Some(path.as_path())); + let _ = std::fs::remove_dir_all(&dir); +} + +/// #3433: a protected template from the platform asks for its password, and the password box +/// retries the command that asked, so the template still starts a new document instead of +/// opening itself. +#[test] +fn a_protected_template_from_the_platform_asks_for_its_password_and_stays_a_new_document() { + let dir = scratch("platform-template-password"); + let path = dir.join("Locked.dotm"); + let bytes = template_file(&path, "Locked template", Some("sesame")); + let name = path.to_string_lossy().to_string(); + + let mut a = app(); + a.open_from_platform(&name, None).unwrap(); + assert_eq!(prompt(&a), Some("password")); + let (then, params) = match &a.dialog { + Some(crate::dialogs::Dialog::Password { then, params, .. }) => (then.clone(), params.clone()), + other => panic!("{:?}", other.as_ref().map(|d| d.name())), + }; + assert_eq!(then, "file.newFromTemplate"); + let mut with = params; + with["password"] = json!("sesame"); + a.execute(&then, with).unwrap(); + assert!(body_text(&a).contains("Locked template"), "{}", body_text(&a)); + assert_eq!((a.session.path.as_ref(), a.session.password.is_some()), (None, false)); + assert_eq!(a.session.documents().len(), 1); + assert_eq!(std::fs::read(&path).unwrap(), bytes); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn word_templates_are_told_by_their_extension() { + use crate::documents::is_word_template; + for t in ["a.dotx", "a.dotm", "a.dot", "A.DOTX", "/x/y.Dotm", "C:\\x\\y.DOT"] { + assert!(is_word_template(t), "{t}"); + } + for t in ["a.docx", "a.doc", "dotx", "a.dotx.docx", "a.ott", "", "a."] { + assert!(!is_word_template(t), "{t}"); + } +} + /// #55: a password-protected document asks for its password (after Save Changes) instead of /// opening blank; scripts get the error instead of a dialog; Encrypt with Password asks for one. #[test] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index f69e584c4c..67d32e937d 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,4 +1,6 @@ # File-format parity with Microsoft Word +> **2026-11-05:** A Word template opened from the file manager starts a new document based on it, and is never saved over (#3433). + > **2026-11-05:** DOCX paragraph `w:adjustRightInd` keeps its default and explicit values through style resolution and schema-ordered repeated saves. > **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-05 · **Change:** trivial (ODT pictures offered in several formats); previously trivial (ODT pictures offered in several formats); previously trivial (ODT pictures offered in several formats); previously trivial (ODT heading text of another size); previously trivial (built-in citation source lookups, #4888; current-main progress records retained); previously trivial (ODT: no settings for what the standard leaves open); previously trivial (picture brightness, contrast and transparency); previously trivial (ODT paragraph groups kept as read); previously trivial (ODT paragraphs with charts kept as read); previously trivial (ODT sections in cells and at empty paragraphs); previously trivial (ODT element audit); previously trivial (ODT reference marks written as such); previously trivial (ODT hidden sections stay hidden; lists kept as read); previously trivial (ODT manifest lists every file); previously trivial (ODT paragraphs with bookmarks, notes, objects and page-anchored frames written as read); previously trivial (ODT paragraphs with text boxes written as read); previously trivial (ODT paragraphs with comments and tracked changes written as read); previously trivial (ODT paragraphs with pictures and shapes written as read); previously trivial (ODT sections inside text boxes); previously trivial (ODT definitions sharing a name, sections around tables, chart replacement pictures, note citations, stacking order); previously trivial (ODT form definitions and indexes written as read); previously trivial (ODT gradient angles by the file's version; deviation documented); previously minor (ODT: unedited tables, pages, sections, properties and declarations written as read; no-edit loss gate); previously minor (ODT: unedited paragraphs and style definitions written as read); previously trivial (ODT author fields said not fixed); previously trivial (ODT default text colour; covered cells' content); previously trivial (ODT widow control profile case; tabs from the indent kept); previously trivial (ODT span kerning; note reference formatting; notes starting with a tab); previously trivial (ODT fixed and sender fields); previously trivial (ODT: table styles saved into their cells; ODT tables carry no Word table style; section column balance); previously trivial (ODT interpretation profile: the standard is law, what it leaves open (including unstated page margins) follows WordCraft, LibreOffice or custom settings); previously trivial (ODT form controls keep their place on the line and text areas their lines; custom shapes keep their geometry); previously trivial (ODT in-line frames placed against the text; in-line pictures saved standing on the baseline); previously trivial (ODT charts written back as they were while unchanged; frames' drawn outline; default header height); previously trivial (ODT header rows only where they repeat); previously trivial (ODT padding without a border; unequal columns; column breaks); previously trivial (ODT lines of only pictures laid out as LibreOffice does; default page without a layout); previously trivial (ODT pictures alone in a paragraph keep its size; in-line frames raised under an inherited paragraph relation); previously trivial (ODT tables fit the margins and keep their space above and below; header spacing kept); previously trivial (ODT frames anchored and placed by their style); previously trivial (ODT default font size saved; page-anchored frames placed from the page; text box paragraph styles kept; empty paragraphs keep their size); previously trivial (ODT default font size; locale-ordered dates); previously trivial (ODT page background pictures); previously trivial (ODT tables fixed layout; tables starting a page); previously trivial (emphasis marks: model, layout, DOCX `w:em`, ODT `style:text-emphasize`, `format.emphasisMark`); previously trivial (ODT variables as SET/REF fields; character styles keep what they turn off); previously trivial (ODT styles keep what they turn off; WMF/EMF pictures kept; pictures in text boxes shown by LibreOffice); previously trivial (ODT unstated page margins are LibreOffice's 2 cm; drawings outside a paragraph join the next one); previously trivial (ODT placeholders as content controls, template dates, user name fields, unsized pages, style-only templates, found with LibreOffice's own templates); previously trivial (ODT hidden form controls kept; `.ott` templates opened and saved); previously trivial (ODT fidelity re-estimated at ~90%; a kept form control made another kind is saved as that kind); previously trivial (ODT form control sizes and buttons kept); previously trivial (ODT turned text in cells and text boxes); previously trivial (ODT macros and unknown package parts kept); previously trivial (ODT table cell formulas as formula fields); previously trivial (ODT custom shapes drawn from their own geometry); previously trivial (ODT bibliography marks as citations of sources); previously trivial (ODT linked text boxes); previously trivial (ODT picture crops and contour wrapping); previously trivial (ODT fidelity re-estimated at ~85%); previously trivial (ODT custom document properties and user fields as DOCPROPERTY); previously trivial (ODT connectors read and written glued); previously trivial (ODT borders around text read and written); previously trivial (ODT index marks read and written as XE fields; an index held around its entries updates); previously trivial (ODT ruby read and written); previously trivial (ODT page breaks after a paragraph, kept lines, widow control, hyphenation, table alignment and width and row heights); previously trivial (in-line drawings raised or lowered by their run's position; ODT in-line frames keep their place on the baseline; leading's space after the last line; text box borders shown by LibreOffice); previously trivial (ODT form controls opened and saved as content controls); previously trivial (password-protected ODT opened and saved); previously trivial (ODT headers and footers keep their shapes, text boxes, charts and embedded objects on save); previously trivial (ODT embedded spreadsheets and OLE objects kept whole instead of lost; flat ODT sub-documents valid `office:document`s); previously trivial (ODT charts in drawing groups drawn on open); previously trivial (ODT charts opened and saved as editable charts; flat ODT sub-documents no longer end the body); previously trivial (ODT leading read as an at-least spacing); previously trivial (ODT tracked formatting changes; changed regions named by `xml:id`); previously trivial (ODT drop caps; tab stops no longer drop a paragraph's background); previously trivial (flat ODT `.fodt` opened and saved; ODT fidelity re-estimated at ~80%); previously trivial (ODT page settings; a footnote no longer takes a footer's text); previously trivial (ODT dates in their formats, sequences, chapters and references); previously trivial (ODT bookmarks over text); previously trivial (ODT tables of contents and indexes; ODT fidelity re-estimated); previously trivial (ODT polygons, polylines and paths); previously trivial (ODT arrowheads, dashes and gradients); previously trivial (ODT groups of shapes and pictures); previously trivial (ODT rotated and mirrored shapes and pictures); previously trivial (ODT drawing shapes read and written); previously trivial (ODT text box frames keep their line, fill, padding and fit); previously trivial (ODT Asian and complex-script fonts); previously trivial (ODT right-to-left paragraphs); previously trivial (ODT text boxes kept); previously trivial (ODT unnumbered headings detached from the outline); previously trivial (ODT contextual spacing and layout settings); previously trivial (ODT custom character styles kept); previously trivial (ODT list level positions); previously trivial (ODT custom paragraph styles kept); previously trivial (ODT equations written as formula objects); previously trivial (ODT numbered headings written as headings); previously trivial (ODT floating pictures); previously trivial (ODT paragraph borders and background); previously trivial (ODT heading numbering); previously trivial (ODT font-face names); previously trivial (ODT table cell borders and padding); previously trivial (ODT heading, title, quote and preformatted styles keep their look); previously trivial (ODT tab stops read and written); previously trivial (ODT character formatting read and written); previously trivial (ODT lists keep their number formats, bullets and identity); previously trivial (ODT comments read and written); previously trivial (ODT footnotes and endnotes read and written); previously trivial (ODT headers, footers and text fields written; fields read); previously trivial (ODT paragraph spacing, indents and line spacing read and written); previously trivial (Replace and Remove Table of Contents treat Word's title above a section break as Word does, #4041); previously trivial (a password-protected .docx opened from the file manager asks for its password also when the startup document was replaced, #5299); previously minor (`w:altChunk` brought in on open; MHT opens; HTML style sheets, paragraph margins and Word's web-page defaults; broader corpus qualification and readiness estimates unchanged); previously trivial (a VML horizontal rule (`o:hr`) is drawn across its line at Word's measured width and kept on save, with rotation and flips, #3956); previously trivial (soft edges on a see-through solid fill fade from the fill's own opacity, #1600); previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (a list inside a Markdown block quote is written back inside the quote); previously trivial (arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (.doc: the default font is the stylesheet's standard font (Aptos in Word 365 files), #5066); previously trivial (a paragraph naming an undefined style is formatted as Normal, as in Word, #5077); previously trivial (a hyperlinked TOC writes its page numbers as `PAGEREF _Toc… \h` fields, as Word does, #5097); previously trivial (HTML import ends the empty comments and at once, #5040); previously trivial (chart and SmartArt text the font lacks is drawn in a fallback face, so Chinese labels no longer show boxes, #4720); previously trivial (bibliography sources keep a corporate author (`b:Corporate`): read, written, cited and listed whole, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (note conversion preserves content with an ordered reference/run walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (built-in heading styles have their outline level without `w:outlineLvl`, #4696); previously trivial (DOCX comment ranges inside footnotes and endnotes are kept on read, #4548); previously trivial (RTF import and export keep Windows and Enhanced Metafile pictures (`\wmetafile`, `\emfblip`), #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously trivial: the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously 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 @@ -68,7 +70,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ | `.docx` page background picture/texture (`v:background/v:fill`) | — | R/W | ✅ | ✅ | ~80% | — | `roundtrip.rs::page_background_picture_round_trips`, `crates/render` | Image part and `type`/`o:title` kept; tiled at 96 dpi or stretched; drawn on screen and in PDF; no Fill Effects dialog yet, gradients and patterns are not read | | `.docx` Word Document (ECMA-376 Transitional) | 70% | R/W | ✅ | ✅ | ~65% | 70–110 | `crates/docx/tests` (roundtrip 34, fixtures 29, malformed 8, bidi 5, bidi_runs 12, 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 | `strict_namespace_and_bad_numbers`, `strict_line_heights_with_a_unit_are_read_as_point_multiples` | Writing Strict is rare; unit-suffixed measures (`13.90pt`) read as points (#2329) | -| `.docm` / `.dotx` / `.dotm` | 3% | R/W | ✅ | ✅ | ~65% | incl. above | `macro_packages.rs` | Macros and signatures kept on save (#172); macros never run | +| `.docm` / `.dotx` / `.dotm` | 3% | R/W | ✅ | ✅ | ~65% | incl. above | `macro_packages.rs`, `a_template_the_platform_hands_the_app_starts_a_new_document` | Macros and signatures kept on save (#172); macros never run. A template (`.dotx`, `.dotm`, `.dot`) the platform hands the app (file manager, command line, Finder) starts a new, untitled document attached to it, as in Word; File › Open edits the template itself (#3433) | | `.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). Hostile files are bounded: a sector table longer than the file is refused before parsing (#3642); formatting and link runs are read once per change, not per character (#3692, #3708); copies of alt text, link targets and drawn objects share a 64 MB budget (#3709, #3710), and what it leaves out is logged once a kind per document; picture decoding shares a 512 MB budget and aliased store entries decode once (#3711, #3712). The default fonts are the stylesheet's standard fonts (`rgftcStandardChpStsh` and `ftcBi` in the `Stshi`, [MS-DOC] §2.9.273): Aptos in a Word 365 file, whose font table still starts with Times New Roman; a header too short for them, or an index past the font table, keeps the table's first font (#5066). Runs' East Asian and complex-script fonts (`sprmCRgFtc1`, `sprmCFtcBi`) are read. | | 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, also with fonts that draw lam-alef as two glyphs at one x, #2310); 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. Outline destinations follow leading page breaks to the heading text. 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 | @@ -334,6 +336,7 @@ For field results, the engine parser treats a backslash in quoted arguments as e | 2026-11-04 | trivial | Engine field parsing and DOCX HYPERLINK targets consume quoted backslash escapes consistently | | 2026-11-03 | trivial | Quoted field arguments honor Word backslash escapes, including literal asterisks | | 2026-11-04 | trivial | CLI input paths keep their native filename when a value-taking option comes first; regression coverage includes conversion to Flat OPC and rejection of non-Unicode option values. | +| 2026-11-05 | trivial | `.dotx`/`.dotm`/`.dot`: a template the platform hands the app starts a new, untitled document attached to it, as Word's default verb does; File › Open still edits the template (#3433); fidelity estimate unchanged | | 2026-11-05 | trivial | DOCX paragraph `w:adjustRightInd` keeps its default and explicit values in schema-ordered repeated saves | | 2026-11-04 | trivial | REF context update: 2026-11-04 · #4609 retains the label and template actually rendered by an ancestor list instance; implicitly counted levels remain distinct from explicitly rendered paragraphs. Synthetic instance-override and field-update regressions; no new native Word qualification or readiness change. | | 2026-11-04 | trivial | PR #4609: relative REF keeps context already displayed by the own or a needed ancestor label; six synthetic legal-template cases and separate-list context follow the Word for Windows measurements reported by @pakiat in PR discussion. Existing unknown-switch/cache boundaries and readiness estimates unchanged; no new native Word run. | From 4821a13ee08b62231239d701784298ef395ada24 Mon Sep 17 00:00:00 2001 From: Diego Guerini <106814760+D13GOOOO@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:36:54 +0100 Subject: [PATCH 03/42] Font menus: entries are drawn from a few shared textures, so Windows no longer shows them on black, white or red strips (#5105) (#5111) * #5105: font menu entries are drawn from a few shared textures Each font menu entry was a texture of its own, created and freed as the list was browsed: on Windows (DirectX 12) the entries showed other textures' memory, black strips in light mode, white or red ones in dark mode, as the Equation tab's pictures did before #1002. They now share an atlas written in place, the one the equation pictures use (MathAtlas, now PreviewAtlas). Co-Authored-By: Claude Opus 5.5 * #5105: font menu entries are drawn again in every window once fonts change The font atlas remembers the font database generation its entries were drawn with and starts over when it changes, so another window's entries and a removed font's entry (Recently Used Fonts) don't keep an old face. Co-Authored-By: Claude Opus 5.5 * #5105: the atlas test doesn't compare with the shared font generation Review: tests running alongside add and remove fonts, so the database's generation may change between the draw and the assertion. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Martin Ball --- ROADMAP.md | 2 + crates/ui-egui/src/app/fonts.rs | 2 +- crates/ui-egui/src/previews.rs | 130 ++++++++++++++++----------- crates/ui-egui/src/previews/tests.rs | 110 ++++++++++++++++++----- docs/ui-parity.md | 1 + 5 files changed, 169 insertions(+), 76 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 388ddcc08c..4b6cd435a2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -157,6 +157,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-11-07: An in-line picture lowered past its own height (`w:position`) lowers its line as far, as in Word; all 156 synthetic lines measured for #3984 match Word (#5796). Exact spacing keeps the line's baseline. - 2026-11-03: Search types in place: a click on the title bar's Search box, or Alt+Q, takes the typing in that box, with the results under it, as in Word, instead of a second box in a dialog (#5398). - 2026-10-31: List labels: a numbering level with format `none` and literal text, such as `RESPONSE:` with a one-inch hanging indent, shows that text as its label, as Word does; it used to show nothing. As in Word, a `none` level still counts and restarts the levels below it, its own number is empty (in legal numbering too), and an empty label still tabs the text to the indent (#4985). Readiness unchanged. +- 2026-10-31: Font menus draw their entries from a few shared textures written in place, as the Equation tab's pictures are (#1002), instead of a texture per font: on Windows (DirectX 12) browsing the list showed the names on black strips in light mode and white or red ones in dark mode (#5105). Readiness unchanged. - 2026-11-01: DOCX tables keep rows and cells wrapped in `mc:AlternateContent`, selecting one supported Choice or Fallback with the existing reader policy (#5219). Original synthetic checks cover namespace aliases, unsupported requirements, nested row/cell content controls and two save/reopen cycles; broader corpus and native Word qualification remain open. - 2026-11-01: Shapes: a line's arrowheads are as long and as wide as Word draws them: 2, 3 or 5 times the line's width for small, medium and large (a line under 2 points as a 2-point one). Their kind, width and length are read from DOCX (DrawingML and VML) and saved back; before, every arrowhead was one size and saved as a medium triangle. @@ -841,6 +842,7 @@ MATH glyphs that reference the same construction now share its immutable variant | 2026-11-07 | trivial | Progress log: An in-line picture lowered past its own height (`w:position`) lowers its line as far, as in Word; all 156 synthetic lines measured for #3984 match Word (#5796). | | 2026-11-03 | trivial | Progress log: the title bar's Search box takes the typing in place (#5398) | | 2026-10-31 | trivial | Progress log: a numFmt none list level shows its literal text (#4985); readiness unchanged | +| 2026-10-31 | trivial | Progress log: font menu entries are drawn from shared textures (#5105) | | 2026-11-02 | trivial | Progress log: alternative format chunks and MHT open | | 2026-11-06 | trivial | Print helpers remove loaded PDF/settings files before driver work, tolerate prior removal and report cleanup errors; a held-driver regression checks the files are already absent (#5710) | | 2026-11-06 | trivial | Windows print submissions isolated per helper process; cancelled driver work cannot block a retry (#5710) | diff --git a/crates/ui-egui/src/app/fonts.rs b/crates/ui-egui/src/app/fonts.rs index fc0370e164..54da1107ca 100644 --- a/crates/ui-egui/src/app/fonts.rs +++ b/crates/ui-egui/src/app/fonts.rs @@ -11,7 +11,7 @@ impl WordApp { } self.font_generation = generation; self.session.relayout(); - self.previews.fonts_changed(ctx, db.user_fonts().iter().flat_map(|f| f.families())); + self.previews.fonts_changed(); ctx.request_repaint(); } diff --git a/crates/ui-egui/src/previews.rs b/crates/ui-egui/src/previews.rs index 92bb56273f..5d02c834dd 100644 --- a/crates/ui-egui/src/previews.rs +++ b/crates/ui-egui/src/previews.rs @@ -28,30 +28,44 @@ pub struct Previews { budget: std::cell::Cell, frame: u64, /// The Equation tab's pictures, packed into one texture (#1002). - math: MathAtlas, + math: PreviewAtlas, + /// The font menus' entries, packed the same way (#5105); shared with the closures + /// [`Previews::font_preview_fn`] hands out. + fonts: Arc>, } -/// Side of each square texture page the equation pictures share, in pixels. -const MATH_ATLAS: usize = 2048; +/// The font menus' atlas, and the font database generation its entries were drawn with. When +/// fonts are added or removed, from this window or another one, every entry is drawn again: an +/// entry drawn in the fallback face before its font was added, or in a removed font's face (a +/// Recently Used Fonts entry), never stays. +#[derive(Default)] +struct FontAtlas { + generation: u64, + atlas: PreviewAtlas, +} + +/// Side of each square texture page the pictures of a [`PreviewAtlas`] share, in pixels. +const ATLAS: usize = 2048; /// At most this many pages: enough for a frame's pictures at a high display scale (the 22 /// built-in equations alone take two pages at 3×), still a handful of textures in all. -const MATH_ATLAS_PAGES: usize = 4; +const ATLAS_PAGES: usize = 4; /// Transparent pixels between pictures, so linear filtering never bleeds one into the next. -const MATH_ATLAS_GAP: usize = 2; +const ATLAS_GAP: usize = 2; -/// Every equation picture (gallery tiles, structure buttons, symbols) packed into a few shared -/// textures ("pages"), row by row, and drawn as a part of one (#1002). +/// Small pictures packed into a few shared textures ("pages"), row by row, and drawn as a part +/// of one: every equation picture (gallery tiles, structure buttons, symbols, #1002), and in an +/// atlas of their own the font menus' entries (#5105). /// -/// They used to be a texture each: hundreds of small textures, created as galleries opened and -/// all freed at once when the preview cache filled. On Windows (DirectX 12) the tiles then -/// showed other textures' memory: inverted pictures, red and teal fills, and even damaged -/// interface letters. A texture written in place is what egui does for its own glyphs; the -/// atlas creates at most [`MATH_ATLAS_PAGES`] textures and frees none while galleries are +/// They used to be a texture each: hundreds of small textures, created as galleries and font +/// menus were browsed and freed again. On Windows (DirectX 12) the pictures then showed other +/// textures' memory: inverted pictures (font names on black strips in light mode, white ones in +/// dark mode), red and teal fills, and even damaged interface letters. A texture written in place is what egui does for its own glyphs; the +/// atlas creates at most [`ATLAS_PAGES`] textures and frees none while galleries are /// browsed. When every page is full, the page least recently drawn from starts over, but never /// one drawn from in the current frame (its pictures are already on screen): a frame whose /// pictures don't fit shows the rest as linear text. #[derive(Default)] -struct MathAtlas { +struct PreviewAtlas { pages: Vec, /// Picture key → its page and part of that page's texture (UV coordinates). uv: HashMap, @@ -73,19 +87,19 @@ struct AtlasPage { impl AtlasPage { /// A free spot for a `w`×`h` picture, or `None` when the page is full or it can't fit. fn place(&mut self, w: usize, h: usize) -> Option<[usize; 2]> { - if w == 0 || h == 0 || w > MATH_ATLAS || h > MATH_ATLAS { + if w == 0 || h == 0 || w > ATLAS || h > ATLAS { return None; } let (mut x, mut y, mut row_h) = (self.x, self.y, self.row_h); - if x.saturating_add(w) > MATH_ATLAS { + if x.saturating_add(w) > ATLAS { x = 0; - y = y.saturating_add(row_h).saturating_add(MATH_ATLAS_GAP); + y = y.saturating_add(row_h).saturating_add(ATLAS_GAP); row_h = 0; } - if y.saturating_add(h) > MATH_ATLAS { + if y.saturating_add(h) > ATLAS { return None; } - self.x = x.saturating_add(w).saturating_add(MATH_ATLAS_GAP); + self.x = x.saturating_add(w).saturating_add(ATLAS_GAP); self.y = y; self.row_h = row_h.max(h); Some([x, y]) @@ -97,12 +111,12 @@ impl AtlasPage { self.y = 0; self.row_h = 0; if let Some(tex) = &mut self.tex { - tex.set(egui::ColorImage::filled([MATH_ATLAS, MATH_ATLAS], egui::Color32::TRANSPARENT), egui::TextureOptions::LINEAR); + tex.set(egui::ColorImage::filled([ATLAS, ATLAS], egui::Color32::TRANSPARENT), egui::TextureOptions::LINEAR); } } } -impl MathAtlas { +impl PreviewAtlas { /// The picture stored under `key`, marking its page as drawn from in `frame`. fn get(&mut self, frame: u64, key: &str) -> Option<(egui::TextureId, Rect)> { let (page, uv) = *self.uv.get(key)?; @@ -116,11 +130,11 @@ impl MathAtlas { /// this frame without wiping pictures already handed out in it. fn add(&mut self, ctx: &egui::Context, frame: u64, key: &str, img: egui::ColorImage) -> Option<(egui::TextureId, Rect)> { let [w, h] = img.size; - if w == 0 || h == 0 || w > MATH_ATLAS || h > MATH_ATLAS { + if w == 0 || h == 0 || w > ATLAS || h > ATLAS { return None; } let mut spot = self.pages.iter_mut().enumerate().find_map(|(i, p)| p.place(w, h).map(|at| (i, at))); - if spot.is_none() && self.pages.len() < MATH_ATLAS_PAGES { + if spot.is_none() && self.pages.len() < ATLAS_PAGES { let mut p = AtlasPage::default(); spot = p.place(w, h).map(|at| (self.pages.len(), at)); self.pages.push(p); @@ -138,18 +152,31 @@ impl MathAtlas { let p = self.pages.get_mut(i)?; let tex = p.tex.get_or_insert_with(|| { ctx.load_texture( - format!("math_atlas_{i}"), - egui::ColorImage::filled([MATH_ATLAS, MATH_ATLAS], egui::Color32::TRANSPARENT), + format!("preview_atlas_{i}"), + egui::ColorImage::filled([ATLAS, ATLAS], egui::Color32::TRANSPARENT), egui::TextureOptions::LINEAR, ) }); tex.set_partial(at, img, egui::TextureOptions::LINEAR); p.used = frame; - let s = MATH_ATLAS as f32; + let s = ATLAS as f32; let uv = Rect::from_min_max(pos2(at[0] as f32 / s, at[1] as f32 / s), pos2((at[0] + w) as f32 / s, (at[1] + h) as f32 / s)); self.uv.insert(key.to_string(), (i, uv)); Some((tex.id(), uv)) } + + /// Forget every picture and clear the pages, keeping their textures. + fn clear(&mut self) { + for p in &mut self.pages { + p.reset(); + } + self.uv.clear(); + } +} + +/// A font menu entry's key in the fonts' atlas: its display scale and family. +fn font_key(name: &str, ppp: f32) -> String { + format!("{ppp}:{name}") } impl Previews { @@ -210,30 +237,37 @@ impl Previews { self.english_names.get(shown).cloned().unwrap_or_else(|| shown.to_string()) } - /// Fonts were added or removed: list the families again and redraw the entries of - /// `families` (an entry drawn before its font was added shows the fallback face). - pub fn fonts_changed(&mut self, ctx: &egui::Context, families: impl Iterator) { + /// Fonts were added or removed: list the families again. The font menus' entries see the + /// change themselves ([`FontAtlas`]), in every window. + pub fn fonts_changed(&mut self) { self.families = None; self.east_asian = None; - ctx.data_mut(|d| { - for f in families { - d.remove::(egui::Id::unique(format!("font:{f}"))); - } - }); } - /// A closure drawing a font-menu entry in its own face. + /// A closure drawing a font-menu entry in its own face, from the fonts' atlas. pub fn font_preview_fn(&mut self) -> Box Response> { - Box::new(|ui: &mut Ui, name: &str| { - let key = format!("font:{name}"); - let tex: Option = ui.ctx().data(|d| d.get_temp::(egui::Id::unique(&key))); + let atlas = Arc::clone(&self.fonts); + Box::new(move |ui: &mut Ui, name: &str| { + let ppp = ui.ctx().pixels_per_point(); + let frame = ui.ctx().cumulative_frame_nr(); + let key = font_key(name, ppp); let (r, resp) = ui.allocate_exact_size(vec2(260.0, crate::widgets::COMBO_PREVIEW_ROW_H), Sense::click()); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), name)); let t = Tokens::get(ui.ctx()); if resp.hovered() { ui.painter().rect_filled(r, 3.0, t.hover); } - let tex = tex.or_else(|| { + // The atlas lock is never held while the Context's is, nor the other way round. + let generation = wordcraft_fonts::FontDb::global().generation(); + let hit = { + let mut fonts = atlas.lock(); + if fonts.generation != generation { + fonts.generation = generation; + fonts.atlas.clear(); + } + fonts.atlas.get(frame, &key) + }; + let hit = hit.or_else(|| { // Context accessors share a lock: read input before taking the data write lock. let now = ui.input(|i| i.time); let n = ui.ctx().data_mut(|d| { @@ -248,22 +282,14 @@ impl Previews { ui.ctx().request_repaint(); return None; } - let ppp = ui.ctx().pixels_per_point(); let mut img = font_entry_image(name, ppp); ink_mask(&mut img); - let h = ui.ctx().load_texture(&key, img, egui::TextureOptions::LINEAR); - ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique(&key), h.clone())); - Some(h) + atlas.lock().atlas.add(ui.ctx(), frame, &key, img) }); - match tex { - Some(h) => { - let sz = h.size_vec2() / ui.ctx().pixels_per_point(); - ui.painter().image( - h.id(), - Rect::from_min_size(pos2(r.min.x + 6.0, r.center().y - sz.y / 2.0), sz), - Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), - t.text, - ); + match hit { + Some((id, uv)) => { + let sz = uv.size() * ATLAS as f32 / ppp; + ui.painter().image(id, Rect::from_min_size(pos2(r.min.x + 6.0, r.center().y - sz.y / 2.0), sz), uv, t.text); } None => { ui.painter().text(pos2(r.min.x + 8.0, r.center().y), egui::Align2::LEFT_CENTER, name, regular(12.0), t.text); @@ -316,7 +342,7 @@ impl Previews { /// An equation rasterised for a gallery tile or a button (`pt`: font size; `size`: the tile in /// points), with its empty slots shown: the shared texture and the picture's UV rectangle in it -/// ([`MathAtlas`]). An ink mask, drawn tinted with the theme's text colour. `None` while waiting +/// ([`PreviewAtlas`]). An ink mask, drawn tinted with the theme's text colour. `None` while waiting /// for this frame's rendering budget. pub fn math_texture( app: &mut WordApp, diff --git a/crates/ui-egui/src/previews/tests.rs b/crates/ui-egui/src/previews/tests.rs index 31066daf38..7a56434542 100644 --- a/crates/ui-egui/src/previews/tests.rs +++ b/crates/ui-egui/src/previews/tests.rs @@ -121,10 +121,10 @@ fn font_menu_previews_render_defer_and_reuse_without_deadlocking() { let (tx, rx) = std::sync::mpsc::channel(); let worker = std::thread::spawn(move || { let ctx = egui::Context::default(); - let preview = Previews::default().font_preview_fn(); + let mut previews = Previews::default(); + let preview = previews.font_preview_fn(); let budget_id = egui::Id::unique("font_preview_budget"); - let sans_id = egui::Id::unique("font:Source Sans 3"); - let inter_id = egui::Id::unique("font:Inter"); + let (sans, inter) = (font_key("Source Sans 3", 1.0), font_key("Inter", 1.0)); ctx.data_mut(|d| d.insert_temp(budget_id, (1.0_f64, 11_u32))); for time in [1.0, 2.0, 3.0] { @@ -134,16 +134,16 @@ fn font_menu_previews_render_defer_and_reuse_without_deadlocking() { preview(ui, "Inter"); }) .drop_without_applying_deltas(); - ctx.data(|d| { - assert!(d.get_temp::(sans_id).is_some()); - if time == 1.0 { - assert!(d.get_temp::(inter_id).is_none(), "over-budget entry should defer"); - assert_eq!(d.get_temp::<(f64, u32)>(budget_id), Some((1.0, 13))); - } else { - assert!(d.get_temp::(inter_id).is_some(), "deferred entry should render next frame"); - assert_eq!(d.get_temp::<(f64, u32)>(budget_id), Some((2.0, 1)), "cached entries should not consume budget"); - } - }); + let drawn = |key: &str| previews.fonts.lock().atlas.uv.contains_key(key); + assert!(drawn(&sans)); + let budget = ctx.data(|d| d.get_temp::<(f64, u32)>(budget_id)); + if time == 1.0 { + assert!(!drawn(&inter), "over-budget entry should defer"); + assert_eq!(budget, Some((1.0, 13))); + } else { + assert!(drawn(&inter), "deferred entry should render next frame"); + assert_eq!(budget, Some((2.0, 1)), "cached entries should not consume budget"); + } } tx.send(()).unwrap(); }); @@ -262,12 +262,12 @@ fn equation_pictures_share_one_texture() { fn math_atlas_packs_rows() { let mut a = AtlasPage::default(); assert_eq!(a.place(100, 40), Some([0, 0])); - assert_eq!(a.place(100, 20), Some([100 + MATH_ATLAS_GAP, 0])); - assert_eq!(a.place(MATH_ATLAS, 10), Some([0, 40 + MATH_ATLAS_GAP]), "a new row under the tallest"); - assert_eq!(a.place(MATH_ATLAS + 1, 10), None); + assert_eq!(a.place(100, 20), Some([100 + ATLAS_GAP, 0])); + assert_eq!(a.place(ATLAS, 10), Some([0, 40 + ATLAS_GAP]), "a new row under the tallest"); + assert_eq!(a.place(ATLAS + 1, 10), None); assert_eq!(a.place(10, 0), None); - assert_eq!(a.place(10, MATH_ATLAS), None, "full"); - assert_eq!(a.place(10, 10), Some([0, 40 + MATH_ATLAS_GAP + 10 + MATH_ATLAS_GAP]), "still room after a refusal"); + assert_eq!(a.place(10, ATLAS), None, "full"); + assert_eq!(a.place(10, 10), Some([0, 40 + ATLAS_GAP + 10 + ATLAS_GAP]), "still room after a refusal"); } /// #1002: a page is never wiped in a frame that handed out one of its pictures (they are @@ -275,16 +275,16 @@ fn math_atlas_packs_rows() { #[test] fn math_atlas_never_wipes_a_page_drawn_from_this_frame() { let ctx = egui::Context::default(); - let img = || egui::ColorImage::filled([MATH_ATLAS, MATH_ATLAS], egui::Color32::TRANSPARENT); + let img = || egui::ColorImage::filled([ATLAS, ATLAS], egui::Color32::TRANSPARENT); let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { - let mut a = MathAtlas::default(); - let first: Vec<_> = (0..MATH_ATLAS_PAGES).map(|i| a.add(ui.ctx(), 1, &format!("p{i}"), img())).collect(); + let mut a = PreviewAtlas::default(); + let first: Vec<_> = (0..ATLAS_PAGES).map(|i| a.add(ui.ctx(), 1, &format!("p{i}"), img())).collect(); assert!(first.iter().all(Option::is_some), "a page each"); let ids: std::collections::HashSet<_> = first.iter().flatten().map(|(id, _)| *id).collect(); - assert_eq!(ids.len(), MATH_ATLAS_PAGES); + assert_eq!(ids.len(), ATLAS_PAGES); assert!(a.add(ui.ctx(), 1, "more", img()).is_none(), "every page was drawn from this frame"); assert_eq!(a.wipes, 0); - assert!(a.uv.len() == MATH_ATLAS_PAGES, "nothing forgotten"); + assert!(a.uv.len() == ATLAS_PAGES, "nothing forgotten"); // Next frame: page 0 is drawn from again, so the one drawn longest ago is page 1. assert_eq!(a.get(2, "p0"), first.first().copied().flatten()); let (id, _) = a.add(ui.ctx(), 2, "more", img()).expect("a page starts over"); @@ -347,6 +347,70 @@ fn ink_mask_keeps_the_ink_and_drops_the_paper() { assert_eq!(img.pixels[2].a(), 127, "antialiased ink keeps its coverage"); } +/// #5105: on Windows, font menu entries showed on black strips in light mode and on white or +/// red ones in dark mode: each entry was a texture of its own, and browsing the list created +/// and freed hundreds of them, the DirectX 12 texture mix-up of #1002. Entries are now drawn +/// from a few shared pages written in place: scrolling through a long font list, at a high +/// display scale too, creates at most [`ATLAS_PAGES`] textures and frees none. Once fonts are +/// added or removed (from any window), every entry is drawn again. +#[test] +fn font_menu_entries_share_a_few_textures_and_free_none() { + for ppp in [1.0, 3.0] { + let ctx = egui::Context::default(); + ctx.set_pixels_per_point(ppp); + let mut previews = Previews::default(); + let preview = previews.font_preview_fn(); + let names: Vec = (0..300).map(|i| format!("Font {i}")).collect(); + let (mut created, mut freed, mut ids) = (std::collections::HashSet::new(), 0, std::collections::HashSet::new()); + // A menu shows about fifteen entries a frame; scroll it through the list twice. + for (frame, rows) in names.chunks(15).chain(names.chunks(15)).enumerate() { + let input = egui::RawInput { time: Some(frame as f64), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| { + for name in rows { + preview(ui, name); + } + }); + created.extend( + out.textures_delta + .set + .iter() + .filter(|(id, ds)| **id != egui::TextureId::default() && ds.iter().any(|d| d.pos.is_none())) + .map(|(id, _)| *id), + ); + freed += out.textures_delta.free.len(); + for s in &out.shapes { + if let egui::Shape::Mesh(m) = &s.shape + && m.texture_id != egui::TextureId::default() + { + ids.insert(m.texture_id); + } + } + out.textures_delta.clear(); + } + assert!(!created.is_empty() && created.len() <= ATLAS_PAGES, "{ppp}: {} textures created", created.len()); + assert_eq!(freed, 0, "{ppp}: no texture is freed while the list is browsed"); + assert!(ids.len() <= ATLAS_PAGES, "{ppp}: entries drawn from {} textures", ids.len()); + // An entry is drawn from the atlas, not as plain text. + ctx.run_ui(egui::RawInput { time: Some(1000.0), ..Default::default() }, |ui| { + preview(ui, "Font 298"); + preview(ui, "Font 299"); + }) + .drop_without_applying_deltas(); + assert!(previews.fonts.lock().atlas.uv.contains_key(&font_key("Font 299", ppp))); + // Fonts were added or removed since the entries were drawn: the next entry drawn starts + // the atlas over, so none keeps a face it no longer has. + previews.fonts.lock().generation = u64::MAX; + ctx.run_ui(egui::RawInput { time: Some(1001.0), ..Default::default() }, |ui| { + preview(ui, "Font 298"); + }) + .drop_without_applying_deltas(); + let fonts = previews.fonts.lock(); + // Not compared with the database's generation: tests running alongside add and remove fonts. + assert_ne!(fonts.generation, u64::MAX, "the atlas took the database's generation"); + assert!(fonts.atlas.uv.contains_key(&font_key("Font 298", ppp)) && !fonts.atlas.uv.contains_key(&font_key("Font 299", ppp))); + } +} + /// #535: font menu entries drew on a white strip in dark mode. They are now tinted with the /// theme's text colour and draw no background of their own. #[test] diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 833118b774..a92e3a724d 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -111,6 +111,7 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text | 2026-11-05 | trivial | Page Setup's margin, paper-size and document-grid number boxes scroll long text inside the field instead of enlarging the dialog (#5650). Readiness estimates unchanged. | | 2026-10-28 | trivial | Home › Paragraph › Shading: text selected within part of one paragraph is shaded itself, as in Word, so No Color removes text shading imported from a file (#4685) | | 2026-11-03 | trivial | Search ("Tell me"): a click on the title bar's Search box, or Alt+Q, types into that box itself, as in Word, with the suggestions and results (commands, then Find in the document) dropping down under it; Enter runs the first, Escape or a click elsewhere closes it. It used to open a second box in a Search Commands dialog, which is still what shows where the box isn't drawn (#5398) | +| 2026-10-31 | trivial | Font menus (ribbon, mini toolbar, Font and other dialogs): entries are drawn from at most four shared textures written in place, as the Equation tab's pictures are (#1002), instead of a texture per font; on Windows (DirectX 12) they showed on black strips in light mode and white or red ones in dark mode (#5105) | | 2026-10-17 | trivial | Drawing Canvas members are selected with a click and dragged, resized, turned, nudged, formatted, deleted and copied on their own inside the canvas; a click in a text box member's text puts the caret there (#2637) | | 2026-11-05 | trivial | On-canvas objects: dragging an In Line with Text picture, shape or text box shows a bold, solid insertion cursor, twice the caret's width, as in Word; the dashed one was hard to see (#4195) | | 2026-10-28 | trivial | Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged. | From 4ba444a3429bd4ca531a96bbd840e716ead6c510 Mon Sep 17 00:00:00 2001 From: Rees <109076338+OPUZZLES@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:39:17 +0100 Subject: [PATCH 04/42] Table Sort: validate explicit columns (#5531) (#5533) Co-authored-by: Martin Ball --- ROADMAP.md | 1 + crates/engine/src/cmd/table.rs | 2 +- crates/engine/src/cmd/table/convert.rs | 9 +++- crates/engine/src/tests.rs | 67 ++++++++++++++++++++++++++ docs/gaps.md | 3 ++ docs/ui-parity.md | 3 ++ 6 files changed, 83 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4b6cd435a2..b6f8ec3c4d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -220,6 +220,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-10-31: ODT: paragraph spacing, indents and line spacing are read and written, and an ODT's own default spacing (none, single lines) replaces Word's 8 pt after and 1.15 lines, so LibreOffice documents keep their vertical rhythm on open and save (gap 19). Readiness unchanged. - 2026-11-01: PDF export, printing and page images show the body's SEQ, STYLEREF and REF fields with their current results, as Word fills them in when it prints or exports even with Update fields before printing off: a caption numbered 7 when it is the first shows 1 on paper, and a REF to a bookmark the document lacks prints Word's "Error! Reference source not found.". NUMWORDS, FILENAME, the document properties, formulas and locked fields keep their stored results there, as in Word; the editor and the saved document keep every stored result. - 2026-10-28: Print Markup HTML, Markdown and text copies discover comment anchors in one paragraph walk, so unrelated bookmark markers no longer cause repeated prefix scans (#3614). Existing quotes, replies, order and export limits are unchanged; original synthetic semantic and scaling regressions cover the traversal. +- 2026-11-04: Table Sort refuses malformed and nonexistent explicit columns before reordering rows (#5531). Omitted column 0, valid columns, header/order/language behavior and Undo/Redo are retained. Original Linux CLI/MCP controls and repeated native/DOCX reopens cover the command; native Word and desktop comparison remain untested. Readiness estimates unchanged. - 2026-10-30: Layout resource caps (#3867, #4385) and linear-time passes (#3866, #4392): repeated table-header rows are capped by item counts, a clipped group counting every nested member, the wrap cache by decoded pixels and the widths a linked text box chain lays out by their lines and items; PDF export, hit testing, tracked-change and clipped-table costs, and long endnote selections no longer grow quadratically. Synthetic regression tests only; no new Word comparison. - 2026-11-03: Proofing (CP4.3): craft-proof moves to craft-libs main with #589 and #590. French spelling suggestions offer the elided form first (`Jadore` → `J'adore`, before `J adore`), and Valencian text (`ca-ES-valencia`) is proofed with the `ca-valencia` pack instead of plain Catalan. Readiness unchanged. - 2026-11-03: A long comment no longer grows its card or balloon without limit (#3382). Its field in the Comments pane and in a selected balloon stops at ten lines and scrolls, keeping the caret in view; a balloon that isn't selected shows the first ten lines of the comment and of each reply, and clicking it opens the scrolling field. The buttons and the next comment stay in view. diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 6734e050c4..925e1f6cab 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -195,7 +195,7 @@ pub fn specs() -> Vec { }) .params(r#"{"text"?: string, "title"?: string} (empty clears)"#)), t(CommandSpec::new("table.sort", "Sort", "Table Layout › Data", sort_table) - .params(r#"{"column"?: n, "descending"?: bool, "header"?: bool}"#)), + .params(r#"{"column"?: non-negative JSON integer (0-based, default 0; must name a column in the table), "descending"?: bool, "header"?: bool}"#)), t(CommandSpec::new("table.toText", "Convert to Text", "Table Layout › Data", to_text) .params(r#"{"separator"?: "tab|comma|paragraph|other", "char"?: string (with other)} (the selected rows, or the whole table)"#)), t(CommandSpec::new("table.formula", "Formula", "Table Layout › Data", formula).params(r#"{"formula"?: "=SUM(ABOVE)"}"#)), diff --git a/crates/engine/src/cmd/table/convert.rs b/crates/engine/src/cmd/table/convert.rs index f8cb418863..e92ce29d9e 100644 --- a/crates/engine/src/cmd/table/convert.rs +++ b/crates/engine/src/cmd/table/convert.rs @@ -8,11 +8,18 @@ use super::{ /// Sort the table's rows by a column. Numbers are read in each cell's language (`1,5` is one and /// a half in Polish) and sort first, numerically; other text sorts in the column's language. pub(super) fn sort_table(s: &mut Session, v: &Value) -> CmdResult { - let col = p::u64(v, "column").unwrap_or(0) as usize; + let column = match v.get("column") { + None => 0, + Some(value) => value.as_u64().ok_or_else(|| CmdError::Params("`column` must be a non-negative JSON integer".into()))?, + }; let desc = p::bool(v, "descending").unwrap_or(false); let header = p::bool(v, "header"); let (tp, _, _) = cell(s)?; let t = s.doc.table(s.sel.focus.story, &tp).ok_or_else(|| CmdError::Failed("no table".into()))?; + let col = usize::try_from(column) + .ok() + .filter(|&col| t.rows.iter().any(|row| col < row.cells.len())) + .ok_or_else(|| CmdError::Params("`column` is outside the table".into()))?; let skip = usize::from(header.unwrap_or(t.rows.first().is_some_and(|r| r.props.header))).min(t.rows.len()); let column = || t.rows.iter().skip(skip).map(|r| r.cells.get(col)); let lang = column().flatten().find_map(|c| cell_lang(&s.doc, c)).or_else(|| crate::cmd::default_lang(s)); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index bf37b90205..d51a5df56f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -10389,6 +10389,73 @@ fn column_texts(s: &Session) -> Vec { t.rows.iter().map(|r| r.cells[0].blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect::()).collect() } +#[test] +fn table_sort_rejects_invalid_columns_without_changing_rows_or_history() { + for column in [ + json!(-1), + json!(0.5), + json!(0.0), + json!("0"), + json!(null), + json!(true), + json!([]), + json!({}), + json!(1), + json!(u32::MAX as u64 + 1), + json!(u64::MAX), + ] { + let mut s = column_table("en-US", &["Charlie", "Alpha", "Bravo"]); + s.close_typing(); + run(&mut s, "text.insert", json!({"text": "!"})); + let before = s.doc.clone(); + let selection = s.sel.clone(); + let dirty = s.dirty; + assert!(s.run("table.sort", &json!({"column": column, "header": false})).is_err(), "column {column}"); + assert_eq!(s.doc, before, "column {column}"); + assert_eq!(s.sel, selection, "column {column}"); + assert_eq!(s.dirty, dirty, "column {column}"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(column_texts(&s), ["Charlie", "Alpha", "Bravo"], "column {column}: refusal must not consume Undo"); + run(&mut s, "edit.redo", json!({})); + assert_eq!(s.doc, before, "column {column}: Redo must restore the preceding edit"); + } +} + +#[test] +fn table_sort_keeps_default_and_valid_columns() { + let mut s = s(); + run(&mut s, "insert.table", json!({"rows": 3, "cols": 2})); + for (k, value) in ["Charlie", "1", "Alpha", "3", "Bravo", "2"].into_iter().enumerate() { + if k > 0 { + run(&mut s, "text.tab", json!({})); + } + run(&mut s, "text.insert", json!({"text": value})); + } + let original = s.doc.clone(); + for params in [json!({"header": false}), json!({"column": 0, "header": false})] { + run(&mut s, "table.sort", params); + assert_eq!(column_texts(&s), ["Alpha", "Bravo", "Charlie"]); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc, original); + } + run(&mut s, "table.sort", json!({"column": 1, "header": false})); + assert_eq!(column_texts(&s), ["Charlie", "Bravo", "Alpha"]); + run(&mut s, "table.sort", json!({"column": 1, "header": false, "descending": true})); + assert_eq!(column_texts(&s), ["Alpha", "Bravo", "Charlie"]); + // A column may be missing in some rows. Its present cells still sort normally. + let at = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32; + let table = s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![at])).unwrap(); + table.rows[0].cells.remove(1); + run(&mut s, "table.sort", json!({"column": 1, "header": false})); + assert_eq!(column_texts(&s), ["Charlie", "Bravo", "Alpha"]); + + let mut s = column_table("en-US", &["Title", "3", "1", "2"]); + let at = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32; + s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![at])).unwrap().rows[0].props.header = true; + run(&mut s, "table.sort", json!({"column": 0})); + assert_eq!(column_texts(&s), ["Title", "1", "2", "3"]); +} + /// #836: table sort reads numbers in the text's language: `1,5` is one and a half in Polish. #[test] fn table_sort_reads_decimal_comma_in_polish() { diff --git a/docs/gaps.md b/docs/gaps.md index 66f41b99e7..e3866b517d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -18,6 +18,8 @@ Group connectors (gap 10): re-routing preserves displayed rotated and flipped fr > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. +Table Sort rejects invalid explicit column selectors instead of silently sorting another column (#5531). This command-boundary correction leaves the ranked gaps and readiness estimates unchanged. + The work list. One entry per shortfall, ranked by how much it stops a Word user from switching (**B** = blocks beta). Each says what's missing, the evidence, who it hurts, an Opus 5.5 agent wall-clock estimate, and which parity document tracks it. Agents: pick from the top, check open PRs @@ -214,6 +216,7 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text | 2026-11-01 | trivial | #10 narrowed: text in a turned text box is drawn turned with it on screen and in PNG and PDF (upside down when flipped across, never mirrored); editing it still goes by its upright text; estimate unchanged | | 2026-10-29 | trivial | Print Markup balloon anchor discovery no longer repeats paragraph-prefix scans (#3614); readiness estimates unchanged | | 2026-10-28 | trivial | Print Markup text-copy comment discovery walks paragraph objects once (#3614); quotes, order, limits and readiness estimates unchanged | +| 2026-11-04 | trivial | Table Sort rejects malformed or nonexistent explicit columns before mutation; valid columns, the omitted-column default and history are retained (#5531). Readiness estimates unchanged. | | 2026-10-30 | trivial | #20 narrowed: layout resource caps and linear-time passes (#3867, #3866) | | 2026-10-17 | trivial | UI/UX gaps: copies put RTF on the clipboard (Windows, macOS, Wayland; not X11), and paste reads RTF on macOS and X11 too | | 2026-11-03 | minor | #11: Arabic interface with mirrored shell (#1195); key languages missing are now hi and id (plus pt-PT); Hebrew UI still open | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index a92e3a724d..0d64fef907 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -77,6 +77,8 @@ Save Changes exposes the document-specific question as an alert-dialog name and The Language dialog reads effective character formatting, including document defaults, paragraph styles and character styles. Accepting it unchanged keeps inherited language and no-proof choices (#2717); explicit direct formatting still takes precedence. Other proofing and Windows-specific reports remain separate. +The `table.sort` command requires an explicit `column` to be a non-negative JSON integer naming a column in the table, indexed from 0 (#5531). Omitting it still sorts by column 0. Invalid selectors return an error before changing rows or history; the Sort dialog and collation rules are unchanged. + ## Find and Replace: regular expressions (#4177) With **Regular expressions** on (Search Options), Replace with expands capture groups: `$1`…`$n`, `${n}`, `${name}` and `$name` (the longest run of letters, digits and `_`), `$0` / `${0}` (the whole match), and `\1`…`\9` as in wildcard Replace; `$$` is a dollar sign, `\t` a tab, `\\` a backslash, `\$` and `\^` a `$` or `^` as typed, and `\n` a paragraph mark, which splits the paragraph as `^p` does (Replace All stays one undo step). Word's `^&`, `^p`, `^t`, `^c` and the other `^` codes work there too (with Special characters off, only the `^` codes are taken literally). A group that doesn't exist, didn't take part in the match or is out of range expands to nothing; a `$` that starts no group, an unclosed `${`, and other `\` escapes stay as written. Plain and wildcard Replace are unchanged. Word has no regular-expression mode; this follows the Rust `regex` crate's Replace syntax. @@ -117,6 +119,7 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text | 2026-10-28 | trivial | Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged. | | 2026-10-31 | trivial | Dialogs are modal (#3859): while one is open, clicks on the ribbon, File or the page, typing and shortcuts do nothing, as in Word. Find and Replace, Symbol, Mark Index Entry, Cross-reference and Insert Merge Field stay modeless, as in Word for Mac. A press beside a modal dialog shakes it a little, or flashes its border with animation effects off, so it is clear why nothing behind it reacts | | 2026-10-17 | trivial | Copy and Cut put RTF beside the text and HTML on the system clipboard, for programs that read RTF rather than HTML (Windows "Rich Text Format", macOS `public.rtf`, Wayland `text/rtf`; an X11 copy stays text and HTML, since arboard owns the selection there; a copy of only pictures or shapes carries no HTML or RTF, so pasting it back keeps every detail); Paste reads another program's RTF on macOS and X11 too, as on Windows and Wayland | +| 2026-11-04 | trivial | Table Sort rejects malformed or nonexistent explicit columns before mutation; valid columns, the omitted-column default and history are retained (#5531). Readiness estimates unchanged. | | 2026-11-03 | trivial | Comments: a long comment's card, balloon and fields stop at ten lines; the fields scroll, and a balloon that isn't selected shows the first ten lines of the comment and of each reply (#3382) | | 2026-11-03 | trivial | Over a turned picture, shape or text box, the pointer on a resize handle shows the arrow along which that handle resizes: turned 90°, the handle now at the top shows an up-down arrow (it showed left-right), turned 45° the middle handles show diagonal ones; also while the handle is dragged (#3932) | | 2026-11-03 | trivial | Shape Format › Size: the width and height show the interface unit and take a size typed in any unit (`3 cm`, `300 px`), applied once typed (Enter, Tab or a click elsewhere; Esc cancels), each side on its own, as Picture Format › Size does; in both, a field clicked and left without typing no longer resizes the object by the rounding of the size it shows (#4196) | From 6b163b33dc4dc3e64273f647dc594d3bdea5a470 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 7 Nov 2026 21:39:35 -0500 Subject: [PATCH 05/42] xtask: no symlink walks, exclusive test dir; Paste Special picture files must be regular files (#5471) Co-authored-by: Martin Ball --- ROADMAP.md | 1 + crates/ui-egui/src/paste_picture.rs | 10 ++++++++-- xtask/src/assets.rs | 6 ++++-- xtask/src/stats.rs | 20 ++++++++++++++++++-- 4 files changed, 31 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b6f8ec3c4d..9445deb18c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -226,6 +226,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-11-03: A long comment no longer grows its card or balloon without limit (#3382). Its field in the Comments pane and in a selected balloon stops at ten lines and scrolls, keeping the caret in view; a balloon that isn't selected shows the first ten lines of the comment and of each reply, and clicking it opens the scrolling field. The buttons and the next comment stay in view. - 2026-11-03: Arabic dialogs: the dialogs of the mirrored interface now read right to left. Rows of a label and its field, lists of columns (Compare, AutoCorrect, Special Characters, Track Changes Options, the Columns widths), the tile galleries (chart types, SmartArt layouts) and the ribbon Paragraph group start at the right edge, through an `rtl::Table` helper that is an `egui::Grid` in English; the Add >> and << Remove arrows swap, the Columns preset captions and the Macros buttons no longer overlap, and the Recipient List grid and About › Models scroll instead of running past the left edge. Readiness unchanged. - 2026-11-03: Shape Format › Size: the width and height fields show the interface unit (centimetres, inches…) instead of always points, and take a size typed in any unit, such as `3 cm` or `300 px`, applied once typed (Esc cancels), each side on its own, as Picture Format › Size does (#4196). In both, a field clicked and left with Enter or Tab without typing no longer resizes the object by the rounding of the size it shows (a 100 pt shape became 100.08 pt in inches). +- 2026-11-03: Security: Paste Special with picture files on the clipboard refuses anything that isn't a regular file (a pipe or device reports no length and can produce endless data), and `cargo xtask stats` no longer follows directory symlinks. Readiness unchanged. - 2026-11-03: Security: pasting text boxes charges each copied story for its formatting too (run, mark, paragraph and section properties), not only its text and objects, so a story with huge style or font names can't copy past the paste's 32 MiB text box budget. Readiness unchanged. - 2026-11-03: Security: `.doc` import keeps one width, shading, alignment and merge override per table cell, the last one winning as before, instead of a copy every time a paragraph's properties repeat the sprm, so a shared PAPX can't pile up copies on every paragraph mark. Readiness unchanged. - 2026-11-03: Security: `.doc` import attaches each section to the paragraph that ends it through an index of paragraph ends, instead of scanning every paragraph for every section, so a file with a thousand sections that end nowhere can't multiply the work over its paragraphs. Readiness unchanged. diff --git a/crates/ui-egui/src/paste_picture.rs b/crates/ui-egui/src/paste_picture.rs index 34172cc1c3..ddbe198072 100644 --- a/crates/ui-egui/src/paste_picture.rs +++ b/crates/ui-egui/src/paste_picture.rs @@ -147,8 +147,14 @@ pub(crate) fn paste(app: &mut WordApp) -> Option> { } let mut r = Err(String::new()); for path in files { - let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0); - r = if size > MAX_FILE_BYTES { + // A clipboard path can name a pipe or device that reports no length and never ends. + let md = std::fs::metadata(&path).ok(); + let size = md.as_ref().map_or(0, |m| m.len()); + r = if md.as_ref().is_some_and(|m| !m.is_file()) { + let e = format!("{} is not a regular file", path.display()); + app.status(e.clone()); + Err(e) + } else if size > MAX_FILE_BYTES { let e = format!("{} is larger than 200 MB", path.display()); app.status(e.clone()); Err(e) diff --git a/xtask/src/assets.rs b/xtask/src/assets.rs index e962f6ef57..4c10c8e8ed 100644 --- a/xtask/src/assets.rs +++ b/xtask/src/assets.rs @@ -73,8 +73,10 @@ mod tests { fn a_failed_file_listing_fails_the_check() { // #1364: outside a git checkout `git ls-files` fails, and its empty output read as "no // files" passed the check. - let dir = std::env::temp_dir().join(format!("wordcraft-xtask-assets-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); + // Created exclusively under a name no one can predict: an existing folder or link there would be someone else's. + let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.subsec_nanos()); + let dir = std::env::temp_dir().join(format!("wordcraft-xtask-assets-{}-{nanos}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); std::fs::write(dir.join("ATTRIBUTION.md"), "").unwrap(); let inside_git = Command::new("git").current_dir(&dir).args(["rev-parse", "--git-dir"]).output().is_ok_and(|o| o.status.success()); let result = run(&dir); diff --git a/xtask/src/stats.rs b/xtask/src/stats.rs index f40b1300ac..8abfea4e58 100644 --- a/xtask/src/stats.rs +++ b/xtask/src/stats.rs @@ -18,12 +18,14 @@ fn walk(dir: &Path, c: &mut Counts) { let p = e.path(); let name = e.file_name(); let name = name.to_string_lossy(); - if p.is_dir() { + // A symlink is never followed: several links to an ancestor would multiply the walk. + if e.file_type().is_ok_and(|t| t.is_dir()) { // Nested fuzz workspaces and build output are not part of the crate. if name != "target" && name != "fuzz" && !name.starts_with('.') { walk(&p, c); } } else if name.ends_with(".rs") + && e.file_type().is_ok_and(|t| t.is_file()) && let Ok(s) = std::fs::read_to_string(&p) { c.files += 1; @@ -108,11 +110,25 @@ pub fn run(_root: &Path, exact: bool) -> Result<(), String> { #[cfg(test)] mod tests { - use super::count_tests; + use super::{Counts, count_tests, walk}; #[test] fn counts_attributes_and_proptest_blocks() { let src = "#[test]\nfn a() {}\n// #[test] commented\n #[test]\nfn b() {}\nproptest! {\n}\n"; assert_eq!(count_tests(src), (2, 1)); } + + #[cfg(unix)] + #[test] + fn a_walk_does_not_follow_directory_links() { + let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.subsec_nanos()); + let dir = std::env::temp_dir().join(format!("wordcraft-xtask-stats-{}-{nanos}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + std::fs::write(dir.join("a.rs"), "#[test]\n").unwrap(); + std::os::unix::fs::symlink(&dir, dir.join("loop")).unwrap(); + let mut c = Counts::default(); + walk(&dir, &mut c); + let _ = std::fs::remove_dir_all(&dir); + assert_eq!((c.files, c.tests), (1, 1)); + } } From 050b6b0da33ae99444aad26a2df8d745586ccd5f Mon Sep 17 00:00:00 2001 From: StrajnarFilip <46705237+StrajnarFilip@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:44:01 +0100 Subject: [PATCH 06/42] Arabic letters join across a change of formatting (#4093) (#5691) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Arabic letters join across a change of formatting (#4093) Each run of a paragraph was shaped on its own, so a letter at a run's edge took the form of a word's start or end. A coloured letter stood apart from its word, and a word edited with Track Changes on read with a space in it (ال ذكاء). Runs are now shaped with the text shown on either side of them, through the shaper's pre- and post-context (fonts::ShapeContext). Runs left out of the layout (hidden text, deletions in a final view) are skipped, so letters join what is on the page. Only paragraphs with a joining script do the work. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LvG6E2QwHfTULQtWYgVEXk * #4093 review: no letter joins a drop cap formatted on its own A drop cap letter in a run of its own (coloured, say) was still passed to the next run as the text before it, so that run's first letter joined the drop cap, which is drawn apart from the word. The shown text now has a floor past the drop cap, and no run takes context from before it. The drop cap test also covers the letter in a run of its own. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LvG6E2QwHfTULQtWYgVEXk --------- Co-authored-by: Filip Strajnar Co-authored-by: Claude Opus 5.5 Co-authored-by: Martin Ball --- ROADMAP.md | 2 + crates/fonts/src/lib.rs | 62 ++++++++++-- crates/fonts/src/script.rs | 22 +++++ crates/layout/src/para/builder.rs | 23 ++++- crates/layout/src/para/paragraph.rs | 58 ++++++++++- crates/layout/src/tests/bidi.rs | 146 ++++++++++++++++++++++++++++ docs/gaps.md | 4 +- docs/layout-parity.md | 3 + docs/localization-parity.md | 3 +- 9 files changed, 309 insertions(+), 14 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9445deb18c..3c31857b74 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -171,6 +171,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-11-04: RTF import skips the declared Unicode fallback count even when it exceeds eight fallback units, so longer ANSI alternatives do not leak into the document text. Skipping stays bounded by the available input and ends at a group boundary. Readiness estimates unchanged. - 2026-11-05: Linux: a WordCraft started by an AppImage app no longer takes that app's file for its own (#5653). The AppImage runtime's `APPIMAGE`, `APPDIR`, `ARGV0` and `OWD` pass to whatever the app starts, so an installed WordCraft opened through `xdg-open` saw another program's `APPIMAGE`, and its self-update replaced that program's file. The app now starts over without those variables unless its executable is under `APPDIR`, so the update goes through the package or plain file it really is. Should starting over fail, the self-update is off in that window and Help › Check for Updates says why. Readiness estimates unchanged. - 2026-11-05: A Word template (`.dotx`, `.dotm`, `.dot`) opened from the file manager, the command line or the Finder starts a new, untitled document based on it, as Word's default verb for a template does (#3433). It opened the template itself, so Save (and AutoSave) wrote over it. The new document is attached to the template and Save asks where to write; a protected template asks for its password and still starts a new document. File › Open and a file dropped on the window still open a template itself, to edit it. +- 2026-11-05: Arabic letters join across a change of formatting (gap 16, #4093). Each run was shaped on its own, so a letter at a run's edge took the form of a word's start or end: a coloured letter stood apart, and a word edited with Track Changes on read with a space in it (ال ذكاء). Each run is now shaped knowing the text shown on either side, past hidden or deleted text left out of the view, as for Syriac, Mongolian and the other joining scripts. Readiness estimates unchanged. - 2026-10-28: Ink replay, eraser picking, lasso selection and arrow-candidate discovery reuse the paragraph prefixes they have already scanned (#3614). Page item order, stroke geometry and saved data retain their behavior. This narrows the stability work; ink import fidelity, native pen input and broader corpus qualification remain open. - 2026-10-29: Page drawing finds text-box warps without rescanning the paragraph for every box (#3614). Only requested offsets are indexed, and each paragraph walk stops after its last requested character. Original Linux discovery, complete display-output and CLI/MCP save/reopen controls; native Word comparison untested. Readiness estimates unchanged. - 2026-10-28: Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged. Existing mutation and protection checks retain their costs; this does not close the broader performance gap. @@ -825,6 +826,7 @@ MATH glyphs that reference the same construction now share its immutable variant > 2026-11-05 · Linux self-update ignores another program's AppImage variables (#5653); recorded in the progress log, readiness unchanged. > 2026-11-05 · A template opened from the file manager starts a new document instead of being edited and saved over (#3433); recorded in the progress log, readiness unchanged. +> 2026-11-05 · Arabic letters join across a change of formatting and past hidden or deleted text (#4093); recorded in the progress log, readiness unchanged. > > 2026-11-03 · Arabic dialogs read right to left (rtl::Table); recorded in the progress log, readiness unchanged. > diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index b99e03b8ff..7305f5af5b 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -21,7 +21,7 @@ pub use fontdb::{ pub use harfrust::Feature; use harfrust::{Direction, ShapeOptions, ShapePlan, ShapePlanKey, Shaper, Tag, UnicodeBuffer}; pub use kurbo::BezPath; -pub use script::{fallback_rank, is_emoji, needs_layout_tables, script_fallbacks}; +pub use script::{fallback_rank, is_emoji, joins_cursively, needs_layout_tables, script_fallbacks}; use skrifa::MetadataProvider; use skrifa::instance::Size; pub use ui_fallback::{ @@ -185,13 +185,34 @@ fn direction_runs(text: &str) -> Vec<(std::ops::Range, bool)> { /// order: right-to-left runs are shaped right to left, then their clusters put back in text /// order (each cluster's glyphs keep the shaper's order); line layout reorders them visually. pub fn shape(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(char) -> char) -> Vec { + shape_in(face, text, features, map, ShapeContext::default()) +} + +/// The text shown on either side of what is being shaped. Only a letter's form depends on it: the +/// letters of an Arabic word join across a change of formatting, so each piece has to know what +/// it continues (#4093). Nothing outside the shaped text is drawn or measured. +#[derive(Clone, Copy, Debug, Default)] +pub struct ShapeContext<'a> { + pub before: &'a str, + pub after: &'a str, +} + +impl<'a> ShapeContext<'a> { + /// This context for the part `r` of `text`: what's beyond `text` where `r` reaches its edge. + fn at_edges_of(self, text: &str, r: &std::ops::Range) -> ShapeContext<'a> { + ShapeContext { before: if r.start == 0 { self.before } else { "" }, after: if r.end == text.len() { self.after } else { "" } } + } +} + +/// [`shape`] of a piece of longer text (see [`ShapeContext`]). +pub fn shape_in(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(char) -> char, around: ShapeContext<'_>) -> Vec { if !text.chars().any(is_rtl) { - return shape_dir(face, text, features, &map, false); + return shape_dir(face, text, features, &map, false, around); } let mut out = Vec::with_capacity(text.len()); for (r, rtl) in direction_runs(text) { let Some(sub) = text.get(r.clone()) else { continue }; - let mut g = shape_run(face, sub, features, &map, rtl); + let mut g = shape_run_in(face, sub, features, &map, rtl, around.at_edges_of(text, &r)); for x in &mut g { x.cluster += r.start; } @@ -205,7 +226,19 @@ pub fn shape(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(cha /// mark positioning. Glyphs come back in logical order like [`shape`]: clusters in text order, /// each cluster's glyphs in the shaper's (visual) order. pub fn shape_run(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(char) -> char, rtl: bool) -> Vec { - let g = shape_dir(face, text, features, &map, rtl); + shape_run_in(face, text, features, map, rtl, ShapeContext::default()) +} + +/// [`shape_run`] of a piece of longer text (see [`ShapeContext`]). +pub fn shape_run_in( + face: &FontFace, + text: &str, + features: &[Feature], + map: impl Fn(char) -> char, + rtl: bool, + around: ShapeContext<'_>, +) -> Vec { + let g = shape_dir(face, text, features, &map, rtl, around); if !rtl { return g; } @@ -221,7 +254,14 @@ pub fn shape_run(face: &FontFace, text: &str, features: &[Feature], map: impl Fn groups.into_iter().flatten().collect() } -fn shape_dir(face: &FontFace, text: &str, features: &[Feature], map: &impl Fn(char) -> char, rtl: bool) -> Vec { +fn shape_dir( + face: &FontFace, + text: &str, + features: &[Feature], + map: &impl Fn(char) -> char, + rtl: bool, + around: ShapeContext<'_>, +) -> Vec { let mut out = Vec::with_capacity(text.len()); let shaped = face.hb().map(|hb| { let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build(); @@ -234,6 +274,10 @@ fn shape_dir(face: &FontFace, text: &str, features: &[Feature], map: &impl Fn(ch for (i, c) in sub.char_indices() { buf.add(map(c), (r.start + i) as u32); } + // After the text: adding to a buffer clears what follows it. + let around = around.at_edges_of(text, &r); + buf.set_pre_context(around.before); + buf.set_post_context(around.after); buf.guess_segment_properties(); buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight }); let plan = shape_plan(face, &shaper, &buf, features); @@ -386,7 +430,10 @@ mod tests { gb.glyph_infos().iter().zip(gb.glyph_positions()).map(|(i, p)| (i.glyph_id, i.cluster, p.x_advance, p.x_offset, p.y_offset)).collect() }; let kept = |face: &FontFace, text: &str, features: &[Feature], rtl: bool| -> Vec<(u32, u32, i32, i32, i32)> { - shape_dir(face, text, features, &|c| c, rtl).iter().map(|g| (g.gid, g.cluster as u32, g.x_advance, g.x_offset, g.y_offset)).collect() + shape_dir(face, text, features, &|c| c, rtl, ShapeContext::default()) + .iter() + .map(|g| (g.gid, g.cluster as u32, g.x_advance, g.x_offset, g.y_offset)) + .collect() }; let db = FontDb::global(); let faces = [db.face(DEFAULT_FAMILY, "Regular"), db.face(DEFAULT_FAMILY, "Italic"), db.face("Source Sans 3", "Regular")]; @@ -421,7 +468,8 @@ mod tests { for n in 0..3 * MAX_SHAPE_PLANS as u32 { let features = [feature_value(b"kern", n % 2), feature_value(b"liga", n)]; let rtl = n % 3 == 0; - let kept: Vec<(u32, i32)> = shape_dir(&face, "office", &features, &|c| c, rtl).iter().map(|g| (g.gid, g.x_advance)).collect(); + let kept: Vec<(u32, i32)> = + shape_dir(&face, "office", &features, &|c| c, rtl, ShapeContext::default()).iter().map(|g| (g.gid, g.x_advance)).collect(); assert_eq!(kept, fresh(&features, rtl), "{n}"); assert!(kept_shape_plans() <= MAX_SHAPE_PLANS, "{}", kept_shape_plans()); } diff --git a/crates/fonts/src/script.rs b/crates/fonts/src/script.rs index e97de89ce7..51c9d252c5 100644 --- a/crates/fonts/src/script.rs +++ b/crates/fonts/src/script.rs @@ -18,6 +18,28 @@ fn real_script(c: char) -> Option