M0: reconcile current main and classify double-space rewrites for egui 0.37

This commit is contained in:
skylar taylor-barrick committed 2026-10-25 16:47:30 +01:00
commit 42477bd806
23 files changed
+853 -61

No files matched your search

+7 -1
View File
@@ -4,7 +4,7 @@
**Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (progress log: egui 0.37 migration records the `craft-i18n.rev` catalog pin (9205108…; 27 languages) and the input-method handling; managed CI passed; native acceptance and native-speaker review remain open; previously trivial: progress log: justified lines ending in a manual line break reach the margin, #3924; previously trivial: progress log: the parity catalog counts Read Mode's View menu, catalog 504/520; previously trivial: progress log: AutoFormat As You Type turns `*bold*` and `_italic_` marks into formatting; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: progress log: Update Table keeps a two-column TOC from a localized Word in its columns, #3889; previously trivial: progress log: comment times in local time, #2322, #2337; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: progress log: AutoCorrect corrects a word typed after a no-break or ideographic space; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously minor: progress log: Spanish proofing, #3360, #3553; previously trivial: progress log: Insert › Table's grid previews the hovered table on the page, #3931; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progressLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (progress log: egui 0.37 migration records the `craft-i18n.rev` catalog pin (9205108…; 27 languages) and the input-method handling; managed CI passed; native acceptance and native-speaker review remain open; progress log: regex Replace expands capture groups, #4177; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: progress log: justified lines ending in a manual line break reach the margin, #3924; previously trivial: progress log: the parity catalog counts Read Mode's View menu, catalog 504/520; previously trivial: progress log: AutoFormat As You Type turns `*bold*` and `_italic_` marks into formatting; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: progress log: Update Table keeps a two-column TOC from a localized Word in its columns, #3889; previously trivial: progress log: comment times in local time, #2322, #2337; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: progress log: AutoCorrect corrects a word typed after a no-break or ideographic space; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously minor: progress log: Spanish proofing, #3360, #3553; previously trivial: progress log: Insert › Table's grid previews the hovered table on the page, #3931; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in WLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (English dialects in proofing, #1281; previously trivial: triple borders draw three lines, #1740; previously trivial: progress log: double-clicking a later page's footer no longer scrolls up, #2103; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
@@ -146,6 +146,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
- 2026-10-23: AutoFormat As You Type: `*Bold* and _italic_ as real formatting`. With the option on, `*word*` typed and finished becomes bold and `_word_` italic, without the marks. It is off by default (`tools.autocorrect {"boldItalic"}`).
- 2026-10-24: Parity catalog: Read Mode's View menu has its own rows. Column Width (`view.readColumnWidth`) and Page Color (`view.readPageColor`) count as live; Layout (`view.readLayout`) is listed as missing. Catalog 504/520 (96.9%).
- 2026-10-24: Find and Replace: with Regular expressions on, Replace with expands capture groups instead of inserting them as typed. `(\w+), (\w+)` replaced with `$2 $1` or `\2 \1` swaps the words, and `(?<y>\d{4})-` with `${y}/` names a group; `$0` is the whole match, `$$` a dollar sign, `\t` a tab and `\n` a paragraph mark that splits the paragraph, as `^p` does. Missing or out-of-range groups expand to nothing, and the `^` codes still work (#4177).
- 2026-10-23: DOCX: a document whose default font size is unset keeps its size when saved and reopened. The size was left out of the file, and the reader read the missing size as 10 pt while the document resolved it as 11 pt; the file now says 11 pt (`DEFAULT_FONT_SIZE`). Files from other programs without a size are read as before.
- 2026-10-23: Insert › Table: hovering the grid shows the table on the page where it would go, as Word's Live Preview does, and the preview follows the pointer from square to square. It is laid out on a copy of the document, so nothing changes, and Undo has nothing new, until a square is clicked (#3931).
- 2026-10-24: Exact line spacing fixes the floating-table contact boundary independently of paragraph-mark metrics (#3977). Both row selection and word skipping use it; original regressions cover oversized and undersized marks, contact and one-twip offsets in modes 11/12/14/15. Shape behavior is unchanged.
@@ -163,6 +164,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
- 2026-10-22: Spanish proofing (#3360, #3553): text in Spanish was never checked (it fell through to the languages without tools). Now Spain (`es-ES`), Mexico (`es-MX`), Latin America (`es-419` and the other countries of the Americas) and Uruguay (`es-UY`) each read their own installed dictionary, with fallbacks; suggestions restore accents first (`prueva` → `prueba`, `tambien` → `también`); Uruguayan Spanish accepts the `vos` forms, imperatives included (`vení`, `decime`, `decímelo`); repeated-word, spacing and missing `¿`/`¡` checks explain in Spanish; Review › Language, the status bar and File › Options › Proofing list the four varieties, and a Spanish interface writes new documents in the region's Spanish. No dictionary data bundled; parity estimates unchanged
- 2026-10-24: AutoCorrect: a word typed after a no-break space or an ideographic space is corrected as after a plain space (replacements, sentence capitals, ordinals and links). AutoCorrect left such a word as typed.
- 2026-10-23: Table of contents: Update Table on a TOC set in columns (here Word's own two-column TOC, written by Word in Portuguese) keeps it in its columns (#3889). The page numbers stand at the column's right edge rather than the page's, so entries no longer run over the next column and push its page numbers off the page; the TOC styles Word stores under translated ids (`Sumrio1` for `toc 1`) are read as the TOC styles, so the entries keep their font, indents and spacing; and no blank line tops the first column. Update, save and reopen keep it so. The same keeps a Portuguese Word's TOC formatted as its TOC styles say through Update Table (#3891).
- 2026-10-22: Markdown inline code replaces each line ending with a space before export, including code in lists and table cells. A following heading, list marker or HTML tag stays literal code (#3738).
- 2026-10-22: Headers and footers: while editing the header, a single click in the footer edits the footer with the caret where clicked (and the reverse, and another page's own header or footer). The click went to the header being edited and the view scrolled back up to it, so a double-click on the footer rarely landed unless both were on screen.
- 2026-10-24: MCP `type_text` with `paragraphs: true` is one command (#3973): it was a `text.newParagraph` and a `text.insert` per line, each snapshotting the whole document for Undo, so 10,000 lines took 2.8 s (doubling the lines cost 2.4–3.2×) and left an Undo step per line; now they take ~10 ms (linear) and one Undo takes them all back. A CRLF is one break, not two, and the breaks no longer run AutoCorrect (the per-line way capitalised every line but the last). Typed line breaks are the Enter key, so in a one-line content control, where Enter does nothing, they no longer split it into paragraphs.
- 2026-10-21: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459).
@@ -303,6 +305,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
| 2026-10-15 | A floating picture or shape in a table cell set to align centre or bottom no longer lands half its height above its line (#1887); it starts at the anchor line |
| 2026-10-18 | Picture Format › Size (#2573): width and height in the interface unit, and a size typed in any unit works, pixels too (`300 px` keeps the picture's proportions, #1901), applied once typed (Enter, Tab or a click elsewhere; Esc cancels) in one undo step. The group's launcher opens a Size dialog: height and width, scale of the original size (or of the size it opened with), Lock aspect ratio, the original size with its pixels, and Reset. `picture.size {"original": true, "scale"?}` sizes from the original; sizes past 4000 pt that keep the proportions shrink as a whole. |
| 2026-10-16 | Save Changes gives assistive technology a named alert dialog with the loss warning and any save failure; visual layout and save policy unchanged (#1015). Native screen-reader verification remains pending. |
| 2026-10-17 | `design.scaleFontSizes` (Design › Document Formatting): grow or shrink every style's font size at once, by points (`delta`) or percent, rounded to half points and clamped to 1–1638 pt; inherited sizes follow, runs are untouched, one undo step (#1475) |
| 2026-10-15 | The font family caches are bounded (4096 families for text, 1024 for math, names over 256 bytes aren't remembered), so a document naming thousands of missing fonts can't grow memory; equation fonts are looked up again after a font is added or removed instead of keeping the old choice (#1354) |
| 2026-10-15 | AutoCorrect recognizes `-->` as `→` (#2223), respecting replacement settings and undo without changing paragraph-start arrow bullets. |
| 2026-10-15 | Numbered headings (#1793): the Multilevel List gallery offers four lists linked to Heading 1–9 (1 / 1.1 / 1.1.1, Article I. / Section 1.01 / (a), I. / A. / 1., Chapter 1; `list.multilevelPreset`). The heading styles carry the numbers, so paragraphs already in a Heading style keep their style and level, any other selected paragraph (Normal, Quote, Title) becomes Heading 1 (checked in Word), a new Heading 2 is numbered 2.1 by itself and body text stays unnumbered; Numbering or typing `1. ` keeps a heading a heading |
@@ -642,6 +645,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
| 2026-10-24 | trivial | Highlight shortcut toggles the last chosen colour off when the whole selection has it; explicit colour commands remain setters (#4085). |
| 2026-10-24 | trivial | Progress log: AutoFormat As You Type turns `*bold*` and `_italic_` marks into formatting |
| 2026-10-24 | trivial | Progress log: the parity catalog counts Read Mode's View menu (Column Width, Page Color, Layout) |
| 2026-10-24 | trivial | Progress log: regex Replace expands capture groups (#4177) |
| 2026-10-23 | trivial | Progress log: interface translations from storytold/craft-i18n through the `craft-i18n` crate |
| 2026-10-23 | trivial | Progress log: comment times in local time (#2322, #2337) |
| 2026-10-22 | trivial | Progress log: mail merge record navigation and record ranges for Finish & Merge and Print Documents (#3028) |
@@ -657,6 +661,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
| 2026-10-20 | minor | Restrict Editing, three more gaps closed: the protection of a protected document only changes through the explicit stop, the session author can't change through `file.setAuthor` under protection, and the post-check compares the settings and the metadata as well as the text and the formatting |
| 2026-10-20 | minor | Restrict Editing: a file command that changes the open document is checked like any other — the privacy options, the properties and the inspector's removals are refused on a protected document, and restoring a stored version no longer lifts the protection or drops its password |
| 2026-10-17 | trivial | Progress log: disabled-control reasons translated (#2678) |
| 2026-10-22 | trivial | Markdown code spans normalize line endings before export, keeping block markers literal (#3738); format readiness unchanged |
| 2026-10-22 | trivial | Progress log: a click in the footer while editing the header moves there |
| 2026-10-18 | trivial | Explicit New/Open is not interrupted by startup recovery discovery; older copies stay available and Recover follows the interface theme. |
| 2026-10-17 | trivial | Progress log: Home › Underline drop-down (#2738) |
@@ -778,6 +783,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
| 2026-10-16 | trivial | Progress log: a text frame in the paragraph defaults frames nothing (#2360) |
| 2026-10-16 | trivial | Progress log: a text file saved over itself closes without asking (#2376) |
| 2026-10-15 | trivial | Progress log: recovery copies and .json files store pictures as base64 (#586 follow-up) |
| 2026-10-17 | trivial | Progress log: Scale Font Sizes command (#1475) |
| 2026-10-14 | trivial | Progress log: row table border exceptions |
| 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks |
| 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged |
+11 -4
View File
@@ -724,7 +724,8 @@ impl Reader<'_> {
if sc.fields.iter().any(|f| f.phase == Phase::Instr) {
return;
}
let c = match k.attr("w:type") {
let ty = k.attr("w:type").map(|t| t.trim().to_ascii_lowercase());
let c = match ty.as_deref() {
Some("page") => "\u{000C}",
Some("column") => "\u{000E}",
_ => "\n",
@@ -739,7 +740,13 @@ impl Reader<'_> {
"w:noBreakHyphen" => self.emit_text(sc, pb, "\u{2011}", props),
"w:softHyphen" => self.emit_text(sc, pb, "\u{00AD}", props),
"w:sym" => {
let ch = k.attr("w:char").and_then(|h| u32::from_str_radix(h.trim(), 16).ok()).and_then(char::from_u32).filter(|c| *c as u32 >= 0x20);
let h_str = k.attr("w:char").map(|h| {
let s = h.trim();
let s = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")).unwrap_or(s);
let s = s.strip_prefix("U+").or_else(|| s.strip_prefix("u+")).unwrap_or(s);
s.strip_prefix("#x").or_else(|| s.strip_prefix("#X")).unwrap_or(s)
});
let ch = h_str.and_then(|h| u32::from_str_radix(h, 16).ok()).and_then(char::from_u32).filter(|c| *c as u32 >= 0x20);
if let Some(ch) = ch {
let mut p = props.clone();
// The symbol's own font, kept apart from the run's (ECMA-376 §17.3.3.30).
@@ -783,7 +790,7 @@ impl Reader<'_> {
}
}
"w:footnoteReference" | "w:endnoteReference" => {
let foot = k.name == "w:footnoteReference";
let foot = k.local() == "footnoteReference";
let fid = k.attr("w:id").and_then(int).unwrap_or(i64::MIN);
let map = if foot { &self.footnotes } else { &self.endnotes };
if let Some(id) = map.get(&fid).copied() {
@@ -794,7 +801,7 @@ impl Reader<'_> {
}
// The note's own number at the start of its text: a reference to the note being read.
"w:footnoteRef" | "w:endnoteRef" => {
let kind = if k.name == "w:footnoteRef" { NoteKind::Footnote } else { NoteKind::Endnote };
let kind = if k.local() == "footnoteRef" { NoteKind::Footnote } else { NoteKind::Endnote };
if let Some((nk, id)) = self.current_note
&& nk == kind
{
+46
View File
@@ -48,6 +48,40 @@ fn read_body(body: &str) -> Document {
wordcraft_docx::read(&docx(body, &[], &[])).unwrap()
}
#[test]
fn repeated_style_ids_use_last() {
let styles = format!(
r#"<w:styles {W_NS}>
<w:style w:type="paragraph" w:styleId="Repeated"><w:name w:val="First"/>
<w:pPr><w:spacing w:after="240"/></w:pPr><w:rPr><w:b/><w:smallCaps/><w:sz w:val="28"/></w:rPr>
</w:style>
<w:style w:type="paragraph" w:styleId="Repeated"><w:name w:val="Last"/>
<w:pPr><w:spacing w:before="120"/></w:pPr><w:rPr><w:i/><w:caps/><w:sz w:val="20"/></w:rPr>
</w:style></w:styles>"#
);
let bytes = docx(
r#"<w:p><w:pPr><w:pStyle w:val="Repeated"/></w:pPr><w:r><w:t>Original style probe</w:t></w:r></w:p>"#,
&[("styles", "styles", "styles.xml")],
&[("word/styles.xml", &styles)],
);
let mut document = wordcraft_docx::read(&bytes).unwrap();
for round in 0..=2 {
let style = document.styles.get("Repeated").unwrap();
assert_eq!(style.name, "Last");
assert_eq!(style.chr.size, Some(10.0));
assert_eq!(style.chr.italic, Some(true));
assert_eq!(style.chr.bold, None);
assert_eq!(style.chr.caps, Some(true));
assert_eq!(style.chr.small_caps, None);
assert_eq!(style.para.space_before, Some(6.0));
assert_eq!(style.para.space_after, None);
assert_eq!(document.styles.styles.iter().filter(|style| style.id == "Repeated").count(), 1);
if round < 2 {
document = wordcraft_docx::read(&wordcraft_docx::write(&document).unwrap()).unwrap();
}
}
}
#[test]
fn paragraph_default_presence_survives_repeated_saves() {
// Omission leaves paragraph defaults application-defined (ISO 29500 pPrDefault).
@@ -339,6 +373,18 @@ fn pcl6_symbol_roundtrip() {
}
}
#[test]
fn sym_hex_prefixes_and_break_type_casing() {
let body = r##"
<w:p>
<w:r><w:sym w:font="Wingdings" w:char="0xF0FC"/><w:sym w:font="Symbol" w:char="U+F061"/><w:sym w:font="Webdings" w:char="#xF061"/></w:r>
<w:r><w:br w:type="Page"/><w:br w:type="Column"/><w:br w:type="textWrapping"/></w:r>
</w:p>"##;
let d = wordcraft_docx::read(&docx(body, &[], &[])).unwrap();
let p = paras(&d);
assert_eq!(p[0].plain_text(), "\u{F0FC}\u{F061}\u{F061}\u{C}\u{E}\n");
}
#[test]
fn alternate_content_choice_and_fallback() {
let body = r#"
+55 -6
View File
@@ -1,7 +1,7 @@
//! Design tab: themes, style sets, colours, fonts, paragraph spacing, watermark, page colour, borders.
use serde_json::{Value, json};
use wordcraft_doc::props::{Border, BorderStyle, Borders, LineSpacing, Rgb, TextColor};
use wordcraft_doc::props::{Border, BorderStyle, Borders, CharProps, LineSpacing, Rgb, TextColor};
use wordcraft_doc::{Watermark, styles};
use super::sel_result;
@@ -67,6 +67,8 @@ pub fn specs() -> Vec<CommandSpec> {
.params(r#"{"name": "default|basic|lines|shaded|casual|centered|minimalist|title"}"#),
CommandSpec::new("design.paragraphSpacing", "Paragraph Spacing", "Design › Document Formatting", para_spacing)
.params(r#"{"value": "default|none|compact|tight|open|relaxed|double"}"#),
CommandSpec::new("design.scaleFontSizes", "Scale Font Sizes", "Design › Document Formatting", scale_font_sizes)
.params(r#"{"delta": pt (-1638..1638, not 0) | "percent": number (-99..1000, not 0; 10 = +10%)} (exactly one)"#),
CommandSpec::new("design.watermark", "Watermark", "Design › Page Background", watermark)
.params(r#"{"text"?: string, "remove"?: bool, "diagonal"?: bool, "color"?: "RRGGBB"}"#),
CommandSpec::new("design.pageColor", "Page Color", "Design › Page Background", |s, v| {
@@ -169,11 +171,19 @@ fn style_set(s: &mut Session, v: &Value) -> CmdResult {
_ => {}
}
}
// Keep custom styles and fonts.
let customs: Vec<_> = s.doc.styles.styles.iter().filter(|x| !x.builtin).cloned().collect();
fresh.styles.extend(customs);
fresh.default_chr.font = s.doc.styles.default_chr.font.clone();
s.doc.styles = fresh;
// Overlay the set onto the document's sheet: every other style, the latent styles and the
// document defaults stay as they are (no set defines its own defaults).
for mut st in fresh.styles {
match s.doc.styles.styles.iter_mut().find(|x| x.id == st.id) {
Some(old) => {
st.default = old.default;
st.locked = old.locked;
st.builtin = old.builtin;
*old = st;
}
None => s.doc.styles.styles.push(st),
}
}
let (h, b) = (s.doc.settings.major_font.clone(), s.doc.settings.minor_font.clone());
set_fonts(s, &h, &b);
set_accent(s, accent);
@@ -196,6 +206,45 @@ fn para_spacing(s: &mut Session, v: &Value) -> CmdResult {
sel_result(s)
}
/// Grow or shrink every style's font size at once. Only explicit style sizes change (including
/// table style parts); styles that inherit one (via `based_on` or the defaults) follow, and runs
/// keep their own direct formatting.
fn scale_font_sizes(s: &mut Session, v: &Value) -> CmdResult {
let (delta, percent) = (p::f32(v, "delta"), p::f32(v, "percent"));
// A field that is present but not a number is an error, not "absent".
if (v.get("delta").is_some() && delta.is_none()) || (v.get("percent").is_some() && percent.is_none()) {
return Err(CmdError::Params("`delta` and `percent` must be numbers".into()));
}
if delta.is_some() == percent.is_some() {
return Err(CmdError::Params("give exactly one of `delta` (points) or `percent`".into()));
}
if delta.is_some_and(|d| !(-1638.0..=1638.0).contains(&d) || d == 0.0) || percent.is_some_and(|x| !(-99.0..=1000.0).contains(&x) || x == 0.0) {
return Err(CmdError::Params("`delta` must be non-zero within ±1638 pt and `percent` non-zero within -99..1000".into()));
}
let factor = 1.0 + percent.unwrap_or(0.0) / 100.0;
let scale = |size: f32| ((size * factor + delta.unwrap_or(0.0)).clamp(1.0, 1638.0) * 2.0).round() / 2.0;
let scale_chr = |c: &mut CharProps| {
c.size = c.size.map(scale);
c.size_cs = c.size_cs.map(scale);
};
let st = &mut s.doc.styles;
// An unset document default resolves to the fallback size; make it explicit so inheriting styles follow.
st.default_chr.size = Some(scale(st.default_chr.size.unwrap_or(wordcraft_doc::props::DEFAULT_FONT_SIZE)));
st.default_chr.size_cs = st.default_chr.size_cs.map(scale);
for style in &mut st.styles {
scale_chr(&mut style.chr);
if let Some(t) = style.table.as_mut() {
for c in [&mut t.header_chr, &mut t.band_chr, &mut t.first_col_chr, &mut t.last_col_chr, &mut t.total_chr, &mut t.col_band_chr] {
scale_chr(c);
}
for part in t.regions.values_mut() {
scale_chr(&mut part.chr);
}
}
}
sel_result(s)
}
fn watermark(s: &mut Session, v: &Value) -> CmdResult {
if p::bool(v, "remove").unwrap_or(false) {
s.doc.settings.watermark = None;
+5 -2
View File
@@ -142,7 +142,7 @@ pub fn specs() -> Vec<CommandSpec> {
.params(r#"{"tab"?: "find|replace|goto" (default replace)}"#)
.pure(),
CommandSpec::new("edit.replaceAll", "Replace All", "Home › Editing › Replace", replace_all)
.params(r#"{"text": string, "with": string (^p ^t ^& ^c …; \\1 with wildcards), …options of edit.find}"#),
.params(r#"{"text": string, "with": string (^p ^t ^& ^c …; \\1 with wildcards; with regex also $1 ${n} ${name} $0 $$ \\1 \\n (paragraph mark) \\t), …options of edit.find}"#),
CommandSpec::new("edit.goto", "Go To", "Home › Editing › Find", goto)
.key("Mod+Alt+G / F5")
.params(r#"{"page"?: n, "pageBy"?: int (pages forward, negative back, from the caret's page), "bookmark"?: string (selects its text), "paragraph"?: n}; none opens Find and Replace on its Go To tab"#)
@@ -459,7 +459,10 @@ pub(crate) fn search_full(s: &Session, story: StoryRef, with_rep: bool) -> Resul
.configure(meta::Config::new().nfa_size_limit(Some(1 << 20)))
.build(&c.pattern)
.map_err(|e| CmdError::Params(format!("bad pattern: {e}")))?;
let pieces = with_rep.then(|| find_pattern::parse_replace(&f.replace, &opts));
let pieces = with_rep.then(|| {
let index = |name: &str| re.group_info().to_index(regex_automata::PatternID::ZERO, name);
find_pattern::resolve_names(find_pattern::parse_replace(&f.replace, &opts), &index)
});
let paths = s.doc.para_paths(story);
// Paragraphs read as one text: consecutive siblings when the pattern spans paragraphs.
let mut chunks: Vec<std::ops::Range<usize>> = Vec::new();
+87 -3
View File
@@ -345,15 +345,20 @@ pub enum Piece {
Lit(String),
/// `^&`: the text found.
Found,
/// `\n` with wildcards: group `n`.
/// `\n` with wildcards, or `$n` / `${n}` / `\n` with a regex: group `n` (0 is the match).
Group(usize),
/// `${name}` / `$name` with a regex: a named group, resolved to a [`Piece::Group`] once the
/// pattern is built ([`resolve_names`]); one left unresolved expands to nothing.
Named(String),
/// `^c`: the clipboard.
Clip,
}
/// Read Replace with text: `^` codes (unless `literal`), and `\1`–`\9` with wildcards.
/// Read Replace with text: `^` codes (unless `literal`), `\1`–`\9` with wildcards, and with a
/// regex also `$n`, `${n}`, `${name}`, `$name`, `$$`, `\n` (paragraph mark), `\t` and `\\`.
pub fn parse_replace(with: &str, o: &Opts) -> Vec<Piece> {
if o.literal || o.regex {
// With a regex, `special: false` turns off only the `^` codes; group references still expand.
if o.literal && !o.regex {
return vec![Piece::Lit(with.to_string())];
}
let chars: Vec<char> = with.chars().collect();
@@ -367,6 +372,22 @@ pub fn parse_replace(with: &str, o: &Opts) -> Vec<Piece> {
let mut i = 0;
while let Some(&c) = chars.get(i) {
i += 1;
if o.regex && (c == '$' || c == '\\') {
match regex_ref(&chars, i, c) {
Some((Ref::Char(ch), used)) => {
cur.push(ch);
i += used;
}
Some((Ref::Group(pc), used)) => {
flush(&mut cur, &mut out);
out.extend(pc);
i += used;
}
// Not a reference or an escape: the character as written.
None => cur.push(c),
}
continue;
}
if c == '\\' && o.wildcards {
match chars.get(i) {
Some(d) if d.is_ascii_digit() && *d != '0' => {
@@ -384,6 +405,7 @@ pub fn parse_replace(with: &str, o: &Opts) -> Vec<Piece> {
}
}
if c == '^'
&& !o.literal
&& let Some(&n) = chars.get(i)
{
if n == '&' || n == 'c' {
@@ -409,6 +431,67 @@ pub fn parse_replace(with: &str, o: &Opts) -> Vec<Piece> {
out
}
/// What a `$` or `\` starts in regex Replace with text.
enum Ref {
/// An escape: `$$`, `\n`, `\t`, `\\`.
Char(char),
/// A group reference; `None` for a number too large to be a group (expands to nothing).
Group(Option<Piece>),
}
/// A group named by its number or name, as the `regex` crate reads it.
fn group_ref(name: &str) -> Option<Piece> {
if name.bytes().all(|b| b.is_ascii_digit()) { name.parse().ok().map(Piece::Group) } else { Some(Piece::Named(name.to_string())) }
}
/// Read a regex reference or escape after `c` (`$` or `\`) at `chars[i..]`: what it is and how
/// many characters it uses after `c`, or `None` when `c` is a plain character.
fn regex_ref(chars: &[char], i: usize, c: char) -> Option<(Ref, usize)> {
let next = *chars.get(i)?;
if c == '\\' {
return match next {
'n' => Some((Ref::Char(PARA_MARK), 1)),
't' => Some((Ref::Char('\t'), 1)),
// `\$` and `\^` keep a `$` or `^` as typed (no group, no `^` code).
'\\' | '$' | '^' => Some((Ref::Char(next), 1)),
'1'..='9' => Some((Ref::Group(next.to_digit(10).map(|d| Piece::Group(d as usize))), 1)),
_ => None,
};
}
if next == '$' {
return Some((Ref::Char('$'), 1));
}
let rest = chars.get(i..).unwrap_or_default();
if next == '{' {
// `${…}`: everything up to the closing brace; unclosed or empty, the `$` is literal.
let close = rest.iter().position(|&ch| ch == '}')?;
let name: String = rest.get(1..close)?.iter().collect();
if name.is_empty() {
return None;
}
return Some((Ref::Group(group_ref(&name)), close + 1));
}
// `$name`: the longest run of letters, digits and `_`.
let len = rest.iter().take_while(|ch| ch.is_ascii_alphanumeric() || **ch == '_').count();
if len == 0 {
return None;
}
let name: String = rest.get(..len)?.iter().collect();
Some((Ref::Group(group_ref(&name)), len))
}
/// Turn named group references into numbered ones with `index` (a name's group number);
/// an unknown name is dropped, so it expands to nothing.
pub fn resolve_names(pieces: Vec<Piece>, index: &dyn Fn(&str) -> Option<usize>) -> Vec<Piece> {
pieces
.into_iter()
.filter_map(|p| match p {
Piece::Named(n) => index(&n).map(Piece::Group),
p => Some(p),
})
.collect()
}
/// Searched text as it is inserted again: marks become paragraph breaks.
fn as_inserted(s: &str) -> String {
s.replace(SECT_MARK, "\r")
@@ -423,6 +506,7 @@ pub fn expand(pieces: &[Piece], found: &str, group: &dyn Fn(usize) -> Option<Str
Piece::Found => out.push_str(&as_inserted(found)),
Piece::Group(n) => out.push_str(&as_inserted(&group(*n).unwrap_or_default())),
Piece::Clip => out.push_str(&clip.replace("\r\n", "\r").replace('\n', "\r")),
Piece::Named(_) => {}
}
}
out
+27 -2
View File
@@ -97,13 +97,14 @@ fn typing_options(s: &mut Session, text: &str) {
}
/// Inserts text with the typing options and smart quotes, records the IME outcome, and for a
/// single trigger character runs list, AutoCorrect and dash AutoFormat as a separate undo step
/// single trigger character runs list, AutoCorrect, dash and double-space AutoFormat as a separate undo step
/// when they rewrite the paragraph.
fn insert(s: &mut Session, v: &Value) -> CmdResult {
let text = p::req_str(v, "text")?;
let raw = p::bool(v, "raw").unwrap_or(false);
typing_options(s, text);
let smart_quote = if raw { None } else { autoformat(s, text) };
let was_collapsed = s.sel.is_collapsed();
let effect = type_text_effect(s, smart_quote.as_deref().unwrap_or(text))?;
let mut outcome = crate::ImeEditOutcome {
classified: true,
@@ -130,9 +131,12 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult {
&& s.prefs.autocorrect.enabled
&& s.prefs.autocorrect.dashes
&& dash_autoformat(s).inspect_err(|_| s.autocap = None)?;
if text == " " && !listed && was_collapsed && s.prefs.autocorrect.enabled && s.prefs.autocorrect.double_space_period {
double_space_period(s)?;
}
// Compare the paragraph with the one exactly as typed. This is the actual effect of
// AutoCorrect (including formatting-only superscripts and links), not an undo label that
// may have been left by an earlier step.
// may have been left by an earlier step. It also covers the double-space period.
let rewritten = typed.as_ref().is_some_and(|(para, _)| s.doc.para_at(&f) != Some(para));
if listed || dashed || rewritten {
outcome.content_changed = true;
@@ -210,6 +214,27 @@ fn list_autoformat(s: &mut Session) -> Result<bool, CmdError> {
Ok(true)
}
/// "word" + two spaces → "word. ": the second Space turns the first into a period, after a
/// letter, a digit or a closing bracket or quote; not after other punctuation, at the start of a
/// paragraph, after a tab, or for a third space (the text before them ends in a period by then).
fn double_space_period(s: &mut Session) -> Result<(), CmdError> {
let f = s.sel.focus.clone();
let Some(para) = s.doc.para_at(&f) else { return Ok(()) };
let Some(before) = para.text.get(..f.off) else { return Ok(()) };
let Some(word) = before.strip_suffix(" ") else { return Ok(()) };
if !word.chars().next_back().is_some_and(|c| c.is_alphanumeric() || ")]}\"'\u{201D}\u{2019}\u{00BB}\u{203A}".contains(c)) {
return Ok(());
}
// The period takes the formatting of the space it replaces.
let at = word.len();
let p = s.doc.para_mut(f.story, &f.path)?;
let props = p.props_at(at + 1).clone().without_symbol();
p.delete(at, at + 1)?;
p.insert_text(at, ".", &props)?;
s.sel = Selection::caret(f);
Ok(())
}
/// "word -- word" → en dash, "word--word" → em dash (on the space after).
fn dash_autoformat(s: &mut Session) -> Result<bool, CmdError> {
let f = s.sel.focus.clone();
+7 -1
View File
@@ -114,6 +114,9 @@ pub struct AutoCorrectPrefs {
/// letter or digit makes it Heading 1 (Heading 2 or 3 after one or two leading tabs).
/// Off by default.
pub heading_styles: bool,
/// A second Space after a word, number or closing bracket or quote turns the first into a
/// period (macOS's "Add period with double-space"); off by default.
pub double_space_period: bool,
/// The user's entries: added, or replacing a built-in one.
pub entries: Vec<(String, String)>,
/// Built-in entries the user deleted.
@@ -152,6 +155,7 @@ impl Default for AutoCorrectPrefs {
border_lines: true,
tab_indent: false,
heading_styles: false,
double_space_period: false,
entries: Vec::new(),
removed: Vec::new(),
exceptions: Vec::new(),
@@ -344,6 +348,7 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult {
("borderLines", &mut ac.border_lines),
("tabIndent", &mut ac.tab_indent),
("headingStyles", &mut ac.heading_styles),
("doubleSpacePeriod", &mut ac.double_space_period),
("autoAddFirstLetter", &mut ac.auto_add_first_letter),
("autoAddInitialCaps", &mut ac.auto_add_initial_caps),
] {
@@ -420,6 +425,7 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult {
"borderLines": ac.border_lines,
"tabIndent": ac.tab_indent,
"headingStyles": ac.heading_styles,
"doubleSpacePeriod": ac.double_space_period,
"autoAddFirstLetter": ac.auto_add_first_letter,
"autoAddInitialCaps": ac.auto_add_initial_caps,
"entries": ac.list().into_iter().map(|(a, b, builtin)| json!({"from": a, "to": b, "builtIn": builtin})).collect::<Vec<_>>(),
@@ -462,7 +468,7 @@ pub fn specs() -> Vec<CommandSpec> {
.pure(),
CommandSpec::new("tools.autocorrect", "AutoCorrect Options", "File › Options › Proofing", autocorrect_options)
.params(
r#"{"enabled"?: bool (all of AutoCorrect), "replaceText"|"twoInitialCaps"|"capSentences"|"capCells"|"capDays"|"capsLock"|"smartQuotes"|"fractions"|"ordinals"|"dashes"|"links"|"bullets"|"numbering"|"borderLines"?: bool, "boldItalic"?: bool (*bold* and _italic_ with real formatting; default false), "tabIndent"?: bool (Set left- and first-indent with tabs and backspaces; default false), "headingStyles"?: bool (Built-in Heading styles; default false), "autoAddFirstLetter"|"autoAddInitialCaps"?: bool (Automatically add words to the exception lists when an automatic capital is undone; default true), "add"?: {"from": string, "to": string} (adds or replaces an entry), "delete"?: string (an entry's "from", built in or added), "addException"?: string, "deleteException"?: string, "addInitialCapsException"?: string (keeps TWo INitial CApitals, like "IDs"), "deleteInitialCapsException"?: string} → options, entries and exceptions"#,
r#"{"enabled"?: bool (all of AutoCorrect), "replaceText"|"twoInitialCaps"|"capSentences"|"capCells"|"capDays"|"capsLock"|"smartQuotes"|"fractions"|"ordinals"|"dashes"|"links"|"bullets"|"numbering"|"borderLines"?: bool, "boldItalic"?: bool (*bold* and _italic_ with real formatting; default false), "tabIndent"?: bool (Set left- and first-indent with tabs and backspaces; default false), "headingStyles"?: bool (Built-in Heading styles; default false), "doubleSpacePeriod"?: bool (Add a period with double-space; default false), "autoAddFirstLetter"|"autoAddInitialCaps"?: bool (Automatically add words to the exception lists when an automatic capital is undone; default true), "add"?: {"from": string, "to": string} (adds or replaces an entry), "delete"?: string (an entry's "from", built in or added), "addException"?: string, "deleteException"?: string, "addInitialCapsException"?: string (keeps TWo INitial CApitals, like "IDs"), "deleteInitialCapsException"?: string} → options, entries and exceptions"#,
)
.pure(),
CommandSpec::new("tools.customizeKeyboard", "Customize Keyboard", "File › Options", customize_keyboard)
+188
View File
@@ -892,6 +892,72 @@ fn find_replace_wildcards() {
assert_eq!(run(&mut s, "edit.find", json!({"text": "eg", "matchSuffix": false, "ignorePunct": true}))["count"], 2);
}
#[test]
fn regex_replace_expands_groups() {
// #4177: with Regular expressions on, Replace with expands capture groups.
let rep = |text: &str, q: &str, with: &str| rep_mode(text, q, with, json!({"regex": true}));
let names = "Smith, John and Doe, Jane";
// The issue's cases: `\n` and `$n` swap words; `${name}` names a group.
assert_eq!(rep(names, r"(\w+), (\w+)", r"\2 \1"), "John Smith and Jane Doe");
assert_eq!(rep(names, r"(\w+), (\w+)", "$2 $1"), "John Smith and Jane Doe");
assert_eq!(rep("2024-05", r"(?<y>\d{4})-", "${y}/"), "2024/05");
// Braced numbers, `$0` / `${0}` (whole match), `$$` (a dollar sign).
assert_eq!(rep("ab", "(a)(b)", "${2}${1}"), "ba");
assert_eq!(rep("ab", "a", "[$0|${0}]"), "[a|a]b");
assert_eq!(rep("5", r"(\d)", "$$$1"), "$5");
// `$name` takes the longest run of letters, digits and `_` (the regex crate's rule).
assert_eq!(rep("ab", "(?<x>a)", "$x_"), "b");
assert_eq!(rep("ab", "(?<x>a)", "${x}_"), "a_b");
assert_eq!(rep("ab", "(a)", "$1a"), "b");
// Missing, unknown and out-of-range groups expand to nothing.
assert_eq!(rep("ab", "(a)", "<$2>"), "<>b");
assert_eq!(rep("ab", "(a)", r"<\9>"), "<>b");
assert_eq!(rep("ab", "(a)", "<${nope}>"), "<>b");
assert_eq!(rep("ab", "(a)", "<$99999999999999999999999>"), "<>b");
assert_eq!(rep("ab", "(a)|(z)", "<$2>"), "<>b");
// A `$` that starts no group, and an unclosed brace, stay as written.
assert_eq!(rep("ab", "a", "$ ${x"), "$ ${xb");
// `\t` a tab, `\\` a backslash; other escapes stay as written.
assert_eq!(rep("ab", "a", r"\t\\\q"), "\t\\\\qb");
// Word's `^` codes still work.
assert_eq!(rep("ab", "a", "^&^t^&"), "a\tab");
// `\$` and `\^` keep the character as typed (CodeRabbit on #4177).
assert_eq!(rep("ab", "(a)", r"\$1\^p"), "$1^pb");
// `special: false` turns off the `^` codes only; groups still expand (CodeRabbit on #4177).
assert_eq!(rep_mode("ab", "(a)", "$1^t", json!({"regex": true, "special": false})), "a^tb");
// `\n` is a paragraph mark: it splits the paragraph, as `^p` does, in one undo step.
let mut s = s();
run(&mut s, "document.setText", json!({"text": "one, two"}));
assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": r"(\w+), (\w+)", "with": r"$1\n$2", "regex": true}))["replaced"], 1);
assert_eq!(text(&s), "one\ntwo");
assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 2);
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s), "one, two");
run(&mut s, "document.setText", json!({"text": "a-b"}));
run(&mut s, "edit.replaceAll", json!({"text": "-", "with": "^p", "regex": true}));
assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 2);
// Plain and wildcard Replace are unchanged: `$1` is literal there.
assert_eq!(rep_mode("ab", "a", "$1", json!({})), "$1b");
assert_eq!(rep_mode("ab", "(a)", "$1\\1", json!({"wildcards": true})), "$1ab");
// Hostile Replace text never panics.
run(&mut s, "document.setText", json!({"text": "ab ab"}));
for with in ["$", "${", "${}", "${0", "$\u{e9}", "\\", "${\u{1F600}}", "$18446744073709551616", "${-1}", "$$$", "\\0\\n\\", "^"] {
let _ = s.run("edit.replaceAll", &json!({"text": "(a)(?<n>b)?", "with": with, "regex": true}));
}
}
/// The body text after Replace All of `q` with `with` in `text`, matching case, with `extra` options.
fn rep_mode(text: &str, q: &str, with: &str, extra: serde_json::Value) -> String {
let mut s = s();
run(&mut s, "document.setText", json!({"text": text}));
let mut v = json!({"text": q, "with": with, "matchCase": true});
if let (Some(o), Some(e)) = (v.as_object_mut(), extra.as_object()) {
o.extend(e.clone());
}
run(&mut s, "edit.replaceAll", v);
self::text(&s)
}
#[test]
fn hostile_find_patterns_are_bounded() {
let mut s = s();
@@ -10608,4 +10674,126 @@ fn text_insert_marks_automatic_rewrites_as_transformed() {
run(&mut session, "text.insert", json!({"text": " "}));
assert!(session.ime_edit_outcome().transformed, "dash AutoFormat: {:?}", session.ime_edit_outcome());
assert_eq!(text(&session), "A\u{2014}b ");
let mut session = s();
run(&mut session, "tools.autocorrect", json!({"doubleSpacePeriod": true}));
for c in "Hello ".chars() {
run(&mut session, "text.insert", json!({"text": c.to_string()}));
}
let outcome = session.ime_edit_outcome();
assert!(outcome.transformed && outcome.content_changed && outcome.state_changed, "double-space period: {outcome:?}");
assert_eq!(text(&session), "Hello. ");
run(&mut session, "edit.undo", json!({}));
assert_eq!(text(&session), "Hello ", "undo restores the spaces in one step");
}
#[test]
fn style_set_keeps_imported_styles_latent_styles_and_defaults() {
use wordcraft_doc::props::{Rgb, TextColor};
use wordcraft_doc::styles::{Style, StyleKind};
for name in ["basic", "lines", "shaded", "casual", "centered", "minimalist", "title"] {
let mut s = s();
let mut body = s.doc.styles.get("Normal").unwrap().clone();
body.id = "BodyText".into();
body.name = "Body Text".into();
body.builtin = true;
body.based_on = Some("Normal".into());
body.chr.size = Some(20.0);
body.chr.color = Some(TextColor::Rgb(Rgb(0xFF, 0, 0)));
let grid = Style { id: "GridTable4".into(), name: "Grid Table 4".into(), kind: StyleKind::Table, builtin: true, ..Default::default() };
s.doc.styles.styles.push(body.clone());
s.doc.styles.styles.push(grid.clone());
s.doc.styles.latent_styles = Some("<w:latentStyles/>".into());
s.doc.styles.default_chr.lang = Some("de-DE".into());
s.doc.styles.default_chr.size = Some(11.0);
run(&mut s, "design.styleSet", json!({ "name": name }));
assert_eq!(s.doc.styles.get("BodyText"), Some(&body), "{name}: BodyText");
assert_eq!(s.doc.styles.get("GridTable4"), Some(&grid), "{name}: GridTable4");
assert_eq!(s.doc.styles.latent_styles.as_deref(), Some("<w:latentStyles/>"), "{name}");
assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("de-DE"), "{name}");
assert_eq!(s.doc.styles.default_chr.size, Some(11.0), "{name}");
assert_eq!(s.doc.styles.styles.iter().filter(|x| x.id == "Heading1").count(), 1, "{name}");
let saved = crate::io::save_bytes("x.docx", &s.doc).unwrap();
let back = crate::io::open_bytes("x.docx", &saved).unwrap();
assert!(back.styles.get("BodyText").is_some(), "{name}: BodyText after round trip");
}
let mut s = s();
run(&mut s, "design.styleSet", json!({ "name": "title" }));
assert_eq!(s.doc.styles.get("Title").unwrap().chr.size, Some(36.0));
}
fn style_size(s: &Session, id: &str) -> f32 {
s.doc.styles.resolve_char(Some(id), &Default::default()).size
}
#[test]
fn scale_font_sizes_adds_points_to_every_style() {
let mut s = Session::new(crate::sample::sample_document());
let ids: Vec<String> = s.doc.styles.styles.iter().map(|x| x.id.clone()).collect();
let before: Vec<f32> = ids.iter().map(|i| style_size(&s, i)).collect();
let base = s.doc.styles.resolve_char(None, &Default::default()).size;
let runs = serde_json::to_string(&s.doc.body).unwrap();
run(&mut s, "design.scaleFontSizes", json!({"delta": 2}));
// Every style (explicit or inherited) grows by exactly 2, once.
for (i, b) in ids.iter().zip(&before) {
assert_eq!(style_size(&s, i), b + 2.0, "{i}");
}
assert_eq!(s.doc.styles.resolve_char(None, &Default::default()).size, base + 2.0);
assert_eq!(serde_json::to_string(&s.doc.body).unwrap(), runs, "runs untouched");
run(&mut s, "edit.undo", json!({}));
for (i, b) in ids.iter().zip(&before) {
assert_eq!(style_size(&s, i), *b, "undo {i}");
}
}
#[test]
fn scale_font_sizes_percent_and_clamp_and_errors() {
let mut s = Session::new(crate::sample::sample_document());
let n = style_size(&s, "Normal");
run(&mut s, "design.scaleFontSizes", json!({"percent": 10}));
assert_eq!(style_size(&s, "Normal"), (n * 1.1 * 2.0).round() / 2.0);
run(&mut s, "design.scaleFontSizes", json!({"delta": -500}));
assert_eq!(style_size(&s, "Normal"), 1.0);
for bad in [
json!({}),
json!({"delta": 1e9}),
json!({"percent": -100}),
json!({"percent": 1e9}),
json!({"delta": "x"}),
json!({"delta": 0}),
json!({"percent": 0}),
json!({"delta": 1, "percent": 10}),
json!({"delta": 2, "percent": "x"}),
json!({"percent": 10, "delta": null}),
] {
assert!(s.run("design.scaleFontSizes", &bad).is_err(), "{bad}");
}
}
#[test]
fn scale_font_sizes_reaches_char_table_and_cs_sizes_and_unset_default() {
use wordcraft_doc::props::CharProps;
use wordcraft_doc::{Style, StyleKind, styles::TablePart};
let mut s = s();
s.doc.styles.default_chr.size = None;
s.doc.styles.default_chr.size_cs = Some(10.0);
let mut ch = Style { id: "Big".into(), name: "Big".into(), kind: StyleKind::Character, ..Default::default() };
ch.chr.size = Some(20.0);
ch.chr.size_cs = Some(22.0);
let mut tb = Style { id: "Grid".into(), name: "Grid".into(), kind: StyleKind::Table, ..Default::default() };
let mut parts = wordcraft_doc::styles::TableStyleParts::default();
parts.header_chr.size = Some(14.0);
parts.regions.entry(TablePart::Band2Horz).or_default().chr.size = Some(8.0);
tb.table = Some(parts);
s.doc.styles.styles.extend([ch, tb]);
run(&mut s, "design.scaleFontSizes", json!({"delta": 2}));
let st = &s.doc.styles;
assert_eq!(st.default_chr.size, Some(13.0), "unset default is 11 pt, made explicit");
assert_eq!(st.default_chr.size_cs, Some(12.0));
let big = CharProps { style: Some("Big".into()), ..Default::default() };
assert_eq!(st.resolve_char(None, &big).size, 22.0);
assert_eq!(st.resolve_char(None, &big).size_cs, 24.0);
let grid = st.styles.iter().find(|x| x.id == "Grid").and_then(|x| x.table.as_ref()).unwrap();
assert_eq!(grid.header_chr.size, Some(16.0));
assert_eq!(grid.regions.get(&TablePart::Band2Horz).unwrap().chr.size, Some(10.0));
}
+45
View File
@@ -3341,3 +3341,48 @@ fn tab_with_a_whole_paragraph_selected_undoes_indent_and_selection_together() {
assert_eq!(s.sel, selected, "and restores the selection the indent was applied to");
assert_eq!(paras(&s), vec![para("Hello world", None)]);
}
/// #2848: "Add a period with double-space" (an AutoFormat As You Type switch, off by default):
/// a second Space after a word, a number or a closing bracket or quote turns the first into a
/// period. Undo puts the two spaces back; other punctuation, a lone space and a run of three
/// spaces are left as typed.
#[test]
fn double_space_adds_a_period_when_asked() {
let with_option = |keys: &str| {
let mut s = s();
run(&mut s, "tools.autocorrect", json!({"doubleSpacePeriod": true}));
typ(&mut s, keys);
s
};
let text = |s: &Session| s.doc.plain_text(StoryRef::Body);
// Off unless asked for.
assert_eq!(text(&typed("Hello world")), "Hello world");
assert!(!s().prefs.autocorrect.double_space_period);
// On: the second space makes the period, and the next sentence starts with a capital.
assert_eq!(text(&with_option("Hello world")), "Hello. world");
assert_eq!(text(&with_option("Hello world ")), "Hello. World ");
assert_eq!(text(&with_option("See page 5 Then")), "See page 5. Then");
assert_eq!(text(&with_option("(it is done) Next")), "(it is done). Next");
assert_eq!(text(&with_option("He said \"stop\" Next")), "He said “stop”. Next");
// Undo brings the two spaces back, in one step.
let mut t = with_option("Hello ");
assert_eq!(text(&t), "Hello. ");
run(&mut t, "edit.undo", json!({}));
assert_eq!(text(&t), "Hello ");
// Left alone: other punctuation before the spaces, a lone space, spaces at the start of a
// paragraph or after a tab, and a third space.
for keys in ["Hello. x", "Hello, x", "Hello? x", "Hello; x", "Hello: x", "Hello x", " x", "\n x", "a\t x"] {
let want = typed(keys);
assert_eq!(text(&with_option(keys)), text(&want), "{keys:?}");
}
assert_eq!(text(&with_option("Hello x")), "Hello. x");
// A selection typed over, and the master switch.
let mut t = with_option("Hello world");
run(&mut t, "select.all", json!({}));
run(&mut t, "text.insert", json!({"text": " "}));
assert_eq!(text(&t), " ");
let mut t = s();
run(&mut t, "tools.autocorrect", json!({"doubleSpacePeriod": true, "enabled": false}));
typ(&mut t, "Hello world");
assert_eq!(text(&t), "Hello world");
}
+102 -2
View File
@@ -5,7 +5,7 @@
//! bottom-border paragraph), bullet and ordered lists (nested by indentation), GFM pipe tables,
//! and inlines: emphasis/strong (the CommonMark delimiter algorithm), `~~strike~~`, code spans,
//! inline links, autolinks, images (`data:` URIs become pictures, others their alt text),
//! backslash escapes, entities, `<br>` and `<a id>` anchors.
//! backslash escapes, entities, HTML comments (`<!-- ... -->`), `<br>` and `<a id>` anchors.
//!
//! Export writes the same constructs; pictures become `![alt](media-name)` placeholders.
@@ -351,6 +351,7 @@ fn interrupts(l: &str) -> bool {
|| thematic_break(t)
|| fence(t).is_some()
|| t.starts_with('>')
|| t.starts_with("<!--")
|| list_marker(t).is_some_and(|m| (!m.ordered || m.start == 1) && !is_blank(t.get(m.len..).unwrap_or("")))
}
@@ -360,7 +361,11 @@ fn flush_para(lines: &mut Vec<String>, out: &mut Vec<FBlock>, depth: usize, refs
}
let text = join_lines(lines);
lines.clear();
let p = Para { inlines: parse_inlines(&text, &Fmt::default(), depth, refs), ..Default::default() };
let inlines = parse_inlines(&text, &Fmt::default(), depth, refs);
if inlines.is_empty() {
return;
}
let p = Para { inlines, ..Default::default() };
out.push(FBlock::Para(p));
}
@@ -470,6 +475,16 @@ fn parse_blocks(lines: &[String], depth: usize, level: u8, refs: &Refs) -> Vec<F
}
continue;
}
if t.starts_with("<!--") {
flush_para(&mut para, &mut out, depth, refs);
while let Some(l) = lines.get(i) {
i += 1;
if l.contains("-->") {
break;
}
}
continue;
}
if t.starts_with('>') {
flush_para(&mut para, &mut out, depth, refs);
let mut inner: Vec<String> = Vec::new();
@@ -1016,6 +1031,12 @@ fn tokenize(s: &str, base: &Fmt, depth: usize, refs: &Refs) -> Vec<Node> {
}
}
b'<' => {
if let Some(pos) = s.get(i..).and_then(|r| r.strip_prefix("<!--")).and_then(|r| r.find("-->")) {
flush!();
i += 4 + pos + 3;
text_start = i;
continue;
}
let rest = s.get(i + 1..).unwrap_or("");
let end = within(rest, MAX_TAG).find('>');
let inner = end.and_then(|e| rest.get(..e)).unwrap_or("");
@@ -1281,6 +1302,7 @@ fn escape_lines(body: String) -> String {
}
fn code_span(t: &str) -> String {
let t = t.replace("\r\n", " ").replace(['\r', '\n'], " ");
let mut longest = 0;
let mut cur = 0;
for c in t.chars() {
@@ -1884,6 +1906,75 @@ mod tests {
assert_eq!(exported, "`cmd | grep`");
}
#[test]
fn code_span_line_endings_stay_literal_on_export() {
let cases = [
("a\n# b", "a # b"),
("a\n<div>b</div>", "a <div>b</div>"),
("a\n- b", "a - b"),
("a\n```", "a ```"),
("a\r# b", "a # b"),
("a\r\n# b", "a # b"),
("a\n\n> b", "a > b"),
("\na\n", " a "),
("`a\n# b`", "`a # b`"),
("cmd | grep", "cmd | grep"),
];
let shapes = [
(Kind::Normal, None),
(Kind::Normal, Some(ListInfo { ordered: false, level: 0, start: 1 })),
(Kind::Code, Some(ListInfo { ordered: true, level: 0, start: 1 })),
];
for (source, expected) in cases {
for (kind, list) in shapes {
let mut p = Para::new(kind);
p.list = list;
p.push_text(source, &Fmt { code: true, ..Default::default() });
let mut doc = model::to_doc(&model::Flow { blocks: vec![FBlock::Para(p)], ..Default::default() });
// The flow builder removes CR controls; keep the native document's raw input.
let p = doc.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap();
p.text = source.to_string();
p.runs[0].len = source.len();
let bytes = crate::export("md", &doc).unwrap().unwrap();
let md = String::from_utf8(bytes).unwrap();
assert_eq!(md.trim_end().lines().count(), 1, "{source:?} {kind:?} {list:?} -> {md:?}");
assert!(!md.contains('\r'), "{md:?}");
let back = crate::import("md", md.as_bytes()).unwrap().unwrap();
let flow = model::from_doc(&back);
let [FBlock::Para(q)] = flow.blocks.as_slice() else { panic!("{md:?}") };
assert_eq!(q.text(), expected, "{source:?} {kind:?} {list:?} -> {md:?}");
assert_eq!(q.list, list, "{md:?}");
assert!(q.inlines.iter().all(|i| matches!(i, Inline::Text(_, f) if f.code)), "{md:?}");
}
}
let fenced = "```\na\n# b\n```";
assert_eq!(export(&import(fenced)).trim_end(), fenced);
}
#[test]
fn code_span_line_endings_in_table_cells_stay_literal() {
for ending in ["\n", "\r", "\r\n"] {
let source = format!("a{ending}# b|c");
let mut p = Para::default();
p.push_text(&source, &Fmt { code: true, ..Default::default() });
let cell = Cell { blocks: vec![FBlock::Para(p)], ..Default::default() };
let table = FTable { rows: vec![vec![cell, Cell::default()], vec![Cell::default(), Cell::default()]], ..Default::default() };
let mut doc = model::to_doc(&model::Flow { blocks: vec![FBlock::Table(table)], ..Default::default() });
let p = doc.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path(vec![0, 0, 0, 0])).unwrap();
p.text = source.clone();
p.runs[0].len = source.len();
let md = export(&doc);
let back = model::from_doc(&import(&md));
let [FBlock::Table(table), ..] = back.blocks.as_slice() else { panic!("{md:?}") };
assert!(back.blocks.iter().skip(1).all(|b| matches!(b, FBlock::Para(p) if p.text().is_empty())), "{md:?}");
assert_eq!(table.rows.len(), 2, "{md:?}");
assert_eq!(table.rows[0].len(), 2, "{md:?}");
let [FBlock::Para(p)] = table.rows[0][0].blocks.as_slice() else { panic!("{md:?}") };
assert_eq!(p.text(), "a # b|c", "{md:?}");
assert!(p.inlines.iter().all(|i| matches!(i, Inline::Text(_, f) if f.code)), "{md:?}");
}
}
#[test]
fn table_code_pipe_export_import_round_trip() {
let mut table = FTable::default();
@@ -2052,4 +2143,13 @@ mod tests {
assert_eq!(p3.text(), "one item");
assert!(p3.list.unwrap().ordered);
}
#[test]
fn html_comments_are_ignored_in_blocks_and_inlines() {
let md = "<!-- block comment -->\n\nHello <!-- inline comment -->world!\n\n<!--\nmultiline\ncomment\n-->\n\nAfter comment.";
let flow = parse(md);
assert_eq!(flow.blocks.len(), 2);
let texts: Vec<String> = flow.blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p.text()) } else { None }).collect();
assert_eq!(texts, ["Hello world!", "After comment."]);
}
}
+57 -4
View File
@@ -2530,6 +2530,8 @@ fn read_meta(xml: &[u8]) -> Meta {
let mut m = Meta::default();
let mut cur: Option<String> = None;
let mut keywords: Vec<String> = Vec::new();
let mut creator = String::new();
let mut mod_date = String::new();
events(xml, |ev| match ev {
Ev::Start(q, _) => cur = Some(q),
Ev::End(_) => cur = None,
@@ -2538,16 +2540,32 @@ fn read_meta(xml: &[u8]) -> Meta {
Some("dc:subject") => m.subject.push_str(&t),
Some("dc:description") => m.description.push_str(&t),
Some("meta:initial-creator") => m.author.push_str(&t),
Some("dc:creator") if m.author.is_empty() => m.author.push_str(&t),
Some("dc:creator") => creator.push_str(&t),
Some("meta:keyword") => keywords.push(t),
Some("meta:creation-date") => m.created.push_str(&t),
Some("dc:date") => mod_date.push_str(&t),
_ => {}
},
});
if m.author.is_empty() {
m.author = creator;
}
if m.created.is_empty() {
m.created = mod_date;
}
m.keywords = keywords.join(", ");
if !m.created.is_empty() && !m.created.ends_with('Z') && !m.created.contains('+') {
let base = m.created.split('.').next().unwrap_or("").to_string();
m.created = format!("{base}Z");
let created = m.created.trim();
if !created.is_empty() {
let has_tz =
created.ends_with(['Z', 'z']) || created.contains('+') || created.split_once('T').map(|(_, time)| time.contains('-')).unwrap_or(false);
if !has_tz {
let base = created.split('.').next().unwrap_or("");
m.created = format!("{base}Z");
} else {
m.created = created.to_string();
}
} else {
m.created.clear();
}
m
}
@@ -3830,4 +3848,39 @@ mod tests {
assert!(!fmt(ps[1]).underline, "a line type does not override a line style of none");
assert!(!fmt(ps[1]).strike);
}
#[test]
fn meta_creation_date_timezones_and_fallbacks() {
let xml_neg = br#"<office:document-meta><office:meta>
<meta:creation-date>2024-03-05T13:45:00-05:00</meta:creation-date>
<dc:creator>Modifier</dc:creator>
<meta:initial-creator>Original Author</meta:initial-creator>
</office:meta></office:document-meta>"#;
let m = read_meta(xml_neg);
assert_eq!(m.created, "2024-03-05T13:45:00-05:00");
assert_eq!(m.author, "Original Author");
let xml_neg_frac = br#"<office:document-meta><office:meta>
<meta:creation-date>2024-03-05T13:45:00.500-05:00</meta:creation-date>
</office:meta></office:document-meta>"#;
assert_eq!(read_meta(xml_neg_frac).created, "2024-03-05T13:45:00.500-05:00");
let xml_local_frac = br#"<office:document-meta><office:meta>
<meta:creation-date>2024-03-05T13:45:00.123</meta:creation-date>
</office:meta></office:document-meta>"#;
assert_eq!(read_meta(xml_local_frac).created, "2024-03-05T13:45:00Z");
let xml_z_lower = br#"<office:document-meta><office:meta>
<meta:creation-date>2024-03-05T13:45:00z</meta:creation-date>
</office:meta></office:document-meta>"#;
assert_eq!(read_meta(xml_z_lower).created, "2024-03-05T13:45:00z");
let xml_dc_date_fallback = br#"<office:document-meta><office:meta>
<dc:date>2024-03-05T13:45:00Z</dc:date>
<dc:creator>Solo Creator</dc:creator>
</office:meta></office:document-meta>"#;
let m2 = read_meta(xml_dc_date_fallback);
assert_eq!(m2.created, "2024-03-05T13:45:00Z");
assert_eq!(m2.author, "Solo Creator");
}
}
+13 -3
View File
@@ -2128,9 +2128,9 @@ impl Reader {
"cell" => self.end_cell(),
"nestcell" => self.end_para(true),
"row" => self.end_row(),
"line" => self.ch('\n'),
"line" | "softline" => self.ch('\n'),
"tab" => self.ch('\t'),
"page" => self.ch('\u{000C}'),
"page" | "softpage" => self.ch('\u{000C}'),
"column" => self.ch('\u{000E}'),
"emdash" => self.ch('—'),
"endash" => self.ch('–'),
@@ -2144,6 +2144,8 @@ impl Reader {
"qmspace" => self.ch('\u{2005}'),
"zwj" => self.ch('\u{200D}'),
"zwnj" => self.ch('\u{200C}'),
"zwbo" => self.ch('\u{200B}'),
"zwnbo" => self.ch('\u{2060}'),
"u" => {
let u = (pv as i64).rem_euclid(65_536) as u16;
if (0xD800..0xDC00).contains(&u) {
@@ -2727,7 +2729,7 @@ fn parse_full(bytes: &[u8]) -> Result<Parsed, String> {
i += 2;
continue;
}
b'~' => {
b'~' | b' ' => {
r.ch('\u{00A0}');
i += 2;
}
@@ -4397,4 +4399,12 @@ mod tests {
assert!(exported.contains("\\clvertalc"));
assert!(exported.contains("\\clvertalb"));
}
#[test]
fn rtf_backslash_space_and_special_breaks() {
let rtf = br"{\rtf1\ansi A\ B\~C\zwbo D\softline E\softpage F\par}";
let doc = import(rtf).unwrap();
let p = doc.body.first().unwrap().as_para().unwrap();
assert_eq!(p.text, "A\u{00A0}B\u{00A0}C\u{200B}D\nE\u{000C}F");
}
}
+4 -10
View File
@@ -58,9 +58,9 @@ pub enum ViewMode {
/// How digits look on screen (Word › File › Options › Advanced › Numeral).
///
/// Display only: the document text keeps the typed codepoints, so find, spelling, copying and
/// the saved file all see the original digits. Extended Arabic-Indic digits (U+06F0–U+06F9,
/// Persian/Urdu ۰۱۲۳…) always show as typed; only ASCII and Arabic-Indic (U+0660–U+0669) digits
/// are substituted.
/// the saved file all see the original digits. Only ASCII digits are substituted: Arabic-Indic
/// (U+0660–U+0669) and Extended Arabic-Indic digits (U+06F0–U+06F9, Persian/Urdu ۰۱۲۳…) always
/// show as typed, so fonts that ligate them (e.g. the end-of-ayah mark U+06DD) keep working.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum NumeralMode {
@@ -121,13 +121,7 @@ impl NumeralMode {
NumeralMode::Context => rtl,
NumeralMode::System => false,
};
if hindi {
if c.is_ascii_digit() { char::from_u32(0x660 + (c as u32 - '0' as u32)).unwrap_or(c) } else { c }
} else if ('\u{660}'..='\u{669}').contains(&c) {
(b'0' + (c as u32 - 0x660) as u8) as char
} else {
c
}
if hindi { if c.is_ascii_digit() { char::from_u32(0x660 + (c as u32 - '0' as u32)).unwrap_or(c) } else { c } } else { c }
}
}
+29 -18
View File
@@ -49,18 +49,18 @@ fn parse_accepts_the_four_modes_any_case() {
}
#[test]
fn map_substitutes_digits_both_ways() {
fn map_substitutes_ascii_digits_only() {
for d in '0'..='9' {
let hindi = char::from_u32(0x660 + (d as u32 - '0' as u32)).unwrap();
assert_eq!(NumeralMode::Hindi.map(d, false), hindi);
assert_eq!(NumeralMode::Hindi.map(d, true), hindi, "Hindi mode ignores direction");
assert_eq!(NumeralMode::Arabic.map(hindi, false), d);
assert_eq!(NumeralMode::Arabic.map(hindi, true), d, "Arabic mode converts back both ways");
assert_eq!(NumeralMode::Arabic.map(hindi, false), hindi);
assert_eq!(NumeralMode::Arabic.map(hindi, true), hindi, "Arabic mode keeps typed Hindi digits");
assert_eq!(NumeralMode::Arabic.map(d, false), d);
assert_eq!(NumeralMode::Context.map(d, true), hindi);
assert_eq!(NumeralMode::Context.map(d, false), d);
assert_eq!(NumeralMode::Context.map(hindi, true), hindi);
assert_eq!(NumeralMode::Context.map(hindi, false), d);
assert_eq!(NumeralMode::Context.map(hindi, false), hindi);
}
// Letters, punctuation and Persian digits are never touched.
for c in ['A', 'ب', '.', ' ', '(', '۰', '۹'] {
@@ -120,21 +120,11 @@ fn hindi_mode_reshapes_digits_but_keeps_offsets() {
}
#[test]
fn arabic_mode_converts_hindi_digits_back() {
fn arabic_mode_keeps_typed_hindi_digits() {
let text = "عام ٢٠٢٤";
let hindi = lay_opts(text, true, NumeralMode::Hindi);
let arabic = lay_opts(text, true, NumeralMode::Arabic);
let h = line_gids(&hindi, text);
let a = line_gids(&arabic, text);
assert_eq!(h.len(), a.len());
let mut changed = 0;
for (n, (gh, ga)) in h.iter().zip(a.iter()).enumerate() {
if text.chars().nth(n).unwrap() == '٢' {
assert_ne!(gh, ga);
changed += 1;
}
}
assert!(changed > 0, "typed Hindi digits display Western in Arabic mode");
let hindi = line_gids(&lay_opts(text, true, NumeralMode::Hindi), text);
let arabic = line_gids(&lay_opts(text, true, NumeralMode::Arabic), text);
assert_eq!(hindi, arabic, "typed Hindi digits display as typed in Arabic mode");
}
#[test]
@@ -176,3 +166,24 @@ fn remap_range_maps_display_bytes_to_original() {
assert_eq!(remap_range(&map_starts, &orig_of, orig.len(), 99, 200), (4, 4));
assert_eq!(remap_range(&map_starts, &orig_of, orig.len(), 3, 1), (1, 2));
}
#[test]
fn arabic_mode_keeps_arabic_indic_digits_as_typed() {
// Issue #3259: the end-of-ayah mark (U+06DD) ligates with the real Arabic-Indic digits that
// follow it, so those codepoints must reach the shaper unchanged in the default mode.
for n in 0..10u32 {
let hindi = char::from_u32(0x660 + n).unwrap();
let ascii = char::from_u32('0' as u32 + n).unwrap();
for m in [NumeralMode::default(), NumeralMode::Arabic, NumeralMode::System] {
assert_eq!(m.map(hindi, false), hindi, "{m:?} keeps {hindi:?} (ltr)");
assert_eq!(m.map(hindi, true), hindi, "{m:?} keeps {hindi:?} (rtl)");
}
let typed = format!("\u{6dd}{hindi}");
let western = format!("\u{6dd}{ascii}");
let t = line_gids(&lay_opts(&typed, true, NumeralMode::default()), &typed);
let w = line_gids(&lay_opts(&western, true, NumeralMode::default()), &western);
let h = line_gids(&lay_opts(&typed, true, NumeralMode::Hindi), &typed);
assert_ne!(t, w, "U+{:04X} is not shaped as ASCII {ascii:?} in the default mode", 0x660 + n);
assert_eq!(t, h, "U+{:04X} shapes the same as in Hindi mode", 0x660 + n);
}
}
+23
View File
@@ -5016,6 +5016,29 @@ mod tests {
assert!(h.state().dialog.is_none(), "a click in the box, away from the X, closes the dialog");
}
/// #4177: Replace All in the Find and Replace dialog, with Regular expressions on, expands
/// capture groups (`$2 $1`, `\2 \1`, `${name}`) instead of inserting them literally.
#[test]
fn replace_all_with_regex_groups_from_the_dialog() {
use egui_kittest::kittest::Queryable;
for (with, want) in [("$2 $1", "John Smith and Jane Doe"), (r"\2 \1", "John Smith and Jane Doe"), ("${last}!", "Smith! and Doe!")] {
let mut h = app_harness(app_with("Smith, John and Doe, Jane"));
h.state_mut().run("ui.dialog", json!({"name": "replace"})).unwrap();
h.run_steps(2);
if let Some(Dialog::Find { query, replace, regex, .. }) = h.state_mut().dialog.as_mut() {
*query = r"(?<last>\w+), (\w+)".into();
*replace = with.into();
*regex = true;
} else {
panic!("Replace opens the Find and Replace dialog");
}
h.run_steps(2);
h.get_by_label("Replace All").click();
h.run_steps(2);
assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body), want, "{with}");
}
}
fn app_harness(app: WordApp) -> egui_kittest::Harness<'static, WordApp> {
egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|ui, app: &mut WordApp| {
+42
View File
@@ -356,6 +356,7 @@ impl AutoCorrectForm {
"borderLines": o.border_lines,
"tabIndent": o.tab_indent,
"headingStyles": o.heading_styles,
"doubleSpacePeriod": o.double_space_period,
"autoAddFirstLetter": o.auto_add_first_letter,
"autoAddInitialCaps": o.auto_add_initial_caps,
})
@@ -401,6 +402,7 @@ fn autocorrect_ui(app: &mut WordApp, ui: &mut Ui, f: &mut AutoCorrectForm) -> bo
ui.checkbox(&mut o.bold_italic, tl!("*Bold* and _italic_ as real formatting"));
ui.checkbox(&mut o.dashes, tl!("Hyphens (--) as dashes (—)"));
ui.checkbox(&mut o.links, tl!("Internet addresses as links"));
ui.checkbox(&mut o.double_space_period, tl!("Add a period with double-space"));
heading(ui, "Apply as you type");
ui.checkbox(&mut o.bullets, tl!("Automatic bulleted lists"));
ui.checkbox(&mut o.numbering, tl!("Automatic numbered lists"));
@@ -900,4 +902,44 @@ mod tests {
}
assert!(texts(&h).iter().any(|t| t.contains("(none)")), "no matches: the hint shows");
}
/// #2848 through the real dialog: tick "Add a period with double-space" on the AutoFormat As
/// You Type tab, press OK, and a double Space typed in the page makes a period.
#[test]
fn the_double_space_option_is_set_in_the_dialog_and_works_when_typing() {
use egui_kittest::kittest::Queryable;
let mut app = app("");
app.run("tools.autocorrect", json!({})).unwrap();
let mut h = egui_kittest::Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
for _ in 0..4 {
h.step();
}
h.get_by_label("AutoFormat As You Type").click();
for _ in 0..3 {
h.step();
}
h.get_by_label("Add a period with double-space").click();
for _ in 0..3 {
h.step();
}
h.get_by_label("OK").click();
for _ in 0..4 {
h.step();
}
assert!(h.state().dialog.is_none(), "OK closes the dialog");
assert!(h.state().session.prefs.autocorrect.double_space_period, "the option is on");
h.state_mut().canvas.focused = true;
for t in ["Hello", " ", " ", "world"] {
h.event(egui::Event::Text(t.to_string()));
h.step();
}
assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body), "Hello. world");
}
}
+66 -1
View File
@@ -232,7 +232,16 @@ pub fn show_in_context_menu(app: &mut WordApp, ui: &mut egui::Ui) {
fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens) {
let st: Value = app.session.run("format.state", &json!({})).unwrap_or_default();
let flag = |k: &str| st.get(k).and_then(Value::as_bool).unwrap_or(false);
let align = app.session.doc.para_at(&app.session.sel.focus).map(|p| app.session.doc.styles.resolve_para(&p.props).align).unwrap_or_default();
// As on the ribbon, the buttons show alignment as seen on the page (#3579).
let align = app
.session
.doc
.para_at(&app.session.sel.focus)
.map(|p| {
let r = app.session.doc.styles.resolve_para(&p.props);
r.align.visual(r.bidi)
})
.unwrap_or_default();
let row = |ui: &mut egui::Ui, app: &mut WordApp, add: &dyn Fn(&mut egui::Ui, &mut WordApp)| {
ui.allocate_ui_with_layout(Vec2::new(ui.available_width(), ROW_H), Layout::left_to_right(Align::Center), |ui| {
ui.set_min_height(ROW_H);
@@ -930,4 +939,60 @@ mod tests {
assert!(h.state().canvas.want_focus, "the swatch pick returns focus to the document");
assert!(format_state(&mut h).get("highlight").is_some_and(|v| !v.is_null()), "the first swatch applied a highlight");
}
/// #3579: in a right-to-left paragraph the bar lit Align Left for a paragraph that sits on
/// the right. Like the ribbon, it marks the alignment as seen on the page, the one its buttons
/// set. Only the bar is drawn, so the ribbon's own alignment buttons can't answer for it.
#[test]
fn alignment_buttons_mark_the_alignment_seen_on_the_page_in_a_right_to_left_paragraph() {
use egui_kittest::kittest::Queryable;
// A left-to-right paragraph first: a bar that always swapped the two would pass the RTL cases alone.
for (rtl, cmd, lit, unlit) in [
(false, None, "Align Left", "Align Right"),
(true, None, "Align Right", "Align Left"),
(true, Some("para.alignLeft"), "Align Left", "Align Right"),
] {
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("سلام دنیا")), Default::default());
a.run("select.all", json!({})).unwrap();
if rtl {
a.run("para.rtl", json!({})).unwrap();
}
if let Some(cmd) = cmd {
a.run(cmd, json!({})).unwrap();
}
// The bar writes in the interface fonts, which take effect the frame after they are set.
let mut fonts = false;
let mut h = egui_kittest::Harness::builder().with_size(Vec2::new(500.0, 200.0)).build_ui_state(
move |ui, app: &mut WordApp| {
if !fonts {
crate::theme::install_fonts(ui.ctx());
fonts = true;
return;
}
egui::Area::new(egui::Id::unique("mini_align_probe")).fixed_pos(pos2(10.0, 10.0)).show(ui.ctx(), |ui| {
let t = Tokens::get(ui.ctx());
content(app, ui, &t);
});
},
a,
);
steps(&mut h);
let checked = Tokens::get(&h.ctx).checked;
let marked: Vec<Rect> = h
.output()
.shapes
.iter()
.filter_map(|s| match &s.shape {
egui::Shape::Rect(r) if r.fill == checked => Some(r.rect),
_ => None,
})
.collect();
let is_marked = |label: &str| {
let r = h.get_by_label(label).rect();
marked.iter().any(|m| m.contains(r.center()))
};
assert!(is_marked(lit), "rtl={rtl} {cmd:?}: {lit} is marked");
assert!(!is_marked(unlit), "rtl={rtl} {cmd:?}: {unlit} is not marked");
}
}
}
+12 -1
View File
@@ -295,7 +295,11 @@ fn display(app: &mut WordApp, ui: &mut Ui) {
}
fn proofing(app: &mut WordApp, ui: &mut Ui) {
// #476 (AutoCorrect Options…) goes here, at the top.
section(ui, "AutoCorrect");
if ui.button(tl!("AutoCorrect Options")).clicked() {
let _ = app.run("tools.autocorrect", json!({}));
}
ui.add_space(8.0);
section(ui, "Spelling");
let pr = app.session.prefs.clone();
flag(app, ui, "Ignore words in capitals", "ignoreUppercase", pr.ignore_uppercase);
@@ -1107,6 +1111,13 @@ mod tests {
h.get_by_label("Ignore words in capitals").click();
h.run_steps(2);
assert!(!h.state().session.prefs.ignore_uppercase);
// The AutoCorrect Options dialog opens from this pane.
assert!(h.state().dialog.is_none());
h.get_by_label("AutoCorrect Options").click();
h.run_steps(3);
assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("autoCorrect"));
h.state_mut().dialog = None;
h.run_steps(2);
// #1063: the dictionaries folder and the dictionary each language found.
assert!(h.query_by_label("Dictionaries folder:").is_some());
assert!(h.query_by_label_contains("Polish: ").is_some(), "the Polish dictionary's line");
+9 -1
View File
@@ -1,6 +1,6 @@
# File-format parity with Microsoft Word
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** 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 `<blockquote>` 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 teLine truncated
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** 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 `<blockquote>` 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 Line truncated
Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the
share of a typical real-world file's content and formatting that survives (read: shows correctly;
@@ -249,6 +249,12 @@ Original binary fixtures and actual WordApp/Session file I/O cover Save, toolbar
Imported text fields use the first nonempty cached result run's formatting on their paragraph span, including character styles. Empty results retain begin-marker formatting. Synthetic fixtures cover simple and complex fields, default inheritance and repeated saves. Mixed-format results remain represented by one span; field refresh policy is unchanged.
## Markdown inline code
Inline code exports each LF, CR or CRLF line ending as one space, following [CommonMark code-span normalization](https://spec.commonmark.org/0.31.2/#code-spans). This keeps a following heading, list marker or HTML tag inside the code span, including code in lists and table cells (#3738). Leading and trailing spaces and backtick fences are preserved; fenced code blocks keep their line breaks. Synthetic export/reimport tests cover these cases.
Repeated DOCX style IDs use the last complete definition. The original regression also covers repeated save/reopen, including removal of earlier-only properties.
## Revision history
> 2026-10-25 · 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`).
@@ -257,11 +263,13 @@ Imported text fields use the first nonempty cached result run's formatting on th
| Date | Change | Summary |
|---|---|---|
| 2026-10-25 | trivial | Repeated DOCX style IDs: repeated save/reopen regression for whole-definition replacement. |
| 2026-10-25 | 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`) |
| 2026-10-23 | trivial | cu20we: preserve and render outline caps across DOCX/VML, screen and PDF; synthetic regressions; readiness estimates unchanged |
| 2026-10-23 | 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 (#3411 follow-up) |
| 2026-10-21 | 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, and an id that only appears as text is not a reference (#3411) |
| 2026-10-23 | trivial | DOCX: TOC styles Word stores under ids in another language (`Sumrio1` named `toc 1`, `CabealhodoSumrio` named `TOC Heading`) read as `TOC1`…`TOC9` and `TOCHeading` unless the file uses those ids, so Update Table builds the entries in the document's own TOC styles (#3891); a TOC whose heading ends a section (entries in columns after a continuous break) is written and read as the engine's TOC, with the page-number tab at the column's edge (#3889) |
| 2026-10-22 | trivial | Markdown code spans normalize line endings before export, keeping block markers literal (#3738); format readiness unchanged |
| 2026-10-21 | trivial | HTML export writes ruby (furigana, phonetic guides) as `<ruby>base<rt>reading</rt></ruby>`, in the base text's run formatting, and HTML import reads `<ruby>`/`<rb>`/`<rt>` back into one ruby object: `<rp>` fallback parentheses and the tags are not text, an unclosed element ends at the next block, and malformed ruby keeps its text. HTML states no alignment, size or raise, so the Phonetic Guide's defaults apply (#1681) |
| 2026-10-21 | trivial | RTF import (#1653): the East Asian multibyte code pages are decoded — 932 Shift-JIS (Japanese), 936 GBK (Simplified Chinese), 949 UHC/windows-949 (Korean) and 950 Big5 (Traditional Chinese), named by `\ansicpg`, a font's `\cpg` or `\fcharset128`/`129`/`134`/`136` (and the OEM charset 255 as 437), escaped (`\'hh`) or raw. Bytes are decoded as a run, so a lead byte and its trail byte decode together; a code page switch, a group end and the end of the document decode what is pending; invalid sequences read as U+FFFD and never lose the rest of the text. The decoder does no BOM sniffing, so a run that starts `EF BB BF` in GBK, Big5 or UHC stays text, and ASCII runs and picture hex no longer decode byte by byte. Single-byte code pages, `\uN?` escapes and their `\ucN` skips are unchanged |
| 2026-10-21 | trivial | Tables: table style band sizes (`w:tblStyleRowBandSize`, `w:tblStyleColBandSize`) are written for our banded styles and read with zero kept; a style without them has no bands, as in Word, so banded rows shade the same rows in both (#3415) |
+2 -1
View File
@@ -1,7 +1,7 @@
# Where WordCraft falls short of Word
> **Highlight update:** 2026-10-25 · Highlight shortcut toggles the last chosen colour off when the whole selection has it; explicit colour commands remain setters (#4085). Readiness estimates unchanged.
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (egui 0.37 migration: the interface catalogs stay shared and pinned by `craft-i18n.rev` at `9205108cfdced5177706264038848b68b2422d2b` (27 languages, English fallback); input-method handling has new regressions but managed CI passed, and native IME acceptance plus native-speaker catalog review remain open; previously trivial: small follow-ups from the field, table and AutoFormat batch, #4232; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: a command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it; previously minor: #8 narrowed: Spanish proofing (Spain, Mexico, Latin America, Uruguay) from the installed Hunspell dictionaries, #3360, #3553; previously trivial: gap 10: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; obstacle routing still missing; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: the ranked gap list held two rows each for #19, #20 and #21; each gap appears once now; a document with many relationships opens in the time its part takes, #3411; 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: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; 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: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24Line truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (egui 0.37 migration: the interface catalogs stay shared and pinned by `craft-i18n.rev` at `9205108cfdced5177706264038848b68b2422d2b` (27 languages, English fallback); input-method handling has new regressions but managed CI passed, and native IME acceptance plus native-speaker catalog review remain open; Markdown code spans keep line-broken text literal, #3738; previously trivial: small follow-ups from the field, table and AutoFormat batch, #4232; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: a command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it; previously minor: #8 narrowed: Spanish proofing (Spain, Mexico, Latin America, Uruguay) from the installed Hunspell dictionaries, #3360, #3553; previously trivial: gap 10: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; obstacle routing still missing; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: the ranked gap list held two rows each for #19, #20 and #21; each gap appears once now; a document with many relationships opens in the time its part takes, #3411; 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: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; 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: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in thLine truncated
Recovery discovery no longer interrupts an explicitly created or opened document. Older copies remain in File › Recover, whose buttons use the interface palette. See [local recovery](recovery.md).
@@ -161,6 +161,7 @@ Small follow-ups from the 2026-10-25 field, table and AutoFormat batch (#4232):
| 2026-10-22 | minor | Compare's new and revised untitled results clear the previous document's version snapshots; a rejected protected comparison or mailing result preserves the snapshots and file identity. No gap row; estimates unchanged. |
| 2026-10-21 | trivial | #20 narrowed: a word with no break opportunity in it no longer makes the layout quadratic (#3412) |
| 2026-10-20 | minor | The Document Inspector's removals are refused while the document tracks changes (they aren't tracked), and a version snapshot belongs to the document it was taken from. No gap row (enforcement fix); estimates unchanged |
| 2026-10-22 | trivial | Markdown code spans normalize line endings before export, keeping block markers literal (#3738); format readiness unchanged |
| 2026-10-18 | trivial | Explicit New/Open is not interrupted by startup recovery discovery; older copies stay available and Recover follows the interface theme. |
| 2026-10-21 | trivial | Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459) |
| 2026-10-21 | trivial | #3172: the ribbon's font name and size boxes, and the dropdown arrows beside them, say what they are to a screen reader |
+8
View File
@@ -156,6 +156,13 @@ ideographic space (`autocorrect_finds_the_word_after_a_wide_space`):
is “ (U+201C) even though it shows on the right of the word, after a letter ” (U+201D); in an
acronym (`צה"ל`) it becomes ” as in Word for Mac 16.113.4, not gershayim (״); `'` after a letter
(`ג'ירפה`) is ’ as after any letter, with no geresh conversion (not yet checked in Word; `smart_quotes_in_hebrew_use_logical_order`, #1670).
- Add a period with double-space (File › Options › Proofing › AutoCorrect Options › AutoFormat As
You Type, `tools.autocorrect {"doubleSpacePeriod"}`; **off by default**, as macOS's system switch
is a per-user choice): a second Space after a letter, a digit or a closing bracket or quote turns
the first into a period (`Hello world`→`Hello. world`). Other punctuation before the spaces,
a lone space, the start of a paragraph, a tab, a selection typed over and a third space are left
as typed; Undo puts the two spaces back (`double_space_adds_a_period_when_asked`, #2848). Not
observed in Word; Word for Mac follows the macOS setting.
- Correct TWo INitial CApitals: a finished word of three or more letters with two capitals and
the rest lowercase loses its second capital (`WOrd`→`Word`, `TEh`→`The`); not the exceptions
(`IDs`, `CDs`, `PCs`, `TVs`, … plus the user's, `tools.autocorrect {"addInitialCapsException"}`)
@@ -497,6 +504,7 @@ again.
| 2026-10-19 | trivial | Delete on whole selected cells (rows, columns, the table, a cell) leaves one empty paragraph in each, with the cell's last paragraph's formatting; nested tables go too (#2802) |
| 2026-10-19 | trivial | Ctrl+Tab (⌥Tab on macOS) types a tab character in a table cell (#3137) |
| 2026-10-16 | trivial | AltGr on Linux (Wayland) types ś, ł, ż… instead of opening KeyTips: the modifiers event without Alt that follows the Right Alt key no longer reads as Alt let go, and a bare AltGr tap doesn't open KeyTips (#2388) |
| 2026-10-20 | trivial | AutoFormat As You Type: optional "Add a period with double-space" (off by default), undoable (#2848) |
| 2026-10-18 | trivial | Typing over a selection takes the first selected character's formatting, also when the selection was made right to left (#2879) |
| 2026-10-17 | trivial | A table inserted in a right-to-left paragraph is a right-to-left table, so Tab moves to the cell on the left (#2634) |
| 2026-10-15 | trivial | Backspace after a section break and Delete before it remove the break, also one on the last paragraph's mark; the text takes the following section's setup and keeps the headers it showed (#2127) |
+8 -1
View File
@@ -1,6 +1,6 @@
# UI parity with Microsoft Word
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** trivial (Review › Accept and Reject menus: Accept/Reject and Move to Next, Accept/Reject All Changes and Stop Tracking; previously trivial: Reading Mode: the parity catalog counts Read Mode's View menu, Layout missing; previously trivial: comment times in the user's time zone, in the pane and the balloons, #2322, #2337; previously trivial: Read Mode's Page color: None, Sepia, Inverse; previously trivial: Insert › Table's grid previews the hovered table on the page, #3931; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously trivial: the Font dialog previews "NaN" or "inf" in the Size box at 12 pt, #3663; previously trivial: File › Options disables the User name field while Restrict Editing is active; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Line truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (Find and Replace: Replace with expands regex capture groups, #4177; previously trivial: the mini toolbar marks a right-to-left paragraph's alignment as seen on the page, like the ribbon, #3579; previously trivial: Review › Accept and Reject menus: Accept/Reject and Move to Next, Accept/Reject All Changes and Stop Tracking; previously trivial: Reading Mode: the parity catalog counts Read Mode's View menu, Layout missing; previously trivial: comment times in the user's time zone, in the pane and the balloons, #2322, #2337; previously trivial: Read Mode's Page color: None, Sepia, Inverse; previously trivial: Insert › Table's grid previews the hovered table on the page, #3931; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously trivial: the Font dialog previews "NaN" or "inf" in the Size box at 12 pt, #3663; previously trivial: File › Options disables the User name field while Restrict Editing is active; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window wiLine truncated
Recovery discovery no longer interrupts an explicitly created or opened document. Older copies remain in File › Recover, whose buttons use the interface palette. See [local recovery](recovery.md).
@@ -66,6 +66,10 @@ 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.
## 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.
## Find navigation (#2096)
Find selects the Results tab, including when it prefills selected text. Editing the search query also reveals matching or empty results. Manual Headings and Pages navigation remains available until the next search action.
@@ -80,9 +84,11 @@ Footnote and endnote editing keeps body and note text at normal opacity, on ligh
| 2026-10-24 | trivial | Read Mode: a Page color menu (None, Sepia, Inverse) beside Line spacing; the columns are drawn on white, sepia or a dark page, in place of the document's page color (`view.readPageColor`) |
| 2026-10-24 | trivial | Reading Mode: Read Mode's View menu in the parity catalog; Column Width and Page Color work, Layout is missing |
| 2026-10-24 | trivial | Review › Accept and Reject menus: Accept and Move to Next and Reject and Move to Next resolve the change at the caret and select the next one (`review.accept` / `review.reject` `{"next": true}`); Accept All Changes and Stop Tracking and Reject All Changes and Stop Tracking also turn Track Changes off, in one undo step (`review.acceptAll` / `review.rejectAll` `{"stopTracking": true}`) |
| 2026-10-24 | trivial | Find and Replace, Regular expressions: Replace with expands `$1`, `${n}`, `${name}`, `$0`, `\1`…`\9`, `$$`, `\n` (splits the paragraph) and `\t`; missing or out-of-range groups expand to nothing (#4177) |
| 2026-10-23 | trivial | Insert › Table: hovering the grid shows the table on the page, at the caret, as Word's Live Preview does; it follows the pointer and is laid out on a copy, so the document and Undo change only when a square is clicked (#3931) |
| 2026-10-23 | trivial | Comment times: the Comments pane and the balloons show each comment's and reply's time in the user's time zone, under its author, as Word does. The file keeps UTC (`w16cex:dateUtc` when Word wrote one), a time without a zone shows as written, and the web app asks the browser for the zone (#2322, #2337) |
| 2026-10-22 | trivial | Mail merge: First Record, Last Record, a record number box, and record choices for Edit Individual Documents and Print Documents (#3028) |
| 2026-10-23 | trivial | Mini toolbar: in a right-to-left paragraph the alignment buttons mark the alignment as seen on the page, as the ribbon does, so a start-aligned paragraph lights Align Right instead of Align Left; the buttons already set the alignment as seen on the page (#3579) |
| 2026-10-22 | trivial | While editing a header, a single click in the footer (or the reverse, or another page's own header) edits that one with the caret where clicked; the click was placed in the header being edited, and the view scrolled back to it, so a double-click on the footer rarely landed |
| 2026-10-18 | trivial | Explicit New/Open is not interrupted by startup recovery discovery; older copies stay available and Recover follows the interface theme. |
| 2026-10-19 | trivial | Insert Table of Figures opens a Table of Figures dialog: caption label (Figure, Table, Equation, the user's own and those the document's captions use), Include label and number, page numbers, right margin and tab leader, with a preview; the ribbon button, KeyTip and command search reach it (#3013) |
@@ -152,6 +158,7 @@ Footnote and endnote editing keeps body and note text at normal opacity, on ligh
| 2026-10-16 | trivial | Rotation handle: pictures and shapes without text stories turn with the preview while the pointer is held; text boxes retain frame-only previews; release commits one `arrange.rotation` command, Escape cancels (#2277) |
| 2026-10-16 | trivial | Save Changes prompt: a plain-text or Markdown file the document was opened from counts as saved once Save writes it, so closing after Ctrl+S asks nothing and Save in the prompt closes the window. Save As to such a format elsewhere is still a copy that leaves the document unsaved (#2376) |
| 2026-10-16 | trivial | On-canvas objects: dragging an In Line with Text picture, shape or text box shows a dashed insertion cursor in the text and drops it there, still inline, instead of floating it with Square wrapping (`arrange.bounds` / `arrange.copyTo` `to`, #2233, #2234) |
| 2026-10-17 | trivial | Design › Document Formatting gains Scale Font Sizes (`design.scaleFontSizes`, +N pt or +N%, all styles at once; no Word equivalent) (#1475) |
| 2026-10-13 | trivial | New/Modify Table Style: No Border on a part that inherits its lines hides them; emptying the font box makes the part's font inherited again (#1288) |
| 2026-10-17 | minor | Insert › Drop Cap is a menu (None, Dropped, In margin, Drop Cap Options…) with a Drop Cap dialog (position, font, lines 1–10, distance 0–22 in), #2601; Modal dialogs 56 → 57 |
| 2026-10-14 | trivial | Tab stops edited on the ruler: corner kind selector, click to set, drag to move or off to clear, per-kind markers mirrored for RTL; each gesture one `para.tabs` call and one undo step; Rulers 85% → 90% |